TypeScript 支持
Vue 3 是用 TypeScript 编写的,这意味着它对 TypeScript 有着天然的支持。所有的 Vue 官方库都自带了类型声明文件。
项目配置
create-vue
,即官方的项目脚手架工具,提供了搭建基于 Vite 且支持 TypeScript 的 Vue 项目的选项。
概览
在基于 Vite 的设置中,开发服务器和打包器将只会对 TypeScript 文件执行转译,而不会执行任何类型检查。这保证了 Vite 开发服务器在使用 TypeScript 时能始终保持飞快的速度。
在开发阶段,我们推荐你依赖一个好的 IDE 设置来获得即时的类型错误反馈。
如果使用单文件组件,可以通过
vue-tsc
工具在命令行检查类型和生成类型声明文件。vue-tsc
是tsc
的一个封装,支持除 TypeScript 文件以外的 Vue 单文件组件。Vue CLI 也提供了对 TypeScript 的支持,但是现在不再推荐了。见下方说明。
IDE 支持
强烈推荐 Visual Studio Code (VSCode),因为它对 TypeScript 有着很好的内置支持。
Volar 是官方的 VSCode 扩展,提供了 Vue 单文件组件中的 TypeScript 支持,还伴随着一些其他非常棒的特性。
TIP
Volar 取代了我们之前为 Vue 2 提供的官方 VSCode 扩展 Vetur。如果你之前已经安装了 Vetur,请确保在 Vue 3 的项目中禁用它。
TypeScript Vue Plugin 也是需要的,用来支持在 TS 文件中 import
*.vue
文件。
WebStorm 同样也为 TypeScript 和 Vue 提供了开箱即用的支持。其他的 JetBrains IDE 也同样支持,不管是开箱即用还是通过免费插件。截至 2023 年 10 月,WebStorm 和 Vue 插件都已支持 Volar。
配置 tsconfig.json
通过 create-vue
搭建的项目包含了预先配置好的 tsconfig.json
。其底层配置抽象在了 @vue/tsconfig
包中。在项目内我们使用 Project References 来确保运行在不同环境下的代码的类型正确 (比如应用代码和测试代码就应该有不同的全局变量)。
当手动配置 tsconfig.json
时,请留意以下选项:
compilerOptions.isolatedModules
应当设置为true
,因为 Vite 使用 esbuild 来转译 TypeScript,并受到单文件转译的限制。compilerOptions.verbatimModuleSyntax
是isolatedModules
的一个超集,也是一个不错的选择——它是@vue/tsconfig
所使用的。如果你使用选项式 API,需要将
compilerOptions.strict
设置为true
(或者至少开启compilerOptions.noImplicitThis
,它是strict
模式的一部分),才能获得对组件选项中this
的类型检查。否则this
会被认为是any
。如果你在构建工具中配置了路径解析别名,例如
@/*
别名被配置在vite.config.js
中,你需要通过compilerOptions.paths
选项为 TypeScript 再配置一遍。如果你打算在 Vue 中使用 TSX,需要将
compilerOptions.jsx
设置为"preserve"
,并将compilerOptions.jsxImportSource
设置为"vue"
。
参考:
Volar Takeover 模式
这一章节只对 VSCode + Volar 适用。
Vue 单文件组件默认使用两个语言服务:
- Volar:提供 Vue 单文件组件相关的语言支持
- TypeScript:提供纯 TypeScript 语言支持
当 TypeScript 和 Vue 一起使用时,默认的设置在性能上不是最优的,因为每个 *.vue
文件都对应两个语言服务实例:一个来自 Volar,一个来自 VSCode 的内置 TS 服务。
Volar 提供了一个叫做 "Takeover 模式" 的功能来提升性能。在 takeover 模式下,Volar 使用一个 TS 语言服务实例同时为 Vue 和 TS 文件提供语言支持。
要开启 Takeover 模式,你需要执行以下步骤在你的项目工作区中禁用 VSCode 的内置 TypeScript 语言服务:
- 在当前项目的工作区中,用
Ctrl + Shift + P
(macOS:Cmd + Shift + P
) 唤起命令面板。 - 输入
built
并选择 "Extensions: Show Built-in Extensions"。 - 输入
typescript
并找到 "TypeScript and JavaScript Language Features",然后右键点击并选择 "Disable (Workspace)"。 - 重新加载工作区。Takeover 模式将会在你打开一个 Vue 或者 TS 文件时自动启用。
关于 Vue CLI 与 ts-loader
的注意事项
在类似于 Vue CLI 这样的基于 webpack 的配置中,一般是在模块转换链条中进行类型检查的,例如使用 ts-loader
。然而这并不是一个理想的解决方案,因为类型系统需要了解整个模块关系图才能执行类型检查。单个模块的转换步骤根本不适合这项任务。这会导致以下问题:
ts-loader
只能对转换后的代码进行类型检查。这与我们在 IDE 或vue-tsc
中看到的基于源代码的错误信息并不一致。类型检查可能很慢。当它与代码转换在同一个线程/进程中执行时,会显著影响整个应用的构建速度。
我们已经在 IDE 中通过单独的进程运行着类型检查了,降低开发体验的速度并没有什么意义。
如果你正通过 Vue CLI 使用 Vue 3 + TypeScript,我们强烈建议你迁移到 Vite。我们也在为 CLI 开发仅转译的 TS 支持,以允许你分离类型检查与转译。
一般用法说明
defineComponent()
为了让 TypeScript 正确地推导出组件选项内的类型,我们需要通过 defineComponent()
这个全局 API 来定义组件:
import { defineComponent } from 'vue'
export default defineComponent({
// 启用了类型推导
props: {
name: String,
msg: { type: String, required: true }
},
data() {
return {
count: 1
}
},
mounted() {
this.name // 类型:string | undefined
this.msg // 类型:string
this.count // 类型:number
}
})
当没有结合 <script setup>
使用组合式 API 时,defineComponent()
也支持对传递给 setup()
的 prop 的推导:
import { defineComponent } from 'vue'
export default defineComponent({
props: {
message: String
},
setup(props) {
props.message // 类型:string | undefined
}
})
参考:
TIP
defineComponent()
也支持纯 JavaScript 编写的组件。
在单文件组件中的用法
要在单文件组件中使用 TypeScript,需要在 <script>
标签上加上 lang="ts"
的 attribute。当 lang="ts"
存在时,所有的模板内表达式都将享受到更严格的类型检查。
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
count: 1
}
}
})
</script>
<template>
<!-- 启用了类型检查和自动补全 -->
{{ count.toFixed(2) }}
</template>
lang="ts"
也可以用于 <script setup>
:
<script setup lang="ts">
// 启用了 TypeScript
import { ref } from 'vue'
const count = ref(1)
</script>
<template>
<!-- 启用了类型检查和自动补全 -->
{{ count.toFixed(2) }}
</template>
模板中的 TypeScript
在使用了 <script lang="ts">
或 <script setup lang="ts">
后,<template>
在绑定表达式中也支持 TypeScript。这对需要在模板表达式中执行类型转换的情况下非常有用。
这里有一个假想的例子:
<script setup lang="ts">
let x: string | number = 1
</script>
<template>
<!-- 出错,因为 x 可能是字符串 -->
{{ x.toFixed(2) }}
</template>
可以使用内联类型转换解决此问题:
<script setup lang="ts">
let x: string | number = 1
</script>
<template>
{{ (x as number).toFixed(2) }}
</template>
TIP
如果使用的是 Vue CLI 或基于 webpack 的配置,模板内表达式的 TypeScript 需要 vue-loader@^16.8.0
。
使用 TSX
Vue 也支持使用 JSX / TSX 编写组件。详情请查阅渲染函数 & JSX 指南。
组合式 API
为 ref()
标注类型
ref 会根据初始化时的值推导其类型:
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
这个类型:
import { ref } from 'vue'
import type { Ref } from 'vue'
const year: Ref<string | number> = ref('2020')
year.value = 2020 // 成功!
或者,在调用 ref()
时传入一个泛型参数,来覆盖默认的推导行为:
// 得到的类型:Ref<string | number>
const year = ref<string | number>('2020')
year.value = 2020 // 成功!
如果你指定了一个泛型参数但没有给出初始值,那么最终得到的就将是一个包含 undefined
的联合类型:
// 推导得到的类型:Ref<number | undefined>
const n = ref<number>()
为 reactive()
标注类型
reactive()
也会隐式地从它的参数中推导类型:
import { reactive } from 'vue'
// 推导得到的类型:{ title: string }
const book = reactive({ title: 'Vue 3 指引' })
要显式地标注一个 reactive
变量的类型,我们可以使用接口:
import { reactive } from 'vue'
interface Book {
title: string
year?: number
}
const book: Book = reactive({ title: 'Vue 3 指引' })
TIP
不推荐使用 reactive()
的泛型参数,因为处理了深层次 ref 解包的返回值与泛型参数的类型不同。
为 computed()
标注类型
computed()
会自动从其计算函数的返回值上推导出类型:
import { ref, computed } from 'vue'
const count = ref(0)
// 推导得到的类型:ComputedRef<number>
const double = computed(() => count.value * 2)
// => TS Error: Property 'split' does not exist on type 'number'
const result = double.value.split('')
你还可以通过泛型参数显式指定类型:
const double = computed<number>(() => {
// 若返回值不是 number 类型则会报错
})
为事件处理函数标注类型
在处理原生 DOM 事件时,应该为我们传递给事件处理函数的参数正确地标注类型。让我们看一下这个例子:
<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
上的属性:
function handleChange(event: Event) {
console.log((event.target as HTMLInputElement).value)
}
为 provide / inject 标注类型
provide 和 inject 通常会在不同的组件中运行。要正确地为注入的值标注类型,Vue 提供了一个 InjectionKey
接口,它是一个继承自 Symbol
的泛型类型,可以用来在提供者和消费者之间同步注入值的类型:
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 的类型放在一个单独的文件中,这样它就可以被多个组件导入:
// keys.ts
export const myInjectionKey = Symbol() as InjectionKey<string>
// 在提供方组件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'
provide(myInjectionKey, 'foo')
// 在注入方组件中
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'
const injected = inject(myInjectionKey)
为模板引用标注类型
模板引用需要通过一个显式指定的泛型参数和一个初始值 null
来创建:
<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
。
为组件模板引用标注类型
有时,你可能需要为一个子组件添加一个模板引用,以便调用它公开的方法。举例来说,我们有一个 MyModal
子组件,它有一个打开模态框的方法:
<!-- MyModal.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const isContentShown = ref(false)
const open = () => (isContentShown.value = true)
defineExpose({
open
})
</script>
为了获取 MyModal
的类型,我们首先需要通过 typeof
得到其类型,再使用 TypeScript 内置的 InstanceType
工具类型来获取其实例类型:
<!-- App.vue -->
<script setup lang="ts">
import MyModal from './MyModal.vue'
const modal = ref<InstanceType<typeof MyModal> | null>(null)
const openModal = () => {
modal.value?.open()
}
</script>
注意,如果你想在 TypeScript 文件而不是在 Vue SFC 中使用这种技巧,需要开启 Volar 的 Takeover 模式。
如果组件的具体类型无法获得,或者你并不关心组件的具体类型,那么可以使用 ComponentPublicInstance
。这只会包含所有组件都共享的属性,比如 $el
:
import { ref } from 'vue'
import type { ComponentPublicInstance } from 'vue'
const child = ref<ComponentPublicInstance | null>(null)
选项式 API
为 props 标注类型
选项式 API 中对 props 的类型标注需要用到 PropType
这个工具类型:
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
interface Book {
title: string
author: string
year: number
}
export default defineComponent({
props: {
book: {
// 提供相对 `Object` 更确定的类型
type: Object as PropType<Book>,
required: true
},
// 也可以标注函数
callback: {
type: Function as PropType<(id: number) => void>
}
},
methods: {
getId(): number {
return 1
}
}
})
WARNING
由于 TypeScript 设计限制,当它来到函数表达式的类型推导时,你必须注意对象和数组的 validator
和 default
值:
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
interface Book {
title: string
year?: number
}
const Component = defineComponent({
props: {
bookA: {
type: Object as PropType<Book>,
// 请务必使用箭头函数
default: () => ({
title: 'Arrow Function Expression'
}),
validator: (book: Book) => !!book.title
}
}
})
这防止了 TypeScript 将 this
参数推导为函数内部的类型,不幸的是,这意味着类型推导无法正常工作,你必须手动转换类型。
为 emits 标注类型
我们可以给 emits
选项提供一个对象来声明组件所触发的事件,以及这些事件所期望的参数类型。试图触发未声明的事件会抛出一个类型错误:
import { defineComponent } from 'vue'
export default defineComponent({
emits: {
addBook(payload: { bookName: string }) {
// 执行运行时校验
return payload.bookName.length > 0
}
},
methods: {
onSubmit() {
this.$emit('addBook', { bookName: 123 }) // 类型错误!
this.$emit('non-declared-event') // 类型错误!
}
}
})
为计算属性标记类型
计算属性会自动从其返回值推导出类型:
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
message: 'Hello!'
}
},
computed: {
greeting() {
return this.message + '!'
}
},
mounted() {
this.greeting // 类型:string
}
})
在某些场景中,你可能想要显式地标记出计算属性的类型以确保其实现是正确的:
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return {
message: 'Hello!'
}
},
computed: {
// 显式标注返回类型
greeting(): string {
return this.message + '!'
},
// 标注一个可写的计算属性
greetingUppercased: {
get(): string {
return this.greeting.toUpperCase()
},
set(newValue: string) {
this.message = newValue.toUpperCase()
}
}
}
})
在某些 TypeScript 因循环引用而无法推导类型的情况下,可能必须进行显式的类型标注。
为事件处理函数标注类型
在处理原生 DOM 事件时,应该为我们传递给事件处理函数的参数正确地标注类型。让我们看一下这个例子:
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
methods: {
handleChange(event: Event) {
console.log((event.target as HTMLInputElement).value)
}
}
})
</script>
<template>
<input type="text" @change="handleChange" />
</template>
没有类型标注时,这个 event
参数会隐式地标注为 any
类型。这也会在 tsconfig.json
中配置了 "strict": true
或 "noImplicitAny": true
时报出一个 TS 错误。因此,建议显式地为事件处理函数的参数标注类型。此外,你可能需要显式地强制转换 event
上的属性。
扩展全局属性
某些插件会通过 app.config.globalProperties
为所有组件都安装全局可用的属性。举例来说,我们可能为了请求数据而安装了 this.$http
,或者为了国际化而安装了 this.$translate
。为了使 TypeScript 更好地支持这个行为,Vue 暴露了一个被设计为可以通过 TypeScript 模块扩展来扩展的 ComponentCustomProperties
接口:
import axios from 'axios'
declare module 'vue' {
interface ComponentCustomProperties {
$http: typeof axios
$translate: (key: string) => string
}
}
参考:
类型扩展的位置
我们可以将这些类型扩展放在一个 .ts
文件,或是一个影响整个项目的 *.d.ts
文件中。无论哪一种,都应确保在 tsconfig.json
中包含了此文件。对于库或插件作者,这个文件应该在 package.json
的 types
属性中被列出。
为了利用模块扩展,你需要确保将扩展的模块放在 TypeScript 模块 中。 也就是说,该文件需要包含至少一个顶级的 import
或 export
,即使它只是 export {}
。如果扩展被放在模块之外,它将覆盖原始类型,而不是扩展!
// 不工作,将覆盖原始类型。
declare module 'vue' {
interface ComponentCustomProperties {
$translate: (key: string) => string
}
}
// 正常工作。
export {}
declare module 'vue' {
interface ComponentCustomProperties {
$translate: (key: string) => string
}
}
扩展自定义选项
某些插件,比如 vue-router
,提供了一些自定义的组件选项,比如 beforeRouteEnter
:
import { defineComponent } from 'vue'
export default defineComponent({
beforeRouteEnter(to, from, next) {
// ...
}
})
如果没有确切的类型标注,这个钩子函数的参数会隐式地标注为 any
类型。我们可以为 ComponentOptionsBase
接口扩展自定义的选项:
import { Route } from 'vue-router'
declare module 'vue' {
interface ComponentOptionsBase<V, D, C, M, E> {
beforeRouteEnter?(to: Route, from: Route, next: () => void): void
}
}
现在这个 beforeRouteEnter
选项会被准确地标注类型。注意这只是一个例子——像 vue-router
这种类型完备的库应该在它们自己的类型定义中自动执行这些扩展。
这种类型扩展和全局属性扩展受到相同的限制。
参考: