第 116 章:组件基础
学习目标
- 理解 Vue 3 组件化思维
- 掌握组件的注册与使用
- 学会 props、emits 的基础用法
- 理解组件通信的基本模式
一、什么是组件
组件是可复用的 Vue 实例,每个组件有自己独立的模板、逻辑、样式。
1.1 一个最简单的组件
vue
<!-- components/HelloWorld.vue -->
<script setup lang="ts">
defineProps<{ msg: string }>();
</script>
<template>
<div class="hello">
<h1>{{ msg }}</h1>
</div>
</template>vue
<!-- App.vue -->
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue';
</script>
<template>
<HelloWorld msg="欢迎使用 Vue 3" />
</template>二、组件注册
2.1 局部注册
vue
<script setup lang="ts">
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';
</script>
<template>
<ComponentA />
<ComponentB />
</template>2.2 全局注册(不推荐)
typescript
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import GlobalComponent from './components/GlobalComponent.vue';
const app = createApp(App);
app.component('GlobalComponent', GlobalComponent);
app.mount('#app');提示
全局注册会让组件在所有地方可用,但不利于 tree-shaking 和类型推断。Vue 3 推荐局部注册。
2.3 自动注册(推荐)
使用 unplugin-vue-components 实现按需自动注册:
typescript
// vite.config.ts
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [ElementPlusResolver()]
})
]
});三、Props 父传子
3.1 基础用法
vue
<!-- Child.vue -->
<script setup lang="ts">
// 方式 1:用 defineProps 宏
const props = defineProps<{
title: string;
count?: number;
items: string[];
}>();
console.log(props.title);
</script>
<template>
<div>{{ title }}</div>
</template>vue
<!-- Parent.vue -->
<script setup lang="ts">
import Child from './Child.vue';
</script>
<template>
<Child title="Hello" :count="10" :items="['a', 'b']" />
</template>3.2 类型定义
typescript
// 写法 1:纯类型
defineProps<{
msg: string;
count?: number;
}>();
// 写法 2:运行时 + 类型
const props = defineProps({
msg: String,
count: {
type: Number,
default: 0
}
});3.3 默认值
typescript
// 用 withDefaults
interface Props {
msg?: string;
count?: number;
items?: string[];
}
const props = withDefaults(defineProps<Props>(), {
msg: '默认值',
count: 0,
items: () => [] // 对象/数组默认值用工厂函数
});3.4 单向数据流
vue
<!-- ❌ 错误:不要直接修改 props -->
<script setup>
const props = defineProps<{ count: number }>();
props.count++; // ❌ 警告
</script>
<!-- ✅ 正确:用本地 ref 包装 -->
<script setup>
const props = defineProps<{ count: number }>();
const localCount = ref(props.count);
</script>四、Emits 子传父
4.1 基础用法
vue
<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits<{
increment: [count: number];
change: [value: string];
}>();
const handleClick = () => {
emit('increment', 1);
emit('change', 'new value');
};
</script>
<template>
<button @click="handleClick">+</button>
</template>vue
<!-- Parent.vue -->
<script setup lang="ts">
import Child from './Child.vue';
const handleIncrement = (count: number) => {
console.log('子组件 +1', count);
};
</script>
<template>
<Child @increment="handleIncrement" />
</template>4.2 带校验
typescript
const emit = defineEmits({
submit: (payload: { id: number }) => {
return typeof payload.id === 'number';
},
cancel: null
});五、组件命名
5.1 PascalCase(推荐)
typescript
import TodoItem from './TodoItem.vue';vue
<TodoItem />5.2 kebab-case(模板中)
vue
<todo-item />PascalCase 在 JS 中使用,kebab-case 在模板中使用。
5.3 文件命名
| 风格 | 示例 | 推荐 |
|---|---|---|
| PascalCase | TodoItem.vue | ✅ |
| kebab-case | todo-item.vue | ✅ |
六、组件复用:插槽
6.1 默认插槽
vue
<!-- Card.vue -->
<template>
<div class="card">
<slot>默认内容</slot>
</div>
</template>vue
<Card>
<p>这是卡片内容</p>
</Card>6.2 具名插槽
vue
<!-- Layout.vue -->
<template>
<div class="layout">
<header>
<slot name="header">默认头部</slot>
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</div>
</template>vue
<Layout>
<template #header>
<h1>标题</h1>
</template>
<p>主体内容(默认插槽)</p>
<template #footer>
<p>版权信息</p>
</template>
</Layout>6.3 作用域插槽
vue
<!-- List.vue -->
<script setup lang="ts">
interface User { id: number; name: string }
defineProps<{ users: User[] }>();
</script>
<template>
<ul>
<li v-for="user in users" :key="user.id">
<slot :user="user" :index="user.id">
{{ user.name }}
</slot>
</li>
</ul>
</template>vue
<List :users="userList">
<template #default="{ user, index }">
<span>{{ index }}. {{ user.name }}</span>
</template>
</List>七、动态组件
7.1 component 元素
vue
<script setup lang="ts">
import { ref } from 'vue';
import Home from './Home.vue';
import About from './About.vue';
const current = ref('Home');
const components = { Home, About };
</script>
<template>
<button @click="current = 'Home'">Home</button>
<button @click="current = 'About'">About</button>
<component :is="components[current]" />
</template>7.2 KeepAlive 缓存
vue
<template>
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
</template>八、组件 v-model
8.1 单个 v-model
vue
<!-- CustomInput.vue -->
<script setup lang="ts">
defineProps<{ modelValue: string }>();
defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>vue
<CustomInput v-model="searchText" />8.2 多个 v-model
vue
<!-- UserForm.vue -->
<script setup lang="ts">
defineProps<{ name: string; age: number }>();
defineEmits<{
'update:name': [value: string];
'update:age': [value: number];
}>();
</script>
<template>
<input :value="name" @input="$emit('update:name', $event.target.value)" />
<input :value="age" type="number" @input="$emit('update:age', +$event.target.value)" />
</template>vue
<UserForm v-model:name="userName" v-model:age="userAge" />九、实战案例:Todo 应用
9.1 TodoInput.vue
vue
<script setup lang="ts">
import { ref } from 'vue';
const emit = defineEmits<{ add: [text: string] }>();
const text = ref('');
const submit = () => {
if (!text.value.trim()) return;
emit('add', text.value);
text.value = '';
};
</script>
<template>
<input v-model="text" @keyup.enter="submit" placeholder="添加待办" />
<button @click="submit">添加</button>
</template>9.2 TodoItem.vue
vue
<script setup lang="ts">
interface Todo {
id: number;
text: string;
done: boolean;
}
const props = defineProps<{ todo: Todo }>();
const emit = defineEmits<{
toggle: [id: number];
remove: [id: number];
}>();
</script>
<template>
<li :class="{ done: todo.done }">
<input
type="checkbox"
:checked="todo.done"
@change="emit('toggle', todo.id)"
/>
<span>{{ todo.text }}</span>
<button @click="emit('remove', todo.id)">×</button>
</li>
</template>9.3 TodoApp.vue(根组件)
vue
<script setup lang="ts">
import { ref } from 'vue';
import TodoInput from './TodoInput.vue';
import TodoItem from './TodoItem.vue';
interface Todo {
id: number;
text: string;
done: boolean;
}
const todos = ref<Todo[]>([]);
const add = (text: string) => {
todos.value.push({
id: Date.now(),
text,
done: false
});
};
const toggle = (id: number) => {
const todo = todos.value.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
};
const remove = (id: number) => {
todos.value = todos.value.filter((t) => t.id !== id);
};
</script>
<template>
<div class="todo-app">
<h1>Todo App</h1>
<TodoInput @add="add" />
<ul>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@toggle="toggle"
@remove="remove"
/>
</ul>
</div>
</template>十、常见错误
10.1 组件名错误
typescript
// ❌ 引入文件名,不是组件名
import todoItem from './TodoItem.vue'; // 文件名是 TodoItem
// ✅ 文件名和组件名一致即可
import TodoItem from './TodoItem.vue';10.2 props 整体赋值
vue
<!-- ❌ 不能直接赋值 props -->
<script setup>
const props = defineProps<{ obj: { count: number } }>();
props.obj = { count: 1 }; // ❌
</script>10.3 组件未导入
vue
<template>
<UserCard /> <!-- ❌ 没 import -->
</template>十一、本章小结
| 要点 | 关键 |
|---|---|
| 组件 | 可复用的 Vue 实例,单文件 .vue |
| 注册 | 局部(推荐) / 全局 / 自动 |
| Props | 父传子,defineProps |
| Emits | 子传父,defineEmits |
| 插槽 | 默认 / 具名 / 作用域 |
| 动态组件 | <component :is="..."> |
| v-model | modelValue + update:modelValue |
动手练习
- 拆分组件:把"用户卡片"拆分为 Avatar、Info、Actions 三个组件
- Props/Emits:实现一个 Modal 组件,父组件控制显示/隐藏
- 插槽:写一个 Layout 组件,支持 header/main/footer 插槽
- 动态组件:实现一个 Tab 切换,使用
<component :is>
推荐阅读
- 📖 Vue 3 组件基础 — 官方文档
- 📖 Vue 3 Props — Props 详解
- 🌐 Vue 3 Component Patterns — 组件模式
下一章:第 117 章:Props 与 Emits 深入 →