深入了解现代 Web 浏览器

2,427 阅读25分钟

这是我参与8月更文挑战的第4天,活动详情查看:8月更文挑战

这篇文章我们将从高级架构到渲染管道的细节深入了解 Chrome 浏览器。如果您想知道浏览器如何将您的代码转换为功能性网站,或者您不确定为什么建议使用特定技术来提高性能,那么本文适合您。

对于前端开发者,知其然知其所以然很重要,所以只有在知己知彼的情况下才能更好的驾驭浏览器,以期浏览器给你带来更高的性能。

核心计算术语和 Chrome 的多进程架构

计算机的核心是 CPU 和 GPU

CPU(中央处理器) 和 GPU(图形处理器) 作为计算机中最重要的两个计算单元直接决定了计算性能。

CPU 可以被认为是您计算机的大脑。与 CPU 不同,GPU 擅长处理简单的任务,但同时跨多个内核。顾名思义,它最初是为处理图形而开发的。这就是为什么在图形上下文中“使用 GPU”或“GPU 支持”与快速渲染和流畅交互相关联。近年来,随着 GPU 加速计算,越来越多的计算成为可能单独使用 GPU。

当您在计算机或手机上启动应用程序时,CPU 和 GPU 是驱动应用程序的驱动力。通常,应用程序使用操作系统提供的机制在 CPU 和 GPU 上运行。

计算机架构。

image.png

我们可以把计算机自下而上分成三层:底部是机器硬件,中间是操作系统,顶部是应用程序。有了操作系统的存在,上层运行的应用可以使用操作系统提供的能力使用硬件资源而不会直接访问硬件资源。

在进程和线程上执行程序

image.png 进程作为边界框,线程作为在进程内游动的抽象鱼

在深入研究浏览器架构之前要掌握的另一个概念是进程和线程。一个进程可以被描述为一个应用程序的执行程序。线程是存在于进程内部并执行其进程程序的任何部分的线程。

当您启动应用程序时,就会创建一个进程。程序可能会创建线程来帮助它工作,但这是可选的。操作系统会为进程分配私有的内存空间以供使用,当关闭程序时,这段私有的内存也会被释放。其实还有比线程更小的存在就是协程,而协成是运行在线程中更小的单位。async/await 就是基于协程实现的。

进程间通信(IPC):一个进程可以让操作系统开启另一个进程处运行不同的任务。当两个进程需要通信时,可以用 IPC(Inter Process Communication)。

多数程序被设计成使用 IPC 来进行进程间的通信,好处在于当一个进程给另一个进程发消息而没有回应时,并不影响当前的进程继续工作。

Chrome 的多进程架构

浏览器架构

那么如何使用进程和线程构建 Web 浏览器呢?嗯,它可能是一个进程有许多不同的线程,也可能是许多不同的进程有几个线程通过 IPC 进行通信。

image.png

这里需要注意的重要一点是,这些不同的架构是实现细节。没有关于如何构建 Web 浏览器的标准规范。一种浏览器的方法可能与另一种完全不同。

在这里我们将使用下图中描述的 Chrome 最新架构。

顶部是浏览器进程与其他处理应用程序不同部分的进程协调。对于渲染器进程,会创建多个进程并将其分配给每个选项卡。直到最近,Chrome 还在可能的情况下为每个选项卡提供了一个进程;现在它尝试为每个站点提供自己的进程,包括 iframe(请参阅站点隔离)。

image.png

哪个进程控制什么?

下表描述了每个 Chrome 进程及其控制的内容:

流程及其控制的内容
浏览器控制应用程序的“chrome”部分,包括地址栏、书签、后退和前进按钮。 还处理 Web 浏览器的不可见的特权部分,例如网络请求和文件访问。
渲染器控制显示网站的选项卡内的任何内容。
插件控制网站使用的任何插件,例如 flash。
图形处理器独立于其他进程处理 GPU 任务。它被分成不同的进程,因为 GPU 处理来自多个应用程序的请求并将它们绘制在同一个表面上。

还有更多的进程,如扩展进程和实用程序进程。如果您想查看 Chrome 中正在运行的进程数,请单击选项菜单图标 more_vert在右上角,选择更多工具,然后选择任务管理器。这将打开一个窗口,其中包含当前正在运行的进程列表以及它们使用的 CPU/内存量。

Chrome 中多进程架构的好处

Chrome 使用多个渲染器进程。在最简单的情况下,您可以想象每个选项卡都有自己的渲染器进程。假设您打开了 3 个选项卡,每个选项卡都由一个独立的渲染器进程运行。如果一个选项卡变得无响应,那么您可以关闭无响应的选项卡并继续前进,同时保持其他选项卡的活动。如果所有选项卡都在一个进程上运行,当一个选项卡变得无响应时,所有选项卡都无响应。那只能重启浏览器了。

将浏览器的工作分成多个进程的另一个好处是安全性和沙盒。

因为进程有自己的私有内存空间,所以它们通常包含公共基础设施的副本(比如 V8,它是 Chrome 的 JavaScript 引擎)。这意味着更多的内存使用量,因为如果它们是同一进程内的线程,它们就不能像它们那样共享。为了节省内存,Chrome 限制了它可以启动的进程数。该限制取决于您设备的内存和 CPU 能力,但当 Chrome 达到限制时,它会开始在一个进程中运行来自同一站点的多个选项卡。

浏览器访问

键入一个 URL 到地址栏中会发生什么

让我们看一个简单的 Web 浏览用例:您在浏览器中键入一个 URL,然后浏览器从 Internet 获取数据并显示一个页面。在这里我们将重点介绍用户请求网站和浏览器准备呈现页面的部分 - 也称为导航。

当您在地址栏中键入 URL 时,您的输入由浏览器进程的 UI 线程处理。

1. 处理输入

当用户开始在地址栏中键入内容时,UI 线程首先询问的是“这是搜索查询还是 URL?”。在 Chrome 中,地址栏也是一个搜索输入字段,因此 UI 线程需要解析并决定是将您发送到搜索引擎,还是发送到您请求的站点。

image.png 因为 Chrome 浏览器的地址栏既可以当做地址栏也可以当做搜索栏

2. 开始访问

当用户按下回车键时,UI 线程会发起网络调用以获取站点内容。浏览器页签的标题上会出现加载中的图标,网络线程通过适当的协议,如 DNS 查找域名并请求服务器建立 TLS 连接。

当服务器返回给浏览器重定向请求时,网络线程会通知 UI 线程需要重定向,然后会以新的地址做开始请求资源。

image.png

当服务器返回给浏览器重定向请求时,网络线程会通知 UI 线程需要重定向,然后会以新的地址做开始请求资源。

3. 处理响应数据

当网络线程收到来自服务器的数据时,会试图从数据中的前面的一些字节中得到数据的类型(Content-Type),以试图了解数据的格式。

当返回的数据类型是 HTML 时,会将数据传递给渲染进程做进一步的渲染工作。但是如果数据类型是 zip 文件或者其他文件格式时,会将数据传递给下载管理器做进一步的文件预览或者下载工作。

在开始渲染之前,网络线程要先检查数据的安全性,这里也是浏览器保证安全的地方。如果返回的数据来自一些恶意的站点,网络线程会显示警告的页面。同时,Cross Origin Read Blocking(CORB)策略也会确保跨域的敏感数据不会被传递给渲染进程。

4. 渲染过程

当所有的检查结束后,网络线程确信浏览器可以访问站点时,网络线程通知 UI 线程数据已经准备好了。UI 线程会根据当前的站点找到一个渲染进程完成接下来的渲染工作。

在第二步,UI 线程将请求地址传递给网络线程时,UI 线程就已经知道了要访问的站点。此时 UI 线程就可以开始查找或启动一个渲染进程,这个动作与让网络线程下载数据是同时的。如果网络线程按照预期获取到数据,则渲染进程就已经可以开始渲染了,这个动作减少了从网络线程开始请求数据到渲染进程可以开始渲染页面的时间。当然,如果出现重定向的请求时,提前初始化的渲染进程可能就不会被使用了,但相比正常访问站点的场景,重定向往往是少数,在实际工作中,也需要根据特定的场景给出特定的方案,不必追求完美的方案。

5. 提交访问

经历前面的步骤,数据和渲染进程都已经准备好了。浏览器进程会通过 IPC 向渲染进程提交这次访问,同时也会保证渲染进程可以通过网络线程继续获取数据。一旦浏览器进程收到来自渲染进程的确认完毕的消息,就意味着访问的过程结束了,文档渲染的过程就开始了。

这时,地址栏显示出表明安全的图标,同时显示出站点的信息。访问历史中也会加入当前的站点信息。为了能恢复访问历史信息,当页签或窗口被关闭时,访问历史的信息会被存储在硬盘中。

额外步骤. 加载完毕

当访问被提交给渲染进程,渲染进程会继续加载页面资源并且渲染页面。当渲染进程"结束"渲染工作,会给浏览器进程发送消息,这个消息会在页面中所有子页面(frame)结束加载后发出,也就是 onLoad 事件触发后发送。当收到"结束"消息后,UI 线程会隐藏页签标题上的加载状态图标,表明页面加载完毕。

但这里"结束"并不意味着所有的加载工作都结束了,因为可能还有 JavaScript 在加载额外的资源或者渲染新的视图。

访问不同的站点

一次普通的访问到此就结束了。当我们输入另外一个地址时,浏览器进程会重复上面的过程。但是在开始新的访问前,会确认当前的站点是否关心beforeunload事件。

beforeunload事件可以提醒用户是否要访问新的站点或者关闭页签,如果用户拒绝则新的访问或关闭会被阻止。

由于所有的包括渲染、运行 Javascript 的工作都发生在渲染进程中,浏览器进程需要在新的访问开始前与渲染进程确认当前的站点是否关心unload

如果一次访问是从一个渲染进程中发起的,例如用户点击一个链接或者运行 JavaScript 代码location = 'http://newsite.com'时,渲染进程首先检查beforeunload。然后再执行和浏览器进程初始化访问同样的步骤,只不过区别在于这样的访问请求是由渲染进程向浏览器进程发起的。

当新的站点请求被创建时,一个独立的渲染进程将被用于处理这个请求。为了支持像unload的事件触发,老的渲染进程需要保持住当前的状态。更详细的生命周期介绍可以参考Page lifecycle

Service worker

Service worker 是一种可以 web 开发者控制缓存的技术。如果 Service worker 被实现成从本地存储获取数据时,那么原本的请求就不会被浏览器发送给服务器了。

值得注意的是,Service worker 中的代码是运行在渲染进程中的。当访问开始时,网络线程会根据域名检查是否有 Service worker 会处理当前地址的请求,如果有,则 UI 线程会找到对应的渲染进程去执行 Service worker 的代码,而 Service worker 可以让开发者决定这个请求是从本地存储还是从网络中获取数据。

访问预加载

如果 Service worker 最终决定要从网络中获取数据时,我们会发现这种跨进程的通信会造成一些延迟。Navigation Preload是一种可以在 Service worker 启动的同时加载资源的优化机制。借助特殊的请求头,服务器可以决定返回什么样的内容给浏览器。

渲染 && 解析

渲染进程负责页面的内容

渲染进程负责所有发生在浏览器页签中的事情。在一个渲染进程中,主线程负责解析,编译或运行代码等工作,当我们使用 Worker 时,Worker 线程会负责运行一部分代码。合成线程和光栅线程是也是运行在渲染进程中的,负责更高效和顺畅的渲染页面。

渲染进程最重要的工作就是将 HTML、CSS 和 Javascript 代码转换成一个可以与用户产生交互的页面。

renderer.png

解析过程

下面的章节主要介绍渲染进程如何将从网络线程中获取的文本转化成图像的过程。

DOM 的创建

当渲染进程接收到来自浏览器进程提交访问的消息后就开始接受 HTML 数据,主线程开始解析 HTML 文本字符串,并且将其转化成 Document Object Model(DOM)

DOM 是一种浏览器内部用于表达页面结构的数据,同时也为 Web 开发者提供了操作页面元素的接口,让 web 开发者可以在 Javascript 代码中获取和操作页面中的元素。

将 HTML 文本转化成 DOM 的标准被HTML Standard定义。我们会发现在转化过程中浏览器从来不会抛出异常,类似关闭标签的丢失,开始、关闭标签匹配错误等等。这是因为 HTML 标准中定义了要静默的处理这些错误,如果对此感兴趣可以阅读An introduction to error handling and strange cases in the parser

额外资源的加载

一个网站通常还会使用类似图片,样式文件和 JavaScript 代码等额外的资源。这些资源也需要从网络或缓存中获取。主线程在转化 HTML 的过程中理应挨个加载它们,但是为了提高效率,预加载扫描(Preload Scanner)与转换过程会同时运行着。当预加载扫描在分析器分析 HTML 过程中发现了类似 img 或 link 这样的标签时,就会发送请求给浏览器进程的网络线程,而主线程会根据这些额外资源是否会阻塞转化过程而决定是否等待资源加载完毕。

JavaScript 会阻塞转化过程

当 HTML 分析器发现<script>标签时,会暂停接下来的 HTML 转化工作,然后加载、解析并且运行 Javascript 代码。因为在 Javascript 代码中可能会使用类似document.write这样的 API 去改变 DOM 的结构。这就是为什么 HTML 分析器必须等待 Javascript 代码运行结束才能继续分析的原因。

告诉浏览器要如何加载资源

如果我们的 Javascript 代码并不需要改变 DOM,可以为<script>标签添加asyncdefer属性,这样浏览器就会异步的加载这些资源并且不会阻塞 HTML 转化过程。如果 script 标签是由 JavaScript 代码创建的,标签的 async 属性会默认为 true。 同时我们也可以使用一些预加载技术,比如<link ref="preload">来通知浏览器这些资源需要越快下载越好。

样式计算(Style calculation)

对于展示一个页面,光有 DOM 是不够的,因为我们还需要样式来让页面变得更美观。主线程会解析样式(CSS)并决定每个 DOM 元素的样式。这些样式取决于 CSS 选择器的范围,在浏览器开发者工具中我们可以看到这些信息。

computedstyle.png

即使我们没有给 DOM 指定任何的样式,<h1>标签也会比<h2>标签显示的大。这是因为浏览器为不同的标签内置了不同的样式。可以通过Chromium源代码得到这些默认样式。

布局(layout)

完成了样式计算工作后,渲染进程已经知道了 DOM 的结构和每个节点的样式,但是依然不足以渲染一个页面。

布局是为元素指定几何信息的过程。主线程遍历 DOM 结构中的元素及其样式,同时创建出带有坐标和元素尺寸信息的布局树(Layout tree)。布局树的结构与 DOM 树的结构十分相似,但只包含将会在页面中显示的元素。当一个元素的样式被设置成 display: none 时,元素就不会出现在布局树中,但那些样式被设置成 visiblility:hidden 的元素会出现在布局树中。 相似的,当我们使用一个包含内容的伪元素(例如p::before { content: 'Hi!' })时,元素会出现在布局树中即使这个元素不存在于 DOM 树中,这也是为什么我们使用 DOM 提供的 API 无法获取伪元素的原因。

layout.png

描述页面布局信息是一项具有挑战性的工作,即使在只有块元素的页面中也必须要考虑字体的大小和在哪里换行,因为在计算下一个元素的位置时需要知道上一个元素的尺寸和形状。

CSS 可以让元素浮动、可以让元素在父元素中溢出,可以改变文字的方向。可以想象,在布局这个阶段是多么繁重的工作。在 Chrome 中,有一整个团队在维护布局工作,更详细的信息可以观看视频

绘制(Paint)

有了 DOM、样式和布局还是无法完成渲染工作。试想,当我们试图复制一张图画。我们知道图画中元素的尺寸、形状和位置,我们还需要知道绘制这些元素的顺序。

在这个阶段,主线程遍历布局树并创建绘制记录,绘制记录是一系列由绘制步骤组成的流程,例如先绘制背景,然后是文字,然后是形状。

paint.png

渲染过程是昂贵的

在渲染过程中,任何一个步骤中产生的数据变化都会引起后续一系列的的变化。例如,当布局树改变时,绘制需要重构页面中变化的部分。

当一些元素有动画发生时,浏览器需要在每一帧中绘制这些元素。当无法保证每一帧绘制的连续性时,用户就会感觉到卡顿。

pagejank1.png

正常情况下渲染操作可以与屏幕刷新保持同步,但由于这些操作运行在主线程中,也就意味这些操作可能被正在运行的 Javascript 代码所阻塞。

pagejank2.png

为了不影响渲染操作,我们可以将 Javascript 操作优化成小块,然后使用requestAnimationFrame(),关于如何优化可以参考Optimize JavaScript Exectuion。当需要大量计算时,也可以使用 Worker 来避免阻塞主进程。

raf.png

合成(Compositing)

现在,浏览器已经知道了文档结构、每一个元素的样式,元素的几何信息,绘制的顺序。将这些信息转化成屏幕上像素的过程叫做光栅化,光栅化是图形学的范畴。

rasterizing.gif

传统的做法是将可视区域的内容进行光栅化。随着用户滚动页面,不断的光栅化更多的区域。然而对于现代浏览器,有着更复杂的的过程,这个过程被称做合成。

composit.gif

合成是一种将页面拆分成多层的技术,合成线程可以将各个层在不同线程中光栅化,再组合成一个页面。当滚动时,如果层已经被光栅化,则会使用已经存在的层合成新的帧,动画则可以通过移动层来实现。

层(Layer)

为了决定层包含哪些元素,主线程需要遍历布局树以找到需要生成的部分。对开发者来说,当某一部分需要用独立的层渲染,我们可以使用 css 属性will-change让浏览器创建层,关于浏览器如何生成层的标准可自行查阅。

layer.png

虽然通过分层可以优化浏览器性能,但并不意味着应该给每个元素一个层,过多的层反而影响性能,所以在层的划分上应该具体形况具体分析。

栅格线程与合成线程

当布局树和绘制顺序确定以后,主线程会将这些信息提交给合成线程。合成线程会光栅化各个层。一个层包含的内容可能是一个完整的页面,也可能是页面的部分,所以合成线程将层拆分成许多块,并将它们发送给栅格线程。栅格线程光栅化这些块并将它们存储在 GPU 缓存中。

raster.png

合成线程可以决定栅格线程光栅块的优先级,这样可以保证用户能看到的部分可以先被光栅化。一个层也会包含多种块以支持类似缩放这样的功能。

当块被光栅化后,合成线程会使用 draw quads 收集这些信息并创建合成帧(Compositor frame)。

Draw quads

存储在缓存中,包含类似块位置这样的信息,用于描述如何使用块合成页面。

Compositor frame

用于存储表现页面一帧中包含哪些 Draw quads 的集合。

然后一个合成帧被提交给浏览器进程。这时如果浏览器 UI 有变化,或者插件的 UI 有变化时,另一个合成帧就会被创建出来。所以每当有交互发生时,合成线程就会创建更多的合成帧然后通过 GPU 将新的部分渲染出来。

composit.png

合成的好处在于其独立于主线程。合成线程不需要等待样式计算和 Javascript 代码的运行。这也是为什么合成更适合优化交互性能,但如果布局或者绘制需要重新计算则主线程是必须要参与的。

本质上,浏览器的渲染过程就是将文本转换成图像的过程,而当用户与页面发生交互动作时,则显示新的图像。在这个过程中由渲染进程中的主线程完成计算工作,由合成线程和栅格线程完成图像的绘制工作。而在计算过程中,还有强制布局、重排、重绘等更加细节的概念会在后面的文章中做讲解。

从浏览器的角度看事件

当我们听到事件时,通常会联想到在一个文本框中输入或者单击鼠标,但从浏览器的角度看,输入事件意味着所有的用户动作。鼠标滚轮滚动或者屏幕触摸都是输入事件。

当用户与页面发生交互时,浏览器进程首先接收到事件,然而,浏览器进程只关心事件发生时是在哪个页签中,所以浏览器进程会将事件类型和位置信息等发送给负责当前页签的渲染进程,渲染进程会恰当的找到事件发生的元素并且触发事件监听器。

input (1).png

合成线程对事件的处理

在前面的章节中,我们知道了合成线程可以通过合成技术合成不同的光栅层优化性能,如果页面并不监听任何事件,合成线程可以完全独立于主线程生成新的合成帧。但如果页面监听了事件呢?

标记“慢滚动”区域

由于运行 Javascript 是主线程的工作,当页面被合成线程合成过,合成线程会标记那些有事件监听的区域。有了这些信息,当事件发生在响应的区域时,合成线程就会将事件发送给主线程处理。如果在非事件监听区域,则渲染进程直接创建新的帧而不关心主线程。

nfsr1.png

在事件监听时标记

在 web 开发中常见的方式就是事件代理。利用事件冒泡,我们可以在目标元素的上层元素中监听事件。参照下面的代码。

document.body.addEventListener('touchstart', event => {  if (event.target === area) {    event.preventDefault();  }});

通过这种写法,可以更高效的监听事件。但如果从浏览器的角度看,此时整个页面会被标记成“慢滚动”区域。这意味着虽然页面中的某些部分并不需要事件监听,但合成线程依然要在每次交互发生后等待主线程处理事件,合成线程的优化效果不复存在。

nfsr2.png

为了解决这个问题,我们可在事件代理时传入passive: true (IE 不支持) 参数。这样告诉渲染线程,依然需要将事件发送给主线程处理,但不需要等待。

document.body.addEventListener('touchstart', event => {    if (event.target === area) {        event.preventDefault()    } }, {passive: true});

关于使用 passive 改善滚屏性能,可以参考MDN 使用passive改善滚屏性能

查找事件目标

hittest.png

当渲染线程将事件发送给主线程后,第一件事就是找到事件触发的目标。通过在渲染过程中生成的绘制信息,可以根据坐标找到目标元素。

减少发送给主线程的事件数量

为了保证动画的顺畅,需要显示器在每秒刷新 60 次。对于典型的触摸事件由合成线程提交给主线程的事件频率可以达到每秒 60-120 次,对于典型的鼠标事件每秒会发送 100 次。事件发送的频率通常比屏幕刷新频率要高。

如果类似touchmove这样的事件每秒向主线程发送 120 次可能会造成主线程执行时间过长而影响性能。

rawevents.png

为了减少发送给主线程的事件数量,Chrome 合并了连续的事件。类似wheelmousewheelmousemovepointermovetouchmove这样的事件会被延迟到下一次requestAnimationFrame前触发.

coalescedevents.png

而任何的离散事件,类似keydown, keyup, mouseup, mousedown, touchstarttouchend都会立即被发送给主线程处理。

总结

到此,我们已经可以通过从用户在浏览器地址栏中的一次输入到页面图像的显示了解浏览器是如何工作的。这里我们总结一下。

  • 浏览器进程做为最重要的进程负责大多数页签外部的工作,包括地址栏显示、网络请求、页签状态管理等。
  • 不同的渲染进程负责不同的站点渲染工作,渲染进程间彼此独立。
  • 渲染进程在渲染页面的过程中会通过浏览器进程获取站点资源,只有安全的资源才会被渲染进程接收到。
  • 渲染进程中主线程负责除了图像生成外绝大多数工作,如何减少主线程上代码的运行是交互性能优化的关键。
  • 渲染进程中的合成线程和栅格线程负责图像生成,利用分层技术可以优化图像生成的效率。
  • 当用户与页面发生交互时,事件的传播途径从浏览器进程到渲染进程的合成线程再根据事件监听的区域决定是否要传递给渲染进程的主线程处理。

最后,感谢你的阅读。

文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞👍和关注😊,希望点赞多多多多...