技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue 原理解析(九):搞懂 computed 和 watch 原理,减少使用场景思考时间

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/43-Vue原理解析(九):搞懂computed和watch原理,减少使用场景思考时间
本文目录14 个章节
  1. 一、这一章要解决什么问题
  2. 二、Vue 2 中的 $watch 和 watch 选项
  3. 三、Vue 2 Watcher 的核心字段
  4. 四、computed 的实现原理:Vue 2.7
  5. 五、Vue 3.5 的 computed:不再是 Vue 2 Watcher
  6. 六、watch 与 computed 的使用选择
  7. 七、Vue 3.5 的 watch、watchEffect 与清理
  8. 八、Vue 2 和 Vue 3 的 watcher 对比
  9. 九、数组监听的迁移提醒
  10. 十、Vue 2 相关 API 的版本边界
  11. 十一、面试题:computed 和 watch 分别适合什么场景
  12. 十二、总结
  13. 官方参考
  14. 原文出处

Vue 原理解析(九):搞懂 computed 和 watch 原理,减少使用场景思考时间

本文原文主要分析 Vue 2 的 Watchercomputedwatch。这条原理主线仍然有价值,但原文部分源码是 Vue 2.6 时代的简化代码,不能直接当作 Vue 2.7.16 或 Vue 3.5.39 的源码。

本文按以下边界整理:

  • Vue 2.7.16:重点分析 WatcherDep.target、队列、deep/immediate/sync 和 computed watcher;
  • Vue 3.5.39:补充 ComputedRefImplReactiveEffectwatchEffectflush、数字深度和 onWatcherCleanup
  • 过时的 $set$delete$on、过滤器等 API 保留为版本迁移说明,不把它们写成 Vue 3 当前 API。

一、这一章要解决什么问题

在 Vue 应用中经常会遇到两个问题:

  1. 一个值能否根据其他响应式数据自动计算?
  2. 某个值改变后,如何执行请求、日志、缓存、动画或其他副作用?

通常可以这样区分:

  • computed:从已有状态派生出另一个值,强调缓存和声明式表达;
  • watch:观察明确的数据变化,执行副作用,强调新值、旧值和清理;
  • watchEffect:自动收集同步执行期间访问的依赖,适合依赖来源较多的副作用;
  • method:每次调用都重新执行,不承担响应式缓存。

例如:

export default {
  data() {
    return {
      firstName: 'Ada',
      lastName: 'Lovelace',
    }
  },
  computed: {
    fullName() {
      return `${this.firstName} ${this.lastName}`
    },
  },
  watch: {
    fullName(newValue, oldValue) {
      console.log('姓名改变了', newValue, oldValue)
    },
  },
}

fullName 是派生值,适合 computed;如果姓名改变后需要请求服务端或写入 localStorage,才需要 watch。

二、Vue 2 中的 $watchwatch 选项

Vue 2 Options API 中的 watch 选项最终会创建 user watcher。下面是常见写法:

export default {
  data() {
    return {
      name: 'cc',
      profile: {
        city: 'London',
      },
    }
  },
  watch: {
    name(newName, oldName) {
      console.log(newName, oldName)
    },
    'profile.city': {
      handler(newCity, oldCity) {
        console.log(newCity, oldCity)
      },
      immediate: true,
    },
    profile: {
      handler(newProfile, oldProfile) {
        console.log(newProfile, oldProfile)
      },
      deep: true,
    },
  },
}

还可以手动调用 $watch

export default {
  data() {
    return {
      name: 'cc',
    }
  },
  created() {
    const unwatch = this.$watch(
      'name',
      (newName, oldName) => {
        console.log(newName, oldName)
      },
      { immediate: true },
    )

    // 需要时停止监听
    // unwatch()
  },
}

this.$watch() 返回一个取消监听函数。watch 选项是它的声明式封装,因此原文“watch 内部使用 $watch”的理解可以保留。

Vue 2 watch 原理流程图(本地化,历史资料)

1. initWatchcreateWatcher

Vue 2.7.16 的 state.ts 中仍然可以看到 initWatchcreateWatcher 等逻辑。下面是为了理解流程而整理的伪代码,不是逐字符复制的完整源码:

function initWatch(vm, watch) {
  for (const key in watch) {
    const handler = watch[key]

    if (Array.isArray(handler)) {
      handler.forEach(item => createWatcher(vm, key, item))
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

function createWatcher(vm, expOrFn, handler, options) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }

  if (typeof handler === 'string') {
    handler = vm[handler]
  }

  return vm.$watch(expOrFn, handler, options)
}

它支持:

export default {
  methods: {
    handleNameChange() {},
  },
  watch: {
    name: 'handleNameChange',
    age: [
      function onAgeChange() {},
      { handler: 'handleNameChange', immediate: true },
    ],
  },
}

实际项目中,回调函数应保持可读,复杂逻辑可以抽成方法或服务。

2. Vue 2.7 $watch 的核心流程

Vue 2.7.16 中 $watch 的概念流程可以简化为:

Vue.prototype.$watch = function (expOrFn, cb, options) {
  const vm = this

  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }

  options = options || {}
  options.user = true

  const watcher = new Watcher(vm, expOrFn, cb, options)

  if (options.immediate) {
    // 实际源码会使用错误处理工具调用回调,并保护依赖收集状态
    cb.call(vm, watcher.value)
  }

  return function unwatchFn() {
    watcher.teardown()
  }
}

上面的 cb.call 只是教学简化。Vue 2.7.16 实际通过 invokeWithErrorHandling 调用用户回调,并在 immediate 回调期间处理依赖收集边界。源码级文章不能把这段伪代码当作完整实现。

immediate 的语义是:watcher 创建并求出当前值后,立即执行一次回调。第一次调用没有可靠的旧值快照,通常只能把旧值看作 undefined。对于对象和数组的深层变化,新旧值也可能是同一个引用,不要把它们当作自动深拷贝。

三、Vue 2 Watcher 的核心字段

Vue 2.7.16 的 Watcher 同时服务于渲染、computed 和用户 watch。为了避免把 2.6 的旧字段误写成 2.7.16 精确源码,下面只保留与本文有关的概念字段:

class Watcher {
  constructor(vm, expOrFn, cb, options) {
    this.vm = vm
    this.cb = cb
    this.active = true

    if (options) {
      this.deep = Boolean(options.deep)
      this.user = Boolean(options.user)
      this.lazy = Boolean(options.lazy)
      this.sync = Boolean(options.sync)
    }

    this.dirty = this.lazy
    this.getter = typeof expOrFn === 'function'
      ? expOrFn
      : parsePath(expOrFn)

    // lazy watcher 第一次不立即求值
    this.value = this.lazy ? undefined : this.get()
  }
}

Vue 2.7.16 的 watcher 生命周期管理已经结合 effect scope;原文中直接写 vm._watchers.push(this) 是 Vue 2.6 时代的简化/旧实现,不能标注为 Vue 2.7.16 的逐行源码。

1. parsePath

$watch 的 source 是合法的点路径时,Vue 2 可以把它转换成一个读取路径的函数:

const bailRE = /[^\w.$]/

function parsePath(path) {
  if (bailRE.test(path)) return

  const segments = path.split('.')

  return function getPath(obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

因此:

this.$watch('profile.city', handler)

大致会转化成一个依次读取 vm.profileprofile.city 的 getter。复杂表达式如 a + b 不适合写成字符串路径,应直接传入函数:

this.$watch(
  () => this.firstName + ' ' + this.lastName,
  handler,
)

2. get:依赖收集

Vue 2 的响应式 getter 会读取 Dep.targetWatcher.get() 在求值前把自己放到 target 栈顶:

get() {
  pushTarget(this)

  let value
  try {
    value = this.getter.call(this.vm, this.vm)

    if (this.deep) {
      traverse(value)
    }
  } finally {
    popTarget()
    this.cleanupDeps()
  }

  return value
}

流程可以概括为:

pushTarget(userWatcher)
       ↓
读取 vm.info.name
       ↓
响应式 getter 找到 Dep.target
       ↓
Dep 收集 userWatcher
       ↓
popTarget,恢复上一个 watcher

原文强调“读取”这个动作是正确的:在 Vue 2 中,访问响应式属性的 getter 才会完成依赖收集。pushTarget() 只是维护当前正在求值的 watcher,不是直接把数据变成响应式。

3. deep 深度监听

如果 source 是对象,普通 watcher 读取对象本身并不会自动读取每一个嵌套属性。deep: true 会通过 traverse() 递归访问对象和数组,让嵌套属性的 getter 有机会收集当前 watcher:

const seenObjects = new Set()

function traverse(value) {
  _traverse(value, seenObjects)
  seenObjects.clear()
}

function _traverse(value, seen) {
  if (!isObject(value) || Object.isFrozen(value)) return

  const observer = value.__ob__
  if (observer) {
    if (seen.has(observer.dep.id)) return
    seen.add(observer.dep.id)
  }

  if (Array.isArray(value)) {
    value.forEach(item => _traverse(item, seen))
  } else {
    Object.keys(value).forEach(key => _traverse(value[key], seen))
  }
}

这是用于理解的简化伪代码。Vue 2.7.16 的实际 traverse 还处理冻结值、VNode、跳过标记和 ref 等边界。

deep 的几个重要限制:

  • 它是递归读取,不是深拷贝;
  • 对象嵌套变化时 newValueoldValue 可能是同一引用;
  • 大对象开启 deep 可能成本较高;
  • Vue 2 中新增对象属性仍需要预先声明或使用 Vue.set / this.$set
  • Vue 3 使用 Proxy 后不再需要 $set 来让新增属性获得响应式,但深度监听成本仍然存在。

4. update、队列和 run

当响应式依赖变化时,Vue 2 watcher 会收到通知。核心分支可以概括为:

update() {
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

原文中把 this.dirty 错写成了 this.diray,那会导致 computed 缓存无法正确失效,必须修正。

普通 user watcher 默认进入异步队列,Vue 会在一个 tick 内批量处理更新:

run() {
  if (!this.active) return

  const value = this.get()
  if (
    value !== this.value ||
    isObject(value) ||
    this.deep
  ) {
    const oldValue = this.value
    this.value = value

    if (this.user) {
      this.cb.call(this.vm, value, oldValue)
    }
  }
}

sync: true 在 Vue 2.7.16 源码中仍然存在,会绕过队列直接执行 run();但它没有作为普通 $watch 的稳定推荐选项写入官方 API 文档。它会失去批处理能力,日常代码不应随意开启。

四、computed 的实现原理:Vue 2.7

1. computed watcher 是 lazy watcher

Vue 2 的 computed 计算属性会创建一个带 lazy: true 的 watcher:

function initComputed(vm, computed) {
  const watchers = vm._computedWatchers = Object.create(null)

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function'
      ? userDef
      : userDef.get

    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      { lazy: true },
    )

    defineComputed(vm, key, userDef)
  }
}

为了聚焦原理,上面忽略了 Vue 2.7 源码中的 SSR、缓存配置、setter 警告和 property 冲突检查。关键是:computed watcher 创建时不会立即执行 getter。

2. 访问 computed 时才求值

function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers[key]

    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }

      if (Dep.target) {
        watcher.depend()
      }

      return watcher.value
    }
  }
}

evaluate() 会在第一次访问或缓存失效后求值:

Watcher.prototype.evaluate = function () {
  this.value = this.get()
  this.dirty = false
}

计算属性内部读取 firstNamelastName 时,会把 computed watcher 收集到这些属性的 Dep 中。随后,如果模板正在读取这个 computed,depend() 会让当前的 render watcher 也订阅这些底层依赖。

3. 依赖变化时只标记 dirty

update() {
  if (this.lazy) {
    this.dirty = true
  } else {
    queueWatcher(this)
  }
}

这就是 computed 缓存的关键:依赖变化时不会马上重新计算,而是将 dirty 设为 true。下一次访问 computed 时,getter 发现 dirty,才重新调用 evaluate()

完整的依赖关系可以简化为:

firstName Dep ─┐
               ├─ computed watcher ── render watcher
lastName Dep ──┘

如果 computed 只依赖非响应式值,例如:

computed: {
  now() {
    return Date.now()
  },
}

它没有可追踪的响应式依赖,因此不会因为时间变化自动重新计算。需要定时更新时,应使用响应式计时器或普通方法。

Vue 2 computed 依赖流程图(本地化,历史资料)

4. computed getter/setter

计算属性可以是函数,也可以是带 getter/setter 的对象:

export default {
  data() {
    return {
      firstName: 'Ada',
      lastName: 'Lovelace',
    }
  },
  computed: {
    fullName: {
      get() {
        return `${this.firstName} ${this.lastName}`
      },
      set(value) {
        const [first, last = ''] = value.split(' ')
        this.firstName = first
        this.lastName = last
      },
    },
  },
}

只有 getter 的 computed 默认是只读的。Vue 2 开发环境对写入只读 computed 会给出警告;需要写入时应显式提供 setter。

五、Vue 3.5 的 computed:不再是 Vue 2 Watcher

Vue 3 没有把 Vue 2 的 Watcher 原样搬过来。Vue 3.5.39 的 computed 由 ComputedRefImpl 直接作为响应式订阅者,结合 DepLink、依赖版本号和 refreshComputed() 实现惰性求值与缓存;ReactiveEffect 主要用于 watch/watchEffect 等副作用 effect,不应把它写成 computed 的主要实现类。

使用方式:

<script setup lang="ts">
import { computed, ref } from 'vue'

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

const editableName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: value => {
    const [first, last = ''] = value.split(' ')
    firstName.value = first
    lastName.value = last
  },
})
</script>

Vue 3 computed 的共同语义仍然是:

  1. 第一次读取时计算;
  2. 依赖未变化时返回缓存值;
  3. 依赖变化时标记需要刷新;
  4. 下次读取时重新求值;
  5. 读取 computed 的副作用会订阅它的依赖。

但源码中的字段、依赖链接和调度机制属于 Vue 3 的实现,不能继续使用“computed-watcher”描述为当前 Vue 3 源码。原理文章应明确:前面的 Watcher 章节是 Vue 2 版本实现,Vue 3 使用新的响应式核心。

六、watch 与 computed 的使用选择

适合 computed 的场景

  • 将多个状态组合为一个显示值;
  • 过滤、排序或格式化列表;
  • 需要缓存的同步派生值;
  • 需要双向转换时使用 getter/setter computed。
computed: {
  activeTodos() {
    return this.todos.filter(todo => !todo.done)
  },
}

适合 watch 的场景

  • source 变化后发起请求;
  • 写入 localStorage、埋点或日志;
  • 根据路由参数加载数据;
  • 监听变化后执行动画或第三方库同步;
  • 需要访问新值和旧值。

watch 回调不应被用来重新计算一个本可以由 computed 表达的纯值,否则容易产生额外状态和同步问题。

七、Vue 3.5 的 watch、watchEffect 与清理

1. watch:显式 source、惰性执行

<script setup lang="ts">
import {
  reactive,
  watch,
} from 'vue'

const state = reactive({
  profile: {
    name: 'Ada',
  },
})

watch(
  () => state.profile.name,
  (newName, oldName) => {
    console.log(newName, oldName)
  },
  {
    immediate: true,
    flush: 'pre',
  },
)
</script>

watch 支持的 source 包括:

watch(countRef, callback)
watch(() => props.id, callback)
watch(reactiveObject, callback)
watch([firstName, lastName], callback)

watch 默认是惰性的:source 改变后才调用回调。immediate: true 会在创建 watcher 后先执行一次。

2. watchEffect:自动追踪依赖

import { ref, watchEffect } from 'vue'

const id = ref(1)

watchEffect(() => {
  console.log('当前 id:', id.value)
})

watchEffect 会立即执行,并自动追踪同步执行期间访问的响应式依赖。它不直接提供传统意义上可靠的旧值,因此需要新旧值比较时应使用 watch

异步 effect 中,await 之后首次访问的响应式值不会被本次同步依赖收集;需要清理异步任务时,要在第一个 await 之前注册清理函数:

<script setup lang="ts">
import {
  onWatcherCleanup,
  ref,
  watchEffect,
} from 'vue'

const id = ref(1)
const result = ref<unknown>(null)

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

  // Vue 3.5:必须在同步阶段注册
  onWatcherCleanup(() => controller.abort())

  try {
    const response = await fetch(`/api/items/${id.value}`, {
      signal: controller.signal,
    })
    result.value = await response.json()
  } catch (error: any) {
    if (error?.name !== 'AbortError') {
      console.error(error)
    }
  }
})
</script>

也可以使用 watch 回调的第三个参数 onCleanup

watch(id, async (newId, oldId, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())

  const response = await fetch(`/api/items/${newId}`, {
    signal: controller.signal,
  })
  // ...
})

onWatcherCleanup 是 Vue 3.5 的 API,必须在 watcher 回调或 effect 的同步阶段调用;第三个参数形式更适合需要在异步流程中保持清理逻辑兼容的代码。

3. deep 和数字深度

import { reactive, watch } from 'vue'

const state = reactive({
  profile: {
    name: 'Ada',
    address: {
      city: 'London',
    },
  },
})

watch(
  () => state.profile,
  () => {
    console.log('profile 发生了受限深度内的变化')
  },
  { deep: 1 },
)

在 Vue 3.5 中,deep 可以是数字,表示遍历的最大深度:

  • deep: false:不主动深度遍历;
  • deep: true:无限深度遍历;
  • deep: 1:最多遍历一层。

deep 可能带来明显遍历成本,大对象应尽量监听具体 getter,而不是无条件深度监听整个对象。

4. flush 调度时机

watch(source, callback, { flush: 'pre' })
watch(source, callback, { flush: 'post' })
watch(source, callback, { flush: 'sync' })
  • pre:默认选项,在组件更新前执行;
  • post:在组件 DOM 更新后执行,需要读取更新后的 DOM 时使用;
  • sync:源变化时同步执行,不进行批处理,使用不当会产生性能问题。

Vue 2 的 sync 是 Watcher 内部选项;Vue 3 的 flush 是 watch API 的调度选项。两者不要混写成同一套源码机制。

5. once

Vue 3.4+ 的 watch 支持 once: true

watch(
  () => route.query.token,
  token => {
    consumeToken(token)
  },
  { once: true },
)

它与 Vue 2 的 syncimmediate 不是一回事,版本敏感代码应标明要求 Vue 3.4+。

八、Vue 2 和 Vue 3 的 watcher 对比

项目Vue 2.7.16Vue 3.5.39
主要实现Watcher + Dep.target + schedulercomputed 使用 ComputedRefImpl/Dep/Link/版本机制;watch 使用 ReactiveEffect + scheduler
computedlazy watcherdirtyevaluatedependComputedRefImpl、惰性刷新和版本机制
watch source字符串路径、函数、对象、数组配置ref、getter、reactive、多个 source 数组
深度监听deep: true,递归 traversedeep: true 或 3.5+ deep: number
立即执行immediateimmediate
调度队列、sync 内部选项flush: pre/post/sync
副作用自动追踪没有 Vue 3 的 watchEffectwatchEffectwatchPostEffectwatchSyncEffect
异步清理手动保存取消函数onWatcherCleanup 或回调第三参数

九、数组监听的迁移提醒

Vue 2 中数组方法会被重写,直接监听数组时可以感知 pushpopsplice 等变化。

Vue 3 中:

const items = ref([1, 2, 3])

watch(items, () => {
  // 默认主要关注 ref.value 被替换
})

如果要同时捕获数组替换和数组自身的一层变化,Vue 3.5+ 可以使用:

watch(items, callback, { deep: 1 })

如果还要深入监听数组元素中的嵌套对象,则使用 deep: true,但应评估遍历成本。参考:Vue 3 Watch 数组迁移说明

十、Vue 2 相关 API 的版本边界

原文开头列出的 API 可以作为 Vue 2 学习目录保留,但不能全部当作 Vue 3 当前 API:

APIVue 2.7Vue 3.5
this.$watch保留Options API 仍可用,Composition API 通常使用 watch
this.$set / this.$deleteVue 2 响应式新增/删除属性时使用移除,Proxy 可直接追踪新增/删除
this.$on/$off/$once保留移除,使用 emits 或第三方事件库
this.$emit保留保留,Composition API 使用 emit
this.$mountVue 2 入口 API使用 createApp().mount()
this.$destroy保留使用 app.unmount(),组件使用 unmount 生命周期
Vue.set/Vue.delete保留移除
Vue.component全局注册使用 app.component
Vue.use插件安装使用 app.use
Vue.filterVue 2 过滤器移除,使用方法或 computed
Vue.mixin保留全局 Vue.mixin 移除,使用 app.mixin(),并应谨慎使用
Vue.compile运行时编译相关不是普通构建的推荐方案

十一、面试题:computed 和 watch 分别适合什么场景

可以这样回答:

  • computed 用于根据一个或多个响应式数据同步计算派生值。它具有缓存,依赖不变化时重复访问不会重新执行 getter;getter 应尽量保持纯函数,不负责请求和其他副作用。
  • watch 用于观察一个明确的数据源,在变化后执行副作用,例如请求、日志、缓存、动画和第三方库同步。它可以拿到新值和旧值,并通过清理函数取消过期异步任务。
  • watchEffect 用于自动追踪 effect 中同步访问的依赖,适合依赖来源较多的副作用;需要精确控制 source 或比较新旧值时使用 watch。
  • 只需要每次调用都重新执行的逻辑,使用 method,不要为了缓存而滥用 computed。

十二、总结

原文最值得保留的原理主线是:

响应式数据被读取
      ↓
依赖收集到 watcher/effect
      ↓
数据变化触发调度
      ↓
computed 失效或 watch 回调执行

但实现需要按版本拆开:

  • Vue 2.7 的 computed 主要是 lazy Watcher,通过 dirty/evaluate/depend 实现缓存;
  • Vue 2.7 的 watch 是 user watcher,默认进入 scheduler 队列,deep 通过递归读取收集依赖;
  • Vue 3.5 不再使用 Vue 2 的 Watcher 源码,computed/watch 建立在新的响应式 effect 系统上;
  • Vue 3.5 的 watchEffectflushdeep:numberonceonWatcherCleanup 是现代代码需要掌握的 API;
  • computed 负责派生值,watch/watchEffect 负责副作用,二者职责不要混用。

官方参考

Vue 2

Vue 3

原文出处

作者:飞跃疯人院

来源:稀土掘金

参考书籍:Vue.js 源码全方位深入解析Vue.js 深入浅出

本文保留原文 Vue 2 computed/watch 原理分析,并补充 Vue 2.7.16 与 Vue 3.5.39 的实现边界和现代 API。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS