深刻淺出基於「依賴收集」的響應式原理

寫於 2017.09.13javascript

每當問到VueJS響應式原理,你們可能都會脫口而出「Vue經過Object.defineProperty方法把data對象的所有屬性轉化成getter/setter,當屬性被訪問或修改時通知變化」。然而,其內部深層的響應式原理可能不少人都沒有徹底理解,網絡上關於其響應式原理的文章質量也是良莠不齊,大可能是貼個代碼加段註釋了事。本文將會從一個很是簡單的例子出發,一步一步分析響應式原理的具體實現思路。java

1、使數據對象變得「可觀測」

首先,咱們定義一個數據對象,就以王者榮耀裏面的其中一個英雄爲例子:數組

const hero = {
  health: 3000,
  IQ: 150
}
複製代碼

咱們定義了這個英雄的生命值爲3000,IQ爲150。可是如今還不知道他是誰,不過這不重要,只須要知道這個英雄將會貫穿咱們整篇文章,而咱們的目的就是經過這個英雄的屬性,知道這個英雄是誰。瀏覽器

如今咱們能夠經過hero.healthhero.IQ直接讀寫這個英雄對應的屬性值。可是,當這個英雄的屬性被讀取或修改時,咱們並不知情。那麼應該如何作纔可以讓英雄主動告訴咱們,他的屬性被修改了呢?這時候就須要藉助Object.defineProperty的力量了。bash

關於Object.defineProperty的介紹,MDN上是這麼說的:網絡

Object.defineProperty() 方法會直接在一個對象上定義一個新屬性,或者修改一個對象的現有屬性, 並返回這個對象。模塊化

在本文中,咱們只使用這個方法使對象變得「可觀測」,更多關於這個方法的具體內容,請參考https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty,就再也不贅述了。函數

那麼如何讓這個英雄主動通知咱們其屬性的讀寫狀況呢?首先改寫一下上面的例子:學習

let hero = {}
let val = 3000
Object.defineProperty(hero, 'health', {
  get () {
    console.log('個人health屬性被讀取了!')
    return val
  },
  set (newVal) {
    console.log('個人health屬性被修改了!')
    val = newVal
  }
})
複製代碼

咱們經過Object.defineProperty方法,給hero定義了一個health屬性,這個屬性在被讀寫的時候都會觸發一段console.log。如今來嘗試一下:優化

console.log(hero.health)

// -> 3000
// -> 個人health屬性被讀取了!

hero.health = 5000
// -> 個人health屬性被修改了
複製代碼

能夠看到,英雄已經能夠主動告訴咱們其屬性的讀寫狀況了,這也意味着,這個英雄的數據對象已是「可觀測」的了。爲了把英雄的全部屬性都變得可觀測,咱們能夠想一個辦法:

/** * 使一個對象轉化成可觀測對象 * @param { Object } obj 對象 * @param { String } key 對象的key * @param { Any } val 對象的某個key的值 */
function defineReactive (obj, key, val) {
  Object.defineProperty(obj, key, {
    get () {
      // 觸發getter
      console.log(`個人${key}屬性被讀取了!`)
      return val
    },
    set (newVal) {
      // 觸發setter
      console.log(`個人${key}屬性被修改了!`)
      val = newVal
    }
  })
}

/** * 把一個對象的每一項都轉化成可觀測對象 * @param { Object } obj 對象 */
function observable (obj) {
  const keys = Object.keys(obj)
  keys.forEach((key) => {
    defineReactive(obj, key, obj[key])
  })
  return obj
}
複製代碼

如今咱們能夠把英雄這麼定義:

const hero = observable({
  health: 3000,
  IQ: 150
})
複製代碼

讀者們能夠在控制檯自行嘗試讀寫英雄的屬性,看看它是否是已經變得可觀測的。

2、計算屬性

如今,英雄已經變得可觀測,任何的讀寫操做他都會主動告訴咱們,但也僅此而已,咱們仍然不知道他是誰。若是咱們但願在修改英雄的生命值和IQ以後,他可以主動告訴他的其餘信息,這應該怎樣才能辦到呢?假設能夠這樣:

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})
複製代碼

咱們定義了一個watcher做爲「監聽器」,它監聽了hero的type屬性。這個type屬性的值取決於hero.health,換句話來講,當hero.health發生變化時,hero.type也應該發生變化,前者是後者的依賴。咱們能夠把這個hero.type稱爲「計算屬性」。

那麼,咱們應該怎樣才能正確構造這個監聽器呢?能夠看到,在設想當中,監聽器接收三個參數,分別是被監聽的對象、被監聽的屬性以及回調函數,回調函數返回一個該被監聽屬性的值。順着這個思路,咱們嘗試着編寫一段代碼:

/** * 當計算屬性的值被更新時調用 * @param { Any } val 計算屬性的值 */
function onComputedUpdate (val) {
  console.log(`個人類型是:${val}`);
}

/** * 觀測者 * @param { Object } obj 被觀測對象 * @param { String } key 被觀測對象的key * @param { Function } cb 回調函數,返回「計算屬性」的值 */
function watcher (obj, key, cb) {
  Object.defineProperty(obj, key, {
    get () {
      const val = cb()
      onComputedUpdate(val)
      return val
    },
    set () {
      console.error('計算屬性沒法被賦值!')
    }
  })
}
複製代碼

如今咱們能夠把英雄放在監聽器裏面,嘗試跑一下上面的代碼:

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})

hero.type

hero.health = 5000

hero.type

// -> 個人health屬性被讀取了!
// -> 個人類型是:脆皮
// -> 個人health屬性被修改了!
// -> 個人health屬性被讀取了!
// -> 個人類型是:坦克
複製代碼

如今看起來沒毛病,一切都運行良好,是否是就這樣結束了呢?別忘了,咱們如今是經過手動讀取hero.type來獲取這個英雄的類型,並非他主動告訴咱們的。若是咱們但願讓英雄可以在health屬性被修改後,第一時間主動發起通知,又該怎麼作呢?這就涉及到本文的核心知識點——依賴收集。

3、依賴收集

咱們知道,當一個可觀測對象的屬性被讀寫時,會觸發它的getter/setter方法。換個思路,若是咱們能夠在可觀測對象的getter/setter裏面,去執行監聽器裏面的onComputedUpdate()方法,是否是就可以實現讓對象主動發出通知的功能呢?

因爲監聽器內的onComputedUpdate()方法須要接收回調函數的值做爲參數,而可觀測對象內並無這個回調函數,因此咱們須要藉助一個第三方來幫助咱們把監聽器和可觀測對象鏈接起來。

這個第三方就作一件事情——收集監聽器內的回調函數的值以及onComputedUpdate()方法。

如今咱們把這個第三方命名爲「依賴收集器」,一塊兒來看看應該怎麼寫:

const Dep = {
  target: null
}
複製代碼

就是這麼簡單。依賴收集器的target就是用來存放監聽器裏面的onComputedUpdate()方法的。

定義完依賴收集器,咱們回到監聽器裏,看看應該在什麼地方把onComputedUpdate()方法賦值給Dep.target

function watcher (obj, key, cb) {
  // 定義一個被動觸發函數,當這個「被觀測對象」的依賴更新時調用
  const onDepUpdated = () => {
    const val = cb()
    onComputedUpdate(val)
  }

  Object.defineProperty(obj, key, {
    get () {
      Dep.target = onDepUpdated
      // 執行cb()的過程當中會用到Dep.target,
      // 當cb()執行完了就重置Dep.target爲null
      const val = cb()
      Dep.target = null
      return val
    },
    set () {
      console.error('計算屬性沒法被賦值!')
    }
  })
}
複製代碼

咱們在監聽器內部定義了一個新的onDepUpdated()方法,這個方法很簡單,就是把監聽器回調函數的值以及onComputedUpdate()打包到一塊,而後賦值給Dep.target。這一步很是關鍵,經過這樣的操做,依賴收集器就得到了監聽器的回調值以及onComputedUpdate()方法。做爲全局變量,Dep.target理所固然的可以被可觀測對象的getter/setter所使用。

從新看一下咱們的watcher實例:

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})
複製代碼

在它的回調函數中,調用了英雄的health屬性,也就是觸發了對應的getter函數。理清楚這一點很重要,由於接下來咱們須要回到定義可觀測對象的defineReactive()方法當中,對它進行改寫:

function defineReactive (obj, key, val) {
  const deps = []
  Object.defineProperty(obj, key, {
    get () {
      if (Dep.target && deps.indexOf(Dep.target) === -1) {
        deps.push(Dep.target)
      }
      return val
    },
    set (newVal) {
      val = newVal
      deps.forEach((dep) => {
        dep()
      })
    }
  })
}
複製代碼

能夠看到,在這個方法裏面咱們定義了一個空數組deps,當getter被觸發的時候,就會往裏面添加一個Dep.target。回到關鍵知識點Dep.target等於監聽器的onComputedUpdate()方法,這個時候可觀測對象已經和監聽器捆綁到一塊。任什麼時候候當可觀測對象的setter被觸發時,就會調用數組中所保存的Dep.target方法,也就是自動觸發監聽器內部的onComputedUpdate()方法。

至於爲何這裏的deps是一個數組而不是一個變量,是由於可能同一個屬性會被多個計算屬性所依賴,也就是存在多個Dep.target。定義deps爲數組,若當前屬性的setter被觸發,就能夠批量調用多個計算屬性的onComputedUpdate()方法了。

完成了這些步驟,基本上咱們整個響應式系統就已經搭建完成,下面貼上完整的代碼:

/** * 定義一個「依賴收集器」 */
const Dep = {
  target: null
}

/** * 使一個對象轉化成可觀測對象 * @param { Object } obj 對象 * @param { String } key 對象的key * @param { Any } val 對象的某個key的值 */
function defineReactive (obj, key, val) {
  const deps = []
  Object.defineProperty(obj, key, {
    get () {
      console.log(`個人${key}屬性被讀取了!`)
      if (Dep.target && deps.indexOf(Dep.target) === -1) {
        deps.push(Dep.target)
      }
      return val
    },
    set (newVal) {
      console.log(`個人${key}屬性被修改了!`)
      val = newVal
      deps.forEach((dep) => {
        dep()
      })
    }
  })
}

/** * 把一個對象的每一項都轉化成可觀測對象 * @param { Object } obj 對象 */
function observable (obj) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i], obj[keys[i]])
  }
  return obj
}

/** * 當計算屬性的值被更新時調用 * @param { Any } val 計算屬性的值 */
function onComputedUpdate (val) {
  console.log(`個人類型是:${val}`)
}

/** * 觀測者 * @param { Object } obj 被觀測對象 * @param { String } key 被觀測對象的key * @param { Function } cb 回調函數,返回「計算屬性」的值 */
function watcher (obj, key, cb) {
  // 定義一個被動觸發函數,當這個「被觀測對象」的依賴更新時調用
  const onDepUpdated = () => {
    const val = cb()
    onComputedUpdate(val)
  }

  Object.defineProperty(obj, key, {
    get () {
      Dep.target = onDepUpdated
      // 執行cb()的過程當中會用到Dep.target,
      // 當cb()執行完了就重置Dep.target爲null
      const val = cb()
      Dep.target = null
      return val
    },
    set () {
      console.error('計算屬性沒法被賦值!')
    }
  })
}

const hero = observable({
  health: 3000,
  IQ: 150
})

watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
})

console.log(`英雄初始類型:${hero.type}`)

hero.health = 5000

// -> 個人health屬性被讀取了!
// -> 英雄初始類型:脆皮
// -> 個人health屬性被修改了!
// -> 個人health屬性被讀取了!
// -> 個人類型是:坦克
複製代碼

上述代碼能夠直接在code pen或者瀏覽器控制檯上執行。

4、代碼優化

在上面的例子中,依賴收集器只是一個簡單的對象,其實在defineReactive()內部的deps數組等和依賴收集有關的功能,都應該集成在Dep實例當中,因此咱們能夠把依賴收集器改寫一下:

class Dep {
  constructor () {
    this.deps = []
  }

  depend () {
    if (Dep.target && this.deps.indexOf(Dep.target) === -1) {
      this.deps.push(Dep.target)
    }
  }

  notify () {
    this.deps.forEach((dep) => {
      dep()
    })
  }
}

Dep.target = null
複製代碼

一樣的道理,咱們對observable和watcher都進行必定的封裝與優化,使這個響應式系統變得模塊化:

class Observable {
  constructor (obj) {
    return this.walk(obj)
  }

  walk (obj) {
    const keys = Object.keys(obj)
    keys.forEach((key) => {
      this.defineReactive(obj, key, obj[key])
    })
    return obj
  }

  defineReactive (obj, key, val) {
    const dep = new Dep()
    Object.defineProperty(obj, key, {
      get () {
        dep.depend()
        return val
      },
      set (newVal) {
        val = newVal
        dep.notify()
      }
    })
  }
}
複製代碼
class Watcher {
  constructor (obj, key, cb, onComputedUpdate) {
    this.obj = obj
    this.key = key
    this.cb = cb
    this.onComputedUpdate = onComputedUpdate
    return this.defineComputed()
  }

  defineComputed () {
    const self = this
    const onDepUpdated = () => {
      const val = self.cb()
      this.onComputedUpdate(val)
    }

    Object.defineProperty(self.obj, self.key, {
      get () {
        Dep.target = onDepUpdated
        const val = self.cb()
        Dep.target = null
        return val
      },
      set () {
        console.error('計算屬性沒法被賦值!')
      }
    })
  }
}
複製代碼

而後咱們來跑一下:

const hero = new Observable({
  health: 3000,
  IQ: 150
})

new Watcher(hero, 'type', () => {
  return hero.health > 4000 ? '坦克' : '脆皮'
}, (val) => {
  console.log(`個人類型是:${val}`)
})

console.log(`英雄初始類型:${hero.type}`)

hero.health = 5000

// -> 英雄初始類型:脆皮
// -> 個人類型是:坦克
複製代碼

代碼已經放在code pen,瀏覽器控制檯也是能夠運行的~

5、尾聲

看到上述的代碼,是否是發現和VueJS源碼裏面的很像?其實VueJS的思路和原理也是相似的,只不過它作了更多的事情,但核心仍是在這裏邊。

在學習VueJS源碼的時候,曾經被響應式原理弄得頭昏腦漲,並不是一會兒就看懂了。後在不斷的思考與嘗試下,同時參考了許多其餘人的思路,才總算把這一塊的知識點徹底掌握。但願這篇文章對你們有幫助,若是發現有任何錯漏的地方,也歡迎向我指出,謝謝你們~

相關文章
相關標籤/搜索