NOTE在本章中,我们将学习如何结合React Context和订阅机制的优势,创建一个更强大的状态管理解决方案。这种组合方法特别适合中型到大型应用程序。
在前两章中,我们学习了如何使用Context和订阅机制来实现全局状态。它们各自都有不同的优势:Context允许我们为不同的子树提供不同的值,而订阅机制则可以防止额外的重新渲染。
核心优势结合Context和订阅机制将为我们带来:
- Context的优势:为子树提供全局状态,支持嵌套提供者
- 订阅机制的优势:精确控制重新渲染,提高性能
在本章中,我们将涵盖以下主题:
- 探索模块状态的局限性
- 理解何时使用Context
- 实现Context和订阅模式
探索模块状态的局限性
WARNING模块状态存在于React组件之外,这导致了一个重要限制:全局定义的模块状态是一个单例,你不能为不同的组件树或子树拥有不同的状态。
让我们回顾一下第4章”通过订阅共享模块状态”中的createStore实现:
const createStore = (initialState) => {
let state = initialState;
const callbacks = new Set();
const getState = () => state;
const setState = (nextState) => {
state = typeof nextState === 'function'
? nextState(state) : nextState;
callbacks.forEach((callback) => callback());
};
const subscribe =(callback) => {
callbacks.add(callback);
return () => { callbacks.delete(callback); };
};
return { getState, setState, subscribe };
};使用这个createStore,让我们定义一个新的store:
const store = createStore({ count: 0 });重要说明注意,这个store是在React组件之外定义的。
要在React组件中使用store,我们使用useStore。以下是一个示例,其中包含两个组件,它们显示来自同一个store变量的共享计数:
const Counter = () => {
const [state, setState] = useStore(store);
const inc = () => {
setState((prev) => ({
...prev,
count: prev.count + 1,
}));
};
return (
<div>
{state.count} <button onClick={inc}>+1</button>
</div>
);
};
const Component = () => (
<>
<Counter />
<Counter />
</>
);TIPCounter组件是可重用的,Component可以有多个Counter实例。这些实例将共享相同的状态。
现在,假设我们想要显示另一对计数器。我们希望在Component中添加两个新组件,但新的一对应该显示与第一组不同的计数器。
让我们创建一个新的计数值。我们可以在已经定义的store对象中添加一个新属性,但我们假设还有其他属性,并且想要隔离store。因此,我们创建store2:
const store2 = createStore({ count: 0 })由于createStore是可重用的,创建一个新的store2对象很简单。
然后我们需要创建使用store2的组件:
const Counter2 = () => {
const [state, setState] = useStore(store2);
const inc = () => {
setState((prev) => ({
...prev,
count: prev.count + 1,
}));
};
return (
<div>
{state.count} <button onClick={inc}>+1</button>
</div>
);
};
const Component2 = () => (
<>
<Counter2 />
<Counter2 />
</>
);你可能注意到Counter和Counter2之间的相似性 —— 它们都是14行代码,唯一的区别是它们引用的store变量 —— Counter使用store,而Counter2使用store2。如果要支持更多的store,我们需要创建Counter3或Counter4。理想情况下,Counter应该是可重用的。但是,由于模块状态是在React之外定义的,这是不可能的。这就是模块状态的局限性。
重要提示
你可能注意到,如果我们将store作为props传递,就可以让Counter组件变得可重用。然而,当组件嵌套很深时,这将需要进行prop drilling(属性钻取),而引入模块状态的主要原因就是为了避免prop drilling。
如果能够为不同的store重用Counter组件会很好。伪代码如下:
const Component = () => (
<StoreProvider>
<Counter />
<Counter />
</StoreProvider>
);
const Component2 = () => (
<Store2Provider>
<Counter />
<Counter />
</Store2Provider>
);
const Component3 = () => (
<Store3Provider>
<Counter />
<Counter />
</Store3Provider>
);如果你查看代码,你会发现Component、Component2和Component3基本上是相同的。唯一的区别是Provider组件。这正是React Context发挥作用的地方。我们将在”实现Context和订阅模式”部分详细讨论这一点。
现在你已经理解了模块状态的局限性以及多个store的理想模式。接下来,我们将回顾React Context并探索Context的使用。
理解何时使用Context
Context的关键用途Context最适合用在以下场景:
- 需要为不同子树提供不同状态值
- 需要在组件树中动态覆盖默认值
- 需要在React生命周期中控制状态
在深入学习如何结合Context和订阅之前,让我们回顾一下Context是如何工作的。
以下是一个使用主题的简单Context示例。我们为createContext指定一个默认值:
const ThemeContext = createContext("light");
const Component = () => {
const theme = useContext(ThemeContext);
return <div>Theme: {theme}</div>;
};useContext(ThemeContext)返回的值取决于组件树中的Context。
要更改Context值,我们使用Context中的Provider组件,如下所示:
<ThemeContext.Provider value="dark">
<Component />
</ThemeContext.Provider>在这种情况下,Component将显示主题为dark。
Provider可以嵌套。它将使用最内层provider的值:
<ThemeContext.Provider value="this value is not used">
<ThemeContext.Provider value="this value is not used">
<ThemeContext.Provider value="this is the value used">
<Component />
</ThemeContext.Provider>
</ThemeContext.Provider>
</ThemeContext.Provider>如果组件树中没有provider,它将使用默认值。
例如,这里我们假设Root是根部的一个组件:
const Root = () => (
<>
<Component />
</>
);在这种情况下,Component将显示主题为light。
让我们看一个在根部使用provider来提供相同默认值的示例:
const Root = () => (
<ThemeContext.Provider value="light">
<Component />
</ThemeContext.Provider>
);在这种情况下,Component也会显示主题为light。
所以,让我们讨论何时使用Context。为此,思考我们的示例:这个带有provider的示例与之前没有provider的示例有什么区别?我们可以说没有区别。使用默认值得到了相同的结果。
为Context设置适当的默认值很重要。Context provider可以被视为一种覆盖默认Context值的方法,或者如果存在父provider,则覆盖父provider提供的值。
在ThemeContext的情况下,如果我们有适当的默认值,那么使用provider的意义是什么?只有当需要为整个组件树的某个子树提供不同的值时才需要使用provider。否则,我们可以直接使用Context的默认值。
对于使用Context的全局状态,你可能只在根部使用一个provider。这是一个有效的用例,但这个用例可以通过我们在第4章”通过订阅共享模块状态”中学习的带有订阅的模块状态来覆盖。考虑到模块状态已经覆盖了在根部使用一个Context provider的用例,只有当我们需要为不同的子树提供不同的值时,才需要使用Context来实现全局状态。
在本节中,我们重新回顾了React Context,并学习了何时使用它。接下来,我们将学习如何结合Context和订阅。
实现Context和订阅模式
NOTE我们将结合Context和订阅机制的优势,克服它们各自的局限性:
- Context的重渲染问题
- 模块状态的单一值限制
首先从createStore开始。这个实现与我们在第4章中的实现相同:
type Store<T> = {
getState: () => T;
setState: (action: T | ((prev: T) => T)) => void;
subscribe: (callback: () => void) => () => void;
};
const createStore = <T extends unknown>(
initialState: T
): Store<T> => {
let state = initialState;
const callbacks = new Set<() => void>();
const getState = () => state;
const setState = (nextState: T | ((prev: T) => T)) => {
state =
typeof nextState === "function"
? (nextState as (prev: T) => T)(state)
: nextState;
callbacks.forEach((callback) => callback());
};
const subscribe = (callback: () => void) => {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
};
return { getState, setState, subscribe };
};在第4章”通过订阅共享模块状态”中,我们使用createStore来实现模块状态。这次,我们将使用createStore作为Context的值。
以下是创建Context的代码。默认值被传递给createContext,我们将其称为默认store:
type State = { count: number; text?: string };
const StoreContext = createContext<Store<State>>(
createStore<State>({ count: 0, text: "hello" })
);在这种情况下,默认store有一个包含两个属性的状态:count和text。
为了给子树提供不同的store,我们实现了StoreProvider,它是StoreContext.Provider的一个简单包装:
const StoreProvider = ({
initialState,
children,
}: {
initialState: State;
children: ReactNode;
}) => {
const storeRef = useRef<Store<State>>();
if (!storeRef.current) {
storeRef.current = createStore(initialState);
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
};我们使用useRef来确保store对象只在第一次渲染时初始化一次。
为了使用store对象,我们实现了一个名为useSelector的钩子函数。与第4章”通过订阅共享模块状态”中定义的useStoreSelector不同,useSelector不需要在其参数中接收store对象。它从StoreContext中获取store对象:
const useSelector = <S extends unknown>(
selector: (state: State) => S
) => {
const store = useContext(StoreContext);
return useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.getState()),
subscribe: store.subscribe,
}),
[store, selector]
)
);
};将useContext与useSubscription结合使用是这种模式的关键点。这种组合让我们能够同时获得Context和订阅的优势。
与模块状态不同,我们需要提供一种使用Context来更新状态的方式。useSetState是一个简单的钩子函数,用于返回store中的setState函数:
const useSetState = () => {
const store = useContext(StoreContext);
return store.setState;
};现在,让我们使用我们已经实现的这些功能。下面是一个显示store中count值的组件,以及一个用于增加count的按钮。我们在Component外部定义selectCount,否则,我们就需要使用useCallback来包装这个函数,这会带来额外的工作:
const selectCount = (state: State) => state.count;
const Component = () => {
const count = useSelector(selectCount);
const setState = useSetState();
const inc = () => {
setState((prev) => ({
...prev,
count: prev.count + 1,
}));
};
return (
<div>
count: {count} <button onClick={inc}>+1</button>
</div>
);
};这里需要注意的是,这个Component组件并不与任何特定的store对象绑定。Component组件可以用于不同的store。
我们可以在不同的位置使用Component:
- 在任何provider之外
- 在第一个provider内部
- 在第二个provider内部
下面的App组件在三个位置包含了Component组件:1) 在StoreProvider之外,2) 在第一个StoreProvider组件内部,3) 在第二个嵌套的StoreProvider组件内部。在不同StoreProvider组件中的Component组件共享不同的count值:
const App = () => (
<>
<h1>使用默认store</h1>
<Component />
<Component />
<StoreProvider initialState={{ count: 10 }}>
<h1>使用store provider</h1>
<Component />
<Component />
<StoreProvider initialState={{ count: 20 }}>
<h1>使用内部store provider</h1>
<Component />
<Component />
</StoreProvider>
</StoreProvider>
</>
);使用相同store对象的每个Component组件将共享该store对象并显示相同的count值。在这种情况下,不同组件树层级中的组件使用不同的store,因此组件在不同位置显示不同的count值。当你运行这个应用时,你会看到类似的效果。
如果你点击”使用默认store”中的+1按钮,你会看到”使用默认store”中的两个count值会一起更新。如果你点击”使用store provider”中的+1按钮,你会看到”使用store provider”中的两个count值会一起更新。对于”使用内部store provider”也是一样的效果。
在本节中,我们学习了如何通过Context和订阅机制来实现全局状态,并充分利用了它们各自的优势。通过Context,我们可以在子树中隔离状态;通过订阅机制,我们可以避免额外的重新渲染。
总结
核心收获在本章中,我们学习了:
- 如何结合React Context和订阅机制
- 如何在子树中提供隔离的状态值
- 如何避免不必要的重渲染
- 这种方法在中大型应用中的实际应用
下一章,我们将深入研究各种全局状态库,了解它们是如何基于这些基础概念构建的。