窥探现代浏览器架构(二)

1,486 阅读12分钟

前言

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

导航的时候都发生了什么

这篇文章是探究Chrome内部工作原理的四集系列文章中的第二篇,在上一篇文章中我们探讨了不同进程或者线程是如何负责浏览器各个不同部分的工作的。在这篇文章中,我们将会深入了解每个进程和线程是如何沟通协作来为我们呈现出网站内容的。

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

一切都从浏览器进程开始

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

UI,网络和存储线程都是属于浏览器进程的

一次简单的导航

第一步:处理输入

当用户开始在导航栏上面输入内容的时候,UI线程(UI thread)做的第一件事就是询问:“你输入的字符串是一些搜索的关键词(search query)还是一个URL地址呢?”。因为对于Chrome浏览器来说,导航栏的输入既可能是一个可以直接请求的域名还可能是用户想在搜索引擎(例如Google)里面搜索的关键词信息,所以当用户在导航栏输入信息的时候UI线程要进行一系列的解析来判定是将用户输入发送给搜索引擎还是直接请求你输入的站点资源。

UI线程在询问输入的字符串是搜索关键词还是一个URL

第二步:开始导航

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

UI线程告诉网络线程跳转到mysite.com

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

第三步:读取响应

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

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

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

网络线程在询问响应的数据是不是来自安全源的HTML文件

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

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

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

网络线程告诉UI线程去寻找一个渲染进程来渲染界面

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

第五步:提交(commit)导航

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

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

浏览器进程通过IPC来对渲染进程发起渲染页面的请求

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

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

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

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

导航到不同的站点

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

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

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

浏览器进程通过IPC告诉渲染进程它将要离开当前页面导航到新的页面了

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

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

浏览器进程告诉新的渲染进程去渲染新的页面并且告诉当前的渲染进程进行收尾工作

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。如果有命中该URL的service worker,UI线程就会为这个service worker启动一个渲染进程(renderer process)来执行它的代码。Service worker既可能使用之前缓存的数据也可能发起新的网络请求。

网络线程会在收到导航任务后寻找有没有对应的service worker

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

导航预加载 - Navigation Preload

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

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

总结

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

持续关注我的技术动态

我是进击的大葱,关注我和我一起进步成独当一面的全栈工程师!

文章首发于:窥探现代浏览器架构(二)

关注我的个人公众号获取我的最新技术推送!