1.前言
微信小程序很多地方很像Vue和React,然而小程序本身没有提供一个类似Vuex或Redux的全局状态管理工具。虽然有app.globalData在那儿杵着,但是app.globalData并不是响应式的。也就是说app.globalData的变化并不能驱动页面,然而Page和Component中的data和properties是可以驱动页面的,所以我想能不能以某种方式将app.globalData和data或properties联动起来。
具体想要达到的效果是: 将特定Page或者Component中的data联动到app.globalData中的值上,当app.globalData中的数据有变化的同时,改变data中的值。
正好最近正在学习mvvm的源码实现,看到一篇很好的帖子,就想着能不能将这片文章的思路用在小程序上。这里是传送门,特别感谢作者的分享。
2.场景示例
假设我的小程序有一个页面index,两个组件分别为childA和childB。它们之间的嵌套关系如下:

现在想要实现的效果是,在index中定义一个数据字段indexData,以属性传值的形式依次向子组件childA和二级子组件childB传递,并且这个值在index、childA和childB的页面上都有显示。而当indexData有所改变时,所有页面也都能够响应。
这个功能很简单,熟悉组件间传值的看官们很轻松就可以实现。
// index.js
Page({
data: {
indexData: '默认值'
},
onLoad: function () {
setTimeout(() => {
this.setData({
indexData: '修改后的值'
})
}, 3000)
}
})
<!--index.wxml-->
<view class="container">
<childA class="childA" childaProp='{{indexData}}'></childA>
<view class="usermotto">{{indexData}}</view>
</view>
首先在index.js声明数据字段indexData,在模板中传递给组件childA的childaProp属性。
// childA.js
Component({
/**
* 组件的属性列表
*/
properties: {
childaProp: {
type: String,
value: ''
}
}
})
<!--childA.wxml-->
<view>我在childA:{{childaProp}}</view>
<childB class="childB" childbProp='{{childaProp}}'></childB>
在childA.js中声明属性childaProp,并在模板中传递给组件B的childbProp属性。
// childB.js
const app = getApp()
Component({
/**
* 组件的属性列表
*/
properties: {
childbProp: {
type: String,
value: ''
}
}
})
<!--childB.wxml-->
<view>我在childB:{{childbProp}}</view>
这样当index在3s后改变indexData时,childaProp和childbProp都可以响应到,并驱动页面的变化。
然而现在需求出现了变化,我们需要在“最孙子”的组件childB中改变childbProp,并且希望childbProp变化后,他爹和他爷爷的值也能变。或者干脆希望当组件childB变化时,跟他没啥联系的兄弟组件(比如起名叫childC)中的值也能响应。
挨个trigger的话太惨了,熟悉全局状态管理的看官一下子就可以想到:哎?这不就是需要Vuex的时候吗?
引用Vue官网的Vuex概述里的原话:
但是,如果您需要构建一个中大型单页应用,您很可能会考虑如何更好地在组件外部管理状态,Vuex 将会成为自然而然的选择。引用 Redux 的作者 Dan Abramov 的话说就是 Flux 架构就像眼镜:您自会知道什么时候需要它。
同志们。。。是到了这个时候了,可是微信小程序并没有一个Flux解决方案。
3.对app.js进行二次开发
下面就是通过对app.globalData这个对象进行数据劫持的过程了,并且通过发布订阅的模式更新所有订阅的数据。这里完全借鉴了开头提到的那篇mvvm原理的文章,区别在于mvvm原理是在模板编译的时候订阅所有需要进行数据绑定的dom节点,而这里是订阅了所有需要绑定的data或者properties。也就是说,人家是data变化的时候,dom随着变;这里是app.globalData变的时候,所指定的data跟着变。
换汤不换药~
首先是数据劫持
App({
onLaunch: function () {
//...此处省略很多自己生成的代码
this.observe(this.globalData.wxMinix)
},
Observe: function (data) {
let _this = this
for (let key in data) {
let val = data[key]
this.observe(data[key])
let dep = new Dep()
Object.defineProperty(data, key, {
configurable: true,
get() {
return val
},
set(newValue) {
if (val === newValue) {
return
}
console.log('newValue', newValue)
val = newValue
_this.observe(newValue)
}
})
}
},
observe: function (data) {
if (!data || typeof data !== 'object') return
this.Observe(data)
},
globalData: {
wxMinix: {
indexData: ''
}
}
})
在globalData这个对象中自定义一个wxMinix属性,我们所劫持的就是这个属性所对应的对象,这样做的目的是职责分离,globalData中的其它属性我们并不做劫持,仍然可以当做普通的app.globalData进行使用。
我们遍历app.globalData.wxMinix中的每一个key,通过Object.defineProperty方法重写他们的访问器属性,当这个属性值仍然是对象的时候,我们重复上述操作,直到把每一个key都劫持到。相当于在这个属性取值或赋值的时候,拉出两个线头,在这两个时间点做我们想做的事情。
此时在小程序的任意一个地方给app.globalData.wxMinix.indexData赋值,都可以在控制台打印出'newVlaue+新的值'。
之后是发布订阅
原贴的作者中讲解了啥是发布订阅,订阅其实就是把要干的事(回调函数)添加进数组,发布就是依次执行他们。这里我们要做的事情就是:订阅我们想要跟app.globalData.wxMinix.indexData绑定的data或者properties,然后监听app.globalData.wxMinix.indexData,他一变,立马通知所有订阅在册的data或者properties,让他们也跟着变。
// 在app.js的全局作用域定义观察者和订阅列表
function Watcher(key, gd, fn) {
this.key = key
this.gd = gd
this.fn = fn
Dep.target = this
let arr = key.split('.')
let val = this.gd
arr.forEach(key => {
val = val[key]
})
Dep.target = undefined
}
Watcher.prototype.update = function () {
let arr = this.key.split('.')
let val = this.gd
console.log(this.gd)
arr.forEach(key => {
val = val[key]
})
this.fn(val)
}
function Dep() {
this.subs = []
}
Dep.prototype = {
addSubs(watcher) {
this.subs.push(watcher)
},
notify() {
this.subs.forEach(watcher => {
watcher.update()
})
}
}
// 修改数据劫持中的代码
Observe: function (data) {
let _this = this
for (let key in data) {
let val = data[key]
this.observe(data[key])
let dep = new Dep() //Dep的实例可在set和get中闭包访问
//也就是说每个key都有对应的要通知的观察列表
Object.defineProperty(data, key, {
configurable: true,
get() {
Dep.target && dep.addSubs(Dep.target) //获取app.globalData.wxMinix对应的值时进行订阅
return val
},
set(newValue) {
if (val === newValue) {
return
}
console.log('newValue', newValue)
val = newValue
_this.observe(newValue)
dep.notify() // 当app.globalData.wxMinix对应的值变化时发布
}
})
}
}
这里主要是先维护了一个Dep类型的构造函数,只有一个数组属性,在它的原型链上有两个方法,一个是addSubs(订阅),一个是notify(发布)。
globalData: {
wxMinix: {
indexData: ''
}
},
makeWatcher: function (key, gb, fn) {
new Watcher(key, gb, fn)
}
最后是实例化观察者,订阅进去就欧了~
最后我们在App.js的构造器中再添加一个方法,方便外部的页面和组件随时调用。这时我们的app.js的功能就添加完毕了,只需要在想要的地方调用app.makeWatcher就可以了。
比如:我希望index.js中的indexData和app.globalData.wxMinix.indexData绑定,那么我只要在index.js的onLoad生命周期中订阅这个观察者就可以了。
onLoad: function () {
let _this = this
app.makeWatcher('wxMinix.indexData', app.globalData, function(newValue) {
_this.setData({
indexData: newValue
})
})
}
这时,如果在childB.js中修改childbProp的值:
lifetimes: {
attached: function () {
let _this = this
// 在组件实例进入页面节点树时执行
setTimeout(() => {
app.globalData.wxMinix.indexData = '从childB中修改后的值'
console.log(app.globalData.indexData)
}, 5000)
},
detached: function () {
// 在组件实例被从页面节点树移除时执行
},
这时你会发现index.js中的indexData也改变了~,如果你想在任意组件绑定数据,只需要在那个页面或者组件的onLoad生命周期中订阅一个观察者,在回调函数中修改这个值就好。
注意
到这里开发工作基本就完成了,还有两点需要注意:
-
想监测的app.globalData.wxMinix中的数据必须事先声明在globalData对象中,因为在onLaunch中做数据劫持的时候,只会把当时有的所有key劫持住,把他们的get和set方法拉出线头儿来~(ps:虽然貌似本身app.globalData中的数据不事先声明在小程序里也访问不到)
-
如果indexData本身也是一个对象,只想改变他其中一个属性值也是可以的,但是需要在特定的页面data或properties中声明一个对应的值,在makeWatcher的时候修改这个值就可以。或者可以在修改app.globalData.wxMinix.indexData时,将indexData深拷贝出来,修改其中一个属性值后,将新的indexData整个赋值上去。比如:
let temp = JSON.parse(JSON.stringify(app.globalData.wxMinix.indexData))
temp.name = '哈哈哈'
app.globalData.wxMinix.indexData = temp
浅拷贝是行不通的�-_-||。
4.后记
写了半天感觉好累啊,希望能帮到有需要的看官,如果有漏洞或者更好的方法也欢迎大家指正。其实小程序组件自带的relations貌似也能实现类似的功能,但是有些太过麻烦。还是需要继续研究。
感觉发布订阅写得比较模糊,如果没看懂的看官可以直接传送门,里面讲得很清楚,我也会继续修改本文。
---------------------------------分割线--------------------------------------
把上述代码进行了一下模块化的封装,而且写了一下使用文档。同志们可以在自己的小程序中试一下~ 传送门