Skip to content

类型推断篇:让 props、ref 与 children 回归纯 TypeScript

Vue JSX 3.3 把普通组件的类型推断放回 TypeScript 自己的 JSX 类型系统里。配置 jsxImportSource: "vue-jsx" 后,TypeScript 会读取 vue-jsx/jsx-runtime 导出的 JSX namespace,这个 namespace 会告诉 TS: Vue 组件在 JSX 调用点应该如何检查——TypeScript 7(tsgo 原生版)也同样 开箱即用。

核心是 JSX.LibraryManagedAttributes。TypeScript 每次检查 <Comp ... /> 时, 都会调用这个类型。Vue JSX 就借这个 hook 扩展组件的 props:普通 props 保持原样,emit 变成 onXxxref 指向 exposed 类型, JSX children 则按 Vue slots 检查。Vapor 组件走的是同一条路径: defineVaporComponent 包裹的组件和普通 vapor 函数组件,props、emit、slots 和 ref(exposed)的推断与 Virtual DOM 组件完全一致。

English

类型入口

默认推荐的 TypeScript 配置只有 JSX runtime:

json
{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "vue-jsx"
  }
}

vue-jsx/jsx-runtime 会导出 runtime JSX namespace,TypeScript 通过 jsxImportSource 解析到它。

TypeScript 检查 JSX 的规则全部来自这个 namespace:什么算 element,props 和 children 从哪里读取、怎么扩展。它定义在 packages/runtime/src/jsx.ts

ts
export namespace JSX {
  export type Element = RenderResult

  export interface ElementAttributesProperty {
    $props: {}
  }

  export interface ElementChildrenAttribute {
    'v-slots': {}
  }

  export interface IntrinsicElements extends NativeElements {
    [name: string]: any
  }

  export interface IntrinsicAttributes extends ReservedProps {
    class?: ClassValue | undefined
    style?: StyleValue | undefined
  }

  export type LibraryManagedAttributes<Component, Props> = // ...
}

ElementAttributesProperty 让构造器形式的 Vue 组件从 $props 暴露 JSX props。 ElementChildrenAttribute 把 children 的检查目标指到 v-slots prop 类型上。剩下真正 的类型适配,交给 LibraryManagedAttributes

LibraryManagedAttributes 做了什么

下面是 runtime 里最关键的一段类型:

展开 LibraryManagedAttributes 完整类型
ts
export type LibraryManagedAttributes<Component, Props> = Omit<Props, 'ref'> &
  (Component extends abstract new (...args: any[]) => infer Instance
    ? {
        ref?: NodeRef<
          ExtractExposed<
            Props,
            'exposed' extends keyof Instance
              ? string extends keyof NonNullable<NonNullable<Instance['exposed']>>
                ? Instance
                : UnwrapRef<Instance['exposed']>
              : Instance
          >
        >
      } & ('v-slots' extends keyof Props
        ? {}
        : '$slots' extends keyof Instance
          ? SlotsToProps<Instance['$slots'] & {}>
          : 'slots' extends keyof Instance
            ? SlotsToProps<Instance['slots'] & {}>
            : {})
    : Component extends (
          props: any,
          ctx: {
            slots: infer Slots
            attrs: any
            emit: infer Emit
            expose: (exposed: infer Exposed extends Record<string, any>) => void
          },
        ) => any
      ? {
          ref?: 'ref' extends keyof Props
            ? Props['ref']
            : NodeRef<
                string extends keyof Exposed
                  ? NativeElement | VaporComponentInstance
                  : UnwrapRef<Exposed>
              >
        } & EmitFnToProps<Emit, keyof Props> &
          ('v-slots' extends keyof Props ? {} : SlotsToProps<Slots & {}>)
      : {
          ref?: VNodeRef
        })

这段类型分三条路。

  1. 构造器组件,也就是 Vue defineComponent 返回的组件,会暴露一个 instance 类型。 Vue JSX 从这个 instance 上读取 $props$slotsslots,以及可选的 exposed shape。

  2. 直接函数组件的信息来自函数签名:第一个参数是 props,第二个参数里的 slotsemitexpose 分别生成 children、事件和 ref 类型。

  3. 不认识的组件回退到 Vue 常规的 VNodeRef

Props 和 Emits

普通 props 最简单:删除 ref 后,原始 Props 仍然保留在 attribute 类型里。所以 literal props、generic props、union、required props 都仍然按正常 TypeScript 规则 工作。

emit 只需要给函数式组件补一层映射:

ts
export type EmitFnToProps<T, ExcludeKeys extends PropertyKey = ''> = T extends (
  event: infer Event extends string,
  ...args: infer Args
) => any
  ? string extends Event
    ? {}
    : {
        readonly [
          K in Event as `on${Capitalize<K>}` extends ExcludeKeys ? never : `on${Capitalize<K>}`
        ]?: (...args: Args) => any
      }
  : {}

如果组件里能写 emit('change', value),JSX 调用点就会得到一个 onChange prop, callback 参数就是同一个 payload。假如同名 prop 已经存在,ExcludeKeys 会避免重复 生成。

tsx
import { type EmitFn } from 'vue'

const Counter = (
  props: { value: number },
  { emit }: { emit: EmitFn<{ change: [value: number] }> },
) => <button onClick={() => emit('change', props.value + 1)} />

export default () => (
  <Counter
    value={1}
    onChange={(value) => {
      value.toFixed()
    }}
  />
)

这里不需要任何编辑器插件去虚拟生成 onChange。它是在 TypeScript 原生 JSX 检查过程中 由 LibraryManagedAttributes 推出来的。

Ref 指向 Exposed

ref 在 Vue JSX 3.3 里不再只是透传 VNodeRef,而是根据组件暴露的东西生成 更精确的类型。组件公开类型里已经知道自己暴露了什么,JSX 层只需要把它提取 出来。

ts
export type NodeRef<T> = ((ref: T | null, refs: Record<string, any>) => void) | Ref | string

declare const exposedType: unique symbol

export type ExtractExposed<Props, Default = never> = typeof exposedType extends keyof Props
  ? Exclude<Props[typeof exposedType], undefined>
  : Default

export type ExposedToProps<T extends Record<string, any>> = string extends keyof T
  ? {}
  : [keyof T] extends [never]
    ? {}
    : {
        readonly [exposedType]?: T
        readonly ref?: NodeRef<T>
      }

构造器组件会优先看 instance 上有没有 exposed。如果 exposed 是明确对象,ref 拿到的是 UnwrapRef<Instance['exposed']>;如果 exposed 太宽,则回退到完整组件实例。

直接函数组件则更直接:ctx.expose() 的参数就是 ref 目标。

tsx
import { computed, type Ref } from 'vue'

const Doubler = (
  props: { count: number },
  { expose }: { expose: (exposed: { double: Ref<number> }) => void },
) => {
  const double = computed(() => props.count * 2)
  expose({ double })
  return <span>{props.count}</span>
}

export default () => (
  <Doubler
    count={2}
    ref={(exposed) => {
      exposed?.double.toFixed()
    }}
  />
)

callback 里看到的是 { double: number } | null,因为这条类型链路经过了 UnwrapRef

Children 就是 Slots

slot 映射是 Vue 用户最关心的部分。JSX children 是语法,但 Vue children 是 slots。 Vue JSX 用 ElementChildrenAttributeSlotsToProps 把两者接起来。

ts
export type SlotsToProps<
  RawSlots extends SlotsType | Record<string, any> = Record<string, any>,
  Slots = RawSlots extends SlotsType ? SetupContext<EmitsOptions, RawSlots>['slots'] : RawSlots,
> = string extends keyof Slots
  ? {}
  : [keyof Slots] extends [never]
    ? {}
    : {
        readonly 'v-slots'?:
          ('default' extends keyof Slots ? Slots['default'] | Slots : Slots) | NoInfer<NodeChild>
      }

这里的妙处在于两个类型的配合。LibraryManagedAttributes 先为组件生成一个 携带 slots 类型的 v-slots prop;ElementChildrenAttribute 再告诉 TypeScript:JSX children 不检查 children prop,而是检查 v-slots——它顶替了 React 里 children 的位置。Vue JSX 本来就没有 children prop 的概念,编译期 却有现成的 v-slots 指令编译,类型层和编译层就这样接上了。所以写 <Panel>{({ active }) => ...}</Panel> 时,TypeScript 实际是拿 children 函数 去匹配 v-slots 上的 Slots['default'] | Slots 类型,active 由此从默认槽 签名推断出来。

这段辅助类型做了几件细活:

  1. 同时接受 Vue SlotsType 和普通 slots record。
  2. 建模 children 的几种写法:有 default 时,slot 函数可以直接写成 children; 完整 slots object 同样支持——直接当 children 传,或用 v-slots 指令。
tsx
import { defineComponent } from 'vue'

const Panel = defineComponent(
  (_, { slots }: { slots: { default?: () => []; footer?: () => [] } }) => {
    return () => <section>{slots.default?.()}</section>
  },
)

export default () => (
  <>
    <Panel>{() => <p />}</Panel>
    <Panel>{{ default: () => <p />, footer: () => <p /> }}</Panel>
    <Panel v-slots={{ default: () => <p />, footer: () => <p /> }} />
  </>
)

为什么不再需要编辑器插件

以前 JSX 类型经常依赖编辑器插件,是因为标准 TypeScript 在 JSX 调用点看不到足够多 Vue 语义。插件需要创建虚拟文件或额外类型上下文,让 props、emits、slots、refs 看起来 像 Vue。

Vue JSX 3.3 把这件事收进包声明里:

  1. jsxImportSource 选择 vue-jsx/jsx-runtime
  2. runtime 暴露 JSX namespace。
  3. TypeScript 向 LibraryManagedAttributes<Component, Props> 请求最终 attribute 类型。
  4. Vue JSX 返回普通 props,加上生成的 onXxx、typed ref、以及通过 v-slots 建模的 children slots。

宏和编辑器语法增强仍然可以作为可选能力存在,但它们不是默认类型方案。对普通组件编写 来说,类型真相来自组件自己的 TypeScript 类型,推断发生在原生 TypeScript checker 里。

试试看

Stepper 是一个普通的 vapor 函数组件,四件事一次占全:带类型的 propsinit)、 变成 onChangeemit(处理器里推断出 value: number)、写成 children 的 default 插槽(value 的类型来自插槽签名)、以及流入 ref callback 类型的 expose——reset 就是从这里来的。