在 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 ofdefineComponent
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: {
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 }
json
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 函數的簽名形式以前,爲了便於解釋,先來看看其關聯的幾個基礎類型定義,大體理解其做用便可,毋需深究: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?: { [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 五、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 關注公衆號
轉載請註明出處
本文分享自微信公衆號 - 雲前端(fewelife)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。