Zustand作为一个全球性的客户端状态管理库,以其简洁、高效和紧凑的体积赢得了开发者的青睐。但有一点让我感到美中不足——它的存储是全局性的。
这听起来似乎与全局状态管理的初衷相悖,不是吗?全局状态管理的目的不就是为了让状态在应用的任何角落都能被访问到吗?
在大多数情况下,这是正确的。然而,回顾我过去几年使用Zustand的经历,我发现我更频繁地需要将某些状态限定在一个特定的组件树中,而不是让它们在整个应用中都可访问。尽管Zustand鼓励我们基于功能创建多个小型存储,但如果我们只需要在特定的路由下使用某个存储,那么全局性就显得有些多余了。此外,我还发现了一些全局存储的潜在问题:
通过Props初始化的问题
由于全局存储是在React组件生命周期之外创建的,我们无法直接使用props中的值来初始化存储。这意味着我们必须先以一个预设的默认状态创建存储,然后通过useEffect将props中的值同步到存储中:
const useBearStore = create((set) => ({
// 使用默认值初始化
bears: 0,
actions: {
increasePopulation: (by) =>
set((state) => ({ bears: state.bears + by })),
removeAllBears: () => set({ bears: 0 }),
},
}))
const App = ({ initialBears }) => {
// 将initialBears同步到我们的存储中
React.useEffect(() => {
useBearStore.set((prev) => ({ ...prev, bears: initialBears }))
}, [initialBears])
return (
main>
RestOfTheApp />
main>
)
}
这种方法不仅让我不想写额外的useEffect,而且还有以下两个缺点:
我们首先用bears: 0渲染,然后在props中的initialBears被正确设置后,会导致组件再次渲染。我们实际上并没有用initialBears来初始化存储,而是用它来同步状态。因此,如果initialBears发生变化,存储中的状态也会相应更新。测试的复杂性
我发现Zustand的测试文档相当复杂,这可能是因为存储是全局的。如果存储被限制在一个组件树中,我们可以直接渲染这些组件,而不需要任何额外的“变通”方法来隔离存储。
可复用性的问题
并非所有的存储都是单例,有时我们也希望在不同的组件中复用Zustand存储。例如,我们的设计系统中有一个复杂的多选组件,它使用本地状态通过React Context来管理内部选择状态。当选项超过50个时,性能就会变得迟缓。这促使我发表了一条推文:
如果Zustand存储是全局的,我们将无法在不共享和覆盖彼此状态的情况下多次实例化组件。
React Context:解决方案
有趣的是,React Context成为了解决这些问题的关键。这听起来可能有些讽刺,因为正是使用Context作为状态管理工具导致了上述问题。但这里我们不是将Context作为状态管理工具,而是通过React Context共享存储实例,而不是存储值本身。
从概念上讲,React Query通过实现的正是这一点,redux也通过其单一存储实现了这一点。因为存储实例是静态的单例,不会经常改变,我们可以轻松地将它们放入React Context中,而不会引起重渲染问题。然后,我们仍然可以为存储创建订阅者,这些订阅者将通过Zustand进行优化。以下是具体的实现方式:
import { createStore, useStore } from 'zustand'
const BearStoreContext = React.createContext(null)
const BearStoreProvider = ({ children, initialBears }) => {
const [store] = React.useState(() =>
createStore((set) => ({
bears: initialBears,
actions: {
increasePopulation: (by) =>
set((state) => ({ bears: state.bears + by })),
removeAllBears: () => set({ bears: 0 }),
},
})
)
return (
BearStoreContext.Provider value={store}>
{children}
BearStoreContext.Provider>
)
}
主要的区别在于我们没有使用create函数来创建一个即插即用的钩子,而是依赖于Zustand的createStore函数来创建存储。我们可以在任何地方这样做,甚至在组件内部。但是,我们必须确保只创建一次存储。我们可以使用refs来实现这一点,但我更喜欢使用useState。我有一篇单独的博客文章解释了为什么。
因为我们在组件内部创建了存储,我们可以将props如initialBears传递给createStore作为真正的初始值。useState的初始化函数只运行一次,所以props的更新不会传递到存储中。然后,我们将存储实例传递给一个简单的React Context。这里就不再有Zustand的约束了。
之后,当我们想要从存储中取出一些值进行消费时,都会用到这个上下文。为此,我们需要传递store和selector给从Zustand中拿到的useStore钩子。这是一个对应自定义钩子的最佳抽象:
const useBearStore = (selector) => {
const store = React.useContext(BearStoreContext)
if (!store) {
throw new Error('Missing BearStoreProvider')
}
return useStore(store, selector)
}
然后,我们就能像之前一样使用useBearStore钩子,并利用一些原子选择器导出其中的自定义钩子:
export const useBears = () => useBearStore((state) => state.bears)
这种方法虽然比直接创建一个全局存储要多写一些代码,但它解决了三个问题:
正如例子中所示,我们可以利用props来初始化我们的状态仓库,因为我们从React 组件树内部创建的。测试变得小菜一碟,因为我们可以选择渲染一个包含了BearStoreProvider的组件,或我们可以渲染一个用于测试的组件。在这些场景中,已创建好的状态仓库能完全隔离测试,所以无需测试间无需重置状态仓库。现在一个组件可以渲染一个BearStoreProvider来给它的子组件提供封装好的Zustand状态仓库。我们可以在一个页面中随心所欲地渲染这个组件 – 每个实例将有它独立的状态仓库,从而我们实现了可复用。
最后即便Zustand文档自豪称无需Context Provider来访问一个状态仓库,我认为有必要了解如何整合状态仓库的创建和React Context,这能够让你得心应手地处理一些需封装可复用的场景。就我而言,我使用这一抽象概念的次数比全局Zustand状态仓库还多。