前言

我在学习 html 的过程中,接受到最多的建议就是把 style 标签写在头部,script 标签写在末尾,好像规范就是如此。

今天就来探讨一下,这样子书写究竟为何?浏览器如何将 html 文档一步步绘制为绚丽多彩的页面的?

主流程

浏览器从接受到 html 文件,到显示出华丽的页面,一共经历以下6步:

  • 解析 HTML

    • 将二进制数据转换成 "UTF-8" 或 "unicode" 字符串
    • 将字符串转化成 Tokens
  • 构建 DOM 树

    • 将 Tokens 解析成 DOM 节点。
    • 将 DOM 节点组合成一棵 DOM 树。
  • 构建 CSSOM 树

    • 通过 Tokens 中的 stylelink 标签获取 CSS 文件。
    • 解析 CSS 文件,构建出对应的 CSSOM 树。
  • 构建 Render 树

    • 结合 DOM 树与CSSOM 树构建出一棵 Render 树。
  • 布局/回流

    • 计算出每个元素的精确位置。
  • 渲染/重绘

    • 逐像素的将元素渲染到屏幕上。

Webkit 主要流程

webkit.png

Gecko 主要流程

gecko.png

虽然 Webkit 和 Gecko 使用的术语略有不同,但流程基本相同。

解析 HTML

看到上面的主流程时你可能会疑惑,在解析 html 时,为什么又要将字符串转化成 Tokens?Tokens是个啥?直接解析 html 字符串不行吗?

别急,我们都知道,html 这门语言的语法非常宽容。我们在编写 html 的过程中,不管我们写了啥,浏览器都会正常的显示,从来没有遇到过“无效语法”这样的错误。

这是因为浏览器接受到我们的错误代码,它兢兢业业地修复了无效内容并继续工作。

例如在 html 文件中写下这样的代码

<body>
  <div>
    ddddd
  <p>
    ppppp
  </div>
  </p>
</body>

很明显的语法错误,缺少 htmlhead 标签,divp 标签相互嵌套,而在浏览器中显示的结果却是这样的

83d3cba9d62244f188794b2b074679e4_tplv-k3u1fbpfcp-watermark.png

浏览器自动为我们添加了 htmlhead 标签,关闭 divp 标签.

正因 html 语法允许程序员犯错,浏览器就必须有一个纠错的过程,这就是需要先转化成 Tokens 的原因。

同时,切片为一段段的 Tokens,很适合接下来渐进式的解析、渲染,给用户带来更好的体验。

接下来浏览器会遍历所有 Tokens,将内容分发给的 DOM、CSS 或 JS 解析器,供他们构建 DOM 树、CSSOM 树或执行脚本。

1.png

构建 DOM 树

这一过程会就将 Tokens 解析成 DOM 节点对象,为 DOM 节点绑定属性,然后添加到 DOM 树中。

比较特殊的是,节点身上的内联样式,它们会调用CSS解析器进行解析,但不会参与到构建 CSSOM 的过程中,内联样式会作为属性保留在节点身上。

构建 CSSOM 树

浏览器通过 Tokens 中的 stylelink 标签获取 CSS 文件,然后通过 CSS 解析器解析文件,构建 CSSOM 树。

需要注意的是,CSS 解析只有获取了完整的 CSS 文件,才会开始解析。

因为 CSS 样式之间存在相互覆盖,很可能前面解析了许多内容,全被后面的样式覆盖了。

也要知道,真正的 CSSOM 树并不只有我们写的样式,浏览器自身还带有大量的默认样式。

构建 Render 树

浏览器结合 DOM 树与 CSSOM 树,生成真正在视图中所呈现的Render 树。

这一过程主要是过滤掉 DOM 树中无需渲染的节点,比如 htmlheadmeta 等标签,或是样式设置为 display:none 的节点。

布局与渲染

根据 Render 树,为每个节点分配一个应出现在屏幕上的确切坐标,然后将节点渲染到页面上。

浏览器运行时的优化会非常关注这一过程,但本文主要探讨的是首屏的加载,在这里不做过多讨论。

渐进式的解析

浏览器解析 HTML 这是一个渐进的过程,为达到更好的用户体验,浏览器会力求尽快将内容显示在屏幕上。

浏览器不必等到整个 HTML 文档解析完毕,就会开始构建 Render 树和设置布局。在不断接受和处理来自网络的其余内容的同时,浏览器会将部分内容解析并显示出来。

正因浏览器的解析是渐进式的,我们希望用户一开始看到的 DOM 元素都在其最终位置上,所以会先把 style 标签写在头部,先构建完 CSSOM 树,再渐进式地构建 DOM 树。

解析过程中阻塞

浏览器是从上至下解析 HTML 的,生成的 Tokens 标签也是按此顺序排列的。

然后浏览器依次遍历每个 Tokens 执行对应的解析器。

本身解析 DOM、解析 CSS、运行 JS 之间并无关系,轮到谁就先解析谁。

但用户看到的都是 DOM 元素,所以程序员就最重视 DOM,常说 CSS、JS 阻塞了 DOM 树的构建。

在 JS 中,可能会改变当前 DOM 树或 CSSOM 树的结构,而之后又要获取最新的状态,浏览器就会停止 JS 的运行,修改 DOM 树或 CSSOM 树,重新布局计算元素位置。所以真正被阻塞的,应该是 JS。

现在我们来看一个例子来加深阻塞的认识

<script>
  const n = document.getElementById('div')
  console.log(n)
</script>
<style>
  div {
    width: 400px;
  }
</style>
<div id="div"></div>
<script>
  const div = document.getElementById('div')
  console.log(div.clientWidth)
  const style = document.createElement('style')
  style.innerHTML = `div {
    width: 200px;
  }`
  document.head.appendChild(style)
  console.log(div.clientWidth)
</script>

而控制台的输出是这样的

null
400
200

我们来分析一下流程

浏览器从上至下解析,在第一个 script 标签时,执行 JS,但 DOM 树中并无 #div 的元素,获取不到节点,输出 null

然后遇到 style 标签,生成 CSSOM 节点,添加到 CSSOM 树中。

再遇到 div 标签,生成 DOM 节点,添加到 DOM 树中,此时已经可以组合成 Render 树,在页面显示。

最后又遇到 script 标签,执行JS,此时已经有 #div 的元素,获取其宽度并输出。

然后这段 JS 还创造了一个 style 标签,插入head节点中(示例代码的上方)。这时 HTML 的结构发生了改变,会为其生成 Token,解析其中的 CSS,更新出最新的 CSSOM 树,重新布局与渲染。

最后提供给 JS 最新的属性值,用以输出。

异步脚本

我们如果在 linkscript 标签引入了外部文件,浏览器就会停止解析,一直等到外部文件下载完成后,才开始解析。

这显然是对资源的浪费,于是便有了异步脚本,通过给script 标签添加 deferasync 属性实现。

  • 浏览器遇到带有 deferasync 属性的 script 标签,会立刻开始下载,但不会阻塞 html 的解析。
  • 带有 defer 属性脚本的执行会推迟到浏览器解析完整个 html 文件,在 DOMContentLoaded 事件之前。
  • 带有 async 属性脚本的会在浏览器解析 html 时异步执行。
  • 多个异步脚本之间并不会按顺序执行。
  • 异步脚本中不应该修改 DOM 的结构。

优化

弄清楚了浏览器渲染页面的流程与原理后,在这里提出几点优化的方法:

  • 书写符合规范的 html 代码,不要浪费浏览器的资源去为代码纠错。
  • 重要样式直接写在 html 的 head 标签中,不重要的样式在末尾书写或用 link 加载。
  • script 标签要写在末尾,不影响 DOM 结构的脚本加上 defer 属性。


参考文档:
How browsers work


清隆
29 声望2 粉丝

学完某项技能一定要写写文章,用的时候都是照搬代码,写出来才能深入理解!