TypeScript 类型安全的 Vue 3 组合式函数

HTMLPAGE 团队
18 分钟阅读

Vue 3 的组合式 API(Composition API)天然适合 TypeScript,但很多项目在组合式函数中丢失了类型信息。本文从 ref 推导、props 类型、composable 泛型到 provide/inject 的类型链路,系统讲解 Vue 3 中的类型安全模式。

#TypeScript #Vue 3 #组合式函数 #类型安全 #Provide/Inject

Vue 3 的组合式 API 在设计上考虑了 TypeScript 的类型推导,和 Options API 相比,组合式函数在类型推断上有天然优势——你不需要在 this 上声明类型,直接写 ref() 就能得到完整的类型链。

但实际项目中还是能看到类型信息断裂的情况:ref<any[]> 取代了精确的类型,defineProps 用了运行时声明而不是类型声明,provide/inject 的注入值变成了隐式的 unknown

ref 的类型推导

Vue 3 的 ref() 会根据初始值推导类型:

import { ref, computed } from 'vue'

const count = ref(0)
// Ref<number>

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

const list = ref([])
// Ref<never[]> —— 空数组推导为 never[]

第三个例子常见的问题是:稍后往 list 里 push 元素时会提示类型不匹配:

const list = ref([])
// list 的类型是 Ref<never[]>
// 给 list.value 赋值会出问题

// 正确做法:显式泛型
const list = ref<string[]>([])
list.value.push('hello') // ✅

// 或者用初始值
const list = ref(['hello'])
// Ref<string[]>

Ref<T> 类型不是普通的 { value: T }。它是一个特殊的泛型接口,Vue 在类型层面区分了响应式对象和普通对象。在模板或 watch 中,ref 会自动解包,但在重新赋值时需要注意:

const state = ref({
  items: [] as string[],
  count: 0,
})

// 正确的更新方式
state.value = {
  items: ['a', 'b'],
  count: 2,
}

// 单个属性更新
state.value.items.push('c')

defineProps 的类型声明

Vue 3.3+ 支持 defineProps 的纯类型声明语法,这是 Vue 3 中类型安全最大的改进之一:

// ❌ 运行时声明(类型推导弱)
const props = defineProps({
  name: { type: String, required: true },
  age: { type: Number, default: 0 },
  tags: { type: Array as () => string[], default: () => [] },
})

// ✅ 类型声明(推荐)
const props = defineProps<{
  name: string
  age?: number
  tags?: string[]
}>()

// ✅ 联合类型 + 泛型辅助
interface TableProps<T> {
  data: T[]
  columns: ColumnDef<T>[]
  loading?: boolean
  pagination?: { page: number; pageSize: number }
}

const props = defineProps<TableProps<User>>()

类型声明的优势:

  1. 不需要写 PropType<T> 来推导复杂类型
  2. 联合类型、泛型、交叉类型都直接支持
  3. default 值需要在 withDefaults 中声明:
const props = withDefaults(defineProps<{
  name: string
  age?: number
  items?: string[]
}>(), {
  age: 18,
  items: () => [],
})

组合式函数的泛型

编写可复用的组合式函数时,泛型是保证调用方类型不丢失的关键:

// 简单组合式函数
function useToggle(initial = false) {
  const state = ref(initial)
  const toggle = () => { state.value = !state.value }
  return { state, toggle }
}

// 调用方得到 { state: Ref<boolean>; toggle: () => void }

更复杂的场景——异步数据获取的组合式函数:

interface UseAsyncResult<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  loading: Ref<boolean>
  execute: () => Promise<void>
}

function useAsync<T>(fetcher: () => Promise<T>): UseAsyncResult<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const loading = ref(false)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      data.value = await fetcher()
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Unknown error'
    } finally {
      loading.value = false
    }
  }

  return { data, error, loading, execute }
}

// 使用
const { data, loading } = useAsync(() => api.getUsers())
// data: Ref<User[] | null>
// loading: Ref<boolean>

这里泛型 T 由传入的 fetcher 函数返回值自动推导。如果不用泛型,要么写成 useAsync<User[]>() 显式传参,要么丢失 data 的类型。

带参数的组合式函数

function usePolling<T>(
  fetcher: () => Promise<T>,
  intervalMs: number
) {
  const data = ref<T | null>(null)
  const error = ref<string | null>(null)
  let timer: ReturnType<typeof setInterval> | null = null

  async function poll() {
    try {
      data.value = await fetcher()
      error.value = null
    } catch (e) {
      error.value = String(e)
    }
  }

  function start() {
    poll()
    timer = setInterval(poll, intervalMs)
  }

  function stop() {
    if (timer) { clearInterval(timer); timer = null }
  }

  onUnmounted(stop)

  return { data, error, start, stop }
}

provide/inject 的类型链路

Vue 3 中 provideinject 默认没有类型约束——inject('key') 返回 unknown。类型安全需要显式声明:

// ❌ 不安全
provide('theme', 'dark')
const theme = inject('theme') // unknown

// ✅ 使用 InjectionKey
import type { InjectionKey } from 'vue'

const ThemeKey: InjectionKey<string> = Symbol('theme')
provide(ThemeKey, 'dark')
const theme = inject(ThemeKey) // string | undefined

对于复杂类型:

interface AuthContext {
  user: Ref<User | null>
  login: (creds: Credentials) => Promise<void>
  logout: () => void
  isAuthenticated: ComputedRef<boolean>
}

const AuthKey: InjectionKey<AuthContext> = Symbol('auth')

// 在父组件中提供
const user = ref<User | null>(null)
const isAuthenticated = computed(() => user.value !== null)

provide(AuthKey, {
  user,
  login: async (creds) => { /* ... */ },
  logout: () => { user.value = null },
  isAuthenticated,
})

// 在子组件中注入
function useAuth() {
  const context = inject(AuthKey)
  if (!context) {
    throw new Error('useAuth() must be used inside a component that provides auth context')
  }
  return context
}

inject 的返回值是 T | undefined——因为组件可能在没有 provide 的情况下调用 inject。这就是为什么 useAuth() 里要检查 context 是否存在。

emits 和 slots 的类型安全

emits 的类型声明

// 运行时声明
const emit = defineEmits(['update', 'delete'])

// 类型声明
const emit = defineEmits<{
  (e: 'update', id: string, data: Partial<User>): void
  (e: 'delete', id: string): void
}>()

emit('update', '123', { name: 'Alice' }) // ✅ 类型检查
emit('delete', '123')                     // ✅
emit('update', 123)                       // ❌ —— id 应为 string

slots 的类型声明

// 3.3+ 支持
defineSlots<{
  default(props: { item: User; index: number }): any
  header(): any
  footer(): any
}>()

// 使用
<template>
  <slot name="default" :item="user" :index="0" />
</template>

类型安全的最佳实践

// 1. 组合式函数始终返回明确的接口类型
function useFeature(): { state: Ref<State>; actions: Actions } {
  return { state, actions }
}

// 2. 避免在组合式函数内部使用 any,用泛型传递类型
function useStorage<T>(key: string, initial: T): Ref<T> {
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : initial)
  watch(data, (val) => localStorage.setItem(key, JSON.stringify(val)))
  return data
}

// 3. props 优先用类型声明而非运行时声明
// 4. InjectionKey 是 provide/inject 类型安全的前提

总结

场景类型手段常见陷阱
ref 类型推导初始值推导或显式泛型空数组推导为 never
defineProps类型声明语法忘记 withDefaults 设置默认值
组合式函数泛型传递调用方类型内部用 any 阻断类型链
provide/injectInjectionKey忘记 null 检查
emits类型声明语法运行时声明不检查参数类型
slotsdefineSlots 类型3.3 以下版本不支持

Vue 3 的组合式 API 在类型安全上的核心原则是:类型信息应该从调用方流向被调用方。组合式函数的类型由入参推导,provide 的类型由 key 携带,emit 的类型由声明确定。任何打破这一流向的做法(如内部强制类型断言、宽泛的 Ref<any>)都会导致类型链断裂。