返回博客

字段渲染器的 if-else 地狱——用策略注册表一刀斩断

某个低代码后台管理系统里,有一个核心组件 FieldValueEditor,负责根据字段类型渲染对应的编辑控件。最开始只有 stringnumberdate 三种,代码干净得很。

半年后,字段类型增长到 12 种:enumpreset_enumcascade_pathrich_textfileformula……这个文件悄悄膨胀到 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)
}
正解:注册表首匹配,每种类型自持 match + render

新增一种字段类型,只需新建一个文件并在 registry.ts 里插一行。主分发器文件永远不会变,不同类型的 PR 互不干扰,单个类型的测试只需关注自己那个策略文件。

注意事项

匹配顺序是关键preset_enumenum 的子类型,必须排在 enum 之前,否则 enumRenderer.match 会先命中(col.type === 'enum' 对两者都成立的情况下尤其危险)。建议在注释里写明每个渲染器的匹配范围和排在哪里的原因。

fallback 不可省略:生产环境里用户可能配置了尚未上线的字段类型,fallback 渲染器(返回 <span>{String(value)}</span>)能保证不崩溃。

迁移策略:不必一次性重写。可以先建好注册表框架,把现有 if-else 里的分支一个个迁移进来,原有 if-else 作为过渡期兜底,每迁移完一种类型就删掉对应分支。

何时不该用

类型稳定且 ≤ 3 种、超过 6 个月没新增类型的场景,不用引入注册表。简单 if-else 反而更易读,不要为了「设计模式」而设计模式。

一句话外卖

分发逻辑和类型实现在同一个文件共存是技术债的温床——注册表把「谁负责分发」和「各自怎么渲染」物理隔离,让字段类型的增长成本从 O(N) 降为 O(1)。