1. 先把 React Hooks 3 这件事聊透
很多同学看到“React Hooks3”这个标题,第一反应是“是不是又出了三个新 Hook”。实际搞过一段时间 React 的人应该都清楚,社区里每隔一阵就会冒出来各种新名词,有的来自 React 官方,有的来自生态工具,有的纯粹是某个团队内部封装后的习惯叫法。我最初看到这个标题也以为是某种新版本发布,后来仔细梳理才发现,把 Hooks 相关的东西按“第三个阶段”来理解更靠谱,或者按“三个核心 Hooks 的组合运用”来切入也非常顺。
这个内容能解决的问题很直接:当 useState、useEffect、useContext 单独用都顺了,怎么把它们组合起来写出更健壮的组件逻辑;怎么在函数组件里组织数据流和副作用;怎么把分散在多个组件里的公共状态逻辑抽出来复用。适合的人群也明确:已经会用基础 Hooks、但面对复杂业务场景还是容易绕晕的中级前端开发者。如果你刚接触 React 没多久,直接上来啃这一篇会有点吃力,建议先把基础 Hook 的文档过一遍再回来继续读。
Hooks 这套东西从一开始出现,目的就是让函数组件拥有类组件的能力,同时避开 class 里 this 指向、生命周期逻辑分散这些问题。但很多人用了一段时间后就会发现一个问题:基础 Hook 只是起点,真正决定代码质量的是组合的思路。你可以在一个组件里写十来个 useState 和 useEffect,跑起来没有任何问题,但别人接手的时候大概率会想骂人。这不是 Hook 本身的问题,是组织方式的问题。那么接下来就从设计思路开始,把 Hooks 组合使用的整套逻辑重新拆一遍。
2. 为什么需要“Hooks 3”这种组合视角
2.1 从“会用单个 Hook”到“体系化用 Hook”的跨越
先看一个非常典型的例子。某个列表页面,需求是进入页面后从后端拉取数据、展示加载态、支持下拉刷新、点击条目跳详情、离开页面时清理定时器。如果你只是会 useState 和 useEffect,大概率会写出这样一段代码:
function ListPage() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); useEffect(() => { fetchData(); }, []); async function fetchData() { setLoading(true); try { const res = await fetch('/api/list'); const json = await res.json(); setData(json.data); } catch (e) { setError(e.message); } finally { setLoading(false); } } async function refreshData() { setRefreshing(true); try { const res = await fetch('/api/list?refresh=1'); const json = await res.json(); setData(json.data); } finally { setRefreshing(false); } } return <div>{/* 渲染逻辑 */}</div>; }这段代码能跑,但问题很明显:loading、refreshing、error 这几个状态各自独立,页面同时存在 loading 和 refreshing 为 true 的可能,逻辑上就是错的;fetchData 和 refreshData 两段逻辑高度重复;定时器清理这种副作用被塞在某个 useEffect 里,等组件复杂起来,副作用与渲染逻辑全部纠缠在一起。
这时候就需要换个视角:不是“这个组件里有哪些状态”,而是“这个页面有几种核心业务能力”。这个视角的转变,就是我理解的“Hooks 3”的核心意义——把 Hooks 当作一种组织业务逻辑的思维工具,而不是单纯的状态开关。按这个思路,上面的列表页可以被拆成几个清晰的逻辑单元:列表数据的拉取、加载态管理、定时器副作用管理。
2.2 三个关键 Hook 的协作模式
为什么要从“三个 Hook 的组合”切入建立体系?因为绝大多数业务场景都能被 useState、useEffect、useContext 这三个核心覆盖:
- useState 是数据的载体,负责保持“当前应该显示什么”的状态;
- useEffect 是数据的桥梁,负责响应状态变化并同步外部副作用;
- useContext 是数据的通路,负责跨层级共享信息,避免 props 层层透传。
这三者的协作模式可以类比成一家小型公司的流程:useState 相当于仓库,负责储存公司当前资产的记录;useEffect 相当于执行部门,每次资产记录变化后,需要按需进行采购、清理、调整;useContext 相当于跨部门沟通渠道,让不同层级的信息可以直接传达,而不必每次都由管理层(祖先组件)层层转发。
这套协作模式落到具体代码中,常见的方式是这样:useState 定义核心状态,useEffect 监听状态变化并触发副作用,useContext 提供跨组件共享的全局状态。例如一个用户登录态的场景,AuthContext 里保存用户信息,LoginPage 用 useState 管理表单输入,登录成功之后通过 useEffect 将用户信息写入 Context,并持久化到 localStorage。
2.3 用自定义 Hook 拆分逻辑单元
当业务逐渐复杂,把所有 useState 和 useEffect 都堆在一个组件里,组件体积会膨胀到难以维护。这时候最自然的下一步,是把这些逻辑抽成一个自定义 Hook。自定义 Hook 本质上就是普通函数,只是约定以 use 开头,并且内部可以调用其他 Hook。
还是刚才的列表页,我会把列表数据的获取逻辑抽成一个 useFetchList 的 Hook:
function useFetchList(url, options = {}) { const [state, setState] = useState({ data: [], loading: false, error: null, refreshing: false }); useEffect(() => { let didCancel = false; setState(prev => ({ ...prev, loading: true })); fetch(url, options) .then(res => res.json()) .then(data => { if (!didCancel) { setState(prev => ({ ...prev, data: data.data, loading: false })); } }) .catch(e => { if (!didCancel) { setState(prev => ({ ...prev, error: e.message, loading: false })); } }); return () => { didCancel = true; }; }, [url]); const refresh = useCallback(() => { // 重新拉取 }, [url]); return { ...state, refresh }; }这样组件里就不需要再关心 fetch 这些细节,只负责消费数据。这种拆分的价值在项目变大之后非常明显:一个页面组件从几百行缩到几十行,新需求加进来时,只需要改自定义 Hook 内部逻辑,页面组件的结构保持稳定。
2.4 什么时候不要用“三 Hook 组合”过度设计
这套组合确实好用,但不意味着每个函数组件都必须套用。如果你的组件里只有一两个状态、没有副作用、也没有跨组件共享的需求,硬套机制只会增加代码量。比如一个纯展示组件:
function ProductCard({ product }) { return <div>{product.name} - {product.price}</div>; }这种组件完全不需要 Hook。很多人学了一套模式之后就忍不住所有组件都用自定义 Hook 包一层,反而把原来简单的代码搞复杂了。合理的评估标准是这样的:当组件里出现两个以上的 useState、一个以上无关联的 useEffect、或者多个组件共享同一份数据时,才值得引入这种组合与拆分策略。
3. 核心逻辑解析:状态、副作用与跨组件通信
3.1 状态管理的正确姿势:多个状态放一个 useState 还是拆开
这是每个 React 开发者都会纠结的问题。我见过很多代码把每个字段单独拆成一个 useState,也见过有人把整个表单对象塞进一个 useState 里。两种方式各有适用场景。
独立的 useState 适合这样几种情况:状态之间没有联动,更新频率差异大,或者状态值本身就是独立实体。比如一个视频播放器,当前播放时间、音量、播放/暂停状态都是互相独立的,拆开更清晰。
对象式 useState 适合字段之间天然关联、经常整体更新的场景。比如表单的输入值,一个 Form 里有 name、email、age 三个字段,用户每次输入更新其中一个,如果拆成三个独立状态,处理函数要写三个,最后提交时还得组装一遍;如果用一个对象,每次 setState 时用展开运算符合并更新即可。
不过对象式 useState 有一个非常容易踩的坑:直接修改对象属性不会触发渲染。很多人会在回调里写成state.name = 'xxx',然后发现页面没有任何变化。React 的比较机制靠的是引用变化,不是深度比较,只有当你创建了一个新对象(比如setState({ ...state, name: 'xxx' })),才能触发更新。这个坑的关键点就在这里,任何 Hook 使用中都绕不开。
再考虑到状态之间的协调,我会把业务强相关的字段聚合为一个状态对象,业务无关的独立字段拆开。比如列表页的 loading、error、refreshing 都与请求状态强相关,聚合为一个 state 对象更合适,因为它们变化的时机是同构的。
3.2 useEffect 的依赖数组:不是约束,是数据流契约
useEffect 的依赖数组是初学者最容易搞混的地方。它本质上是在描述这个副作用依赖哪些数据:当依赖项变化时,副作用重新执行;依赖项不变则不执行。很多人把依赖数组当成“这个 effect 要用到的变量列表”,漏了某个变量之后,组件运行起来出现陈旧数据。
我习惯把依赖数组理解成“数据流的一个节点”。每当渲染发生,React 会比较依赖数组里每一项的新旧值,如果有变化,就触发 effect 重新执行。这里的核心是,你必须把 effect 内部引用的所有外部变量都放进依赖数组,否则 React 无法知道 effect 是否需要重新执行。为了这一点,我通常会遵守两条规则:
- 凡是在 effect 里读取的 props 或 state,都必须出现在依赖里;
- 如果 effect 内部使用的函数是组件内部声明的,优先用 useCallback 包一层,确保函数引用稳定,然后将其加入依赖。
实际操作中,当涉及深层嵌套的复杂状态时,依赖数组并不能帮你做深度比较。useEffect(() => { }, [obj])里面 obj 的任何属性变化都不会触发 effect,因为 obj 的引用没有变。这种情况要么拆散依赖项,要么用 useMemo 创建派生数据。
3.3 useContext:跨组件通信的边界感
Context 提供了一种直接跨层级传递数据的能力,避免 props 层层透传。但 useContext 也有明显边界:一旦某个 Context 的值变化,所有消费它的组件都会重新渲染。这正是性能问题的源头之一。如果你的 Context 里放了一个数据对象,每次父组件更新时,如果 new 出来的对象引用变了,所有子组件会无条件重新渲染。
为了控制这种影响,常见的做法是把 Context 按职责拆成若干独立的小 Context。比如 user 信息一个 Context,主题设置一个 Context,权限数据一个 Context。这样更新 user 时不会把主题相关的所有组件也拖下水。另外可以将 Context 的 value 用 useMemo 包裹,避免无意义的引用变化。
3.4 自定义 Hook 的状态隔离机制
很多人写自定义 Hook 时最大的疑问是:多个组件都用同一个 Hook,它们的状态会不会互相串?答案是不会。每个组件在调用 Hook 时,Hook 内部 useState 创建的都是独立的 state,由该组件实例单独持有。这有点像是每个组件从工厂领取了一套独立工具,工具型号相同,但归各自所有。
这个特性带来了一个自然的分层能力:当你把某些逻辑抽成自定义 Hook 后,组件之间天然隔离,互不污染。比如两个列表页都用了 useFetchList,它们的 data、loading 各管各的,不会有任何通信。真正需要共享的部分,再通过 Context 或者状态管理库显式地共享,这样的设计心智负担最小。
4. 实操过程:从组件代码到完整页面,一步步怎么搭
4.1 第一步:先定数据结构,再动手写 Hook
很多人的习惯是一上来就写组件 JSX,写着写着发现需要状态了,才补 useState。这种反向流程容易让组件代码长成一棵没有章法的结构。我建议的顺序是反的:先想清楚页面需要哪些数据,数据的形态是什么,再定义 Hook 的接口。
假设要做一个后台管理系统里的用户列表页面。需要的数据大致如下:
- 用户列表 users
- 当前加载状态 loading
- 加载失败时的错误 error
- 当前页码 page
- 每页条数 pageSize
这就是一个非常标准的请求状态集合。按照前面说的组合思路,可以把“需要 loading、error 等并列状态”作为一个整体管理,也可以用单一 state 对象管理。我较为推荐用单一对象,原因也很实际:这组状态的变更总是同步发生的。发起请求时 loading 变 true、error 清空;请求结束时 loading 变 false、data 填充。把它们放进同一个 useState 之后,状态变更逻辑就收敛在 Hook 内部,返回给组件的是一个不可分割的数据快照。
4.2 第二步:自定义 Hook 里如何组织 useEffect
以刚才的用户列表为例,我们写一个 useUserList,它接收请求参数并返回列表状态和操作函数:
function useUserList(params) { const [state, setState] = useState({ users: [], loading: false, error: null, page: 1, pageSize: 10 }); useEffect(() => { let cancelled = false; async function loadUsers() { setState(prev => ({ ...prev, loading: true, error: null })); try { const query = new URLSearchParams({ page: String(state.page), pageSize: String(state.pageSize) }).toString(); const res = await fetch(`/api/users?${query}`); const json = await res.json(); if (!cancelled) { setState(prev => ({ ...prev, users: json.data, loading: false })); } } catch (e) { if (!cancelled) { setState(prev => ({ ...prev, error: e.message, loading: false })); } } } loadUsers(); return () => { cancelled = true; }; }, [state.page, state.pageSize]); return { ...state }; }这段代码里最关键的不是 fetch 本身,而是cancelled标记。它在组件卸载时被置为 true,这样异步请求即使晚到了也不会对已卸载组件调用 setState,避免了 React 报“在卸载组件上更新状态”的警告。虽然 React 18 之后这类警告没那么常见了,但清理副作用依然是一个好习惯。
同时依赖数组是[state.page, state.pageSize],这意味着页码变化时自动重新拉取。这种把“依赖驱动”的思维贯穿到代码里的方式,是 Hook 组合写法的精髓。你不需要在点击事件里手动调用 fetch,只需要改 page,请求就被 useEffect 自动触发。
4.3 第三步:把翻页、搜索这类操作接到组件上
有了 useUserList,组件内部逻辑就非常干净了:
function UserListPage() { const { users, loading, error, page, pageSize } = useUserList(); const [keyword, setKeyword] = useState(''); const handlePageChange = (nextPage) => { // 这里触发的状态更新会通过 useEffect 自动重新拉数据 }; const handleSearch = () => { // 把 keyword 传给 useUserList,变成请求参数 }; return ( <div> <input value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <button onClick={handleSearch}>搜索</button> {loading && <div>加载中...</div>} {error && <div>加载失败:{error}</div>} <ul> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> <button onClick={() => handlePageChange(page - 1)}>上一页</button> <button onClick={() => handlePageChange(page + 1)}>下一页</button> </div> ); }这种设计让组件只做两件事:把用户输入事件传递给 Hook,把 Hook 返回的数据渲染出来。业务逻辑全都封装在 Hook 里,组件要测试或者复用就特别简单。将来如果要把这个列表页改成弹窗里的选择器,只需要复用 useUserList 即可,组件层整个替换都不会影响数据逻辑。
4.4 第四步:额外处理的分页参数修改问题
上面的代码有个细节要留意:page 和 pageSize 放在 useState 内部了,所以组件内部 handlePageChange 无法直接修改。如果想要外部控制分页,需要把 useUserList 接收的 params 提升为参数:
function useUserList({ page, pageSize }) { const [state, setState] = useState({ users: [], loading: false, error: null }); useEffect(() => { let cancelled = false; // 请求逻辑 return () => { cancelled = true; }; }, [page, pageSize]); return { ...state }; }这两种写法分别被称为“内部状态”和“受控状态”。内部状态写法简单、适合自包含的场景;受控状态写法更灵活,适合需要外部参与状态决策的场景。实际项目里切换很频繁,这取决于页面是否需要上层控制列表行为。比如 tab 切换时多个列表共享同一个分页状态,就适合用受控写法。
4.5 第五步:跨组件共享筛选条件,用 Context 衔接
假设系统里有多个页面都要展示当前的筛选条件,并且筛选条件变化后各页面联动更新。这种情况下,把筛选条件放在 Context 里比放在各自组件的 state 里更合理。建一个 FilterContext:
const FilterContext = createContext(null); function FilterProvider({ children }) { const [filters, setFilters] = useState({ keyword: '', status: 'all' }); const updateFilters = useCallback((nextFilters) => { setFilters(prev => ({ ...prev, ...nextFilters })); }, []); const value = useMemo(() => ({ filters, updateFilters }), [filters, updateFilters]); return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>; } function useFilters() { const context = useContext(FilterContext); if (!context) { throw new Error('useFilters 必须在 FilterProvider 内使用'); } return context; }这里有个容易被忽略的细节:value 里放了一个updateFilters函数,如果不包useCallback,每次 Provider 重新渲染时 value 都会变化,所有消费 FilterContext 的子组件都会跟着重渲染。这个问题的根源是 Context 的 value 比较逻辑是引用比较,不是值比较。所以useMemo在这里并不是表演,而是实打实的性能保障。
业务组件里调用 useFilters 获取筛选条件,再传给 useUserList,数据流就串起来了:
function UserListPage() { const { filters } = useFilters(); const { users, loading, error, page } = useUserList({ page: 1, keyword: filters.keyword, status: filters.status }); // ... }这样用户在某一个页面改了筛选条件,其他订阅了 FilterContext 的页面会自动感知并更新数据。这种“Context 负责共享,Hook 负责状态推导”的分工模式,恰恰是 React 函数组件时代最常用的组合思路。
4.6 一个完整页面的编排示例
把上面这些组合起来,模拟一个完整的用户管理页面骨架:
function UserManagePage() { const { filters } = useFilters(); const { users, loading, error, page } = useUserList({ page: 1, keyword: filters.keyword, status: filters.status }); const [selectedUserId, setSelectedUserId] = useState(null); useEffect(() => { if (selectedUserId) { document.title = `正在查看用户 ${selectedUserId}`; } else { document.title = '用户管理'; } }, [selectedUserId]); return ( <div> <SearchBar /> <UserTable users={users} loading={loading} error={error} /> <Pagination page={page} /> {selectedUserId && <UserDetailModal id={selectedUserId} />} </div> ); }这个页面组件里只剩两类状态:来自 Context 的筛选条件、一个本地选中的用户 ID。其他业务细节全被 Hook 收走。这样写的组件拿到代码评审会上,任何人一眼就能看出数据从哪里来、到哪里去,维护成本直线下降。
5. 实操中的常见报错与排查手册
5.1 依赖数组漏项导致的“数据总是旧的”
这是 Hooks 使用中最高频的问题之一。表现是:effect 里 reads 了某个 props,但依赖数组没写,props 更新后 effect 不执行,界面还是显示旧数据。
排查方法很简单:把 effect 里用到的所有变量列出来,挨个核对依赖数组。如果你用的是现代编辑器和 ESLint 的 react-hooks 插件,它通常能自动提示缺失依赖。但依赖插件不等于依赖正确,有时插件会建议把整个外部函数加入依赖,而那个函数本身没有 useCallback 包裹,引用了及时更新的状态,导致 effect 频繁触发。这种情况要自己分析是加依赖还是稳定函数。
5.2 无限循环:setState 在 effect 里触发
典型的错误写法:
useEffect(() => { setState(prev => ({ ...prev, users: getData() })); }, [state.page]);如果 getData() 每次返回新数组,那么 state 变化后 effect 再次执行,再次 setState,无限循环。解决方式通常是:让依赖更新与效果触发保持单向。setState 本身不应该成为 effect 执行的直接依赖,除非这是有意为之的解耦逻辑。
5.3 组件卸载后警告
前面提到的 cancelled 标记解决的就是这个问题。在 useEffect 的清理函数里,给一个 flag 标记异步操作的取消状态。另外,如果你的请求库已经支持 AbortController,直接用也可以。
5.4 useContext 使用时容易遇到的三个问题
第一个是忘记包裹 Provider,导致 useContext 返回 null 或抛错。我的习惯是在自定义 Hook 里做显式判断并抛出明确错误信息,而不是让调用方在空值里挣扎。
第二个是 value 引用频繁变化导致大范围重渲染,解决方案就是前面提到的 useMemo 包 value。
第三个是过度使用 Context 导致调试困难。当你打开 React DevTools 发现组件树被一层层 Provider 包裹得密不透风,就该反思哪些状态可以挪到其他层级或合成到单一 Provider 里。实际项目中我倾向于按领域拆开,而不是无脑包一层全部塞进去。
5.5 常见问题速查表
| 问题 | 原因 | 解决思路 |
|---|---|---|
| 数据更新后界面不刷新 | setState 直接修改了对象 | 用展开运算符生成新对象 |
| effect 多次执行 | 依赖了引用变化的函数或对象 | 用 useCallback 或 useMemo 稳定引用 |
| 组件卸载后还 setState | 异步请求没有取消 | 在清理函数里设置 cancelled 标记 |
| useContext 返回 undefined | 组件不在 Provider 内部 | 在 Hook 内抛出显式错误 |
| 状态更新触发两次请求 | React 开发模式严格模式 double-invoke | 用请求标识或 useEffect 依赖控制幂等性 |
6. 从这套组合模式还能扩展到什么地方
如果这个“Hooks 3”的组合思路你已经掌握了,自定义 Hook 还能继续做不少事。
比较常见的是数据请求 Hook 的泛化。可以把 useFetchList 升级成 useRequest,接收 URL、请求参数、转换函数、缓存策略。这样整个项目里的所有请求都可以统一走一个逻辑,统一处理 loading、取消、错误重试。不同业务页面之间只存在参数差异,代码高度复用。
再比如表单场景,把输入值、校验规则、错误信息、脏标记封装成 useFormState,配合 Context 可以做一个全局表单状态管理。这套东西用 Hooks 做起来比传统 class 组件时代轻量太多。
更深一步,还能把主题切换、权限判断、路由联动都封装成自定义 Hook。权限场景往往会按角色从接口拉一份权限列表,封装成 usePermission 之后,任何组件里调用一次就能判断是否可操作。这比在组件里到处 useMemo 计算要清晰不少。
当然,每次引入新抽象都是有代价的。如果一个自定义 Hook 本身的逻辑比它封装的业务还要复杂,那封装就失去了意义。我在实际项目里的判断标准始终是:代码的可读性有没有提升,出现问题时能不能更快定位,改动需求时是不是只需要动一处。如果答案都是肯定的,这种抽法才是值得的。
最终这段实践沉淀下来的结论很简单:Hooks 并不是越多越好,也不是越封装越高级,而是要用它们把数据流梳理清楚,把变化收敛到可控范围。React 的函数组件时代,代码的核心沉淀路径就是状态、副作用和共享数据的组合设计,理解了这条主线之后,再多新 Hook 出现,你也能很快吸收进自己的体系里。