疏通Vue动态组件体系:插槽、数据监听、组件通信、动态组件与缓存,完整知识闭环
不知道大家有没有这种感觉,学 Vue 的时候知识点总是东一块西一块。 插槽单独学、监听单独记、组件通信挨个背,代码调用会写,但脑子一团乱麻。 只懂怎么用API,完全搞不懂每个知识点在整个框架体系里处在什么位置、互相有什么联系。
我觉得学习不能只停留在会敲代码,更要理清底层逻辑、打通知识脉络,搭建属于自己的认知体系。 写这篇文章,更多是学习梳理、复盘感悟,把整条组件化完整思路串通透。
本文会顺着最简单的逻辑,由浅入深、从内到外,完整串联整套动态体系: 结构动态 → 数据动态 → 组件数据互通 → 组件整体切换 → 组件状态缓存 全程通俗易懂、逻辑闭环,读完彻底搞懂Vue组件动态底层思想。
一、为什么我们需要动态组件
最朴素直白地理解: 写死固定不变的页面,就是静态组件。 页面长啥样,打开就永远啥样,结构不动、数据不动、内容不动,呆呆板板,僵硬得不行。
动态,顾名思义就是页面会变化、内容会刷新、视图会跟着数据自动改动。 用户点击、数据更新、状态切换、内容联动,页面可以灵活做出响应,这就是动态。
所以动态能力,是Vue组件开发的灵魂所在。 Vue设计插槽、数据监听、组件通信、组件切换一系列API,归根结底,都是为了一件事: 让组件灵活可变,让页面活起来。
二、结构动态:插槽 Slot,灵活自定义组件DOM
想要组件不再死板,最先要解决的就是布局结构固化的问题,插槽就是 Vue 用来实现结构分发的核心方案。
简单理解: 插槽就是在子组件中预留空位,允许父组件自由传入任意DOM结构,灵活改变子组件内部布局。
Vue 一共提供三类插槽,覆盖绝大多数开发场景:
- 默认插槽:基础内容分发
- 具名插槽:多区域精准布局
- 作用域插槽:子组件存数据,父组件自定义渲染结构
下面简单学习了解一下
一、默认插槽
作用:实现父子组件之间 HTML DOM 结构传递子组件预留占位位置,父组件可传入任意标签内容
Vue2 代码示范
👉 子组件 Child.vue
html
<template>
<div class="card-box">
<h4>我是子组件内部固定标题</h4>
<!--
默认插槽
作用:预留一个空白位置
用来接收父组件传递过来的任意DOM结构
-->
<slot></slot>
</div>
</template>
<script>
export default {
name: "Child"
}
</script>
👉 父组件 Parent.vue
html
<template>
<div class="parent">
<h3>父组件页面</h3>
<!--
子组件标签内部所有内容
都会被分发到子组件 <slot> 位置渲染
-->
<Child>
<p>我是父组件传入的段落内容</p >
<button>父组件自定义按钮</button>
</Child>
</div>
</template>
<script>
import Child from './Child.vue'
export default {
components: { Child }
}
</script>
Vue3 代码示范:默认插槽 Vue2 和 Vue3 语法完全一致,无需改动
二、具名插槽
作用一个组件多个渲染区域通过插槽名字,精准分发不同位置的DOM结构 多用于页面布局:头部、侧边、主体、底部
Vue2 代码示范
👉 子组件 Child.vue
html
<template>
<div class="layout">
<!-- 头部插槽,命名 header -->
<slot name="header"></slot>
<!-- 主体内容插槽,命名 main -->
<slot name="main"></slot>
<!-- 底部插槽,命名 footer -->
<slot name="footer"></slot>
</div>
</template>
<script>
export default {
name: "Child"
}
</script>
👉 父组件 Parent.vue
html
<template>
<div>
<Child>
<!-- slot="名称" 匹配子组件对应插槽 -->
<div slot="header"> 页面头部区域</div>
<div slot="main"> 页面主体内容</div>
<div slot="footer"> 页面底部信息</div>
</Child>
</div>
</template>
<script>
import Child from './Child.vue'
export default {
components: { Child }
}
</script>
Vue3 代码示范
👉 子组件 Child.vue写法不变
👉 父组件 Parent.vue
Vue3 彻底废弃 slot="" 行内写法,统一使用 v-slot:名称 ,简写 #名称 ,必须包裹 template
html
<template>
<div>
<Child>
<!-- # 是 v-slot: 的简写语法 -->
<template #header>
<div>Vue3 专属头部</div>
</template>
<template #main>
<div>Vue3 主体内容区域</div>
</template>
<template #footer>
<div>Vue3 底部</div>
</template>
</Child>
</div>
</template>
<script setup>
import Child from './Child.vue'
</script>
三、作用域插槽(重点)
核心逻辑
数据存放在子组件,DOM结构由父组件自定义编写 子组件向外暴露自己的数据 父组件拿到数据,自由决定标签样式 (业务场景:表格单元格、列表自定义渲染)
Vue2 代码示范
👉 子组件 Child.vue
html
<template>
<div class="list-box">
<!--
作用域插槽
:listData 向外抛出子组件内部数据
把数据传递给父组件使用
-->
<slot :listData="userList"></slot>
</div>
</template>
<script>
export default {
name: "Child",
data() {
return {
// 数据完全由子组件维护
userList: [
{ id: 1, name: "张三" },
{ id: 2, name: "李四" },
{ id: 3, name: "王五" }
]
}
}
}
</script>
👉 父组件 Parent.vue
html
<template>
<div>
<!--
slot-scope 用来接收子组件传递过来的所有数据
scope 是自定义接收对象
-->
<Child slot-scope="scope">
<!-- 从scope中取出子组件的数据,自定义渲染结构 -->
<div>用户姓名:{{ scope.listData.name }}</div>
</Child>
</div>
</template>
<script>
import Child from './Child.vue'
export default {
components: { Child }
}
</script>
Vue3 代码示范
👉 子组件 Child.vue
html
<template>
<div class="list-box">
<!-- 向外暴露子组件内部数据 -->
<slot :listData="userList"></slot>
</div>
</template>
<script setup>
// 子组件自身数据
const userList = [
{ id: 1, name: "张三" },
{ id: 2, name: "李四" },
{ id: 3, name: "王五" }
]
</script>
👉 父组件 Parent.vue
Vue3 删除 slot-scope,全部统一插槽语法
html
<template>
<div>
<!-- #default 代表默认作用域插槽,接收子组件数据 -->
<Child #default="scope">
<!-- 父组件自由编写DOM,使用子组件数据 -->
<div style="color:red">
自定义用户:{{ scope.listData.name }}
</div>
</Child>
</div>
</template>
<script setup>
import Child from './Child.vue'
</script>
对比一下 Vue2 vs Vue3 插槽差异
1. 默认插槽 Vue2、Vue3 语法完全一致,无任何区别
2. 具名插槽
- Vue2:直接 slot="名字" 写在标签上
- Vue3:必须使用 #名字 ,外层包裹 template ,不再支持行内slot
3. 作用域插槽
- Vue2:专用关键字 slot-scope="变量"
- Vue3:全部统一为 v-slot / # 语法,大一统
插槽本质上,只改变组件内部DOM,组件本身不会发生变化,属于组件内部结构层面的动态。
三、数据动态:computed 计算属性 & watch 侦听器
解决完结构问题,我们需要让组件内部的数据拥有响应变化的能力,这里就离不开 computed 和 watch。
一、computed 计算属性
依赖已有数据自动生成全新数据,具备缓存特性,被动触发执行,适合数据拼接、数值换算、状态判断等简单数据处理,只支持同步代码。
1. 基本用法代码(Vue3)
js
<script setup>
import { computed, ref } from 'vue'
// 原始响应式数据
const num1 = ref(10)
const num2 = ref(20)
// 计算属性:依赖现有数据,自动算出新值
const total = computed(() => {
console.log('计算属性执行了')
// 依赖 num1 和 num2
return num1.value + num2.value
})
</script>
2. 主动性 VS 被动性
- computed 是被动触发 :你不去读取它,它永远不执行 只有页面用到、代码读取 total 的时候,它才会计算
3. 依赖关系:多对一
多个原始数据 → 一个计算属性 num1、num2 多个变量,共同生成 一个 total
4. 自带缓存(最核心特性)
只要它依赖的数据没有发生变化,无论你读取多少次 computed,函数只执行一次,直接读缓存,性能极好
5. 只能同步,不能写异步
computed 内部严禁异步请求、定时器 一旦写异步,依赖收集直接失效,整个废掉
6. 本质
数据派生器 根据已有数据,自动推导新数据 属于:数据 → 数据
二、watch 侦听器
主动监听数据变化,数据一旦改变就立刻执行回调函数,无缓存机制,天然支持异步业务逻辑。 日常开发中还有两个高频配置:
- immediate :页面首次加载立即执行监听
- deep:开启深度监听,能够监听到对象、数组内部属性变化
1. 用法代码(含 deep、immediate)
js
<script setup>
import { watch, ref } from 'vue'
const count = ref(0)
// 监听 count 变化
watch(
count,
(newVal, oldVal) => {
// 数据一变,立刻进入这里
console.log('数据变化了', newVal)
},
{
immediate: true, // 页面一加载立刻执行一次
deep: true // 深度监听对象、数组内部变化
}
)
</script>
2. 主动性 VS 被动性
- watch 是主动监听 只要我监听的数据发生改变 不管你用不用、读不读 自动立刻触发函数
3. 依赖关系:一对多
一个被监听数据 可以触发 一大堆业务逻辑、请求、操作、修改其他变量
一个数据变动 → 触发无数行为
4. 无缓存
数据变一次,执行一次 变多少次,跑多少次 不存在缓存
5. 天生支持异步
watch 里面随便写: 接口请求、定时器、复杂判断、大量业务代码 完全没问题
6. 本质
数据变化监视器 盯着一个值,变了就做事 属于:数据变化 → 行为动作
简单区分:需要加工数据用 computed,数据变化要做业务操作用 watch。
二者搭配使用,让组件数据可以自动计算、实时监听、随时更新,真正实现数据动态响应。
四、数据互通:Vue 四大组件通信方案
插槽控制结构、监听控制数据,但每个组件都是独立作用域,数据相互隔离无法共享。 想要多个组件联动变化,就必须掌握全套组件通信方式。
四种通信清晰划分为两大层级,方便理解与选用:
第一层级:基础点对点通信
1. props + $emit --- 父子直系通信
2. provide + inject --- 祖孙跨层通信
第二层级:全局架构级通信
3. EventBus 事件总线 --- 无关组件轻量通信
4. Pinia 全局状态仓库 --- 大型项目统一状态管理
第一层级:基础点对点组件通信详解
特点:组件与组件直接一对一、一对多传值 语法简单、使用频率最高、代码完整、细节拉满
1. 父子组件通信 props + $emit
Vue 最正统、最基础、使用最多的父子通信方式
1.1 父向子传值 --- props
抽象概念
- 数据流向:单向自上而下 父组件 → 子组件
- 主动被动关系:父组件主动推送数据,子组件被动接收数据
- 数据映射关系:一对多 一个父组件,可以同时给多个子组件传递同一份数据
- 数据流特性:单向数据流 数据源头在父组件,子组件只能读取,不允许直接修改 props 数据
- 使用范围:仅限直接父子嵌套组件
代码示例
👉 父组件(数据发送方)
html
<template>
<!-- 通过自定义属性,把数据传递给子组件 -->
<Child :msg="parentMsg" />
</template>
<script setup>
// 引入子组件
import Child from './Child.vue'
// 父组件内部定义响应式数据
const parentMsg = "我是来自父组件的传递数据"
</script>
👉 子组件(数据接收方)
html
<template>
<!-- 直接使用父组件传递过来的数据 -->
<div>接收父组件数据:{{ msg }}</div>
</template>
<script setup>
// 显性声明需要接收父组件哪些参数
const props = defineProps(['msg'])
</script>
1.2 子向父传值 --- $emit 自定义事件
抽象概念
- 数据流向:自下而上 子组件 → 父组件
- 主动被动关系:子组件主动触发事件,父组件被动监听、接收数据
- 数据映射关系:一对多 一个子组件触发事件,可以被多个上层父组件监听
- 底层逻辑:子组件自定义事件,触发事件时携带自身数据向上抛出
代码示例
👉 子组件(数据发送方)
html
<template>
<!-- 点击触发方法,向父组件发送数据 -->
<button @click="sendChildData">把数据传给父组件</button>
</template>
<script setup>
// 定义当前组件需要向外派发的自定义事件
const emit = defineEmits(['getChildInfo'])
// 子组件自身私有数据
const childInfo = "这里是子组件内部数据"
// 触发事件,携带数据向上传递
const sendChildData = () => {
// 参数1:事件名称 参数2:要传递的数据
emit('getChildInfo', childInfo)
}
</script>
👉 父组件(数据接收方)
html
<template>
<!-- 监听子组件抛出的自定义事件,触发对应回调函数 -->
<Child @getChildInfo="handleGetData" />
</template>
<script setup>
// 回调函数,接收子组件传递过来的所有数据
const handleGetData = (value) => {
console.log('成功接收子组件数据:', value)
}
</script>
父子通信总结
- props :属性下发,父传子,负责数据流入 - $emit:事件上抛,子传父,负责数据反馈
一上一下、单向流动、结构规范、日常开发最常用
2. 隔代祖孙通信 provide + inject
抽象底层概念
- 解决痛点:多层嵌套组件,如果用 props 需要一层一层往下传递,中间组件无辜转发、代码冗余
- 数据流向:顶层祖先组件 → 所有下层后代组件
- 主动被动:上层主动提供数据,下层所有后代被动注入获取
- 映射关系:一对多 一个祖先组件,任意层级的孙子、曾孙子都可以直接拿到数据
- 核心能力:组件层级穿透,无视中间嵌套层数
👉 顶层祖先组件(提供数据)
html
<script setup>
import { provide } from 'vue'
// 向外穿透提供数据,所有后代组件均可访问
provide('theme', '全局暗色主题')
</script>
👉 任意深层后代组件(孙子、重孙子)
html
<script setup>
import { inject } from 'vue'
// 直接注入顶层数据,不用管中间嵌套多少层组件
const theme = inject('theme')
</script>
第二层级:全局架构级通信(弱化代码,侧重思想、场景、定位)
不属于简单两个组件点对点传值,偏向项目整体数据流架构,这里简单讲解,不堆砌大量代码
3. 无关组件通信 EventBus 事件总线
抽象概念
1. 适用场景:两个组件不存在任何父子、祖孙嵌套关系,互相独立
2. 底层原理:发布订阅设计模式
- 发布方:主动发射事件、携带数据
- 订阅方:监听对应事件,被动接收数据
3. 数据关系:多对多通信
通俗理解
相当于项目里一个公共中转站组件A把数据丢进总线,组件B、C、D监听总线就能拿到数据
(使用场景小型项目、简单兄弟组件临时通信 缺点:事件杂乱难管理,大型项目基本淘汰)
4. 全局状态管理 Pinia
抽象本质
前面所有通信,都是组件和组件之间互相传数据 Pinia 直接改变思路:所有组件统一读写公共数据仓库 (可以看前面的文章有讲解,这里一笔带过)
到这里我们可以总结:
插槽、数据监听、组件通信,全部都是在组件内部做变化。 组件不会被替换,只是结构、数据、内容在动态流转更新。
五、更高维度动态:component :is 整体动态组件
component :is 本身用法十分简单,几乎所有接触过 Vue 项目的人都不陌生。 很多人日常业务中一直在使用,只是对动态组件这个专业名词不够熟悉。
它不是新增语法,也不是复杂API,是 Vue 框架原生自带、从诞生之初就存在的能力。 放在我们整套组件动态体系里看,它有着非常清晰的层级定位:
- 插槽:负责组件内部结构动态
- 父子通信:负责组件内部数据动态
- component 动态组件:负责组件整体层面的动态切换
前面所有知识点,都在优化单个组件内部。 而动态组件,上升到了组件与组件之间的灵活渲染。
六、动态组件优化:keep-alive 组件缓存
我们用 component :is 动态切换组件。 默认情况下:组件一切换,旧组件直接销毁,新组件重新创建。
只要组件离开视线:
- 组件 onUnmounted 卸载销毁
- 里面填写的数据、输入框内容、页面状态全部清空
- 下次切回来,重新执行 onMounted 重新请求、重新初始化
很多业务场景我们并不希望组件被销毁 比如表单填写、搜索列表、浏览页面、标签页切换。
于是 Vue 提供了内置缓存组件:keep-alive
1. 先进行定位和了解
- 插槽:组件内部结构动态
- 组件通信:组件数据动态
- component:is:组件整体动态切换
- keep-alive:组件切换不销毁、状态保留、生命周期缓存
不写 keep-alive
组件切换: onMounted 挂载 → 切换 → onUnmounted 销毁 每次进出都完整创建+销毁
加上 keep-alive
组件不会走挂载、销毁 多出两个专属生命周期钩子:
- onActivated 组件被激活、显示
- onDeactivated 组件休眠、隐藏
(简单大白话: 组件只是藏起来,不是删掉 数据、输入内容、页面状态全部保留。)
它不是用来写页面的,专门控制组件生命周期。
2.简单代码示例
直接包裹我们的动态组件即可加上切换按钮完整可运行代码
html
<template>
<button @click="currentCom = 'Home'">首页</button>
<button @click="currentCom = 'User'">用户</button>
<!-- 缓存组件,切换不销毁 -->
<keep-alive>
<component :is="currentCom"></component>
</keep-alive>
</template>
<script setup>
import { ref } from 'vue'
import Home from '@/components/Home.vue'
import User from '@/components/User.vue'
const currentCom = ref('Home')
</script>
3. keep-alive (两个重要属性)
1. include 只缓存指定组件
html
<!-- 只缓存 Home 和 User -->
<keep-alive include="Home,User">
<component :is="currentCom"></component>
</keep-alive>
2. exclude 唯独不缓存某个组件
html
<!-- 除了 Cart 全都缓存 -->
<keep-alive exclude="Cart">
<component :is="currentCom"></component>
</keep-alive>
七、全文梳理总结
从头到尾,就围绕一件事来梳理,就是:怎么让 Vue 组件不再死板固定,一步步变得灵活、动态、好用。
最开始我们认识了插槽 slot,它只负责在组件内部动手脚,让一个组件里面的结构、标签内容可以自由自定义,不用把组件写死。
之后学习了父子组件传值,解决了组件之间数据互通的问题,让组件内部的数据也能流动变化。
紧接着我们了解了 component + is 动态组件。 这个东西大家平时写项目天天用,只是专业名词可能见得少。Vue 很早就自带了。作用也很直白:不再固定渲染某一个组件标签,通过变量直接切换项目里不同的 vue 文件,实现一整个组件整体替换。
最后登场的 keep-alive,可以说是动态组件的最佳搭档。 组件一切换默认就会销毁重建,页面数据、填写内容全部清空。而 keep-alive 专门用来缓存组件状态,让组件只是隐藏休眠,不会真正销毁,既保留页面数据,又优化页面性能。搭配 include 、 exclude 还能精准控制哪些组件需要缓存、哪些不需要。
整体梳理一条完整链路:
插槽 → 改变组件内部结构
组件通信 → 流转组件内部数据
动态组件 → 切换整个组件本体
keep-alive → 缓存组件生命周期与页面状态
把零散知识点梳理通顺、理清底层逻辑,简单白话分享出来,一起学习吃透 Vue 组件思想。