聊聊定时器 setTimeout 的时延问题

简介: 聊聊定时器 setTimeout 的时延问题

给大家推荐一个实用面试题库

1、前端面试题库 (面试必备)            推荐:★★★★★

地址:web前端面试题库

全局的 setTimeout()  方法设置一个定时器,一旦定时器到期,就会执行一个函数或指定的代码片段,但是需要注意的是,setTimeout 并不是 ECMAScript 标准的一部分,不过几乎每一个 JS 运行时都支持了这个函数。定时器的使用比较简单,这里不再阐述,我们这篇文章主要聊下关于 setTimeout 有最小时延的相关知识。

一、为什么定时器有最小时延

其实 setTimeout 是有最小时延的,这是为什么呢?有很多因素会导致 setTimeout 的回调函数执行比设定的预期值更久,这里列举一些导致定时器出现时延的原因:

1. 函数过度嵌套

MDN 文档:在浏览器中,每调用一次定时器的最小间隔是 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 以上:

2. 非活动标签的超时

为了优化后台标签的加载损耗(以及降低耗电量),浏览器会在非活动标签中强制执行一个最小的超时延迟,如果一个页面正在使用网络音频 API 播放声音,也可以不执行该延迟。

这方面的具体情况与浏览器有关:

  • Firefox 桌面版和 Chrome 针对不活动标签都有一个 1 秒的最小超时值
  • 安卓版 Firefox 浏览器对不活动的标签有一个至少 15 分钟的超时,并可能完全卸载它们

3. 追踪型脚本的节流

Firefox 对它识别为追踪型脚本的脚本实施额外的节流,当在前台运行时,节流的最小延迟仍然是 4ms,然而,在后台标签中,节流的最小延迟是 10000 毫秒,即 10 秒,在文档首次加载后 30 秒开始生效。

4. 超时延迟

如果页面(或操作系统/浏览器)正忙于其他任务,超时也可能比预期的晚,需要注意的一个重要情况是,在调用 setTimeout() 的线程结束之前,函数或代码片段不能被执行。例如:

function foo() {
  console.log("foo 被调用");
}
setTimeout(foo, 0);
console.log("setTimeout 之后");
// 控制台输出:
// setTimeout 之后
// foo 被调用

出现这个结果的原因是,尽管 setTimeout 以 0ms 的延迟来调用函数,但这个任务已经被放入了队列中并且等待下一次执行,并不是立即执行;队列中的等待函数被调用之前,当前代码必须全部运行完毕,因此这里运行结果并非预想的那样。

5. 在加载页面时推迟超时

当前标签页正在加载时,Firefox 将推迟触发 setTimeout() 计时器,直到主线程被认为是空闲的,类似于 window.requestIdleCallback(),或者直到加载事件触发完毕,才开始触发。

二、为什么定时器最小时延是4ms

我们首先要知道是不是存在具体的规范来指定了 4ms, 还是只是业界实践的既定事实?

1. HTML standard

在 HTML standard 8.6 Timers-2020/6/23 中对于 setTimeout() 有详细的描述,我们只看其中的 10-13 行:

  1. If timeout is less than 0, then set timeout to 0.
  2. If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
  3. Increment nesting level by one.
  4. Let task's timer nesting level be nesting level.

从上面的规范可以看出来:

  1. 如果设置的 timeout 小于 0,则设置为 0
  2. 如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms

到这里,我们似乎已经找到了 4ms 的出处,并且对于 setTimeout 的最小延迟有了更加精确的定义 - 需要同时满足嵌套层级超过 5 层,timeout 小于 4ms,才会设置 4ms

2. 浏览器源码

除了寻找规范的出处之外,还可以去浏览器的源码中寻找答案,我们进入到谷歌浏览器源码中来查找用来设置计时器延时的地方:

static const int maxIntervalForUserGestureForwarding = 1000; // One second matches Gecko.
static const int maxTimerNestingLevel = 5;
static const double oneMillisecond = 0.001;
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops.  Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static const double minimumInterval = 0.004;
double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond);
if (intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel)
    intervalMilliseconds = minimumInterval;

代码逻辑很清晰,设置了几个常量:

  1. maxTimerNestingLevel = 5,也就是 HTML standard 当中提到的嵌套层级
  2. minimumInterval = 0.004,也就是 HTML standard 当中说的最小延迟

在第二段代码中我们会看到,首先会在 延迟时间 和 1ms 之间取一个最大值,换句话说,在不满足嵌套层级的情况下,最小延迟时间设置为 1ms,这也解释了为什么在 chrome 中测试 setTimeout 是上面的结果。

在 chromium 的注释中,解释了为什么要设置 minimumInterval = 4ms。简单来讲,本身 chromium 团队想要设置更低的延迟时间(其实他们期望达到亚毫秒级别),但是由于某些网站对 setTimeout 这种计时器不良的使用,设置延迟过低会导致 CPU-spinning,因此 chromium 做了些测试,选定了 4ms 作为其 minimumInterval。

到这里为止,从浏览器厂商角度和 HTML standard 规范角度都解释了 4ms 的来源和其更加精确的定义。

三、如何实现立刻执行的定时器

假设我们就需要一个「立刻执行」的定时器呢?有什么办法绕过这个 4ms 的延迟吗,其实可以采用 postMessage 来实现真正 0 延迟的定时器:

(function () {
  var timeouts = [];
  var messageName = 'message-zeroTimeout';
  // 保持 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') 的消息通知组合,来实现模拟定时器的功能,再利用上面的嵌套定时器的例子来跑一下测试:

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

1. 测试验证

从理论上来说,由于 postMessage 的实现没有被浏览器引擎限制速度,一定是比 setTimeout 要快的,这里用数据进行验证:分别用 postMessage 版定时器和传统定时器做一个递归执行计数函数的操作,看看同样计数到 100 分别需要花多少时间

function runtest() {
  let output = document.getElementById('output');
  let outputText = document.createTextNode('');
  output.appendChild(outputText);
  function printOutput(line) {
    outputText.data += line + '\n';
  }
  let i = 0;
  let startTime = Date.now();
  // 通过递归 setZeroTimeout 达到 100 计数
  // 达到 100 后切换成 setTimeout 来实验
  function test1() {
    if (++i == 100) {
      let 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) {
      let endTime = Date.now();
      printOutput(
        '100 iterations of setTimeout(0) took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
    } else {
      setTimeout(test2, 0);
    }
  }
}

结论如下:

2. Performance 面板分析

我们打开 Performance 面板,看看更直观的可视化界面中,两个版本的定时器是如何分布的:

左边的 postMessage 版本的定时器分布非常密集,大概在 5ms 以内就执行完了所有的计数任务

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

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 的回调函数,来让浏览器主线程拿回控制权,进行一些更优先的渲染任务。

给大家推荐一个实用面试题库

1、前端面试题库 (面试必备)            推荐:★★★★★

地址:web前端面试题库

相关文章
|
3月前
|
前端开发 JavaScript
前端基础(十三)_定时器(间歇定时器、延迟定时器)
本文介绍了JavaScript中定时器的使用,包括`setTimeout`和`setInterval`两种类型。`setTimeout`是实现延迟执行,即等待一定时间后执行一次指定的函数;而`setInterval`是实现间歇执行,即每隔一定时间就执行一次指定的函数。文章还介绍了如何使用`clearTimeout`和`clearInterval`来取消定时器的执行,并通过示例代码展示了定时器的创建和取消。
130 4
前端基础(十三)_定时器(间歇定时器、延迟定时器)
|
5月前
|
JavaScript
JS【详解】setTimeout 延时(含清除 setTimeout,计时开始时间,0 秒延时解析,多 setTimeout 执行顺序,setTimeout 应用场景,网红面试题)
JS【详解】setTimeout 延时(含清除 setTimeout,计时开始时间,0 秒延时解析,多 setTimeout 执行顺序,setTimeout 应用场景,网红面试题)
1164 0
用555定时器接成的多谐振荡电路的介绍
用555定时器构建的多谐振荡电路 一、引言 多谐振荡电路是一种能够产生多个频率的振荡信号的电路结构。它在音乐合成器、电子琴等设备中有着广泛的应用。本文将介绍一种使用555定时器构建的多谐振荡电路。 二、555定时器简介 555定时器是一种经典的集成电路,由三个功能单元组成:比较器、RS触发器和放大器。它可以用作脉冲发生器、频率分频器、定时器等。在多谐振荡电路中,我们将利用555定时器的单稳态多谐振荡特性来实现多个频率的振荡。 三、电路设计 1. 电路原理 多谐振荡电路的基本原理是利用555定时器的单稳态多谐振荡特性。单稳态多谐振荡是指当555定时器处于单稳态时,输出信号的频率会随着电容和
431 0
|
7月前
定时器
定时器
61 0
|
7月前
|
C#
[C#] 定时器的使用
[C#] 定时器的使用
47 0
相比 setInterval(),更推荐 setTimeout()
相比 setInterval(),更推荐 setTimeout()
98 0
|
前端开发 数据可视化 API
如何实现比 setTimeout 快 80 倍的定时器?
很多人都知道,setTimeout 是有最小延迟时间的,根据 MDN 文档 setTimeout:实际延时比设定值更久的原因:最小延迟时间[1] 中所说:
|
C#
C#中的三种定时器总结
C#中的三种定时器总结
385 0
C#中的三种定时器总结
|
前端开发 JavaScript
34、定时器
setTimeout函数用来指定某个函数或某段代码,在多少毫秒之后执行。
146 0