JSON 工具箱

大 JSON 的解析、渲染与流式处理

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

进阶约 13 分钟发布于 2026-10-30更新于 2026-10-31
大 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)];
}

三条纪律:

  1. 用固定样例,不随机构造,否则数据不可比;
  2. 至少跑 9 次取中位数,首次运行包含 JIT 预热,要丢弃;
  3. 记录运行环境(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 且只解析一次时,搬过去反而多付一次通信成本。