搜索 K
Appearance
Appearance
这是 forge-admin-web 最精彩的一层。一句话:它用一张多维加权注册表,把「哪个字段用哪个组件」这件事,从散落在各页面的 if-else,收敛成一次可计算的「选最具体者」。 本篇讲清楚它的设计动机、算法、与 oinone 的渊源,以及一个微妙但关键的实现细节。
使用层面(怎么注册自定义组件)见使用指南 · 自定义与覆盖,本篇专注「为什么这么设计」。
「为字段选组件」的可变性来自多个维度,且会叠加:
component(中立组件名)选:select → 下拉,money → 金额输入。dataType 兜底:没指定 component 的 ENUM 也该用下拉。score 用星级。如果把这些写成页面里的 if-else,会迅速腐化成一棵无人敢动的判断树。注册表的思路是:把每条「什么情况用什么组件」声明成数据,选择交给一个通用算法。
| 设计取舍 | 选项 | 选择 | 理由 |
|---|---|---|---|
| 可变性形态 | A. 页面内 if-else;B. 声明式注册表 | B | 新增/覆盖只动数据,不动算法;优先级可计算 |
| 维度优先级 | A. 硬编码顺序;B. 加权求和取最大 | B | 多维叠加时用权重统一裁决,避免顺序歧义 |
| 借鉴来源 | A. 自造 IoC 容器;B. 借 oinone SPI、砍掉 IoC/装饰器 | B | 保留多维匹配的精华,去掉重型依赖,纯函数+类型安全 |
整个注册表只有约 40 行,是一个带维度权重的通用容器:
// 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 }
}匹配规则——「注册项约束的每一维都要被上下文命中」:
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
}两条规则合起来即「约束越多越具体、约束全中才候选、候选里特异性最高者胜」。
字段注册表用五个维度,权重按数量级拉开:
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) 之和。若用相邻整数,多个低维叠加可能反超单个高维,优先级就乱了。数量级权重让「越精确的意图越优先」成为铁律——一个字段级注册必然战胜任何模型级/组件级注册。
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)。这是设计的自然结果:注册表鼓励「叠加更具体的覆盖」,而非「平替内置」。
这个性质其实是优点:它让「内置默认」天然成为最低优先级的安全网,业务只能在其之上叠加更精确的意图,不会意外地把默认行为整个抽掉。使用层面的配方见自定义与覆盖 · 覆盖配方。
// 输入(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)除了字段级,还有一个页面级注册表,用于整页替换:
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.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 行通用代码,换来了「全站字段渲染可声明式扩展」的能力,是元数据驱动前端的点睛之笔。
FieldSchema 的每个字段如何喂给引擎。