名称: vue-component-patterns 用户可调用: false 描述: 当需要Vue组件模式时使用,包括属性、事件、插槽和提供/注入。在构建可重用Vue组件时使用。 允许的工具:
- Bash
- Read
Vue 组件模式
掌握Vue组件模式,以构建可重用、可维护的组件 通过适当的属性验证、事件和组合。
属性模式
使用TypeScript的基本属性
<script setup lang="ts">
interface Props {
title: string;
count?: number;
items: string[];
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
});
</script>
<template>
<div>
<h2>{{ title }}</h2>
<p>Count: {{ count }}</p>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</div>
</template>
高级属性类型
<script setup lang="ts">
import type { PropType } from 'vue';
type Status = 'pending' | 'success' | 'error';
interface User {
id: number;
name: string;
email: string;
}
interface Props {
// 字面量类型
status: Status;
// 复杂对象
user: User;
// 函数
onUpdate: (value: string) => void;
// 通用数组
tags: string[];
// 对象数组
users: User[];
// 可为空
description: string | null;
// 联合类型
value: string | number;
}
const props = defineProps<Props>();
</script>
运行时属性验证
<script setup lang="ts">
import type { PropType } from 'vue';
type ButtonSize = 'sm' | 'md' | 'lg';
const props = defineProps({
// 类型检查
title: {
type: String,
required: true
},
// 默认值
count: {
type: Number,
default: 0
},
// 多种类型
value: {
type: [String, Number],
required: true
},
// 对象类型
user: {
type: Object as PropType<{ name: string; age: number }>,
required: true
},
// 数组类型
tags: {
type: Array as PropType<string[]>,
default: () => []
},
// 自定义验证器
size: {
type: String as PropType<ButtonSize>,
default: 'md',
validator: (value: string) => ['sm', 'md', 'lg'].includes(value)
},
// 复杂验证器
email: {
type: String,
validator: (value: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
},
// 函数属性
onClick: {
type: Function as PropType<(id: number) => void>,
required: false
}
});
</script>
带默认值的属性
<script setup lang="ts">
interface Props {
title?: string;
count?: number;
items?: string[];
user?: {
name: string;
email: string;
};
options?: {
enabled: boolean;
timeout: number;
};
}
// 简单默认值
const props = withDefaults(defineProps<Props>(), {
title: '默认标题',
count: 0
});
// 对象/数组的函数默认值
const propsWithComplex = withDefaults(defineProps<Props>(), {
title: '默认',
count: 0,
items: () => [],
user: () => ({ name: '访客', email: 'guest@example.com' }),
options: () => ({ enabled: true, timeout: 5000 })
});
</script>
事件模式
TypeScript事件
<script setup lang="ts">
// 定义事件类型
const emit = defineEmits<{
// 无载荷
close: [];
// 单载荷
update: [value: string];
// 多载荷
change: [id: number, value: string];
// 对象载荷
submit: [data: { name: string; email: string }];
}>();
function handleClose() {
emit('close');
}
function handleUpdate(value: string) {
emit('update', value);
}
function handleChange(id: number, value: string) {
emit('change', id, value);
}
function handleSubmit() {
emit('submit', { name: 'John', email: 'john@example.com' });
}
</script>
运行时事件验证
<script setup lang="ts">
const emit = defineEmits({
// 基本事件
click: null,
// 验证
update: (value: number) => {
return value >= 0;
},
// 复杂验证
submit: (payload: { email: string; password: string }) => {
if (!payload.email || !payload.password) {
console.warn('无效的提交载荷');
return false;
}
return true;
}
});
</script>
自定义v-model
<!-- CustomInput.vue -->
<script setup lang="ts">
interface Props {
modelValue: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
emit('update:modelValue', target.value);
}
</script>
<template>
<input
:value="modelValue"
@input="handleInput"
type="text"
/>
</template>
<!-- 使用 -->
<script setup lang="ts">
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';
const text = ref('');
</script>
<template>
<CustomInput v-model="text" />
</template>
多个v-model
<!-- RangeSlider.vue -->
<script setup lang="ts">
interface Props {
min: number;
max: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:min': [value: number];
'update:max': [value: number];
}>();
</script>
<template>
<div>
<input
type="range"
:value="min"
@input="emit('update:min', Number($event.target.value))"
/>
<input
type="range"
:value="max"
@input="emit('update:max', Number($event.target.value))"
/>
</div>
</template>
<!-- 使用 -->
<script setup lang="ts">
import { ref } from 'vue';
const minValue = ref(0);
const maxValue = ref(100);
</script>
<template>
<RangeSlider v-model:min="minValue" v-model:max="maxValue" />
</template>
插槽模式
基本插槽
<!-- Card.vue -->
<template>
<div class="card">
<header v-if="$slots.header">
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer v-if="$slots.footer">
<slot name="footer" />
</footer>
</div>
</template>
<!-- 使用 -->
<template>
<Card>
<template #header>
<h1>卡片标题</h1>
</template>
<p>卡片内容在此</p>
<template #footer>
<button>操作</button>
</template>
</Card>
</template>
作用域插槽
<!-- List.vue -->
<script setup lang="ts" generic="T">
interface Props {
items: T[];
}
const props = defineProps<Props>();
</script>
<template>
<div>
<div v-for="(item, index) in items" :key="index">
<slot :item="item" :index="index" />
</div>
</div>
</template>
<!-- 使用 -->
<script setup lang="ts">
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' }
];
</script>
<template>
<List :items="users">
<template #default="{ item, index }">
<div>
{{ index + 1 }}. {{ item.name }} - {{ item.email }}
</div>
</template>
</List>
</template>
后备插槽内容
<!-- Button.vue -->
<template>
<button>
<slot>
点击我
</slot>
</button>
</template>
<!-- 自定义内容 -->
<Button>自定义文本</Button>
<!-- 使用后备 -->
<Button />
动态插槽
<!-- DynamicSlots.vue -->
<script setup lang="ts">
import { useSlots } from 'vue';
const slots = useSlots();
// 检查插槽是否存在
const hasHeader = !!slots.header;
// 访问插槽属性
const headerProps = slots.header?.();
</script>
<template>
<div>
<div v-if="hasHeader" class="header">
<slot name="header" />
</div>
<slot />
</div>
</template>
无渲染组件与插槽
<!-- Mouse.vue - 无渲染组件 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const x = ref(0);
const y = ref(0);
function update(event: MouseEvent) {
x.value = event.pageX;
y.value = event.pageY;
}
onMounted(() => {
window.addEventListener('mousemove', update);
});
onUnmounted(() => {
window.removeEventListener('mousemove', update);
});
</script>
<template>
<slot :x="x" :y="y" />
</template>
<!-- 使用 -->
<template>
<Mouse v-slot="{ x, y }">
<p>鼠标位置: {{ x }}, {{ y }}</p>
</Mouse>
</template>
提供和注入用于深层传递
基本提供/注入
<!-- Parent.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue';
const theme = ref('dark');
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark';
}
provide('theme', theme);
provide('toggleTheme', toggleTheme);
</script>
<!-- Child.vue (任意深度) -->
<script setup lang="ts">
import { inject, type Ref } from 'vue';
const theme = inject<Ref<string>>('theme');
const toggleTheme = inject<() => void>('toggleTheme');
</script>
<template>
<div :class="theme">
<button @click="toggleTheme">切换主题</button>
</div>
</template>
类型安全的提供/注入
// types.ts
import type { InjectionKey, Ref } from 'vue';
export interface AppConfig {
apiUrl: string;
timeout: number;
}
export interface User {
id: number;
name: string;
email: string;
}
export const ConfigKey: InjectionKey<AppConfig> = Symbol('config');
export const UserKey: InjectionKey<Ref<User | null>> = Symbol('user');
// 提供者
<script setup lang="ts">
import { provide, ref } from 'vue';
import { ConfigKey, UserKey } from './types';
const config: AppConfig = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
const user = ref<User | null>(null);
provide(ConfigKey, config);
provide(UserKey, user);
</script>
// 消费者
<script setup lang="ts">
import { inject } from 'vue';
import { ConfigKey, UserKey } from './types';
const config = inject(ConfigKey);
const user = inject(UserKey);
// 完全类型化!
console.log(config?.apiUrl);
console.log(user?.value?.name);
</script>
提供/注入与响应性
<!-- App.vue -->
<script setup lang="ts">
import { provide, reactive, readonly } from 'vue';
interface State {
count: number;
user: { name: string };
}
const state = reactive<State>({
count: 0,
user: { name: 'John' }
});
function increment() {
state.count++;
}
// 提供只读以防止突变
provide('state', readonly(state));
provide('increment', increment);
</script>
<!-- 消费者 -->
<script setup lang="ts">
import { inject } from 'vue';
const state = inject('state');
const increment = inject('increment');
</script>
<template>
<div>
<p>计数: {{ state.count }}</p>
<button @click="increment">增加</button>
</div>
</template>
组件注册
全局注册
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import BaseButton from './components/BaseButton.vue';
import BaseInput from './components/BaseInput.vue';
const app = createApp(App);
// 全局注册
app.component('BaseButton', BaseButton);
app.component('BaseInput', BaseInput);
app.mount('#app');
// 无需导入即可在任何地方使用
<template>
<BaseButton>点击</BaseButton>
<BaseInput v-model="text" />
</template>
局部注册
<script setup lang="ts">
import BaseButton from './components/BaseButton.vue';
import BaseInput from './components/BaseInput.vue';
// 自动在此组件中注册
</script>
<template>
<BaseButton>点击</BaseButton>
<BaseInput v-model="text" />
</template>
自动导入组件
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
export default defineConfig({
plugins: [
vue(),
Components({
// 从components目录自动导入
dirs: ['src/components'],
// 生成类型
dts: true
})
]
});
// 现在无需导入即可使用组件
<template>
<BaseButton>无需导入!</BaseButton>
</template>
异步组件
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
// 基本异步组件
const AsyncComponent = defineAsyncComponent(() =>
import('./components/Heavy.vue')
);
// 带加载和错误状态
const AsyncWithOptions = defineAsyncComponent({
loader: () => import('./components/Heavy.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 3000
});
</script>
<template>
<Suspense>
<AsyncComponent />
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</template>
Teleport用于模态框和门户
<!-- Modal.vue -->
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
show: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
}>();
</script>
<template>
<Teleport to="body">
<div v-if="show" class="modal-backdrop" @click="emit('close')">
<div class="modal" @click.stop>
<slot />
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: white;
padding: 2rem;
border-radius: 8px;
}
</style>
<!-- 使用 -->
<script setup lang="ts">
import { ref } from 'vue';
import Modal from './Modal.vue';
const showModal = ref(false);
</script>
<template>
<button @click="showModal = true">打开模态框</button>
<Modal :show="showModal" @close="showModal = false">
<h2>模态框内容</h2>
<p>这被传送到body!</p>
</Modal>
</template>
KeepAlive用于组件缓存
<script setup lang="ts">
import { ref } from 'vue';
import TabA from './TabA.vue';
import TabB from './TabB.vue';
import TabC from './TabC.vue';
const currentTab = ref('TabA');
const tabs = {
TabA,
TabB,
TabC
};
</script>
<template>
<div>
<button
v-for="(_, tab) in tabs"
:key="tab"
@click="currentTab = tab"
>
{{ tab }}
</button>
<!-- 缓存非活动组件 -->
<KeepAlive>
<component :is="tabs[currentTab]" />
</KeepAlive>
<!-- 包含/排除特定组件 -->
<KeepAlive :include="['TabA', 'TabB']">
<component :is="tabs[currentTab]" />
</KeepAlive>
<!-- 最大缓存实例数 -->
<KeepAlive :max="3">
<component :is="tabs[currentTab]" />
</KeepAlive>
</div>
</template>
高阶组件
// withLoading.ts
import { defineComponent, h, ref, onMounted } from 'vue';
export function withLoading(Component: any, loadFn: () => Promise<void>) {
return defineComponent({
setup(props, { attrs, slots }) {
const loading = ref(true);
const error = ref<Error | null>(null);
onMounted(async () => {
try {
await loadFn();
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
});
return () => {
if (loading.value) {
return h('div', '加载中...');
}
if (error.value) {
return h('div', `错误: ${error.value.message}`);
}
return h(Component, { ...props, ...attrs }, slots);
};
}
});
}
// 使用
const UserProfile = withLoading(
UserProfileComponent,
async () => {
// 加载用户数据
}
);
何时使用此技能
在构建现代、生产就绪的应用程序时使用vue-component-patterns,这些应用程序需要:
- 可重用组件库
- 复杂组件通信
- 类型安全组件API
- 灵活的插槽内容投影
- 无需属性钻孔的深层传递
- 模态框和门户管理
- 组件性能优化
- 大规模组件架构
组件设计最佳实践
- 单一职责 - 每个组件应做好一件事
- 属性向下,事件向上 - 数据通过属性向下流动,变更通过事件向上流动
- 使用TypeScript - 类型安全的属性和事件防止错误
- 验证属性 - 对关键属性使用运行时验证
- 提供默认值 - 对可选属性使用
withDefaults - 使用作用域插槽 - 与消费者共享组件状态
- 避免属性钻孔 - 使用提供/注入进行深层传递
- 使用
v-model进行双向绑定 - 特别是表单输入 - 用插槽组合 - 使组件灵活可重用
- 保持组件小型 - 将复杂逻辑提取到组合式函数
组件反模式
- 突变属性 - 属性是只读的,应发射事件
- 紧耦合 - 组件不应了解其父级
- 组件中的全局状态 - 使用组合式函数或存储代替
- 过多属性 - 考虑插槽或组合
- 嵌套v-model - 可能导致混淆,要明确
- 不使用TypeScript - 失去类型安全和开发体验
- 过度使用提供/注入 - 用于应用级状态,而非所有内容
- 无属性验证 - 可能导致运行时错误
- 混合关注点 - 分离UI、逻辑和数据获取
- 未清理 - 在
onUnmounted中移除事件监听器
常见组件模式
表单输入组件
<script setup lang="ts">
interface Props {
modelValue: string;
label?: string;
error?: string;
placeholder?: string;
required?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: string];
blur: [];
}>();
</script>
<template>
<div class="form-field">
<label v-if="label">
{{ label }}
<span v-if="required" class="required">*</span>
</label>
<input
:value="modelValue"
:placeholder="placeholder"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="emit('blur')"
/>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
数据表格组件
<script setup lang="ts" generic="T">
interface Column<T> {
key: keyof T;
label: string;
sortable?: boolean;
}
interface Props {
data: T[];
columns: Column<T>[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
sort: [column: keyof T];
rowClick: [item: T];
}>();
</script>
<template>
<table>
<thead>
<tr>
<th
v-for="col in columns"
:key="String(col.key)"
@click="col.sortable && emit('sort', col.key)"
>
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in data"
:key="index"
@click="emit('rowClick', item)"
>
<td v-for="col in columns" :key="String(col.key)">
<slot :name="`cell-${String(col.key)}`" :item="item">
{{ item[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>