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

五、调度执行

  可调度性是响应系统非常重要的特性。首先我们需要明确什么是可调度性。所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
  首先来看一下,如何决定副作用函数的执行方式,以下面的代码为例:

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

effect(() => { 
	console.log(obj.foo) 
}) 

obj.foo++ 

console.log('结束了')

//1 
//2 
//'结束了'

现在假设需求有变,输出顺序需要调整为:

//1 
//'结束了' 
//2

  根据打印结果我们很容易想到对策,即把语句 obj.foo++ 和语句 console.log(‘结束了’) 位置互换即可。那么有没有什么办法能够在不调整代码的情况下实现需求呢?这时就需要响应系统支持调度。
  我们可以为 effect 函数设计一个选项参数 options,允许用户指定调度器:

effect( 
	() => { 
		console.log(obj.foo) 
	}, 
	// options 
	{ 
		// 调度器 scheduler 是一个函数
		scheduler(fn) { 
			// ... 
		} 
	} 
)

  如上面的代码所示,用户在调用 effect 函数注册副作用函数时,可以传递第二个参数options。它是一个对象,其中允许指定 scheduler 调度函数,同时在 effect 函数内部我们需要把 options 选项挂载到对应的副作用函数上:

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

  有了调度函数,我们在 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 => { 
		if (effectFn !== activeEffect) { 
			effectsToRun.add(effectFn) 
		} 
	}) 
	effectsToRun.forEach(effectFn => { 
		// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
		if (effectFn.options.scheduler) { // 新增
			effectFn.options.scheduler(effectFn) // 新增
		} else { 
			// 否则直接执行副作用函数(之前的默认行为)
			effectFn() // 新增
		} 
	}) 
} 

  如上面的代码所示,在 trigger 动作触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制如何执行;否则保留之前的行为,即直接执行副作用函数。有了这些基础设施之后,我们就可以实现前文的需求了,如以下代码所示:

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

effect( 
	() => { 
		console.log(obj.foo) 
	}, 
	// options 
	{ 
		// 调度器 scheduler 是一个函数
		scheduler(fn) { 
			// 将副作用函数放到宏任务队列中执行
			setTimeout(fn) 
		} 
	}
) 

obj.foo++ 

console.log('结束了')

我们使用 setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能实现期望的打印顺序了:

//1 
//'结束了' 
//2

  除了控制副作用函数的执行顺序,通过调度器还可以做到控制它的执行次数,这一点也尤为重要。我们思考如下例子:

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

effect(() => { 
	console.log(obj.foo) 
}) 

obj.foo++ 
obj.foo++

//1
//2
//3

  由输出可知,字段 obj.foo 的值一定会从 1 自增到 3, 2 只是它的过渡状态。如果我们只关心最终结果而不关心过程,那么执行三次打印操作是多余的,我们期望的打印结果是:

//1
//3

其中不包含过渡状态,基于调度器我们可以很容易地实现此功能:

// 定义一个任务队列
const jobQueue = new Set() 
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve() 

// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() { 
	// 如果队列正在刷新,则什么都不做
	if (isFlushing) return
	// 设置为 true,代表正在刷新
	isFlushing = true
	// 在微任务队列中刷新 jobQueue 队列
	p.then(() => { 
		jobQueue.forEach(job => job()) 
	}).finally(() => { 
		// 结束后重置 isFlushing 
		isFlushing = false
	}) 
} 

effect(() => { 
	console.log(obj.foo) 
}, { 
	scheduler(fn) { 
		// 每次调度时,将副作用函数添加到 jobQueue 队列中
		jobQueue.add(fn) 
		// 调用 flushJob 刷新队列
		flushJob() 
	} 
}) 

obj.foo++ 
obj.foo++ 

  观察上面的代码,首先,我们定义了一个任务队列 jobQueue,它是一个 Set 数据结构,目的是利用 Set 数据结构的自动去重能力。接着我们看调度器 scheduler 的实现,在每次调度执行时,先将当前副作用函数添加到 jobQueue 队列中,再调用 flushJob 函数刷新队列。然后我们把目光转向 flushJob 函数,该函数通过 isFlushing 标志判断是否需要执行,只有当其为 false 时才需要执行,而一旦 flushJob 函数开始执行,isFlushing 标志就会设置为 true,意思是无论调用多少次 flushJob 函数,在一个周期内都只会执行一次。需要注意的是,在 flushJob 内通过 p.then将一个函数添加到微任务队列,在微任务队列内完成对 jobQueue 的遍历执行。
  整段代码的效果是,连续对 obj.foo 执行两次自增操作,会同步且连续地执行两次 scheduler调度函数,这意味着同一个副作用函数会被 jobQueue.add(fn) 语句添加两次,但由于 Set 数据结构的去重能力,最终 jobQueue 中只会有一项,即当前副作用函数。类似地,flushJob 也会同步且连续地执行两次,但由于 isFlushing 标志的存在,实际上 flushJob 函数在一个事件循环内只会执行一次,即在微任务队列内执行一次。当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。由于此时 jobQueue 队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段 obj.foo 的值已经是 3 了,这样我们就实现了期望的输出。
  可能你已经注意到了,这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍的相同。

六、计算属性 computed 与 lazy

  前文介绍了 effect 函数,它用来注册副作用函数,同时它也允许指定一些选项参数 options,例如指定 scheduler 调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的 track 函数,以及用来触发副作用函数重新执行的 trigger 函数。实际上,综合这些内容,我们就可以实现 Vue.js 中一个非常重要并且非常有特色的能力——计算属性。
  在深入讲解计算属性之前,我们需要先来聊聊关于懒执行的 effect,即 lazy 的 effect。这是什么意思呢?举个例子,现在我们所实现的 effect 函数会立即执行传递给它的副作用函数,例如:

effect( 
	// 这个函数会立即执行
	() => { 
		console.log(obj.foo) 
	} 
) 

  但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。这时我们可以通过在 options 中添加 lazy 属性来达到目的,如下面的代码所示:

effect( 
	// 指定了 lazy 选项,这个函数不会立即执行
	() => { 
		console.log(obj.foo) 
	}, 
	// options 
	{ 
		lazy: true
	} 
)

  lazy 选项和之前介绍的 scheduler 一样,它通过 options 选项对象指定。有了它,我们就可以修改 effect 函数的实现逻辑了,当 options.lazy 为 true 时,则不立即执行副作用函数:

function effect(fn, options = {}) { 
	const effectFn = () => { 
		cleanup(effectFn) 
		activeEffect = effectFn 
		effectStack.push(effectFn) 
		fn() 
		effectStack.pop() 
		activeEffect = effectStack[effectStack.length - 1] 
	} 
	effectFn.options = options 
	effectFn.deps = [] 
	// 只有非 lazy 的时候,才执行
	if (!options.lazy) { // 新增
		// 执行副作用函数
		effectFn() 
	} 
	// 将副作用函数作为返回值返回
	return effectFn // 新增
}

  通过这个判断,我们就实现了让副作用函数不立即执行的功能。但问题是,副作用函数应该什么时候执行呢?通过上面的代码可以看到,我们将副作用函数 effectFn 作为 effect 函数的返回值,这就意味着当调用 effect 函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行该副作用函数了:

const effectFn = effect(() => { 
	console.log(obj.foo) 
}, { lazy: true }) 

// 手动执行副作用函数
effectFn()

  如果仅仅能够手动执行副作用函数,其意义并不大。但如果我们把传递给 effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值,例如:

const effectFn = effect( 
	// getter 返回 obj.foo 与 obj.bar 的和
	() => obj.foo + obj.bar, 
	{ lazy: true } 
)

这样我们在手动执行副作用函数时,就能够拿到其返回值:

const effectFn = effect( 
	// getter 返回 obj.foo 与 obj.bar 的和
	() => obj.foo + obj.bar, 
	{ lazy: true } 
) 
// value 是 getter 的返回值
const value = effectFn()

为了实现这个目标,我们需要再对 effect 函数做一些修改,如以下代码所示:

function effect(fn, options = {}) { 
	const effectFn = () => { 
		cleanup(effectFn) 
		activeEffect = effectFn 
		effectStack.push(effectFn) 
		// 将 fn 的执行结果存储到 res 中
		const res = fn() // 新增
		effectStack.pop() 
		activeEffect = effectStack[effectStack.length - 1] 
		// 将 res 作为 effectFn 的返回值
		return res // 新增
	} 
	effectFn.options = options 
	effectFn.deps = [] 
	if (!options.lazy) { 
		effectFn() 
	}
	return effectFn 
}

  通过新增的代码可以看到,传递给 effect 函数的参数 fn 才是真正的副作用函数,而 effectFn是我们包装后的副作用函数。为了通过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其保存到 res 变量中,然后将其作为 effectFn 函数的返回值。

  现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了,如下所示:

function computed(getter) { 
	// 把 getter 作为副作用函数,创建一个 lazy 的 effect 
	const effectFn = effect(getter, { 
		lazy: true
	}) 

	const obj = { 
		// 当读取 value 时才执行 effectFn 
		get value() { 
			return effectFn() 
		} 
	} 
	return obj 
}

  首先我们定义一个 computed 函数,它接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy 的 effect。computed 函数的执行会返回一个对象,该对象的value 属性是一个访问器属性,只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回。
  我们可以使用 computed 函数来创建一个计算属性:

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

const sumRes = computed(() => obj.foo + obj.bar) 

console.log(sumRes.value) // 3 

  可以看到它能够正确地工作。不过现在我们实现的计算属性只做到了懒计算,也就是说,只有当你真正读取 sumRes.value 的值时,它才会进行计算并得到值。但是还做不到对值进行缓存,即假如我们多次访问 sumRes.value 的值,会导致 effectFn 进行多次计算,即使 obj.foo 和 obj.bar 的值本身并没有变化:

console.log(sumRes.value) // 3 
console.log(sumRes.value) // 3 
console.log(sumRes.value) // 3 

  上面的代码多次访问 sumRes.value 的值,每次访问都会调用 effectFn 重新计算。为了解决这个问题,就需要我们在实现 computed 函数时,添加对值进行缓存的功能,如以下代码所示:

function computed(getter) { 
	// value 用来缓存上一次计算的值
	let value 
	// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
	let dirty = true

	const effectFn = effect(getter, { 
		lazy: true
	}) 

	const obj = { 
		get value() { 
			// 只有“脏”时才计算值,并将得到的值缓存到 value 中
			if (dirty) { 
				value = effectFn() 
				// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
				dirty = false
			} 
			return value 
		} 
	} 

	return obj 
}

  我们新增了两个变量 value 和 dirty,其中 value 用来缓存上一次计算的值,而 dirty 是一个标识,代表是否需要重新计算。当我们通过 sumRes.value 访问值时,只有当 dirty 为 true 时才会调用 effectFn 重新计算值,否则直接使用上一次缓存在 value 中的值。这样无论我们访问多少次 sumRes.value ,都只会在第一次访问时进行真正的计算,后续访问都会直接读取缓存的value 值。
  相信聪明的你已经看到问题所在了,如果此时我们修改 obj.foo 或 obj.bar 的值,再访问sumRes.value 会发现访问到的值没有发生变化:

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

const sumRes = computed(() => obj.foo + obj.bar) 

console.log(sumRes.value) // 3 
console.log(sumRes.value) // 3 

// 修改 obj.foo 
obj.foo++
// 再次访问,得到的仍然是 3,但预期结果应该是 4 
console.log(sumRes.value) // 3 

  这是因为,当第一次访问 sumRes.value 的值后,变量 dirty 会设置为 false,代表不需要计算。即使我们修改了 obj.foo 的值,但只要 dirty 的值为 false ,就不会重新计算,所以导致我们得到了错误的值。
  解决办法很简单,当 obj.foo 或 obj.bar 的值发生变化时,只要 dirty 的值重置为 true 就可以了。那么应该怎么做呢?这时就用到了上一节介绍的 scheduler 选项,如以下代码所示:

function computed(getter) { 
	let value 
	let dirty = true

	const effectFn = effect(getter, { 
		lazy: true, 
		// 添加调度器,在调度器中将 dirty 重置为 true 
		scheduler() { 
			dirty = true
		} 
	}) 

	const obj = { 
		get value() { 
			if (dirty) { 
				value = effectFn() 
				dirty = false
			} 
			return value 
		} 
	} 

	return obj 
}

  我们为 effect 添加了 scheduler 调度器函数,它会在 getter 函数中所依赖的响应式数据变化时执行,这样我们在 scheduler 函数内将 dirty 重置为 true,当下一次访问 sumRes.value 时,就会重新调用 effectFn 计算值,这样就能够得到预期的结果了。
  现在,我们设计的计算属性已经趋于完美了,但还有一个缺陷,它体现在当我们在另外一个effect 中读取计算属性的值时:

const sumRes = computed(() => obj.foo + obj.bar) 

effect(() => { 
	// 在该副作用函数中读取 sumRes.value 
	console.log(sumRes.value) 
}) 

// 修改 obj.foo 的值
obj.foo++ 

  如以上代码所示,sumRes 是一个计算属性,并且在另一个 effect 的副作用函数中读取了 sumRes.value 的值。如果此时修改 obj.foo 的值,我们期望副作用函数重新执行,就像我们在 Vue.js 的模板中读取计算属性值的时候,一旦计算属性发生变化就会触发重新渲染一样。但是如果尝试运行上面这段代码,会发现修改 obj.foo 的值并不会触发副作用函数的渲染,因此我们说这是一个缺陷。
  分析问题的原因,我们发现,从本质上看这就是一个典型的 effect 嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect 收集为依赖。而当把计算属性用于另外一个 effect 时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。
  解决办法很简单。当读取计算属性的值时,我们可以手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,我们可以手动调用 trigger 函数触发响应:

function computed(getter) { 
	let value 
	let dirty = true

	const effectFn = effect(getter, { 
		lazy: true, 
		scheduler() { 
			if (!dirty) { 
				dirty = true
				// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
				trigger(obj, 'value') 
			} 
		} 
	}) 

	const obj = { 
		get value() { 
			if (dirty) { 
				value = effectFn() 
				dirty = false
			} 
			// 当读取 value 时,手动调用 track 函数进行追踪
			track(obj, 'value') 
			return value 
		} 
	} 

	return obj 
}

  如以上代码所示,当读取一个计算属性的 value 值时,我们手动调用 track 函数,把计算属性返回的对象 obj 作为 target,同时作为第一个参数传递给 track 函数。当计算属性所依赖的响应式数据变化时,会执行调度器函数,在调度器函数内手动调用 trigger 函数触发响应即可。这时,对于如下代码来说:

effect(function effectFn() { 
	console.log(sumRes.value) 
})

它会建立这样的联系:

computed(obj) 
	└── value 
		└── effectFn 

下图给出了更详细的描述.
在这里插入图片描述

七、watch 的实现原理

  所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。举个例子:

watch(obj, () => { 
	console.log('数据变了') 
}) 

// 修改响应数据的值,会导致回调函数执行
obj.foo++ 

  假设 obj 是一个响应数据,使用 watch 函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。
  实际上 ,watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项,如以下代码所示:

effect(() => { 
	console.log(obj.foo) 
}, { 
	scheduler() { 
		// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
	} 
})

  在一个副作用函数中访问响应式数据 obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。但有一个例外,即如果副作用函数存在 scheduler 选项,当响应式数据发生变化时,会触发 scheduler 调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler 调度函数就相当于一个回调函数,而 watch 的实现就是利用了这个特点。下面是最简单的 watch 函数的实现:

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) { 
	effect( 
		// 触发读取操作,从而建立联系
		() => source.foo, 
		{ 
			scheduler() { 
				// 当数据变化时,调用回调函数 cb 
				cb() 
			} 
		} 
	) 
} 

我们可以如下所示使用 watch 函数:

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

watch(obj, () => { 
	console.log('数据变化了') 
}) 

obj.foo++ 

  上面这段代码能正常工作,但是我们注意到在 watch 函数的实现中,硬编码了对 source.foo 的读取操作。换句话说,现在只能观测 obj.foo 的改变。为了让 watch 函数具有通用性,我们需要一个封装一个通用的读取操作:

function watch(source, cb) { 
	effect( 
		// 调用 traverse 递归地读取
		() => traverse(source), 
		{ 
			scheduler() { 
				// 当数据变化时,调用回调函数 cb
				cb() 
			} 
		} 
	) 
} 

function traverse(value, seen = new Set()) { 
	// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
	if (typeof value !== 'object' || value === null || seen.has(value)) return
	// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
	seen.add(value) 
	// 暂时不考虑数组等其他结构
	// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
	for (const k in value) { 
		traverse(value[k], seen) 
	} 

	return value 
}

  如上面的代码所示,在 watch 内部的 effect 中调用 traverse 函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行。
  watch 函数除了可以观测响应式数据,还可以接收一个 getter 函数:

watch( 
	// getter 函数
	() => obj.foo, 
	// 回调函数
	() => { 
		console.log('obj.foo 的值变了') 
	} 
)

  如以上代码所示,传递给 watch 函数的第一个参数不再是一个响应式数据,而是一个 getter函数。在 getter 函数内部,用户可以指定该 watch 依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:

function watch(source, cb) { 
	// 定义 getter 
	let getter 
	// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter 
	if (typeof source === 'function') { 
		getter = source 
	} else { 
		// 否则按照原来的实现调用 traverse 递归地读取
		getter = () => traverse(source) 
	} 

	effect(
		// 执行 getter 
		() => getter(), 
		{ 
			scheduler() { 
				cb() 
			} 
		} 
	) 
}

  首先判断 source 的类型,如果是函数类型,说明用户直接传递了 getter 函数,这时直接使用用户的 getter 函数;如果不是函数类型,那么保留之前的做法,即调用 traverse 函数递归地读取。这样就实现了自定义 getter 的功能,同时使得 watch 函数更加强大。
  细心的你可能已经注意到了,现在的实现还缺少一个非常重要的能力,即在回调函数中拿不到旧值与新值。通常我们在使用 Vue.js 中的 watch 函数时,能够在回调函数中得到变化前后的值:

watch( 
	() => obj.foo, 
	(newValue, oldValue) => { 
		console.log(newValue, oldValue) // 2, 1 
	} 
) 

obj.foo++

那么如何获得新值与旧值呢?这需要充分利用 effect 函数的 lazy 选项,如以下代码所示:

function watch(source, cb) { 
	let getter 
	if (typeof source === 'function') { 
		getter = source 
	} else { 
		getter = () => traverse(source) 
	} 
	// 定义旧值与新值
	let oldValue, newValue 
	// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
	const effectFn = effect( 
		() => getter(), 
		{ 
			lazy: true, 
			scheduler() { 
				// 在 scheduler 中重新执行副作用函数,得到的是新值
				newValue = effectFn() 
				// 将旧值和新值作为回调函数的参数
				cb(newValue, oldValue) 
				// 更新旧值,不然下一次会得到错误的旧值
				oldValue = newValue 
			} 
		} 
	)
	// 手动调用副作用函数,拿到的值就是旧值
	oldValue = effectFn() 
} 

  在这段代码中,最核心的改动是使用 lazy 选项创建了一个懒执行的 effect。注意上面代码中最下面的部分,我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数 cb 就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值。

八、立即执行的 watch 与回调执行时机

  上一节中我们介绍了 watch 的基本实现。在这个过程中我们认识到,watch 的本质其实是对 effect 的二次封装。本节我们继续讨论关于 watch 的两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机。
  首先来看立即执行的回调函数。默认情况下,一个 watch 的回调只会在响应式数据发生变化时才执行:

// 回调函数只有在响应式数据 obj 后续发生变化时才执行
watch(obj, () => { 
	console.log('变化了') 
})

在 Vue.js 中可以通过选项参数 immediate 来指定回调是否需要立即执行:

watch(obj, () => { 
		console.log('变化了') 
	}, { 
		// 回调函数会在 watch 创建时立即执行一次
		immediate: true
	}
)

  当 immediate 选项存在并且为 true 时,回调函数会在该 watch 创建时立刻执行一次。仔细思考就会发现,回调函数的立即执行与后续执行本质上没有任何差别,所以我们可以把 scheduler 调度函数封装为一个通用函数,分别在初始化和变更时执行它,如以下代码所示:

function watch(source, cb, options = {}) { 
	let getter 
	if (typeof source === 'function') { 
		getter = source 
	} else { 
		getter = () => traverse(source) 
	}
	let oldValue, newValue 

	// 提取 scheduler 调度函数为一个独立的 job 函数
	const job = () => { 
		newValue = effectFn() 
		cb(newValue, oldValue) 
		oldValue = newValue 
	} 

	const effectFn = effect( 
		// 执行 getter 
		() => getter(), 
		{ 
			lazy: true, 
			// 使用 job 函数作为调度器函数
			scheduler: job 
		} 
	) 

	if (options.immediate) { 
		// 当 immediate 为 true 时立即执行 job,从而触发回调执行
		job() 
	} else { 
		oldValue = effectFn() 
	} 
}

  这样就实现了回调函数的立即执行功能。由于回调函数是立即执行的,所以第一次回调执行时没有所谓的旧值,因此此时回调函数的 oldValue 值为 undefined,这也是符合预期的。除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定回调函数的执行时机,例如在 Vue.js 3 中使用 flush 选项来指定:

watch(obj, () => { 
		console.log('变化了') 
	}, { 
		// 回调函数会在 watch 创建时立即执行一次
		flush: 'pre' // 还可以指定为 'post' | 'sync' 
	}
)

  flush 本质上是在指定调度函数的执行时机。前文讲解过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。当 flush 的值为 ‘post’ 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行,我们可以用如下代码进行模拟:

function watch(source, cb, options = {}) { 
	let getter 
	if (typeof source === 'function') { 
		getter = source 
	} else { 
		getter = () => traverse(source) 
	} 
	let oldValue, newValue 

	const job = () => { 
		newValue = effectFn() 
		cb(newValue, oldValue) 
		oldValue = newValue 
	} 

	const effectFn = effect( 
		// 执行 getter 
		() => getter(), 
		{ 
			lazy: true, 
			scheduler: () => { 
				// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
				if (options.flush === 'post') { 
					const p = Promise.resolve() 
					p.then(job) 
				} else { 
					job() 
				} 
			} 
		} 
	) 

	if (options.immediate) { 
		job() 
	} else { 
		oldValue = effectFn() 
	} 
}

  如以上代码所示,我们修改了调度器函数 scheduler 的实现方式,在调度器函数内检测 options.flush 的值是否为 post,如果是,则将 job 函数放到微任务队列中,从而实现异步延迟执行;否则直接执行 job 函数,这本质上相当于 ‘sync’ 的实现机制,即同步执行。对于 options.flush 的值为 ‘pre’ 的情况,我们暂时还没有办法模拟,因为这涉及组件的更新时机,其中 ‘pre’ 和 ‘post’ 原本的语义指的就是组件更新前和更新后,不过这并不影响我们理解如何控制回调函数的更新时机。

九、过期的副作用

  竞态问题通常在多进程或多线程编程中被提及,前端工程师可能很少讨论它,但在日常工作中你可能早就遇到过与竞态问题相似的场景,举个例子:

let finalData 

watch(obj, async () => {
	// 发送并等待网络请求
	const res = await fetch('/path/to/request') 
	// 将请求结果赋值给 data 
	finalData = res 
})

  在这段代码中,我们使用 watch 观测 obj 对象的变化,每次 obj 对象发生变化都会发送网络请求,例如请求接口数据,等数据请求成功之后,将结果赋值给 finalData 变量。
  观察上面的代码,乍一看似乎没什么问题。但仔细思考会发现这段代码会发生竞态问题。假设我们第一次修改 obj 对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求 A。随着时间的推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都在进行中,那么哪一个请求会先返回结果呢?我们不确定,如果请求 B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请求的结果,如下图所示。
在这里插入图片描述
  但由于请求 B 是后发送的,因此我们认为请求 B 返回的数据才是“最新”的,而请求 A 则应该被视为“过期”的,所以我们希望变量 finalData 存储的值应该是由请求 B 返回的结果,而非请求 A 返回的结果。
  实际上,我们可以对这个问题做进一步总结。请求 A 是副作用函数第一次执行所产生的副作用,请求 B 是副作用函数第二次执行所产生的副作用。由于请求 B 后发生,所以请求 B 的结果应该被视为“最新”的,而请求 A 已经“过期”了,其产生的结果应被视为无效。通过这种方式,就可以避免竞态问题导致的错误结果。
  归根结底,我们需要的是一个让副作用过期的手段。为了让问题更加清晰,我们先拿 Vue.js 中的 watch 函数来复现场景,看看 Vue.js 是如何帮助开发者解决这个问题的,然后尝试实现这个功能。
  在 Vue.js 中,watch 函数的回调函数接收第三个参数 onInvalidate,它是一个函数,类似于事件监听器,我们可以使用 onInvalidate 函数注册一个回调,这个回调函数会在当前副作用函数过期时执行:

watch(obj, async (newValue, oldValue, onInvalidate) => { 
	// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
	let expired = false
	// 调用 onInvalidate() 函数注册一个过期回调
	onInvalidate(() => { 
		// 当过期时,将 expired 设置为 true 
		expired = true
	}) 

	// 发送网络请求
	const res = await fetch('/path/to/request') 

	// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
	if (!expired) { 
		finalData = res 
	} 
})

  如上面的代码所示,在发送请求之前,我们定义了 expired 标志变量,用来标识当前副作用函数的执行是否过期;接着调用 onInvalidate 函数注册了一个过期回调,当该副作用函数的执行过期时将 expired 标志变量设置为 true;最后只有当没有过期时才采用请求结果,这样就可以有效地避免上述问题了。
  那么 Vue.js 是怎么做到的呢?换句话说, onInvalidate 的原理是什么呢?其实很简单,在 watch 内部每次检测到变更后,在副作用函数重新执行之前,会先调用我们通过 onInvalidate 函数注册的过期回调,仅此而已,如以下代码所示:

function watch(source, cb, options = {}) { 
	let getter 
	if (typeof source === 'function') { 
		getter = source 
	} else { 
		getter = () => traverse(source) 
	} 

	let oldValue, newValue 

	// cleanup 用来存储用户注册的过期回调
	let cleanup 
	// 定义 onInvalidate 函数
	function onInvalidate(fn) { 
		// 将过期回调存储到 cleanup 中
		cleanup = fn 
	} 

	const job = () => { 
		newValue = effectFn() 
		// 在调用回调函数 cb 之前,先调用过期回调
		if (cleanup) { 
			cleanup() 
		} 
		// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
		cb(newValue, oldValue, onInvalidate) 
		oldValue = newValue 
	} 

	const effectFn = effect( 
		// 执行 getter 
		() => getter(), 
		{ 
			lazy: true, 
			scheduler: () => { 
				if (options.flush === 'post') { 
					const p = Promise.resolve() 
					p.then(job) 
				} else { 
					job() 
				} 
			} 
		} 
	) 

	if (options.immediate) { 
		job() 
	} else { 
		oldValue = effectFn() 
	} 
} 

  在这段代码中,我们首先定义了 cleanup 变量,这个变量用来存储用户通过 onInvalidate 函数注册的过期回调。可以看到 onInvalidate 函数的实现非常简单,只是把过期回调赋值给了 cleanup 变量。这里的关键点在 job 函数内,每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数 cleanup。最后我们把 onInvalidate 函数作为回调函数的第三个参数传递给 cb,以便用户使用。
  我们还是通过一个例子来进一步说明:

watch(obj, async (newValue, oldValue, onInvalidate) => { 
	let expired = false
	onInvalidate(() => { 
		expired = true
	}) 

	const res = await fetch('/path/to/request') 

	if (!expired) { 
		finalData = res 
	} 
}) 

// 第一次修改
obj.foo++ 
setTimeout(() => { 
	// 200ms 后做第二次修改
	obj.foo++ 
}, 200)

  如以上代码所示,我们修改了两次 obj.foo 的值,第一次修改是立即执行的,这会导致 watch 的回调函数执行。由于我们在回调函数内调用了 onInvalidate,所以会注册一个过期回调,接着发送请求 A。假设请求 A 需要 1000ms 才能返回结果,而我们在 200ms 时第二次修改了 obj.foo 的值,这又会导致 watch 的回调函数执行。这时要注意的是,在我们的实现中,每次执行回调函数之前要先检查过期回调是否存在,如果存在,会优先执行过期回调。由于在 watch 的回调函数第一次执行的时候,我们已经注册了一个过期回调,所以在 watch 的回调函数第二次执行之前,会优先执行之前注册的过期回调,这会使得第一次执行的副作用函数内闭包的变量 expired 的值变为 true,即副作用函数的执行过期了。于是等请求 A 的结果返回时,其结果会被抛弃,从而避免了过期的副作用函数带来的影响,如下图所示。
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值