大 GeoJSON 加载卡顿别急着换引擎:Web Worker 多线程把解析搬出 UI 线程
用浏览器打开一张全国行政区划地图,理论上最省事的做法是让主线程把整份 GeoJSON 一次性解析完。可真把全国省市区县geojson数据下载回来那批文件喂进去,首次解析要卡住页面一两秒——文本框不能输入、按钮点了没反应,体感就是"网页死了"。其实数据未必大到扛不动,问题出在你让 UI 线程一个人干了重活。把解析搬去 Web Worker,主线程只是收发消息,卡顿基本能消掉八成。这篇讲我踩过的路子和几个实打实的测量结果。
先弄清到底卡在哪一步
"加载大 GeoJSON 慢"这句话常常把两件事混在一起:一是网络传输,二是本地解析。前者是带宽和缓存问题,上一篇讲 Brotli 预压缩时已经算过账;这里说的是后者——JSON.parse 把几万要素的文本变成对象树,再遍历 geometry 坐标,这一段 CPU 是同步跑在主线程上的。测试里我拿一份含 30 万个子区的边界文件(约等于全国村级geojson数据下载回来压到一个文件的数量级),主线程裸跑 parse+遍历要 480ms 上下;页面一旦这会儿被拖住,后续任何滚动、点击都跟着掉帧。
| 处理项 | 主线程耗时 | Web Worker 耗时 | 主线程阻塞 |
|---|---|---|---|
| JSON.parse 20MB 边界文本 | ≈ 310ms | ≈ 305ms | 全程阻塞 |
| 遍历 geometry 提取 adcode | ≈ 170ms | ≈ 165ms | 全程阻塞 |
| 合并上两项 | ≈ 480ms | ≈ 470ms | 几乎为零 |
单看数字,Worker 并没让"这台机器算得更快",它俩吃同一块 CPU,耗时基本一样。价值全在最后那列:主线程不被占住,界面照常响应。用户感知的不是省了 10ms,而是"等数据时页面还能点、还能转圈、还能取消"。
把解析和分流全部搬进 worker
封装不复杂。我在构建期把一份全国文件按 adcode 前缀分块,worker 收到整批后挨个解析、归类,最后只把"某一层该要的要素子集"以及每块的 bbox 发回主线程,全文 20MB 里的大头几何从不回传主线程,避免二次拷贝卡出新的阻塞。
// main.js —— 只负责收发,不做解析
const worker = new Worker('./parse.worker.js', { type: 'module' });
worker.postMessage({ file: '/mapjson/national.min.json' }); // 传路径,不含二进制大对象
worker.onmessage = ({ data }) => {
if (data.type === 'subset') {
L.geoJSON(data.features, { style: byLevel(data.level) }).addTo(map);
}
};
worker 里把 fetch 拿到的 ArrayBuffer 原样转给 JSON.parse,解析完按 adcode 过滤、用 Transferable 把结果对象送回主线程。有一处细节容易漏:postMessage 的是引用还是拷贝,直接影响大数组的搬移成本。凡是几十 MB 的 TypedArray/buffer,务必用第二个参数 [buffer] 走转移语义,把所有权直接转手,别让结构化克隆再复制一份——那一步如果漏了,就白做了 worker。
拆分批次,别一次把 70 万个村全塞给一个线程
把全部村级要素一股脑抛进单个 worker,等于只换了个线程继续大活,卡是主线程不卡了,进度和取消依然难做。我的做法是按对象切片分多批:worker 每处理完一批就 postMessage 一个进度事件,主线程据此更新进度条、判断用户是否中途关闭图层。真正到数据量极端的场景——比如要把几十万村级边界跑一次空间归类——再上 worker 池,一个 worker 里再并行拆到 navigator.hardwareConcurrency 份,每份独立 parse 后合并结果。
// worker 池:拆成 4 批交错跑,避免单 worker 长期霸占
const BATCH = 4, parts = await Promise.all(
Array.from({ length: BATCH }, (_, k) => runChunk(chunks[k]))
);
postMessage({ type: 'done', features: parts.flat() });
这儿有个反直觉的观察:盲目提高并发不一定更快。JS 引擎对同一文件多份 parse 会有预热开销,单 worker 拆 4 线程带来的提升,通常到不了 4 倍,实测多在 2 倍上下,且页面更易显眼地吃满 CPU。对绝大多数下载站的交互图层,一个 worker + 分批进度就已经够用,别为炫技把并发拉满。我这份站点主要服务全国省市区县区划边界数据下载之后的在线预览,最后也是停在"1 个 worker + 分批"这套轻量配置上,没有再往上加码。
收尾与踩坑纪要
- 转手别漏
transfer:传大 buffer 不动用转移语义,主线程照样会被结构化克隆卡住,worker 白开。 - 认清收益本质:Worker 提升的是"可响应性"不是"绝对吞吐",拿耗时排第一指标会得出"没提升"的误判。
- 别把 UI 里才需要的 DOM 逻辑写进 worker:worker 没有
window/document,颜色分级、Leaflet 图层创建都应留在主线程,worker 只回干净的数据结果。 - 权限是站内免费预览的一部分,落到怎么花钱是在详情页——当你把一份预览满意的省区县边界拿去出报告或课堂上用,导出 GeoJSON/SHP/SVG 才走 ¥1.99/次 的按次付费,前端这层 worker 加载始终不碰订单链路,职责拆清,免得每次开地图都误触支付。
数据清洗这摊我习惯留一层"壳"在主线程:解析交给 worker,可一旦发现要快速叠个底图看效果,与其在 worker 里反复烧 CPU,不如去站点把这份边界转成预烘焙好的几何再拖进来。真要"拿去做课件、做汇报底图、还要能一省一市单独改色"的最终用途,多线程优化解决的是体验,不是图形好不好改——那种每块都能独立选中的色块,往往需要把边界拆成独立编辑单元。真到这一步,与其总从全国文件开工,不妨走 geojsoncn 的地图 PPT 模板页 按省、按市下钻挑一张可编辑模板,预览满意再下载,幻灯片里直接改色,连重复解析都省了。
把话收回来:给大 GeoJSON 提速不一定先换引擎或者改格式,很多时候只要把解析从 UI 线程挪到 Web Worker,体验就从"页面死掉一秒"变成"悄悄加载"。极致的再上分拆和 transfer,一般的,一个 worker 加进度反馈就到头了——凡做 GeoJSON 下载后的在线预览,这套都通用。