字段渲染器的 if-else 地狱——用策略注册表一刀斩断
某个低代码后台管理系统里,有一个核心组件 FieldValueEditor,负责根据字段类型渲染对应的编辑控件。最开始只有 string、number、date 三种,代码干净得很。
半年后,字段类型增长到 12 种:enum、preset_enum、cascade_path、rich_text、file、formula……这个文件悄悄膨胀到 950 行,成了整个项目里最怕碰的文件——改 file 类型时会动到 enum 的 import,改 formula 逻辑时会产生 git 冲突,给新类型写单测时不得不 mock 整个文件。
现象
export function FieldValueEditor({ col, value, onChange, row }) {
if (col.type === 'string') return <Input value={value} onChange={...} />
if (col.type === 'number') return <NumberInput ... />
if (col.type === 'date') return <DatePicker ... />
if (col.type === 'enum') { /* 40 行:拉选项 + 级联 */ }
if (col.type === 'preset_enum') { /* 60 行:异步字典 + 远程级联 */ }
if (col.type === 'formula') { /* 30 行:只读展示 */ }
if (col.type === 'file') { /* 50 行:上传 + 预览 */ }
if (col.widget === 'custom-xxx') { /* 硬编码的一次性需求 */ }
return <span>{String(value)}</span>
}特征:文件 > 600 行,新增类型必须改这个文件,不相关的类型 PR 频繁发生 git 冲突,给单个分支写测试时要 mock 整个渲染器。
根因
这个反模式违反了两个原则:
- SRP(单一职责):一个文件同时承担了 N 种字段类型的渲染职责,每种类型都有独立的 hook、副作用、接口调用。
- OCP(开闭原则):每次新增类型都要修改已有文件,而不是「扩展」——这是 if-else 链天然的原罪。
在字段类型集合开放(业务不断新增类型)的系统里,if-else 根本无法规模化。
if-else 链把分发器和各类型的实现逻辑耦合在一起。任何一处改动都会触碰整个文件,代码审查噪音大、git 冲突频繁、新人不敢动。随着类型数增长,这个问题会指数级恶化。
正解
策略注册表(Strategy Registry)模式:分发逻辑和类型实现彻底分离,每种类型自己注册自己的匹配规则和渲染函数。
第一步:定义策略接口
// src/engine/field-renderers/types.ts
export interface FieldRendererProps {
col: FieldColumn
value: unknown
onChange: (v: unknown) => void
row?: Record<string, unknown>
readonly?: boolean
}
export interface FieldRenderer {
name: string
match: (col: FieldColumn) => boolean // 自持匹配规则
renderEditor: (props: FieldRendererProps) => ReactElement
renderReadonly?: (props: FieldRendererProps) => ReactElement
}第二步:注册表 + 首匹配分发
// src/engine/field-renderers/registry.ts
import { presetEnumRenderer } from './preset-enum'
import { enumRenderer } from './enum'
import { fileRenderer } from './file'
import { formulaRenderer } from './formula'
import { dateRenderer } from './date'
import { numberRenderer } from './number'
import { stringRenderer } from './string'
import { fallbackRenderer } from './fallback'
// 顺序关键:具体的放前面,泛的放后面,fallback 必须垫底
export const FIELD_RENDERERS: FieldRenderer[] = [
presetEnumRenderer, // preset_enum 是 enum 的子类型,必须先匹配
enumRenderer,
fileRenderer,
formulaRenderer,
dateRenderer,
numberRenderer,
stringRenderer,
fallbackRenderer, // 兜底,生产环境永不抛异常
]
export function resolveRenderer(col: FieldColumn): FieldRenderer {
return FIELD_RENDERERS.find(r => r.match(col))!
}第三步:每种类型自持实现
// src/engine/field-renderers/preset-enum.tsx
function PresetEnumEditor({ col, value, onChange }: FieldRendererProps) {
const dict = usePresetDict(col.presetKey!) // hooks 完全隔离
return <Select options={dict.options} value={value} onChange={onChange} />
}
export const presetEnumRenderer: FieldRenderer = {
name: 'preset_enum',
match: (col) => col.type === 'preset_enum' && !!col.presetKey,
renderEditor: (props) => <PresetEnumEditor {...props} />,
}第四步:分发器只剩 5 行
// src/components/FieldValueEditor.tsx
import { resolveRenderer } from '@/engine/field-renderers/registry'
export function FieldValueEditor(props: FieldRendererProps) {
const renderer = resolveRenderer(props.col)
return props.readonly && renderer.renderReadonly
? renderer.renderReadonly(props)
: renderer.renderEditor(props)
}新增一种字段类型,只需新建一个文件并在 registry.ts 里插一行。主分发器文件永远不会变,不同类型的 PR 互不干扰,单个类型的测试只需关注自己那个策略文件。
注意事项
匹配顺序是关键:preset_enum 是 enum 的子类型,必须排在 enum 之前,否则 enumRenderer.match 会先命中(col.type === 'enum' 对两者都成立的情况下尤其危险)。建议在注释里写明每个渲染器的匹配范围和排在哪里的原因。
fallback 不可省略:生产环境里用户可能配置了尚未上线的字段类型,fallback 渲染器(返回 <span>{String(value)}</span>)能保证不崩溃。
迁移策略:不必一次性重写。可以先建好注册表框架,把现有 if-else 里的分支一个个迁移进来,原有 if-else 作为过渡期兜底,每迁移完一种类型就删掉对应分支。
类型稳定且 ≤ 3 种、超过 6 个月没新增类型的场景,不用引入注册表。简单 if-else 反而更易读,不要为了「设计模式」而设计模式。
一句话外卖
分发逻辑和类型实现在同一个文件共存是技术债的温床——注册表把「谁负责分发」和「各自怎么渲染」物理隔离,让字段类型的增长成本从 O(N) 降为 O(1)。