
一句话:Context 的订阅粒度是整个
value——value 一变,所有消费者都重渲染(React.memo拦不住);优化方案按成本排序是「useMemo稳定 value → 拆分 Context → 值与操作分离 →children透传 → 状态库选择器」。
一、问题模型
const Ctx = createContext<CtxValue | null>(null);
function Provider() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
return (
<Ctx.Provider value={{ a, b }}> {/* ⚠️ 每次渲染都是新对象 */}
<OnlyA /> {/* 只想用 a */}
<OnlyB /> {/* 只想用 b */}
<Button /> {/* 只需要 setB */}
</Ctx.Provider>
);
}
三层原因:
① value 是「对象字面量」→ 每次渲染都是新引用
→ 即使 a/b 没变,所有消费者也会重渲染(「伪更新」)
② 即使 value 用 useMemo 稳定了,只要【其中任何一部分】变化,
React 就会通知所有消费者(订阅粒度是整个 value)
→ 改 a 会让 OnlyB 也重渲染
③ 这个通知【不经过 props 比较】
→ React.memo 无法拦截
结论:Context 适合「不常变化的值」;高频变化的值应该用别的方案。
二、方案一:useMemo 稳定 value(最低成本)
function Provider() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
// ✅ 只有 a 或 b 变化时,value 引用才变化
const value = useMemo(() => ({ a, b }), [a, b]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
解决了什么:消除「内容没变但引用变了」的伪更新
没解决什么:a / b 真的变化时,仍然通知【所有】消费者
⚠️ 常见错误(会让 useMemo 失效):
const value = useMemo(() => ({ a, b }), [a]); // ❌ 漏了 b
const value = useMemo(() => ({ a }), [a, { x: 1 }]); // ❌ 依赖不稳定
三、方案二:拆分 Context(最常用)
// ✅ 按「变化频率」+「使用方」拆分
const UserContext = createContext<User | null>(null); // 很少变
const ThemeContext = createContext<Theme>('light'); // 很少变
const NotificationsContext = createContext<Notification[]>([]); // 经常变
function App({ children }: { children: ReactNode }) {
const [user] = useState(loadUser);
const [theme, setTheme] = useState<Theme>('light');
const [notifications, setNotifications] = useState<Notification[]>([]);
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<NotificationsContext.Provider value={notifications}>
{children}
</NotificationsContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// 组件只订阅自己需要的
function Avatar() {
const user = useContext(UserContext); // ⭐ 只订阅 User
return <img src={user?.avatar} alt="" />;
}
function ToastList() {
const items = useContext(NotificationsContext); // ⭐ 只订阅 Notifications
return <>{items.map((n) => <Toast key={n.id} {...n} />)}</>;
}
// → notifications 变化不会让 Avatar 重渲染
拆分的三个维度:
① 按变化频率(低频 / 高频分开)
② 按使用方(只有侧边栏用的数据不要放全局)
③ 按语义/领域(用户、主题、通知各自独立)
✅ 实践建议:用「一个 AppProviders 组件」包多个 Provider,避免嵌套地狱
function AppProviders({ children }: { children: ReactNode }) {
return (
<UserProvider>
<ThemeProvider>
<NotificationProvider>
<ToastProvider>{children}</ToastProvider>
</NotificationProvider>
</ThemeProvider>
</UserProvider>
);
}
四、方案三:值与操作分离
// ✅ 把「会变的值」与「稳定的操作」拆成两个 Context
const StateContext = createContext<State | null>(null); // 会变
const ActionsContext = createContext<Actions | null>(null); // ⭐ 永不更新
function StoreProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
// ✅ 用 useReducer 的 dispatch(天然稳定)
// 或 useMemo 生成稳定的操作集合
const actions = useMemo<Actions>(
() => ({
increment: () => dispatch({ type: 'increment' }),
reset: () => dispatch({ type: 'reset' }),
}),
[] // 空依赖 → 永不变化
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>
{children}
</StateContext.Provider>
</ActionsContext.Provider>
);
}
export function useStoreState() {
const s = useContext(StateContext);
if (!s) throw new Error('useStoreState 必须在 StoreProvider 内使用');
return s;
}
export function useStoreActions() {
const a = useContext(ActionsContext);
if (!a) throw new Error('useStoreActions 必须在 StoreProvider 内使用');
return a; // ⭐ 引用稳定 → 消费方永不因 state 变化重渲染
}
// 收益示例:只需要「改数据」的组件永远不重渲染
function IncrementButton() {
const { increment } = useStoreActions();
console.log('IncrementButton 渲染'); // 只在挂载时渲染一次
return <button onClick={increment}>+1</button>;
}
function CountDisplay() {
const state = useStoreState();
return <span>{state.count}</span>;
}
// → 点击「+1」时,只有 CountDisplay 重渲染
五、方案四:children 透传
// ❌ Provider 内部直接写子树(state 变化 → 子树重建 → 重渲染)
function ProviderBad({ children }: { children: ReactNode }) {
const [state, setState] = useState(initial);
return (
<Ctx.Provider value={state}>
<ExpensiveTree /> {/* ⚠️ Provider 重渲染 → 它重渲染 */}
</Ctx.Provider>
);
}
// ✅ 用 children 透传(元素由外部创建,引用不受 Provider 重渲染影响)
function ProviderGood({ children }: { children: ReactNode }) {
const [state, setState] = useState(initial);
return <Ctx.Provider value={state}>{children}</Ctx.Provider>;
}
function App() {
return (
<ProviderGood>
<ExpensiveTree /> {/* ⭐ 由 App 创建 → App 未重渲染 → 引用不变 */}
</ProviderGood>
);
}
⚠️ 前提:创建 children 的组件(App)本身没有重渲染。
如果 App 也有状态且频繁变化,那 children 依然会被重建。
所以「children 透传」的典型用法是:
· 一个「外层稳定」的组件负责创建 children
· 一个「内层有状态」的 Provider/容器 负责管理状态
六、方案五:状态库的选择器订阅(根本解法)
// Zustand:用选择器只订阅需要的那一部分
import { create } from 'zustand';
const useStore = create<{ a: number; b: number; inc: () => void }>((set) => ({
a: 0,
b: 0,
inc: () => set((s) => ({ a: s.a + 1 })),
}));
function OnlyA() {
const a = useStore((s) => s.a); // ⭐ 只订阅 a
console.log('OnlyA 渲染'); // b 变化时不渲染
return <div>{a}</div>;
}
function IncButton() {
const inc = useStore((s) => s.inc); // 函数引用稳定
return <button onClick={inc}>+1</button>;
}
// ⚠️ Zustand 的坑:选择器返回新对象 → 每次都重渲染
const { a, b } = useStore((s) => ({ a: s.a, b: s.b })); // ❌ 每次都新对象
// ✅ 分开订阅
const a = useStore((s) => s.a);
const b = useStore((s) => s.b);
// ✅ 或用 useShallow
import { useShallow } from 'zustand/react/shallow';
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));
七、方案六:use-context-selector
// 保留 Context 的用法,但支持「选择器订阅」
import { createContext, useContextSelector } from 'use-context-selector';
const Ctx = createContext<{ a: number; b: number }>({ a: 0, b: 0 });
function OnlyA() {
const a = useContextSelector(Ctx, (v) => v.a); // ⭐ 只在 a 变化时重渲染
return <div>{a}</div>;
}
// Provider 写法相同(但用 use-context-selector 的 Provider 组件)
<Ctx.Provider value={{ a, b }}>...</Ctx.Provider>;
它怎么做到的?
· 内部用 useSyncExternalStore + 每个消费者独立订阅
· 选择器结果变化时才通知该消费者
· 代价:需要替换 createContext / useContext 的导入;Provider value 仍需注意稳定
八、方案对比与决策
| 方案 | 是否需要新依赖 | 改造成本 | 解决程度 |
|---|---|---|---|
useMemo 稳定 value |
❌ | 极低 | 只解决「伪更新」 |
| 拆分 Context | ❌ | 中 | 解决「无关字段互相影响」 |
| 值与操作分离 | ❌ | 中 | 解决「只要操作的组件」 |
children 透传 |
❌ | 低 | 解决「不依赖 context 的子树」 |
| 状态库(Zustand) | ✅ | 中高 | ✅ 根本解决(选择器订阅) |
use-context-selector |
✅ | 中 | ✅ 接近根本解决 |
决策顺序(从低成本到高成本):
① 先用 Profiler 确认「这个 Context 引起的重渲染真的是瓶颈」
→ 消费者很少 / value 很少变 → 可能什么都不用做
② 加 useMemo 稳定 value
→ 消除伪更新(成本几乎为零,必做)
③ 拆分 Context(按频率/使用方/语义)
→ 最常用的有效手段
④ 值与操作分离
→ 特别适合「表单」「编辑器」这类操作多的场景
⑤ children 透传
→ 适合「Provider 包着大树,但树不依赖 context」的结构
⑥ 改用 Zustand / use-context-selector
→ 确实需要「细粒度订阅」时的根本解法
// 综合示例:把上面几招用在一起
const StoreStateContext = createContext<State | null>(null);
const StoreActionsContext = createContext<Actions | null>(null);
function StoreProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(rootReducer, initialState);
const actions = useMemo<Actions>(
() => ({
addTodo: (text: string) => dispatch({ type: 'addTodo', text }),
toggleTodo: (id: string) => dispatch({ type: 'toggleTodo', id }),
}),
[]
);
return (
<StoreActionsContext.Provider value={actions}>
<StoreStateContext.Provider value={state}>
{children} {/* ⭐ children 透传 */}
</StoreStateContext.Provider>
</StoreActionsContext.Provider>
);
}
九、常见坑
// ① value 是对象字面量(伪更新,最常见)
<Ctx.Provider value={{ a, b }}> // ❌
// ② 以为 React.memo 能阻止 Context 引起的重渲染
const C = React.memo(function C() {
const { a } = useContext(Ctx); // ⚠️ 无效
});
// ③ 把所有状态塞进一个 Context
const AppCtx = createContext({ user, theme, cart, toast, modal, ... }); // ❌
// ④ 把高频变化的值放进 Context(鼠标位置、输入值、进度、拖拽)
// ⑤ 只需要 dispatch 的组件也订阅了 state(用「值/操作分离」解决)
// ⑥ useMemo 的依赖写错(漏字段 → 不更新;含字面量 → 依然每次变)
const value = useMemo(() => ({ a, b }), [a]); // ❌
// ⑦ Provider 放在「不需要的层级」(应该尽量放低)
// ⑧ children 透传但外层组件也在重渲染(无效)
// ⑨ Zustand 选择器返回新对象却没 useShallow(每次重渲染)
// ⑩ 拆分过多导致「Provider 嵌套地狱」(用 AppProviders 收敛)
// ⑪ 没有先用 Profiler 确认瓶颈就大改架构
// ⑫ 认为「拆分了 Context 就万事大吉」(还要注意 value 稳定与消费者结构)
// ⑬ 在 Context 里放「大对象」(任何字段变化都全量通知)
// ⑭ 用 useMemo 包「依赖了不稳定值的对象」
const value = useMemo(() => ({ fn: () => doSomething() }), []); // ⚠️ fn 每次都是新的?
// (其实这里 useMemo 空依赖 → fn 只创建一次 → 是稳定的;容易混淆,建议直接定义函数再 useCallback)
十、面试延伸
- 「为什么 Context 会导致大量组件重渲染?」
因为 Context 的订阅粒度是整个value:Provider 用Object.is比较新旧 value,一旦不等就通知所有订阅该 Context 的组件重新渲染——不管它们实际只用到 value 的哪一部分。而且这个通知走的是「Context 传播」路径(不经过 props 比较),因此React.memo无法拦截。如果 value 是「每次新建的对象」或「包含高频变化字段」,重渲染范围就会很大。 -
「怎么优化 Context 的重渲染?」
五个手段按成本排序:①useMemo稳定 value(消除「内容没变但引用变了」的伪更新,几乎零成本必做);② 拆分 Context(按变化频率/使用方/语义拆,最常用);③ 值与操作分离(StateContext会变、ActionsContext永不更新,只有操作的组件永不重渲染);④children透传(把不依赖 context 的子树从内部移到外部);⑤ 改用带选择器的方案(Zustand / Jotai /use-context-selector)——这是根本解法。 -
「
React.memo为什么拦不住 Context 的更新?」
因为React.memo只比较 props,而 Context 的更新是「直接通知消费者组件」的——React 内部维护了「订阅者列表」,value 变化时逐个通知这些组件(通过useContext订阅的组件),这个路径不经过 props 的比较环节。所以即使组件被memo包裹,只要它订阅的 Context 变化,它仍会重渲染。这也是useContext的消费组件「性能难以优化」的根本原因。 -
「什么是『值与操作分离』?为什么有效?」
指把 Context 拆成两个:StateContext(放会变化的数据)与ActionsContext(放稳定的操作函数——useReducer的dispatch天然稳定,或useMemo(() => ({...}), [])生成)。有效性来自「引用稳定」:ActionsContext的值永不变化,所以只订阅它的组件永不因数据变化而重渲染。实际项目里「只需要触发动作」的组件(提交按钮、工具栏、表单操作)非常多,因此这个技巧收益明显。 -
「
children透传为什么对 Context 优化有效?」
因为「元素对象由谁创建决定了它的身份」。如果子树写在 Provider 内部,Provider 每次重渲染都会重新创建这个元素,从而让它重渲染;如果子树由外层组件创建并作为children传入,那么 Provider 重渲染时children是同一个元素对象(因为外层没重渲染),React 发现引用相同就跳过它的子树。前提是「创建 children 的外层组件本身是稳定的」。 -
「什么时候该放弃 Context 改用状态库?」
当出现以下信号时:① 确实需要「只订阅一部分数据」(一个 store 里几十个字段、不同组件各用几个);② 数据变化频繁(输入、拖拽、实时数据)——Context 会造成大范围重渲染;③ 需要在 React 之外读写数据(事件回调、WebSocket、工具函数);④ 需要中间件、DevTools、时间旅行。此时 Zustand(轻量、无 Provider、选择器订阅、可在 React 外读写)或 Jotai(原子化、细粒度)通常是更好的选择。 -
「Zustand 的选择器有什么坑?」
最大的坑是「选择器返回新对象」:useStore((s) => ({ a: s.a, b: s.b }))每次调用都返回新对象 →Object.is比较失败 → 每次都重渲染。解法:① 分开订阅(每个字段一次useStore调用);② 用useShallow包装(浅比较);③ 只选择原始值或稳定引用。这个坑与 Context 的「useMemo包装 value」是同一个原理——都是「引用稳定」问题。 -
「优化 Context 之前应该先做什么?」
先用 React DevTools Profiler 确认「这个 Context 引起的重渲染是否真的是瓶颈」。因为:① 如果消费者只有两三个、或者 value 很少变化,优化收益很小,反而增加代码复杂度(多个 Provider、拆分逻辑);② 真正的瓶颈可能在别处(昂贵子组件、DOM 数量、网络)。性能优化必须基于测量——否则很容易「花大力气重构,指标没变」。
一句话速记
Context 的 value 一变,所有消费者都重渲染(订阅粒度是整个 value、
React.memo拦不住);优化顺序是「先用 Profiler 确认是瓶颈 →useMemo稳定 value(必做)→ 拆分 Context(按频率/使用方/语义)→ 值与操作分离(ActionsContext永不更新)→children透传 → 改用 Zustand/Jotai/use-context-selector做选择器订阅」;别把所有状态塞一个 Context、别把高频数据放进 Context(鼠标/输入/进度)、Provider 尽量放低、用AppProviders收敛嵌套;Zustand 的坑同源——选择器返回新对象要配useShallow或分开订阅。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!