深入了解现代网络浏览器(第 2 部分)

记得要微笑
广东
English

前言

本文是进击的大葱Mario Kosaka写的inside look at modern web browser系列文章的翻译。这里的翻译不是指直译,而是结合个人的理解将作者想表达的意思表达出来,而且会尽量补充一些相关的内容来帮助大家更好地理解。

导航的时候都发生了什么

这是 4 部分博客系列的第 2 部分,该系列探究 Chrome 的内部工作原理。在上一篇文章中,我们探讨了浏览器高层次的架构设计以及多进程架构的带来的好处。同时我们还讨论了服务化和网站隔离这些和浏览器多进程架构息息相关的技术。接下来我们要开始深入了解这些进程和线程是如何呈现我们的网站页面的了。

让我们看一个简单的网页浏览例子:你在浏览器地址栏里面输入一个URL然后按下回车键,浏览器接着会从互联网上获取相关的数据并把网页展示出来。在这篇文章中,我们将会重点关注这个简单场景中网站数据请求以及浏览器在呈现网页之前做的准备工作 - 也就是导航(navigation)的过程。

一切都从浏览器进程开始

我们在第 1 部分:CPU、GPU、内存和多进程架构中提到,浏览器tab外面发生的一切都是由浏览器进程(browser process)控制的。浏览器进程有很多负责不同工作的线程(worker thread),其中包括绘制浏览器顶部按钮和导航栏输入框等组件的UI线程(UI thread)、管理网络请求的网络线程(network thread)、以及控制文件读写的存储线程(storage thread)等。当你在导航栏里面输入一个URL的时候,其实就是UI线程在处理你的输入。

Browser processes

图 1:浏览器的用户界面在顶部,浏览器进程的示意图在底部,里面有UI、网络和存储线程

一次简单的导航

第 1 步:处理输入

当用户开始在地址栏中输入内容时,UI 线程询问的第一件事是“您输入的字符串是搜索的关键词(search query)还是一个URL地址?”。因为对于Chrome来说,地址栏的输入既可能是一个可以直接请求的URL,还可能是用户想在搜索引擎(例如Google)里面搜索的关键词信息,所以 UI 线程需要解析并决定是将用户输入发送到搜索引擎还是直接请求你输入的站点资源。

Handling user input
图 1:UI线程在询问输入的字符串是搜索关键词还是一个URL

第 2 步:开始导航

当用户按下回车键的时候,UI线程会叫网络线程(network thread)初始化一个网络请求来获取站点的内容。这时候tab上会展示一个提示资源正在加载中的旋转圈圈,而且网络线程会进行一系列诸如DNS寻址以及为请求建立TLS连接的操作。

导航开始
图 2:UI线程告诉网络线程跳转到mysite.com

这时如果网络线程收到服务器的HTTP 301重定向响应,它就会告知UI线程进行重定向然后它会再次发起一个新的网络请求。

第 3 步:读取响应

网络线程在收到HTTP响应的主体(payload)流(stream)时,在必要的情况下它会先检查一下流的前几个字节以确定响应主体的具体媒体类型(MIME Type)。响应主体的媒体类型一般可以通过HTTP头部的Content-Type来确定,不过Content-Type有时候会缺失或者是错误的,这种情况下浏览器就要进行MIME类型嗅探来确定响应类型了。MIME类型嗅探并不是一件容易的事情,你可以从Chrome的源代码的注释来了解不同浏览器是如何根据不同的Content-Type来判断出响应主体是属于哪个媒体类型的。

在缺失 MIME 类型或客户端认为文件设置了错误的 MIME 类型时,浏览器可能会通过查看资源来进行 MIME 嗅探。每一个浏览器在不同的情况下会执行不同的操作。因为这个操作会有一些安全问题,有的 MIME 类型表示可执行内容而有些是不可执行内容。浏览器可以通过请求头 Content-Type 来设置 X-Content-Type-Options 以阻止 MIME 嗅探。

HTTP 响应
图 3:响应的头部有Content-Type信息,而响应的主体有真实的数据

如果响应的主体是一个HTML文件,浏览器会将获取的响应数据交给渲染进程(renderer process)来进行下一步的工作。如果拿到的响应数据是一个压缩文件(zip file)或者其他类型的文件,响应数据就会交给下载管理器(download manager)来处理。

MIME 类型嗅探
图 4:网络线程在询问响应的数据是不是来自安全源的HTML文件

网络线程在把内容交给渲染进程之前还会对内容做SafeBrowsing检查。如果请求的域名或者响应的内容和某个已知的病毒网站相匹配,网络线程会给用户展示一个警告的页面。除此之外,网络线程还会做CORBCross Origin Read Blocking)检查来确定哪些敏感的跨站数据不会被发送至渲染进程。

Cross-Origin Read Blocking(下称CORB)不是一个HTTP首部,而是站点隔离机制的一部分[6]。如上文所说,站点隔离可以让不同站点运行在不同进程中,但这样还不够,因为恶意网站仍然可以合法地请求跨源资源。例如,一个恶意网站可以使用一个img元素来请求含有敏感信息(如银行余额)的JSON文件:

<img src="https://your-bank.example/balance.json">

这个JSON文件会出现在该恶意站点的渲染器进程的内存中,渲染器发现这不是一个有效的图片格式,于是不渲染这张图片。在类似Spectre漏洞的帮助下,攻击者可以设法访问这部分内存以获取敏感信息。

CORB正是用于阻止这样的访问。如果一个响应被CORB阻止,这个响应甚至不会到达恶意站点所在的进程中,这比之前[7]所讲的不透明响应(脚本不能访问,但可出现在渲染器进程中)更严格[8]

CORB不会检视以下两类请求:

  • 导航请求或各种嵌入请求,例如跨源的<iframe><object><embed>等。这些嵌入元素本身就有一个独立的安全上下文,在站点隔离的帮助下,其数据与恶意文档的数据分别存于不同进程中,已经足够安全
  • 下载请求,这类请求的响应数据直接储存至硬盘,不会经过跨源文档的上下文,不需要CORB保护

CORB会检视其余的请求,包括:

  • XHRfetch()
  • pingnavigator.sendBeacon()
  • <link rel="prefetch" ...>
  • 以下资源的请求:
    • 图像请求,如<img>元素,网站图标/favicon.icoSVG中的<image>CSS中的background-image等等
    • 脚本请求,如<script>importScripts()navigator.serviceWorker.register()audioWorklet.addModule()等等
    • 音频、视频和字幕请求
    • 字体请求
    • 样式请求
    • 报告请求,如CSP报告、NEL报告等

CORB的核心理念是,考虑一个资源是否在上述所有情景中均不适用,如果这个资源在上述情景中要么产生CORS错误,要么产生语法或解码错误,或生成不透明响应,那么此时CORB就应该阻止这个资源的加载。也就是说,CORB进一步阻止了本来就不可用的资源,本来可用的资源可以照常使用(包括正确实现CORS的跨源资源),因此CORB几乎对兼容性没有影响。

目前,CORB会保护3类内容:JSON、HTML、XML(这里说的保护,就是阻止响应到达恶意站点所在进程)。

Fetch规范规定,在请求的跨源模式为no-cors[9]

  • 未声明Content-Type首部的响应不受CORB保护
  • 如果响应状态码为206Content-Type首部确定的MIME typeHTML、JSONXMLimage/svg+xml除外),则响应受CORB保护
  • 如果响应声明了X-Content-Type-Options: nosniff首部,Content-Type首部确定的MIME type为上面三种,或者为text/plain,该响应也受CORB保护

Chromium还加入了嗅探机制,用于进一步确定Content-Type首部声明的类型是否正确(未声明Content-Type首部的响应依旧不受CORB保护),做了比Fetch规范更细致的分类[10]。由于嗅探机制并非尽善尽美,因此谷歌建议开发者使用正确的Content-Type首部,并声明X-Content-Type-Options: nosniff首部,以避免嗅探[11]

此外,WHATWG成员正在讨论将更多内容类型加入到CORB的保护范围中,如pdf、csv等。

跨源相关机制综述(四):Spectre攻击与跨源机制的改进

第 4 步:寻找一个渲染进程(renderer process)

在网络线程做完所有的检查后并且能够确定浏览器应该导航到该请求的站点,它就会告诉UI线程所有的数据都已经被准备好了。UI线程在收到网络线程的确认后会为这个网站寻找一个渲染进程(renderer process)来渲染界面。

查找渲染器进程
图 5:网络线程告诉UI线程去寻找一个渲染进程来渲染界面

由于网络请求可能需要长达几百毫秒的时间才能完成,为了缩短导航需要的时间,浏览器会在之前的一些步骤里面做一些优化。例如在第二步中当UI线程发送URL链接给网络线程后,它其实已经知晓它们要被导航到哪个站点了,所以在网络线程干活的时候,UI线程会主动地为这个网络请求启动一个渲染线程。如果一切顺利的话(没有重定向之类的东西出现),网络线程准备好数据后页面的渲染进程已经就准备好了,这就节省了新建渲染进程的时间。不过如果发生诸如网站被重定向到不同站点的情况,刚刚那个渲染进程就不能被使用了,它会被摒弃,一个新的渲染进程会被启动。

第 5 步:提交(commit)导航

到这一步的时候,数据和渲染进程都已经准备好了,浏览器进程(browser process)会通过IPC告诉渲染进程去提交本次导航(commit navigation)。除此之外浏览器进程还会将刚刚接收到的响应数据流传递给对应的渲染进程让它继续接收到来的HTML数据。一旦浏览器进程收到渲染线程的回复说导航已经被提交了(commit),导航这个过程就结束了,文档的加载阶段(document loading phase)会正式开始。

到了这个时候,导航栏会被更新,安全指示符(security indicator)和站点设置UIsite settings UI)会展示新页面相关的站点信息。当前tab的会话历史(session history)也会被更新,这样当你点击浏览器的前进和后退按钮也可以导航到刚刚导航完的页面。为了方便你在关闭了tab或窗口(window)的时候还可以恢复当前tab和会话(session)内容,当前的会话历史会被保存在磁盘上面。

Commit the navigation
图 6:浏览器进程通过IPC来对渲染进程发起渲染页面的请求

额外步骤:初始加载完成(Initial load complete)

当导航提交完成后,渲染进程开始着手加载资源以及渲染页面。我会在后面的文章中讲述渲染进程渲染页面的具体细节。一旦渲染进程“完成”(finished)渲染,它会通过IPC告知浏览器进程(注意这发生在页面上所有帧(frames)的onload事件都已经被触发了而且对应的处理函数已经执行完成了的时候),然后UI线程就会停止导航栏上旋转的圈圈。

我这里用到“完成”这个词,因为后面客户端的JavaScript还是可以继续加载资源和改变视图内容的。

页面完成加载
图 7:渲染进程通过IPC告诉浏览器进程页面已经加载完成了

导航到不同的站点

一个最简单的导航情景已经描述完了!可是如果这时用户在导航栏上输入一个不一样的URL会发生什么呢?如果是这样,浏览器进程会重新执行一遍之前的那几个步骤来完成新站点的导航。不过在浏览器进程做这些事情之前,它需要让当前的渲染页面做一些收尾工作,具体就是询问一下当前的渲染进程需不需要处理一下beforeunload事件。

beforeunload可以在用户重新导航或者关闭当前tab时给用户展示一个“你确定要离开当前页面吗?”的二次确认弹框。浏览器进程之所以要在重新导航的时候和当前渲染进程确认的原因是,当前页面发生的一切(包括页面的JavaScript执行)是不受它控制而是受渲染进程控制,所以它也不知道里面的具体情况。

注意:不要随便给页面添加beforeunload事件监听,你定义的监听函数会在页面被重新导航的时候执行,因此这会增加重导航的时延。beforeunload事件监听函数只有在十分必要的时候才能被添加,例如用户在页面上输入了数据,并且这些数据会随着页面消失而消失。

beforeunload 事件处理程序
图 8:浏览器进程通过IPC告诉渲染进程它将要离开当前页面导航到新的页面了

如果重新导航是在页面内被发起的呢?例如用户点击了页面的一个链接或者客户端的JavaScript代码执行了诸如window.location = "https://newsite.com"的代码。这种情况下,渲染进程会自己先检查一个它有没有注册beforeunload事件的监听函数,如果有的话就执行,执行完后发生的事情就和之前的情况没什么区别了,唯一的不同就是这次的导航请求是由渲染进程给浏览器进程发起的。

如果是重新导航到不同站点(different site)的话,会有另外一个渲染进程被启动来完成这次重导航,而当前的渲染进程会继续处理现在页面的一些收尾工作,例如unload事件的监听函数执行。Overview of page lifecycle states这篇文章会介绍页面所有的生命周期状态,the Page Lifecycle API会教你如何在页面中监听页面状态的变化。

新的导航和卸载
图 9:浏览器进程告诉新的渲染进程去渲染新的页面并且告诉当前的渲染进程进行收尾工作

Service Worker的情景

这个导航过程最近发生的一个改变是引进了service worker的概念。因为Service worker可以用来写网站的网络代理(network proxy),所以开发者可以对网络请求有更多的控制权,例如决定哪些数据缓存在本地以及哪些数据需要从网络上面重新获取等等。如果开发者在service worker里设置了当前的页面内容从缓存里面获取,当前页面的渲染就不需要重新发送网络请求了,这就大大加快了整个导航的过程。

这里要重点留意的是service worker其实只是一些跑在渲染进程里面的JavaScript代码。那么问题来了,当导航开始的时候,浏览器进程是如何判断要导航的站点存不存在对应的service worker并启动一个渲染进程去执行它的呢?

其实service worker在注册的时候,它的作用范围(scope)会被记录下来(你可以通过文章The Service Worker Lifecycle了解更多关于service worker作用范围的信息)。在导航开始的时候,网络线程会根据请求的域名在已经注册的service worker作用范围里面寻找有没有对应的service worker。如果有命中该URLservice workerUI线程就会为这个service worker启动一个渲染进程(renderer process)来执行它的代码。Service worker既可能使用之前缓存的数据也可能发起新的网络请求。

服务工作者范围查找
图 10:网络线程会在收到导航任务后寻找有没有对应的service worker

服务工作者导航
图 11:UI线程会启动一个渲染进程来运行找到的service worker代码,代码具体是由渲染进程里面的工作线程(worker thread)执行

导航预加载 - Navigation Preload

在上面的例子中,你应该可以感受到如果启动的service worker最后还是决定发送网络请求的话,浏览器进程和渲染进程这一来一回的通信包括service worker启动的时间其实增加了页面导航的时延。导航预加载就是一种通过在service worker启动的时候并行加载对应资源的方式来加快整个导航过程效率的技术。预加载资源的请求头会有一些特殊的标志来让服务器决定是发送全新的内容给客户端还是只发送更新了的数据给客户端。

导航预载
图 12:UI线程在启动一个渲染进程去运行service worker代码的同时会并行发送网络请求

总结

在本篇文章中,我们讨论了导航具体都发生了哪些事情以及浏览器优化导航效率采取的一些技术方案,在下一篇文章中我们将会深入了解浏览器是如何解析我们的HTML/CSS/JavaScript来呈现出网页内容的。

阅读 359
avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1.7k 声望
4.4k 粉丝
0 条评论
avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1.7k 声望
4.4k 粉丝
宣传栏