zustand 的用法实在太过灵活,用这篇文章记录一下我摸索出来的最佳实践。
store 内仅保留 state。actions 用模块导出函数的形式
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import { mutative } from 'zustand-mutative'
const store = create(subscribeWithSelector(mutative(() => {
return {
name: 'foo',
}
})))
export function getName(state) {
return state.name
}
export function setName(state, newName) {
state.name = newName
}
使用方法:
import { store, getName, setName } from './store'
store.setState(state => {
const name = getName(state)
setName(state, name + 'modified')
})
参考(但不太一样):https://zustand.docs.pmnd.rs/guides/practice-with-no-store-actions
详细解释这种模式
官方推荐的模式是将 actions 混合进 state:
const store = create(mutative((setState, getState) => {
return {
name: 'foo',
getName() {
return getState().name
},
setName(newName) {
setState(state => {
state.name = newName
})
}
}
}))这样做的问题:
- state 和 actions 的边界不够清晰。
- 我倾向于 store 内只保存 state,把 actions 分出来
- actions 的复用会有问题。
- 如果我们需要在一个 action 里调用另一个 action,会造成嵌套的
setState/getState方法执行,导致状态混乱或者报错
- 如果我们需要在一个 action 里调用另一个 action,会造成嵌套的
- Typescript 类型维护麻烦,特别是后期随着 action 越来越多,需要将 actions 切分成多个部分的时候
所以,我最后演变成了前面的做法。
更进一步:hook 机制(此 hook 非 react hook)
import { create } from 'zustand'
import { mutative } from 'zustand-mutative'
function createStore(hooks: {pre, post}[]) {
const store = create(mutative((setState, getState) => {
const initState = {
name: 'foo',
}
hooks.forEach(p => p.pre(initState, setSatte, getState))
return state
}))
hooks.forEach(p => p.post(store))
return store
}
这种 hook 机制可以将 store 的初始化过程切分成多个部分,比如:
// 给 state 加上事件处理函数
export default {
pre: (initState, setSatte, getState) => {
initState.onUserSpeak = () => {
getState().xxx
setState({ ... })
}
}
}// i18n:在用户修改了界面语言后,自动重新设置新语种
const setLangages = (state, locale = defaultLocale) => {
state.languages = getLanguagesByLocale(locale)
}
export default {
pre: (initState) => {
setLangages(state)
},
post: store => {
store.subscribe(state => state.locale, newLocale => {
store.setState(state => {
setLanguages(state, newLocale)
})
})
}
}不使用 useShallow
场景:
// 由于 selector 函数在每次渲染时都会返回一个全新的对象,这会导致组件无限重渲染
const fragment = useStore((state) => {
return {
a: state.a,
b: state.b
}
})
这时候,文档推荐我们使用 useShallow:
const fragment = useStore(useShallow(state => ...))
我的做法:
const a = useStore(state => state.a)
const b = useStore(state => state.b)
const fragment = { a:a, b:b }
// 如果计算过程比较昂贵,可以用 useMemo,还可以进一步结合 memo 来避免子组件重渲染(如果子组件重渲染的成本很高)
// @see https://react.dev/reference/react/useMemo#usage
const fragment = useMemo(() => ({{ a:a, b:b }}), [a, b])
// 如果需要重用,可以封装成一个 hook:
const fragment = useMyState()
我的做法性能更好。
首先,没有“比较”的步骤。
useShallow 原理是先计算出来新的 fragment,再跟旧的 fragment 进行浅比较,而我的写法虽然繁琐,但没有“比较“这一步骤;即使使用了 useMemo,我也只浅比较了依赖数组(即 [a, b]),而不是完整比较 fragment。
这一点在需要对 fragment 进行深度比较时会更有价值。zustand 没有 useDeep 用来进行深比较,虽然可以仿照 useShallow 的源码写一个 useDeep(内部使用 fast-deep-equals 这类函数),但进行深度比较会更耗费性能,所以省略掉“比较”这一步骤或者仅浅比较依赖数组会更好。
参考:https://github.com/pmndrs/zustand/discussions/2867#discussioncomment-11351881
其次,更加灵活,比如可以引入 useMemo 来跳过计算步骤,而 useShallow 做不到。
懒加载
我的 react 组件可能只会在用户触发某个操作(比如按下特定的快捷键)才会被渲染到浏览器里,我希望 store 也只在组件被渲染时才创建
参考:https://zustand.docs.pmnd.rs/previous-versions/zustand-v3-create-context#migration
参考:官方文档提供的一些使用 useStore 的模式 https://zustand.docs.pmnd.rs/hooks/use-store#usage