主题
组合式 API(二)
本页对应 组合式 API,共 6 段视频,约 45 分钟。
学习目标
- 能够使用
defineProps接收父组件传递的静态数据与ref响应式数据,并在模板和脚本区域正确访问。 - 能够使用
defineEmits声明并触发自定义事件完成子传父,并说明“子组件调用父组件方法”的本质。 - 能够使用
ref获取 DOM 对象和组件实例,并通过defineExpose暴露指定属性或方法供父组件访问。 - 能够对比
reactive与ref的差异,并根据数据类型与使用场景选择合适写法。 - 能够使用
watch侦听单个或多个数据,并正确配置immediate与deep选项。 - 能够对比选项式 API 与组合式 API 在父子通信、模板引用等场景下的写法差异。
知识点
先看共同点:二者都用于生成响应式数据
为什么需要:在组合式 API 中,响应式数据不再像选项式那样天然挂在 data 上,而是需要主动创建。reactive 和 ref 都是创建响应式数据的入口,所以先别急着比较优劣,先明确它们解决的是同一类问题。
怎么想:看到“需要响应式”时,先判断数据形态:是简单类型,还是对象/数组等引用类型;再决定用 reactive 还是 ref。笔记给的第一条就是“都是用来生成响应式数据”,说明二者不是互斥的两套体系,而是不同封装层次和适用边界。
怎么做:对象型状态可以想到 reactive,简单类型或希望统一写法时想到 ref。实际选择时不要只记 API 名字,而要记“数据类型 + 访问方式”这两个判断点。
易错点:不要把 reactive 和 ref 当成完全等价、可以随便替换的两个函数;也不要因为都叫“响应式”,就忽略它们在参数类型和访问方式上的差异。
reactive 的边界:不能处理简单类型
为什么需要:明确 reactive 的边界,能避免把字符串、数字、布尔值硬塞进去,写出“看起来创建了响应式数据、实际却不可追踪”的代码。本节笔记直接指出:reactive 不能处理简单类型的数据。
怎么想:reactive 更适合对象、数组这类引用类型;简单类型没有对象属性可供代理追踪,所以应换用 ref。这里要区分“能传进去”与“能得到符合预期的响应式效果”,不要只做参数能否传入的表面判断。
怎么做:遇到 name、count、flag 这类简单类型,优先用 ref;遇到一组相关字段组成的对象状态,再考虑 reactive。如果团队统一用 ref,对象也可以交给 ref 管理。
易错点:不要用 reactive 包字符串或数字后,还期待它在模板和脚本中正常追踪;也不要在需要整体替换对象时忽略 reactive 的引用约束。转写稿没有给出 reactive 的对照调试输出,因此这里不虚构实验现象,只按笔记结论说明边界。
ref 的类型支持更好,但必须通过 .value 访问修改
为什么需要:ref 能同时覆盖简单类型和对象类型,参数类型支持更宽,适合作为统一入口;但它把值放在 .value 里,脚本中读写必须遵守这个规则,否则容易“改了却没反应”。
怎么想:把 ref 理解成一个带 .value 的响应式容器。模板中 Vue 会自动解包,脚本中则要显式写 .value。转写稿视频 2 的调试过程可以印证:先绑定纯静态字符串,子组件显示固定内容;改用 ref 生成响应式数据后,三秒后修改 message.value,子组件内容同步从旧值变为新值,说明 ref 生成的是可追踪的响应式对象。
怎么做:脚本里用 const message = ref('...') 创建,读取写 message.value,修改也写 message.value = '...';模板中直接使用 message 即可。转写稿里“rap 函数”“点 value”等说法,应纠正为 ref 函数和 .value。
易错点:脚本中忘记 .value 是最常见错误;模板中又不要机械地多写 .value。另外,不要把“模板自动解包”误解成“脚本里也能省略 .value”。
ref 内部实现依赖 reactive
为什么需要:理解这层关系,能避免把二者看成毫无关联的两个 API。笔记指出:ref 函数内部的实现依赖于 reactive 函数。
怎么想:ref 是更上层的统一封装:简单类型用 .value 容器管理,对象值在内部可以借助 reactive 做深层响应式处理。日常开发不需要手写内部实现,但要知道 ref 并不是和 reactive 完全并列、毫无关系的另一套机制。
怎么做:写业务时仍按公开 API 使用:简单类型和对象都可以用 ref;需要对象属性级操作时也可以用 reactive。不要把内部依赖关系当成业务代码里必须手动模拟的步骤。
易错点:不要因为“ref 内部依赖 reactive”就推导出“ref 不能处理简单类型”,这正好与笔记结论相反;也不要展开编造源码细节。转写稿没有讲内部源码,这里只按笔记说明依赖关系。
实际工作中的推荐:优先使用 ref
为什么需要:实际项目里,统一写法能降低记忆负担和协作成本。笔记明确建议:推荐使用 ref 函数,减少记忆负担。
怎么想:如果一会儿 reactive、一会儿 ref,开发者就要不断判断“这个变量要不要 .value”“这个对象能不能整体替换”。统一优先 ref 后,规则更稳定:脚本里认准 .value,模板里自动解包。
怎么做:新写的组合式 API 状态优先用 ref;对象状态也可以用 ref 包起来。只有当团队已有明确约定,或某段对象状态用 reactive 更顺手时,再局部使用 reactive。转写稿中模板引用也使用 ref 函数生成对象,并通过 .value 获取 DOM,这能帮助理解 ref 的 .value 访问习惯,但它属于模板引用场景,不要和“响应式数据对比”混为一谈。
易错点:不要因为推荐 ref 就在同一文件里随意混用,导致 .value 漏写;也不要因为 reactive 有边界就完全否定它。推荐 ref 是工程上的统一策略,不是把 reactive 说成错误 API。
先明确这一节要解决什么问题
ref 让数据变成响应式的:count 变了,模板里用到它的地方会自动更新。但模板更新只是“视图层”的事。如果数据变化后,我们还想在脚本里做点别的事情,比如打印日志、发请求、做校验、存草稿,就需要一个“盯着某个数据”的机制,这就是 watch。
这一节笔记只做一件事:侦听单个 ref 数据。注意转写稿这一段主要在讲父传子、子传父和模板引用,并没有直接讲 watch;下面只借用其中“组合式 API 先导入函数、再调用函数”的讲解习惯,以及“打印出来看结果”的调试习惯,不把无关内容硬套进来。
组合式 API 里 watch 的调用思路
在组合式 API 中,很多能力不是挂在组件实例上的,而是需要先从 vue 里导入函数,再在 script setup 里直接调用。转写稿里讲接收 props 时反复说“需要通过一个函数来进行接收”,讲模板引用时也说“先调函数得到对象,再绑定”,本质都是这个套路。
watch 也一样,思路可以拆成三步:
- 从
vue导入watch; - 准备一个要侦听的响应式数据,比如
const count = ref(0); - 调用
watch(源, 回调),在回调里拿到新值、老值。
这里的关键是:第一个参数传“源”本身,不是传 .value。count 是一个 ref 对象,watch 需要这个对象才能建立依赖;如果写成 count.value,传进去的只是当前那个数字 0,watch 就没有办法盯着它变化了。
动手写:导入、创建数据、注册侦听
笔记里的代码就是最小闭环:
vue
<script setup>
// 1. 导入watch
import { ref, watch } from 'vue'
const count = ref(0)
// 2. 调用watch 侦听变化
watch(count, (newValue, oldValue)=>{
console.log(`count发生了变化,老值为${oldValue},新值为${newValue}`)
})
</script>逐行看:
import { ref, watch } from 'vue':ref用来创建响应式数据,watch用来侦听变化,两个都要导入。const count = ref(0):创建了一个ref,初始值是0。在脚本里访问它的值要写count.value,但在模板里 Vue 会自动解包,直接写count即可。watch(count, (newValue, oldValue) => { ... }):第一个参数是count这个ref对象;第二个参数是回调函数。回调的第一个参数是变化后的新值,第二个参数是变化前的老值,顺序不要写反。- 回调里的模板字符串用
${oldValue}、${newValue}把两个值拼进日志里,方便观察。
如果模板里有一个按钮,点击时执行 count++,那么 count 从 0 变成 1 时,回调就会执行。
调试:看控制台到底打印了什么
这类代码不需要靠猜,直接看控制台最清楚。转写稿里多次出现“刷新一下”“点一下按钮”“看打印区域”的调试动作,这里也照这个方式做:
- 保存代码,刷新页面,先打开浏览器控制台。
- 刚进入页面时,控制台不应该有输出。因为
watch默认是惰性的,初始值不会触发回调,只有count真正发生变化才执行。 - 点击按钮让
count从0变成1,控制台应出现:count发生了变化,老值为0,新值为1。 - 再点一次,
count从1变成2,控制台应出现:count发生了变化,老值为1,新值为2。
如果第一次点击没有输出,先检查 watch 的第一个参数是不是写成了 count.value;如果打印出来的新值和老值反了,检查回调参数顺序是不是写成了 (oldValue, newValue)。
另外,转写稿是口语转写,里面有些同音字需要纠正:比如“红函数”实际是“函数”,“pros”实际是“props”,“rap”实际是“ref”。听录播或看转写稿时,按真实 API 名称理解,不要被口误带偏。
易错点小结
- 不要传
count.value:watch(count, ...)侦听的是ref对象;watch(count.value, ...)传的是普通值,失去响应式追踪意义。 - 回调参数顺序是
(newValue, oldValue):新值在前,老值在后。 - 初始不执行是正常现象:
watch默认只在变化后触发;如果业务需要立即执行一次,那是另一个配置项的事,本节先不展开。 - 脚本里取值要
.value:回调参数已经是普通值,直接用newValue、oldValue即可,不要再加.value。 - 只侦听单个数据时,源直接写
count:这一节的范围就是单个ref,先把这一个点用熟,再扩展其他写法。
说明:转写稿与本节笔记的对应关系
提供的转写稿主要讲组合式 API 下的父传子、子传父、模板引用等内容,并没有出现“侦听多个数据”的推导和调试过程。为避免编造,下面只依据本节笔记中的一句话和示例代码做扩写;转写稿中与“侦听多个数据”无关的内容不强行混入。本节笔记也没有包含图片,因此正文不添加图片。
为什么需要侦听多个数据
为什么需要:当组件里有多个响应式数据,并且它们变化后要执行同一段逻辑时,如果分别写多个 watch,代码会重复,维护也麻烦。把多个数据放进同一个侦听器,可以让“多个数据中任意一个变化”都进入同一个回调。
怎么想:watch 的第一个参数不一定只能是一个响应式数据,也可以写成数组。数组里按顺序放入要侦听的数据,回调里拿到的“新值”和“旧值”也会按同样顺序组成数组。
怎么做:先导入 watch 和 ref,定义 count、name 两个响应式数据,然后调用 watch([count, name], 回调)。笔记中的核心写法是:
vue
<script setup>
// 1. 导入watch
import { ref, watch } from 'vue'
const count = ref(0)
const name = ref('cp')
// 2. 调用watch 侦听变化
watch([count, name], ([newCount, newName],[oldCount,oldName])=>{
console.log(`count或者name变化了,[newCount, newName],[oldCount,oldName])
})
</script>易错点:数组里的数据顺序要和回调参数解构的顺序一致;newCount 对应 count 的新值,newName 对应 name 的新值,旧值同理。不要误以为回调第一个参数只代表某一个数据。
数组侦听的回调参数怎么理解
为什么需要:多个数据侦听时,回调参数不再是单个值,而是“新值数组”和“旧值数组”。不理解这一点,容易把 newCount、newName 取错。
怎么想:可以把 watch([count, name], callback) 想成:只要 count 或 name 任意一个变化,就调用 callback。callback 的第一个参数是 [新的 count, 新的 name],第二个参数是 [旧的 count, 旧的 name]。
怎么做:可以直接接收两个数组,也可以像笔记代码那样在参数位置解构:
js
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
// 这里使用 newCount、newName、oldCount、oldName
})易错点:解构时要注意括号位置;第二个参数是旧值数组,不要漏写。若只关心新值,也可以只写第一个参数,但笔记示例同时展示新旧值,便于对照。
原代码中的易错点与修正
为什么需要:笔记示例保留了核心用法,但原样代码中的 console.log 存在模板字符串未闭合的问题,实际运行前需要修正,否则会影响调试。
怎么想:watch 的数组写法本身没有问题;问题在输出语句。原代码里反引号开始后没有结束反引号,且把变量表达式直接写在了字符串内容里。更稳妥的写法是把说明文字和数组分开输出。
怎么做:保留原示例的侦听结构,修正输出语句,例如:
vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
const name = ref('cp')
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
console.log('count或者name变化了', [newCount, newName], [oldCount, oldName])
})
</script>易错点:不要为了“能打印”而随意改变侦听数组的顺序;修正的只是输出语句,核心仍是 watch([count, name], ...)。另外,模板字符串要用成对反引号,普通字符串和数组参数要分清。
小结:写数组侦听时检查什么
为什么需要:数组侦听写法短,但顺序、解构、输出语句都容易出错,写完后按清单检查能减少调试成本。
怎么想:先确认“要同时侦听哪些数据”,再确认“回调里新值、旧值分别怎么取”,最后检查输出和业务逻辑是否使用了正确的变量。
怎么做:检查三件事:第一,watch 第一个参数是否为数组;第二,数组元素顺序是否与回调解构顺序一致;第三,console.log 或业务代码是否正确使用 newCount、newName、oldCount、oldName。
易错点:本节只讲“第一个参数可以改写成数组的写法”,不要把数组侦听和深度侦听、立即执行等其它配置混在一起;笔记未涉及的内容不额外扩展。只要掌握“多个数据放数组、回调按顺序取新旧值”,本节的核心就完成了。
先说明本节范围
本节笔记的核心是 watch 的 immediate 选项:在侦听器创建时立即触发一次回调,之后响应式数据变化时继续执行回调。需要说明的是,你给的转写稿主要讲的是组合式 API 下的父子通信、模板引用等内容,并没有出现 immediate 的调试片段。因此下面不把无关内容硬套进来,而是围绕笔记中的 watch 示例,把“为什么需要、怎么想、怎么做、易错点”讲清楚,并给出可操作的验证思路。
为什么需要 immediate
默认情况下,watch 是“变化后才执行”的。也就是说,如果写成:
js
watch(count, (newValue, oldValue) => {
console.log(newValue, oldValue)
})那么组件第一次渲染时,count 从 0 开始,回调不会执行;只有 count 真正发生变化后,回调才会被触发。
这适合“只关心后续变化”的场景。但有些场景希望在侦听器创建时先执行一次,例如:
- 页面初始化时先根据当前值打印一次日志;
- 初始化时先根据当前
count做一次同步; - 后续值变化时,再继续执行同样的逻辑。
如果每次都手动在 watch 外面再调用一遍回调,逻辑会重复。immediate: true 就是让侦听器创建时先立即执行一次,之后继续侦听变化。
怎么想:immediate 是 watch 的配置项
watch 的第三个参数是一个配置对象。immediate 放在这个对象里,而不是放在回调参数里:
js
watch(source, callback, {
immediate: true
})可以把它理解成:先立即执行一次回调,然后进入正常侦听状态。它不是让数据“立即变化”,也不是让回调“只执行一次”;它只影响回调第一次执行的时机。
怎么做:保留笔记中的代码
vue
<script setup>
// 1. 导入watch
import { ref, watch } from 'vue'
const count = ref(0)
// 2. 调用watch 侦听变化
watch(count, (newValue, oldValue)=>{
console.log(`count发生了变化,老值为${oldValue},新值为${newValue}`)
},{
immediate: true
})
</script>这段代码做了三件事:
- 用
ref(0)创建响应式数据count; - 用
watch(count, callback)侦听count; - 在第三个参数中写
{ immediate: true },让侦听器创建时立即执行一次回调。
因此刷新页面后,控制台会先输出一次回调内容;之后只要 count 发生变化,回调还会继续执行。这正是笔记中“响应式数据变化之后继续执行回调”的含义。
调试与验证思路
可以先不加 immediate,观察默认行为:组件初始化时控制台没有输出;当 count 被修改后,才输出新旧值。
然后加上:
js
{
immediate: true
}再次初始化组件,应该能先看到一次立即执行的回调输出;继续修改 count,回调仍然会再次执行。这样就能区分“初始化立即执行一次”和“后续变化继续执行”两件事。
需要注意,初始立即执行时,oldValue 通常不是上一次的旧值,而是 undefined;因为此时还没有“变化前”的值。不要误以为初始执行也会拿到一个合理的旧值。
易错点
第一,immediate 要写在 watch 的第三个参数对象里,不要写成第二个参数,也不要拼错。第二,immediate: true 不是 deep: true;前者控制“创建时是否立即执行”,后者控制“是否深度侦听对象内部变化”,两者解决的不是同一个问题。第三,侦听 ref 时直接传 count,不要传 count.value,否则会失去对响应式引用的正确侦听。第四,回调参数顺序是 (newValue, oldValue),打印时不要把新旧值写反。第五,immediate 只保证创建时先执行一次,不会阻止后续变化继续触发回调。
理解这几点后,immediate 的用法就很清晰:需要初始化先跑一次,就用它;后续变化继续侦听,是 watch 本身的能力。
先看问题:为什么直接改 count 不触发
本节转写稿主要讲组合式 API 的父子通信、模板引用等内容,没有涉及 watch 的 deep 推导;因此下面不硬套无关转写内容,而是围绕本节笔记代码和注释展开。
为什么需要:监听一个 ref 对象时,我们常以为“对象里任何属性变了都应该触发回调”。但笔记明确指出:watch 监听的 ref 对象默认是浅层侦听,直接修改嵌套属性不会触发回调。
怎么想:先区分“整体替换”和“内部修改”。state.value = { count: 1 } 是替换 .value;state.value.count++ 是修改对象内部属性。浅层侦听只关注外层引用是否变化,不深入对象内部。
怎么做:先按第一段代码运行:watch(state, () => { console.log('数据变化了') }),点击按钮执行 state.value.count++,观察回调是否执行。代码注释已经给出结论:直接修改不会引发回调执行。
易错点:不要把“修改了数据”等同于“watch 一定收到”。嵌套属性变化在默认浅层模式下不会触发;也不要误以为 state.value.count++ 改变了 state 这个 ref 本身。
vue
<script setup>
// 1. 导入watch
import { ref, watch } from 'vue'
const state = ref({ count: 0 })
// 2. 监听对象state
watch(state, ()=>{
console.log('数据变化了')
})
const changeStateByCount = ()=>{
// 直接修改不会引发回调执行
state.value.count++
}
</script>理解 watch 的浅层侦听
为什么需要:理解浅层侦听,才能知道什么时候该加 deep,什么时候不该加。否则遇到“数据明明变了但回调没执行”会误判为 watch 失效。
怎么想:把 watch(state, cb) 想成“盯着 state.value 这一层”。当 state.value 被整体换掉时,外层引用变了,回调有机会执行;当只是 state.value.count 变化时,外层还是同一个对象,默认不会继续往里看。
怎么做:调试时在回调里打印固定文案,在按钮事件里只改 count。先不加 deep,确认控制台没有输出;这能复现笔记中的“浅层”行为。若想验证整体替换,可另写 state.value = { count: 1 } 做对照,但本节笔记没有给出这段代码,实际练习时可自行加一行观察。
易错点:deep 不是“监听某个属性”的替代品。它解决的是递归侦听对象内部变化;如果只想监听 count,更精确的写法应是监听 getter,例如 watch(() => state.value.count, ...)。本节先掌握 deep 的开关作用。
开启 deep 的写法
为什么需要:当业务确实需要对象内部任意层级变化都触发回调时,就要显式开启深度侦听。笔记第二段代码正是这个目的。
怎么想:watch 的第三个参数是配置对象。把 { deep: true } 放在回调之后,表示让侦听器深入对象内部。此时 state.value.count++ 属于被侦听对象内部的变化,回调可以触发。
怎么做:保留同样的 state 和按钮逻辑,只把 watch 改成 watch(state, () => { console.log('数据变化了') }, { deep: true })。点击按钮后观察控制台出现“数据变化了”,与第一段形成对照。
易错点:deep 要写在第三个参数对象里,不要写成 watch(state, cb, true);也不要忘记逗号和大括号。另一个常见错误是开了 deep 后仍以为回调参数一定包含新值旧值——本节回调没有接收参数,先不展开。
vue
<script setup>
// 1. 导入watch
import { ref, watch } from 'vue'
const state = ref({ count: 0 })
// 2. 监听对象state 并开启deep
watch(state, ()=>{
console.log('数据变化了')
},{deep:true})
const changeStateByCount = ()=>{
// 此时修改可以触发回调
state.value.count++
}
</script>对照调试与使用建议
为什么需要:两段代码几乎一样,唯一差别是 { deep: true }。通过对照调试,能把“现象—原因—配置”连起来,而不是死记结论。
怎么想:先跑无 deep 版本,点击按钮,预期看不到“数据变化了”;再跑有 deep 版本,点击按钮,预期看到输出。这样就能确认问题不在按钮、不在 count++,而在 watch 的侦听深度。
怎么做:保留两个版本分别测试,或在一个文件里先注释 deep 再放开。观察按钮点击次数与回调输出次数是否对应。调试时只改一个变量:要么改 deep,要么改修改方式,不要同时改多处。
易错点:深度侦听会递归检查对象内部,对象大、层级深时开销更高,不要无脑给所有 watch 加 deep。如果只需要监听某个字段,优先用 getter 精确监听;如果确实要监听整个对象内部变化,再使用 { deep: true }。本节的核心结论就是:ref 对象默认浅层,嵌套属性修改不触发;需要时显式开启 deep。
先看差异:父传子仍是“先传后接”

选项式里,父传子是“父组件在子组件标签上绑属性,子组件用 props 选项接收”;组合式没有变通信本质,只把接收语法换成了 defineProps 宏函数。
为什么需要:父子通信是组件拆分后的第一需求,必须明确数据从父流向子。
怎么想:先想“传什么、叫什么名字”,再想“子组件怎么接”。名字要一一对应。
怎么做:父组件绑定 name="father name",子组件用 defineProps 声明 name;模板直接使用,脚本区通过 props.name 访问。
易错点:props 名字要对上;短横线命名如 father-message 在接收时转驼峰 fatherMessage;不要直接修改 props。
vue
<!-- 父组件 App.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import Son from './Son.vue'
const message = ref('this is father message')
onMounted(() => {
setTimeout(() => { message.value = 'this is new father message' }, 3000)
})
function getTitle(title) { console.log(title) }
</script>
<template>
<Son name="father name" :father-message="message" @get-title="getTitle" />
</template>vue
<!-- 子组件 Son.vue -->
<script setup>
import { onMounted } from 'vue'
const props = defineProps({
name: { type: String },
fatherMessage: { type: String }
})
const emit = defineEmits(['get-title'])
onMounted(() => console.log(props))
function send() { emit('get-title', '我是来自子组件中的数据') }
</script>
<template>
<div>{{ name }} / {{ fatherMessage }}</div>
<button @click="send">发送数据给父组件</button>
</template>传递响应式数据:把静态值换成 ref 对象
为什么需要:静态字符串不会变,实际页面常要传会变化的数据。
怎么想:绑定方式不变,只把绑定的值从普通字符串换成 ref 产生的响应式对象。
怎么做:父组件 const message = ref(...),模板写 :father-message="message";子组件仍用 fatherMessage 接收。调试时可在 onMounted 里 3 秒后修改 message.value,观察子组件是否同步变化。
易错点:响应式绑定不要漏冒号;模板里写 message,不要写 message.value;子组件接收名要用驼峰。
子传父:先绑定事件,再触发事件
为什么需要:子组件产生数据后,常要通知父组件更新。
怎么想:选项式用 this.$emit,组合式用 defineEmits 先声明事件,再拿到 emit 方法触发。本质仍是“子组件调用父组件方法”。
怎么做:父组件 @get-title="getTitle";子组件 const emit = defineEmits(['get-title']),点击时 emit('get-title', 数据)。调试时点击按钮,父组件函数能打印传回的数据。
易错点:defineEmits 里的事件名要与模板 @get-title、emit('get-title') 匹配;事件名建议全小写短横线;defineEmits 是编译宏,通常无需导入。转写稿还演示了“用 props 传函数”的写法,说明只要子组件调用了父组件方法,也能实现子传父,但常规仍用自定义事件。
模板引用:获取 DOM 与组件实例
为什么需要:表单校验、聚焦、调用子组件方法时,需要拿到 DOM 或组件实例。
怎么想:选项式常用 this.$refs;组合式要先 ref(null) 建变量,再在模板上用同名 ref 绑定,最后通过 .value 访问。
怎么做:DOM 在 onMounted 后打印 divRef.value;组件实例用 testRef.value。子组件自定义属性和方法默认封闭,必须 defineExpose({ name, setName }) 显式暴露,父组件才能调用。
易错点:模板 ref="divRef" 与变量名必须一致;DOM 未挂载时拿不到;组件实例默认拿不到内部自定义内容,需 defineExpose;不要照搬选项式 this.$refs 习惯。
vue
<script setup>
import { ref, onMounted } from 'vue'
const divRef = ref(null)
const testRef = ref(null)
onMounted(() => {
console.log(divRef.value)
console.log(testRef.value)
testRef.value.setName()
})
</script>
<template>
<div ref="divRef">我是 DOM</div>
<Test ref="testRef" />
</template>vue
<!-- 子组件 Test.vue -->
<script setup>
import { ref } from 'vue'
const name = ref('This is test')
function setName() { console.log('setName 被调用') }
defineExpose({ name, setName })
</script>小结
本节对比的核心不是“父子通信流程变了”,而是写法变了:选项式靠 props、this.$emit、this.$refs;组合式靠 defineProps、defineEmits、ref 加 defineExpose。调试时多看控制台打印的 props 对象、事件参数和组件实例 Proxy,能快速确认数据是否真正传到位。
代码示例
示例一:ref 与 reactive 对比
vue
<!-- RefVsReactive.vue -->
<template>
<div>
<p>ref 简单类型 count:{{ count }}</p>
<p>ref 对象 user.name:{{ user.name }}</p>
<p>reactive 对象 state.count:{{ state.count }}</p>
<p>reactive 数组 state.list:{{ state.list }}</p>
<button @click="count++">count + 1</button>
<button @click="user.name = 'cp'">改 user.name</button>
<button @click="state.count++">state.count + 1</button>
<button @click="state.list.push(state.list.length + 1)">push</button>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
// ref:参数类型支持更好,简单类型、对象都能包
const count = ref(0)
const user = ref({ name: 'tom' })
// reactive:只能处理对象/数组,不能处理简单类型
const state = reactive({ count: 0, list: [] })
// 脚本区域访问:ref 必须 .value,reactive 直接访问
console.log(count.value, user.value.name)
console.log(state.count, state.list)
</script>要点:
ref内部实现依赖reactive,所以两者都能生成响应式数据,区别只在“能不能装简单类型”。- 模板里
ref会自动解包,写成{{ count }}即可;脚本区域必须写count.value,这是最常踩的坑。 reactive(0)是无效的,简单类型只能用ref。- 实际工作中推荐统一用
ref,减少记忆负担,只有明确要传对象时再用reactive。
示例二:watch 侦听单个数据与多个数据
vue
<!-- WatchBase.vue -->
<template>
<div>
<p>count:{{ count }}</p>
<p>name:{{ name }}</p>
<button @click="count++">count + 1</button>
<button @click="name = 'cp'">改 name</button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
const name = ref('cp')
// 1. 侦听单个数据
watch(count, (newValue, oldValue) => {
console.log(`count发生了变化,老值为${oldValue},新值为${newValue}`)
})
// 2. 侦听多个数据:第一个参数改写成数组
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
console.log(`count或者name变化了`, [newCount, newName], [oldCount, oldName])
})
</script>要点:
- 侦听多个数据时,第一个参数写数组,回调的第一个参数也是数组,要按顺序解构,顺序和侦听数组保持一致。
- 回调的
newValue / oldValue是解包后的值,不需要再.value。 - 笔记原文里的模板字符串少了结尾的反引号,会导致语法报错,这里已修正,抄代码时注意别漏。
示例三:watch 的 immediate 与 deep
vue
<!-- WatchOptions.vue -->
<template>
<div>
<p>count:{{ count }}</p>
<p>state.count:{{ state.count }}</p>
<button @click="count++">count + 1</button>
<button @click="changeStateByCount">state.count + 1</button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
const state = ref({ count: 0 })
// 3. immediate:侦听器创建时立即执行一次回调
watch(count, (newValue, oldValue) => {
console.log(`count发生了变化,老值为${oldValue},新值为${newValue}`)
}, {
immediate: true
})
// 4. deep:watch 监听的 ref 对象默认是浅层侦听
watch(state, () => {
console.log('数据变化了')
}, { deep: true })
const changeStateByCount = () => {
// 不开启 deep 时,这行不会触发回调
state.value.count++
}
</script>要点:
- 第三个参数是配置对象,
immediate: true让回调在创建时立刻跑一次,此时 oldValue 是undefined,业务里做判断要留意。 watch监听 ref 对象默认是浅层侦听,直接改state.value.count++不触发回调;开启deep: true才会触发。- 只传
deep而不传immediate时,配置对象写成{ deep: true }即可,两个可以同时写在一个对象里。
常见错误
- 现象:父组件传响应式变量,子组件却收到字符串字面量。原因:属性前漏写
:。修法:改为:msg="msg"。 - 现象:子组件修改父组件数据后视图不更新,或解构 props 后失去响应。原因:直接解构
defineProps()得到普通值。修法:保留props.xxx访问,或用toRefs(props);确认 Vue 版本是否支持响应式 props 解构。 - 现象:子组件
emit后父组件方法不执行。原因:事件名两端不一致,或未用defineEmits声明。修法:统一事件名,声明并调用同一名称。 - 现象:
watch监听ref对象后,修改内部属性不触发回调。原因:默认浅层监听。修法:加{ deep: true },或监听具体 getter。 - 现象:
watch([a, b], (newVal) => newVal[0])取值异常。原因:多源监听回调参数仍是数组,误当单值使用。修法:按数组解构([newA, newB], [oldA, oldB])。 - 现象:父组件通过模板引用调用子组件方法报
is not a function。原因:子组件未暴露该方法。修法:子组件用defineExpose({ validate })暴露,父组件在挂载后调用。 - 现象:模板引用拿不到 DOM,
.value为null。原因:写成:ref、绑定名不一致,或在挂载前访问。修法:用ref="divRef",在onMounted或nextTick后访问。
练习
- 用
ref定义count和name,用watch同时监听两者,开启immediate,打印新旧值数组。 - 用
reactive定义form = { keyword: '', page: 1 },只监听keyword变化并打印新值,避免全量deep监听。 - 实现子传父:子组件按钮点击后
emit一个事件并携带对象数据,父组件接收后追加到列表,要求使用defineEmits。 - 实现模板引用:父组件获取子组件实例并调用
validate();子组件内部有validate和reset,只允许父组件调用validate。 - 纠错:指出并修复以下问题——父组件写
:ref="childRef"、子组件未写defineExpose、watch监听对象内部字段未开deep也未写 getter。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 组合式API下的父传子-基础的数据传递 | 09:43 |
| 2 | 组合式API下的父传子-传递响应式数据 | 04:38 |
| 3 | 组合式API下的子传父-基础使用 | 07:59 |
| 4 | 组合式API下的父传子-子传父 | 08:24 |
| 5 | 组合式API-模版引用-获取dom对象 | 06:01 |
| 6 | 组合式API-模版引用获取组件实例(defineExpose) | 08:58 |
小结
<script setup>中defineProps、defineEmits、defineExpose是编译宏,直接使用,不要从vue导入。- 父传子先绑定后接收;响应式变量必须用
:绑定,kebab-case 与 camelCase 要对应。 - 子传父先声明事件再
emit,事件名两端必须一致;其本质是触发父组件函数。 - 模板引用先声明
ref变量,模板用ref="name"绑定,挂载后再访问.value。 - 子组件内部属性和方法默认封闭,父组件要访问必须通过
defineExpose显式暴露。 watch监听对象内部字段需deep或 getter;多源监听回调参数为数组。- 日常优先用
ref;reactive适合对象,简单类型不要用reactive。 - 排错先看控制台警告,再查 Vue Devtools 中的 props、emits 和 exposed。