人人都会AI编程

18.2 响应式数据、Hooks 的类型标注

更新时间:2026-07-11

Vue 3 的组合式 API 与 TypeScript 深度集成,让类型推导和显式标注都变得非常自然。这一节聚焦于日常开发中最常用的两个场景:如何给响应式数据标注类型,以及 如何为自定义 Hooks 编写准确的类型签名


响应式数据的类型标注

1. ref:自动推导 + 必要时显式泛型

ref 会根据初始值自动推导类型,大多数情况下你不需要手动标注:

import { ref } from 'vue'

const count = ref(0)          // Ref<number>
const message = ref('hello')  // Ref<string>
const user = ref({ name: 'Alice', age: 25 }) // Ref<{ name: string; age: number }>

当初始值无法提供完整类型信息时(例如初始值为 null,后续会赋值),使用显式泛型:

const user = ref<User | null>(null) // Ref<User | null>
// 后续赋值
user.value = { name: 'Bob', age: 30, role: 'admin' }

如果某个 ref 存储的是基础类型,不想被自动推导为更窄的字面量类型,也可以显式标注:

const status = ref<'idle' | 'loading' | 'error'>('idle')
// 这样 status.value 就是联合类型,可以被赋值为 'loading' 或 'error'

注意ref 的类型是 Ref<T>,访问 .value 得到 T。在模板中会自动解包,但在 TypeScript 代码逻辑中必须使用 .value

2. reactive:推荐直接为初始化对象定义接口

reactive 同样会根据传入的对象推导类型,但为了更好的可读性与代码提示,强烈建议将被 reactive 包裹的对象显式定义类型

import { reactive } from 'vue'

interface FormState {
  username: string
  password: string
  remember: boolean
}

const form = reactive<FormState>({
  username: '',
  password: '',
  remember: false
})
// 得到的类型是 FormState,直接访问 form.username 即可,不需要 .value

对于复杂嵌套对象,同样可以定义完整接口或使用类型别名:

interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

interface ShoppingCart {
  items: CartItem[]
  total: number
  loading: boolean
}

const cart = reactive<ShoppingCart>({
  items: [],
  total: 0,
  loading: false
})

注意reactive 适用于对象/数组等引用类型,不适用于基础类型(基础类型必须用 ref)。这本身就是 TypeScript 类型系统的一种约束体现。

3. computed:返回值类型自动推导

计算属性的类型由其回调函数的返回值自动推导:

import { ref, computed } from 'vue'

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

const user = ref({ firstName: 'John', lastName: 'Doe' })
const fullName = computed(() => `${user.value.firstName} ${user.value.lastName}`) // ComputedRef<string>

如果需要限制计算属性返回特定类型,也可以显式泛型:

const isValid = computed<boolean>(() => {
  return form.username.length > 3 && form.password.length >= 6
})

自定义 Hooks 的类型标注

自定义 Hooks(也叫组合式函数)本质上是返回一组响应式数据或方法的普通 TypeScript 函数。为了在调用处获得良好的类型提示,必须为参数和返回值显式标注类型。

1. 函数签名设计原则
  • 参数:使用明确的接口或类型别名,不要依赖隐式推导。
  • 返回值:返回一个对象,标注完整的属性类型。如果需要导出多个方法且包含 ref,可以适当解构。
import { ref, computed } from 'vue'

// 定义参数类型
interface UsePaginationOptions {
  pageSize?: number
  total?: number
}

// 定义返回值类型
interface UsePaginationReturn {
  currentPage: Ref<number>
  pageSize: Ref<number>
  totalPages: ComputedRef<number>
  nextPage: () => void
  prevPage: () => void
}

export function usePagination(options: UsePaginationOptions = {}): UsePaginationReturn {
  const currentPage = ref(1)
  const pageSize = ref(options.pageSize ?? 10)
  const total = ref(options.total ?? 0)

  const totalPages = computed(() => Math.ceil(total.value / pageSize.value))

  function nextPage() {
    if (currentPage.value < totalPages.value) currentPage.value++
  }

  function prevPage() {
    if (currentPage.value > 1) currentPage.value--
  }

  return {
    currentPage,
    pageSize,
    totalPages,
    nextPage,
    prevPage
  }
}

使用时,TypeScript 会自动推断出 currentPageRef<number>,调用 nextPage 也有完整的函数签名。

2. 避免返回 ref 的包装类型丢失

当从一个 Hook 中返回多个 ref 时,不要用对象包裹的方式手动声明返回值类型为 Record<string, Ref<any>>,而应该精确声明每个字段的类型,让调用方直接拿到 Ref<T>,而不是一个泛化后的联合类型。

3. 处理可选参数和默认值

使用 TypeScript 函数参数默认值和可选标记:

interface UseFetchOptions {
  immediate?: boolean
  headers?: Record<string, string>
}

export function useFetch<T>(url: string, options: UseFetchOptions = {}) {
  const { immediate = true, headers = {} } = options
  const data = ref<T | null>(null)
  // ...
}
4. 泛型 Hooks 的标注

当 Hook 需要处理不同数据类型时,使用泛型:

export function useStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)

  watch(data, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  })

  return data
}

// 使用
const theme = useStorage<'light' | 'dark'>('theme', 'light')

这里的 useStorage 会根据传入的 defaultValue 推导出 T,同时对返回值 ref 的类型做了约束。

5. 在 <script setup> 中使用组合式 Hooks

由于 <script setup> 中的顶层导入会被自动暴露给模板,如果 Hook 返回的是一个对象,需要确保其类型定义清晰,这样模板中使用时才不会丢失提示。对于返回多个独立 ref 的 Hook,可以在 <script setup> 中直接解构,TypeScript 仍然能保持正确类型:

// hooks/useMouse.ts
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  // ...
  return { x, y }
}

// 组件中
<script setup lang="ts">
import { useMouse } from './hooks/useMouse'
const { x, y } = useMouse()
// x: Ref<number>, y: Ref<number>
</script>

常见问题与最佳实践

  • 不要用 any 偷懒:一旦在 Hook 返回值中使用 any,整个类型推导就废了。如果实在无法预先确定类型,优先使用 unknown 并配合类型守卫。
  • reactive 对象使用 as 进行类型断言时需谨慎:这会绕过类型检查,尽量使用显式接口。
  • 利用 UnwrapRef 工具类型:Vue 提供了 UnwrapRef<T> 用于获取 ref 解包后的类型,可用于一些高级泛型推导场景。

响应式数据和 Hooks 的类型标注并不复杂,只要养成“每次定义 Hook 都写明参数和返回值接口”的习惯,就能让 Vue + TypeScript 的协作如鱼得水。