首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

TypeScript 中高级应用与完美实践

当我们讨论 TypeScript 时,我们在讨论什么?

TypeScript 的定位

  • JavaScript 的超集
  • 编译期行为
  • 不引入额外开销
  • 不改变运行时行为
  • 始终与 ESMAScript 语言标准一致 (stage 3 语法)

TypeScript 中的 Decorator 较为特殊,为 Angular 团队和 TypeScript 团队交易的结果,有兴趣可自行搜索相关资料。而且近期 EcmaScript 规范中的 decorator 提案内容发生了剧烈变动,建议等此语法标准完全稳定后再在生产项目中使用。

本文只讨论图中蓝色部分。

类型的本质是契约

JSDoc 也能标注类型,为什么要用 TypeScript?

  • JSDoc 只是注释,其标注没有约束作用
  • TS 有—checkJs 选项,但不好用

TS 会自动推断函数返回值类型,为什么要多此一举标注出来?

  • 契约高于实现
  • 检查返回值是否写错
  • 写 return 时获得提醒

开始之前

几组 VSCode 快捷键

  • 代码补全?control + 空格 ctrl + 空格
  • 快速修复?command + . ctrl + .
  • 重构(重命名)?fn + f2 f2

一个网站

TypeScript Playground

初始化项目

自行配置

代码语言:javascript
复制
"compilerOptions":?{?????"esModuleInterop":?true,?????"allowSyntheticDefaultImports":?true,?????"noImplicitAny":?true,?????"strictNullChecks":?true,?????"noImplicitThis":?true,?????"moduleResolution":?"node"?}???

react 项目运行?create-react-app ${项目名} —scripts-version=react-scripts-ts

小试牛刀

&和 | 操作符

虽然在写法上,这两个操作符与位运算逻辑操作符相同。但在语义上,它们与位运算刚好相反。

位运算的表现:

代码语言:javascript
复制
1001?|?1010?=?1011????//?合并1?1001?&?1010?=?1000????//?只保留共有1?

在 TypeScript 中的表现:

代码语言:javascript
复制
interface?IA?{?????a:?string?????b:?number?}???type?TB?=?{?????b:?number?????c:?number[]?}???type?TC?=?IA?|?TB;????//?TC类型的变量的键只需包含ab或bc即可,当然也可以abc都有?type?TD?=?IA?&?TB;????//?TD类型的变量的键必需包含abc?

对于这种表现,可以这样理解:?&?表示必须同时满足多个契约,?|?表示满足任意一个契约即可。

interface 和 type 关键字

interface 和 type 两个关键字因为其功能比较接近,常常引起新手的疑问:应该在什么时候用 type,什么时候用 interface?

interface 的特点如下:

  • 同名 interface 自动聚合,也可以和已有的同名 class 聚合,适合做 polyfill
  • 自身只能表示 object/class/function 的类型

建议库的开发者所提供的公共 api 应该尽量用 interface/class,方便使用者自行扩展。举个例子,我之前在给腾讯云 Cloud Studio 在线编辑器开发插件时,因为查阅到的 monaco 文档是 0.15.5 版本(当时的最新版本)的,而 Cloud Studio 使用的 monaco 版本为 0.14.3,缺失了一些我需要的 API,所以需要手动 polyfill 一下。

代码语言:javascript
复制
/**??*?Cloud?Studio使用的monaco版本较老0.14.3,和官方文档相比缺失部分功能??*?另外vscode有一些特有的功能,必须适配??*?故在这里手动实现作为补充??*/?declare?module?monaco?{???interface?Position?{?????delta(deltaLineNumber?:?number,?deltaColumn?:?number):?Position???}?}???//?monaco?0.15.5?monaco.Position.prototype.delta?=?function?(this:?monaco.Position,?deltaLineNumber?=?0,?deltaColumn?=?0)?{???return?new?monaco.Position(this.lineNumber?+?deltaLineNumber,?this.column?+?deltaColumn);?}???

与 interface 相比,type 的特点如下:

  • 表达功能更强大,不局限于 object/class/function
  • 要扩展已有 type 需要创建新 type,不可以重名
  • 支持更复杂的类型操作
代码语言:javascript
复制
type?Tuple?=?[number,?string];?const?a:?Tuple?=?[2,?'sir'];?type?Size?=?'small'?|?'default'?|?'big'?|?number;?const?b:?Size?=?24;?

基本上所有用 interface 表达的类型都有其等价的 type 表达。但我在实践的过程中,也发现了一种类型只能用 interface 表达,无法用 type 表达,那就是往函数上挂载属性。

代码语言:javascript
复制
interface?FuncWithAttachment?{?????(param:?string):?boolean;?????someProperty:?number;?}???const?testFunc:?FuncWithAttachment?=?...;?const?result?=?testFunc('mike');????//?有类型提醒?testFunc.someProperty?=?3;????//?有类型提醒?

extends 关键字

extends 本意为 “拓展”,也有人称其为 “继承”。在 TypeScript 中,extends 既可当作一个动词来扩展已有类型;也可当作一个形容词来对类型进行条件限定(例如用在泛型中)。在扩展已有类型时,不可以进行类型冲突的覆盖操作。例如,基类型中键 a 为 string,在扩展出的类型中无法将其改为 number。

代码语言:javascript
复制
type?A?=?{?????a:?number?}???interface?AB?extends?A?{?????b:?string?}?//?与上一种等价?type?TAB?=?A?&?{?????b:?string?}?

泛型

在前文我们已经看到类型实际上可以进行一定的运算,要想写出的类型适用范围更广,不妨让它像函数一样可以接受参数。TS 的泛型便是起到这样的作用,你可以把它当作类型的参数。它和函数参数一样,可以有默认值。除此之外,还可以用 extends 对参数本身需要满足的条件进行限制。

在定义一个函数、type、interface、class 时,在名称后面加上<> 表示即接受类型参数。而在实际调用时,不一定需要手动传入类型参数,TS 往往能自行推断出来。在 TS 推断不准时,再手动传入参数来纠正。

代码语言:javascript
复制
//?定义?class?React.Component<P?=?{},?S?=?{},?SS?=?any>?{?...?}?interface?IShowConfig<P?extends?IShowProps>?{?...?}?//?调用?class?Modal?extends?React.Component<IModalProps,?IModalState>?{?...?}?

条件类型

除了与、或等基本逻辑,TS 的类型也支持条件运算,其语法与三目运算符相同,为?T extends U ? X : Y?。这里先举一个简单的例子。在后文中我们会看到很多复杂类型的实现都需要借助条件类型。

代码语言:javascript
复制
type?IsEqualType<A,?B>?=?A?extends?B???(B?extends?A???true?:?false)?:?false;?type?NumberEqualsToString?=?IsEqualType<number,?string>;???//?false?type?NumberEqualsToNumber?=?IsEqualType<number,?number>;????//?true?

环境 Ambient Modules

在实际应用开发时有一种场景,当前作用域下可以访问某个变量,但这个变量并不由开发者控制。例如通过 Script 标签直接引入的第三方库 CDN、一些宿主环境的 API 等。这个时候可以利用 TS 的环境声明功能,来告诉 TS 当前作用域可以访问这些变量,以获得类型提醒。

具体有两种方式,declare 和三斜线指令。

代码语言:javascript
复制
declare?const?IS_MOBILE?=?true;????//?编译后此行消失?const?wording?=?IS_MOBILE???'移动端'?:?'PC端';?

用三斜线指令可以一次性引入整个类型声明文件。

代码语言:javascript
复制
///?<reference?path="../typings/monaco.d.ts"?/>?const?range?=?new?monaco.Range(2,?3,?6,?7);?

深入类型系统

基本类型

基本类型,也可以理解为原子类型。包括 number、boolean、string、null、undefined、function、array、字面量(true,false,1,2,‘a’)等。它们无法再细分。

复合类型

TypeScript 的复合类型可以分为两类:?set?和?map?。set 是指一个无序的、无重复元素的集合。而 map 则和 JS 中的对象一样,是一些没有重复键的键值对。

代码语言:javascript
复制
//?set?type?Size?=?'small'?|?'default'?|?'big'?|?'large';?//?map?interface?IA?{?????a:?string?????b:?number?}?

复合类型间的转换

代码语言:javascript
复制
//?map?=>?set?type?IAKeys?=?keyof?IA;????//?'a'?|?'b'?type?IAValues?=?IA[keyof?IA];????//?string?|?number???//?set?=>?map?type?SizeMap?=?{?????[k?in?Size]:?number?}?//?等价于?type?SizeMap2?=?{?????small:?number?????default:?number?????big:?number?????large:?number?}?

map 上的操作

代码语言:javascript
复制
//?索引取值?type?SubA?=?IA['a'];????//?string???????//?属性修饰符?type?Person?=?{?????age:?number?????readonly?name:?string????//?只读属性,初始化时必须赋值?????nickname?:?string????//?可选属性,相当于?|?undefined?}?

映射类型和同态变换

在 TypeScript 中,有以下几种常见的映射类型。它们的共同点是只接受一个传入类型,生成的类型中 key 都来自于 keyof 传入的类型,value 都是传入类型的 value 的变种。

代码语言:javascript
复制
type?Partial<T>?=?{?[P?in?keyof?T]?:?T[P]?}????//?将一个map所有属性变为可选的?type?Required<T>?=?{?[P?in?keyof?T]-?:?T[P]?}????//?将一个map所有属性变为必选的?type?Readonly<T>?=?{?readonly?[P?in?keyof?T]:?T[P]?}????//?将一个map所有属性变为只读的?type?Mutable<T>?=?{?-readonly?[P?in?keyof?T]:?T[P]?}????//?ts标准库未包含,将一个map所有属性变为可写的?

此类变换,在 TS 中被称为同态变换。在进行同态变换时,TS 会先复制一遍传入参数的属性修饰符,再应用定义的变换。

代码语言:javascript
复制
interface?Fruit?{?????readonly?name:?string?????size:?number?}?type?PF?=?Partial<Fruit>;????//?PF.name既只读又可选,PF.size只可选?

其他常用工具类型

由 set 生成 map

代码语言:javascript
复制
type?Record<K?extends?keyof?any,?T>?=?{?[P?in?K]:?T?};???type?Size?=?'small'?|?'default'?|?'big';?/*?{?????small:?number?????default:?number?????big:?number?}??*/?type?SizeMap?=?Record<Size,?number>;?

保留 map 的一部分

代码语言:javascript
复制
type?Pick<T,?K?extends?keyof?T>?=?{?[P?in?K]:?T[P]?};?/*?{?????default:?number?????big:?number?}??*/?type?BiggerSizeMap?=?Pick<SizeMap,?'default'?|?'big'>;???

删除 map 的一部分

代码语言:javascript
复制
type?Omit<T,?K>?=?Pick<T,?Exclude<keyof?T,?K>>;?/*?{?????default:?number?}??*/?type?DefaultSizeMap?=?Omit<BiggerSizeMap,?'big'>;?

保留 set 的一部分

代码语言:javascript
复制
type?Extract<T,?U>?=?T?extends?U???T?:?never;???type?Result?=?1?|?2?|?3?|?'error'?|?'success';?type?StringResult?=?Extract<Result,?string>;????//?'error'?|?'success?

删除 set 的一部分

代码语言:javascript
复制
type?Exclude<T,?U>?=?T?extends?U???never?:?T;?type?NumericResult?=?Exclude<Result,?string>;????//?1?|?2?|?3?

获取函数返回值的类型。但要注意不要滥用这个工具类型,应该尽量多手动标注函数返回值类型。理由开篇时提过,?契约高于实现?。用 ReturnType 是由实现反推契约,而实现往往容易变且容易出错,契约则相对稳定。另一方面,ReturnType 过多也会降低代码可读性。

代码语言:javascript
复制
type?ReturnType<T>?=?T?extends?(...args:?any[])?=>?infer?R????R?:?any;???function?f()?{?return?{?a:?3,?b:?2};?}?/*?{?????a:?number?????b:?number?}??*/?type?FReturn?=?ReturnType<f>;?

以上这些工具类型都已经包含在了 TS 标准库中,在应用中直接输入名字进行使用即可。另外,在这些工具类型的实现中,出现了 infer、never、typeof 等关键字,在后文我会详细解释它们的作用。

类型的递归

TS 原生的 Readonly 只会限制一层写入操作,我们可以利用递归来实现深层次的 Readonly。但要注意,TS 对最大递归层数做了限制,最多递归 5 层。

代码语言:javascript
复制
type?DeepReadony<T>?=?{?????readonly?[P?in?keyof?T]:?DeepReadony<T[P]>?}???interface?SomeObject?{???a:?{?????b:?{???????c:?number;?????};???};?}???const?obj:?Readonly<SomeObject>?=?{?a:?{?b:?{?c:?2?}?}?};?obj.a.b.c?=?3;????//?TS不会报错???const?obj2:?DeepReadony<SomeObject>?=?{?a:?{?b:?{?c:?2?}?}?};?obj2.a.b.c?=?3;????//?Cannot?assign?to?'c'?because?it?is?a?read-only?property.?

never infer typeof 关键字

never?是?|?运算的幺元,即?x | never = x?。例如之前的 Exclude<Result, string> 运算过程如下:

infer?的作用是让 TypeScript 自己推断,并将推断的结果存储到一个临时名字中,并且只能用于 extends 语句中。它与泛型的区别在于,泛型是声明一个 “参数”,而 infer 是声明一个 “中间变量”。infer 我用得比较少,这里借用一下官方的示例。

代码语言:javascript
复制
type?Unpacked<T>?=?????T?extends?(infer?U)[]???U?:?????T?extends?(...args:?any[])?=>?infer?U???U?:?????T?extends?Promise<infer?U>???U?:?????T;???type?T0?=?Unpacked<string>;??//?string?type?T1?=?Unpacked<string[]>;??//?string?type?T2?=?Unpacked<()?=>?string>;??//?string?type?T3?=?Unpacked<Promise<string>>;??//?string?type?T4?=?Unpacked<Promise<string>[]>;??//?Promise<string>?type?T5?=?Unpacked<Unpacked<Promise<string>[]>>;??//?string?

typeof?用于获取一个 “常量” 的类型,这里的 “常量” 是指任何可以在编译期确定的东西,例如 const、function、class 等。它是从?实际运行代码?通向?类型系统?的单行道。理论上,任何运行时的符号名想要为类型系统所用,都要加上 typeof。但是 class 比较特殊不需要加,因为 ts 的 class 出现得比 js 早,现有的为兼容性解决方案。

在使用 class 时,?class 名?表示实例类型,?typeof class?表示 class 本身类型。没错,这个关键字和 js 的 typeof 关键字重名了 :)。

代码语言:javascript
复制
const?config?=?{?width:?2,?height:?2?};?function?getLength(str:?string)?{?return?str.length;?}???type?TConfig?=?typeof?config;????//?{?width:?number,?height:?number?}?type?TGetLength?=?typeof?getLength;????//?(str:?string)?=>?number?

实战演练

我在项目中遇到这样一种场景,需要获取一个类型中所有 value 为指定类型的 key。例如,已知某个 React 组件的 props 类型,我需要 “知道”(编程意义上)哪些参数是 function 类型。

代码语言:javascript
复制
interface?SomeProps?{?????a:?string?????b:?number?????c:?(e:?MouseEvent)?=>?void?????d:?(e:?TouchEvent)?=>?void?}?//?如何得到?'c'?|?'d'????

分析一下这里的思路,我们需要从一个 map 得到一个 set,而这个 set 是 map 的 key 的?子集,筛选子集的?条件?是 value 的类型。要构造 set 的子集,需要用到?never?;要实现条件判断,需要用到?extends?;而要实现 key 到 value 的访问,则需要索引取值。经过一些尝试后,解决方案如下。

代码语言:javascript
复制
type?GetKeyByValueType<T,?Condition>?=?{?????[K?in?keyof?T]:?T[K]?extends?Condition???K?:?never?}?[keyof?T];???type?FunctionPropNames?=??GetKeyByValueType<SomeProps,?Function>;????//?'c'?|?'d'?

这里的运算过程如下:

代码语言:javascript
复制
//?开始?{?????a:?string?????b:?number?????c:?(e:?MouseEvent)?=>?void?????d:?(e:?TouchEvent)?=>?void?}?//?第一步,条件映射?{?????a:?never?????b:?never?????c:?'c'?????d:?'d'?}?//?第二步,索引取值?never?|?never?|?'c'?|?'d'?//?never的性质?'c'?|?'d'?

编译提示 Compiler Hints

TypeScript 只发生在编译期,因此我们可以在代码中加入一些符号,来给予编译器一些提示,使其按我们要求的方式运行。

类型转换

类型转换的语法为?< 类型名> xxx?或?xxx as 类型名?。推荐始终用 as 语法,因为第一种语法无法在 tsx 文件使用,而且容易和泛型混淆。一般只有这几种场景需要使用类型转换:自动推断不准;TS 报错,想不出更好的类型编写方法,手动抄近路;临时 “放飞自我”。

在使用类型转换时,应该遵守几个原则:

  • 若要放松限制,只可放松到能运行的最严格类型上
  • 如果不知道一个变量的精确类型,只标注到大概类型(例如 any[])也比 any 好
  • 任何一段 “放飞自我”(完全没有类型覆盖)区代码不应超过 2 行,应在出现第一个可以确定类型的变量时就补上标注

在编写 TS 程序时,我们的目标是让类型覆盖率无限接近 100%。

! 断言

!?的作用是断言某个变量不会是 null / undefined,告诉编译器停止报错。这里由用户确保断言的正确。它和刚刚进入 EcmaScript 语法提案 stage 3 的 Optional Chaining 特性不同。Optional Chaining 特性可以保证访问的安全性,即使在 undefined 上访问某个键也不会抛出异常。而?!?只是消除编译器报错,不会对运行时行为造成任何影响。

代码语言:javascript
复制
//?TypeScript?mightBeUndefined!.a?=?2?//?编译为?mightBeUndefined.a?=?2?

// @ts-ignore

用于忽略下一行的报错,尽量少用。

其他

我为什么不提 enum

enum 在 TS 中出现的比较早,它引入了 JavaScript 没有的数据结构(编译成一个双向 map),入侵了运行时,与 TypeScript 宗旨不符。用 string literal union('small' | 'big' | 'large')可以做到相同的事,且在 debug 时可读性更好。如果很在意条件比较的性能,应该用二进制 flag 加位运算。

代码语言:javascript
复制
//?TypeScript?enum?Size?{?????small?=?3,?????big,?????large?}?const?a:Size?=?Size.large;????//?5???//?编译为?var?Size;?(function?(Size)?{?????Size[Size["small"]?=?3]?=?"small";?????Size[Size["big"]?=?4]?=?"big";?????Size[Size["large"]?=?5]?=?"large";?})(Size?||?(Size?=?{}));?const?a?=?Size.large;?//?5?

写在最后

应该以什么心态来编写 TypeScript

我们应该编写有类型系统的 JavaScript,而不是能编译成 JavaScript 的 Java/C#。任何一个 TypeScript 程序,在手动删去类型部分,将后缀改成 .js 后,都应能够正常运行。

  • 发表于:
  • 原文链接http://news.51cto.com/art/201908/600625.htm
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券
http://www.vxiaotou.com