从浏览器的渲染机制谈起

前言

  大家应该都知道在前端有一道极其著名的题目:从输入 url 到页面加载完成的过程中发生了什么?不得不说这的确是一道覆盖面很广的题目,从计算机网络到浏览器缓存和渲染等内容几乎都包括在其内,更别提还可以引申出去再谈谈性能优化了,估计单单这一道题,从广度和深度挖下去就足以撑起一场面试了。不过本文只涉及到浏览器渲染方面的内容,从服务器返回响应开始讲起,而此前的内容等以后我理清楚了再分别写几篇博客谈谈。

浏览器的渲染过程

构建 DOM 树

  在计算机网络中传输的内容都是以字节的形式传输的,当浏览器接受到(或者从本地磁盘读取到)这些字节数据(也就是HTML文档)时,会先根据指定的编码方式(如 utf-8)把它们解码成字符串。然后通过词法分析把字符串识别成一个个词语(此处词法分析会识别并跳过空格和换行等无关的字符),再利用这些词语生成节点,而这些节点根据原本的文档结构便构建成了一颗 DOM 树。

  其中词法分析是按一定的语言规则将字符串转化成一个个浏览器可以识别的词语(即 token),每一个 token 会标识出当前的 token 是“开始标签”、“结束标签”还是“文本”等信息。对于词法分析的过程,我猜可能是利用到正则表达式 + 栈来实现的,类似于 Leetcode 上一道算法题,匹配括号判断字符串是否有效,也可能是我想的太简单了。(对于 HTML 字符串如何解析成词语和节点,本文就不深入探讨了,看客有兴趣可以参考《WebKit技术内幕》这本书的 5.2 章。难道我会告诉你其实我也不是很清楚吗?还是等之后我深入理解了后再补充这部分的知识点吧,在这里立个 flag)

构建 CSSOM 树

  CSSOM 树的构建过程和 DOM 树的构建过程很相似,先是解析 CSS 生成 token,再由这些 token 生成节点构建出 CSSOM 树。

在这一过程中浏览器会确定每一个节点的样式到底是什么,而这一过程其实是很消耗时间的。因为样式可以自行设置给某个节点,也可以通过继承父节点获得。所以浏览器得递归 CSSOM 树,才能确定元素的具体样式。

  需要注意的是,上图的 CSSOM 树并不是完整的 CSSOM 树,因为它只显示出了我们在样式表中给定的样式。但除此之外每一个浏览器都会为每一个标签提供默认的样式信息的,比如font-sizemargin等,CSSOM 树中显示的只是我们用来替换默认样式的信息而已。

  不知道诸位看客从上图中有没有发现一个问题,上图的 CSSOM 树是由一个个标签名构建而成的,但往往我们的 CSS 样式表中是通过 class 和 id 来设置样式的,既然如此那 CSSOM 树要如何单单根据 class 和 id 就构建出有父子关系层层嵌套的树呢?(emmm,这个其实我也不知道Orz)

  既然说到了 CSSOM 树,就顺道提一下 CSS 匹配规则吧。我们知道 CSS 匹配节点样式时是从右往左的。举个例子,对于.box div这个 CSS 选择器会先去查找所有的 div 标签,再去匹配这些 div 标签的父元素是否有 box 这个类。所以根据这个匹配规则我们应该尽量使用 id 和 class 选择器,让 CSS 匹配可以更高效,避免使用标签选择器过度嵌套。当然这里面还牵涉到选择器优先级的问题,不过这里就不过多说这些题外话啦,有兴趣的看客就自行 Google 吧。

合成渲染树

  构建完 DOM 树和 CSSOM 树后就会合并两者,根据 DOM 上的节点及其对应的样式生成一颗渲染树(render tree)。在合并的过程中渲染树只会包含可见的节点,对于设置了display: none的节点和<head><script>这些节点是不会被渲染出来的。作为对比,设置visible: hidden的节点虽然不可见,但依旧会占据空间,还是会出现在渲染树中被渲染出来。

布局(Layout)

  有了渲染树就可以对应起每一个节点和相应的样式信息,但这时候每一个节点在文档中所处的位置还是不确定的。所以就需要根据渲染树中的节点及其样式在页面上自上而下,从左到右地计算各个节点所处的位置和大小,也就是布局(有没有联想到什么?每一个节点其实都代表了一个个盒模型啊)。

绘制(Painting)

  顾名思义,就是根据渲染树中的节点和对应的样式,以及计算得到的位置信息等将各个节点转换为页面上的实际像素并绘制出来。

  用一张图总结一下浏览器渲染的五个过程。

  在以上的五个过程中有两个地方需要注意到。

  1. 上述这五个步骤并不是同步的,而是逐步完成的。DOM 并不需要等 HTML 文档完全解析完再开始构建,而是边解析 HTML 边构建 DOM 树的。而且现在的浏览器为了达到更好的用户体验,渲染引擎会尽可能快的将内容渲染到屏幕上,它不会等到整个 HTML 文档都解析完成之后再去构建 render 树和布局渲染,而是同样解析完一部分内容就显示一部分内容(这是一个渐进的过程)。

  2. 现代一些浏览器在开始解析 HTML 的时候会有一个预解析的优化操作,即一开始就会并行去加载 HTML 文档中需要加载到的外部资源(JS 脚本、CSS 样式表以及图片等),而不必等 DOM 构建到它们相应的那个节点(我猜应该是通过正则表达式去匹配它们对应的标签名来实现的吧)。这样当 DOM 构建到相应的标签时,由于外部资源已经预加载好了,所以就可以立即执行而不用再等待资源加载从而避免进一步阻塞页面渲染(预解析并不改变 DOM 树)。

JS 和 CSS 对 DOM 的阻塞

JS 会阻塞 DOM 的解析和页面渲染

  在构建 DOM 树时,如果遇到了<script>加载 JS 脚本,那么 DOM 树会暂停构建,先执行 JS 代码。如果 JS 脚本是通过外部引入的,则是等待 JS 先下载再执行。为什么 JS 脚本会阻塞到 DOM 树的构建呢?看客应该都知道,JavaScript的一个强大之处在于它可以操纵页面中的每一个节点并对其进行增删改查操作,其中也包括修改节点对应的 CSS 样式信息。所以为了防止要执行的 JS 代码和正在构建的 DOM 树起冲突,DOM 树的构建是会先被挂起的,等到 JS 代码执行完毕才会继续构建 DOM 树。如果 JS 脚本加载时间很长,页面无法继续渲染下去就会造成浏览器失去响应处于假死状态。(不知道看客们有没有联想到什么?前面一篇讲事件循环的博客里有提到浏览器的两个线程:GUI 线程JavaScript 引擎线程。这两个线程是互斥的,其中原因和 JS 代码阻塞 DOM 是一样的)。平时我们常说不要把 JS 文件放在<head>头部而要放到<body>尾部防止首屏加载时间过长,就是基于 JS 会阻塞 DOM 的解析和渲染这一个原因。JS 文件放到了<body>的尾部,这样即使 JS 代码长时间加载也不会影响到首屏显示,因为这时候页面已经渲染完毕了。如果有一些 JS 代码必须先行加载(放在<head>头部)的话,最好直接将代码写在页面中而不是以外联载入的形式,这样可以节省加载 JS 文件所消耗的时长(不过 JS 代码放在<body>前面好像没什么用处吧,这时候页面还没有渲染又不能获取到 DOM 节点,通过 CDN 加载第三方库除外)。

  如果页面中有多个<script>标签要加载 JS 文件,例如:

<script src="a.js"></script>
<script src="b.js"></script>

  那么浏览器会并行下载这两个 JS 文件,但执行的时候会保证先执行 a.js,再执行 b.js,即使后者先加载完毕。也就是说,脚本的执行顺序由它们在页面中的出现顺序决定,不过页面的渲染还是得等它们都加载并执行完毕才会继续。

CSS 不会阻塞 DOM 的解析,但会阻塞页面渲染

  DOM 树和 CSSOM 树的构建是两个独立的过程,彼此不会互相依赖,所以 CSS 加载也就不会影响到 DOM 的解析。然而渲染树得依赖于 DOM 树和 CSSOM 树来合成的,所以页面的渲染也就得等待 CSSOM 构建了。也就是说 CSS 不会阻塞 DOM 的解析,但会阻塞页面的渲染。换个角度想想也是,如果页面不等待 CSS 加载完成就渲染,而若正在加载的 CSS 文件修改到页面上已经渲染完成的节点的话,那就得造成页面重绘了,造成很多无谓的消耗。如果还引起页面重排的话那就更消耗性能了,所以等待 CSS 加载完成再继续渲染页面反倒能优化性能。

  如果遇到 JS 脚本时还有未加载完成的 CSS 样式文件,那 CSS 的加载还会阻塞到 JS 的执行。原因也同上面说的,JS 可以增删改查到 DOM 节点,自然也就能读取或修改到 DOM 节点的 CSS 样式信息。为了防止 JS 文件读取读取到不完整的 CSS 信息或是修改时和前面的 CSS 样式表起冲突,所以 JS 文件会等待前面的 CSS 资源加载完成才执行。而 JS 又会阻塞到后面 DOM 的解析和页面渲染,双重阻塞下页面就变得更卡顿了。这也是我们平时常说的不要把 JS 文件放在 CSS 样式表之后的原因。

异步加载 JS 脚本

  在<script>标签中可以设置两个属性,分别是deferasync,设置了deferasync的 JS 脚本相当于异步加载 JS 文件。在普通的<script>下,JS 的加载会暂停后续 DOM 的解析,而设置deferasync<script>可以边加载 JS 文件,边进行 DOM 解析,这两个过程是并行发生的。需要注意的是,这两个属性只能作用于外部引入的脚本,对于内联脚本是不起作用的。

  两者的区别在于,defer加载的 JS 脚本会延迟执行,等到该 JS 脚本加载完毕并且 DOM 也解析完成后才会执行该 JS 代码。而async则是一旦加载完成 JS 脚本就会马上执行,如果这时候 DOM 还没有解析完成也是会先暂停解析先等待 JS 执行完毕的(同样会阻塞)。如果使用defer加载多个 JS 脚本,那 JS 脚本会按照它们在代码中的先后顺序执行。而async因为是 JS 脚本一加载完成就马上执行,所以并不能保证 JS 脚本的执行顺序,这取决于哪个 JS 脚本先加载完成。下面这张图很直观地展示了三者的区别。

  需要注意的是,defer脚本会在DOMContentLoaded监听函数之前(相当于 jQuery 的 ready 事件)执行。而什么是 DOMContentLoaded 事件,用一句话概括就是,DOMContentLoaded会在 DOM 构建完毕后触发,此时<link><img>等外部资源可能还没有加载完成。和DOMContentLoaded相似的还有一个load事件,这个大家应该比较熟悉了。load事件不仅得等待 DOM 构建完成,还得等页面上其他的资源如图片音频和视频等都加载完后才触发,所以load事件是在DOMContentLoaded事件之后才触发的。至于async则情况会复杂一点,并不能确保asyncDOMContentLoaded的执行先后顺序(但会在load之前执行)。这是因为async脚本一加载完毕就会马上执行,所以asyncDOMContentLoaded的执行先后顺序取决于async脚本的加载完成时间。如果async脚本在 DOM 解析完成之前就加载完毕了,那么async脚本会先于DOMContentLoaded执行,反之则是DOMContentLoaded先于async脚本执行。(不知道看客们会不会有一个疑问,defer脚本不是构建完 DOM 才执行的吗,而DOMContentLoaded也是在构建完 DOM 完后就执行,那defer脚本怎么就一定会先于DOMContentLoaded执行呢?其实我也不知道,因为 MDN 就是这么写的。)

  除了使用deferasync异步加载 JS 脚本外,我们还可以动态加载脚本,即在页面加载完成后才导入 JS 脚本,这样也可以避免阻塞页面渲染。

<script>
  window.onload = function() {
    let ele = document.createElement('script');
    ele.type = 'text/javascript';
    ele.src = 'test.js';
    document.body.appendChild(ele);
  }
</script>

重排和重绘

何时发生重排和重绘

  前面在说浏览器渲染的五个阶段的时候,后两个阶段分别是布局和绘制,其实对应的就是重排(也叫做回流)和重绘,而重排和重绘常常是影响页面性能的主因之一。我们先说说什么时候会触发重排和重绘。当页面中某一个元素的几何属性如大小位置等发生变化时,浏览器就需要重新计算该元素的几何属性,并且页面中的其他元素也都会受到影响,所以这时候就会对页面进行重新布局。造成重排的操作包括但不限于:

  • 页面首屏初始化加载。
  • 添加或删除可见的 DOM 元素(包括设置元素的display: none属性)。
  • 改变元素的位置。
  • 改变元素的大小,如外边距、边框、内边距、宽高等。
  • 内容改变导致元素的大小改变,如改变图片大小或是将图片替换成另一张不同尺寸的图片。
  • 改变浏览器窗口大小。

根据改变的范围和程度,渲染树中或大或小的部分需要重新计算,有些改变会触发整个页面的重排,比如,滚动条出现的时候或者修改根节点。

  而重绘则相对友好一些,不会导致页面重新布局,只是重新绘制受影响的部分元素。常见的造成重绘的操作除了上述的操作外还有:修改元素的背景颜色、字体的颜色和修改元素的外观显示outline等。由此我们也可以发现重排和重绘之间的关系:重排一定会造成重绘,但重绘不一定会重排

如何减小重排和重绘

  重排和重绘很消耗性能,所以我们在编写代码的时候应该有意识地去避免一些造成重排和重绘的发生(我们常常被建议不要去操作 DOM,因为操作 DOM 慢,而慢的原因就是因为操作 DOM 常常会引起重排和重绘)。

合并对样式的修改

let ele = document.getElementById('box');
ele.style.top = '100px';
ele.style.left = '100px';

  我们常常会使用诸如上面示例的方法去修改 DOM 节点的样式,但上面这样的修改方式操作到了两次 DOM ,更糟糕的是导致了浏览器发生两次重排。所以我们可以通过cssText来合并对同一个 DOM 节点的多次修改,从而把操作 DOM 和重排的次数降为 1,优化页面性能。

let ele = document.getElementById('box');
ele.style.cssText = 'top: 100px; left: 100px';
ele.style.cssText += 'width: 100px; height: 100px;';

  需要一提的是,cssText会覆盖掉之前设置好的样式,比如示例中如果不使用+=来拼接样式信息的话,则显示出来的元素会丢失原先设置好的leftright样式。当然,除了使用cssText外,我们还可以通过增加或修改元素的class来控制元素的样式显示(在 Vue 中不就常这么干)。

  不过现在大多数浏览器会尽量把所有的样式变动都集中到一起,形成一个队列再批量处理,从而来避免页面多次重排。比如上面对leftright样式的修改,浏览器会把两次修改集中到一起再执行,这样就能只重排一次了。但一些操作会阻止浏览器对样式修改进行批量修改,强制页面马上重新渲染。比如以下方法:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • getComputedStyle()

  以上这些操作要求浏览器返回最新的页面信息,所以浏览器不得不马上渲染已修改了的样式信息,造成页面多次重排。所以平时应该尽量少的去使用上述的属性,或者尽量不要把样式的写操作和读操作放在同一个语句里,可以先使用一个变量存储元素的上述的一些样式信息再进行写操作。

批量修改 DOM

  有时候 DOM 节点并不是静态的,而需要我们去动态添加并进行一系列操作。比如在页面中给ul插入 N 个li,如果我们只是常规地在一个 for 循环中创建节点再添加的话,会对 DOM 操作 N 次并造成 N 次重排,这样对性能的影响可想而知。所以我们可以先让要操作到的元素脱离文档流再进行批量操作,最后再将元素添加回文档流中,这样就可以大大减小页面重排的次数。常用的方法有:

  1. 使用display: none隐藏元素

  利用设置了display: none的元素在文档流中不占空间,所以我们可以先改变uldisplay属性,再在ul上添加li,最后再恢复原先uldisplay属性即可。

  1. 使用文档片段

  使用document.createDocumentFragment()来创建一个文档片段,而因为文档片段存在于内存中,所以将子元素插入到文档片段时并不会对 DOM 树造成任何影响,因此自然就不会造成多余的重排了。只要将操作完后的文档片段添加到ul后即可,而这整个过程只会造成一次重排。

<body>
  <ul id="ul"></ul>
  <script>
    let ul = document.getElementById('ul');
    let fragment = document.createDocumentFragment();
    for(let i=1; i<=100; i++) {
      let li = document.createElement('li');
      li.innerText = i;
      fragment.appendChild(li);
    }
    ul.appendChild(fragment);
  </script>
</body>
  1. 克隆原节点修改后再替换

  使用cloneNode克隆要进行修改的节点,在克隆的节点(副本)上进行操作后再使用replaceChild替换掉原先的节点。

<body>
<ul id="ul"></ul>
  <script>
    let ul = document.getElementById('ul');
    let clone = ul.cloneNode(true);
    for(let i=1; i<=100; i++) {
      let li = document.createElement('li');
      li.innerText = i;
      clone.appendChild(li);
    }
    ul.parentNode.replaceChild(clone, ul);
  </script>
</body>

对于来自同一个域名的资源,比如脚本文件、样式表文件、图片文件等,浏览器一般有限制,同时最多下载6~20个资源,即最多同时打开的 TCP 连接有限制,这是为了防止对服务器造成太大压力。如果是来自不同域名的资源,就没有这个限制。所以,通常把静态文件放在不同的域名之下,以加快下载速度。

后话

  这篇博客页只是对浏览器的渲染过程做了一个大致的分析而已,如果对浏览器渲染的五个步骤进行深究下去其实还可以挖出很多细节的东西(前端就这样,原理的东西可以一个劲地深挖下去,而且还涉及的很广)。碍于个人所学有限,就先做一个简单的分析,等以后有了更深入的认识我再回来补充吧。或者看客有兴趣的话可以阅读下面这几篇深度好文做详细了解(超级长文预警)。

构建对象模型
浏览器的工作原理:新式网络浏览器幕后揭秘
How browsers work
从渲染原理谈前端性能优化


 上一篇
Puzzle Game Puzzle Game
前言  会做这个 Puzzle Game,还是应前几天 lightyears 的一次提议,模仿的是鹰脚网络首页左下角那个拼图小游戏。那天晚上睡觉的时候在床上想了一下,大致 get 到了它内部实现的原理,于是就干脆动手实
2019-05-03
下一篇 
事件模型和事件委托 事件模型和事件委托
  这回说的是事件模型,跟上篇博客说的事件循环关系不大。事件循环主要是同异步事件在内部环境的执行过程,而事件模型主要是涉及到事件的生成过程,在实践中的应用比较多,比如说常见的事件委托(也叫做事件代理)。 事件模型&em
2019-04-12
  目录