React 一面:Hooks 常见考点(useState/useEffect 与自定义 Hook)

现在很多前端一面都会问到 Hooks,尤其是 useState/useEffect 的规则和坑点,这一篇挑几个常见考点和一两道面试题做个系统整理。

React 官方提出了两条“Hooks 规则”:

  1. 只在最顶层调用 Hook(不要在循环、条件判断或嵌套函数中调用);
  2. 只在 React 函数组件或自定义 Hook 中调用 Hook。

一面常见问法:

  • “为什么不能在条件里调用 Hook?”
  • “React 是怎么知道每次 useState/useEffect 对应的是哪个状态的?”

参考回答要点:

  • React 依靠“调用顺序”来匹配每一次渲染中的 Hook 状态,如果在条件/循环中调用,会打乱顺序;
  • 顶层调用可以保证每次渲染时 Hook 的调用顺序是一致的,从而正确地读写对应的状态/副作用。

useState 常见考点:

  • 状态更新的异步性:连续多次 setCount(count + 1) 不一定会得到预期结果;
  • 函数式更新:setCount((prev) => prev + 1) 的优点;
  • 批量更新:在同一个事件处理函数中,多次状态更新会被批量合并。

面试时可以用一个简单例子:

1const [count, setCount] = useState(0);
2
3function handleClick() {
4  setCount(count + 1);
5  setCount(count + 1);
6}

解释:

  • 上面的写法在一次点击中最终只会 +1;
  • 因为两次读取到的 count 都是旧值 0,React 会批量合并更新;
  • 如果想在一次点击中递增 2,应该用函数式更新:
1function handleClick() {
2  setCount((prev) => prev + 1);
3  setCount((prev) => prev + 1);
4}

useEffect 常见考点:

  • 依赖数组的含义:不传 / 空数组 / 含依赖 的区别;
  • 清理函数(return () => {})在组件卸载或依赖变化时的执行时机;
  • 忘记补依赖导致的“读取旧值”或“无限循环请求”问题。

举个典型请求场景的例子:

 1useEffect(() => {
 2  let canceled = false;
 3  async function fetchData() {
 4    const res = await fetch("/api/data?id=" + id);
 5    const json = await res.json();
 6    if (!canceled) {
 7      setData(json);
 8    }
 9  }
10  fetchData();
11  return () => {
12    canceled = true;
13  };
14}, [id]);

解释要点:

  • 依赖数组 [id] 表示 id 变化时重新请求;
  • 清理函数用来在组件卸载或 id 改变时标记“本次请求结果不再需要”,避免竞态条件。

一面有时会问:“自定义 Hook 有什么用?能不能举个例子?”

可以简要说明:

  • 自定义 Hook 的目的,是复用状态逻辑(如数据获取、订阅、表单处理),而不是复用 UI 本身;
  • 它本质上就是一个函数,内部可以调用其它 Hook,并返回一组状态/方法。

简单例子:

 1function useWindowSize() {
 2  const [size, setSize] = useState({
 3    width: window.innerWidth,
 4    height: window.innerHeight
 5  });
 6
 7  useEffect(() => {
 8    function onResize() {
 9      setSize({
10        width: window.innerWidth,
11        height: window.innerHeight
12      });
13    }
14    window.addEventListener("resize", onResize);
15    return () => window.removeEventListener("resize", onResize);
16  }, []);
17
18  return size;
19}

组件中使用:

1function MyComponent() {
2  const { width, height } = useWindowSize();
3  return <div>{width} x {height}</div>;
4}

参考答案要点:

  • React 依靠“函数组件中 Hook 的调用顺序”来管理状态;
  • 如果在条件或循环中调用 Hook,不同渲染之间调用顺序可能不一致,React 无法正确匹配每个 useState/useEffect 对应的内部状态槽;
  • 因此要求 Hook 只能在组件顶层调用,保证每次渲染顺序一致。

可以补一句:

“这也是为什么 React 官方提供了 eslint-plugin-react-hooks 来静态检查 Hook 调用位置。”

可以用前面的 setCount 示例来回答:

  • 在同一个事件处理函数中,多次直接用 setCount(count + 1),最终只会效果等同于一次增加 1;
  • 正确累加应该使用函数式更新形式 setCount(prev => prev + 1),这样每次更新都基于上一次的结果。

如果面试官追问“为什么会这样”,可以说:

  • React 会在事件处理过程中批量合并状态更新,直接使用 count + 1 其实多次读到的是同一个旧值;
  • 函数式更新则能确保每个更新都基于最新的 state。

参考答案要点:

  • 不传依赖数组:每次渲染后都会执行 effect;
  • 传空数组 []:只在组件挂载时执行一次,卸载时执行清理函数;
  • [dep]:在挂载时执行一次,在 dep 变化时再次执行(且先执行上一次的清理函数)。

避免 bug 的关键是:

  • 不要随意漏掉依赖,否则 effect 可能读取到“旧值”;
  • 对于请求类 effect,要注意在卸载/参数变化时取消或标记结果无效,避免竞态条件。

如果能结合一个“忘记加依赖导致无限请求”的例子来讲,会更有说服力。