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