Vue 插槽(Slot)完全指南:组件内容分发的艺术

Vue 插槽(Slot)完全指南:组件内容分发的艺术

插槽(Slot)是 Vue 组件系统中一个非常强大的功能,它允许父组件向子组件传递内容(不仅仅是数据),实现了更灵活的内容分发机制。

一、插槽的基本概念

什么是插槽?

插槽就像是组件预留的"占位符",父组件可以将任意内容"插入"到这些位置,从而实现组件内容的动态分发。

vue 复制代码
<!-- ChildComponent.vue - 子组件定义插槽 -->
<template>
  <div class="card">
    <div class="card-header">
      <!-- 这是一个插槽占位符 -->
      <slot></slot>
    </div>
    <div class="card-body">
      卡片内容
    </div>
  </div>
</template>
vue 复制代码
<!-- ParentComponent.vue - 父组件使用插槽 -->
<template>
  <child-component>
    <!-- 这里的内容会被插入到子组件的 <slot> 位置 -->
    <h3>自定义标题</h3>
    <p>自定义内容</p>
  </child-component>
</template>

二、插槽的核心类型与应用

1. 默认插槽(匿名插槽)

最基本的插槽类型,没有名字的插槽。

vue 复制代码
<!-- Button.vue - 按钮组件 -->
<template>
  <button class="custom-button">
    <!-- 默认插槽,接收按钮文本 -->
    <slot>默认按钮</slot>
  </button>
</template>

<style>
.custom-button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background: #007bff;
  color: white;
  cursor: pointer;
}
</style>
vue 复制代码
<!-- 使用示例 -->
<template>
  <div>
    <!-- 使用自定义内容 -->
    <custom-button>
      <span style="color: yellow;">⭐ 重要按钮</span>
    </custom-button>
    
    <!-- 使用默认内容 -->
    <custom-button></custom-button>
    
    <!-- 带图标的按钮 -->
    <custom-button>
      <template>
        <i class="icon-save"></i> 保存
      </template>
    </custom-button>
  </div>
</template>

2. 具名插槽(Named Slots)

有特定名称的插槽,允许在多个位置插入不同内容。

vue 复制代码
<!-- Layout.vue - 布局组件 -->
<template>
  <div class="layout">
    <header class="header">
      <!-- 名为 header 的插槽 -->
      <slot name="header">
        <h2>默认标题</h2>
      </slot>
    </header>
    
    <main class="main">
      <!-- 名为 content 的插槽 -->
      <slot name="content"></slot>
      
      <!-- 默认插槽(匿名插槽) -->
      <slot>默认内容</slot>
    </main>
    
    <footer class="footer">
      <!-- 名为 footer 的插槽 -->
      <slot name="footer">
        <p>© 2024 默认页脚</p>
      </slot>
    </footer>
  </div>
</template>

<style>
.layout {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
}

.header {
  background: #f8f9fa;
  padding: 20px;
  border-bottom: 1px solid #ddd;
}

.main {
  padding: 20px;
  min-height: 200px;
}

.footer {
  background: #343a40;
  color: white;
  padding: 15px;
  text-align: center;
}
</style>
vue 复制代码
<!-- 使用具名插槽 -->
<template>
  <layout-component>
    <!-- Vue 2.6+ 使用 v-slot 语法 -->
    <template v-slot:header>
      <div class="custom-header">
        <h1>我的网站</h1>
        <nav>
          <a href="/">首页</a>
          <a href="/about">关于</a>
          <a href="/contact">联系</a>
        </nav>
      </div>
    </template>
    
    <!-- 简写语法 # -->
    <template #content>
      <article>
        <h2>文章标题</h2>
        <p>文章内容...</p>
        <p>更多内容...</p>
      </article>
    </template>
    
    <!-- 默认插槽内容 -->
    <p>这里是默认插槽的内容</p>
    
    <!-- 页脚插槽 -->
    <template #footer>
      <div class="custom-footer">
        <p>© 2024 我的公司</p>
        <p>联系方式: contact@example.com</p>
        <div class="social-links">
          <a href="#">Twitter</a>
          <a href="#">GitHub</a>
          <a href="#">LinkedIn</a>
        </div>
      </div>
    </template>
  </layout-component>
</template>

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

.custom-header nav a {
  margin: 0 10px;
  text-decoration: none;
  color: #007bff;
}

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

.social-links a {
  margin: 0 8px;
  color: #fff;
  text-decoration: none;
}
</style>

3. 作用域插槽(Scoped Slots)

允许子组件向插槽传递数据,父组件可以访问这些数据来定制渲染内容。

vue 复制代码
<!-- DataList.vue - 数据列表组件 -->
<template>
  <div class="data-list">
    <div v-for="(item, index) in items" :key="item.id" class="list-item">
      <!-- 作用域插槽,向父组件暴露 item 和 index -->
      <slot name="item" :item="item" :index="index">
        <!-- 默认渲染 -->
        <div class="default-item">
          {{ index + 1 }}. {{ item.name }}
        </div>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true,
      default: () => []
    }
  }
}
</script>

<style>
.data-list {
  border: 1px solid #eee;
  border-radius: 4px;
}

.list-item {
  padding: 12px;
  border-bottom: 1px solid #eee;
}

.list-item:last-child {
  border-bottom: none;
}

.default-item {
  color: #666;
}
</style>
vue 复制代码
<!-- 使用作用域插槽 -->
<template>
  <div>
    <h3>用户列表</h3>
    
    <data-list :items="users">
      <!-- 接收子组件传递的数据 -->
      <template #item="{ item, index }">
        <div class="user-item" :class="{ 'highlight': item.isAdmin }">
          <span class="index">{{ index + 1 }}</span>
          <div class="user-info">
            <strong>{{ item.name }}</strong>
            <span class="email">{{ item.email }}</span>
            <span class="role">{{ item.role }}</span>
          </div>
          <div class="actions">
            <button @click="editUser(item)">编辑</button>
            <button @click="deleteUser(item.id)">删除</button>
          </div>
        </div>
      </template>
    </data-list>
    
    <h3>产品列表</h3>
    
    <data-list :items="products">
      <template #item="{ item }">
        <div class="product-item">
          <img :src="item.image" alt="" class="product-image">
          <div class="product-details">
            <h4>{{ item.name }}</h4>
            <p class="price">¥{{ item.price }}</p>
            <p class="stock" :class="{ 'low-stock': item.stock < 10 }">
              库存: {{ item.stock }}
            </p>
            <button 
              @click="addToCart(item)"
              :disabled="item.stock === 0"
            >
              {{ item.stock === 0 ? '已售罄' : '加入购物车' }}
            </button>
          </div>
        </div>
      </template>
    </data-list>
  </div>
</template>

<script>
export default {
  data() {
    return {
      users: [
        { id: 1, name: '张三', email: 'zhangsan@example.com', role: '管理员', isAdmin: true },
        { id: 2, name: '李四', email: 'lisi@example.com', role: '用户', isAdmin: false },
        { id: 3, name: '王五', email: 'wangwu@example.com', role: '编辑', isAdmin: false }
      ],
      products: [
        { id: 1, name: '商品A', price: 99.99, image: 'product-a.jpg', stock: 15 },
        { id: 2, name: '商品B', price: 149.99, image: 'product-b.jpg', stock: 5 },
        { id: 3, name: '商品C', price: 199.99, image: 'product-c.jpg', stock: 0 }
      ]
    }
  },
  
  methods: {
    editUser(user) {
      console.log('编辑用户:', user)
    },
    
    deleteUser(id) {
      console.log('删除用户:', id)
    },
    
    addToCart(product) {
      console.log('添加到购物车:', product)
    }
  }
}
</script>

<style>
.user-item {
  display: flex;
  align-items: center;
  padding: 10px;
  border-radius: 4px;
}

.user-item.highlight {
  background: #fff3cd;
}

.user-item .index {
  width: 30px;
  text-align: center;
  font-weight: bold;
}

.user-info {
  flex: 1;
  margin-left: 15px;
}

.email {
  color: #666;
  margin: 0 15px;
}

.role {
  background: #6c757d;
  color: white;
  padding: 2px 8px;
  border-radius: 10px;
  font-size: 12px;
}

.actions button {
  margin-left: 8px;
  padding: 4px 12px;
  font-size: 12px;
}

.product-item {
  display: flex;
  align-items: center;
  padding: 15px;
}

.product-image {
  width: 80px;
  height: 80px;
  object-fit: cover;
  border-radius: 4px;
  margin-right: 15px;
}

.product-details {
  flex: 1;
}

.price {
  color: #e4393c;
  font-size: 18px;
  font-weight: bold;
  margin: 5px 0;
}

.stock {
  color: #28a745;
  margin: 5px 0;
}

.low-stock {
  color: #dc3545;
}
</style>

4. 动态插槽名

插槽名可以是动态的,增加了更大的灵活性。

vue 复制代码
<!-- DynamicSlotComponent.vue -->
<template>
  <div class="dynamic-slot">
    <!-- 动态插槽名 -->
    <slot :name="slotName">
      默认动态插槽内容
    </slot>
    
    <!-- 多个动态插槽 -->
    <div v-for="field in fields" :key="field.name">
      <slot :name="field.slotName" :field="field">
        字段: {{ field.label }}
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    slotName: {
      type: String,
      default: 'default'
    },
    fields: {
      type: Array,
      default: () => []
    }
  }
}
</script>
vue 复制代码
<!-- 使用动态插槽 -->
<template>
  <dynamic-slot-component 
    :slot-name="currentSlot"
    :fields="formFields"
  >
    <!-- 动态插槽内容 -->
    <template #[currentSlot]>
      <div class="dynamic-content">
        {{ currentSlot }} 的内容
      </div>
    </template>
    
    <!-- 循环渲染动态插槽 -->
    <template v-for="field in formFields" #[field.slotName]="{ field }">
      <div class="form-field" :key="field.name">
        <label>{{ field.label }}</label>
        <input 
          :type="field.type" 
          :placeholder="field.placeholder"
          v-model="formData[field.name]"
        >
      </div>
    </template>
  </dynamic-slot-component>
</template>

<script>
export default {
  data() {
    return {
      currentSlot: 'main',
      formFields: [
        { name: 'username', label: '用户名', type: 'text', slotName: 'usernameField' },
        { name: 'email', label: '邮箱', type: 'email', slotName: 'emailField' },
        { name: 'password', label: '密码', type: 'password', slotName: 'passwordField' }
      ],
      formData: {
        username: '',
        email: '',
        password: ''
      }
    }
  }
}
</script>

三、高级应用场景

场景1:表格组件封装

vue 复制代码
<!-- SmartTable.vue - 智能表格组件 -->
<template>
  <div class="smart-table">
    <!-- 头部插槽 -->
    <div class="table-header" v-if="showHeader">
      <slot name="header" :columns="columns">
        <div class="default-header">
          <h3>{{ title }}</h3>
          <slot name="header-actions"></slot>
        </div>
      </slot>
    </div>
    
    <!-- 表格主体 -->
    <div class="table-container">
      <table>
        <!-- 表头 -->
        <thead>
          <tr>
            <!-- 表头插槽 -->
            <slot name="thead">
              <th v-for="column in columns" :key="column.key">
                {{ column.title }}
              </th>
              <th v-if="$slots['row-actions']">操作</th>
            </slot>
          </tr>
        </thead>
        
        <!-- 表格内容 -->
        <tbody>
          <template v-if="data.length > 0">
            <!-- 行数据插槽 -->
            <slot v-for="(row, index) in data" :row="row" :index="index">
              <tr :key="row.id || index">
                <!-- 单元格插槽 -->
                <slot 
                  name="cell" 
                  :row="row" 
                  :column="column" 
                  :value="row[column.key]"
                  v-for="column in columns"
                  :key="column.key"
                >
                  <td>{{ row[column.key] }}</td>
                </slot>
                
                <!-- 操作列插槽 -->
                <td v-if="$slots['row-actions']">
                  <slot name="row-actions" :row="row" :index="index"></slot>
                </td>
              </tr>
            </slot>
          </template>
          
          <!-- 空状态插槽 -->
          <slot v-else name="empty">
            <tr>
              <td :colspan="columns.length + ($slots['row-actions'] ? 1 : 0)">
                <div class="empty-state">
                  <slot name="empty-icon">
                    <span>📭</span>
                  </slot>
                  <p>暂无数据</p>
                </div>
              </td>
            </tr>
          </slot>
        </tbody>
      </table>
    </div>
    
    <!-- 分页插槽 -->
    <div class="table-footer" v-if="showPagination">
      <slot name="pagination" :current-page="currentPage" :total="total">
        <div class="default-pagination">
          <button 
            @click="prevPage" 
            :disabled="currentPage === 1"
          >
            上一页
          </button>
          <span>第 {{ currentPage }} 页</span>
          <button 
            @click="nextPage" 
            :disabled="currentPage * pageSize >= total"
          >
            下一页
          </button>
        </div>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  name: 'SmartTable',
  
  props: {
    data: {
      type: Array,
      required: true,
      default: () => []
    },
    columns: {
      type: Array,
      default: () => []
    },
    title: String,
    showHeader: {
      type: Boolean,
      default: true
    },
    showPagination: {
      type: Boolean,
      default: false
    },
    total: {
      type: Number,
      default: 0
    },
    pageSize: {
      type: Number,
      default: 10
    },
    currentPage: {
      type: Number,
      default: 1
    }
  },
  
  emits: ['page-change'],
  
  methods: {
    prevPage() {
      if (this.currentPage > 1) {
        this.$emit('page-change', this.currentPage - 1)
      }
    },
    
    nextPage() {
      if (this.currentPage * this.pageSize < this.total) {
        this.$emit('page-change', this.currentPage + 1)
      }
    }
  }
}
</script>

<style scoped>
.smart-table {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
}

.table-header {
  padding: 16px;
  background: #f8f9fa;
  border-bottom: 1px solid #ddd;
}

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

.table-container {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th, td {
  padding: 12px 16px;
  text-align: left;
  border-bottom: 1px solid #eee;
}

th {
  background: #f8f9fa;
  font-weight: 600;
}

tbody tr:hover {
  background: #f8f9fa;
}

.empty-state {
  text-align: center;
  padding: 40px;
  color: #6c757d;
}

.empty-state span {
  font-size: 48px;
  display: block;
  margin-bottom: 16px;
}

.table-footer {
  padding: 16px;
  border-top: 1px solid #ddd;
  background: #f8f9fa;
}

.default-pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 16px;
}

.default-pagination button {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 4px;
  cursor: pointer;
}

.default-pagination button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>
vue 复制代码
<!-- 使用智能表格 -->
<template>
  <div class="dashboard">
    <smart-table
      :data="userData"
      :columns="userColumns"
      title="用户管理"
      :show-pagination="true"
      :total="totalUsers"
      :current-page="currentPage"
      @page-change="handlePageChange"
    >
      <!-- 自定义表头 -->
      <template #header="{ columns }">
        <div class="custom-header">
          <h2>👥 用户列表 ({{ totalUsers }}人)</h2>
          <div class="header-actions">
            <button @click="refreshData">刷新</button>
            <button @click="exportData">导出</button>
            <button @click="addUser">新增用户</button>
          </div>
        </div>
      </template>
      
      <!-- 自定义表头行 -->
      <template #thead>
        <th>#</th>
        <th>基本信息</th>
        <th>状态</th>
        <th>操作</th>
      </template>
      
      <!-- 自定义单元格渲染 -->
      <template #cell="{ row, column, value }">
        <td v-if="column.key === 'avatar'">
          <img :src="value" alt="头像" class="avatar">
        </td>
        <td v-else-if="column.key === 'status'">
          <span :class="`status-badge status-${value}`">
            {{ statusMap[value] }}
          </span>
        </td>
        <td v-else-if="column.key === 'createdAt'">
          {{ formatDate(value) }}
        </td>
        <td v-else>
          {{ value }}
        </td>
      </template>
      
      <!-- 自定义操作列 -->
      <template #row-actions="{ row, index }">
        <div class="action-buttons">
          <button @click="editUser(row)" class="btn-edit">编辑</button>
          <button 
            @click="toggleStatus(row)" 
            :class="['btn-toggle', row.status === 'active' ? 'btn-disable' : 'btn-enable']"
          >
            {{ row.status === 'active' ? '禁用' : '启用' }}
          </button>
          <button @click="deleteUser(row.id)" class="btn-delete">删除</button>
        </div>
      </template>
      
      <!-- 自定义空状态 -->
      <template #empty>
        <tr>
          <td :colspan="userColumns.length + 1">
            <div class="custom-empty">
              <div class="empty-icon">😔</div>
              <h3>暂无用户数据</h3>
              <p>点击"新增用户"按钮添加第一个用户</p>
              <button @click="addUser">新增用户</button>
            </div>
          </td>
        </tr>
      </template>
      
      <!-- 自定义分页 -->
      <template #pagination="{ currentPage, total }">
        <div class="custom-pagination">
          <button 
            @click="goToPage(currentPage - 1)"
            :disabled="currentPage === 1"
          >
            上一页
          </button>
          
          <div class="page-numbers">
            <button
              v-for="page in visiblePages"
              :key="page"
              @click="goToPage(page)"
              :class="{ active: page === currentPage }"
            >
              {{ page }}
            </button>
          </div>
          
          <button 
            @click="goToPage(currentPage + 1)"
            :disabled="currentPage * 10 >= total"
          >
            下一页
          </button>
          
          <span class="page-info">
            共 {{ Math.ceil(total / 10) }} 页,{{ total }} 条记录
          </span>
        </div>
      </template>
    </smart-table>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentPage: 1,
      totalUsers: 125,
      userColumns: [
        { key: 'id', title: 'ID' },
        { key: 'avatar', title: '头像' },
        { key: 'name', title: '姓名' },
        { key: 'email', title: '邮箱' },
        { key: 'role', title: '角色' },
        { key: 'status', title: '状态' },
        { key: 'createdAt', title: '创建时间' }
      ],
      userData: [
        {
          id: 1,
          avatar: 'https://example.com/avatar1.jpg',
          name: '张三',
          email: 'zhangsan@example.com',
          role: '管理员',
          status: 'active',
          createdAt: '2024-01-01'
        },
        // ... 更多数据
      ],
      statusMap: {
        active: '活跃',
        inactive: '禁用',
        pending: '待审核'
      }
    }
  },
  
  computed: {
    visiblePages() {
      const totalPages = Math.ceil(this.totalUsers / 10)
      const pages = []
      const start = Math.max(1, this.currentPage - 2)
      const end = Math.min(totalPages, this.currentPage + 2)
      
      for (let i = start; i <= end; i++) {
        pages.push(i)
      }
      
      return pages
    }
  },
  
  methods: {
    handlePageChange(page) {
      this.currentPage = page
      this.loadData()
    },
    
    goToPage(page) {
      if (page >= 1 && page <= Math.ceil(this.totalUsers / 10)) {
        this.currentPage = page
        this.loadData()
      }
    },
    
    loadData() {
      // 加载数据逻辑
    },
    
    formatDate(date) {
      return new Date(date).toLocaleDateString()
    },
    
    editUser(user) {
      console.log('编辑用户:', user)
    },
    
    toggleStatus(user) {
      user.status = user.status === 'active' ? 'inactive' : 'active'
    },
    
    deleteUser(id) {
      console.log('删除用户:', id)
    },
    
    refreshData() {
      this.loadData()
    },
    
    exportData() {
      console.log('导出数据')
    },
    
    addUser() {
      console.log('添加用户')
    }
  }
}
</script>

<style scoped>
.custom-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.header-actions button {
  margin-left: 8px;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.header-actions button:first-child {
  background: #6c757d;
  color: white;
}

.header-actions button:nth-child(2) {
  background: #28a745;
  color: white;
}

.header-actions button:last-child {
  background: #007bff;
  color: white;
}

.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  object-fit: cover;
}

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

.status-active {
  background: #d4edda;
  color: #155724;
}

.status-inactive {
  background: #f8d7da;
  color: #721c24;
}

.status-pending {
  background: #fff3cd;
  color: #856404;
}

.action-buttons {
  display: flex;
  gap: 8px;
}

.action-buttons button {
  padding: 4px 12px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}

.btn-edit {
  background: #ffc107;
  color: #000;
}

.btn-toggle {
  color: white;
}

.btn-enable {
  background: #28a745;
}

.btn-disable {
  background: #dc3545;
}

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

.custom-empty {
  text-align: center;
  padding: 60px 20px;
}

.empty-icon {
  font-size: 48px;
  margin-bottom: 16px;
}

.custom-empty h3 {
  margin: 16px 0;
  color: #343a40;
}

.custom-empty p {
  color: #6c757d;
  margin-bottom: 24px;
}

.custom-empty button {
  padding: 10px 24px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.custom-pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.custom-pagination button {
  padding: 8px 12px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 4px;
  cursor: pointer;
}

.custom-pagination button.active {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

.custom-pagination button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.page-numbers {
  display: flex;
  gap: 4px;
}

.page-info {
  margin-left: 16px;
  color: #6c757d;
}
</style>

场景2:表单生成器

vue 复制代码
<!-- FormGenerator.vue - 动态表单生成器 -->
<template>
  <form class="form-generator" @submit.prevent="handleSubmit">
    <!-- 表单标题插槽 -->
    <slot name="form-header" :title="formTitle">
      <h2 class="form-title">{{ formTitle }}</h2>
    </slot>
    
    <!-- 动态表单字段 -->
    <div 
      v-for="field in fields" 
      :key="field.name"
      class="form-field"
    >
      <!-- 字段标签插槽 -->
      <slot name="field-label" :field="field">
        <label :for="field.name" class="field-label">
          {{ field.label }}
          <span v-if="field.required" class="required">*</span>
        </label>
      </slot>
      
      <!-- 字段输入插槽 -->
      <slot :name="`field-${field.type}`" :field="field" :value="formData[field.name]">
        <!-- 默认输入组件 -->
        <component
          :is="getComponentType(field.type)"
          v-model="formData[field.name]"
          v-bind="field.props || {}"
          :id="field.name"
          :name="field.name"
          :required="field.required"
          :placeholder="field.placeholder"
          class="field-input"
        />
      </slot>
      
      <!-- 字段错误信息插槽 -->
      <slot name="field-error" :field="field" :error="errors[field.name]">
        <div v-if="errors[field.name]" class="field-error">
          {{ errors[field.name] }}
        </div>
      </slot>
    </div>
    
    <!-- 表单操作插槽 -->
    <div class="form-actions">
      <slot name="form-actions" :isSubmitting="isSubmitting">
        <button 
          type="submit" 
          :disabled="isSubmitting"
          class="submit-btn"
        >
          {{ isSubmitting ? '提交中...' : '提交' }}
        </button>
        <button 
          type="button" 
          @click="handleReset"
          class="reset-btn"
        >
          重置
        </button>
      </slot>
    </div>
    
    <!-- 表单底部插槽 -->
    <slot name="form-footer"></slot>
  </form>
</template>

<script>
export default {
  name: 'FormGenerator',
  
  props: {
    fields: {
      type: Array,
      required: true,
      validator: (fields) => {
        return fields.every(field => field.name && field.type)
      }
    },
    formTitle: {
      type: String,
      default: '表单'
    },
    initialData: {
      type: Object,
      default: () => ({})
    },
    validateOnSubmit: {
      type: Boolean,
      default: true
    }
  },
  
  emits: ['submit', 'validate', 'reset'],
  
  data() {
    return {
      formData: {},
      errors: {},
      isSubmitting: false,
      validationRules: {}
    }
  },
  
  created() {
    this.initForm()
    this.setupValidation()
  },
  
  methods: {
    initForm() {
      // 初始化表单数据
      this.formData = { ...this.initialData }
      
      // 设置默认值
      this.fields.forEach(field => {
        if (this.formData[field.name] === undefined && field.default !== undefined) {
          this.formData[field.name] = field.default
        }
      })
    },
    
    setupValidation() {
      this.fields.forEach(field => {
        if (field.rules) {
          this.validationRules[field.name] = field.rules
        }
      })
    },
    
    getComponentType(type) {
      const componentMap = {
        text: 'input',
        email: 'input',
        password: 'input',
        number: 'input',
        textarea: 'textarea',
        select: 'select',
        checkbox: 'input',
        radio: 'input',
        date: 'input',
        file: 'input'
      }
      return componentMap[type] || 'input'
    },
    
    async validateForm() {
      this.errors = {}
      let isValid = true
      
      for (const field of this.fields) {
        const value = this.formData[field.name]
        const rules = this.validationRules[field.name]
        
        if (rules) {
          for (const rule of rules) {
            const error = await rule.validate(value, this.formData)
            if (error) {
              this.errors[field.name] = error
              isValid = false
              break
            }
          }
        }
      }
      
      this.$emit('validate', { isValid, errors: this.errors })
      return isValid
    },
    
    async handleSubmit() {
      if (this.validateOnSubmit) {
        const isValid = await this.validateForm()
        if (!isValid) return
      }
      
      this.isSubmitting = true
      try {
        await this.$emit('submit', this.formData)
      } finally {
        this.isSubmitting = false
      }
    },
    
    handleReset() {
      this.initForm()
      this.errors = {}
      this.$emit('reset')
    }
  },
  
  watch: {
    initialData: {
      handler() {
        this.initForm()
      },
      deep: true
    }
  }
}
</script>

<style scoped>
.form-generator {
  max-width: 600px;
  margin: 0 auto;
  padding: 24px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.form-title {
  margin-bottom: 24px;
  color: #333;
  text-align: center;
}

.form-field {
  margin-bottom: 20px;
}

.field-label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
  color: #555;
}

.required {
  color: #dc3545;
}

.field-input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.2s;
}

.field-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.field-error {
  margin-top: 4px;
  color: #dc3545;
  font-size: 12px;
}

.form-actions {
  display: flex;
  gap: 12px;
  margin-top: 32px;
  padding-top: 20px;
  border-top: 1px solid #eee;
}

.submit-btn {
  flex: 1;
  padding: 12px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: background 0.2s;
}

.submit-btn:hover:not(:disabled) {
  background: #0056b3;
}

.submit-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.reset-btn {
  flex: 1;
  padding: 12px;
  background: #6c757d;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: background 0.2s;
}

.reset-btn:hover {
  background: #545b62;
}
</style>
vue 复制代码
<!-- 使用表单生成器 -->
<template>
  <div class="app-container">
    <form-generator
      :fields="registrationFields"
      form-title="用户注册"
      :initial-data="initialData"
      @submit="handleRegistration"
      @validate="handleValidation"
    >
      <!-- 自定义表单头部 -->
      <template #form-header="{ title }">
        <div class="custom-form-header">
          <h1>{{ title }}</h1>
          <p class="subtitle">请填写以下信息完成注册</p>
          <div class="progress-bar">
            <div class="progress" :style="{ width: `${progress}%` }"></div>
          </div>
        </div>
      </template>
      
      <!-- 自定义邮箱字段 -->
      <template #field-email="{ field, value }">
        <div class="custom-email-field">
          <div class="input-with-icon">
            <span class="icon">✉️</span>
            <input
              type="email"
              v-model="formData[field.name]"
              :placeholder="field.placeholder"
              :required="field.required"
              class="email-input"
              @blur="validateEmail"
            >
          </div>
          <div v-if="emailVerified" class="email-verified">
            ✅ 邮箱已验证
          </div>
        </div>
      </template>
      
      <!-- 自定义密码字段 -->
      <template #field-password="{ field }">
        <div class="custom-password-field">
          <div class="password-input-wrapper">
            <input
              :type="showPassword ? 'text' : 'password'"
              v-model="formData[field.name]"
              :placeholder="field.placeholder"
              :required="field.required"
              class="password-input"
            >
            <button
              type="button"
              @click="togglePasswordVisibility"
              class="toggle-password"
            >
              {{ showPassword ? '🙈' : '👁️' }}
            </button>
          </div>
          
          <!-- 密码强度指示器 -->
          <div class="password-strength">
            <div class="strength-bar" :class="strengthClass"></div>
            <span class="strength-text">{{ strengthText }}</span>
          </div>
        </div>
      </template>
      
      <!-- 自定义选择字段 -->
      <template #field-select="{ field }">
        <div class="custom-select-field">
          <select
            v-model="formData[field.name]"
            :required="field.required"
            class="styled-select"
          >
            <option value="" disabled>请选择{{ field.label }}</option>
            <option 
              v-for="option in field.options" 
              :key="option.value" 
              :value="option.value"
            >
              {{ option.label }}
            </option>
          </select>
          <span class="select-arrow">▼</span>
        </div>
      </template>
      
      <!-- 自定义复选框字段 -->
      <template #field-checkbox="{ field }">
        <div class="custom-checkbox-field">
          <label class="checkbox-label">
            <input
              type="checkbox"
              v-model="formData[field.name]"
              class="styled-checkbox"
            >
            <span class="checkmark"></span>
            <span class="checkbox-text">{{ field.label }}</span>
          </label>
          <a href="/terms" target="_blank" class="terms-link">
            查看服务条款
          </a>
        </div>
      </template>
      
      <!-- 自定义表单操作 -->
      <template #form-actions="{ isSubmitting }">
        <div class="custom-actions">
          <button
            type="submit"
            :disabled="isSubmitting || !isFormValid"
            class="custom-submit-btn"
          >
            <span v-if="isSubmitting" class="spinner"></span>
            {{ isSubmitting ? '注册中...' : '立即注册' }}
          </button>
          <div class="alternative-actions">
            <span>已有账号?</span>
            <router-link to="/login" class="login-link">
              立即登录
            </router-link>
          </div>
        </div>
      </template>
      
      <!-- 自定义表单底部 -->
      <template #form-footer>
        <div class="form-footer">
          <p class="agreement">
            注册即表示您同意我们的
            <a href="/terms">服务条款</a>
            和
            <a href="/privacy">隐私政策</a>
          </p>
          <div class="social-login">
            <p>或使用以下方式注册</p>
            <div class="social-buttons">
              <button @click="socialLogin('wechat')" class="social-btn wechat">
                <span class="social-icon">💬</span> 微信
              </button>
              <button @click="socialLogin('github')" class="social-btn github">
                <span class="social-icon">🐙</span> GitHub
              </button>
              <button @click="socialLogin('google')" class="social-btn google">
                <span class="social-icon">🔍</span> Google
              </button>
            </div>
          </div>
        </div>
      </template>
      
      <!-- 自定义错误信息 -->
      <template #field-error="{ error }">
        <div v-if="error" class="custom-error">
          <span class="error-icon">⚠️</span>
          <span>{{ error }}</span>
        </div>
      </template>
    </form-generator>
  </div>
</template>

<script>
import { validateEmail as validateEmailFn } from '@/utils/validation'
import { checkPasswordStrength } from '@/utils/password'

export default {
  data() {
    return {
      progress: 30,
      showPassword: false,
      emailVerified: false,
      formData: {},
      registrationFields: [
        {
          name: 'username',
          label: '用户名',
          type: 'text',
          placeholder: '请输入用户名',
          required: true,
          rules: [
            {
              validate: (value) => value && value.length >= 3,
              message: '用户名至少需要3个字符'
            }
          ]
        },
        {
          name: 'email',
          label: '邮箱',
          type: 'email',
          placeholder: '请输入邮箱地址',
          required: true,
          rules: [
            {
              validate: validateEmailFn,
              message: '请输入有效的邮箱地址'
            }
          ]
        },
        {
          name: 'password',
          label: '密码',
          type: 'password',
          placeholder: '请输入密码',
          required: true,
          rules: [
            {
              validate: (value) => value && value.length >= 8,
              message: '密码至少需要8个字符'
            },
            {
              validate: (value) => /[A-Z]/.test(value),
              message: '密码必须包含大写字母'
            },
            {
              validate: (value) => /[0-9]/.test(value),
              message: '密码必须包含数字'
            }
          ]
        },
        {
          name: 'gender',
          label: '性别',
          type: 'select',
          options: [
            { value: 'male', label: '男' },
            { value: 'female', label: '女' },
            { value: 'other', label: '其他' }
          ]
        },
        {
          name: 'agreeTerms',
          label: '我同意服务条款和隐私政策',
          type: 'checkbox',
          required: true,
          rules: [
            {
              validate: (value) => value === true,
              message: '必须同意服务条款'
            }
          ]
        }
      ],
      initialData: {
        gender: 'male'
      }
    }
  },
  
  computed: {
    strengthClass() {
      const strength = checkPasswordStrength(this.formData.password || '')
      return `strength-${strength.level}`
    },
    
    strengthText() {
      const strength = checkPasswordStrength(this.formData.password || '')
      return strength.text
    },
    
    isFormValid() {
      return this.emailVerified && 
             this.formData.password && 
             this.formData.agreeTerms
    }
  },
  
  watch: {
    'formData.password'(newPassword) {
      this.progress = Math.min(100, 30 + (newPassword?.length || 0) * 5)
    }
  },
  
  methods: {
    async handleRegistration(formData) {
      console.log('提交注册数据:', formData)
      // 实际提交逻辑
      try {
        // 模拟API调用
        await this.$api.register(formData)
        this.$notify.success('注册成功!')
        this.$router.push('/dashboard')
      } catch (error) {
        this.$notify.error('注册失败: ' + error.message)
      }
    },
    
    handleValidation({ isValid, errors }) {
      console.log('验证结果:', isValid, errors)
    },
    
    togglePasswordVisibility() {
      this.showPassword = !this.showPassword
    },
    
    async validateEmail() {
      if (this.formData.email) {
        this.emailVerified = await validateEmailFn(this.formData.email)
      }
    },
    
    socialLogin(provider) {
      console.log('社交登录:', provider)
      // 实现社交登录逻辑
    }
  }
}
</script>

<style scoped>
.app-container {
  max-width: 500px;
  margin: 40px auto;
  padding: 20px;
}

.custom-form-header {
  text-align: center;
  margin-bottom: 30px;
}

.subtitle {
  color: #666;
  margin-top: 8px;
}

.progress-bar {
  height: 4px;
  background: #e0e0e0;
  border-radius: 2px;
  margin-top: 16px;
  overflow: hidden;
}

.progress {
  height: 100%;
  background: #007bff;
  transition: width 0.3s ease;
}

.custom-email-field {
  margin-bottom: 15px;
}

.input-with-icon {
  position: relative;
}

.icon {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  font-size: 18px;
}

.email-input {
  width: 100%;
  padding: 12px 12px 12px 40px;
  border: 2px solid #ddd;
  border-radius: 8px;
  font-size: 16px;
}

.email-input:focus {
  border-color: #007bff;
  outline: none;
}

.email-verified {
  margin-top: 8px;
  color: #28a745;
  font-size: 14px;
}

.custom-password-field {
  margin-bottom: 15px;
}

.password-input-wrapper {
  position: relative;
}

.password-input {
  width: 100%;
  padding: 12px 50px 12px 12px;
  border: 2px solid #ddd;
  border-radius: 8px;
  font-size: 16px;
}

.toggle-password {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  font-size: 18px;
  cursor: pointer;
  padding: 4px;
}

.password-strength {
  margin-top: 8px;
}

.strength-bar {
  height: 4px;
  border-radius: 2px;
  margin-bottom: 4px;
  transition: all 0.3s ease;
}

.strength-weak {
  width: 25%;
  background: #dc3545;
}

.strength-medium {
  width: 50%;
  background: #ffc107;
}

.strength-strong {
  width: 75%;
  background: #28a745;
}

.strength-very-strong {
  width: 100%;
  background: #007bff;
}

.strength-text {
  font-size: 12px;
  color: #666;
}

.custom-select-field {
  position: relative;
}

.styled-select {
  width: 100%;
  padding: 12px;
  border: 2px solid #ddd;
  border-radius: 8px;
  font-size: 16px;
  background: white;
  appearance: none;
  cursor: pointer;
}

.select-arrow {
  position: absolute;
  right: 15px;
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
}

.custom-checkbox-field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px;
  border: 2px solid #ddd;
  border-radius: 8px;
}

.checkbox-label {
  display: flex;
  align-items: center;
  cursor: pointer;
}

.styled-checkbox {
  display: none;
}

.checkmark {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-radius: 4px;
  margin-right: 10px;
  position: relative;
  transition: all 0.2s;
}

.styled-checkbox:checked + .checkmark {
  background: #007bff;
  border-color: #007bff;
}

.styled-checkbox:checked + .checkmark::after {
  content: '✓';
  position: absolute;
  color: white;
  font-size: 14px;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.checkbox-text {
  color: #333;
}

.terms-link {
  color: #007bff;
  text-decoration: none;
  font-size: 14px;
}

.terms-link:hover {
  text-decoration: underline;
}

.custom-actions {
  margin-top: 30px;
}

.custom-submit-btn {
  width: 100%;
  padding: 14px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
  transition: transform 0.2s, opacity 0.2s;
}

.custom-submit-btn:hover:not(:disabled) {
  transform: translateY(-2px);
}

.custom-submit-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.spinner {
  width: 20px;
  height: 20px;
  border: 2px solid white;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-right: 8px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.alternative-actions {
  text-align: center;
  margin-top: 16px;
  color: #666;
}

.login-link {
  color: #007bff;
  text-decoration: none;
  margin-left: 8px;
}

.login-link:hover {
  text-decoration: underline;
}

.form-footer {
  margin-top: 30px;
  padding-top: 20px;
  border-top: 1px solid #eee;
  text-align: center;
}

.agreement {
  color: #666;
  font-size: 14px;
  margin-bottom: 20px;
}

.agreement a {
  color: #007bff;
  text-decoration: none;
}

.agreement a:hover {
  text-decoration: underline;
}

.social-login p {
  color: #666;
  margin-bottom: 12px;
}

.social-buttons {
  display: flex;
  gap: 12px;
  justify-content: center;
}

.social-btn {
  flex: 1;
  padding: 12px;
  border: 2px solid #ddd;
  border-radius: 8px;
  background: white;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: all 0.2s;
}

.social-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.wechat {
  border-color: #07c160;
  color: #07c160;
}

.github {
  border-color: #24292e;
  color: #24292e;
}

.google {
  border-color: #db4437;
  color: #db4437;
}

.custom-error {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #dc3545;
  font-size: 14px;
  margin-top: 8px;
}

.error-icon {
  font-size: 16px;
}
</style>

四、插槽的实用技巧与最佳实践

1. 插槽作用域

vue 复制代码
<!-- 作用域示例 -->
<template>
  <parent-component>
    <!-- 在插槽内容中可以访问父组件的数据 -->
    <template #default>
      <p>父组件数据: {{ parentData }}</p>
    </template>
    
    <!-- 也可以访问子组件暴露的数据 -->
    <template #scoped="slotProps">
      <p>子组件数据: {{ slotProps.childData }}</p>
    </template>
  </parent-component>
</template>

2. 动态插槽内容

vue 复制代码
<script>
export default {
  data() {
    return {
      currentView: 'summary'
    }
  },
  
  computed: {
    slotContent() {
      const views = {
        summary: {
          title: '概要视图',
          content: this.summaryContent
        },
        details: {
          title: '详细视图',
          content: this.detailsContent
        },
        analytics: {
          title: '分析视图',
          content: this.analyticsContent
        }
      }
      return views[this.currentView]
    }
  }
}
</script>

<template>
  <dashboard-layout>
    <!-- 动态切换插槽内容 -->
    <template #[currentView]>
      <div class="view-content">
        <h3>{{ slotContent.title }}</h3>
        <div v-html="slotContent.content"></div>
      </div>
    </template>
  </dashboard-layout>
</template>

3. 插槽验证

vue 复制代码
<script>
export default {
  mounted() {
    // 检查必要的插槽是否提供
    if (!this.$slots.header) {
      console.warn('建议提供 header 插槽内容')
    }
    
    if (!this.$slots.default) {
      console.error('必须提供默认插槽内容')
    }
    
    // 检查具名插槽
    console.log('可用的插槽:', Object.keys(this.$slots))
    console.log('作用域插槽:', Object.keys(this.$scopedSlots))
  }
}
</script>

4. 性能优化

vue 复制代码
<template>
  <!-- 使用 v-once 缓存插槽内容 -->
  <div v-once>
    <slot name="static-content">
      这部分内容只渲染一次
    </slot>
  </div>
  
  <!-- 使用 v-if 控制插槽渲染 -->
  <slot v-if="shouldRenderSlot" name="conditional-content"></slot>
  
  <!-- 懒加载插槽内容 -->
  <suspense>
    <template #default>
      <slot name="async-content"></slot>
    </template>
    <template #fallback>
      <slot name="loading"></slot>
    </template>
  </suspense>
</template>

五、Vue 2 与 Vue 3 的差异

Vue 2 语法

vue 复制代码
<!-- 具名插槽 -->
<template slot="header"></template>

<!-- 作用域插槽 -->
<template slot-scope="props"></template>

<!-- 旧语法混用 -->
<template slot="item" slot-scope="{ item }">
  {{ item.name }}
</template>

Vue 3 语法

vue 复制代码
<!-- 简写语法 -->
<template #header></template>

<!-- 作用域插槽 -->
<template #item="props"></template>

<!-- 解构语法 -->
<template #item="{ item, index }">
  {{ index }}. {{ item.name }}
</template>

<!-- 动态插槽名 -->
<template #[dynamicSlotName]></template>

六、总结

Vue 插槽系统提供了强大的内容分发机制,主要包括:

  1. 默认插槽:基本的内容分发
  2. 具名插槽:多位置内容分发
  3. 作用域插槽:子向父传递数据
  4. 动态插槽:运行时决定插槽位置

最佳实践建议

  • 优先使用作用域插槽而不是 $emit 来传递渲染控制权
  • 为复杂组件提供合理的默认插槽内容
  • 在组件库开发中充分利用插槽的灵活性
  • 在 Vue 3 中使用新的 v-slot 语法
  • 合理组织插槽,避免过度嵌套

插槽让 Vue 组件变得更加灵活和可复用,是构建高级组件和组件库的重要工具。

相关推荐
北辰alk10 小时前
Vue 组件中访问根实例的完整指南
vue.js
北辰alk10 小时前
Vue Router 中获取路由参数的全面指南
vue.js
北辰alk10 小时前
Vue 的 v-cloak 和 v-pre 指令详解
vue.js
期待のcode10 小时前
前后端分离项目 Springboot+vue 在云服务器上的部署
服务器·vue.js·spring boot
xkxnq10 小时前
第一阶段:Vue 基础入门(第 15天)
前端·javascript·vue.js
北辰alk10 小时前
Vue 过滤器:优雅处理数据的艺术
vue.js
源码获取_wx:Fegn089512 小时前
基于 vue智慧养老院系统
开发语言·前端·javascript·vue.js·spring boot·后端·课程设计
毕设十刻12 小时前
基于Vue的人事管理系统67zzz(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
QQ196328847512 小时前
ssm基于Springboot+的球鞋销售商城网站vue
vue.js·spring boot·后端