Vue响应式系统实现基本思路
在vue中,我们可以使用watch监听数据的变化,像下面代码一样。
const vm = new Vue({
data: {
a: 1
}
})
vm.$watch('a', () => {
console.log('a被修改了')
})
vm.a = 2
当执行vm.a = 2的时候,会输出 ‘a被修改了‘, 但这是怎么实现的呢,我将一步一步记录下来,但此篇文章只记录思路,具体vue怎么实现还需要看源码。
思考1. 为什么我们修改vm.a的时候,会触发watch函数, 进一步思考,什么时候我们可以监听对象属性的修改?
首先想到的肯定是对象的访问器属性吧,即getter/setter
, 当我们设置对象的某个属性时, 会触发setter
访问器,当然, es6的Proxy也是可以的,据说vue3是用Proxy重写的。回到正轨,既然我们能监听对象属性的修改,那我们完全可以在setter
中执行watch的回调函数。即
const data = {
a: 1
}
Object.defineProperty(data, 'a', {
set() {
// 执行watch函数的第二个参数, 即回调函数
},
get() {
}
})
$watch('a', () => {
console.log('修改了a')
})
思考2: 如何在setter中执行$watch函数的回调函数(后面称之为 依赖)?
首先,我们要思考如何才能收集到依赖,既然在setter中执行收集到的依赖,现在只剩getter函数了,那我们就在getter函数中收集依赖。
const dep = [] // 收集依赖的数组
const data = {
a: 1
}
Object.defineProperty(data, 'a', {
set() {
// 执行收集到的依赖
dep.forEach(fn => fn())
},
get() {
dep.push(Target)
}
})
以上代码有两个问题
-
当我们执行data.a的时候返回会返回undefined, 为什么呢, 因为我们在写getter函数的时候,没有写return
-
getter函数中的Target是从哪来的?
根据以上两个问题, 我们完善以上代码
const dep = [] // 收集依赖的数组
const data = {
a: 1
}
let val = data.a // 缓存字段原有的值
Object.defineProperty(data, 'a', {
set(newVal) {
if(newVal === val) return // 如果新设置的值和旧值是一样的,则什么都不做
val = newVal // 新值覆盖旧值
// 执行收集到的依赖
dep.forEach(fn => fn())
},
get() {
dep.push(Target)
return val
}
})
let Target = null
function $watch(exp, fn) {
Target = fn // 将fn存储在变量Target中
data[exp] // 通过读取data[exp]的值,触发getter, 从而将fn(依赖)收集到dep中
}
对于第一个问题,我们首先缓存一下data.a的值, 然后再在getter函数中返回
对于第二个问题,我们只能对$watch方法下手了,我们首先将fn存储到Target中,然后通过data[exp]触发getter完成依赖收集。
$watch('a', () => {
console.log('a被修改了')
})
data.a = 2
此时,执行以上代码,就会触发$watch函数, 输出 a被修改了
思考3:以上实现只能监听对象的一个属性,那么如何监听多个属性呢?
对于这个问题,我们完全可以使用for循环来遍历对象,重复执行上述代码
const data = {
a: 1,
b: 2
}
for(let key in data) {
const dep = [] // 收集依赖的数组, 放在for循环里,这个每一个属性都会有自己的依赖收集器
let val = data[key] // 缓存字段原有的值
Object.defineProperty(data, key, {
set(newVal) {
if(newVal === val) return // 如果新设置的值和旧值是一样的,则什么都不做
val = newVal // 新值覆盖旧值
// 执行收集到的依赖
dep.forEach(fn => fn())
},
get() {
dep.push(Target)
return val
}
})
}
let Target = null
function $watch(exp, fn) {
Target = fn // 将fn存储在变量Target中
data[exp] // 通过读取data[exp]的值,触发getter, 从而将fn(依赖)收集到dep中
}
当我们执行以下代码时, 发现data对象的a,b属性都被监听到了。
$watch('a', () => {
console.log('a被修改了')
})
$watch('b', () => {
console.log('b被修改了')
})
data.a = 3
data.b = 4
思考4: 目前已经实现了对对象多个属性的监听,那要是遇到嵌套属性的该如何解决呢?
假设对象data是如下形式
const data = {
a: {
b: 1
}
}
此时,我们依然执行上面的代码,发现只有a属性可以被监听到,而b属性没有被监听到,这不是我们想要的,所以继续修改上述代码。
首先,我们应该修改$watch函数的传参,既然它是嵌套的,所以我们的监听b属性是不是应该写成
$watch('a.b', () => {
console.log('b被修改了')
})
data.a.b = 2
发现还是没有生效😂,仔细观察,这段代码是有问题的
首先, 传递的参数’a.b’是一个字符串,它不可能监听到a.b
的变化,所以,我们要解析这种带.
的字符串, 修改$watch函数如下
let Target = null
function $watch(exp, fn) {
Target = fn
let obj = data
if(/\./.test(exp)) { // 判断传递的参数中是否包含.
let pathArr = exp.split('.') // 获取到嵌套属性的数组,以'a.b'为例,得到[a, b]
pathArr.forEach(key => {
obj = obj[key] // 循环获取到obj.a.b
})
return
}
obj[exp]
}
还记得$watch函数的两个作用吗?
-
通过Target变量存储依赖
-
通过读取属性值触发访问器属性getter,完成依赖收集
所以, 如上代码, 当第一个参数是带有.
的字符串,那么遍历,直到读取到最后一个属性,触发getter,完成依赖收集,之后return,不执行后面的代码。
修改$watch函数以后,我们可以再试试,发现还是不可以,那问题出在哪呢?
代码就这么两部分,$watch函数已经修改了,没能产生我们预期的结果,那说明另一部分的代码也需要修改,回顾代码
for(let key in data) {
const dep = [] // 收集依赖的数组, 放在for循环里,这个每一个属性都会有自己的依赖收集器
let val = data[key] // 缓存字段原有的值
Object.defineProperty(data, key, {
set(newVal) {
if(newVal === val) return // 如果新设置的值和旧值是一样的,则什么都不做
val = newVal // 新值覆盖旧值
// 执行收集到的依赖
dep.forEach(fn => fn())
},
get() {
dep.push(Target)
return val
}
})
}
诶诶诶,我好像发现了什么。我们的for循环只能循环出来第一层对象属性啊,对于嵌套属性没辙啊。所以,怎么办呢?我们可以使用递归啊
function walk(data) {
for(let key in data) {
const dep = []
let val = data[key]
if(Object.prototype.toString.call(val) === '[object Object]') {
walk(val)
}
Object.defineProperty(data, key, {
set(newVal) {
if(newVal === val) return
val = newVal
dep.forEach(fn => fn())
},
get() {
dep.push(Target)
return val
}
})
}
}
walk(data)
我们再次执行如下代码
$watch('a.b', () => {
console.log('b被修改了')
})
data.a.b = 2
诶!怎么这么神奇呢,它按照我们预期的输出了。
思考5: 我们在写Vue代码的时候,在template模板里读取data里的变量,会不会触发依赖收集呢?
答案是肯定的,那它是如何实现的呢?首先template模板会被处理成render函数,然后执行render函数去完成页面的渲染,具体的实现逻辑还请参照vue源码,因为我目前也不是很清楚(TODO),既然它是个函数,那我们的$watch 函数是不是也应该可以接收函数作为参数
const data = {
name: 'simple',
age: 22
}
function render() {
document.write(`hello, my name is ${data.name}, I am ${data.age} years old this year.`)
}
let Target = null
function $watch(exp, fn) {
Target = fn
let obj = data
if(typeof exp === 'function') {
exp()
return
}
if(/\./.test(exp)) {
let pathArr = exp.split('.')
pathArr.forEach(key => {
obj = obj[key]
})
return
}
return obj[key]
}
我们首先判断exp是不是函数,如果是函数,则直接执行函数,完成依赖收集,后面代码不再执行。同时我们要注意传递给$watch函数的第二个参数
$watch(render, render)
第二个参数依然是render,即当依赖发生变化时,会重新执行render函数,我们就实现了数据变化,并将变化应用到DOM上。
总结
以上,大概就是vue响应式系统的基本思路,但是我们实现的都是最简单的,有很多不完善的地方,例如,如何避免重复收集依赖,如何处理数组以及其他边界条件等等。
以下是完整代码
function walk(data) {
for(let key in data) {
const dep = []
let val = data[key]
if(Object.prototype.toString.call(val) === '[object Object]') {
walk(val)
}
Object.defineProperty(data, key, {
set(newVal) {
if(newVal === val) return
val = newVal
dep.forEach(fn => fn())
},
get() {
dep.push(Target)
return val
}
})
}
}
walk(data)
let Target = null
function $watch(exp, fn) {
Target = fn
let obj = data
if(typeof exp === 'function') {
exp()
return
}
if(/\./.test(exp)) {
let pathArr = exp.split('.')
pathArr.forEach(key => {
obj = obj[key]
})
return
}
return obj[key]
}
参考: https://github.com/HcySunYang/vue-design/tree/elegant