让setTimeout准时执行

四糸乃赛高 Lv.5 冰封大地
134 2021/6/12笔记

今天翻到一篇文章,里面有这么一个问题:


了解JS的时间循环就知道,setTimeout并不会准时执行,他只负责在计时完成后将回调函数放入任务队列,不会执行回调函数。

例子:

let start = new Date();

setTimeout(()=>{
    console.log('执行回调')
    let end = new Date();
    console.log(end - start)
},1000)

for (let index = 0; index < 10000; index++) {
    console.log(index)
}

上面的代码理想的结果是在一秒后执行回调,但实际上过了一秒后,setTimeout将回调放入任务队列,但此时主线程上的for循环还未结束,setTimeout中的回调只有等待for循环结束后才执行。


看到这个问题的时候,第一时间是想到用requestAnimationFrame,该方法会在浏览器重绘的时候执行回调,执行频率由显示器的刷新率决定。我的显示器是120刷新率,那么执行的速度就是8ms左右。

下面的代码使用requestAnimationFrame实现计时器功能,每次执行后打印出理想执行时间和实际执行时间的差值。

function timer(speed){
    let start = new Date(),count = 1;
    function fun(){
        let end = new Date()
        if(end - start >= speed * count){
            console.log(`diff:${end - start - (speed * count)}`);
            count++;
        }
        requestAnimationFrame(fun)
    }
    requestAnimationFrame(fun)
}

timer(1000)
console.log('执行完毕')

结果:大致符合预期,误差较小。

diff:8 timer.js:46:21
执行完毕 timer.js:55:9
diff:7 2 timer.js:46:21
diff:8 timer.js:46:21
diff:7 3 timer.js:46:21
diff:0 2 timer.js:46:21
diff:8 2 timer.js:46:21
diff:0 3 timer.js:46:21
diff:1 2 timer.js:46:21
diff:0 2 timer.js:46:21
diff:1 4 timer.js:46:21
diff:2 6 timer.js:46:21
diff:3 6 timer.js:46:21

再看看使用setTimeou实现定时器:

function timer(speed){
    const start = new Date();
    let count = 0;
    function fun(){
        count ++;
        let end = new Date()
        console.log(`diff:${end - start - (speed * count)}`);
        setTimeout(fun,speed)
    }
    setTimeout(fun,speed)
}
timer(1000)

结果:每次执行回调后,与理想执行时间的误差都会增大。

diff:7
diff:22
diff:27
diff:43
diff:56
diff:69
diff:75
diff:92
diff:106
diff:116
diff:119
...

改进一下上面的代码,让定时器对误差的时间进行补偿:

function timer(speed){
    const start = new Date();
    let count = 0;
    function fun(){
        count ++;
        let end = new Date(),diff = end - start - (speed * count);
        console.log(`diff:${diff}`);
        setTimeout(fun,speed - diff)
    }
    setTimeout(fun,speed)
}
timer(1000)

结果:可以看到误差被控制在了一个稳定的范围,并且能够防止增长。

diff:5
diff:2
diff:15
diff:13
diff:12
diff:8
diff:3
diff:2
diff:13
diff:12
diff:7
diff:8
diff:3
diff:15

再来试试使用while作为定时器:

function timer(speed) {
    function fun(){
        const start = new Date();
        while(true) {
            let end = new Date()
            if(end - start >= speed){
                console.log(`diff:${end - start - speed}`);
                return fun()
            }
        }
    }
    fun()
}
timer(1000)
console.log('执行完毕')

结果:可以看到误差为零,但是while会对线程造成阻塞,后面的代码将无法执行,使用场景受限较大。

diff:0
diff:0
diff:0
diff:0
diff:0
diff:0
diff:0
...

那么把while放入另一个线程,不就能解决上面的问题了吗

// main.js
function timer(speed){
    let worker = new Worker('work.js'),count = 0;
    worker.postMessage({msg:'start',speed})
    worker.onmessage = function(e){
        count ++;
        worker.postMessage({msg:'reStart',speed})
    }
}
timer(1000)
console.log('执行完毕')

// work.js
onmessage = function(e){
    let {speed} = e.data;
    let start = new Date()
    while(true) {
        let end = new Date()
        if(end - start >= speed){
            console.log(`diff:${end - start - speed}`);
            this.postMessage('done')
            return;
        }
    }
}

执行结果:可以看到,误差与理想非常接近。

diff:0 3 work.js:7:21
执行完毕 timer.js:39:9
diff:0 43 work.js:7:21
diff:32 work.js:7:21
diff:0 43 work.js:7:21

上面的操作是在worker线程中执行,woker线程中不能操作主线程中的数据,并且能操作的BOM对象受限,还需要改进一下,将代码放入主线程中执行。

// main.js
function timer(speed){
    let worker = new Worker('work.js'),count = 0,start = new Date()
    worker.postMessage({msg:'start',speed})
    worker.onmessage = function(e){
        count ++;
        let end = new Date()
        console.log(`diff:${end - start - (speed * count)}`);
        worker.postMessage({msg:'reStart',speed})
    }
}

timer(1000)
console.log('执行完毕')

// work.js
onmessage = function(e){
    let start = new Date()
    let {speed} = e.data;
    while(true) {
        let end = new Date()
        if(end - start >= speed){
            this.postMessage('done')
            return;
        }
    }
}

结果:还是存在误差,但误差增加的速度慢了很多。个人的猜想:因为JS消息机制的回调属于微任务,对比setTimeout中间少了渲染过程,比起setTimeout更早执行,所以误差增加的速度慢。

执行完毕 timer.js:44:9
diff:20 23 timer.js:36:17
diff:21 6 timer.js:36:17
diff:22 8 timer.js:36:17
diff:23 timer.js:36:17
diff:24 3 timer.js:36:17
diff:25 timer.js:36:17
diff:27 2 timer.js:36:17
diff:53 3 timer.js:36:17
diff:97


总结:上面的方案中,使用while作为定时器的精准度最高,并且能够解决第一段代码中出现的问题,但是使用场景受限,可以配合web worker使用。至于requestAnimationFra,我测试的结果跟看的文章里有点不一样,可能是屏幕刷新率导致的差异,并且上面的例子还存在一些问题,需要改进,这些还需要找大佬讨论一下。


参考文章:

https://jishuin.proginn.com/p/763bfbd57fd7

http://www.ruanyifeng.com/blog/2018/07/web-worker.html


# JavaScript

评论

后参与评论
还没有评论,来说点什么吧~