适用版本:React 16.8+(Hooks 稳定版),本文以 React 18 并发渲染模型为基准讲解
核心问题:解决 props 层层传递(prop drilling)的痛点,实现跨层级组件通信
一、什么是 Context 透传
1.1 prop drilling 痛点
当组件层级较深时,数据需要从顶层组件逐层传递到深层组件,中间组件本身并不需要该数据,仅作为"搬运工"存在:
text
App (state)
└── A
└── B
└── C(需要 App 的 state)
如果只用 props 传递:App → A → B → C,A、B 组件本身根本不需要这个数据,只是做数据中转,这就是 prop drilling(属性钻探)。
1.2 Context 透传方案
Context 是一套跨层级通信方案,核心两步:
-
在顶层用
Context.Provider包裹子树,注入 value -
子树里任意组件,直接
useContext(MyContext)拿到这个 value,不需要中间组件传 props
这就是大家说的「透传」------数据从顶层直接穿透到任意深层组件,中间组件零代码。
二、基础完整使用流程
2.1 创建 Context
jsx
import { createContext, useContext, useState } from 'react';
// 1. 创建上下文,默认值只有【没有Provider包裹时】才生效
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {}
});
重点:defaultValue 不是"初始状态"!
只有组件不在 <ThemeContext.Provider> 内部时,才会读到这个默认值;只要被 Provider 包裹,完全忽略 defaultValue。
2.2 顶层 Provider 提供数据
jsx
function App() {
const [theme, setTheme] = useState('light');
const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light');
// 传给 Provider 的 value,就是向下透传的数据源
const contextValue = {
theme,
toggleTheme
};
return (
<ThemeContext.Provider value={contextValue}>
<A />
</ThemeContext.Provider>
);
}
2.3 任意后代组件 useContext 消费
jsx
function C() {
// 直接拿,不需要 A、B 传参
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<div style={{ background: theme === 'dark' ? '#000' : '#fff' }}>
<button onClick={toggleTheme}>切换主题</button>
</div>
);
}
链路:App Provider → 向下透传 → C 直接消费,A、B 零代码。
三、核心底层原理:Fiber 树匹配机制
3.1 匹配依据:对象引用全等
Context 的匹配不是靠字符串名字、不是靠变量名 ,靠的是同一个 Context 对象引用。
createContext 返回的是一个唯一的对象(Context 实例) ,useContext(ctxObj) 就是拿着这个对象,去 Fiber 链路上找标记了同一个 ctxObj 的 Provider。
3.2 查找路径:向上遍历 Fiber 树
当执行 useContext(C) 时:
-
从当前组件对应的 Fiber 节点,向上沿着 return 链(父级 Fiber)遍历
-
每遇到一个 Fiber,如果它是 Context Provider,就对比:这个 Provider 绑定的 context === C
-
找到第一个(最近的)相等的,取它的 value,遍历停止
-
一路遍历到根都没找到 → 返回
createContext(defaultValue)的默认值
3.3 同 Context 多层嵌套:就近覆盖
同一个 Context 对象,多层 Provider 嵌套时,遵循就近原则------从当前组件向上找,第一个命中的就是最终值:
jsx
const ThemeCtx = createContext();
<ThemeCtx.Provider value="外层">
<A /> {/* 读到外层 */}
<ThemeCtx.Provider value="内层">
<B /> {/* 读到内层,就近覆盖 */}
</ThemeCtx.Provider>
</ThemeCtx.Provider>
遍历顺序:由近到远,找到即停。B 向上找:B → 内层Provider(命中,返回),不会继续走到外层。
3.4 多种 Context 交错嵌套:互不干扰
jsx
<ThemeCtx.Provider value="dark">
<UserCtx.Provider value="u1">
<C />
</UserCtx.Provider>
</ThemeCtx.Provider>
在 C 里面:
-
useContext(ThemeCtx):向上扫,跳过 UserCtx.Provider(context对象对不上),继续往上找到 ThemeCtx.Provider -
useContext(UserCtx):向上第一个命中就是 UserCtx.Provider
形象比喻:ThemeCtx 是红色标签,UserCtx 是蓝色标签。 useContext(ThemeCtx) = 拿着红色卡片往上找,只认贴红色标签的盒子,蓝色盒子直接跳过。
3.5 源码层面简化逻辑
javascript
function useContext(context) {
let fiber = currentlyRenderingFiber;
// 向上遍历父fiber链
while (fiber.return !== null) {
// 判断这个fiber是不是Context Provider,并且context引用全等
if (fiber.type === ContextProvider && fiber.context === context) {
return fiber.providerValue;
}
fiber = fiber.return;
}
// 遍历完没找到,返回defaultValue
return context.defaultValue;
}
真实源码会更复杂,有 bit 标记、依赖订阅链表、并发模式优化,但匹配核心就是 === 引用相等。
四、更新触发规则与性能优化
4.1 最大的坑:value 引用变化
Provider 的 value 引用变了,所有消费该 Context 的组件全部重渲染!
jsx
// 每次渲染App都新建对象,引用永远变,所有消费者无脑重渲染
<ThemeContext.Provider value={{ theme, toggleTheme }} />
每次 App 渲染,{} 是新对象,引用地址变化 → 不管 theme 有没有变,所有 useContext(ThemeContext) 的组件都会重渲染。
4.2 优化方案:useMemo + useCallback
jsx
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
const contextValue = useMemo(() => ({
theme,
toggleTheme
}), [theme, toggleTheme]);
注意:函数也要稳定!toggleTheme 要用 useCallback 包裹,否则依赖数组每次变,useMemo 白写。
4.3 memo 拦不住 Context 更新
React.memo 只能拦截 props 变化,拦不住 Context 更新。
哪怕组件被 memo 包裹,只要它 useContext,并且 Provider value 变了,它依然会重渲染。这个是很多人意料之外的。
五、自定义 Hook 封装(工程化标准写法)
业务里不要到处裸写 useContext(xxx),封装一层,好处:
-
统一类型校验
-
兜底报错(防止忘记包 Provider)
-
后续换实现只改一处
jsx
// theme-context.js
import { createContext, useContext, useState, useMemo, useCallback } from 'react';
const ThemeContext = createContext(null);
// 对外暴露的 Provider 容器
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = useCallback(() => {
setTheme(p => p === 'light' ? 'dark' : 'light');
}, []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
// 自定义消费hook
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme 必须在 ThemeProvider 内部使用!');
}
return ctx;
}
业务组件直接使用:
jsx
const { theme, toggleTheme } = useTheme();
六、进阶:拆分 Context
6.1 问题:一个 Context 塞很多状态
经典坑:一个 Context 塞很多状态,改其中一个字段,所有消费者全部重渲染。
例:一个 AppContext 同时放 theme、user、locale。只改 theme,依赖 user 的组件也重渲染。
6.2 方案:按领域拆分多个独立 Context
jsx
ThemeProvider + UserProvider + LocaleProvider
组件需要哪个就 use 哪个,互不影响。
权衡:不要拆太碎,嵌套 Provider 太多会嵌套地狱;中等项目 3~5 个上下文是比较舒服的。
七、useContext + useReducer(生产最常用模式)
复杂状态(表单、权限、全局状态),搭配 useReducer 管理状态,Context 只负责透传 state + dispatch:
jsx
const UserContext = createContext(null);
function userReducer(state, action) {
switch(action.type) {
case 'login': return { ...state, info: action.payload }
case 'logout': return { ...state, info: null }
default: return state;
}
}
export function UserProvider({children}) {
const [state, dispatch] = useReducer(userReducer, { info: null });
const value = useMemo(() => ({ state, dispatch }), [state]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>
}
export function useUser() {
const ctx = useContext(UserContext);
if(!ctx) throw new Error('需要UserProvider');
return ctx;
}
消费方式:
jsx
const { state, dispatch } = useUser();
dispatch({type: 'login', payload: {name:'xxx'}})
这就是很多轻量全局状态库(比如 Zustand 早期思想、简易版 Redux)的底层原型。
八、常见误区与避坑清单
| 序号 | 误区 | 正确做法 |
|---|---|---|
| 1 | 把 Context 当万能全局状态库 | Context 适合稳定、低频更新的数据:主题、语言、用户权限、全局配置;不适合高频更新(滚动位置、输入框实时输入、鼠标坐标) |
| 2 | 忘记 useMemo / useCallback,value 引用频繁变化 | 用 useMemo 包裹 value 对象,用 useCallback 包裹稳定函数,避免所有消费者无脑重渲染 |
| 3 | 一个 Context 塞入大量不相关状态,粒度太大 | 按领域拆分多个独立 Context,组件按需消费,互不影响 |
| 4 | 在 Provider 外面调用 useContext,读到默认值(隐性bug) | 封装自定义 Hook,调用时检查 null 并抛错,及时发现问题 |
| 5 | 重复 createContext,以为变量名一样就能匹配 | Provider 和消费组件必须 import 同一个导出的 Context 对象,匹配依据是引用全等 |
| 6 | 误以为靠 displayName 匹配 | displayName 只是给 DevTools 看的,不参与匹配 |
九、版本演进:Hooks 与 Context 时间线
| 版本 | 时间 | Context 相关变化 |
|---|---|---|
| React 16.3 | 2018 | 推出新版 Context API :createContext / Provider / Consumer,替代古老的 legacy context(childContextTypes 废弃API)。此时只有类组件或 <Consumer> render props 写法消费上下文,还没有 useContext。 |
| React 16.8 | 2019-02-06 | Hooks 稳定上线 ,同步带来 useState / useEffect / useContext / useReducer 等。函数组件可以直接 useContext() 拿到上下文,不用套 <Consumer>。 |
| React 17 | 2020 | 没有改动 Context / useContext 的核心逻辑,只是过渡版本。 |
| React 18 | 2022 | useContext API 签名本身没变,但是渲染机制大变:自动批处理、并发渲染、Suspense、Server Component 规则全部升级。 |
9.1 为什么现代讲解都以 React 18 为基准
自动批处理(Automatic Batching)
-
React17:只有合成事件里才批量更新
-
React18:所有场景(Promise、setTimeout、原生事件)全部自动批处理
并发渲染(Concurrent Mode)
React18 开启并发渲染之后,Context 的订阅、重渲染逻辑会在并发下工作;16.8/17 是同步渲染,一旦 context value 变,消费者同步全部重渲染。
Next.js / RSC 约束
现在业务、Next13+/App Router、Remix 都是基于 React18+;并且 React Server Component(RSC)硬性规则:useContext 只能在 'use client' 客户端组件使用,这个是18之后才出现的强约束。
十、方案对比与选型
| 方案 | 适用场景 | 优缺点 |
|---|---|---|
| Props | 简单层级少的组件 | 优点:直观、无额外依赖;缺点:层级深就灾难 |
| Context + useReducer | 中低频全局状态,无第三方依赖需求 | 优点:原生 API、零依赖;缺点:更新粒度粗,value 引用一变全部重渲染 |
| Zustand / Jotai | 需要精细订阅、避免整份 context 更新 | 优点:可以只订阅某个字段,性能精细;缺点:引入第三方库 |
| Redux | 大型复杂状态、多人协作项目 | 优点:生态完善、DevTools 强大;缺点:重、样板代码多 |
一句话选型:不想引入第三方库、跨多层透传、更新不频繁 → useContext + useReducer 是首选。
整理自前端技术讲解 · React Hooks 深入系列