要点
七组语法,每组记一条”为什么”:默认 ref(读写踪迹明确)、派生用 computed(缓存)、副作用三选一(watch 显式、watchEffect 自动)、props 只读 emits 通知(单向数据流)、v-model 是语法糖、插槽把内容的决定权还给调用方、组合式函数是逻辑复用单位。
一、ref 与 reactive:为什么默认 ref
两个都能造响应式状态,官方推荐默认 ref。先看 reactive 的三个失联时刻:
import { reactive } from 'vue'
let form = reactive({ name: 'ada', tags: ['vue'] })
const { name } = form // 解构:name 是普通字符串,从此与 form 无关
form = reactive({ name: 'bob' }) // 替换引用:旧代理失联,界面对不上新对象
logIt(form.name) // 传出的是值的拷贝,收到方改它不影响 form
reactive 返回代理对象(Proxy),响应性挂在这个代理上;解构、整体替换、按值传参都会丢掉代理。ref 没有 these 陷阱,代价是 script 里要写 .value:
| 维度 | ref | reactive |
|---|---|---|
| 支持的类型 | 任意(数字、布尔、字符串……) | 仅对象与集合 |
| 整体替换 | list.value = newList 安全 |
替换引用即失联,只能逐属性改 |
| 解构/传参 | ref 本身就是引用,不解体 | 解构即丢失响应性 |
| 书写成本 | script 里处处 .value | 少敲几个字符 |
默认 ref 的理由汇总:.value 让”这一行在改状态”在代码里显式可见;整表替换安全;组合式函数返回值约定统一(见第七节)。reactive 留给”一组字段的表单”这类天然聚合、且不会整体替换的对象。
二、computed、watch、watchEffect:派生与副作用
const query = ref('vue')
// computed:派生值。依赖不变就用缓存,不重算
const results = computed(() => searchAll(query.value))
// watch:显式点名要盯谁。默认惰性(首次不执行),回调拿得到新旧值
watch(query, (next, prev) => saveHistory(next, prev))
// watchEffect:立即执行一次,执行中读到的响应式数据自动成为依赖
watchEffect(() => { document.title = `搜索:${query.value}` })
为什么派生值用 computed 而不是方法调用:模板每次重渲染都会调方法,computed 缓存后依赖不变直接返回旧值——列表过滤、合计这类计算,差距会在数据量上来后显现。
| API | 首次何时跑 | 依赖怎么来 | 典型用途 |
|---|---|---|---|
| computed | 首次被读取时 | getter 里自动收集 | 派生状态(过滤、合计、格式化) |
| watch | 默认惰性,变化才跑 | 第一个参数显式声明 | 要新旧值对比、异步请求、防抖 |
| watchEffect | 立即执行一次 | 回调执行时自动收集 | 把状态同步到外部(标题、canvas、第三方库) |
三、props 与 emits:单向数据流
<!-- Item.vue:defineProps/defineEmits 是编译器宏,无需 import -->
<script setup>
defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['remove'])
</script>
<template>
<li @click="emit('remove', item.id)">{{ item.text }}</li>
</template>
为什么子组件不许改 props:数据属于父组件,”谁声明谁修改”,出问题时”这行代码谁改的”才有唯一答案。子组件想改,就 emit 事件让主人动手——链路多一步,换来的是调试时数据流向永远可追溯。反模式:子组件里给 props 对象的属性赋值,界面可能”碰巧”更新(引用共享),但 Vue 会警告,且父组件下一次渲染就把你的修改覆盖回去。
四、v-model:双向绑定的语法糖本质
<input v-model="text" />
<!-- 完全等价于: -->
<input :value="text" @input="text = $event.target.value" />
记住糖的配方,”双向不生效”才能拆开排查。自定义组件接 v-model,Vue 3.4 起用 defineModel 宏:
<!-- SearchBox.vue -->
<script setup>
const model = defineModel() // 声明后组件即可被父级 v-model
function clear() { model.value = '' }
</script>
<template><input v-model="model" /><button @click="clear">清空</button></template>
本质仍是:父传 modelValue、子 emit update:modelValue。糖可以吃,配方必须背。
五、插槽:内容分发
<!-- Card.vue:布局归组件,内容归调用方 -->
<template>
<div class="card">
<header><slot name="title">默认标题</slot></header>
<main><slot /></main>
</div>
</template>
<!-- 调用:内容在调用方写,结构由组件定 -->
<Card><template #title>周报</template>本周完成了三件事…</Card>
作用域插槽(scoped slot)反向把数据递出来,列表组件最常用:
<!-- List.vue -->
<li v-for="t in todos"><slot :item="t">{{ t.text }}</slot></li>
<!-- 调用方拿到 item 自行决定渲染成什么 -->
<List v-slot="{ item }"><b :class="{ done: item.done }">{{ item.text }}</b></List>
为什么用插槽而不是 props 传渲染函数:插槽是声明式的模板语法,作用域插槽让”数据在子、模板在父”不必靠函数传来传去。
六、生命周期:多数时候只需要两个钩子
setup 执行点在 beforeCreate 与 created 之间,所以 script setup 里根本不需要这两个钩子。真正常用的是:
onMounted(() => { // DOM 就绪:发首个请求、初始化图表、聚焦输入框
inputEl.value?.focus()
})
onUnmounted(() => clearInterval(timer)) // 与注册成对出现,防泄漏
| 钩子 | 时机 | 常用度 |
|---|---|---|
| onBeforeMount / onMounted | 挂载前 / DOM 就绪 | onMounted 高频 |
| onBeforeUpdate / onUpdated | 更新前 / 更新后 | 低——想对变化做反应,watch 通常更合适 |
| onBeforeUnmount / onUnmounted | 卸载前 / 卸载后 | onUnmounted 高频,清理副作用 |
七、组合式函数:逻辑复用单位
// useCounter.js:一段"状态 + 操作"的打包,use 前缀是社区约定
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const inc = () => count.value++
return { count, inc } // 返回 ref 而非解构值,响应性才带得出去
}
// 使用方解构安全:每个字段都是独立 ref
const { count, inc } = useCounter(10)
为什么不用 Vue 2 时代的 mixins:mixin 的字段来源不可见、命名冲突静默覆盖;组合式函数的来源就是 import 语句,类型可推导。与 React hooks 的差别在执行次数:React hooks 每次渲染重跑、依赖调用顺序;Vue 的 setup 只跑一次,没有”不能在 if 里调用”这类规则——但这不等于没有规则,依赖收集仍是 Vue 的隐性契约。两套模型的来龙去脉见《React Hooks 心智模型》,React 为何选择整组件重跑见《React 渲染与重渲染》。
最佳实践
状态声明默认 ref,reactive 只在”聚合对象且不整体替换”时用;派生优先 computed,副作用按”要不要新旧值、要不要立即执行”在 watch 与 watchEffect 里选;组件边界靠 props/emits 与插槽表达,跨层级共享再考虑 provide/inject 或 Pinia。
常见问题
Q1: reactive 解构后界面不更新了
解构拿到的已不是代理。局部修复用 toRefs(form) 把每个字段转成 ref 再解构;新代码直接改用 ref 声明各字段更省心。
Q2: watch 侦听 ref 对象的嵌套属性不触发
ref 包对象时 watch 默认浅侦听,只盯 .value 的引用替换。要么传 getter 指名道姓 watch(() => form.value.name, cb),要么 { deep: true } 整树盯防。
练习与实践
练习一:把第一个应用里的”剩余计数”改成 watch 实现,再改回 computed,体感两者的分工。练习二:抽一个 useLocalStorage(key) 组合式函数,读写待办列表。评估标准:能说出每处选择”为什么不是另外两个 API”。
延伸阅读
逐条查签名、找坑,看下一篇《Vue 3 基础知识参考字典》;工程与调试回到系列首篇《Vue 3 环境搭建与工具链》。
版本核实(撰写时检索,2025-03):本文 API 均为 Vue 3 稳定特性;defineModel 于 Vue 3.4 转稳定;Vue 当前稳定线 3.5.x,3.6(含 Vapor Mode)仍在实验、未发布。具体以官方文档为准。