一文带你了解浏览器是如何渲染页面的

314 阅读14分钟

这篇文章,试图用非常简单的方式,向你阐述浏览器是如何一步步将 HTML、CSS、JavaScript 文件转换成可交互网站的。

了解这些过程,将使你能够优化你的 Web 应用程序,以获得更快的速度和性能。开始吧!

前提

浏览器究竟是如何渲染网站的?我们很快会拆解这个过程,但首先,回顾一下一些重要的基本概念。

浏览器是什么?

浏览器其实就是一个可以从网络(或本地磁盘)加载文件并显示给你,允许你与其交互的软件。

浏览器引擎是什么?

在浏览器中,有个东西可以根据接收到的文件来确定要向你显示什么。这个东西被称为浏览器引擎。

浏览器引擎是每个主流浏览器的核心组件,不同的浏览器制造商对其引擎的称呼不同。Firefox 浏览器引擎名为 Gecko,Chrome 浏览器引擎称为 Blink(WebKit 的一个分支)。如果你感兴趣的话,你可以看看各种浏览器引擎的对比。不要让这些名字混淆你 —— 它们只是个名字。

为了便于说明,下文中所提到的浏览器引擎仅仅是一个普普通通,最基本的浏览器引擎,并不指代某个具体的浏览器。

并且会交替使用“浏览器”和“浏览器引擎”,不要让这些文字困惑你,重要的是你知道浏览器引擎是负责我们讨论的关键软件就可以了。

向浏览器发送和接收信息

这篇文章不是有关计算机网络科学的课程,但你需要知道,数据是由 01 组成的字节包的形式在互联网上传输的。

当你编写了一些 HTML、CSS 和 JS 文件,并试图在浏览器中打开这些文件时,浏览器会从网络(或本地硬盘)中以**原始字节(raw bytes)**的形式读取这些文件,而并不是你实际所编写的代码,我们继续。

浏览器接收到字节数据,但实际上无法对其执行任何操作。因此,必须先将原始字节的数据转换成浏览器能识别的形式。

从 HTML 的原始字节到 DOM

浏览器需要什么样的对象?那便是文档对象模型(DOM —— Document Object Model)。那么,DOM 又是怎么来的呢?我们一步步来看。

字节到字符

首先,将原始字节转换为字符,如图:

byte2character.svg

该转(解)换(码)方式是基于你所编写的 HTML 文件中设置的文件编码,如 <meta charset="UTF-8" /> 的字符集声明。

此时,浏览器已经成功将文件的原始字节数据转换为实际的字符形式。

字符到标记

字符已经很好了,不过,这还不是浏览器最终所需要的。这些字符还会被进一步处理,解析成某种被称为标记的东西。如图:

byte2token.svg

那么,标记(Token) 又是什么呢?

一堆字符的文本文件对浏览器引擎没有什么好处,如果没有进行标记化过程(tokenization process),这些字符只是一些毫无意义的文本,仅仅是 HTML 代码而已,并不会生成一个真实的网站。

当打开 .html 扩展名的文件的时,会向浏览器引擎发出将该文件解释为 HTML 文档的信号。浏览器解释这个文件的方式,首先是解析它。在解析过程中,尤其是在标记化过程中,文件中的每个起始和结束 HTML 标签(tag)都会被考虑在内。

解析器可以理解每个尖括号中的字符单词(例如,<html><p>),同时也理解适用于每个标记的一组规则属性。例如,表示锚点的标签 <a> 与表示段落的标签 <p>将具有不同的规则属性。

从概念上讲,你可以将标记视为一种包含相关 HTML 标签信息的数据结构。一个 HTML 文件会被分解为很多标记解析单元。此时,浏览器才能够理解 HTML 中的代码。如图:

character2token.svg

标记到节点

标记也很不错,但其也不是浏览器最终需要的结果。在标记化完成后,这些标记会被转换为节点(Node)。可以把节点看作具有特定属性的独立的对象(Object)。事实上,更好的解释方式是将节点视为文档对象树中的独立实体。

节点到 DOM

节点也很不错,但它们仍然不是最终结果。现在,到了最后一步。在创建这些节点后,它们会被链接到一个称为文档对象模型(DOM)的树形数据结构中。通过 DOM 建立了父子关系、相邻关系等。节点与节点之间的的关系都会在 DOM 上确立与体现

现在,浏览器得到了它最终想要的结果 —— 文档对象模型(DOM),结构类似于下图:

node2dom.svg

小结

在处理其他事情之前(如执行 JavaScript 代码),浏览器首(必)先(须)要做的就是将 HTML 文件从原始字节数据转换为 DOM,这个过程通常被称为 DOM 构建。根据 HTML 文件的大小,构建 DOM 的过程总是需要花费一些时间的,即使文件再小也不例外。构建 DOM 的流程如下图:

byte2dom.svg

等等,那么 CSS 呢?

构建 DOM 的过程已经讲解完毕,那么 CSS 呢?一个常规的 HTML 文件会包含一些 CSS 样式表的连接,如下:

<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" type="text/css" media="screen" href="main.css" />
</head>
<body>
</body>
</html>

当浏览器接收到 HTML 的原始字节并启动 DOM 构建过程后,一旦发现有指向 CSS 文件的链接标签,就会同时发出请求来获取该文件。

正如你可能猜到的,无论是从网络还是本地磁盘,浏览器接收到的 CSS 数据同样为原始字节(raw bytes),那么又该如何处理这些 CSS 原始字节数据呢?

从 CSS 原始数据到 CSSOM

当浏览器接收到 CSS 的原始字节时,也会启动一个类似于处理 HTML 原始字节的过程。

换句话说,原始字节数据会被转换为字符,然后进行标记化,接着会形成节点,最终形成一个树形结构。

大多数人知道有一个叫做 DOM 的东西。同样,也有一个 CSS 的树形结构,叫做 CSS 对象模型(CSSOM —— CSS Object Model)

构建 CSSOM 的流程与 DOM 类似,如下图:

css2cssom.svg

CSS 有一个称为“层叠(cascade)”的概念。层叠确定了浏览器该如何将哪些样式应用于一个元素。因为影响一个元素的样式可能来自父元素(即通过继承),或者直接在元素上设置,所以使得 CSSOM 的树形构建同样重要。浏览器必须递归地遍历 CSS 的树形结构,并确定影响特定元素的样式。

目前为止,一切都很顺利。浏览器有了 DOM 和 CSSOM。现在可以在屏幕上渲染出一些内容了吗?

渲染树

现在,我们拥有两个独立的树形结构:

  • DOM:包含有关页面 HTML 元素关系的所有信息;

  • CSSOM:包含有关元素样式的信息;

浏览器将 DOM 和 CSSOM 树结合成一个称为**渲染树(Render Tree)**的数据结构。

渲染树包含页面上所有可见的 DOM 内容的信息,以及不同节点所需的所有 CSSOM 信息。请注意,如果一个元素被 CSS 隐藏(例如使用 display: none;),那么该节点将不会出现在渲染树中。

构建完成渲染树后,浏览器会继续进行下一步:布局(layout)

布局(数学家)

构建完渲染树后,下一步就是执行页面布局(有时也会听到回流 reflow 一词)。现在,我们有了需要在屏幕上渲染的内容与样式,但实际上还没有将任何内容渲染到屏幕上。

首先,浏览器必须计算出需要在屏幕上渲染的每个对象的确切大小和位置。这就像将所有要在页面上呈现的元素的内容和样式信息传递给一个“数学家”。这位数学家根据浏览器的视窗(viewport)大小准确计算出每个元素的确切位置和大小。

渲染(艺术家)

既然已经计算出每个元素的确切位置与大小,剩下的就是将这些元素“绘制”到屏幕上。想一想:我们已经拥有在屏幕上实际显示这些元素所需的所有信息。让我们把它展示给用户吧,对吗?

没错!这正是这个阶段的全部内容。在计算出内容(DOM)、样式(CSSOM)和元素的确切布局信息后,浏览器现在将各个节点“绘制”到屏幕上。最后,元素终于渲染到屏幕上了!

会阻塞渲染的资源

当你听到渲染阻塞时会想到什么?

是 “某些东西阻止了在屏幕上绘制节点” 么?如果你这么说,那么你回答正确了!

在成功渲染页面之前,必须先构建 DOM 和 CSSOM,因此 HTML 和 CSS 都是会阻塞渲染的资源。所以优化网站的第一条规则:尽快将 HTML 和 CSS 传递给客户端,以优化应用程序的首次渲染时间

等等,那 JavaScript 呢?

一个不错的 Web 应用程序肯定会使用一些 JavaScript,这是毋庸置疑的。

JavaScript 的“问题”在于,可以使用它来修改页面的内容和样式。这意味着可以从 DOM 树中添加或移除元素,还可以修改元素的 CSSOM 属性。

这很棒!然而,它也有一定的代价。考虑以下 HTML 文档:

<!DOCTYPE html>
<html>

<head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>示例</title>
    <link rel="stylesheet" href="style.css">
</head>

<body>
    <p id="header">浏览器是如何渲染的</p>
</body>

</html>

style.css 文件非常简单,只有简简单单的一个声明:

body {
  background: #8cacea;
}

屏幕上渲染了一个简单的文本。根据前面所讲解的,浏览器从网络或本地读取 HTML 文件的原始字节,并将其转换为字符。这些字符进一步解析成标记。

当解析器读取到 <link rel="stylesheet" href="style.css"> 这一行时,便会请求获取 style.css 样式文件。DOM 构建继续进行,一旦 CSS 文件获取成功并返回内容,同时,CSSOM 构建便开始。

引入 JavaScript 后,这个流程会发生什么变化呢?最重要的一点请你记住:每当浏览器遇到一个 <script> 标签时,DOM 会立刻暂停构建,直到脚本下载并执行完毕,才会继续构建 DOM

这是因为 JavaScript 可以修改 DOM 和 CSSOM。因为浏览器无法确定某段 JavaScript 代码会做什么,所以为了安全起见,会完全暂停整个 DOM 构建过程。

这会带来多大的影响呢?让我们来看看。

在上面的 HTML 示例中加入一点 JavaScript 代码:

<head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>示例</title>
    <link rel="stylesheet" href="style.css">
</head>

<body>
    <p id="header">浏览器是如何渲染的</p>
    <script>
        let header = document.getElementById("header");
        console.log("header is: ", header);
    </script>
</body>

</html>

<script> 标签内部,获取了 idheader 的 DOM 节点,并向控制台打印了这个节点。结果如下:

screenshot_1.png

然而,你是否注意到这个 <script> 标签被放置在 <body> 标签的底部?让我们将它放在 <head> 标签中看看会发生什么:

<head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>示例</title>
    <link rel="stylesheet" href="style.css">
    <script>
        let header = document.getElementById("header");
        console.log("header is: ", header);
    </script>
</head>

<body>
    <p id="header">浏览器是如何渲染的</p>
</body>

</html>

当放在 <head> 标签中时,日志的输出结果为 null

screenshot_2.png

为什么?非常简单。

当在解析 HTML 时,先遇到 <script> 标签,暂停了 DOM 的构建,并下载与运行脚本。此时 <body> 标签及其所有内容尚未解析,所以节点不存在,输出 null

这引出了另一个重点:编写代码时,你的 JavaScript 脚本的位置很重要

而且不仅如此。如果将内联脚本提取到本地的一个外部 JavaScript 文件中,行为结果仍然相同。DOM 的构建仍然会停止,结果请自行尝试。

<head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>示例</title>
    <link rel="stylesheet" href="style.css">
    <!-- 本地地址 -->
    <script src="./app.js"></script>
    <!-- 远程服务器地址 -->
    <script src="https://some-link-to/app.js"></script>
</head>

<body>
    <p id="header">浏览器是如何渲染的</p>
</body>

</html>

只不过将内联脚本的内容剪切到 app.js 文件中:

let header = document.getElementById("header");
console.log("header is: ", header);

如果网络速度很慢,需要数千毫秒才能获取 app.js,那么 DOM 的构建也会因此而停止数千毫秒!这是一个很大的性能问题,但这还不是全部。

请记住,JavaScript 也可以访问 CSSOM 并对其进行操作。例如 document.getElementsByTagName("body")[0].style.backgroundColor = "red"; 这段 JavaScript 代码。

那么,当解析器遇到一个 <script> 标签,但 CSSOM 尚未准备好时会发生什么呢?

答案其实很简单:JavaScript 的执行将会暂停,直到 CSSOM 构建完毕

因此,当不存在 CSSOM 时,构建 DOM,遇到了 <script> 标签,DOM 的构建会暂停,执行 JS 的脚本。当存在 CSSOM 时,需要等 CSSOM 构建完成后,才会执行 JS 脚本。

关键渲染路径(CPR —— Critical Rendering path)

上面我们讨论了从浏览器接收 HTML、CSS 和 JavaScript 的原始字节数据,并将它们转换为屏幕上实际渲染的像素的步骤。

这整个过程被称为关键渲染路径(Critical Rendering Path, CRP)

优化网站性能主要就是通过是优化 CRP。一个优化良好的网站应该经历逐步渲染,而不是整个过程被阻塞。通过考虑资源的加载顺序与资源的加载优先级,来优化关键渲染路径(CRP)使浏览器能够尽快加载页面,从而提升用户体验。

异步 async 与 defer

默认情况下,每个脚本都会阻塞解析器,DOM 构建总是会被暂停。

不过,有两种方法可以改变这种默认行为,那便是使用 asyncdefer 属性。

本节内容翻译自 async-vs-defer-attributes

<script>

让我们首先定义一个没有任何属性的 <script> 标签。HTML 文件会被解析直到遇到 <script> 标签,此时解析过程会暂停,并且会发出获取文件的请求(如果是外部文件)。然后会执行该脚本,之后解析过程才会继续。如下时序图所示:

script-cn.svg

<script async>

async 会在 HTML 解析期间下载文件,并在下载完成后暂停 HTML 解析器来执行该脚本。如下时序图所示:

script-async-cn.svg

<script defer>

defer 会在 HTML 解析期间下载文件,并且只会在解析器完成解析后执行,但在触发 DOMContentLoaded 事件之前执行的(阻塞 DOMContentLoaded 事件触发)。defer 脚本还保证按照它们在文档中出现的顺序执行。如下时序图所示:

script-defer-cn.svg

什么时候应该使用哪个属性?

通常,你应该尽可能使用 async,其次是 defer,最后是不设置任何属性。以下是一些通用规则:

  • 如果脚本是模块化的,并且不依赖于任何其他脚本,那么使用 async
  • 如果脚本依赖于其他脚本或被其他脚本依赖,那么使用 defer
  • 如果脚本很小,并且被一个 async 脚本依赖,那么使用没有属性的内联脚本,并将其放在 async 脚本上方;

DOMContentLoaded 与 load

如果你认真的看完上面的内容,自然就会明白这两者的触发时机了,所以,趁热我引用下 MDN 对两者的说明。

load 事件

在整个页面及所有依赖资源如样式表和图片都已完成加载时触发。它与 DOMContentLoaded 不同,后者只要页面 DOM 加载完成就触发,无需等待依赖资源的加载

DOMContentLoaded 事件

当 HTML 文档完全解析(DOM 构建完成),且所有延迟脚本(<script defer src="…"><script type="module">)下载和执行完毕后,会触发 DOMContentLoaded 事件。不会等待图片、子框架和异步脚本等其他内容完成加载

DOMContentLoaded 不会等待样式表加载,但延迟脚本会等待样式表,而且 DOMContentLoaded 事件排在延迟脚本之后。

此外,非延迟或异步的脚本(如 <script>)将等待已解析的样式表加载(上面已经讲解过了)。

本文为原创内容,版权所有 © 2024 姚生。欢迎转载,但请注明出处,并附上原文链接。未经授权不得用于商业用途。如有疑问,请联系作者。