外观
浏览器渲染原理:从 HTML 解析到合成层
性能优化如果只背结论,很容易变成玄学:"动画要用 transform"、"不要频繁读取 offsetHeight"、"加 will-change 会更快"。这些结论背后都对应浏览器渲染流水线中的具体环节。
理解一次渲染从请求到像素的完整路径,才能判断某个优化是否真的有效,以及它的代价是什么。
从导航到首帧
一次页面加载大致经历以下阶段:
- 导航与响应:浏览器发起请求,收到 HTML 响应流。
- 解析 HTML:主线程解析 HTML 构建 DOM;遇到 CSS 和同步脚本会阻塞后续解析。
- 预加载扫描器:主线程解析的同时,扫描器提前发现图片、脚本、字体等资源并发起请求。
- 构建 CSSOM:解析样式表,计算层叠与继承。
- 生成渲染树:DOM 与 CSSOM 结合,排除
display: none、head等不参与渲染的节点。 - 布局(Layout / Reflow):计算每个盒子的几何位置与尺寸。
- 绘制(Paint):生成绘制指令,填充像素到图层。
- 合成(Composite):把图层交给合成线程,按正确顺序合成到屏幕。
其中第 2 到第 5 步受 JavaScript 执行影响。CSS 是阻塞渲染的资源,同步脚本是阻塞解析的资源,这是"首屏白屏"最常见的两类原因。
主线程与合成线程
渲染主线程负责 JavaScript、样式计算、布局和绘制。当主线程执行长任务时,这些工作都无法进行,页面就会卡顿。
合成线程是独立的。对于 transform、opacity 这类可以在合成阶段处理的属性,浏览器可以跳过布局和绘制,直接由合成线程移动图层。这就是"动画用 transform 更流畅"的原因。
css
.card {
transition: transform 200ms ease, opacity 200ms ease;
}
.card:hover {
transform: translateY(-4px);
opacity: 0.9;
}如果改成 top、left、height,每一帧都要重新布局和绘制,主线程压力会显著增加:
css
/* 不推荐用于高频动画 */
.card:hover {
top: -4px;
height: 104%;
}重排与重绘
- 重排(Reflow):元素的几何属性变化,需要重新计算布局。成本与受影响节点数量相关。
- 重绘(Repaint):外观变化但布局不变,例如颜色、背景、阴影。
- 只合成:
transform、opacity等属性,跳过布局与绘制。
触发重排的典型操作:
- 修改宽高、内外边距、字体大小、定位。
- 增删 DOM 节点。
- 读取
offsetTop、offsetWidth、scrollTop、getBoundingClientRect()等布局属性。 - 修改
class导致布局变化。
注意最后一条:读取布局属性本身会强制浏览器同步计算布局。如果在一个循环里反复"写样式、读布局",就会造成 layout thrashing。
js
// 错误:每次循环都强制同步布局
for (const item of items) {
item.style.width = `${container.offsetWidth / items.length}px`
}
// 正确:先批量读取,再批量写入
const width = container.offsetWidth / items.length
for (const item of items) {
item.style.width = `${width}px`
}把动画交给合成线程
高频动画优先使用 transform 和 opacity。如果需要更精细的控制,使用 Web Animations API:
js
element.animate(
[
{ transform: 'translateY(8px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
],
{ duration: 240, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', fill: 'both' }
)requestAnimationFrame 适合在每帧渲染前更新状态,但不要在回调里做重计算:
js
let start
function tick(now) {
start ??= now
const progress = Math.min(1, (now - start) / 240)
element.style.transform = `translateY(${8 * (1 - progress)}px)`
if (progress < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)will-change 与图层提升
will-change 会提示浏览器提前为元素准备图层,减少动画开始时的抖动:
css
.animated-panel {
will-change: transform;
}但它不是越多越好。每个图层都要占用显存,过多图层会让合成成本上升,移动端尤其明显。原则是:
- 只给确实会频繁动画的元素加。
- 动画结束后考虑移除
will-change。 - 不要给几十个列表项同时加。
另外,创建图层会改变包含块。给 position: fixed 元素的祖先加 transform,可能导致 fixed 定位失效,这是线上布局 bug 的常见来源。
用 contain 和 content-visibility 减少工作
contain 告诉浏览器某个子树与外部布局无关,可以限制重排范围:
css
.widget {
contain: layout paint style;
}content-visibility: auto 让屏幕外的大块内容跳过渲染,适合长文档和复杂列表:
css
.section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}contain-intrinsic-size 用于提供占位尺寸,避免滚动条跳动。注意它不适用于需要精确滚动定位的场景,使用前要测量实际收益。
如何定位渲染问题
Chrome DevTools 中几个直接相关的面板:
- Performance:录制交互,查看主线程任务、布局与绘制耗时,找到长任务。
- Rendering:开启 Paint flashing 观察重绘区域,Layer borders 查看合成层。
- Layers:查看图层数量、尺寸和内存占用。
- CSS Overview 与 Coverage:发现冗余样式和未使用 CSS。
怀疑布局抖动时,在 Performance 面板中搜索 "Layout",如果出现大量短促的强制布局记录,基本可以确认是读写交错导致的。
常见坑
- 只优化动画,不优化首屏。首屏受资源加载、CSS 阻塞和 JavaScript 执行影响,与动画优化是两条链路。
- 盲目加
will-change。图层爆炸反而更卡。 - 在滚动回调里读取布局属性。滚动事件高频触发,读写交错会持续触发布局。
- 忽略字体加载导致的布局偏移。自定义字体应设置
font-display: swap并匹配后备字体的度量。 - 用
display: none切换大块内容。频繁切换会反复触发布局,考虑content-visibility或虚拟化。
小结
渲染流水线的核心是"哪些工作可以跳过"。改动几何属性需要布局,改动外观需要绘制,改动合成属性可以交给合成线程。优化的本质,是让高频操作尽量落在流水线的后段,并减少主线程的长任务。