
去年接手一个后台报表模块接口一次性吐出八万多条明细页面打开后风扇狂转滚动条像被人拽住点一下筛选按钮要愣三秒才响应。当时第一反应是循环写得太笨了于是花半天把forEach换成for、把filter换成手写循环结果只快了不到一成。那次之后我才真正意识到JavaScript 高效处理大型数据集这件事瓶颈极少长在你以为的地方。真正拖垮页面的往往是主线程被长时间占用导致渲染掉帧、内存里反复拷贝产生的额外开销、以及 GC 在关键时刻出来插一脚。这篇文章就是把这几年在报表、日志分析、批量导入、图数据渲染这类场景里攒下来的东西整理一遍数据进内存该怎么选结构、计算怎么切、计算怎么搬出去、渲染怎么只画看得见的部分、内存怎么别自己给自己挖坑。适合已经写过一些业务代码、但一碰到十万级数据就开始发怵的前端同学也适合后端转过来写页面、对浏览器运行机制还不太熟的人。1. 先把慢定位清楚大型数据集在JS里到底卡在哪一层1.1 四种完全不同的瓶颈别混为一谈我见过太多人把页面卡当成一个笼统的问题去治结果东改一榔头西改一棒子性能没上去代码倒是先烂了。实际上大型数据集引发的卡顿至少分四层每层的解法完全不同甚至互相冲突。第一层是计算耗时。比如对十万条记录做多字段排序、分组聚合、模糊匹配过滤。这层的问题特征是CPU 占用高但页面可能还能滚只是点了按钮没反应。第二层是主线程阻塞导致的渲染掉帧。JS 是单线程的一段跑满 800 毫秒的同步代码执行期间浏览器根本没法做样式计算和绘制滚动、动画、输入框光标闪烁全部冻住。这层的特征是只要那段代码不结束整个页面就是一张静态图。第三层是内存压力与垃圾回收。数据量大到一定程度频繁创建临时数组、临时对象会让垃圾回收器反复启动表现为规律的、周期性的卡顿尖峰间隔几百毫秒来一次。第四层是DOM 节点数量爆炸。哪怕计算只花 20 毫秒一次性往页面塞五万个节点布局和绘制照样能让你等到怀疑人生。我一般会先问自己一句这段代码是算得久还是算完之后画得久分不清这两件事后面的优化基本靠碰运气。1.2 用最小成本先量一遍三段式打点法不要一上来就打开性能面板录一段几十秒的火焰图信息量大到反而看不出重点。我的习惯是先做粗粒度打点把一次操作的耗时拆成三段数据准备、核心计算、上屏渲染。const t0 performance.now(); // 第一段数据准备解析、清洗、结构转换 const rows normalize(rawList); const t1 performance.now(); // 第二段核心计算排序、过滤、聚合 const result aggregate(rows); const t2 performance.now(); // 第三段上屏DOM 构建与插入 render(result); const t3 performance.now(); console.table({ prepare: (t1 - t0).toFixed(1), compute: (t2 - t1).toFixed(1), render: (t3 - t2).toFixed(1), });这十几个字符的成本能直接告诉你该往哪个方向使劲。我在一个日志检索页面上的实测结果是准备 40ms、计算 160ms、渲染 1900ms。也就是说计算优化到极致也只能省 8% 的体感时间真正该动的是渲染。反过来在另一个场景里渲染只有 60ms但计算花了 1200ms那就得往算法和数据结构上想。打点位置要放在一次完整用户操作的边界上而不是散落在各个函数里。散点打点看着细致实际上拼不出因果链。1.3 一个反直觉的结论多数卡顿来自渲染而非计算这条经验我反复验证过好几次。人对计算慢的容忍度其实比想象中高只要页面还能滚、还有加载态在动等个一两秒是可以接受的。但一旦主线程被占死滚动条拖不动、按钮点下去没反馈用户立刻就会觉得这破系统卡死了。所以优化的优先级通常是先保证主线程不被长时间独占哪怕总耗时不变再降低总耗时最后才是抠算法常数。这个顺序和很多人的直觉是反的——很多人上来就研究怎么把 O(n²) 降到 O(n log n)却忽略了那个 O(n log n) 如果一口气跑 900 毫秒体验依然是灾难。还有一个隐蔽的点console.log打印大数组在开发者工具打开时会显著拖慢执行速度因为控制台需要序列化并保留引用。我踩过一次一段代码在开了工具的情况下跑了 3 秒关掉工具只要 400 毫秒。调试完记得把日志清掉或者用console.time这种轻量方式。2. 数据进内存的第一步就决定了上限结构与拷贝策略2.1 数组不是唯一答案TypedArray 与普通数组的边界很多人默认数据就是对象数组[{id, name, value}, ...]。十万条这种结构光是对象头和指针的开销就非常可观而且每个对象的属性访问都要走一次形状查找。如果你的数据是同构的、数值为主的比如时间序列、坐标点、指标采样值那用类型化数组存列式数据会快得多。具体做法是把行式改成列式。原来十万个对象每个对象四个字段现在变成四个数组每个数组十万个元素。取值从rows[i].value变成values[i]少了一次对象属性查找更重要的是如果这些数值用Float64Array存内存是连续排布的遍历时的缓存命中率高得多。// 行式十万个小对象 const rows raw.map(r ({ ts: r.t, v: r.v, tag: r.g })); // 列式定长连续内存 const n raw.length; const ts new Float64Array(n); const v new Float64Array(n); const tagIdx new Uint16Array(n); for (let i 0; i n; i) { ts[i] raw[i].t; v[i] raw[i].v; tagIdx[i] tagDict.get(raw[i].g); }边界在哪只要字段里有字符串、有可变结构、有嵌套对象列式就得不偿失。我一般只对数值列 少量枚举列用这套文本列老老实实放普通数组或者做一次字典编码——把字符串映射成整数下标用Uint16Array存下标需要显示时再反查。这一步在字符串去重场景里收益极高同一个标签重复出现十万次字符串本身只存一份。2.2 对象数组的隐性开销形状一致比字段多少更重要如果确实要用对象数组有个细节值得注意尽量让所有对象拥有相同的属性集合并且按相同顺序初始化。JS 引擎为了加速属性访问会给对象建立形状业内也叫隐藏类形状相同的对象走同一套访问路径速度接近数组下标一旦某些对象多一个字段、少一个字段或者用delete删过属性形状就被打破访问退化成哈希查找。我见过一段代码先创建空对象再按条件逐个挂字段// 不推荐 const item {}; item.id r.id; if (r.extra) item.extra r.extra; item.value r.value;十万次循环下来会产生好几种不同形状的对象后续遍历时引擎无法走快路径。改成一个构造函数统一初始化把不存在的字段显式赋null速度差别在大数据量下能到两三成。// 推荐形状统一 function makeItem(id, extra, value) { return { id, extra: extra ?? null, value }; }这个优化的收益不体现在单条数据上而是体现在十万次乘以每次几十纳秒的累积效应上。2.3 深拷贝是性能黑洞什么时候必须、什么时候绝对不要大型数据集处理里最冤的性能损失来自无意识的深拷贝。典型写法是JSON.parse(JSON.stringify(data))或者用展开运算符{...row}在map里逐条复制。前者在十万条数据上轻松吃掉几百毫秒后者稍好但也不便宜。判断标准其实很简单下游会不会修改这份数据如果只是读把引用传下去就行。如果下游要改但改的是少数几条那就用不可变更新的思路只复制被改动的那条和它的父链。只有当下游需要整体变形且不能污染原数据时才值得整体拷贝一次而且这个时候优先用结构化克隆或者专门写一个浅层重建函数别用序列化兜底。// 只读场景直接传引用零成本 process(rows); // 局部修改只复制受影响的那一条 const next rows.slice(); next[index] { ...rows[index], value: newValue };还有一个容易被忽略的点Array.prototype.sort是原地排序会改变原数组。如果你在不能污染原始数据的地方直接rows.sort(...)那就等于强制自己先做一次全量复制。想清楚这个约束是先天的还是自己加上去的能省一次拷贝。2.4 Map 与 Set 在去重和索引场景里的量级差异需要按某个键快速查找记录时用array.find()在十万条里找一条复杂度 O(n)如果外层还要循环一千次总量就是一亿次比较妥妥的卡死。换成Map建一次索引查询降到 O(1)总成本从一亿次比较变成十万次插入加一千次查询。// 建一次索引 const byId new Map(); for (const row of rows) byId.set(row.id, row); // 后续 O(1) 查询 const target byId.get(someId);类似的还有多字段联合查询、去重统计。去重我用Set配合一个签名函数把多个字段拼成字符串如果字段里有对象拼字符串反而更慢那就用嵌套Map或者把对象做一次 ID 归一化。这里没有银弹得看数据形状。经验法则是只要出现了嵌套循环里做查找就该考虑建索引。结构适用场景不适用场景普通对象数组行式数据、字段异构、需要整体序列化纯数值大规模遍历TypedArray 列式数值采样、坐标、时间序列含字符串或嵌套结构Map 索引按主键/联合键高频查找一次性顺序遍历Set去重、存在性判断需要保留原始顺序且频繁增删3. 让主线程喘气分块、时间切片与可中断任务3.1 为什么 setTimeout 0 不等于让出主线程很多教程会说用setTimeout(fn, 0)把任务拆开就不卡了。这句话只对了一半。setTimeout确实会把回调推到下一个宏任务但浏览器对嵌套调用的最小延迟有钳制连续嵌套几层之后每次至少间隔 4 毫秒左右。如果你把一个十万次循环拆成一千批、每批一百次光调度开销就是 4 秒比一次性跑完还慢。正确的拆法是让每批的大小跟批次数量相乘尽量接近总时长而不是固定批大小。这就引出了时间切片不是按条数切而是按毫秒预算切。function runInSlices(items, budgetMs, onProgress) { let i 0; const total items.length; function step() { const start performance.now(); while (i total performance.now() - start budgetMs) { process(items[i]); i; } onProgress(i / total); if (i total) { // 优先让出给渲染其次才是下一个切片 setTimeout(step, 0); } } step(); }预算设多少我的经验值是 8 到 16 毫秒。低于 8 毫秒调度开销占比太高高于 16 毫秒一帧就保不住了滚动还是会顿。这个区间正好卡在一帧 16.7 毫秒的边界上留一点余量给浏览器的样式计算和绘制。3.2 时间切片的具体实现把预算花在刀刃上上面那段代码有两个可以再抠的细节。第一个是**performance.now()本身的调用成本**。它在循环里每轮都调一次虽然单次只有几十纳秒但十万轮下来也有几毫秒。优化方式是把检查频率降下来比如每处理 500 条检查一次时间function runInSlices(items, budgetMs) { let i 0; const total items.length; const CHECK_EVERY 500; function step() { const start performance.now(); while (i total) { process(items[i]); if (i % CHECK_EVERY 0 performance.now() - start budgetMs) break; } if (i total) setTimeout(step, 0); } step(); }第二个是优先级问题。setTimeout排的是宏任务它和用户输入事件的竞争关系并不理想。用户拖滚动条时产生的输入事件优先级更高但你的切片任务仍会持续争抢。更友好的做法是用MessageChannel做零延迟的任务让渡它在事件循环里的调度比setTimeout更及时也不会被嵌套钳制const channel new MessageChannel(); let pending null; channel.port1.onmessage () pending pending(); function yieldToMain() { return new Promise(resolve { pending resolve; channel.port2.postMessage(null); }); }不过这里要提醒一句MessageChannel的让渡粒度很细如果一个切片只跑 2 毫秒频繁让渡反而增加开销。我一般只在预算用完、需要还给渲染的节点用它而不是每处理一条数据就让渡一次。3.3 requestIdleCallback 的适用边界与容易踩的坑requestIdleCallback听起来像是为这种场景量身定做的浏览器空闲时执行忙的时候自动推迟。但它有两个硬伤用之前必须知道。第一它不保证及时执行。页面持续有动画或者高频交互时空闲回调可能几百毫秒都不触发一次。如果你的任务是用户点了按钮必须在一秒内出结果用它就会变成点了没反应。第二单次空闲窗口很短。回调里拿到的deadline.timeRemaining()通常只有几毫秒而且上限是 50 毫秒。指望在回调里跑完一大段是不现实的必须自己再套一层切片。我的用法是非紧急的后台任务用它用户可感知的任务不用它。比如页面加载完之后预热一份统计数据、预建索引这种没人盯着看的活儿适合requestIdleCallback用户主动触发的筛选、排序老老实实用时间切片加进度反馈。3.4 可中断任务的进度反馈设计只要任务被切片了就一定会有跑了一半的中间状态。这时候如果不给反馈用户会以为没点上反复点击结果触发多份并行的切片任务互相争抢反而更慢。我的做法是加两个东西一个取消令牌一个轻量进度条。取消令牌就是一个自增的版本号每次新任务开始时版本号加一切片循环里比对版本号不一致就直接放弃当前循环。let taskVersion 0; function startTask() { const myVersion taskVersion; let i 0; function step() { if (myVersion ! taskVersion) return; // 已被更新的任务取代 const start performance.now(); while (i total performance.now() - start 12) { process(items[i]); } if (i total) setTimeout(step, 0); } step(); }进度条不要每批都更新 DOM那本身又是开销。设一个阈值比如进度变化超过 5% 才写一次写入方式用transform或者width都行别改那些会触发整页重排的属性。提示切片任务一定要处理数据在切片过程中被外部修改的情况。我踩过一次切片进行到一半用户切换了筛选条件底层数组被替换切片还在按旧引用跑最后上屏的是两份数据的混合体排查了很久。4. 把计算搬出去Worker 的分工方式与数据传递成本4.1 结构化克隆的真实代价用 Worker 搬计算的直觉是对的但很多人搬完之后发现好像更慢了原因多半在数据传递上。postMessage传对象时走的是结构化克隆复杂度与数据量线性相关而且是在两边的线程里都要做一次序列化/反序列化。十万条普通对象传一次几百毫秒就没了还不如不搬。这里有个关键认知搬计算的收益 计算耗时 - 传输耗时。如果计算只花 100 毫秒传输就要 300 毫秒那搬出去纯粹是自找麻烦。这套方案只适用于计算耗时明显大于传输成本的场景比如图像逐像素处理、大矩阵运算、复杂排序聚合。// 高成本普通对象数组走结构化克隆 worker.postMessage({ rows }); // 低成本ArrayBuffer 转移所有权零拷贝 const buffer floatArray.buffer; worker.postMessage({ buffer }, [buffer]); // 第二个参数是转移列表注意转移之后主线程这边的buffer会被置空长度变成 0不能再读。这个细节坑过不少人转移完还在主线程里访问原数组得到的是空数组而且不报错静默出错最难查。4.2 Transferable 与共享内存的取舍带转移列表的ArrayBuffer是最划算的传递方式代价接近于零。但它有个前提数据得是能被放进 buffer 的也就是数值型、定长、能列式化。这就又回到了第 2 节讲的列式存储——这两件事是配套的。共享内存SharedArrayBuffer能进一步省掉来回传递但启用条件比较严格需要页面响应头配合做跨源隔离部署环节容易出问题。而且共享就意味着要自己处理并发读写稍不注意就出数据竞争。我目前的实践是能用转移就用转移实在需要双向高频访问同一块数据时才考虑共享内存并且一定要在方案设计阶段就把读写边界划清楚不然调试成本远超收益。4.3 Worker 池任务粒度决定收益单个 Worker 只能串行处理要并行就得开池子。但池子不是越大越好。CPU 核心数决定了真正的并行度开八个 Worker 跑在四核机器上多的那几个只会在队列里排队还多占内存。function createPool(size) { const workers Array.from({ length: size }, () ({ worker: new Worker(./calc.js), busy: false, })); return { run(payload) { return new Promise((resolve, reject) { const slot workers.find(w !w.busy) || null; if (!slot) return reject(new Error(pool full)); slot.busy true; slot.worker.onmessage e { slot.busy false; resolve(e.data); }; slot.worker.onerror e { slot.busy false; reject(e); }; slot.worker.postMessage(payload); }); }, }; }池子大小我一般取navigator.hardwareConcurrency减一留一个核给主线程。任务粒度上单任务最好在 20 毫秒以上、200 毫秒以下太小了调度开销占比高太大了负载不均最后会出现七个 Worker 都闲着等最后一个跑完的场面。4.4 一个常被忽略的问题Worker 里的异常与任务丢失Worker 里抛出的异常不会冒泡到主线程只会触发worker.onerror而且默认不会终止 Worker。如果不挂这个监听表现就是任务发出去没回来Promise 永远挂起页面卡在加载态。还有一种情况是 Worker 脚本加载失败路径错了、语法错误这时 Worker 对象虽然建出来了但永远不回消息。稳妥的做法是加超时function runWithTimeout(worker, payload, ms) { return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(worker timeout)), ms); worker.onmessage e { clearTimeout(timer); resolve(e.data); }; worker.onerror e { clearTimeout(timer); reject(e); }; worker.postMessage(payload); }); }线程间通信还有一个坑消息顺序。同一个 Worker 收到多条消息是按序处理的但如果你在池子里复用了同一个 Worker 实例给不同任务回调覆盖就会导致结果错配。上面的池子写法里用busy标记互斥就是为了避免这个。5. 只在需要时渲染可视窗口与增量上屏5.1 虚拟列表的核心难点不是滚动计算而是测量虚拟列表的原理大家都懂只渲染可视区域加前后缓冲的几十行滚动时根据scrollTop算出起止索引。真正难的是行高不确定的场景。固定行高时startIndex Math.floor(scrollTop / rowHeight)一行代码搞定但业务里的表格经常有换行文本、可变高度的单元格这时你得先测量。我的处理策略分两档。数据量在五万以内、行高变化不大的用采样估算 滚动时修正先渲染前 50 行量出平均高度把总高度按平均值算出来当占位滚动过程中发现偏差再逐步修正偏移量。数据量更大、行高差异明显的老老实实用动态测量加缓存每行渲染完把实际高度记进一个数组同时维护一份前缀和用于二分查找定位。// 前缀和 二分查找定位起始行 function findStart(prefix, scrollTop) { let lo 0, hi prefix.length - 1; while (lo hi) { const mid (lo hi) 1; if (prefix[mid] scrollTop) lo mid 1; else hi mid; } return Math.max(0, lo - 1); }前缀和数组在数据变化时需要重建重建成本是 O(n)十万行大概几毫秒可以接受。但千万别在滚动事件里重建一定放在数据变更时做一次。5.2 分批上屏与滚动节流的配合有些场景不适合虚拟列表比如需要打印、需要 CtrlF 全局搜索、需要整体导出的长列表。这时候可以退一步用分批上屏先渲染前 200 条剩下的用IntersectionObserver监听底部哨兵元素滚到附近再追加下一批。这套方案的关键是别在滚动事件里直接干活。滚动事件触发频率极高在里面做 DOM 操作必然掉帧。要么用requestAnimationFrame把操作推迟到下一帧要么用IntersectionObserver让浏览器自己判断时机后者更省心。const io new IntersectionObserver(entries { if (entries[0].isIntersecting) appendNextBatch(); }, { rootMargin: 200px }); io.observe(sentinel);rootMargin给 200 像素的意思是提前 200 像素就开始加载用户滚到底时数据已经在了不会看到空白。5.3 复用节点时的状态清理陷阱为了减少 DOM 创建销毁很多人会做节点复用可视区滚动时把移出屏幕的节点挪到另一端只更新内容不重建。这个思路本身没问题但清理不干净就会出诡异 bug。我遇到过的几种输入框里用户填了一半的值跟着节点跑到了别的行带展开状态的行被复用后新行莫名其妙是展开的绑定在节点上的事件监听器闭包捕获了旧的数据索引点击时操作了错误的数据。解决办法是在复用前做一次归零清空value、移除动态添加的类名、把事件监听改成事件委托挂在容器上。事件委托这一条特别值得强调。给每一行绑监听十万行就是十万个监听器内存和事件分发都是灾难。改成在容器上挂一个监听通过event.target.closest([data-row])找到目标行再读dataset里的索引去取数据成本一下就降下来了。6. 内存与垃圾回收看不见的那部分开销6.1 什么样的写法会触发频繁 GC垃圾回收的触发频率和数据分配速率强相关。如果你的处理逻辑里每处理一条数据就创建一个临时对象或临时数组十万条就是十万次分配回收器会被反复唤醒。表现是规律性的卡顿尖峰用性能面板看能看到锯齿状的堆内存曲线。常见的无意识分配有几种在map的回调里返回新对象字面量、用展开运算符做浅拷贝、字符串拼接生成大量中间字符串、filter之后再map再filter形成多趟中间数组。前两种在数据量小时无所谓十万级就明显了。改造方向是合并多趟遍历、复用临时容器。比如原来是filter → map → reduce三趟合并成一趟for循环边判断边累加中间数组全省了。这一改在大数据量下经常能砍掉三成以上的时间。// 三趟遍历产生两个中间数组 const total rows.filter(r r.active).map(r r.value).reduce((a, b) a b, 0); // 一趟遍历零中间数组 let total 0; for (let i 0; i rows.length; i) { const r rows[i]; if (r.active) total r.value; }6.2 引用泄漏的几种常见形态内存泄漏在大型数据场景里格外致命因为基数大一点点泄漏就是几十兆。我见过最多的三种形态第一种是闭包捕获了大对象。一个回调里引用了整个rows数组但这个回调本身被挂在了长期存活的对象上导致整个数组无法回收。修法是把需要的字段单独取出来传给回调别整个传。第二种是缓存没有上限。为了加速做了个Map缓存键是查询条件值是查询结果用着用着缓存了几千份结果每份都是几万条数据。加个 LRU 上限超过就淘汰最旧的问题立刻消失。第三种是定时器和监听器没清。组件销毁时忘了clearInterval回调里还引用着数据和 DOM节点被移除但内存不释放。现代框架有生命周期钩子用上就行手写代码就老老实实把销毁逻辑写完整。6.3 手动干预的边界有些运行时提供了手动触发回收的接口只在调试模式下可用。我的建议是只用它来验证问题是不是 GC 导致的不要用它来当解法。生产环境里手动干预既不生效也会掩盖真正的分配问题。看到堆曲线锯齿正确做法是去查分配点而不是想着清一下就好了。7. 几种方案在同一份数据上的实测对照为了把上面的散点串起来我用一份十万条、每条约 120 字节的模拟记录做了组对照测试环境是一台四核笔记本浏览器开发者工具关闭状态每项取五次的中间值。数据仅代表这类场景的量级具体数值在不同机器上会有差异但比例关系大体稳定。方案计算耗时首次可见耗时滚动流畅度适用判断原生循环 全量渲染180ms2100ms严重掉帧数据量小于两千时可用原生循环 虚拟列表180ms260ms流畅首选实现成本中等时间切片 虚拟列表190ms180ms 出首屏1.2s 全量流畅用户可感知进度时优先Worker 计算 列式传输 虚拟列表主线程 20ms240ms流畅计算复杂、可列式化时最优Worker 计算 对象传输 虚拟列表主线程 20ms620ms流畅传输成本吃掉了收益从表里能看出两件事。第一渲染方案的改动带来的收益远大于计算优化全量渲染换成虚拟列表首次可见时间直接砍掉近九成。第二Worker 方案的成败完全取决于传输方式同样是搬出去算对象传输比列式转移慢了将近三倍主线程是轻松了但用户等得更久。还有一个没进表的观察时间切片方案的总耗时其实比一次性跑完略长多了调度开销但体感好得多因为用户能看到进度在动。这个总时长换体感的取舍在交互密集的页面里几乎总是划算的。8. 踩过之后才明白的几条经验第一条是关于优化的顺序。我早期总是先怀疑算法后来发现九成情况下该先看渲染其次看数据结构和拷贝最后才轮到算法。前面那个报表模块最后真正解决问题的不是把排序换成更快的实现而是把三万行全量渲染改成虚拟列表再把一次深拷贝改成引用传递。算法那部分从头到尾没动。第二条是别过早引入 Worker。Worker 会带来一整套新问题调试困难断点要开专门的线程面板、错误处理链路变长、构建配置要额外处理、数据边界要重新设计。只有当主线程计算稳定超过 200 毫秒、而且数据能列式化时搬出去才划算。在此之前时间切片几乎是零成本的替代方案。第三条是测量要在真实数据量上做。我试过用一千条数据做性能验证结论全部是没问题上线之后面对真实数据被打回原形——因为很多问题的复杂度不是线性的缓存失效、GC 频率、DOM 布局成本都会在某个数据量级上突然变脸。哪怕拿不到全量数据也要构造一份字段分布和真实情况接近的样本别用几条{id: 1, name: test}糊弄自己。第四条是给数据加个上限。这听起来不像技术方案但在实际项目里最有效。绝大多数业务场景根本不需要一次展示十万条需要的是找到那几十条。把默认查询加上时间范围、分页、必要的筛选条件从源头上把数据量降下来比任何前端优化都管用。真要做全量分析那就该考虑放到服务端或者离线任务里算前端只负责展示结果。最后分享一个最近在用的小技巧给关键路径加一个性能预算检查。在开发环境里用PerformanceObserver监听长任务超过 100 毫秒就往控制台打一条警告附带调用栈。这个机制会在功能开发过程中就暴露出性能退化而不是等到上线之后被用户投诉。加了之后团队里好几个人都是靠它发现自己随手写的find嵌套循环的。