核心概念:
在学习如何实现一个简易版的 Zustand 之前,我们需要先了解两个核心概念。首先是 发布-订阅模式,它允许外界订阅状态管理库的状态,当状态发生修改时,会将修改内容发布出去,从而触发订阅者的更新。其次是 React 的一个重要 Hook:useSyncExternalStore,它的主要功能是订阅外部状态,在状态更新时触发组件的重新渲染。这两个知识点是实现状态管理的基础。
发布订阅模式:
定义:
发布订阅模式是事件驱动的设计模式,其主要实现三部分: EventBus(事件总线)、Subscriber(事件订阅者)、Publisher(事件发布者)。
EventBus: 主要负责管理整个模式中的事件注册以及事件取消,和通知的行为。
Subscriber: 主要负责订阅EventBus的事件。 完成事件注册。
Publisher: 事件的发出者,通知订阅这个事件的Subscribe事件发生。
在前端中最经典的例子莫过于dom.addEventListener,为dom元素注册事件,然后在被触发的时候调用传入的回调,实现整体的更新。
实现:
上文中,我们介绍了发布-订阅模式的基本概念。接下来,我们将探讨其基本实现思路。
-
首先,我们需要创建一个事件总线(EventBus),用于存储事件监听器的集合。在事件总线中,我们设计了三个核心方法:事件订阅(subscribe)、取消事件订阅(unsubscribe)和事件发布(publish)。
- 事件订阅(subscribe) :此方法接受一个事件名称和一个回调函数,用于在事件发生时接收通知,功能类似于
addEventListener。 - 取消事件订阅(unsubscribe) :此方法的参数与事件订阅相同,但用于移除指定的订阅者,功能类似于
removeEventListener。 - 事件发布(publish) :此方法接受一个事件名称,并触发该事件类型下的所有订阅者(回调函数)。
- 事件订阅(subscribe) :此方法接受一个事件名称和一个回调函数,用于在事件发生时接收通知,功能类似于
-
其次,我们需要一个订阅者(subscriber),用于订阅事件并等待事件的触发。
-
最后,需要一个发布者(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 时,实际上就是在订阅这个状态。当状态发生变化时,组件会自动刷新。以下是这个过程的详细描述:
- 订阅状态:通过
use***StoreHook,组件订阅了某个状态。这意味着当状态发生变化时,组件会被重新渲染。 - 状态更新:当用户调用
create提供的set方法来修改状态时,这实际上是在发布一个状态更新事件。 - 触发订阅:状态更新后,所有订阅了该状态的组件都会被通知。Zustand 通过这种机制触发组件的重新渲染,从而反映最新的状态变化。
useSyncExternalStore
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
参数说明
-
subscribe:
- 类型:函数,参数是一个回调函数。
- 功能:类似于发布订阅模式中的
subscribeA或subscriberB。在useSyncExternalStore中,subscribe用于注册一个监听器(回调函数),当外部状态发生变化时,该监听器会被调用。 - 返回值:一个用于取消注册的函数。这允许在组件卸载时清理订阅。
-
getSnapshot:
- 类型:函数。
- 功能:返回当前状态的快照。这个函数的返回值用于比较新旧状态,以决定组件是否需要重新渲染。React 使用
Object.is来比较快照的变化。
-
getServerSnapshot (可选):
- 用于服务器端渲染时获取初始状态的快照。
返回值
- 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;
}
}
-
checkIfSnapshotChanged:
- 这个辅助函数用于检查外部状态(
store)是否更新。它通过比较上一个值和最新获取的快照来返回一个布尔值,指示状态是否发生了变化。
- 这个辅助函数用于检查外部状态(
-
useLayoutEffect:
- 这是一个同步的副作用函数,会在渲染之前执行。它的主要目的是确保
useSyncExternalStore内部的状态始终与外部状态保持同步,确保组件在更新前状态是最新的。
- 这是一个同步的副作用函数,会在渲染之前执行。它的主要目的是确保
-
useEffect:
- 这是一个异步的副作用函数,主要用于注册订阅回调。通过
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>
)
}
思路:
-
核心功能实现:
- 目标是实现一个
create函数,这个函数接受一个回调,返回一个钩子函数。调用这个钩子函数会返回当前状态的快照。
- 目标是实现一个
-
create 函数的参数和返回值:
- 参数: 接收一个回调函数,该回调函数提供一个
set函数用于更新外部状态,并返回最新的状态。 - 返回值: 返回一个支持传入选择器(
selector)的钩子函数。选择器用于实现细粒度的更新,只关注特定状态的变化。
- 参数: 接收一个回调函数,该回调函数提供一个
-
发布/订阅模式:
set函数不仅更新状态,还触发发布事件,通知订阅者。- 通过结合发布/订阅模式和
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>
)
}