用mobx构建大型项目的最佳实践

9,365 阅读9分钟

下一篇:

用mobx构建大型项目的最佳实践(2)

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)
    
    //装饰器
    @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的信息之后,就可以在管理类里对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){
            this.age = age;
        }
    }
    
    //声明一个action
    class ActionA{
        //调用store方法
        setAge(age){
            this.storeA.setAge(age); //有效
        }
    }
    
    //组件内
    storeA.setAge(age)  //无效

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

  • 声明一个变量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'
    });
    
    //包装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进行项目的开发时能最大限度的保证代码格式的统一,降低项目的维护成本。
关于如何开发和维护一个大型项目是一个很大的话题,应该在约定或者强制某些规范的基础上,再根据所处的业务场景进行特定的设计才可能做好。