Vue.js设计与实现—响应式的设计与实现学习笔记(一)

847 阅读8分钟

Vue.js设计与实现—响应式的设计与实现学习笔记(一)

副作用函数

副作用函数,就是执行之后会产生副作用的函数

const obj = { text: 'hello world' }
// effect是一个副作用函数,它执行后会修改body,
// 而其他任何函数都可以读取或写入body,
// 故effect函数的执行会直接或间接影响其他函数
function effect() {
	document.body.innerText = obj.text
}

响应式数据的基本实现

Proxy实现简单响应式

// 存储副作用函数的存储桶
const bucket = new Set()

// 原始数据
const data = {text: 'hello world'}

// 对原始数据的代理
const obj = new Proxy(data, {
	get(target, key) {
		bucket.add(effect)
		return target[key]
	},
	set(target, key, newV) {
		target[key] = newV
		bucket.forEach(fn => fn())
		return true
	}
})

// 副作用函数
function effect() {
	// 读取obj.text,收集依赖
	document.body.innerText = obj.text
}

// 设置obj.text,执行副作用函数
setTimeout(() => {
	obj.text = 'hello vue3'
}, 2000)

存在的问题:上述代码中,我们使用了一个具名为effect的副作用函数,是不切合实际的。我们希望用户能够自定义副作用函数(具名/匿名)

用户自定义副作用函数的实现

const bucket = new Set()

// 用于存储用户注册的副作用函数
let activeEffect

// 用于注册副作用函数
function effect(fn) {
	activeEffect = fn
	fn()
}

const data = {text: 'hello world'}

const obj = new Proxy(data, {
	get(target, key) {
		if (activeEffect) {
			bucket.add(activeEffect)
		}
		return target[key]
	},
	set(target, key, newV) {
		target[key] = newV
		bucket.forEach(fn => fn())
		return true
	},
})

// 注册副作用函数
effect(() => {
	console.log('执行副作用函数')
	document.body.innerHTML = obj.text
})

setTimeout(() => {
	obj.text = 'hello vue3'
	// 给obj赋一个不存在的属性值
	obj.ok = true
}, 2000)

存在的问题:上述代码中,我们给obj赋了一个不存在的属性ok值,会发现副作用函数执行了。我们希望给target赋一个不存在的属性值时,不会触发副作用函数。即需要建立target-key-fn(副作用函数)明确的绑定关系

建立target-key-fn绑定关系

// 使用WeakMap替代Set
const bucket = new WeakMap()

let activeEffect

function effect(fn) {
	activeEffect = fn
	fn()
}

const data = { text: 'hello world' }

const obj = new Proxy(data, {
	get(target, key) {
		// 没有activeEffect,则return
		if (!activeEffect) return target[key]

		let depsMap = bucket.get(target)
		if (!depsMap) {
			// bucket是一个WeakMap结构,与target关联
			bucket.set(target, (depsMap = new Map()))
		}

		let deps = depsMap.get(key)
		if (!deps) {
			// depsMap上一个Map结构,与key关联
			depsMap.set(key, (deps = new Set()))
		}

		// deps是一个Set集合,与副作用函数关联
		deps.add(activeEffect)
		return target[key]
	},
	set(target, key, newV) {
		target[key] = newV

		// 从桶中取出与target关联的depsMap
		let depsMap = bucket.get(target)
		if (!depsMap) return

		// 从depsMap中取出与key关联的Set(副作用函数集合)
		const effects = depsMap.get(key)
		effects && effects.forEach(fn => fn())
		return true
	},
})

// 注册副作用函数
effect(() => {
	console.log('执行副作用函数')
	document.body.innerHTML = obj.text
})

setTimeout(() => {
	obj.text = 'hello vue3'
	// 给obj赋一个不存在的属性值
	obj.ok = true
}, 2000)

运行上述代码,我们给obj赋了一个不存在的属性ok值,副作用函数没有再执行。对上述代码做一下封装

// 使用WeakMap替代Set
const bucket = new WeakMap()

let activeEffect

function effect(fn) {
	activeEffect = fn
	fn()
}

const data = { text: 'hello world' }

const obj = new Proxy(data, {
	get(target, key) {
		track(target, key)
		return target[key]
	},
	set(target, key, newV) {
		target[key] = newV
		trigger(target, key)
		return true
	},
})

function track(target, key) {
	// 没有activeEffect,则return
	if (!activeEffect) return

	let depsMap = bucket.get(target)
	if (!depsMap) {
		// bucket是一个WeakMap结构,与target关联
		bucket.set(target, (depsMap = new Map()))
	}

	let deps = depsMap.get(key)
	if (!deps) {
		// depsMap上一个Map结构,与key关联
		depsMap.set(key, (deps = new Set()))
	}

	// deps是一个Set集合,与副作用函数关联
	deps.add(activeEffect)
}

function trigger(target, key) {
	// 从桶中取出与target关联的depsMap
	let depsMap = bucket.get(target)
	if (!depsMap) return

	// 从depsMap中取出与key关联的Set(副作用函数集合)
	const effects = depsMap.get(key)
	effects && effects.forEach(fn => fn())
}

// 注册副作用函数
effect(() => {
	console.log('执行副作用函数')
	document.body.innerHTML = obj.text
})

setTimeout(() => {
	obj.text = 'hello vue3'
	// 给obj赋一个不存在的属性值
	obj.ok = true
}, 2000)

副作用函数内分支切换

// 使用WeakMap替代Set
const bucket = new WeakMap()

let activeEffect

function effect(fn) {
	activeEffect = fn
	fn()
}

// 新增ok属性,用于副作用函数内分支切换
const data = { text: 'hello world', ok: true }

const obj = new Proxy(data, {
	get(target, key) {
		track(target, key)
		return target[key]
	},
	set(target, key, newV) {
		target[key] = newV
		trigger(target, key)
		return true
	},
})

function track(target, key) {
	// 没有activeEffect,则return
	if (!activeEffect) return

	let depsMap = bucket.get(target)
	if (!depsMap) {
		// bucket是一个WeakMap结构,与target关联
		bucket.set(target, (depsMap = new Map()))
	}

	let deps = depsMap.get(key)
	if (!deps) {
		// depsMap上一个Map结构,与key关联
		depsMap.set(key, (deps = new Set()))
	}

	// deps是一个Set集合,与副作用函数关联
	deps.add(activeEffect)
}

function trigger(target, key) {
	// 从桶中取出与target关联的depsMap
	let depsMap = bucket.get(target)
	if (!depsMap) return

	// 从depsMap中取出与key关联的Set(副作用函数集合)
	const effects = depsMap.get(key)
	effects && effects.forEach(fn => fn())
}

// 注册副作用函数
effect(() => {
	console.log('执行副作用函数')
	document.body.innerHTML = obj.ok ? obj.text : 'not!'
})

setTimeout(() => {
	// 修改ok属性
	obj.ok = false
	obj.text = 'hello vue3'
}, 2000)

存在的问题:上述代码中,我们在副作用函数内进行了分支切换,我们希望obj.ok赋值为false时,无论text属性如何变化,都不应该执行副作用函数。
但实际上还是执行了,因为text属性的依赖集合已经在初始ok为true时收集起来了,修改text后就会触发副作用函数。
我们希望在执行副作用函数前,先清除上一次关联的依赖集合,执行副作用函数时再重新收集依赖

分支切换与清除遗留副作用函数

在执行副作用函数前,先清空依赖集合,然后会在执行副作用函数的时候重新进行依赖收集。
这样当重新给ok赋值为false时,会先清空依赖集合,然后再执行副作用函数,重新进行依赖收集,根据分支切换语句,就不会再收集到text的依赖。

const bucket = new WeakMap()

let activeEffect

// 重新设计副作用函数注册器
function effect(fn) {
	const effectFn = () => {
		// 在执行副作用函数前,先清空依赖集合,然后会在执行副作用函数的时候重新进行依赖收集
		cleanup(effectFn)
		activeEffect = effectFn
		fn()
	}
	// effectFn.deps用于存储与副作用函数相关联的依赖Set
	effectFn.deps = []
	effectFn()
}

const data = { text: 'hello world', ok: true }

const obj = new Proxy(data, {
	get(target, key) {
		track(target, key)
		return target[key]
	},
	set(target, key, newV) {
		target[key] = newV
		trigger(target, key)
		return true
	},
})

function track(target, key) {
	if (!activeEffect) return

	let depsMap = bucket.get(target)
	if (!depsMap) {
		bucket.set(target, (depsMap = new Map()))
	}

	let deps = depsMap.get(key)
	if (!deps) {
		depsMap.set(key, (deps = new Set()))
	}

	deps.add(activeEffect)

	// 将依赖集合存储到当前激活副作用函数deps中
	activeEffect.deps.push(deps)
}

function trigger(target, key) {
	let depsMap = bucket.get(target)
	if (!depsMap) return

	const effects = depsMap.get(key)
	effects && effects.forEach(fn => fn())
}

// 清除遗留副作用函数集合
function cleanup(effectFn) {
	for (let i = 0, len = effectFn.deps.length; i < len; i++) {
		const deps = effectFn.deps[i]
		// 将副作用函数从依赖集合中移除
		deps.delete(effectFn)
	}
	effectFn.deps.length = 0
}

// 注册副作用函数
effect(() => {
	console.log('副作用函数执行了')
	// 副作用函数内的分支切换
	document.body.innerHTML = obj.ok ? obj.text : 'not!'
})

setTimeout(() => {
	obj.ok = false
	obj.text = 'hello vue3'
}, 2000)

存在的问题:上述代码,看似已经解决了分支切换的问题。运行代码发现死循环了。因为Set结构的特性,在Set forEach中先删除了一个item,再添加了删除的这个item,就会导致死循环。可以创造一个新的Set结构来遍历就可以解决这个问题。

const bucket = new WeakMap()

let activeEffect

// 重新设计副作用函数注册器
function effect(fn) {
	const effectFn = () => {
		// 在执行副作用函数前,先清空依赖集合,然后会在执行副作用函数的时候重新进行依赖收集
		cleanup(effectFn)
		activeEffect = effectFn
		fn()
	}
	// effectFn.deps用于存储与副作用函数相关联的依赖Set
	effectFn.deps = []
	effectFn()
}

const data = { text: 'hello world', ok: true }

const obj = new Proxy(data, {
	get(target, key) {
		track(target, key)
		return target[key]
	},
	set(target, key, newV) {
		target[key] = newV
		trigger(target, key)
		return true
	},
})

function track(target, key) {
	if (!activeEffect) return

	let depsMap = bucket.get(target)
	if (!depsMap) {
		bucket.set(target, (depsMap = new Map()))
	}

	let deps = depsMap.get(key)
	if (!deps) {
		depsMap.set(key, (deps = new Set()))
	}

	deps.add(activeEffect)

	// 将依赖集合存储到当前激活副作用函数deps中
	activeEffect.deps.push(deps)
}

function trigger(target, key) {
	let depsMap = bucket.get(target)
	if (!depsMap) return

	const effects = depsMap.get(key)

	// 用一个新的Set集合来遍历
	const effectsToRun = new Set(effects)
	effectsToRun && effectsToRun.forEach(effectFn => effectFn())
}

// 清除遗留副作用函数集合
function cleanup(effectFn) {
	for (let i = 0, len = effectFn.deps.length; i < len; i++) {
		const deps = effectFn.deps[i]
		// 将副作用函数从依赖集合中移除
		deps.delete(effectFn)
	}
	effectFn.deps.length = 0
}

// 注册副作用函数
effect(() => {
	console.log('副作用函数执行了')
	// 副作用函数内的分支切换
	document.body.innerHTML = obj.ok ? obj.text : 'not!'
})

setTimeout(() => {
	obj.ok = false
	obj.text = 'hello vue3'
}, 2000)

存在的问题:上述代码已经解决了一些问题,但在实际应用中副作用函数嵌套的场景,例如一个组件中渲染另一个组件。

const data = { text: 'hello world', ok: true, msg: '111' }
// 嵌套副作用函数
effect(function effectFn1() {
	console.log('effectFn1执行')
	effect(function effectFn2() {
		console.log('effectFn2执行')
		effect(function effectFn3() {
			console.log('effectFn3执行')
			obj.msg
		})
		obj.text
	})
	obj.ok
})
setTimeout(() => {
	obj.ok = false
}, 2000)

我们期望重新给ok赋值时,打印出来的是‘effectFn1执行 effectFn2执行 effectFn3执行’,但实际打印出来‘effectFn3执行’。是因为原先使用activeEffect存储当前激活的函数,当发生effect嵌套时,内层会覆盖外层,所以无论set哪一个属性,都只会执行最内层的副作用函数。

可嵌套的effect

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

到此我们解决了effect嵌套的问题。还有副作用函数中同时读取和设置的情况,就会产生无限递归循环,导致栈溢出。

const data = {foo: 1}
effect(() => {
	obj.foo = obj.foo + 1
})

此时我们发现track收集和trigger触发执行副作用函数都是activeEffect,故只需要增加一个trigger守卫条件:trigger触发的副作用函数与当前激活的副作用函数是否相同,相同则不执行副作用函数。

避免无限循环

function trigger(target, key) {
	// 从桶中取出与target关联的depsMap
	let depsMap = bucket.get(target)
	if (!depsMap) return

	// 从depsMap中取出与key关联的Set(副作用函数集合)
	const effects = depsMap.get(key)

	// 这是书中的源码
	// TODO:这里为什么要遍历两遍呢?我改成下面注释的这段代码了,运行起来没什么问题。
	const effectsToRun = new Set()
	effects &&
		effects.forEach(effectFn => {
			if (effectFn !== activeEffect) {
				effectsToRun.add(effectFn)
			}
		})
	effectsToRun.forEach(effectFn => effectFn())

	// 非书中源码
	// const effectsToRun = new Set(effects)
	// effectsToRun &&
	// 	effectsToRun.forEach(effectFn => {
	// 		if (effectFn !== activeEffect) {
	// 			effectFn()
	// 		}
	// 	})
}

小结

  1. 了解了什么是副作用函数
  2. 实现了基本响应式
  3. 解决了分支切换,遗留副作用函数绑定问题
  4. 解决了副作用函数嵌套问题
  5. 解决了副作用函数内同时读取和设置导致无限循环栈溢出的问题

参考

[ Vue ] Vue 设计原理之响应式系统实现笔记( 一 )
Vue3--响应式的设计与实现