先说明一下,TypeScript 5.4在本文写作时尚未正式发布(预计2024年3月发布),本文基于TypeScript 5.4的beta版和官方发布的新特性预告来写。如果你对TypeScript 5.4感兴趣,可以先从beta版入手,等正式发布后再深入使用。
TypeScript这几年发展很快,每个版本都有新特性和改进。我们项目最近从TypeScript 4.9升级到了5.4 beta,顺便用新特性重构了一些老旧代码,效果很不错。
这篇文章就来分享一下TypeScript 5.4的新特性,以及如何用这些新特性把烂代码重构成优雅代码。
为什么要重构
先说说为什么要重构。
我们的项目是一个中后台管理系统,用TypeScript写的,已经维护了三年多。随着功能不断叠加,代码越来越臃肿,类型定义混乱,any满天飞,很多地方类型检查形同虚设。
具体的问题有几个:
第一,any太多。很多地方为了图省事,直接用any,导致类型检查失效。一个函数的参数是any,返回值是any,里面的操作也都是any,整个函数就跟JavaScript一样,没有类型安全。
第二,类型定义重复。很多相似的类型在不同地方重复定义,比如用户类型,在A文件定义了一个User接口,在B文件又定义了一个User接口,字段还不一样,维护起来很麻烦。
第三,类型断言滥用。很多地方用as做类型断言,把一个类型断言成另一个类型,绕过类型检查。有些断言是合理的,但很多断言是因为类型定义不对,不得不断言。
第四,联合类型使用不规范。很多地方用字符串字面量联合类型,但没有穷尽检查,导致新增一个枚举值的时候,很多地方忘了处理,出现bug。
第五,泛型使用不足。很多可以用泛型的地方,没有用泛型,导致代码重复,类型不安全。比如一个工具函数,本来可以用泛型支持多种类型,结果写了好几个重载,或者直接用any。
TypeScript 5.4的新特性,正好可以解决这些问题。
TypeScript 5.4新特性
先看看TypeScript 5.4有哪些重要的新特性。
第一个是NoInfer工具类型。这是TypeScript 5.4新增的工具类型,用来阻止类型推断。在泛型函数中,有时候你不希望TypeScript从某个参数推断类型,而是希望用另一个参数的类型,这时候就可以用NoInfer。比如:
function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) { // ... }
createStreetLight(["red", "yellow", "green"], "red"); // OK createStreetLight(["red", "yellow", "green"], "blue"); // Error
这样defaultColor的类型就不会被推断,而是用colors的类型C,检查更严格。
第二个是Object.groupBy和Map.groupBy。这是JavaScript的新特性,TypeScript 5.4增加了类型支持。可以用Object.groupBy把数组按某个条件分组:
const items = [ { name: "apple", category: "fruit" }, { name: "banana", category: "fruit" }, { name: "carrot", category: "vegetable" }, ];
const grouped = Object.groupBy(items, item => item.category); // grouped: { fruit: {...}[], vegetable: {...}[] }
Map.groupBy类似,但返回的是Map。这两个方法让分组操作更简洁,不用自己写reduce了。
第三个是更精确的类型收窄。TypeScript 5.4对类型收窄做了改进,特别是在闭包和回调函数中。以前在闭包中,变量的类型收窄会失效,TypeScript 5.4修复了这个问题。比如:
| function process(data: string | null) { |
|---|
setTimeout(() => { console.log(data.length); // 以前可能报错,5.4之后正确收窄为string }, 1000); }
这个改进让闭包中的类型检查更准确,减少了不必要的类型断言。
第四个是satisfies操作符的改进。satisfies是TypeScript 4.9引入的,5.4做了一些改进,支持更多场景,错误提示也更清晰。
第五个是模块解析的改进。TypeScript 5.4对模块解析做了优化,特别是在monorepo和路径别名的场景下,解析速度更快,也更准确。
还有一些小的改进,比如更快的类型检查、更好的错误提示、更多的JSX支持等。
用NoInfer改进泛型函数
NoInfer是TypeScript 5.4我最喜欢的新特性,解决了很多泛型函数的类型推断问题。
以前我们项目里有一个配置函数,大概是这样的:
function createConfig<T extends string>(keys: T[], defaultKey?: T) { // ... }
这个函数的问题是,defaultKey的类型会被单独推断。如果你调用createConfig(["a", "b"], "c"),TypeScript不会报错,因为defaultKey被推断为string,而不是"a" | "b"。这就导致类型检查失效,传错了也不知道。
以前的解决办法是用函数重载,或者把defaultKey的类型写成T,但效果都不好。
有了NoInfer之后,就可以这样写:
function createConfig<T extends string>(keys: T[], defaultKey?: NoInfer<T>) { // ... }
这样defaultKey的类型就不会被单独推断,而是用keys的类型T。如果你传了一个不在keys里的值,TypeScript就会报错。
我们项目里有很多这样的泛型函数,都用NoInfer重构了,类型检查更严格了,也发现了几个之前没发现的bug。
NoInfer还有一个用处,就是在函数重载中。以前有些重载,类型推断会有问题,用NoInfer可以解决。
用Object.groupBy简化分组代码
Object.groupBy和Map.groupBy虽然是JavaScript的新特性,但TypeScript 5.4的类型支持让它更好用。
以前我们项目里有很多分组代码,都是用reduce写的:
function groupByCategory(items: Item[]) { return items.reduce((acc, item) => { if (!acc[item.category]) { acc[item.category] = []; } acc[item.category].push(item); return acc; }, {} as Record<string, Item[]>); }
这样写不仅啰嗦,而且类型断言as Record<string, Item[]>绕过了类型检查,不安全。
有了Object.groupBy之后,就可以这样写:
const grouped = Object.groupBy(items, item => item.category);
一行代码就搞定了,而且类型是自动推断的,不需要类型断言。grouped的类型是Partial<Record<string, Item[]>>,因为不是每个category都一定有值,所以是Partial的,访问的时候要注意判空。
我们项目里所有的分组代码都用Object.groupBy重构了,代码简洁了很多,类型也更安全了。
Map.groupBy类似,但返回的是Map。如果你需要用Map的方法,或者key不是字符串,就可以用Map.groupBy。
需要注意的是,Object.groupBy和Map.groupBy是比较新的JavaScript特性,需要运行环境支持。如果要兼容旧环境,需要用polyfill,或者用TypeScript的downlevelIteration。
用类型收窄减少类型断言
TypeScript 5.4对类型收窄的改进,也帮我们减少了很多类型断言。
以前我们项目里有很多这样的代码:
| function fetchData(url: string, callback: (data: Data | null) => void) { |
|---|
}
function process(url: string) { let result: Data | null = null;
fetchData(url, (data) => { result = data; });
setTimeout(() => { if (result !== null) { // 以前这里result还是Data | null,需要断言 console.log((result as Data).name); } }, 1000); }
以前在setTimeout的回调里,result的类型收窄会失效,因为TypeScript不知道回调什么时候执行,result可能已经被修改了。所以需要用as Data做类型断言,绕过检查。
TypeScript 5.4改进了这种场景下的类型收窄。如果变量在回调之后没有被修改,TypeScript就能正确收窄类型。上面的代码,在5.4之后就不需要类型断言了,result在if块里会被正确收窄为Data。
我们项目里很多这样的类型断言都去掉了,代码更简洁,类型也更安全。
不过要注意,类型收窄的改进是有条件的。如果变量在回调之后可能被修改,TypeScript还是不会收窄。这是合理的,因为确实可能有并发修改的问题。
用判别联合和穷尽检查
判别联合(Discriminated Union)是TypeScript里一个很强大的特性,5.4之后更好用了。
以前我们项目里有很多这样的代码:
type Shape = | { type: 'circle', radius: number } | { type: 'square', size: number } | { type: 'rectangle', width: number, height: number };
function getArea(shape: Shape): number { switch (shape.type) { case 'circle': return Math.PI shape.radius 2; case 'square': return shape.size 2; case 'rectangle': return shape.width shape.height; default: return 0; } }
这样写有个问题,如果以后新增了一种Shape类型,比如triangle,getArea函数不会报错,因为有default分支。这就导致新增类型的时候,很容易忘了处理,出现bug。
更好的写法是用穷尽检查(Exhaustive Check):
function getArea(shape: Shape): number { switch (shape.type) { case 'circle': return Math.PI shape.radius 2; case 'square': return shape.size 2; case 'rectangle': return shape.width shape.height; default: { const exhaustive: never = shape; return exhaustive; } } }
这样如果新增了一种Shape类型,default分支里的shape就不能赋值给never,TypeScript就会报错,提醒你处理新类型。
TypeScript 5.4对这种穷尽检查的错误提示做了改进,提示更清晰,能告诉你具体是哪个类型没有处理。
我们项目里所有的switch语句都加了穷尽检查,以后新增枚举值或者联合类型的时候,就不会漏处理了。这大大减少了bug。
用泛型和工具类型减少重复
TypeScript的泛型和工具类型很强大,用好了能大大减少重复代码。
以前我们项目里有很多这样的代码:
interface User { id: number; name: string; email: string; age: number; }
interface UserCreateDto { name: string; email: string; age: number; }
interface UserUpdateDto { name?: string; email?: string; age?: number; }
interface UserResponse { id: number; name: string; email: string; age: number; createdAt: string; updatedAt: string; }
这些类型都是基于User的,但每个都重复定义了一遍,维护起来很麻烦。如果User加了一个字段,所有相关的类型都要改。
用工具类型可以简化:
interface User { id: number; name: string; email: string; age: number; createdAt: string; updatedAt: string; }
// 创建的时候不需要id、createdAt、updatedAt type UserCreateDto = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// 更新的时候所有字段都是可选的,也不需要id、createdAt、updatedAt type UserUpdateDto = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
// 响应就是User本身,或者用Pick选择需要的字段 type UserResponse = User;
这样只需要维护一个User接口,其他类型都用工具类型派生出来。如果User加了字段,相关类型自动更新,不用手动改。
常用的工具类型有:
- Partial<T>:所有字段变成可选
- Required<T>:所有字段变成必选
- Readonly<T>:所有字段变成只读
- Pick<T, K>:选择部分字段
- Omit<T, K>:排除部分字段
- Record<K, T>:创建键值对类型
- ReturnType<T>:获取函数返回值类型
- Parameters<T>:获取函数参数类型
- Awaited<T>:获取Promise的resolve类型
TypeScript 5.4又新增了NoInfer,工具类型更丰富了。
我们项目里所有的DTO类型都用工具类型重构了,代码量减少了很多,维护也更方便了。
用类型别名和接口组织代码
除了工具类型,合理组织类型定义也很重要。
以前我们项目里的类型定义很混乱,有的在文件顶部,有的在单独的types文件里,有的甚至直接写在函数参数里。同一个类型,在不同地方有不同的定义,维护起来很麻烦。
重构之后,我们把类型定义统一组织:
第一,领域类型放在domain目录下。比如用户相关的类型放在domain/user.ts,订单相关的类型放在domain/order.ts。这些是核心的领域模型,是其他类型的基础。
第二,DTO类型放在api目录下。比如用户相关的API类型放在api/user.ts,用工具类型从领域类型派生。
第三,组件相关的类型放在组件文件里。如果类型只在一个组件里用,就放在组件文件顶部,不用单独抽出来。
第四,通用的工具类型放在utils/types.ts。比如一些通用的工具类型、泛型工具函数的类型。
这样组织之后,类型定义清晰了很多,找类型也方便了。
还有就是,类型命名要规范。我们的规范是:
- 领域模型用interface,比如User、Order
- DTO用type别名,比如UserCreateDto、UserResponse
- 联合类型用type,比如UserRole = 'admin' | 'user'
- 函数类型用type,比如EventHandler = (event: Event) => void
- 类型名用大驼峰,不要用小写下划线
规范的命名,让代码可读性更好。
用ESLint和类型检查保证质量
重构之后,还要用工具保证代码质量,防止烂代码再次出现。
第一个是严格的tsconfig。我们把tsconfig的strict设为true,开启所有严格检查。包括noImplicitAny、strictNullChecks、strictFunctionTypes等。还开启了noUnusedLocals、noUnusedParameters、noImplicitReturns等检查。这样任何类型问题都会被报错,不能蒙混过关。
第二个是ESLint。我们用了@typescript-eslint插件,开启了很多类型相关的规则,比如no-explicit-any(禁止显式any)、no-non-null-assertion(禁止非空断言)、prefer-optional-chain(优先用可选链)、consistent-type-imports(统一类型导入)等。这样代码风格统一,也能防止一些不好的写法。
第三个是Code Review。每次提交代码,都要经过Code Review。重点看类型定义是否合理、有没有any、有没有不必要的类型断言、有没有重复的类型。Code Review能发现很多工具发现不了的问题。
第四个是类型测试。对于一些复杂的泛型和工具类型,我们会写类型测试,用expect-type或者tsd库,验证类型是否符合预期。这样修改类型的时候,不会不小心改坏了。
有了这些工具和流程,代码质量就能持续保持,不会重构完之后又慢慢变回烂代码。
重构的经验
重构了几个月,总结了一些经验。
第一,不要为了用新特性而用新特性。新特性是为了解决问题的,如果旧代码没有问题,就不用改。只有当新特性能明显提升代码质量或者开发效率的时候,才值得重构。
第二,逐步重构,不要一次性全改。我们是一个模块一个模块地重构,改完一个测试一个,确保没有问题再改下一个。一次性全改风险太大,容易出问题。
第三,重构的时候要跑测试。重构最担心的是改出bug,所以要有测试保障。我们给核心逻辑写了单元测试,重构之后跑一遍测试,确保功能正常。
第四,团队要达成共识。重构不是一个人的事,需要整个团队都认同新的写法和规范。我们制定了类型规范,组织了代码评审,确保大家写出来的代码风格一致。
第五,关注TypeScript的更新。TypeScript的迭代很快,每个版本都有新特性和优化。要关注官方的更新日志,了解新特性,及时用到项目里。
写在最后
TypeScript 5.4的新特性,配合合理的类型组织和规范,能让代码质量提升一个档次。NoInfer让泛型推断更准确,Object.groupBy让分组更简洁,类型收窄的改进减少了类型断言,穷尽检查防止漏处理。
从烂代码到优雅代码,不是一蹴而就的,需要持续的重构和优化。TypeScript 5.4给了我们更好的工具,让重构变得更容易。
如果你还在用旧版本的TypeScript,建议升级到5.4,体验一下新特性。如果你已经在用5.4,不妨看看项目里有没有可以用新特性优化的地方,说不定能让代码优雅很多。
希望这篇文章能给你一些启发。有什么问题欢迎交流。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录