ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3组合式函数类型安全:泛型与interface实战指南

Vue3组合式函数类型安全:泛型与interface实战指南 写组合式函数写了三年多我最大的感受是在项目规模起来之后TypeScript不是锦上添花而是让代码能继续改下去的前提。尤其是Vue 3 / Nuxt 3生态里那种高度灵活、到处被复用的组合式函数如果只用JavaScript裸写几乎必然会在某个深夜因为一个参数传错类型、一个返回值被当成了别的类型而线上翻车。这篇文章我想聊的是如何给组合式函数加上一层“钢筋铁骨”式的类型保障——不是列一堆API文档而是分享我实际在项目里怎么设计泛型、怎么用interface继承、怎么处理可空值和联合类型以及怎么让类型在测试阶段就被固定住。适合正在写业务组件库、或者维护中型以上前端项目、被“这个函数到底返回什么”问烦了的同学参考。1. 没有类型约束时组合式函数最容易出错的三个位置1.1 参数位置API签名不清晰带来的隐式错误组合式函数本质上是一个带状态的函数它的参数往往不止是普通数据还可能是回调、配置项、甚至是另外一个响应式引用。在没有类型约束的情况下最典型的错误是调用方把id传成了number而函数内部期望的是string然后拿去拼URL结果API请求一直404控制台却不报错。这种错误在JavaScript里属于“逻辑错误”调试成本极高。我见过一个真实的例子团队里有人封装了一个useUserInfo(userId)的组合式函数最初设计时userId是字符串后来后端接口调整需要传数字ID调用方改了其中一个页面但另一个页面忘了改于是出现了“用户数据偶尔加载不出来”的诡异bug。排查了两个小时最后发现是类型不一致。如果一开始就用TypeScript把参数类型卡死编辑器里就会直接飘红根本不会带到测试环境。所以参数位置的第一条原则是每个入参都要显式标注类型绝不用any去“暂缓”设计。哪怕是一个简单的useTimer(duration: number)也要写清楚。更讲究一点对于回调参数尽量用小写开头的接口描述它的形状而不是直接写Function——否则调用方不知道回调到底该接收几个参数、返回什么。1.2 返回值位置解构出来的数据“身份不明”组合式函数的返回值通常是一个对象里面塞了状态、方法、计算属性。写了几年JavaScript的人都会有这种体验const { data, refresh } useTable()过了三个月再回来看已经想不起来data到底是数组还是分页对象refresh要不要传参数返回值会不会是一个Promise。这些信息在代码里完全缺失。类型缺失的第二个典型问题就是“解构出来的变量被当成了万能类型”。比如有人写const { list } useList()然后把list直接丢给一个需要string[]的组件结果运行时才发现list里有对象。这个问题在复杂调用链里特别隐蔽因为每个环节看起来都“没有错”但实际上类型已经悄悄发生了变化。TypeScript加持之后返回值会有一个明确的接口描述。在我们设计组合式函数时返回对象应该包含哪些字段、每个字段是什么类型、方法是否有参数和返回值全都一目了然。这样调用方在使用时不需要翻源码也能得到编辑器完整的提示和类型检查。1.3 状态联动位置多个响应式变量之间的约束丢失组合式函数的核心是状态。状态之间往往存在联动关系loading为true时data一定还是上一次的值error存在时data可能为null当前页、总页数、列表数据之间也有明确的同步关系。这些“状态之间的约束”是JavaScript最容易丢失的部分因为每个变量都是独立的你只能靠写注释来提醒自己。我记得有个usePagination函数内部维护了page、pageSize、total调用方需要确保page * pageSize不能超过total否则会出现空页。结果有次一个同事把pageSize从10改成20后忘了改page导致列表空白。这类问题本质上不是某个变量类型错了而是变量之间的关系没有被类型系统表达出来。TypeScript虽然不能完全表达运行时约束但它可以通过设计返回结构来减少这种错误的可能性。比如把分页参数合并成一个pagination对象整个对象作为响应式值传递而不是拆开成三个独立变量。再比如用readonly修饰只读属性让调用方知道哪些字段不该手动改。这些都是“钢筋铁骨”的一部分——不是把所有东西钉死而是用类型把容易出错的地方挡住。2. 用泛型给组合式函数装上“骨架”从useCounter到useList的逻辑推导2.1 泛型不是锦上添花它决定了函数的输入输出契约在很多同学的认知里泛型是TypeScript里比较“高级”的部分平时写业务好像用不到。但组合式函数恰恰是泛型最容易发挥价值的地方因为组合式函数通常是“对某类数据进行操作并返回与这类数据相关的状态”。没有泛型你就只能退而求其次用any、unknown或者写死某一种数据结构这会让函数失去复用性。举一个最直白的例子写一个useCounter如果它的初始值必须是一个数字那你可以这样定义function useCounter(initialValue: number) { const count ref(initialValue) const increment () { count.value } return { count, increment } }这个函数没有泛型也完全没问题因为number就是它的语义。但如果你想做一个useStorage它可以读一个string、也可以读一个对象甚至是布尔值那么返回值类型就必须跟随传入的泛型参数变function useStorageT(key: string, initialValue: T) { const data ref(initialValue) as RefT // ...读取、监听storage变化... return { data } }这种情况下T决定了data的类型。如果你调用useStorageUserInfo(user, defaultUser)那么data.value就自动是UserInfo不需要任何手动断言。使用泛型的核心目的就是让输入类型和输出类型之间建立一种“绑定”关系调用方传入什么你就能准确返回什么。2.2 从useCounter到useList看泛型推导如何消除any我们再来看一个更贴近实际业务的例子假设要封装一个通用列表状态管理的组合式函数useList它接收一个获取列表数据的异步函数并返回列表数据、加载状态和重新加载方法。如果不用泛型你可能会写function useList(fetcher: () Promiseany) { const list refany[]([]) const loading ref(false) const load async () { loading.value true list.value await fetcher() loading.value false } return { list, loading, load } }这个实现虽然“能用”但list是any[]你在任何地方拿它做map、filterTypeScript都不会帮你检查。万一接口返回的结构变化了运行时才会报错。用泛型改造后interface ListResultT { list: T[] total: number } function useListT(fetcher: () PromiseListResultT) { const list refT[]([]) as RefT[] const total ref(0) const loading ref(false) const load async () { loading.value true try { const result await fetcher() list.value result.list total.value result.total } finally { loading.value false } } return { list, total, loading, load } }现在调用方写useList((page) fetchUsers(page))TypeScript会尝试从fetchUsers的返回类型中推断T并且保证list.value是User[]。如果你错误地试图把list.value赋值给string[]类型的变量编辑器会立刻标记错误。这就是泛型的价值——它把“数据是从哪来的”和“数据长什么样”绑定在了一起。还有一点值得注意上面的fetcher接收了一个page参数但如果useList内部还没有管理分页那这个page从哪来这就涉及泛型在设计时的一个常见误区不要为了“通用”把所有参数都泛型化而是让泛型去描述数据的内容不要让它去描述控制流的细节。分页这种状态可以单独封装否则useList会变成一个什么都管的大杂烩类型推导也会变得困难。2.3 返回多个值时如何保证类型之间的关联组合式函数经常返回多个状态而多个状态之间如果由同一个泛型约束整个函数会安全很多。举个例子useAsyncData在Vue生态里很常见它返回data、error、pending。我们需要保证data的类型和fetcher返回的数据类型一致。type AsyncDataStateT { data: RefT | null pending: Refboolean error: RefError | null } function useAsyncDataT(fetcher: () PromiseT): AsyncDataStateT { const data refT | null(null) as RefT | null const pending ref(false) const error refError | null(null) const run async () { pending.value true error.value null try { data.value await fetcher() } catch (e) { error.value e instanceof Error ? e : new Error(String(e)) } finally { pending.value false } } return { data, pending, error, run } }这里的核心是data、pending、error三者放在同一个AsyncDataStateT里它们之间的关联不是随意的——当error不为null时data可能是上一次的成功值也可能是null。这种“可能为null”的语义必须通过T | null来表达否则调用方会以为data永远存在导致在使用时忽略空值判断。我在实际项目中还有一个经验如果组合式函数返回多个相关状态尽量把它们放进一个明确的接口类型里而不是在函数签名中散落一堆类型参数。比如AsyncDataStateT这种写法比起useAsyncDataT(): { data: RefT|null, pending: Refboolean, error: RefError|null }来说至少有三个好处函数签名更短返回结构可以被其他类型复用声明时更能让人一眼看出这几个状态是配套的。3. interface继承在组合式函数设计中的价值配置扩展与返回结构的约束3.1 配置项继承别把所有参数都塞进一个对象组合式函数最常见的参数模式是接收一个options对象。业务复杂之后options会有很多可选字段比如immediate、dedupe、timeout、onError等。如果每次都在函数的参数类型里写一遍完整的接口会导致两个问题一是函数签名太长二是当你需要扩展功能比如增加一个ssrKey字段支持服务端缓存时所有调用该组合式函数的测试、文档都要跟着改。这时候interface extends就非常有用。先定义一个基础配置接口再让具体的组合式函数配置继承它只扩展自己需要的新字段interface BaseAsyncOptions { immediate?: boolean timeout?: number onError?: (error: unknown) void } interface UseUserListOptions extends BaseAsyncOptions { pageSize: number role?: admin | user }这样useUserList(options: UseUserListOptions)的签名就非常清晰它继承了所有基础功能配置同时追加了分页和角色筛选字段。更棒的是如果之后BaseAsyncOptions里增加一个retry字段UseUserListOptions自动获得该字段不需要改动任何业务代码。这个继承机制比交叉类型在错误提示上更友好——当字段不匹配时编辑器会直接告诉你“缺少继承自BaseAsyncOptions的timeout属性”而不是给出一大段类型交叉后的复杂表达式。3.2 返回类型继承保证组合式函数的输出“底座”一致和配置项一样组合式函数的返回值也应该有可继承的结构。比如我们团队内部有一批和请求相关的组合式函数它们都返回data、error、loading但每个函数又会额外返回自己特有的状态。如果不做约束每个函数各写各的调用方要记很多差异而且很难抽象出公共逻辑。这时候可以定义一个基础返回接口interface AsyncStateReturnT { data: RefT | null error: RefError | null loading: Refboolean refresh: () Promisevoid } interface UseUserListReturn extends AsyncStateReturnUser[] { total: Refnumber page: Refnumber changePage: (page: number) void }所有请求类组合式函数都从AsyncStateReturn继承那么调用方只需要知道useUserList().refresh和useUserProfile().refresh的基础用法是一致的——它们都会重新发起请求返回Promise且在请求期间loading为true。同时每一层扩展出来的额外字段又保持了各自的独特性。我踩过的一个坑是把返回类型定义成type别名而不是interface。早期项目里我用type AsyncStateReturnT {...}后来在业务中想通过声明合并给它追加一个方法时发现type不支持声明合并必须去改定义处。换成语境就是如果这个返回结构未来可能被第三方或业务层扩展用interface是更宽容的如果它就是一个固定不变的数据结构type也能用但你在设计“可扩展的返回结构”时interface extends是更自然的表达。这一点在维护长期项目时感知特别明显。3.3 利用interface的声明合并约定返回结构的演进interface还有一个隐藏能力是声明合并。同一个名字的interface会在编译时自动合并。这意味着你可以给组合式函数的返回类型预留“补丁口”。举个例子useUserStore返回一个user但不同模块可能希望给user挂上不同的属性。这时候通过声明合并可以在不改动原有接口的情况下完成扩展interface CurrentUser { id: string name: string } // 在某个业务模块中希望user对象有avatar字段 interface CurrentUser { avatar?: string }不过在实际组合式函数设计中我不建议滥用声明合并。它适合跨团队维护公共类型时“轻量补充”的场景但如果类型字段经常变动合并会让人困惑。更推荐的做法是把稳定不变的字段放到基础接口里通过extends去逐层扩展这样每个组合式函数的类型意图更清晰。声明合并更适合在一个项目里为第三方库的类型做补充时使用。4. 进阶类型收窄与断言技巧让组合式函数在真实业务中不“误伤”4.1 可空值收窄data还没加载时到底该返回什么组合式函数里最容易出现“运行时才知道是空”的地方就是异步数据。data一开始是null加载成功后是值加载失败可能是null或上一次的旧值。如果你在类型上简单写成RefT调用方会以为data永远可用结果渲染模板时data.name直接抛错。最稳妥的做法是明确写出RefT | null然后在使用时通过if (data.value)收窄。但有些场景下你希望“加载过程中保持上一次的数据”这时data真的不是null而是T。怎么处理这种状态迁移我常用的办法是根据pending和error的状态让调用方决定如何处理data。类型上仍然保持T | null但在注释和返回结构里强调“只在error为null且pending为false时data才是非空的有效数据”。这里有一个类型层面的小技巧可以设计一个DataStateT的联合类型用判别字段区分不同状态type AsyncStateT | { status: idle; data: null; error: null } | { status: loading; data: T | null; error: null } | { status: success; data: T; error: null } | { status: error; data: T | null; error: Error }这样返回的status字段就能作为判别类型调用方通过switch或if检查status时TypeScript会自动收窄data和error的类型。比如当status success时data一定是T不需要再做空值判断。这个模式让组合式函数的状态流转变得极其安全代价是状态数量变多了但组合式函数本来就是管理状态的多一个状态字段换来的是调用方的省心和安全。4.2 字面量类型与as const配置模式给IDE更准确的提示组合式函数的第二个容易模糊的地方是配置里的模式字段。比如一个useSortableList可以支持insert、remove、move三种操作模式。如果参数类型写成mode: string调用方传什么都不会报错但这恰恰会让错误在运行时才暴露。用字面量联合类型约束type SortMode insert | remove | move function useSortableList(mode: SortMode) { // ... }这样调用方写useSortableList(delete)会立即标红并且编辑器会给出可选值的提示。字面量类型在组合式函数设计里还有一个常见场景配置options.deep是开启深度监听options.flush是回调时机。这些都应当用联合类型明确列出来。as const则适合用在常量配置对象上。有时候我不希望一个对象被推断成宽泛的string而是希望保留它的字面量属性值const DEFAULT_OPTIONS { retry: 3, mode: silent, } as const这样DEFAULT_OPTIONS.mode的类型就是silent而不是string如果某个函数要求mode只能取silent | verbose用as const定义的对象可以直接通过类型检查。这个技巧在维护配置常量、避免魔法字符串时非常有用。4.3 联合类型与判别字段处理多形态返回结果组合式函数有时需要支持多种形态的数据返回。比如一个useMediaQuery函数它可能返回一个boolean也可能返回一个媒体查询结果对象取决于配置里的value: boolean | detail。这种场景天然适合联合类型interface MediaQueryDetail { matches: boolean media: string } type UseMediaQueryValueT extends boolean | detail T extends detail ? MediaQueryDetail : boolean function useMediaQueryT extends boolean | detail( query: string, mode: T, ): RefUseMediaQueryValueT这个签名用条件类型实现了“传detail就返回详情对象、传boolean就返回布尔值”的类型推导。组合式函数中这类需求并不少关键是不要一上来就写条件类型先用简单的泛型加联合类型往往就能解决。条件类型适合在类型关系比较复杂时才引入否则过度抽象会降低可读性。5. 完整实战写一个类型安全的useRequest覆盖请求状态、数据和错误5.1 需求设计这个组合式函数要解决什么问题在做业务的时候每个页面几乎都会遇到“发请求、显示loading、处理错误、重新加载”这套逻辑。我决定封装一个useRequest让它做四件事接收一个返回Promise的业务函数自动管理loading、error和data支持手动触发和自动触发在组件卸载时避免继续更新状态。这个函数的核心挑战是请求函数返回的数据类型在编译期是不确定的必须由调用方传入的fetcher自动推导错误类型也可能是Error或其他unknown需要做合理的收窄同时一些请求参数是外部传入的函数需要监听它们的变化并重新触发请求。5.2 类型签名设计泛型、默认值、错误类型类型签名是所有实现的基础。我的思路是让useRequest接收一个fetcher和一个options其中fetcher的返回类型会被自动推导为泛型TData同时允许传入TParams描述参数类型。interface UseRequestOptionsTParams extends unknown[] { immediate?: boolean defaultParams?: TParams onSuccess?: (data: TData) void onError?: (error: unknown) void } function useRequestTData, TParams extends unknown[]( fetcher: (...args: TParams) PromiseTData, options?: UseRequestOptionsTParams, ): { data: RefTData | null loading: Refboolean error: RefError | null run: (...args: TParams) PromiseTData refresh: () PromiseTData }这里有几个设计决策TParams extends unknown[]表示参数列表是一个元组类型这样run可以保持参数个数和类型的安全。返回的data是RefTData | null因为请求还没完成时数据必然是空的。error固定为Error | null但实际捕获到的异常可能是unknown所以要在内部收窄。run返回PromiseTData这样调用方可以await请求结果而不必绕道data。实际使用中调用方会写const { data, loading, run } useRequest( (id: number) fetchUser(id), { immediate: true } )TypeScript会自动推导TData为Userrun接收一个number参数。如果你传了一个字符串例如run(abc)编译期就会报错。5.3 实现细节状态流转、类型守卫、防止内存泄漏类型签名设计好之后实现部分也有一些容易踩坑的地方。先看一个完整的实现简化版function useRequestTData, TParams extends unknown[]( fetcher: (...args: TParams) PromiseTData, options?: UseRequestOptionsTParams ) { const data refTData | null(null) as RefTData | null const loading ref(false) const error refError | null(null) let isUnmounted false let currentRequestId 0 const run async (...args: TParams): PromiseTData { const requestId currentRequestId loading.value true error.value null try { const result await fetcher(...args) if (isUnmounted || requestId ! currentRequestId) { return result } data.value result options?.onSuccess?.(result) return result } catch (e) { if (isUnmounted || requestId ! currentRequestId) { throw e } error.value e instanceof Error ? e : new Error(String(e)) options?.onError?.(error.value) throw error.value } finally { if (!isUnmounted requestId currentRequestId) { loading.value false } } } const refresh () run(...(options?.defaultParams ?? [] as unknown as TParams)) if (options?.immediate) { refresh() } return { data, loading, error, run, refresh } }要特别解释三个细节第一个细节是requestId。组合式函数最常见的并发问题来自于快速触发多次请求上一次请求的响应比最后一次晚回来导致界面数据被旧数据覆盖。通过currentRequestId可以保证只有最后一次请求的响应才被写入data。这在类型上虽然看不出来但它是保持状态一致性的关键。第二个细节是错误收窄。catch到的e在TypeScript里是unknown所以不要直接当作Error使用。error.value e instanceof Error ? e : new Error(String(e))这行代码不仅让error字段的类型成立也在运行时避免了“e没有message属性”的隐患。第三个细节是卸载检测。组合式函数在组件onUnmounted中应当清理状态避免异步回调在组件卸载后更新data、loading。很多封装的初版忽略了这点最后在业务页面里反复出现“警告在卸载的组件上更新ref”的问题。isUnmounted标志位要在外部调用的onUnmounted钩子里置为true。不过这里有个取舍如果你把这个函数放在onUnmounted里它就不是纯粹的组合式函数了所以在实际使用时我会在函数内部用getCurrentInstance或提供一个可选的effectScope处理。为了示例清晰简化版里我先用isUnmounted占位。5.4 使用场景页面中如何受益于类型完整推导当上面这个useRequest封装好之后在页面里会是什么体验看看这段代码const { data, loading, error, run } useRequest( (userId: number, includeDetails: boolean) api.getUserProfile(userId, { includeDetails }), { immediate: true } ) async function handleUserSwitch(userId: number) { const profile await run(userId, false) // profile 自动推导为 UserProfile console.log(profile.nickname) }编辑器里profile的类型是UserProfile你不需要翻接口文档确认字段名。如果哪天后端接口返回结构变了你修改api.getUserProfile的返回类型声明后所有依赖方的编译器会直接告诉你哪里需要改——这种“全局重构”的效果比运行时报错要可靠得多。比较有意思的是run(userId, false)这里的第二个参数false会被推导为boolean而TParams是[userId: number, includeDetails: boolean]所以类型完全匹配。如果有人在调用时误写run(userId, false)编辑器会立即标红连测试都不用跑。这正是“钢筋铁骨”带来的安心感。6. 在Playwright测试与tsd断言中验证组合式函数的类型稳定性6.1 为什么运行测试不够还需要类型测试很多前端项目有单元测试和端到端测试却很少有团队写“类型测试”。但组合式函数的类型契约一旦被破坏比如某个泛型修改后推导出any业务代码里所有调用点都可能悄悄失去类型保护。运行测试能证明“功能正确”却不能证明“类型推导正确”。所以我建议给组合式函数补上一层类型断言测试。接下来要介绍的方法和工具同时也能应用于Playwright场景。举例来说你可能用Playwright测试一个页面的交互但Page Object里引用了组合式函数的返回类型如果组合式函数类型设计不当Playwright里做类型断言就会失败。这个问题在真实项目中出现过我们给useTable添加了一个新的返回字段结果把原来返回的list类型从refT[]不小心变成了refany[]运行测试仍然通过但Playwright测试里的类型断言立刻报错帮我们提前发现了问题。6.2 用expectTypeOf和tsd编写组合式函数的类型契约Vitest提供expectTypeOf专门用来在不运行代码的情况下断言类型。它的用法非常自然有点像“对类型做断言”import { expectTypeOf } from vitest it(useRequest 的返回类型应该是 RefUser|null, () { const { data, run } useRequest( (id: number) Promise.resolve({ id, name: test } as User), { immediate: false } ) expectTypeOf(data).toEqualTypeOfRefUser | null() expectTypeOf(run).parameters.toEqualTypeOf[number]() })这里最重要的是类型断言不需要实际运行异步请求。useRequest函数体里的代码不会被执行Vitest只从类型层面分析这个函数。这意味着你可以在测试环境中快速验证组合式函数的泛型约束是否正确——比如data到底是RefUser | null还是变成了Refany。tsd是另一个专门做类型测试的工具可以用.test-d.ts文件描述类型期望。很多开源库都在用。如果项目用的是tsd可以这样写import { expectType } from tsd const { data } useRequest((name: string) Promise.resolve(name.length)) expectTypenumber | null(data.value)需要注意类型测试并非运行时的完全替代但它能捕捉到一类“功能测试测不到”的问题——类型失守。6.3 Playwright场景下使用组合式函数时的类型细节Playwright本身和Vue组件是两套体系但前端项目经常会把一些组合式函数用在Playwright的Page Object或自定义工具里。最典型的场景是你写了一个useTable来管理表格数据同时在Playwright的测试工具函数里引用它来做“等待表格加载完成”的逻辑。如果useTable的返回类型里有loading你就应该能在Page Object里明确写出await expect(page.locator(.table)).toBeVisible()之外的类型判断。这里有一个真实的坑组合式函数返回的Ref在非Vue组件环境下使用类型上有一些别扭。在没有Vue上下文且只是想读取.value时类型没问题但如果这个Ref是通过shallowRef创建的类型上可能被推断为ShallowRef而不是Ref导致你写的类型断言和实际返回类型不匹配。所以我在设计组合式函数时返回类型尽量用RefT这个公共接口而不是具体的ShallowRefT或ReadonlyRefT。这样在Playwright测试工具和其他场景里引用时类型更稳定。另外Playwright测试通常会用waitFor循环等待某个条件成立这种测试代码里如果直接使用组合式函数的data状态最好在类型上显式声明为“可能存在空值”因为第一次运行时data很可能还是null。提前用类型约束住这个边界测试里就不会频繁出现“datais possibly null”这类需要靠!硬着头皮绕过的问题。7. 我在项目里踩过的坑和长期维护建议7.1 过度泛型导致的可读性灾难泛型虽好但并不是越多越好。我见过有人写一个useTable定义了五个泛型参数TRecord、TFilter、TSorter、TPagination、TResponse实际上前四个都可以从参数里推导出来最后一个甚至和第一个重复。调用方看签名就头大useTableUser, UserFilter, Sorter, Page, UserResponse(...)。这种代码虽然类型“很强”但已经失去了组合式函数该有的简洁。我后来给自己定了几条原则泛型参数能够从函数参数中推断出来的就不要让调用方手动传一个组合式函数的泛型参数尽量不要超过两个TData和TParams如果某个泛型需要调用方显式指定那个泛型大概率不应该出现在组合式函数里而是应该放在一个独立的类型定义里。记住泛型是为了让调用方少写类型断言而不是为了让函数作者炫技。设计一个“调用时需要手动传三个泛型”的函数还不如老老实实定义一个接口让调用方直接传入对应类型。7.2 接口与type别名选择背后的维护差异在组合式函数设计中我倾向于描述“对象结构”时用interface因为它可以被extends和声明合并扩展描述“联合类型、元组、从其他类型中提取出来的工具类型”时用type。很多第三库会导出很多type别名这些别名更适合表达“计算出来的类型”而不是“稳定对象结构”。组合式函数的配置项和返回对象往往是稳定结构使用interface更便于后续维护。这个原则在长期项目里尤其重要——当你需要给配置项加一个字段时interface extends比交叉类型更好用当你需要把AsyncStateT拆成联合类型时type更合适。7.3 升级TypeScript版本时最容易出的兼容问题TypeScript的版本升级偶尔会带来类型推断行为的变化这在组合式函数里体现得特别明显。比如早期版本中对泛型默认值的推断会更宽松升级后某些隐式any开始报错或者strictNullChecks开启后原来写RefT | undefined的地方必须改成RefT | null | undefined。我第一次把项目从TypeScript 4.x升到5.x时大量组合式函数的返回类型因为unknown的处理方式不同而报错。我的经验是给组合式函数写类型测试tsd或expectTypeOf能在升级时提供“安全网”。很多错误不会在运行时测试暴露但类型测试会在CI阶段就告诉你“这个返回结构变了”。另外不要在组合式函数内部使用any来“绕过”类型问题而应该优先使用unknown再通过类型守卫收窄。any会让你失去编译期的保护而unknown会在你需要使用数据时强制你去做类型判断这反而能逼你写出更安全的代码。最后再分享一个小技巧如果某个组合式函数的类型推导特别复杂试着在函数顶部显式声明一个独立类型变量比如type UseRequestReturnTData { ... }然后把返回值类型标注为UseRequestReturnTData。这样做的好处是当类型推导出错时错误信息会指向这个明确的类型名而不是一团乱麻的对象字面量。我在处理比较复杂的组合式函数时都会先用接口把返回结构定义清楚再写实现。实践证明这个过程能让设计思路顺很多——“钢筋铁骨”的关键不是事后给代码贴类型而是在动手前就把骨架搭好。
返回列表