Vue3源码解析之响应式原理——Set、Map、WeakSet、WeakMap的代理

361 阅读7分钟

一、Set、Map、WeakSet、WeakMap本身具有的方法

  • 共有的方法:hassizedeleteclear
  • 遍历方法相同:keysvaluesentriesforEach
  • MapWeakMap具有的方法:getset
  • SetWeakSet具有的方法:add
  • 总结上面的方法可以发现setadddeleteclear改变对象,剩余的方法都是在"访问"对象的某个值,仅访问并不修改
  • 很明显,根据响应式原理的设计方案,上述可以"改变"对象的方法中我们需要进行依赖触发,在访问的过程中完成依赖收集

二、Set、Map在响应式设计中的一些“奇怪”用法

正常情况下,在构建响应式的Map时,使用方法一般都如下所示:

const map = reactive(new Map());
const keyA = {};
const reactiveKey = reactive(keyA);
map.set(keyA,'keyA');
map.set(reactiveKey,'keyB'); 
map.get(keyA) // keyB
map.get(reactiveKey) // keyB   
// 正常情况下理解 keyA 和 reactiveKey 不相等,所以这两个键将会在 Map 中创建两个对应的键值对
// 但事实上上面的用法只会创建一个键值对,但使用 keyA 或是 reactiveKey 都会访问到这个键值对,Vue3的响应式设计认为keyA和reactiveKey具有相同的作用,他们只是代理与被代理的关系,
// 并且keyA的改变会使reactiveKey发生变化,反之亦然,变化将会同步。除了指针位置不一样,他们似乎没什么不一样了
// 应该是基于以上原因Vue3认为他们两在访问和修改时应该被视作“同一个”key
// 这么解释有点牵强,但找不到更好的理由了

三、副作用函数

在具体的了解代理原理的实现之前我们先简单了解下副作用函数,副作用函数指的是会产生副作用的函数,如下所示:

const obj = {text: "hello vue3"}
function effect(){
    document.body.innerText = obj.text
}

effect函数开始执行时,他会执行修改body的文本内容,但除了effect以外的都可以读取和设置body的文本内容,也就是说effect函数的执行会直接或间接的影响其他函数的执行.
在vue中我们期待的是当obj.text发生改变后effect能够重新执行一次,为body赋上最新值.

四、响应式的基本实现

基于三中的示例,我们能够发现:

  • 当副作用函数执行时会触发字段obj.text读取操作
  • 当修改obj.text的值时,会触发obj.text设置操作
    根据上面的信息我们考虑到如果在字段的读取过程中将副作用函数存储起来,当字段重新开始设置时我们将该字段对应存储的副作用函数重新执行一遍,我们就能达到二的目标. 那么响应式原理的基本原理就很清晰了:
  • 读取操作时————————>存储当前副作用函数
  • 设置操作时————————>从存储的集合中获取对应的副作用函数重新执行
    具体实现等待后面我们再来实现,这里我们就先简单了解下基本原理

五、size的代理

根据一所描述的,size属性应当完成依赖收集

size是一个构造器属性,并不是一个方法,在访问size时应当使用getter

size的代理实现如下:

get size() { // size是构造器属性,应当使用getter访问
    return function(target, isReadonly = false){
        // 获取原始对象,这里的target指代的是被代理后的对象,通过ReactiveFlags.RAW可以获取到原始对象
        const target = target[ReactiveFlags.RAW] 
        if(!isReadonly){
            // 非只读条件下完成依赖收集,只读模式下不允许修改值,就相当于没有触发依赖的需求了,也就无需收集依赖
            track() 
        }
        return Reflect.get(target,'size',target)
    }
 },

六、get的代理

同理,根据一的描述,get仅为访问,get时应当完成依赖收集 get的代理实现如下:

function get(target, key, isReadonly = false){
    // 获取当前taeget的被代理对象,
    // 例如: 
    // const obj = {name: "张三"}
    // const reactiveObj = reactive(obj); 
    // const map = new Map([reactiveObj,1])
    // const y = reactive(map);
    // const x = readonly(y); 
    // x.get(obj) 
    // 获取本次代理对应的原始的未被代理的对象
    const target = target[ReactiveFlags.RAW];
    const rawKey = toRaw(key); // get 作为Map独有,get(key) 中key 对应的参数可能本身就是一个响应式对象,获取最原始的被代理对象key
    const rawTarget = toRaw(target); // 获取最原始的被代理对象target。因为本次被代理的target可能本身也就是一个响应式对象,
    if(!isReadonly){ 
        // 只读情况下不允许修改对象,意味着不需要触发副作用,那也就意味着不需要收集依赖了
        if(key !== rawKey){
            // 如果访问key是响应式类型,则将响应式的key作为依赖收集的关键key完成一次依赖收集
            // 为什么key为响应式时还要收集一次呢?
            track(rawTarget,"GET",key)  
        }
        track(rawTarget,"GET",rawKey) // 将原始的key作为关键key完成一次依赖收集
    }
    const hasProto = v => Reflect.getPrototypeOf(v);
    // 以上述的x为例,target就是y,rawTarget就是map,rawTarget作为最原始的target对象,那么他对应的属性值一定是未被代理的,因此需要判断当前get的key如果存在于最原始的target上,应当进行响应式处理后再返回
    // 代理的作用也就是代理原始对象,因此这里原始对象的判断优先级会更高
    if(hasProto.call(rawTarget,key)){ 
        return toReactive(target.get(key))
    } else if(hasProto.call(rawTarget,rawKey)){ // 加上前一步的条件这就保证了在get返回时能够保证可以处理响应式的key和原始key
        returnt toReactive(target.get(rawKey))
    } else if (target !== rawTarget){
        return target.get(key)
    }
}

可以看到在依赖收集的时候如果使用的key为响应式的key,那么依赖收集进行了两下.体会一下二中的示例,我们来解释一下:

假设一:   在副作用函数执行后,每次都将原始key作为关键key进行依赖收集,那么在后续修改响应式的key时就无法找到对应的原始key对应的副作用函数了.那么本章第二节中的“怪异”点也就无法实现
假设二:   每次都将响应式的key作为关键key完成依赖收集,那么同理,当修改原始key后,就将无法找到原始key对应的副作用函数,也就无法触发依赖

所以很明显如果只是用key的某一单一状态完成依赖收集,上面章节二的用法就无法实现.归根结底,我认为还是设计者认为被代理的对象和代理对象在某种意义上来说是一模一样的;所以无论是操作原始对象或是响应式对象,都应具有相同的副作用

七、has的代理

has(target,isReadonly=false,key){
    // 第一步,仍然是获取被代理的对象,传入的target其实是因为ts中使用this需要将其作为第一个参数传入进来,也就是指的this
    target = target[ReactiveFlags.RAW]; // 就等于 const target = this[ReactiveFlags.RAW]
    const rawTarget = toRaw(target);
    const rawKey = toRaw(key);
    if(!isReadonly){
        if(key !== rawKey){
            track(rawTarget,"HAS",key)
        }
        track(rawTarget,"HAS",rawKey)
    }
    return key !== rawKey 
           ? target.has(key)
           : target.has(key) || target.has(rawKey)
    
}

根据has的代理和前面其他属性的代理,可以发现当key可能为响应式数据时,我们就需要在收集依赖时手机两遍,将响应式的key与原始的key都进行一次依赖收集。因为在使用时我们无论变更响应式的key还是原始key都会随之触发target进行更新。

八、forEach的代理

function createForEach(isReadonly=false){
    return function forEach(callback,thisArg){
        // 复制this 
        const _this = this; 
        // 获取被代理的对象
        const target = this[ReactiveFlags.Raw];
        // 获取最原始的对象
        const rawTarget = toRaw(target)
        return target.forEach((value,index,_target)=>{
            !isReadonly && track(rawTarget,"FOREACH",'FOREACH')
            return callback.call(thisArg,toReactive(value),toReactive(key),_this)
        })
    }
}

原理:拦截被代理对象的forEach,在被代理对象的遍历方法中遍历调用callback。在调用回调前进行依赖收集。

九、其他遍历的代理

已知MapSetvalues()keys()entries()都是遍历器对象(Iterator)