在vue3中提供了watch
API可以用来侦听一个getter函数、一个ref函数生成的实例对象或者是一个响应式对象等数据。在被侦听的数据发生变化的时候触发回调函数,回调函数接收新值和旧值作为参数,而且回调函数是懒执行的。
这次通过实现一个简易的watch
函数来帮助我们更深刻的理解watch
的监听能力和懒执行能力背后的逻辑。
scheduler调度系统
在vue关于computed
和watch
的代码中都存在这样一个概念:scheduler
调度器。作为一个完整的scheduler
调度系统包括两个部分的实现:
lazy
:懒执行。computed
懒执行传入的getter函数;watch
懒执行的是传入的在数据变化时触发的回调函数scheduler
:调度器。computed
中的scheduler
执行的是触发ComputedRefImpl
实例对象的依赖,也就是调用了computedObj.value
的effect
函数;
懒执行
ts
// effect的额外参数
export interface ReactiveEffectOptions {
lazy?: boolean
scheduler?: EffectScheduler
}
// effect函数基于传入的执行函数生成一个ReactiveEffect实例,目的是分开存储逻辑和执行操作
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
// 生成 ReactiveEffect实例
const _effect = new ReactiveEffect(fn)
// 如果存在 options.lazy === true 的情况下就不会立即执行一次
if (!options || !options.lazy) {
_effect.run()
}
}
懒执行相对比较简单,只需要增加一个lazy
参数用于判断是否立即执行run
函数。
scheduler调度器
调度器的实现比较复杂,主要分为两部分作用:
- 控制执行顺序
- 控制执行规则
控制执行顺序
html
<script>
const { reactive, effect } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(() => {
console.log(obj.count);
})
obj.count = 2
console.log('代码结束');
</script>
正常情况下打印顺序应该是 1 2 代码结束
,现在如果我们希望修改触发依赖的逻辑来改变打印顺序为 1 代码结束 2
应该怎么改呢?答案是可以利用触发依赖来运行替代函数scheduler
,因为effect函数在声明时会触发run
函数,而在obj.count
触发set value
函数时会触发依赖triggerEffect
,如果依赖对象存在scheduler
方法则运行调度器而非run
函数。
html
effect(
() => {
console.log(obj.count);
},
{
scheduler() {
setTimeout(() => {
console.log(obj.count)
})
}
}
)
给effect
传递额外的参数设置scheduler
调度器。
ts
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
// 生成 ReactiveEffect 实例
const _effect = new ReactiveEffect(fn)
+ // 存在 options,则合并配置对象
+ if (options) {
+ Object.assign(_effect, options)
+ }
if (!options || !options.lazy) {
// 执行 run 函数
_effect.run()
}
}
在effect
中增加合并参数对象到ReactiveEffect
实例对象中的步骤。
到这里再尝试打印一遍,结果已经变成 1 打印结束 2
了。
控制执行规则
html
<script>
const { reactive, effect, queuePreFlushCb } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(() => {
console.log(obj.count);
}, {
scheduler() {
queuePreFlushCb(() => console.log(obj.count))
}
})
obj.count = 2
obj.count = 3
console.log('同步代码结束');
</script>
如果我们希望控制触发打印时机为所有同步代码结束后,打印结果为 1 代码结束 3 3
,那么使用异步执行就是理所应当的答案了。大致思路是将本来应该执行的函数在同步执行的阶段先收集起来,然后将依次执行函数的函数立即添加到微队列(事件循环的概念)中。
ts
// 对应promise的状态
let isFlushPending = false
// promise.resolve()的快捷方式
const resolvedPromise = Promise.resolve() as Promise<any>
// 当前执行的任务
let currentFlushPromise: Promise<void>
// 待执行的任务队列:收集容器
const pendingPreFlushCbs: Function[] = []
// 队列预处理函数:暴露出去的接收回调函数的函数
export function queuePreFlushCb(cb: Function) {
queueFlushCb(cb, pendingPreFlushCbs)
}
// 队列处理函数:收集回调函数
function queueFlushCb(cb: Function, pendingQueue: Function[]) {
// 将所有的回调函数放入任务队列中
pendingQueue.push(cb)
queueFlush()
}
// 依次处理队列中的执行函数
function queueFlush() {
if (!isFlushPending) {
// 开始等待函数执行
isFlushPending = true
// 本质上是将 flushJobs 函数立即放入到微队列
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
// 处理队列:同步代码运行完毕运行的函数
function flushJobs() {
// 等待完毕,函数准备开始执行
isFlushPending = false
flushPreFlushCbs()
}
// 依次处理队列中的任务
function flushPreFlushCbs() {
if (pendingPreFlushCbs.length) {
// 将pending状态转换为active
let activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
// 清空pending的队列
pendingPreFlushCbs.length = 0
// 循环执行active队列
for (let i = 0; i < activePreFlushCbs.length; i++) {
activePreFlushCbs[i]()
}
}
}
此时再运行实例代码,打印成功,顺序也没问题。说明成功将收集到的scheduler
调度器函数放入到了微队列中并在同步代码的最后赋值完成后触发,于是打印了两次3。
初步实现watch侦听器
ts
// watch 配置项属性
export interface watchOptions<Immediate = boolean> {
immedaite?: Immediate
deep?: boolean
}
// watch函数
export function watch(source, cb: Function, options?: watchOptions) {
return doWatch(source as any, cb, options)
}
function doWatch(
source,
cb: Function,
{ immedaite, deep }: watchOptions = EMPTY_OBJ
) {
// 第一部分:收集依赖
let getter: any
// 判断source是否为reactive实例对象
if (isReactive(source)) {
getter = () => source
// reactive只能传入对象,deep默认为true
deep = true
} else {
getter = () => {}
}
if (cb && deep) {
// 收集依赖:在传入的reactive实例对象中递归进行get操作
const baseGetter = getter
getter = () => traverse(baseGetter())
}
// 第二部分:构建effect
let oldValue = {}
// job执行方法:获取当前值并执行回调
const job = () => {
if (cb) {
const newValue = effect.run()
if (deep || hasChange(newValue, oldValue)) {
cb(newValue, oldValue)
oldValue = newValue
}
}
}
// 调度器
let scheduler = () => queuePreFlushCb(job)
// 依赖触发的两种方式:run触发getter,triggerEffect触发scheduler
const effect = new ReactiveEffect(getter, scheduler)
// 第三部分:同步代码获取第一次执行的oldValue
if (cb) {
if (immedaite) {
job()
} else {
oldValue = effect.run()
}
} else {
effect.run()
}
return () => {
effect.stop()
}
}
// 递归地访问value中的属性
export function traverse(value: unknown) {
// 当当前访问的属性不是函数后结束递归
if (!isObject(value)) {
return value
}
for (const key in value as object) {
traverse((value as any)[key])
}
return value
}
这一段有点长,但是从功能上区分主要可以分成三个部分:
- 收集依赖:先判断传入的参数得是
reactive
生成的proxy
对象,核心在于traverse
函数,它递归地遍历对象中所有的键值,相当于进行一次get value
。 - 构建
effect
:这里是最巧妙的地方,根据getter
和job
构建了一个effect
依赖,当effect.run()
时调用getter
函数既可以获取到当前最新值,同时也触发了get value
,这样就将当前的effect
收集到了,然后在对应的属性发生set value
的操作时,就会触发effect.scheduler()
,也就是将job
函数立即添加到微队列中,在所有同步代码执行完毕后依次执行收集到的所有job
,job
的作用就是获取当前值赋值给newValue,newValue赋值给oldValue,同时执行一次回调函数。 - 判断
immediate
状态,需不需要立即执行一次job()。
总结
watch
本质上还是依赖于ReactiveEffect
的实现的一个收集依赖 和触发依赖 的过程,区别在于此时的收集依赖是被动完成 的,通过run
和scheduler
我们得以区分使用哪种方式来更新值以及需要在触发依赖后运行的调度器,同时完成调度功能也使用到了微队列的概念帮助我们控制调度执行的时机。