在 Vue 3 的 Composition API 中,采用了 setup() 作为组件的入口函数。
在结合了 TypeScript 的情况下,传统的 Vue.extend 等定义方法无法对此类组件给出正确的参数类型推断,这就需要引入 defineComponent() 组件包装函数,其在 rfc 文档中的说明为:
https://composition-api.vuejs.org/api.html#setup
interface Data {
[key: string]: unknown
}
interface SetupContext {
attrs: Data
slots: Slots
emit: (event: string, ...args: unknown[]) => void
}
function setup(props: Data, context: SetupContext): Data
To get type inference for the arguments passed to
setup()
, the use ofdefineComponent
is needed.
文档中说得相当简略,实际写起来难免还是有丈二和尚摸不着头脑的时候。
本文将采用与本系列之前两篇相同的做法,从单元测试入手,结合 ts 类型定义,尝试弄懂 defineComponent() 的明确用法。
????顺藤摸瓜:用单元测试读懂 vue3 watch 函数
????顺藤摸瓜:用单元测试读懂 vue3 中的 provide/inject
考虑到篇幅和相似性,本文只采用 vue 2.x + @vue/composition-api 的组合进行说明,vue 3 中的签名方式稍有不同,读者可以自行参考并尝试。
在 @vue/composition-api
项目中,test/types/defineComponent.spec.ts
中的几个测试用例非常直观的展示了几种“合法”的 TS 组件方式 (顺序和原文件中有调整):
it('no props', () => {
const App = defineComponent({
setup(props, ctx) {
//...
return () => null
},
})
new Vue(App)
//...
})
it('should accept tuple props', () => {
const App = defineComponent({
props: ['p1', 'p2'],
setup(props) {
//...
},
})
new Vue(App)
//...
})
it('should infer props type', () => {
const App = defineComponent({
props: {
a: {
type: Number,
default: 0,
},
b: String, // 只简写类型
},
setup(props, ctx) {
//...
return () => null
},
})
new Vue(App)
//...
})
组件选项中的 props 类型将被推断为 { readonly foo: string; readonly bar: string; readonly zoo?: string }
it('infer the required prop', () => {
const App = defineComponent({
props: {
foo: {
type: String,
required: true,
},
bar: {
type: String,
default: 'default',
},
zoo: {
type: String,
required: false,
},
},
propsData: {
foo: 'foo',
},
setup(props) {
//...
return () => null
},
})
new Vue(App)
//...
})
it('custom props interface', () => {
interface IPropsType {
b: string
}
const App = defineComponent({ // 写明接口
props: {
b: {}, // 只简写空对象
},
setup(props, ctx) {
//...
return () => null
},
})
new Vue(App)
//...
})
it('custom props type function', () => {
interface IPropsTypeFunction {
fn: (arg: boolean) => void
}
const App = defineComponent({ // 写明接口
props: {
fn: Function as PropType<(arg: boolean) => void>, // 写明类型
},
setup(props, ctx) {
//...
return () => null
},
})
new Vue(App)
//...
})
it('custom props type inferred from PropType', () => {
interface User {
name: string
}
const App = defineComponent({
props: {
user: Object as PropType,
func: Function as PropType<() => boolean>,
userFunc: Function as PropType<(u: User) => User>,
},
setup(props) {
//...
return () => null
},
})
new Vue(App)
//...
})
在阅读 defineComponent 函数的签名形式之前,为了便于解释,先来看看其关联的几个基础类型定义,大致理解其作用即可,毋需深究:
此类型没太多好说的,就是我们熟悉的 Vue 2.x 组件 options 的定义:
// vue 2.x 项目中的 types/options.d.ts
export interface ComponentOptions<
V extends Vue,
Data=DefaultData,
Methods=DefaultMethods,
Computed=DefaultComputed,
PropsDef=PropsDefinition,
Props=DefaultProps> {
data?: Data;
props?: PropsDef;
propsData?: object;
computed?: Accessors;
methods?: Methods;
watch?: Record | WatchHandler>;
el?: Element | string;
template?: string;
// hack is for functional component type inference, should not be used in user code
render?(createElement: CreateElement, hack: RenderContext): VNode;
renderError?(createElement: CreateElement, err: Error): VNode;
staticRenderFns?: ((createElement: CreateElement) => VNode)[];
beforeCreate?(this: V): void;
created?(): void;
beforeDestroy?(): void;
destroyed?(): void;
beforeMount?(): void;
mounted?(): void;
beforeUpdate?(): void;
updated?(): void;
activated?(): void;
deactivated?(): void;
errorCaptured?(err: Error, vm: Vue, info: string): boolean | void;
serverPrefetch?(this: V): Promise;
directives?: { [key: string]: DirectiveFunction | DirectiveOptions };
components?: { [key: string]: Component | AsyncComponent };
transitions?: { [key: string]: object };
filters?: { [key: string]: Function };
provide?: object | (() => object);
inject?: InjectOptions;
model?: {
prop?: string;
event?: string;
};
parent?: Vue;
mixins?: (ComponentOptions | typeof Vue)[];
name?: string;
// TODO: support properly inferred 'extends'
extends?: ComponentOptions | typeof Vue;
delimiters?: [string, string];
comments?: boolean;
inheritAttrs?: boolean;
}
在后面的定义中可以看到,该类型被 @vue/composition-api
引用后一般取别名为 Vue2ComponentOptions 。
继承自符合当前泛型约束的 Vue2ComponentOptions,并重写了自己的几个可选属性:
interface ComponentOptionsBase<
Props,
D = Data,
C extends ComputedOptions = {},
M extends MethodOptions = {}
>
extends Omit<
Vue2ComponentOptions,
'data' | 'computed' | 'method' | 'setup' | 'props'
> {
data?: (this: Props, vm: Props) => D
computed?: C
methods?: M
}
顾名思义,这就是 setup() 函数中第二个参数 context 的类型:
export interface SetupContext {
readonly attrs: Record
readonly slots: { [key: string]: (...args: any[]) => VNode[] }
readonly parent: ComponentInstance | null
readonly root: ComponentInstance
readonly listeners: { [key: string]: Function }
emit(event: string, ...args: any[]): void
}
export type Data = { [key: string]: unknown }
也是我们熟悉的 computed 选项键值对,值为普通的函数(即单个 getter)或 { getter, setter }
的写法:
export type ComputedOptions = Record<
string,
ComputedGetter | WritableComputedOptions
>
export interface MethodOptions {
[key: string]: Function
}
基本就是为了能同时适配 options api 和类组件两种定义,弄出来的一个类型壳子:
// src/component/componentProxy.ts
// for Vetur and TSX support
type VueConstructorProxy = VueConstructor & {
new (...args: any[]): ComponentRenderProxy<
ExtractPropTypes,
ShallowUnwrapRef,
ExtractPropTypes
>
}
type DefaultData = object | ((this: V) => object)
type DefaultMethods = { [key: string]: (this: V, ...args: any[]) => any }
type DefaultComputed = { [key: string]: any }
export type VueProxy<
PropsOptions,
RawBindings,
Data = DefaultData,
Computed = DefaultComputed,
Methods = DefaultMethods
> = Vue2ComponentOptions<
Vue,
ShallowUnwrapRef & Data,
Methods,
Computed,
PropsOptions,
ExtractPropTypes
> &
VueConstructorProxy
代理上的公开属性,被用作模版中的渲染上下文(相当于 render 中的 this
):
// src/component/componentProxy.ts
export type ComponentRenderProxy<
P = {}, // 从 props 选项中提取的类型
B = {}, // 从 setup() 中返回的被称作 RawBindings 的绑定值类型
D = {}, // data() 中返回的值类型
C extends ComputedOptions = {},
M extends MethodOptions = {},
PublicProps = P
> = {
$data: D
$props: Readonly
$attrs: Data
} & Readonly
&
ShallowUnwrapRef &
D &
M &
ExtractComputedReturns &
Omit
也就是 String
、String[]
等:
export type PropType = PropConstructor | PropConstructor[]
type PropConstructor =
| { new (...args: any[]): T & object }
| { (): T }
| { new (...args: string[]): Function }
export interface PropOptions {
type?: PropType | true | null
required?: boolean
default?: T | DefaultFactory | null | undefined
validator?(value: unknown): boolean
}
export type ComponentPropsOptions =
| ComponentObjectPropsOptions
| string[]
export type ComponentObjectPropsOptions
= {
[K in keyof P]: Prop
| null
}
export type Prop = PropOptions | PropType
因为 defineComponent 的几种签名定义主要就是围绕 props 进行的,那么就先回顾一下官网文档中的几度说明:
https://cn.vuejs.org/v2/guide/components.html#%E9%80%9A%E8%BF%87-Prop-%E5%90%91%E5%AD%90%E7%BB%84%E4%BB%B6%E4%BC%A0%E9%80%92%E6%95%B0%E6%8D%AE
Prop 是你可以在组件上注册的一些自定义 attribute。当一个值传递给一个 prop attribute 的时候,它就变成了那个组件实例的一个 property。为了给博文组件传递一个标题,我们可以用一个
props
选项将其包含在该组件可接受的 prop 列表中:
Vue.component('blog-post', {
props: ['title'],
template: '{{ title }}
'
})
https://cn.vuejs.org/v2/guide/components-props.html#Prop-%E7%B1%BB%E5%9E%8B
...到这里,我们只看到了以字符串数组形式列出的 prop:
props: ['title', 'likes', 'isPublished', 'commentIds', 'author']
但是,通常你希望每个 prop 都有指定的值类型。这时,你可以以对象形式列出 prop,这些 property 的名称和值分别是 prop 各自的名称和类型:
props: {
title: String,
likes: Number,
isPublished: Boolean,
commentIds: Array,
author: Object,
callback: Function,
contactsPromise: Promise // or any other constructor
}
https://cn.vuejs.org/v2/guide/components-props.html#Prop-%E9%AA%8C%E8%AF%81
为了定制 prop 的验证方式,你可以为
props
中的值提供一个带有验证需求的对象,而不是一个字符串数组。例如:
Vue.component('my-component', {
props: {
// 基础的类型检查 (`null` 和 `undefined` 会通过任何类型验证)
propA: Number,
// 多个可能的类型
propB: [String, Number],
// 必填的字符串
propC: {
type: String,
required: true
},
// 带有默认值的数字
propD: {
type: Number,
default: 100
},
// 带有默认值的对象
propE: {
type: Object,
// 对象或数组默认值必须从一个工厂函数获取
default: function () {
return { message: 'hello' }
}
},
// 自定义验证函数
propF: {
validator: function (value) {
// 这个值必须匹配下列字符串中的一个
return ['success', 'warning', 'danger'].indexOf(value) !== -1
}
}
}
})
有了上面这些印象和准备,正式来看看 defineComponent() 函数的几种签名:
这种签名的 defineComponent 函数,将适配一个没有 props 定义的 options 对象参数,
// overload 1: object format with no props
export function defineComponent<
RawBindings,
D = Data,
C extends ComputedOptions = {},
M extends MethodOptions = {}
>(
options: ComponentOptionsWithoutProps
): VueProxy
也就是其对应的 VueProxy 类型之 PropsOptions 定义部分为 unknown :
// src/component/componentOptions.ts
export type ComponentOptionsWithoutProps<
Props = unknown,
RawBindings = Data,
D = Data,
C extends ComputedOptions = {},
M extends MethodOptions = {}
> = ComponentOptionsBase & {
props?: undefined
emits?: string[] | Record boolean)>
setup?: SetupFunction
} & ThisType>
在上面的测试用例中就是 [test case 1] 的情况。
props 将被推断为 { [key in PropNames]?: any }
类型:
// overload 2: object format with array props declaration
// props inferred as { [key in PropNames]?: any }
// return type is for Vetur and TSX support
export function defineComponent<
PropNames extends string,
RawBindings = Data,
D = Data,
C extends ComputedOptions = {},
M extends MethodOptions = {},
PropsOptions extends ComponentPropsOptions = ComponentPropsOptions
>(
options: ComponentOptionsWithArrayProps
): VueProxy, RawBindings, D, C, M>
将 props 匹配为属性名组成的字符串数组:
// src/component/componentOptions.ts
export type ComponentOptionsWithArrayProps<
PropNames extends string = string,
RawBindings = Data,
D = Data,
C extends ComputedOptions = {},
M extends MethodOptions = {},
Props = Readonly<{ [key in PropNames]?: any }>
> = ComponentOptionsBase & {
props?: PropNames[]
emits?: string[] | Record boolean)>
setup?: SetupFunction
} & ThisType>
在上面的测试用例中就是 [test case 2] 的情况。
// overload 3: object format with object props declaration
// see `ExtractPropTypes` in ./componentProps.ts
export function defineComponent<
Props,
RawBindings = Data,
D = Data,
C extends ComputedOptions = {},
M extends MethodOptions = {},
PropsOptions extends ComponentPropsOptions = ComponentPropsOptions
>(
options: HasDefined extends true
? ComponentOptionsWithProps
: ComponentOptionsWithProps
): VueProxy
这里要注意的是,如果没有明确指定([test case 5、6]) Props 泛型,那么就利用 ExtractPropTypes 从 props 中每项的 PropType 类型定义自动推断([test case 7]) 。
// src/component/componentOptions.ts
export type ComponentOptionsWithProps<
PropsOptions = ComponentPropsOptions,
RawBindings = Data,
D = Data,
C extends ComputedOptions = {},
M extends MethodOptions = {},
Props = ExtractPropTypes
> = ComponentOptionsBase & {
props?: PropsOptions
emits?: string[] | Record boolean) >
setup?: SetupFunction
} & ThisType>
// src/component/componentProps.ts
export type ExtractPropTypes<
O,
MakeDefaultRequired extends boolean = true
> = O extends object
? { [K in RequiredKeys]: InferPropType } &
{ [K in OptionalKeys]?: InferPropType }
: { [K in string]: any }
// prettier-ignore
type InferPropType = T extends null
? any // null & true would fail to infer
: T extends { type: null | true }
? any // As TS issue https://github.com/Microsoft/TypeScript/issues/14829 // somehow `ObjectConstructor` when inferred from { (): T } becomes `any` // `BooleanConstructor` when inferred from PropConstructor(with PropMethod) becomes `Boolean`
: T extends ObjectConstructor | { type: ObjectConstructor }
? { [key: string]: any }
: T extends BooleanConstructor | { type: BooleanConstructor }
? boolean
: T extends FunctionConstructor
? Function
: T extends Prop
? ExtractCorrectPropType : T;
除去单元测试中几种基本的用法,在以下的 ParentDialog 组件中,主要有这几个实际开发中要注意的点:
自定义组件和全局组件的写法
inject、ref 等的类型约束
setup 的写法和相应 h 的注入问题
tsx 中 v-model 和 scopedSlots 的写法
引入 defineComponent() 以正确推断 setup() 组件的参数类型
defineComponent 可以正确适配无 props、数组 props 等形式
defineComponent 可以接受显式的自定义 props 接口或从属性验证对象中自动推断
在 tsx 中,element-ui 等全局注册的组件依然要用 kebab-case 形式
在 tsx 中,v-model 要用 model={{ value, callback }}
写法
在 tsx 中,scoped slots 要用 scopedSlots={{ foo: (scope) => (
写法
defineComponent 并不适用于函数式组件,应使用 RenderContext
解决
--End--
查看更多前端好文
请搜索 云前端 或 fewelife 关注公众号
转载请注明出处