序
mobx
是一款基于观察者模式的响应式数据管理框架,相对于redux
来说是后起之秀。
有一种观点认为mobx
不适合构建大型项目,这源于mobx
过于灵活的特点。灵活即意味着随意,这在开发日益复杂的大型项目是致命的弱点。redux
则不然,它的唯一数据源、reducer
纯函数、只能通过dispatch
修改状态等几个特性保证了代码书写格式的高度统一。
本文不会讨论mobx
的使用细节,只会在充分利用mobx
优势的基础上,对开发格式进行统一,保证开发大型项目的可维护性。
mobx
的优势极其优秀,面向对象编程、响应式编程、mutable
的数据处理方式、精准更新组件的能力,这里不过多讨论。
mobx劣势
- 0、数据可随处定义。可以定义在组件内,来替代
state
的作用;也可以定义在单独的store
内 - 1、用户交互逻辑可以写在组件声明的方法内,也可以写在
store
声明的方法内。 - 2、用户交互往往涉及多个
store
的数据处理,store
间可能形成交叉引用的网状结构。 - 3、
store
往往按页面和模块划分,散落在各处,不好统一管理。 - 4、
store
实例化的时机和方式不可控。 - 5、当单例
store
因为业务变更需要支持多实例时,改造难度极大 - 6、对服务端渲染不友好。
node
端在读取数据填充页面时,还需要把数据存储到页面,供前端加载时从数据恢复到store
(redxu
的createStore
天然支持从initialState
恢复数据的能力)
面对以上的种种问题,大部分人都会持有mobx
不适合大型项目的观点。
解决方案
在笔者用mobx
+react
做了诸多中大型的前端项目之后,对这些劣势深恶痛绝,也逐渐摸索出了一些方案来解决上述的问题。
1、分层
为了解决数据定义,数据共享以及逻辑代码如何防止等问题,首先对项目结构进行分层。
- 项目按照页面进行分割
- 页面按照
stores
、actions
、views
分为三层 stores
定义页面内各个数据模型及数据的操作方法,各个store之间互相独立views
层作为视图层,接收stores
注入的数据负责渲染actions
层处理交互逻辑,引用各个store
方法调用更新数据,又mobx
自动触发视图刷新
以上是一个典型的mvc
分层结构,这种方式很大程度上解决了问题点0、1、2。
2、唯一数据源
通过第一步的改造,项目的可维护性可谓上升一个台阶。
但是页面的store
和action
需要手动实例化并手动注入到每个页面组件,着实是一个负担。并且store
实例化自由,管理起来较为混乱。并未解决3、4、5的问题。
所以需要开发一个状态管理库,主要实现如下功能
store
和action
的自动查找加载。store
和action
分页面放置,通过某种机制进行查找- 查找到的所有
store
和action
自动实例化,并形成全局唯一数据源 store
提供配置单例或多实例的配置项,减少因需求变更导致的代码改造工作量- 按需实例化
store
。比如访问页面A
,只需实例化A
页面依赖的store
查找机制
store
和action
的查找方式简单介绍两种,一种是通过webpack
提供的require.context
动态的引入特定目录下的store
和action
模块,第二种是通过装饰器模式进行加载。 伪代码如下
//webpack
require.context('./',true,/^(.+\/)*stores\/(.+)\.(t|j)sx?$/i)
//装饰器
@store({
path:'pageA.storeA', //在全局store中的访问路径
type:'singleton'|'multi' // 声明单例还是多实例
})
class StoreA{
}
// store装饰器的实现
let store = (config) => target => {
target['__storeType'] = config.type //保存
App['__stores'] = App['__stores'] || [] //App为状态管理类
App['__stores'].push({ target, path: config.path})
return target;
}
复制代码
拿到所有store
的信息之后,就可以在管理类里对stores
和actions
进行处理,组装全局唯一的rootStore
了,action
处理也是一样。
按需实例化
如果为了追求性能,可以考虑实现这么一个特性。实现方式可以用访问器属性,在访问到store
属性时,再进行动态的实例化。伪代码如下
Object.defineProperty(rootAction, 'storeA', {
configurable: true,
enumerable: true,
get() {
StoreA['__instance'] = StoreA['__instance'] || new StoreA()
return StoreA['__instance']
},
set() {
throw Error("can not set store")
}
})
复制代码
通过这么一个状态管理库,我们解决了3、4、5,对于问题6 服务端渲染,也可以通过简单的处理对rootStore
进行恢复。
3、开发体验优化
(1)path自动声明
上面的装饰器@store
需要手动指定store
在rootStore
中所处的节点,能不能通过store
文件所在的目录名、文件名、store
类名等信息直接映射到对应的结构呢?
答案是可以的,只需要编写一个babel
转换插件,在编译时对文件的抽象语法树进行分析替换,自动填充@store
的path
属性就好了。(笔者项目用的是ts
,提供了一个ts transformer
完成同样的功能)
(2)脚手架
- 由于页面结构保持了高度统一,无论是
store
文件、action
文件,或是jsx
、css
文件,都有或多或少的样板代码。为了开发流程的自动化,可以开发脚手架工具,自动生成页面骨架。一是为了提升开发效率,二可以规范开发流程。 - 如果项目中用到
ts
的话,这种全局自动加载形成的store
会丢失类型信息。所以需要自动的生成一份类型声明文件(.d.ts
)帮助有更好的开发体验。
4、开发规范限制
最后一个话题,如何更严格的规范代码的书写方式。
即使我们限定了业务逻辑只能在action
内处理,但终归是口头约定。老成员总有图便利把逻辑写到view
层的时候,新成员刚加入时的代码更可能如此。
所以我们需要提供一种机制来保证只能在action
内调用store
的方法进行逻辑处理,而在action
外的store
调用都无效,并在开发环境给以警告。
这个问题如果你认为很简单,可能是因为你还没理解到这个的关键点在哪。下面通过例子来讨论解决方案。
//声明一个store
class StoreA{
age = null;
setAge(age){
this.age = age;
}
}
//声明一个action
class ActionA{
//调用store方法
setAge(age){
this.storeA.setAge(age); //有效
}
}
//组件内
storeA.setAge(age) //无效
复制代码
对于上述场景,处理方法比较简单。只需要
- 声明一个变量
flag
- 在实例化
store
和action
时对实例的方法分别进行包装 action
的方法调用前设置flag
为true
,执行action
的方法,然后设置flag
为false
。- 这样
store
的方法如果在action
内调用时访问到的flag
为true
,在其他地方访问到的flag
为false
。 - 对
store
方法的包装比较简单,判断flag
,为true
执行数据操作,为false
进行友好提示
经过上述几步,就完成了同步场景的限制处理。
但实际的项目中大量的存在异步操作,如果action
如下所示,会如何呢?
class ActionA{
//调用store方法
async setAge(age){
await saveAge(url); //接口调用
this.storeA.setAge(age); //有效
}
}
复制代码
这时storeA.setAge
虽然处于action
内,但访问到的flag
却是false
,方案失效了。
对同步操作的处理如此简单,异步操作却是一个巨大的难题。现在的课题可以抽象为如下描述
如何实现在同一个方法内的调用(包括同步操作, setTimeout、promise、rAF、各种事件等异步操作的回调内...)都能访问到同一个上下文(true),而在这个方法外访问到的是另一个(false)
复制代码
内心隐隐约约有一个答案,如果在action
调用时保存这个上下文,并在各种异步的回调里再取出这个上下文即可实现功能。但这是一个可怕的事情,意味着需要我们去代理所有的异步调用,换句话说我们需要覆盖原生的方法来做这么一件事情!
这似乎是很难去实现的,直到我发现了zone.js
。
zone.js
简单介绍一下,zone.js
是angular
框架的核心组件,angular
利用zone.js
监听所有(可能导致数据变化)的异步事件。
这跨度有点大,怎么又扯到了angular
。
没关系,重新介绍一下。zone.js
描述了JavaScript
执行过程的上下文,可以在异步任务之间进行持久性传递。
重点就是这句话,我翻译一下,zonejs
能保持同一个方法内的调用(无论同步还是异步的)都能访问到同一个上下文对象。这不正好解决了我们的问题吗?
现在利用zonejs
来解决我们之前的问题。代码如下
//这里并没有阐述zone.js如何使用,如果看过zonejs文档应该很容易理解下面的代码所做的事情
const zone = Zone.root.fork({
name: '__mobx__zone'
});
//包装action的setAge方法,使得action内的方法调用访问到Zone.current都为zone
let oldFn = ActionA.setAge
ActionA.setAge = (...args) => {
return zone.run(oldFn, context, args)
}
//包装store的方法,判断Zone.current是否为zone,如果在action之外调用则为Zone.root
let oldFn = StoreA.setAge
StoreA.setAge = (...args) => {
if(Zone.current === zone){
return oldFn.apply(context,args)
}else{
//在action外调用store方法触发警告
console.error('invalid call')
}
}
//以上的包装方法均在内部处理,不暴露在业务代码中
复制代码
利用zone.js
可以很容易的实现我们想要的功能,通过粗略的源码浏览发现zone.js
正是暴力的代理了原生的api
。
通过上述几步处理,我们就可以愉快的拿mobx
进行大型项目的构建和持续迭代了。
结尾
本文并未涉及过多的代码细节,对于mobx
如何使用也并未阐述。本文着重去解决在使用mobx
过程中可能引发的问题,并且在规范成员的代码风格方面做了尝试,使得在用mobx
进行项目的开发时能最大限度的保证代码格式的统一,降低项目的维护成本。
关于如何开发和维护一个大型项目是一个很大的话题,应该在约定或者强制某些规范的基础上,再根据所处的业务场景进行特定的设计才可能做好。