深入浏览器

459 阅读13分钟

浏览器架构

浏览器中的进程和线程有着怎样的组织结构呢?可能是包含了多个线程的单进程结构,也可能是多线程结构,且每个线程又可以包含多个线程,进程间通过 IPC 进行通信。如下图所示:

browser-arch.png

浏览器架构没有一个统一的标准,不同的浏览器其架构可能是完全不同的。本文讨论的是最新的 Chrome 浏览器。其架构如下图所示:

browser-arch2.png

最顶层的浏览器进程(Browser Process)负责协调不同任务分工的进程。渲染进程(Renderer Process)对应浏览器的每个 tab 页面,每打开一个新的标签页就会创建相应的渲染进程。这样做的最大好处就是当某个标签页意外崩溃时不会影响其它标签页的正常运行。最近,Chrome 已经开始尝试为每个站点分配一个进程,包括 iframe。
这里,我们通过一个表来总结每个进程的职责:

进程职责
Browser控制地址栏,书签,后退和前进按钮,网络请求,文件访问等
Renderer控制负责展示网站内容的标签页内的任何事情
Plugin控制网站使用的全部插件,例如 flash
GPU处理 GPU 任务,因为要处理来自不同应用的请求,因此 GPU 进程与其它进程是完全隔离的

browserui.png

除了这四种进程,还可能存在扩展进程和实用工具进程。可以通过点击浏览器右上角的菜单按钮-更多工具-任务管理器查看全部的进程信息,包括内存占用,CPU,网络,进程ID。如下图所示:

1619614813146.jpg

浏览器的多进程架构带来的好处除了前面提到的标签页崩溃问题,还包括安全性和沙箱机制。
由于进程拥有独立的内存空间,相比同一个进程内的多线程内存共享,其内存消耗显然更大。为了节省内存,Chrome 限制了进程数量,数量的多少取决于你设备的内存和 CPU 配置。当进程数量达到上限时,来自同一个站点的多个标签页就会被放到同一个进程中处理。

Chrome 正在推进浏览器的架构更新,试图将浏览器的各部分作为服务运行,使其能够很容易地将浏览器拆分成不同的进程或是整合成一个进程。

大致的想法是,当 Chrome 运行在拥有强大硬件资源的设备上时,它会将各个服务拆成单独的进程,提供更强的稳定性。但是当 Chrome 运行在硬件资源受限的设备上时,它就会将服务合并成一个进程,以节省内存空间。
servicfication.png 点击查看架构更新动画

站点隔离是 Chrome 最近引入的特性,每个跨站点的 iframe 都会运行在单独的渲染进程中。前面我们讨论的都是为每个标签页运行一个进程,它允许跨站点的进程运行在单个渲染进程中,且不同站点之间共享同一块内存空间。在同一个渲染进程中运行 a.com 和 b.com 似乎没有什么问题。

同源策略是 web 的核心安全模型,它确保了一个网站不能在未经允许的情况访问其它网站的数据,绕过同源策略的限制是安全攻击的主要目标。进程隔离是分离站点的最有效方法,桌面版 Chrome 自 67 版本开始开启站点隔离,标签页中的每个跨站 iframe 都会得到一个单独的渲染进程。

isolation.png

一切始于浏览器地址栏

前面一节我们介绍了浏览器中进程和线程的概念以及它们所扮演的角色。本节我们将深入进程以及线程为了展现出网页内容所做出的努力。

标签页外部的一切都是由浏览器进程处理的。浏览器进程包含了绘制浏览器按钮和输入栏的 UI 线程(UI thread),处理网络数据的网络线程(network thread),控制文件等信息获取的存储线程(storage thread)。用户在地址栏输入的 URL 会交由浏览器的 UI 线程处理。

从输入 URL 到最终网页内容的呈现,可分为以下几个步骤:

  1. 处理输入 当用户在地址栏输入后,UI 线程首先会判断输入信息是搜索字段还是 URL。浏览器的地址栏兼具“搜索栏”的功能,因此 UI 线程需要解析输入信息,如果是搜索字段就将其交给搜索引擎处理,如果是 url 就访问对应的网站。
    input.png

  2. 开始导航 当用户在地址栏输入并按下回车后,UI 线程发起网络请求以获取网页内容,网络线程通过适当的协议(比如 DNS)为请求查找并建立 TLS 连接。

navstart.png

在这个过程中,网络线程可能会收到服务重定向(比如 HTTP 301),在这种情况下,网络线程会通知 UI 线程有重定向发生,之后就会发起另一个 URL 请求。

  1. 读取响应 一旦响应体开始进入,网络线程必要时会查看流信息的前几个字节。响应的 Content-Type 头部信息会告诉我们响应数据是什么类型的。

response.png

如果响应是 HTML 文件,接下来就会将数据发送到渲染进程,如果响应是压缩文件或是其它类型的文件则意味这是一个下载请求,因此要将响应数据发送到下载管理器。

sniff.png

在这一步,浏览器还会进行安全检查,如果域名和响应数据匹配到了疑似恶意网站,网络线程就会出现警告信息。另外,CORB 检查会确保跨站敏感信息不会进入渲染进程。

  1. 寻找渲染进程 一旦所有的检查完成并且网络线程确认浏览器需要导航到请求的网站,网络线程通知 UI 线程数据已经准备好,UI 线程接着查找渲染进程来渲染网页。 findrenderer.png 网络请求获取响应数据可能需要几百毫秒,为了加速页面渲染的过程,在第2步 UI 线程将 URL 请求发送到网络线程的同时就可以开始查找并启动渲染进程,这样在网络请求获取到数据后立即就可以开始渲染页面。说的明白点,就是将先通过网络请求获取响应数据,接着查找并启动渲染进程的串行过程变成一边获取响应数据一边启动渲染进程的并行过程。

  2. 完成导航 现在数据和渲染进程都已经准备好,浏览器进程通知渲染进程提交导航,一旦浏览器进程接收到渲染进程确认提交导航的消息,意味着导航已经完成,接下来就进入到文档加载阶段。

commit.png

在这个过程中,浏览器会更新相关的 UI(比如地址栏信息,网站安全标识,网站图标等)并记录导航历史信息。

  1. 初始加载完成 一旦提交了导航,渲染进程就开始加载资源并渲染页面。我们将在下一节详细讨论这个阶段发生了什么。一旦渲染进程“完成”渲染,它就会向浏览器进程发送一个IPC(这是在页面中所有帧的 onload 事件都被触发并执行完成之后)。这里的完成之所以加了引号,是因为客户端 JavaScript 在后面仍然可以加载额外的资源并呈现新的视图。

loaded.png

上面只是一个简单导航的流程,对于从一个网站导航到另一个网站的情况我们暂不讨论。

页面是如何被渲染出来的

前一节提到页面是由渲染进程渲染的,那么在页面渲染过程中具体发生了什么呢?这就是本节索要探讨的问题。

页面渲染过程涉及到 web 性能的许多方面,因为渲染过程的复杂性,本节仅作概述。

渲染进程负责标签页内发生的一切。在渲染进程中,主线程处理大部分的代码,如果你有使用 web worker 或是 service worker,部分 JavaScript 代码会在 Worker 线程中处理。Compositor(合成)和 raster(格栅)线程用于高效平滑的渲染页面。

渲染进程的核心工作就是将 HTML、CSS 和 JavaScript 转换成用户可以交互的网页。

renderer.png

解析

要想将代码变成网页,首先需要对 HTML、CSS 和 Javascript 进行解析。

构建 DOM

当渲染进程开始接收 HTML 数据的同时,主线程就开始解析 HTML 并将其转换成 DOM (Document Object Model)。DOM 是浏览器对页面的内部表示,它为前端开发的小伙伴们提供了页面的数据结构和用于操作这些 DOM 的 JavaScript API 。HTML 规范 定义了解析 HTML 的规则。

资源加载

网站通常都会使用外部资源,比如图像、CSS 和 JavaScript。这些文件需要从网络或是缓存加载。主线程可以在解析构建 DOM 时一个一个地请求它们,但是为了加快速度,在解析构建 DOM 的同时会进行资源预加载扫描(preload scanner),如果遇到 <img> <link> <script> 这样的标签就会将相应的请求发送到网络线程。

dom.png

JavaScript 会阻塞解析

在 HTML 解析的过程中,如果遇到了 <script> 标签就会暂停解析并开始加载、解析、执行 JavaScript 代码。这样做是出于什么样的考虑呢?因为 JavaScript 可能会改变 document,最极端的就是document.write() ,直接改变整个 DOM 结构,好家伙!

指定浏览器如何加载资源

为了更好的加载资源,避免 HTML 解析过程阻塞,下面有几种常见的方法可以指定浏览器如何加载资源。

  1. <script> 标签属性 async,defer 异步加载资源,两者的区别在于 JavaScript 代码何时执行
  2. JavaScript 模块化(JavaScript modules),也称之为 "ES modules" 或是 “ECMAScript modules”,这是浏览器的原生模块功能,模块化脚本会自动延迟加载,类似于 defer 属性的效果
  3. <link rel="preload"> 预加载,指明哪些资源是在页面加载完成后即刻需要的,预加载使得资源可以更早的得到加载并可用

样式计算

有了 DOM 还不足以知道页面长什么样子,没有 CSS 的页面就是一副素颜罢了,实在看不下去。主线程解析 CSS 并计算每个 DOM 节点的计算样式(computed style),计算样式表示的是基于 CSS 选择器应用于每个元素的样式信息,可以在浏览器开发者工具的 Elements 面板的 Computed 部分查看全部的计算样式。

computedstyle.png

即使你没有提供任何 CSS,每个 DOM 节点也会有默认的计算样式。<h1> 标签内容比 <h2> 标签内容要大,两者都有外边距。这是因为浏览器有默认的样式表,想要查看 Chrome 的默认 CSS,点击这里

布局

现在渲染引擎知道了页面结构和每个节点的样式,但还不足以渲染出页面。设想下你通过线上语音的方式向你的朋友描述一副画,"有一个大的红色圆形和一个小的蓝色矩形",这位朋友肯定一脸懵。

tellgame.png

布局就是寻找元素几何尺寸的过程。主线程处理 DOM 和计算样式并且创建包含了 x, y 坐标和边框盒子大小信息的布局树。布局树和 DOM 树相似,不同的是布局树仅包含可见元素,对于应用了 display: none 样式的元素不会出现在布局树中,而 visibility: hidden 则会。另外,通过伪类创建的内容会包含在布局树中尽管它不存在于 DOM 中。

layout.png

页面布局是一项复杂的工作。即使是像从上到下排列的简单块级流也需要考虑字体有多大,在哪里换行,因为这些都会影响段落的大小和形状,进而影响后面段落的位置。 点击查看动画

布局经常被我们叫做回流,当我们滚动,缩放页面或是操作 DOM 元素的时候都会发生回流。这里有一个触发元素回流的事件列表

绘制

有了 DOM,样式和布局依然没办法绘制出页面。假设你想复制一幅画,你知道元素的大小、形状和位置,但你仍然需要判断绘制它们的顺序。

drawgame.png

例如,你可能为某些元素设置了 z-index,在这种情况下,页面实际绘制的顺序和 HTML 中的顺序是不一样的。

zindex.png

在绘制这一步,主线程遍历布局树创建绘制记录,它是对绘制过程的记录,比如"先背景,再文本,再矩形"。如果你有使用 JavaScript 绘制 <canvas> 元素,可能会对这个过程很熟悉。

paint.png

合成

如何绘制页面

现在浏览器知道了文档结构,每个元素的样式,页面的几何结构以及绘制顺序,接下来要怎么绘制页面呢?将这些信息转换成屏幕像素点的过程称之为格栅化。

最简单的方式就是对视图中的内容格栅化,如果页面发生滚动,则移动已经格栅化的帧并且格栅化其它部分以填充因为页面滚动出现的空白。点击查看动画

什么是合成

合成是一种将页面拆分成图层,分别对其栅格化,并在一个单独的合成线程中对页面进行合成的技术。它要做的就是合成一个新的帧。动画可以用同样的方式实现,移动图层并合成一个新的帧。

在浏览器开发者工具菜单 More tools - Layers 中可以看到网页是如何划分图层的。

拆分成图层

为了找出哪些元素需要在哪个图层中,主线程会遍历布局树创建图层树。

layer.png

你可能想给每个元素都分配一个图层,但是过多图层的合成可能会比在每一帧中格栅化页面的一小部分显得更慢,因此考量应用的渲染性能至关重要。

脱离主线程的格栅化和合成

一旦创建了图层树并确定了绘制顺序,主线程就会将这些信息提交给合成线程,然后合成线程对每个图层进行格栅化。一个图层可以像整个页面的长度一样大,所以合成线程会将它们拆分成贴图,并将每个贴图发送给栅格线程。格栅线程对每个贴图进行栅格化,并将它们存储在 GPU 内存中。

raster.png

至此,页面的整个渲染流程就结束了,最后用一张图进行总结。
1_yQJkz12sPxS-kJoMDqzbEQ.png

参考

  1. developers.google.com/web/updates…
  2. developers.google.com/web/updates…
  3. developers.google.com/web/updates…
  4. medium.com/jspoint/how…