之前整理过一篇浏览器中 JavaScript 单线程运行机制,里面主要介绍了浏览器中的各类进程和线程以及 JavaScript 的事件循环机制。本文将从整个渲染流程角度继续深入理解浏览器的运行机制。
浏览器的内核是指支持浏览器运行的最核心的程序,分为两个部分,一是渲染引擎,另一个是 JS 引擎。渲染引擎在不同的浏览器中也不是都相同的。目前市面上常见的浏览器内核可以分为这四种:
Webkit 是由苹果主导推出的一个项目,早期的 Chrome 也是使用这款内核,后来谷歌从 Webkit 拉了分支开发自己的内核 Blink,两者开始分道扬镳。
一个个 Web 页面就是通过渲染引擎去呈现给用户的。
在介绍渲染流程前,我们需要简明扼要介绍下页面的加载过程,有助于更好理解后续渲染过程:
这个加载过程涉及到的知识点可以学习 TCP、HTTP 等网络层面的知识,当浏览器拿到请求到的页面后,就正式开始浏览器的渲染过程。
我们一开始从服务端拿到的是一堆 HMTL 格式的字符串,因为只有 HTML 格式浏览器才能正确解析,这是 W3C 标准的要求。
浏览器渲染过程大体分为如下三部分:
这一步会将数据绘制到浏览器上,用户才能真正看到一张完整的页面。
下面将详细介绍具体细节。
浏览器会遵循一套规则将 HTML 解析为 DOM 树:
浏览器从磁盘或网络读取 HTML 的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成字符串。
当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记(Token),Token 中会标识出当前 Token 是“开始标签”或是“结束标签”亦或是“文本”等信息。这一过程在词法分析中叫做标记化(tokenization)
当结束标记化后,这些标记会紧接着转换为 Node,最后这些 Node 会根据不同 Node 之前的联系构建为一棵 DOM Tree。
事实上,解析 DOM 的过程中,不是等所有 token 都转换完成后再去生成节点对象,而是一边生成 Token 一边消耗 Token 来生成节点对象。换句话说,每个Token被生成后,会立刻消耗这个Token创建出节点对象。注意:带有结束标签标识的 Token 不会创建节点对象。
DOM 会捕获页面的内容,但浏览器还需要知道页面如何展示,所以需要 CSSOM。
解析 CSSOM 的过程与解析 DOM 的过程非常相似,当浏览器接收到一段 CSS,浏览器首先要做的是识别出Token,然后解析节点并生成 CSSOM。
在这一过程中,浏览器会确定下每一个节点的样式到底是什么,并且这一过程其实是很消耗资源的。因为样式你可以自行设置给某个节点,也可以通过继承获得。在这一过程中,浏览器得递归 CSSOM 树,然后确定具体的元素到底是什么样式。
CSS 匹配 HTML 元素是一个相当复杂和有性能问题的事情,所以我们应该尽可能的避免写过渡层叠的 CSS 选择器,尽量用 id和 class,减少递归次数。对于 HTML 来说也尽量少的添加无意义标签,保证层级扁平。
<div>
<a> <span></span> </a>
</div>
<style>
/* 只需要一级匹配 */
span {
color: red;
}
/* 多级匹配,影响性能 */
div > a > span {
color: red;
}
</style>
当我们生成 DOM Tree 和 CSS Tree 后,就需要将这两棵树组合为渲染树(Rendering Tree)。
在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是 display: none
的,那么就不会在渲染树中显示。
当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”。
布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。
布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。
我们或许有个疑惑:浏览器如果渲染过程中遇到 JS 文件怎么处理?
渲染过程中,如果遇到 <script>
就停止渲染,执行 JavaScript 代码。因为浏览器有 GUI 渲染线程与 JS 引擎线程,为了防止渲染出现不可预期的结果,这两个线程是互斥的关系。JavaScript 的加载、解析与执行会阻塞 DOM 的解析,也就是说,在解析 DOM 时,HTML 解析器若遇到了 JavaScript,那么它会暂停解析 DOM,将控制权移交给 JS 引擎,等 JS 引擎运行完毕,浏览器再从中断的地方恢复 DOM 解析。
如果 JavaScript 代码是通过外部文件资源引入的,还可以通过设置 async、defer 属性来控制它执行的时机,具体见文章关于 Script 标签的加载过程。
简单可以总结为:
另外一个重要的知识点,JavaScript 不只是阻塞 DOM 的解析,它会导致 CSSOM 也阻塞 DOM 的解析。
原本 DOM 和 CSSOM 的解析是互不影响,是两条并行的路径,但是一旦引入了 JavaScript,CSSOM 也开始阻塞 DOM 的解析,只有 CSSOM 解析完毕后,再恢复 DOM 解析过程。
这是因为 JavaScript 代码不仅可以访问 DOM API,也可以访问 CSSOM API,改变样式。不完整的 CSSOM 无法使用,所以必须等 CSSOM 解析完成后才能执行,而 JavaScript 本身是能打断 DOM 解析的。所以就导致了一个现象,如果浏览器尚未完成 CSSOM 的加载和解析,而我们却想在此时运行脚本,那么浏览器将延迟脚本执行和 DOM 解析,直至其完成 CSSOM 的解析完成。
在整个渲染过程中,有两个概念是逃不开的,那就是重绘(repaint)和回流(reflow)。
我们知道,当网页生成的时候,至少会渲染一次。在用户访问的过程中,还会不断重新渲染。重新渲染会重复回流 + 重绘或者只有重绘。
回流必定会发生重绘,重绘不一定会引发回流。重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。
任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发回流
:hover
)一些常用且会导致回流的属性和方法:
当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color、background-color、visibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。
有时即使仅仅回流一个单一的元素,它的父元素以及任何跟随它的元素也会产生回流。
现代浏览器会对频繁的回流或重绘操作进行优化,浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。
当你访问以下属性或方法时,浏览器会立刻清空队列:
因为队列中可能会有影响到这些属性或方法返回值的操作,即使希望获取的信息与队列中操作引发的改变无关,浏览器也会强行清空队列,确保拿到的值是最精确的。
CSS 优化
JavaScript 优化
关键渲染路径(Critical Rendering Path)是指浏览器将 HTML,CSS,JavaScript 转换为屏幕上所呈现的实际像素这期间所经历的一系列步骤。
关键渲染路径共分五个步骤。构建 DOM -> 构建 CSSOM -> 构建渲染树 -> 布局 -> 绘制。
CSSOM 会阻塞渲染,只有当 CSSOM 构建完毕后才会进入下一个阶段构建渲染树。
通常情况下 DOM 和 CSSOM 是并行构建的,但是当浏览器遇到一个 script 标签时,DOM 构建将暂停,直至脚本完成执行。但由于 JavaScript 可以修改 CSSOM,所以需要等 CSSOM 构建完毕后再执行 JavaScript。
通过上面的讨论,我们基本清楚了整个页面渲染流程,这里再讲一下 CSS 阻塞问题:
由上面对于关键渲染路径(CRP)的讨论,可知页面会等 DOM 解析和 CSSOM 解析一块完成完成才会绘制,如果两者处理时机不对的话,会导致一闪而过的样式变化和白屏情况。
FOUC(Flash of Unstyled Content,无样式内容闪烁)问题来源于浏览器先显示已加载的 HTML 内容,等到 CSS 加载完成后重新对内容添加样式导,一般是由:
而白屏的产生的机制也跟浏览器渲染机制有关,页面的渲染过程出现了阻塞:
所以归根到底,还是要记得这个规矩:将 CSS link 置于头部,script 文件置于底部,合理使用 async 和 defer 控制 JavaScript 的执行机制,避免阻塞。
这里基于页面的渲染机制简单罗列一些优化点,都是些理论知识,以作参考:
提高 html 性能
提高 css 性能
提高 JavaScript 性能