实际上,对浏览器的实现者来说,他们做的事情,就是把一个 URL 变成一个屏幕上显示的网页。
这个过程是这样的:
- 浏览器首先使用 HTTP 协议或者 HTTPS 协议,向服务端请求页面;
- 把请求回来的 HTML 代码经过解析,构建成 DOM 树;
- 计算 DOM 树上的 CSS 属性;
- 最后根据 CSS 属性对元素逐个进行渲染,得到内存中的位图;
- 一个可选的步骤是对位图进行合成,这会极大地增加后续绘制的速度;
- 合成之后,再绘制到界面上。
我们在开始详细介绍之前,要建立一个感性认识。我们从 HTTP 请求回来开始,这个过程并非一般想象中的一步做完再做下一步,而是一条流水线。
从 HTTP 请求回来,就产生了流式的数据,后续的 DOM 树构建、CSS 计算、渲染、合成、绘制,都是尽可能地流式处理前一步的产出:即不需要等到上一步骤完全结束,就开始处理上一步的输出,这样我们在浏览网页时,才会看到逐步出现的页面。
解析代码
1. 词(token)是如何被拆分的
首先我们来看看一个非常标准的标签,会被如何拆分:
<p class="a">text text text</p>
如果我们从最小有意义单元的定义来拆分,第一个词(token)是什么呢?显然,作为一个词(token),整个 p 标签肯定是过大了(它甚至可以嵌套)。
那么,只用 p 标签的开头是不是合适吗?我们考虑到起始标签也是会包含属性的,最小的意义单元其实是“<p” ,所以“ <p” 就是我们的第一个词(token)。
我们继续拆分,可以把这段代码依次拆成词(token):
- <p“标签开始”的开始;
- class=“a” 属性;
- >“标签开始”的结束;
- text text text 文本;
- </p> 标签结束。
2. 构建 DOM 树
接下来我们要把这些简单的词变成 DOM 树
在接收的同时,即开始构建 DOM 树,所以我们的主要构建 DOM 树的算法,就写在 receiveInput 当中。当接收完所有输入,栈顶就是最后的根节点,我们 DOM 树的产出,就是这个 stack 的第一项。
为了构建 DOM 树,我们需要一个 Node 类,接下来我们所有的节点都会是这个 Node 类的实例。
这个过程有点类似于词的拆分的过程
举个栗子🌰来说明
<html maaa=a >
<head>
<title>cool</title>
</head>
<body>
<img src="a" />
</body>
</html>
通过这个栈,我们可以构建 DOM 树:
- 栈顶元素就是当前节点;
- 遇到属性,就添加到当前节点;
- 遇到文本节点,如果当前节点是文本节点,则跟文本节点合并,否则入栈成为当前节点的子节点;
- 遇到注释节点,作为当前节点的子节点;
- 遇到 tag start 就入栈一个节点,当前节点就是这个节点的父节点;
- 遇到 tag end 就出栈一个节点(还可以检查是否匹配)。
用一个 gif 来看看上述过程
浏览器是如何把 CSS 规则应用到节点上,并给这棵朴素的 DOM 树添加上 CSS 属性的
首先 CSS 选择器这个名称,可能会给你带来一定的误解,觉得好像 CSS 规则是 DOM 树构建好了以后,再进行选择并给它添加样式的。实际上,这个过程并不是这样的。
我们回忆一下我们在浏览器第一节课讲的内容,浏览器会尽量流式处理整个过程。我们上一节课构建 DOM 的过程是:从父到子,从先到后,一个一个节点构造,并且挂载到 DOM 树上的,那么这个过程中,我们是否能同步把 CSS 属性计算出来呢?
答案是肯定的。
在这个过程中,我们依次拿到上一步构造好的元素,去检查它匹配到了哪些规则,再根据规则的优先级,做覆盖和调整。所以,从这个角度看,所谓的选择器,应该被理解成“匹配器”才更合适。
不知道你有没有发现,这里的选择器有个特点,那就是选择器的出现顺序,必定跟构建 DOM 树的顺序一致。这是一个 CSS 设计的原则,即保证选择器在 DOM 树构建到当前节点时,已经可以准确判断是否匹配,不需要后续节点信息。
也就是说,未来也不可能会出现“父元素选择器”这种东西,因为父元素选择器要求根据当前节点的子节点,来判断当前节点是否被选中,而父节点会先于子节点构建。
Selector Level 4貌似出了父级选择器。。。
排版
浏览器进行到这一步,我们已经给 DOM 元素添加了用于展现的 CSS 属性,接下来,浏览器的工作就是确定每一个元素的位置了。我们的基本原则仍然不变,就是尽可能流式地处理上一步骤的输出。
这可能也就是一些 css 的书写规范中把定位属性放在最后的原因把~
在构建 DOM 树和计算 CSS 属性这两个步骤,我们的产出都是一个一个的元素,但是在排版这个步骤中,有些情况下,我们就没法做到这样了。
尤其是表格相关排版、flex 排版和 grid 排版,它们有一个显著的特点,那就是子元素之间具有关联性。
渲染
也就是把模型变成位图的过程
我们已经经历了把 URL 变成字符流,把字符流变成词(token)流,把词(token)流构造成 DOM 树,把不含样式信息的 DOM 树应用 CSS 规则,变成包含样式信息的 DOM 树,并且根据样式信息,计算了每个元素的位置和大小。
那么,我们最后的步骤,就是根据这些样式信息和大小信息,为每个元素在内存中渲染它的图形,并且把它绘制到对应的位置。
这里的位图就是在内存里建立一张二维表格,把一张图片的每个像素对应的颜色保存进去(位图信息也是 DOM 树中占据浏览器内存最多的信息,我们在做内存占用优化时,主要就是考虑这一部分)。
浏览器中渲染这个过程,就是把每一个元素对应的盒变成位图。这里的元素包括 HTML 元素和伪元素,一个元素可能对应多个盒(比如 inline 元素,可能会分成多行)。每一个盒对应着一张位图。
注意,我们这里讲的渲染过程,是不会把子元素绘制到渲染的位图上的,这样,当父子元素的相对位置发生变化时,可以保证渲染的结果能够最大程度被缓存,减少重新渲染。
合成
渲染过程不会把子元素渲染到位图上面,合成的过程,就是为一些元素创建一个“合成后的位图”(我们把它称为合成层),把一部分子元素渲染到合成的位图上面。
看到这句话,我想你一定会问问题,到底是为哪些元素创建合成后的位图,把哪些子元素渲染到合成的位图上面呢?
这就是我们要讲的合成的策略。我们前面讲了,合成是一个性能考量,那么合成的目标就是提高性能,根据这个目标,我们建立的原则就是最大限度减少绘制次数原则。
我们举一个极端的例子。如果我们把所有元素都进行合成,比如我们为根元素 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 属性,可以由业务代码来提示浏览器的合成策略,灵活运用这样的特性,可以大大提升合成策略的效果。
绘制
绘制是把“位图最终绘制到屏幕上,变成肉眼可见的图像”的过程,不过,一般来说,浏览器并不需要用代码来处理这个过程,浏览器只需要把最终要显示的位图交给操作系统即可。
当绘制完成时,就完成了浏览器的最终任务,把一个 URL 最后变成了一个可以看的网页图像。
当然本文也忽略了大量中间的细节。
网友评论