小程序里实现 watch 和 computed

小程序里的自定义组件里是有数据监听器的,可以监听对应数据的变化来执行callBack,但是页面Page里没有对应的api就显的很生硬,比如某个数据变了(如切换城市)需要重新刷页面,如果不做监听,每次都要在数据变化的地方手动去调一次函数。

那么如何像vue那样在Page里实现 watch 和 computed 呢 ?如果这时候你脑子里能想到 Obejct.defineProperty 或者 Proxy 那么接下来就慢慢实现吧。

先晒出是这样调用的,请牢记这个调用,后面会反复提到 test2 test3 currentCity:

  this.$computed(this, {
      test2: function() {
        return this.data.currentCity.cityID + '2222222'
      },
      test3: function() {
        return this.data.currentCity.cityID + '3333333'
      }
    })
    this.$watch(this, {
      currentCity(city) {
        console.log('回调传值',city)
        if (city.cityID) {
          this.getHotSpotList()
        }
      }
    })

 

第一步,先定义一个函数来检测对应属性的变化,每当setter,getter的时候会触发。

function defineReactive(data, key, val, fn) {
    Object.defineProperty(data, key, {
      configurable: true,
      enumerable: true,
      get: function() {
        return val
      },
      set: function(newVal) {
        if (val == newVal) return
        val = newVal
      },
    })
}

先实现watch ,简单,把传入对象的每个属性都监测属性变化

function watch(ctx, obj) {
  Object.keys(obj).forEach(key => {
    defineReactive(ctx.data, key, ctx.data[key], function(value) {
      obj[key].call(ctx, value)
    })
  })
}

上面的方法defineReactive需要稍微改造一下,在set改变值的时候,执行回调函数 fn,且set里新旧值的对比要考虑复杂类型的对比,直接引入lodash的isEqual 方法来对比

function defineReactive(data, key, val, fn) {
    Object.defineProperty(data, key, {
      configurable: true,
      enumerable: true,
      get: function() {
        return val
      },
      set: function(newVal) {
        if (_.isEqual(val,newVal)) return
        fn && fn(newVal)
        val = newVal
      },
    })
  }

接下来实现 computed,这个会比较麻烦点,有几个注意的地方,1:需要把computed初始的时候传进来的属性算出值并放在this.data里,跟vue是一样的原理。2:每个传进来的属性值都要进行遍历监听变化。

 function computed(ctx, obj) {
    let keys = Object.keys(obj)
    let dataKeys = Object.keys(ctx.data)
    dataKeys.forEach(dataKey => {
      defineReactive(ctx.data, dataKey, ctx.data[dataKey])
    })
  }

基于上面的,我们要补充实现刚才提到的第一点,算出computed对应属性的初始值并设在this.data里

  function computed(ctx, obj) {
    let keys = Object.keys(obj)
    let dataKeys = Object.keys(ctx.data)
    dataKeys.forEach(dataKey => {
      defineReactive(ctx.data, dataKey, ctx.data[dataKey])
    })
    let firstComputedObj = keys.reduce((prev, next) => {
      prev[next] = obj[next].call(ctx)
      return prev
    }, {})
    ctx.setData(firstComputedObj)
  }

但是现在有个问题,test2 test3 的初始值都算出来了,但后续如果this.data.currentCity变化的时候,test2,test3对应的也要计算出新的值的,这样才是实现了所谓的computed。

那么该如何去处理呢?我们就需要抓住一个时机,当currentCity变化的时候会触发 set,这个时候应该触发一些机制去更新test2,test3.

请注意上面的这行代码:prev[next] = obj[next].call(ctx)

请看obj[next].call(ctx) 调的就是test2,test3对应的function并执行函数,这个时候函数内部的this.data.currentCity 会触发到 get ,就是这个时机,我们能完美的把所有跟currentCity属性相关的其他属性关联到一起。

这个时候触发了get,我们何不把对应的函数记下来,在set的时候去调用,这样就能做到currentCity变化的时候 test2 test3也同步变化。思路大致有了,接下来看代码:

computed 大致如下:

function computed(ctx, obj) {
  let keys = Object.keys(obj)
  let dataKeys = Object.keys(ctx.data)
  dataKeys.forEach(dataKey => {
    defineReactive(ctx.data, dataKey, ctx.data[dataKey])
  })
  let firstComputedObj = keys.reduce((prev, next) => {
    ctx.data.$target = function() {
      ctx.setData({ [next]: obj[next].call(ctx) })
    }
    // obj[next].call(ctx) 执行的时候会触发该函数执行,函数内部的this.data相关属性的调用会触发defineReactive.get
    prev[next] = obj[next].call(ctx)
    ctx.data.$target = null
    return prev
  }, {})
  ctx.setData(firstComputedObj)
}

defineReactive 函数,上面说过在触发currentCity get的时候要记下 test2 test3对应的函数,到了set的时候再去执行,起到cuerrentCity变化的时候,test2,test3 也能同步变化。

上面的  ctx.data.$target 稍微 funtion 后立马再经过 prev[next] = obj[next].call(ctx) 这一句之后,又恢复为null,可能会有点疑惑,上面提过的,你需要注意 prev[next] = obj[next].call(ctx) 中 obj[next] 会触发 test2 test3的 函数,函数里的 this.data.currentCity 会触发自己的get,这个时候我们来把 test2 test3 和 currentCity 关联,在currentcity set的时候,去跟新 test2 test3的值。

defineReactive 的代码需要加个处理,记下test2 test3的处理函数

function defineReactive(data, key, val, fn) {
    let subs = [] 
    Object.defineProperty(data, key, {
      configurable: true,
      enumerable: true,
      get: function() {
        if (data.$target) {
          subs.push(data.$target)
        }
        return val
      },
      set: function(newVal) {
        if (_.isEqual(newVal,val)) return
          fn && fn(newVal)
        if (subs.length) {
          subs.forEach(sub => sub())
        }
        val = newVal
      },
    })
  }

这样处理下来,大致基本实现了,接下来需要处理几个坑点,如果fn函数里有取this.data,可能currentCity仍旧是旧的值,明明set里的是新的值,这个涉及到了this.setData异步的问题,咱们需要加个处理。

function defineReactive(data, key, val, fn) {
    let subs = []
    Object.defineProperty(data, key, {
      configurable: true,
      enumerable: true,
      get: function() {
        if (data.$target) {
          subs.push(data.$target)
        }
        return val
      },
      set: function(newVal) {
        if (_.isEqual(newVal,val)) return
        // 经过试验,这里的触发要早于setData的回调
        // fn && fn(newVal) // 可能setData异步 还没及时完成,newVal 是新的,但是this.data里还是旧的
        //这样watch 里去调用对应的方法,可能取的this.data就不是新的
        // 如果fn取的是函数形参,那么可以不用setTimeout,但如果是函数里取得this.data就需要
        setTimeout(() => {
          // 这时候已经完成了setData,fn里取this.data就是最新的
          fn && fn(newVal)
        }, 0)
        if (subs.length) {
          // 用 setTimeout 因为此时 this.data 还没更新
          // 涉及到微任务,宏任务
          setTimeout(() => {
            subs.forEach(sub => sub())
          }, 0)
          // 跟上面那个setTimeout一样,如果函数里用到了this.data,就需要加setTimeout
        }
        val = newVal
      },
    })
  }

解决完异步的问题,还需要再注意一点:我们在Page里先写了 computed 然后写了 个 watch ,由于 computed初始化完成之后,如上面的 test2 test3 已经添加到 this.data里了,那么在 watch里咱们可以直接对 test2 test3 进行 监听,看上去是挺完美的,但是看 defineReactive 的代码 咱们应该注意,如果由于每次 执行defineReactive subs都是会置空的,那么 computed 就会失效, this.data.currentCity 变化的时候,对应的 test2 test3 的值就得不到更新,因为 subs 都被清空了,currentCity 触发set的时候,subs是空的,很尴尬。。。

那么如何保证 subs 不被清空呢? 咱们只能找个地方记下来,最好跟属性名相关联。

function defineReactive(data, key, val, fn) {
  let subs = data['$' + key] || [] 
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get: function() {
      if (data.$target) {
        subs.push(data.$target)
        data['$' + key] = subs
      }
      //val 形成局部作用域保存在函数内部,set的时候会改变该值,所以一直能返回对应的属性值
      return val
    },
    set: function(newVal) {
      
      // === 不适用判断复杂类型,所以这里引用lodash中的 isEqual 方法
      if (_.isEqual(newVal,val)) return
      // console.log('触发set',newVal, new Date().getTime())
      // 经过试验,这里的触发要早于setData的回调
      // fn && fn(newVal) // 可能setData异步 还没及时完成,newVal 是新的,但是this.data里还是旧的
      //这样watch 里去调用对应的方法,可能取的this.data就不是新的
      // 如果fn取的是函数形参,那么可以不用setTimeout,但如果是函数里取得this.data就需要
      setTimeout(() => {
        // 这时候已经完成了setData,fn里取this.data就是最新的
        fn && fn(newVal)
      }, 0)
      if (subs.length) {
        // 用 setTimeout 因为此时 this.data 还没更新
        // 涉及到微任务,宏任务
        setTimeout(() => {
          subs.forEach(sub => sub())
        }, 0)
        // 跟上面那个setTimeout一样,如果函数里用到了this.data,就需要加setTimeout
      }
      val = newVal
    },
  })
}
到这里,我们算是完成了 computed 和 watch 的实现了。最好把这两个方法绑定到每个page ,这个过程只要进行mixin就好了,大致思路是对小程序的 Page 对象和 mixin 进行 assign

后续有时间会写一下小程序整个Page的封装改造!!!
上一篇:NGINX反向代理与负载均衡


下一篇:Android 之runOnUiThread