Vue3响应系统的设计与实现(2)

78 阅读8分钟

响应系统的设计与实现(2)

上一章节我们基本实现了一个简单的响应式系统,代码如下,本章节我们将继续优化代码,如果没看过上章节内容的可以先去看下上章节内容 链接

let activeEffect
const bucket=new WeakMap();
function effect(fn){
	const effectFn=()=>{ //我们需要把副作用函数和清楚这个逻辑绑定在一起,所以需要写在一块,
//每次set后触发副作用函数又再次触发了get,所以副作用函数又一次进行了收集,每次收集之前都要先进行清除
			cleanup(effectFn);
			activeEffect=effectFn;
			fn()
		}
//初始化deps,只有第一次执行的时候才会执行,后面都是响应式的时候执行effectFn函数的时候在进行收集
		effectFn.deps=[];
		
		effectFn();
}
function cleanup(fn){
	for(let i=0;i<fn.deps.length;i++){
		const dep=fn.deps[i]
		dep.delete(fn);
	}
  fn.deps.length=0;
}
function track(target,key){
	if(!activeEffect) return;
	let depsMaps=bucket.get(target)
	if(!depsMaps){
			bucket.set(target,(depsMaps=new Map()));
		}
	let deps=depsMaps.get(key);
  if(!deps){
			depsMaps.set(key,(deps=new Set()));
		}
	deps.add(activeEffect)
	activeEffect.deps.push(deps)// 进行收集
}

function trigger(target,key){
	const depsMaps=bucket.get(target)
	if(!depsMaps) return;
	const effects=depsMaps.get(key);
  const newSet=new Set(effects);
	newSet&&newSet.forEach(fn=>fn());
}

//上述代码就可以更改为以下形式了
const data={a:'111'}
const bucket=new WeakMap()
const obj=new Proxy(data,{
			get(target,key){
          track(target,key)
					return target[key]

			},
      set(target,key,newVal){
					target[key]=newVal;
					trigger(target,key)
			}
})

嵌套的Effect

在vue中的体现就是组件中拥有组件

effect(()=>{
	foo.render()
	effect(()=>{
	bar.render();
	})
})

思考一下,下面这段代码的输入

const data={foo:true,bar:true}
const obj=new Proxy(data,...)
let temp1,temp2;
effect(()=>{
	console.log('effect1');

	effect(()=>{
		console.log('effect2');
		temp2=obj.bar;	
	})
	temp1=obj.foo;
})

这段代码想实现的想过是obj.foo→effect1,obj.bar→effect2;就是说当我们修改obj.foo的时候会输出effect1,由于effect2嵌套在effect1中所以effect2也会输出,修改bar的时候会输出effect2;

我们可以执行一下上面的代码.

我们会发现输出如下

effect1;

effect2;

修改obj.foo后输出

effect2.

我们发现修改后他的输出不符合我们的预期,我们的预期应该是修改完后输出effect1但由于effect2嵌套在内部所以也会输出effect2.但现在只输出了effect2,问题出在哪里呢?

其实很好理解,问题就是出在我们的activeEffect是唯一的,当执行effect1的时候activeEffect为effect1,当执行到effect2的时候它就变成了effect2,所以obj.foo的桶内存的副作用函数就变成了effect2了。

我们只需要修改effect函数即可,我们可以用一个栈来收集嵌套的副作用,当副作用执行完后则从栈中弹出,让activeEffect为下一个副作用函数即可

const effectStack=[];
function effect(fn){
	const effectFn=()=>{ //我们需要把副作用函数和清楚这个逻辑绑定在一起,所以需要写在一块,
//每次set后触发副作用函数又再次触发了get,所以副作用函数又一次进行了收集,每次收集之前都要先进行清除
			cleanup(effectFn);
			
			activeEffect=effectFn;
			effectStack.push(effectFn);
			fn()
			effectStack.pop();
			activeEffect=effectStack[effectStack.length-1];
		}
//初始化deps,只有第一次执行的时候才会执行,后面都是响应式的时候执行effectFn函数的时候在进行收集
		effectFn.deps=[]
		effectFn()
}

避免无限递归

effect(()=>{
	obj.foo=obj.foo++;
});

上述代码假设obj是一个响应式数据,那么当我们执行上面代码的时候首先会触发track方法进行副作用的收集,再然后我们会发现它还修改了值,这时候就会触发trigger,他在同一个方法里既触发了track,又触发了trigger,这时候就会发现进入了死循环,触发了trigger有执行了副作用,又触发了上一步骤的内容,就会进入死循环,怎么解决这个问题呢,很简单,只要判读当副作用是不是当前的activeEffect,如果是就不执行,说明当前的副作用还在收集的过程中.

我们只需要修改trigger就行了

function trigger(target,key){
	const depsMaps=bucket.get(target)
	if(!depsMaps) return;
	const effects=depsMaps.get(key);
  const newSet=new Set();
	effect&&effect.forEach((fn)=>{
			if(fn!==activeEffect){
				 newSet.add(fn)
			}
	})
	newSet&&newSet.forEach(fn=>fn());
}

支持调度执行

支持调度顾名思义就是让用户可以自定义函数是什么时候执行了的。

const data={foo:1};
const obj=new Proxy(data,....);
effect(()=>{
	console.log(obj.foo)
})
obj.foo++;
console.log('执行了');

上面代码我们的输出肯定是

1
2
执行了

如果我们要把2放在执行了后面我们该怎么做呢,这时候就有个自定义调度的概念了,我们修改effect函数

const effectStack=[];
function effect(fn,options={}){
	const effectFn=()=>{ //我们需要把副作用函数和清楚这个逻辑绑定在一起,所以需要写在一块,
//每次set后触发副作用函数又再次触发了get,所以副作用函数又一次进行了收集,每次收集之前都要先进行清除
			cleanup(effectFn);
			
			activeEffect=effectFn;
			effectStack.push(effectFn);
			fn()
			effectStack.pop();
			activeEffect=effectStack[effectStack.length-1];
		}
		effectFn.options=options
//初始化deps,只有第一次执行的时候才会执行,后面都是响应式的时候执行effectFn函数的时候在进行收集
		effectFn.deps=[]
		effectFn()
}

我们给effect第二个参数options,让他可以传入我们的一些自定义属性,比如这边我们就传入scheduler就可以试试

const data={foo:1};
const obj=new Proxy(data,....);
effect(()=>{
	console.log(obj.foo)
},{
	scheduler(fn){
			setTime(fn)
		}
})
obj.foo++;
console.log('执行了');

我们传入调度器属性为一个函数,这个函数接收一个函数参数并把接收到的函数放入宏任务队列中去,所以我们修改trigger函数,如果有调度则直接传入调度器中否则就正常执行即可

function trigger(target,key){
	const depsMaps=bucket.get(target)
	if(!depsMaps) return;
	const effects=depsMaps.get(key);
  const newSet=new Set();
	effects&&effects.forEach((fn)=>{
			if(fn!==activeEffect){
				 newSet.add(fn)
			}
	})
	newSet&&newSet.forEach(fn=>{
				if(fn.options.scheduler){
					fn.options.scheduler(fn);
				}else{
					fn();
				}
		});
}

计算属性的实现

懒执行的effect,前面的方式执行我们的effect方法会马上执行传入的函数,但是在vue中有部分是不需要立即执行的,比如computed属性,这个时候我们就可以通过options属性来达到目的。

const effectStack=[];
function effect(fn,options={}){
	const effectFn=()=>{ 
			cleanup(effectFn);
			
			activeEffect=effectFn;
			effectStack.push(effectFn);
			fn()
			effectStack.pop();
			activeEffect=effectStack[effectStack.length-1];
		}

		effectFn.options=options
		effectFn.deps=[]
//如果有lazy属性,则立即不执行函数。
		if(!options.lazy){
				effectFn()
		}
		return effectFn;
		
}

我们可以在进行一下修改,因为computed的时候一般都是返回一个计算后的值,上面的代码返回计算的函数,还需要我们手动执行才能拿到真正的值,所以这时候我们就要修改上面的代码,将副作用函数计算后的值返回

const effectStack=[];
function effect(fn,options={}){
	const effectFn=()=>{ 
			cleanup(effectFn);
			activeEffect=effectFn;
			effectStack.push(effectFn);
		  const res=fn();
			effectStack.pop();
			activeEffect=effectStack[effectStack.length-1];
			return res
		}

		effectFn.options=options
		effectFn.deps=[]
//如果有lazy属性,则立即不执行函数。
		if(!options.lazy){
				effectFn()
		}
		return effectFn;
		
}

那么我们的计算属性就可以如下,修改依赖值就会发现sum值也会改变

function computed(getter){
		const effectFn=effect(getter,{
			lazy:true
		});
		const obj={
			get value(){
					return effectFn();
			}
		}
		return obj
}
const sum = computed(() => {
  console.log('执行了')
  return obj.foo + obj.bar
})

我们还会发现当我们每次执行sum.value的时候都会执行effectFn这个函数,即使依赖值未改变也会执行,这与我们的预期不符合,我们修改代码如下

function computed(getter){
		let value;
		let dirty=true
		const effectFn=effect(getter,{
			lazy:true
		});
		const obj={
			get value(){
					if(dirty){
						value=effectFn();
						dirty=false;
					}
					return value
			}
		}
		return obj
}

我们设置一个dirty变量值在第一次执行时会调用effectFn,后面就不会继续执行effectFn,那么如果我们的依赖值变化后怎么把dirty重新制为true呢,只需要添加调度器就行了

const effectFn=effect(getter,{
			lazy:true
			scheduler(){
				dirty=true;
			}
});

我们的计算属性已经接近完美了但还是差一点火候,看下面代码

const sum = computed(() => {
  console.log('执行了')
  return obj.foo + obj.bar
})
effect(()=>{
	console.log(sum.value)
})

当我们修改obj.foo的值时会发现并没有执行后面的输出,就好比vue模版中使用计算属性,但是我们的计算属性的值并不是响应式的,无法进行模版更新,这时候该怎么做呢,这时我们只需要手动收集和触发就行了,如下代码,我们的track其实就是在weakmap中存入,trigger就是取出然后执行,computed不是自动的响应式,他没用proxy来进行代理,而是我们手动执行的。

function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
      trigger(obj, 'value')
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
			//此时avtiveEffectFn为
      track(obj, 'value')
      return value
    }
  }
  return obj
}

这样修改后你就会发现computed的依赖修改后,收集到的副作用也会自动执行了

watch的实现

watch的实现其实就是对调度器的使用


function watch(source,cb){
	effect(()=>
	source.foo)
      ,{
	scheduler(){
			//...自定义操作
			cb();
	}
})
}

上面代码表示我们每次对source.foo进行修改的时候都会执行cb(),但是foo这个属性是我们硬编码进行的,所以我们需要抽象一个函数出来进行修改.

我们只需要写一个函数,这个函数读取了对象中所有的属性即可。

function traverse(value,seen=new Set()){
			//一些不需要继续执行下去的值。
		 if(typeof value!=='object'||value===null||seen.has(value))return;	
		for(const key in value){
					traverse(value[key],seen);
		}
return value

}

watch的第一个参数可以是一个getter也就是函数,这个时候就要对watch进行判断了


function watch(source,cb){
let getter
if(typeof source==='function'){
		getter=souce
}else{
	getter=traverse(source)
}
	effect(getter,{
	scheduler(){
			//...自定义操作
			cb();
	}
})
}

如何获取新值与旧值呢?

function watch(source, cb) {
let newVal,oldVal
 const effectFn= effect(
    () => traverse(source),
    {
			lazy:true,
      scheduler() {
        //...自定义操作
        newVal=effectFn();
				cb(newVal,oldVal);
				oldVal=newVal;
      }
    }
  )
oldVal=effectFn()
}