02-Vue 组件系统与通信
📋 学习目标
掌握Vue组件注册和使用
理解Props和Emit组件通信
学习插槽Slots的使用
掌握Provide/Inject跨层级通信
了解动态组件和异步组件
🧩 组件注册
全局注册
// main.js
import {createApp} from 'vue';
import MyComponent from './MyComponent.vue';
const app = createApp({});
app.component('MyComponent', MyComponent);
app.mount('#app');
局部注册
<script setup>
import MyComponent from './MyComponent.vue';
import AnotherComponent from './AnotherComponent.vue';
</script>
<template>
<MyComponent />
<AnotherComponent />
</template>
自动导入组件
// vite.config.js
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
export default defineConfig({
plugins: [
vue(),
Components({
dirs: ['src/components'],
extensions: ['vue'],
deep: true
})
]
});
📤 Props 传递数据
基本用法
<!-- Parent.vue -->
<template>
<ChildComponent
:title="title"
:count="count"
:user="user"
/>
</template>
<script setup>
import {ref, reactive} from 'vue';
import ChildComponent from './ChildComponent.vue';
const title = ref('Hello Vue');
const count = ref(0);
const user = reactive({
name: 'John',
age: 30
});
</script>
<!-- ChildComponent.vue -->
<template>
<div>
<h2>{{ title }}</h2>
<p>Count: {{ count }}</p>
<p>User: {{ user.name }}, {{ user.age }}</p>
</div>
</template>
<script setup>
// 定义Props
const props = defineProps({
title: {
type: String,
required: true,
default: 'Default Title'
},
count: {
type: Number,
default: 0
},
user: {
type: Object,
required: true
}
});
</script>
TypeScript类型定义
<script setup lang="ts">
interface User {
name: string;
age: number;
}
interface Props {
title: string;
count?: number;
user: User;
}
const props = withDefaults(defineProps<Props>(), {
count: 0
});
</script>
Props验证
<script setup>
defineProps({
// 基础类型检查
title: String,
// 多种类型
id: [String, Number],
// 必填
name: {
type: String,
required: true
},
// 默认值
count: {
type: Number,
default: 0
},
// 对象/数组默认值
user: {
type: Object,
default: () => ({name: 'Guest'})
},
// 自定义验证
age: {
type: Number,
validator: (value) => {
return value >= 0 && value <= 150;
}
}
});
</script>
📥 Emit 事件通信
基本用法
<!-- ChildComponent.vue -->
<template>
<button @click="handleClick">Click Me</button>
<input
v-model="inputValue"
@input="handleInput"
/>
</template>
<script setup>
import {ref} from 'vue';
const emit = defineEmits(['click', 'update', 'custom-event']);
const inputValue = ref('');
function handleClick() {
emit('click', 'button clicked');
}
function handleInput() {
emit('update', inputValue.value);
emit('custom-event', {
type: 'input',
value: inputValue.value
});
}
</script>
<!-- Parent.vue -->
<template>
<ChildComponent
@click="handleChildClick"
@update="handleUpdate"
@custom-event="handleCustom"
/>
</template>
<script setup>
function handleChildClick(message) {
console.log(message);
}
function handleUpdate(value) {
console.log('Updated:', value);
}
function handleCustom(event) {
console.log('Custom event:', event);
}
</script>
TypeScript类型定义
<script setup lang="ts">
interface Emits {
(e: 'click', message: string): void;
(e: 'update', value: string): void;
(e: 'custom-event', payload: {type: string; value: string}): void;
}
const emit = defineEmits<Emits>();
</script>
v-model双向绑定
<!-- CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps({
modelValue: String
});
defineEmits(['update:modelValue']);
</script>
<!-- 使用 -->
<template>
<CustomInput v-model="text" />
</template>
<script setup>
import {ref} from 'vue';
const text = ref('');
</script>
多个v-model
<!-- CustomForm.vue -->
<template>
<input
:value="firstName"
@input="$emit('update:firstName', $event.target.value)"
/>
<input
:value="lastName"
@input="$emit('update:lastName', $event.target.value)"
/>
</template>
<script setup>
defineProps({
firstName: String,
lastName: String
});
defineEmits(['update:firstName', 'update:lastName']);
</script>
<!-- 使用 -->
<template>
<CustomForm
v-model:firstName="firstName"
v-model:lastName="lastName"
/>
</template>
🎰 插槽 Slots
默认插槽
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default Content</slot>
</div>
<div class="card-footer">
<slot name="footer">Default Footer</slot>
</div>
</div>
</template>
<!-- 使用 -->
<template>
<Card>
<template v-slot:header>
<h2>Custom Header</h2>
</template>
<p>This is the main content</p>
<template v-slot:footer>
<button>Action</button>
</template>
</Card>
</template>
作用域插槽
<!-- List.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="index">
{{ item.name }}
</slot>
</li>
</ul>
</template>
<script setup>
defineProps({
items: Array
});
</script>
<!-- 使用 -->
<template>
<List :items="users">
<template v-slot:default="{item, index}">
<div>
<strong>{{ index + 1 }}. {{ item.name }}</strong>
<span>{{ item.email }}</span>
</div>
</template>
</List>
</template>
具名作用域插槽
<!-- Table.vue -->
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
<slot name="header" :column="col">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.id">
<td v-for="col in columns" :key="col.key">
<slot name="cell" :row="row" :column="col">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- 使用 -->
<template>
<Table :columns="columns" :data="users">
<template v-slot:header="{column}">
<span class="custom-header">{{ column.label }}</span>
</template>
<template v-slot:cell="{row, column}">
<span v-if="column.key === 'status'">
<Badge :type="row.status">{{ row.status }}</Badge>
</span>
<span v-else>{{ row[column.key] }}</span>
</template>
</Table>
</template>
🔄 Provide/Inject 跨层级通信
基本用法
<!-- App.vue -->
<template>
<ParentComponent />
</template>
<script setup>
import {provide, ref} from 'vue';
import ParentComponent from './ParentComponent.vue';
const theme = ref('dark');
const user = ref({
name: 'John',
role: 'admin'
});
// 提供数据
provide('theme', theme);
provide('user', user);
provide('updateTheme', (newTheme) => {
theme.value = newTheme;
});
</script>
<!-- DeepChild.vue -->
<template>
<div :class="theme">
<p>User: {{ user.name }}</p>
<button @click="updateTheme('light')">Change Theme</button>
</div>
</template>
<script setup>
import {inject} from 'vue';
// 注入数据
const theme = inject('theme', 'light'); // 默认值
const user = inject('user');
const updateTheme = inject('updateTheme');
</script>
TypeScript类型定义
<script setup lang="ts">
interface User {
name: string;
role: string;
}
// 提供
const theme = ref<string>('dark');
const user = ref<User>({
name: 'John',
role: 'admin'
});
provide<string>('theme', theme);
provide<User>('user', user);
</script>
<script setup lang="ts">
// 注入
const theme = inject<string>('theme', 'light');
const user = inject<User>('user');
</script>
响应式Provide
<script setup>
import {provide, ref, computed, readonly} from 'vue';
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
// 只读,防止子组件修改
provide('count', readonly(count));
provide('doubleCount', doubleCount);
provide('increment', () => {
count.value++;
});
</script>
🔀 动态组件
component动态组件
<template>
<div>
<button
v-for="tab in tabs"
:key="tab"
@click="currentTab = tab"
>
{{ tab }}
</button>
<component :is="currentTab" />
</div>
</template>
<script setup>
import {ref, shallowRef} from 'vue';
import Home from './Home.vue';
import About from './About.vue';
import Contact from './Contact.vue';
const tabs = ['Home', 'About', 'Contact'];
const currentTab = ref('Home');
// 使用shallowRef优化性能
const components = shallowRef({
Home,
About,
Contact
});
</script>
keep-alive缓存组件
<template>
<keep-alive :include="['Home', 'About']">
<component :is="currentTab" />
</keep-alive>
</template>
<script setup>
import {onActivated, onDeactivated} from 'vue';
// 组件激活时调用
onActivated(() => {
console.log('Component activated');
// 恢复数据、重新请求等
});
// 组件失活时调用
onDeactivated(() => {
console.log('Component deactivated');
// 保存数据、清理定时器等
});
</script>
⚡ 异步组件
defineAsyncComponent
<script setup>
import {defineAsyncComponent} from 'vue';
// 基础用法
const AsyncComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
);
// 带加载状态
const AsyncComponentWithLoading = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorComponent,
delay: 200, // 延迟显示加载组件
timeout: 3000 // 超时时间
});
</script>
<template>
<Suspense>
<template v-slot:default>
<AsyncComponent />
</template>
<template v-slot:fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
路由懒加载
// router/index.js
import {createRouter, createWebHistory} from 'vue-router';
const routes = [
{
path: '/home',
component: () => import('../views/Home.vue')
},
{
path: '/about',
component: () => import('../views/About.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
🎯 组件通信总结
通信方式对比
方式 |
适用场景 |
优点 |
缺点 |
|---|---|---|---|
Props |
父子组件 |
简单直接、类型安全 |
只能向下传递 |
Emit |
父子组件 |
事件驱动、解耦 |
只能向上传递 |
Provide/Inject |
跨层级 |
避免逐层传递 |
难以追踪数据流 |
EventBus |
任意组件 |
灵活 |
难以维护、不推荐 |
Vuex/Pinia |
全局状态 |
集中管理、可追踪 |
增加复杂度 |
最佳实践
优先使用Props和Emit:简单场景使用父子组件通信
跨层级使用Provide/Inject:避免Props逐层传递
全局状态使用Pinia:复杂应用使用状态管理
组件解耦:使用事件和插槽提高组件复用性
类型安全:使用TypeScript定义Props和Emit类型
📝 实战示例
表单组件封装
<!-- FormField.vue -->
<template>
<div class="form-field">
<label v-if="label">{{ label }}</label>
<input
:value="modelValue"
:type="type"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur')"
/>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
<script setup>
defineProps({
label: String,
type: {
type: String,
default: 'text'
},
placeholder: String,
modelValue: String,
error: String
});
defineEmits(['update:modelValue', 'blur']);
</script>
<!-- 使用 -->
<template>
<FormField
v-model="username"
label="用户名"
placeholder="请输入用户名"
:error="errors.username"
@blur="validateUsername"
/>
</template>
列表组件封装
<!-- VirtualList.vue -->
<template>
<div class="virtual-list" ref="containerRef">
<div :style="{height: totalHeight + 'px'}">
<div
v-for="item in visibleItems"
:key="item.id"
:style="{transform: `translateY(${item.top}px)`}"
>
<slot :item="item.data" :index="item.index" />
</div>
</div>
</div>
</template>
<script setup>
import {ref, computed, onMounted, onUnmounted} from 'vue';
const props = defineProps({
items: Array,
itemHeight: Number
});
const containerRef = ref(null);
const scrollTop = ref(0);
const visibleItems = computed(() => {
const start = Math.floor(scrollTop.value / props.itemHeight);
const end = start + Math.ceil(containerRef.value?.clientHeight / props.itemHeight);
return props.items.slice(start, end).map((item, index) => ({
...item,
index: start + index,
top: (start + index) * props.itemHeight
}));
});
const totalHeight = computed(() => props.items.length * props.itemHeight);
function handleScroll() {
scrollTop.value = containerRef.value?.scrollTop || 0;
}
onMounted(() => {
containerRef.value?.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll);
});
</script>