2.5 Context 与跨层状态 本节摘要:Context 是 React 内建的跨层数据共享方案,解决 prop drilling——props 层层传递的繁琐与脆弱。本节从 createContext 开始,讲 Provider 提供值、Consumer 与 useContext 两种消费方式,并用主题切换、用户认证、语言环境三个真实例子落地,最后讲清它的性能代价与「何时该升级到状态管理库」的分界线。 本节导航 阅读完本节,你应当能够: 用 createContext、Provider、useContext 实现跨层数据共享 说出 prop drilling 的问题和 Context 的解法 对比 Context.
本节摘要:Context 是 React 内建的跨层数据共享方案,解决 prop drilling——props 层层传递的繁琐与脆弱。本节从 createContext 开始,讲 Provider 提供值、Consumer 与 useContext 两种消费方式,并用主题切换、用户认证、语言环境三个真实例子落地,最后讲清它的性能代价与「何时该升级到状态管理库」的分界线。
阅读完本节,你应当能够:
假设应用有三层结构:App → Layout → UserMenu。用户登录信息在 App 里,UserMenu 要显示用户名。用 props 传递的话,App 把 user 传给 Layout,Layout 再传给 UserMenu——中间那层 Layout 根本不关心 user,只是「路过」。
层数浅时这还好,但真实应用五六层很常见。于是问题来了:
这个问题有个专门的名字:prop drilling(属性钻取)。Context 就是 React 的官方解药——在组件树中创建一个「数据通道」,Provider 在一端注入值,任意深度的组件在另一端直接读取,中间层完全无关。
SOURCE 原文对 Context 的定义很精准:在组件树中创建一个上下文,将数据注入到任何需要访问该数据的组件,无需在每一层手动传递 props。
Context 由三部分组成:
先看最简的三件套。创建上下文:
import { createContext } from 'react'; const ThemeContext = createContext('light'); // 默认值 light
提供值:用 Provider 包裹需要访问该值的子树,value 就是要注入的数据:
function App() { return ( <ThemeContext.Provider value="dark"> <Toolbar /> </ThemeContext.Provider> ); }
消费值:函数组件用 useContext,一行搞定:
import { useContext } from 'react'; function Toolbar() { const theme = useContext(ThemeContext); return <div>当前主题:{theme}</div>; }
Toolbar 无论嵌多深,只要在 Provider 内部,就能直接读到 theme。中间层组件不用传任何 props。
SOURCE 原文的主题切换例子是 Context 的经典教学案例,完整展开。
import { createContext, useState } from 'react'; const ThemeContext = createContext('light'); function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const toggleTheme = () => { setTheme(prev => (prev === 'light' ? 'dark' : 'light')); }; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> ); }
注意 value 是一个对象,同时注入了当前主题和切换函数。这正是 Context 的常见用法——不只传数据,还传操作数据的函数。
import { useContext } from 'react'; function ThemedButton() { const { theme, toggleTheme } = useContext(ThemeContext); return ( <button onClick={toggleTheme} style={{ backgroundColor: theme === 'light' ? '#eee' : '#222', color: theme === 'light' ? '#000' : '#fff', }} > 切换主题 </button> ); }
function App() { return ( <ThemeProvider> <ThemedButton /> </ThemeProvider> ); }
用户点击按钮 → toggleTheme 更新 state → value 变化 → 所有消费 ThemeContext 的组件重渲染。按钮的样式跟着主题走,中间层零参与。这就是「跨层状态共享」的完整链路——Provider 管理数据的来源,消费方只关心自己读到的值,两边的耦合仅存在于 Context 这个接口上。
useContext 之前,类组件或需要在 JSX 里消费值的场景用的是 Consumer 组件——它接收一个函数作为子节点:
function ThemedButton() { return ( <ThemeContext.Consumer> {({ theme, toggleTheme }) => ( <button onClick={toggleTheme} style={{ background: theme }}> 切换主题 </button> )} </ThemeContext.Consumer> ); }
useContext 出现后,函数组件里基本用不到 Consumer 了。但读旧代码时你要认识它——它和 render props 模式一脉相承,函数作为子节点接收上下文值。
还有一个实用细节:Context 的 Provider 和 Consumer 都属于 React 节点,可以出现在任何 JSX 位置。Provider 通常放在应用顶层包裹整棵子树,但也能放在局部——比如只给某个弹窗模块包一个局部 Provider,范围越小,重渲染波及面越小。这个「Provider 分层」思路值得记住:不是所有 Context 都要在根部,按需就近提供,能显著缩小价值变化的传播半径。
| 维度 | useContext | Context.Consumer |
|---|---|---|
| 适用 | 函数组件(Hook) | 类组件、JSX 内联消费 |
| 写法 | 解构取值 | 函数作为子节点 |
| 嵌套多个 Context | 多次调用,清晰 | 层层嵌套,深 |
| 现状 | 主流 | 存量代码 |
Context 可以嵌套。一个组件同时消费多个 Context 是常见需求——比如用户信息和语言环境:
const UserContext = createContext({ username: 'Guest', id: 0 }); const LanguageContext = createContext('zh'); function UserProfile() { const user = useContext(UserContext); const language = useContext(LanguageContext); return ( <div> <p>用户:{user.username}</p> <p>语言:{language === 'zh' ? '中文' : 'English'}</p> </div> ); } function App() { return ( <UserContext.Provider value={{ username: 'John', id: 123 }}> <LanguageContext.Provider value="zh"> <UserProfile /> </LanguageContext.Provider> </UserContext.Provider> ); }
多个 Context 的 Provider 层层包裹,消费端多次调用 useContext。这样做的价值是「按数据主题分箱」——不同类别的数据各自独立,修改一个不影响另一个的订阅者。
SOURCE 原文列出了五个典型场景,我按实践频率重新排一下:
| 场景 | 说明 | 例子 |
|---|---|---|
| 主题切换 | 全站样式主题 | 亮色/暗色 |
| 用户认证 | 登录态全局可用 | token、用户信息 |
| 语言环境 | 多语言切换 | 当前 locale |
| 全局配置 | 所有组件共享 | API 地址、调试开关 |
| 简单全局状态 | 少量低频变化的共享状态 | 购物车数量 |
共同点是:数据全局性或跨大范围共享、变化频率不高。这是 Context 的甜蜜区。
Context 不是免费的。它有一个关键性能特征:Provider 的 value 变化时,所有消费该 Context 的组件都会重渲染,不管它们是否真的用到了变化的那部分。
这个「一刀切」的代价在处理频繁变化的数据时会被放大——比如每秒都在变的实时数据放进 Context,整个订阅树每秒重渲染一次。
缓解手段:
function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const value = useMemo(() => ({ theme, toggleTheme }), [theme]); return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); }
Context 对象可以设置 displayName,这个字符串会显示在 React DevTools 里。默认情况下,调试面板里只能看到「Context.Provider」「Context.Consumer」这类笼统名字,一旦你创建了多个 Context,根本分不清谁是谁。
const ThemeContext = createContext('light'); ThemeContext.displayName = 'ThemeContext'; const UserContext = createContext(null); UserContext.displayName = 'UserContext';
设置之后,DevTools 里显示的就是 ThemeContext.Provider、UserContext.Provider。调试多 Context 应用时,这一个小设置能省很多时间。它不改变任何行为,纯粹是给开发工具看的标签。
createContext 的默认值只在「找不到 Provider」时生效。理解这一点有两个实际影响:
第一,默认值给了「脱离 Provider 也能渲染」的兜底。测试一个只消费不提供的组件时,默认值让它在没有 Provider 的环境下也能跑,不用为每个 Context 都套 Provider。
第二,默认值不能当「初始值」用。Provider 一旦提供 value,默认值就被覆盖,之后 Context 状态的变化也完全由 Provider 的 state 管理。想初始化 Context 里的数据,靠的是 Provider 内部的 useState,而不是 createContext 的参数。
💡 关键直觉:把 Context 想成广播电台——Provider 是发射台,所有订阅组件是收音机。电台一换频率(value 变化),所有收音机同时响应,无法只通知其中一台。想精确定向通知,就得换状态管理库那套「按需订阅」的机制。
⚠️ 常见坑:value 不 memo,导致 Provider 每渲染一次,所有消费方跟着重渲染一次。这个坑几乎人人都踩过,也是「用了 Context 反而变卡」的头号原因。value 对象务必用 useMemo 稳定引用。
经常有人问:有 Context 了,还要 Redux 干嘛?
分界线是「复杂度」。Context 适合低频、结构简单的全局数据;当状态变得复杂——多模块共享、派生状态多、更新逻辑需要可预测、需要时间旅行调试——就该上状态管理库。
| 维度 | Context | 状态管理库(Redux/Zustand) |
|---|---|---|
| 学习成本 | 低 | 中到高 |
| 性能 | 值变全体重渲染 | 按需订阅 |
| 调试 | React DevTools | 专用 DevTools |
| 复杂度上限 | 简单全局状态 | 大型应用 |
| 何时换 | 数据变复杂时 | 一开始就复杂时 |
一句话建议:先 Context,变复杂了再迁移。 不要一上来就 Redux,也不要死守 Context 不放。第 3.3 节会给出完整的选型框架。
直接用 useContext 读 Context 有个小问题:组件里写的是字符串式的 Context 名,抽象层泄漏到了使用方。更好的做法是用自定义 Hook 包装:
function useTheme() { const context = useContext(ThemeContext); if (context === undefined) { throw new Error('useTheme 必须在 ThemeProvider 内使用'); } return context; }
这个模式有几个实际好处:第一,语义化——const { theme, toggleTheme } = useTheme() 比 useContext(ThemeContext) 更表达意图;第二,错误提示——组件忘了包 Provider 时,抛出的错误直接告诉你「useTheme 必须在 Provider 内使用」,而不是让消费方拿到 undefined 然后莫名崩溃;第三,收口——以后想改 Context 的实现(比如换成状态管理库),只需要改 useTheme 内部,使用方零改动。
当一个 Context 比较完整时,把它和它的 Provider、自定义 Hook 组织在一起,用统一导出。常见约定是一个 Context 一个文件夹,内含 context 对象、provider 组件、消费 Hook 三件套,统一从入口导出。团队协作时,新成员看目录结构就能知道这个全局状态怎么用,不用翻代码猜。
有三类情况,Context 不是好选择:
第一,频繁变化的数据。鼠标位置、输入框实时值这类高频状态放进 Context,订阅树会跟着高频重渲染。低频全局数据是 Context 的甜蜜区,高频数据应该留在组件内部或交给按需订阅的库。
第二,需要跨模块深逻辑的状态。Context 的 value 只是一个值,没有内置的「更新规则」。多个模块都要改同一份状态、且更新逻辑复杂时,裸 Context 会演变成「谁都能 setState,没人知道改了什么」的混乱局面。
第三,服务端渲染场景的初始状态注入。SSR 需要在服务端把数据序列化给客户端,Context 本身不处理这个过程,需要额外的桥接机制。这是第 3.7 节 SSR 会涉及的话题。判断这三类情况有一个简单口诀:Context 用「低频、跨层、只读多写少」;状态库用「高频、模块多、更新规则复杂」。拿这个口诀过一遍你的需求,选择基本就清楚了。
把本节知识串成一个完整的认证流程:Provider 持有登录态,任意深度的组件读取并操作它。
const AuthContext = createContext(null); function AuthProvider({ children }) { const [user, setUser] = useState(null); const login = (userData) => setUser(userData); const logout = () => setUser(null); const value = useMemo(() => ({ user, login, logout }), [user]); return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; } function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth 必须在 AuthProvider 内使用'); return ctx; } function UserMenu() { const { user, logout } = useAuth(); if (!user) return <button onClick={() => login({ name: '访客' })}>登录</button>; return <button onClick={logout}>退出({user.name})</button>; }
这个模式是「Provider + 自定义 Hook」的标准模板:认证、主题、语言、购物车,都可以照这个结构写。它把状态管理、上下文提供、消费入口都收在一个模块里,是 Context 实战中的最佳实践形态。刚上手时建议就照这个模板写,少走弯路。
「Provider 嵌套太深怎么办?」 这是 Context 的「回调地狱」变体——五六个 Provider 层层嵌套,组件树看起来像洋葱。几个解法按推荐排序:一是「合并 Provider」,把多个 Context 的 Provider 封装成一个 AppProviders 组件,把嵌套收敛到一层;二是「按需就近提供」,不是所有 Context 都要在根组件包,局部模块自己的数据在局部包 Provider,减少根部的嵌套;三是「重新评估是否都要 Context」,有些数据其实是「单一页面内共享」,用状态提升到最近的公共父组件就够,不必上 Context。深度嵌套不是 Context 本身的问题,是「把所有东西都塞进全局」的组织问题。
「Context 值变化时,只有消费组件重渲染吗?」 是——但「消费」比你想的宽。除了显式调用 useContext 的组件,还有两种会被牵连:一是 Context.Consumer 包裹的节点;二是「Provider 的 value 变化导致 Provider 自身所在的组件重渲染」时,它的子组件(即使不消费)也可能跟着重渲染——这是 React 渲染的正常传播,不是 Context 特有。所以「用 Context 反而卡」的真正原因通常是:value 每次渲染都生成新引用(对象字面量),触发了所有消费方重渲染。解法还是那句:value 用 useMemo 稳定引用,消费组件用 memo 减少无谓渲染。
「多个 Context 的数据能合并成一个吗?」 能,但要想清楚代价。把用户信息、语言、主题、权限全塞进一个「大 Context」,好处是消费方一次解构拿全;坏处是任何一个字段变化,所有消费方都重渲染——即使它们只用其中一个字段。这和第 3.3 节「别把所有状态塞进一个大 store」是同一个原则:按变化频率和数据主题拆分,每个 Context 管一组「同频变化」的数据,改一组不影响另一组的订阅者。拆分的粒度以「这类数据多久变一次」为准,变化频率相近的合并,差异大的分开。
「Context 能跨路由共享吗?」 能,把 Provider 放在 Router 之外(应用最外层)就能覆盖所有路由。常见错误是把 Provider 放在某个页面组件内部——那它只对该页面及其子树生效,切换路由后 Context 状态会丢失(Provider 随页面卸载)。所以「全局性数据」的 Provider 一定要放在路由之上,比如应用入口文件里、Router 的外层。局部数据才适合放在页面内部。这条「Provider 放多高,数据就覆盖多广」的直觉,是组织 Context 时最重要的判断。
下一节看 Refs 与 DOM 访问——Context 解决数据下行,Refs 解决「逃离声明式」的上行。聚焦输入框、操作 DOM、集成第三方库这些必须绕过 React 的场景,都会在这里找到答案。这也是第 2 章核心概念的最后一节,之后我们就从「理解 React 怎么做」进入「在实际项目中怎么选怎么用」。