React Hooks 用到第三篇,我想默认你已经有useState、useEffect、useRef这些基础打底了。这篇要聊的是那些真正写业务系统时才会撞上的问题:回调为什么读不到最新值、依赖数组怎么总给人挖坑、自定义 Hook 怎么设计才不变成又一处技术债、状态管理什么时候用 Hooks 组合拳就够了。这些东西如果只看官方文档,很容易“看着都懂,一写就废”。
先说一个常见现象。很多同学业务代码写了半年,组件能跑,功能不挂,但一到要抽象逻辑、性能告警、或者排查一个“诡异不更新”的 Bug 时,就变得很被动。说白了就是停留在“会用 API”阶段,还没到“理解 Hooks 的运行机制”阶段。这一篇就是帮你在中间这段距离上补几块关键拼图。
1. 先理解 Hooks 的执行机制,再谈用法
1.1 每次渲染都是一次独立的“快照”
不少人把useState想象成一个“全局的变量仓库”,觉得里面放着一个最新值,任何时候读都能拿到。这个直觉是错的。函数组件本身是一个函数,每次触发更新,React 就把这个函数整体调用一遍。useState返回的state值,不过是这一次函数调用时被绑定好的“快照”。
用生活类比来理解:每次渲染就像一场舞台剧的开场,灯光亮起,演员念台词。脚本(JSX)里的每一句台词都已经被写死,哪怕下一幕状态发生了改变,这一幕已经演出去的台词也不会变。
举个例子:
const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); setCount(count + 1); console.log(count); // 依然还是 0 };很多人以为连续两次setCount值会变 2,最后还是 1。这不是 bug,而是因为函数体在一次渲染中只跑一遍,count从头到尾读的都是同一个值。如果你真的想基于最新值累加,得用函数式更新:
setCount((prev) => prev + 1); setCount((prev) => prev + 1);这样 React 会把你传入的函数攒起来,按顺序执行后得到最终结果,结果才是 2。这一条在写计数器、弹窗步进器、批量操作列表时非常容易踩中。
1.2 为什么回调里永远读不到最新 state
最经典的一个坑是setTimeout或setInterval里读 state:
const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log(count); // 永远都是 0 setCount(count + 1); }, 1000); }, []);这里的count是 useEffect 创建“定时器”那一瞬间的旧值。订好的规则很简单:**每次渲染都会产生一组新的闭包环境,函数体里引用的所有变量,都是这一轮渲染时被捕获到的值。**定时器创建时捕获的是第一轮渲染的count = 0,后面轮次渲染虽然产生了新的闭包,但定时器里挂的还是旧函数。
我见过有人用“把依赖数组加上count”或者“在setInterval里换一套回调”来解决,效果确实行,但代码容易又长又绕。更稳的方式是把要读取的“最新值”同步到useRef里:
const countRef = useRef(0); useEffect(() => { countRef.current = count; }, [count]); useEffect(() => { const timer = setInterval(() => { console.log(countRef.current); // 每次都是最新 setCount(countRef.current + 1); }, 1000); }, []);useRef在这里起的是一个“跨渲染共享的可变盒子”作用,它不会因为重新渲染而变化,适合存放那些必须从异步回调里读取的最新值。记住:state 属于这一次渲染,ref 才属于“整个组件生命周期”。
1.3 依赖数组不是 watch,不会自动帮你做深比较
useEffect的第二个参数依赖数组,是很多人常年误解的重灾区。它不是 Vue 里的watch,不是“监听某个值变化了就执行”——它做的事是:在每次渲染结束后,把这次依赖里的值与上一次渲染的对应值逐一用Object.is做比较,有任何一个不一样,就重新执行 effect。
这意味着两件事。
第一,依赖数组里最好只放原始值和稳定引用。假如你放了一个每次渲染都会被重新创建的普通对象:
const user = { name: '张三' }; useEffect(() => { // 每次渲染都会执行,因为 user 每次都是新对象 }, [user]);Object.is对比两个不同对象的引用,永远为 false。你以为是“只有 user 变化时才调用”,实际变成了“每次渲染都调用”。对象想进依赖数组,要么先把对象内容拆成基本类型放进去,要么用useMemo把对象稳定下来。
第二,不要在 effect 内部修改“引起执行”的依赖项,极易产生死循环。比如:
const [list, setList] = useState([]); useEffect(() => { const newList = list.concat({ id: Date.now() }); setList(newList); }, [list]);每次更新 list,effect 又会执行,执行后再更新 list,陷入无限渲染。这类问题在写“依赖某个数组的副作用”时很容易发生,一旦页面白屏、控制台爆出Maximum update depth exceeded,先往依赖项和 setState 的联动关系上查。
提示:我习惯把 useEffect 想成“渲染提交后的同步附加动作”。它不适合承载“某个值变化的时刻响应”,而适合承载“数据就绪后要发起的请求、订阅、DOM 操作”。
2. 自定义 Hook 的正确设计方式
2.1 一个生产可用的 useAsync
自定义 Hook 是 Hooks 体系里最值得认真对待的部分。它能把请求逻辑、弹窗逻辑、分页逻辑从组件里抽出来,让组件只剩下纯 UI 和事件绑定。
就拿最常见的异步请求来演示。很多新手写的请求代码是:
const [loading, setLoading] = useState(false); const [data, setData] = useState(null); const [error, setError] = useState(null); const load = async () => { setLoading(true); try { const res = await fetch('/api/list'); const json = await res.json(); setData(json); } catch (err) { setError(err); } finally { setLoading(false); } };这段逻辑放进组件本身没问题,但当两个页面要复用,或者这个请求还需要处理“参数变化后重新拉取”“组件卸载后不 setState”“竞态请求只保留最后一次结果”时,就得很小心。我建议直接抽一个useAsync:
function useAsync(asyncFn, immediate = true) { const [loading, setLoading] = useState(false); const [data, setData] = useState(null); const [error, setError] = useState(null); const execute = useCallback(() => { setLoading(true); return asyncFn() .then((res) => { setData(res); setError(null); return res; }) .catch((err) => { setError(err); throw err; }) .finally(() => { setLoading(false); }); }, [asyncFn]); useEffect(() => { if (immediate) { execute(); } }, [execute, immediate]); return { loading, data, error, execute }; }这里有两个容易被忽视的细节。
细节一:execute用useCallback包了一层,并且asyncFn要作为依赖。为什么不直接在useEffect里调用asyncFn()?因为如果asyncFn是组件里每次渲染都新创建的箭头函数,它作为依赖会让 effect 每次都跑。把“执行动作”稳定下来,才能控制请求频率。
细节二:.catch里重新throw err。这样调用方可以用execute().catch(...)单独处理失败,而不用只能在error状态里被动等。这是自定义 Hook 设计里“把控制权交还给使用者”的很关键一步。
不过这个版本还没处理竞态。竞态的意思是:用户快速切换筛选条件,第一次请求返回时第二次请求还没回来,结果第一次的结果晚于第二次结果返回,页面显示了旧数据。解决办法是加一个“是否过期”的标记:
const execute = useCallback(() => { let expired = false; setLoading(true); return asyncFn() .then((res) => { if (!expired) { setData(res); } return res; }) .catch((err) => { if (!expired) { setError(err); } throw err; }) .finally(() => { if (!expired) { setLoading(false); } }); // 注意:这个 expired 没法在外部清除,真正可用的版本要用 ref 存储 }, [asyncFn]);真正可用的版本,建议把expired标存放进useRef,在useEffect的 cleanup 里置为 true:
const expiredRef = useRef(false); useEffect(() => { expiredRef.current = false; return () => { expiredRef.current = true; }; }, [execute]);原理是:组件卸载或依赖变化时,上一次的 effect 会先执行 cleanup,把过期标记置为 true;后续迟到的 Promise 即便 resolve,也不会再更新状态。这手操作是异步请求封装里区分初阶和中阶的分水岭。
2.2 命名、职责边界与组合经验
自定义 Hook 的命名规则很简单但很不该破坏:一律use开头。React 的 lint 插件要依据这个前缀判断哪些是 Hooks,哪些是普通函数,从而检查useEffect等内建 Hook 是否被有条件地调用。我曾经见过有人定义一个普通函数loadData,里面用了useState,结果 ESLint 直接报错,因为 React 无法静态分析它是不是 Hook。
职责边界可以按这三条来判断:
- Hook 内部尽量只做一件事。一个 Hook 又管请求、又管弹窗、又管键盘事件,拆出来比耦合在一起好维护得多。
- 对外暴露的 API 要小而稳。能返回对象就返回对象,能提供“命令式操作”(比如
execute)就一定提供,让组件拥有主动触发能力。 - Hook 之间可以组合。比如
useUserInfo内部可以调用useAsync,再对数据做一层过滤,而不是每个 Hook 都从useState开始写。
组合的价值在于复用逻辑而非复用 UI。只要逻辑稳定,自定义 Hook 可以在多个完全不相关的组件里重复使用,这是 Class 组件时代HOC和render props做起来比较绕的事。
3. useMemo 和 useCallback 的取舍
3.1 从一次渲染重刷开始
React 的默认行为是:父组件重新渲染,子组件跟着重渲染。哪怕子组件的 props 没变,只要这个函数被重新调用了,它内部的逻辑就会完整跑一遍,生成新的 JSX。
function Parent() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(count + 1)}>加一</button> <Child name="固定名字" /> </div> ); } function Child({ name }) { console.log('子组件渲染了'); return <span>{name}</span>; }点按钮,控制台会打印“子组件渲染了”。props 明明是固定的,子组件却还是跟着刷了一遍,这在列表很长、组件很深、计算很重的场景里会白白浪费性能。
3.2 什么场景值得 memo
useMemo和useCallback不是把函数包一层就万事大吉的性能法宝,它们本身也有成本:每次渲染都要检查依赖数组是否变化。如果计算本身只要几毫秒,或者传进去的函数根本没有子组件消费,炫技式 memo 反而增加代码复杂度。
我自己的取舍标准是三条:
- 计算成本高(超过几十毫秒)且依赖不常变化的,用
useMemo缓存。比如遍历千条记录做排序、过滤、汇总。 - 函数要传给被
React.memo包裹的子组件时,用useCallback保证引用稳定。假设Child包了 memo,父组件每次传一个新的onClick函数给它,memo 对比后发现 props 引用变了,照样重渲染,之前做的优化全部白费。 - 函数要作为其它 Hook 的依赖项时,用
useCallback稳定引用,避免 effect 反复触发请求。
反面例子我见得太多了。有人把每个函数都包useCallback,把每个对象都包useMemo,结果页面性能没有任何提升,代码却难读了一倍。记住:useMemo只适合“计算昂贵且结果可能被多个子组件消费”的场景,不要在每条数据渲染时都去“缓存”一个简单的拼接字符串。
3.3 React.memo 才是组合搭档
useMemo/useCallback本身不阻止子组件重渲染,真正的搭档是React.memo。React.memo类似 Class 组件里的PureComponent,它会对函数组件的 props 做浅比较,如果 props 没变,就跳过这次函数执行。
import { memo } from 'react'; const Child = memo(function Child({ name, onAction }) { console.log('只有 name 或 onAction 变化时才渲染'); return <button onClick={onAction}>{name}</button>; });注意浅比较背后隐藏的是引用比较。传给 Child 的name如果是字符串,只要内容一样就没事;如果传给 Child 的是一个每次渲染都会产生的数组['a', 'b'],memo 就会认为 props 变了,照样重渲染。所以搭配数组、对象这类非基本类型时,上游必须配合useMemo或useCallback稳定引用,才能形成完整的优化链路。
3.4 我在项目中做的一次性能优化拆解
某后台系统的表格页,父组件维护着筛选条件和一个大列表。原来列表行组件直接渲染,每次输入筛选框,整个表格几百行全部重刷,体感卡顿明显。我的处理顺序是:
- 先用 Performance 面板确认瓶颈确实来自列表重渲染,而不是请求或 DOM 布局。
- 给行组件包
React.memo,确保只有某一行数据变化才更新那一行。 - 把行组件接收的每行数据尽量拆成基本类型字段,减少对象引用的干扰。
- 给筛选按钮的点击回调套
useCallback,避免点击一次后所有行因为onClick变化全部刷新。
最终表格卡顿明显缓解。但我也想说明白,React 本身对轻量级渲染的优化已经做得很好,普通页面数据量不大时完全不用刻意 memo。真正值得优化的是“大列表”和“高频渲染(比如拖拽、缩放)”这类场景。先量化,再动手,不然就是没事找事。
4. useReducer 与 useContext 组合起来管理全局状态
4.1 按领域拆分 Context,不要造一个巨型 Store
useReducer+useContext的组合常被当作“不用第三方库也能做状态管理”的方案。它确实能做到,但做法上有讲究。
最常见的反模式是把所有全局状态塞进一个 Provider,里面放着二三十个 state,所有子组件都搭着同一个 context。这带来两个问题:
- 任何 state 变化,所有消费这个 context 的组件都会重渲染。
- context value 每次渲染都是新对象,如果不 memo,还会牵连更多子组件刷新。
正确思路是按领域拆分成多个 Context。比如一个后台项目,用户信息管一个UserContext,通知列表管一个NotificationContext,侧边栏折叠状态管一个LayoutContext。它们各自独立 Provider,互不干扰。
拿实现一个简化版的应用来验证这种设计。一组用户相关状态用 reducer 管理:
const UserContext = createContext(null); const userReducer = (state, action) => { switch (action.type) { case 'SET_USER': return { ...state, info: action.payload }; case 'CLEAR_USER': return { ...state, info: null }; case 'SET_LOGIN': return { ...state, isLogin: action.payload }; default: return state; } }; function UserProvider({ children }) { const [state, dispatch] = useReducer(userReducer, { info: null, isLogin: false, }); return ( <UserContext.Provider value={{ state, dispatch }}> {children} </UserContext.Provider> ); }这里必须补一个很多文章没强调的细节:context 的 value 本身也要用useMemo包一层。否则父组件里状态一变,Provider 重渲染,value生成新对象,所有消费这个 context 的子组件全部跟着刷。包了useMemo以后,只有state确实变化时,value 引用才变化,消费组件才能精准响应。
在子组件消费时,我习惯再包一层自定义 Hook:
function useUser() { const ctx = useContext(UserContext); if (!ctx) { throw new Error('useUser 必须在 UserProvider 内使用'); } return ctx; }如果 Provider 没包裹,这行调用会返回 null,直接抛错可以避免后续代码继续走导致更诡异的问题。这是自定义 Hook 设计里挺实用的保护措施。
4.2 reducer 编写时容易忽略的规则
用useReducer有一批规则是很多同学知道但做到一半就破功的:
第一,reducer 必须是纯函数。不在 reducer 里发请求、写日志、调Date.now()。请求这类副作用应该放在调用方的事件处理器或useEffect里,发完后dispatch结果。reducer 里可以做的事只有“根据 action 和当前 state,返回一个新 state”。
第二,返回的新状态必须是不变的新对象。不能state.push()然后返回同一个数组引用。React 是靠引用比较来判断是否更新的,你直接改原数组会让组件感知不到变化,界面不刷新。
// 错 case 'ADD_ITEM': state.items.push(action.payload); return state; // 对 case 'ADD_ITEM': return { ...state, items: [...state.items, action.payload] };第三,action 尽量带简单数据分类。dispatch({ type: 'setList', payload: data })比散装传参更容易追查问题,也方便之后做日志中间件。
4.3 什么情况下还是交给第三方状态库
useReducer+useContext能覆盖大部分中后台场景,但它不是万能的。下面几种情况我建议换个方案:
- 状态结构非常深,切分后还有大量跨领域联动,最终 context 拆了一堆还是互相牵扯。
- 需要强大的调试工具,比如时间旅行调试、按 action 回放。
- 需要模块间跨框架共享状态,Hooks 方案解决不了。
- 已有大量基于第三方状态库的代码资产,硬拆回 context 成本不值。
第三方状态库不是“高级”的象征,context 也不是“朴素”的代名词。选型的核心永远是团队维护成本和业务复杂度。小项目用组合拳轻松搞定,大项目按团队习惯走,没有标准答案。
5. 面向新版本的 Hooks 视野:useId 与外部状态订阅
5.1 useId 解决的是标识稳定性
React 18 带来了一批新 Hooks,这里面useId在写表单、无障碍标签时非常好用。
以前要给一个输入框生成独一无二的id,常见做法是Math.random()或全局计数器。但随机数每次渲染都变,会在服务端渲染和客户端 hydration 时造成 mismatch,而且如果组件被多次复用,id还冲突。useId生成的是一个稳定且不重复的标识:
import { useId } from 'react'; function EmailInput() { const id = useId(); return ( <div> <label htmlFor={id}>邮箱</label> <input id={id} name="email" /> </div> ); }关键点是:useId不能用作文本列表的key。key的职责是标识列表项的身份,需要在兄弟节点里稳定且唯一,而useId不是为这个场景设计的。用来绑定元素和 label、aria 属性才是它的正确姿势。
5.2 useSyncExternalStore 的正确订阅姿势
useSyncExternalStore是给订阅“外部数据源”用的,比如浏览器在线状态、路由对象、或者某个全局事件中心。
旧方案通常是在useEffect里 addEventListener,卸载时 removeEventListener。这套逻辑能跑,但并发渲染模式下内部数据版本不一致可能引发 UI 撕裂(tearing)问题。“tearing”指的是同一次渲染里,不同组件读到的同一外部状态不一致,画面看起来“撕开”了。
import { useSyncExternalStore } from 'react'; const emptySubscribe = () => () => {}; function useOnlineStatus() { const isOnline = useSyncExternalStore( (callback) => { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; }, () => window.navigator.onLine ); return isOnline; }useSyncExternalStore的三个参数分别是subscribe、getSnapshot、getServerSnapshot。内部会拿getSnapshot的返回值做缓存比较,只有当返回结果变化时才会触发组件更新,这比你自己在 effect 里 setState 再手动防抖要稳定可靠得多。
这类 Hooks 短小精悍,但在写 Hooks 生态时值得认识一下。官方文档里翻一眼就到,不必主动硬用,真等到需要一个稳定的浏览器枚举状态时可以想起它。
6. 高频疑难问题速查
下面这份速查表来自我在实际开发里被问过最多次的 Hooks 问题,以及我自己踩过的坑。遇到同类现象,先对照排查。
| 现象 | 常见原因 | 排查/解决思路 |
|---|---|---|
| 页面疯狂重渲染,控制台报 Maximum update depth exceeded | effect 内 setState 触发依赖变化,形成循环 | 检查依赖数组里是否引用了在 effect 里被更新的值;去掉不必要的依赖 |
| 回调函数(setTimeout 等)读到的 state 永远是初始值 | 闭包捕获了旧渲染的变量 | 用useRef同步最新值;或把逻辑放到 effect 内并用依赖数组控制 |
| 连续多次 setState,界面最终没更新 | 修改了原数组/对象,引用没变 | 返回新引用:[...oldArr, newItem]、{ ...oldObj, ...patch } |
| 给子组件包了 memo,还是每次都重渲染 | props 里有每次渲染都新建的对象/函数 | 上游配合 useMemo/useCallback 稳定引用 |
| useEffect 在依赖“没变”时还是执行 | 依赖放的是引用类型,每次渲染创建新对象 | 把对象拆成基本类型;或用 useMemo 稳定对象 |
| context 一变,整个页面都刷新 | context value 每次渲染都是新对象,或所有状态塞在一个 Provider | value 用 useMemo;按领域拆分 Provider |
| 组件卸载后仍 setState | 异步请求还没回来组件已经销毁 | effect cleanup 里设置过期标记,或在请求层用 AbortController 取消 |
| hook 在条件语句里调用后报错 “Rendered more hooks than during the previous render” | 违反了 Hooks 顶层调用规则 | 把 hook 调用移到函数顶部,不能在 if/for 里调用 |
我个人在实际操作中的体会是:React Hooks 的文档不算难,难的是把它放进自己项目的真实约束里运行。闭包陷阱、引用比较、渲染时机,这些都是组件运行机制层面的东西,不像配置项那样背一背就有用。它们需要你在一次次的调试里慢慢形成肌肉记忆。
最后分享一个小技巧:遇到奇怪的“不更新”问题,先别急着改代码,先在组件顶层console.log一下当前函数是否真的被重新执行了,再逐步缩小范围。很多时候你会发现某个记忆化完全没生效,或者在某个回调里拿到的值根本不是你以为的那个。把渲染和执行路径摸清楚,再谈优化和封装,才是 Hooks 真正入门的标志。