背景:
前端倒计时
的功能在很多业务场景都可以用到,比如活动开始、结束、秒杀倒计时等等等…我最近在处理流程审批及合同签署的项目,也涉及到倒计时功能,但在开发过程中遇到一些麻烦和坑点,下面和大家分享一下最后是如何解决的:
1、为什么使用setTimeout实现倒计时,而不是setInterval?
正常来说,定时器功能主要由setTimeout()
和setInterval()
这两个函数来完成,它们的内部运行机制完全一样,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。(区别在于前者指定的代码是一次性执行,后者则为反复执行
。)
- 那么为什么使用
setTimeout
实现倒计时,而不是setInterval
呢?
setInterval
指定的是 “开始执行”之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间
。比如,setInterval指定每 100ms 执行一次,每次执行需要 5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长,比如需要105毫秒,那么它结束后,下一次执行就会立即开始。
为了确保两次执行之间有固定的间隔,可以不用setInterval,而是每次执行结束后,使用setTimeout指定下一次执行的具体时间。
var timer = setTimeout(function f() {
// ...
timer = setTimeout(f, 1000);
}, 1000);
上面代码可以确保,下一次执行总是在本次执行结束之后的1000毫秒开始。(当然存在较小误差,不然就没这道题了。。)
setTimeout
的作用是 将代码推迟到指定时间执行 ,如果指定时间为0setTimeout(f, 0),那么会立刻执行吗?
答案是不会。因为必须要等到当前脚本的同步任务,全部处理完以后,才会执行setTimeout指定的回调函数f。也就是说,setTimeout(f, 0)会在下一轮事件循环
一开始就执行。
2、setTimeout倒计时为什么会出现误差?
首先,js是单线程,同一时间只能做一件事情。如果前面一个任务执行时间很长(比如网络请求),后面就必须的等待很长时间。为了解决这个问题,js分为同步任务和异步任务。js会先执行同步任务,执行完后,才会去执行异步任务,异步任务一般放在异步队列中。也就是执行完同步任务后,会不断从异步队列中取出要执行的任务放在主栈中执行,这个过程就称为"event-loop
"。异步队列分为宏任务队列和微任务队列,微任务队列执行顺序大于宏任务队列。
所以:setTimeout是一个异步的宏任务,当执行setTimeout时是将回调函数在指定的时间之后放入到宏任务队列。但如果此时主线程有很多同步代码在等待执行,或者微任务队列以及当前宏任务队列之前还有很多任务在排队等待执行,那么要等他们执行完成之后setTimeout的回调函数才会被执行,因此 并不能保证在setTimeout中指定的时间立刻执行回调函数
所以,
setTimeout出现误差是因为
:1. 没有考虑误差时间(函数执行的时间/其它代码的阻塞)
2. 没有考虑浏览器的“休眠”
如果上面这个大家看不太懂,可以转战我的另一篇文章,先了解一下**event-loop
**
3、那么如何解决呢?
方法一:获取服务器时间到前端显示倒计时
先分析一下过程:
1. 客户端http请求服务器时间;
2. 服务器响应完成;
3. 服务器通过网络传输时间数据到客户端;
4. 客户端根据活动开始时间和服务器时间差做倒计时显示;
服务器响应完成的时间其实就是服务器时间,但经过网络传输这一步,就会产生误差了,误差大小视网络环境而异,这部分时间前端也没有什么好办法计算出来,一般是几十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执行时间一致。若冻结时间特别长的,还要做特殊处理。
方法二:web worker
Web Worker 是HTML5标准的一部分,允许一段JavaScript程序运行在主线程之外的另外一个线程中。因此可以考虑在主线程之外再创建一个 worker 线程用来处理花费大量时间的任务,这样主线程的 UI 渲染就不会被阻塞了。
Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用XMLHttpRequest执行 I/O (尽管responseXML和channel属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。
在这里,我就以vue项目为例:vue中使用web worker 实现倒计时
1、首先,引入worker
yarn add worker-loader -D
vue.config.js文件:
// 解决打包的时报错
parallel: false,
chainWebpack: (config) => {
// set worker-loader
config.module
.rule('worker')
.test(/\.worker\.js$/)
.use('worker-loader')
.loader('worker-loader')
.options({
inline: 'fallback',
filename: 'workerName.[hash].worker.js',
})
.end();
// 解决:worker 热更新问题
config.module.rule('js').exclude.add(/\.worker\.js$/);
// 删除splitChunks 请勿删除
config.optimization.delete('splitChunks');
config.plugin('define').tap((args) => {
const [define] = args;
Object.assign(define, {
IS_APPLET: JSON.stringify(true),
UNIQUE_MARK: JSON.stringify(uniqueMark),
});
return args;
});
},
2、一个专门管理worker交互的js文件
import Worker from './count.worker';
const worker = new Worker();
// 初始化倒计时
export function initCount() {
worker.postMessage({
type: 'init',
data: {},
});
}
// 暂停时间
export function pauseTime(data) {
worker.postMessage({
type: 'pause',
data,
});
}
// 清除计时器
export function clearTimer() {
worker.postMessage({
type: 'clear',
data: {},
});
}
// 移除worker
export function removeWorker() {
worker.terminate();
}
// 根据接口返回的时间校准考试剩余时间
export function alignTime(data) {
worker.postMessage({
type: 'align',
data,
});
}
// 开启倒计时
export function startCount (data) {
worker.postMessage({
type: 'start',
data,
});
}
// 获取worker
export function getWorker () {
return worker;
}
3、使用 Worker,在worker线程中执行我们的倒计时
const countObj = {
// 存储settimeout
timer: null,
// 剩余倒计时时间
examTime: -1,
// 倒计时是否暂停的标识
stopTimeStatus: false,
setTimer(data) {
this.timer = data;
},
setExamTime(data) {
this.examTime = data;
},
setStopTimeStatus(data) {
this.stopTimeStatus = data;
},
};
function countDown() {
if (countObj.timer) {
return;
}
if (countObj.timer === 0) {
postMessage(['timeEnd', 0]);
return;
}
// const start = new Date().getTime();
countObj.timer = setTimeout(() => {
countObj.timer = null;
if (!countObj.getStopTimeStatus()) {
countObj.setExamTime(countObj.getExamTime() - 1);
}
// const end = new Date().getTime();
// console.log('误差', end - start);
postMessage(['countDown', countObj.getExamTime()]);
countDown();
}, 1000);
}
onmessage = function (e) {
const {
data: { type, data },
} = e;
switch (type) {
case 'start':
countObj.setExamTime(data);
countDown();
break;
case 'align':
countObj.setExamTime(data);
postMessage(['alignTime', data]);
break;
case 'pause':
countObj.setStopTimeStatus(data);
break;
case 'init':
countObj.setTimer(null);
countObj.setExamTime(-1);
countObj.setStopTimeStatus(false);
break;
case 'clear':
clearTimeout(countObj.timer);
countObj.setTimer(null);
break;
default:
break;
}
};
4、vue文件中挂载倒计时
import * as countDownInstance from '../utils/countDown';
mounted() {
// 开始倒计时
countDownInstance.startCount(this.examTime);
const worker = countDownInstance.getWorker();
worker.onmessage = async (event) => {
const { data } = event;
const [type, time] = data;
if (type === 'countDown') {
this.examTime = time;
}
if (type === 'timeEnd') {
await this.autoSubmitPaper();
this.$message.warning('时间已结束');
this.examTime = time;
}
};
}
5、注意:
别忘了找个时机 把worker给终止掉,使用
worker.terminate
方法三、封装hooks
const useCountDown = ({ leftTime, ms = 1000, onEnd }: CountDownProps) => {
const countdownTimer = useRef<NodeJS.Timeout | null>();
const startTimeRef = useRef<number>(performance.now());
const nextTimeRef = useRef<number>(leftTime % ms);
const totalTimeRef = useRef<number>(0);
const [count, setCount] = useState(leftTime);
const preLeftTime = usePrevious(leftTime);
const clearTimer = useCallback(() => {
if (countdownTimer.current) {
clearTimeout(countdownTimer.current);
countdownTimer.current = null;
}
}, []);
const startCountDown = useCallback(
(nt: number = 0) => {
clearTimer();
// 每次实际执行的时间
const executionTime = performance.now() - startTimeRef.current; // 1.x
totalTimeRef.current = totalTimeRef.current + executionTime;
// 剩余时间减去应该执行的时间
setCount((count) => {
const nextCount =
count - (Math.floor(executionTime / ms) || 1) * ms - nt;
return nextCount <= 0 ? 0 : nextCount;
});
// 算出下一次的时间
nextTimeRef.current = ms - (totalTimeRef.current % ms);
// 重置初始时间
startTimeRef.current = performance.now();
countdownTimer.current = setTimeout(() => {
requestAnimationFrame(() => startCountDown(0));
}, nextTimeRef.current);
},
[ms]
);
useEffect(() => {
if (preLeftTime !== leftTime && preLeftTime !== undefined) {
clearTimer();
setCount(() => leftTime);
nextTimeRef.current = leftTime % ms;
countdownTimer.current = setTimeout(() => {
requestAnimationFrame(() =>
startCountDown(nextTimeRef.current)
);
}, nextTimeRef.current);
}
}, [leftTime, ms]);
useEffect(() => {
countdownTimer.current = setTimeout(
() => startCountDown(nextTimeRef.current),
nextTimeRef.current
);
return () => {
clearTimer();
};
}, []);
useEffect(() => {
if (count <= 0) {
clearTimer();
onEnd && onEnd();
}
}, [count]);
const formatCount = parseMillisecond(count);
return { formatCount, count };
};
export default useCountDown;