Vue3 + TypeScript 告别 any:类型安全实践指南
2026/9/16 9:40:58 网站建设 项目流程

1. 从代码评审谈起:为什么我们会本能地写出 any

先讲一个我自己的场景。去年在评审一个新同事的合并请求,功能是商品列表筛选,逻辑不复杂,但整个请求文件里出现了 17 个any。接口返回的数据是any,筛选表单的回调参数是any,甚至连一个propsid字段都是any。我问为什么要这么写,他的回答非常实在:“我不知道这里应该是什么类型,写any先跑起来,后面再补。”

这个回答我太熟悉了,因为我自己刚用 TypeScript 写 Vue 的时候也是这么干的。但问题是,“后面再补”这句话基本不会兑现。等需求迭代两轮,这个组件被复制粘贴到第三个页面的时候,any已经从“临时方案”变成了“历史包袱”。你在编辑器里按住Ctrl点击一个any类型的变量,跳过去的是node_modules里的类型定义文档,还是什么有用的业务代码?大概率是前者。这就是类型安全崩坏的开始。

我写这篇文章不想讲any有多坏,因为骂它没有任何意义。我想干的事情是:把 Vue3 + TypeScript 项目里最容易出现any的位置一个一个拆开,告诉你每个位置背后的“为什么”,以及“不用 any 的话应该怎么写”。这些内容全部来自我在真实业务项目里的实践,不是从文档里抄出来的概念。

先定义一下“告别 any”的边界。any不是绝对不能出现,但在业务代码里,它的出现应该是一个“显式的、有理由的决定”,而不是“我不知道这里该写什么”的默认选项。如果别人 review 你的代码时看到一个any,他应该能立刻猜到你为什么这么写,而不是跑来问你。这才是这篇文章的目标。

下面聊的所有内容建立在一个前提上:你的项目已经启用了 Vue3 的组合式 API,且使用<script setup>语法。这是目前 Vue3 项目的主流形态,也是 TypeScript 推导最友好的形态。如果你的项目还在用选项式 API,本文的很多场景会不太一样,建议先完成迁移再讨论类型治理。

2. 从代码评审谈起:为什么我们离了 any 活不下去

2.1 你通常在哪里写出第一个 any

我先盘点一下 Vue3 项目中最容易写any的位置,你在自己的项目里大概率能找到对应场景:

位置典型写法搓火原因
接口返回数据const data = await request('/api/list') as any后端字段太多,懒得定义接口
props 定义defineProps<{ item: any }>()父组件传来的对象结构复杂
表单绑定值const form = reactive<any>({})动态字段不好定义
事件回调@change="(val: any) => handleChange(val)"组件库暴露的值类型不明确
复杂嵌套类型const deep = data?.a?.b?.c as any层层解构类型太麻烦
第三方库import xxx from 'xxx'; (xxx as any).init()库本身类型不完善

这些位置有一个共同特征:它们都处在“类型边界”——数据进入你代码的地方。类型边界本来就应该重点治理,因为你项目里 80% 的运行时错误都发生在跨界数据上。

2.2 写完 any 的那一瞬间,你失去了什么

any最坑的地方不是“没有类型检查”,而是“TypeScript 会默认你写的是对的”。当你写下const data = res.data as any,然后把data传给一个要求string类型参数的函数时,TypeScript 不会报错。运行时不报错,你自然就以为代码没问题。

等到某个凌晨,线上反馈“列表页白屏了”,你打开控制台看到Cannot read properties of undefined (reading 'map'),然后顺着调用链一路找,发现是接口返回结构变了,data变成了{ list: [], total: 0 },而你代码里写的是data.list.map(...),此时这个dataany,TypeScript 根本不会提前告诉你list可能不存在。这就是any的“延迟爆炸”特性——你在写代码时省下的 5 分钟,会在上线后的某个凌晨加倍还回去。

我自己经手的项目有个规律:线上 Bug 中,凡是“首页数据渲染不出来”“列表有时候有数据有时候没有”“点这个按钮报 undefined”这类问题,十有八九发生在any渗透过的数据链路里。而那些把接口数据类型定义清楚了的模块,出问题的概率明显低很多,因为编译器在发版前就拦截了大部分低级错误。

2.3 告别 any 不是为了让编译器高兴,是为了少熬夜

这话说得俗,但确实是实话。你把类型写清楚,最大的受益者不是代码规范审查工具,而是三个月后需要维护这段代码的你(或者你的同事)。类型系统本质上是一种“可执行注释”——它不光告诉后来的人“这里的数据长什么样”,还能在编译阶段就帮他排查掉一批“如果这里传错会炸”的风险。

你在项目里看到的很多类型写法,初看会觉得繁琐,比如一个接口返回类型写 30 行。但等你真正去维护一个七八个文件相互调用的功能模块时,你会感谢那个把类型写清楚的人——因为你不需要打开三个文件去猜某个字段到底是不是字符串。

3. 先搞清楚 Vue3 的推导机制,很多 any 根本不用写

3.1 为什么说 defineProps 泛型是“白嫖”式的类型推导

<script setup>里定义一个最基础的 props:

<script setup lang="ts"> const props = defineProps<{ title: string count?: number }>() </script>

这段代码里你没有给props显式标注类型,但 TypeScript 会从defineProps的泛型参数里自动推导出props的类型,然后在模板里访问props.titleprops.count时获得完整的类型提示。这种“声明一次、处处推导”的设计,是组合式 API 相比选项式 API 在类型体验上的核心优势。

但问题在于,很多项目的defineProps仍然写着defineProps({ title: String })这种运行时声明写法,然后组件的类型就退化成基于String/Number构造器的粗略推导——字段名和类型对不上时不会报错。问题不大,但如果你的目标是类型安全,建议统一使用泛型声明。

3.2 什么时候该用 reactive,什么时候该用 ref

reactive的时候有个很容易翻车的点。你定义了一个表单:

const form = reactive({ name: '', age: 0 })

然后在某个异步回调里给form增加了一个字段。比如接口返回了一个地址,你想“顺便”塞进form里:

form.address = res.address // 这里 TS 直接报错,因为 form 类型里没有 address

很多人的第一反应是把form改成reactive<any>({}),这样就不报错了。但这是最不值得的写法——你失去了form.nameform.age的自动补全和类型检查,只换来了一个“代码能过编译”的结果。

更好的做法是:先设计好类型,再决定用reactive还是ref。如果字段是固定的,用reactive,配合一个完整定义的 interface,你在模板里写form.name时编辑器会给你提示,不会拼错;如果字段后续可能动态增加,用ref配合Record<string, unknown>会更从容,因为你可以明确地处理未知字段。

简单说:类型不完整就不要用reactive,否则你会为了“塞进去一个字段”不断求助于any,而ref+ 泛型至少能逼你面对“数据到底长什么样”这个问题。

3.3 模板引用 ref 与组件实例类型

模板引用的类型标注也是any重灾区。很多人会写const el = ref<any>(null),因为“这个库的组件实例类型太复杂了”。

实际上 Vue3 对模板引用的类型支持非常够用。对于一个 HTML 元素:

const canvasRef = ref<HTMLCanvasElement | null>(null)

对一个自定义组件,配合defineExpose也能拿到精确类型:

// 子组件 defineExpose({ scrollToTop: () => { /* ... */ } }) // 父组件 import ChildComp from './ChildComp.vue' const childRef = ref<InstanceType<typeof ChildComp> | null>(null) // 调用时就有完整提示 childRef.value?.scrollToTop()

InstanceType<typeof ChildComp>这个写法看着唬人,其实就是告诉 TypeScript:“这个 ref 的类型,是子组件实例的类型”。子组件defineExpose里暴露了什么,父组件这里就能用什么,多写几个字符,换来的是调用时安全的提示和编译器检查。

3.4 事件回调里的隐式 any:在模板里就标好类型

<script setup>中写模板事件绑定时,如果你在位回调函数里直接写@change="handleChange"handleChange的参数类型通常需要单独声明。很多人的做法是:

<Select @change="(val: any) => handleChange(val)" />

其实大部分组件库都会在类型里导出事件回调参数类型。比如element-plusSelect组件,change事件的参数类型是SelectValueType,你在script里写好函数以后,直接在模板里绑定:

function handleChange(val: SelectValueType) { // ... }

如果你用的组件库没导出类型,一个比较通用的兜底方案是用Parameters工具类型从组件的 emit 类型里“挖”出参数类型。但这种写法日常用得不多,因为你不需要每次都用它。重点是:不要在模板里直接写(val: any)——哪怕多花两步查一下文档,把这个类型搞清楚,你的后续维护体验会完全不同。

4. 接口返回数据告别 any:从数据源头上锁死类型

4.1 为什么接口层是类型治理的第一阵地

前端拿到的大部分数据来自接口。接口返回的数据如果被定义成any,基本等同于“这一整块功能的数据都不受保护”。反过来,如果接口层把类型定义清楚,所有下游组件、工具函数、状态管理都会受益——这是杠杆率最高的一层。

我见过很多团队的接口层是这样的:

// api/user.ts export const getUserInfo = () => request.get('/user/info')

返回值完全交给 TypeScript 推导。如果request.get的返回类型是Promise<any>,那getUserInfo的返回值也变成了any——你每调一次getUserInfo,就等于在业务代码里埋了一个any

正确的做法是在接口函数上显式标注返回类型:

// api/user.ts export interface UserInfo { id: number name: string avatar: string email: string roles: string[] } export const getUserInfo = (): Promise<UserInfo> => { return request.get('/user/info') }

这样在业务代码里调用const user = await getUserInfo()之后,user会自动拥有UserInfo的完整类型提示,写错字段名编译器会直接报错。

4.2 用泛型封装 request 函数

上面的写法依赖一个前提:request.get的返回类型不是any。如果你项目里封装的request函数长这样:

export const request = { get<T>(url: string): Promise<T> { return axios.get(url).then(res => res.data) } }

那调用方可以指定泛型参数:

export const getUserInfo = () => request.get<UserInfo>('/user/info')

这里的核心点是:泛型在调用时指定返回类型,这是一个编译器不知道“数据真的长这样”的行为。你在运行时必须保证接口真的返回这个结构,否则就是“带了类型帽子的 any”。但相比完全不写类型,这种做法的优势在于:你被迫“声明”了数据结构,至少能统一管理和 review。

如果要做更严格的运行时校验(比如校验接口返回的id确实是一个数字),可以配合zod(一个流行的运行时校验库)或valibot在接口层过滤数据。但实际项目里,很多人不会在每一层都做运行时校验,通常是对关键字段做校验,其余依赖静态类型声明。

4.3 分页列表这种通用结构,定义一次全局复用

分页列表是后台管理系统里最常见的接口形态。每个模块都在写自己的“列表接口返回类型”有点浪费。建议设定全局的通用类型:

// types/api.d.ts 或 types/index.ts export interface PageResult<T> { list: T[] total: number page: number pageSize: number } // api/order.ts export interface OrderItem { id: string orderNo: string amount: number status: OrderStatus createdTime: string } export const getOrderList = (params: PageParams) => request.get<PageResult<OrderItem>>('/order/list', { params })

一件事如果你在不同模块里反复做,就值得抽象成通用类型。PageResult<T>这种泛型容器,日常接下来会频繁使用,提前沉淀到公共类型文件里能省下很多时间。

4.4 后端字段不一致时的兜底方案:type 守卫

有些时候,接口返回的字段是可选的、可能是 null、可能是空字符串。比如:

export interface UserInfo { nickname: string | null email?: string }

在业务代码里使用user.nickname时,TypeScript 会提醒你它可能是null。如果你在模板里直接{{ user.nickname.toUpperCase() }},编译不会报错(模板里的类型检查相对宽松),但在script里写user.nickname.toUpperCase(),就会报“对象可能为 null”。

很多人的处理方式是(user.nickname as string).toUpperCase(),虽然能过,但断言写多了也会丑。更推荐的做法是写一个类型守卫:

function hasNickname(user: UserInfo): user is UserInfo & { nickname: string } { return typeof user.nickname === 'string' && user.nickname.length > 0 } if (hasNickname(user)) { console.log(user.nickname.toUpperCase()) }

类型守卫的好处是:不光编译器知道nicknamestring了,读代码的人也知道这个分支是在“确保障下有值”。这比as string直接断言的语义清晰得多。当然,对于“我真确定这里一定有值”的场景,用as断言也不算错,关键是你心里清楚两者区别就好。

5. 组合式 API 里的类型死角:ref、props、emit、inject 逐个排查

5.1 ref 初始化的“空值困境”与泛型写法

ref是 Vue3 里使用频率最高的 API,但也是any高发地。最常见的场景是:

const list = ref([]) // 类型被推导为 never[]? const info = ref(null) // 类型是 null,不是 null | UserInfo

当你想要一个“稍后才赋值”的响应式数组时,直接ref([])会让 TypeScript 推断成never[],之后向里面push任何东西都会报错。于是有人改成ref<any>([])先跑起来。

正确姿势是给ref传泛型参数:

interface OrderItem { id: string amount: number } const list = ref<OrderItem[]>([]) const info = ref<UserInfo | null>(null) list.value.push({ id: '1', amount: 10 }) // OK if (info.value) { console.log(info.value.name) // OK,因为判空之后类型收窄为 UserInfo }

一个小建议:凡是“先空后填”的响应式数据,尽量用显式泛型把“未来形态”定义清楚,不要指望 TS 能猜到你之后想放什么。这不是any的锅,是别人没法替你想清楚数据结构。

还有一个容易被忽视的点:ref包裹之后,在模板里访问时是自动解包的,但在script里必须写.value。很多刚转 Vue3 的同事会忘记这一点,然后在各种回调里写出list.valuelist混用的代码。类型上不会报错,但语义很混乱。建议统一约定:script里一律.value,模板里一律不加。

5.2 defineEmits 的事件类型也可以完全被推导

事件类型的any往往出现在“事件比较多且参数复杂”的时候,比如一个对话框组件可能输出confirmcancelupdate:visible三个事件,参数分别是对象、布尔值、日期。很多人嫌麻烦写成:

const emit = defineEmits(['confirm', 'cancel'])

这种写法在运行时没问题,但调用方的类型完全丢失。父组件监听@confirm时拿到的参数是未知的,于是父组件里又出现一个(val: any) => {}

推荐使用带类型的 defineEmits 声明:

interface ConfirmPayload { id: string formData: Record<string, unknown> } const emit = defineEmits<{ (e: 'confirm', payload: ConfirmPayload): void (e: 'cancel', reason?: string): void (e: 'update:visible', visible: boolean): void }>()

这种写法初看比较“仪式感”,但它把你组件对外暴露的接口从“只能猜”变成了“开箱即用”。父组件在模板里写@confirm="handler"时,handler的参数会自动推导为ConfirmPayload,写错了字段在编辑器里就会标红。

如果你觉得这种“函数调用签名”风格太啰嗦,也可以用较短的对象字面量写法(不同版本的 Vue 支持情况不同):

const emit = defineEmits<{ confirm: [payload: ConfirmPayload] cancel: [reason?: string] 'update:visible': [visible: boolean] }>()

效果类似,看团队偏好。关键是不要再用纯字符串数组了——那等于你跟 TS 说“我这有事件,但具体有哪些我也不知道”。

5.3 provide / inject 的类型最容易被忽略

跨层级传数据时,provideinject是很大的any来源。因为它俩发生在不同的组件,TypeScript 不会自动知道你 inject 出来的值是啥类型。

很多项目这样写:

// 祖先组件 provide('theme', 'dark') // 子孙组件 const theme = inject('theme') // 类型是 unknown

unknown至少比any安全一点点,但在需要直接用的时候还是得断言,很不爽。更优的做法是提供一个带泛型的 inject 封装函数:

// types/inject.ts import { inject } from 'vue' export function useInjectTheme() { const theme = inject<Ref<string>>('theme') if (!theme) { throw new Error('theme not provided') } return theme }

然后在子孙组件里直接:

const theme = useInjectTheme()

类型一目了然,不会出现any,还为“inject 时忘了 provide 在运行时报错”这一经典问题留下了错误提示的余地。实际项目里,这种“封一层组合式函数”的做法比直接裸用inject更可控,也更容易维护。

5.4 模板里的类型黑洞:v-model 与表单

很多后台管理页面的v-model绑定对象,最终会碰上一个类型难题——对象字段的动态性。比如你使用表单组件库:

<el-form :model="form"> <el-input v-model="form.userName" /> </el-form>

如果formreactive({}),那form.userName就是any。如果formreactive({ userName: '' }),那form.userName的类型是string,在模板里绑定form.phone会直接报错,因为phone不在类型里。

这里的矛盾是:表单字段往往对应一个接口的提交数据,而这个提交数据在“表单填写阶段”就应该是确定的。所以更好的做法不是在v-model上绕,而是先把表单类型定义清楚:

interface UserFormModel { userName: string phone: string age?: number address?: string } const form = reactive<UserFormModel>({ userName: '', phone: '', age: undefined, address: undefined })

这样v-model="form.phone"就会有类型检查,也不会在模板里出现any。如果后续要清空表单,直接Object.assign(form, { userName: '', ... })就行,模板绑定依然安全。

5.5 异步组件与 defineAsyncComponent 的类型注意点

defineAsyncComponent在拆分大组件时非常常用。类型上它并不难,但容易在“组件的 props”上踩坑。假如你异步加载一个组件,并在模板里传参:

<AsyncComp title="hello" />

此时如果用defineAsyncComponent(() => import('./AsyncComp.vue'))包装,AsyncComp在模板里的 props 类型推导是有效的——它会从异步 import 的默认导出模块上推断。如果你在defineAsyncComponent的工厂函数里手动加了一层包装函数,返回类型没指向组件本身,那模板中的 props 可能就直接变成了宽松的any或者未知。

我自己习惯的做法是:异步组件完全不写类型,全部交给import()推导,不在工厂函数里做多余包装。如果非要对defineAsyncComponent做个性化配置(比如loadingComponentdelay),在配置对象里写,工厂函数保持原始返回。

6. 与第三方库打交道时的 any 治理策略

6.1 没有类型声明的库应该怎么处理

遇到 npm 包本身不提供类型时,很多人的第一反应是(xxx as any).init()。这能解决问题,但会让这个库的真实 API 完全失明。稍微上一点台阶的做法是:在项目src/types下为这个库写.d.ts声明文件。

比如你用一个老旧的图表库old-chart,只有运行时没有类型:

// src/types/old-chart.d.ts declare module 'old-chart' { export interface ChartOptions { width?: number height?: number series: Array<{ name: string; data: number[] }> } export function init(el: HTMLElement, options: ChartOptions): void }

这样你在业务代码里 import 这个库时,就有了类型提示。虽然不是库本身维护的类型,但至少是“你们团队约定俗成的使用方式”的类型化表达,比全any强太多。

不过需要注意的是,declare module的声明文件如果写得太宽泛(比如export const anything: any),那跟没写也没区别。所以书写声明时,尽量把高频 API 的核心参数类型写清楚。

6.2 高质量组件库的类型参考价值

你用arco-designelement-plusnaive-ui这类组件库时,大部分组件都已经具备比较完善的类型定义。我的经验是:在业务代码里绑定它们的回调时,不要“凭感觉猜测”参数类型,而是直接去看它导出的类型定义。

比如你使用arco-designTable组件,想要拿到当前行的数据,可以用它的RowContextTableData类型。具体名称因库而异,但方向是一致的:先从组件库源码里找到类型,再在业务文件里显式使用它。这样你就不用写(row: any) => {}了。

只要你不是直接用any当遮羞布,而是花两分钟查一下类型,你的代码质量和可维护性都会显著提升。这个过程在项目初期比较费时间,但一套业务组件用顺手后,基本就是“条件反射”级别的效率。

6.3 复杂类型需要绕路时:as unknown as T 的适用范围

有些第三方库的类型定义极其抽象,或者存在泛型嵌套问题,直接用as断言会报错。这时候可以“二段跳”:

const value = someLibrary.getX() as unknown as MyDefineType

as unknown as T看起来比as any as T更绕,但它的优势在于语义:你是“通过 unknown 强制转换”,而不是“放弃所有类型检查”。不过在业务代码里应该尽量少用这种写法。绝大多数情况下,明确写好中间类型结构才是正途。

另外要提一句:as any在 Vue 的模板编译和ref深度响应式的性能优化上,并没有显著的额外开销。它不是性能问题,是代码可维护性问题。所以不要拿“性能”为any开脱——它只是省了思考的时间,不是省了运行的时间。

7. 合理取舍:不是所有类型都必须极致到底

7.1 什么时候该用 unknown,什么时候用 any

unknownany的安全替代品。区别在于:你不能对unknown做任何操作(除非先收窄类型),而any可以随便操作。所以当你在一个边界位置(比如接口返回、动态数据)看到一个未知类型,正确的做法是先用unknown接住,再通过类型守卫/断言收窄,而不是直接any

比如你写一个本地存储 helper:

function getLocalValue(key: string): unknown { const raw = localStorage.getItem(key) return raw ? JSON.parse(raw) : null } // 使用方自己收窄 const user = getLocalValue('user') as UserInfo | null

getLocalValue内部没有断言成具体类型,因为它真的不知道存的是啥。调用方按自己的需求去断言。这样的分层逻辑清晰,不仅没有any,也不会出现“一个函数返回 any 导致所有调用链都变 any”的连锁反应。

7.2 业务代码里的复杂嵌套,什么时候值得写类型

有些数据设计得很复杂,比如后端返回一个多层级、甚至字段名都不稳定的结构。你写套类型守卫很费劲,不做又难受。我的建议是:

  • 如果这个数据结构会被 3 个以上文件引用,值得花时间写完整类型;
  • 如果它只是一个页面的临时局部数据,可以用unknown接住 + 当前文件内的局部类型;
  • 如果这个接口本身不在你们掌控范围内、字段随时会变,那就别死磕,尽力而为即可。

类型治理的目标不是让所有字段全部最精确,而是让高频使用的路径尽量可预测。为 1% 的边缘动态结构写 100 行类型,性价比很低。

7.3 团队规范层面:lint 规则帮你拦住多数 any

在工程层面,治理any最有效的手段不是 code review 时人肉去找,而是用 ESLint 规则挡住。

推荐开启@typescript-eslint/no-explicit-any规则。它会强制你不能在代码里写显式的any(比如:const x: any会报错)。如果你真的要写,可以使用// eslint-disable-next-line注释,并写下原因——这会让每个any都是“有理由的存在”。这个规则本身很简单,但能带来很明显的团队习惯转变。

另外推荐@typescript-eslint/no-unsafe-argumentno-unsafe-assignment,这两条规则能拦住“把any传给一个期望具体类型的函数”这类风险。它们的报错很精准,配置在团队里也不会引起太多争议。注意这两条规则需要你开启了类型检查(parserOptions.project),如果项目配置不到位,规则会静默失效。

7.4 关于 tsconfig 的 strict 你还需要知道的其他选项

strict: true是标配,不用讨论。除了它,有四个选项单独说:

  • noImplicitAny:严格模式下默认开启。它主要拦“隐式 any”——比如回调参数没写类型,TypeScript 无法推导时,就会报错。这能迫使你给边缘情况写类型。
  • strictNullChecks:严格模式下默认开启。它让string | null不再能直接被当作string使用,逼你处理空值。这个选项对运行时稳定性的提升非常大。
  • noUncheckedIndexedAccess:这不是严格模式的一部分,需要手动开启。它会让arr[0]的类型变成T | undefined,看起来有点啰嗦,但它能拦住一批“取数组下标结果为 undefined 导致崩溃”的场景。后台管理列表页的数据处理尤其受益。
  • exactOptionalPropertyTypes:这个选项比较进阶,开启后可选属性和undefined会被区分。比如interface User { email?: string }不能直接赋值email: undefined,这会改变你写初始化数据的习惯。如果你的团队还在磨合期,建议先不开,等大家适应了前面的约束再说。

合理的 tsconfig 会在不知不觉中帮你减少any的生存空间。它不像 ESLint 那样直接报错,但会通过“这个类型怎么这么别扭”来促使你重新思考数据结构。

8. 在真实项目里推进“无 any 治理”的落地经验

8.1 不要指望一晚上把历史 any 清完

如果你的项目已经跑了一两年,代码里散布着几百个any,想靠一个周末全部清理掉是不现实的。历史包袱要慢慢还,第一步不是改代码,而是定规则:新代码禁止出现any,边界场景必须写注释说明为什么。只有增量稳住了,才有精力去清理存量。

我见过一些团队一上来就开no-explicit-any规则,结果全项目到处是// eslint-disable-next-line @typescript-eslint/no-explicit-any,甚至有人批量复制粘贴这个注释放在所有any上面。这种“形式治理”比不治理更糟糕,因为你新增了一个“看起来在治理”的流程,实际代码质量没变,只是把any藏到了注释后面。

比较务实的做法是分块治理:挑一个相对独立、调用频率最高的模块(比如用户信息、权限列表),先把这个模块涉及的接口类型、状态类型、Props/Emits 类型全部补齐。验收标准是:在这个模块内,你全局搜索any,结果为零。有了一个成功案例,再推广到其他模块,团队接受度会高很多。

8.2 代码评审时怎么盯 any

推动无 any 治理最有效的抓手是 code review。评审时看到any,不要只说“这里怎么有 any”,而是问三个问题:

  1. 这里的数据结构你清楚吗?如果清楚,为什么不写类型?
  2. 这里真的没法确定类型吗?还是只是懒?
  3. 如果这里用unknown+ 守卫,会不会更安全?

这三个问题问完之后,绝大多数场景下,提交者会自己承认“我应该去定义一下类型”。当然,也有少数场景确实是无解的(比如一个真正动态的 JSON 配置),这种就允许用注释说明一下继续前进。

我自己的经验是:代码评审里关于any的反馈,尽量提供替代写法。直接说“给我改成明确类型”很容易引起逆反心理,如果能顺手给一个参考写法,对方会接受得比较快。

8.3 一种务实的分层策略:先保住数据进出边界和复杂状态

如果你所在团队时间紧张,没法全面推开类型治理,我建议把有限精力花在这两个位置:

  • 数据进出边界:接口返回、localStorage、事件总线、postMessage、URL query
  • 复杂状态管理:跨页面共享的全局状态、复杂表单、多步骤流程的状态机

这些位置是运行时报错最密集的地方。把“边界类型”全部写清楚之后,项目整体的运行稳定性会明显提升。至于那些纯展示性质的内部变量,使用推导就好,不用每个都手动标注。

8.4 一个小工具习惯:用 typecheck 脚本卡住 CI

为了不让类型退化和any回流,建议在package.json里加一条脚本:

{ "scripts": { "typecheck": "vue-tsc --noEmit" } }

在 CI 构建流程里先跑npm run typecheck,再跑单元测试。vue-tsc --noEmit会检查.vue文件里的模板表达式、props、事件绑定等 TypeScript 类型是否正确。只要类型挂了,发布流程就会断掉,这比任何口头约定都硬。

这条命令有一个小坑:如果你的项目大、依赖多,首次运行时耗时可能比较长(甚至几十秒)。这是正常的。可以把typecheck放在 pre-commit 钩子里只做增量检查,或者干脆接受它的耗时,毕竟每次发版前多花几十秒,换来的是线上少熬夜,这笔账怎么算都划算。

9. 我自己踩过最痛的 any 坑,以及那个收尾技巧

聊了这么多方法论,最后分享一个具体故事。去年我维护一个中后台项目,有一个“导出报表”的按钮,点击后调用后端接口,拿回来一个 Blob,然后触发浏览器下载。初版代码是这样的:

const res = await exportReport(params) // 某些浏览器上打开下载文件是纯文本,内容是一段 JSON 报错 // 排查半天发现,后端在导出失败时返回的是 JSON { code: 500, message: 'xxx' } // 而我的代码直接把 res 当 Blob 处理了

这个问题的根因不在any,但同类问题在any泛滥的代码里更容易发生:接口函数没有返回类型,调用方完全不知道res到底可能有哪些形态。后来我把接口函数改成了联合类型返回:

type ExportReportResult = | { success: true; blob: Blob } | { success: false; message: string } const res = await exportReport(params) if (res.success) { // blob 下载逻辑 } else { // message 提示逻辑 }

从此导出功能再也没出现过“下载下来一个 JSON”的线上事故。类型收窄的价值就在这种地方体现——你不光告诉编译器“可能是 A 也可能是 B”,你还告诉未来的维护者“这里有分支,别只处理成功路径”。

最后再说一个处理接口字段缺失的小技巧。后端接口经常返回一些可空字段,你在接口类型里写成nickname: string | null,业务里每次用都要判空,很烦。如果这个字段在业务里确实“总是有值”,可以在业务层做一个“兜底默认值”的处理:

const nickname = user.nickname ?? '未设置昵称'

这样既保持了下游数据的完整性,又不用在页面上写一堆判空逻辑。类型安全不等于“所有字段都不允许为空”,而是“每个字段的取值空间都用类型表达清楚了”。至于这个取值空间里是否包含null,完全取决于实际场景,你在类型里写清楚、在业务里手动收窄即可。

如果你刚准备在一个老项目里推类型治理,我建议从“新代码不加 any”开始,配合一个typecheck脚本,然后每两周挑一个模块清理存量any。别追求一步到位,类型安全是“慢慢把欠债还清”的过程,不是“一次性革命”能解决的。相信我,坚持半年之后你再回头看当初那些写满any的文件,你会惊讶于自己当初是怎么在这种代码里改了一个月需求的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询