29、JavaScript/TypeScript中的重构:动态类型的重构技巧、类型系统的利用
说实话,JavaScript 这门语言很有意思。它灵活到让你飞起来,也灵活到让你摔得很惨。我在团队里带过不少项目,每次看到那种「跑起来没问题,但改起来想哭」的 JS 代码,就知道——该重构了。
而 TypeScript 的出现,说白了就是给这匹野马套上了缰绳。但别误会,光有类型系统还不够,关键是怎么用它来指导重构。今天我就聊聊这些年积累的一些实战经验。
动态类型下的重构陷阱
先说说纯 JS 环境下的重构。你想想看,一个函数参数可以是字符串、数字、甚至对象——这种灵活性在小型项目里很爽,但项目一膨胀,问题就来了。
processData(data),你根本不知道 data 到底长什么样。改一行代码,可能炸了三个模块。
我曾经在一个电商后台遇到过这种事。一个订单处理函数,参数有时候是对象,有时候是数组,有时候干脆是 null。调用方有十几个,每个都按自己的理解传参。重构?那简直是拆弹。
那怎么办?我的习惯是:先写测试,再动手改。哪怕没有单元测试,也要先写几个关键的集成测试,把核心路径锁住。
利用 TypeScript 类型系统指导重构
TypeScript 的好处,不只是「能检查类型错误」。它更像是一份活的文档,告诉你「这个函数应该怎么用」。
我建议你在重构前,先做一件事:把 any 类型逐步替换成具体类型。这个过程本身就是在梳理代码结构。
// 重构前
function calculatePrice(items: any): any {
return items.reduce((sum: any, item: any) => sum + item.price, 0);
}
// 重构后
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
function calculatePrice(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
你看,类型一明确,逻辑也跟着清晰了。原来 price 是单价,现在乘上 quantity 才是总价——这个 bug 在类型化之前根本发现不了。
类型守卫与可辨识联合
动态类型最头疼的问题之一:一个变量可能是多种类型。这时候,可辨识联合(Discriminated Union) 就是你的救星。
// 重构前:到处是 if-else 和类型判断
function handleEvent(event: any) {
if (event.type === 'click') {
console.log(event.x, event.y);
} else if (event.type === 'keypress') {
console.log(event.key);
}
}
// 重构后:可辨识联合
type AppEvent =
| { type: 'click'; x: number; y: number }
| { type: 'keypress'; key: string }
| { type: 'resize'; width: number; height: number };
function handleEvent(event: AppEvent) {
switch (event.type) {
case 'click':
console.log(event.x, event.y);
break;
case 'keypress':
console.log(event.key);
break;
case 'resize':
console.log(event.width, event.height);
break;
}
}
嗯,这里要注意:switch 里的每个 case,TypeScript 会自动帮你缩小类型。如果你漏掉某个分支,编译器会报错——这就是类型系统在帮你做「穷举检查」。
泛型:让重构更安全
泛型这东西,很多人觉得是「高级用法」,其实不然。它本质上就是让类型跟着逻辑走。
我举个例子。你写了一个缓存工具,原来用 any 实现:
// 重构前:类型不安全
class Cache {
private store = new Map<string, any>();
get(key: string): any {
return this.store.get(key);
}
set(key: string, value: any): void {
this.store.set(key, value);
}
}
用泛型重构后:
// 重构后:类型安全
class Cache<T> {
private store = new Map<string, T>();
get(key: string): T | undefined {
return this.store.get(key);
}
set(key: string, value: T): void {
this.store.set(key, value);
}
}
// 使用
const userCache = new Cache<User>();
userCache.set('user1', { name: 'Alice' }); // 类型正确
userCache.set('user1', 123); // 编译错误!
你看,泛型把「运行时才能发现的错误」提前到了「编译时」。这省了多少调试时间啊。
重构中的类型演进策略
如果你面对的是一个大型 JS 项目,不可能一夜之间全部改成 TS。我建议分步走:
- 先加
// @ts-check注释,让编辑器帮你做基础检查 - 把 .js 改成 .ts,但先不修类型错误,用
any占位 - 从底层模块开始,逐步替换 any 为具体类型
- 开启 strict 模式,强迫自己处理边界情况
unknown 代替 any。unknown 更安全,因为它强制你在使用前做类型检查。说白了,any 是「放弃治疗」,unknown 是「先确认再动手」。
动态类型重构的避坑指南
我曾经犯过一个错:重构一个工具函数时,把参数类型收窄了,结果调用方传了 undefined 进来,线上直接报错。从那以后,我养成了一个习惯——先看调用方,再改类型。
这里列几个我踩过的坑:
- 别轻易用
!非空断言——它只是让编译器闭嘴,运行时该炸还是炸 - 小心
Object.keys()的返回值——它返回的是string[],不是具体的 key 类型 - 条件类型别嵌套太深——可读性会急剧下降,团队其他人看不懂
知识体系总览
下面这张图,是我对本章内容的一个梳理。你可以把它当作重构时的「检查清单」:
总结一下
JavaScript/TypeScript 的重构,核心就一句话:用类型系统把「运行时错误」变成「编译时错误」。动态类型给了你灵活性,类型系统给了你安全感——两者结合,才是正道。
我个人习惯是:新项目直接上 TypeScript 加 strict 模式;老项目先加类型声明,再逐步替换。别想着一步到位,重构是个持续的过程。
嗯,最后提醒一句:类型系统是工具,不是目的。别为了「类型完美」而写出难以理解的代码。好的重构,是让代码更容易理解、更安全、更容易修改——而不是让类型定义比业务逻辑还长。