前端运行时性能优化主要关注哪些方面?
简化版
运行时性能关注加载后输入、滚动、动画和状态更新是否及时。核心是减少主线程长任务、无效组件更新和 DOM 数量,避免布局抖动,把视觉更新对齐 requestAnimationFrame,对高频事件做合并而非机械节流,并治理内存泄漏;所有优化都应从具体用户动作的 trace 和 INP/帧表现出发。
详细版
浏览器每帧可能经历 JavaScript、样式计算、布局、绘制和合成。读布局属性前若有待处理的样式写入,浏览器可能被迫同步布局;循环交替读写会反复触发,应该批量读取后批量写入。
transform/opacity 动画常可减少布局和绘制,但图层提升有显存与合成成本,不是所有元素都应 will-change。60Hz 每帧约 16.7ms,120Hz 约 8.3ms,脚本只是预算的一部分。
框架层要稳定状态边界、避免无意义重渲染,并对超长列表虚拟化;计算密集任务切片或 Worker 化。定时器、监听器、Observer 和缓存要在生命周期结束时清理,避免页面越用越慢。
完整版教学
一、运行时性能要围绕具体交互观察
首屏快只说明加载阶段不错,用户随后可能在输入、滚动、拖拽、弹窗和切换 Tab 时卡顿。优化对象应是“哪个动作在哪台设备上延迟”,而不是泛泛追求函数更短。
| 用户现象 | 可能瓶颈 | 主要证据 |
|---|---|---|
| 点击后反馈晚 | 长任务、同步渲染 | INP 归因、Main trace |
| 滚动掉帧 | 事件工作、绘制、图片 | Frames、Paint |
| 输入越用越慢 | 重渲染、泄漏 | React/Vue profiler、Heap |
| 动画抖动 | 布局、绘制、图层 | Rendering/Layer 工具 |
心法:先选一条用户动作时间线,再看主线程和渲染流水线,别从“减少重排”口号倒推问题。
二、一帧里浏览器可能做哪些工作
视觉更新通常要经过脚本、样式、布局、绘制和合成,不是每一帧都完整执行所有阶段。改动属性和失效范围决定后续工作,浏览器也会合并批量变更。
Input → JS → Style → Layout → Paint → Composite → Display
60Hz 帧间隔约 1000/60≈16.7ms,120Hz 约 8.3ms;其中还要留给浏览器自身工作,所以 JS 不能占满整个数字。错过一次截止时间就可能显示上一帧,连续错过才形成明显卡顿。
三、布局抖动为什么比单次样式修改昂贵
写入 class/style 后布局可能处于失效状态;随后读取 offsetHeight、getBoundingClientRect() 等几何信息时,浏览器为返回最新值可能同步完成样式和布局。循环中写一次读一次会重复强制工作。
// 不理想:交替写与读
for (const row of rows) {
row.style.width = `${nextWidth}px`
console.log(row.offsetWidth)
}
// 更好:先批量读取,再批量写入
const widths = rows.map(row => row.offsetWidth)
rows.forEach((row, i) => { row.style.width = `${widths[i] + 10}px` })
若 1000 个节点每轮布局 3ms,交替触发 20 轮就可能约 60ms;批量后一次布局约 3ms。数值是示意,真实成本由 DOM、样式和设备决定。
四、动画与合成层不是越多越好
改变 top/left/width 常影响布局,改变背景和阴影可能需要绘制;transform、opacity 在合适条件下可由合成器更新,减少主线程与重绘。但具体路径仍由浏览器判断。
will-change 可提前准备图层,却会占内存并增加合成管理。一个 1000×1000 的 RGBA 图层仅像素存储约 1000×1000×4≈4MB,100 个类似图层约 400MB,尚未计额外缓冲。
| 手段 | 收益 | 代价 |
|---|---|---|
| transform 动画 | 常避免布局 | 合成与纹理内存 |
| rAF 更新 | 对齐下一帧 | 回调内重任务仍会卡 |
| will-change | 提前准备 | 图层泛滥 |
| 降低特效 | 减少绘制 | 视觉取舍 |
五、高频事件应合并工作而非只背节流防抖
scroll、pointermove、resize 可能一秒触发多次。每次都查询布局、更新状态和发请求会放大成本;节流限制频率,防抖等待安静后执行,但交互语义不同。
视觉更新可只保留最新输入并在下一次 rAF 处理一次。被动监听器可帮助浏览器处理不调用 preventDefault 的触摸/滚动监听,但不会让回调里的重计算变快。
let latestY = 0
let scheduled = false
addEventListener('scroll', () => {
latestY = scrollY
if (scheduled) return
scheduled = true
requestAnimationFrame(() => {
updateHeader(latestY)
scheduled = false
})
}, { passive: true })
六、框架更新、计算与内存要一起治理
React/Vue 等框架更新最终仍转成 DOM 与浏览器渲染。应先用框架 profiler 找高成本组件,再稳定 props/依赖、缩小响应式范围或虚拟化列表;盲目 memo 会增加比较和认知成本。
纯 CPU 重计算可缓存、分批或移到 Worker,DOM 操作仍需主线程。缓存必须有容量与失效策略,否则节省 CPU 却持续占用内存。
事件监听、定时器、订阅、Observer 和闭包引用未清理,会让已离开页面对象无法回收。若每次进入页面泄漏 5MB,重复 20 次就是约 100MB,最终 GC 频繁和崩溃;应结合 Heap snapshot 与 allocation timeline 验证保留链。
七、常见误区与追问
- 误区:代码行数越少,运行时一定越快。 性能取决于执行频率、算法、主线程时机和渲染失效范围。
- 误区:transform 和 opacity 永远零成本。 它们仍有合成、纹理上传和显存成本,浏览器也不保证独立图层。
- 误区:给所有元素 will-change 能提升动画。 图层泛滥会增加内存和合成开销,应临近使用并及时移除。
- 追问:为什么读 offsetWidth 会触发布局? 前面有待处理样式变化时,浏览器必须计算最新几何值才能同步返回。
- 追问:节流和防抖如何选择? 连续反馈常节流/按帧合并,停止后执行一次的搜索或校验常防抖。
- 追问:框架重渲染等于真实 DOM 全重建吗? 不等于,框架会协调差异,但计算与提交仍可能昂贵,要用 profiler 判断。
- 追问:怎样确认是内存泄漏? 重复同一操作后强制可回收条件,比较 Heap 与保留路径,而非只看内存短时上涨。
八、加强记忆
用“看动作、控主线程、批读写、慎图层、清生命周期”做运行时优化:从点击或滚动 trace 找最长阶段,切开长任务和无效框架更新,避免交替布局读写,动画优先合适的合成属性但限制图层;最后检查缓存、监听器和订阅能否释放,并用低端设备的 INP、帧与内存趋势验证。