组件最大的价值是复用,但复用就面临一个矛盾:组件的大部分结构是固定的,可总有一小部分内容需要由父组件来决定。比如一个弹窗组件,标题和按钮文字可能每次都不同,甚至整个内容区域都需要自定义。写死肯定不行,全用 Props 传 HTML 字符串又太僵硬。Vue 的插槽系统就是用来解决这个问题的——它让你在组件内部开几个“洞”,由父组件在使用时填入具体内容。
默认插槽:一个最基础的“内容入口”
如果组件只需要一个可替换的区域,就用默认插槽。子组件用 <slot> 标签定义占位,父组件直接在标签内写内容。
<!-- MyDialog.vue - 子组件 -->
<template>
<div class="dialog">
<slot></slot> <!-- 这个位置的 HTML 由父组件提供 -->
</div>
</template>
<!-- 父组件 -->
<MyDialog>
<p>确认要删除这条记录吗?</p>
</MyDialog>
渲染结果就是 <p>确认要删除这条记录吗?</p> 出现在 <div class="dialog"> 里面。父组件写什么,<slot> 就换成什么。如果父组件没传内容,也可以给 <slot> 加一个后备内容:
<slot>这是默认文案,父组件没传内容时会显示</slot>
默认插槽在封装布局类组件时尤其方便,比如页面容器 <Card>,外面是边框和阴影,里面插内容。
具名插槽:多个区域的精确分发
很多时候一个组件不止一个可变区域。一个典型的布局组件可能有头部、主体、底部三个插槽。这时候就需要给每个插槽起个名字。
<!-- LayoutPanel.vue -->
<template>
<div class="panel">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> <!-- 没有 name 的插槽默认名为 "default" -->
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
父组件使用 v-slot 指令(可以简写为 #)来指定内容要放到哪个插槽:
<LayoutPanel>
<template #header>
<h1>页面标题</h1>
</template>
<p>这里是主体内容,自动放入默认插槽</p>
<template #footer>
<button>确定</button>
</template>
</LayoutPanel>
v-slot 只能用在 <template> 标签上(或者组件上,后面作用域插槽会涉及),而默认插槽的内容如果不包装 template,直接放在标签内部就行。
作用域插槽:从子组件向父组件“回传数据”
前面两种插槽是父组件“塞内容”给子组件,数据流向单一。但有时父组件需要根据子组件内部的数据来动态渲染内容。比如一个列表组件 <TodoList>,它掌握了“待办事项数组”这个数据,但每一项的 UI 样式希望由父组件来决定——有的页面是文字加删除按钮,有的页面可能带颜色标签。
这时候就不能让子组件把数据直接渲染成死 HTML,而要“把数据暴露给父组件,让父组件决定怎么渲染”。这就是作用域插槽。
子组件在 <slot> 上绑定属性,父组件通过 v-slot 接收一个对象。
<!-- TodoList.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="index"></slot>
</li>
</ul>
</template>
<script setup>
defineProps(['items'])
</script>
父组件使用:
<TodoList :items="list">
<template v-slot="{ item, index }">
<span>{{ index + 1 }}. {{ item.text }}</span>
<button @click="remove(item.id)">删除</button>
</template>
</TodoList>
每次循环,子组件把当前的 item 和 index 传给插槽,父组件拿到后自由渲染。这种机制让逻辑封装在子组件里,而显示控制权交给父组件,非常灵活。
如果只有默认插槽,v-slot 可以直接写在组件标签上,省略 <template>:
<TodoList :items="list" v-slot="{ item }">
<span>{{ item.text }}</span>
</TodoList>
插槽的底层原理与渲染时机
理解插槽怎么工作的,能帮你少踩坑。
插槽内容的作用域属于父组件。即使你把模板写在子组件标签内部,里面的变量、方法都是父组件的。比如:
<MyDialog>
<p>{{ parentMessage }}</p> <!-- parentMessage 来自父组件 -->
</MyDialog>
这点很重要,因为有些开发者误以为插槽内容是在子组件里执行的,试图访问子组件的数据,结果发现拿不到。
底层过程可以简化为:
- 父组件编译时,遇到子组件标签内的内容,会生成对应的 VNode 树(插槽函数)。
- 子组件渲染时,遇到
<slot>标记,就调用父组件传入的插槽函数,得到 VNode 并放入该位置。 - 对于作用域插槽,子组件在调用插槽函数时会传入数据对象,父组件通过解构拿到。
因此,插槽的本质是父组件定义了一组“待渲染的虚拟节点”,由子组件决定何时、放在哪个位置执行渲染。这解释了为什么作用域插槽的数据必须由子组件显式暴露:父组件的插槽函数本身是父组件作用域的,子组件的数据对它不可见,只有子组件主动通过参数传递才能拿到。
实际应用中的几个常见模式
- 表格的自定义列:表格组件循环展示数据,但某列需要渲染成状态标签或操作按钮,就暴出每行的数据让父组件自定义。
- 表单的二级封装:封装一个
<FormItem>验证容器,中间留插槽放具体输入控件,把error信息通过作用域插槽回传,让父组件决定错误提示的样式。 - 布局与页面骨架:
<Layout>提供 header、sidebar、content 的具名插槽,不同页面直接复用。
插槽系统的设计非常务实:它不搞出一套全新概念,而是类似原生 HTML 的“内容嵌套”,但加入了命名分组和数据回传,让组件复用的边界清晰、灵活且不牺牲可维护性。