陌上人如玉
公子世无双

5.9 Context 引起的重渲染

5.9 Context 引起的重渲染

一句话: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)

十、面试延伸

  1. 「为什么 Context 会导致大量组件重渲染?」
    因为 Context 的订阅粒度是整个 value:Provider 用 Object.is 比较新旧 value,一旦不等就通知所有订阅该 Context 的组件重新渲染——不管它们实际只用到 value 的哪一部分。而且这个通知走的是「Context 传播」路径(不经过 props 比较),因此 React.memo 无法拦截。如果 value 是「每次新建的对象」或「包含高频变化字段」,重渲染范围就会很大。

  2. 「怎么优化 Context 的重渲染?」
    五个手段按成本排序:① useMemo 稳定 value(消除「内容没变但引用变了」的伪更新,几乎零成本必做);② 拆分 Context(按变化频率/使用方/语义拆,最常用);③ 值与操作分离(StateContext 会变、ActionsContext 永不更新,只有操作的组件永不重渲染);④ children 透传(把不依赖 context 的子树从内部移到外部);⑤ 改用带选择器的方案(Zustand / Jotai / use-context-selector)——这是根本解法。

  3. 「React.memo 为什么拦不住 Context 的更新?」
    因为 React.memo 只比较 props,而 Context 的更新是「直接通知消费者组件」的——React 内部维护了「订阅者列表」,value 变化时逐个通知这些组件(通过 useContext 订阅的组件),这个路径不经过 props 的比较环节。所以即使组件被 memo 包裹,只要它订阅的 Context 变化,它仍会重渲染。这也是 useContext 的消费组件「性能难以优化」的根本原因。

  4. 「什么是『值与操作分离』?为什么有效?」
    指把 Context 拆成两个:StateContext(放会变化的数据)与 ActionsContext(放稳定的操作函数——useReducer 的 dispatch 天然稳定,或 useMemo(() => ({...}), []) 生成)。有效性来自「引用稳定」:ActionsContext 的值永不变化,所以只订阅它的组件永不因数据变化而重渲染。实际项目里「只需要触发动作」的组件(提交按钮、工具栏、表单操作)非常多,因此这个技巧收益明显。

  5. 「children 透传为什么对 Context 优化有效?」
    因为「元素对象由谁创建决定了它的身份」。如果子树写在 Provider 内部,Provider 每次重渲染都会重新创建这个元素,从而让它重渲染;如果子树由外层组件创建并作为 children 传入,那么 Provider 重渲染时 children 是同一个元素对象(因为外层没重渲染),React 发现引用相同就跳过它的子树。前提是「创建 children 的外层组件本身是稳定的」。

  6. 「什么时候该放弃 Context 改用状态库?」
    当出现以下信号时:① 确实需要「只订阅一部分数据」(一个 store 里几十个字段、不同组件各用几个);② 数据变化频繁(输入、拖拽、实时数据)——Context 会造成大范围重渲染;③ 需要在 React 之外读写数据(事件回调、WebSocket、工具函数);④ 需要中间件、DevTools、时间旅行。此时 Zustand(轻量、无 Provider、选择器订阅、可在 React 外读写)或 Jotai(原子化、细粒度)通常是更好的选择。

  7. 「Zustand 的选择器有什么坑?」
    最大的坑是「选择器返回新对象」:useStore((s) => ({ a: s.a, b: s.b })) 每次调用都返回新对象 → Object.is 比较失败 → 每次都重渲染。解法:① 分开订阅(每个字段一次 useStore 调用);② 用 useShallow 包装(浅比较);③ 只选择原始值或稳定引用。这个坑与 Context 的「useMemo 包装 value」是同一个原理——都是「引用稳定」问题。

  8. 「优化 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 或分开订阅。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.9 Context 引起的重渲染

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫