Skip to content

组合式 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 也一样,思路可以拆成三步:

  1. 从 vue 导入 watch;
  2. 准备一个要侦听的响应式数据,比如 const count = ref(0);
  3. 调用 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 时,回调就会执行。

调试:看控制台到底打印了什么 ​

这类代码不需要靠猜,直接看控制台最清楚。转写稿里多次出现“刷新一下”“点一下按钮”“看打印区域”的调试动作,这里也照这个方式做:

  1. 保存代码,刷新页面,先打开浏览器控制台。
  2. 刚进入页面时,控制台不应该有输出。因为 watch 默认是惰性的,初始值不会触发回调,只有 count 真正发生变化才执行。
  3. 点击按钮让 count 从 0 变成 1,控制台应出现:count发生了变化,老值为0,新值为1。
  4. 再点一次,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>

这段代码做了三件事:

  1. 用 ref(0) 创建响应式数据 count;
  2. 用 watch(count, callback) 侦听 count;
  3. 在第三个参数中写 { 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 会自动解包,写成 &#123;&#123; count &#125;&#125; 即可;脚本区域必须写 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 } 即可,两个可以同时写在一个对象里。

常见错误 ​

  1. 现象:父组件传响应式变量,子组件却收到字符串字面量。原因:属性前漏写 :。修法:改为 :msg="msg"。
  2. 现象:子组件修改父组件数据后视图不更新,或解构 props 后失去响应。原因:直接解构 defineProps() 得到普通值。修法:保留 props.xxx 访问,或用 toRefs(props);确认 Vue 版本是否支持响应式 props 解构。
  3. 现象:子组件 emit 后父组件方法不执行。原因:事件名两端不一致,或未用 defineEmits 声明。修法:统一事件名,声明并调用同一名称。
  4. 现象:watch 监听 ref 对象后,修改内部属性不触发回调。原因:默认浅层监听。修法:加 { deep: true },或监听具体 getter。
  5. 现象:watch([a, b], (newVal) => newVal[0]) 取值异常。原因:多源监听回调参数仍是数组,误当单值使用。修法:按数组解构 ([newA, newB], [oldA, oldB])。
  6. 现象:父组件通过模板引用调用子组件方法报 is not a function。原因:子组件未暴露该方法。修法:子组件用 defineExpose({ validate }) 暴露,父组件在挂载后调用。
  7. 现象:模板引用拿不到 DOM,.value 为 null。原因:写成 :ref、绑定名不一致,或在挂载前访问。修法:用 ref="divRef",在 onMounted 或 nextTick 后访问。

练习 ​

  1. 用 ref 定义 count 和 name,用 watch 同时监听两者,开启 immediate,打印新旧值数组。
  2. 用 reactive 定义 form = { keyword: '', page: 1 },只监听 keyword 变化并打印新值,避免全量 deep 监听。
  3. 实现子传父:子组件按钮点击后 emit 一个事件并携带对象数据,父组件接收后追加到列表,要求使用 defineEmits。
  4. 实现模板引用:父组件获取子组件实例并调用 validate();子组件内部有 validate 和 reset,只允许父组件调用 validate。
  5. 纠错:指出并修复以下问题——父组件写 :ref="childRef"、子组件未写 defineExpose、watch 监听对象内部字段未开 deep 也未写 getter。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
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

小结 ​

  1. <script setup> 中 defineProps、defineEmits、defineExpose 是编译宏,直接使用,不要从 vue 导入。
  2. 父传子先绑定后接收;响应式变量必须用 : 绑定,kebab-case 与 camelCase 要对应。
  3. 子传父先声明事件再 emit,事件名两端必须一致;其本质是触发父组件函数。
  4. 模板引用先声明 ref 变量,模板用 ref="name" 绑定,挂载后再访问 .value。
  5. 子组件内部属性和方法默认封闭,父组件要访问必须通过 defineExpose 显式暴露。
  6. watch 监听对象内部字段需 deep 或 getter;多源监听回调参数为数组。
  7. 日常优先用 ref;reactive 适合对象,简单类型不要用 reactive。
  8. 排错先看控制台警告,再查 Vue Devtools 中的 props、emits 和 exposed。