从输入 URL 到页面展示发生了什么?

“在浏览器里,从输入 URL 到页面展示,这中间发生了什么? ”这是一道经典的面试题,能比较全面地考察应聘者知识的掌握程度,其中涉及到了网络、操作系统、Web 等一系列的知识。

今天就结合下图对这个过程进行分析。当然实际过程还远比这张图复杂得多。

line.png

一、用户输入

用户输入查询关键字后,地址栏会判断输入的关键字是搜索内容还是请求的 URL。

  • 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成带关键字的 URL。
  • 如果输入内容符合 URL 规则,地址栏会根据规则,把内容加上协议,合成完整的 URL。

当用户输入关键字并键入回车之后,这意味着当前页面即将要被替换成新的页面,不过在这个流程继续之前,浏览器还给了当前页面一次执行 beforeunload 事件的机会,beforeunload 事件允许页面在退出之前执行一些数据清理操作,还可以询问用户是否要离开当前页面,比如当前页面可能有未提交完成的表单等情况,因此用户可以通过 beforeunload 事件来取消导航,让浏览器不再执行任何后续工作。
当前页面没有监听 beforeunload 事件或者同意了继续后续流程,那么浏览器就进入加载状态。

总结就是一句话,浏览器进程处理完URL后,浏览器进程会发出 URL 请求给网络进程。

二、网络请求

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

首先,网络进程会查找本地缓存是否缓存资源。如果存在缓存资源。

1. 浏览器缓存

首先先说一下两个概念,强缓存和协商缓存。

服务端缓存控制

强缓存与服务端的缓存控制息息相关,启用强缓存有以下几种情况。

  • 存在 Cache-Control 属性,设置 max-age 属性值并且不存在 no-cache 和 no-store 。
  • 不存在 Cache-Control 属性,存在 Expires 字段。

服务端缓存控制有以下几种状态:

no-store:不允许缓存,用于某些变化非常频繁的数据,例如秒杀页面;
no-cache:可以缓存,但在使用之前必须要去服务器验证是否过期,是否存在新的版本(如果存在新版本,使用新版本,如果不存在新版本使用本地缓存);
must-revalidate:如果缓存不过期就可以继续使用,过期了如果还想用就去服务器验证;

还有两个和缓存代理相关的属性。

private:表示缓存只能在客户端保存,是用户“私有”的,不能通过代理服务器缓存,与别人分享;
public:缓存完全开放,代理服务器随便缓存,谁都可以存,谁都可以用;

客户端缓存控制

进入协商缓存之前还需要经过 DNS 解析、建立 TCP/IP 连接,如果是 https 协议还需要建立 TLS 连接。

协商缓存由客户端发起(条件请求),如果没有命中强缓存,就会进入协商缓存阶段。

条件请求一共有 5 个头字段,我们最常用的就是“if-Modified-Since”和“If-None-Match”这两个。
使用的前提是需要第一次的响应报文预先提供“Last-modified”和“ETag”,然后第二次请求就可以带上缓存里的原值,验证资源是否是最新的。

如果响应报文里提供了“Last-modified”,但没有“Cache-Control”或“Expires”,浏览器会使用“启发”(Heuristic)算法计算一个缓存时间,在 RFC 里的建议是:(Date - Last-modified) *  10%。

ETag 还有强弱之分,强 ETag 要求资源在字节级别必须完全相符,弱 ETag 在值前面有个 “W/” 标记,只有求资源在语义上没有变化,但内部可能会有部分发生改变(例如 HTML 里的标签顺序调整,或者多了几个空格)。

条件请求里其他的三个头字段是“If-Unmodified-Since”“If-Match”和“If-Range”,和 “if-Modified-Since”和“If-None-Match”用法一致。

如果资源没有变化,服务器就会回应 “304 Not Modified”,表示缓存依然有效。

请求过程中还可能发生另外一种情况,那就是服务器返回状态码 301、302。

重定向

重定向是由服务器发起的,浏览器使用者无法控制,浏览器收到 301、302 这两个状态码就会跳转到新的 URI。

其实除了状态码之外,要想实现重定向还需要 “Location”字段的配合。

Connection: keep-alive Content-Length: 151 Content-Type: text/html Date: Sun, 21 Feb 2021 00:48:52 GMT Location: /index.html Referer: /18-1 Server: openresty/1.19.3.1

“Location”字段属于响应字段,必须出现在响应报文里。但只有配合 301/302 状态码才有意义,它标记了服务器要求重定向的 URI,这里就是要求浏览器跳转到“index.html”。

浏览器收到 301/302 报文,会检查响应头里有没有“Location”。如果有,就从字段值里提取出 URI,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。

“Location”里的 URI 既可以使用绝对 URI,也可以使用相对 URI。
如果重定向只在站内跳转,可以使用相对 URI。如果跳转到站外,必须使用绝对 URI。
重定向报文里还可以用 Refresh 字段,实现延时重定向,例如“Refresh:5; url=xxx” 告诉浏览器 5 秒后再跳转。

2. DNS 解析

上面说到浏览器缓存,首先,网络进程会查找本地缓存是否缓存资源。
如果存在缓存资源,就直接返回资源给浏览器进程。如果不存在缓存,这时就要判断是否需要 DNS 解析。

首先判断 URI 是否是一个域名,如果是一个域名,走 DNS 解析流程,获取服务器的 IP 地址。

DNS 系统

DNS 的核心系统是一个三层的树状、分布式服务,基本对应域名的结构。

  • 根域名服务器(Root DNS Server):管理顶级域名服务器,返回“com”“net”“cn”等顶级域名服务器的 IP 地址;
  • 顶级域名服务器(Top-level DNS Server):管理各自域名下的权威域名服务器,比如 com 顶级域名服务器可以返回 apple.com 域名服务器的 IP 地址;
  • 权威域名服务器(Authoritative DNS Server):管理自己域名下主机的 IP 地址,比如 apple.com 权威域名服务器可以返回 www.apple.com 的 IP 地址;

dns.png

有了这个系统以后,任何一个域名都可以在这个树形结构里从顶至下进行查询,就好像是把域名从右到左顺序走了一遍,最终就获得了域名对应的 IP 地址。

在核心系统之外,还有两种缓存手段来减轻域名解析的压力,可以更快地获取结果。

  • 许多大公司、网络运营商都会建立自己的 DNS 服务器,代替用户访问核心 DNS 系统。这些“野生服务器”都被称为“非权威域名服务器”。
  • 操作系统也会对 DNS 解析结果缓存。操作系统里还有一个特殊的“主机映射文件”,即 Linux 中的“/etc/hosts”,Windows 中的“C:\WINDOWS\system32\drivers\etc\hosts”,如果操作系统在缓存里找不到 DNS 记录,就会找这个文件。

另外浏览器也会对 DNS 缓存的结果进行缓存,缓存策略和浏览器相关。

DNS 解析流程

有了上述理论知识,就可以很轻松掌握 DNS 的解析流程。

浏览器缓存 -> 操作系统缓存 -> 本地 hosts 文件 -> 非权威域名服务器 -> 根域名服务器 -> 顶级域名服务器 -> 权威域名服务器。

win10 启动加载时,会把 hosts 中的条目缓存在操作系统中。
win10 还会监听 hosts 文件的变化,并动态更新操作系统缓存。

3. 建立 TCP/IP 连接

获取到服务器的 IP 地址后,就可以建立 TCP/IP 连接了。

网络分层模型

讲述建立 TCP/IP 连接之前,我们先来了解一下 TCP/IP 网络分层模型。

tcp_ip.png

TCP/IP 协议总共有四层,它的层次顺序是“从下往上”数的,第一层是最下面的一层。

第一层叫“链接层”(link layer),负责在以太网、WiFi 这样的的底层数据上发送原始数据包,工作在网卡这个层次,使用 MAC 地址来标记网络上的设备,所以有时候也叫 MAC 层。

第二层叫“网际层”或者“网络互连层”(internet layer),IP 协议就处在这一层。

第三层叫“传输层”(transport layer),这个层次协议的职责是保证数据在 IP 地址标记的两点之间“可靠”地传输。TCP 协议就位于这一层,另外还有 UDP 也位于这一层。

第四层叫“应用层”(application layer)。这一层有各种面向具体应用的协议。例如 Telnet、SSH、FTP、SMTP 等。当然还有最常见的 HTTP 协议。

三次握手

首先你需要了解以下概念。

  • ACK:Acknowledement 确认字符
  • SYN:Synchronize Sequence Numbers 同步序列编号

其次你还需要知道这几种状态。

  • LISTEN:监听 TCP 端口的连接请求(等待对方发送连接请求)
  • SYN-SENT:发送连接请求后等待匹配的连接请求(已发送请求,等待回复)
  • SYN-RECEIVED:收到和发送一个连接请求等待对连接请求确认(收到连接请求,等待回复)
  • ESTABLISHED:已经打开的连接(已经建立连接,可以发送数据)

下面是三次握手的示意图。

tcp.png

从上图我们就可以看到 TCP/IP 三次握手整个流程,总结如下:

  1. 第一次握手:客户端向服务器发送 SYN 标志位(序号是J),并且进入 SYN_SEND 状态(等待服务器确认状态)。
  2. 第二次握手:服务器收到客户端的 SYN J 后,服务端会确认改数据包已收到并发送 ACK 标志位(序号是J+1)和 SYN 标志位(序号是K),服务器进入 SYN_RECV 状态(请求接收并等待客户端确认状态)。
  3. 第三次握手:客户端进入连接建立状态,向服务端发送 ACK 标志位(序号是K+1),确认客户端已收到建立连接确认,服务端收到 ACK 位后,服务端进入连接已建立状态。

这里的序号 J,K 都代表一个序列号,三次握手的最重要的是交换彼此的 ISN(初始序列号)。

SYN 报文不携带数据,但是它占用一个序号,下次发送数据序列号要加一。
客户端会随机选择一个数字作为初始序列号(ISN)。

ACK 用来告知发送端之前发送的 SYN 段已经收到了,字段指定了发送端下次发送段的序号。

在 HTTP 协议里,建立 TCP/IP 连接后,浏览器会立即发送请求报文。

4. 建立 TLS 连接

建立 TCP/IP 连接后,如果发现请求协议是 HTTPS,还需要建立 TLS 连接。
这个“握手过程”与 TCP 类似,是 HTTPS 和 TLS 协议里最重要、最核心的部分。

HTTPS

众所周知,HTTP 是不安全的。HTTPS 在其基础上增加了 机密性、完整性、身份认证和不可否认四大安全特性。
它把 HTTP 下层的传输协议由 TCP/IP 换成 SSL/TLS,由 “HTTP over TCP/IP”变成 “HTTP over SSL/TLS”。

那么 HTTPS 是如何做到这些的?答案就在最后的 “s”上。

SSL/TLS

SSL 即安全套接层(Secure Sockets Layer),由网景公司与 1994 年发明。
SSL 发展到 v3 时,互联网工程组 IETF 在 1999 年把它改名为 TLS(传输层安全,Transport Layer Security),正式标准化,版本号从 1.0 重新算起,所以 TLS1.0 实际上就是 SSLv3.1。

目前应用最广泛的 TLS 是 1.2,接下来的讲解都针对的是 TLS1.2。
TLS 由记录协议、握手协议、警告协议、变更密码规范协议、扩展协议等几个子协议构成,综合使用了对称加密、非对称加密、身份认证等许多密码学技术。

对称加密、非对称加密

“对称加密”很好理解,就是指加密和解密使用的密钥都是同一个,是“对称”的。

TLS 里有许多对称加密算法,如 RC4、DES、3DES、AES、ChaCha20 等。
前三种算法都被认为是不安全的,通常禁止使用,目前常用的只有 AES 和 ChaCha20。
AES 的意思是“高级加密标准”,密钥长度可以是 128、192 或者 256,它是 DES 算法的替代者。
ChaCha20 是 Google 设计的另一种加密算法,密钥长度固定为 256 位,纯软件运行性能要超过 AES。

对称加密看上去实现了机密性,但是无法保证将密钥安全地传递给对方,即无法安全地实现“密钥交换”。
因为对称加密算法中只有持有密钥就可以解密。如果你和网站约定的密钥被黑客窃取,那通信就没有机密性可言了。

所以,后来就出现了非对称加密算法(也叫公钥加密算法)。
它有两个密钥,一个叫“公钥”,一个叫“私钥”。公钥可以公开给任何人使用,私钥必须严格保密。
两个密钥是不同的,公钥加密只能使用私钥解密,反过来,私钥加密也只能使用公钥解密。

非对称加密可以解决“密钥交换的问题”。网站保管私钥,在网上任意分发私钥,想要登录网站只要用公钥加密就行,密文只能有私钥持有者才能解密。黑客无法得到私钥,所以也就无法破解密文。

TLS 里只有几种非对称加密算法,如 DH、DSA、RSA、ECC 等。
RSA 应该是最著名的一个,相信工作中你也接触过,它的安全性基于“整数分解”的数学难题,使用两个超大素数的乘积作为生成密钥的材料,想要从公钥推算出私钥非常困难。
ECC 是非对称加密里的 “后起之秀”,它基于“椭圆曲线离散对数”的数学难题,使用特定的曲线方恒和基点生成公钥和私钥,子算法 ECDHE 用于密钥交换,ECDSA 用于数字签名。

混合加密

TLS 目前使用的是混合加密。

因为非对称加密虽然没有“密钥交换”的问题,但都是基于复杂的数学难题,运算速度很慢。
如果仅用非对称加密,虽然可以保证安全,但通信速度得不到保证,实用价值为零。
混合加密把对称加密和非对称加密结合起来,两者互相取长补短,既能高效解密,又能安全的进行“密钥交换”。

即在通信刚开始的使用非对称算法,比如 RSA、DCDHE,首先解决密钥交换的问题。
然后使用随机数产生对称算法使用的“会话密钥”(session key),再用公钥加密。
因为会话密钥很短,通常只有 16 字节或 32 字节,所以慢一点也无所谓。
对方拿到密文后用子要解密,取出会话密钥。
这样,双方就实现了对称密钥的安全交换,后续也就不再使用非对称加密,全部使用对称加密进行通信。

混合加密解决的是数据通信中的机密性,但是无法解决完整性、身份认证和不可否认等特性。

数字签名与证书

摘要算法

实现数据完整性的手段主要是摘要算法,也就是常说的散列函数、哈希函数。
常见的摘要算法是 MD5 和 SHA-1,不过这两个算法的安全强度比较低,不够安全,在 TLS 里已经被禁止使用。
目前推荐使用的是 SHA-1 的后继者:SHA-2。

摘要算法可以保证“数字摘要”和原文是等价的。只要在原文后附上它的摘要,就能够保证数据的完整性。
不过摘要算法不具有机密性。所以,完整性必须要建立在机密性之上。
在混合加密系统里用会话密钥加密消息和摘要(哈希消息认证码 HMAC),这时通信过程就比较安全了。

数字签名

加密算法集合摘要算法,看似比较安全,其实还是存在漏洞,就是通信的两个端点。

如果黑客一开始就伪装成网站窃取信息,你无法确认它的身份。
或者黑客还可以伪装成你,像网站发送支付、转账等信息,网站这时无法确认你的身份。

那么如何才能在数字世界中证明你的身份?

答案就是非对称加密里的“私钥”,使用私钥再加上摘要算法,就能够实现“数字签名”,同时也实现了“身份认证”和“不可否认”特性。
数字签名的原理很简单,就是私钥加密、公钥解密。使用私钥加密原文的摘要,就可以得到数字签名。
签名和公钥一样完全公开,但这个签名只能由私钥对应的公钥才能解开,拿到摘要后,在对比原文验证完整性。
这两个行为叫做“签名”和“验签”。只要你和网站互相交换公钥,就可以使用“签名”和“验签”来确认消息的真实性,因为私钥保密,黑客不能伪造签名,就能够保证通信双方的身份。

数字证书和 CA

到现在,综合使用对称加密、非对称加密和摘要算法实现了安全的四大特性,是不是已经很完美了?
其实还存在一个问题,那就是“公钥信任”问题。如果谁都可以发布公钥,还是无法判断这个公钥就是你自己的。

这里就要说到第三方 CA(Certificate Authority,证书认证机构)。
CA 对公钥的签名是有格式的,包含序列号、用途、颁发者、有效时间等,把这些打成一个包在签名,完整地证明公钥关联的各种信息,形成“数字证书”(Certificate)。

证书分 DV、OV、EV 三种,区别在于可信程度。
DV 是最低的,只是域名级别的可信,背后是谁不知道。EV 是最高的,经过了法律和审计的严格核查,可以证明网站拥有者的身份(在浏览器地址栏会显示出公司的名字,例如 Apple、GitHub 的网站)。

不过你可能要问 CA 如何证明自己?

这还是信任链的问题。小一点的 CA 可以让大 CA 签名认证,但链条的最后,也就是 Root CA,就只能自己证明自己了,这个就叫“自签名证书”(Self-Signed Certificate)或者“根证书”(Root Certificate)。你必须相信,否则整个证书信任链就走不下去了。

有了这个证书体系,操作系统和浏览器都内置了各大 CA 的根证书,上网的时候只要服务器发过来它的证书,就可以验证证书里的签名,顺着证书链(Certificate Chain)一层层地验证,直到找到根证书,就能够确定证书是可信的,从而里面的公钥也是可信的。

证书信任链的验证过程如下:

服务器返回的是证书链(不包括根证书,根证书预置在浏览器中),浏览器会根据证书链包含的签发者信息,逐层向上查找知道找到根证书。然后浏览器就可以使用信任的根证书(根公钥)解析证书链的根证书得到一级证书的公钥 + 摘要验签,然后拿一级证书的公钥解密一级证书拿到二级证书的公钥和摘要验签,再然后拿二级证书的公钥解密二级证书得到服务器的公钥和摘要验签,验证过程就结束了。

ECDHE 握手过程

综上所述,TLS 握手过程使用了 混合加密、摘要算法、数字签名和数字证书。
概括一下就是对称加密存在密钥交换的问题,所以需要使用非对称加密。
但是由于非对称加密基于复杂数学难题,运行速度很慢,所以使用了混合加密。
单独使用混合加密只能实现机密性,无法保证数据完整性,所以引入了摘要算法。
混合加密和摘要算法无法保证身份认证和不可否认,所以使用摘要算法和私钥生成数字签名,在使用公钥解密。
但是公钥也无法保证安全,所以引入了第三方机构 CA 认证数字证书,构建起公钥的信任链。

下面讲述的握手过程(ECDHE)是如今主流的 TLS 握手过程,这与传统的握手(RSA)可能有所不同。

下面是TLS 握手的详细图,下面就根据这个图来剖析 TLS 的握手过程。

ecdhe.png

浏览器发送 “Client Hello” 消息,也就是服务器打招呼。
包括客户端的版本号、支持的密码套件,还有一个随机数(Client Random),用于后续生成会话秘钥。

服务器收到 “Client Hello” 后,会返回一个 “Server Hello” 消息。
把版本号对比一下,也给出一个随机数(Server Random),然后从客户端的列表里选一个作为本次通信的密码套件,密码套件假定为 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384。
然后,服务器会把证书也发给客户端(Server Certificate)。因为服务器选择了 ECDHE 算法,所以它会在证书后发送 “Server Key Exchange” 消息,里面是椭圆曲线的公钥(Server Params),用来实现密钥交换算法,再加上自己的私钥签名认证。
服务器发送 “Server Hello Done” 消息,打招呼完毕。

浏览器开始走证书链验证,再用证书公钥验证签名,确认服务器身份。
然后,客户端按照密码套件要求,也生成一个椭圆曲线的公钥(Client Params),用 “Client Key Exchange” 消息发送给服务器。

现在客户端和服务器都拿到秘钥交换的两个参数(Client Params,Server Params),就用 ECDHE 算法生成 “Pre-Master”,也是一个随机数。现在客户端和服务器手里都有三个随机数:Client Random、Server Random、Pre-Master。用这三个作为基础,生成用于加密会话的主密钥,叫 “Master-Secret”。同时也会利用主密钥 通过 RPF 扩展多更多的秘钥。

master_secret = PRF(pre_master_secret, “master secret”, ClientHello.random + ServerHello.random);

这里的“PRF”就是伪随机数函数,它基于密码套件里的最后一个参数,比如这次的 SHA384,通过摘要算法来再一次强化“Master Secret”的随机性。

主密钥有 48 字节,但它也不是最终用于通信的会话密钥,还会再用 PRF 扩展出更多的密钥,比如客户端发送用的会话密钥(client_write_key)、服务器发送用的会话密钥(server_write_key)等等,避免只用一个密钥带来的安全隐患。

有了主密钥和派生的会话秘钥后。
客户端发送一个 “Change Cipher Spec”,然后再发一个 “Finished” 消息,把之前所有要发送的数据做个摘要,再加密一下,让服务器验证。
服务器也是同样的操作,发 “Change Cipher Spec” 和 “Finished” 消息,双方都验证加密解密 ok,握手正式结束,后面就收发被加密的 HTTP 请求和响应了。

5. HTTP 代理

建立 TCP/IP 连接或 TLS 连接后,就可以发起 HTTP 数据请求了。
不过数据请求的过程中还可能存在一个或者多个中间人,有可能请求并不会直接到达源服务器。

代理服务器处理 HTTP 通信过程中的中间位置,对上屏蔽了真实客户端,对下屏蔽了真实服务器。
在这个中间层可以做很多的事情,为 HTTP 协议增加了更多的灵活性。

代理的一个常见的功能就是负载均衡,可以掌握请求分发的“大权”,决定由后面的哪台服务器响应请求。

代理中存在一些常用的负载均衡算法,比如轮询、一致性哈希等,这些算法的目的就是尽量把外部流量合理地分散到多台源服务器,提高系统的整体资源利用率和性能。

负载均衡算法

随机:简单、是否均匀看随机情况。
轮询(一般轮询、加权轮询):相对简单,会考虑机器资源和性能的均衡性。
哈希(一般哈希、一致性哈希、带虚拟节点的一致性哈希):相对复杂,越公平就会越复杂,适当考虑了请求。

我们使用的 CDN 就是一种代理,它可以代替源站服务器响应客户端的请求,通常扮演透明代理和反向代理的角色。
如果 CDN 的调度算法还优秀,还可以找到距离用户最近的节点,大幅度缩短响应时间。

CDN 也是现在互联网中的一项重要基础设施,除了基本的网络加速外,还提供负载均衡、安全防护、边缘计算、跨运营商网络等功能,能够成倍地“放大”源站服务器的服务能力,很多云服务商都把 CDN 作为产品的一部分。

代理相关的知识还有很多,比如缓存代理、负载均衡等,这里就不展开阐述了。

三、处理响应数据

HTTP 请求的数据类型,可能是一个下载类型,也可能是 HTML 页面,浏览器是如何区分它们的?

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

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

所以,不同 Content-Type 的后续处理流程也截然不同。

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

四、准备渲染进程

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

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

要解决这个问题,我们就需要先了解下什么是同一站点(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。

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

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

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

五、提交文档

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

  • 首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息;
  • 渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”;
  • 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程;
  • 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面;

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

六、渲染阶段

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

render_line.png

按照渲染的时间顺序,流水线可分为如下几个子阶段:
构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

1. 构建 DOM 树

浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。

dom_tree.png

2. 样式计算

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

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

CSS 样式来源主要有三种:

  • 通过 link 引用的外部 CSS 文件
  • 通过 link 引用的外部 CSS 文件
  • 元素的 style 属性内嵌的 CSS

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

你可以在 Chrome 控制台中输入 document.styleSheets 查看其结构。

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

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

css02.png

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

计算出 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;}

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

css03.png

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

3. 布局阶段

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。

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

创建布局树

你可能注意到了 DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树。

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

layout.png

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

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

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

布局计算

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

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

4. 分层

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

答案依然是否定的。

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

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

layer.png

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

layer02.png

现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。下面我们再来看看这些图层和布局树节点之间的关系,如文中图所示:

tree.png

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如上图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

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

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

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

context.png

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

若你想要了解更多层叠上下文的知识,你可以参考 MDN 这篇文章

第二点,需要剪裁(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>

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

clip.png

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

clip02.png

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

5. 图层绘制

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

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

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

  1. 绘制蓝色背景;
  2. 在中间绘制一个红色的圆;
  3. 再在圆上绘制绿色三角形;

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

render_list.png

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

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

paint.gif

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

6. 栅格化(raster)操作

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

paint.png

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

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

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

基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512。

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

paint02.png

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

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

paint03.png

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

7. 合成与显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

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

那文章开头的“从输入 URL 到页面展示,这中间发生了什么?”这个过程及其“串联”的问题也就解决了。

8. 总结

一个完整的渲染流程大致可总结为如下。

  • 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构;
  • 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式;
  • 创建布局树,并计算元素的布局信息;
  • 对布局树进行分层,并生成分层树;
  • 为每个图层生成绘制列表,并将其提交到合成线程;
  • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图;
  • 合成线程发送绘制图块命令 DrawQuad 给浏览器进程;
  • 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上;

总结

看完这篇文章,如果再有面试官问你“从输入 URL 到页面展示,这中间发生了什么?”这个问题,你知道怎么回答了吗?你也可以用自己的语言组织下,为你自己的面试做准备。

以上的内容其实还是不够完整。比如面试官还可能会问起跨域、同源策略、HTTP 各版本差异、响应状态码、大文件传输、Cookie、http 优化、OSI 网络分层模型等问题,但是无外乎也是这个流程的一部分,所以也不必过于恐慌,遇到再完善补充就是了。

参考资料

  • 《浏览器工作原理与实践》- 李兵。
  • 《透视 HTTP 协议》- 罗剑峰。
  • 《深入理解 TCP 协议:从原理到实战》- 挖坑的张师傅。