本篇定位 :计算属性和侦听器是 Vue 响应式系统中处理派生状态 和副作用 的核心工具。本篇将深入讲解
computed的缓存机制与读写特性、watch的多种监听模式、watchEffect的自动依赖收集,以及 Vue 3.5+ 的新特性。读完本篇,你将能够精准地选择和使用这些工具,构建高效、可维护的响应式逻辑。
一、计算属性(computed)深度解析
1.1 计算属性的本质与缓存机制
计算属性是基于响应式依赖 进行缓存的派生值。只有当依赖发生变化时,计算属性才会重新计算。
vue
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
const age = ref(25)
// 只读计算属性
const fullName = computed(() => {
console.log('计算 fullName 被执行') // 依赖不变时只会执行一次
return firstName.value + ' ' + lastName.value
})
// 带格式化的计算属性
const userDisplay = computed(() => {
return `${fullName.value}(${age.value}岁)`
})
// 模板中多次使用,但计算只执行一次
</script>
<template>
<p>{{ fullName }}</p> <!-- 触发计算 -->
<p>{{ fullName }}</p> <!-- 使用缓存,不重新计算 -->
<p>{{ userDisplay }}</p> <!-- 依赖 fullName,但 fullName 已缓存 -->
</template>
缓存机制的工作原理:
- 计算属性首次被访问时,执行 getter 函数并缓存结果
- Vue 会追踪 getter 中访问的所有响应式依赖
- 当依赖发生变化时,标记计算属性为"脏"(dirty)
- 下次访问时,重新执行 getter 并更新缓存
与方法的对比:
| 特性 | computed |
method |
|---|---|---|
| 缓存 | ✅ 有缓存,依赖不变不重新计算 | ❌ 每次渲染都执行 |
| 性能 | 高(避免重复计算) | 低(每次渲染都执行) |
| 适用场景 | 派生状态、复杂计算 | 事件处理、需要传入参数的计算 |
1.2 可写计算属性(getter + setter)
计算属性默认是只读的,但可以通过提供 setter 来实现可写:
vue
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
const fullName = computed({
// getter
get: () => {
return firstName.value + ' ' + lastName.value
},
// setter
set: (newValue) => {
const [first, last] = newValue.split(' ')
firstName.value = first || ''
lastName.value = last || ''
}
})
// 现在可以双向绑定
function updateName() {
fullName.value = '李 四' // 触发 setter
// firstName = '李', lastName = '四'
}
</script>
<template>
<input v-model="fullName" placeholder="请输入姓名" />
<!-- 输入 '王 五' 会自动拆分到 firstName 和 lastName -->
</template>
可写计算属性的典型应用场景:
- 将多个输入字段合并为一个字段(如姓名拆分/合并)
- 将复杂对象转换为字符串表示(如日期选择器)
- 在
v-model中使用计算属性作为中间层
1.3 计算属性的性能优化
避免在计算属性中产生副作用:
vue
<script setup>
// ❌ 错误:计算属性中不应该修改其他状态
const count = ref(0)
const double = computed(() => {
count.value++ // ❌ 副作用!会导致无限循环
return count.value * 2
})
// ✅ 正确:只做纯粹的计算
const double = computed(() => count.value * 2)
</script>
减少不必要的计算:
vue
<script setup>
const users = ref([/* 10000 个用户 */])
const searchKeyword = ref('')
// ❌ 每次搜索都会遍历整个数组
const filteredUsers = computed(() => {
return users.value.filter(u => u.name.includes(searchKeyword.value))
})
// ✅ 使用防抖 + 计算属性的优化组合
import { useDebounce } from './composables/useDebounce'
const { debouncedValue: debouncedSearch } = useDebounce(searchKeyword, 300)
const filteredUsers = computed(() => {
if (!debouncedSearch.value) return users.value
return users.value.filter(u => u.name.includes(debouncedSearch.value))
})
</script>
1.4 计算属性的类型推导
使用 TypeScript 时,计算属性的类型会自动推导:
typescript
import { ref, computed } from 'vue'
const count = ref(0)
// 类型自动推导为 ComputedRef<number>
const double = computed(() => count.value * 2)
// 显式类型声明
const doubleNumber: ComputedRef<number> = computed(() => count.value * 2)
// 可写计算属性的类型
const writable = computed({
get: (): string => `${count.value}`,
set: (val: string) => { count.value = parseInt(val, 10) }
})
二、侦听器(watch)深度解析
2.1 watch 的基本用法
watch 用于侦听响应式数据的变化,并在变化时执行副作用(如 API 调用、DOM 操作、日志记录等)。
vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
const user = ref({ name: '张三', age: 25 })
// 侦听单个 ref
watch(count, (newVal, oldVal) => {
console.log(`count 从 ${oldVal} 变为 ${newVal}`)
})
// 侦听多个来源(数组)
watch([count, () => user.value.age], ([newCount, newAge], [oldCount, oldAge]) => {
console.log('count 或 age 变化', { newCount, newAge, oldCount, oldAge })
})
// 侦听 reactive 对象的属性(使用 getter 函数)
watch(() => user.value.name, (newName, oldName) => {
console.log(`姓名从 ${oldName} 变为 ${newName}`)
})
</script>
watch 的核心参数:
watch(source, callback, options?)
source:侦听的数据源(ref、reactive、getter 函数、数组)callback:变化时的回调函数,接收新值和旧值options:配置对象(deep、immediate、flush、once等)
2.2 深度侦听(deep)
默认情况下,watch 是浅层侦听 ,只有直接赋值才会触发。使用 deep: true 可以侦听嵌套属性的变化:
vue
<script setup>
import { ref, watch } from 'vue'
const user = ref({
name: '张三',
profile: {
age: 25,
address: {
city: '北京',
district: '朝阳区'
}
}
})
// ❌ 浅层侦听:修改嵌套属性不会触发
watch(user, (newVal) => {
console.log('user 变化', newVal)
})
// 修改嵌套属性:不会触发
user.value.profile.age = 30
// ✅ 深度侦听:所有层级变化都会触发
watch(user, (newVal) => {
console.log('user 深层变化', newVal)
}, { deep: true })
user.value.profile.age = 30 // ✅ 触发
user.value.profile.address.district = '海淀区' // ✅ 触发
// 深度侦听 + immediate
watch(user, (newVal) => {
console.log('立即执行 + 深层侦听', newVal)
}, { deep: true, immediate: true })
</script>
性能注意事项:
- 深度侦听会递归遍历整个对象,对大型对象性能开销较大
- 优先使用 getter 函数侦听特定属性,而不是深度侦听整个对象
typescript
// 性能更好:只侦听特定属性
watch(() => user.value.profile.age, (newAge) => {
console.log('年龄变化', newAge)
})
2.3 即时执行(immediate)
设置 immediate: true 会让回调在侦听开始时立即执行一次:
vue
<script setup>
import { ref, watch } from 'vue'
const userId = ref(1)
const userData = ref(null)
// 立即执行:页面加载时自动获取用户数据
watch(userId, async (newId) => {
const data = await fetchUser(newId)
userData.value = data
}, { immediate: true })
// 等价于在 onMounted 中手动调用
</script>
常见应用场景:
- 页面初始化时加载数据(替代
onMounted+ 手动调用) - 表单默认值初始化
- 路由参数变化时重新加载数据
2.4 侦听多个来源
watch 可以同时侦听多个数据源,回调会接收到数组形式的新值和旧值:
vue
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
const category = ref('all')
const sortBy = ref('name')
// 同时侦听三个数据源
watch(
[keyword, category, sortBy],
([newKeyword, newCategory, newSort], [oldKeyword, oldCategory, oldSort]) => {
console.log('筛选条件变化:', { keyword: newKeyword, category: newCategory, sortBy: newSort })
// 执行搜索
performSearch({ keyword: newKeyword, category: newCategory, sortBy: newSort })
}
)
function performSearch(params: any) {
console.log('搜索参数:', params)
}
</script>
2.5 watch 的清理与取消
watch 返回一个停止函数,用于取消侦听:
vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// watch 返回停止函数
const stopWatch = watch(count, (newVal) => {
console.log('count 变化', newVal)
})
// 条件性地停止侦听
function stopListening() {
stopWatch()
}
// 在组件卸载时自动停止(Vue 会自动清理)
// 但如果是条件性侦听,需要手动清理
</script>
在组合式函数中使用 watch 并自动清理:
typescript
// composables/useAutoSave.ts
import { watch, onUnmounted } from 'vue'
export function useAutoSave(data: any, saveFn: (data: any) => Promise<void>) {
let timer: ReturnType<typeof setTimeout> | null = null
const stop = watch(data, () => {
// 防抖保存
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
saveFn(data)
timer = null
}, 500)
}, { deep: true })
// 组件卸载时自动停止侦听并清理定时器
onUnmounted(() => {
stop()
if (timer) {
clearTimeout(timer)
timer = null
}
})
}
2.6 watch 的新特性(Vue 3.5+)
① once 选项:回调只执行一次,然后自动停止侦听。
vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// 只侦听第一次变化
watch(count, (newVal) => {
console.log('count 第一次变化为', newVal)
}, { once: true })
// 触发第一次后,后续变化不会再触发
count.value = 1 // 输出:count 第一次变化为 1
count.value = 2 // 无输出
</script>
② onWatcherCleanup:在 watch 回调中注册清理函数,在下次回调执行前或组件卸载时调用。
vue
<script setup>
import { ref, watch, onWatcherCleanup } from 'vue'
const userId = ref(1)
watch(userId, async (newId, oldId, onCleanup) => {
// 使用 onWatcherCleanup 注册清理
onWatcherCleanup(() => {
console.log('清理之前的请求')
// 取消之前的异步请求
})
// 执行异步操作
const data = await fetchUser(newId)
// 处理数据...
})
</script>
③ flush 选项:控制回调的执行时机。
| 值 | 说明 | 适用场景 |
|---|---|---|
'pre'(默认) |
在 DOM 更新前执行 | 需要访问旧 DOM 状态 |
'post' |
在 DOM 更新后执行 | 需要访问更新后的 DOM |
'sync' |
同步执行(不进入异步队列) | 需要立即响应变化 |
vue
<script setup>
import { ref, watch, nextTick } from 'vue'
const count = ref(0)
const el = ref<HTMLElement | null>(null)
// DOM 更新后执行
watch(count, async (newVal) => {
// 此时 DOM 已更新,可以安全地操作 DOM
if (el.value) {
el.value.textContent = `当前值:${newVal}`
}
}, { flush: 'post' })
// 同步执行(慎用,可能影响性能)
watch(count, (newVal) => {
console.log('同步执行', newVal)
}, { flush: 'sync' })
</script>
三、watchEffect:自动收集依赖
3.1 基本用法
watchEffect 会自动追踪其回调函数中访问的所有响应式依赖,当任何依赖变化时,回调会重新执行。
vue
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('张三')
const age = ref(25)
// watchEffect 自动收集依赖
watchEffect(() => {
// 访问了 count 和 name,这两个变化都会触发回调
console.log(`count: ${count.value}, name: ${name.value}`)
// age 没有被访问,它的变化不会触发回调
})
// 修改 count 或 name → 触发回调
count.value++
name.value = '李四'
// 修改 age → 不会触发回调
age.value = 30
</script>
watchEffect 的核心特征:
- 自动依赖收集:不需要显式指定侦听的数据源
- 立即执行 :创建时立即执行一次(类似
immediate: true) - 无法访问旧值:回调只接收新值,不提供旧值
3.2 watch 与 watchEffect 的对比
| 特性 | watch |
watchEffect |
|---|---|---|
| 显式指定依赖 | ✅ 需要 | ❌ 自动收集 |
| 立即执行 | 可选(immediate) |
✅ 默认立即执行 |
| 访问旧值 | ✅ 提供 oldVal |
❌ 不提供 |
| 侦听多个来源 | ✅ 数组形式 | ✅ 自动收集 |
| 精确控制 | 高(可指定何时触发) | 低(依赖变化即触发) |
| 适用场景 | 需要精确控制、需要旧值 | 自动追踪依赖的副作用 |
选择指南:
- 需要旧值 对比 → 用
watch - 需要精确控制 触发条件 → 用
watch - 只需要在依赖变化时执行副作用,不关心旧值 → 用
watchEffect - 多个数据源共同决定一个副作用 → 用
watchEffect(更简洁)
3.3 watchEffect 的清理与停止
与 watch 类似,watchEffect 也返回一个停止函数:
vue
<script setup>
import { ref, watchEffect, onUnmounted } from 'vue'
const search = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
// watchEffect 返回停止函数
const stop = watchEffect((onCleanup) => {
// 注册清理函数:在副作用重新执行前或组件卸载时调用
onCleanup(() => {
if (timer) {
clearTimeout(timer)
timer = null
}
console.log('清理搜索')
})
// 执行副作用
timer = setTimeout(() => {
console.log('搜索:', search.value)
}, 300)
})
// 停止侦听
function stopWatching() {
stop()
}
// 组件卸载时会自动停止,但手动停止更可控
</script>
3.4 watchEffect 与异步操作
watchEffect 可以配合异步操作,但需要注意竞态条件:
vue
<script setup>
import { ref, watchEffect, onWatcherCleanup } from 'vue'
const query = ref('')
const results = ref([])
watchEffect(async (onCleanup) => {
// 使用 onCleanup 取消之前的请求
let cancelled = false
onCleanup(() => {
cancelled = true
})
if (!query.value.trim()) {
results.value = []
return
}
// 模拟异步请求
const data = await fetchSearchResults(query.value)
// 如果已被取消,不更新结果
if (!cancelled) {
results.value = data
}
})
</script>
3.5 条件性侦听
有时需要根据条件动态启用/禁用侦听:
vue
<script setup>
import { ref, watch, watchEffect } from 'vue'
const enabled = ref(true)
const count = ref(0)
// 使用 watch + 条件判断
watch(count, (newVal) => {
if (enabled.value) {
console.log('count 变化', newVal)
}
})
// 或者使用条件性停止
let stop: (() => void) | null = null
function startWatching() {
if (stop) return
stop = watchEffect(() => {
console.log('侦听中:', count.value)
})
}
function stopWatching() {
if (stop) {
stop()
stop = null
}
}
// 根据 enabled 自动控制
watch(enabled, (newVal) => {
if (newVal) {
startWatching()
} else {
stopWatching()
}
}, { immediate: true })
</script>
四、性能优化与常见陷阱
4.1 避免在 watch 中直接修改侦听的数据
vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// ❌ 错误:在 watch 中修改侦听的数据,可能导致无限循环
watch(count, (newVal) => {
count.value = newVal + 1 // ❌ 死循环!
})
// ✅ 正确:使用条件判断避免循环
watch(count, (newVal) => {
if (newVal < 10) {
count.value = 10 // 只在满足条件时修改
}
})
</script>
4.2 侦听大量数据时的性能问题
vue
<script setup>
import { ref, watch, computed } from 'vue'
const largeData = ref(/* 包含 10000 个对象的数组 */)
// ❌ 性能差:每次变化都深度遍历整个数组
watch(largeData, (newVal) => {
console.log('数据变化', newVal.length)
}, { deep: true })
// ✅ 性能好:只侦听数组长度变化
watch(() => largeData.value.length, (newLen) => {
console.log('数据长度变化', newLen)
})
// ✅ 更好:使用计算属性缓存派生值
const dataLength = computed(() => largeData.value.length)
watch(dataLength, (newLen) => {
console.log('数据长度变化', newLen)
})
</script>
4.3 在 watch 中使用异步操作的最佳实践
vue
<script setup>
import { ref, watch, onWatcherCleanup } from 'vue'
const userId = ref('')
const userData = ref(null)
const isLoading = ref(false)
watch(userId, async (newId, oldId, onCleanup) => {
// 1. 取消之前的请求
let abortController = new AbortController()
onCleanup(() => {
abortController.abort()
})
// 2. 设置加载状态
if (!newId) {
userData.value = null
return
}
isLoading.value = true
try {
// 3. 执行请求(支持取消)
const response = await fetch(`/api/users/${newId}`, {
signal: abortController.signal
})
const data = await response.json()
userData.value = data
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被取消')
} else {
console.error('请求失败', error)
}
} finally {
isLoading.value = false
}
}, { immediate: true })
</script>
4.4 watch 与 computed 的协作模式
vue
<script setup>
import { ref, computed, watch } from 'vue'
const firstName = ref('')
const lastName = ref('')
// 使用 computed 派生数据
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// 使用 watch 执行副作用(如 API 调用)
watch(fullName, (newFullName) => {
console.log('姓名变化:', newFullName)
// 执行副作用,如更新文档标题、发起请求等
})
// 这是一个非常常见的模式:computed 负责派生,watch 负责副作用
</script>
4.5 同步 watch vs 异步 watch
vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// 默认异步执行(在 DOM 更新后执行)
watch(count, (newVal) => {
console.log('异步执行:', newVal) // 在微任务队列中执行
})
// 同步执行(立即执行,不进入队列)
watch(count, (newVal) => {
console.log('同步执行:', newVal) // 立即执行
}, { flush: 'sync' })
// 性能影响:同步 watch 可能导致频繁的重渲染
// 推荐使用默认的异步执行
</script>
五、综合实战:带自动保存的表单
让我们综合运用 computed、watch 和 watchEffect,构建一个带自动保存功能的表单组件:
vue
<script setup lang="ts">
import { ref, reactive, computed, watch, watchEffect, onMounted, onWatcherCleanup } from 'vue'
// -------- 表单数据类型 --------
interface FormData {
title: string
content: string
tags: string[]
category: string
status: 'draft' | 'published'
lastSaved: Date | null
}
// -------- 表单状态 --------
const form = reactive<FormData>({
title: '我的文章标题',
content: '这是文章内容...',
tags: ['Vue', 'JavaScript'],
category: 'tech',
status: 'draft',
lastSaved: null
})
// -------- UI 状态 --------
const isSaving = ref(false)
const saveMessage = ref('')
const autoSaveEnabled = ref(true)
// -------- 计算属性:表单完整性验证 --------
const isFormValid = computed(() => {
return form.title.trim().length > 0 &&
form.content.trim().length >= 10 &&
form.tags.length > 0
})
// -------- 计算属性:表单修改状态(用于提示用户) --------
const isFormDirty = ref(false)
const originalFormData = ref<FormData | null>(null)
// 保存原始数据的快照
function snapshotForm(): FormData {
return {
title: form.title,
content: form.content,
tags: [...form.tags],
category: form.category,
status: form.status,
lastSaved: form.lastSaved
}
}
// 检查表单是否被修改
function checkDirty() {
if (!originalFormData.value) return false
const current = {
title: form.title,
content: form.content,
tags: [...form.tags],
category: form.category,
status: form.status,
lastSaved: form.lastSaved
}
return JSON.stringify(current) !== JSON.stringify(originalFormData.value)
}
// 初始化原始数据
onMounted(() => {
originalFormData.value = snapshotForm()
})
// 当表单变化时更新脏状态
watchEffect(() => {
if (originalFormData.value) {
isFormDirty.value = checkDirty()
}
})
// -------- 保存函数 --------
async function saveForm() {
if (!isFormValid.value) {
saveMessage.value = '⚠️ 表单不完整,请检查必填项'
return
}
isSaving.value = true
saveMessage.value = '💾 保存中...'
try {
// 模拟 API 请求
await new Promise(resolve => setTimeout(resolve, 800))
form.lastSaved = new Date()
originalFormData.value = snapshotForm()
isFormDirty.value = false
saveMessage.value = '✅ 保存成功 ' + form.lastSaved.toLocaleTimeString()
} catch (error) {
saveMessage.value = '❌ 保存失败,请重试'
} finally {
isSaving.value = false
}
}
// -------- 自动保存(使用 watch) --------
// 侦听表单变化,触发自动保存(防抖)
let saveTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => ({
title: form.title,
content: form.content,
tags: form.tags,
category: form.category
}),
(newVal, oldVal) => {
if (!autoSaveEnabled.value) return
if (!isFormValid.value) return
// 防抖:500ms 无操作后保存
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
console.log('自动保存触发', newVal)
saveForm()
saveTimer = null
}, 500)
},
{ deep: true }
)
// 组件卸载时清理定时器
import { onUnmounted } from 'vue'
onUnmounted(() => {
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
})
// -------- 手动保存快捷键(Ctrl+S) --------
import { useEventListener } from './composables/useEventListener'
useEventListener(document, 'keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
saveForm()
}
})
// -------- 离开页面时提醒未保存 --------
watch(isFormDirty, (dirty) => {
if (dirty) {
// 注意:在真实项目中,使用 beforeunload 事件
// 这里仅作演示
console.log('表单未保存')
}
})
// 使用 beforeunload 事件提醒
useEventListener(window, 'beforeunload', (e) => {
if (isFormDirty.value) {
e.preventDefault()
e.returnValue = '您有未保存的更改,确定离开吗?'
}
})
// -------- 切换自动保存 --------
function toggleAutoSave() {
autoSaveEnabled.value = !autoSaveEnabled.value
}
// -------- 重置表单 --------
function resetForm() {
if (!isFormDirty.value) return
if (confirm('确定要重置表单吗?未保存的更改将丢失。')) {
form.title = ''
form.content = ''
form.tags = []
form.category = 'tech'
form.status = 'draft'
originalFormData.value = snapshotForm()
isFormDirty.value = false
saveMessage.value = '🔄 表单已重置'
}
}
</script>
<template>
<div class="form-container">
<h1>📝 文章编辑器</h1>
<!-- 状态栏 -->
<div class="status-bar">
<span :class="['status-indicator', { dirty: isFormDirty }]">
{{ isFormDirty ? '⚡ 未保存' : '✅ 已保存' }}
</span>
<span v-if="form.lastSaved" class="last-saved">
最后保存:{{ form.lastSaved.toLocaleTimeString() }}
</span>
<span class="save-message">{{ saveMessage }}</span>
</div>
<!-- 工具栏 -->
<div class="toolbar">
<button @click="saveForm" :disabled="isSaving || !isFormValid" class="btn-save">
{{ isSaving ? '⏳ 保存中...' : '💾 保存' }}
</button>
<button @click="toggleAutoSave" class="btn-toggle">
{{ autoSaveEnabled ? '🔔 自动保存已开启' : '🔕 自动保存已关闭' }}
</button>
<button @click="resetForm" :disabled="!isFormDirty" class="btn-reset">
🔄 重置
</button>
<span class="shortcut-hint">Ctrl + S 快速保存</span>
</div>
<!-- 表单字段 -->
<div class="form-group">
<label>标题 <span class="required">*</span></label>
<input
v-model="form.title"
placeholder="请输入文章标题"
class="form-input"
:class="{ error: form.title.trim().length === 0 && isFormDirty }"
/>
</div>
<div class="form-group">
<label>分类</label>
<select v-model="form.category" class="form-select">
<option value="tech">技术</option>
<option value="life">生活</option>
<option value="thoughts">思考</option>
<option value="tutorial">教程</option>
</select>
</div>
<div class="form-group">
<label>内容 <span class="required">*</span></label>
<textarea
v-model="form.content"
placeholder="请输入文章内容(至少 10 个字符)"
class="form-textarea"
:class="{ error: form.content.trim().length < 10 && isFormDirty }"
rows="8"
/>
<span class="hint">{{ form.content.length }} 个字符</span>
</div>
<div class="form-group">
<label>标签</label>
<div class="tags-container">
<span
v-for="(tag, index) in form.tags"
:key="index"
class="tag-item"
>
{{ tag }}
<button @click="form.tags.splice(index, 1)" class="tag-remove">×</button>
</span>
<input
v-model="newTag"
@keydown.enter="addTag"
placeholder="添加标签后按 Enter"
class="tag-input"
/>
</div>
</div>
<div class="form-group">
<label>状态</label>
<div class="radio-group">
<label>
<input v-model="form.status" type="radio" value="draft" />
草稿
</label>
<label>
<input v-model="form.status" type="radio" value="published" />
发布
</label>
</div>
</div>
<!-- 表单验证状态 -->
<div class="validation-status">
<p v-if="!isFormValid" class="validation-error">
⚠️ 请完善表单:标题不能为空,内容至少 10 个字符,至少选择一个标签
</p>
<p v-else class="validation-success">✅ 表单完整,可以保存</p>
</div>
<!-- 实时预览(使用计算属性) -->
<div class="preview" v-if="form.title || form.content">
<h2>📄 预览</h2>
<div class="preview-content">
<h3>{{ form.title || '(无标题)' }}</h3>
<p>{{ form.content || '(暂无内容)' }}</p>
<div class="preview-meta">
<span>分类:{{ form.category }}</span>
<span>标签:{{ form.tags.join('、') || '无' }}</span>
<span>状态:{{ form.status === 'published' ? '已发布' : '草稿' }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
// 用于标签输入的临时变量
const newTag = ref('')
function addTag() {
const tag = newTag.value.trim()
if (tag && !form.tags.includes(tag)) {
form.tags.push(tag)
newTag.value = ''
}
}
</script>
<style scoped>
.form-container {
max-width: 800px;
margin: 20px auto;
padding: 30px;
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
}
h1 { font-size: 24px; margin-bottom: 20px; }
.status-bar {
display: flex;
gap: 20px;
padding: 10px 16px;
background: #f7fafc;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
align-items: center;
flex-wrap: wrap;
}
.status-indicator {
font-weight: 600;
}
.status-indicator.dirty { color: #ecc94b; }
.status-indicator:not(.dirty) { color: #48bb78; }
.last-saved { color: #718096; }
.save-message { color: #4299e1; }
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
align-items: center;
}
.btn-save, .btn-toggle, .btn-reset {
padding: 8px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-save { background: #4299e1; color: white; }
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-toggle { background: #e2e8f0; color: #4a5568; }
.btn-reset { background: #fed7d7; color: #c53030; }
.btn-reset:disabled { opacity: 0.4; cursor: not-allowed; }
.shortcut-hint { font-size: 12px; color: #a0aec0; margin-left: auto; }
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 14px;
color: #2d3748;
}
.required { color: #fc8181; }
.hint { font-size: 12px; color: #a0aec0; }
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 15px;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: #4299e1;
}
.form-input.error, .form-textarea.error {
border-color: #fc8181;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
min-height: 44px;
align-items: center;
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 4px;
background: #ebf8ff;
color: #2b6cb0;
padding: 4px 8px 4px 12px;
border-radius: 16px;
font-size: 13px;
}
.tag-remove {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #2b6cb0;
padding: 0 2px;
}
.tag-input {
flex: 1;
min-width: 100px;
border: none;
padding: 4px 0;
font-size: 14px;
background: transparent;
}
.tag-input:focus { outline: none; }
.radio-group {
display: flex;
gap: 20px;
padding: 4px 0;
}
.radio-group label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 400;
cursor: pointer;
}
.validation-status {
margin: 16px 0 20px;
padding: 10px 16px;
border-radius: 8px;
}
.validation-error {
color: #c53030;
background: #fff5f5;
padding: 8px 16px;
border-radius: 6px;
}
.validation-success {
color: #276749;
background: #f0fff4;
padding: 8px 16px;
border-radius: 6px;
}
.preview {
margin-top: 24px;
padding: 20px;
background: #f7fafc;
border-radius: 10px;
border: 1px solid #e2e8f0;
}
.preview h2 { font-size: 18px; margin-bottom: 12px; }
.preview-content h3 { font-size: 20px; margin-bottom: 8px; }
.preview-content p { color: #4a5568; line-height: 1.8; }
.preview-meta {
display: flex;
gap: 20px;
margin-top: 12px;
font-size: 13px;
color: #718096;
}
</style>
六、本篇小结
| 知识点 | 核心内容 | 关键要点 |
|---|---|---|
| computed | 派生状态,有缓存 | 避免副作用,依赖变化才重新计算 |
| 可写 computed | getter + setter | 实现双向绑定转换 |
| watch | 显式侦听数据变化 | 支持 deep、immediate、once、flush 选项 |
| watchEffect | 自动收集依赖 | 立即执行,不提供旧值 |
| 清理函数 | onWatcherCleanup / onCleanup |
处理竞态条件、取消请求 |
| 性能优化 | 避免深度侦听大型对象 | 使用 getter 函数精准侦听 |
| 选择指南 | watch vs watchEffect | 需要旧值/精确控制用 watch,否则用 watchEffect |
七、思考与练习
-
基础练习 :创建一个
useLocalStorage组合式函数,使用watch自动将数据同步到localStorage。 -
实践任务 :在表单自动保存示例中,增加一个"版本历史"功能,每次保存时记录一个版本快照,并支持回滚到任意历史版本。使用
watch和computed配合实现。 -
陷阱排查:分析以下代码的问题,并给出修正方案:
vue<script setup> const count = ref(0) const double = computed(() => { count.value++ // 尝试在 computed 中修改数据 return count.value * 2 }) </script> -
深度思考 :为什么
watchEffect不提供旧值?在什么场景下你仍然需要旧值?如何在不使用watch的情况下获取旧值? -
扩展任务 :为表单添加"草稿自动恢复"功能,每当用户输入时,将表单数据保存到
sessionStorage,页面刷新后自动恢复。
下篇预告
第 14 篇我们将学习生命周期钩子,深入理解 Vue 组件从创建到销毁的完整过程:
- 选项式 API 与组合式 API 的生命周期对比
- 父子组件的生命周期执行顺序
- 在组合式 API 中使用生命周期钩子
- 生命周期与异步操作的配合
- 常见的生命周期使用场景与最佳实践
准备好掌握组件生命周期的每一个关键时刻!⏳