Vue3里slot和事件怎么配合?从基础到实践全解析
Vue3的slot基础和事件有啥关联?
很多刚学Vue3的同学,对slot(插槽)和event(事件)的关系容易懵:插槽明明是用来“塞内容”的,咋和事件扯上关系了?其实核心逻辑是 “插槽里的内容要和外部(父组件)互动” ——比如插槽里放个按钮,点击后要告诉父组件“我被点了,要干啥干啥”;或者插槽里是个输入框,输入变化后要把值传给父组件,这时候,事件就是“互动”的桥梁。
先回忆slot的基础:子组件用<slot>标签留个“坑”,父组件用<template #插槽名>往坑里塞内容,比如子组件MyButton.vue长这样:
<template>
<button class="custom-btn">
<slot></slot> <!-- 父组件往这里塞文字或元素 -->
</button>
</template>
<style scoped>
.custom-btn { padding: 8px 16px; }
</style>
父组件用的时候,塞个“提交”文字:
<MyButton>提交</MyButton>
这时候按钮能点击,但点击后没反应——因为没绑定事件,如果要让点击后父组件执行逻辑,就得处理事件绑定,这时候分两种情况:
情况1:插槽里是原生DOM元素(如button、input)
原生DOM的事件(如click、input)会“冒泡”,所以父组件可以直接在子组件标签上监听事件,比如给MyButton加@click:
<MyButton @click="handleSubmit">提交</MyButton>
<script setup>
const handleSubmit = () => {
console.log('按钮被点了,要提交表单啦~');
};
</script>
这时候点击“提交”按钮,会触发子组件内部button的click事件,然后冒泡到MyButton组件标签,父组件的@click就能监听到,这时候事件是原生DOM事件的冒泡传递,子组件没写任何事件逻辑,全靠DOM默认行为。
情况2:插槽里是自定义组件(非原生DOM)
比如父组件往MyButton的slot里塞一个自己写的<CustomIcon>组件,这个组件点击后要通知父组件,这时候原生事件冒泡没用了,得用自定义事件(emit)。
子组件MyButton得把事件“透传”吗?不,应该是<CustomIcon>自己emit事件,父组件监听,但如果MyButton想封装逻辑(比如点击时加loading状态),那MyButton得自己处理click事件,再emit给父组件。
举个例子,MyButton要控制点击后loading:
<template>
<button class="custom-btn" @click="handleClick" :disabled="loading">
<slot></slot>
<span v-if="loading">加载中...</span>
</button>
</template>
<script setup>
import { ref } from 'vue';
const loading = ref(false);
const emit = defineEmits(['btn-click']);
const handleClick = () => {
loading.value = true;
emit('btn-click'); // 通知父组件“我被点了,现在loading”
setTimeout(() => {
loading.value = false;
}, 2000);
};
</script>
父组件用的时候,监听btn-click事件:
<MyButton @btn-click="handleSubmit">提交</MyButton>
<script setup>
const handleSubmit = () => {
console.log('父组件收到点击事件,执行提交逻辑~');
};
</script>
这时候slot里的内容(比如文字“提交”)被MyButton的button包裹,点击时先触发MyButton的handleClick(设置loading),再emit给父组件,这就是子组件封装事件逻辑,通过emit和父组件通信,而slot负责内容展示。
所以slot和事件的关联,本质是在哪里,事件逻辑由谁控制” ——要么父组件直接控制插槽内原生DOM的事件,要么子组件封装事件逻辑后emit给父组件,插槽只负责展示。
插槽里咋给父组件传事件?分普通插槽和作用域插槽说
前面讲了基础关联,现在深入:不同类型的插槽(普通插槽、作用域插槽),事件传递的方式不一样,得分别搞清楚,不然写代码容易卡壳。
普通插槽:事件由父组件“直接管”还是子组件“封装管”?
普通插槽指的是子组件不给slot传数据,父组件塞啥内容,子组件只负责展示,这时候事件传递分两种写法:
写法A:父组件直接在插槽内容上绑事件(适合原生DOM)
比如子组件MyCard.vue是个卡片容器:
<template>
<div class="card">
<header><slot name="header"></slot></header>
<section><slot></slot></section>
<footer><slot name="footer"></slot></footer>
</div>
</template>
<style scoped>
.card { border: 1px solid #eee; padding: 16px; }
</style>
父组件往header插槽塞个按钮,直接在按钮上绑@click:
<MyCard>
<template #header>
<button @click="handleHeaderClick">展开卡片</button>
</template>
<p>卡片内容...</p>
<template #footer>
<button @click="handleFooterClick">收藏卡片</button>
</template>
</MyCard>
<script setup>
const handleHeaderClick = () => {
console.log('头部按钮被点,展开卡片~');
};
const handleFooterClick = () => {
console.log('底部按钮被点,收藏卡片~');
};
</script>
这种写法的核心是:插槽里的元素是父组件自己写的,所以事件逻辑直接写在父组件,子组件只是个容器,不管事件,只负责布局和样式。
写法B:子组件封装事件,emit给父组件(适合自定义逻辑)
如果子组件要控制事件的“前置逻辑”(比如防重复点击、权限判断),就得子组件自己绑事件,再emit。
比如MyCard的header插槽要加“点击后loading2秒再通知父组件”:
<template>
<div class="card">
<header>
<slot name="header">
<!-- 子组件默认内容,父组件没传的话显示这个 -->
<button @click="handleHeaderClick" :disabled="headerLoading">
{{ headerLoading ? '加载中...' : '展开卡片' }}
</button>
</slot>
</header>
<!-- 其他插槽... -->
</div>
</template>
<script setup>
import { ref } from 'vue';
const headerLoading = ref(false);
const emit = defineEmits(['header-click']);
const handleHeaderClick = () => {
headerLoading.value = true;
emit('header-click');
setTimeout(() => {
headerLoading.value = false;
}, 2000);
};
</script>
父组件用的时候,不需要在插槽内容绑事件,直接监听子组件的header-click:
<MyCard @header-click="handleHeaderClick">
<!-- 父组件可以选择不传header插槽,用子组件默认的按钮 -->
<p>卡片内容...</p>
</MyCard>
<script setup>
const handleHeaderClick = () => {
console.log('父组件收到头部点击事件,执行展开逻辑~');
};
</script>
这种写法的优势是子组件封装了交互逻辑(loading),父组件只需要关心“事件触发后做什么”,不用管中间过程,适合子组件需要统一逻辑的场景(比如所有按钮点击都要loading)。
作用域插槽:子组件传数据,事件咋双向玩?
作用域插槽是子组件给slot传数据,父组件可以用这些数据,这时候事件传递更灵活——子组件不仅传数据,还能传“事件方法”,父组件可以选择“直接用”或“包装后用”。
举个经典场景:todo列表,每个todo项的删除按钮,子组件控制删除逻辑(比如发请求删数据),但按钮的UI由父组件自定义。
子组件TodoItem.vue:
<template>
<li class="todo-item">
<slot
:todo="todo"
:onDelete="handleDelete"
:onEdit="handleEdit"
></slot>
</li>
</template>
<script setup>
import { defineProps, defineEmits, ref } from 'vue';
const props = defineProps(['todo']); // todo是父组件传的待办对象,包含id、title等
const emit = defineEmits(['delete', 'edit']);
const handleDelete = () => {
// 子组件可以加前置逻辑,比如确认弹窗
if (window.confirm('确定删除?')) {
emit('delete', props.todo.id); // 通知父组件删除这个todo
}
};
const handleEdit = () => {
emit('edit', props.todo); // 通知父组件编辑这个todo
};
</script>
父组件用作用域插槽,拿到todo、onDelete、onEdit:
<template>
<ul>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@delete="handleRealDelete"
@edit="handleRealEdit"
>
<template #default="{ todo, onDelete, onEdit }">
<!-- 父组件自定义todo项的UI -->
<span>{{ todo.title }}</span>
<button @click="onDelete">删除</button> <!-- 直接用子组件的onDelete -->
<button @click="handleCustomEdit(todo, onEdit)">编辑</button> <!-- 包装后用onEdit -->
</template>
</TodoItem>
</ul>
</template>
<script setup>
import { ref } from 'vue';
const todos = ref([/* 待办数据 */]);
const handleRealDelete = (todoId) => {
// 父组件发请求删除数据,更新todos
todos.value = todos.value.filter(t => t.id !== todoId);
};
const handleRealEdit = (todo) => {
// 父组件打开编辑弹窗,传todo数据
console.log('要编辑的todo:', todo);
};
const handleCustomEdit = (todo, onEdit) => {
// 父组件加自定义逻辑,比如记录编辑时间
todo.editTime = new Date();
onEdit(); // 调用子组件的onEdit,触发emit('edit', todo)
};
</script>
这里的关键是:子组件通过作用域插槽,把“带逻辑的事件方法”(onDelete、onEdit)传给父组件,父组件可以:
- 直接调用(如
<button @click="onDelete">):触发子组件的handleDelete(含确认逻辑),再emit给父组件。 - 包装后调用(如
handleCustomEdit):父组件先处理一些逻辑(如给todo加editTime),再调用onEdit,触发子组件的emit。
这种模式实现了 “子组件管逻辑,父组件管UI+可选逻辑增强” 的解耦,特别适合通用组件(如表格、列表项)的封装。
作用域插槽和事件结合,这些细节容易踩坑!
很多同学学了基础用法后,写代码还是容易出错,因为没注意这些“细节陷阱”,把常见坑列出来,避坑才能写得顺!
事件的“作用域”搞混:父组件不能直接emit子组件的事件
错误案例:父组件想自己触发子组件的emit,结果报错。
<!-- 父组件错误写法 -->
<template #default="{ todo }">
<button @click="$emit('delete', todo.id)">删除</button>
<!-- 这里$emit是父组件的!子组件的emit父组件拿不到,所以触发无效 -->
</template>
为啥错?因为$emit是当前组件(父组件)的实例方法,子组件的emit是子组件的实例方法,父组件没法直接调用子组件的emit,必须通过子组件传递的事件方法(如onDelete)来触发。
正确做法:子组件把emit封装成方法传给插槽,父组件调用这个方法。
<!-- 子组件TodoItem.vue里的slot --> <slot :onDelete="handleDelete"></slot> <!-- 父组件里调用 --> <button @click="onDelete">删除</button>
这里onDelete是子组件的handleDelete方法,里面调用了子组件的emit('delete', ...),所以能正确触发。
事件冒泡导致“一次点击,多个事件触发”
比如子组件用div包裹slot,div自己绑了click事件,插槽里的按钮也绑了click事件:
<template>
<div class="container" @click="handleDivClick">
<slot></slot>
</div>
</template>
<script setup>
const handleDivClick = () => {
console.log('div被点击');
};
</script>
父组件往slot里塞按钮:
<MyContainer>
<button @click="handleBtnClick">点我</button>
</MyContainer>
<script setup>
const handleBtnClick = () => {
console.log('按钮被点击');
};
</script>
这时候点击按钮,会先触发button的handleBtnClick,然后触发div的handleDivClick(因为事件冒泡),如果这两个事件有冲突(比如按钮是“关闭”,div是“展开”),就会逻辑混乱。
解决方法:用事件修饰符.stop阻止冒泡。
<MyContainer> <button @click.stop="handleBtnClick">点我</button> </MyContainer>
这样点击按钮时,事件到button就停止冒泡,不会触发div的click事件。
TypeScript项目里,作用域插槽的事件方法要写类型
如果项目用TypeScript,作用域插槽传递的方法必须定义类型,否则编辑器会报错,运行时也可能出问题。
子组件TodoItem.vue(TS版):
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
interface Todo {
id: number; string;
}
const props = defineProps<{
todo: Todo;
}>();
// 定义emit的类型:事件名是'delete',参数是number(todo.id)
const emit = defineEmits<{
(event: 'delete', id: number): void;
(event: 'edit', todo: Todo): void;
}>();
const handleDelete = () => {
emit('delete', props.todo.id);
};
const handleEdit = () => {
emit('edit', props.todo);
};
</script>
父组件用的时候,作用域插槽的参数会自动推导类型:
<template #default="{ todo, onDelete, onEdit }">
<!-- onDelete的类型是 () => void -->
<!-- onEdit的类型是 () => void -->
<button @click="onDelete">删除</button>
</template>
如果不定义类型,父组件里的onDelete可能被当成any类型,容易传错参数或调用错误,所以TS项目里,emit和作用域插槽的类型定义一定要写清楚。
作用域插槽的“参数解构”容易漏传
比如子组件传了多个参数(todo、onDelete、onEdit),父组件解构时漏写了:
<template #default="{ todo }"> <!-- 漏了onDelete、onEdit -->
<button @click="onDelete">删除</button> <!-- 这里onDelete未定义,报错! -->
</template>
解决方法:要么全解构,要么用...rest接收所有参数:
<template #default="{ todo, ...rest }">
<button @click="rest.onDelete">删除</button>
</template>
或者父组件不需要的参数可以忽略,但要用的必须解构出来。
实战场景:这些slot事件的用法你肯定用得到!
光讲理论不够,结合实际业务场景,才能真正理解“slot+事件”怎么用,分享两个高频场景,看完就能套用到项目里。
场景1:动态表格的“操作列”自定义
后台管理系统里,表格的“操作列”(编辑、删除按钮)通常需要自定义,用作用域插槽+事件,既保留
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


