React Hooks 深入与实现思路:useState、useEffect 与自定义 Hooks

在日常开发里,Hooks 很容易被当成「写法糖」,真正踩坑的时候,问题往往出在几个核心心智没立稳:渲染是快照、依赖是谁的依赖、执行时机和批量更新

下面从常用的几个 Hooks 往里拧几圈,把这些细节讲清楚。

组件函数执行时,拿到的 state 是这一次渲染的快照,而不是「会自动追踪变化的引用」:

 1function Counter() {
 2  const [count, setCount] = useState(0);
 3
 4  function handleClick() {
 5    setCount(count + 1);
 6    setCount(count + 1);
 7  }
 8
 9  return <button onClick={handleClick}>{count}</button>;
10}

点击一次,只会加 1 而不是 2。原因有两个:

  • 同一次渲染中 count 不会变:两次 setCount(count + 1) 都是基于同一个旧值。
  • 更新会被批处理合并:React 在批量更新阶段,会把多次覆盖同一个 state 的更新合成一次。

遇到「多次更新依赖上一次结果」时,应该用函数式更新:

 1function Counter() {
 2  const [count, setCount] = useState(0);
 3
 4  function handleClick() {
 5    setCount(c => c + 1);
 6    setCount(c => c + 1);
 7  }
 8
 9  return <button onClick={handleClick}>{count}</button>;
10}

这里的 c 是 React 在内部按顺序传进来的「前一次更新后的值」,多次更新会按队列依次执行。

这一条可以直接当成一个规则记住:凡是下一次状态依赖上一次状态,优先用函数式更新。

在 React 18 之后,大部分情况下 setState 会被批量处理。常见现象是:

 1function App() {
 2  const [count, setCount] = useState(0);
 3
 4  function handleClick() {
 5    setCount(1);
 6    console.log('count in handler:', count); // 还是旧值
 7  }
 8
 9  console.log('render with count =', count);
10
11  return <button onClick={handleClick}>click</button>;
12}

点击后,handleClick 里的日志仍然是旧值,这是正常的:

  • setCount(1) 只是「发起更新请求」;
  • 真正的 state 变更发生在下一次渲染;
  • 此时组件函数整体会重新执行,拿到的新快照。

如果想要在 state 更新后做点事,常见做法是交给 useEffect,而不是在 setState 后面立刻读取。

useEffect 的依赖数组,应该写「在 effect 里实际读到的所有外部变量」。

 1function Search({ keyword }) {
 2  const [data, setData] = useState(null);
 3
 4  useEffect(() => {
 5    let cancelled = false;
 6
 7    fetch(`/api/search?q=${keyword}`)
 8      .then(res => res.json())
 9      .then(result => {
10        if (!cancelled) {
11          setData(result);
12        }
13      });
14
15    return () => {
16      cancelled = true;
17    };
18  }, [keyword]);
19}

这里的依赖数组里必须有 keyword,否则 effect 拿到的是「初次渲染时的 keyword 快照」,后续搜索词变了,副作用没跟着变。

一条简单的安全原则:

  • 在 effect 里读到的所有外部变量,都应该出现在依赖数组里;
  • 如果 lint 提示依赖缺失,优先考虑补全依赖,而不是强行关掉规则。

下面这种写法很常见:

 1function Timer() {
 2  const [count, setCount] = useState(0);
 3
 4  useEffect(() => {
 5    const id = setInterval(() => {
 6      setCount(count + 1);
 7    }, 1000);
 8
 9    return () => clearInterval(id);
10  }, []);
11
12  return <div>{count}</div>;
13}

看上去没问题,实际上只会加到 1 就停住,因为:

  • 依赖数组是 [],effect 只在初次渲染执行一次;
  • 闭包里捕获的是初次渲染的 count(值为 0);
  • 每次 setState 都是 0 + 1,React 认为是「状态没变」,后续不再触发渲染。

修正方式同样是使用函数式更新:

1setCount(c => c + 1);

这条规则和前面是一致的:异步回调里如果要基于旧值计算新值,优先用函数式更新,避开闭包里的过期值。

useMemo 用来缓存「代价较高的计算」的结果,而不是给所有表达式套一层:

1const expensiveValue = useMemo(() => {
2  return heavyCompute(input);
3}, [input]);

使用的时候更重要的是问自己两件事:

  • 这段计算是不是足够贵,值得多一层复杂度;
  • 依赖数组是否准确,是否会意外地「用到旧结果」。

useCallback 常被滥用成「所有函数都包一层」,但真正有意义的场景是:

  • 函数被传给子组件,且子组件做了 React.memo
  • 函数作为依赖传给其他 Hooks(例如 useEffect、自定义 Hook)。
1const handleClick = useCallback(() => {
2  doSomething(value);
3}, [value]);

如果子组件没有做 memo,或者回调本身非常轻量,其实完全可以不包 useCallback,减少心智负担。

常见几类可以抽成自定义 Hook 的逻辑:

  • 多个组件都在复制粘贴同样的状态逻辑;
  • 某个组件里,某一部分逻辑已经自成「一小块」,但又不足以单独拆组件;
  • 需要在视图层之外,对数据/副作用做统一约束。

例子:一个简单的请求 Hook:

 1function useRequest<T>(fn: () => Promise<T>) {
 2  const [data, setData] = useState<T | null>(null);
 3  const [loading, setLoading] = useState(false);
 4  const [error, setError] = useState<Error | null>(null);
 5
 6  const run = useCallback(async () => {
 7    setLoading(true);
 8    setError(null);
 9    try {
10      const result = await fn();
11      setData(result);
12    } catch (e) {
13      setError(e as Error);
14    } finally {
15      setLoading(false);
16    }
17  }, [fn]);
18
19  useEffect(() => {
20    run();
21  }, [run]);
22
23  return { data, loading, error, retry: run };
24}

组件内部就可以变成:

 1function UserList() {
 2  const { data, loading, error, retry } = useRequest(() =>
 3    fetch('/api/users').then(res => res.json())
 4  );
 5
 6  if (loading) return <div>加载中...</div>;
 7  if (error) return <div>出错了 <button onClick={retry}>重试</button></div>;
 8
 9  return <ul>{data.map((u: any) => <li key={u.id}>{u.name}</li>)}</ul>;
10}

这里的关键点不是「把逻辑挪到了 Hook」,而是:

  • 视图层少了大量状态管理代码;
  • 请求的生命周期、错误处理、重试策略都能复用。

如果一个组件很复杂,直接把「组件所有逻辑」塞进一个自定义 Hook 里,意义不大,只是平移了复杂度。

更合理的做法是按「关注点」拆开:

  • 一个 Hook 管网络请求;
  • 一个 Hook 管表单状态;
  • 一个 Hook 管滚动/尺寸/可见性之类的 UI 状态。

拆到这个程度,读代码时可以更容易看出:这个组件大概是几块逻辑拼起来的,每一块都在哪。

  • 把 state 当成「渲染快照」,凡是依赖旧值的更新,优先用函数式更新,避免闭包里的过期值。
  • useEffect 的依赖数组写的是「你在 effect 里实际读到了谁」,不要和「希望谁变化时触发」混淆。
  • useMemo/useCallback 用在真正有性能压力的节点上,而不是统一套一层。
  • 自定义 Hooks 更像是「把一小撮相关逻辑拎出来」,服务于可读性和复用,而不是把所有逻辑打包成一个大 Hook。