前端不可不知的现代浏览器原理

2024-03-13 • ☕️☕️☕️☕️ 18 min read

前言

当我准备要开始写博客的时候,关于如何开始,我的脑海中萦绕着各种声音,像诸如你怎么写好一篇博客呢?怎么保证你的博客的输出频率?...这些想法让我迟迟没有迈出第一步。

但是,今天要写这一篇关于浏览器原理的文章的时候,我回想起当时脑海中的这些想法,只想对当时的自己说:“F**k them,You're on the road now”. 的确,很多事情我们当时也许并不知道答案,但是我们直接出发上路后,在旅途中学习成长,终将会找到答案。

这篇浏览器原理其实一开始也没打算写的,在写 《理解 JavaScript:事件循环详解》 寻找相关资料的时候,有些文章多少介绍了些浏览器运行机制的内容,顺藤摸瓜,就摸到浏览器去原理了。废话少说,开始正文。

进程和线程

在深入了解浏览器原理之前,我们先要了解下浏览器的运行环境--操作系统中的进程(process)和线程(thread)的概念。

进程是应用程序在操作系统执行过程中的抽象,对于操作系统来说,当我们启动应用程序时,就会创建一个进程,比如打开一个文本编辑器就启动一个编辑器进程,操作系统会为编辑器进程分配内存,编辑器所有的应用程序状态都保存在该私有内存空间中。而当我们关闭应用程序时,进程会消失,其分配的内存也会被操作系统回收。

大部分应用程序都会同时做很多事情,就拿我们前端程序员所熟悉的 VSCode 来说,我们在编写代码时,VSCode 还要实时的对输入的代码进行语法高亮并且定期保存,如果还安装了其它插件,这些插件可能也会在后台运行。

在这种情况下,VSCode 可以让操作系统再启动多个进程来运行不同的任务,进程间通过使用进程间通信 (IPC) 来进行通信。多进程的这种模式的稳定性很高,如果应用程序中的子进程崩溃了,可以重新启动它而不会影响其它进程。

应用程序还有一种方法就是启动一个进程,在一个进程内启动多个线程,让多个线程执行多个任务。线程是进程当中的一条执行流程。应用程序至少执行一个任务,所以一个进程至少有一个线程,同一个进程内多个线程之间可以共享进程内存中的数据。但是多线程模式的缺点就是任何一个线程挂掉都可能导致整个进程崩溃。

浏览器架构

那么浏览器使用的是多进程还是多线程架构呢?这是现在还没有答案的问题,因为至今还没有一份关于如何构建浏览器的标准规范,所以不同浏览器厂商实现浏览器的架构是不一样的。

根据 statcounter 2023 年的数据,Chrome 在浏览器全球市场份额稳居第一。鉴于 Chrome 在浏览器市场遥遥领先的地位,而我也是 Chrome 的用户,所以本文会以 Chrome 浏览器为例进行浏览器的原理说明。

Chrome 采用多进程架构,其顶层存在一个浏览器进程(Browser process)用以协调浏览器的其它进程。我们可以打开浏览器的更多工具->任务管理器查看当前正在运行的进程列表以及它们正在使用多少 CPU 和内存。

chrome-muti-process.png

这里简单解释一下主要进程的作用:

  • 浏览器进程(Browser Process):主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • GPU 进程 (GPU Process):独立于其他进程处理 GPU 任务,因为 GPU 需要处理来自多个应用程序的请求并将它们绘制在同一个表面上。
  • 渲染进程(Renderer Process):渲染进程的核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。

默认情况下,Chrome 会为每个 Tab 都分配一个渲染进程,同时为了节约内存,也会尝试为每个站点分配同一个渲染进程。当某个站点出现无响应时,我们可以单独关掉这个站点而不影响其它打开的站点。如果浏览器是单进程架构的话,其中某个站点或者插件崩溃了,整个浏览器就崩溃了,这是很令人悲伤的事情。

渲染引擎

浏览器由这么多个进程组成,要说其中哪个进程与我们前端息息相关,那么非渲染进程莫属,它是页面渲染、脚本执行、事件循环的基础。然而渲染进程是因为其中运行的渲染引擎才会如此重要。渲染引擎有时也被称为浏览器内核,浏览器内核可以分成两个部分:排版引擎和 JavaScript 引擎。

解析 HTML、CSS,布局和绘制都是在排版引擎中进行,因此在有些文章中渲染引擎经常是单指排版引擎。而我在这里为了文章的清晰描述会进行进一步划分,因为执行和解析 JavaScript 是在 JavaScript 引擎中运行,这还会引申出两者之间的重要关系会在渲染流程小节中介绍。

同一个网页在不同浏览器之间的差异通常就是不同浏览器使用的不同排版引擎和 JavaScript 引擎造成的。Google 最初使用 WebKit 作为 Chrome 浏览器的排版引擎,但现在是使用基于 WebKit 自行构建并开源的分支 Blink 作为排版引擎,而 JavaScript 引擎则是其使用 C++ 编写的 V8 引擎。

渲染引擎中会有一个主线程,N 个工作线程和几个内部线程,它们各司其职并且互相配合处理 Tab 内发生的所有事情。

chrome-render-engine

但是可以把渲染引擎理解为是单线程的,因为除了网络操作和一些可在后台执行的操作之外,几乎所有重要的事情都发生在主线程中。关于 JavaScript 如何在主线程中执行,感兴趣的可以看我之前写的 《理解 JavaScript:调用栈、任务队列》

渲染流程

在了解了渲染引擎的组成部分后,我们接下来开始了解渲染引擎是如何运行的。由于渲染机制过于复杂,所以渲染引擎在运行的过程中会被划分为很多子阶段。

解析

当渲染引擎接收到 HTML 数据时,其中的排版引擎开始解析 HTML 并将其转换为文档对象模型 (DOM)。

将 HTML 文档解析为 DOM 是由 HTML 标准 定义的,DOM 是浏览器的页面内部表示,也是 Web 开发人员可以通过 JavaScript 进行交互的数据结构和 API。

<html> 元素是第一个标签也是文档树的根节点。

<html>
  <head>
    <title>Web page parsing</title>
  </head>
  <body>
    <div>
      <h1>Web page parsing</h1>
      <p>This is an example Web page.</p>
    </div>
  </body>
</html>

DOM 树描述了文档的内容,反映了不同标记之间的关系和层次结构。上面这段 HTML 会解析成这样的一个树形结构:

html-dom-tree.webp

除了 HTML 文档本身,一个网页通常还会包含图像、CSS 和 JavaScript 等外部资源,这些文件需要从网络或缓存加载。排版引擎可以在解析 DOM 的过程中找到这些资源并将请求发送到浏览器进程中的网络线程。

但是当遇到 <script> 标签时,排版引擎会停止解析 HTML,直到 JavaScript 引擎解析和执行完 JavaScript 代码。JavaScript 代码会被解析为抽象语法树(AST),AST 是源代码语法结构的一种抽象表示。关于如何解析 JavaScript,感兴趣的读者可以看看我另外写的 Babel 编译器系列 文章进行了解~

排版引擎与 JavaScript 引擎的运行是互斥的,背后的原因在于 JavaScript 是可以操作 DOM 的,如果在修改 DOM 结构时渲染界面(即排版引擎与 JavaScript 引擎同时运行),那么渲染前后获得的结果就可能不一致了,所以浏览器将它们之间的运行设为互斥以解决这个问题。

在排版引擎将 HTML 解析为 DOM 树后还不足以了解网页的外观,因为我们可以在 CSS 中设置网页元素的样式。接下来一步是排版引擎解析 CSS。

排版引擎首先会遍历 CSS 中的每个规则集,根据 CSS 选择器创建具有父、子和兄弟关系的 CSSOM 树。CSSOM 跟 DOM 很像,但是不同。DOM 构造是增量的,CSSOM 却不是,这是因为 CSS 规则可以被覆盖,所以内容不能被渲染直到 CSSOM 的完成。

计算样式

经过解析的步骤,这时候有了 DOM 树和 CSSOM 树,渲染引擎还需要确定每个 DOM 节点的计算样式,也就是将 CSS 样式与 DOM 树中的每个可见节点匹配起来。排版引擎会遍历 DOM 树和 CSSOM 树并根据 CSS 级联规则把 DOM 树和 CSSOM 树结合成渲染树(Render tree)

渲染树的结构与 DOM 树的结构类似,但它只包含与页面上可见节点相关的信息。不会被显示的元素,如 <head> 元素及其子元素,以及任何带有 display: none 的节点都不会包含在渲染树中。但是应用了 visibility: hidden 的节点会包含在渲染树中,因为它们会占用空间。

到这步为止,我们有了渲染树,现在可以知道页面上每个可见节点的具体样式,但是这还远远不够。

布局

渲染引擎想要渲染一个完整的页面,除了获知每个节点的具体样式,还需要知道每一个节点在页面上的位置。想象一个场景,你跟你的朋友描述一幅画上有一个红色的大方块和一个蓝色的小方块。你的朋友并不能准确知道这幅画到底是什么样子,他并不知道这些方块的具体大小和在画上的位具体位置。

渲染引擎为了确定每个节点的确切大小和位置,还需要一个被称为布局的步骤。布局取决于屏幕的尺寸,这个步骤确定了页面上每个节点的大小和位置的。排版引擎从渲染树的根开始遍历,然后再生成布局树(layout tree)。

浏览器默认使用基于流的布局模型,该布局模型中的每个视觉元素通常代表一个矩形区域,对应于一个 CSS 盒子,它包含宽度、高度和位置等几何信息,它们通常按 HTML 文档书写顺序组成从上到下的块流。

如果读者对 CSS 布局感兴趣,可以看看我写的这篇 《CSS 布局:常规流布局》

确定页面的布局是一项具有挑战性的任务。即使是最简单的常规流布局,也必须考虑字体有多大以及在哪里换行,因为这些会影响段落的大小和形状,还会影响下一段的位置。

不过这些有挑战性的任务就交给那些浏览器工程师们,我们只要知道经过布局阶段后生成了布局树,渲染引擎就可以根据布局树确定页面上每个元素的大小和位置。

绘制

最后一步是根据布局树将各个节点绘制到屏幕上,排版引擎将在布局阶段计算的每个视觉元素转换为屏幕上的实际像素,将这些信息转换成屏幕上的像素的过程称为光栅化(rasterizing)。

就拿 12.9 英寸 2732 x 2048 分辨率的 iPad Pro 来说,有超过 568 万像素将被绘制到屏幕上,那是很多像素需要在每帧之间快速绘制。为了确保绘制的速度更快,渲染进程会为特定的节点生成专用的图层 (layer),这些图层按照一定顺序叠加在一起,就形成了最终的页面。

将页面分解成多个图层的操作被称为分层,最后将这些图层合并到一层的操作称为合成。分层可以将绘制任务提交到 GPU 进程,GPU 可以并行绘制多个层,从而提高整体绘制速度。而且当只需要更新一个图层时,只需重新绘制该层,而无需重新绘制整个屏幕。

将页面经过分层处理确实可以提高性能,但缺点是在内存管理方面成本较高,因此不应作为 Web 性能优化策略的过度使用。

以下类型的元素可以实例化为一个层:

  • <video> 元素
  • <canvas> 元素
  • 任何具有以下 CSS 属性的元素:
    • opacity
    • 3D 变换(例如 transform: rotateX(45deg))
    • will-change 属性

此外,还有一些其他元素可以实例化一个层,例如:

  • <iframe> 元素
  • <svg> 元素
  • 具有 position: fixedposition: absolute 属性的元素

总结

当我开始学会构建网站时,我几乎只考虑如何编写代码以及用什么框架可以提高工作效率,而忽视了浏览器是如何处理我所编写的代码。现代浏览器通过很多优化来提升用户的网络体验,了解其背后的原理可以帮助我们编写更好的代码,以更进一步提升用户体验。

参考资料