今天想要分享的是一个面试题,也是一个我们在项目中常用的功能:倒计时。
首先我们在写倒计时的时候必须要考虑到是:准确性、性能。接下来我们一步一步实现这个完美地倒计时功能。
-
setInterval
先来简单实现一个倒计时的函数:
function example1(leftTime) { let t = leftTime; setInterval(() => { t = t - 1000; console.log(t); }, 1000); } example1(10);
可以看到使用
setInterval
即可,但是setInterval
真的准确吗?我们来看一下 MDN 中的说明:如果你的代码逻辑执行时间可能比定时器时间间隔要长,建议你使用递归调用了 setTimeout() 的具名函数
例如,使用
setInterval()
以 5 秒的间隔轮询服务器,可能因网络延迟、服务器无响应以及许多其他的问题而导致请求无法在分配的时间内完成。简单来说意思就是,js 因为是单线程的原因,如果前面有阻塞线程的任务,那么就可能会导致
setInterval
函数延迟,这样倒计时就肯定会不准确,建议使用setTimeout
替换setInterval
。 -
setTimeout
按照上述的建议将
setInterval
换为setTimeout
后,我们来看下代码:function example2(leftTime) { let t = leftTime; setTimeout(() => { t = t - 1000; if (t > 0) { console.log(t); example2(t); } console.log(t); }, 1000); }
MDN 中也说了,有很多因素会导致
setTimeout
的回调函数执行比设定的预期值更久,比如嵌套超时、非活动标签超时、追踪型脚本的节流、超时延迟等等。 总之呢就是和setInterval
差不多,时间一长,就会有误差出现,而且setTimeout
有一个很不好的点在于,当你的程序在后台运行时,setTimeout
也会一直执行,这样会严重的而浪费性能,那么有什么办法可以解决这种问题吗? -
requestAnimationFrame
这里就不得不提一个新的方法
requestAnimationFrame
,它是一个浏览器 API,允许以 60 帧/秒 (FPS) 的速率请求回调,而不会阻塞主线程。通过调用requestAnimationFrame
方法浏览器会在下一次重绘之前执行指定的函数,这样可以确保回调在每一帧之间都能够得到适时的更新。 这个 API 我们在大屏可视化项目中需要动态切换图表数据时也是常用的。那么我们使用
requestAnimationFrame
结合setTimeout
来优化一下之前的代码:function example4(leftTime) { let t = leftTime; function start() { requestAnimationFrame(() => { t = t - 1000; setTimeout(() => { console.log(t); start(); }, 1000); }); } start(); }
为什么要使用
requestAnimationFrame
+setTimeout
呢?原因一个是息屏或者切后台的操作时,
requestAnimationFrame
是不会继续调用函数的,但是如果只使用requestAnimationFrame
的话,函数相当于 1 秒的时候要调用 60 次,太浪费性能。在切后台或者息屏的实际操作执行时会发现,当回到页面时,倒计时会接着切后台时的时间执行,而没有更新到最新的时间,这样的bug是接受不了的。
-
diffTime差值计算
要解决上述的问题,最通用的办法就是通过时间差值每次进行对比就可以了。
function example5(leftTime) { const now = performance.now(); function start() { setTimeout(() => { const diff = leftTime - (performance.now() - now); console.log(diff); requestAnimationFrame(start); }, 1000); } start(); }
上面的代码实现思路其实在实际的业务中已经能够满足我们的使用场景,但其实还是没有解决setTimeout会延迟的问题,当线程被占用之后,很容易出现误差,那么有什么更新的办法进行处理呢?
最佳方案
先要明确的是,setTimeout函数中执行代码的时间肯定是要大于等于setTimeout时间的,那么就可能出现设定的 1 秒,实际执行却执行了 2 秒的情况,那么我们的实现思路也很简单,每次计算一下setTimeout实际执行的时间,然后动态的调整下一次执行的时间,而不是设置固定的值。
我们来用图表举例推演一下每次执行的情况:
第n次执行 | executionTime 实际执行时间 | nextTime 下次需要执行的时间 | totleTime 执行的总时间 |
---|---|---|---|
0 | 0 | 1000 | 0 |
1 | 1200 | 800 | 1200 |
2 | 1100 | 700 | 2300 |
3 | 1000 | 700 | 3300 |
4 | 2200 | 500 | 5500 |
5 | 1300 | 200 | 6800 |
6 | 1200 | 1000 | 8000 |
… | … | … | … |
从中可以看到:下次执行的时间 nextTime = 1000 - totleTime % 1000;这样我们就可以得出下次执行的时间,从而每次都去动态的调整多余消耗的时间,大大减小倒计时最终的误差
还有需要考虑的是,实际业务中返回的剩余时间肯定不会是整数,所以我们的第一次执行的时间最好可以先让剩余时间变为整数,这样可以在倒计时到最后一秒时更加的精确。
根据上述的思路来看一下最终封装出来的 react hooks:
const useCountDown = ({ leftTime, ms = 1000, onEnd }) => {
const countdownTimer = useRef();
const startTimer = useRef();
//记录初始时间
const startTimeRef = useRef(performance.now());
// 第一次执行的时间处理,让下一次倒计时时调整为整数
const nextTimeRef = useRef(leftTime % ms);
const [count, setCount] = useState(leftTime);
const clearTimer = () => {
countdownTimer.current && clearTimeout(countdownTimer.current);
startTimer.current && clearTimeout(startTimer.current);
};
const startCountDown = () => {
clearTimer();
const currentTime = performance.now();
// 算出每次实际执行的时间
const executionTime = currentTime - startTimeRef.current;
// 实际执行时间大于上一次需要执行的时间,说明执行时间多了,否则需要补上差的时间
const diffTime =
executionTime > nextTimeRef.current
? executionTime - nextTimeRef.current
: nextTimeRef.current - executionTime;
setCount((count) => {
const nextCount =
count - (Math.floor(executionTime / ms) || 1) * ms - nt;
return nextCount <= 0 ? 0 : nextCount;
});
// 算出下一次的时间
nextTimeRef.current =
executionTime > nextTimeRef.current ? ms - diffTime : ms + diffTime;
// 重置初始时间
startTimeRef.current = performance.now();
countdownTimer.current = setTimeout(() => {
requestAnimationFrame(startCountDown);
}, nextTimeRef.current);
};
useEffect(() => {
setCount(leftTime);
startTimer.current = setTimeout(startCountDown, nextTimeRef.current);
return () => {
clearTimer();
};
}, [leftTime]);
useEffect(() => {
if (count <= 0) {
clearTimer();
onEnd && onEnd();
}
}, [count]);
return count;
};
export default useCountDown;
如果想要封装组件的话,可以在hooks的基础上进行二次封装。
到这里,肯定会有人说,做了这么多的操作,有必要吗,就算差0点几秒,在实际体验中用户完全感受不出来。我想说的是,细节决定成败,有可能这零点几秒的内容就决定了面试的成败。如果做什么事都只做个差不多,那你永远不会有自己的"核心科技"。