[译]浏览器好文 - 浏览器是如何工作的 - Part1: 架构

161 阅读13分钟

原文链接:www.vineetgupta.com/category/we…

以浏览器为依托进行编程是一件很不容易的事情。我是在 2008 年 12 月意识到这一点的,当时我们决定将 Directi 的一个产品的架构改为与 REST API 通信的纯 JavaScript 客户端,而在 2009 年 4 月,当我们决定基于 webkit 构建桌面产品时,情况更是如此。然而,尽管存在这些问题,使用 web 技术构建客户端还是给我们带来了巨大的回报,自那以后,我们在使用 web 技术构建客户端上的投入成倍增加。在过去的两年中,Directi 的多个团队已经发现了编写浏览器的痛苦和快乐,几乎每天我们都能了解到一些有关浏览器行为的有趣信息。在我们不断学习的过程中,我做了笔记和注释,并想要与更多人分享。这是我打算介绍的一系列文章中的第一篇(不一定按这个顺序或以这个开篇):

  • 架构

  • HTTP

  • 安全模型

  • 内容渲染

  • JavaScript

  • 应用

话虽如此,我还是有几篇文章没来得及写第二部分。希望这种情况不会发生在这次的连载中。

1. 渲染过程

浏览器的工作是获取和显示网页。在较高的层次上,大多数现代浏览器执行以下步骤来呈现HTML页面:

(参考: developer.mozilla.org/en/Introduc…)

  • 加载 HTML

  • 解析

  • 添加样式文件

  • 构建页面 frame

  • 页面布局

  • 绘制 frame

  1. 加载: 浏览器尝试从指定的位置获取页面。这通常是通过 HTTP 客户端实现的。然而,HTML 页面也可以从文件系统加载。加载器从其位置为出发点获取 HTML 页面。浏览器缓存这个非常重要的概念在这里发挥了作用,但后面会有更多介绍。加载 HTML 页面的方式与加载资源的方式不同。在 WebKit 中有两个不同的管道 —— 一个用于加载页面,另一个用于加载资源:

(参考: webkit.org/blog/1188/h…)

  1. 解析:当流从加载器中流过时,HTML 解析器开始构建 DOM (也称为“内容树”)—— 这里的每棵树上每一个节点都是一个 HTML 元素。现在,网络上的许多 HTML 都被破坏了,每个浏览器都不得不实现自己的特性来解析 HTML,从而导致微妙的不兼容性。然而 HTML 5 指定了解析算法。当这种方法被采用时,x-browser 因为解析而不兼容的问题应该会消失。在解析时,引擎可能会遇到资源(JS、CSS、图像、字体等),当这种情况发生时,特定的资源会排队加载并继续解析。同样,还有更多的内容,我们将在后面处理。

  2. 计算样式:浏览器提供一个默认样式表。通常,HTML 页面还指定了一组样式。这些样式需要应用到内容树。为此,我们构建了一个“渲染树” —— 它本质上是由要渲染的元素组成的。例如,display 设置为 none 的元素将不会出现在这棵树中(它的后代元素也不会出现)。像HEAD 和 SCRIPT 这样的元素也不会。渲染树中的节点表示样式信息:CSS 框模型、z 轴次序、不透明度都在这里指定。

  3. 构造框架:大多数可渲染元素遵循 CSS 框模型:它们有高度、宽度、边框、间距、填充、边距和位置。对于这些对象,创建一个称为 Frame 的矩形框。并非所有对象都有帧—例如上面的 SVG 图像就没有帧。它被放在一个 iframe 中,iframe 有一个框架。一个帧拥有所有关于物体本身如何渲染的信息。然而,我们还不知道的是,该元素与其他元素之间的相互作用将如何进行。

  4. 计算流:流计算或布局计算元素是如何相互放置的,这一过程主要由 CSS 可视化渲染模型控制。这通常是一个从树的根到叶的递归过程。此外,这是一个典型的惰性过程——它是根据需要完成的。基本上,当布局引擎确定一个元素需要被布局时(例如一个新添加的 Node),它会通过设置 dirty 位来标记它。实际的布局只在调用需要新信息的方法时完成。在这些视频中可以看到布局过程的可视化表示:

  • Gecko 重绘 Google 主页

  • Gecko 重绘 Wikipedia 主页

  • Gecko 重绘 Mozilla.org 主页

大多数浏览器的流量计算分辨率都比任何显示器的分辨率都高。这是为了支持缩放——当用户放大或缩小时,对象可以在屏幕上正确绘制,而不需要任何额外的步骤,除了将坐标映射到真实的像素。

  1. 绘制:一旦引擎知道需要绘制对象的确切位置,就会进入在屏幕上实际绘制对象的过程。这个过程叫做“绘制”,在 CSS 2.1 规范的附录 E 中有详细的描述。这基本上是一个从渲染树的根开始的树遍历,每个节点自行绘制。实际的渲染是通过图形引擎抽象出来的,图形引擎负责实际打开像素和硬件加速等。

2. 渲染模型

上面描述的呈现过程的实际执行可能完全根据浏览器决定对特定页面使用的呈现模式而改变。浏览器有不同的呈现模式是因为 web 的历史,理解呈现模式对于理解浏览器的行为是非常重要的。但是,我不会在这里讨论它,因为 hsivonen.iki.fi/doctype/ 描述的非常详细。如果你只是对背景感兴趣,请阅读 en.wikipedia.org/wiki/Quirks…

3. 动态页面

页面可能会因为 JavaScript 脚本行为或用户交互触发了渲染过程的某些部分而改变:

(参考: developer.mozilla.org/en/Introduc…)

  • 如果添加或删除 DOM 元素,浏览器的典型响应是按照前面描述的几乎是连续的顺序执行渲染过程
  • 如果更改了元素的 Style 属性,则需要重新计算元素的样式,并重新绘制页面
  • 浏览器可以通过批处理方式优化,通过排队重新计算
  • 然而,脚本经常会回读它们刚刚做出的更改,这就需要刷新重新样式队列
  • 为了获得更好的性能,将样式更改作为批处理,然后以批处理方式读取它们,这样队列刷新的频率就会降低 有些情况下,页面布局改变的代价更小:
  • 改变大小/位置不需要重新计算风格,但只需要重新流动和重新油漆
  • 颜色变化不需要重新流动,只需要重新喷漆
  • 滚动也不需要重新计算,但只是重画-这通常是增量完成,甚至可能不需要完全重画(但像固定的背景图像将需要完全重画)。因此,通过编程方式滚动移动元素比通过修改样式属性移动元素要快
  • 回流 - 由于位置或大小的变化-通常是递归的(从根到叶)
  • 子代的一些属性更改可以触发整个祖先的更改,一直到根。例如:高度变化
  • 父节点的某些属性变化会触发所有子节点的变化,包括叶子。例如:宽度变化
  • 浏览器可以检测到只有一段树可能改变,并只在该子树上做回流

4. 资源加载

当解析器浏览内容树时,它可能会看到一个引用外部资源(图像、CSS、JS、字体等)的元素,这些资源需要被加载。加载过程如下:

加载顺序

虽然资源通常是按照在文档中的出现顺序加载的,但浏览器通过将样式表和 JavaScript 文件优先于图像来优化加载顺序。此外,我们建议将样式表放在顶部。这是因为:

  • 样式表需要构建渲染树,但对内容树没有影响,所以 HTML 解析和 JS 执行可以继续,而 CSS 下载和加载。

  • 即使样式表正在下载,渲染树正在构建,脚本也可以请求样式信息。如果发生这种情况,浏览器将会报出 “想在 JS 开始执行之前加载样式” 的错误

并行加载

现代浏览器维护到服务器的多个持久连接。这允许并行加载。并行加载是一件好事,因为它减少了将页面交付给最终用户的总体延迟。然而,考虑到 web 服务器的负载因素,HTTP 1.1 RFC 建议“使用持久连接的客户端应该限制他们与给定服务器保持的并行连接的数量。一个单用户客户端不应该与任何服务器或代理保持超过 2 个连接。

请注意,我们需要权衡打开的套接字数量开销与打开新套接字开销以及它对延迟的影响。随着页面获取的外部资源数量的增加,优化以减少必须设置新连接的次数,以减少延迟并改善用户体验是有意义的。事实上,现在大多数浏览器都允许每个主机同时进行 2 个以上的连接。Steve Souders 在他的并行连接综述中很好地总结了当前的情况。

阻塞加载

由于脚本可以调用 document.write(),所以在脚本完全加载、执行(如果脚本块中有任何内联脚本)和插入 document.write() 之前,解析不能继续进行。这意味着脚本加载会阻塞解析,会阻塞进一步的加载,从而阻塞上面提到的并行加载。现代浏览器对这一问题做了优化。例如,在 WebKit 中,当主解析器因为脚本加载而阻塞时,它会启动一个侧解析器,该解析器会找出在 HTML 的其余部分中加载的其他资源。然而,这是 WebKit -对于其他浏览器,有几个方法:

  1. 将脚本块放在末尾—这样它们就不会阻塞任何进一步的解析

  2. 使用一种方法来异步下载脚本——sounders 在他的文章《无阻塞加载脚本》中总结了这些方法

  3. HTML 5 在脚本标记上指定了 async 属性,它告诉浏览器脚本不需要同步执行,解析器可以继续。WebKit 最近开始支持这个属性,Firefox 从3.6开始也支持这个属性。

5. 物理架构

Web 浏览器从单进程、单线程模型开始。一开始这是可以接受的,因为网页只是需要呈现的文件。然而,网络现在已经从以文档为中心发展到以应用为中心——现在很多网站都是应用,有很多动态功能相关的代码,与浏览器设计用来呈现的静态内容相去不远。这就产生了稳定性、性能和安全性的问题。为了解决这些问题,大多数浏览器已经转移(或正在转移中)到多进程体系结构。这一趋势背后有三个驱动因素:

性能:多进程可以充分利用多核优势

安全:浏览器可以以较低优先级执行新进程,从而减少或避免了恶意代码可能带来的影响

稳定性:一个行为不良的页面/脚本/插件不会影响其他进程,因为浏览器采用了进程隔离

Firefox

Firefox 使用单线程、单进程模型。这意味着在 Firefox 中,一个 UI 线程被所有窗口共享。这样做的原因显然是允许 X-DOM 阻塞来自相同来源的不同页面的调用。详情请登录www.mail-archive.com/mozilla-lay… web-worker 请求在不同的线程上处理。

为了提供更好的隔离和可靠性,Firefox将 通过其 Electrolysis 项目转向多进程模型。然而,这似乎只适用于插件,页面将继续由单个进程提供服务。

IE

第一个提供多进程支持的浏览器是IE 7,每个浏览器窗口都在自己的进程中运行:

(参考: blogs.msdn.com/b/ie/archiv…)

IE 8 对这个模型进行了改进,将每个标签放在自己的进程中,但将框架进程和代理进程移动到一个共同的进程中,以提高启动时间。微软称这种架构为松散耦合 Internet Explorer (LCIE):

(参考: blogs.msdn.com/b/ie/archiv…)

然而,实际的模型比上图所示的要复杂得多,因为 IE 8 试图在不损害安全的情况下,平衡更多进程的好处和额外的开销。实际流程模型为:

  • 保护模式进程:不考虑内存开销,不同配置安全级别的站点在不同进程中开放。这种方法称为保护模式 (Protected Mode),它基于强制性完整性控制 (Mandatory Integrity Control)

  • 基于上下文的选项卡进程:是否创建新的选项卡进程取决于可用的内存数量

  • 最大页签进程:单个隔离会话在特定 MIC 上可以创建的最大页签进程的值

更多细节请参考: blogs.msdn.com/b/askie/arc…

Chrome

Chrome 遵循类似于 IE 8 的方法——标签的宿主进程被称为渲染进程,代理进程被称为浏览器:

(来源:dev.chromium.org/developers/…)

Chrome 支持四种进程模型:

  • 每个站点实例进程 (Process per site-instance):对一个站点的不同访问位于不同的进程中。提供最高级别的隔离,但也会产生更多的开销。
  • 每个站点进程:不同站点之间相互隔离,但访问同一站点运行在同一进程中。减少整体内存开销,但如果站点上有几个页面打开,单个渲染器的大小将相当大,可能会减慢它的速度。
  • 每个选项卡的过程:虽然以前的模型考虑来源,每个选项卡模型的过程是基于用户做出的选择。一个过程用于呈现一个选项卡,如果在同一个选项卡中切换到不同的站点,该过程将继续。
  • 单进程:这是没有隔离的最简单的进程。

在 Chrome 和 IE 中,框架和它的父页面运行在同一个进程中。此外,单独的程序可能会防止来自同一来源的两页之间的法律互动。Chrome 的解决方案是不允许 x 进程调用,即使它是合法的。IE 所做的就是代理这些特定的调用,并在幕后将它们转换成某种 IPC。这也可能被 Chrome 在稍后的一些阶段支持。