深入淺出Vue基于“依賴收集”的響應(yīng)式原理

原文:https://zhuanlan.zhihu.com/p/29318017

每當(dāng)問到VueJS響應(yīng)式原理,大家可能都會(huì)脫口而出 Vue通過 Object.defineProperty 方法把data對象的全部屬性轉(zhuǎn)化成 getter/setter,當(dāng)屬性被訪問或修改時(shí)通知變化 。然而,其內(nèi)部深層的響應(yīng)式原理可能很多人都沒有完全理解,網(wǎng)絡(luò)上關(guān)于其響應(yīng)式原理的文章質(zhì)量也是參差不齊,大多是貼個(gè)代碼加段注釋了事。本文將會(huì)從一個(gè)非常簡單的例子出發(fā),一步一步分析響應(yīng)式原理的具體實(shí)現(xiàn)思路。

一、使數(shù)據(jù)對象變得“可觀測”

首先,我們定義一個(gè)數(shù)據(jù)對象,就以王者榮耀里面的其中一個(gè)英雄為例子:

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

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

現(xiàn)在我們可以通過hero.health和hero.IQ直接讀寫這個(gè)英雄對應(yīng)的屬性值。但是,當(dāng)這個(gè)英雄的屬性被讀取或修改時(shí),我們并不知情。那么應(yīng)該如何做才能夠讓英雄主動(dòng)告訴我們,他的屬性被修改了呢?這時(shí)候就需要借助Object.defineProperty的力量了。

關(guān)于Object.defineProperty的介紹,MDN上是這么說的:

Object.defineProperty() 方法會(huì)直接在一個(gè)對象上定義一個(gè)新屬性,或者修改一個(gè)對象的現(xiàn)有屬性, 并返回這個(gè)對象。

在本文中,我們只使用這個(gè)方法使對象變得“可觀測”,更多關(guān)于這個(gè)方法的具體內(nèi)容,請參考MDN Object.defineProperty(),就不再贅述了。

那么如何讓這個(gè)英雄主動(dòng)通知我們其屬性的讀寫情況呢?首先改寫一下上面的例子:

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定義了一個(gè)health屬性,這個(gè)屬性在被讀寫的時(shí)候都會(huì)觸發(fā)一段console.log。現(xiàn)在來嘗試一下:

console.log(hero.health)

// -> 3000
// -> 我的health屬性被讀取了!

hero.health = 5000
// -> 我的health屬性被修改了

可以看到,英雄已經(jīng)可以主動(dòng)告訴我們其屬性的讀寫情況了,這也意味著,這個(gè)英雄的數(shù)據(jù)對象已經(jīng)是“可觀測”的了。為了把英雄的所有屬性都變得可觀測,我們可以想一個(gè)辦法:

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

/**
 * 把一個(gè)對象的每一項(xiàng)都轉(zhuǎn)化成可觀測對象
 * @param { Object } obj 對象
 */
function observable (obj) {
  const keys = Object.keys(obj)
  keys.forEach((key) => {
    defineReactive(obj, key, obj[key])
  })
  return obj
}

現(xiàn)在我們可以把英雄這么定義:

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

讀者們可以在控制臺(tái)自行嘗試讀寫英雄的屬性,看看它是不是已經(jīng)變得可觀測的。

二、計(jì)算屬性

現(xiàn)在,英雄已經(jīng)變得可觀測,任何的讀寫操作他都會(huì)主動(dòng)告訴我們,但也僅此而已,我們?nèi)匀徊恢浪钦l。如果我們希望在修改英雄的生命值和IQ之后,他能夠主動(dòng)告訴他的其他信息,這應(yīng)該怎樣才能辦到呢?假設(shè)可以這樣:

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

我們定義了一個(gè)watcher作為“監(jiān)聽器”,它監(jiān)聽了hero的type屬性。這個(gè)type屬性的值取決于hero.health,換句話來說,當(dāng)hero.health發(fā)生變化時(shí),hero.type也應(yīng)該發(fā)生變化,前者是后者的依賴。我們可以把這個(gè)hero.type稱為“計(jì)算屬性”。

那么,我們應(yīng)該怎樣才能正確構(gòu)造這個(gè)監(jiān)聽器呢?可以看到,在設(shè)想當(dāng)中,監(jiān)聽器接收三個(gè)參數(shù),分別是被監(jiān)聽的對象、被監(jiān)聽的屬性以及回調(diào)函數(shù),回調(diào)函數(shù)返回一個(gè)該被監(jiān)聽屬性的值。順著這個(gè)思路,我們嘗試著編寫一段代碼:

/**
 * 當(dāng)計(jì)算屬性的值被更新時(shí)調(diào)用
 * @param { Any } val 計(jì)算屬性的值
 */
function onComputedUpdate (val) {
  console.log(`我的類型是:${val}`);
}

/**
 * 觀測者
 * @param { Object } obj 被觀測對象
 * @param { String } key 被觀測對象的key
 * @param { Function } cb 回調(diào)函數(shù),返回“計(jì)算屬性”的值
 */
function watcher (obj, key, cb) {
  Object.defineProperty(obj, key, {
    get () {
      const val = cb()
      onComputedUpdate(val)
      return val
    },
    set () {
      console.error('計(jì)算屬性無法被賦值!')
    }
  })
}

現(xiàn)在我們可以把英雄放在監(jiān)聽器里面,嘗試跑一下上面的代碼:

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

hero.type

hero.health = 5000

hero.type

// -> 我的health屬性被讀取了!
// -> 我的類型是:脆皮
// -> 我的health屬性被修改了!
// -> 我的health屬性被讀取了!
// -> 我的類型是:坦克

現(xiàn)在看起來沒毛病,一切都運(yùn)行良好,是不是就這樣結(jié)束了呢?別忘了,我們現(xiàn)在是通過手動(dòng)讀取hero.type來獲取這個(gè)英雄的類型,并不是他主動(dòng)告訴我們的。如果我們希望讓英雄能夠在health屬性被修改后,第一時(shí)間主動(dòng)發(fā)起通知,又該怎么做呢?這就涉及到本文的核心知識點(diǎn)——依賴收集。

三、依賴收集

我們知道,當(dāng)一個(gè)可觀測對象的屬性被讀寫時(shí),會(huì)觸發(fā)它的getter/setter方法。換個(gè)思路,如果我們可以在可觀測對象的getter/setter里面,去執(zhí)行監(jiān)聽器里面的onComputedUpdate()方法,是不是就能夠?qū)崿F(xiàn)讓對象主動(dòng)發(fā)出通知的功能呢?

由于監(jiān)聽器內(nèi)的onComputedUpdate()方法需要接收回調(diào)函數(shù)的值作為參數(shù),而可觀測對象內(nèi)并沒有這個(gè)回調(diào)函數(shù),所以我們需要借助一個(gè)第三方來幫助我們把監(jiān)聽器和可觀測對象連接起來。

這個(gè)第三方就做一件事情——收集監(jiān)聽器內(nèi)的回調(diào)函數(shù)的值以及onComputedUpdate()方法。

現(xiàn)在我們把這個(gè)第三方命名為“依賴收集器”,一起來看看應(yīng)該怎么寫:

const Dep = {
  target: null
}

就是這么簡單。依賴收集器的target就是用來存放監(jiān)聽器里面的onComputedUpdate()方法的。

定義完依賴收集器,我們回到監(jiān)聽器里,看看應(yīng)該在什么地方把onComputedUpdate()方法賦值給Dep.target:

function watcher (obj, key, cb) {
  // 定義一個(gè)被動(dòng)觸發(fā)函數(shù),當(dāng)這個(gè)“被觀測對象”的依賴更新時(shí)調(diào)用
  const onDepUpdated = () => {
    const val = cb()
    onComputedUpdate(val)
  }

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

我們在監(jiān)聽器內(nèi)部定義了一個(gè)新的onDepUpdated()方法,這個(gè)方法很簡單,就是把監(jiān)聽器回調(diào)函數(shù)的值以及onComputedUpdate()給打包到一塊,然后賦值給Dep.target。這一步非常關(guān)鍵,通過這樣的操作,依賴收集器就獲得了監(jiān)聽器的回調(diào)值以及onComputedUpdate()方法。作為全局變量,Dep.target理所當(dāng)然的能夠被可觀測對象的getter/setter所使用。

重新看一下我們的watcher實(shí)例:

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

在它的回調(diào)函數(shù)中,調(diào)用了英雄的health屬性,也就是觸發(fā)了對應(yīng)的getter函數(shù)。理清楚這一點(diǎn)很重要,因?yàn)榻酉聛砦覀冃枰氐蕉x可觀測對象的defineReactive()方法當(dāng)中,對它進(jìn)行改寫:

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

可以看到,在這個(gè)方法里面我們定義了一個(gè)空數(shù)組deps,當(dāng)getter被觸發(fā)的時(shí)候,就會(huì)往里面添加一個(gè)Dep.target?;氐疥P(guān)鍵知識點(diǎn)Dep.target等于監(jiān)聽器的onComputedUpdate()方法,這個(gè)時(shí)候可觀測對象已經(jīng)和監(jiān)聽器捆綁到一塊。任何時(shí)候當(dāng)可觀測對象的setter被觸發(fā)時(shí),就會(huì)調(diào)用數(shù)組中所保存的Dep.target方法,也就是自動(dòng)觸發(fā)監(jiān)聽器內(nèi)部的onComputedUpdate()方法。

至于為什么這里的deps是一個(gè)數(shù)組而不是一個(gè)變量,是因?yàn)榭赡芡粋€(gè)屬性會(huì)被多個(gè)計(jì)算屬性所依賴,也就是存在多個(gè)Dep.target。定義deps為數(shù)組,若當(dāng)前屬性的setter被觸發(fā),就可以批量調(diào)用多個(gè)計(jì)算屬性的onComputedUpdate()方法了。

完成了這些步驟,基本上我們整個(gè)響應(yīng)式系統(tǒng)就已經(jīng)搭建完成,下面貼上完整的代碼:

/**
 * 定義一個(gè)“依賴收集器”
 */
const Dep = {
  target: null
}

/**
 * 使一個(gè)對象轉(zhuǎn)化成可觀測對象
 * @param { Object } obj 對象
 * @param { String } key 對象的key
 * @param { Any } val 對象的某個(gè)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()
      })
    }
  })
}

/**
 * 把一個(gè)對象的每一項(xiàng)都轉(zhuǎn)化成可觀測對象
 * @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
}

/**
 * 當(dāng)計(jì)算屬性的值被更新時(shí)調(diào)用
 * @param { Any } val 計(jì)算屬性的值
 */
function onComputedUpdate (val) {
  console.log(`我的類型是:${val}`)
}

/**
 * 觀測者
 * @param { Object } obj 被觀測對象
 * @param { String } key 被觀測對象的key
 * @param { Function } cb 回調(diào)函數(shù),返回“計(jì)算屬性”的值
 */
function watcher (obj, key, cb) {
  // 定義一個(gè)被動(dòng)觸發(fā)函數(shù),當(dāng)這個(gè)“被觀測對象”的依賴更新時(shí)調(diào)用
  const onDepUpdated = () => {
    const val = cb()
    onComputedUpdate(val)
  }

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

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

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

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

hero.health = 5000

// -> 我的health屬性被讀取了!
// -> 英雄初始類型:脆皮
// -> 我的health屬性被修改了!
// -> 我的health屬性被讀取了!
// -> 我的類型是:坦克

四、代碼優(yōu)化

在上面的例子中,依賴收集器只是一個(gè)簡單的對象,其實(shí)在defineReactive()內(nèi)部的deps數(shù)組等和依賴收集有關(guān)的功能,都應(yīng)該集成在Dep實(shí)例當(dāng)中,所以我們可以把依賴收集器改寫一下:

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都進(jìn)行一定的封裝與優(yōu)化,使這個(gè)響應(yīng)式系統(tǒng)變得模塊化:

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('計(jì)算屬性無法被賦值!')
      }
    })
  }
}

然后我們來跑一下:

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

// -> 英雄初始類型:脆皮
// -> 我的類型是:坦克

五、尾聲

看到上述的代碼,是不是發(fā)現(xiàn)和VueJS源碼里面的很像?其實(shí)VueJS的思路和原理也是類似的,只不過它做了更多的事情,但核心還是在這里邊。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容