浏览器渲染原理及过程
本文前置知识可参考我上一篇写的文章:https://www.cnblogs.com/ahaMOMO/p/13124700.html
通常,我们编写好HTML、CSS、JavaScript等文件,经过浏览器就会显示出漂亮的页面(如下图所示),但是你知道它们是如何转化成页面的吗?
从图中可以看出,左边输入的是HTML、CSS、JavaScript数据,这些数据经过中间渲染模块的处理,最终输出为屏幕上的像素。
这中间的渲染模块就是我们今天要讨论的主题。
首先我们先了解一下渲染主流程的示例
通常情况下,不同浏览器内核的解析渲染过程也略有不同,我们以Chrome、Safari浏览器的Webkit内核和Firefox浏览器的Gecko内核为例,看看渲染引擎工作流程的具体步骤:
从上图中,我们可以看到一些区别:
- webkit内核中的HTML和CSS解析可以认为是并行的;而Gecko则是先解析HTML,生成内容Sink(Content Sink可以认为是构建DOM结构树的工厂方法)后再开始解析CSS。
1.构建DOM树(生成了DOM树)
从图中可以看出,解析HTML构建DOM树时渲染引擎会先将HTML元素标签解析成多个DOM元素对象节点组成的且具有节点父子关系的DOM树结构。(构建DOM树的输入内容是一个非常简单的HTML文件,然后经由HTML解析器解析,最终输出树状结构的DOM)。
但是HTML解析器是怎么解析的呢?
1)字节流转换为字符并W3C标准令牌化
读取 HTML 的原始字节流(字节流来源于网络进程传给渲染引擎,然后渲染引擎传给HTML解析器),并根据文件的指定编码(例如 UTF-8)将它们转换成各个字符。 并将字符串转换成 W3C HTML5 标准规定的各种令牌,例如,“<html>
”、“<body>
”以及其他尖括号内的字符串。每个令牌都具有特殊含义和一组规则。
一堆字节流 bytes
3C 62 6F ...
<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>
2) 通过分词器将字节流转化为 Token
分词器将字节流转换为一个一个的 Token,Token 分为 Tag Token和文本 Token,上面这段代码最后分词器转化后的结果是:
由图可以看出,Tag Token 又分 StartTag 和 EndTag。StartTag 和EndTag分别对于图中的蓝色和红色块,文本 Token 对应的绿色块。
3)将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中
HTML 解析器维护了一个 Token 栈结构,这个栈结构的目的就是用来计算节点间的父子关系,在上一个阶段生成的 Token 会被顺序压到这个栈中,以下是具体规则:
-
如果压入到栈中的是StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
-
-
如果分词器解析出来的是EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。
通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。
为了更加直观地理解整个过程,下面我们结合一段 HTML 代码(如下),来一步步分析 DOM 树的生成过程。
<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>
这里需要补充说明下,HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底。然后经过分词器解析出来的第一个 StartTag html Token 会被压入到栈中,并创建一个 html 的 DOM 节点,添加到 document 上,如下图所示:
然后按照同样的流程解析出来 StartTag body 和 StartTag div,其 Token 栈和 DOM 的状态如下图所示:
接下来解析出来的是第一个 div 的文本 Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点,如下图所示:
再接下来,分词器解析出来第一个 EndTag div,这时候 HTML 解析器会去判断当前栈顶的元素是否是 StartTag div,如果是则从栈顶弹出 StartTag div,如下图所示:
按照同样的规则,一路解析,最终结果如下图所示:
通过上面的介绍,相信你已经清楚 DOM 是怎么生成的了。不过在实际生产环境中,HTML 源文件中既包含 CSS 和 JavaScript,又包含图片、音频、视频等文件,所以处理过程远比上面这个示范 Demo 复杂。不过理解了这个简单的 Demo 生成过程,我们就可以往下分析更加复杂的场景了。(复杂场景这里不分析先)
这里提一个问题:HTML 解析器是等整个 HTML 文档加载完成之后开始解析的,还是随着 HTML 文档边加载边解析的?
答案是HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。
2.样式计算(生成CSS Rule Tree(ComputedStyle))
样式计算的目的是为了计算出DOM节点中每个元素的具体样式。
这个阶段可以分为3个步骤:(同样的读取CSS内容也是和DOM树过程类似,先将CSS字节转为字符,再转为token和节点,最后形成树结构)
1) 把CSS转换为浏览器能够理解的结构
从图中可以看出,这个样式表包含了很多种样式,已经把那三种来源的样式都包含进去了。
里面的具体结构读者感兴趣可以自行查阅。
可以参考此文章:https://juejin.im/post/5d150afee51d45777a1261d2
2)转换样式表中的属性值,使其标准化
现在我们已经把现有的CSS文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作。
要理解什么是属性值标准化,你可以看下面这样一段CSS文本:
body { font-size: 2em }
p {color:blue;}
span {display: none}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
那标准化后的属性值是什么样子的?
3)计算DOM树中每个节点的具体样式
现在样式的属性已被标准化了,接下来就需要计算DOM树中每个节点的样式属性了,如何计算呢?
这就涉及到CSS的继承规则和层叠规则了。
首先是CSS继承。CSS继承就是每个DOM节点都包含有父节点的样式。这么说可能有点抽象,我们可以结合具体例子,看下面这样一张样式表是如何应用到DOM节点上的。
body { font-size: 20px }
p {color:blue;}
span {display: none}
div {font-weight: bold;color:red}
div p {color:green;}
这张样式表最终应用到DOM节点的效果如下图所示:
从图中可以看出,所有子节点都继承了父节点样式。比如body节点的font-size属性是20,那body节点下面的所有节点的font-size都等于20。
样式计算过程中的第二个规则是样式层叠(此处忽略不讲)。层叠是CSS的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在CSS处于核心地位,CSS的全称“层叠样式表”正是强调了这一点。
此时一颗CSS Rule Tree就构建出来了。
3.布局阶段
现在,我们有DOM树和DOM树中元素的样式,但这还不足以显示页面,因为我们还不知道DOM元素的几何位置信息。那么接下来就需要计算出DOM树中可见元素的几何位置,我们把这个计算过程叫做布局。
Chrome在布局阶段需要完成两个任务:创建布局树和布局计算
1)创建布局树(在这一步生成了渲染树)
浏览器引擎会通过DOM Tree 和CSS Rule Tree (ComputedStyle)来构造 Rendering Tree(渲染树)。
下面我们就来看看Rendering Tree的构造过程。
从上图可以看出,DOM树中所有不可见的节点都没有包含到渲染树中。
为了构建渲染树,浏览器大体上完成了下面这些工作
-
遍历DOM树中的所有可见节点,并把这些节点加到布局中;
-
而不可见的节点会被布局树忽略掉,如
head
标签下面的全部内容,再比如body.p.span
这个元素,因为它的属性包含dispaly:none
,所以这个元素也没有被包进布局树 -
对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。
2)布局计算(回流/重排发生在此处)
到目前为止,我们计算了哪些节点应该是可见的以及它们的计算样式,已经有了一颗完整的布局树(渲染树),但我们尚未计算它们在设备
为弄清每个对象在网页上的确切大小和位置,浏览器从渲染树的根节点开始进行遍历。让我们考虑下面这样一个简单的实例:
<!DOCTYPE html>
<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>
以上网页的正文包含两个嵌套 div:第一个(父)div 将节点的显示尺寸设置为视口宽度的 50%,—父 div 包含的第二个 div—将其宽度设置为其父项的 50%;即视口宽度的 25%。
布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸:所有相对测量值都转换为屏幕上的绝对像素。
在这里,会根据每个渲染树节点在页面中的大小和位,将节点固定到页面的对应位置上。
4.分层
现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?
答案依然是否定的。
因为页面中有很多复杂的效果,如一些复杂的3D变换、页面滚动,或者使用z-indexing做z轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。如果你熟悉PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。
但是这里我们先跳过分层的具体内容。
5.图层绘制(重绘发生在此处)
布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,进入绘制阶段。在绘制阶段中,系统会遍历呈现树,并调用呈现器的“paint”方法,将呈现器的内容显示在屏幕上。绘制工作是使用用户界面基础组件完成的。
绘制顺序为:
其实渲染引擎实现图层的绘制时候,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
6.栅格化操作
7.合成和显示
总结:
下面这篇文章是我根据以上的渲染流程总结的一篇比较常见的问题回答,以及一些有关的页面性能优化:
还没写出来先哈哈
参考文章:
https://blog.poetries.top/browser-working-principle/
https://juejin.im/post/5bee2366e51d451fa238957c
参考书籍:
现代前端技术解析