luokuning / blogs

翻译,随笔,以及懒得整理……
81 stars 2 forks source link

探讨 TypeScript 中的一些概念 #15

Open luokuning opened 4 years ago

luokuning commented 4 years ago

TLDR: 这篇文章并不是关于 TypeScript 的入门教程,而是总结我在学习 TypeScript 过程中遇到的一些疑问,和整理一些并不是那么显而易见的概念。如果你在看完 TypeScript 官方文档或者写过不少项目后,还是觉得对 TypeScript 整体有些不甚清晰的点,那么这篇文章或许对你有所帮助。

什么是类型 (Type)?

别误会,我并不是要介绍 TypeScript 中的一些基础语法或者基础类型,这些我相信你在学习 TypeScript 第一天的时候就已经烂熟于胸了。比如我们都知道字符串的类型是 string、 在该用到数字的时候应该限制类型为 number=== 运算符的返回值是 boolean,对类型的理解和使用似乎已经成为了一种本能,但是我还是想给什么是类型下个统一的定义:类型就是一系列值以及你可以对这些值做的事情。比如我们刚刚说的 number 类型其实就是所有的数字加上所有你能对数字做的操作,比如 +, -, *, / 操作符以及能对数字类型调用的方法。

这里可能有点咬文嚼字了,但是通过对类型下一个定义,或者说用一种接近本质的角度来看待类型,能让人在每次看到它们时,有一种更深刻的理解,至少对我是这样。

{}, object, Object 有什么区别?

首先,这三种类型都表示你的值是一个没有任何自定义属性的对象,只从 Object.prototype 继承了基本的方法。意味着 TypeScript 会有以下限制:

let user: object = { name: 'lk' }
user.toString() // correct
user.name // error: Property 'name' does not exist on type 'object'.(2339)

另一方面,如果你之前不了解 {}, object, Object 分别代表哪些值 (回想一下我们上面对类型的定义),下面这段代码可能会让你感觉相当困惑:

let title: {}
title = {} // correct
title = [] // correct
title = 123 // correct

let content: object
content = {} // correct
content = [] // correct
content = 123 // error: Type '123' is not assignable to type 'object'.ts(2322)

title 不是一个空对象吗?为什么可以被赋值成一个数字或者数组?content = 123 的报错似乎很明显,但是为什么 content = [] 又不会报错?

我们知道 JavaScript 中有 7 种原始类型 (primitive type):

  1. string
  2. boolean
  3. number
  4. bigint
  5. symbol
  6. null
  7. undefined

除此之外的类型都称为非原始类型 (non-primitive type),而 object (TypeScript v2.2 新加入的类型)就是用来表示这些非原始类型的。也就是说,如果一个变量的值是 object 类型,那么它可以是任何非原始类型值,比如上面的空对象和空数组,但是不能是原始类型值,比如 123

{} 类型不仅包含非原始类型,还包含除 null | undefined 之外的其他原始类型,这也是为什么把 123[] 赋值给 {} 类型都不会报错。清楚了每个类型所包含的值的范围,也就很好理解上面的代码为什么会有这样的差异了。至于 Object 的话,在行为上跟 {} 基本上是一样的。

新增的 object 类型在某些情况下是有用的,比如用来限定 Object.create 方法的参数类型:

interface ObjectConstructor {
  // ...
  create(o: object | null): any;
  // ...
}

什么时候用 type alias?什么时候用 Interface?

type alias 和 interface 在很多时候都可以相互替换使用,具体什么情况该用哪一个并没有强制的要求。相比直接提供一些使用的建议,我觉得把两者主要的差异点先列出来也许更有必要:

  1. 同一个作用域中同名的 interface 会合并声明 (declaration merging),而同一个作用域同名的 type alias 会报错;
  2. type alias 的右值可以是任何类型,包括原始类型 (比如 string, number) 和类型表达式,interface 只能是对象类型 (shape);
  3. interface 可以继承 (extends) 其他 shape 类型;

注意:上面有提到一个 shape 类型,其实就是非原始类型 object。很多人会误以为 interface 只能继承其他 interface、class 只能 implements interface,但实际上可以 extends 或者 implements 其他任何 shape 类型。

了解了这几个重要的差异之后,我们再回到 type alias 和 interface 的使用场景。一般来讲,使用哪种更多的是个人偏好,不过 type alias 似乎比 interface 要简洁通用一些 (type alias 支持类型表达式比如条件判断)。而如果你准备编写一个公共库,可能还需要仔细考虑库中定义的类型是否允许使用者扩展 (declaration merging)。

有哪些地方可以定义泛型参数?

回忆一下我们用到泛型最多的情况应该是在函数中:

function arrayify<T>(data: T): T[] {
  return [data]
}

这算的上是最简单的使用泛型的场景了。我们除了可以在函数里使用泛型参数来设置约定,还有以下几个场景可以用到泛型:

  1. class Arrayify<T> {}
  2. type Arrayify = <T>(data: T) => T[]
  3. type Arrayify<T> = (data: T) => T[]
  4. interface Arrayify { <T>(data: T): T[] }
  5. interface Arrayify<T> { (data: T): T[] }

例 1 很简单,class 在 JavaScript 中本质上还是函数,所以泛型的使用跟普通函数一致。2、 3 一眼看上去非常类似,只是泛型定义的位置不同。2 中的泛型参数定义在调用签名 (call signature) 前面,而 3 的泛型参数紧跟在 type alias 后面。就这个 Arrayify 例子而言,虽然泛型位置不同,但是 2 跟 3 的效果是一样的,那么这两种定义方式有什么区别?

简单来讲,泛型定义的位置决定了它涵盖的作用域。再举个例子:

type Arrayify = {
  <T>(data: T): T[]
  customProp: string
}

type Arrayify<T> = {
  (data: T): T[]
  customProp: T
}

这个例子应该是非常清晰的,定义在调用签名签名的泛型参数只能用在单个调用签名中,而定义在 type alias 后面的泛型参数可以用在整个 type 中。

例 4、5 没有细讲,是因为 interface 在使用泛型的情况下跟 type alias 是类似的,大家自行脑补就好。

使用泛型时为什么有的时候我不用提供具体的类型,而有时候必须要提供?

以上面的 arrayify 函数举例:

const stringArr = arrayify('hello') // string[]
const ageArr = arrayify({ age: 100 }) // {age: number}[]

我们调用 arrayify 时传入了不同类型的参数,但是并没有显示指定泛型参数 T 的具体类型,这是因为 TypeScript 会根据函数参数来推断泛型的具体类型

再来看看上面的 Arrayify:

type Arrayify = <T>(data: T) => T[]
type StringArr = Arrayify // correct

上面的代码很好理解,只是把 Arrayify 赋值给了另一个 type alias StringArr,这俩是等价的。我们再试试当 Arrayify 的泛型参数是声明在 type alias 后面的情况:

type Arrayify<T> = (data: T) => T[]
type StringArr = Arrayify // error: Generic type 'Arrayify' requires 1 type argument(s).(2314)
type NumberArr = Arrayify<number> // correct

TypeScript 并不允许我们在不提供泛型参数值的情况下直接把 Arrayify 赋值给 StringArr。回想一下上一个问题中我有提到过:“简单来讲,泛型定义的位置决定了它涵盖的作用域”,注意只是“简单来讲”,还没说完呢,实际上泛型定义的位置不仅决定了它涵盖的作用域,还决定了 TypeScript 什么时候会给泛型参数赋予具体类型。如果泛型参数声明在调用签名前,表示函数调用的时候会决定好泛型的具体类型 (我们可以手动指定,也可以让 TypeScript 根据函数参数来推断)。而如果是直接定义在 type alias 后面的泛型参数,那么在使用这个 type alias 时我们必须要手动指定明确的具体类型,因为这种情况 TypeScript 无法帮我们推断出泛型的具体类型。

关于 Arrayify<number> 还有一点要说的是,可以把 Arrayify 理解成一个“函数”,Arrayify<number> 理解成函数调用。每次“调用” Arrayify 时,会生成一个新的、泛型参数绑定到我们传入类型的 type alias。

为什么在构造 Promise 实例时需要明确指定泛型参数的具体类型?

我们先来构造一个简单的 Promise 实例:

let promise = new Promise(resolve => resolve(45))
promise.then(value => value * 2) // error: Object is of type 'unknown'.(2571)

上面这段代码没有类型信息,只是一段普通的 JavaScript 代码,但是在 TypeScript 环境中执行到 value * 2 时却报错了。

要知道具体原因的话我们得看看 Promise 构造函数本身具体的定义是怎样的:

interface PromiseConstructor {
  // ...
  new <T>(executor: (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;  
  // ...
}

上面的类型定义来自 TypeScript 内置的 lib.es2015.promise.d.ts 文件,我们省略了一些实例方法,只关注构造函数。可以看到 Promise 构造函数有一个泛型参数,回忆下在上一问题中我们说过:TypeScript 通过函数参数来推断其泛型参数,那么 new Promise(resolve => resolve(45)) 显然是不能提供足够的信息来帮助 TypeScript 推断出泛型参数具体类型的,因为resolve(45) 是函数体中的表达式。

既然 TypeScript 无法从参数里推断出泛型的具体类型,我们在 new 表达式中也没有为泛型指定具体类型,那么 T 的具体类型应该是什么?在 TypeScript v3.7.2 中,T 绑定到了 unknown, 而我测试在 v3.4.4 中 T 则被绑定到了 {}。无论是 unknown 还是 {},TypeScript 都不会让我们执行 value * 2,因为 * 操作数只能是数字类型的 (实际上可以是 any, number, bigint 或者 enum type)。

所以我们必须给 Promise 中的 T 明确指定一个具体类型:

let promise = new Promise<number>(resolve => resolve(45))
// or
let promise: Promise<number> = new Promise(resolve => resolve(45))

如何理解泛型参数运算符 extends?

顾名思义,extends 表示继承,出现最多的场景也是类的继承,比如 class Dog extends Animal。在 TypeScript 中,extends 关键字不仅可以用来继承类,还能用来操作泛型参数:

type UserProp = 'name' | 'gender'

function generateArr<T extends UserProp>(prop: T): T[] {
  return [prop]
}

那这里怎么理解这个 extends 比较合适?T 继承 UserProp?似乎有点不是很直观,其实这里指的是,T 必须是 UserProp 本身或者其子类型,是对 T 的一种限制 (constraint),有种类继承反过来的意思。

因为 T 必须是 UserProp 的子类型,那么形参 prop 必须是 name 或者 gender

d.ts 跟普通的 .ts 文件有什么区别?

.ts 文件不用多说,就是存储 TypeScript 常规代码的文件扩展名。那么 d.ts 文件又是用来做什么的?其实是用来给 JavaScript 代码添加类型的。TypeScript 允许我们在 .ts 里导入外部的 JavaScript 模块,但是 JavaScript 本身并没有 type 信息,意味着 TypeScript 没办法帮我们安全地校验模块的使用方式,所以需要有一种方式来给 JavaScript 代码添加类型信息。为了搞清楚应该如何给 JavaScript 添加类型信息,我们可以利用 TypeScript 在编译 .ts 文件的时候能够自动生成对应 d.ts 文件的特性看看 d.ts 文件的具体内容。

假设我们要编译的模块是 util.ts:

const greetingLevel = 1
export function greeting(name: string) {
  return `hello ${name}`
}

tsconfig.json 中,开启编译选项 declaration,告诉 TypeScript 在将 util.ts 编译成 JavaScript 代码时,自动生成 d.ts 文件,里面包含对应的类型信息:

{
  "compilerOptions": {
    // ...
    "target": "es2015",
    "module": "esnext",
    "declaration": true,
    // ...
  }
}

在执行 tsc 之后,我们可以看到 util.ts 同级目录中会多出两个文件:util.jsutil.d.tsutil.jsutil.ts 编译后的 JavaScript 代码,而 util.d.ts 就是 util.js 对应的类型信息。

打开 util.d.ts 可以看到里面的内容如下:

export declare function greeting(name: string): string;

语法看起来跟普通的 TypeScript 代码一样,不过只有类型,没有值。可以理解为 util.d.ts 就是 util.ts 的代码减去值,只保留了类型信息。utils.ts 中除了 greeting 函数外还声明了一个模块内的局部变量 greetingLevel,但是这个变量并没有出现在 util.d.ts 中。这是因为 util.ts模块模式 (module mode),所以对应的 d.ts 只会包含模块导入导出的类型信息,而局部变量的类型会被省略。这很好理解,毕竟模块的局部变量无法在模块外访问,所以自然也没有必要把局部变量的类型信息包含在 d.ts 中。我们还注意到 export 后面出现了一个新的关键字 declaredeclare 用来表示一个断言:在相应的 JavaScript 模块中,一定导出了一个函数 greeting,它的类型是 (name: string) => string。注意只有在编写类型信息时才会用到 declare 关键字。

这里总结一下,d.ts 是用来给 JavaScript 添加类型信息的,所以我们能够在 TypeScript 项目中安全的使用 JavaScript 模块。如果项目都是 TypeScript 代码,那么基本上不会用到 d.ts 文件,因为 .ts 文件本身就包含类型。但是如果我们某些依赖的模块是用 JavaScript 写的,并且没有对应的 d.tsDefinitelyTyped 中也没有第三方贡献的 type 模块,这个时候可能需要我们自己在项目中新建一个 d.ts 文件,为这些 JavaScript 模块增加相应的类型。

什么是三斜杠指令 (Triple slash directive)?什么时候我该用它们?

三斜杠指令只是一种特殊的 JavaScript 注释,以 /// 开头,紧接着一个 XML 标签,比如 <reference lib="dom" />。三斜杠指令一般用来为某一文件制定特殊的编译选项,或者指示某一文件依赖其他文件。还有一类是跟 AMD 模块相关的指令,比如 <amd-module name="CustomModuleName" />

那么你什么时候会用它们?你不用。是的,你几乎永远都不会用到三斜杠指令,所以这里也不再讨论每个指令的具体含义。反过来讲,如果你发现自己好像必须要用到某个指令,最好想想目前的编码模式是不是有问题。

总结

TypeScript 出现的目的是给 JavaScript 附加一套完整的类型系统,但是早期的 JavaScript 并没有完备的规范,所以对一些问题衍生出了很多不同的解决方案,比如模块系统。TypeScript 为了兼容现有的 JavaScript 生态,使自身也增加了很多除普通类型系统之外的东西,例如兼容各类模块规范、给 JavaScript 代码编写类型、扩展模块类型信息等等。正是因为 TypeScript 在语法和使用方面细节很多,官方文档难免会有些遗漏,所以希望这篇文章能做到一个不错的补充。