React Hooks 深入与实现思路:useState、useEffect 与自定义 Hooks
在日常开发里,Hooks 很容易被当成「写法糖」,真正踩坑的时候,问题往往出在几个核心心智没立稳:渲染是快照、依赖是谁的依赖、执行时机和批量更新。
下面从常用的几个 Hooks 往里拧几圈,把这些细节讲清楚。
useState:渲染快照与更新批处理
状态是渲染时刻的快照
组件函数执行时,拿到的 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:依赖数组和闭包陷阱
依赖数组是「你用到了谁」,不是「你希望谁变化时触发」
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 / useCallback:只优化「贵的那部分」
缓存计算结果:useMemo
useMemo 用来缓存「代价较高的计算」的结果,而不是给所有表达式套一层:
1const expensiveValue = useMemo(() => {
2 return heavyCompute(input);
3}, [input]);
使用的时候更重要的是问自己两件事:
- 这段计算是不是足够贵,值得多一层复杂度;
- 依赖数组是否准确,是否会意外地「用到旧结果」。
缓存回调引用:useCallback
useCallback 常被滥用成「所有函数都包一层」,但真正有意义的场景是:
- 函数被传给子组件,且子组件做了
React.memo; - 函数作为依赖传给其他 Hooks(例如
useEffect、自定义 Hook)。
1const handleClick = useCallback(() => {
2 doSomething(value);
3}, [value]);
如果子组件没有做 memo,或者回调本身非常轻量,其实完全可以不包 useCallback,减少心智负担。
自定义 Hooks:把「一小撮逻辑」拎出来
适合抽成 Hook 的信号
常见几类可以抽成自定义 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 管表单状态;
- 一个 Hook 管滚动/尺寸/可见性之类的 UI 状态。
拆到这个程度,读代码时可以更容易看出:这个组件大概是几块逻辑拼起来的,每一块都在哪。
小结
- 把 state 当成「渲染快照」,凡是依赖旧值的更新,优先用函数式更新,避免闭包里的过期值。
useEffect的依赖数组写的是「你在 effect 里实际读到了谁」,不要和「希望谁变化时触发」混淆。useMemo/useCallback用在真正有性能压力的节点上,而不是统一套一层。- 自定义 Hooks 更像是「把一小撮相关逻辑拎出来」,服务于可读性和复用,而不是把所有逻辑打包成一个大 Hook。