最开始查到这个是因为在做版本更新弹窗的定时器轮询,然后查看到一篇文章关于前端倒计时存在误差,从而衍生出高并发秒杀场景下,应该如何实现倒计时工具:
最开始想到的办法是setTimeout。
但因为setTimeout本身因为JavaScript是单线程,setTimeout 的回调函数会被放入事件队列,浏览器资源分配中前面任务阻塞延迟的问题会存在一定性的误差,但是误差较小,大部分场景都可以忽略。
但是如果切换Tab页或者最小化之后再切换到前台,因为浏览器会识别当前页面需要资源减少,降低定时器的执行频率,直到切回来后才会恢复原本的轮询间隔,所以这样切回来的那一次计算时间会有误差。
对此因为项目会在第一次挂载的时候立刻弹窗以及切换到后台再切回前台的时候再次立刻弹窗,所以只需要在切换回来的时候立刻执行一次versionListener,并删除定时器,重新创建一个新的定时器就能恢复原本的时限误差
如果没有以上的策略,坚持原本的定时器,并且不希望出现误差的话,可以使用web worker单独处理定时器。
Web Worker 是独立于主线程的后台线程,不受前台 / 后台标签的节流规则限制,其事件循环和定时器执行逻辑完全独立,因此切后台后仍能保持接近预期的执行频率
面试官:前端倒计时有误差怎么解决前言 去年遇到的一个问题,也是非常经典的面试题了。能聊的东西还蛮多的 倒计时为啥不准 一 - 掘金 (juejin.cn)
看了一下这个里面的一个评论:
setTimeout 的延时不应当被依赖用来进行倒计时,因为它有非常多的不稳定因素(参见MDN)。
最佳实践是将倒计时结束的时间戳计算出来,再用 setTimeout 或者 requestAnimationFrame 进行更新(计算结束和当前时间戳的时间差,舍入到秒数然后更新到页面上),这种方式误差最小且最节能。至于需要和服务器实时同步的场景则考虑sse授时。
具体为:
- 定时器的本质 :
setTimeout的delay是「事件循环中任务的最小等待时间」,如果队列中有其他任务,实际执行时间会更长; - requestAnimationFrame 优势 :浏览器会在重绘前执行回调,前台 60fps(约 16ms / 次),后台自动暂停,比
setInterval节能 90%+; - 时间戳选择 :
performance.now()是高精度时间戳(微秒级),且不受系统时间修改影响,优先于Date.now()。
由此衍生两种核心方法:
- 计算出倒计时结束时间,轮询是否到达结尾时间,进行报时,performanceAPI在Node环境下无法使用,只支持浏览器环境
js
function preciseCountdown(endTime:number, onUpdate:(seconds:number)=>void, onComplete:()=>void) {
// 高精度时间戳(优先用 performance.now,无兼容问题时用 Date.now)
const getNow = () => performance.now() + performance.timeOrigin;
const update = () => {
const now = getNow();
const remaining = Math.max(endTime - now, 0); // 剩余时间(ms)
const remainingSeconds = Math.floor(remaining / 1000); // 舍入到秒
// 更新页面(只传最终要显示的秒数,避免浮点误差),在高频报数场景下每秒报数发生变化由此实现倒计时
//实际上是10->10->...(省略n个10)->10 ->9->9->...->9->8....
onUpdate(remainingSeconds);
if (remaining <= 0) {
onComplete();
return;
}
// 优先用 requestAnimationFrame(更节能,适配屏幕刷新率)
// 降级用 setTimeout(兼容老环境,延时设为 16ms 接近 60fps)
if (requestAnimationFrame) {
//进行高频报数展示
requestAnimationFrame(update);
} else {
setTimeout(update, 16);
}
};
// 立即执行一次更新,避免首屏延迟
update();
}
// 用法示例:倒计时 10 秒
const endTime = Date.now() + 10 * 1000;
preciseCountdown(
endTime,
(seconds) => {
console.log('剩余秒数:', seconds);
// 更新 DOM:document.getElementById('countdown').textContent = seconds;
},
() => {
console.log('倒计时结束');
}
);
- 使用后端sse进行处理,相比于普通的接口请求,sse具备高度的实时性
| 对比维度 | 普通后端接口(HTTP/REST) | SSE(Server-Sent Events) |
|---|---|---|
| 通信方向 | 双向(客户端请求 → 服务端响应),但「请求 - 响应」是单次单向 | 单向(服务端 → 客户端),服务端主动推送 |
| 连接模式 | 短连接:请求发起 → 响应返回 → TCP 连接关闭 | 长连接:一次 TCP 握手后,连接持续保持,服务端按需推数据 |
| 触发方式 | 客户端主动触发(点击、定时轮询、页面加载) | 服务端主动触发(数据更新 / 事件发生时推送) |
| 数据传输形式 | 单次完整数据(JSON/Form/ 二进制),响应结束即终止 | 流式文本数据(UTF-8),分块传输(一行 / 多行数据) |
| 实时性 | 低(轮询依赖间隔,有延迟) | 高(数据更新立即推送,无轮询延迟) |
| 网络开销 | 高(轮询场景下多次 TCP 握手、重复请求头) | 低(一次连接持续传输,仅首次握手开销) |
ini
// SSE:客户端建立连接,监听服务端推送
const sse = new EventSource('/api/sse/countdown');
sse.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log('服务端推送的实时数据:', data); // 服务端有更新就会触发
};
- 普通接口:默认是「短连接」------TCP 三次握手后传输数据,响应完成后四次挥手关闭连接;若要实时性,需用「定时轮询」(如每 1 秒请求一次),但会产生大量重复的 TCP 握手 / 挥手开销,且有轮询间隔的延迟。
- SSE :是「HTTP 长连接」------ 一次 TCP 握手后,连接保持打开状态,服务端通过「分块传输编码(Chunked Transfer Encoding)」向客户端流式推送数据,直到连接被主动关闭(客户端
close()或服务端断开)。优势:无轮询的重复开销,实时性接近 "即时" - WebSocket 是双向通信(客户端↔服务端)、基于独立的 WebSocket 协议、功能更强但复杂度更高;常用于双向实时交互(如聊天、在线游戏)场景。
目前的电商秒杀倒计时用的主流方案应该是performance计算最终时间-起始时间,而不是sse,因为sse在电商这类高并发场景下需要大量的后端开销去建立链接窗口。
在进行秒杀时间校准的场景下,如何防止用户手动修改本地时间,主要依赖三个参数:
- 后端在秒杀开始前会传给前端接口秒杀开始的准确时间戳,比如xxxxxxxxxx
- 前端通过performance.timeOrigin获取绝对起始时间,这个时间一旦定下为YYYYYYY,无论中途用户修改多少次本地时间都不会发生变化
- 前端在建立performance.timeOrigin的时候会绑定建立performance.now(),为绝对流逝时间,也就是相对performance.timeOrigin绝对起始时间变化的时间,不受本地时间影响
最终xxxxxxxxxx-(performance.timeOrigin+performance.now())就是需要倒计时的时间