页面填充:浏览器的工作原理

用户希望 Web 体验能够快速加载内容并流畅交互。因此,开发人员应努力实现这两个目标。

要了解如何改进性能和感知性能,有助于了解浏览器的工作原理。

概述

快速的网站提供更好的用户体验。用户希望并期望 Web 体验能够快速加载内容并流畅交互。

Web 性能中的两个主要问题是与延迟相关的问题以及与浏览器在大多数情况下是单线程相关的问题。

延迟是我们确保页面快速加载能力的最大威胁。开发人员的目标是使网站尽可能快地加载——或者至少 *看起来* 加载得非常快——以便用户能够尽快获取所需的信息。网络延迟是指通过空中传输字节到计算机所需的时间。Web 性能是我们必须做的事情,以使页面尽可能快地加载。

在大多数情况下,浏览器被认为是单线程的。也就是说,它们在执行另一个任务之前会从头到尾执行一个任务。为了实现流畅的交互,开发人员的目标是确保网站交互的性能,从流畅的滚动到对触摸的响应。渲染时间是关键,确保主线程可以完成我们赋予它的所有工作,并且始终可以处理用户交互。通过了解浏览器的单线程特性并尽可能地减少主线程的职责,可以提高 Web 性能,从而确保渲染流畅,对交互的响应即时。

导航是加载网页的第一步。它发生在用户通过在地址栏中输入 URL、点击链接、提交表单以及其他操作请求页面时。

Web 性能的目标之一是最小化导航完成所需的时间。在理想条件下,这通常不会花费太长时间,但延迟和带宽是可能导致延迟的敌人。

DNS 查询

导航到网页的第一步是找到该页面的资源所在的位置。如果您导航到 https://example.com,则 HTML 页面位于 IP 地址为 93.184.216.34 的服务器上。如果您从未访问过此网站,则必须进行 DNS 查询。

您的浏览器请求 DNS 查询,该查询最终由名称服务器处理,然后名称服务器会响应一个 IP 地址。在此初始请求之后,IP 可能会被缓存一段时间,这可以通过从缓存中检索 IP 地址而不是再次联系名称服务器来加快后续请求的速度。

DNS 查询通常每个主机名只需要执行一次页面加载。但是,必须为请求的页面引用的每个唯一主机名执行 DNS 查询。如果您的字体、图像、脚本、广告和指标都具有不同的主机名,则必须为每个主机名进行 DNS 查询。

Mobile requests go first to the cell tower, then to a central phone company computer before being sent to the internet

这可能会对性能造成问题,尤其是在移动网络上。当用户使用移动网络时,每个 DNS 查询都必须从手机到蜂窝塔才能到达权威 DNS 服务器。手机、蜂窝塔和名称服务器之间的距离可能会增加很大的延迟。

TCP 握手

一旦知道 IP 地址,浏览器就会通过 TCP 三次握手 建立与服务器的连接。这种机制的设计是为了让两个试图通信的实体——在本例中是浏览器和 Web 服务器——在传输数据之前协商网络 TCP 套接字连接的参数,通常通过 HTTPS 进行。

TCP 的三次握手技术通常称为“SYN-SYN-ACK”——或更准确地说是 SYN、SYN-ACK、ACK——因为 TCP 传输了三个消息来协商并在两台计算机之间启动 TCP 会话。是的,这意味着在每个服务器之间来回传输三个消息,并且请求尚未发出。

TLS 协商

对于通过 HTTPS 建立的安全连接,需要另一个“握手”。这个握手,或者更准确地说,TLS 协商,确定将使用哪个密码来加密通信,验证服务器,并在开始实际数据传输之前建立安全连接。这需要在实际发送内容请求之前再进行五次往返服务器。

The DNS lookup, the TCP handshake, and 5 steps of the TLS handshake including clienthello, serverhello and certificate, clientkey and finished for both server and client.

虽然建立安全连接会增加页面加载时间,但安全连接值得延迟开销,因为浏览器和 Web 服务器之间传输的数据不会被第三方解密。

在进行了八次往返服务器之后,浏览器终于能够发出请求了。

响应

一旦我们与 Web 服务器建立了连接,浏览器就会代表用户发送初始 HTTP GET 请求,对于网站来说,这通常是一个 HTML 文件。服务器收到请求后,将回复相关的响应头和 HTML 内容。

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8" />
    <title>My simple page</title>
    <link rel="stylesheet" href="styles.css" />
    <script src="myscript.js"></script>
  </head>
  <body>
    <h1 class="heading">My Page</h1>
    <p>A paragraph with a <a href="https://example.com/about">link</a></p>
    <div>
      <img src="myimage.jpg" alt="image description" />
    </div>
    <script src="anotherscript.js"></script>
  </body>
</html>

此初始请求的响应包含接收到的第一个数据字节。第一个字节时间 (TTFB) 是用户发出请求(例如,通过点击链接)到收到此第一个 HTML 数据包之间的时间。第一块内容通常是 14KB 的数据。

在我们上面的示例中,请求肯定小于 14KB,但链接的资源直到浏览器在解析过程中遇到链接时才会被请求,如下所述。

拥塞控制 / TCP 慢启动

TCP 数据包在传输过程中被分割成段。由于 TCP 保证数据包的顺序,因此服务器必须在发送一定数量的段后,从客户端以 ACK 数据包的形式接收确认。

如果服务器在每个段之后等待 ACK,这将导致客户端频繁发送 ACK,并可能增加传输时间,即使是在网络负载较低的情况下。

另一方面,一次发送太多段会导致一个问题,即在繁忙的网络中,客户端将无法接收段,并且只会长时间响应 ACK,服务器将不得不继续重新发送段。

为了平衡传输段的数量,使用 TCP 慢启动 算法逐渐增加传输数据量,直到确定最大网络带宽,并在网络负载较高时减少传输数据量。

要传输的段数由拥塞窗口 (CWND) 的值控制,该值可以初始化为 1、2、4 或 10 MSS(MSS 在以太网协议上为 1500 字节)。该值是要发送的字节数,在接收到该值后,客户端必须发送 ACK。

如果收到 ACK,则 CWND 值将加倍,因此服务器下次可以发送更多段。如果未收到 ACK,则 CWND 值将减半。因此,该机制在发送过多段和发送过少段之间取得了平衡。

解析

浏览器收到第一块数据后,就可以开始解析接收到的信息了。解析 是浏览器将通过网络接收到的数据转换为 DOMCSSOM 的步骤,渲染器使用它将页面绘制到屏幕上。

DOM 是浏览器对标记的内部表示。DOM 也可以通过 JavaScript 中的各种 API 公开和操作。

即使请求的页面的 HTML 大于初始的 14KB 数据包,浏览器也会开始解析并尝试基于它拥有的数据渲染体验。这就是为什么 Web 性能优化需要包含浏览器开始渲染页面所需的一切,或者至少是页面的模板——第一次渲染所需的 CSS 和 HTML——在第一个 14KB 中。但在将任何内容渲染到屏幕之前,必须解析 HTML、CSS 和 JavaScript。

构建 DOM 树

我们在 关键渲染路径 中描述了五个步骤。

第一步是处理 HTML 标记并构建 DOM 树。HTML 解析涉及标记化和树构建。HTML 标记包括开始和结束标签,以及属性名称和值。如果文档格式良好,则解析它很简单且更快。解析器将标记化的输入解析到文档中,构建文档树。

DOM 树描述了文档的内容。<html> 元素是文档树的第一个元素和根节点。该树反映了不同元素之间的关系和层次结构。嵌套在其他元素中的元素是子节点。DOM 节点数越多,构建 DOM 树所需的时间就越长。

The DOM tree for our sample code, showing all the nodes, including text nodes.

当解析器找到非阻塞资源(例如图像)时,浏览器将请求这些资源并继续解析。遇到 CSS 文件时可以继续解析,但<script> 元素(特别是那些没有asyncdefer 属性的元素)会阻止渲染并暂停 HTML 的解析。尽管浏览器的预加载扫描器加快了此过程,但过多的脚本仍然可能是一个重大的瓶颈。

预加载扫描器

在浏览器构建 DOM 树的同时,此过程会占用主线程。在此过程中,预加载扫描器将解析可用的内容并请求高优先级资源,例如 CSS、JavaScript 和网络字体。由于预加载扫描器,我们不必等到解析器找到对外部资源的引用才能请求它。它将在后台检索资源,以便当主 HTML 解析器到达请求的资产时,它们可能已经在传输中或已下载。预加载扫描器提供的优化减少了阻塞。

html
<link rel="stylesheet" href="styles.css" />
<script src="myscript.js" async></script>
<img src="myimage.jpg" alt="image description" />
<script src="anotherscript.js" async></script>

在此示例中,当主线程解析 HTML 和 CSS 时,预加载扫描器将找到脚本和图像,并开始下载它们。为了确保脚本不会阻塞该过程,请添加async 属性,或者如果 JavaScript 解析和执行顺序很重要,则添加defer 属性。

等待获取 CSS 不会阻塞 HTML 解析或下载,但会阻塞 JavaScript,因为 JavaScript 通常用于查询 CSS 属性对元素的影响。

构建 CSSOM 树

关键渲染路径的第二步是处理 CSS 并构建 CSSOM 树。CSS 对象模型类似于 DOM。DOM 和 CSSOM 都是树。它们是独立的数据结构。浏览器将 CSS 规则转换为它可以理解和使用的样式映射。浏览器遍历 CSS 中的每个规则集,根据 CSS 选择器创建具有父子和兄弟关系的节点树。

与 HTML 一样,浏览器需要将接收到的 CSS 规则转换为它可以使用的内容。因此,它重复了 HTML 到对象的处理过程,但用于 CSS。

CSSOM 树包含来自用户代理样式表的样式。浏览器从适用于节点的最通用规则开始,并通过应用更具体的规则递归地细化计算出的样式。换句话说,它级联属性值。

构建 CSSOM 非常快,并且在当前的开发者工具中没有以独特的颜色显示。相反,开发者工具中的“重新计算样式”显示了解析 CSS、构建 CSSOM 树和递归计算计算样式所需的时间。在 Web 性能优化方面,有一些更简单的成果,因为创建 CSSOM 的总时间通常小于进行一次 DNS 查询所需的时间。

其他进程

JavaScript 编译

在 CSS 被解析和 CSSOM 被创建的同时,其他资产(包括 JavaScript 文件)正在下载(感谢预加载扫描器)。JavaScript 被解析、编译和解释。脚本被解析成抽象语法树。一些浏览器引擎获取抽象语法树 并将其传递给编译器,输出字节码。这称为 JavaScript 编译。大部分代码是在主线程上解释的,但也有例外,例如在Web Worker中运行的代码。

构建可访问性树

浏览器还会构建一个可访问性树,辅助设备使用该树来解析和解释内容。可访问性对象模型 (AOM) 就像 DOM 的语义版本。DOM 更新时,浏览器会更新可访问性树。辅助技术本身无法修改可访问性树。

在构建 AOM 之前,内容无法被屏幕阅读器访问。

渲染

渲染步骤包括样式、布局、绘制以及在某些情况下进行合成。在解析步骤中创建的 CSSOM 和 DOM 树组合成渲染树,然后使用该树计算每个可见元素的布局,然后将其绘制到屏幕上。在某些情况下,内容可以提升到自己的图层并进行合成,通过在 GPU 而不是 CPU 上的执行线程上绘制屏幕的部分内容来提高性能,从而释放主线程。

样式

关键渲染路径的第三步是将 DOM 和 CSSOM 组合成渲染树。计算出的样式树或渲染树的构建从 DOM 树的根开始,遍历每个可见节点。

不会显示的元素,例如<head> 元素及其子元素以及任何具有display: none 的节点(例如,您会在用户代理样式表中找到的script { display: none; })不包含在渲染树中,因为它们不会出现在渲染输出中。应用了visibility: hidden 的节点包含在渲染树中,因为它们确实占用空间。由于我们没有给出任何指令来覆盖用户代理默认值,因此我们上面代码示例中的script 节点将不包含在渲染树中。

每个可见节点都应用了其 CSSOM 规则。渲染树保存所有具有内容和计算样式的可见节点——将所有相关样式与 DOM 树中的每个可见节点匹配,并根据CSS 级联确定每个节点的计算样式。

布局

关键渲染路径的第四步是在渲染树上运行布局以计算每个节点的几何形状。布局是确定渲染树中所有节点的尺寸和位置的过程,以及确定页面上每个对象的大小和位置。重排是随后对页面或整个文档的任何部分进行的任何尺寸和位置确定。

构建渲染树后,布局开始。渲染树识别哪些节点显示(即使不可见)以及它们的计算样式,但没有识别每个节点的尺寸或位置。为了确定每个对象的确切大小和位置,浏览器从渲染树的根开始并遍历它。

在网页上,几乎所有东西都是一个盒子。不同的设备和不同的桌面首选项意味着无限数量的不同视口大小。在此阶段,考虑到视口大小,浏览器确定屏幕上所有不同框的大小。以视口大小为基础,布局通常从主体开始,布局主体所有后代的大小,每个元素的盒子模型属性为替换元素提供占位符空间,它不知道这些元素的尺寸,例如我们的图像。

第一次确定每个节点的大小和位置称为布局。随后的重新计算称为重排。在我们的示例中,假设初始布局发生在图像返回之前。由于我们没有声明图像的尺寸,因此一旦知道图像尺寸,就会发生重排。

绘制

关键渲染路径的最后一步是将各个节点绘制到屏幕上,第一次出现称为首次内容绘制。在绘制或光栅化阶段,浏览器将布局阶段中计算的每个框转换为屏幕上的实际像素。绘制涉及将元素的每个视觉部分绘制到屏幕上,包括文本、颜色、边框、阴影和替换元素(如按钮和图像)。浏览器需要非常快地执行此操作。

为了确保平滑滚动和动画,占用主线程的所有内容,包括计算样式以及重排和绘制,都必须在浏览器中花费不到 16.67 毫秒才能完成。在 2048 x 1536 时,iPad 有超过 3,145,000 个像素需要绘制到屏幕上。需要非常快地绘制大量像素。为了确保重新绘制的速度甚至比初始绘制更快,屏幕绘制通常分解成几个图层。如果发生这种情况,则需要合成。

绘制可以将布局树中的元素分解成图层。将内容提升到 GPU 上的图层(而不是 CPU 上的主线程)可以提高绘制和重新绘制性能。有一些特定的属性和元素会实例化图层,包括<video><canvas>,以及任何具有 CSS 属性opacity、3D transformwill-change 和其他一些属性的元素。这些节点将绘制到它们自己的图层上,以及它们的后代,除非后代由于上述一个(或多个)原因需要它自己的图层。

图层确实可以提高性能,但在内存管理方面成本很高,因此不应在 Web 性能优化策略中过度使用。

合成

当文档的各个部分在不同的图层中绘制,相互重叠时,需要进行合成以确保它们按正确的顺序绘制到屏幕上,并且内容正确呈现。

随着页面继续加载资产,可能会发生重排(回想一下我们延迟到达的示例图像)。重排会引发重新绘制和重新合成。如果我们定义了图像的尺寸,则无需重排,并且只需要重新绘制需要重新绘制的图层,并在必要时进行合成。但我们没有包含图像尺寸!从服务器获取图像后,渲染过程会返回到布局步骤并从那里重新开始。

交互性

一旦主线程完成页面绘制,您可能会认为我们“一切就绪”。情况并非总是如此。如果加载包括 JavaScript,并且已正确延迟,并且仅在onload 事件触发后执行,则主线程可能很忙,并且无法用于滚动、触摸和其他交互。

页面交互时间 (TTI) 是衡量从初始请求(导致 DNS 解析和 TCP 连接)到页面可交互之间的时间。可交互是指在首次内容绘制之后,页面在 50 毫秒内响应用户交互的时间点。如果主线程忙于解析、编译和执行 JavaScript,则它不可用,因此无法及时(少于 50 毫秒)响应用户交互。

例如,图像可能加载很快,但 anotherscript.js 文件可能为 2MB,并且用户的网络连接速度很慢。在这种情况下,用户会很快看到页面,但在脚本下载、解析和执行完成之前,无法流畅滚动。这并非良好的用户体验。避免占用主线程,如 WebPageTest 示例所示。

The main thread is occupied by the downloading, parsing and execution of a JavaScript file - over a fast connection

在本例中,JavaScript 执行花费了超过 1.5 秒,并且主线程在此期间完全被占用,无法响应点击事件或屏幕点击。

另请参阅