大 JSON 的解析、渲染与流式处理
结论:解析往往不是瓶颈,渲染和拷贝才是。按「解析 → 遍历 → 渲染 → 传输」四层拆开优化,才不会白费力气。

结论:先用 performance.now() 或 process.memoryUsage() 把解析、遍历、渲染、传输四段的耗时逐段量出来,再按「减少拷贝 → 按需展开与虚拟滚动 → 搬进 Worker → 必要时流式解析 → 换 JSON Lines」的顺序动手;只有文件大到装不下内存,或只需要少数字段时,才值得先上流式。
「JSON.parse 慢」这个结论十次里有八次是错的。真正慢的是解析完之后你做的事。
一、先量化,再优化
同一份 10MB JSON,在不同环节的耗时量级差别很大:
| 环节 | 典型耗时 | 主要成本 |
|---|---|---|
JSON.parse(原生) |
几十到一百多毫秒 | 单次线性扫描 + 建对象 |
深拷贝(structuredClone / 递归) |
可能是解析的 1~3 倍 | 重新建整棵对象图 |
| 深比较 / diff | 可能再翻一倍 | 递归遍历 + 字符串比较 |
| 渲染成 DOM 树 | 几万个节点时就卡住 | 布局、重排 |
| 字符串拼接再序列化 | 与输出大小成正比 | 内存翻倍 |
所以在动手之前先测:在浏览器里用 performance.now() 包住每一段,或用 console.time;
在 Node 里用 process.memoryUsage() 看堆增长。没有基线就没有优化。
二、解析层:三种策略
2.1 全量解析(默认选择)
const data = JSON.parse(text);
原生实现由 C++ 完成扫描与建对象,纯 JS 库很难超过它。只要满足下面三条,就用它:
- 文本能完整放进内存(通常 < 100MB);
- 解析完成后确实要用到大部分字段;
- 数据不受信任的程度可控。
2.2 流式增量解析
用 stream-json 这类 SAX 风格的解析器,可以在不建完整对象树的情况下扫出目标字段:
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';
const pipeline = createReadStream('huge.json')
.pipe(parser())
.pipe(pick({ filter: 'items' }))
.pipe(streamArray());
for await (const { value } of pipeline) {
// 一次只处理一个元素,内存与元素数量无关
handleItem(value);
}
代价:
- 代码复杂度显著上升,错误处理更麻烦;
- 只能顺序访问,不能随机取;
- 对「需要全局聚合」的需求不友好。
判断标准:文件大到装不下,或者只需要其中很小一部分字段,才值得上流式。
2.3 换格式
如果数据是「一批记录」而不是「一个文档」,那么一开始就该用 JSON Lines。 行分隔格式让「流式」变成「按行读」,实现复杂度低一个数量级。
三、遍历层:少做一次拷贝就赢一半
// 反例:为了「安全」先深拷贝
const safeCopy = structuredClone(bigData); // 内存直接翻倍
// 正例:只读场景直接传递引用
function renderSummary(data: OrderDocument): Summary {
return { total: data.items.length, amount: data.total };
}
常见的隐性拷贝:
JSON.parse(JSON.stringify(x))做「深拷贝」——这是最贵的一种;{ ...data }只拷一层,但对大对象仍会复制所有顶层键的引用数组;Array.prototype.map生成新数组,元素多时同样可观;- 框架的响应式代理(Vue 的
reactive)对大对象做递归代理,开销可能比解析还高。
实践建议:把「原始数据」保持为不可变的普通对象,不做响应式包装; 需要展示的派生数据单独计算并缓存。
四、渲染层:别把整棵树塞进 DOM
一个 5MB 的 JSON 大约有 3~10 万个节点,全部渲染成 DOM 元素会让浏览器直接失去响应。
三种可行方案:
4.1 按需展开
只渲染根节点的直接子节点,展开时才计算下一层。这是树形查看器的标准做法:
type TreeNode = { key: string; value: unknown; expanded: boolean };
/**
* 只返回当前可见的节点,避免一次性构建完整树。
* @param rootNode 根节点
* @param expandedPaths 已展开的路径集合
* @returns 可见节点列表
*/
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
const visibleNodes: TreeNode[] = [];
/**
* 递归收集已展开分支的子节点。
* @param currentNode 当前节点
* @param currentPath 当前路径
*/
const walk = (currentNode: TreeNode, currentPath: string): void => {
visibleNodes.push(currentNode);
if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
return;
}
for (const [childKey, childValue] of Object.entries(currentNode.value)) {
walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
}
};
walk(rootNode, '');
return visibleNodes;
}
4.2 虚拟滚动
已展开的节点超过几百个时,只渲染视口内的几十个,其余用占位高度撑开。 列表类视图(表格、日志)用它效果最明显。
4.3 只显示摘要
先把「结构 + 类型 + 数量」呈现出来(比如 items: Array(12000)),用户点开某一条才去取具体值。
这一招对「我就想确认字段对不对」的场景最有效。
五、主线程:该搬到 Worker 就搬
超过 1MB 的解析、格式化、diff,都应该放进 Web Worker:
// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
try {
const parsed = JSON.parse(event.data.text);
self.postMessage({ ok: true, data: parsed });
} catch (parseError) {
self.postMessage({ ok: false, message: (parseError as Error).message });
}
};
/**
* 在 Worker 中解析 JSON,避免阻塞主线程。
* @param text JSON 文本
* @returns 解析结果
*/
function parseInWorker(text: string): Promise<unknown> {
return new Promise((resolvePromise, rejectPromise) => {
const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event) => {
worker.terminate();
if (event.data.ok) {
resolvePromise(event.data.data);
} else {
rejectPromise(new Error(event.data.message));
}
};
worker.postMessage({ text });
});
}
注意 Worker 的通信成本:postMessage 默认走结构化克隆,10MB 对象克隆一次也是几十毫秒。
如果只是「解析完给主线程用」,可以只在 Worker 里做重活(解析 + 转换 + 校验),把结果摘要传回来。
六、传输层:体积优化
| 手段 | 典型收益 | 代价 |
|---|---|---|
| gzip / brotli | 压缩后常为原体积的 10%~25% | 需要服务端支持 |
| 去掉缩进与空格 | 减少 20%~40% | 不可读 |
| 字段名缩写 | 深度嵌套时收益明显 | 可读性下降,需要映射表 |
| 数字代替枚举字符串 | 小 | 需要字典 |
| 分页 / 按需字段 | 最大 | 接口改造 |
对「给人看」的页面,正确的组合是:接口传压缩后的紧凑 JSON,展示时才美化。 很多工具站默认把美化后的文本再传给前端,白白多传 30%。
七、基准测试怎么做
/**
* 测量一段同步逻辑的中位耗时,避免单次抖动干扰判断。
* @param runOnce 待测函数
* @param repeatCount 重复次数
* @returns 中位耗时(毫秒)
*/
function measureMedian(runOnce: () => void, repeatCount = 9): number {
const durations: number[] = [];
for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
const startedAt = performance.now();
runOnce();
durations.push(performance.now() - startedAt);
}
durations.sort((firstValue, secondValue) => firstValue - secondValue);
return durations[Math.floor(durations.length / 2)];
}
三条纪律:
- 用固定样例,不随机构造,否则数据不可比;
- 至少跑 9 次取中位数,首次运行包含 JIT 预热,要丢弃;
- 记录运行环境(Node 版本、CPU),否则跨机器对比毫无意义。
八、检查清单
- [ ] 有基线数据,优化前后可对比;
- [ ] 超过 1MB 的解析放到 Worker;
- [ ] 树形视图按需展开,列表用虚拟滚动;
- [ ] 不做无意义的深拷贝与响应式包装;
- [ ] 数据是「一批记录」时用 JSON Lines;
- [ ] 接口传紧凑 JSON,展示时才格式化。
常见问题
如何判断瓶颈落在解析、渲染还是传输?
在浏览器里用 performance.now() 或 console.time 包住每一段,在 Node 里用 process.memoryUsage() 看堆增长,先拿到基线再动手。再看主线程:单段占用超过 50ms,滚动和输入就会掉帧;渲染成 DOM 树在几万个节点时就会卡住,那时瓶颈已经不在 JSON.parse。三段分开记,才知道该优化哪一层。
为什么 JSON.parse 通常不是最慢的一环?
原生实现由 C++ 完成扫描与建对象,10MB 文本一般几十到一百多毫秒,纯 JS 库很难超过它。真正贵的是解析之后的事:深拷贝可能是解析的 1~3 倍,深比较再翻一倍,把几万个节点渲染成 DOM 会直接卡住。所以「JSON.parse 慢」这个判断十次里有八次是错的。
是否应该把所有 JSON 处理都搬进 Worker?
超过 1MB 的解析、格式化、diff 都应该放进 Worker,但 Worker 不是免费的:postMessage 默认走结构化克隆,10MB 对象克隆一次也是几十毫秒。更稳的做法是只在 Worker 里做重活(解析 + 转换 + 校验),把结果摘要传回主线程;小于 1MB 且只解析一次时,搬过去反而多付一次通信成本。