JSON Toolkit

Vì sao cây JSON lớn bị giật: cuộn ảo và chiến lược thu gọn

Kết luận: Giật bắt đầu khi các nút DOM được tạo, không phải khi bạn cuộn. Bài viết trình bày kết xuất ưu tiên thu gọn, danh sách ảo, tô sáng khi tìm kiếm và ranh giới Worker, kèm các chỉ số.

Nâng caoĐọc khoảng 10 phútXuất bản 2026-10-31Cập nhật 2026-10-31
Cuộn ảo và chiến lược thu gọn cho trình xem JSON

Kết luận: hãy tách "chậm" thành phân tích, tạo nút, bố cục và vẽ, cùng phản hồi tương tác, rồi đo từng giai đoạn. Hãy hiện thực theo thứ tự này — thu gọn mặc định, thêm danh sách ảo, chuyển tính toán vào Worker, và giới hạn tô sáng ở các hàng hiển thị. Nhảy thẳng vào danh sách ảo mà không thu gọn chỉ thu nhỏ tập hiển thị; việc tính lại toàn cây vẫn khiến bạn phải trả giá.

Cơn giật thường đến từ hàng trăm nghìn nút DOM bạn đã tạo cho 5MB đó, không phải từ chính 5MB.

1. Tách "chậm" thành bốn giai đoạn

Khi người dùng nói "nó đóng băng khi mở", vấn đề có thể nằm ở bốn giai đoạn hoàn toàn khác nhau, mỗi giai đoạn một cách khắc phục khác nhau:

Giai đoạn Nguyên nhân điển hình Ngưỡng cảm nhận được Chi phí chi phối
Phân tích và dựng object Mở tệp, dán văn bản Mọi tác vụ vượt 50ms Quét chuỗi, dựng đồ thị đối tượng
Tạo nút DOM Kết xuất cả cây cùng lúc Hàng chục nghìn nút Cấp phát phần tử, nút văn bản, object kiểu
Bố cục và vẽ Hiển thị lần đầu, cuộn Mọi khung hình vượt 16,7ms Hình học, reflow, repaint
Phản hồi tương tác Mở rộng, thu gọn, tìm kiếm Trên 100ms sẽ cảm thấy chậm Làm phẳng lại và kết xuất lại

Luồng chính của trình duyệt làm một việc mỗi lần. Nếu bất kỳ giai đoạn nào chiếm nó quá 50ms, việc cuộn và gõ sẽ rớt khung hình; vài tác vụ như vậy liên tiếp và trang trông như đã chết. Vì vậy tối ưu hóa đầu tiên không phải requestAnimationFrame; mà là đo xem giai đoạn nào đang thật sự tiêu thời gian.

2. Số nút là kẻ thù thật sự

Trình xem cây nhân số nút lên một cách cố hữu. Một dòng JSON thường biến thành "thụt lề + khóa + dấu hai chấm + giá trị + dấu phẩy + chevron", nên ước tính thận trọng là 3 đến 6 nút DOM mỗi dòng:

Kích thước tệp Số dòng ước tính Nút DOM của chế độ xem cây (ước tính) Kết xuất một lần trông như thế nào
100 KB 2.500 10.000 Thường chịu được
1 MB 25.000 100.000 Rõ ràng giật, có thể vài giây màn hình trắng
5 MB 125.000 500.000 Rất có thể không phản hồi
20 MB 500.000 Trên 2 triệu Cả bộ nhớ lẫn bố cục sụp

Mỗi phần tử không chỉ vài chục byte object: nó mang quy tắc kiểu, một hộp bố cục và chỉ dẫn vẽ. Một nút đã được tạo vẫn tốn công bố cục và kiểu ngay cả khi sau đó bị ẩn bởi cha đã thu gọn, miễn là nó chưa thật sự bị gỡ bỏ. "Kết xuất mọi thứ rồi ẩn đi" không phải tối ưu hóa; nó chỉ hoãn chi phí sang lần reflow kế tiếp.

3. Thu gọn trước, ảo hóa sau

Cuộn ảo giải quyết "quá nhiều hàng hiển thị." Thu gọn giải quyết "đáng lẽ không cần thấy nhiều hàng đến vậy." Thứ tự quan trọng: với một tài liệu triệu dòng được mở rộng mặc định, ngay cả một danh sách ảo hoàn hảo cũng phải tính lại toàn bộ cây khi người dùng bấm "thu gọn tất cả."

Một chiến lược thu gọn khả thi:

  • Mặc định chỉ mở rộng hai cấp đầu. Nút gốc và các trường trực tiếp của nó hiển thị; các cấp sâu hơn hiện tóm tắt như items: Array(12000);
  • Đưa kiểu và số lượng vào tóm tắt. Người dùng kiểm tra "tên trường có đúng không" hiếm khi cần mười nghìn bản ghi;
  • Đệm hàng theo từng cây con. Mỗi nút đã mở rộng sở hữu mảng hàng đã làm phẳng của nó; thu gọn thì bỏ nó, mở rộng thì chỉ tính lại cây con đó;
  • Đừng bao giờ quét lại toàn cây ở mỗi lần bật/tắt. Nếu việc mở rộng xảy ra ở hàng 3.000, chỉ thay khoảng đó và giữ nguyên các hàng xung quanh.
/**
 * Insert a subtree's rows after its parent instead of re-flattening the whole tree.
 * @param rows Current visible row array
 * @param parentIndex Index of the parent row
 * @param childRows Flattened rows of the subtree
 * @returns A new visible row array
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

Mẫu cần tránh là "một lần mở rộng, một lần bố cục lại toàn bộ." Việc đi lại toàn bộ đồ thị đối tượng ở mỗi lần bấm làm chi phí tăng tuyến tính với tài liệu. Với đệm theo từng cây con, một lần mở rộng chỉ tốn đúng bằng cây con đó.

4. Danh sách ảo: chỉ kết xuất khung nhìn

Khi thu gọn đã giảm số hàng, phần còn lại vẫn có thể là hàng chục nghìn hàng. Danh sách ảo đặt chiều cao vật chứa bằng "tổng số hàng x chiều cao hàng cố định" và chỉ tạo phần tử cho khung nhìn cộng một bộ đệm nhỏ ở trên và dưới.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * Compute the row range that the viewport should render.
 * @param rows All visible rows
 * @param scrollTop Current scroll offset of the container
 * @param viewportHeight Visible height of the container
 * @returns Rows to render, the start index, and the vertical offset
 */
function getViewportRows(
  rows: TreeRow[],
  scrollTop: number,
  viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
  const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const end = Math.min(
    rows.length,
    Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
  );

  const visibleNodes = rows.slice(start, end);
  return {
    visibleRows: visibleNodes,
    startIndex: start,
    offsetTop: start * ROW_HEIGHT,
  };
}

Các ràng buộc không thể thương lượng:

  • Chiều cao hàng phải cố định. Chỉ khi đó scrollTop / ROW_HEIGHT mới cho bạn chỉ số trực tiếp. Cho phép hàng xuống dòng buộc bạn phải đo và đệm mọi chiều cao, thêm một bậc độ lớn về độ phức tạp và độ rung;
  • Dịch chuyển bằng transform hoặc định vị tuyệt đối. Đừng đẩy nội dung xuống bằng một phần tử đệm, nếu không mỗi lần cuộn lại thay đổi bố cục;
  • Dùng khóa ổn định. Đặt khóa hàng theo đường dẫn, không theo chỉ số mảng, nếu không việc mở rộng một hàng sẽ gắn lại mọi hàng sau nó;
  • Chỉ làm phép tính trong trình xử lý cuộn. Hoãn kết xuất sang requestAnimationFrame và chống lặp lịch;
  • Giữ bộ đệm nhỏ. Năm đến mười hàng mỗi phía là đủ; gấp đôi bộ đệm sẽ gấp đôi nút DOM và công việc kiểu.

Với khung nhìn 800 pixel và hàng 24 pixel, khoảng 34 hàng hiển thị, và ngay cả với bộ đệm bạn cũng chỉ kết xuất khoảng năm mươi. Tài liệu bên dưới có thể chứa hàng trăm nghìn hàng trong khi DOM luôn giữ vài trăm phần tử, giữ chi phí bố cục phẳng.

5. Thu gọn, mở rộng và neo khi cuộn

Khi chỉ số hàng trở thành tọa độ duy nhất, vài chi tiết tương tác bắt đầu hỏng:

  1. Giữ điểm neo thị giác khi thu gọn phía trên khung nhìn. Các hàng bên dưới dịch lên; nếu scrollTop không được điều chỉnh trong cùng một commit, nội dung trông như bị nhảy. Hãy ghi đường dẫn của hàng neo và khôi phục vị trí cuộn bằng chỉ số mới của nó.
  2. Mở rộng tổ tiên trước khi nhảy. Khi tìm kiếm trúng một trường sâu, hãy mở rộng mọi cha trước, rồi tính chỉ số hàng mục tiêu, rồi cuộn và tô sáng. Bỏ bước đầu và hàng mục tiêu thậm chí không nằm trong mảng hiển thị.
  3. Điều khiển bàn phím từ các hàng hiển thị. Lên và xuống di chuyển chỉ số hàng; trái và phải mở rộng và thu gọn. Đừng bao giờ đi qua toàn cây bên trong trình xử lý phím.
  4. Gộp cập nhật vào một khung hình. Khi mở rộng nhiều cấp liên tiếp, hãy gộp các thay đổi mảng và kết xuất một lần, nếu không mỗi cấp lại kích hoạt một lần reflow toàn bộ.

6. Tìm kiếm và tô sáng: tính một lần, hiển thị một ít

Tìm kiếm là bẫy hiệu năng bị bỏ qua phổ biến nhất trong trình xem cây. Nếu mỗi lần gõ phím đi qua toàn bộ đồ thị đối tượng và dựng lại danh sách kết quả, một tài liệu hàng trăm nghìn nút sẽ đóng băng ngay lập tức.

Một cách tiếp cận phân tầng:

  • Debounce đầu vào 150-250ms và gắn số thứ tự cho mỗi lần tìm kiếm để kết quả cũ bị loại bỏ;
  • Quét trong Worker. Đi qua đồ thị đối tượng là tính toán thuần không phụ thuộc DOM, nên nó không được chặn việc gõ;
  • Chỉ lưu đường dẫn và chỉ số hàng. Đừng sao chép mọi nút trúng hay dựng sẵn HTML đã tô sáng;
  • Chỉ tô sáng các hàng hiển thị. Khung nhìn giữ vài chục hàng; hãy áp kiểu theo đường dẫn trúng lúc kết xuất;
  • Tái sử dụng "mở rộng tổ tiên, rồi định vị chỉ số" cho việc nhảy, cùng đường đi mà việc bấm chevron dùng.

Một chi tiết bảo mật: khóa và giá trị trong trình xem cây là dữ liệu người dùng. Dựng HTML đã tô sáng từ chúng cần escape, hoặc một chuỗi chứa đánh dấu sẽ thành XSS. Một trình xem JSON không cần văn bản định dạng phong phú, nên nút văn bản cộng tên lớp vừa an toàn hơn vừa nhanh hơn.

7. Tô sáng cú pháp: CodeMirror và Monaco là công cụ khác nhau

Chế độ xem cây và trình biên tập văn bản là những mô hình kết xuất khác nhau, nên "cái nào nhanh hơn" không phải so sánh trực tiếp:

Khía cạnh CodeMirror 6 Monaco Editor
Định vị Lõi trình biên tập mô-đun Trình biên tập IDE đầy đủ
Kết xuất Dựa trên khung nhìn Dựa trên khung nhìn, nhưng model và decoration nặng hơn
Phân tích Phân tích tăng dần Lezer trên các dải đã thay đổi Tokenizer Monarch, theo từng dòng
Tệp lớn Phụ thuộc extension; mặc định nhẹ Đầy đủ tính năng, chi phí bộ nhớ và khởi động cao hơn
Thu gọn và tìm kiếm Extension riêng, nạp theo yêu cầu Có sẵn, với chi phí thường trực
Cạm bẫy điển hình Quá nhiều extension khôi phục lại chi phí Minimap, sticky scroll và decoration xếp lớp

Hai kết luận thực tế:

  • Chế độ xem cây không cần tokenizer đầy đủ. Hãy cho khóa, chuỗi, số và boolean mỗi loại một màu bằng nút văn bản thuần và lớp CSS; cách đó tốn ít hơn một bậc độ lớn so với chạy tô sáng cú pháp;
  • Đừng gắn chế độ xem cây và trình biên tập cùng lúc. Duy trì hai model cho một tài liệu nhân đôi bộ nhớ; hãy hủy chế độ xem cũ thay vì ẩn nó.

Nếu một trình biên tập thật sự thuộc về trình xem, CodeMirror 6 phù hợp với "chỉ đọc, tệp lớn, tô sáng cục bộ", trong khi Monaco phù hợp với gợi ý, chẩn đoán và chỉnh sửa nhiều con trỏ. Hãy đo thời gian vẽ lần đầu và bộ nhớ thường trú trước khi đọc danh sách tính năng. Để so sánh sâu hơn về trình biên tập và công cụ dòng lệnh, xem Trình biên tập, công cụ dòng lệnh và tự động hóa.

8. Ranh giới Worker: đưa phân tích và dựng hàng ra khỏi luồng chính

Nửa còn lại của cơn giật khi kết xuất là việc phân tích trên luồng chính. Với JSON lớn hơn 1MB, việc phân tích, định dạng và dựng hàng phẳng thuộc về Web Worker; luồng chính chỉ nhận hàng và vẽ khung nhìn.

Worker không miễn phí: postMessage dùng sao chép có cấu trúc, nên gửi một đồ thị đối tượng 50MB về luồng chính dựng nên bản sao thứ hai và nhân đôi đỉnh. Ranh giới đúng là phân tích và làm phẳng bên trong Worker, chỉ trả về những hàng mà bộ kết xuất cần:

worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
  rows = event.data.rows;
  requestAnimationFrame(() => renderViewport());
};

worker.postMessage({ text });

Nếu dữ liệu hàng vẫn lớn, hãy nén hàng thành các mảng song song (đường dẫn, kiểu, offset giá trị), đặt chúng vào một ArrayBuffer, và chuyển quyền sở hữu để luồng chính không phải trả giá cho một bản sao. Cái giá là phải tự viết serializer, chỉ đáng ở mức hàng triệu hàng.

Quy tắc cho ranh giới rất đơn giản: luồng chính chỉ nên làm công việc buộc phải chạm DOM. Phân tích, tìm kiếm, sắp xếp, định dạng và diff đều có thể chuyển đi. Kết xuất, cuộn, đầu vào và focus ở lại luồng chính, và mỗi khung hình phải nằm trong ngân sách công việc.

9. Chỉ số và thứ tự hiện thực

Tối ưu hóa không thể dừng ở "cảm giác nhanh hơn". Bộ chỉ số sau có thể đưa thẳng vào danh sách nghiệm thu:

Chỉ số Mục tiêu Cách đo
Khung hình hiển thị đầu tiên Dưới 300ms performance.now() từ mở tới lần vẽ đầu
Tác vụ dài Không có tác vụ nào vượt 50ms PerformanceObserver theo dõi longtask
Thời gian khung hình khi cuộn Ổn định dưới 16,7ms Ghi bản ghi DevTools Performance
Phần tử DOM trong khung nhìn Dưới 2.000 document.querySelectorAll('*').length
Phản hồi mở rộng / thu gọn Dưới 50ms Dấu thời gian quanh tương tác
Tìm kiếm trên 100.000 hàng Dưới 300ms, bên trong Worker Đo trong Worker và trả về thời lượng
Heap thường trú Dưới 3 lần kích thước văn bản Heap snapshot trong DevTools Memory

Hãy đo bằng một tệp thật thay vì các object nhỏ sinh đều; hình dạng dữ liệu quan trọng, và chuỗi dài, lồng sâu cùng mảng rất rộng đều tạo ra đường cong khác nhau. Về chính sổ đồ thị đối tượng, xem Sổ ghi bộ nhớ của JSON.

Hiện thực theo thứ tự này:

  1. Đo đường cơ sở: thời gian kết xuất đầu tiên, số nút, thời gian khung hình khi cuộn;
  2. Thu gọn mặc định: mở rộng hai cấp, hiện kiểu và số lượng trong tóm tắt;
  3. Danh sách ảo: chiều cao hàng cố định, khóa ổn định, khung nhìn cộng một bộ đệm nhỏ;
  4. Chuyển tính toán: phân tích, tìm kiếm và làm phẳng vào Worker;
  5. Giới hạn tô sáng: chỉ tạo kiểu cho hàng hiển thị, tránh vẽ lại toàn cây;
  6. Thêm lan can: trên một ngưỡng, đề nghị "hiển thị N hàng đầu" hoặc yêu cầu bộ lọc tìm kiếm.

Mỗi bước chia sẻ một mục tiêu: giảm các nút và object phải tồn tại cùng lúc, thay vì yêu cầu trình duyệt làm việc vất vả hơn với chúng. Bối cảnh đầy đủ về chiến lược phân tầng nằm trong Phân tích, kết xuất và truyền luồng JSON lớn.

Cuộn ảo có sửa được cơn giật lúc phân tích không?

Không. Cuộn ảo chỉ giảm nút DOM. Nếu việc phân tích vẫn diễn ra trên luồng chính, trang vẫn giật. Hãy chuyển việc phân tích vào Worker cho tài liệu trên 1MB và xử lý hai vấn đề riêng biệt.

Mọi thứ đã mở rộng mà vẫn giật. Có gì sai?

Hoặc danh sách ảo chưa thật sự hoạt động, hoặc mỗi lần mở rộng lại quét toàn cây. Hãy kiểm tra xem các hàng ngoài khung nhìn có còn tạo DOM không, và việc mở rộng có chỉ tính lại cây con bị ảnh hưởng không.

CodeMirror hay Monaco cho trình xem JSON?

Ưu tiên CodeMirror 6 cho tệp lớn, chỉ đọc và tô sáng cục bộ; việc nạp theo yêu cầu dạng mô-đun của nó nhẹ hơn. Chọn Monaco khi bạn cần gợi ý, chẩn đoán và chỉnh sửa nhiều con trỏ. Với cả hai, hãy tránh gắn chế độ xem cây và trình biên tập cho cùng một tài liệu cùng lúc.

Vì sao vị trí cuộn sai sau khi nhảy tìm kiếm?

Thường vì chế độ xem đã cuộn trước khi mở rộng. Hãy mở rộng mọi tổ tiên trước, rồi đặt vị trí cuộn từ chỉ số hàng mới, và tô sáng sau cùng. Đặt khóa hàng theo đường dẫn thay vì chỉ số để việc mở rộng không làm dịch mọi chỉ số.

FAQ

Làm sao biết cơn giật đến từ giai đoạn nào?

Hãy tách "chậm" thành phân tích và dựng object, tạo nút DOM, bố cục và vẽ, cùng phản hồi tương tác, rồi đo từng giai đoạn bằng performance.now() từ mở tới lần vẽ đầu. Sau đó theo dõi longtask bằng PerformanceObserver — mọi tác vụ vượt 50ms đều làm rớt khung hình, và bản ghi DevTools Performance cho thấy thời gian khung hình khi cuộn có giữ dưới 16,7ms hay không.

Vì sao kết xuất cả cây cùng lúc lại làm trắng màn hình?

Một tài liệu 5MB có khoảng 125.000 dòng, và chế độ xem cây thường biến mỗi dòng thành 3 đến 6 nút DOM — khoảng 500.000 phần tử cùng lúc. Mỗi phần tử còn mang quy tắc kiểu, một hộp bố cục và chỉ dẫn vẽ, nên vài tác vụ luồng chính trên 50ms liên tiếp làm trang trông như đã chết. Các nút bị ẩn bởi cha đã thu gọn vẫn tốn công bố cục, nên "kết xuất mọi thứ rồi ẩn đi" không phải tối ưu hóa.

Tôi có nên gửi đồ thị đối tượng 50MB trả về từ Worker không?

Không. postMessage dùng sao chép có cấu trúc, nên phía nhận dựng bản sao thứ hai và đỉnh nhân đôi. Ranh giới đúng là phân tích và làm phẳng bên trong Worker và chỉ trả về những hàng mà bộ kết xuất cần; ở mức hàng triệu hàng bạn có thể đóng gói các mảng song song (đường dẫn, kiểu, offset giá trị) vào một ArrayBuffer và chuyển quyền sở hữu.