Vue 原理解析(九):搞懂 computed 和 watch 原理,减少使用场景思考时间
本文原文主要分析 Vue 2 的
Watcher、computed和watch。这条原理主线仍然有价值,但原文部分源码是 Vue 2.6 时代的简化代码,不能直接当作 Vue 2.7.16 或 Vue 3.5.39 的源码。本文按以下边界整理:
- Vue 2.7.16:重点分析
Watcher、Dep.target、队列、deep/immediate/sync和 computed watcher;- Vue 3.5.39:补充
ComputedRefImpl、ReactiveEffect、watchEffect、flush、数字深度和onWatcherCleanup;- 过时的
$set、$delete、$on、过滤器等 API 保留为版本迁移说明,不把它们写成 Vue 3 当前 API。
一、这一章要解决什么问题
在 Vue 应用中经常会遇到两个问题:
- 一个值能否根据其他响应式数据自动计算?
- 某个值改变后,如何执行请求、日志、缓存、动画或其他副作用?
通常可以这样区分:
- 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 中的 $watch 和 watch 选项
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”的理解可以保留。

1. initWatch 和 createWatcher
Vue 2.7.16 的 state.ts 中仍然可以看到 initWatch、createWatcher 等逻辑。下面是为了理解流程而整理的伪代码,不是逐字符复制的完整源码:
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.profile、profile.city 的 getter。复杂表达式如 a + b 不适合写成字符串路径,应直接传入函数:
this.$watch(
() => this.firstName + ' ' + this.lastName,
handler,
)
2. get:依赖收集
Vue 2 的响应式 getter 会读取 Dep.target。Watcher.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 的几个重要限制:
- 它是递归读取,不是深拷贝;
- 对象嵌套变化时
newValue和oldValue可能是同一引用; - 大对象开启 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
}
计算属性内部读取 firstName、lastName 时,会把 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()
},
}
它没有可追踪的响应式依赖,因此不会因为时间变化自动重新计算。需要定时更新时,应使用响应式计时器或普通方法。

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 直接作为响应式订阅者,结合 Dep、Link、依赖版本号和 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 的共同语义仍然是:
- 第一次读取时计算;
- 依赖未变化时返回缓存值;
- 依赖变化时标记需要刷新;
- 下次读取时重新求值;
- 读取 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 的 sync、immediate 不是一回事,版本敏感代码应标明要求 Vue 3.4+。
八、Vue 2 和 Vue 3 的 watcher 对比
| 项目 | Vue 2.7.16 | Vue 3.5.39 |
|---|---|---|
| 主要实现 | Watcher + Dep.target + scheduler | computed 使用 ComputedRefImpl/Dep/Link/版本机制;watch 使用 ReactiveEffect + scheduler |
| computed | lazy watcher、dirty、evaluate、depend | ComputedRefImpl、惰性刷新和版本机制 |
| watch source | 字符串路径、函数、对象、数组配置 | ref、getter、reactive、多个 source 数组 |
| 深度监听 | deep: true,递归 traverse | deep: true 或 3.5+ deep: number |
| 立即执行 | immediate | immediate |
| 调度 | 队列、sync 内部选项 | flush: pre/post/sync |
| 副作用自动追踪 | 没有 Vue 3 的 watchEffect | watchEffect、watchPostEffect、watchSyncEffect |
| 异步清理 | 手动保存取消函数 | onWatcherCleanup 或回调第三参数 |
九、数组监听的迁移提醒
Vue 2 中数组方法会被重写,直接监听数组时可以感知 push、pop、splice 等变化。
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:
| API | Vue 2.7 | Vue 3.5 |
|---|---|---|
this.$watch | 保留 | Options API 仍可用,Composition API 通常使用 watch |
this.$set / this.$delete | Vue 2 响应式新增/删除属性时使用 | 移除,Proxy 可直接追踪新增/删除 |
this.$on/$off/$once | 保留 | 移除,使用 emits 或第三方事件库 |
this.$emit | 保留 | 保留,Composition API 使用 emit |
this.$mount | Vue 2 入口 API | 使用 createApp().mount() |
this.$destroy | 保留 | 使用 app.unmount(),组件使用 unmount 生命周期 |
Vue.set/Vue.delete | 保留 | 移除 |
Vue.component | 全局注册 | 使用 app.component |
Vue.use | 插件安装 | 使用 app.use |
Vue.filter | Vue 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 的
watchEffect、flush、deep:number、once和onWatcherCleanup是现代代码需要掌握的 API; - computed 负责派生值,watch/watchEffect 负责副作用,二者职责不要混用。
官方参考
Vue 2
- Computed Properties and Watchers
vm.$watchAPI- Vue 2 响应式原理
- Vue 2.7.16
state.ts - Vue 2.7.16
watcher.ts - Vue 2.7.16
traverse.ts - Vue 2.7.16
scheduler.ts
Vue 3
- Computed Properties
- Watchers
- Reactivity Core API
- Vue 3.5.39
computed.ts - Vue 3.5.39
watch.ts - Vue 3.5.39
apiWatch.ts - Vue 3 Watch 数组迁移
原文出处
作者:飞跃疯人院
来源:稀土掘金
参考书籍:Vue.js 源码全方位深入解析、Vue.js 深入浅出
本文保留原文 Vue 2 computed/watch 原理分析,并补充 Vue 2.7.16 与 Vue 3.5.39 的实现边界和现代 API。