Vue watch 源码解析
原理综述
本文主要介绍watch和watchEffect两个API的实现原理,它们的原理可以简单地抽象为通过:
const effect = new ReactiveEffect(getter, scheduler)
来创建一个副作用,其中:
getter对于watch来说就是监听的数据源source对应的getter,对于watchEffect来说就是它所运行的副作用函数scheduler可以简单的理解为是watch所传入的回调,或是watchEffect所运行的副作用函数,对应的是当数据源更改时,所需要执行的操作
因此,在watch初始化时通过effect.run收集依赖,当watch和watchEffect的依赖更改时,这些依赖触发effect,从而调用effect.scheduler方法,执行watch的回调或重新执行watchEffect传入的副作用。
关于watch的卸载原理,简单来说就是:在创建组件时,会同时创建一个effectScope,所有通过ReactiveEffect创建的副作用都会在constructor中通过recordEffectScope被push到effectScope当中,而组件在卸载时会调用effectScope.stop,从而停止该副作用域里面的所有副作用,自然也包括watch和watchEffect,于是就实现了侦听器的自动停止。
源码分析
watch API
懒监听 + 深度监听
watch API总共接受三个参数:监听的数据源source、数据源更改后执行的回调函数cb以及选项options,其中options中最为常用的属性为immediate和deep,分别表示立即执行和深度监听,watch默认懒监听和深度监听。
watch API会通过调用doWatch函数对数据源进行监听,并返回unwatch函数,用来取消监听。
doWatch函数会首先初始化getter,getter会被用于进行依赖收集,对于数据源是不同的类型会做不同的处理:
- 如果数据源是
ref,那么则追踪source.value - 如果是响应式对象,则追踪该对象,并通过设置
deep=true强制深度追踪依赖 - 如果是数组,则其中每一项都是数据源,因此通过
map对于其中的每一项都进行对应的处理后得到getter - 如果是函数,根据是否有
cb判断是watch还是watchEffect,若有cb说明是watch,因此getter就是source(只是使用了callWithErrorHandling进行了错误处理);若没有cb说明是watchEffect,因此getter就是其对应的副作用函数(除此之外额外添加了一些内容,不是很重要)
if (isRef(source)) {
getter = () => source.value
forceTrigger = isShallow(source)
} else if (isReactive(source)) {
getter = () => source
deep = true
} else if (isArray(source)) {
isMultiSource = true
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
getter = () =>
source.map(s => {
if (isRef(s)) {
return s.value
} else if (isReactive(s)) {
return traverse(s)
} else if (isFunction(s)) {
return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
} else {
__DEV__ && warnInvalidSource(s)
}
})
} else if (isFunction(source)) {
if (cb) {
// getter with cb
getter = () =>
callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
} else {
// no cb -> simple effect
getter = () => {
if (instance && instance.isUnmounted) {
return
}
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup]
)
}
}
} else {
getter = NOOP
__DEV__ && warnInvalidSource(source)
}
在得到getter后,在deep=true的时候需要深度监听对象,因此需要得到深度遍历后的getter,所以调用traverse函数对getter中的对象进行深度遍历。traverse函数通过递归调用对对象进行深度访问,从而可以进行深度的依赖收集
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
// traverse 函数,代码太长不用看,就是用来对对象进行深度访问的,这样才能进行依赖收集
export function traverse(value: unknown, seen?: Set<unknown>) {
if (!isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value
}
seen = seen || new Set()
if (seen.has(value)) {
return value
}
seen.add(value)
if (isRef(value)) {
traverse(value.value, seen)
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, seen)
})
} else if (isPlainObject(value)) {
for (const key in value) {
traverse((value as any)[key], seen)
}
}
return value
}
得到getter之后,我们使用getter和scheduler得到effect,之后会调用effect.run(也就是调用getter函数),进行依赖的收集(scheduler放在这部分的最后讲)
const effect = new ReactiveEffect(getter, scheduler)
// initial run
if (cb) {
if (immediate) {
job()
} else {
oldValue = effect.run() // 懒监听 + 深度监听情况下,只有这行会被执行
}
} else if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense
)
} else {
effect.run()
}
最后返回unwatch函数用来取消这个watch,unwatch函数也很简单,就是调用副作用的stop方法,并且使用remove函数将这个副作用从所在实例(一般是组件实例)的作用域中移除,至此整体的逻辑完成
const unwatch = () => {
effect.stop()
if (instance && instance.scope) {
remove(instance.scope.effects!, effect)
}
}
return unwatch
scheduler的内容(也就是new ReactiveEffect的第二个参数)比较长,所以放在这一部分的最后来说。
默认情况下,flush==='pre',因此scheduler函数就是把job回调函数放在队列中,等待在渲染前的时间节点统一执行。所以当调用effect.scheduler的时候实际上只是调度了job而非执行
if (flush === 'sync') {
scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
// default: 'pre'
job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
}
那么,job函数的任务是什么呢?
其实代码虽多,最核心的工作就是通过调用callWithAsyncErrorHandling函数来调用cb,也就是watch的回调,并更新执行后的oldValue;对于watchEffect的情况,实际上只需要重新执行副作用,因此调用effect.run方法
那么为什么对于watch的情况需要做那么多的判断呢,这里设计者应该是有他的考量,他似乎不希望watch深度监听类似于ref({ count: 0 })的情况,这种情况下,只有ref本身的引用(也就是.value)发生改变才会导致回调触发,但是我也不清楚为什么要这样做
const job: SchedulerJob = () => {
if (!effect.active) {
return
}
if (cb) {
// watch(source, cb)
const newValue = effect.run()
if (
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) =>
hasChanged(v, (oldValue as any[])[i])
)
: hasChanged(newValue, oldValue)) ||
(__COMPAT__ &&
isArray(newValue) &&
isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
) {
// cleanup before running cb again
if (cleanup) {
cleanup()
}
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE
? undefined
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
? []
: oldValue,
onCleanup
])
oldValue = newValue
}
} else {
// watchEffect
effect.run()
}
}
立即监听
对于立即监听的情况,实际上是希望在创建watch时能够立即调用一次回调,那么我们只需要立即执行一次job函数即可
// initial run
if (cb) {
if (immediate) {
job() // 立即监听
} else {
oldValue = effect.run()
}
} else if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense
)
} else {
effect.run()
}
浅监听
如果需要浅监听而非深度监听,只需要不执行traverse的过程即可
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter()) // deep=false,不会执行深度依赖收集
}
watchEffect API
原理
其实原理,上面讲watch的时候已经带到了,这里简单总结他的过程,相比于watch简单很多:
- 创建
effect,getter就是watchEffect的副作用,scheduler就是把effect.run放进队列中等待调度,因此两者是相似的 - 创建后会立即通过
effect.run()收集依赖,同时也会执行副作用 - 依赖触发过程会调用
effect.scheduler,实际上就是effect.run,因此会重新执行副作用
和 effect 函数的对比
watchEffect这个API其实和Vue内置的effect函数功能很像,都是立即执行一个副作用函数,当依赖改变时副作用会重新执行。主要区别在于:
watchEffect可以通过设置flush控制副作用的触发时机,例如默认(flush: pre)是在组件渲染前执行,通过设置flush: post则可以推迟到组件渲染后;而effect函数则是在依赖更改后立即触发副作用,无法进行控制effect不是Vue暴露的API,不用于生产当中
值得注意的是,不同于watch,这里收集依赖并不是深度的,这一点和effect是相同的。
const obj = reactive({ count: 0 })
watchEffect(() => {
console.log(obj)
})
obj.count += 1 // 并不会触发 watchEffect