在 Vue 3 的 Composition API 中,采用 setup() 作为组件的入口函数。
在结合了 TypeScript 的情况下,传统的 Vue.extends 等定义方法无法对此类组件给出正确的参数类型推断,这就需要引入 defineComponent() 组件包装函数,其在 rfc 文档中的说明为:
composition-api.vuejs.org/api.html#se…
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() 的明确用法。
考虑到篇幅和相似性,本文只采用 vue 2.x + @vue/composition-api 的组合进行说明,vue 3 中的签名方式稍有不同,读者可以自行参考并尝试。
I. 测试用例
在 @vue/composition-api
项目中,test/types/defineComponent.spec.ts
中的几个测试用例非常直观的展示了几种“合法”的 TS 组件方式 (顺序和原文件中有调整):
[test case 1] 无 props
it('no props', () => { const App = defineComponent({ setup(props, ctx) { //... return () => null }, }) new Vue(App) //... })
[test case 2] 数组形式的 props
it('should accept tuple props', () => { const App = defineComponent({ props: ['p1', 'p2'], setup(props) { //... }, }) new Vue(App) //... })
[test case 3] 自动推断 props
it('should infer props type', () => { const App = defineComponent({ props: { a: { type: Number, default: 0, }, b: String, // 只简写类型 }, setup(props, ctx) { //... return () => null }, }) new Vue(App) //... })
[test case 4] 推断是否必须
组件选项中的 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) //... })
[test case 5] 显式自定义 props 接口
it('custom props interface', () => { interface IPropsType { b: string } const App = defineComponent<IPropsType>({ // 写明接口 props: { b: {}, // 只简写空对象 }, setup(props, ctx) { //... return () => null }, }) new Vue(App) //... })
[test case 6] 显式接口和显式类型
it('custom props type function', () => { interface IPropsTypeFunction { fn: (arg: boolean) => void } const App = defineComponent<IPropsTypeFunction>({ // 写明接口 props: { fn: Function as PropType<(arg: boolean) => void>, // 写明类型 }, setup(props, ctx) { //... return () => null }, }) new Vue(App) //... })
[test case 7] 从显式类型推断 props
it('custom props type inferred from PropType', () => { interface User { name: string } const App = defineComponent({ props: { user: Object as PropType<User>, func: Function as PropType<() => boolean>, userFunc: Function as PropType<(u: User) => User>, }, setup(props) { //... return () => null }, }) new Vue(App) //... })
II. 一些基础类型定义
在阅读 defineComponent 函数的签名形式之前,为了便于解释,先来看看其关联的几个基础类型定义,大致理解其作用即可,毋需深究:
引自 vue 2.x 中的 options 基础类型接口
此类型没太多好说的,就是我们熟悉的 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 。
composition 式组件 options 类型基础接口
继承自符合当前泛型约束的 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 函数上下文类型接口
顾名思义,这就是 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 }
普通键值数据
export type Data = { [key: string]: unknown }
计算值选项类型
也是我们熟悉的 computed 选项键值对,值为普通的函数(即单个 getter)或 { getter, setter }
的写法:
export type ComputedOptions = Record< string, ComputedGetter<any> | WritableComputedOptions<any> >
方法选项类型
export interface MethodOptions { [key: string]: Function }
Vue 组件代理
基本就是为了能同时适配 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 }
属性验证类型定义
export interface PropOptions<T = any> { type?: PropType<T> | true | null required?: boolean default?: T | DefaultFactory<T> | null | undefined validator?(value: unknown): boolean }
兼容字符串和验证对象的 props 类型定义
export type ComponentPropsOptions<P = Data> = | ComponentObjectPropsOptions<P> | string[] export type ComponentObjectPropsOptions<P = Data> = { [K in keyof P]: Prop<P[K]> | null } export type Prop<T> = PropOptions<T> | PropType<T>
III. 官网文档中的 props
因为 defineComponent 的几种签名定义主要就是围绕 props 进行的,那么就先回顾一下官网文档中的几度说明:
Prop 是你可以在组件上注册的一些自定义 attribute。当一个值传递给一个 prop attribute 的时候,它就变成了那个组件实例的一个 property。为了给博文组件传递一个标题,我们可以用一个
props
选项将其包含在该组件可接受的 prop 列表中:
Vue.component('blog-post', { props: ['title'], template: '<h3>{{ title }}</h3>' })
...到这里,我们只看到了以字符串数组形式列出的 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 }
为了定制 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 } } } })
IV. defineComponent 函数签名
有了上面这些印象和准备,正式来看看 defineComponent() 函数的几种签名:
签名 1:无 props
这种签名的 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] 的情况。
签名 2:数组形式的 props
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] 的情况。
签名3:非数组 props
// 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<Props> extends true ? ComponentOptionsWithProps<PropsOptions, RawBindings, D, C, M, Props> : ComponentOptionsWithProps<PropsOptions, RawBindings, D, C, M> ): VueProxy<PropsOptions, RawBindings, D, C, M>
这里要注意的是,如果没有明确指定([test case 5、6]) Props 泛型,那么就利用 ExtractPropTypes 从 props 中每项的 PropType 类型定义自动推断([test case 7]) 。
ts
代码解读
复制代码
// 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;
V. 开发实践
除去单元测试中几种基本的用法,在以下的 ParentDialog 组件中,主要有这几个实际开发中要注意的点:
- 自定义组件和全局组件的写法
- inject、ref 等的类型约束
- setup 的写法和相应 h 的注入问题
- tsx 中 v-model 和 scopedSlots 的写法
ParentDialog.vue
<script lang="tsx"> import { noop, trim } from 'lodash'; import { inject, Ref, defineComponent, getCurrentInstance, ref } from '@vue/composition-api'; import filters from '@/filters'; import CommonDialog from '@/components/CommonDialog'; import ChildTable, { getEmptyModelRow } from './ChildTable.vue'; export interface IParentDialog { show: boolean; specFn: (component_id: HostComponent['id']) => Promise<{ data: DictSpecs }>; } export default defineComponent<IParentDialog>({ // tsx 中自定义组件依然要注册 components: { ChildTable }, props: { show: { type: Boolean, default: false }, specFn: { type: Function, default: noop } }, // note: setup 须用箭头函数 setup: (props, context) => { // 修正 tsx 中无法自动注入 'h' 函数的问题 // eslint-disable-next-line no-unused-vars const h = getCurrentInstance()!.$createElement; const { emit } = context; const { specFn, show } = props; // filter 的用法 const { withColon } = filters; // inject 的用法 const pageType = inject<CompSpecType>('pageType', 'foo'); const dictComponents = inject<Ref<DictComp[]>>('dictComponents', ref([])); // ref的类型约束 const dictSpecs = ref<DictSpecs>([]); const loading = ref(false); const _lookupSpecs = async (component_id: HostComponent['id']) => { loading.value = true; try { const json = await specFn(component_id); dictSpecs.value = json.data; } finally { loading.value = false; } }; const formdata = ref<Spec>({ component_id: '', specs_id: '', model: [getEmptyModelRow()] }); const err1 = ref(''); const err2 = ref(''); const _doCheck = () => { err1.value = ''; err2.value = ''; const { component_id, specs_id, model } = formdata.value; if (!component_id) { err1.value = '请选择部件'; return false; } for (let i = 0; i < model.length; i++) { const { brand_id, data } = model[i]; if (!brand_id) { err2.value = '请选择品牌'; return false; } if ( formdata.value.model.some( (m, midx) => midx !== i && String(m.brand_id) === String(brand_id) ) ) { err2.value = '品牌重复'; return false; } } return true; }; const onClose = () => { emit('update:show', false); }; const onSubmit = async () => { const bool = _doCheck(); if (!bool) return; const params = formdata.value; emit('submit', params); onClose(); }; // note: 在 tsx 中,element-ui 等全局注册的组件依然要用 kebab-case 形式 🤦 return () => ( <CommonDialog class="comp" title="新建" width="1000px" labelCancel="取消" labelSubmit="确定" vLoading={loading.value} show={show} onClose={onClose} onSubmit={onSubmit} > <el-form labelWidth="140px" class="create-page"> <el-form-item label={withColon('部件类型')} required={true} error={err1.value}> <el-select class="full-width" model={{ value: formdata.value.component_id, callback: (v: string) => { formdata.value.component_id = v; _lookupSpecs(v); } }} > {dictComponents.value.map((dictComp: DictComp) => ( <el-option key={dictComp.id} label={dictComp.component_name} value={dictComp.id} /> ))} </el-select> </el-form-item> {formdata.value.component_id ? ( <el-form-item labelWidth="0" label="" required={true} error={err2.value}> <child-table list={formdata.value.model} onChange={(v: Spec['model']) => { formdata.value.model = v; }} onError={(err: string) => { console.log(err, 111); err3.value = err; }} /> </el-form-item> ) : null} </el-form> </CommonDialog> ); } }); </script> <style lang="scss" scoped> </style>
VI. 全文总结
- 引入 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>
解决