跳到主要内容

Vue computed 和 watch

Vue 提供了两个重要的 API 来处理响应式数据的变化:computed 用于创建计算属性,watch 用于监听数据变化并执行相应的操作。

computed()

computed() 用于创建计算属性,它基于响应式数据进行计算,并缓存结果。

基本用法

import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 创建计算属性
const fullName = computed(() => {
return firstName.value + lastName.value
})

console.log(fullName.value) // '张三'

// 修改依赖的数据
firstName.value = '李'
console.log(fullName.value) // '李三'

计算属性 vs 方法

计算属性和方法的主要区别在于缓存

import { ref, computed, Date } from 'vue'

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

// 计算属性 - 有缓存,只有依赖变化时才重新计算
const sum = computed(() => {
console.log('计算中...') // 只执行一次
return items.value.reduce((a, b) => a + b, 0)
})

// 方法 - 每次调用都会执行
function getSum() {
console.log('计算中...') // 每次调用都执行
return items.value.reduce((a, b) => a + b, 0)
}
<template>
<p>计算属性: {{ sum }}</p> <!-- 只计算一次 -->
<p>计算属性: {{ sum }}</p> <!-- 使用缓存 -->
<p>方法: {{ getSum() }}</p> <!-- 每次都重新计算 -->
<p>方法: {{ getSum() }}</p> <!-- 每次都重新计算 -->
</template>

可写的计算属性

计算属性默认是只读的,但可以通过 get 和 set 创建可写的计算属性:

import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

const fullName = computed({
get() {
return firstName.value + lastName.value
},
set(value) {
// 当设置 fullName 时,自动拆分到 firstName 和 lastName
firstName.value = value.slice(0, 1)
lastName.value = value.slice(1)
}
})

console.log(fullName.value) // '张三'

fullName.value = '李四'
console.log(firstName.value) // '李'
console.log(lastName.value) // '四'

实际示例:购物车

import { ref, computed } from 'vue'

const cart = ref([
{ name: '苹果', price: 5, quantity: 2 },
{ name: '香蕉', price: 3, quantity: 1 },
{ name: '橙子', price: 4, quantity: 3 }
])

// 计算总数量
const totalQuantity = computed(() => {
return cart.value.reduce((sum, item) => sum + item.quantity, 0)
})

// 计算总价
const totalPrice = computed(() => {
return cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})

// 计算折扣后价格
const discountedPrice = computed({
get() {
return totalPrice.value * 0.9 // 9折
},
set(value) {
// 根据折扣后价格反推原价
const originalPrice = value / 0.9
console.log('原价:', originalPrice.toFixed(2))
}
})

watch()

watch() 用于监听数据变化并执行相应的操作。

基本用法

import { ref, watch } from 'vue'

const count = ref(0)

// 监听 count 的变化
watch(count, (newValue, oldValue) => {
console.log(`count 变化: ${oldValue} -> ${newValue}`)
})

count.value++ // 输出: count 变化: 0 -> 1

监听多个数据源

import { ref, watch } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 监听多个数据源
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`${oldFirst} -> ${newFirst}, ${oldLast} -> ${newLast}`)
})

firstName.value = '李' // 输出: 张 -> 李, 三 -> 三

深度监听

import { ref, reactive, watch } from 'vue'

const state = reactive({
user: {
name: '张三',
age: 25
}
})

// 深度监听
watch(state, (newValue, oldValue) => {
console.log('state 变化:', newValue)
}, { deep: true })

state.user.age = 30 // 触发监听

立即执行

import { ref, watch } from 'vue'

const message = ref('')

// 添加 immediate 选项,立即执行一次
watch(message, (newValue) => {
console.log('消息:', newValue)
}, { immediate: true })

// 输出: 消息: (初始值)

获取新值和旧值

import { reactive, watch } from 'vue'

const state = reactive({ count: 0 })

watch(state, (newValue, oldValue) => {
console.log('新值:', newValue)
console.log('旧值:', oldValue)
// 注意:对于 reactive 对象,新值和旧值是同一个对象
})

state.count = 1
// 新值: { count: 1 }
// 旧值: { count: 1 } // 相同引用!

监听特定属性

import { reactive, watch } from 'vue'

const state = reactive({
user: {
name: '张三',
age: 25
}
})

// 只监听 user.name 的变化
watch(() => state.user.name, (newValue, oldValue) => {
console.log(`name: ${oldValue} -> ${newValue}`)
})

state.user.name = '李四' // 触发
state.user.age = 30 // 不触发

watchEffect()

watchEffect() 会立即执行传入的回调函数,并自动追踪回调中使用的所有响应式数据。

基本用法

import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('张三')

watchEffect(() => {
console.log(`${name.value}: ${count.value}`)
})

count.value++ // 输出: 张三: 1
name.value = '李四' // 输出: 李四: 1

watch vs watchEffect

特性watchwatchEffect
惰性执行否(默认惰性,可配置 immediate)是(立即执行)
获取新值/旧值支持不支持
明确指定依赖需要(第一个参数)自动追踪
适用场景需要获取变化前后的值只需要响应式依赖变化

选择建议

// 使用 watchEffect
// - 不关心旧值
// - 想在初始化时执行
// - 依赖明确,不需要显式指定

watchEffect(() => {
console.log(searchQuery.value)
fetchResults(searchQuery.value)
})

// 使用 watch
// - 需要访问旧值
// - 只在特定条件下监听
// - 明确的依赖关系

watch(searchQuery, (newValue, oldValue) => {
console.log(`${oldValue} -> ${newValue}`)
}, { immediate: true })

停止监听

自动停止

setup()<script setup> 中使用 watch,组件卸载时会自动停止:

<script setup>
import { watch } from 'vue'

const count = ref(0)

watch(count, () => {
console.log(count.value)
})
// 组件卸载时自动停止
</script>

手动停止

使用 watch 返回的停止函数:

import { watch } from 'vue'

const count = ref(0)

const stop = watch(count, () => {
console.log(count.value)
})

// 停止监听
stop()

count.value++ // 不再触发

实际示例:搜索功能

<script setup>
import { ref, watch, watchEffect } from 'vue'

const searchQuery = ref('')
const results = ref([])
const isLoading = ref(false)

// 使用 watchEffect 自动搜索
watchEffect(async () => {
if (!searchQuery.value) {
results.value = []
return
}

isLoading.value = true
try {
const response = await fetch(`/api/search?q=${searchQuery.value}`)
results.value = await response.json()
} finally {
isLoading.value = false
}
})

// 使用 watch 监听特定变化
watch(results, (newResults) => {
console.log('搜索结果更新:', newResults.length)
}, { deep: true })
</script>

<template>
<div>
<input v-model="searchQuery" placeholder="搜索..." />
<p v-if="isLoading">加载中...</p>
<ul>
<li v-for="result in results" :key="result.id">
{{ result.name }}
</li>
</ul>
</div>
</template>

示例:表单验证

<script setup>
import { ref, computed, watch } from 'vue'

const email = ref('')
const errors = ref({
email: ''
})

const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})

watch(email, (newValue) => {
if (!newValue) {
errors.value.email = '邮箱不能为空'
} else if (!isValidEmail.value) {
errors.value.email = '邮箱格式不正确'
} else {
errors.value.email = ''
}
})

const isFormValid = computed(() => {
return !errors.value.email
})
</script>

<template>
<form>
<div>
<input v-model="email" type="email" placeholder="邮箱" />
<span v-if="errors.email" class="error">{{ errors.email }}</span>
</div>
<button :disabled="!isFormValid">提交</button>
</div>
</template>

计算属性调试

computed() 支持调试选项,可以在开发模式下追踪依赖关系:

onTrack 和 onTrigger

import { ref, computed } from 'vue'

const count = ref(0)

const plusOne = computed(() => count.value + 1, {
// 当依赖被追踪时调用
onTrack(e) {
console.log('追踪依赖:', e)
// e = { effect, target, type, key }
debugger // 可以设置断点调试
},

// 当依赖变化触发重新计算时调用
onTrigger(e) {
console.log('触发重新计算:', e)
// e = { effect, target, type, key, newValue, oldValue }
debugger
}
})

// 访问计算属性,会触发 onTrack
console.log(plusOne.value) // onTrack 被调用

// 修改依赖,会触发 onTrigger
count.value = 1 // onTrigger 被调用

调试事件对象

type DebuggerEvent = {
effect: ReactiveEffect // 当前的副作用函数
target: object // 响应式对象
type: TrackOpTypes // 操作类型: 'get' | 'has' | 'iterate'
key: any // 属性键
newValue?: any // 新值(仅 onTrigger)
oldValue?: any // 旧值(仅 onTrigger)
}

watch 高级选项

flush 选项

控制回调的触发时机:

import { watch, watchEffect } from 'vue'

const count = ref(0)

// 'pre'(默认):组件更新前执行
watch(count, () => {
// 在组件更新前执行
// DOM 还是旧的状态
}, { flush: 'pre' })

// 'post':组件更新后执行
watch(count, () => {
// 在组件更新后执行
// DOM 已更新,可以安全访问
}, { flush: 'post' })

// 'sync':同步执行
watch(count, () => {
// 响应式数据变化后立即执行
// 注意:可能导致性能问题和数据一致性问题
}, { flush: 'sync' })

watchPostEffect 和 watchSyncEffect

Vue 提供了便捷的别名:

import { watchPostEffect, watchSyncEffect } from 'vue'

// 等价于 watchEffect(() => {}, { flush: 'post' })
watchPostEffect(() => {
// DOM 更新后执行
console.log(document.getElementById('count')?.textContent)
})

// 等价于 watchEffect(() => {}, { flush: 'sync' })
watchSyncEffect(() => {
// 同步执行,谨慎使用!
console.log('同步执行')
})

once 选项(Vue 3.4+)

回调只触发一次:

import { watch } from 'vue'

const count = ref(0)

watch(count, (newValue, oldValue) => {
console.log(`只执行一次: ${oldValue} -> ${newValue}`)
}, { once: true })

count.value = 1 // 输出: 只执行一次: 0 -> 1
count.value = 2 // 不再触发

deep 选项详解

深度监听对象的所有嵌套属性变化:

import { reactive, watch } from 'vue'

const state = reactive({
user: {
name: '张三',
address: {
city: '北京'
}
}
})

// 监听整个对象
watch(
() => state,
(newValue, oldValue) => {
// 注意:深度监听时,newValue 和 oldValue 是同一个对象
console.log('状态变化:', newValue)
console.log(newValue === oldValue) // true
},
{ deep: true }
)

// 也可以指定深度层级(Vue 3.5+)
watch(
() => state,
() => {
console.log('状态变化')
},
{ deep: 2 } // 只监听 2 层深度
)

state.user.name = '李四' // 触发
state.user.address.city = '上海' // 触发

暂停和恢复监听(Vue 3.5+)

可以临时暂停和恢复监听器:

import { watch, ref } from 'vue'

const count = ref(0)

const { stop, pause, resume } = watch(count, (newValue) => {
console.log('count:', newValue)
})

count.value = 1 // 输出: count: 1

// 暂停监听
pause()
count.value = 2 // 不触发

// 恢复监听
resume()
count.value = 3 // 输出: count: 3

// 停止监听(永久)
stop()
count.value = 4 // 不触发

副作用清理

onCleanup 回调

当监听的值变化时,可能需要清理之前的副作用:

import { ref, watch } from 'vue'

const id = ref(1)

watch(id, async (newId, oldId, onCleanup) => {
// 创建一个可取消的请求
const controller = new AbortController()

// 注册清理函数
// 当 id 再次变化或监听器停止时,清理函数会被调用
onCleanup(() => {
controller.abort() // 取消正在进行的请求
console.log('清理之前的请求')
})

try {
const response = await fetch(`/api/user/${newId}`, {
signal: controller.signal
})
const data = await response.json()
console.log('获取数据:', data)
} catch (e) {
if (e.name === 'AbortError') {
console.log('请求被取消')
}
}
})

watchEffect 中的清理

import { ref, watchEffect } from 'vue'

const id = ref(1)

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

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

const response = await fetch(`/api/data/${id.value}`, {
signal: controller.signal
})
const data = await response.json()
// 处理数据
})

// 或者使用 Vue 3.5+ 的 onWatcherCleanup
import { onWatcherCleanup } from 'vue'

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

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

const response = await fetch(`/api/data/${id.value}`, {
signal: controller.signal
})
})

实际应用:防抖搜索

import { ref, watch } from 'vue'

const searchQuery = ref('')
const results = ref([])

watch(searchQuery, (newQuery, oldQuery, onCleanup) => {
let cancelled = false

// 设置超时防抖
const timeoutId = setTimeout(async () => {
if (cancelled) return

const response = await fetch(`/api/search?q=${newQuery}`)
if (!cancelled) {
results.value = await response.json()
}
}, 300)

// 清理函数:取消之前的搜索
onCleanup(() => {
cancelled = true
clearTimeout(timeoutId)
})
})

实际应用示例

实时数据同步

<script setup>
import { ref, watch, onCleanup } from 'vue'

const userId = ref(1)
const userData = ref(null)
const lastSyncTime = ref(null)

watch(userId, async (newId, oldId, onCleanup) => {
let cancelled = false
const controller = new AbortController()

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

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

if (!cancelled) {
userData.value = await response.json()
lastSyncTime.value = new Date().toLocaleTimeString()
}
} catch (e) {
if (e.name !== 'AbortError') {
console.error('获取用户数据失败:', e)
}
}
}, { immediate: true })
</script>

复杂表单验证

<script setup>
import { ref, computed, watch } from 'vue'

const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})

const errors = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})

const touched = reactive({
username: false,
email: false,
password: false,
confirmPassword: false
})

// 用户名验证
watch(
() => form.username,
(value) => {
if (touched.username) {
if (!value) {
errors.username = '用户名不能为空'
} else if (value.length < 3) {
errors.username = '用户名至少3个字符'
} else {
errors.username = ''
}
}
}
)

// 邮箱验证
watch(
() => form.email,
(value) => {
if (touched.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!value) {
errors.email = '邮箱不能为空'
} else if (!emailRegex.test(value)) {
errors.email = '邮箱格式不正确'
} else {
errors.email = ''
}
}
}
)

// 密码验证
watch(
[() => form.password, () => form.confirmPassword],
([password, confirmPassword]) => {
if (touched.password) {
if (!password) {
errors.password = '密码不能为空'
} else if (password.length < 6) {
errors.password = '密码至少6个字符'
} else {
errors.password = ''
}
}

if (touched.confirmPassword) {
if (password !== confirmPassword) {
errors.confirmPassword = '两次密码不一致'
} else {
errors.confirmPassword = ''
}
}
}
)

const isFormValid = computed(() => {
return !Object.values(errors).some(e => e)
})

function handleBlur(field) {
touched[field] = true
}

function submit() {
if (isFormValid.value) {
console.log('提交表单:', form)
}
}
</script>

小结

本章我们详细学习了 computed 和 watch 的完整内容:

  1. computed():创建计算属性,自动缓存
  2. 可写计算属性:使用 get 和 set
  3. computed 调试:onTrack 和 onTrigger 调试选项
  4. watch():监听数据变化,获取新旧值
  5. watchEffect():自动追踪依赖的响应式数据
  6. watch 高级选项:flush、deep、immediate、once
  7. watchPostEffect/watchSyncEffect:不同执行时机的便捷别名
  8. 暂停和恢复:pause/resume 功能
  9. 副作用清理:onCleanup 处理异步操作取消
  10. 深度监听:监听对象深层变化

练习

  1. 创建一个实时搜索的组件,使用副作用清理处理请求取消
  2. 实现一个复杂的表单验证系统,支持实时验证和提交验证
  3. 创建一个数据同步组件,支持暂停和恢复同步
  4. 实现一个带防抖的自动保存功能

准备好进入下一章,学习组合式 API 的高级用法的内容了吗?