如何实现一个零延迟的定时器?

更新日期: 2021-06-08阅读: 1.8k标签: 定时器

起因

很多人都知道,setTimeout是有最小延迟时间的,根据MDN 文档 setTimeout:实际延时比设定值更久的原因:最小延迟时间中所说:

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

html Standard规范中也有提到更具体的:

Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.

简单来说,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 以上。


更详细的原因,可以参考为什么 setTimeout 有最小时延 4ms \?


探索

假设我们就需要一个「立刻执行」的定时器呢?有什么办法绕过这个 4ms 的延迟吗,上面那篇 MDN 文档的角落里有一些线索,用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类似,都属于宏任务,所以可以简单利用postMessage和addEventListener('message')的消息通知组合,来实现模拟定时器的功能。

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

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


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


测试

从理论上来说,由于postMessage的实现没有被浏览器引擎限制速度,一定是比 setTimeout 要快的。但空口无凭,咱们用数据说话。

作者设计了一个实验方法,就是分别用postMessage版定时器和传统定时器做一个递归执行计数函数的操作,看看同样计数到 100 分别需要花多少时间。

实验代码:

function runtest() {
  var output = document.getElementById('output');
  var outputText = document.createTextNode('');
  output.appendChild(outputText);
  function printOutput(line) {
    outputText.data += line + '\n';
  }

  var i = 0;
  var startTime = Date.now();
  // 通过递归 setZeroTimeout 达到 100 计数
  // 达到 100 后切换成 setTimeout 来实验
  function test1() {
    if (++i == 100) {
      var endTime = Date.now();
      printOutput(
        '100 iterations of setZeroTimeout took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
      i = 0;
      startTime = Date.now();
      setTimeout(test2, 0);
    } else {
      setZeroTimeout(test1);
    }
  }

  setZeroTimeout(test1);

  // 通过递归 setTimeout 达到 100 计数
  function test2() {
    if (++i == 100) {
      var endTime = Date.now();
      printOutput(
        '100 iterations of setTimeout(0) took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
    } else {
      setTimeout(test2, 0);
    }
  }
}

实验代码很简单,先通过setZeroTimeout也就是postMessage版本来递归计数到 100,然后切换成 setTimeout 计数到 100。

直接放结论,这个差距不固定,在我的 mac 上用无痕模式排除插件等因素的干扰后,以计数到 100 为例,大概有 80 ~ 100 倍的时间差距。在我硬件更好的台式机上,甚至能到 200 倍以上。



Performance 面板

只是看冷冰冰的数字还不够过瘾,我们打开 Performance 面板,看看更直观的可视化界面中,postMessage版的定时器和setTimeout版的定时器是如何分布的。


这张分布图非常直观的体现出了我们上面所说的所有现象,左边的postMessage版本的定时器分布非常密集,大概在 5ms 以内就执行完了所有的计数任务。

而右边的setTimeout版本相比较下分布的就很稀疏了,而且通过上方的时间轴可以看出,前四次的执行间隔大概在 1ms 左右,到了第五次就拉开到 4ms 以上。


作用

也许有同学会问,有什么场景需要无延迟的定时器?其实在 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的回调函数,来让浏览器主线程拿回控制权,进行一些更优先的渲染任务(比如用户输入)。

为什么不用执行时机更靠前的微任务呢?参考我的这篇对 EventLoop 规范的解读深入解析 EventLoop 和浏览器渲染、帧动画、空闲回调的关系[7],关键的原因在于微任务会在渲染之前执行,这样就算浏览器有紧急的渲染任务,也得等微任务执行完才能渲染。


总结

通过本文,你大概可以了解如下几个知识点:

setTimeout的 4ms 延迟历史原因,具体表现。
如何通过postMessage实现一个真正 0 延迟的定时器。
postMessage定时器在 React 时间切片中的运用。
为什么时间切片需要用宏任务,而不是微任务。


链接: https://fly63.com/article/detial/10519

js中setTimeout和setInterval的深入理解:它们之间的区别,原理,“异步“等

这篇文章将带你深入理解js中定时器是如何工作的,setTimeout和setInterval的原理是什么?

为什么尽量别用setInterval

在开发一个在线聊天工具时,经常会有过多少毫秒就重复执行一次某操作的需求。“没问题”,大家都说,“用setInterval好了。”我觉得这个点子很糟糕。

你可能不知道的setInterval的坑

之前印象中一直记得setInterval有一些坑,但是一直不是很清楚那些坑是什么。setInterval会无视代码的错误、setInterval会无视任何情况下定时执行、、setInterval不能确保每次调用都能执行

setInterval和setTimeout的区别以及setInterval越来越快问题的解决方法

setInterval()和setTimeout()方法都是js原生的定时方法,当然它们两个的作用也是不同的,并且最近在做上下滚动公告栏的时候,发现了setInterval()非常令人抓狂的问题,那就是用setInterval()做的定时滚动会随着浏览器页面切换变得无法控制!为什么会说无法控制呢

如何通过setTimeout理解JS运行机制详解

setTimeout()函数:用来指定某个函数或某段代码在多少毫秒之后执行。它返回一个整数,表示定时器timer的编号,可以用来取消该定时器。JavasScript引擎是基于事件驱动和单线程执行的,JS引擎一直等待着任务队列中任务的到来

node-schedule 全局内关闭定时器

用Cron表达式完成定时器,全局内关闭定时器需要获取到定时器的引用,scheduleJob存在第四个参数,然而readme中没有提及,可知API

js 随机点名

主要是利用定时器,点击开始IDE时候不断的执行,并同时生成随机数,利用数组的下标完成展示。主要用到的知识点:setInterval,Math.random()

js定时器setTiemout、setInterval

JS提供了一些原生方法来实现延时去执行某一段代码,下面来简单介绍一下setTiemout、setInterval、setImmediate、requestAnimationFrame。JS提供了一些原生方法来实现延时去执行某一段代码,下面来简单介绍一下。

Js定时器越走越快的问题

之前在项目中写了定时器来做循环播放,但是总是会有越走越快的问题,开始是以为前后的HTML代码拼接的有问题,时间紧急的情况下反复改了很多也没什么效果,后来发现是js定时器的问题,在这里记录一下。

JS 定时器的4种写法及介绍

JS提供了一些原生方法来实现延时去执行某一段代码,下面来简单介绍一下setTiemout、setInterval、setImmediate、requestAnimationFrame。setTimeout: 设置一个定时器,在定时器到期后执行一次函数或代码段

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!