组件基础
组件允许我们将 UI 划分为独立、可复用的片段,并且可以对每个片段进行单独的思考。在实际应用中,组件常常被组织成层层嵌套的树状结构。
定义组件
单文件组件 (SFC)
当使用构建工具时,我们一般会将 Vue 组件定义在一个单独的 .vue
文件中,这被叫做单文件组件 (也被称为 *.vue
文件,英文 Single-File Components,缩写为 SFC)。
vue
<!-- ButtonCounter.vue -->
<template>
<div class="button-counter">
<h3>{{ title }}</h3>
<button
@click="increment"
:class="buttonClass"
:disabled="disabled"
>
点击了 {{ count }} 次
</button>
<div class="counter-info">
<p>当前计数: <strong>{{ count }}</strong></p>
<p>状态: <span :class="statusClass">{{ status }}</span></p>
<p>最后点击: {{ lastClickTime || '从未点击' }}</p>
</div>
<div class="counter-controls">
<button @click="reset" class="reset-btn" :disabled="count === 0">
重置
</button>
<button @click="toggleDisabled" class="toggle-btn">
{{ disabled ? '启用' : '禁用' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// Props
const props = defineProps({
title: {
type: String,
default: '计数器组件'
},
initialCount: {
type: Number,
default: 0
},
maxCount: {
type: Number,
default: 10
}
})
// Emits
const emit = defineEmits(['update', 'reset', 'max-reached'])
// 响应式数据
const count = ref(props.initialCount)
const disabled = ref(false)
const lastClickTime = ref('')
// 计算属性
const buttonClass = computed(() => {
return {
'btn-primary': count.value < props.maxCount,
'btn-warning': count.value >= props.maxCount,
'btn-disabled': disabled.value
}
})
const statusClass = computed(() => {
return {
'status-normal': count.value < props.maxCount,
'status-max': count.value >= props.maxCount
}
})
const status = computed(() => {
if (disabled.value) return '已禁用'
if (count.value >= props.maxCount) return '已达上限'
return '正常'
})
// 方法
function increment() {
if (disabled.value || count.value >= props.maxCount) return
count.value++
lastClickTime.value = new Date().toLocaleTimeString()
// 发射事件
emit('update', count.value)
if (count.value >= props.maxCount) {
emit('max-reached', count.value)
}
}
function reset() {
count.value = props.initialCount
lastClickTime.value = ''
emit('reset')
}
function toggleDisabled() {
disabled.value = !disabled.value
}
</script>
<style scoped>
.button-counter {
max-width: 400px;
margin: 20px auto;
padding: 20px;
background-color: #f8f9fa;
border-radius: 12px;
border: 1px solid #dee2e6;
text-align: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.button-counter h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 20px;
}
.button-counter button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin: 5px;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
transform: translateY(-1px);
}
.btn-warning {
background-color: #ffc107;
color: #212529;
}
.btn-warning:hover:not(:disabled) {
background-color: #e0a800;
}
.btn-disabled {
background-color: #6c757d;
color: white;
cursor: not-allowed;
}
.reset-btn {
background-color: #dc3545;
color: white;
}
.reset-btn:hover:not(:disabled) {
background-color: #c82333;
}
.toggle-btn {
background-color: #28a745;
color: white;
}
.toggle-btn:hover {
background-color: #1e7e34;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.counter-info {
margin: 20px 0;
padding: 15px;
background-color: white;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.counter-info p {
margin: 8px 0;
font-size: 14px;
}
.status-normal {
color: #28a745;
font-weight: bold;
}
.status-max {
color: #dc3545;
font-weight: bold;
}
.counter-controls {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 15px;
}
@media (max-width: 480px) {
.button-counter {
margin: 10px;
padding: 15px;
}
.counter-controls {
flex-direction: column;
align-items: center;
}
.counter-controls button {
width: 100%;
max-width: 200px;
}
}
</style>
使用组件
vue
<template>
<div class="component-demo">
<h2>组件使用示例</h2>
<div class="demo-section">
<h3>基本使用</h3>
<ButtonCounter />
</div>
<div class="demo-section">
<h3>传递 Props</h3>
<ButtonCounter
title="自定义计数器"
:initial-count="5"
:max-count="15"
@update="handleUpdate"
@reset="handleReset"
@max-reached="handleMaxReached"
/>
</div>
<div class="demo-section">
<h3>多个实例</h3>
<div class="counters-grid">
<ButtonCounter
v-for="counter in counters"
:key="counter.id"
:title="counter.title"
:initial-count="counter.initialCount"
:max-count="counter.maxCount"
@update="(value) => handleCounterUpdate(counter.id, value)"
/>
</div>
<div class="counters-summary">
<h4>计数器统计</h4>
<p>总计数器数量: {{ counters.length }}</p>
<p>总点击次数: {{ totalClicks }}</p>
<p>平均点击次数: {{ averageClicks }}</p>
</div>
</div>
<div class="demo-section">
<h3>动态组件</h3>
<div class="component-switcher">
<button
v-for="comp in componentTypes"
:key="comp.name"
@click="currentComponent = comp.name"
:class="{ active: currentComponent === comp.name }"
class="switch-btn"
>
{{ comp.label }}
</button>
</div>
<div class="dynamic-component-container">
<component
:is="currentComponent"
v-bind="currentComponentProps"
@update="handleDynamicUpdate"
/>
</div>
</div>
<div class="demo-section">
<h3>事件日志</h3>
<div class="event-logs">
<div class="log-controls">
<button @click="clearLogs" class="clear-btn">
清空日志
</button>
<span class="log-count">共 {{ eventLogs.length }} 条日志</span>
</div>
<div class="logs-container">
<div
v-for="(log, index) in eventLogs"
:key="index"
class="log-entry"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-event">{{ log.event }}</span>
<span class="log-data">{{ log.data }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import ButtonCounter from './ButtonCounter.vue'
// 组件数据
const counters = ref([
{ id: 1, title: '计数器 A', initialCount: 0, maxCount: 5, currentValue: 0 },
{ id: 2, title: '计数器 B', initialCount: 2, maxCount: 8, currentValue: 2 },
{ id: 3, title: '计数器 C', initialCount: 1, maxCount: 12, currentValue: 1 }
])
const eventLogs = ref([])
// 动态组件
const currentComponent = ref('ButtonCounter')
const componentTypes = [
{ name: 'ButtonCounter', label: '按钮计数器' },
{ name: 'SimpleCounter', label: '简单计数器' }
]
// 计算属性
const totalClicks = computed(() => {
return counters.value.reduce((total, counter) => total + counter.currentValue, 0)
})
const averageClicks = computed(() => {
if (counters.value.length === 0) return 0
return (totalClicks.value / counters.value.length).toFixed(1)
})
const currentComponentProps = computed(() => {
if (currentComponent.value === 'ButtonCounter') {
return {
title: '动态组件示例',
initialCount: 0,
maxCount: 20
}
}
return {}
})
// 方法
function addLog(event, data = '') {
eventLogs.value.unshift({
time: new Date().toLocaleTimeString(),
event,
data: typeof data === 'object' ? JSON.stringify(data) : String(data)
})
// 限制日志数量
if (eventLogs.value.length > 100) {
eventLogs.value = eventLogs.value.slice(0, 100)
}
}
function handleUpdate(value) {
addLog('计数器更新', `新值: ${value}`)
}
function handleReset() {
addLog('计数器重置')
}
function handleMaxReached(value) {
addLog('达到最大值', `最大值: ${value}`)
}
function handleCounterUpdate(counterId, value) {
const counter = counters.value.find(c => c.id === counterId)
if (counter) {
counter.currentValue = value
addLog(`计数器 ${counter.title} 更新`, `新值: ${value}`)
}
}
function handleDynamicUpdate(value) {
addLog('动态组件更新', `组件: ${currentComponent.value}, 值: ${value}`)
}
function clearLogs() {
eventLogs.value = []
addLog('日志已清空')
}
</script>
<style scoped>
.component-demo {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.demo-section {
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 12px;
border: 1px solid #dee2e6;
}
.demo-section h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 18px;
border-bottom: 2px solid #007bff;
padding-bottom: 8px;
}
.counters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.counters-summary {
padding: 15px;
background-color: white;
border-radius: 8px;
border: 1px solid #dee2e6;
text-align: center;
}
.counters-summary h4 {
margin: 0 0 15px 0;
color: #333;
}
.counters-summary p {
margin: 8px 0;
font-size: 16px;
font-weight: 500;
}
.component-switcher {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
}
.switch-btn {
padding: 10px 20px;
border: 2px solid #007bff;
background-color: white;
color: #007bff;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
.switch-btn:hover {
background-color: #f8f9fa;
}
.switch-btn.active {
background-color: #007bff;
color: white;
}
.dynamic-component-container {
display: flex;
justify-content: center;
}
.event-logs {
background-color: white;
border-radius: 8px;
border: 1px solid #dee2e6;
overflow: hidden;
}
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.clear-btn {
padding: 8px 16px;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.clear-btn:hover {
background-color: #c82333;
}
.log-count {
color: #6c757d;
font-size: 14px;
}
.logs-container {
max-height: 300px;
overflow-y: auto;
padding: 10px;
}
.log-entry {
display: flex;
padding: 8px;
margin-bottom: 5px;
background-color: #f8f9fa;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}
.log-time {
color: #6c757d;
margin-right: 15px;
min-width: 80px;
}
.log-event {
color: #007bff;
font-weight: bold;
margin-right: 15px;
min-width: 100px;
}
.log-data {
color: #333;
flex: 1;
}
@media (max-width: 768px) {
.counters-grid {
grid-template-columns: 1fr;
}
.component-switcher {
flex-direction: column;
align-items: center;
}
.log-controls {
flex-direction: column;
gap: 10px;
}
.log-entry {
flex-direction: column;
gap: 5px;
}
.log-time,
.log-event {
min-width: auto;
}
}
</style>
Props
Props 是组件的自定义属性,用于从父组件向子组件传递数据。
Props 声明
javascript
// 字符串数组形式
defineProps(['title', 'likes', 'isPublished'])
// 对象形式(推荐)
defineProps({
title: String,
likes: Number,
isPublished: Boolean,
commentIds: Array,
author: Object,
callback: Function,
contactsPromise: Promise
})
// 详细配置
defineProps({
// 基础类型检查
propA: Number,
// 多种可能的类型
propB: [String, Number],
// 必传,且为 String 类型
propC: {
type: String,
required: true
},
// Number 类型的默认值
propD: {
type: Number,
default: 100
},
// 对象类型的默认值
propE: {
type: Object,
default() {
return { message: 'hello' }
}
},
// 自定义验证函数
propF: {
validator(value) {
return ['success', 'warning', 'danger'].includes(value)
}
},
// 函数类型的默认值
propG: {
type: Function,
default() {
return 'Default function'
}
}
})
Props 使用示例
vue
<!-- UserCard.vue -->
<template>
<div class="user-card" :class="cardClass">
<div class="user-avatar">
<img v-if="user.avatar" :src="user.avatar" :alt="user.name">
<div v-else class="avatar-placeholder">
{{ user.name.charAt(0).toUpperCase() }}
</div>
</div>
<div class="user-info">
<h3 class="user-name">{{ user.name }}</h3>
<p class="user-email">{{ user.email }}</p>
<p class="user-role" :class="roleClass">{{ user.role }}</p>
<div class="user-stats">
<span class="stat">
<strong>{{ user.postsCount }}</strong> 文章
</span>
<span class="stat">
<strong>{{ user.followersCount }}</strong> 关注者
</span>
</div>
</div>
<div class="user-actions">
<button
v-if="showFollowButton"
@click="handleFollow"
:class="followButtonClass"
:disabled="isFollowing"
>
{{ followButtonText }}
</button>
<button
v-if="showMessageButton"
@click="handleMessage"
class="message-btn"
>
发消息
</button>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
// Props 定义
const props = defineProps({
user: {
type: Object,
required: true,
validator(value) {
return value && value.name && value.email
}
},
size: {
type: String,
default: 'medium',
validator(value) {
return ['small', 'medium', 'large'].includes(value)
}
},
theme: {
type: String,
default: 'light',
validator(value) {
return ['light', 'dark'].includes(value)
}
},
showFollowButton: {
type: Boolean,
default: true
},
showMessageButton: {
type: Boolean,
default: true
},
isFollowed: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['follow', 'unfollow', 'message'])
// 响应式数据
const isFollowing = ref(false)
// 计算属性
const cardClass = computed(() => {
return {
[`user-card--${props.size}`]: true,
[`user-card--${props.theme}`]: true
}
})
const roleClass = computed(() => {
const role = props.user.role?.toLowerCase()
return {
'role-admin': role === 'admin',
'role-moderator': role === 'moderator',
'role-user': role === 'user'
}
})
const followButtonClass = computed(() => {
return {
'follow-btn': !props.isFollowed,
'unfollow-btn': props.isFollowed,
'following': isFollowing.value
}
})
const followButtonText = computed(() => {
if (isFollowing.value) return '处理中...'
return props.isFollowed ? '取消关注' : '关注'
})
// 方法
function handleFollow() {
isFollowing.value = true
setTimeout(() => {
if (props.isFollowed) {
emit('unfollow', props.user.id)
} else {
emit('follow', props.user.id)
}
isFollowing.value = false
}, 1000)
}
function handleMessage() {
emit('message', props.user.id)
}
</script>
<style scoped>
.user-card {
display: flex;
padding: 20px;
background-color: white;
border-radius: 12px;
border: 1px solid #e1e5e9;
transition: all 0.3s ease;
max-width: 400px;
}
.user-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.user-card--small {
padding: 15px;
max-width: 300px;
}
.user-card--large {
padding: 25px;
max-width: 500px;
}
.user-card--dark {
background-color: #2d3748;
border-color: #4a5568;
color: white;
}
.user-avatar {
margin-right: 15px;
flex-shrink: 0;
}
.user-avatar img {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
}
.user-card--small .user-avatar img {
width: 50px;
height: 50px;
}
.user-card--large .user-avatar img {
width: 80px;
height: 80px;
}
.avatar-placeholder {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #007bff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
margin: 0 0 5px 0;
font-size: 18px;
font-weight: 600;
color: inherit;
}
.user-card--small .user-name {
font-size: 16px;
}
.user-card--large .user-name {
font-size: 20px;
}
.user-email {
margin: 0 0 5px 0;
color: #6c757d;
font-size: 14px;
}
.user-card--dark .user-email {
color: #a0aec0;
}
.user-role {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.role-admin {
color: #dc3545;
}
.role-moderator {
color: #ffc107;
}
.role-user {
color: #28a745;
}
.user-stats {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.stat {
font-size: 14px;
color: #6c757d;
}
.user-card--dark .stat {
color: #a0aec0;
}
.user-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-self: flex-start;
}
.follow-btn,
.unfollow-btn,
.message-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
white-space: nowrap;
}
.follow-btn {
background-color: #007bff;
color: white;
}
.follow-btn:hover:not(:disabled) {
background-color: #0056b3;
}
.unfollow-btn {
background-color: #6c757d;
color: white;
}
.unfollow-btn:hover:not(:disabled) {
background-color: #545b62;
}
.message-btn {
background-color: #28a745;
color: white;
}
.message-btn:hover {
background-color: #1e7e34;
}
.following {
background-color: #ffc107 !important;
color: #212529 !important;
cursor: not-allowed;
}
@media (max-width: 480px) {
.user-card {
flex-direction: column;
text-align: center;
}
.user-avatar {
margin: 0 auto 15px auto;
}
.user-actions {
flex-direction: row;
justify-content: center;
margin-top: 15px;
}
}
</style>
事件
组件可以触发自定义事件来与父组件通信。
触发和监听事件
vue
<!-- 子组件 -->
<template>
<div class="custom-input">
<label v-if="label" class="input-label">{{ label }}</label>
<input
:value="modelValue"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
:placeholder="placeholder"
:disabled="disabled"
class="input-field"
>
<span v-if="error" class="error-message">{{ error }}</span>
</div>
</template>
<script setup>
// Props
const props = defineProps({
modelValue: String,
label: String,
placeholder: String,
disabled: Boolean,
error: String
})
// 定义事件
const emit = defineEmits({
'update:modelValue': (value) => typeof value === 'string',
'focus': (event) => event instanceof Event,
'blur': (event) => event instanceof Event,
'change': (value) => typeof value === 'string'
})
// 事件处理
function handleInput(event) {
const value = event.target.value
emit('update:modelValue', value)
emit('change', value)
}
function handleFocus(event) {
emit('focus', event)
}
function handleBlur(event) {
emit('blur', event)
}
</script>
vue
<!-- 父组件 -->
<template>
<div>
<CustomInput
v-model="inputValue"
label="用户名"
placeholder="请输入用户名"
:error="inputError"
@focus="handleInputFocus"
@blur="handleInputBlur"
@change="handleInputChange"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const inputValue = ref('')
const inputError = ref('')
function handleInputFocus() {
console.log('输入框获得焦点')
inputError.value = ''
}
function handleInputBlur() {
console.log('输入框失去焦点')
validateInput()
}
function handleInputChange(value) {
console.log('输入值变化:', value)
}
function validateInput() {
if (!inputValue.value) {
inputError.value = '用户名不能为空'
} else if (inputValue.value.length < 3) {
inputError.value = '用户名至少3个字符'
}
}
</script>
插槽 (Slots)
插槽允许父组件向子组件传递模板内容。
基本插槽
vue
<!-- Card.vue -->
<template>
<div class="card" :class="cardClass">
<div v-if="$slots.header" class="card-header">
<slot name="header"></slot>
</div>
<div class="card-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup>
const props = defineProps({
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
}
})
const cardClass = computed(() => {
return `card--${props.variant}`
})
</script>
作用域插槽
vue
<!-- DataList.vue -->
<template>
<div class="data-list">
<div class="list-header">
<slot name="header" :total="items.length" :loading="loading">
<h3>数据列表 ({{ items.length }} 项)</h3>
</slot>
</div>
<div v-if="loading" class="loading">
<slot name="loading">
<p>加载中...</p>
</slot>
</div>
<div v-else-if="items.length === 0" class="empty">
<slot name="empty">
<p>暂无数据</p>
</slot>
</div>
<div v-else class="list-content">
<div
v-for="(item, index) in items"
:key="item.id || index"
class="list-item"
>
<slot
name="item"
:item="item"
:index="index"
:isFirst="index === 0"
:isLast="index === items.length - 1"
>
<!-- 默认项模板 -->
<div class="default-item">
<h4>{{ item.title || item.name || `项目 ${index + 1}` }}</h4>
<p>{{ item.description || item.content || '无描述' }}</p>
</div>
</slot>
</div>
</div>
<div class="list-footer">
<slot name="footer" :total="items.length" :hasMore="hasMore">
<button v-if="hasMore" @click="loadMore" class="load-more-btn">
加载更多
</button>
</slot>
</div>
</div>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
hasMore: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['load-more'])
function loadMore() {
emit('load-more')
}
</script>
组件最佳实践
1. 组件命名
javascript
// ✅ 推荐:使用 PascalCase
const MyComponent = defineComponent({})
// ✅ 推荐:多词组件名
const UserProfile = defineComponent({})
const ProductCard = defineComponent({})
// ❌ 避免:单词组件名
const User = defineComponent({})
const Card = defineComponent({})
2. Props 设计
javascript
// ✅ 推荐:详细的 props 定义
defineProps({
user: {
type: Object,
required: true,
validator: (value) => value && value.id
},
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
}
})
// ❌ 避免:过于简单的定义
defineProps(['user', 'size'])
3. 事件命名
javascript
// ✅ 推荐:使用 kebab-case
emit('user-updated', userData)
emit('item-selected', item)
// ❌ 避免:使用 camelCase
emit('userUpdated', userData)
emit('itemSelected', item)
4. 组件职责单一
vue
<!-- ✅ 推荐:职责单一的组件 -->
<template>
<button :class="buttonClass" @click="handleClick">
<slot></slot>
</button>
</template>
<!-- ❌ 避免:职责过多的组件 -->
<template>
<div>
<form><!-- 表单逻辑 --></form>
<table><!-- 表格逻辑 --></table>
<chart><!-- 图表逻辑 --></chart>
</div>
</template>