渲染引擎的工作原理


# 渲染引擎的工作原理

这个系列的第一篇文章中对「浏览器从输入 URL 到页面展示」的整个流程做了小结,但其实最后一步在获得到 HTML、CSS 和 JavaScript 文件后,对渲染进程做的工作只是简单概括了一下,这里单独写一篇作为细节扩充。

# HTML、CSS 和 JavaScript 是如何变成页面的

渲染引擎的工作过程相当复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。其大致流程如下图所示:

简单的渲染流程示意图

(简单的渲染流程示意图,图来源于网络)

按照渲染的时间顺序,整个渲染流程可分为如下几个子阶段:构建 DOM 树、样式计算、布局、分层、图层绘制、栅格化、合成和显示。为了方便记忆,每个子阶段都应该重点关注其输入的内容处理过程输出内容

# 流程小结

大体的渲染流程还是复制之前一篇文章里归纳的 8 个步骤,这里在每个步骤之下补充一些细节知识点:

  • 构建 DOM 树:通过 HTML 解析器将 HTML 转换成 DOM 树。
  • 样式计算:渲染引擎将 CSS 文本转换为 styleSheets,计算出 DOM 节点的样式。
    • 转成 styleSheets 后,会先进行属性值的标准化操作,再根据 CSS 的继承规则和层叠规则计算每个节点的具体样式。
  • 布局:创建布局树(只包含可见元素的树),并计算元素的布局信息。
    • 布局是为了计算 DOM 树中可见元素的几何位置,因为前一步只是获得了元素样式,并不知道它应该放哪。
    • 布局树会忽略不可见节点,比如 head 标签,和使用了 display:none 属性的元素等。
    • 目前的布局操作中,布局树既是输入内容也是输出内容,Chrome 团队为了分离输入和输出,正在重构布局代码,下一代布局系统叫 LayoutNG。
  • 分层:对布局树进行分层,并生成分层树。
    • 分层是为了实现页面中一些复杂的效果:3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等。
    • 如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。
    • 拥有层叠上下文属性的元素(比如定位属性元素、透明属性元素、CSS 滤镜属性元素)提升为单独的一层,需要裁剪(clip)的地方也会被创建为图层。
  • 图层绘制:为每个图层生成绘制列表,并将其提交到合成线程。
    • 把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。(就跟绘画一样,先画什么,再画什么……)
  • 栅格化:合成线程将图层分成图块,通过栅格化把图块转换为位图。
    • 为了提高性能,即不绘制视口(viewport)以外的图层,合成线程会将图层划分为图块。
    • 图块是栅格化执行的最小单位,合成线程会按照视口附近的图块来优先生成位图。
    • 通常栅格化过程都会使用 GPU 来加速生成,这是一种跨进程操作(GPU 操作是运行在 GPU 进程中)。
  • 合成:所有图块都被光栅化后,渲染引擎中的合成线程发送一个绘制图块的命令「DrawQuad」给浏览器进程。
  • 显示:浏览器进程根据接收到的「DrawQuad」消息绘制页面,并显示到显示器上。

# 完整流程示意图

下图是一张李兵老师梳理的「完整的渲染流水线示意图」:

完整的渲染流水线示意图

(完整的渲染流水线示意图,图来源于网络)

# 重排、重绘、和合成

这是三个和渲染流水线相关的概念 —— 「重排」、「重绘」和「合成」,这三个概念与 Web 的性能优化有关。

# 重排:更新了元素的几何属性

  • 如果修改了元素的几何属性(元素的位置和尺寸大小),就会触发重新布局、解析之后的一系列子阶段。
  • 重排需要更新完整的渲染流水线,所以开销也是最大的。
  • 常见的引起重排的操作:
    • 添加或者删除可见的 DOM 元素。
    • 元素尺寸改变 —— 边距、填充、边框、宽度和高度。
    • 内容变化,比如用户在 input 框中输入文字。
    • 浏览器窗口尺寸改变 —— resize 事件发生时。
    • 计算 offsetWidthoffsetHeight 属性。
    • 设置 style 属性的值。

常见的引起重排的属性和方法:

width height margin padding
display border position overflow
clientWidth clientHeight clientTop clientLeft
offsetWidth offsetHeight offsetTop offsetLeft
scrollWidth scrollHeight scrollTop scrollLeft
scrollIntoView() scrollTo() getComputedStyle()
getBoundingClientRect() scrollIntoViewIfNeeded()

# 重绘:更新元素的绘制属性

  • 如果修改了元素的外观属性(颜色、背景、边框等),就会直接进入了绘制阶段,然后执行之后的一系列子阶段。
  • 因为几何位置没有变换,所以布局阶段不会被执行,省去了布局和分层阶段,所以执行效率会比重排操作要高一些。

常见的引起重绘的属性:

color border-style visibility background
text-decoration background-image background-position background-repeat
outline-color outline outline-style border-radius
outline-width box-shadow background-size

# 合成:直接合成阶段

  • 如果更改的是一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作。
  • 例如:使用 CSS3 的 transform 来实现动画效果,比使用 JS 修改 style 高效。

# 优化策略:减少重排和重绘

# 1.对 dom 属性的读写操作要分离

// 这段代码会触发 4 次重排 + 重绘
// 因为在 console 中用到的这几个属性虽然跟操作修改的值没关联, 
// 但浏览器为了给我们最精确的值, 会立即重排 + 重绘.
// 
// 这种会强制刷新渲染队列并触发立即重排的属性还有:
// offsetTop, offsetLeft, offsetWidth, offsetHeight
// scrollTop, scrollLeft, scrollWidth, scrollHeight
// clientTop, clientLeft, clientWidth, clientHeight
// getComputedStyle(), 或者 IE 的 currentStyle
div.style.left = '10px';
console.log(div.offsetLeft);
div.style.top = '10px';
console.log(div.offsetTop);
div.style.width = '20px';
console.log(div.offsetWidth);
div.style.height = '20px';
console.log(div.offsetHeight);

// 这段代码只会触发 1 次重排 + 重绘
// 因为操作值的时候, 只有等到渲染队列中到了一定大小, 或一定时间间隔后, 浏览器才会批量执行这些操作;
// 而读取值的时候, 因为渲染队列本来就是空的, 所以并没有触发重排, 仅仅拿值而已.
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
console.log(div.offsetLeft);
console.log(div.offsetTop);
console.log(div.offsetWidth);
console.log(div.offsetHeight);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 2.使用 class 操作样式,而不是频繁操作 style

虽然现在大部分浏览器有渲染队列优化,不排除有些浏览器以及老版本的浏览器效率仍然低下,所以建议通过改变 class 或者 cssText 属性集中改变样式。

// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top  = top  + "px";

// good 
el.className += " theclassname";
// good
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
1
2
3
4
5
6
7
8
9
10

# 3.缓存布局信息

// bad 强制刷新, 触发两次重排
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';

// good 缓存布局信息, 相当于读写分离
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';
1
2
3
4
5
6
7
8
9

# 4.离线改变 DOM

  • 隐藏要操作的 DOM
    在要操作 DOM 之前,通过 display 隐藏 DOM ,当操作完成之后(比如修改 100 次后),才将元素的 display 属性设置为可见,因为不可见的元素不会触发重排和重绘。
    dom.display = 'none'
    // 修改 DOM 样式
    dom.display = 'block'
    
    1
    2
    3
  • 通过使用 DocumentFragment (opens new window) 创建一个 DOM 碎片,在它上面批量操作 DOM,操作完成之后,再添加到文档中,这样只会触发一次重排。
  • 复制节点,在副本上工作,然后替换它。
  • 使用现代化框架,例如 Vue、React。

# 5.position 属性为 absolute 或 fixed

position 属性为 absolute 或 fixed 的元素,重排开销比较小,不用考虑它对其他元素的影响。

# 6.优化动画

  • 可以把动画效果应用到 position 属性为 absolute 或 fixed 的元素上,这样对其他元素影响较小。
    • 动画效果还应牺牲一些平滑,来换取速度,这中间的度自己衡量:比如实现一个动画,以 1 个像素为单位移动这样最平滑,但是 reflow 就会过于频繁,大量消耗 CPU 资源,如果以 3 个像素为单位移动则会好很多。
  • 启用 GPU 硬件加速
    • GPU 硬件加速是指应用 GPU 的图形性能对浏览器中的一些图形操作交给 GPU 来完成,因为 GPU 是专门为处理图形而设计,所以它在速度和能耗上更有效率。
    • GPU 加速通常包括以下几个部分:Canvas2D,布局合成,CSS3转换(transitions),CSS3 3D 变换(transforms),WebGL 和视频(video)。
    /*
     * 根据上面的结论
     * 将 2d transform 换成 3d
     * 就可以强制开启 GPU 加速
     * 提高动画性能
     */
    div {
      transform: translate(10px, 10px);
    }
    div {
      transform: translate3d(10px, 10px, 0);
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

# 7.对 window resize 事件做防抖处理

这个延迟时间视场合而定。

# 8.不要使用 table 布局

因为 table 中某个元素一旦触发了重排,那么整个 table 的元素都会触发重排。在不得已使用 table 的场合,可以设置 table-layout: auto; 或者是 table-layout:fixed 这样可以让 table 一行一行的渲染,这种做法也是为了限制重排的影响范围。

# 9.尽可能不要修改影响范围比较大的 DOM

尽可能限制重排的影响范围,尽可能在低层级的 DOM 节点上作修改。比如要改变 p 标签的样式,class 就不要加在它上层的 div 标签上,通过父元素去影响子元素不好。

# 10.慎用 CSS 表达式

如果 CSS 里面有计算表达式,每次都会重新计算一遍,都会触发一次重排。

(完)