实现Vue的响应式

603 阅读9分钟

什么是响应式?

简单来说就是当我们有数据发生变化时对该数据有依赖的代码会重新执行。例如在Vue中,当我们的数据发生改变,界面上对该数据有引用的组件会重新渲染。

如何实现响应式?

纯手动实现响应式

  • 最简单的实现响应式的方法,就是当数据发生变化时,我们手动执行需要重新执行的代码
// 想要做到b一直是a的两倍
let a = 2
let b = a * 2

// 当a发生变化时,这个时候b就不是a的两倍了。
a = 4
// 如果想要b是a的两倍,那么我们就需要重新执行 b = a * 2的代码
b = a * 2
  • 上面的实现方式看起来有点蠢,因为每当a发生变化时,都需要我们手动执行b = a * 2这行代码。
  • 如何让b = a * 2这行代码能够自动执行呢?想要让b = a * 2这行代码自动执行,那么就需要知道a什么时候发生变化。 也就是说需要能够监听a发生变化这个行为

如何监听一个对象的某些属性发生变化?

使用Object.defineProperty监听对象

可以使用Object.defineProperty这个API实现监听对象属性发生变化。这也是Vue2响应式实现的方式。如果对Object.defineProperty这个API不熟悉的话,可以点击这里进行查看。

代码实现

let obj = {
    name: 'aaa',
    age: 18
}

// 获取obj对象的所有key
const keys = Object.keys(obj)

// 遍历keys数组,对obj对象的没一个属性进行处理
keys.forEach(key => {
    // 使用value变量保存key对应的属性值
    let value = obj[key]
    // 使用Object.defineProperty进行处理
    Object.defineProperty(obj, key, {
        get() { // 当获取属性时,会来到这里
            console.log(`${key}属性被获取`)
            return value
        },
        set(newValue) { // 当属性被修改时,会来到这里。并且设置的值会传给newValue参数
            console.log(`${key}属性被修改`)
            // 这里不能写成obj[key] = newValue
            // 如果这样写相当于又对该属性进行修改值,又会进入set,让就死循环了。
            value = newValue
        }
    })
})

// 现在我们已经可以实现监听obj对象的读取与修改了
console.log(obj.name) // 在打印'aaa'之前会先打印 'name属性被获取',也就是说监听到属性的获取。
obj.name = 'bbb'  // 打印name属性被修改,也就是说监听到了属性的改变

使用ES6的Proxy实现监听对象。

  • 使用Object.defineProperty虽然也可以实现监听对象的属性。但是它没有办法做到对对象新增的属性进行监听,同时也没有办法做到对数据进行监听。这也是为什么在Vue2中新增属性如果需要响应式那么需要用 $set 来实现,对数组的监听需要重新Array原型上的一些方法。
  • ES6推出了Proxy这个API,该API就是用来实现监听对象的,而且该API对数组同样也是有效果的。在使用Proxy时,通常会搭配Reflect一起使用。如果对Proxy不熟悉可以点击这里进行查看如果对Reflect不熟悉可以点击这里进行查看 代码实现
let obj = {
    name: 'aaa',
    age: 18
}

// 第一个参数为要代理的对象,第二个参数为handler。
const proxy = new Proxy(obj, {
    // 当访问某一个属性时,会来到该getter。
    // 同时会传递三个参数。
    // target要进行代理的对象,这里就是obj
    // key被访问的属性
    // receiver用来绑定this的
    get(target, key, receiver) {
        console.log(`${key}属性被访问`)
        return Reflect.get(target, key, receiver)
    },
    // 当修改某一属性时,会来到该Setter
    // 同时会传递四个参数
    // target要进行代理的对象,这里就是obj
    // key被访问的属性
    // newValue新修改的值
    // receiver用来绑定this的
    set(target, key, newValue, receiver) {
        console.log(`${key}属性被修改`)
        return Reflect.set(target, key, newValue, receiver)
    },
    // 后面还有很多handler可以进行书写,这里就不进行书写了。
})

// 执行完上面的代码后,得到的proxy对象就是obj对象的代理。
// 我们只需要修改代理对象就可以做到修改原对象的效果
// 而且我们对代理对象的修改是我们能够监听到的。
console.log(proxy.name) // 在打印'aaa'之前会先打印 'name属性被获取',也就是说监听到属性的获取。
proxy.name = 'bbb'  // 打印name属性被修改,也就是说监听到了属性的改变

现在我们已经可以做到监听对象了,那么想要实现响应式,还需要做到什么呢?我们还需要知道,当某个属性发生改变了,那么哪些代码需要重新执行,也就是说哪些代码依赖到了这个属性。

如何实现依赖的收集?

创建一个依赖的类

class Dep {
    constructor() {
        this.effects = new Set()
    }
    
    // 添加依赖
    addDep() {
    
    }
    
    // 重新执行所有依赖
    notify() {
        this.effects.forEach(effect => {
            effect()
        })
    }
}
  • 上面的Dep类就是我们用来收集依赖的类。用一个Set来保存副作用函数,这样可以避免同一个副作用函数被执行多次。当发现某个属性发生变化时时,调用notify即可执行所有的副作用函数,做到响应式。
  • 上面提到了副作用函数副作用函数简单来说就是当某个属性发生变化时,需要重新执行的函数,就把它称之为副作用函数
  • 想要重新执行副作用函数,我们就必须收集副作用函数。我们可以实现一个类似于Vue3的watchEffect函数来对副作用函数进行收集。

watchEffect函数

// 用一个全局变量来保存当前的副作用函数,这也是Vue的做法。
let effect = null

// 该函数接收一个副作用函数作为参数
watchEffect(fn) {
    // 将fn保存到effect中
    effect = fn
    // 传入watchEffect的参数要被执行,所以需要调用fn函数
    fn()
    // 将effect置空
    effect = null
}
  • watchEffect函数先将fn保存到全局变量effect中,然后再执行fn这个副作用函数。收集依赖的时候就可以根据这个effect是否有值来进行收集。
  • 有了这个effect的全局变量,我们就可以实现Dep类中的addDep方法了。
class Dep {
    constructor() {
        this.effects = new Set()
    }
    
    // 添加依赖
    addDep() {
        // 判断effect是否有值,如果有值,那么就对该effect进行收集。
        if (effect) {
            this.effects.add(effect)
        }
    }
    
    // 重新执行所有依赖
    notify() {
        this.effects.forEach(effect => {
            effect()
        })
    }
}
  • 实现了依赖收集,以及数据监听后。还需要做到保存依赖和数据的对应关系。比如说,当obj对象的a属性发生变化了,我们得知道哪些函数依赖了obj对象的a属性,这样才能对这些依赖函数进行重新执行。

实现依赖和属性的对应关系。

想要实现依赖和属性的对应关系就必须选择一个合适的数据结构来保存对应关系。我们可以使用WeakMap和Map来进行保存。如果对Map不是很了解的话可以点击这里。 如果对WeakMap不是很了解的话可以点击这里。

为什么要使用WeakMap和Map呢

当一个对象的属性发生变化时,我们可以获取到这个对象以及这个属性的key。那么我们就可以将这个对象作为WeakMap的key对应的Value就是一个Map对象。这个Map的key是监听对象的属性,value就是收集的这个属性对应的依赖。

image.png 有了这个数据结构之后,需要编写一个通过该数据结构获取依赖的函数

代码实现

// 用来保存对应关系的
const weakMap = new WeakMap()

function getDep(target, key) {
    // 这里得到的targetDep就相当于上图的Map对象
    let targetDep = weakMap.get(target)
    // 判断targetDep是否存在
    if (!targetDep) {
        // 如果不存在,则进行添加
        weakMap.set(target, new Map())
        // 重新获取Map对象
        targetDep = weakMap.get(target) 
    }
    // 这里的到的keyDep就相当于上图的Map对象中的value
    let keyDep = targetDep.get(key)
    // 判断keyDep是否存在
    if (!keyDep) {
        // 如果不存在,这添加一个 Dep实例
        targetDep.set(key, new Dep())
        keyDep = targetDep.get(key)
    }
  // 返回依赖
  return keyDep
}

经过了上面一步一步实现,我们现在只需要将上面的所有东西合并起来就可以实现响应式了

响应式完整代码实现

// 依赖的类
class Dep {
    constructor() {
        this.effects = new Set()
    }
    
    addDep() {
        if (effect) {
            this.effects.add(effect)
        }
    }
    
    notify() {
        this.effects.forEach(effect => {
            effect()
        })
    }
}

// 全局变量用于保存当前执行的副作用函数
let effect = null
function watchEffect(fn) {
    effect = fn
    fn()
    effect = null
}

// 用来保存对应关系的weakMap
const weakMap = new WeakMap()
// 获取依赖的函数
function getDep(target, key) {
    // 这里得到的targetDep就相当于上图的Map对象
    let targetDep = weakMap.get(target)
    // 判断targetDep是否存在
    if (!targetDep) {
        // 如果不存在,则进行添加
        weakMap.set(target, new Map())
        // 重新获取Map对象
        targetDep = weakMap.get(target) 
    }
    // 这里的到的keyDep就相当于上图的Map对象中的value
    let keyDep = targetDep.get(key)
    // 判断keyDep是否存在
    if (!keyDep) {
        // 如果不存在,这添加一个 Dep实例
        targetDep.set(key, new Dep())
        keyDep = targetDep.get(key)
    }
  // 返回依赖
  return keyDep
}

// 把上面监听对象的代码封装成一个函数。
function reactive(obj) {
    return new Proxy(obj, {
        get(target, key, receiver) {
            // 当执行副作用函数时,会访问到proxy对象的属性,然后在这里被监听。
            // 获取dep实例
            const dep = getDep(target, key)
            // 调用dep实例的addDep方法收集依赖
            dep.addDep()
            return Reflect.get(target, key, receiver)
        },
        set(target, key, newValue, receiver) {
            // 当修改属性值时,会来到这里。我们需要这这里进行触发dep实例的notify
            // 先对属性值进行更改
            Reflect.set(target, key, newValue, receiver)
            // 获取dep实例
            const dep = getDep(target, key)
            // 调用notify方法,重新执行所有副作用函数。
            dep.notify()
            return true // 在严格模式下,需要有返回值 
        }
    })
}

测试代码

// 调用reactive让对象变成响应式对象
const obj = reactive({
  name: 'aaa',
  age: 18
})

const obj1 = reactive({
  name: "bbb",
  age: 19
})

// 使用watchEffect来实现数据变化,函数重新执行
watchEffect(() => { 
  console.log(obj.name);
})

watchEffect(() => { 
  console.log(obj1.name);
})

obj.name = 'ccc'
obj1.name = 'ddd'

// 代码首先会打印 'aaa' 'bbb'
// 由于我们重新修改了obj.name 和 obj1.name,所以会重新执行副作用函数,打印'ccc' 'ddd'

总结

我们对一个对象使用reactive函数,这样我们就可以监听到这个对象的属性获取和属性变化了。通过watchEffect执行函数,执行函数之前先将该函数保存到一个全局变量effect中,然后在对传入的函数进行执行。在执行这个函数的过程中,由于用到了响应式对象的某个属性,所以我们会来到这个属性的get方法中,在get方法中,我们通过target和key获取到了dep实例,然后调用这个dep实例的addDep方法addDep方法会将我们保存在全局变量effect的这个函数收集到依赖中。这样我们通过执行传入watchEffect的函数就可以做到依赖的收集。当我们修改响应式对象的某个属性时,我们会来到这个属性的set方法。在set方法中,我们先对对象属性进行修改,这样才能确保后面重新执行的副作用函数获取到的值是新的值。然后通过getDep方法获取到dep实例,然后调用dep实例的notify方法,从而就做到了响应式。