# 浏览器渲染基本原理

浏览器对于前端工程师,就相当于赛车于赛车手,想要更好的在赛道上驰骋,就需要对自己的赛车有更深入的了解,甚至人车合一,所以本篇文章我们就来打开浏览器的大门,一起努力吧!

# 前言

一篇文章很难将所有的细节覆盖,同时我们也不需要将所有流程和每一个环节都摸透,我们就依照脉络来聊一聊浏览器的渲染过程。

我们都知道一个页面通常由三个部分组成,即HTMl+CSS+JS,并通过一系列的步骤转换。

  • HTML 的内容是由标记和文本组成
  • CSS 称为层叠样式表,是由选择器和属性组成
  • JS 是可以使网页的内容“动”起来

用户请求的 HTML 文本(text/html)基础的渲染流程图大致是这样的:

在这里插入图片描述

  1. 解析 HTML+CSS -> 解析HTML生成 DOM树,同时解析CSS生成 CSSOM树
  2. 构建 Render 树 -> “枝干”和“树芽”配合,两个合并生成新的用于渲染的树;
  3. 布局 Render 树 -> 对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置;
  4. 绘制 Render 树 -> 最后遍历渲染树并用UI后端层将每一个节点绘制出来,前三步都是在绘制“蓝图”;

用流程表示如下图:

在这里插入图片描述

既然浏览的渲染分流程,我们也按照它的顺序来了解每一步都发生了什么。

# 一、浏览器渲染基本步骤

引入一个概念——关键渲染路径

从收到 HTML、CSS 和 JavaScript 字节到对其进行必需的处理,从而将它们转变成渲染的像素这一过程,即关键渲染路径。

它就是可以优化的点,也是可以

# 1.1 构建对象模型

斟酌了一下,决定将 DOM 树和 CSSOM 树放在一起。浏览器在渲染页面之前第一步就需要先构建DOM树CSSOM树,因此我们需要确保尽快将 HTML 和 CSS 提供给浏览器。

下面是构建对象模型的描述,本章就这些内容:

  • 字节 → 字符 → 令牌 → 节点 → 对象模型
  • HTML 标记转换成文档对象模型 (DOM);
  • CSS 标记转换成 CSS 对象模型 (CSSOM)。
  • DOM 和 CSSOM 是独立的数据结构。
  • Chrome DevTools Timeline 让我们可以捕获和检查 DOM 和 CSSOM 的构建和处理开销。

# 1.1.1 DOM 树(文档对象模型)

因为浏览器不能直接理解和使用 HTML,所以需要将 HTML 解析为浏览器能够理解的结构,即 DOM 树。DOM 树的根节点就是 document 对象。

试想一下这段代码:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
    <title>DOM</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> performance</p>
    <div><img src="awesome-photo.jpg" /></div>
  </body>
</html>

一个包含一些文本和一幅图片的普通 HTML 页面。经过字节 -> 字符 ... -> 模型过程

在这里插入图片描述

  • 转换: 浏览器从磁盘或网络读取 HTML 的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成各个字符。
  • 令牌化: 浏览器将字符串转换成 W3C HTML5 标准规定的各种令牌,例如,<html><body>,以及其他尖括号内的字符串。每个令牌都具有特殊含义和一组规则。
  • 词法分析: 发出的令牌转换成定义其属性和规则的“对象”。
  • DOM 构建: 最后,由于 HTML 标记定义不同标记之间的关系(一些标记包含在其他标记内),创建的对象链接在一个树数据结构内,此结构也会捕获原始标记中定义的父项-子项关系: HTML 对象是 body 对象的父项,body 是 paragraph 对象的父项,依此类推。

打开控制台(F12)在console内输入document,如图:

在这里插入图片描述

整个流程的最终输出是我们这个简单页面的文档对象模型 (DOM),浏览器对页面进行的所有进一步处理都会用到它。

浏览器每次处理 HTML 标记时,都会完成以上所有步骤: 将字节转换成字符,确定令牌,将令牌转换成节点,然后构建 DOM 树。这整个流程可能需要一些时间才能完成,有大量 HTML 需要处理时更是如此。

DOM 树捕获文档标记的属性和关系,但并未告诉我们元素在渲染后呈现的外观。那是 CSSOM 的责任。

# 1.1.2 CSSOM(CSS 对象模型)

在浏览器构建我们这个简单页面的 DOM 时,在文档的head部分遇到了一个link标记,该标记引用一个外部 CSS 样式表: style.css。由于预见到需要利用该资源来渲染页面,它会立即发出对该资源的请求,并返回以下内容:

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

与处理 HTML 时一样,我们需要将收到的 CSS 规则转换成某种浏览器能够理解和处理的东西。因此,我们会重复 HTML 过程,不过是为 CSS 而不是 HTML:

在这里插入图片描述

我打开知乎,搜索 111,如图:

在这里插入图片描述

我们的样式表需要大约 5.8 毫秒的处理时间,影响页面上的 24 个元素,开销中等。不过这24个元素从何而来呢?让我们谈一谈将 DOMCSSOM 关联在一起的渲染树。

# 1.2 生成 Render 树

CSSOM树DOM树合并成渲染树,然后用于计算每个可见元素的布局,并输出给绘制流程,将像素渲染到屏幕上。优化上述每一个步骤对实现最佳渲染性能至关重要。

一个描述内容,另一个则是描述需要对文档应用的样式规则。我们该如何将两者合并,让浏览器在屏幕上渲染像素呢?

  • DOM 树与 CSSOM 树合并后形成渲染树。
  • 渲染树只包含渲染网页所需的节点。
  • 布局计算每个对象的精确位置和大小。
  • 最后一步是绘制,使用最终渲染树将像素渲染到屏幕上。

浏览器将 DOM 和 CSSOM 合并成一个“渲染树”,网罗网页上所有可见的 DOM 内容,以及每个节点的所有 CSSOM 样式信息。

在这里插入图片描述

  1. renderer 与 DOM 元素是相对应的,但并不是一一对应,有些 DOM 元素没有对应的 renderer,而有些 DOM 元素却对应了好几个 renderer
  2. 某些节点通过 CSS 隐藏,因此在渲染树中也会被忽略,例如,上例中的 span 节点---不会出现在渲染树中,---因为有一个显式规则在该节点上设置了“display: none”属性。
  3. 请注意 visibility: hiddendisplay: none 是不一样的。前者隐藏元素,但元素仍占据着布局空间(即将其渲染成一个空框),而后者 (display: none) 将元素从渲染树中完全移除,元素既不可见,也不是布局的组成部分。

# 1.3 布局(layout)

最终输出的渲染同时包含了屏幕上的所有可见内容及其样式信息。有了渲染树,我们就可以进入“布局”阶段。

到目前为止,我们计算了哪些节点应该是可见的以及它们的计算样式,但我们尚未计算它们在设备视口内的确切位置和大小---这就是“布局”阶段,也称为“自动重排”。

为弄清每个对象在网页上的确切大小和位置,浏览器从渲染树的根节点开始进行遍历。让我们考虑下面这样一个简单的实例:

  1. 结合设备的屏幕信息,计算每个元素的位置和尺寸(由此可知 css 中定义的量,未必是实际使用的量)
  2. 以浏览器可见区域为画布,左上角为 (0,0)基础坐标,从左到右,从上到下从 DOM 的根节点开始画
  3. 最后布局输出的结果就是盒模型,他会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都转换为屏幕上的绝对像素。

在这里插入图片描述

再来看看刷新本篇文章页面,布局花了多久时间:

在这里插入图片描述

# 1.4 绘制(paint)

其实到这里,基本的渲染过程就已经结束了

布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。

栅格化

我们知道了哪些节点可见、它们的计算样式以及几何信息,我们终于可以将这些信息传递给最后一个阶段: 将渲染树中的每个节点转换成屏幕上的实际像素。这一步通常称为“绘制”或“栅格化”。

# 1.5 小结

我们按顺序概述下浏览器渲染的基本步骤:

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,以计算每个节点的几何信息。
  5. 将各个节点绘制到屏幕上。

上面的步骤,又被称为关键渲染路径

# 二、CSS 会阻塞渲染吗?

CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。

默认情况下,CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕。所以:

  1. 请务必精简您的 CSS
  2. 尽快提供它
  3. 利用媒体类型和查询来解除对渲染的阻塞。

在渲染树构建中,我们看到关键渲染路径要求我们同时具有 DOM 和 CSSOM 才能构建渲染树。这会给性能造成严重影响: HTML 和 CSS 都是阻塞渲染的资源。 HTML 显然是必需的,因为如果没有 DOM,我们就没有可渲染的内容,但 CSS 的必要性可能就不太明显。如果我们在 CSS 不阻塞渲染的情况下尝试渲染一个普通网页会怎样?

在这里插入图片描述

上例展示了知乎在移除 CSS 的显示效果,它证明了为何要在CSS准备就绪之前阻塞渲染,---没有 CSS 的网页实际上无法使用。浏览器将阻塞渲染,直至 DOM 和 CSSOM 全都准备就绪。

不过,如果我们有一些 CSS 样式只在特定条件下(例如显示网页或将网页投影到大型显示器上时)使用,又该如何?如果这些资源不阻塞渲染,该有多好。

让我们考虑下面这些实例:

<link href="style.css" rel="stylesheet" />
<link href="style.css" rel="stylesheet" media="all" />
<link href="portrait.css" rel="stylesheet" media="orientation:portrait" />
<link href="print.css" rel="stylesheet" media="print" />
  1. 第一个声明未提供任何媒体类型或查询,因此它适用于所有情况,它始终会阻塞渲染。
  2. 第二个声明同样阻塞渲染: “all”是默认类型,如果您不指定任何类型,则隐式设置为“all”。因此,第一个声明和第二个声明实际上是等效的。
  3. 第三个声明具有动态媒体查询,将在网页加载时计算。根据网页加载时设备的方向,portrait.css 可能阻塞渲染,也可能不阻塞渲染。
  4. 最后一个声明只在打印网页时应用,因此网页首次在浏览器中加载时,它不会阻塞渲染。

最后,请注意“阻塞渲染”仅是指浏览器是否需要暂停网页的首次渲染,直至该资源准备就绪。无论哪一种情况,浏览器仍会下载 CSS 资产,只不过不阻塞渲染的资源优先级较低罢了。

# 三、JavaScript 对渲染的影响

JavaScript 允许我们修改网页的方方面面。不过,JavaScript 也会阻止DOM构建和延缓网页渲染。为了实现最佳性能,可以让您的JavaScript异步执行,并去除关键渲染路径中任何不必要的 JavaScript。

先来看看 Js 在渲染相关的特点:

  • JavaScript 可以查询和修改 DOM 与 CSSOM。
  • JavaScript 执行会阻止 CSSOM。
  • 除非将 JavaScript 显式声明为异步,否则它会阻止构建 DOM。

# 3.1 js 可能会延缓首次渲染

执行我们的内联脚本会阻止 DOM 构建,也就延缓了首次渲染

我们看下面的例子:

<body>
  <script>
    var span = document.getElementsByTagName("span")[0]; //
    console.log("span:", span); // undefined
  </script>
  <p>
    Hello
    <span>web performance</span>
    performance
  </p>
</body>

打开控制台,你会发现输出了span:undefined,这是因为页面的渲染因为script脚本暂停了,自然也就获取不到script标签之后的 dom 节点

将 span 元素的 display 属性从 none 更改为 inline。最终结果如何?我们现在遇到了竞态问题。

如果浏览器尚未完成CSSOM的下载和构建,而我们却想在此时运行脚本,会怎样?答案很简单,对性能不利: 浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建。

此时我们有了新的结论:

  • 脚本在文档中的位置很重要。
  • 当浏览器遇到一个script标记时,DOM构建将暂停,直至脚本完成执行。
  • JavaScript执行将暂停,直至CSSOM就绪
  • DOM 解析、JS 执行、CSSOM 之间存在一定的关系影响

# 3.2 js 会阻止解析器

默认情况下,JavaScript 执行会“阻止解析器”,由于浏览器不了解脚本计划在页面上执行什么操作,它会作最坏的假设并阻止解析器。向浏览器传递脚本不需要在引用位置执行的信号既可以让浏览器继续构建 DOM,也能够让脚本在就绪后执行;例如,在从缓存或远程服务器获取文件后执行。

为此,我们可以将脚本标记为异步:

<body>
  <div><img src="awesome-photo.jpg" /></div>
  <script src="app.js" async></script>
</body>

# 3.3 小节

  • 执行我们的内联脚本会阻止DOM构建,也就延缓了首次渲染。这也是为什么 script 标签要放在页面的底部;
  • 浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建。
  • 脚本在文档中的位置很重要。
  • 当浏览器遇到一个script标记时,DOM构建将暂停,直至脚本完成执行。
  • JavaScript执行将暂停,直至CSSOM就绪
  • DOM 解析、JS 执行、CSSOM 之间存在一定的关系影响

# 四、回流与重绘

回流和重绘其实就发生在浏览器渲染的某一个阶段,了解他将有助于

# 4.1 回流(reflow)——重排

回流:布局或者几何属性需要改变就称为回流。回流是影响浏览器性能的关键因素,因为其变化涉及到部分页面(或是整个页面)的布局更新。一个元素的回流可能会导致了其所有子元素以及 DOM 中紧随其后的节点、祖先节点元素的随后的回流。比如:

  • 折叠列表
  • 自适应输入框

回流必定会发生重绘,重绘不一定会引发回流。

# 4.2 重绘(repaint)

由于节点的样式发生了但未影响到几何属性,对于这样的变化,浏览器不需重新计算元素的几何属性,可以直接为元素绘制新的样式,称为重绘,例如:

  • color
  • background-color
  • border-color

改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。

# 4.3 浏览器的优化机制

现代浏览器大多都是通过队列机制来批量更新布局,浏览器会把修改操作放在队列中,至少一个浏览器刷新(即 16.6ms)才会清空队列,但当你获取布局信息的时候,队列中可能有会影响这些属性或方法返回值的操作,即使没有,浏览器也会强制清空队列,触发回流与重绘来确保返回正确的值。

主要包括以下属性或方法:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • width、height
  • getComputedStyle()
  • getBoundingClientRect()

所以,我们应该避免频繁的使用上述的属性,他们都会强制渲染刷新队列。

# 4.4 减少重绘和回流(重排)

CSS

  • 使用 transform 替代 top
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局
  • 避免使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局。
  • 尽可能在 DOM 树的最末端改变 class,回流是不可避免的,但可以减少其影响。尽可能在 DOM 树的最末端改变 class,可以限制了回流的范围,使其影响尽可能少的节点。
  • 避免设置多层内联样式,CSS 选择符从右往左匹配查找,避免节点层级过多。
span {
  color: red;
}
div > a > span {
  color: red;
}
  • 对于第一种设置样式的方式来说,浏览器只需要找到页面中所有的span标签设置颜色,但是对于第二种设置样式的方式来说,浏览器需要一层一层筛选,这样的递归过程就很复杂。所以我们应该尽量保证层级扁平。
  • 将动画效果应用在position:absolute | fixed的元素上,避免影响其他元素的布局,这样只会引发一个重绘,而不是回流。
  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点,例如 will-change、video、iframe 等标签,浏览器会自动将该节点变为图层。
  • CSS3硬件加速(GPU 加速),使用 css3硬件加速,可以让 transform、opacity、filters 这些动画不会引起回流重绘 。但是对于动画的其它属性,比如 background-color 这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

JavaScript

  • 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次性更改;
  • 避免频繁操作 DOM,创建一个documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中。
  • 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
  • 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。

# 写在最后

尝试走进浏览器的大门,你会对自己天天要调试的页面(浏览器)变得越来越亲切,也许有一天你会深耕在浏览器领域,希望而这一切的起因都是因为这个浏览器系列,来自《余光前端笔记》 (opens new window)

参考

关于我

  • 花名:余光
  • WX:j565017805
  • 邮箱:webbj97@163.com

其他沉淀

Last Updated: 2/14/2023, 2:43:41 PM