在 Vue3 开发中,你是否遇到过这些痛点?
- 状态管理混乱:全局变量到处飞,修改状态时不知道哪里出了问题;
- 组件逻辑冗余:多个组件重复写同样的请求、计算逻辑,改一个地方要改十处;
- 模板可读性差:复杂的条件分支和循环嵌套,看一眼就头大;
- 组件职责不清:一个组件又做展示、又发请求、又改状态,变成“万能组件”。
这些问题的根源,往往是缺乏对设计模式的理解。设计模式不是“银弹”,却是解决共性问题的“工具箱”——它能帮你把混乱的代码变成可维护、可扩展的工程化代码。
本文将结合 Vue3 的Composition API,详细讲解 12 种实战设计模式,每个模式都包含“痛点、解法、代码示例、注意事项”,帮你从“写代码”升级到“设计代码”。
一、为什么需要 Vue3 设计模式?
Vue3 的核心是Composition API(组合式 API),它的设计初衷就是“让逻辑更易复用”。但如果没有设计模式的指导,你可能会写出这样的代码:
- 把所有逻辑堆在
<script setup>里,变成“长组件”; - 用
ref定义一堆全局变量,状态修改全靠value赋值; - 组件接收 10 个 props,发 5 个事件,变成“臃肿组件”。
设计模式的价值,就是给你的逻辑套上“约束”——让你知道“什么逻辑该放在哪里”“如何组织代码更合理”。
二、12 个实战设计模式:从痛点到解决
1. 数据存储模式:轻量级状态管理,告别全局变量
痛点:
小型项目不想引入 Pinia/Vuex(嫌麻烦),但又需要共享状态(比如主题切换、用户登录状态),直接用全局变量会导致状态修改不可控。
解法:
用可组合函数+模块作用域创建轻量级全局状态。核心思路是:
- 在模块作用域定义
reactive状态(全局单例); - 暴露只读状态和修改方法,避免外部直接修改;
- 只暴露必要的状态,保持封装性。
代码示例(主题管理):
// composables/useTheme.ts
import { reactive, toRefs, readonly } from 'vue'
import { validThemes } from '@/utils/constants' // 有效主题列表:['nord', 'dracula', 'light']
// 模块作用域的全局状态(单例)
const state = reactive({
darkMode: false,
sidebarCollapsed: false,
theme: 'nord', // 私有状态,不直接暴露
})
export default function useTheme() {
// 1. 暴露部分状态(用toRefs保持响应式)
const { darkMode, sidebarCollapsed } = toRefs(state)
// 2. 暴露只读状态(避免外部修改)
const theme = readonly(state.theme)
// 3. 修改状态的方法(保证逻辑一致性)
const toggleDarkMode = () => {
state.darkMode = !state.darkMode
}
const setTheme = (newTheme: string) => {
if (validThemes.includes(newTheme)) {
state.theme = newTheme
}
}
return {
darkMode,
sidebarCollapsed,
theme,
toggleDarkMode,
setTheme,
}
}使用场景:
- 全局主题切换;
- 用户登录状态;
- 侧边栏折叠状态。
优势:
- 轻量:不用引入额外库;
- 可控:修改状态只能通过方法;
- 封装:私有状态不暴露。
注意事项:
- 不要滥用全局状态:只有需要共享的状态才用这种模式;
- 保持状态精简:不要把无关状态塞到同一个可组合函数里。
2. 轻量级可组合函数:分离反应式与业务逻辑
痛点:
组件里的逻辑又做“反应式管理”(比如watch),又做“业务计算”(比如温度转换),导致逻辑混杂,难以测试。
解法:
用纯函数+轻量级反应式层分离逻辑。核心思路是:
- 把业务逻辑写成纯函数(无副作用、输入输出可预测);
- 用可组合函数封装反应式逻辑(
ref、watch); - 暴露反应式结果,供组件使用。
代码示例(温度转换):
// utils/temperature.ts(纯函数,无反应式)
export function celsiusToFahrenheit(celsius: number): number {
return (celsius * 9) / 5 + 32
}
export function fahrenheitToCelsius(fahrenheit: number): number {
return ((fahrenheit - 32) * 5) / 9
}// composables/useTemperatureConverter.ts(反应式层)
import { ref, watch, Ref } from 'vue'
import { celsiusToFahrenheit } from '@/utils/temperature'
export function useTemperatureConverter(celsius: Ref<number>) {
const fahrenheit = ref(0)
// 监听摄氏度变化,调用纯函数计算
watch(
celsius,
(newCelsius) => {
fahrenheit.value = celsiusToFahrenheit(newCelsius)
},
{ immediate: true }
) // 初始化时计算一次
return { fahrenheit }
}组件中使用:
<script setup lang="ts">
import { ref } from 'vue'
import { useTemperatureConverter } from '@/composables/useTemperatureConverter'
const celsius = ref(25)
const { fahrenheit } = useTemperatureConverter(celsius)
</script>
<template>
<div class="temperature-converter">
<input
v-model.number="celsius"
type="number"
placeholder="摄氏度"
/>
<span>→</span>
<span>{{ fahrenheit.toFixed(1) }} ℉</span>
</div>
</template>优势:
- 可测试:纯函数可以单独测试(不用模拟 Vue 环境);
- 可复用:同一纯函数可以被多个可组合函数使用;
- 逻辑清晰:反应式与业务逻辑分离,代码更容易理解。
注意事项:
- 纯函数要“无副作用”:不要修改外部变量,不要发请求;
- 反应式层要“轻”:只做状态监听和结果计算,不做业务逻辑。
3. 谦逊组件模式:组件只做“展示和传事件”,告别万能组件
痛点:
一个组件又做展示、又发请求、又改状态,变成“万能组件”——修改一个逻辑要动整个组件,复用性极低。
解法:
让组件谦逊(Humble):只做两件事:
- 展示数据:接收 props,渲染 UI;
- 传递事件:用户交互时发事件,让父组件处理逻辑。
核心原则:
- 不请求数据:数据由父组件或可组合函数提供;
- 不修改状态:状态修改由父组件或可组合函数处理;
- 不处理逻辑:复杂计算由父组件或纯函数处理。
代码示例(用户卡片组件):
<!-- components/UserCard.vue -->
<template>
<div class="user-card">
<img
:src="user.avatar"
alt="用户头像"
class="avatar"
/>
<div class="user-info">
<h3 class="name">{{ user.name }}</h3>
<p class="email">{{ user.email }}</p>
<p class="role">{{ user.role }}</p>
</div>
<button
@click="$emit('edit-user')"
class="edit-btn"
>
编辑用户
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
user: {
id: number
name: string
email: string
role: string
avatar: string
}
}>()
defineEmits<{
(e: 'edit-user'): void
}>()
</script>父组件中使用:
<script setup lang="ts">
import { ref } from 'vue'
import UserCard from '@/components/UserCard.vue'
import { fetchUser } from '@/api/user'
const user = ref(null)
// 父组件处理请求逻辑
const loadUser = async (userId: number) => {
user.value = await fetchUser(userId)
}
// 父组件处理编辑逻辑
const handleEditUser = () => {
console.log('打开编辑弹窗', user.value.id)
}
</script>
<template>
<div class="user-page">
<UserCard
:user="user"
@edit-user="handleEditUser"
/>
</div>
</template>优势:
- 高复用性:只要传入符合结构的
userprops,就能在任何地方使用; - 易测试:组件只依赖 props,不用模拟请求或状态;
- 职责明确:父组件管逻辑,子组件管展示,分工清晰。
反例(不要做的事):
<!-- 错误示例:组件自己发请求 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchUser } from '@/api/user'
const props = defineProps<{ userId: number }>()
const user = ref(null)
// 组件自己发请求——违反谦逊原则
onMounted(async () => {
user.value = await fetchUser(props.userId)
})
</script>4. 提取条件逻辑:复杂条件分支,拆成组件更清晰
痛点:
模板里有复杂的v-if/v-else-if/v-else,比如:
<template>
<div v-if="status === 'loading'">加载中...</div>
<div v-else-if="status === 'error'">{{ errorMessage }}</div>
<div v-else-if="status === 'empty'">暂无数据</div>
<div v-else>{{ data }}</div>
</template>这样的模板可读性差,修改条件要动很多地方。
解法:
把每个条件分支拆成单独组件,用component动态渲染。核心思路是:
- 用
computed根据状态计算要渲染的组件; - 每个条件对应一个组件,职责单一。
代码示例(状态提示组件):
<!-- components/StatusIndicator.vue -->
<script setup lang="ts">
import Loading from './Loading.vue'
import Error from './Error.vue'
import Empty from './Empty.vue'
import Content from './Content.vue'
import { computed } from 'vue'
const props = defineProps<{
status: 'loading' | 'error' | 'empty' | 'success'
data?: any
errorMessage?: string
}>()
// 根据状态计算要渲染的组件
const currentComponent = computed(() => {
switch (props.status) {
case 'loading':
return Loading
case 'error':
return Error
case 'empty':
return Empty
case 'success':
return Content
default:
return Loading
}
})
</script>
<template>
<component
:is="currentComponent"
:data="data"
:error-message="errorMessage"
/>
</template>使用方式:
<template>
<StatusIndicator
:status="fetchStatus"
:data="data"
:error-message="errorMessage"
/>
</template>优势:
- 模板更简洁:不用写冗长的条件分支;
- 可维护性高:修改某个状态的 UI,只需要改对应的组件;
- 可复用性高:状态提示组件可以在整个项目中使用。
注意事项:
- 不要过度拆分:如果条件分支很简单(比如只有两个分支),不用拆成组件;
- 组件命名要清晰:比如
Loading、Error,一看就知道是做什么的。
5. 提取可组合函数:即使只⽤一次,也要拆出来
痛点:
组件里有一段逻辑(比如计数器、表单验证),虽然只在一个组件里用,但逻辑很复杂,堆在<script setup>里影响可读性。
解法:
把逻辑提取到可组合函数里——即使只⽤一次。核心思路是:
- 可组合函数封装状态+逻辑;
- 组件只需要调用可组合函数,渲染 UI。
代码示例(计数器逻辑):
// composables/useCounter.ts
import { ref, watch } from 'vue'
export function useCounter(initialValue = 0, step = 1) {
const count = ref(initialValue)
const increment = () => {
count.value += step
}
const decrement = () => {
count.value -= step
}
const reset = () => {
count.value = initialValue
}
// 监听count变化,做日志(示例逻辑)
watch(count, (newVal, oldVal) => {
console.log(`计数器从${oldVal}变成${newVal}`)
})
return {
count,
increment,
decrement,
reset,
}
}组件中使用:
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
// 调用可组合函数,获取状态和方法
const { count, increment, decrement, reset } = useCounter(0, 2)
</script>
<template>
<div class="counter">
<button @click="decrement">-</button>
<span class="count">{{ count }}</span>
<button @click="increment">+</button>
<button
@click="reset"
class="reset-btn"
>
重置
</button>
</div>
</template>优势:
- 逻辑分离:组件里只有 UI 渲染,逻辑在可组合函数里;
- 易扩展:如果以后需要给计数器加“最大值限制”,只需要修改可组合函数;
- 可测试:可组合函数可以单独测试,不用模拟组件。
注意事项:
- 可组合函数命名要清晰:用
use前缀(比如useCounter),一看就知道是可组合函数; - 不要过度封装:如果逻辑只有 1-2 行,不用拆成可组合函数。
6. 列表组件模式:把 v-for 拆成组件,告别长列表模板
痛点:
组件里有一个长列表,v-for循环里写了很多 UI 代码,模板变得冗长:
<template>
<div class="task-list">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
>
<input
type="checkbox"
v-model="task.completed"
/>
<span :class="{ completed: task.completed }">{{ task.title }}</span>
<button @click="deleteTask(task.id)">删除</button>
</div>
</div>
</template>解法:
把列表项拆成单独组件,父组件只做v-for循环。核心思路是:
- 父组件:循环渲染列表项组件,传递数据;
- 子组件:接收列表项数据,渲染 UI,传递事件。
代码示例(任务列表):
<!-- components/TaskItem.vue -->
<template>
<div class="task-item">
<input
type="checkbox"
v-model="task.completed"
@change="$emit('task-update', task)"
/>
<span :class="{ completed: task.completed }">{{ task.title }}</span>
<button @click="$emit('task-delete', task.id)">删除</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
task: {
id: number
title: string
completed: boolean
}
}>()
defineEmits<{
(e: 'task-update', task: typeof props.task): void
(e: 'task-delete', taskId: number): void
}>()
</script>父组件使用:
<template>
<div class="task-list">
<TaskItem
v-for="task in tasks"
:key="task.id"
:task="task"
@task-update="updateTask"
@task-delete="deleteTask"
/>
</div>
</template>优势:
- 模板更清晰:父组件只负责循环,子组件负责渲染列表项;
- 可复用:列表项组件可以在其他列表中使用;
- 易修改:修改列表项的 UI,只需要改子组件。
注意事项:
- 列表项组件要“谦逊”:只做展示和传事件,不处理逻辑;
key要唯一:用列表项的id而不是索引,避免渲染错误。
7. 保留对象模式:传递整个对象,简化组件调用
痛点:
组件需要多个相关联的 props时,调用会变得非常繁琐。比如一个展示用户信息的组件,需要接收name、age、address、email四个 props:
<template>
<UserInfo
:name="user.name"
:age="user.age"
:address="user.address"
:email="user.email"
/>
</template>如果以后需要新增phone字段,不仅要修改UserInfo组件的 props 定义,还要修改所有调用UserInfo的地方——这会带来大量的重复工作。
解法:
传递整个对象而非零散的 props。核心思路是:
- 将相关联的数据封装成一个对象(比如
user); - 组件接收这个对象作为 props,内部解构使用;
- 保持对象结构的稳定性,避免频繁修改组件接口。
代码示例(用户信息组件):
首先,用 TypeScript 定义User接口,明确对象结构:
// types/user.ts
export interface User {
id: number
name: string
age: number
address: string
email: string
phone?: string // 可选字段,未来可扩展
}然后,UserInfo组件接收整个User对象:
<!-- components/UserInfo.vue -->
<template>
<div class="user-info">
<h3>{{ user.name }}</h3>
<p>年龄:{{ user.age }}</p>
<p>地址:{{ user.address }}</p>
<p>邮箱:{{ user.email }}</p>
<p v-if="user.phone">电话:{{ user.phone }}</p>
</div>
</template>
<script setup lang="ts">
import type { User } from '@/types/user'
defineProps<{
user: User
}>()
</script>父组件调用:
只需传递整个user对象,无需拆分字段:
<template>
<UserInfo :user="currentUser" />
</template>优势:
- 简化调用:减少 props 数量,避免重复传递多个字段;
- 扩展性好:新增字段(比如
phone)时,只需扩展User接口,无需修改组件调用处; - 保持数据整体性:相关联的数据封装成对象,更符合真实业务逻辑(比如用户信息本来就是一个整体)。
注意事项:
- 对象结构要稳定:如果对象结构频繁变化(比如字段经常增减),这种模式的优势会降低;
- 明确对象类型:用 TypeScript 接口定义对象结构,避免组件接收不符合预期的对象;
- 不要传递过大的对象:只传递组件需要的字段,避免传递整个“大对象”(比如包含无关字段的全局状态)。
8. 控制器组件模式:连接 UI 与逻辑的“中间层”
痛点:
组件如果同时处理UI 渲染和业务逻辑(比如请求数据、修改状态),会变得“职责不清”。比如一个任务列表组件:
<!-- 错误示例:组件既做UI又做逻辑 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchTasks, deleteTask } from '@/api/tasks'
const tasks = ref([])
const loading = ref(false)
// 组件自己发请求——职责不清
onMounted(async () => {
loading.value = true
tasks.value = await fetchTasks()
loading.value = false
})
// 组件自己处理删除逻辑——职责不清
const handleDelete = async (taskId: number) => {
await deleteTask(taskId)
tasks.value = tasks.value.filter((task) => task.id !== taskId)
}
</script>这样的组件难以复用(逻辑和 UI 绑定),也难以测试(需要模拟请求和状态)。
解法:
用控制器组件(Controller Component)分离逻辑与 UI。核心分工:
- 控制器组件:负责管理状态、调用 API、处理业务逻辑(依赖可组合函数);
- UI 组件(谦逊组件):负责渲染 UI、传递事件(不处理逻辑)。
代码示例(任务管理):
首先,用可组合函数封装业务逻辑:
// composables/useTasks.ts
import { ref, onMounted } from 'vue'
import { fetchTasks, deleteTask as apiDeleteTask } from '@/api/tasks'
import type { Task } from '@/types/task'
export function useTasks() {
const tasks = ref<Task[]>([])
const loading = ref(false)
// 加载任务逻辑
const loadTasks = async () => {
loading.value = true
tasks.value = await fetchTasks()
loading.value = false
}
// 删除任务逻辑
const deleteTask = async (taskId: number) => {
await apiDeleteTask(taskId)
tasks.value = tasks.value.filter((task) => task.id !== taskId)
}
// 初始化加载
onMounted(() => loadTasks())
return {
tasks,
loading,
loadTasks,
deleteTask,
}
}然后,控制器组件(TaskController.vue)使用可组合函数,协调 UI 组件:
<!-- components/TaskController.vue -->
<script setup lang="ts">
import { useTasks } from '@/composables/useTasks'
import TaskInput from './TaskInput.vue'
import TaskList from './TaskList.vue'
const { tasks, loading, deleteTask } = useTasks()
</script>
<template>
<div class="task-controller">
<TaskInput @add-task="loadTasks" />
<div v-if="loading">加载中...</div>
<TaskList
:tasks="tasks"
@delete-task="deleteTask"
/>
</div>
</template>最后,UI 组件(TaskList.vue)保持谦逊:
<!-- components/TaskList.vue -->
<template>
<div class="task-list">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
>
{{ task.title }}
<button @click="$emit('delete-task', task.id)">删除</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Task } from '@/types/task'
defineProps<{ tasks: Task[] }>()
defineEmits<{ (e: 'delete-task', taskId: number): void }>()
</script>优势:
- 职责清晰:控制器组件管逻辑,UI 组件管展示,分工明确;
- 高可测试性:可组合函数和控制器组件可以单独测试(不用模拟 UI);
- 高复用性:UI 组件(比如
TaskList)可以在其他控制器组件中使用。
注意事项:
- 控制器组件不要做 UI:控制器组件只负责协调逻辑,不要渲染复杂的 UI(比如只做加载状态和组件组合);
- UI 组件要保持谦逊:UI 组件只接收 props 和传递事件,不处理任何逻辑;
- 避免控制器组件臃肿:如果逻辑复杂,可以拆分成多个可组合函数(比如
useTaskLoading、useTaskDeletion)。
9. 策略模式:动态切换组件的“开关”
痛点:
根据不同的条件展示不同的组件时,用v-if/v-else会让模板变得混乱。比如支付方式选择:
<template>
<div v-if="paymentMethod === 'alipay'">支付宝支付组件</div>
<div v-else-if="paymentMethod === 'wechat'">微信支付组件</div>
<div v-else-if="paymentMethod === 'unionpay'">银联支付组件</div>
<div v-else>请选择支付方式</div>
</template>如果新增支付方式(比如“ PayPal”),需要修改模板的条件分支,维护成本高。
解法:
用策略模式动态切换组件。核心思路是:
- 定义“策略映射”(支付方式 → 组件的映射);
- 根据条件(比如
paymentMethod)选择对应的组件; - 用
component:is动态渲染组件。
代码示例(支付方式切换):
首先,定义支付方式的策略映射:
// utils/paymentStrategies.ts
import AlipayPayment from '@/components/AlipayPayment.vue'
import WechatPayment from '@/components/WechatPayment.vue'
import UnionpayPayment from '@/components/UnionpayPayment.vue'
import type { Component } from 'vue'
export const paymentStrategies = {
alipay: AlipayPayment,
wechat: WechatPayment,
unionpay: UnionpayPayment,
} as const
export type PaymentMethod = keyof typeof paymentStrategies然后,在组件中动态切换:
<!-- components/PaymentSelector.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
paymentStrategies,
type PaymentMethod,
} from '@/utils/paymentStrategies'
const paymentMethod = ref<PaymentMethod>('alipay')
const currentComponent = computed(() => {
return paymentStrategies[paymentMethod.value]
})
</script>
<template>
<div class="payment-selector">
<button
v-for="method in Object.keys(paymentStrategies)"
:key="method"
@click="paymentMethod = method as PaymentMethod"
:class="{ active: paymentMethod === method }"
>
{{ method }}
</button>
<component :is="currentComponent" />
</div>
</template>优势:
- 简化模板:避免复杂的条件分支,用“策略映射”集中管理组件切换;
- 扩展性好:新增支付方式只需修改
paymentStrategies,无需修改模板; - 可读性高:策略逻辑集中在一处,便于理解和维护。
注意事项:
- 组件接口要统一:动态切换的组件要接收相同的 props 和传递相同的事件(比如都接收
amountprops,都传递payment-success事件); - 用 TypeScript 约束策略:用
as const和keyof确保策略映射的键和值类型正确; - 避免过多策略:如果策略(支付方式)过多(比如超过 10 种),要考虑是否需要拆分策略映射。
10. 隐藏组件模式:拆分“多功能组件”为“专注组件”
痛点:
有些组件试图“包打天下”,通过 props 控制多种功能。比如一个DataDisplay组件,既可以展示图表又可以展示表格:
<!-- 错误示例:多功能组件 -->
<script setup lang="ts">
defineProps<{
type: 'chart' | 'table'
data: any[]
options?: any
}>()
</script>
<template>
<div v-if="type === 'chart'">
<!-- 图表渲染逻辑 -->
</div>
<div v-else-if="type === 'table'">
<!-- 表格渲染逻辑 -->
</div>
</template>这样的组件会越来越臃肿(需要处理两种不同的逻辑),而且难以复用(比如只需要图表时,还是要传递type="chart")。
解法:
拆分多功能组件为专注组件。核心思路是:
- 每个组件只做一件事(比如
Chart组件只展示图表,Table组件只展示表格); - 父组件根据需要选择使用哪个组件,而不是用 props 控制。
代码示例(数据展示):
拆分后的Chart组件:
<!-- components/Chart.vue -->
<template>
<div class="chart">
<!-- 图表渲染逻辑 -->
</div>
</template>
<script setup lang="ts">
defineProps<{
data: any[]
options?: any
}>()
</script>拆分后的Table组件:
<!-- components/Table.vue -->
<template>
<div class="table">
<!-- 表格渲染逻辑 -->
</div>
</template>
<script setup lang="ts">
defineProps<{
data: any[]
options?: any
}>()
</script>父组件使用:
<template>
<div class="data-page">
<Chart
:data="data"
:options="chartOptions"
/>
<Table
:data="data"
:options="tableOptions"
/>
</div>
</template>优势:
- 职责单一:每个组件只做一件事,逻辑更清晰;
- 高复用性:
Chart和Table可以在其他地方独立使用; - 易维护:修改图表逻辑只需改
Chart组件,不影响表格逻辑。
注意事项:
- 组件命名要清晰:比如
Chart、Table,一看就知道功能; - 不要过度拆分:如果组件的“多功能”很简单(比如一个按钮有两种状态),不用拆分成多个组件;
- 保持接口一致:拆分后的组件尽量接收相同的 props(比如都接收
data和options),便于父组件调用。
11. 内部交易模式:解决“父子组件过度耦合”
痛点:
有些子组件完全依赖父组件,没有独立复用的价值。比如一个UserForm组件,接收user props 和传递user-update事件,但user的结构完全由父组件控制,子组件无法独立使用:
<!-- 子组件:完全依赖父组件 -->
<script setup lang="ts">
defineProps<{
user: {
name: string
email: string
}
}>()
defineEmits<{
(e: 'user-update', user: typeof props.user): void
}>()
</script>这样的子组件和父组件“过度耦合”,拆分成独立组件反而增加了维护成本。
解法:
如果子组件没有复用价值,可以将其内联到父组件中,或者合并成一个组件。核心思路是:
- 避免“为了拆分而拆分”;
- 如果组件之间的耦合度极高,合并反而更有利于维护。
代码示例(合并组件):
之前的UserForm子组件和父组件合并后:
<!-- 合并后的组件:不再拆分 -->
<script setup lang="ts">
import { ref } from 'vue'
import type { User } from '@/types/user'
const user = ref<User>({ name: '', email: '' })
const handleUpdate = () => {
// 直接处理用户更新逻辑,不用传递事件
console.log('更新用户:', user.value)
}
</script>
<template>
<div class="user-form">
<input
v-model="user.name"
placeholder="姓名"
/>
<input
v-model="user.email"
placeholder="邮箱"
/>
<button @click="handleUpdate">更新</button>
</div>
</template>优势:
- 减少组件数量:避免过度拆分导致的“组件碎片化”;
- 提高连贯性:相关逻辑放在一起,便于理解和修改;
- 简化代码:不用传递 props 和事件,减少“中间层”。
注意事项:
- 只适用于无复用价值的组件:如果组件有复用价值(比如
Button、Input),不要合并; - 避免回到“长组件”:合并后的组件如果变得很长,要考虑拆分成“专注组件”(参考第 12 个模式);
- 权衡耦合与维护成本:如果合并后的组件维护成本低于拆分后的成本,才选择合并。
12. 长组件模式:分解“冗长组件”为“自文档化组件”
痛点:
有些组件的代码长达几百行,包含多个功能模块(比如用户详情页包含“基本信息”“地址管理”“订单列表”三个模块)。这样的“长组件”会导致:
- 难以理解:某段代码属于哪个模块需要翻半天;
- 难以修改:改“订单列表”模块要先找到对应的代码段;
- 难以协作:多个开发者修改同一个组件容易冲突(比如同时改“基本信息”和“地址管理”)。
解法:
将长组件分解为多个“自文档化”子组件——每个子组件对应一个功能模块,命名要清晰(比如UserBasicInfo对应“基本信息”)。核心思路是:
- 模块拆分:把长组件的功能模块拆成独立子组件;
- 自文档化:子组件命名反映其功能(不用“Part1”“SectionA”);
- 父组件组合:父组件只负责组合子组件,不处理具体逻辑。
代码示例(用户详情页分解):
① 分解前的长组件(反例)
<!-- 长组件:包含多个模块,逻辑混杂 -->
<script setup lang="ts">
import { ref } from 'vue'
import type { User, Order } from '@/types'
const user = ref<User>({
name: '张三',
age: 25,
address: '北京市朝阳区',
orders: [
{ id: 1, title: '订单1' },
{ id: 2, title: '订单2' },
],
})
const updateAddress = (newAddress: string) => {
user.value.address = newAddress
}
</script>
<template>
<div class="user-details">
<!-- 1. 基本信息模块 -->
<div class="basic-info">
<h3>基本信息</h3>
<p>姓名:{{ user.name }}</p>
<p>年龄:{{ user.age }}</p>
</div>
<!-- 2. 地址管理模块 -->
<div class="address">
<h3>地址管理</h3>
<input
v-model="user.address"
placeholder="地址"
/>
<button @click="updateAddress(user.address)">保存地址</button>
</div>
<!-- 3. 订单列表模块 -->
<div class="order-list">
<h3>订单列表</h3>
<div
v-for="order in user.orders"
:key="order.id"
>
{{ order.title }}
</div>
</div>
</div>
</template>② 分解后的子组件(自文档化)
子组件 1:基本信息模块(UserBasicInfo.vue)
<template>
<div class="basic-info">
<h3>基本信息</h3>
<p>姓名:{{ user.name }}</p>
<p>年龄:{{ user.age }}</p>
</div>
</template>
<script setup lang="ts">
import type { User } from '@/types'
defineProps<{
user: Pick<User, 'name' | 'age'> // 只接收需要的字段
}>()
</script>子组件 2:地址管理模块(UserAddress.vue)
<template>
<div class="address">
<h3>地址管理</h3>
<input
v-model="user.address"
placeholder="地址"
/>
<button @click="$emit('update-address', user.address)">保存地址</button>
</div>
</template>
<script setup lang="ts">
import type { User } from '@/types'
defineProps<{
user: Pick<User, 'address'>
}>()
defineEmits<{
(e: 'update-address', address: string): void
}>()
</script>子组件 3:订单列表模块(UserOrderList.vue)
<template>
<div class="order-list">
<h3>订单列表</h3>
<div
v-for="order in orders"
:key="order.id"
>
{{ order.title }}
</div>
</div>
</template>
<script setup lang="ts">
import type { Order } from '@/types'
defineProps<{
orders: Order[]
}>()
</script>③ 分解后的父组件(组合子组件)
<!-- 父组件:只组合子组件,不处理具体逻辑 -->
<script setup lang="ts">
import { ref } from 'vue'
import type { User, Order } from '@/types'
import UserBasicInfo from './UserBasicInfo.vue'
import UserAddress from './UserAddress.vue'
import UserOrderList from './UserOrderList.vue'
const user = ref<User>({
name: '张三',
age: 25,
address: '北京市朝阳区',
orders: [
{ id: 1, title: '订单1' },
{ id: 2, title: '订单2' },
],
})
const handleUpdateAddress = (newAddress: string) => {
user.value.address = newAddress
}
</script>
<template>
<div class="user-details">
<UserBasicInfo :user="user" />
<UserAddress
:user="user"
@update-address="handleUpdateAddress"
/>
<UserOrderList :orders="user.orders" />
</div>
</template>优势:
- 结构清晰:每个子组件对应一个功能模块,一看就知道作用;
- 易修改:改“订单列表”只需改
UserOrderList组件,不用翻长组件; - 易协作:多个开发者可以同时修改不同的子组件,减少冲突;
- 自文档化:子组件命名反映功能(比如
UserBasicInfo→ 基本信息),代码可读性高。
注意事项:
- 子组件要保持专注:每个子组件只处理一个功能模块(比如
UserAddress只处理地址管理); - 父组件不处理逻辑:父组件只组合子组件,具体逻辑由子组件或可组合函数处理;
- 避免过度拆分:如果功能模块很简单(比如一个标题),不用拆分成子组件;
- 命名要清晰:子组件命名要反映其功能(不用“Part1”“SectionA”)。
三、总结:Vue3 设计模式的核心价值
Vue3 的设计模式不是“教条”,而是解决共性问题的“工具箱”——它的核心价值是:
- 让代码更可维护:通过职责分离、模块拆分,减少修改代码的成本;
- 让代码更可扩展:通过可组合函数、策略模式,轻松应对需求变化;
- 让代码更易协作:通过自文档化组件、清晰的职责分工,提升团队效率。
最后给你的建议:
- 不要生搬硬套:根据项目实际情况选择设计模式(比如小型项目不用 Pinia,用“数据存储模式”即可);
- 先理解再使用:搞懂设计模式的“痛点”和“解法”,再用到项目中;
- 持续重构:代码写完后,定期检查是否有“长组件”“万能组件”,逐步优化。
希望这 12 个设计模式能帮你从“写代码”升级到“设计代码”,让你的 Vue3 项目更健壮、更易维护!
如果觉得有用,点个赞支持一下~ 😊
参考
在原文基础上,敲了下代码加了点自己的理解 ~
- 本文链接:https://fridolph.top/posts/2025-08-07__vue-design
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。