Vue.js组件插槽

本章重点:插槽是Vue组件内容分发的核心机制,允许父组件向子组件传递任意内容,实现高度灵活的组件复用。

什么是插槽?

插槽(Slot)是Vue组件提供的一种内容分发机制。它允许你在组件模板中预留位置,让父组件可以插入自定义的内容。

父组件
提供内容
插槽
内容占位符
子组件
渲染内容

插槽的价值

灵活性

可以插入任意内容

可组合性

组件像乐高一样组合

复用性

同一组件不同内容

插槽类型概览

默认插槽

单个内容插槽

基本
具名插槽

多个指定位置插槽

常用
作用域插槽

数据回传的插槽

高级
插槽类型 语法 描述 使用场景
默认插槽 <slot></slot> 未命名的插槽,接收所有未匹配的内容 简单内容分发
具名插槽 <slot name="header"></slot> 有名称的插槽,可以指定插入位置 布局组件、卡片组件
作用域插槽 <slot :item="item"></slot> 子组件向插槽内容传递数据 列表渲染、数据表格
后备内容 <slot>默认内容</slot> 当父组件没有提供内容时显示的默认内容 可配置的默认状态
动态插槽名 <slot :name="slotName"></slot> 动态决定插槽名称 高度动态的布局

默认插槽

最简单的插槽形式,用于接收父组件传递的所有内容:

基础示例

<!-- Alert.vue - 子组件 -->
<template>
  <div class="alert" :class="type">
    <!-- 默认插槽 -->
    <slot></slot>

    <!-- 带后备内容的插槽 -->
    <slot>
      <!-- 后备内容:当父组件没有提供内容时显示 -->
      默认警告信息
    </slot>
  </div>
</template>

<script>
export default {
  name: 'Alert',
  props: {
    type: {
      type: String,
      default: 'info',
      validator: value => ['info', 'success', 'warning', 'danger'].includes(value)
    }
  }
}
</script>

<style scoped>
.alert {
  padding: 15px;
  border-radius: 5px;
  margin: 10px 0;
}

.alert.info {
  background-color: #d1ecf1;
  border: 1px solid #bee5eb;
  color: #0c5460;
}

.alert.success {
  background-color: #d4edda;
  border: 1px solid #c3e6cb;
  color: #155724;
}

.alert.warning {
  background-color: #fff3cd;
  border: 1px solid #ffeaa7;
  color: #856404;
}

.alert.danger {
  background-color: #f8d7da;
  border: 1px solid #f5c6cb;
  color: #721c24;
}
</style>
                                    

<!-- 父组件使用 -->
<template>
  <div>
    <!-- 使用自定义内容 -->
    <alert type="success">
      <strong>操作成功!</strong> 您的数据已保存。
    </alert>

    <!-- 使用复杂内容 -->
    <alert type="warning">
      <div>
        <h4>警告</h4>
        <p>即将删除重要数据,请确认操作。</p>
        <button @click="confirmDelete">确认删除</button>
      </div>
    </alert>

    <!-- 不提供内容,显示后备内容 -->
    <alert type="info" />

    <!-- 使用动态内容 -->
    <alert type="danger">
      <p>错误代码: {{ errorCode }}</p>
      <p>错误信息: {{ errorMessage }}</p>
    </alert>
  </div>
</template>

<script>
import Alert from './Alert.vue'

export default {
  components: {
    Alert
  },
  data() {
    return {
      errorCode: 404,
      errorMessage: '页面不存在'
    }
  },
  methods: {
    confirmDelete() {
      console.log('删除确认');
    }
  }
}
</script>
                                    
渲染效果预览:
操作成功! 您的数据已保存。
警告

即将删除重要数据,请确认操作。

默认警告信息

错误代码: 404

错误信息: 页面不存在

最佳实践:
  • 为重要的插槽提供有意义的后备内容
  • 默认插槽应该接收通用内容
  • 避免在默认插槽中使用过于复杂的逻辑
  • 考虑使用具名插槽替代多个默认插槽

具名插槽

当组件需要多个插槽时,可以使用具名插槽来区分不同的内容位置:

具名插槽结构

<!-- Card.vue - 卡片组件 -->
<template>
  <div class="card">
    <!-- 头部插槽 -->
    <div class="card-header" v-if="$slots.header">
      <slot name="header"></slot>
    </div>

    <!-- 默认内容插槽 -->
    <div class="card-body">
      <slot>
        <!-- 默认内容 -->
        卡片内容
      </slot>
    </div>

    <!-- 底部插槽 -->
    <div class="card-footer" v-if="$slots.footer">
      <slot name="footer"></slot>
    </div>

    <!-- 额外操作插槽 -->
    <div class="card-actions" v-if="$slots.actions">
      <slot name="actions"></slot>
    </div>
  </div>
</template>
                                    
插槽结构说明
header 头部区域
default 主要内容区域
footer 底部区域
actions 操作按钮区域

<!-- 父组件使用具名插槽 -->
<template>
  <div>
    <!-- 使用v-slot指令 -->
    <card>
      <template v-slot:header>
        <h3>用户信息</h3>
      </template>

      <!-- 默认插槽可以简写 -->
      <p>用户名: 张三</p>
      <p>邮箱: zhangsan@example.com</p>

      <template v-slot:footer>
        <small class="text-muted">最后更新: 2024-01-15</small>
      </template>

      <template v-slot:actions>
        <button class="btn btn-primary">编辑</button>
        <button class="btn btn-danger">删除</button>
      </template>
    </card>

    <!-- 简写语法(Vue 2.6+) -->
    <card>
      <template #header>
        <h3>产品信息</h3>
      </template>

      <p>产品名称: Vue.js实战</p>
      <p>价格: ¥89.00</p>

      <template #footer>
        <small class="text-muted">库存: 100件</small>
      </template>

      <template #actions>
        <button class="btn btn-success">加入购物车</button>
      </template>
    </card>
  </div>
</template>
                        

作用域插槽

作用域插槽允许子组件向插槽内容传递数据,实现更灵活的渲染控制:

作用域插槽工作原理
子组件

绑定数据到插槽

<slot :item="item">
数据传递

通过slot props传递

父组件

接收并渲染数据

v-slot="slotProps"

<!-- ListComponent.vue - 子组件 -->
<template>
  <div class="list">
    <ul>
      <li v-for="item in items" :key="item.id">
        <!-- 作用域插槽:向父组件传递item数据 -->
        <slot :item="item" :index="index">
          <!-- 默认渲染 -->
          {{ item.name }}
        </slot>
      </li>
    </ul>

    <!-- 多个数据的作用域插槽 -->
    <slot
      :items="items"
      :total="items.length"
      :selected="selectedItem"
    ></slot>
  </div>
</template>

<script>
export default {
  name: 'ListComponent',
  props: {
    items: {
      type: Array,
      required: true,
      default: () => []
    }
  },
  data() {
    return {
      selectedItem: null
    }
  }
}
</script>
                            

<!-- 父组件使用 -->
<template>
  <div>
    <!-- 基本用法 -->
    <list-component :items="users">
      <template v-slot:default="slotProps">
        <li>
          <strong>{{ slotProps.item.name }}</strong>
          <span> - {{ slotProps.item.email }}</span>
          <button @click="selectUser(slotProps.item)">
            选择
          </button>
        </li>
      </template>
    </list-component>

    <!-- 解构语法(推荐) -->
    <list-component :items="products">
      <template v-slot:default="{ item, index }">
        <li :class="{ 'active': index % 2 === 0 }">
          {{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}
        </li>
      </template>
    </list-component>

    <!-- 简写语法 -->
    <list-component :items="orders">
      <template #default="{ item }">
        <div class="order-item">
          <h4>{{ item.orderNumber }}</h4>
          <p>状态: {{ item.status }}</p>
          <p>金额: ¥{{ item.amount }}</p>
        </div>
      </template>
    </list-component>

    <!-- 使用多个数据的插槽 -->
    <list-component :items="data">
      <template #default="{ items, total, selected }">
        <div>
          <p>共 {{ total }} 条记录</p>
          <div v-if="selected">
            已选择: {{ selected.name }}
          </div>
        </div>
      </template>
    </list-component>
  </div>
</template>

<script>
export default {
  data() {
    return {
      users: [
        { id: 1, name: '张三', email: 'zhangsan@example.com' },
        { id: 2, name: '李四', email: 'lisi@example.com' }
      ],
      products: [
        { id: 1, name: 'Vue.js实战', price: 89 },
        { id: 2, name: 'React进阶', price: 99 }
      ],
      orders: [
        { orderNumber: 'ORD001', status: '已发货', amount: 299 },
        { orderNumber: 'ORD002', status: '处理中', amount: 599 }
      ]
    }
  },
  methods: {
    selectUser(user) {
      console.log('选择用户:', user)
    }
  }
}
</script>
                            

插槽组合使用

在实际项目中,经常需要组合使用多种插槽:

数据表格组件

<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <!-- 表格标题 -->
    <slot name="header">
      <h3>数据表格</h3>
    </slot>

    <!-- 表格内容 -->
    <table>
      <thead>
        <slot name="thead">
          <tr>
            <th v-for="col in columns" :key="col.key">
              {{ col.title }}
            </th>
          </tr>
        </slot>
      </thead>
      <tbody>
        <tr v-for="(item, index) in data" :key="item.id">
          <slot name="tbody" :item="item" :index="index">
            <!-- 默认单元格渲染 -->
            <td v-for="col in columns" :key="col.key">
              {{ item[col.key] }}
            </td>
          </slot>
        </tr>
      </tbody>
    </table>

    <!-- 表格底部 -->
    <slot name="footer" :data="data" :total="data.length">
      <div>共 {{ data.length }} 条记录</div>
    </slot>
  </div>
</template>
                                
布局组件

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header class="header">
      <slot name="header">默认头部</slot>
    </header>

    <div class="content">
      <aside class="sidebar" v-if="$slots.sidebar">
        <slot name="sidebar"></slot>
      </aside>

      <main class="main">
        <slot>主要内容区域</slot>
      </main>
    </div>

    <footer class="footer">
      <slot name="footer">默认底部</slot>
    </footer>
  </div>
</template>
                                
列表渲染组件

<!-- RenderList.vue -->
<template>
  <div class="render-list">
    <!-- 列表头部 -->
    <slot name="before-list"></slot>

    <!-- 列表项渲染 -->
    <div v-for="(item, index) in items" :key="item.id">
      <slot name="item" :item="item" :index="index">
        <!-- 默认列表项渲染 -->
        <div class="list-item">
          {{ index + 1 }}. {{ item.name }}
        </div>
      </slot>

      <!-- 分隔符插槽 -->
      <slot
        v-if="index < items.length - 1"
        name="separator"
        :current="item"
        :next="items[index + 1]"
      >
        <hr>
      </slot>
    </div>

    <!-- 列表底部 -->
    <slot name="after-list" :items="items"></slot>
  </div>
</template>
                                

完整示例:可复用数据表格

创建一个完整的可复用数据表格组件:


// DataTable.vue
export default {
    name: 'DataTable',

    props: {
        data: {
            type: Array,
            required: true,
            default: () => []
        },
        columns: {
            type: Array,
            default: () => []
        },
        loading: {
            type: Boolean,
            default: false
        },
        emptyText: {
            type: String,
            default: '暂无数据'
        }
    },

    data() {
        return {
            sortBy: '',
            sortDirection: 'asc'
        };
    },

    computed: {
        sortedData() {
            if (!this.sortBy) return this.data;

            return [...this.data].sort((a, b) => {
                const aVal = a[this.sortBy];
                const bVal = b[this.sortBy];

                if (this.sortDirection === 'asc') {
                    return aVal > bVal ? 1 : -1;
                } else {
                    return aVal < bVal ? 1 : -1;
                }
            });
        }
    },

    methods: {
        handleSort(column) {
            if (this.sortBy === column.key) {
                this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
            } else {
                this.sortBy = column.key;
                this.sortDirection = 'asc';
            }

            this.$emit('sort-change', {
                sortBy: this.sortBy,
                sortDirection: this.sortDirection
            });
        }
    },

    template: `
        <div class="data-table">
            <!-- 表格标题插槽 -->
            <div class="table-header">
                <slot name="header">
                    <h4>数据列表</h4>
                </slot>

                <!-- 工具栏插槽 -->
                <slot name="toolbar" :data="data"></slot>
            </div>

            <!-- 加载状态插槽 -->
            <slot v-if="loading" name="loading">
                <div class="loading">加载中...</div>
            </slot>

            <!-- 空状态插槽 -->
            <slot v-else-if="data.length === 0" name="empty">
                <div class="empty">{{ emptyText }}</div>
            </slot>

            <!-- 表格主体 -->
            <table v-else>
                <thead>
                    <tr>
                        <!-- 表头插槽 -->
                        <slot name="thead" :columns="columns" :sortBy="sortBy" :sortDirection="sortDirection">
                            <th
                                v-for="col in columns"
                                :key="col.key"
                                @click="col.sortable ? handleSort(col) : null"
                                :class="{ sortable: col.sortable, active: sortBy === col.key }"
                            >
                                {{ col.title }}
                                <span v-if="sortBy === col.key" class="sort-indicator">
                                    {{ sortDirection === 'asc' ? '↑' : '↓' }}
                                </span>
                            </th>
                        </slot>
                    </tr>
                </thead>

                <tbody>
                    <!-- 行插槽 -->
                    <slot name="tbody" :data="sortedData" :columns="columns">
                        <tr v-for="(item, index) in sortedData" :key="item.id">
                            <!-- 单元格插槽 -->
                            <slot name="tbody-cell" :item="item" :index="index" :columns="columns">
                                <td v-for="col in columns" :key="col.key">
                                    {{ item[col.key] }}
                                </td>
                            </slot>

                            <!-- 操作列插槽 -->
                            <td v-if="$slots.actions">
                                <slot name="actions" :item="item" :index="index"></slot>
                            </td>
                        </tr>
                    </slot>
                </tbody>
            </table>

            <!-- 分页插槽 -->
            <slot name="pagination" :data="data">
                <div class="pagination">
                    显示 {{ data.length }} 条记录
                </div>
            </slot>

            <!-- 底部统计插槽 -->
            <slot name="footer" :data="data">
                <div class="table-footer">
                    共 {{ data.length }} 条数据
                </div>
            </slot>
        </div>
    `
};
                            

<!-- 使用数据表格组件 -->
<template>
  <div>
    <data-table
      :data="users"
      :columns="columns"
      :loading="loading"
      @sort-change="handleSortChange"
    >
      <!-- 自定义标题 -->
      <template #header>
        <div class="d-flex justify-content-between align-items-center">
          <h3>用户管理</h3>
          <button class="btn btn-primary" @click="addUser">
            添加用户
          </button>
        </div>
      </template>

      <!-- 自定义表头 -->
      <template #thead="{ columns }">
        <tr>
          <th v-for="col in columns" :key="col.key">
            {{ col.title }}
          </th>
          <th>操作</th>
        </tr>
      </template>

      <!-- 自定义行内容 -->
      <template #tbody="{ data, columns }">
        <tr v-for="user in data" :key="user.id">
          <td>{{ user.id }}</td>
          <td>
            <div class="d-flex align-items-center">
              <img :src="user.avatar" class="avatar">
              <span>{{ user.name }}</span>
            </div>
          </td>
          <td>{{ user.email }}</td>
          <td>
            <span :class="['badge', user.status === 'active' ? 'bg-success' : 'bg-secondary']">
              {{ user.status }}
            </span>
          </td>
          <td>{{ formatDate(user.createdAt) }}</td>

          <!-- 自定义操作按钮 -->
          <td>
            <template #actions="{ item }">
              <button @click="editUser(item)" class="btn btn-sm btn-outline-primary">
                编辑
              </button>
              <button @click="deleteUser(item)" class="btn btn-sm btn-outline-danger">
                删除
              </button>
            </template>
          </td>
        </tr>
      </template>

      <!-- 自定义空状态 -->
      <template #empty>
        <div class="text-center py-5">
          <i class="fas fa-users fa-3x text-muted mb-3"></i>
          <p>暂无用户数据</p>
          <button @click="reloadData" class="btn btn-primary">
            重新加载
          </button>
        </div>
      </template>

      <!-- 自定义分页 -->
      <template #pagination="{ data }">
        <div class="d-flex justify-content-between align-items-center">
          <div>共 {{ data.length }} 条记录</div>
          <div class="pagination-controls">
            <button class="btn btn-sm" :disabled="currentPage === 1">
              上一页
            </button>
            <span class="mx-2">第 {{ currentPage }} 页</span>
            <button class="btn btn-sm" :disabled="currentPage === totalPages">
              下一页
            </button>
          </div>
        </div>
      </template>
    </data-table>
  </div>
</template>
                            

高级插槽模式

动态插槽名

<!-- 子组件 -->
<template>
  <div>
    <slot :name="slotName">动态插槽</slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      slotName: 'header' // 可以动态改变
    }
  }
}
</script>

<!-- 父组件使用 -->
<child-component>
  <template #[slotName]>
    动态插入的内容
  </template>
</child-component>
                                
插槽转发

<!-- WrapperComponent.vue -->
<template>
  <base-component>
    <!-- 转发所有插槽 -->
    <template v-for="slot in Object.keys($slots)" #[slot]="scope">
      <slot :name="slot" v-bind="scope" />
    </template>

    <!-- 转发作用域插槽 -->
    <template v-slot:item="{ item }">
      <slot name="item" :item="item" />
    </template>
  </base-component>
</template>
                                
插槽函数

// 使用函数作为插槽内容
export default {
    methods: {
        renderItem(item) {
            return this.$createElement('div', [
                this.$createElement('strong', item.name),
                this.$createElement('span', ` - ${item.email}`)
            ]);
        }
    },

    template: `
        <list-component :items="users">
            <template v-slot:default="{ item }">
                <!-- 使用函数渲染 -->
                {{ renderItem(item) }}
            </template>
        </list-component>
    `
};
                                

插槽最佳实践

设计和使用指南
命名规范
  • 使用语义化的插槽名称
  • 具名插槽使用kebab-case命名
  • 避免使用过于通用的名称
  • 为重要的插槽添加文档说明
防御性编程
  • 检查插槽是否存在再渲染 v-if="$slots.name"
  • 为可选插槽提供合理的后备内容
  • 处理作用域插槽的数据边界情况
  • 避免在插槽中使用副作用操作
性能优化
  • 避免在插槽中进行复杂计算
  • 合理使用v-once提高静态内容性能
  • 避免不必要的插槽内容重新渲染
  • 考虑使用函数式组件优化大量插槽
可维护性
  • 保持插槽API的稳定性
  • 为复杂的插槽提供使用示例
  • 避免嵌套过深的插槽结构
  • 考虑使用作用域插槽替代复杂Props

常见陷阱与解决方案

插槽使用常见问题
作用域问题

现象:插槽内访问不到父组件数据

解决:插槽内容在父组件作用域编译

样式冲突

现象:插槽内容样式影响组件

解决:使用scoped样式或CSS Modules

插槽未渲染

现象:v-if导致插槽不显示

解决:使用v-show或检查$slots

性能问题

现象:复杂插槽导致渲染缓慢

解决:优化插槽内容,使用虚拟滚动

插槽练习

为以下模态框组件设计合适的插槽:


// Modal.vue - 模态框组件
Vue.component('Modal', {
    props: {
        visible: Boolean,
        title: String,
        size: {
            type: String,
            default: 'medium'
        }
    },

    // TODO: 设计插槽
    // 考虑以下需求:
    // 1. 自定义标题区域
    // 2. 自定义内容区域
    // 3. 自定义底部按钮区域
    // 4. 自定义关闭按钮
    // 5. 加载状态插槽

    template: `
        <div v-if="visible" class="modal">
            <div class="modal-dialog" :class="'modal-' + size">
                <div class="modal-content">
                    <!-- TODO: 实现插槽 -->
                </div>
            </div>
        </div>
    `
});