Vue3 + TypeScript 前端实战:从零构建类型安全的现代Web应用

前言

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 的类型安全

使用 definePropsdefineEmits 的泛型写法,获得完整的类型提示:

// 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.jsonpaths 配置,并确保 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

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片快捷回复

    暂无评论内容