JohnieXu's Blog

Back

thumbnail.jpgthumbnail.jpg

Vue3 watchEffect 用法与实现原理#

在 Vue 3 的 Composition API 中,watchEffect 是一个很容易上手、但内部机制非常值得细读的 API。它的使用体验像是“把副作用函数丢给 Vue,Vue 自动知道它依赖了哪些响应式数据”,但这句话背后其实串起了 Vue 3 响应式系统里几个核心部件:

  • ReactiveEffect:把一段函数包装成可追踪、可停止、可调度的响应式副作用。
  • track / trigger:在读取时收集依赖,在写入时触发依赖。
  • scheduler:控制副作用重新执行的时机,避免同步多次变更导致重复执行。
  • cleanup:在副作用失效前清理上一次副作用留下的异步请求、定时器或订阅。

本文基于 vuejs/core ↗ 的当前 main 分支,围绕 packages/runtime-core/src/apiWatch.ts、packages/reactivity/src/watch.ts、packages/reactivity/src/effect.ts、packages/reactivity/src/dep.ts 等源码,由浅入深分析 watchEffect 的用法和实现原理,并横向对比 React、Solid、Svelte 等框架里的类似能力。

1. 先从使用场景看 watchEffect#

watchEffect 的类型大致如下:

function watchEffect(
  effect: (onCleanup: (cleanupFn: () => void) => void) => void,
  options?: {
    flush?: 'pre' | 'post' | 'sync'
    onTrack?: (event: DebuggerEvent) => void
    onTrigger?: (event: DebuggerEvent) => void
  }
): WatchHandle
ts

它会立即执行传入的函数,并在函数同步执行期间自动收集读到的响应式依赖;这些依赖后续发生变化时,函数会再次执行。

import { ref, watchEffect } from 'vue'

const count = ref(0)

const stop = watchEffect(() => {
  console.log('count changed:', count.value)
})

count.value++

stop()
ts

这段代码里没有显式告诉 Vue “我要监听 count”,但 console.log 读取了 count.value,所以 count 会被自动记录为依赖。

1.1 适合用 watchEffect 的场景#

watchEffect 更适合“副作用依赖和副作用逻辑天然写在一起”的场景:

const todoId = ref(1)
const todo = ref(null)

watchEffect(async (onCleanup) => {
  const controller = new AbortController()

  onCleanup(() => {
    controller.abort()
  })

  const res = await fetch(`/api/todos/${todoId.value}`, {
    signal: controller.signal
  })
  todo.value = await res.json()
})
ts

相比下面的 watch,watchEffect 少维护了一份 source:

watch(
  todoId,
  async (id, _oldId, onCleanup) => {
    const controller = new AbortController()

    onCleanup(() => {
      controller.abort()
    })

    const res = await fetch(`/api/todos/${id}`, {
      signal: controller.signal
    })
    todo.value = await res.json()
  },
  { immediate: true }
)
ts

当副作用内部会读取多个响应式字段,或者读取的是深层对象中的少量字段时,watchEffect 通常比 deep: true 更经济:它只追踪实际读到的属性,而不是递归遍历整个对象。

1.2 不适合用 watchEffect 的场景#

watchEffect 的代价是依赖不够显式,所以以下场景更适合 watch:

  • 需要精确控制“哪个 source 变化才触发副作用”。
  • 需要拿到 newValue 和 oldValue。
  • 副作用里会读取一些“不应该触发重新执行”的响应式状态。
  • 依赖关系复杂到需要让读者一眼看出触发来源。

可以简单记:

API依赖来源首次执行是否能拿旧值适合场景
watchEffect自动收集同步读取立即执行否自动请求、同步外部系统、调试依赖
watch显式 source默认懒执行是精准监听、比较新旧值、控制触发条件

2. 两个容易踩坑的语义#

2.1 只追踪同步阶段读到的依赖#

Vue 官方文档特别强调:watchEffect 使用异步回调时,只会追踪第一个 await 之前同步读到的依赖。

watchEffect(async () => {
  console.log(id.value) // 会被追踪

  await fetch('/api/profile')

  console.log(token.value) // 不会被这次 watchEffect 自动追踪
})
ts

原因后面看源码会很清楚:Vue 只能在 ReactiveEffect.run() 执行期间设置“当前正在收集依赖的 effect”。一旦同步调用栈结束,当前 effect 会被恢复,await 后面的代码已经不在这次依赖收集上下文里。

2.2 cleanup 要在副作用失效前执行#

watchEffect 的 onCleanup 用来处理“上一次副作用已经过期”的情况:

watchEffect((onCleanup) => {
  const timer = window.setInterval(() => {
    console.log(count.value)
  }, 1000)

  onCleanup(() => {
    window.clearInterval(timer)
  })
})
ts

当依赖变化导致 effect 重新执行,或者手动 stop() 时,Vue 会先执行上一次注册的 cleanup,再运行下一轮 effect。Vue 3.5+ 还提供了 onWatcherCleanup,可以在 watch / watchEffect 同步执行阶段直接注册清理函数。

3. 从入口看:watchEffect 只是 doWatch 的特殊分支#

watchEffect 的运行时入口在 packages/runtime-core/src/apiWatch.ts:

export function watchEffect(effect: WatchEffect, options?: WatchEffectOptions): WatchHandle {
  return doWatch(effect, null, options)
}
ts

这里最关键的是第二个参数传了 null。同一个 doWatch 同时承载了 watch 和 watchEffect:

  • watch(source, cb, options):有 cb,source 和副作用分离。
  • watchEffect(effect, options):没有 cb,传入的函数本身就是副作用。

doWatch 会处理运行时层面的能力,比如组件实例、错误处理、SSR、调度时机:

const runsImmediately = (cb && immediate) || (!cb && flush !== 'post')

if (flush === 'post') {
  baseWatchOptions.scheduler = (job) => {
    queuePostRenderEffect(job, instance && instance.suspense)
  }
} else if (flush !== 'sync') {
  baseWatchOptions.scheduler = (job, isFirstRun) => {
    if (isFirstRun) {
      job()
    } else {
      queueJob(job)
    }
  }
}

const watchHandle = baseWatch(source, cb, baseWatchOptions)
ts

默认 flush 是 'pre',所以 watchEffect 会立即执行第一次;依赖变化后的重新执行会进入调度队列。flush: 'post' 会放到组件渲染之后,flush: 'sync' 则不走异步队列,依赖一变就同步执行。

4. reactivity 层:把函数变成 ReactiveEffect#

doWatch 最终调用的是 @vue/reactivity 包里的 watch。当它发现 source 是函数且没有 cb 时,会进入 watchEffect 分支:

if (isFunction(source)) {
  if (cb) {
    getter = call ? () => call(source, WatchErrorCodes.WATCH_GETTER) : source
  } else {
    getter = () => {
      if (cleanup) {
        pauseTracking()
        try {
          cleanup()
        } finally {
          resetTracking()
        }
      }

      const currentEffect = activeWatcher
      activeWatcher = effect
      try {
        return call
          ? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup])
          : source(boundCleanup)
      } finally {
        activeWatcher = currentEffect
      }
    }
  }
}
ts

这一段做了三件事:

  1. 每次重新运行 effect 前,先执行上一次注册的 cleanup。
  2. 临时设置 activeWatcher = effect,让 onWatcherCleanup 知道当前属于哪个 watcher。
  3. 执行用户传入的 source(boundCleanup),也就是我们的 watchEffect 回调。

接着 Vue 创建真正的响应式副作用对象:

effect = new ReactiveEffect(getter)

effect.scheduler = scheduler ? () => scheduler(job, false) : job

boundCleanup = (fn) => onWatcherCleanup(fn, false, effect)
ts

可以把 watchEffect 理解成:

用 ReactiveEffect 包装用户函数;执行时自动收集依赖;依赖触发时,不直接运行用户函数,而是交给 scheduler 决定何时运行。

5. 自动依赖收集:为什么读 count.value 就会被记录#

以 ref 为例,count.value 的 getter 会调用 dep.track():

get value() {
  this.dep.track()
  return this._value
}
ts

reactive 对象也类似,Proxy 的 get 拦截里会调用 track(target, TrackOpTypes.GET, key)。

真正的依赖关系存储在 packages/reactivity/src/dep.ts。Vue 3 当前实现使用 WeakMap -> Map -> Dep 找到某个对象某个 key 对应的依赖集合:

export const targetMap: WeakMap<object, KeyToDepMap> = new WeakMap()

export function track(target: object, type: TrackOpTypes, key: unknown): void {
  if (shouldTrack && activeSub) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }

    let dep = depsMap.get(key)
    if (!dep) {
      depsMap.set(key, (dep = new Dep()))
    }

    dep.track()
  }
}
ts

这里的 activeSub 就是当前正在执行的 ReactiveEffect。而 activeSub 是在 ReactiveEffect.run() 中设置的:

run(): T {
  this.flags |= EffectFlags.RUNNING
  cleanupEffect(this)
  prepareDeps(this)

  const prevEffect = activeSub
  const prevShouldTrack = shouldTrack
  activeSub = this
  shouldTrack = true

  try {
    return this.fn()
  } finally {
    cleanupDeps(this)
    activeSub = prevEffect
    shouldTrack = prevShouldTrack
    this.flags &= ~EffectFlags.RUNNING
  }
}
ts

因此 watchEffect(() => count.value) 的完整链路是:

  1. watchEffect 创建 ReactiveEffect(getter)。
  2. 初次运行 effect.run()。
  3. ReactiveEffect.run() 把 activeSub 设置为当前 effect。
  4. 用户函数读取 count.value。
  5. ref.value getter 调用 dep.track()。
  6. dep.track() 发现当前有 activeSub,于是把 count.value 对应的 Dep 和当前 effect 建立订阅关系。
  7. 执行结束后恢复上一个 activeSub,清理本轮没再用到的旧依赖。

这就是“自动依赖收集”的本质:不是静态分析代码,也不是编译器猜测变量,而是在运行时通过 getter/Proxy 捕获真实读取行为。

6. 触发更新:写入 count.value 后发生了什么#

ref.value 的 setter 会在值变化后调用 dep.trigger():

set value(newValue) {
  if (hasChanged(newValue, oldValue)) {
    this._rawValue = newValue
    this._value = toReactive(newValue)
    this.dep.trigger()
  }
}
ts

reactive 对象的 set、deleteProperty、集合操作也会走到 trigger(...)。触发时,Vue 会找到这个 key 关联的 Dep,再通知其中订阅的 effect:

trigger(debugInfo?: DebuggerEventExtraInfo): void {
  this.version++
  globalVersion++
  this.notify(debugInfo)
}

notify(debugInfo?: DebuggerEventExtraInfo): void {
  startBatch()
  try {
    for (let link = this.subs; link; link = link.prevSub) {
      link.sub.notify()
    }
  } finally {
    endBatch()
  }
}
ts

ReactiveEffect.notify() 不会立刻执行用户函数,而是先进入批处理:

notify(): void {
  if (this.flags & EffectFlags.RUNNING && !(this.flags & EffectFlags.ALLOW_RECURSE)) {
    return
  }

  if (!(this.flags & EffectFlags.NOTIFIED)) {
    batch(this)
  }
}
ts

批处理结束后,ReactiveEffect.trigger() 再根据是否有 scheduler 决定执行方式:

trigger(): void {
  if (this.flags & EffectFlags.PAUSED) {
    pausedQueueEffects.add(this)
  } else if (this.scheduler) {
    this.scheduler()
  } else {
    this.runIfDirty()
  }
}
ts

这解释了两个现象:

  • 同一个 tick 内多次同步修改同一个依赖,默认不会导致 watchEffect 重复无意义地执行很多次。
  • flush: 'sync' 绕过调度队列,适合极少数需要同步失效的缓存场景,但不适合监听会被高频同步修改的数据结构。

7. flush 时机:pre、post、sync 的真正区别#

watchEffect 的 flush 不是“是否执行”,而是“依赖变化后排到哪里执行”。

7.1 默认 pre:组件更新前#

默认模式会在第一次运行时直接执行:

baseWatchOptions.scheduler = (job, isFirstRun) => {
  if (isFirstRun) {
    job()
  } else {
    queueJob(job)
  }
}
ts

随后重新执行会进入 queueJob。doWatch 还会给 job 标记 PRE,并把组件实例 uid 设置为 job id:

if (isPre) {
  job.flags! |= SchedulerJobFlags.PRE
  if (instance) {
    job.id = instance.uid
    job.i = instance
  }
}
ts

调度器会按 id 排序,保证父子组件和 pre watcher 的执行顺序稳定。默认 watcher 会在所属组件 DOM 更新前运行,所以不适合在里面读取更新后的 DOM。

7.2 post:组件更新后#

如果需要访问更新后的 DOM,应使用:

watchEffect(
  () => {
    console.log(el.value?.offsetHeight)
  },
  { flush: 'post' }
)
ts

或者语义更明确的别名:

watchPostEffect(() => {
  console.log(el.value?.offsetHeight)
})
ts

源码里 flush: 'post' 会使用 queuePostRenderEffect,它会在渲染完成后执行。

7.3 sync:同步触发#

watchSyncEffect 等价于 watchEffect(..., { flush: 'sync' })。它不进入 queueJob,依赖一变就同步触发:

watchSyncEffect(() => {
  cache.invalidate(key.value)
})
ts

这个模式要谨慎使用。如果同步 push 一千次数组,普通 watcher 可以被批处理合并,sync watcher 则会更容易出现性能问题和中间状态问题。

8. cleanup、pause、resume、stop 如何工作#

watchEffect 返回的是一个可调用的 handle:

const handle = watchEffect(() => {})

handle.pause()
handle.resume()
handle.stop()
handle() // 等价于 stop()
ts

在 @vue/reactivity 的 watch 实现中:

const watchHandle: WatchHandle = () => {
  effect.stop()
  if (scope && scope.active) {
    remove(scope.effects, effect)
  }
}

watchHandle.pause = effect.pause.bind(effect)
watchHandle.resume = effect.resume.bind(effect)
watchHandle.stop = watchHandle
ts

stop() 会断开当前 effect 和所有 dep 的订阅关系,并执行 onStop。watchEffect 注册的 cleanup 最终挂在 effect.onStop 上:

cleanup = effect.onStop = () => {
  const cleanups = cleanupMap.get(effect)
  if (cleanups) {
    for (const cleanup of cleanups) cleanup()
    cleanupMap.delete(effect)
  }
}
ts

pause() / resume() 则通过 EffectFlags.PAUSED 控制触发行为:暂停期间依赖变化不会立即执行;恢复时如果暂停期间发生过触发,会补跑一次。

9. 和 computed 的区别#

watchEffect 和 computed 都基于 ReactiveEffect,也都会自动收集依赖,但目的完全不同:

能力computedwatchEffect
目标产生可缓存的派生值执行副作用
是否应该有副作用不应该本来就是副作用
返回值一个 ref-like 结果停止/暂停/恢复 handle
执行策略懒计算,读取 .value 时刷新创建后立即执行
依赖变化后标记 dirty,必要时重新计算重新执行用户副作用

如果一段逻辑是“根据 A、B 算出 C”,优先用 computed;如果是“当 A、B 变化时请求接口、写日志、同步 DOM、订阅外部资源”,才考虑 watchEffect。

10. 横向对比其他框架#

10.1 React useEffect:依赖数组是显式契约#

React 的 useEffect 依赖数组是显式声明:

useEffect(() => {
  document.title = `${count}`
}, [count])
tsx

React 不会在运行时追踪 effect 里读了哪些状态。依赖数组写少了会产生闭包陈旧问题,写多了可能导致不必要执行,所以 React 社区需要 eslint-plugin-react-hooks 帮助检查依赖。

Vue 的 watchEffect 则更像运行时依赖追踪:读取了什么,就订阅什么。好处是少写依赖数组,坏处是依赖隐藏在函数体内部,复杂副作用的触发来源不如 watch 或 React 依赖数组直观。

10.2 Solid createEffect:同样自动追踪,但粒度更细#

Solid 的 createEffect 和 Vue 的 watchEffect 在心智模型上更接近:

createEffect(() => {
  console.log(count())
})
ts

它也通过运行时读取 signal 来自动建立依赖。不过 Solid 的渲染模型本身就是细粒度响应式,组件函数不是像 React 那样反复整体执行;Vue 则把细粒度响应式和组件级渲染 effect 结合起来,通过 scheduler 协调 watcher、组件更新和 post flush callback。

10.3 Svelte $effect:编译器参与更多#

Svelte 5 的 $effect 也可以写出类似自动依赖的副作用:

$effect(() => {
  console.log(count)
})
ts

Svelte 的特色是编译器参与度更高,会把组件代码编译成更直接的更新逻辑。Vue 3 则主要依靠运行时 Proxy/ref 追踪,在无需编译器特殊语法的情况下,让普通 JavaScript 访问也能参与响应式。

11. 用一段伪代码总结 watchEffect#

把细节压缩后,watchEffect 大致可以看成:

function watchEffect(userEffect, options) {
  let cleanup

  const getter = () => {
    if (cleanup) cleanup()

    userEffect((fn) => {
      cleanup = fn
    })
  }

  const effect = new ReactiveEffect(getter)

  effect.scheduler = createScheduler(options.flush)

  effect.run()

  return () => effect.stop()
}
ts

ReactiveEffect.run() 内部再做:

function run() {
  activeSub = currentEffect
  try {
    getter()
  } finally {
    activeSub = previousEffect
  }
}
ts

响应式读取时:

function track(target, key) {
  if (activeSub) {
    deps[target][key].add(activeSub)
  }
}
ts

响应式写入时:

function trigger(target, key) {
  for (const effect of deps[target][key]) {
    effect.scheduler ? effect.scheduler() : effect.run()
  }
}
ts

真实 Vue 源码比这复杂得多:它要处理嵌套 effect、依赖清理、批处理、组件更新顺序、递归保护、调试钩子、SSR、effect scope、暂停恢复等。但核心闭环始终是这四步:

运行 effect -> 读取时 track -> 写入时 trigger -> scheduler 决定何时重新运行 effect。

12. 实战建议#

最后给出一些使用建议:

  1. 优先保持副作用小而清晰:watchEffect 里读取的响应式数据越多,触发来源越隐蔽。
  2. 异步请求一定考虑 cleanup:避免旧请求晚返回后覆盖新状态。
  3. 需要旧值就用 watch:不要在 watchEffect 里手写额外变量模拟 oldValue,可读性通常更差。
  4. 读 DOM 用 post:需要访问组件更新后的 DOM 时使用 flush: 'post' 或 watchPostEffect。
  5. 谨慎使用 sync:只有缓存失效这类非常明确的同步场景才考虑 watchSyncEffect。
  6. 复杂场景用 onTrack/onTrigger 调试:它们可以告诉你 effect 追踪了什么、被什么触发。

结语#

watchEffect 的 API 很简单,但它正好站在 Vue 3 响应式系统和运行时调度系统的交界处。理解它,可以顺着一条很清晰的线索看懂 Vue 3 的响应式核心:ReactiveEffect 负责描述“谁依赖谁”,track / trigger 负责建立和触发依赖关系,scheduler 负责把重新执行安排在正确的时机,cleanup 负责让副作用具备失效语义。

当你只是想“依赖什么就自动重新做什么”时,watchEffect 很顺手;当你需要更强的边界、更明确的触发条件和新旧值时,watch 仍然是更合适的工具。理解二者背后的实现差异,才能在业务代码里做出更稳定的选择。

Vue3 watchEffect 用法与实现原理
https://johniexu.github.io/blog/vue3-watch-effect-principles
Author JohnieXu
Published at April 28, 2026