如果你维护的 React 项目已经超过十几个页面八成会遇到这样一个场景登录信息放在根组件里偏偏只有最深层的头像组件要用。你硬着头皮把 user 对象从 App 传到 Layout传给 Header再传给 UserAvatar中间每一层都要声明一个自己根本不关心的 props。这个操作有学名叫 prop drilling。后来我在项目里系统性地改用 context-mode基于 React Context API 的状态管理模式组件层级一下子清爽了许多。这篇文章我会把完整的思路、代码模板和踩坑记录都摊开来讲。不管你是刚接触 Context 的初学者还是已经写过一阵子 Provider 的老手应该都能找到几段对你有用的内容。1. context-mode 到底是什么从一次让你头疼的 props 透传说起1.1 先还原一个真实场景假设你在维护一个电商后台。页面结构是 App 顶层包裹着 LayoutLayout 里放着 HeaderHeader 右下角是 UserAvatar这个头像组件最终需要显示当前登录用户的昵称和头像。最粗暴的写法是什么让 App 拿到 user 对象然后一层一层往下传Layout 接收 userHeader 接收 userUserAvatar 再接收 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-mode2.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-modeContext useReducerRedux ToolkitMobX外部依赖无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 相互独立即使购物车数量 1UserAvatar 也不会被波及。这个拆法有一个额外的好处以后如果某个 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不是某个假数据。这样一旦组件忘了包 Providerhook 里能立即抛出错误而不是静默读取到一个错误默认值// 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 可以直接消费 UserContextHeader 中间层终于从 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 classNameuser-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 来自 useReducerReact 保证它的引用是稳定的所以不放进依赖数组也没问题。第二个方向是拆分 context。假如购物车里有一个高频变动字段比如正在提交中的商品 id而 items 本身是低频变更的你应该让高频字段和低频字段住在不同 context 里。这样高频字段每次变化只有关心它的一两个组件重渲染购物车列表不会被连带刷一遍。4.2 常见问题速查表我把在项目里摸爬滚打遇到的高频问题整理成了一张速查表遇到现象可以直接对着找解法问题现象排查思路解决方案忘记包 Provider组件渲染时抛useUser 必须在 UserProvider 内部使用顺着组件树回溯看 Provider 是否在祖先节点在 App 根部统一嵌套所有 Provider维护单一入口value 引用频繁变化消费方组件无限重渲染页面卡顿打开 React DevTools 查看 Provider 高亮范围用 useMemo 包裹 value按变动频率拆分 contextcreateContext 默认值误用组件读取到 undefined 或空数据不报错但数据不对检查 createContext 的初始参数和 hook 抛错逻辑初始值传 nullhook 里显式抛错杜绝静默失败多个 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 的项目里都会重复看到希望它也能帮你在需求爆发期保持清醒。
阅读完成 · 觉得有帮助?