大树渲染为什么卡: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 转移所有权。