深入 TypeScript

深入 TypeScript

接上回 重温 TypeScript,在之前的章节当中,我们简单的梳理一下 TypeScript 的基础内容和基本用法,本章当中我们就来看一些 TypeScript 当中的进阶内容,也算是针对于 TypeScript 做一个比较深入的学习记录吧,主要包括以下内容

  • 类型别名
  • 字面量类型
  • 元组
  • 枚举
  • 类与接口
  • 泛型(因为泛型涉及的相关内容较多,所以另起篇幅来进行介绍)

下面我们就先从比较简单的类型别名和字符串字面量类型开始看起

类型别名

这个很好理解,就是用来给一个类型起个新名字,方式是使用 type 来创建类型别名

1
2
3
4
5
6
7
8
9
10
11
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;

function getName(n: NameOrResolver): Name {
if (typeof n === 'string') {
return n
} else {
return n()
}
}

关于首字母大写的问题,通常来说在语法上没有限制,不过参考 TypeScript 官方 的写法,一般建议首字母大写

字面量类型

同类型别名一样,字面量类型也是使用 type 来进行定义,它的作用简单来说就是用来约束取值只能是某几个字段当中的一个,比如下面这个示例

1
2
3
4
5
6
7
8
type EventNames = 'click' | 'scroll' | 'mousemove'
function handleEvent(ele: Element, event: EventNames) {
// ...
}

const el = document.getElementById('el')
handleEvent(el, 'click') // ✅
handleEvent(el, 'dblclick') // ❌

在上面的示例当中,我们使用 type 定义了一个字符串字面量类型 EventNames,它规定只能取三种事件名当中的一种,如果定义了约定以外的字段,就会报错

当然除了字符串字面量类型,数值类型也是可以的

1
2
3
4
type Nums = 1 | 2 | 3

let x: Nums = 1 // ✅
let y: Nums = 4 // ❌

元组

我们在之前的章节当中介绍了数组的类型,众所周知,数组一般由同种类型的值组成,也就是合并了相同类型的对象,但有时我们需要在单个变量中存储不同类型的值,在这种情况下我们就可以使用元组,它可以理解为是合并了不同类型的对象

JavaScript 中是没有元组的,元组是 TypeScript 中特有的类型,其工作方式类似于数组,
元组可用于定义具有有限数量的未命名属性的类型,每个属性都有一个关联的类型,使用元组时必须提供每个属性的值

为了更直观地理解元组的概念,我们来看一个具体的例子

1
let x: [string, number] = ['zhangsan', 18]

在上面代码中,我们定义了一个名为 x 的变量,它的类型是一个类型数组 [string, number],然后我们按照正确的类型依次初始化 x 变量,与数组一样,我们可以通过下标来访问元组中的元素并且操作它们

1
2
3
4
5
6
7
let x: [string, number] = ['zhangsan', 18]

x[0] = 'lisi'
x[1] = 20

x[0].slice(1)
x[1].toFixed()

但是当直接对元组类型的变量进行初始化或者赋值的时候,需要提供所有元组类型中指定的项,否则会报错

1
2
3
4
let x: [string, number]

x = ['zhangsan', 18] // ✅
x = ['zhangsan'] // ❌

另外需要注意的是,如果当添加越界的元素时,它的类型会被限制为元组中每个类型的联合类型

1
2
3
4
5
6
7
let x: [string, number] = ['zhangsan', 18]

x.push('lisi') // ✅
x.push(true) // ❌

console.log(x) // ✅
console.log(x[2]) // ❌

但是通常不建议超出范围,因为使用元祖可以确定元素数据类型,可以把元祖理解为固定长度,但是超出范围不能保证其类型

枚举

枚举(Enum)类型在某些方面与我们之前介绍的元组有一些类似的地方,它主要用于取值被限定在一定范围内的场景,比如一周只能有七天,颜色限定为红绿蓝等等,使用 enum 关键字来定义

1
enum Days { Sun, Mon, Tue, Wed, Thu, Fri, Sat }

枚举成员会被赋值为从 0 开始递增的数字,同时也会对枚举值到枚举名进行反向映射

1
2
3
4
enum Days { Sun, Mon, Tue, Wed, Thu, Fri, Sat }

Days[0] // Sun
Days['Sun'] // 0

我们也可以给枚举项手动赋值

1
2
3
4
5
enum Days { Sun = 7, Mon, Tue = 1, Wed, Thu, Fri, Sat }

Days['Sun'] // 7
Days['Mon'] // 8
Days['Wed'] // 2

运行后可以发现,未手动赋值的枚举项会接着上一个枚举项依次递增,但是需要注意的是,如果未手动赋值的枚举项与手动赋值的重复了,TypeScript 是不会察觉到这一点的

1
2
3
4
enum Days { Sun = 3, Mon = 1, Tue, Wed, Thu, Fri, Sat }

Days['Sun'] === 3 // true
Days['Wed'] === 3 // true

在上面的示例当中,递增到 3 的时候与我们前面手动赋值的 Sun 的取值重复了,但是 TypeScript 并没有报错,导致 Days[3] 的值先是 'Sun',而后又被 'Wed' 覆盖了,但是我们在实际应用场景当中需要注意,最好不要出现这种覆盖的情况

当然,手动赋值的枚举项也可以为小数或负数,此时后续未手动赋值的项的递增步长仍为 1

1
2
3
4
5
enum Days { Sun = 1.2, Mon, Tue, Wed, Thu, Fri, Sat }

Days['Mon'] // 2.2
Days['Tue'] // 3.2
Days['Wed'] // 4.2

这里我们简单总结一下枚举成员的特点,主要有两点

  • 是只读属性,无法修改
  • 枚举成员值默认从 0 开始递增,可以自定义设置初始值
1
2
3
4
5
6
7
enum Gender {
BOY = 1,
GIRL
}

Gender.BOY // 1
Gender // { '1': 'BOY', '2': 'GIRL', BOY: 1, GIRL: 2 }

而枚举成员值有以下特点

  • 可以没有初始值
  • 可以是一个对常量成员的引用
  • 可以是一个常量表达式
  • 也可以是一个非常量表达式
1
2
3
4
5
6
7
8
9
10
11
12
enum Char {
// const member 常量成员,在编译阶段被计算出结果
a, // 没有初始值
b = Char.a, // 对常量成员的引用
c = 1 + 3, // 常量表达式

// computed member 计算成员,表达式保留到程序的执行阶段
d = Math.random(), // 非常量表达式
e = '123'.length,
f = 6, // 紧跟在计算成员后面的枚举成员必须有初始值
g
}

常量枚举与普通枚举的区别

主要有以下一些区别

  • 常量枚举会在编译阶段被删除
  • 枚举成员只能是常量成员
1
2
3
4
5
6
7
8
const enum Colors {
Red,
Yellow,
Blue
}

// 常量枚举会在编译阶段被删除
let myColors = [Colors.Red, Colors.Yellow, Colors.Blue]

上面代码经过编译以后是下面这样

1
2
'use strict'
var myColors = [0 /* Red */, 1 /* Yellow */, 2 /* Blue */]
  • 常量枚举不能包含计算成员,如果包含了计算成员,则会在编译阶段报错
1
2
3
4
// ❌
const enum Color { Red, Yellow, Blue = 'blue'.length }

console.log(Colors.RED)

枚举的使用场景

我们先来看一段经常会遇见的代码风格,如下

1
2
3
4
5
6
7
8
9
10
11
function initByRole(role) {
if (role === 1 || role == 2) {
console.log('1, 2')
} else if (role == 3 || role == 4) {
console.log('3, 4')
} else if (role === 5) {
console.log('5')
} else {
console.log('')
}
}

上面的代码虽然可以正常运行,但是也存在着一些问题

  • 可读性差,很难记住数字的含义
  • 可维护性差,硬编码,后续修改的话牵一发动全身

我们可以使用枚举的方式来改善一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
enum Role {
Reporter,
Developer,
Maintainer,
Owner,
Guest
}

function init(role: number) {
switch (role) {
case Role.Reporter:
console.log('Reporter: 1')
break
case Role.Developer:
console.log('Developer: 2')
break
case Role.Maintainer:
console.log('Maintainer: 3')
break
case Role.Owner:
console.log('Owner: 4')
break
default:
console.log('Guest: 5')
break
}
}

init(Role.Developer)

使用数字类型作为标志

这种使用方式经常在一些第三方类库当中可以看到,因为枚举的一个很好用途是使用枚举作为标志,这些标志允许你检查一组条件中的某个条件是否为真,比如下面这个例子,我们有一组关于 animals 的属性

1
2
3
4
5
6
7
enum AnimalFlags {
None = 0,
HasClaws = 1 << 0,
CanFly = 1 << 1,
EatsFish = 1 << 2,
Endangered = 1 << 3
}

在这里我们使用了左移的位运算符,将数字 1 的二进制向左移动位置得到数字 0001001001001000(换成十进制结果是 1248),当我们在使用这种标记的时候,|(或)、&(和)、~(非)等位运算符将会是很好的搭配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
enum AnimalFlags {
None = 0,
HasClaws = 1 << 0,
CanFly = 1 << 1
}

interface Animal {
flags: AnimalFlags
[key: string]: any
}

function printAnimalAbilities(animal: Animal) {
var animalFlags = animal.flags
if (animalFlags & AnimalFlags.HasClaws) {
console.log('animal has claws')
}
if (animalFlags & AnimalFlags.CanFly) {
console.log('animal can fly')
}
if (animalFlags == AnimalFlags.None) {
console.log('nothing')
}
}

var animal = { flags: AnimalFlags.None }
printAnimalAbilities(animal) // nothing

animal.flags |= AnimalFlags.HasClaws
printAnimalAbilities(animal) // animal has claws

animal.flags &= ~AnimalFlags.HasClaws
printAnimalAbilities(animal) // nothing

animal.flags |= AnimalFlags.HasClaws | AnimalFlags.CanFly
printAnimalAbilities(animal) // animal has claws, animal can fly

在上面的示例当中

  • 我们使用 |= 来添加一个标志
  • 组合使用 &=~ 来清理一个标志
  • 使用 | 来合并标志

因为组合标志可以在枚举类型中定义方便快捷的方式,比如下面的 EndangeredFlyingClawedFishEating

1
2
3
4
5
6
7
8
9
enum AnimalFlags {
None = 0,
HasClaws = 1 << 0,
CanFly = 1 << 1,
EatsFish = 1 << 2,
Endangered = 1 << 3,

EndangeredFlyingClawedFishEating = HasClaws | CanFly | EatsFish | Endangered
}

以上关于枚举的相关内容我们就暂时介绍到这里,因为毕竟平时涉及到的有限,如果后续工作当中有遇到相关内容的话再来进行完善,枚举的其他一些用法可以参考文档当中的 枚举

传统方法中,JavaScript 通过构造函数实现类的概念,通过原型链实现继承,而在 ES6 中,我们终于迎来了 classTypeScript 除了实现了所有 ES6 中的类的功能以外,还添加了一些新的用法,下面就让我们就来看看 TypeScript 中类的用法

TypeScript 可以使用三种访问修饰符(Access Modifiers),分别是 publicprivateprotected

  • public,修饰的属性或方法是公有的,可以在任何地方被访问到,默认所有的属性和方法都是 public
  • private,修饰的属性或方法是私有的,不能在声明它的类的外部访问
  • protected,修饰的属性或方法是受保护的,它和 private 类似,区别是它在子类中也是允许被访问的

下面我们来看几个示例

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public name
public constructor(name) {
this.name = name
}
}

let a = new Person('zhangsan')
a.name // zhangsan

a.name = 'lisi'
a.name // lisi

在上面示例当中,name 被设置为了 public,所以直接访问实例的 name 属性是允许的,如果我们希望有的属性是无法直接存取的,这时候就可以用 private

1
2
3
4
5
6
7
8
9
10
11
class Person {
private name
public constructor(name) {
this.name = name
}
}

let a = new Person('zhangsan')

// ❌,因为属性 name 为私有属性,只能在类 Person 中访问
console.log(a.name)

但是需要注意的是,在 TypeScript 编译之后的代码中,其实并没有限制 private 属性在外部的可访问性

1
2
3
4
5
6
7
8
9
10
var Person = (function () {
function Person(name) {
this.name = name
}
return Person
})()

var a = new Person('zhangsan')

a.name // zhangsan

同时,使用 private 修饰的属性或方法,在子类中也是不允许访问的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
private name
public constructor(name) {
this.name = name
}
}

class Man extends Person {
constructor(name) {
super(name)
// ❌
console.log(this.name)
}
}

而如果是用 protected 修饰,则允许在子类中访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
protected name
public constructor(name) {
this.name = name
}
}

class Man extends Person {
constructor(name) {
super(name)
// ✅
console.log(this.name)
}
}

如果我们不想让该类被继承或者实例化,那么可以在构造函数上设置 private 修饰

1
2
3
4
5
6
7
8
9
10
11
class Person {
public name
private constructor(name) {
this.name = name
}
}

// ❌
class Man extends Person {
// ...
}

当构造函数修饰为 protected 时,该类只允许被继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
public name
protected constructor(name) {
this.name = name
}
}


// ✅
class Man extends Person {
// ...
}

// ❌
let man = new Person()

同样的,上面介绍到的修饰符和下面提到的 readonly 都可以使用在构造函数的参数当中,等同于类中定义该属性的同时给该属性赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
public name
protected constructor(name) {
this.name = name
}
}

// 等同于 ==>

class Person {
protected constructor(public name) {

}
}

readonly

只读属性关键字,表示只允许出现在属性声明或索引签名或构造函数中,比如我们可以指定一个类的属性为只读,然后在声明时或者构造函数中初始化它们

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
readonly name
public constructor(name) {
this.name = name
}
}

// ✅
let man = new Person('zhangsan')

// ❌
man.name = 'list'

不过需要注意的是,如果 readonly 和其他访问修饰符同时存在的话,需要写在其后面

1
2
3
4
5
6
7
8
9
10
11
class Person {
// ✅
public readonly name

// ❌
readonly public name

public constructor(name) {
this.name = name
}
}

当然也可以在 interfacetype 里使用 readonly

1
2
3
4
5
6
7
8
type Foo = {
readonly bar: number
readonly bas: number
}

const foo: Foo = { bar: 123, bas: 456 }

foo.bar = 456 // ❌

甚至可以把索引签名标记为只读

1
2
3
4
5
6
7
8
interface Foo {
readonly [x: number]: number
}

const foo: Foo = { 0: 123, 2: 345 }

console.log(foo[0]) // ✅
foo[0] = 456 // ❌ 属性只读,不可操作

如果想以不变的方式使用原生 JavaScript 数组,可以使用 TypeScript 提供的 ReadonlyArray<T> 接口

1
2
3
4
5
6
7
let foo: ReadonlyArray<number> = [1, 2, 3]

console.log(foo[0]) // ✅

foo.push(4) // ❌ ReadonlyArray 上不存在 push,因为它会改变数组

foo = foo.concat(4) // ✅ 创建副本是可行的

还有一个 Readonly 的映射类型,它接收一个泛型 T,用来把它的所有属性标记为只读类型

1
2
3
4
5
6
7
8
9
10
11
12
type Foo = {
bar: number
bas: number
}

type FooReadonly = Readonly<Foo>

const foo: Foo = { bar: 123, bas: 456 }
const fooReadonly: FooReadonly = { bar: 123, bas: 456 }

foo.bar = 456 // ✅
fooReadonly.bar = 456 // ❌

另外在 React 当中我们也可以标记 PropsState 为不可变数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Props {
readonly foo: number
}

interface State {
readonly bar: number
}

export class Something extends React.Component<Props, State> {
someMethod() {
this.props.foo = 123 // ❌ props 是不可变的
this.state.baz = 456 // ❌ state 也是同理,应该使用 this.setState()
}
}

然而我们并不需要这样操作,因为 React 的声明文件已经标记这些为 readonly(通过传入泛型参数至一个内部包装,来把每个属性标记为 readonly

1
2
3
4
5
6
export class Something extends React.Component<{ foo: number }, { baz: number }> {
someMethod() {
this.props.foo = 123 // ❌ props 是不可变的
this.state.baz = 456 // ❌ state 也是同理,应该使用 this.setState()
}
}

最后我们再来简单的了解一下 readonlyconst 之间的区别,首先 const 用于变量,而 readonly 用于属性,比如下面这个示例

1
2
3
4
5
const foo = 123         // 变量

let bar: {
readonly bar: number // 属性
}

另外 const 变量不能重新赋值给其他任何事物,而 readonly 用于别名,可以修改属性

1
2
3
4
5
6
7
8
9
10
11
const foo: { readonly bar: number } = {
bar: 123
}

function iMutateFoo(foo: { bar: number }) {
foo.bar = 456
}

iMutateFoo(foo)

console.log(foo.bar) // 456

当然如果我们在上面的 iMutateFoo 当中明确的表示参数是不可修改的,那么编译器会发出错误警告

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Foo {
readonly bar: number
}

let foo: Foo = {
bar: 123
}

function iTakeFoo(foo: Foo) {
// ❌ 属性只读,不可操作
foo.bar = 456
}

iTakeFoo(foo)

抽象类

使用 abstract 来定义抽象类和其中的抽象方法,那么什么是抽象类呢?首先抽象类是不允许被实例化的

1
2
3
4
5
6
7
8
9
abstract class Person {
public name
public constructor(name) {
this.name = name
}
}

// ❌
let man = new Person('zhangsan')

在上面的例子当中,我们定义了一个抽象类 Person,可以发现在实例化抽象类的时候报错了,另外如果抽象类当中存在抽象方法(指不包含具体实现的方法),如下

1
2
3
4
5
6
7
abstract class Person {
public name
public constructor(name) {
this.name = name
}
public abstract say()
}

其中的抽象方法必须被子类实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
abstract class Person {
public name
public constructor(name) {
this.name = name
}
public abstract say()
}

class Man extends Person {
// 这里需要实现父类当中的抽象方法,否则会报错
public say() {
console.log(this.name)
}
}

let man = new Man('zhangsan')

man.say()

私有字段

TypeScript 3.8 版本就开始支持 ECMAScript 私有字段,使用方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
#name: string

constructor(name: string) {
this.#name = name
}

greet() {
console.log(`${this.#name}`)
}
}

let man = new Person('zhangsan')

// ❌
man.#name

可以发现,会有报错信息,提示我们属性 #name 在类 Person 外部不可访问,另外,私有字段与常规属性(甚至使用 private 修饰符声明的属性)不同,私有字段需要约定以下几点规则

  • 私有字段以 # 字符开头,有时候我们称其为『私有名称』
  • 每个私有字段名称都唯一地限定于其包含的类
  • 不能在私有字段上使用 TypeScript 可访问性修饰符(如 publicprivate
  • 私有字段不能在包含的类之外访问,甚至不能被检测到

在使用私有字段的过程当中,不得不让我们想起另外一个与其十分类似的东西,那就是 private 修饰符,那么使用 # 定义的私有字段与 private 修饰符定义字段有什么区别呢?先让我们来看一个 private 的示例

1
2
3
4
5
6
7
8
class Person {
constructor(private name: string) { }
}

let person = new Person('zhangsan')

// ❌
console.log(person.name)

在上面代码中,我们创建了一个 Person 类,该类中使用 private 修饰符定义了一个私有属性 name,接着使用该类创建一个 person 对象,然后通过 person.name 来访问 person 对象的私有属性,这时 TypeScript 编译器会提示我们有以下异常

1
Property 'name' is private and only accessible within class 'Person'.(2341)

那如何解决这个异常呢?当然我们可以使用类型断言把 person 转为 any 类型

1
2
3
4
5
(<any>person).name

// or

(person as any).name

通过这种方式虽然解决了 TypeScript 编译器的异常提示,但是在运行时我们依然可以发现还是可以访问到 Person 类内部的私有属性,至于为什么会这样,我们来看一下编译生成的 ES5 代码就知道答案了

1
2
3
4
5
6
7
8
9
10
var Person = /** @class */ (function () {
function Person(name) {
this.name = name
}
return Person
}())

var person = new Person('zhangsan')

person.name

下面我们再来看看在 TypeScript 3.8 以上版本通过 # 号定义的私有字段编译后会生成什么代码,这里还是以上面的代码为例

1
2
3
4
5
6
7
8
9
10
11
class Person {
#name: string

constructor(name: string) {
this.#name = name
}

greet() {
console.log(`${this.#name}`)
}
}

我们将编译的目标设置为 ES2015,会编译生成以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
'use strict'
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet)
|| function (receiver, privateMap, value) {
if (!privateMap.has(receiver)) {
throw new TypeError('attempted to set private field on non-instance')
}
privateMap.set(receiver, value)
return value
}

var __classPrivateFieldGet = (this && this.__classPrivateFieldGet)
|| function (receiver, privateMap) {
if (!privateMap.has(receiver)) {
throw new TypeError('attempted to get private field on non-instance')
}
return privateMap.get(receiver)
}

var _name
class Person {
constructor(name) {
_name.set(this, void 0)
__classPrivateFieldSet(this, _name, name)
}
greet() {
console.log(`${__classPrivateFieldGet(this, _name)}`)
}
}
_name = new WeakMap()

通过观察我们可以发现,在使用 # 号定义的私有字段当中,会通过 WeakMap 对象来存储,同时编译器会生成 __classPrivateFieldSet__classPrivateFieldGet 这两个方法用于设置值和获取值,这样一来我们就无法在外部进行 name 属性的访问了

类与接口

实现(implements)是面向对象中的一个重要概念,一般来说一个类只能继承自另一个类,有时候不同类之间可以有一些共有的特性,这时候就可以把特性提取成接口(interfaces),用 implements 关键字来实现,这个特性大大提高了面向对象的灵活性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface SayName {
sayName(): void
}

class Animal { }

class Cat extends Animal implements SayName {
sayName() {
console.log(`cat`)
}
}

class Dog extends Animal implements SayName {
sayName() {
console.log(`dog`)
}
}

同样的,一个类可以实现多个接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface SayName {
sayName(): void
}

interface SayAge {
sayAge(): void
}

class Rabbit implements SayName, SayAge {
sayName() {
console.log(`cat`)
}
sayAge() {
console.log(`3`)
}
}

但是需要注意一点,接口与接口之间也是可以是继承关系的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface SayName {
sayName(): void
}

interface SayAge extends SayName {
sayAge(): void
}

class Rabbit implements SayAge {
sayName() {
console.log(`cat`)
}
sayAge() {
console.log(`3`)
}
}

接口继承类

最后我们再来看一种比较有趣的情况,但是也是平常使用较少的一种情况,那就是接口继承类,在常见的面向对象语言中,接口是不能继承类的,但是在 TypeScript 中却是可以的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {
x: number
y: number
constructor(x: number, y: number) {
this.x = x
this.y = y
}
}

interface Point3d extends Point {
z: number
}

let point3d: Point3d = { x: 1, y: 2, z: 3 }

但是为什么 TypeScript 会支持接口继承类呢?实际上当我们在声明 class Point 的时候,除了会创建一个名为 Point 的类之外,同时也创建了一个名为 Point 的类型(实例的类型)

所以我们既可以将 Point 当做一个类来用(使用 new Point 创建它的实例)

1
2
3
4
5
6
7
8
9
10
class Point {
x: number
y: number
constructor(x: number, y: number) {
this.x = x
this.y = y
}
}

const p = new Point(1, 2)

也可以将 Point 当做一个类型来用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {
x: number
y: number
constructor(x: number, y: number) {
this.x = x
this.y = y
}
}

function printPoint(p: Point) {
console.log(p.x, p.y)
}

printPoint(new Point(1, 2))

上面这个例子实际上可以等价于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Point {
x: number
y: number
constructor(x: number, y: number) {
this.x = x
this.y = y
}
}

interface PointInstanceType {
x: number
y: number
}

function printPoint(p: PointInstanceType) {
console.log(p.x, p.y)
}

printPoint(new Point(1, 2))

在上面示例当中我们新声明了一个 PointInstanceType 的类型,其实本质上与声明 class Point 时创建的 Point 类型是等价的,所以在回过头来看我们之前的示例,就很容易的理解为什么 TypeScript 会支持接口继承类了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Point {
x: number
y: number
constructor(x: number, y: number) {
this.x = x
this.y = y
}
}

interface PointInstanceType {
x: number
y: number
}

// 其实等价于 interface Point3d extends PointInstanceType
interface Point3d extends Point {
z: number
}

let point3d: Point3d = { x: 1, y: 2, z: 3 }

当我们声明 interface Point3d extends Point 的时候,本质上 Point3d 继承的是类 Point 的实例的类型,我们可以理解为定义了一个接口 Point3d 继承另一个接口 PointInstanceType,所以『接口继承类』和『接口继承接口』没有什么本质的区别

但是需要注意的是,那就是 PointInstanceType 相比于 Point 缺少了 constructor 方法,这是因为声明 Point 类时创建的 Point 类型是不包含构造函数的,另外除了构造函数是不包含的,静态属性或静态方法也是不包含的(实例的类型当然不应该包括构造函数、静态属性或静态方法)

换句话说,声明 Point 类时创建的 Point 类型只包含其中的实例属性和实例方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Point {
static origin = new Point(0, 0) // 静态属性,坐标系原点
static distanceToOrigin(p: Point) { // 静态方法,计算与原点距离
return Math.sqrt(p.x * p.x + p.y * p.y)
}
x: number // 实例属性 x 轴的值
y: number // 实例属性 y 轴的值
constructor(x: number, y: number) {
this.x = x
this.y = y
}
printPoint() { // 实例方法
console.log(this.x, this.y)
}
}

interface PointInstanceType {
x: number
y: number
printPoint(): void
}

let p1: Point
let p2: PointInstanceType

上例中最后的类型 Point 和类型 PointInstanceType 本质上是等价的,所以我们可以发现,在接口继承类的时候,只会继承它的实例属性和实例方法

类型接口之间的区别

这里我们简单的总结一下几种不同的类型接口之间的区别

  • 『可索引类型接口』

这个我们在上面也有所提及,它一般用来约束数组和对象,其中『数字索引』通常用来约束数组,其中的 index 可以任意取名,只要 index 的类型是 number,那么值的类型必须是 string

1
2
3
4
5
6
7
8
9
interface StringArray {
// key 的类型为 number,一般都代表是数组
// 限制 value 的类型为 string
[index: number]: string
}

let arr: StringArray = ['aaa', 'bbb']

console.log(arr)

而『字符串索引』通常用来约束对象,只要 index 的类型是 string,那么值的类型必须是 string

1
2
3
4
5
6
7
interface StringObject {
// key 的类型为 string,一般都代表是对象
// 限制 value 的类型为 string
[index: string]: string
}

let obj: StringObject = { name: 'ccc' }
  • 『函数类型接口』

通常是对方法传入的参数和返回值进行约束,但是需要注意普通的接口与函数类型接口之间的区别

1
2
3
4
// 普通的接口
interface discount1 {
getNum: (price: number) => number
}
1
2
3
4
5
6
7
8
9
10
// 函数类型接口
interface discount2 {
// `:` 前面的是函数的签名,用来约束函数的参数
// `:` 后面的用来约束函数的返回值
(price: number): number
}

let cost: discount2 = function (price: number): number {
return price * .8
}

另外,也可以使用类型别名

1
2
3
type Add = (x: number, y: number) => number

let add: Add = (a: number, b: number) => a + b
  • 『类类型接口』

简单来说,就是对类的约束,让类去实现接口(可以实现多个接口),如果接口用于一个类的话,那么接口会表示『行为的抽象』,并且接口只能约束类的公有成员(实例属性或方法),而无法约束私有成员、构造函数、静态属性或方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface Speakable {
name: string
speak(words: string): void
}

interface Speakable2 {
age: number
}

class Dog implements Speakable, Speakable2 {
name!: string
age = 18

speak(words: string) {
console.log(words)
}
}

let dog = new Dog()

dog.speak('wang')
  • 『混合类型接口』

简单来说就是一个对象可以同时做为函数和对象使用,也就是我们之前提到过的接口与接口之间的继承

1
2
3
4
5
6
7
8
interface FnType {
(getName: string): string
}

interface MixedType extends FnType {
name: string
age: number
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface Counter {
(start: number): string
interval: number
reset(): void
}

function getCounter(): Counter {
let counter = <Counter>function (start: number) { }
counter.interval = 123
counter.reset = function () { }
return counter
}

let c = getCounter()
c(10)

c.reset()
c.interval = 5.0

interface 与 type 的区别

最后我们再来看看 interfacetype 之间的区别,其实简单来说,interface 主要用于描述『数据结构』,而 type 主要用于描述『类型关系』,下面我们通过一些示例来进行区分

  • 相同点一,都可以描述一个对象或者函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// interface
interface User {
name: string
age: number
}

interface SetUser {
(name: string, age: number): void
}

// type
type User = {
name: string
age: number
}

type SetUser = (name: string, age: number) => void
  • 相同点二,都允许拓展(extends

interfacetype 都可以拓展,并且两者并不是相互独立的,也就是说 interface 可以 extends typetype 也可以 extends interface,虽然效果差不多,但是两者语法不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// interface extends interface
interface Name {
name: string
}

interface User extends Name {
age: number
}

// type extends type
type Name = {
name: string
}

type User = Name & { age: number }

// interface extends type
type Name = {
name: string
}

interface User extends Name {
age: number
}

// type extends interface
interface Name {
name: string
}

type User = Name & {
age: number
}
  • 不同点一,type 可以而 interface 不行

type 可以声明基本类型别名,联合类型,元组等类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 基本类型别名
type Name = string

// 联合类型
interface Dog {
wang()
}

interface Cat {
miao()
}

type Pet = Dog | Cat

// 具体定义数组每个位置的类型
type PetList = [Dog, Pet]

还可以使用 typeof 获取实例的 类型进行赋值

1
2
3
4
// 当我们想获取一个变量的类型时,可以使用 typeof
let div = document.createElement('div')

type B = typeof div

另外还可以用来做一些骚操作

1
2
3
4
5
6
7
8
9
10
11
12
13
type StringOrNumber = string | number

type Text = string | { text: string }

type NameLookup = Dictionary<string, Person>

type Callback<T> = (data: T) => void

type Pair<T> = [T, T]

type Coordinates = Pair<number>

type Tree<T> = T | { left: Tree<T>, right: Tree<T> }
  • 不同点二,interface 可以而 type 不行

好像只有一点,那就是 interface 能够声明合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface User {
name: string
age: number
}

interface User {
sex: string
}

/*
User 接口为 {
name: string
age: number
sex: string
}
*/

其实一般来说,如果不清楚什么时候用 interface/type,能用 interface 实现,就用 interface,如果不能就用 type,并没有强行规定谁好或是谁不好

tsconfig.json

最后的最后,我们再来简单的看一下 tsconfig.json 这个配置文件和平常遇到的一些与其相关的配置问题,我们先来看看 tsconfig.json 的作用,总的来说,tsconfig.json 的作用主要有以下几点

  • 用于标识 TypeScript 项目的根路径
  • 用于配置 TypeScript 编译器
  • 用于指定编译的文件

而其中涉及到的字段较多,我们这里只是挑选几个比较重要的简单介绍一下,如下

  • files,设置要编译的文件的名称
  • include,设置需要进行编译的文件,支持路径模式匹配
  • exclude,设置无需进行编译的文件,支持路径模式匹配
  • compilerOptions,设置与编译流程相关的选项

这里我们重点关注一下 compilerOptions 这个字段,compilerOptions 支持很多选项,常见的有 baseUrltargetbaseUrlmoduleResolutionlib 等,每个选项的详细说明如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
{
"compilerOptions": {

/* 基本选项 */
"incremental": true, // 开启增量编译,第一次编译的时候会生成一个存储编译信息的文件,下一次编译的时候,会根据这个文件进行增量的编译,以此提高编译速度
"tsBuildInfoFile": "./", // 指定存储增量编译信息的文件位置
"diagnostics": true, // 打印诊断信息
"listEmittedFiles": true, // 打印输出的文件
"listFiles": true, // 打印编译的文件(包括引用的声明文件)
"target": "es5", // 指定 ECMAScript 目标版本,包括 ES3(默认)/ES5/ES6/ES2016/ES2017/ESNEXT
"module": "commonjs", // 指定使用模块,包括 none/commonjs/amd/system/umd/es2015/ESNext
"lib": [], // 指定要包含在编译中的库文件(引用类库,即申明文件)
"allowJs": true, // 允许编译 JavaScript 文件
"checkJs": true, // 检查 JavaScript 文件中的错误
"jsx": "preserve", // 指定 jsx 代码的生成,包括 preserve/react-native/react

/* 声明文件相关配置 */
"declaration": true, // 生成相应的 .d.ts 文件
"declarationDir": "./d", // 声明文件的输出路径
"emitDeclarationOnly": true, // 只生成声明文件,不生成 JavaScript
"typeRoots": [], // 声明文件目录,默认 node_modules/@types
"types": [], // 要导入的声明文件包,默认导入上面声明文件目录下的所有声明文件
"outFile": "./", // 将多个相互依赖的文件合并并且把编译后的内容输出到一个文件里
"outDir": "./out", // 指定编译文件的输出目录
"rootDir": "./", // 指定输入文件的根目录,用于控制输出目录的结构
"composite": true, // 启用项目编译
"removeComments": true, // 输出的时候移除注释
"noEmit": true, // 不生成输出文件
"noEmitOnError": true, // 发生错误时不输出文件
"noEmitHelpers": true, // 不生成 helper 函数,类似于 babel,会给每个文件都生成 helper 函数,会使得最终编译后的包的体积变大
"importHelpers": true, // 现在可以通过 tslib(TS 内置的库)引入 helper 函数(文件必须是模块)
"downlevelIteration": true, // 当目标是 ES5 或 ES3 的时候提供对 for-of、扩展运算符和解构赋值中对于迭代器的完整支持
"isolatedModules": true, // 把每一个文件转译成一个单独的模块

/* 严格检查配置 */
"strict": true, // 开启所有的严格检查配置
"noImplicitAny": true, // 不允许使用隐式的 any 类型
"strictNullChecks": true, // 不允许把 null、undefined 赋值给其他类型变量
"strictFunctionTypes": true, // 不允许函数参数双向协变
"strictBindCallApply": true, // 使用 bind/call/apply 时,严格检查函数参数类型
"strictPropertyInitialization": true, // 类的实例属性必须初始化
"noImplicitThis": true, // 不允许 this 有隐式的 any 类型,即 this 必须有明确的指向
"alwaysStrict": true, // 在严格模式下解析并且向每个源文件中注入 use strict

/* 额外的语法检查配置,建议交给 eslint 处理,无需配置 */
"noUnusedLocals": true, // 有未使用到的本地变量时报错
"noUnusedParameters": true, // 有未使用到的函数参数时报错
"noImplicitReturns": true, // 每个分支都要有返回值
"noFallthroughCasesInSwitch": true, // 报告 switch 语句的 fallthrough 错误(即不允许 switch 的 case 语句贯穿)

/* 模块解析配置 */
"moduleResolution": "node", // 选择模块解析策略,包括 node(Node.js)和 classic(TypeScript pre-1.6)
"baseUrl": "./", // 在解析非绝对路径模块名的时候的基准路径
"paths": {}, // 基于 baseUrl 的路径映射集合
"rootDirs": ["src", "out"], // 根文件夹列表,其组合内容表示项目运行时的结构内容
"esModuleInterop": true, // 允许 export = xx 导出 ,并使用 import xx form 'module-name' 导入
"allowSyntheticDefaultImports": true, // 当模块没有默认导出的时候,允许被别的模块默认导入,只是在类型检查的时候生效
"preserveSymlinks": true, // 不需要 symlinks 解析的真正路径
"allowUmdGlobalAccess": true, // 允许在模块中以全局变量的方式访问 UMD 模块内容

/* Source Map 配置 */
"sourceRoot": "", // 指定 ts 文件位置
"mapRoot": "", // 指定 map 文件存放的位置
"sourceMap": true, // 生成目标文件的 sourceMap
"inlineSources": true, // 将代码与 sourcemaps 生成到一个文件中,要求同时设置 --inlineSourceMap 或 --sourceMap 属性
"inlineSourceMap": true, // 生成目标文件的 inline sourceMap,源文件和 sourcemap 文件在同一文件中,而不是把 map 文件放在一个单独的文件里
"declarationMap": true, // 生成声明文件的 sourceMap

/* 实验性的配置 */
"experimentalDecorators": true, // 启用装饰器
"emitDecoratorMetadata": true, // 为装饰器提供元数据的支持

/* 高级配置 */
"forceConsistentCasingInFileNames": true // 强制区分大小写
}
"files": [], // 指定需要编译的单个文件列表
"include": ["src"] // 设置需要进行编译的文件,支持路径模式匹配,只写一个目录名等价于 "./src/**/*"
"exclude": [] // 设置无需进行编译的文件,支持路径模式匹配
"extends": "./tsconfig.base.json" // 配置文件继承
}

下面我们再来看看平常会遇到的一些与 tsconfig.json 相关的配置问题

如何对 .js 文件进行类型检查

  • tsconfig.json 中可以设置 checkJs: true 来对 .js 文件进行类型检查和错误提示
    • 通过在 .js 文件顶部添加 // @ts-nocheck 注释,让编译器忽略当前文件的类型检查
    • 相反可以通过不设置 checkJs: true 并在 .js 文件顶部添加一个 // @ts-check 注释,让编译器检查当前文件
    • 也可以在 tsconfig.json 中配置 include/exclude,选择或是排除对某些文件进行类型检查
    • 你还可以使用 // @ts-ignore 来忽略本行的错误
  • .js 文件里,类型可以和在 .ts 文件里一样被推断出来,当类型不能被推断时,可以通过 jsdoc 来指定类型
  • 另外,在 TypeScript 当中支持 jsdoc 注解
1
2
3
4
5
/** @type {number} */
var x

x = 0 // OK
x = false // Error: boolean is not assignable to number

for-of 无法遍历 map 数据结构

比如如下示例,当我们设置 "target": "es5" 的时候,会报错误,并且无法执行 for 语句

1
2
3
4
5
6
7
8
9
10
11
12
13
const map = new Map([
['F', 'no'],
['T', 'yes'],
])

for (let key of map.keys()) {
console.log(key)
}

// 用 forEach 也可以遍历
map.forEach((value, key) => {
console.log(key)
})

编辑器会提示我们有错误存在

1
TS2569: Type 'Map<string, string>' is not an array type or a string type. Use compiler. option '- downlevellteration' to allow iterating of iterators.

针对于这种情况,我们就需要配置 tsconfig.json 当中的 dom.iterabledownlevelIteration

1
2
3
4
5
6
7
8
9
10
11
{
/* 当目标是 ES5 或 ES3 的时候提供对 for-of、扩展运算符和解构赋值中对于迭代器的完整支持 */
"downlevelIteration": true,
"lib": [
"dom",
"es5",
"es6",
"es7",
"dom.iterable"
]
}

当然,如果配置的是 "target": "es6" 的时候,也可以正常执行,具体原因可见 tsc CLI Options,但是在这里我们需要了解一下在配置 lib 时需要注意的问题

当我们在安装 TypeScript 时,会顺带安装 lib.d.ts 等声明文件,此文件包含 JavaScript 运行时以及 DOM 中存在各种常见的环境声明

  • 它自动包含在 TypeScript 项目的编译上下文中
  • 它能让你快速开始书写经过类型检查的 JavaScript 代码

tsconfig.json 中的 lib 选项用来指定当前项目需要注入哪些声明库文件,如果没有指定,默认注入的库文件列表为

1
2
For --target ES5: DOM, ES5, ScriptHost
For --target ES6: DOM, ES6, DOM.Iterable, ScriptHost

如果在 TypeScript 中想要使用一些 ES6 以上版本或者特殊的语法,就需要引入相关的类库,例如 ES7DOM.Iterable

另外需要注意的一点就是,如果配置的是 "target": "es6",则 TSC 就会默认使用 "classic" 模块解析策略,这个策略对于 import * as abc from "@babel/types" 这种非相对路径的导入,不能正确解析,解决方法就是指定解析策略为 node,也就是配置 "moduleResolution": "node"

关于 moduleResolution 的解析策略可见 模块解析

为什么在 exclude 列表里的模块还会被编译器使用

有时候是被 tsconfig.json 自动加入的,如果编译器识别出一个文件是模块导入目标,它就会加到编译列表里,不管它是否被排除了

因此,要从编译列表中排除一个文件,你需要在排除它的同时,还要排除所有对它进行 import 或使用了 /// 指令的文件

如何在 TypeScript 当中使用 JSX

如果想在 TypeScript 当中使用 JSX,必须具备两个条件

  • 首先给文件一个 .tsx 扩展名
  • 其次启用 jsx 选项

TypeScript 具有三种 JSX 模式,即 preserve/react/react-native,这些模式只在代码生成阶段起作用,类型检查并不受影响

  • preserve 模式下不会将 JSX 编译成 JavaScript,生成代码中会保留 JSX,以供后续的转换操作使用(比如 Babel),另外输出文件会带有 .jsx 扩展名
  • react 模式下直接将 JSX 编译成 JavaScript,会生成 React.createElement 的形式,在使用前不需要再进行转换操作了,输出文件的扩展名为 .js
  • react-native 模式下相当于 preserve,它也保留了所有的 JSX,但是输出文件的扩展名是 .js

具体区别如下表所示

模式 输入 输出 输出文件扩展名
preserve <div /> <div /> .jsx
react <div /> React.createElement('div') .js
react-native <div /> <div /> .js

esModuleInterop 与 allowSyntheticDefaultImports

  • esModuleInterop

如果一个模块遵循 ES6 模块规范,当默认导出内容时(export default xx),ES6 模块系统会自动给当前模块的顶层对象加上一个 default 属性,指向导出的内容

当一个 ES6 模块引入该模块时(import moduleName from 'xx'),ES6 模块系统默认会自动去该模块中的顶层对象上查找 default 属性并将值赋值给 moduleName,而如果一个非 ES6 规范的模块引入 ES6 模块直接使用时(var moduleName = require('xx'))就会报错,需要通过 moduleName.default 来使用

TypeScript 为了兼容,引入了 esModuleInterop 选项,设置 esModuleInteroptrue,在编译时自动给该模块添加 default 属性,就可以通过 import moduleName from 'xx' 的形式导入非 ES6 模块,不再需要使用 import moduleName = require('xx') 的形式

  • allowSyntheticDefaultImports

它的作用是允许默认导入没有设置默认导出(export default xx)的模块,可以以 import xx from 'xx' 的形式来引入模块

1
2
3
4
5
6
7
// 配置前
import * as React from 'react'
import * as ReactDOM from 'react-dom'

// 配置后
import React from 'react'
import ReactDOM from 'react-dom'

总结

我们在 重温 TypeScript 一节当中梳理了一些 TypeScript 的基础内容,也算是回顾一下 TypeScript 的基本用法,而在本章当中则是简单的介绍了一些比较常用的姑且算是进阶的内容

但是 TypeScript 当中所涉及到的内容并不仅仅只有我们介绍到的这些,其它一些相关内容比如 函数输入的类型推论 或是 多态 等平常很少涉及到的内容,我们也就没有多做提及,不过以后如果在工作当中遇到相关内容的话会再来完善这两章的相关内容

更多相关内容可以参考 官方手册 来了解更多,中文版可见 TypeScript 中文

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×