DevFormatLab:一个把所有计算都关进 Web Worker 的离线工具箱
一、起因:一次安全告警
每天打开工位的第一件事,是粘一段线上日志到某个 JSON 格式化网站。
直到有一天,安全部门在群里通报:某同事把带 access_token 的请求 body 贴进了一个 .top 域名的格式化站,被出口流量审计抓到,走了一遍合规流程。
那一刻我意识到两件事:
- 主流"野生工具站"对企业合规几乎都是不合格的------轻则记访问日志,重则后端落库;
- 即便是合规的站,遇到几 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。
java
// 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 |
~80ms | 5% |
JSON.stringify(obj, null, 2) |
~120ms | 8% |
| 语法高亮 tokenize | ~600ms | 40% |
| DOM 节点挂载 | ~700ms | 47% |
也就是说------JSON.parse 根本不是大头,高亮 + DOM 渲染才是真凶 。很多文章上来就把 JSON.parse 扔进 Worker,其实优化的是个寂寞。
明白这点后,方案就清晰了:
- 能放 Worker 的纯计算全放进去:parse、stringify、tokenize、CSV/JSON 互转、diff;
- DOM 渲染必须留在主线程,但用虚拟滚动把节点数从 5w+ 砍到 30 个;
- 大字符串传递用 Transferable Objects,绕过结构化克隆的拷贝开销。
四、Worker 通信:别忽略 Transferable
大数据 postMessage 默认走「结构化克隆」,意味着 10MB 的字符串要被完整复制一份------主线程一份、Worker 一份,瞬时内存翻倍,序列化本身也耗时。
字符串本身没法 transfer,但底层 ArrayBuffer 可以 。所以我把入口数据先编码成 Uint8Array,转移所有权过去,Worker 里再解码:
typescript
// 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]);
});
}
typescript
// 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 好的结果,列表项只做纯渲染,不再做任何字符串处理:
ini
<Virtuoso
data={tokenizedLines} // Worker 输出的扁平化行数组
itemContent={(_, line) => <HighlightedLine tokens={line} />}
overscan={400} // 上下多渲染 400px,避免快速滚动出现白屏
computeItemKey={(i) => i}
/>
这一步是整个项目里 ROI 最高的优化,单它一项就把"能用 / 不能用"的分水岭从几千行拉到了几十万行。
六、PWA:拔网线也能用
纯静态站接 PWA 几乎零成本。我用了 next-pwa,它内部走的是 Workbox,预缓存策略对 Next 静态产物开箱即用:
php
// 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 转换全都照常工作------因为这些功能本来就不需要网络。
可以做个验证:装上之后开飞行模式刷新一下,整站照常打开。
七、踩过的坑
按踩坑严重程度列几个,给后来人避雷:
next/image在 static export 下默认 loader 直接报错 :必须images.unoptimized = true或自己写 loader。- Worker 里别用
window/document:构建时不会报错,运行时直接ReferenceError。涉及到的第三方库要确认它能在 Web Worker 环境跑。 new URL('./worker.ts', import.meta.url)这种写法是 webpack/turbopack 识别 Worker 入口的"魔法" ,不能用变量拼,否则编译时找不到。- Service Worker 的更新策略 :
skipWaiting: true配合用户当前打开的页面会强制刷新,对工具站可以接受;如果是文档站要慎用,体验会很奇怪。 - JWT 解码这种功能千万别"顺手"加一个"在线验签"的便利按钮:一旦验签就需要密钥,密钥要走网络发出去就破坏了整个"本地优先"承诺。一开始我差点犯这个错。
八、目前的状态
第一阶段上线的功能(全部在本地完成,不发起任何业务请求):
- JSON 格式化 / 压缩 / 错误位置定位 / 双栏 diff
- JWT 本地解码(不验签,不联网)
- URL / Base64 / HTML 编解码
- CSV ↔ JSON 互转
🔗 在线地址:devformatlab.com
后续会加的:YAML ↔ JSON、正则可视化测试、cron 表达式解释器。如果你有想加的工具或者在使用中发现 bug,欢迎在评论区或者站内反馈页留言。
如果这套思路(纯静态 + Worker + 虚拟滚动 + PWA)对你做自己的开发者工具有一点帮助,那这篇文章就值了。
写在最后
JUST DO IT!
文章里所有性能数字都来自我本地 M1 Pro + Chrome 122 的实测,仅供参考;不同设备和浏览器结果会有差异。