NOTE在上一章中,我们学习了如何使用Context来实现全局状态。Context并不是为单例模式设计的;它是一种避免单例模式的机制,可以为不同的子树提供不同的值。对于类似全局状态这样的单例,使用模块状态更有意义,因为它在内存中就是一个单例值。
本章的目标是学习如何在React中使用模块状态。这是一种比Context不太为人所知的模式,但经常用于集成现有的模块状态。
什么是模块状态?
TIP从严格定义来说,模块状态是在ECMAScript(ES)模块作用域中定义的一些常量或变量。在本书中,我们不会遵循这个严格的定义。你可以简单地认为模块状态是在全局范围或文件作用域内定义的变量。
我们将探索如何在React中使用模块状态作为全局状态。为了在React组件中使用模块状态,我们使用订阅机制。
本章将涵盖以下内容:
- 探索模块状态
- 在React中使用模块状态作为全局状态
- 添加基本的订阅机制
- 使用选择器和useSubscription
探索模块状态
模块状态是在模块级别定义的变量。这里的模块指的是ES模块或者简单的一个文件。为了简单起见,我们可以认为在函数外部定义的变量就是一个模块状态。
例如,让我们定义一个count状态:
let count = 0;
假设这是在一个模块中定义的,那么这就是一个模块状态。
TIP通常在React中,我们更倾向于使用对象状态。下面定义了一个包含count的对象状态:
let state = { count: 0, };
我们可以向这个对象添加更多的属性,也可以嵌套其他对象。
现在,让我们定义一些函数来访问这个模块状态:
export const getState = () => state; export const setState = (nextState) => { state = nextState; };
WARNING注意,我们在这些函数前添加了export关键字,这表明这些函数期望在模块外部被使用。
在React中,我们经常使用函数来更新状态。让我们修改setState以支持函数式更新:
export const setState = (nextState) => { state = typeof nextState === 'function' ? nextState(state) : nextState; };
你可以像下面这样使用函数式更新:
setState((prevState) => ({ ...prevState, count: prevState.count + 1 }));
TIP除了直接定义模块状态,我们还可以创建一个函数来生成包含状态和访问函数的容器。
下面是这样一个函数的具体实现:
export const createContainer = (initialState) => { let state = initialState; const getState = () => state; const setState = (nextState) => { state = typeof nextState === 'function' ? nextState(state) : nextState; }; return { getState, setState }; };
你可以像这样使用它:
import { createContainer } from '...'; const { getState, setState } = createContainer({ count: 0 });
到目前为止,模块状态与React还没有任何关联。在下一节中,我们将学习如何在React中使用模块状态。
在React中使用模块状态作为全局状态
NOTE正如我们在第3章”使用Context共享组件状态”中所讨论的,React Context的设计目的是为不同的子树提供不同的值。虽然使用React Context来实现单例的全局状态是一种有效的操作,但这并没有充分利用Context的全部功能。
如果你需要的是一个适用于整个组件树的全局状态,那么使用模块状态可能更合适。然而,要在React组件中使用模块状态,我们需要自己处理重新渲染的问题。
让我们从一个简单的例子开始。不幸的是,这是一个无法正常工作的例子:
let count = 0; const Component1 = () => { const inc = () => { count += 1; } return ( <div>{count} <button onClick={inc}>+1</button></div> ); };
WARNING你会在开始时看到count为0。点击按钮会增加count变量的值,但它不会触发组件重新渲染。
在编写本书时,React只有两个钩子(useState和useReducer)可以触发重新渲染。我们需要使用其中之一来使组件对模块状态产生响应。
上面的例子可以通过以下修改来使其正常工作:
let count = 0; const Component1 = () => { const [state, setState] = useState(count); const inc = () => { count += 1; setState(count); } return ( <div>{state} <button onClick={inc}>+1</button></div> ); };
现在,如果你点击按钮,它会增加count变量的值,同时也会触发组件重新渲染。
让我们看看如果我们有另一个类似的组件会发生什么:
const Component2 = () => { const [state, setState] = useState(count); const inc2 = () => { count += 2; setState(count); } return ( <div>{state} <button onClick={inc2}>+2</button></div> ); };
即使你点击Component1中的按钮,它也不会触发Component2重新渲染。只有当你点击Component2中的按钮时,它才会重新渲染并显示最新的模块状态。这就是Component1和Component2之间的不一致性,而我们期望两个组件应该显示相同的值。这种不一致性在两个Component1组件之间也会发生。
解决这个问题的一个简单方法是同时调用Component1和Component2中的setState函数。这需要在模块级别保存setState函数。我们还应该考虑组件的生命周期,并使用useEffect钩子在React外部修改一个用于保存setState函数的集合。
下面是一个可能的解决方案。这个示例主要用于说明思路,实际应用中可能并不是很实用:
let count = 0; const setStateFunctions = new Set<(count: number) => void>(); const Component1 = () => { const [state, setState] = useState(count); useEffect(() => { setStateFunctions.add(setState); return () => { setStateFunctions.delete(setState); }; }, []); const inc = () => { count += 1; setStateFunctions.forEach((fn) => { fn(count); }); } return ( <div>{state} <button onClick={inc}>+1</button></div> ); };
WARNING注意我们在useEffect中返回了一个清理函数。在inc函数中,我们调用了setStateFunctions集合中的所有setState函数。
现在,Component2也需要像Component1一样修改:
const Component2 = () => { const [state, setState] = useState(count); useEffect(() => { setStateFunctions.add(setState); return () => { setStateFunctions.delete(setState); }; }, []); const inc2 = () => { count += 2; setStateFunctions.forEach((fn) => { fn(count); }); } return ( <div>{state} <button onClick={inc2}>+2</button></div> ); };
NOTE如前所述,这并不是一个很实用的解决方案。我们在Component1和Component2中有一些重复的代码。
在下一节中,我们将介绍一个订阅机制,并减少重复的代码。
添加基本的订阅机制
在这里,我们将学习订阅机制以及如何将模块状态与React状态连接起来。
订阅是一种获取更新通知的方式。一个典型的订阅使用示例如下:
const unsubscribe = store.subscribe(() => { console.log('store is updated'); });
在这里,我们假设store变量有一个subscribe方法,该方法接受一个回调函数并返回一个取消订阅函数。
在这种情况下,预期的行为是每当store更新时,回调函数就会被调用,并显示控制台日志。
现在,让我们实现一个带有订阅功能的模块状态。我们将其称为store,除了我们在探索模块状态部分描述的getState和setState方法外,它还包含了状态值和subscribe方法。createStore是一个用于创建具有初始状态值的store的函数:
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 }; };
与我们在探索模块状态部分实现的createContainer函数相比,createStore具有subscribe方法,并且其setState方法会调用回调函数。
我们可以像下面这样使用createStore:
import { createStore } from '...'; const store = createStore({ count: 0 }); console.log(store.getState()); store.setState({ count: 1 }); store.subscribe(...);
store变量在其中保存了状态,整个store变量可以被视为一个模块状态。
接下来,我们将学习如何在React中使用store变量。
我们定义一个新的钩子函数useStore,它将返回一个包含store状态值和更新函数的元组:
const useStore = (store) => { const [state, setState] = useState(store.getState()); useEffect(() => { const unsubscribe = store.subscribe(() => { setState(store.getState()); }); setState(store.getState()); // [1] return unsubscribe; }, [store]); return [state, store.setState]; };
NOTE你可能注意到了[1]。这是为了处理一个边缘情况。它在useEffect中调用了一次setState()函数。这是因为useEffect是延迟执行的,而store可能已经有了新的状态。
下面是一个使用useStore的组件示例:
const Component1 = () => { const [state, setState] = useStore(store); const inc = () => { setState((prev) => ({ ...prev, count: prev.count + 1, })); }; return ( <div> {state.count} <button onClick={inc}>+1</button> </div> ); };
WARNING重要的是要以不可变的方式更新模块状态,就像更新React状态一样,因为模块状态最终会被设置到React状态中。
与Component1类似,我们定义另一个组件Component2,如下所示:
const Component2 = () => { const [state, setState] = useStore(store); const inc2 = () => { setState((prev) => ({ ...prev, count: prev.count + 2, })); }; return ( <div> {state.count} <button onClick={inc2}>+2</button> </div> ); };
这两个组件中的按钮都会更新store中的模块状态,并且两个组件的状态是共享的。
最后,我们定义App组件:
const App = () => ( <> <Component1 /> <Component2 /> </> );
当你运行这个应用时,你会看到类似图4.1的效果。如果你点击+1或+2按钮中的任何一个,你会看到两个计数(显示为3)会同时更新。
在本节中,我们使用订阅机制将模块状态连接到React组件。使用选择器和useSubscription
NOTE我们在上一节中创建的useStore钩子会返回整个状态对象。这意味着状态对象中任何小部分的变化都会通知所有的useStore钩子,这可能会导致额外的重新渲染。
为了避免额外的重新渲染,我们可以引入一个选择器来只返回组件感兴趣的那部分状态。
让我们首先开发useStoreSelector。
我们使用上一节中定义的相同的createStore函数,并创建一个store变量,如下所示:
const store = createStore({ count1: 0, count2: 0 });
store中的状态有两个计数 - count1和count2。
useStoreSelector钩子与useStore类似,但它接收一个额外的选择器函数。它使用选择器函数来限定状态的范围:
const useStoreSelector = <T, S>( store: Store<T>, selector: (state: T) => S ) => { const [state, setState] = useState(() => selector(store.getState())); useEffect(() => { const unsubscribe = store.subscribe(() => { setState(selector(store.getState())); }); setState(selector(store.getState())); return unsubscribe; }, [store, selector]); return state; };
与useStore相比,useStoreSelector中的useState钩子保存的是选择器函数的返回值,而不是整个状态。
现在我们定义一个使用useStoreSelector的组件。useStoreSelector的返回值是一个count数字。在这种情况下,我们直接调用store.setState()来更新状态。Component1是一个用于显示状态中count1的组件:
const Component1 = () => { const state = useStoreSelector( store, useCallback((state) => state.count1, []), ); const inc = () => { store.setState((prev) => ({ ...prev, count1: prev.count1 + 1, })); }; return ( <div> count1: {state} <button onClick={inc}>+1</button> </div> ); };
TIP注意我们需要使用useCallback来获取一个稳定的选择器函数。否则,由于选择器函数被指定为useEffect的第二个参数,每次Component1渲染时都会重新订阅store变量。
我们定义了Component2,它用于显示count2而不是count1。这次我们在组件外部定义选择器函数来避免使用useCallback:
const selectCount2 = ( state: ReturnType<typeof store.getState> ) => state.count2; const Component2 = () => { const state = useStoreSelector(store, selectCount2); const inc = () => { store.setState((prev) => ({ ...prev, count2: prev.count2 + 1, })); }; return ( <div> count2: {state} <button onClick={inc}>+1</button> </div> ); };
最后,App组件为了演示目的渲染了两个Component1组件和两个Component2组件:
const App = () => ( <> <Component1 /> <Component1 /> <Component2 /> <Component2 /> </> );
图4.2中的前两行由Component1渲染。如果你点击前两个+1按钮中的任何一个,它将增加count1,这会触发Component1重新渲染。然而,Component2(图4.2中的最后两行)不会重新渲染,因为count2没有改变。
WARNING虽然useStoreSelector钩子运行良好,可以在生产环境中使用,但当store或selector发生变化时需要注意一个问题。由于useEffect的执行稍有延迟,在重新订阅完成之前,它会返回一个过时的状态值。我们可以自己修复这个问题,但这有点技术性。
幸运的是,React团队为这种用例提供了一个官方钩子。它叫做use-subscription(https://www.npmjs.com/package/use-subscription)。
让我们使用useSubscription重新定义useStoreSelector。代码非常简单,如下所示:
const useStoreSelector = (store, selector) => useSubscription( useMemo(() => ({ getCurrentValue: () => selector(store.getState()), subscribe: store.subscribe, }), [store, selector]) );
使用这个改变后,应用仍然可以正常工作。
我们可以避免使用useStoreSelector钩子,直接在Component1中使用useSubscription:
const Component1 = () => { const state = useSubscription(useMemo(() => ({ getCurrentValue: () => store.getState().count1, subscribe: store.subscribe, }), [])); const inc = () => { store.setState((prev) => ({ ...prev, count1: prev.count1 + 1, })); }; return ( <div> count1: {state} <button onClick={inc}>+1</button> </div> ); };
在这种情况下,由于已经使用了useMemo,就不需要useCallback了。
useSubscription和useSyncExternalStore
NOTE在未来的React版本中,将包含一个名为useSyncExternalStore的钩子。这是useSubscription的继任者。因此,使用模块状态将变得更加容易(https://github.com/reactwg/react-18/discussions/86)。
在本节中,我们学习了如何使用选择器来限定状态范围,以及如何使用官方的useSubscription钩子来获得更具体的解决方案。
总结
TIP在本章中,我们学习了如何创建模块状态并将其集成到React中。通过所学的知识,你可以在React中使用模块状态作为全局状态。订阅机制在集成过程中扮演着重要角色,因为它允许在模块状态发生变化时触发组件的重新渲染。除了使用基本的订阅实现来在React中使用模块状态外,还有一个官方包可供使用。基本的订阅实现和官方包都适用于生产环境。
在下一章中,我们将学习实现全局状态的第三种模式,这是第一种模式和第二种模式的组合。