浏览器是如何渲染页面的?概述浏览器渲染原理
前言
上一篇文章:面试官:从「敲下一个 URL」到「页面出现在屏幕」都经历了什么?,最后一步页面出现在屏幕的时候浏览器如何渲染页面只是做了简要的讲解,这一篇文章将做详细讲解
当浏览器的网络线程收到 HTML 文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列。
在事件循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。
整个渲染流程分为多个阶段,分别是: HTML 解析、样式计算、布局、分层、绘制、分块、光栅化、画
每个阶段都有明确的输入输出,上一个阶段的输出会成为下一个阶段的输入。
这样,整个渲染流程就形成了一套组织严密的生产流水线。
flowchart LR
A[HTML解析\nHTML Parsing] --> B[DOM树构建\nDOM Tree]
B --> C[样式计算\nStyle Calculation]
C --> D[渲染树\nRender Tree]
D --> E[布局\nLayout]
E --> F[分层\nLayering]
F --> G[绘制\nPaint]
G --> H[分块\nTiling]
H --> I[光栅化\nRasterize]
I --> J[合成\nComposite]
style A fill:#f9f,stroke:#333
style J fill:#6f9,stroke:#333
class A,B,C,D 解析阶段
class E,F 布局阶段
class G,H,I,J 绘制阶段
classDef 解析阶段 fill:#f96,stroke:#333;
classDef 布局阶段 fill:#6cf,stroke:#333;
classDef 绘制阶段 fill:#9c6,stroke:#333;
1. 解析HTML - Parse HTML
解析DOM - Document Object Model
在说构建DOM树之前,我们首先需要知道,为什么要构建DOM树呢? 这是因为,浏览器是无法直接理解和使用HTML的,所以需要将HTML转化为浏览器能够理解的结构——DOM树。
每个HTML标签都会被浏览器解析成文档对象。HTML本质上就是一个嵌套结构,在解析时会把每个文档对象用一个树形结构组织起来,所有的文档对象都会挂在document上,这种组织方式就是HTML最基础的结构——文档对象模型(DOM),这棵树的每个文档对象就叫做DOM节点。
解析CSS 形成CSSOM - CSS Object Model
浏览器无法直接理解CSS代码,需要将其解析为浏览器可以理解的CSSOM树,跟DOM树类似,也是一个树形结构
至于为什么形成CSSOM,形成一个对象,是为了提供一种操作能力,提供给JS操作样式的能力
下面是一个小例子,展示解析css的过程
body h1 {
color: red;
}
解析后为:
graph TD
A[StyleSheetList] --> B[CSStyleSheet]
B --> C[CSSStyleRule]
B --> D[CSSStyleRule]
C --> E[body h1]
C --> F[style]
F --> J[color: red]
D --> G[...]
D --> H[...]
如上图所示,CSS跟HTML一样,也有根节点
DOM树和CSS树分别是两颗描述不同层级的树,CSS有属于自己的样式表
CSS样式来源,有哪些样式表?
css样式表分为内部样式表、外部样式表、行内样式表以及浏览器默认样式表
graph TD
A[StyleSheetList] --> B[CSStyleSheet]
A --> C[CSStyleSheet]
A --> D[CSStyleSheet]
A --> E[CSStyleSheet]
A --> F[...]
浏览器的默认样式表以 user agent stylesheet
为标识, 浏览器打开f12开发者工具,选择Element 元素选项,在Styles里面就能看到带有标识的样式

以谷歌 Google浏览器为例,查看一下它的默认样式表都有哪些,可以通过浏览器的源码来看,chromium github网址网站上面已经开源,可以进行查看,这里就以html.css
作为展示

浏览器的默认样式把body div
等标签设置为了 display: block;
所以它们才是块盒
根节点 StyleSheetList
对象就类似于 document
对象
根节点下面又有很多样式表(CSStyleSheet),样式表下面又有很多规则对象(CSSStyleRule)
// 下面的就相当于一个一个的规则对象
body h1 {
color: red;
}
body h2 {
color: green;
}
规则对象里面又有很多选择器(body h1)和样式(style),样式里面又包含多个具体的样式键值对(color: red;)
除了浏览器的默认样式外,其余的样式js都能进行操作
下面就是以百度的网页为例,在浏览器控制台中输入 document.styleSheets
得到的样式列表

// 通过js操作添加一条规则,为页面所有的div加上边框
document.styleSheets[0].addRule('div', 'border: 1px solid red !important')
在将样式表中的CSS转换为属性对象之前,需要对样式进行标准化处理
// 标准化之前:
font-weight: bold;
color: red;
// 标准化之后:
font-weight: 700;
color: rgb(255, 0, 0);
还需要考虑一些样式继承、样式层叠等等因素
HTML解析的时候遇到CSS怎么办
为了提高效率,浏览器会启动一个预解析器先下载和解析CSS,如果主线程解析到link
位置,此时外部的CSS文件还没有下载解析好,主线程不会等待,继续解析后续的HTML。这是因为下载和解析CSS的工作是在预解析线程中进行的。这就是CSS不会阻塞HTML解析的根本原因。
graph TD
%% ---------- 主线程 ----------
subgraph 主线程
A[加载HTML] --> B[解析DOM]
B --> C[遇到CSS/JS]
C --> D[同步加载并执行]
D --> E[构建CSSOM]
E --> F[渲染树布局]
F --> G[绘制页面]
end
%% ---------- 预解析线程 ----------
subgraph 预解析线程
H[预扫描HTML] --> I[提前加载CSS/JS]
I --> J[预构建CSSOM]
J --> K[缓存资源]
end
%% ---------- 线程交互 ----------
C -.->|通知预加载| I
K -.->|提供缓存| D
HTML解析过程中遇到JS代码怎么办?
与上面的流程图不一样,遇到JS代码的时候,HTML会暂停解析
渲染主线程遇到JS时必须暂停一切行为,等待下载执行完后才能继续,预解析线程可以分担一点下载JS的任务
如果主线程解析到script
位置,会停止解析 HTML,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析 HTML。这是因为 JS 代码的执行过程可能会修改当前的 DOM 树,所以 DOM 树的生成必须暂停。这就是 JS 会阻塞 HTML 解析的根本原因。
为什么会暂停HTML解析呢? 是因为解析js的时候,js代码有可能对DOM进行改动
graph TD
%% ---------- 主线程 ----------
subgraph 主线程
A[加载HTML] --> B[解析DOM]
B --> C{遇到JS?}
C -- 是 --> D[暂停HTML解析]
D --> E[下载并执行JS]
E --> B
C -- 否 --> F[继续解析DOM]
F --> G[构建CSSOM]
G --> H[渲染树布局]
H --> I[绘制页面]
end
%% ---------- 预解析线程 ----------
subgraph 预解析线程
J[预扫描HTML] --> K[发现JS/CSS链接]
K --> L[提前下载JS/CSS]
L --> M[预解析CSSOM]
end
%% ---------- 线程交互 ----------
K -.->|提前下载| D
M -.->|提供预解析CSSOM| G
style C fill:#ffcccc,stroke:#ff0000
可以通过CDN、压缩脚本等方式来加速js脚本的加载。如果脚本文件中没有操作DOM的相关代码,就可以将js脚本设置为异步加载,给script标签添加async或defer属性来实现脚本的异步加载
defer
MDN中的关于defer的解释
The defer
property of the HTMLScriptElement
interface is a boolean value that controls how the script should be executed. For classic scripts, if the defer
property is set to true
, the external script will be executed after the document has been parsed, but before firing DOMContentLoaded
event. For module scripts, the defer
property has no effect.
大致意思就是 defer属性是一个布尔值,用来控制script怎样执行,如果defer属性被设置为true,外部脚本就会在document被解析后,在DOMContentLoaded
事件之前执行,对于模块脚本,defer属性没有影响
async
MDN中关于async的解释
The async
property of the HTMLScriptElement
interface is a boolean value that controls how the script should be executed. For classic scripts, if the async
property is set to true
, the external script will be fetched in parallel to parsing and evaluated as soon as it is available. For module scripts, if the async
property is set to true
, the script and all their dependencies will be fetched in parallel to parsing and evaluated as soon as they are available.
async属性是一个布尔值,用来控制脚本执行方式,如果该属性被设置为true,外部脚本将在解析的同时并行获取,一旦可用就立即执行。对于模块脚本,如果async
属性设置为true
,该脚本及其所有依赖项将在解析的同时并行获取,一旦可用就立即执行。
多个带async属性的标签,不能保证加载的顺序;多个带defer属性的标签,按照加载顺序执行;
第一步解析HTML完成后,会得到 DOM 树和 CSSOM 树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在 CSSOM 树中。
2. 样式计算 Computed Style
主线程会遍历得到的DOM和CSSOM树,依次为树中的每个节点得到含有最终样式,称之为Computed Style
在这一过程中,很多预设值会变成绝对值,比如red
会变成rgb(255,0,0)
;相对单位会变成绝对单位,比如em
会变成px
这一步完成后,会得到一颗带有样式的树
在浏览器中也能找到Computed Style
的展示, f12打开浏览器开发者工具,打开Element 元素选项卡就能找到Computed
选项卡

3. 布局阶段 Layout
布局树中会依次遍历 DOM 树的每一个节点,计算每个节点的几何信息。例如节点的宽高、相对包含块的位置。
结果很简单,但是过程很复杂,因为元素之间的样式是相互影响的,
会有很多因素导致DOM树和最终形成的Layout树不一致,大部分时候都不会一一对应
在DOM树中是DOM对象,布局树(Layout)中是一个一个的c++对象
比如:DOM树中有一个p元素是浮动的,那么就会在布局树中生成一个FloatingObject
对象
下面有一些需要注意的地方:
-
父元素的高度自动,根据子元素算出来,父元素浮动,宽度自动等等,窗口尺寸也会影响布局,包括层叠、继承、视觉格式化模型(盒模型(怪异、正常)、包含块(单独介绍)、BFC(块级上下文)、流式布局等等)
-
有些宽高能算出来 有些宽高算不出来(auto、百分比)
-
有些隐藏的元素(display: none;)不会出现在布局树中,比如link、head、meta等等元素被浏览器设置了display: none;
,这些元素没有几何信息,所以不参与样式计算
base, basefont, datalist, head, link, meta, noembed,
noframes, param, rp, script, style, template, title {
display: none;
}
-
还有一些元素设置了伪元素,伪元素不会存在DOM中,而会在布局树中存在
-
行盒和块盒是不能相邻的(行盒、块盒指CSS层面,行级元素、块级元素指HTML层面)
<p>111</p>
222
<p>333</p>
最终形成的Layout树里
p标签和111之间会加上一个匿名行盒
222上面会加上一个匿名块盒,而这个匿名块盒下面再加上一个匿名行盒,匿名行盒下面才是222
其实说布局树(Layout树)的概念比较笼统,但是通过几行代码就了解了
document.body.clientWidth
document.body.clientHeight
还有 offsetWidth offsetHeight getComputedStyle 等等一切能获取到元素最终样式的操作 这些就是布局树暴露出来的信息 供操作
4. 分层 Layer
这一步浏览器考虑到要对Layout布局树进行一些优化,很多操作都会影响界面的变化,每次操作都要进行全部重新绘制,所以进行了分层
举个例子,一张报纸上面有很多个板块,这么多板块都已经界定好了位置、区域,如果其中一个板块发生了变化,那么就只需要操作这个板块就行了,不管是删除还是更新等等,这样都能最小化操作,只对该板块进行处理,提高效率
以Google浏览器为例,可以找到这些分层的信息
f12打开开发者工具,右上角菜单栏里面有一个More tools
更多工具,下面有一个Layers,就可以看到当前网页的层级信息了

还是以百度的官网为例:


进行旋转操作,就能清晰的看到页面的层次分布。
那么是不是每个板块都会进行分层呢?自然也不是,浏览器对于分层是有一定策略的
有一些特定的属性和元素可以实例化一个层,包括video和canvas标签,任何 CSS 属性为 opacity 、3D transform、will-change的元素,还有一些跟堆叠上下文有关的属性会影响分层策略,但是也只是影响,最终的分层还是要看浏览器
will-change
属性能够较大程度的影响浏览器分层结果的,告知浏览器哪些属性有可能会变动,让浏览器自己去决策
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.container {
will-change: transform;
width: 200px;
background: #f40;
color: #fff;
margin: 0 auto;
}
</style>
</head>
<body>
<div class="container">
<p>Lorem.</p>
<p>Ab?</p>
<p>Fugit.</p>
</div>
</body>
</html>

但是will-change
这个属性不要去滥用,如果页面渲染效率出了问题、卡顿,某个板块经常变动,不希望重绘的过多,这个时候才会考虑该属性
分层确实可以提高性能,但在内存管理方面成本较高
5.绘制 Paint
关键渲染路径中的最后一步是将各个节点绘制到屏幕上,其中第一次的绘制被称为首次有意义的绘制(是在发生最大的首屏布局更改与 Web 字体加载后进行的绘制)。在绘制或光栅化阶段,浏览器将在布局阶段计算的每个盒子转换为屏幕上的实际像素。
生成绘制指令集,并排序,先绘制什么后绘制什么。
完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成
6. 分块 Tiling
浏览器会开辟一个合成线程,合成线程首先对每个图层进行分块,将其划分为更多的小区域,会从线程池中拿取多个线程来完成分块工作处理每个分块的时候又会启动更多的分块子线程,处理完成之后再回归合成线程
7. 光栅化 Raster
使用GPU进程来处理光栅化,光栅化就是将每个块变成位图,优先处理靠近视口的块
8. 画 Draw
合成线程计算出每个位图在屏幕上的位置,交给GPU进行最终呈现
在画之前呢,要确定每个块的指引信息(quad),每一个块相对于屏幕的位置在哪里,再把生成的quad信息交给GPU进程,再交给硬件处理,再进行显示
指引会标识出每个位图应该画到屏幕的哪个位置,以及会考虑到旋转、缩放等变形。
transform
之所以效率高是因为发生在合成线程,与渲染主线程无关
扩展:为什么合成线程要交给GPU处理一下,而不是直接交给硬件进行处理呢?
合成线程在渲染进程里,渲染进程是放在沙盒里面的,出于一些安全性考虑,将渲染进程放在沙盒里,与外界隔离
就算是在浏览网页的时候,渲染进程被攻击了也影响不了操作系统,不会导致计算机中病毒,除非是去下载安装一些程序之类的
因为在沙盒里面对硬件进行隔离,所以找不到硬件,自然也就无法直接交给硬件来处理了
最后 end
整理了很多资料,自己也学习到了不少
关于这篇文章的扩展知识点还有很多,堆叠上下文、BFC、包含块、重排、重绘等等,之后会一一总结出来
希望也能给你们带来一些帮助,有写的不好的地方或者需要补充的地方,请大家指出,Peace Yo!
参考:
- 渲染页面:浏览器工作原理
- 画了20张图,详解浏览器渲染引擎工作原理
- 图解浏览器渲染原理
- MDN中的关于defer的解释
- MDN中关于async的解释