TypeScript 笔记

TypeScript

项目文件结构

tsconfig.json

{
  "compilerOptions": {
    "lib": ["es2015"],
    "module": "commonjs",
    "outDir": "dist",
    "sourceMap": true,
    "strict": true,
    "target": "es2015"
  },
  "include": [
    "src"
  ]
}

放于项目根目录下。

  • include: TSC 在哪些文件夹中寻找 TypeScript 文件
  • lib: TSC 假定运行代码的环境中有哪些 API?(es2015, es2020, esnext 等等)编写在浏览器中运行的 TypeScript 需要加入 "dom"
  • module: TSC 把代码编译成哪个模块系统(commonjs, amd 等等)
  • outDir: 生成的 JavaScript 放置的目录
  • strict: TypeScript 严格模式(true, false)
  • target: TSC 把代码编译成哪个 JavaScript 版本(es2015, es2020, esnext 等等)

tsling.json

{
  "defaultSeverity": "error",
  "extends": [
    "tslint:recommended"
  ],
  "jsRules": {},
  "rules": {
    "indent": [true, "spaces", 2],
    "semicolon": false,
    "trailing-comma": false
  },
  "rulesDirectory": []
}

文件生成命令:./node_modules/.bin/tslint --init

可定制代码风格约定:tab 还是 space、用不用分号等。详见 https://palantir.github.io/tslint/rules

类型

TypeScript 使用类型注解来限制变量的类型,注解的方式是在其背后添加冒号和具体的类型,例如:let n: number = 1 限制 n 的类型为 numberfunction fullname(p: Person): string 限制参数 p 的类型为 Person,返回值类型为 string

类型推断

在没有显式声明变量类型时,TypeScript 会自动进行类型推断:

let m = 12
let s = 'a string'

如果用的是 VSCode 编辑器,将鼠标悬停在 ms 上方可以看到它们的类型分别是 numberstring

但如果使用 const 而非 let 来声明变量,会看到 m 的类型变成了 12s 的类型变成了 "a string"

const m = 12
const s = 'a string'

像这样的类型叫作字面量类型,完整的写法如下:

const m: 12 = 12
const s: 'a string' = 'a string'

any

any 类型的对象可以调用任意方法,访问任意属性,类型检查不起任何作用,就像一个常规的 JavaScript 对象。能不使用 any 的情况下,应该尽量避免使用。

在使用 any 类型时,必须显式注解,不能用类型推导。如果 TypeScript 推导出某个对象类型为 any 则会抛出运行时异常。

unknown

unknownany 的主要区别是,你不能对 unknown 对象进行任何方法调用和属性访问,除非在上下文中显式地使用 typeof 或者 instanceof 对其进行过类型判断。但你可以直接对其使用 ==, ===, ||, &&, ?, !.

object

在 TypeScript 中显式地将一个对象声明为 object 类型时,无法调用其任何方法,也无法访问其任何属性。

let o: object = {a: 1}
console.log(o.a) // 编译时出错

但如果不显式声明类型:

let o = {a: 1}
console.log(o.a)

这样是可以正常编译和执行的。TypeScript 为自动推断 o 的类型为 {a: number},这种类型被称作对象字面量类型。完整的写法如下:

let o: {a: number} = {a: 1}
console.log(o.a)

而将变量声明为 object 只表示该变量的类型是非原始类型,除此之外没有更具体的类型信息。

声明为 Object(首字母 O 大写)也类似。区别在于后者可以表示原始的封装类型,前者不行:

let o: object = 'a string' // 编译出错
let o: Object = 'a string' // 能正常执行

但请尽量避免使用 Object 类型。

对象字面量类型

前面的示例代码中使用了对象字面量类型:

let o: {a: number} = {a: 1}
console.log(o.a)

这种类型告诉 TypeScript 编译器 o 这个对象的结构是含有 a 属性的一个对象,并且 a 属性的类型为 number。实际上,如果假设有一个类型 C,它的内部也有一个名为 anumber 属性,这时是可以将 C 的实例赋值给上面那个 o 的。

如果给对象字面量类型的变量赋值时,少了某个属性,或者多了某个属性,则会无法编译:

let o1: {a: number, b: string} = {a: 1}
let o2: {a: number} = {a: 1, b: 2}

但多了属性的情况下,如果不以对象字面量的形式直接赋值,而是通过一个中间变量进行赋值,则可以正常运行:

let x = {a: 1, b: 2}
let o: {a: number} = x

对象字面量类型中有一个特别的类型 {},作用与 Object 类似,也尽量不要使用。

可选属性

如果希望某个属性是可选的,可以使用可选属性

let o: {a: number, b?: string} = {a: 1}
o = {a: 1, b: 'a string'}

像这样在 b 属性的后面加一个问号,就表示这个 b 属性是可选的。

索引签名

如果希望拥有不确定个数的某个类型属性,可以使用索引签名

let o: {a: number, [key: number]: boolean} = {a: 1}
o = {a: 1, 0: true, 1: false, 2: false}

[key: number]: boolean 表示该类型含有 0 个或多个属性名为 number 类型,属性值为 boolean 类型的属性。注意这里的 key 可以是任何合法的变量,不一定非得用 key,用 index 或者 asdf 都可以。并且它的类型必须是 number 或者 string,因为 JavaScript 对象通过 [] 取属性时只能传入数字或者字符串。

只读属性

使用 readonly 可以限定某个属性为只读属性:

let o: {readonly a: number} = {a: 1}
o.a = 2 // 无法修改

类型别名

可以使用 type 给类型起一个别名,之后就可以拿这个别名来用作类型声明。使用类型别名的地方都可以替换成源类型:

type Age = number
let age: Age = 10
let n: number = 20
age = number

由于 TypeScript 不会自行推断某个对象为类型别名,因此在需要使用的时候必须显式声明。

在同一作用域当中,类型别名无法重复赋值。与 constlet 类似,type 声明的类型别名具有块级作用域,块内部的声明将覆盖外部的声明。

交叉类型和联合类型

将多个类型用 & 拼接起来,就是交叉类型(Intersection Types),声明为该类型的对象必须拥有所有类型的属性:

type Cat = {name: string, purrs: boolean}
type Dog = {name: string, barks: boolean, wags: boolean}

type CatAndDog = Cat & Dog
let wat: CatAndDog = {name: 'wat', purrs: true, barks: true, wags: true}
let wrongWat: CatAndDog = {name: 'wat', purrs: true, wags: true} // 编译错误,缺少 barks 属性

将多类类型用 | 连接起来,就是联合类型(Union Types),声明为该类型的对象必须是这些类型中的其中一种或多种:

// ...
type CatOrDogOrBoth = Cat | Dog

let wat: CatOrDogOrBoth = {name: 'wat', purrs: true}
let watWithoutName: CatOrDogOrBoth = {purrs: true} // 编译错误,缺少 name 属性
let watWithoutName: CatOrDogOrBoth = {name: 'wat', purrs: true, wags: true}

在一些情况下,你可能需要限制某个函数的返回值只能在某几个固定值当中取值,例如:

function rollDie(): 1 | 2 | 3 | 4 | 5 | 6 {
  // ...
}

这个函数返回值只能是 1, 2, 3, 4, 5, 6 其中的一个。这种类型叫数字字面量类型。当然,还有字符串字面量类型,布尔字面量类型等。

数组中的联合类型

TypeScript 可以对 let a = [1, 2, 3] 里的 a 推导出类型为 number[]let a = [1, 's'] 的类型则会被推导为 (number|string)[]。下面是稍微复杂点的情况:

function buildArray() {
  let a = []
  a.push(1)
  a.push('s')
  return a
}

let array = buildArray()
array.push(true) // 无法编译,(number|string)[] 类型中无法插入 boolean 值

这种情况下,buildArray() 内部的 a 初始化时被推断为 any[] 类型,函数返回时类型确定,因此 buildArray() 的返回值被推断为 (number|string)[] 类型,所以外面的那个 array 无法再存入 boolean 值。

元组(Tuple)

元组是数组的子类型,区别在于元组的长度是固定的,并且各索引位置上值的类型是已知的。由于元组字面量与数组字面量没有区别,因此在使用元组时必须显式声明其类型,否则会被自动推断成数组。

let tuple: [string, number, boolean] = ['a string', 1, false]

元组也支持可选元素和剩余元素:

let tuple: [string, number, boolean?] = ['a string', 1]
let tuple2: [string, number, ...boolean[]] = ['a string', 1, true, false]

null, undefined, voidnever

null 表示空值,undefined 表示未定义,void 表示函数没有返回值,never 表示函数根本不返回(抛异常或者死循环)。

枚举

enum Language {
  Chinese,
  English,
  Russian
}

默认情况下枚举值为从 0 开始的数字,你也可以指定具体的值。枚举值可以是数字或字符串:

enum Language {
  Chinese = 'Chinese',
  English = 2
}

枚举值可以通过名字访问,也可以通过下标访问。不过通过下标访问容易出问题(访问不存在的位置),可以使用 const 来修饰枚举,这样 TypeScript 会强制你使用名字来访问枚举值。

const enum 生成的 JavaScript 代码中不会含有对应定义枚举的代码,而是直接将枚举值内插到使用它的地方,例如直接将 Language.English 替换成 2。

函数

一个完整的函数定义是这样的:

function add(a: number, b: number): number {
  return a + b
}

多数情况下,参数的类型无法推断(除非是默认参数),因此需要声明类型。而返回值类型多数时候是可以推断出来的,因此可以省去类型声明。

可选参数

函数的参数同样可以用 ? 标记为可选参数。如果参数中同时包含可选参数和必要的参数,可选参数必须放在必选参数的后面。

function log(message: string, userId?: string) {
  let time = new Date().toLocaleTimeString()
  console.log(time, message, userId || 'Not signed in')
}

默认参数

默认参数与 JavaScript 一样,不赘述。

剩余参数

参数名前面加上 ... 会使该参数变成一个数组,其中存放着参数列表中未定义的所有剩余参数。一个函数中只能有一个剩余参数,而且必须是参数列表中的最后一个参数。

function log(message: string, ...additionalInfo: string[]) {
  let time = new Date().toLocaleTimeString()
  console.log(time, message, additionalInfo.join(', '))
}

log('Hello', 'and', 'goodbye')

this 参数

和 JavaScript 一样,TypeScript 的函数在调用时,内部的 this 有可能指向不同的对象。如果你想限制函数内部的 this 指向对象的类型,可以使用 this 参数:

function fancyDate(this: Date) {
  return `${this.getFullYear()}-${this.getMonth() + 1}-${this.getDate()}`
}

console.log(fancyDate.bind(new Date)())
console.log(fancyDate.bind("2022-02-02")()) // 无法编译

this 参数必须放在参数列表的最前面,并且实际上它并不占用参数列表的位置,是个“假参数”。

生成器函数

函数声明时,在 function 后面加一个星号,该函数就成为了一个生成器。

function* createFibonacciGenerator() {
  let a = 0
  let b = 1
  while(true) { // 无限循环
    yield a; // 使用 yield 产出值,generator.next() 的值从这里来。注意分号。
    [a, b] = [b, a + b]
  }
}

let generator = createFibonacciGenerator()

console.log(generator.next()) //{ value: 0, done: false }
console.log(generator.next()) //{ value: 1, done: false }
console.log(generator.next()) //{ value: 1, done: false }
console.log(generator.next()) //{ value: 2, done: false }
console.log(generator.next()) //{ value: 3, done: false }
console.log(generator.next()) //{ value: 5, done: false }

生成器函数是惰性的,只在调用 .next() 时执行一次,然后就停止执行,直到下一次调用 .next(),因此上面的死循环并不会让程序卡死。这里执行的结果中,done 一直是 false。如果生成器内部不是无限循环,我们就有机会看到 {done: true} 的结果。

调用 createFibonacciGenerator() 得到的是一个 IterableIterator<number> 迭代器。

迭代器

迭代器是一个实现了 Iterator protocol 的任意对象。该协议要求对象里含有一个 next() 方法,该方法返回一个带有 valuedone 属性的对象,正如上面的 generator。生成器本身同时也是一个迭代器。而迭代器可以有很多种。

可迭代对象

可迭代对象拥有 Symbol.iterator 属性,并且该属性是一个函数,其中也使用 yield 产出值。对可迭代对象,可以使用 for...of 来迭代。

let numbers = {
  *[Symbol.iterator]() {
    for(let n = 1; n <= 10; n++) {
      yield n
    }
  }
}

for(let n of numbers) {
  console.log(n)
}

JavaScript 内置的常用集合类型(Array, Map, Set, String)都是可迭代对象。可迭代对象除了可以用 for...of 来操作,还可以像一个数组一样用 ... 展开,也可以像数组一样被解构。

let [a, b] = "a string"

函数签名

function sum(a: number, b: number) {
  return a + b
}

以上函数的签名是 (x: number, y: number) => number。可以换一种写法:

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

let sum: Sum = (a, b) => {
  return a + b
}

这种写法相当于定义了一个函数接口 Sum,实现该接口的函数(例如这里的 sum())都必须有两个 number 类型的参数,并且返回一个 number 类型的值。

因为 Sum 已经定义了参数的类型,sum() 又被指定为了 Sum 类型。因此 sum() 在实现时,参数不再需要指定类型,类型信息可以直接从 Sum 的定义中推断得出。

需要注意的是,带有默认值参数的函数,和带有可选参数的函数的签名是一样的:

type Log = (message: string, userId?: string) => void

let log: Log = (message, userId = 'Not signed in') => {
  let time = new Date().toISOString()
  console.log(time, message, userId)
}

实际上,函数签名的完整写法如下:

// type Log = (message: string, userId?: string) => void
type Log = {
  (message: string, userId?: string): void // 这里需要把箭头改为冒号
}

实际上这里的 type Log = 可以替换成 interface Log,所以前文一直在使用“接口”、“实现”这样的概念。

函数重载

下面是一个预订机票的函数:

type Reserve = (from: Date, to: Date, destination: string) => void

let reserve: Reserve = (from, to, destination) => {
  // ...
}

假如要加入单程旅行的支持,则需要提供一个省去 to 参数的函数:

type Reserve = {
  (from: Date, to: Date, destination: string): void
  (from: Date, destination: string): void
}

let reserve: Reserve = (from, to, destination) => { // 编译出错
  // ...
}

这里编译会出错,因为声明为 Reservereserve 并没有实现所有的函数签名,此时,需要将代码修改为:

let reserve: Reserve = (from, toOrDestination: Date | string, destination?: string) => {
  // 代码中需要根据参数的不同情况来执行不同的逻辑
}

以上的写法是通过接口来定义重载,还有另一种方式,是用函数声明来定义重载:

function reserve(from: Date, to: Date, destination: string): void
function reserve(from: Date, destination: string): void
function reserve(from: Date, toOrDestination: Date | string, destination?: string) {
  // ...
}

这只是两种不同的写法。

泛型

// 略

访问修饰符

TypeScript 中,类的成员默认都是 public 的。

TypeScript 使用的是结构性类型系统。 比较两种不同的类型时,TypeScript 并不在乎它们具体的类型是什么,只要所有成员的类型都是兼容的,就认为它们的类型是兼容的。

class Zebra {
  trot() { }
}

class Poodle {
  trot() { }
}

function ambleAround(animal: Zebra) {
  animal.trot()
}

以上两个类的实例都可以传入 ambleAround() 函数,在 TypeScript 中不会有任何问题。

但是在类中含有 private 或 protected 成员时,结果就不一样了。当两个类只是拥有同样的 private/protected 成员时,这两个类型不一定是兼容的,只有当它们的 private/protected 成员来自同一处声明时,这两个类型才能算兼容。下面的代码展示了这一点:

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

class Rhino extends Animal {
  constructor() { super("Rhino") }
}

class Employee {
  private name: string
  constructor(theName: string) { this.name = theName }
}

let animal = new Animal("Goat")
let rhino = new Rhino()
let employee = new Employee("Bob")

function printName(o: Animal) {
  // ...
}

printName(employee) // 错误: Animal 与 Employee 不兼容.

把代码中的 private 修饰符去掉,这段代码才不会报错。

TypeScript 的 protected 表现与 Java 相同:在子类中可以访问。

参数属性

TypeScript 可以在构造函数的参数中直接声明成员变量,这叫作“参数属性”。下面两段代码是等价的:

class Octopus {
  name: string;
  constructor (theName: string) {
    this.name = theName;
  }
}

class Octopus {
  constructor (public name: string) {
  }
}

参数属性通过给构造函数参数前面添加一个访问限定符来声明,可以是 public, protected, private, readonly

super

在子类中调用父类的构造函数,用 super()。在子类中调用父类的其它方法,用 super.method()super 不能访问父类的属性。

返回 this

类似下面这种情况,子类和父类中两个方法逻辑一样,只是返回的类型不同,可以用返回 this 的方式消除重复:

class Set {
  add(value: number): Set {
    // ...
    return this
  }
}

class MutableSet extends Set {
  add(value: number): MutableSet {
    // ...
    return this
  }
}

add() 返回 this 之后:

class Set {
  add(value: number): this {
    // ...
    return this
  }
}

class MutableSet extends Set {}

extends 关键字

常见的用法是放在用 class, interface 的后面,表示继承。

interface Everything {}
interface Something extends Everything {}
class Super implements Everything {}
class Sub extends Super {}

还可以出现在泛型参数里面,表示泛型类型必须是某个指定类型本身或其子类。

class StringContainer<T extends string> {}

还有一种用法,是用于声明一个类型时,放在两个类之间,判断前者是否是后者的子类。

type x = Sub extends Super ? 0 : 1

在该例中,如果 SubSuper 的子类,那么 x 为字面量类型 0,否则为字面量类型 1

但当它出现在泛型中时,又比较特殊:

type G<T> = T extends 's' ? string : number;
type X = G<'s' | 0>

此时,应该先把 G<'s' | 0> 中的 's' 代入 type G<T> 得到 string,再将 0 代入 type G<T> 得到 number,结果 X 的类型是 string | number

其它

TypeScript 类支持静态属性:

class Grid {
  static origin = {x: 0, y: 0}
}

也支持抽象类:

abstract class Animal {
  abstract makeSound(): void
  move(): void {
    console.log('roaming the earch...')
  }
}

接口

实际上前面已经一直在用接口了:

type Cat = {name: string, purrs: boolean}
// 相当于
interface Cat {
  name: string
  purrs: boolean
}

两者用起几乎一样,但有些细微差别。比如在同一作用域中用 interface 声明的多个接口会自动合并。但用 type 声明多个同名接口(类型)会出错:

interface I {
  prop1: string
}

interface I {
  prop2: boolean
}

// 相当于:

interface I {
  prop1: string
  prop2: boolean
}

接口的属性可以是可选的:

interface SquareConfig {
  color?: string
  width?: number
}

也可以是只读的:

interface Point {
  readonly x: number
  readonly y: number
}

TypeScript 提供了一个 ReadonlyArray<T> 类型,是 Array<T> 去掉了写操作的只读版本,可以确保数组创建之后不能被修改。

let a: number[] = [1, 2, 3, 4]
const ro: ReadonlyArray<number> = a
ro[0] = 12 // error!
ro.push(5) // error!
ro.length = 100 // error!
a = ro // error! 但可以强制转换: `a = ro as number[]`

接口也可以定义函数类型:

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

const mySearch: SearchFunc = function(a: string, b: string) {
  const result = a.search(b)
  return result > -1
}

注:上例中,实现了 SearchFunc 接口的 mySearch() 函数,参数中的 string 类型声明其实可以省去。

接口还能用来定义“可索引类型”:

interface StringArray {
  [i: number]: string
}

const myArray: StringArray = ["Bob", "Fred"]

const myStr: string = myArray[0]

这个 StringArray 接口要求其实现必须可以通过 [n] 的方式读取数据,其中 nnumber 类型,并且返回一个 string

TypeScript 支持两种索引签名:字符串和数字。 可以同时使用两种类型的索引。但是数字索引的返回值必须与字符串索引返回值的类型相同,或者是其子类型。 这是因为当使用 number 来索引时,JavaScript 会将它转换成 string 然后再去索引对象。

另外,因为 JavaScript 中,所有对象的属性都可以通过 [key] 的方式获得,因此,在接口中定义了字符串索引签名后,该接口的所有其它属性返回值都必须与之相同:

interface NumberDictionary {
  [index: string]: number
  length: number    // 可以,length是number类型
  name: string       // 错误,`name`的类型与索引类型返回值的类型不匹配
}

(没看懂这种接口的作用)

索引签名可以设置为只读:

interface ReadonlyStringArray {
  readonly [index: number]: string
}

与 Java 一样,TypeScript 的接口里也可以定义一些方法:

interface ClockInterface {
  currentTime: Date
  setTime(d: Date): void
}

可以用带 new() 签名的接口来限制某个类的构造函数:

interface ClockConstructor {
  new(hour: number, minute: number): ClockInterface
}

interface ClockInterface {
  tick(): void
}

function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
  return new ctor(hour, minute)
}

class DigitalClock implements ClockInterface {
  constructor(h: number, m: number) { }
  tick() {
    console.log("beep beep")
  }
}
class AnalogClock implements ClockInterface {
  constructor(h: number, m: number) { }
  tick() {
    console.log("tick tock")
  }
}

let digital = createClock(DigitalClock, 12, 17)
let analog = createClock(AnalogClock, 7, 32)

与 Java 一样,TypeScritp 中的一个接口也可以继承多个其它接口。

JavaScript 中,一个 function 同时是一个对象,可以拥有其它属性,下面是一个 TypeScript 中的混合类型的示例:

interface Counter {
  (start: number): string
  interval: number
  reset(): void
}

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

let c = getCounter()
c(10)
c.reset()
c.interval = 5.0

TypeScript 中的接口可以继承类。当一个接口继承一个类时,该类中所有成员都会被继承,包括 protected 和 private 成员,但是没有具体的实现。这意味着该接口只能被这个类或它的子类实现。

1
2