微信小程序全局状态管理 实现响应式的app.globalData

11,709 阅读7分钟

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貌似也能实现类似的功能,但是有些太过麻烦。还是需要继续研究。

感觉发布订阅写得比较模糊,如果没看懂的看官可以直接传送门,里面讲得很清楚,我也会继续修改本文。

---------------------------------分割线--------------------------------------

把上述代码进行了一下模块化的封装,而且写了一下使用文档。同志们可以在自己的小程序中试一下~ 传送门