Vue3 组件基础详解
核心概念理解
什么是组件?
组件是 Vue 应用的基本构建块,它是一个可复用的 Vue 实例,具有预定义的选项。组件可以看作是自定义的 HTML 元素。
为什么需要组件?
- 复用性:相同的 UI 结构可以在多处使用
- 维护性:修改组件即可影响所有使用该组件的地方
- 可读性:将复杂界面拆分成小的、可管理的部分
- 团队协作:不同开发者可以独立开发不同组件
组件基础结构
1. 单文件组件 (.vue)
<!-- MyComponent.vue -->
<template>
<!-- 模板部分:定义组件的 HTML 结构 -->
<div class="my-component">
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<button @click="handleClick">点击我</button>
</div>
</template>
<script setup>
// 逻辑部分:定义组件的行为
import { ref } from 'vue'
const title = ref('我的组件')
const message = ref('这是一个基础组件示例')
const handleClick = () => {
alert('按钮被点击了!')
}
</script>
<style scoped>
/* 样式部分:定义组件的样式 */
.my-component {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #f9f9f9;
}
.my-component h2 {
color: #333;
margin-bottom: 10px;
}
.my-component p {
color: #666;
margin-bottom: 15px;
}
</style>
组件通信
1. Props - 父组件向子组件传递数据
<!-- ParentComponent.vue -->
<template>
<div class="parent-component">
<h2>父组件</h2>
<!-- 向子组件传递数据 -->
<ChildComponent
title="用户信息"
:user="currentUser"
:show-details="true"
@user-updated="handleUserUpdate"
/>
<div class="user-controls">
<button @click="updateUser">更新用户信息</button>
<button @click="resetUser">重置用户信息</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
// 父组件的数据
const currentUser = ref({
name: '张三',
age: 25,
email: 'zhangsan@example.com',
avatar: '👨'
})
// 更新用户信息
const updateUser = () => {
currentUser.value = {
name: '李四',
age: 30,
email: 'lisi@example.com',
avatar: '👩'
}
}
const resetUser = () => {
currentUser.value = {
name: '张三',
age: 25,
email: 'zhangsan@example.com',
avatar: '👨'
}
}
// 处理子组件发出的事件
const handleUserUpdate = (newUser) => {
console.log('收到子组件更新:', newUser)
currentUser.value = { ...newUser }
}
</script>
<style>
.parent-component {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f0f8ff;
border-radius: 10px;
}
.user-controls {
margin-top: 20px;
display: flex;
gap: 10px;
}
.user-controls button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
}
</style>
<!-- ChildComponent.vue -->
<template>
<div class="child-component">
<h3>{{ title }}</h3>
<div v-if="showDetails" class="user-card">
<div class="avatar">{{ user.avatar }}</div>
<div class="user-info">
<p><strong>姓名:</strong> {{ user.name }}</p>
<p><strong>年龄:</strong> {{ user.age }}</p>
<p><strong>邮箱:</strong> {{ user.email }}</p>
</div>
</div>
<div class="edit-section">
<input
v-model="editableUser.name"
placeholder="姓名"
class="edit-input"
>
<input
v-model.number="editableUser.age"
type="number"
placeholder="年龄"
class="edit-input"
>
<button @click="updateUser" class="update-btn">更新用户</button>
</div>
</div>
</template>
<script setup>
// 定义 props
defineProps({
title: {
type: String,
default: '默认标题'
},
user: {
type: Object,
required: true
},
showDetails: {
type: Boolean,
default: true
}
})
// 定义 emits
const emit = defineEmits(['userUpdated'])
import { ref, watch } from 'vue'
// 可编辑的用户数据
const editableUser = ref({
name: '',
age: 0,
email: '',
avatar: ''
})
// 监听 props 变化
watch(
() => props.user,
(newUser) => {
if (newUser) {
editableUser.value = { ...newUser }
}
},
{ immediate: true }
)
// 更新用户
const updateUser = () => {
emit('userUpdated', { ...editableUser.value })
}
</script>
<style scoped>
.child-component {
padding: 20px;
border: 2px solid #007bff;
border-radius: 8px;
background-color: white;
margin: 20px 0;
}
.user-card {
display: flex;
align-items: center;
padding: 15px;
background-color: #f8f9fa;
border-radius: 6px;
margin-bottom: 20px;
}
.avatar {
font-size: 48px;
margin-right: 15px;
}
.user-info p {
margin: 5px 0;
}
.edit-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.edit-input {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
.update-btn {
padding: 10px 16px;
border: none;
border-radius: 4px;
background-color: #28a745;
color: white;
cursor: pointer;
font-size: 14px;
}
</style>
2. Emits - 子组件向父组件传递数据
<!-- EventDemo.vue -->
<template>
<div class="event-demo">
<h2>组件事件通信</h2>
<!-- 基础事件 -->
<CounterComponent
@increment="handleIncrement"
@decrement="handleDecrement"
@reset="handleReset"
/>
<!-- 带参数的事件 -->
<TodoComponent
@add-todo="handleAddTodo"
@remove-todo="handleRemoveTodo"
@update-todo="handleUpdateTodo"
/>
<!-- 自定义事件 -->
<FormComponent
@form-submit="handleFormSubmit"
@form-validate="handleFormValidate"
/>
<div class="status-display">
<h3>状态显示</h3>
<p>计数器值: {{ counter }}</p>
<p>待办事项数: {{ todos.length }}</p>
<p v-if="lastFormSubmit">最后提交: {{ lastFormSubmit }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import CounterComponent from './CounterComponent.vue'
import TodoComponent from './TodoComponent.vue'
import FormComponent from './FormComponent.vue'
const counter = ref(0)
const todos = ref([])
const lastFormSubmit = ref('')
// 处理计数器事件
const handleIncrement = () => {
counter.value++
console.log('计数器增加')
}
const handleDecrement = () => {
counter.value--
console.log('计数器减少')
}
const handleReset = () => {
counter.value = 0
console.log('计数器重置')
}
// 处理待办事项事件
const handleAddTodo = (todo) => {
todos.value.push({
id: Date.now(),
...todo,
completed: false
})
console.log('添加待办事项:', todo)
}
const handleRemoveTodo = (id) => {
todos.value = todos.value.filter(todo => todo.id !== id)
console.log('删除待办事项:', id)
}
const handleUpdateTodo = (updatedTodo) => {
const index = todos.value.findIndex(todo => todo.id === updatedTodo.id)
if (index !== -1) {
todos.value[index] = { ...updatedTodo }
}
console.log('更新待办事项:', updatedTodo)
}
// 处理表单事件
const handleFormSubmit = (formData) => {
lastFormSubmit.value = `${formData.name} - ${new Date().toLocaleTimeString()}`
console.log('表单提交:', formData)
}
const handleFormValidate = (isValid) => {
console.log('表单验证状态:', isValid)
}
</script>
<style>
.event-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.status-display {
margin-top: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.status-display h3 {
margin-top: 0;
color: #495057;
}
</style>
<!-- CounterComponent.vue -->
<template>
<div class="counter-component">
<h3>计数器组件</h3>
<div class="counter-display">
<span class="count">{{ count }}</span>
</div>
<div class="counter-controls">
<button @click="increment" class="btn-primary">+</button>
<button @click="decrement" class="btn-secondary">-</button>
<button @click="reset" class="btn-danger">重置</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
// 定义事件
const emit = defineEmits(['increment', 'decrement', 'reset'])
const increment = () => {
count.value++
emit('increment')
}
const decrement = () => {
count.value--
emit('decrement')
}
const reset = () => {
count.value = 0
emit('reset')
}
</script>
<style scoped>
.counter-component {
padding: 20px;
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: white;
margin-bottom: 20px;
text-align: center;
}
.counter-display {
margin: 20px 0;
}
.count {
font-size: 48px;
font-weight: bold;
color: #007bff;
}
.counter-controls {
display: flex;
justify-content: center;
gap: 10px;
}
.counter-controls button {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
</style>
<!-- TodoComponent.vue -->
<template>
<div class="todo-component">
<h3>待办事项组件</h3>
<!-- 添加待办 -->
<div class="add-todo">
<input
v-model="newTodoText"
@keyup.enter="addTodo"
placeholder="输入待办事项"
class="todo-input"
>
<button @click="addTodo" class="add-btn">添加</button>
</div>
<!-- 待办列表 -->
<div class="todo-list">
<div
v-for="todo in todos"
:key="todo.id"
class="todo-item"
:class="{ completed: todo.completed }"
>
<input
type="checkbox"
v-model="todo.completed"
@change="updateTodo(todo)"
>
<span class="todo-text">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)" class="remove-btn">×</button>
</div>
</div>
<div class="todo-stats">
<span>总计: {{ todos.length }}</span>
<span>已完成: {{ completedCount }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const newTodoText = ref('')
const todos = ref([])
// 计算已完成数量
const completedCount = computed(() => {
return todos.value.filter(todo => todo.completed).length
})
// 定义事件
const emit = defineEmits(['addTodo', 'removeTodo', 'updateTodo'])
const addTodo = () => {
if (newTodoText.value.trim()) {
const newTodo = {
text: newTodoText.value.trim(),
completed: false
}
emit('addTodo', newTodo)
newTodoText.value = ''
}
}
const removeTodo = (id) => {
emit('removeTodo', id)
}
const updateTodo = (todo) => {
emit('updateTodo', { ...todo })
}
</script>
<style scoped>
.todo-component {
padding: 20px;
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: white;
margin-bottom: 20px;
}
.add-todo {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input {
flex: 1;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
}
.add-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
background-color: #28a745;
color: white;
cursor: pointer;
}
.todo-list {
margin-bottom: 15px;
}
.todo-item {
display: flex;
align-items: center;
padding: 10px;
margin: 8px 0;
background-color: #f8f9fa;
border-radius: 4px;
transition: all 0.2s ease;
}
.todo-item:hover {
background-color: #e9ecef;
}
.todo-item.completed {
opacity: 0.6;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
}
.todo-text {
flex: 1;
margin: 0 10px;
}
.remove-btn {
background-color: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.todo-stats {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #6c757d;
}
</style>
<!-- FormComponent.vue -->
<template>
<div class="form-component">
<h3>表单组件</h3>
<form @submit.prevent="handleSubmit" class="demo-form">
<div class="form-group">
<label>姓名:</label>
<input
v-model="formData.name"
@input="validateForm"
type="text"
placeholder="请输入姓名"
class="form-input"
:class="{ error: errors.name }"
>
<span v-if="errors.name" class="error-message">{{ errors.name }}</span>
</div>
<div class="form-group">
<label>邮箱:</label>
<input
v-model="formData.email"
@input="validateForm"
type="email"
placeholder="请输入邮箱"
class="form-input"
:class="{ error: errors.email }"
>
<span v-if="errors.email" class="error-message">{{ errors.email }}</span>
</div>
<div class="form-group">
<label>年龄:</label>
<input
v-model.number="formData.age"
@input="validateForm"
type="number"
placeholder="请输入年龄"
class="form-input"
:class="{ error: errors.age }"
>
<span v-if="errors.age" class="error-message">{{ errors.age }}</span>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">提交</button>
<button @click="resetForm" type="button" class="reset-btn">重置</button>
</div>
</form>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formData = reactive({
name: '',
email: '',
age: null
})
const errors = reactive({
name: '',
email: '',
age: ''
})
// 定义事件
const emit = defineEmits(['formSubmit', 'formValidate'])
// 表单验证
const validateForm = () => {
// 验证姓名
if (!formData.name.trim()) {
errors.name = '姓名不能为空'
} else if (formData.name.length < 2) {
errors.name = '姓名至少2个字符'
} else {
errors.name = ''
}
// 验证邮箱
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formData.email.trim()) {
errors.email = '邮箱不能为空'
} else if (!emailRegex.test(formData.email)) {
errors.email = '请输入有效的邮箱地址'
} else {
errors.email = ''
}
// 验证年龄
if (formData.age === null || formData.age === '') {
errors.age = '年龄不能为空'
} else if (formData.age < 0 || formData.age > 150) {
errors.age = '请输入有效的年龄'
} else {
errors.age = ''
}
// 发出验证状态事件
const isValid = !Object.values(errors).some(error => error)
emit('formValidate', isValid)
}
// 提交表单
const handleSubmit = () => {
validateForm()
const isValid = !Object.values(errors).some(error => error)
if (isValid) {
emit('formSubmit', { ...formData })
} else {
console.log('表单验证失败')
}
}
// 重置表单
const resetForm = () => {
formData.name = ''
formData.email = ''
formData.age = null
Object.keys(errors).forEach(key => {
errors[key] = ''
})
emit('formValidate', false)
}
</script>
<style scoped>
.form-component {
padding: 20px;
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: white;
}
.demo-form {
margin-top: 15px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #495057;
}
.form-input {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-input.error {
border-color: #dc3545;
}
.error-message {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
display: block;
}
.form-actions {
display: flex;
gap: 15px;
}
.submit-btn, .reset-btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.submit-btn {
background-color: #007bff;
color: white;
}
.submit-btn:hover {
background-color: #0056b3;
}
.reset-btn {
background-color: #6c757d;
color: white;
}
.reset-btn:hover {
background-color: #545b62;
}
</style>
组件插槽 (Slots)
1. 默认插槽和具名插槽
<!-- CardComponent.vue -->
<template>
<div class="card-component">
<!-- 头部插槽 -->
<div class="card-header">
<slot name="header">
<h3>默认标题</h3>
</slot>
</div>
<!-- 默认插槽 -->
<div class="card-body">
<slot>
<p>默认内容</p>
</slot>
</div>
<!-- 底部插槽 -->
<div class="card-footer">
<slot name="footer">
<p>默认底部</p>
</slot>
</div>
</div>
</template>
<script setup>
// Card 组件不需要额外的逻辑
</script>
<style scoped>
.card-component {
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: 20px 0;
}
.card-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.card-header h3 {
margin: 0;
color: #495057;
}
.card-body {
padding: 20px;
min-height: 100px;
}
.card-footer {
padding: 15px 20px;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
font-size: 14px;
color: #6c757d;
}
</style>
<!-- SlotDemo.vue -->
<template>
<div class="slot-demo">
<h2>组件插槽示例</h2>
<!-- 基础用法:只使用默认插槽 -->
<CardComponent>
<p>这是默认插槽的内容</p>
</CardComponent>
<!-- 使用具名插槽 -->
<CardComponent>
<template #header>
<h3>用户信息</h3>
</template>
<div class="user-content">
<p><strong>姓名:</strong> 张三</p>
<p><strong>年龄:</strong> 25岁</p>
<p><strong>职业:</strong> 软件工程师</p>
</div>
<template #footer>
<div class="user-actions">
<button class="action-btn">编辑</button>
<button class="action-btn">删除</button>
</div>
</template>
</CardComponent>
<!-- 复杂插槽内容 -->
<CardComponent>
<template #header>
<div class="product-header">
<h3>商品信息</h3>
<span class="product-price">¥299</span>
</div>
</template>
<div class="product-content">
<img src="https://via.placeholder.com/200x150" alt="商品图片" class="product-image">
<p class="product-description">这是一款高质量的商品,具有优秀的性能和设计。</p>
</div>
<template #footer>
<div class="product-footer">
<span class="rating">⭐⭐⭐⭐☆ 4.5</span>
<button class="buy-btn">立即购买</button>
</div>
</template>
</CardComponent>
</div>
</template>
<script setup>
import CardComponent from './CardComponent.vue'
</script>
<style>
.slot-demo {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.user-content p {
margin: 10px 0;
}
.user-actions {
display: flex;
gap: 10px;
}
.action-btn {
padding: 6px 12px;
border: 1px solid #007bff;
background-color: white;
color: #007bff;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.action-btn:hover {
background-color: #007bff;
color: white;
}
.product-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 20px;
font-weight: bold;
color: #28a745;
}
.product-image {
width: 100%;
max-width: 200px;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
.product-description {
color: #6c757d;
line-height: 1.6;
}
.product-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.rating {
color: #ffc107;
}
.buy-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #28a745;
color: white;
cursor: pointer;
font-size: 14px;
}
.buy-btn:hover {
background-color: #218838;
}
</style>
2. 作用域插槽 (Scoped Slots)
<!-- DataTable.vue -->
<template>
<div class="data-table">
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
{{ column.title }}
</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data" :key="item.id">
<td v-for="column in columns" :key="column.key">
<!-- 作用域插槽:向父组件传递数据 -->
<slot
:name="column.key"
:item="item"
:value="item[column.key]"
>
{{ item[column.key] }}
</slot>
</td>
<td>
<slot name="actions" :item="item">
<button @click="$emit('edit', item)" class="action-btn edit">编辑</button>
<button @click="$emit('delete', item)" class="action-btn delete">删除</button>
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
defineProps({
data: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
}
})
defineEmits(['edit', 'delete'])
</script>
<style scoped>
.data-table {
width: 100%;
border-collapse: collapse;
background-color: white;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.data-table th {
background-color: #f8f9fa;
font-weight: bold;
color: #495057;
}
.data-table tbody tr:hover {
background-color: #f8f9fa;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
margin: 0 2px;
}
.edit {
background-color: #007bff;
color: white;
}
.delete {
background-color: #dc3545;
color: white;
}
</style>
<!-- ScopedSlotDemo.vue -->
<template>
<div class="scoped-slot-demo">
<h2>作用域插槽示例</h2>
<DataTable
:data="users"
:columns="userColumns"
@edit="handleEdit"
@delete="handleDelete"
>
<!-- 自定义姓名列的显示 -->
<template #name="{ item, value }">
<div class="user-name-cell">
<span class="avatar">{{ item.avatar }}</span>
<span class="name">{{ value }}</span>
</div>
</template>
<!-- 自定义状态列的显示 -->
<template #status="{ value }">
<span :class="['status-badge', value]">
{{ value === 'active' ? '活跃' : '非活跃' }}
</span>
</template>
<!-- 自定义操作列 -->
<template #actions="{ item }">
<button @click="viewDetails(item)" class="action-btn view">查看</button>
<button @click="editUser(item)" class="action-btn edit">编辑</button>
<button @click="deleteUser(item)" class="action-btn delete">删除</button>
</template>
</DataTable>
<!-- 用户详情弹窗 -->
<div v-if="showDetail" class="modal-overlay" @click="showDetail = false">
<div class="modal-content" @click.stop>
<h3>用户详情</h3>
<div v-if="selectedUser" class="user-detail">
<p><strong>姓名:</strong> {{ selectedUser.name }}</p>
<p><strong>邮箱:</strong> {{ selectedUser.email }}</p>
<p><strong>年龄:</strong> {{ selectedUser.age }}</p>
<p><strong>状态:</strong>
<span :class="['status-badge', selectedUser.status]">
{{ selectedUser.status === 'active' ? '活跃' : '非活跃' }}
</span>
</p>
</div>
<button @click="showDetail = false" class="close-btn">关闭</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import DataTable from './DataTable.vue'
const users = ref([
{ id: 1, name: '张三', email: 'zhangsan@example.com', age: 25, status: 'active', avatar: '👨' },
{ id: 2, name: '李四', email: 'lisi@example.com', age: 30, status: 'inactive', avatar: '👩' },
{ id: 3, name: '王五', email: 'wangwu@example.com', age: 28, status: 'active', avatar: '👨' },
{ id: 4, name: '赵六', email: 'zhaoliu@example.com', age: 35, status: 'inactive', avatar: '👩' }
])
const userColumns = ref([
{ key: 'name', title: '姓名' },
{ key: 'email', title: '邮箱' },
{ key: 'age', title: '年龄' },
{ key: 'status', title: '状态' }
])
const showDetail = ref(false)
const selectedUser = ref(null)
const handleEdit = (user) => {
console.log('编辑用户:', user)
editUser(user)
}
const handleDelete = (user) => {
if (confirm(`确定要删除用户 ${user.name} 吗?`)) {
users.value = users.value.filter(u => u.id !== user.id)
}
}
const viewDetails = (user) => {
selectedUser.value = user
showDetail = true
}
const editUser = (user) => {
alert(`编辑用户: ${user.name}`)
}
const deleteUser = (user) => {
handleDelete(user)
}
</script>
<style>
.scoped-slot-demo {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.user-name-cell {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
font-size: 24px;
}
.name {
font-weight: bold;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.status-badge.active {
background-color: #d4edda;
color: #155724;
}
.status-badge.inactive {
background-color: #f8d7da;
color: #721c24;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 8px;
max-width: 400px;
width: 90%;
}
.modal-content h3 {
margin-top: 0;
color: #495057;
}
.user-detail p {
margin: 10px 0;
}
.close-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #6c757d;
color: white;
cursor: pointer;
margin-top: 15px;
}
.view {
background-color: #28a745;
color: white;
}
</style>
实际应用示例
1. 完整的用户管理组件
<!-- UserManagement.vue -->
<template>
<div class="user-management">
<h2>用户管理系统</h2>
<!-- 搜索和过滤 -->
<div class="search-section">
<input
v-model="searchTerm"
placeholder="搜索用户..."
class="search-input"
>
<select v-model="statusFilter" class="filter-select">
<option value="">所有状态</option>
<option value="active">活跃</option>
<option value="inactive">非活跃</option>
</select>
<button @click="showAddModal = true" class="add-btn">添加用户</button>
</div>
<!-- 用户列表 -->
<div class="user-list">
<UserCard
v-for="user in filteredUsers"
:key="user.id"
:user="user"
@edit="handleEditUser"
@delete="handleDeleteUser"
@toggle-status="handleToggleStatus"
/>
</div>
<!-- 添加/编辑用户模态框 -->
<UserModal
v-if="showAddModal || showEditModal"
:user="editingUser"
:is-edit="showEditModal"
@save="handleSaveUser"
@cancel="handleCancelModal"
/>
<!-- 删除确认对话框 -->
<div v-if="showDeleteConfirm" class="confirm-overlay" @click="showDeleteConfirm = false">
<div class="confirm-dialog" @click.stop>
<h3>确认删除</h3>
<p>确定要删除用户 "{{ userToDelete?.name }}" 吗?</p>
<div class="confirm-actions">
<button @click="confirmDelete" class="confirm-btn">确认</button>
<button @click="showDeleteConfirm = false" class="cancel-btn">取消</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive } from 'vue'
import UserCard from './UserCard.vue'
import UserModal from './UserModal.vue'
// 用户数据
const users = ref([
{ id: 1, name: '张三', email: 'zhangsan@example.com', age: 25, status: 'active', avatar: '👨', role: 'admin' },
{ id: 2, name: '李四', email: 'lisi@example.com', age: 30, status: 'inactive', avatar: '👩', role: 'user' },
{ id: 3, name: '王五', email: 'wangwu@example.com', age: 28, status: 'active', avatar: '👨', role: 'user' },
{ id: 4, name: '赵六', email: 'zhaoliu@example.com', age: 35, status: 'inactive', avatar: '👩', role: 'user' }
])
// 搜索和过滤
const searchTerm = ref('')
const statusFilter = ref('')
// 模态框状态
const showAddModal = ref(false)
const showEditModal = ref(false)
const showDeleteConfirm = ref(false)
// 当前操作的用户
const editingUser = ref(null)
const userToDelete = ref(null)
// 计算过滤后的用户
const filteredUsers = computed(() => {
let result = users.value
// 搜索过滤
if (searchTerm.value) {
const term = searchTerm.value.toLowerCase()
result = result.filter(user =>
user.name.toLowerCase().includes(term) ||
user.email.toLowerCase().includes(term)
)
}
// 状态过滤
if (statusFilter.value) {
result = result.filter(user => user.status === statusFilter.value)
}
return result
})
// 处理用户操作
const handleEditUser = (user) => {
editingUser.value = { ...user }
showEditModal.value = true
}
const handleDeleteUser = (user) => {
userToDelete.value = user
showDeleteConfirm.value = true
}
const handleToggleStatus = (user) => {
const index = users.value.findIndex(u => u.id === user.id)
if (index !== -1) {
users.value[index] = {
...user,
status: user.status === 'active' ? 'inactive' : 'active'
}
}
}
// 模态框操作
const handleSaveUser = (userData) => {
if (showEditModal.value) {
// 编辑用户
const index = users.value.findIndex(u => u.id === userData.id)
if (index !== -1) {
users.value[index] = { ...userData }
}
} else {
// 添加用户
const newUser = {
id: Date.now(),
...userData,
avatar: userData.name.charAt(0)
}
users.value.push(newUser)
}
handleCancelModal()
}
const handleCancelModal = () => {
showAddModal.value = false
showEditModal.value = false
editingUser.value = null
}
// 删除确认
const confirmDelete = () => {
if (userToDelete.value) {
users.value = users.value.filter(u => u.id !== userToDelete.value.id)
showDeleteConfirm.value = false
userToDelete.value = null
}
}
</script>
<style>
.user-management {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.search-section {
display: flex;
gap: 15px;
margin-bottom: 30px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
align-items: center;
flex-wrap: wrap;
}
.search-input, .filter-select {
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
}
.search-input {
flex: 1;
min-width: 200px;
}
.filter-select {
min-width: 120px;
}
.add-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
background-color: #28a745;
color: white;
cursor: pointer;
font-size: 16px;
white-space: nowrap;
}
.add-btn:hover {
background-color: #218838;
}
.user-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background-color: white;
padding: 25px;
border-radius: 8px;
max-width: 400px;
width: 90%;
text-align: center;
}
.confirm-dialog h3 {
margin-top: 0;
color: #495057;
}
.confirm-actions {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 20px;
}
.confirm-btn, .cancel-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.confirm-btn {
background-color: #dc3545;
color: white;
}
.cancel-btn {
background-color: #6c757d;
color: white;
}
</style>
<!-- UserCard.vue -->
<template>
<div class="user-card" :class="user.status">
<div class="card-header">
<div class="user-avatar">{{ user.avatar }}</div>
<div class="user-info">
<h3>{{ user.name }}</h3>
<p class="user-role">{{ user.role }}</p>
</div>
<div class="status-indicator" :class="user.status">
{{ user.status === 'active' ? '活跃' : '非活跃' }}
</div>
</div>
<div class="card-body">
<div class="user-details">
<p><strong>邮箱:</strong> {{ user.email }}</p>
<p><strong>年龄:</strong> {{ user.age }}</p>
</div>
</div>
<div class="card-footer">
<button @click="$emit('edit', user)" class="action-btn edit">编辑</button>
<button @click="$emit('toggleStatus', user)" class="action-btn toggle">
{{ user.status === 'active' ? '禁用' : '启用' }}
</button>
<button @click="$emit('delete', user)" class="action-btn delete">删除</button>
</div>
</div>
</template>
<script setup>
defineProps({
user: {
type: Object,
required: true
}
})
defineEmits(['edit', 'delete', 'toggleStatus'])
</script>
<style scoped>
.user-card {
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: all 0.2s ease;
}
.user-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.user-card.active {
border-color: #28a745;
}
.user-card.inactive {
opacity: 0.7;
}
.card-header {
display: flex;
align-items: center;
padding: 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.user-avatar {
font-size: 48px;
margin-right: 15px;
}
.user-info h3 {
margin: 0 0 5px 0;
color: #495057;
}
.user-role {
margin: 0;
color: #6c757d;
font-size: 14px;
}
.status-indicator {
margin-left: auto;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.status-indicator.active {
background-color: #d4edda;
color: #155724;
}
.status-indicator.inactive {
background-color: #f8d7da;
color: #721c24;
}
.card-body {
padding: 20px;
}
.user-details p {
margin: 8px 0;
color: #495057;
}
.card-footer {
display: flex;
padding: 15px 20px;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
gap: 10px;
}
.action-btn {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.edit {
background-color: #007bff;
color: white;
}
.toggle {
background-color: #ffc107;
color: #212529;
}
.delete {
background-color: #dc3545;
color: white;
}
.action-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
</style>
<!-- UserModal.vue -->
<template>
<div class="modal-overlay" @click="$emit('cancel')">
<div class="modal-content" @click.stop>
<h3>{{ isEdit ? '编辑用户' : '添加用户' }}</h3>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-group">
<label>姓名 *</label>
<input
v-model="formData.name"
type="text"
placeholder="请输入姓名"
class="form-input"
:class="{ error: errors.name }"
>
<span v-if="errors.name" class="error-message">{{ errors.name }}</span>
</div>
<div class="form-group">
<label>邮箱 *</label>
<input
v-model="formData.email"
type="email"
placeholder="请输入邮箱"
class="form-input"
:class="{ error: errors.email }"
>
<span v-if="errors.email" class="error-message">{{ errors.email }}</span>
</div>
<div class="form-group">
<label>年龄</label>
<input
v-model.number="formData.age"
type="number"
placeholder="请输入年龄"
class="form-input"
>
</div>
<div class="form-group">
<label>角色</label>
<select v-model="formData.role" class="form-select">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="save-btn">保存</button>
<button @click="$emit('cancel')" type="button" class="cancel-btn">取消</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
const props = defineProps({
user: {
type: Object,
default: null
},
isEdit: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['save', 'cancel'])
// 表单数据
const formData = reactive({
name: '',
email: '',
age: null,
role: 'user'
})
// 错误信息
const errors = reactive({
name: '',
email: ''
})
// 初始化表单数据
watch(
() => props.user,
(newUser) => {
if (newUser) {
Object.assign(formData, newUser)
} else {
Object.assign(formData, {
name: '',
email: '',
age: null,
role: 'user'
})
}
// 清空错误信息
Object.keys(errors).forEach(key => {
errors[key] = ''
})
},
{ immediate: true }
)
// 表单验证
const validateForm = () => {
let isValid = true
// 验证姓名
if (!formData.name.trim()) {
errors.name = '姓名不能为空'
isValid = false
} else if (formData.name.length < 2) {
errors.name = '姓名至少2个字符'
isValid = false
} else {
errors.name = ''
}
// 验证邮箱
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!formData.email.trim()) {
errors.email = '邮箱不能为空'
isValid = false
} else if (!emailRegex.test(formData.email)) {
errors.email = '请输入有效的邮箱地址'
isValid = false
} else {
errors.email = ''
}
return isValid
}
// 提交表单
const handleSubmit = () => {
if (validateForm()) {
emit('save', { ...formData })
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 25px;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-content h3 {
margin-top: 0;
color: #495057;
}
.user-form {
margin-top: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #495057;
}
.form-input, .form-select {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.2s ease;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-input.error {
border-color: #dc3545;
}
.error-message {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
display: block;
}
.form-actions {
display: flex;
gap: 15px;
margin-top: 30px;
}
.save-btn, .cancel-btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.save-btn {
background-color: #28a745;
color: white;
}
.save-btn:hover {
background-color: #218838;
}
.cancel-btn {
background-color: #6c757d;
color: white;
}
.cancel-btn:hover {
background-color: #545b62;
}
</style>
注意事项和最佳实践
1. 组件命名规范
<!-- 好的命名示例 -->
<template>
<div>
<!-- PascalCase 命名 -->
<UserProfileCard />
<DataTableComponent />
<SearchFormInput />
<!-- kebab-case 命名 -->
<user-profile-card />
<data-table-component />
<search-form-input />
</div>
</template>
<script setup>
// 导入时使用 PascalCase
import UserProfileCard from './UserProfileCard.vue'
import DataTableComponent from './DataTableComponent.vue'
import SearchFormInput from './SearchFormInput.vue'
</script>
2. Props 验证和默认值
<!-- PropsBestPractice.vue -->
<template>
<div class="props-demo">
<h3>{{ title }}</h3>
<p>用户: {{ user.name }} ({{ user.age }}岁)</p>
<p>显示详情: {{ showDetails ? '是' : '否' }}</p>
<p>标签: {{ tags.join(', ') }}</p>
</div>
</template>
<script setup>
// 详细的 props 定义
const props = defineProps({
// 基本类型验证
title: {
type: String,
required: true,
default: '默认标题'
},
// 对象类型验证
user: {
type: Object,
required: true,
validator: (value) => {
// 自定义验证函数
return value.name && typeof value.age === 'number'
}
},
// 布尔值类型
showDetails: {
type: Boolean,
default: false
},
// 数组类型
tags: {
type: Array,
default: () => [] // 注意:对象和数组的默认值必须使用函数返回
},
// 多种类型
size: {
type: [String, Number],
default: 'medium',
validator: (value) => {
if (typeof value === 'string') {
return ['small', 'medium', 'large'].includes(value)
}
return typeof value === 'number' && value > 0
}
}
})
</script>
3. 组件样式最佳实践
<!-- StyleBestPractice.vue -->
<template>
<div class="style-demo">
<!-- 使用 scoped 样式避免样式污染 -->
<div class="local-content">
<h3>局部样式内容</h3>
<p>这部分使用 scoped 样式</p>
</div>
<!-- 需要全局样式时使用深度选择器 -->
<div class="global-content">
<h3>需要全局样式的内容</h3>
<p>这部分可能需要影响子组件</p>
</div>
</div>
</template>
<script setup>
// 组件逻辑
</script>
<style scoped>
/* scoped 样式只影响当前组件 */
.style-demo {
padding: 20px;
border: 1px solid #ddd;
}
.local-content {
background-color: #f0f8ff;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
/* 深度选择器:影响子组件 */
:deep(.child-component) {
color: #007bff;
}
/* 插槽内容样式 */
:slotted(.slot-content) {
font-weight: bold;
}
</style>
<style>
/* 全局样式:谨慎使用 */
.global-style {
/* 这会影响整个应用 */
}
</style>
4. 性能优化建议
<!-- PerformanceTips.vue -->
<template>
<div class="performance-demo">
<!-- 使用 v-show 而不是 v-if 进行频繁切换 -->
<div v-show="isVisible" class="toggle-content">
<p>这个内容会频繁显示/隐藏</p>
</div>
<!-- 使用计算属性缓存复杂计算 -->
<div class="computed-demo">
<p>过滤用户数: {{ filteredUserCount }}</p>
<p>平均年龄: {{ averageAge }}</p>
</div>
<!-- 使用 key 强制重新渲染 -->
<div :key="componentKey" class="key-demo">
<p>组件 key: {{ componentKey }}</p>
<button @click="resetComponent">重置组件</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const isVisible = ref(true)
const componentKey = ref(0)
// 模拟大量用户数据
const users = ref(Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `用户${i}`,
age: Math.floor(Math.random() * 50) + 18,
active: Math.random() > 0.5
})))
// 使用计算属性缓存结果
const filteredUserCount = computed(() => {
console.log('计算过滤用户数')
return users.value.filter(user => user.active).length
})
const averageAge = computed(() => {
console.log('计算平均年龄')
const totalAge = users.value.reduce((sum, user) => sum + user.age, 0)
return (totalAge / users.value.length).toFixed(1)
})
const resetComponent = () => {
componentKey.value += 1
}
</script>
<style scoped>
.performance-demo {
padding: 20px;
}
.toggle-content {
padding: 15px;
background-color: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 4px;
margin-bottom: 20px;
}
.computed-demo {
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
margin-bottom: 20px;
}
.key-demo {
padding: 15px;
background-color: #cce7ff;
border-radius: 4px;
}
.key-demo button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
}
</style>
总结
组件核心概念
- Props: 父组件向子组件传递数据
- Emits: 子组件向父组件传递事件
- Slots: 父组件向子组件传递内容
- Scoped Slots: 带数据的作用域插槽
组件通信方式
| 方式 | 说明 | 适用场景 |
|---|---|---|
| Props | 父传子 | 传递配置和数据 |
| Emits | 子传父 | 传递事件和数据 |
| v-model | 双向绑定 | 表单组件 |
| Provide/Inject | 跨层级 | 祖先组件向后代组件传递数据 |
| EventBus | 全局事件 | 非父子关系组件通信 |
最佳实践
- 命名规范:使用 PascalCase 或 kebab-case
- Props 验证:定义类型和验证器
- 样式隔离:使用 scoped 样式
- 性能优化:合理使用计算属性和 key
- 组件复用:设计通用、可配置的组件
记忆口诀:
- 组件就像积木:可以重复使用
- Props 向下传:父组件给子组件数据
- Emits 向上传:子组件告诉父组件事件
- Slots 插内容:父组件在子组件中插入内容
- 命名要规范:PascalCase 或 kebab-case
- 样式要隔离:使用 scoped 避免污染
这样就能很好地掌握 Vue3 组件的基础用法了!