深入淺出基於“依賴收集”的響應式原理

jrainlau發表於2017-09-13

clipboard.png

每當問到VueJS響應式原理,大家可能都會脫口而出“Vue通過Object.defineProperty方法把data物件的全部屬性轉化成getter/setter,當屬性被訪問或修改時通知變化”。然而,其內部深層的響應式原理可能很多人都沒有完全理解,網路上關於其響應式原理的文章質量也是參差不齊,大多是貼個程式碼加段註釋了事。本文將會從一個非常簡單的例子出發,一步一步分析響應式原理的具體實現思路。

一、使資料物件變得“可觀測”

首先,我們定義一個資料物件,就以王者榮耀裡面的其中一個英雄為例子:

const hero = {
  health: 3000,
  IQ: 150
}

我們定義了這個英雄的生命值為3000,IQ為150。但是現在還不知道他是誰,不過這不重要,只需要知道這個英雄將會貫穿我們整篇文章,而我們的目的就是通過這個英雄的屬性,知道這個英雄是誰。

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

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

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

在本文中,我們只使用這個方法使物件變得“可觀測”,更多關於這個方法的具體內容,請參考https://developer.mozilla.org…,就不再贅述了。

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

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
})

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

二、計算屬性

現在,英雄已經變得可觀測,任何的讀寫操作他都會主動告訴我們,但也僅此而已,我們仍然不知道他是誰。如果我們希望在修改英雄的生命值和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屬性被修改後,第一時間主動發起通知,又該怎麼做呢?這就涉及到本文的核心知識點——依賴收集。

三、依賴收集

我們知道,當一個可觀測物件的屬性被讀寫時,會觸發它的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或者瀏覽器控制檯上執行。

四、程式碼優化

在上面的例子中,依賴收集器只是一個簡單的物件,其實在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,瀏覽器控制檯也是可以執行的~

五、尾聲

看到上述的程式碼,是不是發現和VueJS原始碼裡面的很像?其實VueJS的思路和原理也是類似的,只不過它做了更多的事情,但核心還是在這裡邊。

在學習VueJS原始碼的時候,曾經被響應式原理弄得頭昏腦漲,並非一下子就看懂了。後在不斷的思考與嘗試下,同時參考了許多其他人的思路,才總算把這一塊的知識點完全掌握。希望這篇文章對大家有幫助,如果發現有任何錯漏的地方,也歡迎向我指出,謝謝大家~

相關文章