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

結論:先把「卡」拆成解析、建節點、佈局繪製、互動四段並量出各段耗時,再按「預設摺疊 → 虛擬列表 → 計算搬進 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 裡始終只有幾百個元素,佈局成本基本恆定。
五、展開、摺疊與定位的互動細節
虛擬列表把「列的下標」變成了唯一座標,互動上有幾個容易翻車的點:
- 在上方摺疊時保持視覺錨點。 摺疊發生在視口上方,後面的列會整體上移;如果不同步調整
scrollTop,使用者會感覺內容「跳了一下」。正確做法是記錄錨點列的路徑,重排後按新下標回滾滾動位置。 - 跳轉前先展開祖先。 搜尋命中深層欄位時,必須先展開它的所有父節點,再計算目標列下標,最後滾動並高亮。少了第一步,目標列根本不在可見列陣列裡。
- 鍵盤導航要基於可見列。 上下鍵移動的是「列下標」,左右鍵才是展開與摺疊;不要在按鍵處理裡同步遍歷整棵樹。
- 批次更新合併到一個影格。 連續展開多層時,把多次陣列變更合併後只渲染一次,否則每一層都觸發一次完整重排。
六、搜尋與高亮:算一次,顯示一點
搜尋是樹檢視器裡最容易被忽略的效能陷阱。使用者輸入一個字元,如果每次都遍歷整棵物件圖並重建結果列表,十萬節點的文件會直接卡死。
建議的分層做法:
- 輸入防抖 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 的記憶體帳:字串、物件與大陣列各佔多少》。
按下面的順序落地:
- 先量基線:記錄首次渲染耗時、節點數、滾動影格時間;
- 預設摺疊:只展開前兩層,摘要顯示型別與數量;
- 虛擬列表:固定列高、穩定 key、視口加緩衝;
- 搬走計算:解析、搜尋、扁平化進 Worker;
- 收斂高亮:只對可見列加樣式,避免整樹重繪;
- 設護欄:超過閾值直接提示「僅顯示前 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 轉移所有權。