work hard work smart

专注于AI+Java后端开发。 不断总结,举一反三。
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

Vue 自定义组件完全指南:从零构建待办事项应用

本文通过从零构建一个「待办事项应用」,深入讲解 Vue 自定义组件的核心概念与最佳实践。所有示例代码均为原创设计,帮助你理解组件化开发的精髓。

目录


为什么需要自定义组件

在现代前端开发中,组件化思维是构建大型应用的基础。想象一下,你正在开发一个待办事项应用——界面包含标题栏、添加表单、任务列表、筛选器等多个部分。如果把所有逻辑写在一个文件里,代码会变得臃肿且难以维护。

自定义组件带来的好处:

优势 说明
可复用性 同一个组件可以在多个地方使用,减少重复代码
可维护性 每个组件职责单一,修改时影响范围可控
可测试性 小组件更容易编写单元测试
团队协作 不同开发者可以并行开发不同组件
代码可读性 组件名称本身就是文档,一目了然

以我们的待办事项应用为例,整个应用可以拆分为以下组件树:

App.vue (根组件 - 管理所有状态)
├── AppHeader.vue        -- 应用标题栏
├── TodoInput.vue        -- 待办输入表单
├── TodoFilter.vue       -- 筛选器(全部/进行中/已完成)
└── TodoList.vue         -- 待办列表容器
    └── TodoItem.vue     -- 单个待办项

这种层级结构让代码组织清晰,每个组件只关心自己的逻辑。


组件的基本结构

一个 Vue 单文件组件(SFC)由三个部分组成:

┌─────────────────────────────────────┐
│  <template>                         │
│    组件的 HTML 模板                   │
│    定义组件长什么样                     │
│  </template>                        │
├─────────────────────────────────────┤
│  <script setup>                     │
│    组件的 JavaScript/TypeScript 逻辑  │
│    定义组件的行为                      │
│  </script>                          │
├─────────────────────────────────────┤
│  <style scoped>                     │
│    组件的 CSS 样式                    │
│    scoped 确保样式只作用于当前组件       │
│  </style>                           │
└─────────────────────────────────────┘

<script setup> 是 Vue 3 引入的组合式 API 语法糖,它让代码更简洁:

  • 不需要 export default
  • 顶层的变量和函数自动暴露给模板
  • 不需要 components 选项注册组件

组件注册与引入

局部注册(推荐)

<script setup> 中,引入即注册:

// 1. 引入组件
import AppHeader from './components/AppHeader.vue'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'

// 2. 直接在模板中使用,无需额外注册

这种方式的好处是:

  • 按需加载,未使用的组件不会被打包
  • 作用域明确,不会污染全局
  • 代码可读性强,一眼看出组件来源

全局注册

如果你希望一个组件在所有地方都能使用,可以在应用入口全局注册:

// main.js 中的应用入口逻辑

import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'

const app = createApp(App)

// 全局注册组件,之后可以在任何地方使用 <BaseButton />
app.component('BaseButton', BaseButton)

app.mount('#app')

何时使用全局注册?

  • 极高频使用的基础组件(如按钮、输入框、图标)
  • UI 库的组件

何时使用局部注册?

  • 大部分业务组件
  • 只在特定页面使用的组件

💡 最佳实践:默认使用局部注册,只在确实需要时才全局注册。这有助于保持打包体积最小化。


Props:父传子的数据通道

Props 是父组件向子组件传递数据的主要方式。它遵循单向数据流原则——数据只能从父流向子,不能反向修改。

定义 Props

在子组件中,使用 defineProps 宏声明接收的 props:

// 子组件 TodoItem.vue 中

defineProps({
  todo: {
    type: Object,
    required: true    // 必填项
  },
  index: {
    type: Number,
    default: 0        // 默认值
  }
})

Prop 验证

Vue 提供了强大的 prop 类型验证:

选项 说明 示例
type 类型检查 String, Number, Boolean, Object, Array, Function
required 是否必填 true / false
default 默认值 基础类型直接写,引用类型用工厂函数
validator 自定义验证 (value) => ['active', 'done'].includes(value)

实际应用场景

在我们的待办应用中,Props 的使用非常典型:

TodoList 组件接收待办数组:

// 父组件 App.vue 传递数据
<TodoList
  :todos="filteredTodos"
  :loading="isLoading"
/>

// 子组件 TodoList.vue 接收数据
defineProps({
  todos: {
    type: Array,
    default: () => []   // 数组/对象类型必须用工厂函数
  },
  loading: {
    type: Boolean,
    default: false
  }
})

TodoItem 组件接收单个待办:

// 父组件 TodoList.vue 中遍历传递
<TodoItem
  v-for="todo in todos"
  :key="todo.id"
  :todo="todo"
/>

// 子组件 TodoItem.vue
defineProps({
  todo: {
    type: Object,
    required: true,
    validator: (value) => {
      // 自定义验证:确保 todo 对象包含必要字段
      return value.id && value.title !== undefined
    }
  }
})

⚠️ 注意:以 : 开头的 prop 表示传递的是 JavaScript 表达式,而不是字符串。

单向数据流

父组件                    子组件
┌──────────┐    Props    ┌──────────┐
│  todos   │ ──────────> │   todo   │
│          │             │ (只读)    │
└──────────┘             └──────────┘
     ↑                        │
     │      Emits/回调         │
     └────────────────────────┘

如果子组件需要修改 prop 的值,应该通过事件通知父组件,而不是直接修改。


Emits:子传父的事件机制

当子组件需要通知父组件某些事情发生时,使用 emits。这是 Vue 组件通信的核心机制之一。

定义 Emits

// 子组件 TodoInput.vue 中

const emit = defineEmits(['add'])

function handleSubmit() {
  const text = inputText.value.trim()
  if (!text) return

  // 触发事件,传递新待办的标题
  emit('add', text)
  inputText.value = ''
}

父组件监听事件

// 父组件 App.vue 中

<TodoInput @add="handleAddTodo" />

// 处理函数
function handleAddTodo(title) {
  todos.value.push({
    id: Date.now(),
    title: title,
    completed: false
  })
}

事件命名规范

风格 示例 说明
kebab-case @toggle-complete 推荐,与 HTML 属性风格一致
camelCase @toggleComplete 也可用,但在 DOM 模板中需要转换

实际应用:TodoItem 组件的多事件

// 子组件 TodoItem.vue
const emit = defineEmits(['toggle', 'delete', 'edit'])

// 切换完成状态
function handleToggle() {
  emit('toggle', props.todo.id)
}

// 删除待办
function handleDelete() {
  emit('delete', props.todo.id)
}

// 编辑待办(进入编辑模式)
function handleEdit() {
  emit('edit', props.todo.id)
}
// 父组件 TodoList.vue
<TodoItem
  v-for="todo in todos"
  :key="todo.id"
  :todo="todo"
  @toggle="handleToggle"
  @delete="handleDelete"
  @edit="handleEdit"
/>

带类型定义的 Emits(推荐)

为了更好地进行类型检查,可以定义 emits 的验证:

const emit = defineEmits({
  // 带验证函数
  add: (title) => {
    if (!title || title.trim() === '') {
      console.warn('Invalid add event payload!')
      return false
    }
    return true
  },
  // 不验证
  delete: null
})

模板语法实战

Vue 的模板语法强大而灵活,让我们看看实际项目中的常用技巧。

条件渲染

根据待办状态显示不同的 UI:

<!-- 根据完成状态显示不同图标 -->
<span v-if="todo.completed" class="icon-done">✓</span>
<span v-else class="icon-pending">○</span>

<!-- 空状态提示 -->
<div v-if="todos.length === 0" class="empty-state">
  <p>暂无待办事项</p>
  <p class="hint">添加一个新的待办吧</p>
</div>

<!-- 加载状态 -->
<div v-else-if="loading" class="loading">加载中...</div>

<!-- 正常列表 -->
<div v-else class="todo-list">
  <!-- 列表内容 -->
</div>

列表渲染

遍历待办数组,为每个待办创建组件实例:

<TodoItem
  v-for="(todo, index) in todos"
  :key="todo.id"
  :todo="todo"
  :index="index"
  @toggle="handleToggle"
  @delete="handleDelete"
/>

💡 关键点key 属性帮助 Vue 追踪每个节点的身份,确保列表更新时高效复用 DOM。永远使用唯一标识(如 id)作为 key,避免使用 index。

动态类绑定

根据状态动态切换 CSS 类:

<!-- 单个类 -->
<div :class="{ completed: todo.completed }">
  {{ todo.title }}
</div>

<!-- 多个类 -->
<div :class="{
  'todo-item': true,
  'is-completed': todo.completed,
  'is-overdue': isOverdue(todo.deadline)
}">

<!-- 数组语法 -->
<div :class="[baseClass, todo.completed ? 'completed' : '']">

<!-- 结合普通 class -->
<div class="todo-item" :class="{ completed: todo.completed }">

动态样式绑定

<!-- 对象语法 -->
<div :style="{ color: priorityColor(todo.priority) }">

<!-- 多个样式 -->
<div :style="{
  color: priorityColor(todo.priority),
  fontWeight: todo.important ? 'bold' : 'normal'
}">

事件处理

<!-- 基础事件 -->
<button @click="handleClick">点击</button>

<!-- 带参数 -->
<button @click="handleDelete(todo.id)">删除</button>

<!-- 事件修饰符 -->
<form @submit.prevent="handleSubmit">...</form>
<input @keydown.enter.exact="handleAdd" />

<!-- 阻止冒泡:点击删除按钮不触发父元素的点击 -->
<button @click.stop="handleDelete">删除</button>

<!-- 只触发一次 -->
<button @click.once="handleFirstClick">只触发一次</button>

双向绑定

<!-- 输入框双向绑定 -->
<input v-model="newTodoTitle" placeholder="添加新待办..." />

<!-- 复选框 -->
<input type="checkbox" v-model="todo.completed" />

<!-- 下拉选择 -->
<select v-model="selectedFilter">
  <option value="all">全部</option>
  <option value="active">进行中</option>
  <option value="completed">已完成</option>
</select>

计算属性与侦听器

计算属性(Computed)

计算属性基于响应式依赖进行缓存,只有依赖变化时才重新计算:

// 根据筛选条件过滤待办
const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter(t => !t.completed)
    case 'completed':
      return todos.value.filter(t => t.completed)
    default:
      return todos.value
  }
})

// 统计未完成数量
const activeCount = computed(() => {
  return todos.value.filter(t => !t.completed).length
})

// 统计已完成数量
const completedCount = computed(() => {
  return todos.value.filter(t => t.completed).length
})

// 是否所有待办都已完成
const allCompleted = computed(() => {
  return todos.value.length > 0 &&
         todos.value.every(t => t.completed)
})

计算属性 vs 方法:

特性 计算属性 方法
缓存 ✅ 有缓存,依赖不变不重算 ❌ 每次调用都执行
响应式 ✅ 自动追踪依赖 ❌ 需要手动调用
适用场景 数据转换、过滤、派生状态 复杂逻辑、副作用操作
性能 更好 相对较差

侦听器(Watch)

当需要在数据变化时执行副作用(如本地存储、API 请求)时使用:

// 监听待办变化,自动保存到本地存储
watch(
  todos,
  (newTodos) => {
    localStorage.setItem('todos', JSON.stringify(newTodos))
  },
  { deep: true }
)

// 监听筛选条件变化,记录日志
watch(filter, (newFilter, oldFilter) => {
  console.log(`筛选条件从 ${oldFilter} 变为 ${newFilter}`)
})

// 立即执行的侦听器
watch(
  todos,
  (newTodos) => {
    updateStatistics(newTodos)
  },
  { immediate: true }  // 初始化时立即执行一次
)

侦听器选项:

选项 说明
deep: true 深度监听对象/数组内部变化
immediate: true 立即执行一次
flush: 'post' 在 DOM 更新后执行

侦听器 vs 计算属性

// ❌ 不推荐:用侦听器做计算属性的事
watch(todos, (newTodos) => {
  activeCount.value = newTodos.filter(t => !t.completed).length
})

// ✅ 推荐:用计算属性
const activeCount = computed(() => {
  return todos.value.filter(t => !t.completed).length
})

💡 经验法则:如果目的是派生数据,用计算属性;如果目的是执行副作用,用侦听器。


生命周期钩子

每个 Vue 组件实例在创建时都会经历一系列初始化步骤。Vue 提供了生命周期钩子,让你在特定阶段执行代码。

常用生命周期钩子

┌─────────────────────────────────────────────────────────────┐
│                     组件生命周期                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  beforeCreate ──> created ──> beforeMount ──> mounted       │
│                                      │                      │
│                                      v                      │
│  beforeUnmount <── unmounted <── beforeUpdate <── updated   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
钩子 时机 常见用途
onMounted 组件挂载到 DOM 后 初始化第三方库、发起 API 请求、操作 DOM
onUpdated 数据变化导致 DOM 更新后 操作更新后的 DOM
onUnmounted 组件从 DOM 卸载后 清理定时器、取消订阅、释放资源
onBeforeMount 挂载开始前 最后一次修改数据的机会(不会触发更新)
onBeforeUnmount 卸载开始前 清理工作的最后时机

实际应用

// 组件挂载时从本地存储加载待办
onMounted(() => {
  const saved = localStorage.getItem('todos')
  if (saved) {
    todos.value = JSON.parse(saved)
  }
})

// 组件卸载时清理定时器
let timer = null

onMounted(() => {
  timer = setInterval(() => {
    checkReminders()
  }, 60000) // 每分钟检查一次
})

onUnmounted(() => {
  if (timer) {
    clearInterval(timer)
  }
})

生命周期与侦听器的选择

// ❌ 不推荐:在 mounted 中设置侦听
onMounted(() => {
  watch(todos, saveToStorage, { deep: true })
})

// ✅ 推荐:直接在 setup 中设置侦听器
watch(todos, saveToStorage, { deep: true })

// ✅ 推荐:mounted 只用于 DOM 操作
onMounted(() => {
  inputRef.value?.focus()
})

组件通信模式

1. 父传子:Props

最常用、最推荐的方式:

// 父组件
<TodoItem :todo="todoData" :index="0" />

// 子组件
defineProps({
  todo: { type: Object, required: true },
  index: { type: Number, default: 0 }
})

2. 子传父:Emits

子组件通知父组件执行操作:

// 子组件
const emit = defineEmits(['delete'])
emit('delete', todoId)

// 父组件
<TodoItem @delete="handleDelete" />

3. 双向绑定:v-model

简化父子组件的双向数据同步:

// 子组件 TodoInput.vue
const modelValue = defineModel()  // Vue 3.4+ 语法

// 或手动实现
defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])

function updateValue(newValue) {
  emit('update:modelValue', newValue)
}

// 父组件
<TodoInput v-model="newTodoText" />
<!-- 等价于 -->
<TodoInput
  :modelValue="newTodoText"
  @update:modelValue="newTodoText = $event"
/>

4. 祖先传后代:Provide/Inject

当组件层级很深时,避免逐层传递 props:

// 根组件 App.vue
import { provide } from 'vue'

// 提供主题配置,任何后代组件都可以获取
provide('theme', {
  primaryColor: '#42b883',
  darkMode: false
})

// 深层后代组件 DeepChild.vue
import { inject } from 'vue'

const theme = inject('theme', {
  // 默认值(当祖先没有 provide 时使用)
  primaryColor: '#000',
  darkMode: false
})

5. 模板引用:ref / defineExpose

直接访问子组件实例或 DOM 元素:

<!-- 父组件模板 -->
<TodoInput ref="todoInputRef" />

<!-- 父组件脚本 -->
import { ref, onMounted } from 'vue'

const todoInputRef = ref(null)

onMounted(() => {
  // 调用子组件暴露的方法
  todoInputRef.value.focus()
})

// 子组件 TodoInput.vue - 使用 defineExpose 暴露方法
const inputRef = ref(null)

function focus() {
  inputRef.value?.focus()
}

function clear() {
  inputText.value = ''
}

// 只暴露需要的方法给父组件
defineExpose({ focus, clear })

6. 跨组件通信:事件总线

适用于不相关组件之间的通信(谨慎使用):

// event-bus.js
import mitt from 'mitt'
export const emitter = mitt()

// 组件 A:发送事件
import { emitter } from './event-bus'
emitter.emit('todo-added', newTodo)

// 组件 B:监听事件
import { emitter } from './event-bus'
import { onMounted, onUnmounted } from 'vue'

onMounted(() => {
  emitter.on('todo-added', handleTodoAdded)
})

onUnmounted(() => {
  emitter.off('todo-added', handleTodoAdded)
})

⚠️ 注意:事件总线会使数据流难以追踪,优先使用 Props/Emits 或 Provide/Inject。


样式隔离与 Scoped CSS

Scoped 样式

<style> 标签上添加 scoped 属性,样式只会作用于当前组件:

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}

.todo-item.completed .title {
  text-decoration: line-through;
  color: #999;
}
</style>

Vue 会自动为选择器添加数据属性,确保样式隔离:

/* 编译前 */
.todo-item { ... }

/* 编译后 */
.todo-item[data-v-xxxx] { ... }

深度选择器

当需要影响子组件的样式时,使用 :deep() 伪类:

<style scoped>
/* 影响子组件的内部元素 */
:deep(.child-component .inner-element) {
  color: red;
}

/* 更具体的示例:自定义第三方组件样式 */
:deep(.el-input__wrapper) {
  border-radius: 8px;
}
</style>

插槽选择器

为插槽内容添加样式:

<style scoped>
/* 插槽内容的样式 */
:slotted(.custom-content) {
  font-weight: bold;
  color: #42b883;
}
</style>

全局样式

如果某些样式需要全局生效,可以使用单独的 <style> 标签(不带 scoped):

<!-- 组件级全局样式 -->
<style>
/* 这些样式会应用到整个应用 */
.text-primary { color: #42b883; }
.text-danger { color: #e74c3c; }
.btn { padding: 8px 16px; border-radius: 4px; }
</style>

<!-- 组件级局部样式 -->
<style scoped>
/* 这些样式只在当前组件生效 -->
.todo-item { background: white; }
</style>

CSS 变量

使用 CSS 变量实现主题切换:

<!-- App.vue -->
<style>
:root {
  --primary-color: #42b883;
  --bg-color: #ffffff;
  --text-color: #333333;
}

[data-theme="dark"] {
  --primary-color: #64d6a6;
  --bg-color: #1a1a1a;
  --text-color: #ffffff;
}
</style>

<style scoped>
.todo-app {
  background: var(--bg-color);
  color: var(--text-color);
}

.btn-primary {
  background: var(--primary-color);
}
</style>

实战:构建待办事项应用

让我们通过一个完整的待办事项应用案例,串联所有知识点。

项目结构

src/
├── components/
│   ├── App.vue              -- 根组件:状态管理
│   ├── AppHeader.vue        -- 应用标题栏
│   ├── TodoInput.vue        -- 待办输入表单
│   ├── TodoFilter.vue       -- 筛选器
│   ├── TodoList.vue         -- 待办列表
│   └── TodoItem.vue         -- 单个待办项
├── composables/
│   └── useTodos.js          -- 待办逻辑组合式函数
├── utils/
│   └── storage.js           -- 本地存储工具
├── styles/
│   └── main.css             -- 全局样式
└── main.js                  -- 入口文件

组件职责划分

组件 职责 接收的 Props 触发的 Emits
App 状态管理、协调各组件 - -
AppHeader 显示标题和统计信息 title, activeCount, completedCount -
TodoInput 待办输入、添加功能 placeholder add
TodoFilter 筛选状态切换 currentFilter update:filter
TodoList 待办列表展示 todos, loading toggle, delete, edit
TodoItem 单个待办的展示与交互 todo toggle, delete, edit

数据流向

用户操作
   │
   ▼
┌──────────┐    add      ┌──────────┐
│TodoInput │ ──────────> │   App    │
└──────────┘             │          │
                         │ 状态管理  │
┌──────────┐   todos     │          │
│ TodoList │ <────────── │          │
│          │             │          │
│ ┌──────┐ │             │          │
│ │TodoItem│ │           │          │
│ └──────┘ │             └──────────┘
└──────────┘
     │
     │ toggle/delete/edit
     ▼
  App 处理

核心实现要点

1. 状态提升

将共享状态放在最近的共同祖先组件中:

// App.vue 管理所有共享状态
const todos = ref([])           // 待办列表
const filter = ref('all')       // 当前筛选
const isLoading = ref(false)    // 加载状态

// 计算属性:根据筛选过滤
const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter(t => !t.completed)
    case 'completed':
      return todos.value.filter(t => t.completed)
    default:
      return todos.value
  }
})

2. 添加待办

// App.vue
function handleAddTodo(title) {
  todos.value.push({
    id: Date.now(),
    title: title,
    completed: false,
    createdAt: new Date().toISOString()
  })
}

// TodoInput.vue
const emit = defineEmits(['add'])
const inputText = ref('')

function handleSubmit() {
  const text = inputText.value.trim()
  if (!text) return

  emit('add', text)
  inputText.value = ''
}

3. 切换完成状态

// App.vue
function handleToggle(id) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) {
    todo.completed = !todo.completed
  }
}

// TodoItem.vue
const emit = defineEmits(['toggle'])

function handleToggle() {
  emit('toggle', props.todo.id)
}

4. 本地存储持久化

// App.vue
import { watch, onMounted } from 'vue'

// 初始化时加载
onMounted(() => {
  const saved = localStorage.getItem('todos')
  if (saved) {
    todos.value = JSON.parse(saved)
  }
})

// 变化时自动保存
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

5. 组合式函数(Composables)

将可复用的逻辑提取到独立函数:

// composables/useTodos.js
import { ref, computed, watch, onMounted } from 'vue'

export function useTodos() {
  const todos = ref([])
  const filter = ref('all')

  const filteredTodos = computed(() => {
    switch (filter.value) {
      case 'active':
        return todos.value.filter(t => !t.completed)
      case 'completed':
        return todos.value.filter(t => t.completed)
      default:
        return todos.value
    }
  })

  const activeCount = computed(() =>
    todos.value.filter(t => !t.completed).length
  )

  const completedCount = computed(() =>
    todos.value.filter(t => t.completed).length
  )

  function addTodo(title) {
    todos.value.push({
      id: Date.now(),
      title,
      completed: false,
      createdAt: new Date().toISOString()
    })
  }

  function toggleTodo(id) {
    const todo = todos.value.find(t => t.id === id)
    if (todo) todo.completed = !todo.completed
  }

  function deleteTodo(id) {
    todos.value = todos.value.filter(t => t.id !== id)
  }

  // 本地存储
  onMounted(() => {
    const saved = localStorage.getItem('todos')
    if (saved) todos.value = JSON.parse(saved)
  })

  watch(todos, (val) => {
    localStorage.setItem('todos', JSON.stringify(val))
  }, { deep: true })

  return {
    todos,
    filter,
    filteredTodos,
    activeCount,
    completedCount,
    addTodo,
    toggleTodo,
    deleteTodo
  }
}

// App.vue 中使用
import { useTodos } from './composables/useTodos'

const {
  todos,
  filter,
  filteredTodos,
  activeCount,
  completedCount,
  addTodo,
  toggleTodo,
  deleteTodo
} = useTodos()

最佳实践总结

组件设计原则

原则 说明
单一职责 每个组件只做一件事
高内聚低耦合 相关功能放在一起,减少组件间依赖
可复用性 设计时考虑能否在其他地方使用
可测试性 易于编写单元测试

Props 设计

✅ 推荐
- 使用描述性的名称:todoTitle 而不是 data
- 提供合理的默认值
- 明确类型和必填性
- 避免传递过多 props(考虑拆分子组件)

❌ 避免
- 模糊的命名:item, data, obj
- 不指定类型
- 直接修改 prop 值
- Props 超过 5 个(考虑用对象传递或拆分)

事件设计

✅ 推荐
- 使用动词命名:add, delete, update
- 传递必要的数据,不要过多
- 保持事件语义清晰

❌ 避免
- 在事件中传递整个组件实例
- 一个事件做太多事情
- 事件名称过于模糊(如 change, click)

性能优化

技巧 场景
v-show vs v-if 频繁切换用 v-show,条件渲染用 v-if
computed 缓存 复杂计算使用计算属性而非方法
key 属性 列表渲染时提供唯一 key,避免使用 index
懒加载 大组件使用 defineAsyncComponent
虚拟滚动 长列表使用 vue-virtual-scroller
shallowRef 大型对象/数组,不需要深度响应式时

代码组织

✅ 推荐的组件内部结构

1. <script setup> 中的顺序:
   - imports
   - defineProps / defineEmits / defineModel
   - 响应式状态 (ref, reactive)
   - 计算属性 (computed)
   - 侦听器 (watch)
   - 生命周期钩子 (onMounted, etc.)
   - 方法函数
   - defineExpose

2. <template> 中的顺序:
   - 注释分隔不同区块
   - 主要容器
   - 条件渲染的内容
   - 列表渲染的内容

3. <style> 中的顺序:
   - 布局相关
   - 主题相关
   - 动画相关
   - 响应式媒体查询

常见陷阱

陷阱 说明 解决方案
直接修改 prop 违反单向数据流 使用 emit 通知父组件
用 index 作为 key 列表重排时出错 使用唯一 id
忘记 deep: true 监听不到对象内部变化 对对象/数组使用深度监听
计算属性做副作用 违反纯函数原则 改用 watch
过度使用事件总线 数据流难以追踪 优先 Props/Provide

结语

Vue 自定义组件是构建现代前端应用的基石。通过本文的学习,你应该掌握了:

  • 组件的基本结构和注册方式
  • Props 和 Emits 的双向通信机制
  • 模板语法的实战技巧
  • 计算属性和侦听器的使用场景
  • 组件通信的多种模式
  • 样式隔离的最佳实践
  • 组合式函数的复用模式

记住,好的组件设计是可复用、可测试、易维护的。在实际项目中,多思考组件的职责边界,保持接口的简洁清晰。

本文通过一个完整的待办事项应用示例,展示了组件化开发在真实场景中的应用。建议你动手实践,从简单的组件开始,逐步构建复杂的应用。


参考资料