Skip to content

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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例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 内置索引。
  • 作用域插槽用于子组件主动向父组件插槽内容传递数据,常见于列表渲染时由父组件决定每项样式/结构。

常见错误 ​

  1. 现象:子组件已 defineProps(["count"]),仍想从 $attrs.count 取到 count。
    原因:props 优先级高于 attrs,同名属性已从 attrs 中剥离。
    修法:改用 props.count;需要透传的属性不要声明为 props。

  2. 现象:Vue3 父组件通过 ref 拿到子组件实例,却取不到 money 或方法。
    原因:<script setup> 组件默认不暴露内部状态。
    修法:子组件使用 defineExpose({ money, method }) 显式暴露。

  3. 现象:Vue3 中继续写 this.$children[0] 获取子组件。
    原因:Vue3 已移除 $children。
    修法:改用模板 ref 获取单个/多个子组件实例,或调整通信方案。

  4. 现象:在 <script setup> 中直接写 this.$parent。
    原因:组合式 API 没有组件实例 this。
    修法:模板中传 $parent 给方法,或优先改用 props/emit/provide/inject。

  5. 现象:provide 后,兄弟组件或父级组件 inject 不到。
    原因:provide/inject 只对当前组件的后代生效,不向上、不跨兄弟。
    修法:把 provide 提升到共同祖先,或改用状态管理/事件通信。

  6. 现象:作用域插槽父组件拿不到 suibian/$index,或渲染时报未定义。
    原因:未通过 v-slot/#default 接收子组件 slot 上传的插槽 props。
    修法:写成 <template #default="{ suibian, $index }">,并使用解构后的变量。

练习 ​

  1. 改错题:子组件写了 defineProps(["count"]),模板仍用 $attrs.count 渲染。请指出问题并改为正确写法。

  2. 实现题:Vue3 <script setup> 中,父组件点击按钮调用子组件 changeUserName("lisi")。请补全父组件 ref、子组件 defineExpose 的关键代码。

  3. 判断题:App 中 provide("t", token),Child2 是 App 的直接子组件,One 是 Child 的子组件。判断:Child2 能否 inject("t")?One 能否 inject("t")?Child 与 Child2 能否互相 inject?说明理由。

  4. 插槽题:子组件循环 info,写 <slot :item="item" :index="index"></slot>。请写出父组件使用 #default 解构,并按 item.done 设置背景色的模板。

  5. 对比题:结合 @click="fn" 透传示例,说明 Vue3 useAttrs() 与 Vue2 $attrs/$listeners 在事件获取上的差异,并指出一个迁移时容易漏改的点。

原视频对照 ​

#视频时长
1vue3之useAttrs11:53
2vue2之$refs-$children-$parent11:31
3vue3之ref06:08
4vue3之ref与$parent12:43
5组合式API provide - inject12:39
6作用域插槽15:04

小结 ​

  1. props 声明会“吃掉”同名 attrs,取不到时先检查是否已声明为 props。
  2. Vue3 的 attrs 已合并事件能力,不要再按 Vue2 的 $attrs + $listeners 思维硬拆。
  3. <script setup> 组件对外访问必须 defineExpose,否则 ref 只能拿到空壳实例。
  4. Vue3 父取子优先用模板 ref,不要继续依赖已移除的 $children。
  5. $parent 适合兜底和演示,业务代码优先 props/emit/provide/inject,降低耦合。
  6. provide/inject 的边界是“祖先到后代”,兄弟、向上、无关组件都不可达。
  7. 作用域插槽的关键是父组件必须通过 v-slot 接收子组件上传的插槽 props。
  8. 调试跨组件问题时,先查“是否暴露、键名是否一致、是否在祖先链上”,再查响应式 .value。