setTimeout 和 setInterval 做倒计时不精准,以及替代方案

3,900 阅读5分钟

最近在做一个需求,抢购倒计时和各种活动倒计时,使用了setTimeoutsetInterval 然而,测试同学反馈bug倒计时: 不精确

问题的本质

let count = 10;
const timer = setInterval(() => {
  console.log(count);
  count--;
  if (count < 0) {
    clearInterval(timer);
  }
}, 1000);

理论上,这段代码应该每隔一秒输出一个数字,从 10 倒数到 0,总共耗时 10 秒。但实际运行时,发现总时间往往会超过 10 秒,有时甚至相差较大。

为什么会不精确?

JS 的事件循环(Event Loop)机制是导致这个问题的根本原因。setTimeoutsetInterval 并不能保证在指定时间后精确执行,它们只能保证在指定时间后将回调函数加入到事件队列中。

主要几个关键问题:

  1. 事件队列阻塞:如果主线程正在执行耗时任务,即使定时器到期,回调函数也必须等待主线程空闲才能执行。
  2. 最小延迟时间:浏览器对嵌套的 setTimeout 调用有最小延迟限制,通常是 4ms。这意味着即使你设置 setTimeout(fn, 0),实际延迟也至少是 4ms。
  3. 定时器精度问题:浏览器的定时器精度有限,不同浏览器实现也不同。
  4. 系统休眠和后台运行:当浏览器标签页处于非活动状态或系统进入休眠状态时,定时器的行为会变得更不可预测。
  5. 垃圾回收和其他浏览器活动:浏览器的垃圾回收和其他内部活动也会影响定时器的精确性。

实测与分析

const startTime = Date.now();
let count = 0;

const timer = setInterval(() => {
  count++;
  const currentTime = Date.now();
  const elapsedTime = currentTime - startTime;
  const expectedTime = count * 1000;
  const drift = elapsedTime - expectedTime;
  
  console.log(`第 ${count} 秒,预期:${expectedTime}ms,实际:${elapsedTime}ms,偏差:${drift}ms`);
  
  if (count >= 60) {
    clearInterval(timer);
  }
}, 1000);

运行这段代码一分钟,会发现随着时间推移,偏差会越来越大。在我的测试中,一分钟后的偏差通常在 200-500ms 之间,有时甚至更大。

在高负载情况下,问题会更加明显。如果主线程忙于处理其他任务(如复杂的 DOM 操作或大量计算),偏差会急剧增加。

替代方案

既然了解了问题所在,我们来看几种更精确的倒计时实现方案。

1. 基于 requestAnimationFrame 的实现

requestAnimationFrame 提供了一种与显示器刷新率同步的方式来执行动画,通常为 60fps(约 16.7ms 一帧)。虽然它本身不是为了计时而设计的,但我们可以利用它来创建更精确的定时器:

function createPreciseTimer(callback, interval) {
  let expectedTime = performance.now() + interval;
  
  function step(currentTime) {
    const deltaTime = currentTime - expectedTime;
    
    if (deltaTime >= 0) {
      callback();
      // 调整下一次期望执行的时间,考虑累积误差
      expectedTime += interval;
    }
    
    requestAnimationFrame(step);
  }
  
  requestAnimationFrame(step);
}

// 使用示例
let count = 10;
createPreciseTimer(() => {
  console.log(count);
  count--;
  if (count < 0) {
    // 这里需要额外的清理机制
  }
}, 1000);

这种方法的优点是能够自我校正,减少累积误差。但缺点是 requestAnimationFrame 在非活动标签页中会被暂停,而且持续运行会消耗更多资源。

2. 基于时间戳的校正方法

这种方法不是试图让每次回调精确地在固定间隔后执行,而是根据实际经过的时间来调整显示:

function createAccurateTimer(callback, duration, step = 1000) {
  const startTime = performance.now();
  const endTime = startTime + duration;
  
  function update() {
    const currentTime = performance.now();
    const remaining = Math.max(0, endTime - currentTime);
    const elapsed = duration - remaining;
    const progress = elapsed / duration;
    
    callback(remaining, progress);
    
    if (remaining > 0) {
      // 计算下一次更新的延迟时间
      const nextUpdateIn = Math.min(step, remaining);
      setTimeout(update, nextUpdateIn);
    }
  }
  
  update();
}

// 使用示例
createAccurateTimer((remaining, progress) => {
  const seconds = Math.ceil(remaining / 1000);
  console.log(`剩余 ${seconds} 秒,进度 ${(progress * 100).toFixed(2)}%`);
}, 10000); // 10秒倒计时

这种方法的优点是显示的时间总是基于实际经过的时间,不会出现明显的偏差。缺点是回调执行的间隔可能不均匀。

3. Web Workers

如果主线程的负载是导致定时器不准确的主要原因,我们可以考虑使用 Web Worker 来在单独的线程中运行定时器:

// timer-worker.js
self.onmessage = function(e) {
  if (e.data.command === 'start') {
    const interval = e.data.interval;
    const duration = e.data.duration;
    const startTime = Date.now();
    const endTime = startTime + duration;
    
    function tick() {
      const now = Date.now();
      if (now < endTime) {
        self.postMessage({
          remaining: endTime - now,
          elapsed: now - startTime
        });
        setTimeout(tick, interval);
      } else {
        self.postMessage({
          remaining: 0,
          elapsed: duration,
          done: true
        });
      }
    }
    
    tick();
  }
};

主线程使用:

const timerWorker = new Worker('timer-worker.js');

timerWorker.onmessage = function(e) {
  const data = e.data;
  console.log(`剩余: ${Math.ceil(data.remaining / 1000)}秒`);
  
  if (data.done) {
    console.log('倒计时结束');
    timerWorker.terminate();
  }
};

timerWorker.postMessage({
  command: 'start',
  interval: 1000,
  duration: 10000 // 10秒
});

Web Worker 的优点是不受主线程阻塞的影响,缺点是创建和通信有一定开销,不适合短时间的简单倒计时。

4. 使用 Playwright 的 Clock API 进行测试

在测试环境中,我们可以使用 Playwright 的 Clock API 来模拟时间流逝,这对于测试倒计时功能特别有用 :

// 初始化时钟并设置固定时间
await page.clock.install({ time: new Date('2024-02-02T08:00:00') });
await page.goto('http://localhost:3333');

// 快进时间到特定点
await page.clock.pauseAt(new Date('2024-02-02T10:00:00'));

// 断言页面状态
await expect(page.getByTestId('countdown')).toHaveText('00:00:00');

// 再次快进时间
await page.clock.fastForward('30:00');

这种方法特别适合测试倒计时功能,无需等待实际时间流逝。

实际项目

在实际项目中,我推荐以下方案:

  1. 对于服务器时间同步:关键的倒计时(如抢购)应该基于服务器时间,而不是客户端时间。定期与服务器同步时间差,然后在客户端计算剩余时间。
  2. 使用计算属性和侦听器
<template>
  <div>
    <p>{{ formattedTime }}</p>
  </div>
</template>
<script>
export default {
  props: {
    targetDate: {
      type: [Date, Number],
      required: true
    }
  },
  data() {
    return {
      now: Date.now(),
      timerId: null
    }
  },
  computed: {
    remaining() {
      return Math.max(0, this.targetDate - this.now);
    },
    seconds() {
      return Math.floor((this.remaining / 1000) % 60);
    },
    minutes() {
      return Math.floor((this.remaining / (1000 * 60)) % 60);
    },
    hours() {
      return Math.floor((this.remaining / (1000 * 60 * 60)) % 24);
    },
    formattedTime() {
      return `${this.hours.toString().padStart(2, '0')}:${this.minutes.toString().padStart(2, '0')}:${this.seconds.toString().padStart(2, '0')}`;
    }
  },
  created() {
    // 服务端渲染期间不会执行 [^1]
    this.updateTime();
  },
  methods: {
    updateTime() {
      this.now = Date.now();
      
      if (this.remaining > 0) {
        this.timerId = setTimeout(this.updateTime, 1000);
      }
    }
  },
  beforeDestroy() {
    if (this.timerId) {
      clearTimeout(this.timerId);
    }
  }
}
</script>
  1. 避免使用 eval:在处理定时器时,永远不要使用字符串形式的回调函数,这不仅不安全,还会导致性能问题 :
// 不要这样做
setTimeout("console.log('时间到')", 1000);

// 正确的做法
setTimeout(() => console.log('时间到'), 1000);
  1. 考虑用户体验:对于 UI 显示的倒计时,精确到秒通常就足够了。如果需要毫秒级精度(如竞技游戏),考虑使用 requestAnimationFrame
  2. 处理页面可见性变化:使用 Page Visibility API 来处理页面切换到后台的情况:
<template>
  <div>
    <p>倒计时:{{ count }}</p>
  </div>
</template>
<script>
export default {
  data() {
    return {
      count: 10,
      lastTime: 0,
      rafId: null,
      interval: 1000
    }
  },
  methods: {
    tick() {
      const now = performance.now();
      const deltaTime = now - this.lastTime;
      
      if (deltaTime >= this.interval) {
        this.lastTime = now;
        this.count--;
        
        if (this.count < 0) {
          cancelAnimationFrame(this.rafId);
          return;
        }
      }
      
      this.rafId = requestAnimationFrame(this.tick);
    },
    handleVisibilityChange() {
      if (document.hidden) {
        // 页面不可见,停止 requestAnimationFrame
        cancelAnimationFrame(this.rafId);
        this.lastTime = performance.now();
      } else {
        // 页面可见,重新开始
        this.tick();
      }
    }
  },
  mounted() {
    this.lastTime = performance.now();
    document.addEventListener('visibilitychange', this.handleVisibilityChange);
    this.tick();
  },
  beforeDestroy() {
    cancelAnimationFrame(this.rafId);
    document.removeEventListener('visibilitychange', this.handleVisibilityChange);
  }
}
</script>

总结

  • 对于简单的 UI 倒计时,基于时间戳的校正方法通常足够。
  • 对于需要高精度的场景,可以考虑 requestAnimationFrame 或 Web Worker。
  • 对于关键业务,应该结合服务器时间同步来确保准确性。

技术沟通交流VX:1010368236