zustand,一个只有1kb大小的 react 状态管理库是如何实现的

405 阅读4分钟

image.png zustand 的维护者 Daishi 在一篇文档中写道:

A part of Zustand philosophy is being small, crazy small.

Zustand哲学的一部分是小,疯狂的小!

这也是我喜欢这个库的很大一部分原因。在 npmtrends 上,zustand 的发展势头看起来很不错,下载量已经超过了 mobx。

在 react 刚推出 hooks 的时候,有一种声音说:我们不再需要状态管理库了,用 useContext 就够了!这听起来很诱人,但很快就会发现 useContext 有两个很难绕开的问题:

  • 使用 useContext 需要由 Provider 组件包裹;
  • useContext 无法细粒度更新,当 context 下的任何数据发生变动时,使用该 context 的组件都会发生重渲染。

zustand 是如何避免了上面的问题的呢?

使用 zustand

// useStore.js
import { create } from 'zustand'

export const useStore = create((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
}))

// App.js
import { useStore } from './useStore'

const App = () => {
  const { count, inc } = useStore()
  return (
    <div>
      {count}
      <button onClick={inc}>click</button>
    </div>
  )
}

使用 zustand 很简单,我们这里只用到了一个名为 create 的来创建 store,然后像 useState 一样使用它。

代码实现

从使用上看,我们可以猜想一下 create 函数内部干了什么:create 函数返回了一个名叫 useStore 的方法。每次组件渲染时都会调用 useStore 方法。我们可以利用这个时机做依赖收集。

想象 zustand 的 store 内部维护了一个依赖集合,用于记录有哪些组件用到了这个 store。当组件初始化时,useStore 被调用,此时会向 store 的依赖集合里添加一个方法。同时告诉 store:嘿!我给你注入了一个方法,当有什么地方触发了更新(比如例子里面有地方调用了 inc),你就调用我注入的方法,我就会强制更新自己。

等等,怎么强制更新自己呢?如果你对类组件还有印象,类上有个 forceUpdate 方法,我们可以用 useReducer 模拟一个 forceUpdate 方法,像这样:

const [, forceUpdate] = useReducer((c) => c + 1, 0)

以下是我们实现的第一版代码:

const create = (createState) => {
  let state

  const listeners = new Set()

  const getState = () => state

  const setState = (partial) => {
    const newState = typeof partial === 'function' ? partial(state) : partial
    state = { ...state, ...newState }
    listeners.forEach((listener) => listener())
  }

  state = createState(setState, getState)

  return () => {
    const [, forceUpdate] = useReducer((c) => c + 1, 0)
    useEffect(() => {
      listeners.add(forceUpdate)
      return () => {
        listeners.delete(forceUpdate)
      }
    }, [])
    
    return state
  }
}

👉戳我看代码演示

cool!我们的 zustand 看起来运行良好,但是这一版代码还没有解决细粒度更新的问题,zustand 使用 selector 来控制更新粒度,比如下面的代码:

// zustand 会比较上一次的 state.count 和这一次的 state.count 是否相等,如果不相等,才重新渲染  
const count = useBearStore((state) => state.count)

改造一下我们的函数,加上 selector 功能:

  return (selector) => {
    const [, forceUpdate] = useReducer((c) => c + 1, 0)
    useEffect(() => {
      const listener = (state, prevState) => {
        if (
          !selector ||
          (selector && !Object.is(selector(state), selector(prevState)))
        ) {
          forceUpdate()
        }
      }

      listeners.add(listener)
      return () => {
        listeners.delete(listener)
      }
    }, [])

    return selector ? selector(state) : state
  }

当用户传入 selector 函数时,我们会比较两次渲染间 selector 的值是否相等。代码里用到了 Object.is 进行浅比较,zustand 还支持用户传入一个比较函数。为了代码的简洁性,我们省去这部分逻辑。

到这里,我们解决了上面使用 context 来进行状态管理遇到的两个问题。

👉戳我看代码演示

并发模式

还有一个问题需要解决,react 18 引入了并发模式。组件的渲染可以被更高优级事件打断。考虑如下组件:

const Parent = () => (
  <div>
    <Child />
    <Child />
  </div>
)

const Child = () => {
  const count = useStore(state => state.count);
  return <div>{count}</div>
}

在并发模式下,如果渲染第一个 Child 组件时,count 是 0,此时渲染被用户点击增加按钮的操作打断,接着渲染第二个 Child 组件时,count 已经变成了 1,这就会导致两个 Child 渲染结果不一致的问题。

这个现象被称为 tearing(撕裂),在这里可以看到更详细地讨论。

screenshot1.gif 一个撕裂问题的演示,来自 blog.axlight.com/posts/how-i…

如果我们像开始提到的用 context 来管理状态就不会有这个问题,react 内部帮我们处理了这个问题,但 zustand 是通过消息订阅机制实现的状态管理,react 并不知道什么时候触发了状态修改。

为了解决这个问题,react 提供了 useSyncExternalStore 钩子,基本使用如下:

const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
  • subscribe 订阅 store 并返回一个取消订阅的方法
  • getSnapshot 从 store 中读取数据快照的方法

通过 useSyncExternalStore 改写后的代码:

const create = (createState) => {
  let state
  const listeners = new Set()

  const getState = () => state

  const setState = (partial) => {
    const newState = typeof partial === 'function' ? partial(state) : partial
    const prevState = state
    state = Object.assign({}, state, newState)
    listeners.forEach((listener) => listener(state, prevState))
  }

  const subscribe = (listener) => {
    listeners.add(listener)
    return () => {
      listeners.delete(listener)
    }
  }

  state = createState(setState, getState)

  return (selector = (s) => s) => {
    useSyncExternalStoreWithSelector(subscribe, getState, getState, selector)

    return selector ? selector(state) : state
  }
}

zustand 使用了 react 官方推出的 use-sync-external-store 包来支持低版本的 react 和 selector 能力。改动后的代码虽然只有寥寥不到30行的代码,但这已经是 zustand 的大部分核心代码了。

👉戳我看代码演示

感谢阅读。