Javascript is required
性能优化发布于 2026-07-28审校于 2026-08-0813 分钟阅读

Web Worker 与虚拟列表渲染超大 JSON 的实现方法

几十 MB 甚至更大的 JSON 文件会同时放大文件读取、UTF-8 解码、JSON.parse、对象内存、线程通信和 DOM 节点数量。Web Worker 只能把同步解析移出主线程,虚拟列表只能减少 DOM;两者都不能保证任意 100MB 文件一定可打开。本文给出一个可运行的浏览器端架构,并明确它适合什么数据、何时应改用流式解析或服务端处理。

JSONWeb Worker虚拟列表大文件Vue 3

一、先区分四个瓶颈,避免只优化 JSON.parse

超大 JSON 查看器通常不是单点变慢。原始 ArrayBuffer、解码后的 JavaScript 字符串、解析后的对象以及用于展示的行数据可能同时存在;如果再把完整对象从 Worker 发回主线程,还会触发结构化克隆。

虚拟列表只控制可见 DOM 数量,不会自动降低解析对象的内存。Web Worker 只保证 UI 线程不执行解析,不会让解析本身变成流式,也不会减少总内存。

  • 读取:File.arrayBuffer 会把文件内容载入内存。
  • 解码:TextDecoder 把 UTF-8 字节转换为 JavaScript 字符串。
  • 解析:JSON.parse 同步构造完整对象。
  • 渲染:递归创建全部 DOM 会造成节点、布局和样式计算压力。

二、推荐架构:完整数据留在 Worker,主线程只拿可见切片

主线程把 File 转成 ArrayBuffer,并通过 transferable list 把 buffer 的所有权交给 Worker。ArrayBuffer 可以转移,普通 JavaScript 对象不能按同样方式零拷贝转移。

Worker 完成解码、解析和扁平化后保存 rows,只返回总行数。滚动时主线程发送 start/end,Worker 返回几十行可见数据。这样不会在初始化时克隆整棵对象到主线程。

// message types shared by the main thread and worker
export type MainToWorker =
  | { type: 'load'; buffer: ArrayBuffer }
  | { type: 'range'; start: number; end: number };

export type WorkerToMain =
  | { type: 'ready'; total: number }
  | { type: 'rows'; start: number; rows: JsonRow[] }
  | { type: 'error'; message: string };

export type JsonRow = {
  id: number;
  depth: number;
  key: string;
  preview: string;
  valueType: string;
};

三、Worker:解码、解析并按范围返回数据

下面示例把 JSON 树扁平化为轻量行,用于演示固定行高虚拟列表。它仍然会保存完整解析对象和 rows,因此不是无限容量方案;对于极深对象、超大字符串字段或数百万节点,应使用增量解析器、懒展开索引或服务端预处理。

为了避免主线程收到完整对象,range 消息只返回当前窗口。preview 对长字符串进行截断,防止单个字段再次制造巨量 DOM 文本。

// json-viewer.worker.ts
type JsonRow = {
  id: number;
  depth: number;
  key: string;
  preview: string;
  valueType: string;
};

let rows: JsonRow[] = [];
let nextId = 0;

function preview(value: unknown): string {
  if (typeof value === 'string') {
    return JSON.stringify(value.length > 200 ? `${value.slice(0, 200)}…` : value);
  }
  if (value === null) return 'null';
  if (typeof value === 'object') {
    return Array.isArray(value) ? `Array(${value.length})` : 'Object';
  }
  return String(value);
}

function flatten(value: unknown, key = '$', depth = 0): void {
  rows.push({
    id: nextId++,
    depth,
    key,
    preview: preview(value),
    valueType: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value,
  });

  if (Array.isArray(value)) {
    value.forEach((child, index) => flatten(child, `[${index}]`, depth + 1));
  } else if (value && typeof value === 'object') {
    for (const [childKey, childValue] of Object.entries(value)) {
      flatten(childValue, childKey, depth + 1);
    }
  }
}

self.onmessage = (event: MessageEvent) => {
  try {
    if (event.data.type === 'load') {
      rows = [];
      nextId = 0;
      const text = new TextDecoder('utf-8', { fatal: true }).decode(event.data.buffer);
      const value: unknown = JSON.parse(text);
      flatten(value);
      self.postMessage({ type: 'ready', total: rows.length });
      return;
    }

    if (event.data.type === 'range') {
      const start = Math.max(0, event.data.start);
      const end = Math.min(rows.length, event.data.end);
      self.postMessage({ type: 'rows', start, rows: rows.slice(start, end) });
    }
  } catch (error) {
    self.postMessage({ type: 'error', message: (error as Error).message });
  }
};

四、主线程:转移 ArrayBuffer,而不是发送完整字符串副本

File.arrayBuffer 得到的 buffer 在 postMessage 的第二个参数中被转移后,主线程一侧会失去对这块底层内存的访问权。这个行为是预期结果。

在 Nuxt 3 中创建 Worker 的代码必须只在客户端执行,例如放在 onMounted 内;不要在 SSR 阶段访问 Worker、File 或 window。

import { onBeforeUnmount, onMounted, ref } from 'vue';

const totalRows = ref(0);
const visibleRows = ref<Array<{
  id: number;
  depth: number;
  key: string;
  preview: string;
  valueType: string;
}>>([]);

let worker: Worker | undefined;

onMounted(() => {
  worker = new Worker(
    new URL('./json-viewer.worker.ts', globalThis._importMeta_.url),
    { type: 'module' },
  );

  worker.onmessage = (event) => {
    if (event.data.type === 'ready') totalRows.value = event.data.total;
    if (event.data.type === 'rows') visibleRows.value = event.data.rows;
    if (event.data.type === 'error') console.error(event.data.message);
  };
});

async function loadFile(file: File): Promise<void> {
  if (!worker) throw new Error('Worker is not ready');
  const buffer = await file.arrayBuffer();
  worker.postMessage({ type: 'load', buffer }, [buffer]);
}

onBeforeUnmount(() => worker?.terminate());

五、固定行高虚拟列表的核心计算

固定行高实现最简单:用总行数乘行高撑起滚动区域,再根据 scrollTop 计算起止索引,并增加 overscan 防止快速滚动时出现空白。只有 visibleRows 对应的节点进入 DOM。

如果内容允许自动换行,实际行高会变化,固定行高公式失效。大 JSON 查看器通常应禁止换行、截断预览,并在点击时单独展示完整值。

const rowHeight = 28;
const overscan = 8;

function calculateRange(
  scrollTop: number,
  viewportHeight: number,
  total: number,
) {
  const first = Math.floor(scrollTop / rowHeight);
  const visibleCount = Math.ceil(viewportHeight / rowHeight);
  const start = Math.max(0, first - overscan);
  const end = Math.min(total, first + visibleCount + overscan);
  return {
    start,
    end,
    offsetY: start * rowHeight,
    totalHeight: total * rowHeight,
  };
}

六、Vue 3 视口示例与请求节流

滚动事件中只更新范围,并在下一帧向 Worker 请求切片。若 start/end 没变化则不重复请求。模板中的 spacer 负责滚动条总高度,rows 容器通过 translateY 移动到正确位置。

<template>
  <div ref="viewport" class="viewport" @scroll="scheduleRange">
    <div :style="{ height: `${totalHeight}px` }" />
    <div class="rows" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="row in visibleRows"
        :key="row.id"
        class="row"
        :style="{ height: `${rowHeight}px`, paddingLeft: `${row.depth * 16}px` }"
      >
        <strong>{{ row.key }}</strong>: {{ row.preview }}
      </div>
    </div>
  </div>
</template>

<style scoped>
.viewport { position: relative; height: 600px; overflow: auto; }
.rows { position: absolute; inset: 0 0 auto 0; }
.row { box-sizing: border-box; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>

七、内存边界与不适用场景

JSON.parse 需要完整文本,不能因为放进 Worker 就变成真正的流式解析。若浏览器需要同时保留字节、字符串、对象和行索引,峰值内存可能远大于文件大小。设备、浏览器、数据结构和字符串重复度都会影响结果,不能承诺“100MB 一定流畅”。

以下场景应考虑服务端分页、NDJSON、分块 API、专用流式 JSON 解析器或桌面应用:文件超过目标设备内存预算;只需要搜索少量字段;顶层数组可以天然分页;数据来自服务端且可改变协议;页面需要编辑而不只是查看。

  • 不要把完整 parsed object 通过 postMessage 发回主线程。
  • 不要同时长期保留原始文本、格式化文本、完整对象和完整 DOM。
  • 不要对所有节点做语法高亮;只处理可见切片。
  • 不要在每次 scroll 事件中同步执行大量计算或创建新 Worker。

八、如何验证、FAQ 与结论

使用 Performance 面板观察主线程长任务;在 Worker 内分别记录 decode、parse、flatten 时间;用浏览器任务管理器或 Memory 工具观察峰值内存;滚动时确认 DOM 节点数量接近可见行加 overscan,而不是总行数。测试数据应包含大数组、深层对象、超长字符串和非法 UTF-8。

问:Worker 会让 JSON.parse 更快吗?答:不一定,它主要避免解析阻塞 UI,实际解析耗时仍取决于引擎和数据。

问:可以把 parsed object 设为 Transferable 吗?答:普通对象不可以;ArrayBuffer 等特定对象可转移,普通对象通过 structured clone 复制。

问:为什么虚拟列表后内存仍然高?答:它减少的是 DOM,完整字符串、对象和索引仍可能占用大量内存。

结论:可靠方案是转移输入 buffer、让完整数据留在 Worker、按视口返回行切片,并明确内存上限。语法失败应参考 /articles/json-unexpected-token;数字精度问题应参考 /articles/json-number-precision

来源与延伸阅读

技术审校所依据的规范与权威参考资料。

相关文章

继续阅读

可打开关联的浏览器工具,使用自己的样本验证文中的处理流程。

打开关联工具