如何构建自己的小程序状态管理工具

1,966 阅读16分钟
原文链接: mp.weixin.qq.com

作者:途家 梁亚辉

在刚刚加入这家公司的时候,技术 Leader就和我说过一件事情,希望能够落地前端的自动化,希望我能够出一个可行方案。而此时,在公司前端团队还非常年轻,但是业务的发展导致团队规模扩大了一倍多。加上 toC业务千变万化,导致各种 bug满天飞。前端自动化测试的成本是非常高昂的,在长期加班感需求的情况下还要去顾及自动化测试的脚本开发几乎是很难实施的。如何去寻找一个低成本可行的自动化测试技术方案就是我做这个 miniredux的初衷。之所以叫 mini版。一方面是自己能力的不足还无法实现一套完整的小程序版本 redux的技术实现,另一方面以渐进迭代的原则,现满足基本需要前提下,根据后续使用情况,逐步优化。也有可能不久后 dan大神自己出了一个也说不定。

1. 互联网 toC 应用研发之痛

缺人,缺人,我们缺少高质量前端,这可能是绝大多数技术管理者的诉求。面对系统中漫天的 bug,蟑螂一样杀不尽的低级错误,是否总是那么的无能为力。虽然我们有很多测试工具以及自动化测试的库,但是我们依然会困惑于为什么做前端自动化测试实施起来这么难?

在十一期间,我开发了一个 mini版本的 Redux用于解决这个问题,并且在这周团队内部讨论后方案是可行的,也开始准备运用到项目中去验证。因此也将思路分享给大家

2. 前端应用自动化实施目前的困惑:

  • 前端的 Javacript语言是一种若类型脚本语言,而且很多错误是运行时才会被发现。因此这种特性也就导致了前端代码质量难以保证。因此有的团队会毅然决然的选择了  TS,  TS确实极大的改善了这一状况。

  • 由于现代前端对于代码分层掌握的不是很好,尤其以 Vue项目最为严重。导出都是耦合在一起的UI交互逻辑和业务逻辑。也导致了前端目前的一个困境,耦合严重。耦合就意味着每个地方的影响范围都特别大。经常会导致,明明同样的业务,这里改了,另一个地方不该改的地方也受影响了。(这一点我们不得不承认  Angular为什么会被称为企业级前端框架,它自身已经提供了自己的模块划分标准和规则,  React也有自己的  Flux架构方法去指导大家,而  Vue在这块的缺失也使它在日渐复杂的系统中产生混乱,而尤大也说了  Vuex并不适用于大型应用。也侧面反映了这一点)

  • 绝大多数年轻前端对于业务模型的设计能力不足,也导致目前项目代码中状态的混乱。虽然很多优秀的前端工程师能把 mvvm,  vdom,双向绑定的实现,单项数据流讲的头头是道,甚至是自己都可以当场给你写一套实现。但是在实践中,因为缺乏对业务数据建模的理解。经常会发生混乱。这也是很多团队实践  react -redux遇到的最大困惑

  • 一旦 UI逻辑和业务逻辑耦合,那么我们只能通过虚拟页面  DOM来进行测试,但是对于  toC业务基本上每两三个月就大变脸的  UI来说,这种自动化测试方式的开发成本是非常巨大的。但是对业务逻辑的变化其实是很小的。

因此针对以上几种问题,寻找一个方法将业务与视图层解偶,只对业务层单独进行测试,这样既可以大大降低 toC应用自动化测试开发成本,又可以极大的提高项目中业务的准确性。至于视图层,因为基于 MVVM的前端应用大多都是数据驱动的系统,因此只要业务数据模型的正确就可以极大的保证系统的健壮性。

3. 如何解偶视图层与业务逻辑层

其实模块拆分一直都是一个软件开发领域的难题,如何去解偶各个模块,虽然我们有各种方法论去指导我们去实施,但是方法论毕竟只是一个理论。真正做起来的时候会受各种因素影响,而并不是每个团队都有具备这种能力的牛人。

而在前端领域, MVC过于复杂,对于前端来说太重,而长期关注与视图的呈现,大部分人是缺乏这方面设计能力的。而 MVVM在这方面给前端提供了一个方向(Flux全文翻译):

Flux原文:

Flux eschews MVC in favor of a unidirectional data flow. When a user interacts with a React view, the view propagates an action through a central dispatcher, to the various stores that hold the application's data and business logic, which updates all of the views that are affected. This works especially well with React's declarative programming style, which allows the store to send updates without specifying how to transition views between states.

译文:

Flux 避开了 MVC,采取了单向数据流,当用户与 React 视图进行交互的时候,视图通过 dispatcher 方法传递一个 action 对象到保存数据和业务逻辑的各个存储对象区 store 中。这些存储区的数据变化会影响所有视图,并导致视图发生更新。这与 React 的编程风格有关,该风格允许通过数据的变化来改变视图,而不需要指定如何通过状态切换视图。

通过 Flux的讲解,我们可以清楚的意识到视图中,在对用户各种行为的响应中,通过派发器( dispatch)将新的业务数据以动作( action)为载体灌入用于处理和存储业务数据模型( store)中。如下图:

而在基于Flux的架构方法论基础上衍生出来的Redux就是其中被人广泛熟知的,而Vuex也随着Vue的火爆而被人广泛认识。

但是 ReduxVuex虽然在开发上起着同样的作用,但是在本质上却存在着很大的不同,这也是为什么 Vuex不适用于大型前端应用:

  • Redux是框架无关的,  Vuex需要依托于  Vue的响应式属性

  • Redux是纯原生  JS,与视图无关,这也就意味着它可以帮助我们方便的剥离业务到  Redux中。从而可以方便的复用到任何前端技术中去。而  Vuex很难做到这一点。

  • Redux强调  reduce必须是  纯函数,纯函数意味着相同的参数会导致相同的结果,也就是结果是可以预知的,从而具有非常好的可测性,这也就满足了我们对业务进行自动化测试的需求。而  Vuex是依托于修改参数引用(  mutations)的方式,并且  actions是支持异步导致了返回值的不确定性。

结合以上原因,一个小程序版本的 Redux才是我们需要的。

4. 如何构建一个小程序版本的 Redux

我相信大部分人都阅读过 Redux源码,当然我也写过一篇关于Redux源码的文章,我相信原理大家都懂,但是如何去实现一个小程序版本的 Redux的难点是我们如何实现一个类似于 react-redux的东西将 Redux结合到小程序里面来。

我们面临以下几个技术问题:

  • store存在哪?

  • 如何暴露接口?

  • 如何将 store中的数据与  Page中的数据进行响应式?

其实就是做一个发布订阅模式的实现,但是我们要保证我们 store内部的数据不能被随意修改,这样才能保证我们的业务稳定性。

我想,一提到小程序内部数据共享,大家肯定会想到 globalData。但是 globalData是依托于全局 app对象,而全局变量的影响大家是心知肚明的,不一定哪个新手给你搞坏了也是说不定的。所以也就导致了状态变化的不可跟踪。

那么如何避免使用全局变量又能解决数据存储的问题呢?答案是 ---- 沙盒模式

沙盒模式,是JS非常普遍的一个设计模式,它通过闭包的原理将数据维持在一个函数作用于中,而通过返回值内的函数引用这个函数包体内的变量的方式,形成闭包,而只有通过该函数的返回函数才能访问和修改该闭包内的数据,从而起来了数据保护的作用。

  1. function initMpState () { // mp-redux初始化函数,在这里形成一个独立作用域

  2.  const reducers = {};  // 该函数作用域内的数据

  3.  const finalState = {};  // 该函数作用域内的数据

  4.  const listeners = [];  // 该函数作用域内的数据

  5.  let injectMethod = null;  // 该函数作用域内的数据

  6.  function getStore() { // 用于访问沙盒内数据的接口

  7.    return finalState;

  8.  }

  9.  function createStore(modules, injectFunc) { // 用于初始化沙盒内数据的接口

  10.    ...

  11.  }

  12.  function dispatch(action) { // 用于操作沙盒内数据的接口

  13.    ...

  14.  }

  15.  function connect(mapStoreToState, component) { // 用于关联小程序Page对象的高级API

  16.    ...

  17.  }

  18.  return {

  19.    createStore,

  20.    dispatch,

  21.    connect,

  22.    getStore

  23.  }

  24. }

  25. module.exports = initMpState();

通过沙盒模式,我们很好的保护了我们的数据,并且提供了有限的操作手段,安全又可靠的保存了我们的业务数据模型中的数据。

5. 如何在小程序初始化我们的 store

既然需要暴露接口,又要保持这个函数内的闭包。好复杂呀。但是 commonjs在这方面起到了很好的帮助:

当我们 require一个模块的时候, commonjs会维持这个模块在一个独立的作用域中。并且一直存在。典型的应用场景就是 Nodejs

所以通过 commonjs,我们使用 module.exports将我们的 mp-redux初始化函数返回的 api集合暴露给调用方:

                                                                    
  1.   // mp-redux/index.js

  2.   function initMpState () {

  3.     ...

  4.   }

  5.  module .exports = initMpState ();

这样我们就可以在任何地方视无忌惮的搞事情了(使用 api操作 store数据).

6. 初始化业务模型

store中的数据是根据业务而来,如何保存业务模型将是我们的重点。而这些业务模型又会带有很多业务逻辑数据处理。同时,我们还要保证业务的可测性。

因此 reduxreduce方式是我们需求的绝佳选择,因此每一个 model必须是一个纯函数,它需要每次操作后都要返回一个纯对象,也就是业务数据模型。

                                                                                
  1.   /*

  2.  modules,这里参考redux,我们可以拆分很多业务模块,每个业务模块会有自己的业务模型,因此这里的modules是一个对象,而key就是业务模块的名字value就是处理业务模型的纯函数。

  3.  这里提供一injectFunc 主要是因为小程序在系统加载后就初始化,

  4.  因此我们需要劫持特定api来在这个api中同步store中数据到当前显示的页面中。为什么不写死成小程序的onShow?主要是以后考虑百度小程序,支付宝小程序。这样更灵活。

  5.  */

  6.   function createStore (modules , injectFunc ) {

  7.     if (injectFunc && typeof injectFunc === 'string') {

  8.      injectMethod = injectFunc ;

  9.     }

  10.     // 我们将用户自己定义的业务模型(model)保存到沙盒内的reducers中

  11.     if (modules && typeof modules === 'object') {

  12.       const keys = Object. keys( modules);

  13.       const len = keys .length ;

  14.       for (let i = 0; i < len; i++) {

  15.         const key = keys [i ];

  16.         if (modules .hasOwnProperty (key ) && typeof modules [key ] === 'function') {

  17.          reducers [key ] = modules [key ];

  18.         }

  19.       }

  20.     }

  21.     // 对store进行初始化

  22.    dispatch ({type : '@MPSTATE/INIT'});

  23.   }

7. 如何关联 store的数据到小程序页面中,并且进行响应式处理?

小程序会自动订阅 Page参数中的 data对象,因此我们只要在提供一个包裹函数将我们需要订阅的 store中的数据模型反映到小程序 Page函数构建需要的参数中即可。并且注入 dispatch方法,以及数据映射函数 mapStoreToState

因为每个页面只订阅自己关心的业务数据状态 ,因此我们不能把整个 store都扔给人家。所以我们需要通过 mapStoreToState来仅仅将用户需要的业务数据状态注入到页面中去。

  1.  /*

  2.   *mapStoreToState,用于用户自己将自己关注的业务数据状态订阅到自己的页面中

  3.   */

  4.  function connect(mapStoreToState, component) {

  5.    if (!component || typeof component !== 'object') {

  6.      throw new Error('mpState[connect]: Component must be a Object!');

  7.    }

  8.    if (!mapStoreToState || typeof mapStoreToState !== 'function') {

  9.      throw new Error('mpState[connect]: mapStoreToState must be a Function!');

  10.    }

  11.    // 我们需要将redux相关的函数和状态注入到用户的page定义中

  12.    const newComponent = { ...component };

  13.    // 拿到用户自己在页面定义的data,我们需要保留原来的状态

  14.    const data = component.data || {};

  15.    // 获取用户订阅的store中的状态

  16.    const extraData = mapStoreToState(finalState);

  17.    if (!extraData || typeof extraData !== 'object') {

  18.      throw new Error('mpState[connect]: mapStoreToState must return a Object!');

  19.    }

  20.    // 合并用户自己页面中的状态,和通过connect注入的store中的状态,这里我的实现有点不好

  21.    let newData = null;

  22.    if (typeof data === 'function') {

  23.      newData = {

  24.        ...data(),

  25.        ...extraData

  26.      }

  27.    } else {

  28.      newData = {

  29.        ...data,

  30.        ...extraData

  31.      }

  32.    }

  33.    // 注入到Page对象中

  34.    if (newData) {

  35.      newComponent.data = newData;

  36.    }

  37.    // 获取需要劫持的生命周期钩子,因为每个页面不一定都劫持同一个生命周期,因此提供了一个各个页面可以自定义修改劫持钩子的方法

  38.    const injectFunc = component.getInjectMethod;

  39.    const methods = component.methods || {};

  40.    const newLiftMethod = injectFunc && injectFunc() || injectMethod;

  41.    const oldLiftMethod = component[newLiftMethod];

  42.    // 注入dispatch api

  43.    methods.dispatch = dispatch;

  44.    newComponent.methods = methods;

  45.    newComponent.dispatch = dispatch;

  46.    newComponent.mapStoreToState = mapStoreToState;

  47.    //生命周期钩子劫持

  48.    if (newLiftMethod) {

  49.      newComponent[newLiftMethod] = function() {

  50.        if (this) {

  51.          // 在劫持的钩子中同步store的数据到页面

  52.          this.dispatch({});

  53.          oldLiftMethod && oldLiftMethod.call(this, arguments);

  54.        }

  55.      }

  56.    }

  57.    // 返回新的Page对象

  58.    return newComponent;

  59.  }

  1.  // 使用connect来注入需要订阅的状态,并且mp-redux会在页面对象中自动注入dispatch方法

  2.  const mpState = require('./../../mp-redux/index.js');

  3.  const util = require('../../utils/util.js');

  4.  const logActions = require('./../../action/logs.js');

  5.  Page(mpState.connect((state) => {

  6.    return {

  7.      userInfo: state.userInfo.userInfo,

  8.      logs: state.logs.logs

  9.    }

  10.  },

  11.  { // 在这里所有的业务数据都保存在store中,所以页面如果只有业务数据的话,是不需要data属性的。

  12.    clearLogs() {

  13.      this.dispatch({ // 通过dispatch方法来发出action,从而更新store中的数据

  14.        type: logActions.clearLogs

  15.      })

  16.    }

  17.  }))

8. 如何派发更新 store中的数据,并且反应到小程序的页面中来?

因为小程序的状态更新需要通过 setData这个 api,因此,我们就需要在 dispatch中通过该 api来同步 store中的数据状态

                                                                                                
  1.   /*

  2.   * 这里一定要注意,action是一个原生JS对象,而不是函数,Redux的异步是通过redux-thunk来实现的,但是我的诉求是需要让我们应用中的业务逻辑更加容易被测试,因此也就没有去提供支持,其实实现起来也很简单。可以参考我做的[vue-with-redux源码](https://github.com/ryouaki/vue-with-redux/blob/master/src/index.js)

  3.  */

  4.   function dispatch (action ) {

  5.     // debugger

  6.     const keys = Object. keys( reducers);

  7.     const len = keys .length ;

  8.     // 这个循环用于遍历model来重新计算出新的store

  9.     for (let i = 0; i < len; i++) {

  10.       const key = keys [i ];

  11.       const currentReduce = reducers [key ];

  12.       const currentState = finalState [key ];

  13.       const newState = currentReduce (currentState , action );

  14.      finalState [key ] = newState ;

  15.     }

  16.     if (this ) {

  17.       // 这里是根据组件内部的订阅规则来将新的数据模型通过setData注入到页面中

  18.       const componentState = this. mapStoreToState( finalState) || {};

  19.     // 这里提供了对react和vue的支持,因此也就导致代码多了几行,还在测试中。

  20.       if (this .setData ) { // 小程序

  21.         this. setData({ ... componentState })

  22.       } else if (this .setState ) { // react什么的吧

  23.         this. setState({ ... componentState })

  24.       } else { // VUE

  25.         const propKeys = Object. keys( componentState);

  26.         for ( let i = 0; i < propKeys. length; i++) {

  27.           this[ propKeys[ i]] = componentState[ propKeys[ i]];

  28.         }

  29.       }

  30.     }

  31.   }

其实通过上面的代码我们基本上就完成了一个简单的发布订阅了。

9. action和  model(我觉得  model比  reduce更容易理解,所以我叫  model,哈哈)

不过这里没什么好说的,都和 redux一样

                                                                                                        
  1. const actions = require( './../action/logs.js');

  2. const initState = {

  3.  logs : []

  4. }

  5. module .exports = function (state = initState , action = {}) {

  6.   const newState = { ...state };

  7.   switch (action .type ) {

  8.     case actions .addLogs :

  9.       const now = new Date();

  10.      newState .logs .push ({

  11.        time : now .getHours () + ":" + now .getMinutes () + ":" + now .getSeconds (),

  12.        value : action .data

  13.       });

  14.       return newState ;

  15.     case actions .clearLogs :

  16.      newState .logs = [];

  17.       return newState ;

  18.     default:

  19.       return newState ;

  20.   }

  21. }

实例以及源码

实例和源码

目前 log页面的代码上传失败了丢失了。下周补上。家里是 ubuntu没办法打开小程序工具。

最后

通过这个 mp-redux,实现了业务逻辑,数据与视图的分离,而业务逻辑与数据都保存在纯js代码中。方便多平台移植,而要做的只是做一个平台数据响应式的适配。

更大的好处是解决了视图与业务层耦合的痛点,并且将数据业务剥离到纯函数中,大大提高了业务代码的可测是性。

由于提供了业务数据的独立测试途径,也降低了整体的测试成本。

另外

给团队招人,途家网,地点国家会议中心,目前前端团队非常年轻,我们有很多需求是没有既有库能够满足的,所以我们有很多技术创新的机会。爱折腾的就联系我吧。

另外我在搞前端微服务的实践,而且已经成功,有兴趣的一定要联系我呀。

再者,我们技术要求不高,我不在乎什么Vue源码原理研究多深,也不在乎算法多么牛,JS用多溜,我期望那些热爱技术,喜欢专研技术,喜欢通过团队业务开发中痛点挖掘出技术创新点,提高团队整体生产效率的人加入我们 ( 这是我的观点,不代表老大是否赞同(-_-!))。