Vue3 组件设计模式
大约 12 分钟约 3617 字
Vue3 组件设计模式
简介
组件是 Vue.js 应用的基本构建单元。良好的组件设计可以提高代码复用性、可维护性和可测试性。掌握 Props/Emit 通信、插槽(Slot)、组合式函数(Composables)和组件设计原则,可以构建高质量的 Vue3 应用。
特点
基础组件
Props 与 Emit
<!-- UserCard.vue — 展示型组件 -->
<script setup lang="ts">
interface Props {
name: string
email: string
avatar?: string
role?: 'admin' | 'editor' | 'viewer'
}
const props = withDefaults(defineProps<Props>(), {
avatar: '/default-avatar.png',
role: 'viewer'
})
const emit = defineEmits<{
edit: [userId: string]
delete: [userId: string]
}>()
const handleEdit = () => emit('edit', props.name)
</script>
<template>
<div class="user-card">
<img :src="avatar" :alt="name" class="avatar" />
<div class="info">
<h3>{{ name }}</h3>
<p>{{ email }}</p>
<span class="badge">{{ role }}</span>
</div>
<div class="actions">
<button @click="handleEdit">编辑</button>
</div>
</div>
</template>v-model 双向绑定
<!-- SearchInput.vue — 表单组件 -->
<script setup lang="ts">
const modelValue = defineModel<string>({ required: true })
const handleInput = (e: Event) => {
modelValue.value = (e.target as HTMLInputElement).value
}
</script>
<template>
<div class="search-input">
<input :value="modelValue" @input="handleInput" placeholder="搜索..." />
<button v-if="modelValue" @click="modelValue = ''" class="clear">×</button>
</div>
</template>
<!-- 使用 -->
<SearchInput v-model="searchKeyword" />Props 校验与高级用法
<!-- DataTable.vue — 高级 Props 校验 -->
<script setup lang="ts">
interface Column {
key: string
title: string
width?: string
sortable?: boolean
align?: 'left' | 'center' | 'right'
render?: (row: Record<string, any>) => string
}
interface Props {
data: Record<string, any>[]
columns: Column[]
rowKey: string
loading?: boolean
stripe?: boolean
border?: boolean
height?: string | number
// 支持函数类型的 Prop
rowClassName?: (row: Record<string, any>, index: number) => string
// 支持复杂对象
pagination?: {
page: number
pageSize: number
total: number
}
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
stripe: true,
border: false,
})
// 使用 computed 对 props 进行派生
const sortedData = computed(() => {
return [...props.data].sort((a, b) => {
// 排序逻辑
return 0
})
})
// 监听 props 变化
watch(() => props.data, (newVal) => {
console.log('数据更新:', newVal.length)
}, { deep: true })
</script>
<template>
<div class="data-table" :class="{ stripe, border }">
<table :style="{ height: height ? `${height}px` : undefined }">
<thead>
<tr>
<th v-for="col in columns" :key="col.key"
:style="{ width: col.width, textAlign: col.align || 'left' }">
{{ col.title }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="columns.length" class="loading-row">加载中...</td>
</tr>
<tr v-for="(row, index) in data" :key="row[rowKey]"
:class="rowClassName?.(row, index)">
<td v-for="col in columns" :key="col.key">
<template v-if="col.render">
{{ col.render(row) }}
</template>
<template v-else>
{{ row[col.key] }}
</template>
</td>
</tr>
</tbody>
</table>
</div>
</template>多个 v-model 绑定
<!-- RangeSlider.vue — 多个 v-model -->
<script setup lang="ts">
// Vue3 支持多个 v-model,通过命名区分
const min = defineModel<number>('min', { required: true })
const max = defineModel<number>('max', { required: true })
const step = defineModel<number>('step', { default: 1 })
function handleMinChange(e: Event) {
const val = Number((e.target as HTMLInputElement).value)
if (val <= max.value) {
min.value = val
}
}
function handleMaxChange(e: Event) {
const val = Number((e.target as HTMLInputElement).value)
if (val >= min.value) {
max.value = val
}
}
</script>
<template>
<div class="range-slider">
<input type="range" :value="min" @input="handleMinChange" :step="step" />
<input type="range" :value="max" @input="handleMaxChange" :step="step" />
<span>{{ min }} - {{ max }}</span>
</div>
</template>
<!-- 使用:多个 v-model 同时绑定 -->
<!-- <RangeSlider v-model:min="priceMin" v-model:max="priceMax" v-model:step="priceStep" /> -->Provide / Inject 跨层级通信
<!-- ThemeProvider.vue — 使用 provide/inject 跨层级传递 -->
<script setup lang="ts">
import { provide, ref, readonly } from 'vue'
interface Theme {
mode: 'light' | 'dark'
primaryColor: string
fontSize: number
}
const theme = ref<Theme>({
mode: 'light',
primaryColor: '#1890ff',
fontSize: 14
})
// 提供 readonly 防止子组件直接修改
provide('theme', readonly(theme))
// 提供修改方法
function toggleMode() {
theme.value.mode = theme.value.mode === 'light' ? 'dark' : 'light'
}
function setPrimaryColor(color: string) {
theme.value.primaryColor = color
}
provide('themeActions', { toggleMode, setPrimaryColor })
</script>
<!-- 任何层级的子组件中使用 -->
<!-- DeepChild.vue -->
<script setup lang="ts">
import { inject } from 'vue'
// 使用 InjectionKey 保证类型安全
const theme = inject<Readonly<Ref<Theme>>>('theme')
const { toggleMode } = inject<{ toggleMode: () => void }>('themeActions')!
</script>
<template>
<div :style="{ color: theme?.primaryColor, fontSize: `${theme?.fontSize}px` }">
<button @click="toggleMode">切换主题</button>
</div>
</template>插槽(Slot)
内容分发
<!-- Card.vue — 通用卡片组件 -->
<script setup lang="ts">
interface Props {
title: string
bordered?: boolean
loading?: boolean
}
withDefaults(defineProps<Props>(), { bordered: true, loading: false })
</script>
<template>
<div class="card" :class="{ bordered, loading }">
<!-- 头部插槽 -->
<div class="card-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<slot name="actions" />
</div>
<!-- 默认插槽 -->
<div class="card-body">
<slot />
</div>
<!-- 底部插槽 -->
<div class="card-footer" v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</template>
<!-- 使用 -->
<Card title="用户列表">
<template #actions>
<button @click="showAddDialog">新增</button>
</template>
<!-- 默认插槽内容 -->
<UserTable :users="users" />
<template #footer>
<Pagination :total="total" v-model:page="currentPage" />
</template>
</Card>
<!-- 作用域插槽 -->
<ListView :items="products">
<template #default="{ item, index }">
<div>{{ index }}. {{ item.name }} - ¥{{ item.price }}</div>
</template>
</ListView>动态插槽与条件渲染
<!-- DynamicLayout.vue — 动态插槽 -->
<script setup lang="ts">
interface Props {
layout: 'horizontal' | 'vertical' | 'grid'
}
defineProps<Props>()
</script>
<template>
<component :is="layout === 'grid' ? 'div' : 'section'"
:class="['layout', `layout--${layout}`]">
<!-- 根据 layout 条件渲染不同插槽 -->
<div v-if="layout === 'horizontal'" class="layout-horizontal">
<div class="layout-sidebar">
<slot name="sidebar">
<nav>默认侧边栏</nav>
</slot>
</div>
<div class="layout-main">
<slot />
</div>
</div>
<div v-else-if="layout === 'vertical'" class="layout-vertical">
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer v-if="$slots.footer">
<slot name="footer" />
</footer>
</div>
<div v-else class="layout-grid">
<slot />
</div>
</component>
</template>动态组件与异步组件
<!-- DynamicComponentLoader.vue -->
<script setup lang="ts">
import { defineAsyncComponent, shallowRef, ref, onErrorCaptured } from 'vue'
// 异步组件 — 带加载和错误状态
const AsyncHeavyComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: () => import('./LoadingSpinner.vue'),
errorComponent: () => import('./ErrorFallback.vue'),
delay: 200, // 延迟显示 loading
timeout: 10000 // 超时时间
})
// 动态组件切换
const currentTab = ref('overview')
const tabComponents: Record<string, any> = {
overview: defineAsyncComponent(() => import('./tabs/Overview.vue')),
analytics: defineAsyncComponent(() => import('./tabs/Analytics.vue')),
settings: defineAsyncComponent(() => import('./tabs/Settings.vue')),
}
const activeComponent = computed(() => tabComponents[currentTab.value])
// 错误捕获
onErrorCaptured((err, instance, info) => {
console.error('组件错误:', err, info)
// 可以在这里上报错误
return false // 阻止错误继续向上传播
})
</script>
<template>
<div>
<nav class="tabs">
<button v-for="tab in ['overview', 'analytics', 'settings']"
:key="tab"
:class="{ active: currentTab === tab }"
@click="currentTab = tab">
{{ tab }}
</button>
</nav>
<KeepAlive>
<component :is="activeComponent" :key="currentTab" />
</KeepAlive>
</div>
</template>Composables
逻辑复用
// composables/useLoading.ts — 加载状态
import { ref } from 'vue'
export function useLoading(defaultLoading = false) {
const loading = ref(defaultLoading)
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true
try {
return await fn()
} finally {
loading.value = false
}
}
return { loading, withLoading }
}
// composables/usePagination.ts — 分页逻辑
import { ref, computed } from 'vue'
export function usePagination(options: { pageSize?: number } = {}) {
const page = ref(1)
const pageSize = ref(options.pageSize ?? 20)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
function nextPage() {
if (page.value < totalPages.value) page.value++
}
function prevPage() {
if (page.value > 1) page.value--
}
function goToPage(p: number) {
page.value = Math.max(1, Math.min(p, totalPages.value))
}
function reset() {
page.value = 1
}
return { page, pageSize, total, totalPages, nextPage, prevPage, goToPage, reset }
}
// 使用
const { loading, withLoading } = useLoading()
const { page, total, totalPages, nextPage } = usePagination({ pageSize: 20 })
await withLoading(async () => {
const res = await api.getUsers(page.value)
users.value = res.data
total.value = res.total
})事件总线 Composable
// composables/useEventBus.ts
import { ref } from 'vue'
type Callback = (...args: any[]) => void
const bus = new Map<string, Set<Callback>>()
export function useEventBus() {
function on(event: string, callback: Callback) {
if (!bus.has(event)) bus.set(event, new Set())
bus.get(event)!.add(callback)
}
function off(event: string, callback: Callback) {
bus.get(event)?.delete(callback)
}
function emit(event: string, ...args: any[]) {
bus.get(event)?.forEach(cb => cb(...args))
}
return { on, off, emit }
}useMousePosition — 鼠标追踪 Composable
// composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
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, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}useDebounceFn — 防抖函数 Composable
// composables/useDebounceFn.ts
import { ref, watch, onUnmounted } from 'vue'
export function useDebounceFn<T extends (...args: any[]) => any>(
fn: T,
delay: number = 300
) {
let timer: ReturnType<typeof setTimeout> | null = null
const debouncedFn = (...args: Parameters<T>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}
function cancel() {
if (timer) {
clearTimeout(timer)
timer = null
}
}
// 组件卸载时自动清除
onUnmounted(cancel)
return { debouncedFn, cancel }
}
// 使用示例
const { debouncedFn } = useDebounceFn((keyword: string) => {
searchApi(keyword)
}, 500)useLocalStorage — 本地存储 Composable
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
// 从 localStorage 读取初始值
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>
// 监听变化自动存储
watch(
data,
(newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
},
{ deep: true }
)
return data
}
// 使用
const theme = useLocalStorage<'light' | 'dark'>('app-theme', 'light')
const userSettings = useLocalStorage('user-settings', { language: 'zh-CN', fontSize: 14 })useFetch — 请求封装 Composable
// composables/useFetch.ts
import { ref, watch, onUnmounted } from 'vue'
import type { Ref } from 'vue'
interface UseFetchOptions {
immediate?: boolean
initialData?: any
onSuccess?: (data: any) => void
onError?: (error: Error) => void
}
export function useFetch<T = any>(
urlFn: () => string,
options: UseFetchOptions = {}
) {
const {
immediate = true,
initialData = null,
onSuccess,
onError
} = options
const data = ref<T | null>(initialData) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
let abortController: AbortController | null = null
async function execute() {
// 取消上一次请求
abortController?.abort()
abortController = new AbortController()
loading.value = true
error.value = null
try {
const url = urlFn()
const response = await fetch(url, {
signal: abortController.signal
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
onSuccess?.(data.value)
} catch (err: any) {
if (err.name !== 'AbortError') {
error.value = err
onError?.(err)
}
} finally {
loading.value = false
}
}
if (immediate) execute()
onUnmounted(() => {
abortController?.abort()
})
return { data, error, loading, execute }
}
// 使用 — 响应式参数自动重新请求
const keyword = ref('')
const { data, loading, error } = useFetch(
() => `/api/search?q=${keyword.value}`,
{ immediate: false }
)
watch(keyword, () => execute())组件设计原则
模式总结
// 组件分类:
// 1. 展示型组件 — 只负责 UI 展示,通过 Props 接收数据
// 2. 容器型组件 — 负责数据获取和状态管理
// 3. 表单型组件 — 支持 v-model 双向绑定
// 4. 布局型组件 — 提供页面骨架(Header/Sidebar/Footer)
// Props 降级原则:
// - 能用 Props + Emit 解决的,不用 Store
// - 能用 Composables 复用的,不写重复逻辑
// - 能用 Slot 分发的,不用 Props 传模板组件命名与目录规范
src/components/
├── common/ # 通用基础组件
│ ├── Button.vue
│ ├── Input.vue
│ ├── Modal.vue
│ └── index.ts # 统一导出
├── business/ # 业务组件
│ ├── UserCard.vue
│ ├── OrderList.vue
│ └── SearchBar.vue
├── layout/ # 布局组件
│ ├── AppHeader.vue
│ ├── AppSidebar.vue
│ └── AppFooter.vue
└── composables/ # 组合式函数
├── useLoading.ts
├── usePagination.ts
└── useFetch.ts组件注册最佳实践
// components/common/index.ts — 全局注册基础组件
import type { App } from 'vue'
import Button from './Button.vue'
import Input from './Input.vue'
import Modal from './Modal.vue'
// 支持 Tree-shaking 的全局注册
const components = { Button, Input, Modal }
export default {
install(app: App) {
for (const [name, component] of Object.entries(components)) {
app.component(name, component)
}
}
}
// main.ts 中按需引入
import commonComponents from '@/components/common'
app.use(commonComponents)高阶组件模式 — Renderless Component
<!-- RenderlessHover.vue — 无渲染组件,只提供逻辑 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const isHovered = ref(false)
function onMouseEnter() { isHovered.value = true }
function onMouseLeave() { isHovered.value = false }
onMounted(() => {
// 这里可以添加更多事件监听
})
onUnmounted(() => {
// 清理事件监听
})
</script>
<template>
<slot :isHovered="isHovered" :hoverClass="isHovered ? 'hovered' : ''">
<!-- 默认内容 -->
<div :class="{ hovered: isHovered }">
<slot />
</div>
</slot>
</template>
<!-- 使用 — 完全控制 UI 渲染 -->
<RenderlessHover v-slot="{ isHovered }">
<div class="custom-hover-area" :style="{ background: isHovered ? '#f0f0f0' : '#fff' }">
鼠标悬停效果:{{ isHovered ? '激活' : '未激活' }}
</div>
</RenderlessHover>组件通信方式对比与选型
// 通信方式选型指南:
//
// 1. Props / Emit — 父子组件直接通信(首选)
// 适用:父子关系明确,数据流向清晰
// 优点:类型安全,数据流可追踪
//
// 2. Provide / Inject — 跨层级通信
// 适用:主题、配置、国际化等深层传递的场景
// 优点:避免 Props 逐层透传
// 注意:配合 InjectionKey 保证类型安全
//
// 3. Pinia / Vuex — 全局状态管理
// 适用:多个不相关组件共享状态(用户信息、购物车等)
// 优点:DevTools 支持,状态持久化
//
// 4. EventBus — 组件间事件通信(谨慎使用)
// 适用:兄弟组件或跨层级的事件通知
// 缺点:事件流不可追踪,建议用 Pinia 替代
//
// 5. Ref 模板引用 — 直接操作子组件
// 适用:需要调用子组件方法或访问子组件数据
// 优点:直接且高效
// 注意:破坏封装性,应谨慎使用
// Ref 模板引用示例
// Parent.vue
const childRef = ref<InstanceType<typeof ChildComponent>>()
function callChildMethod() {
childRef.value?.doSomething()
}
// Child.vue
defineExpose({
doSomething() {
console.log('子组件方法被调用')
}
})组件生命周期与错误处理
<!-- ErrorBoundary.vue — 错误边界组件 -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
interface ErrorInfo {
error: Error
info: string
timestamp: number
}
const hasError = ref(false)
const errorInfo = ref<ErrorInfo | null>(null)
onErrorCaptured((err, instance, info) => {
hasError.value = true
errorInfo.value = {
error: err,
info,
timestamp: Date.now()
}
// 上报错误到监控平台
console.error('[ErrorBoundary]', err, info)
return false // 阻止错误继续向上传播
})
function retry() {
hasError.value = false
errorInfo.value = null
}
</script>
<template>
<div v-if="hasError" class="error-fallback">
<h3>组件加载失败</h3>
<p>{{ errorInfo?.error.message }}</p>
<button @click="retry">重试</button>
</div>
<slot v-else />
</template>
<!-- 使用 -->
<ErrorBoundary>
<ProblematicComponent />
</ErrorBoundary>优点
缺点
总结
Vue3 组件设计核心原则:单一职责、Props 向下 Emit 向上、Slot 灵活分发。展示型组件只负责 UI,通过 Props 接收数据。逻辑复用优先用 Composables(替代 Mixins)。v-model 简化表单组件的双向绑定。组件粒度以复用性为判断标准,不复用的不需要拆。
关键知识点
- 先判断主题更偏浏览器原理、框架机制、工程化还是性能优化。
- 前端问题很多看似是页面问题,实际源头在构建、缓存、状态流或接口协作。
- 真正成熟的前端方案一定同时考虑首屏、交互、可维护性和线上诊断。
- 前端主题最好同时看浏览器原理、框架机制和工程化约束。
项目落地视角
- 把组件边界、状态归属、网络层规范和错误处理先定下来。
- 上线前检查包体积、缓存命中、接口失败路径和关键交互降级策略。
- 如果主题和性能有关,最好用 DevTools、Lighthouse 或埋点验证。
- 对关键页面先建立状态流和数据流,再考虑组件拆分。
常见误区
- 只盯框架 API,不理解浏览器和运行时成本。
- 把状态、请求和 UI 更新混成一层,后期难维护。
- 线上问题出现时没有日志、埋点和性能基线可对照。
- 只追框架新特性,不分析实际渲染成本。
进阶路线
- 继续补齐 SSR、边缘渲染、设计系统和监控告警能力。
- 把主题和后端接口约定、CI/CD、缓存策略一起思考。
- 沉淀组件规范、页面模板和性能基线,减少团队差异。
- 继续补齐设计系统、SSR/边缘渲染、监控告警和组件库治理。
适用场景
- 当你准备把《Vue3 组件设计模式》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合中后台应用、门户站点、组件库和实时交互页面。
- 当需求涉及状态流、路由、网络缓存、SSR/CSR 或性能治理时,这类主题很关键。
落地建议
- 先定义组件边界和状态归属,再落地 UI 细节。
- 对核心页面做首屏、体积、缓存和错误路径检查。
- 把安全、兼容性和可访问性纳入默认交付标准。
排错清单
- 先用浏览器 DevTools 看请求、性能面板和控制台错误。
- 检查依赖版本、构建配置、环境变量和静态资源路径。
- 如果是线上问题,优先确认缓存、CDN 和构建产物是否一致。
复盘问题
- 如果把《Vue3 组件设计模式》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《Vue3 组件设计模式》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《Vue3 组件设计模式》最大的收益和代价分别是什么?
