0%

EventLoop规范 - 更新渲染(Update the rendering)的时机验证

event loop 中的 Update the rendering(更新渲染)

这是 event loop 中很重要部分,在这篇文章处理流程(processing model)中第 3 步会进行 Update the rendering(更新渲染),规范允许浏览器自己选择是否更新视图。也就是说可能不是每轮事件循环都去更新视图,只在有必要的时候才更新视图。

渲染的基本流程:

alt text

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

Note: 可以看到渲染树的一个重要组成部分是 CSSOM 树,绘制会等待 css 样式全部加载完成才进行,所以 css 样式加载的快慢是首屏呈现快慢的关键点。

下面讨论一下渲染的时机。规范定义在一次循环中,Update the rendering 会在 Microtasks: Perform a microtask checkpoint 后运行。

渲染时机

以下的例子中,用 chrome 的 Developer tools 的 Timeline 查看各部分运行的时间点。当我们点击这个 div 的时候,截取了部分时间线。

黄色部分是脚本运行,紫色部分是更新 render 树、计算布局,绿色部分是绘制。

绿色和紫色部分可以认为是 Update the rendering。

例子 1

1
2
3
4
5
6
7
8
9
10
<div id="con">this is con</div>
<script>
var t = 0
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = t
}, 0)
}
</script>

alt text

在这一轮事件循环中,setTimeout1 是作为 task 运行的,可以看到 paint 确实是在 task 运行完后才进行的。

例子 2

现在换成一个 microtask 任务,看看有什么变化

1
2
3
4
5
6
7
8
9
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
Promise.resolve().then(function Promise1() {
con.textContext = 0
})
}
</script>

alt text

和上一个例子很像,不同的是这一轮事件循环的 task 是 click 的回调函数,Promise1 则是 microtask,paint 同样是在他们之后完成。

标准就是那么定义的,答案似乎显而易见,我们把例子变得稍微复杂一些。

例子 3

1
2
3
4
5
6
7
8
9
10
11
12
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function click1() {
setTimeout(function setTimeout1() {
con.textContent = 0
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1
}, 0)
}
</script>

alt text
alt text

经过多次测试,执行和渲染顺序可能会出现上图的两种情况,根据 timeline 可以看出,图 1 中 setTimeout 分别执行并且浏览器绘制了两次,图二中 setTimeout1 和 setTimeout2 中间并没有绘制, 而是最后绘制了一次,也基本符合规范,但是需要验证这两次 setTimeout 是否在两次 task 中。

例子 4

在两个 setTimeout 中增加 microtask。再次确认 setTimeout 在两个 task 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = 0
Promise.resolve().then(function Promise1() {
console.log('Promise1')
})
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1
Promise.resolve().then(function Promise2() {
console.log('Promise2')
})
}, 0)
}
</script>

alt text
alt text

从 run microtasks 中可以看出来,setTimeout1、setTimeout2 是运行在两次 event loop 中

例子 5

将时间间隔加大一些。

1
2
3
4
5
6
7
8
9
10
11
12
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function click1() {
setTimeout(function setTimeout1() {
con.textContent = 0
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1
}, 17)
}
</script>

当间隔增大,大部分时候可以肉眼从看到先变成 0,再变成 1 的过程。但是有时也是会合并起来,只 paint 一次

alt text

通过 timeline,可以看到 setTimeout1 后接着 paint,后执行了 setTimeout2 后也有 paint

例子 6

我们在同一时间执行多个 setTimeout 来模拟执行间隔很短的 task。

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
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function () {
con.textContent = 0
}, 0)
setTimeout(function () {
con.textContent = 1
}, 0)
setTimeout(function () {
con.textContent = 2
}, 0)
setTimeout(function () {
con.textContent = 3
}, 0)
setTimeout(function () {
con.textContent = 4
}, 0)
setTimeout(function () {
con.textContent = 5
}, 0)
setTimeout(function () {
con.textContent = 6
}, 0)
}
</script>

alt text
图一中总共 paint 了一次

alt text

图二中一共 paint 了两次,所以多次 task 的间隔很短,仍会进行绘制。

例子 7

有说法是一轮 event loop 执行的 microtask 有数量限制,多余的 microtask 会放到下一轮执行。下面例子将 microtask 的数量增加到 25000。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = 'task1'
for (var i = 0; i < 250000; i++) {
Promise.resolve().then(function () {
con.textContent = i
})
}
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 'task2'
}, 0)
}
</script>

alt text

可以看到脚本的运行耗费大量的时间,并且阻塞了渲染。

我们看 setTimeout2 的运行情况

alt text

可以看到 setTimeout2 这轮 event loop 没有 run microtasks,microtasks 在 setTimeout1 被全部执行完了。

25000 个 microtasks 不能说明 event loop 对 microtasks 数量没有限制,有可能这个限制数很高,远超 25000,但日常使用基本不会使用那么多了。

对 microtasks 增加数量限制,一个很大的作用是防止脚本运行时间过长,阻塞渲染。

例子 8

使用 requestAnimationFrame。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
var i = 0
var raf = function () {
requestAnimationFrame(function () {
con.textContent = i
Promise.resolve().then(function () {
i++
if (i < 3) raf()
})
})
}
con.onclick = function () {
raf()
}
</script>

看下总体的 timeline
alt text

单看某一个 requestAnimationFrame
alt text

  • 可以看出 requestAnimationFrame 是在更新渲染阶段的执行的,其执行顺序早于 paint,并且不会被合并执行,非常适合做动画
  • 在 requestAnimationFrame 回调内有新的 microtasks 进入时,也会执行完所有的 microtasks 才会进入到渲染阶段

例子 9

验证 postMessage 是否是 task

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script>
setTimeout(function setTimeout1() {
console.log('setTimeout1')
}, 0)
var channel = new MessageChannel()
channel.port1.onmessage = function onmessage1() {
console.log('postMessage')
Promise.resolve().then(function promise1() {
console.log('promise1')
})
}
channel.port2.postMessage(0)
setTimeout(function setTimeout2() {
console.log('setTimeout2')
}, 0)
console.log('sync')
</script>

执行顺序:

1
2
3
4
5
sync
setTimeout1
postMessage
promise1
setTimeout2

alt text

第一个黄块是 setTimeout1,第二个是 onmessage1,第三个是 promise1,第四个是 setTimeout2。显而易见,postMessage 属于 task。

总结

结合规范和以上验证案例可以得出一些结论:

  • event loop 的大致循环过程,可以用下边的图表示:

alt text

  • 在一轮 event loop 中多次修改同一 dom,只有最后一次会进行绘制。
  • 例 3-例 6 这几个结果是非常不可控的,如果这两个 Task 之间正好遇到了浏览器认定的渲染机会,那么它会重绘,否则就不会。
  • 渲染更新(Update the rendering)会在 event loop 中的 tasks 和 microtasks 完成后进行,但并不是每轮 event loop 都会更新渲染,这里有一个 rendering opportunity 的概念,判断是否需要渲染,这取决于是否修改了 dom 和浏览器觉得是否有必要在此时立即将新状态呈现给用户,也要要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定。通常来说这个渲染间隔是固定的。(所以多个 task 很可能在一次渲染之间执行)
  • 事件循环不一定每轮都伴随着重渲染,但是如果有微任务,一定会伴随着微任务执行。
  • 如果希望在每轮 event loop 都即时呈现变动,可以使用 requestAnimationFrame。