原文: Inside Look at Modern Web Browser (part 2)作者: Mariko Kosaka
译者: kyrieliu
本文是这个系列的第二篇文章,会深入到 Chrome 的内部工作。在上一篇文章中,我们了解了线程和进程在浏览器中的不同,而在这篇文章中,我们会更加深入的了解当浏览器为用户呈现一个页面时,这些进程和线程之间是如何通信的。
让我们以一个常见的例子作为起点:输入一个 url,浏览器会从服务端获取数据并将页面展示出来。本文会聚焦在用户通过浏览器向一个站点发起访问请求以及浏览器准备渲染这个页面的部分,这个过程我称之为导航。
以一个浏览器进程为起点
我们在上一篇文章中提过,所有处于窗口之外的部分都由同一个浏览器进程进行掌管。浏览器的进程又同时拥有许多线程,掌管浏览器的不同部分:UI 线程用来绘制顶部的操作按钮和输入框、网络线程负责处理并接收来自互联网的数据、存储线程控制着访问本地文件的权限等。当你将一个网站的 url 输入到浏览器的地址栏时,此刻正是浏览器进程中的 UI 线程在起作用。
一次简单的导航
Step 1:处理用户输入
当用户开始在地址栏输入时,UI 线程首先会问:“大兄弟,你输入的是个查询字符串还是网站地址?”。因为 Chrome 的地址栏同时还是个搜索框,所以 UI 线程需要解析用户的输入,才能决定该直接访问网址还是把用户的输入丢给搜索引擎处理。
Step 2:开始导航
当用户按下回车键后,UI 线程要求网络线程去获取网站的内容。窗口的 Tab 上会开始转菊花,网络线程会采用一系列的协议和操作(比如 DNS)查询必要的信息并为请求建立连接。
此时,网络线程可能会收到来自服务器的一个标记着重定向指令的头部比如 HTTP 301,在这种情况下,网络线程会把这件事情告诉 UI 线程,之后则会发起一次指向重定向地址的新的网络请求。
Step 3:读取响应
当响应的数据开始传送到浏览器时,网络线程会在必要的情况下检查一些来自响应的字段。响应数据的 Content-Type
字段会表示当前返回的是哪种类型的数据,但它也不完全靠谱,经常会出现丢失或者干脆不准确的情况,但也不用担心,MIME 嗅探会完成确缺失的工作。正如源码的注释中写道,这是一个可以被解释为 hack 的方案,如果感兴趣的话,你也可以去阅读这些注释,这样就能了解不同的浏览器是如何将实际的数据与 Content-Type
匹配了。
如果响应数据是一个 HTML 文件,那么接下来的一步会是把数据传递给浏览器的渲染进程;但如果数据是 zip 压缩文件或其他类型的文件,意味着这将被定位成一次下载动作,于是浏览器会将数据转交给下载管理器去处理。
通常这一步也是安全检测发生的时候:如果域名或响应数据和已知的恶意网站匹配时,网络进程会抛出一个警告,并展现一个告警的页面。另外,CORB 检测也会开始工作,确保那些来自敏感站点的跨站响应数据不会进入到浏览器的渲染进程中。
Step 4:渲染进程
网络线程以获取了全部的数据,并完成了所有需要的检查,此刻它自信的告诉 UI 线程:“小兄弟,数据准备好了!”。接着,UI 线程会唤起一个渲染进程去渲染页面。
由于网络情况的不可控,一个请求可能会花上好几百毫秒才能把响应数据拿回来,所以这里浏览器默认开启了用来加速这一过程的优化。在 Step 2 中,当 UI 线程将需要请求的 url 告诉网络线程时,其实它本身已经知道要导航到哪个网站了,于是 UI 线程在把 url 传递给网络线程的同时,会尝试启动一个渲染进程。如果一切都按照预期正常进行的话,当网络线程拿到数据时,渲染进程就已经处于待命状态了。也会有例外的情况:比如导航重定向到一个另外的站点,那么预先启动好的渲染进程将不会被使用,这导致 UI 线程需要重新启动一个渲染进程。
Step 5:触发导航
现在我们假设数据和渲染进程都准备好了,浏览器进程通过 IPC 告知渲染进程可以出发本次导航了。与此同时,数据流也将传递给渲染进程,这样后者就能继续接收 HTML 数据。一旦浏览器收到了来自渲染进程的导航启动信号,这次导航也就完成了,下一步进入文档的加载阶段。
到这会儿,浏览器的地址栏更新,安全指示符和站点的设置 UI 会将新页面的信息呈现出来。当前窗口的 session 将会更新,刚导航到的页面会被后退/前进按钮记录到窗口的页面历史中。为了便于在关闭窗口时恢复页面,历史的会话记录会保存在本地的磁盘上。
Extra Step:初始加载完成
当导航触发后,渲染进程会持续接收资源并渲染页面。我们将在下一篇文章中讨论这一步的更多细节。当渲染进程“完成”渲染后,它会通过 IPC 告知浏览器进程(页面的 onload 事件均已执行完毕后),UI 线程也就不再在 tab 上转菊花了。
上面的“完成”两个字,之所以打了双引号,因为在实际场景中,它通常并不真正意味着完成,因为客户端的 JavaScript 可能在此时持续地加载资源并渲染新的视图。
导航到另一个网站
一次简单的导航截至目前已经完成了。假如这时用户输入了一个不同的 url 会发生什么呢?其实也没啥,浏览器进程会按照上面的步骤导航到这个网站。但在这一切开始之前,浏览器会检查当前已经渲染好了的网站是否需要在网页卸载之前搞一点事情,这就是 beforeunload
事件。
在 beforeunload
事件中,我们可以在用户即将跳转至其他页面或者关闭 Tab 的时候发起一个“确认离开当前页面?”的二次确认。Tab 中的所有东西都由渲染进程控制着,当然也包括开发者编写的 JavaScript,所以当一个新的导航请求即将到来时,浏览器进程会对当前的渲染进程做最后的检查。
我们应当尽量避免在 beforeunload
中添加总会执行的事件代码,这会造成更多的交互延时,毕竟它们总会在新的导航开始之前执行。只在需要的时候添加这些代码,比如提醒用户如果进入新的页面那么当前页面的数据会丢失。
如果导航是在渲染进程中被创建的(比如用户点击了页面上的某一链接或者在 JavaScript 运行了 window.location.href = 'https://kyrieliu.cn'
),则当前的渲染进程会首先检查是 beforeunload
中是否有东西需要执行。之后,它会经历与浏览器进程直接发起导航后一样的导航过程。
当新的导航将发往与当前页面不同的站点时,浏览器将会创建一个新的渲染进程去处理这些新工作,旧的渲染进程则则用来在剩余的时间里处理诸如 unload
的页面事件。如果你想了解更多的话,可以看看页面生命周期概览和页面生命周期 API这两篇文章。
如果有 Service Worker...
Service Worker 的引入会对页面的导航流程带来一些改变。Service Worker 是一种可以在应用代码中编写网络代理的方法;增强了开发者对于本地缓存以及何时发起网络请求的控制。如果 Service Worker 提前设置了从本地缓存中读取某一页面的数据,那么也就不需要发起网络请求了。
需要明确的一点是,即使 Service Worker 提供了听起来很高端的功能,但它实质上也是运行在渲染进程中的 JavaScript 代码。那么问题来了:当用户发起一次导航时,浏览器进程是如何知道目标站点存在一个 Service Worker 的呢?
当一个 Service Worker 注册后,它的作用域会保存在一个引用中(你可以通过 Service Worker 的生命周期 这篇文章了解我所说的“作用域”)。当导航发生时,网络线程会依据域名在已注册的 Service Worker 作用域集合中查询,如果找到某个对应的 Service Worker,UI 线程会发起一个渲染进程去执行 Service Worker 中的代码。Service Worker 可以从本地缓存中加载数据(无需发起网络请求),也可以选择通过网络请求获取最新的资源和数据。
导航预加载
相信你可以发现,如果 Service Worker 最终决定从网络中请求数据,那么之前在浏览器进程和渲染进程之间所发生的通信都将成为导致响应延时的罪魁祸首。导航预加载就是用来加速这一进程的机制:与 Service Worker 并行启动去加载资源。它将为这些请求设置一个 Header,又服务端来决定为这些请求发送不同的内容;比如,仅返回更新的数据而不是整个文档。
总结
在这篇文章中,我们检视了在导航时都发生了什么,以及 Web 应用的代码比如响应头和客户端的 JavaScript 代码是如何与浏览器进行交互的。 了解了浏览器是如何一步步从网络中请求数据的,这能让我们更好的理解很多 API 比如导航预加载的诞生初衷。
在下一篇文章中,我们会深入讨论浏览器是如何执行 HTML/CSS/JavaScript 代码从而完成一个页面的渲染的。