浏览器是如何工作的

1. 背景

对于前端开发者来说,浏览器的底层原理通常不需要有太深入的了解。因为现代浏览器已经将所有的底层细节封装得很好,前端开发只需要关注HTML+CSS+JavaScript这三样东西即可。

在软件工程领域,封装是降低系统复杂度的一种常用,且通用的手段。通常这对于开发者而言是件好事:只需要关心接口的使用,无需了解底层细节,这样开发的门槛就降低了。但是伴随开发水平提升,逐渐就会遇到瓶颈。浏览器构建的沙盒环境(sandbox)固然美好,但真实世界毕竟不是这个样子,只有深入进去,了解浏览器的运行逻辑后,才能更加清晰地知道为什么结果是这个样子,为什么这样写会存在问题等等。这样以后写起代码来,会更加胸有成竹。

浏览器虽然是个复杂的领域,像Chromium,源码就有千万行,基本和操作系统一个层级,但其中的原理还是比较简单的,一篇文章差不多能讲清楚。

2. 开始

整个过程,将从在地址栏输入google.com开始,然后到浏览器上渲染出Google网页结束。这个过程,看似简单,背后却发生了非常多的故事,很少有人能把这个流程,尤其是其中的细节,描述得清楚。

3. 主要功能

浏览器的主要功能,就是将用户请求的网络资源,从服务器获取后,展示到屏幕上。

资源可以是HTML,也可以是PDF,每个资源都有一个独一无二的标识符,URI(Uniform Resource Identifier)。

浏览器理解资源,例如HTML和CSS,遵循一定规范,这个规范主要由W3C(World Wide Web Consortium)制定。规范的好处在于,相同一份资源,在不同的浏览器上表现基本一致,这样就减少了很多兼容性的麻烦。

4. 组织结构

一个浏览器,大致可以划分为7个部分。

  1. 用户界面。包括地址栏,前进/后退按钮,书签等。
  2. 浏览器引擎。响应用户的操作。
  3. 渲染引擎。负责将网络请求的内容展示,涉及HTML和CSS的解析,渲染等。
  4. 网络。负责网络请求,例如HTTP。
  5. UI。窗口绘制。
  6. JavaScript解释器。解析和执行JavaScript代码。
  7. 数据存储。例如,IndexDB、WebSQL等。

5. 渲染引擎如何工作

不同浏览器使用不同的渲染引擎,例如IE使用Trident,Firefox使用Gecko,Safari使用WebKit,Chrome,Edge和Opera都基于Chromium,使用WebKit的一个fork版本,Blink。

渲染引擎从网络层获取到数据后,开始了一场漫长的奇妙之旅。

文本格式的数据,是如何经过一步步的处理,到最后展示到屏幕上?

基本的流程如下:

  1. 解析HTML,构建DOM树
  2. DOM树结构样式信息,构建Render树
  3. Render树布局
  4. 绘制Render树

流程看似简单,实则有非常多的细节要处理。

5.1 Parser

HTML的parser,并不符合上下文无关语法(Context Free Grammar),因为用户的输入很有可能不符合规范,因此渲染引擎有很多容错的机制。这也是为什么,无论HTML写成什么样,我们都没有见过相关报错。浏览器总是会尽可能的将内容呈现。

解析的过程,有完整的规范:Parsing HTML documents

与HTML不同,CSS符合上下文无关语法,也有定义的规范:Grammar of CSS 2.1。语法是用BNF描述,WebKit就使用Flex和Bison从语法文件中自动创建解析器。

另外,在parse的过程中,也存在一些值得优化的地方。

在解析过程中,遇到<script>标签,解析器会暂停,直到script被执行。如果引用的是外部脚本,那么必须先从网络中获取到才行,而这个过程,是同步的。这无疑是降低解析的效率。

这也是为什么,<script>通常被建议使用在<body>尾部,就是为了提高解析效率。

当然,现在有了更好的方案,在<script>里增加defer属性,这样script的执行就是异步的,不会阻塞HTML的解析,而且会在DOM准备好之后执行,同时在DOMContentLoaded事件前。

不仅如此,现代浏览器还会做更多的优化,例如推测性解析(Speculative parsing),利用多线程,加速解析的过程,但只针对外部资源。

5.2 Render

Render的过程,也是通过构建一颗树实现。

WebKit中的主要数据结构RenderObject这么定义:

class RenderObject{
  virtual void layout();
  virtual void paint(PaintInfo);
  virtual void rect repaintRect();
  Node* node;  //the DOM node
  RenderStyle* style;  // the computed style
  RenderLayer* containgLayer; //the containing z-index layer
}

RenderTree和DOM Tree存在对应关系,但并非所有的DOM节点都挂载到RenderTree上,只有那些可见的才会被插入到树上。

每个Render节点都有一系列丰富的样式,如何计算是个相当复杂的过程,在渲染引擎里有专门的样式计算逻辑。主要的难点有三个:

  1. 属性相关的数据很大
  2. 在诸多样式里找到最匹配的那个,需要复杂的寻找算法,尤其对于结构复杂的选择器而言尤其如此
  3. 实现CSS定义的复杂级联规则

不过渲染引擎把这里的内部实现封装的很好,对于开发者而言,只需要写好CSS,就可以得到令人满意的渲染结果。

5.3 Layout

当RenderTree被创建好的时候,还并没有位置和大小信息。计算这些值的过程,叫做布局(Layout)。

HTML使用了一种基于流(Flow)的布局模型,这意味着多数情况下,几何形状可以一次性(Single Pass)计算出。流里后出现的元素会影响之前出现的,因此布局处理的过程,自左向右,自顶向下。

为了避免微小的改动造成整个布局的重新计算,浏览器使用了一种“脏位(dirty bit)”的思想:布局存在改变的节点被标记为脏,脏存在两种类型:节点自身为脏,子节点为脏。因此在后续计算的时候,只有标记为脏的节点会被重新计算。这种影响布局的方式是增量布局。

还有一种全量布局,例如font size改变,窗口resize的时候会发生。

对于增量布局而言,这个过程是异步的,Firefox会把增量布局累积到队列里然后批量执行,WebKit也存在类似的计时器。

5.4 Paint

在绘制阶段,整个RenderTree被遍历,并触发*paint()*方法,借助UI基础组件实现绘制。

至此,页面的解析,构建,布局,绘制的过程就完成了。

6. 总结

这篇博客主要解释了渲染引擎的工作原理,这是离用户最近,但同时也是离前端开发者最远的一块。

渲染引擎很重要,但对于现在浏览器而言,也只是其中的一个组件。还有多进程架构,进程间通信,网络组件等等,这个在接下来的文章里会继续进行探讨。

(完)

参考

在 GitHub 上编辑本页面 更新时间: 10/13/2023, 12:55:21 AM