定时器不准时☞带你揭秘setTimeout和setInterval

7,273 阅读6分钟

一、一个面试题引起的思考

某天上班摸鱼,一个Q群里有人在发笔试题在线求助。大概瞄了一下。发现里面有道主观判断题。

代码中有setInterval(()=>{console.log('a')},10000),那一定会每隔10秒在控制台打印个a。

可能很多人第一印象,包括我再内,都认为这道题是对的。但是其实是错的!!

为什么呢,就是JavaScript执行机制搞得鬼,那什么是JavaScript执行机制,不懂可以点这里看一下。

二、setTimeout的定义和用法

1、setTimeout的定义

setTimeout()方法用于在指定的毫秒数后调用函数或计算表达式。

2、setTimeout的参数

  • 第一个参数function,必填的,回调函数,可以是一个函数,也可以是一个函数名。

  • 第二个参数delay,可选的,延迟时间,单位是ms。

  • 第三个参数param1,param2,param3...,可选的,是传递给回调函数的参数,比较不常用到,在IE9 及其更早版本不支持该参数。

    setTimeout(function(a) {
    	console.log(a);
    }, 2000,'我是定时器')
    
    setTimeout(foo, 2000,'我是定时器')
    function foo(a){
        console.log(a)
    }
    

3、setTimeout的返回值

返回一个 ID(数字),可以将这个ID传递给clearTimeout()来取消执行。

三、setInterval的定义和用法

1、setInterval的定义

setInterval()方法可按照指定的周期(以毫秒计)来调用函数或计算表达式。

2、setInterval的参数

  • 第一个参数function,必填的,回调函数,可以是一个函数,也可以是一个函数名。
  • 第二个参数delay,可选的,间隔时间,单位是ms。
  • 第三个参数param1,param2,param3...,可选的,是传递给回调函数的参数,比较不常用到,在IE9 及其更早版本不支持该参数。
    setInterval(function(a) {
    	console.log(a);
    }, 2000,'我是定时器')
    
    setInterval(foo, 2000,'我是定时器')
    function foo(a){
        console.log(a)
    }
    

3、setInterval的返回值

返回一个 ID(数字),可以将这个ID传递给clearInterval()以取消执行。

四、setTimeout的最短延迟时间

第二个参数delay未设置的时候,默认为0,意味着“马上”执行,或者尽快执行。

但是有一个规定如下

If timeout is less than 0, then set timeout to 0. If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

上面的意思是说,如果延迟时间短于0,则将延迟时间设置为0。如果嵌套级别大于5,延迟时间短于4ms,则将延迟时间设置为4ms。

还有另外一种情况。为了节电,对于那些不处于当前窗口的页面,浏览器会将最短延时限制扩大到1000ms。

以上可以说是造成定时器不准时原因之一

五、setInterval的最短间隔时间

在John Resig的新书《Javascript忍者的秘密》一书中提到

Browsers all have a 10ms minimum delay on OSX and a(approximately) 15ms delay on Windows.

在苹果机上的最短间隔时间是10毫秒,在Windows系统上的最短间隔时间大约是15毫秒。

大多数电脑显示器的刷新频率是60HZ,大概相当于每秒钟重绘60次。因此,最平滑的动画效的最佳循环间隔是1000ms/60,约等于16.6ms。

综上所述,我认为setInterval的最短间隔时间应该为16.6ms。

六、不准时的setTimeout和setInterval

不管是哪种情况,实际的延迟时间可能会比期待的(delay毫秒数) 值长。

除了设置的delay比最短延迟时间和最短间隔时间还短造成的,还有一个原因就是JavaScript执行机制造成,下面以一个例子来分析。

<body>
    <button id="btn"></button>
    <script>
        const btn = document.getElementById("btn");
        btn.addEventListener('click',function handleClick(){
            //...代码执行时间需80ms
        })
    	setTimeout(function handlerTimeout(){
            //...代码执行时间需60ms
        }, 100);
        setInterval(function handlerInterval(){
            //...代码执行时间需80ms
        },100)
        //... 其余代码执行时间需要180ms
    </script>
</body>

我们借助一个时间轴来描述这段代码是怎么执行的。

在100ms时,本来两个定时器是同时完成的,但是setTimeout定时器写在前面,所以其回调函数handlerTimeout先进入事件队列先执行。回调函数handlerInterval后进入事件队列后执行。

但是实际情况是,因为还有其余代码执行时间需要180ms,也就是说主线程中需要到180ms时才有空闲,所以回调函数handlerTimeout只能180ms时才能执行。回调函数handlerInterval需要等回调函数handlerTimeout执行完才能执行,相当在240ms时才执行。

为什么会出现上述现象,真正的原因可以去看一下我的另一篇文章JavaScript到底是怎么执行的🔥

所以可以得出一个结论setTimeout、setInterval无法保证准时执行回调函数。

七、被废弃的setInterval回调函数

在200ms时,setInterval又执行完了,回调函数handlerInterval会不会又进入事件队列。

答案是不会,因为此时事件队列中已经有一个回调函数handlerInterval了。

此时setInterval回调函数是被废弃了。

八、setInterval回调函数的执行时间

在240ms时,回调函数handlerTimeout执行结束,开始执行回调函数handlerInterval。

在300ms时,setInterval又执行完了,发现事件队列中已经没有回调函数handlerInterval了。这时回调函数handlerInterval会进入事件队列。

在320ms时,上个回调函数handlerTimeout执行结束,下个回调函数handlerTimeout接着马上执行。

在400ms时,setInterval又执行完了,发现事件队列中已经没有回调函数handlerInterval了。这时回调函数handlerInterval会进入事件队列。恰好上个回调函数handlerTimeout执行结束,下个回调函数handlerTimeout接着马上执行。

这时就会发现,回调函数handlerTimeout执行起来没间隔,间隔不见了。

所以setInterval的间隔时间一定要比回调函数的执行时间大。

但是在很多情况下,我们并不能清晰的知道回调函数的执行时间,为了能按照一定的间隔周期性的触发定时器,可以用以下方法实现。

setTimeout(function handlerInterval(){
    // do something
    setTimeout(handlerInterval,100); 
    // 执行完处理程序的内容后,在末尾再间隔100毫秒来调用该程序,这样就能保证一定是100毫秒的周期调用
},100)

但是这个方法有个时间误差,在优化一下

function mySetInterval(timeout) {
    const startTime = new Date().getTime();
    let countIndex = 1;
    let onOff=true;
    startSetInterval(timeout)
    function startSetInterval(interval) {
        setTimeout(() => {
            const endTime = new Date().getTime();
            // 偏差值
            const deviation = endTime - (startTime + countIndex * timeout);
            console.log(`${countIndex}: 偏差${deviation}ms`);
            countIndex++;
            // 下一次
            if(onOff){
                startSetInterval(timeout - deviation);
            }
        }, interval);
    }
    
    function stopSetInterval(){
        onOff=false;
    }
    return stopSetInterval
}