DNS 解析
向本地 DNS 发起请求,解析 ip。
采用递归或者迭代的方式,向根域名服务器、顶级域名服务器、权威域名服务器发起请求。
找到一组 ip,返回给浏览器。
解析失败,返回错误。
开始 HTTP 请求
建立连接
调用 socket、bind、listen 和 accept 四个函数完成绑定公网 ip、监听 443 和接收请求的任务。
客户端通过 socket 和 connect 函数主动打开连接,发送带有 SYN 标志位的分组。随机生成一个初始序号 x,附带 MSS(Maximum Segment Size, 最大段大小)等额外信息。MSS 的值一般为以太网 MTU(Maximum Transmission Unit, 最大传输单元)的值减去 IP 头部和 TCP 头部大小,等于 1460bytes。
服务器确认收到客户端的分组,发送带有 SYN+ACK 标志位的分组,随机生成一个序号 y,确认号为 x+1,以及额外的 MSS 信息。当一端收到另一端的 MSS 信息后,会取 MSS 的最小值来决定 TCP 最大报文的大小。
客户端确认收到服务器的分组,发送带有 SYN+ACK 标志位的分组,确认号为 y+1,建立 TCP 连接。
如果是初次会话,双方需要一次完整的四次握手。客户端首先向服务器发送 Client Hello 报文,包含一个随机数、TLS 协议版本、按优先级排列的加密套件列表。
服务器向客户端发送 Server Hello 报文,包含一个新的随机数、TLS 协议版本、选择后的加密套件。
服务器向客户端发送 Certificate 报文,包含服务器证书链。其中第一个为主证书,中间证书按照顺序跟在主证书后,根 CA 证书一般附加在浏览器里,无需发送。
如果选择 DH 加密算法,服务器会向客户端发送 Server Key Exchange 报文,包含 DH 密钥交换的所需参数。如果选择 RSA 算法,则跳过这一步。
服务器向客户端发送 Server Hello Done 报文,表示所有握手消息已经发送完毕。
客户端向服务器发送 Client Key Exchange 报文,如果密钥交换选择 RSA 算法,由客户端生成预主密钥,使用服务器证书中的公钥对其加密,包含在报文中,服务器只需使用自己的私钥解密就可以取出预主密钥;如果密钥交换选择 DH 算法,客户端会在报文中包含自己的 DH 参数,之后双方都根据 DH 算法计算出相同的预主密钥。需要注意的是,密钥交换的只是预主密钥,这个值还需进一步加工,结合客户端和服务器两个随机数种子,双方使用 PRF(pseudorandom function,伪随机函数)生成相同的主密钥。
客户端向服务器发送 Change Cipher Spec 报文,表明已经生成主密钥,在随后的传输过程中都使用这个主密钥加密。
客户端向服务器发送 Finished 报文,这条信息是经过加密的。如果服务器能解析出报文的内容,说明双方生成的主密钥是一致的。
服务器向客户端发送 New Session Ticket 报文,这个 Session Ticker 只有服务器能解密。客户端会将他保存下来,在之后的 TLS 重新握手过程中带上它进行快速会话恢复,减少往返时间。
14. 服务器向客户端发送 Change Cipher Spec 报文,同样表明已经生成主密钥在随后的传输过程中都使用这个主密钥加密。
15. 服务器向客户端发送 Finished 报文,如果客户端能解密出报文内容,则说明双方生成的主密钥是一致的。至此,完成所有握手协商。
发送 HTTP 请求
建立起安全的加密信道后,浏览器开始发送 HTTP 请求,一个请求报文由请求行、请求头、空行、实体(Get 请求没有)组成。请求头由通用首部、请求首部、实体首部、扩展首部组成。其中,通用首部表示无论是请求报文还是响应报文都可以使用,比如 Date;请求首部表示只有在请求报文中才有意义,分为 Accept 首部、条件请求首部、安全请求首部和代理请求首部这四类;实体首部作用于实体内容,分为内容首部和缓存首部这两类;扩展首部表示用户自定义的首部,通过 X-
前缀来添加。另外需要注意的是,HTTP 请求头是不区分大小写的,它基于 ASCII 进行编码,而实体可以基于其它编码方式,由 Content-Type
决定。
返回 HTTP 响应
服务器接受并处理完请求,返回 HTTP 响应,一个响应报文格式基本等同于请求报文,由响应行、响应头、空行、实体组成。区别于请求头,响应头有自己的响应首部集,比如 Vary、Set-Cookie,其它的通用首部、实体首部、扩展首部则共用。此外,浏览器和服务器必须保证 HTTP 的传输顺序,各自维护的队列中请求/响应顺序必须一一对应,否则会出现乱序而出错的情况。
维持连接
完成一次 HTTP 请求后,服务器并不是马上断开与客户端的连接。在 HTTP/1.1 中,Connection: keep-alive
是默认启用的,表示持久连接,以便处理不久后到来的新请求,无需重新建立连接而增加慢启动开销,提高网络的吞吐能力。在反向代理软件 Nginx 中,持久连接超时时间默认值为 75 秒,如果 75 秒内没有新到达的请求,则断开与客户端的连接。同时,浏览器每隔 45 秒会向服务器发送 TCP keep-alive 探测包,来判断 TCP 连接状况,如果没有收到 ACK 应答,则主动断开与服务器的连接。注意,HTTP keep-alive 和 TCP keep-alive 虽然都是一种保活机制,但是它们完全不相同,一个作用于应用层,一个作用于传输层。
断开连接
服务器向客户端发送 Alert 报文,类型为 Close Notify,通知客户端不再发送数据,即将关闭连接,同样,这条报文也是经过加密处理的。
服务器通过调用 close 函数主动关闭连接,向客户端发送带有 FIN 标志位的分组,序列号为 m。
客户端确认收到该分组,向服务器发送带有 ACK 标志位的分组,确认号为 m+1。
客户端发送完所有数据后,向服务器发送带有 FIN 标志位的分组,序列号为 n。
服务器确认收到该分组,向客户端发送带有 ACK 标志位的分组,序列号为 n+1。客户端收到确认分组后,立即进入 CLOSED 状态;同时,服务器等待 2 个 MSL(Maximum Segment Lifetime,最大报文生存时间) 的时间后,进入 CLOSED 状态。
浏览器解析
现代浏览器是一个及其庞大的大型软件,在某种程度上甚至不亚于一个操作系统,它由多媒体支持、图形显示、GPU 渲染、进程管理、内存管理、沙箱机制、存储系统、网络管理等大大小小数百个组件组成。虽然开发者在开发 Web 应用时,无需关心底层实现细节,只需将页面代码交付于浏览器计算,就可以展示出丰富的内容。但页面性能不仅仅关乎浏览器的实现方式,更取决于开发者的水平,对工具的熟悉程度,代码优化是无止尽的。显然,了解浏览器的基本原理,了解 W3C 技术标准,了解网络协议,对设计、开发一个高性能 Web 应用帮助非常大。
当我们在使用 Chrome 浏览器时,其背后的引擎是 Google 开源的 Chromium 项目,而 Chromium 的内核则是渲染引擎 Blink(基于 Webkit)和 JavaScript 引擎 V8。在阐述浏览器解析 HTML 文件之前,先简单介绍一下 Chromium 的多进程多线程架构(图 5),它包括多个进程:
- 一个 Browser 进程
- 多个 Renderer 进程
- 一个 GPU 进程
- 多个 NPAPI Render 进程
- 多个 Pepper Plugin 进程
而每个进程包括若干个线程:
- 一个主线程
- 在 Browser 进程中:渲染更新界面
- 在 Renderer 进程中:使用持有的内核 Blink 实例解析渲染更新界面
- 一个 IO 线程
- 在 Browser 进程中:处理 IPC 通信和网络请求
- 在 Renderer 进程中:处理与 Browser 进程之间的 IPC 通信
- 一组专用线程
- 一个通用线程池
Chromium 支持多种不同的方式管理 Renderer 进程,不仅仅是每一个开启的 Tab 页面,iframe 页面也包括在内,每个 Renderer 进程是一个独立的沙箱,相互之间隔离不受影响。
- Process-per-site-instance:每个域名开启一个进程,并且从一个页面链接打开的新页面共享一个进程(noopener 属性除外),这是默认模式
- Process-per-site:每个域名开启一个进程
- Process-per-tab:每个 Tab 页面开启一个进程
- Single process:所有页面共享一个进程
当 Renderer 进程需要访问网络请求模块(XHR、Fetch),以及访问存储系统(同步 Local Storage、同步 Cookie、异步 Cookie Store)时,则调用 RenderProcess 全局对象通过 IO 线程与 Browser 进程中的 RenderProcessHost 对象建立 IPC 信道,底层通过 socketpair 来实现。正由于这种机制,Chromium 可以更好地统一管理资源、调度资源,有效地减少网络、性能开销。
主流程
页面的解析工作是在 Renderer 进程中进行的,Renderer 进程通过在主线程中持有的 Blink 实例边接收边解析 HTML 内容(图 6),每次从网络缓冲区中读取 8KB 以内的数据。浏览器自上而下逐行解析 HTML 内容,经过词法分析、语法分析,构建 DOM 树。当遇到外部 CSS 链接时,主线程调用网络请求模块异步获取资源,不阻塞而继续构建 DOM 树。当 CSS 下载完毕后,主线程在合适的时机解析 CSS 内容,经过词法分析、语法分析,构建 CSSOM 树。浏览器结合 DOM 树和 CSSOM 树构建 Render 树,并计算布局属性,每个 Node 的几何属性和在坐标系中的位置,最后进行绘制展示在屏幕上。当遇到外部 JS 链接时,主线程调用网络请求模块异步获取资源,由于 JS 可能会修改 DOM 树和 CSSOM 树而造成回流和重绘,此时 DOM 树的构建是处于阻塞状态的。但主线程并不会挂起,浏览器会使用一个轻量级的扫描器去发现后续需要下载的外部资源,提前发起网络请求,而脚本内部的资源不会识别,比如 document.write
。当 JS 下载完毕后,浏览器调用 V8 引擎在 Script Streamer 线程中解析、编译 JS 内容,并在主线程中执行(图 7)。
渲染流程
当 DOM 树构建完毕后,还需经过好几次转换,它们有多种中间表示(图 8)。首先计算布局、绘图样式,转换为 RenderObject 树(也叫 Render 树)。再转换为 RenderLayer 树,当 RenderObject 拥有同一个坐标系(比如 canvas、absolute)时,它们会合并为一个 RenderLayer,这一步由 CPU 负责合成。接着转换为 GraphicsLayer 树,当 RenderLayer 满足合成层条件(比如 transform,熟知的硬件加速)时,会有自己的 GraphicsLayer,否则与父节点合并,这一步同样由 CPU 负责合成。最后,每个 GraphicsLayer 都有一个 GraphicsContext 对象,负责将层绘制成位图作为纹理上传给 GPU,由 GPU 负责合成多个纹理,最终显示在屏幕上。
另外,为了提升渲染性能效率,浏览器会有专用的 Compositor 线程来负责层合成(图 9),同时负责处理部分交互事件(比如滚动、触摸),直接响应 UI 更新而不阻塞主线程。主线程把 RenderLayer 树同步给 Compositor 线程,由它开启多个 Rasterizer 线程,进行光栅化处理,在可视区域以瓦片为单位把顶点数据转换为片元,最后交付给 GPU 进行最终合成渲染。
页面生命周期
页面从发起请求开始,结束于跳转、刷新或关闭,会经过多次状态变化和事件通知,因此了解整个过程的生命周期非常有必要。浏览器提供了 Navigation Timing 和 Resource Timing 两种 API 来记录每一个资源的事件发生时间点,你可以用它来收集 RUM(Real User Monitoring,真实用户监控)数据,发送给后端监控服务,综合分析页面性能来不断改善用户体验。图 10 表示 HTML 资源加载的事件记录全过程,而中间黄色部分表示其它资源(CSS、JS、IMG、XHR)加载事件记录过程,它们都可以通过调用 window.performance.getEntries()
来获取具体指标数据。
图 10:页面加载事件记录流程
衡量一个页面性能的方式有很多,但能给用户带来直接感受的是页面何时渲染完成、何时可交互、何时加载完成。其中,有两个非常重要的生命周期事件,DOMContentLoaded 事件表示 DOM 树构建完毕,可以安全地访问 DOM 树所有 Node 节点、绑定事件等等;load 事件表示所有资源都加载完毕,图片、背景、内容都已经完成渲染,页面处于可交互状态。但是迄今为止浏览器并不能像 Android 和 iOS app 一样完全掌控应用的状态,在前后台切换的时候,重新分配资源,合理地利用内存。实际上,现代浏览器都已经在做这方面的相关优化,并且自 Chrome 68 以后提供了Page Lifecycle API,定义了全新的浏览器生命周期(图 11),让开发者可以构建更出色的应用。
现在,你可以通过给 window
和 document
绑定上所有生命周期监听事件(图 12),来监测页面切换、用户交互行为所触发的状态变化过程。不过,开发者只能感知事件在何时发生,不能直接获取某一刻的页面状态(图 11 中的 STATE)。即使如此,利用这个 API,也可以让脚本在合适的时机执行某项任务或进行界面 UI 反馈。图 11:新版页面生命周期