本文适合谁?
初中级前端开发者,想系统理解浏览器渲染原理。
希望写出高性能页面、减少卡顿和重排的开发者。
对 defer / async / DOMContentLoaded 一知半解,想彻底搞懂的人。
读完本文,你将能解释:
浏览器如何一步步把 HTML/CSS 变成页面。
为什么 transform 比 left 性能好得多。
怎样批量操作 DOM 才能不卡。
defer 和 async 到底怎么选。
一、概览:一次页面请求的“流水线”
想象浏览器是一个高度自动化的工厂,输入是 HTML、CSS、JavaScript,输出是屏幕上的像素。这条流水线大致分八个步骤:
1. 下载 HTML → 2. 解析 HTML(构建 DOM 树)
↓
3. 解析 CSS(构建 CSSOM 树) ← 4. 执行脚本(可能阻塞)
↓
5. 合并为渲染树(只包含可见元素)
↓
6. 布局(计算每个元素的位置和大小)
↓
7. 绘制(填充像素,生成绘制指令)
↓
8. 合成(GPU 合并图层,显示到屏幕)整个过程也叫 关键渲染路径 。优化页面,本质上就是缩短这条路径的时间。
通俗易懂流程图
具体流程:
下载 HTML → 同时触发预加载扫描器(提前请求图片、CSS、脚本等)。
解析 HTML → 生成 DOM 树(页面的结构骨架)。
解析 CSS → 生成 CSSOM 树(页面的样式规则)。合并为渲染树 → 只保留可见元素及其最终计算后的样式。
布局(Reflow) → 计算每个元素精确的几何尺寸和位置。
绘制(Paint) → 生成绘制指令(颜色、文字、边框等)。
合成(Composite) → GPU 将多个图层合并成最终图像。
显示到屏幕。
二、DOM 树:页面的骨架
2.1 解析过程不是“等全部下载完再开始”
浏览器非常聪明,它采用 流式解析 :一边从网络接收 HTML 字节流,一边解析。这样首屏能更快出现。
解析流程:
字节流 → 字符流 → Token(标签/文本/注释) → DOM 节点 → DOM 树2.2 预加载扫描器:提前“偷看”未来资源
主解析器在构建 DOM 时,另一个叫 预加载扫描器 的线程会扫描后续的 HTML 源码,一旦发现 <img>、<link>、<script> 等,就会立刻向网络层发起请求,不阻塞主解析。这就是为什么即使 script 放在后面,图片也能提前加载。
<!-- 预加载扫描器会并行请求 style.css 和 logo.png -->
<link rel="stylesheet" href="style.css">
<img src="logo.png" alt="logo">2.3 什么会阻塞 DOM 解析?
普通的 <script>(没有 defer 或 async):必须下载并执行完,才会继续解析。因为脚本可能用 document.write 修改 HTML。
CSS 文件 不阻塞 DOM 解析 ,但会阻塞 脚本执行(因为脚本可能需要知道样式)。
2.4 小口诀:DOM 树是结构的树
<html>
<head><title>我的页面</title></head>
<body>
<div class="box">
<h1>Hello</h1>
<p>World</p>
</div>
</body>
</html>对应的 DOM 树(简化):
Document
└─ html
├─ head
│ └─ title → "我的页面"
└─ body
└─ div.box
├─ h1 → "Hello"
└─ p → "World"三、CSSOM 树:页面的妆扮
3.1 CSS 解析独立但重要
CSS 文本被解析成 CSSOM 树(CSS Object Model)。它和 DOM 树结构并不完全一致,而是一组规则树,每个 DOM 元素最终会匹配到一组 CSS 声明。
body { font-size: 16px; color: #333; }
.box { width: 80%; margin: 0 auto; }
h1 { font-size: 2em; color: blue; }3.2 继承与计算
子元素会继承父元素的部分属性(如 color、font-size)。
相对单位(em、rem、%)会被转换成 绝对像素值。比如 h1 的 2em,在 body 字体为 16px 时,最终变成 32px。
3.3 重要事实:CSS 阻塞渲染
浏览器必须等 CSSOM 构建完成才能进入渲染阶段,因为“没有样式,怎么画?”所以首屏 CSS 越小越好,非关键 CSS 可以延迟加载。
四、渲染树:只有“可见”元素才上场
DOM 树 + CSSOM 树 → 渲染树。渲染树只包含 那些会显示在屏幕上的元素 :
元素/样式 | 是否进入渲染树 |
|---|---|
<head> 及其子元素 | 否 |
display: none | 否 |
visibility: hidden | 是,占位但看不见 |
<body>、<div>、<p>等普通元素 | 是 |
::before 伪元素 | 是 |
DOM 树 CSSOM 规则 渲染树
html body { display: block } body
├─head (忽略) .box { width: 100px } └─ div.box
└─body h1 { color: red } ├─ h1 "Hi"
└─div.box p { margin: 0 } └─ p "there"
├─h1
└─p注意:渲染树中的每个节点(通常叫 RenderObject)同时包含了从 CSSOM 计算来的样式。
五、布局(Layout / Reflow):计算精确位置
现在浏览器知道每个元素长什么样,但还不知道它们 具体在屏幕的哪个坐标 。布局阶段就是计算每个元素的宽度、高度、x、y 值。
5.1 触发布局的“雷区”
以下操作会触发整个页面或部分页面的重新布局(reflow),代价很高:
修改元素的 width、height、margin、padding、border。
改变 font-size、display(比如从 none 变成 block)。
添加或删除 DOM 节点。
读取布局属性(如 offsetTop、getBoundingClientRect())——这可能会 强制浏览器立即重新计算布局。
5.2 重排 vs 重绘 vs 合成
操作 | 触发重排 | 触发重绘 | 性能开销 |
|---|---|---|---|
改 width / height | 是 | 是 | 极高 |
改 color / background | 否 | 是 | 中等 |
改 transform / opacity | 否 | 否 | 低(仅合成) |
重排一定导致重绘,但重绘不一定导致重排。
5.3 实战:避免“强制同步布局”
// 灾难性写法:读 → 写 → 读 → 写...
const boxes = document.querySelectorAll('.box');
for (let box of boxes) {
const width = box.offsetWidth; // 读取,可能触发之前写入的布局
box.style.width = width + 10 + 'px'; // 写入,标记布局失效
}// 正确写法:先批量读取,再批量写入
const widths = [];
for (let box of boxes) {
widths.push(box.offsetWidth);
}
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = widths[i] + 10 + 'px';
}六、绘制(Paint)与合成(Composite)
6.1 绘制:生成“绘画说明书”
布局结束后,渲染引擎把每个可见元素转换成具体的绘制操作:画一个矩形、描一段文字、贴一张图片……这些指令被记录在 Display List 中。
6.2 分层:像 Photoshop 图层
浏览器会把页面拆成多个 图层。拥有以下特性的元素会获得独立图层:
根元素(html)。
设置了 transform: translateZ(0) 或 will-change: transform。
<video>、<canvas>、WebGL。
有 3D 变换或滤镜效果。
透明度动画(opacity 变化)。
独立图层的好处是:该图层的变化不会影响其他图层,且可以由 GPU 直接处理。
6.3 合成:GPU 加速的魔法
合成线程将各个图层栅格化(变成位图),然后通过 GPU 快速合并,输出到屏幕。
最佳性能动画属性:transform 和 opacity。因为它们不需要触发布局或绘制,只走合成。
/* 高性能动画 */
.card {
transition: transform 0.2s;
will-change: transform;
}
.card:hover {
transform: translateY(-5px);
}七、脚本与事件:defer、async 与 DOMContentLoaded
很多初学者搞不清脚本执行时机,这里用一个表格讲透。
7.1 三种脚本对比
类型 | 下载时机 | 执行时机 | 阻塞 HTML 解析 | 执行顺序 |
|---|---|---|---|---|
普通 <script> | 立即下载,阻塞解析 | 下载后立刻执行 | 阻塞 | 按文档顺序 |
<script defer> | 并行下载(不阻塞) | HTML 解析完成后,DOMContentLoaded 之前 | 不阻塞 | 按文档顺序 |
<script async> | 并行下载(不阻塞) | 下载完成后立刻执行(可能还没解析完) | 可能阻塞 | 不保证顺序 |
7.2 两个关键事件
DOMContentLoaded:整个 HTML 解析完成,并且所有 defer 脚本执行完毕 后触发。不等待 async、图片、样式表。
load:所有资源(图片、样式表、异步脚本、iframe 等)都加载完成后触发。
<head>
<script defer src="defer.js"></script>
<script async src="async.js"></script>
<link rel="stylesheet" href="style.css">
</head><body>
<img src="big.jpg">
<script>
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM 就绪,且所有 defer 脚本执行完了');
});
window.addEventListener('load', () => {
console.log('所有资源(含图片)加载完了');
});
console.log('内联脚本:此时 DOM 还在解析中');
</script>
</body>典型输出顺序:
内联脚本:此时 DOM 还在解析中
…(解析继续)…
(defer.js 执行)
DOM 就绪,且所有 defer 脚本执行完了
(async.js 可能在这之前或之后)
(图片加载完)
所有资源(含图片)加载完了八、初中级开发者必须掌握的优化技巧
8.1 关键渲染路径优化
内联首屏关键 CSS,避免额外网络请求阻塞渲染。
脚本尽量用 defer,除非确实需要尽早执行且不依赖 DOM(此时用 async)。
非关键 CSS 延迟加载:
<link rel="preload" href="non-critical.css" as="style" onload="this.rel='stylesheet'">8.2 减少重排与重绘
使用 document.createDocumentFragment 批量添加多个子节点。
改用 classList 切换整类,而不是逐个修改 style。
动画元素设置为 position: absolute 或 fixed,使其脱离文档流,减少对其他元素的影响。
8.3 使用现代 CSS 特性
属性 | 作用 |
|---|---|
will-change: transform | 提前告知浏览器该元素会变化,浏览器会为其创建独立层 |
contain: layout style paint | 告诉浏览器该元素的内部变化不会影响外部,可限制重排范围 |
content-visibility: auto | 自动跳过视口外元素的渲染(谨慎使用) |
8.4 资源提示帮你提速
<!-- 提前连接第三方来源 -->
<link rel="preconnect" href="https://api.example.com">
<!-- 预加载下一个页面需要的资源 -->
<link rel="prefetch" href="next.html">总结
概念 | 一句话记住 |
|---|---|
DOM 树 | HTML 解析出的页面结构树 |
CSSOM 树 | CSS 解析出的样式规则树,与 DOM 结合才能渲染 |
渲染树 | DOM + CSSOM,但剔除不可见元素 |
布局(重排) | 计算每个元素的位置和大小,开销最大 |
绘制 | 生成绘制指令,填充颜色、文字等 |
合成 | GPU 合并图层,transform/opacity 动画不走重排 |
普通脚本 | 阻塞 HTML 解析,应避免放在 <head> |
defer | 不阻塞解析,顺序执行,在 DOMContentLoaded 前完成 |
async | 不阻塞解析,下载完就执行,顺序不定 |
优化核心 | 减少重排、使用合成属性、批量 DOM 操作、合理放置脚本 |
把浏览器的渲染流水线装进心里,写出的代码会自然避开性能陷阱。如果你现在能回答“为什么 transform 更快”,以及“怎样避免强制同步布局”,恭喜你,已经超越了大部分初中级开发者。
想要更深入?可以阅读 Chrome 官方渲染性能指南 和 CSS Triggers 这个经典参考。