如果你维护的 React 项目已经超过十几个页面,八成会遇到这样一个场景:登录信息放在根组件里,偏偏只有最深层的头像组件要用。你硬着头皮把 user 对象从 App 传到 Layout,传给 Header,再传给 UserAvatar,中间每一层都要声明一个自己根本不关心的 props。这个操作有学名,叫 prop drilling。后来我在项目里系统性地改用 context-mode(基于 React Context API 的状态管理模式),组件层级一下子清爽了许多。这篇文章我会把完整的思路、代码模板和踩坑记录都摊开来讲。不管你是刚接触 Context 的初学者,还是已经写过一阵子 Provider 的老手,应该都能找到几段对你有用的内容。
1. context-mode 到底是什么:从一次让你头疼的 props 透传说起
1.1 先还原一个真实场景
假设你在维护一个电商后台。页面结构是 App 顶层包裹着 Layout,Layout 里放着 Header,Header 右下角是 UserAvatar,这个头像组件最终需要显示当前登录用户的昵称和头像。最粗暴的写法是什么?让 App 拿到 user 对象,然后一层一层往下传:Layout 接收 user,Header 接收 user,UserAvatar 再接收 user。中间层的 Layout 和 Header 其实完全不渲染用户信息,但它们必须声明这个 props,否则数据就传不到终点。更烦人的是,如果哪天 UserAvatar 下面还要加一层子组件,你又得在 UserAvatar 上多接一次 props。
这种数据接力赛,就是 typing 上让人崩溃的 prop drilling。它不只是"多写几行代码"的问题,而是让中间层组件被迫关心跟自己业务无关的数据,代码的耦合度越来越高。改一个 UserAvatar 的需求,你可能要连带改 Header、Layout 的接口签名,一不小心就把无关组件搞出 bug。我在一个项目里经历过一次这样的重构之后,就下定决心要把共享状态从"逐层传递"改成"就近取用"。
context-mode 做的事情,本质上是把这套接力赛干掉。你用 createContext 创建一个上下文容器,在组件树顶部用一个 Provider 放入共享数据,之后无论目标组件藏得多深,都能通过 useContext 直接读取这份数据。中间任何一层组件都不需要再转发 props,它们只需要做好自己的事。
1.2 context-mode 的三层结构与核心价值
理解 context-mode 只需要抓住三个角色:createContext 创建容器、Provider 提供数据、useContext 消费数据。用生活化的方式去记就是这样——社区公告栏立在广场上,物业把通知贴上去(Provider),居民想看通知时直接来广场看(useContext),不需要找人一层层带话。
它解决的核心问题有三个:消除 prop drilling、统一跨层级数据访问、提高组件复用性。中间层组件不再承载转发职责,挪到哪里都能用;共享数据有了明确的归属地,而不是散落在各个组件内部。
它的核心价值我整理成四条:
- 官方原生能力,不引入任何第三方依赖。useContext、createContext 是 React 内置 API,只需要一个版本够新的 React 就能跑。
- 数据边界清晰。你完全可以根据业务域创建多个 context,比如用户信息一个、购物车一个、主题配置一个,各管各的。这一点是 context-mode 相对"全局单 store"方案最舒服的地方。
- 学习成本极低。团队里哪怕刚入职不久的新人,理解 Provider 和 useContext 两个概念之后,基本能直接上手维护这段代码。
- 配合 useReducer 可以复刻 Redux 的数据流纪律,action、reducer、dispatch 一样不少,但模板代码少很多。
2. 选型拆解:为什么我放弃了 Redux,改成 context-mode
2.1 我对 Redux 的真实感受
先声明一下,我并不是劝所有人都把 Redux 丢掉。Redux 在大型复杂应用里依然有它的价值,尤其是 devtools 的时间旅行调试、严格单向数据流、成熟的生态,在多人协作时非常能打。但我最近几年接手的项目,体量大多处于"中等规模、业务边界清晰、团队不希望被框架绑架"的状态,这时候 Redux 的几个问题就变得非常刺眼。
第一是模板代码量。一个异步请求要配 action、reducer、thunk 或者 saga,经常为一个接口状态写三四个文件。业务迭代快的时候,这些重复劳动非常消耗士气。第二是全局单 store 的隐式依赖。状态全在一个大 store 里,组件不小心 connect 了不该 connect 的数据,很容易产生莫名其妙的联动更新,排起 bug 来要顺着 reducer 一条条查。第三是心智负担。新成员上手 Redux 通常需要一到两周的适应期,期间各种 action type 命名、reducer 拆分规范、异步副作用处理,都是要反复讲的东西。
而 context-mode 正好踩在这些痛点上。它是 React 原生能力,不维护额外状态库;可以按业务域拆多个 context 而不是把所有状态塞进一个大仓库;配合 useReducer 又能保留 action/reducer 的结构纪律。对于很多中小型团队来说,这套组合拳的性价比是明显更高的。
2.2 三种方案横向对比
我直接把自己做选型时的对比表放出来,供你参考:
| 对比项 | context-mode(Context + useReducer) | Redux Toolkit | MobX |
|---|---|---|---|
| 外部依赖 | 无,React 内置 | 有,额外安装 | 有,额外安装 |
| 模板代码量 | 少 | 中等 | 少 |
| 学习曲线 | 低 | 中高 | 中 |
| 状态存储方式 | 可拆分多个 Context | 单一全局 store | 可拆分多个 store |
| 调试工具 | React DevTools 够用 | Redux DevTools 强 | React DevTools 够用 |
| 性能控制 | 需手动 useMemo、拆分 | 内置 selector 机制 | 自动追踪依赖 |
| 适合场景 | 中小型项目、业务域清晰 | 大型复杂应用 | 中大型应用 |
我现在的选型倾向是这样:新项目启动时默认考虑 context-mode,先把业务跑通;只有出现跨模块复杂状态流转、多个页面共享同一份高频变化的业务数据,或者团队明确需要时间旅行调试时,我才会主动引入 Redux Toolkit。千万别一上来就上大炮打蚊子。
2.3 context 拆分策略:一个大 context 不如多个小 context
我见过不少项目把整个应用状态都塞进一个全局 Provider,结果任意一个 state 变化,所有消费方组件全部重渲染。页面卡顿还是小事,最头疼的是每次状态更新,React DevTools 里整棵组件树高亮一大片,你根本分不清是谁引发的更新。
后来我总结出一个拆分原则:按数据变动频率和业务边界来拆。比如电商项目里,UserContext 管登录用户信息,这是低频变动数据;CartContext 管购物车,属于中高频变动;UIContext 管主题切换、侧边栏展开收起,虽然变动频繁但影响范围局限在局部。三个 context 相互独立,即使购物车数量 +1,UserAvatar 也不会被波及。
这个拆法有一个额外的好处:以后如果某个 context 变得过于臃肿,你只需要重构这一块,不会牵扯到其他模块。就像一栋楼里各层独立配电,某一层跳闸,其他楼层灯火通明,排查故障也容易得多。
3. 实操落地:手写一个"用户会话 + 购物车"的 context-mode 模块
3.1 目录结构与文件规划
关于 context 文件的组织方式,我强烈建议按业务域建目录,而不是把所有 context 塞进一个 giant contexts.js。下面是我实践下来比较舒服的结构:
src/ ├─ contexts/ │ ├─ user/ │ │ ├─ UserContext.js # context 对象与 Provider │ │ ├─ userReducer.js # 状态变化逻辑 │ │ └─ useUser.js # 对外暴露的 hook │ └─ cart/ │ ├─ CartContext.js │ ├─ cartReducer.js │ └─ useCart.js ├─ components/ │ ├─ Header.jsx │ ├─ UserAvatar.jsx │ ├─ LoginButton.jsx │ └─ CartBadge.jsx └─ App.jsx每个模块包含三个文件:Provider 负责状态容器和注入,reducer 负责纯函数状态更新,hook 负责对外暴露简洁的调用接口。以后新加一个业务域,复制文件夹改个名字,按照同样的模式填内容就行。这种统一模板能大幅降低团队成员的认知成本,看到新模块代码时不需要重新学习结构。
3.2 为什么用 useReducer 而不是 useState
这里有个很实际的问题:管理共享状态时,直接用 useState 不够吗?当然可以,但如果共享状态内部有多个字段、多个操作,useState 很快就会暴露两个问题。一是更新逻辑会散落在各个组件里,比如"加购商品"的逻辑,可能在 Header、商品列表、详情页各写一遍;二是当更新逻辑复杂时,比如要同时更新 items、totalAmount、coupon 多个字段,setState 的写法会让组件变得臃肿。
useReducer 的优势在于把更新逻辑集中到一个纯函数里,组件只负责派发意图,不关心计算细节。这点和 Redux 的核心思想一致,但因为没有全局 store 的约束,代码量反而小很多。我一般把这么几条当作判断标准:状态字段超过两个、存在同一种状态多种操作方式、状态更新依赖当前状态值。满足任意一条,就值得用 useReducer。
3.3 核心代码实现:从 reducer 到 Provider 到 hook
先来看用户模块。reducer 负责定义状态结构和变化逻辑:
// contexts/user/userReducer.js export const initialUserState = { id: null, name: '', avatar: '', isLoggedIn: false, }; export function userReducer(state, action) { switch (action.type) { case 'LOGIN': return { ...state, id: action.payload.id, name: action.payload.name, avatar: action.payload.avatar, isLoggedIn: true, }; case 'LOGOUT': return { ...initialUserState }; case 'UPDATE_AVATAR': return { ...state, avatar: action.payload }; default: return state; } }接着是 context 和 Provider。注意 createContext 的初始值我传的是 null,不是某个假数据。这样一旦组件忘了包 Provider,hook 里能立即抛出错误,而不是静默读取到一个错误默认值:
// contexts/user/UserContext.js import { createContext, useReducer } from 'react'; import { userReducer, initialUserState } from './userReducer'; const UserContext = createContext(null); export function UserProvider({ children }) { const [state, dispatch] = useReducer(userReducer, initialUserState); return ( <UserContext.Provider value={{ state, dispatch }}> {children} </UserContext.Provider> ); } export default UserContext;这个版本我们先不追求性能优化,直接写最直观的结构,后面第 4 章我会专门讲重渲染问题的处理。
对外暴露的 hook 是我比较看重的部分。它负责两件事:保证 useContext 一定在 Provider 内部使用,以及把 dispatch 包装成有语义的方法:
// contexts/user/useUser.js import { useContext } from 'react'; import UserContext from './UserContext'; export function useUser() { const context = useContext(UserContext); if (!context) { throw new Error('useUser 必须在 UserProvider 内部使用'); } return context; }这个抛错的写法,能帮你在组件树层级混乱时立刻定位问题。比起界面诡异白屏,一个明确的报错信息不知道友好多少倍。
再来是购物车模块。这里的状态更新逻辑要复杂一些,恰好能展示 reducer 的作用:
// contexts/cart/cartReducer.js export const initialCartState = { items: [], // [{ id, name, price, count }] totalAmount: 0, }; export function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': { const existing = state.items.find((item) => item.id === action.payload.id); let items; if (existing) { items = state.items.map((item) => item.id === action.payload.id ? { ...item, count: item.count + 1 } : item ); } else { items = [...state.items, { ...action.payload, count: 1 }]; } return { ...state, items, totalAmount: calcTotal(items) }; } case 'REMOVE_ITEM': { const items = state.items.filter((item) => item.id !== action.payload.id); return { ...state, items, totalAmount: calcTotal(items) }; } case 'CLEAR_CART': return initialCartState; default: return state; } } function calcTotal(items) { return items.reduce((sum, item) => sum + item.price * item.count, 0); }注意我特意把 calcTotal 定义成 reducer 文件内的私有函数,没有暴露出去。这个设计是刻意的:reducer 内部的计算细节不该让外部知道,组件只需要关心"我加了商品,金额自动被算好了"。
购物车的 Provider 和用户模块结构完全一致,这里不重复贴。关键是向外暴露的 hook,我把 action 创建函数一起封装掉,调用方就不用再接触 dispatch 和 action type 这些细节:
// contexts/cart/useCart.js import { useContext, useCallback } from 'react'; import CartContext from './CartContext'; export function useCart() { const { state, dispatch } = useContext(CartContext); const addItem = useCallback( (item) => dispatch({ type: 'ADD_ITEM', payload: item }), [dispatch] ); const removeItem = useCallback( (id) => dispatch({ type: 'REMOVE_ITEM', payload: { id } }), [dispatch] ); const clearCart = useCallback(() => dispatch({ type: 'CLEAR_CART' }), [dispatch]); return { state, addItem, removeItem, clearCart }; }useCallback 在这里的作用不是做渲染优化,而是保证 addItem 这些方法的引用稳定,让使用方用 useEffect 依赖它们时不至于莫名其妙地反复触发。这是我在写 context-mode 时比较得意的一个细节。
3.4 组件消费与跨 context 联动
Provider 的嵌套组织在 App 里完成。嵌套顺序有讲究,一般把"基础数据"放外面,"派生数据"放里面。这里用户和购物车没有强依赖,顺序影响不大,但如果购物车需要读取用户信息(比如根据用户等级算折扣),就得把 UserProvider 放在外层:
// App.jsx import { UserProvider } from './contexts/user/UserContext'; import { CartProvider } from './contexts/cart/CartContext'; import Layout from './components/Layout'; function App() { return ( <UserProvider> <CartProvider> <Layout /> </CartProvider> </UserProvider> ); }现在 UserAvatar 可以直接消费 UserContext,Header 中间层终于从 props 接力赛中解放出来:
// components/UserAvatar.jsx import { useUser } from '../contexts/user/useUser'; export default function UserAvatar() { const { state } = useUser(); if (!state.isLoggedIn) { return <span>未登录</span>; } return ( <div className="user-avatar"> <img src={state.avatar} alt={state.name} /> <span>{state.name}</span> </div> ); }跨 context 联动是 context-mode 比较亮眼的场景。比如退出登录时要同时清空购物车,这个动作只发生在 LoginButton 组件内部,但它同时依赖 user 和 cart 两个 context:
// components/LoginButton.jsx import { useUser } from '../contexts/user/useUser'; import { useCart } from '../contexts/cart/useCart'; export default function LoginButton() { const { state, dispatch } = useUser(); const { clearCart } = useCart(); function handleLogin() { dispatch({ type: 'LOGIN', payload: { id: 1, name: '林晓', avatar: '/avatars/linxiao.png' }, }); } function handleLogout() { dispatch({ type: 'LOGOUT' }); clearCart(); } return state.isLoggedIn ? ( <button onClick={handleLogout}>退出登录</button> ) : ( <button onClick={handleLogin}>登录</button> ); }这种多人协作的组件消费方式,业务边界非常清楚:LoginButton 只负责登录状态和退出行为,不需要知道购物车内部怎么组织;clearCart 方法被封装好,真正的数据变更逻辑在 cartReducer 里。每个模块各自独立,组合在一起又能协作,这就是 context-mode 让我觉得舒服的地方。
4. 重渲染、调试与测试:我在 context-mode 里踩过的那些坑
4.1 性能陷阱:一个小动静让整栋楼都亮灯
context-mode 最大的坑,就是 Provider 的 value 对象引用变化引发的连锁重渲染。当你直接写value={{ state, dispatch }}时,每次 Provider 组件自身重渲染,都会创建一个新的对象引用。React 对比到 value 变了,会把所有消费这个 context 的组件全部重新渲染一遍,不管它们关注的字段有没有实际变化。
这就像一个开关控制整层楼的灯,某间办公室开了个台灯,整层走廊的灯都会跟着亮。组件数量少时无所谓,页面一复杂就到处是性能瓶颈。
解决办法有两个方向。第一个是给 value 包一层 useMemo,让对象引用只在 state 真正变化时才更新:
// contexts/user/UserContext.js import { createContext, useMemo, useReducer } from 'react'; import { userReducer, initialUserState } from './userReducer'; const UserContext = createContext(null); export function UserProvider({ children }) { const [state, dispatch] = useReducer(userReducer, initialUserState); const value = useMemo(() => ({ state, dispatch }), [state]); return <UserContext.Provider value={value}>{children}</UserContext.Provider>; }这里你可能会疑惑:useMemo 依赖 [state],state 一变 value 还是会变啊,那不是白写了?注意,useMemo 防的是"Provider 因为父级重渲染而被连带重渲染"的场景。只要 state 没变,value 就不会因为父组件刷新而产生新引用,消费方也就不会跟着白跑一轮。dispatch 来自 useReducer,React 保证它的引用是稳定的,所以不放进依赖数组也没问题。
第二个方向是拆分 context。假如购物车里有一个高频变动字段,比如"正在提交中的商品 id",而 items 本身是低频变更的,你应该让高频字段和低频字段住在不同 context 里。这样高频字段每次变化,只有关心它的一两个组件重渲染,购物车列表不会被连带刷一遍。
4.2 常见问题速查表
我把在项目里摸爬滚打遇到的高频问题整理成了一张速查表,遇到现象可以直接对着找解法:
| 问题 | 现象 | 排查思路 | 解决方案 |
|---|---|---|---|
| 忘记包 Provider | 组件渲染时抛"useUser 必须在 UserProvider 内部使用" | 顺着组件树回溯,看 Provider 是否在祖先节点 | 在 App 根部统一嵌套所有 Provider,维护单一入口 |
| value 引用频繁变化 | 消费方组件无限重渲染,页面卡顿 | 打开 React DevTools 查看 Provider 高亮范围 | 用 useMemo 包裹 value,按变动频率拆分 context |
| createContext 默认值误用 | 组件读取到 undefined 或空数据,不报错但数据不对 | 检查 createContext 的初始参数和 hook 抛错逻辑 | 初始值传 null,hook 里显式抛错,杜绝静默失败 |
| 多个 context 相互牵连 | 修改一个模块状态,另一个模块组件也跟着刷 | 检查是否存在嵌套过深或共享同一个 Provider | 拆分 context,避免无关数据共享同一个容器 |
| useEffect 依赖方法导致重复请求 | 每次渲染都会触发接口请求 | 检查 effect 依赖数组里的方法引用是否稳定 | 用 useCallback 封装 hook 返回的方法,保证引用稳定 |
这里我最想强调的还是第三行。createContext 的默认值是一个很容易被忽视的坑。很多人图省事,初始值传一个对象,结果组件在没有 Provider 包裹时也不会报错,界面看上去正常,数据却莫名其妙是初始值。把初始值设计成 null,配合 hook 里的 throw,这个小改进能帮你拦住一整个类别的隐性 bug。
4.3 调试技巧与单元测试
调试 context-mode 模块,我基本靠两个工具。第一个是 React DevTools 的 Components 面板,点开某个 Provider 组件,能直接看到当前 value 里的完整数据;再点开消费组件,能看到它从 useContext 拿到的值。从数据来源到消费链路,一眼就能看穿。
第二个是 useDebugValue。给自定义 hook 加一行描述信息,DevTools 里就能直接看到 hook 的语义化状态:
// contexts/cart/useCart.js import { useContext, useDebugValue } from 'react'; import CartContext from './CartContext'; export function useCart() { const context = useContext(CartContext); const { state, dispatch } = context; useDebugValue(state.items.length ? `购物车共 ${state.items.length} 种商品` : '空购物车'); return { state, dispatch }; }测试方面,我推荐用 @testing-library/react 的 renderHook 来测自定义 hook。核心思路是构造一个只包了 Provider 的 wrapper,然后就像普通 hook 一样调用方法、断言结果,完全可以不依赖真实业务组件:
import { renderHook, act } from '@testing-library/react'; import { CartProvider } from '../contexts/cart/CartContext'; import { useCart } from '../contexts/cart/useCart'; test('addItem 应该把商品加入购物车并正确累计金额', () => { const wrapper = ({ children }) => <CartProvider>{children}</CartProvider>; const { result } = renderHook(() => useCart(), { wrapper }); act(() => { result.current.addItem({ id: 1, name: '测试商品', price: 20 }); }); expect(result.current.state.items).toHaveLength(1); expect(result.current.state.totalAmount).toBe(20); });我还发现一个额外的好处:因为 reducer 是纯函数,它的单元测试可以独立写,连 React 渲染都不需要。状态更新逻辑转成纯函数测试之后,覆盖率很好拉,回归风险也低。这算是 context-mode 搭上函数式编程的顺风车吧。
5. 个人体会:context-mode 用什么心态去用
5.1 我的默认选型原则
踩过这么多坑之后,我现在的个人倾向非常明确:新起一个中小型 React 项目,默认就用 context-mode,只有当真正出现跨模块复杂共享、需要严格时间旅行调试的场景,我才会主动考虑 Redux。context-mode 不是银弹,但它是 React 官方提供的最贴合心智模型的状态方案。用好了它,组件层级会清爽很多,新成员接手项目时的心理负担也会小不少。
5.2 一个值得你留意的信号
最后分享一个我在实践中发现的设计信号:如果你发现某个 context 的消费组件越来越多,先别急着归因成性能问题。这通常不是要用 selector 解决的性能问题,而是设计问题。它说明你的数据边界拆得不够细,或者消费方组件职责过重,需要把组件再拆小、把 context 再切薄。我后来凡是遇到"这个组件好卡,我要不要用 use-context-selector"的念头,都会先停下来审视一眼组件和 context 的边界,往往调整完结构,性能问题自己就消失了。
这个信号我几乎在每个用到 context-mode 的项目里都会重复看到,希望它也能帮你在需求爆发期保持清醒。