刨根问底栏目组 - 学习 Zustand 的广播哲学

在前端状态管理的学习中,我们经常听到"发布订阅模式"、"观察者模式"等术语。本文将从一次意外的"无限循环"报错开始,通过对比 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)。比如"点击保存"、"请求失败"。

  • 流程

  1. 订阅key = 'login_success',把我加到这个 key 的列表里。

  2. 查找:触发时,先通过 key 去 Map 里查表。

  3. 执行:只通知关注这个 key 的人。

2. Zustand:全员广播的"班级黑板"

Zustand 的核心数据结构是 Set

  • 结构Set<Callback>

  • 哲学单一信源(Single Source of Truth)

  • 场景:数据驱动(Data-driven)。比如"用户信息更新"、"计数器变化"。

  • 流程

  1. 订阅:不管你关心啥,先把你的名字加到名单里。

  2. 查找不需要查找

  3. 执行 :数据一变,直接遍历 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 时,幕后发生的过程如下:

  1. 组件挂载 (Mount):组件初始化。

  2. 自动连线 :React 的 useSyncExternalStore 内部会自动创建一个 forceUpdate 函数,并调用 store.subscribe(forceUpdate)

  3. 登记在册 :此时,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 发生时:

  1. Zustand :遍历 Set,触发所有组件的检查函数。

  2. React (检查函数) :运行你的 Selector s => ({ ... })

  3. 比对

  • 旧值:上一次渲染时的对象。

  • 新值 :Selector 刚刚生成的新对象

  1. 悲剧发生 :如果是引用比较,新对象 !== 旧对象,React 判定必须更新。组件重新渲染 -> 再次生成新对象 -> 再次更新 -> Loop

五、 总结:大智若愚的广播哲学

Zustand 的设计哲学可以总结为:"大喇叭通知 + 此时无声胜有声"

  • Store (发布者) :我很懒,我不知道你们谁关心 count,谁关心 name。反正数据变了,我就喊一声"变天啦!"。

  • Component (订阅者):我很勤快。听到"变天啦"之后,我先拿 Selector 看看"我看的那块云彩"变没变。如果没变,我就接着睡;如果变了,我才起床干活。

这种机制看似"粗暴",实则解耦 。Store 不再维护复杂的事件映射关系,而是把"过滤"的权力下放给了每个组件(Selector)。这就是为什么 Selector 的稳定性(如使用 shallowEqual 或缓存)如此重要的原因。

相关推荐
yxorg2 小时前
vue自动打包工程为压缩包
前端·javascript·vue.js
Bigger2 小时前
shadcn-ui 的 Radix Dialog 这两个警告到底在说什么?为什么会报?怎么修?
前端·react.js·weui
MrBread2 小时前
突破限制:vue-plugin-hiprint 富文本支持深度解析与解决方案
前端·开源
用户4099322502122 小时前
Vue3中v-if与v-for为何不能在同一元素上混用?优先级规则与改进方案是什么?
前端·vue.js·后端
与兰同馨2 小时前
【踩坑实录】一次 H5 页面在 PC 端的滚动条与轮播图修复全过程(Vue + Vant)
前端
全栈技术负责人2 小时前
前端架构演进之路——从网页到应用
前端·架构
T___T3 小时前
React Props:从基础使用到高级组件封装
前端·react.js
汉堡大王95273 小时前
React组件通信全解:父子、子父、兄弟及跨组件通信
前端·javascript·前端框架
霍理迪3 小时前
CSS继承,优先级以及字体样式
前端·css