Vue3 低代码里,我怎么让 Schema 的 componentProps 自动出提示
写 Schema 驱动表单时,最烦的不是渲染,而是:
component: 'Radio'写完,componentProps还是any,错字段名只能等运行时爆。
这篇文章讲的是:在 Vue3 里,如何从「组件注册表」一路推导出 Schema 的类型,让 IDE 在写配置时就能提示 props / slots / decorator。
文章偏实战,会穿插条件类型、模板字面量类型、映射类型,以及 Vue3「泛型组件偏弱」时的一个小技巧。思路上参考了 Formily 一类 Schema 表单方案,实现是我们自己项目里的简化版。
先看我们想要的 DX
理想用法大概长这样:
const { SchemaField, defineSchema } = createSchemaField({
components: {
Input,
Select,
Radio,
FormItem,
ATestComp
}
})
const schemaJson = defineSchema([
{
name: 'doctorServiceAttitude',
type: 'string',
title: '医生服务态度',
decorator: 'FormItem',
decoratorProps: {
required: true
// ↑ 这里应该提示 FormItem 的 props
},
component: 'Radio',
componentProps: {
options: [
{ label: '满意', value: 'satisfied' }
// ↑ 这里应该提示 Radio 的 props
]
}
}
])
目标可以拆成四句:
component只能是注册过的组件名(含Input.Textarea这种两层路径)componentProps跟着component变decorator/decoratorProps成对联动slots尽量对齐组件真实插槽
运行时 defineSchema 几乎什么都不做,它存在的意义主要是把类型拴在参数上:
function defineSchema(schemas: SomeTypedSchema[]) {
return schemas // 恒等函数,类型在编译期生效
}
DX效果如下
1)
componentProps.自动完成
2)写错 prop 名时报错
总览:类型是怎么一路推下来的
整条链路可以画成:
components 注册表
↓ VueComponentPath // 'Input' | 'Input.Textarea' | 'Radio' ...
↓ GetComponentByPath // 路径 → 组件构造类型
↓ ComponentProps / Slots // InstanceType 取 $props / $slots
↓ VueComponentTypeData // { componentProps, decorator, slots }
↓ SchemaType / defineSchema
↓(可选)MarkupField 的 as new <T>() 泛型伪装
工厂函数入口大概是:
export function createSchemaField<Components extends SchemaVueComponents>(
options: SchemaFieldOptions<Components>
) {
type ComponentPropsMap = {
[P in VueComponentPath<Components>]: ComponentPropsMapValue<Components, P>
}
type ComponentSlotsMap = {
[P in VueComponentPath<Components>]: ComponentSlotsMapValue<Components, P>
}
return _createSchemaField<ComponentPropsMap, ComponentSlotsMap, Components>(options)
}
关键点:Components 是调用方传进来的字面量对象类型。只要注册表写对了,后面的 Path / PropsMap 都能跟着推。
一、先约定:怎么从组件上拿到 Props / Slots
Vue 组件在类型世界里并不统一。我们这边常用「构造器」视角:
export type ComponentClass = abstract new (...args: unknown[]) => any
export type ComponentProps<T extends ComponentClass> = InstanceType<T>['$props']
export type ComponentSlots<T extends ComponentClass> = InstanceType<T>['$slots']
意思是:先把组件当成可 new 的类,再从实例上取 $props / $slots。
这在配合 defineComponent / 部分 SFC 类型时够用;遇到函数式组件、奇怪包装层,可能要再补一层适配(我们项目里也还在打磨)。
另外有一个小 Helper,用来从 props 形状反推组件类型(库作者常见写法):
class Helper<Props> {
Return = defineComponent({} as Record<keyof Props, any>)
}
export type DefineComponent<Props> = Helper<Props>['Return']
二、组件路径:支持 Input 和 Input.Textarea
物料里经常有「主组件 + 子组件」:
export const Input = composeExport(InnerInput, { Textarea })
// 运行时:Input.Textarea
// 类型上:希望 component 能写 'Input' | 'Input.Textarea'
1)抽出「挂在组件上的子组件」
export type ExtractChildren<T> = T extends object
? {
[K in keyof T as T[K] extends ComponentClass
? string extends K
? never
: number extends K
? never
: symbol extends K
? never
: K
: never]: T[K] extends ComponentClass ? T[K] : never
}
: Record<string, never>
这里用了 key remapping(as):
- 值必须是
ComponentClass才保留 string extends K这类过宽的 key 丢掉,避免脏 key 污染路径联合
2)生成路径联合
export type VueComponentPath<
T extends SchemaVueComponents,
Key extends keyof T = keyof T
> = Key extends string
? T[Key] extends VueComponent
? `${Key}.${Extract<keyof ExtractChildren<T[Key]>, string>}` | Key
: Key
: never
于是注册了 Input(带 Textarea)和 Radio 之后,路径大概是:
'Input' | 'Input.Textarea' | 'Radio' | ...
3)路径解析成组件类型
目前我们只认真支持两层路径(够覆盖 Input.Textarea / Input.Search 这类):
export type GetComponentByPath<
T extends ComponentMap,
Path extends string
> = Path extends `${infer First}.${infer Second}`
? First extends keyof T
? T[First] extends ComponentMap
? Second extends keyof T[First]
? T[First][Second] extends ComponentClass
? T[First][Second]
: never
: never
: never
: never
: Path extends keyof T
? T[Path] extends ComponentClass
? T[Path]
: never
: never
模板字面量类型 + infer,本质上就是在类型系统里做一次「按 . 拆字符串」。
拿到组件后,Props / Slots 可以包一层 infer,避免同一 key 反复展开:
export type ComponentPropsMapValue<
Components extends SchemaVueComponents,
P extends string
> = GetComponentByPath<Components, P> extends infer C
? C extends ComponentClass
? ComponentProps<C>
: never
: never
三、Decorator 和 DecoratorProps 如何「成对」
只提示 decorator: 'FormItem' 不够,还希望:
- 选了
FormItem,decoratorProps就是 FormItem 的 props - 选了别的装饰器,props 跟着变
做法是:先建成「按路径索引的对象类型」,再取联合索引,收成可区分联合:
export type DecoratorType<T extends SchemaVueComponents> = {
[K in VueComponentPath<T>]?: {
decorator?: K
decoratorProps?: T[K] extends ComponentClass
? Partial<ComponentProps<T[K]>>
: Record<string, never>
}
}[VueComponentPath<T>]
读法:
- 对每个路径
K,生成{ decorator?: K; decoratorProps?: ... } - 最后用
[VueComponentPath<T>]把整个对象类型「摊」成联合
这是 TS 里很实用的模式:Mapped Type 造表,索引访问收联合。
不只低代码能用,配置对象里「type 决定 payload」的场景都能套。
组件侧完整一块数据类型可以长这样:
export type VueComponentTypeData<
Component extends ComponentClass,
Components extends SchemaVueComponents
> = {
componentProps?: Partial<ComponentProps<Component>>
decorator?:
| DecoratorType<Components>
| {
decorator?: null | undefined
decoratorProps?: { [K: string]: never }
}
slots?: {
[key in keyof ComponentSlots<Component>]?:
| ((
...args: Parameters<ComponentSlots<Component>[key]>
) => ReturnType<ComponentSlots<Component>[key]>)
| string
| number
| VNode
| VNode[]
}
}
decorator: null 单独开了一支,方便「不要装饰器」的字段。
四、把「组件名 → 类型数据包」收成 Schema 联合
有了每个组件对应的 componentProps / decorator / slots,下一步是生成 Schema 数组元素类型:
export type SchemaType<
T extends Record<string, unknown>,
Props extends Record<string, unknown> = Record<string, unknown>
> = {
[K in keyof T]: T[K] extends Record<string, unknown>
? Omit<JsonSchema<...>, 'children'> & {
component?: K
componentProps?: T[K]['componentProps']
slots?: T[K]['slots']
children?: SchemaType<T>[]
} & T[K]['decorator'] &
Props
: never
}[keyof T]
同样是「造表再 [keyof T] 摊平」。
于是 component: 'Radio' 时,同一对象上的 componentProps 会被收窄到 Radio 那一支。
defineSchema 把这层类型挂到参数上:
function defineSchema<Component extends keyof ComponentPropsMap>(
schemas: SchemaType<
{
[P in Component]: ComponentPathToVueComponentPath<Components, P & string>
}
>[]
) {
return schemas
}
其中:
export type ComponentPathToVueComponentPath<
Components extends SchemaVueComponents,
P extends string
> = GetComponentByPath<Components, P> extends infer C
? C extends ComponentClass
? VueComponentTypeData<C, Components>
: never
: never
到这里,JSON Schema 配置的类型 DX 主链路就通了。
五、Vue3 泛型组件偏弱时:用 as new <T>() 曲线救国
除了 defineSchema,我们还有 Markup 写法(<SchemaField.String component="Input" /> 这类)。
Vue3 对「组件自身带泛型」支持一般,库里常见做法是:
运行时仍是普通 defineComponent,类型上把它断言成泛型构造器。
import type { CreateComponentPublicInstanceWithMixins } from 'vue'
const MarkupField = defineComponent({
name: 'MarkupField',
props: { /* ... */ },
setup(props, { slots }) {
// ...
}
}) as new <
Decorator extends keyof ComponentPropsMap | ComponentClass,
Component extends keyof ComponentPropsMap | ComponentClass
>(
props: ISchemaMarkupFieldProps<Decorator, Component, ComponentPropsMap, ComponentSlotsMap>
) => CreateComponentPublicInstanceWithMixins<
ISchemaMarkupFieldProps<Decorator, Component, ComponentPropsMap, ComponentSlotsMap>
>
ISchemaMarkupFieldProps 里根据 Decorator / Component 是「字符串 key」还是「组件类」,分别从 PropsMap 或 ComponentProps 取值。
| 收益 | 代价 |
|---|---|
| TSX 里可以拿到接近泛型组件的提示 | 断言较「硬」,要和运行时 props 定义对齐 |
| 能复用已有 PropsMap | 模板 .vue 里提示能力仍受限于 Vue 工具链 |
| 对库作者可控 | 新手维护成本偏高,改类型要小心 |
如果你们业务只走 defineSchema,甚至可以先不上 Markup 泛型,复杂度会低一截。
DX效果如下
六、一个小技巧:string & Record<string, unknown>
Schema 协议里常有「推荐枚举,但允许扩展」:
export type SchemaTypes =
| 'string'
| 'object'
| 'array'
| 'number'
| 'boolean'
| 'void'
| 'date'
| 'datetime'
| (string & Record<string, unknown>)
以及 display / pattern 等。目的是:
- IDE 优先提示常用字面量
- 又不把字段直接放成裸
string(有时会冲掉联合提示)
社区也常叫它 LiteralUnion 一类技巧。Formily 相关类型里也能看到类似写法。
注意:不同 TS 版本、不同工具对提示效果略有差异,不要神话它。
七、可以复用的「最小配方」(给想抄走的读者)
如果你不做完整表单引擎,只想要「注册表 → 配置类型」,最小闭环是:
Components用as const或函数泛型锁住字面量Path = keyof Components | \${keyof}.子组件``GetComponent(Path)→PropsSchema = { [K in Path]: { component?: K; componentProps?: PropsOf<K> } }[Path]defineConfig(config: Schema[]) { return config }
先跑通一层路径,再加 Input.Textarea,最后再考虑 decorator 联动和 Vue 泛型伪装。
循序渐进,比一上来抄完整类型文件更不容易劝退。
八、我们踩过的坑
写类型文章如果只秀肌肉,读者很难信任。同步说几点现状:
- 路径目前按两层设计,更深的
A.B.C没有当成一等公民,因为用递归类型去提取太耗性能了,而且对绝大部分的组件库或者组件两层足够了。 - 不是所有 Vue 组件形态都能完美
InstanceType['$props'],复杂 HOC / 函数式组件要额外适配。 - 设计灵感来自 Formily 等成熟方案;价值在于把「注册表驱动 Schema 类型」这件事,在 Vue3 工程里落地,因为
formily的Json Schema写法没有比较全面的类型提示。 - 当组件注册过多时用vue模版写法的时候会造成
volar插件(vue3+ts的类型提示靠的就是这个插件)的属性联想失效(在vue模版里的volar插件的属性联想不是惰性的,会把泛型的所有情况提取成一个大的联合类型,当类型过多会造成类型爆炸),后面通过优化实现了注册50个组件没问题
vue模版写法的属性联想效果
小结
回顾一条线:
- 用注册表锁定
Components字面量类型 - 用模板字面量 +
ExtractChildren支持Input.Textarea - 用 Mapped Type 收联合,让
component/componentProps、decorator/decoratorProps联动 - 用恒等函数
defineSchema把类型挂到业务配置上 - 需要 Markup 时,再用
as new <T>()弥补 Vue 泛型组件不足
如果你也在做低代码、表单引擎、或「JSON 配置驱动 UI」,希望这套拆解能少走一点弯路。
有问题欢迎评论区聊;如果希望我把某一段拆成「从零手写 30 行可运行精简版」,也可以说,我可以再开一篇续作。