浏览器是如何渲染出一个完整的页面(DOM树、分层渲染、缓存策略)

1,715 阅读18分钟

前言

前端的工作离不开浏览器,那么明白浏览器是如何工作的,也成为了面试官几乎必问的问题了。

我们常见问题有:

  1. 当我输入了一个URL之后,浏览器是如何渲染界面的?
  2. 浏览器缓存静态资源的规则?

如果你对这些问题还有不清楚的地方,那么我们一起共同学习把。

chrome 浏览器多进程架构

  • 浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

优点

多进程模型提升了浏览器的稳定性、流畅性和安全性

缺点

  • 更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

理解一次完整的请求

  • 首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程。
  • 然后,在网络进程中发起真正的 URL 请求。
  • 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。
  • 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航 (CommitNavigation)”消息到渲染进程;
  • 渲染进程接收到“提交导航”的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立数据管道;
  • 最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。
  • 浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。

用户发出 URL 请求到页面到开始解析的这个过程,就叫做导航。

用户输入

当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。

  • 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL。
  • 如果判断输入内容符合 URL 规则,比如输入的是 www.baidu.com,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL,如 https://www.baidu.com

URL 请求过程

接下来,便进入了页面资源请求过程。这时,浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。

  1. 首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。
  2. 接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。
  3. 服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。
    • 重定向,在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。
    • 响应数据类型处理,Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。

准备渲染进程

默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。

但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。

渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。

提交文档

所谓提交文档,就是指浏览器进程将网络进程接收到的 HTML 数据提交给渲染进程,具体流程是这样的:

  1. 首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息;
  2. 渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”;
  3. 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程;
  4. 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。

到这里,一个完整的导航流程就“走”完了,这之后就要进入渲染阶段了。

渲染阶段

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

重点关注每个子阶段都有其输入的内容、处理过程以及每个子阶段会生成输出内容。

第一步:构建 DOM 树

为什么要构建 DOM 树呢?这是因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。

好了,现在我们已经生成 DOM 树了,但是 DOM 节点的样式我们依然不知道,要让 DOM 节点拥有正确的样式,这就需要样式计算了。

  1. 输入:HTML 文档;
  2. 处理:HTML 解析器解析;
  3. 输出:DOM 数据解构。

第二步:样式计算(Recalculate Style)

1、 把 CSS 转换为浏览器能够理解的结构

  • 通过 link 引用的外部 CSS 文件
  • <style>标签内的样式
  • 元素的 style 属性内嵌的 CSS

将上述三种 CSS 文本转换为浏览器可以理解的结构——styleSheets。

Chrome 控制台中输入document.styleSheets可以查看其结构

2、转换样式表中的属性值,使其标准化

3、 计算出 DOM 树中每个节点的具体样式 现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,如何计算呢?

利用 CSS 的继承规则和层叠规则进行计算标签的具体样式

  1. 输入:CSS 文本;
  2. 处理:属性值标准化,每个节点具体样式(继承、层叠);
  3. 输出:styleSheets(CSSOM)。

第三步:布局阶段

我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。

Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。

1、创建布局树

你可能注意到了 DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树。

构建布局树,浏览器大体上完成了下面这些工作:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
  • 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树。

2、布局计算

现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置了。并将这些信息保存在布局树中。

  1. 输入:DOM & CSSOM 合并成渲染树;
  2. 处理:布局树(DOM 树中的可见元素);
  3. 输出:布局树。

第四步:分层

现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?答案依然是否定的。

因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-index 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。

浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。

并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

1、拥有层叠上下文属性的元素会被提升为单独的一层。明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。

2、需要剪裁(clip)的地方也会被创建为图层。

div {
    width: 200;
    height: 200;
    overflow:auto;
    background: gray;
} 

我们把 div 的大小限定为 200 * 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域

出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。

  1. 输入:布局树;
  2. 处理:布局树中满足分层条件的元素;
  3. 输出:layerTree。

第五步:图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制

绘制操作是由渲染引擎中的合成线程来完成的。

当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程

  1. 输入:layerTree;
  2. 处理:拆分成绘制指令,生成绘制列表,提交到合成线程;
  3. 输出:绘制列表。

第六步:栅格化(raster)操作

通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。

基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:

合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。

  1. 输入:绘制列表;
  2. 处理:合成线程会按照视口附近的图块来优先生成位图;
  3. 输出:位图。

第七步:合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

  1. 输入:位图;
  2. 处理:合成线程发出命令——“DrawQuad”到主线程;
  3. 输出:屏幕上的最终展示。

步骤总结

一个完整的渲染流程大致可总结为如下:

  1. 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
  2. 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并生成分层树。
  5. 为每个图层生成绘制列表,并将其提交到合成线程。
  6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
  7. 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
  8. 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。

再来看几个面试常问的概念

重排

更新了元素的几何属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。

重绘

更新元素的绘制属性,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

合成

直接合成阶段,使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

理解了这些,那么肯定是要面对一个问题,在平时开发中如何减少重绘重排?

  1. 使用 class 操作样式,而不是频繁操作 style
  2. 避免使用 table 布局
  3. 批量dom 操作,例如 createDocumentFragment,或者使用框架(框架使用虚拟DOM技术最小化操作DOM)。
  4. window resize 这种触发概率频繁的事件要做节流

浏览器缓存策略

这个问题想必也是面试时问的最多的问题了,因为缓存总是关乎性能。

浏览器的缓存策略都是通过HTTP的响应报文来控制采取何种策略的,因此前端是没有办法主动去控制的。

缓存分为强制缓存和协商缓存,从名字上可以看出强制缓存优先级大于协商缓存的

缓存机制

强制缓存就是向浏览器缓存查找该请求的结果

两种设置方式:

  • Expires,是HTTP/1.0控制网页缓存的字段,其值为服务器返回该请求结果缓存的到期时间,即再次发起该请求时,如果客户端的时间小于Expires的值时,直接使用缓存结果。它有两个问题(1、客户端和服务端有一方的时间不准确。2、它是1.0版本,现在是HTTP/1.1版本被Cache-Control替代)
  • Cache-Control
    • public:所有内容都将被缓存(客户端和代理服务器都可缓存)
    • private:所有内容只有客户端可以缓存,Cache-Control的默认取值
    • no-cache:客户端缓存内容,但是是否使用缓存则需要经过协商缓存来验证决定
    • no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
    • max-age=xxx (xxx is numeric):缓存内容将在xxx秒后失效

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程

设置方式:

  • Last-Modified,返回该资源文件在服务器最后被修改的时间

  • If-Modified-Since,则是客户端再次发起该请求时,携带上次请求返回的Last-Modified值,通过此字段值告诉服务器该资源上次请求返回的最后被修改时间。服务器收到该请求,发现请求头含有If-Modified-Since字段,则会根据If-Modified-Since的字段值与该资源在服务器的最后被修改时间做对比,若服务器的资源最后被修改时间大于If-Modified-Since的字段值,则重新返回资源,状态码为200;否则则返回304,代表资源无更新,可继续使用缓存文件

  • Etag,是服务器响应请求时,返回当前资源文件的一个唯一标识

  • If-None-Match,是客户端再次发起该请求时,携带上次请求返回的唯一标识Etag值,通过此字段值告诉服务器该资源上次请求返回的唯一标识值。服务器收到该请求后,发现该请求头中含有If-None-Match,则会根据If-None-Match的字段值与该资源在服务器的Etag值做对比,一致则返回304,代表资源无更新,继续使用缓存文件;不一致则重新返回资源文件,状态码为200

If-Modified-SinceIf-None-Match 都是浏览器在收到了 Last-ModifiedEtag 时自动添加上的。

浏览器小知识

浏览器显示的是图片,那么为什么浏览器中鼠标能选中文字?

点击鼠标选中文字的时候,这些消息会传递到渲染进程,渲染进程再合成选中文字的状态,然后更新图片!

如果打开了 2个页面,会有几个进程呢?是 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 2个渲染进程,共 5个吗?这些进程是可以在浏览器开发者中被实际观察到的吗?

通常情况下会是五个,但是有很多其他情况:

  1. 如果页面里有iframe的话,iframe也会运行在单独的进程中!
  2. 如果页面里有插件,同样插件也需要开启一个单独的进程!
  3. 如果你装了扩展的话,扩展也会占用进程
  4. 如果2个页面属于同一站点的话,并且从a页面中打开的b页面,那么他们会公用一个渲染进程

这些进程都可以通过chrome的任务管理器来查看。

浏览器系统架构发展史

  1. 单进程架构
  2. 多进程架构
  3. 面向服务架构

每个iframe标签都会创建一个新的渲染进程吗?

是的