Web Worker 与虚拟列表渲染超大 JSON 的实现方法
几十 MB 甚至更大的 JSON 文件会同时放大文件读取、UTF-8 解码、JSON.parse、对象内存、线程通信和 DOM 节点数量。Web Worker 只能把同步解析移出主线程,虚拟列表只能减少 DOM;两者都不能保证任意 100MB 文件一定可打开。本文给出一个可运行的浏览器端架构,并明确它适合什么数据、何时应改用流式解析或服务端处理。
一、先区分四个瓶颈,避免只优化 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。
来源与延伸阅读
技术审校所依据的规范与权威参考资料。
相关文章
万行大文件代码对比性能优化:Web Worker 异步计算与 DOM 虚拟化分片渲染
讲解在前端对比万行大文件时,如何使用 Web Worker 隔离 CPU 密集 Diff 计算,并结合 DOM 虚拟化视口 (Virtualization) 解决主线程卡死问题。
踩坑避坑JSON 大整数精度丢失:Number 安全范围与解决方案
说明 JSON 大整数进入 JavaScript 后为什么会被舍入,演示 Number.MAX_SAFE_INTEGER 边界,并给出字符串契约、BigInt 转换和序列化的可验证方案。
错误排查JSON Unexpected token:8 类原因与定位
从原始响应、错误位置和 JSON 语法三层定位 JSON.parse 的 Unexpected token,覆盖 HTML 响应、重复解析、尾逗号、引号、控制字符、非法数字和截断数据。
继续阅读
可打开关联的浏览器工具,使用自己的样本验证文中的处理流程。
打开关联工具