第9章 TypeScript集成

第27节 TypeScript进阶

9.27.1 泛型在Vue中的应用

泛型是TypeScript中的重要特性,它允许我们创建可重用的组件和函数,这些组件和函数可以处理多种类型的数据。在Vue中,泛型可以用于组件、组合式函数、Props等多个场景。

泛型组件

我们可以创建泛型组件,使其能够处理不同类型的数据:

<template>
  <div class="list-container">
    <h2>{{ title }}</h2>
    <ul>
      <li v-for="(item, index) in items" :key="index">
        <slot :item="item" :index="index"></slot>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts" generic="T">
interface Props<T> {
  title: string
  items: T[]
}

const props = defineProps<Props<T>>()
</script>

在父组件中使用泛型组件:

<template>
  <div>
    <!-- 字符串列表 -->
    <GenericList title="字符串列表" :items="stringItems">
      <template #default="{ item }">
        {{ item }}
      </template>
    </GenericList>

    <!-- 数字列表 -->
    <GenericList title="数字列表" :items="numberItems">
      <template #default="{ item }">
        {{ item }}
      </template>
    </GenericList>

    <!-- 用户列表 -->
    <GenericList title="用户列表" :items="userItems">
      <template #default="{ item }">
        {{ item.name }} - {{ item.email }}
      </template>
    </GenericList>
  </div>
</template>

<script setup lang="ts">
import GenericList from './GenericList.vue'

interface User {
  name: string
  email: string
}

const stringItems = ['item1', 'item2', 'item3']
const numberItems = [1, 2, 3, 4, 5]
const userItems: User[] = [
  { name: '张三', email: 'zhangsan@example.com' },
  { name: '李四', email: 'lisi@example.com' }
]
</script>

泛型Props

在定义Props时,我们可以使用泛型来增强类型安全性:

<template>
  <div>
    <h1>{{ title }}</h1>
    <div v-if="value" class="value">
      {{ value }}
    </div>
    <div v-else class="empty">
      无数据
    </div>
  </div>
</template>

<script setup lang="ts" generic="T">
interface Props<T> {
  title: string
  value: T | null
}

const props = defineProps<Props<T>>()
</script>

泛型组合式函数

泛型在组合式函数中也非常有用,可以创建更通用的逻辑:

// composables/usePagination.ts
import { ref, computed } from 'vue'

export interface PaginationOptions {
  pageSize?: number
}

export function usePagination<T>(items: T[], options: PaginationOptions = {}) {
  const { pageSize = 10 } = options
  
  const currentPage = ref(1)
  
  const totalPages = computed(() => {
    return Math.ceil(items.length / pageSize)
  })
  
  const paginatedItems = computed(() => {
    const start = (currentPage.value - 1) * pageSize
    const end = start + pageSize
    return items.slice(start, end)
  })
  
  const goToPage = (page: number) => {
    currentPage.value = Math.max(1, Math.min(page, totalPages.value))
  }
  
  const nextPage = () => {
    if (currentPage.value < totalPages.value) {
      currentPage.value++
    }
  }
  
  const prevPage = () => {
    if (currentPage.value > 1) {
      currentPage.value--
    }
  }
  
  return {
    currentPage,
    totalPages,
    paginatedItems,
    goToPage,
    nextPage,
    prevPage
  }
}

在组件中使用:

<template>
  <div>
    <h1>分页示例</h1>
    
    <ul>
      <li v-for="(item, index) in paginatedItems" :key="index">
        {{ item.name }} - {{ item.email }}
      </li>
    </ul>
    
    <div class="pagination">
      <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
      <span>{{ currentPage }} / {{ totalPages }}</span>
      <button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { usePagination } from '../composables/usePagination'

interface User {
  name: string
  email: string
}

const users = ref<User[]>([
  { name: '张三', email: 'zhangsan@example.com' },
  { name: '李四', email: 'lisi@example.com' },
  { name: '王五', email: 'wangwu@example.com' },
  // 更多用户...
])

const { currentPage, totalPages, paginatedItems, nextPage, prevPage } = usePagination(users.value, { pageSize: 2 })
</script>

9.27.2 类型工具使用

TypeScript提供了许多内置的类型工具,可以帮助我们创建和转换类型。以下是一些常用的类型工具:

Pick 和 Omit

  • Pick&lt;T, K&gt;:从类型T中选择指定的属性K,创建一个新类型
  • Omit&lt;T, K&gt;:从类型T中排除指定的属性K,创建一个新类型
interface User {
  id: number
  name: string
  email: string
  password: string
  createdAt: Date
}

// 只选择id、name和email属性
type UserPublic = Pick<User, 'id' | 'name' | 'email'>
// 等价于:
// interface UserPublic {
//   id: number;
//   name: string;
//   email: string;
// }

// 排除password属性
type UserWithoutPassword = Omit<User, 'password'>
// 等价于:
// interface UserWithoutPassword {
//   id: number;
//   name: string;
//   email: string;
//   createdAt: Date;
// }

在组件中使用:

<script setup lang="ts">
import type { User } from '../types'

type UserPublic = Pick<User, 'id' | 'name' | 'email'>

const fetchUser = async (id: number): Promise<UserPublic> => {
  const response = await fetch(`/api/users/${id}`)
  const user = await response.json()
  // 只返回公开信息
  return { id: user.id, name: user.name, email: user.email }
}
</script>

Extract 和 Exclude

  • Extract&lt;T, U&gt;:从类型T中提取可以赋值给类型U的类型
  • Exclude&lt;T, U&gt;:从类型T中排除可以赋值给类型U的类型
type EventType = 'click' | 'hover' | 'focus' | 'change' | 'submit'
type FormEventType = 'change' | 'submit' | 'reset'

// 提取同时属于EventType和FormEventType的类型
type CommonEvent = Extract<EventType, FormEventType> // 'change' | 'submit'

// 从EventType中排除FormEventType
type NonFormEvent = Exclude<EventType, FormEventType> // 'click' | 'hover' | 'focus'

在组件中使用:

<template>
  <div>
    <button @click="handleEvent('click')">点击</button>
    <input @change="handleEvent('change')" placeholder="输入内容" />
    <form @submit.prevent="handleEvent('submit')">
      <button type="submit">提交</button>
    </form>
  </div>
</template>

<script setup lang="ts">
type EventType = 'click' | 'hover' | 'focus' | 'change' | 'submit'
type FormEventType = 'change' | 'submit'
type NonFormEvent = Exclude<EventType, FormEventType> // 'click' | 'hover' | 'focus'

const handleEvent = (eventType: EventType) => {
  if (isFormEvent(eventType)) {
    console.log('表单事件:', eventType)
  } else {
    console.log('非表单事件:', eventType)
  }
}

// 类型守卫
function isFormEvent(event: EventType): event is FormEventType {
  return ['change', 'submit'].includes(event)
}
</script>

Partial 和 Required

  • Partial&lt;T&gt;:将类型T的所有属性变为可选
  • Required&lt;T&gt;:将类型T的所有属性变为必填
interface User {
  id: number
  name: string
  email: string
  age?: number
  isActive?: boolean
}

// 所有属性变为可选
type PartialUser = Partial<User>
// 等价于:
// interface PartialUser {
//   id?: number;
//   name?: string;
//   email?: string;
//   age?: number;
//   isActive?: boolean;
// }

// 所有属性变为必填
type RequiredUser = Required<User>
// 等价于:
// interface RequiredUser {
//   id: number;
//   name: string;
//   email: string;
//   age: number;
//   isActive: boolean;
// }

在组件中使用:

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
  age?: number
  isActive?: boolean
}

// 更新用户时,只需要提供部分属性
type UpdateUserDto = Partial<Omit<User, 'id'>>

const updateUser = async (id: number, data: UpdateUserDto) => {
  const response = await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  })
  return response.json()
}

// 使用示例
updateUser(1, { name: '新名称' }) // 只更新名称
updateUser(1, { email: 'new@email.com', isActive: true }) // 更新邮箱和激活状态
</script>

Record

  • Record&lt;K, T&gt;:创建一个对象类型,其属性键为K,属性值为T
// 创建一个以字符串为键,数字为值的对象类型
type ScoreRecord = Record<string, number>

// 使用示例
const scores: ScoreRecord = {
  '张三': 95,
  '李四': 88,
  '王五': 92
}

// 更复杂的Record类型
interface User {
  name: string
  email: string
}

type UserRecord = Record<number, User>

const users: UserRecord = {
  1: { name: '张三', email: 'zhangsan@example.com' },
  2: { name: '李四', email: 'lisi@example.com' }
}

9.27.3 第三方库类型声明

当使用第三方库时,我们可能需要为其添加类型声明。TypeScript提供了几种方式来处理这种情况:

使用@types包

许多流行的第三方库都有对应的@types包,我们可以直接安装:

# 安装axios的类型声明
npm install -D @types/axios

# 安装lodash的类型声明
npm install -D @types/lodash

自定义类型声明

对于没有@types包的库,我们可以创建自定义类型声明文件:

  1. 在项目根目录创建types文件夹
  2. 在该文件夹中创建类型声明文件,如my-library.d.ts
// types/my-library.d.ts

// 声明模块
declare module 'my-library' {
  // 声明函数
  export function myFunction(arg: string): string
  
  // 声明类
  export class MyClass {
    constructor(options: MyOptions)
    method(): void
  }
  
  // 声明接口
  export interface MyOptions {
    name: string
    value?: number
  }
}
  1. tsconfig.json中配置类型声明文件路径:
{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./types"]
  }
}

模块扩展

我们可以扩展现有模块的类型:

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

declare module 'vue-router' {
  interface RouteMeta {
    // 添加自定义元数据类型
    title?: string
    requiresAuth?: boolean
    roles?: string[]
  }
}

这样我们就可以在路由配置中使用自定义的元数据:

const routes = [
  {
    path: '/admin',
    component: () => import('../views/Admin.vue'),
    meta: {
      title: '后台管理',
      requiresAuth: true,
      roles: ['admin']
    }
  }
]

9.27.4 类型安全的路由与状态管理

类型安全的Vue Router

Vue Router 4提供了良好的TypeScript支持,我们可以为路由添加类型安全:

  1. 定义路由类型:
// router/types.ts
export interface RouteParams {
  // 定义所有路由参数类型
  id: string
  userId: string
  productId: string
}
  1. 配置路由:
// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../views/Home.vue')
  },
  {
    path: '/users',
    name: 'Users',
    component: () => import('../views/Users.vue')
  },
  {
    path: '/users/:id',
    name: 'UserDetail',
    component: () => import('../views/UserDetail.vue')
  },
  {
    path: '/products/:productId',
    name: 'ProductDetail',
    component: () => import('../views/ProductDetail.vue')
  }
]

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

export default router
  1. 在组件中使用类型安全的路由:
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'

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

// 类型安全的路由参数
const userId = route.params.id // string | undefined

// 类型安全的路由跳转
const goToProduct = (productId: string) => {
  router.push({
    name: 'ProductDetail',
    params: { productId } // 类型检查:必须提供productId
  })
}
</script>

类型安全的Pinia

Pinia天生支持TypeScript,我们可以为状态管理添加类型安全:

  1. 定义Store:
// stores/counter.ts
import { defineStore } from 'pinia'

interface CounterState {
  count: number
  name: string
  isActive: boolean
}

export const useCounterStore = defineStore('counter', {
  state: (): CounterState => ({
    count: 0,
    name: '计数器',
    isActive: true
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    isPositive: (state) => state.count > 0
  },
  
  actions: {
    increment(amount: number = 1) {
      this.count += amount
    },
    reset() {
      this.count = 0
      this.isActive = true
    }
  }
})
  1. 在组件中使用类型安全的Store:
<script setup lang="ts">
import { useCounterStore } from '../stores/counter'

const counterStore = useCounterStore()

// 类型安全的状态访问
const count = counterStore.count // number
const name = counterStore.name // string

// 类型安全的getters访问
const doubleCount = counterStore.doubleCount // number
const isPositive = counterStore.isPositive // boolean

// 类型安全的actions调用
const increment = () => {
  counterStore.increment(5) // 类型检查:参数必须是number
}

const reset = () => {
  counterStore.reset() // 类型检查:不需要参数
}
</script>

最佳实践与注意事项

  1. 使用类型别名和接口

    • 对于复杂类型,使用类型别名或接口可以提高代码的可读性
    • 接口可以被扩展,类型别名可以使用联合和交叉类型
  2. 合理使用泛型

    • 泛型可以提高代码的复用性,但不要过度使用
    • 为泛型添加约束,避免过于宽泛的类型
  3. 使用类型守卫

    • 类型守卫可以帮助TypeScript进行更准确的类型推断
    • 使用is关键字创建自定义类型守卫
  4. 为第三方库添加类型声明

    • 优先使用@types包
    • 对于没有@types包的库,创建自定义类型声明
  5. 利用TypeScript的自动类型推断

    • 对于简单类型,TypeScript可以自动推断,不需要显式注解
    • 对于复杂类型,显式注解可以提高代码的可读性和安全性
  6. 定期更新TypeScript版本

    • 新版本的TypeScript提供了更多的类型特性和更好的性能
    • 保持TypeScript版本与Vue和其他库兼容

小结

本节我们学习了TypeScript进阶内容,包括:

  • 泛型在Vue组件、Props和组合式函数中的应用
  • 常用类型工具:Pick、Omit、Extract、Exclude、Partial、Required、Record
  • 第三方库类型声明的添加方式
  • 类型安全的Vue Router和Pinia配置

通过掌握这些TypeScript进阶知识,我们可以创建更安全、更可维护的Vue应用。TypeScript的静态类型检查可以帮助我们在开发阶段发现潜在错误,提高代码质量和开发效率。

思考与练习

  1. 创建一个泛型组件,使其能够处理不同类型的数据列表。
  2. 使用类型工具(Pick、Omit、Partial等)创建不同的类型变体。
  3. 为一个第三方库添加自定义类型声明。
  4. 配置类型安全的Vue Router,包括路由参数和元数据。
  5. 创建一个类型安全的Pinia Store,并在组件中使用。
  6. 尝试使用泛型组合式函数处理不同类型的数据。
« 上一篇 25-typescript-composition-api 下一篇 » 27-element-plus