侦听器是 Vue.js 提供的一个 API(侦听某个响应式数据,数据变化时通知执行某个回调函数),应用广泛。Vue.js 2.x 已在组件对象中提供 watch 选项,Vue.js 3.0 仍支持沿用 2.x 写法,也可单独使用侦听器 API。
既然开发时常用,它内部如何实现?与 computed 差异在哪?
侦听器 API:watch 和 watchEffect
Vue.js 3.0 提供 watch 和 watchEffect 两个 API。
watch 的使用:
const count = ref(0)
// 侦听 ref 数据
watch(count, (count, prevCount) => {
// 回调函数
})watch 第一个参数接收 ref 对象 count,第二个参数接收回调函数,当 count 值变化时执行回调函数。
watch 还可接收 getter 函数:
const state = reactive({ count: 0 })
// 侦听 reactive 对象
watch(() => state.count, (count, prevCount) => {
// 回调函数
})getter 函数返回 state.count,当 state.count 变化也执行回调函数。
get 操作读取响应式数据 state.count 时进行依赖收集,对应数据变化触发派发通知,执行 watch 回调函数。
watch 还能侦听多个来源:
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
// 回调函数
})第一个参数传入数组,侦听 fooRef 和 barRef 两个 ref 的变化。
watchEffect 的使用:
const count = ref(0)
// 侦听依赖变化
watchEffect(() => console.log(count.value))
// 输出 0
count.value++
// 输出 1watchEffect 接受一个函数,并立即执行;函数内部访问响应式数据 count.value 会进行依赖收集,然后 count.value 变化触发依赖派发,再次执行该函数。
watchEffect 与 watch 区别:watchEffect 不需要指定侦听的数据源,组件初始化时执行一次该函数,并响应式侦听函数内部访问的响应式数据,在响应式数据变化后重新执行;watch 则必须指定监听的数据源。
二者不同点决定了使用场景:watch 适用于依赖项变化时执行异步/开销较大的操作;watchEffect 适用于简单逻辑,如状态变化后更新 DOM、日志输出等。
下面分析侦听器内部实现。
注:Vue.js 3.0 的 watch API 本质上是对 doWatch 函数的封装。
watch API 实现
watch API 内部通过 doWatch 实现。先看核心参数:source(侦听的数据源)、cb(回调函数)、options(配置对象)。
function watch(source, cb, options) {
// 标准化 source 和 cb
// ...代码在后面详细解释
return doWatch(source, cb, options)
}
function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) {
// 先解释主要流程逻辑,暂时忽略开发环境的处理
// 省略不影响逻辑的开发和兼容性代码
// 保存包装回调函数
let getter
// source 是 ref 对象
if (isRef(source)) {
getter = () => source.value
}
// source 是 reactive 对象
else if (isReactive(source)) {
getter = () => source
// 默认深度侦听
deep = true
}
// source 是函数
else if (isFunction(source)) {
if (cb) {
getter = () => source()
}
// source 作为 getter,不需要 cb
else {
getter = () => {
// 组件销毁时不运行
if (instance && instance.isUnmounted) {
return
}
// cleanup 函数
if (cleanup) {
cleanup()
}
// 执行 source 函数,并传入 onInvalidate 函数,返回 source 运行结果
return source(onInvalidate)
}
}
}
else {
// 其它情况
getter = () => {
// 如果遍历 source 有变化,则进行监听
if (instance && instance.isUnmounted) {
return
}
// 如果 source 是数组,则遍历 source 依次执行 watch
if (isArray(source)) {
return source.map(s => {
// 单个 source 用 watch 处理,如果有 cb 则第三个参数为空,否则传入 onInvalidate
return watch(s, cb)
})
}
// 数组和对象外的其它不合法数据
else {
(process.env.NODE_ENV !== 'production') && warnInvalidSource(source)
}
}
}
// 定义 job 和 scheduler 函数
// 省略
// 创建副作用函数
const effect = new ReactiveEffect(getter, scheduler)
// 省略
return () => {
// 停止侦听
effect.stop()
// 卸载组件时停止侦听
if (instance) {
remove(instance.effects, effect)
}
}
}doWatch 内部逻辑复杂,省略不影响理解主干的开发和兼容性代码。核心逻辑主要做四件事:标准化 source、new ReactiveEffect 创建副作用函数、组件卸载时停止侦听、返回 stop 句柄停止侦听。
标准化 source:根据 source 类型不同,赋值给 getter 函数。computed 与 watch 的相似处在于都内部创建 ReactiveEffect,watch 的核心逻辑也建立在 ReactiveEffect 基础上。
创建副作用函数:对 getter 创建 ReactiveEffect。传入的第二个参数是调度函数 scheduler,先略过,后面详细分析。注意 new ReactiveEffect 而非上一课分析的 effect 函数,二者区别是 effect 会判断 lazy 配置非 lazy 则立即执行一次,而 new ReactiveEffect 不会立即执行。
组件卸载时停止侦听:doWatch 末尾返回一个 stop 句柄用于停止侦听,其中组件卸载时也需要停止侦听,否则组件销毁后 watch 的回调函数仍在执行会内存泄漏。
返回 stop 句柄:编写 watch 时该句柄通常用于组件卸载时手动停止侦听(如 onUnmounted(() => stop()))。
new ReactiveEffect 的第二个参数 scheduler 是核心:
// 定义 job 和 scheduler 函数
// 组件卸载时停止侦听
// const effect = new ReactiveEffect(getter, scheduler)
// job 是侦听的回调函数
const job = () => {
// 组件销毁时不运行
if (!(instance && instance.isUnmounted)) {
// 重新运行 getter
const newValue = effect.run()
// 派发更新
if (hasChanged(newValue, oldValue) ||
(
// 数组或对象需要深度遍历比较
deep && isMultiSource)) {
// 数据变化时的回调
// 注意回调函数调用前先执行 cleanup 清理函数
callWithAsyncErrorHandling(cb, instance, 3 /* WATCH_CALLBACK */, [
newValue,
// 第一次调用时旧值是 undefined
oldValue === EMPTY_OBJ ? undefined : oldValue,
onInvalidate
])
// 更新旧值
oldValue = newValue
}
}
}
// scheduler 调度器的作用是根据配置决定副作用函数执行的时机
const scheduler = () => {
// 组件销毁时不运行
if (!(instance && instance.isUnmounted)) {
// 非同步执行,将数据变化放入队列,等所有数据变化完成之后执行
if (flush === 'sync') {
job()
}
else if (flush === 'pre') {
// 如果组件已经被挂载,则放入更新队列,等组件更新前执行 job 函数
if (instance && instance.isMounted) {
queueJob(job)
}
// 组件未挂载则直接执行 job 函数
else {
job()
}
}
// post 默认会放入一个异步队列,等组件挂载更新之后执行 job
else {
queuePostRenderEffect(job, instance && instance.suspense)
}
}
}job 和 scheduler 的逻辑较复杂,核心是:getter 收集依赖,响应式数据变化时 effect 派发通知执行 scheduler,scheduler 内部根据配置 flush 决定 job 执行时机——job 才真正执行用户传入的 watch 回调函数(即 cb)。
注意 job 在调用 cb 前用 callWithAsyncErrorHandling 包装,便于捕获 cb 内部异常。watch 配置还有 immediate 和 deep,下面分析。
immediate 配置
immediate 配置表示组件初始化时是否立即执行一次 watch 的 cb 回调函数。默认不会立即执行。
const count = ref(0)
// 默认不立即执行,第一次会输出 undefined
watch(count, (count, prevCount) => {
// 回调函数
}, {
immediate: true
})immediate 默认 false,watch 在 new ReactiveEffect 后不立即执行。分析 doWatch 中 immediate 逻辑:
// 创建副作用函数
const effect = new ReactiveEffect(getter, scheduler)
// 首次执行 job 不传入 cb,第二次执行才传入,因此首次执行 oldValue 是空对象
if (cb) {
if (immediate) {
job()
}
else {
// 首次执行 job 不传入 cb,第二次执行才传入,因此首次执行 oldValue 是空对象
oldValue = effect.run()
}
}
// 没有 cb 则立即执行
else {
effect.run()
}判断是否 immediate,是则立即执行 job,job 内部会调用 cb 且 oldValue 为 EMPTY_OBJ,因此 cb 参数中旧值为 undefined。若非 immediate,则把 getter 函数返回结果作为 oldValue(执行一次 effect.run 收集依赖)。
deep 配置
deep 配置表示是否深度侦听 source 的变化。除 ref 和 getter 形式的 source,其它 source 默认开启深度侦听。
const state = reactive({ count: { num: 0 } })
watch(() => state.count, (count, prevCount) => {
// 回调函数
}, {
deep: true
})
state.count.num = 1doWatch 内部根据 source 类型判断 deep 默认值:
// source 是 ref 对象
if (isRef(source)) {
getter = () => source.value
}
// source 是 reactive 对象
else if (isReactive(source)) {
getter = () => source
// 默认深度侦听
deep = true
}reactive 对象默认设置 deep 为 true。getter 是 () => source,访问 source 仅访问 state 对象本身,state.count 和 state.count.num 变化不会触发 getter,因此需深度遍历 state 访问其属性收集依赖。
doWatch 中 deep 逻辑:
// 深度侦听,递归访问每个属性,触发依赖收集
if (deep) {
const baseGetter = getter
// 新的 getter 内部会深度遍历整个对象,访问每个属性
getter = () => traverse(baseGetter())
}
// 遍历对象,触发依赖收集
function traverse(value, seen = new Set()) {
// 非对象或者已经在当前递归中访问过,直接返回
if (!isObject(value) || seen.has(value)) {
return value
}
// 记录已访问对象
seen.add(value)
// 访问对象每个属性触发依赖收集
for (const key in value) {
traverse((value)[key], seen)
}
return value
}deep 为 true 时通过 traverse 递归遍历整个对象,触发每个属性的 getter 进行依赖收集,实现的深度侦听相比 Vue.js 2.x 更简单(Vue.js 2.x 利用子响应式对象的 deep watcher 递归收集),也更高效(Vue.js 3.0 用 seen 集合记录已访问对象,避免循环引用死循环)。
侦听器与 computed 的区别
computed 与 watch 实现建立在同一个 ReactiveEffect 类上,二者相同点是:都用 getter 函数创建 ReactiveEffect;getter 内部访问响应式数据做依赖收集,数据变化时执行调度函数。
核心是调度函数的区别:computed 的调度函数把 dirty 设为 true 并通知其依赖(通常是组件副作用渲染函数)更新;watch 的调度函数根据 flush 配置决定 job 执行时机,job 内部比较新旧值变化后执行用户 cb 回调。
computed 与 watch 的相同点是都基于
ReactiveEffect做依赖收集;不同点是调度策略:computed 惰性求值 + 通知渲染,watch 比较新旧值 + 执行回调。
侦听器的使用场景
watch 函数的使用场景
watch 函数适用于需要在数据变化后执行异步或开销较大操作的场景。
const count = ref(0)
watch(count, async (count, prevCount) => {
const data = await fetchData(count)
// 等待数据获取完成,处理数据
})count 变化时发起异步请求获取数据,watch 自动处理,无需手动组件初始化时调用 fetchData。
也可侦听多个数据源:
// 侦听多个数据源
watch([fooRef, barRef], async ([foo, bar], [prevFoo, prevBar]) => {
const data = await fetchData(foo, bar)
// 处理数据
})侦听多个数据源变化,同时发起请求并在回调中处理数据,更加灵活。
watchEffect 函数的使用场景
watchEffect 函数适用于依赖变化后进行简单操作的场景,例如更新 DOM、输出日志等。
watchEffect(() => {
// 依赖变化后执行操作
document.title = `count is ${count.value}`
})watchEffect 会在组件初始化时立即执行一次,监听 count.value 变化,自动更新 document.title,无需手动组件初始化时设置。
也可用于日志输出:
watchEffect(() => {
// 依赖变化后输出日志
console.log(count.value)
})watchEffect 会自动监听 count.value 并输出日志,便于调试状态变化。
总结
本文介绍了侦听器 API(watch / watchEffect)及其实现原理。watch 和 watchEffect 都基于 ReactiveEffect 实现,内部通过 getter 访问响应式数据做依赖收集,在数据变化时通过调度函数触发不同的响应逻辑:watch 比较新旧值后执行用户回调,watchEffect 立即运行副作用函数。
watch 适合在数据变化后执行异步或开销较大的操作,watchEffect 适合执行轻量的副作用。watch 还支持 immediate、deep、flush 等配置,控制首次是否执行、是否深度侦听以及回调执行时机。
本文的相关代码在源代码中的位置如下: packages/runtime-core/src/apiWatch.ts