49

TypeScript 中高级应用与最佳实践

 4 years ago
source link: https://www.tuicool.com/articles/FvYfEnz
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

TypeScript 的定位

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

7NF773r.png!web

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

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

类型的本质是契约

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

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

QbMBvay.png!web

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

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

IVjMVj3.png!web

开始之前

几组 VSCode 快捷键

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

一个网站

TypeScript Playground

初始化项目

自行配置

"compilerOptions": {
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "moduleResolution": "node"
}
 

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

小试牛刀

&和 | 操作符

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

位运算的表现:

1001 | 1010 = 1011    // 合并1
1001 & 1010 = 1000    // 只保留共有1

在 TypeScript 中的表现:

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 一下。

/**
 * 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,不可以重名
  • 支持更复杂的类型操作
type Tuple = [number, string];
const a: Tuple = [2, 'sir'];
type Size = 'small' | 'default' | 'big' | number;
const b: Size = 24;

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

interface FuncWithAttachment {
    (param: string): boolean;
    someProperty: number;
}
 
const testFunc: FuncWithAttachment = ...;
const result = testFunc('mike');    // 有类型提醒
testFunc.someProperty = 3;    // 有类型提醒

extends 关键字

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

type A = {
    a: number
}
 
interface AB extends A {
    b: string
}
// 与上一种等价
type TAB = A & {
    b: string
}

泛型

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

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

// 定义
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 。这里先举一个简单的例子。在后文中我们会看到很多复杂类型的实现都需要借助条件类型。

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 和三斜线指令。

declare const IS_MOBILE = true;    // 编译后此行消失
const wording = IS_MOBILE ? '移动端' : 'PC端';

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

/// <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 的复合类型可以分为两类: setmap 。set 是指一个无序的、无重复元素的集合。而 map 则和 JS 中的对象一样,是一些没有重复键的键值对。

// set
type Size = 'small' | 'default' | 'big' | 'large';
// map
interface IA {
    a: string
    b: number
}

复合类型间的转换

// 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 上的操作

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

映射类型和同态变换

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

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 会先复制一遍传入参数的属性修饰符,再应用定义的变换。

interface Fruit {
    readonly name: string
    size: number
}
type PF = Partial<Fruit>;    // PF.name既只读又可选,PF.size只可选

其他常用工具类型

由 set 生成 map

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 的一部分

type Pick<T, K extends keyof T> = { [P in K]: T[P] };
/*
{
    default: number
    big: number
}
 */
type BiggerSizeMap = Pick<SizeMap, 'default' | 'big'>;
 

删除 map 的一部分

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
/*
{
    default: number
}
 */
type DefaultSizeMap = Omit<BiggerSizeMap, 'big'>;

保留 set 的一部分

type Extract<T, U> = T extends U ? T : never;
 
type Result = 1 | 2 | 3 | 'error' | 'success';
type StringResult = Extract<Result, string>;    // 'error' | 'success

删除 set 的一部分

type Exclude<T, U> = T extends U ? never : T;
type NumericResult = Exclude<Result, string>;    // 1 | 2 | 3

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

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 层。

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> 运算过程如下:

yUR3umz.png!web

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

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 关键字重名了 :)。

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 类型。

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 的访问,则需要索引取值。经过一些尝试后,解决方案如下。

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

这里的运算过程如下:

// 开始
{
    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 只发生在编译期,因此我们可以在代码中加入一些符号,来给予编译器一些提示,使其按我们要求的方式运行。

类型转换

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

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

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

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

! 断言

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

// TypeScript
mightBeUndefined!.a = 2
// 编译为
mightBeUndefined.a = 2

// @ts-ignore

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

其他

我为什么不提 enum

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

// 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 后,都应能够正常运行。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK