现代浏览器背后发生了什么

2023-07-19 pv

我们几乎每天都在使用浏览器。

可能是从 PC 端,可能是在移动端。

你每天看的微信公众号,包括这篇文章,背后都离不开浏览器的默默工作。

之前有篇文章,浏览器是如何工作的🔗,主要描述了浏览器如何将网络请求的内容渲染到屏幕上。

这一次我们将尝试从更加宏观的视角,解释浏览器在稳定性、性能以及安全性等多个方面所做的诸多工作。

主要分为四个部分:

  1. 多进程架构
  2. 导航
  3. 渲染
  4. 处理输入

1. 多进程架构

从硬件视角观察,电脑也好,手机也罢,核心的器件有两个,CPU(Central Processing Unit)和 GPU(Graphics Processing Unit)。

其中大部分的计算都在 CPU,但针对某些特殊场景,例如图形绘制,密集计算等,可以交给 GPU 来实现加速。

基于软件视角,程序运行也有两个概念:进程(process)和线程(thread)。当你打开一个程序的时候,进程会被创建出来,进程里会有一个主线程,同时还可以创建更多其他的工作线程,尽管这不是必须的。

进程可以理解为资源的容器,主要可分为计算资源存储资源

其中线程可理解为计算资源的基本单元。同时对于一个进程内的多个线程,存储资源共享。

但对于多个进程而言,不同资源间存在隔离,如果需要共享,就需要 IPC(Inter Process Communication)的机制。

这也引出了两种浏览器架构:多线程架构和多进程架构

这两种架构方式各有优劣,但多数现代浏览器选择了后一种架构方式。

我们这里以经典的 Chromium 多进程架构为例,尝试理解这种架构风格背后的权衡。

对于 Chromium 而言,Brower 进程是主进程,主进程会派生出其他不同类型的子进程来分担工作:

  • GPU 进程,负责加速计算
  • Utility 进程
  • Plugin 进程,插件运行在独立的进程里
  • Render 进程,负责解析和渲染网页

不同类型的进程各司其职,进程间通过 IPC 进行通信。

多进程架构有缺点,一是资源消耗较大(Chrome 就是个吃内存大户),另外就是进程间通信成本较高。

但其好处也很明显。

进程与进程之间独立,其中某一进程崩溃不会影响其他进程,这就提升了整个程序的健壮性(robust)。

例如,不同的 Tab 页是由不同的 Render 进程负责,如果某一个 Tab 页停止响应,其他的 Tab 页依然可以正常工作。

但是为了避免创建过多的 Render 进程,Chromium 提供了 Site Isolation🔗 Site Isolation 制,同一个站点打开的 Tab,可以由同一个 Render 进程托管。

另一个好处是多进程架构更加安全。因为除了 Browser 进程有较大权限外,类似 Render 的进程都是运行在一个受限的 Sandbox 环里,连文件访问的权限都没有。这样恶意脚本就无处施展了。

2. 导航

导航(Navigation)差不多是浏览器最核心的功能,涉及多个进程间的交互。从用户在地址栏键入 URL 开始,到在屏幕上呈现出网页的全部过程,需要各个进程共同参与。

当用户输入 URL 的时候,Browser 进程中的 UI 线程负责处理用户输入。

当导航开始时,网络线程会执行一些前期任务,比如查询 DNS,建立 TLS 连接等。

接收到返回数据后,网络线程去查看 Header,其中的Content-Type字段标记了数据的类型。如果是 HTML 文件,数据会被转发到 Render 进程,如果是zip或者其他文件,数据会被转发给下载管理器。

也会有一些安全检查,如果域(Domain)的返回数据和已知恶意站点匹配,网络线程也会发出警告

一旦通过检查,网络线程就会告诉 UI 线程数据已经准备好,UI 线程此时寻找一个合适的 Render 进程负责数据渲染。

由于网络请求的过程通常比较耗时,为了加速,UI 线程会尝试在请求网络的同时并行查找或启动 Render 进程。

当数据和 Render 进程都准备好,一个 IPC 会从 Browser 进程发给 Render 进程。一旦这次 Commit 被确认,整个导航的过程随即完成,并进入到加载阶段。

当 Render 进程接收到数据并渲染完成后,返回一个 IPC 给 Brower 进程,告诉 Browser 进程渲染完成。

以上只是最简单的一种导航

如果想要导航到另一个 URL,在新的导航开始前,会先检查是否有beforeunload事件,如果有则会被执行。

这种设计非常贴心,因为重新导航到另一个 URL,当前网页内容会被清理。类似form这类有状态的标签,数据丢失无疑会降低用户体验。而浏览器提供的这个事件,无疑给了开发者干预下一次导航的触点。

如果被允许,导航的过程与上述基本一致。差异只在于,旧的 Render 进程会执行unload事件,并清理对应网页。

传统的浏览器只能运行在联网状态。

而现代浏览器提供了一种叫做Service Worker的特性,允许将缓存的内容作为网络请求的内容返回。这就意味着,网站支持离线运行。

最经典的使用场景就是 PWA(Progressive Web Application)。

3. 渲染

关于渲染,之前的文章 浏览器是如何工作的🔗 浏览器是如何工作的经谈及。为保证体系的完整,这里会从另一个角度,尝试从顶层观察这个过程。

Render 进程是个非常重要的进程,网页里的发生的方方面面的事情都要参与:

  • 主线程会处理大部分和用户相关的逻辑
  • 当你使用 Web Worker 和 Service Worker 时,部分 JavaScript 被工作线程执行
  • 合成器线程负责高效的渲染

Render 进程将 HTML 的文本内容转换为显示的网页,总共用了四步:

  1. 解析(Parsing)
  2. 布局(Layout)
  3. 绘制(Paint)
  4. 合成(Composition)
3.1 解析

解析的第一步是构建 DOM(Document Object Model)树。

DOM 是浏览器内部,组织网页内容的数据结构。前端开发者借助 JavaScript API 可对其进行操作。

DOM 的解析方式有相应的 HTML 标准🔗,不再赘述,通常不需要深入了解。

浏览器在解析 HTML 时,有时会遇到一些外部资源,例如图片、CSS 和 JavaScript 脚本等。这些资源同样需要从网络中获取。

主线程可以顺序请求,但为了加速,preload scanner会并行处理。

即便如此,<script>标签还是会阻塞整个解析流程。因为脚本里的内容可能会修改文档。但现代浏览器提供了asyncdefer关键字,允许开发者提供一些提示以便更高效地加载资源。

仅仅有 DOM 树是不够的,因为它缺乏样式信息。CSS 此时参与进来,提供每个 DOM 节点样式信息,例如长、宽等。

3.2 布局

接下来,Render 进程进入到布局阶段:为每个元素确定几何形状。

此时主线程会遍历整个 DOM,同时计算属性,创建 Layout 树,不仅包含 x,y 等坐标信息,也包含边界框尺寸等。

3.3 绘制

有了这些信息还不够,因为元素之间存在重叠,例如使用z-index属性。

主线程会遍历 Layout 树,并创建绘制记录。这就像一条流水线,从头到尾跑一遍,就能得到绘制结果。

在流水线上,每一步都会使用前一步的结果,因此只要中间某些环节有更新,这种更新就会往后蔓延。

这么看来更新成本很高,对于动画这种场景尤其明显。如果使用 JavaScript 生成动画,在执行的时候,更新会被阻塞,网页卡就会顿,此时可借助requestAnimationFrame()避免此种情况,可见 Optimize JavaScript execution🔗

3.4 合成

将上述所有信息转换为屏幕上的像素,这个过程叫做栅格化。

合成(Composition)是栅格化的一种手段。将页面的各个部分分成图层,分别栅格化,并在合成器线程中合成为页面。

DevTools 可以帮助查看网页是如何分割为不同的层的,可见 Layers panel🔗

4. 处理输入

对于浏览器而言,最重要的工作就是及时响应用户的请求,返回需要的结果。这个过程要尽可能高效,否则用户半天得不到响应,体验感就会变差。

最后一个部分关于浏览器如何流畅地完成这些事情。

从浏览器视角出发,所有的用户行为都可以理解为键盘输入,鼠标滑动,触摸等。这些事件首先会被 Browser 进程捕获,然后传递给 Render 进程,接着 Render 进程寻找到响应事件的目标,执行注册好的事件监听器。

在 Web 开发中,最常见的事件处理模式是事件委托。例如:

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

通常来说,事件监听的目标应该足够小,因为事件冒泡机制的存在,较大的监听目标会导致频繁的事件触发,降低性能。但是通过传入passive: true可以在一定程度上提高性能,详见:Improving scrolling performance with passive listeners🔗.

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

有些事件是连续的,比如touchmove。但这会给浏览器带来较大的处理开销,因此为降低主线程的负担,Chromium 会合并连续事件。对于大多数 Web 应用而言,合并事件可以提供更好的用户体验。

但也有例外。如果你正在构建绘图程序之类的应用,合并事件则可能导致绘制精度丢失。

此时可使用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.
}
});

接下来,如果你对网页优化感兴趣,Lighthouse🔗 Lighthouse 个不错的工具,how to measure your site’s performance🔗 how to measure your site’s performance 是个各类优化工具的集合。

5. 总结

对于前端开发者,关注代码的组织和功能实现是最重要的。

但这并不意味着你不需要了解底层浏览器的运行原理。因为现代浏览器,一直在持续地将新的功能和优化机制引入,以提升用户体验。

你不了解它,就不可能更好地利用它。

(完)

参考

在 GitHub 上编辑本页面

最后更新于: 2024-06-14T07:17:44+08:00