Vue3源码学习——侦听器Watch
前言
上一节我们在看nextTick
相关的源码时,发现 Vue3 关于任务执行顺序方面是有特定的一套逻辑的:
- 首先,更新那些带有
pre
属性的job
。 - 然后,执行那些普通的
job
。 - 最后,执行
flushPostFlushCbs
函数去处理后置任务队列中的job
。
这里要是感觉有点遗忘的话,可以再回顾一下《nextTick源码》这篇文章。
我们可以发现 Vue3 首先会执行带有pre
属性的任务,那到底什么样的任务会被赋上pre
属性呢?这里就要引出我们今天要去探讨的内容——侦听器watch。
watch
我们先来看一下官网介绍watch的基本用法:
const x = ref(0)
const y = ref(0)
// 单个 ref
watch(x, (newX) => {
console.log(`x is ${newX}`)
})
// getter 函数
watch(
() => x.value + y.value,
(sum) => {
console.log(`sum of x + y is: ${sum}`)
}
)
// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
console.log(`x is ${newX} and y is ${newY}`)
})
watch名为侦听器,在组合式 API 中,我们可以使用 watch
函数在每次响应式状态发生变化时触发回调函数。
根据上面的例子,我们可以发现watch 函数
可以侦听的类型还是挺多的,可以是:
- 单个ref
- getter函数
- 多个来源组成的数组
那么,它的内部逻辑是如何处理多类型参数的呢?我们看一下watch
的源码:
function watch(source, cb, options) {
// 如果传入的cb不是函数则给出警告
if (!isFunction(cb)) {
warn(xxx)
}
return doWatch(source, cb, options);
}
watch函数
的主体逻辑都放在doWatch函数里,doWatch函数比较长,我们一点一点分析:
getter
首先,会创建一个getter
的变量,同时会分情况进行赋值:
function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
...
// 获取当前实例
const instance = currentInstance
let getter: () => any
let forceTrigger = false
// source是否为数组
let isMultiSource = false
if (isRef(source)) {
// source是ref类型:
getter = () => source.value
forceTrigger = isShallow(source)
} else if (isReactive(source)) {
// source是reactive类型:
getter = () => source
deep = true
} else if (isArray(source)) {
// 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)) {
// source是函数类型:
if (cb) {
// 如果存在回调函数,getter赋值为执行source的函数
getter = () => callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
} else {
// 如果watch中并未传入cb,执行的内容即为watchEffect逻辑
getter = () => {
if (instance && instance.isUnmounted) {
return
}
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup]
)
}
}
} else {
// 将getter赋值为一个返回空对象的函数 () => {},同时给出警告
getter = NOOP
__DEV__ && warnInvalidSource(source)
}
...
}
这里我们可以看到,doWatch函数首先做的就是根据source
的不同类型,定义不同的getter
:
- ref类型:
getter = () => source.value
- reactive类型:
getter = () => source
- array类型:
getter = () => source.map(...)
- function类型(只考虑有cb的情况):
getter = () => callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
- 其他:
getter = () => {}
尽管getter的形式各异,但是它们有一个共同的特点都是一个函数,是可以被执行的。
我们可以回想一下,在之前聊到响应式的时候,通常我们说的getter
,都是用来进行依赖收集的,那这里的getter
和那边的getter
是一样的作用吗?也是用于依赖收集的吗?
这里先剧透一下,doWatch函数这里的getter在后面会被包装为一个副作用函数effect,并会在相应的时机去执行effect.run方法,也就触发了getter函数的执行,完成副作用函数的收集。
我们接着往下看:
function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
...
// 根据source不同定义不同的getter
...
// 如果是深度监听,则在getter中会去执行traverse方法
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
...
}
这里涉及到了深度监听,如果deep属性为true的话,则会将getter重新赋值为一个新的函数,这个函数通过调用traverse函数,从而实现深度访问的效果。
traverse
traverse函数的作用是,通过递归调用自身去深度访问接收到的变量,从而完成深度依赖的收集
function traverse(value, seen) {
if (!isObject(value) || value["__v_skip" /* SKIP */]) {
return value;
}
seen = seen || /* @__PURE__ */ new Set();
// 如果已经访问过,则直接返回
if (seen.has(value)) {
return value;
}
// 将value进行缓存
seen.add(value);
// 根据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) => {
traverse(v, seen);
});
} else if (isPlainObject(value)) {
for (const key in value) {
traverse(value[key], seen);
}
}
return value;
}
主要逻辑
回到doWatch函数:
var INITIAL_WATCHER_VALUE = {}
function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
...
// 定义旧值变量,初始变量都为undefined
let oldValue = isMultiSource ? new Array(source.length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE;
const job = () => {
if (!effect.active) {
return;
}
if (cb) {
// 如果传入watch函数有回调函数cb,则通过执行effect.run方法,拿到执行后的结果
const newValue = effect.run();
// 传入的source含有响应式或有深度监听deep配置
if (deep || forceTrigger || (isMultiSource ? newValue.some(
(v, i) => hasChanged(v, oldValue[i])
) : hasChanged(newValue, oldValue)) || false) {
...
// 执行cb函数,同时会将newValue和oldValue作为参数传递给回调函数
callWithAsyncErrorHandling(cb, instance, 3 /* WATCH_CALLBACK */, [
newValue,
// 初始旧值赋值为undefined
oldValue === INITIAL_WATCHER_VALUE ? void 0 : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue,
onCleanup
]);
oldValue = newValue;
}
} else {
// 如果没有cb函数,则将watch当做watchEffect直接执行source函数
effect.run();
}
};
job.allowRecurse = !!cb;
let scheduler;
// flush是watch函数的第三个参数options中的其中一个属性,用于配置回调函数的触发时机
if (flush === "sync") {
scheduler = job;
} else if (flush === "post") {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense);
} else {
// 默认会为job赋上pre属性,从而使得watch中的回调函数会处于任务队列的前列,排在DOM更新函数之前
job.pre = true;
if (instance)
job.id = instance.uid;
scheduler = () => queueJob(job);
}
// 将getter包装成一个effect副作用函数,而副作用函数的执行顺序则由上面的scheduler决定
const effect = new ReactiveEffect(getter, scheduler);
if (cb) {
// 如果是立即执行
if (immediate) {
job();
} else {
// 不是立即执行,则执行run 函数,获取旧值
oldValue = effect.run();
}
} else {
// watchEffect相关的逻辑
...
}
...
}
这一块逻辑属于doWatch函数的主体逻辑了。
根据前面,我们知道doWatch函数已经处理好了getter,这一环节的主要工作就是:
- 将watch的回调函数cb根据配置项flush的不同,将其维护进任务队列的不同位置,同时,将getter处理为副作用函数effect,并执行,从而完成副作用函数的收集。
- 因此,当我们的依赖项发生变化的时候,就会触发副作用函数的执行,从而会触发传入watch的cb函数执行,并将doWatch函数一开始维护的getter对应的oldValue,newValue(也就是监听对象的新老值)作为cb的参数。
这里watch函数中的配置项flush取值可以是:
- pre,默认,侦听器将在组件渲染之前执行。
- post,侦听器在组件渲染后执行,也就是回调函数能够访问到更新后的DOM。
- sync,在某些特殊情况下 (例如要使缓存失效),可能有必要在响应式依赖发生改变时立即触发侦听器,也就是同步执行了。
unwatch
最后,还剩下关于取消监听部分的逻辑,我们善始善终,继续往下看:
function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
...
const unwatch = () => {
effect.stop()
if (instance && instance.scope) {
remove(instance.scope.effects!, effect)
}
}
return unwatch
}
doWatch函数的最后部分工作就比较简单了,定义一个取消监听的函数,通过执行effect.stop方法移除收集到的对应的副作用函数,并将unwatch方法暴露出去,这样我们就可以通过在外界调用从而取消监听了。
举个例子:
let state = reactive({
a: 1
})
let cancel = watch(state, (newVal) => {
console.log(newVal.a)
})
setTimeout(() => {
cancel()
console.log('cancel了')
}, 3000)
function increment() {
state.a++
}
我们在一开始,每次调用increment方法
,控制台都会打印改变后的state.a
的值,但是当3秒之后,调用了cancel
方法,则取消了watch侦听器,所以再调用increment方法
,state.a
仍然会进行加一的操作,但控制台不再会打印state.a
的值了。
总结
到这里,doWatch函数的整体逻辑基本上我们就过了一遍,整体在做的事情就是:
- 将watch函数传入的第一个变量source封装为格式一致的getter函数。
- 根据配置项flush的不同,确定调度程序scheduler的值。
- 将
getter函数
结合scheduler
包装为一个副作用函数effect。 - 定义job函数,主要功能为执行effect函数的run方法,这里执行run方法就会执行我们前面步骤维护的getter,从而能够完成依赖收集
- 当监听的内容发生改变时,将getter获取到的值赋值给newValue,然后去执行watch的第二个参数,也就是监听器的回调函数cb,并将旧值oldValue和新值newValue作为cb的参数。
- 最后,返回一个取消监听的方法unwatch,可供用户调用,取消监听。
参考文章:
转载自:https://juejin.cn/post/7244802134905749541