前言
Vue3 引入 Composition API 后,与 TypeScript 的结合变得更加自然。TypeScript 能在编译阶段捕获大量类型错误,配合 Vue3 的 <script setup> 语法,开发体验大幅提升。本文将通过一个完整的实战项目,带你掌握 Vue3 + TypeScript 的核心套路。
一、项目初始化与配置
1.1 使用 Vite 创建项目
Vite 是目前最推荐的 Vue3 构建工具,冷启动速度极快:
npm create vite@latest vue3-ts-demo -- --template vue-ts
cd vue3-ts-demo
npm install
npm install -D vue-tsc @types/node
1.2 tsconfig.json 关键配置
确保 tsconfig.json 中启用严格模式,并正确配置路径别名:
// tsconfig.json
{
"compilerOptions":
{
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths":
{
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
1.3 Vue 类型声明扩展
在 src/env.d.ts 中添加 Vue 模块声明,解决 TS 无法识别 .vue 文件的问题:
// src/env.d.ts
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
二、Composition API + TypeScript 核心技巧
2.1 ref 与 reactive 的类型标注
使用 ref() 时,TypeScript 会自动推断类型,但复杂场景下建议显式标注:
// src/composables/useCounter.ts
import { ref, computed } from 'vue'
// 显式标注泛型,避免 TS 推断为 unknown
export function useCounter(initialValue: number = 0) {
const count = ref<number>(initialValue)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return { count, doubled, increment, decrement, reset }
}
2.2 reactive 与接口定义
对于表单、配置对象等复杂数据结构,先用 interface 定义形状:
// src/types/user.ts
export interface UserForm {
username: string
email: string
age: number
role: 'admin' | 'editor' | 'viewer'
avatar?: string // 可选字段
}
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
}
// src/components/UserForm.vue
import { reactive } from 'vue'
import type { UserForm } from '@/types/user'
const form = reactive<UserForm>({
username: '',
email: '',
age: 18,
role: 'viewer'
})
2.3 props 与 emit 的类型安全
使用 defineProps 和 defineEmits 的泛型写法,获得完整的类型提示:
// src/components/PostCard.vue
<script setup lang="ts">
interface Post {
id: number
title: string
summary: string
createdAt: string
}
const props = defineProps<{
post: Post
loading?: boolean
}>()
const emit = defineEmits<{
(e: 'select', postId: number): void
(e: 'delete', postId: number): void
}>()
function handleClick() {
emit('select', props.post.id)
}
</script>
三、Composables 复用逻辑最佳实践
3.1 封装 useFetch 通用请求 Hook
一个类型安全的请求 Hook,可以在所有组件中复用:
// src/composables/useFetch.ts
import { ref } from 'vue'
interface UseFetchReturn<T> {
data: typeof ref<T | null>
error: typeof ref<string | null>
loading: typeof ref<boolean>
execute: (url: string, options?: RequestInit) => Promise<T>
}
export function useFetch<T = unknown>(): UseFetchReturn<T> {
const data = ref<T | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)
async function execute(url: string, options?: RequestInit): Promise<T> {
loading.value = true
error.value = null
try {
const res = await fetch(url, options)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
return data.value as T
} catch (err) {
error.value = (err as Error).message
throw err
} finally {
loading.value = false
}
}
return { data, error, loading, execute }
}
3.2 在组件中使用 useFetch
// src/views/PostList.vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useFetch } from '@/composables/useFetch'
import type { Post } from '@/types/post'
interface PostListResponse {
list: Post[]
total: number
}
const { data, loading, error, execute } = useFetch<PostListResponse>()
onMounted(() => {
execute('/api/posts')
})
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<ul v-else>
<li v-for="post in data?.list" :key="post.id">
{{ post.title }}
</li>
</ul>
</template>
四、Pinia 状态管理的 TypeScript 集成
4.1 定义类型安全的 Store
Pinia 对 TypeScript 支持非常友好,无需额外配置即可获得完整类型推断:
// src/stores/user.ts
import { defineStore } from 'pinia'
import type { UserForm } from '@/types/user'
interface UserState {
token: string | null
userInfo: UserForm | null
permissions: string[]
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
token: localStorage.getItem('token'),
userInfo: null,
permissions: []
}),
getters: {
isLoggedIn: (state) => !!state.token,
username: (state) => state.userInfo?.username ?? '游客'
},
actions: {
async login(username: string, password: string) {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}).then(r => r.json())
this.token = res.data.token
this.userInfo = res.data.user
localStorage.setItem('token', res.data.token)
},
logout() {
this.token = null
this.userInfo = null
this.permissions = []
localStorage.removeItem('token')
}
}
})
4.2 在组件中使用 Store
// src/components/NavBar.vue
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// storeToRefs 保持响应式解构
const { isLoggedIn, username } = storeToRefs(userStore)
</script>
五、路由与异步组件优化
5.1 路由配置类型约束
// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/post/:id',
name: 'PostDetail',
component: () => import('@/views/PostDetail.vue'),
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫,类型安全
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
next({ name: 'Login' })
} else {
next()
}
})
export default router
5.2 异步组件与 Suspense
Vue3 支持 Suspense,配合异步组件可以实现优雅的加载状态:
// src/App.vue
import { defineAsyncComponent } from 'vue'
const PostEditor = defineAsyncComponent(() =>
import('@/components/PostEditor.vue')
)
// 或者使用 Suspense 包裹
// <Suspense>
// <PostEditor />
// <template #fallback>编辑器加载中...</template>
// </Suspense>
六、实战:完整组件示例
下面是一个带表单验证的用户设置组件,展示 Vue3 + TS 完整开发流程:
// src/components/UserSettings.vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import type { UserForm } from '@/types/user'
// 表单数据,带类型约束
const form = reactive<UserForm>({
username: '',
email: '',
age: 18,
role: 'viewer'
})
// 表单验证错误
const errors = reactive<Partial<Record<keyof UserForm, string>>>({})
const submitting = ref(false)
// 验证函数,返回类型安全的错误信息
function validate(): boolean {
let valid = true
if (!form.username.trim()) {
errors.username = '用户名不能为空'
valid = false
} else {
delete errors.username
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = '邮箱格式不正确'
valid = false
} else {
delete errors.email
}
if (form.age < 1 || form.age > 150) {
errors.age = '年龄必须在 1-150 之间'
valid = false
} else {
delete errors.age
}
return valid
}
async function handleSubmit() {
if (!validate()) return
submitting.value = true
try {
const res = await fetch('/api/user/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
if (!res.ok) throw new Error('保存失败')
alert('保存成功!')
} catch (err) {
alert((err as Error).message)
} finally {
submitting.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="settings-form">
<div class="field">
<label>用户名</label>
<input v-model="form.username" type="text" />
<span class="error" v-if="errors.username">{{ errors.username }}</span>
</div>
<div class="field">
<label>邮箱</label>
<input v-model="form.email" type="email" />
<span class="error" v-if="errors.email">{{ errors.email }}</span>
</div>
<div class="field">
<label>年龄</label>
<input v-model.number="form.age" type="number" min="1" max="150" />
<span class="error" v-if="errors.age">{{ errors.age }}</span>
</div>
<div class="field">
<label>角色</label>
<select v-model="form.role">
<option value="viewer">观察者</option>
<option value="editor">编辑</option>
<option value="admin">管理员</option>
</select>
</div>
<button type="submit" :disabled="submitting">
{{ submitting ? '保存中...' : '保存设置' }}
</button>
</form>
</template>
七、常见问题与解决方案
- TS 报错:不能找到模块 ‘@/xxx’:检查
tsconfig.json的paths配置,并确保 Vite 的resolve.alias与之对应。 - ref 解包问题:在
<template>中 Vue 会自动解包 ref,但在 TS 逻辑中必须访问.value。 - 组件 Emit 类型不正确:使用
defineEmits<{ (e: 'eventName', payload: Type): void }>()泛型写法。 - Pinia Store 类型丢失:确保 state 函数返回类型注解
state: (): StateType => (...)。
总结
Vue3 与 TypeScript 的组合,让前端开发从”能跑就行”升级为”类型安全、可维护、可重构”的工程化开发。核心要点:interface 先行、泛型复用、Composables 封装、Pinia 集中状态。建议在新项目中直接使用这套技术栈,旧项目也可以逐步迁移。
完整示例代码已上传至 GitHub,欢迎 Star 和 Fork:vue3-ts-template

















暂无评论内容