跳到主要内容

浏览器渲染原理

当你在浏览器地址栏输入一个 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)

转换过程

具体步骤:

  1. 字节 → 字符:根据编码格式(如 UTF-8)将原始字节转换为字符串
  2. 字符 → 令牌(Token):通过词法分析(Tokenization) 将字符串拆分为一个个有意义的标记,比如 <html><body><p>Hello</p>
  3. 令牌 → 节点:将令牌转换为具有属性和规则的节点对象
  4. 节点 → 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 树的特点
  • 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 树结构:

CSSOM 的两个重要特性
  1. 层叠(Cascade):当多条规则作用于同一元素时,按照优先级规则(内联 > ID > 类 > 标签)决定最终生效的样式
  2. 继承(Inheritance):某些属性(如 font-sizecolor)会从父元素自动继承给子元素

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) 的规则,从底到顶依次是:

  1. 背景色 / 背景图
  2. z-index 为负值的元素
  3. 正常文档流中的块级元素
  4. 浮动元素
  5. 正常文档流中的行内元素
  6. z-index: 0 / auto 的定位元素
  7. 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 阻塞,通过 transformopacity 实现的动画依然可以流畅运行,因为它们只需要在合成阶段处理。

完整渲染流水线总结

将上述所有步骤整合在一起:

关键渲染路径(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 对渲染的阻塞,可以使用 asyncdefer 属性:

属性下载时机执行时机执行顺序适用场景
遇到即下载下载完立即执行按出现顺序需要立即执行且有依赖的脚本
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)" />

页面生命周期事件

在渲染过程中,浏览器会在不同阶段触发关键事件:

事件触发时机常见用途
DOMContentLoadedHTML 解析完成,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 到渲染出页面的完整过程。

答: 浏览器渲染的完整过程如下:

  1. 构建 DOM 树:解析 HTML 字节流,经过字节 → 字符 → Token → 节点 → DOM 树的转换
  2. 构建 CSSOM 树:解析 CSS 资源,生成 CSS 对象模型
  3. 样式计算:将 CSS 值标准化(如 empx),处理继承和层叠,计算每个节点的最终样式
  4. 布局:构建布局树(排除 display: none 的元素,加入伪元素),计算每个节点的位置和大小
  5. 分层:将页面分成多个图层,方便局部更新
  6. 绘制:为每个图层生成绘制指令列表
  7. 分块:合成线程将图层切分为小图块
  8. 光栅化:将图块转换为像素位图(GPU 加速)
  9. 合成:将所有图块合成为最终画面,显示在屏幕上

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 → 渲染树 → 布局 → 绘制。优化策略包括:

  1. 减少关键资源数量:内联关键 CSS,deferasync 加载 JS
  2. 减少关键资源大小:压缩、Tree Shaking、代码分割
  3. 缩短关键路径长度:减少关键资源之间的依赖链,使用 preload 预加载关键资源
  4. 避免渲染阻塞:非首屏 CSS 异步加载,使用 media 属性按条件加载 CSS

Q4:为什么操作 DOM 很"慢"?

答: 操作 DOM "慢"有两个层面的原因:

  1. 跨线程通信:JavaScript 运行在 JS 引擎中,DOM 存在于渲染引擎中。每次 JS 操作 DOM,都需要跨越这两个引擎的"桥梁"进行通信,有一定开销。
  2. 触发渲染流水线:DOM 的修改可能触发样式计算、布局、绘制等一系列后续操作。尤其是触发重排(Reflow) 时,浏览器可能需要重新计算大量元素的布局信息。
  3. 强制同步布局:如果在修改 DOM 后立即读取布局属性(如 offsetHeight),浏览器被迫立即执行重排,无法利用批量优化机制。

所以核心优化思路是:减少 DOM 操作次数、避免强制同步布局、使用 transform 做动画避免重排。

Q5:浏览器的多进程架构有什么好处?渲染进程中有哪些重要的线程?

答: Chrome 采用多进程架构(浏览器进程、渲染进程、网络进程、GPU 进程等),好处包括:

  • 稳定性:一个标签页崩溃不会影响其他标签页
  • 安全性:渲染进程运行在沙箱中,限制了对系统资源的访问
  • 流畅性:GPU 进程独立处理合成任务,不受主线程阻塞影响

渲染进程中的重要线程:

  • 主线程:负责 DOM 解析、样式计算、布局、绘制和 JS 执行(与 JS 引擎互斥)
  • 合成线程:接收绘制指令、分块、管理光栅化、生成合成帧
  • 光栅化线程:将绘制指令转化为像素位图(通常借助 GPU)

Q6:DOMContentLoadedload 事件有什么区别?

答:

  • DOMContentLoaded:在 HTML 完全解析、DOM 树构建完成后触发,不需要等待样式表、图片和子框架等外部资源加载完成。通常用于初始化 JS 逻辑和绑定 DOM 事件。
  • loadwindow.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>

这就是为什么即使有阻塞脚本,浏览器依然能相对快速地加载页面 —— 预解析器让资源下载提前开始,大大减少了等待时间。