Render Code Tốt Hơn Qua Virtualization

Cách Codecov render file có 50.000+ dòng mà không crash — tái hiện toàn bộ giải pháp từ blog kỹ thuật của Sentry.

Đọc bài blog gốc →

Vấn Đề

Renderer code của Codecov bị crash với file lớn. File checker.ts của TypeScript có 52.283 dòng. Render tốn ~0.9ms mỗi dòng. Với 52K dòng, đó là 46.9 giây render — tab trình duyệt chết.

52.283 Dòngchecker.ts
×
0.9ms / dòngChi phí render
=
46.9 giâyTrang bị đóng băng

Tokenization nhanh (~90ms). Nút cổ chai là tạo 52K DOM element cùng lúc — mỗi dòng có số dòng, nội dung syntax-highlighted và dữ liệu coverage.

1

Virtual List — Chỉ Render Dòng Hiển Thị

Giải pháp cốt lõi: Chỉ render các dòng hiển thị trong viewport. Một spacer div có toàn bộ chiều cao ảo (để scrollbar hoạt động), nhưng DOM element thực sự chỉ tồn tại cho các dòng hiển thị + buffer overscan. Codecov dùng @tanstack/react-virtual — ở đây chúng ta tái hiện cùng logic trong JS thuần.

// Configuration — same params as @tanstack/react-virtual const LINE_HEIGHT = 20; const OVERSCAN = 45; const totalLines = tokens.length;

Cấu trúc render dùng một spacer div với toàn bộ chiều cao ảo, và mỗi dòng hiển thị được absolute position bằng translateY:

// Container has full virtual height — scrollbar works correctly const spacer = document.createElement('div'); spacer.style.height = (totalLines * LINE_HEIGHT) + 'px'; spacer.style.position = 'relative'; viewport.appendChild(spacer); const pool = new Map(); // tracks which lines are in the DOM function render() { // Calculate which lines are visible const scrollTop = viewport.scrollTop; const viewH = viewport.clientHeight; const vStart = Math.floor(scrollTop / LINE_HEIGHT); const vEnd = Math.ceil((scrollTop + viewH) / LINE_HEIGHT); const rStart = Math.max(0, vStart - overscan); const rEnd = Math.min(totalLines - 1, vEnd + overscan); // Remove lines that scrolled out of range for (const [idx, el] of pool) { if (idx < rStart || idx > rEnd) { el.remove(); pool.delete(idx); } } // Add lines that scrolled into range for (let i = rStart; i <= rEnd; i++) { if (!pool.has(i)) { const el = document.createElement('div'); el.style.cssText = `position:absolute;top:0;width:100%; height:${LINE_HEIGHT}px; transform:translateY(${i * LINE_HEIGHT}px)`; el.innerHTML = getHTMLLine(i); spacer.appendChild(el); pool.set(i, el); } } } viewport.addEventListener('scroll', render, { passive: true }); render(); // initial render

Thử nghiệm: So sánh render 10.000 dòng tất cả cùng lúc vs ảo hóa.

Tổng Dòng
DOM Node
Thời Gian Render
Dòng Hiển Thị
2

Overscan = 45 — Ngăn Flash Trống

Virtualization đơn giản cho thấy khoảng trống khi cuộn nhanh. Giải pháp: render thêm 45 dòng phía trên và dưới viewport. Người dùng không bao giờ thấy khoảng trống vì buffer đã sẵn sàng trước khi họ cuộn đến đó.

const overscan = 45; // 45 extra rows above + 45 below viewport const vStart = Math.floor(scrollTop / LINE_HEIGHT); const vEnd = Math.ceil((scrollTop + viewH) / LINE_HEIGHT); const rStart = Math.max(0, vStart - overscan); const rEnd = Math.min(totalLines - 1, vEnd + overscan);

Điều chỉnh overscan và cuộn viewer phía trên để cảm nhận sự khác biệt:

DOM node:
Hiển thị:
Overscan buffer:
3

Z-Index Layering — Textarea + Số Dòng + Code

Virtualization làm hỏng tính năng tìm kiếm native của trình duyệt (Ctrl+F) vì nội dung DOM không hiển thị không tồn tại. Giải pháp của Codecov: overlay một textarea trong suốt chứa toàn bộ nội dung file. Trình duyệt tìm kiếm trong text của textarea, và người dùng thấy code syntax-highlighted phía sau.

z-index: 3 Số Dòng

Render ảo, click để điều hướng hash

z-index: 2 Textarea

Toàn bộ nội dung, color: transparent, bật tìm kiếm Ctrl+F

z-index: 1 Hiển Thị Code

Render ảo với syntax highlighting

// Textarea: z-index 2, color transparent — enables Ctrl+F const textarea = document.createElement('textarea'); textarea.className = 'virt-textarea'; // CSS: z-index:2, color:transparent textarea.readOnly = true; textarea.value = generatePlainText(totalLines); // full file content // Line numbers: z-index 3 — above textarea, clickable const lineNumLayer = document.createElement('div'); lineNumLayer.className = 'virt-linenums'; // CSS: z-index:3 const lineNumPool = new Map(); // Code display: z-index 1 — behind textarea, syntax highlighted const codeOverlay = document.createElement('div'); codeOverlay.className = 'virt-code-overlay'; // CSS: z-index:1 const codeLinePool = new Map(); // In render(), add/remove from both pools: for (let i = rStart; i <= rEnd; i++) { if (!lineNumPool.has(i)) { const ln = document.createElement('div'); ln.className = 'virt-linenum'; ln.style.transform = `translateY(${i * LINE_HEIGHT}px)`; ln.textContent = i + 1; ln.addEventListener('click', () => selectLine(i)); lineNumLayer.appendChild(ln); lineNumPool.set(i, ln); } if (!codeLinePool.has(i)) { const cl = document.createElement('div'); cl.className = 'virt-code-line'; cl.style.transform = `translateY(${i * LINE_HEIGHT}px)`; cl.innerHTML = getHTMLLine(i); codeOverlay.appendChild(cl); codeLinePool.set(i, cl); } }

Thử nghiệm: Nhấn Ctrl+F và tìm text trong demo đầy đủ bên dưới — tính năng tìm kiếm native của trình duyệt hoạt động dù hầu hết dòng không có trong DOM, vì textarea chứa toàn bộ text.

4

Đồng Bộ Cuộn Ngang

Vì người dùng tương tác với textarea trong suốt (z-index 2), cuộn ngang xảy ra trên textarea. Phần hiển thị code (z-index 1) phải theo. Đồng bộ scrollLeft từ textarea đến code overlay:

// Sync horizontal scroll: textarea → code display overlay textarea.addEventListener('scroll', () => { codeOverlay.scrollLeft = textarea.scrollLeft; updateHScrollbar(); }, { passive: true }); // Width sync — all code lines share the same width function syncWidth() { const w = Math.max(textarea.scrollWidth, viewport.clientWidth - 56); codeOverlay.style.width = (viewport.clientWidth - 56) + 'px'; for (const [, el] of codeLinePool) { el.style.minWidth = w + 'px'; } }

Họ cũng cần đồng bộ chiều rộng — tất cả dòng code phải có cùng chiều rộng (đo từ scrollWidth của textarea) để tránh không nhất quán khi cuộn ngang.

5

Tối Ưu Pointer Events

Khi cuộn, trình duyệt kiểm tra những gì dưới con trỏ để xử lý hover — gây ra forced reflow mỗi frame. Giải pháp: đặt pointerEvents: 'none' khi cuộn, reset về 'auto' sau 50ms nhàn rỗi.

// CSS: .scrolling .virt-code-line { pointer-events: none; } // In the render() function, called on every scroll: viewport.classList.add('scrolling'); // disable pointer-events via CSS clearTimeout(state.scrollTimer); state.scrollTimer = setTimeout(() => { viewport.classList.remove('scrolling'); // re-enable after 50ms idle }, 50);

Cuộn demo bên dưới — quan sát log để thấy pointer-events bật/tắt:

6

Cuộn Đến Dòng qua URL Hash

Codecov lưu dòng đích trong URL hash (ví dụ #L500) và cuộn đến dòng đó khi tải trang. Một flag ngăn chạy nhiều hơn một lần:

// Select line — updates URL hash and highlights function selectLine(idx) { state.selectedLine = idx; history.replaceState(null, '', '#L' + (idx + 1)); // Toggle .selected / .highlighted on pool elements for (const [i, el] of lineNumPool) el.classList.toggle('selected', i === idx); for (const [i, el] of codeLinePool) el.classList.toggle('highlighted', i === idx); } // Scroll to line function scrollToLine(n) { viewport.scrollTop = Math.max(0, n - 1) * LINE_HEIGHT; selectLine(n - 1); } // Read URL hash on init function readHashOnInit() { const match = location.hash.match(/^#L(\d+)$/); if (match) { const line = parseInt(match[1]); if (line > 0 && line <= totalLines) scrollToLine(line); } } readHashOnInit();

Thử nghiệm: Click vào số dòng trong demo, hoặc dùng nút jump. URL hash cập nhật và có thể chia sẻ.

Demo Đầy Đủ — Tất Cả Giải Pháp Kết Hợp

50.000 dòng với đủ 6 kỹ thuật: virtual list, overscan 45, z-index layering với textarea cho Ctrl+F, đồng bộ cuộn ngang, tối ưu pointer-events và URL hash cuộn-đến-dòng.

Tổng Dòng
DOM Node
Thời Gian Khởi Tạo
Dòng Đã Chọn

Điểm Cốt Lõi