Skip to content

自定义与覆盖 ★

元数据驱动的好处是「新增模型零前端改动」,但总有字段需要专属表现:评分用星级、颜色用色板、地址用地图选点。本篇讲清楚如何在不改渲染引擎、不动内置组件的前提下,把任意字段/页面换成你自己的组件。

这套能力的核心是一个多维加权注册表:你注册一个组件并声明「它适用于哪些维度(场景/组件名/数据类型/模型/字段)」,引擎在渲染时选出最具体的那个。先读完字段渲染理解默认机制,再看本篇如何改变它。

两个注册函数

全部能力就两个函数,从 @/widgets 导入:

ts
import { registerFieldWidget, registerPageWidget } from '@/widgets'

// 注册自定义字段组件(局部替换某字段/模型/组件类型)
registerFieldWidget(match: Record<string, unknown>, comp: Component): void

// 注册自定义整页组件(整页替换)
registerPageWidget(match: Record<string, unknown>, comp: Component): void

match 是「维度约束」对象,声明你这个组件适用于哪些维度;只声明你关心的维度,不关心的不写。

维度与权重:谁更「具体」谁胜出

字段注册表有五个维度,各有权重:

维度权重含义
field10000字段名(最具体,能精确锁定一个字段)
model1000模型编码(锁定某个模型)
component10中立组件名(text/select/…)
dataType5数据类型(STRING/ENUM/…)
scene1场景(input 表单 / cell 展示)

注册时,引擎把你声明了的维度的权重相加得到特异性(specificity)。渲染某字段时,引擎构造上下文 { scene, component, dataType, model, field },在所有「约束全部命中」的注册项里,选特异性最高的那个。

权重用「数量级」拉开是有意为之:一个 field 级注册(10000)必然压过任何 model(1000)/component(10)级注册——越精确的意图越优先。其设计原理见设计文档 · 渲染引擎

关键规则:覆盖必须「更具体」,同特异性会输给内置项

内置组件在模块加载时就注册好了,你的注册发生在启动之后。引擎排序是稳定排序,特异性相同时先注册的(内置)排在前面、被优先选中

所以:注册一个与内置项特异性相同的项,覆盖不会生效。要覆盖必须让特异性严格更高——多约束一个维度即可。例如想替换内置的 select 输入({scene:'input', component:'select'},特异性 11),不能再注册一个同样的,而要:

  • dataType 做全局覆盖:{scene:'input', component:'select', dataType:'ENUM'}(特异性 16,仍匹配所有枚举下拉);
  • 或加 model/field 做局部覆盖(特异性更高,见下文配方)。

注册时机与放置位置

注册表是模块级单例,注册只需在应用启动、首个页面渲染之前做一次。推荐新建一个 src/widgets/custom.ts 集中注册,并在 main.ts 挂载前 import:

ts
// src/widgets/custom.ts —— 业务自定义组件集中注册
import { registerFieldWidget } from '@/widgets'
import RateInput from './custom/RateInput.vue'
import RateCell from './custom/RateCell.vue'

registerFieldWidget({ scene: 'input', model: 'biz.product', field: 'score' }, RateInput)
registerFieldWidget({ scene: 'cell', model: 'biz.product', field: 'score' }, RateCell)
ts
// src/main.ts —— 挂载前引入,确保注册早于渲染
import '@/widgets/custom'
// ...
app.mount('#app')

当前没有内置的业务注册文件

框架导出了 registerFieldWidget/registerPageWidget 作为扩展点,但仓库里目前没有任何业务注册调用——扩展点是留给你的。按上面的约定新建 custom.ts 即可,无需改动渲染引擎。

组件契约(写自定义组件的唯一约束)

自定义组件只要满足与内置组件相同的 props 契约即可即插即用(详见字段渲染 · 组件契约):

ts
// 输入组件
defineProps<{ field: FieldSchema; value: any; model?: string }>()
defineEmits<{ 'update:value': [any] }>()

// 单元格组件
defineProps<{ field: FieldSchema; value: any; record?: any; model?: string }>()

一个完整的自定义评分输入组件:

vue
<!-- src/widgets/custom/RateInput.vue -->
<script setup lang="ts">
import type { FieldSchema } from '@/types/protocol'
defineProps<{ field: FieldSchema; value: any }>()
const emit = defineEmits<{ 'update:value': [any] }>()
</script>
<template>
  <a-rate :value="value" :disabled="field.readonly"
    @update:value="(v) => emit('update:value', v)" />
</template>

覆盖配方

下面按「从最局部到最全局」给出常用配方。match 越具体,影响面越小、优先级越高。

配方 1:只改某模型的某个字段(最常用)

商品的 score 字段用星级,其余商品字段、其余模型的 score 都不受影响:

ts
registerFieldWidget({ scene: 'input', model: 'biz.product', field: 'score' }, RateInput)
registerFieldWidget({ scene: 'cell',  model: 'biz.product', field: 'score' }, RateCell)

特异性 = scene(1) + model(1000) + field(10000) = 11001,碾压一切。

配方 2:改某个字段名(跨所有模型)

所有模型只要字段名叫 color 都用色板:

ts
registerFieldWidget({ scene: 'input', field: 'color' }, ColorPickerInput)

特异性 = 1 + 10000 = 10001。

配方 3:接入一个全新的组件名(需后端配合)

后端给某字段下发 component: 'rate'(一个内置清单里没有的中立名),前端注册它:

ts
registerFieldWidget({ scene: 'input', component: 'rate' }, RateInput)
registerFieldWidget({ scene: 'cell',  component: 'rate' }, RateCell)

这是最干净的扩展方式:因为 rate 不在内置清单里,不存在「同特异性输给内置」的问题。后端如何把字段标成 rate,见数据建模的字段 UI 元数据。

配方 4:全局替换某内置组件类型(需出更高特异性)

想让所有枚举下拉的录入换成自定义实现,给内置 {scene,component:'select'}(特异性 11)多加一维 dataType 压过它:

ts
registerFieldWidget({ scene: 'input', component: 'select', dataType: 'ENUM' }, MySelect)

特异性 = 1 + 10 + 5 = 16 > 11,且仍匹配所有枚举下拉。

配方 5:按数据类型兜底

为某数据类型提供统一展示,比 component 兜底更宽(注意仍要避免与内置同特异性):

ts
registerFieldWidget({ scene: 'cell', dataType: 'DATE' }, RelativeTimeCell)

配方对比

配方match特异性影响面
1 模型+字段{scene, model, field}11001一个模型的一个字段
2 字段名{scene, field}10001所有模型的同名字段
3 新组件名{scene, component:'rate'}11后端标了该组件名的字段
4 类型+组件{scene, component, dataType}16该组件+该类型的所有字段
5 数据类型{scene, dataType}6该类型的所有字段

match 还支持数组(多值匹配)

某维度可传数组表示「命中其一即可」。例如 { scene: 'input', component: ['date', 'datetime'] } 同时匹配两种组件。引擎对数组维度用 includes 判断。

整页替换(机制就绪,视图层尚未接线)

除了字段级,还有一个页面级注册表 pageRegistry,维度为 viewType(权重 1)+ model(权重 1000),配套 registerPageWidget / selectPageWidget

ts
import { registerPageWidget } from '@/widgets'
// 为商品换一个完全自定义的列表页
registerPageWidget({ viewType: 'TABLE', model: 'biz.product' }, ProductBoardView)

诚实说明:整页替换当前未生效

pageRegistryselectPageWidget 已实现,但没有任何视图调用 selectPageWidget——也就是说,注册了整页组件目前不会被采用。要让它生效,需要在路由视图(如 ResourceListView)入口处加一句「先问 selectPageWidget(viewType, model),命中就渲染自定义页、否则走默认渲染」的接线。这是一个已声明但未接线的扩展点,字段级覆盖(上文配方)则是完全可用的。

自检清单

给字段换组件前,对照这份清单:

  • [ ] 组件 props 是否满足契约(input 有 update:value,cell 只读)?
  • [ ] match 的特异性是否严格高于要覆盖的内置项(同特异性会失效)?
  • [ ] 注册是否在 main.ts 挂载之前执行(import custom.ts)?
  • [ ] inputcell 两个场景是否都要覆盖(表单换了、列表也要换吗)?
  • [ ] 新组件名是否需要后端在字段 schema 里下发对应 component

下一步