TypeScript 深度加持:让你的组合式函数拥有“钢筋铁骨”

19 阅读7分钟

前言

在 JavaScript 的世界里,自由往往伴随着风险。当你写下一个函数,一个月后回来修改时,你还记得它接受什么参数、返回什么值吗?当团队成员接手你的代码时,他们需要花多少时间去理解函数的使用方式?

TypeScript 的出现改变了这一切。特别是当它与 Vue3 的组合式函数相结合时,TypeScript 不再是可选项,而是构建可靠、可维护应用的必备工具。本文将深入探讨如何为组合式函数添加 TypeScript 支持,让它们从“手工作坊”升级为“工业标准”。

TypeScript 为什么要深度集成?

开发时智能提示:再也不用翻文档

没有 TypeScript 的组合式函数,就像一本没有目录的书:

// 纯 JavaScript 版本
export function useUser() {
  // 这个函数返回什么?怎么用?
  // 只能去看源码或者猜
}

// 使用时
const user = useUser()
// user 里有什么?不知道

有了 TypeScript,一切变得清晰明了:

// TypeScript 版本
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface UseUserReturn {
  user: Ref<User | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  fetchUser: (id: number) => Promise<void>
  updateUser: (data: Partial<User>) => Promise<void>
}

export function useUser(): UseUserReturn {
  // 实现...
}

// 使用时,编辑器会提供完美的智能提示
const { user, loading, fetchUser } = useUser()
// 鼠标悬停在 fetchUser 上,就能看到参数类型
fetchUser(123) // ✅ 正确
fetchUser('abc') // ❌ TypeScript 报错:类型错误

重构时的信心保证:改一处,TypeScript 帮你检查所有使用处

这是 TypeScript 最强大的特性之一。当我们需要修改一个组合式函数的返回类型时,TypeScript 会帮我们找到所有受影响的地方:

// 假设有一个 usePagination 组合式函数
function usePagination(initialPage = 1) {
  const page = ref(initialPage)
  const pageSize = ref(10)
  const total = ref(0)
  
  return { page, pageSize, total }
}

// 现在需要重构,将返回值改为响应式对象
function usePagination(initialPage = 1) {
  const state = reactive({
    page: initialPage,
    pageSize: 10,
    total: 0
  })
  
  return { state } // 返回方式改变了
}

// TypeScript 会立即标记所有使用了 page.value 的地方
const { state } = usePagination()
// ❌ 错误:page 不存在于返回值中
// 必须改为 state.page

这种“编译时检查”的特性,让我们在进行大规模重构时,不用担心遗漏任何使用之处。

运行时错误左移:在编译阶段发现潜在 bug

JavaScript 的错误往往在运行时才暴露,而 TypeScript 能在代码运行前就发现问题:

// ❌ JavaScript:运行时才报错
function processUser(user) {
  return user.name.toUpperCase() // 如果 user 是 null,这里会崩溃
}

// ✅ TypeScript:编译时就能发现问题
function processUser(user: User | null) {
  // ❌ 编译错误:对象可能为 null
  return user.name.toUpperCase() 
  
  // ✅ 正确处理
  return user?.name.toUpperCase() ?? ''
}

常见的 TypeScript 错误

错误1:拼写错误

const user = useUser()
user.nmae // ❌ 编译错误:属性 'nmae' 不存在于类型 'User'

错误2:类型不匹配

function updateProduct(id: number) { /* ... */ }
updateProduct('abc') // ❌ 编译错误:不能将 string 赋值给 number

错误3:忘记处理 undefined

const products = ref<Product[]>([])
const firstProduct = products.value[0]
console.log(firstProduct.price) // ❌ 编译错误:对象可能为 undefined

错误4:错误的参数个数

function fetchData(id: number, options?: FetchOptions) { /* ... */ }
fetchData(123, { cache: true }, 'extra') // ❌ 编译错误:参数过多

组合式函数的基础类型定义

为 ref 和 reactive 定义类型

Vue3 的响应式 API 与 TypeScript 配合得天衣无缝:

import { ref, reactive, computed } from 'vue'

// ref 的类型推导
const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

// 显式定义 ref 类型
const user = ref<User | null>(null) // Ref<User | null>

// 数组类型
const items = ref<Item[]>([]) // Ref<Item[]>

// reactive 的类型推导
const state = reactive({
  count: 0,
  name: '张三'
}) // { count: number; name: string }

// 显式定义 reactive 类型
interface FormState {
  username: string
  password: string
  remember: boolean
}

const form = reactive<FormState>({
  username: '',
  password: '',
  remember: false
})

// computed 的类型
const double = computed(() => count.value * 2) // ComputedRef<number>

注:基础数据类型,TypeScript 可以自行推导,因此不建议显示定义基础数据类型: const count = ref<number>(0) // ❌ 不建议这样写 const count = ref(0) // ✅

为函数的参数和返回值定义接口

这是组合式函数类型定义的核心,一个好的类型定义应该清晰地表达:

  • 函数接受什么参数
  • 函数返回什么
  • 各种边界情况
interface UseCounterOptions {
  initialValue?: number
  min?: number
  max?: number
  step?: number
}

interface UseCounterReturn {
  count: Ref<number>
  increment: (step?: number) => void
  decrement: (step?: number) => void
  reset: () => void
  set: (value: number) => void
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { 
    initialValue = 0, 
    min = -Infinity, 
    max = Infinity, 
    step = 1 
  } = options
  
  const count = ref(clamp(initialValue, min, max))
  
  function increment(stepSize = step) {
    const newValue = count.value + stepSize
    if (newValue <= max) {
      count.value = newValue
    }
  }
  
  // 其他方法...
  
  return {
    count,
    increment,
    decrement,
    reset: () => { count.value = clamp(initialValue, min, max) },
    set: (value) => { count.value = clamp(value, min, max) }
  }
}

实战:为 useMousePosition 定义完善的类型

我们来看一个完整的实战案例,展示如何为真实的组合式函数添加类型:

// composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'

// 1. 定义位置接口
export interface MousePosition {
  x: number
  y: number
  timestamp: number
}

// 2. 定义配置选项
export interface UseMousePositionOptions {
  /**
   * 节流时间(毫秒),默认 0 表示不节流
   */
  throttle?: number
  
  /**
   * 监听的目标元素,默认 window
   */
  target?: HTMLElement | null | (() => HTMLElement | null)
  
  /**
   * 是否立即开始监听,默认 true
   */
  immediate?: boolean
  
  /**
   * 坐标类型,默认 'client'
   */
  type?: 'client' | 'page' | 'screen'
}

// 3. 定义返回值类型
export interface UseMousePositionReturn {
  /**
   * 当前鼠标位置
   */
  position: Ref<MousePosition>
  
  /**
   * 是否正在监听
   */
  isListening: Ref<boolean>
  
  /**
   * 开始监听
   */
  start: () => void
  
  /**
   * 停止监听
   */
  stop: () => void
  
  /**
   * 重置位置为 (0, 0)
   */
  reset: () => void
}

// 4. 工具函数:获取坐标
function getMousePosition(event: MouseEvent, type: 'client' | 'page' | 'screen'): MousePosition {
  const timestamp = Date.now()
  
  switch (type) {
    case 'client':
      return { x: event.clientX, y: event.clientY, timestamp }
    case 'page':
      return { x: event.pageX, y: event.pageY, timestamp }
    case 'screen':
      return { x: event.screenX, y: event.screenY, timestamp }
  }
}

// 5. 主函数实现
export function useMousePosition(options: UseMousePositionOptions = {}): UseMousePositionReturn {
  const {
    throttle = 0,
    target = window,
    immediate = true,
    type = 'client'
  } = options

  // 创建响应式状态
  const position = ref<MousePosition>({ x: 0, y: 0, timestamp: 0 })
  const isListening = ref(false)
  
  // 获取目标元素
  const getTarget = (): EventTarget | null => {
    if (typeof target === 'function') {
      return target()
    }
    return target
  }
  
  // 节流控制
  let lastRun = 0
  let rafId: number | null = null
  
  // 鼠标移动处理函数
  const handleMouseMove = (event: MouseEvent) => {
    const now = Date.now()
    
    // 节流处理
    if (throttle > 0 && now - lastRun < throttle) {
      return
    }
    
    // 使用 requestAnimationFrame 优化性能
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
    }
    
    rafId = requestAnimationFrame(() => {
      position.value = getMousePosition(event, type)
      lastRun = now
      rafId = null
    })
  }
  
  // 开始监听
  const start = () => {
    if (isListening.value) return
    
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.addEventListener('mousemove', handleMouseMove)
      isListening.value = true
    }
  }
  
  // 停止监听
  const stop = () => {
    const targetEl = getTarget()
    if (targetEl) {
      targetEl.removeEventListener('mousemove', handleMouseMove)
    }
    
    if (rafId !== null) {
      cancelAnimationFrame(rafId)
      rafId = null
    }
    
    isListening.value = false
  }
  
  // 重置位置
  const reset = () => {
    position.value = { x: 0, y: 0, timestamp: 0 }
  }
  
  // 自动开始监听
  if (immediate) {
    onMounted(() => {
      start()
    })
  }
  
  // 清理
  onUnmounted(() => {
    stop()
  })
  
  return {
    position,
    isListening,
    start,
    stop,
    reset
  }
}

泛型约束:让复用更灵活

场景:实现一个通用的 useLocalStorage

没有泛型之前,我们可能会写出这样的代码:

// ❌ 不够通用,只能处理 string
function useLocalStorage(key: string, initialValue: string) {
  const value = ref(initialValue)
  
  onMounted(() => {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = stored
    }
  })
  
  watch(value, (newValue) => {
    localStorage.setItem(key, newValue)
  })
  
  return value
}

// 想存储数字?不行
const count = useLocalStorage('count', 0) // 类型错误!

解决方案:使用泛型约束

// ✅ 使用泛型,支持任意可序列化的类型
function useLocalStorage<T>(key: string, initialValue: T) {
  // 指定 ref 的类型为 T
  const value = ref<T>(initialValue) as Ref<T>
  
  onMounted(() => {
    try {
      const stored = localStorage.getItem(key)
      if (stored !== null) {
        // 反序列化,并确保类型正确
        value.value = JSON.parse(stored) as T
      }
    } catch (e) {
      console.error(`Failed to parse localStorage key "${key}":`, e)
    }
  })
  
  watch(value, (newValue) => {
    try {
      localStorage.setItem(key, JSON.stringify(newValue))
    } catch (e) {
      console.error(`Failed to stringify value for key "${key}":`, e)
    }
  }, { deep: true })
  
  return value
}

// 现在可以存储任意类型
const count = useLocalStorage('count', 0) // Ref<number>
const user = useLocalStorage('user', { name: '张三' }) // Ref<{ name: string }>
const items = useLocalStorage('items', [1, 2, 3]) // Ref<number[]>

进阶:添加类型约束和默认值处理

// 定义可序列化类型的约束
type Serializable = 
  | string 
  | number 
  | boolean 
  | null 
  | undefined
  | Serializable[]
  | { [key: string]: Serializable }

// 扩展选项
interface UseStorageOptions<T> {
  /**
   * 存储类型,默认 localStorage
   */
  storage?: 'local' | 'session'
  
  /**
   * 序列化函数
   */
  serializer?: {
    read: (raw: string) => T
    write: (value: T) => string
  }
  
  /**
   * 监听深度
   */
  deep?: boolean
  
  /**
   * 错误处理
   */
  onError?: (error: Error) => void
}

// 增强版的 useStorage
export function useStorage<T extends Serializable>(
  key: string,
  initialValue: T,
  options: UseStorageOptions<T> = {}
): Ref<T> {
  const {
    storage = 'local',
    deep = true,
    onError = (e) => console.error(`Storage error: ${e}`)
  } = options
  
  // 默认使用 JSON 序列化
  const serializer = options.serializer ?? {
    read: (raw: string) => JSON.parse(raw) as T,
    write: (value: T) => JSON.stringify(value)
  }
  
  const storageObj = storage === 'local' ? localStorage : sessionStorage
  const value = ref<T>(initialValue) as Ref<T>
  
  // 读取存储的值
  try {
    const raw = storageObj.getItem(key)
    if (raw !== null) {
      value.value = serializer.read(raw)
    } else {
      // 初始化存储
      storageObj.setItem(key, serializer.write(initialValue))
    }
  } catch (e) {
    onError(e as Error)
  }
  
  // 监听变化
  watch(value, (newValue) => {
    try {
      storageObj.setItem(key, serializer.write(newValue))
    } catch (e) {
      onError(e as Error)
    }
  }, { deep })
  
  return value
}

// 使用示例
const settings = useStorage('settings', {
  theme: 'dark',
  fontSize: 14,
  notifications: true
})

// 类型安全
settings.value.theme = 'light' // ✅
settings.value.theme = 123 // ❌ 类型错误

// 自定义序列化
const dates = useStorage('dates', [new Date()], {
  serializer: {
    read: (raw) => JSON.parse(raw).map((d: string) => new Date(d)),
    write: (value) => JSON.stringify(value.map(d => d.toISOString()))
  }
})

实战:useAsyncData 的泛型设计

// 定义异步操作的状态
interface AsyncState<T> {
  data: T | null
  loading: boolean
  error: Error | null
}

// 定义返回值类型
interface UseAsyncDataReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  execute: (...args: any[]) => Promise<T>
  refresh: () => Promise<T>
}

// 带泛型的异步数据获取组合式函数
export function useAsyncData<T>(
  fetcher: (...args: any[]) => Promise<T>,
  options: {
    immediate?: boolean
    initialData?: T | null
    onSuccess?: (data: T) => void
    onError?: (error: Error) => void
  } = {}
): UseAsyncDataReturn<T> {
  const data = ref<T | null>(options.initialData ?? null)
  const loading = ref(false)
  const error = ref<Error | null>(null)
  
  const execute = async (...args: any[]): Promise<T> => {
    loading.value = true
    error.value = null
    
    try {
      const result = await fetcher(...args)
      data.value = result
      options.onSuccess?.(result)
      return result
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      error.value = err
      options.onError?.(err)
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const refresh = () => execute()
  
  if (options.immediate !== false) {
    execute()
  }
  
  return {
    data,
    loading,
    error,
    execute,
    refresh
  }
}

// 使用示例
interface User {
  id: number
  name: string
  email: string
}

const { data, loading, error } = useAsyncData<User>(
  () => fetch('/api/user').then(r => r.json())
)

// TypeScript 知道 data 是 User | null
if (data.value) {
  console.log(data.value.name) // ✅ 类型安全
}

类型推导的艺术:何时自动推导,何时显式注解?

自动推导的场景

TypeScript 的类型推导非常智能,很多情况下不需要显式注解:

简单值可以自动推导

const count = ref(0) // Ref<number>
const name = ref('') // Ref<string>
const isActive = ref(false) // Ref<boolean>

对象字面量可以推导

const user = ref({
  name: '张三',
  age: 25
}) // Ref<{ name: string; age: number }>

函数返回值可以推导

function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment } // { count: Ref<number>; increment: () => void }
}

computed 可以推导

const double = computed(() => count.value * 2) // ComputedRef<number>

需要显式注解的场景

有些场景必须显式注解,否则类型会不正确:

空数组无法推导元素类型

const items = ref<Item[]>([]) 

null 初始值无法推导

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

复杂嵌套对象,类型太长

interface AppState {
  user: { name: string; age: number }
  settings: { theme: string }
}
const state = reactive<AppState>({ ... })

导出给外部使用的 API

export function useFeature(): FeatureReturn {
  // 明确告诉使用者返回什么
  return { ... }
}

类型推导原则

原则1:内部实现多用推导,外部接口显式注解

function useInternal() {
  // 内部实现,让 TypeScript 自己推导
  const count = ref(0)
  const double = computed(() => count.value * 2)
  return { count, double }
}

export function usePublic(): PublicAPI {
  // 导出的 API 显式注解
  const { count, double } = useInternal()
  return { count, double }
}

原则2:复杂类型提取为接口

interface User {
  name: string
  age: number
}

interface UpdateUserData {
  name?: string
  age?: number
}

function useUser() {
  const user = ref<User>({ name: '张三', age: 25 })
  const updateUser = (data: UpdateUserData) => {
    Object.assign(user.value, data)
  }
  return { user, updateUser }
}

原则3:使用 satisfies 确保类型正确(TS 4.9+)

const routes = {
  home: { path: '/', component: Home },
  about: { path: '/about', component: About }
} satisfies Record<string, Route>

原则4:使用 const 断言锁定字面量类型

const user = {
  name: '张三',
  role: 'admin'
} as const

高级技巧:类型守卫与类型收窄

使用自定义类型守卫处理异步数据的不同状态

在处理异步数据时,我们经常需要根据状态执行不同的逻辑:

// 定义三种状态类型
interface IdleState {
  status: 'idle'
}

interface LoadingState {
  status: 'loading'
}

interface SuccessState<T> {
  status: 'success'
  data: T
}

interface ErrorState {
  status: 'error'
  error: Error
}

// 联合类型
type AsyncState<T> = 
  | IdleState 
  | LoadingState 
  | SuccessState<T> 
  | ErrorState

// 组合式函数
function useAsyncState<T>(fetcher: () => Promise<T>) {
  const state = ref<AsyncState<T>>({ status: 'idle' })
  
  const execute = async () => {
    state.value = { status: 'loading' }
    
    try {
      const data = await fetcher()
      state.value = { status: 'success', data }
    } catch (e) {
      state.value = { 
        status: 'error', 
        error: e instanceof Error ? e : new Error(String(e))
      }
    }
  }
  
  return {
    state: readonly(state),
    execute
  }
}

// 类型守卫
function isIdle<T>(state: AsyncState<T>): state is IdleState {
  return state.status === 'idle'
}

function isLoading<T>(state: AsyncState<T>): state is LoadingState {
  return state.status === 'loading'
}

function isSuccess<T>(state: AsyncState<T>): state is SuccessState<T> {
  return state.status === 'success'
}

function isError<T>(state: AsyncState<T>): state is ErrorState {
  return state.status === 'error'
}

在组件中使用类型守卫

<template>
  <div>
    <div v-if="isLoading(state)">加载中...</div>
    
    <div v-else-if="isError(state)" class="error">
      错误: {{ state.error.message }}
      <button @click="retry">重试</button>
    </div>
    
    <div v-else-if="isSuccess(state)" class="data">
      <!-- 这里 state.data 的类型是 T -->
      <pre>{{ state.data }}</pre>
    </div>
    
    <div v-else-if="isIdle(state)">
      <button @click="execute">开始加载</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useAsyncState, isSuccess, isError, isLoading, isIdle } from './composables/useAsyncState'

interface UserData {
  id: number
  name: string
}

const { state, execute } = useAsyncState<UserData>(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

// 类型守卫让 TypeScript 能够收窄类型
watch(state, (newState) => {
  if (isSuccess(newState)) {
    // 这里 TypeScript 知道 newState 是 SuccessState<UserData>
    console.log('用户数据:', newState.data.name)
  } else if (isError(newState)) {
    // 这里知道是 ErrorState
    console.error('错误:', newState.error.message)
  }
})

function retry() {
  if (isError(state.value)) {
    // 只有在错误状态下才能看到错误详情
    console.log('重试,之前的错误:', state.value.error)
    execute()
  }
}
</script>

使用判别式联合类型实现状态机

// 更复杂的异步操作状态机
interface PendingState {
  status: 'pending'
}

interface LoadingState {
  status: 'loading'
  progress?: number
}

interface SuccessState<T> {
  status: 'success'
  data: T
  timestamp: number
}

interface ErrorState {
  status: 'error'
  error: Error
  retryCount: number
}

interface CancelledState {
  status: 'cancelled'
  reason?: string
}

type RequestState<T> = 
  | PendingState
  | LoadingState
  | SuccessState<T>
  | ErrorState
  | CancelledState

// 类型守卫函数可以自动生成
const guards = {
  isPending: <T>(s: RequestState<T>): s is PendingState => s.status === 'pending',
  isLoading: <T>(s: RequestState<T>): s is LoadingState => s.status === 'loading',
  isSuccess: <T>(s: RequestState<T>): s is SuccessState<T> => s.status === 'success',
  isError: <T>(s: RequestState<T>): s is ErrorState => s.status === 'error',
  isCancelled: <T>(s: RequestState<T>): s is CancelledState => s.status === 'cancelled'
}

// 使用示例
function handleRequestState<T>(state: RequestState<T>) {
  if (guards.isSuccess(state)) {
    // 这里 state.data 可用
    console.log(`数据获取成功,时间戳: ${state.timestamp}`)
  } else if (guards.isError(state)) {
    // 这里可以访问 state.retryCount
    console.log(`错误,已重试 ${state.retryCount} 次`)
  } else if (guards.isCancelled(state)) {
    // 这里可以访问 state.reason
    console.log(`已取消: ${state.reason}`)
  }
}

TypeScript 配置的最佳实践

项目配置建议

// tsconfig.json
{
  "compilerOptions": {
    // 严格模式必须开启
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    
    // Vue 3 推荐配置
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    
    // 路径别名
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@composables/*": ["src/composables/*"]
    }
  },
  
  // 包含的文件
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.vue"
  ],
  
  // 排除的文件
  "exclude": [
    "node_modules",
    "dist"
  ]
}

VSCode 配置建议

// .vscode/settings.json
{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.preferences.autoImportFileExcludePatterns": [
    "vue-router",
    "pinia"
  ],
  "typescript.suggest.autoImports": true,
  "typescript.suggest.completeFunctionCalls": true,
  
  // 保存时自动修复
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  
  // 启用 Vue 语言服务
  "volar.autoCompleteRefs": true,
  "volar.completion.preferredTagNameCase": "kebab",
  "volar.completion.preferredAttrNameCase": "kebab"
}

组合式函数 TypeScript 最佳实践清单

  • 为所有导出函数定义接口:导出的 API 必须有清晰的类型定义
  • 使用泛型增加复用性:对于需要处理多种类型的函数,使用泛型约束
  • 提供完整的 JSDoc 注释:为参数和返回值添加说明
  • 使用 readonly 保护内部状态:对于不应该被修改的 ref,使用 readonly 包装
  • 类型守卫处理联合类型:使用自定义类型守卫收窄类型范围
  • 避免 any 类型:使用 unknown 替代 any,配合类型守卫
  • 提取共用类型:将重复使用的类型提取为接口
  • 测试类型定义:使用 tsddtslint 测试类型定义的正确性

结语

当我们的组合式函数拥有了完善的 TypeScript 支持,它们就不再是普通的函数,而是拥有“钢筋铁骨”的可靠组件。这不仅提升了开发体验,更重要的是让整个应用的质量有了根本性的保障。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!