<返回目录     Powered by claud/xia兄

第6课: 组件基础

什么是组件?

组件是Vue应用的基本构建块,允许我们将UI拆分成独立、可复用的部分。每个组件都有自己的模板、逻辑和样式。

组件化开发的优势

定义组件

单文件组件(SFC)结构

Vue的单文件组件将模板、脚本和样式封装在一个.vue文件中,包含三个主要部分:

单文件组件(SFC)

<!-- MyButton.vue -->
<template>
  <button @click="count++" class="my-button">
    点击了 {{ count }} 次
  </button>
</template>

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

const count = ref(0)
</script>

<style scoped>
.my-button {
  background: #42b883;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.my-button:hover {
  background: #369870;
}
</style>

Options API写法

<!-- MyButton.vue -->
<template>
  <button @click="count++" class="my-button">
    点击了 {{ count }} 次
  </button>
</template>

<script>
export default {
  name: 'MyButton',
  data() {
    return {
      count: 0
    }
  }
}
</script>

<style scoped>
.my-button {
  background: #42b883;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>
Composition API vs Options API

使用组件

<!-- App.vue -->
<template>
  <div class="app">
    <h1>我的应用</h1>
    <div class="button-group">
      <MyButton />
      <MyButton />
      <MyButton />
    </div>
  </div>
</template>

<script setup>
import MyButton from './components/MyButton.vue'
</script>

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

.button-group {
  display: flex;
  gap: 10px;
  margin-top: 20px;
}
</style>
组件实例独立性

每个组件实例都维护自己的状态,互不影响。这是组件化开发的核心特性之一。

组件命名规范

Props传递数据

Props用于父组件向子组件传递数据。

Props的核心特性

Composition API写法

<!-- BlogPost.vue -->
<template>
  <div class="blog-post" :class="{ featured: isFeatured }">
    <h3>{{ title }}</h3>
    <p class="content">{{ content }}</p>
    <div class="meta">
      <span class="author">作者: {{ author }}</span>
      <span class="date">发布时间: {{ publishDate }}</span>
    </div>
    <div v-if="tags.length" class="tags">
      <span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
    </div>
  </div>
</template>

<script setup>
// 定义props
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  content: {
    type: String,
    default: ''
  },
  author: {
    type: String,
    default: '匿名'
  },
  publishDate: {
    type: String,
    default: '未知'
  },
  tags: {
    type: Array,
    default: () => []
  },
  isFeatured: {
    type: Boolean,
    default: false
  }
})

// 在模板中可以直接使用props,无需props.前缀
</script>

<style scoped>
.blog-post {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 20px;
}

.blog-post.featured {
  border-color: #42b883;
  background-color: #f8fff8;
}

.content {
  line-height: 1.6;
  color: #666;
}

.meta {
  display: flex;
  justify-content: space-between;
  margin-top: 15px;
  font-size: 0.9em;
  color: #999;
}

.tags {
  margin-top: 10px;
}

.tag {
  display: inline-block;
  background: #f0f0f0;
  padding: 2px 8px;
  border-radius: 4px;
  margin-right: 5px;
  font-size: 0.8em;
}
</style>

使用Props

<!-- App.vue -->
<template>
  <div class="app">
    <h1>博客文章列表</h1>
    
    <!-- 静态props传递 -->
    <BlogPost
      title="Vue3学习笔记"
      content="Vue3是一个很棒的框架,提供了更好的性能和开发体验。"
      author="张三"
      publishDate="2024-01-15"
      :tags="['Vue3', '前端', 'JavaScript']"
      :isFeatured="true"
    />

    <!-- 动态props传递 -->
    <BlogPost
      v-for="post in posts"
      :key="post.id"
      :title="post.title"
      :content="post.content"
      :author="post.author"
      :publishDate="post.publishDate"
      :tags="post.tags"
      :isFeatured="post.isFeatured"
    />

    <!-- 使用对象展开语法 -->
    <BlogPost v-bind="newPost" />
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import BlogPost from './components/BlogPost.vue'

const posts = ref([
  { 
    id: 1, 
    title: '文章1', 
    content: '这是第一篇文章的内容', 
    author: '作者1',
    publishDate: '2024-01-10',
    tags: ['技术', '分享'],
    isFeatured: false
  },
  { 
    id: 2, 
    title: '文章2', 
    content: '这是第二篇文章的内容', 
    author: '作者2',
    publishDate: '2024-01-12',
    tags: ['Vue', '教程'],
    isFeatured: true
  }
])

const newPost = reactive({
  title: '新文章',
  content: '这是通过v-bind传递的新文章',
  author: '系统',
  publishDate: '2024-01-20',
  tags: ['新功能'],
  isFeatured: false
})
</script>

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

h1 {
  color: #333;
  text-align: center;
  margin-bottom: 30px;
}
</style>
Props传递方式总结

Props验证

Props验证的重要性

Props验证是Vue组件开发中的重要环节,它能够:

<script setup>
// 完整的props验证示例
defineProps({
  // 基础类型检查
  title: String,
  likes: Number,
  isPublished: Boolean,
  tags: Array,
  author: Object,
  callback: Function,

  // 多种可能的类型
  propA: [String, Number],

  // 必填的字符串
  propB: {
    type: String,
    required: true,
    validator: (value) => value.length > 0
  },

  // 带有默认值的数字
  propC: {
    type: Number,
    default: 100,
    validator: (value) => value >= 0 && value <= 100
  },

  // 带有默认值的对象(必须使用工厂函数)
  propD: {
    type: Object,
    default: () => ({ 
      message: 'hello',
      count: 0 
    })
  },

  // 带有默认值的数组(必须使用工厂函数)
  propE: {
    type: Array,
    default: () => []
  },

  // 自定义验证函数
  propF: {
    validator: (value) => {
      return ['success', 'warning', 'danger'].includes(value)
    }
  },

  // 复杂对象验证
  user: {
    type: Object,
    required: true,
    validator: (value) => {
      return value && typeof value === 'object' && 
             'name' in value && 'email' in value
    }
  }
})
</script>
Props验证最佳实践

触发事件(Emits)

子组件通过emit向父组件发送事件。

Emits的作用和优势

子组件

<!-- Counter.vue -->
<template>
  <div class="counter">
    <h3>计数器组件</h3>
    <div class="controls">
      <button @click="decrement" class="btn btn-decrement">-</button>
      <span class="count">{{ count }}</span>
      <button @click="increment" class="btn btn-increment">+</button>
    </div>
    <div class="actions">
      <button @click="reset" class="btn btn-reset">重置</button>
    </div>
  </div>
</template>

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

const count = ref(0)

// 定义emits(推荐使用对象语法进行验证)
const emit = defineEmits({
  // 无参数的简单事件
  increment: null,
  
  // 带参数的事件
  change: (payload) => {
    // 验证payload结构
    return typeof payload === 'object' && 
           'count' in payload && 
           'timestamp' in payload
  },
  
  // 带验证的事件
  reset: (value) => {
    return value === 0
  }
})

function increment() {
  count.value++
  emit('increment')
  emit('change', { 
    count: count.value, 
    timestamp: Date.now(),
    action: 'increment'
  })
}

function decrement() {
  if (count.value > 0) {
    count.value--
    emit('change', { 
      count: count.value, 
      timestamp: Date.now(),
      action: 'decrement'
    })
  }
}

function reset() {
  const oldValue = count.value
  count.value = 0
  emit('reset', oldValue)
  emit('change', { 
    count: count.value, 
    timestamp: Date.now(),
    action: 'reset',
    oldValue: oldValue
  })
}
</script>

<style scoped>
.counter {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 20px;
  text-align: center;
  max-width: 300px;
  margin: 0 auto;
}

.controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 15px;
  margin: 20px 0;
}

.count {
  font-size: 24px;
  font-weight: bold;
  min-width: 50px;
}

.btn {
  padding: 10px 15px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 18px;
}

.btn-increment {
  background: #42b883;
  color: white;
}

.btn-decrement {
  background: #f44336;
  color: white;
}

.btn-reset {
  background: #ff9800;
  color: white;
}

.actions {
  margin-top: 15px;
}
</style>

父组件

<!-- App.vue -->
<template>
  <div class="app">
    <h1>事件通信示例</h1>
    
    <Counter
      @increment="handleIncrement"
      @change="handleChange"
      @reset="handleReset"
    />
    
    <div class="status">
      <h3>父组件状态</h3>
      <p>当前计数: {{ parentCount }}</p>
      <p>最后操作: {{ lastAction }}</p>
      <p>操作时间: {{ lastTimestamp ? new Date(lastTimestamp).toLocaleString() : '暂无' }}</p>
    </div>
  </div>
</template>

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

const parentCount = ref(0)
const lastAction = ref('')
const lastTimestamp = ref(null)

function handleIncrement() {
  console.log('子组件触发了增加事件')
}

function handleChange(payload) {
  parentCount.value = payload.count
  lastAction.value = payload.action
  lastTimestamp.value = payload.timestamp
  
  console.log('计数变化:', payload)
}

function handleReset(oldValue) {
  console.log('计数器已重置,原值:', oldValue)
  lastAction.value = 'reset'
  lastTimestamp.value = Date.now()
}
</script>

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

.status {
  margin-top: 30px;
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}

h1 {
  text-align: center;
  color: #333;
}
</style>
Emits最佳实践

v-model组件

v-model的工作原理

v-model是Vue中实现双向数据绑定的语法糖,它实际上是以下代码的简写:

<CustomInput 
  :modelValue="message" 
  @update:modelValue="newValue => message = newValue" 
/>

在Vue 3中,v-model的默认prop名称是modelValue,默认事件名称是update:modelValue

基础v-model实现

<!-- CustomInput.vue -->
<template>
  <div class="custom-input">
    <label v-if="label">{{ label }}</label>
    <input
      :value="modelValue"
      @input="handleInput"
      :placeholder="placeholder"
      :type="type"
      class="input-field"
    >
    <span v-if="error" class="error-text">{{ error }}</span>
  </div>
</template>

<script setup>
const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: ''
  },
  label: String,
  placeholder: String,
  type: {
    type: String,
    default: 'text'
  },
  error: String
})

const emit = defineEmits(['update:modelValue'])

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

<style scoped>
.custom-input {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
  color: #333;
}

.input-field {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.input-field:focus {
  outline: none;
  border-color: #42b883;
}

.error-text {
  color: #f44336;
  font-size: 14px;
  margin-top: 5px;
}
</style>

使用v-model

<!-- App.vue -->
<template>
  <div class="app">
    <h1>自定义输入框组件</h1>
    
    <CustomInput 
      v-model="username"
      label="用户名"
      placeholder="请输入用户名"
      :error="usernameError"
    />
    
    <CustomInput 
      v-model="email"
      label="邮箱"
      placeholder="请输入邮箱地址"
      type="email"
      :error="emailError"
    />
    
    <CustomInput 
      v-model="password"
      label="密码"
      placeholder="请输入密码"
      type="password"
      :error="passwordError"
    />
    
    <div class="preview">
      <h3>预览数据</h3>
      <p>用户名: {{ username }}</p>
      <p>邮箱: {{ email }}</p>
      <p>密码: {{ password }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import CustomInput from './components/CustomInput.vue'

const username = ref('')
const email = ref('')
const password = ref('')

const usernameError = computed(() => {
  if (!username.value) return '用户名不能为空'
  if (username.value.length < 3) return '用户名至少3个字符'
  return ''
})

const emailError = computed(() => {
  if (!email.value) return '邮箱不能为空'
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!emailRegex.test(email.value)) return '请输入有效的邮箱地址'
  return ''
})

const passwordError = computed(() => {
  if (!password.value) return '密码不能为空'
  if (password.value.length < 6) return '密码至少6个字符'
  return ''
})
</script>

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

.preview {
  margin-top: 30px;
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}

h1 {
  text-align: center;
  color: #333;
}
</style>
v-model高级用法

组件注册

组件注册方式比较

注册方式 作用域 使用场景 优点 缺点
全局注册 整个应用 常用基础组件 随处可用,无需重复导入 增加打包体积,无法tree-shaking
局部注册 当前组件 特定功能组件 按需加载,tree-shaking友好 需要手动导入

全局注册

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import MyButton from './components/MyButton.vue'
import MyInput from './components/MyInput.vue'
import MyModal from './components/MyModal.vue'

const app = createApp(App)

// 全局注册常用组件
app.component('MyButton', MyButton)
app.component('MyInput', MyInput)
app.component('MyModal', MyModal)

// 也可以批量注册
const components = {
  MyButton,
  MyInput,
  MyModal
}

Object.entries(components).forEach(([name, component]) => {
  app.component(name, component)
})

app.mount('#app')

局部注册

<!-- App.vue -->
<template>
  <div>
    <Header />
    <MainContent />
    <Sidebar />
    <Footer />
  </div>
</template>

<script setup>
// 局部注册 - Composition API
import Header from './components/Header.vue'
import MainContent from './components/MainContent.vue'
import Sidebar from './components/Sidebar.vue'
import Footer from './components/Footer.vue'

// 导入后自动注册,可直接在模板中使用
</script>
<!-- Options API写法 -->
<script>
import Header from './components/Header.vue'
import MainContent from './components/MainContent.vue'
import Sidebar from './components/Sidebar.vue'
import Footer from './components/Footer.vue'

export default {
  name: 'App',
  components: {
    Header,
    MainContent,
    Sidebar,
    Footer
  }
}
</script>
注册策略建议

实战示例:完整的用户管理系统

项目目标

创建一个完整的用户管理系统,包含用户列表展示、用户卡片、搜索过滤等功能,展示组件化开发的实际应用。

用户卡片组件

<!-- UserCard.vue -->
<template>
  <div class="user-card" :class="{ featured: isFeatured }">
    <div class="avatar-section">
      <img :src="avatar" :alt="name" class="avatar">
      <span v-if="isOnline" class="online-indicator"></span>
    </div>
    
    <div class="info-section">
      <h3 class="name">{{ name }}</h3>
      <p class="bio">{{ bio }}</p>
      <div class="meta">
        <span class="role" :class="role">{{ roleText }}</span>
        <span class="join-date">加入时间: {{ joinDate }}</span>
      </div>
    </div>
    
    <div class="actions-section">
      <button 
        @click="handleFollow" 
        class="follow-btn"
        :class="{ following: isFollowing }"
      >
        {{ isFollowing ? '已关注' : '关注' }}
      </button>
      
      <button 
        @click="handleMessage" 
        class="message-btn"
      >
        发消息
      </button>
    </div>
  </div>
</template>

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

const props = defineProps({
  name: {
    type: String,
    required: true
  },
  avatar: {
    type: String,
    required: true
  },
  bio: {
    type: String,
    default: ''
  },
  role: {
    type: String,
    default: 'user',
    validator: (value) => ['user', 'admin', 'moderator'].includes(value)
  },
  joinDate: {
    type: String,
    default: '未知'
  },
  isOnline: {
    type: Boolean,
    default: false
  },
  isFeatured: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['follow', 'unfollow', 'message'])

const isFollowing = ref(false)

const roleText = computed(() => {
  const roleMap = {
    user: '普通用户',
    admin: '管理员',
    moderator: '版主'
  }
  return roleMap[props.role] || '用户'
})

function handleFollow() {
  isFollowing.value = !isFollowing.value
  emit(isFollowing.value ? 'follow' : 'unfollow', {
    name: props.name,
    timestamp: Date.now()
  })
}

function handleMessage() {
  emit('message', {
    to: props.name,
    timestamp: Date.now()
  })
}
</script>

<style scoped>
.user-card {
  border: 1px solid #e0e0e0;
  border-radius: 12px;
  padding: 20px;
  display: flex;
  align-items: center;
  gap: 15px;
  transition: all 0.3s ease;
  background: white;
}

.user-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  transform: translateY(-2px);
}

.user-card.featured {
  border-color: #42b883;
  background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
}

.avatar-section {
  position: relative;
}

.avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  object-fit: cover;
}

.online-indicator {
  position: absolute;
  bottom: 5px;
  right: 5px;
  width: 12px;
  height: 12px;
  background: #4caf50;
  border-radius: 50%;
  border: 2px solid white;
}

.info-section {
  flex: 1;
}

.name {
  margin: 0 0 8px 0;
  color: #333;
  font-size: 18px;
}

.bio {
  margin: 0 0 10px 0;
  color: #666;
  line-height: 1.4;
}

.meta {
  display: flex;
  gap: 10px;
  align-items: center;
}

.role {
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: bold;
}

.role.user {
  background: #e3f2fd;
  color: #1976d2;
}

.role.admin {
  background: #ffebee;
  color: #d32f2f;
}

.role.moderator {
  background: #f3e5f5;
  color: #7b1fa2;
}

.join-date {
  font-size: 12px;
  color: #999;
}

.actions-section {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.follow-btn, .message-btn {
  padding: 8px 16px;
  border: 1px solid #42b883;
  border-radius: 6px;
  background: white;
  color: #42b883;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.follow-btn.following {
  background: #42b883;
  color: white;
}

.follow-btn:hover {
  background: #42b883;
  color: white;
}

.message-btn {
  border-color: #666;
  color: #666;
}

.message-btn:hover {
  background: #666;
  color: white;
}
</style>

用户列表组件

<!-- UserList.vue -->
<template>
  <div class="user-list">
    <div class="filters">
      <input 
        v-model="searchQuery" 
        placeholder="搜索用户..." 
        class="search-input"
      >
      
      <select v-model="roleFilter" class="role-select">
        <option value="">所有角色</option>
        <option value="user">普通用户</option>
        <option value="admin">管理员</option>
        <option value="moderator">版主</option>
      </select>
      
      <label class="online-filter">
        <input type="checkbox" v-model="onlineOnly">
        仅显示在线用户
      </label>
    </div>
    
    <div class="users-grid">
      <UserCard
        v-for="user in filteredUsers"
        :key="user.id"
        v-bind="user"
        @follow="handleFollow"
        @unfollow="handleUnfollow"
        @message="handleMessage"
      />
    </div>
    
    <div v-if="filteredUsers.length === 0" class="empty-state">
      没有找到匹配的用户
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import UserCard from './UserCard.vue'

const props = defineProps({
  users: {
    type: Array,
    required: true,
    default: () => []
  }
})

const emit = defineEmits(['userAction'])

const searchQuery = ref('')
const roleFilter = ref('')
const onlineOnly = ref(false)

const filteredUsers = computed(() => {
  return props.users.filter(user => {
    const matchesSearch = user.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
                         user.bio.toLowerCase().includes(searchQuery.value.toLowerCase())
    
    const matchesRole = !roleFilter.value || user.role === roleFilter.value
    
    const matchesOnline = !onlineOnly.value || user.isOnline
    
    return matchesSearch && matchesRole && matchesOnline
  })
})

function handleFollow(payload) {
  emit('userAction', { type: 'follow', ...payload })
}

function handleUnfollow(payload) {
  emit('userAction', { type: 'unfollow', ...payload })
}

function handleMessage(payload) {
  emit('userAction', { type: 'message', ...payload })
}
</script>

<style scoped>
.user-list {
  max-width: 800px;
  margin: 0 auto;
}

.filters {
  display: flex;
  gap: 15px;
  margin-bottom: 20px;
  align-items: center;
  flex-wrap: wrap;
}

.search-input {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  flex: 1;
  min-width: 200px;
}

.role-select {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: white;
}

.online-filter {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 14px;
}

.users-grid {
  display: grid;
  gap: 20px;
}

.empty-state {
  text-align: center;
  padding: 40px;
  color: #999;
  font-size: 16px;
}
</style>

组件开发最佳实践

组件设计原则

练习与挑战

练习1: 创建可配置的按钮组件

创建一个按钮组件,支持以下功能:

  • 多种颜色主题(primary、secondary、danger等)
  • 不同尺寸(small、medium、large)
  • 加载状态显示
  • 禁用状态
  • 图标支持

练习2: 实现模态框组件

创建一个模态框组件,包含以下功能:

  • 支持v-model控制显示/隐藏
  • 自定义标题和内容
  • 支持自定义底部按钮
  • 点击遮罩层关闭
  • 动画效果

练习3: 构建表单组件库

创建一组表单组件:

  • 输入框(支持验证和错误提示)
  • 选择器
  • 单选框组
  • 复选框组
  • 表单验证器

本章总结

核心知识点回顾

学习建议