Skip to content

组件基础

组件允许我们将 UI 划分为独立、可复用的片段,并且可以对每个片段进行单独的思考。在实际应用中,组件常常被组织成层层嵌套的树状结构。

定义组件

单文件组件 (SFC)

当使用构建工具时,我们一般会将 Vue 组件定义在一个单独的 .vue 文件中,这被叫做单文件组件 (也被称为 *.vue 文件,英文 Single-File Components,缩写为 SFC)。

vue
<!-- ButtonCounter.vue -->
<template>
  <div class="button-counter">
    <h3>{{ title }}</h3>
    <button 
      @click="increment" 
      :class="buttonClass"
      :disabled="disabled"
    >
      点击了 {{ count }} 次
    </button>
    
    <div class="counter-info">
      <p>当前计数: <strong>{{ count }}</strong></p>
      <p>状态: <span :class="statusClass">{{ status }}</span></p>
      <p>最后点击: {{ lastClickTime || '从未点击' }}</p>
    </div>
    
    <div class="counter-controls">
      <button @click="reset" class="reset-btn" :disabled="count === 0">
        重置
      </button>
      <button @click="toggleDisabled" class="toggle-btn">
        {{ disabled ? '启用' : '禁用' }}
      </button>
    </div>
  </div>
</template>

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

// Props
const props = defineProps({
  title: {
    type: String,
    default: '计数器组件'
  },
  initialCount: {
    type: Number,
    default: 0
  },
  maxCount: {
    type: Number,
    default: 10
  }
})

// Emits
const emit = defineEmits(['update', 'reset', 'max-reached'])

// 响应式数据
const count = ref(props.initialCount)
const disabled = ref(false)
const lastClickTime = ref('')

// 计算属性
const buttonClass = computed(() => {
  return {
    'btn-primary': count.value < props.maxCount,
    'btn-warning': count.value >= props.maxCount,
    'btn-disabled': disabled.value
  }
})

const statusClass = computed(() => {
  return {
    'status-normal': count.value < props.maxCount,
    'status-max': count.value >= props.maxCount
  }
})

const status = computed(() => {
  if (disabled.value) return '已禁用'
  if (count.value >= props.maxCount) return '已达上限'
  return '正常'
})

// 方法
function increment() {
  if (disabled.value || count.value >= props.maxCount) return
  
  count.value++
  lastClickTime.value = new Date().toLocaleTimeString()
  
  // 发射事件
  emit('update', count.value)
  
  if (count.value >= props.maxCount) {
    emit('max-reached', count.value)
  }
}

function reset() {
  count.value = props.initialCount
  lastClickTime.value = ''
  emit('reset')
}

function toggleDisabled() {
  disabled.value = !disabled.value
}
</script>

<style scoped>
.button-counter {
  max-width: 400px;
  margin: 20px auto;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 12px;
  border: 1px solid #dee2e6;
  text-align: center;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.button-counter h3 {
  margin: 0 0 20px 0;
  color: #333;
  font-size: 20px;
}

.button-counter button {
  padding: 12px 24px;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.3s ease;
  margin: 5px;
}

.btn-primary {
  background-color: #007bff;
  color: white;
}

.btn-primary:hover:not(:disabled) {
  background-color: #0056b3;
  transform: translateY(-1px);
}

.btn-warning {
  background-color: #ffc107;
  color: #212529;
}

.btn-warning:hover:not(:disabled) {
  background-color: #e0a800;
}

.btn-disabled {
  background-color: #6c757d;
  color: white;
  cursor: not-allowed;
}

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

.reset-btn:hover:not(:disabled) {
  background-color: #c82333;
}

.toggle-btn {
  background-color: #28a745;
  color: white;
}

.toggle-btn:hover {
  background-color: #1e7e34;
}

button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  transform: none !important;
}

.counter-info {
  margin: 20px 0;
  padding: 15px;
  background-color: white;
  border-radius: 8px;
  border: 1px solid #dee2e6;
}

.counter-info p {
  margin: 8px 0;
  font-size: 14px;
}

.status-normal {
  color: #28a745;
  font-weight: bold;
}

.status-max {
  color: #dc3545;
  font-weight: bold;
}

.counter-controls {
  display: flex;
  justify-content: center;
  gap: 10px;
  margin-top: 15px;
}

@media (max-width: 480px) {
  .button-counter {
    margin: 10px;
    padding: 15px;
  }
  
  .counter-controls {
    flex-direction: column;
    align-items: center;
  }
  
  .counter-controls button {
    width: 100%;
    max-width: 200px;
  }
}
</style>

使用组件

vue
<template>
  <div class="component-demo">
    <h2>组件使用示例</h2>
    
    <div class="demo-section">
      <h3>基本使用</h3>
      <ButtonCounter />
    </div>
    
    <div class="demo-section">
      <h3>传递 Props</h3>
      <ButtonCounter 
        title="自定义计数器"
        :initial-count="5"
        :max-count="15"
        @update="handleUpdate"
        @reset="handleReset"
        @max-reached="handleMaxReached"
      />
    </div>
    
    <div class="demo-section">
      <h3>多个实例</h3>
      <div class="counters-grid">
        <ButtonCounter 
          v-for="counter in counters" 
          :key="counter.id"
          :title="counter.title"
          :initial-count="counter.initialCount"
          :max-count="counter.maxCount"
          @update="(value) => handleCounterUpdate(counter.id, value)"
        />
      </div>
      
      <div class="counters-summary">
        <h4>计数器统计</h4>
        <p>总计数器数量: {{ counters.length }}</p>
        <p>总点击次数: {{ totalClicks }}</p>
        <p>平均点击次数: {{ averageClicks }}</p>
      </div>
    </div>
    
    <div class="demo-section">
      <h3>动态组件</h3>
      <div class="component-switcher">
        <button 
          v-for="comp in componentTypes" 
          :key="comp.name"
          @click="currentComponent = comp.name"
          :class="{ active: currentComponent === comp.name }"
          class="switch-btn"
        >
          {{ comp.label }}
        </button>
      </div>
      
      <div class="dynamic-component-container">
        <component 
          :is="currentComponent" 
          v-bind="currentComponentProps"
          @update="handleDynamicUpdate"
        />
      </div>
    </div>
    
    <div class="demo-section">
      <h3>事件日志</h3>
      <div class="event-logs">
        <div class="log-controls">
          <button @click="clearLogs" class="clear-btn">
            清空日志
          </button>
          <span class="log-count">共 {{ eventLogs.length }} 条日志</span>
        </div>
        
        <div class="logs-container">
          <div 
            v-for="(log, index) in eventLogs" 
            :key="index"
            class="log-entry"
          >
            <span class="log-time">{{ log.time }}</span>
            <span class="log-event">{{ log.event }}</span>
            <span class="log-data">{{ log.data }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

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

// 组件数据
const counters = ref([
  { id: 1, title: '计数器 A', initialCount: 0, maxCount: 5, currentValue: 0 },
  { id: 2, title: '计数器 B', initialCount: 2, maxCount: 8, currentValue: 2 },
  { id: 3, title: '计数器 C', initialCount: 1, maxCount: 12, currentValue: 1 }
])

const eventLogs = ref([])

// 动态组件
const currentComponent = ref('ButtonCounter')
const componentTypes = [
  { name: 'ButtonCounter', label: '按钮计数器' },
  { name: 'SimpleCounter', label: '简单计数器' }
]

// 计算属性
const totalClicks = computed(() => {
  return counters.value.reduce((total, counter) => total + counter.currentValue, 0)
})

const averageClicks = computed(() => {
  if (counters.value.length === 0) return 0
  return (totalClicks.value / counters.value.length).toFixed(1)
})

const currentComponentProps = computed(() => {
  if (currentComponent.value === 'ButtonCounter') {
    return {
      title: '动态组件示例',
      initialCount: 0,
      maxCount: 20
    }
  }
  return {}
})

// 方法
function addLog(event, data = '') {
  eventLogs.value.unshift({
    time: new Date().toLocaleTimeString(),
    event,
    data: typeof data === 'object' ? JSON.stringify(data) : String(data)
  })
  
  // 限制日志数量
  if (eventLogs.value.length > 100) {
    eventLogs.value = eventLogs.value.slice(0, 100)
  }
}

function handleUpdate(value) {
  addLog('计数器更新', `新值: ${value}`)
}

function handleReset() {
  addLog('计数器重置')
}

function handleMaxReached(value) {
  addLog('达到最大值', `最大值: ${value}`)
}

function handleCounterUpdate(counterId, value) {
  const counter = counters.value.find(c => c.id === counterId)
  if (counter) {
    counter.currentValue = value
    addLog(`计数器 ${counter.title} 更新`, `新值: ${value}`)
  }
}

function handleDynamicUpdate(value) {
  addLog('动态组件更新', `组件: ${currentComponent.value}, 值: ${value}`)
}

function clearLogs() {
  eventLogs.value = []
  addLog('日志已清空')
}
</script>

<style scoped>
.component-demo {
  max-width: 1000px;
  margin: 0 auto;
  padding: 20px;
}

.demo-section {
  margin-bottom: 30px;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 12px;
  border: 1px solid #dee2e6;
}

.demo-section h3 {
  margin: 0 0 20px 0;
  color: #333;
  font-size: 18px;
  border-bottom: 2px solid #007bff;
  padding-bottom: 8px;
}

.counters-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
  gap: 20px;
  margin-bottom: 20px;
}

.counters-summary {
  padding: 15px;
  background-color: white;
  border-radius: 8px;
  border: 1px solid #dee2e6;
  text-align: center;
}

.counters-summary h4 {
  margin: 0 0 15px 0;
  color: #333;
}

.counters-summary p {
  margin: 8px 0;
  font-size: 16px;
  font-weight: 500;
}

.component-switcher {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
  justify-content: center;
}

.switch-btn {
  padding: 10px 20px;
  border: 2px solid #007bff;
  background-color: white;
  color: #007bff;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  transition: all 0.3s;
}

.switch-btn:hover {
  background-color: #f8f9fa;
}

.switch-btn.active {
  background-color: #007bff;
  color: white;
}

.dynamic-component-container {
  display: flex;
  justify-content: center;
}

.event-logs {
  background-color: white;
  border-radius: 8px;
  border: 1px solid #dee2e6;
  overflow: hidden;
}

.log-controls {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px;
  background-color: #f8f9fa;
  border-bottom: 1px solid #dee2e6;
}

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

.clear-btn:hover {
  background-color: #c82333;
}

.log-count {
  color: #6c757d;
  font-size: 14px;
}

.logs-container {
  max-height: 300px;
  overflow-y: auto;
  padding: 10px;
}

.log-entry {
  display: flex;
  padding: 8px;
  margin-bottom: 5px;
  background-color: #f8f9fa;
  border-radius: 4px;
  font-family: monospace;
  font-size: 13px;
}

.log-time {
  color: #6c757d;
  margin-right: 15px;
  min-width: 80px;
}

.log-event {
  color: #007bff;
  font-weight: bold;
  margin-right: 15px;
  min-width: 100px;
}

.log-data {
  color: #333;
  flex: 1;
}

@media (max-width: 768px) {
  .counters-grid {
    grid-template-columns: 1fr;
  }
  
  .component-switcher {
    flex-direction: column;
    align-items: center;
  }
  
  .log-controls {
    flex-direction: column;
    gap: 10px;
  }
  
  .log-entry {
    flex-direction: column;
    gap: 5px;
  }
  
  .log-time,
  .log-event {
    min-width: auto;
  }
}
</style>

Props

Props 是组件的自定义属性,用于从父组件向子组件传递数据。

Props 声明

javascript
// 字符串数组形式
defineProps(['title', 'likes', 'isPublished'])

// 对象形式(推荐)
defineProps({
  title: String,
  likes: Number,
  isPublished: Boolean,
  commentIds: Array,
  author: Object,
  callback: Function,
  contactsPromise: Promise
})

// 详细配置
defineProps({
  // 基础类型检查
  propA: Number,
  
  // 多种可能的类型
  propB: [String, Number],
  
  // 必传,且为 String 类型
  propC: {
    type: String,
    required: true
  },
  
  // Number 类型的默认值
  propD: {
    type: Number,
    default: 100
  },
  
  // 对象类型的默认值
  propE: {
    type: Object,
    default() {
      return { message: 'hello' }
    }
  },
  
  // 自定义验证函数
  propF: {
    validator(value) {
      return ['success', 'warning', 'danger'].includes(value)
    }
  },
  
  // 函数类型的默认值
  propG: {
    type: Function,
    default() {
      return 'Default function'
    }
  }
})

Props 使用示例

vue
<!-- UserCard.vue -->
<template>
  <div class="user-card" :class="cardClass">
    <div class="user-avatar">
      <img v-if="user.avatar" :src="user.avatar" :alt="user.name">
      <div v-else class="avatar-placeholder">
        {{ user.name.charAt(0).toUpperCase() }}
      </div>
    </div>
    
    <div class="user-info">
      <h3 class="user-name">{{ user.name }}</h3>
      <p class="user-email">{{ user.email }}</p>
      <p class="user-role" :class="roleClass">{{ user.role }}</p>
      
      <div class="user-stats">
        <span class="stat">
          <strong>{{ user.postsCount }}</strong> 文章
        </span>
        <span class="stat">
          <strong>{{ user.followersCount }}</strong> 关注者
        </span>
      </div>
    </div>
    
    <div class="user-actions">
      <button 
        v-if="showFollowButton" 
        @click="handleFollow"
        :class="followButtonClass"
        :disabled="isFollowing"
      >
        {{ followButtonText }}
      </button>
      
      <button 
        v-if="showMessageButton"
        @click="handleMessage"
        class="message-btn"
      >
        发消息
      </button>
    </div>
  </div>
</template>

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

// Props 定义
const props = defineProps({
  user: {
    type: Object,
    required: true,
    validator(value) {
      return value && value.name && value.email
    }
  },
  size: {
    type: String,
    default: 'medium',
    validator(value) {
      return ['small', 'medium', 'large'].includes(value)
    }
  },
  theme: {
    type: String,
    default: 'light',
    validator(value) {
      return ['light', 'dark'].includes(value)
    }
  },
  showFollowButton: {
    type: Boolean,
    default: true
  },
  showMessageButton: {
    type: Boolean,
    default: true
  },
  isFollowed: {
    type: Boolean,
    default: false
  }
})

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

// 响应式数据
const isFollowing = ref(false)

// 计算属性
const cardClass = computed(() => {
  return {
    [`user-card--${props.size}`]: true,
    [`user-card--${props.theme}`]: true
  }
})

const roleClass = computed(() => {
  const role = props.user.role?.toLowerCase()
  return {
    'role-admin': role === 'admin',
    'role-moderator': role === 'moderator',
    'role-user': role === 'user'
  }
})

const followButtonClass = computed(() => {
  return {
    'follow-btn': !props.isFollowed,
    'unfollow-btn': props.isFollowed,
    'following': isFollowing.value
  }
})

const followButtonText = computed(() => {
  if (isFollowing.value) return '处理中...'
  return props.isFollowed ? '取消关注' : '关注'
})

// 方法
function handleFollow() {
  isFollowing.value = true
  
  setTimeout(() => {
    if (props.isFollowed) {
      emit('unfollow', props.user.id)
    } else {
      emit('follow', props.user.id)
    }
    isFollowing.value = false
  }, 1000)
}

function handleMessage() {
  emit('message', props.user.id)
}
</script>

<style scoped>
.user-card {
  display: flex;
  padding: 20px;
  background-color: white;
  border-radius: 12px;
  border: 1px solid #e1e5e9;
  transition: all 0.3s ease;
  max-width: 400px;
}

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

.user-card--small {
  padding: 15px;
  max-width: 300px;
}

.user-card--large {
  padding: 25px;
  max-width: 500px;
}

.user-card--dark {
  background-color: #2d3748;
  border-color: #4a5568;
  color: white;
}

.user-avatar {
  margin-right: 15px;
  flex-shrink: 0;
}

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

.user-card--small .user-avatar img {
  width: 50px;
  height: 50px;
}

.user-card--large .user-avatar img {
  width: 80px;
  height: 80px;
}

.avatar-placeholder {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  background-color: #007bff;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  font-weight: bold;
}

.user-info {
  flex: 1;
  min-width: 0;
}

.user-name {
  margin: 0 0 5px 0;
  font-size: 18px;
  font-weight: 600;
  color: inherit;
}

.user-card--small .user-name {
  font-size: 16px;
}

.user-card--large .user-name {
  font-size: 20px;
}

.user-email {
  margin: 0 0 5px 0;
  color: #6c757d;
  font-size: 14px;
}

.user-card--dark .user-email {
  color: #a0aec0;
}

.user-role {
  margin: 0 0 10px 0;
  font-size: 12px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.role-admin {
  color: #dc3545;
}

.role-moderator {
  color: #ffc107;
}

.role-user {
  color: #28a745;
}

.user-stats {
  display: flex;
  gap: 15px;
  margin-bottom: 15px;
}

.stat {
  font-size: 14px;
  color: #6c757d;
}

.user-card--dark .stat {
  color: #a0aec0;
}

.user-actions {
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-self: flex-start;
}

.follow-btn,
.unfollow-btn,
.message-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.3s;
  white-space: nowrap;
}

.follow-btn {
  background-color: #007bff;
  color: white;
}

.follow-btn:hover:not(:disabled) {
  background-color: #0056b3;
}

.unfollow-btn {
  background-color: #6c757d;
  color: white;
}

.unfollow-btn:hover:not(:disabled) {
  background-color: #545b62;
}

.message-btn {
  background-color: #28a745;
  color: white;
}

.message-btn:hover {
  background-color: #1e7e34;
}

.following {
  background-color: #ffc107 !important;
  color: #212529 !important;
  cursor: not-allowed;
}

@media (max-width: 480px) {
  .user-card {
    flex-direction: column;
    text-align: center;
  }
  
  .user-avatar {
    margin: 0 auto 15px auto;
  }
  
  .user-actions {
    flex-direction: row;
    justify-content: center;
    margin-top: 15px;
  }
}
</style>

事件

组件可以触发自定义事件来与父组件通信。

触发和监听事件

vue
<!-- 子组件 -->
<template>
  <div class="custom-input">
    <label v-if="label" class="input-label">{{ label }}</label>
    <input
      :value="modelValue"
      @input="handleInput"
      @focus="handleFocus"
      @blur="handleBlur"
      :placeholder="placeholder"
      :disabled="disabled"
      class="input-field"
    >
    <span v-if="error" class="error-message">{{ error }}</span>
  </div>
</template>

<script setup>
// Props
const props = defineProps({
  modelValue: String,
  label: String,
  placeholder: String,
  disabled: Boolean,
  error: String
})

// 定义事件
const emit = defineEmits({
  'update:modelValue': (value) => typeof value === 'string',
  'focus': (event) => event instanceof Event,
  'blur': (event) => event instanceof Event,
  'change': (value) => typeof value === 'string'
})

// 事件处理
function handleInput(event) {
  const value = event.target.value
  emit('update:modelValue', value)
  emit('change', value)
}

function handleFocus(event) {
  emit('focus', event)
}

function handleBlur(event) {
  emit('blur', event)
}
</script>
vue
<!-- 父组件 -->
<template>
  <div>
    <CustomInput
      v-model="inputValue"
      label="用户名"
      placeholder="请输入用户名"
      :error="inputError"
      @focus="handleInputFocus"
      @blur="handleInputBlur"
      @change="handleInputChange"
    />
  </div>
</template>

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

const inputValue = ref('')
const inputError = ref('')

function handleInputFocus() {
  console.log('输入框获得焦点')
  inputError.value = ''
}

function handleInputBlur() {
  console.log('输入框失去焦点')
  validateInput()
}

function handleInputChange(value) {
  console.log('输入值变化:', value)
}

function validateInput() {
  if (!inputValue.value) {
    inputError.value = '用户名不能为空'
  } else if (inputValue.value.length < 3) {
    inputError.value = '用户名至少3个字符'
  }
}
</script>

插槽 (Slots)

插槽允许父组件向子组件传递模板内容。

基本插槽

vue
<!-- Card.vue -->
<template>
  <div class="card" :class="cardClass">
    <div v-if="$slots.header" class="card-header">
      <slot name="header"></slot>
    </div>
    
    <div class="card-body">
      <slot></slot>
    </div>
    
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  variant: {
    type: String,
    default: 'default',
    validator: (value) => ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
  }
})

const cardClass = computed(() => {
  return `card--${props.variant}`
})
</script>

作用域插槽

vue
<!-- DataList.vue -->
<template>
  <div class="data-list">
    <div class="list-header">
      <slot name="header" :total="items.length" :loading="loading">
        <h3>数据列表 ({{ items.length }} 项)</h3>
      </slot>
    </div>
    
    <div v-if="loading" class="loading">
      <slot name="loading">
        <p>加载中...</p>
      </slot>
    </div>
    
    <div v-else-if="items.length === 0" class="empty">
      <slot name="empty">
        <p>暂无数据</p>
      </slot>
    </div>
    
    <div v-else class="list-content">
      <div 
        v-for="(item, index) in items" 
        :key="item.id || index"
        class="list-item"
      >
        <slot 
          name="item" 
          :item="item" 
          :index="index" 
          :isFirst="index === 0"
          :isLast="index === items.length - 1"
        >
          <!-- 默认项模板 -->
          <div class="default-item">
            <h4>{{ item.title || item.name || `项目 ${index + 1}` }}</h4>
            <p>{{ item.description || item.content || '无描述' }}</p>
          </div>
        </slot>
      </div>
    </div>
    
    <div class="list-footer">
      <slot name="footer" :total="items.length" :hasMore="hasMore">
        <button v-if="hasMore" @click="loadMore" class="load-more-btn">
          加载更多
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  items: {
    type: Array,
    default: () => []
  },
  loading: {
    type: Boolean,
    default: false
  },
  hasMore: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['load-more'])

function loadMore() {
  emit('load-more')
}
</script>

组件最佳实践

1. 组件命名

javascript
// ✅ 推荐:使用 PascalCase
const MyComponent = defineComponent({})

// ✅ 推荐:多词组件名
const UserProfile = defineComponent({})
const ProductCard = defineComponent({})

// ❌ 避免:单词组件名
const User = defineComponent({})
const Card = defineComponent({})

2. Props 设计

javascript
// ✅ 推荐:详细的 props 定义
defineProps({
  user: {
    type: Object,
    required: true,
    validator: (value) => value && value.id
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  }
})

// ❌ 避免:过于简单的定义
defineProps(['user', 'size'])

3. 事件命名

javascript
// ✅ 推荐:使用 kebab-case
emit('user-updated', userData)
emit('item-selected', item)

// ❌ 避免:使用 camelCase
emit('userUpdated', userData)
emit('itemSelected', item)

4. 组件职责单一

vue
<!-- ✅ 推荐:职责单一的组件 -->
<template>
  <button :class="buttonClass" @click="handleClick">
    <slot></slot>
  </button>
</template>

<!-- ❌ 避免:职责过多的组件 -->
<template>
  <div>
    <form><!-- 表单逻辑 --></form>
    <table><!-- 表格逻辑 --></table>
    <chart><!-- 图表逻辑 --></chart>
  </div>
</template>

下一步

基于 Vue.js 官方文档构建的学习宝典