TypeScript 在 Vue 3 中的最佳实践
TypeScript 让 Vue 开发更加安全可靠。本文讲解如何在 Vue 3 中高效使用 TypeScript。
1. 基础类型定义
组件 Props 的类型定义
// ✅ 完整的类型定义
import { PropType } from 'vue'
interface User {
id: string
name: string
email: string
role: 'admin' | 'user' | 'guest'
}
interface Props {
// 必需的属性
title: string
// 可选属性
count?: number
// 对象类型
user?: User
// 数组类型
items?: (string | number)[]
// 函数类型
onSubmit?: (data: any) => void
// 字面量类型
size?: 'sm' | 'md' | 'lg'
// any 类型 (避免)
// data?: any
}
export default {
props: {
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0
},
user: {
type: Object as PropType<User>,
default: () => ({})
},
size: {
type: String,
default: 'md',
validator: (value: string) => ['sm', 'md', 'lg'].includes(value)
}
}
}
// 在 <script setup> 中
<script setup lang="ts">
interface Props {
title: string
count?: number
}
withDefaults(defineProps<Props>(), {
count: 0
})
</script>
组件 Emits 的类型定义
// ✅ 类型安全的事件发射
interface Emits {
(e: 'submit', data: { name: string; email: string }): void
(e: 'cancel'): void
(e: 'delete', id: string): void
}
// 选项式 API
export default {
emits: {
submit: (data: { name: string; email: string }) => {
// 可选: 验证
return data.name && data.email
},
cancel: null,
delete: (id: string) => id !== ''
}
}
// <script setup>
<script setup lang="ts">
const emit = defineEmits<{
submit: [data: { name: string; email: string }]
cancel: []
delete: [id: string]
}>()
const handleSubmit = () => {
emit('submit', { name: 'Alice', email: 'alice@example.com' })
}
</script>
2. 高级类型模式
泛型组件
// components/List.vue
<script setup lang="ts" generic="T extends Record<string, any>">
interface Props {
items: T[]
keyField?: keyof T
}
const props = withDefaults(defineProps<Props>(), {
keyField: 'id' as keyof T
})
const emit = defineEmits<{
select: [item: T]
}>()
</script>
<template>
<div>
<div
v-for="item in items"
:key="item[keyField]"
@click="emit('select', item)"
>
{{ item }}
</div>
</div>
</template>
// 使用泛型组件
<script setup lang="ts">
interface User {
id: string
name: string
}
const users = ref<User[]>([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' }
])
const handleSelect = (user: User) => {
console.log('选中:', user.name)
}
</script>
<template>
<!-- ✅ 类型完全推断 -->
<List
:items="users"
key-field="id"
@select="handleSelect"
/>
</template>
条件类型和分布式条件类型
// 条件类型 (Conditional Types)
// 基础条件类型
type IsString<T> = T extends string ? true : false
type A = IsString<'hello'> // true
type B = IsString<number> // false
// 分布式条件类型
type Flatten<T> = T extends Array<infer U> ? U : T
type Str = Flatten<string[]> // string
type Num = Flatten<number> // number
type Mixed = Flatten<(string | number)[]> // string | number
// 实战: 提取 Promise 的结果类型
type Awaited<T> = T extends Promise<infer U> ? U : T
type Result = Awaited<Promise<string>> // string
// 实战: API 响应类型
interface APIResponse<T> {
code: number
message: string
data: T
}
type ExtractData<T> = T extends APIResponse<infer D> ? D : never
type UserData = ExtractData<APIResponse<{ name: string }>> // { name: string }
复杂的接口设计
// 实战: 表单验证框架
// 1. 定义字段验证规则
interface FieldRule {
required?: boolean
minLength?: number
maxLength?: number
pattern?: RegExp
custom?: (value: any) => boolean | string
}
// 2. 为每个字段定义规则
interface FormSchema {
[fieldName: string]: FieldRule
}
// 3. 带验证的表单处理
class FormValidator<T extends Record<string, any>> {
constructor(private schema: FormSchema) {}
validate(data: T): Record<keyof T, string[]> {
const errors: Record<string, string[]> = {}
for (const [field, rules] of Object.entries(this.schema)) {
const errors_list: string[] = []
const value = data[field]
if (rules.required && !value) {
errors_list.push(`${field} 是必需的`)
}
if (rules.minLength && value?.length < rules.minLength) {
errors_list.push(`${field} 至少需要 ${rules.minLength} 个字符`)
}
if (errors_list.length > 0) {
errors[field] = errors_list
}
}
return errors as Record<keyof T, string[]>
}
}
// 使用
interface LoginForm {
email: string
password: string
}
const validator = new FormValidator<LoginForm>({
email: { required: true, pattern: /^.+@.+\..+$/ },
password: { required: true, minLength: 6 }
})
const errors = validator.validate({
email: 'invalid',
password: '123'
})
3. 组合式 API 的类型定义
Composable 的返回类型
// composables/useCounter.ts
import { ref, computed, Ref } from 'vue'
// 定义返回类型
interface UseCounterReturn {
count: Ref<number>
double: ComputedRef<number>
increment: () => void
reset: () => void
}
// 实现 composable
export const useCounter = (initialValue: number = 0): UseCounterReturn => {
const count = ref(initialValue)
const double = computed(() => count.value * 2)
const increment = () => count.value++
const reset = () => count.value = initialValue
return {
count,
double,
increment,
reset
}
}
// 使用时自动推断类型
<script setup lang="ts">
const { count, double, increment } = useCounter(10)
// count: Ref<number>
// double: ComputedRef<number>
// increment: () => void
</script>
Composable 的泛型
// composables/useFetch.ts
interface UseFetchReturn<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<Error | null>
refresh: () => Promise<void>
}
export const useFetch = async <T = any>(
url: string | Ref<string>,
options?: FetchOptions
): Promise<UseFetchReturn<T>> => {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const refresh = async () => {
loading.value = true
try {
const response = await $fetch<T>(url, options)
data.value = response
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
await refresh()
return { data, loading, error, refresh }
}
// 使用
<script setup lang="ts">
interface User {
id: string
name: string
email: string
}
const { data: users, loading } = await useFetch<User[]>('/api/users')
// users: Ref<User[] | null>
// loading: Ref<boolean>
</script>
4. 常见类型错误和解决方案
错误 1: Any 类型滥用
// ❌ 避免
const handleClick = (event: any) => {
console.log(event.target.value) // 无类型检查
}
const fetchData = async (url: any) => {
const response = await $fetch(url)
return response // any 类型
}
// ✅ 正确
const handleClick = (event: MouseEvent) => {
if (event.target instanceof HTMLInputElement) {
console.log(event.target.value)
}
}
interface FetchOptions {
method?: 'GET' | 'POST'
headers?: Record<string, string>
body?: Record<string, any>
}
const fetchData = async (url: string, options?: FetchOptions) => {
const response = await $fetch(url, options)
return response
}
错误 2: 类型断言滥用
// ❌ 避免 (类型断言隐藏问题)
const data = response as User[]
const count = element as HTMLInputElement
// ✅ 正确 (类型保护)
const isUserArray = (data: unknown): data is User[] => {
return Array.isArray(data) && data.every(item => 'id' in item)
}
if (isUserArray(response)) {
// 现在 response 被确定为 User[]
}
const parseElement = (element: Element): HTMLInputElement | null => {
if (element instanceof HTMLInputElement) {
return element
}
return null
}
错误 3: Props 类型和运行时定义不匹配
// ❌ 避免 (类型和运行时不一致)
interface Props {
user: User | null
}
export default {
props: {
user: String // ❌ 类型是 object,运行时是 string!
}
}
// ✅ 正确
interface Props {
user?: User | null
}
export default {
props: {
user: {
type: Object as PropType<User | null>,
default: null
}
}
}
5. Pinia Store 的类型定义
// stores/useUserStore.ts
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
}
interface UserState {
users: User[]
currentUser: User | null
loading: boolean
error: string | null
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
users: [],
currentUser: null,
loading: false,
error: null
}),
getters: {
isAdmin: (state) => state.currentUser?.role === 'admin',
getUserById: (state) => (id: string): User | undefined => {
return state.users.find(user => user.id === id)
}
},
actions: {
async fetchUsers(): Promise<void> {
this.loading = true
try {
const users = await $fetch<User[]>('/api/users')
this.users = users
} catch (error: any) {
this.error = error.message
} finally {
this.loading = false
}
},
setCurrentUser(user: User | null): void {
this.currentUser = user
}
}
})
// 使用
<script setup lang="ts">
const userStore = useUserStore()
// 所有都有完整的类型提示
const { currentUser, isAdmin } = storeToRefs(userStore)
const user = userStore.getUserById('123') // User | undefined
</script>
6. API 响应的类型定义
// types/api.ts
// 通用 API 响应格式
interface APIResponse<T> {
code: number
message: string
data: T
timestamp: number
}
// 分页响应
interface PaginatedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
hasMore: boolean
}
// 具体的 API 类型
interface User {
id: string
name: string
email: string
avatar?: string
createdAt: string
}
interface UserResponse extends APIResponse<User> {}
interface UsersListResponse extends APIResponse<PaginatedResponse<User>> {}
type CreateUserRequest = Omit<User, 'id' | 'createdAt'>
// API 调用类型安全
<script setup lang="ts">
const getUserList = async (): Promise<UsersListResponse> => {
return await $fetch('/api/users')
}
const createUser = async (data: CreateUserRequest): Promise<UserResponse> => {
return await $fetch('/api/users', {
method: 'POST',
body: data
})
}
// 使用
const response = await getUserList()
// response: UsersListResponse
// response.data.items: User[]
</script>
7. 类型工具函数
// types/utils.ts
// 1. 提取对象键的联合类型
type Keys<T> = keyof T
type UserKeys = Keys<User> // 'id' | 'name' | 'email'
// 2. 提取对象值的联合类型
type Values<T> = T[keyof T]
type UserValues = Values<User> // string | number
// 3. 部分可选
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
type UserWithOptionalEmail = PartialBy<User, 'email'>
// { id: string; name: string; email?: string }
// 4. 深度只读
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>
}
// 5. 记录类型
type UserRole = 'admin' | 'user' | 'guest'
type RolePermissions = Record<UserRole, string[]>
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read']
}
// 6. 排除类型
type Exclude<T, U> = T extends U ? never : T
type Admin = Exclude<UserRole, 'guest' | 'user'> // 'admin'
// 7. 提取类型
type Extract<T, U> = T extends U ? T : never
type NotAdmin = Extract<UserRole, Exclude<UserRole, 'admin'>> // 'user' | 'guest'
8. 最佳实践总结
// ✅ TypeScript 最佳实践清单
// 1. 优先使用 interface 定义数据结构
interface User {
id: string
name: string
}
// 2. 为函数参数和返回值添加类型
function getUser(id: string): Promise<User> {
// ...
}
// 3. 避免使用 any,使用 unknown 然后类型保护
// ❌ const data: any
// ✅ const data: unknown
if (typeof data === 'object' && data !== null) {
// 现在可以安全地使用 data
}
// 4. 使用字面量类型替代字符串常量
// ❌ status: string
// ✅ status: 'pending' | 'success' | 'error'
// 5. 充分利用泛型
const useData = <T>(url: string): Promise<T> => {
// ...
}
// 6. 为 Vue 组件的 Props 和 Emits 定义类型
interface Props {
title: string
count?: number
}
type Emits = {
'update:title': [title: string]
'increment': []
}
总结
TypeScript 在 Vue 3 中的优势:
| 特性 | 优势 |
|---|---|
| 类型安全 | 编译时发现错误 |
| 自动补全 | IDE 提示更准确 |
| 可维护性 | 代码意图更明确 |
| 重构安全 | 改变代码有反馈 |
| 文档作用 | 类型即文档 |