组件系统是Vue.js最强大的功能之一,它允许我们将UI拆分成独立、可复用的代码片段。本章将深入学习组件的定义、使用、通信和高级特性。
4.1 组件基础
组件定义和注册
<!-- HelloWorld.vue -->
<template>
<div class="hello-world">
<h1>{{ title }}</h1>
<p>{{ message }}</p>
<button @click="updateMessage">更新消息</button>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
data() {
return {
title: 'Hello World 组件',
message: '这是一个Vue组件示例'
}
},
methods: {
updateMessage() {
this.message = '消息已更新!时间:' + new Date().toLocaleTimeString()
}
}
}
</script>
<style scoped>
.hello-world {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
margin: 10px;
}
.hello-world h1 {
color: #42b983;
}
.hello-world button {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.hello-world button:hover {
background: #369870;
}
</style>
<!-- App.vue -->
<template>
<div id="app">
<h1>Vue组件系统示例</h1>
<!-- 使用组件 -->
<HelloWorld />
<HelloWorld />
<!-- 全局注册的组件 -->
<GlobalComponent />
<!-- 局部注册的组件 -->
<LocalComponent />
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
import LocalComponent from './components/LocalComponent.vue'
export default {
name: 'App',
components: {
HelloWorld,
LocalComponent
}
}
</script>
// main.js - 全局组件注册
import { createApp } from 'vue'
import App from './App.vue'
import GlobalComponent from './components/GlobalComponent.vue'
const app = createApp(App)
// 全局注册组件
app.component('GlobalComponent', GlobalComponent)
app.mount('#app')
组件的data必须是函数
<!-- Counter.vue -->
<template>
<div class="counter">
<h3>计数器 {{ id }}</h3>
<p>当前计数: {{ count }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">重置</button>
</div>
</template>
<script>
export default {
name: 'Counter',
props: ['id'],
// data必须是函数,确保每个组件实例都有独立的数据副本
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
},
decrement() {
this.count--
},
reset() {
this.count = 0
}
}
}
</script>
<style scoped>
.counter {
border: 1px solid #ddd;
padding: 15px;
margin: 10px;
border-radius: 5px;
background: #f9f9f9;
}
.counter button {
margin: 0 5px;
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 3px;
cursor: pointer;
}
.counter button:hover {
background: #e0e0e0;
}
</style>
<!-- 使用多个Counter实例 -->
<template>
<div>
<h2>多个计数器实例</h2>
<Counter id="A" />
<Counter id="B" />
<Counter id="C" />
</div>
</template>
<script>
import Counter from './Counter.vue'
export default {
components: {
Counter
}
}
</script>
4.2 Props(父向子通信)
基本Props使用
<!-- UserCard.vue -->
<template>
<div class="user-card">
<img :src="avatar" :alt="name" class="avatar">
<div class="user-info">
<h3>{{ name }}</h3>
<p>{{ email }}</p>
<p>年龄: {{ age }}</p>
<p>状态: <span :class="statusClass">{{ status }}</span></p>
<div class="tags">
<span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'UserCard',
props: {
// 基本类型检查
name: String,
email: String,
age: Number,
// 多种可能的类型
avatar: [String, Object],
// 带有默认值的prop
status: {
type: String,
default: 'offline'
},
// 数组类型
tags: {
type: Array,
default: () => []
},
// 对象类型
userInfo: {
type: Object,
default: () => ({})
},
// 自定义验证函数
priority: {
type: String,
validator: function (value) {
return ['low', 'medium', 'high'].indexOf(value) !== -1
}
},
// 必需的prop
userId: {
type: [String, Number],
required: true
}
},
computed: {
statusClass() {
return {
'status-online': this.status === 'online',
'status-offline': this.status === 'offline',
'status-busy': this.status === 'busy'
}
}
}
}
</script>
<style scoped>
.user-card {
display: flex;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
margin: 10px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin-right: 20px;
object-fit: cover;
}
.user-info h3 {
margin: 0 0 10px 0;
color: #333;
}
.user-info p {
margin: 5px 0;
color: #666;
}
.status-online { color: #4CAF50; }
.status-offline { color: #9E9E9E; }
.status-busy { color: #FF9800; }
.tags {
margin-top: 10px;
}
.tag {
display: inline-block;
background: #e1f5fe;
color: #0277bd;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
margin-right: 5px;
}
</style>
<!-- 父组件使用UserCard -->
<template>
<div>
<h2>用户列表</h2>
<!-- 静态props -->
<UserCard
user-id="1"
name="张三"
email="zhangsan@example.com"
:age="25"
avatar="https://via.placeholder.com/80"
status="online"
:tags="['开发者', 'Vue.js', '前端']"
priority="high"
/>
<!-- 动态props -->
<UserCard
v-for="user in users"
:key="user.id"
:user-id="user.id"
:name="user.name"
:email="user.email"
:age="user.age"
:avatar="user.avatar"
:status="user.status"
:tags="user.tags"
:priority="user.priority"
/>
<!-- 使用v-bind传递整个对象 -->
<UserCard
v-for="user in users"
:key="'obj-' + user.id"
v-bind="user"
/>
</div>
</template>
<script>
import UserCard from './components/UserCard.vue'
export default {
components: {
UserCard
},
data() {
return {
users: [
{
userId: '2',
name: '李四',
email: 'lisi@example.com',
age: 30,
avatar: 'https://via.placeholder.com/80',
status: 'busy',
tags: ['设计师', 'UI/UX'],
priority: 'medium'
},
{
userId: '3',
name: '王五',
email: 'wangwu@example.com',
age: 28,
avatar: 'https://via.placeholder.com/80',
status: 'offline',
tags: ['后端', 'Node.js', 'Python'],
priority: 'low'
}
]
}
}
}
</script>
Prop验证和类型检查
<!-- ProductCard.vue -->
<template>
<div class="product-card">
<img :src="product.image" :alt="product.name" class="product-image">
<div class="product-info">
<h3>{{ product.name }}</h3>
<p class="description">{{ product.description }}</p>
<div class="price">
<span class="current-price">${{ formatPrice(product.price) }}</span>
<span v-if="product.originalPrice" class="original-price">
${{ formatPrice(product.originalPrice) }}
</span>
</div>
<div class="rating">
<span v-for="star in 5" :key="star" class="star" :class="{ filled: star <= product.rating }">
★
</span>
<span class="rating-text">({{ product.rating }}/5)</span>
</div>
<button @click="addToCart" :disabled="!product.inStock" class="add-to-cart">
{{ product.inStock ? '加入购物车' : '缺货' }}
</button>
</div>
</div>
</template>
<script>
export default {
name: 'ProductCard',
props: {
product: {
type: Object,
required: true,
validator(value) {
// 验证product对象必须包含必要的属性
const requiredProps = ['id', 'name', 'price', 'image']
return requiredProps.every(prop => prop in value)
}
},
// 货币格式
currency: {
type: String,
default: 'USD',
validator(value) {
return ['USD', 'EUR', 'CNY', 'JPY'].includes(value)
}
},
// 显示模式
displayMode: {
type: String,
default: 'card',
validator(value) {
return ['card', 'list', 'grid'].includes(value)
}
},
// 是否显示评分
showRating: {
type: Boolean,
default: true
},
// 折扣信息
discount: {
type: [Number, Object],
default: null,
validator(value) {
if (typeof value === 'number') {
return value >= 0 && value <= 100
}
if (typeof value === 'object' && value !== null) {
return 'percentage' in value || 'amount' in value
}
return true
}
}
},
emits: ['add-to-cart', 'product-click'],
methods: {
formatPrice(price) {
return parseFloat(price).toFixed(2)
},
addToCart() {
this.$emit('add-to-cart', {
productId: this.product.id,
name: this.product.name,
price: this.product.price,
quantity: 1
})
}
}
}
</script>
<style scoped>
.product-card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
max-width: 300px;
margin: 10px;
}
.product-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-info {
padding: 15px;
}
.product-info h3 {
margin: 0 0 10px 0;
color: #333;
font-size: 18px;
}
.description {
color: #666;
font-size: 14px;
margin-bottom: 10px;
line-height: 1.4;
}
.price {
margin-bottom: 10px;
}
.current-price {
font-size: 20px;
font-weight: bold;
color: #e74c3c;
}
.original-price {
font-size: 16px;
color: #999;
text-decoration: line-through;
margin-left: 10px;
}
.rating {
margin-bottom: 15px;
}
.star {
color: #ddd;
font-size: 16px;
}
.star.filled {
color: #ffc107;
}
.rating-text {
margin-left: 5px;
color: #666;
font-size: 14px;
}
.add-to-cart {
width: 100%;
padding: 10px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.add-to-cart:hover:not(:disabled) {
background: #369870;
}
.add-to-cart:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
4.3 自定义事件(子向父通信)
基本事件发射
<!-- ChildComponent.vue -->
<template>
<div class="child-component">
<h3>子组件</h3>
<p>内部计数: {{ internalCount }}</p>
<!-- 发射简单事件 -->
<button @click="sendMessage">发送消息给父组件</button>
<!-- 发射带数据的事件 -->
<button @click="sendData">发送数据</button>
<!-- 发射多个参数的事件 -->
<button @click="sendMultipleData">发送多个参数</button>
<!-- 计数器事件 -->
<button @click="increment">增加计数</button>
<button @click="decrement">减少计数</button>
<!-- 表单事件 -->
<div class="form-section">
<input v-model="inputValue" @input="handleInput" placeholder="输入内容">
<button @click="submitForm">提交表单</button>
</div>
</div>
</template>
<script>
export default {
name: 'ChildComponent',
// 声明组件会发射的事件
emits: {
// 简单事件声明
'message-sent': null,
// 带验证的事件声明
'data-changed': (payload) => {
return payload && typeof payload === 'object'
},
// 计数器事件
'count-updated': (count) => {
return typeof count === 'number'
},
// 表单事件
'form-submitted': (formData) => {
return formData && formData.value !== undefined
},
// 输入事件
'input-changed': String
},
data() {
return {
internalCount: 0,
inputValue: '',
messages: []
}
},
methods: {
sendMessage() {
// 发射简单事件
this.$emit('message-sent')
this.messages.push('消息已发送: ' + new Date().toLocaleTimeString())
},
sendData() {
// 发射带数据的事件
const data = {
timestamp: Date.now(),
message: '来自子组件的数据',
count: this.internalCount
}
this.$emit('data-changed', data)
},
sendMultipleData() {
// 发射多个参数的事件
this.$emit('multiple-data', 'param1', 'param2', { key: 'value' })
},
increment() {
this.internalCount++
this.$emit('count-updated', this.internalCount)
},
decrement() {
this.internalCount--
this.$emit('count-updated', this.internalCount)
},
handleInput() {
this.$emit('input-changed', this.inputValue)
},
submitForm() {
const formData = {
value: this.inputValue,
timestamp: new Date().toISOString(),
source: 'child-component'
}
this.$emit('form-submitted', formData)
this.inputValue = '' // 清空输入
}
}
}
</script>
<style scoped>
.child-component {
border: 2px solid #42b983;
padding: 20px;
margin: 10px;
border-radius: 8px;
background: #f0f9ff;
}
.child-component h3 {
color: #42b983;
margin-top: 0;
}
.child-component button {
margin: 5px;
padding: 8px 12px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.child-component button:hover {
background: #369870;
}
.form-section {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #ddd;
}
.form-section input {
padding: 8px;
margin-right: 10px;
border: 1px solid #ccc;
border-radius: 4px;
width: 200px;
}
</style>
<!-- ParentComponent.vue -->
<template>
<div class="parent-component">
<h2>父组件</h2>
<!-- 监听子组件事件 -->
<ChildComponent
@message-sent="handleMessageSent"
@data-changed="handleDataChanged"
@count-updated="handleCountUpdated"
@form-submitted="handleFormSubmitted"
@input-changed="handleInputChanged"
@multiple-data="handleMultipleData"
/>
<!-- 显示接收到的数据 -->
<div class="received-data">
<h3>接收到的数据:</h3>
<div v-if="lastMessage" class="message">
<strong>最后消息:</strong> {{ lastMessage }}
</div>
<div v-if="lastData" class="data">
<strong>最后数据:</strong> {{ JSON.stringify(lastData, null, 2) }}
</div>
<div class="count">
<strong>子组件计数:</strong> {{ childCount }}
</div>
<div v-if="lastInput" class="input">
<strong>输入内容:</strong> {{ lastInput }}
</div>
<div v-if="submittedForms.length > 0" class="forms">
<strong>提交的表单:</strong>
<ul>
<li v-for="(form, index) in submittedForms" :key="index">
{{ form.value }} ({{ form.timestamp }})
</li>
</ul>
</div>
</div>
<!-- 事件日志 -->
<div class="event-log">
<h3>事件日志:</h3>
<div class="log-container">
<div v-for="(log, index) in eventLogs" :key="index" class="log-item">
<span class="timestamp">{{ log.timestamp }}</span>
<span class="event-type">{{ log.type }}</span>
<span class="event-data">{{ log.data }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
name: 'ParentComponent',
components: {
ChildComponent
},
data() {
return {
lastMessage: '',
lastData: null,
childCount: 0,
lastInput: '',
submittedForms: [],
eventLogs: []
}
},
methods: {
handleMessageSent() {
this.lastMessage = '子组件发送了消息'
this.addEventLog('message-sent', '简单消息事件')
},
handleDataChanged(data) {
this.lastData = data
this.addEventLog('data-changed', JSON.stringify(data))
},
handleCountUpdated(count) {
this.childCount = count
this.addEventLog('count-updated', `计数更新为: ${count}`)
},
handleFormSubmitted(formData) {
this.submittedForms.push(formData)
this.addEventLog('form-submitted', `表单提交: ${formData.value}`)
},
handleInputChanged(value) {
this.lastInput = value
this.addEventLog('input-changed', `输入变化: ${value}`)
},
handleMultipleData(param1, param2, param3) {
this.addEventLog('multiple-data', `多参数: ${param1}, ${param2}, ${JSON.stringify(param3)}`)
},
addEventLog(type, data) {
this.eventLogs.unshift({
timestamp: new Date().toLocaleTimeString(),
type,
data
})
// 限制日志数量
if (this.eventLogs.length > 10) {
this.eventLogs.pop()
}
}
}
}
</script>
<style scoped>
.parent-component {
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.received-data {
background: white;
padding: 15px;
margin: 20px 0;
border-radius: 8px;
border: 1px solid #ddd;
}
.received-data > div {
margin-bottom: 10px;
}
.event-log {
background: white;
padding: 15px;
margin: 20px 0;
border-radius: 8px;
border: 1px solid #ddd;
}
.log-container {
max-height: 200px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 4px;
}
.log-item {
padding: 8px;
border-bottom: 1px solid #eee;
display: flex;
gap: 10px;
}
.log-item:last-child {
border-bottom: none;
}
.timestamp {
color: #666;
font-size: 12px;
min-width: 80px;
}
.event-type {
color: #42b983;
font-weight: bold;
min-width: 120px;
}
.event-data {
color: #333;
flex: 1;
}
</style>
事件验证和自定义事件名
<!-- CustomEventComponent.vue -->
<template>
<div class="custom-event-component">
<h3>自定义事件组件</h3>
<!-- 用户操作事件 -->
<div class="user-actions">
<button @click="handleLogin">登录</button>
<button @click="handleLogout">登出</button>
<button @click="handleProfileUpdate">更新资料</button>
</div>
<!-- 数据操作事件 -->
<div class="data-actions">
<button @click="createItem">创建项目</button>
<button @click="updateItem">更新项目</button>
<button @click="deleteItem">删除项目</button>
</div>
<!-- 状态变化事件 -->
<div class="status-actions">
<select @change="changeStatus" v-model="currentStatus">
<option value="active">激活</option>
<option value="inactive">非激活</option>
<option value="pending">待处理</option>
<option value="suspended">暂停</option>
</select>
</div>
</div>
</template>
<script>
export default {
name: 'CustomEventComponent',
// 详细的事件声明
emits: {
// 用户认证事件
'user:login': (userData) => {
return userData && userData.username && userData.timestamp
},
'user:logout': (logoutData) => {
return logoutData && logoutData.timestamp
},
'user:profile-updated': (profileData) => {
return profileData && typeof profileData === 'object'
},
// 数据操作事件
'data:item-created': (item) => {
return item && item.id && item.type
},
'data:item-updated': (updateInfo) => {
return updateInfo && updateInfo.id && updateInfo.changes
},
'data:item-deleted': (deleteInfo) => {
return deleteInfo && deleteInfo.id
},
// 状态变化事件
'status:changed': (statusInfo) => {
return statusInfo && statusInfo.from && statusInfo.to
}
},
data() {
return {
currentStatus: 'active',
previousStatus: 'active'
}
},
methods: {
handleLogin() {
const userData = {
username: 'user123',
timestamp: new Date().toISOString(),
loginMethod: 'password'
}
this.$emit('user:login', userData)
},
handleLogout() {
const logoutData = {
timestamp: new Date().toISOString(),
reason: 'user_initiated'
}
this.$emit('user:logout', logoutData)
},
handleProfileUpdate() {
const profileData = {
userId: 'user123',
changes: {
email: 'newemail@example.com',
phone: '+1234567890'
},
timestamp: new Date().toISOString()
}
this.$emit('user:profile-updated', profileData)
},
createItem() {
const item = {
id: 'item_' + Date.now(),
type: 'document',
name: '新文档',
createdAt: new Date().toISOString()
}
this.$emit('data:item-created', item)
},
updateItem() {
const updateInfo = {
id: 'item_123',
changes: {
name: '更新的文档名称',
lastModified: new Date().toISOString()
},
updatedBy: 'user123'
}
this.$emit('data:item-updated', updateInfo)
},
deleteItem() {
const deleteInfo = {
id: 'item_123',
deletedAt: new Date().toISOString(),
deletedBy: 'user123'
}
this.$emit('data:item-deleted', deleteInfo)
},
changeStatus() {
const statusInfo = {
from: this.previousStatus,
to: this.currentStatus,
timestamp: new Date().toISOString(),
changedBy: 'user123'
}
this.$emit('status:changed', statusInfo)
this.previousStatus = this.currentStatus
}
}
}
</script>
<style scoped>
.custom-event-component {
border: 1px solid #ddd;
padding: 20px;
margin: 10px;
border-radius: 8px;
background: white;
}
.user-actions,
.data-actions,
.status-actions {
margin: 15px 0;
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
}
.user-actions button,
.data-actions button {
margin: 5px;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background: white;
}
.user-actions button:hover,
.data-actions button:hover {
background: #f0f0f0;
}
.status-actions select {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 150px;
}
</style>
4.4 插槽(Slots)
基本插槽使用
<!-- BaseCard.vue -->
<template>
<div class="base-card" :class="cardClass">
<!-- 卡片头部插槽 -->
<header v-if="$slots.header" class="card-header">
<slot name="header"></slot>
</header>
<!-- 默认插槽(卡片内容) -->
<main class="card-content">
<slot>
<!-- 默认内容 -->
<p>这是默认内容,当没有提供插槽内容时显示</p>
</slot>
</main>
<!-- 卡片底部插槽 -->
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</footer>
<!-- 侧边栏插槽 -->
<aside v-if="$slots.sidebar" class="card-sidebar">
<slot name="sidebar"></slot>
</aside>
</div>
</template>
<script>
export default {
name: 'BaseCard',
props: {
type: {
type: String,
default: 'default',
validator: value => ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
},
shadow: {
type: Boolean,
default: true
}
},
computed: {
cardClass() {
return {
[`card-${this.type}`]: true,
'card-shadow': this.shadow,
'has-sidebar': !!this.$slots.sidebar
}
}
}
}
</script>
<style scoped>
.base-card {
border: 1px solid #ddd;
border-radius: 8px;
background: white;
overflow: hidden;
margin: 10px;
display: flex;
flex-direction: column;
}
.base-card.has-sidebar {
flex-direction: row;
}
.card-shadow {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.card-header {
background: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #dee2e6;
font-weight: bold;
}
.card-content {
padding: 20px;
flex: 1;
}
.card-footer {
background: #f8f9fa;
padding: 15px 20px;
border-top: 1px solid #dee2e6;
}
.card-sidebar {
background: #f1f3f4;
padding: 20px;
border-left: 1px solid #dee2e6;
min-width: 200px;
}
/* 卡片类型样式 */
.card-primary {
border-color: #007bff;
}
.card-primary .card-header {
background: #007bff;
color: white;
}
.card-success {
border-color: #28a745;
}
.card-success .card-header {
background: #28a745;
color: white;
}
.card-warning {
border-color: #ffc107;
}
.card-warning .card-header {
background: #ffc107;
color: #212529;
}
.card-danger {
border-color: #dc3545;
}
.card-danger .card-header {
background: #dc3545;
color: white;
}
</style>
<!-- 使用BaseCard组件 -->
<template>
<div class="card-examples">
<h2>插槽示例</h2>
<!-- 基本使用 -->
<BaseCard>
<p>这是卡片的主要内容</p>
<p>可以包含任何HTML内容</p>
</BaseCard>
<!-- 带头部和底部的卡片 -->
<BaseCard type="primary">
<template #header>
<h3>用户信息</h3>
</template>
<div class="user-profile">
<img src="https://via.placeholder.com/60" alt="用户头像" class="avatar">
<div class="user-details">
<h4>张三</h4>
<p>前端开发工程师</p>
<p>邮箱: zhangsan@example.com</p>
</div>
</div>
<template #footer>
<button class="btn btn-primary">编辑资料</button>
<button class="btn btn-secondary">发送消息</button>
</template>
</BaseCard>
<!-- 带侧边栏的卡片 -->
<BaseCard type="success">
<template #header>
<h3>项目详情</h3>
</template>
<div class="project-content">
<h4>Vue.js 学习项目</h4>
<p>这是一个用于学习Vue.js的示例项目,包含了组件系统、路由、状态管理等核心概念。</p>
<div class="project-stats">
<span class="stat">进度: 75%</span>
<span class="stat">团队成员: 5人</span>
<span class="stat">截止日期: 2024-03-15</span>
</div>
</div>
<template #sidebar>
<h5>项目工具</h5>
<ul class="tool-list">
<li>Vue 3</li>
<li>Vite</li>
<li>Vue Router</li>
<li>Pinia</li>
<li>Element Plus</li>
</ul>
<h5>团队成员</h5>
<ul class="member-list">
<li>张三 (PM)</li>
<li>李四 (前端)</li>
<li>王五 (后端)</li>
<li>赵六 (设计)</li>
<li>钱七 (测试)</li>
</ul>
</template>
<template #footer>
<button class="btn btn-success">查看详情</button>
<button class="btn btn-outline">加入项目</button>
</template>
</BaseCard>
<!-- 警告卡片 -->
<BaseCard type="warning">
<template #header>
<h3>⚠️ 重要提醒</h3>
</template>
<div class="warning-content">
<p>您的账户将在3天后到期,请及时续费以避免服务中断。</p>
<ul>
<li>当前套餐: 专业版</li>
<li>到期时间: 2024-02-15</li>
<li>剩余天数: 3天</li>
</ul>
</div>
<template #footer>
<button class="btn btn-warning">立即续费</button>
<button class="btn btn-outline">稍后提醒</button>
</template>
</BaseCard>
</div>
</template>
<script>
import BaseCard from './components/BaseCard.vue'
export default {
components: {
BaseCard
}
}
</script>
<style scoped>
.card-examples {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.user-profile {
display: flex;
align-items: center;
gap: 15px;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
}
.user-details h4 {
margin: 0 0 5px 0;
color: #333;
}
.user-details p {
margin: 2px 0;
color: #666;
font-size: 14px;
}
.project-stats {
margin-top: 15px;
}
.stat {
display: inline-block;
background: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
margin-right: 10px;
}
.tool-list,
.member-list {
list-style: none;
padding: 0;
margin: 10px 0;
}
.tool-list li,
.member-list li {
padding: 5px 0;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.tool-list li:last-child,
.member-list li:last-child {
border-bottom: none;
}
.warning-content ul {
margin: 15px 0;
padding-left: 20px;
}
.warning-content li {
margin: 5px 0;
}
/* 按钮样式 */
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
transition: all 0.2s;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #1e7e34;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
}
.btn-outline {
background: transparent;
border: 1px solid #ccc;
color: #333;
}
.btn-outline:hover {
background: #f8f9fa;
}
</style>
作用域插槽
<!-- DataTable.vue -->
<template>
<div class="data-table">
<!-- 表格头部 -->
<div class="table-header">
<slot name="header" :total="filteredData.length" :selected="selectedItems">
<h3>数据表格 ({{ filteredData.length }} 条记录)</h3>
</slot>
</div>
<!-- 搜索和过滤 -->
<div class="table-controls">
<slot name="controls" :search="search" :updateSearch="updateSearch">
<input
v-model="search"
placeholder="搜索..."
class="search-input"
>
</slot>
</div>
<!-- 表格内容 -->
<div class="table-content">
<table>
<thead>
<tr>
<th v-if="selectable">
<input
type="checkbox"
:checked="isAllSelected"
@change="toggleSelectAll"
>
</th>
<th v-for="column in columns" :key="column.key">
{{ column.title }}
</th>
<th v-if="$slots.actions">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in paginatedData" :key="getItemKey(item, index)">
<td v-if="selectable">
<input
type="checkbox"
:checked="isSelected(item)"
@change="toggleSelect(item)"
>
</td>
<td v-for="column in columns" :key="column.key">
<!-- 作用域插槽:允许自定义单元格内容 -->
<slot
:name="`cell-${column.key}`"
:item="item"
:value="getNestedValue(item, column.key)"
:index="index"
:column="column"
>
<!-- 默认单元格内容 -->
{{ formatCellValue(item, column) }}
</slot>
</td>
<td v-if="$slots.actions">
<!-- 操作列作用域插槽 -->
<slot
name="actions"
:item="item"
:index="index"
:edit="() => editItem(item)"
:delete="() => deleteItem(item)"
>
<button @click="editItem(item)">编辑</button>
<button @click="deleteItem(item)">删除</button>
</slot>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="table-pagination">
<slot
name="pagination"
:currentPage="currentPage"
:totalPages="totalPages"
:pageSize="pageSize"
:total="filteredData.length"
:goToPage="goToPage"
:changePageSize="changePageSize"
>
<div class="pagination-info">
显示 {{ startIndex + 1 }}-{{ endIndex }} 条,共 {{ filteredData.length }} 条
</div>
<div class="pagination-controls">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
>
上一页
</button>
<span>第 {{ currentPage }} 页,共 {{ totalPages }} 页</span>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
>
下一页
</button>
</div>
</slot>
</div>
</div>
</template>
<script>
export default {
name: 'DataTable',
props: {
data: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
},
selectable: {
type: Boolean,
default: false
},
pageSize: {
type: Number,
default: 10
},
searchFields: {
type: Array,
default: () => []
}
},
emits: ['selection-change', 'item-edit', 'item-delete'],
data() {
return {
search: '',
currentPage: 1,
selectedItems: []
}
},
computed: {
filteredData() {
if (!this.search) return this.data
const searchLower = this.search.toLowerCase()
return this.data.filter(item => {
if (this.searchFields.length > 0) {
return this.searchFields.some(field => {
const value = this.getNestedValue(item, field)
return String(value).toLowerCase().includes(searchLower)
})
} else {
return Object.values(item).some(value =>
String(value).toLowerCase().includes(searchLower)
)
}
})
},
totalPages() {
return Math.ceil(this.filteredData.length / this.pageSize)
},
startIndex() {
return (this.currentPage - 1) * this.pageSize
},
endIndex() {
return Math.min(this.startIndex + this.pageSize, this.filteredData.length)
},
paginatedData() {
return this.filteredData.slice(this.startIndex, this.endIndex)
},
isAllSelected() {
return this.paginatedData.length > 0 &&
this.paginatedData.every(item => this.isSelected(item))
}
},
methods: {
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => current?.[key], obj)
},
formatCellValue(item, column) {
const value = this.getNestedValue(item, column.key)
if (column.formatter && typeof column.formatter === 'function') {
return column.formatter(value, item)
}
return value
},
getItemKey(item, index) {
return item.id || item.key || index
},
isSelected(item) {
return this.selectedItems.some(selected =>
this.getItemKey(selected) === this.getItemKey(item)
)
},
toggleSelect(item) {
const index = this.selectedItems.findIndex(selected =>
this.getItemKey(selected) === this.getItemKey(item)
)
if (index > -1) {
this.selectedItems.splice(index, 1)
} else {
this.selectedItems.push(item)
}
this.$emit('selection-change', this.selectedItems)
},
toggleSelectAll() {
if (this.isAllSelected) {
// 取消选择当前页的所有项
this.paginatedData.forEach(item => {
const index = this.selectedItems.findIndex(selected =>
this.getItemKey(selected) === this.getItemKey(item)
)
if (index > -1) {
this.selectedItems.splice(index, 1)
}
})
} else {
// 选择当前页的所有项
this.paginatedData.forEach(item => {
if (!this.isSelected(item)) {
this.selectedItems.push(item)
}
})
}
this.$emit('selection-change', this.selectedItems)
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page
}
},
changePageSize(newSize) {
this.pageSize = newSize
this.currentPage = 1
},
updateSearch(newSearch) {
this.search = newSearch
this.currentPage = 1
},
editItem(item) {
this.$emit('item-edit', item)
},
deleteItem(item) {
this.$emit('item-delete', item)
}
}
}
</script>
<style scoped>
.data-table {
border: 1px solid #ddd;
border-radius: 8px;
background: white;
overflow: hidden;
}
.table-header {
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.table-controls {
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.search-input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
width: 300px;
}
.table-content {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
tr:hover {
background: #f8f9fa;
}
.table-pagination {
padding: 15px 20px;
background: #f8f9fa;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 10px;
}
.pagination-controls button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.pagination-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-controls button:hover:not(:disabled) {
background: #f8f9fa;
}
</style>
本章小结
本章我们深入学习了Vue.js的组件系统:
- 组件基础:组件定义、注册和data函数的重要性
- Props通信:父向子传递数据,包括类型检查和验证
- 自定义事件:子向父通信,事件发射和监听
- 插槽系统:内容分发,包括具名插槽和作用域插槽
下一章预告
下一章我们将学习Vue.js的高级组件特性,包括: - 动态组件和异步组件 - 组件生命周期 - 混入和组合式API - 高阶组件模式
练习题
基础练习
组件通信练习:
- 创建一个父子组件通信的示例
- 实现数据的双向传递
- 使用自定义事件处理用户操作
插槽练习:
- 创建一个模态框组件
- 使用具名插槽定义头部、内容和底部
- 实现作用域插槽传递数据
进阶练习
复杂组件练习:
- 创建一个可复用的表单组件
- 支持不同类型的表单字段
- 实现表单验证和提交功能
组件库练习:
- 设计一套基础UI组件
- 包括按钮、输入框、卡片等
- 确保组件的可复用性和一致性
提示:组件系统是Vue.js的核心,掌握好组件通信和插槽使用对构建复杂应用至关重要。