{T}

侦听器是 Vue.js 提供的一个 API(侦听某个响应式数据,数据变化时通知执行某个回调函数),应用广泛。Vue.js 2.x 已在组件对象中提供 watch 选项,Vue.js 3.0 仍支持沿用 2.x 写法,也可单独使用侦听器 API。

既然开发时常用,它内部如何实现?与 computed 差异在哪?

侦听器 API:watch 和 watchEffect

Vue.js 3.0 提供 watchwatchEffect 两个 API。

watch 的使用:

js
const count = ref(0)
// 侦听 ref 数据
watch(count, (count, prevCount) => {
  // 回调函数
})

watch 第一个参数接收 ref 对象 count,第二个参数接收回调函数,当 count 值变化时执行回调函数。

watch 还可接收 getter 函数:

js
const state = reactive({ count: 0 })
// 侦听 reactive 对象
watch(() => state.count, (count, prevCount) => {
  // 回调函数
})

getter 函数返回 state.count,当 state.count 变化也执行回调函数。

get 操作读取响应式数据 state.count 时进行依赖收集,对应数据变化触发派发通知,执行 watch 回调函数。

watch 还能侦听多个来源:

js
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
  // 回调函数
})

第一个参数传入数组,侦听 fooRef 和 barRef 两个 ref 的变化。

watchEffect 的使用:

js
const count = ref(0)
// 侦听依赖变化
watchEffect(() => console.log(count.value))
// 输出 0
count.value++
// 输出 1

watchEffect 接受一个函数,并立即执行;函数内部访问响应式数据 count.value 会进行依赖收集,然后 count.value 变化触发依赖派发,再次执行该函数。

watchEffectwatch 区别:watchEffect 不需要指定侦听的数据源,组件初始化时执行一次该函数,并响应式侦听函数内部访问的响应式数据,在响应式数据变化后重新执行;watch 则必须指定监听的数据源。

二者不同点决定了使用场景:watch 适用于依赖项变化时执行异步/开销较大的操作;watchEffect 适用于简单逻辑,如状态变化后更新 DOM、日志输出等。

下面分析侦听器内部实现。

注:Vue.js 3.0 的 watch API 本质上是对 doWatch 函数的封装。

watch API 实现

watch API 内部通过 doWatch 实现。先看核心参数:source(侦听的数据源)、cb(回调函数)、options(配置对象)。

js
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 的相似处在于都内部创建 ReactiveEffectwatch 的核心逻辑也建立在 ReactiveEffect 基础上。

创建副作用函数:对 getter 创建 ReactiveEffect。传入的第二个参数是调度函数 scheduler,先略过,后面详细分析。注意 new ReactiveEffect 而非上一课分析的 effect 函数,二者区别是 effect 会判断 lazy 配置非 lazy 则立即执行一次,而 new ReactiveEffect 不会立即执行。

组件卸载时停止侦听doWatch 末尾返回一个 stop 句柄用于停止侦听,其中组件卸载时也需要停止侦听,否则组件销毁后 watch 的回调函数仍在执行会内存泄漏。

返回 stop 句柄:编写 watch 时该句柄通常用于组件卸载时手动停止侦听(如 onUnmounted(() => stop()))。

new ReactiveEffect 的第二个参数 scheduler 是核心:

js
// 定义 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 配置还有 immediatedeep,下面分析。

immediate 配置

immediate 配置表示组件初始化时是否立即执行一次 watch 的 cb 回调函数。默认不会立即执行。

js
const count = ref(0)
// 默认不立即执行,第一次会输出 undefined
watch(count, (count, prevCount) => {
  // 回调函数
}, {
  immediate: true
})

immediate 默认 false,watchnew ReactiveEffect 后不立即执行。分析 doWatch 中 immediate 逻辑:

js
// 创建副作用函数
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 默认开启深度侦听。

js
const state = reactive({ count: { num: 0 } })
watch(() => state.count, (count, prevCount) => {
  // 回调函数
}, {
  deep: true
})
state.count.num = 1

doWatch 内部根据 source 类型判断 deep 默认值:

js
// 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.countstate.count.num 变化不会触发 getter,因此需深度遍历 state 访问其属性收集依赖。

doWatch 中 deep 逻辑:

js
// 深度侦听,递归访问每个属性,触发依赖收集
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 函数适用于需要在数据变化后执行异步或开销较大操作的场景。

js
const count = ref(0)
watch(count, async (count, prevCount) => {
  const data = await fetchData(count)
  // 等待数据获取完成,处理数据
})

count 变化时发起异步请求获取数据,watch 自动处理,无需手动组件初始化时调用 fetchData。

也可侦听多个数据源:

js
// 侦听多个数据源
watch([fooRef, barRef], async ([foo, bar], [prevFoo, prevBar]) => {
  const data = await fetchData(foo, bar)
  // 处理数据
})

侦听多个数据源变化,同时发起请求并在回调中处理数据,更加灵活。

watchEffect 函数的使用场景

watchEffect 函数适用于依赖变化后进行简单操作的场景,例如更新 DOM、输出日志等。

js
watchEffect(() => {
  // 依赖变化后执行操作
  document.title = `count is ${count.value}`
})

watchEffect 会在组件初始化时立即执行一次,监听 count.value 变化,自动更新 document.title,无需手动组件初始化时设置。

也可用于日志输出:

js
watchEffect(() => {
  // 依赖变化后输出日志
  console.log(count.value)
})

watchEffect 会自动监听 count.value 并输出日志,便于调试状态变化。

总结

本文介绍了侦听器 API(watch / watchEffect)及其实现原理。watchwatchEffect 都基于 ReactiveEffect 实现,内部通过 getter 访问响应式数据做依赖收集,在数据变化时通过调度函数触发不同的响应逻辑:watch 比较新旧值后执行用户回调,watchEffect 立即运行副作用函数。

watch 适合在数据变化后执行异步或开销较大的操作,watchEffect 适合执行轻量的副作用。watch 还支持 immediatedeepflush 等配置,控制首次是否执行、是否深度侦听以及回调执行时机。

本文的相关代码在源代码中的位置如下: packages/runtime-core/src/apiWatch.ts