vue.js 3设计与实现 -- 响应系统的作用与实现(一)


若只想了解vue的响应系统,看前三节即可。

一、响应式数据与副作用函数

  副作用函数指的是会产生副作用的函数,如下面的代码所示:

function effect() { 
	document.body.innerText = 'hello vue3' 
}

  当 effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说, effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。

  再来说说什么是响应式数据。假设在一个副作用函数中读取了某个对象的属性:

const obj = { text: 'hello world' } 
function effect() { 
	// effect 函数的执行会读取 obj.text 
	document.body.innerText = obj.text 
}

  如上面的代码所示,副作用函数 effect 会设置 body 元素的 innerText 属性,其值为 obj.text ,当 obj.text 的值发生变化时,我们希望副作用函数 effect 会重新执行:

obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行

  如果能实现这个目标,那么对象 obj 就是响应式数据。但很明显,以上面的代码来看,我们还做不到这一点,因为 obj 是一个普通对象,当我们修改它的值时,除了值本身发生变化之外,不会有任何其他反应。

二、响应式数据的基本实现

  接着上文思考,如何才能让 obj 变成响应式数据呢?通过观察我们能发现两点线索:

  • 当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;
  • 当修改 obj.text 的值时,会触发字段 obj.text 的设置操作。

  如果我们能拦截一个对象的读取和设置操作,事情就变得简单了,当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里,如下图所示。
在这里插入图片描述
  接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行即可,如下图所示。
在这里插入图片描述
  现在问题的关键变成了我们如何才能拦截一个对象属性的读取和设置操作。在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。

接下来我们就根据如上思路,采用 Proxy 来实现:

// 存储副作用函数的桶
const bucket = new Set() 
// 原始数据
const data = { text: 'hello world' } 
// 对原始数据的代理
const obj = new Proxy(data, { 
	// 拦截读取操作
	get(target, key) { 
		// 将副作用函数 effect 添加到存储副作用函数的桶中
		bucket.add(effect) 
		// 返回属性值
		return target[key] 
	}, 
	// 拦截设置操作
	set(target, key, newVal) { 
		// 设置属性值
		target[key] = newVal 
		// 把副作用函数从桶里取出并执行
		bucket.forEach(fn => fn()) 
		// 返回 true 代表设置操作成功
		return true
	} 
})

可以使用下面的代码来测试一下:

// 副作用函数
function effect() { 
	document.body.innerText = obj.text 
} 
// 执行副作用函数,触发读取
effect() 
// 1 秒后修改响应式数据
setTimeout(() => { 
	obj.text = 'hello vue3' 
}, 1000)

  在浏览器中运行上面这段代码,会得到期望的结果。但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为 myEffect ,甚至是一个匿名函数,因此我们要想办法去掉这种硬编码的机制。

三、设计一个完善的响应系统

  从上一节的例子中不难看出,一个响应系统的工作流程如下:

  • 当读取操作发生时,将副作用函数收集到“桶”中;
  • 当设置操作发生时,从“桶”中取出副作用函数并执行。

  看上去很简单,但需要处理的细节还真不少。例如在上一节的实现中,我们硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确地工作了。而我们希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到“桶”中。为了实现这一点,我们需要提供一个用来注册副作用函数的机制,如以下代码所示:

// 用一个全局变量存储被注册的副作用函数
let activeEffect 
// effect 函数用于注册副作用函数
function effect(fn) {
	// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect 
	activeEffect = fn 
	// 执行副作用函数
	fn() 
}

  首先,定义了一个全局变量 activeEffect,初始值是 undefined,它的作用是存储被注册的副作用函数。接着重新定义了 effect 函数,它变成了一个用来注册副作用函数的函数,effect 函数接收一个参数 fn,即要注册的副作用函数。我们可以按照如下所示的方式使用 effect 函数:

effect( 
	// 一个匿名的副作用函数
	() => {
		document.body.innerText = obj.text;
	}
}

  可以看到,我们使用一个匿名的副作用函数作为 effect 函数的参数。当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量 activeEffect。接着执行被注册的匿名副作用函数 fn,这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get 拦截函数:

const obj = new Proxy(data, { 
	get(target, key) { 
		// 将 activeEffect 中存储的副作用函数收集到“桶”中
		if (activeEffect) { // 新增
			bucket.add(activeEffect) // 新增
		} // 新增
		return target[key] 
	}, 
	set(target, key, newVal) { 
		target[key] = newVal 
		bucket.forEach(fn => fn()) 
		return true
	} 
})

  如上面的代码所示,由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。

  但如果我们再对这个系统稍加测试,例如在响应式数据 obj 上设置一个不存在的属性时:

effect( 
	// 匿名副作用函数
	() => { 
		console.log('effect run') // 会打印 2 次
		document.body.innerText = obj.text 
	} 
)
setTimeout(() => { 
	// 副作用函数中并没有读取 notExist 属性的值
	obj.notExist = 'hello vue3' 
}, 1000) 

  我们知道,在匿名副作用函数内并没有读取 obj.notExist 属性的值,所以理论上,字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。但如果我们执行上述这段代码就会发现,定时器到时后,所有匿名副作用函数却重新执行了,这是不正确的。为了解决这个问题,我们需要重新设计“桶”的数据结构。

  导致上面问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。要明确操作目标字段时应该触发哪些副作用函数不应该触发哪些副作用函数。

接下来我们尝试用代码来实现一个新的“桶”,来解决上面的问题。首先,需要使用 WeakMap 代替 Set 作为桶的数据结构:

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

然后修改 get/set 拦截器代码:

const obj = new Proxy(data, { 
	// 拦截读取操作
	get(target, key) { 
		// 没有 activeEffect,直接 return 
		if (!activeEffect) return target[key] 
		// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects 
		let depsMap = bucket.get(target) 
		// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
		if (!depsMap) { 
			bucket.set(target, (depsMap = new Map())) 
		} 
		// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
		// 里面存储着所有与当前 key 相关联的副作用函数:effects 
		let deps = depsMap.get(key) 
		// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
		if (!deps) { 
			depsMap.set(key, (deps = new Set())) 
		} 
		// 最后将当前激活的副作用函数添加到“桶”里
		deps.add(activeEffect) 
		// 返回属性值
		return target[key] 
	}, 
	// 拦截设置操作
	set(target, key, newVal) { 
		// 设置属性值
		target[key] = newVal 
		// 根据 target 从桶中取得 depsMap,它是 key --> effects 
		const depsMap = bucket.get(target) 
		if (!depsMap) return
		// 根据 key 取得所有副作用函数 effects 
		const effects = depsMap.get(key) 
		// 执行副作用函数
		effects && effects.forEach(fn => fn()) 
	} 
}) 

从这段代码可以看出构建数据结构的方式,我们分别使用了 WeakMap、Map 和 Set:

  • WeakMap 由 target --> Map 构成;
  • Map 由 key --> Set 构成。

  其中 WeakMap 的键是原始对象 target, WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。它们的关系如下图所示。
在这里插入图片描述
至于为什么使用 WeakMap ,可以参考这篇文章https://blog.csdn.net/qq_36973122/article/details/125926309

  最后,我们对上文中的代码做一些封装处理。在目前的实现中,当读取属性值时,我们直接在 get 拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑,但更好的做法是将这部分逻辑单独封装到一个 track 函数中,函数的名字叫 track 是为了表达追踪的含义。同样,我们也可以把触发副作用函数重新执行的逻辑封装到 trigger 函数中:

const obj = new Proxy(data, { 
	// 拦截读取操作
	get(target, key) { 
		// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
		track(target, key) 
		// 返回属性值
		return target[key] 
	}, 
	// 拦截设置操作
	set(target, key, newVal) { 
		// 设置属性值
		target[key] = newVal 
		// 把副作用函数从桶里取出并执行
		trigger(target, key) 
	} 
}) 

// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) { 
	// 没有 activeEffect,直接 return 
	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) 
} 

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) { 
	const depsMap = bucket.get(target) 
	if (!depsMap) return
	const effects = depsMap.get(key) 
	effects && effects.forEach(fn => fn()) 
}

四、响应系统需要考虑的问题

1. 分支切换与 cleanup

首先,我们需要明确分支切换的定义,如下面的代码所示:

const data = { ok: true, text: 'hello world' } 
const obj = new Proxy(data, { /* ... */ }) 

effect(function effectFn() { 
	document.body.innerText = obj.ok ? obj.text : 'not' 
}) 

  在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok 值的不同会执行不同的代码分支。当字段 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。

  分支切换可能会产生遗留的副作用函数。拿上面这段代码来说,字段 obj.ok 的初始值为true,这时会读取字段 obj.text 的值,所以当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text 这两个属性的读取操作,副作用函数 effectFn 分别被字段 data.ok 和字段 data.text 所对应的依赖集合收集。当字段 obj.ok 的值修改为 false ,并触发副作用函数重新执行后,由于此时字段 obj.text 不会被读取,只会触发字段 obj.ok 的读取操作,所以理想情况下副作用函数 effectFn 不应该被字段 obj.text 所对应的依赖集合收集。但事实上 obj.text 的值修改都会触发 effectFn 的执行,这时就产生了遗留的副作用函数。

  解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。所以,如果我们能做到每次副作用函数执行前,将其从相关联的依赖集合中移除,那么问题就迎刃而解了。

  要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,如下面的代码所示。在 effect 内部我们定义了新的effectFn 函数,并为其添加了 effectFn.deps 属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合:

// 用一个全局变量存储被注册的副作用函数
let activeEffect 
function effect(fn) { 
	const effectFn = () => { 
		// 当 effectFn 执行时,将其设置为当前激活的副作用函数
		activeEffect = effectFn 
		fn() 
	} 
	// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
	effectFn.deps = [] 
	// 执行副作用函数
	effectFn() 
}

那么 effectFn.deps 数组中的依赖集合是如何收集的呢?其实是在 track 函数中:

function track(target, key) { 
	// 没有 activeEffect,直接 return 
	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 中
	deps.add(activeEffect) 
	// deps 就是一个与当前副作用函数存在联系的依赖集合
	// 将其添加到 activeEffect.deps 数组中
	activeEffect.deps.push(deps) // 新增
}

  有了这个联系后,我们就可以在每次副作用函数执行时,根据 effectFn.deps 获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:

// 用一个全局变量存储被注册的副作用函数
let activeEffect 
function effect(fn) { 
	const effectFn = () => { 
		// 调用 cleanup 函数完成清除工作
		cleanup(effectFn) // 新增
		activeEffect = effectFn 
		fn() 
	} 
	effectFn.deps = [] 
	effectFn() 
}

function cleanup(effectFn) { 
	// 遍历 effectFn.deps 数组
	for (let i = 0; i < effectFn.deps.length; i++) { 
		// deps 是依赖集合
		const deps = effectFn.deps[i] 
		// 将 effectFn 从依赖集合中移除
		deps.delete(effectFn) 
	} 
	// 最后需要重置 effectFn.deps 数组
	effectFn.deps.length = 0 
}

  至此,我们的响应系统已经可以避免副作用函数产生遗留了。但如果你尝试运行代码,会发现目前的实现会导致无限循环执行,问题出在 trigger 函数中:

function trigger(target, key) { 
	const depsMap = bucket.get(target) 
	if (!depsMap) return
	const effects = depsMap.get(key) 
	effects && effects.forEach(fn => fn()) // 问题出在这句代码
}

  语言规范中对此有明确的说明:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问。因此,上面的代码会无限执行。解决办法很简单,我们可以构造另外一个 Set 集合并遍历它:

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

	const effectsToRun = new Set(effects) // 新增
	effectsToRun.forEach(effectFn => effectFn()) // 新增
	// effects && effects.forEach(effectFn => effectFn()) // 删除
} 

  如以上代码所示,我们新构造了 effectsToRun 集合并遍历它,代替直接遍历 effects 集合,从而避免了无限执行。

2. 嵌套的 effect 与 effect 栈

effect 是可以发生嵌套的,例如:

effect(function effectFn1() { 
	effect(function effectFn2() { /* ... */ }) 
	/* ... */ 
})

  在上面这段代码中,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。那么,什么场景下会出现嵌套的 effect 呢?拿 Vue.js 来说,实际上 Vue.js 的渲染函数就是在一个 effect 中执行的:

// Foo 组件
const Foo = { 
	render() { 
		return /* ... */ 
	} 
}

在一个 effect 中执行 Foo 组件的渲染函数:

effect(() => { 
	Foo.render() 
})

当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件:

// Bar 组件
const Bar = { 
	render() { /* ... */ }, 
} 
// Foo 组件渲染了 Bar 组件
const Foo = { 
	render() { 
		return <Bar /> // jsx 语法
	}, 
}

此时就发生了 effect 嵌套,它相当于:

effect(() => { 
	Foo.render() 
	// 嵌套
	effect(() => { 
		Bar.render() 
	}) 
})

  上面的例子说明了为什么 effect 要设计成可嵌套的。接下来,我们需要搞清楚,如果 effect 不支持嵌套会发生什么?实际上,按照前文的介绍与实现来看,我们所实现的响应系统并不支持 effect 嵌套,可以用下面的代码来测试一下:

// 原始数据
const data = { foo: true, bar: true } 
// 代理对象
const obj = new Proxy(data, { /* ... */ }) 

// 全局变量
let temp1, temp2 
// effectFn1 嵌套了 effectFn2 
effect(function effectFn1() { 
	console.log('effectFn1 执行') 

	effect(function effectFn2() { 
		console.log('effectFn2 执行') 
		// 在 effectFn2 中读取 obj.bar 属性
		 temp2 = obj.bar 
	}) 
	// 在 effectFn1 中读取 obj.foo 属性
	temp1 = obj.foo 
})

  在理想情况下,我们希望当修改 obj.foo 时会触发 effectFn1 执行。由于 effectFn2 嵌套在 effectFn1 里,所以会间接触发 effectFn2 执行,而当修改 obj.bar 时,只会触发 effectFn2 执行。但结果不是这样的,我们尝试修改 obj.foo 的值,会发现输出为:

'effectFn1 执行' 
'effectFn2 执行' 
'effectFn2 执行'

  一共打印三次,前两次分别是副作用函数 effectFn1 与 effectFn2 初始执行的打印结果,到这一步是正常的,问题出在第三行打印。我们修改了字段 obj.foo 的值,发现 effectFn1 并没有重新执行,反而使得 effectFn2 重新执行了,这显然不符合预期。
  问题出在哪里呢?其实就出在我们实现的 effect 函数与 activeEffect 上。观察下面这段代码:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect 
function effect(fn) { 
	const effectFn = () => { 
		cleanup(effectFn) 
		// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect 
		activeEffect = effectFn 
		fn() 
	}
	// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
	effectFn.deps = [] 
	// 执行副作用函数
	effectFn() 
}

  我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这就是问题所在。

  为了解决这个问题,我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况,如以下代码所示:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect 
// effect 栈
const effectStack = [] // 新增

function effect(fn) { 
	const effectFn = () => { 
		cleanup(effectFn) 
		// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect 
		activeEffect = effectFn 
		// 在调用副作用函数之前将当前副作用函数压入栈中
		effectStack.push(effectFn) // 新增
		fn() 
		// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
		effectStack.pop() // 新增
		activeEffect = effectStack[effectStack.length - 1] // 新增
	} 
	// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
	effectFn.deps = [] 
	// 执行副作用函数
	effectFn() 
}

  我们定义了 effectStack 数组,用它来模拟栈,activeEffect 没有变化,它仍然指向当前正在执行的副作用函数。不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数,当内层副作用函数 effectFn2 执行完毕后,它会被弹出栈,并将副作用函数 effectFn1 设置为 activeEffect。如此一来,响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。

3. 避免无限递归循环

  如前文所说,实现一个完善的响应系统要考虑诸多细节。而本节要介绍的无限递归循环就是其中之一,先举个例子:

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

effect(() => obj.foo++)

可以看到,在 effect 注册的副作用函数内有一个自增操作 obj.foo++,该操作会引起栈溢出:

Uncaught RangeError: Maximum call stack size exceeded

为什么会这样呢?实际上,我们可以把 obj.foo++ 这个自增操作分开来看,它相当于:

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

  在这个语句中,既会读取 obj.foo 的值,又会设置 obj.foo 的值,而这就是导致问题的根本原因。我们可以尝试推理一下代码的执行流程:首先读取 obj.foo 的值,这会触发 track 操作,将当前副作用函数收集到“桶”中,接着将其加 1 后再赋值给 obj.foo ,此时会触发 trigger 操作,即把“桶”中的副作用函数取出并执行。但问题是该副作用函数正在执行中,还没有执行完毕,就要开始下一次的执行。这样会导致无限递归地调用自己,于是就产生了栈溢出。

  解决办法并不难。通过分析这个问题我们能够发现,读取和设置操作是在同一个副作用函数内进行的。此时无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是 activeEffect。基于此,我们可以在 trigger 动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行,如以下代码所示:

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

	const effectsToRun = new Set() 
	effects && effects.forEach(effectFn => { 
		// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
		if (effectFn !== activeEffect) { // 新增
			effectsToRun.add(effectFn) 
		} 
	}) 
	effectsToRun.forEach(effectFn => effectFn()) 
	// effects && effects.forEach(effectFn => effectFn()) 
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值