浏览器篇 | 渲染顺序
前言
当你在浏览器地址栏敲下回车的那一刻,到页面呈现在你眼前,中间经历了什么?这个问题几乎是前端面试的”开场白级”考题。
大部分人都能说出”HTML 解析成 DOM,CSS 解析成 CSSOM,然后合并成渲染树”这样的大致流程。但如果面试官追问下去:
- CSS 到底是阻塞 DOM 解析还是阻塞渲染?两者有什么区别?
- JS 为什么能阻塞 DOM 解析?是下载阻塞还是执行阻塞?
DOMContentLoaded和load事件分别在什么时机触发?它们和 CSS 加载有什么关系?- 什么是”关键渲染路径”?怎么优化它?
很多人就开始含糊了。
本章我们就把浏览器从拿到 HTML 到像素呈现在屏幕上的完整过程拆解清楚,让你面试时能讲出一条完整、准确的链路。
诊断自测
Q1:CSS 会阻塞 DOM 解析吗?会阻塞渲染吗?
点击查看答案
CSS 不阻塞 DOM 解析,但阻塞渲染。浏览器解析 HTML 构建 DOM 树的过程不会因为 CSS 而暂停,但在 CSSOM 构建完成之前,浏览器不会进行渲染(不会生成渲染树)。此外,CSS 还会阻塞其后 JS 的执行——因为 JS 可能会读取样式信息(比如 getComputedStyle),所以浏览器必须等 CSSOM 就绪后才执行 JS。
Q2:DOMContentLoaded 事件是在 DOM 解析完就触发,还是要等 CSS 加载完?
点击查看答案
DOMContentLoaded 是在DOM 解析完成后触发,不等待图片、样式表等子资源。但有一个例外:如果页面中有 <script> 标签(非 async),而这个脚本前面有 CSS 还没加载完,浏览器会等 CSS 加载完 → 执行 JS → 然后才能继续解析 DOM → 最终触发 DOMContentLoaded。所以 CSS 可能间接延迟 DOMContentLoaded。
Q3:Layout(布局)和 Paint(绘制)的区别是什么?
点击查看答案
Layout(也叫 Reflow,回流)计算的是每个元素在页面上的几何位置和大小——在哪里、多宽、多高。Paint(绘制)则是把元素的视觉属性画出来——颜色、边框、阴影、文字等。Layout 确定”在哪画、画多大”,Paint 确定”画成什么样”。改变元素的位置或尺寸会触发 Layout + Paint,而只改变颜色等视觉属性只触发 Paint。
一、关键渲染路径全景图
浏览器从接收到 HTML 到把像素画到屏幕上,经历以下主要步骤:
HTML 字节流
↓ 解码
字符流
↓ 词法分析(Tokenize)
Token 流
↓ 语法分析
DOM 树
+
CSS 字节流 → CSSOM 树
↓ 合并
Render Tree(渲染树)
↓
Layout(布局 / 回流)
↓
Paint(绘制)
↓
Composite(合成)
↓
屏幕上的像素
这条链路叫做关键渲染路径(Critical Rendering Path),是浏览器渲染性能的核心。下面我们逐步拆解每个环节。
二、HTML → DOM 树
解析过程
浏览器收到 HTML 后,经过以下步骤构建 DOM 树:
- 解码:将字节流按编码(如 UTF-8)转换为字符
- 词法分析(Tokenize):将字符流拆分成一个个 Token(如
StartTag: div、Character: hello、EndTag: div) - 语法分析(Parse):根据 Token 构建 DOM 节点,形成树形结构
<html>
<body>
<div>
<p>Hello</p>
</div>
</body>
</html>
解析后生成的 DOM 树:
Document
└── html
└── body
└── div
└── p
└── "Hello"
增量解析
DOM 的构建是增量的——浏览器不需要等整个 HTML 下载完才开始解析。网络传来一部分字节,就解析一部分,构建一部分 DOM 节点。这就是为什么大型页面可以”逐渐”显示出来。
什么会打断 DOM 解析
DOM 解析过程中,如果遇到以下内容,会暂停:
- 同步
<script>标签:暂停解析,下载并执行脚本 document.write():可能修改 token 流,导致解析器重新工作
这就是我们在后续章节(async 与 defer)会详细讲解的:普通 script 会阻塞 DOM 解析。
三、CSS → CSSOM 树
解析过程
和 DOM 的构建类似,浏览器也会将 CSS 解析成一个树形结构——CSSOM(CSS Object Model)。
body { font-size: 16px; }
div { color: red; }
div p { font-weight: bold; }
解析后生成的 CSSOM 树(简化示意):
body
font-size: 16px
└── div
color: red
└── p
font-weight: bold
CSSOM 是一棵树,而不是扁平的键值对列表,因为 CSS 属性会继承——子节点会继承父节点的某些样式。
CSS 阻塞渲染但不阻塞 DOM 解析
这个区分非常重要,也是面试高频考点:
- 不阻塞 DOM 解析:浏览器在等待 CSS 下载的同时,可以继续解析 HTML、构建 DOM 树
- 阻塞渲染:在 CSSOM 构建完成之前,浏览器不会进行渲染。因为没有 CSSOM 就无法构建渲染树,渲染树是渲染的前提
为什么要阻塞渲染?因为如果浏览器在 CSS 加载完之前就渲染页面,用户会先看到没有样式的”裸”页面,CSS 加载完后页面又突然变样——这种体验叫做 FOUC(Flash of Unstyled Content,无样式内容闪烁),非常糟糕。
CSS 阻塞 JS 执行
还有一个容易被忽略的关系:CSS 会阻塞其后 JS 的执行。
<link rel="stylesheet" href="style.css">
<script>
// 这段 JS 会等 style.css 加载完后才执行
console.log(getComputedStyle(document.body).fontSize);
</script>
为什么?因为 JS 可能需要读取元素的样式信息(通过 getComputedStyle、offsetWidth 等)。如果 CSS 还没加载完就让 JS 执行,JS 拿到的样式可能是错的。所以浏览器会保证:在一个 script 执行之前,它前面的所有 CSS 都已经加载并解析完毕。
这就导致了一个连锁效应:
CSS 下载中 → 阻塞 JS 执行 → JS 阻塞 DOM 解析 → DOM 解析被延迟
虽然 CSS 本身不阻塞 DOM 解析,但通过”阻塞 JS”这个中间环节,它可能间接延迟 DOM 的解析。
四、DOM + CSSOM → Render Tree(渲染树)
当 DOM 树和 CSSOM 树都准备好了,浏览器会将它们合并成一棵渲染树(Render Tree)。
渲染树的构建规则
- 从 DOM 树的根节点开始遍历
- 对每个可见节点,在 CSSOM 中找到对应的样式规则
- 将节点及其计算后的样式组合成渲染树的节点
哪些节点不会出现在渲染树中
display: none的元素:既不可见也不占空间,不会进入渲染树<head>、<script>、<meta>等非可视元素:不需要渲染visibility: hidden的元素:虽然不可见,但会出现在渲染树中(因为它仍然占据空间)
/* 不在渲染树中 */
.hidden { display: none; }
/* 在渲染树中(占空间但不可见) */
.invisible { visibility: hidden; }
/* 在渲染树中(完全透明但占空间且可交互) */
.transparent { opacity: 0; }
这也是一道经典面试题:display: none、visibility: hidden、opacity: 0 三者的区别。
| 属性 | 是否在渲染树中 | 是否占空间 | 是否可交互 | 是否触发重排 |
|---|---|---|---|---|
display: none | 否 | 否 | 否 | 切换时触发 |
visibility: hidden | 是 | 是 | 否 | 不触发 |
opacity: 0 | 是 | 是 | 是 | 不触发 |
五、Layout(布局 / 回流)
渲染树告诉浏览器”哪些节点需要显示,以及它们的样式是什么”,但还没有确定每个节点的具体位置和大小。这就是 Layout 阶段要做的事。
Layout 做什么
Layout(也叫 Reflow)的任务是:根据渲染树中每个节点的样式,计算出它在视口中的精确几何信息——位置(x, y)、宽度、高度。
这个过程是递归的:父元素的大小可能依赖子元素(比如 height: auto),子元素的位置又依赖父元素。浏览器需要在整棵树上做多次遍历才能确定所有节点的布局信息。
什么操作会触发 Layout
Layout 是一个计算密集的操作。以下操作会触发 Layout:
- 改变元素的几何属性:
width、height、padding、margin、border - 改变元素的位置:
top、left、position - 改变字体大小
- 改变窗口大小(resize)
- 读取某些布局属性:
offsetWidth、offsetHeight、clientWidth、scrollTop、getComputedStyle()
最后一条容易被忽略:读取布局属性也会触发 Layout。因为浏览器必须保证你读到的值是最新的,所以它不得不先执行一次 Layout。
// ❌ 糟糕的写法:每次循环都触发 Layout
for (let i = 0; i < 100; i++) {
el.style.width = el.offsetWidth + 1 + 'px'; // 读 + 写交替
}
// ✅ 好的写法:先批量读,再批量写
const width = el.offsetWidth; // 读一次
for (let i = 0; i < 100; i++) {
el.style.width = width + i + 1 + 'px'; // 只写
}
上面的反面例子就是所谓的强制同步布局(Forced Synchronous Layout),也叫布局抖动(Layout Thrashing),是性能杀手。
六、Paint(绘制)
Layout 确定了每个元素的位置和大小后,浏览器进入 Paint 阶段。
Paint 做什么
Paint 的任务是:将每个元素的视觉属性转换成实际的像素——颜色、背景、边框、阴影、文字等。
Paint 不关心元素在哪、多大(那是 Layout 的事),它只关心”画成什么样”。
绘制顺序
浏览器按照**层叠顺序(stacking order)**绘制元素。大致顺序是:
- 背景色
- 背景图
- 边框
- 子元素
- 轮廓(outline)
什么操作只触发 Paint 不触发 Layout
如果你只改变了元素的视觉属性,但没有改变它的几何信息,就只会触发 Paint 而不触发 Layout:
colorbackground-colorbox-shadowvisibilityborder-color(不改变 border-width)
只触发 Paint 的操作比触发 Layout 的操作开销小得多。 这也是性能优化的一个方向:尽量让动画只涉及不触发 Layout 的属性。
七、Composite(合成)
现代浏览器不会把所有内容都画在同一个画布上。它会将页面分成多个图层(Layer),分别绘制后再合成到一起。
为什么需要图层
当页面中有动画、transform、固定定位等元素时,如果每一帧都重新绘制整个页面,性能开销太大。把这些元素提升为独立的图层,改变时只需要重新绘制/合成这一层,其他层不受影响。
什么情况会创建新图层
以下情况会让浏览器为元素创建独立的合成层:
transform的 3D 变换(translate3d、translateZ)will-change属性position: fixed<video>、<canvas>、<iframe>元素- 有 CSS 动画的
opacity或transform
GPU 加速
合成阶段通常由 GPU 完成,比 CPU 绘制快得多。这就是为什么人们说”用 transform 做动画比用 left/top 做动画更流畅”:
/* ❌ 触发 Layout + Paint,由 CPU 处理 */
.move-bad {
animation: move-bad 1s;
}
@keyframes move-bad {
to { left: 100px; }
}
/* ✅ 只触发 Composite,由 GPU 处理 */
.move-good {
animation: move-good 1s;
}
@keyframes move-good {
to { transform: translateX(100px); }
}
transform 和 opacity 的动画可以完全由合成器线程处理,不需要主线程参与,也不会触发 Layout 和 Paint。这是实现 60fps 流畅动画的关键。
完整渲染流水线
把上面的所有步骤串起来:
JavaScript → Style(重新计算样式) → Layout → Paint → Composite
这就是浏览器的渲染流水线(Rendering Pipeline)。不同类型的 CSS 属性变化会走不同长度的流水线:
| 属性类型 | 经过的阶段 | 示例 |
|---|---|---|
| 几何属性 | Style → Layout → Paint → Composite | width, height, margin |
| 视觉属性 | Style → Paint → Composite | color, background, box-shadow |
| 合成属性 | Style → Composite | transform, opacity |
优化原则:尽量使用只触发 Composite 的属性做动画。
八、DOMContentLoaded vs load 事件
这两个事件的区别是高频面试题,也是理解渲染流程的关键节点。
DOMContentLoaded
触发时机:HTML 文档被完全解析,DOM 树构建完成。不等待样式表、图片、子框架等外部资源。
document.addEventListener('DOMContentLoaded', () => {
// DOM 已就绪,可以安全操作 DOM
console.log(document.getElementById('app'));
});
但有一个重要的细节:DOMContentLoaded 会等待同步 script 执行完。因为 script 可能会修改 DOM,所以 DOM 解析不会在 script 执行完之前结束。而 CSS 又会阻塞 script 的执行,所以 CSS 可能间接延迟 DOMContentLoaded。
<link rel="stylesheet" href="heavy.css"> <!-- 加载很慢 -->
<script src="app.js"></script> <!-- 等 CSS 加载完才执行 -->
<!-- DOMContentLoaded 要等 app.js 执行完才触发 -->
load
触发时机:页面上所有资源(包括图片、样式表、脚本、子框架等)都完全加载完毕。
window.addEventListener('load', () => {
// 所有资源都加载完了
console.log('页面完全加载');
});
load 事件通常比 DOMContentLoaded 晚很多,因为图片等资源的下载可能很慢。
beforeunload 和 unload
除了 DOMContentLoaded 和 load,页面生命周期中还有两个重要事件:
beforeunload:用户即将离开页面时触发,可以用来弹出”确认离开”的对话框unload:页面正在被卸载时触发,通常用来做清理工作(但现代浏览器中unload的可靠性有限)
完整的生命周期:
DOMContentLoaded → load → beforeunload → unload
实际中怎么选
| 需求 | 事件 |
|---|---|
| 操作 DOM 元素 | DOMContentLoaded |
| 需要图片尺寸等信息 | load |
| 初始化交互逻辑 | DOMContentLoaded |
| 统计页面加载时间 | load |
在现代框架(React、Vue)中,这些事件的使用频率已经降低了——框架会在组件的生命周期钩子中处理这些逻辑。但理解它们对于排查性能问题仍然很重要。
九、首屏渲染优化策略
理解了关键渲染路径后,优化思路就很清晰了:缩短关键渲染路径的长度和时间。
1. 减少关键资源数量
- 内联首屏关键 CSS(Critical CSS),减少 CSS 请求
- 使用
defer或async加载非关键 JS - 去除首屏不需要的 CSS 和 JS
2. 减少关键资源大小
- 压缩 HTML、CSS、JS(minify + gzip/brotli)
- 使用 Tree Shaking 去除未使用的代码
- 图片使用现代格式(WebP、AVIF)并适当压缩
3. 减少关键渲染路径长度
- 使用
preload预加载关键资源 - 减少 CSS 和 JS 的链式依赖(A 依赖 B,B 依赖 C)
- 使用 HTTP/2 的多路复用减少请求阻塞
4. 优化渲染性能
- 避免强制同步布局
- 使用
transform和opacity做动画 - 使用
will-change提示浏览器创建合成层(但不要滥用) - 使用
requestAnimationFrame而不是setTimeout做动画
5. 骨架屏和渐进式渲染
- 在主内容加载前先显示骨架屏(Skeleton Screen),给用户”正在加载”的感知
- 服务端渲染(SSR)让首屏 HTML 直接包含内容,不需要等 JS 执行
<!-- 骨架屏示例 -->
<div id="app">
<div class="skeleton">
<div class="skeleton-header"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</div>
<script defer src="app.js"></script>
常见误区
误区一:“CSS 阻塞 DOM 解析”
CSS 不阻塞 DOM 解析,只阻塞渲染。浏览器可以在等待 CSS 下载的同时继续解析 HTML、构建 DOM。但 CSS 会阻塞 JS 执行,而 JS 会阻塞 DOM 解析,所以 CSS 可能通过这条链路间接延迟 DOM 解析。直接说”CSS 阻塞 DOM 解析”是不准确的。
误区二:“重绘(Repaint)一定比回流(Reflow)开销小”
一般情况下确实如此,因为回流涉及几何计算且可能影响整棵树,而重绘只处理视觉属性。但如果重绘的区域很大(比如改变整个页面的背景色),开销也不小。而如果回流只影响了一个独立层中的一个小元素,开销可能很小。所以不能一概而论,要看具体情况。
误区三:“DOM 树和渲染树是一一对应的”
不是。渲染树只包含可见节点。display: none 的元素、<head> 中的内容、<script> 标签等都不会出现在渲染树中。另外,一些 CSS 属性(如 ::before、::after 伪元素)会在渲染树中创建节点,但它们在 DOM 树中并不存在。
误区四:“用了 GPU 加速就一定更快”
过度使用 will-change 或 transform: translateZ(0) 来”hack”GPU 加速,会导致创建大量合成层,反而增加内存消耗和合成开销。每个合成层都需要独立的纹理内存。正确的做法是:只对确实需要频繁变化的元素使用 GPU 加速,而不是给每个元素都加。
小结
本章从 HTML 字节流开始,完整走了一遍浏览器的关键渲染路径。
核心要点
- 关键渲染路径:HTML → DOM、CSS → CSSOM → 合并成 Render Tree → Layout → Paint → Composite
- CSS 阻塞渲染不阻塞 DOM 解析:CSSOM 未就绪时不会渲染,但 DOM 解析可以继续
- CSS 阻塞 JS 执行:JS 可能读取样式,所以浏览器等 CSSOM 就绪后才执行 JS
- JS 阻塞 DOM 解析:同步 script 会暂停 HTML 解析
- 渲染树只包含可见节点:
display: none不在渲染树中 - Layout 计算几何信息,Paint 绘制视觉属性,Composite 合成图层
transform和opacity动画只触发 Composite,性能最好DOMContentLoaded在 DOM 解析完触发,load在所有资源加载完触发
本章思维导图
- 关键渲染路径
- HTML → DOM 树(增量解析)
- CSS → CSSOM 树
- DOM + CSSOM → Render Tree
- Layout(布局 / 回流)
- Paint(绘制 / 重绘)
- Composite(合成)
- 阻塞关系
- CSS 不阻塞 DOM 解析,阻塞渲染
- CSS 阻塞其后的 JS 执行
- 同步 JS 阻塞 DOM 解析
- CSS → 阻塞 JS → 阻塞 DOM(间接链路)
- 渲染树
- 只包含可见节点
- display: none 不在渲染树中
- visibility: hidden 在渲染树中
- 渲染流水线
- 几何属性变化:Layout → Paint → Composite
- 视觉属性变化:Paint → Composite
- 合成属性变化:Composite
- 重要事件
- DOMContentLoaded:DOM 解析完
- load:所有资源加载完
- beforeunload / unload
- 首屏优化
- 减少关键资源数量
- 减少关键资源大小
- 缩短关键路径长度
- 用 transform/opacity 做动画
- 骨架屏 / SSR
练习挑战
第一题(⭐ 基础):事件触发顺序
document.addEventListener('DOMContentLoaded', () => {
console.log('A: DOMContentLoaded');
});
window.addEventListener('load', () => {
console.log('B: load');
});
console.log('C: inline script');
假设页面中有一张很大的图片,请问 A、B、C 的输出顺序是什么?
点击查看答案与解析
输出顺序:C → A → B
C:内联脚本同步执行,最先输出A:DOM 解析完毕后触发,不等待图片B:所有资源(包括那张大图片)加载完后触发,最后输出
第二题(⭐⭐ 进阶):分析阻塞关系
<head>
<link rel="stylesheet" href="style.css"> <!-- 假设加载需要 2 秒 -->
<script src="app.js"></script> <!-- 假设加载需要 1 秒 -->
</head>
<body>
<div id="app">Hello</div>
</body>
请问:
app.js什么时候开始执行?#app节点什么时候被解析出来?DOMContentLoaded大约在第几秒触发?
点击查看答案与解析
-
app.js在style.css加载完后才能执行(CSS 阻塞 JS)。假设两者并行下载,CSS 2 秒下完,JS 1 秒下完但要等 CSS,所以 app.js 在第 2 秒开始执行。 -
app.js是同步 script,它阻塞 DOM 解析。#app在 script 之后,所以要等 script 执行完才能被解析出来。#app在第 2 秒 + JS 执行时间后被解析。 -
DOMContentLoaded在整个 DOM 解析完后触发。大约在第 2 秒 + JS 执行时间后触发。
这个例子很好地展示了 CSS → 阻塞 JS → 阻塞 DOM 解析的间接链路。
第三题(⭐⭐⭐ 综合):优化下面的代码
下面的代码有严重的性能问题,请找出问题并优化:
const list = document.getElementById('list');
const items = document.querySelectorAll('.item');
items.forEach(item => {
// 读取布局信息
const height = item.offsetHeight;
// 根据高度设置新样式
item.style.marginTop = height / 2 + 'px';
// 读取新的布局信息
const newHeight = item.offsetHeight;
// 设置宽度
item.style.width = newHeight * 2 + 'px';
});
点击查看答案与解析
问题:布局抖动(Layout Thrashing)。循环中反复交替读写布局属性,每次读取都会触发强制同步布局。
优化方案:先批量读取,再批量写入。
const list = document.getElementById('list');
const items = document.querySelectorAll('.item');
// 第一遍:批量读取所有需要的布局信息
const measurements = Array.from(items).map(item => ({
height: item.offsetHeight
}));
// 第二遍:批量写入
items.forEach((item, i) => {
const marginTop = measurements[i].height / 2;
item.style.marginTop = marginTop + 'px';
});
// 如果还需要基于新的布局信息做操作,用 requestAnimationFrame 分帧处理
requestAnimationFrame(() => {
const newMeasurements = Array.from(items).map(item => ({
height: item.offsetHeight
}));
items.forEach((item, i) => {
item.style.width = newMeasurements[i].height * 2 + 'px';
});
});
核心原则:读写分离。将所有读操作集中在一起,所有写操作集中在一起,避免读写交替导致的强制同步布局。如果需要基于写入后的新值再做操作,用 requestAnimationFrame 延迟到下一帧。
自我检测
- 能完整说出关键渲染路径的六个步骤:DOM → CSSOM → Render Tree → Layout → Paint → Composite
- 能区分”CSS 阻塞渲染”和”CSS 阻塞 DOM 解析”,以及解释 CSS 如何间接影响 DOM 解析
- 能说出
display: none、visibility: hidden、opacity: 0在渲染树和行为上的区别 - 能区分 Layout(回流)和 Paint(重绘),以及知道哪些 CSS 属性只触发 Paint
- 能解释为什么
transform做动画比left/top更流畅 - 能说清楚
DOMContentLoaded和load的触发时机和区别 - 能识别代码中的布局抖动(Layout Thrashing)问题并给出优化方案
- 能列出至少三种首屏渲染优化策略
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90