➡️ 本文内容主体部分来自 浏览器的渲染过程 - 知乎
通常,我们只需要编写 HTML,CSS,JavaScript,浏览器上就能呈现出漂亮的网页了,但是浏览器是如何使用我们的代码在屏幕上渲染像素的呢?
请先看一张大图
浏览器将 HTML,CSS,JavaScript 代码转换成屏幕上所能呈现的实际像素,这期间所经历的一系列步骤,叫做关键渲染路径(Critical Rendering Path)。其中包含:
- 构建 对象模型(DOM,CSSOM)
- 构建渲染树(RenderTree)
- 布局
- 渲染
在构建对象模型到构建 渲染树的这一过程,还穿插着 JS 脚本的加载和执行。如下图所示:
![[assets/Pasted image 20241213170156.png]]
DOMTree 的构建
浏览器的渲染从解析 HTML 文档开始,宏观上,可以分为下面几个步骤:
第一步(解析):从网络或者磁盘下读取的 HTML 原始 字节码,通过设置的 charset 编码,转换成相应字符。
![[assets/Pasted image 20241213170126.png]]
第二步(token 化):通过 词法分析器,将字符串解析成 Token,Token 中会标注出当前的 Token 是 开始标签
,还是 结束标签
,或者 文本标签
等。
第三步(生成 Nodes 并构建 DOM 树):浏览器会根据 Tokens 里记录的 开始标签
,结束标签
,将 Tokens 之间相互串联起来(带有结束标签的 Token 不会生成 Node)。
Node 包含了这个节点的所有属性。例如 <img src="xxx.png" >
标签最终生成出的节点对象中会保存图片地址等信息。
事实上,在构建 DOM 树时,不是要等所有的 Tokens 都转换成 Nodes 后才开始,而是一边生成 Token 一边采取 深度遍历算法
消耗 Token 来生成 Node,如下图所示:
图中有颜色的小数字代表构建的具体步骤,可以看出,首先生成出 html Token
,并消耗 Token 创建出 html 节点对象
,接着生成 head Token
并消耗 Token 创建出 head节点对象
……,当所有的 Tokens 都消耗完了,紧接着 DOM 树也就构建完了。
![[assets/Pasted image 20241213170055.png]]
这里抛出个小问题,为什么有时在 js 中访问 DOM 时浏览器会报错呢?
因为在上述的解析的过程中,如果碰到了 script
或者 link
标签,就会根据 src
对应的地址去加载资源,在 script
标签没有设置 async/defer
属性时,这个加载过程是 下载并执行完全部的代码
,此时,DOM 树还没有完全创建完毕,这个时候如果 js 企图访问 script 标签后面的 DOM 元素,浏览器就会抛出找不到该 DOM 元素的错误。
值得注意的是:从 bytes 到 Tokens 的这个过程,浏览器都可以交给其他单独的线程去处理,不会堵塞浏览器的 渲染线程。但是后面的部分就都在渲染线程下进行了,也就是我们常说的 js 单线程环境。
CSSOMTree 的构建
DOM 会记录页面的内容,但是浏览器还需要知道这些内容该用什么样式去展示,所以还需要构建 CSSOMTree。CSSOM 的生成过程和 DOM 的生成过程十分相似,也是:1.解析,2. Token 化,3.生成 Nodes 并构建 CSSOMTree:
假设浏览器收到了下面这样一段 css:
|
|
最终会生成如下的 CSSOMTree:
从图中可以看出,最开始 body
有一个样式规则是 font-size:16px
,之后,在 body 这个样式基础上每个 子节点还会添加自己单独的样式规则,比如 span
又添加了一个样式规则 color:red
。正是因为样式这种类似于继承的特性,浏览器设定了一条规则:CSSOMTree 需要等到完全构建后才可以被使用,因为后面的属性可能会覆盖掉前面的设置。比如在上面的 css 代码基础上再添加一行代码 p {font-size:12px}
,那么之前设置的 16px
将会被覆盖成 12px
。
下面是官方给的一种解释:
未构建完的 CSSOMTree 是不准确的,浏览器必须等到 CSSOMTree 构建完毕后才能进入下一阶段。
所以,CSS 的加载速度与构建 CSSOMTree 的速度将直接影响首屏渲染速度,因此在默认情况下 CSS 被视为阻塞渲染的资源,需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
那么回到上面生成 DOM 时提到的 JS 问题:在标签没有设置 async/defer
属性时,js 会阻塞 DOM 的生成。原因是 js 会改变 DOMTree 的内容,如果不阻塞,会出现一边生成 DOM 内容,一边修改 DOM 内容的情况,无法确保最终生成的 DOMTree 是确定唯一的。
同理,JS 也会可以修改 CSS 样式,影响 CSSOMTree 最终的结果。而我们前面提到,不完整的 CSSOMTree 是不可以被使用的,如果 JS 试图在浏览器还未完成 CSSOMTree 的下载和构建时去操作 CSS 样式,浏览器会暂停脚本的运行和 DOM 的构建,直至浏览器完成了 CSSOM 的下载和构建。也就是说,JS 脚本的出现会让 CSSOM 的构建阻塞 DOM 的构建。
平时谈及页面性能优化,经常会强调 css 文件应该放在 html 文档中的前面引入,js 文件应该放在后面引入,这么做的原因是什么呢?
举个例子:本来,DOM 构建和 CSSOM 构建是两个过程,井水不犯河水。假设 DOM 构建完成需要 1s,CSSOM 构建也需要 1s,在 DOM 构建了 0.2s 时发现了一个 link
标签,此时完成这个操作需要的时间大概是 1.2s,如下图所示:
而此时我们在 HTML 文档的中间插中入了一段 JS 代码,在 DOM 构建中间的过程中发现了这个 script
标签,假设这段 JS 代码只需要执行 0.0001s,那么完成这个操作需要的时间就会变成:
![[assets/Pasted image 20241213170001.png]]
那如果我们把 css 放到前面,js 放到最后引入时,构建时间会变成:
![[assets/Pasted image 20241213165908.png|600]]
由此可见,虽然只是插入了小小的一段只运行 0.0001s 的 js 代码,不同的引入时机也会严重影响 DOMTree 的构建速度。
简而言之,如果在 DOM,CSSOM 和 JavaScript 执行之间引入大量的依赖关系,可能会导致浏览器在处理渲染资源时出现大幅度延迟:
- 当浏览器遇到一个 script 标签时,DOMTree 的构建将被暂停,直至脚本执行完毕
- JavaScript 可以查询和修改 DOMTree 与 CSSOMTree
- 直至 CSSOM 构建完毕,JavaScript 才会执行
- 脚本在文档中的位置很重要
渲染树的构建
现在,我们已经拥有了完整的 DOM 树和 CSSOM 树。DOM 树上每一个节点对应着网页里每一个元素,CSSOM 树上每个节点对应着网页里每个元素的样式,并且此时浏览器也可以通过 JavaScript 操作 DOM/CSSOM 树,动态改变它的结构。但是 DOM/ CSSOM 树本身并不能直接用于排版和渲染,浏览器还会生成另外一棵树:Render 树。
接下来我们来谈几条概念
- Render 树上的每一个节点被称为:
RenderObject
。 - RenderObject 跟 DOM 节点几乎是一一对应的,当一个
可见的 DOM 节点
被添加到 DOM 树上时,内核就会为它生成对应的 RenderOject 添加到 Render 树上。 - 其中,可见的 DOM 节点不包括:
- 一些不会体现在渲染输出中的节点(
<html><script><link>….
),会直接被忽略掉。 - 通过 CSS 隐藏的节点。例如上图中的
span
节点,因为有一个 CSS 显式规则在该节点上设置了display:none
属性,那么它在生成 RenderObject 时会被直接忽略掉。
- 一些不会体现在渲染输出中的节点(
- Render 树是衔接浏览器排版引擎和渲染引擎之间的桥梁,它是排版引擎的输出,渲染引擎的输入。
此时的 Render 树上,已经包含了网页上所有可见元素的内容和位置信息 排版引擎会根据 Render 树的内容和结构,准确的计算出元素该在网页上的什么位置。到此,我们已经具备进入布局的一切准备条件,但是通过上面我们知道,布局后面还有一个渲染过程,那么 Render 树是衔接浏览器排版引擎和渲染引擎之间的桥梁,它是排版引擎的输出,渲染引擎的输入。这句话是什么意思呢?
RenderObject 和 RenderLayer
浏览器渲染引擎并不是直接使用 Render 树进行绘制,为了方便处理
Positioning,Clipping,Overflow-scroll,CSS Transfrom/Opacrity/Animation/Filter,Mask or Reflection,Z-indexing
等属性,浏览器需要生成另外一棵树:Layer 树 。
浏览器会为一些特定的 RenderObject
生成对应的 RenderLayer
,其中的规则是:
- 是否是页面的根节点
- 是否有 css 的一些布局属性
- 是否透明
- 是否有溢出
- 是否有 css 滤镜
- 是否包含一个 canvas 元素使得节点拥有视图上下文
- 是否包含一个 video 元素
当满足上面其中一个条件时,这个 RrenderObject
就会被浏览器选中生成对应的 RenderLayer
。至于那些没有被命运选中的 RrenderObject,会从属与父节点的 RenderLayer。最终,每个 RrenderObject 都会直接或者间接的属于一个 RenderLayer。
浏览器渲染引擎在布局和渲染时会遍历整个 Layer 树,访问每一个 RenderLayer
,再遍历从属于这个 RenderLayer 的 RrenderObject
,将每一个 RenderObject 绘制出来。可以理解为:Layer 树决定了网页绘制的层次顺序,而从属于 RenderLayer 的 RrenderObject 决定了这个 Layer 的内容,所有的 RenderLayer
和 RrenderObject
一起就决定了网页在屏幕上最终呈现出来的内容。
布局
到目前为止,浏览器计算出了哪些节点是可见的以及它的信息和样式,接下来就需要计算这些节点在设备视口内的确切位置和大小,这个过程我们称之为“布局”。
布局最后的输出是一个“盒模型”:将所有相对测量值都转换成屏幕上的绝对像素。
渲染
最后,既然我们知道了哪些节点可见、它们的计算样式以及几何信息,我们终于可以将这些信息传递给最后一个阶段:将渲染树中的每个节点转换成屏幕上的实际像素:浏览器通过发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。
至此,我们就能够在浏览器上看到漂亮的网页了
谈及页面性能优化,我们也常说要尽量减少浏览器的重排和重绘,浏览器重排和重绘时究竟做了哪些工作呢?
我们平时常说的重排,其实就是浏览器计算 render 树,布局到渲染的这个过程,而重绘就是计算 layer 树到渲染的这个过程,每当触发一次重绘和重排时,浏览器都需要重新经过一遍上述的计算。很显然,重排会产生比重绘更大的开销,但无论是重排还是重绘,都会给浏览器渲染线程造成很大的负担,所以,我们在实际生产中要严格注意减少重排和重绘的触发。至于如何减少重排和重绘的次数,这里就不多做展开了,详细请听下回分解~
总结
经过 1.构建对象模型(DOM,CSSOM),2.构建渲染树(RenderTree),3.布局,4.渲染
这几个步骤后,我们就能在浏览器上看到漂亮的网页啦。
CSS 被视为阻塞渲染的资源,应放到代码的头部尽快加载。
同步的 JavaScript 会暂停 DOMTree 的构建,应放到代码的尾部最后加载,或者使用 async/defer 属性
异步加载 JavaScript。
重排和重绘会给浏览器渲染线程造成很大的负担,尽量减少重排和重绘的触发次数。