vlambda博客
学习文章列表

【第2241期】TypeScript类型编写指南之上篇

前言

今日前端早读课文章由百度@沈毅分享,由@羡辙授权分享。

正文从这开始~~

本文是 Apache ECharts PMC 成员沈毅(GitHub ID:pissang)在 ECharts 5 重构过程中积累的关于 TypeScript 类型的经验之谈,对于通用的 TypeScript 项目以及从 JavaScript 迁移到 TypeScript 都能提供不少启发。

本文主要作为平时在 TypeScript 代码中编写类型以及对 TypeScript 代码进行 review 时候的参考手册,并非强制执行的规范,也不涉及纯代码风格以及代码逻辑上的指导。

为什么我们要添加类型

首先是最重要的,完善的类型能够帮助我们提前在编译期发现很多低级或者隐蔽的错误(比如拼错单词,少传参数,参数类型传错等),避免把这些错误遗留到后面单测,回归测试,甚至线上的时候才被发现从而提高排查成本。

尤其是在代码重构的时候,诸如方法重命名,参数增减,参数类型调整,对象属性调整等都有可能因为改漏了部分代码而带来一些很隐蔽的 bug,而类型的加入就可以帮助我们避免这些问题。进一步的也可以降低我们平时写代码和重构代码时候的心智负担。

最后这些类型也可以像 jsdoc 一样帮助我们理解代码,比如参数对象中有哪些属性,这些属性分别是什么类型的等等,工具精确的智能提示也可以提高我们写代码的效率。我们经常会存在将一个参数对象在各个方法中互相传递,到最后已经搞不清楚参数对象长什么样的情况了,而良好的类型声明可以有效的帮助我们能避免这种情况。类型相比于在 jsdoc 更好的一点在于,jsdoc 容易在代码发生变化时忘记同步更新从而误导后面阅读代码的开发者,而类型因为存在编译器的检查所以不会存在这个情况。

完善项目中的类型

刚才我们提了很多添加类型的好处,能够最大化利用这些好处的前提是项目中的代码都拥有了比较完善的类型。

为了能够让之前大量的 JavaScript 存量代码逐渐过渡和迁移到 TypeScript 而不会让项目产生问题,TypeScript 中就有像any这样的设计让类型检查不会那么严格,但是这些不那么严格检查的设计存在也让类型的收益大打折扣。

那么怎么样的类型才算是比较完善了,当然最健全的情况是具体实现的逻辑代码能够完全处理声明的类型,从而能够在编译期就能够发现因为代码未处理到的输入而可能出现的潜在 bug。

但是在项目中追求完全健全的类型是一件很困难甚至不现实的事,因此我们要做的是尽可能让类型贴合实际实现的逻辑代码,从而后期重构的时候任何接口定义等类型上的改动,在相关的代码上都可以通过报错反应出来,保证重构的可靠性。

如何让类型尽量贴合实际实现,我们举个简单的例子:

现在有发送和接受消息的方法:

 
   
   
 
  1. declare function sendMessage(type: string, message: object): void

  2. declare function onMessage(type: string, message: object): void

最基础的类型定义就如上面代码,消息类型为字符串,消息携带的参数为一个对象。但是实际上处理的消息类型以及消息参数通常是有限的可以枚举的。比如这个消息类型可能为'init' 或者 'update',那像下面这样的拼写错误就没法被类型检查系统发现。

 
   
   
 
  1. sendMessage('udpate', {});

这个错误的原因是因为函数声明的类型范围比函数实际能处理的范围更广,所以导致一些错误的输入无法被处理最终产生 bug。

因此更好的方法是能够限制type的类型,并且定义第二个参数message对象的属性。

 
   
   
 
  1. type MessageType = 'init' | 'update'

  2. interface MessageParams {

  3. initData?: string; // init 时候要用到的数据

  4. updateData?: string; // update 时候要用到的数据

  5. }

  6. declare function sendMessage(type: MessageType, message: MessageParams): void

  7. declare function onMessage(type: MessageType, message: MessageParams): void

这样像刚才那样的拼写错误就在编译的时候就会被提前发现(实际上因为代码的自动补全很难再会有这样的拼写错误),而且message参数的属性也被限制了,调用的时候不能传任意的参数。

这样的类型已经可以避免出现大部分低级错误了,而且重构的时候(比如将initData和updateData属性改为了一个对象)也可以顺利的检查出所有没更新的使用代码。但是还是不够健壮,假如我们像下面这样在'init'消息中传入了一个updateData参数,类型系统就没法检查出来。

 
   
   
 
  1. sendMessage('init', { updateData: 'foo' });

只从这段代码来看大家可能会觉得这个从命名上就很容易看出来问题,应该使用initData而非updateData,但是有时候这个命名的区别并没有这么明显,或者这个消息参数是从别处传过来的,使用的时候无法确定里面是initData还是updateData。因此我们需要对参数类型以及对应的参数属性作进一步的约束。

我们可以用函数重载来实现不同参数类型的约束:

 
   
   
 
  1. interface InitMessageParams {

  2. initData?: string;

  3. }

  4. interface UpdateMessageParams {

  5. updateData?: string;

  6. }

  7. declare function sendMessage(type: 'update', message: UpdateMessageParams): void

  8. declare function sendMessage(type: 'init', message: InitMessageParams): void

  9. declare function onMessage(type: 'update', message: UpdateMessageParams): void

  10. declare function onMessage(type: 'init', message: InitMessageParams): void

更通用的方式是利用函数泛型的自动推导功能,在lib.dom.d.ts中对于addEventListener的类型定义就是这么做的。

 
   
   
 
  1. interface MessageParamsMap {

  2. init: InitMessageParams;

  3. update: UpdateMessageParams;

  4. }

  5. type MessageType = keyof MessageParamsMap;

  6. declare function sendMessage<T extends MessageType>(type: T, message: MessageParamsMap[T]): void

  7. declare function onMessage<T extends MessageType>(type: T, message: MessageParamsMap[T]): void

这里的类型参数T会根据根据你调用时候第一个参数传入的类型自动推导出来,推导出来是'init'还是'update',从而进一步索引出第二个参数message的类型。

 
   
   
 
  1. // 下面这行代码会报类型错误

  2. sendMessage('init', { updateData: 'foo' });

示例完整代码[1]

[1]: http://t.cn/A6czaaMa

这是一个比较常见和基础的例子,用来说明如何让类型更加健全。更多细节的建议会在后面类型编写建议段落中一一列出。

any 的使用

我们经常能看到对于TypeScript中要避免使用any的说法。但是这不是意味着我们看到有any的代码就觉得这个一定是洪水猛兽,就要批判一番。实际上any最大的坏处并不是当前这个变量失去了类型,而是其带来的传染性导致后续其它访问到这个变量的代码都可能会被自动推导成any,而且我们往往很难意识到这些相关的代码也失去了类型。比如下面这个例子

 
   
   
 
  1. declare const options: {

  2. [key: string]: any;

  3. };

  4. const value = options.value; // value 类型是 any

  5. const valueStr = value.toFixed(2); // valueStr 类型也是 any

因此如果一些基础的变量类型是any,那么上层使用这些变量的代码也都变成了any,而这个代码中其它部分的类型写得再精确也失去了其意义。

所以我们在使用any的时候一定要非常小心这个any是不是可能会被传染,把any限制在非常局部的自己了解并且能够控制的代码内。实际上更建议在所有要写any的地方,把这个any先改成unknown避免未知类型的传染。

是否需要经常做类型体操?

大家经常调侃 TypeScript 需要做类型体操去处理一些很复杂的类型,这些类型体操写起来的难度并不比逻辑代码小,有时候甚至更费脑子,那么我们是否会因为引入 TypeScript 所以经常需要做类型体操,导致开发成本反而变高。

首先类型体操往往存在于一些比较基础和底层的方法的类型定义中,这些方法或者模块往往面向的上层场景比较广,处理参数比较通用,使用也比较频繁,所以需要通过泛型,类型重载等复杂的方式让函数的输入和输出都拥有准确的类型,从而上层在使用这些方法或者模块的时候能够顺利推导出准确的类型。我们可以这么说,底层方法的类型越完备,上层业务逻辑代码就越不不需要操心类型。

而对于更多的业务逻辑代码中,类型往往是通过推导或者简单的声明得到,并不需要写太多复杂的类型。

但是有时候我们也要谨防底层写出太过复杂的类型体操代码,太过复杂的类型代码可能会导致报错信息很晦涩和难定位,这个时候需要大家自己权衡(TypeScript 版本升级也可能会改善报错信息)。

为已有的 JavaScript 项目添加类型

在我们将一个 JavaScript 重构成 TypeScript 的时候,需要按照从下至上的顺序依次将各模块改成 TypeScript 并且添加上完善的类型。也就是完成底层的通用模块的类型添加,然后再将上层的模块改造成类型。

这么做的原因主要也是因为刚才我们提到的any的传染性,假如我们是从上往下的方式给各个模块添加类型,上层使用到的底层模块因为还没添加类型,所以类型都是any,这样会导致上层模块类型都加上了,但是业务逻辑代码中推导得到的类型还都是any导致类型检查失去了作用。

原则:

  • 在给代码添加类型的过程中,如果碰到使用到的类,方法还没有添加类型,则需要优先给这些未添加类型的类和方法添加类型

  • 在添加类型的时候避免同步修改代码逻辑


为你推荐






欢迎自荐投稿,前端早读课等你来