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和订阅机制
- 如何在子树中提供隔离的状态值
- 如何避免不必要的重渲染
- 这种方法在中大型应用中的实际应用
下一章,我们将深入研究各种全局状态库,了解它们是如何基于这些基础概念构建的。