React 倒计时组件进阶:从基础实现到可复用 Hook
在前端面试中,“写一个倒计时组件”是非常常见的问题。很多人第一反应是直接在组件里用 useState、setInterval 写一段逻辑。但如果面试官接着问:
- 如果另一个地方也需要倒计时,但样式不同怎么办?
- 能否支持回调?
- 能否一加载就自动开始?
- 能否支持毫秒级精度?
就需要更系统的思考与封装。
1. 基础版本:秒级倒计时
最初的版本直接把逻辑写在组件内部:
import { useState, useRef, useEffect, useCallback } from "react";
export const Counter = () => {
const [timeLeft, setTimeLeft] = useState(2);
const [isRunning, setIsRunning] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const start = useCallback(() => setIsRunning(true), []);
const pause = useCallback(() => setIsRunning(false), []);
const reset = useCallback(() => {
pause();
setTimeLeft(2);
}, [pause]);
useEffect(() => {
if (isRunning) {
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current!);
timerRef.current = null;
setIsRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [isRunning]);
return (
<div>
<button onClick={start}>Start</button>
<button onClick={pause}>Pause</button>
<button onClick={reset}>Reset</button>
<div>{timeLeft}</div>
</div>
);
};
这个版本能跑,但存在问题:
- 逻辑和 UI 强耦合:如果另一个页面要用,就只能复制代码并修改 JSX。
- 扩展性差:很难支持“自动开始”“回调”“毫秒精度”等新需求。
2. 提取逻辑:封装成 Hook
将倒计时逻辑抽离成 自定义 Hook (useCountdown) ,UI 组件只负责渲染。
import { useState, useRef, useEffect, useCallback } from "react";
export function useCountdown(initialTime: number) {
const [timeLeft, setTimeLeft] = useState(initialTime);
const [isRunning, setIsRunning] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const start = useCallback(() => setIsRunning(true), []);
const pause = useCallback(() => setIsRunning(false), []);
const reset = useCallback(() => {
pause();
setTimeLeft(initialTime);
}, [initialTime, pause]);
useEffect(() => {
if (isRunning) {
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(timerRef.current!);
timerRef.current = null;
setIsRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [isRunning]);
return { timeLeft, isRunning, start, pause, reset };
}
3. 在不同组件中复用
简单按钮版
import { useCountdown } from "./useCountdown";
export const SimpleCounter = () => {
const { timeLeft, start, pause, reset } = useCountdown(5);
return (
<div>
<button onClick={start}>Start</button>
<button onClick={pause}>Pause</button>
<button onClick={reset}>Reset</button>
<div>{timeLeft}</div>
</div>
);
};
更复杂的 UI
import { useCountdown } from "./useCountdown";
import { Progress, Button } from "antd";
export const FancyCounter = () => {
const { timeLeft, start, pause, reset } = useCountdown(10);
return (
<div>
<Progress percent={(10 - timeLeft) * 10} />
<Button type="primary" onClick={start}>Start</Button>
<Button onClick={pause}>Pause</Button>
<Button danger onClick={reset}>Reset</Button>
<div style={{ fontSize: 24 }}>{timeLeft}s</div>
</div>
);
};
这样,同一套逻辑可以在不同 UI 中复用。
4. 面试官进阶追问:更强功能
4.1 支持回调 & 自动开始
新增onEnd?: () => void;和 autoStart?: boolean参数
const [isRunning, setIsRunning] = useState(autoStart ?? false);
4.2 支持毫秒级精度
setInterval 在长时间运行时会有时间漂移问题。更好的方法是用 requestAnimationFrame + performance.now():
export function usePreciseCountdown({
initialMs,
onEnd,
autoStart,
}: {
initialMs: number;
onEnd?: () => void;
autoStart?: boolean;
}) {
const [timeLeft, setTimeLeft] = useState(initialMs);
const [isRunning, setIsRunning] = useState(autoStart ?? false);
const endTimeRef = useRef<number | null>(null);
const frameRef = useRef<number | null>(null);
const tick = useCallback(() => {
const now = performance.now();
const left = Math.max(0, (endTimeRef.current ?? 0) - now);
setTimeLeft(left);
if (left > 0) {
frameRef.current = requestAnimationFrame(tick);
} else {
setIsRunning(false);
onEnd?.();
}
}, [onEnd]);
const start = useCallback(() => {
endTimeRef.current = performance.now() + timeLeft;
setIsRunning(true);
frameRef.current = requestAnimationFrame(tick);
}, [timeLeft, tick]);
const pause = useCallback(() => {
setIsRunning(false);
if (frameRef.current) cancelAnimationFrame(frameRef.current);
}, []);
const reset = useCallback(() => {
pause();
setTimeLeft(initialMs);
}, [initialMs, pause]);
return { timeLeft, isRunning, start, pause, reset };
}
✅ 总结:如何封装一个优秀的 Hook
-
抽象复用:将通用逻辑抽象出来,避免与具体 UI 组件强耦合。
-
单一职责:每个 Hook 只做一件事,关注点单一。例如:useFetch 只负责数据请求,useDrag 只负责拖拽逻辑。
-
参数简洁明了:只暴露必要的参数和返回值,避免暴露内部实现细节。
- 返回值结构清晰:通常返回一个对象或数组,包含需要暴露的状态和方法。
-
命名规范:以 use 开头,返回值和参数命名要有语义。
-
合理使用 useEffect/useLayoutEffect:处理副作用(如事件监听、订阅、定时器等)。 在组件卸载或依赖变化时,清理事件监听、定时器等,防止内存泄漏。