Vue 3 派生状态与副作用:computed、watch、生命周期和组合式函数
Vue 3 派生状态与副作用:computed、watch、生命周期和组合式函数
系列导航:Vue 3 现代开发指南
上一篇:Vue 3 响应式基础
下一篇:组件契约:Props、Emits、defineModel与插槽
响应式状态解决“数据变化后视图如何更新”,但应用还需要派生数据、异步请求、订阅和资源清理。Vue 对这些职责做了明确区分:用 computed 描述纯派生状态,用 watch 或 watchEffect 执行副作用,用生命周期钩子连接浏览器或第三方资源。
一、优先用 computed 表达派生状态
计算属性根据响应式依赖生成新值,并按依赖缓存:
<script setup lang="ts">
import { computed, ref } from 'vue'
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed(
() => `${firstName.value} ${lastName.value}`,
)
</script>
<template>
<p>{{ fullName }}</p>
</template>
只要 firstName 和 lastName 没变,多次读取 fullName 不会重复计算。计算 getter 应保持纯净:不要在里面发请求、写其他状态、操作 DOM 或记录依赖执行次数。
需要双向转换时,可以创建可写计算属性:
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
const [first = '', ...rest] = value.trim().split(/\s+/)
firstName.value = first
lastName.value = rest.join(' ')
},
})
可写计算属性适合格式转换,不应被用来掩盖复杂的多状态同步。
二、watch 能监听哪些数据
watch() 的数据源可以是:
- 一个
ref或计算属性; - 一个响应式对象;
- 返回待监听值的 getter;
- 由上述数据源组成的数组。
监听 ref:
const page = ref(1)
watch(page, (newPage, oldPage) => {
console.log({ newPage, oldPage })
})
监听对象的某个基本类型属性时,必须使用 getter:
const filters = reactive({ keyword: '', category: 'all' })
watch(
() => filters.keyword,
(keyword) => {
console.log(keyword)
},
)
监听多个数据源:
watch(
[page, () => filters.category],
([newPage, newCategory], [oldPage, oldCategory]) => {
console.log({ newPage, newCategory, oldPage, oldCategory })
},
)
不要写 watch(filters.keyword, ...),因为这会把调用当下的字符串传进去,而不是一个可监听的数据源。
三、深度监听与 Vue 3.5 的数字深度
直接监听 reactive 对象时,Vue 会深度遍历它:
const settings = reactive({
theme: 'dark',
editor: { fontSize: 16 },
})
watch(settings, () => {
console.log('设置发生变化')
})
深度监听中的 newValue 与 oldValue 可能指向同一个对象,因为内部属性变化并没有替换根对象。需要可靠快照时,应监听具体字段,或在边界处自行创建快照。
Vue 3.5 起,deep 可以是最大遍历深度:
watch(
() => settings,
() => {
console.log('最多检查两层')
},
{ deep: 2 },
)
大型对象的无限深度监听成本很高。优先监听真正会触发业务动作的字段,不要用 deep: true 代替状态设计。
四、常用监听选项
watch(source, callback, {
immediate: true,
once: true,
flush: 'post',
})
immediate:创建监听器时立即执行一次,首次旧值为undefined;once:Vue 3.4+,在数据第一次变化后自动停止;flush: 'pre':默认值,在组件 DOM 更新前执行;flush: 'post':需要读取更新后的组件 DOM 时使用;flush: 'sync':同步触发,容易造成重复执行,应谨慎使用。
watch() 返回的句柄可以暂停、恢复或停止:
const handle = watch(page, loadPage)
handle.pause()
handle.resume()
handle.stop()
在组件 setup() 同步阶段创建的监听器,会随组件卸载自动停止。异步回调里延迟创建的监听器则需要自行管理。
五、异步请求一定要清理过期副作用
搜索词快速变化时,旧请求可能比新请求更晚返回,导致界面显示过期结果。Vue 3.5 的 onWatcherCleanup() 可以取消上一轮请求:
import { onWatcherCleanup, ref, watch } from 'vue'
interface SearchResult {
id: number
title: string
}
const keyword = ref('')
const results = ref<SearchResult[]>([])
const loading = ref(false)
watch(keyword, async (value) => {
const normalized = value.trim()
if (!normalized) {
results.value = []
loading.value = false
return
}
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
loading.value = true
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(normalized)}`,
{ signal: controller.signal },
)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
results.value = await response.json()
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
throw error
}
} finally {
if (!controller.signal.aborted) loading.value = false
}
})
onWatcherCleanup() 必须在监听回调的同步执行阶段调用,不能放在第一个 await 之后。旧版本或需要把清理注册逻辑传给其他函数时,也可以使用回调第三个参数 onCleanup。
六、watchEffect 适合自动收集依赖
watchEffect() 会立即执行,并自动跟踪同步执行期间读取的响应式依赖:
const width = ref(window.innerWidth)
const compact = ref(false)
watchEffect(() => {
compact.value = width.value < 768
})
这个例子实际上更适合写成计算属性:
const compact = computed(() => width.value < 768)
这体现了选择原则:
- 能返回一个值的纯计算,使用
computed; - 需要明确来源、新旧值和触发条件,使用
watch; - 副作用依赖很多,且自动收集更清楚时,使用
watchEffect。
异步 watchEffect 只会追踪第一个 await 之前同步读取的依赖,不能假设整个异步函数里的读取都会被收集。
七、生命周期钩子
Composition API 常用生命周期如下:
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
} from 'vue'
日常最常见的是:
onMounted():首次 DOM 已创建后访问 DOM 或初始化只接受真实元素的库;onBeforeUnmount()/onUnmounted():移除事件、断开连接和销毁第三方实例;onUpdated():任意响应式更新后触发,通常不应在这里继续修改状态。
如果只想等待本轮状态对应的 DOM 更新,使用 nextTick(),通常比全局的 onUpdated() 更精确:
import { nextTick, ref } from 'vue'
const open = ref(false)
async function openPanel() {
open.value = true
await nextTick()
document.querySelector<HTMLElement>('[data-panel]')?.focus()
}
八、把资源生命周期封装进组合式函数
组合式函数应自己管理它创建的资源:
// composables/useWindowWidth.ts
import { onMounted, onUnmounted, ref } from 'vue'
export function useWindowWidth() {
const width = ref(0)
function update() {
width.value = window.innerWidth
}
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width }
}
这样每个使用者只关心返回值,不需要记住额外的清理步骤。若应用会 SSR,浏览器全局对象应只在挂载后访问,或先检查运行环境。
小结
派生值用 computed,有明确来源的副作用用 watch,适合自动收集依赖的副作用用 watchEffect。异步监听要取消过期任务,资源要在卸载时清理。Vue 3.5 的 onWatcherCleanup() 和数字 deep 能让这些边界更明确,但仍不能替代良好的状态设计。