浏览器原理
# 1. 宏观视角下的浏览器
# 1.1 Chrome架构演变史
# 单进程浏览器时代:
单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等
如此多的功能模块运行在一个进程里,导致单进程浏览器不稳定、不流畅和不安全的一个主要因素。
- 不稳定
早期浏览器需要借助于插件来实现诸如 Web 视频、Web 游戏等各种强大的功能,但是插件是最容易出问题的模块,并且还运行在浏览器进程之中,所以一个插件的意外崩溃会引起整个浏览器的崩溃。
除了插件之外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。和插件一样,渲染引擎的崩溃也会导致整个浏览器的崩溃。
- 不流畅
所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行。
function freeze() {
while (1) {
console.log("freeze");
}
}
freeze();
如果让这个脚本运行在一个单进程浏览器的页面里 会导致什么?
因为浏览器中所有的页面都运行在该线程中,所以这些页面都没有机会去执行任务,这样就会导致整个浏览器失去响应,变卡顿。
除了上述脚本或者插件会让单进程浏览器变卡顿外,页面的内存泄漏也是单进程变慢的一个重要原因。通常浏览器的内核都是非常复杂的,运行一个复杂点的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是使用时间越长,内存占用越高,浏览器会变得越慢。
- 不安全
插件可以使用 C/C++ 等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。
# 多进程浏览器时代
早期多进程架构:
Chrome早期多进程架构,由插件进程、渲染进程和主进程组成,进程之间由 IPC进行通信
- 由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就解决了页面或者插件的崩溃会导致整个浏览器崩溃。
多进程架构也会出现一个页面崩溃影响到其他页面的情况
通常情况下是一个页面使用一个进程,但是,有一种情况,叫"同一站点(same-site)"。
具体地讲,我们将“同一站点”定义为根域名(例如,geekbang.org)加上协议(例如,https:// 或者http://),还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:
- https://time.geekbang.org
- https://www.geekbang.org
- https://www.geekbang.org:8080
都是属于同一站点,因为它们的协议都是https,而根域名也都是geekbang.org。
Chrome的默认策略是:每个标签对应一个渲染进程。但是如果从一个页面打开了新页面,而新页面和当前页面属于同一站点时,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫process-per-site-instance
。 直白的讲,就是如果几个页面符合同一站点,那么他们将被分配到一个渲染进程里面去。 所以,这种情况下,一个页面崩溃了,会导致同一站点的页面同时崩溃,因为他们使用了同一个渲染进程。 为什么要让他们跑在一个进程里面呢? 因为在一个渲染进程里面,他们就会共享JS的执行环境,也就是说A页面可以直接在B页面中执行脚本。因为是同一家的站点,所以是有这个需求的。
同一站点和同源策略区别
内存泄漏的解决方法: 因为当关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。
安全问题解决:使用安全沙箱。可以把沙箱看成是操作系统给进程上了一把锁,沙箱里面的程序可以运行,但是不能在你的硬盘上写入任何数据,也不能在敏感位置读取任何数据,例如你的文档和桌面。Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。
# 目前多进程架构
最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
浏览器主进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,
排版引擎 Blink
和JavaScript 引擎 V8
都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。GPU进程:Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
# 优点/缺点
多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:
- 更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
- 更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。
对于上面这两个问题,Chrome 团队一直在寻求一种弹性方案,既可以解决资源占用高的问题,也可以解决复杂的体系架构的问题。
# 未来面向服务的架构
为了解决这些问题,在 2016 年,Chrome 官方团队使用“面向服务的架构”(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。
通俗来说就是以前是每个页面需要若干进程完成各自的工作,现在是将各个页面通用的功能(视频、网络、渲染等)发布为系统服务,页面在需要的时候与相应的服务通信完成需要的功能。这起码把进程间的耦合从页面中分离出去了。
Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务
Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上(如下图),Chrome 会将很多服务整合到一个进程中,从而节省内存占用。
- 在资源不足的设备上,将服务合并到浏览器进程中
早期浏览器: 不稳定(单独进程) 不流畅(单独进程) 不安全(沙箱)
早期多进程架构: 主进程 渲染进程 插件进程
现代多进程架构: 主进程 渲染进程 插件进程 GPU进程 网络进程
未来: 面向服务架构
# 2. TCP协议
性能指标:
- FP(First Paint):从开始加载到浏览器首次绘制像素到屏幕上的时间,也就是页面在屏幕上首次发生视觉变化的时间。
- FCP(First Contentful Paint):浏览器首次绘制来自 DOM 的内容的时间。这是用户第一次开始看到页面内容,但仅仅有内容,并不意味着它是有用的内容(例如 Header、导航栏等),也不意味着有用户要消费的内容。
- FMP(First Meaningful Paint):页面的主要内容绘制到屏幕上的时间。主要内容的定义因页面而异,例如对于博客文章,它的主要内容是标题和摘要,对于搜索页面,它的主要内容是搜索结果,对于电商的页面,图片则是主要内容。
- FSP(First Screen Paint):页面从开始加载到首屏内容全部绘制完成的时间,用户可以看到首屏的全部内容。
- TTI(Time to Interactive):表示网页第一次完全达到可交互状态的时间点,浏览器已经可以持续性的响应用户的输入。
互联网中的数据是通过数据包来传输的。如果发送的数据很大,那么该数据就会被拆分为很多小数据包来传输。比如你现在听的音频数据,是拆分成一个个小的数据包来传输的,并不是一个大的文件一次传输过来的。
- IP:把数据包送达目的主机
IP协议把数据分解为数据包,打包时加上本机的Ip地址,称为ip头数据
,通过物理底层传输出去。数据包的构成:数据部分+IP头部分
。
- 上层将含有“极客时间”的数据包交给网络层;
- 网络层再将 IP 头附加到数据包上,组成新的 IP 数据包,并交给底层;
- 底层通过物理网络将数据包传输给主机 B;
- 数据包被传输到主机 B 的网络层,在这里主机 B 拆开数据包的 IP 头信息,并将拆开来的数据部分交给上层;
- 最终,含有“极客时间”信息的数据包就到达了主机 B 的上层了。
# UDP:把数据包送达应用程序
“用户数据包协议(User Datagram Protocol)”,简称 UDP。
UDP 中一个最重要的信息是端口号,端口号其实就是一个数字,每个想访问网络的程序都需要绑定一个端口号。通过端口号 UDP
就能把指定的数据包发送给指定的程序了,所以 IP
通过 IP
地址信息把数据包发送给指定的电脑,而 UDP
通过端口号把数据包分发给正确的程序。和 IP
头一样,端口号会被装进UDP
头里面,UDP
头再和原始数据包合并组成新的 UDP
数据包。UDP
头中除了目的端口,还有源端口号等信息。
- 上层将含有“极客时间”的数据包交给传输层;
- 传输层会在数据包前面附加上 UDP 头,组成新的 UDP 数据包,再将新的 UDP 数据包交给网络层;
- 网络层再将 IP 头附加到数据包上,组成新的 IP 数据包,并交给底层;
- 数据包被传输到主机 B 的网络层,在这里主机 B 拆开 IP 头信息,并将拆开来的数据部分交给传输层;
- 在传输层,数据包中的 UDP 头会被拆开,并根据 UDP 中所提供的端口号,把数据部分交给上层的应用程序;
- 最终,含有“极客时间”信息的数据包就旅行到了主机 B 上层应用程序这里。
# UDP优/缺点
在使用 UDP 发送数据时,有各种因素会导致数据包出错,虽然 UDP 可以校验数据是否正确,但是对于错误的数据包,UDP 并不提供重发机制
,只是丢弃当前的包,而且 UDP 在发送之后也无法知道是否能达到目的地。
UDP 不能保证数据可靠性,但是传输速度却非常快,所以 UDP 会应用在一些关注速度、但不那么严格要求数据完整性的领域,如在线视频、互动游戏等。
# TCP:把数据完整地送达应用程序
UDP缺点:
- 数据包在传输过程中容易丢失;
- 大文件会被拆分成很多小的数据包来传输,这些小的数据包会经过不同的路由,并在不同的时间到达接收端,而 UDP 协议并不知道如何组装这些数据包,从而把这些数据包还原成完整的文件。
TCP
(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。相对于 UDP,TCP 有下面两个特点:
- 对于数据包丢失的情况,TCP 提供
重传机制
; - TCP 引入了
数据包排序机制
,用来保证把乱序的数据包组合成一个完整的文件。
和UDP 头一样,TCP 头除了包含了目标端口和本机端口号外,还提供了用于排序的序列号,以便接收端通过序号来重排数据包。
与UDP不同的地方在于,通过 TCP 头的信息保证了一块大的数据传输的完整性。
互联网中的数据是通过数据包来传输的,数据包在传输过程中容易丢失或出错。IP 负责把数据包送达目的主机。 UDP 负责把数据包送达具体应用。 而 TCP 保证了数据完整地传输,它的连接可分为三个阶段:建立连接、传输数据和断开连接 三个阶段。
TCP传送数据时 浏览器端就做渲染处理了么?如果前面数据包丢了 后面数据包先来是要等么?类似的那种实时渲染怎么处理?针对数据包的顺序性?
接收到http响应头中的content-type类型时就开始准备渲染进程了,响应体数据一旦接受到便开始做DOM解析了!基于http不用担心数据包丢失的问题,因为丢包和重传都是在tcp层解决的。http能保证数据按照顺序接收的(也就是说,从tcp到http的数据就已经是完整的了,即便是实时渲染,如果发生丢包也得在重传后才能开始渲染)
# 3. HTTP请求流程
HTTP
是一种允许浏览器向服务器获取资源的协议,是 Web
的基础,通常由浏览器发起请求,用来获取不同类型的文件,例如 HTML
文件、CSS
文件、JavaScript
文件、图片、视频等。此外,HTTP 也是浏览器使用最广的协议。
# 浏览器端发起 HTTP 请求流程
如果在浏览器地址栏里输入某个网站的地址,浏览器会完成哪些动作呢?
- 构建请求 浏览器构建请求行信息(如下所示),构建好后,浏览器准备发起网络请求。
GET /index.html HTTP1.1
查找缓存 当浏览器发现请求的资源已经在浏览器缓存中存有副本,它会拦截请求,返回该资源的副本,并直接结束请求,而不会再去源服务器重新下载。
准备 IP 地址和端口
浏览器使用 HTTP 协议作为应用层协议,用来封装请求的文本信息;并使用 TCP/IP 作传输层协议将它发到网络上,所以在 HTTP 工作开始之前,浏览器需要通过 TCP 与服务器建立连接。也就是说 HTTP 的内容是通过 TCP 的传输数据阶段来实现的
- DNS解析
- 等待 TCP 队列
Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。
http/1.1: 一个tcp同时只能处理一个请求,浏览器会为每个域名维护6个tcp连接! 但是每个tcp连接是可以复用的,也就是处理完一个请求之后,不断开这个tcp连接,可以用来处理下个http请求!
http2: 可以并行请求资源的,所以如果使用http2,浏览器只会为每个域名维护一个tcp连接
建立 TCP 连接
发送 HTTP 请求
浏览器会向服务器发送请求行,它包括了请求方法、请求 URI(Uniform Resource Identifier)和 HTTP 版本协议。
# 服务器端处理 HTTP 请求流程
- 返回请求
curl -i https://time.geekbang.org/
服务器会返回响应行,包括协议版本和状态码。
- 断开连接
通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭 TCP 连接。不过如果浏览器或者服务器在其头信息中加入了:
Connection:Keep-Alive
那么 TCP 连接在发送后将仍然保持打开状态,这样浏览器就可以继续通过同一个 TCP 连接发送请求。保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。比如,一个 Web 页面中内嵌的图片就都来自同一个 Web 站点,如果初始化了一个持久连接,你就可以复用该连接,以请求其他资源,而不需要重新再建立新的 TCP 连接。
- 重定向
curl -I geekbang.org
状态码301,从响应头的Location内容中取出重定向地址。
-I表示只需要获取响应头和响应行数据,而不需要获取响应体的数据 -i是为了返回响应行、响应头和响应体的数据
HTTP 请求示意图:
浏览器中的 HTTP 请求从发起到结束一共经历了如下八个阶段:构建请求、查找缓存、准备 IP 和端口(DNS)、等待 TCP 队列、建立 TCP 连接、发起 HTTP 请求、服务器处理请求、服务器返回请求和断开连接。
# 4. 导航流程
从输入URL到页面展示,这中间发生了什么?
浏览器主要负责用户交互,子进程管理和文件存储等功能。
网络进程是面向渲染进程和浏览器进程等提供网络下载功能。
渲染进程的主要职责是把从网络下载的HTML,JavaScript,CSS,图片等资源解析为可以显示和交互的页面。因为渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信息的。所以chrome采用安全沙箱模式。
首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程。
然后,在网络进程中发起真正的 URL 请求。
接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。
浏览器进程接收到网络进程的响应头数据之后,发送“提交导航 (CommitNavigation)”消息到渲染进程;
渲染进程接收到“提交导航”的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立
数据管道
;最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。
- 从输入 URL 到页面展示
当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL。如果判断输入内容符合 URL 规则,比如输入的是 time.geekbang.org
,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL.
当用户输入关键字并键入回车之后,这意味着当前页面即将要被替换成新的页面,不过在这个流程继续之前,浏览器还给了当前页面一次执行 beforeunload
事件的机会,(beforeunload事件可以在跳转前执行一些逻辑,或者取消跳转)
- URL 请求过程
接下来,便进入了页面资源请求过程。这时,浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程
首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。
然后利用 IP 地址和服务器建立 TCP 连接(如果请求协议是 HTTPS,那么还需要建立 TLS 连接)。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。
服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了
(1)重定向
在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301
或者 302
,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。
301/302区别
301:永久改变的资源位置, 302:暂时改变的资源位置
- (2)响应数据类型处理
Content-Type。Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。
- 准备渲染进程
默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。但是,也有一些例外,在某些情况下,浏览器会让多个页面直接运行在同一个渲染进程中(同一站点, iframe级别的渲染进程分配)。
打开一个新页面采用的渲染进程策略就是:
通常情况下,打开新的页面都会使用单独的渲染进程;如果从 A 页面打开 B 页面,且 A 和 B 都属于同一站点的话,那么 B 页面复用 A 页面的渲染进程;如果是其他情况,浏览器进程则会为 B 创建一个新的渲染进程。
- 提交文档 所谓提交文档,就是指浏览器进程将网络进程接收到的 HTML 数据提交给渲染进程,具体流程是这样的:
- 首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息;
- 渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”;
- 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程;
- 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。
形象描述
- 大哥大(
浏览器进程
)接收到小弟(网络进程
)响应头数据后,通知小弟(渲染进程
)(提交文档),让其与网络进程建立管道通讯, - 收到大哥大的命令,小弟(
渲染进程
)赶紧与小弟(网络进程
)建立数据管道 - 渲染进程接收完网络进程的数据后,赶紧给大哥大
(浏览器进程)
汇报(确认提交) - 大哥大知道小弟处理好琐事了,开始做小弟们做不了的事,更新
浏览器界面状态
- 安全状态 - 地址栏 URL - 前进后退的历史状态 - 更新 Web 页面
- 渲染阶段
一旦文档被提交,渲染进程便开始页面解析和子资源加载了。
- 服务器可以根据响应头来控制浏览器的行为,如跳转、网络数据类型判断。
- Chrome 默认采用每个标签对应一个渲染进程,但是如果两个页面属于同一站点,那这两个标签会使用同一个渲染进程。
- 浏览器的导航过程涵盖了从用户发起请求到提交文档给渲染进程的中间所有阶段。
从输入 URL 到页面展示,这中间发生了什么 ?
- 用户输入url并回车
- 浏览器进程检查url,组装协议,构成完整的url
- 浏览器进程通过进程间通信(IPC)把url请求发送给网络进程
- 网络进程接收到url请求后检查本地缓存是否缓存了该请求资源,如果有则将该资源返回给浏览器进程
- 如果没有,网络进程向web服务器发起http请求(网络请求),请求流程如下:
- 5.1 进行DNS解析,获取服务器ip地址,端口
- 5.2 利用ip地址和服务器建立tcp连接
- 5.3 构建请求头信息
- 5.4 发送请求头信息
- 5.5 服务器响应后,网络进程接收响应头和响应信息,并解析响应内容
- 网络进程解析响应流程;
- 6.1 检查状态码,如果是301/302,则需要重定向,从Location自动中读取地址,重新进行第4步,如果是200,则继续处理请求。
- 6.2 200响应处理: 检查响应类型Content-Type,如果是字节流类型,则将该请求提交给下载管理器,该导航流程结束,不再进行 后续的渲染,如果是html则通知浏览器进程准备渲染进程准备进行渲染。
- 准备渲染进程
- 7.1 浏览器进程检查当前url是否和之前打开的渲染进程根域名是否相同,如果相同,则复用原来的进程,如果不同,则开启新的渲染进程
- 传输数据、更新状态
- 8.1 渲染进程准备好后,浏览器向渲染进程发起“提交文档”的消息,渲染进程接收到消息和网络进程建立传输数据的“管道”
- 8.2 渲染进程接收完数据后,向浏览器发送“确认提交”
- 8.3 浏览器进程接收到确认消息后更新浏览器界面状态:安全、地址栏url、前进后退的历史状态、更新web页面。
# 5. 渲染流程
由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线
- 按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成
# 5.1 构建 DOM 树
这是因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。
构建 DOM 树的输入内容是一个非常简单的 HTML 文件,然后经由 HTML
解析器解析,最终输出树状结构的DOM
。
# 5.2 样式计算(Recalculate Style)
- 把 CSS 转换为浏览器能够理解的结构 CSS 样式来源主要有三种:
- 通过 link 引用的外部 CSS 文件
- 元素的 style 属性内嵌的 CSS
当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets(cssom树)。
# 5.3 布局阶段
Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。
- 创建布局树
- 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
- 而不可见的节点会被布局树忽略掉
- 布局计算
# 5.4 分层
渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层
渲染引擎为特定的节点创建新的图层:
渲染引擎会为节点创建新的图层:
- 拥有层叠上下文属性的元素 层叠上下文属性:明确定位属性的元素,定义透明属性的元素,使 用css滤镜元素,设置z-index属性的元素
- 需要裁剪的地方
# 5.5 图层绘制
可以打开“开发者工具”的“Layers”标签,选择“document”层,来实际体验下绘制列表,如下图所示:
区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。
# 5.6 栅格化(raster)操作
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:
当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程
在有些情况下,有的图层可以很大,比如有的页面要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512
- 合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行。
- 栅格化:指讲图块转化为位图;
- 图块是栅格化的最小单位,渲染进程维护了一个栅格化线程池,所有的图块栅格化都是在栅格化线程池内执行;
栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。
# 5.7 合成和显示
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——DrawQuad
,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz
的组件,用来接收合成线程发过来的 DrawQuad
命令,然后根据 DrawQuad
命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
整体渲染:
- 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
- 创建布局树,并计算元素的布局信息。对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
- 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
- 浏览器进程根据
DrawQuad
消息生成页面,并显示到显示器上。
“重排”“重绘”和“合成”:
重排: 更新元素的几何属性,重排需要更新完整的渲染流水线 重绘: 更新元素的绘制属性,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。 合成: CSS3 transform,避开重排和重绘阶段,直接在非主线程上执行合成动画操作,相对于重绘和重排,合成能大大提升绘制效率。
减少重排重绘, 方法如下:
- 使用 class 操作样式,而不是频繁操作 style
- 避免使用 table 布局
- 批量dom 操作,例如 createDocumentFragment,或者使用框架,例如 React
- Debounce window resize 事件
- 对 dom 属性的读写要分离
- will-change: transform 做优化
# 6. 浏览器下的执行机制
# 6.1 变量提升
JavaScript 代码的执行流程
1.编译阶段: JS代码在正式执行前会先进行编译,在此过程中会生成执行上下文和可执行代码。所谓执行上下文就是一段JS代码的执行环境。在执行上下文中会创建一个变量环境对象,JS引擎在遇到var声明的变量或者function声明的函数时,会在该环境对象中创建一个与变量或函数同名的属性保存undefined或函数在堆中的地址。其他声明以外的代码,JS引擎会将其编译为字节码。(对于let声明的变量保存在执行上下文的词法环境中) 。 函数提升要比变量提升的优先级要高一些,且不会被变量声明覆盖,但是会被变量赋值之后覆盖。
2.执行阶段: 按照顺序一行一行的执行可执行代码,遇到变量或者函数,会去变量环境对象中去查找,当执行过程中遇到变量赋值则会将结果更新到环境对象中。
# 6.2 调用栈
- 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
- 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
- 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
- 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。
# 7. 页面循环系统
# 7.1 消息队列和事件循环
- 第一版线程模型
如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务
- 第二版线程模型 在线程中引入事件循环
处理其他线程发送过来的任务
渲染主线程会频繁接收到来自于 IO
线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。
- 第三版线程模型
消息队列:
队列+线程模型:
- 添加一个消息队列;
- IO 线程中产生的新任务添加进消息队列尾部;
- 渲染主线程会循环地从消息队列头部中读取任务,执行任务。
单一主进程多个子进程操作同一个消息队列在I/O任务时候需要加上同步锁避免出错。
- 跨进程之间的任务
渲染进程专门有一个 IO
线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。
- 消息队列中的任务类型
有很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。 除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。
以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。
# 7.2 setTimeout实现原理
浏览器页面是由消息队列和事件循环系统来驱动的。
# 浏览器怎么实现 setTimeout
渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。
要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,不能将定时器的回调函数直接添加到消息队列中。
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
void ProcessTimerTask(){
//从delayed_incoming_queue中取出已经到期的定时器任务
//依次执行这些任务
}
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
for(;;){
//执行消息队列中的任务
Task task = task_queue.takeTask();
ProcessTask(task);
//执行延迟队列中的任务
ProcessDelayTask()
if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
break;
}
}
执行时机:处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask
函数。ProcessDelayTask
函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。
setTimeout 注意事项:
- 如果当前任务执行时间过久,会影响定时器任务的执行
- 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒
function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);
- 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
- 延时执行时间有最大值
Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。
function showName(){
console.log("极客时间")
}
var timerID = setTimeout(showName,2147483648);//会被理解调用执行
# 7.3 XMLHttpRequest实现原理
# 回调函数 VS 系统调用栈
将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。
回调函数 callback 是在主函数 doWork 返回之前执行的,我们把这个回调过程称为同步回调
。
回调函数在主函数外部执行的过程称为异步回调
。
let callback = function(){
console.log('i am do homework')
}
function doWork(cb) {
console.log('start do work')
setTimeout(cb,1000)
console.log('end do work')
}
doWork(callback)
浏览器页面是通过事件循环机制来驱动的,每个渲染进程都有一个消息队列,页面主线程按照顺序来执行消息队列中的事件,如执行 JavaScript 事件、解析 DOM 事件、计算布局事件、用户输入事件等等,如果页面有新的事件产生,那新的事件将会追加到事件队列的尾部。所以消息队列和主线程循环机制保证了页面有条不紊地运行。
当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈
。这个系统调用栈类似于 JavaScript 的调用栈。每次执行一个任务,这个栈就会重新创建。
- 消息循环系统调用栈记录
这幅图记录了一个
Parse HTML
的任务执行过程,其中黄色的条目表示执行 JavaScript 的过程,其他颜色的条目表示浏览器内部系统的执行过程。
Parse HTML 任务在执行过程中会遇到一系列的子过程,比如在解析页面的过程中遇到了 JavaScript 脚本,那么就暂停解析过程去执行该脚本,等执行完成之后,再恢复解析过程。然后又遇到了样式表,这时候又开始解析样式表……直到整个任务执行完成。
异步回调是指回调函数在主函数之外执行,一般有两种方式: 第一种是把异步函数做成一个任务,添加到信息队列尾部; 第二种是把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务了。
# XMLHttpRequest 运作机制
function GetWebData(URL){
/**
* 1:新建XMLHttpRequest请求对象
*/
let xhr = new XMLHttpRequest()
/**
* 2:注册相关事件回调处理函数
*/
xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 0: //请求未初始化
console.log("请求未初始化")
break;
case 1://OPENED
console.log("OPENED")
break;
case 2://HEADERS_RECEIVED
console.log("HEADERS_RECEIVED")
break;
case 3://LOADING
console.log("LOADING")
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log("DONE")
break;
}
}
xhr.ontimeout = function(e) { console.log('ontimeout') }
xhr.onerror = function(e) { console.log('onerror') }
/**
* 3:打开请求
*/
xhr.open('Get', URL, true);//创建一个Get请求,采用异步
/**
* 4:配置参数
*/
xhr.timeout = 3000 //设置xhr请求的超时时间
xhr.responseType = "text" //设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")
/**
* 5:发送请求
*/
xhr.send();
}
- 创建
XMLHttpRequest
对象。当执行到let xhr = new XMLHttpRequest()后,JavaScript 会创建一个 XMLHttpRequest 对象 xhr,用来执行实际的网络请求操作。 - 为 xhr 对象注册回调函数。 因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。
- 配置基础的请求信息。
- 发起请求。
渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC
来通知渲染进程;
渲染进程接收到消息之后,会将xhr
的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
# 7.4 宏任务和微任务
# 宏任务
- 渲染事件(如解析 DOM、计算布局、绘制);
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
- JavaScript 脚本执行事件;
- 网络请求完成、文件读写完成事件。
为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务。
# 事件循环机制
- 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
- 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
- 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
- 最后统计执行完成的时长等信息。
# 微任务
当 JavaScript 执行一段脚本的时候:
- 渲染主线程执JS脚本
- JS脚本的执行是一个宏任务
- 宏任务中存在微任务队列
# 微任务产生的时机和执行微任务队列的时机
产生微任务有两种方式:
- MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
- 使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。
通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。
如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。
宏任务和微任务之间的关系
因为在微任务中产生的宏任务也是要插入到消息队列或者是延迟队列的末尾的,这肯定是需要下一次事件循环才有可能被执行的,而微任务在这一次的事件循环之前就会被执行
# 7.5 async/await
# 生成器 VS 协程
生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。
function* genDemo() {
console.log("开始执行第一段")
yield 'generator 2'
console.log("开始执行第二段")
yield 'generator 2'
console.log("开始执行第三段")
yield 'generator 2'
console.log("执行结束")
return 'generator 2'
}
console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')
函数 genDemo 并不是一次执行完的,全局代码和 genDemo 函数交替执行。其实这就是生成器函数的特性,可以暂停执行
,也可以恢复执行
。
在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。外部函数可以通过 next
方法恢复函数的执行。
协程是一种比线程更加轻量级的存在。可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。
- 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。
- 要让 gen 协程执行,需要通过调用 gen.next。
- 当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
- 如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程。
提示
gen 协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过 yield 和 gen.next 来配合完成的。
当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。
# async/await
- async async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。
async function foo() {
return 2
}
console.log(foo()) // Promise {<resolved>: 2}
- await
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
async/await 执行流程图:
首选: 执行console.log(0)这个语句,打印出来 0。
接着就是执行 foo 函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,JavaScript 引擎会保存当前的调用栈等信息,然后执行 foo 函数中的console.log(1)语句,并打印出 1。
然后就执行到 foo 函数中的await 100这个语句了。当执行到await 100时,会默认创建一个 Promise 对象,
let promise_ = new Promise((resolve,reject){
resolve(100)
})
在这个 promise_ 对象创建的过程中,JavaScript 引擎会将该任务提交给微任务队列。
然后 JavaScript
引擎会暂停当前协程
的执行,将主线程的控制权转交给父协程
执行,同时会将 promise_
对象返回给父协程。
主线程的控制权已经交给父协程了,这时候父协程要做的一件事是调用 promise_.then
来监控 promise
状态的改变。
接下来继续执行父协程的流程,这里执行console.log(3)
,并打印出来
随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)
的任务等待执行,执行到这里的时候,会触发 promise_.then
中的回调函数
promise_.then((value)=>{
//回调函数被激活后
//将主线程控制权交给foo协程,并将vaule值传给协程
})
该回调函数被激活以后,会将主线程的控制权交给 foo
函数的协程,并同时将 value
值传给该协程。
foo
协程激活之后,会把刚才的 value
值赋给了变量 a,然后 foo 协程继续执行后续语句,执行完成之后,将控制权归还给父协程。
# 8. 浏览器中的页面
# 8.1 DOM 树如何生成
在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。
HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。
字节流转换为 DOM 需要三个阶段:
通过分词器将字节流转换为 Token。 通过分词器先将字节流转换为一个个 Token,分为 Tag Token 和文本 Token,Tag Token 又分 StartTag 和 EndTag,比如
<body>
就是 StartTag ,</body>
就是EndTag。第二个和第三个阶段是同步进行的,需要将
Token
解析为DOM
节点,并将DOM
节点添加到DOM
树中。
HTML 解析器维护了一个 Token
栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:
如果压入到栈中的是
StartTag Token
,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。如果分词器解析出来是
文本 Token
,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。如果分词器解析出来的是 EndTag 标签,比如是
EndTag div
,HTML 解析器会查看 Token 栈顶的元素是否是StarTag div
,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。
通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。
HTML 解析器
开始工作时,会默认创建了一个根为 document
的空 DOM 结构
# JavaScript 是如何影响 DOM 生成的
解析到<script>
标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
</script>
<div>test</div>
</body>
</html>
执行脚本时 DOM 的状态:
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>
执行到 JavaScript 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码,不过这里执行 JavaScript 时,需要先下载这段 JavaScript 代码。这里需要重点关注下载环境,因为 JavaScript 文件的下载过程会阻塞 DOM 解析
,而通常下载又是非常耗时的,会受到网络环境、JavaScript 文件大小等因素的影响。
Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作
。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
优化:
async
或defer
属性优化 script src 加载
async/defer 区别
使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用了 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。
# 8.2 CSS如何影响首次渲染时的白屏时间
含有 JavaScript 文件和 CSS 文件页面的渲染流水线:
在接收到 HTML 数据之后的预解析过程中,HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,然后就同时发起这两个文件的下载请求,需要注意的是,这两个文件的下载过程是重叠的,所以下载时间按照最久的那个文件来算。
后面的流水线就和前面是一样的了,不管 CSS 文件和 JavaScript 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JavaScript 脚本,最后再继续构建 DOM,构建布局树,绘制页面。
# 影响页面展示的因素以及优化策略
渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验。
从发起 URL 请求开始,到首次显示页面的内容,在视觉上经历的三个阶段。
第一个阶段,等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容(影响因素主要是网络或者是服务器处理)
第二个阶段,提交数据之后渲染进程会创建一个空白页面,我们通常把这段时间称为解析白屏,并等待 CSS 文件和 JavaScript 文件的加载完成,生成 CSSOM 和 DOM,然后合成布局树,最后还要经过一系列的步骤准备首次渲染。
第三个阶段,等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来。
针对第二个阶段:通常存在的瓶颈:下载 CSS 文件、下载 JavaScript 文件和执行 JavaScript。
缩短白屏时长,策略:
- 通过内联 JavaScript、内联 CSS 来移除这两种类型的文件下载,这样获取到 HTML 文件之后就可以直接开始渲染流程了。
- 但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过 webpack 等工具移除一些不必要的注释,并压缩 JavaScript 文件。
- 还可以将一些不需要在解析 HTML 阶段使用的 JavaScript 标记上 sync 或者 defer。
- 对于大的 CSS 文件,可以通过媒体查询属性,将其拆分为多个不同用途的 CSS 文件,这样只有在特定的场景下才会加载特定的 CSS 文件。
# 8.3 CSS动画比Javascript 高效
# 显示器是怎么显示图像的
每个显示器都有固定的刷新频率,通常是 60HZ,也就是每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方,显示器所做的任务很简单,就是每秒固定读取 60 次前缓冲区中的图像,并将读取的图像显示到显示器上。
# 显卡作用
显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。
# 帧 VS 帧率
渲染流水线生成的每一副图片称为一帧,把渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中 1 秒更新了 60 帧,那么帧率就是 60Hz(或者 60FPS)
由于用户很容易观察到那些丢失的帧,如果在一次动画过程中,渲染引擎生成某些帧的时间过久,那么用户就会感受到卡顿,这会给用户造成非常不好的印象。
要解决卡顿问题,就要解决每帧生成时间过久的问题,为此 Chrome 对浏览器渲染方式做了大量的工作,其中最卓有成效的策略就是引入了分层
和合成机制
。
# 分层/合成机制
在 Chrome 的渲染流水线中,分层体现在生成布局树之后,渲染引擎会根据布局树的特点将其转换为层树(Layer Tree),层树是渲染流水线后续流程的基础结构。
层树中的每个节点都对应着一个图层,下一步的绘制阶段就依赖于层树中的节点,绘制阶段其实并不是真正地绘出图片,而是将绘制指令组合成一个列表,比如一个图层要设置的背景为黑色,并且还要在中间画一个圆形,那么绘制过程会生成|Paint BackGroundColor:Black | Paint Circle|这样的绘制指令列表,绘制过程就完成了。
有了绘制列表之后,就需要进入光栅化阶段了,光栅化就是按照绘制列表中的指令生成图片。每一个图层都对应一张图片,合成线程有了这些图片之后,会将这些图片合成为“一张”图片,并最终将生成的图片发送到后缓冲区。这就是一个大致的分层、合成流程。
合成操作是在渲染进程的合成线程上执行的,执行速度快,且不占用主线程。
# 9. 浏览器中的网络
# 9.1 HTTP/1
# 超文本传输协议 HTTP/0.9
HTTP/0.9 请求流程:
- 因为 HTTP 都是基于 TCP 协议的,所以客户端先要根据 IP 地址、端口和服务器建立 TCP 连接,而建立连接的过程就是 TCP 协议三次握手的过程。
- 建立好连接之后,会发送一个 GET 请求行的信息,如GET /index.html用来获取 index.html。
- 服务器接收请求信息之后,读取对应的 HTML 文件,并将数据以 ASCII 字符流返回给客户端。
- HTML 文档传输完成后,断开连接。
HTTP/0.9 特点:
- 第一个是只有一个请求行,并没有 HTTP 请求头和请求体,因为只需要一个请求行就可以完整表达客户端的需求了。
- 第二个是服务器也没有返回头信息,这是因为服务器端并不需要告诉客户端太多信息,只需要返回数据就可以了。
- 第三个是返回的文件内容是以 ASCII 字符流来传输的,因为都是 HTML 格式的文件,所以使用 ASCII 字节码来传输是最合适的。
# 被浏览器推动的 HTTP/1.0
在浏览器中展示的不单是 HTML 文件了,还包括了 JavaScript、CSS、图片、音频、视频等不同类型的文件。因此支持多种类型的文件下载是 HTTP/1.0 的一个核心诉求,而且文件格式不仅仅局限于 ASCII 编码,还有很多其他类型编码的文件。
HTTP/1.0 引入了请求头和响应头,它们都是以为 Key-Value 形式保存的,在 HTTP 发送请求时,会带上请求头信息,服务器返回数据时,会先返回响应头信息。
HTTP/1.0 具体的请求流程:
HTTP/1.0 通过请求头和响应头来进行协商,在发起请求时候会通过 HTTP 请求头告诉服务器它期待服务器返回什么类型的文件、采取什么形式的压缩、提供什么语言的文件以及文件的具体编码。
accept: text/html
accept-encoding: gzip, deflate, br
accept-Charset: ISO-8859-1,utf-8
accept-language: zh-CN,zh
HTTP/1.0 除了对多文件提供良好的支持外, 引入了:状态码
、缓存机制
、用户代理信息
# 缝缝补补的 HTTP/1.1
1. 改进持久连接
HTTP/1.0 每进行一次 HTTP 通信,都需要经历建立 TCP 连接、传输 HTTP 数据和断开 TCP 连接三个阶段
HTTP/1.1 中增加了持久连接的方法,它的特点是在一个 TCP 连接上可以传输多个 HTTP 请求,只要浏览器或者服务器没有明确断开连接,那么该 TCP 连接会一直保持。
HTTP 的持久连接可以有效减少 TCP 建立连接和断开连接的次数,这样的好处是减少了服务器额外的负担,并提升整体 HTTP 的请求时长。
持久连接在 HTTP/1.1 中是默认开启的,所以你不需要专门为了持久连接去 HTTP 请求头设置信息,如果你不想要采用持久连接,可以在 HTTP 请求头中加上Connection: close。目前浏览器中对于同一个域名,默认允许同时建立 6 个 TCP 持久连接。
2. 不成熟的 HTTP 管线化 - 队头阻塞
持久连接虽然能减少 TCP 的建立和断开次数,但是它需要等待前面的请求返回之后,才能进行下一次请求。如果 TCP 通道中的某个请求因为某些原因没有及时返回,那么就会阻塞后面的所有请求,这就是著名的队头阻塞
的问题。
HTTP/1.1 中试图通过管线化
的技术来解决队头阻塞的问题。HTTP/1.1 中的管线化是指将多个 HTTP 请求整批提交给服务器的技术,虽然可以整批发送请求,不过服务器依然需要根据请求顺序来回复浏览器的请求。
FireFox、Chrome 都做过管线化的试验,但是由于各种原因,它们最终都放弃了管线化技术。
3. 提供虚拟主机的支持 在 HTTP/1.0 中,每个域名绑定了一个唯一的 IP 地址,因此一个服务器只能支持一个域名。但是随着虚拟主机技术的发展,需要实现在一台物理主机上绑定多个虚拟主机,每个虚拟主机都有自己的单独的域名,这些单独的域名都公用同一个 IP 地址。
因此,HTTP/1.1 的请求头中增加了 Host
字段,用来表示当前的域名地址,这样服务器就可以根据不同的 Host 值做不同的处理。
4. 对动态生成的内容提供了完美支持
在设计 HTTP/1.0 时,需要在响应头中设置完整的数据大小,如Content-Length: 901,这样浏览器就可以根据设置的数据大小来接收数据。不过随着服务器端的技术发展,很多页面的内容都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。
HTTP/1.1 通过引入 Chunk transfer
机制来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。这样就提供了对动态内容的支持。
5. 客户端 Cookie、安全机制
HTTP/1.1 引入了客户端 Cookie 机制和安全机制
HTTP/1.1 为网络效率做了大量的优化,最核心的有如下三种方式:
- 增加了持久连接;
- 浏览器为每个域名最多同时维护 6 个 TCP 持久连接;
- 使用 CDN 的实现域名分片机制。
# 9.2 HTTP/2
HTTP/1.1 的主要问题: 对带宽的利用率
提示
带宽是指每秒最大能发送或者接收的字节数。我们把每秒能发送的最大字节数称为上行带宽,每秒能够接收的最大字节数称为下行带宽。
导致原因:
- 第一个原因,TCP 的慢启动。
一旦一个 TCP 连接建立之后,就进入了发送数据状态,刚开始 TCP 协议会采用一个非常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到一个理想状态,我们把这个过程称为慢启动。
你可以把每个 TCP 发送数据的过程看成是一辆车的启动过程,当刚进入公路时,会有从 0 到一个稳定速度的提速过程,TCP 的慢启动就类似于该过程。
慢启动是 TCP 为了减少网络拥塞的一种策略,我们是没有办法改变的。
- 第二个原因,同时开启了多条 TCP 连接,那么这些连接会竞争固定的带宽。
资源竞争时无法区分优先级
- 第三个原因,HTTP/1.1 队头阻塞的问题。
# HTTP/2 的多路复用
HTTP/1.1 所存在的一些主要问题: 慢启动和 TCP 连接之间相互竞争带宽是由于 TCP 本身的机制导致的,而队头阻塞是由于 HTTP/1.1 的机制导致的。
每个请求都有一个对应的 ID,如 stream1 表示 index.html 的请求,stream2 表示 foo.css 的请求。这样在浏览器端,就可以随时将请求发送给服务器了。
HTTP/2 使用了多路复用技术
,可以将请求分成一帧一帧的数据去传输,这样带来了一个额外的好处,就是当收到一个优先级高的请求时,比如接收到 JavaScript 或者 CSS 关键资源的请求,服务器可以暂停之前的请求来优先处理关键资源的请求。
# 多路复用的实现
HTTP/2 协议栈:
HTTP/2 添加了一个二进制分帧层
,通过引入二进制分帧层,就实现了 HTTP 的多路复用技术。
- 首先,浏览器准备好请求数据,包括了请求行、请求头等信息,如果是 POST 方法,那么还要有请求体。
- 这些数据经过二进制分帧层处理之后,会被转换为一个个带有请求 ID 编号的帧,通过协议栈将这些帧发送给服务器。
- 服务器接收到所有帧之后,会将所有
相同 ID 的帧
合并为一条完整的请求信息。然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层。 - 同样,二进制分帧层会将这些响应数据转换为一个个带有请求 ID 编号的帧,经过协议栈发送给浏览器。
- 浏览器接收到响应帧之后,会根据
ID
编号将帧的数据提交给对应的请求。
多路复用是 HTTP/2 的最核心功能,它能实现资源的并行传输。多路复用技术是建立在二进制分帧层的基础之上。其实基于二进制分帧层,HTTP/2 还附带实现了很多其他功能:
- 可以设置请求的优先级
HTTP/2 提供了请求优先级,可以在发送请求时,标上该请求的优先级,这样服务器接收到请求之后,会优先处理优先级高的请求。
- 服务器推送
HTTP/2 还可以直接将数据提前推送到浏览器。当用户请求一个 HTML 页面之后,服务器知道该 HTML 页面会引用几个重要的 JavaScript 文件和 CSS 文件,那么在接收到 HTML 请求之后,附带将要使用的 CSS 文件和 JavaScript 文件一并发送给浏览器,这样当浏览器解析完 HTML 文件之后,就能直接拿到需要的 CSS 文件和 JavaScript 文件,这对首次打开页面的速度起到了至关重要的作用。
- 头部压缩
无论是 HTTP/1.1 还是 HTTP/2,它们都有请求头和响应头,这是浏览器和服务器的通信语言。HTTP/2 对请求头和响应头进行了压缩,提升文件传输效率。
# 9.3 HTTP/3
HTTP/2 的一个核心特性是使用了多路复用技术
,因此它可以通过一个 TCP 连接来发送多个 URL 请求。多路复用技术能充分利用带宽,最大限度规避了 TCP 的慢启动
所带来的问题,同时还实现了头部压缩
、服务器推送
等功能,使得页面资源的传输速度得到了大幅提升。在 HTTP/1.1 时代,为了提升并行下载效率,浏览器为每个域名维护了 6 个 TCP 连接;而采用 HTTP/2 之后,浏览器只需要为每个域名维护 1 个 TCP 持久连接,同时还解决了 HTTP/1.1 队头阻塞的问题。
TCP 的队头阻塞
在数据传输的过程中,有一个数据因为网络故障或者其他原因而丢包了,那么整个 TCP 的连接就会处于暂停状态,需要等待丢失的数据包被重新传输过来:
在 TCP 传输过程中,由于单个数据包的丢失而造成的阻塞称为 TCP 上的队头阻塞。
HTTP/2 多路复用
通过该图,我们知道在 HTTP/2 中,多个请求是跑在一个 TCP 管道中的,如果其中任意一路数据流中出现了丢包的情况,那么就会阻塞该 TCP 连接中的所有请求。这不同于 HTTP/1.1,使用 HTTP/1.1 时,浏览器为每个域名开启了 6 个 TCP 连接,如果其中的 1 个 TCP 连接发生了队头阻塞,那么其他的 5 个连接依然可以继续传输数据。
TCP 建立连接的延时
把从浏览器发送一个数据包到服务器,再从服务器返回数据包到浏览器的整个往返时间称为 RTT。
TCP 协议僵化:
TCP 协议存在队头阻塞和建立连接延迟。
中间设备的僵化: 如果我们在客户端升级了 TCP 协议,但是当新协议的数据包经过这些中间设备时,它们可能不理解包的内容,于是这些数据就会被丢弃掉。这就是中间设备僵化,它是阻碍 TCP 更新的一大障碍
操作系统也是导致 TCP 协议僵化的另外一个原因:因为 TCP 协议都是通过操作系统内核来实现的,应用程序只能使用不能修改。通常操作系统的更新都滞后于软件的更新,因此要想自由地更新内核中的 TCP 协议也是非常困难的。
QUIC 协议
UDP 协议,基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能,我们把这套功能称为 QUIC 协议。
HTTP/2 和 HTTP/3 协议栈:
实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些 TCP 中存在的特性。
集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相较于早期版本 TLS1.3 有更多的优点,其中最重要的一点是减少了握手所花费的 RTT 个数。
实现了 HTTP/2 中的多路复用功能。和 TCP 不同,QUIC 实现了在同一物理连接上可以有多个独立的逻辑数据流(如下图)。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题。
实现了快速握手功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使用 0-RTT 或者 1-RTT 来建立连接,这意味着 QUIC 可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度。
QUIC 丢掉了 TCP、TLS 的包袱,基于 UDP,并对 TCP、TLS、HTTP/2 的经验加以借鉴、改进,实现了一个安全高效可靠的 HTTP 通信协议。凭借着 0 RTT 建立连接、平滑的连接迁移、基本消除了队头阻塞、改进的拥塞控制和流量控制等优秀的特性,QUIC 在绝大多数场景下获得了比 HTTP/2 更好的效果。
HTTP/3 的挑战
第一,从目前的情况来看,服务器和浏览器端都没有对 HTTP/3 提供比较完整的支持。Chrome 虽然在数年前就开始支持 Google 版本的 QUIC,但是这个版本的 QUIC 和官方的 QUIC 存在着非常大的差异。
第二,部署 HTTP/3 也存在着非常大的问题。因为系统内核对 UDP 的优化远远没有达到 TCP 的优化程度,这也是阻碍 QUIC 的一个重要原因。
中间设备僵化的问题。这些设备对 UDP 的优化程度远远低于 TCP,据统计使用 QUIC 协议时,大约有 3%~7% 的丢包率。