上周我在FDConf的分享《让你的网页更丝滑》中提到了“时间切片”,因为时间关系当时并无对时间切片展开更细致的讨论。因此回来后就想着补一篇文章针对“时间切片”展开详细的讨论。javascript
从用户的输入,再到显示器在视觉上给用户的输出,这一过程若是超过100ms,那么用户会察觉到网页的卡顿,因此为了解决这个问题,每一个任务不能超过50ms,W3C性能工做组在LongTask规范中也将超过50ms的任务定义为长任务。java
关于这50毫秒我在FDConf的分享中进行了很详细的讲解,没有听到的小伙伴也不用着急,后续我会针对此次分享的内容补一篇文章。git
在线PPT地址:ppt.baomitu.com/d/b267a4a3github
因此为了不长任务,一种方案是使用Web Worker,将长任务放在Worker线程中执行,缺点是没法访问DOM,而另外一种方案是使用时间切片。浏览器
时间切片的核心思想是:若是任务不能在50毫秒内执行完,那么为了避免阻塞主线程,这个任务应该让出主线程的控制权,使浏览器能够处理其余任务。让出控制权意味着中止执行当前任务,让浏览器去执行其余任务,随后再回来继续执行没有执行完的任务。异步
因此时间切片的目的是不阻塞主线程,而实现目的的技术手段是将一个长任务拆分红不少个不超过50ms的小任务分散在宏任务队列中执行。函数
上图能够看到主线程中有一个长任务,这个任务会阻塞主线程。使用时间切片将它切割成不少个小任务后,以下图所示。工具
能够看到如今的主线程有不少密密麻麻的小任务,咱们将它放大后以下图所示。性能
能够看到每一个小任务中间是有空隙的,表明着任务执行了一小段时间后,将让出主线程的控制权,让浏览器执行其余的任务。学习
使用时间切片的缺点是,任务运行的总时间变长了,这是由于它每处理完一个小任务后,主线程会空闲出来,而且在下一个小任务开始处理以前有一小段延迟。
可是为了不卡死浏览器,这种取舍是颇有必要的。
时间切片是一种概念,也能够理解为一种技术方案,它不是某个API的名字,也不是某个工具的名字。
事实上,时间切片充分利用了“异步”,在早期,可使用定时器来实现,例如:
btn.onclick = function () {
someThing(); // 执行了50毫秒
setTimeout(function () {
otherThing(); // 执行了50毫秒
});
};
复制代码
上面代码当按钮被点击时,本应执行100毫秒的任务如今被拆分红了两个50毫秒的任务。
在实际应用中,咱们能够进行一些封装,封装后的使用效果相似下面这样:
btn.onclick = ts([someThing, otherThing], function () {
console.log('done~');
});
复制代码
固然,关于ts
这个函数的API的设计并非本文的重点,这里想说明的是,在早期能够利用定时器来实现“时间切片”。
ES6带来了迭代器的概念,并提供了生成器Generator函数用来生成迭代器对象,虽然Generator函数最正统的用法是生成迭代器对象,但这不妨咱们利用它的特性作一些其余的事情。
Generator函数提供了yield
关键字,这个关键字可让函数暂停执行。而后经过迭代器对象的next
方法让函数继续执行。
对Generator函数不熟悉的同窗,须要先学习Generator函数的用法。
利用这个特性,咱们能够设计出更方便使用的时间切片,例如:
btn.onclick = ts(function* () {
someThing(); // 执行了50毫秒
yield;
otherThing(); // 执行了50毫秒
});
复制代码
能够看到,咱们只须要使用yield
这个关键字就能够将本应执行100毫秒的任务拆分红了两个50毫秒的任务。
咱们甚至能够将yield关键字放在循环里:
btn.onclick = ts(function* () {
while (true) {
someThing(); // 执行了50毫秒
yield;
}
});
复制代码
上面代码咱们写了一个死循环,但依然不会阻塞主线程,浏览器也不会卡死。
经过前面的例子,咱们会发现基于Generator的时间切片很是好用,但其实ts函数的实现原理很是简单,一个最简单的ts函数只须要九行代码。
function ts (gen) {
if (typeof gen === 'function') gen = gen()
if (!gen || typeof gen.next !== 'function') return
return function next() {
const res = gen.next()
if (res.done) return
setTimeout(next)
}
}
复制代码
代码虽然所有只有9行,关键代码只有三、4行,但这几行代码充分利用了事件循环机制以及Generator函数的特性。
创造出这样的代码我仍是很开心的。
上面代码核心思想是:经过yield
关键字能够将任务暂停执行,从而让出主线程的控制权;经过定时器能够将“未完成的任务”从新放在任务队列中继续执行。
使用yield
来切割任务很是方便,但若是切割的粒度特别细,反而效率不高。假设咱们的任务执行100ms
,最好的方式是切割成两个执行50ms
的任务,而不是切割成100个执行1ms
的任务。假设被切割的任务之间的间隔为4ms
,那么切割成100个执行1ms
的任务的总执行时间为:
(1 + 4) * 100 = 500ms
复制代码
若是切割成两个执行时间为50ms
的任务,那么总执行时间为:
(50 + 4) * 2 = 108ms
复制代码
能够看到,在不影响用户体验的状况下,下面的总执行时间要比前面的少了4.6倍。
保证切割的任务恰好接近50ms
,能够在用户使用yield
时自行评估,也能够在ts
函数中根据任务的执行时间判断是否应该一次性执行多个任务。
咱们将ts
函数稍微改进一下:
function ts (gen) {
if (typeof gen === 'function') gen = gen()
if (!gen || typeof gen.next !== 'function') return
return function next() {
const start = performance.now()
let res = null
do {
res = gen.next()
} while(!res.done && performance.now() - start < 25);
if (res.done) return
setTimeout(next)
}
}
复制代码
如今咱们测试下:
ts(function* () {
const start = performance.now()
while (performance.now() - start < 1000) {
console.log(11)
yield
}
console.log('done!')
})();
复制代码
这段代码在以前的版本中,在个人电脑上能够打印出 215 次 11
,在后面的版本中能够打印出 6300 次 11
,说明在总时间相同的状况下,能够执行更多的任务。
再看另外一个例子:
ts(function* () {
for (let i = 0; i < 10000; i++) {
console.log(11)
yield
}
console.log('done!')
})();
复制代码
在个人电脑上,这段代码在以前的版本中,被切割成一万个小任务,总执行时间为 46
秒,在以后的版本中,被切割成 52 个小任务,总执行时间为 1.5
秒。
我将时间切片的代码放在了个人Github上,感兴趣的能够参观下:github.com/berwin/time…