Code前端首页关于Code前端联系我们

Vue3里slot和事件怎么配合?从基础到实践全解析

terry 2小时前 阅读数 7 #SEO
文章标签 Vue3插槽

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>

这时候点击“提交”按钮,会触发子组件内部buttonclick事件,然后冒泡到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里的内容(比如文字“提交”)被MyButtonbutton包裹,点击时先触发MyButtonhandleClick(设置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

比如MyCardheader插槽要加“点击后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>

父组件用作用域插槽,拿到todoonDeleteonEdit

<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>

这里的关键是:子组件通过作用域插槽,把“带逻辑的事件方法”(onDeleteonEdit)传给父组件,父组件可以:

  • 直接调用(如<button @click="onDelete">):触发子组件的handleDelete(含确认逻辑),再emit给父组件。
  • 包装后调用(如handleCustomEdit):父组件先处理一些逻辑(如给todoeditTime),再调用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>

这时候点击按钮,会先触发buttonhandleBtnClick,然后触发divhandleDivClick(因为事件冒泡),如果这两个事件有冲突(比如按钮是“关闭”,div是“展开”),就会逻辑混乱。

解决方法:用事件修饰符.stop阻止冒泡。

<MyContainer>
  <button @click.stop="handleBtnClick">点我</button>
</MyContainer>

这样点击按钮时,事件到button就停止冒泡,不会触发divclick事件。

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和作用域插槽的类型定义一定要写清楚

作用域插槽的“参数解构”容易漏传

比如子组件传了多个参数(todoonDeleteonEdit),父组件解构时漏写了:

<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前端网发表,如需转载,请注明页面地址。

热门