TypeScript 核心概念梳理

avatar
阿里巴巴集团 @大淘宝技术,服务9亿用户,赋能各行业1000万商家,作为核心技术团队保障14次双十一购物狂欢节成功

8月20日,TypeScript 4.0 正式发布了( Announcing TypeScript 4.0 ),虽然没有重大的变更和特性,可以看做是 3.9 版本正常迭代,不过 Daniel 也在公告中说了:对于初学者而言,现在是最好的上手时机。

In fact, if you’re new to the language, now is the best time to start using it.

确实 TS 在经过了几年的发展后,使用 TS 的团队也越来越多,更重要的是 TS 的生态越来越完备,非常多的库、框架等都支持了类型系统甚至直接用 TS 重写,现在开始使用 TS 就能够直接享受整个技术生态带来的开发效率提升。回归到业务,我们团队最近也确实在开始用 TS 来进行类库的开发,所以结合官方文档和社区文档并从新人学习的角度梳理了一份包含大部分 TS 核心概念的学习手册。本文主要是做减法,梳理出核心的点,能够先用起来,然后按工作需要找一些点逐个进行深入学习。

背景

因为日常工作会使用页面搭建系统来生成很多前端页面,所以会开发很多的楼层模块配合搭建系统使用,最近模块是基于 Rax 开发的,后续会支持越来越多的投放渠道:web、weex、淘宝小程序、支付宝小程序等等,为了兼容约来越多的渠道,很多功能被抽象成了一个个小的类库,在类库中去兼容各个渠道,从而让模块中的业务代码保持尽量的只有清晰的业务逻辑。

但是随着支持渠道的增多,不可避免的导致类库在不同的渠道支持的特性不一致,比如 web 中,类库A 支持三个参数甲、乙、丙,而在小程序中类库A 仅支持两个参数甲、乙,所以丙这个参数要设计成可选参数,在类似的场景变多之后,这些类库的文档说明成了很重要的工作,同时在 IDE 中写代码时如果有类型系统能自动告诉开发人员这个函数支持哪些参数就更好了,所以我们准备对类库进行 TS 的重写来提高我们的生产效率,也为后续 TS 在团队内的落地打一个好基础。

什么是 TypeScript

  • 简单的说 TypeScript 是 JavaScript 一个超集,能够编译成 JavaScript 代码

  • 其核心能力是在代码编写过程中提供了类型支持,以及在编译过程中进行类型校验

先说一下 JS 的现状:

1、在 JS 中的变量本身是没有类型,变量可以接受任意不同类型的值,同时可以访问任意属性,属性不存在无非是返回 undefined

2、JS 也是有类型的,但是 JS 的类型是和值绑定的,是值的类型,用 typeof 判断变量类型其实是判断当前值的类型

// JavaScript

var a = 123
typeof a // "number"
a = 'sdf'
typeof a // "string"
a = { name: 'Tom' }
a = function () {
  return true
}
a.xxx // undefined

TS 做的事情就是给变量加上类型限制

1、限制在变量赋值的时候必须提供类型匹配的值

2、限制变量只能访问所绑定的类型中存在的属性和方法

举个简单的例子,如下是一段能够正常执行的 JS 代码:

let a = 100
if (a.length !== undefined) {
  console.log(a.length)
} else {
  console.log('no length')
}

直接用 TS 来重写上面的代码,把变量 a 的类型设置为 number

在 TS 中给变量设置类型的语法是 【 : Type 】 类型注解

let a: number = 100
if (a.length !== undefined) { // error TS2339: Property 'length' does not exist on type 'number'.
  console.log(a.length)
} else {
  console.log('no length')
}

但是如果直接对这个 TS 代码进行编译会报错,因为当变量被限制了类型之后,就无法访问该类型中不存在的属性或方法。

那再来写一段能正常执行的 TS

let a: string = 'hello'
console.log(a.length)

编译成 JS 后的代码为

var a = 'hello'
console.log(a.length)

可以发现 : string 这个类型限制编译之后是不存在的,只在编译时进行类型校验。

当 TS 源码最终被编译成 JS 后,是不会产生任何类型代码的,所以在运行时自然也不存在类型校验。

也就是说,假设一个项目,用 TS 来写,哼哧哼哧加上各种类型检验,项目测试通过部署到线上之后

最后运行在客户端的代码和我直接用 JS 来写的代码是一样的,写了很多额外的类型代码,竟然是为了保证能顺利编译成原来的代码

TypeScript 的作用

那 TS 的作用究竟是什么呢,主要是以下三点:

1、将类型系统看作为文档,在代码结构相对复杂的场景中比较适用,本质上就是良好的注释。

2、配合 IDE,有更好的代码自动补全功能。

3、配合 IDE,在代码编写的过程中就能进行一些代码校验。例如在一些 if 内部的类型错误,JS 需要执行到了对应代码才能发现错误,而 TS 在写代码的过程中就能发现部分错误,代码交付质量相对高一些,不过对于逻辑错误,TS 当然也是无法识别的。

TypeScript 类型梳理

分两类来介绍 TS 的类型系统:

1、JS 中现有的值类型在 TS 中对应如何去限制变量

2、TS 中拓展的类型,这些类型同样只在编译时存在,编译之后运行时所赋的值其实也是 JS 现有的值类型

下文中会穿插一些类似 [ xx ] 这样的标题,这是在列举介绍 TS 类型的过程中插入介绍的 TS 概念

JS 中现有的值类型如何绑定到变量

  • 使用语法:类型注解【 : Type 】

布尔值

let isDone: boolean = false

数值

let age: number = 18

字符串

let name: string = 'jiangmo'

空值

function alertName(): void { // 用 : void 来表示函数没有返回值
  alert('My name is Tom')
}

Null 和 Undefined

let u: undefined = undefined
let n: null = null
// 注意:和所有静态类型的语言一样,TS 中不同类型的变量也无法相互赋值
age = isDone // error TS2322: Type 'false' is not assignable to type 'number'.
// 但是因为 undefined 和 null 是所有类型的子类型,所以可以赋值给任意类型的变量
age = n // ok

[ 类型推论 ]

  • 如果没有明确的指定类型,那么 TypeScript 会依照类型推论的规则推断出一个类型

例如:定义变量的时候同时进行赋值,那么 TS 会自动推断出变量类型,无需类型注解

let age = 18
// 等价于
let age: number = 18
// 所以上面代码中的类型声明其实都可以省略
// 但是如果定义的时候没有赋值,不管之后有没有赋值,则这个变量完全不会被类型检查(被推断成了 any 类型)
let x
x = 'seven'
x = 7
// 所以这个时候应该显示的声明类型
let x: number
x = 7

继续列举类型

数组的类型

  • 语法是 【 Type[] 】
let nameList: string[] = ['Tom', 'Jerry']
let ageList: number[] = [5, 6, 20]

对象的类型

  • 接口 (interface) 用于描述对象的类型
interface Person { // 自定义的类型名称,一般首字母大写
  name: string
  age: number
}
let tom: Person = {
  name: 'Tom',
  age: 25,
}

函数的类型

  • 以函数表达式为例 ( 函数声明定义的函数也是用类似的 参数注解 语法来进行类型约束 )
// JavaScript

const sum = function (x, y) {
  return x + y
}

TS 中有多种语法来定义函数类型

  • 直接约束出入参类型
const sum = function (x: number, y: number): number {
  return x + y
}
  • 单独给 sum 变量设置类型
const sum: (x: number, y: number) => number = function (x, y) {
  return x + y
}

这里如果把函数类型直接提取出来用并起一个自定义的类型名,代码会更美观,也易复用。

利用 类型别名 可以给 TS 类型重命名

[ 类型别名 ]

  • 类型别名的语法是 【 type 自定义的类型名称 = Type 】
type MySum = (x: number, y: number) => number
const sum: MySum = function (x, y) {
  return x + y
}

回到函数类型

1、用接口定义函数的类型

interface MySum {
  (a: number, b: number): number
}
const sum: MySum = function (x, y) {
  return x + y
}

函数类型介绍完了,最后额外补充一下函数类型怎么 定义剩余参数的类型 以及 如何设置默认参数。

const sum = function (x: number = 1, y: number = 2, ...args: number[]): number {
  return x + y
}

类的类型

  • 和函数类型的语法相似,直接在 ES6 语法中用【 : Type 】类型注解 和 参数注解 语法给类的属性和方法设置类型
class Animal {
  name: string // 这一行表示声明实例属性 name
  constructor(name: string) {
    this.name = name
  }
  sayHi(): string {
    return `My name is ${this.name}`
  }
}
let a: Animal = new Animal('Jack') // : Animal 约束了变量 a 必须是 Animal 类的实例
console.log(a.sayHi()) // My name is Jack

顺便值得一提的是,除了类型支持以外,TS 也拓展了 class 的语法特性

新增了三种访问修饰符 public、 private、 protected 和只读属性关键字 readonly 以及 abstract 抽象类

这里就不展开了,有需要的再去查阅一下官方文档即可

内置对象和内置方法

JavaScript 中有很多内置对象和工具函数,TS 自带其对应的类型定义

  • 很多内置对象可以直接在 TypeScript 中当做定义好了的类型来使用
let e: Error = new Error('Error occurred')
let d: Date = new Date()
let r: RegExp = /[a-z]/
let body: HTMLElement = document.body
  • 一些内置的方法,TS 也补充了类型定义,配合 IDE 在编写代码的时候也能得到 TS 的参数提示。
Math.pow(2, '3') // error TS2345: Argument of type '"3"' is not assignable to parameter of type 'number'.

TS 中拓展的类型

任意值 any

与其说 any 是 JS 中不存在的类型,不如说原本 JS 中的变量只有一个类型就是 any

任意值 any 的特点:

any 类型的变量可以赋值给任何别的类型,这一点和 null 与 undefined 相同任何类型都可以赋值给 any 类型的变量

在任意值上访问任何属性都是允许的

let a: any = 123
a = '123' // ok
let n: number[] = a // ok
a.foo && a.foo() // ok

所以 any 是万金油,也是和 TS 进行类型约束的目的是相违背的,要尽量避免使用 any。

联合类型

  • 类型中的或操作,在列出的类型里满足其中一个即可
let x: string | number = 1
x = '1'

不过联合类型有一个额外约束:

当 TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型里共有的属性或方法。

let x: string | number = 1
x = '1'
x.length // 这里能访问到 length ,因为 TS 能确定此时 x 是 string 类型
// 下面这个例子就会报错
function getLength(something: string | number): number {
  return something.length // error TS2339: Property 'length' does not exist on type 'string | number'.
}

两种解决思路

  • 让 TS 能够自行推断出具体类型
function getLength(something: string | number): number {
  if (typeof something === 'string') { // TS 能识别 typeof 语句
    return something.length // 所以在这个 if 分支里, something 的类型被推断为 string
  } else {
    return 0
  }
}

利用 类型断言,手动强制修改现有类型

function getLength(something: string | number): number {
  return (something as string).length // 不过这样做实际上代码是有问题的,所以用断言的时候要小心
}

[ 类型断言 ]

  • 用来手动指定一个值的类型,语法 【 value as Type 】

用类型断言修改类型时的限制:

1、联合类型可以被断言为其中一个类型

2、父类可以被断言为子类

3、任何类型都可以被断言为 any

4、any 可以被断言为任何类型

总结成一条规律就是:要使得 A 能够被断言为 B,只需要 A 兼容 B 或 B 兼容 A 即可。

双重断言

  • 利用利用上述 3 和 4 两条规则,可以强制把一个值改为任意其他类型
let a = 3
(a as any) as string).split // ok

如果说断言有风险,那双重断言就是在反复横跳了

字符串字面量类型

用来约束取值只能是某几个字符串中的一个

type EventNames = 'click' | 'scroll' | 'mousemove'
function handleEvent(ele: Element, event: EventNames) {
  // do something
}

注意,只有一个字符串也是字符串字面量类型

type MyType = 'hello'

虽然一般不会手动设置这样的类型,不过类型推论经常会推断出这种类型。

比如某次编译报错提示为:Argument of type '"foo"' is not assignable to parameter of type 'number'.

提示中的 type '"foo"' 一般就是根据字符串 'foo' 推断出来的字符串字面量类型。

元组

  • 类似 Python 中的元组,可以看做是固定长度和元素类型的数组
let man: [string, number] = ['Tom', 25]

// 不过 TS 中的元组支持越界
// 当添加越界的元素时,它的类型会被限制为元组中每个类型的联合类型
man.push('male')

枚举

  • 用于取值被限定在一定范围内的场景,可以替代 JS 中用字面量来定义一个对象作为字典的场景
enum Directions {
  Up,
  Down,
  Left,
  Right,
}
let d: Directions = Directions.Left

这里看到 Directions.Left 直接把类型当做一个值来用了。

不是说类型是用于【 : Type 】类型注解 语法来约束变量,编译之后类型代码都会被删除吗?

为了解释这个问题,我们先来来看看单纯的类型代码会被编译成什么。

  • 首先以一个联合类型举例
type MyType = string | number | boolean

编译结果:

// 不会产生任何 JS 代码
  • 再来看看枚举类型会被编译成什么
enum Directions {
  Up,
  Down,
  Left,
  Right,
}
console.log(Directions)

编译结果:

var Directions
;(function (Directions) {
  Directions[(Directions['Up'] = 0)] = 'Up'
  Directions[(Directions['Down'] = 1)] = 'Down'
  Directions[(Directions['Left'] = 2)] = 'Left'
  Directions[(Directions['Right'] = 3)] = 'Right'
})(Directions || (Directions = {}))
console.log(Directions)
/*
  运行时 log 出来的 Directions 变量如下
  { 
    '0': 'Up',
    '1': 'Down',
    '2': 'Left',
    '3': 'Right',
    Up: 0,
    Down: 1,
    Left: 2,
    Right: 3 
  }
*/

这怎么理解呢?

let d: Directions = Directions.Left

其实这一行代码中,前一个 Directions 表示类型,后一个 Directions 表示值。

即 Directions 是一个值和类型的“复合体”,在不同的语法中具象化为值或者类型。

其实有办法可以把类型部分从 Directions 中抽离出来。

enum Directions {
  Up,
  Down,
  Left,
  Right,
}
type MyDirections = Directions
console.log(MyDirections) // error TS2693: 'MyDirections' only refers to a type, but is being used as a value here.

此时 MyDirections 就是一个纯粹的类型,不能当做一个值来使用。

其实之前介绍的函数类型、类类型等声明中,也存在这样的值与类型的“复合体”

const sum = function (x: number, y: number = 5): number {
  return x + y
}
console.log(sum) // [Function: sum]
type MySum = typeof sum // 注意,剥离出来的函数类型是不会带有默认参数的,因为默认参数其实是函数的特性,和类型系统无关
const f: MySum = (a, b) => 3 // ok
console.log(MySum) // error TS2693: 'MySum' only refers to a type, but is being used as a value here.

然后再回到枚举。

字符串枚举

用字符串字面量初始化枚举成员,在实际使用过程中很常见

enum Directions {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT',
}
console.log(Directions.Up === 'UP') // true

常数枚举

  • 用 const enum 定义的枚举类型

  • 和普通枚举的区别就是对应的值也会在编译阶段被删除,只会留下枚举成员的值

const enum Directions {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT',
}
let d = Directions.Left

// 如果取消注释下面这行代码,编译会报错
// console.log(Directions) // error TS2475: 'const' enums can only be used in property or index access expressions or the right hand side of an import declaration or export assignment or type query.

编译结果:

var d = 'LEFT' /* Left */

泛型

  • 其实泛型并不是一种具体的类型,而是在定义函数、接口或类的类型时的的拓展特性。

  • 泛型是类型系统里的 “函数” ,通过传入具体的 类型参数 来得到一个具体的类型,从而达到复用类型代码的目的

假设一个场景,某个函数的入参类型为 number | string ,并且出参类型和入参相同

先尝试用联合类型来约束出入参

type MyFunc = (x: number | string) => number | string

但是 MyFunc 无法表示出参类型和入参相同,即入参是 number 的时候出参也是 number。

在这个场景下,可以利用泛型来定义出多个类似的函数类型。

泛型函数

  • 表示声明了一个 类型参数,在定义类型的时候 T 就可以作为一个类型来使用

  • 类型参数也可以定义多个,比如 <A, B, C>

function GenericFunc<T>(arg: T): T {
  return arg
}
// 这里的 GenericFunc<number> 是表示的是一个函数值,同时将类型参数 T 赋值为 number
let n = GenericFunc<number>(1) // n 可以通过类型推论得出类型为 :number
// 进一步,利用 泛型约束 ,限制出入参为 number | string
type MyType = number | string
function GenericFunc<T extends MyType>(arg: T): T { // extends MyType 表示类型参数 T 符合 MyType 类型定义的形状
  return arg
}
let s = GenericFunc<string>('qq')
let b = GenericFunc<boolean>(false) // error TS2344: Type 'boolean' does not satisfy the constraint 'string | number'.

泛型接口

  • 用 泛型接口 来定义函数类型
interface GenericFn<T> {
  (arg: T): T
}
// 定义一个泛型函数作为函数实现
function identity<T>(arg: T): T {
  return arg
}
// 使用泛型时传入一个类型来使 类型参数 变成具体的类型
// <number> 表示 T 此时就是 number 类型,GenericFn<number> 类似是 “函数调用” 并返回了一个具体的类型 (这里是一个函数类型)
const myNumberFn: GenericFn<number> = identity
const myStringFn: GenericFn<string> = identity
let n = myNumberFn(1) // n 可以通过类型推论得出类型为 :number
let s = myStringFn('string') // s 可以通过类型推论得出类型为 :string

对比上述的 泛型函数 和 泛型接口,有一个区别:

  • 给泛型函数传参之后得到的是一个函数值,而不是类型
// GenericFunc 是上面定义的泛型函数
type G = GenericFunc<string> // error TS2749: 'GenericFunc' refers to a value, but is being used as a type here.
  • 而泛型接口传参之后得到的是一个类型,而不是函数值
// GenericFn 是上面定义的泛型接口
type G = GenericFn<number> // ok
GenericFn<number>() // error TS2693: 'GenericFn' only refers to a type, but is being used as a value here.

泛型类

  • 用 泛型类 来定义类的类型
class GenericClass<T> {
  zeroValue: T
  constructor(a: T) {
    this.zeroValue = a
  }
}
let instance = new GenericClass<number>(1)
// 等价于
let instance: GenericClass<number> = new GenericClass(1)
// 因为有类型推论,所以可以简写成
let instance = new GenericClass(1)

内置的数组泛型

  • TS 中内置类一个数组泛型 Array,传入类型参数后会返回对应的数组类型
// 数组的类型之前是用 【 Type[] 】 语法来表示的
let list: number[] = [1, 2, 3]
// 现在也可以这么表示
let list: Array<number> = [1, 2, 3]

[ 声明合并 ]

上面那个场景,某个函数的入参类型为 number | string ,并且出参类型和入参相同,其实不用泛型也可以用函数重载来实现

函数的合并

  • 即函数声明的合并,即函数重载

  • TS 中的重载并不是真正意义上的重载,只是在根据不同的实参类型,从上而下挑选出一个具体的函数类型来使用

function func(x: number): number
function func(x: string): string
function func(x: any): any {
  // 这里定义的函数类型 (x: any): any 会被覆盖失效
  return x.length
}
let n = func(1) // n 可以通过类型推论得出类型为 :number
let s = func('1') // s 可以通过类型推论得出类型为 :string
// 需要注意的是,如上重载之后只剩下两种函数类型,调用时的入参要么是 number 要么是 string,无法传入其他类型的值
let b = func(true) // error
/*
- error TS2769: No overload matches this call.
  Overload 1 of 2, '(x: number): number', gave the following error.
    Argument of type 'true' is not assignable to parameter of type 'number'.
  Overload 2 of 2, '(x: string): string', gave the following error.
    Argument of type 'true' is not assignable to parameter of type 'string'.
*/

接口的合并

  • 接口中方法的合并和函数的合并相同,但是 属性的合并要求类型必须唯一
interface Alarm {
  price: number
  alert(s: string): string
}
interface Alarm {
  weight: number
  alert(s: string, n: number): string
}
// 相当于
interface Alarm {
  price: number
  weight: number
  alert(s: string): string
  alert(s: string, n: number): string
}

声明文件

  • 以 .d.ts 结尾的文件

  • 声明文件里面 100% 全部都是纯类型的声明,不会编译出任何 JS 代码

一般来说,TS 会解析项目中所有的 *.ts 以及 .d.ts 结尾的文件,从而获取其中这些类型声明。

使用场景:作为一个第三方类库的开发者

**假如你的库是用 TypeScript 写的,**但是最终你的库分发出去的时候要编译成 JS。

否则这个类库就只能给 TS 项目来使用了,因为没有使用 TS 的项目没法直接引用 .ts 文件。

但是编译成 JS 有一个问题就是类型代码都被删除了之后,对于 TS 项目的使用方来说就没法继承 TS 的三大优势 (文档、自动补全、类型校验)

所以需要在类库的编译产出文件中保留一些 .d.ts 类型声明文件,和编译出来的 JS 文件中导出的函数、类等进行一一匹配。

这样 JS 文件和类型文件分离之后,你的类库就可以同时被 JS 项目和 TS 项目引用了。

**假如你的库是并没有使用 TypeScript 来编写,**那就需要额外有人给这个库写单独的声明文件。

比如 jQuery 并不是用 TS 来写的,但是你可以安装单独的 TS 类型包来实现补全这个包的类型系统。

npm install @types/jquery --save-dev

感谢&全文引用:

TypeScript 入门教程

TypeScript 官方手册

TypeScript Handbook