现代浏览器结构

318 阅读22分钟

最近阅读了 Chrome 的技术博客,是关于现代浏览器的描述。那么我希望在这篇博客的基础上写一点我自己的总结和心得,希望可以给大家一些启发。

大家对 CPU 是什么应该很了解,那么什么是 GPU 呢?GPU 的全称是 Graphics Processing Unit,即图形进程单元。GPU 擅长同时跨多核处理简单任务,最初在被开发出来的时候就是为了处理图形相关的问题。因此直接使用 GPU 进行渲染的任务都意味着快和丝滑。

CPU 和 GPU 基本上决定了应用在当前机器上的执行效率。

了解了硬件对应用的影响之后,我们来简单了解一下进程和线程。我们可以说一个应用运行之后就是一个进程,线程就是存在在进程中的一个任务。当你打开了一个应用,一个进程就会被创建,同时可能也会有线程被创建去处理任务, 与此同时,操作系统也会给当前的进程分配存储空间。

一个进程也可以告知操作系统去开启另外一个进程去运行不同的任务,同时也会给操作系统分配新的内存空间。如果这两个进程需要沟通和交流,会使用 IPC (Inter Process Communication)进行通讯。所以一个工作进程没有反应的话,也不会影响其他的进程。

浏览器结构

其实浏览器就是由进程和线程组成的,有可能是单进程的,也有可能是多进程使用 IPC 来交流的组织结构。这其中其实没有什么标准,不同浏览器之间很有可能千差万别,因此接下来的讨论仅限于 Chrome,毕竟行业楷模。

在 Chrome 中,broswer process 是位于顶部的进程,浏览器进程可以调度其他的进程来运行应用的不同的部分。renderer process 则是存在在每一个 tab 中,并且每个 tab 独有一个 renderer process。Chrome 也在尝试着以站点来划分进程,包括 iframe。

每一个浏览器进程控制的内容

  1. Broswer: 基本控制了 Chrome 应用所有的功能,包括地址栏、书签、后退、前进按钮等。它也会控制浏览器的权限和优先级,比如网络请求、文件权限等。

  2. Renderer: 控制一个 tab 内所有站点的展现

  3. Plugin:控制网站的插件使用:比如 flush

  4. GPU: 控制 GPU 任务并且和其他进程的 GPU 任务进行隔离

这里甚至有更多的进程,比如 Extension process 和 Utility process,用来控制扩展和工具库。

多进程结构给 Chrome 带来的好处

对于 Chrome 来讲,每一个 tab 都拥有一个渲染进程。比如说你当前打开了 3 个页面,每一个页面都是独立运行的一个 render 进程,如果一个页面长期没有响应,你就可以关掉没有响应的那个页面,并且继续在其他页面上进行操作。但是如果说所有的 tab 都用一个进程,那么如果有一个页面卡死了,其他的页面也会一起卡死。

将页面的进程隔离开还有一个好处,就是可以保证安全和沙箱隔离。操作系统提供了一种方法去限制进程的权限,因此浏览器可以将具体的特性从特定的沙箱中隔离出来。例如:Chrome 会限制二进制文件接近那些会有用户输入二进制数据的进程,比如渲染进程。

因为每个进程都有自己的内存空间,因此它们通常会包含一些公共的基础设施的副本,比如 V8 引擎。而正是因为他们不是同一个进程中的不同线程,因此他们也没有办法共享内存,这也意味着更多的内存消耗。为了节约内存,Chrome 对于一个最多可以开多少进程做了一个限制。这个限制取决于你当前的设备的 CPU 的能力,一旦命中了这个限制,它就会将进程的单位归并为站点而不是页面。

节省内存

由于进程对内存的消耗比较大,因此 Chrome 正在做一个较大的结构调整,将浏览器中的每一个程序都当做一个单独的服务,从而可以将它们轻易地分割成不同的进程或者合成一个进程。

这个思路总体来说就是当 Chrome 运行在一个配置非常好的硬件上时,它也许会将每一个服务都分割成不同的进程来提供更高的稳定性。但是如果它运行在一个一般的硬件上时,Chrome 就会将所有的服务整合成为一个进程来节约内存。同样整合资源节约内存的方法也同样运用到了其他的平台上,比如安卓。

帧渲染进程 —— 站点隔离

站点隔离是 Chrome 中的一个特性,它为每一个跨站的 iframe 提供了一个单独的 renderer process。

我们在之前已经说过了在 Chrome 中开启一个 tab 通常就是一个 renderer process,并且在这种模式下它还允许跨站的 iframe 在单独运行一个 renderer process 的情况下去和不同的站点共享内存。也就是说在同一个 renderer process 中同时运行 a.com 和 b.com 是完全可能的。同源策略是 web 中最核心的模型,这确保了一个域名在没有权限的时候无法从另外一个域名中拉数据,越过这个策略是安全攻击的一个基本目标,进程隔离是分离站点最高效的方法。从 Chrome 67 的桌面版开始,每一个跨站的 iframe 都有一个单独的 renderer process。

站点隔离是经历数年的工程化的结果,因为它不是简单地去分配不同地 render 进程,它从底层改变了不同的 iframe 互相沟通的方式。在一个页面上打开 dev tools 而这个页面上的 iframe 运行在一个独立的 render 进程中意味着这个进程需要无缝衔接所有背后的工作。

在 Navigation 过程中发生了什么

通过上面对于浏览器结构有了一个简单的了解,下面我们就具体通过一些浏览器的功能来说。首先我们来看看 navigation 这个过程中发生了什么。我们在浏览器的地址框内输入一个地址,浏览器从后台拉取到数据,然后进行页面渲染,最终页面呈现在我们面前,这个过程我们就叫作 navigation。

这个过程由一个 broswer process 开始

正如我们在上面说的,在 tab 之外的所有事情都是由 broswer process 来完成的。Broswer process 中的线程,比如 UI 线程,就是来描绘浏览器上面的按钮和输入框的;network 线程是用来处理从网络中拉取数据的;内存线程就是用来控制文件的。你输入 url 的地址栏输入框就是由 UI 线程控制的。

一个简单的 navigation 过程

第一步:控制输入

当你在地址栏输入内容的时候,UI 线程最先检查的是 "这是一个查询字符串还是一个 url"。在 Chrome 中,地址栏也同样是搜索框,所以 UI 线程首先需要去解析并且去判断你输入的是一个内容还是 url。

第二步:导航

当用户确认导航之后,UI 线程会初始化一个网络请求去拿网页内容,同时 loading 样式也会在 tab 的边缘出现。之后 network 线程会通过适当的协议进行 DNS 查找并且为这个请求建立 TLS 链接。在这个节点上,network 线程也许会接收一个服务器重定向,类似于 HTTP 301 请求码。如果是这样的话,network 线程会告知 UI 线程页面被重定向了,然后这个 url 请求会被重新初始化。

第三步:理解返回

当页面开始返回数据之后,network 线程会去关注开始进来的一些数据流。如果必要的话,返回体里面的 Content-Type 字段应该标明当前返回数据的类型。但是由于这个字段经常有可能会被漏掉,因此类型校验通常是在 network 线程中完成的。如果返回的是一个 HTML 类型的文本,那么数据就会被传递给 renderer process。但是如果是一个 zip 类型的文件或者其他文件那就意味着这是一个下载任务,会交给下载控制器去做。

也是在这一步,会进行浏览器的安全检查。如果当前的域名和数据能够匹配一个已知的恶意站点,network 线程会抛出一个警告页面。另外,CORB 检查会去确认敏感数据没有传递给 renderer process。

第四步:拿到一个 renderer process

当所有检查都完毕之后,network 线程已经确认浏览器可以被导入到当前的这个站点了。netWork 线程会告知 UI 线程数据已经准备好了。UI 线程此时就会找一个 renderer process 去进行网页渲染。

因为网络请求需要花费几百毫秒去将结果拿回来,因此在这里会有一个优化的方案。当 UI 线程发送一个 url 请求去 network 线程,它已经知道当前的页面要导航到哪个网站,因此它会在将请求发到 network 线程的同时也会同时开启一个 renderer process。通过这种方式,如果一切顺利的话,数据返回的时候已经有一个现成的 renderer process 了。但是如果当前的导航有重定向,那么就需要一个新的进程。

第五步:完成导航

现在数据和 renderer process 准备好了。broswer process 会给 renderer process 发送一个 IPC 消息去完成导航,同时它也会将数据传递过去,这也是为什么 renderer process 可以一直接收 HTML 数据。一旦 broswer process 接收到 renderer process 内已经发生提交的确认信息,navigation 这个过程就已经完成了,文档的加载开始了。

在这个节点上,地址栏已经更新完毕了,安全性的指标和站点的 UI 设置反映出了新页面的信息。当前 tab 的session 历史会被更新,前进和后退按钮也会被导向最近访问的页面上。为了在你关闭窗口或者页面之后当前的 tab 或者 session 能够被唤醒,session 历史会被存在硬盘上。

额外的一步:初始化加载完成

navigation 这一步完成了之后,renderer process 会继续加载资源并且渲染页面。当渲染进程完成之后,它会发送一个 IPC 消息给 broswer process(这个消息会在 onload 事件之后发送,也就是所有的帧被执行完毕之后)。到当前这个节点,loading 标志会消失掉。

导航到另一个站点

一个简单的导航就完成了!但是如果用户再次在地址栏里面输入另外一个 url,会发生什么事情呢?浏览器当然会重新进行上述的步骤。但是在走上述步骤之前,它需要检查当前页面有没有监听 beforeunload 事件。

当你离开或者关闭该网页的时候,beforeunload 事件的触发会弹出 "是否离开该网站" 的预警,在 tab 内部所有的逻辑都是由 renderer process 来进行控制的,因此每当新的 navigation 请求进入的时候,broswer process 都需要去检查 renderer process。

如果当前的导航是在 renderer process 中发起的(比如用户通过点击一个 a 标签打开页面),renderer process 首先就会去检查 beforeunload 事件,然后它才会再走一遍上述相同的程序去初始化导航。唯一不同的是这个导航是从 renderer process 发起到 broswer process 的。

如果当前的新导航是跳去一个新的站点而不是刷新当前页面,一个独立的 renderer process 会被唤醒去控制新的页面,同时当前的 renderer process 会继续控制如 unload 事件。

关于 service worker 的例子

service worker 是一种在应用中去写网络代理的工具,这个工具能够让开发者更加灵活地控制在本地缓存的内容和什么时候从后台拉取数据。如果本地可以通过 service worker 缓存来拉取页面,就没有必要再去网络上去拉取数据了。

service worker 是运行在 renderer process 内的 js 代码,但是什么时候 navigation 会被开启,一个 broswer process 如何知道当前的站点是有 service worker 配置的呢?

当一个 service worker 被注册了之后,service worker 的空间会被保存成为一个引用。当一个 navigation 开始了之后,network 线程会去检查当前的域名和已注册的 service worker 空间是否一致。如果能够和 url 匹配一致,UI 线程会找到一个 renderer process 去执行 service worker 的代码。Service worker 有可能会去缓存拿数据、减少去网络中的请求、也有可能会从网络中去获取新的资源。

Navigation 预加载

如果数据最终还是要从远端网络上拉取的话,上述在 broswer process 和 renderer process 中进行的过程都会被拖慢。Navigation 预加载就是用来解决这个问题的一种方法,它会在 service worker 打开的同时进行资源的加载。它会在这些请求的 header 上做一个标记,让服务器对于这些请求做出不同的内容返回。比如说它不会将整个 HTML 下载下来而是会采取更新数据的方式。

细究 renderer process 内部机制

那上面是从一个更为宏观的 broswer process 来看我们的浏览器机制的。在这一部分我们会从一个 tab 深入地研究 renderer process 内部的机制。

它可以控制页面内的所有内容

除了用 web worker 和 service worker 的情况, 在一个 renderer process 中,主线程控制所有的逻辑代码。合成和光栅线程也运行在 renderer process 里面,这两个线程的存在可以明显地提高页面的顺滑度和执行效率。

renderer process 的工作就是将 js、html、和 css 转换成我们看到的页面, 一般来说,转换会涉及以下几步。

1. 解析

浏览器会根据当前的 HTML 结构,构建 DOM 树,并且会同时加载次级资源,比如页面上的 CSS 样式表、图片、和 js 脚本。

在这个过程中,如果遇到了 js 脚本的加载,浏览器会先停止解析 DOM 树而先执行 js 脚本。这是因为 js 中有可能会改变 DOM 树的结构。当然,如果当前的 js 脚本中的执行确定不会对 DOM 树有所改变,浏览器也提供了可以不阻塞的加载方式,可以使用 defer 或者 async 异步加载,也可以使用

<link rel="preload">

进行预加载

2. 样式计算

解析完 DOM 结构之后,主线程会根据 CSS 选择器来进行样式计算,并将样式添加到具体的 DOM 节点上,来呈现出 HTML 应有的样子。就算没有 CSS 样式表,浏览器也会提供一个默认样式。

3. 页面布局

主线程在这个过程中会计算所有的 DOM 节点来计算样式,并且通过元素的边框大小和横纵坐标来进行排版。布局树可能会比 DOM 树的体积稍小一点,但是它只包含当前页面上展现出来的布局。比如说一个元素如果 display: none, 那么它不会在最终的布局树上,但是 DOM 树上会展现它。而 p::before{content:"Hi!"} 这样的代码,会在最终的布局树上呈现,却不会出现在 DOM 树上。

4. 渲染

知道了样式、结构和布局,现在就可以渲染页面了。有了上面这些条件,你还需要知道以怎样的顺序去渲染这些元素。在绘制这一步,主线程会根据布局树来生成一个绘制记录,这个记录的具体过程就是类似于先渲染背景、然后是文字、最后是内容。z-index 这个属性就在此过程中显示的比较重要。

合成阶段

在这一步的时候,浏览器已经知道了整个文件的结构、每一个元素的样式、整个页面的几何位置和绘制顺序,那么怎样来绘制一个页面呢?将上面这些信息变成页面上的一个一个像素,这个过程叫作光栅化。原生的去进行这一步的方式是指光栅化可视化视图中的某一部分。如果一个用户去滚动页面,就会有已经被光栅化的帧被移动掉,并且新显示出来的页面会被光栅化。这就是 Chrome 最先被发出 release 的版本之后光栅被操作的过程,现代浏览器对于这一过程的操作要复杂得多。

组合的定义

光栅化可以将页面的不同部分分离成不同的层次,分别光栅化这些部分,并且在 compositor 线程中去合成一个页面。当页面被滚动的时候,因为各个部分的光栅化已经完成,因此现在需要完成的任务就是合成一个新的帧。动画也可以由同样的方式完成。

分割层次

为了找到元素在具体哪个层次中,主线程会遍历 layout tree 然后生成一个 layer tree。如果页面中的一个部分应该被分割为一个层(比如页面的侧边栏),你就可以使用 will-change 这个 CSS 属性去告知浏览器。你也许想帮每个元素都找到它的层,但是合成大量层的操作可能比光栅化每一帧要慢得多。所以你自己操纵页面的渲染过程实际上是不可取的。

从主线程上去除光栅化和合成过程

在 layer tree 被生成了并且渲染顺序被决定了之后,主线程会将当前的信息一起传递给 compositor 线程。compositor 线程会在下一步光栅化每一个层。由于一个层次最大可以大到整个页面,因此 compositor 线程可以将它们碎片化之后传递给 raster 线程。raster 线程会将它们光栅化并且将它们存储在 GPU 的内存中。

Compositor 线程可以将不同的 raster 线程进行分级,因此在视图内的层次可以首先被进行光栅化操作。一个层内部同样也可以被碎片化。

这些碎片被光栅化之后,compositor 线程会创建信息去创建一个合成帧。

合成帧信息会通过 IPC 传递给 broswer process, 在这个节点上,另外一个合成帧也可以从 UI 线程添加进来处理浏览器的 UI 变化或者来自于其他 renderer process 的扩展。这些合成帧会被发送到 GPU 并渲染到屏幕上。如果一个滚动事件被触发,compositor 线程会创建另外一个合成帧并发送给 GPU。

使用合成的好处就是它不占用主线程,compositor 线程不需要等待样式计算或者 js 执行。这也是为什么合成动画被认为是最流畅的。但是当排版和绘制重新被计算,主线程就会重新被牵扯进来。

那么在用户进行输入的时候合成器是如何保持整个页面顺滑的呢?

从浏览器的角度看用户输入事件

当你听到 "输入事件" 的时候,我们可能想起的就是文本输入框或者鼠标点击事件。但是从浏览器的视角来看,其实用户输入包含了所有的用户姿势。比如滚动和触摸事件对于浏览器来说都算是用户输入。

当用户输入发生时,broswer process 是最先接收到消息的。但是由于 tab 内部都是由 renderer process 去控制的,因此 broswer process 只能意识到当前的这个行为是在哪里发生的。所以 broswer process 会将事件类型和发生位置的坐标发送给 renderer process。Renderer process 通过查找事件发生的目标和控制事件绑定的回调函数来合理地控制事件。

合成器接收输入事件

如果一个页面上没有任何的事件监听器,compositor 线程会创建一个完全独立于主线程的新的合成帧。但是如果当前页面上绑定了事件会发生什么呢?compositor 线程如何找出当前事件是否需要被控制。

理解"非快速滚动"区域

既然运行 js 代码是主线程的任务,当页面被合成之后,compositor 线程会将页面中有事件绑定的区域定义为 "非快速滚动区域"。有了这些信息之后,compositor 线程会去向主线程确认当前的事件输入是否发生在这个区域。如果事件输入超出了这个区域,合成器会继续合成新的帧而不会等待主线程。

写事件监听时需要注意的事

web 页面中最常见的事件监听就是事件委托,通过事件冒泡,你可以在最顶层的元素上并且代理事件目标的任务。因为你只需要写一个事件监听就可以委托所有的元素,事件委托的设计方式是十分吸引人的。然而,如果你站在浏览器代码的角度看这个问题,现在整个页面都被标记为一个非快速滚动区域了。这意味着你的事件绑定如果不在一个确切的位置,每次事件输入被触发的时候合成器就不得不每次都去询问主线程然后等待返回。如果这样的话,合成器的顺滑性也会丢失掉。

为了从一开始避免这种情况,你可以在你的事件监听中传递 passive: true 这个属性。它可以让你继续在主线程中监听事件,而合成器则可以先行一步去合成新的帧。

检查当前的事件是否可以取消

试想你在当前页面中有一个盒子而且你只想把滚动方向限制在水平方向。使用 passive: true 这个配置意味着当前页面可以滚动得很顺滑。但是也许你想要使用 preventDefault 制止垂直滚动发生的时候,它已经开始了。你可以使用 event.cancelable 来验证这一猜想。

document.body.addEventListener('pointermove', event => {
    if(event.cancelable){
        event.preventDefault();
    }
}, {passive: true});

同样的,你也可以使用 CSS 相关的属性,如 touch-action 来减少事件控制。

#area {
    touch-action: pan-x;
}

查找事件目标

当 compositor 线程发送给一个输入事件给主线程的时候,第一件事就是要通过命中测试来找到当前触发事件的目标元素。命中测试是使用从 renderer process 中继承的绘制记录的数据来找出点击坐标之下的是哪个元素。

最小化主线程的事件派发

在上面的内容中我们讨论过最典型的刷新频率是一秒钟 60 次,在这种频率下我们才可以保证最顺滑的动画节奏。对于一个输入来说,普通的触屏设备每秒钟会触发 60-120 次触摸事件,鼠标则是每秒钟 100 次,因此输入事件比屏幕刷新具有更高的保真度。

如果一个如 touchmove 这样的连续事件是以每秒钟 120 次的频率发送给主线程的,它很有可能会超过屏幕刷新的频率和 js 执行次数

为了在主线程中最小化调用次数,Chrome 会聚集连续性的事件,并且会延迟派发时间直到下一次 requestAnimationFrame。其他的非连续事件, 比如 keydown、keyup、mouseup 等事件会立即派发。

使用 getCoalescedEvents 去获取帧内事件

对于大多数的 web 应用来说,事件聚集足够提供一个好的用户体验。但是,如果你运行的是一个绘图应用或者跟踪 touchmove 事件的坐标来画图的话,可能会在画图的中间断掉。在这种情况下,你可以使用 getCoalescedEvents 方法在事件中去收集聚集事件的信息。

window.addEventListener('pointermove', event => {
    const events = event.getCoalescedEvents();
    for (let event of events) {
        const x = event.pageX;
        const y = event.pageY;
        // draw a line using x and y coordinates.
    }
});

那么以上就是我们对于浏览器结构的一个较为浅层次的了解。浏览器是我们前端开发的重中之重,了解浏览器的知识不仅可以帮助我们弄清楚我们的页面是怎么渲染在我们的眼前的,也可以在此基础上深入地优化我们的代码,并且在问题的查找上也可以更加便利。