156

TypeScript 简明教程

TypeScript 是 JavaScript 的一个超集,它添加了可选的静态类型和基于类的面向对象编程。

介绍

任何可以使用 JavaScript 来编写的优秀的大型应用程序,最终都会由 TypeScript 编写。 —— 韩骏

编程语言类型

  • 动态类型语言(Dynamically Typed Language),如 JavaScript、Python、Ruby 等
  • 静态类型语言(Statically Typed Language),如 Java、C#、C++ 等

TypeScript 是什么

TypeScript 是 JavaScript 的一个超集,主要提供了类型系统和对 ES6-ES10 的支持。TypeScript 由 Microsoft 开发,是 JavaScript 的一个开源语言。

为什么要使用 TypeScript

  • 代码更加健壮,更少的 Bug ,在编译阶段就能发现大部分错误,减少线上 Bug
  • 函数或者类的参数类型更加明确,代码本身就是一种文档
  • 更好的 IDE 支持,包括代码提示、类型提示、自动补全等
  • 包容性更好,TypeScript 是 JavaScript 的超集,可以很好的兼容 JavaScript 代码

缺点:增加了学习成本,需要学习 TypeScript 的语法,需要编译成 JavaScript 才能运行。

安装 TypeScript

需要先安装 Node.js,然后使用 npm 安装 TypeScript。

npm install -g typescript

检查是否安装成功

tsc -v

tsc 即 TypeScript Compiler,是 TypeScript 的编译器。

Hello World

新建一个 hello.ts 文件,输入以下代码:

function hello(person: string) {
  // 指定 person 参数类型为 string
  return 'Hello, ' + person;
}
 
hello('TypeScript');

编译成 JavaScript 文件:

tsc hello.ts

会生成一个 hello.js 文件,内容如下:

function hello(person) {
  return 'Hello, ' + person;
}
 
hello('TypeScript');

如果传入的参数不是字符串:

hello(123);

编译时会报错:

tsc hello.ts
hello.ts:2:12 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

我们会发现,编译后的 JavaScript 文件中没有类型信息,这是因为 TypeScript 本质上是开发时的工具,编译后的 JavaScript 文件是为了在浏览器中运行的,所以不需要类型信息。

基本类型

基础类型

  • 布尔值(Boolean)
  • 数字(Number)
  • 字符串(String)
  • 数组(Array)
  • 长整型(BigInt)
  • Symbol
  • Null
  • Undefined

布尔值(Boolean)

let isDone: boolean = false;

数字(Number)

ES6 提供了二进制和八进制数值的新的写法,分别用前缀 0b(或 0B)和 0o(或 0O)表示。

let decLiteral: number = 6;
let hexLiteral: number = 0xf00d;
let binaryLiteral: number = 0b1010;
let octalLiteral: number = 0o744;

字符串(String)

let name: string = 'bob';
name = 'smith';

另外还可以使用模板字符串,它可以定义多行文本和内嵌表达式。这种字符串是被反引号包围,并且以 ${ expr } 这种形式嵌入表达式。

let name: string = `Gene`;
let age: number = 37;
let sentence: string = `Hello, my name is ${ name }. I'll be ${ age + 1 } years old next month.`;

数组(Array)

let list: number[] = [1, 2, 3];
let list: Array<number> = [1, 2, 3];

长整型(BigInt)

const max: bigint = 9007199254740991n;

Symbol

let sym1 = Symbol();
let sym2 = Symbol('key');

Null

let n: null = null;

Undefined

let u: undefined = undefined;

Any

在 TypeScript 中,任何类型都可以赋值给 any 类型,而 any 类型也可以赋值给任何类型。

let notSure: any = 4;
notSure = 'maybe a string instead';
notSure = false; // okay, definitely a boolean

联合类型

联合类型(Union Types)表示取值可以为多种类型中的一种。

let myFavoriteNumber: string | number = 'seven';
myFavoriteNumber = 'seven';
myFavoriteNumber = 7;

但是,访问联合类型的属性或方法时,只能访问此联合类型的所有类型里共有的属性或方法,不能访问其他类型的属性或方法。

数组

let fibonacci: number[] = [1, 1, 2, 3, 5];

或者使用数组泛型,泛型将在后面介绍。

let fibonacci: Array<number> = [1, 1, 2, 3, 5];

类数组:

function sum() {
  let args: {
    [index: number]: number;
    length: number;
    callee: Function;
  } = arguments;
}

类数组不是数组类型,比如 arguments。常见的类数组都有自己的接口定义,如 IArguments, NodeList, HTMLCollection 等,类数组不具备数组的一些方法,比如 push,pop 等,所以在 TypeScript 中,类数组只能用接口表示。

元组(Tuple)

元组(Tuple)合并了不同类型的对象,比如一个数组合并了多种类型的对象。

let xcatliu: [string, number] = ['Xcat Liu', 25];

接口

Interface 接口可以用来定义对象的类型。

功能

  • 描述对象的形状(Shape)
  • 对类(Class)进行抽象
  • Duck Typing(鸭子类型),即如果一个对象有某些属性,那么就可以认为它是某种类型

接口定义

interface Person {
  name: string;
  age: number;
}
 
let tom: Person = {
  name: 'Tom',
  age: 25,
};

倘若不一致:

interface Person {
  name: string;
  age: number;
}
 
let tom: Person = {
  name: 'Tom',
};

那么会报错:

Type '{ name: string; }' is not assignable to type 'Person'.
  Property 'age' is missing in type '{ name: string; }' but required in type 'Person'.

可选属性

当然,也可以定义可选属性:

interface Person {
  name: string;
  age?: number;
}
 
let tom: Person = {
  name: 'Tom',
};

由于可选属性的存在,那么就不会报错了。

只读属性

interface Person {
  readonly id: number;
  name: string;
  age?: number;
}
 
let tom: Person = {
  id: 89757,
  name: 'tom',
  age:10,
};
 
tom.id = 9527;

上述代码会报错:

Cannot assign to 'id' because it is a read-only property.

函数

函数时一等公民,可以作为参数传递,也可以作为返回值返回。

函数定义

function add(x: number, y: number): number {
  return x + y;
}
 
let result = add(1, 2);

第一、第二个 number 表示参数类型,第三个 number 表示函数的返回值类型。

如果过多或过少的参数,那么会报错:

function add(x: number, y: number): number {
  return x + y;
}
 
let result = add(1);
Type '(x: number) => number' is not assignable to type '(x: number, y: number) => number'.
  Types of parameters 'x' and 'x' are incompatible.
    Type 'number' is not assignable to type 'string'.

可选参数

function add(x: number, y?: number): number {
  return y ? x + y : x;
}
 
let result = add(1);

可选参数只能放在最后,否则会报错:

function add(x?: number, y: number): number {
  return y ? x + y : x;
}
 
let result = add(1);
Type '(x?: number, y: number) => number' is not assignable to type '(x: number, y?: number) => number'.
  Types of parameters 'x' and 'x' are incompatible.
    Type 'number' is not assignable to type 'number | undefined'.

函数表达式

const add = function (x: number, y: number): number {
  return x + y;
};
 
let result = add(1, 2);

箭头函数

在 ES6 中,可以使用箭头函数,简化函数表达式的定义:

const add = (x: number, y: number): number => {
  return x + y;
};
 
let result = add(1, 2);

类型推断

在 TypeScript 中,如果没有明确指定类型,那么 TS 会自动推断出一个类型:

const add = (x: number, y: number) => {
  return x + y;
};
 
let result = add(1, 2);

此时,result 的类型会被推断为 number

面向对象

  • 类(class):抽象的概念,是一类事物的抽象,是一类事物的模板,是一类事物的蓝图
  • 对象(object):具体的事物,是类的实例,是类的具体表现
  • 面向对象(OOP):是一种编程思想,是一种解决问题的思路和方法,有三大特征:封装、继承、多态

类的定义

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  run() {
    return `${this.name} is running`;
  }
}

上面的代码定义了一个类 Animal,它包含了两个成员:namerun。其中,name 是一个实例属性,run 是一个实例方法。注意,实例属性必须有初始值或者在构造函数中被初始化

constructor 是构造函数,它会在类被实例化的时候被调用,用来初始化实例属性。上面的代码中,constructor 接受一个 name 参数,然后把它赋值给 name 实例属性,这个过程是在实例化的时候自动完成的。

我们可以创建一个 Animal 的实例:

const snake = new Animal('lily');
console.log(snake.run()); // lily is running

:::tip ts-node ts-node 是一个 TypeScript 的执行环境,可以直接在命令行中运行 TypeScript 代码,不需要编译成 js 文件。如果你使用的是 ts-node,那么你可以直接在命令行中运行上面的代码,不需要编译成 js 文件。实际上是 ts-node 帮我们编译成了 js 文件,然后再执行的。 :::

类的继承

class Dog extends Animal {
  bark() {
    return `${this.name} is barking`;
  }
}

然后调用 Dog 的实例方法:

const xiaobao = new Dog('xiaobao');
console.log(xiaobao.run()); // xiaobao is running
console.log(xiaobao.bark()); // xiaobao is barking

还可以重写构造函数:

class Cat extends Animal {
  constructor(name) {
    super(name);
    console.log(this.name);
  }
  run() {
    return 'Meow, ' + super.run();  // 注意这里的 super
  }
}
 
const maomao = new Cat('maomao');
console.log(maomao.run()); // Meow, maomao is running

在构造函数中,必须使用 super 调用父类的构造函数,否则会报错。这是因为子类实例的创建,是基于父类实例的创建的,如果不调用 super,子类实例就得不到 this 对象,而 this 对象是实例化类时最为重要的一个环节。

类的修饰符

TypeScript 中有三种修饰符:publicprotectedprivate

public

public 修饰符是默认的修饰符,可以省略不写。public 修饰符修饰的属性或方法是公有的,可以在任何地方被访问到。

class Animal {
  public name: string;
  public constructor(name: string) {
    this.name = name;
  }
  public run() {
    return `${this.name} is running`;
  }
}
 
const snake = new Animal('lily');
// 更新 name 属性
snake.name = 'lucy'; // 正确

praivate

private 修饰符修饰的属性或方法是私有的,不能在声明它的类的外部访问。在子类中也是不允许访问的

class Animal {
  private name: string;
  public constructor(name: string) {
    this.name = name;
  }
  public run() {
    return `${this.name} is running`;
  }
}
 
const snake = new Animal('lily');
// 更新 name 属性
snake.name = 'lucy'; // 报错

protected

protected 修饰符修饰的属性或方法是受保护的,它和 private 类似,区别是它在子类中也是允许被访问的

class Animal {
  protected name: string;
  public constructor(name: string) {
    this.name = name;
  }
  public run() {
    return `${this.name} is running`;
  }
}
 
// 更新 name 属性
snake.name = 'lucy'; // 报错
 
 
// 在子类中访问
 
class Dog extends Animal {
  bark() {
    return `${this.name} is barking`;   // 正确
  }
}
 
const xiaobao = new Dog('xiaobao');
console.log(xiaobao.bark()); // xiaobao is barking

readonly

readonly 修饰符修饰的属性只读,不能被修改。由于 readonly 修饰符修饰的属性是只读的,所以必须在声明时或构造函数中被初始化。被它修饰的属性和方法被称为静态属性和方法。

class Animal {
  public readonly name: string;
  public constructor(name: string) {
    this.name = name;
  }
}
 
const snake = new Animal('lily');
// 更新 name 属性
snake.name = 'lucy'; // 报错

下面是一个静态方法的例子:

class Animal {
  public static categories: string[] = ['mammal', 'bird'];
  public static isAnimal(a) {
    return a instanceof Animal;
  }
}
 
console.log(Animal.categories); // ['mammal', 'bird']

类和接口

接口可以对类的一部分行为进行抽象,只要类实现了接口中的方法,就可以认为它实现了这个接口。

在下面的例子中,carcellphone 都实现了 Radio 接口,所以它们都有 switchRadio 方法。我们可以把 Radio 接口看成是一个规范,只要满足这个规范的类都可以实现这个接口。

class Car{
    switchRadio(trigger: boolean) {
        // ...
    }
}
 
class Cellphone {
    switchRadio(trigger: boolean) {
        // ...
    }
}

实现 Radio 接口:

interface Radio {
    switchRadio(trigger: boolean): void;
}

CarCellphone 类中使用 implements 关键字实现 Radio 接口:

class Car implements Radio {
    switchRadio(trigger: boolean) {
        // ...
    }
}
 
class Cellphone implements Radio {
    switchRadio(trigger: boolean) {
        // ...
    }
}

备注:如果不实现 Radio 接口中的 switchRadio 方法,那么 Car 类就会报错。

可以实现多个接口,用逗号隔开:

interface Radio {
    switchRadio(trigger: boolean): void;
}
 
interface Battery {
    checkBatteryStatus(): void;
}
 
class cellPhone implements Radio, Battery {
    switchRadio(trigger: boolean) {
        // ...
    }
 
    checkBatteryStatus() {
        // ...
    }
}

甚至可以将接口继承自另一个接口:

interface Radio {
    switchRadio(trigger: boolean): void;
}
 
interface Battery {
    checkBatteryStatus(): void;
}
 
interface RadioWithBattery extends Radio {
    checkBatteryStatus(): void;
}
 
class cellPhone implements RadioWithBattery {   // 这里只用写 RadioWithBattery 接口就可以了
    switchRadio(trigger: boolean) {
        // ...
    }
 
    checkBatteryStatus() {
        // ...
    }
}

枚举

数字枚举

enum Direction {
  Up = 1,
  Down,
  Left,
  Right,
}

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

console.log(Direction.Up); // 1
console.log(Direction[1]); // Up

当然我们可以手动赋值:

enum Direction {
  Up = 10,
  Down,
  Left,
  Right,
}

此时,Up 为 10,Down 为 11,依次类推。

它会被编译为:

var Direction;
(function (Direction) {
  Direction[(Direction["Up"] = 10)] = "Up";
  Direction[(Direction["Down"] = 11)] = "Down";
  Direction[(Direction["Left"] = 12)] = "Left";
  Direction[(Direction["Right"] = 13)] = "Right";
})(Direction || (Direction = {})); // 这里的 Direction || (Direction = {}) 是为了防止重复声明

字符串枚举

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

假如从服务器获取到的数据是 UP,那么我们可以通过 Direction.Up 来获取到 UP

const value = "UP";
if (value === Direction.Up) {
  // ...
}

常量枚举

const enum Direction {
  Up,
  Down,
  Left,
  Right,
}

使用常量枚举可以提升性能,因为它会在编译阶段被移除。

const enum Direction {
  Up,
  Down,
  Left,
  Right,
}
 
const value=1;
if (value === Direction.Up) {
  // ...
}

编译后:

const value = 1;
if (value === 0 /* Up */) {
  // ...
}

泛型

泛型是 TypeScript 中最难理解的概念之一,但是它也是 TypeScript 中最强大的特性之一。泛型提供了一种在定义函数、接口或类的时候,不预先指定具体类型的方式。

为什么需要泛型

如果用 js 来实现一个函数来 return 传入的参数,那么就是这样的:

function identity(arg) {
  return arg;
}

那么在 ts 中,我们应该怎样写呢?

function identity(arg: number): number {
  return arg;
}

但是这样写的话,我们就只能传入 number 类型的参数了,如果我们想传入 string 类型的参数呢?

any 类型来实现:

function identity(arg: any): any {
  return arg;
}

但是这会导致我们丢失一些信息,比如传入的参数类型和返回的参数类型应该是相同的。如果我们传入一个数字,我们只知道任何类型的值都有可能被返回。例如:

let output :string = identity(123);

上面的例子中,我们传入了一个数字,但是却返回了一个字符串,这行代码不会报错,但是也不会得到我们想要的结果。

这时候,我们就可以使用泛型了。

function identity<T>(arg: T): T {
  return arg;
}

这里的 T 是我们创建的泛型的名称,我们可以在函数体中使用这个泛型。实质上它是一个占位符,我们可以传入任何类型的参数,比如:

let output = identity('myString'); // type of output will be 'string'

这里还涉及了类型推论,因为我们传入了一个字符串,所以编译器会自动推断出我们的参数类型为 string,而不是 any。

如果我们想要的和传入的参数类型不一样:

function loggingIdentity<T>(arg: T): T {
  return arg;
}
 
const res:string = loggingIdentity(123);

这里会报错,因为我们传入的参数类型是 number,但是我们想要的参数类型是 string。

下面是一个更复杂的例子:

function swap<T, U>(tuple: [T, U]): [U, T] {
  return [tuple[1], tuple[0]];
}
 
swap([7, 'seven']); // ['seven', 7]
swap([true, 123]); // [123, true]

这里我们传入了一个元组,然后返回了一个元组,但是元组中的类型是反过来的。

泛型约束

有下面一个例子:

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length);  // Error: T doesn't have .length
  return arg;
}

这里会报错,因为我们传入的参数可能没有 length 属性,所以我们需要对传入的参数进行指定。

function loggingIdentity<T>(arg: T[]): T[] {
  console.log(arg.length);  // Error: T doesn't have .length
  return arg;
}
 
const res = loggingIdentity([1, 2, 3]);

但是我们可以进一步的优化,我们可以使用泛型约束来实现:

interface Lengthwise {
  length: number;
}
 
function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);  // Now we know it has a .length property, so no more error
  return arg;
}
 
const res = loggingIdentity([1, 2, 3]);
const res2 = loggingIdentity({length: 10, value: 3});
const res3 = loggingIdentity('123'); 
const res4 = loggingIdentity(123); // Error, number doesn't have a .length property

上面的代码表示传入的参数必须包含 length 属性。否则会报错。

类和接口中的泛型

我们同样可以在类和接口中使用泛型。我们要实现一个队列类,它有一个 push 方法和一个 pop 方法,我们可以使用数组来实现:

class Queue {
  private data = [];
  push(item) {
    return this.data.push(item);
  }
  pop() {
    return this.data.shift();
  }
}
 
const queue = new Queue();
queue.push(1);
queue.push('str');
console.log(queue.pop().toFixed()); // 1 ,toFix()方法是number类型的方法
console.log(queue.pop().toFixed()); // Error, toFix()方法是number类型的方法,但是这里是string类型

在上面的例子中,我们可以看到,我们可以向队列中添加任何类型的数据,但是当我们调用 pop 方法的时候,我们只能得到 any 类型的数据,这样就会导致我们调用 any 类型的方法的时候会报错。我们希望在任何时候,我们推入和弹出的数据类型是一致的。

class Queue<T> {
  private data = [];
  push(item: T) {
    return this.data.push(item);
  }
  pop(): T {
    return this.data.shift();
  }
}
 
const queue = new Queue<number>();
queue.push(1);
queue.push('str'); // Error, 'str' is not assignable to parameter of type 'number'
console.log(queue.pop().toFixed()); // 1 ,toFix()方法是number类型的方法
 
const queue2 = new Queue<string>();
queue2.push('str');
queue2.push(1); // Error, 1 is not assignable to parameter of type 'string'
console.log(queue2.pop().length); // 3 ,length是string类型的方法

接口

我们可以使用接口来定义一个类的结构,我们可以使用泛型来定义一个接口的结构。

interface KeyPair<T, U> {
  key: T;
  value: U;
}
 
let kp1: KeyPair<number, string> = { key: 123, value: 'str' }; // OK
let kp2: KeyPair<string, number> = { key: 'str', value: 123 }; // OK
let kp3: KeyPair<number, string> = { key: 123, value: 123 }; // Error, 123 is not assignable to type

前面提到数组初始化的时候,我们可以使用泛型来定义数组的类型,我们也可以使用泛型来定义数组的结构。

let arr: Array<number> = [1, 2, 3];
let arr2: Array<string> = ['1', '2', '3'];

同样也可使用泛型来描述一个函数类型。

interface IPlus<T> {
  (a: T, b: T): T;
}
 
function plus(a: number, b: number): number {
  return a + b;
}
 
function connect(a: string, b: string): string {
  return a + b;
}
 
const a: IPlus<number> = plus;
const b: IPlus<string> = connect;

类型别名和断言

类型别名

类型别名(Type Aliases)是 TypeScript 1.9 版本中引入的新特性,它可以给一个类型起一个新的名字。

type PlusType = (x: number, y: number) => number;
 
function add(x: number, y: number): number {
  return x + y;
}
 
const add2: PlusType = add;

另一个例子:

type NameResolver = () => string;
type NameOrResolver = string | NameResolver;
function getName(n: NameOrResolver): string {
  if (typeof n === "string") {
    return n;
  } else {
    return n();
  }
}

类型断言

类型断言(Type Assertion)可以用来手动指定一个值的类型。

function getLength(input: string | number): number {
  const str = input as String;  // 类型断言,注意这里的写法,String 不是 string,这里是一个对象
  if (str.length) {
    return str.length;
  } else {
    const number = input as Number;
    return number.toString().length;
  }
}

或者可以这样写:

function getLength(input: string | number): number {
  const str = <string>input;  // 类型断言
  if (str.length) {
    return str.length;
  } else {
    const number = <number>input;
    return number.toString().length;
  }
}

声明文件

声明文件(Declaration Files)是一个全局的概念,它是用来描述一个库的 API 的,比如 jQuery、React、Vue 等等。

例如,我们想使用 jQuery,那么我们需要引入 jQuery 的声明文件:

// index.ts
import $ from 'jquery';
declare var jQuery: (selector: string) => any;

可以将这个文件命名为 index.d.ts,然后在 tsconfig.json 中配置 "include": ["index.d.ts"]。在别的文件中就可以使用 jQuery 了。

// index.ts
import $ from 'jquery';
jQuery('#foo');

实际上,jQuery 的声明文件已经在 @types/jquery 中了,我们可以直接安装使用:

npm install @types/jquery --save-dev

这里的 @types 是一个 npm 包,它包含了很多第三方库的声明文件。包括但不限于:jQuery、React、Vue、Angular、Node 等等。

高级特性

交叉类型

交叉类型(Intersection Types)是将多个类型合并为一个类型。

interface DogInterface {
  run(): void;
}
 
interface CatInterface {
  jump(): void;
}
 
let pet: DogInterface & CatInterface = {
  run() {},
  jump() {}
};

交叉类型是将多个类型合并为一个类型,这就意味着这个类型拥有了所有类型的特性。

类型保护和类型守卫

类型保护(Type Guards)是一些表达式,它们会在运行时检查以确保在某个作用域里的类型。

function add(first: string | number, second: string | number) {
  if(typeof first === 'string' || typeof second === 'string') {
    return `${first}${second}`;
  }
    return first + second;
}

这段代码具体来说,就是在 add 函数中,我们使用了类型保护,它的作用是在运行时检查 firstsecond 的类型,如果是 string 类型,就执行拼接字符串的操作,否则就执行加法操作。

或者看下面这个例子:

interface IA {
    a1:1,
    a2:2
}
 
interface IB {
    b1:1,
    b2:2
}
 
function fn1(obj:IA|IB){
    if(obj.a1){
        console.log(obj.a1)
    }else{
        console.log(obj.b1)
    }
}

这段代码执行时,会报错,因为 obj 的类型是 IA | IB,而 obj.a1obj.b1 都可能不存在,所以会报错。

我们可以使用类型保护来解决这个问题:

interface IA {
    a1:1,
    a2:2
}
 
interface IB {
    b1:1,
    b2:2
}
 
// 他的返回值是一个类型谓词
function getIsIA(obj:IA|IB):obj is IA{
    return (obj as IA).a1;
}
 
function fn1(obj:IA|IB){
    if(getIsIA(obj)){
        console.log(obj.a1)
    }else{
        console.log(obj.b1)
    }
}