# 参透了浏览器的工作原理,你就能解决 80%的前端难题

# 浏览器的进化路线

浏览器的发展历程中可分为三个大的进化路线,能让我们了解目前的 Web 应用到底能做什么,以及未来能适用于哪些新领域。

📌 1. 应用程序 Web 化

随着云计算的普及和 HTML5 技术的快速发展,越来越多的应用转向了浏览器 / 服务器(B/S)架构,这种改变让浏览器的重要性与日俱增,视频、音频、游戏几大核心场景也都在往 Web 的使用场景切换。

📌 2. Web 应用移动化

对于移动设备应用,Web 天生具有开放的基因,虽然在技术层面还有问题尚待解决(比如,渲染流程过于复杂且性能不及原生应用、离线时用户无法使用、无法接收消息推送、移动端没有一级入口),但 Google 推出了 PWA 方案来整合 Web 和本地程序各自的优势。

📌 3. Web 操作系统化

在我看来,Web 操作系统有两层含义:一是利用 Web 技术构建一个纯粹的操作系统,如 ChromeOS;二是浏览器的底层结构往操作系统架构方向发展,在整个架构演化的大背景下会牵涉诸多改变,下面列举一些我认为相对重要的改变。

  • Chrome 朝着 SOA 的方向演化,未来很多模块都会以服务的形式提供给上层应用使用;

  • 在浏览器中引入多种编程语言的支持,比如新支持的 WebAssembly;

  • 简化渲染流程,使得渲染过程更加直接高效;

  • 加大对系统设备特性的支持;

  • 提供对复杂 Web 项目开发的支持。

也就是说,浏览器已经逐步演化成了操作系统之上的 “操作系统”。

# 为什么需要学习浏览器工作原理?

  1. 准确评估 Web 开发项目的可行性。

  2. 从更高维度审视页面,能站在用户体验角度来考虑页面性能。

  3. 在快节奏的技术迭代中把握本质。

# 评论区学习

查看 (opens new window)

# Chrome 架构:仅仅打开了 1 个页面,为什么有 4 个进程?

Chrome 打开一个页面需要启动 4 个进程?我们可以点击 Chrome 浏览器右上角的 “选项” 菜单,选择 “更多工具” 子菜单,点击 “任务管理器”,这将打开 Chrome 的任务管理器的窗口,如下图:

browser

在解答这个问题之前,我们需要知道进程和线程的概念以及区别。

# 进程和线程

# 并行处理

计算机中的并行处理就是同一时刻处理多个任务,比如我们要计算下面这三个表达式的值,并显示出结果。

A = 1 + 2;
B = 20 / 5;
C = 7 * 8;
1
2
3

在编写代码的时候,我们可以把这个过程拆分为四个任务:

  • 任务 1 是计算 A=1+2;

  • 任务 2 是计算 B=20/5;

  • 任务 3 是计算 C=7*8;

  • 任务 4 是显示最后计算的结果。

正常情况下程序可以使用单线程来处理,也就是分四步按照顺序分别执行这四个任务。

如果采用多线程,会怎么样呢?我们只需分 “两步走”:第一步,使用三个线程同时执行前三个任务;第二步,再执行第四个显示任务。

通过对比分析,你会发现用单线程执行需要四步,而使用多线程只需要两步。因此,使用并行处理能大大提升性能。

# 线程

多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的。

# 进程

一个进程就是一个程序的运行实例。

详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。

上述的计算过程可以看下面这张对比图:

browser

从图中可以看到,线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率

进程和线程之间的关系有以下 4 个特点。

1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。

2. 线程之间共享进程中的数据。

当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。

比如之前的 IE 浏览器,支持很多插件,而这些插件很容易导致内存泄漏,这意味着只要浏览器开着,内存占用就有可能会越来越多,但是当关闭浏览器进程时,这些内存就都会被系统回收掉。

3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。

4. 进程之间的内容相互隔离。

进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。

如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。

# 单进程浏览器时代

单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。其实早在 2007 年之前,市面上浏览器都是单进程的。

单进程浏览器的架构如下图所示:

browser

单进程浏览器会有不稳定、不流畅和不安全的问题。

1. 不稳定

早期浏览器需要借助于插件来实现诸如 Web 视频、Web 游戏等各种强大的功能,但是插件是最容易出问题的模块,并且还运行在浏览器进程之中,所以一个插件的意外崩溃会引起整个浏览器的崩溃。

除了插件之外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。和插件一样,渲染引擎的崩溃也会导致整个浏览器的崩溃。

2. 不流畅

从上面的 “单进程浏览器架构示意图” 可以看出,所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行。

比如,下面这个无限循环的脚本:

function freeze() {
  while (1) {
    console.log("freeze");
  }
}
freeze();
1
2
3
4
5
6

如果让这个脚本运行在一个单进程浏览器的页面里,你感觉会发生什么?

因为这个脚本是无限循环的,所以当其执行时,它会独占整个线程,这样导致其他运行在该线程中的模块就没有机会被执行。因为浏览器中所有的页面都运行在该线程中,所以这些页面都没有机会去执行任务,这样就会导致整个浏览器失去响应,变卡顿。

除了上述脚本或者插件会让单进程浏览器变卡顿外,页面的内存泄漏也是单进程变慢的一个重要原因。通常浏览器的内核都是非常复杂的,运行一个复杂点的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是使用时间越长,内存占用越高,浏览器会变得越慢。

3. 不安全

这里依然可以从插件和页面脚本两个方面来解释该原因。

插件可以使用 C/C++ 等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。

至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。

# 多进程浏览器时代

# 早期多进程架构

下图是 2008 年 Chrome 发布时的进程架构。

browser

从图中可以看出,Chrome 的页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,而进程之间是通过 IPC 机制进行通信(如图中虚线部分)。

1. 解决不稳定的问题

由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题。

2. 解决不流畅的问题

同样,JavaScript 也是运行在渲染进程中的,所以即使 JavaScript 阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中的。所以当我们再在 Chrome 中运行上面那个死循环的脚本时,没有响应的仅仅是当前的页面。

对于内存泄漏的解决方法那就更简单了,因为当关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。

3. 解决安全问题

采用多进程架构的额外好处是可以使用安全沙箱,你可以把沙箱看成是操作系统给进程上了一把锁,沙箱里面的程序可以运行,但是不能在你的硬盘上写入任何数据,也不能在敏感位置读取任何数据,例如你的文档和桌面。Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。

# 目前多进程架构

下图是最新的 Chrome 进程架构。

browser

从图中可以看出,最新的 Chrome 浏览器包括:

  • 1 个浏览器(Browser)主进程

  • 1 个 GPU 进程

  • 1 个网络(NetWork)进程

  • 多个渲染进程

  • 多个插件进程

这几个进程的功能如下。

1. 浏览器主进程

主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

2. 渲染进程

核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程

出于安全考虑,渲染进程都是运行在沙箱模式下。

3. GPU 进程

其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。

4. 网络进程

主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,后来才独立出来,成为一个单独的进程。

5. 插件进程

主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

到这里,我们就可以回答一开始提出的那个问题了:仅仅打开了 1 个页面,为什么有 4 个进程?

因为打开一个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。

不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:

  • 更高的资源占用

因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。

  • 更复杂的体系架构

浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

# 未来面向服务的架构

为了解决这些问题,在 2016 年,Chrome 官方团队使用 “面向服务的架构”(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。

也就是说 Chrome 整体架构会朝向现代操作系统所采用的 “面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。

Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务,下面是 Chrome “面向服务的架构” 的进程模型图:

browser

目前 Chrome 正处在老的架构向服务化架构过渡阶段,这将是一个漫长的迭代过程。

Chrome 正在逐步构建 Chrome 基础服务(Chrome Foundation Service),同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上(如下图),Chrome 会将很多服务整合到一个进程中,从而节省内存占用。

browser

# 评论区学习

查看 (opens new window)

# TCP 协议:如何保证页面文件能被完整送达浏览器?

在衡量 Web 页面性能的时候有一个重要的指标叫 “FP(First Paint)”,是指从页面加载到首次开始绘制的时长。这个指标直接影响了用户的跳出率,更快的页面响应意味着更多的 PV、更高的参与度,以及更高的转化率。那什么影响 FP 指标呢?其中一个重要的因素是网络加载速度。

要想优化 Web 页面的加载速度,你需要对网络有充分的了解。而理解网络的关键是要对网络协议有深刻的认识,不管你是使用 HTTP,还是使用 WebSocket,它们都是基于 TCP/IP 的,如果你对这些原理有足够了解,也就清楚如何去优化 Web 性能,或者能更轻松地定位 Web 问题了。此外,TCP/IP 的设计思想还有助于拓宽你的知识边界,从而在整体上提升你对项目的理解和解决问题的能力。

在网络中,一个文件通常会被拆分为很多数据包来进行传输,而数据包在传输过程中又有很大概率丢失或者出错。那么如何保证页面文件能被完整地送达浏览器呢?

# 一个数据包的 “旅程”

下面将分别从 “数据包如何送达主机” “主机如何将数据包转交给应用” 和 “数据是如何被完整地送达应用程序” 这三个角度来讲述数据的传输过程。

互联网,实际上是一套理念和协议组成的体系架构。其中,协议是一套众所周知的规则和标准,如果各方都同意使用,那么它们之间的通信将变得毫无障碍。

互联网中的数据是通过数据包来传输的。如果发送的数据很大,那么该数据就会被拆分为很多小数据包来传输。比如你现在听的音频数据,是拆分成一个个小的数据包来传输的,并不是一个大的文件一次传输过来的。

# 1. IP:把数据包送达目的主机

数据包要在互联网上进行传输,就要符合网际协议(Internet Protocol,简称 IP)标准。互联网上不同的在线设备都有唯一的地址,地址只是一个数字,这和大部分家庭收件地址类似,你只需要知道一个家庭的具体地址,就可以往这个地址发送包裹,这样物流系统就能把物品送到目的地。

计算机的地址就称为 IP 地址,访问任何网站实际上只是你的计算机向另外一台计算机请求信息。

如果要想把一个数据包从主机 A 发送给主机 B,那么在传输之前,数据包上会被附加上主机 B 的 IP 地址信息,这样在传输过程中才能正确寻址。额外地,数据包上还会附加上主机 A 本身的 IP 地址,有了这些信息主机 B 才可以回复信息给主机 A。这些附加的信息会被装进一个叫 IP 头的数据结构里。

IP 头是 IP 数据包开头的信息,包含 IP 版本、源 IP 地址、目标 IP 地址、生存时间等信息。如果想要详细了解 IP 头信息,可以参考这里 (opens new window)

为了方便理解,先把网络简单分为三层结构,如下图:

简化的 IP 网络三层传输模型

# 2. UDP:把数据包送达应用程序

IP 是非常底层的协议,只负责把数据包传送到对方电脑,但是对方电脑并不知道把数据包交给哪个程序,是交给浏览器还是交给王者荣耀?

因此,需要基于 IP 之上开发能和应用打交道的协议,最常见的是 “用户数据包协议(User Datagram Protocol)”,简称 UDP。

UDP 中一个最重要的信息是端口号,端口号其实就是一个数字,每个想访问网络的程序都需要绑定一个端口号。通过端口号 UDP 就能把指定的数据包发送给指定的程序了,所以 IP 通过 IP 地址信息把数据包发送给指定的电脑,而 UDP 通过端口号把数据包分发给正确的程序。和 IP 头一样,端口号会被装进 UDP 头里面,UDP 头再和原始数据包合并组成新的 UDP 数据包。UDP 头中除了目的端口,还有源端口号等信息。

为了支持 UDP 协议,把前面的三层结构扩充为四层结构,在网络层和上层之间增加了传输层,如下图所示:

简化的 UDP 网络四层传输模型

在使用 UDP 发送数据时,有各种因素会导致数据包出错,虽然 UDP 可以校验数据是否正确,但是对于错误的数据包,UDP 并不提供重发机制,只是丢弃当前的包,而且 UDP 在发送之后也无法知道是否能达到目的地。

虽说 UDP 不能保证数据可靠性,但是传输速度却非常快,所以 UDP 会应用在一些关注速度、但不那么严格要求数据完整性的领域,如在线视频、互动游戏等。

# 3. TCP:把数据完整地送达应用程序

对于浏览器请求,或者邮件这类要求数据传输可靠性(reliability)的应用,如果使用 UDP 来传输会存在两个问题:

  • 数据包在传输过程中容易丢失;

  • 大文件会被拆分成很多小的数据包来传输,这些小的数据包会经过不同的路由,并在不同的时间到达接收端,而 UDP 协议并不知道如何组装这些数据包,从而把这些数据包还原成完整的文件。

基于这两个问题,我们引入 TCP 了。TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。相对于 UDP,TCP 有下面两个特点:

  • 对于数据包丢失的情况,TCP 提供重传机制;

  • TCP 引入了数据包排序机制,用来保证把乱序的数据包组合成一个完整的文件。

和 UDP 头一样,TCP 头除了包含了目标端口和本机端口号外,还提供了用于排序的序列号,以便接收端通过序号来重排数据包

下面看看 TCP 下的单个数据包的传输流程:

简化的 TCP 网络四层传输模型

通过上图应该可以了解一个数据包是如何通过 TCP 来传输的。

TCP 单个数据包的传输流程和 UDP 流程差不多,不同的地方在于,通过 TCP 头的信息保证了一块大的数据传输的完整性。

下面再看下完整的 TCP 连接过程,通过这个过程你可以明白 TCP 是如何保证重传机制和数据包的排序功能的。

从下图可以看出,一个完整的 TCP 连接的生命周期包括了 “建立连接” “传输数据” 和 “断开连接” 三个阶段

一个 TCP 连接的生命周期

  • 首先,建立连接阶段。这个阶段是通过 “三次握手” 来建立客户端和服务器之间的连接。TCP 提供面向连接的通信传输。面向连接是指在数据通信开始之前先做好两端之间的准备工作。所谓三次握手,是指在建立一个 TCP 连接时,客户端和服务器总共要发送三个数据包以确认连接的建立。

  • 其次,传输数据阶段。在该阶段,接收端需要对每个数据包进行确认操作,也就是接收端在接收到数据包之后,需要发送确认数据包给发送端。所以当发送端发送了一个数据包之后,在规定时间内没有接收到接收端反馈的确认消息,则判断为数据包丢失,并触发发送端的重发机制。同样,一个大的文件在传输过程中会被拆分成很多小的数据包,这些数据包到达接收端后,接收端会按照 TCP 头中的序号为其排序,从而保证组成完整的数据。

  • 最后,断开连接阶段。数据传输完毕之后,就要终止连接了,涉及到最后一个阶段 “四次挥手” 来保证双方都能断开连接。

TCP 为了保证数据传输的可靠性,牺牲了数据包的传输速度,因为 “三次握手” 和 “数据包校验机制” 等把传输过程中的数据包的数量提高了一倍。

# 评论区学习

查看 (opens new window)

# HTTP 请求流程:为什么很多站点第二次打开速度会很快?

一个 TCP 连接过程包括了建立连接、传输数据和断开连接三个阶段。而 HTTP 协议,正是建立在 TCP 连接基础之上的。

HTTP 是一种允许浏览器向服务器获取资源的协议,是 Web 的基础,通常由浏览器发起请求,用来获取不同类型的文件,例如 HTML 文件、CSS 文件、JavaScript 文件、图片、视频等。此外,HTTP 也是浏览器使用最广的协议,所以要想学好浏览器,就要先深入了解 HTTP。

带着下面两个问题来继续看接下来的内容:

  • 为什么通常在第一次访问一个站点时,打开速度很慢,当再次访问这个站点时,速度就很快了?

  • 当登录过一个网站之后,下次再访问该站点,就已经处于登录状态了,这是怎么做到的呢?

# 浏览器端发起 HTTP 请求流程

如果我们在浏览器地址栏访问某个网址,那么接下来,浏览器会完成哪些动作呢?下面我们就一步一步详细 “追踪” 下。

# 1. 构建请求

首先,浏览器构建请求行信息(如下所示),构建好后,浏览器准备发起网络请求。

GET /index.html HTTP1.1
1

# 2. 查找缓存

在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件。

其中,浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术。

当浏览器发现请求的资源已经在浏览器缓存中存有副本,它会拦截请求,返回该资源的副本,并直接结束请求,而不会再去源服务器重新下载。这样做的好处有:

  • 缓解服务器端压力,提升性能(获取资源的耗时更短了);

  • 对于网站来说,缓存是实现快速资源加载的重要组成部分。

当然,如果缓存查找失败,就会进入网络请求过程了。

# 3. 准备 IP 地址和端口

因为浏览器使用 HTTP 协议作为应用层协议,用来封装请求的文本信息;并使用 TCP/IP 作传输层协议将它发到网络上,所以在 HTTP 工作开始之前,浏览器需要通过 TCP 与服务器建立连接。也就是说 HTTP 的内容是通过 TCP 的传输数据阶段来实现的,可以结合下图更好地理解这二者的关系。

TCP 和 HTTP 的关系示意图

那接下来可以思考这么 “一连串” 问题:

  • HTTP 网络请求的第一步是做什么呢?结合上图看,是和服务器建立 TCP 连接。

  • 那建立连接的信息都有了吗?上一篇文章中,我们讲到建立 TCP 连接的第一步就是需要准备 IP 地址和端口号。

  • 那怎么获取 IP 地址和端口号呢?这得看看我们现在有什么,我们有一个 URL 地址,那么是否可以利用 URL 地址来获取 IP 和端口信息呢?

在上一篇文章中,我们介绍过数据包都是通过 IP 地址传输给接收方的。由于 IP 地址是数字标识,比如极客时间网站的 IP 是 39.106.233.176, 难以记忆,但使用极客时间的域名(time.geekbang.org)就好记多了,所以基于这个需求又出现了一个服务,负责把域名和 IP 地址做一一映射关系。

这套域名映射为 IP 的系统就叫做 “域名系统”,简称 DNS(Domain Name System)。

所以,在第一步浏览器会请求 DNS 返回域名对应的 IP

当然浏览器还提供了 DNS 数据缓存服务,如果某个域名已经解析过了,那么浏览器会缓存解析的结果,以供下次查询时直接使用,这样也会减少一次网络请求。

拿到 IP 之后,接下来就需要获取端口号了。通常情况下,如果 URL 没有特别指明端口号,那么 HTTP 协议默认是 80 端口。

# 4. 等待 TCP 队列

现在已经把端口和 IP 地址都准备好了,那么下一步是不是可以建立 TCP 连接了呢?

答案依然是 “不行”。Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。

当然,如果当前请求数量少于 6,会直接进入下一步,建立 TCP 连接。

# 5. 建立 TCP 连接

排队等待结束之后,终于可以快乐地和服务器握手了,在 HTTP 工作开始之前,浏览器通过 TCP 与服务器建立连接。

# 6. 发送 HTTP 请求

一旦建立了 TCP 连接,浏览器就可以和服务器进行通信了。而 HTTP 中的数据正是在这个通信过程中传输的。

可以结合下图来理解,浏览器是如何发送请求信息给服务器的。

HTTP 请求数据格式

首先浏览器会向服务器发送请求行,它包括了请求方法、请求 URI(Uniform Resource Identifier)和 HTTP 版本协议。

发送请求行,就是告诉服务器浏览器需要什么资源,最常用的请求方法是 Get。比如,直接在浏览器地址栏键入极客时间的域名(time.geekbang.org),这就是告诉服务器要 Get 它的首页资源。

另外一个常用的请求方法是 POST,它用于发送一些数据给服务器,比如登录一个网站,就需要通过 POST 方法把用户信息发送给服务器。如果使用 POST 方法,那么浏览器还要准备数据给服务器,这里准备的数据是通过请求体来发送。

在浏览器发送请求行命令之后,还要以请求头形式发送其他一些信息,把浏览器的一些基础信息告诉服务器。比如包含了浏览器所使用的操作系统、浏览器内核等信息,以及当前请求的域名信息、浏览器端的 Cookie 信息等等。

# 服务器端处理 HTTP 请求流程

接下来,服务器会根据浏览器的请求信息来准备相应的内容。

# 1. 返回请求

一旦服务器处理结束,便可以返回数据给浏览器了。我们可以通过工具软件 curl 来查看返回请求数据,具体使用方法是在命令行中输入以下命令:

curl -i  https://time.geekbang.org/
1

注意这里加上了 -i 是为了返回响应行、响应头和响应体的数据,返回的结果如下图所示,可以结合这些数据来理解服务器是如何响应浏览器的。

服务器响应的数据格式

首先服务器会返回响应行,包括协议版本和状态码。

但并不是所有的请求都可以被服务器处理的,那么一些无法处理或者处理出错的信息,怎么办呢?服务器会通过请求行的状态码来告诉浏览器它的处理结果,比如:

  • 最常用的状态码是 200,表示处理成功;

  • 如果没有找到页面,则会返回 404。

详细状态码可参考这里:HTTP 状态码

随后,正如浏览器会随同请求发送请求头一样,服务器也会随同响应向浏览器发送响应头。

响应头包含了服务器自身的一些信息,比如服务器生成返回数据的时间、返回的数据类型(JSON、HTML、流媒体等类型),以及服务器要在客户端保存的 Cookie 等信息。

发送完响应头后,服务器就可以继续发送响应体的数据,通常,响应体就包含了 HTML 的实际内容。

以上这些就是服务器响应浏览器的具体过程。

# 2. 断开连接

通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接。不过如果浏览器或者服务器在其头信息中加入了:

Connection:Keep-Alive
1

那么 TCP 连接在发送后将仍然保持打开状态,这样浏览器就可以继续通过同一个 TCP 连接发送请求。

保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。

比如,一个 Web 页面中内嵌的图片就都来自同一个 Web 站点,如果初始化了一个持久连接,你就可以复用该连接,以请求其他资源,而不需要重新再建立新的 TCP 连接。

# 3. 重定向

到这里似乎请求流程快结束了,不过还有一种情况你需要了解下,比如当你在浏览器中打开 geekbang.org 后,你会发现最终打开的页面地址是 https://www.geekbang.org。

这两个 URL 之所以不一样,是因为涉及到了一个重定向操作。跟前面一样,你依然可以使用 curl 来查看下请求 geekbang.org 会返回什么内容?

在控制台输入如下命令:

curl -I geekbang.org
1

注意这里输入的参数是 -I,和 -i 不一样,-I 表示只需要获取响应头和响应行数据,而不需要获取响应体的数据,最终返回的数据如下图所示:

服务器返回响应行和响应头(含重定向格式)

从图中你可以看到,响应行返回的状态码是 301,状态 301 就是告诉浏览器,我需要重定向到另外一个网址,而需要重定向的网址正是包含在响应头的 Location 字段中,接下来,浏览器获取 Location 字段中的地址,并使用该地址重新导航,这就是一个完整重定向的执行流程。这也就解释了为什么输入的是 geekbang.org,最终打开的却是 https://www.geekbang.org 了。

# 问题解答

# 1. 为什么很多站点第二次打开速度会很快?

如果第二次页面打开很快,主要原因是第一次加载页面过程中,缓存了一些耗时的数据。

那么,哪些数据会被缓存呢?

从上面介绍的核心请求路径可以发现,DNS 缓存和页面资源缓存这两块数据是会被浏览器缓存的。其中,DNS 缓存比较简单,它主要就是在浏览器本地把对应的 IP 和域名关联起来,这里就不做过多分析了。

重点看下浏览器资源缓存,下面是缓存处理的过程:

缓存查找流程示意图

首先,我们看下服务器是通过什么方式让浏览器缓存数据的?

从上图的第一次请求可以看出,当服务器返回 HTTP 响应头给浏览器时,浏览器是通过响应头中的 Cache-Control 字段来设置是否缓存该资源。通常,我们还需要为这个资源设置一个缓存过期时长,而这个时长是通过 Cache-Control 中的 Max-age 参数来设置的,比如上图设置的缓存过期时间是 2000 秒。

Cache-Control:Max-age=2000
1

这也就意味着,在该缓存资源还未过期的情况下, 如果再次请求该资源,会直接返回缓存中的资源给浏览器。

但如果缓存过期了,浏览器则会继续发起网络请求,并且在 HTTP 请求头中带上:

If-None-Match:"4f80f-13c-3a1xb12a"
1

服务器收到请求头后,会根据 If-None-Match 的值来判断请求的资源是否有更新。

  • 如果没有更新,就返回 304 状态码,相当于服务器告诉浏览器:“这个缓存可以继续使用,这次就不重复发送数据给你了。”

  • 如果资源有更新,服务器就直接返回最新资源给浏览器。

详细的缓存内容可以参考这里:HTTP 的缓存机制

简要来说,很多网站第二次访问能够秒开,是因为这些网站把很多资源都缓存在了本地,浏览器缓存直接使用本地副本来回应请求,而不会产生真实的网络请求,从而节省了时间。同时,DNS 数据也被浏览器缓存了,这又省去了 DNS 查询环节。

# 2. 登录状态是如何保持的?

  • 用户打开登录页面,在登录框里填入用户名和密码,点击确定按钮。点击按钮会触发页面脚本生成用户登录信息,然后调用 POST 方法提交用户登录信息给服务器。

  • 服务器接收到浏览器提交的信息之后,查询后台,验证用户登录信息是否正确,如果正确的话,会生成一段表示用户身份的字符串,并把该字符串写到响应头的 Set-Cookie 字段里,如下所示,然后把响应头发送给浏览器。

Set-Cookie: UID=3431uad;
1
  • 浏览器在接收到服务器的响应头后,开始解析响应头,如果遇到响应头里含有 Set-Cookie 字段的情况,浏览器就会把这个字段信息保存到本地。比如把 UID=3431uad 保持到本地。

  • 当用户再次访问时,浏览器会发起 HTTP 请求,但在发起请求之前,浏览器会读取之前保存的 Cookie 数据,并把数据写进请求头里的 Cookie 字段里(如下所示),然后浏览器再将请求头发送给服务器。

Cookie: UID=3431uad;
1
  • 服务器在收到 HTTP 请求头数据之后,就会查找请求头里面的 “Cookie” 字段信息,当查找到包含 UID=3431uad 的信息时,服务器查询后台,并判断该用户是已登录状态,然后生成含有该用户信息的页面数据,并把生成的数据发送给浏览器。

  • 浏览器在接收到该含有当前用户的页面数据后,就可以正确展示用户登录的状态信息了。

通过这个流程可以知道浏览器页面状态是通过使用 Cookie 来实现的。Cookie 流程可以参考下图:

Cookie 流程图

简单地说,如果服务器端发送的响应头内有 Set-Cookie 的字段,那么浏览器就会将该字段的内容保持到本地。当下次客户端再往该服务器发送请求时,客户端会自动在请求头中加入 Cookie 值后再发送出去。服务器端发现客户端发送过来的 Cookie 后,会去检查究竟是从哪一个客户端发来的连接请求,然后对比服务器上的记录,最后得到该用户的状态信息。

# 总结

下图展现了浏览器中的 HTTP 请求所经历的各个阶段。

HTTP 请求流程示意图

从图中可以看到,浏览器中的 HTTP 请求从发起到结束一共经历了如下八个阶段:

  1. 构建请求

  2. 查找缓存

  3. 准备 IP 和端口

  4. 等待 TCP 队列

  5. 建立 TCP 连接

  6. 发起 HTTP 请求

  7. 服务器处理请求

  8. 服务器返回请求和断开连接。

# 评论区学习

查看 (opens new window)

# 导航流程:从输入 URL 到页面展示,这中间发生了什么?

下图是 “从输入 URL 到页面展示完整流程示意图”:

从输入 URL 到页面展示完整流程示意图

从图中可以看出,整个过程需要各个进程之间的配合,所以在开始正式流程之前,先来快速回顾下浏览器进程、渲染进程和网络进程的主要职责。

  • 浏览器进程主要负责用户交互、子进程管理和文件储存等功能。

  • 网络进程是面向渲染进程和浏览器进程等提供网络下载功能。

  • 渲染进程的主要职责是把从网络下载的 HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。

因为渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么 Chrome 会让渲染进程运行在安全沙箱里,就是为了保证系统的安全。

回顾了浏览器的进程架构后,我们再结合上图来看下这个完整的流程,可以看出,整个流程包含了许多步骤,其中几个核心的节点用蓝色背景标记出来了。这个过程可以大致描述为如下。

  • 首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程。

  • 然后,在网络进程中发起真正的 URL 请求。

  • 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。

  • 浏览器进程接收到网络进程的响应头数据之后,发送 “提交导航 (CommitNavigation)” 消息到渲染进程;

  • 渲染进程接收到 “提交导航” 的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立数据管道;

  • 最后渲染进程会向浏览器进程 “确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。

  • 浏览器进程接收到渲染进程 “提交文档” 的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。

这其中,用户发出 URL 请求到页面开始解析的这个过程,就叫做导航。

# 从输入 URL 到页面展示

# 1. 用户输入

当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。

  • 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL。

  • 如果判断输入内容符合 URL 规则,比如输入的是 time.geekbang.org,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL,如 https://time.geekbang.org。

当用户输入关键字并键入回车之后,这意味着当前页面即将要被替换成新的页面,不过在这个流程继续之前,浏览器还给了当前页面一次执行 beforeunload 事件的机会。

beforeunload 事件允许页面在退出之前执行一些数据清理操作,还可以询问用户是否要离开当前页面,比如当前页面可能有未提交完成的表单等情况,因此用户可以通过 beforeunload 事件来取消导航,让浏览器不再执行任何后续工作。

当前页面没有监听 beforeunload 事件或者同意了继续后续流程,那么浏览器便进入下图的状态:

开始加载 URL 浏览器状态

从图中可以看出,当浏览器刚开始加载一个地址之后,标签页上的图标便进入了加载状态。但此时图中页面显示的依然是之前打开的页面内容,并没立即替换为极客时间的页面。因为需要等待提交文档阶段,页面内容才会被替换。

# 2. URL 请求过程

接下来,便进入了页面资源请求过程。这时,浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。那具体流程是怎样的呢?

  • 首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。

  • 接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。

  • 服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。(为了方便讲述,下面将服务器返回的响应头和响应行统称为响应头。)

# (1)重定向

在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。

比如,我们在终端里输入以下命令:

curl -I http://time.geekbang.org/
1

curl -I + URL 的命令是接收服务器返回的响应头的信息。执行命令后,我们看到服务器返回的响应头信息如下:

响应行返回状态码 301

从图中可以看出,极客时间服务器会通过重定向的方式把所有 HTTP 请求转换为 HTTPS 请求。也就是说你使用 HTTP 向极客时间服务器请求时,服务器会返回一个包含有 301 或者 302 状态码响应头,并把响应头的 Location 字段中填上 HTTPS 的地址,这就是告诉了浏览器要重新导航到新的地址上。

下面我们再使用 HTTPS 协议对极客时间发起请求,看看服务器的响应头信息是什么样子的。

curl -I https://time.geekbang.org/
1

我们看到服务器返回如下信息:

响应行返回状态码 200

从图中可以看出,服务器返回的响应头的状态码是 200,这是告诉浏览器一切正常,可以继续往下处理该请求了。

总结下,在导航过程中,如果服务器响应行的状态码包含了 301、302 一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是 200,那么表示浏览器可以继续处理该请求。

# (2)响应数据类型处理

URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面,那么浏览器是如何区分它们呢?

答案是 Content-TypeContent-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。

在终端输入以下命令:

curl -I https://time.geekbang.org/
1

返回信息如下图:

含有 HTML 格式的 Content-Type

从图中可以看到,响应头中的 Content-type 字段的值是 text/html,这就是告诉浏览器,服务器返回的数据是 HTML 格式。

接下来我们再来利用 curl 来请求极客时间安装包的地址,如下所示:

curl -I https://res001.geekbang.org/apps/geektime/android/2.3.1/official/geektime_2.3.1_20190527-2136_offical.apk
1

请求后返回的响应头信息如下:

含有 stream 格式的 Content-Type

从返回的响应头信息来看,其 Content-Type 的值是 application/octet-stream,显示数据是字节流类型的,通常情况下,浏览器会按照下载类型来处理该请求。

需要注意的是,如果服务器配置 Content-Type 不正确,比如将 text/html 类型配置成 application/octet-stream 类型,那么浏览器可能会曲解文件内容,比如会将一个本来是用来展示的页面,变成了一个下载文件。

所以,不同 Content-Type 的后续处理流程也截然不同。如果 Content-Type 字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是 HTML,那么浏览器则会继续进行导航流程。由于 Chrome 的页面渲染是运行在渲染进程中的,所以接下来就需要准备渲染进程了。

# 3. 准备渲染进程

默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。但是,也有一些例外,在某些情况下,浏览器会让多个页面直接运行在同一个渲染进程中。

比如我从极客时间的首页里面打开了另外一个页面——算法训练营,我们看下图的 Chrome 的任务管理器截图:

多个页面运行在一个渲染进程中

从图中可以看出,打开的这三个页面都是运行在同一个渲染进程中,进程 ID 是 23601。

那什么情况下多个页面会同时运行在一个渲染进程中呢?

要解决这个问题,我们就需要先了解下什么是同一站点(same-site)

具体地讲,我们将 “同一站点” 定义为根域名(例如,geekbang.org)加上协议(例如,https:// 或者 http://),还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:

https://time.geekbang.org
https://www.geekbang.org
https://www.geekbang.org:8080
1
2
3

它们都是属于同一站点,因为它们的协议都是 HTTPS,而且根域名也都是 geekbang.org。

Chrome 的默认策略是,每个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫 process-per-site-instance

注意

总结来说,打开一个新页面采用的渲染进程策略就是:

  • 通常情况下,打开新的页面都会使用单独的渲染进程;

  • 如果从 A 页面打开 B 页面,且 A 和 B 都属于同一站点的话,那么 B 页面复用 A 页面的渲染进程;如果是其他情况,浏览器进程则会为 B 创建一个新的渲染进程。

渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。

# 4. 提交文档

所谓提交文档,就是指浏览器进程将网络进程接收到的 HTML 数据提交给渲染进程,具体流程是这样的:

  • 首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起 “提交文档” 的消息;

  • 渲染进程接收到 “提交文档” 的消息后,会和网络进程建立传输数据的 “管道”;

  • 等文档数据传输完成之后,渲染进程会返回 “确认提交” 的消息给浏览器进程;

  • 浏览器进程在收到 “确认提交” 的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。

其中,当浏览器进程确认提交之后,更新内容如下图所示:

导航完成状态

这也就解释了为什么在浏览器的地址栏里面输入了一个地址后,之前的页面没有立马消失,而是要加载一会儿才会更新页面。

到这里,一个完整的导航流程就 “走” 完了,这之后就要进入渲染阶段了。

# 5. 渲染阶段

一旦文档被提交,渲染进程便开始页面解析和子资源加载了,关于这个阶段的完整过程,会在接下来专门介绍。这里只需要先了解一旦页面生成完成,渲染进程会发送一个消息给浏览器进程,浏览器接收到消息后,会停止标签图标上的加载动画。如下所示:

渲染结束

至此,一个完整的页面就生成了。

# 评论区学习

查看 (opens new window)

# 渲染流程(上):HTML、CSS 和 JvaScript,是如何变成页面的?

由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线。按照渲染的时间顺序,流水线可分为如下几个子阶段:

  • 构建 DOM 树

  • 样式计算

  • 布局阶段

  • 分层

  • 绘制

  • 分块

  • 光栅化

  • 合成

对于每个阶段,应该重点关注以下三点内容:

  • 开始每个子阶段都有其输入的内容;

  • 然后每个子阶段有其处理过程;

  • 最终每个子阶段会生成输出内容。

理解了这三部分内容,能让我们更加清晰地理解每个子阶段。

# 构建 DOM 树

为什么要构建 DOM 树呢?这是因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构 —— DOM 树

DOM 树的构建过程可以参考下图:

DOM 树构建过程示意图

从图中可以看出,构建 DOM 树的输入内容是一个非常简单的 HTML 文件,然后经由 HTML 解析器解析,最终输出树状结构的 DOM。

为了更加直观地理解 DOM 树,可以打开 Chrome 的 “开发者工具”,然后在控制台里面输入 “document” 后回车,这样就能看到一个完整的 DOM 树结构,如下图所示:

DOM 可视化

图中的 document 就是 DOM 结构,可以看到,DOM 和 HTML 内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中的树状结构,可以通过 JavaScript 来查询或修改其内容。比如:

document.getElementsByTagName("p")[0].innerText = "black";
1

现在已经生成 DOM 树了,但是 DOM 节点的样式依然不知道,要让 DOM 节点拥有正确的样式,这就需要样式计算了。

# 样式计算(Recalculate Style)

样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。

# 1. 把 CSS 转换为浏览器能够理解的结构

那 CSS 样式的来源主要有哪些呢?可以先参考下图:

HTML 加载 CSS 的三种方式

和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构 —— styleSheets

为了加深理解,可以在 Chrome 控制台中查看其结构,只需要在控制台中输入 document.styleSheets,然后就看到如下图所示的结构:

styleSheets

从图中可以看出,这个样式表包含了很多种样式,已经把那三种来源的样式都包含进去了。

# 2. 转换样式表中的属性值,使其标准化

现在我们已经把现有的 CSS 文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作

要理解什么是属性值标准化,可以看下面这样一段 CSS 文本:

body {
  font-size: 2em;
}
p {
  color: blue;
}
span {
  display: none;
}
div {
  font-weight: bold;
}
div p {
  color: green;
}
div {
  color: red;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

可以看到上面的 CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

那标准化后的属性值是什么样子的?

标准化属性值

从图中可以看到,2em 被解析成了 32px,red 被解析成了 rgb(255, 0, 0),bold 被解析成了 700……

# 3. 计算出 DOM 树中每个节点的具体样式

现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,如何计算呢?

这就涉及到 CSS 的继承规则和层叠规则了。

首先是 CSS 继承。CSS 继承就是每个 DOM 节点都包含有父节点的样式。这么说可能有点抽象,我们可以结合具体例子,看下面这样一张样式表是如何应用到 DOM 节点上的。

body {
  font-size: 20px;
}
p {
  color: blue;
}
span {
  display: none;
}
div {
  font-weight: bold;
  color: red;
}
div p {
  color: green;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这张样式表最终应用到 DOM 节点的效果如下图所示:

计算后 DOM 的样式

从图中可以看出,所有子节点都继承了父节点样式。比如 body 节点的 font-size 属性是 20,那 body 节点下面的所有节点的 font-size 都等于 20。

为了加深对 CSS 继承的理解,可以打开 Chrome 的 “开发者工具”,选择第一个 “element” 标签,再选择 “style” 子标签,会看到如下界面:

样式的继承过程界面

样式计算过程中的第二个规则是样式层叠。层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称 “层叠样式表” 正是强调了这一点。

总之,样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。

如果想了解每个 DOM 元素最终的计算样式,可以打开 Chrome 的 “开发者工具”,选择第一个 “element” 标签,然后再选择 “Computed” 子标签,如下图所示:

DOM 元素最终计算的样式

上图红色方框中显示了 html.body.div.p 标签的 ComputedStyle 的值。你想要查看哪个元素,点击左边对应的标签就可以了。

# 布局阶段

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。

那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。

Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算

# 1. 创建布局树

你可能注意到了 DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。

所以在显示之前,我们还要额外地构建一棵只包含可见元素的布局树。

结合下图来看看布局树的构造过程:

布局树构造过程示意图

从上图可以看出,DOM 树中所有不可见的节点都没有包含到布局树中。

为了构建布局树,浏览器大体上完成了下面这些工作:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;

  • 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树。

# 2. 布局计算

现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置了。布局的计算过程非常复杂,在后面再做详细介绍。

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。

针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

# 评论区学习

查看 (opens new window)

# 渲染流程(下):HTML、CSS 和 JavaScript,是如何变成页面的?

先回顾下上一节渲染流水线中的 DOM 生成、样式计算和布局三个阶段:

在 HTML 页面内容被提交给渲染引擎之后,渲染引擎首先将 HTML 解析为浏览器可以理解的 DOM;然后根据 CSS 样式表,计算出 DOM 树所有节点的样式;接着又计算每个元素的几何坐标位置,并将这些信息保存在布局树中。

# 分层

现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?

答案依然是否定的。

因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-index 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。

要想直观地理解什么是图层,可以打开 Chrome 的 “开发者工具”,选择 “Layers” 标签,就可以可视化页面的分层情况,如下图所示:

渲染引擎给页面多图层示意图

从上图可以看出,渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面,可以参考下图:

图层叠加的最终展示页面

浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。

下面再来看看这些图层和布局树节点之间的关系,如下图所示:

布局树和图层树关系示意图

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。

如上图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

# 第一点,拥有层叠上下文属性的元素会被提升为单独的一层

页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。可以结合下图来直观感受下:

层叠上下文示意图

从图中可以看出,明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性

更多层叠上下文知识 (opens new window)

# 第二点,需要剪裁(clip)的地方也会被创建为图层

不过首先需要了解什么是剪裁,结合下面的 HTML 代码:

<style>
  div {
    width: 200;
    height: 200;
    overflow: auto;
    background: gray;
  }
</style>
<body>
  <div>
    <p>
      所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:
    </p>
    <p>
      从上图我们可以看到,document层上有A和B层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。
    </p>
    <p>
      图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update
      LayerTree)。
    </p>
  </div>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

在这里我们把 div 的大小限定为 200 _ 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 _ 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域,下图是运行时的执行结果:

剪裁执行结果

出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。你可以参考下图:

被裁剪的内容会出现在单独一层

所以说,元素有了层叠上下文的属性或者需要被剪裁,满足其中任意一点,就会被提升成为单独一层。

# 图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来看看渲染引擎是怎么实现图层绘制的?

试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?

通常,你会把你的绘制操作分解为三步:

  1. 绘制蓝色背景;

  2. 在中间绘制一个红色的圆;

  3. 再在圆上绘制绿色三角形。

渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:

绘制列表

从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。

可以打开开发者工具的 “Layers” 标签,选择 “document” 层,来实际体验下绘制列表,如下图所示:

一个图层的绘制列表

在该图中,区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。

# 栅格化(raster)操作

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:

渲染进程中的合成线程和主线程

如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?

首先得先来看看什么是视口,可以参看下图:

视口

通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。

在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256 x 256 或者 512 x 512,如下图所示:

图层被划分为图块示意图

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。

而图块是栅格化执行的最小单位。

渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

合成线程提交图块给栅格化线程池

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式可以参考下图:

GPU 栅格化

从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。

# 合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令—— “DrawQuad” ,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。

# 渲染流水线大总结

到现在就已经分析完了整个渲染流程,从 HTML 到 DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面用一张图来总结下这整个渲染流程:

完整的渲染流水线示意图

结合上图,一个完整的渲染流程大致可总结为如下:

1. 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。

2. 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。

3. 创建布局树,并计算元素的布局信息。

4. 对布局树进行分层,并生成分层树。

5. 为每个图层生成绘制列表,并将其提交到合成线程。

6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。

7. 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。

8. 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。

# 相关概念

下面介绍三个和渲染流水线相关的概念—— “重排”、“重绘” 和 “合成”

# 1. 更新了元素的几何属性(重排)

参考下图:

更新元素的几何属性

从上图可以看出,如果你通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。

无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。

# 2. 更新元素的绘制属性(重绘)

接下来,再来看看重绘,比如通过 JavaScript 更改某些元素的背景颜色,渲染流水线会怎样调整呢?可以参考下图:

更新元素背景

从图中可以看出,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。

相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

# 3. 直接合成阶段

那如果更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局、分层和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:

避开重排和重绘

在上图中,我们使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。

这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局、分层和绘制三个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

# 评论区学习

查看 (opens new window)

# 浏览上下文组:如何计算 Chrome 中渲染进程的个数?

在前面导航流程这一讲中我们介绍过了,在默认情况下,如果打开一个标签页,那么浏览器会默认为其创建一个渲染进程。如果从一个标签页中打开了另一个新标签页,当新标签页和当前标签页属于同一站点的话,那么新标签页会复用当前标签页的渲染进程。

具体地讲,如果我从极客邦 (www.geekbang.org) 的标签页中打开新的极客时间 (time.geekbang.org) 标签页,由于这两个标签页属于同一站点 (相同协议、相同根域名),所以它们会共用同一个渲染进程。可以看下面这张 Chrome 的任务管理器截图:

多个标签页运行在同一个渲染进程

观察上图,我们可以看到,极客邦官网和极客时间标签页都共用同一个渲染进程,该进程 ID 是 84748。

不过如果我们分别打开这两个标签页,比如先打开极客邦的标签页,然后再新建一个标签页,再在这个新标签页中打开极客时间,这时候我们可以看到这两个标签页分别使用了两个不同的渲染进程。可以参看下图:

多个标签页运行在不同的渲染进程中

既然都是同一站点,为什么从 A 标签页中打开 B 标签页,就会使用同一个渲染进程,而分别打开这两个标签页,又会分别使用不同的渲染进程?

# 标签页之间的连接

要搞清楚这个问题,我们要先来分析下浏览器标签页之间的连接关系。

我们知道,浏览器标签页之间是可以通过 JavaScript 脚本来连接的,通常情况下有如下几种连接方式:

📌 通过标签来和新标签建立连接

这种方式我们最熟悉,比如下面这行代码:

<a href="https://time.geekbang.org/" target="_blank" class="">极客时间</a>
1

这是从极客邦官网中打开极客时间的链接,点击该链接会打开新的极客时间标签页,新标签页中的 window.opener 的值就是指向极客邦标签页中的 window,这样就可以在新的极客时间标签页中通过 opener 来操作上个极客邦的标签页了。这样我们可以说,这两个标签页是有连接的。

📌 通过 JavaScript 中的 window.open 方法来和新标签页建立连接

演示代码如下所示:

new_window = window.open("http://time.geekbang.org");
1

通过上面这种方式,可以在当前标签页中通过 new_window 来控制新标签页,还可以在新标签页中通过 window.opener 来控制当前标签页。所以我们也可以说,如果从 A 标签页中通过 window.open 的方式打开 B 标签页,那么 A 和 B 标签页也是有连接的。

其实通过上述两种方式打开的新标签页,不论这两个标签页是否属于同一站点,它们之间都能通过 opener 来建立连接,所以它们之间是有联系的。

在 WhatWG 规范中,把这一类具有相互连接关系的标签页称为浏览上下文组 ( browsing context group)。

既然提到浏览上下文组,就有必要提下浏览上下文,通常情况下,我们把一个标签页所包含的内容,诸如 window 对象,历史记录,滚动条位置等信息称为浏览上下文。这些通过脚本相互连接起来的浏览上下文就是浏览上下文组。感兴趣可以参考下规范文档 (opens new window)

也就是说,如果在极客邦的标签页中,通过链接打开了多个新的标签页,不管这几个新的标签页是否是同一站点,它们都和极客邦的标签页构成了浏览上下文组,因为这些标签页中的 opener 都指向了极客邦标签页。

Chrome 浏览器会将浏览上下文组中属于同一站点的标签分配到同一个渲染进程中。

这是因为如果一组标签页,既在同一个浏览上下文组中,又属于同一站点,那么它们可能需要在对方的标签页中执行脚本。因此,它们必须运行在同一渲染进程中。

现在我们清楚了浏览器是怎么分配渲染进程的了,接下来我们就可以来分析文章开头提的那个问题了:

既然都是同一站点,为什么从 A 标签页中打开 B 标签页,就会使用同一个渲染进程?而分别打开这两个标签页,又会分别使用不同的渲染进程?

首先来看第一种,在极客邦标签页内部通过链接打开极客时间标签页,那么极客时间标签页和极客邦标签页属于同一个浏览上下文组,且它们属于同一站点,所以浏览器会将它们分配到同一个渲染进程之中。

而第二种情况就简单多了,因为第二个标签页中并没有第一个标签页中的任何信息,第一个标签页也不包含任何第二个标签页中的信息,所以它们不属于同一个浏览上下文组,因此即便它们属于同一站点,也不会运行在同一个渲染进程之中。

下面是计算标签页的流程图,可以参考下:

计算标签页使用的渲染进程数目

# 一个 “例外”

现在我们清楚了 Chrome 浏览器为标签页分配渲染进程的策略了:

  • 如果两个标签页都位于同一个浏览上下文组,且属于同一站点,那么这两个标签页会被浏览器分配到同一个渲染进程中。

  • 如果这两个条件不能同时满足,那么这两个标签页会分别使用不同的渲染进程来渲染。

现在你可以想一下,如果从 A 标签页中打开 B 标签页,那我们能肯定 A 标签页和 B 标签页属于同一浏览上下文组吗?

答案是 “不能”。

来看下面这个问题:

https://linkmarket.aliyun.com (opens new window) 内新开的标签页都是新开一个渲染进程,这是为什么?

首先打开 linkmarket.aliyun.com 这个标签页,再在这个标签页中随便点击两个链接,然后就打开了两个新的标签页了,如下图所示:

“例外” 情况

我通过 A 标签页中的链接打开了两个新标签页,B 和 C,而且我们也可以看出来,A、B、C 三个标签页都属于同一站点,正常情况下,它们应该共用同一个渲染进程,不过通过上图我们可以看出来,A、B、C 三个标签页分别使用了三个不同的渲染进程。

既然属于同一站点,又不在同一个渲染进程中,所以可以推断这三个标签页不属于同一个浏览上下文组,那么我们接下来的分析思路就很清晰了:

1. 首先验证这三个标签页是不是真的不在同一个浏览上下文组中;

2. 然后再分析它们为什么不在同一浏览上下文组。

为了验证猜测,我们可以通过控制台,来看看 B 标签页和 C 标签标签页的 opener 的值,结果发现这两个标签页中的 opener 的值都是 null,这就确定了 B、C 标签页和 A 标签页没有连接关系,当然也就不属于同一浏览上下文组了。

验证了猜测,接下来的我们就是来查查,阿里的这个站点是不是采用了什么特别的手段,移除了这两个标签页之间的连接关系。

我们可以看看实现链接的 HTML 文件,如下图所示:

链接使用了 rel = noopener

通过上图,我们可以发现,a 链接的 rel 属性值都使用了 noopenernoreferrer,通过 noopener,我们能猜测得到这两个值是让被链接的标签页和当前标签页不要产生连接关系。

通常,将 noopener 的值引入 rel 属性中,就是告诉浏览器通过这个链接打开的标签页中的 opener 值设置为 null,引入 noreferrer 是告诉浏览器,新打开的标签页不要有引用关系。

# 站点隔离

上面我们都是基于标签页来分析渲染进程的,不过在安全沙箱中也介绍过,目前 Chrome 浏览器已经默认实现了站点隔离的功能,这意味着:

标签页中的 iframe 也会遵守同一站点的分配原则,如果标签页中的 iframe 和标签页是同一站点,并且有连接关系,那么标签页依然会和当前标签页运行在同一个渲染进程中,如果 iframe 和标签页不属于同一站点,那么 iframe 会运行在单独的渲染进程中。

先来看下面这个具体的例子:

<head>
    <title>站点隔离:demo</title>
    <style>
        iframe {
            width: 800px;
            height: 300px;
        }
    </style>
</head>
<body>
    <div><iframe src="iframe.html"></iframe></div>
    <div><iframe src="https://www.infoq.cn/"></iframe></div>
    <div><iframe src="https://time.geekbang.org/"></iframe></div>
    <div><iframe src="https://www.geekbang.org/"></iframe></div>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

在 Chrome 浏览器中打开上面这个标签页,然后观察 Chrome 的任务管理,我们会发现这个标签页使用了四个渲染进程,如下图所示:

iframe 使用单独的渲染进程

结合上图和 HTML 代码,我们可以发现,由于 InfoQ、极客邦两个 iframe 与父标签页不属于同一站点,所以它们会被分配到不同的渲染进程中,而 iframe.html 和源标签页属于同一站点,所以它会和源标签页运行在同一个渲染进程中。下面是计算 iframe 使用渲染进程数目的流程图,可以对照着参考下:

计算 iframe 所使用的渲染进程数目总结

# 评论区学习

查看 (opens new window)

上次更新时间: 2023年12月16日 17:23:04