我们都知道,setTimeout与setInterval都不是精确的计时器,它还与当前主线程任务队列中的任务大小数量有关。当主线程任务队列的任务没有执行完毕时,setTimeout与setInterval就不会准确的在 t 时间后运行。那怎么才能提高二者的精确度呢?

1. setTimeout/setInterval用法

这里还是简单提一嘴吧。这两个函数是定时器函数,setTimeout表示回调函数在 t 时间后运行,setInterval表示回调函数每隔 t 时间运行。

setTimeout(function(){
    ...
}, t)

setInterval(function(){
    ...
}, t)
代码 1.1

在 CSS3/HTML5之前,二者常用来绘制页面动画。CSS3/HTML5之后,常用css来绘制动画。另外还一个API是requestAnimationFrame. 该函数与setInterval类似,区别在于该函数没有 t 参数,默认是屏幕刷新的每帧调用一次。每秒调用(1/屏幕刷新率)次

requestAnimationFrame(function(){
    // DOM动画
})
代码 1.2

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

执行结果如图 2.1 所示

图 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')
代码 3.1

代码中, worker 线程每秒运行一次回调函数,进行计数,当回调函数执行了 10 次时,通知主线程。主线程打印 worker 线程运行时间。

代码运行结果如图3.1所示

图 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)
    }
})
...
代码 3.2

运行结果如下:

图 3.2

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

运行结果如图4.1:

图 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

运行结果如图4.2:

图 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计时精确度本身是一件无意义的事情,实际中本就没有这样的应用场景,但探索的过程还是蛮有意义的(就是有点耗费头发😭)。。