我们先来了解页面文档的渲染机制
- 浏览器通过HTTP协议请求服务器,获取HMTL文档并开始从上到下解析,构建DOM。
- 在构建DOM过程中,如果遇到外联的样式声明和脚本声明(包含js的代码),则暂停文档解析,创建新的网络连接,并开始下载样式文件和执行脚本文件。
- 样式文件下载完成后,构建CSSDOM;脚本文件下载完成后,解释并执行,然后继续解析文档构建DOM 。
- 完成文档解析后,将DOM和CSSDOM进行关联和映射,最后将视图渲染到浏览器窗口。
(1)解析HTML
解析的过程可以分为四个步骤:
1. 解码(encoding)
传输回来的其实都是一些二进制字节数据,浏览器需要根据文件指定编码(例如UTF-8)转换成字符串,也就是HTML 代码。
2. 预解析(pre-parsing)
预解析做的事情是提前加载资源,减少处理时间,它会识别一些会请求资源的属性,比如img
标签的src
属性,并将这个请求加到请求队列中。
3. 标记化(Tokenization)
标记化是词法分析的过程,将输入解析成符号,HTML 符号包括,开始标签、结束标签、属性名和属性值。
它通过一个状态机去识别符号的状态,比如遇到<
,>
状态都会产生变化。
这个算法输入为HTML文本
,输出为HTML标记
,也成为标记生成器。其中运用有限自动状态机来完成。即在当当前状态下,接收一个或多个字符,就会更新到下一个状态。标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。该算法的输出结果是 HTML 标记。
<html>
<body>
Hello sanyuan
</body>
</html>
通过一个简单的例子来演示一下标记化
的过程。
遇到<
, 状态为标记打开。
接收[a-z]
的字符,会进入标记名称状态。
这个状态一直保持,直到遇到>
,表示标记名称记录完成,这时候变为数据状态。
接下来遇到body
标签做同样的处理。
这个时候html
和body
的标记都记录好了。
现在来到<body>中的>,进入数据状态,之后保持这样状态接收后面的字符hello sanyuan。
接着接收 </body> 中的<
,回到标记打开, 接收下一个/
后,这时候会创建一个end tag
的token。
随后进入标记名称状态, 遇到>
回到数据状态。
接着以同样的样式处理 </body>。
4. 构建树(tree construction)
注意:符号化和构建树是并行操作的,也就是说只要解析到一个开始标签,就会创建一个 DOM 节点。
在创建解析器的同时,也会创建 Document 对象。在树构建阶段,以 Document 为根节点的 DOM 树也会不断进行修改,向其中添加各种元素。标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的 DOM 元素,这些元素会在接收到相应的标记时创建。这些元素不仅会添加到 DOM 树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法也可以用状态机来描述。这些状态称为“插入模式”。
树构建阶段的输入是一个来自标记化阶段的标记序列。第一个模式是“initial mode”。接收 HTML 标记后转为“before html”模式,并在这个模式下重新处理此标记。这样会创建一个 HTMLHtmlElement 元素,并将其附加到 Document 根对象上。
<html>
<body>
Hello world
</body>
</html>
然后状态将改为“before head”。此时我们接收“body”标记。即使我们的示例中没有“head”标记,系统也会隐式创建一个 HTMLHeadElement,并将其添加到树中。
现在我们进入了“in head”模式,然后转入“after head”模式。系统对 body 标记进行重新处理,创建并插入 HTMLBodyElement,同时模式转变为“in body”。
现在,接收由“Hello world”字符串生成的一系列字符标记。接收第一个字符时会创建并插入“Text”节点,而其他字符也将附加到该节点。
接收 body 结束标记会触发“after body”模式。现在我们将接收 HTML 结束标记,然后进入“after after body”模式。接收到文件结束标记后,解析过程就此结束。
(2)css解析
WebKit CSS 解析器
WebKit 使用 Flex 和 Bison 解析器生成器,通过 CSS 语法文件自动创建解析器。正如我们之前在解析器简介中所说,Bison 会创建自下而上的移位归约解析器。Firefox 使用的是人工编写的自上而下的解析器。这两种解析器都会将 CSS 文件解析成 StyleSheet 对象,且每个对象都包含 CSS 规则。CSS 规则对象则包含选择器和声明对象,以及其他与 CSS 语法对应的对象。
一旦浏览器下载了 CSS,CSS 解析器就会处理它遇到的任何 CSS,根据语法规范解析出所有的 CSS 并进行标记化,然后我们得到一个规则表。
样式计算
关于CSS样式,它的来源一般是三种:
- link标签引用
- style标签中的样式
- 元素的内嵌style属性
构建呈现树时,需要计算每一个呈现对象的可视化属性。这是通过计算每个元素的样式属性来完成的。
样式包括来自各种来源的样式表、inline 样式元素和 HTML 中的可视化属性(例如“bgcolor”属性)。其中后者将经过转化以匹配 CSS 样式属性。
样式表的来源包括浏览器的默认样式表、由网页作者提供的样式表以及由浏览器用户提供的用户样式表
当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。
- 解析 HTML 文件,构建 DOM 树,同时浏览器主进程负责下载 CSS 文件
- CSS 文件下载完成,解析 CSS 文件成树形的数据结构,然后结合 DOM 树合并成 RenderObject 树
- 布局 RenderObject 树 (Layout/reflow),负责 RenderObject 树中的元素的尺寸,位置等计算
- 绘制 RenderObject 树 (paint),绘制页面的像素信息
-浏览器主进程将默认的图层和复合图层交给GPU进程,GPU进程再将各个图层合成(composite),最后显示出页面
当 DOM 树和 CSSOM 树都构建完成的时候,他们就会合并在一起构建 render tree,因为要在页面上渲染不仅需要这个页面的结构,也需要知道整个页面的样式,所以 render tree 是 DOM 树和 CSSDOM 树的结合体,有了 render tree,浏览器才能知道把什么内容按照什么样式渲染在屏幕上。
(3)构建呈现树的流程(DOM 树和 CSSDOM 树的结合体)
处理 html 和 body 标记就会构建呈现树根节点。这个根节点呈现对象对应于 CSS 规范中所说的容器 block,这是最上层的 block,包含了其他所有 block。它的尺寸就是视口,即浏览器窗口显示区域的尺寸。
(4)生成布局树
- 遍历生成的 DOM 树节点,并把他们添加到
布局树中
。 - 计算布局树节点的坐标位置。
呈现器在创建完成并添加到呈现树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。布局是一个递归的过程。它从根呈现器(对应于 HTML 文档的 <html>
元素)开始,然后递归遍历部分或所有的框架层次结构,为每一个需要计算的呈现器计算几何信息。
- 获取DOM后分割为多个图层
- 对每个图层的节点计算样式结果 (Recalculate style--样式重计算)
- 为每个节点生成图形和位置 (Layout--重排,回流)
- 将每个节点绘制填充到图层位图中 (Paint--重绘)
- 图层作为纹理上传至GPU
- 组合多个图层到页面上生成最终屏幕图像 (Composite Layers--图层重组)
呈现树和 DOM 树的关系
呈现器是和 DOM 元素相对应的,但并非一一对应。非可视化的 DOM 元素不会插入呈现树中,例如“head”元素。如果元素的 display 属性值为“none”,那么也不会显示在呈现树中(但是 visibility 属性值为“hidden”的元素仍会显示)。
(5)渲染流程:
如果你觉得现在DOM节点
也有了,样式和位置信息也都有了,可以开始绘制页面了,那你就错了。
因为你考虑掉了另外一些复杂的场景,比如3D动画如何呈现出变换效果,当元素含有层叠上下文时如何控制显示和隐藏等等。
为了解决如上所述的问题,浏览器在构建完布局树
之后,还会对特定的节点进行分层,构建一棵图层树
(Layer Tree
)。
那这棵图层树是根据什么来构建的呢?
一般情况下,节点的图层会默认属于父亲节点的图层(这些图层也称为合成层)。那什么时候会提升为一个单独的合成层呢?
有两种情况需要分别讨论,一种是显式合成,一种是隐式合成。
显式合成
下面是显式合成
的情况:
一、 拥有层叠上下文的节点。
层叠上下文也基本上是有一些特定的CSS属性创建的,一般有以下情况:
- HTML根元素本身就具有层叠上下文。
- 普通元素设置position不为static并且设置了z-index属性,会产生层叠上下文。
- 元素的 opacity 值不是 1
- 元素的 transform 值不是 none
- 元素的 filter 值不是 none
- 元素的 isolation 值是isolate
- will-change指定的属性值为上面任意一个。(will-change的作用后面会详细介绍)
二、需要剪裁的地方。
比如一个div,你只给他设置 100 * 100 像素的大小,而你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条会被单独提升为一个图层。
隐式合成
接下来是隐式合成
,简单来说就是层叠等级低
的节点被提升为单独的图层之后,那么所有层叠等级比它高
的节点都会成为一个单独的图层。
这个隐式合成其实隐藏着巨大的风险,如果在一个大型应用中,当一个z-index
比较低的元素被提升为单独图层之后,层叠在它上面的的元素统统都会被提升为单独的图层,可能会增加上千个图层,大大增加内存的压力,甚至直接让页面崩溃。这就是层爆炸的原理。
值得注意的是,当需要repaint
时,只需要repaint
本身,而不会影响到其他的层。
生成图块和生成位图
现在开始绘制操作,实际上在渲染进程中绘制操作是由专门的线程来完成的,这个线程叫合成线程。
在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。
顺便提醒一点,渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据。
然后合成线程会选择视口附近的图块,把它交给栅格化线程池生成位图。
生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程
。
回流和重绘
简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流
的过程。
当 DOM 的修改导致了样式的变化,并且没有影响几何属性的时候,会导致重绘
(repaint
)。
为什么 Javascript 要是单线程的 ?
如果 Javascript 是多线程的话,在多线程的交互下,处于 UI 中的 DOM 节点就可能成为一个临界资源,
假设存在两个线程同时操作DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果。
为什么 JS 阻塞页面加载 ?
由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JavaScript 引擎为互斥的关系。
css 加载会造成阻塞吗 ?
DOM 和 CSSOM 通常是并行构建的,所以 CSS 加载不会阻塞 DOM 的解析。
然而,由于 Render Tree 是依赖于 DOM Tree 和 CSSOM Tree 的,
所以他必须等待到 CSSOM Tree 构建完成,也就是 CSS 资源加载完成(或者 CSS 资源加载失败)后,才能开始渲染。
因此,CSS 加载会阻塞 Dom 的渲染。
因此,样式表会在后面的 js 执行前先加载执行完毕,所以css 会阻塞后面 js 的执行。
什么是 CRP,即关键渲染路径(Critical Rendering Path)? 如何优化 ?
关键渲染路径的度量标准是路径长度。最理想的关键路径长度是1。如果页面包含一些内部样式和 JavaScript ,关键路径发生以下改变。
关键字节数
我们使用一个外部 CSS 文件,一个外部 JavaScript 文件,和一个外部带 async
属性的 JavaScript 文件。关键路径图如下:
关键渲染路径是浏览器将 HTML CSS JavaScript 转换为在屏幕上呈现的像素内容所经历的一系列步骤。也就是我们上面说的浏览器渲染流程。
为尽快完成首次渲染,我们需要最大限度减小以下三种可变因素:
关键资源的数量: 可能阻止网页首次渲染的资源。
关键路径长度: 获取所有关键资源所需的往返次数或总时间。
关键字节: 实现网页首次渲染所需的总字节数,等同于所有关键资源传送文件大小的总和。
- 优化 DOM
删除不必要的代码和注释包括空格,尽量做到最小化文件,可以利用 GZIP 压缩文件,结合 HTTP 缓存文件。
- 优化 CSSOM
缩小、压缩以及缓存同样重要,对于 CSSOM 我们前面重点提过了它会阻止页面呈现,因此我们可以从这方面考虑去优化。
减少关键 CSS 元素数量
当我们声明样式表时,请密切关注媒体查询的类型,它们极大地影响了 CRP 的性能 。
- 优化 JavaScript
当浏览器遇到 script 标记时,会阻止解析器继续操作,直到 CSSOM 构建完毕,JavaScript 才会运行并继续完成 DOM 构建过程。
async: 当我们在 script 标记添加 async 属性以后,浏览器遇到这个 script 标记时会继续解析 DOM,同时脚本也不会被 CSSOM 阻止,即不会阻止 CRP。
defer: 与 async 的区别在于,脚本需要等到文档解析后( DOMContentLoaded 事件前)执行,.它们会在浏览器解析到结束的</html>
标签后才会执行。
这个属性表示脚本在执行的时候不会改变页面的结构。也就是说,脚本会被延迟到整个页面都解析完毕后再运行。因此,在<script>
元素上设置defer
属性,相当于告诉浏览器立即下载,但延迟执行。
当我们的脚本不会修改 DOM 或 CSSOM 时,推荐使用 async 。
预加载 —— preload & prefetch 。
DNS 预解析 —— dns-prefetch 。
总结
分析并用 关键资源数 关键字节数 关键路径长度 来描述我们的 CRP 。
最小化关键资源数: 消除它们(内联)、推迟它们的下载(defer)或者使它们异步解析(async)等等
优化关键字节数(缩小、压缩)来减少下载时间 。
优化加载剩余关键资源的顺序: 让关键资源(CSS)尽早下载以减少 CRP 长度 。
渲染
渲染的流程基本上如下(黄色的四个步骤):
计算CSS样式
构建Render Tree
Layout – 定位坐标和大小,是否换行,各种position, overflow, z-index属性 ……
正式开画
浏览器这边做的工作大致分为以下几步:
加载:根据请求的URL进行域名解析,向服务器发起请求,接收文件(HTML、JS、CSS、图象等)。
解析:对加载到的资源(HTML、JS、CSS等)进行语法解析,建议相应的内部数据结构(比如HTML的DOM树,JS的(对象)属性表,CSS的样式规则等等)
渲染:构建渲染树,对各个元素进行位置计算、样式计算等等,然后根据渲染树对页面进行渲染(可以理解为“画”元素)
这几个过程不是完全孤立的,会有交叉,比如HTML加载后就会进行解析,然后拉取HTML中指定的CSS、JS等。