Skip to content

渲染引擎 ★

这是 forge-admin-web 最精彩的一层。一句话:它用一张多维加权注册表,把「哪个字段用哪个组件」这件事,从散落在各页面的 if-else,收敛成一次可计算的「选最具体者」。 本篇讲清楚它的设计动机、算法、与 oinone 的渊源,以及一个微妙但关键的实现细节。

使用层面(怎么注册自定义组件)见使用指南 · 自定义与覆盖,本篇专注「为什么这么设计」。

问题:可变性放哪

「为字段选组件」的可变性来自多个维度,且会叠加:

  • 通常按 component(中立组件名)选:select → 下拉,money → 金额输入。
  • 有时要按 dataType 兜底:没指定 component 的 ENUM 也该用下拉。
  • 有时要按具体模型的具体字段特事特办:商品的 score 用星级。
  • 还要区分场景:同一字段,表单要可编辑(input)、列表要只读展示(cell)。

如果把这些写成页面里的 if-else,会迅速腐化成一棵无人敢动的判断树。注册表的思路是:把每条「什么情况用什么组件」声明成数据,选择交给一个通用算法。

设计取舍选项选择理由
可变性形态A. 页面内 if-else;B. 声明式注册表B新增/覆盖只动数据,不动算法;优先级可计算
维度优先级A. 硬编码顺序;B. 加权求和取最大B多维叠加时用权重统一裁决,避免顺序歧义
借鉴来源A. 自造 IoC 容器;B. 借 oinone SPI、砍掉 IoC/装饰器B保留多维匹配的精华,去掉重型依赖,纯函数+类型安全

核心:createRegistry

整个注册表只有约 40 行,是一个带维度权重的通用容器:

ts
// widgets/registry.ts(核心)
export function createRegistry<V>(dims: DimensionSpec[]): Registry<V> {
  const entries: Entry<V>[] = []

  function register(match: Record<string, unknown>, value: V) {
    let specificity = 0
    for (const d of dims) {
      const m = match[d.key]
      if (m !== undefined && m !== null) specificity += d.weight  // 声明了该维度 → 累加权重
    }
    entries.push({ match, value, specificity })
    entries.sort((a, b) => b.specificity - a.specificity)         // 高特异性优先
  }

  function select(ctx: Record<string, unknown>): V | undefined {
    for (const e of entries) {
      if (matches(e.match, ctx, dims)) return e.value             // 命中即用(已按特异性降序)
    }
    return undefined
  }

  return { register, select }
}

匹配规则——「注册项约束的每一维都要被上下文命中」:

ts
function matches(match, ctx, dims): boolean {
  for (const d of dims) {
    const m = match[d.key]
    if (m === undefined || m === null) continue   // 该项未约束此维度 → 跳过(更宽松)
    const c = ctx[d.key]
    if (Array.isArray(m)) { if (!m.includes(c)) return false }  // 数组 → 命中其一
    else if (m !== c) return false                              // 标量 → 全等
  }
  return true
}

两条规则合起来即「约束越多越具体、约束全中才候选、候选里特异性最高者胜」。

字段注册表的权重

字段注册表用五个维度,权重按数量级拉开:

ts
export const fieldRegistry = createRegistry<Component>([
  { key: 'scene', weight: 1 },
  { key: 'dataType', weight: 5 },
  { key: 'component', weight: 10 },
  { key: 'model', weight: 1000 },
  { key: 'field', weight: 10000 },
])

为什么用数量级而非 1/2/3/4/5?因为要保证高维永远压过低维的任意组合field(10000) 必须 > model+component+dataType+scene(1016) 之和。若用相邻整数,多个低维叠加可能反超单个高维,优先级就乱了。数量级权重让「越精确的意图越优先」成为铁律——一个字段级注册必然战胜任何模型级/组件级注册。

选组件的完整链路

ts
export function selectFieldWidget(scene, field, model): Component {
  return fieldRegistry.select({
    scene, component: field.component, dataType: field.dataType,
    model, field: field.field,
  }) ?? (scene === 'input' ? TextInput : DefaultCell)
}

注意有双层兜底:注册表内有 {scene:'input'} / {scene:'cell'} 的兜底项(特异性 1,永远最后),selectFieldWidget 外还有一层 ?? TextInput/DefaultCell 的硬兜底。后者是防御性的——即便注册表被清空也不会渲染出 undefined

一个微妙但关键的细节:同特异性,先注册者胜

entries.sort((a,b) => b.specificity - a.specificity)稳定排序(ES2019+ 保证)。这意味着特异性相同时,先注册的排在前面、被 select 优先返回

内置组件在模块 import 阶段就注册完毕,业务自定义注册发生在应用启动之后。因此:

覆盖必须「严格更具体」,等特异性会被内置项吃掉

注册一个与内置项特异性相同的项,覆盖不会生效——因为内置项先注册、排在前面。要覆盖,必须让特异性严格更高(多约束一个维度)。例如要全局替换内置的 select 输入({scene,component:'select'},特异性 11),需写 {scene,component:'select',dataType:'ENUM'}(特异性 16)。这是设计的自然结果:注册表鼓励「叠加更具体的覆盖」,而非「平替内置」

这个性质其实是优点:它让「内置默认」天然成为最低优先级的安全网,业务只能在其之上叠加更精确的意图,不会意外地把默认行为整个抽掉。使用层面的配方见自定义与覆盖 · 覆盖配方

内置注册项一览

ts
// 输入(scene=input)
register({ scene: 'input' }, TextInput)                          // 兜底, 特异性 1
register({ scene: 'input', component: 'select' }, SelectInput)   // 特异性 11
register({ scene: 'input', component: 'money' }, MoneyInput)
// … text/textarea/number/switch/date/datetime 同理

// 单元格(scene=cell)
register({ scene: 'cell' }, DefaultCell)                         // 兜底, 特异性 1
register({ scene: 'cell', component: 'money' }, MoneyCell)
register({ scene: 'cell', component: 'select' }, TagCell)
register({ scene: 'cell', component: 'switch' }, TagCell)

页面注册表:机制就绪,尚未接线

除了字段级,还有一个页面级注册表,用于整页替换

ts
export const pageRegistry = createRegistry<Component>([
  { key: 'viewType', weight: 1 },
  { key: 'model', weight: 1000 },
])
export function selectPageWidget(viewType, model): Component | undefined { ... }

selectPageWidget 当前无人调用

pageRegistry / selectPageWidget / registerPageWidget 都已实现,但没有任何路由视图调用 selectPageWidget——整页替换目前不会生效。要接线,需在 ResourceListView 等视图入口加「先问 selectPageWidget(viewType, model),命中则渲染自定义页、否则走默认」。这是一个已声明但未接线的扩展点,与之对照,字段级注册表是完全可用的主力。

FieldInput / FieldCell:注册表的两个消费入口

注册表对业务组件透明——业务只用两个薄包装组件:

vue
<!-- FieldInput.vue:表单态 -->
<component :is="selectFieldWidget('input', field, model)"
  :field :value @update:value="..." />
<!-- FieldCell.vue:展示态 -->
<component :is="selectFieldWidget('cell', field, model)"
  :field :value :record />

它们被 MetaForm(表单)、MetaSearch(搜索)用作 input,被 MetaTable(列表)、MetaDetail(详情)用作 cell。布局组件只管「有哪些字段、怎么排」,选组件全权交给引擎——这正是整体架构里「布局/渲染」那条腰线的落地。

设计总评

优点代价
新增/覆盖组件只动数据,不动算法多一层「选组件」的间接,调试时要理解特异性
优先级可计算,无顺序歧义「等特异性失效」对新手不直观(已用 warning 标注)
内置默认天然是最低优先级安全网「平替内置」需出更高特异性,不能等量替换
纯函数 + 类型安全,无 IoC/装饰器依赖整页替换尚需补一处接线

一句话——它用 40 行通用代码,换来了「全站字段渲染可声明式扩展」的能力,是元数据驱动前端的点睛之笔。

下一步