揭秘 Zustand 的核心:发布订阅与 useSyncExternalStore

430 阅读9分钟

核心概念:

在学习如何实现一个简易版的 Zustand 之前,我们需要先了解两个核心概念。首先是 发布-订阅模式,它允许外界订阅状态管理库的状态,当状态发生修改时,会将修改内容发布出去,从而触发订阅者的更新。其次是 React 的一个重要 Hook:useSyncExternalStore,它的主要功能是订阅外部状态,在状态更新时触发组件的重新渲染。这两个知识点是实现状态管理的基础。

发布订阅模式:

定义:

发布订阅模式是事件驱动的设计模式,其主要实现三部分: EventBus(事件总线)、Subscriber(事件订阅者)、Publisher(事件发布者)。

EventBus: 主要负责管理整个模式中的事件注册以及事件取消,和通知的行为。

Subscriber: 主要负责订阅EventBus的事件。 完成事件注册。

Publisher: 事件的发出者,通知订阅这个事件的Subscribe事件发生。

在前端中最经典的例子莫过于dom.addEventListener,为dom元素注册事件,然后在被触发的时候调用传入的回调,实现整体的更新。

实现:

上文中,我们介绍了发布-订阅模式的基本概念。接下来,我们将探讨其基本实现思路。

  1. 首先,我们需要创建一个事件总线(EventBus),用于存储事件监听器的集合。在事件总线中,我们设计了三个核心方法:事件订阅(subscribe)、取消事件订阅(unsubscribe)和事件发布(publish)。

    1. 事件订阅(subscribe) :此方法接受一个事件名称和一个回调函数,用于在事件发生时接收通知,功能类似于 addEventListener
    2. 取消事件订阅(unsubscribe) :此方法的参数与事件订阅相同,但用于移除指定的订阅者,功能类似于 removeEventListener
    3. 事件发布(publish) :此方法接受一个事件名称,并触发该事件类型下的所有订阅者(回调函数)。
  2. 其次,我们需要一个订阅者(subscriber),用于订阅事件并等待事件的触发。

  3. 最后,需要一个发布者(publisher),用于触发事件并通知所有相关的订阅者。

通过以上步骤,我们可以实现一个简单而有效的发布-订阅模式。

代码:
class EventBus {

  // 初始化,通过一个对象存储整体的订阅者(回调)。
  constructor() {
    // type: Record<string, Function[]>
    this.listeners = {}
  }

  // 订阅,形如addEventListener。
  subscribe (type, callback) {
    if(this.listeners[type]) {
      this.listeners[type].push(callback)
      return
    }

    this.listeners[type] = [callback]
  }

  // 取消订阅, 形如removeEventListener。
  unSubscribe(type, callback) {
    if(!this.listeners[type])
      return

    this.listeners[type] = this.listeners[type].filter(i => i != callback)
  }

  // 事件发布, 形如用户的点击, 这里我们通过顺序运行触发它。
  publish(type, data) {
    if(!this.listeners[type])
      return
    this.listeners[type]?.map(item => item?.(data))
  }

}

// 事件总线
const eventBus = new EventBus()

// 订阅者A
function suberscriberA(data) {
  console.log(data)
}

// 订阅者B
function suberscriberB(data) {
  console.log(data)
}

// 订阅行为 」 注册
eventBus.subscribe("eventA", suberscriberA)

eventBus.subscribe("eventB", suberscriberB)

// 发布事件, 触发订阅者(回调函数)
eventBus.publish("eventA", "A状态更新事件")

eventBus.publish("eventB", "B状态更新事件")
运行结果:

zustand如何使用:

在 Zustand 中,发布订阅机制是其核心功能之一。当用户在组件中使用 use***Store 的 Hook 时,实际上就是在订阅这个状态。当状态发生变化时,组件会自动刷新。以下是这个过程的详细描述:

  1. 订阅状态:通过 use***Store Hook,组件订阅了某个状态。这意味着当状态发生变化时,组件会被重新渲染。
  2. 状态更新:当用户调用 create 提供的 set 方法来修改状态时,这实际上是在发布一个状态更新事件。
  3. 触发订阅:状态更新后,所有订阅了该状态的组件都会被通知。Zustand 通过这种机制触发组件的重新渲染,从而反映最新的状态变化。

useSyncExternalStore

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

参数说明

  1. subscribe:

    1. 类型:函数,参数是一个回调函数。
    2. 功能:类似于发布订阅模式中的 subscribeAsubscriberB。在 useSyncExternalStore 中,subscribe 用于注册一个监听器(回调函数),当外部状态发生变化时,该监听器会被调用。
    3. 返回值:一个用于取消注册的函数。这允许在组件卸载时清理订阅。
  2. getSnapshot:

    1. 类型:函数。
    2. 功能:返回当前状态的快照。这个函数的返回值用于比较新旧状态,以决定组件是否需要重新渲染。React 使用 Object.is 来比较快照的变化。
  3. getServerSnapshot (可选):

    1. 用于服务器端渲染时获取初始状态的快照。

返回值

  • snapshot: 这是 getSnapshot 函数返回的当前状态数据。组件会根据这个快照进行渲染。
发布订阅模式:

type Listener = (...rest: unknown[]) => void

// 事件中心
export class EventBus {
  
  // 存储订阅的回调
  listeners: Array<Listener>

  constructor() {
    this.listeners = []
  }
  
  // 订阅动作
  subscribe(callback: Listener) {
    this.listeners.push(callback)
    
    return () => {
      this.listeners.filter(item => item !== callback)
    }
  }

  // 触发所有订阅的回调
  publish () {
    this.listeners?.forEach(i => i())
  }

}
React实例:
import { Button } from "@mini-ui/ui"
import "@mini-ui/ui/dist/index.css"
import "./App.css"
import { useSyncExternalStore } from "react"
import { EventBus } from "./utils/subscribe"

// 外部状态,如果放在组件中,每次render都会重置。
const eventBus = new EventBus()

let store = {
  count: 1
}

// react组件
function App() {

  // 使用useSyncExternalStore完成订阅, 以及store的状态快照获取。
  const snapShot = useSyncExternalStore(
    cb => eventBus.subscribe(cb),
    () => store
  )

  // 两件事: 1. 修改外部状态的值, 2. 发布状态更新事件(触发render)
  function updateStore() {
    store = {
      count: store.count + 1
    }

    eventBus.publish() 
  }

  return (
    <div className="app">
      <div>
        { snapShot.count }
      </div>
      <Button onClick={updateStore}>点击更新页面数据</Button>
    </div>
  )
}

export default App
效果:

原理:

useSyncExternalStore点击超链接就可以跳转到github中查看其详细代码。这里我将删除掉其中的开发环境下的内容, 以及大段的英文注释,仅保留核心内容,了解整体实现的思路。

先上代码:


export function useSyncExternalStore<T>(
  subscribe: (() => void) => () => void,    // 上面的eventBus的subscribe用来存入订阅者
  getSnapshot: () => T,            // 获取外部store快照
  getServerSnapshot?: () => T,    // ssr情况, 可以忽略
): T {
  
  // 获取外部store的快照
  const value = getSnapshot(); 

  const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
  
  // 保持内部的inst与外界的值同步
  useLayoutEffect(() => {
    inst.value = value;
    inst.getSnapshot = getSnapshot;
    
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst});
    }
  }, [subscribe, value, getSnapshot]);

  // 注册订阅的回调
  useEffect(() => {
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst});
    }
    const handleStoreChange = () => {
      if (checkIfSnapshotChanged(inst)) {
        // Force a re-render.
        forceUpdate({inst});
      }
    };
    // Subscribe to the store and return a clean-up function.
    return subscribe(handleStoreChange);
  }, [subscribe]);

  useDebugValue(value);
  return value;
}

function checkIfSnapshotChanged<T>(inst: {
  value: T,
  getSnapshot: () => T,
}): boolean {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value;
  try {
    const nextValue = latestGetSnapshot();
    return !is(prevValue, nextValue);
  } catch (error) {
    return true;
  }
}
  1. checkIfSnapshotChanged:

    1. 这个辅助函数用于检查外部状态(store)是否更新。它通过比较上一个值和最新获取的快照来返回一个布尔值,指示状态是否发生了变化。
  2. useLayoutEffect:

    1. 这是一个同步的副作用函数,会在渲染之前执行。它的主要目的是确保 useSyncExternalStore 内部的状态始终与外部状态保持同步,确保组件在更新前状态是最新的。
  3. useEffect:

    1. 这是一个异步的副作用函数,主要用于注册订阅回调。通过 forceUpdate 机制,组件在外部状态变化时能够重新渲染。这个过程从外部观察来看,就是当发布事件被触发时,直接导致组件的更新。

实战:

作者实现的版本只实现了MVP版本, 相较于Zustand官方包中缺乏了一些边界处理以及细节逻辑。而且较为不同,主要还是实现思路。相信大家有了上面的铺垫,再去理解zustand官方代码也会更加轻松。

上面介绍useSyncExternalStore钩子的示例中其实已经基本实现了接入外部状态这一目的。但是并没有封装出一个好用的方法。 Zustand巧妙的API设计和实现更方便的实现接入外部状态的目标。

Zustand 用例:

下面的例子,就是zustand官网中的实际例子。虽然简单但是基本诠释了zustand的整体api。

import { create } from 'zustand'

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

function Counter() {
  const { count, inc } = useStore()
  return (
    <div>
      <span>{count}</span>
      <button onClick={inc}>one up</button>
    </div>
  )
}

思路:

  1. 核心功能实现:

    1. 目标是实现一个 create 函数,这个函数接受一个回调,返回一个钩子函数。调用这个钩子函数会返回当前状态的快照。
  2. create 函数的参数和返回值:

    1. 参数: 接收一个回调函数,该回调函数提供一个 set 函数用于更新外部状态,并返回最新的状态。
    2. 返回值: 返回一个支持传入选择器(selector)的钩子函数。选择器用于实现细粒度的更新,只关注特定状态的变化。
  3. 发布/订阅模式:

    1. set 函数不仅更新状态,还触发发布事件,通知订阅者。
    2. 通过结合发布/订阅模式和 useSyncExternalStore,实现了状态的高效管理和更新。

开发:

zustand子包初始化:

进入到目标目录,运行npm init -y将项目初始化,然后添加tsup作为打包工具, 其余的配置项基本等同前面的ui库的配置,主要就是tsup.config.ts以及package.json、pnpm-workspace.yaml, 也可以去仓库看具体的提交记录。

Vanilla

  • 在基础的 TypeScript 文件中,通过结合状态和发布/订阅模式,创建了一个基础的 API。
  • createStoreImpl 函数负责管理状态、订阅者和状态更新逻辑。
import { TCreateStore, TSubersciber, TSetStateParam } from "./type"
import { isFunction } from "./utils"

// 标准写法,增加一层Impl便于后续拓展
const createStoreImpl = <T,>(createStoreCb: TCreateStore<T>) => {

  let state: T  // 状态, 用户传入的回调的返回值
  let listener: TSubersciber[] = [] // 订阅者(回调)列表

  // 获取最新的状态
  function getSnapShot() {
    return state
  }

  /**
   * @desc 为create回调参数中传入的set值
   * - 实现状态的更新
   * - 调用发布事件,通知订阅者。
   * */ 
  function setState(partial: TSetStateParam<T>) {
    const nextState = isFunction(partial)
      ? partial(state)
      : partial

    if (!Object.is(state, nextState)) {
      state = Object.assign({}, state, nextState)

      publish()
    }

  }

  // 注册订阅信息,在react中被useSyncExternalStore调用,注册forceupdate的回调。
  function subscribe (cb: TSubersciber) {
    listener.push(cb)

    return () => {
      listener = listener?.filter(item => item !== cb)
    }
  }

  // 发布事件,如果在react中会触发组件render
  function publish() {
    listener?.forEach(i => i?.())
  }

  state = createStoreCb(setState)

  return {
    state,
    subscribe,
    setState,
    getSnapShot
  }
}

export function createStore<T>(createStoreCb: TCreateStore<T>) {
  return createStoreImpl(createStoreCb)
}

React

  • 通过 useSyncExternalStore 接入外部状态,利用选择器实现状态的细粒度更新。
  • createImpl 函数结合了基础的状态管理逻辑和 React 的特性,提供了一个更高层次的 API。
/**
 * @desc 连接发布订阅、状态、react特性
 * - 通过useSyncExternalStore接入外部状态。
 * - 通过selector实现快照获取,达到细粒度更新的效果。
 * */ 
const useStore = <T,>(
  api: ReturnType<typeof createStore<T>>,
  selector: (state: T) => any
) => {
  const slice = useSyncExternalStore(
    api.subscribe,
    () => selector(api.getSnapShot())
  )

  return slice
}

// 标准实现
const createImpl = <T,>(createStoreCb: TCreateStore<T>) => {
  const api = createStore(createStoreCb)

  const store = (selector: (state: T) => any) => useStore(api, selector)

  return Object.assign(store, api)
}

export const create = <T,>(createStoreCb: TCreateStore<T>) => {
  return createImpl(createStoreCb)
}

测试:

import { create } from "@mini/zustand";

interface ICountStore {
  count: number,
  inc: () => void
}

export const useCount = create<ICountStore>((set) => {
  return {
    count: 1,
    inc: () => {
      set(state => {
        return {
          ...state,
          count: state.count + 1
        }
      })
    }
  }
})
import { Button } from "@mini-ui/ui"
import { useCount } from "../store"

export function ZustandDemo1() {
  const count = useCount(state => state.count)
  return (
    <div>
      {count}
    </div>
  )
}

export function ZustandDemo2() {
  const inc = useCount(state => state.inc)
  return (
    <div>
      <Button onClick={inc}>
        点击增加
      </Button>
    </div>
  )
}

项目链接