专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

为何说 postMessage 才是真正的 setTimeout(0)?

ins518 2025-05-15 18:22:09 技术文章 6 ℃ 0 评论

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

setTimeout 通常用于将任务分成更小的部分,以便在执行任务时用户能与页面正常交互,同时在一定的时间后还能继续之前的工作。

然而,不幸的是,大多数浏览器中的 setTimeout 并非真正的 0 毫秒延迟。因此,从这个时间来看,任务并没有尽快完成! 虽然,Chrome 已将其更改为 2 毫秒,但依然存在一些问题。

1.一起看看 setTimeout(0)延迟代码

下面的代码演示了 setTimeout 延迟,即以 0 延迟递归调用 setTimeout 100 次。

正如亲眼所见,运行 100 次迭代的执行时间约为 500 毫秒,您也可以重新执行该代码片段

function bar(iterations) {
  if (iterations === 0) {
    console.log('done in: ' + (new Date() - startTimeout) + ' msec');
  } else {
    setTimeout(bar, 0, iterations - 1);
  }
}
startTimeout = new Date();
console.log('Start1');
bar(100);

2.为什么 setTimeout 时钟不准

在现代浏览器中,setTimeout 被限制在 4ms 左右,因此协程之间的通信以及挂起、恢复的进度将非常缓慢,因为协程恢复当前是使用零内部 setTimeout 进行调度的。

有多种原因导致 setTimeout 执行可能需要比预期更长的时间。

2.1 嵌套超时

根据 HTML 标准中的规定,一旦对 setTimeout 的嵌套调用被安排了 5 次,浏览器将强制执行 4 毫秒的最小超时

下面的示例嵌套了对 setTimeout 的调用,延迟为 0 毫秒,并在每次调用处理程序时记录延迟。 前四次延迟约为 0 毫秒,之后约为 4 毫秒:

<button id="run">Run</button>
<table>
  <thead>
    <tr>
      <th>Previous</th>
      <th>This</th>
      <th>Actual delay</th>
    </tr>
  </thead>
  <tbody id="log"></tbody>
</table>

下面是 JavaScript 代码:

let last = 0;
let iterations = 10;

function timeout() {
  // 打印本地调用时间
  logline(new Date().getMilliseconds());
  if (iterations-- > 0) {
    setTimeout(timeout, 0);
  }
}
function run() {
  const log = document.querySelector('#log');
  while (log.lastElementChild) {
    log.removeChild(log.lastElementChild);
  }
  iterations = 10;
  last = new Date().getMilliseconds();
  setTimeout(timeout, 0);
}

function logline(now) {
  const tableBody = document.getElementById('log');
  const logRow = tableBody.insertRow();
  logRow.insertCell().textContent = last;
  logRow.insertCell().textContent = now;
  logRow.insertCell().textContent = now - last;
  last = now;
}

document.querySelector('#run').addEventListener('click', run);

运行以上代码多次,真实延迟数据如下:

2.2 非活动 Tab 超时

为了减少后台选项卡的负载(以及相关的电池使用量),浏览器将在非活动选项卡中强制执行最小超时延迟。 如果页面使用 Web Audio API AudioContext 播放声音,也可能会被放弃。

具体细节取决于浏览器:

  • Firefox Desktop 和 Chrome 的非活动选项卡的最小超时时间均为 1 秒。
  • Android 版 Firefox 对于非活动选项卡的最短超时时间为 15 分钟,并且可能会完全卸载。
  • 如果选项卡包含 AudioContext,则 Firefox 不会限制非活动选项卡。

2.3 跟踪脚本的限制

Firefox 对识别为跟踪脚本的脚本实施额外的限制。 前台运行时,节流最小延迟仍为 4ms。 然而,在后台选项卡中,限制最小延迟为 10,000 毫秒,即 10 秒,在文档首次加载后 30 秒生效。

2.4 延迟的 setTimeout

如果页面(或操作系统/浏览器)正忙于其他任务,则 setTimeout 也可能比预期晚触发。 需要注意的一个点是,在调用 setTimeout() 的线程终止之前,setTimeout 函数或代码片段无法执行。 例如:

function foo() {
  console.log('foo has been called');
}
setTimeout(foo, 0);
console.log('After setTimeout');

控制台将打印:

After setTimeout
foo has been called

这是因为即使 setTimeout 的延迟为零,也会被放置在队列中并计划在下一个循环运行而不是立即。 当前正在执行的代码必须在队列上的函数执行之前完成,因此结果执行顺序可能不符合预期。

2.5 页面加载期间延迟超时

Firefox 将在当前选项卡加载时推迟触发 setTimeout() 计时器。 触发会被推迟,直到主线程被视为空闲(类似于
window.requestIdleCallback()),或者直到触发 load 事件。

2.6 WebExtension 后台页面和计时器

在 WebExtensions 中,setTimeout() 无法可靠地工作。 扩展作者应该使用警报 API。

2.7 最大延迟值

浏览器在内部将延迟存储为 32 位有符号整数。 当使用大于 2,147,483,647 毫秒(约 24.8 天)的延迟时,会导致整数溢出,导致超时立即执行。

3.使用 postMessage 无限接近真正零延迟

下面的方法使用 postMessage 获得相当于 setTimeout(0) 的效果,实现真正的零延迟。

// 将setZeroTimeout添加到window上,其他通过闭包抹平副作用
(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);
  // 添加事件监听器到Window对象
  window.setZeroTimeout = setZeroTimeout;
})();

实际运行场景,setZeroTimeout 证明比 setTimeout(0) 快得多。 在 Firefox nightly 上,setZeroTimeout 的 100 次迭代的大部分约需要 10-20 毫秒,但有时会更长; 在 WebKit 构建上,需要大约 4-6 毫秒,但有时也会更长一些。

相比之下,在 Firefox 和非基于 Chromium 的 WebKit 上,setTimeout 版本大约需要一秒钟,而在 Windows 上可能更长。

以下是完整的测试代码:

(function () {
  var timeouts = [];
  var messageName = 'zero-timeout-message';
  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);
  window.setZeroTimeout = setZeroTimeout;
})();

// 下面使用setZeroTimeout方法

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();
  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);
  //执行100次,即100次setTimeout和100次setZeroTimeout
  function test2() {
    if (++i == 100) {
      var endTime = Date.now();
      printOutput(
        '100 iterations of setTimeout(0) took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
    } else {
      setTimeout(test2, 0);
    }
  }
}

参考资料

https://dbaron.org/log/20100309-faster-timeouts

https://github.com/Kotlin/kotlinx.coroutines/issues/194

https://bugs.chromium.org/p/chromium/issues/detail?id=888

https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified

https://blog.klipse.tech/javascript/2016/11/01/setTimeout-0msec.html

https://stackoverflow.com/questions/779379/why-is-settimeoutfn-0-sometimes-useful

https://dmitripavlutin.com/javascript-promises-settimeout/

https://www.educba.com/javascript-settimeout/

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表