浏览器的多进程架构
进程是资源分配的最小单位,线程是CPU调度的最小单位。
在正式开始之前,我认为有必要以 Chrome 为例,介绍一下现代浏览器的多进程架构(multi-process architecture)
其中主要的部分有:
- 浏览器进程 (Browser Process),也叫主进程
- UI 线程(UI Thread),控制浏览器上的按钮和输入框等UI
- 网络线程(NetWork Thread),负责资源的下载
- 存储线程(Storage Thread),负责本地缓存文件的访问
- 渲染进程 (Renderer Process),也叫浏览器内核
- JS 引擎,负责执行 JavaScript,也是 JS 是单线程的由来
- GUI 渲染线程,负责渲染资源,与 JS 引擎互斥(一个运行一个挂起)
- 事件触发线程,管理事件循环 (鼠标点击、setTimeout、Mutation Observer、Ajax等),按顺序把事件放到 JS 执行队列
- 定时器线程,
setTimeout
并不是 JS 的功能,只是浏览器开给 JS 的一个接口 - 异步请求线程,处理
AJAX
请求,通过回调函数通知事件触发进程
- GPU进程,负责与 GPU 通信
- 第三方插件进程,就是我们安装的浏览器插件
说完浏览器的多进程架构,下面我们就正式开始讲浏览器从输入URL到页面展示到底发生了什么。
1. 构建请求
输入URL后,主线程中的UI进程接收到用户输入的URL,判断是query还是URL。
如果是搜索关键字,会将其拼接到默认搜索引擎的参数部分去搜索。这个流程需要对输入的不安全字符编码进行转义(安全字符指的是数字、英文和少数符号)。因为URL的参数是不能有中文的,也不能有一些特殊字符,比如= ? &
。(会被认为是URL本身的分隔符=
而产生歧义)
URL对非安全字符转义时,使用的编码叫百分号编码,因为它使用百分号加上两位的16进制数表示。这两位16进制数来自UTF-8编码,将每一个中文转换成3个字节。
我们经常会用的encodeURI
和 encodeURIComponent
正是起这个作用的,它们的规则基本一样,只是= ? & ; /
这类URI组成符号,这些在encodeURI
中不会被编码,但在encodeURIComponent
中统统会。因为encodeURI
是编码整个URL,而encodeURIComponent
编码的是参数部分。
(与之相反的解析方法即decodeURI
&decodeURIComponent
)
如果是URL,会把不完整的URL合成完整的URL。一个完整的URL应该是:协议+主机+端口+路径[+参数][+锚点]
。比如我们在地址栏输入www.baidu.com
,浏览器最终会将其拼接成https://www.baidu.com/
,默认使用443端口。
然后将URL转发给网络线程,网络线程会构建请求行信息,构建好后,浏览器就准备发起网络请求。
// 请求方法是GET,路径为根路径,HTTP协议版本为1.1
GET / HTTP/1.1
2. 查找强缓存
浏览器在发起真正的网络请求之前,会先检查浏览器的强缓存,如果命中,直接返回对应资源文件的副本。否则进入下一步。
2.1 什么是强缓存
浏览器的缓存策略分为强缓存和协商缓存,他们之间的根本区别在于是否需要发请求。
简单来说,就是强缓存就是你本地文件(保存在硬盘或者内存中),你可以立马访问到。memory cache
是指从资源从内存中被取出,disk cache
是指从磁盘中被取出;从内存中读取比从磁盘中快很多,但资源能不能分配到内存要取决于当下的系统状态。通常来说,刷新页面会使用内存缓存,关闭后重新打开会使用磁盘缓存。
协商缓存是需要你发请求给服务器,问问资源是否有更新,如果没有更新就访问本地缓存;如果有更新,服务器就会返回更新后的资源文件。
2.2 强缓存的实现
在 HTTP/1.0 时代,强缓存是通过HTTP响应头的Expires字段实现的。Expires表示绝对的失效时间,例如Expires:Wed, 05 Apr 2020 00:55:35 GMT
。浏览器通过这个字段的时间和用户的本地时间做比较来判定是否要读取缓存中的资源副本。这就可能导致一个问题:用户可以自己修改本地时间,使缓存失效。
所以在 HTTP/1.1 时代中新加入了 Cache-Control 字段来解决这个问题,通过设置cache-control: max-age=XXX
,可以实现缓存在 XXX 秒后过期(相对时间),这样就规避了用户可以自己篡改本地时间使缓存失效的问题。
在cache-control
和Expires
同时存在时,以cache-control
优先。
更多cache-control指令:
可缓存性
public
表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容。(例如:1.该响应没有
max-age
指令或Expires
消息头;2. 该响应对应的请求方法是 POST 。)private
表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容,比如:对应用户的本地浏览器。
禁止任何缓存:
no-store
禁止强缓存,必须协商缓存:
no-cache
ormax-age=0, must-revalidate
注意
如果服务器关闭或失去连接,no-cache
可能会造成使用缓存。到期
max-age=<seconds>
设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与
Expires
相反,时间是相对于请求的时间。s-maxage=<seconds>
覆盖
max-age
或者Expires
头,但是仅适用于共享缓存(比如各个代理),私有缓存会忽略它。max-stale[=<seconds>]
表明客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。
min-fresh=<seconds>
表示客户端希望获取一个能在指定的秒数内保持其最新状态的响应。
重新验证和重新加载
must-revalidate
一旦资源过期(比如已经超过
max-age
),在成功向原始服务器验证之前,缓存不能用该资源响应后续请求。proxy-revalidate
与must-revalidate作用相同,但它仅适用于共享缓存(例如代理),并被私有缓存忽略。
其他
no-transform
不得对资源进行转换或转变。
Content-Encoding
、Content-Range
、Content-Type
等HTTP头不能由代理修改。例如,非透明代理或者如Google’s Light Mode可能对图像格式进行转换,以便节省缓存空间或者减少缓慢链路上的流量。no-transform
指令不允许这样做。only-if-cached
表明客户端只接受已缓存的响应,并且不要向原始服务器检查是否有更新的拷贝。
3. DNS解析
发送真正的网络请求首先需要进行DNS解析,目的就是找到URL对应的服务器IP地址。
会依次搜索:
- 浏览器的DNS缓存;
- 操作系统的DNS缓存;
- 路由器的DNS缓存;
- 向服务商的DNS服务器查询;
- 向全球13台根域名服务器查询;
为了节省时间,可以在HTML头部去做DNS的预解析:
<link rel="dns-prefetch" href="http://www.baidu.com" />
or
<meta http-equiv="x-dns-prefetch-control" content="on">
dns-prefetch
仅对跨域的 DNS查找有效当浏览器从(第三方)服务器请求资源时,必须先将该跨域域名解析为 IP 地址,然后浏览器才能发出请求。此过程称为 DNS 解析。
DNS 缓存可以帮助减少此延迟,而 DNS 解析可能导致请求增加明显的延迟。
还可以通过使用 HTTP 链接字段将
dns-prefetch
(以及其他资源提示)指定为 HTTP标头Link: <https://fonts.gstatic.com/>; rel=dns-prefetch
将
dns-prefetch
与preconnect
(预连接)提示配对<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin> <link rel="dns-prefetch" href="https://fonts.gstatic.com/"> // 给不支持preconnect的浏览器做兜底
尽管
dns-prefetch
仅执行 DNS查找,但preconnect
会建立与服务器的连接。如果站点是通过 HTTPS 服务的,则此过程包括 DNS 解析,建立 TCP 连接以及执行 TLS 握手。将两者结合起来可提供进一步减少跨域请求的感知延迟的机会。如果页面需要建立与许多第三方域的连接,则将它们预先连接会适得其反。
preconnect
提示最好仅用于最关键的连接。对于其他的,只需使用<link rel="dns-prefetch">
即可节省第一步的时间-DNS查找。
4. 建立TCP连接
知道服务器的 IP 地址后,就可以跟服务器正式建立连接了。
连接的方式分为两种,可靠的 TCP 和不可靠的 UDP。
? 为什么是三次握手而不是一次、两次?
✍️ 因为浏览器和服务器都需要确认对方有正常收发的能力;如果只有两次握手的话,客户端知道服务端能收能发,而服务端只知道客户端能发送数据,不知道是否能接受数据。
延伸问题:
三次握手?中断会怎样?
四次挥手?为什么是四次?
5. 发送请求,接收数据
建立了TCP连接,浏览器就可以和服务器通信了。HTTP中的数据就是在通信过程中传递的。
下图是完整的HTTP请求示例:
服务器收到请求后,会返回一个响应
服务器会通过响应中的状态码来告诉浏览器它的处理结果,常见的状态码如下:
- 2XX:成功,最常见的是 200 OK
- 3XX:需要进一步操作,比如 301 永久重定向,302 临时重定向, 304 未修改
- 4XX:请求出错,最常见的是 404 未找到资源,403 禁止请求
- 5XX:服务器错误,最常见的是 500 服务器内部错误,502 网关错误
6. 查找协商缓存
在上一步中,如果HTTP响应头中的状态码为 304(Not Modified 未修改),内容为空时,就相当于告诉浏览器"服务器上的资源文件和你本地缓存的资源副本一样,从缓存中拿就好啦",这就是协商缓存的流程。当强缓存过期,或者cache-control
设置为no-cache
时,就会进行协商缓存,浏览器会发送请求到服务器,根据响应头中的状态码判断是否要从缓存中读取。
6.1 协商缓存的实现
协商缓存是通过 HTTP/1.0 中的 Last-Modified
和 HTTP/1.1 中的 E-Tag
实现的。
Last-Modified 的验证规则:浏览器第一次发送请求,服务器会在响应头中带上Last-Modified
,并返回资源。浏览器下次发送同样的请求时,在请求头中If-Modified-Since
字段带上之前收到的Last-Modified
内容。服务器收到后会跟请求资源的最后修改时间做对比,如果相同,返回 HTTP 304,如果不相同,返回 HTTP 200,并返回最新的资源。如下图:
E-Tag 的验证规则:验证规则与 Last-Modified
类似,只不过浏览器第一次收到的是etag
,第二次发送的是If-None-Match
。跟 Last-Modified 的区别是 E-Tag 的内容是资源的唯一标识符,Last-Modified 的内容是最后修改时间,且 E-Tag 优先级高于 Last-Modified。
? 为什么 HTTP/1.1 要引入 E-Tag 实现协商缓存呢?
✍️ 因为:
- 有些资源会被周期性重写,但内容完全一样
- 有些资源可能被修改,但是修改内容没必要让用户重新下载(修改注释或拼写)
- 有些资源的变化时间小于1秒(比如实时监视器),所以 Last-Modified 的时间粒度不够了
这几种情况下,唯一标识资源的 E-Tag 就比 Last-Modified 管用啦。
7. 断开TCP连接
浏览器接受完服务器返回的资源后,需要断开TCP连接。断开TCP连接需要经历四次挥手,如下图:
? 那么为什么要四次挥手,而不是三次?
✍️ 因为多了服务端通知客户端数据发送完毕的第三次挥手。大家想想,如果没有第三次挥手,而是客户端直接确认关闭连接的第四次挥手,客户端就无法收到服务器还没发完的数据,导致数据丢失。传输数据要有始有终,不得不佩服发明 TCP 协议的*罗伯特*·卡恩和*文顿*·瑟夫,40多年前就发明出了如此严谨的数据传输协议。
8. 解析HTML, 构建DOM树(Construction of a DOM)
完成上面的网络请求过程后,接下来就是浏览器的渲染进程解析和渲染资源的过程了。
首先对于HTML文件,浏览器由他生成DOM树(一种浏览器可以理解的树形结构,全称是 Domcument Object Model)。
那么浏览器是如何具体构建DOM树的呢
- 转换(Conversion)浏览器读取原始字节形式的HTML,并按照指定的格式(例如 UTF-8)把这些字节翻译成个字符。
- 序列化(Tokenizing)浏览器把第一步得到的字符串转换成不同标记,例如
<html>
,<body>
等,每个标记都有自己的含义和规则。 - 词法分析(Lexing)把这些标记转换为‘对象’,来定义其属性及规则。
- 构建DOM(DOM Construction)因为 HTML 标签有特定的包含规则,比如 html 包含 body,body 包含 div,我们又通过上一步生成的对象知道了标签之间的父子关系,所以就可以构建出 DOM 树。
每次浏览器处理 HTML 文件,都会经历上面的四个过程。在 HTML 比较复杂时,整个流程可能会比较费时。
9. 样式计算,构建 CSSOM 树 (Style calculation)
样式计算的目的是为了计算出DOM节点中每个元素的具体样式,这个阶段大概分为3步:
- 把css转化为浏览器能够理解的结构(styleSheets)
跟 HTML 文本一样,浏览器是无法直接理解纯文本的 CSS 样式,所以渲染进程在接受到 CSS 文本时,会先执行一个转化操作,将 CSS 文本转化成浏览器能够理解的结构 styleSheets。
在 Chrome 控制台中输入 document.styleSheets 就可以看到如下结构:
转换样式表中的属性值,使其标准化
计算出 DOM 树中每个节点的具体样式
样式的属性已经被标准化了,接下来就是要计算 DOM 树中每个节点的样式属性了,如何计算呢?
这里就会涉及到 CSS 的继承规则和层叠规则了。
首先是CSS继承,每个DOM节点都会继承其父节点的样式,如下图
其次是样式层叠,CSS的全称就是“层叠样式表”。关于层叠顺序,可以引出CSS的优先级:
- 内联style > id > class > 标签
- 内联style > 内部style > 外部style
- 一般来讲,越具体优先级越高,
!important
的优先级最高,但是要慎用。
经过计算会生成CSSOM(Css Object Model)树 ,大致如下图所示:
让我们回顾一下浏览器处理 CSS 的过程,其实跟 HTML 是很类似的,从字节开始,翻译成字符、序列化、生成节点,最终生成 CSSOM (CSS Object Model)。
10. 布局(Layout)
我们虽然有 DOM 树和 DOM 树中每个节点的样式,但还不知道这些 DOM 元素的几何位置,所以接下来就需要计算出 DOM 树中可见元素的几何位置。我们把这个计算过程叫做布局。布局阶段可以分成两个子阶段,创建布局树和布局计算。
布局树的构造过程大概是这样:
我们可以观察到DOM树中所有display:none
的节点都没有出现在布局树中。所以构建布局树的步骤可以简单概括为:
- 遍历DOM树中的所有可见节点,并把这些节点添加到布局树中
- 不可见节点会被布局树忽略,如 head 标签下的全部内容,以及样式为
display: none
的元素
延伸问题:
display: none
& visibility: hidden
& opacity: 0
的区别
构建完布局树,接下来就是计算布局树节点的实际坐标了。至于具体的计算过程非常的复杂,我们暂且跳过,等待日后补充。
11. 生成图层树(Dividing into layers)
有了布局树,而且还计算出了每个元素的具体位置信息。绘制之前还有一个生成图层树(Layer Tree)的过程。
? 为什么需要生成图层树呢?
✍️ 因为现代的前端页面有着非常复杂的效果,比如页面滚动,z-index方向上的排序等,为了更方便的实现这些效果,渲染进程还需要为特定的节点生成专用的图层,并生成一颗对应的图层树。
渲染进程在什么情况下会为特定节点创建新的图层呢?
- 拥有层叠上下文属性的元素会被提升为单独一层
页面是二维平面,但层叠上下文能够让 HTML 具有三维概念。这些 HTML 元素会按照他们的优先级分布在垂直于二维平面的 z 轴上。具体的优先级顺序如下:
正z-index > z-index = 0 > inline > float > block > 负z-index > border > background
- 需要剪裁的地方也会被创建为图层
裁剪的意思就是要显示的内容超出它的容器(比如 200 x 200 像素的 div 里面里面放着 1000 个字),另外如果出现滚动条,滚动条也会被提升为单独的层。
12. 绘制 (Paint)
完成构建图层树之后,接下来就是渲染引擎对图层树中每个图层的绘制,具体的实现是渲染引擎会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制的列表,就像下图这样。
我们可以打开 Chrome 开发者工具的 Layers 标签,选择 “document” 层,实际体验下绘制列表的过程。给大家一个示意图作参考:
图中圈出来的就是 document 的绘制列表,拖动右侧的进度条就可以重现列表的绘制过程。
13. 栅格化 (raster)
生成绘制列表后,会进行栅格化。绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:
如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?这里需要先引入一个做过 H5 手机页面开发的都会比较熟悉的标签:
<meta name="viewport" content="width=device-width, initial-scale=1">
这里面的viewpoint就是用户可以实际看到的部分,叫做“视口”。
很多时候页面的长度都是远大于屏幕高度的,所以图层都会比较大,但是通过视口,用户只能看到其中的一部分内容,一次性绘制所有图层内容会产生非常大的开销,且没有必要。
基于这个原因,图层会将视图划分为图块(tile),这些图块的大小通常为256 * 256或者512 * 512。
然后合成线程会按照视口附近的来优先生成位图,实际生成位图的操作由栅格化来执行。
所谓栅格化,就是指将图块转换为位图。
图块是栅格化执行的最小单位。渲染进程维护了一个栅格化线程池,所有的栅格化都是在线程池内执行的。
通常,栅格化过程都会使用GPU加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。
GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。
14. 合成与显示(Composite and Display)
一旦所有图块都被栅格化,合成线程就会生成一个绘制图块的命令——“DrawQuad
”,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz
的组件,用来接收合成线程发送的DrawQuad
命令。根据命令将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。
渲染流水线总结
下面用一张图总结一下从收到服务器发来的资源后的整个渲染和显示过程,我们把这个过程称为渲染流水线。
一个完整的渲染流程可以总结如下:
- 渲染进程将HTML内容转换为能够读懂的 DOM树 结构
- 渲染进程将CSS样式表转换为浏览器可以理解的 styleSheets ,计算并生成 CSSOM树
- 创建布局树,并计算元素的布局信息
- 对布局树进行分层,并生成图层树
- 对每个图层生成绘制列表,并将其提交到合成线程
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图
- 合成线程发送制图块命令 DrawQuad 给浏览器进程
- 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上