3023 words
15 minutes
5. 通过Context和订阅共享组件状态
NOTE

在本章中,我们将学习如何结合React Context和订阅机制的优势,创建一个更强大的状态管理解决方案。这种组合方法特别适合中型到大型应用程序。

在前两章中,我们学习了如何使用Context和订阅机制来实现全局状态。它们各自都有不同的优势:Context允许我们为不同的子树提供不同的值,而订阅机制则可以防止额外的重新渲染。

核心优势

结合Context和订阅机制将为我们带来:

  1. Context的优势:为子树提供全局状态,支持嵌套提供者
  2. 订阅机制的优势:精确控制重新渲染,提高性能

在本章中,我们将涵盖以下主题:

  • 探索模块状态的局限性
  • 理解何时使用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 /> </> );
TIP

Counter组件是可重用的,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,我们可以在子树中隔离状态;通过订阅机制,我们可以避免额外的重新渲染。

总结#

核心收获

在本章中,我们学习了:

  1. 如何结合React Context和订阅机制
  2. 如何在子树中提供隔离的状态值
  3. 如何避免不必要的重渲染
  4. 这种方法在中大型应用中的实际应用

下一章,我们将深入研究各种全局状态库,了解它们是如何基于这些基础概念构建的。

5. 通过Context和订阅共享组件状态
https://0bipinnata0.my/posts/react/micro-state-management/05-通过context和订阅共享组件状态/
Author
0bipinnata0
Published at
2025-02-23 16:03:04