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 插槽系统提供了强大的内容分发机制,主要包括:
- 默认插槽:基本的内容分发
- 具名插槽:多位置内容分发
- 作用域插槽:子向父传递数据
- 动态插槽:运行时决定插槽位置
最佳实践建议:
- 优先使用作用域插槽而不是
$emit来传递渲染控制权 - 为复杂组件提供合理的默认插槽内容
- 在组件库开发中充分利用插槽的灵活性
- 在 Vue 3 中使用新的
v-slot语法 - 合理组织插槽,避免过度嵌套
插槽让 Vue 组件变得更加灵活和可复用,是构建高级组件和组件库的重要工具。