Vue3底层响应式原理 (二)
上节说完了响应式系统怎么实现的一些基本远离,并且也实现了一个最基本的响应式系统,接下来就研究一下Vue3中的非基本类型是怎么实现其响应式的。包括如何拦截Object中的in以及for in 操作。还有如何对数组进行代理,对集合类型(Map、Set、WeakMap、WeakSet等)如何进行代理。我们先来讨论对Object的拦截。
二、非基本类型的响应式原理(第一节实现Object的响应式)
1、理解Proxy和Reflect
我们知道Vue3中对对象object的响应式拦截是用Es6中的Proxy来实现的。那我们就很有必要来理解一下Proxy以及结合使用的Reflect的知识。使用Proxy可以创建代理对象,也就是说Proxy只能代理对象,对基本的String、Boolean、Number等基本数据类型是不可以进行代理的。
- 首先我们来了解一下什么是对对象的代理:
所谓的代理,就是对一个对象基本语义的代理,它允许我们拦截并重新定义一个对象的基本操作。而基本语义指的是对一个obj进行的基本操作,比如读、设置。
类似这种对对象的读取和设置,就属于对象的基本语义,那么就允许我们使用Proxy来对其进行拦截。const obj = { text: 'Hello Vue' } obj.text; obj.text = 'Hello Vue3'
const p = new Proxy(obj, { get() {...}, set() {...} })
- 理解了Proxy,接下来我们了解一下Reflect。
Reflect其实是一个全局对象,它有很对方法,例如Reflect.get() Reflect.set()等。并且Reflect下的方法都与Proxy拦截器的名字一一对应。
它的功能其实就是可以访问一个对象属性的默认行为,例如下面的操作其实是等价的:
那么它的意义是什么呢,其实它还有最重要的第三个参数,就是receiver,它可以指定接受的对象,也就是相当于this。const obj = { num : 1}; console.log(obj.num); console.log(Reflect.get(obj,'num'));
在这段代码中,我们指定receiver为一个对象。那么这里读取了number值。就是我们指定的receiver中的number的值。Reflect其实还有很多。其他方面的意义。我们在这里只讨论他和proxy结合使用的作用。那么我们回顾一下上一文中我们最后实现的响应式系统。中的核心代码。const obj = { num : 1 }; console.log( Reflect.get(obj, 'num', {num : 2} ) ) \\ 2
这是上一次我们实现的响应式系统,我们其实并没有用到Reflect,那么这样会有什么问题呢?我们借助effect函数让问题暴露出来,看下面的例子:const p = new Proxy(data, { get(target, key) { // 我们将核心代码进行封装 track(target,key); // 并没有用Reflect.get()进行读取 return target[key]; }, // 拦截obj的设置操作 set(target, key, newVal) { // 并没有用Reflect.set()进行设置 target[key] = newVal; trigger(target, key); } })
我们先来分析一下,我们使用effect注册一个副作用函数,副作用函数中访问了p.bar,而bar是一个函数,里面会返回number的值,那么这就间接的读取了number属性,因此我们期望的是对number也应该建立响应式,所以当我们修改number的值的时候,也应该重新执行effect里面的副作用函数才对。但是,上面的响应式系统在我们修改number的值之后,并没有重新执行副作用函数。那么问题出在哪里呢?const obj = { number: 1, bar() { return this.number; } } const p = new Proxy(obj, { // 不重复写了 ... }) effect( () => { console.log(p.bar) })
实际上,问题出在bar函数中的this上,我们来看一下这段代码中的this指向的是谁:
当我们使用Proxy进行对obj对象的代理,在访问p.bar的时候,首先会出发get进行拦截。其中get拦截器中的target就是指的的我们的原始对象obj,而key就是bar,所以我们return的target[key],其实就是obj.bar,那么这样在执行到bar函数中的this的时候,里面的this其实是指向的obj,注意,这里的obj是我们的源对象,那么我们this.number其实就是访问的obj.number。那么问题就出在这里,在访问源对象上的属性number肯定是不会建立响应式的。这其实等价于:const obj = { number: 1, bar() { // this ? return this.number; } }
而不是:effect( () => { obj.number; })
那么这样的问题怎么解决呢?Reflect就提现出了它的作用:effect( () => { p.number; })
当我们使用get拦截器的时候,接受第三个参数receiver,当我们访问p.bar时,那么receiver就是p,这样我们就解决了上面this指向的问题,return this.number这里的this就正确的指向了p,那么这样就读取了p.number的值,响应式系统就会收集到副作用函数,当我们修改p.number的值的时候,会顺利的执行副作用函数,达到我们的期望。const p = new Proxy(obj, { get(target,key,receiver) { track(target,key); return Reflect.get(target,key,receiver); } })
2、Javascript对象和Proxy的工作原理
我们知道Javascript中一切皆对象,那么什么是对象呢?
根据ECMAScript的规范,Javascript中的对象分为两种,一种叫做常规对象,另一种叫做异质对象。Javascript中所有的对象都在这两类当中。那么什么是常规对象什么是异质对象呢?首先我们要来了解一下什么是对象的内部方法。
我们知道函数也属于对象,那么JS是怎么区分一个obj是普通的对象还是一个函数呢?其实这都是由对象的内部方法来决定的。而所谓的内部方法,其实就是在对一个对象进行操作时候在引擎内部调用的方法。而这些内部方法对于开发者来说是不可见的。比如:在我们执行obj.number时,引擎内部会调用[[GET]]方法来读取属性值。当然,引擎的内部方法有很多,感兴趣的可以去ECMAScript看看。对于函数来说,会部署引擎内部的[[Call]]方法,也就是说区分一个对象是普通对象还是函数,就是判断它引擎内部有没有部署此方法。
其实在我们对一个对象进行操作时,会调用引擎上的各种内部方法。而Proxy代理的原理其实就是指定了拦截函数来替代对象的引擎上的相对应的内部方法。从而实现拦截的效果。而Proxy内部除了get、set之外其实还部署了11种内部方法,感兴趣的可以去搜一下。
3、如何代理Object
了解了Proxy的工作原理之后,我们来着手实现一下对Object的代理。首先我们知道对对象的读取是一个很宽泛的概念。我们看一下对对象读取的所有的可能性:
- 访问对象的属性:obj.number;
- 判断对象或原型上是否存在key:key in obj;
- for…in循环遍历对象: for(const key in obj) {}
第一种我们之前已经实现了,就是利用get拦截器来实现,用reflect.get()来进行返回。在这里我们重点对后两种进行实现代理。
3.1 拦截 in 操作符
既然要拦截in操作符,我们就要找到与in操作符相对应的一个引擎的内部方法。对其进行拦截就可。其实对in操作符所定义的内部函数叫做[[HasProperty]],而对应的Proxy上的拦截器叫做has,这样我们就可以进行拦截in操作了。
const obj = {
number: 1
}
const p = new Proxy( obj, {
has(target,key) {
// 进行拦截,收集副作用函数
track(target,key);
return Reflect.has(target,key)
}
})
effect( () => {
number in p; // 当使用in操作符时,就会对其进行拦截,建立依赖关系
})
3.1 拦截 for…in 操作
对于for…in的拦截稍微有一些复杂,因为之前所实现的拦截器都是与key有关的,那么在for…in中,我们是不知道具体操作的那一个key,那么这就需要用到symbol来建立一个唯一标识。在for…in操作所对应的Proxy中的拦截器叫做ownKeys,我们可以写一下该拦截器。
const obj = {
num: 1
}
const ITERATE_KEY = Symbol();
const p = new Proxy( obj, {
ownKeys(target) {
// 收集副作用函数,用ITERATE KEY作为唯一标识
track(target,ITERATE_KEY);
return Reflect.ownKeys(target)
},
})
相应的在set拦截器当中,我们也应该使用ITERATE_KEY作为key来进行触发副作用函数。
trigger(target,ITERATE_KEY);
这里其实暴露出来一个问题,就是在我们为p新添加一个属性时,比如p.num2 = 2,这样会触发p的set拦截器,而key是num2,那么我们期望副作用函数重新执行,并且循环出来的key是num和num2,但是副作用上面的代码实现中,并没有重新执行,这是副作用函数只与ITERATE_KEY有关联与num2还没有建立联系。所以得不到我们所期望的。我们在上面的基础上做出优化:
const obj = {
num: 1
}
const ITERATE_KEY = Symbol();
const p = new Proxy(obj, {
ownKeys(target) {
// 收集副作用函数,用ITERATE KEY作为唯一标识
track(target, ITERATE_KEY);
return Reflect.ownKeys(target)
},
set(target, key, newVal, receiver) {
// 判断是新加属性 还是 设置原有属性
const type = Object.prototype.hasOwnProperty.call(target, key) ? "SET" : "ADD";
// 利用receiver设置属性值
const res = Reflect.set(target, key, newVal, receiver);
trigger(target, key, type);
return res;
},
})
const trigger = (target, key, type) => {
const depMap = bucket.get(target);
if(!depMap) {
return
}
const effects = depMap.get(key);
const effectsToRun = new Set();
effects && effects.forEach( effect => {
if(effect !== activeEffect) {
effectsToRun.add(effect);
}
});
if(type === "ADD") {
const iterateEffects = depMap.get(ITERATE_KEY);
iterateEffects && iterateEffects.forEach(effect => {
if (effect !== activeEffect) {
effectsToRun.add(effect);
}
});
}
effectsToRun.forEach( effect => {
effect();
})
}
这样我们就把对象的读取的所有可能性都实现了,另外这里的delete操作符感兴趣的可以自己写一下,它可以用内部方法deleteProperty进行拦截。
下一次我们重点来实现一下数组的代理!