摘要:本文从浏览器内核视角,把 HTML/CSS/JS 变成屏幕像素的全过程拆成「关键渲染路径」5 步,并讲清重排、重绘、合成三者的成本差异与触发条件。配合可运行对比代码(left 动画 vs transform 动画、读取 offset 触发强制重排、批量改样式),给出布局抖动解法、GPU 加速实战与 10 条首屏优化清单,帮你把零散概念串成可落地的性能优化体系。
一、引言:浏览器到底把你的代码变成了什么?
你写的 .html、.css、.js 只是一堆文本字节,最终却变成了屏幕上跳动的按钮、滚动的列表、流畅的动画。中间这段「字节 → 像素」的转化,就是前端性能优化的主战场------不是调几个参数这么简单,而是理解浏览器是怎么干活的。
很多同学都有类似的困惑:
- 知道
display:none能隐藏元素,却不清楚渲染树为什么直接把它排除,而visibility:hidden却被保留; - 做位移动画时用
left/top/margin,页面肉眼可见地卡,但说不清背后触发了什么; - 改一个样式整页抖动(布局抖动 / Layout Thrashing),不知道读取
offsetTop这类属性会强制浏览器立即回流; - 首屏加载慢,却不知道该从「关键渲染路径」(Critical Rendering Path)的哪一步下手。
要回答这些问题,先建立一个最朴素的目标感:流畅的画面是 60fps ,也就是每秒 60 帧。一秒 1000 毫秒,平均每帧只有约 16.7ms 的预算,而这一帧里要塞下 JavaScript、样式计算、布局、绘制、合成所有活儿。一旦某一步超过预算,掉帧就开始了。
| 指标 | 数值 | 说明 |
|---|---|---|
| 目标帧率 | 60fps | 人眼感知流畅的底线 |
| 单帧预算 | ≈ 16.7ms | 1000ms / 60,含脚本到合成全流程 |
| 单帧预算(高刷) | ≈ 8.3ms | 120fps 设备,余量更紧 |
本文主线分三块:关键渲染路径(字节到像素的 5 步)→ 重排 / 重绘 / 合成的成本差异 → 首屏优化与 GPU 加速实战。下面一节一节拆开。
二、关键渲染路径全景:从字节到像素的 5 步
浏览器把文档变成像素所经历的步骤序列,称为关键渲染路径(Critical Rendering Path, CRP)。它直接决定「首次渲染」需要多久,是首屏优化的核心抓手。
完整的链路是:
text
HTML 字节 ──▶ DOM 树
│
CSS 字节 ──▶ CSSOM 树
│
渲染树(Render Tree,仅可见节点 + 计算样式)
│
布局(Layout / Reflow,算尺寸与位置)
│
绘制(Paint,转实际像素)
│
合成(Composite,GPU 多图层拼合输出)
也就是说,从两个独立的数据结构(DOM、CSSOM)出发,先合成出「渲染树」,再依次做布局、绘制、合成。对每一步的直觉:
- DOM:HTML 解析出的文档对象模型,描述结构;
- CSSOM:CSS 解析出的样式树,描述每个节点的计算样式;
- 渲染树:DOM 与 CSSOM 的交集------只有「可见且有样式」的节点才进;
- 布局:给每个节点算出在视口里的宽高和坐标;
- 绘制:把每个盒子画成像素(文字、颜色、边框、阴影);
- 合成:把多个图层交给 GPU 拼成最终画面。
节点越多、CSS 选择器越复杂、样式层叠越深,CRP 后面几个阶段就越慢。所以「优化 CRP」的本质,是让这条链路更短、更便宜,从而缩短首次渲染时间。

三、解析 HTML:增量构建 DOM 树
浏览器拿到 HTML 响应后是增量解析的------不需要等整个文件下载完,边下载边把字节转成 DOM 树。过程可以概括成四步:
text
字节流(bytes) ──▶ 标记(token) ──▶ 节点(node) ──▶ DOM 树(tree)
网络传过来的是原始字节(如 UTF-8 编码的 <div>),分词器把它们切成标记(start tag、end tag、text、comment 等),再组装成节点对象,最后连成树。
但有几个外部资源会打断这个顺畅的过程:
html
<!DOCTYPE html>
<html>
<head>
<!-- 样式表:阻塞渲染(Render-Blocking) -->
<link rel="stylesheet" href="style.css">
<!-- 普通脚本:阻塞 HTML 解析(Parser-Blocking) -->
<script src="app.js"></script>
</head>
<body>
<img src="hero.png" alt="首图">
<!-- 带 async 的脚本不阻塞解析 -->
<script async src="track.js"></script>
</body>
</html>
几个要点:
<link rel="stylesheet">是阻塞渲染的:浏览器要等 CSSOM 构建完,才敢进入渲染树阶段,否则用户会看到「无样式的闪屏」;- 头部没有
defer/async的<script>会阻塞 HTML 解析 ------解析器遇到它要先停下来下载并执行,这也是为什么通常建议脚本放底部或用defer; - DOM 节点的总数量会一路传导到布局、绘制、合成阶段,节点越多后面越慢。
四、解析 CSS:构建 CSSOM 与样式层叠
很多人以为「样式是挂在 DOM 上的」,其实 CSSOM 是和 DOM 相互独立的另一棵树。浏览器处理 CSS 的方式和 HTML 类似:把一条条规则解析成节点,再组织成树状结构。
难点在「层叠(cascade)」:同一个元素可能被多个来源的规则命中------
text
作者样式(你写的 CSS)
↑ 覆盖
用户样式(浏览器设置 / 扩展)
↑ 覆盖
UA 默认样式表(浏览器内置,如 <h1> 默认加粗、<a> 默认蓝色)
浏览器从「最通用」的 UA 默认样式表开始,逐层被更具体的规则覆盖,最终递归细化出每个节点的计算样式(computed style) 。这套层叠 + 继承 + 优先级的计算,正是 DevTools 里 Recalculate Style 这一项的耗时来源------它等于「解析 CSS + 构建 CSSOM + 计算所有计算样式」的总时间。
一个容易忽略的事实:即使你只写了很少 CSS,浏览器也要先加载 UA 默认样式表,再叠加你的规则。所以「页面默认就有样式」不是魔法,是层叠的结果。
五、DOM + CSSOM 合成渲染树:谁进了渲染树,谁被排除?
渲染树是 DOM 与 CSSOM 的交集。浏览器从 DOM 根节点开始遍历,对每个「可见」节点去匹配它的计算样式,把两者合并成渲染树节点。
这里就回答了一开始的问题:为什么 display:none 和 visibility:hidden 命运不同?
| 属性 | 是否进入渲染树 | 是否占空间 | 说明 |
|---|---|---|---|
display: none |
否 | 否 | 节点从渲染树中完全移除,不绘制 |
visibility: hidden |
是 | 是 | 节点在渲染树中,占位置但不显示 |
<script> / <head> |
否 | 否 | 不带可见内容,天然不进渲染树 |
opacity: 0 |
是 | 是 | 只是透明,仍参与布局和绘制 |
一句话记:渲染树 = 所有「可见 + 有内容 + 有计算样式」的节点的集合 。display:none 因为「不可见」被直接排除,后续布局 / 绘制根本不会为它分配成本;visibility:hidden 仍然占着布局空间,所以进渲染树但不画出来。
用代码感受一下区别:
html
<div class="box-a" style="display:none">我不在渲染树里</div>
<div class="box-b" style="visibility:hidden">我在渲染树里,但看不见</div>
<div class="box-c">我是正常可见节点</div>
改写样式把 display:none 换成 display:block,这个节点会从无到有进入渲染树 ,触发一次完整的「布局 → 绘制 → 合成」;而把 visibility:hidden 换成 visible 只是「从不显示变显示」,只在重绘阶段处理,代价明显更低。

六、布局(Layout / Reflow):计算每个节点的几何信息
拿到渲染树后,浏览器要计算每个节点在屏幕上的尺寸和位置------这个过程叫布局(Layout),也叫重排(Reflow,指后续重新计算)。
布局基于视口大小 ,从 body 开始自顶向下遍历盒模型:每个元素多大、相对父级偏移多少、文字折行后高度怎么变,全部在这一步定下来。
一个经典坑:没有声明尺寸的图片 。图片是异步到达的,若 HTML 里没写 width / height,浏览器在布局阶段不知道它占多大,等图片下载完、知道真实尺寸后,只能再触发一次重排把后面的内容往下挤:
html
<!-- 反例:不写尺寸,图片到达后触发二次重排 -->
<img src="banner.png" alt="横幅">
<!-- 正例:提前占好位,避免布局抖动 -->
<img src="banner.png" alt="横幅" width="1200" height="400">
在现代浏览器里还可以用 aspect-ratio 或 CSS width / height 属性预留宽高比,让布局时就能算准位置,图片到达后只补像素、不重排。这点在第十一节的优化清单里还会展开。
七、绘制(Paint)与合成(Composite):图层与 GPU 加速
布局算出了「在哪、多大」,**绘制(Paint)**负责把每个盒子真正变成像素:文字字形、颜色填充、边框、阴影、替换元素(图片 / 视频)等,逐一画到内存里的图层上。
浏览器并不会把所有东西都画在一张「画布」上,而是把页面拆成多个图层(layer) ,各自绘制,最后由 GPU 合成(Composite) 把这些图层拼成最终画面。这带来一个关键收益:当一个图层内部变化时,只需重绘该层再合成,不必重绘整页。
哪些元素 / 属性会「升级」成独立合成层(送 GPU)?常见清单:
| 元素 / 属性 | 是否通常创建合成层 | 说明 |
|---|---|---|
拥有 3D transform(如 translateZ(0)) |
是 | 经典「图层提升」hack |
will-change: transform 等 |
是 | 显式提示浏览器 |
position: fixed / sticky |
通常是 | 滚动时独立合成更顺 |
<video> / <canvas> / <iframe> |
通常是 | 自带合成层 |
高 opacity 动画 |
是(动画期间) | 仅合成、不重绘 |
普通 background-color 变化 |
否 | 通常触发重绘而非独立层 |
注意:分层能省 CPU 但更耗内存 (每层都要一块显存)。所以合成层不是「越多越好」------这正是第十节 will-change 不能乱加的原因。
八、重排与重绘:触发条件、代价与「渲染瀑布流」
现在把前面串起来,看一次样式变化到底会点燃多长的链路(渲染瀑布流):
text
Recalculate Style(算计算样式)
↓
Layout(重排)------ 若存在几何变化
↓
Paint(重绘)------ 若存在像素变化
↓
Composite(合成)------ 始终存在(拼图层)
代价从高到低分三类,记住这个表就能判断「我这个改动贵不贵」:
| 你改的属性 | 触发链路 | 代价 | 典型例子 |
|---|---|---|---|
| 几何 / 位置类 | 重排 → 重绘 → 合成 | 最贵 | width、height、margin、top、left、增删节点、resize 视口 |
| 像素 / 外观类 | 重绘 → 合成 | 中等 | color、background、box-shadow、visibility、outline |
| 合成层属性 | 仅合成 | 最便宜 | transform、opacity(在自身合成层由 GPU 处理) |
直觉记忆:动「筋骨」(位置尺寸)最贵,换「皮肤」(颜色)中等,换「图层」(transform/opacity)最便宜。
触发重排(Layout)的常见操作:
javascript
// 几何相关:几乎都会重排
el.style.width = '300px';
el.style.marginTop = '20px';
el.style.top = '100px';
el.appendChild(document.createElement('div')); // 增删节点
window.resizeTo(800, 600); // 视口变化
// 仅重绘(不会重排)
el.style.color = '#f00';
el.style.backgroundColor = '#fff';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,.2)';
// 仅合成(自身层内)
el.style.transform = 'translateX(100px)';
el.style.opacity = '0.5';

九、强制同步布局与布局抖动(Layout Thrashing)
前面说「重排很贵」,正常情况下浏览器会聪明地批量 处理:你连续写一堆样式,它攒到最后一次性重排。但有些代码会破坏这个优化------主动逼浏览器立刻回流。
当你读取几何类属性时,浏览器为了保证返回的是「最新值」,必须先把前面累积的写操作结算成一次重排,才能读:
javascript
// 这些「读」会强制同步布局(forced synchronous layout)
const top = el.offsetTop;
const w = el.offsetWidth;
const s = el.scrollTop;
const cw = el.clientWidth;
const style = getComputedStyle(el).width; // 同样会强制回流
问题在「写---读---写---读」循环里:每读一次,浏览器就得先把前面所有写结算成重排,于是循环一次就重排一次,整页疯狂抖动,这就是布局抖动(Layout Thrashing)。
javascript
// 反例:循环中读写 offset,每次读都强制重排,性能灾难
function bad() {
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
box.style.width = box.offsetWidth + 10 + 'px'; // 读前先重排!
});
}
// 正例:先批量读,再批量写,只重排一次
function good() {
const boxes = document.querySelectorAll('.box');
const widths = [...boxes].map(box => box.offsetWidth); // 先全读
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px'; // 再全写
});
}
更稳妥的做法是用 requestAnimationFrame 把「读」和「写」分到不同帧,或在写入前把需要的值一次性读齐。核心原则就一句:读归读、写归写,别在循环里交替。

十、GPU 加速实战:把动画送上合成层
动画是重排重绘最容易翻车的地方。结论先给:位移 / 缩放动画优先用 transform 和 opacity,它们只走合成,不触发重排 / 重绘。
css
/* 反例:用 left 做位移动画,每帧触发重排 → 重绘 → 合成,掉帧 */
.box-bad {
position: absolute;
left: 0;
transition: left 1s ease;
}
.box-bad.run { left: 300px; }
/* 正例:用 transform 做位移,仅合成,GPU 处理,丝滑 */
.box-good {
transform: translateX(0);
transition: transform 1s ease;
}
.box-good.run { transform: translateX(300px); }
在 Chrome DevTools 里打开 Paint Flashing (高亮重绘区域):用 left 动画时整块区域疯狂闪烁(持续重绘),用 transform 时几乎不闪------这就是「只合成」的直观证据。
will-change 是给浏览器的「预告」:这个元素接下来要变,提前帮我把层建好。
css
.card {
will-change: transform; /* 提示浏览器:transform 会动,预提升图层 */
transition: transform .3s;
}
.card:hover { transform: scale(1.05); }
但 will-change 是「最后一招」,不是银弹:
- 别给几十个元素都加
will-change,每层吃显存,滥用反而更卡; - 元素不再变化时应移除(如动画结束
will-change: auto),让浏览器回收图层; - 与其预测性堆
will-change,不如先确认瓶颈真在重绘 / 重排上(用 Performance 面板看); - 经典「图层提升」写法
transform: translateZ(0)与will-change: transform效果类似,但同样不应滥用。
其它会被送 GPU 的情况:position: fixed、3D 变换(translateZ / rotate3d)、<video> / <canvas> 等。

十一、首屏优化清单:缩短关键渲染路径的 10 条建议
把前面所有原理落到「首屏更快」上,给出 10 条可直接照做的清单:
- 非关键 JS 用
defer/async,避免阻塞 HTML 解析。 - 拆分 CSS 用媒体查询,让不阻塞渲染的样式不挡路。
- 内联首屏关键 CSS,其余异步加载,缩短首次渲染。
- 移除未使用 CSS,减小 CSSOM 构建与层叠计算成本。
- 图片声明
width/height(或aspect-ratio),避免到达后二次重排。 - 图片懒加载
loading="lazy",缩短关键渲染路径上的资源量。 - 预连接 / 预加载关键资源 :
dns-prefetch、preconnect、preload。 - 减少 DOM 节点数,降低布局 / 绘制 / 合成的整体开销。
- 批量改样式而非逐条改,避免布局抖动(参考第九节)。
- 动画走
transform/opacity,谨慎使用will-change,控制首屏内容体积。
第 1 条和第 2 条写成代码就是这样:
html
<!-- 脚本:defer 等 DOM 解析完再按顺序执行;async 下载完就执行,不保证顺序 -->
<script defer src="app.js"></script>
<script async src="analytics.js"></script>
<!-- CSS:print 媒体不阻塞屏幕渲染;screen 才阻塞 -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="main.css" media="screen">
<!-- 预连接 / 预加载关键资源,抢在请求之前建立连接 -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="preload" href="critical.css" as="style">
第 5、6 条:
html
<!-- 声明尺寸,占位不重排 -->
<img src="hero.png" alt="首图" width="1200" height="400">
<!-- 非首屏图片懒加载 -->
<img src="below-fold.jpg" alt="次屏" loading="lazy">

十二、常见误区与 DevTools 验证
最后澄清几个高频误区,并给出用 DevTools 自证的方法。
| 误区 | 真相 |
|---|---|
display:none 一定比 visibility:hidden 省 |
前者不进渲染树、后者进但占空间;要「彻底移除」用前者,要「保留布局」用后者,看场景 |
will-change 加得越多越快 |
分层耗显存,应针对性使用并在动画后回收,滥用更卡 |
| 改颜色一定比重排便宜 | 改颜色确实只重绘,但大面积 / 全屏重绘仍然贵,别轻视重绘成本 |
用了 transform 就绝对不重绘 |
仅当元素在自身合成层且只动 transform/opacity 时「只合成」;涉及其它属性仍会重绘 |
验证手段(以 Chrome 为例):
- Performance 面板 :录制一次交互,看 Layout / Paint / Composite 各占多少时间。掉帧时通常能看到长条的 Layout 或 Paint 任务;
- Paint Flashing 工具 (Rendering → Paint flashing):开启后重绘区域会绿色高亮。用
left做动画时满屏闪,用transform时基本不闪------直观验证属性选择; - 对比实测 :写一个
transition: left和一个transition: transform的方块,各跑一遍 Performance,对比 Layout 耗时差异。
把这套「看瀑布流 → 找最贵一步 → 换成更便宜的属性」的方法变成习惯,性能优化就不是玄学,而是可度量、可复现的工程动作。
参考资料
- MDN Web Docs:关键渲染路径(https://developer.mozilla.org/zh-CN/docs/Web/Performance/Critical_rendering_path)
- MDN Web Docs:CSS 性能优化(https://developer.mozilla.org/zh-CN/docs/learn/performance/css)
- 延伸阅读:浏览器渲染原理以及重排与重绘(https://blog.csdn.net/m0_47901007/article/details/125452798)
© 2026 | 转载请注明出处
结论:PASS