具名插槽与作用域插槽

在上一集中,我们学习了插槽的基础知识,包括匿名插槽和具名插槽。在这一集中,我们将深入学习插槽的高级用法,特别是作用域插槽,它允许父组件在插槽内容中访问子组件的数据,实现更灵活的组件通信和内容定制。

1. 具名插槽的高级用法

1.1 动态插槽名

在Vue 2.6+中,我们可以使用动态指令参数来定义动态的插槽名:

<!-- 父组件 -->
<template>
  <div>
    <h2>动态插槽名示例</h2>
    <!-- 动态插槽名 -->
    <ChildComponent>
      <template v-slot:[dynamicSlotName]>
        <p>这是动态插槽的内容</p>
      </template>
      
      <!-- 简写形式 -->
      <template #[dynamicSlotName]>
        <p>这是动态插槽的内容(简写)</p>
      </template>
    </ChildComponent>
    
    <!-- 切换插槽名 -->
    <button @click="toggleSlotName">切换插槽名</button>
  </div>
</template>

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

const dynamicSlotName = ref('header')

const toggleSlotName = () => {
  dynamicSlotName.value = dynamicSlotName.value === 'header' ? 'footer' : 'header'
}
</script>

<!-- 子组件 -->
<template>
  <div class="container">
    <header>
      <slot name="header">默认头部</slot>
    </header>
    <footer>
      <slot name="footer">默认底部</slot>
    </footer>
  </div>
</template>

1.2 插槽的缩写语法

Vue提供了插槽的缩写语法,使用#符号代替v-slot:

<!-- 完整语法 -->
<template v-slot:header>
  <h1>自定义头部</h1>
</template>

<!-- 缩写语法 -->
<template #header>
  <h1>自定义头部</h1>
</template>

2. 作用域插槽

2.1 什么是作用域插槽

作用域插槽是一种特殊的插槽,它允许子组件向插槽内容传递数据。这意味着父组件可以在插槽内容中访问子组件的数据,实现更灵活的内容定制。

2.2 基本用法

在子组件中,我们可以通过v-bind指令(或简写:)向插槽传递数据:

<!-- 子组件 -->
<template>
  <div class="list">
    <div
      v-for="(item, index) in items"
      :key="index"
      class="list-item"
    >
      <!-- 向插槽传递数据 -->
      <slot :item="item" :index="index" :is-even="index % 2 === 0">
        <!-- 默认内容 -->
        {{ item }}
      </slot>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  items: {
    type: Array,
    default: () => []
  }
})
</script>

<!-- 父组件 -->
<template>
  <div>
    <h2>作用域插槽示例</h2>
    <ChildComponent :items="fruits">
      <!-- 使用作用域插槽访问子组件数据 -->
      <template #default="slotProps">
        <div class="fruit-item" :class="{ 'even': slotProps.isEven }">
          <span class="index">{{ slotProps.index + 1 }}.</span>
          <span class="name">{{ slotProps.item.name }}</span>
          <span class="price">¥{{ slotProps.item.price.toFixed(2) }}</span>
        </div>
      </template>
    </ChildComponent>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const fruits = ref([
  { name: '苹果', price: 5.99 },
  { name: '香蕉', price: 3.99 },
  { name: '橙子', price: 4.99 },
  { name: '葡萄', price: 9.99 }
])
</script>

<style scoped>
.fruit-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px;
  border-bottom: 1px solid #eee;
}

.fruit-item.even {
  background-color: #f8fafc;
}

.index {
  font-weight: bold;
  color: #666;
  width: 20px;
}

.name {
  flex: 1;
}

.price {
  color: #ef4444;
  font-weight: bold;
}
</style>

2.3 解构插槽 props

我们可以使用ES6的解构语法来简化插槽props的使用:

<!-- 父组件 -->
<template>
  <ChildComponent :items="fruits">
    <!-- 解构插槽props -->
    <template #default="{ item, index, isEven }">
      <div class="fruit-item" :class="{ 'even': isEven }">
        <span class="index">{{ index + 1 }}.</span>
        <span class="name">{{ item.name }}</span>
        <span class="price">¥{{ item.price.toFixed(2) }}</span>
      </div>
    </template>
  </ChildComponent>
</template>

2.4 具名作用域插槽

作用域插槽也可以是具名插槽:

<!-- 子组件 -->
<template>
  <div class="card">
    <div class="card-header">
      <!-- 具名作用域插槽 -->
      <slot name="header" :title="defaultTitle">{{ defaultTitle }}</slot>
    </div>
    <div class="card-body">
      <!-- 匿名作用域插槽 -->
      <slot :content="defaultContent">{{ defaultContent }}</slot>
    </div>
  </div>
</template>

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

const defaultTitle = ref('默认标题')
const defaultContent = ref('默认内容')
</script>

<!-- 父组件 -->
<template>
  <ChildComponent>
    <!-- 具名作用域插槽 -->
    <template #header="{ title }">
      <h3>{{ title }} - 自定义头部</h3>
    </template>
    
    <!-- 匿名作用域插槽 -->
    <template #default="{ content }">
      <p>{{ content }} - 自定义内容</p>
    </template>
  </ChildComponent>
</template>

3. 独占默认插槽的缩写

当一个组件只有一个默认插槽时,我们可以使用更简洁的语法:

<!-- 完整语法 -->
<ChildComponent>
  <template #default="slotProps">
    <p>{{ slotProps.message }}</p>
  </template>
</ChildComponent>

<!-- 缩写语法 -->
<ChildComponent #default="slotProps">
  <p>{{ slotProps.message }}</p>
</ChildComponent>

<!-- 进一步缩写 -->
<ChildComponent v-slot="slotProps">
  <p>{{ slotProps.message }}</p>
</ChildComponent>

4. 组合式API中的作用域插槽

在组合式API中,我们可以使用useSlots函数来访问作用域插槽:

<template>
  <div class="container">
    <slot name="header" :data="headerData">默认头部</slot>
    <slot :data="bodyData">默认内容</slot>
  </div>
</template>

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

const headerData = ref('头部数据')
const bodyData = ref('内容数据')

const slots = useSlots()

onMounted(() => {
  // 检查是否提供了header插槽
  if (slots.header) {
    console.log('提供了header插槽')
  }
  
  // 检查是否提供了默认插槽
  if (slots.default) {
    console.log('提供了默认插槽')
  }
})
</script>

5. 动态插槽内容

我们可以使用v-ifv-for等指令来动态生成插槽内容:

<template>
  <ChildComponent>
    <template #item="{ item, index }">
      <div v-if="item.isActive" class="active-item">
        <span>{{ index + 1 }}. {{ item.name }}</span>
        <button @click="deactivateItem(index)">停用</button>
      </div>
      <div v-else class="inactive-item">
        <span>{{ index + 1 }}. {{ item.name }}</span>
        <button @click="activateItem(index)">激活</button>
      </div>
    </template>
  </ChildComponent>
</template>

6. 完整示例:自定义表格组件

<!-- CustomTable.vue 子组件 -->
<template>
  <div class="custom-table">
    <!-- 表格头部 -->
    <div class="table-header">
      <div
        v-for="(column, index) in columns"
        :key="index"
        class="table-header-cell"
        :style="{ width: column.width || 'auto' }"
      >
        <slot name="header-cell" :column="column" :index="index">
          {{ column.label }}
        </slot>
      </div>
    </div>
    
    <!-- 表格主体 -->
    <div class="table-body">
      <div
        v-for="(row, rowIndex) in data"
        :key="rowIndex"
        class="table-row"
        :class="{ 'row-even': rowIndex % 2 === 0 }"
      >
        <div
          v-for="(column, colIndex) in columns"
          :key="colIndex"
          class="table-cell"
          :style="{ width: column.width || 'auto' }"
        >
          <slot name="cell" :row="row" :column="column" :row-index="rowIndex" :col-index="colIndex">
            {{ row[column.prop] }}
          </slot>
        </div>
      </div>
      
      <!-- 空表格 -->
      <div v-if="data.length === 0" class="empty-table">
        <slot name="empty">
          <p>表格为空</p>
        </slot>
      </div>
    </div>
    
    <!-- 表格底部 -->
    <div class="table-footer">
      <slot name="footer">
        <p>共 {{ data.length }} 行数据</p>
      </slot>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  // 表格列配置
  columns: {
    type: Array,
    required: true
  },
  // 表格数据
  data: {
    type: Array,
    default: () => []
  }
})
</script>

<style scoped>
.custom-table {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  overflow: hidden;
  max-width: 800px;
  margin: 0 auto;
}

.table-header {
  display: flex;
  background-color: #f8fafc;
  border-bottom: 1px solid #e2e8f0;
}

.table-header-cell {
  padding: 12px 16px;
  font-weight: 600;
  color: #1e293b;
  border-right: 1px solid #e2e8f0;
}

.table-header-cell:last-child {
  border-right: none;
}

.table-body {
  max-height: 400px;
  overflow-y: auto;
}

.table-row {
  display: flex;
  border-bottom: 1px solid #f1f5f9;
}

.table-row:last-child {
  border-bottom: none;
}

.table-row.row-even {
  background-color: #fafafa;
}

.table-cell {
  padding: 12px 16px;
  border-right: 1px solid #f1f5f9;
}

.table-cell:last-child {
  border-right: none;
}

.empty-table {
  padding: 32px;
  text-align: center;
  color: #64748b;
}

.table-footer {
  padding: 12px 16px;
  background-color: #f8fafc;
  border-top: 1px solid #e2e8f0;
  text-align: right;
  color: #64748b;
}
</style>

<!-- 父组件 -->
<template>
  <div class="table-demo">
    <h2>自定义表格组件示例</h2>
    
    <CustomTable :columns="columns" :data="users">
      <!-- 自定义头部单元格 -->
      <template #header-cell="{ column }">
        <div class="custom-header-cell">
          <span>{{ column.label }}</span>
          <button v-if="column.sortable" @click="toggleSort(column.prop)">
            {{ sortColumn === column.prop ? (sortOrder === 'asc' ? '↑' : '↓') : '↕' }}
          </button>
        </div>
      </template>
      
      <!-- 自定义内容单元格 -->
      <template #cell="{ row, column }">
        <div v-if="column.prop === 'name'" class="name-cell">
          <span class="avatar">{{ row.name.charAt(0) }}</span>
          <span>{{ row.name }}</span>
        </div>
        
        <div v-else-if="column.prop === 'status'" class="status-cell">
          <span :class="['status-badge', `status-${row.status}`]">
            {{ row.status === 'active' ? '活跃' : row.status === 'inactive' ? '停用' : ' pending' }}
          </span>
        </div>
        
        <div v-else-if="column.prop === 'actions'" class="actions-cell">
          <button @click="editUser(row.id)" class="btn-edit">编辑</button>
          <button @click="deleteUser(row.id)" class="btn-delete">删除</button>
        </div>
        
        <div v-else>
          {{ row[column.prop] }}
        </div>
      </template>
      
      <!-- 自定义空表格内容 -->
      <template #empty>
        <div class="custom-empty">
          <p>暂无用户数据</p>
          <button @click="addUser">添加用户</button>
        </div>
      </template>
      
      <!-- 自定义表格底部 -->
      <template #footer>
        <div class="custom-footer">
          <p>共 {{ users.length }} 位用户</p>
          <button @click="refreshData">刷新数据</button>
        </div>
      </template>
    </CustomTable>
  </div>
</template>

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

// 表格列配置
const columns = ref([
  { prop: 'name', label: '姓名', width: '150px', sortable: true },
  { prop: 'email', label: '邮箱', width: '250px' },
  { prop: 'status', label: '状态', width: '100px' },
  { prop: 'createdAt', label: '创建时间', width: '180px' },
  { prop: 'actions', label: '操作', width: '120px' }
])

// 表格数据
const users = ref([
  { id: 1, name: '张三', email: 'zhangsan@example.com', status: 'active', createdAt: '2023-01-01' },
  { id: 2, name: '李四', email: 'lisi@example.com', status: 'inactive', createdAt: '2023-02-01' },
  { id: 3, name: '王五', email: 'wangwu@example.com', status: 'active', createdAt: '2023-03-01' },
  { id: 4, name: '赵六', email: 'zhaoliu@example.com', status: 'pending', createdAt: '2023-04-01' }
])

// 排序状态
const sortColumn = ref('')
const sortOrder = ref('asc')

// 切换排序
const toggleSort = (column) => {
  if (sortColumn.value === column) {
    sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
  } else {
    sortColumn.value = column
    sortOrder.value = 'asc'
  }
  
  // 执行排序
  users.value.sort((a, b) => {
    if (a[column] < b[column]) {
      return sortOrder.value === 'asc' ? -1 : 1
    }
    if (a[column] > b[column]) {
      return sortOrder.value === 'asc' ? 1 : -1
    }
    return 0
  })
}

// 编辑用户
const editUser = (id) => {
  alert(`编辑用户 ${id}`)
}

// 删除用户
const deleteUser = (id) => {
  users.value = users.value.filter(user => user.id !== id)
}

// 添加用户
const addUser = () => {
  const newUser = {
    id: users.value.length + 1,
    name: `用户${users.value.length + 1}`,
    email: `user${users.value.length + 1}@example.com`,
    status: 'active',
    createdAt: new Date().toISOString().split('T')[0]
  }
  users.value.push(newUser)
}

// 刷新数据
const refreshData = () => {
  alert('刷新数据')
}
</script>

<style scoped>
.table-demo {
  padding: 20px;
}

.custom-header-cell {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.name-cell {
  display: flex;
  align-items: center;
  gap: 8px;
}

.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  background-color: #3b82f6;
  color: white;
  border-radius: 50%;
  font-weight: 600;
  font-size: 14px;
}

.status-cell {
  display: flex;
  align-items: center;
}

.status-badge {
  padding: 4px 8px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 500;
}

.status-active {
  background-color: #d1fae5;
  color: #065f46;
}

.status-inactive {
  background-color: #fee2e2;
  color: #991b1b;
}

.status-pending {
  background-color: #fef3c7;
  color: #92400e;
}

.actions-cell {
  display: flex;
  gap: 8px;
}

.btn-edit,
.btn-delete {
  padding: 4px 8px;
  border: none;
  border-radius: 4px;
  font-size: 12px;
  cursor: pointer;
}

.btn-edit {
  background-color: #3b82f6;
  color: white;
}

.btn-delete {
  background-color: #ef4444;
  color: white;
}

.custom-empty {
  text-align: center;
}

.custom-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

button {
  padding: 6px 12px;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

button:hover {
  background-color: #2563eb;
}
</style>

7. 作用域插槽的最佳实践

7.1 明确数据传递

在设计作用域插槽时,明确要传递给父组件的数据,避免传递不必要的数据,提高组件的性能和易用性。

7.2 使用描述性的prop名称

为作用域插槽的props使用描述性的名称,提高代码的可读性和可维护性。

7.3 提供合理的默认内容

为作用域插槽提供合理的默认内容,当父组件没有提供自定义内容时,组件仍然可以正常显示。

7.4 结合v-for使用

作用域插槽经常与v-for结合使用,用于自定义列表项、表格行等重复元素的渲染。

7.5 避免过度复杂化

虽然作用域插槽非常灵活,但过度使用会增加组件的复杂性。对于简单的组件,考虑使用Props和事件的组合,而不是作用域插槽。

8. 常见问题与解决方案

8.1 为什么无法访问作用域插槽的数据?

  • 检查是否在子组件中正确绑定了数据
  • 检查父组件中是否正确使用了作用域插槽语法
  • 检查插槽名称是否匹配

8.2 如何在作用域插槽中使用v-model?

可以在作用域插槽中使用v-model,但需要注意双向绑定的数据流:

<template>
  <ChildComponent>
    <template #item="{ item }">
      <input v-model="item.name" type="text" />
      <!-- 注意:这会直接修改子组件的数据,违反单向数据流原则 -->
    </template>
  </ChildComponent>
</template>

更好的做法是通过事件通知父组件更新数据:

<template>
  <ChildComponent>
    <template #item="{ item, updateItem }">
      <input
        :value="item.name"
        @input="updateItem({ ...item, name: $event.target.value })"
        type="text"
      />
    </template>
  </ChildComponent>
</template>

8.3 如何在作用域插槽中使用计算属性?

可以在父组件中定义计算属性,然后在作用域插槽中使用:

<template>
  <ChildComponent>
    <template #item="{ item }">
      <div :class="{ 'highlight': isHighlighted(item) }">
        {{ item.name }}
      </div>
    </template>
  </ChildComponent>
</template>

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

const isHighlighted = (item) => {
  return item.score > 90
}
</script>

9. 总结

在这一集中,我们深入学习了Vue中的插槽高级用法,包括具名插槽和作用域插槽。

具名插槽允许我们在子组件中定义多个插槽,父组件可以根据名称插入不同的内容,实现复杂的布局。作用域插槽则允许子组件向插槽内容传递数据,父组件可以在插槽内容中访问这些数据,实现更灵活的内容定制。

作用域插槽的主要特点包括:

  • 允许子组件向插槽内容传递数据
  • 支持解构语法,简化代码
  • 可以是具名插槽
  • 与v-for结合使用,实现自定义列表项
  • 与组合式API良好兼容

在实际开发中,我们应该根据组件的复杂度和需求,合理使用插槽类型:

  • 对于简单的内容分发,使用匿名插槽
  • 对于复杂的布局,使用具名插槽
  • 对于需要访问子组件数据的情况,使用作用域插槽

通过合理使用插槽,我们可以创建出更加灵活、易用和可维护的Vue组件。

10. 练习题

  1. 创建一个自定义表格组件,支持以下功能:

    • 动态列配置
    • 作用域插槽自定义列内容
    • 排序功能
    • 分页功能
    • 筛选功能
  2. 创建一个自定义卡片组件,支持以下功能:

    • 具名插槽(header、body、footer)
    • 作用域插槽传递卡片数据
    • 支持不同的卡片样式
    • 支持卡片动画效果
  3. 创建一个自定义树形组件,支持以下功能:

    • 递归渲染树形结构
    • 作用域插槽自定义节点内容
    • 节点展开/折叠功能
    • 节点选择功能
    • 搜索过滤功能

通过这些练习,你将更加熟悉Vue中的具名插槽和作用域插槽,能够创建出更加灵活和强大的组件。

« 上一篇 插槽基础:内容分发 下一篇 » 动态组件与keep-alive