在前端状态管理的学习中,我们经常听到"发布订阅模式"、"观察者模式"等术语。本文将从一次意外的"无限循环"报错开始,通过对比 EventBus 和 Zustand 的底层差异,深入剖析 Zustand 那个简单却精妙的广播机制。
一、 引子:一次意想不到的死循环
故事始于一行看似无害的代码:
typescript
// 😱 导致报错的代码
const obj = useMyStore((s) => ({ count: s.count, text: s.text }), shallowEqual);
现象 :页面报错 Maximum update depth exceeded。
原因:
React 的 useSyncExternalStore 依赖 引用稳定性 来判断是否停止更新。
当 Selector 返回一个新对象 { ... } 时,即使内容没变,引用的内存地址也变了。React 认为状态变了 - 重新渲染 - 再次调用 selector 生成新对象 - 再次认为变了 - 死循环。
这引出了我们的第一个思考:Zustand 的通知机制到底有多"傻"?它为什么不帮我过滤掉这些没用的更新?
二、 广播的两种流派:EventBus vs Zustand
为了理解 Zustand 的行为,我们需要把它和我们熟悉的 EventBus(事件总线)做一个对比。
1. EventBus:精准投递的"广播站"
EventBus 的核心数据结构通常是 Map。
-
结构 :
Map<EventName, Array<Callback>> -
哲学 :分频道。
-
场景:动作驱动(Action-driven)。比如"点击保存"、"请求失败"。
-
流程:
-
订阅 :
key = 'login_success',把我加到这个 key 的列表里。 -
查找:触发时,先通过 key 去 Map 里查表。
-
执行:只通知关注这个 key 的人。
2. Zustand:全员广播的"班级黑板"
Zustand 的核心数据结构是 Set。
-
结构 :
Set<Callback> -
哲学 :单一信源(Single Source of Truth)。
-
场景:数据驱动(Data-driven)。比如"用户信息更新"、"计数器变化"。
-
流程:
-
订阅:不管你关心啥,先把你的名字加到名单里。
-
查找 :不需要查找。
-
执行 :数据一变,直接遍历 Set,通知所有人。
关键区别:
Map 天然贴合"频道"区分(KV结构),而 Set 天然贴合 forEach 循环(集合结构)。
三、 深入虎穴:Zustand 是如何"追加监听"的?
我们可以剥离 React,用纯 JS 还原 Zustand 在 subscribe 时做的事情。它就像一个负责的邮局。
模拟实现
js
// 1. 这是一个极其迷你的 Store
const store = {
// 这是那个名单本子 (Set)
listeners: new Set(),
// ✨重点在这里:订阅函数✨
subscribe: function(callback) {
// 动作:把你传进来的函数(联系方式),加到本子上
this.listeners.add(callback);
console.log(`✅ 成功追加一个监听!现在名单里有 ${this.listeners.size} 个人。`);
// 返回一个函数,用来取消订阅(以后再说)
return () => this.listeners.delete(callback);
},
// 假装数据变了,通知大家
setState: function() {
console.log("📢 只有一件事:数据变了!开始挨个通知...");
// 遍历 Set,执行每个函数
this.listeners.forEach(run => run());
}
};
// ==========================================
// 场景开始:两个"组件"来订阅了
// ==========================================
// 模拟组件 A(比如是页面顶部的 Header)
const componentA_Update = () => console.log(" -> 组件A收到通知:我要检查下用户名变没变");
// 模拟组件 B(比如是页面底部的 Footer)
const componentB_Update = () => console.log(" -> 组件B收到通知:我要检查下版权年份变没变");
// 动作 1:组件 A 出生了,请求订阅
store.subscribe(componentA_Update);
// 👉 结果:Set 内部现在是 { componentA_Update }
// 动作 2:组件 B 出生了,请求订阅
store.subscribe(componentB_Update);
// 👉 结果:Set 内部现在是 { componentA_Update, componentB_Update }
// ==========================================
// 动作 3:数据变了!
// ==========================================
store.setState();
当你调用 store.subscribe(fn) 时,Zustand 真的只是简单地把 fn 扔进了那个 Set 里。如果有 100 个组件订阅,Set 里就有 100 个函数。
四、 React 是何时介入的?
你可能会问:"我写组件时只用了 hooks,没写 subscribe 啊?"
答案是:Hooks 帮你做了脏活累活。
当你在组件中使用 useStore 时,幕后发生的过程如下:
-
组件挂载 (Mount):组件初始化。
-
自动连线 :React 的
useSyncExternalStore内部会自动创建一个forceUpdate函数,并调用store.subscribe(forceUpdate)。 -
登记在册 :此时,Store 的
Set里多了一个属于该组件的监听器。
useSyncExternalStore内部到底干了啥
作用:安全地将React组件链接到外部状态管理库(如Redux、Zustand、浏览器storage),解决并发渲染下的撕裂问题
ts
function useSyncExternalStore(subscribe, getSnapshot) {
const [state, setState] = useState(getSnapshot());
useEffect(() => {
const handleStoreChange = () => {
setState(getSnapshot());
};
// 1. 订阅状态变化(返回清理函数)
const unsubscribe = subscribe(handleStoreChange);
// 2. 返回清理函数(组件卸载时执行)
return unsubscribe;
}, [subscribe, getSnapshot]);
return state;
}
可见,当我们在组件内部调用useStore时,其内部的useSyncExternalStore方法已经自动为我们完成了注册和订阅的过程。
通知的过程(为什么死循环发生在这里)
当 store.setState 发生时:
-
Zustand :遍历
Set,触发所有组件的检查函数。 -
React (检查函数) :运行你的 Selector
s => ({ ... })。 -
比对:
-
旧值:上一次渲染时的对象。
-
新值 :Selector 刚刚生成的新对象。
- 悲剧发生 :如果是引用比较,
新对象 !== 旧对象,React 判定必须更新。组件重新渲染 -> 再次生成新对象 -> 再次更新 -> Loop。
五、 总结:大智若愚的广播哲学
Zustand 的设计哲学可以总结为:"大喇叭通知 + 此时无声胜有声"。
-
Store (发布者) :我很懒,我不知道你们谁关心
count,谁关心name。反正数据变了,我就喊一声"变天啦!"。 -
Component (订阅者):我很勤快。听到"变天啦"之后,我先拿 Selector 看看"我看的那块云彩"变没变。如果没变,我就接着睡;如果变了,我才起床干活。
这种机制看似"粗暴",实则解耦 。Store 不再维护复杂的事件映射关系,而是把"过滤"的权力下放给了每个组件(Selector)。这就是为什么 Selector 的稳定性(如使用 shallowEqual 或缓存)如此重要的原因。