顺藤摸瓜:用单元测试读懂 vue3 中的 defineComponent

it2025-06-14  16

在 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 of defineComponent is needed.

文档中说得相当简略,实际写起来难免还是有丈二和尚摸不着头脑的时候。

本文将采用与本系列之前两篇相同的做法,从单元测试入手,结合 ts 类型定义,尝试弄懂 defineComponent()  的明确用法。

????顺藤摸瓜:用单元测试读懂 vue3 watch 函数

????顺藤摸瓜:用单元测试读懂 vue3 中的 provide/inject

考虑到篇幅和相似性,本文只采用 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 进行的,那么就先回顾一下官网文档中的几度说明:

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       }     }   } })

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]) 。

// 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) => {                     err3.value = err;                   }}                   scopedSlots={{                       default: (scope: any) => (                         <p>{ scope.foo }</p>                       )                   }}                 />               </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> 解决

--End--

查看更多前端好文请搜索 云前端 或 fewelife 关注公众号转载请注明出处

最新回复(0)