起因
一个想法:能否把 Vuex
搬进微信小程序项目中,管理项目的一些全局状态呢???
微信小程序并没有提供像 Vuex
或者 Redux
这种全局状态管理机制的插件。日常开发对于一些全局性的数据,虽然我们经常用 app.globalData
来解决,但 app.globalData
定义的数据并不是响应式的,无法做到改变数据来驱动页面视图的变化。
对于一个喜欢偷懒的程序猿来说,这可不能忍,下面就带你来实现它,让你在下一个项目中能偷偷懒,我们直接来开干。(^ω^)
项目准备
接下来我们就正式开始,先初始化一个微信小程序项目,在根目录下创建 store
目录,分别再创建 globalState.js
、index.js
、observer.js
、proxys.js
四个文件,它们就是这次的主角了。
具体使用
完成项目的初始化工作后,我们先不着急来编码,我们先来看看最终的成果在具体项目业务中是如何使用的。
对于熟悉 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
的使用很像,但也不完全一样,下面我简单讲讲我自己的设计思路吧。
- 首先,我们在
Vue
中使用Vuex
的时候,无论在那个页面,我们都能通过this.$store
来访问它。但在小程序上,每个页面的this
都是指向当前页面实例的,所以为了每个小程序页面也都能共享我们自己设计的$store
,我们需要把它挂载在全局对象上,也就是app
(const app = getApp()
)身上。具体在哪里挂载?如何挂载?后续会在代码中继续讲到。 - 其次,我们在
Vue
项目的页面中能直接以{{$store.state.xxx}}
或者{{$store.getters.xxx}}
的形式来展示全局数据。而在小程序上这略微有不同,小程序的{{xxx}}
语法,xxx
变量必须在页面的data
中定义。那这需要我们在页面加载前,把$store.state
给this.setData
一下给当前页面的data
,其实就是劫持onLoad
事件在上面做文章。当然,也并不是$store.state
下的所有的数据在页面上都会使用到,所以可以采取声明的方式来获取需要的数据即可。 - 最后,在
$store
对象上挂载commit
和dispatch
事件,我们就能对全局共享数据进行修改了。但是,还没完,当我们利用commit
或者dispatch
事件修改了全局数据后,怎么去更新视图呢?更新视图只能通过this.setData
的形式,但现在修改数据和展示的视图二者还并没有什么关联关系存在,那怎么让视图使用的{{xxx}}
形式和我们调用app.commit(stateName)
形式的两者关联起来呢?其实就是让xxx
和stateName
有某种关联存在。
好了,大概就这三步骤(都给自己说乱了(ー_ー)!!),下面我们就按这三步目标来编写代码康康。
源码
第一步骤
首先,我们先编写核心的 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
挂载起来,然后再定义它本身具有的方法的,主要是 commit
和 dispatch
方法,具体过程代码都有详细注释,就不多说了。当然,它还继承了 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() {},
...
}))
到此,我们完成了两步操作了,页面能展示和修改全局数据了。但是,现在我们去修改了全局共享的数据,视图却没有更新,这是我们要实现的最关键的第三步骤了。(敲黑板,重点,要考的)
第三步骤(重点)
改变数据 --> 更新视图,要实现这个目标,我们需要做两件事件:
- 知道页面使用了那些全局数据,其实就是
globalState
属性标记的那些数据。对这些数据进行一个依赖收集过程,也就给它们注册一个订阅者(watcher)身份,然后把这些订阅者都集中放到一个订阅器(Dep)中去。(其实说白了这些订阅者就是一个回调函数,函数里面做的事情就是进行this.setData
操作而已)。 - 当数据改变的时候,去通知订阅器里面对应的订阅者更新视图即可,而改变
$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
的方式有很多,这里我想了四种方式,分别如下:
- 以约定式的方式来获取到
stateName
,mutations
中方法名称的定义为set + stateName 驼峰
的形式。这种方式最为简单。 - 初始化劫持的时候,保留一份
state
的初始数据,每次修改数据后,对比前后的数据,也能知道那些数据被修改了,从而获取到stateName
。这种也比较好实现,但就是要维护好另一份state
数据有点麻烦。 - 以正则的形式来匹配
mutations
方法中,内部对state.stateName
的修改,也能获取到stateName
。这就比较复杂一点了。 - 最暴力的一种,直接就是不实现
mutations
和actions
, 直接实现一个$store.globalUpdate(stateName, value, () = {})
方法,每次修改就传递stateName
,然后直接调用notify
即可。
到这里改变数据 --> 更新视图的目标就实现啦。
具体源码
整个过程涉及 globalState.js
、index.js
、observer.js
、proxys.js
四个文件,globalState.js
和 observer.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
的双向数据绑定还差那么一向,不过也够用啦,后面看看有需要的话小编再回来补上。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。