浏览器的渲染

浏览器的渲染,就是将(字符串形式)HTML代码变成页面上的像素信息

1
2
3
4
5
function render(html: string) {
// 一系列操作……
// 得到屏幕每个像素点的像素信息,交给GPU
return pixels
}

何时开始渲染

网络进程中有一个线程,会通过网络通信得到HTML(CSS和JS已通过<link><script>包含在内),再将其作为任务推入消息队列,随后渲染主线程将其渲染

渲染流水线

渲染需经过多步,它们有明确的输入输出,每一步的输出都会成为下一步的输入,俨然一套流水线

渲染流程

这是一整个渲染流程

解析HTML ParseHTML

字符串难以操作,而对象易于操作,所以要将HTML字符串转化成两棵树:DOM树和CSSOM树,以便后续流程进行

使用JS直接修改CSS样式表而非element.style是可能的

渲染主线程是很忙的,为了提高解析效率,浏览器会启动一个预解析器(一个线程)预先下载和解析CSS

预解析线程也可以分担一点下载JS的任务,但渲染主线程遇到JS时必须暂停一切行为,等待JS下载执行完后才能继续解析,因为JS可能会修改当前DOM树

渲染这些元素时会以从父到子/从根到叶的顺序渲染

样式计算Recalculate Style

根据上一步生成的DOM树和CSSOM树,计算样式,需要得到带有样式的DOM树,其中每个节点都具有最终样式(Computed Style,计算后的样式)信息

最终样式并非是所有样式表给某一元素设置的所有规则,而是该元素所有样式的取值,在JS中可以使用getCoumputedStyle()获取

层叠和继承就是在此处计算的,(部分)相对单位、预设值等也会被转化成绝对单位

最终CSS样式的计算顺序:

  1. 根据样式声明(样式表)赋予无冲突的样式值
  2. 对于存在冲突的规则,根据层叠计算(权重)的结果赋予样式值
    1. 比较重要性(自定义样式表>浏览器默认样式表)
    2. 比较特殊性(根据选择器权重值判断更特殊的规则)
    3. 比较源次序(后面定义的规则>之前定义的规则)
  3. 对于尚未被赋值的样式,如果该样式能继承,则从父元素那里继承样式
  4. 如果都没有,则赋予默认值

布局Layout

根据上一步生成的带样式的DOM树,计算各个元素的几何信息,例如位置和尺寸

元素间的位置和尺寸会相互影响,再加上浏览器窗口大小、元素采用的CSS视觉格式化模型规则、等等等影响,这内部的计算过程很复杂,很耗时,但它的计算结果很简单,是一个记录元素尺寸和位置的布局(Layout)树

尺寸就是元素的宽高,有一些长度单位无法在上一步计算宽高(例如百分比、auto),就会在这一步计算;而位置是元素相对包含块的位置

DOM树和Layout树不一定是对应的,例如:

Layout树中的节点不是DOM对象,而是底层C++中的对象,JS无法直接获取,但是可以通过一些API间接获取,例如clientWidth

分层Layer

根据上一步生成的Layout树,将整个页面分出一些层次,这样某一层的改动不会影响到其他层次,只需要重新绘制该层即可。

浏览器不会为页面分太多层次,因为每一个层次都需要占用内存,所以浏览器在分层时,需要在页面规模、节省内存和方便重绘中权衡,不同浏览器对此的策略不一样

层叠上下文有关的CSS属性也会影响(不是决定)浏览器的分层决策

可以使用will-change: transform标记某一元素可能会经常发生变化,但分层的最终决定权仍然在浏览器,用户无法直接操控分层

绘制Paint

根据上一步生成的分层信息为每一层单独生成绘制指令集

绘制指令集,记录的是如何画,这并不是真的画了出来,例如:

  1. 将画笔移动到(0,0)

  2. 画一个200x200的矩形

  3. 用绿色填充矩形

会发现canvas与这些绘制指令相似,其实它就是在调用浏览器内核的绘制功能

渲染主线程的工作到此为止,剩余步骤会交给其他线程

分块Tiling

根据上一步生成的绘制指令集,将每一层分为多个小区域

一层中需要绘制的内容有很多,分块便于决定绘制优先级,例如优先绘制视口区域内的块

渲染主线程会把上一步的绘制指令集交给合成线程,而合成线程会将分块工作交给多个线程,提高效率

光栅化Raster

根据上一步生成的分块信息,将每个块变成位图,靠近视口的块会被优先处理

位图是由像素点组成的图像,每个像素点存储着颜色信息,Raster graphics - Wikipedia

这个过程会用到GPU加速,合成线程会把工作交给浏览器的GPU进程

画Draw

合成线程在上一步生成的位图信息基础上,进一步计算出了每个位图在屏幕上的位置,并交给GPU进行最终呈现

GPU(图形处理单元,简称显卡)擅长图像操作,常用于执行绘图运算Graphics processing unit - Wikipedia

渲染主线程和合成线程所属的渲染进程位于沙盒里,沙盒与操作系统硬件隔离,这么做是为了安全,防止恶意浏览器插件,但这样一来它也无法进行系统调用(调用操作系统的接口)绘制位图,所以需要浏览器的GPU进程中转

概念

重新布局Reflow

当JS修改CSSOM中一些与几何信息相关的属性,或者改变DOM结构时,页面的布局信息需要改变,需要一个新的Layout树,这就会启动一系列样式重新计算

这其中涉及这么多步骤,尤其是布局layout比较复杂,对性能的影响比较大,所以编码时应该尽量少进行影响布局的操作

作为优化,浏览器会避免连续多次操作导致的Layout树反复计算:将这些操作合并成任务推入队列,当JS代码全部完成后再进行统一计算

所以改动属性造成的reflow是异步完成的,那是不是说明,JS可能无法获取最新的布局信息?

1
2
3
4
5
6
7
// 修改了这么多,但布局树只会重新计算一次
div.style.width = "100px"
div.style.height = "100px"
div.style.padding = "10px"
div.style.margin = "10px"
// 如果按这个逻辑,这里是不是只能读取到旧信息?
console.log(div.clientWidth)

为了解决这一问题,当JS代码读取布局信息时,会产生一个同步任务:先reflow完成再执行接下来的JS代码

总结:设置不会立即导致reflow,读取会立即导致reflow

如果需要强制进行重新布局,就可以当场抓个幸运元素读取一下它的布局信息

重绘Repaint

当JS修改一些与几何信息无关的可见属性,例如颜色时,就会进行重绘,样式计算结束后可以直接跳到绘制paint步骤

不需要执行布局layout步骤,大部分情况下也不需要执行分层layer步骤

reflow一定会引发repaint,因为元素的布局信息也属于可见样式

transform

CSS transform属性效率高,本质是因为它在渲染的最后一个步骤,画draw这一步实现

合成线程给GPU进程的指引信息quad不仅指引绘制位置,还会考虑transform变形。它不在主线程进行,且不涉及前面复杂的步骤,所以效率才会这么高

无论渲染主线程如何忙碌,都不会影响transform

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<style>
* {
margin: 0;
}

.ball {
background-color: #bfc;
width: 50px;
height: 50px;
border-radius: 50%;
margin: 20px;
}

.ball1 {
animation: move1 1s alternate infinite ease-in-out;
}

.ball2 {
position: fixed;
left: 0;
animation: move2 1s alternate infinite ease-in-out;
}

@keyframes move1 {
to {
/* 压根和渲染主线程没关系 */
transform: translate(100px);
}
}

@keyframes move2 {
to {
/* 布局改变,会引起reflow */
left: 100px
}
}
</style>
</head>
<body>
<button>开始死循环</button>
<!-- 当开始死循环时,这个小球不会卡死 -->
<div class="ball ball1"></div>
<!-- 但这个小球会 -->
<div class="ball ball2"></div>
<script>
const delay = (duration)=> {
const start = Date.now()
while(Date.now()-start<duration){}
}
const btn = document.querySelector('button')
btn.onclick = ()=> {
// 让页面卡死五秒
delay(5000)
}
</script>
</body>
</html>
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
<!DOCTYPE html>
<html lang="en">

<head>
<title>Document</title>
</head>

<body>
<button>开始死循环</button>
<!--
即使死循环也不会影响页面的滚动
因为滚动后产生的视口内容变化无关布局和绘制等
它只会发生在draw这一步,这和CSS transform一样
-->
<p>(总之往里面塞一堆巨长的文字)</p>
<script>
const delay = (duration) => {
const start = Date.now()
while (Date.now() - start < duration) { }
}
const btn = document.querySelector('button')
btn.onclick = () => {
// 让页面卡死五秒
delay(5000)
}
</script>
</body>

</html>