最近接手了一个老模块的重构任务,代码里到处都是硬编码的中文字符串,配置散落在各个组件里,状态映射逻辑重复了好几遍。你懂的,就是那种"能跑但看着难受"的代码。
趁着这次重构,我把一些设计模式用在了实际业务里。不是为了炫技,纯粹是解决问题。写完之后觉得挺有收获,分享出来聊聊。
背景:一个典型的后台管理模块
项目是 Vue 3 + TypeScript + Element Plus 的技术栈。这个模块大概长这样:
- 顶部有一排流程统计卡片
- 中间是一个多 Tab 切换的数据表格,每个 Tab 对应不同的列配置
- 底部是图表统计区域
- 还要支持中英文切换
代码的问题很明显:
typescript
// 散落在组件里的配置
const columns = [
{ key: 'orderNo', label: '订单号', minWidth: '140px' },
{ key: 'status', label: '状态', minWidth: '100px' },
// ...
]
const STATUS_MAP = {
pending: { text: '待处理', type: 'warning' },
done: { text: '已完成', type: 'success' },
}
问题来了:
- label 写死中文,国际化怎么办?
- 配置散落各处,改一个地方要找半天
- 状态映射重复,composable 里有一份,组件里又有一份
方案一:工厂函数模式
最直接的问题是国际化。一开始我想用 computed 包一层,但很快发现不对劲------配置对象在模块顶层定义,那时候 i18n 实例还没初始化呢。
解法是把静态配置改成工厂函数:
typescript
// constants/index.ts
import type { ComposerTranslation } from 'vue-i18n'
// 静态配置:路由、图标这些不需要翻译的
export const ROUTE_CONFIG = {
detail: '/order/detail',
edit: '/order/edit',
} as const
// 工厂函数:需要翻译的配置
export function createStatusConfig(t: ComposerTranslation) {
return {
pending: { text: t('status.pending'), type: 'warning' },
processing: { text: t('status.processing'), type: 'info' },
done: { text: t('status.done'), type: 'success' },
failed: { text: t('status.failed'), type: 'danger' },
}
}
export function createTableColumns(t: ComposerTranslation) {
return [
{ key: 'orderNo', label: t('columns.orderNo'), minWidth: '140px' },
{ key: 'status', label: t('columns.status'), minWidth: '100px' },
{ key: 'createTime', label: t('columns.createTime'), minWidth: '160px' },
]
}
使用的时候:
typescript
// 组件或 composable 里
const { t } = useI18n()
const statusConfig = createStatusConfig(t)
const columns = computed(() => createTableColumns(t))
为什么这么做?
说实话一开始我也觉得麻烦,直接在模板里写 {{ t('xxx') }} 不香吗?
但仔细想想,配置集中管理的好处太多了:
- 改列配置不用翻组件代码
- 多个地方用同一套状态映射,改一处就够了
- 类型提示友好,拼错 key 立刻报错
而且工厂函数还有个好处------可以保留静态版本做向后兼容:
typescript
/** @deprecated 请使用 createStatusConfig(t) */
export const STATUS_CONFIG = {
pending: { text: '待处理', type: 'warning' },
// ...
}
老代码可以慢慢迁移,不用一口气改完。
方案二:策略模式处理状态映射
业务里有个场景:后端返回一个 timeStatus 字段,可能是 'urgent'、'normal'、'overdue',前端要映射成不同的样式和文案。
最初的写法大概是这样:
typescript
function getStatusStyle(status: string) {
if (status === 'urgent') return 'warning'
if (status === 'normal') return 'success'
if (status === 'overdue') return 'disabled'
return 'info'
}
function getStatusText(status: string) {
if (status === 'urgent') return '即将超时'
if (status === 'normal') return '正常'
if (status === 'overdue') return '已超时'
return '-'
}
两个函数里写了两遍映射逻辑,而且新增状态要改两个地方。
用策略模式重构一下:
typescript
// 类型定义
type TimeStatus = 'urgent' | 'normal' | 'overdue'
interface StatusStrategy {
style: 'warning' | 'success' | 'disabled' | 'info'
i18nKey: string
}
// 策略映射表
const TIME_STATUS_STRATEGY: Record<TimeStatus, StatusStrategy> = {
urgent: { style: 'warning', i18nKey: 'status.urgent' },
normal: { style: 'success', i18nKey: 'status.normal' },
overdue: { style: 'disabled', i18nKey: 'status.overdue' },
}
// 统一的获取方法
function getTimeStatus(status: string): StatusStrategy {
return TIME_STATUS_STRATEGY[status as TimeStatus] ?? {
style: 'info',
i18nKey: 'status.unknown'
}
}
现在新增一个状态,只需要在映射表里加一行。
这个模式还有个妙用------处理多 Tab 场景:
typescript
type TabType = 'pending' | 'processing' | 'completed'
// 每个 Tab 对应不同的列配置
const TAB_COLUMNS_STRATEGY: Record<TabType, () => TableColumn[]> = {
pending: () => createPendingColumns(t),
processing: () => createProcessingColumns(t),
completed: () => createCompletedColumns(t),
}
// 使用
const columns = computed(() => {
const strategy = TAB_COLUMNS_STRATEGY[activeTab.value]
return strategy ? strategy() : []
})
比一堆 if-else 清爽多了。
方案三:Composables 的组合复用
Vue 3 的 Composition API 本身就是一种组合模式的体现。但我发现很多人写 composable 的时候,要么写成一个巨大的函数,要么拆得太碎不好用。
这次我的做法是按职责拆分,按场景组合:
typescript
// composables/useTableConfig.ts
// 只负责表格配置相关
export function useTableConfig(tabRef: Ref<TabType>) {
const { t } = useI18n()
const columns = computed(() => {
const columnsMap = createTabColumnsMap(t)
return columnsMap[tabRef.value] ?? []
})
return { columns }
}
// composables/useStatusMapper.ts
// 只负责状态映射
export function useStatusMapper() {
const { t } = useI18n()
function getStatusDisplay(status: string) {
const strategy = TIME_STATUS_STRATEGY[status]
return {
text: strategy ? t(strategy.i18nKey) : '-',
type: strategy?.style ?? 'info',
}
}
return { getStatusDisplay }
}
// composables/usePageData.ts
// 页面级 composable,组合上面的功能
export function usePageData() {
const activeTab = ref<TabType>('pending')
// 组合其他 composables
const { columns } = useTableConfig(activeTab)
const { getStatusDisplay } = useStatusMapper()
// 页面特有的逻辑
const tableData = ref([])
async function loadData() { /* ... */ }
return {
activeTab,
columns,
tableData,
getStatusDisplay,
loadData,
}
}
组合的原则是什么?
我的经验是:
- 基础 composable:单一职责,不依赖具体业务
- 业务 composable:组合基础能力,添加业务逻辑
- 页面 composable:组装一切,对接组件
这样拆的好处是,基础 composable 可以在其他模块复用,而业务逻辑变化只影响上层。
踩过的坑
说几个实际遇到的问题:
1. 工厂函数调用时机
一开始我在模块顶层调用工厂函数:
typescript
// ❌ 错误:模块加载时 i18n 还没准备好
const config = createStatusConfig(t)
正确做法是在 setup 里调用,或者用 computed 包装:
typescript
// ✅ 正确
const config = computed(() => createStatusConfig(t))
2. 类型推导丢失
策略表如果用 Record<string, xxx> 定义,key 的类型提示就没了:
typescript
// ❌ key 是 string,没有提示
const map: Record<string, Strategy> = { ... }
// ✅ 用 as const 或明确 key 类型
const map: Record<'a' | 'b' | 'c', Strategy> = { ... }
3. 向后兼容别忘了
重构不是一锤子买卖。保留老的导出,加上 @deprecated 注释,让调用方有时间迁移:
typescript
/** @deprecated 请使用 createXxx() */
export const OLD_CONFIG = { ... }
写在最后
设计模式这东西,学的时候觉得玄乎,用的时候发现也就那样------本质上是前人踩坑踩出来的套路。
不用刻意追求"我要用 xx 模式",而是遇到问题的时候想想:
- 这块逻辑重复了,能不能抽出来?
- 这个 if-else 越来越长,能不能用映射表?
- 这个配置老是改,能不能集中管理?
想清楚问题,模式自然就出来了。
希望对你有帮助。有问题欢迎评论区交流。
技术栈:Vue 3.5 + TypeScript 5 + vue-i18n