VUE3源码学习笔记-第一部分:非原始值的响应式方案

275 阅读9分钟

1.理解Proxy和Reflect

Proxy可以创建一个代理对象,能够实现对其它对象的代理,它只能代理对象,不能代理非对象,如字符串、布尔值等。代理指的是拦截并重新定义对一个对象的基本操作。Reflect是一个全局对象,所有能在Proxy拦截器中找到方法都能在Reflect中找到同名函数,以Reflect.get为例,它用于访问一个对象属性,Reflect.get(obj,"foo")等价于obj.foo,它还能接收第三个参数,相当于this。以如下代码为例:

const obj = {
    get foo(){
        return this.foo
    }
}
obj.foo = 1;
console.log(Reflect.get(obj,"foo",{foo:2}))//这里输出的是2而不是1

Reflect.get可以接收第三个参数作为this的特性可以用于解决一个重要问题。假设有一个obj对象和它的代理对象p:

const obj = {
    foo:1,
    get bar(){
        return this.foo
    }
}
const p = new Proxy(obj,{
        get(target,key){
            //依赖收集
            track(target,key)
            return target[key]
        }
    }
)

当我们读取p.bar时,我们希望将p.bar收集依赖,但是obj的get bar中的this指向obj,收集的依赖也是obj.foo。而在副作用函数中通过原始对象访问它的属性是不能建立响应联系的。即

effect(()=>{
    obj.foo//obj.foo是原始数据,不是代理对象,不能建立响应联系。
})

这时候需要使用Reflect.get:

const p = new Proxy(obj,{
        get(target,key,receiver){
            //依赖收集
            track(target,key)
            //此时obj的get bar中的this指向receiver,也就是代理对象p。
            return Relect.get(target,key,receiver)
        }
    }
)

obj的get bar中的this指向receiver,也就是代理对象p。这样就能建立联系了。

2.如何代理Object

代理Object的in操作符:ECMA-262规范中定义了in操作符的逻辑,in操作符的运算结果是通过调用HasProperty抽象方法得到的,该方法对应的拦截函数是has,所以可以用has拦截函数实现对in的代理。 代理Object的for...in循环:for in 循环使用ownKeys来拦截; 代理Object的delete操作:使用deleteProperty操作来拦截,先检查被删除属性是否属于对象自身,然后调用Reflect.deleteProperty完成删除工作,这两步都成功后再调用trigger函数触发副作用函数执行。

3.合理地触发响应

如果值没有发生变化,应该不需要触发响应,我们需要修改set拦截函数的代码,在调用trigger触发响应之前,检查值是否变化:

const p = new Proxy(obj,{
    set(target,key,newVal,receiver){
        const oldVal = target[key],res = Reflect.set(target,key,newVal,receiver);
        if(oldVal !== newVal){
            trigger(target,key,type)
        }
        return res;
    }
})

讨论从原型上继承属性的情况,封装一个reactive函数,它接收一个对象,并返回为其创建的响应式数据。

function reactive(obj){
    return new Proxy(obj,{
         //拦截函数
    })
}

然后我们基于这个函数举例:

const obj = {},proto = {bar:1},child = reactive(obj),parent = reactive(proto);
//将parent设为child的原型
Object.setPrototypeOf(child,parent);
effect(()=>{
    console.log(child.bar)
})
child.bar = 2

当读取child.bar时,child本身没有bar属性,会在原型上查找,获取parent.bar,parent本身也是响应式数据,所以child.bar和parent.bar都被收集依赖。设置child.bar时会触发child.bar和parent.bar的副作用函数。因此我们需要屏蔽原型引起的更新:

function reactive(obj){
    return new Proxy(obj,{
         //拦截函数
         get(target,key,receiver){
             //给拦截对象添加通过raw属性访问原始数据的能力
             if(key === "raw"){
                 return target
             }
         }
    })
}

在set中判断:

//如果target === receiver.raw说明receiver就是target的代理对象。
if(target === receiver.raw){
trigger()
}

4.深响应与浅响应

目前实现的reactive是浅响应的,举例:

const obj = reactive({foo:{bar:1}});
effect(()=>{
    console.log(obj.foo.bar);
})
//设置obj.foo.bar的值时并不会触发响应
obj.foo.bar = 2;

当我们读取obj.foo.bar时,会先读取obj.foo,Reflect.get返回的obj.foo是一个普通对象,不是响应式对象。要解决这个问题,我们需要对Reflect.get返回的结果做一层包装,如果结果是对象且不为空,则包装成响应式数据。

const res = Reflect.get(target,key,receiver);
if(typeof res === "object" && res !== null){
     return reactive(res);
}
return res

有时我们不希望都是深响应,这就催生了shallowReactive,即浅响应,也就是只有第一层数据是响应的,只需要在深响应代码的基础上加一个参数isShallow用来标识是否浅响应即可。如果是浅响应,则直接返回,否则对返回结果做包装。

5.只读与浅只读

我们希望某些数据是只读的,当用户修改它们时,修改无效而且抛出警告。在为对象创建响应式代理的时候,通过参数isReadOnly实现,如果它为true,则在set和delete操作的时候打印警告信息并直接返回。同时,如果一个对象是只读的,那么就不需要建立响应联系,所以在get时也做判断,isReadOnly为false时,才使用track函数建立联系。还有一个参数isShallow来确定是否是深只读,如果值为false表示是深只读,递归地对值进行包装。包装值的函数为readOnly:

function readOnly(obj){
    //createReactive是为对象创建响应代理的方法,接收三个参数,分别为被代理对象、isShallow、isReadOnly
    return createReactive(obj,false,true)
}

function createReactive(obj,isShallow = false,isReadOnly = false){
    return new Proxy(obj,{
        get(target,key,receiver){
            if(!isReadOnly){
                track(target,key);
            }
            const res = Reflect.get(target,key,receiver);
            if(isShallow){
                return res
            }
            //如果res是对象且不为空
            if(typeof res === "object" && res !== null){
                //如果数据是只读就调用readOnly做包装
                return isReadOnly ? readOnly(res):reactive(res)
            }
            return res
        }
    })
}

6.代理数组

6.1数组的索引和length

当通过数组索引设置数组元素时,会触发数组的set函数,如果索引值大于数组当前长度,就会修改数组的length,此时应当触发与length相关的副作用函数重新执行。

function createReactive(obj,isShallow = false,isReadOnly = false){
    return new Proxy(obj,{
        set(target,key,newVal,receiver){
            if(isReadOnly){
               console.warn(`属性${key}是只读的`);
               return true
            }
            const oldVal = target[key];
            let type;
            //如果代理目标是数组,则判断索引是否大于长度
            if(Array.isArray(target)){
                  type = Number(key) < target.length ? 'SET' : 'ADD';
            }else{
              //如果属性不存在说明是添加属性,否则是设置属性
              type =  Object.prototype.hasOwnProperty.call(target,key) ? 'SET' :'ADD'
            }
            const res = Reflect.set(target,key,newVal,receiver);
            trigger(target,key,type)
        }
    })
}

我们把索引大于数组长度的操作类型视为ADD操作,在trigger中取出与length相关的副作用函数并执行。

function trigger(target,key,type){
    const depsMap = bucket.get(target);
    if(!depsMap){
        return
    }
    if(type === "ADD" && Array.isArray(target)){
        const lengthEffects = depsMap.get("length");
        lengthEffects && lengthEffects.forEach(fn =>{
            if(fn !== activeEffect){
                effecctsToRun.add(fn)
            }
        })
    }
    //执行副作用函数
    effecctsToRun.forEach(fn =>{
        fn()
    })
}

此外,如果我们修改数组的length属性,也会影响数组元素,假如将数组length设置为比数组长度小的值,也就会将数组中索引大于length的元素删除。我们需要在set函数中记录新的length值,将它传递给trigger函数,在trigger函数中将数组中所有索引比新的length值大的元素相关联的副作用函数取出并执行:

depsMap.forEach((effects,key) =>{
    if(key >= newIndex){
        effects.forEach(fn =>{
            if(fn !== activeEffect){
                effectsToRun.add(fn)
            }
        })
    }
})

6.2.遍历数组

使用for in 遍历数组可以像对象一样用ownKeys拦截,但是添加删除新元素和修改数组长度都会影响for in对数组的遍历。我们需要在ownKeys函数中使用length作为key值去建立响应联系。

function createReactive(obj,isShallow = false,isReadOnly = false){
    return new Proxy(obj,{
        ownKeys(target){
            track(target,Array.isArray(target) ? 'length' : ITERATE_KEY);
            return Reflect.ownKeys(target)
        }
    })
}

for of遍历数组在迭代数组时,和for in是一样的,都是通过ownKeys拦截,都是在副作用函数与数组的长度和索引之间建立联系,所以不需要额外的代码。但是在for of循环会调用数组的Symbol.iterator属性,它是一个symbol值,为了避免出错和性能的考虑,我们不应该在副作用函数和symbol值之间建立响应联系。因此需要修改get函数,如果是symbol就不进行追踪。

6.3数组的查找方法

数组的includes方法会访问数组的length属性和数组索引,所以大多数情况下我们修改元素值时能够触发响应。但元素为引用类型就会出现问题。

const obj = {},arr = reactive([obj]);
console.log(arr.includes(arr[0]) //false

当获取arr[0]时,得到的并非最初的obj,而是基于obj创建的代理对象。又由于arr.includes中的arr是代理对象,所以includes的this也指向代理对象,includes方法通过访问索引0得到的对象也是一个代理对象,而每次调用reactive函数时都会创建一次代理对象,所以即使是同一个响应数据,多次调用ractive函数得到的也是不同的代理对象。所以就出现了上述的问题。解决方案是定义一个map实例,来储存响应数据与代理对象的关系,如果一个响应数据已经有了代理对象就直接使用该代理对象而不重复创建:

const reactiveMap = new Map();
function reactive(obj){
    //查询该响应数据是否已经有了代理对象,如果有则直接返回
    const existionProxy = reactiveMap.get(obj);
    if(existionProxy){
        return existionProxy;
    }
    const proxy = createReactive(obj);
    //储存到map中
    reactiveMap.set(obj,proxy);
    return proxy
}

这样之前的arr.includes(arr[0])中的arr[0]和arr.includes查找的结果都指向同一个代理对象,这就符合预期了。但如果把arr[0]替换成obj,还是会出错。因此我们需要重写数组的includes方法,先直接查找如果找不到就通过this.raw获取原始数组,再在原始数组中查找,这样返回的结果就是obj了。类似的indexOf和lastIndexOf也需要做类似的重写。

6.4隐式修改数组长度的方法

数组的splice方法和栈方法如:push、pop、shift、unshift等会读取和设置数组的长度值。假如对同一个数组执行两个副作用函数:

const arr = reactive([]);
//第一个副作用函数
effect(())=>{
    arr.push(1);
}
//第二个副作用函数
effect(())=>{
    arr.push(2);
}

这两个函数都调用数组的push方法, 第一次的push操作会读取数组的length属性,与length属性建立响应联系; 第二次的push操作也会读取数组的length属性,与length属性建立响应联系,还会设置length属性,将与length相关的副作用函数都取出执行,其中就有第一个副作用函数; 第二个副作用函数未执行完毕,就再次执行第一个副作用函数,而第一个副作用函数也会设置length属性,将第二个副作用函数取出执行,循环往复造成死循环; 我们需要重写push,在push读取length时,不建立响应联系:

//用来标记是否调用track方法建立响应联系
let shouldTrack = true;
const methods = ['push'];
//重写数组的push方法
methods.forEach(method => {
    //取得原始push方法
    const originMethod = Array.prototype[method];
    arrayInstrumentation[method] = function(...args){
        //在调用原始方法前禁止追踪
        shouldTrack = false;
        //执行push方法的默认行为
        let res = originMethod.apply(this,args);
        shouldTrack = true;
        return res;
    }
})

其他方法也需要做类似的重写。