深入理解现代浏览器 - 导航

用户浏览网页的过程一般是:

  1. 在浏览器地址栏中输入 URL

  2. 浏览器通过网络请求获取数据

  3. 浏览器渲染页面

其中,从浏览器发起请求到准备渲染页面的这个过程我们称之为导航(navigation)。

我们得从浏览器进程(browser process)讲起,浏览器进程里有这么几个线程:

  • UI 线程(UI thread):负责绘制浏览器的工具按钮和地址栏输入框等

  • 网络线程(network thread):负责维护一个网络栈来接收网络数据

  • 存储线程(storage thread):负责文件访问等事宜

拆解一个简单的导航

第一步:处理用户输入

当我们往地址栏里输入一个 URL 时,我们的输入是由 UI 线程来控制的。

但 Chrome 浏览器顶部的地址栏同时还是一个搜索输入框,所以当用户往地址栏里输入内容时,UI 线程的第一个任务就是判断用户输入:

  • 输入网站地址 -> 导航到对应的网址

  • 输入搜索字符串 -> 导航到默认的搜索引擎

第二步:开始导航

  • 用户在地址栏中敲下回车

  • UI 线程发起一个网络请求去请求网站的数据,这时标签页上会显示一个加载中的圈圈

  • 网络线程开始 DNS 查询,建立 TSL 连接等

  • 在这一步,网络线程可能会收到来自服务器的重定向响应(HTTP 301),这种情况下,网络线程会把重定向需求通知 UI 线程,UI 线程则会重新发起一个网络请求。不然就继续下一步。

第三步:读取响应数据

浏览器从服务器那里接收到响应数据后,如果有必要,网络线程可能会检查数据中的前几个字节。

  • 响应数据中的头部字段 Content-Type 应该要说明返回的数据是什么类型的,不过这个字段有时可能会被忽略或者存在偏差,这时候我们就需要 MIME Type Sniffing 啦。

  • 接下来是“安全浏览”检查(SafeBrowsing check),如果域名和返回数据匹配已知的恶意站点,网络线程就会显示一个警告页面。

  • 同时,CORB(Cross Origin Read Block)检查也会执行,以保证敏感的跨站点数据不会被传送到渲染器进程。

  • 最后就是处理数据了,如果返回数据是 HTML 文件,会被交给一个渲染器进程来处理,如果是 zip 文件之类的,说明这是一个下载请求,那就需要交给下载管理器(download manager)来处理。

第四步:准备一个渲染器进程

等所有检查都通过之后,网络线程觉得可以了,就会通知 UI 线程数据已经准备好了,UI 线程会准备一个(已存在的或者新建的)渲染器进程来负责页面渲染。

优化:因为网络线程发送请求到接收数据可能会耗时几百毫秒,这种情况下浏览器可以采取一些优化措施。在第二步中,UI 线程发起网络请求时,它已经知道接下来要导航到哪个网站了,所以 UI 线程在这个时候就可以先启动一个渲染器进程,这个过程和网络线程发送请求是同时发生的,等到网络线程拿到数据时,渲染器进程也已经准备好了。不过,如果网络线程接收到一个跨站的重定向响应,那这个预先启动的渲染器进程就用不上了,我们可能需要一个新的渲染器进程。

第五步:提交导航

现在网页数据和渲染器进程都已经准备就绪了,浏览器进程就会向渲染器进程发送一个 IPC 来提交导航,同时浏览器进程还会以数据流的方式向渲染器进程传送 HTML 数据,等到浏览器进程从渲染器进程那里得到确认消息后,导航就完成了,文档加载阶段也就开始了。

在这个阶段还发生以下几件小事:

  1. 地址栏的内容会更新

  2. 安全指示图标和网页设置 UI 会显示和新页面相关的内容

  3. 当前标签页的会话历史会更新(P.S. 会话历史是存储在硬盘上的,这是为了能在下次打开浏览器时恢复上次浏览的标签和会话)

最后一步:初始加载完成

导航完成之后,渲染器进程就接手了接下来的工作(加载资源、渲染页面),等到渲染器进程“完成”页面渲染后(完成加了双引号是因为之后还可以通过 JS 来加载新的资源并更新视图),它会发送一个 IPC 给浏览器进程(发送时间是页面中的所有 frame 的 onload 事件都触发且事件处理函数都执行完毕之后),浏览器进程接收到信号后,就会停掉标签页上那个转动的圈圈,一个简单的导航就结束了。

导航到新站点

导航的过程是一样的,不过在导航开始之前浏览器还有一些事情要做,从一个页面导航到另一个页面有以下两种情况:

  1. 导航从浏览器进程触发:用户在当前页面重新往地址栏里输了另外一个地址。首先,浏览器进程需要跟当前页面的渲染器进程沟通,检查当前页面是否要对 beforeunload 事件作出反应(是否要弹出确认离开的弹窗),然后,就可以开始上述的导航流程了。

  2. 导航从渲染器进程触发:用户点击了页面中的链接或者通过 JS 来跳转到新页面。首先,渲染器进程会检查有没有 beforeunload 事件处理函数,之后,就跟在浏览器进程触发导航是一样的流程了。

新的导航完成后,会有一个新的渲染器进程来处理新的网页,但旧的渲染器进程依然会停留一段时间来处理旧页面的 unload 事件。

Service Worker

Service Worker 的出现让这个导航的过程发生了一点不同,service worker 是网页中的网络代理服务,让开发者可以通过 JS 代码来实现控制网站或是从本地缓存中获取数据或是发送网络请求获取新的数据。

重点是,service worker 是在渲染器进程中执行的 JS 代码,那么,浏览器进程在处理一个导航请求时,如何得知当前需要导航的网站是否存在 service worker 呢?这个工作流程大概是这样子的:

  • 当某个网站注册了一个 service worker 时,浏览器会保存一个指向这个 service worker 的指针。

  • 当一个导航开始时,网络线程会先检查当前域名是否匹配某个已经注册的 service worker,

  • 如果没有匹配的,就会走普通的导航流程;

  • 如果有匹配,UI 线程就会准备一个渲染器进程来运行这个 service worker 的代码,接着 service worker 就可以决定是从本地缓存中获取数据或者发送网络请求获取新的数据,如果决定从缓存中加载数据,那就省了一次网络请求。

不过如果 service worker 最终决定从网络中获取新数据,那此前浏览器进程和渲染器进程之间的通信就有点浪费时间了,这会导致网页渲染延迟。为了优化这种情况,导航预加载(Navigation Preload)机制出现了,这个机制的实现方式是,在渲染器进程开始执行 service worker 代码时,浏览器进程中的网络线程同时向服务器发送请求(通过 HTTP 头部与服务器沟通,服务器再决定返回的内容,比如是否完全更新页面),这样即使最后 service worker 决定获取新数据,也不用重新发送网络请求,节省了时间。

小结

在这一部分我们拆解了一个简单导航的过程,了解了网站代码是如何和浏览器沟通的。了解浏览器获取网站数据的过程可以帮助我们更好地理解诸如 navigation preload 这些 API 出现的原因。

Last updated