Render 100.000 dòng mà không làm treo trình duyệt: Hướng dẫn Virtualization trong React

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Kẻ sát nhân hiệu năng ẩn mình trong ứng dụng React

Các danh sách React thông thường thường bắt đầu bằng một lời gọi .map() đơn giản. Cách tiếp cận này hoạt động hoàn hảo khi bạn xử lý 50 hoặc 100 mục. Tuy nhiên, ngay khi ứng dụng của bạn cần hiển thị 10.000 dòng log hoặc một danh mục sản phẩm khổng lồ, trình duyệt bắt đầu có hiện tượng giật lag. Bạn sẽ nhận thấy độ trễ khoảng 200ms khi nhập vào thanh tìm kiếm, việc cuộn trang trở nên nặng nề và mức sử dụng bộ nhớ của tab có thể dễ dàng vượt quá 1GB.

React không phải là nút thắt cổ chai ở đây; mà chính là DOM. Mỗi node bạn chèn vào đều tiêu tốn bộ nhớ và buộc trình duyệt phải tính toán lại layout (bố cục). Nếu mỗi mục trong danh sách chứa năm phần tử con, một danh sách 10.000 dòng sẽ đẩy 50.000 node vào tài liệu. Ngay cả khi chúng nằm ngoài màn hình, trình duyệt vẫn phải giữ chúng trong bộ nhớ. Virtual Scrolling (Cuộn ảo), hay còn gọi là Windowing, giải quyết nút thắt này bằng cách thay đổi cách chúng ta tư duy về DOM.

Virtual Scrolling thực sự hoạt động như thế nào?

Trong các ứng dụng thực tế (production-grade), virtualization là một kỹ thuật tối ưu bắt buộc cho các giao diện dày đặc dữ liệu. Khái niệm này rất đơn giản. Thay vì render toàn bộ danh sách, chúng ta chỉ render các mục hiện đang hiển thị trong viewport (khung nhìn) của người dùng. Chúng ta cũng bao gồm một vùng đệm (buffer) nhỏ gồm các mục phía trên và phía dưới vùng hiển thị để giữ cho trải nghiệm cuộn luôn mượt mà.

Hãy tưởng tượng nó giống như một chiếc cửa sổ thực thụ trong một ngôi nhà. Bạn có thể đang đứng trước một bức tường dài 50 mét, nhưng bạn chỉ có thể nhìn thấy phần khu vườn lộ ra qua lớp kính rộng 1 mét. Khi bạn đi dọc hành lang, khung cảnh thay đổi, nhưng kích thước của cửa sổ vẫn giữ nguyên. Về mặt kỹ thuật, chúng ta thiết lập một container với chiều cao có thể cuộn cực lớn để giữ cho thanh cuộn chính xác, nhưng chúng ta chỉ mount khoảng 10 đến 20 phần tử <div> thực sự tại bất kỳ thời điểm nào.

Phép toán cốt lõi đằng sau kỹ thuật này

Để xây dựng một bộ virtualizer từ đầu, bạn cần theo dõi ba biến cụ thể:

  • Scroll Top: Vị trí cuộn dọc hiện tại tính bằng pixel.
  • Viewport Height: Chiều cao của vùng hiển thị (ví dụ: 500px).
  • Item Height: Chiều cao của một dòng đơn lẻ.

Với những con số này, bạn có thể xác định chính xác index (chỉ mục) nào cần hiển thị. Nếu người dùng đã cuộn xuống 1.000px và mỗi dòng cao 50px, mục hiển thị đầu tiên là index 20. Đó là những phép toán đơn giản, nhưng nó ngăn trình duyệt bị “ngộp” trong các node không cần thiết.

Thực hành: Triển khai Virtual Scrolling với react-window

Mặc dù bạn có thể tự viết các trình lắng nghe onScroll tùy chỉnh, cộng đồng đã xây dựng các công cụ tối ưu hóa cao cho việc này. Tôi đề xuất react-window vì nó rất nhẹ—chỉ khoảng 6kb sau khi nén gzipped. Nó xử lý các trường hợp biên phức tạp như điều hướng bằng bàn phím và logic cuộn-đến-chỉ-mục (scroll-to-index) một cách sẵn có. Hãy cùng xem một triển khai cơ bản cho 10.000 mục.

import React from 'react';
import { FixedSizeList as List } from 'react-window';

const Row = ({ index, style }) => (
  <div style={{ ...style, borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center' }}>
    Dòng {index} - Dữ liệu: {Math.random().toFixed(4)}
  </div>
);

const VirtualList = () => {
  const itemCount = 10000;
  
  return (
    <div style={{ height: '400px', width: '100%', border: '1px solid #ccc' }}>
      <List
        height={400}
        itemCount={itemCount}
        itemSize={50} 
        width="100%"
      >
        {Row}
      </List>
    </div>
  );
};

export default VirtualList;

Mở DevTools của trình duyệt và kiểm tra danh sách trong khi cuộn. Bạn sẽ thấy rằng khi các mục di chuyển ra khỏi khung nhìn, các DOM node của chúng sẽ được tái sử dụng hoặc xóa bỏ ngay lập tức. Tổng số lượng node vẫn ổn định bất kể danh sách của bạn có 100 hay 100.000 mục.

Xử lý chiều cao động: Thử thách thực sự

Chiều cao cố định thì dễ tính toán, nhưng dữ liệu thực tế hiếm khi đồng nhất. Điều gì sẽ xảy ra nếu một dòng chỉ có một câu và dòng tiếp theo có ba đoạn văn? Nếu bạn không biết chiều cao của mục thứ 500, bạn không thể tính toán chính xác tổng diện tích có thể cuộn hoặc vị trí của thanh cuộn.

Các ứng dụng hiện đại thường giải quyết vấn đề này bằng cách sử dụng Estimated Heights (Chiều cao ước tính). Các thư viện như @tanstack/react-virtual cho phép bạn cung cấp một chiều cao dự đoán sát nhất. Khi một dòng thực sự render, thư viện sẽ đo lường DOM node thật và cập nhật toàn bộ layout danh sách một cách linh hoạt. Điều này ngăn hiện tượng “nhảy” (jumping) khi người dùng cuộn qua nội dung có độ dài khác nhau.

Dưới đây là cách @tanstack/react-virtual xử lý các kịch bản động này:

import { useVirtualizer } from '@tanstack/react-virtual';

function DynamicList({ items }) {
  const parentRef = React.useRef();

  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100, // Ước tính của chúng ta
  });

  return (
    <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
      <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
        {rowVirtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            data-index={virtualRow.index}
            ref={rowVirtualizer.measureElement}
            style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${virtualRow.start}px)` }}
          >
            {items[virtualRow.index].content}
          </div>
        ))}
      </div>
    </div>
  );
}

Các Best Practice để có trải nghiệm mượt mà

Một danh sách virtualized vẫn có thể mang lại cảm giác “giật” nếu không được cấu hình đúng cách. Để đảm bảo hiệu năng 60fps, hãy tuân theo ba quy tắc sau từ các lần audit dự án trước đây của tôi.

1. Sử dụng Overscan làm vùng đệm

Overscan render thêm một vài mục nằm ngay bên ngoài viewport hiển thị. Nếu người dùng cuộn nhanh, vùng đệm này đảm bảo họ thấy nội dung ngay lập tức thay vì một khoảng trắng trống. Thiết lập overscan từ 5 đến 10 mục thường là đủ để che giấu độ trễ render trên hầu hết các thiết bị di động.

2. Memoize các Component dòng

Bộ virtualizer kích hoạt re-render liên tục trong quá trình cuộn. Nếu component dòng của bạn phức tạp, hãy sử dụng React.memo để ngăn React tính toán lại logic nội bộ của mọi dòng vẫn đang hiển thị. Thay đổi nhỏ này có thể giảm 30% mức sử dụng CPU khi cuộn nhanh.

3. Giữ cho logic trong dòng tinh gọn

Tránh xử lý dữ liệu nặng hoặc định dạng ngày tháng bên trong component Row. Nếu bạn cần định dạng 10.000 ngày tháng, hãy thực hiện một lần khi dữ liệu được tải về. Việc truyền các chuỗi đã được định dạng sẵn vào danh sách của bạn nhanh hơn đáng kể so với việc tính toán chúng trên mỗi khung hình (frame).

Kết luận

Virtual scrolling là một kỹ thuật tối ưu hóa nền tảng cho các ứng dụng web hiện đại. Bằng cách thoát khỏi tư duy “render mọi thứ”, bạn có thể xây dựng các giao diện luôn phản hồi nhanh nhạy ngay cả với các tập dữ liệu khổng lồ.

Cho dù bạn chọn react-window vì sự gọn nhẹ hay TanStack Virtual cho các layout động phức tạp, tác động đến trải nghiệm người dùng là thấy rõ ngay lập tức. Hãy kiểm tra lại dự án hiện tại của bạn cho bất kỳ danh sách nào dài hơn 200 mục; việc thay thế chúng bằng phiên bản virtualized thường là thắng lợi lớn nhất về hiệu năng mà bạn có thể đạt được.

Share: