JSON 工具箱

大樹渲染為什麼卡:JSON 檢視器的虛擬滾動與摺疊策略

結論:一份 5MB 的 JSON 能產生幾十萬個 DOM 節點,卡頓從建立節點那一刻就開始,而不是從滾動開始。本文拆開解析、建節點、佈局與繪製的成本,給出摺疊優先、虛擬列表、搜尋高亮與 Worker 分層的具體做法,並列出可以驗收的指標。

實戰約 10 分鐘發佈於 2026-10-31更新於 2026-10-31
JSON 檢視器的虛擬滾動與摺疊策略示意

結論:先把「卡」拆成解析、建節點、佈局繪製、互動四段並量出各段耗時,再按「預設摺疊 → 虛擬列表 → 計算搬進 Worker → 高亮收斂到可見列」的順序落地;跳過摺疊直接上虛擬列表,只是讓可見列變少,整棵樹的重算成本還在。

卡頓的根源往往不是你載入的那 5MB 資料,而是你為它建立的那幾十萬個 DOM 節點。

一、先把「卡」拆成四段

使用者說「開啟就卡」,實際可能發生在四個完全不同的階段,治理手段也不一樣:

階段 典型觸發 可感知的閾值 主要成本
解析與建物件 開啟檔案、貼上文字 單一任務超過 50ms 掃描字串、建物件圖
建 DOM 節點 一次性渲染整棵樹 節點數超過數萬 分配元素、文字節點、樣式物件
佈局與繪製 首次展示、滾動 單一影格超過 16.7ms 計算幾何、重排、重繪
互動回應 展開、摺疊、搜尋 超過 100ms 有明顯延遲 重新扁平化、重新渲染

瀏覽器的主執行緒一次只能做一件事。上面任何一段佔用主執行緒超過 50ms,滾動和輸入就會掉影格;連續幾個這樣的任務,頁面看起來就是「死了」。所以最佳化的第一步不是加 requestAnimationFrame,而是先量出時間到底花在哪一段。

二、節點數量才是真正的敵人

樹形檢視器天然會放大節點數。一行 JSON 在樹裡通常要拆成「縮排 + 鍵 + 冒號 + 值 + 逗號 + 摺疊圖示」等多個元素,保守估計一行對應 3 到 6 個 DOM 節點:

檔案大小 大致列數 樹檢視 DOM 節點(估算) 一次性渲染的結果
100 KB 2,500 1 萬 通常還能接受
1 MB 25,000 10 萬 明顯卡頓,可能白屏數秒
5 MB 125,000 50 萬 大概率失去回應
20 MB 500,000 200 萬以上 記憶體與佈局雙雙失控

每個元素不只是幾十位元組的物件,還牽著樣式規則、佈局盒和繪製指令。節點一旦建出來,即使後來被摺疊隱藏,只要沒有真正移除,佈局與樣式計算仍要為它付出代價。「先全渲染、再隱藏」不是最佳化,只是把成本延後到下一次重排。

三、摺疊優先:先減少可見列,再談虛擬化

虛擬滾動解決的是「可見列太多」,摺疊解決的是「根本不需要看見這麼多列」。兩者順序不能反:一份預設全展開的百萬列文件,即使做了虛擬列表,使用者點一次「全部摺疊」仍要重算整棵樹。

可落地的摺疊策略:

  • 預設只展開前兩層。 根物件、一級欄位可見,更深層顯示為 items: Array(12000) 這樣的摘要;
  • 摘要顯示型別與數量。 使用者判斷「欄位對不對」時,通常不需要看到一萬條明細;
  • 按子樹快取列。 每個已展開節點維護自己的扁平列陣列,摺疊時直接丟棄,展開時只計算這一棵子樹;
  • 不要每次操作都全量重掃。 展開發生在第 3,000 列時,只替換這一段區間,前後列原樣保留。
/**
 * 用子樹的列替換掉父列,避免整棵樹重新扁平化。
 * @param rows 目前可見列陣列
 * @param parentIndex 父列在陣列中的下標
 * @param childRows 子樹展開後的列陣列
 * @returns 新的可見列陣列
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

真正需要警惕的是「展開一次,全表重排」。如果每次點擊都重新遍歷完整物件圖,節點越多越慢,而且耗時隨資料量線性增長;按子樹快取後,單次展開的耗時只與這棵子樹的大小有關。

四、虛擬列表:只渲染視口內的列

摺疊把列數降下來之後,剩下的部分仍然可能上萬列。這時用虛擬列表:容器高度按「總列數 × 固定列高」撐開,只建立視口內、上下各留幾列緩衝的元素。

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * 計算目前視口應渲染的列區間。
 * @param rows 全部可見列
 * @param scrollTop 容器滾動距離
 * @param viewportHeight 容器可視高度
 * @returns 需要渲染的列、起始下標與位移量
 */
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,
  };
}

幾個必須守住的約束:

  • 列高必須固定。 固定列高才能用 scrollTop / ROW_HEIGHT 直接算出下標;一旦允許列內換行,就必須測量並快取每一列高度,複雜度與抖動都會上一個台階;
  • 用 transform 或絕對定位偏移。 不要靠撐高的佔位元素把內容「推」下去,否則每次滾動都在改佈局;
  • 給列穩定的 key。 用「路徑」而不是陣列下標做 key,否則展開一列會讓後面所有列重新掛載;
  • 滾動回呼裡只做區間計算。 渲染放到 requestAnimationFrame,並用一個旗標避免重複排隊;
  • 緩衝區不要太大。 上下各 5 到 10 列足夠;緩衝區翻倍,DOM 節點和樣式計算也翻倍。

視口 800 像素、列高 24 像素時,可見列大約 34 列,加上緩衝也不過五十列。即使底層有十萬列,DOM 裡始終只有幾百個元素,佈局成本基本恆定。

五、展開、摺疊與定位的互動細節

虛擬列表把「列的下標」變成了唯一座標,互動上有幾個容易翻車的點:

  1. 在上方摺疊時保持視覺錨點。 摺疊發生在視口上方,後面的列會整體上移;如果不同步調整 scrollTop,使用者會感覺內容「跳了一下」。正確做法是記錄錨點列的路徑,重排後按新下標回滾滾動位置。
  2. 跳轉前先展開祖先。 搜尋命中深層欄位時,必須先展開它的所有父節點,再計算目標列下標,最後滾動並高亮。少了第一步,目標列根本不在可見列陣列裡。
  3. 鍵盤導航要基於可見列。 上下鍵移動的是「列下標」,左右鍵才是展開與摺疊;不要在按鍵處理裡同步遍歷整棵樹。
  4. 批次更新合併到一個影格。 連續展開多層時,把多次陣列變更合併後只渲染一次,否則每一層都觸發一次完整重排。

六、搜尋與高亮:算一次,顯示一點

搜尋是樹檢視器裡最容易被忽略的效能陷阱。使用者輸入一個字元,如果每次都遍歷整棵物件圖並重建結果列表,十萬節點的文件會直接卡死。

建議的分層做法:

  • 輸入防抖 150 到 250 毫秒,並給每次搜尋一個序號,丟棄過期結果;
  • 在 Worker 裡掃描。 遍歷物件圖是純計算,不需要 DOM,放到 Worker 不阻塞輸入;
  • 結果只保留路徑與列下標。 不要把命中的所有節點都複製一份,也不要提前產生高亮 HTML;
  • 高亮只作用於可見列。 視口裡只有幾十列,渲染時再按命中路徑加樣式即可;
  • 跳轉複用「展開祖先 + 定位下標」這條路徑,與點擊摺疊圖示的邏輯保持一致。

還有一個安全細節:樹檢視裡的鍵和值都是使用者資料,拼高亮 HTML 時必須轉義,否則一段帶標籤的字串就能變成 XSS。JSON 檢視器不需要富文字,用文字節點加類名更安全,也更快。

七、語法高亮的成本:CodeMirror 與 Monaco 不是一回事

樹檢視和文字編輯器是兩種渲染模型,不能直接比較「誰更快」:

維度 CodeMirror 6 Monaco Editor
定位 模組化編輯器核心 完整的 IDE 編輯器
渲染 視口內按需渲染 視口渲染,但模型與裝飾器更重
語法分析 Lezer 增量解析,只處理變化區間 Monarch 分詞器,按列處理
大檔案 依賴擴充組合,預設較輕 功能全,記憶體與初始化成本更高
摺疊與搜尋 獨立擴充,按需引入 內建,但會常駐開銷
典型問題 擴充裝太多,成本回升 minimap、黏性滾動、裝飾器疊加

兩條實踐結論:

  • 樹檢視不需要完整分詞器。 鍵、字串、數字、布林值各一種顏色,用普通文字節點加 CSS 類就夠,成本比跑一遍語法高亮低一個量級;
  • 不要同時掛樹檢視和編輯器。 同一份文件維護兩套模型,記憶體直接翻倍;需要切換時銷毀舊檢視,不要只是隱藏。

如果確實要在檢視器裡嵌入編輯器,CodeMirror 6 更適合「唯讀 + 大檔案 + 局部高亮」的場景;Monaco 適合需要補全、診斷、多游標的重編輯場景。選型時先量首屏時間與常駐記憶體,不要只看功能列表。編輯器與命令列工具的更多對比見《編輯器、命令列與自動化工具鏈》。

八、Worker 分層:把解析和建列走在路上

渲染卡頓的另一半原因,是把解析也放在主執行緒。超過 1MB 的 JSON,解析、格式化、建構扁平列都應該放進 Web Worker;主執行緒只負責「拿列、渲染視口」。

但 Worker 不是免費的:postMessage 預設走結構化複製,把 50MB 物件圖發回主執行緒,會在接收端再建一份,峰值直接翻倍。正確的邊界是在 Worker 裡完成「解析 + 扁平化」,只回傳渲染需要的列:

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

worker.postMessage({ text });

如果列資料仍然很大,可以進一步把列壓縮成平行的陣列(路徑、型別、值位移),放進 ArrayBuffer 後用 transferable 轉移所有權,主執行緒就不會多背一份複製。代價是序列化與反序列化要自己寫,只有列數上百萬時才值得。

分層的判斷標準很簡單:主執行緒只做「必須接觸 DOM」的事。 解析、搜尋、排序、格式化、diff 全部可以搬走;渲染、滾動、輸入、焦點留在主執行緒,並且每個影格控制在工作預算內。

九、驗收指標與落地順序

最佳化不能停在「感覺快了」。下面這組指標可以直接寫進驗收單:

指標 目標 測量方式
首次可見 小於 300ms performance.now() 從開啟到首幀
長任務 無超過 50ms 的任務 PerformanceObserver 監聽 longtask
滾動影格時間 穩定在 16.7ms 以內 DevTools Performance 錄製
視口內 DOM 元素 小於 2,000 document.querySelectorAll('*').length
展開 / 摺疊回應 小於 50ms 操作前後打點
搜尋 10 萬列 小於 300ms,且在 Worker Worker 內計時並回傳
常駐堆記憶體 小於文字大小的 3 倍 DevTools Memory 堆快照

測量時用真實檔案,不要用產生的均勻小物件;資料形狀對結果的影響很大,長字串、深巢狀、超寬陣列都會給出完全不同的曲線。關於物件圖本身的帳,見《JSON 的記憶體帳:字串、物件與大陣列各佔多少》。

按下面的順序落地:

  1. 先量基線:記錄首次渲染耗時、節點數、滾動影格時間;
  2. 預設摺疊:只展開前兩層,摘要顯示型別與數量;
  3. 虛擬列表:固定列高、穩定 key、視口加緩衝;
  4. 搬走計算:解析、搜尋、扁平化進 Worker;
  5. 收斂高亮:只對可見列加樣式,避免整樹重繪;
  6. 設護欄:超過閾值直接提示「僅顯示前 N 列」或要求搜尋過濾。

這套順序的共同點是:每一步都在減少「同一時刻需要存在的節點與物件」,而不是讓瀏覽器更努力地處理它們。分層策略的完整背景見《大 JSON 的解析、渲染與串流處理》。

虛擬滾動能解決解析卡頓嗎?

不能。虛擬滾動只減少 DOM 節點,解析仍發生在主執行緒時照樣會卡。超過 1MB 的文件要把解析放進 Worker,兩件事分開治理。

全部展開後還是卡,問題出在哪?

說明虛擬列表沒有真正生效,或者每次展開都在重掃整棵樹。檢查視口外的列是否仍建立了 DOM,以及展開操作是否只重算受影響的子樹。

CodeMirror 和 Monaco 該選哪個做 JSON 檢視器?

唯讀、大檔案、局部高亮優先 CodeMirror 6,模組化、按需載入更輕;需要補全、診斷、多游標編輯再選 Monaco。兩者都要避免與樹檢視同時掛載同一份文件。

搜尋跳轉後位置不準怎麼辦?

通常是先滾動、後展開導致的。正確順序是先展開所有祖先節點,再按新列下標設定滾動位置,最後高亮。列 key 用路徑而不是下標,可以避免展開後下標整體偏移。

常見問題

如何定位卡頓發生在哪一段?

先把「卡」拆成解析與建物件、建 DOM 節點、佈局與繪製、互動回應四段,各段用 performance.now() 打點從開啟到首幀。再用 PerformanceObserver 監聽 longtask,單一任務超過 50ms 就會掉影格;DevTools Performance 錄製能看出滾動影格時間是否穩定在 16.7ms 以內。

為什麼把整棵樹一次性渲染出來會白屏?

5MB 的 JSON 大約 125,000 列,樹檢視一列通常拆成 3 到 6 個 DOM 節點,一次性就是約 50 萬個元素。每個元素不只佔幾十位元組,還牽著樣式規則、佈局盒和繪製指令,主執行緒連續幾個超過 50ms 的任務,頁面看起來就是失去回應。節點建出來後被摺疊隱藏也不會退還成本,所以「先全渲染、再隱藏」不是最佳化。

是否可以直接把 50MB 物件圖從 Worker 傳回主執行緒?

不建議。postMessage 預設走結構化複製,接收端會再建一份,峰值直接翻倍;正確的邊界是在 Worker 裡完成解析與扁平化,只回傳渲染需要的列。列數上百萬時可以壓成平行的陣列(路徑、型別、值位移)放進 ArrayBuffer,用 transferable 轉移所有權。