给微信小程序设计一个Vuex玩玩吧,支持数据驱动视图变化!!!

776 阅读8分钟

起因

一个想法:能否把 Vuex 搬进微信小程序项目中,管理项目的一些全局状态呢???

微信小程序并没有提供像 Vuex 或者 Redux 这种全局状态管理机制的插件。日常开发对于一些全局性的数据,虽然我们经常用 app.globalData 来解决,但 app.globalData 定义的数据并不是响应式的,无法做到改变数据来驱动页面视图的变化。

对于一个喜欢偷懒的程序猿来说,这可不能忍,下面就带你来实现它,让你在下一个项目中能偷偷懒,我们直接来开干。(^ω^)

项目准备

接下来我们就正式开始,先初始化一个微信小程序项目,在根目录下创建 store 目录,分别再创建 globalState.jsindex.jsobserver.jsproxys.js 四个文件,它们就是这次的主角了。

image.png

具体使用

完成项目的初始化工作后,我们先不着急来编码,我们先来看看最终的成果在具体项目业务中是如何使用的。

对于熟悉 Vuex 的小伙伴来说,用下面的结构来定义全局共享的数据是再熟悉不过的了,是不是倍感亲切感?(-^〇^-)

// globalState.js
export default {
  state: {
    name: '橙某人'
  },
  mutations: {
    setName(state, name) {
      state.name = name;
    }
  },
  actions: {
    asyncUpdateName({commit}, name) {
      setTimeout(() => {
        commit('setName', name)
      }, 2000)
    }
  },
  getters: {
    name: state => state.name
  }
}

我们再来瞧瞧下面的小程序代码,是不是也很熟悉的感觉?

// index.wxml
<view>名字是:{{name}}</view>
<button catchtap="setNameEvent">同步修改名称</button>
<button catchtap="asyncUpdateNameEvent">异步修改名称</button>
<button catchtap="getNameEvent">获取名称</button>
// index.js
const app = getApp()
Page(app.$store.createPage({
  globalState: ['name'], // 声明的响应数据
  onLoad() {},
  setNameEvent() {
    app.$store.commit('setName', '同步-新名字')
  },
  asyncUpdateNameEvent() {
    app.$store.dispatch('asyncUpdateName', '异步-新名字')
  },
  getNameEvent() {
    console.log(app.$store.getters.name);
  }
}))

设计思路

看完上面的使用过程,不知道你有没有了一点自己的想法思路了呢?它和 Vuex 的使用很像,但也不完全一样,下面我简单讲讲我自己的设计思路吧。

  1. 首先,我们在 Vue 中使用 Vuex 的时候,无论在那个页面,我们都能通过 this.$store 来访问它。但在小程序上,每个页面的 this 都是指向当前页面实例的,所以为了每个小程序页面也都能共享我们自己设计的 $store,我们需要把它挂载在全局对象上,也就是 appconst app = getApp())身上。具体在哪里挂载?如何挂载?后续会在代码中继续讲到。
  2. 其次,我们在 Vue 项目的页面中能直接以 {{$store.state.xxx}} 或者 {{$store.getters.xxx}} 的形式来展示全局数据。而在小程序上这略微有不同,小程序的 {{xxx}} 语法,xxx 变量必须在页面的 data 中定义。那这需要我们在页面加载前,把 $store.statethis.setData 一下给当前页面的 data ,其实就是劫持 onLoad 事件在上面做文章。当然,也并不是 $store.state 下的所有的数据在页面上都会使用到,所以可以采取声明的方式来获取需要的数据即可。
  3. 最后,在 $store 对象上挂载 commitdispatch 事件,我们就能对全局共享数据进行修改了。但是,还没完,当我们利用 commit 或者 dispatch 事件修改了全局数据后,怎么去更新视图呢?更新视图只能通过 this.setData 的形式,但现在修改数据和展示的视图二者还并没有什么关联关系存在,那怎么让视图使用的 {{xxx}} 形式和我们调用 app.commit(stateName) 形式的两者关联起来呢?其实就是让 xxxstateName 有某种关联存在。

好了,大概就这三步骤(都给自己说乱了(ー_ー)!!),下面我们就按这三步目标来编写代码康康。

源码

第一步骤

首先,我们先编写核心的 store/index.js 文件,也就是后续的 $store 对象。

// store/index.js
import Proxys from './proxys';
class Store extends Proxys {
  constructor(store) { // store 为 globalState.js 文件定义的共享数据
    const { state, mutations, actions, getters } = store;
    super({ globalState: state || {} }); // 以对象的形式可以传递后续扩展更灵活
    this.state = Object.assign({}, state);
    this.mutations = Object.assign({}, mutations);
    this.actions = Object.assign({}, actions);
    // 处理getters
    this.getters = {};
    getters && this.handleGetters(getters);
    // 把 commit 方法的 this 绑定成当前对象 Store, 防止在 actions 中执行 commit 时,  this 指向不正常的问题
    this.commit = this.commit.bind(this);
  }
  /**
   * 执行 mutations 中的方法, 同步
   * @param {String} mutationsEventName: mutations 中的事件名称
   * @param {*} value
   * @param {Function} callback: 视图后执行的回调, 也就是在 this.setData((), () => {}) 第二个参数中被调用
   */
  commit(mutationsEventName, value, callback) {
    if(!this.mutations.hasOwnProperty(mutationsEventName)) {
      return console.error('[store] unknown mutations type: ' + mutationsEventName);
    }
    // 执行在 globalState.js 文件中定义的 mutations 方法
    this.mutations[mutationsEventName](this.state, value);
  }
  /**
   * 执行 actions 中的方法, 异步
   * @param {*} actionsEventName 
   * @param {*} value 
   * @returns 默认返回一个 Promise, then 结果为 actions 中 return 的结果
   */
  dispatch(actionsEventName, value) {
    if(!this.actions.hasOwnProperty(actionsEventName)) {
      return console.error('[store] unknown actions type: ' + actionsEventName);
    }
    return new Promise((resolve, reject) => {
      try {
        let actionsResult = this.actions[actionsEventName](this, value);
        resolve(actionsResult);
        // 捕获 actions 的执行错误而已哦, actions 的异步错误可以在 actions 中借助 Promise 来实现, 也就直接调用 reject, 就能继续往下传递
      }catch(err) { 
        reject(err);
      }
    })
  }
  handleGetters(getters) {
    Object.keys(getters).map(key => {
      Object.defineProperty(this.getters, key, {
        get: () => getters[key](this.state)
      })
    })
  }
}

export default Store;

$store 对象的设计不难,主要就是先把 globalState.js(在具体使用里面有提及该文件) 文件定义的state/mutations/actions/getters 挂载起来,然后再定义它本身具有的方法的,主要是 commitdispatch 方法,具体过程代码都有详细注释,就不多说了。当然,它还继承了 Proxys 对象,这是为后续的工作做准备,可以先不管吧。

下面我们来看看 $store 是如何初始化挂载在全局对象上的。

// app.js
import Store from './store/index.js';
import globalState from './store/globalState';
App({
  onLaunch() {},
  $store: new Store(globalState)
})

挂载好 $store 对象,我们就能通过 app.$store.xxx 来访问了,这就基本完成第一步骤了。

第二步骤

第二步就是来劫持 onLoad 事件,进行 this.setData 操作了, 让页面能以 {{xxx}} 的形式去展示全局数据。 这工作主要是在 Proxys 对象上,记住!上面说过 $store 对象继承了 Proxys 对象,所以在 Proxys 上编写的方法,通过 $store.xxx 是能访问到的哦,这是基础知识了哦,我们先上代码康康。

// proxys.js
class Proxys {
  constructor({ globalState }) {
    this.globalState = globalState;
  }
  createPage(options) {
    const _this = this;
    const { globalState = [], onLoad } = options;
    // 要求 globalState 只能是字符数组的形式
    if(!(Array.isArray(globalState) && globalState.every(stateName => typeof stateName === 'string'))) {
      throw new TypeError('The globalState options type require Array of String');
    }

    // 劫持onLoad 
    options.onLoad = function(...params) {
      _this.bindWatcher(this, globalState);
      typeof onLoad === 'function' && onLoad.apply(this, params);
    }

    // 劫持onUnload
    options.onUnload = function() {
      typeof onUnload === 'function' && onUnload.apply(this);
    }

    delete options.globalState;
    return options;
  }
  /**
   * 通过onLoad这些钩子, 在页面加载的时候, 劫持 globalState 属性,初始化页面数据
   * @param {Object} instance: 页面实例 or 组件实例
   * @param {Array[string]} globalState: stateName
   */
  bindWatcher(instance, globalState) {
    const instanceData = {};
    // 通过页面定义的响应数据 globalState 去 $store.state里面找初始值
    globalState.forEach(stateName => {
      if(Object.keys(this.globalState).includes(stateName)) {
        instanceData[stateName] = this.globalState[stateName];
      }
    })
    // 把 $store.state对应的数据 this.setData 给当前页面实例的 data 身上
    instance.setData(instanceData);
  }
}

export default Proxys;

Proxys 对象做的主要工作就是通过 createPage 方法拦截到了 onLoad 事件和 globalState 属性,再通过 globalState 属性去 $store.state 身上找到对应的全局数据,进行 this.setData 操作,初始化页面的 data 数据。

// index.js
const app = getApp()
Page(app.$store.createPage({
  globalState: ['name'],
  onLoad() {},
  ...
}))

到此,我们完成了两步操作了,页面能展示和修改全局数据了。但是,现在我们去修改了全局共享的数据,视图却没有更新,这是我们要实现的最关键的第三步骤了。(敲黑板,重点,要考的)

image.png

第三步骤(重点)

改变数据 --> 更新视图,要实现这个目标,我们需要做两件事件:

  1. 知道页面使用了那些全局数据,其实就是 globalState 属性标记的那些数据。对这些数据进行一个依赖收集过程,也就给它们注册一个订阅者(watcher)身份,然后把这些订阅者都集中放到一个订阅器(Dep)中去。(其实说白了这些订阅者就是一个回调函数,函数里面做的事情就是进行 this.setData 操作而已)。
  2. 当数据改变的时候,去通知订阅器里面对应的订阅者更新视图即可,而改变 $store.state 的数据都会通过 commit 方法,我们可以从这里下手。

讲那么多,其实它是一个简单的发布订阅设计模式,如果有了解过 Vue 源码的小伙伴可能就更加熟悉了。

下面我们上代码来看看如何进行订阅者的注册和收集:

// proxys.js
import Observer from "./observer";
class Proxys extends Observer {
  constructor({ globalState }) {
    super();
    ...
  }
  createPage(options) {
    ...
    // 方便onUnload销毁操作
    const globalStateWatcher = {};
    options.onLoad = function(...params) {
      // 进行依赖收集
      _this.bindWatcher(this, globalState, globalStateWatcher);
      typeof onLoad === 'function' && onLoad.apply(this, params);
    }
    options.onUnload = function() {
      // 页面销毁, 清除依赖
      _this.unbindWatcher(globalStateWatcher);
      typeof onUnload === 'function' && onUnload.apply(this);
    }
    ...
  }
  bindWatcher(instance, globalState, globalStateWatcher) {
    const instanceData = {};
    globalState.forEach(stateName => {
      if(Object.keys(this.globalState).includes(stateName)) {
        instanceData[stateName] = this.globalState[stateName];
        // 这里的回调执行时机对应着 notify 的 emit 通知, 它借用了 observer 对象来实现更新视图
        globalStateWatcher[stateName] = (newValue, callback) => {
          instance.setData({
            [stateName]: newValue
          }, () => {
            callback && callback(newValue);
          })
        }
        // 注册订阅者
        this.on(stateName, globalStateWatcher[stateName])
      }
    })
    instance.setData(instanceData);
  }
  /**
   * 开始时我们把 globalState 的所有回调都暂时存在 globalStateWatcher 上, 
   * 现在页面卸载前, 我们要解绑对应的回调, 也就是清除 events 身上存的回调, 释放内存.
   * @param {*} globalStateWatcher 
   * @param {*} watcher
   */
   unbindWatcher(globalStateWatcher) {
    for(let key in globalStateWatcher) {
      this.off(key, globalStateWatcher[key]);
    }
  }
  /**
   * 通知视图更新
   * @param {String} stateName 
   * @param {*} newValue 
   * @param {Function} callback: 视图后执行的回调, 也就是在 this.setData((), () => {}) 第二个参数中被调用
   */
  notify(stateName, newValue, callback) {
    this.emit(stateName, newValue, callback);
  }
}

export default Proxys;

上面进行了一些代码的省略,主要做的事情是在 onLoad 事件进行订阅者注册和收集,在 onUnload 事件进行清除,然后提供 notify 方法用于后续数据改变的时候进行通知。

它还继承了一个 Observer 对象,该对象主要是用于存放订阅者,也就是订阅器(Dep),还有注册、触发和销毁订阅者的操作。

// observer.js
class Observer {
  constructor() {
    this.Deps = {};
  }
  on(WatcherName, cb) {
    !this.Deps[WatcherName] && (this.Deps[WatcherName] = []);
    this.Deps[WatcherName].push(cb);
  }
  emit(WatcherName, ...params) {
    if (this.Deps[WatcherName]) {
      this.Deps[WatcherName].forEach(cb => {
        cb.apply(this, params);
      })
    }
  }
  off(WatcherName, cb) {
    !this.Deps[WatcherName] && (this.Deps[WatcherName] = []);
    this.Deps[WatcherName].forEach((itemCb, index) => {
      itemCb === cb && this.Deps[WatcherName].splice(index, 1);
    })
  }
}

export default Observer;

Observer 对象的职责很明确,就是对订阅器进行一个 “增删查改” 的操作,它的目标是就是一个个的订阅者。

做完了订阅者的注册和收集工作后,接下来就是最后一步了,改变数据的时候去通知订阅者了。那么如何通知就是一个问题了,上面我们提供了 notify 方法来通知订阅者,该方法需要一个 stateName 参数,它是全局数据的 key 也是订阅者存放在订阅器的一个标识,所以获取它是一个关键所在。

notify(stateName, newValue, callback) { 
    this.emit(stateName, newValue, callback); 
}

我们先来看看代码:

// store/index.js
import Proxys from './proxys';
class Store extends Proxys {
  ...
  commit(mutationsEventName, value, callback) {
    if(!this.mutations.hasOwnProperty(mutationsEventName)) {
      return console.error('[store] unknown mutations type: ' + mutationsEventName);
    }
    this.mutations[mutationsEventName](this.state, value);
    // 通过 mutationsEventName 来获取到stateName, mutationsEventName 约定为 set + stateName 驼峰的形式
    let targetState = mutationsEventName.split('set')[1];
    let stateName = targetState && targetState.replace(targetState[0], targetState[0].toLowerCase());
    // 对 mutationsEventName 格式做一些提示
    if(!targetState || (targetState && !this.state.hasOwnProperty(stateName))) {
      console.warn('The mutationsEventName is required to conform to the (set + stateName)');
    }
    super.notify(stateName, value, callback);
  }
}

export default Store;

通过在 commit 方法中拦截到改变数据的操作,然后我们以约定式的形式来获取到 stateName,进而去调用 notify 方法。

当我们调用 app.commit(mutatinsEventName, value, () => {}) 去改变全局数据的时候,要想获取到stateName 的方式有很多,这里我想了四种方式,分别如下:

  1. 以约定式的方式来获取到 stateNamemutations 中方法名称的定义为 set + stateName 驼峰 的形式。这种方式最为简单。
  2. 初始化劫持的时候,保留一份 state 的初始数据,每次修改数据后,对比前后的数据,也能知道那些数据被修改了,从而获取到 stateName 。这种也比较好实现,但就是要维护好另一份 state 数据有点麻烦。
  3. 以正则的形式来匹配 mutations 方法中,内部对 state.stateName 的修改,也能获取到 stateName。这就比较复杂一点了。
  4. 最暴力的一种,直接就是不实现 mutationsactions, 直接实现一个 $store.globalUpdate(stateName, value, () = {}) 方法,每次修改就传递 stateName,然后直接调用 notify 即可。

到这里改变数据 --> 更新视图的目标就实现啦。

g.gif

具体源码

整个过程涉及 globalState.jsindex.jsobserver.jsproxys.js 四个文件,globalState.jsobserver.js 两个文件上面已经有完整源码了,下面再放一下剩下两个文件的完整源码。

// store/index.js
import Proxys from './proxys';
class Store extends Proxys {
  constructor(store) {
    const { state, mutations, actions, getters } = store;
    super({ globalState: state || {} });
    this.state = Object.assign({}, state);
    this.mutations = Object.assign({}, mutations);
    this.actions = Object.assign({}, actions);
    this.getters = {};
    getters && this.handleGetters(getters);
    this.commit = this.commit.bind(this);
  }
  handleGetters(getters) {
    Object.keys(getters).map(key => {
      Object.defineProperty(this.getters, key, {
        get: () => getters[key](this.state)
      })
    })
  }
  commit(mutationsEventName, value, callback) {
    if(!this.mutations.hasOwnProperty(mutationsEventName)) {
      return console.error('[store] unknown mutations type: ' + mutationsEventName);
    }
    this.mutations[mutationsEventName](this.state, value);
    let targetState = mutationsEventName.split('set')[1];
    let stateName = targetState && targetState.replace(targetState[0],targetState[0].toLowerCase());
    if(!targetState || (targetState && !this.state.hasOwnProperty(stateName))) {
      console.warn('The mutationsEventName is required to conform to the (set + stateName)');
    }
    super.notify(stateName, value, callback);
  }
  dispatch(actionsEventName, value) {
    if(!this.actions.hasOwnProperty(actionsEventName)) {
      return console.error('[store] unknown actions type: ' + actionsEventName);
    }
    return new Promise((resolve, reject) => {
      try {
        let actionsResult = this.actions[actionsEventName](this, value);
        resolve(actionsResult);
      }catch(err) { 
        reject(err);
      }
    })
  }
}
export default Store;
// proxys.js
import Observer from "./observer";
class Proxys extends Observer {
  constructor({ globalState }) {
    super();
    this.globalState = globalState;
  }
  createPage(options) {
    const _this = this;
    const { globalState = [], onLoad } = options;
    if(!(Array.isArray(globalState) && globalState.every(stateName => typeof stateName === 'string'))) {
      throw new TypeError('The globalState options type require Array of String');
    }
    const globalStateWatcher = {};
    options.onLoad = function(...params) {
      _this.bindWatcher(this, globalState, globalStateWatcher);
      typeof onLoad === 'function' && onLoad.apply(this, params);
    }
    options.onUnload = function() {
      _this.unbindWatcher(globalStateWatcher);
      typeof onUnload === 'function' && onUnload.apply(this);
    }
    delete options.globalState;
    return options;
  }
  bindWatcher(instance, globalState, globalStateWatcher) {
    const instanceData = {};
    globalState.forEach(stateName => {
      if(Object.keys(this.globalState).includes(stateName)) {
        instanceData[stateName] = this.globalState[stateName];
        globalStateWatcher[stateName] = (newValue, callback) => {
          instance.setData({
            [stateName]: newValue
          }, () => {
            callback && callback(newValue);
          })
        }
        this.on(stateName, globalStateWatcher[stateName])
      }
    })
    instance.setData(instanceData);
  }
  unbindWatcher(globalStateWatcher) {
    for(let key in globalStateWatcher) {
      this.off(key, globalStateWatcher[key]);
    }
  }
  notify(stateName, newValue, callback) {
    this.emit(stateName, newValue, callback);
  }
}

export default Proxys;



至此,本篇文章就写完啦,撒花撒花。当然它现在相当于一个单向数据绑定,和 Vue 的双向数据绑定还差那么一向,不过也够用啦,后面看看有需要的话小编再回来补上。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。