6.3 与引擎合作的性能优化


6.3 与引擎合作的性能优化

本节摘要:性能优化不是零散技巧的堆叠,而是把前五章的引擎知识变成写代码的默认姿势:让对象形状稳定(隐藏类与内联缓存友好)、让分配可控(减少热路径上的临时对象与字符串拼接)、让主线程呼吸(长任务拆解、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、隐藏类、内联缓存、并发标记),你的职责只是别把它的假设全拆掉。优化清单上的每一条,都是「顺着引擎的假设写」的自然结果。

  • 先测量后优化,火焰图指认热区,直觉不算数;
  • 四主轴:形状稳定、分配可控、主线程呼吸、别打翻优化器;
  • DOM 三纪律:读写分段、批量更新、动画走合成通道;
  • 50 毫秒是同步段红线,超线就分片、搬 Worker、降复杂度;
  • 微基准会骗人,真实负载加多次中位才是可信数据。

优化决策演练

三个真实形态的场景题,只练「怎么决策」,不给万能代码。

场景一:商品列表滚动卡顿。 现象:滚动时掉帧。先测量: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 停顿,形状稳定既快又少隐藏类开销。真正冲突的场合(空间换时间的缓存类设计),按上面的信号做裁决并记录上限。

这套清单多久复查一次?
两个触发点:技术性的——运行时重大更新(引擎版本、框架大版本)后,用真实页面重跑一遍基准,优化假设可能过时;组织性的——新人大批入场或代码风格漂移时,把清单里的「评审拦截面」(读写交错、混装数组、无界缓存、未配对监听)重新宣讲一次。清单本身也该迭代:每次事故复盘后,把新学到的反模式补进表格——它的价值不在「正确」,在「持续贴合你团队的真实现场」。

有没有「一句话版」的全书性能心法?
有:让引擎的假设成立。形状稳定是让隐藏类的假设成立,元素纯净是让元素形态优化的假设成立,读写分离是让浏览器合并布局的假设成立,分片让出是让事件循环调度的假设成立,配对清理是让回收器可达性的假设成立。全书的优化清单,本质都是「别亲手拆掉引擎替你搭好的加速结构」——记住这一句,清单忘了也能现场推出来。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U