Dark Dwarf Blog background

React Context

React Context

1. Context 的引入

在 React 中,数据流通常是自上而下的(通过 props)。但当应用规模变大,组件层级变深时,在组件之间传递数据,尤其是全局状态(如用户登录信息、UI 主题),会变得非常繁琐。一个典型的例子就是 Props 钻探,它有下面的问题:

  1. 代码冗余和可读性差:许多组件的 props 列表会变得臃肿,包含了大量与自身无关的属性,使得组件的真实依赖关系模糊不清。
  2. 重构困难:如果数据源的 props 名称或结构发生改变,所有中间传递路径上的组件都需要修改,维护成本极高。
  3. 过度渲染:某些情况下,不必要的 props 传递可能导致中间组件发生不必要的重渲染。

2. Context API

a.a. 基本概念

Context 提供了一种让子组件可以“订阅”全局数据的方式,而无需显式地通过 props 一层层传递。

使用 Context 的过程可以清晰地分为三个步骤:

  1. 创建 (Create): 使用 createContext 创建一个 Context 对象。
  2. 提供 (Provide): 使用 Context.Provider 组件在组件树中“提供”数据。
  3. 消费 (Consume): 在任何子组件中使用 useContext Hook 来“消费”或读取数据。

b.b. createContext

createContext 函数用于创建 Context。它接收一个默认值作为参数。这个默认值只有在组件没有被相应的 Provider 包裹时才会被使用。

// contexts/CartContext.js
import { createContext } from 'react';

// 提供一个有意义的默认值,有助于测试和 IDE 自动补全
export const CartContext = createContext({
  cartItemsCount: 0,
  setCartItemsCount: () => {}, // 空函数作为默认值,防止调用时出错
});

c.c. Provider

每个 Context 对象都有一个名为 Provider 的组件,用于提供数据。它接收一个 value 属性,所有嵌套在它内部的子组件(无论层级多深)都能访问到这个 value

// App.js
import { useState } from 'react';
import { CartContext } from './contexts/CartContext';
import Header from './Header';

function App() {
  const [cartItemsCount, setCartItemsCount] = useState(5);

  // 将 state 和更新函数作为 value 传递下去
  const providerValue = { cartItemsCount, setCartItemsCount };

  return (
    <CartContext.Provider value={providerValue}>
      {/* Header 及其所有子组件现在都能访问到 cartItemsCount */}
      <Header />
    </CartContext.Provider>
  );
}

d.d. useContext

useContext Hook 是在函数组件中订阅 Context 的最简单方式,它负责提供数据。它接收 Context 对象作为参数,并返回 Provider 提供的 value

// Links.js (Header 的子组件)
import { useContext } from 'react';
import { CartContext } from './contexts/CartContext';

function Links() {
  // 直接从 Context 中获取数据,不再需要 props
  const { cartItemsCount } = useContext(CartContext);

  return <div>购物车: {cartItemsCount}</div>;
}

通过这种方式,我们彻底消除了 Header 组件作为中间人的角色,实现了 AppLinks 之间的直接通信。

3. 封装自定义 Provider

一个常见的最佳实践是,将 Context 的状态逻辑(useStateuseReducer)和 Provider 本身封装在一个自定义的组件中。这使得状态管理逻辑集中,并且更易于复用。

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

// 1. 创建 Context
const CartContext = createContext(null);

// 2. 创建自定义 Provider 组件
export function CartProvider({ children }) {
  const [cartItemsCount, setCartItemsCount] = useState(0);

  // 在这里可以添加更多购物车相关的逻辑,如 addToCart, removeFromCart 等

  const value = { cartItemsCount, setCartItemsCount };

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

// 3. 创建自定义 Hook,简化消费过程
export function useCart() {
  const context = useContext(CartContext);
  if (context === null) {
    throw new Error('useCart 必须在 CartProvider 内部使用');
  }
  return context;
}

这样封装后,我们就可以如下使用:

  1. 在应用的顶层用 CartProvider 包裹:
// App.js
import { CartProvider } from './contexts/CartContext';
    
function App() {
  return (
    <CartProvider>
      {/* ...其他组件... */}
      </CartProvider>
    );
  }
  1. 在任何子组件中用 useCart Hook 消费数据:
// Links.js
import { useCart } from './contexts/CartContext';
    
function Links() {
  const { cartItemsCount } = useCart();
  return <div>购物车: {cartItemsCount}</div>;
}

4. Context 性能优化

a.a. 重渲染问题

Context 的一个主要缺点是:只要 Providervalue 发生变化,所有消费该 Context 的组件都会重渲染,即使组件只用到了 value 对象中的一小部分,或者它用到的那部分数据根本没有改变。

const value = { user, theme }; // 当 theme 改变时,使用 user 的组件也会重渲染

解决这个问题有两个主要策略。

b.b. 拆分 Context

最有效的优化方法是将一个大的、包含多个值的 Context 拆分成多个更小、更专注的 Context。这样,当一部分状态更新时,只有订阅了那部分状态的组件才会重渲染。

// 不要这样做
const AppContext = createContext({ user, theme, settings });

// 推荐这样做
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const SettingsContext = createContext(settings);

c.c. React.memouseMemo

  • useMemo: 当传递给 Providervalue 是一个对象或数组时,父组件的每次渲染都会创建一个新的对象,导致 value 的引用变化,从而触发所有消费者的重渲染。使用 useMemo 可以确保只有在 value 的依赖项真正改变时,才创建新的 value 对象

    function App() {
      const [cartItemsCount, setCartItemsCount] = useState(5);
      const [user, setUser] = useState('Guest');
    
      // 只有当 cartItemsCount 变化时,providerValue 才会重新计算
      const providerValue = useMemo(() => ({
        cartItemsCount,
        setCartItemsCount
      }), [cartItemsCount]);
    
      return (
        <CartContext.Provider value={providerValue}>
          <Header />
        </CartContext.Provider>
      );
    }

如果一个消费 Context 的组件本身也接收 props,并且它的重渲染成本很高,可以用 React.memo 包裹它。这样,即使 Context 的 value 改变,只要该组件自身的 props 没有改变,它就可以避免重渲染(但这需要结合拆分 Context 等其他策略才更有效)。

5. Context 替代方法

a.a. 组件组合

在许多情况下,所谓的“Props 钻探”问题其实是组件结构设计不佳的信号。React 的核心思想之一就是组件组合,通过将组件作为 props(尤其是 children)传递,可以构建出更灵活、解耦的 UI 结构,从而自然地避免钻探。

假设我们有一个 Page 组件,需要显示用户信息 userPage 组件内部有一个 PageLayout 负责布局,而 PageLayout 内部有一个 NavigationBar,最终 NavigationBar 里的 Avatar 组件需要 user 数据来显示头像。下面是一般的组件钻探写法:

// 钻探:Page -> PageLayout -> NavigationBar -> Avatar

function Page() {
  const [user, setUser] = useState({ name: 'Alice', avatarUrl: '...' });
  return <PageLayout user={user} />;
}

function PageLayout({ user }) {
  // PageLayout 自己不用 user,只是为了往下传
  return <NavigationBar user={user} />;
}

function NavigationBar({ user }) {
  // NavigationBar 自己也不用 user,只是为了往下传
  return <Avatar user={user} />;
}

function Avatar({ user }) {
  return <img src={user.avatarUrl} alt={user.name} />;
}

这里的 PageLayoutNavigationBar 都被迫接收和传递了它们自己并不关心的 user prop。

作为替代,我们可以不传递 user 数据,而是直接将已经包含了数据的最终组件 Avatar 作为 children 或其他 prop 传递下去。

// 组合:中间组件不再关心具体数据

function Page() {
  const [user, setUser] = useState({ name: 'Alice', avatarUrl: '...' });
  const userAvatar = <Avatar user={user} />;
  
  // 直接将最终的 UI (userAvatar) 传递给 NavigationBar
  return <PageLayout navigationBar={<NavigationBar avatar={userAvatar} />} />;
}

function PageLayout({ navigationBar }) {
  // PageLayout 只负责布局,不关心 navigationBar 是什么
  return <div>{navigationBar}</div>;
}

function NavigationBar({ avatar }) {
  // NavigationBar 只负责布局,不关心 avatar 是什么
  return <nav>{/*...*/} {avatar}</nav>;
}

function Avatar({ user }) {
  return <img src={user.avatarUrl} alt={user.name} />;
}

在这个重构后的版本中,PageLayoutNavigationBar 变得更加通用和解耦。它们不再需要知道 user 的存在,只负责渲染传递给它们的 JSX,然后父组件 Page 负责把这些东西统一组装起来。这样就避免了属性的传递。

同时,现在的 PageLayoutNavigationBar 复用性也提高了:

  • PageLayout 可以接收任何东西作为 navigationBar prop,甚至可以不传,显示一个空的 div。
  • NavigationBar 也可以接收一个搜索框、一个 Logo 或者任何其他组件作为 avatar prop 的替代品。

这里体现了 “控制反转” 的思想:在 props 钻探模式中,PageLayout 控制着 NavigationBar,并告诉它:“用这个 user 数据去构建你的内容”。而在组合模式中,控制权被反转了:Page 直接告诉 PageLayout:“别管里面是什么,这是已经准备好的内容 (navigationBar),你只要把它显示出来就行。”

更进一步,我们可以使用 children 这个特殊的 props 来让这些组件变得更通用:

// PageLayout 现在是一个通用的“页面框架”
function PageLayout({ children }) {
  return (
    <div className="page-container">
      {/* children 会被渲染在这里 */}
      {children}
    </div>
  );
}

// NavigationBar 也是一个通用的“导航框架”
function NavigationBar({ children }) {
  return (
    <nav className="nav-bar">
      <span>Logo</span>
      {/* 其他导航项 */}
      <div className="nav-right-section">
        {children} {/* 把右侧的内容插在这里 */}
      </div>
    </nav>
  );
}

// Page 组件负责组合一切
function Page() {
  const [user, setUser] = useState({ name: "Alice", avatarUrl: "..." });

  return (
    <PageLayout>
      {/* PageLayout 的 children 开始 */}
      <h1>Welcome to the Page</h1>
      <NavigationBar>
        {/* NavigationBar 的 children 开始 */}
        <Avatar user={user} />
        {/* NavigationBar 的 children 结束 */}
      </NavigationBar>
      <p>Here is some page content...</p>
      {/* PageLayout 的 children 结束 */}
    </PageLayout>
  );
}

function Avatar({ user }) {
  return <img src={user.avatarUrl} alt={user.name} />;
}

b.b. 第三方状态管理库

对于大型应用中复杂、高频更新的全局状态,Context 的性能问题(所有消费者都重渲染)会变得很突出。这时,专用的状态管理库是更好的选择。

i.i. Redux (& Redux Toolkit)

Redux 是一个历史悠久、生态成熟的状态管理库,它遵循严格的单向数据流,核心理念是“单一数据源”。它包含如下核心概念:

  1. Store: 整个应用只有一个 Store,存储着所有的 state。
  2. Action: 与 useReducer 中的 Action 类似,是描述事件的普通对象。
  3. Reducer: 纯函数,根据 action 计算新 state。

组件通过 useSelector Hook 从 Store 中“选择”自己需要的数据。当需要更新状态时,组件通过 useDispatch Hook 分发一个 action,触发 reducer 计算新状态,然后 useSelector 会智能地检测组件依赖的数据是否变化,只有变化时才触发组件重渲染。

Redux 有如下优点:

  • 可预测性强:严格的数据流和不可变性使得状态变化易于追踪。
  • 强大的开发工具:Redux DevTools 提供了时间旅行调试、action 日志等强大功能。
  • 丰富的中间件 (Middleware):可以处理异步操作(如 Redux Thunk/Saga)、日志、路由等。

Redux Toolkit (RTK) 是官方推荐的 Redux 开发工具集,它极大地简化了 Redux 的模板代码,内置了 Immer 来实现“不可变更新”,并集成了 Thunk 进行异步操作,使得 Redux 的使用体验大幅提升。下面是一个简单的计数器例子:

// store.js (使用 Redux Toolkit)
import { configureStore, createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1; }, // RTK 内部使用 Immer,可以直接修改
  },
});

export const { increment } = counterSlice.actions;
export const store = configureStore({ reducer: { counter: counterSlice.reducer } });

// Component.js
import { useSelector, useDispatch } from 'react-redux';
import { increment } from './store';

function Counter() {
  const count = useSelector(state => state.counter.value); // 只订阅 count
  const dispatch = useDispatch();

  return <button onClick={() => dispatch(increment())}>{count}</button>;
}

在点击按钮后,下面的事件会依次发生:

  1. 点击事件: 按钮的 onClick 被触发。
  2. Dispatch Action: dispatch(increment()) 被调用。increment() 创建一个 action ({type: 'counter/increment'}),dispatch 将它发送出去。
  3. Reducer 执行: Redux store 接收到这个 action,并找到对应的 reducer——也就是 counterSlice 中 increment 对应的那个函数。
  4. 状态更新: reducer 函数执行 state.value += 1。在 Immer 的帮助下,一个新的 state 对象被创建,其中 state.counter.value 的值增加了 1。
  5. 组件重新渲染: useSelector 检测到它所订阅的 state.counter.value 发生了变化。
  6. UI 更新: React 重新渲染 Counter 组件,按钮上显示的数字 {count} 从 0 变成了 1。

ii.ii. Zustand

Zustand 是一个近年来非常流行的轻量级状态管理库,它借鉴了 Redux 的思想,但 API 设计得更简洁、更符合 Hooks 的直觉。

Zustand 通过 create 函数创建一个“Store”,这个 Store 本身就是一个 Hook。组件直接调用这个 Store Hook,并传入一个“选择器”函数来获取所需的状态切片。当状态更新时,Zustand 会比较选择器返回的值,只有值发生变化时才触发组件重渲染。

Zustand 具有如下优点:

  • 极简 API:几乎没有模板代码,上手非常快。
  • 默认优化:基于选择器的订阅机制天然地避免了 Context 的重渲染问题,无需额外优化。
  • 对异步操作友好:可以直接在 Store 的 action 中使用 async/await
// store.js
import { create } from 'zustand';

const useCounterStore = create(set => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
}));

// Component.js
import useCounterStore from './store';

function Counter() {
  // 从 store hook 中选择你需要的数据
  const count = useCounterStore(state => state.count);
  const increment = useCounterStore(state => state.increment);

  return <button onClick={increment}>{count}</button>;
}
特性React ContextRedux ToolkitZustand
核心思想依赖注入单一数据源 (Flux)Hooks 式状态
性能默认较差 (全员更新)优秀 (选择器)优秀 (选择器)
API 复杂度简单较复杂非常简单
DevTools无内置强大需中间件支持
适用场景低频更新的全局数据大型、复杂应用中小型应用,或作为大型应用的一部分