在展开渲染机制相关内容之前,我们先来简单的了解一下常见的浏览器内核有哪些,浏览器的内核是指支持浏览器运行的最核心的程序,分为两个部分的,一是渲染引擎,另一个是 JavaScript
引擎(比如我们经常可以听到的 V8
引擎),渲染引擎在不同的浏览器中也不是都相同的,目前市面上常见的浏览器内核主要有以下这些
浏览器/RunTime | 内核(渲染引擎) | JavaScript 引擎 |
---|---|---|
Chrome |
Blink(28~)Webkit(Chrome 27) |
V8 |
FireFox |
Gecko |
SpiderMonkey |
Safari |
Webkit |
JavaScriptCore |
Edge |
EdgeHTML |
Chakra(for JavaScript) |
IE |
Trident |
Chakra(for JScript) |
PhantomJS |
Webkit |
JavaScriptCore |
Node.js |
- | V8 |
这里面大家最耳熟能详的可能就是 Webkit
内核了,其中的 Blink
其实就是 Webkit
的一个分支,也就是说它也是基于 Webkit
的,所以本章也就以 Webkit
为例,来看看浏览器的渲染机制到底是一个什么样的过程,不过在此之前,我们先来简单的了解一下浏览器的主要组成部分和它包含的一些主要进程,这有助于我们下面更好的理解浏览器当中的渲染机制
浏览器的主要组成部分
主要分为以下几部分
- 用户界面,包括地址栏、前进/后退 按钮、书签菜单等,除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面
- 浏览器引擎,在用户界面和呈现引擎之间传送指令
- 呈现引擎,负责显示请求的内容,如果请求的内容是
HTML
,它就负责解析HTML
和CSS
内容,并将解析后的内容显示在屏幕上 - 网络,用于网络调用,比如
HTTP
请求,其接口与平台无关,并为所有平台提供底层实现 - 用户界面后端,用于绘制基本的窗口小部件,比如组合框和窗口,其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法
JavaScript
解释器,用于解析和执行JavaScript
代码- 数据存储,这是持久层,浏览器需要在硬盘上保存各种数据,例如
Cookie
,新的HTML
规范(HTML5
)定义了『网络数据库』,这是一个完整(但是轻便)的浏览器内数据库
如下图所示
浏览器的主要进程
我们在之前的章节当中已经简单介绍了进程和线程的一些相关内容(可见 JavaScript 中的事件轮询机制),这里我们简单的复习一下
- 进程是
CPU
资源分配的最小单位(是能拥有资源和独立运行的最小单位) - 线程是
CPU
调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程) - 不同进程之间也可以通信,不过代价较大
- 单线程与多线程,都是指在一个进程内的单和多
对于计算机来说,每一个应用程序都是一个进程,而每一个应用程序都会分别有很多的功能模块,这些功能模块实际上是通过子进程来实现的,对于这种子进程的扩展方式,我们可以称这个应用程序是多进程的
而对于浏览器来说,浏览器就是多进程的,比如我们在 Chrome
浏览器中打开了多个 TAB
,然后打开控制管理器是可以看到一个 Chrome
浏览器启动了好多个进程,那么浏览器又是具体包含了哪些进程呢?可以参考下面这个图片
可以归纳为以下这些
- 主进程(
Browser Process
)- 协调控制其他子进程(创建、销毁)
- 浏览器界面显示,用户交互,前进、后退、收藏
- 将渲染进程得到的内存中的
Bitmap
,绘制到用户界面上 - 处理不可见操作,网络请求,文件访问等
- 第三方插件进程(
Plugin Process
)- 每种类型的插件对应一个进程,仅当使用该插件时才创建
GPU
进程(GPU Process
)- 最多只有一个,用于
3D
绘制等
- 最多只有一个,用于
- 渲染进程(
Renderer Process
)- 也称为浏览器渲染进程或浏览器内核,内部是多线程的
- 主要负责页面渲染,脚本执行,事件处理等
- 每个
TAB
页一个渲染进程
其实上面的有些进程我们不需要太过理解,在这里我们只需要重点关心渲染进程,也就是我们常说的浏览器内核
渲染进程(浏览器内核)
进程和线程是一对多的关系,也就是说一个进程包含了多条线程,而对于渲染进程来说,它当然也是多线程的了,接下来我们来看一下渲染进程包含哪些线程,主要有以下这些
GUI
渲染线程- 负责渲染浏览器界面,解析
HTML
,CSS
,构建DOM
树和RenderObject
树,布局和绘制等 - 当界面需要重绘(
Repaint
)或由于某种操作引发回流(reflow
)时,该线程就会执行 - 注意,
GUI
渲染线程与JavaScript
引擎线程是互斥的,当JavaScript
引擎执行时GUI
线程会被挂起(相当于被冻结了),GUI
更新会被保存在一个队列中等到JavaScript
引擎空闲时立即被执行
- 负责渲染浏览器界面,解析
JavaScript
引擎线程- 该线程也称为
JavaScript
内核,负责处理JavaScript
脚本程序(例如V8
引擎) JavaScript
引擎线程负责解析JavaScript
脚本,运行代码JavaScript
引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab
页(renderer
进程)中无论什么时候都只有一个JavaScript
线程在运行JavaScript
程序- 注意,
GUI
渲染线程与JavaScript
引擎线程是互斥的,所以如果JavaScript
执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞
- 该线程也称为
- 事件触发线程
- 归属于浏览器而不是
JavaScript
引擎,用来控制事件循环(可以理解,JavaScript
引擎自己都忙不过来,需要浏览器另开线程协助) - 当
JavaScript
引擎执行代码块如setTimeOut
时(也可来自浏览器内核的其他线程,如鼠标点击、Ajax
异步请求等),会将对应任务添加到事件线程中 - 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待
JavaScript
引擎的处理 - 注意,由于
JavaScript
的单线程关系,所以这些待处理队列中的事件都得排队等待JavaScript
引擎处理(当JavaScript
引擎空闲时才会去执行)
- 归属于浏览器而不是
- 定时触发器线程
- 传说中的
setInterval
与setTimeout
所在线程 - 浏览器定时计数器并不是由
JavaScript
引擎计数的,(因为JavaScript
引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确) - 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待
JavaScript
引擎空闲后执行) - 注意,
W3C
在HTML
标准中规定,规定要求setTimeout
中低于4ms
的时间间隔算为4ms
- 传说中的
- 异步
HTTP
请求线程- 在
XMLHttpRequest
在连接后是通过浏览器新开一个线程请求 - 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由
JavaScript
引擎执行
- 在
在了解完浏览器的一些相关内容后,下面我们就来正式的看看浏览器的渲染过程到底是什么样子的
渲染过程
本小节内容主要参考的是 Critical Rendering Path 当中的内容,这里有所调整,主要是方便自己理解,如果想要了解更为详细的内容可以参考原文
大家都听说过 <script>
标签会阻塞 HTML
页面解析,而 <link>
标签则不会,结果也确实是这样的,<link>
等样式资源的下载、解析确实不会阻塞页面的解析,但它们会阻塞页面的渲染,我们来看看两者的区别
- 页面解析,阻塞
HTML
页面解析,HTML
页面会被继续下载,但阻塞点后面的标签不会被解析,<img>
,<link>
等不会发请求获取外部资源 - 页面渲染,阻塞
HTML
页面渲染,HTML
页面会被继续下载,阻塞点后面的标签会继续被解析,<img>
,<link>
等会继续发送请求获取外部资源,但不会合成Rendering Tree
或不会触发页面渲染,也不会执行JavaScript
代码
至于为什么会这样,我们就需要在浏览器的渲染机制当中来寻找答案了,简单来说有以下几个步骤
1、解析 HTML 标签,构建 DOM 树
这个阶段对应着 Create/Update DOM And request CSS/Images/JavaScript
的过程,也就是说浏览器请求到 HTML
代码后,在生成 DOM
的最开始阶段(应该是 Bytes → characters
后),并行发起 CSS
、Images
、JavaScript
的请求,无论他们是否在 HEAD
里
不过需要注意的是,发起 JavaScript
文件的下载 request
,并不需要 DOM
处理到那个 script
节点
2. 解析 CSS 标签,构建 CSSOM 树
这个阶段对应着 Create/Update Render CSSOM
的过程,即 CSS
文件下载完成,开始构建 CSSOM
,其中的 CSSOM
即 CSS Object Model
,是浏览器将 CSS
代码解析成树形的数据结构,这个我们会在下方来进行介绍
其中 DOM
和 CSSOM
都是以下图当中所示的流程,也就是依次经过 Bytes
(字节数据),Characters
(字符串),Tokens
,Nodes
最后到 Object Model
这样的方式生成最终的数据,也就是如下几个步骤
- 转码,浏览器将接收到的二进制数据按照指定编码格式转化为
HTML
字符串 - 生成
Tokens
,之后开始parser
,浏览器会将HTML
字符串解析成Tokens
- 构建
Nodes
,对Node
添加特定的属性,通过指针确定Node
的父、子、兄弟关系和所属treeScope
- 生成
DOM Tree
,通过Node
包含的指针确定的关系构建出DOM Tree
在这里我们需要注意的是,DOM
树的构建过程是一个深度遍历过程,当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点
3. 把 DOM 和 CSSOM 组合成渲染树
这个阶段对应着 Create/Update Render Tree
的过程,所有 CSS
文件下载完成,CSSOM
构建结束后,和 DOM
一起生成 Render Tree
4. 在渲染树的基础上进行布局,计算每个节点的几何结构
这个阶段对应着 Layout
的过程,有了 Render Tree
,浏览器已经能知道网页中有哪些节点、各个节点的 CSS
定义以及他们的从属关系,这一步操作之所以称为 Layout
,顾名思义就是计算出每个节点在屏幕中的位置
5. 把每个节点绘制到屏幕上
这个阶段对应着 Painting
的过程,Layout
后,浏览器已经知道了哪些节点要显示(which nodes are visible
)、每个节点的 CSS
属性是什么(their computed styles
)、每个节点在屏幕中的位置是哪里(geometry
),就进入了最后一步 Painting
,按照算出来的规则,通过显卡,把内容画到屏幕上
Render Tree
和 DOM
一样,以多叉树的形式保存了每个节点的 CSS
属性、节点本身属性、以及节点的孩子节点
这里有一个比较特殊的情况,就是
display: none
的节点不会被加入Render Tree
,而visibility: hidden
则会,所以如果某个节点最开始是不显示的,设为display: none
是更优的
以上五个步骤前三个步骤之所有使用 Create/Update
是因为 DOM
、CSSOM
、Render Tree
都可能在第一次 Painting
后又被更新多次,比如 JavaScript
修改了 DOM
或者 CSS
属性,Layout
和 Painting
也会被重复执行,除了 DOM
、CSSOM
更新的原因外,图片下载完成后也需要调用 Layout
和 Painting
来更新网页
渲染过程拆解
我们在上面小节当中提到的渲染过程的五个步骤看起来可能比较抽象,下面我们就来将它们拆解成我们经常遇到的一些操作,然后简单的归纳一下各个步骤
通常来说我们在编写 Web
页面时,我们需要理解我们所写的页面代码是如何被转换成屏幕上显示的像素的,这个转换过程我们可以归纳为这样的一个流水线,包含五个关键步骤
JavaScript
- 一般来说,我们会使用
JavaScript
来实现一些视觉变化的效果,比如用jQuery
的animate
函数做一个动画、对一个数据集进行排序、或者往页面里添加一些DOM
元素等,当然除了JavaScript
还有其他一些常用方法也可以实现视觉变化效果,比如CSS Animations
,Transitions
和Web Animation API
- 一般来说,我们会使用
Style calculations
(计算样式)- 这个过程是根据
CSS
选择器,比如.headline
或.nav > .nav_item
,对每个DOM
元素匹配对应的CSS
样式,这一步结束之后,就确定了每个DOM
元素上该应用什么CSS
样式规则
- 这个过程是根据
Layout
(布局)- 上一步确定了每个
DOM
元素的样式规则,这一步就是具体计算每个DOM
元素最终在屏幕上显示的大小和位置,Web
页面中元素的布局是相对的,因此一个元素的布局发生变化,会联动地引发其他元素的布局发生变化,比如<body>
元素的宽度的变化会影响其子元素的宽度,其子元素宽度的变化也会继续对其孙子元素产生影响,因此对于浏览器来说,布局过程是经常发生的
- 上一步确定了每个
Paint
(绘制)- 本质上就是填充像素的过程,包括绘制文字、颜色、图像、边框和阴影等,也就是一个
DOM
元素所有的可视效果,一般来说,这个绘制过程是在多个图层上完成的
- 本质上就是填充像素的过程,包括绘制文字、颜色、图像、边框和阴影等,也就是一个
Compositing
(组合)- 由上一步可知,对页面中
DOM
元素的绘制是在多个层上进行的,在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上,对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常
- 由上一步可知,对页面中
上述过程的每一步中都有产生掉帧的问题,因此一定要弄清楚我们的代码将会运行在哪一步,有时我们可能会听到栅格化(rasterize
)与绘制一起使用,这是因为绘制这个动作实际是包含两步
- 产生系列的格子
- 往格子中填充像素
这个过程后来被称之为栅格化(rasterization
),所以我们在 DevTools
中看到的绘制记录,应该要知道其中已经包含了栅格化这一过程,但是我们不需要了解所有帧画面渲染流程上的所有流程,实际上,当我们修改视图的时候,有三种方式会重新生成一个帧画面,无论是修改 JavaScript
,CSS
还是 Web Animations
,这里我们重点关注这三种方式
JavaScript/CSS > Style > layout > Paint > Composite
如果我们修改布局属性(元素的几何形状),比如宽度,高度以及位置,那么浏览器会检查哪些元素需要重新布局,然后对页面激发一个 reflow 过程完成重新布局,被 reflow
的元素,接下来也会激发绘制过程,最后激发渲染层合并过程,生成最后的画面
JavaScript/CSS > Style > Paint > Composite
如果我们只是修改了绘制属性,比如说背景图片,字体颜色,阴影等,这些属性不属于页面布局,因此浏览器会在完成样式计算之后,跳过布局过程,只做绘制和渲染层合并过程
JavaScript/CSS > Style > Composite
如果我们修改的属性既不属于布局,也不属于绘制,那么浏览器会在完成样式计算之后,跳过布局和绘制的过程,直接做渲染层合并,第三种方式在性能上是最理想的,对于动画和滚动这种负荷很重的渲染,我们要争取使用第三种渲染流程
如果想知道哪些
CSS
属性会触发这三种方式,可以参考 CSS Triggers,而对于高性能动画方面则可以参考 使用渲染层合并属性
补充说明
我们下面来看几个在上面过程当中延伸出来的问题
1. CSSOM
CSSOM
是 CSS Object Model
的缩写,大体上来说,CSSOM
是一个建立在 Web
页面上的 CSS
样式的映射,它和 DOM
类似,但是只针对 CSS
而不是 HTML
,浏览器将 DOM
和 CSSOM
结合来渲染 Web
页面
CSSOM 是做什么的
CSSOM
将样式表中的规则映射到页面对应的元素上,虽然 CSSOM
采取了复杂的措施来做这件事,但是 CSSOM
最终的功能还是将样式映射到它们应该对应的元素上去,更确切地说,CSSOM
识别 tokens
并把这些 Tokens
转换成一个树结构上的对应的结点,所有结点以及它们所关联的页面中的样式就是所谓的 CSS Object Model
,从上面的渲染机制可以看出,CSSOM
对于 Web
页面的展示起着重要作用
使用 CSSOM
其实我们不必为了优化 Web
页面而去了解 CSSOM
是怎样工作的,这里有几个关于 CSSOM
的关键点是我们需要知道的,利用这些关键点可以优化页面的加载速度
CSSOM
阻止任何东西渲染CSSOM
在加载一个新页面时必须重新构建- 页面中
CSS
的加载和页面中JavaScript
的加载是有关系的
下面我们就分别来看看以上三种情况
CSSOM
阻止任何东西渲染
所有的 CSS
都是阻塞渲染的(意味着在 CSS
没处理好之前所有东西都不会展示),具体的原因是如果浏览器在 CSS
检查之前展示了页面,那么每个页面都是没有样式的,等一会之后又突然有了样式,整个页面的体验就会很糟糕
由于 CSSOM
被用作创建 Render Tree
,那么如果不能高效的利用 CSS
会有一些严重的后果,而主要的后果就是我们的页面在加载时白屏
CSSOM
在加载一个新页面时必须重新构建
这意味着即使我们的 CSS
文件被缓存了,也并不意味着这个已经构建好了的 CSSOM
可以应用到每一个页面,当用户跳到我们的另一个页面时(即使浏览器缓存了所有需要的 CSS
),CSSOM
也必须重新构建一遍,也就是说,如果我们的 CSS
文件写得很蹩脚,或者体积很大,这也会对我们页面加载产生负面的影响
- 页面中
CSS
的加载和页面中JavaScript
的加载是有关系的
JavaScript
的加载可能会阻塞 CSSOM
的构建,简单来说,CSSOM
是展示任何东西的必需品,在 CSSOM
构建之前,所有东西都不会展示
- 如果我们阻塞了
CSSOM
的构建,CSSOM
的构建就会消耗更长的时间,这就意味着页面的渲染也需要更长的时间 - 如果我们的
JavaScript
阻塞了CSSOM
的构建,我们的用户就会面对更长时间的白屏
2. 为什么 JavaScript 是单线程的
首先是历史原因,在创建 JavaScript
这门语言时,多进程多线程的架构并不流行,硬件支持并不好,其次是因为多线程的复杂性,多线程操作需要加锁,编码的复杂性会增高
如果 JavaScript
是多线程的方式来操作这些 UI DOM
,则可能出现 UI
操作的冲突,在多线程的交互下,处于 UI
中的 DOM
节点就可能成为一个临界资源,假设存在两个线程同时操作一个 DOM
,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果
当然我们可以通过锁来解决上面的问题,但为了避免因为引入了锁而带来更大的复杂性,JavaScript
在最初就选择了单线程执行
3. 为什么 GUI 渲染线程为什么与 JavaScript 引擎线程互斥
由于 JavaScript
是可操纵 DOM
的,如果在修改这些元素属性同时渲染界面(即 JavaScript
线程和 UI
线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了,因此为了防止渲染出现不可预期的结果,浏览器『设置 GUI
渲染线程与 JavaScript
引擎为互斥的关系』
当 JavaScript
引擎执行时 GUI
线程会被挂起,GUI
更新会被保存在一个队列中等到引擎线程空闲时立即被执行,浏览器在执行 JavaScript
程序的时候,GUI
渲染线程会被保存在一个队列中,直到 JavaScript
程序执行完成,才会接着执行
因此如果 JavaScript
执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉
4. CSS 加载会造成阻塞吗
在上面的浏览器渲染流程当中我们可以看出,DOM
和 CSSOM
通常是并行构建的,所以 CSS
加载不会阻塞 DOM
的解析,然而由于 Render Tree
是依赖于 DOM Tree
和 CSSOM Tree
的,所以它必须等待到 CSSOM Tree
构建完成,也就是 CSS
资源加载完成(或者 CSS
资源加载失败)后,才能开始渲染,因此 CSS
加载会阻塞 DOM
的渲染
由于 JavaScript
是可以操纵 DOM
和 CSS
样式的,如果在修改这些元素属性同时渲染界面(即 JavaScript
线程和 UI
线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了,因此为了防止渲染出现不可预期的结果,浏览器设置 GUI
渲染线程与 JavaScript
引擎为互斥的关系
这样一来,样式表会在后面的 JavaScript
执行前先加载执行完毕,所以 CSS
会阻塞后面 JavaScript
的执行
5. DOMContentLoaded 与 onload 的区别
当 DOMContentLoaded
事件触发时,仅当 DOM
解析完成后,不包括样式表和图片,我们在上面提到过,CSS
加载会阻塞 DOM
的渲染和后面 JavaScript
的执行,也就是说 JavaScript
会阻塞 DOM
解析
所以我们可以得到结论,当文档中没有脚本时,浏览器解析完文档便能触发 DOMContentLoaded
事件,如果文档中包含脚本,则脚本会阻塞文档的解析,而脚本需要等 CSSOM
构建完成才能执行,在任何情况下,DOMContentLoaded
的触发不需要等待图片等其他资源加载完成
而当 onload
事件触发时,页面上所有的 DOM
,样式表,脚本,图片等资源已经加载完毕,所以流程应该是由 DOMContentLoaded
到 onload
6. 如何优化 CRP
CRP
,即关键渲染路径(Critical Rendering Path
),它是浏览器将 HTML/CSS/JavaScript
转换为在屏幕上呈现的像素内容所经历的一系列步骤,也就是我们上面说的浏览器渲染流程,为了尽快完成首次渲染,我们需要最大限度减小以下三种可变因素
- 关键资源的数量,可能阻止网页首次渲染的资源
- 关键路径长度,获取所有关键资源所需的往返次数或总时间
- 关键字节,实现网页首次渲染所需的总字节数,等同于所有关键资源传送文件大小的总和
优化 DOM
- 删除不必要的代码和注释包括空格,尽量做到最小化文件
- 可以利用
GZIP
压缩文件 - 结合
HTTP
缓存文件
优化 CSSOM
缩小、压缩以及缓存同样重要,对于 CSSOM
我们前面重点提过了它会阻止页面呈现,因此我们可以从这方面考虑去优化,步骤如下
- 减少关键
CSS
元素数量 - 当我们声明样式表时,请密切关注媒体查询的类型,它们极大地影响了
CRP
的性能
优化 JavaScript
当浏览器遇到 <script>
标记时,会阻止解析器继续操作,直到 CSSOM
构建完毕,JavaScript
才会运行并继续完成 DOM
构建过程,所以我们可以考虑以下方式
Async
,当我们在<script>
标记中添加Async
属性以后,浏览器遇到这个标记时会继续解析DOM
,同时脚本也不会被CSSOM
阻止,即不会阻止CRP
Defer
,与Async
的区别在于,脚本需要等到文档解析后(DOMContentLoaded
事件前)执行,而Async
允许脚本在文档解析时位于后台运行(两者下载的过程不会阻塞DOM
,但执行会)- 当我们的脚本不会修改
DOM
或CSSOM
时推荐使用Async
- 预加载(
preload && prefetch
) - DNS 预解析(
dns-prefetch
)
总结
- 分析并用 关键资源数 关键字节数 关键路径长度 来描述我们的
CRP
- 最小化关键资源数,消除它们(内联)、推迟它们的下载(
Defer
)或者使它们异步解析(Async
)等等 - 优化关键字节数(缩小、压缩)来减少下载时间
- 优化加载剩余关键资源的顺序,让关键资源(
CSS
)尽早下载以减少CRP
长度
更多相关内容可以参考 前端性能优化之关键路径渲染优化
7. Async 和 Defer 的区别
我们先来对比下 Async
和 Defer
属性的区别,如下图所示
其中蓝色线代表 JavaScript
加载,红色线代表 JavaScript
执行,绿色线代表 HTML
解析,所以我们也对应的分别来看看三种情况
1 | <script src="example.js"></script> |
- 没有
Async
和Defer
,浏览器会立即加载并执行指定的脚本,也就是说不等待后续载入的文档元素,读到就加载并执行
1 | <script async src="example.js"></script> |
Async
属性表示异步执行引入的JavaScript
,与Defer
的区别在于,如果已经加载好,就会开始执行,即无论此刻是HTML
解析阶段还是DOMContentLoaded
触发之后,不过需要注意的是,这种方式加载的JavaScript
依然会阻塞load
事件,换句话说,async-script
可能在DOMContentLoaded
触发之前或之后执行,但一定在load
触发之前执行
1 | <script defer src="example.js"></script> |
Defer
属性表示延迟执行引入的JavaScript
,即这段JavaScript
加载时HTML
并未停止解析,这两个过程是并行的,整个document
解析完毕且defer-script
也加载完成之后(这两件事情的顺序无关),会执行所有由defer-script
加载的JavaScript
代码,然后触发DOMContentLoaded
事件
因此,我们可以得出结论
Defer
和Async
在网络读取(下载)这部分是一样的,都是异步的(相较于HTML
解析)- 在加载多个
JavaScript
脚本的时候,Async
是无顺序的加载,而Defer
是有顺序的加载 - 差别在于脚本下载完之后何时执行,显然
Defer
是最接近我们对于应用脚本加载和执行的要求的 Async
是乱序执行,它的加载和执行是紧紧挨着的,所以不管声明的顺序如何,只要它加载完了就会立刻执行- 通常来说,
Async
对于应用脚本的用处不大,因为它完全不考虑依赖(哪怕是最低级的顺序执行),不过它对于那些可以不依赖任何脚本或不被任何脚本依赖的脚本来说却是非常合适的
8. 如何解析 CSS 选择器
浏览器会『从右往左』解析 CSS
选择器,我们知道 DOM Tree
与 Style Rules
合成为 Render Tree
,实际上是需要将 Style Rules
附着到 DOM Tree
上,因此需要根据选择器提供的信息对 DOM Tree
进行遍历,才能将样式附着到对应的 DOM
元素上,我们以下面这段 CSS
为例
1 | .mod-nav h3 span {font-size: 16px;} |
对应的 DOM Tree
如下
若从左向右的匹配,过程是
- 从
.mod-nav
开始,遍历子节点header
和子节点div
- 然后各自向子节点遍历,在右侧
div
的分支中,最后遍历到叶子节点a
,发现不符合规则 - 所以需要回溯到
ul
节点,再遍历下一个li-a
,一颗DOM
树的节点动不动上千,所以可以发现这种效率很低
如果从右至左的匹配
- 先找到所有的最右节点
span
,对于每一个span
,向上寻找节点h3
- 由
h3
再向上寻找.mod-nav
的节点 - 最后找到根元素
html
则结束这个分支的遍历
两者对比下来,可以明显的发现后者匹配性能更好,是因为从右向左的匹配在第一步就筛选掉了大量的不符合条件的最右节点(叶子节点),而从左向右的匹配规则的性能都浪费在了失败的查找上面
9. 回流与重绘
渲染的流程基本上是这样(如下图黄色的四个步骤)
- 计算
CSS
样式 - 构建
Render Tree
Layout
定位坐标和大小- 正式开画
这里需要注意上图流程中有很多连接线,这表示了 JavaScript
动态修改了 DOM
属性或是 CSS
属性会导致重新 Layout
,但有些改变不会重新 Layout
,就是上图中那些指到天上的箭头,比如修改后的 CSS rule
没有被匹配到元素,这里重要要说两个概念,一个是 Reflow
,另一个是 Repaint
- 重绘,当我们对
DOM
的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节) - 回流,当我们对
DOM
的修改引发了DOM
几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来,这个过程就是回流(也叫重排)
我们知道,当网页生成的时候,至少会渲染一次,在用户访问的过程中,还会不断重新渲染,重新渲染会重复 回流 + 重绘 或者只有重绘,回流必定会发生重绘,重绘不一定会引发回流,重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能,回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流
常见引起回流属性和方法
任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发回流,比如下面这些方式
1 | clientWidth、clientHeight、clientTop、clientLeftoffsetWidth |
具体表现为
- 添加或者删除可见的
DOM
元素 - 元素尺寸改变(边距、填充、边框、宽度和高度)
- 内容变化,比如用户在
input
框中输入文字 - 浏览器窗口尺寸改变(
resize
事件发生时) - 计算
offsetWidth
和offsetHeight
属性 - 设置
style
属性的值
常见引起重绘属性和方法
如何减少回流、重绘
- 使用
transform
替代top
- 使用
visibility
替换display: none
,因为前者只会引起重绘,后者会引发回流(改变了布局) - 不要把节点的属性值放在一个循环里当成循环里的变量,就下面这样
1 | for (let i = 0; i < 1000; i++) { |
- 不要使用
table
布局,可能很小的一个小改动会造成整个table
的重新布局 - 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用
requestAnimationFrame
CSS
选择符从右往左匹配查找,避免节点层级过多- 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点,比如对于
video
标签来说,浏览器会自动将该节点变为图层 - 集中改变样式,即通过改变
class
的方式来集中改变样式
1 | // 判断是否是黑色系样式 |
- 使用
DocumentFragment
,我们可以通过createDocumentFragment
创建一个游离于DOM
树之外的节点,然后在此节点上批量操作,最后插入DOM
树中,因此只触发一次重排
1 | var fragment = document.createDocumentFragment() |
- 提升为合成层(关于合成层我们会在下面进行介绍),将元素提升为合成层有以下优点
- 合成层的位图,会交由
GPU
合成,比CPU
处理要快 - 当需要
repaint
时,只需要repaint
本身,不会影响到其他的层 - 对于
transform
和opacity
效果,不会触发layout
和paint
- 通常来说,提升合成层的最好方式是使用
CSS
的will-change
属性 - 更多关于合成层的相关内容可以参考 无线性能优化:Composite
- 合成层的位图,会交由
1 | #target { |
10. 性能优化策略
基于上面介绍的浏览器渲染原理,DOM
和 CSSOM
结构构建顺序,初始化可以对页面渲染做些优化,提升页面性能
JavaScript
优化,<script>
标签加上Defer
属性和Async
属性用于在不阻塞页面文档解析的前提下,控制脚本的下载和执行Defer
属性,用于开启新的线程下载脚本文件,并使脚本在文档解析完成后执行Async
属性,HTML5
新增属性,用于异步下载脚本文件,下载完毕立即解释执行代码
CSS
优化,<link>
标签的rel
属性中的属性值设置为preload
能够让我们在HTML
页面中可以指明哪些资源是在页面加载完成后即刻需要的,最优的配置加载顺序,提高渲染性能
11. 浏览器解析 JavaScript 的过程
早期,浏览器内部对 JavaScript
的处理过程大致如下
- 读取代码,进行词法分析(
Lexical Analysis
),将代码分解成词元(token
) - 对词元进行语法分析(
Parsing
),将代码整理成语法树(Syntax Tree
) - 使用翻译器(
Translator
),将代码转为字节码(Bytecode
) - 使用字节码解释器(
Bytecode Interpreter
),将字节码转为机器码
逐行解释将字节码转为机器码,是很低效的,为了提高运行速度,现代浏览器改为采用即时编译(Just In Time compiler
,JIT
),即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(Inline Cache
),通常来说,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升
关于这部分更为详细的内容可以参考我们之前整理过的 V8 引擎机制 这篇文章了解更多
12. 什么是渲染层合并(Composite)
渲染层合并,对于页面中 DOM
元素的绘制(Paint
)是在多个层上进行的,在每个层上完成绘制过程之后,浏览器会将绘制的位图发送给 GPU
绘制到屏幕上,将所有层按照合理的顺序合并成一个图层,然后在屏幕上呈现,对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常
如上图
RenderLayers
渲染层,这是负责对应DOM
子树GraphicsLayers
图形层,这是负责对应RenderLayers
子树RenderObjects
保持了树结构,一个RenderObjects
知道如何绘制一个Node
的内容,通过向一个绘图上下文(GraphicsContext
)发出必要的绘制调用来绘制Nodes
每个 GraphicsLayer
都有一个 GraphicsContext
,GraphicsContext
会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU
中,最后由 GPU
将多个位图进行合成,然后画到屏幕上,此时我们的页面也就展现到了屏幕上
GraphicsContext
绘图上下文的责任就是向屏幕进行像素绘制(这个过程是先把像素级的数据写入位图中,然后再显示到显示器),在 Chrome
里,绘图上下文是包裹了的 Skia
(Chrome
自己的 2D
图形绘制库)
某些特殊的渲染层会被认为是合成层(Compositing Layers
),合成层拥有单独的 GraphicsLayer
,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer
父层公用一个
合成层的优点
一旦 renderLayer
提升为了合成层就会有自己的绘图上下文,并且会开启硬件加速,有利于性能提升
- 合成层的位图,会交由
GPU
合成,比CPU
处理要快,也就是说,提升到合成层后合成层的位图会交GPU
处理,但请注意,仅仅只是合成的处理(把绘图上下文的位图输出进行组合)需要用到GPU
,生成合成层的位图处理(绘图上下文的工作)是需要CPU
- 当需要
repaint
时,只需要repaint
本身,不会影响到其他的层,当需要repaint
的时候可以只repaint
本身,不影响其他层,但是paint
之前还有style
,layout
那就意味着即使合成层只是repaint
了自己,但style
和layout
本身就很占用时间 - 对于
transform
和opacity
效果,不会触发layout
和paint
,仅仅是transform
和opacity
不会引发layout
和paint
,其他的属性不确定
一般一个元素开启硬件加速后会变成合成层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能
注意不能滥用 GPU
加速,一定要分析其实际性能表现,因为 GPU
加速创建渲染层是有代价的,每创建一个新的渲染层,就意味着新的内存分配和更复杂的层的管理,并且在移动端 GPU
和 CPU
的带宽有限制,创建的渲染层过多时,合成也会消耗跟多的时间,随之而来的就是耗电更多,内存占用更多,过多的渲染层来带的开销而对页面渲染性能产生的影响,甚至远远超过了它在性能改善上带来的好处
更多详细内容可以参考下面几个链接
总结
- 浏览器渲染工作的主流程分为下面五个步骤(如下图所示)
- 解析
HTML
文件,构建DOM
树,同时浏览器主进程负责下载CSS
文件 CSS
文件下载完成,解析CSS
文件成树形的数据结构,然后结合DOM
树合并成RenderObject
树- 布局
RenderObject
树(Layout/reflow
),负责RenderObject
树中的元素的尺寸,位置等计算 - 绘制
RenderObject
树(paint
),绘制页面的像素信息 - 浏览器主进程将默认的图层和复合图层交给
GPU
进程,GPU
进程再将各个图层合成(composite
),最后显示出页面
- 解析
CSSOM
会阻塞渲染,只有当CSSOM
构建完毕后才会进入下一个阶段构建渲染树- 通常情况下
DOM
和CSSOM
是并行构建的,但是当浏览器遇到一个不带defer
或async
属性的<script>
标签时,DOM
构建将暂停,如果此时又恰巧浏览器尚未完成CSSOM
的下载和构建,由于JavaScript
可以修改CSSOM
,所以需要等CSSOM
构建完毕后再执行JavaScript
,最后才重新DOM
构建