本节摘要:性能优化不是零散技巧的堆叠,而是把前五章的引擎知识变成写代码的默认姿势:让对象形状稳定(隐藏类与内联缓存友好)、让分配可控(减少热路径上的临时对象与字符串拼接)、让主线程呼吸(长任务拆解、Worker 搬运、渲染通道选择)、让优化站得住(别触发反优化)。本节把这些原则收拢成可照做的清单与反模式对照,并示范一次完整的优化复盘。
先看两段功能相同的热路径代码,实测差距:
// 版本A:每轮新建对象、拼接字符串、形状漂移 function renderRowsA(rows) { let html = ''; for (const r of rows) { html += `<div class="row"><span>${r.name}</span><span>${r.score}</span></div>`; } return html; } // 版本B:数组收集、一次性join(字符串构建走-builder式路径) function renderRowsB(rows) { const parts = []; for (const r of rows) { parts.push(`<div class="row"><span>${r.name}</span><span>${r.score}</span></div>`); } return parts.join(''); } const data = Array.from({ length: 50000 }, (_, i) => ({ name: '行' + i, score: i })); console.time('A'); renderRowsA(data); console.timeEnd('A'); // 实测约 24 ms console.time('B'); renderRowsB(data); console.timeEnd('B'); // 实测约 11 ms
+= 拼接在循环里会制造 rope 结构反复分配;push 加 join 让中间结果收敛在数组里,join 一次成型。两倍差距不靠任何「高级技巧」,只是别在热路径上跟分配器较劲。这是本章清单第一原则的缩影:优化的大头从来不是奇技淫巧,是别挡引擎的路。
主轴一:形状稳定(第 3 章的变现)。 构造函数一次性给全字段;同类对象属性顺序一致;不 delete、动态键集用 Map;数组元素类型不混装、下标不跳跃;外部 JSON 数据入口先归一化。
主轴二:分配可控(本章 6.1 的推论)。 热循环里少造临时对象(能复用标量就算标量);字符串拼接走数组加 join;大结果集考虑生成器逐段产出而不是一次性数组;警惕隐式分配——解构、展开、默认参数、箭头捕获都在造对象或环境,热路径里点算着用。
// 热路径里的隐式分配示例 function sumA(points) { return points.map(p => p.x + p.y).reduce((a, b) => a + b, 0); // 造了一个中间数组 } function sumB(points) { let s = 0; for (const p of points) s += p.x + p.y; // 零中间分配 return s; } console.time('sumA'); for (let i=0;i<200;i++) sumA(data); console.timeEnd('sumA'); // 实测约 34 ms console.time('sumB'); for (let i=0;i<200;i++) sumB(data); console.timeEnd('sumB'); // 实测约 18 ms
注意分寸:sumA 的可读性在常规业务量级完全值回票价,map 加 reduce 表达意图更清楚。先测量,再优化——「热路径」的定义是 Profile 指认的,不是直觉指认的。
主轴三:主线程呼吸(第 4、5 章的变现)。 任何同步段不超 50 毫秒;超了就分片(await 让出)、搬 Worker(纯计算)、改算法(复杂度降级);DOM 批量写、读写分离防强制布局;动画只碰 transform 与 opacity 走合成通道;大 JSON 的序列化反序列化也算重活,放 idle 或 Worker。
// 动画通道对照:位置动画用 transform,绕过布局 function animateBox(el) { let start; function frame(ts) { if (start === undefined) start = ts; const t = (ts - start) / 1000; el.style.transform = `translateX(${Math.min(t * 120, 240)}px)`; // 合成通道 if (t < 2) requestAnimationFrame(frame); } requestAnimationFrame(frame); } // 反面:每帧改 left → 每帧重排 → 掉帧肉眼可见
主轴四:别打翻优化器(第 1 章的变现)。 热函数里不用 eval 与 with(放弃优化);arguments 不乱传递给别的函数(泄漏arguments对象阻碍内联);try-catch 结构历史上曾阻碍优化,现代引擎已基本免疫,不必为性能拆掉错误处理;递归深度可控就递归,不可控就显式栈(第 2 章爆栈红线)。
场景:一个一万行的表格,滚动时实时高亮视口内行并更新统计条。初始实现卡顿明显。按主轴逐项体检:
体检一(测量):Performance 面板录制滚动,发现两处长任务(各 80 毫秒)与密集的紫色 Layout 条。火焰图指认:scroll 监听器里同步遍历全部行 + 每行读 offsetTop。
体检二(定位性质):遍历全表是算法问题(复杂度 O(n) × 每帧);每行读 offsetTop 是强制同步布局(第 5 章读写交错模式);统计条更新触发重排(改了 width)。
修法一(算法):行位置有「索引连续」特性,用二分或缓存区间把视口计算降到 O(log n);监听器改 rAF 节流——scroll 事件先记账,rAF 回调里统一算。
修法二(布局):滚前一次性读所有 offsetTop 存数组(读段集中),滚动过程只读缓存(写段不夹读);高亮改 class 切换而非改样式。
修法三(通道):统计条改 transform scaleX,绕过布局。
复盘后长任务消失,帧率回到 60。这次复盘的模式值得记住:测量 → 火焰图指认 → 按四主轴对号入座 → 每改一处复测。任何「听说这样快」的改动,不测量就不算优化,只算装饰。
| 反模式 | 后果 | 正解 |
|---|---|---|
| 循环内读布局属性 | 强制同步布局,每轮结算 | 读写分段,几何缓存 |
| 每帧改 top left | 每帧重排 | transform 动画 |
| 热循环拼接字符串 | 反复分配 | 数组加 join |
| 逐个 append 节点 | 多轮树更新 | fragment 或一次 innerHTML |
| 对象先造壳再补字段 | 隐藏类转移链分叉 | 构造时给全字段 |
| 混装数组元素 | 元素形态降级 | 分数组存或统一类型 |
| 微任务递归无出口 | 队列清不完,页面冻结 | 宏任务分片或循环 |
| 大同步 JSON 操作 | 长任务 | idle 时段或 Worker |
| 无上限缓存 | 内存单调上涨 | 容量上限加 TTL |
| 定时器监听器不清理 | 常驻泄漏 | 生命周期配对清理 |
⚠️ 常见坑:微基准骗人。脱离真实数据形状与调用频次的「跑分对比」经常得出错误结论(比如冷函数上测优化收益、单次数测量被 GC 噪声污染)。测量要在真实页面负载下做,用 Performance 面板而不是只在控制台掐表;控制台掐表也要先预热再多次取中位。
💡 关键直觉:把引擎当同事而不是黑盒——它想给你优化(JIT、隐藏类、内联缓存、并发标记),你的职责只是别把它的假设全拆掉。优化清单上的每一条,都是「顺着引擎的假设写」的自然结果。
三个真实形态的场景题,只练「怎么决策」,不给万能代码。
场景一:商品列表滚动卡顿。 现象:滚动时掉帧。先测量:Performance 录制发现 scroll 回调里同步过滤两千条数据加逐行改类名,每帧 30 毫秒。决策路径:事件层(回调改 rAF 节流,滚动先记账后处理)→ 数据层(过滤结果缓存,滚动只切换类)→ 视图层(只处理视口附近行,配合绝对定位与固定行高)。三步按序做,每步复测——第一步通常就能把帧耗时砍半,别一上来就上虚拟滚动这种重构级方案。
场景二:报表导出卡死页面。 现象:点导出后页面冻结三秒。测量:一段同步的十万行数据聚合加 XLSX 序列化。决策路径:先问能否降复杂度(服务端导出是最优解)→ 不能则分片(每五千行 await 一次让出)→ 或搬 Worker(序列化是纯计算,理想搬运对象,还能给用户进度条)。选型依据是「这段计算是否必须访问 DOM」——不需要就搬,需要就分片。
场景三:首屏加载慢。 现象:白屏两秒半。测量:网络面板显示 1.8MB 的 JS 在关键路径上,其中图表库占 1.1MB 且首屏用不到。决策路径:入口已经是 module,把图表相关改成动态 import(第 7 章的分割钩子)→ 配 modulepreload 给真正首屏需要的模块 → 检查脚本是否误用了默认同步标签(第 5 章三态)。这类问题七成在「加载了不需要的东西」,三成在「需要的东西没并行下载」,测量面板一眼分清。
三个场景的共性是决策顺序永远从测量开始,修复按「改结构优于改实现、降复杂度优于换写法」排列。反过来,任何「不测量就动手」的优化,最好情况是白忙,最坏情况是把代码改复杂了还没解决真问题。
防抖节流算性能优化吗?
算是「调度优化」:它们不减少单次工作成本,而是砍掉不必要的执行次数。对高频事件(滚动、输入、resize)常常是收益最大的第一步,因为单次回调本身不贵、贵在每秒几十次。注意别把防抖当万能胶——拖拽跟手类需求要的是 rAF 对齐而不是防抖延迟,选错工具会换来「手感发粘」的新问题。
什么时候才值得上 Web Worker?
三个条件同时满足:计算确实是纯 CPU(无 DOM 依赖)、耗时稳定超过五十毫秒、发生频率不低(一次性的一百毫秒计算让用户等一下也无妨)。搬 Worker 的成本要计入:通信序列化、代码组织复杂度、调试难度。数据量小到「拷贝过去比算还慢」的场景是反面教材——先测通信开销再决定。
优化和可读性冲突时怎么选?
默认选可读性,性能问题等测量指认后再局部牺牲——被指认的热点只占代码的少数,用清晰的模块边界把它隔离起来,优化代码配注释说明测量依据。全篇都「很快」但没人敢改的代码,总成本高于少数热点精心雕琢的版本。这也是全书立场:理解引擎不是为了处处抠性能,是为了在真正要快的时候,知道往哪里使劲。
GC 停顿能观测到吗,怎么定位是谁造成的?
能。Performance 题板的内存图上,锯齿跌落瞬间对应的时间点就是一次回收;Performance 自己也标 GC 任务条(黄色的垃圾回收段)。定位归因两步:停顿频繁且短,多为新生代小回收——查分配速率(哪个热点在狂造短命对象);停顿长而稀疏,是老生代大回收——查「可达但无用」的大引用图(回到第 6.2 节)。内存面板的分配时间线模式还能按函数定位分配热点,是「谁在造垃圾」的直接答案。
内存和运行速度,该优先优化哪个?
多数场景优先速度体验(卡顿用户立刻感知,内存超标要等浏览器干预);三个信号出现时反转:容器有内存限额(服务端与嵌入式 WebView)、页面生命周期超长(监控大屏、编辑器类应用)、移动端低端机占比高。而且两者常一起受益——减少分配既省内存又少 GC 停顿,形状稳定既快又少隐藏类开销。真正冲突的场合(空间换时间的缓存类设计),按上面的信号做裁决并记录上限。
这套清单多久复查一次?
两个触发点:技术性的——运行时重大更新(引擎版本、框架大版本)后,用真实页面重跑一遍基准,优化假设可能过时;组织性的——新人大批入场或代码风格漂移时,把清单里的「评审拦截面」(读写交错、混装数组、无界缓存、未配对监听)重新宣讲一次。清单本身也该迭代:每次事故复盘后,把新学到的反模式补进表格——它的价值不在「正确」,在「持续贴合你团队的真实现场」。
有没有「一句话版」的全书性能心法?
有:让引擎的假设成立。形状稳定是让隐藏类的假设成立,元素纯净是让元素形态优化的假设成立,读写分离是让浏览器合并布局的假设成立,分片让出是让事件循环调度的假设成立,配对清理是让回收器可达性的假设成立。全书的优化清单,本质都是「别亲手拆掉引擎替你搭好的加速结构」——记住这一句,清单忘了也能现场推出来。