浏览器工作原理
- 2020.11.25
对浏览器的实现者来说,他们做的事情,就是把一个 URL 变成一个屏幕上显示的网页。
这个过程是这样的:
浏览器首先使用 HTTP 协议或者 HTTPS 协议,向服务端请求页面;
把请求回来的 HTML 代码经过解析,构建成 DOM 树;
计算 DOM 树上的 CSS 属性;
最后根据 CSS 属性对元素逐个进行渲染,得到内存中的位图;
一个可选的步骤是对位图进行合成,这会极大地增加后续绘制的速度;
合成之后,再绘制到界面上。
TIP
我们要清楚一个事情,从 HTTP 请求回来开始,这个过程并非一般想象中的一步做完再做下一步,而是一条流水线。
从 HTTP 请求回来,就产生了流式的数据,后续的 DOM 树构建、CSS 计算、渲染、合成、绘制,都是尽可能地流式处理前一步的产出:即不需要等到上一步骤完全结束,就开始处理上一步的输出,这样我们在浏览网页时,才会看到逐步出现的页面。
HTTP 协议
浏览器首先要做的事就是根据 URL 把数据取回来,取回数据使用的是 HTTP 协议,实际上这个过程之前还有 DNS 查询,不过这里就不详细展开了,完整的相关阐述查看上文HTTP相关知识
。
HTTP 协议是基于 TCP 协议出现的,对 TCP 协议来说,TCP 协议是一条双向的通讯通道,HTTP 在 TCP 的基础上,规定了 Request-Response
的模式。这个模式决定了通讯必定是由浏览器端首先发起的。
大部分情况下,浏览器的实现者只需要用一个 TCP 库,甚至一个现成的 HTTP 库就可以搞定浏览器的网络通讯部分。HTTP 是纯粹的文本协议,它是规定了使用 TCP 协议来传输文本格式的一个应用层协议。
HTTP 协议格式
HTTP 协议,大概可以划分成如下部分。
浏览器如何解析请求回来的 HTML 代码,DOM 树又是如何构建的。
解析代码
HTML 的结构不算太复杂,我们日常开发需要的 90% 的“词”(指编译原理的术语 token,表示最小的有意义的单元),种类大约只有标签开始、属性、标签结束、注释、CDATA 节点几种。
1. 词(token)是如何被拆分的
首先我们来看看一个非常标准的标签,会被如何拆分:
<p class="a">text text text</p>
如果我们从最小有意义单元的定义来拆分,第一个词(token)是什么呢?显然,作为一个词(token),整个 p 标签肯定是过大了(它甚至可以嵌套)。
那么,只用 p 标签的开头是不是合适吗?我们考虑到起始标签也是会包含属性的,最小的意义单元其实是<p
,所有<p
是第一个词。
我们继续拆分,可以把这段代码依次拆成词(token):
<p
“标签开始”的开始;class=“a”
属性;>
“标签开始”的结束;text text text
文本;</p>
标签结束。
根据这样的分析,现在我们讲讲浏览器是如何用代码实现,我们设想,代码开始从 HTTP 协议收到的字符流读取字符。
在接受第一个字符之前,我们完全无法判断这是哪一个词(token),不过,随着我们接受的字符越来越多,拼出其他的内容可能性就越来越少。
比如,假设我们接受了一个字符“ < ” 我们一下子就知道这不是一个文本节点啦。
之后我们再读一个字符,比如就是 x,那么我们一下子就知道这不是注释和 CDATA 了,接下来我们就一直读,直到遇到“>”或者空格,这样就得到了一个完整的词(token)了。
实际上,我们每读入一个字符,其实都要做一次决策,而且这些决定是跟“当前状态”有关的。在这样的条件下,浏览器工程师要想实现把字符流解析成词(token),最常见的方案就是使用状态机。
2. 状态机
绝大多数语言的词法部分都是用状态机实现的。那么我们来把部分词(token)的解析画成一个状态机看看:
TIP
这里是一个简化版的状态机实现图,用来理解状态机运行机制已经够用了,完整版的可以参考HTML 官方文档,HTML 官方文档规定了 80 个状态。
状态机的初始状态,我们仅仅区分 “< ”和 “非 <”:
如果获得的是一个非 < 字符,那么可以认为进入了一个文本节点;
如果获得的是一个 < 字符,那么进入一个标签状态。
不过当我们在标签状态时,则会面临着一些可能性。
比如下一个字符是“ ! ” ,那么很可能是进入了注释节点或者 CDATA 节点。
如果下一个字符是 “/ ”,那么可以确定进入了一个结束标签。
如果下一个字符是字母,那么可以确定进入了一个开始标签。
如果我们要完整处理各种 HTML 标准中定义的东西,那么还要考虑“ ? ”“% ”等内容。
我们可以看到,用状态机做词法分析,其实正是把每个词的“特征字符”逐个拆开成独立状态,然后再把所有词的特征字符链合并起来,形成一个联通图结构。
由于状态机设计属于编译原理
的基本知识,这里我们仅作一个简要的介绍。
大致的思路就是,我们把每个函数当做一个状态,参数是接受的字符,返回值是下一个状态函数。
var data = function(c){
if(c=="&") {
return characterReferenceInData;
}
if(c=="<") {
return tagOpen;
}
else if(c=="\0") {
error();
emitToken(c);
return data;
}
else if(c==EOF) {
emitToken(EOF);
return data;
}
else {
emitToken(c);
return data;
}
};
var tagOpenState = function tagOpenState(c){
if(c=="/") {
return endTagOpenState;
}
if(c.match(/[A-Z]/)) {
token = new StartTagToken();
token.name = c.toLowerCase();
return tagNameState;
}
if(c.match(/[a-z]/)) {
token = new StartTagToken();
token.name = c;
return tagNameState;
}
if(c=="?") {
return bogusCommentState;
}
else {
error();
return dataState;
}
};
//……
这段代码给出了状态机的两个状态示例:data 即为初始状态,tagOpenState 是接受了一个“ < ” 字符,来判断标签类型的状态。
这里的状态机,每一个状态是一个函数,通过“if else”来区分下一个字符做状态迁移。这里所谓的状态迁移,就是当前状态函数返回下一个状态函数。
这样,我们的状态迁移代码非常的简单:
var state = data;
var char
while(char = getInput())
state = state(char);
这段代码的关键一句是“ state = state(char) ”,不论我们用何种方式来读取字符串流,我们都可以通过 state 来处理输入的字符流,这里用循环是一个示例,真实场景中,可能是来自 TCP 的输出流。
状态函数通过代码中的 emitToken 函数来输出解析好的 token(词),我们只需要覆盖 emitToken,即可指定对解析结果的处理方式。
词法分析器接受字符的方式很简单,就像下面这样:
function HTMLLexicalParser(){
//状态函数们……
function data() {
// ……
}
function tagOpen() {
// ……
}
// ……
var state = data;
this.receiveInput = function(char) {
state = state(char);
}
}
至此,我们就把字符流拆成了词(token)了。
构建 DOM 树
接下来我们要把这些简单的词变成 DOM 树,这个过程我们是使用栈
来实现的。
function HTMLSyntaticalParser(){
var stack = [new HTMLDocument];
this.receiveInput = function(token) {
//……
}
this.getOutput = function(){
return stack[0];
}
}
我们这样来设计 HTML 的语法分析器,receiveInput 负责接收词法部分产生的词(token),通常可以由 emitToken 来调用。
在接收的同时,即开始构建 DOM 树,所以我们的主要构建 DOM 树的算法,就写在 receiveInput 当中。当接收完所有输入,栈顶就是最后的根节点,我们 DOM 树的产出,就是这个 stack 的第一项。
为了构建 DOM 树,我们需要一个 Node 类,接下来我们所有的节点都会是这个 Node 类的实例。
function Element(){
this.childNodes = [];
}
function Text(value){
this.value = value || "";
}
前面我们的词(token)中,以下两个是需要成对匹配的:
tag start
tag end
对于 Text 节点,我们则需要把相邻的 Text 节点合并起来,我们的做法是当词(token)入栈时,检查栈顶是否是 Text 节点,如果是的话就合并 Text 节点。
<html>
<head>
<title>cool</title>
</head>
<body>
<img src="a" />
</body>
</html>
通过这个栈,我们可以构建 DOM 树:
- 栈顶元素就是当前节点;
- 遇到属性,就添加到当前节点;
- 遇到文本节点,如果当前节点是文本节点,则跟文本节点合并,否则入栈成为当前节点的子节点;
- 遇到注释节点,作为当前节点的子节点;
- 遇到 tag start 就入栈一个节点,当前节点就是这个节点的父节点;
- 遇到 tag end 就出栈一个节点(还可以检查是否匹配)。
构建CSSOM树
浏览器是如何把 CSS 规则应用到节点上,并给这棵朴素的 DOM 树添加上 CSS 属性的。
首先我们还是要感性地理解一下这个过程。
CSS 选择器这个名称,可能会给你带来一定的误解,觉得好像 CSS 规则是 DOM 树构建好了以后,再进行选择并给它添加样式的。实际上,这个过程并不是这样的。
在这个过程中,我们依次拿到上一步构造好的元素,去检查它匹配到了哪些规则,再根据规则的优先级,做覆盖和调整。所以,从这个角度看,所谓的选择器,应该被理解成“匹配器”才更合适。
这里的选择器有个特点,那就是选择器的出现顺序,必定跟构建 DOM 树的顺序一致。这是一个 CSS 设计的原则,即保证选择器在 DOM 树构建到当前节点时,已经可以准确判断是否匹配,不需要后续节点信息。
也就是说,未来也不可能会出现“父元素选择器”这种东西,因为父元素选择器要求根据当前节点的子节点,来判断当前节点是否被选中,而父节点会先于子节点构建。
重排和重绘
浏览器进行到这一步,我们已经给 DOM 元素添加了用于展现的 CSS 属性,接下来,浏览器的工作就是确定每一个元素的位置了。我们的基本原则仍然不变,就是尽可能流式地处理上一步骤的输出。
在构建 DOM 树和计算 CSS 属性这两个步骤,我们的产出都是一个一个的元素,但是在排版这个步骤中,有些情况下,我们就没法做到这样了。
尤其是表格相关排版、Flex 排版和 Grid 排版,它们有一个显著的特点,那就是子元素之间具有关联性。
渲染
我们已经经历了把 URL 变成字符流,把字符流变成词(token)流,把词(token)流构造成 DOM 树,把不含样式信息的 DOM 树应用 CSS 规则,变成包含样式信息的 DOM 树,并且根据样式信息,计算了每个元素的位置和大小。
我们最后的步骤,就是根据这些样式信息和大小信息,为每个元素在内存中渲染它的图形,并且把它绘制到对应的位置。
这里的位图就是在内存里建立一张二维表格,把一张图片的每个像素对应的颜色保存进去(位图信息也是 DOM 树中占据浏览器内存最多的信息,我们在做内存占用优化时,主要就是考虑这一部分)。
浏览器中渲染这个过程,就是把每一个元素对应的盒变成位图。这里的元素包括 HTML 元素和伪元素,一个元素可能对应多个盒(比如 inline 元素,可能会分成多行)。每一个盒对应着一张位图。
这个渲染过程是非常复杂的,但是总体来说,可以分成两个大类:图形和文字。
盒的背景、边框、SVG 元素、阴影等特性,都是需要绘制的图形类。这就像我们实现 HTTP 协议必须要基于 TCP 库一样,这一部分,我们需要一个底层库来支持。
一般的操作系统会提供一个底层库,比如在 Android 中,有大名鼎鼎的 Skia,而 Windows 平台则有 GDI,一般的浏览器会做一个兼容层来处理掉平台差异。
在最普遍的情况下,渲染过程生成的位图尺寸跟它在上一步排版时占据的尺寸相同。但是理想和现实是有差距的,很多属性会影响渲染位图的大小,比如阴影,它可能非常巨大,或者渲染到非常遥远的位置,所以为了优化,浏览器实际的实现中会把阴影作为一个独立的盒来处理。
注意,我们这里讲的渲染过程,是不会把子元素绘制到渲染的位图上的,这样,当父子元素的相对位置发生变化时,可以保证渲染的结果能够最大程度被缓存,减少重新渲染。
合成
我们上一小节中讲到,渲染过程不会把子元素渲染到位图上面,合成的过程,就是为一些元素创建一个“合成后的位图”(我们把它称为合成层),把一部分子元素渲染到合成的位图上面。
看到这句话,我想你一定会问问题,到底是为哪些元素创建合成后的位图,把哪些子元素渲染到合成的位图上面呢?
这就是我们要讲的合成的策略。我们前面讲了,合成是一个性能考量,那么合成的目标就是提高性能,根据这个目标,我们建立的原则就是最大限度减少绘制次数原则。
我们举一个极端的例子。如果我们把所有元素都进行合成,比如我们为根元素 HTML 创建一个合成后的位图,把所有子元素都进行合成,那么会发生什么呢?
那就是,一旦我们用 JavaScript 或者别的什么方式,改变了任何一个 CSS 属性,这份合成后的位图就失效了,我们需要重新绘制所有的元素。
那么如果我们所有的元素都不合成,会怎样呢?结果就是,相当于每次我们都必须要重新绘制所有的元素,这也不是对性能友好的选择。
那么好的合成策略是什么呢,好的合成策略是“猜测”可能变化的元素,把它排除到合成之外。
举个例子:
<div id="a">
<div id="b">...</div>
<div id="c" style="transform:translate(0,0)"></div>
</div>
假设我们的合成策略能够把 a、b 两个 div 合成,而不把 c 合成,那么,当我执行以下代码时:
document.getElementById("c").style.transform = "translate(100px, 0)";
我们绘制的时候,就可以只需要绘制 a 和 b 合成好的位图和 c,从而减少了绘制次数。这里需要注意的是,在实际场景中,我们的 b 可能有很多复杂的子元素,所以当合成命中时,性能提升收益非常之高。
目前,主流浏览器一般根据 position、transform 等属性来决定合成策略,来“猜测”这些元素未来可能发生变化。
但是,这样的猜测准确性有限,所以新的 CSS 标准中,规定了 will-change
属性,可以由业务代码来提示浏览器的合成策略,灵活运用这样的特性,可以大大提升合成策略的效果。
绘制是把“位图最终绘制到屏幕上,变成肉眼可见的图像”的过程,不过,一般来说,浏览器并不需要用代码来处理这个过程,浏览器只需要把最终要显示的位图交给操作系统即可。
一般最终位图位于显存中,也有一些情况下,浏览器只需要把内存中的一张位图提交给操作系统或者驱动就可以了,这取决于浏览器运行的环境。不过无论如何,我们把任何位图合成到这个“最终位图”的操作称为绘制。
计算机图形学中,我们使用的方案就是“脏矩形”算法,也就是把屏幕均匀地分成若干矩形区域。
设置合适的矩形区域大小,可以很好地控制绘制时的消耗。设置过大的矩形会造成绘制面积增大,而设置过小的矩形则会造成计算复杂。
我们重新绘制脏矩形区域时,把所有与矩形区域有交集的合成层(位图)的交集部分绘制即可。