前置说明
Vue 官方文档具体有对 CompositionAPI + TypeScript 的使用说明,请参考:
TypeScript 与组合式 API | Vue.js
Vue3 的源码是使用 TypeScript 进行编写的,所以 compositionAPI 和 TypeScript 本身就十分"丝滑"。
本文是针对 Vue 的 <script setup lang="ts">
语法进行阐述的。
一切要从宏函数说起:
所谓的宏函数 ,也叫编译宏 ,编译宏函数 :就是在一个 ES 编写作用域内本身并没有定义,而是在编译过程中注入的工具函数。(类比绝地求生中,虽然系统本身没有瞄准功能,但是鼠标给予瞄准功能添加的鼠标宏 )
<script setup lang="ts">
本身是 defineComponent + setup 函数的语法糖
举个例子,考虑下面两段代码:
vue
<script setup lang="ts">
const props = defineProps({
a: {
type: String,
default: ''
}
});
const emit = defineEmits(['change']);
</script>
<template>
<div>
<!-- sub views -->
</div>
</template>
vue
<script lang="ts">
import { defineComponent as _defineComponent } from 'vue';
export default _defineComponent({
name: 'Comp',
props: {
a: {
type: String,
default: ''
}
},
emits: ['change'],
setup(props, ctx) {
return {
};
}
});
</script>
<template>
<div>
<!-- sub views -->
</div>
</template>
在上述的第一段代码中,defineProps
和defineEmits
本身在 script 中都没有被定义,而是最终会在编译的过程中定义这两个函数并执行传递给 defineComponents 中,所以defineProps
和defineEmits
这两个函数就是宏函数。
简单实现一下吧
javascript
export default function (source) {
const importSentense = source.matches(/^import/gm);
const commonSentense = source.matches(/^[^import]/gm);
return `
import { defineComponent as _defineComponent } from 'vue';
${ importSentense.join('\n') }
let obj = {
setup(props, ctx) {
let scope = {};
${commonSentense}
${
commonSentense.replace(/^(let|const|var)/gm, (source, key) => {
const varName = key.matches(/?=\=/).trim();
scope[\`${{varName}}\`] = varName;
return source;
})
}
return scope;
}
};
function defineProps(propsOptions) {
obj.props = propsOptions;
}
function defineEmits(emitOptions) {
obj.emits = emitOptions;
}
function defineExpose(exposeOptions) {
const setup = obj.setup;
obj.setup = function (props, ctx) {
// ...
}
}
return _defineComponent(obj);`;
}
在 Vue 的 <script setup>
语法中,主要有以下几个宏函数:
- defineProps : 用来定义组件的 props 的
- defineEmits: 用来定义组件的自定义传出事件 emits 的
- defineExpose: 用来定义组件的自定义暴露属性或者方法的 (在函数调用组件时可能会非常有用, 比如封装一个
<MessageBox>
) - ......
从宏函数到宏函数定义:
宏函数定义主要可以分为编译时定义和运行时定义
defineProps
defineProps
主要用于声明 Vue 组件中的 props
。
defineProps
的定义方式主要有三种:
运行时定义:
使用运行时定义 props 时,定义原始值和 JavaScript写法没有什么太大的区别;定义引用值需要使用 PropType 进行断言 (T 指代需要定义的类型)
vue
<script setup lang="ts">
import { PropType } from 'vue';
interface IObject {
a: number;
b: number;
c: number;
}
defineProps({
num: Number,
name: {
type: String,
default: '',
required: true,
},
isOk: Boolean,
list: {
type: Array as PropType<string[]>,
default: () => []
},
obj: {
type: Object as PropType<IObject>,
default: () => ({ a: 1, b: 2, c: 3 })
},
map: {
type: Map as PropType<Map<String, Function>>
}
});
</script>
注意
- 使用 PropType 进行断言声明值时,无法限制将 object 的默认值限制为对应的接口 IObject
- 使用 PropType 进行断言声明值时,无法限制组件调用时传入 IObject 传入额外的属性值
编译时定义:
- 在 defineProps 方法的泛型中直接给入TS类型(interface, type 均可)并且 defineProps 不要传入运行时定义的配置对象
typescript
export interface IDemoProps {
num: number;
name: string;
isOk: boolean;
list: string[];
obj: IObject;
map: Map<String, Function>;
}
vue
<script setup lang="ts">
import { IDemoProps } from '@/types';
defineProps<IDemo1Props>();
</script>
注意:
- 使用编译时定义可以让外界严格地按照 TypeScript 的类型检查进行调用
- 使用编译时定义无法直接给组件定义默认值
编译时默认值定义:
如果需要使用编译时具有类型检查,并且还能给 props 添加默认值,需要使用 withDefaults
编译宏包裹 defineProps
进行定义
vue
<script setup lang="ts">
withDefaults(defineProps<IDemo1Props>(), {
num: 1,
name: 'Demo',
isOk: true,
list: () => [],
obj: () => ({
a: 1,
b: 2,
c: 3,
d: 4
}),
map: () => new Map<string, Function>()
});
</script>
注意:
withDefaults
可以给defineProps
设置对应的,可选的默认值;被设置后的默认值外界渲染组件的时候可以不用传递这些值withDefaults
设置props
默认值时,原始默认值直接传递值的本身,引用默认值传递一个返回对应引用的函数(e.g() => []
) (类比 options API 定义 props 时的规则)
defineEmits
defineEmits
主要是告诉注册的组件内部到底有什么自定义事件。
同样地,defineEmits
也可以分成:
运行时定义
运行时定义的方式和 options API 定义 emits
选项的方式是一样的
vue
<script setup lang="ts">
const emit = defineEmits(['update', 'init', 'change']);
</script>
小提示:
- 使用运行时定义 defineEmit 可以定义 emit 的事件名称,但是不够精确
编译时定义
编译时定义(基于类型定义)可以告诉 TypeScript 需要检查 emit 调用方法时需要检查参数。
- 直接定义
vue
export interface IDemoEmit {
(e: 'change', id: number): void;
(e: 'update', value: string): void;
(e: 'init'): void;
}
vue
<script setup lang="ts">
import { IDemoEmit } from '@/types';
const emit = defineEmits<IDemoEmit>();
</script>
- v3.3 版本以上更简明的定义方式
typescript
const emit = defineEmits<{
change: [id: number];
update: [value: string];
init: [];
}>();
总结:
- 添加参数设置能够使自定义事件调用时更加严谨,更推荐使用第二种方式进行声明
defineExpose
defineExpose 用于组件抛出公共 API。defineExpose 可以直接定义、添加泛型后定义。
直接定义
vue
<script>
defineExpose({
show() {},
hide() {},
});
</script>
添加泛型后定义
添加泛型后定义能够让组件外部能够更精细的进行抛出方法的调用。
typescript
export interface IDemoExpose {
show(): void;
hide(): void;
}
vue
<script>
import { IDemoExpose } from '@/types';
defineExpose<IDemoExpose>({
show() {},
hide() {},
});
</script>
vue
<template>
<div class="demo-container">
<demo3 ref="demo3Ref" />
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import Demo3 from './components/Demo3.vue';
import { IDemoExpose } from '@/types';
const demo3Ref = ref<IDemoExpose>();
onMounted(() => {
demo3Ref.value?.show();
});
</script>
总结:
- 添加泛型后定义能新增组件的严谨性和可扩展性,更推荐使用第二种方式进行声明
非 setup 语法下的定义:
非 setup 语法需要在 defineComponent 定义组件时依次传入泛型参数:
- 第一个参数 Props
- 第二个参数 Emit
- 第三个参数 Expose
- 第四个参数 Slot
响应式 API 的定义:
ref
ref
会根据初始化时的值推导其类型:
typescript
import { ref } from 'vue';
// 推导出的类型:Ref<number>
const year = ref(2020);
// => TS Error: Type 'string' is not assignable to type 'number'.
year.value = '2020';
有时我们可能想为 ref
内的值指定一个更复杂的类型,可以通过使用 Ref 这个类型:
typescript
import { ref } from 'vue'
import type { Ref } from 'vue'
const year: Ref<string | number> = ref('2020')
year.value = 2020 // 成功!
或者,在调用 ref()
时传入一个泛型参数,来覆盖默认的推导行为:
typescript
// 得到的类型:Ref<string | number>
const year = ref<string | number>('2020')
year.value = 2020 // 成功!
如果你指定了一个泛型参数但没有给出初始值,那么最后得到的就将是一个包含 undefined
的联合类型:
typescript
// 推导得到的类型:Ref<number | undefined>
const n = ref<number>()
reactive
reactive() 也会隐式地从它的参数中推导类型:
typescript
import { reactive } from 'vue'
// 推导得到的类型:{ title: string }
const book = reactive({ title: 'Vue 3 指引' })
要显式地标注一个 reactive 变量的类型,我们可以使用接口:
typescript
import { reactive } from 'vue'
interface Book {
title: string
year?: number
}
const book: Book = reactive({ title: 'Vue 3 指引' })
注意:
- 不推荐使用
reactive()
的泛型参数,因为处理了深层次 ref 解包的返回值与泛型参数的类型不同。 - 限制参数类型请在 reactive 外面定义,而不是在泛型中定义。
computed
typescript
import { ref, computed, ComputedRef } from 'vue';
const count = ref(0);
// 类型推导
const doubleCount = computed(() => count.value * 2);
// 泛型定义
const trippleCount = computed<number>(() => count.value * 3);
// 直接定义 computed
const fourTimeCount: ComputedRef<number> = computed(() => count.value *4);
provide & inject
provide 和 inject 通常会在不同的组件中运行。要正确地为注入的值标记类型,Vue 提供了一个 InjectionKey 接口,它是一个继承自 Symbol 的泛型类型,可以用来在提供者和消费者之间同步注入值的类型:
typescript
import { provide, inject } from 'vue'
import type { InjectionKey } from 'vue'
const key = Symbol() as InjectionKey<string>
provide(key, 'foo') // 若提供的是非字符串值会导致错误
const foo = inject(key) // foo 的类型:string | undefined
建议将注入 key 的类型放在一个单独的文件中,这样它就可以被多个组件导入。
当使用字符串注入 key 时,注入值的类型是 unknown,需要通过泛型参数显式声明:
typescript
const foo = inject<string>('foo') // 类型:string | undefined
注意注入的值仍然可以是 undefined,因为无法保证提供者一定会在运行时 provide 这个值。
当提供了一个默认值后,这个 undefined 类型就可以被移除:
typescript
const foo = inject<string>('foo', 'bar') // 类型:string
如果你确定该值将始终被提供,则还可以强制转换该值:
typescript
const foo = inject('foo') as string
为模板引用标注类型
模板引用需要通过一个显式指定的泛型参数和一个初始值 null 来创建:
vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const el = ref<HTMLInputElement | null>(null)
onMounted(() => {
el.value?.focus()
})
</script>
<template>
<input ref="el" />
</template>
注意
为了严格的类型安全,有必要在访问 el.value 时使用可选链或类型守卫。这是因为直到组件被挂载前,这个 ref 的值都是初始的 null,并且在由于 v-if 的行为将引用的元素卸载时也可以被设置为 null。
事件处理函数的定义
在处理原生 DOM 事件时,应该为我们传递给事件处理函数的参数正确地标注类型。让我们看一下这个例子:
vue
<script setup lang="ts">
function handleChange(event) {
// `event` 隐式地标注为 `any` 类型
console.log(event.target.value)
}
</script>
<template>
<input type="text" @change="handleChange" />
</template>
没有类型标注时,这个 event 参数会隐式地标注为 any 类型。这也会在 tsconfig.json 中配置了 "strict": true 或 "noImplicitAny": true 时报出一个 TS 错误。因此,建议显式地为事件处理函数的参数标注类型。此外,你在访问 event 上的属性时可能需要使用类型断言:
typescript
function handleChange(event: Event) {
console.log((event.target as HTMLInputElement).value)
}