第 9 章

useContext:依赖注入与性能陷阱

第9章:useContext:依赖注入与性能陷阱

React Context 是依赖注入机制。它让你把值'广播'到组件树的任意深度,但它的广播本质也意味着性能上的隐患。

本章核心问题:Context 的性能特性是什么?为什么所有消费者都会重渲染? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Context 是依赖注入

在软件工程中,**依赖注入(Dependency Injection)**是一种模式:不是让组件自己创建它需要的依赖,而是从外部"注入"进来。这样组件不需要知道依赖从哪里来,也不需要了解依赖的创建细节。

React Context 就是一种依赖注入机制。它让你可以把值"广播"到组件树的任意深度,而不需要通过每一层手动传递 prop(俗称"prop drilling")。

// 创建 Context
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);

// Provider:向子树注入值
function App() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [user, setUser] = useState<User | null>(null);

  return (
    <ThemeContext.Provider value={theme}>
      <UserContext.Provider value={user}>
        <Layout />
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}

// 任意深度的消费者
function Avatar() {
  const theme = useContext(ThemeContext);
  const user = useContext(UserContext);

  return (
    <img
      className={`avatar avatar-${theme}`}
      src={user?.avatarUrl}
      alt={user?.name}
    />
  );
}

Context 特别适合以下类型的数据:

所有消费者都会重渲染:Context 的性能特性

这是 Context 最重要也最容易被忽视的特性:每当 Provider 的 value 发生变化,所有使用 useContext 消费该 Context 的组件都会重渲染,无论这个组件实际上有没有用到变化的部分。

const AppContext = createContext(null);

function App() {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);

  // 每当 notifications 更新,user 没有变,
  // 但所有消费 AppContext 的组件都会重渲染
  const value = { user, notifications, setUser, setNotifications };

  return (
    <AppContext.Provider value={value}>
      <Header />       {/* 只用了 user,但也会因 notifications 变化重渲染 */}
      <NotificationBell /> {/* 只用了 notifications,正确重渲染 */}
      <MainContent />  {/* 两者都没用,但也会重渲染 */}
    </AppContext.Provider>
  );
}

为什么会这样?因为 React 没有办法知道 Header 只关心 user 而不关心 notifications——Context 是整体传递的,消费者要么订阅整个 Context 对象,要么不订阅。

问题的根源:value 对象每次都是新引用

// 错误示范:每次渲染都创建新对象,导致所有消费者每次都重渲染
<AppContext.Provider value={{ user, notifications }}>

即使 usernotifications 没有变,{ user, notifications } 这个对象字面量每次渲染都是一个新引用,Context 检测到值变化,所有消费者重渲染。

// 部分缓解:用 useMemo 稳定 value 对象
const value = useMemo(() => ({ user, notifications }), [user, notifications]);
<AppContext.Provider value={value}>

但这只解决了"无意义的重渲染"问题,没有解决"notifications 变化导致只用 user 的组件也重渲染"的问题。

Context vs 其他状态共享方式

Context vs Prop Drilling

Prop drilling 是指通过多层组件传递 prop 的模式。它有一个常被忽视的优点:数据流明确,你可以通过追踪 prop 轻松知道数据从哪里来、传到哪里去。

什么时候 prop drilling 比 Context 更好:

// 2 层传递:prop drilling 完全可以接受,不需要 Context
function App() {
  const user = useCurrentUser();
  return <Layout user={user} />;
}

function Layout({ user }) {
  return <Header user={user} />;
}

function Header({ user }) {
  return <span>{user.name}</span>;
}

Context vs 外部状态管理库

外部状态管理库(Zustand、Jotai、Redux Toolkit)与 Context 的根本区别在于性能模型

// Zustand:精确订阅,只有 user 变化时重渲染
const useStore = create(set => ({
  user: null,
  notifications: [],
  setUser: user => set({ user }),
  addNotification: n => set(s => ({ notifications: [...s.notifications, n] })),
}));

function Header() {
  // 只订阅 user,notifications 变化不触发重渲染
  const user = useStore(state => state.user);
  return <nav>{user?.name}</nav>;
}

选择建议


Level 2 · 它是怎么运行的(3-5年经验)

一个完整的 Context 架构示例

综合本章的最佳实践,构建一个生产级别的认证 Context:

type User = { id: string; name: string; role: 'admin' | 'user' };
type AuthState = { user: User | null; isLoading: boolean };

// 读写分离
const AuthStateContext = createContext<AuthState>({ user: null, isLoading: true });
const AuthActionsContext = createContext<{
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}>({ login: async () => {}, logout: () => {} });

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<AuthState>({ user: null, isLoading: true });

  useEffect(() => {
    // 初始化时检查已有 session
    checkSession()
      .then(user => setState({ user, isLoading: false }))
      .catch(() => setState({ user: null, isLoading: false }));
  }, []);

  // 使用 useMemo 确保 actions 引用稳定(不因 state 变化而重创建)
  const actions = useMemo(() => ({
    async login(credentials: Credentials) {
      const user = await authenticate(credentials);
      setState({ user, isLoading: false });
    },
    logout() {
      clearSession();
      setState({ user: null, isLoading: false });
    },
  }), []); // actions 不依赖 state,引用永远稳定

  return (
    <AuthActionsContext.Provider value={actions}>
      <AuthStateContext.Provider value={state}>
        {children}
      </AuthStateContext.Provider>
    </AuthActionsContext.Provider>
  );
}

// 封装为自定义 Hook,提供更好的 DX 和类型安全
function useAuthState() {
  return useContext(AuthStateContext);
}

function useAuthActions() {
  return useContext(AuthActionsContext);
}

// LoginButton 只订阅 actions,不会因为 user 变化而重渲染
function LoginButton() {
  const { login } = useAuthActions();
  return <button onClick={() => login({ email: '', password: '' })}>登录</button>;
}

// UserGreeting 只订阅 state
function UserGreeting() {
  const { user, isLoading } = useAuthState();
  if (isLoading) return <Skeleton />;
  if (!user) return null;
  return <span>你好,{user.name}</span>;
}

Level 3 · 规范怎么定义的(资深)

React 19:use() 消费 Context

React 19 引入的 use() Hook 可以在任何地方消费 Context,包括条件语句内(这是传统 Hooks 做不到的):

import { use } from 'react';

function Greeting({ isLoggedIn }: { isLoggedIn: boolean }) {
  // use() 可以在条件语句内使用——这是 useContext 做不到的
  if (isLoggedIn) {
    const user = use(UserContext);
    return <h1>欢迎,{user.name}</h1>;
  }
  return <h1>请先登录</h1>;
}

use() vs useContext() 的对比

特性 useContext use()
可在条件语句中用
可消费 Promise
触发 Suspense 是(消费 Promise 时)
语义 只读 Context 读 Context 或 Promise

use() 消费 Context 时行为与 useContext 一致——同样的重渲染语义。它的优势在于灵活性(条件消费)和统一性(同一个 API 消费 Context 和 Promise)。


Level 4 · 边界与陷阱(所有人)

解决 Context 性能问题的策略

策略一:拆分 Context

最简单也最有效的方案:把频繁变化和不常变化的数据分到不同的 Context 里。

// 分离:频率不同的数据用不同 Context
const UserContext = createContext<User | null>(null);
const NotificationsContext = createContext<Notification[]>([]);
const ThemeContext = createContext<Theme>('light');

function App() {
  const [user] = useState<User | null>(null);
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [theme] = useState<Theme>('light');

  return (
    <ThemeContext.Provider value={theme}>
      <UserContext.Provider value={user}>
        <NotificationsContext.Provider value={notifications}>
          <AppLayout />
        </NotificationsContext.Provider>
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}

// Header 只订阅 UserContext,notifications 变化不影响它
function Header() {
  const user = useContext(UserContext);
  return <nav>{user?.name}</nav>;
}

策略二:进一步拆分读写 Context

一种常见的 Context 设计模式是把状态更新函数分开放在两个 Context 里。因为更新函数(如果用 useCallback 或者传递 dispatch)是稳定的引用,不会触发消费者重渲染:

const CountContext = createContext<number>(0);
const CountDispatchContext = createContext<React.Dispatch<number>>(() => {});

function CountProvider({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);

  return (
    <CountDispatchContext.Provider value={setCount}>
      <CountContext.Provider value={count}>
        {children}
      </CountContext.Provider>
    </CountDispatchContext.Provider>
  );
}

// 这个组件只订阅 dispatch,count 变化不会让它重渲染
function ResetButton() {
  const setCount = useContext(CountDispatchContext);
  return <button onClick={() => setCount(0)}>重置</button>;
}

// 这个组件只订阅 count
function CountDisplay() {
  const count = useContext(CountContext);
  return <span>{count}</span>;
}

策略三:Context Selector 模式

有时你确实需要一个大的 Context,但只想在某个特定字段变化时重渲染。React 原生不支持 context selector,但可以用第三方库或者手动实现:

// use-context-selector 库实现 selector
import { createContext, useContextSelector } from 'use-context-selector';

const AppContext = createContext({ user: null, notifications: [] });

// 只有当 user 变化时才重渲染,notifications 变化不影响
function Header() {
  const user = useContextSelector(AppContext, ctx => ctx.user);
  return <nav>{user?.name}</nav>;
}

手动实现的折中方案:用 React.memo 包裹组件,并把需要的值从 Context 中提取出来作为 prop:

function Header() {
  const { user } = useContext(AppContext);
  return <HeaderInner user={user} />;
}

// HeaderInner 只有当 user 变化时才重渲染
const HeaderInner = React.memo(function HeaderInner({ user }) {
  return <nav>{user?.name}</nav>;
});

这个方案的局限:Header 本身还是会因为任何 Context 变化而重渲染,只是 HeaderInner 的渲染被跳过了——如果 Header 的渲染本身开销不大,这是可以接受的。

什么时候不应该用 Context

Context 常被误用为"全局变量的 React 替代品"。以下场景用 Context 是错误的:

错误一:服务端状态(Server State)

网络请求的数据、缓存、实时更新——这些不适合 Context,应该用专门的数据获取库(React Query、SWR):

// 错误:用 Context 管理服务端状态
const UserDataContext = createContext(null);
function UserDataProvider({ children }) {
  const [userData, setUserData] = useState(null);
  useEffect(() => {
    fetch('/api/me').then(r => r.json()).then(setUserData);
  }, []);
  // 没有缓存、没有重试、没有失效策略
  return <UserDataContext.Provider value={userData}>{children}</UserDataContext.Provider>;
}

// 正确:用 React Query
function useCurrentUser() {
  return useQuery({ queryKey: ['me'], queryFn: () => fetch('/api/me').then(r => r.json()) });
}

错误二:表单状态

表单的字段值是局部状态,不需要 Context。即使表单很复杂,也应该用专门的表单库(React Hook Form、Formik)。

错误三:高频更新的状态

如果状态每秒更新多次(鼠标位置、动画进度),Context 不适合——每次更新会重渲染所有消费者。应该用 ref + 命令式更新,或者外部状态库的精确订阅。

本章评分
4.7  / 5  (38 评分)

💬 留言讨论