React useContext 性能瓶颈:利用选择器模式(Selector)抑制不相关的组件重渲染

React Context 性能瓶颈:如何用“选择器模式”拯救你的 CPU

大家好,我是你们的老朋友,那个总是能在 React 渲染循环里找到 Bug 的“老司机”。

今天我们要聊的话题,是很多 React 开发者——尤其是刚从 Vue 跳槽过来的,或者刚开始用 Redux 觉得它太重想搞点轻量级方案的“极客”们——最迷恋,也最头疼的东西:Context

大家常说:“Context 是 React 的圣杯,它解决了跨组件传参的痛苦。” 没错,Context 就像是一个巨大的广播站。你往里面扔一个消息,全世界的组件都能听到。

但是,这个“圣杯”有时候也会变成“潘多拉魔盒”。如果你不小心,你的应用就会变成一个只会疯狂重绘的“抽风机”。

今天,我们就来一场手术刀式的解剖,看看如何利用选择器模式,给这个巨大的广播站装上过滤器,让它只把你想听的内容传给听众,而不是把噪音丢进你的组件里。


第一部分:欢迎来到“重绘地狱”

首先,让我们看看一个典型的、没有任何优化意识的 Context 使用场景。

假设我们在做一个电商 App。为了方便,我们把用户信息购物车数据全部塞进了一个 AppContext 里。这很方便,对吧?不用一层层往下传 usercart 了。

// AppContext.js
import { createContext, useState, useContext } from 'react';

const AppContext = createContext();

export const AppProvider = ({ children }) => {
  // 假设这个 Context 包含了所有全局数据
  const [user, setUser] = useState({ name: "张三", id: 101, level: "VIP" });
  const [theme, setTheme] = useState({ color: "blue", fontSize: 16 });
  const [cartCount, setCartCount] = useState(0);
  const [settings, setSettings] = useState({ notifications: true, darkMode: false });

  return (
    <AppContext.Provider value={{ user, theme, cartCount, settings, setUser, setTheme, setCartCount, setSettings }}>
      {children}
    </AppContext.Provider>
  );
};

// 一个只关心用户名的组件
const UserProfile = () => {
  const context = useContext(AppContext);
  // 噢,这里我们只用了 context.user.name,但整个 context 对象都被解构出来了
  console.log("UserProfile 渲染了!"); // 每次父组件更新,这里都会打印
  return <div>你好,{context.user.name}</div>;
};

// 一个只关心购物车数量的组件
const CartBadge = () => {
  const context = useContext(AppContext);
  // 这里只用了 cartCount
  console.log("CartBadge 渲染了!"); // 每次父组件更新,这里都会打印
  return <div>购物车: {context.cartCount}</div>;
};

// 一个设置组件,包含了暗黑模式开关
const SettingsPanel = () => {
  const context = useContext(AppContext);
  // 这里修改了 theme
  const toggleTheme = () => {
    context.setTheme({ color: "red", fontSize: 16 }); // 改个颜色
  };
  return (
    <div>
      <button onClick={toggleTheme}>切换主题色</button>
      <p>当前主题: {context.theme.color}</p>
    </div>
  );
};

// 主应用
export default function App() {
  return (
    <AppProvider>
      <div>
        <UserProfile />
        <CartBadge />
        <SettingsPanel />
      </div>
    </AppProvider>
  );
};

发生了什么?

当你点击 SettingsPanel 里的按钮,toggleTheme 函数被触发,theme 对象被修改,React 发现 Context 的 value 变了。

于是,React 像一个尽职的邮递员,拿着新的 Context 数据,敲响了每一扇门:

  1. UserProfile:“喂!我也要重新渲染!虽然我只关心名字,但新的主题色我也得看一眼,万一呢?”
  2. CartBadge:“喂!我也要重新渲染!虽然我关心数量,但新的主题色我也得看一眼,万一呢?”
  3. SettingsPanel:“喂!我也要重新渲染!这是理所当然的。”

这就是“重绘地狱”

如果你的应用里有 50 个组件订阅了这个 Context,哪怕只是改了一个无关紧要的设置,这 50 个组件都会重新计算 Virtual DOM,重新比对,甚至重新执行 Effect。如果这些组件里还有昂贵的计算(比如大列表渲染、复杂的图表),那你的应用瞬间就会卡顿,用户的 CPU 会在几秒钟内烧干。

这就像什么?
这就好比你在学校食堂大喊一声:“我要吃红烧肉!”
结果全校几百个学生都停下了手里的饭碗,齐刷刷地看着你,因为他们都订阅了这个“红烧肉广播站”。哪怕他们其实只想吃米饭。这效率,低得令人发指。


第二部分:选择器模式——给广播站装上过滤器

那么,有没有办法让食堂阿姨(React)只把“红烧肉”的消息告诉想吃红烧肉的学生,而把“米饭”的消息屏蔽掉?

有,这就是我们要讲的选择器模式

选择器的核心思想非常简单:不要把整个 Context 的值传给组件,而是只把组件需要的那一小部分“切”出来。

在 Redux 中,我们习惯了 useSelector(state => state.user)。在 React Context 里,我们也需要这种能力。

2.1 手写一个简易的 useContextSelector

React 官方的 useContext hook 并没有内置选择器功能。它非常“憨厚”,给什么就吃什么。所以,我们需要自己动手,丰衣足食。

我们可以利用 useMemo 来实现一个简单的选择器 Hook。原理是:只有当 Context 的值发生变化,并且选择器函数计算出的结果发生变化时,组件才重新渲染。

import { useContext, useMemo, useEffect, useRef } from 'react';

// 1. 定义 Context
const AppContext = createContext();

// 2. 定义我们的选择器 Hook
const useContextSelector = (Context, selector) => {
  const contextValue = useContext(Context);

  // 使用 useRef 来保存上一次计算的结果,避免每次渲染都重新计算
  const selectorRef = useRef(selector);
  selectorRef.current = selector;

  // 使用 useMemo 来缓存选择后的结果
  // 依赖项是 Context 的当前值
  const selectedValue = useMemo(() => {
    return selectorRef.current(contextValue);
  }, [contextValue]);

  return selectedValue;
};

// 3. 组件中使用
const UserProfile = () => {
  // 这里只请求了 user 对象
  const user = useContextSelector(AppContext, state => state.user);

  // 注意:即使 Theme 改变了,这里也不会重新渲染!
  console.log("UserProfile 渲染了?"); // 只有 user 变了才会打印
  return <div>你好,{user.name}</div>;
};

const CartBadge = () => {
  const cartCount = useContextSelector(AppContext, state => state.cartCount);

  console.log("CartBadge 渲染了?"); // 只有 cartCount 变了才会打印
  return <div>购物车: {cartCount}</div>;
};

const SettingsPanel = () => {
  const context = useContext(AppContext);

  const toggleTheme = () => {
    // 修改的是整个 context
    context.setTheme({ color: "red", fontSize: 16 });
  };

  // 这里我们依然需要获取整个 context,因为我们要修改它
  return (
    <div>
      <button onClick={toggleTheme}>切换主题色</button>
    </div>
  );
};

// Provider 保持不变
export default function App() {
  const [state, setState] = useState({ user: { name: "张三", id: 101 }, theme: { color: "blue" }, cartCount: 0 });

  return (
    <AppContext.Provider value={state}>
      <div>
        <UserProfile />
        <CartBadge />
        <SettingsPanel />
      </div>
    </AppContext.Provider>
  );
};

看懂了吗?

现在,当你点击 SettingsPanel 切换主题时:

  1. React 检测到 Context 的 value 变了。
  2. React 调用 UserProfile 的选择器:state => state.user。结果还是 { name: "张三", id: 101 }
  3. 因为结果没变,React 懒得重新渲染 UserProfile。它甚至懒得去比对 Virtual DOM。
  4. React 调用 CartBadge 的选择器:state => state.cartCount。结果还是 0
  5. CartBadge 也不渲染。
  6. SettingsPanel 渲染。

结果: 只有 SettingsPanel 重新渲染了。CPU 得救了,用户的手指不抖了。


第三部分:陷阱与玄学——引用相等性的噩梦

虽然 useContextSelector 看起来很美,但在实际开发中,我们往往会遇到一个令人抓狂的问题:明明数据没变,组件却还是重绘了。

为什么?因为 JavaScript 对象的引用相等性

还记得我们在第一部分代码里写的 toggleTheme 吗?

const toggleTheme = () => {
  context.setTheme({ color: "red", fontSize: 16 }); 
};

注意看!每次点击按钮,我们都在创建一个全新的对象 { color: "red", fontSize: 16 }

在 React 的世界里,{ a: 1 } !== { a: 1 }。这两个对象长得一模一样,但在内存里,它们是两个完全不同的陌生人。

这就导致了以下情况:

  1. SettingsPanel 点击按钮,创建了新对象,Context 的 value 引用变了。
  2. UserProfile 的选择器 state => state.user 执行。state.user 还是 { name: "张三", id: 101 }。结果没变。
  3. UserProfile 不渲染。

这看起来没问题,对吧? 如果我们只是用 useContextSelector,确实没问题。

但是,如果我们用 useMemo 来缓存组件内部的数据呢?

const UserProfile = () => {
  const user = useContextSelector(AppContext, state => state.user);

  // 我们把 user 放进了 useMemo
  const formattedUser = useMemo(() => {
    console.log("计算格式化用户数据...");
    return { fullName: user.name, ... };
  }, [user]); // 依赖项是 user

  return <div>...</div>;
};

问题来了!
虽然 user 对象本身(引用)没变,但是 React 可能会认为 user 对象的“属性”变了,或者 useMemo 的依赖项检查机制比较激进。
更常见的情况是,如果我们没有正确使用 useMemo,直接在组件内部解构:

const UserProfile = () => {
  const user = useContextSelector(AppContext, state => state.user);
  const { name } = user; // 这里解构

  // 如果 user 对象的引用没变,但 name 的值没变,通常不会触发重渲染
  // 除非... 你在 selector 里做了什么奇怪的事情
};

真正的坑在哪里?

坑在于,如果你在 Selector 函数本身使用了 useMemo 或者依赖项不正确,或者,更常见的是,你把 Selector 的结果当作依赖项传给了 useEffectuseCallback,而 React 的依赖检查机制非常严格。

但是,最大的坑其实是“浅比较”的局限性。

假设 Context 的值是一个数组:

const [tags, setTags] = useState(["react", "js", "hooks"]);

组件里:

const tags = useContextSelector(AppContext, state => state.tags);

当你修改数组时,比如 setTags(prev => [...prev, "new-tag"])

  1. Context 的值变了(数组引用变了)。
  2. Selector 返回了新数组。
  3. 组件重绘。

这看起来没问题。但如果 Selector 返回的是一个新创建的对象,即使里面的数据没变,组件也会重绘。

// 错误示范:Selector 每次都返回新对象
const UserProfile = () => {
  const user = useContextSelector(AppContext, state => {
    // 即使 state.user 没变,这里每次都返回一个新对象!
    return { ...state.user }; 
  });
  // 这会导致 UserProfile 每次都重绘
};

解决方案:
我们需要一个深度比较的 Selector。

我们可以利用 useMemo 的第二个参数(比较函数)来实现深度比较,或者使用第三方库如 lodash.isEqual

import { isEqual } from 'lodash';

const selectedValue = useMemo(() => {
  const next = selector(contextValue);
  const prev = selectorRef.current;
  // 只有当值真正改变时才返回
  return isEqual(next, prev) ? prev : next;
}, [contextValue]);

或者更高级一点,直接让 React 帮我们做这个检查。虽然标准 API 没有直接支持,但我们可以利用 useContext 的特性结合 useReducer 来实现。


第四部分:终极方案——基于 Reducer 的 Context 订阅

如果你觉得上面手写的 useContextSelector 太脆弱,或者你不想引入 lodash,我们可以利用 React 的 useReduceruseEffect 来构建一个更健壮的选择器。

这个方案的逻辑是:我们不直接读取 Context,而是让 Context 去通知我们,只有当我们要的数据真正改变时,我们才更新状态。

这有点像 Redux 的做法。

import { useContext, useReducer, useEffect, useRef } from 'react';

// 定义 Context
const AppContext = createContext();

// 定义 reducer,用于处理订阅逻辑
const reducer = (state, action) => {
  if (action.type === 'UPDATE') {
    return {
      ...state,
      lastUpdate: action.payload,
      subscribers: action.subscribers
    };
  }
  return state;
};

// 创建 Provider
export const AppProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, {
    data: { user: { name: "张三" }, theme: "blue" },
    subscribers: new Map() // 存储订阅者和选择器函数
  });

  const updateData = (newData) => {
    // 更新数据
    const newState = { ...state, data: newData };
    dispatch({ type: 'UPDATE', payload: newState });

    // 遍历所有订阅者,通知他们
    state.subscribers.forEach((selector, id) => {
      const selected = selector(newData);
      const prevSelected = selector(state.data); // 注意:这里简化了,实际应存储上一次值

      // 深度比较
      if (!isEqual(selected, prevSelected)) {
        // 触发订阅者的更新
        // 这里我们通过一个全局的事件总线或者直接调用 setState 来实现
        // 为了简单演示,我们假设订阅者有一个 update 方法
        selector.onUpdate(selected);
      }
    });
  };

  return (
    <AppContext.Provider value={{ data: state.data, updateData }}>
      {children}
    </AppContext.Provider>
  );
};

// 自定义 Hook
export const useAppSelector = (selector) => {
  const [selectedValue, setSelectedValue] = useState(null);
  const selectorRef = useRef(selector);
  selectorRef.current = selector;
  const idRef = useRef(Math.random().toString(36).substr(2, 9));

  useEffect(() => {
    // 注册订阅
    // 这里需要 Provider 把订阅器传进去,或者使用 Context 的订阅机制
    // 实际实现中,通常使用 Context 的 value 变化来触发

    // 简化版逻辑:
    // 每次 Provider 的 value 变化时,dispatch 会触发所有 selector 的计算
    // 我们在 Provider 里通过一个 ref 存储所有 selector

    return () => {
      // 清理订阅
    };
  }, []);

  // 绑定回调
  useEffect(() => {
    const handleUpdate = (value) => {
      setSelectedValue(value);
    };
    selectorRef.current.onUpdate = handleUpdate;
    return () => {
      selectorRef.current.onUpdate = null;
    };
  }, []);

  // 初始化
  useEffect(() => {
    // 这里需要从 Provider 获取当前的 data
    // 实际实现比较复杂,通常使用第三方库如 zustand 的 subscribe 机制
  }, []);

  return selectedValue;
};

(注:上面的代码是为了演示逻辑而写的伪代码,实际工程中,使用成熟的库如 ZustandJotai 会更简单。Zustand 的 useStore 默认就支持选择器,并且非常高效。)


第五部分:现代状态管理库的启示

既然我们聊到了性能和选择器,就不得不提一下现代状态管理库。为什么大家现在都不怎么用 Redux 了?

因为 Redux 的 useSelector 默认就是选择器模式!
当你写 useSelector(state => state.todos) 时,Redux 内部做了什么?它比较了上一次选中的值和这一次选中的值。如果一样,它甚至不会把 state 传给你的组件,更别说触发组件渲染了。

Zustand 更是把这个做到了极致。它的 useStore 就是基于订阅模式实现的,完全不需要 Provider 包裹,也没有 Context 重绘的全局广播问题。

import create from 'zustand';

const useStore = create((set) => ({
  user: { name: '张三' },
  theme: 'blue',
  updateUser: (name) => set((state) => ({ user: { ...state.user, name } })),
}));

// 组件 A
const UserProfile = () => {
  const user = useStore((state) => state.user); // 只订阅 user
  return <div>{user.name}</div>;
};

// 组件 B
const ThemeViewer = () => {
  const theme = useStore((state) => state.theme); // 只订阅 theme
  return <div>Theme: {theme}</div>;
};

// 组件 C
const Actions = () => {
  const updateUser = useStore((state) => state.updateUser);
  return <button onClick={() => updateUser('李四')}>改名</button>;
};

当你点击“改名”按钮,UserProfile 会重新渲染(因为 user 变了),但 ThemeViewer 不会。这就是选择器模式的威力。

回到 React Context:
如果你真的必须用 Context(比如为了配合现有的架构,或者为了共享 Context 的 type),那么不要把整个巨大的对象塞进去。

  1. 拆分 Context:UserContextThemeContext 分开。ThemeContext 改变只影响 ThemeViewer
  2. 使用选择器: 如果你必须共享一个大对象,就用我们上面讲的 useContextSelector

第六部分:深度剖析——为什么 React 18 没能完全解决这个问题?

很多同学会问:“React 18 不是有并发模式吗?不是有自动批处理吗?为什么 Context 还是很慢?”

这是一个非常好的问题。

并发模式确实解决了用户体验问题。它让重渲染变得“不可见”,不会阻塞 UI 线程。但是,重渲染依然在发生

想象一下,你有一个巨大的组件树,里面有 1000 个组件都订阅了一个 Context。

  1. React 17: Context 更新 -> 触发 1000 个组件重渲染 -> 1000 个 Virtual DOM Diff -> 1000 个 DOM 更新。CPU 100%。
  2. React 18 并发模式: Context 更新 -> 触发 1000 个组件重渲染 -> React 把这些渲染排队 -> 利用空闲时间慢慢跑 -> 用户感觉不到卡顿。

但是! 这 1000 个组件依然在消耗内存,依然在计算,依然在浪费电量。而且,如果你在这些组件里做了昂贵的计算(比如复杂的图表初始化、网络请求),并发模式也没法阻止它们运行。

选择器模式解决的是“源头”问题。 它从根子上减少了需要渲染的组件数量。


第七部分:实战演练——构建一个高性能的 Context

让我们来实战一下,写一个生产级别的、带深度比较的 Context Hook。

import { useContext, useMemo, useRef, useEffect } from 'react';
import { isEqual } from 'lodash-es'; // 或者手写一个简单的深度比较

// 1. 定义 Context
const AppContext = createContext();

// 2. 定义自定义 Hook
export const useAppSelector = (selector, equalityFn = isEqual) => {
  const context = useContext(AppContext);

  // 保存上一次计算的结果
  const lastSelectedValue = useRef();

  // 保存 selector 函数本身
  const selectorRef = useRef(selector);
  selectorRef.current = selector;

  // 使用 useMemo 计算当前选中的值
  // 关键点:我们只在 context.value 变化时重新计算
  const selectedValue = useMemo(() => {
    const nextValue = selectorRef.current(context.value);

    // 如果值没变(深度比较),返回旧值,不触发重渲染
    if (lastSelectedValue.current != null && equalityFn(lastSelectedValue.current, nextValue)) {
      return lastSelectedValue.current;
    }

    // 值变了,更新引用
    lastSelectedValue.current = nextValue;
    return nextValue;
  }, [context.value, equalityFn]);

  // 3. 处理副作用
  // 如果你需要在值变化时执行某些逻辑(比如副作用),可以使用 useEffect
  // 但要注意,这里依然是在 context.value 变化时触发,而不是在 selectedValue 变化时触发
  // 如果你想在 selectedValue 变化时触发,需要把 useEffect 放在组件内部,
  // 并依赖 selectedValue。但这会导致组件在 context.value 变化但 selectedValue 不变时依然重绘。

  // 所以,这个 Hook 的最佳实践是:
  // 如果组件有副作用,直接在组件里用 useEffect 监听 selectedValue。

  return selectedValue;
};

// 3. Provider 实现
export const AppProvider = ({ children }) => {
  const [value, setValue] = useState({ /* 初始数据 */ });

  return (
    <AppContext.Provider value={{ value, setValue }}>
      {children}
    </AppContext.Provider>
  );
};

使用示例

const HeavyComponent = () => {
  // 只订阅 user.name
  const userName = useAppSelector(state => state.user.name);

  // 只订阅 cart.items
  const cartItems = useAppSelector(state => state.cart.items);

  // 依赖项是 selectedValue
  useEffect(() => {
    console.log("数据变了,执行副作用:", userName);
  }, [userName]); // 只有 name 变了才会执行

  return <div>用户: {userName}</div>;
};

第八部分:总结与建议

好了,老司机今天的“驾驶课”就上到这里。让我们回顾一下重点。

  1. Context 不是银弹: 它是全局的,意味着它的影响也是全局的。不要把所有东西都塞进一个 Context 里,除非你想让整个应用都感冒。
  2. 重绘是昂贵的: 每一次不必要的重绘都在吞噬你的 CPU 和电量。在移动端,这甚至比流量更可怕。
  3. 选择器是解药: useSelector 模式是解决 Context 性能瓶颈的核心手段。它通过“按需订阅”,精准控制了组件的渲染范围。
  4. 引用陷阱: 小心对象引用。如果你在 Selector 里每次都解构或创建新对象,你就失去了选择器的意义。
  5. 工具比轮子好: 虽然我们可以手写 useContextSelector,但在现代开发中,使用像 ZustandJotai 或者 React Query 这样的库,它们内部已经实现了非常高效的选择器和订阅机制,通常比你自己写的 Context 更健壮、更简单。

最后的建议:

当你发现你的应用开始卡顿,或者你在控制台看到一串串红色的“Rendered too many times”警告时,不要慌。拿起你的“手术刀”——也就是选择器模式——去检查你的 Context 订阅。

把那个巨大的 Context 拆开,或者给每个组件加上精准的过滤器。让你的组件只渲染它们真正需要渲染的东西,让你的 CPU 只做它真正需要做的事情。

记住,优秀的代码不是写得最复杂的,而是让浏览器最轻松的。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注