NOTEReact Context 是一个强大的特性,它可以帮助我们在组件树中优雅地传递数据,避免 props 的层层传递。本章将深入探讨如何有效地使用 Context 进行状态管理。
React 从 16.3 版本开始提供了 Context API。虽然 Context 本身与状态管理无关,但它提供了一种在组件之间传递数据的机制,无需通过 props 层层传递。通过将 Context 与组件状态结合,我们可以实现全局状态管理。
除了 React 16.3 提供的 Context 支持外,React 16.8 还引入了 useContext hook。通过组合使用 useContext 和 useState(或 useReducer),我们可以创建用于全局状态管理的自定义 hooks。
WARNINGContext 并非专门为全局状态管理而设计。它的一个已知限制是:当 Context 值更新时,所有使用该 Context 的消费组件都会重新渲染,这可能导致不必要的渲染。因此,建议将全局状态分割成更小的部分。
在本章中,我们将详细讨论使用 Context 的最佳实践,并展示具体示例。同时,我们还会介绍如何在 TypeScript 环境下使用 Context。我们的目标是让你能够自信地使用 Context 进行状态管理。
本章将涵盖以下主题:
- 深入理解 useState 和 useContext
- Context 工作原理解析
- 使用 Context 创建全局状态
- Context 使用的最佳实践
让我们开始学习如何有效地使用 Context 来管理 React 应用中的状态。
3.1. 探索 useState 和 useContext
TIP通过组合使用 useState 和 useContext,我们可以创建一个简单的全局状态。让我们先回顾一下如何单独使用 useState,然后了解 useContext 如何处理静态值,最后学习如何组合使用 useState 和 useContext。
不使用 useContext 的 useState
在深入理解 useContext 之前,让我们通过一个具体示例回顾一下如何使用 useState。这个示例将作为本章后续示例的参考。
在这里,我们在组件树的较高层级使用 useState 定义一个 count 状态,并将状态值和更新函数向下传递。
在 App 组件中,我们使用 useState 获取 count 和 setCount,并将它们传递给 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 值。
TIPProvider 的嵌套能力和消费组件的复用性是 React Context 的重要特性。如果您的使用场景不需要这些能力,可能并不需要 Context。我们将在第 4 章《使用订阅模式共享模块状态》中讨论无需 Context 的订阅实现方式。
结合使用 useState 和 useContext
现在让我们学习如何通过组合使用 useState 和 useContext 来组织代码结构。我们可以通过 Context 传递状态值和更新函数,而不是通过 props。
以下示例使用 useState 和 useContext 实现了一个简单的计数状态。我们定义一个包含 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.Provider 的 Context 值是一个包含 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>
);
};该示例的渲染行为特征如下:
- 初始渲染阶段:所有组件都会执行首次渲染
- 修改输入框颜色值时:
- App 组件因状态更新重新渲染
- ColorContext.Provider 接收新值
- Parent 组件随之重新渲染
- 组件更新表现:
- DummyComponent 会重新渲染(未使用 memo)
- MemoedDummyComponent 不会重新渲染(memo 生效)
- ColorComponent 重新渲染(同时受父组件更新和 Context 值变化影响)
- MemoedColorComponent 重新渲染(仅受 Context 值变化驱动)
WARNINGmemo 无法阻止消费 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>
);
};该示例表现出以下特征:
- 点击count1按钮时:
- MemoCounter1正常更新(count1变化)
- MemoCounter2也会重新渲染(count2未变化)
- 点击count2按钮时:
- MemoCounter2正常更新(count2变化)
- MemoCounter1也会重新渲染(count1未变化)
即使消费组件仅使用对象值的部分属性,且该属性未发生变化,Context值的任何属性更新仍会导致所有消费组件重新渲染。这是React Context的重要行为限制,在组织复杂状态时需要特别注意。
后续章节将讨论如何通过状态分割或使用选择器模式来解决该问题。
额外重新渲染的性能考量
NOTE虽然额外重新渲染会带来一定的性能开销,但在以下场景中可能无需过度优化:
- 组件树规模较小(渲染耗时 < 1ms)
- 用户交互频率较低(如按钮每分钟点击次数 < 10)
- 组件渲染结果无实际变化(虚拟DOM比对未触发真实DOM更新)
TIP优化权衡原则:
避免为了消除少量额外渲染而引入复杂的优化逻辑,除非性能指标已明确显示相关组件成为瓶颈。过度优化可能带来以下问题:
- 代码可维护性下降
- 状态管理复杂度上升
- 潜在的内存泄漏风险
在后续章节中,我们将学习如何通过以下模式合理优化全局状态管理:
- Context状态分割策略
- 选择器(Selector)模式实现细粒度订阅
- 状态变更批处理技术
3.3. 使用 Context 创建全局状态
NOTE基于 React Context 的行为特性,我们将探讨两种使用 Context 管理全局状态的解决方案:
- 状态分片策略
- 使用 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 的具体实现,它将在组织你的应用代码时发挥作用。
在下一章中,我们将学习另一种使用订阅模式实现全局状态的方法。