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端在读取数据填充页面时,还需要把数据存储到页面,供前端加载时从数据恢复到storeredxucreateStore天然支持从initialState恢复数据的能力)

面对以上的种种问题,大部分人都会持有mobx不适合大型项目的观点。

解决方案

在笔者用mobx+react做了诸多中大型的前端项目之后,对这些劣势深恶痛绝,也逐渐摸索出了一些方案来解决上述的问题。

1、分层

为了解决数据定义,数据共享以及逻辑代码如何防止等问题,首先对项目结构进行分层。

  • 项目按照页面进行分割
  • 页面按照 storesactionsviews分为三层
  • stores定义页面内各个数据模型及数据的操作方法,各个 store 之间互相独立
  • views层作为视图层,接收stores注入的数据负责渲染
  • actions层处理交互逻辑,引用各个store方法调用更新数据,又mobx自动触发视图刷新

以上是一个典型的mvc分层结构,这种方式很大程度上解决了问题点 0、1、2。

2、唯一数据源

通过第一步的改造,项目的可维护性可谓上升一个台阶。

但是页面的storeaction需要手动实例化并手动注入到每个页面组件,着实是一个负担。并且store实例化自由,管理起来较为混乱。并未解决 3、4、5 的问题。

所以需要开发一个状态管理库,主要实现如下功能

  • storeaction的自动查找加载。storeaction分页面放置,通过某种机制进行查找
  • 查找到的所有storeaction自动实例化,并形成全局唯一数据源
  • store提供配置单例或多实例的配置项,减少因需求变更导致的代码改造工作量
  • 按需实例化store。比如访问页面A,只需实例化A页面依赖的store
查找机制

storeaction的查找方式简单介绍两种,一种是通过webpack提供的require.context动态的引入特定目录下的storeaction模块,第二种是通过装饰器模式进行加载。 伪代码如下

    //webpack 
    require.context('./',true,/^(.+\/)*stores\/(.+)\.(t|j)sx?$/i)
<span class="hljs-comment">//装饰器</span>
@store({
    <span class="hljs-attr">path</span>:<span class="hljs-string">'pageA.storeA'</span>, <span class="hljs-comment">//在全局store中的访问路径</span>
    type:<span class="hljs-string">'singleton'</span>|<span class="hljs-string">'multi'</span> <span class="hljs-comment">// 声明单例还是多实例</span>
})
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">StoreA</span></span>{
    
}

<span class="hljs-comment">// store装饰器的实现</span>
<span class="hljs-keyword">let</span> store = <span class="hljs-function">(<span class="hljs-params">config</span>) =&gt;</span> target =&gt; {
  target[<span class="hljs-string">'__storeType'</span>] = config.type <span class="hljs-comment">//保存</span>
  App[<span class="hljs-string">'__stores'</span>] = App[<span class="hljs-string">'__stores'</span>] || [] <span class="hljs-comment">//App为状态管理类</span>
  App[<span class="hljs-string">'__stores'</span>].push({ target, <span class="hljs-attr">path</span>: config.path})
  <span class="hljs-keyword">return</span> target;
}
复制代码

拿到所有store的信息之后,就可以在管理类里对storesactions进行处理,组装全局唯一的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需要手动指定storerootStore中所处的节点,能不能通过store文件所在的目录名、文件名、store类名等信息直接映射到对应的结构呢?

答案是可以的,只需要编写一个babel转换插件,在编译时对文件的抽象语法树进行分析替换,自动填充@storepath属性就好了。(笔者项目用的是ts,提供了一个ts transformer完成同样的功能)

(2)脚手架
  • 由于页面结构保持了高度统一,无论是store文件、action文件,或是jsxcss文件,都有或多或少的样板代码。为了开发流程的自动化,可以开发脚手架工具,自动生成页面骨架。一是为了提升开发效率,二可以规范开发流程。
  • 如果项目中用到ts的话,这种全局自动加载形成的store会丢失类型信息。所以需要自动的生成一份类型声明文件 (.d.ts) 帮助有更好的开发体验。

4、开发规范限制

最后一个话题,如何更严格的规范代码的书写方式。

即使我们限定了业务逻辑只能在action内处理,但终归是口头约定。老成员总有图便利把逻辑写到view层的时候,新成员刚加入时的代码更可能如此。

所以我们需要提供一种机制来保证只能在action内调用store的方法进行逻辑处理,而在action外的store调用都无效,并在开发环境给以警告。

这个问题如果你认为很简单,可能是因为你还没理解到这个的关键点在哪。下面通过例子来讨论解决方案。

    // 声明一个 store
    class StoreA{
        age = null;
    setAge(age){
        <span class="hljs-keyword">this</span>.age = age;
    }
}

<span class="hljs-comment">//声明一个action</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ActionA</span></span>{
    <span class="hljs-comment">//调用store方法</span>
    setAge(age){
        <span class="hljs-keyword">this</span>.storeA.setAge(age); <span class="hljs-comment">//有效</span>
    }
}

<span class="hljs-comment">//组件内</span>
storeA.setAge(age)  <span class="hljs-comment">//无效</span>
复制代码

对于上述场景,处理方法比较简单。只需要

  • 声明一个变量flag
  • 在实例化storeaction时对实例的方法分别进行包装
  • action的方法调用前设置flagtrue,执行action的方法,然后设置flagfalse
  • 这样store的方法如果在action内调用时访问到的flagtrue,在其他地方访问到的flagfalse
  • 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.jsangular框架的核心组件,angular利用zone.js监听所有(可能导致数据变化)的异步事件。

这跨度有点大,怎么又扯到了angular

没关系,重新介绍一下。zone.js描述了JavaScript执行过程的上下文,可以在异步任务之间进行持久性传递。

重点就是这句话,我翻译一下,zonejs能保持同一个方法内的调用(无论同步还是异步的)都能访问到同一个上下文对象。这不正好解决了我们的问题吗?

现在利用zonejs来解决我们之前的问题。代码如下

    // 这里并没有阐述 zone.js 如何使用,如果看过 zonejs 文档应该很容易理解下面的代码所做的事情
    const zone = Zone.root.fork({
      name: '__mobx__zone'
    });
<span class="hljs-comment">//包装action的setAge方法,使得action内的方法调用访问到Zone.current都为zone</span>
<span class="hljs-keyword">let</span> oldFn = ActionA.setAge
ActionA.setAge = <span class="hljs-function">(<span class="hljs-params">...args</span>) =&gt;</span> {
  <span class="hljs-keyword">return</span> zone.run(oldFn, context, args)
}

<span class="hljs-comment">//包装store的方法,判断Zone.current是否为zone,如果在action之外调用则为Zone.root</span>
 <span class="hljs-keyword">let</span> oldFn = StoreA.setAge
StoreA.setAge = <span class="hljs-function">(<span class="hljs-params">...args</span>) =&gt;</span> {
  <span class="hljs-keyword">if</span>(Zone.current === zone){
    <span class="hljs-keyword">return</span> oldFn.apply(context,args)
  }<span class="hljs-keyword">else</span>{
      <span class="hljs-comment">//在action外调用store方法触发警告</span>
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'invalid call'</span>)
  }
}

<span class="hljs-comment">//以上的包装方法均在内部处理,不暴露在业务代码中</span>
复制代码

利用zone.js可以很容易的实现我们想要的功能,通过粗略的源码浏览发现zone.js正是暴力的代理了原生的api

通过上述几步处理,我们就可以愉快的拿mobx进行大型项目的构建和持续迭代了。

结尾

本文并未涉及过多的代码细节,对于mobx如何使用也并未阐述。本文着重去解决在使用mobx过程中可能引发的问题,并且在规范成员的代码风格方面做了尝试,使得在用mobx进行项目的开发时能最大限度的保证代码格式的统一,降低项目的维护成本。
关于如何开发和维护一个大型项目是一个很大的话题,应该在约定或者强制某些规范的基础上,再根据所处的业务场景进行特定的设计才可能做好。

感谢    赞同    分享    收藏    关注    反对    举报    ...