js定时器什么时候会不准确?

270 阅读4分钟

定时器不准确的情景

定时器,相信每个写前端的小伙伴都用过,分别是在window对象上的setInterval()方法和setTimeout()方法,前者是重复调用一个函数或执行一个代码片段,在每次调用之间具有固定的时间间隔。后者则是,一旦定时器到期,就会执行一个函数或指定的代码片段。

一个非常好用的api,但是在一些场景下,他们也并不是完全靠谱的。

1 delay参数超过最大值

参数 delay 会被转换成一个有符号 32 位整数。也就是2的31次方 ,这也就将delay 限制到了 2147483647 毫秒(大约 24.8 天)以内。

但是,在js中number类型的数字采用64位浮点格式表示,除去符号位和标识指数的位数,有效存储数字的位数有52位,我们可以通过Number.MAX_VALUE查看number类型的最大安全值(1.7976931348623157e+308),也就是在正负2的53次方之间(-9007199254740992 -> +9007199254740992),如果我们将定时器的 延迟时间设置为超过2147483647的数字,定时器会立刻执行。

setInterval(() => {
  console.log('我会立刻执行');
}, 2147483648);
setInterval(() => {
  console.log('我会每间隔2147483647毫秒执行一次');
}, 2147483647);

同理setTimeout()方法也是一样的

setTimeout(() => {
  console.log('我会立刻执行');
}, 2147483648);
setTimeout(() => {
  console.log('我会在2147483647毫秒后执行');
}, 2147483647);

2. 多层嵌套

一旦对 setTimeout 的嵌套调用被安排了 5 次,浏览器将强制执行 4 毫秒的最小超时。什么意思,我们直接来看代码

let n = 0;
const fn = () => {
  n != 0 && console.log('setTimeout', Date.now());
  // 嵌套开启10个定时器
  if (n < 10) {
    n++;
    setTimeout(fn, 0);
  }
};
fn();

1.jpg

我们发现,我们定义的delay为0,但是只有前四次是同时执行的,后面的相隔了最小4毫秒

3. 非活动标签

为了优化后台标签的加载损耗(以及降低耗电量),浏览器会在非活动标签中强制执行一个最小的超时延迟。

简单来说就是,如果你的一个窗口开启了定时器,当你切换到其它窗口,你的定时器执行频率会被改变,甚至定时器失效,下面我们来实验下:

let now = Date.now();
setInterval(() => {
  console.log('setInterval', Date.now() - now);
  now = Date.now();
}, 200);

window.addEventListener('visibilitychange', () => {
  document.hidden ? console.log('窗口隐藏了') : console.log('窗口显示了');
});

2.png

我们可以看到,我们设置的执行时间是200毫秒,当我们的窗口隐藏后在,定时器的执行频率居然变成了1秒,这是setInterval的结果,那么setTimeout呢?

let now = Date.now();
for (let i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log('setTimeout', Date.now() - now);
    now = Date.now();
  }, 200 * i);
}
window.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    now = Date.now();
    console.log('窗口隐藏了');
    for (let i = 0; i < 10; i++) {
      setTimeout(() => {
        console.log('setTimeout', Date.now() - now);
        now = Date.now();
      }, 200 * i);
    }
  } else {
    console.log('窗口显示了')
  }
});

3.png

我们可以发现,setTimeout方法也有同样的问题。

如何解决

例如,我们现在要做一个页面倒计时的功能,这里提供两种思路:

1. 当用户离开页面时记录下离开的时间并关闭定时器,当用户重新回来后计算倒计时的开始时间并重新开始倒计时

let timer;
let duration = 60

function run () {
  timer = setInterval(() => {
    time.innerText = duration--
  }, 1000)
}

// 记录页面隐藏的时间
let leaveTime = undefined;
window.addEventListener('visibilitychange', () => {
  // 监听页面隐藏,关闭定时器,记录时间
  if (document.hidden) {
    clearInterval(timer)
    leaveTime = Date.now();
  } else {
    // 监听页面显示,开启定时器,
    duration -= Math.floor((Date.now() - leaveTime) / 1000)
    time.innerText = duration
    run()
  }
});

run()

2. 使用web Worker

Web Worker 为 Web 内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。

const worker = new Worker('./webWork.js');
// 通知开启60秒的倒计时
worker.postMessage(60)
// 接收倒计时秒数
worker.onmessage = function (e) {
  time.innerText = e.data
}

webWork.js


let intervalId = null;
let duration = 0;
addEventListener('message', (event) => {
  // 如果传入的倒计时秒数不为0,则开启定时器
  if (event.data !== 0) {
    duration = event.data;
    postMessage(duration)
    intervalId = setInterval(function () {
      postMessage(--duration);
    }, 1000);
  } else if (event.data === 0) {
    duration = 0
    clearInterval(intervalId);
    intervalId = null;
  }
})

4. 超时延迟

setTimeout属于宏任务,在调用 setTimeout()的线程结束之前,函数或代码片段不能被执行。

setInterval(() => {
  console.log('setTimeout', Date.now());
})
for (let i = 0; i < 10; i++){
  console.log(i)
}
console.log('同步代码', Date.now());

4.png

尽管 setTimeout 以零延迟来调用函数,但这个任务已经被放入了队列中并且等待下一次执行,并不是立即执行。

5. API不存在

WebExtension中(浏览器扩展),setTimeout() 不会可靠工作。扩展开发者应当使用 alarmsAPI 作为替代。