从一道面试题聊如何自定义hooks

92 阅读3分钟

React 倒计时组件进阶:从基础实现到可复用 Hook

在前端面试中,“写一个倒计时组件”是非常常见的问题。很多人第一反应是直接在组件里用 useStatesetInterval 写一段逻辑。但如果面试官接着问:

  • 如果另一个地方也需要倒计时,但样式不同怎么办?
  • 能否支持回调?
  • 能否一加载就自动开始?
  • 能否支持毫秒级精度?

就需要更系统的思考与封装。


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:处理副作用(如事件监听、订阅、定时器等)。 在组件卸载或依赖变化时,清理事件监听、定时器等,防止内存泄漏。