
写组件这件事很多人一开始都是把数据通过props传进去然后子组件负责渲染。但写着写着就会撞上一堵墙我想往子组件中间塞一段 HTML想在弹窗底部放自定义按钮想给表格某列自定义渲染逻辑——这些用props做起来极其别扭。我第一次碰到这个场景是在封装后台管理系统里的卡片组件时那时候还不知道插槽这个概念硬是写了十来个 props什么title、icon、footerText、footerBtnText后来需求一变组件直接改废了。后来把 Vue 插槽系统学透了才发现这玩意儿就是为“组件内容分发”而生的。插槽能让你把组件的骨架和内容拆开组件负责定结构、定样式父组件负责填空、定义具体内容。这篇博文我把自己在真实项目里用过、踩过的插槽应用场景和实现方式整理了一遍从三种插槽类型到高频业务场景再到常见报错排查希望能帮到正在学 Vue 或者已经写组件但没深入用过插槽的人。1. 先搞清楚插槽到底是干什么的1.1 props 不够用的场景很多人问组件传内容用 props 不就行了为什么非要有插槽props 是适合传“数据”的。比如一个卡片组件需要标题文字、描述文字、按钮文字这些是简单的字符串用 props 完全没问题ArticleCard titleVue 入门指南 desc一篇写给新手的文章 /但现实需求往往没这么老实。比如同一个卡片组件第一处用的时候标题旁边要加一个图标第二处用的时候描述下面要放一张图片第三处用的时候整个底部要换成一组操作按钮。这时候如果你还拿 props 传要么传 HTML 字符串然后v-html渲染——既不安全又难维护要么把组件拆成好几个变体——代码量爆炸。正确的解法就是插槽组件的某个区域不写死内容而是留一个“口子”由父组件决定往里面放什么。组件只负责这个区域的样式、布局和位置内容完全交给使用方。1.2 插槽的核心设计组件留白内容由父组件决定插槽的设计思路可以类比成电脑主板的接口主板本身有固定的尺寸和电路布局但在机箱前面板的地方留了一个 USB 口你插 U 盘、插鼠标、插散热风扇都走这个接口接口本身不变插上去的设备由你决定。在 Vue 里这个“接口”就是slot标签。子组件在自己的模板里写slot/slot相当于告诉 Vue“这里的内容先空着等父组件使用我的时候它传什么我就显示什么。”举个例子最简单的卡片组件长这样!-- BaseCard.vue -- template div classbase-card div classcard-header span classcard-title卡片标题/span /div div classcard-body slot/slot /div /div /template父组件使用时在标签内部写的内容就会替换掉slot/slotBaseCard p这一段内容会显示在卡片 body 里/p img src... alt配图 / /BaseCard这个看似简单的机制解决的是组件可组合性的问题。组件不再是一个封闭的黑盒而是一个对外开放的半成品使用方可以按需填充自己关心的部分。这在组件库设计里几乎是必须的能力也是我后来封装任何组件时第一时间会考虑的事情哪里是固定的哪里应该是插槽。2. 三种插槽类型的实现要点2.1 默认插槽最简单的组件内容分发默认插槽就是不带name属性的slot也是用得最多的一种。父组件写在子组件标签内部的内容都会进到默认插槽里。它的实现最简单但有几个细节值得注意。第一子组件里可以给默认插槽设置兜底内容也就是当父组件什么都不传时显示的内容div classempty-state slot暂无数据请稍后再试/slot /div父组件传了内容就显示父组件的内容没传内容就显示“暂无数据请稍后再试”。很多同学不知道slot标签中间还能写字这个特性在实际业务里非常实用尤其是在处理空状态、加载失败、默认提示这些场景。第二默认插槽的内容里可以包含任意合法的 Vue 模板结构包括组件、指令、事件绑定。比如BaseCard button clickhandleRefresh RefreshIcon / 刷新数据 /button /BaseCard插槽内容里的click、v-if、v-for这些指令都是基于父组件的作用域来解析的这一点在后面讲作用域插槽时会详细说但默认插槽里也得记住插槽内容写的是父组件的模板它属于父组件。2.2 具名插槽多个分发出口怎么组织当组件里需要多个插槽出口时默认插槽就不够用了。比如一个布局组件头部、侧边栏、内容区、底部都需要由父组件传入这时候就得给每个插槽起名字。子组件定义具名插槽!-- AdminLayout.vue -- template div classadmin-layout header classlayout-header slot nameheader/slot /header aside classlayout-sidebar slot namesidebar/slot /aside main classlayout-main !-- 不带 name 的就是默认插槽 -- slot/slot /main footer classlayout-footer slot namefooter/slot /footer /div /template父组件使用templatev-slot指令来指定内容进哪个插槽v-slot可以简写成#AdminLayout template #header Logo / UserInfo / /template template #sidebar Menu :itemsmenuItems / /template !-- 不带 template 包裹的内容自动进默认插槽 -- p这里是主内容区/p template #footer Copyright / /template /AdminLayout这里有一个非常容易踩坑的地方具名插槽只能用template标签包裹。你不能写div slotheader那是 Vue 2.6 之前的老语法。在 Vue 3 里必须写成template #header除非你的组件里只有一个默认插槽才可以直接把内容写在组件标签内部。另外v-slot有个语法边界它不能用在普通的 HTML 标签上只能用在template标签上或者组件标签上。如果你写成div v-slot:headerVue 会直接报错因为它分不清这个v-slot是给哪个插槽用的。2.3 作用域插槽子组件数据传给父组件的正确姿势前两种插槽解决的是“内容从哪来”的问题作用域插槽解决的是“数据从哪来”的问题。有时候插槽内容需要用到子组件内部的数据。比如我做了一个列表组件列表项的数据在子组件内部通过v-for遍历得到但每一项的展示方式需要由父组件决定这时候父组件怎么才能拿到当前这一项的数据这就是作用域插槽的经典场景。子组件在slot标签上通过属性把数据传出去!-- ProductList.vue -- template ul classproduct-list li v-foritem in products :keyitem.id classproduct-item slot :productitem :indexindex{{ item.name }}/slot /li /ul /template script setup import { ref } from vue const products ref([ { id: 1, name: 机械键盘, price: 399 }, { id: 2, name: 显示器, price: 1299 }, { id: 3, name: USB Hub, price: 89 } ]) /script父组件使用v-slot接收这些数据ProductList template #default{ product, index } div classproduct-info span{{ index 1 }}. {{ product.name }}/span span classprice¥{{ product.price }}/span button clickaddToCart(product)加入购物车/button /div /template /ProductList这里#default后面的解构语法是从插槽属性里取product和index。注意这个解构是在父组件模板里完成的但数据源是子组件。这也是作用域插槽最让新手困惑的地方看起来数据在父组件中用但它不是父组件的数据而是子组件通过插槽属性“递”出来的。作用域插槽还有一个进阶用法同时使用具名和默认作用域插槽时给默认插槽显式命名。比如某个组件既有具名插槽又要给每个具名插槽传数据父组件写成DataTable :datarows template #default{ row } {{ row.name }} /template template #actions{ row } button clickedit(row)编辑/button /template /DataTable记住一点v-slot的值本质上是一个函数参数接收的是一个对象这个对象里的属性就是子组件在slot标签上绑定的所有属性。所以解构语法在插槽里是合法的而且可以给默认值template #default{ row {} } {{ row.name || 未命名 }} /template3. 高频应用场景实战拆解3.1 场景一统一风格 灵活内容的弹窗组件弹窗可能是插槽用得最多的组件之一。后台管理系统里的弹窗外层结构永远是遮罩层、居中面板、标题栏、关闭按钮、底部操作按钮唯一变化的只有中间的内容区。于是我们封装一个全局通用的AppDialog!-- AppDialog.vue -- template Teleport tobody div v-ifvisible classdialog-mask click.selfhandleMaskClick div classdialog-panel div classdialog-header slot nametitle{{ title }}/slot button classdialog-close clickclose×/button /div div classdialog-body slot/slot /div div classdialog-footer slot namefooter button classbtn btn-cancel clickclose取消/button button classbtn btn-confirm clickconfirm确定/button /slot /div /div /div /Teleport /template头部的标题默认读取传入的titleprop但允许父组件用具名插槽覆盖成更复杂的结构比如标题旁加个问号图标、加个 tooltip。底部的按钮默认是“取消 确定”但父组件往往要自定义成“保存草稿 提交审核”这时候具名插槽加默认内容的组合就非常舒服AppDialog :visibletrue title发布文章 template #title span发布文章/span Tooltip text文章发布后可在管理后台编辑 QuestionIcon / /Tooltip /template template #footer button clicksaveDraft存草稿/button button classbtn-primary clickpublish发布/button /template !-- 这里放表单 -- ArticleForm :articlearticle / /AppDialog这也是我封装组件的经验之一凡是“大概率要被覆盖”的内容都先用默认值写一遍再用插槽留出口。这样既保证开箱即用又不把使用方限制死。3.2 场景二二次封装表格组件自定义列渲染做中后台系统基本绕不开表格。直接用 Element Plus 的el-table确实方便但项目里往往有统一的样式和交互要求比如操作列要统一放“编辑/删除/详情”三个按钮某些列要根据状态显示不同颜色标签。这时候就在el-table外面再包一层业务组件。我最常用的一种封装方式是把列配置化 作用域插槽结合!-- ProTable.vue -- template el-table :datadata v-bind$attrs el-table-column v-forcol in columns :keycol.prop :propcol.prop :labelcol.label :widthcol.width template #default{ row } !-- 如果列配置里有 slotName 且父组件传了同名插槽就渲染插槽 -- slot v-ifcol.slotName :namecol.slotName :rowrow :columncol {{ row[col.prop] }} /slot template v-else {{ row[col.prop] }} /template /template /el-table-column /el-table /template script setup defineProps({ data: { type: Array, required: true }, columns: { type: Array, required: true } }) /script使用的时候哪一列要自定义渲染就在 columns 配置里写slotName然后父组件用对应名字的插槽接数据ProTable :datauserList :columnsuserColumns template #status{ row } el-tag :typerow.status active ? success : info {{ row.status active ? 启用 : 禁用 }} /el-tag /template template #action{ row } el-button sizesmall clickeditUser(row)编辑/el-button el-button sizesmall typedanger clickdeleteUser(row)删除/el-button /template /ProTable这里有个细节#status和#action都是具名作用域插槽{ row }是子组件从el-table-column默认插槽中拿到的当前行数据再转递给父组件插槽。这种封装的好处在于表格的“行数据渲染逻辑”全部放到父组件里子组件只负责把el-table-column的透传能力搭好业务变化时不需要改ProTable本身。3.3 场景三表单驱动视图用插槽处理特殊字段另一个我经常用插槽的地方是动态表单。后台类的表单大多长得很像文本框、下拉框、日期选择器但它们又有各自的特殊情况。比如我封装了一个FormRenderer根据配置项动态渲染表单控件同时对需要特殊处理的字段留插槽口FormRenderer :schemaformSchema v-modelformData template #custom-field{ field, value, updateValue } el-upload :file-listvalue change(uploadFile) updateValue(uploadFile) / /template /FormRenderer这样同一个表单渲染组件可以处理普通字段也能处理文件上传这种复杂逻辑。插槽在这里的角色是“逃生舱”凡是配置驱动解决不了的个性化场景都能通过插槽直接接管渲染逻辑而不是回退到写一整个新组件。4. 踩坑记录与排查技巧实录4.1 作用域插槽编译作用域的困惑最常见的问题就是插槽内容里到底能不能用子组件的数据。很多新手写默认插槽时会写ChildComponent {{ childData }} /ChildComponent然后发现childData是 undefined。记住这句口诀插槽内容属于父组件的作用域它只能访问父组件作用域里的数据想用子组件的数据必须通过作用域插槽由子组件“给”出来。这也解释了为什么“插槽是组件间通信的一种手段”。它和 props 一样是数据流只不过 props 是正向的父 → 子作用域插槽是逆向的子 → 父。理解了这个方向基本就不会再犯这个错误。4.2 具名插槽和默认插槽不能混在一个标签上Vue 3 里有个明确的限制一个组件根标签上不能同时用v-slot和具名插槽。比如!-- 错误写法 -- Child v-slot:defaultslotProps v-slot:headerheaderProps这种写法会直接报错因为v-slot只能出现一次。正确做法是用template分开Child template #defaultslotProps.../template template #headerheaderProps.../template /Child其实我把这理解为“具名插槽的操作单元是template”默认插槽是唯一一个可以直接写在组件标签内部的特例。4.3 v-slot 解构出错的两种情况作用域插槽使用解构时有两个细节特别容易出错。第一种情况是解构的数据根本不存在。比如子组件slot :nameitem.name父组件写template #default{ name }但item.name没有传值那就拿不到。这时候就要回到子组件检查slot标签上的属性绑定。第二种情况是解构时的默认值只在属性为undefined时生效如果子组件传的是null默认值也不会生效。所以最好在子组件里就做好空值处理不要把判断逻辑全丢给父组件。4.4 动态插槽名的使用边界Vue 2.6 和 Vue 3 支持用变量作为插槽名写法是template v-slot:[dynamicSlotName]或简写template #[dynamicSlotName]。这个特性我一般在封装“多态渲染”时用比如不同 type 对应不同插槽template v-foritem in items :keyitem.type template #[item.slotName]{ data } component :isgetComponent(item.type) :datadata / /template /template但这里有一个坑动态插槽名可能和具名插槽重名或者动态解析出来直接把默认插槽覆盖掉。有一次我动态插槽名解析出来的值是default结果把整个默认插槽的内容全替换了排查了很久才发现是配置数据里写错了。所以使用动态插槽名时建议在业务层做一层校验确认slotName在白名单内避免鬼畜数据干扰。4.5 作用域插槽的性能问题插槽还有一个不太容易被注意到的点作用域插槽内容变化时会触发子组件重新渲染的范围可能比预期大。因为插槽内容本质上是在父组件作用域内编译的父组件更新时插槽内容相关的虚拟 DOM 也会重新生成。如果插槽里传了非常大的列表且父组件的更新频率很高就可能导致性能下降。解决思路一般是两条一是把计算逻辑放在子组件内部减少父组件无意义的更新触发二是对不需要频繁更新的插槽内容用v-memoVue 3.2需谨慎使用或者把父组件里开销大的部分抽成computed降低更新成本。还有一点不要在插槽内容里直接写内联箭头函数比如click() doSomething(item)这会让父组件每次patch时都生成新函数导致组件库内部为了比较 props 做额外工作。如果遇到复杂表格性能下降优先审视一下这些细节。4.6 常见错误速查表错误现象原因解决方案插槽内的内容不显示子组件里没写slot或写的位置不对检查子组件模板中是否声明了对应 name 的 slot插槽内容无法访问子组件数据在默认插槽里直接使用了子组件内部变量改用作用域插槽通过slot :xxxxxx向父组件暴露编译错误v-slotcan only be used on components ortemplate把v-slot写在了普通 HTML 标签上将v-slot移到template上同时使用默认插槽和具名插槽时结构错乱默认插槽内容没有用template #default包住建议统一用template #default显式声明动态插槽名没有生效插槽名变量没有正确传递或解析成了空字符串打印确认dynamicSlotName的值确保以字符串形式返回Vue 3 中使用了slot/slot-scope这是 Vue 2 已废弃语法改用v-slot完整写法见上文5. 组件设计阶段到底该怎么规划插槽5.1 先定骨架再定插槽做了好几个组件库项目之后我总结出一个相对稳定的流程先画组件的视觉结构和 DOM 骨架再找出“哪些区域是永远不会变的哪些区域未来一定会被外部覆盖”。比如弹窗组件遮罩层、面板圆角、标题栏的位置基本固定这是骨架标题文字、内容区、底部按钮这三块“大概率会变”就应该设计成插槽。再比如表格组件表格边框、表头背景、行高是骨架单元格内容、操作列这是可变区域。判断标准其实就一条一个区域的内容是业务相关的还是组件逻辑相关的业务相关的内容交给插槽组件逻辑相关的样式和布局保留在组件内部。这一条想清楚了后续扩展就会很顺。5.2 插槽默认值要适时使用很多人写插槽不写默认值觉得“反正父组件会传”。但实际业务里插槽没有默认值导致的问题很常见后端数据还没返回时插槽区域是空的视觉上会出现难看的空白或布局跳动。我的建议是能用默认值表达的插槽尽量给默认值。比如一个即将发布的提示组件slot nametip暂未开放敬请期待/slot再比如分页组件的“页面总览”插槽slot nametotal 共 {{ total }} 条第 {{ currentPage }} / {{ pageCount }} 页 /slot这样组件不传任何配置也能良好展示传了配置就可以覆盖成更丰富的样式。这对组件库的“开箱即用”体验非常重要也是我后来评审别人组件代码时一定会 check 的点。5.3 什么时候不该用插槽插槽虽好但不能滥用。如果组件里到处都是插槽那这个组件的“骨架”就没有意义了使用方等于把一个空壳子拿回去自己拼。我的经验是当一个组件里超过四五个插槽而且使用方每次都要写大量模板才能跑起来时就该考虑是不是把组件拆小了。插槽适合作为“可变点”存在而不是让整个组件变成乐高积木。另外如果只是简单的字符串或数字差异优先用 props不要为了“灵活”而定义一堆插槽。插槽的代价是父组件模板变长、类型检查变难、test 覆盖率降低。能用 props 解决的问题不急着上插槽插槽是要为“结构变化”服务的不是为“参数变化”服务的。5.4 插槽命名规范团队协作时插槽命名最好有一个约定。我见过项目里的插槽名有叫content的有叫body的还有叫main的同一个含义三种写法非常痛苦。个人建议是插槽名和 UI 区域强绑定比如header、footer、title、action看一眼就知道它渲染在哪个位置。其次是语义化的功能名比如empty、loading、toolbar。尽量不要用slot1、slot2这种纯序号代码可读性会大打折扣。对于组件库这类公共资产建议在文档里明确列出“可用插槽名称 作用域参数 示例”用起来体验完全不同。6. 扩展插槽与组件通信的联动设计6.1 插槽和 v-model 联动在实际业务中插槽经常和v-model配合使用。比如一个筛选组件父组件通过v-model管理筛选条件但要给某个筛选器留自定义渲染出口FilterPanel v-modelfilters template #price-range{ value, updateValue } el-slider :model-valuevalue update:model-valueupdateValue range / /template /FilterPanel子组件内部维护value时通过抛事件来同步父组件的数据插槽在这里充当了“自定义控件”的接入点。这样既保留了v-model的双向绑定语义又能扩展现有控件的 UI 灵活性。6.2 用插槽实现 renderless 组件还有一个相对进阶的玩法是 renderless 组件也就是“无渲染组件”。组件本身不输出任何 DOM只通过作用域插槽把逻辑和数据暴露给父组件。比如一个倒计时组件!-- Countdown.vue -- script setup import { ref, onMounted, onUnmounted } from vue const props defineProps({ endTime: { type: Number, required: true } }) const remaining ref(0) let timer null function update() { remaining.value Math.max(0, props.endTime - Date.now()) } onMounted(() { update() timer setInterval(update, 1000) }) onUnmounted(() clearInterval(timer)) /script template slot :remainingremaining :formatformatRemaining / /template script function formatRemaining(ms) { const totalSeconds Math.floor(ms / 1000) const days Math.floor(totalSeconds / 86400) const hours Math.floor((totalSeconds % 86400) / 3600) const minutes Math.floor((totalSeconds % 3600) / 60) const seconds totalSeconds % 60 return ${days}天 ${hours}小时 ${minutes}分 ${seconds}秒 } /script等等上面这段script里函数定义的位置不对实际项目中会换成script setup ... function formatRemaining(ms) { ... } /script这样父组件就能完全控制倒计时的视觉表现逻辑在子组件里维护。这种模式在需要“逻辑复用 视图自定义”的场景下特别香。6.3 插槽在组件库二次封装中的地位现在很多团队都在做自己的中后台组件封装比如二次封装 Element Plus、Ant Design Vue。插槽在这里几乎是绕不开的工具你要保留原组件库的插槽同时在原组件的外面包一层自己的业务逻辑。如果你的封装组件只是简单地透传slot /那使用方没法直接利用原组件库的插槽能力。解决办法是显式把原组件库的插槽接出来再传回去!-- 封装 el-select 时保留原插槽 -- el-select v-bind$attrs v-modelmodel template v-for(_, name) in $slots #[name]slotProps slot :namename v-bindslotProps / /template /el-select$slots是父组件传给当前组件的所有插槽集合用动态插槽名把原组件的插槽和父组件的插槽建立连接。这样封装之后使用方传入的插槽还能继续在el-select内部生效不会被我们的中间层吃掉。这个技巧在我们项目里帮了大忙也是目前供应链上中后台组件封装最高频的需求之一。最后聊几句实用的做 Vue 组件设计这几年我最深的体会是插槽不是“万能的银弹”但它是组件设计里“开放性”的关键手段。一个组件如果只有 props 没有插槽就像一台焊死了面板的设备用户只能按预设按钮操作加上插槽之后这台设备变成了开放的平台使用方可以在结构层面自由发挥。如果你正在学 Vue建议从今天开始找一个自己写过的组件尝试把其中“可能被外部覆盖”的部分改成插槽。改完之后你会直观地感受到组件的复用性提升不靠堆参数而靠设计出口。踩坑几次之后我现在设计任何组件的固定流程基本是先问三句话——哪里是骨架哪里是业务多变区域这些多变区域的数据从哪里来把这三个问题回答清楚了插槽的类型、命名和作用域参数就都定了。剩下的就是写代码验证了。