10. Vue3 + TypeScript:完整集成指南

Vue3 + TypeScript:完整集成指南

Vue3 TypeScript

恭喜你走到最后一期!前面我们学习了 ES6、Vite、Vue3 基础、组合式 API 和 TypeScript。现在,让我们把 TypeScript 完整集成到 Vue3 项目中。

项目创建

Vite + Vue + TypeScript

npm create vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install

项目结构

src/
├── main.ts
├── App.vue
├── vite-env.d.ts
├── components/
│   └── HelloWorld.vue
├── views/
├── router/
│   └── index.ts
├── stores/
│   └── user.ts
├── types/
│   └── index.ts
├── api/
│   └── user.ts
├── composables/
│   └── useUser.ts
└── utils/
    └── format.ts

类型定义

全局类型

// types/index.ts
export interface User {
  id: number
  name: string
  email: string
  avatar?: string
}

export interface ApiResponse {
  code: number
  message: string
  data: T
}

export type Theme = 'light' | 'dark'

export interface AppState {
  user: User | null
  theme: Theme
  loading: boolean
}

组件 Props 类型


interface Props {
  title: string
  count?: number
  users: User[]
}

const props = withDefaults(defineProps(), {
  count: 0
})

组件 Emits 类型


interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
  (e: 'change', event: Event): void
}

const emit = defineEmits()

function handleUpdate() {
  emit('update', 'new value')
}

组合式 API + TypeScript

ref

import { ref } from 'vue'

// 类型推断
const count = ref(0)
const name = ref('Alice')

// 显式类型
const user = ref(null)
const items = ref([])

reactive

import { reactive } from 'vue'

interface State {
  count: number
  name: string
  items: string[]
}

const state = reactive({
  count: 0,
  name: '',
  items: []
})

computed

import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// 自动推断返回类型
const fullName = computed(() => <code>${firstName.value} ${lastName.value})

// 显式类型
const userCount = computed(() => users.value.length)

组合式函数

// composables/useUser.ts
import { ref, computed } from 'vue'
import type { User } from '@/types'

export function useUser() {
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const isLoggedIn = computed(() => !!user.value)
  const userName = computed(() => user.value?.name ?? 'Guest')

  async function fetchUser(id: number) {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(<code>/api/users/${id})
      user.value = await response.json()
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Unknown error'
    } finally {
      loading.value = false
    }
  }

  function logout() {
    user.value = null
  }

  return {
    user,
    loading,
    error,
    isLoggedIn,
    userName,
    fetchUser,
    logout
  }
}

Pinia + TypeScript

Store 类型定义

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref(null)
  const token = ref(localStorage.getItem('token'))

  // Getters
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => user.value?.name ?? 'Guest')

  // Actions
  async function login(email: string, password: string) {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    })

    const data = await response.json()
    user.value = data.user
    token.value = data.token
    localStorage.setItem('token', data.token)
  }

  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }

  return {
    user,
    token,
    isLoggedIn,
    userName,
    login,
    logout
  }
}, {
  persist: true
})

使用 Store


import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// 解构(保持响应性)
const { user, isLoggedIn, userName } = storeToRefs(userStore)

// Actions
const { login, logout } = userStore

// 表单
const email = ref('')
const password = ref('')

async function handleLogin() {
  await login(email.value, password.value)
}

  <div>
    <p>Welcome, {{ userName }}</p>
    <button>Logout</button>
  </div>

    <button type="submit">Login</button>

Vue Router + TypeScript

路由类型

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/users/:id',
    name: 'User',
    component: () => import('@/views/User.vue'),
    props: true,
    meta: {
      requiresAuth: true
    }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

路由元信息类型

// types/router.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
    roles?: string[]
  }
}

使用路由


import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// 获取参数(类型安全)
const userId = computed(() => Number(route.params.id))

// 编程式导航
function goToUser(id: number) {
  router.push({ name: 'User', params: { id } })
}

API 集成

类型安全的 API 客户端

// api/client.ts
import type { ApiResponse } from '@/types'

class ApiClient {
  private baseUrl: string

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
  }

  private async request(
    path: string,
    options?: RequestInit
  ): Promise {
    const response = await fetch(<code>${this.baseUrl}${path}, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers
      }
    })

    if (!response.ok) {
      throw new Error(HTTP error! status: ${response.status})
    }

    return response.json()
  }

  get(path: string): Promise {
    return this.request(path)
  }

  post(path: string, data: unknown): Promise {
    return this.request(path, {
      method: 'POST',
      body: JSON.stringify(data)
    })
  }

  put(path: string, data: unknown): Promise {
    return this.request(path, {
      method: 'PUT',
      body: JSON.stringify(data)
    })
  }

  delete(path: string): Promise {
    return this.request(path, {
      method: 'DELETE'
    })
  }
}

export const api = new ApiClient('/api')

API 模块

// api/user.ts
import { api } from './client'
import type { User, ApiResponse } from '@/types'

export const userApi = {
  getAll(): Promise {
    return api.get<ApiResponse>('/users').then(r => r.data)
  },

  getById(id: number): Promise {
    return api.get<ApiResponse>(<code>/users/${id}).then(r => r.data)
  },

  create(data: Omit): Promise {
    return api.post<ApiResponse>('/users', data).then(r => r.data)
  },

  update(id: number, data: Partial): Promise {
    return api.put<ApiResponse>(/users/${id}, data).then(r => r.data)
  },

  delete(id: number): Promise {
    return api.delete(/users/${id})
  }
}

使用 API


import { ref, onMounted } from 'vue'
import { userApi } from '@/api/user'
import type { User } from '@/types'

const users = ref([])
const loading = ref(false)
const error = ref(null)

async function fetchUsers() {
  loading.value = true
  error.value = null

  try {
    users.value = await userApi.getAll()
  } catch (e) {
    error.value = e instanceof Error ? e.message : 'Unknown error'
  } finally {
    loading.value = false
  }
}

onMounted(fetchUsers)

  <div>Loading...</div>
  <div>{{ error }}</div>
  <ul>
    <li>
      {{ user.name }}
    </li>
  </ul>

表单处理

类型安全的表单


import { ref, reactive } from 'vue'

interface FormData {
  name: string
  email: string
  age: number
  subscribe: boolean
}

interface FormErrors {
  name?: string
  email?: string
  age?: string
}

const form = reactive({
  name: '',
  email: '',
  age: 0,
  subscribe: false
})

const errors = reactive({})

function validate(): boolean {
  let isValid = true

  if (!form.name.trim()) {
    errors.name = 'Name is required'
    isValid = false
  }

  if (!form.email.includes('@')) {
    errors.email = 'Invalid email'
    isValid = false
  }

  if (form.age  150) {
    errors.age = 'Invalid age'
    isValid = false
  }

  return isValid
}

async function handleSubmit() {
  if (!validate()) return

  // 提交表单
  await submitForm(form)
}

    <div>

      <span>{{ errors.name }}</span>
    </div>

    <div>

      <span>{{ errors.email }}</span>
    </div>

    <div>

      <span>{{ errors.age }}</span>
    </div>

    <div>
      <label>

        Subscribe to newsletter
      </label>
    </div>

    <button type="submit">Submit</button>

使用 vee-validate

npm install vee-validate zod @vee-validate/zod

import { useForm, useField } from 'vee-validate'
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'

const schema = toTypedSchema(
  z.object({
    name: z.string().min(1, 'Name is required'),
    email: z.string().email('Invalid email'),
    age: z.number().min(0).max(150)
  })
)

const { handleSubmit, errors } = useForm({
  validationSchema: schema
})

const { value: name } = useField('name')
const { value: email } = useField('email')
const { value: age } = useField('age')

const onSubmit = handleSubmit((values) => {
  console.log(values)
})

工具类型

组件实例类型

import type { ComponentInstance } from 'vue'

// 获取组件实例类型
type MyComponentInstance = ComponentInstance

提取 Props 类型

import type { ExtractPropTypes } from 'vue'

const propsDefinition = {
  title: String,
  count: {
    type: Number,
    default: 0
  }
} as const

type Props = ExtractPropTypes
// { title: string; count: number }

组件自定义类型

// 自定义组件类型
declare module '@vue/runtime-core' {
  export interface GlobalComponents {
    MyButton: typeof import('@/components/MyButton.vue')['default']
    MyInput: typeof import('@/components/MyInput.vue')['default']
  }
}

测试

Vitest 配置

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true
  }
})

组件测试

// components/__tests__/Button.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '../Button.vue'

describe('Button', () => {
  it('renders with text', () => {
    const wrapper = mount(Button, {
      props: { text: 'Click me' }
    })
    expect(wrapper.text()).toBe('Click me')
  })

  it('emits click event', async () => {
    const wrapper = mount(Button, {
      props: { text: 'Click me' }
    })
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeTruthy()
  })
})

最佳实践

1. 使用 script setup


// 推荐

2. 类型优先

// 先定义类型,再实现
interface User {
  id: number
  name: string
}

const user = ref(null)

3. 使用组合式函数

// 提取可复用逻辑
export function useUser() {
  // ...
}

4. 严格模式

// tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

5. 类型安全的 Props


interface Props {
  title: string
  count?: number
}

const props = withDefaults(defineProps(), {
  count: 0
})

系列总结

恭喜你完成了从 ES6 到 Vue3 + TypeScript 的完整学习之旅!

第一期:TypeScript 基础类型系统
第二期:接口、泛型与类型体操
第三期:项目配置与最佳实践
第四期:TypeScript 与前端框架
第五期:Vue3 + TypeScript 完整集成

你现在掌握了:

  • TypeScript 类型系统
  • Vue3 组合式 API
  • 类型安全的组件开发
  • Pinia 状态管理
  • Vue Router 类型安全
  • API 集成最佳实践

下一步

  • 学习 Nuxt 3(Vue3 全栈框架)
  • 探索 Vite 插件开发
  • 学习服务端渲染
  • 阅读官方文档

进阶资源


恭喜!你已经掌握了 Vue3 + TypeScript 的核心技能。去构建你的下一个项目吧!

Views: 0