午间十五分钟,今天四则:点聚合的算法库、Vue 2 里一笔看不见的内存开销、把 AI 输出的 HTML 洗干净再往页面上塞,以及时间戳那个"JS 以为自己没 2038 问题"的误会。所有数字都是我自己跑出来的,跑不通的写法也一并写上了。
supercluster:五万个点,建索引 142 ms,查一次 0.2 ms
轨迹回放、POI 搜索这种场景,点一多就卡。supercluster(Mapbox 那一系)是个纯计算库:你给它一堆 GeoJSON 点,它在内存里建一棵多层聚合树,你问它"这个视野、这个 zoom 级该画几个 marker",它返回聚合结果。npm 上最新是 9.1.0,发布日期 2026-09-03,上周(10-06 到 10-12)下载量 8,429,549 次——数据来自https://api.npmjs.org/downloads/point/last-week/supercluster,浏览器直接打开就是一行 JSON。
我在杭州周边随机造了 5 万个点实测:
importSuperclusterfrom'supercluster';// v9 只能 import,不能 require(下面细说)// points = 5 万个 { type:'Feature', properties:{id,name,cat}, geometry:{type:'Point',coordinates:[lng,lat]} }constidx=newSupercluster({radius:40,maxZoom:16,minPoints:3});idx.load(points);// 建索引,141.8 msidx.getClusters([120.08,30.18,120.28,30.34],11);// 查询,0.18 ms查询结果(同一个视野,只换 zoom):
| zoom | 返回要素数 | 其中簇 | 散点 | 单次耗时 |
|---|---|---|---|---|
| 8 | 2 | 2 | 0 | 0.38 ms |
| 10 | 28 | 28 | 0 | 0.18 ms |
| 11 | 107 | 107 | 0 | 0.18 ms |
| 12 | 421 | 417 | 4 | 0.38 ms |
| 14 | 6711 | 5306 | 1405 | 5.70 ms |
也就是说 zoom 11 时,5 万个点你只需要画 107 个 marker。点某个簇要看细节,三个 API 够用,合计 0.51 ms:
constp=clusterFeature.properties;// { cluster: true, cluster_id: <整数>, point_count: 1022, ... }idx.getClusterExpansionZoom(p.cluster_id);// -> 12,即"放大到这个级别它就散开"idx.getChildren(p.cluster_id);// -> 7 个下级要素idx.getLeaves(p.cluster_id,20,0);// -> 原始点,第 1 个是 {id:31861,name:'POI31861'} [120.21947, 30.2023]几个踩过的坑,直接说结论:
- v9 是 ESM-only。
package.json里"type": "module"且"exports": "./index.js",所以我require('supercluster')时直接拿到ERR_REQUIRE_ESM。老 webpack 项目要么用import,要么引它自带的 UMD 那份dist/supercluster.js:32088 B,gzip 后 8535 B。走 ESM 源码的话是 supercluster 18815 B(gzip 5348 B)+ 依赖 kdbush 12973 B(gzip 3358 B),依赖只有这一个(外加@types/geojson)。 - 方法名别靠直觉。我一开始写成
getClusterChildren(),回报TypeError: idx.getClusterChildren is not a function。正确的是getChildren(clusterId)和getLeaves(clusterId, limit = 10, offset = 0)(这个签名是它 README 里写的)。 - 它不碰 DOM。我 grep 过
index.js,里面没有document/window.,所以完全能塞进 Web Worker:主线程只做"把 Worker 算好的簇丢给地图",5 万点的索引那 142 ms 就不占首屏了。
Object.freeze:Vue 2 拿到两万行数据时,先"复印"了 49 MB
这个应该很多人知道,但知道程度大概停在"加上好像会快点"。我用 vue 2.7.16 + jsdom 24.1.3(Node v20.13.1)测了 2 万行 POI 数据(每行 6 个字段):
constlist=Array.from({length:20000},(_,i)=>({id:'poi'+i,lng:120.1+i%1000*0.0002,lat:30.2+i%700*0.0002,name:'门店'+i,cat:i%5,visible:true,}));newVue({data:{list}});// 什么都不做,只是把它塞进 data| 写法 | new Vue({data})耗时 | heapUsed 增量 | 数组上有__ob__ | 首行有__ob__ |
|---|---|---|---|---|
| 原样 | 129.9 ms | 49.1 MB | 有 | 有 |
list.forEach(Object.freeze) | 3.3 ms | 约 0 | 有 | 没有 |
Object.freeze(list) | 0.1 ms | 约 0 | 没有 | 没有 |
Vue 2 的 Observer 会把每个对象的每个字段都定义成 getter/setter 并挂上 Dep,两万行就是四十万个响应式属性——那 49 MB 和 130 ms 花在这儿。冻结之后它直接跳过,行为差别也很具体:
- 冻行(推荐):数组本身还能响应
push(length的 watcher 实测触发 1 次,新推进去的对象照常变响应式),但行内字段不再是响应式,改了不重渲染。 - 冻整串:连数组都不可扩展,
vm.list.push(...)抛TypeError: Cannot add property 100, object is not extensible。而且 Vue 一句警告都没有——我挂了Vue.config.warnHandler,抓到 0 条。也就是说这条路上出问题,控制台是干净的。 - 改冻结行的字段会不会静默失败?看严格模式。我的测试脚本第一版是 CJS 非严格模式,赋值静默失败;第二版加了
'use strict'(SFC 和 ESM 都是严格模式),当场抛:TypeError: Cannot assign to read only property 'cat' of object '#<Object>'。所以别指望"改了不生效",在.vue文件里它是直接报错。
还有个更容易误会的点:冻结省的是响应式,不省重渲染。v-for 两万行时,改一个跟数据无关的selectedId,render watcher 照样重跑:
plain 改 list[0].cat -> 140.2 ms(nextTick flush 完) freezeRows 改 list[0].cat -> 抛错,改不动 freezeRows 改 selectedId -> 48.3 ms(两万 vnode 重建,跑不掉)首屏$mount(两万<li>)从 410.4 ms 降到 343.9 ms,只省一成多——DOM 那部分才是大头。所以这招真正的收益在初始化时间和内存占用上,落地姿势是:轨迹点、POI 列表、表格只读数据冻行;选中/高亮状态放外面(selectedId+ computed 查),别往每一行塞visible。
DOMPurify:AI 输出的 HTML 是"别人写的字符串",v-html 之前洗一遍
现在的常见写法是让大模型返回带标签的 HTML/SVG,前端v-html一插了事。这一步等价于"把陌生人发的 HTML 直接 innerHTML",所以该过一道 sanitizer。DOMPurify3.4.17 是 2026-10-12 发的(三天前),上周下载量 71,623,117 次,浏览器版dist/purify.min.js29828 B,gzip 后 11773 B。license 是(MPL-2.0 OR Apache-2.0),运行时依赖只有@types/trusted-types。
我把几种典型载荷在真实 Chrome(150.0.7871.114)里跑了一遍,结果分两类。
被处理掉的,符合直觉:
<img src="x" onerror="alert(1)"> -> <img src="x"> <svg onload="alert(1)">…</svg> -> onload 没了,svg 和 circle 留着 <a href="javascript:alert(1)">go -> <a>go</a> (data: 协议同理) <iframe> <object> <embed> <link> <meta http-equiv=refresh> <base> -> 整个删掉 <div onclick="alert(1)"> -> <div> <button formaction="https://evil"> -> <button> <noscript><p title="</noscript><img src=x onerror=alert(1)>"> -> <p>x</p>三处和直觉相反的,值得记一下:
target="_blank"默认被删。<a href="https://ok.example" target="_blank">出来只剩<a href="https://ok.example">。AI 生成的 markdown 链接想开新窗口,得自己ADD_ATTR: ['target'],顺带补rel(用addHook('afterSanitizeAttributes', ...)加noopener noreferrer,实测出来的确是rel=" noopener noreferrer"——注意前导空格,是拼接时的,介意的话先判空)。<form action="https://evil.example"><input name="pwd">原样通过,<video src>、<canvas>、<textarea>也留着。这不是漏洞,是它的威胁模型只管 XSS,不管"AI 给你塞了个长得像登录框的表单"。要挡就FORBID_TAGS: ['form','input'],实测同一段输入变成<p>搜索</p>。style属性默认保留,<div style="color:red">原样出来,连width:expression(alert(1))都过了(那玩意只有 IE 认)。想收紧只能用ALLOWED_ATTR白名单收窄。
正常 AI 回答基本无损:<h3> <ul> <li> <b> <table> <tr> <td> <pre> <code>全留,class="markdown-body"、id、data-*、aria-*也全留。唯一的变化是它会补一个<tbody>,所以别对净化结果做逐字符断言或快照测试。
另外SAFE_FOR_TEMPLATES: true千万别顺手开:实测它把<p>你好 {{msg}}</p>净化成了<p> </p>——整段文本直接没了。它是给"HTML 会被当模板编译"的场景准备的,v-html不是那种场景。
流式渲染要注意成本。DOMPurify.sanitize('<b>ok</b>')在浏览器里 1000 次 237.3 ms(单次约 0.24 ms),看着很便宜;贵的是"每来一帧就把到目前为止的整段重洗一遍"。Node + jsdom 里模拟 60 帧 SSE、末帧 8265 B:
每帧都净化整段 -> 235.7 ms 每 10 帧净化一次 -> 24.3 ms 只在 [DONE] 后净化一次 -> 5.8 ms(三者最终结果一致)所以姿势是:打字机过程中直接渲染纯文本(或只在节流后洗),流结束再 sanitize 一次上屏。27 KB 的整段,浏览器里实测约 37 ms,一次就够。
用法(浏览器里不需要 jsdom,一行):
importDOMPurifyfrom'dompurify';computed:{safeAnswer(){returnDOMPurify.sanitize(this.aiHtml,{ADD_ATTR:['target'],FORBID_TAGS:['form','input','video','audio'],});},}想在 Node 里跑单测的话得挂 jsdom:createDOMPurify(new JSDOM('').window)。顺便一个坑:jsdom 30.1.2 在 Node 20.13.1 上require()会因html-encoding-sniffer引 ESM 包@exodus/bytes而报ERR_REQUIRE_ESM,我退回 24.1.3 才跑通。
冷知识:JS 没有 2038 问题,但你的时间戳有
Date.now()现在是 13 位(我跑的时候是1792039211539),而后端很常见的秒级时间戳是 10 位。位数搞错的后果,实测:
newDate(1791866086)// 把 10 位秒当 13 位毫秒 -> 1970/1/22 01:44:26(上海)newDate(1792039211539*1000)// 反过来 -> 58751/12/24 03:33:58,而且不报错第二个才是阴间的:JS 的 Date 用 64 位毫秒,允许范围到8.64e15,对应+275760-09-13T00:00:00.000Z,再多 1 ms 才Invalid Date。所以"公元 58751 年"在 JS 里是一个合法日期,它能安静地走完你整条链路,最后在图表 x 轴上炸。
而 2038 问题本身是 32 位有符号整型的边界:2**31 - 1 = 2147483647秒对应2038-01-19T03:14:07.000Z,再一秒就装不下。JS 自己不截断,但时间戳只要流过任何 int32 通道(位运算、二进制协议里的 4 字节字段、后端的 int 列),它就回来了:
newDate(Date.now()|0)// -> 1970/1/13 08:17:29(上海)(2**31*1000)|0// -> 0,于是 new Date(...) 给出 1970-01-01T00:00:00.000Z顺便,判断一串数字是秒还是毫秒,比猜更稳的办法是看位数或者做量级比较(ts > 1e12 ? ts : ts * 1000),这个写法在 2286 年之前都成立——按 JS 的上限,你不用担心那天有人真跑到。
最后
今天四则有个共同点:开销和风险都藏在你看不见的那一层。supercluster 把 5 万点藏成 107 个 marker,代价是一次 142 ms 的索引;Vue 把 2 万行数据藏成"随便用",代价是四十万个 getter 和 49 MB;DOMPurify 默认白名单藏了 form 和 target,得读一遍才知道边界在哪;JS 把时间戳藏成 64 位毫秒,于是 2038 换了个地方出现。
这类东西不值得背,值得测:手边有 Node 就跑十行,比查帖子快,也不会被过期答案骗。下次可以试试把你最大那张列表的数据forEach(Object.freeze)一下,看首屏省多少——我这儿是 130 ms 变成 3.3 ms。