Vue3 组件完全指南:从零开始构建可复用UI

前言:为什么需要组件化开发?

想象一下,如果你要建造一栋房子,你是会选择一块砖一块砖地砌,还是选择使用预制的墙板、门窗模块?组件化开发就是前端世界的"预制模块",让我们能够:

  • 🚀 提高开发效率 - 一次编写,多次使用
  • 🔧 简化维护 - 修改一处,处处更新
  • 🎯 提升可读性 - 每个组件职责单一,易于理解
  • 📦 促进协作 - 不同开发者可以并行开发不同组件

第一章:什么是Vue组件?

组件就是Vue应用中的基本构建块,它包含:

  • 模板 (Template) - UI结构,类似HTML
  • 逻辑 (Script) - 数据处理和业务逻辑
  • 样式 (Style) - 组件外观
js 复制代码
<!-- 就像搭积木一样简单 -->
<template>
  <div class="building-block">
    <!-- 这里是组件的外观 -->
  </div>
</template>

<script setup>
// 这里是组件的"大脑"
</script>

<style scoped>
/* 这里是组件的"衣服" */
</style>

第二章:创建你的第一个组件

2.1 单文件组件(推荐方式)

js 复制代码
<!-- UserProfile.vue -->
<template>
  <!-- 组件的HTML结构 -->
  <div class="user-profile">
    <img :src="avatar" alt="用户头像" class="avatar">
    <h3>{{ name }}</h3>
    <p>{{ bio }}</p>
    <button @click="followUser" class="follow-btn">
      {{ isFollowing ? '已关注' : '关注' }}
    </button>
  </div>
</template>

<script setup>
// 导入Vue的响应式功能
import { ref } from 'vue'

// 定义组件内部的数据(状态)
const name = ref('前端小王子')
const bio = ref('热爱编程的前端开发者')
const avatar = ref('/default-avatar.jpg')
const isFollowing = ref(false)

// 定义组件的方法(行为)
const followUser = () => {
  isFollowing.value = !isFollowing.value
  console.log(isFollowing.value ? '关注成功' : '取消关注')
}
</script>

<style scoped>
/* scoped表示这些样式只在这个组件内生效 */
.user-profile {
  border: 1px solid #e1e1e1;
  border-radius: 8px;
  padding: 20px;
  text-align: center;
  max-width: 200px;
}

.avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  margin-bottom: 12px;
}

.follow-btn {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.follow-btn:hover {
  background-color: #0056b3;
}
</style>

2.2 如何使用这个组件?

js 复制代码
<!-- App.vue -->
<template>
  <div class="app">
    <h1>开发者社区</h1>
    
    <!-- 像使用HTML标签一样使用组件 -->
    <div class="user-list">
      <UserProfile />
      <UserProfile />
      <UserProfile />
    </div>
  </div>
</template>

<script setup>
// 1. 导入组件(就像导入JS模块一样)
import UserProfile from './components/UserProfile.vue'
// 注意:在<script setup>中,导入后自动注册,无需额外步骤
</script>

<style>
.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.user-list {
  display: flex;
  gap: 20px;
  flex-wrap: wrap;
}
</style>

💡 重要特性 :每个组件实例都是独立的!上面的三个UserProfile组件会各自维护自己的关注状态。

第三章:组件通信 - 让组件"说话"

3.1 Props:父组件向子组件传递数据

比喻:Props就像函数的参数,父组件通过Props向子组件传递配置信息。

js 复制代码
<!-- 子组件:ProductCard.vue -->
<template>
  <div class="product-card">
    <img :src="image" :alt="name" class="product-image">
    <h3>{{ name }}</h3>
    <p class="price">¥{{ price }}</p>
    <p class="description">{{ description }}</p>
    <button 
      @click="addToCart" 
      :disabled="!inStock"
      :class="{ 'disabled': !inStock }"
    >
      {{ inStock ? '加入购物车' : '缺货' }}
    </button>
  </div>
</template>

<script setup>
// 定义组件接收的Props(就像定义函数参数)
defineProps({
  // 基础类型检查
  name: {
    type: String,
    required: true  // 必须传递
  },
  price: {
    type: Number,
    required: true
  },
  // 带默认值的可选属性
  image: {
    type: String,
    default: '/default-product.jpg'
  },
  description: {
    type: String,
    default: '暂无描述'
  },
  inStock: {
    type: Boolean,
    default: true
  }
})

// 组件内部方法
const addToCart = () => {
  console.log('商品已添加到购物车')
  // 这里可以添加添加到购物车的逻辑
}
</script>

<style scoped>
.product-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;
  text-align: center;
}

.product-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 4px;
}

.price {
  color: #e4393c;
  font-size: 1.2em;
  font-weight: bold;
}

button.disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
</style>
js 复制代码
<!-- 父组件:ProductList.vue -->
<template>
  <div class="product-list">
    <h2>热门商品</h2>
    
    <!-- 使用子组件并传递Props -->
    <div class="products">
      <!-- 静态Props传递 -->
      <ProductCard 
        name="iPhone 15"
        :price="5999"
        image="/iphone15.jpg"
        description="最新款苹果手机"
        :in-stock="true"
      />
      
      <!-- 动态Props传递(从数据中获取) -->
      <ProductCard
        v-for="product in products"
        :key="product.id"
        :name="product.name"
        :price="product.price"
        :image="product.image"
        :description="product.description"
        :in-stock="product.inStock"
      />
    </div>
  </div>
</template>

<script setup>
import ProductCard from './ProductCard.vue'
import { ref } from 'vue'

// 模拟商品数据
const products = ref([
  {
    id: 1,
    name: 'MacBook Pro',
    price: 12999,
    image: '/macbook.jpg',
    description: '专业级笔记本电脑',
    inStock: true
  },
  {
    id: 2,
    name: 'AirPods Pro',
    price: 1899,
    image: '/airpods.jpg',
    description: '降噪无线耳机',
    inStock: false  // 缺货
  },
  {
    id: 3,
    name: 'iPad Air',
    price: 4399,
    image: '/ipad.jpg',
    description: '轻薄便携平板',
    inStock: true
  }
])
</script>

<style>
.products {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
  margin-top: 20px;
}
</style>

3.2 自定义事件:子组件向父组件发送消息

比喻:自定义事件就像子组件对父组件说:"嘿,我这边发生了某件事,你要处理一下吗?"

js 复制代码
<!-- 子组件:SearchBox.vue -->
<template>
  <div class="search-box">
    <!-- 搜索输入框 -->
    <input
      type="text"
      v-model="searchText"
      placeholder="输入关键词搜索..."
      @input="handleInput"
      @keyup.enter="handleSearch"
      class="search-input"
    >
    
    <!-- 搜索按钮 -->
    <button @click="handleSearch" class="search-btn">
      搜索
    </button>
    
    <!-- 清空按钮 -->
    <button 
      @click="handleClear" 
      v-if="searchText"
      class="clear-btn"
    >
      清空
    </button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 定义这个组件会发出哪些事件
const emit = defineEmits(['search', 'clear', 'input-change'])

// 搜索文本
const searchText = ref('')

// 输入处理
const handleInput = () => {
  // 发出输入变化事件,让父组件知道
  emit('input-change', searchText.value)
}

// 搜索处理
const handleSearch = () => {
  if (searchText.value.trim()) {
    // 发出搜索事件,并传递搜索关键词
    emit('search', searchText.value.trim())
  }
}

// 清空处理
const handleClear = () => {
  searchText.value = ''
  // 发出清空事件
  emit('clear')
}
</script>

<style scoped>
.search-box {
  display: flex;
  gap: 10px;
  margin: 20px 0;
}

.search-input {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.search-btn {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.clear-btn {
  background-color: #6c757d;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}
</style>
js 复制代码
<!-- 父组件:ProductList.vue(续接上面代码) -->
<template>
  <div class="product-list">
    <h2>热门商品</h2>
    
    <!-- 搜索组件 -->
    <SearchBox 
      @search="handleSearch"
      @clear="handleClear"
      @input-change="handleInputChange"
    />
    
    <!-- 显示搜索结果 -->
    <div v-if="searchResults.length > 0">
      <h3>搜索结果 ({{ searchResults.length }} 个商品)</h3>
      <div class="products">
        <ProductCard
          v-for="product in searchResults"
          :key="product.id"
          v-bind="product"
        />
      </div>
    </div>
    
    <!-- 显示所有商品 -->
    <div v-else>
      <h3>所有商品</h3>
      <div class="products">
        <ProductCard
          v-for="product in filteredProducts"
          :key="product.id"
          v-bind="product"
        />
      </div>
    </div>
  </div>
</template>

<script setup>
// ... 之前的导入和商品数据

import { ref, computed } from 'vue'

// 搜索相关状态
const searchKeyword = ref('')

// 处理搜索事件
const handleSearch = (keyword) => {
  searchKeyword.value = keyword
  console.log('搜索关键词:', keyword)
}

// 处理清空事件
const handleClear = () => {
  searchKeyword.value = ''
  console.log('搜索已清空')
}

// 处理输入变化
const handleInputChange = (text) => {
  console.log('输入内容:', text)
}

// 计算属性:根据搜索关键词过滤商品
const filteredProducts = computed(() => {
  if (!searchKeyword.value) return products.value
  
  return products.value.filter(product => 
    product.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
    product.description.toLowerCase().includes(searchKeyword.value.toLowerCase())
  )
})

// 搜索结果显示
const searchResults = computed(() => {
  return filteredProducts.value
})
</script>

第四章:插槽 - 组件的"占位符"

比喻:插槽就像在组件中预留的"空洞",让父组件可以自由填充内容。

js 复制代码
<!-- 子组件:Modal.vue -->
<template>
  <!-- 模态框遮罩层 -->
  <div class="modal-overlay" @click="handleClose">
    <!-- 模态框内容区域 -->
    <div class="modal-content" @click.stop>
      
      <!-- 头部插槽:如果没有提供内容,显示默认标题 -->
      <header class="modal-header">
        <slot name="header">
          <h2>默认标题</h2>
        </slot>
        <button @click="handleClose" class="close-btn">×</button>
      </header>
      
      <!-- 主体内容插槽(默认插槽) -->
      <main class="modal-body">
        <slot>
          <p>这里是默认内容</p>
        </slot>
      </main>
      
      <!-- 底部插槽 -->
      <footer class="modal-footer">
        <slot name="footer">
          <!-- 默认底部内容 -->
          <button @click="handleClose" class="btn-secondary">关闭</button>
        </slot>
      </footer>
    </div>
  </div>
</template>

<script setup>
// 定义事件
const emit = defineEmits(['close'])

// 关闭模态框
const handleClose = () => {
  emit('close')
}
</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;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  border-radius: 8px;
  width: 90%;
  max-width: 500px;
  max-height: 80vh;
  overflow-y: auto;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  border-bottom: 1px solid #e1e1e1;
}

.modal-body {
  padding: 20px;
}

.modal-footer {
  padding: 16px 20px;
  border-top: 1px solid #e1e1e1;
  text-align: right;
}

.close-btn {
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
  color: #666;
}

.btn-secondary {
  background-color: #6c757d;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}
</style>
js 复制代码
<!-- 父组件使用模态框 -->
<template>
  <div class="app">
    <button @click="showUserModal = true" class="btn-primary">
      用户信息模态框
    </button>
    
    <button @click="showConfirmModal = true" class="btn-primary">
      确认对话框
    </button>
    
    <!-- 用户信息模态框 -->
    <Modal v-if="showUserModal" @close="showUserModal = false">
      <!-- 插入到header插槽的内容 -->
      <template #header>
        <h2>用户详细信息</h2>
      </template>
      
      <!-- 插入到默认插槽的内容 -->
      <div class="user-detail">
        <img src="/avatar.jpg" alt="头像" class="detail-avatar">
        <div class="user-info">
          <p><strong>姓名:</strong>前端小王子</p>
          <p><strong>邮箱:</strong>web@example.com</p>
          <p><strong>注册时间:</strong>2023-01-01</p>
        </div>
      </div>
      
      <!-- 插入到footer插槽的内容 -->
      <template #footer>
        <button @click="showUserModal = false" class="btn-secondary">
          取消
        </button>
        <button @click="saveUser" class="btn-primary">
          保存更改
        </button>
      </template>
    </Modal>
    
    <!-- 确认对话框 -->
    <Modal v-if="showConfirmModal" @close="showConfirmModal = false">
      <template #header>
        <h2>确认操作</h2>
      </template>
      
      <p>您确定要执行此操作吗?此操作不可撤销。</p>
      
      <template #footer>
        <button @click="showConfirmModal = false" class="btn-secondary">
          取消
        </button>
        <button @click="confirmAction" class="btn-danger">
          确认删除
        </button>
      </template>
    </Modal>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import Modal from './components/Modal.vue'

const showUserModal = ref(false)
const showConfirmModal = ref(false)

const saveUser = () => {
  console.log('保存用户信息')
  showUserModal.value = false
}

const confirmAction = () => {
  console.log('执行确认操作')
  showConfirmModal.value = false
}
</script>

<style>
.btn-primary {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  margin: 5px;
}

.btn-danger {
  background-color: #dc3545;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.user-detail {
  display: flex;
  align-items: center;
  gap: 20px;
}

.detail-avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
}

.user-info {
  flex: 1;
}
</style>

第五章:动态组件 - 灵活的组件切换

应用场景:标签页、步骤向导、多状态界面等

js 复制代码
<!-- 动态组件示例:标签页系统 -->
<template>
  <div class="tab-system">
    <!-- 标签页导航 -->
    <nav class="tab-nav">
      <button
        v-for="tab in tabs"
        :key="tab.name"
        @click="currentTab = tab.name"
        :class="['tab-btn', { active: currentTab === tab.name }]"
      >
        {{ tab.label }}
      </button>
    </nav>
    
    <!-- 动态组件区域 -->
    <div class="tab-content">
      <!-- component标签是Vue的动态组件 -->
      <!-- :is指定要渲染的组件 -->
      <KeepAlive>
        <component :is="currentTabComponent" />
      </KeepAlive>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, shallowRef } from 'vue'

// 导入各个标签页组件
import UserProfile from './components/UserProfile.vue'
import ProductList from './components/ProductList.vue'
import SettingsPage from './components/SettingsPage.vue'

// 当前选中的标签
const currentTab = ref('profile')

// 标签页配置
const tabs = [
  { name: 'profile', label: '个人资料', component: UserProfile },
  { name: 'products', label: '商品列表', component: ProductList },
  { name: 'settings', label: '设置', component: SettingsPage }
]

// 计算当前应该显示的组件
const currentTabComponent = computed(() => {
  const tab = tabs.find(t => t.name === currentTab.value)
  return tab ? tab.component : UserProfile
})
</script>

<style scoped>
.tab-system {
  border: 1px solid #e1e1e1;
  border-radius: 8px;
  overflow: hidden;
}

.tab-nav {
  display: flex;
  background-color: #f8f9fa;
  border-bottom: 1px solid #e1e1e1;
}

.tab-btn {
  padding: 12px 24px;
  background: none;
  border: none;
  cursor: pointer;
  border-bottom: 2px solid transparent;
}

.tab-btn.active {
  background-color: white;
  border-bottom-color: #007bff;
  color: #007bff;
}

.tab-content {
  padding: 20px;
  min-height: 300px;
}
</style>

第六章:最佳实践和常见陷阱

6.1 Props设计最佳实践

js 复制代码
<script setup>
// ✅ 推荐:明确的Props定义
defineProps({
  // 必填字符串
  title: {
    type: String,
    required: true,
    validator: (value) => value.length > 0
  },
  
  // 可选数字,带默认值
  count: {
    type: Number,
    default: 0
  },
  
  // 复杂对象
  user: {
    type: Object,
    default: () => ({
      name: '匿名用户',
      age: 0
    })
  },
  
  // 自定义验证
  status: {
    type: String,
    validator: (value) => ['success', 'warning', 'error'].includes(value)
  }
})

// ❌ 避免:过于简单的Props定义
// defineProps(['title', 'count', 'user'])
</script>

6.2 组件命名规范

js 复制代码
<!-- ✅ 推荐:PascalCase命名 -->
<script setup>
// 组件文件名为:UserProfileCard.vue
</script>

<template>
  <!-- 在模板中使用 -->
  <UserProfileCard />
  <ProductListItem />
  <NavigationMenu />
</template>

6.3 避免直接修改Props

js 复制代码
<script setup>
const props = defineProps(['value'])

// ❌ 错误:直接修改Props
// const handleInput = (event) => {
//   props.value = event.target.value
// }

// ✅ 正确:通过事件通知父组件
const emit = defineEmits(['update:value'])

const handleInput = (event) => {
  emit('update:value', event.target.value)
}
</script>

实战项目:构建一个任务管理应用

js 复制代码
<!-- TodoApp.vue -->
<template>
  <div class="todo-app">
    <header class="app-header">
      <h1>任务管理器</h1>
      <TodoStats :todos="todos" />
    </header>
    
    <main class="app-main">
      <!-- 添加新任务 -->
      <TodoInput @add-todo="addTodo" />
      
      <!-- 任务筛选 -->
      <TodoFilter v-model="filter" />
      
      <!-- 任务列表 -->
      <TodoList 
        :todos="filteredTodos"
        @toggle-todo="toggleTodo"
        @delete-todo="deleteTodo"
        @edit-todo="editTodo"
      />
    </main>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import TodoStats from './components/TodoStats.vue'
import TodoInput from './components/TodoInput.vue'
import TodoFilter from './components/TodoFilter.vue'
import TodoList from './components/TodoList.vue'

// 任务数据
const todos = ref([
  { id: 1, text: '学习Vue3组件', completed: true },
  { id: 2, text: '写技术博客', completed: false },
  { id: 3, text: '项目代码重构', completed: false }
])

// 筛选条件
const filter = ref('all')

// 筛选后的任务
const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter(todo => !todo.completed)
    case 'completed':
      return todos.value.filter(todo => todo.completed)
    default:
      return todos.value
  }
})

// 添加任务
const addTodo = (text) => {
  todos.value.push({
    id: Date.now(),
    text: text.trim(),
    completed: false
  })
}

// 切换任务状态
const toggleTodo = (id) => {
  const todo = todos.value.find(todo => todo.id === id)
  if (todo) {
    todo.completed = !todo.completed
  }
}

// 删除任务
const deleteTodo = (id) => {
  todos.value = todos.value.filter(todo => todo.id !== id)
}

// 编辑任务
const editTodo = (id, newText) => {
  const todo = todos.value.find(todo => todo.id === id)
  if (todo) {
    todo.text = newText
  }
}
</script>

<style>
.todo-app {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.app-header {
  text-align: center;
  margin-bottom: 30px;
}

.app-main {
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
</style>

总结

通过本文的学习,你应该已经掌握了:

  1. 组件基础 - 什么是组件,如何创建和使用
  2. 组件通信 - Props向下传递,事件向上传递
  3. 内容分发 - 使用插槽创建灵活组件
  4. 动态组件 - 运行时切换不同组件
  5. 最佳实践 - 编写可维护的组件代码

记住组件设计的黄金法则:让每个组件只做好一件事,保持简单和专注。

现在,开始用组件化的思维来构建你的Vue应用吧!如果你在实战中遇到问题,欢迎在评论区留言讨论。

相关推荐
布列瑟农的星空3 小时前
CSS5中的级联层@layer
前端·css
Bella_a3 小时前
挑战100道前端面试题--Vue2和Vue3响应式原理的核心区别
vue.js
薄雾晚晴3 小时前
大屏开发实战:用 autofit.js 实现 1920*1080 设计稿完美自适应,告别分辨率变形
前端·javascript·vue.js
yannick_liu3 小时前
vue项目打包后,自动部署到服务器上面
前端
布列瑟农的星空3 小时前
升级一时爽,降级火葬场——tailwind4降级指北
前端·css
谁黑皮谁肘击谁在连累直升机3 小时前
for循环的了解与应用
前端·后端
不系舟同学3 小时前
Three.js + CSS3DSprite 首帧精灵图模糊问题排查、解决
前端
诚实可靠王大锤3 小时前
react-native集成PDF预览组件react-native-pdf
前端·react native·react.js·pdf
Hilaku3 小时前
前端的设计模式?我觉得90%都是在过度设计!
前端·javascript·设计模式