zustand 的维护者 Daishi 在一篇文档中写道:
A part of Zustand philosophy is being small, crazy small.
Zustand哲学的一部分是小,疯狂的小!
这也是我喜欢这个库的很大一部分原因。在 npmtrends 上,zustand 的发展势头看起来很不错,下载量已经超过了 mobx。
在 react 刚推出 hooks 的时候,有一种声音说:我们不再需要状态管理库了,用 useContext 就够了!这听起来很诱人,但很快就会发现 useContext 有两个很难绕开的问题:
- 使用 useContext 需要由 Provider 组件包裹;
- useContext 无法细粒度更新,当 context 下的任何数据发生变动时,使用该 context 的组件都会发生重渲染。
zustand 是如何避免了上面的问题的呢?
使用 zustand
// useStore.js
import { create } from 'zustand'
export const useStore = create((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
// App.js
import { useStore } from './useStore'
const App = () => {
const { count, inc } = useStore()
return (
<div>
{count}
<button onClick={inc}>click</button>
</div>
)
}
使用 zustand 很简单,我们这里只用到了一个名为 create 的来创建 store,然后像 useState 一样使用它。
代码实现
从使用上看,我们可以猜想一下 create 函数内部干了什么:create 函数返回了一个名叫 useStore 的方法。每次组件渲染时都会调用 useStore 方法。我们可以利用这个时机做依赖收集。
想象 zustand 的 store 内部维护了一个依赖集合,用于记录有哪些组件用到了这个 store。当组件初始化时,useStore 被调用,此时会向 store 的依赖集合里添加一个方法。同时告诉 store:嘿!我给你注入了一个方法,当有什么地方触发了更新(比如例子里面有地方调用了 inc),你就调用我注入的方法,我就会强制更新自己。
等等,怎么强制更新自己呢?如果你对类组件还有印象,类上有个 forceUpdate 方法,我们可以用 useReducer 模拟一个 forceUpdate 方法,像这样:
const [, forceUpdate] = useReducer((c) => c + 1, 0)
以下是我们实现的第一版代码:
const create = (createState) => {
let state
const listeners = new Set()
const getState = () => state
const setState = (partial) => {
const newState = typeof partial === 'function' ? partial(state) : partial
state = { ...state, ...newState }
listeners.forEach((listener) => listener())
}
state = createState(setState, getState)
return () => {
const [, forceUpdate] = useReducer((c) => c + 1, 0)
useEffect(() => {
listeners.add(forceUpdate)
return () => {
listeners.delete(forceUpdate)
}
}, [])
return state
}
}
cool!我们的 zustand 看起来运行良好,但是这一版代码还没有解决细粒度更新的问题,zustand 使用 selector 来控制更新粒度,比如下面的代码:
// zustand 会比较上一次的 state.count 和这一次的 state.count 是否相等,如果不相等,才重新渲染
const count = useBearStore((state) => state.count)
改造一下我们的函数,加上 selector 功能:
return (selector) => {
const [, forceUpdate] = useReducer((c) => c + 1, 0)
useEffect(() => {
const listener = (state, prevState) => {
if (
!selector ||
(selector && !Object.is(selector(state), selector(prevState)))
) {
forceUpdate()
}
}
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}, [])
return selector ? selector(state) : state
}
当用户传入 selector 函数时,我们会比较两次渲染间 selector 的值是否相等。代码里用到了 Object.is 进行浅比较,zustand 还支持用户传入一个比较函数。为了代码的简洁性,我们省去这部分逻辑。
到这里,我们解决了上面使用 context 来进行状态管理遇到的两个问题。
并发模式
还有一个问题需要解决,react 18 引入了并发模式。组件的渲染可以被更高优级事件打断。考虑如下组件:
const Parent = () => (
<div>
<Child />
<Child />
</div>
)
const Child = () => {
const count = useStore(state => state.count);
return <div>{count}</div>
}
在并发模式下,如果渲染第一个 Child 组件时,count 是 0,此时渲染被用户点击增加按钮的操作打断,接着渲染第二个 Child 组件时,count 已经变成了 1,这就会导致两个 Child 渲染结果不一致的问题。
这个现象被称为 tearing(撕裂),在这里可以看到更详细地讨论。
一个撕裂问题的演示,来自 blog.axlight.com/posts/how-i…
如果我们像开始提到的用 context 来管理状态就不会有这个问题,react 内部帮我们处理了这个问题,但 zustand 是通过消息订阅机制实现的状态管理,react 并不知道什么时候触发了状态修改。
为了解决这个问题,react 提供了 useSyncExternalStore 钩子,基本使用如下:
const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
subscribe订阅 store 并返回一个取消订阅的方法getSnapshot从 store 中读取数据快照的方法
通过 useSyncExternalStore 改写后的代码:
const create = (createState) => {
let state
const listeners = new Set()
const getState = () => state
const setState = (partial) => {
const newState = typeof partial === 'function' ? partial(state) : partial
const prevState = state
state = Object.assign({}, state, newState)
listeners.forEach((listener) => listener(state, prevState))
}
const subscribe = (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
state = createState(setState, getState)
return (selector = (s) => s) => {
useSyncExternalStoreWithSelector(subscribe, getState, getState, selector)
return selector ? selector(state) : state
}
}
zustand 使用了 react 官方推出的 use-sync-external-store 包来支持低版本的 react 和 selector 能力。改动后的代码虽然只有寥寥不到30行的代码,但这已经是 zustand 的大部分核心代码了。
感谢阅读。