深入浅出vue响应式原理

1,609 阅读3分钟

前言

相信大家在面试的时候没被面试官少问vue的响应式原理,大家可能都会说通过发布订阅模式+数据劫持(Object.defineProperty)把对象里的属性转化为get和set,当属性被修改或访问就通知变化,然而,大多数人可能只是知道这一层面,并没有完全理解。本文将从一个简单的例子出发,一步步深入响应式原理。

可观测的对象

举一个简单的例子,我们先定义一个对象:

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。现在来尝试一下:

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源码时,不知如何下手,希望这篇文章能给你提供帮助。作者也是参考了许多他人的思想和不断的尝试才掌握。

本文是作者蛮早以前的笔记重新整理了一篇供大家食用,如有意见或其他问题欢迎大家指出,如果对你有帮助请记得点赞关注收藏三连击。