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

本文是做者蛮早之前的笔记从新整理了一篇供你们食用,若有意见或其余问题欢迎你们指出,若是对你有帮助请记得点赞关注收藏三连击。

相关文章
相关标签/搜索