纯静态 + Web Worker + 虚拟滚动:我是怎么让浏览器吃下 10MB JSON 不卡的

0 阅读6分钟

DevFormatLab:一个把所有计算都关进 Web Worker 的离线工具箱

一、起因:一次安全告警

每天打开工位的第一件事,是粘一段线上日志到某个 JSON 格式化网站。

直到有一天,安全部门在群里通报:某同事把带 access_token 的请求 body 贴进了一个 .top 域名的格式化站,被出口流量审计抓到,走了一遍合规流程。

那一刻我意识到两件事:

  1. 主流"野生工具站"对企业合规几乎都是不合格的——轻则记访问日志,重则后端落库;
  2. 即便是合规的站,遇到几 MB 的日志也大概率把浏览器卡到无响应。

周末我用 Next.js + Web Worker 攒了一个纯静态、可离线、把所有重活都关在 Worker 里的工具箱:DevFormatLab。这篇文章不吹效果,只复盘里面的几个技术选择,以及踩过的坑。


二、为什么是 Next.js Static Export

需求很明确:没有业务后端、没有数据库,只用静态 CDN 分发。这样在物理上断绝了"用户数据被服务端记录"的路径——因为根本没有服务端可以记录。

Next.js 的 App Router + output: 'export' 是目前体验最好的纯静态方案:能用上 React Server Components 的预渲染、文件路由、<Image> 优化,最终产物又是一堆纯 HTML/JS/CSS。

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  images: { unoptimized: true }, // 静态导出下 next/image 无法用默认 loader
  trailingSlash: true,           // Cloudflare Pages / GitHub Pages 上更稳
};
module.exports = nextConfig;

产物丢到 Cloudflare Pages,全球 CDN 命中,冷启动几十毫秒。带宽和运维成本接近为零,作为非盈利项目刚好。

需要诚实承认的一点:用户访问时拉到的 JS 仍然来自我的 CDN,理论上我可以"偷偷"在某个版本里加追踪。所以信任根其实还是落在「代码开源 + 你可以自己 fork 后部署到自己的 Cloudflare」上——这一点会放在仓库 README 里写明。


三、真正的性能瓶颈是什么

写 Worker 之前,先得搞清楚瓶颈到底在哪。我用 Chrome Performance 把 10MB JSON 走一遍流程录下来,结果是这样的:

阶段主线程耗时占比
JSON.parse~80ms5%
JSON.stringify(obj, null, 2)~120ms8%
语法高亮 tokenize~600ms40%
DOM 节点挂载~700ms47%

也就是说——JSON.parse 根本不是大头,高亮 + DOM 渲染才是真凶。很多文章上来就把 JSON.parse 扔进 Worker,其实优化的是个寂寞。

明白这点后,方案就清晰了:

  1. 能放 Worker 的纯计算全放进去:parse、stringify、tokenize、CSV/JSON 互转、diff;
  2. DOM 渲染必须留在主线程,但用虚拟滚动把节点数从 5w+ 砍到 30 个;
  3. 大字符串传递用 Transferable Objects,绕过结构化克隆的拷贝开销。

四、Worker 通信:别忽略 Transferable

大数据 postMessage 默认走「结构化克隆」,意味着 10MB 的字符串要被完整复制一份——主线程一份、Worker 一份,瞬时内存翻倍,序列化本身也耗时。

字符串本身没法 transfer,但底层 ArrayBuffer 可以。所以我把入口数据先编码成 Uint8Array,转移所有权过去,Worker 里再解码:

// main.ts
const worker = new Worker(new URL('./formatter.worker.ts', import.meta.url), {
  type: 'module',
});

export function formatJson(raw: string): Promise<string> {
  const buf = new TextEncoder().encode(raw).buffer;
  return new Promise((resolve, reject) => {
    const onMessage = (e: MessageEvent) => {
      worker.removeEventListener('message', onMessage);
      e.data.error ? reject(new Error(e.data.error)) : resolve(e.data.result);
    };
    worker.addEventListener('message', onMessage);
    // 第二个参数:把 buf 的所有权转移到 worker,零拷贝
    worker.postMessage({ type: 'FORMAT_JSON', buf }, [buf]);
  });
}
// formatter.worker.ts
self.onmessage = (e: MessageEvent) => {
  const { type, buf } = e.data;
  if (type !== 'FORMAT_JSON') return;
  try {
    const raw = new TextDecoder().decode(buf);
    const result = JSON.stringify(JSON.parse(raw), null, 2);
    self.postMessage({ result });
  } catch (err) {
    self.postMessage({ error: (err as Error).message });
  }
};

实测 10MB 输入下,主线程从 200ms 阻塞降到 <5ms。代价是 Worker 内存峰值会高一点,但用户感知层面已经完全无卡顿。

进阶选项是 SharedArrayBuffer,可以做到真正零拷贝双向共享,但需要部署侧配 Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp,对 Cloudflare Pages 这种纯静态托管来说配置成本偏高,目前没启用。


五、虚拟滚动:5w 行 DOM → 30 个 DOM

格式化后的 JSON 一旦展开有几万行,常规做法是一次性渲染所有 <div>,瞬间几百 MB 内存、滚动卡到怀疑人生。

最终用了 react-virtuoso,可视区外的行完全不挂载。配合 Worker 提前 tokenize 好的结果,列表项只做纯渲染,不再做任何字符串处理:

<Virtuoso
  data={tokenizedLines}            // Worker 输出的扁平化行数组
  itemContent={(_, line) => <HighlightedLine tokens={line} />}
  overscan={400}                   // 上下多渲染 400px,避免快速滚动出现白屏
  computeItemKey={(i) => i}
/>

这一步是整个项目里 ROI 最高的优化,单它一项就把"能用 / 不能用"的分水岭从几千行拉到了几十万行。


六、PWA:拔网线也能用

纯静态站接 PWA 几乎零成本。我用了 next-pwa,它内部走的是 Workbox,预缓存策略对 Next 静态产物开箱即用:

// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  skipWaiting: true,
});

module.exports = withPWA({
  output: 'export',
  images: { unoptimized: true },
  trailingSlash: true,
});

再补一份 public/manifest.json 声明应用元信息。第一次访问后整站会被 Service Worker 静默缓存到本地,之后即便断网,JSON 格式化、JWT 解码、Base64 转换全都照常工作——因为这些功能本来就不需要网络。

可以做个验证:装上之后开飞行模式刷新一下,整站照常打开。


七、踩过的坑

按踩坑严重程度列几个,给后来人避雷:

  1. next/image 在 static export 下默认 loader 直接报错:必须 images.unoptimized = true 或自己写 loader。
  2. Worker 里别用 window / document:构建时不会报错,运行时直接 ReferenceError。涉及到的第三方库要确认它能在 Web Worker 环境跑。
  3. new URL('./worker.ts', import.meta.url) 这种写法是 webpack/turbopack 识别 Worker 入口的"魔法" ,不能用变量拼,否则编译时找不到。
  4. Service Worker 的更新策略skipWaiting: true 配合用户当前打开的页面会强制刷新,对工具站可以接受;如果是文档站要慎用,体验会很奇怪。
  5. JWT 解码这种功能千万别"顺手"加一个"在线验签"的便利按钮:一旦验签就需要密钥,密钥要走网络发出去就破坏了整个"本地优先"承诺。一开始我差点犯这个错。

八、目前的状态

第一阶段上线的功能(全部在本地完成,不发起任何业务请求):

  • JSON 格式化 / 压缩 / 错误位置定位 / 双栏 diff
  • JWT 本地解码(不验签,不联网)
  • URL / Base64 / HTML 编解码
  • CSV ↔ JSON 互转

🔗 在线地址:devformatlab.com

后续会加的:YAML ↔ JSON、正则可视化测试、cron 表达式解释器。如果你有想加的工具或者在使用中发现 bug,欢迎在评论区或者站内反馈页留言。

如果这套思路(纯静态 + Worker + 虚拟滚动 + PWA)对你做自己的开发者工具有一点帮助,那这篇文章就值了。


写在最后

JUST DO IT!

文章里所有性能数字都来自我本地 M1 Pro + Chrome 122 的实测,仅供参考;不同设备和浏览器结果会有差异。