TL;DR

Lập trình viên Distill (@TGUPJ trên X) vừa công khai gọn một sự thật mà nhiều team làm feed UI phải mất một năm mới hiểu: "the centerpiece of distill is a custom virtualizer. not the 'fewer dom nodes' kind. the dynamic heights, prepend anchoring, webkit-doesn't-care-about-your-feelings kind." Dịch ngắn gọn: virtualizer của họ không phải loại "bớt DOM node" cho vui, mà là loại xử lý chiều cao động, neo vị trí khi chèn item đầu list, và WebKit không quan tâm bạn muốn gì. Đây là ba bài toán thực sự khó — và là lý do Twitter, Reddit, Slack đều tự viết tầng virtualization riêng.

Cái gì đáng chú ý ở đây

Phần lớn bài viết về virtualization trên web dừng ở một bản demo: list 10.000 số nguyên, mỗi row cao 40px cố định, dùng react-window, xong. Bản demo đó không nói dối — nhưng nó bỏ qua 80% công việc phát sinh khi bạn build một feed thật: tweet có ảnh, quote nhúng, reply mở rộng, chiều cao không biết trước và thay đổi sau khi mount. TGUPJ đặt tên thẳng cho ba lớp vấn đề khó nhất, và toàn bộ cộng đồng frontend ngầm đồng ý.

Vấn đề 1: Dynamic heights

Virtualizer giáo khoa giả định row size cố định hoặc rẻ để ước lượng. Thực tế, một tweet có thể từ 80px (text ngắn) tới 2000px (quote có ảnh dài + embed video). Bạn không biết chiều cao cho tới khi item mount. Tệ hơn, chiều cao thay đổi khi user bấm "Show more", khi ảnh load xong, khi media player expand.

Giải pháp thực tế đòi hỏi: một ResizeObserver gắn lên từng row đang render, một offset table cho phép patch chiều cao của một item mà không invalidate toàn bộ item phía dưới, và một height estimator đủ tốt để scrollbar không nói dối quá nhiều. Mọi thư viện lớn đều có issue mở quanh cái này — react-virtualized #424, #610, dotnet/aspnetcore #65158 — cho thấy đây là lớp bài toán chưa có lời giải "bỏ vào là xong".

Vấn đề 2: Prepend anchoring

Khi item mới xuất hiện phía trên viewport hiện tại (pull-to-refresh, realtime insert, "N more replies"), tổng chiều cao list tăng. Mặc định browser sẽ hoặc (a) giữ nguyên scrollTop và user thấy nội dung đang đọc bị đẩy xuống mất tiêu, hoặc (b) "tự nhiên" cố anchor bằng scroll anchoring — và đoán sai.

Để giữ đúng viewport trước khi insert, bạn cần: set overflow-anchor: none trên container để tắt anchor tự động của browser, rồi trong đúng frame DOM được mutate, tự cộng scrollTop bằng chính xác tổng chiều cao các item vừa prepend. Nghe đơn giản — thực tế là một bãi mìn timing. TanStack Virtual vẫn gắn nhãn đây là solution một phần, và virtua đang có bug mở về reverse scroll.

Vấn đề 3: WebKit không quan tâm

Safari có scroll anchoring, nhưng timing layout/scroll của WebKit khác Chromium. Trên Mobile Safari, layout update bị hoãn trong khi momentum scroll. Rubber-banding ăn frame. Và điều khó chịu nhất: ghi vào scrollTop gần đầu scroll container có thể bị âm thầm bỏ qua cho tới khi momentum scroll kết thúc. Issue inokawa/virtua #473 mô tả chính xác: "chỉ update layout khi scroll được release vài trăm mili giây."

Kết quả là bạn viết một code path cho Chromium, rồi một code path thứ hai — chậm hơn, phòng thủ hơn — cho WebKit. Tự thân WebKit có lịch sử dài quanh scroll anchoring: bug 171099 mở 2017, commit bật default, rồi có giai đoạn disable trong một số trường hợp.

Vì sao ba bài toán cộng lại mới đáng sợ

Tách riêng, mỗi vấn đề đều giải được. Cộng lại, chúng đánh nhau. Đo một row xong thay đổi tổng chiều cao. Tổng chiều cao thay đổi kích hoạt scroll anchor của browser. Tắt anchor browser đồng nghĩa bạn giờ tự phải anchor. Tự anchor đòi hỏi ghi scrollTop trong đúng frame layout-consistent. WebKit không tôn trọng cái ghi đó khi đang momentum scroll.

Nói cách khác, virtualizer "thật" không phải bài toán cấu trúc dữ liệu. Nó là một tầng mediate các quirk của browser, và bạn viết nó mỗi ngày một ít mỗi khi một edge case mới xuất hiện.

Tại sao không dùng thư viện?

  • TanStack Virtual — API generic tốt, prepend anchoring thừa nhận là partial.
  • react-window — fixed-size là thế mạnh; variable-size phải gọi resetAfterIndex thủ công sau mọi mutation.
  • react-virtualized — legacy, dynamic height có các issue jump scroll lâu năm.
  • virtua (inokawa) — mới, chắc tay, vẫn đang có bug WebKit reverse-scroll mở.
  • catamphetamine/virtual-scroller — lấy cảm hứng từ chính VirtualScroller của Twitter; trade-off về độ chính xác estimator.

Với một sản phẩm mà toàn bộ UX là feed scroll — reader, Twitter client, chat — thư viện sẽ hoặc chặn một feature quan trọng, hoặc bug của nó trở thành bug của bạn. Tự viết không phải NIH (Not Invented Here); đó là chấp nhận rằng virtualizer chính là product surface, chứ không phải dependency.

Bằng chứng TGUPJ nói đúng

  • Twitter tự build VirtualScroller riêng cho Twitter Lite — Paul Armstrong mô tả lý do.
  • WebKit có chu kỳ bật–tắt–bật lại scroll anchoring nhiều năm.
  • Mỗi virtualizer lớn đều có issue top-traffic dạng "scroll jumps on prepend" hoặc "broken on Safari".
  • Firefox cũng có bug tương tự với Twitter — bug 1561823.

Takeaway

Nếu bạn đang build một feed UI và roadmap giả định "virtualization là bài toán đã giải": không phải. Dự trù engineering time cho một tầng virtualizer riêng, hoặc chấp nhận trần UX bị giới hạn bởi thư viện bạn chọn. Một câu tweet của TGUPJ là phiên bản cô đặc của một sự thật mà đa số team mất một năm mới nhận ra — và là lý do Distill dám gọi virtualizer là centerpiece thay vì một hàm tiện ích trong utils/.

Nguồn: tweet gốc của @TGUPJ, TanStack Virtual discussion, virtua #473, WebKit bug 171099.