順藤摸瓜:用單元測試讀懂 vue3 中的 defineComponent

在 Vue 3 的 Composition API 中,採用了 setup() 做爲組件的入口函數。css

在結合了 TypeScript 的狀況下,傳統的 Vue.extend 等定義方法沒法對此類組件給出正確的參數類型推斷,這就須要引入 defineComponent() 組件包裝函數,其在 rfc 文檔中的說明爲:html

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.vue

文檔中說得至關簡略,實際寫起來不免仍是有丈二和尚摸不着頭腦的時候。git

本文將採用與本系列以前兩篇相同的作法,從單元測試入手,結合 ts 類型定義,嘗試弄懂 defineComponent()  的明確用法。github


考慮到篇幅和類似性,本文只採用 vue 2.x + @vue/composition-api 的組合進行說明,vue 3 中的簽名方式稍有不一樣,讀者能夠自行參考並嘗試。web

I. 測試用例

@vue/composition-api 項目中,test/types/defineComponent.spec.ts 中的幾個測試用例很是直觀的展現了幾種「合法」的 TS 組件方式 (順序和原文件中有調整)element-ui

[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: {
          typeNumber,
          default0,
        },
        b: String// 只簡寫類型
      },
      setup(props, ctx) {
        //...
        return () => null
      },
    })
    new Vue(App)
    //...
  })

[test case 4] 推斷是否必須

組件選項中的 props 類型將被推斷爲 { readonly foo: string; readonly bar: string; readonly zoo?: string }json

  it('infer the required prop'() => {
    const App = defineComponent({
      props: {
        foo: {
          typeString,
          required: true,
        },
        bar: {
          typeString,
          default'default',
        },
        zoo: {
          typeString,
          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 函數的簽名形式以前,爲了便於解釋,先來看看其關聯的幾個基礎類型定義,大體理解其做用便可,毋需深究:api

引自 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?: { [keystring]: DirectiveFunction | DirectiveOptions };
  components?: { [keystring]: Component<anyanyanyany> | AsyncComponent<anyanyanyany> };
  transitions?: { [keystring]: object };
  filters?: { [keystring]: Function };

  provide?: object | (() => object);
  inject?: InjectOptions;

  model?: {
    prop?: string;
    event?: string;
  };

  parent?: Vue;
  mixins?: (ComponentOptions<Vue> | typeof Vue)[];
  name?: string;
  // TODOsupport properly inferred 'extends'
  extends?: ComponentOptions<Vue> | typeof Vue;
  delimiters?: [stringstring];
  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<stringstring>
  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> = { [keystring]: (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'>

屬性類型定義

也就是 StringString[] 等:

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<stringnull | ((emitData: any) => boolean)>
  setup?: SetupFunction<PropsRawBindings>
} & ThisType<ComponentRenderProxy<PropsRawBindingsDCM>>

在上面的測試用例中就是  [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<stringnull | ((emitData: any) => boolean)>
  setup?: SetupFunction<PropsRawBindings>
} & ThisType<ComponentRenderProxy<PropsRawBindingsDCM>>

在上面的測試用例中就是  [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 五、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<stringnull | ((emitData: any) => boolean) >
  setup?: SetupFunction<PropsRawBindings>
} & ThisType<ComponentRenderProxy<PropsRawBindingsDCM>>
// 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 { typenull | 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: {
      typeBoolean,
      defaultfalse
    },
    specFn: {
      typeFunction,
      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 關注公衆號

轉載請註明出處


本文分享自微信公衆號 - 雲前端(fewelife)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索