1. 从代码评审谈起:为什么我们会本能地写出 any
先讲一个我自己的场景。去年在评审一个新同事的合并请求,功能是商品列表筛选,逻辑不复杂,但整个请求文件里出现了 17 个any。接口返回的数据是any,筛选表单的回调参数是any,甚至连一个props的id字段都是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(...),此时这个data是any,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.title、props.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.name、form.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-plus的Select组件,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()) }类型守卫的好处是:不光编译器知道nickname是string了,读代码的人也知道这个分支是在“确保障下有值”。这比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.value和list混用的代码。类型上不会报错,但语义很混乱。建议统一约定:script里一律.value,模板里一律不加。
5.2 defineEmits 的事件类型也可以完全被推导
事件类型的any往往出现在“事件比较多且参数复杂”的时候,比如一个对话框组件可能输出confirm、cancel、update: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 的类型最容易被忽略
跨层级传数据时,provide和inject是很大的any来源。因为它俩发生在不同的组件,TypeScript 不会自动知道你 inject 出来的值是啥类型。
很多项目这样写:
// 祖先组件 provide('theme', 'dark') // 子孙组件 const theme = inject('theme') // 类型是 unknownunknown至少比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>如果form是reactive({}),那form.userName就是any。如果form是reactive({ 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做个性化配置(比如loadingComponent、delay),在配置对象里写,工厂函数保持原始返回。
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-design、element-plus、naive-ui这类组件库时,大部分组件都已经具备比较完善的类型定义。我的经验是:在业务代码里绑定它们的回调时,不要“凭感觉猜测”参数类型,而是直接去看它导出的类型定义。
比如你使用arco-design的Table组件,想要拿到当前行的数据,可以用它的RowContext或TableData类型。具体名称因库而异,但方向是一致的:先从组件库源码里找到类型,再在业务文件里显式使用它。这样你就不用写(row: any) => {}了。
只要你不是直接用any当遮羞布,而是花两分钟查一下类型,你的代码质量和可维护性都会显著提升。这个过程在项目初期比较费时间,但一套业务组件用顺手后,基本就是“条件反射”级别的效率。
6.3 复杂类型需要绕路时:as unknown as T 的适用范围
有些第三方库的类型定义极其抽象,或者存在泛型嵌套问题,直接用as断言会报错。这时候可以“二段跳”:
const value = someLibrary.getX() as unknown as MyDefineTypeas unknown as T看起来比as any as T更绕,但它的优势在于语义:你是“通过 unknown 强制转换”,而不是“放弃所有类型检查”。不过在业务代码里应该尽量少用这种写法。绝大多数情况下,明确写好中间类型结构才是正途。
另外要提一句:as any在 Vue 的模板编译和ref深度响应式的性能优化上,并没有显著的额外开销。它不是性能问题,是代码可维护性问题。所以不要拿“性能”为any开脱——它只是省了思考的时间,不是省了运行的时间。
7. 合理取舍:不是所有类型都必须极致到底
7.1 什么时候该用 unknown,什么时候用 any
unknown是any的安全替代品。区别在于:你不能对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 | nullgetLocalValue内部没有断言成具体类型,因为它真的不知道存的是啥。调用方按自己的需求去断言。这样的分层逻辑清晰,不仅没有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-argument和no-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”,而是问三个问题:
- 这里的数据结构你清楚吗?如果清楚,为什么不写类型?
- 这里真的没法确定类型吗?还是只是懒?
- 如果这里用
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的文件,你会惊讶于自己当初是怎么在这种代码里改了一个月需求的。