从 5 个 Hooks 到注册表模式:Vue 3 复杂详情页的架构演进与原则沉淀
一个"拆分得很好"的组件,为什么依然难以维护?本文从一个真实的重构案例出发,剖析模块化拆分的隐性成本,并提炼出五大架构原则------它们不仅适用于 Vue,更是一套通用的前端复杂度管理方法论。
一、引言:一个"拆分得很好"的组件,为什么还要重构?
先看一段代码结构:
taskDetail/
├── index.vue
├── hooks/
│ ├── useRouteState.ts
│ ├── useTaskFetch.ts
│ ├── useBtnVisibility.ts
│ ├── useTaskActions.ts
│ └── useButtonRender.ts
乍一看,这已经是一个相当规范的 Vue 3 Composition API 实践了:
- 组件本身几乎不包含业务逻辑,所有逻辑下沉到 5 个 hooks
- 每个 hook 职责明确:路由解析、数据请求、权限判断、操作管理、按钮渲染
- 弹窗统一管理、按钮动态渲染、Tab 懒加载策略------该考虑的都考虑了
团队在初次重构时,也正是带着这样的自信交付的。然而,随后的迭代暴露出一些令人不安的信号:
- 新增一个"转派"按钮,需要修改 3 个文件
useTaskActions的submitForm函数膨胀到 200+ 行,if/else 嵌套 4 层useTaskFetch和useButtonRender之间出现了循环依赖,不得不用一个延迟绑定的工厂函数来"打补丁"- 新加入的同事对着 5 个 hook 的参数传递链条发懵,改一个字段要追溯 4 层调用
问题出在哪里?
表面上看,代码已经"拆分"了。但拆分的粒度、方向、以及模块间的依赖关系,决定了这套架构是真正降低了复杂度,还是仅仅把复杂度从一个文件转移到了另一个文件。
本文将复盘这次重构的完整历程:先对旧架构做一次彻底的"体检",诊断出五类结构性成本;然后逐一拆解新架构的五大核心原则;最后量化对比重构前后的收益,并将这套方法论迁移到 React 等其他框架中。
二、旧架构深度体检:五个隐蔽的结构性成本
让我们以一份真实的架构分析文档为样本,逐层解剖旧架构的问题。
2.1 Hooks 调用链的循环依赖
首先看 Hooks 之间的依赖关系: useRouteState() → useTaskFetch() → useBtnVisibility() / useTaskActions()
↓
useButtonRender()
↓
getButtonHelpers() ← useTaskFetch()
注意最后一步:useTaskFetch 依赖 useButtonRender 产出的 getButtonHelpers,而 useButtonRender 又依赖 useTaskFetch 产出的 detailData。这是一个典型的循环依赖。
旧架构的解法是引入一个延迟绑定的工厂函数:
ts
// useTaskFetch.ts 中的"补丁"
getButtonHelpers: () => ({ getBtnStatus, getBtnText, extendedButtonTools })
这段代码的意思是:"我现在还不知道你要什么,先给你一个回调,等你需要的时候再调用我"。它在功能上确实解了循环依赖的"死锁",但也带来了两个问题:
- 执行时机不确定:调用方需要知道何时该调用这个工厂函数,何时不该
- 调试成本高:追踪一个按钮的渲染逻辑,需要在两个 hook 之间反复跳转,且调用栈被工厂函数打断
循环依赖的本质是模块边界的划分出了问题。当 A 依赖 B,B 又依赖 A 时,意味着它们本应属于同一个内聚单元,却被强行分开了。
2.2 八个弹窗的静态声明:模板臃肿与横向散点
打开 index.vue 的模板部分,你会看到这样的结构
vue
<template>
<DetailFormContainer>
<!-- ... -->
<PauseDig v-model="pauseVisible" />
<AbortedDig v-model="abortedVisible" />
<CompletedDig v-model="completedVisible" />
<RejectDig v-model="rejectVisible" />
<RefuseDig v-model="refuseVisible" />
<PostponeDig v-model="postponeVisible" />
<ReassignDig v-model="reassignVisible" />
<DecomposeDig v-model="decomposeVisible" />
</DetailFormContainer>
</template>
8 个弹窗组件,每一个都需要:
- 在模板中声明一行
<XxxDig v-model="xxxVisible" /> - 在
useTaskActions中定义对应的ref状态 - 在按钮点击时调用对应的开关函数
新增一个弹窗操作,意味着要同时修改模板、Hook、按钮配置三处。这种"横向散点"的修改模式,随着操作种类的增加,维护成本线性上升。
2.3 按钮可见性规则:与响应式系统的强耦合
useBtnVisibility 的核心逻辑大致是这样的
ts
export function useBtnVisibility(detailData, taskStatus, userRole) {
const pauseVisible = computed(() => {
return taskStatus.value === 'running' && userRole.value === 'owner'
})
const cancelVisible = computed(() => {
return ['pending', 'running'].includes(taskStatus.value)
})
// ... 更多 computed
}
这段代码有两个问题:
- 无法独立测试 :要验证
pauseVisible的逻辑,你必须构造一个 Vue 响应式上下文,mockcomputed、ref等 API - 规则散落在 computed 回调中:当规则变复杂时(比如加上"是否已关联项目"、"是否在特定时间段内"),computed 内部的逻辑会迅速膨胀,且难以复用
业务规则本质上是纯逻辑:给定输入,返回输出。把它和响应式系统绑定,是增加了耦合而非减少了复杂度。
2.4 跨 Hook 状态共享的隐式依赖
旧架构中,taskStatus、activeId、detailData 等核心状态被多个 Hook 读写:
useTaskFetch创建并写入taskStatususeBtnVisibility读取taskStatus来计算可见性useTaskActions读取并可能修改detailData(如乐观更新)useButtonRender读取taskStatus来决定按钮样式
这种"多个 Hook 共享一个 ref"的模式,看起来是利用了 Vue 的响应式能力,实际上制造了一个隐式的全局状态总线 。当你需要修改 taskStatus 的更新逻辑时,你无法确定有多少个 Hook 依赖于它的变化时机和值------只能全局搜索,逐个排查。
2.5 "拆分"的幻觉:修改一个操作要动多少文件?
最后一个诊断,是一个简单的测试:新增一个"申请延期"操作,需要改哪些文件?
在旧架构中的答案:
useTaskActions.ts:添加postponeVisibleref 和开关函数useBtnVisibility.ts:添加postponeVisiblecomputed 规则useButtonRender.ts:在按钮数组中添加配置项index.vue模板:添加<PostponeDig v-model="postponeVisible" />- 可能还有
types/index.ts:添加类型定义
5 个文件。这不是模块化,这是把同一个功能的代码"撒"到了不同目录里。
三、重构目标与架构设计原则
基于上述诊断,我们明确了重构的核心目标:
不是把代码拆得更细,而是让"新增一个操作"的变更范围收敛到最小。
具体来说,理想状态是:
- 新增一个操作:只加一个配置项 + 写一个弹窗组件
- 修改可见性规则:只改一个纯函数,且能单测
- 调整数据请求逻辑:只改 service 层,不碰组件
为了实现这个目标,新架构确立了五大原则:
| 原则 | 解决的问题 |
|---|---|
| 1. provide/inject 替代参数传递 | 跨 Hook 状态共享的隐式依赖 |
| 2. 按钮注册表模式 | 模板臃肿 + 横向散点修改 |
| 3. 可见性纯函数 | 规则与响应式的强耦合、无法单测 |
| 4. 策略模式拆分 submitForm | 巨型函数 + if/else 迷宫 |
| 5. Service 层数据转换 | 逻辑混入 Hook、复用困难 |
下文逐一展开。
四、五大核心原则的逐一拆解与实现
4.1 provide/inject 替代参数传递:收敛状态来源
旧架构的问题 :多个 Hook 各自持有或传递 taskStatus、activeId 等状态,来源不统一,依赖关系如蛛网。
新架构的解法 :在根组件 index.vue 中创建单一上下文 ,所有子组件和 Hook 通过 inject 获取,不再层层传递。
ts
// composables/useFeatureContext.ts
const CONTEXT_KEY = Symbol('featureContext')
export function useFeatureContext() {
const status = ref('')
const detailData = ref(null)
const activeId = ref('')
const ctx = {
status: readonly(status),
detailData: readonly(detailData),
activeId: readonly(activeId),
updateStatus: (newStatus: string) => { status.value = newStatus },
refreshDetail: async () => { /* ... */ }
}
provide(CONTEXT_KEY, ctx)
return ctx
}
// 任意子组件或 Hook
export function useSomeFeature() {
const ctx = inject(CONTEXT_KEY)
// 直接使用 ctx.status, ctx.refreshDetail...
}
关键变化:
- 状态有且只有一个来源(Context),修改状态的函数也收敛在 Context 内
- 使用者不需要关心状态来自哪个 Hook------它来自"上下文"
index.vue的职责从"接线板"变成"装配器":只负责 provide 和布局,通常不超过 80 行
与 Pinia 的区别 :这套模式是组件树级别的局部状态管理,而非全局 Store。对于详情页这种"随组件挂载创建、随组件卸载销毁"的场景,局部 Context 的生命周期更匹配,也避免了全局 Store 的命名冲突和清理负担。
4.2 按钮注册表模式:从"散点修改"到"配置新增"
旧架构的问题 :按钮列表在 useButtonRender 中生成,但弹窗声明散落在模板里,新增操作需修改多处。
新架构的解法 :声明式的操作注册表 + 动态弹窗宿主。
ts
// actions/registry.ts
export const actionRegistry: ActionDef[] = [
{
event: 'pause',
label: '暂停',
visible: (ctx) => isButtonVisible('pause', ctx),
dialog: () => import('./dialogs/PauseDialog.vue'),
},
{
event: 'cancel',
label: '取消',
visible: (ctx) => isButtonVisible('cancel', ctx),
dialog: () => import('./dialogs/CancelDialog.vue'),
},
{
event: 'reassign',
label: '转派',
visible: (ctx) => isButtonVisible('reassign', ctx),
dialog: () => import('./dialogs/ReassignDialog.vue'),
},
]
模板中的按钮渲染变成一行遍历:
vue
<!-- FeatureHeader.vue -->
<template>
<div class="action-bar">
<el-button
v-for="action in visibleActions"
:key="action.event"
@click="dispatch(action)"
>
{{ action.label }}
</el-button>
</div>
<ActionDialogHost :event="currentEvent" @closed="handleClosed" />
</template>
关键变化:
- 新增操作 :只需在
actionRegistry中添加一项 + 实现弹窗组件 - 弹窗动态渲染 :
ActionDialogHost根据event类型动态加载对应的弹窗组件,模板不再需要 8 个<XxxDialog>标签 - 可见性计算 :通过
visible函数声明,按钮列表自动过滤
注册表模式的本质是将"如何渲染"与"渲染什么"分离。它把散落在各处的配置信息集中到一个数据结构中,让新增操作这件事从"修改代码"变成"添加数据"。
4.3 可见性纯函数:让业务规则可测试、可独立演进
旧架构的问题 :规则写在 computed 里,与 Vue 响应式绑定,无法单独测试和复用。
新架构的解法 :将可见性规则抽成纯函数,只依赖输入参数,不依赖任何框架特性。
ts
// permissions/rules.ts
export function isButtonVisible(
event: BtnEvent,
status: string,
role: UserRole,
flags: TaskFlags
): boolean {
switch (event) {
case 'pause':
return status === 'running' && role === 'owner'
case 'cancel':
return ['pending', 'running'].includes(status)
case 'reassign':
return ['pending', 'running', 'paused'].includes(status)
&& role !== 'viewer'
// ...
}
}
Hook 只负责调用纯函数并传入响应式数据:
ts
// composables/useBtnVisibility.ts
export function useBtnVisibility() {
const ctx = inject(CONTEXT_KEY)
const getVisible = (event: BtnEvent) => {
return isButtonVisible(
event,
ctx.status.value,
ctx.role.value,
ctx.flags.value
)
}
return { getVisible }
}
**
关键收益**:
- 可单测 :
isButtonVisible是纯函数,可以写完整的单元测试覆盖所有规则组合,无需启动 Vue 环境 - 可复用 :同样的规则函数可以被注册表的
visible字段直接调用 - 规则集中:所有可见性逻辑在一个文件中,修改时不需要在多个 computed 之间跳转
这是一个重要的架构思想:业务规则不应该被框架绑架。Vue 的响应式系统是"如何把规则应用到 UI"的桥梁,而不是"规则本身"的容器。
4.4 策略模式拆分 submitForm:告别 if/else 迷宫
旧架构的问题 :一个 submitForm 函数处理创建、编辑、运行中保存等多种场景,内部 if/else 嵌套严重
ts
// 旧架构的典型形态
async function submitForm(payload) {
if (mode === 'create') {
// 20 行创建逻辑
} else if (mode === 'update') {
if (status === 'running') {
// 运行中编辑的特殊逻辑 30 行
} else {
// 普通编辑 20 行
}
} else if (mode === 'reassign') {
// ...
}
}
新架构的解法:策略表 + 独立 handler。
ts
// services/submitStrategies.ts
type SubmitHandler = (payload: Payload, ctx: Context) => Promise<Result>
export const strategies: Record<SubmitMode, SubmitHandler> = {
create: async (payload, ctx) => {
const res = await createTaskApi(payload)
ctx.refreshDetail()
return res
},
update: async (payload, ctx) => {
const res = await updateTaskApi(payload)
ctx.refreshDetail()
return res
},
runningSave: async (payload, ctx) => {
// 运行中保存的特殊逻辑
const res = await saveRunningTaskApi(payload)
ctx.updateStatus('running')
return res
},
}
// 统一入口
export async function submitForm(mode: SubmitMode, payload: Payload, ctx: Context) {
const handler = strategies[mode]
if (!handler) throw new Error(`Unknown submit mode: ${mode}`)
return handler(payload, ctx)
}
关键变化:
- 每种提交场景被隔离在独立的函数中,互不干扰
- 新增场景只需添加一个新的 key 和 handler,不需要阅读或修改已有逻辑
- 每个 handler 可以独立测试、独立优化
策略模式在这里的价值是隔离变更的影响范围 。当产品要求"运行中保存时需要额外校验 A 字段"时,你只需要修改 runningSave 这一个函数,不用担心误伤 create 或 update。
4.5 Service 层数据转换:让逻辑脱离 Vue 响应式系统
旧架构的问题:API 返回数据的格式转换逻辑散落在 Hook 内部,既难以复用,也增加了 Hook 的体积。
新架构的解法:独立的 Service 层,只包含纯函数。
ts
// services/featureService.ts
export async function fetchDetail(id: string, type: string): Promise<Detail> {
const res = await getDetailApi({ id, type })
return transformDetail(res.data)
}
export function transformDetail(raw: RawDetail): Detail {
return {
id: raw.task_id,
name: raw.task_name,
status: normalizeStatus(raw.status),
createdAt: formatDate(raw.create_time),
// ... 更多字段映射
}
}
export function transformFormData(form: FormModel): Payload {
return {
task_id: form.id,
task_name: form.name,
// 提交时的逆向转换
}
}
关键收益:
- 框架无关 :
transformDetail是纯函数,可以在任何地方调用,不依赖 Vue - 可测试:数据处理逻辑的测试成本极低
- Hook 轻量化:Hook 只负责调用 Service 和管理响应式状态,不再包含数据处理细节
这一层的意义在于明确划分"数据边界" :Vue 组件不应该知道后端返回的字段是 task_id 还是 taskId,这些脏活累活应该在进入响应式世界之前就处理完毕。
五、重构前后量化对比:从"感觉难改"到"可度量"
重构完成后,我们用新架构文档中的复杂度阈值表来对照评估:
| 指标 | 旧架构 | 新架构 | 阈值参考 |
|---|---|---|---|
index.vue 行数 |
~200 行 | ~70 行 | >100 行应拆组件 |
| 单个 Hook 最大行数 | ~460 行 (useTaskActions) |
<150 行 | >150 行应拆分 |
| Hook 参数个数 | 5-8 个 | 0 个(通过 inject) | >6 个应改用 Context |
| 按钮数量管理方式 | 手动维护数组 + 模板声明 | 注册表遍历 | >5 个按钮用注册表 |
| 弹窗管理方式 | 8 个静态标签 | 1 个动态宿主 | >3 个弹窗用宿主 |
submitForm 分支 |
if/else 嵌套 4 层 | 策略表 4 个独立函数 | >3 种场景用策略模式 |
| 新增操作改动文件数 | 5 个 | 2 个(注册表 + 弹窗组件) | --- |
核心收益:
- 变更成本大幅降低:新增操作从改 5 处变成改 2 处
- 可测试性提升:权限规则、数据转换均可脱离框架单测
- 新人上手时间缩短:清晰的模块边界,不再需要追踪参数传递链条
- 代码行数减少:通过消除重复的模板声明和参数传递代码
六、超越 Vue:这套模式在 React 中的等价实现
一个自然的问题是:这些原则是否与 Vue 强绑定?答案是否定的。让我们逐一看 React 中的对应实现。
6.1 Context 替代 provide/inject
React 的 Context API 天然支持这一模式:
ts
// FeatureContext.tsx
const FeatureContext = createContext<FeatureContextType | null>(null)
export function FeatureProvider({ children }) {
const [status, setStatus] = useState('')
const [detailData, setDetailData] = useState(null)
const value = {
status,
detailData,
updateStatus: setStatus,
refreshDetail: async () => { /* ... */ }
}
return (
<FeatureContext.Provider value={value}>
{children}
</FeatureContext.Provider>
)
}
// 子组件
export function useFeature() {
const ctx = useContext(FeatureContext)
if (!ctx) throw new Error('useFeature must be used within FeatureProvider')
return ctx
}
6.2 注册表模式
React 中同样可以用配置数组驱动按钮渲染,弹窗用 React.lazy 实现动态加载:
tsx
const actionRegistry = [
{
event: 'pause',
label: '暂停',
visible: (ctx) => isButtonVisible('pause', ctx),
Dialog: lazy(() => import('./dialogs/PauseDialog')),
},
// ...
]
function ActionBar() {
const ctx = useFeature()
const [currentEvent, setCurrentEvent] = useState(null)
const visibleActions = actionRegistry.filter(a => a.visible(ctx))
return (
<>
{visibleActions.map(action => (
<Button key={action.event} onClick={() => setCurrentEvent(action.event)}>
{action.label}
</Button>
))}
<ActionDialogHost event={currentEvent} onClose={() => setCurrentEvent(null)} />
</>
)
}
6.3 策略模式与纯函数
这两者完全与框架无关,可以直接复用相同的 TypeScript 代码。
策略表模式:
ts
const strategies: Record<SubmitMode, SubmitHandler> = {
create: handleCreate,
update: handleUpdate,
// ...
}
纯函数权限规则:
ts
export function isButtonVisible(event: BtnEvent, status: string, role: string) {
// 相同的逻辑,不依赖任何框架
}
核心启示:好的架构设计往往"浮在框架之上"。框架负责调度和渲染,而业务逻辑应该像纯函数一样干净、可移植。
七、结语:架构的本质是管理变更的成本
回顾这次重构,最大的收获不是用了什么新技术,而是对"什么是好架构"有了更清醒的认识。
在旧架构中,代码确实被"拆分"了------5 个 Hooks,职责清晰,命名规范。但变更一个操作依然要修改 5 个文件,这暴露了拆分的真实目的:不是为了拆分而拆分,而是为了降低变更的成本。
新架构的五大原则,每一招都直指同一个目标:
- provide/inject:让状态变更的影响范围收敛在 Context 内
- 注册表模式:让新增操作从"改代码"变成"加配置"
- 纯函数规则:让业务逻辑的修改可以独立于框架验证
- 策略模式:让新增场景不需要触碰已有逻辑
- Service 层:让数据转换的变更不影响 UI 层
这五条原则可以提炼为一句话:
架构的本质不是组织代码,而是管理变更的"爆炸半径"。
当一个产品需求到来时,你需要修改的文件越少、需要阅读的代码越少、需要担心的副作用越少------你的架构就越好。
希望本文的诊断过程和重构路径,能为你面对自己的"复杂详情页"时,提供一个可参照的坐标。无论是 Vue、React 还是其他框架,这些原则都具有普适性------因为变更的成本,永远是软件工程中最真实的痛觉。