主题
vue3之useAttrs与vue2之$refs-$chil(Lesson26 3/3)
本页对应 Lesson26,共 6 段视频,约 1 小时 10 分。
学习目标
- 能够使用
useAttrs获取父组件传入的非props属性与自定义事件,并说明同名属性在props声明后不再出现在attrs中的优先级规则。 - 能够使用 Vue2 的
$refs、$children、$parent获取组件实例,并调用其数据或方法完成父子组件通信。 - 能够使用 Vue3 的
ref配合defineExpose访问子组件暴露的数据,并使用$parent获取父组件暴露的数据,说明 Vue3 中$children不再可用。 - 能够使用组合式 API 的
provide与inject实现祖先与后代组件间的跨层级数据共享和修改,并判断直接包裹、隔代包裹及兄弟组件中的可访问范围。 - 能够实现作用域插槽:子组件通过
<slot>向父组件传递数据,父组件使用v-slot、#或解构语法接收并渲染,并能使用默认插槽简写。
知识点
先看目标:父组件把属性和事件传给子组件
为什么需要:父组件经常要把普通数据、DOM 属性或事件处理函数交给子组件。Vue3 里,如果子组件没有用 props 显式声明这些内容,它们会进入 attrs。理解 useAttrs,就是理解“没有声明成 props 的传参,最后去了哪里、怎么用”。
怎么想:先按笔记把父组件 App.vue 和子组件 Child.vue 建好。父组件传 a="1" b="2" c="3" :count="count",还通过 @click="fn" 传了一个自定义点击处理函数。子组件先不声明 props,观察 $attrs 和 useAttrs() 的输出。
怎么做:保留本节代码:
vue
<!-- src/App.vue -->
<template>
<h3>我是父组件App{{count}}</h3>
<hr/>
<Child @click="fn" a="1" b="2" c="3" :count="count"/>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import {ref} from "vue";
const count = ref(100);
const fn = function(a,b,c,d){
console.log("fn",a,b,c,d);
count.value = a+b+c+d+count.value;
}
</script>
<style scoped>
</style>vue
<!-- src/components/Child.vue -->
<template>
<h3>我是子组件Child</h3>
<p>{{$attrs}}</p>
<p>{{$attrs.onClick}}</p>
<button @click="$attrs.onClick(1,2,3,4)">点我</button>
</template>
<script lang="ts" setup>
// 模板中可以使用$attrs
// 可以使用组合API->useAttrs
// props的优先级高于attrs(如果在props当中声明了一个数据状态,那么attrs中不会存在与其同名的。)
// 相较于vue2中的$attrs而言通过useAttrs获取到的不仅仅是属性也包含自定义的事件。
// 即:useAttrs运行的结果是vue2中的$attrs与$listeners的一个结合体。
import {useAttrs} from "vue";
const $attrs = useAttrs();
// const props = defineProps(["count"]);
console.log($attrs);
</script>
<style scoped>
</style>易错点:父组件写的是 @click="fn",它进入子组件 attrs 后通常以 onClick 形式存在,不是 click。另外,子组件模板里 {{$attrs}} 能看到属性集合,但不要误以为所有内容都会“直观显示”。
模板中的 $attrs:能看到什么,看不到什么
为什么需要:调试传参时,第一步是确认父组件到底传进来了什么。模板里可以直接用 $attrs,这是最直观的观察方式。
怎么想:先渲染 {{$attrs}},应能看到 a、b、c、count 等信息;再渲染 {{$attrs.onClick}}。转写稿里演示时发现页面上似乎“没有显示函数”,这不是没传进来,而是函数直接插值渲染时看不到可读内容。
怎么做:保留 <p>{{$attrs}}</p> 和 <p>{{$attrs.onClick}}</p> 用于观察;真正调用事件时用按钮:
vue
<button @click="$attrs.onClick(1,2,3,4)">点我</button>点击后,父组件 fn 收到 1,2,3,4,执行 count.value = a+b+c+d+count.value,于是 100 + 1 + 2 + 3 + 4 = 110。
易错点:不要因为页面上看不到函数,就判断 onClick 不存在。函数、对象直接插值往往看不到内部结构;要确认应打印到控制台,或通过按钮调用验证。
useAttrs:在 script 中拿到属性与自定义事件
为什么需要:模板里能用 $attrs,但很多时候需要在 script setup 中读取、打印或进一步处理这些内容,这时用组合式 API useAttrs。
怎么想:转写稿中在 Child.vue 里写 const $attrs = useAttrs(); console.log($attrs);。控制台输出是一个 Proxy,里面能看到 a、b、c、count,也能看到 onClick。这说明 Vue3 的 useAttrs 不只是普通属性,还包含自定义事件监听。
怎么做:按笔记引入并使用:
ts
import {useAttrs} from "vue";
const $attrs = useAttrs();
console.log($attrs);按钮仍然可以通过 $attrs.onClick(1,2,3,4) 调用父组件函数。转写稿里也强调:useAttrs() 的结果相当于 Vue2 中 $attrs 与 $listeners 的结合体。
易错点:事件名要按 onClick 这种形式取;不要把它当成 Vue2 的 $listeners 再单独找。调试时先 console.log($attrs),不要只盯着模板插值结果。
props 优先级:声明过的 count 会从 attrs 消失
为什么需要:子组件有时需要把某些传参声明为 props,以便类型、默认值或明确接口。必须知道:一旦声明为 props,同名内容就不会再出现在 attrs 中。
怎么想:在 Child.vue 中临时写 const props = defineProps(["count"]);,再观察 $attrs。转写稿中验证到:原来 $attrs 里的 count 没有了。这和 Vue2 中 props 优先级高于 $attrs 的结论一致。
怎么做:做对比实验时保留 defineProps(["count"]),观察 $attrs 输出;确认后按笔记注释掉,恢复原样。
易错点:不要同时期待 props.count 和 $attrs.count 都存在。声明了 count,就应从 props 取;没声明,才从 attrs 取。
完整调用链与调试结论
为什么需要:useAttrs 的典型价值是:子组件不显式声明 props,也能接收父组件传来的普通属性和事件,并在需要时调用父组件方法。
怎么想:调用链是“父组件传 @click="fn" → 子组件 attrs.onClick → 按钮触发 $attrs.onClick(1,2,3,4) → 父组件 fn 执行 → 修改父组件 count”。转写稿中还验证了子组件通过该调用可以影响父组件状态,父组件界面从 100 变为 110。
怎么做:按本节代码运行,先看模板 $attrs,再看控制台 Proxy,最后点按钮观察 count 变化。若把 count 写进 defineProps,再观察 attrs 中同名项消失。
易错点:不要把 useAttrs 和 props 混用成“同名双份”;不要漏掉事件名大小写形式;调试函数时用调用和 console.log,不要只靠页面插值判断是否存在。
先理解:Vue2 里访问组件实例的三个口子
本节笔记解决的是“父子组件之间直接拿实例”的问题。除了 props 传值、$emit 事件通信,Vue2 还提供了三个实例属性:$refs、$children、$parent。它们适合在父组件里主动取子组件实例、在子组件里取父组件实例,做一些快速读取数据或调用方法的操作。
为什么需要:有时父组件要直接调用子组件的方法,或读取子组件 data;子组件也可能要读取父组件状态。
怎么想:先明确方向——父取子用 $refs 或 $children;子取父用 $parent。
怎么做:在模板上给子组件加 ref,父组件通过 this.$refs.xxx 拿实例;$children 是当前组件的子组件数组;$parent 是父实例。
易错点:这些都不是推荐的常规通信方式,耦合较强;$refs 必须配合 ref 属性;$children 顺序不保证,不能当稳定索引长期依赖。
父取子:$refs 配合 ref 获取子组件实例
笔记里父组件 App.vue 的核心写法是:
vue
<template>
<div>
<h3>父组件App:{{userName}}</h3>
<button @click="getChildInfo">点我获取子组件信息</button>
<hr/>
<Child ref="child"/>
</div>
</template>
<script>
import Child from "@/components/Child";
// 获取子组件实例方案有两种:
// 1-$refs:可以借助ref与$refs获取真实的DOM,与组件结合使用可以获取组件实例。
// 2-$children:获取当前组件下使用的子组件列表(数组)
export default {
name:"App",
components: {Child},
data(){
return {
userName:"张三"
}
},
methods:{
getChildInfo(){
// this.$refs.child 即是子组件Child的实例。
// console.log("getChildInfo",this.$refs.child);
// 获取子组件数据状态
// console.log("getChildInfo",this.$refs.child.userName)
// 调用 子组件的方法
// this.$refs.child.changeUserName("lisi");
// console.log(this.$children[0]);
this.$children[0].changeUserName("wangwu");
}
}
}
</script>为什么需要:父组件想精确拿到某一个子组件,而不是所有子组件时,ref 是最直接的方式。
怎么想:ref="child" 相当于给这个子组件挂了一个名字,父组件通过 this.$refs.child 找到它。转写稿里演示时先打印 this.$refs.child,看到的是一个 Vue 实例,再打印 this.$refs.child.userName 就能读到子组件 data 里的状态,调用 this.$refs.child.changeUserName("lisi") 就能执行子组件方法。
怎么做:模板写 <Child ref="child"/>,方法里写 this.$refs.child.userName 或 this.$refs.child.changeUserName("lisi")。
易错点:ref 名字和 $refs 的键必须一致;如果子组件被 v-if 隐藏,this.$refs.child 可能是 undefined;不要在 created 里急着拿,通常等 mounted 后更稳妥。
父取子:$children 获取子组件数组
笔记里 $children 的用法是:
js
console.log(this.$children[0]);
this.$children[0].changeUserName("wangwu");为什么需要:当父组件下有子组件,但不想额外写 ref 时,$children 可以直接拿到子组件实例列表。
怎么想:它返回数组,this.$children[0] 就是第一个子组件实例。转写稿里打印当前实例时能看到 $children 是一个数组,里面放着子组件实例,于是用 [0] 取到 Child,再调用它的 changeUserName 方法。
怎么做:父组件方法里写 this.$children[0].changeUserName("wangwu"),页面中子组件的 userName 就变成 wangwu。
易错点:$children 的顺序不保证,官方也不推荐依赖它做稳定通信;它只适合临时调试或非常明确的单子组件场景。多个子组件时用索引很容易取错。
子取父:$parent 获取父组件实例
子组件 Child.vue 的笔记代码是:
vue
<template>
<div>
<h3>子组件Child</h3>
<p>userName:{{userName}}</p>
<p>父组件中的userName:{{$parent.userName}}</p>
<button @click="getParent">获取父组件实例</button>
</div>
</template>
<script>
// $parent:可以获取父组件实例
export default {
name:"Child",
data(){
return {
userName:"child"
}
},
methods:{
getParent(){
// console.log("child",this.$parent);
this.$parent.userName="你被我修改了!";
},
changeUserName(v){
this.userName = v;
}
}
}
</script>为什么需要:子组件有时需要读取或修改父组件状态,$parent 可以直接拿到父实例。
怎么想:模板里 {{$parent.userName}} 能显示父组件的 userName;方法里 this.$parent 就是父组件实例。转写稿里点击按钮后打印 this.$parent,能在实例上看到父组件的 userName,随后执行 this.$parent.userName="你被我修改了!",父组件页面内容随之变化。
怎么做:子组件方法中写 this.$parent.userName = "你被我修改了!"。
易错点:$parent 只指向直接父组件,层层往上写会形成强耦合;根组件或某些边界场景下可能拿不到父实例。实际项目优先用 props / $emit,不要滥用直接改父状态。
调试过程与选择建议
转写稿的调试顺序很清楚:先在父组件点击按钮,打印 this.$refs.child,确认它是子组件实例;再读 this.$refs.child.userName,确认能拿到子组件数据;再调用 changeUserName,确认能执行子组件方法。接着用 this.$children[0] 取第一个子组件,调用同样方法把名字改成 wangwu。子组件这边点击按钮,打印 this.$parent,确认父实例存在,再修改 this.$parent.userName,父组件显示随之更新。
为什么需要区分:父取子有两种方案,子取父有一种方案,知道边界才不会乱写。
怎么想:精确取某个子组件用 $refs;临时看所有子组件用 $children;子组件回取父组件用 $parent。
怎么做:按笔记代码保留 ref="child"、this.$refs.child、this.$children[0]、this.$parent.userName 即可复现。
易错点:不要把 $children 当成响应式数据源;不要在模板里大量写 $parent;这些 API 是“实例访问”,不是常规数据流,项目里仍应优先 props 和自定义事件。
本节要解决的问题
这一节用一个“父亲和儿子互相借钱”的小例子,串起 Vue3 中父子组件互相访问实例的两种方式:父组件用 ref 拿子组件实例,子组件用 $parent 拿父组件实例。它们不是常规的 props/emit 通信,而是直接访问组件暴露出来的数据,所以必须配合 defineExpose 使用。
为什么需要:有时父组件要直接调用子组件的数据或方法,子组件也可能要主动修改父组件状态。怎么想:父取子,用模板 ref;子取父,用模板中的 $parent。怎么做:先写父子组件,再分别暴露 money。易错点:Vue3 没有 $children,且 <script setup> 中没有 this,不能直接写 $parent。
父组件用 ref 获取子组件:先暴露再访问
为什么需要:父组件点击“向儿子借10元”时,要让子组件的 money 减 10,同时自己的 money 加 10。怎么想:在父组件模板里给子组件加 ref="son",在 <script setup> 中声明 const son = ref() 接收子组件实例;子组件必须用 defineExpose({ money }) 把 money 暴露出去,父组件才能通过 son.value.money 访问。怎么做:代码如下。
vue
<template>
<h3>我是父组件App</h3>
<p>我是父亲,我现在的资产有:{{money}}</p>
<button @click="borrowByChild">向儿子借10元</button>
<hr/>
<Child ref="son"/>
</template>
<script lang="ts" setup>
// 在vue3中没有$children,但是在模块中有$parent.
import Child from "@/components/Child.vue";
import {ref} from "vue";
const money = ref(200);
const son = ref();
const borrowByChild = function(){
// 让儿子的资产减少10元
son.value.money -= 10;
// 老子的资产资产加10元
money.value+=10;
}
defineExpose({money})
</script>
<style scoped>
</style>易错点:son 初始是 undefined,挂载后才有实例;模板 ref="son" 与脚本变量名要对应;子组件没写 defineExpose({ money }) 时,son.value.money 拿不到值,点击按钮不会生效。
子组件用 $parent 获取父组件:模板传参,父组件也要暴露
为什么需要:子组件点击“给父亲100元”时,要让自己减 100、父亲加 100。怎么想:Vue3 模板中可以使用 $parent,但 <script setup> 中没有 this,不能直接写 $parent,所以把 $parent 作为事件实参传给 sendFather。父组件也必须 defineExpose({ money }),否则 parent.money 是 undefined。怎么做:代码如下。
vue
<template>
<h3>我是子组件Child</h3>
<p>我还小,还是个孩子,现在的资产共{{money}}</p>
<button @click="sendFather($parent)">给父亲100元</button>
</template>
<script lang="ts" setup>
import {ref} from "vue";
const money = ref(2000);
const sendFather = function(parent:any){
// 儿子减少100
money.value-=100;
// 父亲增加100
parent.money+=100;
}
defineExpose({money})
</script>
<style scoped>
</style>易错点:父组件 App.vue 中的 defineExpose({ money }) 不能漏;parent.money += 100 能直接加,是因为模板传入的是组件实例代理,暴露的 ref 在实例代理上会自动解包,不要写成 parent.money.value;$parent 只适合简单父子场景,层级和耦合都不稳定。
调试过程与运行结果
按转写稿的调试顺序:先让父亲 money = 200、儿子 money = 2000。第一次点“向儿子借10元”时,儿子没减,原因是子组件没有暴露 money;补上 defineExpose({money}) 后,儿子变为 1990,父亲变为 210。再点“给父亲100元”时,父亲没加,原因是父组件也没有暴露 money;在 App.vue 补上 defineExpose({money}) 后,儿子变为 1890,父亲变为 310。这个过程说明:ref 取子组件、$parent 取父组件,都要求对方组件先暴露数据。
使用边界小结
ref 适合父组件精确获取某个子组件实例;$parent 适合子组件向上找父组件。Vue3 已移除 $children,不能像 Vue2 那样批量拿子组件;需要多个子组件时,应使用多个 ref 或模板 ref 数组。实例访问会破坏组件封装,实际开发中优先使用 props/emit、provide/inject 等显式通信;本节示例用于理解 ref、defineExpose 与 $parent 的基本机制。
为什么需要 provide / inject:跨层级共享数据
父传子用 props,子传父用事件,这在直接父子之间没问题;但爷爷组件要把数据给孙子组件,或者多个后代都要用同一份状态时,逐层 props 会很啰嗦。provide / inject 就是祖先组件向后代组件共享数据的方式:祖先用 provide 提供,后代用 inject 接收。它不替代 props,而是解决跨层级、隔代共享的问题。
怎么想:provide("标识", 数据) 把数据挂到当前组件的作用域里;被它直接或间接包裹的后代,都可以 inject("标识") 取到。数据用 ref 包起来,接收方修改后仍保持响应式。
怎么做:在 App.vue 里定义 token,provide("t", token);在 Child.vue 和 One.vue 里 inject("t")。Child.vue 再定义 age,provide("age", age),给它的后代 One.vue 使用。
App.vue:提供 token,并验证 inject 的方向
vue
<template>
<h3>我是父组件App(爷爷):{{token}}</h3>
<p>age:{{age}}</p>
<hr/>
<Child/>
<hr/>
<Child2/>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import Child2 from "@/components/Child2.vue";
import {ref, provide, inject} from "vue";
const token = ref("appToken");
// 第一个参数是标识,第二个参数是数据状态
provide("t",token);// 提供数据,让自己所包裹的组件(可以隔代)使用。
const age = inject("age");
</script>
<style scoped>
</style>这里 App 提供 t,所以 Child、One 都能拿到。注意 const age = inject("age") 在 App 里拿不到值:age 是 Child 提供的,App 是 Child 的祖先,不是它的后代。转写稿里也专门试过:在祖先里 inject 后代提供的数据会找不到。这是理解作用域的关键:provide 只向下,不向上。
Child.vue:接收并修改 token,再向下提供 age
vue
<template>
<h3 @click="t+='!'">我是子组件Child(儿子):{{t}}</h3>
<hr/>
<One/>
</template>
<script lang="ts" setup>
import One from "@/components/One.vue"
import {inject, provide, ref} from "vue";
const age = ref(100);
const t = inject("t");
provide("age",age);
</script>
<style scoped>
</style>inject("t") 拿到的是 App 提供的 ref,模板里会自动解包,所以 @click="t+='!'" 实际是在修改 token.value。因为 App、Child、One 用的是同一个响应式引用,所以一处修改,多处同步更新。Child 又 provide("age", age),这个 age 只能给 Child 的后代用,One 能拿到。
易错点:不要以为 provide 是全局的;它只对当前组件的后代生效。标识字符串要一致,provide("t") 对应 inject("t")。
One.vue:隔代接收,验证响应式
vue
<template>
<h3 @click="t+='?'">One组件--孙子:{{t}}</h3>
<p>来自于child的age:{{age}}</p>
</template>
<script lang="ts" setup>
import {inject} from "vue";
const t = inject("t");
const age = inject("age");
</script>
<style scoped>
</style>One 是 Child 的后代,所以既能拿到 App 隔代提供的 t,也能拿到 Child 提供的 age。点击 One 的标题修改 t,App 和 Child 里的 token 也会变;点击 Child 的标题修改 t,One 同样同步。这验证了“接收之后数据可以修改,并支持响应式”。
Child2.vue:兄弟组件拿不到 age
vue
<template>
<h3>我是组件Child2:{{age}}</h3>
</template>
<script lang="ts" setup>
import {inject} from "vue";
const age = inject("age");
</script>
<style scoped>
</style>Child2 是 App 的直接子组件,和 Child 是兄弟关系,并不是 Child 的后代。因此 Child 提供的 age 在 Child2 里 inject("age") 拿不到。转写稿里也做了兄弟组件的测试,结论同样是找不到。若兄弟之间要共享,应把 provide 提升到共同祖先 App,或使用状态管理,而不是在兄弟里互相 inject。
调试结论与易错点
调试时先看组件层级:谁提供、谁接收、接收方是不是提供方的后代。provide 的第一个参数是标识,第二个参数是数据状态;数据用 ref 才能保持响应式。inject 得到 ref 后,模板中可直接使用,JS 中要记得 .value。常见错误:父组件 inject 子组件数据、兄弟组件互相 inject、标识写错、把普通值当响应式值修改。记住:provide / inject 的作用域是“自己直接包裹或间接包裹的组件”,可以隔代,但不能向上,也不能横向跨兄弟。
作用域插槽要解决什么问题
普通插槽是父组件把模板内容“塞”给子组件,但模板里能用的数据通常来自父组件。作用域插槽反过来:子组件把自己内部的数据,通过 <slot> 暴露给父组件使用。转写稿里说得很直白:作用域插槽可以实现“子组件主动向父组件传递数据”,它也是父子传参的一种方式。
为什么需要?比如子组件 Child.vue 负责 v-for 渲染列表,但父组件想决定每一项长什么样,还要用到子组件循环出来的 item 和 index。父组件自己拿不到这些内部变量,于是子组件在插槽上“挂”数据,父组件再通过 v-slot 接收。
怎么想?把 <slot> 看成子组件开的一个“出口”:出口上写 :suibian="item" :$index="index",父组件使用这个出口时,就能收到一个包含 suibian、$index 的对象。怎么做?先看子组件:
vue
<template>
<h3>我是子组件Child</h3>
<div v-for="(item,index) in info" :key="item.id">
<slot :suibian="item" :$index="index"></slot>
</div>
</template>
<script lang="ts" setup>
defineProps(["info"]);
</script>这里 :suibian="item" 是给插槽 prop 起名,名字叫 suibian,值是当前循环项;:$index="index" 同理,把索引以 $index 这个名字传出去。易错点:suibian 不是固定 API,只是示例里随便起的名字;父组件解构时必须和它一致。$index 也不是这里自动产生的,而是子组件显式传出的插槽 prop 名。
父组件如何接收插槽数据
父组件先用 :info="info" 把数据传给子组件,然后在 <Child> 内部写 <template v-slot="props">。转写稿的调试过程是:先写 v-slot="props",在模板里输出/使用 props,确认它确实能拿到子组件传出的对象;再写 props.suibian.done 判断背景色,写 props.$index 和 props.suibian.do 渲染内容。这样推导比死记语法更稳:先确认 props 结构,再解构使用。
vue
<Child :info="info">
<template v-slot="props">
<p :style="{
background: props.suibian.done ? 'green' : 'red'
}">{{ props.$index }}|{{ props.suibian.do }}</p>
</template>
</Child>确认能拿到后,可以解构赋值:
vue
<template v-slot="{suibian,$index}">
<p :style="{
background: suibian.done ? 'green' : 'red'
}">{{ $index }}|{{ suibian.do }}</p>
</template>易错点:解构名写错就取不到值;suibian.done 里的 done 来自原数据对象,不是插槽额外生成的。转写稿中还演示了默认插槽写法 v-slot:default,以及简写 #default,最后简写为 #:
vue
<template #default="{suibian,$index}">
...
</template>
<template #="{suibian,$index}">
...
</template># 是 v-slot 的简写;用于默认插槽时可以只写 #。易错点:# 后如果写具名插槽名,名字要和子组件 <slot name="..."> 对应;本节是默认插槽,所以 #、#default 效果一致。
完整代码与调试顺序
笔记中保留了多个被注释掉的版本,正好体现推导过程:从 v-slot="props" 到解构,再到 v-slot:default、#default,最后 #。实际写的时候保留最终版本即可,但理解这些中间形态有助于排错。
src/App.vue:
vue
<template>
<h3>我是父组件App</h3>
<hr/>
<!-- <Child :info="info">-->
<!-- <template v-slot="props">-->
<!-- <p :style="{-->
<!-- background:props.suibian.done?'green':'red'-->
<!-- }">{{props.$index}}|{{props.suibian.do}}</p>-->
<!-- </template>-->
<!-- </Child>-->
<!--可以通过解构赋值的形式接收数据-->
<!-- <Child :info="info">-->
<!-- <template v-slot="{suibian,$index}">-->
<!-- <p :style="{-->
<!-- background:suibian.done?'green':'red'-->
<!-- }">{{$index}}|{{suibian.do}}</p>-->
<!-- </template>-->
<!-- </Child>-->
<!-- <Child :info="info">-->
<!-- <template v-slot:default="{suibian,$index}">-->
<!-- <p :style="{-->
<!-- background:suibian.done?'green':'red'-->
<!-- }">{{$index}}|{{suibian.do}}</p>-->
<!-- </template>-->
<!-- </Child>-->
<!-- <Child :info="info">-->
<!-- <template #default="{suibian,$index}">-->
<!-- <p :style="{-->
<!-- background:suibian.done?'green':'red'-->
<!-- }">{{$index}}|{{suibian.do}}</p>-->
<!-- </template>-->
<!-- </Child>-->
<Child :info="info">
<template #="{suibian,$index}">
<p :style="{
background:suibian.done?'green':'red'
}">{{$index}}|{{suibian.do}}</p>
</template>
</Child>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import {ref} from "vue";
const info = ref([
{
id: 1,
do: "吃了一个包子!",
done: true
}, {
id: 2,
do: "扶老太太过马路!",
done: false
}
, {
id: 3,
do: "赚了1000万!",
done: true
}
]);
</script>
<style scoped>
</style>src/components/Child.vue:
vue
<template>
<h3>我是子组件Child</h3>
<div v-for="(item,index) in info" :key="item.id">
<slot :suibian="item" :$index="index"></slot>
</div>
</template>
<script lang="ts" setup>
defineProps(["info"]);
</script>
<style scoped>
</style>调试时如果页面不显示,按转写稿思路检查:先看子组件 <slot> 是否真的传了 :suibian、:$index;再看父组件 v-slot 解构名是否一致;最后确认 info 是否通过 :info="info" 传入。易错点还有:不要在父组件模板里直接写 item、index,它们不是父组件作用域变量;必须从插槽 props 里取。作用域插槽的核心就是:子组件负责提供数据,父组件负责决定渲染结构。
课堂画面






代码示例
示例1:Vue3 useAttrs
vue
<!-- src/App.vue -->
<template>
<h3>我是父组件App{{ count }}</h3>
<hr />
<Child @click="fn" a="1" b="2" c="3" :count="count" />
</template>
<script lang="ts" setup>
import Child from "./components/Child.vue";
import { ref } from "vue";
const count = ref(100);
const fn = function (a: number, b: number, c: number, d: number) {
console.log("fn", a, b, c, d);
count.value = a + b + c + d + count.value;
};
</script>vue
<!-- src/components/Child.vue -->
<template>
<h3>我是子组件Child</h3>
<p>{{ $attrs }}</p>
<p>{{ $attrs.onClick }}</p>
<button @click="attrs.onClick(1, 2, 3, 4)">点我</button>
</template>
<script lang="ts" setup>
import { useAttrs } from "vue";
const attrs = useAttrs();
console.log(attrs);
// 如果写 defineProps(["count"]),则 attrs 中不再出现 count
// props 优先级高于 attrs
</script>要点解释:
- 父组件
@click="fn"没有在子组件defineEmits中声明,会进入attrs,Vue3 中事件监听器以onClick形式存在。 useAttrs()返回对象包含普通属性和自定义事件监听器;模板中也可以直接使用内置$attrs。- 若子组件声明
defineProps(["count"]),count会从attrs中移除,体现props优先级高于attrs。 - 易错点:不要在
<script setup>中把useAttrs()结果命名为$attrs,避免与模板内置$attrs混淆;示例用attrs,模板中调用attrs.onClick。
示例2:Vue3 ref 与 $parent
vue
<!-- src/App.vue -->
<template>
<h3>我是父组件App</h3>
<p>我是父亲,我现在的资产有:{{ money }}</p>
<button @click="borrowByChild">向儿子借10元</button>
<hr />
<Child ref="son" />
</template>
<script lang="ts" setup>
import Child from "./components/Child.vue";
import { ref } from "vue";
const money = ref(200);
const son = ref();
const borrowByChild = function () {
son.value.money -= 10;
money.value += 10;
};
defineExpose({ money });
</script>vue
<!-- src/components/Child.vue -->
<template>
<h3>我是子组件Child</h3>
<p>我还小,还是个孩子,现在的资产共{{ money }}</p>
<button @click="sendFather($parent)">给父亲100元</button>
</template>
<script lang="ts" setup>
import { ref } from "vue";
const money = ref(2000);
const sendFather = function (parent: any) {
money.value -= 100;
parent.money += 100;
};
defineExpose({ money });
</script>要点解释:
- Vue3 没有
$children,父组件获取子组件数据/方法用ref+defineExpose。 - 子组件获取父组件实例可在模板中使用
$parent,但父组件<script setup>中必须defineExpose({ money })暴露数据。 defineExpose({ money })暴露的是ref,通过组件实例访问时会被代理解包,所以示例中可直接son.value.money -= 10、parent.money += 100。- 易错点:未
defineExpose时,父组件通过ref拿不到子组件内部数据;子组件通过$parent也访问不到父组件未暴露的数据。
示例3:作用域插槽
vue
<!-- src/App.vue -->
<template>
<h3>我是父组件App</h3>
<hr />
<Child :info="info">
<template #="{ suibian, $index }">
<p :style="{ background: suibian.done ? 'green' : 'red' }">
{{ $index }}|{{ suibian.do }}
</p>
</template>
</Child>
</template>
<script lang="ts" setup>
import Child from "./components/Child.vue";
import { ref } from "vue";
const info = ref([
{ id: 1, do: "吃了一个包子!", done: true },
{ id: 2, do: "扶老太太过马路!", done: false },
{ id: 3, do: "赚了1000万!", done: true }
]);
</script>vue
<!-- src/components/Child.vue -->
<template>
<h3>我是子组件Child</h3>
<div v-for="(item, index) in info" :key="item.id">
<slot :suibian="item" :$index="index"></slot>
</div>
</template>
<script lang="ts" setup>
defineProps(["info"]);
</script>要点解释:
- 子组件
<slot :suibian="item" :$index="index">把循环项和索引作为插槽 props 传给父级插槽内容。 - 父组件
<template #="{ suibian, $index }">使用#(v-slot简写)解构接收;#default也可写默认插槽。 - 易错点:插槽 prop 名由子组件决定,父组件解构名必须一致;
$index只是子组件自定义的 prop 名,不是 Vue 内置索引。 - 作用域插槽用于子组件主动向父组件插槽内容传递数据,常见于列表渲染时由父组件决定每项样式/结构。
常见错误
现象:子组件已
defineProps(["count"]),仍想从$attrs.count取到count。
原因:props优先级高于attrs,同名属性已从attrs中剥离。
修法:改用props.count;需要透传的属性不要声明为props。现象:Vue3 父组件通过
ref拿到子组件实例,却取不到money或方法。
原因:<script setup>组件默认不暴露内部状态。
修法:子组件使用defineExpose({ money, method })显式暴露。现象:Vue3 中继续写
this.$children[0]获取子组件。
原因:Vue3 已移除$children。
修法:改用模板ref获取单个/多个子组件实例,或调整通信方案。现象:在
<script setup>中直接写this.$parent。
原因:组合式 API 没有组件实例this。
修法:模板中传$parent给方法,或优先改用props/emit/provide/inject。现象:
provide后,兄弟组件或父级组件inject不到。
原因:provide/inject只对当前组件的后代生效,不向上、不跨兄弟。
修法:把provide提升到共同祖先,或改用状态管理/事件通信。现象:作用域插槽父组件拿不到
suibian/$index,或渲染时报未定义。
原因:未通过v-slot/#default接收子组件slot上传的插槽 props。
修法:写成<template #default="{ suibian, $index }">,并使用解构后的变量。
练习
改错题:子组件写了
defineProps(["count"]),模板仍用$attrs.count渲染。请指出问题并改为正确写法。实现题:Vue3
<script setup>中,父组件点击按钮调用子组件changeUserName("lisi")。请补全父组件ref、子组件defineExpose的关键代码。判断题:
App中provide("t", token),Child2是App的直接子组件,One是Child的子组件。判断:Child2能否inject("t")?One能否inject("t")?Child与Child2能否互相inject?说明理由。插槽题:子组件循环
info,写<slot :item="item" :index="index"></slot>。请写出父组件使用#default解构,并按item.done设置背景色的模板。对比题:结合
@click="fn"透传示例,说明 Vue3useAttrs()与 Vue2$attrs/$listeners在事件获取上的差异,并指出一个迁移时容易漏改的点。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | vue3之useAttrs | 11:53 |
| 2 | vue2之$refs-$children-$parent | 11:31 |
| 3 | vue3之ref | 06:08 |
| 4 | vue3之ref与$parent | 12:43 |
| 5 | 组合式API provide - inject | 12:39 |
| 6 | 作用域插槽 | 15:04 |
小结
props声明会“吃掉”同名attrs,取不到时先检查是否已声明为props。- Vue3 的
attrs已合并事件能力,不要再按 Vue2 的$attrs + $listeners思维硬拆。 <script setup>组件对外访问必须defineExpose,否则ref只能拿到空壳实例。- Vue3 父取子优先用模板
ref,不要继续依赖已移除的$children。 $parent适合兜底和演示,业务代码优先props/emit/provide/inject,降低耦合。provide/inject的边界是“祖先到后代”,兄弟、向上、无关组件都不可达。- 作用域插槽的关键是父组件必须通过
v-slot接收子组件上传的插槽 props。 - 调试跨组件问题时,先查“是否暴露、键名是否一致、是否在祖先链上”,再查响应式
.value。