浏览器的渲染机制

浏览器的渲染机制

在展开渲染机制相关内容之前,我们先来简单的了解一下常见的浏览器内核有哪些,浏览器的内核是指支持浏览器运行的最核心的程序,分为两个部分的,一是渲染引擎,另一个是 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,它就负责解析 HTMLCSS 内容,并将解析后的内容显示在屏幕上
  • 网络,用于网络调用,比如 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 渲染线程
    • 负责渲染浏览器界面,解析 HTMLCSS,构建 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 引擎空闲时才会去执行)
  • 定时触发器线程
    • 传说中的 setIntervalsetTimeout 所在线程
    • 浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JavaScript 引擎空闲后执行)
    • 注意,W3CHTML 标准中规定,规定要求 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 后),并行发起 CSSImagesJavaScript 的请求,无论他们是否在 HEAD

不过需要注意的是,发起 JavaScript 文件的下载 request,并不需要 DOM 处理到那个 script 节点

2. 解析 CSS 标签,构建 CSSOM 树

这个阶段对应着 Create/Update Render CSSOM 的过程,即 CSS 文件下载完成,开始构建 CSSOM,其中的 CSSOMCSS Object Model,是浏览器将 CSS 代码解析成树形的数据结构,这个我们会在下方来进行介绍

其中 DOMCSSOM 都是以下图当中所示的流程,也就是依次经过 Bytes(字节数据),Characters(字符串),TokensNodes 最后到 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 TreeDOM 一样,以多叉树的形式保存了每个节点的 CSS 属性、节点本身属性、以及节点的孩子节点

这里有一个比较特殊的情况,就是 display: none 的节点不会被加入 Render Tree,而 visibility: hidden 则会,所以如果某个节点最开始是不显示的,设为 display: none 是更优的

以上五个步骤前三个步骤之所有使用 Create/Update 是因为 DOMCSSOMRender Tree 都可能在第一次 Painting 后又被更新多次,比如 JavaScript 修改了 DOM 或者 CSS 属性,LayoutPainting 也会被重复执行,除了 DOMCSSOM 更新的原因外,图片下载完成后也需要调用 LayoutPainting 来更新网页

渲染过程拆解

我们在上面小节当中提到的渲染过程的五个步骤看起来可能比较抽象,下面我们就来将它们拆解成我们经常遇到的一些操作,然后简单的归纳一下各个步骤

通常来说我们在编写 Web 页面时,我们需要理解我们所写的页面代码是如何被转换成屏幕上显示的像素的,这个转换过程我们可以归纳为这样的一个流水线,包含五个关键步骤

  • JavaScript
    • 一般来说,我们会使用 JavaScript 来实现一些视觉变化的效果,比如用 jQueryanimate 函数做一个动画、对一个数据集进行排序、或者往页面里添加一些 DOM 元素等,当然除了JavaScript 还有其他一些常用方法也可以实现视觉变化效果,比如 CSS AnimationsTransitionsWeb Animation API
  • Style calculations(计算样式)
    • 这个过程是根据 CSS 选择器,比如 .headline.nav > .nav_item,对每个 DOM 元素匹配对应的 CSS 样式,这一步结束之后,就确定了每个 DOM 元素上该应用什么 CSS 样式规则
  • Layout(布局)
    • 上一步确定了每个 DOM 元素的样式规则,这一步就是具体计算每个 DOM 元素最终在屏幕上显示的大小和位置,Web 页面中元素的布局是相对的,因此一个元素的布局发生变化,会联动地引发其他元素的布局发生变化,比如 <body> 元素的宽度的变化会影响其子元素的宽度,其子元素宽度的变化也会继续对其孙子元素产生影响,因此对于浏览器来说,布局过程是经常发生的
  • Paint(绘制)
    • 本质上就是填充像素的过程,包括绘制文字、颜色、图像、边框和阴影等,也就是一个 DOM 元素所有的可视效果,一般来说,这个绘制过程是在多个图层上完成的
  • Compositing(组合)
    • 由上一步可知,对页面中 DOM 元素的绘制是在多个层上进行的,在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上,对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常

上述过程的每一步中都有产生掉帧的问题,因此一定要弄清楚我们的代码将会运行在哪一步,有时我们可能会听到栅格化(rasterize)与绘制一起使用,这是因为绘制这个动作实际是包含两步

  1. 产生系列的格子
  2. 往格子中填充像素

这个过程后来被称之为栅格化(rasterization),所以我们在 DevTools 中看到的绘制记录,应该要知道其中已经包含了栅格化这一过程,但是我们不需要了解所有帧画面渲染流程上的所有流程,实际上,当我们修改视图的时候,有三种方式会重新生成一个帧画面,无论是修改 JavaScriptCSS 还是 Web Animations,这里我们重点关注这三种方式

JavaScript/CSS > Style > layout > Paint > Composite

如果我们修改布局属性(元素的几何形状),比如宽度,高度以及位置,那么浏览器会检查哪些元素需要重新布局,然后对页面激发一个 reflow 过程完成重新布局,被 reflow 的元素,接下来也会激发绘制过程,最后激发渲染层合并过程,生成最后的画面

JavaScript/CSS > Style > Paint > Composite

如果我们只是修改了绘制属性,比如说背景图片,字体颜色,阴影等,这些属性不属于页面布局,因此浏览器会在完成样式计算之后,跳过布局过程,只做绘制和渲染层合并过程

JavaScript/CSS > Style > Composite

如果我们修改的属性既不属于布局,也不属于绘制,那么浏览器会在完成样式计算之后,跳过布局和绘制的过程,直接做渲染层合并,第三种方式在性能上是最理想的,对于动画和滚动这种负荷很重的渲染,我们要争取使用第三种渲染流程

如果想知道哪些 CSS 属性会触发这三种方式,可以参考 CSS Triggers,而对于高性能动画方面则可以参考 使用渲染层合并属性

补充说明

我们下面来看几个在上面过程当中延伸出来的问题

1. CSSOM

CSSOMCSS Object Model 的缩写,大体上来说,CSSOM 是一个建立在 Web 页面上的 CSS 样式的映射,它和 DOM 类似,但是只针对 CSS 而不是 HTML,浏览器将 DOMCSSOM 结合来渲染 Web 页面

CSSOM 是做什么的

CSSOM 将样式表中的规则映射到页面对应的元素上,虽然 CSSOM 采取了复杂的措施来做这件事,但是 CSSOM 最终的功能还是将样式映射到它们应该对应的元素上去,更确切地说,CSSOM 识别 tokens 并把这些 Tokens 转换成一个树结构上的对应的结点,所有结点以及它们所关联的页面中的样式就是所谓的 CSS Object Model,从上面的渲染机制可以看出,CSSOM 对于 Web 页面的展示起着重要作用

使用 CSSOM

其实我们不必为了优化 Web 页面而去了解 CSSOM 是怎样工作的,这里有几个关于 CSSOM 的关键点是我们需要知道的,利用这些关键点可以优化页面的加载速度

  • CSSOM 阻止任何东西渲染
  • CSSOM 在加载一个新页面时必须重新构建
  • 页面中 CSS 的加载和页面中 JavaScript 的加载是有关系的

下面我们就分别来看看以上三种情况

  1. CSSOM 阻止任何东西渲染

所有的 CSS 都是阻塞渲染的(意味着在 CSS 没处理好之前所有东西都不会展示),具体的原因是如果浏览器在 CSS 检查之前展示了页面,那么每个页面都是没有样式的,等一会之后又突然有了样式,整个页面的体验就会很糟糕

由于 CSSOM 被用作创建 Render Tree,那么如果不能高效的利用 CSS 会有一些严重的后果,而主要的后果就是我们的页面在加载时白屏

  1. CSSOM 在加载一个新页面时必须重新构建

这意味着即使我们的 CSS 文件被缓存了,也并不意味着这个已经构建好了的 CSSOM 可以应用到每一个页面,当用户跳到我们的另一个页面时(即使浏览器缓存了所有需要的 CSS),CSSOM 也必须重新构建一遍,也就是说,如果我们的 CSS 文件写得很蹩脚,或者体积很大,这也会对我们页面加载产生负面的影响

  1. 页面中 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 加载会造成阻塞吗

在上面的浏览器渲染流程当中我们可以看出,DOMCSSOM 通常是并行构建的,所以 CSS 加载不会阻塞 DOM 的解析,然而由于 Render Tree 是依赖于 DOM TreeCSSOM Tree 的,所以它必须等待到 CSSOM Tree 构建完成,也就是 CSS 资源加载完成(或者 CSS 资源加载失败)后,才能开始渲染,因此 CSS 加载会阻塞 DOM 的渲染

由于 JavaScript 是可以操纵 DOMCSS 样式的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了,因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JavaScript 引擎为互斥的关系

这样一来,样式表会在后面的 JavaScript 执行前先加载执行完毕,所以 CSS 会阻塞后面 JavaScript 的执行

5. DOMContentLoaded 与 onload 的区别

DOMContentLoaded 事件触发时,仅当 DOM 解析完成后,不包括样式表和图片,我们在上面提到过,CSS 加载会阻塞 DOM 的渲染和后面 JavaScript 的执行,也就是说 JavaScript 会阻塞 DOM 解析

所以我们可以得到结论,当文档中没有脚本时,浏览器解析完文档便能触发 DOMContentLoaded 事件,如果文档中包含脚本,则脚本会阻塞文档的解析,而脚本需要等 CSSOM 构建完成才能执行,在任何情况下,DOMContentLoaded 的触发不需要等待图片等其他资源加载完成

而当 onload 事件触发时,页面上所有的 DOM,样式表,脚本,图片等资源已经加载完毕,所以流程应该是由 DOMContentLoadedonload

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,但执行会)
  • 当我们的脚本不会修改 DOMCSSOM 时推荐使用 Async
  • 预加载(preload && prefetch
  • DNS 预解析(dns-prefetch
总结
  • 分析并用 关键资源数 关键字节数 关键路径长度 来描述我们的 CRP
  • 最小化关键资源数,消除它们(内联)、推迟它们的下载(Defer)或者使它们异步解析(Async)等等
  • 优化关键字节数(缩小、压缩)来减少下载时间
  • 优化加载剩余关键资源的顺序,让关键资源(CSS)尽早下载以减少 CRP 长度

更多相关内容可以参考 前端性能优化之关键路径渲染优化

7. Async 和 Defer 的区别

我们先来对比下 AsyncDefer 属性的区别,如下图所示

其中蓝色线代表 JavaScript 加载,红色线代表 JavaScript 执行,绿色线代表 HTML 解析,所以我们也对应的分别来看看三种情况

1
<script src="example.js"></script>
  1. 没有 AsyncDefer,浏览器会立即加载并执行指定的脚本,也就是说不等待后续载入的文档元素,读到就加载并执行
1
<script async src="example.js"></script>
  1. Async 属性表示异步执行引入的 JavaScript,与 Defer 的区别在于,如果已经加载好,就会开始执行,即无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后,不过需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件,换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行
1
<script defer src="example.js"></script>
  1. Defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的,整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件

因此,我们可以得出结论

  • DeferAsync 在网络读取(下载)这部分是一样的,都是异步的(相较于 HTML 解析)
  • 在加载多个 JavaScript 脚本的时候,Async 是无顺序的加载,而 Defer 是有顺序的加载
  • 差别在于脚本下载完之后何时执行,显然 Defer 是最接近我们对于应用脚本加载和执行的要求的
  • Async 是乱序执行,它的加载和执行是紧紧挨着的,所以不管声明的顺序如何,只要它加载完了就会立刻执行
  • 通常来说,Async 对于应用脚本的用处不大,因为它完全不考虑依赖(哪怕是最低级的顺序执行),不过它对于那些可以不依赖任何脚本或不被任何脚本依赖的脚本来说却是非常合适的

8. 如何解析 CSS 选择器

浏览器会『从右往左』解析 CSS 选择器,我们知道 DOM TreeStyle 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. 回流与重绘

渲染的流程基本上是这样(如下图黄色的四个步骤)

  1. 计算 CSS 样式
  2. 构建 Render Tree
  3. Layout 定位坐标和大小
  4. 正式开画

这里需要注意上图流程中有很多连接线,这表示了 JavaScript 动态修改了 DOM 属性或是 CSS 属性会导致重新 Layout,但有些改变不会重新 Layout,就是上图中那些指到天上的箭头,比如修改后的 CSS rule 没有被匹配到元素,这里重要要说两个概念,一个是 Reflow,另一个是 Repaint

  • 重绘,当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)
  • 回流,当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来,这个过程就是回流(也叫重排)

我们知道,当网页生成的时候,至少会渲染一次,在用户访问的过程中,还会不断重新渲染,重新渲染会重复 回流 + 重绘 或者只有重绘,回流必定会发生重绘,重绘不一定会引发回流,重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能,回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流

常见引起回流属性和方法

任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发回流,比如下面这些方式

1
2
3
4
5
6
7
clientWidth、clientHeight、clientTop、clientLeftoffsetWidth

offsetHeight、offsetTop、offsetLeftscrollWidth

scrollHeight、scrollTop、scrollLeftscrollIntoView()、scrollIntoViewIfNeeded()

getComputedStyle()、getBoundingClientRect()、scrollTo()

具体表现为

  • 添加或者删除可见的 DOM 元素
  • 元素尺寸改变(边距、填充、边框、宽度和高度)
  • 内容变化,比如用户在 input 框中输入文字
  • 浏览器窗口尺寸改变(resize 事件发生时)
  • 计算 offsetWidthoffsetHeight 属性
  • 设置 style 属性的值
常见引起重绘属性和方法

如何减少回流、重绘
  • 使用 transform 替代 top
  • 使用 visibility 替换 display: none,因为前者只会引起重绘,后者会引发回流(改变了布局)
  • 不要把节点的属性值放在一个循环里当成循环里的变量,就下面这样
1
2
3
4
for (let i = 0; i < 1000; i++) {
// 获取 offsetTop 会导致回流,因为需要去获取正确的值
console.log(document.querySelector('.test').style.offsetTop)
}
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • CSS 选择符从右往左匹配查找,避免节点层级过多
  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点,比如对于 video 标签来说,浏览器会自动将该节点变为图层
  • 集中改变样式,即通过改变 class 的方式来集中改变样式
1
2
3
4
5
// 判断是否是黑色系样式
const theme = isDark ? 'dark' : 'light'

// 根据判断来设置不同的class
ele.setAttribute('className', theme)
  • 使用 DocumentFragment,我们可以通过 createDocumentFragment 创建一个游离于 DOM 树之外的节点,然后在此节点上批量操作,最后插入 DOM 树中,因此只触发一次重排
1
2
3
4
5
6
7
8
9
var fragment = document.createDocumentFragment()

for (let i = 0; i < 10; i++) {
let node = document.createElement('p')
node.innerHTML = i
fragment.appendChild(node)
}

document.body.appendChild(fragment)
  • 提升为合成层(关于合成层我们会在下面进行介绍),将元素提升为合成层有以下优点
    • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
    • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
    • 对于 transformopacity 效果,不会触发 layoutpaint
    • 通常来说,提升合成层的最好方式是使用 CSSwill-change 属性
    • 更多关于合成层的相关内容可以参考 无线性能优化:Composite
1
2
3
#target {
will-change: transform;
}

10. 性能优化策略

基于上面介绍的浏览器渲染原理,DOMCSSOM 结构构建顺序,初始化可以对页面渲染做些优化,提升页面性能

  • 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 compilerJIT),即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(Inline Cache),通常来说,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升

关于这部分更为详细的内容可以参考我们之前整理过的 V8 引擎机制 这篇文章了解更多

12. 什么是渲染层合并(Composite)

渲染层合并,对于页面中 DOM 元素的绘制(Paint)是在多个层上进行的,在每个层上完成绘制过程之后,浏览器会将绘制的位图发送给 GPU 绘制到屏幕上,将所有层按照合理的顺序合并成一个图层,然后在屏幕上呈现,对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常

如上图

  • RenderLayers 渲染层,这是负责对应 DOM 子树
  • GraphicsLayers 图形层,这是负责对应 RenderLayers 子树
  • RenderObjects 保持了树结构,一个 RenderObjects 知道如何绘制一个 Node 的内容,通过向一个绘图上下文(GraphicsContext)发出必要的绘制调用来绘制 Nodes

每个 GraphicsLayer 都有一个 GraphicsContextGraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后画到屏幕上,此时我们的页面也就展现到了屏幕上

GraphicsContext 绘图上下文的责任就是向屏幕进行像素绘制(这个过程是先把像素级的数据写入位图中,然后再显示到显示器),在 Chrome 里,绘图上下文是包裹了的 SkiaChrome 自己的 2D 图形绘制库)

某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个

合成层的优点

一旦 renderLayer 提升为了合成层就会有自己的绘图上下文,并且会开启硬件加速,有利于性能提升

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快,也就是说,提升到合成层后合成层的位图会交 GPU 处理,但请注意,仅仅只是合成的处理(把绘图上下文的位图输出进行组合)需要用到 GPU,生成合成层的位图处理(绘图上下文的工作)是需要 CPU
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层,当需要 repaint 的时候可以只 repaint 本身,不影响其他层,但是 paint 之前还有 stylelayout 那就意味着即使合成层只是 repaint 了自己,但 stylelayout 本身就很占用时间
  • 对于 transformopacity 效果,不会触发 layoutpaint,仅仅是 transformopacity 不会引发 layoutpaint,其他的属性不确定

一般一个元素开启硬件加速后会变成合成层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能

注意不能滥用 GPU 加速,一定要分析其实际性能表现,因为 GPU 加速创建渲染层是有代价的,每创建一个新的渲染层,就意味着新的内存分配和更复杂的层的管理,并且在移动端 GPUCPU 的带宽有限制,创建的渲染层过多时,合成也会消耗跟多的时间,随之而来的就是耗电更多,内存占用更多,过多的渲染层来带的开销而对页面渲染性能产生的影响,甚至远远超过了它在性能改善上带来的好处

更多详细内容可以参考下面几个链接

总结

  • 浏览器渲染工作的主流程分为下面五个步骤(如下图所示)
    1. 解析 HTML 文件,构建 DOM 树,同时浏览器主进程负责下载 CSS 文件
    2. CSS 文件下载完成,解析 CSS 文件成树形的数据结构,然后结合 DOM 树合并成 RenderObject
    3. 布局 RenderObject 树(Layout/reflow),负责 RenderObject 树中的元素的尺寸,位置等计算
    4. 绘制 RenderObject 树(paint),绘制页面的像素信息
    5. 浏览器主进程将默认的图层和复合图层交给 GPU 进程,GPU 进程再将各个图层合成(composite),最后显示出页面

  • CSSOM 会阻塞渲染,只有当 CSSOM 构建完毕后才会进入下一个阶段构建渲染树
  • 通常情况下 DOMCSSOM 是并行构建的,但是当浏览器遇到一个不带 deferasync 属性的 <script> 标签时,DOM 构建将暂停,如果此时又恰巧浏览器尚未完成 CSSOM 的下载和构建,由于 JavaScript 可以修改 CSSOM,所以需要等 CSSOM 构建完毕后再执行 JavaScript,最后才重新 DOM 构建

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×