Vue 3 插槽完全指南:构建灵活组件的终极武器

1. 插槽基础概念

1.1 什么是插槽?

插槽是 Vue 组件的一种内容分发机制,允许父组件向子组件传递模板内容。

1.2 基本语法

<!-- 子组件 ChildComponent.vue -->
<template>
  <div class="child">
    <h2>子组件标题</h2>
    <slot></slot>
  </div>
</template>

<!-- 父组件使用 -->
<template>
  <ChildComponent>
    <p>这是插入到子组件中的内容</p>
  </ChildComponent>
</template>

2. 默认插槽

2.1 基本默认插槽

<!-- 子组件 BaseCard.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot>默认内容</slot>
    </div>
  </div>
</template>

<!-- 父组件使用 -->
<template>
  <!-- 使用自定义内容 -->
  <BaseCard>
    <h3>自定义标题</h3>
  </BaseCard>

  <!-- 使用默认内容 -->
  <BaseCard />
</template>

2.2 多个插槽内容

<!-- 父组件 -->
<template>
  <BaseCard>
    <p>第一段内容</p>
    <p>第二段内容</p>
    <p>第三段内容</p>
  </BaseCard>
</template>

<!-- 渲染结果 -->
<div class="card">
  <div class="card-header">
    <p>第一段内容</p>
    <p>第二段内容</p>
    <p>第三段内容</p>
  </div>
</div>

3. 具名插槽

3.1 基本具名插槽

<!-- 子组件 Layout.vue -->
<template>
  <div class="layout">
    <header class="header">
      <slot name="header"></slot>
    </header>
    
    <main class="main">
      <slot></slot>  <!-- 默认插槽 -->
    </main>
    
    <footer class="footer">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<!-- 父组件使用 -->
<template>
  <Layout>
    <template #header>
      <h1>页面标题</h1>
    </template>

    <!-- 默认插槽内容 -->
    <p>这是主要内容...</p>
    <p>更多内容...</p>

    <template #footer>
      <p>版权信息 © 2024</p>
    </template>
  </Layout>
</template>

3.2 具名插槽的语法糖

<!-- 三种等价写法 -->
<template #header>...</template>
<template v-slot:header>...</template>
<slot name="header">...</slot>

4. 作用域插槽

4.1 基本作用域插槽

<!-- 子组件 UserList.vue -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot :user="user" :index="index"></slot>
    </li>
  </ul>
</template>

<script setup>
import { ref } from 'vue'

const users = ref([
  { id: 1, name: 'Alice', age: 25 },
  { id: 2, name: 'Bob', age: 30 }
])
</script>

<!-- 父组件使用 -->
<template>
  <UserList>
    <template #default="{ user, index }">
      <span>{{ index + 1 }}. {{ user.name }} - {{ user.age }}岁</span>
    </template>
  </UserList>
</template>

4.2 具名作用域插槽

<!-- 子组件 DataTable.vue -->
<template>
  <table>
    <thead>
      <tr>
        <slot name="header" :columns="columns"></slot>
      </tr>
    </thead>
    <tbody>
      <tr v-for="item in data" :key="item.id">
        <slot name="body" :item="item" :index="index"></slot>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
const columns = ['姓名', '年龄', '操作']
const data = [
  { id: 1, name: 'Alice', age: 25 },
  { id: 2, name: 'Bob', age: 30 }
]
</script>

<!-- 父组件使用 -->
<template>
  <DataTable>
    <template #header="{ columns }">
      <th v-for="col in columns" :key="col">{{ col }}</th>
    </template>

    <template #body="{ item, index }">
      <td>{{ item.name }}</td>
      <td>{{ item.age }}</td>
      <td>
        <button @click="editItem(item)">编辑</button>
      </td>
    </template>
  </DataTable>
</template>

5. 动态插槽名

5.1 动态插槽

<!-- 父组件 -->
<template>
  <DynamicSlotComponent>
    <template #[dynamicSlotName]>
      <p>动态插槽内容</p>
    </template>
    
    <template v-slot:[anotherSlotName]>
      <p>另一个动态插槽</p>
    </template>
  </DynamicSlotComponent>
</template>

<script setup>
import { ref } from 'vue'

const dynamicSlotName = ref('header')
const anotherSlotName = ref('footer')
</script>

6. 插槽高级用法

6.1 条件插槽

<!-- 子组件 ConditionalSlot.vue -->
<template>
  <div>
    <slot name="content" v-if="showContent"></slot>
    <slot name="fallback" v-else></slot>
  </div>
</template>

<script setup>
defineProps({
  showContent: Boolean
})
</script>

<!-- 父组件使用 -->
<template>
  <ConditionalSlot :show-content="isContentVisible">
    <template #content>
      <p>主要内容</p>
    </template>
    
    <template #fallback>
      <p>备用内容</p>
    </template>
  </ConditionalSlot>
</template>

6.2 插槽传递

<!-- 中间组件 Wrapper.vue -->
<template>
  <ChildComponent>
    <!-- 将插槽传递给子组件 -->
    <template v-for="(_, name) in $slots" #[name]="slotProps">
      <slot :name="name" v-bind="slotProps"></slot>
    </template>
  </ChildComponent>
</template>

7. 使用 <script setup> 的插槽

7.1 插槽类型定义

<!-- 子组件 TypedSlot.vue -->
<template>
  <div>
    <slot name="item" :data="itemData" :index="0"></slot>
    <slot :user="currentUser"></slot>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

interface User {
  name: string
  age: number
}

interface ItemData {
  id: number
  title: string
}

// 组件数据
const itemData = ref<ItemData>({ id: 1, title: '示例' })
const currentUser = ref<User>({ name: 'John', age: 25 })

// 插槽类型定义(用于 TypeScript)
defineSlots<{
  // 具名插槽
  item: (props: { data: ItemData; index: number }) => any
  // 默认插槽
  default: (props: { user: User }) => any
}>()
</script>

7.2 使用 useSlots()

<!-- 子组件 SlotInspector.vue -->
<template>
  <div>
    <div v-if="hasHeaderSlot" class="header">
      <slot name="header"></slot>
    </div>
    
    <div class="content">
      <slot></slot>
    </div>
    
    <div v-if="hasFooterSlot" class="footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script setup>
import { useSlots, computed } from 'vue'

const slots = useSlots()

// 检查插槽是否存在
const hasHeaderSlot = computed(() => !!slots.header)
const hasFooterSlot = computed(() => !!slots.footer)
const hasDefaultSlot = computed(() => !!slots.default)
</script>

8. 实战案例

8.1 可复用的模态框组件

<!-- Modal.vue -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <div class="modal-header">
        <slot name="header">
          <h3>{{ title }}</h3>
        </slot>
        <button @click="$emit('close')" class="close-btn">×</button>
      </div>
      
      <div class="modal-body">
        <slot></slot>
      </div>
      
      <div class="modal-footer">
        <slot name="footer">
          <button @click="$emit('close')">关闭</button>
        </slot>
      </div>
    </div>
  </div>
</template>

<script setup>
defineProps({
  visible: Boolean,
  title: {
    type: String,
    default: '默认标题'
  }
})

defineEmits(['close'])
</script>

<style scoped>
.modal {
  position: fixed;
  top: 0; left: 0;
  width: 100%; height: 100%;
  background: rgba(0,0,0,0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal-content {
  background: white;
  padding: 20px;
  border-radius: 8px;
  min-width: 400px;
}

.modal-header {
  display: flex;
  justify-content: between;
  align-items: center;
  margin-bottom: 15px;
}

.close-btn {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
}
</style>

<!-- 父组件使用 -->
<template>
  <button @click="showModal = true">打开模态框</button>
  
  <Modal v-model:visible="showModal" title="用户信息">
    <template #header>
      <h3 style="color: blue;">自定义标题</h3>
    </template>
    
    <p>这是模态框的主要内容...</p>
    <p>可以放置任何内容</p>
    
    <template #footer>
      <button @click="showModal = false">取消</button>
      <button @click="confirm">确认</button>
    </template>
  </Modal>
</template>

<script setup>
import { ref } from 'vue'

const showModal = ref(false)

const confirm = () => {
  console.log('确认操作')
  showModal.value = false
}
</script>

8.2 数据列表组件

<!-- DataList.vue -->
<template>
  <div class="data-list">
    <div class="list-header">
      <slot name="header" :total="data.length" :filtered="filteredData.length">
        共 {{ filteredData.length }} 条数据
      </slot>
    </div>
    
    <div class="list-items">
      <div 
        v-for="(item, index) in filteredData" 
        :key="item.id" 
        class="list-item"
      >
        <slot 
          name="item" 
          :item="item" 
          :index="index" 
          :is-selected="selectedItem?.id === item.id"
        >
          <div>{{ item.name }}</div>
        </slot>
      </div>
    </div>
    
    <div class="list-footer">
      <slot name="footer" :selected="selectedItem"></slot>
    </div>
  </div>
</template>

<script setup>
import { computed, ref } from 'vue'

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  filter: {
    type: String,
    default: ''
  }
})

const selectedItem = ref(null)

const filteredData = computed(() => {
  if (!props.filter) return props.data
  return props.data.filter(item => 
    item.name.toLowerCase().includes(props.filter.toLowerCase())
  )
})

const selectItem = (item) => {
  selectedItem.value = item
}

// 暴露方法给父组件
defineExpose({
  selectItem,
  selectedItem
})
</script>

<!-- 父组件使用 -->
<template>
  <DataList :data="users" :filter="searchText">
    <template #header="{ total, filtered }">
      <div style="display: flex; justify-content: space-between;">
        <span>用户列表 ({{ filtered }}/{{ total }})</span>
        <input v-model="searchText" placeholder="搜索用户..." />
      </div>
    </template>
    
    <template #item="{ item, index, isSelected }">
      <div 
        :class="['user-item', { selected: isSelected }]"
        @click="selectUser(item)"
      >
        <div class="user-avatar">{{ item.name.charAt(0) }}</div>
        <div class="user-info">
          <div class="user-name">{{ item.name }}</div>
          <div class="user-email">{{ item.email }}</div>
        </div>
        <div class="user-actions">
          <button @click.stop="editUser(item)">编辑</button>
        </div>
      </div>
    </template>
    
    <template #footer="{ selected }">
      <div v-if="selected" class="selected-info">
        已选择: {{ selected.name }}
      </div>
    </template>
  </DataList>
</template>

<script setup>
import { ref } from 'vue'

const searchText = ref('')
const users = ref([
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
  { id: 3, name: 'Charlie', email: 'charlie@example.com' }
])

const selectUser = (user) => {
  console.log('选择用户:', user)
}

const editUser = (user) => {
  console.log('编辑用户:', user)
}
</script>

<style scoped>
.user-item {
  display: flex;
  align-items: center;
  padding: 10px;
  border: 1px solid #ddd;
  margin: 5px 0;
  cursor: pointer;
}

.user-item.selected {
  background-color: #e3f2fd;
  border-color: #2196f3;
}

.user-avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #2196f3;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 10px;
}

.user-info {
  flex: 1;
}

.user-name {
  font-weight: bold;
}

.user-email {
  color: #666;
  font-size: 0.9em;
}
</style>

9. 最佳实践

9.1 提供合理的默认内容

<template>
  <div class="component">
    <slot name="title">
      <h2>默认标题</h2>
    </slot>
    
    <slot>
      <p>请提供内容</p>
    </slot>
  </div>
</template>

9.2 使用作用域插槽提供数据

<template>
  <slot :data="componentData" :methods="{ addItem, removeItem }"></slot>
</template>

9.3 类型安全的插槽(TypeScript)

<script setup lang="ts">
interface SlotProps {
  item: User
  index: number
  isActive: boolean
}

defineSlots<{
  default: (props: SlotProps) => any
  header: (props: { title: string }) => any
}>()
</script>

10. 常见问题与解决方案

10.1 插槽内容更新问题

<!-- 使用 key 强制重新渲染 -->
<slot :key="item.id"></slot>

10.2 条件插槽渲染

<template>
  <div v-if="$slots.default">
    <slot></slot>
  </div>
  <div v-else>
    <p>没有内容</p>
  </div>
</template>
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐