浏览器渲染原理
当你在浏览器地址栏输入一个 URL 并按下回车,浏览器到底做了什么才让一个网页"活"起来?这背后涉及网络请求、HTML/CSS 解析、DOM 构建、布局计算、像素绘制等一系列复杂过程。理解浏览器渲染原理,不仅是前端性能优化的基础,也是面试中的重点考察内容。
从输入 URL 到页面渲染的全流程
在深入渲染细节之前,先从宏观角度了解浏览器处理一个页面请求的完整过程:
本文重点聚焦上图中的第 6 步 —— 浏览器解析与渲染,这是前端工程师最需要深入理解的部分。
浏览器的多进程架构
现代浏览器(以 Chrome 为例)采用多进程架构,渲染过程涉及多个进程的协作:
| 进程 | 职责 |
|---|---|
| 浏览器主进程 | 管理地址栏、书签、前进/后退,协调其他进程 |
| 渲染进程 | 解析 HTML/CSS,执行 JS,完成页面渲染(每个标签页通常一个渲染进程) |
| 网络进程 | 负责网络资源的请求和加载 |
| GPU 进程 | 处理 GPU 任务,如合成层的绘制和页面的最终呈现 |
| 插件进程 | 运行浏览器插件(每个插件一个进程) |
多进程架构的最大好处是隔离:一个标签页崩溃不会影响其他标签页,一个插件崩溃不会导致整个浏览器卡死。同时也提供了更好的安全性,渲染进程运行在沙箱中,即使被恶意代码攻击也无法直接访问系统资源。
渲染进程中的线程
渲染进程是页面渲染的核心,它内部又包含多个重要线程:
| 线程 | 职责 |
|---|---|
| 主线程(Main Thread) | 解析 HTML/CSS、构建 DOM/CSSOM、计算样式、布局、绘制,执行 JavaScript |
| 合成线程(Compositor Thread) | 接收绘制指令,将页面分层,交给光栅化线程处理 |
| 光栅化线程(Raster Threads) | 将图层的绘制指令转化为实际的像素位图 |
| JS 引擎线程(V8) | 解析和执行 JavaScript 代码 |
主线程和 JS 引擎线程是互斥的 —— 它们无法同时工作。当 JavaScript 执行时,渲染会被阻塞;当渲染进行时,JavaScript 无法执行。这就是为什么长时间运行的 JS 代码会导致页面卡顿。
渲染流水线(Rendering Pipeline)
浏览器拿到 HTML 文档后,渲染进程的主线程会按照以下流水线依次处理:
下面我们逐一详细讲解每个步骤。
第一步:构建 DOM 树
浏览器从网络进程拿到 HTML 的原始字节流后,需要将其转换为一棵DOM 树(Document Object Model)。
转换过程
具体步骤:
- 字节 → 字符:根据编码格式(如 UTF-8)将原始字节转换为字符串
- 字符 → 令牌(Token):通过词法分析(Tokenization) 将字符串拆分为一个个有意义的标记,比如
<html>、<body>、<p>Hello</p>等 - 令牌 → 节点:将令牌转换为具有属性和规则的节点对象
- 节点 → DOM 树:根据标签的嵌套关系,将节点组装成一棵树形结构
示例
对于如下 HTML:
<!DOCTYPE html>
<html>
<head>
<title>示例页面</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<h1>Hello</h1>
<p>World</p>
</body>
</html>
生成的 DOM 树结构如下:
- DOM 树是增量构建的,解析器不会等整个 HTML 下载完才开始,而是边接收数据边解析
- 每个 HTML 标签对应一个 DOM 节点,文本内容也是节点(文本节点)
- DOM 树同时也是 JavaScript 操作页面的接口(
document.getElementById等方法就是在操作 DOM 树)
HTML 解析的容错机制
HTML 解析器非常"宽容",即使 HTML 有语法错误,浏览器也会尽力修复并正常显示:
<!-- 浏览器能"修复"的错误 HTML -->
<p>段落一
<p>段落二
<!-- 浏览器会自动补上 </p> -->
<table>
<tr><td>单元格</td></tr>
<!-- 浏览器会自动补上 </table> -->
这种容错机制虽然方便,但不应该依赖它,编写规范的 HTML 有助于提升解析效率。
第二步:构建 CSSOM 树
在解析 HTML 的过程中,如果遇到 CSS 资源(<link> 标签或 <style> 标签),浏览器会解析 CSS 并构建CSSOM 树(CSS Object Model)。
转换过程
CSS 的解析过程与 HTML 类似:
示例
对于如下 CSS:
body {
font-size: 16px;
}
h1 {
color: red;
font-weight: bold;
}
p {
color: blue;
}
生成的 CSSOM 树结构:
- 层叠(Cascade):当多条规则作用于同一元素时,按照优先级规则(内联 > ID > 类 > 标签)决定最终生效的样式
- 继承(Inheritance):某些属性(如
font-size、color)会从父元素自动继承给子元素
CSS 解析的特殊性
与 DOM 树的增量构建不同,CSSOM 树的构建具有如下特点:
- CSS 是渲染阻塞资源:浏览器必须等 CSSOM 构建完成后,才能进入下一步(样式计算),因为不完整的 CSS 可能导致样式错误
- CSS 不会阻塞 DOM 的解析:HTML 解析和 CSS 解析可以并行进行
- CSS 会阻塞 JS 的执行:因为 JS 可能会读取元素的样式信息(如
getComputedStyle),所以浏览器必须保证 CSSOM 已经就绪
第三步:样式计算(Computed Style)
有了 DOM 树和 CSSOM 树后,浏览器需要计算出每个 DOM 节点的最终样式。这个过程叫做样式计算。
样式计算主要做三件事:
1. 格式化样式值
将 CSS 中各种写法的值统一转换为标准化的计算值(Computed Value):
/* 原始写法 */
p {
font-size: 2em; /* 相对单位 */
color: red; /* 颜色关键字 */
width: 50%; /* 百分比 */
font-weight: bold; /* 关键字 */
}
/* 计算后的值 */
p {
font-size: 32px; /* 转为绝对像素 */
color: rgb(255, 0, 0); /* 转为 RGB 值 */
width: 300px; /* 转为绝对像素(假设父元素 600px) */
font-weight: 700; /* 转为数值 */
}
2. 处理继承
对于可继承属性,如果元素自身没有设置值,就从父元素获取:
body {
font-size: 16px;
color: #333;
}
/* h1 没有设置 color,自动继承 body 的 color: #333 */
3. 层叠规则
当多个来源的样式冲突时,按优先级决定最终值:
浏览器默认样式 < 用户样式 < 开发者样式 < 开发者 !important < 用户 !important
在同一来源中:
内联 style > #id 选择器 > .class 选择器 > 标签选择器
在 Chrome DevTools 中,选中任意元素 → 切换到 Computed 面板,就能看到该元素所有属性的最终计算值。
第四步:布局(Layout)
样式计算完成后,浏览器已经知道每个节点"长什么样"了,但还不知道它们在页面上的具体位置和大小。布局阶段就是计算这些信息的过程。
构建布局树(Layout Tree)
布局阶段首先会构建一棵布局树,它和 DOM 树有几个关键区别:
布局树与 DOM 树的区别:
| 对比项 | DOM 树 | 布局树 |
|---|---|---|
display: none 的元素 | 存在 | 不存在(不参与布局) |
head 元素 | 存在 | 不存在(不可见) |
CSS 伪元素(::before) | 不存在 | 存在(参与布局) |
visibility: hidden 的元素 | 存在 | 存在(占位但不可见) |
布局计算
布局树构建完成后,浏览器会遍历布局树,计算每个节点的精确位置和大小。这涉及复杂的计算逻辑,包括:
- 盒模型计算:
content+padding+border+margin - 文档流:正常流、浮动、定位
- Flex/Grid 布局:弹性盒子和网格布局的空间分配
- 文本换行:根据容器宽度计算文本在哪里换行
第五步:分层(Layer)
布局完成后,浏览器并不会直接开始绘制整个页面,而是会先将页面分成多个图层(Layer)。
为什么要分层?
想象你在画一幅风景画。如果所有内容(天空、山、树、人物)都画在同一张纸上,要修改人物的位置就需要重画整幅画。但如果用透明胶片分层绘制,修改人物只需要换一张胶片,其他层保持不变。
浏览器分层的原理是一样的 —— 当某个层的内容发生变化时,只需要重新处理该层,而不用影响其他层。
哪些情况会创建新的图层?
| 创建方式 | 触发条件 | 说明 |
|---|---|---|
| 显式创建 | position: fixed / sticky | 固定定位和粘性定位 |
| 显式创建 | will-change: transform 等 | 开发者主动提示 |
| 显式创建 | transform: translateZ(0) | 3D 变换触发 |
| 显式创建 | opacity 小于 1 且有动画 | 透明度动画 |
| 显式创建 | <video>、<canvas>、<iframe> | 特殊元素 |
| 隐式创建 | 与已有合成层重叠 | 浏览器自动提升,可能导致层爆炸 |
如果页面中有大量元素与合成层重叠,浏览器可能会自动为它们都创建独立图层,导致内存占用飙升。这种现象叫做层爆炸(Layer Explosion)。可以通过 Chrome DevTools 的 Layers 面板查看页面的图层情况。
第六步:绘制(Paint)
分层完成后,主线程会为每个图层生成一系列绘制指令(Paint Records),类似于"先画一个红色矩形在 (10, 20) 位置,再画一段文字在 (10, 50) 位置"这样的指令清单。
注意:绘制阶段只是生成了"怎么画"的指令,还没有真正将像素画到屏幕上。真正的像素填充发生在下一步(光栅化)。
绘制顺序
同一图层内,元素的绘制遵循层叠上下文(Stacking Context) 的规则,从底到顶依次是:
- 背景色 / 背景图
z-index为负值的元素- 正常文档流中的块级元素
- 浮动元素
- 正常文档流中的行内元素
z-index: 0/auto的定位元素z-index为正值的定位元素
第七步:分块与光栅化(Tiling & Raster)
绘制指令生成后,主线程的工作告一段落,接下来的工作由合成线程接管。
分块(Tiling)
合成线程会将每个图层切分成许多小的图块(Tile),通常是 256×256 或 512×512 像素大小。
光栅化(Rasterization)
分块后,合成线程会将图块交给光栅化线程池,将绘制指令转换为真正的像素位图(Bitmap)。
浏览器会优先光栅化视口(Viewport)附近的图块,用户当前看到的区域最先处理,远离视口的部分稍后处理。这就是为什么快速滚动页面时,有时会看到空白区域短暂出现。
现代浏览器的光栅化通常由 GPU 加速完成,这也是为什么 GPU 进程在浏览器中如此重要。
第八步:合成与显示(Composite & Display)
所有图块光栅化完成后,合成线程会生成绘制四边形(Draw Quads) 信息,标明每个图块在屏幕上的位置和变换参数,然后将这些信息打包成一个合成帧(Compositor Frame) 发送给 GPU 进程。
GPU 进程收到合成帧后,将各个图块的位图按照正确的位置和顺序合成在一起,最终呈现在屏幕上。
合成阶段完全在合成线程和 GPU 进程上执行,不需要主线程参与。这意味着即使主线程被 JavaScript 阻塞,通过 transform 和 opacity 实现的动画依然可以流畅运行,因为它们只需要在合成阶段处理。
完整渲染流水线总结
将上述所有步骤整合在一起:
关键渲染路径(Critical Rendering Path)
关键渲染路径是指浏览器从接收 HTML 到首次渲染像素到屏幕上,必须经历的最短路径。优化关键渲染路径是提升首屏加载速度的核心。
关键渲染路径上的阻塞资源
理解哪些资源会阻塞渲染,对性能优化至关重要:
| 资源类型 | 阻塞 DOM 解析? | 阻塞渲染? | 说明 |
|---|---|---|---|
CSS(<link>) | 否 | 是 | CSS 不阻塞 DOM 解析,但阻塞渲染和 JS 执行 |
JS(<script>) | 是 | 是 | 默认阻塞 DOM 解析和渲染 |
JS(<script async>) | 否 | 可能 | 异步下载,下载完立即执行(可能阻塞) |
JS(<script defer>) | 否 | 否 | 异步下载,DOMContentLoaded 前按序执行 |
| 图片 | 否 | 否 | 不阻塞,但影响页面完全加载时间 |
| 字体 | 否 | 可能 | 可能导致文字闪烁(FOIT/FOUT) |
JavaScript 对渲染的影响
JavaScript 是渲染过程中最大的"搅局者"。了解 JS 如何影响渲染,才能写出不阻塞页面的代码。
为什么 JS 会阻塞 DOM 解析?
因为 JavaScript 可以通过 document.write() 等方法修改 DOM 结构,浏览器无法预知脚本会做什么,所以遇到 <script> 标签时必须暂停 DOM 解析,等待脚本下载并执行完毕。
<!-- DOM 解析会在这里暂停 -->
<script src="app.js"></script>
<!-- 等 app.js 下载 + 执行完毕,DOM 解析才能继续 -->
async 和 defer 的区别
为了减少 JS 对渲染的阻塞,可以使用 async 或 defer 属性:
| 属性 | 下载时机 | 执行时机 | 执行顺序 | 适用场景 |
|---|---|---|---|---|
| 无 | 遇到即下载 | 下载完立即执行 | 按出现顺序 | 需要立即执行且有依赖的脚本 |
async | 与 HTML 解析并行 | 下载完立即执行 | 不保证顺序 | 独立的第三方脚本(统计、广告) |
defer | 与 HTML 解析并行 | DOM 解析完成后、DOMContentLoaded 之前 | 按出现顺序 | 需要操作 DOM 的主业务脚本 |
<!-- 推荐的脚本加载方式 -->
<!-- 第三方独立脚本:用 async -->
<script async src="analytics.js"></script>
<script async src="ads.js"></script>
<!-- 主业务脚本:用 defer -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>
- 将
<script>标签放在<body>底部,或使用defer属性 - 独立脚本使用
async - 有依赖关系的脚本使用
defer(保证执行顺序) - 内联关键 JS(首屏需要的少量代码)直接写在
<head>中
CSS 对渲染的影响
CSS 是渲染阻塞资源
虽然 CSS 不阻塞 DOM 解析,但它阻塞渲染。浏览器不会在 CSSOM 构建完成之前进行任何渲染——因为没有样式信息的页面会呈现"裸奔"状态(无样式内容闪烁,即 FOUC)。
CSS 阻塞 JS 执行
如果一个 <script> 标签前面有尚未加载完成的 CSS,JS 的执行会被推迟,因为 JS 可能会读取元素的样式信息:
<link rel="stylesheet" href="style.css" />
<!-- 如果 style.css 还没加载完,下面的脚本不会执行 -->
<script>
// 可能会读取样式信息
console.log(getComputedStyle(document.body).color);
</script>
CSS 优化建议
<!-- 1. 将关键 CSS 内联到 <head> 中 -->
<style>
/* 首屏关键样式 */
.header { display: flex; height: 60px; }
.hero { min-height: 400px; }
</style>
<!-- 2. 非关键 CSS 异步加载 -->
<link rel="preload" href="non-critical.css" as="style"
onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="non-critical.css" /></noscript>
<!-- 3. 使用 media 属性按条件加载 -->
<link rel="stylesheet" href="print.css" media="print" />
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)" />
页面生命周期事件
在渲染过程中,浏览器会在不同阶段触发关键事件:
| 事件 | 触发时机 | 常见用途 |
|---|---|---|
DOMContentLoaded | HTML 解析完成,DOM 树就绪(不等图片等资源) | 初始化 JS 逻辑、绑定事件 |
load | 所有资源(图片、CSS、iframe 等)加载完毕 | 启动需要完整资源的功能 |
beforeunload | 用户即将离开页面 | 提示用户保存未提交的数据 |
unload | 页面正在卸载 | 发送统计数据(不推荐,用 sendBeacon) |
// DOMContentLoaded:DOM 就绪后执行
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM 已就绪,可以操作 DOM 了');
const btn = document.getElementById('my-btn');
btn.addEventListener('click', handleClick);
});
// load:所有资源加载完毕后执行
window.addEventListener('load', () => {
console.log('页面完全加载(包括图片等资源)');
// 可以获取图片的实际尺寸等信息
});
// beforeunload:用户离开前提示
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault(); // 现代浏览器会显示默认提示
}
});
渲染性能优化总结
基于对渲染原理的理解,以下是一份系统化的性能优化清单:
减少关键资源
| 优化策略 | 具体做法 |
|---|---|
| 内联关键 CSS | 首屏所需的 CSS 直接写在 <style> 中 |
| 异步加载非关键 CSS | 使用 preload + onload 模式 |
使用 defer / async | 避免 JS 阻塞 DOM 解析 |
| 移除未使用的 CSS/JS | 使用 Tree Shaking、PurgeCSS 等工具 |
减少渲染流水线开销
| 优化策略 | 具体做法 |
|---|---|
使用 transform / opacity 做动画 | 跳过布局和绘制阶段 |
| 避免强制同步布局 | 不在修改后立即读取布局属性 |
| 减少 DOM 操作 | 使用 DocumentFragment、虚拟列表 |
使用 content-visibility: auto | 让浏览器跳过屏幕外元素的渲染 |
| 减少选择器复杂度 | 避免深层嵌套的 CSS 选择器 |
使用现代 API
// 1. content-visibility:跳过屏幕外元素的渲染
// CSS:
// .card { content-visibility: auto; contain-intrinsic-size: 200px; }
// 2. requestIdleCallback:在浏览器空闲时执行低优先级任务
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0) {
// 执行非紧急任务
doSomeNonUrgentWork();
}
});
// 3. Intersection Observer:懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 懒加载图片
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
面试高频问答
Q1:请描述浏览器从接收到 HTML 到渲染出页面的完整过程。
答: 浏览器渲染的完整过程如下:
- 构建 DOM 树:解析 HTML 字节流,经过字节 → 字符 → Token → 节点 → DOM 树的转换
- 构建 CSSOM 树:解析 CSS 资源,生成 CSS 对象模型
- 样式计算:将 CSS 值标准化(如
em转px),处理继承和层叠,计算每个节点的最终样式 - 布局:构建布局树(排除
display: none的元素,加入伪元素),计算每个节点的位置和大小 - 分层:将页面分成多个图层,方便局部更新
- 绘制:为每个图层生成绘制指令列表
- 分块:合成线程将图层切分为小图块
- 光栅化:将图块转换为像素位图(GPU 加速)
- 合成:将所有图块合成为最终画面,显示在屏幕上
Q2:CSS 和 JS 是如何影响渲染的?它们的加载会阻塞什么?
答:
- CSS:不阻塞 DOM 解析,但阻塞渲染(浏览器不会在 CSSOM 就绪前渲染页面)。另外 CSS 还会阻塞后续 JS 的执行,因为 JS 可能需要读取样式信息。
- JS(无 async/defer):既阻塞 DOM 解析,也阻塞渲染。遇到
<script>标签时,浏览器必须暂停 HTML 解析,等脚本下载并执行完毕才能继续。 - JS(async):不阻塞 DOM 解析(并行下载),但下载完后会立即执行,此时会暂停 DOM 解析。
- JS(defer):不阻塞 DOM 解析(并行下载),等 DOM 解析完成后才按序执行,是最推荐的方式。
Q3:什么是关键渲染路径?如何优化?
答: 关键渲染路径是从接收 HTML 到首次渲染像素到屏幕的最短流程,包括 HTML → DOM、CSS → CSSOM、DOM + CSSOM → 渲染树 → 布局 → 绘制。优化策略包括:
- 减少关键资源数量:内联关键 CSS,
defer或async加载 JS - 减少关键资源大小:压缩、Tree Shaking、代码分割
- 缩短关键路径长度:减少关键资源之间的依赖链,使用
preload预加载关键资源 - 避免渲染阻塞:非首屏 CSS 异步加载,使用
media属性按条件加载 CSS
Q4:为什么操作 DOM 很"慢"?
答: 操作 DOM "慢"有两个层面的原因:
- 跨线程通信:JavaScript 运行在 JS 引擎中,DOM 存在于渲染引擎中。每次 JS 操作 DOM,都需要跨越这两个引擎的"桥梁"进行通信,有一定开销。
- 触发渲染流水线:DOM 的修改可能触发样式计算、布局、绘制等一系列后续操作。尤其是触发重排(Reflow) 时,浏览器可能需要重新计算大量元素的布局信息。
- 强制同步布局:如果在修改 DOM 后立即读取布局属性(如
offsetHeight),浏览器被迫立即执行重排,无法利用批量优化机制。
所以核心优化思路是:减少 DOM 操作次数、避免强制同步布局、使用 transform 做动画避免重排。
Q5:浏览器的多进程架构有什么好处?渲染进程中有哪些重要的线程?
答: Chrome 采用多进程架构(浏览器进程、渲染进程、网络进程、GPU 进程等),好处包括:
- 稳定性:一个标签页崩溃不会影响其他标签页
- 安全性:渲染进程运行在沙箱中,限制了对系统资源的访问
- 流畅性:GPU 进程独立处理合成任务,不受主线程阻塞影响
渲染进程中的重要线程:
- 主线程:负责 DOM 解析、样式计算、布局、绘制和 JS 执行(与 JS 引擎互斥)
- 合成线程:接收绘制指令、分块、管理光栅化、生成合成帧
- 光栅化线程:将绘制指令转化为像素位图(通常借助 GPU)
Q6:DOMContentLoaded 和 load 事件有什么区别?
答:
DOMContentLoaded:在 HTML 完全解析、DOM 树构建完成后触发,不需要等待样式表、图片和子框架等外部资源加载完成。通常用于初始化 JS 逻辑和绑定 DOM 事件。load(window.onload):在页面所有资源(包括图片、CSS、iframe 等)都加载完毕后触发。用于需要完整资源的操作,如获取图片实际尺寸。- 触发顺序:
DOMContentLoaded总是在load之前触发。 - 注意:如果页面有
defer脚本,DOMContentLoaded会等defer脚本执行完才触发;但不会等async脚本。
Q7:什么是合成(Composite)?为什么 transform 动画比 top/left 动画性能好?
答: 合成是渲染流水线的最后一步,将各个图层的像素位图按正确顺序合并,最终显示在屏幕上。合成操作由合成线程和 GPU 进程完成,不需要主线程参与。
transform 动画性能优于 top/left 的原因:
top/left改变了元素的布局属性,每一帧都需要经过 Layout → Paint → Composite 的完整流程,开销大transform不会改变元素在布局中的位置(布局阶段仍认为元素在原位),只在合成阶段改变元素的视觉位置,跳过了 Layout 和 Paint,每帧只需 Composite- 由于合成不在主线程执行,即使主线程被 JS 阻塞,
transform动画也能流畅运行
Q8:什么是预解析(Preload Scanner)?它有什么作用?
答: 预解析器是浏览器的一项优化机制。当主线程的 HTML 解析器被 <script> 标签阻塞时,预解析器会继续扫描后面的 HTML,提前发现需要下载的资源(CSS、JS、图片等),让网络进程并行下载,而不是等脚本执行完毕后才发现后续资源。
例如:
<script src="heavy-script.js"></script>
<!-- 即使上面的脚本阻塞了解析,预解析器也会发现下面的资源并提前下载 -->
<link rel="stylesheet" href="style.css" />
<img src="hero.png" />
<script src="another-script.js"></script>
这就是为什么即使有阻塞脚本,浏览器依然能相对快速地加载页面 —— 预解析器让资源下载提前开始,大大减少了等待时间。