前言
在 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,配合类型守卫 - 提取共用类型:将重复使用的类型提取为接口
- 测试类型定义:使用
tsd或dtslint测试类型定义的正确性
结语
当我们的组合式函数拥有了完善的 TypeScript 支持,它们就不再是普通的函数,而是拥有“钢筋铁骨”的可靠组件。这不仅提升了开发体验,更重要的是让整个应用的质量有了根本性的保障。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!