我们都知道,setTimeout与setInterval都不是精确的计时器,它还与当前主线程任务队列中的任务大小数量有关。当主线程任务队列的任务没有执行完毕时,setTimeout与setInterval就不会准确的在 t 时间后运行。那怎么才能提高二者的精确度呢?
1. setTimeout/setInterval用法
这里还是简单提一嘴吧。这两个函数是定时器函数,setTimeout表示回调函数在 t 时间后运行,setInterval表示回调函数每隔 t 时间运行。
setTimeout(function(){
...
}, t)
setInterval(function(){
...
}, t)
在 CSS3/HTML5之前,二者常用来绘制页面动画。CSS3/HTML5之后,常用css来绘制动画。另外还一个API是requestAnimationFrame. 该函数与setInterval类似,区别在于该函数没有 t 参数,默认是屏幕刷新的每帧调用一次。每秒调用(1/屏幕刷新率)次
requestAnimationFrame(function(){
// DOM动画
})
2. 发现问题
Js是一个单线程语言,同一时间只能做同一件事情。这很好理解,如果同一时间能做多个任务,那么多任务都在操作DOM时,页面该怎么显示呢?因而JS设计为了单线程,但单线程也带来了一些问题,比如线程阻塞,CPU利用率不高等问题。看代码2.1
setTimeout(function () {
console.log('task finished after ' + (Date.now() - startTime) + 'ms')
}, 0)
for (let i = 0 ; i < 9000 ; i++) {
console.log(5 + 8 + 8 + 8)
}
console.log('主线程复杂任务耗时:' + (Date.now() - startTime) + 'ms')
执行结果如图 2.1 所示
for循环是一个耗时任务,阻塞了setTimeout的执行。我们设定0秒后执行输出,可实际结果是在1243ms之后输出,所以setTimeout的 t 参数定时不够精确。
关于事件循环、宏任务、微任务不是本文重点,感兴趣的可以看看这篇文章笔试题——JavaScript事件循环机制(event loop、macrotask、microtask)
那么到底要怎么解决精确度问题呢?
3. 解决问题
前文我们一直在提及主线程主线程的,那么有没有办法利用其它线程进行计时呢?当代码执行到特定位置,通知其它线程中开始计时任务,主线程继续完成复杂任务,那么就不会阻塞计时器的运行,这样的话计时精确度就提高了。那么问题来了,JS不是一门单线程语言吗,怎么能利用除了主线程之外的其他线程呢?
在HTML5中,Worker的引入使得我们有了利用其它线程的能力。注意:Worker中不能操作DOM。
事情好办了。洋洋洒洒写下如下代码:
// worker生成器
const createWorker = (fn, options) => {
const blob = new Blob(['(' + fn.toString() + ')()']);
const url = URL.createObjectURL(blob);
if (options) {
return new Worker(url, options);
}
return new Worker(url);
}
// 生成worker
const worker = createWorker(function () {
onmessage = function(e) {
console.log('进入WebWorker')
let count = 1
let startTime = Date.now()
let timer = setInterval(function () {
if (count++ === e.data) {
clearInterval(timer)
postMessage(startTime)
}
}, 1000)
}
})
// 将代码2.1中的setTimeout替换为了 worker
// worker线程任务
const startTime = Date.now()
worker.postMessage(10)
worker.onmessage = function (e) {
console.log('Worker线程任务内部耗时:' + (Date.now() - e.data) + 'ms')
console.log('Worker线程任务耗时:'+ (Date.now() - startTime) + 'ms')
this.terminate()
}
// 主线程任务
for (let i = 0 ; i < 5000 ; i++) {
console.log(5 + 8 + 8 + 8)
}
console.log('主线程复杂任务耗时:' + (Date.now() - startTime) + 'ms')
代码中, worker 线程每秒运行一次回调函数,进行计数,当回调函数执行了 10 次时,通知主线程。主线程打印 worker 线程运行时间。
代码运行结果如图3.1所示
运行结果并不符合预期。预期结果应当是Worker线程任务内部耗时与Worker线程任务耗时相差不多。出现这个结果的原因在于js代码在执行到worker.postMessage(10)
时,并没有通知到Worker开始工作,而是等待当前任务队列执行完毕,才开始调用 Worker😭..这里不像fetch函数那样,代码执行到fetch时,浏览器另开线程去处理请求,拿到结果再进行回调,其他代码接着运行。
那么问题又来了,worker是属于宏任务还是微任务呢?
验证代码如下:
...
setTimeout(()=>{
console.log('SetTimeout')
}, 0)
const worker = createWorker(function () {
onmessage = function(e) {
console.log('进入WebWorker ')
let count = 1
let startTime = Date.now()
let timer = setInterval(function () {
if (count++ === e.data) {
clearInterval(timer)
postMessage(startTime)
}
}, 1000)
}
})
...
运行结果如下:
SetTimeout
在进入WebWorker
之前输出,说明Worker是宏任务
4. 更进一步
上一节中,我们考虑将计时器函数丢到Worker中去执行.但实际运行结果并不符合预期。那我们换一种思路,将主线程耗时任务丢到Worker执行,计时器精度会提高吗?答案是肯定的
看如下代码4.1:
const startTime = Date.now()
setTimeout(function () {
console.log('task finished after ' + (Date.now() - startTime) + 'ms')
}, 0)
// 将代码2.1中的耗时任务放到worker
const createWorker = (fn, options) => {
const blob = new Blob(['(' + fn.toString() + ')()']);
const url = URL.createObjectURL(blob);
if (options) {
return new Worker(url, options);
}
return new Worker(url);
}
const worker = createWorker(function () {
const startTime = Date.now()
for (let i = 0; i < 9000; i++) {
console.log(5 + 8 + 8 + 8)
if (i === 9000 - 1) {
postMessage(startTime)
}
}
})
worker.postMessage('start')
worker.onmessage = function (e) {
console.log('Worker线程任务内部耗时:' + (Date.now() - e.data) + 'ms')
console.log('Worker线程任务耗时:'+ (Date.now() - startTime) + 'ms')
this.terminate()
}
运行结果如图4.1:
对比上文代码2.1,可以看到计时器精度提高了不少。
将Worker替换成setTimeout试试
const startTime = Date.now()
setTimeout(function () {
console.log('task finished after ' + (Date.now() - startTime) + 'ms')
}, 0)
// 将代码2.1中耗时任务放到setTimeout中
setTimeout(function () {
for (let i = 0 ; i < 9000 ; i++) {
console.log(5 + 8 + 8 + 8)
}
console.log('主线程复杂任务耗时:' + (Date.now() - startTime) + 'ms')
}, 0)
运行结果如图4.2:
对比上文代码2.1,可以看到计时器精度也提高了不少。
总结:主线程遇到耗时任务,应当考虑将其丢到下一个宏任务中去执行,这样不会阻塞当前任务队列的执行。题目中讲到要提高计时器函数精度,实际计时器也是一个宏任务。计时器与耗时任务在丢到下一个任务队列,要保证计时器任务在前,这样就可以提高计时器精度。再者,计时器函数要尽量靠前,先于其他代码执行,因为代码执行本身也会消耗时间。
5. 最佳实战
主线程遇到无关DOM的耗时任务时,应当首先考虑使用Worker进行处理与计算。这样才不会阻塞主线程。代码4.2中,我们尝试将耗时任务放到主线程的中的下一个宏任务队列中,如果任务有上万数据进行计算,还是会阻塞主线程。Worker虽然属于宏任务,但只是将数据传输到Worker线程,由Worker进行计算。计算完成后,通知主线程。
6. 实现一个相对精确的setInterval
function loop(fn, interval) {
const startTime = Date.now()
let count = 0
let currentInterval = interval
let timer = 0
function inner() {
count++
let offset = new Date().getTime() - (startTime + count * interval)
currentInterval = interval - offset
console.log('代码执行时间:' + offset, '下次循环间隔' + currentInterval)
timer = setTimeout(inner, currentInterval)
fn(timer)
}
setTimeout(inner, currentInterval)
}
let count = 0
loop(function (timer) {
console.log('外部函数')
if (count++ === 10) {
console.log('外部函数执行完毕')
clearTimeout(timer)
}
}, 1000)
如上代码,我们构造了一个与setInterval
函数功能类似的loop函数。插入主线程的任务的时间排除了代码的执行时间,相对于setInterval精度提高了很多。但当代码的执行时间大于设定的t时,主线程插入任务时间也不会准。
7. 后记
这个问题来自于公众号上看到的某大厂的面试题。因而引发了一系列思考与探讨,顺便研究了一下WebWorker。其实提高setTimeout与setInterval计时精确度本身是一件无意义的事情,实际中本就没有这样的应用场景,但探索的过程还是蛮有意义的(就是有点耗费头发😭)。。