vue3 +TypeScrpit高阶运用:让低代码的Json Schema拥有完整的类型提示

82 阅读7分钟

Vue3 低代码里,我怎么让 Schema 的 componentProps 自动出提示

写 Schema 驱动表单时,最烦的不是渲染,而是:component: 'Radio' 写完,componentProps 还是 any,错字段名只能等运行时爆。
这篇文章讲的是:在 Vue3 里,如何从「组件注册表」一路推导出 Schema 的类型,让 IDE 在写配置时就能提示 props / slots / decorator。

文章偏实战,会穿插条件类型、模板字面量类型、映射类型,以及 Vue3「泛型组件偏弱」时的一个小技巧。思路上参考了 Formily 一类 Schema 表单方案,实现是我们自己项目里的简化版。


先看我们想要的 DX

image.png

理想用法大概长这样:

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
      ]
    }
  }
])

目标可以拆成四句:

  1. component 只能是注册过的组件名(含 Input.Textarea 这种两层路径)
  2. componentProps 跟着 component
  3. decorator / decoratorProps 成对联动
  4. slots 尽量对齐组件真实插槽

运行时 defineSchema 几乎什么都不做,它存在的意义主要是把类型拴在参数上

function defineSchema(schemas: SomeTypedSchema[]) {
  return schemas // 恒等函数,类型在编译期生效
}

DX效果如下

image.png image.png

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']

二、组件路径:支持 InputInput.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' 不够,还希望:

  • 选了 FormItemdecoratorProps 就是 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>]

读法:

  1. 对每个路径 K,生成 { decorator?: K; decoratorProps?: ... }
  2. 最后用 [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 版本、不同工具对提示效果略有差异,不要神话它。


七、可以复用的「最小配方」(给想抄走的读者)

如果你不做完整表单引擎,只想要「注册表 → 配置类型」,最小闭环是:

  1. Componentsas const 或函数泛型锁住字面量
  2. Path = keyof Components | \${keyof}.子组件``
  3. GetComponent(Path)Props
  4. Schema = { [K in Path]: { component?: K; componentProps?: PropsOf<K> } }[Path]
  5. defineConfig(config: Schema[]) { return config }

先跑通一层路径,再加 Input.Textarea,最后再考虑 decorator 联动和 Vue 泛型伪装。
循序渐进,比一上来抄完整类型文件更不容易劝退。


八、我们踩过的坑

写类型文章如果只秀肌肉,读者很难信任。同步说几点现状:

  1. 路径目前按两层设计,更深的 A.B.C 没有当成一等公民,因为用递归类型去提取太耗性能了,而且对绝大部分的组件库或者组件两层足够了。
  2. 不是所有 Vue 组件形态都能完美 InstanceType['$props'],复杂 HOC / 函数式组件要额外适配。
  3. 设计灵感来自 Formily 等成熟方案;价值在于把「注册表驱动 Schema 类型」这件事,在 Vue3 工程里落地,因为formily的Json Schema写法没有比较全面的类型提示。
  4. 当组件注册过多时用vue模版写法的时候会造成volar插件(vue3+ts的类型提示靠的就是这个插件)的属性联想失效(在vue模版里的volar插件的属性联想不是惰性的,会把泛型的所有情况提取成一个大的联合类型,当类型过多会造成类型爆炸),后面通过优化实现了注册50个组件没问题

vue模版写法的属性联想效果

image.png


小结

回顾一条线:

  1. 用注册表锁定 Components 字面量类型
  2. 用模板字面量 + ExtractChildren 支持 Input.Textarea
  3. 用 Mapped Type 收联合,让 component / componentPropsdecorator / decoratorProps 联动
  4. 用恒等函数 defineSchema 把类型挂到业务配置上
  5. 需要 Markup 时,再用 as new <T>() 弥补 Vue 泛型组件不足

如果你也在做低代码、表单引擎、或「JSON 配置驱动 UI」,希望这套拆解能少走一点弯路。

有问题欢迎评论区聊;如果希望我把某一段拆成「从零手写 30 行可运行精简版」,也可以说,我可以再开一篇续作。