likes
comments
collection
share

react的状态库最佳实践非zustand莫属

作者站长头像
站长
· 阅读数 14

为什么要用Zustand ?

react的状态库生态繁荣,最佳实践一直没有,选择简单易上手又能处理复杂状态的库是一件很麻烦的事,Zustand可以说是当前最火热的react的状态管理库,简洁方便快捷,并解决了死节点问题、react Concurrent、Context loss问题。是解决所有这些问题的唯一的状态库。毫不夸张的说,react的状态库最佳实践非zustand莫属

docs: docs.pmnd.rs/zustand/get…

Redux与Zustand对比

Redux:

const countReducer = (state = {count: 0}, action: any) => {
  switch(action.type) {
    case 'Increment': return {count: state.count + 1}
    case 'Decrement': return {count: state.count - 1}
    default: return state
  }
}

export const countActions = {
  add: () => ({type: 'Increment'}),
  del: () => ({type: 'Decrement'})
}

export const store = createStore(countReducer)

// comp
import React from 'react'
import { store, countActions } from "./redux";

export default function ReduxTest() {
  const [count, setcount] = React.useState(store.getState().count)

  const handleClick = () => {
    store.dispatch(countActions.add())
    setcount(store.getState().count)
  }
  return (
    <>
      <h3>{count}</h3>
      <button onClick={handleClick}>one up</button>
    </>
  )
}

Zustand:

// 封装共用方法,方便获取state
export function createSelectors(store) {
  store.use = {}
  for (let k of Object.keys(store.getState())) {
    (store.use)[k] = () => store((s) => s[k])
  }
  return store
}
export const useCountStore = create((set, getState, api) => ({
  count: 0,
}))

export const Increment = () => useCountStore.setState((state) => ({ count: state.count + 1 }))
export const Decrement = () => useCountStore.setState((state) => ({ count: state.count - 1 }))
export const getCount = () => createSelectors(useCountStore).use.count()

// comp
import React from 'react'
import { Increment, getCount } from "./store";

export const Zustand = () => {
  return <>
    <div>{getCount()}</div>
    <button onClick={Increment}>one up</button>
  </>
}

从Zustand中获取状态值同样很方便, 再也不用像以前大量的hooks的定义状态值了。

import React from 'react'
// ...
export const Zustand = () => {
  const { count, //...... } = useStore(state => state)
  return <>
    <div>{count}</div>
  </>
}

从上面的例子我们可以看出,redux要定义reducer、actions、dispatch更新等,而zustand十分简洁易懂, 几行代码搞定。在组件的使用上,redux在组件内部我们还需要定义一个state来触发更新,当然我们一般结合react-redux,是不是就变更复杂了?而在zustand中已经自动缓存了状态,而不用定义useState, 直接调用方法就行。

Zustand解决的三个疑难杂症

死节点问题

docs: react-redux.js.org/api/hooks#s…

具体例子: codesandbox.io/s/race-cond…

我们可以看到了child死节点,更新为3的时候,父组件的counter并没有更新。在一些特殊情况,useSelector的使用可能会出现问题。

React Concurrent mode 兼容问题

Context loss 问题

issues: github.com/facebook/re…

问题:

ReactDOM.render(
  <TickProvider>
    <Canvas>
      <Square />
    </Canvas>
    <Consumer>{value => value.toFixed(2)}</Consumer>
  </TickProvider>,
  document.getElementById('outside')
);

这种情况Canvas内部的Square是无法得到外部context,本质其实是react的createContext无法在react的节点传递到渲染器的节点内,在一些第三方的图表库、3d渲染库会存在无法获取外部的状态问题。而zustand本身是不需要Provider的。

解决方案:

ReactDOM.render(
  <TickProvider>
    <Consumer>
      {value => (
        <Canvas>
          <Provider value={value}>
            <Square />
          </Provider>
        </Canvas>
      )}
    </Consumer>
    <Consumer>{value => value.toFixed(2)}</Consumer>
  </TickProvider>,
  document.getElementById('bridge')
);

这样可以解决获取不到的状态,但是canvas很容易被重复渲染,所以canvas和square组件需要memo()包裹,防止重复渲染,使用zustand可以轻松解决。

优势

如何使用

zustand初始化:

import { create } from 'zustand'

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

组件内使用:

function BearCounter() {
  const bears = useStore((state) => state.bears)
  return <h1>{bears} around here...</h1>
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

更简洁的方式

你甚至可以更简洁

export const useBoundStore = create(() => ({
  count: 0,
  text: 'hello',
}))

export const inc = () =>
  useBoundStore.setState((state) => ({ count: state.count + 1 }))

export const setText = (text) => useBoundStore.setState({ text })

不可变状态

zustand通过create返回useStore,你可以通过useStore处理你的state,比如更新、获取、订阅、取消订阅、销毁处理。由于默认对state支持浅拷贝,如果要更新深拷贝通过{state,...newState}可能并不是一种好的方法,但是zustand可以很好的支持immer。

immerInc: () =>
    set(produce((state: State) => { ++state.deep.nested.obj.count })),

兼容redux的Api

如果你习惯了redux的方法,也可以这么做, 改造下reducer和dispatch。

const types = { increase: 'INCREASE', decrease: 'DECREASE' }

const reducer = (state, { type, by = 1 }) => {
  switch (type) {
    case types.increase:
      return { grumpiness: state.grumpiness + by }
    case types.decrease:
      return { grumpiness: state.grumpiness - by }
  }
}

const useGrumpyStore = create((set) => ({
  grumpiness: 0,
  dispatch: (args) => set((state) => reducer(state, args)),
}))

const dispatch = useGrumpyStore((state) => state.dispatch)
dispatch({ type: types.increase, by: 2 })

你甚至也可以使用中间件

import { redux } from 'zustand/middleware'

const useReduxStore = create(redux(reducer, initialState))

更方便的获取state的属性

import { StoreApi, UseBoundStore } from 'zustand'

type WithSelectors<S> = S extends { getState: () => infer T }
  ? S & { use: { [K in keyof T]: () => T[K] } }
  : never

const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
  _store: S
) => {
  let store = _store as WithSelectors<typeof _store>
  store.use = {}
  for (let k of Object.keys(store.getState())) {
    ;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
  }

  return store
}

//...
const useBearStore = createSelectors(useBearStoreBase)

// get the property
const bears = useBearStore.use.bears()

// get the action
const increase = useBearStore.use.increment()

比react-toolkit更简单的切片

export const createFishSlice = (set) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})

export const createBearSlice = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})

import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'

export const useBoundStore = create((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
}))

大量的中间件支持

react的状态库最佳实践非zustand莫属

库自带的中间件比如immer、combine处理自定义state和create合并,devtools辅助,persist缓存,redux兼容中间件,还有订阅的钩子。其他中间件docs.pmnd.rs/zustand/int…

兼容最新的react

Concurrent 模式在react18得以真正以fiber架构实现了异步。通过使用 useTransition、useDeferredValue,更新对应的 reconcile 过程变为可中断,不再会因为长时间占用主线程而阻塞渲染进程,使得页面的交互过程可以更加流畅。在我们使用诸如 redux、mobx 等第三方状态库时,如果开启了 Concurrent 模式,那么就有可能会出现状态不一致的情形,给用户带来困扰。针对这种情况, React18 提供了一个新的 hook - useSyncExternalStore给第三方状态库的接口解决这个问题。zustand就使用了use-sync-external-store的useSyncExternalStoreWithSelector的api管理状态。selector获取属性的钩子,以及是否更新的qualityFn函数

export function useStore<TState, StateSlice>(
  api: WithReact<StoreApi<TState>>,
  selector: (state: TState) => StateSlice = api.getState as any,
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getServerState || api.getState,
    selector,
    equalityFn
  )
  useDebugValue(slice)
  return slice
}

更方便的访问外部的state

const useStore = create((set, get) => ({
  sound: 'grunt',
  action: () => {
    const sound = get().sound
    // ...
  },
}))

从createState就解决了访问外部的state的问题,zustand本身不用useContext来传递react的状态,那么就不会存在渲染器上下文获取不到的情况

更简单的异步操作

const useStore = create((set) => ({
  fishies: {},
  fetch: async (pond) => {
    const response = await fetch(pond)
    set({ fishies: await response.json() })
  },
}))

再也不用redux-thunk来调用函数的中间件支持了,不管同步异步我们可以轻松处理状态

Zustand的源码实现

create创建store入口

export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
  createState ? createImpl(createState) : createImpl) as Create

create就是初始化store,简单的缓存判断是否第一次创建store, 返回的是一个useStore函数, 用于更新store状态的方法。

createImpl 初始化

const createImpl = <T>(createState: StateCreator<T, [], []>) => {
  if (
    import.meta.env?.MODE !== 'production' &&
    typeof createState !== 'function'
  ) {
    console.warn(
      "[DEPRECATED] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`."
    )
  }
  
  // 初始化的api对象,  { setState, getState, subscribe, destroy }
  const api =
    typeof createState === 'function' ? createStore(createState) : createState
   
  // 这里是create返回的useStore, selector代表找到对应state属性的钩子函数,equalityFn这里代表比对的规则,比如shallow浅拷贝
  const useBoundStore: any = (selector?: any, equalityFn?: any) =>
    useStore(api, selector, equalityFn)
   
  // 合并api
  Object.assign(useBoundStore, api)

  return useBoundStore
}

createImpl定义了基础的zustand的api,useBounodStore方法内部的useStore将store的控制权交由react更新处理,zustand这里只是包了一层传入selector,quealityFn,然后返回的是useBoundStore,也就是我们create返回的useStore

use-sync-external-store与useStore

import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector'
const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports
// ...
export function useStore<TState, StateSlice>(
  api: WithReact<StoreApi<TState>>,
  selector: (state: TState) => StateSlice = api.getState as any,
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getServerState || api.getState,
    selector,
    equalityFn
  )
  useDebugValue(slice)
  return slice
}

这里最关键的是 use-sync-external-store这个库,详情在github.com/reactwg/rea…

使用 useSyncExternalStore API 与自定义 Hook 最大的不同在于,使用 API 我们就是将状态更新如何触发重渲染的逻辑与时机交给 React 来负责。这样业务层面所提供的外部状态,在接入新的 React 渲染逻辑(如 18 之后出现的 Concurrent 模式),从而能够获得更多的框架内部优化机会,不像自定义 Hook 一定只能基于 Effect 执行逻辑从而存在优化上限。

createStore 创建或更新state

export const createStore = ((createState) =>
  createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore

这里同样做了初次缓存的处理

const createStoreImpl: CreateStoreImpl = (createState) => {
  type TState = ReturnType<typeof createState>
  type Listener = (state: TState, prevState: TState) => void
  let state: TState
  // 所有更新的方法存在一个set集合中
  const listeners: Set<Listener> = new Set()
    
  // set
  const setState: StoreApi<TState>['setState'] = (partial, replace) => {
     // 如果是函数就调用更新state
    const nextState =
      typeof partial === 'function'
        ? (partial as (state: TState) => TState)(state)
        : partial
    if (!Object.is(nextState, state)) {
      const previousState = state
      // 这里判断是要需要浅拷贝,更新state
      state =
        replace ?? typeof nextState !== 'object'
          ? (nextState as TState)
          : Object.assign({}, state, nextState)
      //执行监听器的队列
      listeners.forEach((listener) => listener(state, previousState))
    }
  }

  const getState: StoreApi<TState>['getState'] = () => state

  const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
    listeners.add(listener)
    // Unsubscribe
    return () => listeners.delete(listener)
  }

  const destroy: StoreApi<TState>['destroy'] = () => {
    if (import.meta.env?.MODE !== 'production') {
      console.warn(
        '[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected.'
      )
    }
    listeners.clear()
  }

  const api = { setState, getState, subscribe, destroy }
  state = createState(setState, getState, api)
  return api as any
}

createStoreImpl执行后默认创建state,并返回api对象,包含setState、getState、subscribe、destory方法,简单来说他做的就是更新api对象,执行createState创建state对象

setState主要根据更新值判断是否需要浅拷贝,并更新state,对于监听器的队列触发一轮。 getState得到当前的state subscribe 注册监听器函数,并返回取消订阅的函数 destory 清空监听器队列

export const useOneCountStore = create<CountState>((set, getState, api) => ({
  count: 0,
  increasePopulation: () => set((state) => {
    return { count: state.count + 1 }
  }),
  removeAllBears: () => set({ count: 0 }),
}))

我们可以从这个例子看出来,create内部的函数,就是createState。在初始化一个store的时候会执行一次。

react中全局初始化props原理

react一般通过createContext来创建上下文,返回的Context的对象属性中存在Provide提供全局的store状态。在子组件useContext可以获取全局的state。zustand只不过将这个流程封装了一遍,核心还是react的api,并初始化createStore, 并集成一些核心的zustand的api。

兼容createContext

function createContext<S extends StoreApi<unknown>>() {
  const ZustandContext = reactCreateContext<S | undefined>(undefined)
    
   // 构造Provider组件
  const Provider = ({
    createStore,
    children,
  }: {
    createStore: () => S
    children: ReactNode
  }) => {
    const storeRef = useRef<S>()

    if (!storeRef.current) {
      storeRef.current = createStore()
    }

    return createElement(
      ZustandContext.Provider,
      { value: storeRef.current },
      children
    )
  }
    
   // 支持react的useContext
  const useContextStore: UseContextStore = (
    selector?: (state: ExtractState<S>) => StateSlice,
    equalityFn?: (a: StateSlice, b: StateSlice) => boolean
  ) => {
    const store = useContext(ZustandContext)
    if (!store) {
      throw new Error(
        'Seems like you have not used zustand provider as an ancestor.'
      )
    }
    return useStore(
      store,
      selector as (state: ExtractState<S>) => StateSlice,
      equalityFn
    )
  }

  // 防止重渲染
  const useStoreApi = () => {
    const store = useContext(ZustandContext)
    if (!store) {
      throw new Error(
        'Seems like you have not used zustand provider as an ancestor.'
      )
    }
    return useMemo<WithoutCallSignature<S>>(() => ({ ...store }), [store])
  }

  return {
    Provider,
    useStore: useContextStore,
    useStoreApi,
  }
}

zustand默认兼容了react的useContext的用法,这里要注意下useStoreApi,返回的是useMemo缓存的store,因为在路由跳转等其他情况下Provider的value的对象地址会变化,在较大的应用程序大量的重渲染还是很致命的。

immer与 zustand中间件的集成原理

使用方法:

import produce from 'immer'

const useStore = create((set) => ({
  lush: { forest: { contains: { a: 'bear' } } },
  set: (fn) => set(produce(fn)),
}))

const set = useStore((state) => state.set)
set((state) => {
  state.lush.forest.contains = null
})

这里我们可以细分到对应的方法里面,控制不可变,但是同样我们可以用中间件来全局控制,而不用到处写produce

immerImpl中间件源码:

const immerImpl: ImmerImpl = (initializer) => (set, get, store) => {
  type T = ReturnType<typeof initializer>

  store.setState = (updater, replace, ...a) => {
    const nextState = (
      typeof updater === 'function' ? produce(updater as any) : updater
    ) as ((s: T) => T) | T | Partial<T>

    return set(nextState as any, replace, ...a)
  }

  return initializer(store.setState, get, store)
}

中间件执行过程:

createState
createState+中间件1号
createState+中间件1号+中间件2号

中间件的实现其实就是通过闭包,一层一层函数嵌套递归,最终将传入的状态值返回更新结果。而每一层函数就是中间件。在zustand中其实就是createState经过中间件的不断的改造,得到新的createState,我们一旦create后,执行createImpl会执行完这里的每个中间件初始化,后续每次set更新state都会进入中间件的执行过程。

所以我们可以看到immerImpl首先将上一轮的createState传入,然后将更新的set、get、store传入下一轮的createState中,注意这个过程是中间件桥接的初始化的必然过程,我们可以看到中间件内部执行了setState并返回且执行下一轮的setState,所以当我们useStore执行selector更新,其实执行了一连串的setState。

兼容redux的中间件的原理

使用方法:

import { redux } from 'zustand/middleware'
const useReduxStore = create(redux(reducer, initialState))

源码:

const reduxImpl: ReduxImpl = (reducer, initial) => (set, _get, api) => {
  type S = typeof initial
  type A = Parameters<typeof reducer>[1]
  ;(api as any).dispatch = (action: A) => {
    ;(set as NamedSet<S>)((state: S) => reducer(state, action), false, action)
    return action
  }
  ;(api as any).dispatchFromDevtools = true

  return { dispatch: (...a) => (api as any).dispatch(...a), ...initial }
}
export const redux = reduxImpl as unknown as Redux

在redux方法执行后,返回的是一个createStore, 然后在create方法中传入createStore,执行createStoreImpl(createState)创建store, 注意这里返回的是一个dispatch方法和inital的对象而不是createState。所以reduxImpl是不支持联合其他中间件的。

手写个log中间件

const log = (config) => (set, get, api) =>
  config(
    (args) => {
      console.log('  applying', args)
      set(args)
      console.log('  new state', get())
    },
    get,
    api
  )

Zustand 原理

原理简述

简单理解为create创建store,然后传入创建后的set,get,api对象,这么做的目的更方便我们处理state,然后将状态交由react控制,通过getState访问Zustand的state, 到这里为止我们获取到useStore,如果要更新state,每次需要调用useStore传入seletor函数,后续的更新操作其实一直调用的是react的状态更新的接口。

而中间件原理就是createState不断嵌套,在执行过程中做你想做的,比如修改setState,但要注意为了能执行下去你需要return新的setState。

Zustand 执行过程

接下来我们回顾下zustand的用法

import { create } from 'zustand'

// store
const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

// comp
function BearCounter() {
  const bears = useStore((state) => state.bears)
  return <h1>{bears} around here...</h1>
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

Zustand执行过程:

create
第一次执行createImpl传入createState
如果createState是函数执行createStoreImpl初始化,否则返回createState
createStoreImpl初始化会创建一次state和api对象
useBoundStore将状态交由react管理
得到useStore
通过selector更新目标值,equalityFn来确认是否要更新

总结

我们再也不用原来繁杂的用redux、react-redux、toolkit、thunk等来处理状态,当然mobx基于proxy代理也很简单,但是装饰器、类的写法以及防止重渲染逻辑处理,相比zustand更繁琐些。当然zustand的自身优势也非常明显, 比如在渲染器嵌套的情况下,canvas组件是无法获取外部的上下文等,而zustand可以解决这些特殊场景。所以以后react的状态管理用zustand吧。