使用 Generator 和 requestIdleCallback 实现简单的时间分片

知识准备

  1. 如果不熟悉 generator 先去看下文档
    https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator
    https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield
    https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield*

  2. requestIdleCallback
    https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
    了解过 react16 任务调度的同学对这个 api 应该不会陌生

开搞

如果没空看文档,这里就简单说一下前提:

  • 在 generator 函数里面,你可以使用 yield 进行“暂停”
  • requestIdleCallback 提供了在一帧的空闲时间内执行所给的回调的能力,也就是执行回调的控制权交回给浏览器结合以上两点,我们理论上可以对现有的长耗时任务进行时间分片,使得任务执行时不“阻塞”主进程。

一个简单的时间分片函数如下:

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
// 接受一个 generator 函数
function timeSlicing(gen) {
// 生成 generator
gen = gen();
// deadline 为 传进 requestIdleCallback 的回调自带的参数
return function next(idleDeadline) {
// 剩余的 generator 可迭代部分
let res = null;
// generator 的未迭代完,并且这一帧的空闲时间大于 0
// 也就是说单帧的空闲时间为可以执行多个任务
do {
// 迭代一次
// 可以理解为 执行一个任务
res = gen.next();
} while(
!res.done &&
idleDeadline.timeRemaining() > 0
);

// generator 已经迭代完了,所有分割的任务都完成了
// 退出
if (res.done) {
return;
}

// 将剩余的任务放在下一次 idle 执行
requestIdleCallback(next);
}
}

具体代码可以查看 github

一个简单的例子

现在网页上有个 button

1
2
3
4
5
6
7
8
9
10
11
12
const button = document.querySelector('#button');

document.addEventListener('mousemove', ({ pageX, pageY }) => {
button.style.top = `${pageY}px`;
button.style.left = `${pageX}px`;
});

setTimeout(() => {
for(let i = 0; i < 1000000; i++) {
button.innerHTML = i;
}
}, 1000);

这个的执行结果应该大家都会知道,就是一秒后,同步代码会阻塞主进程,同步代码执行完后,innerHTML 才会改变,并且 button 才会跟随鼠标移动,情况如下:
2020-01-25-13-47-28.gif

但是如果我们使用 时间分片来做这个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const button = document.querySelector('#button');

document.addEventListener('mousemove', ({ pageX, pageY }) => {
button.style.top = `${pageY}px`;
button.style.left = `${pageX}px`;
})

window.requestIdleCallback(
timeSlicing(
// 这里相当于把这个 for 循环分割成了 1000000 个任务
function*() {
for(let i = 0; i < 1000000; i++) {
yield;
button.innerHTML = i;
}
}
)
);

for 循环的过程中掺杂着 渲染 以及 事件监听响应,情况如下
2020-01-25-13-47-49.gif

有兴趣的同学可以使用 浏览器 的 performance 查看一下每一帧的执行情况,相信会更有收获。

需要注意的点

  1. 时间分片不是“银弹“,只是提供一个在长耗时任务执行过程中响应用户交互的可能性
  2. 任务切分的颗粒度非常重要,会影响到任务执行的总时长(不过最终的结果都是会导致任务执行的总时长会更长,颗粒度可以优化)
  3. 如果用户的交互持续且频繁,任务执行的总时长会变得更加长,有点得不偿失(虽然可以通过给 requestIdleCallback 的第二个参数里面添加 timeout 来解决,但是仍然会有性能问题)
  4. 这跟 react16 的时间分片有比较大的联系吗?几乎没有。有兴趣的小伙伴可以看这篇文章 https://zhuanlan.zhihu.com/p/60307571 了解一下 react 的调度以及时间分片

最后

祝大家 2020 新年快乐