最近在做一个需求,抢购倒计时和各种活动倒计时,使用了setTimeout 和 setInterval 然而,测试同学反馈bug倒计时: 不精确。
问题的本质
let count = 10;
const timer = setInterval(() => {
console.log(count);
count--;
if (count < 0) {
clearInterval(timer);
}
}, 1000);
理论上,这段代码应该每隔一秒输出一个数字,从 10 倒数到 0,总共耗时 10 秒。但实际运行时,发现总时间往往会超过 10 秒,有时甚至相差较大。
为什么会不精确?
JS 的事件循环(Event Loop)机制是导致这个问题的根本原因。setTimeout 和 setInterval 并不能保证在指定时间后精确执行,它们只能保证在指定时间后将回调函数加入到事件队列中。
主要几个关键问题:
- 事件队列阻塞:如果主线程正在执行耗时任务,即使定时器到期,回调函数也必须等待主线程空闲才能执行。
- 最小延迟时间:浏览器对嵌套的
setTimeout调用有最小延迟限制,通常是 4ms。这意味着即使你设置setTimeout(fn, 0),实际延迟也至少是 4ms。 - 定时器精度问题:浏览器的定时器精度有限,不同浏览器实现也不同。
- 系统休眠和后台运行:当浏览器标签页处于非活动状态或系统进入休眠状态时,定时器的行为会变得更不可预测。
- 垃圾回收和其他浏览器活动:浏览器的垃圾回收和其他内部活动也会影响定时器的精确性。
实测与分析
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');
这种方法特别适合测试倒计时功能,无需等待实际时间流逝。
实际项目
在实际项目中,我推荐以下方案:
- 对于服务器时间同步:关键的倒计时(如抢购)应该基于服务器时间,而不是客户端时间。定期与服务器同步时间差,然后在客户端计算剩余时间。
- 使用计算属性和侦听器:
<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>
- 避免使用 eval:在处理定时器时,永远不要使用字符串形式的回调函数,这不仅不安全,还会导致性能问题 :
// 不要这样做
setTimeout("console.log('时间到')", 1000);
// 正确的做法
setTimeout(() => console.log('时间到'), 1000);
- 考虑用户体验:对于 UI 显示的倒计时,精确到秒通常就足够了。如果需要毫秒级精度(如竞技游戏),考虑使用
requestAnimationFrame。 - 处理页面可见性变化:使用 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