本文是对原文的翻译,如有疏漏还望指正。
Zustand 是一个很棒的全局客户端状态管理的库。它简单、快捷而有着较小的包体积。不过,有一点我不太喜欢:
状态仓库是全局的
好吗?但这不就是全局状态管理的意义之所在吗?让你的状态在你的应用各个地方都是可用的?
有时,我认为是这样的。然而,当我回看我最近几年使用 zustand 的经历,我经常意识到,我需要一些状态在一个组件子树中全局可用而不是整个应用。使用 zustand,是完全可以 - 甚至是鼓励 - 去按照功能来创建多个小型的状态仓库。所以,如果我只需要在面板路由中使用面板筛选器的状态,为什么我需要我的面板过滤器仓库全局可用呢?当然,我可以无痛地去使用它,但我发现一些使用全局状态仓库的弊端:
通过 Props 来初始化#
全局的状态仓库是在 React 组件生命周期外创建的,所以我们无法利用一个 prop 中的值来初始化我们的状态仓库。在一个全局状态仓库中,我们需要通过一个已知的默认状态来创建,然后利用useEffect
来从 props 到 store 同步状态:
const useBearStore = create((set) => ({
// ⬇️ initialize with default value
bears: 0,
actions: {
increasePopulation: (by) =>
set((state) => ({ bears: state.bears + by })),
removeAllBears: () => set({ bears: 0 }),
},
}))
const App = ({ initialBears }) => {
//😕 write initialBears to our store
React.useEffect(() => {
useBearStore.set((prev) => ({ ...prev, bears: initialBears }))
}, [initialBears])
return (
<main>
<RestOfTheApp />
</main>
)
}
除了不想写useEffect
,还有两个原因使它表现不理想:
- 当我们初次在触发 effect 之前使用
bears: 0
去初始化<RestOfTheApp />
,然后在正确的initialBears
赋值上后会导致不止一次的渲染。 - 我们不需要利用
initialBears
去初始化我们的状态仓库 - 我们只是用于同步。所以如果initialBears
发生改变,我们会看到我们的状态仓库同步更新。
测试#
我发现 zustand 的测试文档十分让人困惑且复杂。这的测试都是关于模拟 zustand 和重置其状态仓库等。我认为这一切都源于状态仓库是全局的这点。如果它被限定于一个组件子树中,我们可以渲染那些组件的同时使其状态仓库保持隔离,不需要任何那些所谓的 “变通” 方法。
可复用性#
不是所有的状态仓库都是我们在我们的应用或特定路由中使用一次的单例。有时,我们想 zustand 的状态仓库可以在组件中复用。其中一个例子是过去我们设计系统中的一个复杂、多选组件。它使用本地状态通过 React Context 自上而下传递来管理内部多选的状态。当它有 50 或更多选项被选中的时候就会变得迟缓。它迫使我发了这条推文。
如果一个 zustand 状态仓库是全局的,我们将无法在没有共享和覆盖彼此状态的情况下多次实例化组件。
有趣的是,有一种方法能解决所有这类问题:
React Context#
React Context 就是那个方法在这显得很滑稽和讽刺,因为使用 Context 作为状态管理工具会立马出现上述的问题。但这不是我推荐的。这样做只是通过 React Context 共享状态仓库实例 - 而不是仓库中的状态本身。
概念上,这就是 React Query 在<QueryClientProvider>
上的实现,redux
对它的单一状态仓库也是如此。因为状态仓库的实例是静态的单例,不会经常改变,我们把他们放到 React Context 中非常容易且不会导致重渲染的问题。然后,我们依旧可以为状态仓库创建订阅者,这些订阅者将通过 zustand 进行优化。这就是具体实现的样子:
v5 语法
在这篇文章中,我将会展示 v5 的语法去整合 zustand 和 React Context。在此之前,zustand 有一个明确的createContext
函数,导出自zustand/context
。
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
函数,这将更好地来创建一个状态仓库。同时我们可以在任何地方这么做 - 甚至在组件内部。然而,我们必须确保创建状态仓库的行为只会发生一次。我们可以用 ref 来解决,但我更倾向于用useState
。如果你想知道为什么,我有一篇单独的文章专门解答。
因为我们在组件内部创建了状态仓库,我们可以停止像initialBears
这类 props,把他们传递到createStore
中作为真正的初始值。使用useState
初始化方法只会调用一次,所以 prop 的更新将不会传递到状态仓库中去。然后,我们把状态仓库实例化并传递给一个简单的 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 状态仓库还多。😄
这就是今天我想聊的。如果你有任何问题,欢迎在twitter上找我,或是在评论区底下留言。⬇️