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

138 阅读9分钟

Vue3响应式系统的设计与实现

首先我们要理解几个概念:

副作用函数:副作用函数的执行会直接或者间接的影响到其他的函数运行结果,比如说你的函数修改了一个全局变量,那么它就是一个具有副作用的函数,因为其他函数可能也会使用到这个全局变量

什么是响应式数据呢,可以举个例子

const obj={a:'abc'}
function effect(){
	document.body.innerText=obj.a
}

可以看到我们的effect函数就把obj对象中的值符给了document.body,所谓的响应式数据就是如果obj.a的值发生了变化,那么我们希望effect这个函数能够被重新执行。

那么如何让obj变成一个响应式数据呢,这边可以直接用到vue2中的设计思想,我们可以看到两点:

  • 我们执行effect的时候触发了obj的读取(get)操作
  • 当我们修改obj值的时候触发了obj的设置(set)操作

基于上面两点我们就可以实现一个简单的响应式系统,原理如下:当读取我们的属性值的时候可以把effect函数放在一个桶中,当我们触发了设置方法的时候我们再把effect从桶中拿出来执行就行啦。

在es5前,也就是vue2的实现方法使用的是Object.defineProperty来监听对象的get和set方法,而在es6后我们就可以使用proxy来实现了,这也是vue3中所采用的方式

简单实现

const bucket=new Set() 
const data={a:'123'}
const obj=new Proxy(data,{
	get(target,key){ 
   //读取对象属性的时候将副作用函数加入到桶中
			bucket.add(effect)
			return target[key]
	},
  set(target,key,newVal){
			target[key]=newVal;
      //遍历桶中的副作用函数并执行
			bucket.forEach(fn=>fn())
			return true

	}
})

上面的obj就是一个简单的响应式数据啦,那么我们怎么使用它呢,我们只要出发get就行啦

function effect(){
	document.body.innerText=obj.a;
}
effect() //执行effect的时候出发了get方法
obj.a=3 //执行了set方法,重新触发effect

大家可以自己在浏览器中试一下就知道啦,当我们修改obj.a值的时候页面也会跟着变化。

上面只是一个简单的响应式数据的时候,但是它还不够完善,例如我们直接通过effect函数的具体名字来获取副作用函数,这还不够灵活。

优化一下

上面的实现effect是硬编码的,如果我们的effect换个方法或者换成匿名函数上面的代码就不能够正常执行了,我们要实现一个即使我们不知道effect的名字,也能够收集到这个副作用函数,我们就需要提供一个方法,这个方法就是用来注册副作用函数的,如下

let activeEffect;
function effect(fn){
	activeEffect=fn; //将副作用函数赋给统一的activeEffect变量
	fn();
}

我们可以修改上述代码为下面这样

const data={a:'123'}
const bucket=new Set();
const obj=new Proxy(data,{
	  get(target,key){
		bucket.add(activeEffect) //当执行effect的时候将副作用函数赋值给了activeEffect
                return target[key]
		},
	set(target,key,newVal){
		target[key]=newVal
		bucket.forEach(fn=>fn());
		return true
	}
})

effect(()=>{
	document.body.innerText=obj.a
})

obj.a=4//可以看到页面也会执行了我们传入的副作用函数

上面的activeEffect就是让我们有了一个统一的副作用函数收集的地方,而不用我们知道定义的副作用函数的名称。

上面只是做了一个小改动,来思考一个问题,如果副作用函数中获取了一个原本就不存在的属性会怎么样呢?

effect(()=>{
	console.log('修改Text才出发的副作用')
	document.body.innerText=obj.text
})
obj.a=4 //我们可以看到当我们执行这一句的时候上面的副作用也执行了

上面的问题可以看到虽然我们收集了副作用,但我们不知道副作用函数该什么属性出发set的时候在执行,这时候我们又可以修改我们的响应式系统了,怎么修改呢?其实就是为我们的每个属性都有一个桶的概念,触发了当前属性的set后我们就只从当前属性的桶中拿出副作用函数来执行就行啦,修改起来也非常简单。

const data={a:'111'}
const bucket=new WeakMap()
const obj=new Proxy(data,{
	get(tartget,key){
          if(!activeEffect) return;
	  const 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)
	  return target[key]
      },
      set(target,key,newVal){
	  target[key]=newVal;
	  const depsMaps=bucket.get(target)
	  if(!depsMaps) return;
	  const effects=depsMaps.get(key);
	  effects&&effects.forEach(fn=>fn());
      }

})

上面的映射关系如下

image.png 我们最外层一个WeakMap的键是存的对象,而值则是一个Map,这个Map的键是对象的属性,值则是属性所对应的effects,这样每个对象的每个值都有自己的桶,不会出现互相影响的情况了.

WeakMap的作用?

WeakMap的key是弱引用,他不影响垃圾回收器的工作,看下面这段代码就能知道WeakMap和Map的区别了

const map=new Map()
const weakMap=new WeakMap();
(function(){
	const foo={foo:1}
	const bar={bar:1}
	map.set(foo,1);
	weakMap.set(bar,1)

}())
map // map还能继续访问到foo
weakMap //可以看到WeakMap不能访问到bar了

使用WeakMap可以让不被引用的依赖自动被gc回收,而不会造成内存的oom;

继续优化

我们可以继续优化上面的代码,把上面的较为复杂的逻辑抽离出来

let activeEffect;
function effect(fn){
	activeEffect=fn;
	fn();
}
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)

}
function trigger(target,key){
	const depsMaps=bucket.get(target)
	if(!depsMaps) return;
	const effects=depsMaps.get(key);
	effects&&effects.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)
      }
})

上面这个样子还会有一个问题,我们看下面的代码

const data={ok:true,text:'111'}
const obj=new Proxy(....)
effect(()=>{
	console.log('执行了')
	document.body.innerText=obj.ok?obj.text:''
})

我们就会发现ok属性和text属性的副作用函数桶内都收集了相同的副作用。

ok→effectFn;text→effectFn;

我们执行下下面的操作

const data={ok:true,text:'111'}
const obj=new Proxy(data.....)
effect(()=>{
	console.log('执行了')
	document.innerText=obj.text
})
//页面变为111

obj.ok=false //页面变为空
//执行了

obj.text='222'
//执行了
//页面依旧为空,当ok为false 的时候text的值改变后不应该触发无用的副作用函数

如果我们修改ok的值为false,那么会重新触发我们的副作用函数,这时候的副作用函数就会变成如下:

effect(()=>{
	document.body.innerText=''
})

这时候如果我们再次改动text的值的话我们会发现又再次触发了副作用函数的执行,当ok为false的时候其实副作用函数并没有读取text属性,我们理想的情况是当ok为false 的时候text属性并没有被读取,不应该收集这个副作用函数且修改text属性的时候也不应该触发副作用函数

如何解决这个问题呢?

我们需要做的就是每次执行副作用函数的时候都要重新建立副作用函数和对应属性的联系,比如我们上面的例子中,obj.ok=false的时候就要移除text的副作用桶内的函数,我们需要额外建立一个映射关系就是哪些桶中收集了当前的副作用。

effectFn→Set

我们修改我们的effect方法

let activeEffect
function effect(fn){

		fn.deps=[]
		activeEffect=fn;
		fn();
}

我们给fn一个deps属性用来收集 —**收集了这个副作用的桶 —**这句话比较绕,大家可以细细揣摩下,那么怎么收集呢,就在我们的track函数中进行收集

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)// 进行收集
}

当我们副作用再次执行的时候要将之前收集到的桶中当前副作用全部去除,然后重新建立联系,每次执行依赖前我们都需要cleanup一次,我们就需要改成下面这样的形式

let activeEffect
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;
}

这时候我们再次执行我们的代码

const obj=new Proxy(....)
effect(()=>{
	document.body.innerText=obj.ok?obj.text:''
})

这时候你会发现当我们执行obj.ok=false的时候会进入无限循环这是为什么呢,这是因为js的一个语法限制,我们都知道触发了set后又执行了get那么会再次执行副作用函数,这时候我们会先执行一次cleanup将有该副作用的桶都清空这个函数,然后在重新收集到桶中,就好像下面这个代码:

function demo(){
        const bucket=new Set([1]);
        bucket.forEach(i=>{ 
//这里的代码就是我们副作用执行的代码,要先清楚再进行收集
	bucket.delete(1);
        bucket.add(1)
	console.log('遍历中')

});
}

你执行了上面这段代码就会发现也死循环了这是为什么呢,因为当使用forEach遍历set的时候如果你删除了一个元素又再次添加那么仍旧会继续遍历这个元素,所以导致了死循环。

所以我们怎么解决呢?我们需要修改我们的trigger函数

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());
}

这个时候我们在进行上面的操作就完全没问题啦

当然这里实现的响应式系统也只是一个粗糙的系统还有很多问题可以进行改善,后面对于这部分我会持续进行更新

参考文献
《vuejs设计与实现》