类型推断篇:让 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 变成 onXxx,ref 指向 exposed 类型, JSX children 则按 Vue slots 检查。Vapor 组件走的是同一条路径: defineVaporComponent 包裹的组件和普通 vapor 函数组件,props、emit、slots 和 ref(exposed)的推断与 Virtual DOM 组件完全一致。
类型入口
默认推荐的 TypeScript 配置只有 JSX runtime:
{
"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:
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 完整类型
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
})这段类型分三条路。
构造器组件,也就是 Vue
defineComponent返回的组件,会暴露一个 instance 类型。 Vue JSX 从这个 instance 上读取$props、$slots或slots,以及可选的exposedshape。直接函数组件的信息来自函数签名:第一个参数是 props,第二个参数里的
slots、emit、expose分别生成 children、事件和 ref 类型。不认识的组件回退到 Vue 常规的
VNodeRef。
Props 和 Emits
普通 props 最简单:删除 ref 后,原始 Props 仍然保留在 attribute 类型里。所以 literal props、generic props、union、required props 都仍然按正常 TypeScript 规则 工作。
emit 只需要给函数式组件补一层映射:
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 会避免重复 生成。
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 层只需要把它提取 出来。
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 目标。
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 用 ElementChildrenAttribute 和 SlotsToProps 把两者接起来。
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 由此从默认槽 签名推断出来。
这段辅助类型做了几件细活:
- 同时接受 Vue
SlotsType和普通 slots record。 - 建模 children 的几种写法:有
default时,slot 函数可以直接写成 children; 完整 slots object 同样支持——直接当 children 传,或用v-slots指令。
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 把这件事收进包声明里:
jsxImportSource选择vue-jsx/jsx-runtime。- runtime 暴露
JSXnamespace。 - TypeScript 向
LibraryManagedAttributes<Component, Props>请求最终 attribute 类型。 - Vue JSX 返回普通 props,加上生成的
onXxx、typedref、以及通过v-slots建模的 children slots。
宏和编辑器语法增强仍然可以作为可选能力存在,但它们不是默认类型方案。对普通组件编写 来说,类型真相来自组件自己的 TypeScript 类型,推断发生在原生 TypeScript checker 里。
试试看
Stepper 是一个普通的 vapor 函数组件,四件事一次占全:带类型的 props(init)、 变成 onChange 的 emit(处理器里推断出 value: number)、写成 children 的 default 插槽(value 的类型来自插槽签名)、以及流入 ref callback 类型的 expose——reset 就是从这里来的。