主题
组合式 API(三)
本页对应 组合式 API,共 6 段视频,约 1 小时 10 分。
学习目标
- 能够使用
provide/inject在祖先与后代组件之间跨层传递普通数据和响应式数据,并正确匹配唯一 key、保持响应式传递。 - 能够通过向下传递函数的方式,实现底层组件修改顶层组件数据,并说明该方案与父子组件通信的适用场景差异。
- 能够使用
defineExpose暴露子组件属性或方法,并通过模板引用获取组件实例,完成弹框开关控制或表单数据回填。 - 能够基于已有项目环境完成依赖安装、启动项目,并识别和调用 mock 接口完成列表数据获取与渲染。
- 能够按照“获取 ID—确认提示—调用删除接口—刷新列表”的流程实现删除功能。
- 能够按照“打开弹框—回填表单—双向绑定编辑—调用更新接口—触发子传父事件刷新列表”的流程实现编辑功能。
知识点
为什么需要子传父
子组件不能直接修改父组件的数据,这是单向数据流的基本约束。更合理的做法是:谁的数据谁负责改。子组件只负责把“事情发生了”通知父组件,父组件收到通知后,再决定要不要改数据、要不要重新请求列表。转写稿里的编辑案例就是这样:子组件完成更新接口调用后,需要让父组件重新拉取列表,否则页面上的列表不会刷新。这个“通知父组件”的动作,就是子传父。

基本思路:父绑定事件,子触发事件
本节笔记的基本思想只有两步:
- 父组件中给子组件标签通过
@绑定事件; - 子组件内部通过
emit方法触发事件。
可以把它理解成“父组件提前留好一个回调入口,子组件在合适的时机按约定触发”。转写稿里也强调,子组件更新完成后要通知父组件刷新列表,这属于典型的子传父场景。事件名建议语义明确,例如 update:list,父组件绑定后调用自己的 getList 方法。
父组件怎么做
父组件要在子组件标签上绑定自定义事件,并把事件触发后要执行的方法挂上去。示例中父组件绑定 @update:list="getList",意思是:子组件一旦触发 update:list,父组件就执行自己的 getList。
vue
<!-- 父组件中使用子组件 -->
<Edit
@update:list="getList"
/>为什么这样写?因为刷新列表的数据在父组件里,父组件最清楚自己的 getList 怎么调用。怎么想?不要把“刷新列表”的逻辑塞进子组件,子组件只报告“更新完成”。怎么做?在子组件标签上绑定事件名,并指向父组件方法。易错点:父组件绑定的事件名必须和子组件 emit 的名称完全一致;方法名要存在;不要把事件写成普通属性。
子组件怎么做
子组件要先声明自己会触发哪些事件,再在接口调用完成后触发。转写稿中调试时发现:编辑确认后弹窗关闭了,但列表没有刷新,于是补上子传父逻辑。子组件内部写法如下:
js
const emit = defineEmits(['update:list'])
// 更新接口调用完成后
emit('update:list')为什么需要 defineEmits?Vue 3 组合式 API 中没有直接使用 this.$emit 的写法,需要先声明事件,得到 emit 方法。怎么想?子组件只负责“通知”,不直接改父组件列表。怎么做?先声明 update:list,再在更新成功后触发。易错点:忘记 defineEmits 导致 emit 不可用;事件名大小写、冒号、连字符不一致;emit 放错位置,比如接口还没成功就触发;在子组件里直接修改父组件数据。
调试过程与完整链路
转写稿中的调试过程很清楚:先实现弹窗打开、回填、确认更新;确认后关闭弹窗,但发现列表没有更新,于是补上子传父。父组件绑定 @update:list="getList",子组件在更新接口完成后触发 emit('update:list')。测试时点击第二个数据并确认,列表发生变化,同时在 Network 中看到刷新列表的接口被调用。这个过程说明:子传父不是“子组件改父组件”,而是“子组件通知,父组件执行自己的方法”。事件名写成 update:list 这种全小写、中间用连字符连接的方式,便于绑定和阅读。
易错点小结
第一,父组件绑定的事件名和子组件 emit 的名称必须一致;第二,Vue 3 组合式 API 要先 defineEmits,不能想当然写 this.$emit;第三,emit 要放在接口成功之后,保证父组件刷新时拿到的是新数据;第四,子组件不要越权直接改父组件状态,保持“谁的数据谁负责改”的边界。掌握这两步,子传父就能稳定用于列表刷新、弹窗关闭通知等场景。
先明确本节范围
本节笔记标题是“1. 基本使用”,给出的两步是:调用 ref 函数生成一个 ref 对象;通过 ref 标识把该对象绑定到标签。这里讲的是模板引用 ref 的基本使用,不是 provide/inject。转写稿开头也明确说“上节课讲完了模板引用”,随后才进入 provide/inject 的跨层通信;因此本节不把 provide/inject 的 provide/inject 步骤混进来,避免把两个知识点写串。笔记没有给出完整代码,只给出两步流程和一张示意图,下面按这两步展开。

知识点一:调用 ref 函数生成 ref 对象
为什么需要:在组合式 API 中,模板是声明式的,通常不直接写 DOM 查询。但有些场景必须拿到真实 DOM 或组件实例,例如让输入框聚焦、读取元素尺寸、调用子组件暴露的方法。此时需要一个“容器”来接住模板引用,ref 就是最常用的容器。
怎么想:先不要急着操作 DOM,而是想“我要引用谁”。在 setup 或 <script setup> 中声明一个 ref 对象,初值通常给 null,表示“现在还没挂载,等模板渲染后再放进来”。这个对象本身是响应式的,真正要访问被引用的元素或组件时,通过它的 .value 取。
怎么做:按笔记第一步,调用 ref 函数生成一个 ref 对象,并给它一个语义清晰的名字,例如表示输入框、容器或子组件。名字一旦确定,后面模板里的 ref 标识要与之对应。此时只是“准备好容器”,还没有真正绑定。
易错点:不要把它当成普通变量直接使用;在脚本中访问时通常要写 .value。初值不要随意写成字符串或数字,除非你明确知道要区分“未绑定”和“已绑定”。另外,ref 变量名要和模板中的 ref 标识保持一致,否则容器永远是空的。
知识点二:通过 ref 标识绑定 ref 对象到标签
为什么需要:只声明 ref 对象还不够,Vue 不知道它要接住哪个标签或组件。必须在模板目标标签上写 ref 标识,Vue 在渲染和挂载过程中才会把对应的 DOM 元素或组件实例放进这个容器。
怎么想:把“声明容器”和“模板绑定”看成一对关系:脚本里有一个 ref 对象,模板里有一个同名 ref 标识,两边对上才会生效。绑定后不要立刻在同步代码里读取,因为此时可能还没挂载;更稳的时机是挂载完成后的生命周期中读取。转写稿后面在讲获取子组件实例时,也是先通过 ref 拿到实例对象,再打印、再操作;如果绑定的是子组件,还要注意组件默认是封闭的,内部属性或方法不会自动暴露。
怎么做:按笔记第二步,在目标标签上通过 ref 标识绑定前面生成的 ref 对象。绑定完成后,在合适的时机通过 ref对象.value 访问。如果引用的是普通 DOM,拿到的是元素;如果引用的是子组件,拿到的是组件实例。转写稿中的调试过程是:先 log 出实例对象,确认里面有没有目标字段;若没有,再检查子组件是否通过 defineExpose 手动暴露。这个调试思路同样适用于本节的基本绑定:先确认“容器里有没有东西”,再确认“东西是不是我要的”。
易错点:第一,模板中的 ref 标识与脚本变量名不一致,导致拿不到;第二,在挂载前读取,得到 null;第三,绑定的是子组件却直接访问内部属性,发现访问不到,此时应检查 defineExpose;第四,同一页面多个 ref 重名,后绑定的会覆盖前一个;第五,在 v-for 中绑定 ref 时,拿到的往往不是单个元素,需要按数组思路处理。转写稿里反复强调“先拿到实例、打印确认、再操作”,就是为了避免凭感觉写。
调试与验证建议
写完两步后,不要只看代码,要做一次最小验证:在挂载完成后的钩子里打印 ref对象.value。如果打印为 null,先查模板 ref 标识是否写对、目标标签是否真的渲染;如果打印到的是组件实例但字段拿不到,查子组件是否暴露;如果打印到 DOM 但操作无效,查操作时机是否过早。转写稿中“先打印实例对象,再决定改属性还是调方法”的过程,本质上就是这种验证思路。对通用组件要谨慎使用模板引用直接改内部状态,能通过方法或事件完成时,优先走公开接口。
小结
本节只需牢牢记住两步:先用 ref 生成容器,再用模板 ref 标识完成绑定。理解“先声明、后绑定、挂载后再访问”的顺序,就能避开大部分空值和拿不到实例的问题。
为什么需要 defineExpose
在 <script setup> 语法糖里,组件内部的属性和方法默认是“封闭”的:父组件即使通过模板 ref 拿到了子组件实例,也不能直接访问子组件里的 dialog、form、testMessage 这些内部数据。笔记里明确说:“默认情况下在 <script setup> 语法糖下组件内部的属性和方法是不开放给父组件访问的,可以通过 defineExpose 编译宏指定哪些属性和方法容许访问。”
为什么需要它?因为父组件有时确实要控制子组件,比如打开子组件里的弹框、让子组件回填表单。但组件封装又要求默认不暴露,避免父组件随意改子组件内部状态。defineExpose 就是折中:只把允许父组件用的东西列成“白名单”。

怎么想:把“父组件要用的东西”显式列出来
怎么想这件事?先判断父组件到底要什么:如果只是控制弹框开关,就暴露 dialog;如果还要让子组件回填数据,就暴露一个方法,比如 changeForm。笔记里说“指定 testMessage 属性可以被访问到”,本质就是把 testMessage 放进 defineExpose 的对象里。
注意转写稿里的调试过程:父组件先通过 ref 拿到子组件实例,打印 editRef.value,确认实例上有没有目标属性;如果默认拿不到,就回到子组件补 defineExpose。这说明 defineExpose 不是自动的,而是手动声明。
易错点是:不要把子组件所有东西都暴露;暴露越多,父组件越容易耦合子组件内部实现。
怎么做:子组件声明,父组件通过 ref 访问
做法分两步。子组件里定义好数据和函数,再调用 defineExpose({ ... }):
vue
<script setup>
import { ref } from 'vue'
const dialog = ref(false)
const form = ref({ name: '', place: '', id: '' })
function changeForm(row) {
form.value.name = row.name
form.value.place = row.place
form.value.id = row.id
}
// 只暴露父组件需要用的属性和方法
defineExpose({
dialog,
changeForm
})
</script>父组件用模板 ref 绑定子组件,挂载后通过 editRef.value 访问:
vue
<Edit ref="editRef" />
<script setup>
import { ref } from 'vue'
const editRef = ref(null)
function handleEdit(row) {
editRef.value.dialog = true
editRef.value.changeForm(row)
}
</script>转写稿里编辑功能的调试就是这样:父组件拿到 editRef.value 后,把 dialog 从 false 改成 true,弹框打开;回填时一开始想直接改子组件的 form,发现对象层级复杂,后来改为在子组件里封装 changeForm,父组件只调用方法并传 row。这正符合“谁的数据谁负责修改”的思路。
易错点:编译宏、.value 与封装边界
第一,defineExpose 是编译宏,不需要 import,也不能在普通函数里随便调用,应放在 <script setup> 顶层。第二,父组件访问实例要写 editRef.value,模板里绑定 ref="editRef" 的变量名要一致。第三,父组件初始 ref(null),子组件挂载后才有实例,不要在挂载前直接访问。第四,暴露 ref 后父组件通过实例赋值可更新,但复杂表单不建议让父组件层层点进去改,像转写稿那样封装 changeForm(row) 更稳。第五,defineExpose 只是“允许访问”,未列出的属性和方法仍然拿不到;笔记里的 testMessage 能访问,是因为它被写进了暴露对象。
最后,defineExpose 适合少量、明确的父子交互;不要因为它方便就把子组件内部状态全暴露,否则组件边界会被破坏。
作用与场景:为什么需要 provide / inject

当组件形成 App -> Father -> Son 这样的三层嵌套时,如果 App 要把数据交给 Son,按父子通信的常规做法,需要 App 先传给 Father,Father 再传给 Son。中间层并不消费这份数据,却被迫充当“二传手”,代码会变啰嗦。provide / inject 解决的就是这种跨层组件通信:顶层组件向任意底层组件传递数据和方法,中间层可以完全绕过。
怎么想:只要两个组件在组件树中存在上下嵌套关系,就可以让上层 provide 提供数据,下层 inject 接收数据。怎么做:先搭出 App 套 Father、Father 套 Son 的结构,确认调试工具里出现嵌套关系,再在 App 中提供、在 Son 中接收。易错点:不要把“顶层/底层”理解成全项目唯一的一对,它们是相对概念;只要存在嵌套关系,就可以构成提供与接收关系。
基础用法:提供静态数据与响应式数据
为什么需要先讲基础用法:跨层通信第一步是能把数据取到。怎么想:provide 接收两个参数,第一个是独一无二的 key,第二个是要传的数据;inject 用同一个 key 接收,返回值就是提供的数据。怎么做:在 App.vue 中写:
vue
<script setup>
import { provide, ref } from 'vue'
import Father from './components/Father.vue'
provide('name-key', '我是App中的name')
const message = ref('我是App中的message')
provide('message-key', message)
</script>
<template>
<Father />
</template>在 Son.vue 中接收并渲染:
vue
<script setup>
import { inject } from 'vue'
const name = inject('name-key')
const message = inject('message-key')
</script>
<template>
<div>来自App:{{ name }}</div>
<div>{{ message }}</div>
</template>转写稿中的调试过程是:刷新页面后,Son 能显示“我是App中的name”;再用定时器修改 message.value,约 3 秒后页面从“我是App中的message”变成“我是一个新的message”,说明 ref 响应式数据跨层传递后仍然保持响应式。
易错点:key 必须匹配;传响应式对象时不要把 ref 写成 .value,否则响应式会丢失;多个 provide 的 key 不能重复。
反向修改:把方法传给底层组件
为什么需要:底层组件有时不仅要显示数据,还要修改顶层数据。怎么想:谁的数据谁负责修改,顶层把“修改数据的方法”一起提供下去,底层只负责调用方法并传参。怎么做:在 App.vue 中提供数据和函数:
vue
<script setup>
import { provide, ref } from 'vue'
const count = ref(100)
const setCount = (newCount) => {
count.value = newCount
}
provide('count-key', {
count,
setCount
})
</script>在 Son.vue 中接收并调用:
vue
<script setup>
import { inject } from 'vue'
const countObj = inject('count-key')
</script>
<template>
<div>{{ countObj.count }}</div>
<button @click="countObj.setCount(1000)">修改count</button>
</template>调试时先打印 countObj,可以看到它是个对象,里面有 count 和 setCount;点击按钮后,数值从 100 变为 1000。易错点:不要在底层直接改顶层数据,而应调用顶层提供的方法;方法内部仍要通过 count.value 修改。
补充注意:相对概念、选择与限制
为什么需要补充这些:provide / inject 很方便,但滥用会带来维护问题。怎么想:第一,顶层和底层是相对概念,组件树中只要存在上下关系,就可能有多组提供与接收。第二,父子通信本身能解决时,优先用父子通信,provide / inject 更适合小范围跨层通信。第三,它可以被多次使用,并且 inject 取值遵循“就近查找”的链式原则:先找最近的同名 key,没有才继续向上找。怎么做:给 key 起清晰、唯一的名字,避免多层重复 key 导致取值混乱。易错点:组件一旦写了 inject,就要求它的上游必须提供对应 key,否则会找不到并报错;因此通用组件不要随意使用 inject,否则组件不能随便放到任意位置。
先看场景:为什么需要 provide / inject

当组件形成 App → Father → Son 这样的嵌套关系时,如果 App 想把自己的数据给 Son 用,用父子传参就要在 Father 中转一层;层级再深,代码会越来越绕。provide / inject 解决的就是这种“跨层传递普通数据”的问题:上层组件提供数据,下层组件直接接收,中间组件不用参与。
怎么想:先确认两个组件在组件树里存在上下层关系,哪怕中间隔了很多层,也可以用它通信。
怎么做:本节按笔记里的两步走:
- 顶层组件通过
provide函数提供数据; - 底层组件通过
inject函数接收数据。注意笔记里“底层组件通过inject函数提供数据”应理解为“接收数据”。
易错点:所谓“顶层/底层”是相对概念。App 对 Son 是顶层,Father 对 Son 也可以是顶层;只要在组件树中存在上下嵌套关系即可,不是全项目只有一个顶层和一个底层。
顶层提供数据:provide 的两个参数
为什么需要:下层组件要知道“取哪份数据”,所以上层必须先用一个明确的 key 把数据提供出去。
怎么想:provide 接收两个参数:第一个是独一无二的 key,第二个是要传递的数据。
怎么做:在 App 中写:
vue
<script setup>
import { provide } from 'vue'
provide('nameKey', '我是APP中的name')
</script>这里的 nameKey 就是后面接收时用来匹配的标识。转写稿中调试时,先在 App 里调用 provide,再到 Son 里接收,最后在模板中渲染“来自 APP 中的数据”,页面上能显示“我是APP中的name”,说明跨层传递成功。
易错点:key 要唯一且前后一致;不要写成 name-key 接收时却写 nameKey。另外,provide 不一定只能在 App 写,只要某个组件是下层组件的上游,它也可以提供数据。
底层接收数据:inject 按 key 取值
为什么需要:下层组件不能直接访问上层作用域里的变量,必须通过 inject 按 key 取。
怎么想:inject 接收一个参数,通常就是上层 provide 时使用的 key;它会返回对应的值。
怎么做:在 Son 中写:
vue
<script setup>
import { inject } from 'vue'
const name = inject('nameKey')
</script>
<template>
<div>来自 App 中的数据:{{ name }}</div>
</template>易错点:inject 的 key 必须和 provide 完全匹配;如果上游没有提供对应 key,组件就取不到值。也正因为如此,写了 inject 的组件会依赖上游的 provide,通用性会变差,不宜把这类组件随便放到任意位置。
传递响应式数据:传 ref 对象本身
为什么需要:实际开发中要传的往往不是静态字符串,而是会变化的数据。
怎么想:把 provide 的第二个参数换成 ref 对象即可。关键是保持它仍然是响应式对象。
怎么做:在 App 中:
vue
<script setup>
import { provide, ref } from 'vue'
const message = ref('我是APP中的message')
provide('messageKey', message)
setTimeout(() => {
message.value = '我是一个新的message'
}, 3000)
</script>在 Son 中:
vue
<script setup>
import { inject } from 'vue'
const message = inject('messageKey')
</script>
<template>
<div>{{ message }}</div>
</template>转写稿中的调试过程是:刷新后先显示“我是APP中的message”,3 秒后变成“我是一个新的message”,说明传递的是响应式数据,视图会跟着更新。
易错点:provide('messageKey', message) 时不要把第二个参数写成 message.value。传 ref 对象本身,inject 拿到的才是响应式对象;如果传 .value,拿到的只是普通字符串,后续修改不会驱动视图。脚本里修改时仍然要写 message.value,模板中会自动解包。
链式查找与使用边界
为什么需要:理解查找规则,才能避免同名 key 取错数据。
怎么想:如果 App 和 Father 都提供了同名 key,Son 会先就近找自己上游最近的 provide;没有才继续往上找。转写稿中把这种机制称为“就近查找、链式查找”。
怎么做:尽量让 key 语义清晰、避免同名覆盖;跨层传递只用于确实需要跨层的普通数据,父子之间能正常用 props / emit 时,优先用父子通信。
易错点:不要因为 provide / inject 方便就到处用。它适合小范围跨层通信;写了 inject 的组件依赖上游提供数据,放到没有对应 provide 的组件树位置就会出问题。

场景:为什么要跨层传响应式数据
为什么需要:当组件形成 App → Father → Son 这样的三层嵌套时,如果 App 里的数据要传给 Son,用传统的父子通信就要一层层 props 往下传,中间层 Father 可能根本不关心这份数据。provide / inject 就是为这种跨层通信准备的:上层组件提供数据,下层组件直接接收,绕过中间层。
怎么想:先别急着写响应式,先确认通信链路能不能通。转写稿里的推导是:先搭出 App 套 Father、Father 套 Son 的嵌套关系,然后在 App 中用 provide 提供数据,在 Son 中用 inject 接收。先传一个静态字符串,比如 nameKey,验证 Son 能拿到“我是APP中的name”,说明跨层通路没问题。接着再考虑:如果传的是会变化的数据,还能不能保持响应式?
怎么做:在顶层组件调用 provide,第一个参数是独一无二的 key,第二个参数是要传的数据;在底层组件调用 inject,参数用同一个 key 接收。静态字符串可以这样传,但它只是值本身,不会自动追踪变化。要传响应式数据,关键就是本节笔记这句话:在调用 provide 函数时,第二个参数设置为 ref 对象。
易错点:不要以为 provide 只能传字符串;它也可以传对象、函数、ref。但传普通值得到的是静态快照,后续上层变量变化,下层不会跟着变。key 必须上下对应,转写稿里反复强调“必须匹配”“独一无二”。
做法:provide 的第二个参数传 ref,而不是 ref.value
为什么需要:如果写成 provide('messageKey', message.value),传下去的是当前字符串值,Son 里 inject 拿到的是普通字符串,App 后续修改 message.value,Son 不会更新。要让下层仍然持有同一个响应式引用,就要把 ref 对象本身传下去。
怎么想:把 ref 看成一个“响应式盒子”。provide 传盒子,inject 收盒子,盒子里值变化,使用盒子的模板就会更新。转写稿里说得很直接:“为了保持响应式,千万不能加点 value,要保证它是一个响应式的 ref 对象才可以。”
怎么做:顶层先声明 const message = ref('我是APP中的message'),再 provide('messageKey', message);底层 const message = inject('messageKey')。模板里直接写 {{ message }},Vue 会自动解包;如果在脚本里读写,则用 message.value。
vue
<!-- App.vue -->
<script setup>
import { ref, provide } from 'vue'
import Father from './components/Father.vue'
const message = ref('我是APP中的message')
provide('messageKey', message)
</script>
<template>
<Father />
</template>vue
<!-- Son.vue -->
<script setup>
import { inject } from 'vue'
const message = inject('messageKey')
</script>
<template>
<div>来自App:{{ message }}</div>
</template>易错点:provide 第二个参数不要写 message.value;inject 的 key 要和 provide 的 key 完全一致;在脚本中访问 ref 要加 .value,在模板中通常不用手动加。
验证:定时修改 ref,观察底层是否更新
为什么需要:只看初始渲染不能证明响应式还在,必须让顶层数据发生变化,再观察底层组件是否自动更新。
怎么想:转写稿的调试方式是加一个定时器,3 秒后修改 message.value,看 Son 里的文字是否从“我是APP中的message”变成“我是一个新的message”。如果变了,说明 inject 拿到的是响应式 ref;如果不变,就要检查是不是误传了 .value。
怎么做:在 App.vue 中加:
js
setTimeout(() => {
message.value = '我是一个新的message'
}, 3000)刷新页面,先看到“我是APP中的message”,约 3 秒后 Son 中变为“我是一个新的message”,说明跨层传递的响应式数据生效。
易错点:调试时不要只打印一次就下结论;要观察修改后的视图。若使用 setTimeout 修改的是 ref,必须通过 .value 赋值;模板里展示时不要误写成 {{ message.value }} 造成重复解包问题。
补充边界:key 对应、就近查找与使用克制
为什么需要:provide / inject 很方便,但滥用会让组件依赖不清晰。转写稿补充了两个边界:一是 key 要唯一且对应;二是 inject 会沿组件树向上查找,存在“就近查找”的链式原则。
怎么想:如果 Father 也 provide 了同名 key,Son 会先找离自己最近的提供者,而不是一定拿 App 的。所以同名 key 要小心。另一个边界是:写了 inject 的组件,默认要求上游必须有对应 provide,否则拿不到数据,组件通用性会变差。
怎么做:跨层通信只在确实需要绕过中间层时使用;普通父子通信仍用 props / emit;key 命名尽量语义化并保持唯一;不要在通用组件里随意 inject 一个强依赖的数据。
易错点:把 provide / inject 当成全局状态到处用;同名 key 造成就近覆盖;忘记上游必须提供数据。掌握这些边界,跨层传递响应式数据才既好用又可控。
代码示例
示例 1:provide / inject 跨层传递普通数据与响应式数据
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>provide / inject 基础用法</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
.box { border: 1px solid #ccc; padding: 10px; margin: 8px; border-radius: 4px; }
.app { border-color: #42b883; }
.father { border-color: #35495e; }
</style>
</head>
<body>
<div id="app"></div>
<script>
const { createApp, ref, provide, inject, defineComponent } = Vue;
// 最底层:孙子组件
const GrandSon = defineComponent({
setup() {
// 接收数据:key 必须和顶层 provide 的 key 完全一致
const name = inject('name-key');
const message = inject('message-key');
return { name, message };
},
template: `
<div class="box">
<p>来自 App 的 name:{{ name }}</p>
<p>来自 App 的 message:{{ message }}</p>
</div>
`
});
// 中间层:Father,不参与通信
const Father = defineComponent({
components: { GrandSon },
template: `<div class="box father">我是 Father<GrandSon /></div>`
});
// 顶层:App
const App = defineComponent({
components: { Father },
setup() {
// 1. 跨层传递普通数据(静态字符串)
provide('name-key', '我是 App 中的 name');
// 2. 跨层传递响应式数据:第二个参数直接给 ref 对象
const message = ref('我是 App 中的 message');
provide('message-key', message);
// 3 秒后修改数据,验证孙子组件会跟着变
setTimeout(() => {
message.value = '我是一个新的 message';
}, 3000);
return {};
},
template: `<div class="box app">我是 App<Father /></div>`
});
createApp(App).mount('#app');
</script>
</body>
</html>关键行
provide('name-key', '我是 App 中的 name'):顶层组件用provide提供数据,第一个参数是独一无二的 key(字符串),第二个参数是要传的数据。inject('message-key'):底层组件用inject接收,key 必须与顶层完全对应才能拿到值。provide('message-key', message):要传响应式数据,第二个参数直接放 ref 对象。- 中间层
Father完全不用写任何代码,数据跨过它直接到达孙子组件。
易错点
provide第二个参数传 ref 时千万不要写.value,写成.value就变成普通值,响应式丢失。- key 是字符串,两边拼写必须一致,不一致拿到的是
undefined。 provide不是响应式 API,它是一次性提供,但里面放的 ref 对象本身是响应式的,所以数据变化能传递下去。inject只能拿到上游提供的数据,上游没写provide会报错/拿不到值。
示例 2:provide / inject 传递方法,实现底层修改顶层数据
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>provide / inject 传递函数反向修改数据</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
.box { border: 1px solid #ccc; padding: 10px; margin: 8px; border-radius: 4px; }
button { margin-top: 8px; }
</style>
</head>
<body>
<div id="app"></div>
<script>
const { createApp, ref, provide, inject, defineComponent } = Vue;
const GrandSon = defineComponent({
setup() {
// 接收到的是一个对象:{ count: ref, setCount: fn }
const countObj = inject('count-key');
// 顶层返回 ref,模板中会自动解包
const count = countObj.count;
// 谁的数据谁负责改:底层只负责把顶层给的方法调起来
const handleClick = () => {
countObj.setCount(1000);
};
return { count, handleClick };
},
template: `
<div class="box">
<p>count 的值:{{ count }}</p>
<button @click="handleClick">点我把 count 改成 1000</button>
</div>
`
});
const Father = defineComponent({
components: { GrandSon },
template: `<div class="box">我是 Father<GrandSon /></div>`
});
const App = defineComponent({
components: { Father },
setup() {
const count = ref(100);
const setCount = (newCount) => {
count.value = newCount;
};
// 数据和修改数据的方法放进同一个对象一起 provide,内聚性更好
// 注意:对象里的 ref 不要加 .value,否则响应式丢失
provide('count-key', { count, setCount });
return { count };
},
template: `<div class="box">我是 App,count:{{ count }}</div>`
});
createApp(App).mount('#app');
</script>
</body>
</html>关键行
provide('count-key', { count, setCount }):把数据 + 修改数据的方法包成一个对象传下去,两者是一体的,内聚性更好。countObj.setCount(1000):底层只负责调用方法并传参,参数会作为newCount自动进入顶层的setCount。- 顶层
return { count }:setup 顶层返回的 ref 在模板中会自动解包,可以直接写{{ count }}。
易错点
- 底层拿到对象后,
countObj.count仍是 ref,在 JS 里取值要.value,但作为 setup 顶层返回给模板后会自动解包;直接写{{ countObj.count }}会渲染成对象。 - 不要在底层直接改
countObj.count.value = 1000,这违背“谁的数据谁负责改”的思路,也会让数据流向混乱。 - 传对象时若写成
provide('count-key', { count: count.value, setCount }),响应式就丢了。
示例 3:多个 provide 的链式查找(就近原则)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>provide / inject 链式查找</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
.box { border: 1px solid #ccc; padding: 10px; margin: 8px; border-radius: 4px; }
</style>
</head>
<body>
<div id="app"></div>
<script>
const { createApp, provide, inject, defineComponent } = Vue;
const GrandSon = defineComponent({
setup() {
// 就近查找:先找最近的 Father,找到就停,找不到再往上层 App 找
const name = inject('name-key');
return { name };
},
template: `<div class="box">孙子拿到的 name:{{ name }}</div>`
});
const Father = defineComponent({
components: { GrandSon },
setup() {
// 把下面这行注释掉,孙子就会拿到 App 提供的 name
provide('name-key', '我是 Father 提供的 name');
return {};
},
template: `<div class="box">我是 Father<GrandSon /></div>`
});
const App = defineComponent({
components: { Father },
setup() {
provide('name-key', '我是 App 提供的 name');
return {};
},
template: `<div class="box">我是 App<Father /></div>`
});
createApp(App).mount('#app');
</script>
</body>
</html>关键行
Father里也调了一次provide('name-key', ...),与 App 的 key 相同。- 孙子
inject('name-key')时按就近原则取到 Father 的值,Father 没有提供时才继续向上找 App。
易错点
- 顶层与底层是相对概念:只要在组件树中存在嵌套的上下游关系,就能用
provide/inject,不限于“只有一个顶层一个底层”。 - 因为存在链式查找,同名 key 容易被覆盖,所以 key 命名要尽量唯一,使用时保持克制,父子通信还是优先用 props / emit。
- 写了
inject的组件,其上游必须有人provide,否则拿到undefined甚至报错,组件通用性变差,不适合做通用组件。 - 兄弟/无嵌套关系的两个组件之间不存在上下游关系,不能用这套 API 通信。
常见错误
- 现象:底层 inject 拿不到数据或拿到旧值 → 原因:provide/inject 的 key 不一致,或多层 provide 同名 key 触发就近覆盖 → 修法:把 key 抽成常量统一引用;排查同名 provide,明确使用最近一层还是改名隔离。
- 现象:跨层传的 count 不再响应式 → 原因:provide 时误写
count.value,把 ref 解包成了普通值 → 修法:传ref对象本身;接收后按 ref 规则使用,模板中注意自动解包。 - 现象:父组件
ref.value为null,或调用子组件方法报undefined→ 原因:模板 ref 名称与变量不一致、子组件未defineExpose、或在挂载前访问 → 修法:同名绑定 ref,子组件显式暴露方法/属性,在onMounted后或事件回调中访问。 - 现象:子组件
emit后父组件无反应 → 原因:未用defineEmits声明事件,或父组件@事件名与emit名称不一致 → 修法:先声明 emits,父子事件名统一;检查 kebab-case 与 camelCase 对应关系。 - 现象:编辑弹窗回填时代码越写越复杂,父组件直接改子组件 form 容易失效 → 原因:直接操作子组件内部状态,未暴露方法或暴露了过多内部字段 → 修法:子组件暴露
open(row)/fillForm(row)一类方法,父组件只传 row 调方法,由子组件改自己的表单。 - 现象:删除/编辑接口成功,但列表仍旧 → 原因:只调了删除/编辑接口,没有重新拉列表,或子组件未通知父组件刷新 → 修法:删除后调
getList;编辑保存成功后emit('update'),父组件监听后刷新列表。 - 现象:Element Plus 确认框沿用
this.$confirm报错 → 原因:Vue3 组合式 API 中没有this,确认框需按需导入 → 修法:import { ElMessageBox } from 'element-plus',调用ElMessageBox.confirm(...)。 - 现象:请求地址跑到第三方域名,baseURL 像被覆盖 → 原因:请求 URL 写成带协议的绝对路径,绝对路径优先于 baseURL → 修法:项目内接口用相对路径;第三方接口再显式传完整地址,并联调 Network 确认。
练习
- 用
provide/inject实现:App 向孙组件传递count和setCount,孙组件点击按钮把count改为 1000;同时说明为什么不能传count.value。 - 实现父组件点击“编辑”打开子组件弹窗:要求子组件用
defineExpose暴露open(row),父组件通过模板 ref 调用,并完成当前行数据回填。 - 实现子组件保存成功后通知父组件刷新列表:用
defineEmits声明事件,父组件监听并调用getList;写出关键步骤和事件名。 - 排查题:孙组件
inject('nameKey')得到undefined,且 Father 与 App 都provide了同名nameKey。请说明可能原因、就近查找影响和修复方案。 - 用 Element Plus 实现删除确认:Vue3 组合式写法导入
ElMessageBox,确认后调删除接口并刷新列表;指出与 Vue2this.$confirm写法的差异。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 组合式API-provide和inject的基础用法 | 09:09 |
| 2 | 组合式API-provide和inject反向修改数据 | 14:55 |
| 3 | 综合案例-环境准备 | 06:31 |
| 4 | 综合案例-列表渲染 | 05:26 |
| 5 | 综合案例-删除功能实现 | 08:21 |
| 6 | 综合案例-编辑功能实现 | 25:45 |
小结
provide/inject适合局部、上下游关系明确的跨层通信,通用组件不要默认依赖上游provide。provide/inject的 key 应统一管理,避免同名 key 造成链式就近覆盖和排查困难。- 跨层传响应式数据时传
ref对象本身,使用端按 ref 规则取值。 - 父组件获取子组件能力应优先用“模板 ref +
defineExpose”,并且只暴露必要方法。 - 子通知父统一用
emit,事件声明、触发、监听三处名称必须一致。 - 编辑弹窗推荐“父传 row,子组件暴露方法回填”,不要在父组件里直接改子组件内部表单状态。
- 删除/编辑流程应固定检查:取 ID 或行数据、确认、调接口、关闭弹窗或刷新列表。
- 请求异常先看 Network,重点确认相对路径、绝对路径与 baseURL 的关系。