TypeScript 与 Vue 3
大白话解释: TypeScript + Vue 3 就像"给 Vue 加了类型检查"。写代码时编辑器能自动提示有哪些属性、方法,写错了会立即报错,不用等到运行时才发现。
为什么要用 TypeScript + Vue 3?
- 智能提示:输入
user.自动弹出name、age、email等属性 - 提前发现错误:拼写错误、类型错误在写代码时就能发现
- 重构更安全:修改接口后,所有用到的地方都会报错,不会遗漏
- 代码更易读:类型就是最好的文档
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,对象用 reactiveprops
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: '李四' })