前端倒计时为什么会走慢?怎样用服务端时间校准?
setTimeout/setInterval 只保证不早于指定延迟执行,主线程繁忙和后台标签页节流都会累积误差;倒计时应以目标时间点为准每次重新计算剩余时间,用 performance.now 计时避免系统时钟被修改,并通过接口往返估算与服务端的时间差。
参考答案
简答
- 定时器只保证不早于指定延迟执行:主线程繁忙会推迟回调,嵌套超过 5 层后最小延迟被钳制为 4ms,后台标签页会被节流到至少每秒一次甚至更低频率。
- 每次回调把计数减一的写法会累积误差;正确做法是记住结束时间点,每次用"结束时间 - 当前时间"重新计算剩余时间。
Date.now()依赖系统时钟,用户改时间会让倒计时跳变;performance.now()是单调时钟,不受系统时钟调整影响,适合计算经过的时间。- 结束时间以服务端为准:通过一次接口往返估算本地与服务端的时间差,切回前台时重新校准;关键判断(如能否下单)仍由服务端在请求时校验。
误差从哪里来
JavaScript
// 错误写法:每秒减一,误差随时间累积
let left = 60;
setInterval(() => {
left -= 1;
render(left);
}, 1000);
每次回调都可能比预期晚几毫秒到几百毫秒,长任务或页面卡顿时更多;标签页切到后台后,Chrome 会把定时器限制为每秒检查一次,隐藏超过 5 分钟且满足其他条件时降到每分钟一次。累加计数会把这些延迟全部计入,几分钟后就能看出偏差。
以目标时间计算
JavaScript
const formatRemain = (endAt, now) => {
const seconds = Math.ceil(Math.max(0, endAt - now) / 1000);
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
return `${mm}:${ss}`;
};
console.log(formatRemain(125000, 0)); // 02:05
console.log(formatRemain(1000, 1500)); // 00:00
JavaScript
function startCountdown(endAt, now, onTick) {
let timer;
const tick = () => {
clearTimeout(timer);
const current = now(); // 只取一次,显示与调度用同一时刻
const remain = endAt - current;
onTick(formatRemain(endAt, current));
if (remain <= 0) return;
// 对齐到下一个整秒,减少显示跳秒
timer = setTimeout(tick, remain % 1000 || 1000);
};
tick();
return { stop: () => clearTimeout(timer), refresh: tick };
}
即使某次回调延迟了,下一次也会显示正确的剩余时间,误差不会累积。后台恢复时(visibilitychange 变回 visible)调用返回的 refresh() 立即刷新,它会先清掉旧定时器,不会出现两条计时链。
服务端时间校准
JavaScript
async function syncOffset() {
const t0 = performance.now();
const res = await fetch('/api/time');
const t1 = performance.now();
const { serverTime } = await res.json(); // 服务端毫秒时间戳
// 假设往返延迟对称,响应生成于往返中点
const serverAtT1 = serverTime + (t1 - t0) / 2;
return { serverAtT1, perfAtT1: t1 };
}
// 之后任意时刻的服务端时间估算
const serverNow = ({ serverAtT1, perfAtT1 }) => serverAtT1 + (performance.now() - perfAtT1);
以 performance.now() 作为本地流逝时间,用户修改系统时间不影响结果。网络往返不对称时估算会有误差,可以多次请求取往返时间最短的那次。页面从后台恢复或从休眠唤醒时重新校准:MDN 指出只有 Windows 上的浏览器在系统休眠期间仍让 performance.now() 继续计时。
把 serverNow 作为上面 startCountdown 的 now 参数传入即可。
业务边界
- 前端倒计时只是展示,"到点可抢购"必须由服务端在请求到达时判断,提前发出的请求应被拒绝。
- 大量用户同时到点请求会形成尖峰,可以在开始前几秒随机错开预热请求,或由服务端排队。
- 倒计时结束后应从服务端重新拉取状态,而不是假定状态已切换。
定时器的测试方法见《前端怎样测试定时器、重试和超时,避免 Fake Timers 造成假通过?》。
常见追问
requestAnimationFrame更准吗?它随屏幕刷新执行,后台标签页会暂停,不适合作为倒计时的时间来源,只适合做动画显示。- Web Worker 里的定时器能避免节流吗?不能保证,各浏览器策略不同,仍需按目标时间重新计算。
- 毫秒级倒计时怎么做?用
requestAnimationFrame刷新显示,剩余时间依然用目标时间计算。