03_处理响应式系统的边界情况

分支切换和cleanup

问题分析

案例代码如下:

javascript 复制代码
const obj = {
	ok: true,
	text: 'hello world'
}

const objProxy = new Proxy(obj, {/* ... */})

function foo() {
	console.log(objProxy.ok ? objProxy.text : 'not')
}

effect(foo)

输出结果如图:

可以看到执行了三次输出,但是应该吗?其实不应该

ok 的初始值为 true,那么第一次会收集 ok、text 两个属性,所以会把 foo 收集两次,如果只是此时将 ok 改为 false 之后,调用一次 foo,但是后续再把 text 重新赋值时,ok 为 false 就不会执行 obj.text,那么就不应该被收集,而且要将上次收集的清除掉,所以按照设想来说,只会触发两次。

解决方案

要解决这一点其实也简单,只要每次重新执行依赖之前,就把之前旧的依赖清除掉即可。

但是如何清除就需要进行一番思考,首先如果要清楚,那总得能存储下来吧,为了实现这一点,我们需要对 effect 函数进行一些改造,如下:

javascript 复制代码
function effect(fn) {
	const effectFn = () => {
		activeFn = effectFn
		fn()
	}
	// 给这个函数添加一个 deps 属性,用来存储所有与该副作用函数相关联的依赖关系
	//  - 里面存储的都是一个个的 Set 依赖集合
    //  - 为啥是数组?比如一个 foo 里面会访问 obj 里面不同的属性,那么就存在多个了
	effectFn.deps = []
	effectFn()
}

有了存储的位置之后,那么肯定就要有加入存储的地方,而收集依赖的地方就在 track 函数中,所以只需要在 track 函数中进行简单的修改即可,如下:

javascript 复制代码
function track(target, key) {
	if (!activeFn) return
	let depsMap = targetMap.get(target)
	if (!depsMap) {
		depsMap = new Map()
		targetMap.set(target, depsMap)
	}
	let deps = depsMap.get(key)
	if (!deps) {
		deps = new Set()
		depsMap.set(key, deps)
	}
	deps.add(activeFn)
	// 将 deps 添加到 activeFn.deps 中-便于后续进行清除上一次的依赖
	activeFn.deps.push(deps)
}

现在已经完成了依赖的记录,剩下的就是清除,那么我们在定义一个清除函数,如下:

javascript 复制代码
function cleanup(effectFn) {
	for (let i = 0; i < effectFn.deps.length; i++) {
		const deps = effectFn.deps[i]
		// 利用 Set 可以很方便的进行删除操作-只需删除特定的依赖即可
		deps.delete(effectFn)
	}
	// 清空 effectFn.deps
	effectFn.deps.length = 0
}

然后再执行副作用函数前,将其清除即可,如下:

javascript 复制代码
function effect(fn) {
	const effectFn = () => {
		activeFn = effectFn
		// 执行前,先清空 deps
		cleanup(effectFn)
		fn()
	}
	effectFn.deps = []
	effectFn()
}

但是如果此时这样就直接执行,那么就会陷入死循环,为什么呢?这是因为在 cleanup 中清除了上一次的依赖之后,就再次执行了代码 fn() 导致依赖被重新收集了,即在 Set 中先进行了一次 delete 的操作,又进行了一次 add 的操作,这个行为可以使用一段示例代码表示,如下:

javascript 复制代码
const set = new Set([1])

set.forEach(item => {
	set.delete(1)
	set.add(1)
	console.log('遍历中')
})

此时运行这段代码,就会陷入死循环,原因就是:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,且此时 forEach 的遍历没有结束的话,那么该值将会重新被访问。

在 MDN 中,有一段描述可以看到这点,点击查看,或者查看记录的截图,如图:

当然,这点在 ECMAScript 中也可以查看到,点击查看

解决这个也很简单,在 trigger 重新派发更新时,我重新构建一个 Set,利用这个 Set 进行遍历即可,代码如下:

javascript 复制代码
function trigger(target, key) {
	let depsMap = targetMap.get(target)
	if (!depsMap) return
	let deps = depsMap.get(key)
	if (!deps) return

	// 重新构造一个 Set,避免死循环
	const effetsToRun = new Set(deps)
	effetsToRun.forEach(fn => fn())
}

测试代码如下:

javascript 复制代码
function foo() {
	console.log('foo run')
	console.log(objProxy.ok ? objProxy.text : 'not')
}

effect(foo)

objProxy.ok = false
objProxy.text = 'hello vue3'

现在我们在 ok 改为 false 之后,再次修改 text 的值就不会再次触发 foo,如图:

嵌套的 effect 和 effect 栈

问题分析

effect 是可以发生嵌套的,而且这个常见在 Vue 中非常常见,如果你之前了解过相关的知识,就知道一个组件的都是由 render 函数生成的,所以组件的嵌套也就是 render 函数的嵌套,类似与 effect(render)。

因此 effect 就需要支持这种嵌套写法,那么现在如果进行嵌套会发生什么呢?我们写一段代码测试一下,如下:

javascript 复制代码
const obj = { foo: 'foo', bar: 'bar' }

const objProxy = new Proxy(obj, /* ... */)

let a, b

effect(function effectFn1() {
	console.log('effectFn1 执行')

	effect(function effectFn2() {
		console.log('effectFn2 执行')
		// 在 effecFn1 中访问 obj.bar
		b = objProxy.bar
	})
	// 在 effecFn1 中访问 obj.foo
	a = objProxy.foo
})

objProxy.bar = 'bar-bar'

如果按照这个设想,我们期望的依赖关系如下:

javascript 复制代码
obj
   |------------ foo
  		  |------------ effctFn1
   |------------ bar
  		  |------------ effctFn2

此时按照我们的期望,如果是修改 bar 的值,那么就只触发 effectFn2,看一下输出的结果,如图:

结果是正常的,那么我们修改一下 foo 的值呢?objProxy.foo = 'foo-foo',此时我们期望的是再次触发 effectFn1 并顺带执行 effectFn2,查看一下输出的结果,如图:

可以看到结果还是没有改变,第一次输出的 effectFn1 和 effectFn2 是正常的,但是第三次输出的 effectFn2 的时候就不对了,我们修改的 foo 的值,但是输出的却是 effectFn2,这是因为我们采用的是全局变量 activeFn 来记录当前激活的函数,执行 effect(effectFn1) 的时候,还没执行完成,就执行了 effect(effectFn2),而 effect 函数内部执行的时候会覆盖 activeFn 全局变量的值,这就导致了正确的收集完成 effectFn2 之后,执行 effectFn1 剩下的代码的时候,activeFn 还是 effectFn2,这就导致收集的依赖关系变成了下面这样:

javascript 复制代码
obj
   |------------ foo
  		  |------------ effctFn2
   |------------ bar
  		  |------------ effctFn2

解决方案

在上述的分析中,结果明显是不对的,那此时可能就会想到一个解决方案,每次 effect 执行完成后都把 activeFn 置空会如何呢?代码如下:

javascript 复制代码
function effect(fn) {
	const effectFn = () => {
		activeFn = effectFn
		cleanup(effectFn)
		fn()
	}
	effectFn.deps = []
	effectFn()
	// 置空
	activeFn = null
}

可以看到结果还是不对,这是因为 effect(effectFn2) 执行完成之后,把 activeFn 设置为了 null,此时 activeFn 为 null,在 track 函数中就不会进行依赖收集的逻辑,自然修改 foo 的值就不会发生任何的函数重新执行。

那应该如何解决呢?其实也简单,我们维护一个栈,每次执行副作用函数之前,把当前执行的副作用函数加入到这个栈中,而当前副作用函数执行完毕后,就把这个函数移出栈,并把 activeFn 赋值为栈中存储的最新一个副作用函数即可,代码如下:

javascript 复制代码
const effectStack = []

/* 省略其他代码... */

function effect(fn) {
	const effectFn = () => {
		activeFn = effectFn
		cleanup(effectFn)
		// 需要进行副作用处理的函数执行前,将当前副作用函数入栈
		effectStack.push(effectFn)
		fn()
		// 当前副作用函数执行后,将当前副作用函数出栈
		effectStack.pop()
		// 并更新当前副作用函数为副作用函数栈中的最后一个副作用函数
		activeFn = effectStack[effectStack.length - 1]
	}
	effectFn.deps = []
	effectFn()
}

此时再次查看执行结果,如图:

避免无限递归循环

问题分析

案例代码如下:

javascript 复制代码
const obj = { a: 1 } 
const objProxy = new Proxy(obj, /* ... */)

effect(() => {
	objProxy.a++
})

这段代码会导致出现一个栈溢出的错误,如图:

这是为什么呢?objProxy.a++ 可以划分为 objProxy.a = objProxy.a + 1,这里 objProxy.a 进行了获取,然后又执行了 objProxy.a 的 赋值,也就说进行了读的操作之后,就又进行了写的操作,写的操作又会导致执行副作用函数,而上一次的副作用函数还没执行完成,就立马进行了下一次的执行,而这个下一次的执行又会重新执行这个步骤,这样无限的递归调用自己,就会导致栈溢出。

解决方案

解决方法也很简单,既然是不停的调用自身,那么只要判断一下如果当前正在执行的副作用函数和当前派发更新的函数一样,就跳过,不进行派发更新调用,代码如下:

javascript 复制代码
function trigger(target, key) {
	let depsMap = targetMap.get(target)
	if (!depsMap) return
	let deps = depsMap.get(key)
	if (!deps) return

	const effetsToRun = new Set(deps)
	effetsToRun.forEach(fn => {
		// 派发更新时的函数不等于当前正在执行的函数,才进行函数的重新触发
		if (fn !== activeFn) {
			fn()
		}
	})
}

当然,也可以在重新构造 Set 集合的时候,将其排除掉,如下:

javascript 复制代码
function trigger(target, key) {
	let depsMap = targetMap.get(target)
	if (!depsMap) return
	let deps = depsMap.get(key)
	if (!deps) return

	const effetsToRun = new Set()
	deps.forEach(effectFn => {
		// 在遍历加入的时候,如果当前正在执行的副作用函数和遍历到的副作用函数是同一个,则不加入
		if (effectFn !== activeFn) {
			effetsToRun.add(effectFn)
		}
	})
	effetsToRun.forEach(fn => fn())
}
相关推荐
开心工作室_kaic43 分钟前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
刚刚好ā43 分钟前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
沉默璇年2 小时前
react中useMemo的使用场景
前端·react.js·前端框架
yqcoder2 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
2401_882727572 小时前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架
SoaringHeart3 小时前
Flutter进阶:基于 MLKit 的 OCR 文字识别
前端·flutter
会发光的猪。3 小时前
css使用弹性盒,让每个子元素平均等分父元素的4/1大小
前端·javascript·vue.js
天下代码客3 小时前
【vue】vue中.sync修饰符如何使用--详细代码对比
前端·javascript·vue.js
猫爪笔记3 小时前
前端:HTML (学习笔记)【1】
前端·笔记·学习·html
前端李易安4 小时前
Webpack 热更新(HMR)详解:原理与实现
前端·webpack·node.js