Vue3——第 13 篇:计算属性与侦听器——computed 与 watch 的进化

本篇定位 :计算属性和侦听器是 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>

缓存机制的工作原理:

  1. 计算属性首次被访问时,执行 getter 函数并缓存结果
  2. Vue 会追踪 getter 中访问的所有响应式依赖
  3. 当依赖发生变化时,标记计算属性为"脏"(dirty)
  4. 下次访问时,重新执行 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

七、思考与练习

  1. 基础练习 :创建一个 useLocalStorage 组合式函数,使用 watch 自动将数据同步到 localStorage。

  2. 实践任务 :在表单自动保存示例中,增加一个"版本历史"功能,每次保存时记录一个版本快照,并支持回滚到任意历史版本。使用 watch 和 computed 配合实现。

  3. 陷阱排查:分析以下代码的问题,并给出修正方案:

    vue 复制代码
    <script setup>
    const count = ref(0)
    const double = computed(() => {
      count.value++  // 尝试在 computed 中修改数据
      return count.value * 2
    })
    </script>
  4. 深度思考 :为什么 watchEffect 不提供旧值?在什么场景下你仍然需要旧值?如何在不使用 watch 的情况下获取旧值?

  5. 扩展任务 :为表单添加"草稿自动恢复"功能,每当用户输入时,将表单数据保存到 sessionStorage,页面刷新后自动恢复。


下篇预告

第 14 篇我们将学习生命周期钩子,深入理解 Vue 组件从创建到销毁的完整过程:

  • 选项式 API 与组合式 API 的生命周期对比
  • 父子组件的生命周期执行顺序
  • 在组合式 API 中使用生命周期钩子
  • 生命周期与异步操作的配合
  • 常见的生命周期使用场景与最佳实践

准备好掌握组件生命周期的每一个关键时刻!⏳

相关推荐
怒放的生命19911 小时前
本地用 pnpm 省磁盘,团队继续用 npm:极简实践
前端·npm·node.js
用户117953654781 小时前
企业团队编程软件怎么选:AI编程工具的协作与落地能力对比
javascript
闲来没事抠鼻屎1 小时前
标签打印 8 个尺寸公式:小票、价签、吊牌、面单通用
vue.js
believed_breadfruit1 小时前
我开源了一个HTML 分页导出工具,解决了复杂页面的内容截断、跨页错乱和长文档白屏问题
前端·javascript·github
福狼1 小时前
从“网墙”到柔性曲面:一次 WebGPU 波浪的逆向分析与重建
前端·逆向
宝哥聊编程2 小时前
主流 Agent 平台对比:Coze、Dify、飞书、扣子、OpenClaw 落地实操手册
前端·人工智能·飞书
时间的情敌2 小时前
Vue3 + Cesium 实战:本地上传 GLB 模型、模型上叠加 GLB、点击控制透明度
前端·vue.js·cesium
拖孩2 小时前
流量主门槛从 500 降到 100,我的小程序上线 18 小时够到了线
前端·后端·微信小程序
深蓝AI2 小时前
编码 Agent 的界面味靠提示词压不住:Impeccable 用无模型规则拦同一套模板
前端·程序员