浏览器是如何渲染网页的?——DOM,CSSOM 以及渲染

浏览器是如何渲染网页的?——DOM,CSSOM 以及渲染

翻译自 Uday Hiwarale 的 How the browser renders a web page? — DOM, CSSOM, and Rendering

前言

当你开发一个网站的时候,有些东西对于良好的用户体验来说是非常必要的。网站可能遇到的一些常见问题可能是资源加载缓慢,在初始渲染时等待不必要的文件下载,样式未应用的内容闪烁(flash of unstyled content (FOUC))等。为了避免类似问题,我们需要了解浏览器渲染典型网页的生命周期。

首先,我们需要了解什么是 DOM。当浏览器相服务器发送请求,获取一个 HTML 文档的时候,服务器会返回一个二进制流格式的 HTML 页面,它基本就是一个文本文件,其响应头的 Content-Type 设置为 text/html;charset-UTF-8。这里的 text/html ·是一个 MIME 类型,它告诉浏览器这是一个 HTML 文档,charset=UTF-8 告诉浏览器它是以 UTF-8 字符编码的。利用这些信息,浏览器剋将二进制格式转换为可读的文本文件。如下图所示。
1_Tm-HPhmGA0BL7HIj38H8Qw.png

如果该 header 缺失,浏览器将无法理解如何处理该文件,它将以纯文本格式呈现。但如果一切正常,经过这次转换后,浏览器就可以开始阅读 HTML 文档了。一个典型的 HTML 文档大致如下:

<!DOCTYPE html>
<html>
  <head>
    <title>Rendering Test</title>

    <!-- stylesheet -->
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div class="container">
      <h1>Hello World!</h1>
      <p>This is a sample paragraph.</p>
    </div>

    <!-- script -->
    <script src="./main.js"></script>
  </body>
</html>
复制代码

在上面的文档中,我们的网页依赖 style.cssHTML 元素提供样式,main.js 来执行一些 JavaScript 操作。通过一些 CSS 样式,我们上面的网页就会变成这样:
1_Tm-HPhmGA0BL7HIj38H8Qw.png

但问题还是在于,浏览器是如何从一个简单的,只包含文本的 HTML 文件渲染出这个好看的网页呢?为此,我们需要了解什么是 DOMCSSOMRender Tree

Document Object Model (DOM)

当浏览器读取 HTML 代码时,只要遇到 body、divHTML 元素,就会创建一个名为 NodeJavaScript 对象。

由于每个 HTML 元素都有不同的属性,所以 Node 对象将从不同的类(构造函数)中创建。例如,div 元素的 Node 对象是由继承自 Node 类的 HTMLDivElement 创建的。对于我们前面的 HTML 文档,我们可以用一个简单的测试来可视化这些节点,如下图:
1618739869298.png

浏览器自带了 HTMLDivElement、HTMLScriptElement、Node 等内置类。

浏览器从 HTML 文档中创建了 Node 之后,就要把这些节点对象创建成树状结构。由于我们在 HTML 文件中的 HTML 元素是互相嵌套的,所以浏览器需要使用之前创建的 Node 对象复制它们。这将帮助浏览器在网页的整个生命周期中高效地渲染和管理网页。

1618739869298.png

我们之前的 HTML 文档的 DOM 树就像上面一样。一棵 DOM 树从最上面的 html 元素开始,根据 HTML 元素在文档中的出现和嵌套情况进行分支。每当发现一个 HTML 元素时,它就会从其各自的类中创建一个 DOM 节点(Node)对象(构造函数)。

一个 DOM 节点并不总是必须是一个 HTML 元素。当浏览器创建 DOM 树时,它也会将注释、属性、文本等内容作为树中的单独节点保存。但为了简单起见,我们只考虑 HTML 元素的 DOM 节点,也就是 DOM 元素。这里是所有 DOM 节点类型的列表。

你可以在 Google Chrome DevTools Console 中看到 DOM 树,如下所示。这将显示 DOM 元素的层次结构(DOM 树的高级视图)以及每个 DOM 元素的属性。
1618742342888.png

JavaScript 不明白 DOM 是什么,它不是 JavaScript 规范的一部分。DOM 是浏览器提供的一种高级 Web API,用于高效地渲染网页,供开发者动态操作 DOM 元素以达到各种目的。

使用 DOM API,开发者可以添加或删除 HTML 元素,改变其外观或绑定事件监听器。使用 DOM API,可以在内存中创建或克隆 HTML 元素,并在不影响 DOM 树的情况下进行修改。这使得开发者有能力构建高度动态的网页,并提供丰富的用户体验。

CSS Object Model (CSSOM)

当我们设计一个网站时,我们的目的是让它尽可能的好看。而我们通过为 HTML 元素提供一些样式来实现。在 HTML 页面中,我们使用 CSS(Cascading Style Sheets,即层叠样式表) 为 HTML 元素提供样式。使用 CSS 选择器,我们可以针对 DOM 元素,为样式属性设置一个值,比如颜色或字体大小。

将样式应用到 HTML 元素上有不同的方法,比如使用外部 CSS 文件、使用 <style> 标签嵌入 CSS、使用 HTML 元素上的 style 属性的内联方法或使用 JavaScript。但最终,浏览器还是要担起将 CSS 样式应用到 DOM 元素上的重任。

比方说,对于我们前面的例子,我们将使用下面的 CSS 样式(这不是截图中显示的卡片所使用的 CSS)。为了简单起见,我们不打算理会如何在 HTML 页面中导入 CSS 样式。

html {
  padding: 0;
  margin: 0;
}

body {
  font-size: 14px;
}

.container {
  width: 300px;
  height: 200px;
  color: black;
}

.container > h1 {
  color: gray;
}

.container > p {
  font-size: 12px;
  display: none;
}
复制代码

在构建了 DOM 之后,浏览器从所有的源头(外部的、嵌入式的、内嵌的、用户代理的等)读取 CSS ,并构建一个 CSSOMCSSOMCSS 对象模型的缩写,它和 DOM 一样是一个树状结构。

这个树中的每个节点都包含 CSS 样式信息,这些信息将被应用到它所针对的 DOM 元素上(由选择器指定)。然而,CSSOM 并不包含那些不能在屏幕上打印的 DOM 元素,如<meta>、<script>、<title>等。

我们知道,大多数浏览器都自带样式表,这个样式表被称为用户代理样式表,浏览器首先用开发者属性提供的 CSS 覆盖用户代理样式(使用特定性的规则),计算出 DOM 元素的最终 CSS 属性,然后构造一个节点。

即使某个 HTML 元素的 CSS 属性(如 display )没有被开发者或浏览器定义,其值也会被设置为 W3C CSS 标准规定的该属性的默认值。在选择 CSS 属性的默认值时,如果某个属性符合 W3C 文档中提到的继承规则,则会使用一些继承规则。

例如,如果一个 HTML 元素缺少颜色和字体大小等属性,那么这些属性就会继承父元素的值。所以你可以想象一下,在一个 HTML 元素上有这些属性,它的所有子元素都会继承它。这就是所谓的样式层叠,这也是为什么 CSS 是层叠样式表的缩写。这也正是浏览器构建一个树状结构的CSSOM 的原因,用于根据 CSS 层叠规则计算样式。

您可以在 Element 面板中使用 Chrome DevTools 控制台查看 HTML 元素的计算样式。从左侧面板中选择任意一个 HTML 元素,然后点击右侧面板中的计算选项卡。

我们可以用下图来使前面例子的 CSSOM 树可视化。为了简单起见,我们将忽略用户代理样式,而专注于前面提到的 CSS 样式。

1618747003004.png

从上图中可以看出,我们的 CSSOM 树中不包含<link>、<title>、<script>等不会被打印到屏幕上的元素。红色的 CSS 属性值是从顶部层叠下来的,而灰色的属性值则是覆盖了继承的值。

Render Tree

Render-Tree 也是一个将 DOM 树和 CSSOM 树组合在一起构建的树状结构。浏览器要计算每个可见元素的布局,并把它们画在屏幕上,为此浏览器使用了这个 Render-Tree。因此,如果没有构建 Render-Tree,那么任何东西都不会被打印在屏幕上,这就是为什么我们同时需要 DOM 和 CSSOM 树。

由于 Render-Tree 是对最终将被打印在屏幕上的内容的低级表示,它不会包含在像素矩阵(页面)中不包含任何区域(一定情况下可认为是:不占空间)的节点。例如,display:none;元素的尺寸为0px X 0px,因此它们不会出现在 Render-Tree 中。

1618747752887.png

从上图可以看出,Render-Tree 结合了 DOMCSSOM,生成了一个树状结构,其中只包含了要打印在屏幕上的元素。

因为在 CSSOM 中,位于 div 内的 p 元素被设置了 display:none; 样式,所以它和它的子元素不会出现在 Render-Tree 中,因为它在屏幕上不占空间。但是,如果你有 visibility:hiddenopacity:0 的元素,它们将占据屏幕空间,因此它们将出现在 Render-Tree 中。

DOM API 不同的是,DOM API 可以访问浏览器构建的 DOM 树中的 DOM 元素,而 CSSOM 则是对用户隐藏的。但由于浏览器将 DOMCSSOM 结合在一起形成了 Render Tree,所以浏览器通过在 DOM 元素本身提供高级 API 来暴露 DOM 元素的 CSSOM 节点。这样开发者就可以访问或改变 CSSOM 节点的 CSS 属性。

由于使用 JavaScript 操作元素的样式超出了本文的范围,所以这里有一个 CSS Tricks 文章的链接,它涵盖了 CSSOM API 的广泛范围。我们还有新的 JavaScript 中的 CSS Typed Object API,它可以更准确地操纵元素的样式。

渲染顺序

现在我们已经很好地理解了什么是 DOM、CSSOMRender-Tree,让我们了解一下浏览如何使用它们来渲染一个典型的网页。对这个过程有一个最起码的了解对任何 Web 开发人员来说都是至关重要的,因为它将帮助我们设计我们的网站以获得最大的用户体验(UX)和性能。

当一个网页被加载时,浏览器首先读取 HTML 文本并从中构建 DOM 树。然后它处理 CSS,无论是内嵌的、嵌入式的、还是外部的 CSS,并从中构建 CSSOM 树。

构建完这些树后,它再从中构建 Render-Tree。一旦构建了 Render-Tree,浏览器就会开始在屏幕上打印各个元素。

布局操作

首先浏览器创建每个单独的 Render-Tree 节点的布局。布局包括每个节点的大小(以像素为单位)和它将被打印在屏幕上的位置。这个过程被称为布局,因为浏览器正在计算每个节点的布局信息。

这个过程也被称为回流或浏览器回流(reflow),当你滚动、调整窗口大小或操作 DOM 元素时,也会发生这个过程。这里是可以触发元素布局/回流的事件列表。

我们应该避免网页因为微不足道的原因进行多次布局操作,因为这是一种昂贵的操作。这里Paul Lewis 的一篇文章,他讲述了我们如何避免复杂而昂贵的布局操作以及布局打乱

绘制操作

直到现在,我们还有一个需要打印在屏幕上的几何体列表。由于 Render-Tree 中的元素(或子树)可以相互重叠,而且它们可以具有 CSS 属性,使它们经常改变外观、位置或几何显示(如动画),因此浏览器会为它创建一个图层。

创建图层可以帮助浏览器在网页的整个生命周期中高效地执行绘制操作,比如在滚动或调整浏览器窗口大小的时候。拥有图层还可以帮助浏览器按照开发者的意图,正确地按照堆叠顺序(沿 z 轴)绘制元素。

现在我们有了图层,我们可以将它们组合起来,并在屏幕上绘制。但是浏览器并不是一次性绘制所有的图层。每个图层都是先单独绘制的。

在每个图层里面,浏览器会对元素的任何可见属性,如边框、背景色、阴影、文字等进行单独的像素填充。这个过程也被称为栅格化。为了提高性能,浏览器可以使用不同的线程来执行栅格化。

Photoshop 中图层的类比也可以应用到浏览器渲染网页的方式。您可以从 Chrome DevTools 中看到网页上的不同图层。打开 DevTools,从更多工具选项中选择 “Layers”。您还可以从 “Rendering” 面板中看到图层边框。

栅格化通常是在 CPU 中完成的,这使得它的速度很慢,而且成本很高,但是我们现在有了新的技术,可以在 GPU 中进行性能提升。这篇 intel 文章 详细介绍了绘画主题,这是一篇必读的文章。要想详细了解图层的概念,这是一篇必读的文章。

合成操作

到目前为止,我们还没有在屏幕上绘制一个像素。我们拥有的是不同的图层(位图图像),它们应该按照特定的顺序绘制在屏幕上。在合成操作中,这些图层会被发送到 GPU 上,最终将其绘制在屏幕上。

每次回流(布局)或重绘时都要将整个图层送去绘制,这显然是很低效的。因此,一个图层被分解成不同的块,然后将其绘制在屏幕上。你也可以在 Chrome 的 DevTool Rendering 面板中看到这些块。

从上面的信息中,我们可以从简单的 HTML 和 CSS 文本内容中,构建出一整个 浏览器从一个网页到在屏幕上渲染出事物所经历的事件顺序。
1618750000712.png

这个事件顺序也被称为 关键渲染路径

Mariko Kosaka 为这个流程写了一篇漂亮的文章,有酷炫的插图和对每个概念的更广泛的解释。强烈推荐。

浏览器引擎

创建 DOM 树、CSSOM 树和处理渲染逻辑的工作是由一个叫做浏览器引擎(也称为渲染引擎或布局引擎)的浏览器进程来完成的,它位于浏览器内部。这个浏览器引擎包含了,将一个网页从 HTML 代码,渲染到屏幕上的实际像素的,所有必要的元素和逻辑。

如果你听到人们谈论 WebKit,他们说的是一个浏览器引擎。WebKit 被苹果的 Safari 浏览器使用,也是谷歌 Chrome 浏览器的默认渲染引擎。截至目前, Chromium 项目使用 Blink 作为默认渲染引擎。这里是一些顶级网络浏览器使用的不同浏览器引擎的列表。

浏览器的渲染流程

我们都知道 JavaScript 语言是通过 ECMAScript 标准来实现标准化的,其实由于 JavaScript 被注册为商标,所以我们现在只叫它 ECMAScript。因此,每一个 JavaScript 引擎提供商,如 V8、Chakra、Spider Monkey 等都要遵守这个标准的规则。

有了标准,我们就可以在所有 JavaScript 运行时,如浏览器、Node、Deno 等,获得一致的 JavaScript 体验。这对于多平台的 JavaScript(和 Web)应用的一致和完美的开发是非常好的。

然而,浏览器的渲染方式却并非如此。HTML、CSS 或 JavaScript,这些语言都是由某个实体或某个组织标准化的。然而,浏览器如何将它们管理在一起,在屏幕上呈现东西,却不是标准化的。谷歌 Chrome 的浏览器引擎可能会与 Safari 的浏览器引擎做不同的事情。

因此,很难预测特定浏览器的渲染顺序及其背后的机制。然而,HTML5 规范已经做出了一些努力,在理论上标准化渲染应该如何工作,但浏览器如何遵守这一标准完全取决于它们。

尽管存在这些不一致的地方,但所有浏览器之间通常都有一些共同的原则。让我们来了解一下浏览器在屏幕上渲染事物的常见方法,以及这个过程的生命周期事件。

解析和外部资源

解析是指读取 HTML 内容并从中构建 DOM 树的过程。因此,这个过程也被称为 DOM 解析,完成这个过程的程序被称为 DOM 解析器。

大多数浏览器都提供了 DOMParser Web API 来从 HTML 代码中构建 DOM 树。 DOMParser 类的一个实例代表了一个 DOM 解析器,使用 parseFromString 原型方法,我们可以将原始的 HTML 文本(代码)解析成一棵 DOM 树(如下图所示)。

当浏览器请求一个网页,服务器响应一些 HTML 文本(Content-Type 头设置为 text/html)时,浏览器可能会在整个文档的几个字符或几行字可用时就开始解析 HTML。因此,浏览器可以逐步建立 DOM 树,一次一个节点。浏览器从上到下解析 HTML,而不是中间的任何地方,因为 HTML 代表了一个嵌套的树状结构。

在上面的例子中,我们从 Node 服务器访问了 incremental.html 文件,并将网络速度设置为只有 10kbps(从网络面板)。由于浏览器加载(下载)这个文件需要很长的时间(因为它包含 1000 个 h1 元素),所以浏览器从最初的几个字节构建了一个 DOM 树,并将它们打印在屏幕上(因为它在后台下载 HTML 文件的剩余内容)。

如果你看一下上面请求的性能图,你就能在 Timing 行看到一些事件。这些事件通常被称为 性能指标。当这些事件放在尽可能近的地方,并且尽可能早的发生,用户体验会更好。

FPFirst Paint 的缩写,意思是浏览器开始在屏幕上打印东西的时间(可以简单到正文背景色的第一个像素)。

FCPFirst Contentful Paint 的缩写,意思是浏览器渲染出文字或图片等内容的第一个像素的时间。LCPLargest Contentful Paint 的缩写,是指浏览器渲染大块文字或图片的时间。

L 代表 onload 事件,它是由浏览器在 window 对象上发出的。同样,DCL 代表 DOMContentLoaded 事件,它是在 document 对象上发出的,但会冒泡到 window 上,因此你也可以在 window 上监听它。这些事件理解起来有点复杂,所以我们稍后会讨论它们。

每当浏览器遇到一个外部资源,如通过 <script src="url"></script> 元素的脚本文件(JavaScript)、通过 <link rel="stylesheet" href="url"/> 标签的样式表文件(CSS)、通过 <img src="url" /> 元素的图像文件或任何其他外部资源,浏览器将在后台(JavaScript 执行的主线程之外)开始下载该文件。

最重要的一点是,DOM 解析通常发生在主线程上。因此,如果主 JavaScript 执行线程很忙,DOM 解析将无法进行,直到该线程空闲下来。你可能会问为什么这么重要?因为脚本元素是会阻塞解析器的。除了脚本(.js)文件请求外,每一个外部文件请求,如图片、样式表、pdf、视频等都不会阻塞 DOM 的构建(解析)。

解析器阻塞型脚本(Parser-Blocking Scripts)

解析器阻塞型脚本是一个 script(JavaScript)文件/代码,它可以停止对 HTML 的解析。当浏览器遇到一个 script 元素时,如果它是一个嵌入式 script,那么它将首先执行该 script,然后继续解析 HTML,构建 DOM 树。所以所有的嵌入式 script都是解析器阻塞型的,讨论结束。

如果 script 元素是外部 script 文件,浏览器会在主线程之外开始下载外部 script 文件,但在该文件下载完毕之前,会停止主线程的执行。这意味着在 script 文件下载之前,不会再进行 DOM 解析。

一旦 script 文件下载完毕,浏览器将首先在主线程上执行下载好的 script 文件,然后继续进行 DOM 解析。如果浏览器再次发现 HTML 中的其他 script 元素,就会执行同样的操作。那么为什么浏览器要停止 DOM 解析,直到 JavaScript 被下载并执行?

浏览器将 DOM API 暴露给 JavaScript 运行时,这意味着我们可以从 JavaScript 中访问和操作 DOM 元素。这就是 React 和 Angular 等动态 Web 框架的工作方式。但如果浏览器希望并行运行 DOM 解析和 script 执行,那么 DOM 解析线程和主线程之间可能会出现竞赛条件,这就是为什么 DOM 解析必须在主线程上进行。

然而,在后台下载 script 文件时停止 DOM 解析,在大多数情况下是完全没有必要的。因此,HTML5 为我们提供了 script 标签的 async 属性。当 DOM 解析器遇到一个带有 async 属性的外部 script 元素时,它不会在后台下载 script 文件时停止解析过程。但是一旦文件下载完毕,解析过程就会停止,script(代码)就会被执行。

我们还为 script 元素设置了一个神奇的 defer 属性,它的工作原理与 async 属性类似,但与 async 属性不同的是,即使文件完全下载完毕,script 也不会执行。一旦解析器解析了所有的 HTML,也就是说 DOM 树已经完全构建完成,所有的 defer script就会被执行。与异步 script 不同的是,所有的延迟 script 是按照它们在 HTML 文档(或 DOM 树)中出现的顺序来执行的。

所有普通的 script(嵌入式或外部)都是解析器阻塞型的,因为它们停止了 DOM 的构建。所有异步 script 在下载之前不会阻塞解析器。一旦一个异步 script 被下载,它就变成了阻断解析器的 script。然而,所有的 defer 脚本都是非阻断解析器型的脚本,因为它们不阻断解析器,并且在 DOM 树完全构建完成后执行。

在上面的例子中,parser-blocking.html 文件在 30 个元素后包含了一个阻塞解析的脚本,这就是为什么浏览器一开始显示 30 个元素,停止 DOM 解析,并开始加载脚本文件的原因。第二个脚本文件由于有 defer 属性,所以不会阻止解析,所以一旦 DOM 树完全构建完成,它就会执行。

如果我们看一下 Performance 面板,FPFCP 会尽快发生(隐藏在 Timings 标签后面),因为浏览器一有 HTML 内容就开始建立 DOM 树,因此可以在屏幕上渲染一些像素。

LCP 发生在 5 秒后,因为阻塞解析器的脚本已经阻断了 5 秒的 DOM 解析(它的下载时间),当 DOM 解析器被阻断时,屏幕上只呈现了 30 个文本元素,这不足以被称为最大的内容绘制(根据 Google Chrome 标准)。但是一旦下载并执行该脚本,DOM 解析就恢复了,并且在屏幕上呈现了大量的内容,这导致了 LCP 事件的触发。

Parser-blocking 也被称为 render-blocking,因为除非 DOM 树被构造出来,否则渲染是不会发生的,但这两者是完全不同的事情,我们稍后会看到

一些浏览器可能会包含一个推测性解析策略,即 HTML 解析(但不包括 DOM 树的构建)被挂载到一个单独的线程中,这样浏览器就可以读取链接(CSS)、script、img 等元素,并更早地下载这些资源。

这对于以下这种情况来说是很有用的,比如你有三个紧贴的 script 元素,但由于 DOM 解析器无法读取第二个 script 元素,所以在第一个 script 下载完毕之前,浏览器无法开始下载第二个 script。我们可以通过使用 async 标签轻松解决这个问题,但是异步脚本不能保证按顺序执行。

之所以叫推测性解析,是因为浏览器在做一个推测,预计未来会加载某个资源,所以最好现在就在后台加载。但是,如果某些 JavaScript 操作 DOM,或用外部资源删除/隐藏元素,那么推测就失败了,这些文件就白白加载了。

每个浏览器都有自己的规范,所以不能保证何时或是否会发生推测性解析。不过,你可以使用 <link rel="preload"> 元素要求浏览器提前加载一些资源。

渲染阻塞型 CSS

正如我们所了解到的,除了阻塞解析器的 script 文件外,任何外部资源请求都不会阻塞 DOM 解析过程。因此,CSS(包括嵌入式)不会直接阻塞 DOM 解析器。等等,是的,CSS 可以阻止 DOM 解析,但在此之前,我们需要了解渲染过程。

浏览器内部的浏览器引擎使用从服务器接收到的 HTML 内容作为文本文档来构建 DOM 树。同样,它也会根据外部 CSS 文件或 HTML 中嵌入(以及内联)的 CSS 样式表内容来构建 CSSOM 树。

DOM 和 CSSOM 树的构建都发生在主线程上,而且这些树的构建是同时进行的。它们共同构成了用于在屏幕上打印东西的 Render Tree,而 Render Tree 也随着 DOM 树的构建而逐步构建。

我们已经了解到,DOM 树的生成是增量的,这意味着当浏览器读取 HTML 时,它会将 DOM 元素添加到 DOM 树中。但 CSSOM 树却不是这样。与 DOM 树不同,CSSOM 树的构建不是递增的,必须以特定的方式进行。

当浏览器找到 <style> 块时,它会解析所有嵌入的 CSS,并以新的 CSS (样式)规则更新 CSSOM 树。之后,它将继续以正常方式解析 HTML。内联样式也是如此。

然而,当浏览器遇到一个外部样式表文件时,事情就会发生巨大的变化。与外部 script 文件不同的是,外部样式表文件不是解析器阻塞型的资源,因此浏览器可以在后台静默地下载它,DOM 解析也会继续进行。

但与 HTML 文件(用于 DOM 构建)不同,浏览器不会一个字节一个字节地处理样式表文件内容。这是因为浏览器在读取 CSS 内容时,不能增量地构建 CSSOM 树。原因是,文件最后的 CSS 规则可能会覆盖写在文件顶部的 CSS 规则。

因此,如果浏览器在解析样式表内容时开始递增地构建 CSSOM,就会导致渲染树的多次渲染,因为样式覆盖规则会使同样的 CSSOM 节点,因后面新出现的样式表文件而导致更新。当 CSS 被解析时,可以在屏幕上看到元素样式的改变,这将是一种不愉快的用户体验。由于 CSS 样式是层叠的,一个规则的改变可能会影响许多元素。

因此,浏览器不会逐步处理外部 CSS 文件,CSSOM 树更新是在样式表中所有 CSS 规则处理完毕后一次性完成的。CSSOM 树更新完成后,再更新渲染树,然后渲染到屏幕上。

CSS 是一种渲染阻塞型资源。一旦浏览器提出获取外部样式表的请求,Render Tree 的构建就会停止。因此,关键渲染路径(CRP)也被卡住了,没有任何东西被渲染到屏幕上,如下图所示。然而,在后台下载样式表时,DOM 树的构建仍在进行中。

浏览器可以使用 CSSOM 树的旧状态来生成 Render Tree,因为 HTML 正在被解析,以递增的方式在屏幕上呈现事物。但这有一个巨大的缺点。在这种情况下,一旦样式表被下载和解析,CSSOM 被更新,Render Tree 就会被更新并呈现在屏幕上。现在,用旧 CSSOM 生成的 Render Tree 节点将重绘新的样式,这也可能导致 Flash of Unstyled Content (FOUC),这对用户体验非常不利。

因此,浏览器会等到样式表被加载和解析。一旦样式表被解析,CSSOM 被更新,Render Tree 就会被更新,CRP 就会继续进行,从而使 Render Tree 绘制在屏幕上。由于这个原因,建议尽早加载所有外部样式表。

让我们想象一下这样一个场景:浏览器已经开始解析 HTML,并且遇到了一个外部样式表文件。它将在后台开始下载文件,阻塞 CRP,并继续进行 DOM 解析。但是它又遇到了一个 script 标签,于是它就会在后台开始下载外部脚本文件,并阻止 DOM 解析。现在浏览器是坐等样式表和脚本文件完全下载完毕。

但这次外部脚本文件已经完全下载完毕,而样式表还在后台下载。浏览器要不要执行这个脚本文件?这样做有什么危害吗?

我们知道,CSSOM 提供了一个高级的 JavaScript API 来与 DOM 元素的样式进行交互。例如,你可以使用 elem.style.backgroundColor 属性来读取或更新一个 DOM 元素的背景颜色。与 elem 元素相关联的样式对象暴露了 CSSOM 的 API,还有很多其他的 API 可以做同样的事情(请阅读这篇 css-tricks 文章)。

当一个样式表被后台下载时,JavaScript 仍然可以执行,因为主线程没有被加载的样式表所阻挡。如果我们的 JavaScript 程序访问 DOM 元素的 CSS 属性(通过 CSSOM API),我们会得到一个合适的值(根据 CSSOM 的当前状态)。

但是一旦样式表被下载和解析,导致 CSSOM 更新,我们的 JavaScript 现在有一个过时的元素的 CSS 值,因为新的 CSSOM 更新可能已经改变了该 DOM 元素的 CSS 属性。由于这个原因,在下载样式表的时候执行 JavaScript 是不安全的。

根据 HTML5 规范,浏览器可以下载一个脚本文件,但不会执行它,除非之前所有的样式表都被解析了。当一个样式表阻止脚本的执行时,它被称为脚本阻塞型样式表(script-blocking stylesheet)或脚本阻塞型 CSS(script-blocking CSS)。

在上面的例子中,script-blocking.html 包含了一个link 标签(用于外部的样式表),后面是一个 script 标签(用于外部 JavaScript)。这里的 script 下载速度非常快,没有任何延迟,但样式表需要 6 秒才能下载完。因此,尽管我们可以从网络面板上看到,脚本已经完全下载,但浏览器并没有立即执行。只有在样式表加载后,我们看到脚本打印的 Hello World 消息。

就像 asyncdefer 属性使 script 元素不阻塞解析一样,外部的样式表也可以通过 media 属性使其不阻塞渲染。使用 media 属性值,浏览器可以智能地决定何时去加载样式表

文档的 DOMContentLoader 事件

DOMContentLoaded(DCL)事件标志着,浏览器从所有可用的 HTML 中,构建出了一个完整的DOM树的时间点。但 DCL 事件被触发时,有很多涉及到的因素会发生变化。

document.addEventListener("DOMContentLoaded", function (e) {
  console.log("DOM is fully parsed!");
});
复制代码

如果我们的 HTML 中不包含任何脚本,DOM 解析就不会被阻塞,DCL 就会随着浏览器解析完整个 HTML 而触发。如果我们有解析器阻塞型脚本,那么 DCL 必须等待所有解析器阻塞型脚本被下载并执行。

当样式表被扔进页面时,事情就变得有点复杂了。即使你没有外部脚本,DCL 也会等到所有样式表被加载。因 DCL 标志着整个 DOM 树已经准备好的时间点,但是在 CSSOM 也被完全构建之前,访问 DOM 都是不太安全的(对于样式信息)。因此,大多数浏览器都会等到所有外部样式表被加载和解析。

脚本阻塞型样式表显然会延迟 DCL。在这种情况下,由于脚本在等待样式表的加载,DOM 树没有被构造出来。

DCL 是网站性能指标之一。我们应该优化 DCL,使其尽可能的小(发生的时间)。其中一个最好的做法是尽可能地使用 deferasync 标签来处理 script 元素,这样当脚本在后台下载时,浏览器可以执行其他事情。其次,我们应该优化脚本阻塞型和渲染阻塞型的样式表。

窗口的 load 事件

我们知道 JavaScript 可以阻止 DOM 树的生成,但是对于外部的样式表和文件,如图片、视频等就不是这样了。

DOMContentLoaded 事件标志着 DOM 树已经完全构造完成,可以安全访问,window.onload 事件标志着外部样式表和文件下载完毕,我们的Web应用已经完成下载的时间点。

window.addEventListener( 'load', function(e) {
  console.log( 'Page is fully loaded!' );
} )
复制代码

1618839528167.png

在上面的例子中,rendering.html 文件的头部有一个外部样式表,下载时间大约为5秒。由于它在头部部分,FPFCP 发生在5秒之后,因为样式表会阻止它下面的任何内容的渲染(也就是它阻止了CRP)。

在这之后,我们有一个 img 元素,加载一个需要10秒左右下载的图片。所以浏览器会在后台一直下载这个文件,然后继续进行 DOM 的解析和渲染(因为外部图片资源既不阻挡解析器,也不阻挡渲染)。

接下来,我们有三个外部 JavaScript 文件,它们的下载时间分别为 3s、6s 和 9s,最重要的是,它们不是异步的。这意味着总的加载时间应该接近 18秒,因为在前一个脚本执行之前,后续的脚本不会开始下载。然而,从 DCL 事件来看,我们的浏览器似乎采用了推测性策略,预先下载脚本文件,所以总加载时间接近 9秒。

由于能够影响 DCL 的最后一个下载的文件是最后一个脚本文件,加载时间为9秒(因为样式表已经在 5 秒内下载完毕),所以DCL事件发生在 9.1 秒左右。

我们还有一个外部资源是图片文件,它一直在后台加载。当它完全下载完毕后(需要 10 秒),窗口的 load 事件在10.2秒后被启动,这标志着网页(应用程序)已经完全加载完毕。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享