在 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): DataTo get type inference for the arguments passed to setup(), the use of defineComponent 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 组件方式 (顺序和原文件中有调整):
组件选项中的 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) //... })在阅读 defineComponent 函数的签名形式之前,为了便于解释,先来看看其关联的几个基础类型定义,大致理解其作用即可,毋需深究:
此类型没太多好说的,就是我们熟悉的 Vue 2.x 组件 options 的定义:
// vue 2.x 项目中的 types/options.d.ts export interface ComponentOptions< V extends Vue, Data=DefaultData<V>, Methods=DefaultMethods<V>, Computed=DefaultComputed, PropsDef=PropsDefinition<DefaultProps>, Props=DefaultProps> { data?: Data; props?: PropsDef; propsData?: object; computed?: Accessors<Computed>; methods?: Methods; watch?: Record<string, WatchOptionsWithHandler<any> | WatchHandler<any>>; el?: Element | string; template?: string; // hack is for functional component type inference, should not be used in user code render?(createElement: CreateElement, hack: RenderContext<Props>): 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<void>; directives?: { [key: string]: DirectiveFunction | DirectiveOptions }; components?: { [key: string]: Component<any, any, any, any> | AsyncComponent<any, any, any, any> }; transitions?: { [key: string]: object }; filters?: { [key: string]: Function }; provide?: object | (() => object); inject?: InjectOptions; model?: { prop?: string; event?: string; }; parent?: Vue; mixins?: (ComponentOptions<Vue> | typeof Vue)[]; name?: string; // TODO: support properly inferred 'extends' extends?: ComponentOptions<Vue> | 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<Vue, D, M, C, Props>, 'data' | 'computed' | 'method' | 'setup' | 'props' > { data?: (this: Props, vm: Props) => D computed?: C methods?: M }顾名思义,这就是 setup() 函数中第二个参数 context 的类型:
export interface SetupContext { readonly attrs: Record<string, string> 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 }也是我们熟悉的 computed 选项键值对,值为普通的函数(即单个 getter)或 { getter, setter } 的写法:
export type ComputedOptions = Record< string, ComputedGetter<any> | WritableComputedOptions<any> >基本就是为了能同时适配 options api 和类组件两种定义,弄出来的一个类型壳子:
// src/component/componentProxy.ts // for Vetur and TSX support type VueConstructorProxy<PropsOptions, RawBindings> = VueConstructor & { new (...args: any[]): ComponentRenderProxy< ExtractPropTypes<PropsOptions>, ShallowUnwrapRef<RawBindings>, ExtractPropTypes<PropsOptions, false> > } type DefaultData<V> = object | ((this: V) => object) type DefaultMethods<V> = { [key: string]: (this: V, ...args: any[]) => any } type DefaultComputed = { [key: string]: any } export type VueProxy< PropsOptions, RawBindings, Data = DefaultData<Vue>, Computed = DefaultComputed, Methods = DefaultMethods<Vue> > = Vue2ComponentOptions< Vue, ShallowUnwrapRef<RawBindings> & Data, Methods, Computed, PropsOptions, ExtractPropTypes<PropsOptions, false> > & VueConstructorProxy<PropsOptions, RawBindings>代理上的公开属性,被用作模版中的渲染上下文(相当于 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<P & PublicProps> $attrs: Data } & Readonly<P> & ShallowUnwrapRef<B> & D & M & ExtractComputedReturns<C> & Omit<Vue, '$data' | '$props' | '$attrs'>也就是 String、String[] 等:
export type PropType<T> = PropConstructor<T> | PropConstructor<T>[] type PropConstructor<T> = | { new (...args: any[]): T & object } | { (): T } | { new (...args: string[]): Function }因为 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: '<h3>{{ title }}</h3>' })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<unknown, RawBindings, D, C, M> ): VueProxy<unknown, RawBindings, D, C, M>也就是其对应的 VueProxy 类型之 PropsOptions 定义部分为 unknown :
// src/component/componentOptions.ts export type ComponentOptionsWithoutProps< Props = unknown, RawBindings = Data, D = Data, C extends ComputedOptions = {}, M extends MethodOptions = {} > = ComponentOptionsBase<Props, D, C, M> & { props?: undefined emits?: string[] | Record<string, null | ((emitData: any) => boolean)> setup?: SetupFunction<Props, RawBindings> } & ThisType<ComponentRenderProxy<Props, RawBindings, D, C, M>>在上面的测试用例中就是 [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<PropNames, RawBindings, D, C, M> ): VueProxy<Readonly<{ [key in PropNames]?: any }>, 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, D, C, M> & { props?: PropNames[] emits?: string[] | Record<string, null | ((emitData: any) => boolean)> setup?: SetupFunction<Props, RawBindings> } & ThisType<ComponentRenderProxy<Props, RawBindings, D, C, M>>在上面的测试用例中就是 [test case 2] 的情况。
这里要注意的是,如果没有明确指定([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<PropsOptions> > = ComponentOptionsBase<Props, D, C, M> & { props?: PropsOptions emits?: string[] | Record<string, null | ((emitData: any) => boolean) > setup?: SetupFunction<Props, RawBindings> } & ThisType<ComponentRenderProxy<Props, RawBindings, D, C, M>> // src/component/componentProps.ts export type ExtractPropTypes< O, MakeDefaultRequired extends boolean = true > = O extends object ? { [K in RequiredKeys<O, MakeDefaultRequired>]: InferPropType<O[K]> } & { [K in OptionalKeys<O, MakeDefaultRequired>]?: InferPropType<O[K]> } : { [K in string]: any } // prettier-ignore type InferPropType<T> = 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<infer V> ? ExtractCorrectPropType<V> : 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) => (<Bar/>) }} 写法
defineComponent 并不适用于函数式组件,应使用 RenderContext<interface> 解决
--End--
查看更多前端好文请搜索 云前端 或 fewelife 关注公众号转载请注明出处