news 2026/10/15 14:31:30

五万个点画不动?一个 8.5 KB 的纯计算聚合,和 AI 给你的 HTML 该先过一道水

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
五万个点画不动?一个 8.5 KB 的纯计算聚合,和 AI 给你的 HTML 该先过一道水

午间十五分钟,今天四则:点聚合的算法库、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返回要素数其中簇散点单次耗时
82200.38 ms
10282800.18 ms
1110710700.18 ms
1242141740.38 ms
146711530614055.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 ms49.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>

三处和直觉相反的,值得记一下:

  1. 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"——注意前导空格,是拼接时的,介意的话先判空)。
  2. <form action="https://evil.example"><input name="pwd">原样通过,<video src>、<canvas>、<textarea>也留着。这不是漏洞,是它的威胁模型只管 XSS,不管"AI 给你塞了个长得像登录框的表单"。要挡就FORBID_TAGS: ['form','input'],实测同一段输入变成<p>搜索</p>。
  3. 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。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 14:31:14

收藏!小白程序员必看:用TaoToken统一Key打通AI Agent变现第一步

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 14:30:55

8款论文写作工具实测:从文献综述到查重降重全流程指南

1. 论文写作工具测评背景与价值作为一名经历过本科论文写作的过来人&#xff0c;我深知这个过程中的痛点&#xff1a;文献综述耗时、格式调整繁琐、查重压力大。现在市面上涌现了大量号称能"一键生成论文"的工具&#xff0c;但质量参差不齐。这次我精选了8款主流工具…

作者头像 李华
网站建设 2026/10/15 14:30:12

Qt界面开发:跨平台GUI框架核心技术与实践

1. Qt界面开发的核心价值与应用场景Qt作为一款跨平台的C图形用户界面应用程序框架&#xff0c;在工业界已经活跃了超过25年。我最早接触Qt是在2010年参与一个医疗影像处理项目&#xff0c;当时就被其信号槽机制和布局管理系统所折服。时至今日&#xff0c;Qt已经发展到6.5版本&…

作者头像 李华
网站建设 2026/10/15 14:30:10

嵌入式固件逆向:从095-12-5src.rar解包到CAN协议栈复用

简介&#xff1a;本资源为《冒险岛》095版本完整服务端源码&#xff0c;面向Java后端开发者、游戏服务器运维人员及MMORPG二次开发学习者&#xff0c;适用于搭建本地测试服、研究经典页游服务端架构或进行版本定制化改造。压缩包为RAR格式&#xff0c;体积832KB&#xff0c;虽未…

作者头像 李华
网站建设 2026/10/15 14:29:56

JCache API缓存加载机制与性能优化实践

1. JCache API中的缓存加载机制解析在Java缓存体系中&#xff0c;JCache&#xff08;JSR-107&#xff09;规范定义了标准的缓存操作接口&#xff0c;其中CacheLoader作为核心加载器组件&#xff0c;提供了load和loadAll这两个关键方法。它们与getAll的配合使用构成了完整的懒加…

作者头像 李华