«
用单元测试读懂 vue3 中的 defineComponent

时间:2022-3   


在 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()  的明确用法。


考虑到篇幅和相似性,本文只采用 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 supporttype VueConstructorProxy<PropsOptions, RawBindings> = VueConstructor &amp; {  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> &amp; Data,  Methods,  Computed,  PropsOptions,  ExtractPropTypes<PropsOptions, false>> &amp;  VueConstructorProxy<PropsOptions, RawBindings>

组件渲染代理

代理上的公开属性,被用作模版中的渲染上下文(相当于 render 中的  this ):

// src/component/componentProxy.tsexport type ComponentRenderProxy<  P = {}, // 从 props 选项中提取的类型  B = {}, // 从 setup() 中返回的被称作 RawBindings 的绑定值类型  D = {}, // data() 中返回的值类型  C extends ComputedOptions = {},  M extends MethodOptions = {},  PublicProps = P> = {  $data: D  $props: Readonly<P &amp; PublicProps>  $attrs: Data} &amp; Readonly<P> &amp;  ShallowUnwrapRef<B> &amp;  D &amp;  M &amp;  ExtractComputedReturns<C> &amp;  Omit<Vue, '$data' | '$props' | '$attrs'>

属性类型定义

也就是  StringString[]  等:

export type PropType<T> = PropConstructor<T> | PropConstructor<T>[]type PropConstructor<T> =  | { new (...args: any[]): T &amp; 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 propsexport 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> &amp; {  props?: undefined  emits?: string[] | Record<string, null | ((emitData: any) => boolean)>  setup?: SetupFunction<Props, RawBindings>} &amp; 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 supportexport 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.tsexport 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> &amp; {  props?: PropNames[]  emits?: string[] | Record<string, null | ((emitData: any) => boolean)>  setup?: SetupFunction<Props, RawBindings>} &amp; ThisType<ComponentRenderProxy<Props, RawBindings, D, C, M>>

在上面的测试用例中就是  [test case 2] 的情况。

签名3:非数组 props

// overload 3: object format with object props declaration// see `ExtractPropTypes` in ./componentProps.tsexport 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.tsexport type ComponentOptionsWithProps<  PropsOptions = ComponentPropsOptions,  RawBindings = Data,  D = Data,  C extends ComputedOptions = {},  M extends MethodOptions = {},  Props = ExtractPropTypes<PropsOptions>> = ComponentOptionsBase<Props, D, C, M> &amp; {  props?: PropsOptions  emits?: string[] | Record<string, null | ((emitData: any) => boolean) >  setup?: SetupFunction<Props, RawBindings>} &amp; ThisType<ComponentRenderProxy<Props, RawBindings, D, C, M>>
// src/component/componentProps.tsexport type ExtractPropTypes<  O,  MakeDefaultRequired extends boolean = true> = O extends object  ? { [K in RequiredKeys<O, MakeDefaultRequired>]: InferPropType<O[K]> } &amp;      { [K in OptionalKeys<O, MakeDefaultRequired>]?: InferPropType<O[K]> }  : { [K in string]: any }// prettier-ignoretype InferPropType<T> = T extends null  ? any // null &amp; 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 组件中,主要有这几个实际开发中要注意的点:

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 &amp;&amp; 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. 全文总结