最近好几个月都在使用 react 原生的 context 做状态管理,在这个过程中我为了性能做了很多努力,经常为了性能写出一些不优雅的代码,并且问题不止于此(文章后面有我的吐槽)。
终于我把手上一个状态复杂的项目从 context 重构为 zustand
,这是我第一次使用 zustand
,重构过程很顺利。可以说从前 context 的问题全部都被自然的解决掉了,我没有为了解决问题而多写什么代码,一直都在删代码。并且它比 react-redux 简洁。
先来看看 zustand
的基本用法,我就直接把官网的代码贴过来了
javascript
import { create } from 'zustand'
const useStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
function Counter() {
const { count, inc } = useStore()
// 当然可以传入selector选择需要的状态
const count2 = useStore(s => s.count)
return (
<div>
<span>{count}</span>
<button onClick={inc}>one up</button>
</div>
)
}
是的,这就是使用 zustand
的全部代码了,通过 create
创建一个 hook,仅此而已,在 react 中使用这个 hook 组件就能响应到状态变化。为什么呢?🤔
让我们扒开源码看看它的庐山真面目
源码阅读 ------ 核心代码
分两部分来看,首先是 zustand
的核心代码,这部分不和任何库绑定。
javascript
const createStore = (createState) => {
let state
const listeners = new Set()
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
replace ?? typeof nextState !== 'object'
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState = () => state
const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const destroy = () => {
listeners.clear()
}
const api = { setState, getState, subscribe, destroy }
state = createState(setState, getState, api)
return api
}
原本是 TS 代码,为了方便看,我改为基础的 JS 代码,并且删掉一些 console,其余代码全部保留了,请放心食用。
可以看到这就是一个状态管理最基本的样子, state、setState、getState、 subscribe
四要素齐全了。这就是一个订阅模型必要的 api。 和 redux 很相似,zustand
也是基于不可变数据的。
订阅模型的 api 需要有一个全局的 state 作为"血液",否则它们毫无意义。
createStore
的参数 createState
就是我们上面使用 demo 中给 create
传入的参数。
createState
应该是一个函数(当然也可以是对象,道理是相通的),这个函数可以通过参数接收到订阅模型中必要的 api(getState\setState 等等),然后返回 store 中的状态,此时订阅模型 api 的"血液"就有了。
所以说 createStore
就返回了一个完整的订阅模型,大白话就是针对于一个全局状态的 setState、getState 这些 api。如果你觉得有点绕,请对比着上面的 demo 一起食用。(我写的时候都觉得绕🙇)
set 的自动合并
稍微展开说说 setState
,可以看到它是可以自动合并的
javascript
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
replace ?? typeof nextState !== 'object'
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
就像 react 类组件的 setState
一样,给 setState
传的状态(或者 updater 更新函数返回的新状态)不需要包含状态的全部属性,就像下面这样:
javascript
const useStore = create((set) => ({
count: 1,
person: {name: 'hxy', age: 18, job: []},
addCount: () => set((state) => ({ count: state.count + 1 })),
}))
更新 count 时只需要设置新的 count,zustand
会帮你将其和原状态合并。
但这仅限于顶层合并,一旦你需要 设置深层属性,还是要老老实实拷贝
javascript
const useStore = create((set) => ({
count: 1,
person: {name: 'hxy', age: 18, job: []},
changePersonAge: () => set((state) => ({
person: {...state.person, age: 20}
})),
// 这样不行❌
changePersonAge: () => set((state) => ({
person: {age: 20}
})),
}))
源码阅读 ------ 和 react 连接
javascript
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim'
function useStore(api, selector, equalityFn) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
selector,
equalityFn,
)
return slice
}
const createImpl = (createState) => {
const api =
typeof createState === 'function' ? createStore(createState) : createState
const useBoundStore = (selector, equalityFn) =>
useStore(api, selector, equalityFn)
Object.assign(useBoundStore, api)
return useBoundStore
}
即使是和 react 连接,代码也是如此简洁。这里的 createImpl
,就是 demo 中引用的 create
方法。
得益于 react 的 useSyncExternalStore
,使用 zustand
不需要给程序包裹 context provider,这是和 redux 不同的地方,也是我喜欢的地方。
我们先介绍一下 useSyncExternalStore
,这是一个让你订阅外部 store 的 hook,它的用法如下,图片来源于 react 官网。
需要给他传入外部 store 的 subscribe
方法 和 获取状态快照的方法,可以理解为 getState
方法,第三个参数和服务端渲染有关,这里不考虑。
它的工作机制可以这样理解:当 store 的状态改变时,订阅函数会执行,此时 react 会调用 getSnapshot
和之前的状态快照比较(通过 Object.is 比较),如果状态发生改变,组件会重新渲染。
所以说使用这个 hook 订阅的外部 store 需要基于不可变数据。用官网的话说就是:
在 store 不变的情况下,重复调用 getSnapshot 必须返回同一个值
我们回到 zustand
,可以看到它从 use-sync-external-store 导入了useSyncExternalStoreWithSelector
,先不管,就当它使用的 useSyncExternalStore
。
可以看到先通过上面介绍过的 createStore
,获取到我们 store 的 getState、setState 这些 api
javascript
const api =
typeof createState === 'function' ? createStore(createState) : createState
然后建立一个函数 useBoundStore
,它的逻辑是把 api 传给 useStore
javascript
// 省略equalityFn参数
const useBoundStore = (selector) => useStore(api, selector)
useStore
也只是把 store 的 subscribe 方法 和 getState 方法传给useSyncExternalStore
javascript
function useStore(api, selector, equalityFn) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
selector,
equalityFn,
)
return slice
}
然后返回 useBoundStore
,好了,大功告成,只要在组件中使用 useBoundStore
,就相当于是使用 useSyncExternalStore
,当状态改变时组件自然会重新渲染。
请注意返回 useBoundStore
前,通过 Object.assign
把 getState、setState 这些 api 加到了useBoundStore
函数本体上了,这个很有用,我们先按下不表。
useSyncExternalStoreWithSelector
先解释一下 use-sync-external-store/shim,这是 react
团队发布的包。 useSyncExternalStore
是 react18 新增的特性,所以 react 团队发布了这个向后兼容的包,以便 18 以前的版本也可以使用(当然要大于 16.8 版本)。
我们再来看看这个 hook 的用法。前面提到过,getSnapshot 返回的是状态快照,用于检查订阅的值自上次渲染以来是否发生了变化,因此结果需要引用稳定。这意味着它要么需要是一个不可变的值,如字符串或数字,要么需要是一个缓存/记忆的对象。
为了方便起见,react 团队提供一个自动支持记忆结果的 getSnapshot
API 版本,这就是useSyncExternalStoreWithSelector
,源码如下(代码里有一点注释)
javascript
function useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getServerSnapshot,
selector,
isEqual,
) {
const instRef = useRef(null);
let inst;
if (instRef.current === null) {
inst = {
hasValue: false,
value: null,
};
instRef.current = inst;
} else {
inst = instRef.current;
}
const [getSelection, getServerSelection] = useMemo(() => {
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection;
const memoizedSelector = (nextSnapshot) => {
if (!hasMemo) {
// 第一次调用hook时,没有缓存
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
// 需要用户自己提供isEqual
if (isEqual !== undefined) {
if (inst.hasValue) {
const currentSelection = inst.value;
if (isEqual(currentSelection, nextSelection)) {
memoizedSelection = currentSelection;
return currentSelection;
}
}
}
memoizedSelection = nextSelection;
return nextSelection;
}
const prevSnapshot = memoizedSnapshot;
const prevSelection = memoizedSelection;
if (is(prevSnapshot, nextSnapshot)) {
// 快照与上次相同,重复使用之前的结果
return prevSelection;
}
// 快照已更改,需要获取新的快照
const nextSelection = selector(nextSnapshot);
// 如果提供了自定义 isEqual 函数,会使用它来检查数据是否,已经改变。
// 如果未改变,返回之前的结果,React会退出渲染
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
return prevSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
const maybeGetServerSnapshot =
getServerSnapshot === undefined ? null : getServerSnapshot;
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
const getServerSnapshotWithSelector =
maybeGetServerSnapshot === null
? undefined
: () => memoizedSelector(maybeGetServerSnapshot());
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
const value = useSyncExternalStore(
subscribe,
getSelection,
getServerSelection,
);
useEffect(() => {
inst.hasValue = true;
inst.value = value;
}, [value]);
useDebugValue(value);
return value;
}
可以发现,这个 hook 做的事确实就是提供了一个自动支持记忆结果的 getSnapshot ,它把我们传入的 selector 和 store 的 getSnapshot 结合,封装组合成了一个自动缓存版的 getSnapshot 并传给 useSyncExternalStore
。
当然我们要注意,此 hook 内部并没有默认的 isEqual
方法,如果你需要,要自己提供。
javascript
const count = useStore(s => s.count, isEqual)
初次读这段源码,我很疑惑,我们一般用 zustand
写 selector 都是这样写
javascript
const count = useStore(s => s.count)
selector 的引用是不稳定的,每渲染一次 selector 引用就变一次,而 useSyncExternalStoreWithSelector
里 useMemo
的依赖项就有 selector,岂不是每次都不会命中缓存? 🤔
其实这个想法的根本方向就是错的, useSyncExternalStoreWithSelector
处理的是外部 store 的改变,具体说就是:外部 store 改变时,react 组件要不要重新渲染 ?一旦组件已经渲染了,selector 引用改变也就不重要了,因为本身已经渲染了呀(好像一句废话哈哈)。并且有 instRef
保存了之前选中的状态快照,不会丢失旧状态。
如果你也有这个疑惑,希望我的这段简析可以给你一个方向(让你走出牛角尖)。
这个时候,我们再来回顾一下文章开头的 demo
javascript
import { create } from 'zustand'
// create接收一个函数,函数可以接收set、get、subscribe、destroy参数
// 返回的useStore内部就是一个useSyncExternalStoreWithSelector
const useStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
function Counter() {
// 使用useStore相当于使用useSyncExternalStoreWithSelector
const { count, inc } = useStore()
// 可以传入selector,也可以传入isEqual方法
const count2 = useStore(s => s.count)
return (
<div>
<span>{count}</span>
<button onClick={inc}>one up</button>
</div>
)
}
好了,到目前为止 zustand
的源码解读和使用方式就已经介绍完了,感谢你看到这里🌹。
但是我还没有结束,下面我还会介绍中间件和和实际开发中的一些小技巧。
如果你感兴趣,欢迎继续阅读👏
中间件
很多人都知道 redux 的中间件机制是一个 compose,遵循的就是函数组合这样的函数式编程理念。
在 zustand
核心源码中并没有发现任何和中间件有关的代码,但其实它和 redux 一样也是利用函数组合。
我们直接来看一个中间件
javascript
import { produce } from 'immer'
const immerImpl = (initializer) => (set, get, store) => {
store.setState = (updater, replace, ...a) => {
const nextState = typeof updater === 'function' ? produce(updater) : updater
return set(nextState, replace, ...a)
}
return initializer(store.setState, get, store)
}
这是 zustand
官方提供的中间件,给 set 加上了 immer 的功能。
中间件是一个函数,先看看返回值。
它返回了另外一个函数,返回的函数我们很熟悉,参数是 set、get 这些,所以它就是前面提到的传给 create
的参数。
所以如果你把 immerImpl
函数调用的结果传给 create
,完全没问题。
再来看看参数,其实这个参数,和返回值一样,也等于是传给 create
的参数,我们在调用这个参数方法的时候也传入了 set、get 等方法,只不过调用之前做了一些手脚,给 set 加入了 immer 的功能。 这就是 zustand
的中间件机制,用法如下:
javascript
import { create } from 'zustand'
const middleware1 = (fun) => (set, get, store) => {
// ......
return fun(set, get, store)
}
const middleware2 = (fun) => (set, get, store) => {
// ......
return fun(set, get, store)
}
const useStore = create(middleware1(middleware2((set, get, store) => {
// ......
})))
可以说这和 redux 的理念一模一样,只是 redux 显式的使用了 compose 这个经典函数组合函数,zustand
需要用户自己调用。
如果你也想使用 compose,完全没问题
javascript
import { create } from 'zustand'
const middleware1 = (fun) => (set, get, store) => {
// ......
return fun(set, get, store)
}
const middleware2 = (fun) => (set, get, store) => {
// ......
return fun(set, get, store)
}
const allMiddleware = compose(middleware1, middleware2)
const useStore = create(allMiddleware((set, get, store) => {
// ......
}))
也就是说,zustand
把最初的订阅模型的那些 api 传出来,经过这些中间件一层层的加工,我们最终拿到的就是一个有了很多装备的 api。
中间件生态
截止目前,zustand
官方提供了六个中间件,我列举几个
- immer:给 set 加入 immer 的功能
- persist:用于持久化存储状态,存储到例如 localStorage、IndexedDB 等,当应用重新加载时可以从存储引擎中恢复状态。
- redux:利用 redux 的 dispatch\reducer 方式编写,通过这个中间件可以很方便的把 useReducer 或者 redux 管理的状态迁移到 zustand 中。
更多的可以查看这个页面 欢迎你来创造高质量 zustand
中间件,共建生态。
开发技巧
订阅
上面多次提到,store 的 api 中有 subscribe 方法,这用于订阅状态更新,但原生的 subscribe 只能订阅全量状态,想订阅部分状态的话可以使用 subscribeWithSelector 中间件。
此时就可以给 subscribe 传入 selector。
javascript
useStore.subscribe(
(s) => s.count,
(newState, oldState) => { }
);
不需要强制订阅状态
这个标题可能会看的你一头雾水,没关系,往下看,你会理解我的。
需要提前说明,我这里指的订阅,并不是上面提到的 subscribe 那种,其实可以理解为获取状态。比如在 context 中,这里的订阅指的是useContext()
,在 zustand 中,指的就是useStore(selector)
。
想象一个场景,有一个操作需要高频率触发状态更新,不相关的组件应该避免订阅,但同时,某些组件中存在一种逻辑:它是非状态更新触发的,但它需要用到这个频繁触发的状态。
拿我手上的一个项目举例,这是一个编辑器,有一个画布,画布内有许多组件,画布可以缩放,缩放是一个高频触发状态更新的操作,随便缩放一下都会触发 scale 多次更新。
我当然要避免不相关的组件订阅 scale, 但同时我的某些组件的 mosemove 事件中有需要用到 scale,我感觉很冤🤕,我不需要响应 scale 的更新,但我依然需要强制订阅 scale,这个时候,你应该理解我的标题了吧。
之前我使用 context 管理状态,位了解决这个问题,我不得不建一个全局的对象
javascript
const SCALE = {value: null}
然后在我的 context 层实时更新这个常量
这样,在 mousemove 事件中使用这个常量即可解决这个问题。
可是,这代码好不优雅啊。
当我迁移到 zustand
中,这个问题迎刃而解,因为zustand
本身就是个外部的 store 。
还记得之前说的按下不表吗,我把代码贴过来
javascript
const createImpl = (createState) => {
const api =
typeof createState === 'function' ? createStore(createState) : createState
const useBoundStore = (selector, equalityFn) =>
useStore(api, selector, equalityFn)
Object.assign(useBoundStore, api)
return useBoundStore
}
把 store 的这些 api 合并到了 useBoundStore
上,我们就可以通过 useBoundStore.getState
直接获取最新状态,就像下面这样,不需要常量,不需要订阅。
javascript
const onMousemove = () => {
// .....
const scale = useStore.getState().scale
// ....
}
稍微扩展一下,在 context 管理状态的程序中,如果我们需要一个工具函数做一些事,和上面的例子一样,这个函数调用不会被状态更新触发,完全由用户触发,比如某些事件回调。
比如说获取一个树的某个节点下的所有子节点的 id。
这段逻辑不难,但有什么特点?没错,它需要用到 context 中的 tree 状态,这就意味着,我们要么在组件中订阅状态,传给工具函数;要么把工具函数写在 context 层,然后在组件中订阅此函数。
我不想直接订阅 tree 状态,所以无脑选择后者。但设想一下,这又有多少坑?为了保证工具函数引用不变,我需要用 useCallback
缓存它,并且不给他任何依赖项,同时为了避免闭包陷阱,我需要用 useRef
保存 tree 状态,在函数内部还需要通过 ref 来访问最新的 tree。并且我的组件中使用了 useContext
,所以我需要一些特殊手段防止 context 的更新引发组件渲染。
仅仅想封装一个工具函数,我感觉好累🫨。
问题的根源在于,不论我的用处是什么,只要我需要用状态,我都需要订阅,我怎么感觉自己被 context 绑架了。。。
如果使用 zustand
,我可以很优雅的把这个工具函数放到我项目的 utils 目录下,通过 useStore.getState
获取最新状态即可,不存在任何订阅。
这不是 zustand
独自的优点,这是所有外部 store 的共同优点,只是 zustand
直接把 store 的 api 合并到生成的 useStore 上,用起来很方便。
当然如果你的 react 组件的性能开销不大,你完全可以无脑订阅,不采用任何优化手段。只是当你遇到不得不优化的情况时,你一定会遇到这些问题。
同时选中多个状态
如果在一个组件中需要获取多个状态,我们需要写很多次 useStore
javascript
const App = () => {
const state1 = useStore(s => s.state1)
const state2 = useStore(s => s.state2)
const state3 = useStore(s => s.state3)
const state4 = useStore(s => s.state4)
}
可不可以调一次,获取多个呢,就像这样?
javascript
const App = () => {
const { state1, state2, state3, ,state4 } = useStore(s => ({
state1: s.state1,
state2: s.state2,
state3: s.state3,
state4: s.state4,
}))
}
是可以的,但需要一点改变,我们知道在 store 不变的情况下,重复调用 selector 必须返回同一个值,这样每次调都是一个新的对象,组件就会无限渲染死循环,但还记得之前说过 zustand
用的是useSyncExternalStoreWithSelector
吗,你可以给 useStore 传一个 isEqual
方法(第二个参数),就没问题了。
zustand
也提供了另一个版本的 create
方法
javascript
import { createWithEqualityFn } from "zustand/traditional";
用法和默认的 create
一致,除了你可以给他传入第二个参数,isEqual
方法。 这样在你使用 useStore 时,就不需要再传 isEqual
了。
开发者工具
zustand
官方提供了一个中间件 devtools,通过它你可以使用 redux devtools 开发 zustand
应用程序。
搭配 redux 中间件食用更佳。 除此之外,社区还有一个中间件simple-zustand-devtools,使用它可以将状态连接到 react 开发者工具中
就我个人而言,我更喜欢后者,我在使用前者时偶尔会碰到一些问题,比如 map 没办法显示,可能搭配 redux 中间件时会更好用,但我不喜欢。
终于结束了
因为我现在开发的项目确实遇到了 context 的各种问题,再加上 zustand 用法简单,源码精简(阅读体验很棒),所以有了这篇文章。
到这里就结束了,这是我接触 zustand 的第三天,遇到的情况并不多,往后有什么坑或者最佳实践,我也会再分享出来。
再次感谢你读到这里。