如何让秒杀、活动倒计时更“精确”?
背景
前端网页倒计时是非常常见的应用,我们在各大购物网站的秒杀活动中总是能见到它的身影。但是在实际情况中,我们常常会发现当网页不刷新、让倒计时程序持续运行时,显示时间相比实际时间会越来越慢,相信大家也有在秒杀时间即
将到来时不停刷新页面的经历。原因自然也不难理解:倒计时通常使用定时器(setTimeout
或者 setInterval
)实现,首先我们明白,因为JavaScript
是单线程的,在事件循环过程中,当前宏观任务队列中的微观任务会阻塞下一个宏
观任务队列中任务的执行。所以会造成一种现象,定时器中的真实执行时间并不会精准的按照第2个参数所设定的数值执行。比如设置1000毫秒,如果到了1000毫秒,主线程被其他任务所占用了,那么就会等待其它任务的执行,等其它任
务执行完毕后,才会执行定时器的回调函数。也就是说,如下代码代表的意思不是1秒后执行,而是最快1秒后执行。( JavaScript 的单线程特性使得主线程执行栈中出现阻塞时,任务队列中的异步任务并不能及时执行,因此浏览器并不能保
证在定时器设置的时间结束后代码总是被准时执行,这就造成了倒计时的偏差。)
setTimeout(() => {console.log('我是定时器!')},1000);
一般的解决方法是前端定时向服务器发送请求获取最新的时间差来校准倒计时时间,主动(程序里设置定时请求)或被动的(F5 已被用户按坏)区别而已。这个方法简单但也有点粗暴。
计时器原理
倒计时功能离不开setTimeout或setInterval这两个函数,要用好这两个函数必先了解好Javascript解释器的工作原理
前端开发同学都知道,javascript是单线程的(web worker除外),更好理解的解释是javascript解释器是单线程工作,它不能在处理一个ajax的callback的同时去处理click event的callback,而是必须按照先后队列顺序执行。
这图从上往下看,垂直方向是时间,以ms为单位,蓝色模块是执行代码所占的时间段,如第一个代码模块执行js占用了约18ms, 第二个模块执行js占用了约11ms,其他模块类似。由于js是单线程执行,同一时间只能执行一个js代码(同一时间其他异步事件执行会被阻塞 ) , 当异步事件发生时,它会进入代码执行队列,执行线程空闲时依照队列顺序依次执行代码。
第一个模块初始化了两个定时器,一个10ms延迟的setTimeout和10ms的setInterval。这些定时器可能会在我们第一个代码块执行结束之前就触发,这取决于定时器在第一个代码块中启动的位置和时间。注意,定时器虽然触发了,但是并不会立即执行,它只是把需要延迟执行的函数按时间先后加入了执行队列,在线程的某一个空闲的时间点,这个函数就能够得到执行。
按照第一个模块事件触发的顺序(Mouse Click Occurs -. 10ms Timer Fires),第一个模块代码执行结束后,按照队列中等待的先后顺序执行事件,先执行Mouse Click CallBack再执行Timer。在执行Mouse Click CallBack模块时,Interval第一次触发未执行加入队列。在执行Timer模块时,Interval第二次触发未执行加入队列。待Mouse Click CallBack和Timer模块都执行完毕后,再依次执行队列中已触发的Interval事件。后面模块由于没有阻塞的事件了,所以按照既定10ms执行Interval事件。
倒计时实现原理
基本的一个倒计时的原理非常简单了,使用setTimout或者setInterval来对一个函数进行递归或者重复调用,然后对DOM节点做对应的render处理,并对时间做倒计时的格式化处理。
现有存在的问题
参考一
尝试执行如下代码,会发现定时器的执行时间应该超过了1秒钟,如果正常执行,你可以从循环条件后面加个0。电脑配置很差的就不要试了。
setTimeout(() => {console.log('我是定时器!')}, 1000);
for (let i = 0; i<1000000000; i++) {}
碰到这种循环或者递归代码时,回调函数的执行时间会根据不同的电脑运算速度决定。如果你的电脑配置够强,比如小型机,高性能服务器等,能够在1秒以内执行完逻辑,那么就不会影响定时器的正常执行。
要想做到时间相对准确,就必须解决这个问题,办法有很多种,最常见也最有效的办法,是在当前定时器的回调函数中校验误差并调整下一次定时器的发生时间,达到平均1秒的效果。(也就是下面介绍的解决思路实现)
参考二
用现有 mobi 手机端欢迎页倒计时为例,以下是功能截图。
代码如下:
var second = 10; // 倒计时时间为 10 s
var timer;
var timer_div = $('#timer_div');
var start = new Date().getTime();
var count = 0;
clearInterval(timer);
timer = setInterval(showTime, 1000);
function showTime() {
if (second === 0) {
...
clearInterval(timer);
return false;
}
count++;
console.log(new Date().getTime() - (start + count * 1000)); // 这里代码运行结果,定时器每秒执行一次,每次输出应该是0 。
timer_div.html('<div>' + second + 's</div>');
second--;
}
以上代码实际输出如下:
结论:由于代码执行占用时间和其他事件阻塞原因,导致有些事件执行延迟了几ms,但影响还不是很大。
下面加一段阻塞线程的代码看看:
var start = new Date().getTime();
var count = 0;
// 占用线程事件
setInterval(function () {
var j = 0;
while(j++ < 100000000);
}, 0);
//定时器测试
setInterval(function () {
count++;
console.log(new Date().getTime() - (start + count * 1000));
}, 1000);
以上代码实际输出如下:
结论:由于加了很占线程的阻塞事件,导致定时器事件每次执行延迟越来越严重。
以上的阻塞线程的代码还不算很极端,假如在执行定时器的过程中有同步 ui 事件的代码,同步代码会立即执行。实际上在移动端的滚动页面中是有可能出现这种情况的,以下是一个例子。
function runForSeconds(s) {
var start = +new Date();
while (start + s * 1000 > (+new Date())) {}
}
document.body.addEventListener("click", function () {
runForSeconds(10);
}, false);
setTimeout(function () {
console.log("Done!");
}, 1000 * 3);
时间线对比:
等待 3 秒 |----1s----|----2s----|----3s----|--->console.log("Done!");
经过 2 秒 |----1s----|----2s----| ----------|-->console.log("Done!");
点击 body 后
以为是这样:|----1s----|----2s----|----3s----|--->console.log("Done!")--->|------------------10s----------------|
其实是这样:|----1s----|----2s----|------------------10s----------------|--->console.log("Done!");
结论:如果有同步的 ui 事件代码出现,实际功能的倒计时基本“失效”了,这时不同浏览器打开相同的倒计时页面往往误差非常大。
解决思路
分析一下从获取服务器时间到前端显示倒计时的过程:
-
客户端 http 请求服务器时间;
-
服务器响应完成;
-
服务器通过网络传输时间数据到客户端;
-
客户端根据活动开始时间和服务器时间差做倒计时显示;
服务器响应完成的时间其实就是服务器时间,但经过网络传输这一步,就会产生误差了,误差大小视网络环境而异,这部分时间前端也没有什么好办法计算出来,一般是几十 ms 以内,大的可能有几百 ms 。
可以得出:当前服务器时间 = 服务器系统返回时间 + 网络传输时间 + 前端渲染时间 + 常量(可选),这里重点是说要考虑前端渲染的时间,避免不同浏览器渲染快慢差异造成明显的时间不同步,这是第一点。(网络传输时间忽略或加个
常量),前端渲染时间可以在服务器返回当前时间和本地前端的时间的差值得出。
获得服务器时间后,前端进入倒计时计算和计时器显示,这步就要考虑 js 代码冻结和线程阻塞造成计时器延时问题了,思路是通过引入计数器,判断计时器延迟执行的时间来调整,尽量让误差缩小,不同浏览器不同时间段打开页面倒计时
误差可控制在 1s 以内。
// 继续线程占用
setInterval(function () {
var j = 0;
while(j++ < 100000000);
}, 0);
//倒计时
var interval = 1000,
ms = 50000, // 从服务器和活动开始时间计算出的时间差,这里测试用 50000ms
count = 0,
startTime = new Date().getTime();
if (ms >= 0) {
var timeCounter = setTimeout(countDownStart, interval);
}
function countDownStart() {
count++;
var offset = new Date().getTime() - (startTime + count * interval);
var nextTime = interval - offset;
var daytohour = 0;
if (nextTime < 0) {
nextTime = 0
};
ms -= interval;
console.log("误差:" + offset + "ms,下一次执行:" + nextTime + "ms后,离活动开始还有:" + ms + "ms");
if (ms < 0) {
clearTimeout(timeCounter);
} else {
timeCounter = setTimeout(countDownStart, nextTime);
}
}
运行结果如下:
结论:由于线程阻塞延迟问题,做了 setTimeout 执行时间的误差修正,保证 setTimeout 执行时间一致。若冻结时间特别长的,还要做特殊处理。
setTimeout
进行倒计时操作的执行。而每次执行函数时会维护一个 count 变量,用以记录已经执行过的倒计时次数,使用代码 A 处的公式可计算出当前执行倒计时的时间与实际应执行时间的偏倒计时组件
组件:http://imgcache.gtimg.cn/club/common/lib/zero/widgets/date/Date.1.1.1.js
应用项目地址:http://m.vip.qq.com/clubact/2014/jdfl/index.html?_wv=1
总结
做100%精确的倒计时很难,但做到相对比较准确是可以的。
在倒计时功能开发中,有几点总结:
1. 要了解好js单线程工作原理;
2. 清楚了解服务器系统时间传送到前端的流程;
3. 了解前端渲染和线程阻塞造成的时间误差;
参考
JS实现活动精确倒计时(推荐,与1相似,比1更全面)
前端如何实现一个倒计时组件?(推荐,react中倒计时组件使用和web worker使用)