乐趣区

浏览器渲染机制

本文示例源代码请戳 github 博客,建议大家动手敲敲代码。
前言
浏览器渲染页面的过程
从耗时的角度,浏览器请求、加载、渲染一个页面,时间花在下面五件事情上:

DNS 查询
TCP 连接
HTTP 请求即响应
服务器响应
客户端渲染

本文讨论第五个部分,即浏览器对内容的渲染,这一部分(渲染树构建、布局及绘制),又可以分为下面五个步骤:

处理 HTML 标记并构建 DOM 树。
处理 CSS 标记并构建 CSSOM 树
将 DOM 与 CSSOM 合并成一个渲染树。
根据渲染树来布局,以计算每个节点的几何信息。
将各个节点绘制到屏幕上。

需要明白,这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM。
1、浏览器的线程
在详细说明之前我们来看一下浏览器线程。这将有助于我们理解后续内容。
浏览器是多线程的,它们在内核制控下相互配合以保持同步。一个浏览器至少实现三个常驻线程:JavaScript 引擎线程,GUI 渲染线程,浏览器事件触发线程。

GUI 渲染线程:负责渲染浏览器界面 HTML 元素,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。在 Javascript 引擎运行脚本期间,GUI 渲染线程都是处于挂起状态的,也就是说被”冻结”了。

JavaScript 引擎线程:主要负责处理 Javascript 脚本程序。

定时器触发线程:浏览器定时计数器并不是由 JavaScript 引擎计数的,JavaScript 引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确,因此浏览器通过单独线程来计时并触发定时。

事件触发线程:当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。这些事件包括当前执行的代码块如定时任务、浏览器内核的其他线程如鼠标点击、AJAX 异步请求等。由于 JS 的单线程关系所有这些事件都得排队等待 JS 引擎处理。定时块任何和 ajax 请求等这些异步任务,事件触发线程只是在到达定时时间或者是 ajax 请求成功后,把回调函数放到事件队列当中。

异步 HTTP 请求线程:在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript 引擎的处理队列中等待处理。在发起了一个异步请求时,http 请求线程则负责去请求服务器,有了响应以后,事件触发线程再把回到函数放到事件队列当中。

2、构建 DOM 树与 CSSOM 树
浏览器从网络或硬盘中获得 HTML 字节数据后会经过一个流程将字节解析为 DOM 树:

编码:先将 HTML 的原始字节数据转换为文件指定编码的字符。

令牌化:然后浏览器会根据 HTML 规范来将字符串转换成各种令牌(如 <html>、<body> 这样的标签以及标签中的字符串和属性等都会被转化为令牌,每个令牌具有特殊含义和一组规则)。令牌记录了标签的开始与结束,通过这个特性可以轻松判断一个标签是否为子标签(假设有 <html> 与 <body> 两个标签,当 <html> 标签的令牌还未遇到它的结束令牌 </html> 就遇见了 <body> 标签令牌,那么 <body> 就是 <html> 的子标签)。

生成对象:接下来每个令牌都会被转换成定义其属性和规则的对象(这个对象就是节点对象)

构建完毕:DOM 树构建完成,整个对象集合就像是一棵树形结构。可能有人会疑惑为什么 DOM 是一个树形结构,这是因为标签之间含有复杂的父子关系,树形结构正好可以诠释这个关系(CSSOS 同理,层叠样式也含有父子关系。例如:div p {font-size: 18px},会先寻找所有 p 标签并判断它的父标签是否为 div 之后才会决定要不要采用这个样式进行渲染)。

整个 DOM 树的构建过程其实就是:字节 -> 字符 -> 令牌 -> 节点对象 -> 对象模型,下面将通过一个示例 HTML 代码与配图更形象地解释这个过程。
<html>
<head>
<meta name=”viewport” content=”width=device-width,initial-scale=1″>
<link href=”style.css” rel=”stylesheet”>
<title>Critical Path</title>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src=”awesome-photo.jpg”></div>
</body>
</html>

当上述 HTML 代码遇见 <link> 标签时,浏览器会发送请求获得该标签中标记的 CSS 文件(使用内联 CSS 可以省略请求的步骤提高速度,但没有必要为了这点速度而丢失了模块化与可维护性),style.css 中的内容如下:
body {font-size: 16px}
p {font-weight: bold}
span {color: red}
p span {display: none}
img {float: right}
浏览器获得外部 CSS 文件的数据后,就会像构建 DOM 树一样开始构建 CSSOM 树,这个过程没有什么特别的差别。
3、构建渲染树
在构建了 DOM 树和 CSSOM 树之后,浏览器只是拥有了两个互相独立的对象集合,DOM 树描述了文档的结构与内容,CSSOM 树则描述了对文档应用的样式规则,想要渲染出页面,就需要将 DOM 树与 CSSOM 树结合在一起,这就是渲染树。

浏览器会先从 DOM 树的根节点开始遍历每个可见节点(不可见的节点自然就没必要渲染到页面了,不可见的节点还包括被 CSS 设置了 display: none 属性的节点,值得注意的是 visibility: hidden 属性并不算是不可见属性,它的语义是隐藏元素,但元素仍然占据着布局空间,所以它会被渲染成一个空框)
对每个可见节点,找到其适配的 CSS 样式规则并应用。
渲染树构建完成,每个节点都是可见节点并且都含有其内容和对应规则的样式。

4、布局与绘制
CSS 采用了一种叫做盒子模型的思维模型来表示每个节点与其他元素之间的距离,盒子模型包括外边距 (Margin),内边距 (Padding),边框 (Border),内容 (Content)。页面中的每个标签其实都是一个个盒子
布局阶段会从渲染树的根节点开始遍历,然后确定每个节点对象在页面上的确切大小与位置,布局阶段的输出是一个盒子模型,它会精确地捕获每个元素在屏幕内的确切位置与大小,所有相对的测量值也都会被转换为屏幕内的绝对像素值。
<html>
<head>
<meta name=”viewport” content=”width=device-width,initial-scale=1″>
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style=”width: 50%”>
<div style=”width: 50%”>Hello world!</div>
</div>
</body>
</html>

当 Layout 布局事件完成后,浏览器会立即发出 Paint Setup 与 Paint 事件,开始将渲染树绘制成像素,绘制所需的时间跟 CSS 样式的复杂度成正比,绘制完成后,用户就可以看到页面的最终呈现效果了。
我们对一个网页发送请求并获得渲染后的页面可能也就经过了 1~2 秒,但浏览器其实已经做了上述所讲的非常多的工作,总结一下浏览器关键渲染路径的整个过程:

处理 HTML 标记数据并生成 DOM 树。
处理 CSS 标记数据并生成 CSSOM 树。
将 DOM 树与 CSSOM 树合并在一起生成渲染树。
遍历渲染树开始布局,计算每个节点的位置信息。
将每个节点绘制到屏幕。

5、外部资源是如何请求的
为了直观的观察浏览器加载和渲染的细节,本地用 nodejs 搭建一个简单的 HTTP Server。index.js
const http = require(‘http’);
const fs = require(‘fs’);
const hostname = ‘127.0.0.1’;
const port = 8080;
http.createServer((req, res) => {
if (req.url == ‘/a.js’) {
fs.readFile(‘a.js’, ‘utf-8’, function (err, data) {
res.writeHead(200, {‘Content-Type’: ‘text/plain’});
setTimeout(function () {
res.write(data);
res.end()
}, 5000)
})
} else if (req.url == ‘/b.js’) {
fs.readFile(‘b.js’, ‘utf-8’, function (err, data) {
res.writeHead(200, {‘Content-Type’: ‘text/plain’});
res.write(data);
res.end()
})
} else if (req.url == ‘/style.css’) {
fs.readFile(‘style.css’, ‘utf-8’, function (err, data) {
res.writeHead(200, {‘Content-Type’: ‘text/css’});
res.write(data);
res.end()
})
} else if (req.url == ‘/index.html’) {
fs.readFile(‘index.html’, ‘utf-8’, function (err, data) {
res.writeHead(200, {‘Content-Type’: ‘text/html’});
res.write(data);
res.end()
})
}
}).listen(port, hostname, () => {
console.log(‘Server running at ‘ + hostname + ‘:’ + port);
});
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset=”utf-8″>
<meta http-equiv=”cache-control” content=”no-cache,no-store, must-revalidate”/>
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
<title> 浏览器渲染 </title>
<link rel=”stylesheet” href=”http://127.0.0.1:8080/style.css”>
<script src=’http://127.0.0.1:8080/a.js’></script>
</head>
<body>
<p id=’header’>1111111</p>
<script src=’http://127.0.0.1:8080/b.js’></script>
<p>222222</p>
<p>3333333</p>
</body>
</html>
style.css
#header{
color: red;
}
a.js、b.js 暂时为空可以看到,服务端将对 a.js 的请求延迟 5 秒返回。Server 启动后,在 chrome 浏览器中打开 http://127.0.0.1:8080/index.html 我们打开 chrome 的调试面板第一次解析 html 的时候,外部资源好像是一起请求的,说资源是预解析加载的,就是说 style.css 和 b.js 是 a.js 造成阻塞的时候才发起的请求,图中也是可以解释得通,因为第一次 Parse HTML 的时候就遇到阻塞,然后预解析就去发起请求,所以看起来是一起请求的。
6、HTML 是否解析一部分就显示一部分
我们修改一下 html 代码
<!DOCTYPE html>
<html>
<head>
<meta charset=”utf-8″>
<meta http-equiv=”cache-control” content=”no-cache,no-store, must-revalidate”/>
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
<title> 浏览器渲染 </title>
<link rel=”stylesheet” href=”http://127.0.0.1:8080/style.css”>
</head>
<body>
<p id=’header’>1111111</p>
<script src=’http://127.0.0.1:8080/a.js’></script>
<script src=’http://127.0.0.1:8080/b.js’></script>
<p>222222</p>
<p>3333333</p>
</body>
</html>
因为 a.js 的延迟,解析到 a.js 所在的 script 标签的时候,a.js 还没有下载完成,阻塞并停止解析,之前解析的已经绘制显示出来了。当 a.js 下载完成并执行完之后继续后面的解析。当然,浏览器不是解析一个标签就绘制显示一次,当遇到阻塞或者比较耗时的操作的时候才会先绘制一部分解析好的。
7、js 文件的位置对 HTML 解析有什么影响
7.1 js 文件在头部加载。
修改 index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset=”utf-8″>
<meta http-equiv=”cache-control” content=”no-cache,no-store, must-revalidate”/>
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
<title> 浏览器渲染 </title>
<link rel=”stylesheet” href=”http://127.0.0.1:8080/style.css”>
<script src=’http://127.0.0.1:8080/a.js’></script>
<script src=’http://127.0.0.1:8080/b.js’></script>
</head>
<body>
<p id=’header’>1111111</p>
<p>222222</p>
<p>3333333</p>
</body>
</html>
因为 a.js 的阻塞使得解析停止,a.js 下载完成之前,页面无法显示任何东西。
7.2、js 文件在中间加载。
<!DOCTYPE html>
<html>
<head>
<meta charset=”utf-8″>
<meta http-equiv=”cache-control” content=”no-cache,no-store, must-revalidate”/>
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
<title> 浏览器渲染 </title>
<link rel=”stylesheet” href=”http://127.0.0.1:8080/style.css”>
</head>
<body>
<p id=’header’>1111111</p>
<script src=’http://127.0.0.1:8080/a.js’></script>
<script src=’http://127.0.0.1:8080/b.js’></script>
<p>222222</p>
<p>3333333</p>
</body>
</html>

解析到 js 文件时出现阻塞。阻塞后面的解析,导致后面的不能很快的显示。
7.3、js 文件在尾部加载。
<!DOCTYPE html>
<html>
<head>
<meta charset=”utf-8″>
<meta http-equiv=”cache-control” content=”no-cache,no-store, must-revalidate”/>
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
<title> 浏览器渲染 </title>
<link rel=”stylesheet” href=”http://127.0.0.1:8080/style.css”>
</head>
<body>
<p id=’header’>1111111</p>
<p>222222</p>
<p>3333333</p>
<script src=’http://127.0.0.1:8080/a.js’></script>
<script src=’http://127.0.0.1:8080/b.js’></script>
</body>
</html>
解析到 a.js 部分的时候,页面要显示的东西已经解析完了,a.js 不会影响页面的呈现速度。
由上面我们可以总结一下

直接引入的 JS 会阻塞页面的渲染(GUI 线程和 JS 线程互斥)
JS 不阻塞资源的加载
JS 顺序执行,阻塞后续 JS 逻辑的执行

下面我们来看下异步 js
7.4、async 和 defer 的作用是什么?有什么区别?
接下来我们对比下 defer 和 async 属性的区别:其中蓝色线代表 JavaScript 加载;红色线代表 JavaScript 执行;绿色线代表 HTML 解析。
情况 1 <script src=”script.js”></script>

没有 defer 或 async,浏览器会立即加载并执行指定的脚本,也就是说不等待后续载入的文档元素,读到就加载并执行。
情况 2 <script async src=”script.js”></script> (异步下载)
async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。
情况 3 <script defer src=”script.js”></script>(延迟执行)
defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。
defer 与相比普通 script,有两点区别:

载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。
在加载多个 JS 脚本的时候,async 是无顺序的加载,而 defer 是有顺序的加载。

8、css 文件的影响
服务端将 style.css 的相应也设置延迟。
fs.readFile(‘style.css’, ‘utf-8’, function (err, data) {
res.writeHead(200, {‘Content-Type’: ‘text/css’});
setTimeout(function () {
res.write(data);
res.end()
}, 5000)
})
<!DOCTYPE html>
<html>
<head>
<meta charset=”utf-8″>
<meta http-equiv=”cache-control” content=”no-cache,no-store, must-revalidate”/>
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
<title> 浏览器渲染 </title>
<link rel=”stylesheet” href=”http://127.0.0.1:8080/style.css”>
</head>
<body>
<p id=’header’>1111111</p>
<p>222222</p>
<p>3333333</p>
<script src=’http://127.0.0.1:8080/a.js’ async></script>
<script src=’http://127.0.0.1:8080/b.js’ async></script>
</body>
</html>

可以看出来,css 文件不会阻塞 HTML 解析,但是会阻塞渲染,导致 css 文件未下载完成之前已经解析好 html 也无法先显示出来。
我们把 css 调整到尾部
<!DOCTYPE html>
<html>
<head>
<meta charset=”utf-8″>
<meta http-equiv=”cache-control” content=”no-cache,no-store, must-revalidate”/>
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
<title> 浏览器渲染 </title>
</head>
<body>
<p id=’header’>1111111</p>
<p>222222</p>
<p>3333333</p>
<link rel=”stylesheet” href=”http://127.0.0.1:8080/style.css”>
<script src=’http://127.0.0.1:8080/a.js’ async></script>
<script src=’http://127.0.0.1:8080/b.js’ async></script>
</body>
</html>
这是页面可以渲染了,但是没有样式。直到 css 加载完成
以上我们可以简单总结。

CSS 放在 head 中会阻塞页面的渲染(页面的渲染会等到 css 加载完成)
CSS 阻塞 JS 的执行(因为 GUI 线程和 JS 线程是互斥的,因为有可能 JS 会操作 CSS)
CSS 不阻塞外部脚本的加载(不阻塞 JS 的加载,但阻塞 JS 的执行,因为浏览器都会有预先扫描器)

参考浏览器渲染过程与性能优化聊聊浏览器的渲染机制你不知道的浏览器页面渲染机制

退出移动版