深刻淺出vue響應式原理

前言

相信你們在面試的時候沒被面試官少問vue的響應式原理,你們可能都會說經過發佈訂閱模式+數據劫持(Object.defineProperty)把對象裏的屬性轉化爲get和set,當屬性被修改或訪問就通知變化,然而,大多數人可能只是知道這一層面,並無徹底理解。本文將從一個簡單的例子出發,一步步深刻響應式原理。vue

可觀測的對象

舉一個簡單的例子,咱們先定義一個對象:react

const hero = {
    hp: 1000,
    ad: 100
}
複製代碼

這裏定義了一個英雄,hp爲1000,ad爲100。面試

如今咱們能夠經過hero.hphero.ad來讀寫對應的屬性值,可是這個英雄的屬性被讀寫時,咱們並不知道。數組

這時候經過Object.defineProperty就能夠在對應的getset來實現了。瀏覽器

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

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

hero.hp
// -> 1000 
// -> hp屬性被讀取了! 

hero.hp = 4000 
// -> hp屬性被修改了!

複製代碼

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

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

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

如今可使用上面的方法來定義一個響應式的英雄對象。函數

const hero = observable({
    hp: 1000,
    ad: 100
})
複製代碼

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

計算屬性

如今,對象已經可觀測,任何讀寫操做他都會主動告訴咱們,若是咱們但願在修改完對象的屬性值以後,他能主動告訴他的其餘信息該怎麼作?假設有一個watcher方法優化

watcher(hero, 'type', () => {
    return hero.hp <= 1000 ? '後排' : '坦克'
})
複製代碼

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

watcher的三個參數分別是被監聽的對象、被監聽的屬性以及回調函數。回調函數返回一個該被監聽屬性的值。順着這個思路,咱們嘗試着編寫一段代碼:

/** * 當計算屬性的值被更新時調用 * @param { Any } val 計算屬性的值 */ 
function computed(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()
            computed(val)
            return val
        },
        set() {
            console.error('計算屬性沒法被賦值!')
        }
    })
}
複製代碼

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

watcher(hero, 'type', () => {
    return hero.hp <= 1000 ? '後排' : '坦克'
})
hero.type 
hero.hp = 4000 
hero.type
// -> 個人hp屬性被讀取了! 
// -> 個人類型是:後排
// -> 個人hp屬性被修改了! 
// -> 個人hp屬性被讀取了! 
// -> 個人類型是:坦克

複製代碼

這樣看起來確實不錯,可是咱們如今是經過hero.type來獲取這個英雄的類型,並非他主動告訴咱們的,若是但願他的hp修改後能夠當即告訴咱們該怎麼作? ----依賴收集

依賴收集

當一個可觀測的對象被讀取後,會觸發對應的getset,若是在這裏面執行監聽器的computed方法,可讓對象發出通知嗎?

因爲computed方法須要接受回調函數,而可觀測對象內並沒有這個函數,因此須要創建一個「中介」把可觀測對象和監聽器鏈接起來。

中介用來收集監聽器的回調函數的值一級computed()方法

這個中介就叫「依賴收集器」:

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

target用來存放監聽器裏的computed方法。

回到監聽器,看看在什麼地方把computed賦值給Dep.target

/** * 觀測者 * @param { Object } obj 被觀測對象 * @param { String } key 被觀測對象的key * @param { Function } cb 回調函數,返回「計算屬性」的值 */
function watcher(obj, key, cb) {
    // 定義一個被動觸發函數,當這個「被觀測對象」的依賴更新時調用
    const onDepUpdated = () => { 
        const val = cb() 
        computed(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()方法,這個方法很簡單,就是把監聽器回調函數的值以及computed()給打包到一塊,而後賦值給Dep.target。這一步很是關鍵,經過這樣的操做,依賴收集器就得到了監聽器的回調值以及computed()方法。做爲全局變量,Dep.target理所固然的可以被可觀測對象的getter/setter所使用。

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

watcher(hero, 'type', () => {
    return hero.hp <= 1000 ? '後排' : '坦克'
})
複製代碼

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

/** * 使一個對象轉化成可觀測對象 * @param { Object } obj 對象 * @param { String } key 對象的key * @param { Any } val 對象的某個key的值 */
function reactive(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,當get被觸發的時候,就會往裏面添加一個Dep.target。回到關鍵知識點Dep.target等於監聽器的computed()方法,這個時候可觀測對象已經和監聽器捆綁到一塊。任什麼時候候當可觀測對象的set被觸發時,就會調用數組中所保存的Dep.target方法,也就是自動觸發監聽器內部的computed()方法。

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

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

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

/** * 使一個對象轉化成可觀測對象 * @param { Object } obj 對象 * @param { String } key 對象的key * @param { Any } val 對象的某個key的值 */
function reactive(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)
    keys.forEach((key) => { reactive(obj, key, obj[key]) })
    return obj
}

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

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

watcher(hero, 'type', () => {
    return hero.hp <= 1000 ? '後排' : '坦克'
})

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

hero.hp = 4000

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

上述代碼在瀏覽器控制檯可直接執行

代碼優化

在上面的例子中,依賴收集器只是一個簡單的對象,其實在reactive()內部的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.reactive(obj, key, obj[key]) 
        }) 
        return obj 
    }
    reactive(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, computed) { 
        this.obj = obj 
        this.key = key 
        this.cb = cb 
        this.computed = computed 
        return this.defineComputed() 
    }
    
    defineComputed() { 
        const self = this 
        const onDepUpdated = () => { 
            const val = self.cb() 
            this.computed(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({
    hp: 1000,
    ad: 100
})

new Watcher(hero, 'type', () => {
    return hero.hp <= 1000 ? '後排' : '坦克'
}, (val) => {
    console.log(`個人類型是:${hero.type}`)
})

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

hero.hp = 4000

// -> 英雄初始類型:後排 
// -> 個人類型是:坦克
// -> 4000
複製代碼

上述代碼在瀏覽器控制檯可直接執行

結尾

上述代碼,是否是和vue裏的源碼很類似?其實思路是同樣的,本文把核心部分挑出供你們食用。若是你們在學習vue源碼時,不知如何下手,但願這篇文章能給你提供幫助。做者也是參考了許多他人的思想和不斷的嘗試才掌握。

本文是做者蠻早之前的筆記從新整理了一篇供你們食用,若有意見或其餘問題歡迎你們指出,若是對你有幫助請記得點贊關注收藏三連擊。

相關文章
相關標籤/搜索