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

概要

接上文,我们已经写出了基础的effect收集,但是还是会有些问题。这一篇,我们就是来解决这些问题的

分支切换与cleanup

首先,我们需要明确分钟切换的定义,看下以下代码:

const data = {
	text:'hello word',
	ok:true
}
const obj = new Proxy(data,{/*...*/})
effect(()=>{
	document.body.innerText = obj.ok?obj.text:'not'
})

当effectFn (effect(effectFn) effectFn是effect传参的函数) 函数内部是一个三元表达式的时候,会根据obj.ok的值执行不同的代码分支,当obj.ok的值发生变化时,代码执行的分支也会跟着变化,这就是所谓的分支切换

执行以上代码,我们发现当obj.ok的值时false的时候,修改obj.text的值。effectFn也会重新执行,这个肯定是有问题的,因为在effectFn中,我们走的分支并没有读取到obj.text的值。原因在于第一次执行的时候读取了obj.text的值,已经把effectFn对应的key值 text 放到了依赖收集的桶中,所以更改obj.text值的时候,就会取出effectFn执行。

我们已经知道原因了,那想必各位同学都有想法了吧?对的我们只要每次执行的副作用函数的时候,把它从之前的所有已关联的依赖集合中删除,当副作用函数执行完毕后,就会建立新的联系。这里就有一个问题,我们要怎么知道它是和哪些依赖集合中呢?所有我们修改effect函数和tarck函数,如以下代码:

let activeEffect
function effect(fn){
	const effectFn = ()=>{
		cleanup(effectFn)
		activeEffect = effectFn
		fn()
	}
	//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
	effectFn.deps = []
	effectFn()
}
function cleanup(effectFn){
	for(let i =0 ;i <effectFn.deps.length;i++){
		const deps = effectFn.deps[i]
		deps.delete(effectFn)
	}
	effectFn.deps.length = 0
}
function tarck(target,key){
	if(!activeEffect) return 
	const desMap = bucket.get(target)
	if(!desMap){
		bucket.set(target,desMap = new Map())
	}
	const deps = desMap.get(key)
	if(!deps){
		desMap.set(key,deps = new Set())
	}
	deps.add(activeEffect)
	activeEffect.deps.push(deps)
}

意思就是:我们在effectFn副作用函数上添加deps属性来记录所关联的依赖,在每次执行effectFn函数的时候,都会把effectFn.deps清空,我们可以看到在tarck函数的activeEffect.deps.push(deps)这句,deps是一个数组,数组里面存了deps这个Set的数据,Set是一个引用数据类型,所有push进入effectFn.deps的也是一个地址,这个地址指向了一个堆空间,我们不管在Set里面更改还是在effectFn.deps里面更改,更改的都是这个堆里面的内容。

我们执行上面代码,我们会发现,会一直无限循环,这是为什么呢?
我们看下下面这段代码:

const set = new Set([1])
set.forEach(item=>{
	set.delete(1)
	set.add(1)
	console.log('遍历中)
})

在语言规范中有明确的提到,在调用forEach遍历Set集合的时候,如果一个值被访问过,单该值被删除并重新添加到集合,如果此时forEach遍历还没有结束,就会重新访问,所以会进行无限递归。

那么问题又来了,要怎么解决呢?我们可以在用一个Set集合去遍历它,如以下代码:

const set = new Set([1])
const newSet = newSet(set)
newSet .forEach(item=>{
	set.delete(1)
	set.add(1)
	console.log('遍历中)
})

这里,同学们可以理解吗?set和newSet是两个不同的东西了,所以newSet遍历去更改set里面的值是对newSet没有任何影响的。所以就不会无限递归了

所以我们修改修改trigger函数里面的内容了,如下:

function trigger(target,key){
	const depsMap = bucket.get(target)
	if(!depsMap) return 
	const effects = depsMap.get(key)
	const effectsToRun = new Set(effects)
	effectsToRun && effectsToRun.forEach(effectFn=>effectFn())
}

嵌套的effect与effect栈

我们可以设想一下,如果effect函数不支持嵌套,会出现什么的问题的:我们看下以下代码:

const data = {foo:true,bar:true}
const obj = new Proxy(data,{/*...*/})
let temp1,temp2
effect(function effectFn1(){
	console.log('effectFn1执行')
	effect(function effectFn2(){
		console.log('effectFn2执行')
		temp2 = obj.bar
	})
	temp1 = obj.foo
})

然后我们在修改一下 obj.foo的值,我们会发现 他执行的是 effectFn2,这是为什么呢?我们按照代码走下去:

  1. 创建了一个data对象
  2. 对data对象进行proxy代理
  3. 创建了两个变量
  4. 执行effect函数,这里我们要着重分析一下,我们先回顾一下 effect的函数内容,如下:
function effect(fn){
	const effectFn = ()=>{
		cleanup(effectFn)
		activeEffect = effectFn
		fn()
	}
	//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
	effectFn.deps = []
	effectFn()
}

effect函数执行的时候 首先会把effectFn1函数放进来,activeEffect函数执行的就是effectFn1函数,到这里时没有问题,但是在fn执行的时候,我们就把effectFn2函数赋值给了activeEffect函数,这时候我们做了get操作,获取了obj.foo,此事,对应的activeEffect函数对应的effectFn2函数,所以foo这个key值对应的副作用函数就变成了effectFn2而不是effectFn1了。我们已经知道问题所在,那我们就把effect函数改造一下,如下:

//我们新建一个数组,来模拟栈
const  effectStack = []
function effect(fn){
	const effectFn = ()=>{
		cleanup(effectFn)
		effectStack.push(effectFn) 
		activeEffect = effectFn
		fn()
		effectStack.pop()
		activeEffect = effectStack[effectStack.length -1]
	}
	//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
	effectFn.deps = []
	effectFn()
}

我们在fn调用完成后 在effectStack里面弹出最后一个,再把activeEffect赋值effectStack的最后一项,这么做的目的就是为了保证 当前执行的key值的副作用函数,在执行get操作的时候,可以准确副作用函数添加到Set集合中,我们改完后再来执行一下的嵌套函数,我们再来分析一下:

  1. 创建了一个data对象
  2. 对data对象进行proxy代理
  3. 创建了两个变量
  4. 执行effect函数,先拿到effectFn1,把他加入到effectStack里面,然后执行effectfn1,然后我们就碰到了effectFn2,然后执行effectFn2,这时候,我们的activeEffect就会等于 effectFn2, 我们把effectFn2 push 到了effectStack里面,当effectFn2执行完成后,我们在effectStack弹出了effectFn2,把activeEffect赋值成effectFn1,我们在执行了obj.foo的get操作,我们就把key为foo的副作用函数对应到了effectFn1,这样就满足了我们的要求。

避免无限递归循环

我们在实现一个完美的响应式系统的时候,需要考虑到诸多的细节,我们试下下面的代码会造成什么样的情况

const data = { foo : 1}
const obj = new Proxy(data,{/*...*/})
effect(()=>{obj.foo++})

执行上面的代码,我们会发现栈溢出,这是为什么呢?obj.foo++ 是不是等于 obj.foo = obj.foo +1,我们就会发现一个问题,这个代码执行get操作也执行了set操作,当我们执行set操作的时候,又会把副作用函数拿出来执行一次,就会往复循环,我们会知道在vue中,这种代码的话,就是只会执行一次,那我们就往一次的方向想。

我们会发现 它执行set和get的时候 activeEffect指向的函数都是一样的,那我们是不是可以加一个守卫条件,当set的时候 如果副作用函数和activeEffect时一样的时候,我们就不触发呢?
我们来试下,直接修改trigger函数:

function trigger(target,key){
	const depsMap = bucket.get(target)
	if(!depsMap) return 
	const effects = depsMap.get(key)
	//const effectsToRun = new Set(effects) //直接在遍历赋值的时候,把activeEffect给排除掉
	const effectsToRun = new Set()
	effects && effects.forEach((effectFn)=>{
		if(effectFn != activeEffect){
			effectsToRun.add(effectFn)
		}
	})
	effectsToRun && effectsToRun.forEach(effectFn=>effectFn())
}

这样我们就可以避免无限递归调用了

调度执行

可调度性也是响应式系统非常重要的的特性,首先我们要知道什么是可调度性。所谓的可调度性,是指trigger函数执行副作用函数的时候,可以决定副作用函数的执行时机、次数以及方式
我们看下以下代码:

const data = { foo : 1}
const obj = new Proxy(data,{/*...*/})
effect(()=>{
	console.log(obj.foo)
})
obj.foo++
console.log('结束了!')

这样代码输出结果是:

1
2
结束了

如果需求有变,我们输出的顺序要改成:

1
结束了
2

我们需要怎么不改变代码的顺序的情况下来改变执行结果呢?我们这个功能可以给用户自由调配。我们添加一个options来给用户自己配置,那我们需要更改以下代码了,如下:

effect(()=>{
	console.log(obj.foo)
},{
	scheduler(fn){
		//...
	}
})
function effect(fn,options={}){
	const effectFn = ()=>{
		cleanup(effectFn)
		effectStack.push(effectFn) 
		activeEffect = effectFn
		fn()
		effectStack.pop()
		activeEffect = effectStack[effectStack.length -1]
	}
	effectFn.options = options //新增 把他挂载在副作用函数上
	//effectFn新增属性deps,用来存储所有与该副作用函数相关联的依赖集合
	effectFn.deps = []
	effectFn()
}
//在tirgger函数更改以下
function tirgger(target,key){
	const depsMap = bucket.get(target)
	if(!depsMap) return 
	const effects = depsMap.get(key)
	//const effectsToRun = new Set(effects) //直接在遍历赋值的时候,把activeEffect给排除掉
	const effectsToRun = new Set()
	effects && effects.forEach((effectFn)=>{
		if(effectFn != activeEffect){
			effectsToRun.add(effectFn)
		}
	})
	effectsToRun && effectsToRun.forEach(effectFn=>{
		if(effectFn.options.scheduler){
			effectFn.options.scheduler(effectFn)
		}else{
			effectFn()
		}
	})
}

我们值执行副作用函数的时候,先判断以下是否存在调度器,如果存在就把副作用函数放到调度器中执行,由用户自己控制如何执行。
这样实现要求 我们只要在调用effect方法的时候,传入一个调度器就好了,如以下:

effect(()=>{
	console.log(obj.foo)
},{
	scheduler(fn){
		setTimeout(fn)
	}
})

这样可以控制副作用函数执行的时机,我们也要控制副作用函数的次数,如以下代码:

const data = { foo: 1}
const obj = new Proxy(data,{/*...*/})
effect(()=>{
	console.log(obj.foo)
})
obj.foo++
obj.foo++

通过运行以上代码,我们发现会打印

1
2
3

2只是过渡状态,我们只关注结果,而不关注过程,在这里,我们调度器可以很简单的实现:

//定义一个任务队列
const jobQueue = new Set()
//使用promise.resolve()创建一个promise实例,我们用它将一个任务放入微任务执行
const p = promise.resolve()
//给一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob(){
	if(isFlushing) return
	isFlushing = true
	p.then(()=>{
		jobQueue.forEach(job => job())
	}).finally(()=>{
	isFlushing = false
	})
}
effect(()=>{
	console.log(obj.foo)
},{
	scheduler(fn){
		jobQueue.add(fn)
		flushJob()
	}
})

我们可以看到 我们把副作用函数的执行放到了微任务执行,使用set集合是为了set集合的去重能力,只有当副作用函数的调度器执行完成后,才会进行微任务的执行,这样就把重复的副作用函数过滤掉了

小结

到这里我们就可以发现,我们以及写出了相对完善的响应式系统了,下一章,我们学习一下,vue的计算属性和lazy

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值