JS定时器不可靠的原因及解决方案

语言: CN / TW / HK

前言

在工作中应用定时器的场景非常多,但你会发现有时候定时器好像并没有按照我们的预期去执行,比如我们常遇到的 setTimeout(()=>{},0) 它有时候并不是按我们预期的立马就执行。想要知道为什么会这样,我们首先需要了解 Javascript 计时器 的工作原理。

定时器工作原理

为了理解计时器的内部工作原理,我们首先需要了解一个非常重要的概念: 计时器设定的延时是没有保证的。因为所有在浏览器中执行的JavaScript单线程异步事件(比如鼠标点击事件和计时器)都只有在它有空的时候才执行。

这么说可能不是很清晰,我们来看下面这张图

图中有很多信息需要消化,但是完全理解它会让您 更好地了解异步JavaScript执行 是如何工作的。这张图是一维的:垂直方向是(挂钟)时间,单位是毫秒。蓝色框表示正在执行的JavaScript部分。例如,第一个JavaScript块执行大约18ms,鼠标点击块执行大约11ms,以此类推。

​ 由于 JavaScript一次只能执行一段代码(由于它的单线程特性) ,所以每一段代码都会“阻塞”其他异步事件的进程。这意味着, 当异步事件发生时(如鼠标单击、计时器触发或XMLHttpRequest完成),它将排队等待稍后执行。

​ 首先,在JavaScript的第一个块中,启动了两个计时器:一个10ms的setTimeout和一个10ms的setInterval。由于计时器是在哪里和什么时候启动的,它实际上在我们实际完成第一个代码块之前触发,但是请注意,它不会立即执行(由于线程的原因,它无法这样做)。相反,被延迟的函数被排队,以便在下一个可用的时刻执行。

​ 此外,在第一个JavaScript块中,我们看到鼠标单击发生。与此异步事件相关联的JavaScript回调(我们永远不知道用户何时会执行某个动作,因此它被认为是异步的)无法立即执行,因此,就像初始计时器一样,它被排队等待稍后执行。

​ 在JavaScript的初始块完成执行后,浏览器会立即问一个问题:等待执行的是什么?在本例中,鼠标单击处理程序和计时器回调都在等待。然后浏览器选择一个(鼠标点击回调)并立即执行它。计时器将等待到下一个可能的时间,以便执行。

setInterval调用被废弃

在click事件执行时,第20毫秒处,第二个 setInterval 也到期了,因为此时已经click事件占用了线程,所以 setInterval 还是不能被执行,并且因为此时 队列中已经有一个 setInterval 正在排队等待执行,所以这一次的 setInterval 的调用将被废弃

浏览器不会对同一个setInterval处理程序多次添加到待执行队列。

​ 实际上,我们可以看到,当第三个interval回调被触发时,interval本身正在执行。这向我们展示了一个重要的事实:interval并不关心当前执行的是什么,它们将不加区别地排队,即使这意味着回调之间的时间间隔将被牺牲。

setTimeout / setInterval 无法保证准时执行回调函数

​ 最后,在第二个interval回调执行完成后,我们可以看到JavaScript引擎没有任何东西可以执行了。这意味着浏览器现在等待一个新的异步事件发生。当interval再次触发时,我们会在50ms处得到这个值。但是这一次,没有任何东西阻碍它的执行,因此它立即触发。

OK,总的来说造成JS定时器不可靠的原因就是JavaScript是单线程的,一次只能执行一个任务,而setTimeout() 的第二个参数(延时时间)只是告诉 JavaScript 再过多长时间把当前任务添加到队列中。如果队列是空的,那么添加的代码会立即执行;如果队列不是空的,那么它就要等前面的代码执行完了以后再执行定时器任务必须等主线程任务执行才可能开始执行,无论它是否到达我们设置的时间

这里我们可以再来了解下Javascript的事件循环

事件循环

JavaScript中所有的任务分为同步任务与异步任务,同步任务,顾名思义就是立即执行的任务,它一般是直接进入到主线程中执行。而我们的异步任务则是进入任务队列等待主线程中的任务执行完再执行。

任务队列是一个事件的队列,表示相关的异步任务可以进入执行栈了。主线程读取任务队列就是读取里面有哪些事件。

队列是一种 先进先出 的数据结构。

上面我们说到异步任务又可以分为宏任务与微任务,所以任务队列也可以分为 宏任务队列微任务队列

  • Macrotask Queue:进行比较大型的工作,常见的有 setTimeout,setInterval ,用户交互操作,UI渲染等;

  • Microtask Queue:进行较小的工作,常见的有Promise,Process.nextTick;

  1. 同步任务直接放入到主线程执行,异步任务(点击事件,定时器,ajax等)挂在后台执行,等待I/O事件完成或行为事件被触发。
  2. 系统后台执行异步任务,如果某个异步任务事件(或者行为事件被触发),则将该任务添加到任务队列,并且每个任务会对应一个回调函数进行处理。
  3. 这里异步任务分为宏任务与微任务,宏任务进入到宏任务队列,微任务进入到微任务队列。
  4. 执行任务队列中的任务具体是在执行栈中完成的,当主线程中的任务全部执行完毕后,去读取微任务队列,如果有微任务就会全部执行,然后再去读取宏任务队列
  5. 上述过程会不断的重复进行,也就是我们常说的 事件循环(Event-Loop)

这里更详细的内容可以看我之前的文章 探索JavaScript执行机制

导致定时器不可靠的原因

当前任务执行时间过久

JS 引擎会先执行同步的代码之后才会执行异步的代码,如果同步的代码执行时间过久,是会导致异步代码延迟执行的。

setTimeout(() => {
  console.log(1);
}, 20);
for (let i = 0; i < 90000000; i++) { } 
setTimeout(() => {
  console.log(2);
}, 0);

这个按预期应该是会先打印出2,然后再打印1,但事实并不是如此,就算第二个定时器的时间更短,但中间那个for循环的执行时间远远超过了这两个定时器设定的时间。

setTimeout 设置的回调任务是 按照顺序添加到延迟队列里面的 ,当执行完一个任务之后, ProcessDelayTask 函数会根据发起时间和延迟时间来计算出到期的任务,然后 依次执行 这些到期的任务。

在执行完前面的任务之后,上面例子的两个 setTimeout 都到期了,那么按照顺序执行就是打印 12 。所以在这个场景下, setTimeout 就显得不那么可靠了。

延迟执行时间有最大值

包括 IE, Chrome, Safari, Firefox 在内的浏览器其内部以32位带符号整数存储延时。这就会导致如果一个延时(delay)大于 2147483647 毫秒 (大约24.8 天)时就会溢出,导致定时器将会被立即执行。(MDN)

setTimeout 的第二个参数设置为 0 (未设置、小于 0 、大于 2147483647 时都默认为 0 )的时候,意味着马上执行,或者尽快执行。

setTimeout(function () {
  console.log("你猜它什么时候打印?")
}, 2147483648);

把这段代码放到浏览器控制台执行,你会发现它会立马打印出 你猜它什么时候打印?

最小延时>=4ms(嵌套使用定时器)

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

  • setTimeout 的第二个参数设置为 0 (未设置、小于 0 、大于 2147483647 时都默认为 0 )的时候,意味着马上执行,或者尽快执行。

  • 如果延迟时间小于 0 ,则会把延迟时间设置为 0 。如果定时器嵌套 5 次以上并且延迟时间小于 4ms ,则会把延迟时间设置为 4ms

function cb() { f(); setTimeout(cb, 0); }
setTimeout(cb, 0);

在Chrome 和 Firefox中, 定时器的第5次调用被阻塞了;在Safari是在第6次;Edge是在第3次。所以后面的定时器都最少被延迟了4ms

未被激活的tabs的定时最小延迟>=1000ms

浏览器为了优化后台tab的加载损耗(以及降低耗电量),在未被激活的tab中定时器的最小延时限制为1S(1000ms)。

let num = 100;
function setTime() {
  // 当前秒执行的计时
  console.log(`当前秒数:${new Date().getSeconds()} - 执行次数:${100-num}`);
  num ? num-- && setTimeout(() => setTime(), 50) : "";
}
setTime();

这里我在39秒时切到了其他标签页,我们会发现它后面的执行间隔都是1秒执行一次,并不是我们设定的50ms。

setInterval的处理时长不能比设定的间隔长

setInterval 的处理时长不能比设定的间隔长,否则 setInterval 将会没有间隔的重复执行

但是对这个问题,很多情况下,我们并不能清晰的把控处理程序所消耗的时长,为了能够 按照一定的间隔周期性的触发定时器 ,我们可以使用 setTimeout 来代替 setInterval 执行。

setTimeout(function fn(){
  // todo
  setTimeout(fn,10)
    // 执行完处理程序的内容后,在末尾再间隔10毫秒来调用该程序,这样就能保证一定是10毫秒的周期调用,这里时间按自己的需求来写
},10)

解决方案

方法一:requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行,理想状态下回调函数执行次数通常是每秒60次(也就是我们所说的60fsp),也就是每16.7ms 执行一次,但是并不一定保证为 16.7 ms。

const t = Date.now()
function mySetTimeout (cb, delay) {
  let startTime = Date.now()
  loop()
  function loop () {
    if (Date.now() - startTime >= delay) {
      cb();
      return;
    }
    requestAnimationFrame(loop)
  }
}
mySetTimeout(()=>console.log('mySetTimeout' ,Date.now()-t),2000) //2005
setTimeout(()=>console.log('SetTimeout' ,Date.now()-t),2000) // 2002

这种方案看起来像是增加了误差,这是因为requestAnimationFrame每16.7ms 执行一次,因此它不适用于间隔很小的定时器修正。

方法二: Web Worker

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用 XMLHttpRequest 执行 I/O (尽管 responseXMLchannel 属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。

Web Worker 的作用就是 为 JavaScript 创造多线程环境 ,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程不会被阻塞或拖慢。

// index.js
let count = 0;
//耗时任务
setInterval(function(){
  let i = 0;
  while(i++ < 100000000);
}, 0);

// worker 
let worker = new Worker('./worker.js')
// worker.js
let startTime = new Date().getTime();
let count = 0;
setInterval(function(){
    count++;
    console.log(count + ' --- ' + (new Date().getTime() - (startTime + count * 1000)));
}, 1000);

这种方案体验整体上来说还是比较好的,既能较大程度修正计时器也不影响主进程任务

总结

由于js的单线程特性,所以会有事件排队、先进先出、setInterval调用被废弃、定时器无法保证准时执行回调函数以及出现setInterval的连续执行。