20 定时器

20.1 setInterval存在哪些问题?

JavaScript中使用 setInterval 开启轮询。定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。而javascript引擎对这个问题的解决是:当使用setInterval()时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。

但是,这样会导致两个问题:

  • 某些间隔被跳过;
  • 多个定时器的代码执行之间的间隔可能比预期的小

20.2 链式调用setTimeout对比setInterval

setInterval本身是会存在一些问题的。而使用链式调用setTimeout这种方式会比它好一些:

    setTimeout(function fn(){
        console.log('我是setTimeout');
        setTimeout(fn, 1000);
    },1000);

这个模式链式调用了setTimeout(),每次函数执行的时候都会创建一个新的定时器。第二个setTimeout()调用当前执行的函数,并为其设置另外一个定时器。这样做的好处是:

  • 在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔。
  • 而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。

20.3 实现比 setTimeout 快 80 倍的定时器

在浏览器中,setTimeout()/setInterval() 的每调用一次定时器的最小间隔是 4ms,这通常是由于函数嵌套导致(嵌套层级达到一定深度)

简单来说,5 层以上的定时器嵌套会导致至少 4ms 的延迟。

用如下代码做个测试:

    let a = performance.now();
    setTimeout(() => {
      let b = performance.now();
      console.log(b - a);
      setTimeout(() => {
        let c = performance.now();
        console.log(c - b);
        setTimeout(() => {
          let d = performance.now();
          console.log(d - c);
          setTimeout(() => {
            let e = performance.now();
            console.log(e - d);
            setTimeout(() => {
              let f = performance.now();
              console.log(f - e);
              setTimeout(() => {
                let g = performance.now();
                console.log(g - f);
              }, 0);
            }, 0);
          }, 0);
        }, 0);
      }, 0);
    }, 0);

在浏览器中的打印结果大概是这样的,和规范一致,第五次执行的时候延迟来到了 4ms 以上

    // 结果是
    1.2999999970197678
    1.5
    1.2999999970197678
    1.9000000059604645
    4.5
    4.5999999940395355

如果想在浏览器中实现 0ms 延时的定时器,可以用 window.postMessage 来实现真正 0 延迟的定时器

    (function () {
      var timeouts = [];
      var messageName = 'zero-timeout-message';
    
      // 保持 setTimeout 的形态,只接受单个函数的参数,延迟始终为 0。
      function setZeroTimeout(fn) {
        timeouts.push(fn);
        window.postMessage(messageName, '*');
      }
    
      function handleMessage(event) {
        if (event.source == window && event.data == messageName) {
          event.stopPropagation();
          if (timeouts.length > 0) {
            var fn = timeouts.shift();
            fn();
          }
        }
      }
    
      window.addEventListener('message', handleMessage, true);
    
      // 把 API 添加到 window 对象上
      window.setZeroTimeout = setZeroTimeout;
    })();

由于 postMessage 的回调函数的执行时机和 setTimeout 类似,都属于宏任务,所以可以简单利用 postMessageaddEventListener('message') 的消息通知组合,来实现模拟定时器的功能。

这样,执行时机类似,但是延迟更小的定时器就完成了。

再利用下面的嵌套定时器的例子来跑一下测试:

    var a = performance.now();
    setZeroTimeout(() => {
      let b = performance.now();
      console.log(b - a);
      setZeroTimeout(() => {
        let c = performance.now();
        console.log(c - b);
        setZeroTimeout(() => {
          let d = performance.now();
          console.log(d - c);
          setZeroTimeout(() => {
            let e = performance.now();
            console.log(e - d);
            setZeroTimeout(() => {
              let f = performance.now();
              console.log(f - e);
              setZeroTimeout(() => {
                let g = performance.now();
                console.log(g - f);
              }, 0);
            }, 0);
          }, 0);
        }, 0);
      }, 0);
    }, 0);
    // 结果
    0.30000000447034836
    0.19999999552965164
    0.10000000149011612
    0.10000000149011612
    0.10000000149011612
    0.10000000149011612

全部在 0.1 ~ 0.3 毫秒级别,而且不会随着嵌套层数的增多而增加延迟

有什么场景需要无延迟的定时器?其实在 React 的源码中,做时间切片的部分就用到了

    // 伪代码
    
    const channel = new MessageChannel();
    const port = channel.port2;
    
    // 每次 port.postMessage() 调用就会添加一个宏任务
    // 该宏任务为调用 scheduler.scheduleTask 方法
    channel.port1.onmessage = scheduler.scheduleTask;
    
    const scheduler = {
      scheduleTask() {
        // 挑选一个任务并执行
        const task = pickTask();
        const continuousTask = task();
    
        // 如果当前任务未完成,则在下个宏任务继续执行
        if (continuousTask) {
          port.postMessage(null);
        }
      },
    };

React 把任务切分成很多片段,这样就可以通过把任务交给 postMessage 的回调函数,来让浏览器主线程拿回控制权,进行一些更优先的渲染任务(比如用户输入)

为什么不用执行时机更靠前的微任务呢?关键的原因在于微任务会在渲染之前执行,这样就算浏览器有紧急的渲染任务,也得等微任务执行完才能渲染

22.4 说一下requestAnimationFrame

简介:

显示器都有自己固有的刷新频率(60HZ或者75HZ),也就是说每秒最多重绘60次或者75次。而requestAnimationFrame的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行重绘。

特点:

  • 使用这个API时,一旦页面不处于浏览器的当前标签,就会自动停止刷新,这样就节省了CPUGPU、电力。
  • 由于它时在主线程上完成的,所以若是主线程非常忙时它的动画也会收到影响
  • 它使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。

使用:

正常使用:

    const requestID = window.requestAnimationFrame(callback);

兼容版本:

    // 给 window 下挂载一个兼容版本的 requestAniFrame
    window.requestAniFrame = (function () {
      return  window.requestAnimationFrame || 
        window.webkitRequestAnimationFrame || 
        window.mozRequestAnimationFrame    || 
        window.oRequestAnimationFrame      || 
        window.msRequestAnimationFrame     || 
        function( callback ){
          window.setTimeout(callback, 1000 / 60);
        };
    })();

22.5 requestAnimationFrame对比setTimeout

  • 屏幕刷新频率 屏幕每秒出现图像的次数。普通笔记本为60Hz
  • 动画原理 计算机每16.7ms刷新一次,由于人眼的视觉停留,所以看起来是流畅的移动。
  • setTimeout 通过设定间隔时间来不断改变图像位置,达到动画效果。但是容易出现卡顿抖动的现象;原因是:
  1. settimeout 任务被放入异步队列,只有当主线程任务执行完后才会执行队列中的任务,因此实际执行时间总是比设定时间要晚;
  2. settimeout 的固定时间间隔不一定与屏幕刷新时间相同,会引起丢帧。

requestAnimationFrame 优势:由系统决定回调函数的执行时机。60Hz的刷新频率,那么每次刷新的间隔中会执行一次回调函数,不会引起丢帧,不会卡顿。且由于一旦页面不处于浏览器的当前标签,就会自动停止刷新,这样就节省了CPU、GPU、电力。

Last Updated:
Contributors: leeguooooo