Vue3 组件基础详解

119 阅读9分钟

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>

总结

组件核心概念

  1. Props: 父组件向子组件传递数据
  2. Emits: 子组件向父组件传递事件
  3. Slots: 父组件向子组件传递内容
  4. Scoped Slots: 带数据的作用域插槽

组件通信方式

方式说明适用场景
Props父传子传递配置和数据
Emits子传父传递事件和数据
v-model双向绑定表单组件
Provide/Inject跨层级祖先组件向后代组件传递数据
EventBus全局事件非父子关系组件通信

最佳实践

  1. 命名规范:使用 PascalCase 或 kebab-case
  2. Props 验证:定义类型和验证器
  3. 样式隔离:使用 scoped 样式
  4. 性能优化:合理使用计算属性和 key
  5. 组件复用:设计通用、可配置的组件

记忆口诀

  • 组件就像积木:可以重复使用
  • Props 向下传:父组件给子组件数据
  • Emits 向上传:子组件告诉父组件事件
  • Slots 插内容:父组件在子组件中插入内容
  • 命名要规范:PascalCase 或 kebab-case
  • 样式要隔离:使用 scoped 避免污染

这样就能很好地掌握 Vue3 组件的基础用法了!