项目复杂业务的数据流解耦处理方案整理
目前项目中使用mobx,项目比较久了,每个Store的内容是越来越多了,逻辑也是越来越复杂,如果不梳理估计以后模块的层级会很乱。
之前整理了一些数据流管理的对比实践和最佳方案的梳理,最后写来写去感觉还是要整理一个架构层级的梳理,不然没有办法治理现在的问题:
- 单个数据流管理无限膨胀。
- 数据流Store职责不清晰,相互调用无法解耦。
- 各种数据瞎提升,没有一个清晰的约束。
- UI数据和逻辑数据耦合。
具体的层级架构图因为一些保密性的原因就不展示了,最后梳理的结果是一个非常大的工程,但是在这个思考过程中也受到了非常多的代码启发,将一些经验和体会记录在这。
下面的部分是到处抄出来的,有一些逻辑不通顺的地方八成是反复修改框架图造成的。
参考
- https://phodal.github.io/clean-frontend/
- https://cn.mobx.js.org/best/store.html
- 精读《对前端架构的理解 - 分层与抽象》
- https://www.tangshuang.net/8212.html
- 这可能是大型复杂项目下数据流的最佳实践
- https://juejin.cn/post/7141210516101759013?searchId=2025011220230401FF03ACE5F349C6457A
- https://juejin.cn/post/6844903498266443789?searchId=20250112194820475FC27215758EBC7A4F
术语介绍 & 划分逻辑
1. MVVM和MVC工程理念
MVC:
Model-View-Controller(模型-视图-控制器) 模式,这种模式可以理解为:Controller负责将Model的数据用View显示出来。它是一种单向数据流管理。
MVVM:
Model-ViewModel-View模式,MVC的进阶版,ViewModel在其中起到了双向绑定的作用,解耦了View层直接读取Model层,并且ViewModel实现了View和Model的自动同步,使开发者不必再去关心交互时的同步问题,或者直接操作DOM节点,更专注于数据逻辑的处理。
在本文中,层级结构以MVVM的分层理念作为划分基础,但是在架构中我们不会感知到一个具体的Model层级的存在。
在项目中,我们概念上的“Model”应该是一个个“Store”,它包含了数据和业务逻辑的整合。而概念中的“ViewModel”应该是框架中负责双向通知的部分,即:Mobx-React工具(负责响应式的数据通知UI更新)、Mobx中的observable和computed、action等,它们都用于Model和View之间进行数据同步和事件处理。
Model和ViewModel在实际实现中无法区分,但是在框架层级中又必须体现,尤其是viewModel这块怎么管理数据流必须要定义清楚,因此其实并不是非常严格地遵循MVVM,只是作一个大方向上的指引。
2. 模块依赖关系处理-依赖反转原则(SOLID原则)
模块依赖关系处理是我们项目中的一个难题,因为我们常常互相调用、公共调用方法。在复用层面这是不可避免的。我们知道分层可以解耦,对降低复杂度非常有效。那我们是否也可以对 Store 也进行分层,且约束他们之间互相调用?
但是分层调用,就涉及到一个问题:模块依赖关系如何处理?
稳定依赖
假设我们分层,那就会出现这样的依赖场景:
模块D被A、B、C同时依赖,修改模块D的同时将影响A、B、C三个模块,这不仅没有解耦,完全是在给模块找个麻烦。上述场景中,我们肯定希望D模块是稳定的,因为改它的成本比改子模块高多了。所以在设计之初,我们就应当遵守 稳定依赖原则,即依赖关系必须要指向更加稳定的方向。
一般来说,每个项目不稳定的原因都来自于需求变更,那么可以说更加靠近业务侧的偏向于不稳定,而远离业务侧的则偏向于稳定。
设计一个更稳定的系统,区分模块是否稳定很重要,因为模块D如果混入了不稳定的逻辑,它将为整个系统构造带来隐患。但是在开发中我们经常会发现,A、B、C当中确实存在复用却不稳定的逻辑方法。
这种时候应该怎么设计比较好呢?
依赖反转原则
SOLID原则提出了五个稳定性原则,都可以用于指导我们的代码开发和模块规划,本文章讨论的只是其中一个“依赖反转原则”。它将在我们Store模块解耦规划中发挥重要作用。
依赖反转原则的核心思想是,高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。这旨在减少模块间的耦合,使得系统更容易修改和扩展。
By GPT
对于这种情况,我们可以打破ABC和D之间的依赖关系,在中间新增一个中间的抽象模块。这时候依赖关系就变成了下图所示。
E中是A、B、C中的复用方法的抽象,A、B、C是方法的具体实现,修改需求时我们只修改具体实现,而复用的抽象逻辑是稳定不变更的。
这样一来,ABC共同依赖于E,E模块也是一个稳定的模块,所以这也符合稳定依赖原则。
说回到项目中的耦合问题,目前我们的项目中store是同层级的,因此会有下面这样的互相调用,这样就会导致单个模块的不稳定:假如原本A模块的复杂度是n,它又调用了B模块的方法,导致它的调用链条增长,A模块的复杂度是倍数上升的,2*n,再假如B又调用了C的方法,当你改动到A模块的功能时,为了确定影响范围,要了解所有的调用链条,就要去找A、B、C三个模块才能得到结果。
模块之前相互依赖导致的问题:
- 依赖链条太长。
- 影响范围不清晰,一个功能的内部涉及到多个复杂模块,功能高度聚合,以后想复用、迁移、扩展都会非常麻烦。
因此,我们希望最小层级之间的A、B是一个功能明确的单一模块。我们知道分层可以解耦,对降低复杂度非常有效。那我们是否也可以对 Store 也进行分层来约束他们之间互相调用?
对分出来的root层有如下约束: - root内的方法必须是抽象的、单例的、最小粒度的,而业务功能的具体实现和方法的组装则细化到具体的功能模块中。
- 保持root的稳定性,作为一个共有依赖模块,它的维护决定了整个项目的复杂度。
- 同层调用仍然存在调用链不明晰的风险,但是这个风险变得可控,唯独牢记一点:决不允许跨层调用。
- 父依赖root不能调用任何子模块的方法。
- 其他中心的子模块不能直接调用父级依赖方法,只能同层级调用其他功能模块中已经具体实现的功能方法。
3. 数据全局化和划分粒度
上面是Store架构的介绍,但是落实到具体的页面场景中它的划分粒度还是太“粗犷”,我们在开发时经常会有一个疑惑:有一个公共数据,我该放在哪里?是Store里,还是页面里,还是组件里;而放置后,我又该如何通信?如果不划分好这一点,架构层级将无法落实到开发实际中。
在我们的项目中目前存在如下问题:
- 只要是共享数据就提升全局,导致全局状态逻辑混乱。
- UI数据和业务逻辑杂糅。
通信
在非父子组件之间共享传递一些状态,我们会使用状态提升来解决这个问题。但是如果此时组件之间的嵌套过深,那么中间经过的组件都会帮忙传递这些无用的 props, 且如果需要传递参数或者增加 props ,都需要修改 A、B、中间组件 * n 个地方。
一些时候,只要是跨组件的数据状态,我们都会提升到全局Store中,久而久之,Store的数据越来越多,难以治理。
React 官方的指导意见:如果多个 Component 之间要发生交互, 那么数据就维护在这些 Component 的最小公约父节点上。
但是,实际开发中,就像下图,多个节点之间共有可以提取的Store很多。
太多的状态提升不利于项目管理,而且我们页面的组件数据层级也不算很深,没必要搞太多的小型Store出来。
不过这部分组件通信的共享数据如果放到全局Store中也并不合适。那么,又像上面所说,新增分层去解决这个问题,演进如下,新增了PageStore的层级。为了减少mobx全局和页面级Store之间易于混淆的地方,我们项目中的PageStore使用context去实现。
页面级的数据复杂度应该可以满足目前的要求,所以也就不再分成更细粒度的组件Store层。
数据类型与存放
上面介绍了最终的状态管理层级,那么,问题又来了:我们怎么区分一个数据应该放哪里,我想这是开发者最关心的问题。
目前我们将项目数据分为了三类型:(1就不讨论了,重点是2、3)
- 全局数据。
- 业务数据。(业务逻辑数据)
- UI数据。(UI视图数据,比如domVisible,domRef等等)
目前我们的项目Store里的数据业务数据和UI数据杂糅,看到很多最佳实践里面都推荐去具体细化数据类型,拆分视图数据和业务数据。
观察了一下,一些推荐的拆分方案一般是将数据+行为提取为一个抽象的功能模块,它是一个提供功能服务的模块,但是并不是Store。比如现在我们要控制页面上导航的高度,以往我们会放在mobx中,但是,按照UI逻辑解耦的方案,我们可以考虑将导航的整个控制功能抽象为一个功能模块,这个模块中包含如下的数据和使用方法:
- 导航的DOM节点,可以方便控制。
- 控制导航显示隐藏,和内部模块的显示隐藏。
- 控制导航高度等等。
按照如上的公共功能模块抽离,同样解决了UI层逻辑复用的问题。
上面介绍了一种解耦UI层和Store层的方案,但是在我们项目目前的实际开发中,并不打算一次性做的这么分离。高内聚和低耦合当然是好的,但是如果不划分内聚和解耦的粒度,也会带来很多的麻烦。
不打算做UI和业务数据的层级分离的原因:
- 增加了开发时的心智负担,在开发前需要做好详细设计。
- 目前我们的项目涉及到的这个层面的杂糅并不是当前项目的主要问题。
- 可以作为日后优化的保留迭代项目。
4. DDD(Domain-Driven Design)领域模型
书面化的说法:领域驱动设计(DDD)运用全域内的软件模型与其核心理念相结合的方式,进一步应对复杂数据及其综合系统分析的基础用法。
它是一种治理复杂数据耦合的架构设计理念,其实具体很复杂,不同的人理解下的 DDD 也会有所差异,它还包括了一系列如何划分领域模型的方法论,我也没有深入了解。
它提出了一种“领域模型”的概念,又可以称之为“实体(entity)”,实体可以是拥有方法的对象,也可以是数据结构和函数的集合。在我们的项目中,实体指的是应用里的业务对象,即实体封装了商城的核心业务逻辑,并且抽象成通用的实体服务。规则抽象成实体服务后,任何操作层面的改动都不会影响到这一层。
在这里介绍DDD,一是它可以作为架构分层的划分依据,二是用于指导我们的开发逻辑复用处理,帮助我们更好地维护项目。
如果一个重复逻辑在项目中反复出现,那么它就是一个可以复用的逻辑,这时候我们可以把它抽象为方法、hook等,但如果是一个复杂的逻辑、包括逻辑中存在的数据,在项目中被反复使用,我们应该怎么去维护这个数据结构呢?
是的,我们可以把它抽象为一个领域模型(实体,或者class类),其实我们项目中目前很多这种大量的“实体”服务,这些都是领域模型的一种。
抽象出来的“领域模型”是一个复用性很高、具有高度稳定性的结构组合,有了这样一个稳定的领域模型,视图层只需要实现视觉稿和组装业务逻辑,具备很强的灵活性,就好像搭积木一样,底层的领域模型不需要变动,只需要改动交互变更或视图。极大提升了开发效率和维护性。
最后形成的框架图示意
UI页面 / 组件
⬆️ ↖️
pageStore / LayoutStore ⬅️ api层 ⬅️ api防腐层 ⬅️ 后端接口
⬆️ ↙️
moduleStore ⬅️ service ↙️
————————————————————————————
⬆️ ⬆️
各种功能系统*
⬆️ ⬆️
基础config,libs,utils等```
解释说明
PageStore(ViewModel)
页面级的Store层级。
作用
- 明确当前页面的影响范围。
- 解耦UI数据和业务数据。
- 明确使用依赖,有利于按需初始化所需的ModuleStore。
- 解开ModuleStore之间的相互调用。
开发说明
- 每个页面专有的状态管理,放置于当前页面的文件夹内。
- 内部注入所需的ModuleStore,页面组件内以PageStore.Module.observabelData方式使用。
- 公共组件被多个页面使用,应该在设计公共组件时就考虑做成无状态的公共组件,所有的业务方法从外部传入,这样才能被PageStore控制。
ModuleStore
根据各个功能模块划分的Store。
数据流顺序为PageStore -调用-> ModuleStore ,不允许反向调用。
开发说明
- 业务相关的数据、方法放在Module,划分依据如下:
- 如果一个数据由接口获得,则必然是业务数据。
- 如果拿不准一个数据是不是业务数据,则建议优先放在moduleStore中,因为PageStore渲染数据时可以调用功能模块,但是功能模块需要某个数据时不能反向调用。
- 功能模块的相互调用通过RootStore,但是只允许全局类型的Store,功能模块之间的相互调用通过上层PageStore的聚合。
- 方法使用Promise便于上层控制。
废稿说明
在上面的划分逻辑中其实我有两个概念在最后真正成型的架构图中被舍弃了。
1. RootStore
上面在“稳定依赖”目录下,说过两个耦合的功能模块中提取出公共模块moduleRootStore,这个父层级的Store会被定义为抽象模块,可以被单功能依赖。
实际可能出现的问题:
- 父层级的功能抽离不出来,对开发个人的要求比较高,而且要提前设计好复用能力。
- 超复杂的模块依赖被我们亲手创建出来了(😂)。随着业务扩展,肯定会有越来越多的东西堆在这个新的父层级里,最后父层级既无法做到“稳定”,又无法做到“公共”,变成了整个项目里最复杂的部分,而且改动它将影响下面所有依赖它的子moduleStore。
- 没有办法真正地解决我们模块功能耦合的问题。
解决办法
将moduleStore的耦合功能,直接提升层级解耦,提出了PageStore的层级。PageStore去组合、调用moduleStore的功能,PageStore的使用逻辑很明确,而且不存在往里面丢垃圾的情况(毕竟它只做调度不做真正的业务功能处理)。
2. 不处理UI数据
上面的“数据类型与存放”里面,写了很长的一段话说暂时不想改UI数据的存放位置,还是放在ModuleStore里面,但是提取出PageStore之后这个问题也可以迎刃而解了。
页面 / 组件的UI处理关我功能Store什么事呢?
页面 / 组件的UI数据放到PageStore里面,如果有全局公共的就放到LayoutStore里面。