记录:Vue如何正确推导Props类型

问题背景

在使用 Vue + TypeScript 的时候,应该都会有个疑问,这些选项都是怎么推导出来的?特别是 props 这个选项,有着好几种形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 数组
props: ['hello']

// 对象
props: {
hello: String,
}

// 对象里面的 prop 是数组
props: {
hello: [String, Boolean],
}

// 对象里面的 prop 是对象
props: {
hello: {
type: String,
default: 'hello',
},
}

有着这么多种形式,最后还能在其他地方推导出正确的类型

props

怎么做到的?

查看源码

我们从 Vue.extend 点进去,找到 vue.d.ts 中定义的 extend

1
2
3
4
5
export interface VueConstructor<V extends Vue = Vue> {
// ...省略其他部分代码
extend<Data, Methods, Computed, Props>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
// ...
}

顺着 Props 相关的类型,找到 vue 的 options.d.ts 文件中的 ComponentOptions 接口,这是我们使用 Vue.extend 时传入选项的接口。

可以看到 props?: PropsDef;

我们顺着这个 PropsDef 把 Props 相关的定义都提取出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export type Prop<T> = { (): T } | { new(...args: any[]): T & object } | { new(...args: string[]): Function }

export type PropType<T> = Prop<T> | Prop<T>[];

export type PropValidator<T> = PropOptions<T> | PropType<T>;

export interface PropOptions<T=any> {
type?: PropType<T>;
required?: boolean;
default?: T | null | undefined | (() => T | null | undefined);
validator?(value: T): boolean;
}

export type RecordPropsDefinition<T> = {
[K in keyof T]: PropValidator<T[K]>
}
export type ArrayPropsDefinition<T> = (keyof T)[];
export type PropsDefinition<T> = ArrayPropsDefinition<T> | RecordPropsDefinition<T>;

export interface ComponentOptions<
// ...
PropsDef=PropsDefinition<DefaultProps>,
Props=DefaultProps> {
props?: PropsDef;
// ...
}

简化代码

上边源码看起来还是有点多,我这边大概理解了推导过程,所以写一个简化版的便于理解,只考虑 props 为对象的情况:

1
2
3
4
5
Vue.extend({
props: {
test: String,
}
})

简化的类型代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Prop<T> = () => T

type PropDef<T> = {
[K in keyof T]: Prop<T[K]>
}

// 简化版 VueConstructor ,只保留 extend
interface PropsConstructor {
// 从这里可以得到 PropDef<Prop> = { test: String } , 所以 Prop = { test: string }
extend<Prop>(options?: PropDef<Prop>): Prop
}

declare const Props: PropsConstructor

const props = Props.extend({
test: String,
})

把上述简化的代码贴到 VSCode ,把鼠标移到 const props 上,可以发现正确推导出了 { test: string }

怎么做到的呢?这里我之前犯了个错误,把 Prop 这个类型当成是我们传进去的对象了,所以一直没法理解。

而实际上,PropDef<Prop> 才是我们传进去的类型,这边是 通过 PropDef<Prop> 推导出了 Prop

我们把 { test: string } 从上往下代入类型中,就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Prop<string> = () => string

type PropDef<{
test: string,
}> = {
// 这里 Prop<string> 就是相当于 String 构造函数
test: Prop<string>;
}

interface PropsConstructor {
// 这边的 PropDef<{ test: string }> 也就是 { test: Prop<string> }
extend<{ test: string }>(options?: PropDef<{ test: string }>): { test: string }
}

代入后,不知各位会不会清楚一些。

这边的重点就是 通过 PropDef<Prop> 推导出了 Prop

我们写 Vue.extend 的时候定义的是 PropDef<Prop> ,而 Prop 则是通过 TypeScript 推导出来的,因此我们在其他地方才能获得正确的类型。

其他情况

如果理解了上面那种情况,那其他情况其实就比较好理解了。

我们看到 vue 里面有这样一条类型定义:

1
export type PropsDefinition<T> = ArrayPropsDefinition<T> | RecordPropsDefinition<T>;

显然,这是区分了 props 是数组跟对象的情况。

数组

首先看数组:

1
export type ArrayPropsDefinition<T> = (keyof T)[];

数组的情况比较简单,假设我们传入的是 ['test'] ,那么就直接对应了这个类型,在 extend 时会转为一个对象,类型都是 any ,即 { test: any }

对象

然后是对象:

1
2
3
4
5
6
7
export type PropType<T> = Prop<T> | Prop<T>[];

export type PropValidator<T> = PropOptions<T> | PropType<T>;

export type RecordPropsDefinition<T> = {
[K in keyof T]: PropValidator<T[K]>
}

这边的 PropType<T> 基本对应了刚刚简化版的情况,不过还加了一个数组的情况,就是:

1
2
3
4
5
6
7
8
9
10
11
Vue.extend({
props: {
test: String,
},
})

Vue.extend({
props: {
test: [String, Boolean],
},
})

对应这两种情况。

最后的 PropOptions<T> 就是对应最复杂的那种情况了:

1
2
3
4
5
6
7
8
Vue.extend({
props: {
test: {
type: String,
default: 'xxx',
},
},
})

这个结构对应 PropOptions<T>

1
2
3
4
5
6
export interface PropOptions<T=any> {
type?: PropType<T>;
required?: boolean;
default?: T | null | undefined | (() => T | null | undefined);
validator?(value: T): boolean;
}

PropOptions 的那个 T 就是我们最后推导拿到的类型,即 { test: string }

多个 extend 方法的重载

vue.d.ts 中,可以看到 VueConstructor 有很多 extend 方法的重载:

1
2
3
4
5
6
7
8
9
10
11
export interface VueConstructor<V extends Vue = Vue> {
// ...省略部分代码

extend<Data, Methods, Computed, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;
extend<Data, Methods, Computed, Props>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
extend<PropNames extends string = never>(definition: FunctionalComponentOptions<Record<PropNames, any>, PropNames[]>): ExtendedVue<V, {}, {}, {}, Record<PropNames, any>>;
extend<Props>(definition: FunctionalComponentOptions<Props, RecordPropsDefinition<Props>>): ExtendedVue<V, {}, {}, {}, Props>;
extend(options?: ComponentOptions<V>): ExtendedVue<V, {}, {}, {}, {}>;

// ...
}

这边通过 options 参数的类型,可以看出应对不同 props 形式而做的重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export interface VueConstructor<V extends Vue = Vue> {
// ...省略部分代码

// props: ['test'] 的情况
extend<Data, Methods, Computed, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;

// props: { test: String } 或 props: { test: [String, Boolean] } 或 props: { test: { type: String } }
extend<Data, Methods, Computed, Props>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;

// functional 组件中 props: ['test'] 的情况
extend<PropNames extends string = never>(definition: FunctionalComponentOptions<Record<PropNames, any>, PropNames[]>): ExtendedVue<V, {}, {}, {}, Record<PropNames, any>>;

// functional 组件中 props 为对象的情况
extend<Props>(definition: FunctionalComponentOptions<Props, RecordPropsDefinition<Props>>): ExtendedVue<V, {}, {}, {}, Props>;

// 其他情况
extend(options?: ComponentOptions<V>): ExtendedVue<V, {}, {}, {}, {}>;

// ...
}

结论

通过 PropDef<Prop> 推导出了 Prop

就是本次记录的结论,你在 VueConstructor['extend'] 的多种重载中看到的 Props ,都是已经推导好的类型,而不是用户传进去的代码。

学过 TypeScript 的各位大佬们可能很快就理解了这种用法,我比较迟钝,记录一下hhh。