Skip to content

TypeScript 支持

Vue 3 是用 TypeScript 编写的,这意味着它对 TypeScript 有着天然的支持。所有的 Vue 官方库都自带了类型声明文件。

项目配置

create-vue,即官方的项目脚手架工具,提供了搭建基于 Vite 且支持 TypeScript 的 Vue 项目的选项。

概览

在基于 Vite 的设置中,开发服务器和打包器将只会对 TypeScript 文件执行转译,而不会执行任何类型检查。这保证了 Vite 开发服务器在使用 TypeScript 时能始终保持飞快的速度。

  • 在开发阶段,我们推荐你依赖一个好的 IDE 设置来获得即时的类型错误反馈。

  • 如果使用单文件组件,可以通过 vue-tsc 工具在命令行检查类型和生成类型声明文件。vue-tsctsc 的一个封装,支持除 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 时,请留意以下选项:

参考:

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 语言服务:

  1. 在当前项目的工作区中,用 Ctrl + Shift + P (macOS:Cmd + Shift + P) 唤起命令面板。
  2. 输入 built 并选择 "Extensions: Show Built-in Extensions"。
  3. 输入 typescript 并找到 "TypeScript and JavaScript Language Features",然后右键点击并选择 "Disable (Workspace)"。
  4. 重新加载工作区。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 来定义组件:

ts
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 的推导:

ts
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" 存在时,所有的模板内表达式都将享受到更严格的类型检查。

vue
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      count: 1
    }
  }
})
</script>

<template>
  <!-- 启用了类型检查和自动补全 -->
  {{ count.toFixed(2) }}
</template>

lang="ts" 也可以用于 <script setup>

vue
<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。这对需要在模板表达式中执行类型转换的情况下非常有用。

这里有一个假想的例子:

vue
<script setup lang="ts">
let x: string | number = 1
</script>

<template>
  <!-- 出错,因为 x 可能是字符串 -->
  {{ x.toFixed(2) }}
</template>

可以使用内联类型转换解决此问题:

vue
<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 会根据初始化时的值推导其类型:

ts
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 这个类型:

ts
import { ref } from 'vue'
import type { Ref } from 'vue'

const year: Ref<string | number> = ref('2020')

year.value = 2020 // 成功!

或者,在调用 ref() 时传入一个泛型参数,来覆盖默认的推导行为:

ts
// 得到的类型:Ref<string | number>
const year = ref<string | number>('2020')

year.value = 2020 // 成功!

如果你指定了一个泛型参数但没有给出初始值,那么最终得到的就将是一个包含 undefined 的联合类型:

ts
// 推导得到的类型:Ref<number | undefined>
const n = ref<number>()

reactive() 标注类型

reactive() 也会隐式地从它的参数中推导类型:

ts
import { reactive } from 'vue'

// 推导得到的类型:{ title: string }
const book = reactive({ title: 'Vue 3 指引' })

要显式地标注一个 reactive 变量的类型,我们可以使用接口:

ts
import { reactive } from 'vue'

interface Book {
  title: string
  year?: number
}

const book: Book = reactive({ title: 'Vue 3 指引' })

TIP

不推荐使用 reactive() 的泛型参数,因为处理了深层次 ref 解包的返回值与泛型参数的类型不同。

computed() 标注类型

computed() 会自动从其计算函数的返回值上推导出类型:

ts
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('')

你还可以通过泛型参数显式指定类型:

ts
const double = computed<number>(() => {
  // 若返回值不是 number 类型则会报错
})

为事件处理函数标注类型

在处理原生 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 上的属性:

ts
function handleChange(event: Event) {
  console.log((event.target as HTMLInputElement).value)
}

为 provide / inject 标注类型

provide 和 inject 通常会在不同的组件中运行。要正确地为注入的值标注类型,Vue 提供了一个 InjectionKey 接口,它是一个继承自 Symbol 的泛型类型,可以用来在提供者和消费者之间同步注入值的类型:

ts
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 的类型放在一个单独的文件中,这样它就可以被多个组件导入:

ts
// keys.ts
export const myInjectionKey = Symbol() as InjectionKey<string>
ts
// 在提供方组件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, 'foo')
ts
// 在注入方组件中
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const injected = inject(myInjectionKey)

参考:为 provide / inject 标注类型

为模板引用标注类型

模板引用需要通过一个显式指定的泛型参数和一个初始值 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

为组件模板引用标注类型

有时,你可能需要为一个子组件添加一个模板引用,以便调用它公开的方法。举例来说,我们有一个 MyModal 子组件,它有一个打开模态框的方法:

vue
<!-- 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 工具类型来获取其实例类型:

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

ts
import { ref } from 'vue'
import type { ComponentPublicInstance } from 'vue'

const child = ref<ComponentPublicInstance | null>(null)

选项式 API

为 props 标注类型

选项式 API 中对 props 的类型标注需要用到 PropType 这个工具类型:

ts
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 设计限制,当它来到函数表达式的类型推导时,你必须注意对象和数组的 validatordefault 值:

ts
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 选项提供一个对象来声明组件所触发的事件,以及这些事件所期望的参数类型。试图触发未声明的事件会抛出一个类型错误:

ts
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') // 类型错误!
    }
  }
})

为计算属性标记类型

计算属性会自动从其返回值推导出类型:

ts
import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      message: 'Hello!'
    }
  },
  computed: {
    greeting() {
      return this.message + '!'
    }
  },
  mounted() {
    this.greeting // 类型:string
  }
})

在某些场景中,你可能想要显式地标记出计算属性的类型以确保其实现是正确的:

ts
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 事件时,应该为我们传递给事件处理函数的参数正确地标注类型。让我们看一下这个例子:

vue
<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 接口:

ts
import axios from 'axios'

declare module 'vue' {
  interface ComponentCustomProperties {
    $http: typeof axios
    $translate: (key: string) => string
  }
}

参考:

类型扩展的位置

我们可以将这些类型扩展放在一个 .ts 文件,或是一个影响整个项目的 *.d.ts 文件中。无论哪一种,都应确保在 tsconfig.json 中包含了此文件。对于库或插件作者,这个文件应该在 package.jsontypes 属性中被列出。

为了利用模块扩展,你需要确保将扩展的模块放在 TypeScript 模块 中。 也就是说,该文件需要包含至少一个顶级的 importexport,即使它只是 export {}。如果扩展被放在模块之外,它将覆盖原始类型,而不是扩展!

ts
// 不工作,将覆盖原始类型。
declare module 'vue' {
  interface ComponentCustomProperties {
    $translate: (key: string) => string
  }
}
ts
// 正常工作。
export {}

declare module 'vue' {
  interface ComponentCustomProperties {
    $translate: (key: string) => string
  }
}

扩展自定义选项

某些插件,比如 vue-router,提供了一些自定义的组件选项,比如 beforeRouteEnter

ts
import { defineComponent } from 'vue'

export default defineComponent({
  beforeRouteEnter(to, from, next) {
    // ...
  }
})

如果没有确切的类型标注,这个钩子函数的参数会隐式地标注为 any 类型。我们可以为 ComponentOptionsBase 接口扩展自定义的选项:

ts
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 这种类型完备的库应该在它们自己的类型定义中自动执行这些扩展。

这种类型扩展和全局属性扩展受到相同的限制

参考:

基于 Vue.js 官方文档构建的学习宝典