wencaizhang
Open for collabs!

Zustand 应用不再头疼!用回调函数模式优雅解决 Store 循环依赖

Published onJune 09, 2025
-Views
5Minutes Read
在开发复杂的前端应用时,我们经常会遇到多个状态管理模块之间需要相互通信的场景。本文将分享一个在 React + Zustand 项目中遇到的实际问题,以及如何通过回调函数模式优雅地解决循环依赖问题。

问题背景

在我们的画布编辑器项目中,有两个核心的 Zustand store:
  1. Flow Store - 管理画布上的节点、连线等核心数据
  2. History Store - 管理撤销/重做功能的历史记录
这两个 store 之间存在紧密的协作关系:
  • Flow Store 在数据变更时需要通知 History Store 记录历史
  • History Store 在执行撤销/重做时需要更新 Flow Store 的状态

为什么要分离这两个 Store?

你可能会好奇:为什么我们不将历史记录功能直接内嵌到 Flow Store 中?这背后的设计考量主要基于以下几点:
  1. 单一职责原则:Flow Store 专注于业务逻辑,History Store 专注于历史管理
  2. 代码复用:History Store 可以被其他需要撤销/重做功能的模块复用
  3. 测试友好:分离后每个模块都可以独立测试
  4. 代码维护:避免单个文件过于庞大,提高可维护性
但是,这种分离带来了一个新的挑战:两个独立的模块如何进行数据通信?

初始设计的问题

最直观的想法是让 History Store 直接导入 Flow Store:
这种设计会导致循环依赖问题:
  • 导入
  • 导入
  • 形成循环引用,可能导致模块加载失败或运行时错误

循环依赖的危害

让我们深入理解一下循环依赖的危害:
  1. 模块加载顺序混乱:JavaScript 模块系统无法确定先加载哪个模块
  2. 运行时错误:可能出现
    引用,因为模块还没完全初始化
  3. 打包工具警告:Webpack、Vite 等工具会发出循环依赖警告
  4. 代码难以理解:循环依赖让代码的执行流程变得复杂难懂

问题的本质分析

这个问题的本质是:我们需要双向通信,但模块系统只支持单向依赖
传统的解决思路可能是:
  1. 把所有功能合并到一个 store(违背了模块化原则)
  2. 引入全局状态管理(增加了复杂性)
  3. 使用事件系统(可能过度设计)
但我们选择了一种更优雅的方案:依赖注入 + 回调函数

解决方案:回调函数模式

我们采用了回调函数模式来解决这个问题。核心思想是:让 History Store 不直接依赖 Flow Store,而是通过回调函数来获取和设置状态

设计思路解析

为了更直观地理解这一精妙的设计,让我们跳出代码,进入一个更贴近日常的场景:

🎮 游戏存档系统的比喻

想象你在玩一个角色扮演游戏:
角色设定:
  • 🎮 游戏引擎(Flow Store):管理游戏世界,处理玩家操作、怪物AI、物品系统等
  • 💾 存档系统(History Store):负责保存游戏进度,支持"读档"功能
现实需求:
  • 玩家每完成一个关键操作,存档系统要自动保存当前游戏状态
  • 玩家想要"读档"时,存档系统要能让游戏回到之前的状态
这个比喻更贴近现实,因为:
  • ✅ 现实中确实有游戏存档系统
  • ✅ 存档系统确实需要"读取"和"恢复"游戏状态
  • ✅ 游戏引擎和存档系统确实是相对独立的模块

🔄 传统方案的问题

就像存档系统直接访问游戏引擎的内存:
  • ✅ 可以随时读取游戏状态
  • ✅ 可以直接修改游戏数据
  • ❌ 但存档系统和特定游戏引擎绑死了
  • ❌ 无法为其他游戏提供存档服务
  • ❌ 游戏引擎也必须了解存档系统的实现细节

🎯 回调函数模式的解决方案

核心思想:游戏引擎提供两个标准接口(回调函数)
  1. 📊 状态读取接口(getState 回调)
    • 存档系统调用:"请告诉我当前游戏状态"
    • 游戏引擎回应:"玩家等级5,金币1000,位置(100,200)"
  2. 🔧 状态恢复接口(setState 回调)
    • 存档系统调用:"请恢复到这个游戏状态"
    • 游戏引擎执行:"好的,正在恢复..."
这样设计的妙处:
  • 🎯 存档系统变通用了:只要游戏提供这两个接口,就能为任何游戏工作
  • 🎮 游戏引擎保持独立:不需要知道存档系统的具体实现
  • 🔄 职责清晰:游戏引擎专心跑游戏,存档系统专心管存档
  • 🚫 没有循环依赖:游戏引擎知道存档系统,但存档系统不直接知道游戏引擎

技术实现的关键点

让我们分析一下这个模式的几个关键技术点:

1. 依赖方向的转换

2. 控制反转(IoC)

  • History Store 不再主动获取 Flow Store 的引用
  • 而是被动接收 Flow Store 提供的访问方法

3. 延迟绑定

  • 回调函数在运行时设置,而不是编译时
  • 确保了模块加载顺序的灵活性

1. History Store 设计

在实现之前,让我们先理解 History Store 的设计思路:
核心问题:History Store 需要做两件事:
  1. 获取当前状态用于记录历史
  2. 设置状态用于撤销/重做
解决思路
  • 定义两个回调函数类型,描述我们需要什么样的"服务"
  • 提供设置这些回调函数的接口
  • 在需要时调用这些回调函数,而不是直接操作其他 store
让我们分步骤来看代码实现:

第一步:定义游戏接口标准

第二步:准备接口注册机制

第三步:存档系统的核心功能

关键洞察: 存档系统完全不知道具体游戏的实现,它只知道有两个标准接口可以调用。

2. Flow Store 设计

Flow Store 的设计思路相对简单,但有几个关键点需要注意:
关键思路
  1. 正常实现业务逻辑:Flow Store 专注于自己的核心功能
  2. 在适当时机通知历史记录:在状态变更后调用 History Store 的方法
  3. 在模块末尾设置回调:确保 Flow Store 完全初始化后再建立连接
为什么要在文件末尾设置回调? 这是一个很重要的细节。如果在 store 创建过程中设置回调,可能会遇到:
  • Store 还没完全初始化,
    可能返回不完整的数据
  • 某些方法还没定义,调用时会出错
把回调设置放在文件末尾,确保了所有初始化都完成了。

第一步:游戏引擎的正常业务

第二步:注册游戏接口到存档系统

为什么要在最后注册接口?
  • 确保游戏引擎完全初始化后再注册接口
  • 避免存档系统调用接口时游戏还没准备好

🎬 执行流程:看看接口是怎么工作的

场景一:🎮 玩家完成了一个任务

场景二:⏪ 玩家想要读取之前的存档

🔍 关键洞察

通过这个流程,我们发现了一个巧妙的设计:
这就像现实中的API设计:不同的系统通过标准化的接口进行协作,各自专注于自己的核心功能。

方案优势

1. 解决循环依赖

  • History Store 不再直接导入 Flow Store
  • 依赖关系变成单向:Flow Store → History Store

2. 松耦合设计

  • History Store 成为一个通用的历史管理模块
  • 可以轻松适配其他类型的状态管理需求

3. 控制初始化时机

  • 回调函数在 Flow Store 完全初始化后才设置
  • 避免了初始化顺序导致的问题

4. 易于测试

  • 可以轻松 mock 回调函数进行单元测试
  • History Store 的逻辑完全独立

5. 扩展性强

  • 如果将来需要添加新的 store(比如 Selection Store),可以用同样的模式
  • History Store 可以轻松支持多个数据源的历史记录

设计模式的本质思考

这个回调函数模式实际上体现了几个重要的设计原则:

1. 依赖倒置原则(DIP)

  • 高层模块(History Store)不依赖低层模块(Flow Store)
  • 两者都依赖抽象(回调函数接口)

2. 控制反转(IoC)

  • History Store 不主动获取依赖,而是被动接收
  • 依赖的创建和注入由外部控制

3. 接口隔离原则(ISP)

  • History Store 只依赖它需要的接口(get/set 回调)
  • 不需要了解 Flow Store 的其他方法

4. 单一职责原则(SRP)

  • 每个 store 都专注于自己的核心职责
  • 通信逻辑被抽象为简单的回调接口
这些原则的应用让我们的代码更加:
  • 可维护:模块间耦合度低,修改一个模块不会影响其他模块
  • 可测试:每个模块都可以独立测试
  • 可扩展:新增功能时不需要修改现有代码
  • 可理解:职责清晰,代码逻辑简单明了

🎯 实际使用:简单得出乎意料

使用体验:
  • ✅ 开发者只需要关心业务逻辑
  • ✅ 历史记录功能完全透明
  • ✅ 撤销重做开箱即用

方案对比:为何回调函数模式脱颖而出?

在解决这个问题时,我们考虑了多种方案。让我们来对比分析一下:

方案对比表

方案优点缺点适用场景
回调函数模式解耦彻底、易测试、扩展性强需要额外的设置代码模块间需要双向通信
合并 Store实现简单、无循环依赖违背单一职责、难以复用功能简单、不需要复用
事件系统完全解耦、支持多对多通信增加复杂性、难以调试复杂的多模块通信
中间件模式统一处理、功能强大学习成本高、可能过度设计需要统一的数据处理逻辑

为什么最终选择回调函数模式?

  1. 问题匹配度高:我们的问题就是两个模块间的双向通信,回调函数模式完美解决
  2. 实现成本低:相比事件系统,回调函数模式更简单直接
  3. 维护成本低:相比中间件模式,学习和维护成本更低
  4. 扩展性好:相比合并 store,未来扩展更容易

其他可选方案详解

让我们详细了解一下其他几种方案:

1. 事件发布/订阅模式 📡

这种模式就像一个广播电台:
特点: 完全解耦,但可能在简单的双向通信场景中显得过于复杂,难以追踪数据流。

2. 合并 Store 🏢

这种方式直接将所有相关功能集成到一个大型 Store 中,就像将图书馆和档案室合并成一个大楼:
特点: 实现简单直接,但违背了单一职责原则,随着功能增多,单个 Store 会变得臃肿,难以维护和复用。

实践建议

基于我们的实践经验,这里有一些使用回调函数模式的建议:

1. 何时使用这个模式?

适合的场景:
  • 两个模块需要双向通信,并且希望保持模块的独立性。
  • 需要避免循环依赖。
  • 追求代码的可测试性。
不适合的场景:
  • 模块间关系很简单,直接合并 Store 可能更简单。
  • 需要复杂的多对多通信,事件系统可能更合适。
  • 团队对这种模式不熟悉,需要考虑学习成本。

2. 实现时的注意事项

  1. 回调函数的设置时机很重要:确保在所有初始化完成后再设置,避免获取到不完整或未定义的状态。
  2. 错误处理要完善:在使用回调函数前,务必检查它是否已经设置(例如
    ),避免运行时错误。
  3. 类型定义要清晰:使用 TypeScript 时,明确定义回调函数的参数和返回值类型,增强代码的健壮性和可读性。
  4. 文档要详细:这种模式对新人来说可能不够直观,需要好的文档或注释来解释其工作原理。

3. 测试策略 🧪

在测试时,回调函数模式的优势尤为明显,可以轻松模拟依赖的行为:
测试优势: 可以完全控制游戏接口(即 Flow Store 的行为),让测试更可靠和独立,无需启动整个应用就能测试单个模块。

总结与思考

回调函数模式是解决状态管理模块间循环依赖的一种优雅方案。它的核心价值在于:

技术层面

  • 解决了循环依赖问题:通过依赖倒置,将双向依赖转为单向依赖。
  • 提高了代码质量:松耦合、高内聚、易测试。
  • 增强了扩展性:新增模块时不需要修改现有代码。

设计层面

  • 体现了优秀的设计原则:依赖倒置、控制反转、单一职责。
  • 平衡了复杂性和灵活性:既解决了问题,又没有过度设计。
  • 提供了可复用的解决方案:这个模式可以应用到类似的场景中。

团队层面

  • 提高了代码可维护性:模块职责清晰,修改影响范围小。
  • 降低了协作成本:不同开发者可以独立开发不同的模块。
  • 积累了架构经验:为团队提供了处理类似问题的标准方案。
在选择解决方案时,建议根据项目的具体情况来权衡:
  • 简单场景:直接合并 store,降低复杂性。
  • 中等复杂度:使用回调函数模式,平衡灵活性和复杂性。
  • 高复杂度:考虑事件系统或更复杂的状态管理框架。
最重要的是,没有银弹。每种方案都有其适用场景,关键是要理解问题的本质,选择最合适的解决方案。
希望这个深入的分析能帮助你更好地理解和应用这个设计模式,在面对类似问题时能够做出明智的技术决策。
Tags:
#React
#Zustand