Skip to content
返回

我的 Zustand 最佳实践

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 方法执行,导致状态混乱或者报错
  • 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


分享这篇文章:

上一篇
Astro vs Next.js
下一篇
npm link 的使用踩坑记录