陌上人如玉
公子世无双

12.8 Context 为什么全量重渲染

12.8 Context 为什么全量重渲染

一句话:因为 Context 的订阅粒度是整个 value(而不是「value 里的某个字段」),且它的更新直接通知消费者、不经过 props 比较——所以 React.memo 也拦不住。

一、结论先行

问题:Context 为什么会「全量重渲染」?

一句话回答:
  因为 Context 的「订阅粒度」是【整个 value】:
  value 一变,React 就通知【所有】订阅该 Context 的组件重渲染——
  不管它只用到 value 的哪一部分。
  而且这个通知【不走 props 比较】→ React.memo 拦不住。

⭐ 两层原因:
① 订阅粒度粗(整个 value)
② 通知路径绕过了 memo

二、验证(跑一遍就明白)

const Ctx = createContext<{ a: number; b: number } | null>(null);

function Consumer() {
  const ctx = useContext(Ctx);
  console.log('Consumer 渲染');                     // ⭐ 观察渲染次数
  return <div>{ctx?.a}</div>;                        // 只用到 a
}

function Provider() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);

  return (
    <Ctx.Provider value={{ a, b }}>                  {/* ① 每次渲染都是新对象 */}
      <button onClick={() => setB((x) => x + 1)}>改 b(Consumer 不用它)</button>
      <Consumer />
    </Ctx.Provider>
  );
}
点击「改 b」时的现象:
Consumer 重新渲染了 —— 尽管它只用到 a

原因两层:
① value 是「对象字面量」→ 每次渲染都是新引用
   → 即使 a/b 都没变,value 也「变了」→ 通知所有消费者(「伪更新」)
② 即使 value 用 useMemo 稳定了,只要【a 或 b 任一变化】,
   React 也会通知所有消费者(订阅粒度是整个 value)
③ 而且这个通知【不经过 props 比较】→ React.memo 无法拦截
// ✅ 加上 React.memo 试试(依然会渲染)
const Consumer = React.memo(function Consumer() {
  const ctx = useContext(Ctx);
  console.log('Consumer 渲染');                      // ⚠️ 依然打印
  return <div>{ctx?.a}</div>;
});
// ⭐ 这验证了「memo 拦不住 Context 的更新」

三、为什么 React.memo 拦不住

⭐ 两条不同的更新路径:

① props 更新的路径:
   父组件重渲染 → 重新创建子元素 → 比较 props(React.memo 在这里生效)
   → 相等则【跳过子组件渲染】

② Context 更新的路径:
   Provider 的 value 变化 → React 直接【通知订阅者列表】里的组件
   (这些组件通过 useContext 注册进去)
   → 走的是【Context 传播】,不经过 props 比较
   → React.memo 没有机会介入

⭐ 所以:即使组件被 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, { extra: 1 }]);   // ❌ 依赖不稳定

五、解法二:拆分 Context(最常用)

// ✅ 按「变化频率 / 使用方 / 语义」拆分
const UserContext = createContext<User | null>(null);              // 很少变(登录)
const ThemeContext = createContext<Theme>('light');                // 很少变(切主题)
const NotificationContext = createContext<Notification[]>([]);     // 经常变

function App({ children }: { children: React.ReactNode }) {
  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <NotificationContext.Provider value={notifications}>
          {children}
        </NotificationContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// 组件只订阅自己需要的那一个
function Avatar() {
  const user = useContext(UserContext);                 // ⭐ 只订阅 User
  return <img src={user?.avatar} alt="" />;
}

function ToastList() {
  const items = useContext(NotificationContext);         // ⭐ 只订阅 Notification
  return <>{items.map((n) => <Toast key={n.id} {...n} />)}</>;
}
// → notifications 变化不会让 Avatar 重渲染
⭐ 拆分的三个维度:
① 按「变化频率」(低频:用户/主题/权限;高频:通知/进度/拖拽状态)
② 按「使用方」(只有侧边栏用的数据不要放全局)
③ 按「语义/领域」(用户、主题、通知各自独立)

✅ 用「一个 AppProviders 组件」包多个 Provider,避免嵌套地狱

六、解法三:值与操作分离(收益明显)

// ✅ 把「会变的值」与「稳定的操作」拆成两个 Context
const StateContext = createContext<State | null>(null);        // 会变
const ActionsContext = createContext<Actions | null>(null);    // ⭐ 永不更新

function StoreProvider({ children }: { children: React.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>
  );
}
// ✅ 只需要「改数据」的组件永不重渲染
function IncrementButton() {
  const { increment } = useActions();
  console.log('IncrementButton 渲染');                     // 只在挂载时渲染一次
  return <button onClick={increment}>+1</button>;
}
// ⭐ 实际项目里「只需要操作」的组件(提交按钮、工具栏)非常多 → 收益明显

七、解法四:children 透传(结构技巧)

// ❌ Provider 内部直接写子树 → state 变化时子树重建
function ProviderBad({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState(initial);
  return (
    <Ctx.Provider value={state}>
      <ExpensiveTree />                                   {/* ⚠️ 跟着重渲染 */}
    </Ctx.Provider>
  );
}

// ✅ 用 children 透传(元素由外部创建,不受 Provider 重渲染影响)
function ProviderGood({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState(initial);
  return <Ctx.Provider value={state}>{children}</Ctx.Provider>;
}

function App() {
  return (
    <ProviderGood>
      <ExpensiveTree />                                   {/* ⭐ 由 App 创建 → 引用不变 → 跳过 */}
    </ProviderGood>
  );
}
⭐ 原理:React Element 是「不可变对象」,身份由「谁创建」决定。
   · 写在 Provider 内部 → 每次渲染都新创建 → 重渲染
   · 由外部创建并传入 → Provider 渲染时是同一个对象 → 跳过子树

⚠️ 前提:创建 children 的那个组件(App)本身没有重渲染。

八、解法五:改用带选择器的状态库(根本解法)

// ✅ 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>;
}

// ⚠️ 注意:选择器返回「新对象」会失效(与 Context 的 useMemo(value) 同理)
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }));     // ❌ 每次新对象
// ✅ 分开订阅,或用 useShallow
const a = useStore((s) => s.a);
const b = useStore((s) => 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>;
}

九、五种解法的对比与选择

解法 需要依赖 改造成本 解决程度
useMemo 稳定 value ❌ 极低 只解决「伪更新」
拆分 Context ❌ 中 解决「无关字段互相影响」
值与操作分离 ❌ 中 解决「只要操作的组件」
children 透传 ❌ 低 解决「不依赖 context 的子树」
状态库(Zustand) ✅ 中高 ✅ 根本解决(选择器订阅)
use-context-selector ✅ 中 ✅ 接近根本
⭐ 决策顺序(从低成本到高成本):
① 先用 Profiler 确认「这个 Context 的重渲染真的是瓶颈」
   (消费者少 / value 很少变 → 可能什么都不用做)
② 加 useMemo 稳定 value(几乎零成本,必做)
③ 拆分 Context(按频率/使用方/语义)
④ 值与操作分离(适合「操作多」的场景)
⑤ children 透传(适合「Provider 包大树、树不依赖 context」)
⑥ 改用 Zustand / use-context-selector(需要「细粒度订阅」时的根本解法)
// ✅ 综合示例:把几招用在一起
function StoreProvider({ children }: { children: React.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 (
    <ActionsContext.Provider value={actions}>
      <StateContext.Provider value={state}>
        {children}                                        {/* ⭐ children 透传 */}
      </StateContext.Provider>
    </ActionsContext.Provider>
  );
}

十、常见追问

Q: 用 useMemo 包 value 就够了吗?
A: 不够。它只消除「伪更新」;「某字段真变化时全量通知」依然存在。
   要「只通知用到该字段的组件」必须拆分或用选择器。

Q: 为什么不用「选择器」解决(像 Vue 的 computed)?
A: 原生 Context 的设计就是「按整个 value 订阅」——
   它没有「选择器」这个概念。需要选择器就得用第三方方案
   (use-context-selector / Zustand / Jotai)。

Q: Context 更新会「穿透」memo 吗?
A: 会。Context 的通知不走 props 比较路径,
   所以 React.memo 无法拦截。

Q: 用 useMemo 包函数呢(避免 value 里的函数每次都变)?
A: 也是必要的——因为 value 是一个对象,
   里面任何一个字段(包括函数)的引用变化都会让 value 变化。
   所以「值 + 函数」都应该稳定(useMemo + useCallback)。

Q: 多个 Provider 嵌套会有性能问题吗?
A: 不会(每个 Context 独立通知自己的订阅者)。
   但「嵌套过深」会影响可读性 → 用「一个 AppProviders 组件」收敛。

Q: 把「高频变化的值」放 Context 会怎样?
A: 所有消费者频繁重渲染(最坏情况)。
   → 高频值应该用「状态库 + 选择器」,或「状态下移」。

Q: Context 可以替代状态管理库吗?
A: 小范围可以(Context + useReducer),但它「没有选择器订阅」、
   「不能在 React 之外读写」、「没有 DevTools/中间件」。
   所以「低频共享值」用 Context(更好),
   「高频/需细粒度订阅」用状态库。

Q: 为什么「值与操作分离」这么有效?
A: 因为「只需要操作」的组件非常多(按钮、工具栏、表单提交),
   而 ActionsContext 的引用永不变化 → 这些组件永不重渲染。

十一、常见坑

// ① value 是对象字面量(伪更新,最常见)
<Ctx.Provider value={{ a, b }}>                                    // ❌

// ② 以为 React.memo 能阻止 Context 引起的重渲染
// ③ 把所有状态塞进一个 Context
const AppCtx = createContext({ user, theme, cart, toast, modal, ... });   // ❌

// ④ 把高频变化的值放进 Context(鼠标位置、输入值、进度、拖拽)
// ⑤ 只需要 dispatch 的组件也订阅了 state
// ⑥ useMemo 的依赖写错(漏字段 → 不更新;含字面量 → 依然每次变)
// ⑦ Provider 放在「不需要的层级」(应该尽量放低)
// ⑧ children 透传但外层组件也在重渲染(无效)
// ⑨ Zustand 选择器返回新对象却没 useShallow
// ⑩ 拆分过多导致「Provider 嵌套地狱」(用 AppProviders 收敛)
// ⑪ 没有先用 Profiler 确认瓶颈就大改架构(过度优化)
// ⑫ 认为「拆分了 Context 就万事大吉」(还要注意 value 稳定与消费者结构)
// ⑬ 在 Context 里放「大对象」(任何字段变化都全量通知)
// ⑭ 忘记「Context 也会在 SSR 中传递」(注意「服务端与客户端值要一致」)
// ⑮ 用 Context 传「函数」但每次渲染新建(应该 useCallback 或放进 actions)

十二、面试延伸

  1. 「Context 为什么会全量重渲染?」
    因为 Context 的订阅粒度是整个 value——Provider 用 Object.is 比较新旧 value,一旦不等就通知所有订阅该 Context 的组件重新渲染,不管它们只用到 value 的哪一部分。而且这个通知走的是「Context 传播」路径(React 内部维护订阅者列表并逐个通知),不经过 props 比较,因此 React.memo 无法拦截。这两点合起来,导致「value 里放高频数据」时性能很差。

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

  3. 「为什么 React.memo 拦不住 Context 的更新?」
    因为 React 有两条不同的更新路径:① props 更新——父组件重渲染 → 创建新元素 → 比较 props(React.memo 在这条路上生效);② Context 更新——Provider 的 value 变化 → React 直接通知订阅者列表里的组件(这些组件通过 useContext 注册),不经过 props 比较。所以 React.memo 没有机会介入——即使组件被 memo 包裹,只要它订阅的 Context 变化了,它就会重渲染。

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

  5. 「拆分 Context 有哪些维度?」
    三个:① 按变化频率——把「低频」(用户、主题、权限)与「高频」(通知、进度、拖拽状态)分开,避免高频更新影响低频消费者;② 按使用方——「只有侧边栏用」的数据不要放进全局 Context;③ 按语义/领域——不同领域的数据(用户、购物车、UI 偏好)不要混在一个 Context 里。经验法则是「一个 Context 只服务一类用途、一群消费者」,并用「AppProviders 组件」收敛嵌套。

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

  7. 「children 透传为什么有效?前提是什么?」
    因为它利用「React Element 是不可变对象、身份由『谁创建』决定」这一事实:如果子树写在 Provider 内部,Provider 每次重渲染都会重新创建这个元素(→ 子树重渲染);如果子树由外层组件创建并作为 children 传入,那么 Provider 重渲染时 children 是同一个元素对象(因为外层没重渲染),React 发现引用相同就跳过整棵子树。前提是「创建 children 的那个外层组件本身是稳定的」——如果它也在频繁重渲染,children 一样会被重建。

  8. 「优化 Context 之前应该先做什么?」
    先用 React DevTools Profiler 确认「这个 Context 引起的重渲染是否真的是瓶颈」。因为:① 如果消费者只有两三个、或者 value 很少变化,优化收益很小,反而增加代码复杂度(多个 Provider、拆分逻辑);② 真正的瓶颈可能在别处(昂贵子组件、DOM 数量、网络)。性能优化必须基于测量——否则很容易「花大力气重构,指标没变」(这是最常见的过度优化)。

一句话速记

Context 全量重渲染的两层原因是「订阅粒度是整个 value」+「通知不走 props 比较,React.memo 拦不住」;优化五招按成本是「① useMemo 稳定 value(必做,只解决伪更新)→ ② 拆分 Context(按频率/使用方/语义)→ ③ 值与操作分离(ActionsContext 永不更新,收益明显)→ ④ children 透传(元素由外层创建,前提外层稳定)→ ⑤ 改用 Zustand/Jotai/use-context-selector 做选择器订阅(根本解法)」;别把所有状态塞一个 Context、别放高频数据、Provider 尽量放低、用 AppProviders 收敛嵌套;Zustand 的坑同源——选择器返回新对象会导致每次都重渲染(分开订阅或用 useShallow);优化前先用 Profiler 确认是瓶颈(避免过度优化)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 12.8 Context 为什么全量重渲染

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫