深入理解现代浏览器

390 阅读23分钟

此篇是对 google developer 中 Inside look at modern web browser 系列的理解和整理笔记。

浏览器架构

浏览器的架构可以有两种实现方式:

  1. 使用一个进程,并在进程里开启多个线程来处理不同的任务。
  2. 使用多个进程,在每个进程里开启少量线程来处理不同任务,进程间通过 IPC 进行通信。

但浏览器的实现并无标准可遵循,浏览器产商们可以选择他们喜欢的方式来实现,所以浏览器和浏览器之间可能会完全不一样。

浏览器架构详解

我们以新版 Chrome 为例,它主要由以下几个进程组成:浏览器进程、渲染器进程、插件进程、GPU 进程

浏览器进程(browser process)

Chrome 的最上层就是浏览器进程,它负责协调其他进程(下面会提到,这些进程分别承担 Chrome 应用的各项工作任务)。

浏览器进程还负责浏览器的地址栏、书签、前进/后退按钮的管理,还有一些高权限的任务(如网络请求和文件访问)。

渲染器进程(renderer process)

渲染器进程负责在一个标签页中显示网站和处理一切相关事务。每打开一个新标签页,Chrome 就会新建一个渲染器进程来负责管理这个标签页。

不仅如此,现在 Chrome 还会尽量给每个站点分配不同的渲染器进程,也就是说,如果在一个网页中内嵌了两个 iframe,Chrome 不仅会给最外层的网站分配一个渲染器进程,还会分别给两个 iframe 各分配一个独立的渲染器进程。

插件进程(plugin process)

插件进程负责管理在网站中运行的插件,比如 flash。

GPU 进程(GPU process)

GPU 进程负责 GPU 任务,之所以分出一个独立的进程来负责 GPU,是因为 GPU 要处理来自多个网页应用的请求并在同一个界面上绘制图形。

Chrome 多进程架构的优点

优点一,可以想象以下两个场景:

  • 场景 A: 浏览器是单进程的,打开 3 个标签页,浏览器在同一个进程中管理这些标签页,如果其中一个标签页奔溃了,另外两个标签也会无法响应。

  • 场景 B: 浏览器是多进程的,打开 3 个标签页,浏览器创建了 3 个渲染器进程,如果其中一个标签页奔溃了无法响应,剩余的两个标签不会受到任何影响。

优点二:安全和隔离。因为操作系统提供了限制进程权限的方式,如果浏览器是多进程的,那它就可以根据进程的不同来隔离一些权限操作,比如禁止渲染器进程随意访问文件。

由于进程拥有独立的内存空间,导致一些本可以共用的基础设施(如 V8 引擎)会在每个进程的内存空间里都有一个副本,这就比单进程的浏览器占了更多的内存。为了节省内存,Chrome 会限制打开进程的数量上限(具体数字由设备的内存和 CPU 决定),当进程达到上限时,Chrome 会把同站点的不同标签页放到一个进程中去管理。

Chrome 的服务化

Chrome 的浏览器进程也用到了上面说的那种方法。Chrome 把浏览器程序中的不同部分看作一个个不同的服务,每个服务可以在不同的进程中运行,也可以在同一个进程中运行。

在硬件条件允许的情况下,Chrome 会把各个服务放在不同的进程中运行,以保证程序的稳定性。如果设备硬件条件有限,Chrome 就把各个服务放在一个进程中来节省内存消耗。

站点隔离

站点隔离是 Chrome 新引进的特性,在此之前,虽然 Chrome 给每一个标签页新开一个渲染器进程,但如果一个网站中内嵌了一个跨站的 iframe,那这两个站点是在运行在同一个渲染器进程中,分享同一块内存的。

虽然有同源策略来保证一个站点不能未经同意就从另一个站点获取数据,不过有人发现在现代 CPU 中,进程有可能读取任意内存,所以最有效的隔离站点的方式还是进程隔离。自桌面版 Chrome 67 起,Chrome 都默认采用站点隔离,也就是在同一个标签页中的不同站点都会有各自独立的渲染器进程。

实现站点隔离可不是易事,不仅需要彻底改变 iframe 之间的通信方式,还要实现 devtool 的无缝切换,还有 Ctrl+F 网页搜索功能也要实现成在多个进程中搜索,所以这称得上是一个里程碑式的特性了。

小结

本节以 Chrome 浏览器为例,详解了浏览器的架构,深入了解了 Chrome 的多进程架构及其优点,还有 Chrome 在多进程架构上实现的服务化和站点隔离。

导航

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

  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 出现的原因。

渲染器进程做了什么

渲染器进程负责一个标签页里的所有工作,它的主要工作就是把 HTML, CSS, JS 结合起来变成一个可交互的网页。在渲染器进程中,主要是以下几个线程在工作着:

  • 主线程(main thread):负责运行客户端的大部分代码。
  • 工作线程(worker thread):如果网页应用中有 web worker 或者 service worker 的话,这些 JS 代码会在工作线程中运行。
  • 栅格化线程(raster thread):负责栅格化图层,也就是把网页图层转换成屏幕上的像素。
  • 合成器线程(compositor thread):负责把已经完成栅格化的图层合成页面。

页面渲染过程包含了以下几个步骤。

第一步:解析 HTML

  • 构建 DOM:当渲染器进程从浏览器进程那里收到提交导航的信号并开始接收 HTML 数据流时,渲染器进程中的主线程就开始了解析 HTML 字符串并把它转化成一个 DOM 树的工作。

  • 加载子资源:在解析 HTML 文档时,解析器会不时遇到 <img> <link> <script> 等标签,这些外部资源需要通过网络下载或者可以从缓存中获取。当然,主线程可以在解析到这些标签的时候停下来去发送请求,不过这样就太浪费时间了。为了提高效率,一个“预加载扫描器”(preload scanner)会同时运行,当主线程解析到以上标签时,预加载扫描器就负责给浏览器进程中的网络线程发送请求。

  • JS 会阻塞 HTML 解析:当 HTML 解析器碰到 <script> 标签时,它会停下来,等到 JS 代码加载、解析、运行结束后,再继续解析 HTML。这是因为在 JS 代码中开发者可以使用 document.write() 之类的来修改 DOM 树的结构,所以在 JS 代码执行完成之前,继续往下解析 HTML 是没有意义的。

  • 修改资源加载的时机:如果你的 JS 代码里面没有修改 DOM 的操作,那我们可以通过给 <script> 标签加上 async 或者 defer 属性,告诉浏览器这些资源可以异步加载,这样解析器就可以不被打断继续解析 HTML。如果必要的话,还可以使用 JS 模块(JS 模块默认是异步加载的)。

第二步:计算样式

生成 DOM 树之后,主线程会去解析 CSS,并根据选择器、优先级等来计算出每一个 DOM 节点的最终样式(computed style)。

就算你的代码里没有任何 CSS,这一步也是不可省的,因为浏览器本身会提供一份默认的 CSS 样式表。

第三步:布局

接下来主线程会遍历整个 DOM 树和计算样式,生成一个布局树(layout tree),布局树上会记录每个节点在页面上的横/纵坐标和盒子大小这些信息。

布局树和 DOM 树在结构上很相似,不过布局树只关心在页面上显示的内容:

  • display: none; 的元素不会在布局树上(不过 visibility: hidden; 的元素却在布局树上)。
  • 伪元素如 ::before 虽然不在 DOM 树上,却在布局树上。

第四步:绘制

在这一步主线程会遍历布局树并生成一个绘制记录(paint record),根据 z-index 属性等来决定并记录元素绘制的先后顺序,如果两个元素有重合的部分,先绘制的元素就会被后绘制的元素覆盖。

第五步:划分图层

现在我们知道了整个 HTML 文档的结构,每个元素的样式、横/纵坐标、盒子大小,以及绘制的顺序,接下来就需要把这些信息转化成屏幕上的像素了,这个转化的过程叫栅格化(Rastering)。

栅格化的实现方式有两种:

  1. Chrome 以前用到的老办法,先把视口(viewport)中的一部分网页栅格化显示出来,当页面发生滚动时,把已经栅格化的部分向上/下移动,然后对空白的部分进行栅格化填充页面。

  2. 更成熟的解决方法是合成(compositoring),也是 Chrome 现在采用的方式。先把页面的各个部分分成不同的图层(layer),然后分别栅格化这些图层,接着在一个独立的线程中(合成器线程)把这些栅格化的图层组合成页面。当页面发生滚动时,因为各个图层都是已经栅格化了的,要做的工作就只是用图层重新合成一个新的页面视图而已。

  • 在这一步,主线程会遍历整个布局树,决定哪些元素放到哪个图层,然后合成图层树(layer tree)。

  • 开发者还可以通过 CSS 的 will-change 属性来告诉浏览器哪些元素应该放在独立的图层中。

  • 不要想着把所有元素都放在单独的图层中,因为合成过多数目的图层可以会比老的栅格化方法还要慢呢。

P.S. 可以在 DevTools 的 Layers panel 看到当前网页的所有图层,如果 Layers panel 没有打开,可以通过 Ctrl+P 搜索打开

最后一步:合成

  • 当图层树创建完成,而且元素的绘制顺序也已确定了,主线程就会向合成器线程传送这些信息。

  • 接着合成器线程就会把图层分发到不同的栅格化线程中去,如果图层太大,合成器线程还会先把图层分成小片(tile),然后分给不同的栅格化线程去处理。

  • 合成器线程还可以安排不同栅格化线程的优先级,所以视口(viewport)及其附近的图层会先进行栅格化处理。

  • 栅格化线程把接收的图层或者图层小片栅格化并存储到 GPU 的内存中,这些图层小片保存着它们在内存中的位置以及它们在页面上应该在什么位置。

  • 完成图层栅格化后,合成器线程就会根据图层小片的信息,也叫绘制方块(draw quads),来创建一个合成器帧(compositor frame),合成器帧只是多个绘制方块拼成的页面中的一帧。

  • 创建好的合成器帧会通过 IPC 传送到浏览器进程,接着再传送到 GPU 进程,由 GPU 进程把它画到屏幕上。

  • 如果发生滚动事件,合成器线程就会再创建新的合成器帧并发送给 GPU 进程。

渲染是一个耗时的过程

从 HTML 解析到绘制的这个过程中,我们注意到了每一步的操作都需要依赖上一步的结果,所以如果布局树发生了变化,那绘制顺序也需要重新计算了。

如果页面上有动画的话,那浏览器就需要在每一帧都完成一次上述过程,否则动画看起来就不太流畅,而现在的显示屏一般是每秒刷新 60 次(60fps),也就是浏览器每秒要重复 60 次这个过程。

不过,即使浏览器的渲染速度能跟上屏幕的刷新速度,动画的流畅度也不能保证。因为这个渲染过程的所有计算都是在主线程中完成的,而主线程同时也负责 JS 代码的执行,JS 代码可能会阻塞这些渲染计算,导致动画卡帧。

想要避免 JS 阻塞渲染这个问题的话,我们可以使用 requestAnimationFrame() 来把 JS 代码分成小块并分到每个帧中去运行,或者把 JS 从主线程中抽出来,放到 web worker 中去运行。

当然,还有更好的实现动画的方式,注意在完整的渲染过程中,合成操作是在独立的合成器线程中完成的,所以,如果动画只改变了图层相关的 CSS 属性,也就是 transform: translate(x, y);, transform: scale(n);, transform: rotate(ndeg), opacity: 1; 之类的,页面就可以在不需要主线程参与的情况下更新。

但是,如果动画涉及了绘制相关的属性,如 color, visibility 等,页面更新需要从绘制开始往下走流程,更麻烦的是,如果动画涉及了布局相关的属性,如 width, float, display 等,更新就得从重新生成布局树开始了。

小结

这一节拆解渲染流水线(rendering pipeline),详细介绍了渲染过程从解析到合成的各个步骤。

交互

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

在浏览器看来,用户输入包括:

  • 在输入框中输入文字
  • 点击鼠标
  • 滚动鼠标滚轮
  • 移动鼠标,或者手指在屏幕上滑动
  • ...

当这些事件发生时,浏览器进程是最先收到消息的,但是,浏览器进程只知道发生的事件类型以及事件发生的坐标,因为网页内容是渲染器进程在管,所以浏览器进程会把事件类型(event type)和事件发生的坐标传送给渲染器进程,渲染器进程再找到事件目标(event target)并触发事件处理函数(event handler)。

合成器线程接收事件

如果用户触发了页面滑动事件,浏览器进程会把事件信息发送到渲染器进程中的合成器线程,合成器线程的处理分两种情况:

  • 如果页面上没有绑定事件处理函数,那么合成器线程只需要把已经栅格化的图层合成一个新的合成器帧就可以了,这样整个滑动过程会非常丝滑。

  • 如果页面绑定了事件处理函数,合成器线程会通知主线程来执行相应的事件处理函数。

非快速滑动区域(non-fast scrollable region)

当合成器线程在合成页面时,它会把页面中有绑定事件处理函数的区域标记为“非快速滑动区域”,如果用户输入事件发生在这些区域,合成器线程会通知主线程去处理这些事件,否则合成器线程就可以不用和主线程通信,直接合成新的合成器帧即可。

那么问题来了,我们在绑定事件处理函数的时候,常常会使用事件代理模式,试想一下我们把整个页面的事件处理都交给 <body> 元素来代理监听,那整个页面都会被合成器线程标记为“非快速滑动区域”,那合成器线程的丝滑滑动优点就发挥不了作用了,现在每次页面滑动时合成器线程都得先和主线程通信并等待主线程的回应。

为了合成器丝滑滑动和事件代理这两个优点能共存,我们可以在监听事件的时候传递第三个参数 { passive: true },意思是,主线程还是照样监听事件,但合成器线程就不用等待主线程的回复了,可以在通知主线程之后直接开始合成新的合成器帧。

定位事件对象

当主线程从合成器线程那里接收到事件信息之后,它要做的第一件事情就是命中测试(hit test),找到事件对象,命中测试会使用在渲染过程中生成的绘制记录(paint record)来找到在事件发生坐标的具体是哪个元素。

降低向主线程发送事件的频率

一般触屏设备每秒会触发 60-120 次 touch 事件,一般鼠标每秒会触发 100 鼠标事件,而显示器每秒只会刷新 60 次。假如每秒连续向主线程发送 120 次 touchmove,就会触发 120 次命中测试,由于屏幕刷新次数远小于 120 次,有些命中测试就有点多余了。

所以 Chrome 会把连续触发的事件(如 wheel, mousewheel, mousemove, pointermove, touchmove)结合起来,等到下一个 requestAnimationFrame() 调用前再把事件信息发送给主线程。

不过非连续的事件,如 keydown, keyup, mouseup, mousedown, touchstart, touchend 都是立刻就通知主线程的。

getCoalescedEvents()

对于一般应用程序,Chrome 合并连续触发事件的这个操作并不会影响用户体验,不过如果有需要,可以通过 event.getCoalescedEvents() 来获取被合并的事件的信息。

小结

这一节介绍了浏览器是怎么处理用户输入的。

如果这篇笔记有帮助到你的话也欢迎来我的小仓库点个 star 让我开心一下啦~