陌上人如玉
公子世无双

3.4 Context 的性能问题与拆分

3.4 Context 的性能问题与拆分

一句话:Context 的 value 一旦变化,所有消费者都会重渲染(React.memo 拦不住)——解法优先级是「拆分 Context → useMemo 稳定 value → 值与操作分离 → children 透传 → 改用带选择器的状态库」。

一、问题复现

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

function Consumer() {
  const ctx = useContext(Ctx);
  console.log('Consumer 渲染');
  return 
{ctx?.a}
; // 只用到 a } function Provider() { const [a, setA] = useState(0); const [b, setB] = useState(0); console.log('Provider 渲染'); return ( ); }

现象:点击「改 b」时,Consumer 也会重新渲染——尽管它只用到 a。

原因有三层:

① Provider 的 value 是「对象字面量」→ 每次渲染都是新对象
   → React 用 Object.is 比较,认为 value 变了 → 通知所有消费者

② 即使 value 用 useMemo 稳定了,只要【value 里的任何一部分】变化,
   React 也会通知所有消费者(订阅粒度是整个 value)

③ 这个通知【不经过 props 比较】→ React.memo 无法拦截
   (Context 的传播是「直接通知订阅者」,与 props 更新是两条路径)

二、解法一: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 ...;
}
解决了什么:消除了「内容没变但引用变了」导致的【伪更新】
没解决什么:a / b 真的变化时,依然会通知【所有】消费者

所以这一步是「必要但不够」——它保证了「不该更新的不更新」,
真正想「只通知用到该字段的组件」还需要拆分或换方案。

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

// ✅ 按「数据的变化频率」拆分
const UserContext = createContext(null);        // 很少变(登录时)
const ThemeContext = createContext('light');          // 很少变(切主题时)
const NotificationsContext = createContext([]);   // 经常变

function App() {
  return (
    
      
        
          
        
      
    
  );
}

// 组件只订阅自己需要的
function Avatar() {
  const user = useContext(UserContext);        // ⭐ 只订阅 User
  return ;
}

function ToastList() {
  const items = useContext(NotificationsContext);   // ⭐ 只订阅 Notifications
  return <>{items.map((n) => )};
}
// → notifications 变化不会让 Avatar 重渲染
拆分的三个维度:

① 按「变化频率」拆
   低频(用户/主题/权限)与高频(通知/进度/拖拽状态)分开

② 按「使用方」拆
   只有侧边栏用的数据,不要放进「全局 Context」

③ 按「语义」拆
   不同领域的数据不要混在一个 Context 里

四、解法三:值与操作分离(很有效)

// ✅ 把「会变的值」与「稳定的操作」拆成两个 Context
const StateContext = createContext(null);       // 会变
const ActionsContext = createContext(null);   // 引用稳定,永不更新

function Provider({ children }: { children: ReactNode }) {
  const [state, setState] = useState(initialState);

  // ⭐ dispatch 或 handlers 引用稳定 → ActionsContext 永不更新
  const actions = useMemo(
    () => ({
      increment: () => setState((s) => ({ ...s, count: s.count + 1 })),
      reset: () => setState(initialState),
    }),
    []                                  // 空依赖 → 永不变化
  );

  return (
    
      
        {children}
      
    
  );
}

// 只需要「改数据」的组件(如提交按钮)永不因 state 变化而重渲染
function IncrementButton() {
  const { increment } = useContext(ActionsContext)!;
  console.log('IncrementButton 渲染');       // 只在挂载时渲染一次
  return ;
}

// 只有需要「看数据」的组件才订阅 StateContext
function CountDisplay() {
  const state = useContext(StateContext)!;
  return {state.count};
}
方案 收益
值/操作分离 只有 dispatch/actions 消费方永不重渲染(实际项目里这类组件很多)
用 useReducer 的 dispatch dispatch 天然稳定,可以直接放进「永不更新」的 Context
// 用 useReducer 的更简洁版本
function Provider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
          {/* dispatch 稳定 → 永不更新 */}
      {children}
    
  );
}

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

// ❌ Provider 内部直接写会重渲染的子树
function Provider() {
  const [state, setState] = useState(initialState);
  return (
    
                        {/* ⚠️ state 变化 → 它会重建元素 → 重渲染 */}
    
  );
}

// ✅ 用 children 透传(元素由「外部」创建,Provider 重渲染时它不受影响)
function Provider({ children }: { children: ReactNode }) {
  const [state, setState] = useState(initialState);
  return {children};
}

function App() {
  return (
    
                        {/* ⭐ 这个元素在 App 里创建,Provider 重渲染不会重建它 */}
    
  );
}
为什么有效?

① 元素(React Element)是【不可变对象】,由「谁创建」决定它的身份
② 在 App 里创建的  元素,每次 App 渲染时引用可能相同
   (因为 App 没重渲染 → 这个元素对象没变)
③ Provider 重渲染时,children 是「外部传进来的同一个元素对象」
   → React 发现元素没变 → 跳过它的子树渲染

⚠️ 前提:App 本身没有重渲染(否则 children 也会重建)

六、解法五:改用带选择器的状态库

// Zustand:订阅时可以用选择器,只订阅需要的那一部分
import { create } from 'zustand';

const useStore = create<{ a: number; b: number; setA: (v: number) => void }>((set) => ({
  a: 0,
  b: 0,
  setA: (a) => set({ a }),
}));

function Consumer() {
  const a = useStore((s) => s.a);       // ⭐ 只订阅 a
  console.log('Consumer 渲染');          // b 变化时不会渲染
  return 
{a}
; }
// 或用 use-context-selector(保留 Context 的用法,但支持选择器)
import { createContext, useContextSelector } from 'use-context-selector';

const Ctx = createContext<{ a: number; b: number }>({ a: 0, b: 0 });

function Consumer() {
  const a = useContextSelector(Ctx, (v) => v.a);   // ⭐ 只在 a 变化时重渲染
  return 
{a}
; }
方案 是否支持选择器 代价
原生 Context ❌ —
use-context-selector ✅ 需要替换 createContext/useContext
Zustand / Jotai / Redux ✅ 引入库,但这也是主流做法
useSyncExternalStore + 自研选择器 ✅ 自己实现(见 2.11 的选择器缓存)

七、方案选择与组合

决策顺序(从低成本到高成本):

① 先看「是不是真的有问题」
   → 用 Profiler 确认这个 Context 引起的重渲染是否真的是瓶颈
   → 如果消费者很少、或 value 很少变化 → 可能什么都不用做

② 加 useMemo 稳定 value
   → 解决「伪更新」(内容没变但引用变了),成本几乎为零

③ 拆分 Context(按变化频率/使用方/语义)
   → 最常用的有效手段,改造成本中等

④ 值与操作分离
   → 特别适合「表单」「编辑器」这类「操作多、数据也多变」的场景
   → 收益明显(很多组件只需要操作)

⑤ children 透传
   → 适合「Provider 包着大树,但树本身不依赖 Context」的结构

⑥ 改用状态库(Zustand/Jotai)或 use-context-selector
   → 当「确实需要细粒度订阅」时的根本解法
// 一个综合示例:把上面几招用在一起
const StoreStateContext = createContext(null);
const StoreActionsContext = createContext(null);

function StoreProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(rootReducer, initialState);

  // ⭐ 操作集合用 useMemo(引用稳定 → ActionsContext 永不更新)
  const actions = useMemo(
    () => ({
      addTodo: (text: string) => dispatch({ type: 'addTodo', text }),
      toggleTodo: (id: string) => dispatch({ type: 'toggleTodo', id }),
      removeTodo: (id: string) => dispatch({ type: 'removeTodo', id }),
    }),
    []
  );

  return (
    
      
        {children}          {/* ⭐ children 透传,减少无关重渲染 */}
      
    
  );
}

export function useStoreState() {
  const s = useContext(StoreStateContext);
  if (!s) throw new Error('useStoreState 必须在 StoreProvider 内使用');
  return s;
}

export function useStoreActions() {
  const a = useContext(StoreActionsContext);
  if (!a) throw new Error('useStoreActions 必须在 StoreProvider 内使用');
  return a;               // ⭐ 引用稳定 → 消费方不会因 state 变化重渲染
}

八、常见坑

// ① value 是对象字面量(最常见的「伪更新」来源)
            // ❌ 每次渲染都是新对象

// ② 以为 React.memo 能阻止 Context 引起的重渲染
const Consumer = React.memo(function Consumer() {
  const { a } = useContext(Ctx);           // ⚠️ memo 无效
});

// ③ 把所有状态塞进一个 Context
const AppCtx = createContext({ user, theme, cart, notifications, modal, ... });
// → 任何一部分变化都让所有消费者重渲染

// ④ 把「高频变化的值」放进 Context(鼠标位置、输入值、进度)
// ⑤ 把「只需要 dispatch」的组件也订阅了 state
// ✅ 用「值/操作分离」解决

// ⑥ useMemo 的依赖写错(漏了字段 → value 不更新;多写了对象 → 依然每次都变)
const value = useMemo(() => ({ a, b }), [a]);              // ❌ 漏了 b
const value2 = useMemo(() => ({ a }), [a, { extra: 1 }]);  // ❌ 依赖不稳定

// ⑦ Provider 放在「不需要的层级」
// ✅ 尽量把 Provider 放低(只包住真正需要的子树)

// ⑧ children 透传但 App 本身也在重渲染 → 无效
// ⑨ 拆分过多 Context 导致嵌套地狱(可读性下降)
//    ✅ 用「一个 Provider 组件包多个 Context」的方式收敛
// ⑩ 用 Context 传「函数」但每次都是新的(应该 useCallback 或放进 actions)
// ⑪ 认为「拆分了 Context 就万事大吉」(还要注意 useMemo 与消费者结构)
// ⑫ 没有先用 Profiler 确认瓶颈就大改架构(可能过度优化)
// ⑨ 的解法:多个 Context 用一个 Provider 组件包起来
function AppProviders({ children }: { children: ReactNode }) {
  return (
    
      
        
          {children}
        
      
    
  );
}

// 使用

  
;

九、面试延伸

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

  2. 「怎么优化 Context 的重渲染?」
    五种手段,按成本从低到高:① useMemo 稳定 value(消除「内容没变但引用变了」的伪更新);② 拆分 Context(按变化频率/使用方/语义拆,最常用);③ 值与操作分离(StateContext 会变、ActionsContext 永不更新,只有操作的组件永不重渲染);④ children 透传(把不依赖 Context 的子树作为 children 传入,Provider 重渲染不会重建它们);⑤ 改用带选择器的方案(Zustand / Jotai / use-context-selector),这是「细粒度订阅」的根本解法。

  3. 「React.memo 能阻止 Context 引起的重渲染吗?」
    不能。React.memo 只比较 props;而 Context 的更新是「直接通知消费者组件」的(React 内部维护了 context._currentValue 与订阅者列表),不经过 props 的比较环节。所以即使组件被 memo 包裹,只要它订阅的 Context 的 value 变化了,它仍会重渲染。这也是「Context 性能优化」需要靠「拆分/分离/换方案」而不是靠 memo 的原因。

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

  5. 「children 透传为什么能减少重渲染?」
    因为「元素对象由谁创建,决定了它的身份」。如果把 <ExpensiveTree /> 写在 Provider 内部,Provider 每次重渲染都会重新创建这个元素,从而让它重渲染;如果改成「在父组件里创建、作为 children 传进去」,那么 Provider 重渲染时 children 是同一个元素对象(因为父组件没重渲染、元素没重建),React 发现元素没变就跳过它的子树。这也是 React 官方文档里推荐的一个模式。

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

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

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

一句话速记

Context 的 value 一变,所有消费者都重渲染,且 React.memo 拦不住(订阅粒度是整个 value,传播不走 props);优化优先级是「先 Profiler 确认是瓶颈 → useMemo 稳定 value → 拆分 Context(按频率/使用方/语义)→ 值与操作分离(ActionsContext 永不更新)→ children 透传 → 改用带选择器的 Zustand/Jotai/use-context-selector」;别把所有状态塞进一个 Context、别把高频数据放进 Context、Provider 尽量放低;没有测量的大改造很可能是过度优化。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.4 Context 的性能问题与拆分

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫