← 返回主页

第14课: TypeScript与Vue3

为什么使用TypeScript?

TypeScript为Vue3提供了类型安全、更好的IDE支持和代码提示。Vue3本身就是用TypeScript编写的,对TS有完整的支持。

创建TypeScript项目

npm create vue@latest

# 选择TypeScript支持
✔ Add TypeScript? Yes

基础类型定义

<script setup lang="ts">
import { ref, reactive } from 'vue'

// 基本类型
const count = ref<number>(0)
const message = ref<string>('Hello')
const isActive = ref<boolean>(true)

// 数组类型
const numbers = ref<number[]>([1, 2, 3])
const users = ref<User[]>([])

// 对象类型
interface User {
  id: number
  name: string
  email: string
}

const user = reactive<User>({
  id: 1,
  name: '张三',
  email: 'zhang@example.com'
})
</script>

Props类型定义

<script setup lang="ts">
interface Props {
  title: string
  count?: number  // 可选属性
  tags: string[]
  user: {
    name: string
    age: number
  }
}

// 使用withDefaults设置默认值
const props = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => []
})
</script>

Emits类型定义

<script setup lang="ts">
// 定义事件类型
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
  (e: 'change', data: { name: string; age: number }): void
}

const emit = defineEmits<Emits>()

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

function handleDelete() {
  emit('delete', 123)
}

function handleChange() {
  emit('change', { name: '张三', age: 25 })
}
</script>

Ref类型定义

<script setup lang="ts">
import { ref } from 'vue'
import type { Ref } from 'vue'

// 方式1:泛型
const count = ref<number>(0)

// 方式2:类型注解
const message: Ref<string> = ref('Hello')

// 复杂类型
interface User {
  id: number
  name: string
}

const user = ref<User | null>(null)

// DOM元素引用
const inputRef = ref<HTMLInputElement | null>(null)

onMounted(() => {
  inputRef.value?.focus()
})
</script>

Reactive类型定义

<script setup lang="ts">
import { reactive } from 'vue'

interface State {
  count: number
  message: string
  user: {
    name: string
    age: number
  }
}

const state = reactive<State>({
  count: 0,
  message: 'Hello',
  user: {
    name: '张三',
    age: 25
  }
})
</script>

Computed类型定义

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ComputedRef } from 'vue'

const count = ref(0)

// 自动推导类型
const double = computed(() => count.value * 2)

// 显式类型
const triple: ComputedRef<number> = computed(() => count.value * 3)

// 可写计算属性
const fullName = computed<string>({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(value: string) {
    const names = value.split(' ')
    firstName.value = names[0]
    lastName.value = names[1]
  }
})
</script>

事件处理类型

<script setup lang="ts">
// 原生事件
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') {
    console.log('按下回车')
  }
}
</script>

<template>
  <button @click="handleClick">点击</button>
  <input @input="handleInput" @keydown="handleKeydown" />
</template>

组合式函数类型

// composables/useCounter.ts
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  double: ComputedRef<number>
  increment: () => void
  decrement: () => void
}

export function useCounter(initialValue = 0): UseCounterReturn {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  return {
    count,
    double,
    increment,
    decrement
  }
}

Pinia Store类型

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

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)

  const isLoggedIn = computed(() => !!token.value)
  const isAdmin = computed(() => user.value?.role === 'admin')

  async function login(email: string, password: string): Promise<boolean> {
    try {
      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
      return true
    } catch (error) {
      console.error(error)
      return false
    }
  }

  function logout(): void {
    user.value = null
    token.value = null
  }

  return {
    user,
    token,
    isLoggedIn,
    isAdmin,
    login,
    logout
  }
})

API响应类型

// types/api.ts
export interface ApiResponse<T> {
  code: number
  message: string
  data: T
}

export interface User {
  id: number
  name: string
  email: string
  avatar?: string
}

export interface Post {
  id: number
  title: string
  content: string
  author: User
  createdAt: string
}
// composables/useApi.ts
import type { Ref } from 'vue'
import type { ApiResponse } from '@/types/api'

export function useApi<T>(url: string) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)

  async function fetch(): Promise<void> {
    loading.value = true
    error.value = null

    try {
      const response = await window.fetch(url)
      const result: ApiResponse<T> = await response.json()
      data.value = result.data
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  return { data, error, loading, fetch }
}

路由类型

// 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: '/user/:id',
    name: 'User',
    component: () => import('@/views/User.vue'),
    props: true
  }
]

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

export default router
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'

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

// 访问路由参数(类型安全)
const userId = route.params.id as string

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

全局类型声明

// types/global.d.ts
export {}

declare global {
  interface Window {
    myApp: {
      version: string
      config: Record<string, any>
    }
  }
}

// 扩展Vue组件实例
declare module 'vue' {
  interface ComponentCustomProperties {
    $filters: {
      formatDate: (date: Date) => string
      currency: (value: number) => string
    }
  }
}

环境变量类型

// env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string
  readonly VITE_APP_TITLE: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

实战示例:完整组件

<template>
  <div class="user-list">
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">错误: {{ error.message }}</div>
    <div v-else>
      <div v-for="user in users" :key="user.id" class="user-card">
        <h3>{{ user.name }}</h3>
        <p>{{ user.email }}</p>
        <button @click="handleEdit(user)">编辑</button>
        <button @click="handleDelete(user.id)">删除</button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

interface Props {
  filter?: string
}

interface Emits {
  (e: 'edit', user: User): void
  (e: 'delete', id: number): void
}

const props = defineProps<Props>()
const emit = defineEmits<Emits>()

const users = ref<User[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)

async function fetchUsers(): Promise<void> {
  loading.value = true
  error.value = null

  try {
    const response = await fetch('/api/users')
    users.value = await response.json()
  } catch (e) {
    error.value = e as Error
  } finally {
    loading.value = false
  }
}

function handleEdit(user: User): void {
  emit('edit', user)
}

function handleDelete(id: number): void {
  emit('delete', id)
}

onMounted(() => {
  fetchUsers()
})
</script>

TypeScript配置

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "strict": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["node_modules"]
}

练习

  1. 将一个JavaScript组件改写为TypeScript
  2. 创建一个类型安全的表单组件,包含验证
  3. 定义API响应类型,创建类型安全的数据获取函数
  4. 创建一个TypeScript的Pinia store,包含完整的类型定义