关于如何写一个精确的倒计时

探讨如何实现高性能、稳定且准确的倒计时器,提供最佳实践和代码示例,确保倒计时的精确性和效率。


又到了金三银四的季节了,面试的各位同学要开始准备起来了,今天主要分享一个在面试中经常被提到的一个面试题:倒计时。其实这个问题不仅是在面试中,也在我们的业务里也会经常用到,所以到底如何才能写好一个倒计时呢?

首先我们在写倒计时的时候必须要考虑到三点:准确性、性能。接下来我们来一步一步实现一个准确的定时器。

setInterval:

我们先来简单实现一个倒计时的函数:

function example1(leftTime) {
	let t = leftTime;
	setInterval(() => {
		t = t - 1000;
		console.log(t);
	}, 1000);
}
 
example1(10);

可以看到使用 setInterval 即可,但是 setInterval 真的准确吗?我们来看一下 MDN 中的说明:

简单来说意思就是,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 的回调函数执行比设定的预期值更久,比如嵌套超时、非活动标签超时、追踪型脚本的节流、超时延迟等等,详情见https://developer.mozilla.org/zh-CN/docs/Web/API/setTimeout,总就就是和 setInterval 差不多,时间一长,就会有误差出现,而且 setTimeout 有一个很不好的点在于,当你的程序在后台运行时,setTimeout 也会一直执行,这样会严重的而浪费性能,那么有什么办法可以解决这种问题吗?

requestAnimationFrame

这里就不得不提一个新的方法 requestAnimationFrame,它是一个浏览器 API,允许以 60 帧/秒 (FPS) 的速率请求回调,而不会阻塞主线程。通过调用 requestAnimationFrame 方法浏览器会在下一次重绘之前执行指定的函数,这样可以确保回调在每一帧之间都能够得到适时的更新。

我们使用 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 = performace.now();
	function start() {
		setTimeout(() => {
			const diff = leftTime - (performace.now() - now);
			console.log(diff);
			requestAnimationFrame(start);
		}, 1000);
	}
	start();
}

上面的代码实现思路其实在实际的业务中已经能够满足我们的使用场景,但其实还是没有解决 setTimeout 会延迟的问题,当线程被占用之后,很容易出现误差,那么有什么更新的办法进行处理呢?

最佳方案

先要明确的是,setTimeout 函数中执行代码的时间肯定是要大于等于 setTimeout 时间的,那么就可能出现设定的 1 秒,实际执行却执行了 2 秒的情况,那么我们的实现思路也很简单,每次计算一下 setTimeout 实际执行的时间,然后动态的调整下一次执行的时间,而不是设置固定的值

我们来用图表举例推演一下每次执行的情况:

第 n 次执行executionTime 实际执行时间nextTime 下次需要执行的时间totleTime 执行的总时间
0010000
112008001200
211007002300
310007003300
422005005500
513002006800
6120010008000

从中可以看到:下次执行的时间 nextTime = 1000 - totleTime % 1000;这样我们就可以得出下次执行的时间,从而每次都去动态的调整多余消耗的时间,大大减小倒计时最终的误差

还有需要考虑的是,实际业务中返回的剩余时间肯定不会是整数,所以我们的第一次执行的时间最好可以先让剩余时间变为整数,这样可以在倒计时到最后一秒时更加的精确。

根据上述的思路来看一下最终封装出来的 react 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;
		}
	}, []);
 
	// requestAnimationFrame
 
	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;

如果想要封装组件的话,可以在 hooks 的基础上进行二次封装。

到这里,肯定会有人说,做了这么多的操作,有必要吗,就算差 0 点几秒,在实际体验中用户完全感受不出来。我想说的是,完美比完成更重要,积累是一个持续的过程,很多没有必要的东西如果你能进行了解研究,从中学到一些解题的思路或者方法,这才是对我们最有意义的。