一起学 TypeScript 基础篇

8,763 阅读33分钟

今年10月初尤雨溪在 GitHub 发布了 vue3 的 Pre-Alpha 版本源码,同时大部分源码使用了 TypeScript 语言进行编写。可以说 TypeScript 已经成为前端开发未来的趋势。

本篇大部分内容讲 TypeScript 的基础知识,后续内容会更新介绍 TypeScript 在工作中的项目开发及运用。如果您想要得到最新的更新,可以点击下面的链接:

TypeScript开发教程 文档版

TypeScript开发教程 GitHub

什么是 TypeScript

TypeScript 是一种由微软开发的自由和开源的编程语言,它是 JavaScript 的一个超集,扩展了 JavaScript 的语法。

安装 TypeScript

通过 npm 安装

$ npm install typescript -g

以上命令会在全局环境下安装 tsctsserver 两个命令,安装完成之后,我们就可以在任何地方执行它了。

tsserver

TypeScript 独立服务器(又名 tsserver )是一个节点可执行文件,它封装了 TypeScript 编译器和语言服务,并通过 JSON 协议公开它们。tsserver 非常适合编辑器和 IDE 支持。

一般工作中不常用到它。进一步了解tsserver

tsc

tsc 为 typescript compiler 的缩写,即 TypeScript 编译器,用于将 TS 代码编译为 JS 代码。使用方法如下:

$ tsc index.ts

编译成功后,就会在相同目录下生成一个同名 js 文件,你也可以通过命令参数来修改默认的输出名称。

默认情况下编译器以 ECMAScript 3(ES3)为目标。可以通过 tsc -h 命令查看相关帮助,可以了解更多的配置。

我们约定使用 TypeScript 编写的文件以 .ts 为后缀,用 TypeScript 编写 React 时,以 .tsx 为后缀。

Hello TypeScript

结合 tsc 命令,我们一起写一个简单的例子。

创建一个 index.ts 文件。

let text: string = 'Hello TypeScript'

执行 tsc index.ts 命令,会在同目录下生成 index.js 文件。

var text = 'Hello TypeScript';

一个简单的例子就实现完了。我们可以通过官网提供的 Playground 进行验证。

但是在项目开发过程中我们会结合构建工具,如 webpack,和对应的本地服务 dev-server 等相关工具一同使用。

接下来把我们了解到的知识结合在一起。搭建一个完整的项目

项目根目录中有一个 tsconfig.json 文件,简单介绍其作用。

tsconfig.json

如果一个目录下存在一个 tsconfig.json 文件,那么它意味着这个目录是 TypeScript 项目的根目录。tsconfig.json 文件中指定了用来编译这个项目的根文件和编译选项。 一个项目可以通过以下方式之一来编译:

  • 不带任何输入文件的情况下调用 tsc,编译器会从当前目录开始去查找 tsconfig.json文 件,逐级向上搜索父目录。
  • 不带任何输入文件的情况下调用 tsc,且使用命令行参数 --project(或 -p )指定一个包含 tsconfig.json 文件的目录。

当命令行上指定了输入文件时,tsconfig.json文件会被忽略。

基础类型

TypeScript 支持与 JavaScript 几乎相同的数据类型。

JavaScript 数据类型

String、Number、Boolean、Object(Array、Function)、Symbol、undefined、null

TypeScript 新增数据类型

void、any、never、元组、枚举、高级类型

类型注解

作用:相当于强类型语言中的类型声明

语法:(变量/函数): type

介绍

字符串类型

我们使用 string 表示文本数据类型。 和 JavaScript 一样,可以使用双引号 " 或单引号 ' 表示字符串, 反引号 ` 来定义多行文本和内嵌表达式。

let str: string = 'abc'

数字类型

和 JavaScript 一样,TypeScript 里的所有数字都是浮点数。这些浮点数的类型是 number。 除了支持十进制和十六进制字面量,TypeScript 还支持 ECMAScript 2015 中引入的二进制和八进制字面量。

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

布尔类型

我们使用 boolean 表示布尔类型,表示逻辑值 true / false

let bool: boolean = true

数组类型

TypeScript 有两种定义数组的方式。 第一种,可以在元素类型后加上 []。 第二种,可以使用数组泛型 Array<元素类型>。 此外,在元素类型中可以使用联合类型。 符号 | 表示或。

let arr1: number[] = [1, 2, 3]
let arr2: Array<number> = [1, 2, 3]
let arr3: Array<number | string> = [1, 2, 3, 'a']

元组

元组类型用来表示已知元素数量和类型的数组,各元素的类型不必相同,对应位置的类型必须相同。

let tuple: [number, string] = [0, '1']
tuple = ['1', 0] // Error

当访问一个已知索引的元素,会得到正确的类型:

tuple[0].toFixed(2)
tuple[1].toFixed(2) // Error: Property 'toFixed' does not exist on type 'string'.

可以调用数组 push 方法添加元素,但并不能读取新添加的元素。

tuple.push('a')
console.log(tuple) // [0, "1", "a"]
tuple[2] // Error: Tuple type '[number, string]' of length '2' has no element at index '2'.

枚举

我们使用 enum 表示枚举类型。 枚举成员值只读,不可修改。 枚举类型是对 JavaScript 标准数据类型的一个补充。C# 等其它语言一样,使用枚举类型为一组数值赋予友好的命名。

数字枚举

初始值为 0, 逐步递增,也可以自定义初始值,之后根据初始值逐步递增。

enum Role {
  Reporter = 1,
  Developer,
  Maintainer,
  Owner,
  Guest
}

console.log(Role.Developer) // 2
console.log(Role[2]) // Developer

数字枚举会反向映射,可以根据索引值反向获得枚举类型。原因如下编译后代码所示:

var Role;
(function (Role) {
    Role[Role["Reporter"] = 1] = "Reporter";
    Role[Role["Developer"] = 2] = "Developer";
    Role[Role["Maintainer"] = 3] = "Maintainer";
    Role[Role["Owner"] = 4] = "Owner";
    Role[Role["Guest"] = 5] = "Guest";
})(Role || (Role = {}));

字符串枚举

字符串枚举不支持反向映射

enum Message {
  Success = '成功',
  Fail = '失败'
}

常量枚举

在枚举关键字前添加 const,该常量枚举会在编译阶段被移除。

const enum Month {
  Jan,
  Feb,
  Mar
}
let month = [Month.Jan, Month.Feb, Month.Mar]

编译后:

"use strict";
var month = [0 /* Jan */, 1 /* Feb */, 2 /* Mar */]; // [0 /* Jan */, 1 /* Feb */, 2 /* Mar */]

外部枚举

外部枚举(Ambient Enums)是使用 declare enum 定义的枚举类型。

declare enum Month {
  Jan,
  Feb,
  Mar
}
let month = [Month.Jan, Month.Feb, Month.Mar]

编译后:

"use strict";
let month = [Month.Jan, Month.Feb, Month.Mar];

declare 定义的类型只会用于编译时的检查,编译结果中会被删除。所以按照上述例子编译后的结果来看,显然是不可以的。因为 Month 未定义。

  • declareconst 可以同时存在

对象

TypeScript 有两种定义对象的方式。 第一种,可以在元素后加上 object。 第二种,可以使用 { key: 元素类型 } 形式。 同样在元素类型中可以使用联合类型。注意第一种形式对象元素为只读。

let obj1: object = { x: 1, y: 2 }
obj1.x = 3 // Error: Property 'x' does not exist on type 'object'.

let obj2: {  x: number, y: number } = { x: 1, y: 2 }
obj2.x = 3

Symbol

symbol 类型的值是通过 Symbol 构造函数来创建

let s: symbol = Symbol()

Null & Undefined

null 表示对象值缺失,undefined 表示未定义的值。

let un: undefined = undefined
let nu: null = null

若其他类型需要被赋值为 nullundefined 时, 在 tsconfig.json 中将 scriptNullChecks 设置为 false。或者 使用联合类型。

void

用于标识方法返回值的类型,表示该方法没有返回值。

function noReturn (): void {
  console.log('No return value')
}
  • undefined 并不是保留字段可以被赋值,所以设置undefined时,建议使用 void 0

任意类型

声明为 any 的变量可以赋予任意类型的值。

let x: any
x = 1
x = 'a'
x = {}

let arr: any[] = [1, 'a', null]

函数

我们先回顾在 JavaScript 中,使用 es6 语法定义一个函数。

let add = (x, y) => x + y

上面例子中,add 函数有两个参数 xy 返回其相加之和。 该例子放在 TypeScript 中会提示 参数 xy 隐含一个 any 类型。 所以我们修改如下:

let add = (x: number, y: number): number => x + y

给参数添加 number 类型,在括号之后也添加返回值的类型。这里返回值类型可以省略,因为 TypeScript 有类型推断机制,这个我们之后详细介绍。

接下来我们使用 TypeScript 定义一个函数类型并实现它。

let plus: (x: number, y: number) => number

plus = (a, b) => a + b

plus(2, 2) // 2

never

never 类型表示的是那些永不存在的值的类型。 例如,never 类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型;变量也可能是 never 类型,当它们被永不为真的类型保护所约束时。

never 类型是任何类型的子类型,也可以赋值给任何类型;然而,没有类型是 never 的子类型或可以赋值给 never 类型(除了 never 本身之外)。 即使 any 也不可以赋值给 never

let error = (): never => {
    throw new Error('error')
}
let endless = (): never => {
    while(true) {}
}
  • 类型推断:变量在声明时并未赋值,类型推断为 any

接口

在 TypeScript 中,我们可以使用接口 interface 来定义对象类型。

介绍

接口是一系列抽象方法的声明,是一些方法特征的集合,这些方法都应该是抽象的,需要由具体的类去实现,然后第三方就可以通过这组抽象方法调用,让具体的类执行具体的方法。

接下来,定义一个简单的接口:

interface Person {
  name: string
  age: number
}

let man: Person = {
  name: 'James',
  age: 30
}

我们定义了一个接口 Person 和变量 man,变量的类型是 Person。 这样我们就约束了该变量的值中对象的 keyvalue 要和接口一致。

需要注意的是:

  1. 接口规范首字母大写;
  2. 被赋值的变量必须和接口的定义保持一致,参数不能多也不能少;
  3. 类型检查器不会去检查属性的顺序,只要相应的属性存在并且类型正确即可。

可选属性

接口的所有属性可能都不是必需的。

interface Person {
  name: string
  age?: number
}

let man: Person = {
  name: 'James'
}

只读属性

属性名前使用 readonly 关键字制定为只读属性,初始化后不可更改。

interface Person {
  readonly name: string
  age: number
}

let man: Person = {
  name: 'James',
  age: 30
}

man.name = 'Tom' // Error: Cannot assign to 'name' because it is a read-only property.

任意属性

用任意的字符串索引,使其可以得到任意的结果。

interface Person {
  name: string
  age: number
  [x: string]: any
}

let man: Person = {
  name: 'James',
  age: 30,
  height: '180cm'
}

除了 nameage 必须一致以外,其他属性可以随意定义数量不限。

  • 一旦定义了任意属性,那么其他属性的类型必须是任意属性类型的子集。
interface Person {
  name: string
  age: number
  [x: string]: string
}

let man: Person = {
  name: 'James',
  age: 30,
  height: '180cm'
}

/**
 * Type '{ name: string; age: number; height: string; }' is not assignable to type 'Person'.
 * Property 'age' is incompatible with index signature.
 * Type 'number' is not assignable to type 'string'.
 */

数字索引

可以得到任意长度的数组。

interface StringArray {
  [i: number]: string
}
let chars: StringArray = ['a', 'b']

接口能够描述 JavaScript 中对象拥有的各种各样的外形。 除了描述带有属性的普通对象外,接口也可以描述对象类型函数类型

对象类型接口

示例如下:

interface List {
  readonly id: number
  name: string
  age?: number
}

interface Result {
  data: List[]
}

function render (result: Result) {
  console.log(JSON.stringify(result))
}

首先我们定义了一个 List 对象接口,它的内部有 idnameage 属性。接下来我们又定义了一个对象接口,这个对象接口有只一个属性 data,它类型为 List[]。接下来有一个函数,参数类型为 Result

接下来我们定义一个变量 result,将它传入 render 函数。

let result = {
  data: [
    { id: 1, name: 'A', sex: 'male' },
    { id: 2, name: 'B' }
  ]
}

render(result)

这里需要注意 data 数组内的第一个对象里,增加了一个 sex 属性,但是在上面的接口定义中没有 sex 属性。这时把对象赋给 result 变量,传入函数,不会被编译器检查到。

再看下面的例子:

render({
  data: [
    { id: 1, name: 'A', sex: 'male' },
    { id: 2, name: 'B' }
  ]
})
// Error: Object literal may only specify known properties, and 'sex' does not exist in type 'List'.

我们将对象字面当做参数传给了 render 函数时,编译器会对对象内的属性进行检查。

我们可以通过类型断言规避这个问题

render({
  data: [
    { id: 1, name: 'A', sex: 'male'},
    { id: 2, name: 'B' }
  ]
} as Result)

除了使用 as 关键字,还可以用 <> 符号:

render(<Result>{
  data: [
    { id: 1, name: 'A', sex: 'male'},
    { id: 2, name: 'B' }
  ]
})

函数类型接口

为了使用接口表示函数类型,我们需要给接口定义一个调用签名。 它就像是一个只有参数列表和返回值类型的函数定义。参数列表里的每个参数都需要名字和类型。

在数据类型中我们提到过,可以用一个变量声明一个函数类型。

let add: (x: number, y: number) => number

此外,我们还可以用接口来定义它。

interface Add {
  (x: number, y: number): number
}

let add: Add = (a, b) => a + b

除此之外,还有一种更简洁的方式就是使用类型别名

类型别名使用 type 关键字

type Add = (x: number, y: number) => number

let add: Add = (a, b) => a + b
  • interface 定义函数(Add)和用 type 定义函数(Add)有区别?

typeinterface 多数情况下有相同的功能,就是定义类型。 但有一些小区别:
type:不是创建新的类型,只是为一个给定的类型起一个名字。type还可以进行联合、交叉等操作,引用起来更简洁。
interface:创建新的类型,接口之间还可以继承、声明合并。建议优先使用 interface

函数

和 JavaScript 一样,TypeScript 函数可以创建有名字的函数或匿名函数,TypeScript 为 JavaScript 函数添加了额外的功能,让我们可以更容易的使用它。

在基本类型和接口部分中多多少少提到过函数,接下来总结四种定义函数的方式。

function add (x: number, y: number) {
  return x + y
}

const add: (x: number, y: number) => number

type add = (x: number, y: number) => number

interface add {
  (x: number, y: number) => number
}

TypeScript 里的每个函数参数都是必要的。这里不是指不能把 nullundefined 当做参数,而是说编译器检查用户是否为每个参数都传入了值。也就是说,传递给一个函数的参数个数必须与函数期望的参数个数保持一致。我们举个例子:

function add (x: number, y: number, z: number) {
  return x + y
}

add(1, 2) // Error: Expected 3 arguments, but got 2.

在上述例子中,函数定义了3个参数,分别为 xyz,结果返回 xy 的和。并没有使用参数 z,调用 add 只传入 xy 的值。这时 TypeScript 检查机制提示预期为三个参数,但实际只传入两个参数的错误。如何避免这种情况呢?

可选参数

在 TypeScript 里我们可以在参数名旁使用 ? 实现可选参数的功能。

function add (x: number, y: number, z?: number) {
  return x + y
}

add(1, 2)

经过修改,参数 z 变为可选参数,检查通过。

  • 可选参数必须在必选参数之后

默认参数

与 JavaScript 相同,在 TypeScript 里函数参数同样可以设置默认值,用法一致。

function add (x: number, y = 2) {
  return x + y
}

根据类型推断机制,参数 y 为推断为 number 类型。

剩余参数

与 JavaScript 相同。TypeScript 可以把所有参数收集到一个变量里。

function add (x: number, ...rest: number[]) {
  return x + rest.reduce((prev, curr) => prev + curr)
}

add(1, 2, 3) // 6
  • 剩余参数必须在必选参数之后,可选参数不允许和剩余参数共同出现在一个函数内。

函数重载

TypeScript 的函数重载,要求我们先定义一系列名称相同的函数声明。

function add (...rest: number[]): number
function add (...rest: string[]): string
function add (...rest: any[]): any {
  let first = rest[0]
  let type = typeof first
  switch (type) {
    case 'number':
      return rest.reduce((prev, curr) => prev + curr)
    case 'string':
      return rest.join('')
  }
  return null
}

上面例子中,我们定义了三个相同名称的函数,参数分别为 numberstringany 类型数组,相继返回的类型与参数类型相同。当调用该函数时,TypeScript 编译器能够选择正确的类型检查。在重载列表里,会从第一个函数开始检查,从上而下,所以我们使用函数重载时,应该把最容易用到的类型放在最上面。

  • any 类型函数不是重载列表的一部分

传统的 JavaScript 使用函数和基于原型的继承来创建可重用的组件。

function Point (x, y) {
  this.x = x
  this.y = y
}

Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')'
}

var p = new Point(1, 2)

从 ES6 开始,我们能够使用基于类的面向对象的方式。

class Point {
  constructor (x, y) {
    this.x = x
    this.y = y
  }
  toString () {
    return `(${this.x}, ${this.y})`
  }
}

TypeScript 除了保留了 ES6 中类的功能以外,还增添了一些新的功能。

class Dog {
  constructor (name: string) {
    this.name = name
  }
  name: string
  run () {}
}

class Husky extends Dog {
  constructor (name: string, color: string) {
    super(name)
    this.color = color
  }
  color: string
}

上面的例子中需要注意以下几点:

  1. 继承类中的构造函数里访问 this 的属性之前,一定要调用 super 方法;
  2. TypeScript 和 ES6 中,“类的成员属性”都是实例属性,而不是原型属性,“类的成员方法”都是“原型”方法。Dog.prototype => {constructor: ƒ, run: ƒ}new Dog('huang') => {name: "huang"}
  3. TypeScript 中实例的属性必须有初始值,或者在构造函数中被初始化。

public、private、protected、readonly

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

  • public 修饰的属性或方法是公有的,可以在任何地方被访问到,默认所有的属性和方法都是 public

  • private 修饰的属性或方法是私有的,不能在声明它的类的外部访问,包括继承它的类也不可以访问

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

  • 以上三种可以修饰构造函数,默认为 public,当构造函数为 private 时,该类不允许被继承或实例化;当构造函数为 protected 时,该类只允许被继承。

  • readonly 修饰的属性为只读属性,只允许出现在属性声明或索引签名中。

public

公共修饰符

class Animal {
  public name: string
  public constructor (name: string) {
    this.name = name
  }
}

let a = new Animal('Jack')
console.log(a.name) // Jack
a.name = 'Tom'
console.log(a.name) // Tom

private

私有修饰符

class Animal {
  private name: string
  public constructor (name: string) {
    this.name = name
  }
}

let a = new Animal('Jack')
console.log(a.name) // Error: Property 'name' is private and only accessible within class 'Animal'.

class Cat extends Animal {
  constructor (name: string) {
    super(name)
    console.log(this.name) // Error: // Property 'name' is private and only accessible within class 'Animal'.
  }
}

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

上面的例子编译后的代码如下:

var Animal = (function () {
    function Animal (name) {
        this.name = name
    }
    return Animal
}())
var a = new Animal('Jack')
console.log(a.name)

protected

受保护修饰符

class Animal {
  protected name: string
  public constructor (name: string) {
    this.name = name
  }
}

class Cat extends Animal {
  constructor (name: string) {
    super(name)
    console.log(this.name)
  }
}
  • 构造函数参数添加修饰等同于在类中定义该属性,这样使代码更为简洁。
class Animal {
  // public name: string
  constructor (public name: string) {
    this.name = name
  }
}
class Cat extends Animal {
  constructor (public name: string) {
    super(name)
  }
}

let a = new Animal('Jack')
console.log(a.name) // Jack
a.name = 'Tom'
console.log(a.name) // Tom

readonly

只读修饰符

class Animal {
  readonly name: string
  public constructor (name: string) {
    this.name = name
  }
}

let a = new Animal('Jack')
console.log(a.name) // Jack
a.name = 'Tom' //Error: Cannot assign to 'name' because it is a read-only property.

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

class Animal {
  // public readonly name: string
  public constructor (public readonly name: string) {
    this.name = name
  }
}

抽象类

abstract 用于定义抽象类和其中的抽象方法。需要注意以下两点:

抽象类不允许被实例化

abstract class Animal {
  public name: string
  public constructor (name: string) {
    this.name = name
  }
}

var a = new Animal('Jack') //Error: Cannot create an instance of an abstract class.

抽象类中的抽象方法必须被继承类实现

abstract class Animal {
  public name: string;
  public constructor (name: string) {
    this.name = name;
  }
  abstract sayHi (): any
}

class Cat extends Animal {
  public color: string
  sayHi () { console.log(`Hi`) }
  constructor (name: string, color: string) {
    super(name)
    this.color = color
  }
}

var a = new Cat('Tom', 'Blue')

类与接口

本章节主要介绍类与接口之间实现、相互继承的操作。

类实现接口

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

interface Animal {
  name: string
  eat (): void
}

class Cat implements Animal {
  constructor (name: string) {
    this.name = name
  }
  name: string
  eat () {}
}
  • 类实现接口时,必须声明接口中所有定义的属性和方法。
interface Animal {
  name: string
  eat (): void
}

class Cat implements Animal {
  constructor (name: string) {
    this.name = name
  }
  name: string
  // eat () {}
}

// Error: Class 'Cat' incorrectly implements interface 'Animal'. Property 'eat' is missing in type 'Cat' but required in type 'Animal'.
  • 类实现接口时,声明接口中定义的属性和方法不能修饰为 privateprotected
interface Animal {
  name: string
  eat (): void
}

class Cat implements Animal {
  constructor (name: string) {
    this.name = name
  }
  private name: string
  eat () {}
}

// Error: Class 'Cat' incorrectly implements interface 'Animal'. Property 'name' is private in type 'Cat' but not in type 'Animal'.
  • 接口不能约束类中的构造函数
interface Animal {
  new (name: string): void
  name: string
  eat (): void
}

class Cat implements Animal {
  constructor (name: string) {
    this.name = name
  }
  name: string
  eat () {}
}

// Error: Class 'Cat' incorrectly implements interface 'Animal'. Type 'Cat' provides no match for the signature 'new (name: string): void'.

接口继承接口

实现方法如下:

interface Animal {
  name: string
  eat (): void
}

interface Predators extends Animal {
  run (): void
}

class Cat implements Predators {
  constructor (name: string) {
    this.name = name
  }
  name: string
  eat () {}
  run () {}
}
  • 继承多个接口用 , 分割,同理实现多个接口方式相同。
interface Animal {
  name: string
  eat (): void
}
  
interface Lovely {
  cute: number
}

interface Predators extends Animal, Lovely {
  run (): void
}

class Cat implements Predators {
  constructor (name: string, cute: number) {
    this.name = name
    this.cute = cute
  }
  name: string
  cute: number
  eat () {}
  run () {}
}

接口继承类

实现方法如下:

class Auto {
  constructor (state: string) {
    this.state = state
  }
  state: string
}

interface AutoInterface extends Auto {}

class C implements AutoInterface {
  state = ''
}

混合类型

interface SearchFunc {
  (source: string, subString: string): boolean
}

let mySearch: SearchFunc
mySearch = function(source: string, subString: string) {
  return source.search(subString) !== -1
}

一个函数还可以有自己的属性和方法

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

小结

  1. 接口与接口、类与类之间可以相互继承(extends)
  2. 接口可以通过类来实现的(implements),接口只能约束类的公有成员
  3. 接口可以抽离出类的成员、包括公有(public)、私有(private)、受保护(protected)成员

泛型

泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

  • 小技巧:直接把泛型理解为代表类型的参数

简单的例子

首先,我们来实现一个函数 createArray,它可以创建一个指定长度的数组,同时将每一项都填充一个默认值:

function createArray(length: number, value: any): Array<any> {
  let result = []
  for (let i = 0; i < length; i++) {
    result[i] = value
  }
  return result
}

createArray(3, 'x') // ['x', 'x', 'x']

这段代码编译不会报错,但是一个显而易见的缺陷是,它并没有准确的定义返回值的类型:

Array<any> 允许数组的每一项都为任意类型。但是我们预期的是,数组中每一项都应该是输入的 value 的类型。

这时候,泛型就派上用场了:

function createArray<T>(length: number, value: T): Array<T> {
  let result: T[] = []
  for (let i = 0; i < length; i++) {
    result[i] = value
  }
  return result
}

createArray<string>(3, 'x') // ['x', 'x', 'x']

上例中,我们在函数名后添加了 <T>,其中 T 用来指代任意输入的类型,在后面的输入 value: T 和输出 Array<T> 中即可使用了。

接着在调用的时候,可以指定它具体的类型为 string。当然,也可以不手动指定,而让类型推断自动推算出来:

function createArray<T>(length: number, value: T): Array<T> {
  let result: T[] = [];
  for (let i = 0; i < length; i++) {
    result[i] = value
  }
  return result
}

createArray(3, 'x') // ['x', 'x', 'x']

同样类型数组也可以被类型推断。

function log<T> (value: T): T {
  console.log(value)
  return value
}

log<string[]>(['a', 'b'])
// or
log(['a', 'b'])

多个类型参数

定义泛型的时候,可以一次定义多个类型参数:

function swap<T, U>(tuple: [T, U]): [U, T] {
  return [tuple[1], tuple[0]]
}

swap([7, 'seven']) // ['seven', 7]

上例中,我们定义了一个 swap 函数,用来交换输入的元组。

泛型约束

在函数内部使用泛型变量的时候,由于事先不知道它是哪种类型,所以不能随意的操作它的属性或方法。

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length) // Error: Property 'length' does not exist on type 'T'.
  return arg
}

上例中,泛型 T 不一定包含 length 属性,所以编译的时候会报错。

这时,我们可以对泛型进行约束,只允许这个函数传入那些包含 length 属性的变量。这就叫泛型约束

interface Lengthwise {
  length: number
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length)
  return arg
}

上例中,我们使用了 extends 约束了泛型 T 必须符合接口 Lengthwise 的形状,也就是必须包含 length 属性。

此时如果调用 loggingIdentity 函数的时候,传入的参数不包含 length,那么在编译阶段就会报错了。

interface Lengthwise {
  length: number
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length)
  return arg
}

loggingIdentity(7) // Error: Argument of type '7' is not assignable to parameter of type 'Lengthwise'.

多个类型参数之间也可以相互约束。

function copyFields<T extends U, U>(target: T, source: U): T {
  for (let id in source) {
    target[id] = (<T>source)[id]
  }
  return target
}

let x = { a: 1, b: 2, c: 3, d: 4 }

copyFields(x, { b: 10, d: 20 }) // { a: 1, b: 10, c: 3, d: 20 }

上述例子中,我们使用了两个类型参数,其中要求 T 继承 U,这样就保证了 U 上不会出现 T 中不存在的字段。

泛型函数

可以用泛型来约束函数的参数和返回值类型。

type Log = <T>(value: T) => T

let log: Log = (value) => {
  console.log(value)
  return value
}

log<number>(2) // 2
log('2') // '2'
log(true) // <boolean>true 

泛型接口

之前学习过,可以使用接口的方式来定义一个函数需要符合的形状。

interface SearchFunc {
  (source: string, subString: string): boolean
}

let mySearch: SearchFunc
mySearch = function (source: string, subString: string) {
  return source.search(subString) !== -1
}

同样也可以使用含有泛型的接口来定义函数的形状。

interface CreateArrayFunc {
  <T>(length: number, value: T): Array<T>
}

let createArray: CreateArrayFunc
createArray = function<T>(length: number, value: T): Array<T> {
  let result: T[] = []
  for (let i = 0; i < length; i++) {
    result[i] = value
  }
  return result
}

createArray(3, 'x') // ['x', 'x', 'x']

进一步,我们可以把泛型参数提前到接口名上。

interface CreateArrayFunc<T> {
  <T>(length: number, value: T): Array<T>
}

let createArray: CreateArrayFunc<any>
createArray = function<T>(length: number, value: T): Array<T> {
  let result: T[] = []
  for (let i = 0; i < length; i++) {
    result[i] = value
  }
  return result
}

createArray(3, 'x') // ['x', 'x', 'x']

注意,此时在使用泛型接口的时候,需要定义泛型的类型。

若不想在使用泛型接口时定义泛型的类型,那么,需要在接口名上的泛型参数设置默认类型。

interface CreateArrayFunc<T = any> {
  <T>(length: number, value: T): Array<T>
}

let createArray: CreateArrayFunc

泛型类

与泛型接口类似,泛型也可以用于类的类型定义中。

class Log<T> {
  run (value: T) {
    console.log(value)
    return value
  }
}

let log1 = new Log<number>()
log1.run(1) // 1

let log2 = new Log()
log2.run('1') // '1'
  • 注意: 泛型不能应用于类的静态成员。
class Log<T> {
  static run (value: T) {
    console.log(value)
    return value
  }
}
// Error: Static members cannot reference class type parameters.

小结

  1. 函数和类可以轻松支持多种类型,增强程序的扩展性
  2. 不必写多条函数重载,冗长的联合类型声明,增强代码可读性
  3. 灵活控制类型之间的约束

类型检查机制

TypeScript 编译器在做类型检查时,所秉承的一些原则,以及表现出的一些行为。

本章节分为三大部分:类型推断类型兼容性类型保护

类型推断

不需要指定变化的类型(函数的返回值类型),TypeScript 可以根据某些规则自动为其推断出一个类型。

基础类型推断

基本类型推断经常出现在初始化变量的时候。

let a
// let a: any

let a = 1
// let a: number

let a = []
// let a: any[]

声明变量 a 时,我们不指定它的类型,ts 就会默认推断出它是 any 类型。

如果我们将它复制为 1ts 就会推断出它是 number 类型。

如果我们将它复制为 []ts 就会推断出它是 any 类型的数组。

基本类型推断还会出现在定义函数参数。

let a = (x = 1) => {}
// let a: (x?: number) => void

声明函数 a,设置一个参数 x,为它赋值一个默认参数 1,此时 ts 就会推断出它是 number 类型。同样返回值类型也会被推断。

最佳通用类型推断

当需要从多个类型中推断出一个类型时,ts 就会尽可能的推断出一个最佳通用类型。

let a = [1, null]
// let a: (number | null)[]

声明一个变量 a,值为一个包含数字 1null 的数组。此时,变量 a 就被推断为 numbernull 的联合类型。

以上的类型推断都是从右向左的推断,根据表达式的值推断出变量的类型。还有一种方式是从左到右,根据上下文推断。

上下文类型推断

通常发生在事件处理中。

window.onkeydown = (event) => {
}
// (parameter) event: KeyboardEvent

window 绑定 onkeydown 事件,参数为 event,此时 ts 会根据左侧的事件绑定推断出右侧事件的类型。

类型兼容性

当一个类型 Y 可以赋值给另一个类型 X 时,我们可以认为类型 X 兼容类型 Y。

X 兼容 Y : X (目标类型) = Y (源类型)

变量兼容性

let s: string = 'abc'
s = null

默认会提示 Type 'null' is not assignable to type 'string'. 如果将 tsconfig.json 内的 strictNullChecks 的值设置为 false,这时编译就不会报错。

可以说明 string 类型兼容 null 类型,nullstring 类型的子类型。

接口兼容性

示例如下:

interface X {
  a: any
  b: any
}

interface Y {
  a: any
  b: any
  c: any
}

let x: X = { a: 1, b: 2 }
let y: Y = { a: 1, b: 2, c: 3 }

x = y
y = x // Error: Property 'c' is missing in type 'X' but required in type 'Y'.

y 可以赋值给 xx 不可以赋值给 y

  • 接口之间相互赋值时,成员少的会兼容成员多的。源类型必须具备目标类型的必要属性。

函数兼容性

函数个数

示例如下:

type Handler = (a: number, b: number) => void
function hof(handler: Handler) {
  return handler
}

let handler1 = (a: number) => {}
hof(handler1)

let handler2 = (a: number, b: number, c: number) => {}
hof(handler2)
// Error: Argument of type '(a: number, b: number, c: number) => void' is not assignable to parameter of type 'Handler'.

let handler3 = (a: string) => {}
hof(handler3)
// Error: Types of parameters 'a' and 'a' are incompatible. Type 'number' is not assignable to type 'string'.

上述示例中,目标类型 handler 有两个参数,定义了三个不同的函数进行测试。

  1. handler1 函数只有一个参数,将 handler1 传入 hof 方法作为参数(兼容)
  2. handler2 函数有三个参数,同样作为参数传入 hof 方法(不兼容)。
  3. handler2 函数参数类型与目标函数参数类型不同(不兼容)
  • 函数参数个数,参数多的兼容参数少的。换句话说,参数多的可以被参数少的替换。

固定参数、可选参数、剩余参数

示例如下:

// 固定参数
let a = (p1: number, p2: number) => {}
// 可选参数
let b = (p1?: number, p2?: number) => {}
// 剩余参数
let c = (...args: number[]) => {}

a = b
a = c
b = a // Error
b = c // Error
c = a
c = b
  • 固定参数兼容可选参数和剩余参数。可选参数不兼容固定参数和剩余参数,如果将 tsconfig.json 内的 strictFunctionTypes 的值设置为 false,这时编译就不会报错。剩余参数兼容固定参数和可选参数。

复杂类型

示例如下:

interface Point3D {
  x: number
  y: number
  z: number
}

interface Point2D {
  x: number
  y: number
}
let p3d = (point: Point3D) => {}
let p2d = (point: Point2D) => {}

p3d = p2d
p2d = p3d // Error: Property 'z' is missing in type 'Point2D' but required in type 'Point3D'.
  • 成员个数多的兼容成员个数少的,这里与接口兼容性结论相反。可以把对象拆分为参数,参数多的兼容参数少的,与函数兼容性结论一致。

如果想要上述示例中的 p2d = p3d 兼容。将 tsconfig.json 内的 strictFunctionTypes 的值设置为 false

返回值类型

示例如下:

let f = () => ({ name: 'Alice' })
let g = () => ({ name: 'Alice', location: 'Beijing' })
f = g
g = f // Error
  • 目标函数的返回值类型,必须与源函数的返回值类型相同,或为其子类型。成员少的兼容成员多的。

函数重载

在函数部分中有介绍函数重载,这里我们重温一下。

function overload (a: number, b: number): number
function overload (a: string, b: string): string
function overload (a: any, b: any): any {}

函数重载分为两个部分,第一个部分为函数重载的列表,也就是第一、二个 overload 函数,也就是目标函数。第二个部分就是函数的具体实现,也就是第三个 overload 函数,也就是源函数。

  • 在重载列表中,目标函数的参数要大于等于源函数的参数。

枚举兼容性

示例如下:

enum Fruit { Apple, Banana }
enum Color { Red, Yellow }

let fruit: Fruit.Apple = 3
let no: number = Fruit.Apple

let color: Color.Red = Fruit.Apple // Error
  • 枚举类型和数值(number)类型相互兼容,枚举与枚举之间相互不兼容

类兼容性

示例如下:

class A {
  constructor (p: number, q: number) {}
  id: number = 1
}

class B {
  static s = 1
  constructor (p: number) {}
  id: number = 2
}

let aa = new A(1, 2)
let bb = new B(1)

aa = bb
bb = aa
  • 比较类与类是否兼容时,静态成员和构造函数不进行比较。成员少的兼容成员多的,父类与子类的实例相互兼容。

泛型兼容性

示例如下:

interface Empty<T> {}

let obj1: Empty<number> = {}
let obj2: Empty<String> = {}

obj1 = obj2

// 设置属性

interface Empty<T> {
  value: T
}

let obj1: Empty<number> = { value: 1 }
let obj2: Empty<String> = { value: 'a'}

obj1 = obj2 // Error
  • 泛型接口未设置任何属性时,obj1obj2 相互兼容,若此时 Empty 设置了属性 value: T 时,obj1obj2 不兼容。

泛型函数

let log1 = <T>(x: T): T => {
  console.log('x')
  return x
}
let log2 = <U>(y: U): U => {
  console.log('y')
  return y
}

log1 = log2
  • 泛型函数参数类型相同,参数多的兼容参数少的。

小结

  1. 结构之间兼容,成员少的兼容成员多的
  2. 函数之间兼容,参数多的兼容参数少的

类型保护

TypeScript 能够在特定的区块中保证变量属于某种确定的类型。

可以再此区块中放心地引用此类型的属性,或者调用此类型的方法。

enum Type { Strong, Week }

class Java {
  helloJava () {
    console.log('hello java')
  }
  java: any
}

class JavaScript {
  helloJavaScript () {
    console.log('hellp javascript')
  }
  javascript: any
}

function getLanguage (type: Type, x: string | number) {
  let lang = type === Type.Strong ? new Java() : new JavaScript()
  if (lang.helloJava) {
    lang.helloJava()
  } else {
    lang.helloJavaScript()
  }
  return lang
}

getLanguage(Type.Strong)

定义 getLanuage 函数参数 type,判断 type 为强类型时,返回 Java 实例,反之返回 JavaScript 实例。

判断 lang 是否有 helloJava 方法,有则执行该方法,反之执行 JavaScript 方法。此时这里有一个错误 Property 'helloJava' does not exist on type 'Java | JavaScript'.

解决这个错误,我们需要给 lang 添加类型断言。

  if ((lang as Java).helloJava) {
    (lang as Java).helloJava()
  } else {
    (lang as JavaScript).helloJavaScript()
  }

这显然不是非常理想的解决方案,代码可读性很差。我们可以利用类型保护机制,如下几个方法。

instanceof

判断实例是否属于某个类

if (lang instanceof Java) {
  lang.helloJava()
} else {
  lang.helloJavaScript()
}

in

判断一个属性是否属于某个对象

if ('java' in lang) {
  lang.helloJava()
} else {
  lang.helloJavaScript()
}

typeof

判断一个基本类型

if (typeof x === 'string') {
  x.length
} else {
  x.toFixed(2)
}

创建类型保护函数

function isJava(lang: Java | JavaScript): lang is Java {
  return (lang as Java).helloJava !== undefined
}

if (isJava(lang)) {
  lang.helloJava()
} else {
  lang.helloJavaScript()
}

高级类型

介绍五种 TypeScript 高级类型:交叉类型联合类型索引类型映射类型条件类型

这些类型在前面多多少少有被提到过,我们在统一梳理一遍。

交叉类型

& 符号,多个类型合并为一个类型,新的类型具有所有类型的特性。

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

联合类型

取值可以为多种类型中的一种

let a: number | string = 1 // or '1'

字面量联合类型

let a: 'a' | 'b' | 'c'
let b: 1 | 2 | 3

对象联合类型

interface DogInterface {
  run (): void
}
interface CatInterface {
  jump (): void
}
class Dog implements DogInterface {
  run () {}
  eat () {}
}
class Cat implements CatInterface {
  jump () {}
  eat () {}
}
enum Master { Boy, Girl }
function getPet (master: Master) {
  let pet = master === Master.Boy ? new Dog() : new Cat()
  pet.eat()
  return pet
}

getPet 方法体内的 pet 变量被推断为 DogCat 的联合类型。在类型未确定的情况下,只能访问联合类型的公有成员 eat 方法。

索引类型

let obj = {
  a: 1,
  b: 2,
  c: 3
}
function getValues (obj: any, keys: string[]) {
  return keys.map(key => obj[key])
}

getValues(obj, ['a', 'b']) // [1, 2]
getValues(obj, ['d', 'e']) // [undefined, undefined]

keys 传入非 obj 中的属性时,会返回 undefined。如何进行约束呢?这里就需要索引类型。

索引类型的查询操作符 keyof T 表示类型 T 的所有公共属性的字面量联合类型

interface Obj {
  a: number
  b: string
}
let key: keyof Obj // let key: "a" | "b"

索引访问操作符 T[K] 对象 T 的属性 K 代表的类型

let value: Obj['a'] // let value: number

泛型约束 T extends U

let obj = {
  a: 1,
  b: 2,
  c: 3
}
function getValues <T, U extends keyof T>(obj: T, keys: U[]): T[U][] {
  return keys.map(key => obj[key])
}

getValues(obj, ['a', 'b']) // [1, 2]
getValues(obj, ['d', 'e']) // Type 'string' is not assignable to type '"a" | "b" | "c"'.

映射类型

可以讲一个旧的类型生成一个新的类型,比如把一个类型中的所有属性设置成只读。

interface Obj {
  a: string
  b: number
  c: boolean
}

// 接口所有属性设置成只读
type ReadonlyObj = Readonly<Obj>

// 源码
/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

// 接口所有属性设置成可选
type PartialObj = Partial<Obj>

// 源码
/**
 * Make all properties in T optional
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};

// 抽取Obj子集
type PickObj = Pick<Obj, 'a' | 'b'>

// 源码
/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

type RecordObj = Record<'x' | 'y' , Obj>

ts 还有更多内置的映射类型,路径在 typescript/lib/lib.es5.d.ts 内提供参考。

条件类型

形式为 T extends U ? X : Y,如果类型 T 可以赋值为 U 结果就为 X 反之为 Y

type TypeName<T> =
  T extends string ? 'string' :
  T extends number ? 'number' :
  T extends boolean ? 'boolean' :
  T extends undefined ? 'undefined' :
  T extends Function ? 'function' :
  'object'

type T1 = TypeName<string> // type T1 = "string"
type T2 = TypeName<string[]> // type T2 = "object"

(A | B) extends U ? X : Y 形式,其约等于 (A extends U ? X : Y) | (B extends U ? X : Y)

type T3 = TypeName<string | number> // type T3 = "string" | "number"

利用该特性可实现类型过滤。

type Diff<T, U> = T extends U ? never : T

type T4 = Diff<'a' | 'b', 'a'> // type T4 = "b"

// 拆解
// Diff<'a', 'a'> | Diff<'b', 'a'>
// never | 'b'
// 'b'

根据 Diff 再做拓展。

type NotNull<T> = Diff<T, undefined | null>

type T5 = NotNull<string | number | undefined | null> // type T5 = string | number

以上 DiffNotNull 条件类型官方已经实现了。

Exclude<T, U> 等于 Diff<T, U>

NonNullable<T> 等于 NotNull<T>

还有更多的官方提供的条件类型,可供大家参考。

// Extract<T, U>
type T6 = Extract<'a', 'a' | 'b'> // type T6 = "a"

// ReturnType<T>
type T7 = ReturnType<() => string> // type T7 = string