在微信小程式裡使用 watch 和 computed

youknow發表於2019-02-28

在開發 vue 的時候,我們可以使用 watch 和 computed 很方便的檢測資料的變化,從而做出相應的改變,但是在小程式裡,只能在資料改變時手動觸發 this.setData(),那麼如何給小程式也加上這兩個功能呢?

我們知道在 vue 裡是通過 Object.defineProperty 來實現資料變化檢測的,給該變數的 setter 裡注入所有的繫結操作,就可以在該變數變化時帶動其它資料的變化。那麼是不是可以把這種方法運用在小程式上呢?

實際上,在小程式裡實現要比 vue 裡簡單,應為對於 data 裡物件來說,vue 要遞迴的繫結物件裡的每一個變數,使之響應式化。但是在微信小程式裡,不管是對於物件還是基本型別,只能通過 this.setData() 來改變,這樣我們只需檢測 data 裡面的 key 值的變化,而不用檢測 key 值裡面的 key

先上測試程式碼

<view>{{ test.a }}</view>
<view>{{ test1 }}</view>
<view>{{ test2 }}</view>
<view>{{ test3 }}</view>
<button bindtap="changeTest">change</button>
複製程式碼
const { watch, computed } = require(`./vuefy.js`)
Page({
  data: {
    test: { a: 123 },
    test1: `test1`,
  },
  onLoad() {
    computed(this, {
      test2: function() {
        return this.data.test.a + `2222222`
      },
      test3: function() {
        return this.data.test.a + `3333333`
      }
    })
    watch(this, {
      test: function(newVal) {
        console.log(`invoke watch`)
        this.setData({ test1: newVal.a + `11111111` })
      }
    })
  },
  changeTest() {
    this.setData({ test: { a: Math.random().toFixed(5) } })
  },
})
複製程式碼

現在我們要實現 watch 和 computed 方法,使得 test 變化時,test1、test2、test3 也變化,為此,我們增加了一個按鈕,當點選這個按鈕時,test 會改變。

watch 方法相對簡單點,首先我們定義一個函式來檢測變化:

function defineReactive(data, key, val, fn) {
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get: function() {
      return val
    },
    set: function(newVal) {
      if (newVal === val) return
      fn && fn(newVal)
      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)
    })
  })
}
複製程式碼

這裡有引數是 fn ,即上面 watch 方法裡 test 的值,這裡把該方法包一層,繫結 context。

接著來看 computed,這個稍微複雜,因為我們無法得知 computed 裡依賴的是 data 裡面的哪個變數,因此只能遍歷 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) => {
    ctx.data.$target = function() {
      ctx.setData({ [next]: obj[next].call(ctx) })
    }
    prev[next] = obj[next].call(ctx)
    ctx.data.$target = null
    return prev
  }, {})
  ctx.setData(firstComputedObj)
}
複製程式碼

詳細解釋下這段程式碼,首先給 data 裡的每個屬性呼叫 defineReactive 方法。接著計算 computed 裡面每個屬性第一次的值,也就是上例中的 test2、test3。

computed(this, {
  test2: function() {
    return this.data.test.a + `2222222`
  },
  test3: function() {
    return this.data.test.a + `3333333`
  }
})
複製程式碼

這裡分別呼叫 test2 和 test3 的值,將返回值與對應的 key 值組合成一個物件,然後再呼叫 setData() ,這樣就會第一次計算這兩個值,這裡使用了 reduce 方法。但是你可能會發現其中這兩行程式碼,它們好像都沒有被提到是幹嘛用的。

  ctx.data.$target = function() {
    ctx.setData({ [next]: obj[next].call(ctx) })
  }
  
  ctx.data.$target = null
複製程式碼

可以看到,test2 和 test3 都是依賴 test 的,這樣必須在 test 改變的時候在其的 setter 函式中呼叫 test2 和 test3 中對應的函式,並通過 setData 來設定這兩個變數。為此,需要將 defineReactive 改動一下。

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 (newVal === val) return
      fn && fn(newVal)
      // 新增
      if (subs.length) {
        // 用 setTimeout 因為此時 this.data 還沒更新
        setTimeout(() => {
          subs.forEach(sub => sub())
        }, 0)
      }
      val = newVal
    },
  })
}
複製程式碼

相較於之前,增加了幾行程式碼,我們宣告瞭一個變數來儲存所有在變化時需要執行的函式,在 set 時執行每一個函式,因為此時 this.data.test 的值還未改變,使用 setTimeout 在下一輪再執行。現在就有一個問題,怎麼將函式新增到 subs 中。不知道各位還是否記得上面我們說到的在 reduce 裡的那兩行程式碼。因為在執行計算 test1 和 test2 第一次 computed 值的時候,會呼叫 test 的 getter 方法,此刻就是一個好機會將函式注入到 subs 中,在 data 上宣告一個 $target 變數,並將需要執行的函式賦值給該變數,這樣在 getter 中就可以判斷 data 上有無 target 值,從而就可以 push 進 subs,要注意的是需要馬上將 target 設為 null,這就是第二句的用途,這樣就達到了一石二鳥的作用。當然,這其實就是 vue 裡的原理,只不過這裡沒那麼複雜。

到此為止已經實現了 watch 和 computed,但是還沒完,有個問題。當同時使用這兩者的時候,watch 裡的物件的鍵也同時存在於 data 中,這樣就會重複在該變數上呼叫 Object.defineProperty ,後面會覆蓋前面。因為這裡不像 vue 裡可以決定兩者的呼叫順序,因此我們推薦先寫 computed 再寫 watch,這樣可以 watch computed 裡的值。這樣就有一個問題,computed 會因覆蓋而無效。

思考一下為什麼?

很明顯,這時因為之前的 subs 被重新宣告為空陣列了。這時,我們想一個簡單的方法就是把之前 computed 裡的 subs 存在一個地方,下一次呼叫 defineReactive 的時候看對應的 key 是否已經有了 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 // 新增
      }
      return val
    },
    set: function(newVal) {
      if (newVal === val) return
      fn && fn(newVal)
      if (subs.length) {
        // 用 setTimeout 因為此時 this.data 還沒更新
        setTimeout(() => {
          subs.forEach(sub => sub())
        }, 0)
      }
      val = newVal
    },
  })
}
複製程式碼

這樣,我們就一步一步的實現了所需的功能。完整的程式碼和例子請戳

雖然經過了一些測試,但不保證沒有其它未知錯誤,歡迎提出問題。

相關文章