侧边栏

zustand使用和源码分析

发布于 | 分类于 源码分析

React项目一大痛点就是技术选型太混乱了,没有官方统一的技术栈(当然这也是React生态丰富的原因),就拿全局状态管理来说,我就经历了reduxdvaredux-toolkitmobx等。

最近接手的项目里面使用了zustand,这是最近两年比较火的react状态管理工具,之前没接触过,本文整理一下其使用方式,在查看源码的时候发现其实现比较简短精炼,因此也一并分析一下源码。

参考

使用

Zustand来自于德语,意思是“状态”。

基础用法

定义store

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

然后就可以在组件中通过selector获取state和action

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

function Controls() {
    const bears = useStore((state) => state.bears)
    const increasePopulation = useStore((state) => state.increasePopulation)
    const removeAllBears = useStore((state) => state.removeAllBears)

    return (
        <Space size="large">
            <Button type="primary" onClick={increasePopulation}>one up</Button>
            {bears > 0 && <Button type="primary" danger onClick={removeAllBears}>clear</Button>}
        </Space>
    )
}

export default () => {
    return (
        <>
            <BearCounter />
            <Controls />
        </>
    )
}

当action触发state更新时,就会自动更新对应的组件。

zustand可以智能的收集当前组件订阅的state,只有订阅了的state发生了变化,才会触发组件的渲染,这在某些性能优化的场景下比较有用。

计算属性

zustand本身并没有计算属性的概念,但可以通过函数的形式实现计算属性的效果

js
const useStore = create((set, get) => ({
  items: [],
  totalPrice: () => get().items.reduce((sum, item) => sum + item.price, 0),
}))

然后通过函数调用获取对应的值

js
const totalPrice = useStore(state=>state.totalPrice)
totalPrice()

与Vue等计算属性的区别在于其中没有数据缓存的机制:即计算函数依赖的原始状态没有变化时,可以缓存上一次的计算结果,减少运行消耗。该机制需要由开发自己实现。

组件外使用

如果要在React组件之外使用store,比如封装的一个业务逻辑函数,那么可以使用store的getState,该方法会返回store上面的state和action

ts
function test() {
    const store = useStore.getState()
    console.log(store.bears)
    store.increasePopulation()
}

在组件内部,状态变化时会自动触发组件更新,则组件外部,如果想要监听某个状态的变化,可以通过subscribe的形式

useStore.subscribe((state) => {
  console.log('State changed:', state)
})

选择多个状态

上面这种useStore的写法比较繁琐,有较多的模版代码

js
const bears = useStore((state) => state.bears)
const increasePopulation = useStore((state) => state.increasePopulation)
const removeAllBears = useStore((state) => state.removeAllBears)

但是写成下面这种解构赋值的形式会报错

js
function Controls() {
  const { bears, increasePopulation } = useBearStore((state) => {
    return {
      bears: state.bears,
      increasePopulation: state.increasePopulation
    }
  })
  return <button onClick={increasePopulation}>one up {bears}</button>
}

在使用zustand时,当选择器函数返回的状态部分发生变化时,组件才会重新渲染。这里的关键是,zustand默认使用的是严格相等(===)来比较选择器返回的结果是否变化。

如果返回的是一个新对象,即使对象的属性值没有变化,每次调用选择器函数都会生成一个新的对象引用,导致React认为状态发生了变化,从而触发不必要的渲染。

根据文档介绍,zustand提供了useShallow的方法来选择多个状态

js
import { useShallow } from 'zustand/react/shallow'
const { bears, increasePopulation } = useStore(useShallow((state)=>{
    return {
        bears: state.bears,
        increasePopulation: state.increasePopulation
    }
}) )

在v5之前的版本,可以使用shallow这个工具方法,用于返回值的浅比较

js
import shallow from 'zustand/shallow'

const {...} = useStore(
  (state) => {...},
  shallow
)

另外一种减少选择器模版代码的方式是创建一个createSelectors,参考文档

ts
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
}

然后使用

ts
const useBearStore = createSelectors(useBearStoreBase)

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

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

固定的selector

selector是一个接收state作为参数的纯函数,因此是可以写在组件外部的

也就是说,下面这种写法

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

可以写成

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

这样可以避免频繁创建和回收内联的selector带来性能损耗(虽然比较少),参考这个issue的讨论,不过在zustand v4及之后的版本,这个优化带来的提升可以忽略,因为使用了useSyncExternalStore,不再需要一个固定的selector了。

中间件

zustand的核心API create 方法的参数,是一个接受 set、get、store 的三个参数的函数。

因此只要对这个函数进行包装,做一些修改,就可以实现一个中间件

js
function withLog(stateCreator){
    return (set, get, store)=>{
        const setWithLog = (...args)=>{
            console.log('set state',...args)
            set.call(null,...args)
        }
        // 还可以对get等进行包装
        return stateCreator(setWithLog, get, store)
    }
}
const useStore = create(withLog((set,get) => ({})))

zustand内置了一些常用的中间件,如immerreduxpersist

immer

每次set之后,zustand都会创建一个新的state,为了避免同一个引用对象导致视图更新异常,往往需要对对象进行展开和合并的操作,比较繁琐

可以使用内置的immer中间件,避免嵌套很深的对象的展开

js
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  immer((set) => ({
    user: {
      name: 'Alice',
      age: 25,
      address: { city: 'Tokyo' },
    },
    updateCity: (city) =>
      set((state) => {
        state.user.address.city = city // 直接修改 draft,无需展开
      }),
  }))

但是immer本身也有一定的性能消耗,需要根据实际业务做一些取舍。

源码分析

zustand的核心代码非常简单,就是一个工厂函数,返回一些暴露了getState、setState方法的hook函数,然后通过发布-订阅的机制在状态更新后通知视图更新

本次阅读的版本是目前最新的代码v5.0.3

目录结构

(有了AI工具之后,这些代码量比较小的仓库源码阅读非常简单)

下面是zustand 的源码结构,主要分为以下几个部分:

  1. 核心实现 :
  • vanilla.ts :最核心的状态管理实现,提供了创建 store 的基础功能
  • react.ts :React 绑定层,提供了 useStore hook 的实现
  • traditional.ts :提供了支持自定义比较函数的 store 实现
  1. 中间件 (在 middleware 目录下):
  • persist.ts :实现状态持久化
  • immer.ts :集成 Immer 以实现不可变状态更新
  • devtools.ts :集成 Redux DevTools
  • redux.ts :提供 Redux 风格的操作方式
  • subscribeWithSelector.ts :支持选择性订阅状态变化
  • combine.ts :组合多个 store
  1. 工具函数 :
  • shallow.ts :提供浅比较功能

建议按照以下顺序阅读源码:

  1. 首先阅读 vanilla.ts ,理解核心的状态管理实现:

    • createStore 函数的实现
    • 状态更新机制
    • 发布订阅模式
  2. 然后阅读 react.ts ,了解与 React 的集成:

    • useStore hook 的实现
    • React 状态同步机制
  3. 最后可以选择性地阅读中间件实现,比如:

    • persist.ts 了解状态持久化
    • devtools.ts 了解开发工具集成
    • immer.ts 了解不可变更新的实现 关键概念:
  4. Store 创建:使用闭包保存状态

  5. 状态更新:支持部分更新和完全替换

  6. 订阅机制:使用 Set 存储监听器

  7. 中间件机制:通过类型系统支持扩展

预备知识:useSyncExternalStore

在 React 18 的并发渲染中,render 可能被中断和恢复,这可能导致使用外部数据源时出现不一致。

React.useSyncExternalStore 是 React 18 引入的一个新 Hook,主要用于安全地订阅外部数据源,如 Redux、MobX 或其他非 React 状态管理库)并在外部存储更新时同步更新 React 组件

useSyncExternalStore 接受三个参数(第三个参数可选):

  1. subscribe:一个函数,用于订阅外部存储的变化。它接收一个回调函数作为参数,并在存储变化时调用该回调函数,这会导致React调用getSnapshot并在需要的时候重新渲染组件。该函数需要返回一个取消订阅的函数。
  2. getSnapshot:一个函数,用于获取当前的存储快照。它必须返回一个不可变值或缓存对象,React 会调用这个函数以获取最新的状态,如果返回的状态发生了变化,React就会重新渲染组件
  3. getServerSnapshot(可选):一个函数,用于在服务器端渲染时获取存储快照。
jsx
import React, { useSyncExternalStore } from 'react';

// 模拟一个外部存储
const store = {
  state: 0,
  listeners: new Set(),
  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  },
  setState(newState) {
    this.state = newState;
    this.listeners.forEach((listener) => listener());
  },
  getState() {
    return this.state;
  },
};

function useStore() {
  return useSyncExternalStore(
    store.subscribe.bind(store),
    store.getState.bind(store)
  );
}

然后就可以安全地使用这个外部数据

jsx
function Counter() {
  const state = useStore();
  return (
    <div>
      <p>Count: {state}</p>
      <button onClick={() => store.setState(state + 1)}>Increment</button>
    </div>
  );
}

export default Counter;

在更早期的React版本中,可以使用use-sync-external-store/shim/with-selector来实现类似的功能

vanilla.ts

src/vanilla.ts里面包含了zustand的核心实现,具体功能包括

  1. 状态管理 :

    • 使用闭包保存 state
    • 通过 Set 存储监听器,实现发布订阅模式
  2. setState 实现 :

    • 支持两种方式更新状态:直接传值或传入函数
    • 使用 Object.is 进行状态比较,避免不必要的更新
    • 支持部分更新和完全替换两种模式
  3. 订阅机制 :

    • 提供 subscribe 方法添加监听器
    • 返回取消订阅的函数
    • 状态变化时通知所有监听器
  4. 类型系统 :

    • 使用了复杂的类型系统来支持中间件机制(Mutate, StoreMutators 等)
    • 支持类型推导和类型安全
js
//src/vanilla.ts
const createStoreImpl: CreateStoreImpl = (createState) => {
  let state
  const listeners: Set<Listener> = new Set()

  const setState: StoreApi<TState>['setState'] = (partial, replace) => {
    const nextState =
      typeof partial === 'function'
        ? (partial as (state: TState) => TState)(state)
        : partial
    if (!Object.is(nextState, state)) {
      const previousState = state
      state =
        (replace ?? (typeof nextState !== 'object' || nextState === null))
          ? (nextState as TState)
          : Object.assign({}, state, nextState)
      // 通知更新
      listeners.forEach((listener) => listener(state, previousState))
    }
  }

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

  const getInitialState: StoreApi<TState>['getInitialState'] = () =>
    initialState

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

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

可以看出,整个实现非常简单,一个store就是一个普通的js对象,包含几个特殊的方法

这里的代码可以解释下面这种写法

js
function test() {
    const store = useStore.getState()
    console.log('before',store.bears)
    // 这里是异步的
    store.increasePopulation()
    // 所以这里取到的值跟上面一致
    console.log('after',store.bears)
  	// 这里可以获取到最新的值
    console.log('after',useStore.getState().bears)
}

其本质是useStore.getState()拿到的是一个state的快照,从源码可以看出,每次set之后,会返回一个新的state,因此,如果想要获取到最新的state,最好是都通过useStore.getState()获取。

react.ts

该文件是React 绑定层,提供了 useStore hook 的实现,核心代码也很简短

ts
export function useStore<TState, StateSlice>(
  api: ReadonlyStoreApi<TState>,
  selector: (state: TState) => StateSlice = identity as any,
) {
  // 订阅state的变化
  const slice = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState()),
  )
  React.useDebugValue(slice)
  return slice
}
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
  const api = createStore(createState)

  const useBoundStore: any = (selector?: any) => useStore(api, selector)

  // 可以在外部使用store相关的接口,如getState()等
  Object.assign(useBoundStore, api)

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

其内部使用了useSyncExternalStore,并且将selector(api.getState())作为当前的getSnapshot ,这样,只有对应的selector返回值发生了变化,才会触发视图的更新。这也是zustand可以作为性能优化方案的原因之一。

创建useBoundStore,可以作为一个hook调用

js
const bears = useStore(state=>state.bears)

也可以在组件外作为store的访问方法

ts
const bears = useStore().getState().bears

zustand解决的react-redux的问题

参考:

Redux 中的 “Stale Props”(过时属性)和 “Zombie Children”(僵尸子组件)是两类典型的状态管理问题,主要源于 Redux 的设计机制(如 connect 高阶组件和手动订阅管理)。

Zustand 通过更现代的 React Hooks 和订阅优化,能够有效规避这些问题。

Stale Props

当父组件传递 props 给子组件,且子组件同时通过 connect 连接到 Redux store 时,如果父组件的 props 更新而 Redux 状态未及时同步,子组件可能使用过期的 props 和 Redux 状态的混合数据,导致渲染不一致。

jsx
// 父组件传递 `id`,子组件通过 Redux 获取数据
const Parent = ({ id }) => {
  return <Child id={id} />;
};

// 子组件通过 connect 连接到 Redux
const Child = connect(
  (state, ownProps) => ({ data: state.data[ownProps.id] }) // 依赖 ownProps.id
)(({ data }) => <div>{data}</div>);

在上面的代码中,若父组件的 id 变化,但 Redux 中 state.data[newId] 尚未加载完成,子组件可能短暂显示旧 id 对应的数据。

这是因为

  • Redux 的 connect 高阶组件通过 mapStateToProps 选择性订阅 store,更新逻辑与父组件 props 更新不同步。
  • React 的渲染批次机制可能导致 Redux 状态和 props 更新的时序问题。

zombie-children

僵尸子节点专门指以下情况:

  • 首先,挂载了多个嵌套且相关联的组件,导致子组件在父组件前订阅了 store
  • 分发一个从 store 中删除数据的动作,例如删除一个 todo 项
  • 父组件将因此停止渲染该子组件
  • 但是,由于子组件先进行了订阅,它的订阅执行于父组件停止渲染它之前,当它基于 props 从 store 中读取一个值时,这个数据不复存在,并且如果读取逻辑不完善的话,则可能引发错误。

可以看出,zombie-children实际上是Stale Props中一种更特殊的场景。

重复渲染

如果connect的mapState中,每次都返回了一个新的对象,即使数据内容相同,也会导致子组件的更新,这导致redux在react中也存在一些性能优化的问题。

上面的几种问题都是由于react-redux的connect机制导致的。zustand通过selector直接获取组件需要的state,可以保证只有订阅的数据更新了才会触发组件的更新,其内部使用的react内置的useSyncExternalStore来实现

小结

zustand是最近两年比较火热的react状态管理工具,学习使用和了解其内部原理是很有必要的。

在初步使用了一下zustand,给我的感觉就像是react中的pinia一样,灵活、好用、性能优秀,待我进一步深入体验。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。