Skip to content

TypeScript 与 Vue 3

大白话解释: TypeScript + Vue 3 就像"给 Vue 加了类型检查"。写代码时编辑器能自动提示有哪些属性、方法,写错了会立即报错,不用等到运行时才发现。

为什么要用 TypeScript + Vue 3?

  • 智能提示:输入 user. 自动弹出 nameageemail 等属性
  • 提前发现错误:拼写错误、类型错误在写代码时就能发现
  • 重构更安全:修改接口后,所有用到的地方都会报错,不会遗漏
  • 代码更易读:类型就是最好的文档

TypeScript 在 Vue 3 中的应用:

  • ref/reactive 的类型标注
  • defineProps/defineEmits 的类型声明
  • 组件的类型定义
  • 泛型组件(如通用表格、表单)

Vue 3 对 TypeScript 的原生支持,从组件类型声明到泛型组件的完整指南。


基本类型标注

ref / reactive

ts
import { ref, reactive } from 'vue'

// ---- ref:响应式引用 ----
// 大白话:就像给变量"装了个监控",值变了,用到它的地方自动更新

// 自动推导(推荐,简单场景)
const count = ref(0)         // 推导为 Ref<number>
const name = ref('张三')      // 推导为 Ref<string>
const flag = ref(true)       // 推导为 Ref<boolean>

// 显式标注(复杂类型或初始值为 null 时)
const list = ref<number[]>([])
const user = ref<User | null>(null)  // 可能是 User,也可能是 null

// 复杂类型
interface User {
  id: number
  name: string
  email?: string
  roles: string[]
}
const currentUser = ref<User | null>(null)

// 函数类型
const handler = ref<((e: Event) => void) | null>(null)

// ---- reactive:响应式对象 ----
// 大白话:就像给对象"装了监控",对象的任何属性变了,都会触发更新

// 自动推导(推荐)
const state = reactive({
  count: 0,
  name: '张三',
})

// 接口标注(复杂对象)
interface FormState {
  username: string
  password: string
  remember: boolean
}
const form = reactive<FormState>({
  username: '',
  password: '',
  remember: false,
})

// 常见坑:ref 和 reactive 的区别
// ref 需要 .value 访问,reactive 不需要
// ref 可以替换整个值,reactive 不能
// 推荐:基本类型用 ref,对象用 reactive

props

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

// ---- props:父组件传给子组件的数据 ----
// 大白话:就像"快递",父组件打包,子组件签收

// 方式一:运行时声明(简单,但类型推断不够精确)
const props1 = defineProps({
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  items: { type: Array as PropType<string[]>, default: () => [] },
})

// 方式二:类型声明(推荐,类型推断精确)
const props2 = defineProps<{
  title: string
  count?: number           // 可选属性
  items: string[]
  user: {
    id: number
    name: string
  }
  status: 'loading' | 'success' | 'error'  // 字面量类型
}>()

// 方式三:带默认值(推荐,既类型安全又有默认值)
const props3 = withDefaults(
  defineProps<{
    title: string
    count?: number
    items?: string[]
    status?: 'loading' | 'success' | 'error'
  }>(),
  {
    count: 0,
    items: () => [],
    status: 'loading',
  }
)
</script>

emits

vue
<script setup lang="ts">
// ---- emits:子组件通知父组件 ----
// 大白话:就像"打电话",子组件打电话告诉父组件发生了什么

// 类型声明(推荐)
const emit = defineEmits<{
  change: [id: number]                           // 单个参数
  submit: [data: FormData]                       // 对象参数
  'update:modelValue': [value: string]           // v-model 事件
  delete: [id: number, force: boolean]           // 多个参数
}>()

// 使用
emit('change', 123)
emit('submit', new FormData())
emit('update:modelValue', 'hello')
emit('delete', 1, true)

// emit('change', 'abc')  // ❌ 类型错误,第一个参数必须是 number
</script>

defineModel(Vue 3.4+)

vue
<script setup lang="ts">
// ---- defineModel:简化 v-model ----
// 大白话:就像"双向快递",父组件传过来,子组件改了还能传回去

// 基本用法
const model = defineModel<string>()

// 带选项(required 表示父组件必须传入该 v-model)
const modelRequired = defineModel<string>({ required: true })

// 多个 v-model
const name = defineModel<string>('name')
const age = defineModel<number>('age')

// 使用
// <MyComponent v-model="model" v-model:name="name" v-model:age="age" />
</script>

ref 组件引用

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

// ---- ref 组件引用:获取子组件实例 ----
// 大白话:就像"遥控器",父组件可以遥控子组件

// 引用子组件
const childRef = ref<InstanceType<typeof ChildComponent>>()

// 使用子组件的方法
childRef.value?.validate()

// 泛型组件引用
const listRef = ref<InstanceType<typeof GenericList>>()

// 实际用法:表单验证
const formRef = ref<InstanceType<typeof Form>>()

async function handleSubmit() {
  const valid = await formRef.value?.validate()
  if (valid) {
    // 提交表单
  }
}
</script>

泛型组件

vue
<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
// ---- 泛型组件:支持多种数据类型 ----
// 大白话:就像"万能列表",不管存什么类型的数据都能用
// generic="T extends { id: number }" 声明泛型参数 T
// T extends { id: number }:T 必须有 id 属性(用于列表的 key)

// Props 使用泛型 T
// items: T[]:列表数据,类型是 T 的数组
// selected?: T:当前选中项,可选
defineProps<{
  items: T[]
  selected?: T
}>()

// Events 也使用泛型 T
// select 事件传出选中的项(类型是 T)
defineEmits<{
  select: [item: T]
  delete: [id: number]
}>()
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"                    <!-- item.id 由泛型约束保证存在 -->
      :class="{ active: item.id === selected?.id }"
      @click="$emit('select', item)"
    >
      <!-- 插槽:把 item 传给父组件,父组件决定怎么渲染 -->
      <slot :item="item" />
    </li>
  </ul>
</template>
vue
<!-- 使用泛型组件 -->
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

interface Product {
  id: number
  title: string
  price: number
}

const users = ref<User[]>([
  { id: 1, name: '张三', email: '[email protected]' },
  { id: 2, name: '李四', email: '[email protected]' },
])

const products = ref<Product[]>([
  { id: 1, title: '商品A', price: 99 },
  { id: 2, title: '商品B', price: 199 },
])

// 使用同一个组件,渲染不同类型的数据
function handleSelectUser(user: User) {
  console.log(user.name)    // ✅ 类型安全,编辑器有提示
}

function handleSelectProduct(product: Product) {
  console.log(product.title) // ✅ 类型安全
}
</script>

<template>
  <!-- 用户列表:T = User -->
  <GenericList :items="users" @select="handleSelectUser">
    <template #default="{ item }">
      {{ item.name }} - {{ item.email }}
    </template>
  </GenericList>

  <!-- 商品列表:T = Product -->
  <GenericList :items="products" @select="handleSelectProduct">
    <template #default="{ item }">
      {{ item.title }} - ¥{{ item.price }}
    </template>
  </GenericList>
</template>

类型工具

PropType

ts
import { PropType } from 'vue'

// ---- PropType:运行时声明的类型工具 ----
// 大白话:就像"类型转换器",让运行时声明也能有精确类型

defineProps({
  // 复杂对象类型
  config: {
    type: Object as PropType<{
      baseURL: string
      timeout: number
      headers?: Record<string, string>
    }>,
    required: true,
  },

  // 联合类型
  status: {
    type: String as PropType<'loading' | 'success' | 'error'>,
    default: 'loading',
  },

  // 函数类型
  formatter: {
    type: Function as PropType<(value: number) => string>,
  },

  // 数组泛型
  list: {
    type: Array as PropType<User[]>,
    default: () => [],
  },
})

ExtractPropTypes

ts
import type { ExtractPropTypes, PropType } from 'vue'

// ---- ExtractPropTypes:从运行时声明提取 TS 类型 ----
// 大白话:就像"反向工程",从运行时声明反推出类型

const myProps = {
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  items: { type: Array as PropType<string[]>, default: () => [] },
}

// 提取为 TS 类型
type MyProps = ExtractPropTypes<typeof myProps>
// { title: string; count: number; items: string[] }

// 实际用法:在其他地方复用 props 类型
function processProps(props: MyProps) {
  console.log(props.title.toUpperCase())
}

defineSlots(Vue 3.3+)

vue
<script setup lang="ts">
// ---- defineSlots:类型安全的插槽 ----
// 大白话:就像"预留座位",告诉组件有哪些座位(插槽),每个座位坐什么人(类型)

defineSlots<{
  default(props: { item: any }): any
  header(props: { title: string }): any
  footer(): any
}>()
</script>

<template>
  <div>
    <slot name="header" title="标题" />
    <slot :item="item" />
    <slot name="footer" />
  </div>
</template>

事件类型

vue
<script setup lang="ts">
// ---- DOM 事件类型 ----
// 大白话:就像"交通信号灯",不同的事件有不同的"信号"

// 鼠标事件
function handleClick(event: MouseEvent) {
  console.log(event.clientX, event.clientY)
}

// 输入事件
function handleInput(event: Event) {
  const target = event.target as HTMLInputElement
  console.log(target.value)
}

// 键盘事件
function handleKeydown(event: KeyboardEvent) {
  if (event.key === 'Enter') {
    submitForm()
  }
  if (event.key === 'Escape') {
    closeDialog()
  }
}

// 表单事件
function handleSubmit(event: SubmitEvent) {
  event.preventDefault()
  const form = event.target as HTMLFormElement
  const formData = new FormData(form)
}

// 拖拽事件
function handleDrag(event: DragEvent) {
  event.dataTransfer?.setData('text/plain', 'dragged')
}

// 滚动事件
function handleScroll(event: Event) {
  const target = event.target as HTMLElement
  console.log(target.scrollTop)
}
</script>

<template>
  <button @click="handleClick">点击</button>
  <input @input="handleInput" @keydown="handleKeydown" />
  <form @submit="handleSubmit">...</form>
  <div draggable @dragstart="handleDrag">拖拽</div>
  <div @scroll="handleScroll">滚动区域</div>
</template>

Composables 类型标注

ts
// ---- Composables:自定义 Hooks 的类型标注 ----
// 大白话:就像"可复用的工具包",把逻辑抽出来,哪里需要哪里用

// 基本 composable
function useCounter(initialValue: number = 0) {
  const count = ref(initialValue)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  return {
    count: readonly(count),  // 返回只读的 ref
    increment,
    decrement,
  }
}

// 使用
const { count, increment, decrement } = useCounter(10)
// count 的类型自动推导为 Readonly<Ref<number>>

// 带泛型的 composable
function useFetch<T>(url: string) {
  // T 是返回数据的类型,由调用时指定
  // ref<T | null>:初始值为 null,请求成功后是 T 类型
  const data = ref<T | null>(null)
  const loading = ref(false)
  // error 的类型是 string | null,存错误信息或 null(无错误)
  const error = ref<string | null>(null)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(url)
      // 检查 HTTP 状态码,4xx/5xx 抛异常
      if (!response.ok) {
        throw new Error(`请求失败: ${response.status} ${response.statusText}`)
      }
      data.value = await response.json()
    } catch (e) {
      // e 是 unknown 类型(不知道是 Error 还是其他)
      // instanceof Error 检查是否是标准错误对象
      error.value = e instanceof Error ? e.message : '未知错误'
    } finally {
      // finally 无论成功失败都会执行,确保 loading 被重置
      loading.value = false
    }
  }

  return {
    data: readonly(data),      // 只读,防止外部意外修改
    loading: readonly(loading),
    error: readonly(error),
    execute,                   // 手动触发请求
  }
}

// 使用
interface User {
  id: number
  name: string
}

const { data, loading, error, execute } = useFetch<User[]>('/api/users')
// data 的类型是 Readonly<Ref<User[] | null>>
// loading 的类型是 Readonly<Ref<boolean>>
// error 的类型是 Readonly<Ref<string | null>>

// 在模板中使用
// <div v-if="loading">加载中...</div>
// <div v-else-if="error">{{ error }}</div>
// <div v-else>{{ data }}</div>

// 带类型的 composable 接口
interface UseCounterOptions {
  min?: number
  max?: number
  step?: number
}

function useCounter(options: UseCounterOptions = {}) {
  const { min = -Infinity, max = Infinity, step = 1 } = options
  const count = ref(0)

  function increment() {
    count.value = Math.min(count.value + step, max)
  }

  function decrement() {
    count.value = Math.max(count.value - step, min)
  }

  function reset() {
    count.value = 0
  }

  return {
    count: readonly(count),
    increment,
    decrement,
    reset,
  }
}

// 使用
const { count, increment, decrement, reset } = useCounter({ min: 0, max: 100, step: 5 })

模块声明

环境模块(env.d.ts)

ts
// ---- 环境模块:告诉 TS 怎么处理特殊文件 ----
// 大白话:就像"翻译器",让 TS 能理解 .vue、.png 等文件

// src/env.d.ts
/// <reference types="vite/client" />

// 声明 .vue 文件类型
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

// 声明无类型的第三方库
declare module 'some-lib' {
  export function doSomething(): void
  export const version: string
}

// 声明图片资源
declare module '*.png' {
  const src: string
  export default src
}
declare module '*.svg' {
  const src: string
  export default src
}
declare module '*.jpg' {
  const src: string
  export default src
}

全局类型

ts
// ---- 全局类型:整个项目都能用的类型 ----
// 大白话:就像"公共设施",所有人都能用

// src/types/global.d.ts
export {}

declare global {
  // 全局变量:扩展 Window 接口
  interface Window {
    __APP_VERSION__: string
    __DEV__: boolean
  }

  // 全局类型工具:任何地方都能用
  type Nullable<T> = T | null
  type Recordable<T = any> = Record<string, T>
  type Optional<T> = T | undefined
}

// 全局组件声明:让 TS 识别 Vue 组件
// 注意:这个声明要放在独立的 .d.ts 文件中,不要放在 declare global 里面
// src/components.d.ts
declare module 'vue' {
  export interface GlobalComponents {
    // typeof import(...) 动态导入获取组件类型
    RouterLink: typeof import('vue-router')['RouterLink']
    RouterView: typeof import('vue-router')['RouterView']
    // 可以继续添加全局注册的组件
    // MyButton: typeof import('@/components/Button.vue')['default']
  }
}

export {}

环境变量类型

ts
// ---- 环境变量类型:给 .env 文件加类型 ----
// 大白话:就像"配置文件的说明书"

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_APP_TITLE: string
  readonly VITE_APP_VERSION: string
  readonly VITE_APP_ENV: 'development' | 'production' | 'test'
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

// 使用
const apiUrl = import.meta.env.VITE_API_BASE_URL  // 类型安全
// const xxx = import.meta.env.VITE_XXX  // ❌ 不存在的环境变量会报错

TypeScript 配置

json
// tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "types": ["vite/client"]
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
  "exclude": ["node_modules", "dist"]
}

常见坑点

1. ref 推导不精确

ts
// ❌ 推导为 Ref<null>,不是 Ref<number | null>
const count = ref(null) // Ref<null>

// ✅ 显式标注
const count = ref<number | null>(null) // Ref<number | null>

// ❌ 推导为 Ref<number[]>,不能替换整个数组
const list = ref([1, 2, 3])

// ✅ 显式标注,可以替换整个数组
const list = ref<number[]>([1, 2, 3])
list.value = [4, 5, 6]  // ✅

2. defineProps 不支持外部 import 的 interface

vue
<script setup lang="ts">
// ❌ Vue 3.3 之前,不能直接 import interface
// import type { User } from '@/types'
// defineProps<{ user: User }>()

// ✅ Vue 3.3+ 支持了
import type { User } from '@/types'
defineProps<{ user: User }>()

// ✅ 旧版本方案:在文件内定义或用 import()
</script>

3. template 中的类型推导

vue
<script setup lang="ts">
const list = ref([1, 2, 3])
</script>

<template>
  <!-- ✅ item 自动推导为 number -->
  <div v-for="item in list" :key="item">
    {{ item }}
  </div>
</template>

4. 组件类型导出

vue
<!-- MyComponent.vue -->
<script setup lang="ts">
defineProps<{ msg: string }>()
defineExpose({ validate: () => boolean })
</script>
ts
// 导出组件类型
import type MyComponent from './MyComponent.vue'
type MyComponentInstance = InstanceType<typeof MyComponent>

// 实际用法:获取子组件类型
const childRef = ref<MyComponentInstance>()
childRef.value?.validate()  // 类型安全

5. reactive 不能替换整个对象

ts
const state = reactive({ count: 0, name: '张三' })

// ❌ 错误:不能替换整个对象
state = { count: 1, name: '李四' }

// ✅ 正确:修改属性
state.count = 1
state.name = '李四'

// ✅ 正确:使用 Object.assign
Object.assign(state, { count: 1, name: '李四' })

参考

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准