7634 words
38 minutes
3. 使用 Context 共享组件状态
2025-02-23 09:09:42
2025-12-24 23:45:46
NOTE

React Context 是一个强大的特性,它可以帮助我们在组件树中优雅地传递数据,避免 props 的层层传递。本章将深入探讨如何有效地使用 Context 进行状态管理。

React 从 16.3 版本开始提供了 Context API。虽然 Context 本身与状态管理无关,但它提供了一种在组件之间传递数据的机制,无需通过 props 层层传递。通过将 Context 与组件状态结合,我们可以实现全局状态管理。

除了 React 16.3 提供的 Context 支持外,React 16.8 还引入了 useContext hook。通过组合使用 useContextuseState(或 useReducer),我们可以创建用于全局状态管理的自定义 hooks。

WARNING

Context 并非专门为全局状态管理而设计。它的一个已知限制是:当 Context 值更新时,所有使用该 Context 的消费组件都会重新渲染,这可能导致不必要的渲染。因此,建议将全局状态分割成更小的部分。

在本章中,我们将详细讨论使用 Context 的最佳实践,并展示具体示例。同时,我们还会介绍如何在 TypeScript 环境下使用 Context。我们的目标是让你能够自信地使用 Context 进行状态管理。

本章将涵盖以下主题:

  • 深入理解 useStateuseContext
  • Context 工作原理解析
  • 使用 Context 创建全局状态
  • Context 使用的最佳实践

让我们开始学习如何有效地使用 Context 来管理 React 应用中的状态。


3.1. 探索 useStateuseContext#

TIP

通过组合使用 useStateuseContext,我们可以创建一个简单的全局状态。让我们先回顾一下如何单独使用 useState,然后了解 useContext 如何处理静态值,最后学习如何组合使用 useStateuseContext

不使用 useContextuseState#

在深入理解 useContext 之前,让我们通过一个具体示例回顾一下如何使用 useState。这个示例将作为本章后续示例的参考。

在这里,我们在组件树的较高层级使用 useState 定义一个 count 状态,并将状态值和更新函数向下传递。

在 App 组件中,我们使用 useState 获取 countsetCount,并将它们传递给 Parent 组件。代码如下所示:

const App = () => {
    const [count, setCount] = useState(0);
    return <Parent count={count} setCount={setCount} />;
};
NOTE

这是一个非常基础的模式,我们在第 2 章《使用本地和全局状态》中已经学习过,这被称为状态提升。

const Parent = ({ count, setCount }) => (
    <>
        <Component1 count={count} setCount={setCount} />
        <Component2 count={count} setCount={setCount} />
    </>
);
WARNING

这种从父组件向子组件逐层传递 props 的方式被称为属性钻取(prop drilling),这种重复的传递操作在实际开发中可能导致代码难以维护。

Component1 和 Component2 组件分别用于展示 count 状态和通过 setCount 更新该状态的操作按钮,具体实现如下:

const Component1 = ({ count, setCount }) => (
    <div>
        {count}
        <button onClick={() => setCount((c) => c + 1)}>
            +1
        </button>
    </div>
);

const Component2 = ({ count, setCount }) => (
    <div>
        {count}
        <button onClick={() => setCount((c) => c + 2)}>
            +2
        </button>
    </div>
);

这两个组件都是纯组件(pure components),意味着它们仅接收 props 并根据 props 进行渲染。Component2 与 Component1 的区别在于每次点击按钮会将计数增加 2,如果是完全相同的行为,我们就没有必要定义两个组件。

TIP

这个示例本身没有问题,但当应用规模扩大时,通过 props 层层传递状态(即属性钻取)会变得难以维护。此时 Parent 组件并不需要关心 count 状态,更好的做法是让 Parent 组件无需感知 count 状态的存在。

使用静态值的 useContext#

React Context 的核心作用是消除 props 的层层传递。它提供了一种无需通过 props 即可将值从父组件传递给子组件树的机制。

以下示例展示了如何将 React Context 与静态值结合使用。该示例包含多个提供不同值的 Provider,这些 Provider 可以嵌套使用。消费组件(即使用 useContext 的组件)会优先选择组件树中最近的 Provider 来获取 Context 值。示例中仅定义了一个使用 useContext 的消费组件,并在多个位置重复使用。

首先我们通过 createContext 创建颜色 Context,并设置默认值:

const ColorContext = createContext('black');

此处颜色 Context 的默认值为 ‘black’。当组件未包含在任何 Provider 中时,将使用这个默认值。

接下来定义消费组件。该组件通过读取颜色 Context 来展示对应颜色的文本:

const Component = () => {
    const color = useContext(ColorContext);
    return <div style={{ color }}>Hello {color}</div>;
};

Component 组件读取颜色 Context 的值,此时具体的颜色值完全取决于所处的 Context 环境。

最后定义 App 组件,在组件树中嵌套使用多个不同颜色的 ColorContext.Provider

const App = () => (
    <>
        <Component />
        <ColorContext.Provider value="red">
            <Component />
        </ColorContext.Provider>
        <ColorContext.Provider value="green">
            <Component />
        </ColorContext.Provider>
        <ColorContext.Provider value="blue">
            <Component />
            <ColorContext.Provider value="skyblue">
                <Component />
            </ColorContext.Provider>
        </ColorContext.Provider>
    </>
);
NOTE

第一个 Component 实例显示 “black”(未包裹在任何 Provider 中),第二个和第三个分别显示 “red” 和 “green”。第四个 Component 显示 “blue”,最后一个则显示 “skyblue”——尽管它位于值为 “blue” 的 Provider 内部,但会优先采用最近的 skyblue 值。

TIP

Provider 的嵌套能力和消费组件的复用性是 React Context 的重要特性。如果您的使用场景不需要这些能力,可能并不需要 Context。我们将在第 4 章《使用订阅模式共享模块状态》中讨论无需 Context 的订阅实现方式。

结合使用 useStateuseContext#

现在让我们学习如何通过组合使用 useStateuseContext 来组织代码结构。我们可以通过 Context 传递状态值和更新函数,而不是通过 props。

以下示例使用 useStateuseContext 实现了一个简单的计数状态。我们定义一个包含 count 状态值和 setCount 更新函数的 Context。Parent 组件不再接收 props,Component1 和 Component2 通过 useContext 获取状态。

首先,我们为计数状态创建 Context。默认值包含静态的 count 值和一个空 setCount 函数作为回退。代码实现如下:

const CountStateContext = createContext({
  count: 0,
  setCount: () => {},
});
WARNING

默认值有助于 TypeScript 的类型推断。但在实际场景中,我们通常需要的是动态状态而非静态值,因为默认值在这种情况下几乎不会用到。更好的做法是抛出错误提示,我们将在后续 “Context 使用最佳实践” 章节讨论这个问题。

App 组件通过 useState 创建状态,并将 count 和 setCount 传递给 Context Provider 组件。代码实现如下:

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <CountStateContext.Provider 
      value={{ count, setCount }}
    >
      <Parent />
    </CountStateContext.Provider>
  );
};

传递给 CountStateContext.ProviderContext 值是一个包含 count 和 setCount 的对象,其结构与默认值保持一致。

我们定义 Parent 组件。与前文示例不同,这里不再需要传递 props。代码实现如下:

const Parent = () => (
  <>
    <Component1 />
    <Component2 />
  </>
);
NOTE

尽管 Parent 组件位于 App 的 Context Provider 中,但它并不感知 count 状态的存在。Parent 内部的组件仍然可以通过 Context 获取 count 状态。

最后定义 Component1 和 Component2 组件。它们通过 Context 值而非 props 获取 count 和 setCount。代码实现如下:

const Component1 = () => {
  const { count, setCount } = useContext(CountStateContext);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

const Component2 = () => {
  const { count, setCount } = useContext(CountStateContext);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 2)}>
        +2
      </button>
    </div>
  );
};
TIP

这些组件获取的 Context 值来自最近的 Provider。我们可以使用多个 Provider 来提供隔离的 count 状态,这再次体现了 React Context 的重要能力。

本节我们学习了 React Context 的基本原理,以及如何用它创建简单的全局状态。接下来,我们将深入探讨 React Context 的具体行为。

3.2. 理解 Context 工作机制#

WARNING

当 Context 提供器的 Context 值更新时,所有 Context 消费组件都会接收新值并重新渲染。这意味着提供器中的新值会传播给所有消费者。理解 Context 值的传播机制及其局限性非常重要。

Context 传播机制#

当 Context 提供器接收到新的 Context 值时,会触发所有消费组件的重新渲染。这种传播可能造成子组件因两个原因重新渲染:一是父组件更新,二是 Context 值变化。

要阻止非 Context 值变化引起的重新渲染,我们可以使用 “内容提升” 技术或 memo 方法。memo 用于包裹组件,当组件 props 未变化时阻止重新渲染。

我们通过一个使用 memo 包裹组件的示例来理解其行为。沿用之前的颜色 Context 示例:

const ColorContext = createContext('black');

‘black’ 作为默认值,当组件树中不存在 Context 提供器时将被使用。

定义 ColorComponent 组件时,我们添加 renderCount 用于显示组件渲染次数:

let renderCount = 0;

const ColorComponent = memo(() => {
  const color = useContext(ColorContext);
  renderCount += 1;
  return (
    <div style={{ color }}>
      Hello {color} (渲染次数: {renderCount})
    </div>
  );
});

我们使用 useRef 记录渲染次数,并通过 useEffect 更新计数:

const ColorComponent = () => {
  const color = useContext(ColorContext);
  const renderCount = useRef(1);
  
  useEffect(() => {
    renderCount.current += 1;
  });

  return (
    <div style={{ color }}>
      Hello {color} (渲染次数: {renderCount.current})
    </div>
  );
};

创建记忆化组件版本:

const MemoedColorComponent = memo(ColorComponent);

定义对比组件 DummyComponent(不使用 Context):

const DummyComponent = () => {
  const renderCount = useRef(1);
  
  useEffect(() => {
    renderCount.current += 1;
  });

  return <div>对比组件 (渲染次数: {renderCount.current})</div>;
};

const MemoedDummyComponent = memo(DummyComponent);

定义包含四类组件的父容器:

const Parent = () => (
  <ul>
    <li><DummyComponent /></li>
    <li><MemoedDummyComponent /></li>
    <li><ColorComponent /></li>
    <li><MemoedColorComponent /></li>
  </ul>
);

最后实现带颜色输入控制的 App 组件:

const App = () => {
  const [color, setColor] = useState('red');
  
  return (
    <ColorContext.Provider value={color}>
      <input
        value={color}
        onChange={(e) => setColor(e.target.value)}
      />
      <Parent />
    </ColorContext.Provider>
  );
};

该示例的渲染行为特征如下:

  1. 初始渲染阶段:所有组件都会执行首次渲染
  2. 修改输入框颜色值时:
    • App 组件因状态更新重新渲染
    • ColorContext.Provider 接收新值
    • Parent 组件随之重新渲染
  3. 组件更新表现
    • DummyComponent 会重新渲染(未使用 memo)
    • MemoedDummyComponent 不会重新渲染(memo 生效)
    • ColorComponent 重新渲染(同时受父组件更新和 Context 值变化影响)
    • MemoedColorComponent 重新渲染(仅受 Context 值变化驱动)
WARNING

memo 无法阻止消费 Context 组件的重新渲染。这是必要的行为机制,否则组件可能持有不一致的 Context 值。当 Context 值更新时,所有消费该 Context 的组件(无论是否被 memo 包裹)都必须重新渲染以保证数据一致性。


对象值Context的局限性#

WARNING

当Context值为包含多个属性的对象时,即使消费组件仅使用部分属性,任何属性更新都会触发所有消费组件重新渲染。我们通过以下示例演示该现象:

定义包含两个计数状态的Context:

const CountContext = createContext({ 
  count1: 0, 
  count2: 0 
});

创建仅使用count1的计数器组件:

const Counter1 = () => {
  const { count1 } = useContext(CountContext);
  const renderCount = useRef(1);
  
  useEffect(() => {
    renderCount.current += 1;
  });

  return (
    <div>
      Count1: {count1} (渲染次数: {renderCount.current})
    </div>
  );
};

const MemoedCounter1 = memo(Counter1);

创建仅使用count2的计数器组件:

const Counter2 = () => {
  const { count2 } = useContext(CountContext);
  const renderCount = useRef(1);
  
  useEffect(() => {
    renderCount.current += 1;
  });

  return (
    <div>
      Count2: {count2} (渲染次数: {renderCount.current})
    </div>
  );
};

const MemoCounter2 = memo(Counter2);

定义父容器组件:

const Parent = () => (
  <>
    <MemoCounter1 />
    <MemoCounter2 />
  </>
);

实现状态管理组件:

const App = () => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  
  return (
    <CountContext.Provider value={{ count1, count2 }}>
      <button onClick={() => setCount1(c => c + 1)}>
        {count1}
      </button>
      <button onClick={() => setCount2(c => c + 2)}>
        {count2}
      </button>
      <Parent />
    </CountContext.Provider>
  );
};

该示例表现出以下特征:

  1. 点击count1按钮时:
    • MemoCounter1正常更新(count1变化)
    • MemoCounter2也会重新渲染(count2未变化)
  2. 点击count2按钮时:
    • MemoCounter2正常更新(count2变化)
    • MemoCounter1也会重新渲染(count1未变化)

即使消费组件仅使用对象值的部分属性,且该属性未发生变化,Context值的任何属性更新仍会导致所有消费组件重新渲染。这是React Context的重要行为限制,在组织复杂状态时需要特别注意。

后续章节将讨论如何通过状态分割或使用选择器模式来解决该问题。

额外重新渲染的性能考量#

NOTE

虽然额外重新渲染会带来一定的性能开销,但在以下场景中可能无需过度优化:

  1. 组件树规模较小(渲染耗时 < 1ms)
  2. 用户交互频率较低(如按钮每分钟点击次数 < 10)
  3. 组件渲染结果无实际变化(虚拟DOM比对未触发真实DOM更新)
TIP

优化权衡原则
避免为了消除少量额外渲染而引入复杂的优化逻辑,除非性能指标已明确显示相关组件成为瓶颈。过度优化可能带来以下问题:

  • 代码可维护性下降
  • 状态管理复杂度上升
  • 潜在的内存泄漏风险

在后续章节中,我们将学习如何通过以下模式合理优化全局状态管理:

  1. Context状态分割策略
  2. 选择器(Selector)模式实现细粒度订阅
  3. 状态变更批处理技术

3.3. 使用 Context 创建全局状态#

NOTE

基于 React Context 的行为特性,我们将探讨两种使用 Context 管理全局状态的解决方案:

  1. 状态分片策略
  2. 使用 useReducer 创建单一状态并通过多个 Context 传播

让我们逐一分析这两种解决方案。

状态分片策略#

第一种解决方案是将全局状态拆分为多个独立片段。与使用单一复合对象不同,我们为每个状态片段创建独立的全局状态和 Context。

以下示例创建了两个计数状态,分别为每个计数状态定义独立的 Context 和 Provider 组件。 首先,我们定义两个上下文(Context),Count1Context 和 Count2Context,每个对应一个独立的状态片段,如下所示:

type CountContextType = [
  number,
  Dispatch<SetStateAction<number>>
];

上下文的值是一个包含计数和更新函数的元组。我们指定了一个静态值和一个空函数作为默认值。

然后,我们定义一个仅使用 Count1Context 的 Counter1 组件,如下所示:

const Counter1 = () => {
  const [count1, setCount1] = useContext(Count1Context);
  return (
    <div>
      Count1: {count1}
      <button onClick={() => setCount1((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

注意,Counter1 组件的实现仅依赖于 Count1Context,它对其他上下文一无所知。

同样,我们定义一个仅使用 Count2Context 的 Counter2 组件,如下所示:


const Counter2 = () => {
  const [count2, setCount2] = useContext(Count2Context);
  return (
    <div>
      Count2: {count2}
      <button onClick={() => setCount2((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

Parent 组件包含 Counter1 和 Counter2 组件,如下代码片段所示:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);

这里 Parent 组件各包含两个计数器,仅用于演示目的。

我们为 Count1Context 定义一个 Count1Provider 组件。Count1Provider 组件使用 useState 来管理 count 状态,并将计数值和更新函数传递给 Count1Context.Provider 组件,如下代码片段所示:

const Count1Provider = ({
  children
}: {
  children: ReactNode
}) => {
  const [count1, setCount1] = useState(0);
  return (
    <Count1Context.Provider value={[count1, setCount1]}>
      {children}
    </Count1Context.Provider>
  );
};

同样,我们为 Count2Context 定义一个 Count2Provider 组件,如下所示:

const Count2Provider = ({
  children
}: {
  children: ReactNode
}) => {
  const [count2, setCount2] = useState(0);
  return (
    <Count2Context.Provider value={[count2, setCount2]}>
      {children}
    </Count2Context.Provider>
  );
};

Count1Provider 和 Count2Provider 组件很相似,唯一的区别在于它们所提供值的上下文不同。

最后,App 组件包含一个 Parent 组件以及两个提供者组件,如下代码片段所示:

const App = () => (
  <Count1Provider>
    <Count2Provider>
      <Parent />
    </Count2Provider>
  </Count1Provider>
);

注意,App 组件中有两个提供者组件是嵌套的。提供者组件越多,嵌套就会越深。我们将在“使用 Context 的最佳实践”部分讨论如何减轻嵌套问题。

这个示例不会受到上一节中所描述的额外重新渲染限制的影响。这是因为上下文只保存原始值。Counter1 和 Counter2 组件只会在 count1 和 count2 分别发生变化时重新渲染。为每个状态创建一个提供者是很有必要的;否则,useState 会返回一个新的元组对象,上下文就会触发重新渲染。

如果你确定某个对象会被一次性使用,并且这种使用方式不会触及 Context 行为的限制,那么将该对象作为 Context 值是完全可以接受的。以下是一个会被一次性使用的用户对象示例:

const [user, setUser] = useState({
  firstName: 'react',
  lastName: 'hooks'
});

在这种情况下,将其拆分为多个 Context 是没有意义的。为用户对象使用单个 Context 会更好。

接下来,让我们看看另一种解决方案。

使用 useReducer 创建单一状态并通过多个 Context 传播#

第二种解决方案是创建一个单一状态,并使用多个 Context 来分发状态片段。在这种情况下,应该使用单独的 Context 来分发用于更新状态的函数。

以下示例基于 useReducer 实现。它使用了三个 Context,其中两个用于状态片段,最后一个用于 dispatch 函数。

首先,我们为两个计数器创建两个值 Context,并为用于更新这两个计数器的 dispatch 函数创建一个 Context,如下所示:

type Action = { type: "INC1" } | { type: "INC2" };

const Count1Context = createContext<number>(0);
const Count2Context = createContext<number>(0);
const DispatchContext = createContext<Dispatch<Action>>(
  () => {}
);

在这种情况下,如果我们有更多的计数器,我们会创建更多的计数器 Context,但 dispatch Context 仍然只有一个。

在这个示例的后续部分,我们将为 dispatch 函数定义一个 reducer。

接下来,我们定义一个 Counter1 组件,它使用两个 Context:一个用于值,另一个用于 dispatch 函数,如下所示:

const Counter1 = () => {
  const count1 = useContext(Count1Context);
  const dispatch = useContext(DispatchContext);
  return (
    <div>
      Count1: {count1}
      <button onClick={() => dispatch({ type: "INC1" })}>
        +1
      </button>
    </div>
  );
};

Counter1 组件从 Count1Context 中读取 count1。

我们定义一个 Counter2 组件,它和 Counter1 类似,只是它从不同的 Context 中读取 count2。代码如下所示:

const Counter2 = () => {
  const count2 = useContext(Count2Context);
  const dispatch = useContext(DispatchContext);
  return (
    <div>
      Count2: {count2}
      <button onClick={() => dispatch({ type: "INC2" })}>
        +1
      </button>
    </div>
  );
};

Counter1 和 Counter2 组件都使用相同的 DispatchContext。

Parent 组件和之前的示例相同,如下所示:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);

现在,我们定义一个在这个示例中独有的 Provider 组件。该 Provider 组件使用 useReducer。reducer 函数处理两种动作类型:INC1 和 INC2。Provider 组件包含我们之前定义的三个 Context 的提供者。代码如下所示:

const Provider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(
    (
      prev: { count1: number; count2: number },
      action: Action
    ) => {
      if (action.type === "INC1") {
        return { ...prev, count1: prev.count1 + 1 };
      }
      if (action.type === "INC2") {
        return { ...prev, count2: prev.count2 + 1 };
      }
      throw new Error("no matching action");
    },
    {
      count1: 0,
      count2: 0,
    }
  );
  return (
    <DispatchContext.Provider value={dispatch}>
      <Count1Context.Provider value={state.count1}>
        <Count2Context.Provider value={state.count2}>
          {children}
        </Count2Context.Provider>
      </Count1Context.Provider>
    </DispatchContext.Provider>
  );
};

由于 reducer 的存在,这段代码有点长,而且 reducer 可能会更复杂。关键在于使用嵌套的 Provider 组件,分别提供每个状态片段和一个 dispatch 函数。

最后,App 组件只包含 Provider 组件和 Parent 组件,如下代码片段所示:

const App = () => (
  <Provider>
    <Parent />
  </Provider>
);

这个示例同样不会受到额外重新渲染问题的影响;更改状态中的 count1 只会触发 Counter1 组件重新渲染,而 Counter2 组件不受影响。

在之前的示例中,使用单一状态而非多个状态的好处在于,单一状态可以通过一个操作更新多个部分。例如,你可以在 reducer 中添加如下代码:

if (action.type === "INC_BOTH") {
  return {
    ...prev,
    count1: prev.count1 + 1,
    count2: prev.count2 + 1,
  };
}

正如我们在第一种解决方案中所讨论的,在这种解决方案中,为一个对象(如用户对象)创建一个 Context 也是可以接受的。

在本节中,我们学习了两种使用 Context 进行全局状态管理的解决方案。它们是典型的解决方案,但可能会有许多变体。关键在于使用多个 Context 来避免额外的重新渲染。在下一节中,我们将学习一些基于多个 Context 处理全局状态的最佳实践。

3.4 使用 Context 的最佳实践#

在本节中,我们将学习三种处理全局状态上下文的模式,具体如下:

  • 创建自定义钩子和提供者组件
  • 使用自定义钩子的工厂模式
  • 使用 reduceRight 避免提供者嵌套

让我们逐一了解这些模式。

创建自定义钩子和提供者组件#

在本章前面的示例中,我们直接使用 useContext 来获取上下文值。现在,我们将显式地创建自定义钩子来访问上下文值,同时创建提供者组件。这样做可以隐藏上下文并限制其使用范围。

以下示例展示了如何创建自定义钩子和提供者组件。我们将上下文的默认值设为 null,并在自定义钩子中检查该值是否为 null。这可以确保自定义钩子是在提供者的作用域内使用的。

和往常一样,我们首先要创建一个上下文;这次,上下文的默认值为 null,这意味着不能使用默认值,必须始终使用提供者。代码如下所示:

type CountContextType = [
  number,
  Dispatch<SetStateAction<number>>
];

const Count1Context = createContext<
  CountContextType | null
>(null);

然后,我们定义 Count1Provider 组件,它使用 useState 创建一个状态,并将其传递给 Count1Context.Provider,代码片段如下所示:

export const Count1Provider = ({
  children
}: {
  children: ReactNode
}) => (
  <Count1Context.Provider value={useState(0)}>
    {children}
  </Count1Context.Provider>
);

请注意,我们在 JavaScript 语法扩展(JSX)形式中使用了 useState(0)。这是有效的,它是 const [count, setCount] = useState(0); 和 return <Count1Context.Provider value={[count, setCount]}> 合为一行的简写形式。

接下来,我们定义一个 useCount1 钩子,用于从 Count1Context 中返回一个值。在这里,我们检查如果上下文的值为 null,则抛出一个有意义的错误。开发人员经常会犯错,明确的错误信息能让我们更轻松地检测到 bug。代码片段如下所示:

export const useCount1 = () => {
  const value = useContext(Count1Context);
  if (value === null) throw new Error("Provider missing");
  return value;
};

接着,我们创建 Count2Context,定义 Count2Provider 组件和 useCount2 钩子(除了名称之外,它们与 Count1Context、Count1Provider 和 useCount1 相同)。代码片段如下所示:

const Count2Context = createContext<
  CountContextType | null
>(null);
export const Count2Provider = ({
  children
}: {
  children: ReactNode
}) => (
  <Count2Context.Provider value={useState(0)}>
    {children}
  </Count2Context.Provider>
);
export const useCount2 = () => {
  const value = useContext(Count2Context);
  if (value === null) throw new Error("Provider missing");
  return value;
};

接下来,我们定义一个 Counter1 组件,用于使用 count1 状态,并显示计数和一个按钮。请注意,在以下代码片段中,这个组件并不知道 Context 的存在,Context 被隐藏在 useCount1 钩子中:

const Counter1 = () => {
  const [count1, setCount1] = useCount1();
  return (
    <div>
      Count1: {count1}
      <button onClick={() => setCount1((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

同样,我们定义一个 Counter2 组件,如下所示:

const Counter2 = () => {
  const [count2, setCount2] = useCount2();
  return (
    <div>
      Count2: {count2}
      <button onClick={() => setCount2((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

注意,Counter2 组件与 Counter1 组件几乎相似。主要区别在于,Counter2 组件使用 useCount2 钩子而不是 useCount1 钩子。

我们定义一个 Parent 组件,其中包含之前定义的 Counter1 和 Counter2 组件,如下所示:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);

最后,我们定义一个 App 组件来完善这个示例。它使用两个提供者组件包裹 Parent 组件,如下所示:

const App = () => (
  <Count1Provider>
    <Count2Provider>
      <Parent />
    </Count2Provider>
  </Count1Provider>
);

虽然在这个代码片段中不太明显,但通常我们会为每个 Context 创建一个单独的文件,比如 contexts/count1.jsx,并且只导出像 useCount1 这样的自定义钩子和像 Count1Provider 这样的提供者组件。在这种情况下,Count1Context 不会被导出。

使用自定义钩子的工厂模式#

创建自定义钩子和提供者组件是一项有点重复的任务;不过,我们可以创建一个函数来完成这项任务。

以下示例展示了 createStateContext 的具体实现。createStateContext 函数接受一个 useValue 自定义钩子,该钩子接受一个初始值并返回一个状态。如果你使用 useState,它会返回一个包含状态值和 setState 函数的元组。createStateContext 函数返回一个包含提供者组件和用于获取状态的自定义钩子的元组。这就是我们在前几节中学到的模式。

此外,这还提供了一个新特性;提供者组件接受一个可选的 initialValue 属性,该属性会被传递给 useValue。这允许你在运行时设置状态的初始值,而不是在创建时定义初始值。代码如下所示:

const createStateContext = (
  useValue: (init) => State,
) => {
  const StateContext = createContext(null);
  const StateProvider = ({
    initialValue,
    children,
  }) => (
    <StateContext.Provider value={useValue(initialValue)}>
      {children}
    </StateContext.Provider>
  );
  const useContextState = () => {
    const value = useContext(StateContext);
    if (value === null) throw new Error("Provider missing");
    return value;
  };
  return [StateProvider, useContextState] as const;
};

现在,让我们看看如何使用 createStateContext。我们定义一个自定义钩子 useNumberState,它接受一个可选的 init 参数。然后我们使用 init 调用 useState,如下所示:

const useNumberState = (init) => useState(init || 0);

通过将 useNumberState 传递给 createStateContext,我们可以根据需要创建任意数量的状态上下文;我们创建了两组。useCount1 和 useCount2 的类型是从 useNumberState 推断出来的。代码如下所示:

const [Count1Provider, useCount1] =
  createStateContext(useNumberState);
const [Count2Provider, useCount2] =
  createStateContext(useNumberState);

注意,由于使用了 createStateContext,我们避免了重复定义。

然后,我们定义 Counter1 和 Counter2 组件。useCount1 和 useCount2 的使用方式与之前的示例相同,如下代码片段所示:

const Counter1 = () => {
  const [count1, setCount1] = useCount1();
  return (
    <div>
      Count1: {count1}
      <button onClick={() => setCount1((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};
const Counter2 = () => {
  const [count2, setCount2] = useCount2();
  return (
    <div>
      Count2: {count2}
      <button onClick={() => setCount2((c) => c + 1)}>
        +1
      </button>
    </div>
  );
};

最后,我们创建 Parent 和 App 组件。Count1Provider 和 Count2Provider 的使用方式同样与之前的示例相同,如下所示:

const Parent = () => (
  <div>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </div>
);
const App = () => (
  <Count1Provider>
    <Count2Provider>
      <Parent />
    </Count2Provider>
  </Count1Provider>
);

注意,与之前的示例相比,我们的代码量减少了。createStateContext 的核心目的就是避免代码重复,同时提供相同的功能。

我们可以使用 useReducer 来创建自定义钩子,而不是使用结合 useState 的 useNumberState,示例如下:

const useMyState = () => useReducer({}, (prev, action) => {
  if (action.type === 'SET_FOO') {
    return { ...prev, foo: action.foo };
  }
  // ...
});

我们还可以创建更复杂的钩子。以下示例包含 inc1 和 inc2 自定义动作函数,并使用 useEffect 在控制台显示更新日志:

const useMyState = (initialState = { count1: 0, count2: 0 }) => {
  const [state, setState] = useState(initialState);
  useEffect(() => {
    console.log('updated', state);
  });
  const inc1 = useCallback(() => {
    setState((prev) => ({
      ...prev,
      count1: prev.count1 + 1
    }));
  }, []);
  const inc2 = useCallback(() => {
    setState((prev) => ({
      ...prev,
      count2: prev.count2 + 1
    }));
  }, []);
  return [state, { inc1, inc2 }];
};

对于这些 useMyState 钩子以及其他任何自定义钩子,我们仍然可以使用 createStateContext 函数。

值得注意的是,这种工厂模式在 TypeScript 中表现出色。TypeScript 提供了类型检查,开发者可以从类型检查中获得更好的开发体验。以下代码片段展示了 createStateContext 和 useNumberState 的类型化版本:

const createStateContext = <Value, State>(
  useValue: (init?: Value) => State
) => {
  const StateContext = createContext<State | null>(null);
  const StateProvider = ({
    initialValue,
    children,
  }: {
    initialValue?: Value;
    children?: ReactNode;
  }) => (
    <StateContext.Provider value={useValue(initialValue)}>
      {children}
    </StateContext.Provider>
  );
  const useContextState = () => {
    const value = useContext(StateContext);
    if (value === null) { 
      throw new Error("Provider missing");
    }
    return value;
  };
  return [StateProvider, useContextState] as const;
};
const useNumberState = (init?: number) => useState(init || 0);

如果我们使用 createStateContext 和 useNumberState 的类型化版本,那么结果也会是类型化的。

使用 reduceRight 避免提供者嵌套#

借助 createStateContext 函数,我们可以轻松地创建多个状态。假设我们创建了五个状态,如下所示:

const [Count1Provider, useCount1] =  createStateContext(useNumberState);
const [Count2Provider, useCount2] =  createStateContext(useNumberState);
const [Count3Provider, useCount3] =  createStateContext(useNumberState);
const [Count4Provider, useCount4] =  createStateContext(useNumberState);
const [Count5Provider, useCount5] =  createStateContext(useNumberState);

那么我们的 App 组件将会是这样的:

const App = () => (
  <Count1Provider initialValue={10}>
    <Count2Provider initialValue={20}>
      <Count3Provider initialValue={30}>
        <Count4Provider initialValue={40}>
          <Count5Provider initialValue={50}>
            <Parent />
          </Count5Provider>
        </Count4Provider>
      </Count3Provider>
    </Count2Provider>
  </Count1Provider>
);

这样的写法完全正确,它体现了组件树的结构。然而,过多的嵌套在编码时会让人感觉不太舒服。为了改善这种编码风格,我们可以使用 reduceRight 方法。App 组件可以按以下示例进行重构:

const App = () => {
  const providers = [
    [Count1Provider, { initialValue: 10 }],
    [Count2Provider, { initialValue: 20 }],
    [Count3Provider, { initialValue: 30 }],
    [Count4Provider, { initialValue: 40 }],
    [Count5Provider, { initialValue: 50 }],
  ] as const;
  return providers.reduceRight(
    (children, [Component, props]) =>
      createElement(Component, props, children),
    <Parent />,
  );
};

这种技术可能会有一些变体,比如创建高阶组件(HOC),但关键在于使用 reduceRight 来构建提供者树。

这种技术不仅适用于使用 Context 管理的全局状态,也适用于任何组件。

在本节中,我们学习了一些使用 Context 管理全局状态的最佳实践。这些并不是必须遵循的规则,只要你理解了 Context 的行为及其局限性,任何模式都可以正常工作。

总结#

在本章中,我们学习了如何使用 React Context 创建全局状态。Context 的传播机制有助于避免逐层传递 props。如果你正确理解了 Context 的行为,那么使用 Context 实现全局状态是很简单的。基本上,我们应该为每个状态片段创建一个 Context,以避免额外的重新渲染。一些最佳实践有助于使用 Context 实现全局状态,特别是 createStateContext 的具体实现,它将在组织你的应用代码时发挥作用。

在下一章中,我们将学习另一种使用订阅模式实现全局状态的方法。

3. 使用 Context 共享组件状态
https://0bipinnata0.my/posts/react/micro-state-management/03-使用context共享组件状态/
Author
0bipinnata0
Published at
2025-02-23 09:09:42