填充页面:浏览器如何工作
用户希望获得加载速度快、交互流畅的网页体验。因此,开发者应努力实现这两个目标。
要了解如何提高性能和感知性能,了解浏览器的工作原理会有所帮助。
概述
快速的网站提供更好的用户体验。用户希望并期待内容加载速度快、交互流畅的网页体验。
网页性能的两个主要问题是与延迟相关的问题,以及浏览器在大多数情况下是单线程的事实。
延迟是我们确保页面快速加载能力的最大威胁。开发者的目标是使网站尽可能快地加载——或者至少看起来加载得非常快——这样用户就能尽快获得所请求的信息。网络延迟是将字节通过空中传输到计算机所需的时间。网页性能是我们为使页面尽快加载所必须做的工作。
在大多数情况下,浏览器被认为是单线程的。也就是说,它们在处理下一个任务之前,会从头到尾执行一个任务。为了实现流畅的交互,开发者的目标是确保高性能的网站交互,从流畅的滚动到响应触摸。渲染时间是关键,确保主线程能够完成我们分配给它的所有工作,并且始终可用于处理用户交互。通过了解浏览器的单线程特性,并在可能和适当的情况下,最大限度地减少主线程的职责,以确保渲染流畅,交互响应即时,可以提高网页性能。
导航
导航是加载网页的第一步。每当用户通过在地址栏输入 URL、点击链接、提交表单以及其他操作请求页面时,就会发生导航。
网页性能的目标之一是最大限度地减少导航完成所需的时间。在理想条件下,这通常不会花费太长时间,但延迟和带宽是可能导致延迟的敌人。
DNS 查询
导航到网页的第一步是查找该页面资产的位置。如果您导航到 https://example.com
,HTML 页面位于 IP 地址为 93.184.216.34
的服务器上。如果您从未访问过此网站,则必须进行 DNS 查询。
您的浏览器请求 DNS 查询,最终由域名服务器处理,域名服务器反过来会响应一个 IP 地址。在此初始请求之后,IP 可能会被缓存一段时间,这通过从缓存中检索 IP 地址而不是再次联系域名服务器来加快后续请求。
DNS 查询通常每个主机名每个页面加载只需要执行一次。但是,对于请求页面引用的每个唯一主机名,都必须执行 DNS 查询。如果您的字体、图像、脚本、广告和指标都具有不同的主机名,则每个主机名都必须进行 DNS 查询。
这可能对性能造成问题,尤其是在移动网络上。当用户处于移动网络时,每个 DNS 查询都必须从手机到蜂窝塔才能到达权威 DNS 服务器。手机、蜂窝塔和名称服务器之间的距离会增加显著的延迟。
TCP 握手
一旦知道 IP 地址,浏览器就会通过TCP 三次握手与服务器建立连接。这种机制旨在使试图通信的两个实体——在本例中是浏览器和 Web 服务器——能够在传输数据之前协商网络 TCP 套接字连接的参数,通常是通过HTTPS。
TCP 的三次握手技术通常被称为“SYN-SYN-ACK”——或者更准确地说是 SYN、SYN-ACK、ACK——因为 TCP 传输了三个消息来协商和启动两个计算机之间的 TCP 会话。是的,这意味着每个服务器之间又来回发送了三个消息,而请求尚未发出。
TLS 协商
对于通过 HTTPS 建立的安全连接,还需要另一个“握手”。这个握手,或者更确切地说,TLS 协商,决定了将用于加密通信的密码,验证服务器,并在开始实际数据传输之前建立安全连接。这需要在向服务器发出实际内容请求之前再进行五次往返。
虽然建立安全连接会增加页面加载时间,但安全连接值得延迟开销,因为浏览器和 Web 服务器之间传输的数据不能被第三方解密。
在与服务器进行了八次往返之后,浏览器终于能够发出请求。
Response
一旦我们与 Web 服务器建立了连接,浏览器就会代表用户发送初始的 HTTP GET
请求,对于网站而言,这通常是一个 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="my-image.jpg" alt="image description" />
</div>
<script src="another-script.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 值将减半。这种机制因此实现了发送过多分段和发送过少分段之间的平衡。
解析
一旦浏览器收到第一块数据,它就可以开始解析收到的信息。解析是浏览器将通过网络接收到的数据转换为 DOM 和 CSSOM 的步骤,渲染器使用它们在屏幕上绘制页面。
DOM 是浏览器对标记的内部表示。DOM 也被暴露出来,可以通过 JavaScript 中的各种 API 进行操作。
即使请求页面的 HTML 大于初始的 14KB 数据包,浏览器也会开始解析并尝试根据其拥有的数据渲染体验。这就是为什么网页性能优化必须在最初的 14KB 中包含浏览器开始渲染页面所需的一切,或者至少是页面模板——第一次渲染所需的 CSS 和 HTML。但在屏幕上渲染任何内容之前,HTML、CSS 和 JavaScript 都必须被解析。
构建 DOM 树
我们描述了关键渲染路径中的五个步骤。
第一步是处理 HTML 标记并构建 DOM 树。HTML 解析涉及标记化和树构建。HTML 标记包括开始和结束标记,以及属性名称和值。如果文档格式良好,解析它会更直接、更快。解析器将标记化的输入解析到文档中,构建文档树。
DOM 树描述了文档的内容。<html>
元素是文档树的第一个元素和根节点。该树反映了不同元素之间的关系和层次结构。嵌套在其他元素中的元素是子节点。DOM 节点数量越多,构建 DOM 树所需的时间就越长。
当解析器发现非阻塞资源(例如图像)时,浏览器将请求这些资源并继续解析。当遇到 CSS 文件时,解析可以继续,但是 <script>
元素——特别是那些没有 async
或 defer
属性的元素——会阻塞渲染,并暂停 HTML 的解析。尽管浏览器的预加载扫描器加快了此过程,但过多的脚本仍然可能是重要的瓶颈。
预加载扫描器
当浏览器构建 DOM 树时,这个过程会占用主线程。与此同时,预加载扫描器将解析可用的内容并请求高优先级资源,如 CSS、JavaScript 和 Web 字体。多亏了预加载扫描器,我们不必等到解析器找到对外部资源的引用才请求它。它会在后台检索资源,这样当主 HTML 解析器到达请求的资产时,它们可能已经在传输中或已经下载。预加载扫描器提供的优化减少了阻塞。
<link rel="stylesheet" href="styles.css" />
<script src="my-script.js" async></script>
<img src="my-image.jpg" alt="image description" />
<script src="another-script.js" async></script>
在这个例子中,当主线程正在解析 HTML 和 CSS 时,预加载扫描器会找到脚本和图像,并开始下载它们。为了确保脚本不会阻塞进程,如果 JavaScript 解析和执行顺序很重要,请添加 async
属性或 defer
属性。
等待获取 CSS 不会阻塞 HTML 解析或下载,但它会阻塞 JavaScript,因为 JavaScript 通常用于查询 CSS 属性对元素的影响。
构建 CSSOM 树
关键渲染路径的第二步是处理 CSS 并构建 CSSOM 树。CSS 对象模型与 DOM 类似。DOM 和 CSSOM 都是树。它们是独立的数据结构。浏览器将 CSS 规则转换为它可以理解和使用的样式映射。浏览器遍历 CSS 中的每个规则集,根据 CSS 选择器创建具有父、子和兄弟关系的节点树。
与 HTML 一样,浏览器需要将接收到的 CSS 规则转换为它可以处理的内容。因此,它重复 HTML 到对象的过程,但用于 CSS。
CSSOM 树包含来自用户代理样式表的样式。浏览器从适用于节点的通用规则开始,并通过应用更具体的规则递归地优化计算样式。换句话说,它会层叠属性值。
构建 CSSOM 非常非常快,此构建时间信息不会在开发人员工具中显示。相反,开发人员工具中的“重新计算样式”显示解析 CSS、构建 CSSOM 树和递归计算计算样式的总时间。在网页性能方面,有许多更好的方法来投入优化工作,因为创建 CSSOM 的总时间通常小于一次 DNS 查找所需的时间。
其他进程
JavaScript 编译
当 CSS 被解析并创建 CSSOM 时,其他资产,包括 JavaScript 文件,正在下载(多亏了预加载扫描器)。JavaScript 被解析、编译和解释。脚本被解析成抽象语法树。一些浏览器引擎将抽象语法树传递给编译器,输出字节码。这被称为 JavaScript 编译。大部分代码在主线程上解释执行,但也有例外,例如在Web Workers中运行的代码。
构建可访问性树
浏览器还会构建一个可访问性树,辅助设备使用它来解析和解释内容。可访问性对象模型(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>
,以及任何具有opacity
、3D transform
、will-change
等 CSS 属性的元素。这些节点将与它们的子孙一起绘制到自己的层中,除非子孙由于上述一个(或多个)原因需要自己的层。
层确实能提高性能,但在内存管理方面成本很高,因此不应作为网页性能优化策略的一部分过度使用。
合成
当文档的不同部分在不同层中绘制并相互重叠时,需要进行合成以确保它们以正确的顺序绘制到屏幕上并正确渲染内容。
随着页面继续加载资产,可能会发生回流(回想一下我们迟到的示例图像)。回流会触发重绘和重新合成。如果我们在图像尺寸已知之前定义了图像的尺寸,则不需要回流,并且只需要重绘需要重绘的层,并在必要时进行合成。但我们没有包含图像尺寸!当从服务器获取图像时,渲染过程将返回到布局步骤并从那里重新开始。
交互性
一旦主线程完成页面绘制,您可能会认为我们“万事俱备”。情况并非总是如此。如果加载包含正确延迟的 JavaScript,并且仅在 onload
事件触发后执行,则主线程可能正在忙碌,无法用于滚动、触摸和其他交互。
可交互时间 (TTI) 是衡量从导致 DNS 查找和 TCP 连接的首次请求到页面可交互所需的时间——可交互是指在首次内容绘制之后,页面在 50 毫秒内响应用户交互的时间点。如果主线程忙于解析、编译和执行 JavaScript,它就不可用,因此无法及时(少于 50 毫秒)响应用户交互。
在我们的示例中,图片可能加载得很快,但 another-script.js
文件可能为 2MB,而我们用户的网络连接很慢。在这种情况下,用户会很快看到页面,但除非脚本下载、解析并执行完毕,否则无法流畅滚动。这不是良好的用户体验。避免占用主线程,如本 WebPageTest 示例所示
在此示例中,JavaScript 执行耗时超过 1.5 秒,主线程在此期间完全被占用,无法响应点击事件或屏幕点击。