概述
我们知道,Vue 的响应式原理应该是面试中最常被问到的知识点之一了。那么 Vue 的响应式原理到底是如何做的呢?简而言之,Vue 2的响应式原理是基于 Object.defineProperty() ,Vue3 是基于 Proxy 实现的。具体是如何实现数据变化视图就能自动变化的,可以参考下图:
详细的过程说明,我们可以单独再开一篇进行讲解。本文主要手写实现一下数据的响应式更新。
手写响应式原理
认识响应式逻辑
什么是数据响应式?简而言之,数据响应式就是数据变动能够被自动的监测到,并且自动的触发相应的数据更新。看下面的例子:
// 对象的响应式
const obj = {
name: "Lee",
age: 18
}
function foo() {
console.log(obj.name) // Lee
console.log(obj.age) // 18
}
foo()
function bar() {
console.log(obj.age + 100)
}
// 修改obj对象
obj.name = "Melody"
obj.age = 20
bar() // 120
看上面的代码,我们可能会疑惑道,这都是一些普通的同步代码,和响应式有什么关系呢?别着急,慢慢来嘛。从上面的代码我们可以知道,foo函数的执行结果是 obj 对象的初始值,而 bar 函数的执行结果是 obj 属性变化后的值,但现在我们做的都是一种手动的过程,我们现在需要做的就是,如何让其变成一种自动化的过程,只要 obj 的属性发生变化,那么 bar 函数就会自动重新执行。即需要一种方式自动的收集依赖的变化,当对应的依赖发生变化时,自动的更新数据。
响应式依赖收集
现在的问题是我们该如何收集依赖的变化呢?首先,执行响应式的代码肯定不止一行,所以我们可以将这些代码放到一个函数中。那么现在的问题就变成了,当数据发生变化时,去自动的执行某一个函数。
但是有一个问题:在开发中我们是有很多的函数的,我们如何区分一个函数需要响应式,还是不需要响应式呢?
- 很明显,下面的函数中 foo 需要在 obj 的 name 发生变化时,重新执行,做出相应;
- 而 bar 函数是一个完全独立于obj的函数,它不需要执行任何响应式的操作;
所以现在我们的问题就是,如何区分哪些函数是需要响应式的,哪些函数是不需要的
- 这个时候我们就需要封装一个新的函数watchFn,凡是传入到watchFn的函数,就是需要响应式的,其他默认定义的函数都是不需要响应式的。这么说可能还是有些抽象,看代码吧:
const obj = {
name: "Lee",
age: 18
}
// function foo() {
// console.log("foo:", obj.name)
// console.log("foo", obj.age)
// console.log("foo function")
// }
// function bar() {
// console.log("bar:", obj.name + " hello")
// console.log("bar:", obj.age + 10)
// console.log("bar function")
// }
// 设置一个专门执行响应式函数的一个函数
const reactiveFns = []
function watchFn(fn) {
reactiveFns.push(fn)
fn()
}
watchFn(function foo() {
console.log("foo:", obj.name)
console.log("foo", obj.age)
console.log("foo function")
})
watchFn(function bar() {
console.log("bar:", obj.name + " hello")
console.log("bar:", obj.age + 10)
console.log("bar function")
})
// 修改obj的属性
console.log("name发生变化-----------------------")
obj.name = "Melody"
reactiveFns.forEach(fn => {
fn()
})
从上述代码可以看出,我们使用了函数来手动的收集对象的依赖,然后设置了一个函数 watchFn,只要传入到watchFn中的函数就是响应式的。我们将所有收集到的依赖放到一个数组 reactiveFns中进行管理,当依赖发生变化时,遍历 reactiveFns ,也就将所有的监听函数执行了一遍,这样也就实现了数据的响应式更新。
响应式依赖收集优化
上面的代码实现了一个简单的数据响应式更新流程,但有个问题就是,目前我们收集到的依赖是放入到一个数组来保存的,但是这里会存在数据管理的问题:
- 我们实际开发中需要监听很多对象的响应式,这些对象需要监听的不只是一个属性,他们很多属性的变化,都会有对应的响应式函数,我们不可能在全局维护一大堆的数组来保存这些响应函数。
所以,我们所以我们要设计一个类,这个类用于管理某一个对象的某一个属性的所有响应式函数:
- 相当于替代了原来的简单reactiveFns的数组
看代码吧:
class Depend {
constructor() {
this.reactiveFns = []
}
addDepend(fn) {
if (fn) {
this.reactiveFns.push(fn)
}
}
notify() {
this.reactiveFns.forEach(fn => {
fn()
})
}
}
const obj = {
name: "Lee",
age: 18
}
// 设置一个专门执行响应式函数的一个函数
const dep = new Depend()
function watchFn(fn) {
dep.addDepend(fn)
fn()
}
watchFn(function foo() {
console.log("foo:", obj.name)
console.log("foo", obj.age)
console.log("foo function")
})
watchFn(function bar() {
console.log("bar:", obj.name + " hello")
console.log("bar:", obj.age + 10)
console.log("bar function")
})
// 修改obj的属性
console.log("name发生变化-----------------------")
obj.name = "Melody"
dep.notify()
console.log("age发生变化-----------------------")
dep.notify()
console.log("name发生变化-----------------------")
obj.name = "james"
监听属性的变化
通过上述代码我们手动的实现了数据的响应式依赖收集工作。从现在开始我们慢慢的从手动收集的过程转向自动化的过程。从上述的代码可以看出,当我们收集的依赖发生改变时,我们是手动的通知相关的响应式函数进行更新的,且针对对象的每个属性发生变化时,我们都要手动的进行通知。
但是实际的开发中,我们需要监听对象的每个属性发生的变化,并自动的通知监听函数执行更新。
那现在我们应该采取什么样的方法呢?这个时候就要用到 Vue 的响应式原理了,我们先实现一下 Vue2 的,最后再去实现 Vue3的。**在 Vue2 中我们使用的是 Object.defineProperty 来实现对象属性的监听,并且在属性发生变化时,通过全局对象 dep 去通知监听函数执行更新数据。**我们一起来看一下代码的实现吧:
class Depend {
constructor() {
this.reactiveFns = []
}
addDepend(fn) {
if (fn) {
this.reactiveFns.push(fn)
}
}
notify() {
this.reactiveFns.forEach(fn => {
fn()
})
}
}
const obj = {
name: "Lee",
age: 18
}
// 设置一个专门执行响应式函数的一个函数
const dep = new Depend()
function watchFn(fn) {
dep.addDepend(fn)
fn()
}
// 方案一: Object.defineProperty() -> Vue2
Object.keys(obj).forEach(key => {
let value = obj[key]
Object.defineProperty(obj, key, {
set: function(newValue) {
/*
这里一定不能直接写成 obj[key] = newValue,
大家可以思考一下为什么(提示:死循环)
*/
value = newValue
dep.notify()
},
get: function() {
return value
}
})
})
watchFn(function foo() {
console.log("foo:", obj.name)
console.log("foo", obj.age)
console.log("foo function")
})
watchFn(function bar() {
console.log("bar:", obj.name + " hello")
console.log("bar function")
})
// 修改obj的属性
console.log("name发生变化-----------------------")
obj.name = "Melody"
console.log("age发生变化-----------------------")
obj.age = 20
console.log("name发生变化-----------------------")
obj.name = "james"
自动收集依赖
我们目前是创建了一个Depend对象,用来管理对于属性变化需要监听的响应函数
但是实际开发中我们会有不同的对象,另外会有不同的属性需要管理;而我们上面的做法是,不管响应式依赖收集函数的内部如何,都会不管它三七二十一,直接将其加入到 dep 对象中,然后执行。事实上,看上述代码我们就应该知道,当obj的name属性发生变化时,foo和bar函数都应该执行;而当obj的age属性发生变化时,应该只执行 foo 函数,不应该去执行bar函数。
那我们如何可以使用一种数据结构来管理不同对象的不同依赖关系呢? 这个时候就需要用到 Map 和 WeakMap了,如下图:
这里对dep对象数据结构的管理再做一个说明:
- 每一个对象的每一个属性都会对应一个dep对象
- 同一个对象的多个属性的dep对象是存放一个map对象中
- 多个对象的map对象, 会被存放到一个objMap的对象中
至于为什么管理各个对象时用的是WeakMap,而管理每个对象的各个属性用的是Map呢?
这就牵扯到强引用、弱引用和垃圾回收机制了。我们知道WeakMap的key对于对象的引用是弱引用,这意味着当没有其他引用引用这个对象时,该对象就会被GC回收。而Map对象的key的引用是强引用,除非主动清除,否则GC不会回收。
现在回到最初的问题,为什么管理各个对象时用的是WeakMap呢?我们可以想一下,如果现在我们不需要监听 obj1了,将obj1 置为 null,此时obj1应该被销毁,即使它仍然存在于WeakMap中,但是如果我们使用的Map,则此时仍然存在一个对obj1的强引用,就无法销毁obj1。
为了实现依赖的自动收集,我们先来封装一个函数,这个函数负责通过obj的key获取对应的Depend对象,也就是上面图示所做的内容。
// 封装一个函数: 负责通过obj的key获取对应的Depend对象
const objMap = new WeakMap()
function getDepend(obj, key) {
// 1.根据对象obj, 找到对应的map对象
let map = objMap.get(obj)
if (!map) {
map = new Map()
objMap.set(obj, map)
}
// 2.根据key, 找到对应的depend对象
let dep = map.get(key)
if (!dep) {
dep = new Depend()
map.set(key, dep)
}
return dep
}
这里有必要再对依赖收集做出进一步的声明:
-
我们之前收集依赖的地方是在watchFn中
- 但是这种收集依赖的方式我们根本不知道是哪一个key的哪一个depend需要收集依赖;
- 你只能针对一个单独的depend对象来添加你的依赖对象;
-
那么正确的应该是在哪里收集呢?
- 应该在我们调用了Object.defineProperty的get捕获器时,因为如果一个函数中使用了某个对象的key,那么它应该被收集依赖;
- 所以我们应将依赖收集的工作放到get捕获器中去做,当执行get函数时自动的添加响应式依赖收集函数fn
接下来我们来实现一下响应式依赖的自动收集。
/**
* 1.dep对象数据结构的管理
* 每一个对象的每一个属性都会对应一个dep对象
* 同一个对象的多个属性的dep对象是存放一个map对象中
* 多个对象的map对象, 会被存放到一个objMap的对象中
* 2.依赖收集: 当执行get函数, 自动的添加响应式依赖收集函数fn
*/
class Depend {
constructor() {
this.reactiveFns = []
}
addDepend(fn) {
if (fn) {
this.reactiveFns.push(fn)
}
}
notify() {
this.reactiveFns.forEach(fn => {
fn()
})
}
}
const obj = {
name: "Lee",
age: 18
}
// 设置一个专门执行响应式函数的一个函数
let reactiveFn = null
function watchFn(fn) {
// dep.addDepend(fn)
reactiveFn = fn
fn()
reactiveFn = null
}
// 封装一个函数: 负责通过obj的key获取对应的Depend对象
const objMap = new WeakMap()
function getDepend(obj, key) {
// 1.根据对象obj, 找到对应的map对象
let map = objMap.get(obj)
if (!map) {
map = new Map()
objMap.set(obj, map)
}
// 2.根据key, 找到对应的depend对象
let dep = map.get(key)
if (!dep) {
dep = new Depend()
map.set(key, dep)
}
return dep
}
// 方案一: Object.defineProperty() -> Vue2
Object.keys(obj).forEach(key => {
let value = obj[key]
Object.defineProperty(obj, key, {
set: function(newValue) {
value = newValue
const dep = getDepend(obj, key)
dep.notify()
},
get: function() {
// 拿到obj -> key
// console.log("get函数中:", obj, key)
// 找到对应的obj对象的key对应的dep对象
const dep = getDepend(obj, key)
dep.addDepend(reactiveFn)
return value
}
})
})
watchFn(function foo() {
console.log("foo function")
console.log("foo:", obj.name)
console.log("foo", obj.age)
})
watchFn(function bar() {
console.log("bar function")
console.log("bar:", obj.age + 10)
})
// 修改obj的属性
console.log("name发生变化-----------------------")
obj.name = "Melody"
// console.log("age发生变化-----------------------")
// obj.age = 20
执行上述代码后我们会发现,当obj的name属性发生改变时,只有foo函数会执行,bar函数不会执行。
自动收集依赖的细节处理(优化)
我们上述的代码还存在有两个问题:
- 问题一:如果函数中有用到两次key,比如name,那么这个函数会被收集两次
- 问题二:我们并不希望将添加reactiveFn放到get中,因为它是属于Dep的行为
所以我们需要对Depend类进行重构:
- 解决问题一的方法:不使用数组,而是使用Set
- 解决问题二的方法:添加一个新的方法,用于收集依赖
class Depend {
constructor() {
this.reactiveFns = new Set()
}
addDepend(fn) {
if (fn) {
this.reactiveFns.add(fn)
}
}
depend() {
if (reactiveFn) {
this.reactiveFns.add(reactiveFn)
}
}
notify() {
this.reactiveFns.forEach(fn => {
fn()
})
}
}
// 设置一个专门执行响应式函数的一个函数
let reactiveFn = null
function watchFn(fn) {
reactiveFn = fn
fn()
reactiveFn = null
}
// 封装一个函数: 负责通过obj的key获取对应的Depend对象
const objMap = new WeakMap()
function getDepend(obj, key) {
// 1.根据对象obj, 找到对应的map对象
let map = objMap.get(obj)
if (!map) {
map = new Map()
objMap.set(obj, map)
}
// 2.根据key, 找到对应的depend对象
let dep = map.get(key)
if (!dep) {
dep = new Depend()
map.set(key, dep)
}
return dep
}
const obj = {
name: "Lee",
age: 18,
address: "广州市"
}
// 方案一: Object.defineProperty() -> Vue2
Object.keys(obj).forEach(key => {
let value = obj[key]
Object.defineProperty(obj, key, {
set: function(newValue) {
value = newValue
const dep = getDepend(obj, key)
dep.notify()
},
get: function() {
// 拿到obj -> key
// console.log("get函数中:", obj, key)
// 找到对应的obj对象的key对应的dep对象
const dep = getDepend(obj, key)
// dep.addDepend(reactiveFn)
dep.depend()
return value
})
})
// ========================= 业务代码 ========================
watchFn(function() {
console.log(obj.name)
console.log(obj.age)
console.log(obj.age)
})
// watchFn(function() {
// console.log(obj.address)
// })
// watchFn(function() {
// console.log(obj.age)
// console.log(obj.address)
// })
// 修改name
console.log("--------------")
// obj.name = "Melody"
obj.age = 20
// obj.address = "上海市"
多个对象响应式
我们目前的响应式是针对于obj一个对象的,我们可以创建出来一个函数,针对所有的对象都可以变成响应式对象:
function reactive(obj) {
Object.keys(obj).forEach(key => {
let value = obj[key]
Object.defineProperty(obj, key, {
set: function(newValue) {
value = newValue
const dep = getDepend(obj, key)
dep.notify()
},
get: function() {
// 拿到obj -> key
// console.log("get函数中:", obj, key)
// 找到对应的obj对象的key对应的dep对象
const dep = getDepend(obj, key)
// dep.addDepend(reactiveFn)
dep.depend()
return value
}
})
})
return obj
}
Vue3实现响应式
我们前面所实现的响应式的代码,其实就是Vue2中的响应式原理:
- Vue2中通过我们前面学习过的Object.defineProerty的方式来实现对象属性的监听
- Vue3主要是通过Proxy来监听数据的变化以及收集相关的依赖的;
- 我们可以将reactive函数进行如下的重构:
- 在传入对象后,直接通过Proxy监听对象的所有属性,并在在setter和getter捕获器中来监听属性的获取和修改,捕获器中的逻辑和Object.defineProperty是一致的
响应式原理的完整实现
class Depend {
constructor() {
this.reactiveFns = new Set()
}
addDepend(fn) {
if (fn) {
this.reactiveFns.add(fn)
}
}
depend() {
if (reactiveFn) {
this.reactiveFns.add(reactiveFn)
}
}
notify() {
this.reactiveFns.forEach(fn => {
fn()
})
}
}
// 设置一个专门执行响应式函数的一个函数
let reactiveFn = null
function watchFn(fn) {
reactiveFn = fn
fn()
reactiveFn = null
}
// 封装一个函数: 负责通过obj的key获取对应的Depend对象
const objMap = new WeakMap()
function getDepend(obj, key) {
// 1.根据对象obj, 找到对应的map对象
let map = objMap.get(obj)
if (!map) {
map = new Map()
objMap.set(obj, map)
}
// 2.根据key, 找到对应的depend对象
let dep = map.get(key)
if (!dep) {
dep = new Depend()
map.set(key, dep)
}
return dep
}
// 方案一: Object.defineProperty() -> Vue2
// function reactive(obj) {
// Object.keys(obj).forEach(key => {
// let value = obj[key]
// Object.defineProperty(obj, key, {
// set: function(newValue) {
// value = newValue
// const dep = getDepend(obj, key)
// dep.notify()
// },
// get: function() {
// // 拿到obj -> key
// // console.log("get函数中:", obj, key)
// // 找到对应的obj对象的key对应的dep对象
// const dep = getDepend(obj, key)
// // dep.addDepend(reactiveFn)
// dep.depend()
// return value
// }
// })
// })
// return obj
// }
// 方式二: new Proxy() -> Vue3
function reactive(obj) {
const objProxy = new Proxy(obj, {
set: function(target, key, newValue, receiver) {
// target[key] = newValue
Reflect.set(target, key, newValue, receiver)
const dep = getDepend(target, key)
dep.notify()
},
get: function(target, key, receiver) {
const dep = getDepend(target, key)
dep.depend()
return Reflect.get(target, key, receiver)
}
})
return objProxy
}
// ========================= 业务代码 ========================
const obj = reactive({
name: "Lee",
age: 18,
address: "广州市"
})
watchFn(function() {
console.log(obj.name)
console.log(obj.age)
console.log(obj.age)
})
// 修改name
console.log("--------------")
// obj.name = "Melody"
obj.age = 20
// obj.address = "上海市"
console.log("=============== user =================")
const user = reactive({
nickname: "abc",
level: 100
})
watchFn(function() {
console.log("nickname:", user.nickname)
console.log("level:", user.level)
})
user.nickname = "cba"
总结
通过上述步骤我们一步步的实现了Vue中数据响应式的核心原理,相信大家跟着做了以后一定会对Vue的响应式有更加深刻的了解。下次见~