TypeScript 与 Vue3

Vue3 从底层重构了类型系统,配合 <script setup lang="ts"> 让 TypeScript 开发体验达到全新高度。

一、前言

TypeScript 为 JavaScript 提供了静态类型检查,能够在编译阶段发现潜在错误,提升代码的可维护性和开发效率。Vue3 使用 TypeScript 完全重写了核心源码,提供了开箱即用的类型支持。

相比 Vue2 需要依赖 vue-class-component 或复杂的类型声明,Vue3 的 Composition API 与 TypeScript 结合更加自然。本文将系统讲解 Vue3 + TypeScript 的开发实践。

二、<script setup lang="ts"> 基础

2.1 启用 TypeScript 支持

在 Vue3 单文件组件中,只需添加 lang="ts" 即可使用 TypeScript:

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

// TypeScript 会自动推断类型
const count = ref(0)        // Ref<number>
const message = ref('hello') // Ref<string>
const isShow = ref(true)     // Ref<boolean>
</script>

2.2 类型推断与显式声明

Vue3 的响应式 API 具有良好的类型推断能力,但在复杂场景下建议显式声明类型:

<script setup lang="ts">
import { ref, reactive } from 'vue'

// 自动推断
const autoCount = ref(0) // Ref<number>

// 显式声明(推荐用于复杂类型)
const count = ref<number>(0)
const name = ref<string>('Vue3')
const items = ref<string[]>(['a', 'b', 'c'])

// 接口定义
interface User {
  id: number
  name: string
  email?: string  // 可选属性
}

const user = ref<User>({
  id: 1,
  name: '张三'
})

// reactive 的类型推断
const state = reactive({
  count: 0,
  user: { name: '李四' } as User
})
</script>

三、Props 类型声明

3.1 使用类型字面量

<script setup lang="ts">
// 简单类型声明
const props = defineProps<{
  title: string
  count?: number      // 可选
  items: string[]
  user: { name: string; age: number }
}>()
</script>

3.2 使用接口定义

<script setup lang="ts">
// 接口定义(推荐,可复用)
interface Props {
  title: string
  count?: number
  disabled?: boolean
}

const props = defineProps<Props>()
</script>

3.3 带默认值的 Props

使用 withDefaults 编译器宏设置默认值:

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  disabled?: boolean
  tags?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  disabled: false,
  tags: () => ['default']  // 对象/数组默认值需用工厂函数
})
</script>

3.4 复杂的 Props 类型

<script setup lang="ts">
// 定义枚举类型
type ButtonType = 'primary' | 'success' | 'warning' | 'danger'
type ButtonSize = 'small' | 'medium' | 'large'

interface Props {
  type?: ButtonType
  size?: ButtonSize
  loading?: boolean
  // 函数类型 Props
  onClick?: (event: MouseEvent) => void
  // 对象数组
  options: Array<{
    label: string
    value: string | number
    disabled?: boolean
  }>
}

const props = withDefaults(defineProps<Props>(), {
  type: 'primary',
  size: 'medium',
  loading: false
})
</script>

四、Emits 类型声明

4.1 基本用法

<script setup lang="ts">
// 声明 emits 及其参数类型
const emit = defineEmits<{
  // 无参数事件
  click: []
  // 单参数事件
  update: [value: string]
  // 多参数事件
  submit: [data: FormData, callback: (result: boolean) => void]
  // 可选参数事件
  change: [value?: number]
}>()

const handleClick = () => {
  emit('click')
}

const handleUpdate = (value: string) => {
  emit('update', value)
}
</script>

4.2 与 v-model 配合

<!-- InputField.vue -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
  />
</template>

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

4.3 多个 v-model

<script setup lang="ts">
interface Props {
  title: string
  content: string
}

const props = defineProps<Props>()

const emit = defineEmits<{
  'update:title': [value: string]
  'update:content': [value: string]
}>()
</script>

<template>
  <div>
    <input
      :value="title"
      @input="emit('update:title', ($event.target as HTMLInputElement).value)"
    />
    <textarea
      :value="content"
      @input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
    />
  </div>
</template>

五、响应式 API 的类型

5.1 ref 的类型

<script setup lang="ts">
import { ref, Ref } from 'vue'

// 基本类型
const count: Ref<number> = ref(0)

// 对象类型
interface User {
  name: string
  age: number
}

const user = ref<User>({ name: '张三', age: 25 })
// user.value 的类型为 User

// 可能为 null 的引用(常用于 DOM 引用)
const inputRef = ref<HTMLInputElement | null>(null)

// 数组类型
const list = ref<number[]>([1, 2, 3])

// 联合类型
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
</script>

5.2 computed 的类型

<script setup lang="ts">
import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 自动推断返回类型为 string
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

// 显式声明类型
const age = ref<string | number>(25)
const ageDisplay = computed<string>(() => `${age.value} 岁`)

// 可写 computed
const count = ref(0)
const doubleCount = computed({
  get: (): number => count.value * 2,
  set: (val: number) => {
    count.value = val / 2
  }
})
</script>

5.3 reactive 的类型

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

// 接口定义
interface FormState {
  username: string
  password: string
  remember: boolean
  errors: Record<string, string[]>
}

const form = reactive<FormState>({
  username: '',
  password: '',
  remember: false,
  errors: {}
})

// 使用类型断言处理可选属性
interface Config {
  apiUrl: string
  timeout?: number
}

const config = reactive<Config>({
  apiUrl: '/api'
  // timeout 是可选的,可以不提供
})
</script>

六、组件类型

6.1 组件实例类型

<script setup lang="ts">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

// 获取子组件实例类型
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)

const callChildMethod = () => {
  // TypeScript 知道 childRef.value 上有哪些方法
  childRef.value?.someMethod()
}
</script>

<template>
  <ChildComponent ref="childRef" />
</template>

6.2 事件类型

<script setup lang="ts">
// 模板引用事件处理
const handleInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  console.log(target.value)
}

// 键盘事件
const handleKeydown = (event: KeyboardEvent) => {
  if (event.key === 'Enter') {
    console.log('按下了回车键')
  }
}

// 鼠标事件
const handleMouseMove = (event: MouseEvent) => {
  console.log(event.clientX, event.clientY)
}
</script>

<template>
  <input @input="handleInput" @keydown="handleKeydown" />
  <div @mousemove="handleMouseMove">移动鼠标</div>
</template>

6.3 全局组件类型声明

components.d.ts 中声明全局组件:

// components.d.ts
import MyGlobalComponent from './src/components/MyGlobalComponent.vue'

declare module 'vue' {
  export interface GlobalComponents {
    MyGlobalComponent: typeof MyGlobalComponent
  }
}

export {}

七、TSX / JSX 在 Vue3 中的使用

7.1 基本 TSX 组件

// HelloWorld.tsx
import { ref, defineComponent } from 'vue'

interface Props {
  name: string
  count?: number
}

export default defineComponent({
  props: ['name', 'count'] as const,
  setup(props: Props) {
    const internalCount = ref(props.count || 0)

    const increment = () => {
      internalCount.value++
    }

    return () => (
      <div class="hello">
        <h1>Hello, {props.name}!</h1>
        <p>Count: {internalCount.value}</p>
        <button onClick={increment}>Increment</button>
      </div>
    )
  }
})

7.2 使用 <script setup> 风格的 TSX

// Counter.tsx
import { ref } from 'vue'

interface Props {
  initial?: number
  step?: number
}

const props = withDefaults(defineProps<Props>(), {
  initial: 0,
  step: 1
})

const emit = defineEmits<{
  change: [value: number]
}>()

const count = ref(props.initial)

const increment = () => {
  count.value += props.step
  emit('change', count.value)
}

export default () => (
  <div class="counter">
    <span>{count.value}</span>
    <button onClick={increment}>+{props.step}</button>
  </div>
)

7.3 TSX 类型配置

tsconfig.json 中配置 JSX:

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "vue"
  }
}

八、类型安全的 Pinia Store

8.1 定义类型安全的 Store

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 定义用户接口
export interface User {
  id: number
  name: string
  email: string
  avatar?: string
}

// 定义 Store 状态接口
export interface UserState {
  user: User | null
  token: string | null
  isLoggedIn: boolean
}

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref<User | null>(null)
  const token = ref<string | null>(localStorage.getItem('token'))

  // Getters
  const isLoggedIn = computed(() => !!token.value && !!user.value)
  const userName = computed(() => user.value?.name || '访客')

  // Actions
  const setUser = (userData: User) => {
    user.value = userData
  }

  const setToken = (newToken: string) => {
    token.value = newToken
    localStorage.setItem('token', newToken)
  }

  const login = async (credentials: { email: string; password: string }) => {
    // 模拟 API 调用
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const data = await response.json() as { user: User; token: string }

    setUser(data.user)
    setToken(data.token)
  }

  const logout = () => {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }

  return {
    user,
    token,
    isLoggedIn,
    userName,
    setUser,
    setToken,
    login,
    logout
  }
})

8.2 在组件中使用

<script setup lang="ts">
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// TypeScript 提供完整的类型提示
console.log(userStore.userName) // string
console.log(userStore.isLoggedIn) // boolean

const handleLogin = async () => {
  await userStore.login({
    email: 'user@example.com',
    password: 'password123'
  })
}
</script>

九、常见类型问题与解决方案

9.1 ref 的解包问题

<script setup lang="ts">
import { ref, unref } from 'vue'

const count = ref(0)

// 在模板中 ref 会自动解包
// 在 JS 中需要 .value
console.log(count.value)

// 使用 unref 处理可能是 ref 的值
function useMaybeRef(maybeRef: number | Ref<number>) {
  const value = unref(maybeRef)
  console.log(value) // number
}
</script>

9.2 响应式对象解构丢失响应性

<script setup lang="ts">
import { reactive, toRefs } from 'vue'

interface State {
  count: number
  name: string
}

const state = reactive<State>({
  count: 0,
  name: 'Vue'
})

// 错误:解构会丢失响应性
// const { count, name } = state

// 正确:使用 toRefs
const { count, name } = toRefs(state)

// 现在 count 和 name 都是 Ref
console.log(count.value)
console.log(name.value)
</script>

9.3 模板引用类型

<script setup lang="ts">
import { ref, onMounted } from 'vue'

// 元素引用
const inputRef = ref<HTMLInputElement | null>(null)

// 组件引用
const childRef = ref<{ focus: () => void } | null>(null)

onMounted(() => {
  // TypeScript 会提示可能为 null
  inputRef.value?.focus()
  childRef.value?.focus()
})
</script>

<template>
  <input ref="inputRef" />
  <ChildComponent ref="childRef" />
</template>

9.4 第三方库类型扩展

// types/shims.d.ts
import { ComponentCustomProperties } from 'vue'
import { Store } from 'pinia'

declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $store: Store
  }
}

// 为全局属性添加类型
declare module 'vue' {
  interface ComponentCustomProperties {
    $http: typeof fetch
  }
}

9.5 泛型组件

<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
interface Props {
  items: T[]
  keyProp?: keyof T
}

const props = defineProps<Props>()

const emit = defineEmits<{
  select: [item: T]
}>()
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="emit('select', item)"
    >
      <slot :item="item" />
    </li>
  </ul>
</template>

使用泛型组件:

<script setup lang="ts">
import GenericList from './GenericList.vue'

interface User {
  id: number
  name: string
  age: number
}

const users: User[] = [
  { id: 1, name: '张三', age: 25 },
  { id: 2, name: '李四', age: 30 }
]

const handleSelect = (user: User) => {
  console.log(user.name)
}
</script>

<template>
  <GenericList :items="users" @select="handleSelect">
    <template #default="{ item }">
      {{ item.name }} - {{ item.age }}岁
    </template>
  </GenericList>
</template>

十、类型系统架构图

Vue3 + TypeScript

组件类型

响应式类型

Props/Emits 类型

Store 类型

TSX/JSX 类型

InstanceType

模板引用类型

全局组件声明

Ref<T>

Reactive<T>

ComputedRef<T>

defineProps<Props>

defineEmits<Events>

withDefaults

Pinia Store

State 接口

Action 类型

defineComponent

JSX.Element

泛型组件

十一、常见问题

Q1:为什么 ref(null) 推断为 Ref<any>

当没有提供初始值或初始值为 null 时,TypeScript 无法推断具体类型。需要显式声明:

const el = ref<HTMLDivElement | null>(null)
const user = ref<User | null>(null)

Q2:如何解决 defineProps 的复杂类型报错?

对于复杂类型(如从其他文件导入的接口),确保类型是字面量类型或接口:

// 推荐:使用接口
interface Props { ... }
const props = defineProps<Props>()

// 避免:使用复杂的类型工具
// const props = defineProps<ReturnType<typeof useProps>>() // 可能报错

Q3:TypeScript 严格模式下 refundefined 问题

// 在 strictNullChecks 模式下
const maybeUser = ref<User>() // Ref<User | undefined>

// 访问时需要判断
if (maybeUser.value) {
  console.log(maybeUser.value.name)
}

// 或使用非空断言(谨慎使用)
console.log(maybeUser.value!.name)

Q4:如何为 Vue Router 添加类型支持?

// typed-router.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
    roles?: string[]
  }
}

十二、总结

Vue3 的 TypeScript 支持是框架的核心优势之一:

特性 Vue2 Vue3
源码语言 Flow / JavaScript TypeScript
Props 类型 运行时校验 编译时类型 + 运行时校验
Emits 类型 完整类型支持
响应式类型 有限 完整泛型支持
TSX 支持 需额外配置 原生支持
类型推断 较弱 优秀

核心要点

  1. 使用 lang="ts" 启用 TypeScript 支持
  2. 用接口定义 Props 类型,提高可复用性
  3. defineEmits 使用元组语法声明事件参数
  4. 复杂类型使用 withDefaults 设置默认值
  5. Pinia 配合 TypeScript 提供完整的 Store 类型安全
  6. 善用泛型组件处理列表等通用场景

十三、练习题

  1. 将现有的一个 Vue3 组件改写为完整的 TypeScript 版本,包括 Props、Emits、模板引用的类型声明。

  2. 使用 TypeScript 定义一个表单验证 Hook,要求支持:

    • 泛型表单数据类型
    • 类型安全的验证规则
    • 自动推断错误信息类型
  3. 创建一个类型安全的 Event Bus(或使用 mitt),确保事件名称和参数类型在发布和订阅时一致。

更多推荐