参考文档

关于JavaScript中的类型

  1. 函数是js的一等公民,函数也是对象。函数可以作为构造函数使用,ES6中的class实际也是函数的语法糖。函数有一个独特的属性prototype,这个prototype有一个constructor属性,指向函数本身。

    function Parent(){}
    Parent.prototype.constructor === Parent // true
  2. 任何对象都有一个__proto__属性,指向构造这个对象的构造函数的prototype

    经常使用这个特性来实现对象的公有属性和方法,也就是继承。

    let p = new Parent()
    p.__proto__ === Parent.prototype // true
    Parent.prototype.__proto__ === Object.prototype // true. Parent原型对象的构造函数是Object,所以__proto__指向Object的原型对象。
    Object.prototype.__proto__ === null // 原型链到顶了

    注意:Parent.prototype的构造函数是Object,不是Parent.prototype.constructor,这里的constructor只是一个属性。

  3. 函数也是对象

    Parent.__proto__ === Function.prototype // true
    Function.__proto__ === Function.prototype // true. Function是函数也是对象,它的构造函数也是Function
    Object.__proto__ === Function.prototype // true. Object构造函数也是函数,都由Function构造
    Function.prototype.__proto__ === Object.prototype // true. Function的原型对象也是由Object构造
  4. instanceof原理

    instanceof是看对象是属于哪个具体类型,这从上面的原型链可以看出,其实就是查对象的构造函数,可以转换为查__proto__指向的是不是该构造函数的prototype,而这是一层一层遍历的:

    left instanceof right
    left.__proto__ === right.prototype 
    left.__proto__.__proto__ === right.prototype
    ....
    // 一层层遍历

    这个一层层遍历就是原型链遍历,查找对象的属性和方法也是按这个顺序遍历,即instanceof返回为true,则可以使用rightprototype上的方法和属性。

  5. typeof作用

    instanceof只用来判断对象是否属于某个类型。那有没有什么方法能判断是字符串、数字、布尔、对象等等呢?

    typeof 可以判断number, string, object, boolean, function, undefined, symbol7种类型。

    typeof 1 === "number" // true
    typeof "str" === "string" // true
    let s =  new String("str")
    typeof s === "object" // true. 不是string,而且这里只能判断是不是object,至于是哪个类型的object,需要靠instanceof
  6. 包装类

    javascript提供了四个包装类:Boolean、Number、String、Symbol。分别对应布尔、数字、字符串、symbol 4个基本类型。

    "str".toString() // 并不是基本类型str调用了这个方法,基本类型无法调用,实际是先转换成了包装类型,调用了对象的方法
  7. 字面量对象

    let o = {a: 1}
    o.__proto__ === Object.prototype // true
    let obj = new Object()
    obj.__proto__ === Object.prototype // true

    可以简单将字面量对象,理解为Object构造函数创建对象的语法糖。(所有基于构造函数的对象,原型链的终点就是Object,包括字面量对象)

    在js中可以这么理解,但是ts中,object和Object是不一样的类型。

    ts中:

    let o: object = {a: 1}  // 在ts中object表示字面量对象
    let o: Object = new SomeClass() // Object表示基于构造函数的对象的基类
    let o: Object = {a: 1} // 也是可以,字面量对象可以理解为new Object()
    // 实际上在ts中字面量对象在编译时表现为object,运行时表现为Object

javascript中涉及到的类型:

  • 基本数据类型:string, number, boolean, null, undefined, bigint, symbol

  • 对象:字面量对象、class类(构造函数)创建的自定义对象、包装类对象。

  • 特殊对象: Function, Object。 Function是所有函数的直接构造函数,包括Function这个函数本身。Object是所有对象的基类。

    Function.__proto__ === Function.prototype // true
    Function.prototype.__proto__ === Object.prototype // true
    Function instanceof Object // true

什么是TypeScript

  • TypeScript是JavaScript的超集。为什么会出现这个?

    JavaScript由于他的”灵活“,导致不同的人写出来的代码差异很大,可读性维护性都差强人意,需要一套规范来约束,这个规范主要解决类型问题,相当于变成了静态类型语言,就像Java一样,静态类型语言在可读性可写性上都强于动态类型语言,在IDE的支持上更是秒杀动态类型语言。

    类型系统 + JavaScript = TypeScript。

    TypeScript就是一套类型系统,并且完全兼容JavaScript,TypeScript最终都会编译成JavaScript来运行(非常重要,这意味约束是在编译阶段完成,运行的时候和TypeScript就没什么关系了,比如说运行时实际传入的参数不是想要的类型,这TypeScript就无法控制,所以很多时候还要进行类型判断。)。

    用写TypeScript代替写JavaScript,间接实现了JavaScript代码的规范。

    TypeScript和其他静态类型语言不一样的地方是,比如Java,Java只有一套编译规范,大家都按这套方式写Java代码,但是TypeScript提供了很多编译选项,意味者可以定制自己的规范,在类型检查上可强可弱。

  • TypeScript是静态类型语言,但是是弱类型,意味者类型可以隐式转换。

    let i: number = 1;
    let str: string = i + '1'; // 11 i进行了隐士转换,number转string
  • 可以为以前的JavaScript项目,添加类型声明文件,而不是一刀切的用TypeScript重写一遍,这样可以降低迁移成本。

TypeScript严格来说并不仅仅就是给JavaScript加了类型系统,还添加了一些更好用的语法。

安装TypeScript

npm install -g typescript  # 全局安装

然后就可以使用tsc命令编译.ts文件了。

基础

原始数据类型

JavaScript的原始数据类型主要有5种,null,undefine,布尔,数值,字符串。

  • 布尔

    let b: boolean = false;
    // 通过构造器构造的对象,不是boolean,是Boolean类型,这和Java的包装对象很像。
    let b: boolean = new Boolean(1); 
    // 不能将类型“Boolean”分配给类型“boolean”。“boolean”是基元,但“Boolean”是包装器对象。如可能首选使用“boolean”。
    let b: Boolean = new Boolean(1); // 正确
    let b: boolean = Boolean(1); // 正确
  • 数值

    let n1: number = 1
    let n2: number = 0.1
    let n3: number = 0xf00d // 16进制
    let n4: number = 0b1001 // 2进制,编译js后会转换为10进制
    let n5: number = 0o774 // 8进制,编译js后会转换为10进制
    // 特殊类型NaN,不是数字的数值类型!,Infinity表示无穷大
    let n6: number = NaN
    let n7: number = Infinity

    编译后:

    var n1 = 1;
    var n2 = 0.1;
    var n3 = 0xf00d; // 16进制
    var n4 = 9; // 2进制
    var n5 = 508; // 8进制
    // 特殊类型NaN,不是数字的数值类型!,Infinity表示无穷大
    var n6 = NaN;
    var n7 = Infinity;
  • 字符串

    let myName: string = 'foo'
    let myAge: number = 18
    // 模板字符串语法``
    let str: string = `my name is ${myName}.
    I'll be ${myAge + 1} years old next year;

    编译后:

    var myName = 'foo';
    var myAge = 18;
    var str = "my name is ".concat(myName, ".\nI'll be ").concat(myAge + 1, " years old next year;\n");
  • 空值

    关键字void,基本只用于没有返回值的函数。在非strictNullChecks时,可以void类型的变量可以被赋值为nullundefined

    let v: void = null
    function sayHello(): void {
      console.log('Hello World!')
    }
  • null和undefined

    nulll和undefined是所有类型的子类型。

    let u: null = undefined
    let un: undefined = null
    let nn: number = null
    let nu: number = undefined

任意类型

any类型,一个any类型的变量,可以被赋值任何类型的值,一个any类型的值可以赋值给任意类型的变量(部分特殊的不行),这就是javascript的特性了,所有都用any,那类型系统就完全失去意义。(不要图一时方便滥用any)。

let a: any = '1'
let b: number = a
let c: string = a
let u: null = a
let v: void = a
a = 2

一个变量没有声明类型,就等于声明了any。

let anything;
//等价于
let anything: any;

**注意:**对一个任意类型的值进行任意操作都是编译允许的,而且返回的值也是任意类型。

Nerver

nerver主要应用于检查编译时联合类型缺少对应的实现以及分析unreachable code

never类型可以看作所有类型的子类型,除了never没有其他类型是never的子类

Exhaustive检查

如果联合类型添加了某个类型,但是没有做对应的实现处理,就会编译报错。

interface Foo {
  type: 'foo'
}
 
interface Bar {
  type: 'bar'
}
 
interface Fuck {
  type: 'fuck'
}
 
type All = Foo | Bar | Fuck // 后续添加了Fuck类型,但是switch中没有做实现,此时报错
function handlerValue(val: All) {
  switch (val.type) {
    case 'foo':
      break
    case 'bar':
      break
    default:
      const exhaustiveCheck: never = val // 不能将类型“Fuck”分配给类型“never”。never类型是所有类型的子类型,父类型的值不能赋给子类型
      break
  }
}

控制流分析

function fail(message: string): never {
  throw new Error(message)
}
 
function fx(x: number): number {
  if (x >= 0) {
    return x
  }
  fail('negative number') // never是所有的子类,可以赋值给number
 
  x // 检查到无法访问的代码,如果fail返回值是void,这里就不会编译报错
}

类型运算

type n = 1 & 2 // never
 
type Check<T> = never extends T ? true : false
 
type result = Check<xxx> // true
 
type CheckNever<T> = never extends never ? false : T extends never ? true : false // false

Unkown

never是bottom类型,any即是top也是bottom类型,unkown是top类型。

top: 所有类型的父类

bottom: 所有类型的子类

unkown类型可以被赋予任何值。反过来unkown类型的值只能被赋予unkownany,因为到“顶”了。

any类型的值可以执行各种操作,而unkown类型的值无法执行。

let a: any
let u: unknown
a.do()
u.do() // 报错,对象的类型为 "unknown"。

作用:unkown就是类型安全的anyany类型不会参与类型检查,但是unkown会。

使用:对于unkown类型,必须要先缩小或确定类型,才能进行操作。

function handler(val: unknown): number | undefined {
  if (typeof val === 'string') {
    return val.length
  }
  if (typeof val === 'function') {
    val()
  }
  if (val instanceof Date) {
    //...
  }
}

类型推论

变量在定义的时候,会确定类型,有三种情况:

let a: number = 1 // 指定类型
let b = "1" // 类型推断为string
let c // 类型推断为 any

不管是指定还是推断,一当确定,后面都不会改变了。

联合类型

变量可以被赋值为多个类型的一种。

赋值时根据联合类型来检查,赋值后根据类型推断得到具体类型。

let ut: string | number // 管道符声明联合类型
function getLength(some: string | number): string {
  // return some.length // 此时不知道具体类型,只能调用公共方法
  return some.toString()
}
let ustr: string
ustr = ut // 错误,不能将string | number赋值给string,具体错误是不能将number赋值给string
ut = 10 // 此时ut的具体类型为number
if (typeof ut === 'number') {
  console.log('number')
}
ustr = ut // 错误,不能将number赋值给string
ut = '10' // 此时ut的具体类型为string
if (typeof ut === 'string') {
  console.log('string')
}
ustr = ut // 正确,string可以赋值给string

接口

TS中interface有2种表述意义:

  • 常规面向对象中的行为抽象
  • 对象形状的描述

对象描述

相同的属性,对应类型的值,属性不能多,不能少,不能变

interface Person {
  name: string
  age: number
}
 
let p: Person = {
  name: 'bob',
  age: 18,
}

可选属性

?设置后,该属性可以不赋值

interface Person {
  name: string
  age?: number
}
 
let p: Person = {
  name: 'bob',
}
 
let p: Person = {
  name: 'bob',
  age: 18,
}

任意属性

可通过下面的形式给接口定义任意属性:

interface: InterfaceName {
    [propName: 属性类型]: 值类型
}

此时,可以给该类型的对象添加任意属性,只要该属性满足属性类型和值类型的限定即可。

同时:这个限定也约束了该接口其他固定属性、可选属性,只要固定属性、可选属性的属性类型是任意属性的属性类型及子类型的,则他们的值也要受到任意类型的值类型的限制。

interface Human {
  name: string
  age?: number
  [propName: string]: string // 任意属性的属性类型和值类型,对已有的属性且是propName类型及子类型的,对它们的值进行同样的约束
}

此时这个接口自相矛盾了,age是string类型,受任意类型的约束,它的值也必须是string,但是age的值是number,产生冲突了。

需要调整:

interface Person {
  name: string
  age?: number
  [propName: string]: string | number
}
 
let p: Person = {
    name: 'bob'
    age: 18
    id: 123
    address: 'xxxx'
}

只读属性

只读属性规定,在对象第一次被赋值后,该对象的该属性不可再被赋值

interface Company {
  readonly id?: number
  name: string
}
 
// id是可选属性,对象在赋值的时候可以不赋值id
let c: Company = {
    name: 'company'
}
 
c.id = 123 // 此时再赋值就报错,无法分配到 "id" ,因为它是只读属性。

数组

常规表示

类型 + [] 表示法:

let arr: number[] = [1, 2]

泛型表示

Array<T> 表示法:

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

类数组

用接口表示数组,表面看是数组,但其实不是,无法调用数组的相关方法。

interface ArrayNumber {
  [index: number]: number // 这就是前面的任意属性,index就是propName,只是一个表示,无固定写法,表示key
}
 
let arr:  ArrayNumber = [1, 2] // 此时可以arr[0]输出1,但是无法调用数组方法,比如filter,foreach等等
let arr2: ArrayNumber = {
    0: 1,
    1: 2
}  // 这和上面的内容没什么差别

类数组一般不会用来表示数组,过于复杂,因为要模拟很多数组方法。但是类数组常用来表示一些特殊对象,比如方法形参、NodeList等等。

interface IArguments {
    [index: number]: any;
    length: number;  // length和callee本身为string类型,不受上面任意类型的约束
    callee: Function;
}

函数

声明式函数

对应javascript中的声明式写法,然后添加了类型定义,就是对输入输出进行了描述。

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

表达式函数

// 不规范写法
let mysumExpression = function (x: number, y: number): number {
  return x + y
}
 
// 规范写法
let mysumExpression: (x: number, y: number) => number = function (x: number, y: number): number {
  return x + y
}

不规范写法,并不报错,但是在TS中含义就变成了,对等号右侧的匿名函数进行了类型定义,然后赋值给等号左边的变量,同时该变量根据类型推断获得(x: number, y: number) => number类型。

标准写法,应该给变量设定好类型后,再赋值对应类型的值。

注意这里的****不是箭头函数,是用来关联函数类型输入和输出的。

接口定义函数

接口可以描述对象的形状,而函数也是对象。

// FnInterface表示一个函数类型(这是用表达式或声明式都无法达到的效果,当然用type是可以的)
interface FnInterface {
  (arg1: string, arg2: string): boolean // 对输入和输出进行描述
}
 
let fn: FnInterface = function (source: string, substring: string): boolean {
  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;

可选参数

?表示可选参数。可选参数后面不能跟必选参数。

let buildName: (arg1: string, arg2?: string) => string = function (
  firstName: string,
  lastName?: string // 为可选参数,调用时可不用传值
): string {
  return lastName ? firstName + lastName : firstName
}

参数默认值

默认值不能放在类型定义里,只能放在函数签名中。对于设置了默认值的参数,系统会认为是可选参数,但是不用遵守后面不能有必选参数的规定。

let buildName: (arg1: string | undefined, arg2: string) => string = function (
  firstName: string = 'andrew',
  lastName: string 
): string {
  return firstName + lastName
}
 
buildName(undefined,'rogers') // 传入undefined相当于没有传值,会使用默认值

由于此时带默认值的参数放在前面,调用的时候,如果想使用默认值,需要用undefined占位,而在strictNullCheck模式下,函数定义中该参数必须联合undefined类型。

let buildName: (arg1: string, arg2?: string) => string = function (
  firstName: string,
  lastName: string = 'rogers'
): string {
  return firstName + lastName
}
 
buildName('andrew') // 此时只需要传一个参数

**所以:**尽管使用默认值不用遵循可选参数后不能有必填参数的规定,但是还是应该尽量放在最后,这样可以避免传参的时候还要传入undefined进行占位。

剩余参数

...用于表示剩余参数,只能放在形参最后,实际会被当做一个数组。所以可以用数组类型来限定。

let push: (dest: any[], ...source: any[]) => void = function (dest: any[], ...source: any[]): void {
  source.forEach(e => {
    dest.push(e)
  })
}

...还可以用来解构数组和对象。

函数重载

同样的函数名,根据形参类型、个数、顺序的不同,调用不同的函数体。

TS中的重载比较弱鸡,形式大于意义。因为重载只能有签名,具体的函数体实现是汇总到一起的,只有一个。

由于函数名必须相同,所以只能用声明的方式来写。

function reverse(x: string): string // 重载函数的签名
function reverse(x: number): number // 重载函数的签名
// 实际调用的函数,这个函数要考虑所有重载的情况
function reverse(x: string | number): string | number | void { // 这里必须加上void,因为函数体使用的if...else if,可能存在没有return的情况,返回值就是undefined,但是函数没有返回值时一般用void表示
  if (typeof x === 'number') {
    return Number(x.toString().split('').reverse().join(''))
  } else if (typeof x === 'string') {
    return x.split('').reverse().join('')
  }
}

使用重载的好处是可以更清晰的看到不同的重载输入和输出的对应关系。

使用接口来描述重载:

// 这个函数类型是一个重载
interface FnType {
  (attr1: string, options?: { color: string }): void;
  (attr2: number): void; // 重载的参数名,并不要求相同,在函数定义中,只关心参数的位置
}
 
// 实现体必须满足2个条件。
const fn: FnType = (attr: string | number, options?: { color: string }) => {
  if (typeof attr === 'string') {
    console.log(attr);
  }
  if (typeof attr === 'number') {
    console.log(attr);
  }
 
  if (options) {
    console.log(options.color);
  }
};

如果只看fn,好像可以使用fn(1,options: {color: '#fff'}),但是这无法编译通过,因为不匹配重载中的任何一个签名。

this参数

ts中函数的定义,实际有一个隐藏的第一参数,就是this: any,编译的时候会抹去。

function fn(a: string): number {
  //...
}
// 等价于
 
function fn(this: any,a: string): number {
  // ...
}

编译后

function fn(a){
  // ...
}

如果需要在函数中使用this,能明确this类型的时候,一定要明确:

function fn(this: SomeType,a: string){
  this.xxx // 这时候才能更好的调用
}

注意普通函数的this是运行时确定的,这是js特性。

注意箭头函数中的this。

this还可以作为动态类型,在class中有说明。

特殊说明

函数和其他不同,在进行类型检查的时候是可以省略参数的和返回值类型的。

这其实就是为了匹配js中的函数调用。

比如:

type Example = (a: string,b: number) => void
fn1: Example = () => {} // 编译通过
fn2: Example = (a: string) => {return 1} // 编译通过
fn3: Example = (m: string,n: number) => {} // 编译通过

但是执行的时候,比如fn1()会严格按照类型检查。

这在使用可选参数的时候,比较有用:

type Example = (a?: string,b?: number) => void
fn1: Example = () => {} 
fn2: Example = (a: string) => {} 
fn3: Example = (m: string,n: number) => {} 

3个实现,编译都能通过,调用也能检查通过。

断言

将一个值断言为某个类型,这就是告诉编译系统可以将这个值当作这个类型处理,断言实际就是欺骗编译系统,实际运行时没有作用。

语法:

as 类型 // 推荐
<类型>值

2种方式,强烈建议只用第一种,第二种容易和泛型混淆,同时在某些框架中,比如React,这种写法有特殊含义,容易冲突。

前置知识

类型的转换,比如:

let a: any = '1'  // string 赋值给 any
let n: number = a  // any 赋值给 number

这种声明转换,可以理解为子类型转换为父类型,这是允许的。

any可以理解为所有类型的父类,也可以是所有类型的子类,所以任意其他类型可以赋值给any类型,同时any类型也可以赋值给任意其他类型。(部分特殊类型除外)

联合类型理解上可以看作被联合类型的父类型。

由于TS是结构类型系统,所以父子关系并不仅仅是通过定义时确定的,而是看二者的最终结构:

interface Animal {
    name: string
}
interface Cat {
    name: string
    run(): void
}

表面看AnimalCat不存在父子关系,因为没有使用显式定义,比如extends,但是从结构上看CatAnimal的全部特征,所以下面的转换是正确的:

let tom: Cat = {
  name: 'tom',
  run: () => {
    console.log('cat run')
  },
}
 
let yellowAnimal: Animal = tom // tom从Cat类型转换为Animal类型

上面都是通过类型的声明完成类型的转换。有些方法也可以转换类型,比如Boolean(1) === true

**注意:**类型转换不是类型断言,类型转换要严格的多,应尽量优先使用转换,断言只在编译阶段有效。

断言的用途

断言并不是把2个毫无关系的类型强制断言关联,2者之间必须有兼容关系(个人理解为父子关系)。

此时可以:

  • 把父类型的值断言为子类型:因为父类型的值本身可能就是子类型
  • 把子类型的值断言为父类型:子类型本身就可以转换为父类型,断言更是可以

也就是只要存在兼容(父子)关系,则可以互相断言为对方。

  1. 联合类型断言为其中一个类型

    归类:父类型断言为子类型

    interface Cat {
      name: string
      run(): void
    }
     
    interface Fish {
      name: string
      swim(): void
    }
     
    function isFish(animal: Cat | Fish): boolean {
        // 如果参数持有swim函数,则为Fish,但是animal是个联合类型,默认只能访问公共属性和方法,直接调用swim编译不通过,此时可以通过断言欺骗编译系统,实际运行时就没有影响了,就算传入了Cat类型的值,.swim返回的是undefined,结果也是正确的
      if (typeof (animal as Fish).swim === 'function') {
        return true
      } else {
        return false
      }
    }
  2. 将任何类型断言为any

    非必要不要用,本身TS就是为了确定类型,断言为any就相当于失去了类型。

    但是某些情况下是可以用的。

    归类:此时可以理解为父类断言为子类

    let cache: any = (window as any).cache // 如果在window上添加了cache属性,后面要进行访问,正常肯定是编译不过,报window上没有该属性的错误,但是我们很明确的知道有这个属性,此时可以断言为any,编译器允许any访问任何属性和方法
  3. any 断言为任何类型

    归类:此时可以理解为父类断言为子类

    function getCacheData(key: string): any {
      return (window as any).cache[key]
    }
     
    let foo = getCacheData(tom.name) as Cat // any断言为Cat,可以理解为抽象的父类断言为具体的子类,此时foo可以调用Cat相关的属性和方法>

    双重断言:前面说到断言的类型之间要有兼容关系,如果允许2个任意类型之间断言,那很可能造成运行错误。但是通过上面的any断言可以看出,完全可以借助any实现2个毫无关系的类型之间的断言,typeA as any as typeB,这种双重断言尽量不要用,危害太大。正常的依据兼容关系(非any)的双重断言可正常使用。

**现在可以明确:**断言就是在了解到实际运行的情况后,为避免无法编译通过,而人工进行的类型告知,告诉编译系统按这个类型来处理。

但是不节制的断言也会造成危害:

  • 推断实际运行没问题,编译不通过,此时断言,正确用法
  • 推断实际运行可能有问题,编译不通过,此时断言,错误做法,因为此时仅仅是欺骗了编译系统,但是实际运行时还是可能报错

类型断言 VS 类型声明/转换

上面第3点的内容中,完全可以进行直接转换:

function getCacheData(key: string): any {
  return (window as any).cache[key]
}
 
let foo: Cat = getCacheData(tom.name)  // 直接转换,更推荐的做法

看起来2者没什么区别得到的foo都是Cat类型。

但是如果getCacheData(tom.name)返回的值实际类型是Animal呢?

interface Animal {
  name: string
}
 
interface Cat {
  name: string
  run(): void
}
 
let anotherAnimal: Animal = {
  name: 'another',
}
 
let anotherCat = anotherAnimal as Cat  // 此时是将一个真正的父类型值断言为了子类型,虽然编译通过,但是后续极有可能报错,比如调用run方法
 
let anotherCat: Cat = anotherAnimal // 如果直接进行类型声明转换,此时编译错误,因为父类型无法转换为子类型,问题在编译阶段暴露显然更合理

所以类型声明转换比断言更严格,毕竟父类型可以断言为子类型,但是父类型无法直接转换为子类型。

所以类型断言的使用场景就很明确了:明确知道该值的具体类型,但是和声明类型不同,此时具体类型必然是声明类型的子类型(如果是父类型,就相对于父类型的值赋值给子类型了,前提就错了),要想将声明类型改成具体类型,Java中使用强制转换,TS中就是断言了。

let s: Father = son  // s的声明类型是Father,具体类型为Son
let fs: Son = s as Son  // s断言为Son,声明类型改为具体类型

结合泛型

function getCacheData(key: string): any {
  return (window as any).cache[key]
}
 
let foo = getCacheData(tom.name) as Cat

上面的例子中返回值的声明类型是any,具体类型是Cat,此时可以直接应用,但是如果返回值的具体类型是动态的,可能是Cat可能是Fish,那对foo的操作,有可能就报错,虽然anyCat可以编译通过,但是实际运行中无法使用操作Cat的方式操作一个具体类型是Fish的对象。

此时可以使用泛型,让foo的类型动态化:

function getCacheData<T>(key: string): T {
  return (window as any).cache[key]
}
 
let foo = getCacheData<Cat>(tom.name) // foo类型推断为Cat
let foo = getCacheData<Fish>(tom.name) // foo类型推断为Fish

总结

断言应该使用在明确知道父类型的值的实际类型是子类型的情况下将值的类型转换为子类型,以便扩展操作。

核心是围绕声明类型和具体类型的关联上,而这也是TS和JS的区别,TS是编译时,重点在声明类型,JS是运行时,重点在具体类型。

声明空间

TS中,一个作用域下,其实还分了2个板块,类型声明空间和变量声明空间。使用方式不同。

interface Foo {} 
class Bar {}
type Bas = {}
// 这些都是声明的类型,在类型声明空间中,可以作为类型注解使用
let foo: Foo
let foo = Foo // 不能作为变量来使用

也就是类型声明空间中的内容,只能作为类型注解使用。

let foo: String // 声明的是变量,在变量声明空间中
let bar = foo // 可以进行赋值
let bar: foo // 不能作为类型注解

也就是变量声明空间中的内容,可以进行赋值,不能进行类型注解。

比较特殊的是class,它既在类型声明空间中,也在变量声明空间中,即可以作为类型使用,也可以作为变量使用。

模块化

nodejs js模块

nodejs支持CommonJS和ESM模块系统。

nodejs项目可以混合使用这2种模块,模块根据扩展名和package.json(最近的)中的type字段决定这是什么模块。

并不是根据是否使用了import,export,require,module.exports等关键字判定。

比如:

CommonJS:

  • .cjs
  • type=“commonjs” 或者不设置type时

ESM:

  • .mjs
  • type=“module”

CommonJS使用require;ESM使用import,且不能import目录,必须完整路径加文件扩展。

ESM可以同时导入ESM和CommonJS模块。

CommonJS可以通过import()动态导入ESM模块。

关于AMD和UMD模块:

AMD主要用于浏览器环境,比较古老。

UMD可以同时支持AMD,CommonJS和全局变量,所以可以同时在浏览器和nodejs环境下使用。

可以同时发布为多种模块系统,通过package.json中的条件exports实现被按需引入:

  "exports": {                        // 条件导出(Node.js 12+ 支持)
    ".": {
      "require": "./dist/cjs/index.js", // CommonJS
      "import": "./dist/esm/index.js",  // ESM
      "default": "./dist/umd/index.js"  // 兜底(如浏览器)
    }
  }

nodejs ts模块

ts模块同时支持ESM和CommonJS语法。

依然根据扩展名(cts,mts)和package.json中的type(不是所有情况都会读取type,见下面的模块总结)来判定这是什么类型的模块。

然后根据module选项判定编译成什么模块,编译时导入导出语法会进行对应的转换,也会自动写入一些帮助函数。

编译后的模块引用参考nodejs js模块。

TS中的模块化

类似javascript,同样可以模块化。

**全局模块:**默认都是在全局命名空间下。

**文件模块:**文件根级别位置使用了importexport关键字的,在这个文件中就会有一个本地作用域,即我们所说的模块化。

模块总结

整个TS项目中模块是怎么处理的?

核心涉及三个选项:

  1. package.json中的type
  2. tsconfig中的module
  3. tsconfig中的moduleResolution

type决定了这些是什么模块(.mjs,.cjs不受这个字段影响),module决定了编译成什么模块,moduleResolution决定了ts的模块解析策略。

type主要是编译后介入,编译中根据情况介入,module和moduleResolution都是编译时。

这3个字段的值必然要协调的,否则肯定报错,比如module为esnext,type为commonjs,就会产生冲突了。

疑问点:

moduleResolution影响编译时,同时需要知道当前是什么模块,那TS是怎么确定这是esmodule还是commonjs呢?

  1. cts,mts类似cjs,mjs
  2. 如果module为node16,nodenext时,根据type决定是什么模块
  3. 根据文件内容特征,比如import,export语法,会被识别为esmodule

编译选项

TS最终会被编译成JS,TS中的模块,也会变成JS中的模块,JS模块常用的有: ES、CommonJS。

虽然默认使用commonjs模块,但是没有强制要求一定要用CommonJS的语法来写模块

相反,更推荐使用ES的模块语法。

关于模块,本身js在es,commonjs,amd,umd(同时支持amd和commonjs,是一种复合写法)上就是一团麻,各个模块系统之间的坑很多,一般需要引入第三方工具来处理集成,比如babel,webpack等。又加上ts的模块处理,更是麻中麻。

ts的模块主要涉及3个选项:

  • module: 最后要编译成哪种模块系统,比如es2022,esnext,node16,nodenext,commonjs等等

    选项决定了各种import,export,require语法的转换

  • moduleResolution: 模块查找策略

  • esModuleInterop: es模块交互,这个主要是解决es模块引入commonjs模块中的default属性问题。

ts是用于编译,不会直接运行,所以核心设置基本都是围绕编译选项,编译时需要解析模块,需要最终转换为js,转换为的js受package.json中的选项影响,所以在设置ts编译选项时,一般也要注意同步修改package.json。

模块查找

node的模块解析策略为例。

node策略下package.json有个main属性,用来显示的指定导出的js文件(入口文件)。对应的TS库中,package.json有个types属性,用来显示指定类型声明文件,也就是导出的类型声明文件。

除了常规的模仿node寻找模块的策略,由于还引入了@types(单独的类型声明文件所在位置,有些库并没有直接提供类型声明文件),所以在node策略的基础上有一点变化。

参考资料

相对路径引入:

比如:import { b } from "./moduleB" in /root/src/moduleA.ts 查找过程为:

  1. /root/src/moduleB.ts
  2. /root/src/moduleB.tsx
  3. /root/src/moduleB.d.ts
  4. /root/src/moduleB/package.json (if it specifies a types property)
  5. /root/src/moduleB/index.ts
  6. /root/src/moduleB/index.tsx
  7. /root/src/moduleB/index.d.ts

即:先从相对路径下找同名的.ts,.tsx,.d.ts文件,如果没找到则去同名文件夹下的package.json下找types属性有没有,如果还是没有,则去找index开头的文件。

非相对路径引入:

比如:import { b } from "moduleB" in /root/src/moduleA.ts

import { b } from “path/moduleB”,这也是非相对路径引入,只是前面加了个路径,查找的时候也会带上这个路径

  1. /root/src/node_modules/moduleB.ts
  2. /root/src/node_modules/moduleB.tsx
  3. /root/src/node_modules/moduleB.d.ts
  4. /root/src/node_modules/moduleB/package.json (if it specifies a types property)
  5. /root/src/node_modules/@types/moduleB.d.ts
  6. /root/src/node_modules/moduleB/index.ts
  7. /root/src/node_modules/moduleB/index.tsx
  8. /root/src/node_modules/moduleB/index.d.ts

即:先从当前文件所在目录的node_modules下开始寻找,寻找过程为,先找同名的.ts,.tsx,.d.ts文件,如果没找到则去同名文件夹下的package.json下找types属性有没有,如果还没有,则去@types下找(遵循相对路径寻找过程),如果还是没有,则去找index开头的文件。

如果这个node_modules中没有找到,会去上一层node_modules中寻找,过程同上,一直遍历到根目录为止。

不管是引用.ts还是.d.ts,编译时实际拿到的都是变量和它的定义以及纯粹的类型,运行时拿到的才是真正的“对象”。

总体过程:

graph LR
1[文件] --> 2[文件夹/package.json] --非相对--> 3["@types"] --> 4[文件夹/index]
2 --相对--> 4

与模块查找相关的编译选项:

相对路径的模块查找方式基本是固定的,非相对模块的查找除了按照上面的路径进行查找,还可以进行相关配置,扩展查找路径。

方式一:baseUrlpaths

  • baseUrl: 用于指定额外的搜索路径,当正常没有查找到模块时,此时会去baseUrl指定的路径下查找,查找过程类似相对路径查找。通常配合paths来映射路径
  • paths: 路径映射,值为数组,数组中的路径都是相对于baseUrl,映射匹配后,会去匹配的路径中查找,查找过程类似相对路径查找。

这种模式还有个作用,就是避免引用路径太长,类似路径别名。

比如:

{
    "compilerOptions": {
        "baseUrl": "./typings",
        "paths": { // 路径映射,相对于baseUrl
            "*": ["*","node_modules/*"] 
        }
    }
}
import foo from 'foo'

此时如果正常的查找没有找到,会读取baseUrlpaths的内容,如果paths中没有匹配到,则会直接去./typings目录下查找,查找过程类似相对路径查找。如果paths中可以匹配到,比如上面的*可以匹配任意值,则会去对应的映射地址中找,此时先去"*"(数组中的*意思是同名不变,前面匹配到的是什么这里就是什么)对应的地址找,也就是找./typings/foo,如果没有找到,则去找./typings/node_modules/foo

**注意:**这里增加的额外搜索路径,只应用于import,里面的全局变量是无法自动引入的,如果要自动引入,需要使用下面的方式。


方式二:typeRootstypes

**注意:**这2个选项不会影响import导入,import导入模块时,完全会按照上面的模式一步步查找。

这2个选项的作用是:指定自动引入全局变量的范围。import查找declare module模块时,也会从这里找,前提是这个module是全局的,且被加入到编译范围中。

比如:

// declaration\tsconfig.json
{
  "include": ["src/**/*"], // 编译范围,如果不指定就是根目录下所有ts文件
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": ["*", "typings/*"]
    },
    "module": "commonjs",
    "typeRoots": ["./typings"], // 自动引入./typings和node_modules/@types下的类型声明文件.d.ts
    "types": ["foo", "jquery", "bar"] // 指定具体要引入的文件,如果不指定,比如[],就是禁止自动引入
  }
}

typingsfoo文件夹,下有:foo.d.tsindex.d.ts:

// declaration\typings\foo\foo.d.ts
declare function feel(name: string): string
// declaration\typings\foo\index.d.ts
/// <reference path = "./foo.d.ts" />
interface Foo {
  name: string
}

此时foo在编译范围内,Foo是全局的,所以Foo会被拉入全局作用域,而function feel因为引用关系,也会被拉入全局作用域。


typings下有bar文件夹,下有: index.d.tsstudent.d.ts

// declaration\typings\bar\index.d.ts
/// <reference path = "./student.d.ts" />
export interface Person {
  name: string
  id: number
}
 
import * as moment from 'moment'
 
declare module 'moment' {
  export function foo(): moment.CalendarKey
}
 
// declaration\typings\bar\student.d.ts
declare module 'student' {
  export interface P1 {
    id: number
    name: string
    address?: string
  }
}

此时bar在编译范围,但是index.d.ts是个模块,Person不会被拉入全局作用域,而且import bar也无法搜索到这里,需要使用路径映射。

import moment不受影响,正常搜索,并且通过declare module 'moment'扩展了moment这个模块的功能。表面看moment不是全局的,后面在使用时应该import不到,但是这里不是单独定义了一个moment模块,而是对已有模块功能的扩展,后面import moment实际是从node_modules中查找。这里的模块功能扩展生效的前提是bar在编译范围内。

student.d.ts不是一个模块文件,本身没有在types中,但是被bar引用了。而declare module 'student'定义了一个单独的模块,且是全局的,此时会被拉入全局作用域下,所以import student有效。


// declaration\src\test.ts
import * as $ from 'jquery'  // jquery在types中
 
feel('foo') // foo在types中,feel被自动拉入全局作用域
 
import { Person } from 'bar' // 通过baseUrl和paths,此时可以找到bar
 
import * as student from 'student' // module student在全局作用域下
 
import * as moment from 'moment' // moment在node_modules中
 
moment.foo() // bar在types中,功能扩展成功
 
import * as car from 'car' // 这是src下的一个declare module 'car',是全局的,本身就在编译范围(include指定),所以可以import
 

总结

  1. 相对路径查找过程固定
  2. 非相对路径import总是从node_modules,@types中寻找。当查找不到时还会去baseUrl,paths映射的路径中找。
  3. typeRoots,types会指定一个范围,这个范围内的全局变量会被自动拉入全局作用域,全局作用域中的declare module不能直接使用,要先import

/// 引用语法不会改变引用文件中变量的作用域,可以理解为仅仅是产生了关联

声明文件

在使用TS编写源码时,如果要用到第三库,这时候要调用第三方库的内容,肯定需要知道该怎么调用,此时就要知道要调用内容的定义。这就是声明文件中的内容:各种变量的定义,以及涉及到的类型。声明文件通常用.d.ts作为后缀。

这和直接写ts不一样,直接写ts,比如函数,不仅仅是有定义(类型),还会有实现体(值)。而声明文件只需要定义,仅仅用于告知该如何调用即可。

即:直接写ts要有类型也要有值,而声明文件只需要类型(定义)。

语法也不一样,声明文件主要使用declare关键字来声明定义。

声明文件可以看作是库文件的说明书,我们依据声明文件书写源文件,运行时,源文件直接调用的是库文件中的内容,但是它会根据声明文件的定义来运行。

来源

一般的库都有声明文件:

  1. 和源文件放在一起,通过npm install xxx直接安装就能看到
  2. 没有和源文件放在一起,放在@types这个命名空间下(一般这样的都是第三方提供的声明文件),通过npm install @types/xxx安装,会放在node_modules/@types下。

如果确实没有,就需要自己去写声明文件。

如果自己编写库文件,用ts写,可以直接生成声明文件

书写方式

声明文件和库文件是严格对应的,而库文件又分为很多种:

  • 全局变量:通过 <script> 标签引入第三方库,注入全局变量
  • npm 包:通过 import foo from 'foo' 导入,符合 ES6 模块规范
  • UMD库:既可以通过 <script> 标签引入,又可以通过 import 导入

针对不同的类型,声明文件的写法都有差异。但是总的来说,库文件提供了什么,声明文件中就要有什么,只是不需要具体实现而已。

全局变量

这是最简单的一种方式,只需要定义好要用到的全局变量。如果是自己给库文件写声明文件,这种全局类型的可以直接放在src目录中。

  • declare var/let/const

    声明一个全局变量

    declare const var_jquery: (selector: string) => any // 一般全局变量都不会让你修改,所以都是使用const
  • declare function

    声明一个全局函数

    declare function fn_jquery(selector: string): any // 类似函数的声明写法,但是只有签名
    declare function fn_jquery(callback: () => any): any // 支持函数重载
  • declare class

    声明一个全局类

    declare class Animal {
      name: string
      constructor(name: string)
      run(): void // 不能有具体实现
    }
  • declare enum

    声明一个全局枚举类

    declare enum Directions {
        Up,
        Down,
        Left,
        Right
    }

    这里有点混淆,声明文件中正常是不能出现具体实现的,但是枚举类提供了类型,也提供了实例。

    编译后:

    var directives = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];

    Directions实际就是一个对象,这个对象有统一含义的key-value。(这也可以看作是能抽象成枚举类的条件)

  • declare namespace

    namepspace现在只建议在声明文件中使用。

    声明文件中namespace表示这个变量是个对象,包含很多子属性。

    declare namespace n_jquery {
        // 包含多个子属性
      function ajax(url: string, settings?: any): void
      const version: string
    }

    对象有多个层级,所以namespace是可以嵌套的。

    declare namespace n_jquery {
      function ajax(url: string, settings?: any): void
      const verson: string
      namespace fn {
        function extend(arg: any): void
      }
    }

    可以用interface来模拟namespace,最终目的是抽象对象。

    这里也能看出declare namespace和declare module的区别,namespace在调用时必须要带这个前缀,而module不用。

  • interfacetype

    前面大部分都是声明变量的类型(function,class也可以看作变量 + 类型,class还可以当作纯粹的类型),其实也可以直接声明类型。

    interface settings {
      method: 'GET' | 'POST' // 联合类型,固定死了
      data?: Object
    }
     
    type classType = typeof Animal
  • 命名冲突

    namespace用来对应对象,因为编译后执行时是带着这个前缀的。

    namespace也不仅仅用来表示对象,它的作用还有处理命名冲突,尤其是针对纯粹的类型interfacetype,因为类型会在编译后删除,也不用当心这个对象没有这个属性了。

    declare namespace n_jquery {
      function ajax(url: string, settings?: any): void
      const verson: string
      namespace fn {
        function extend(arg: any): void
      }
      interface settings { // settings的调用也需要使用n_jquery.settings,并且完全不用当心n_jquery实际对象中没有这个属性,因为编译后不会存在n_jquery.settings
        method: 'GET' | 'POST'
        data?: Object
      }
    }
  • 声明合并

    ts可以很智能的合并声明。在声明文件中,同样可以合并声明,而不会报命名冲突。

    declare function n_jquery(object: any): any
     
    declare namespace n_jquery {
      function ajax(url: string, settings?: any): void
    }

    这实际可以看作给函数n_jquery添加了一个属性ajax

    其他还有很多合并,比如classfunctioninterface合并。

    有些会报命名冲突,有些会直接智能合并。

npm包

npm包是以模块化的形式,一般都是用import导入。在给npm库文件书写声明文件时,不建议放在node_modules/@types目录下,因为这个目录,不会被git控制,容易丢失,一般都是单独建统一的typings目录(注意要加入到paths,不然模块可能查找不到),用于存放自己写的声明文件。

基本上绝大部分流行包都有声明文件。

  • export

    当文件中有export时,就是模块化了,此时按照上面全局变量的定义,只会是在当前作用域下,需要export出去。

    // 直接export
    export let name: string
    export interface Car {
      color: string
    }
     
    // 先定义再export
    declare function getName(): string
    export { getName }
    import { name, Car, getName } from './declare/npm'
  • export namespace

    导出一个对象。

    declare namespace Point {
      let x: number
      let y: number
      function calc(x: number, y: number): number
    }
     
    export { Point }

    直接导出:

    export namespace Point {
      let x: number
      let y: number
      function calc(x: number, y: number): number
    }
  • export default

    对于export default,可以使用import xxx from 'module'这种导入模式。

    但是只有function,interface,class可以直接使用,其他都需要先declare

    export default function getName(): string
    // export
    export default Color // export default建议放在文件最前面
    declare enum Color {
      BLACK,
      WHITE,
      GREEN,
    }
     
    // import
    import SpecialColor from './declare/npm'
    let color: SpecialColor = SpecialColor.BLACK
  • export =

    CommonJS模块使用exports来导出:

    // index.js
    // 整体导出
    module.exports = foo;
    // 单个导出
    exports.bar = bar;

    也就是实际导出的是exports这个单个对象,它包含了所有的导出内容(作用和ES6中的export default很像)。为了适配编译后的文件能正确调用库文件导出,需要使用export = 语法来导出单个对象。

    使用export =后,必须要用:

    import module = require("module") // 导入export = 导出的对象

    为什么不能用export default应用于CommonJS库文件对应的声明文件?

    参考资料

    // importer.ts                                  // compiled.js
    // ===========                                  // ===========
    import Calculator from "./calculator";          // exports.__esModule = true;
                                                    // var calculator = require("./calculator");
    let calc = new Calculator();                    // var calc = new calculator["default"]();
                                                    // console.log(calc.add(2, 2));
    console.log(calc.add(2, 2));                    //

    export default后,使用import XXX语法来导入,编译后是通过xxx["default"]来获取这个对象。而对于CommonJS库文件的导出:

    class Calculator {}
    module.exports = Calculator // 直接导出这个构造函数

    导入后的调用,很明显应该是:

    var calculator = require("./calculator")
    var calc = new calculator()

    而不是:

    var calculator = require("./calculator");
    var calc = new calculator["default"](); // 很明显此时是找不到default属性的

    但是使用export =对应的import ... = require("module")语法导入:

    // importer.ts                                  // compiled.js
    // ===========                                  // ===========
    import Calculator = require("./calculator");    // exports.__esModule = true;
                                                    // var Calculator = require("./calculator");
    let calc = new Calculator();                    // var calc = new Calculator();
                                                    // console.log(calc.add(2, 2));
    console.log(calc.add(2, 2));                    //

    编译后是直接调用require到的对象,符合预期。

UMD库

UMD库是即要支持CommonJS,AMD,也要支持全局变量。

UMD库实际上并不是严格上的模块化方法,它是判断当前是否为CommonJS/AMD/Global环境,然后进行对应的导出。

// calculator
(function(global, main) {
    // 根据当前环境采取不同的导出方式
    if (typeof define === 'function' && defind.amd) {
        // AMD
        define(...)
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = ...
    } else {
        global.add = ...
    }
})(this, function() {
    // 定义模块主体
    return {...}
})
  • export as namespace

    可以将声明好的变量变为全局变量。

    对于UMD模块的声明文件,如果要导入,最好用export = 配合export as namespace,这样在非ESM模块中,可以使用全局,在ESM模块中可以用import导入。

    export = setName // 导出CommonJs或AMD模块的exports
    export as namespace setName // 进入全局声明
    export { setName }
    declare function setName(name: string): void

扩展全局变量

基本就是利用声明合并的方式实现。

// 通过接口合并,合并后的String除了原来的属性,还有新加的newFn
interface String {
    newFn():void
}
 
// 通过命名空间合并,给jQuery中新增一个类型
declare namespace jQuery {
    interface NewType {
        foo: string
    }
}

模块内扩展全局变量

如果模块内扩展了全局变量,那声明文件中也要对应有定义。

本质还是使用声明合并,但是需要在declare global下:

declare global { // 给String扩展全局方法
  interface String {
    foo(): void
  }
}
export {} // 如果该声明文件没有需要导出的,也一定要导出一个空{},表示这是一个模块文件

扩展已有模块的功能

使用declare module可以对已有模块的功能进行扩展。

declare module也可以单独定义模块,并且可以被import

声明文件依赖

声明文件也会有依赖,这和正常的模块依赖没有什么区别。

  • 通过import导入

    正常的模块之间的引用

  • 通过///语法引入

    三斜线语法虽然可以定义ts模块之间的依赖,但是现在基本都使用import来关联,三斜线建议只用于声明文件中,且只限于以下3种情况:

    1. 书写一个全局变量的声明文件

      此时如果依赖其他模块,又不能使用import,只能用///

    2. 引入一个全局变量的文件

      全局变量不支持import导入,此时也只能///引入

    3. 拆分全局声明文件

      全局声明文件太大,可以拆分到不同的子声明文件,最后引入到入口文件即可

      // node_modules/@types/jquery/index.d.ts
       
      /// <reference types="sizzle" />
      /// <reference path="JQueryStatic.d.ts" />
      /// <reference path="JQuery.d.ts" />
      /// <reference path="misc.d.ts" />
      /// <reference path="legacy.d.ts" />
       
      export = jQuery;

    types表示模块,path表示路径。

    ///的引入,并不改变引入的声明文件中的变量作用域。

自动生成声明文件

如果项目使用ts编写,则只需要在tsconfig.json中加入编译选项:

{
    "compilerOptions": {
        "declaration": true, // 自动生成声明文件
    }
}

内置对象

javascript有很多标准的对象,比如ES环境下的String,Date,Error,RegExp等等,还有其他环境比如DOM,BOM下的标准对象,这些内容typescript官方都提供了声明文件。

比如lib.es5.d.ts,lib.dom.d.ts等等,这些都在TypeScript 核心库的定义文件中。

可以看到核心库中并没有node.js,需要手动安装。

npm install @types/node --save-dev

进阶

更丰富的类型说明。

类型别名

关键字type,用于声明一个类型变量,这个变量在类型声明空间中,只能被赋值为类型。

type Name = string // Name这个类型就是string的别名
type NameResolver = () => string // 给一个函数类型指定名称
function getName(n: Name | NameResolver): string {
  if (typeof n === 'string') {
    return n
  } else {
    return n()
  }
}

类型别名主要用于各种高级类型,比如类型转换、映射,通常都是结合泛型使用。

字面量类型

就是用来限定取值只能从字面量类型定义中取。(有点像枚举)

type EventNames = 'click' | 'scroll' | 'mousemove' // 字面量组成的联合类型,不只是字符串,其他基本类型也可以
let eventType: EventNames = 'click'

像上面的字面量类型都是string的子类。

而数值字面量类型都是number的子类。

元组

所谓元组就是元素类型不相同的数组。

let person: [string, number] // 拥有多个类型
person = ['foo', 1] // 赋值时每个元素都要赋值
 
let p0 = person[0] // 根据索引获取,类型会被正确推断
let p1 = person[1]
 
person[0] = 'bar' // 根据索引赋值
 
person.push(2) // ['foo', 1, 2] 添加越界元素,元素类型是 string | number 联合类型

枚举

enum Days {
  Sun,
  Mon,
  Tue,
  Wed,
  Thu,
  Fri,
  Sat,
}
console.log(Days['Sun'] === 0) // true
console.log(Days['Mon'] === 1) // true
console.log(Days['Tue'] === 2) // true
console.log(Days['Sat'] === 6) // true
 
console.log(Days[0] === 'Sun') // true
console.log(Days[1] === 'Mon') // true
console.log(Days[2] === 'Tue') // true
console.log(Days[6] === 'Sat') // true

相当于对Days这个对象里的值和索引双向映射,看编译之后的代码就明确了:

var Days; //声明变量
// 执行自执行函数,传入Days,Days被赋值空对象。Days添加属性Sun,并赋值为0,同时对Days[0]赋值Sun
(function (Days) {
    Days[Days["Sun"] = 0] = "Sun";
    Days[Days["Mon"] = 1] = "Mon";
    Days[Days["Tue"] = 2] = "Tue";
    Days[Days["Wed"] = 3] = "Wed";
    Days[Days["Thu"] = 4] = "Thu";
    Days[Days["Fri"] = 5] = "Fri";
    Days[Days["Sat"] = 6] = "Sat";
})(Days || (Days = {}));

枚举类的实例无法直接作为key,因为key不能是某个class 类型,但是枚举类的实例是有对应的索引值的,所以[EnumClass.instance]可以作为key,实际是number类型。

手动赋值

枚举项的值默认是按0开始递增的,也可以手动赋值,没有赋值的会按前一个的值自动递增

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

手动赋值甚至可以不赋number,但是要进行类型断言欺骗编译系统,反正编译后是不管类型的。

enum Days {Sun = 7, Mon = 1, Tue, Wed, Thu, Fri, Sat = "S" as any}; 

常数值和计算值

枚举成员的值可以是常量,也可以是计算的。

上面的例子都是常量。

enum Color {
  Red,
  Green,
  Blue = 'blue'.length, // 计算的
  Gray = 10, // 计算值后面不能跟默认值,因为前面没有提供初始值,无法自动 + 1
}

const枚举

常量枚举在编译后会直接删除,无法访问。

const enum Letter {
  A,
  B,
  C,
}
 
console.log(Letter.A) // 只能获取枚举成员的值 Letter.A就是0

编译后:

console.log(0 /* A */)

外部枚举

就是declare enum,一般用于声明文件。

外部枚举在编译后也会完全删除,只能用于编译时类型检查。

但是和常数枚举不同,常数枚举在编译后只会存在值,外部枚举编译后不会变成值

declare enum Letter {
  A,
  B,
  C,
}
 
console.log(Letter.A)

编译后:

console.log(Letter.A)

外部枚举也可以结合const使用:

declare const enum Letter {
  A,
  B,
  C,
}
 
console.log(Letter.A)

编译后:

console.log(0 /* A */)

封装、继承、多态

ES6中的class

ES6中的类实际可以看作ES5prototype的语法糖,但是要严格一些。

class Animal {  // 定义class
  constructor(name) {  // 构造函数
    this.name = name // 属性
  }
  set _name(value) { // setter方法,用于改变属性赋值的逻辑,给_name赋值时调用该方法
    this.name = value
  }
  get _name() { // getter方法,获取读取_name时调用
    return this.name
  }
  run() { // 方法
    return `${this.name} is running...`
  }
  static isAnimal(obj) { // 静态方法
    return obj instanceof Animal
  }
}
 
class Dog extends Animal {  // 继承
  constructor(name) {
    super(name) // 通过super访问父类方法,只有构造函数中可以直接使用super(),表示父类构造函数,其他地方需要明确访问的是父类哪个方法
  }
  run() {
    return `Dog ${super.run()}` // super.run(),明确访问的是父类的run方法
  }
}
 
class Cat extends Animal {
  constructor(name) {
    super(name)
  }
  run() {
    return `Cat ${super.run()}`
  }
}
 
let dog = new Dog('tom')  // dog和cat此时都是Animal类型,可以表现出多态
let cat = new Cat('tom')
 
dog._name = 'foo' // 出发setter
 
console.log(Animal.isAnimal(dog)) // 调用静态方法

ES7中的class

class Work {
  task = 'task' // 直接写实例属性
  static com = 'Company' // 定义静态属性
  do() {
    console.log(`${this.task} doing...`)
  }
}
 
let work = new Work()

TypeScript中的class

访问修饰符

访问修饰符的限制实际更像是一种人为约定,并没有强制限制效果。就算是java中也可以通过反射获得private属性,ts还会被编译为js,访问限制更是完全失效,但是这并不是说修饰符没有用,这表示了一种约定、规范。

  • private: 私有,内部使用
  • protected: 可以在子类中访问
  • public: 任意地方访问
class Animal {
  private name: string // 私有属性
  public constructor(name: string) { // 修饰方法,构造方法如果是private,表示无法实例化,如果是protected表示只能被子类调用
    this.name = name
  }
}

参数属性

可以直接在构造函数的参数中定义属性,可以使用修饰符和readonly。这种定义的属性,在调用构造函数时会直接赋值,意味者构造函数内部不用再赋值了。

class Animal {
  private name: string
 
  public constructor(name: string, public color: string, public readonly age: number) {
      // 此时只需要赋值name,color和age会直接赋值
    this.name = name
  }
}

readonly需要在访问修饰符后面,且只能通过构造函数赋值,赋值后无法修改。

抽象类

java中的概念相同,只要有抽象方法的类都是抽象类,需要被继承实现。

abstract class Animal {
  public constructor(public name: string, public color: string) {}
  public abstract run(): void
}
 
class Dog extends Animal {
  public constructor(name: string, color: string) {
    super(name, color)
  }
  public run(): void {
    console.log(`a ${this.color} dog whoes name is ${this.name} is running...`)
  }
}

类的类型

和接口一样

let animal: Animal = new Dog('tom','yellow')

类比较特殊,即是类型也是值。

当作值(变量)时理解上可以:

// 实际不能这么写
class Animal: typeof Animal {
    // ....
}

使用:

let AnimalClass: typeof Animal = Animal  //class本身的类型
let animal: AnimalClass = new AnimalClass() // AnimalClass就是一个类了

class本身可以理解为构造函数的语法糖。

所以class当作值时,可以理解为就是某个构造函数,即某个构造函数类型对应的值:

class Base {
  name: string = 'a';
}
class Father extends Base {}
type constructorType = new (arg1: string, arg2: string) => Base; // 构造函数类型
const constructorIns: constructorType = Father; // 这个类型对应的值是具体的构造函数,即可以是某个类
const constructorIn2: constructorType = Base;
 
const obj1 = new constructorIns('a', 'b'); //obj1: Base
const obj2 = new constructorIn2('a', 'b'); // obj2: Base

接口

接口即可以用来描述形状,也可以用来抽象行为,也就是面向对象中的接口含义。

当然用于抽象行为的时候,也可以当作用来描述形状,并不冲突。

什么时候使用接口、什么时候使用类?

接口:行为抽象、形状抽象(更贴切的说有哪些属性,方法也是属性)

类: 形状具体描述

  • 类实现接口

    interface Run {
      run(): void  // 行为抽象
    }
     
    interface Eat {
      eat(food: string): void
    }
     
    class Animal implements Run, Eat { // 多重实现
      public name: string
      constructor(name: string) {
        this.name = name
      }
      eat(food: string): void { // 实现的接口只能是public,默认就是public
        console.log(`${this.name} like ` + food)
      }
      run(): void {
        console.log(`${this.name} is running...`)
      }
    }
  • 接口继承接口

    interface Action extends Run, Eat {} // 还可以一次继承多个接口
  • 接口继承类

    类可以当作,也可以当作类型。当作类型时,实际就是实例的类型,此时实际上可以把类当作接口,用于描述实例的形状,当然此时只有实例属性实例方法会被当作描述的内容。

    当作接口时,其他接口就可以继承它了。

    类可以被当作接口:

    class Point {
      public x: number
      public y: number
      constructor(x: number, y: number) {
        this.x = x
        this.y = y
      }
      setPoint(x: number, y: number): void {
        this.x = x
        this.y = y
      }
    }
     
    interface PointInterface {
      x: number
      y: number
      setPoint(x: number, y: number): void
    }
     
    function getPoint(p: Point): string {
      return `point is: ${p.x},${p.y}`
    }
     
    let p: PointInterface = {
      x: 12,
      y: 13,
      setPoint(x: number, y: number) {
        // ...
      },
    }
     
    // 可以看到PointInterface可以当作Point类型,因为他们的结构一致,TS认为他们是一个类型
    getPoint(p)

    接口继承类

    接口会继承类所有的成员(属性和方法)但不包括实现,就好像接口声明了所有类中存在的成员,但并没有提供具体实现一样。

    private属性也会被继承,这意味者该类的子类才能实现继承了该类的接口(只有子类才有这个私有属性,虽然不能访问,但结构上存在)。

    interface Point3D extends Point {
      z: number
    }
    // 此时Point3D的结构为
    /* {
      x: number
      y: number
      z: number
      setPoint(x: number, y: number): void 
    } */

泛型

定义函数、接口、类时不指定某个具体类型,使用时才去确定。泛型就是类型参数化。

  • 泛型函数

    function toArray<T>(...elements: Array<T>): Array<T> {
      let arr: Array<T> = []
      elements.forEach(e => arr.push(e))
      return arr
    }
  • 泛型接口

    interface CreateArray { 
      <T>(...elements: Array<T>): Array<T>  //泛型在函数上
    }
     
    let creatArray: CreateArray = function <T>(...elements: Array<T>) { // 赋值时不用确定泛型
      let arr: Array<T> = []
      elements.forEach(e => arr.push(e))
      return arr
    }
     
    interface CreateArrayGenerics<T> { // 泛型在接口上
      (...elements: Array<T>): Array<T>
    }
     
    // 使用时需要确定类型
    let createArrayGenerics: CreateArrayGenerics<number> = function (
      ...elements: Array<number>
    ): Array<number> {
      let arr: Array<number> = []
      elements.forEach(e => arr.push(e))
      return arr
    }
  • 泛型类

    class GenericClass<T = number> { // 可以给泛型设定默认值,当没有直接明示,也无法推断时会使用
      field: T
      fn: (arg1: T, arg2: T) => T
    }

多个泛型:

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

泛型约束:

限制泛型的范围。

// 要将U中的值替换T中的值,为了防止U中存在T中没有的属性,所以T extendx U,这样T类型就拥有U类型的所有属性
function copyFields<T extends U, U>(target: T, source: U): T {
  for (let key in source) {
    target[key] = (source as T)[key] // 需要转换
  }
  return target
}
 
let target = { a: 1, b: 2, c: 3 }
let source = { b: 22, c: 33 }

联合类型的泛型约束

type Test = 'x' | 'y' extends 'x' | 'y' | 'z' // true x,y都来源于extends右边
type Test = 'x' | 'y' | 'z' extends 'x' | 'y' // false

extends用法

  1. 常规意义上的继承

    • 接口
  2. 条件判断

    • 普通用法

      type Test = 'x' | 'y' extends 'x' | 'y' | 'z' // true x,y都来源于extends右边
      type Test = 'x' | 'y' | 'z' extends 'x' | 'y' // false
    • 泛型用法

      type P<T> = T extends 'x' | 'y' ? string : number
       
      type Test = P<'x' | 'y' | 'z'>  // string | number
       
      type Test2 = P<never> // never

      很奇怪,Test理论上不应该是number吗?这里就是所谓的分配条件类型了,如果extends前面的参数是一个泛型类型,当传入该参数的是联合类型,则使用分配律计算最终的结果。分配律是指,将联合类型的联合项拆成单项,分别代入条件类型,然后将每个单项代入得到的结果再联合起来,得到最终的判断结果。

      never被认为是空的联合,所以P<never>依然满足分配律,但是never没有子项,所以P<never>实际就没有执行(空参数),没有返回值,就是never

      有没有办法阻止分配律呢,用[]

      type P<T> = [T] extends 'x' | 'y' ? string : number
       
      type Test = P<'x' | 'y' | 'z'> // number

      分配律常见的应用是条件类型

infer用法

infer用于extends语法中推断的类型变量。

一般 T extends R 中R已知,T被约束,而infer是反过来的,T已知,R未知,需要被推断。

内置类型:

type ReturnType<T> = T extends (...args) => infer P : P: any

函数T如果可以被分配给(...args) => P,则返回P,否则返回any,这个P是推断出的返回类型,也就是T如果extends成功,这P的类型就应该是某个类型,此时推断成功,得到正确的返回类型。

例子:

type TTuple = [string, number];
type TArray = Array<string | number>;
 
type Res = TTuple extends TArray ? true : false; // true
type ResO = TArray extends TTuple ? true : false; // false

可以用infer很方便的将元组转为联合类型:

type ElementOf<T> = T extends Array<infer P> ? P: never
type TTuple = [string, number];
type ToUnion = ElementOf<TTuple>; // string | number

还有比如Sinon框架中的常见用法,推断函数的参数和返回值类型,用于其他地方:

<T, K extends keyof T>(obj: T, method: K): T[K] extends (...args: infer TArgs) => infer TReturnValue
            ? SinonStub<TArgs, TReturnValue>
            : SinonStub;

声明合并

  • 函数声明合并

    重载

  • 接口声明合并

    属性合并,方法使用重载的方式合并

    interface Point {
      x: number
      y: number
      setPoint(x: number, y: number): number
    }
     
    interface Point {
      z: number
      setPoint(x: number, y: number, z: number): number
    }
    // 上面会合并等价于
    interface Point {
      x: number
      y: number
      z: number
      setPoint(x: number, y: number): number
      setPoint(x: number, y: number, z: number): number
    }
  • 类的合并

    和接口合并规则相同

高级类型

交叉类型、this类型、映射类型、条件类型等等。高级类型就是根据已有类型计算出其他类型,可以看作类型的运算

keyof操作

如果说typeof是用于获取的类型,属于变量声明空间中的操作,那么keyof只是用来操作类型的,用于获取类型的属性组成的联合类型,属于类型声明空间中的操作。

类型声明空间中的操作,完全可以像变量声明空间中的操作一样,比如函数运算操作,输入一个类型,经过函数处理,输出另一个类型。

type Animal = {
  name: string
  age: number
  run(): void
}
 
// 获取类型的属性
type AnimalKey = keyof Animal // 'name' | 'age' | 'run'
 
class Point {
  x: number
  y: number
}
// 获取类的属性
type PointKey = keyof Point // 'x' | 'y'
 
// 获取数组的属性
type PointArray = keyof Point[] // "concat" | "copyWithin" | "entries" | "every" | "fill" | ... 都是数组方法
 
// 获取原生类型的属性
type numberKey = keyof number // "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
 
 
interface TestNumber {
  [index: number]: string
}
 
interface TestString {
  [index: string]: string
}
// 这种[index: number]模式下,获得的就不是字面量了,就是指定的类型
type TestNumberIndex = keyof TestNumber // number
 
// 注意是number | string 不是string
type TestStringIndex = keyof TestString // number | string

为什么[index: string]得到的类型是number | string呢?

对于javascript中的object,number类型的key会自动被当作string

let obj = {1: 'obj'}
obj[1] === obj['1'] // true
 
interface Test {
  1: string // 这里会报错,因为1被当作string,受任意类型限制,它对应的value,必须是number或number子类型
  [index: string]: number // [index: string] 实际就是 [index: string | number]
}

中括号语法:

let obj = {name: ‘foo’}

obj.name === obj[‘name’] // true

一般用的较多的场景:

  • key是number:obj[1]
  • key是表达式: [EnumClass.instance],返回的是枚举类的索引值

keyof有什么作用:

keyof的主要作用是返回类型对应的属性组成的联合字面量类型,而字面量类型实际就是取值范围的约束,即可以通过keyof得出key的取值范围。

比如:对于任意对象obj返回obj[key],现在要进行类型限定:

function pro(obj: object, key: string): any {
  return obj[key]
}
// 编译时报错Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.No index signature with a parameter of type 'string' was found on type '{}'.

因为没有在object这个类型中没有找到string的索引签名。

key的取值必须是obj中的属性,即key的类型应该是obj对应类型的属性字面量类型,此时就可以使用keyof获取字面量类型:

// 完整写法
function pro<T extends object, U extends keyof T>(obj: T, key: U): T[U] {
  return obj[key]
}

索引类型

T[U]写法的含义:

对于类型声明空间中,这实际就是通过key的类型,获取key对应的value类型,这也叫索引类型

type Test = {
  name: string
}
type Value = Test[keyof Test] // string
 
interface Map<T> {
    [key: string]: T;
}
let keys: keyof Map<number>; // string
let value: Map<number>['foo']; // number

关于objectObject

  • Object可以看作是所有对象(基于某个构造函数)的基类

    let obj = { name: 'foo' }
    function pro<T extends object, U extends keyof T>(obj: T, key: U): T[U] {
      return obj[key]
    }
    console.log(obj instanceof Object) // true
    console.log(pro instanceof Object) // true
  • object可以看作{}类型,它是所有用{}表示的对象的父类

    let obj = { name: 'foo' }  // 可以obj.name
    let o: object = obj // 此时无法o.name
    typeof obj === 'object' // true,注意这里的typeof其实是javascript中的用法,得到的是'object',而不是{name: string},在ts中2种用法都没错

交叉类型

联合类型是|或,交叉类型就是&与。

联合类型可以看作被联合类型的父类型,交叉类型可以看作被交叉类型的子类型,它拥有所有被交叉类型的特征。

interface Name {
  name: string
}
interface Age {
  age: number
}
 
type Animal = Name & Age
 
let animal: Animal = { name: 'foo', age: 3 } // 同时拥有name,age

交叉类型一般用于对象混入:

// 2个对象的混入
function mixins<T extends object, U extends object>(first: T, second: U): T & U {
  let result = <T & U>{}
  for (let k1 in first) {
    ;(result as T)[k1] = first[k1]
  }
  for (let k2 in second) {
    if (!(result as Object).hasOwnProperty(k2)) {
      ;(result as U)[k2] = second[k2]
    }
  }
  return result
}

类型保护

主要针对联合类型,要确定到具体类型时。

  1. 断言

  2. typeof

    if(typeof variable === 'number') // 确定某个原生类型
  3. instanceof

    if(variable instanceof Constructor Function) // 属于某个构造函数
  4. 自定义类型保护

    interface Dog {
      run(): void
      sleep(): void
    }
     
    interface Fish {
      swim(): void
      sleep(): void
    }
     
    // 自定义类型保护方法,通过 arg is xxx 确定为某个具体类型
    function isFish(animal: Dog | Fish): animal is Fish {
      return (animal as Fish).swim !== undefined
    }
     
    function todo(animal: Dog | Fish): void {
      // 此时只能调用公共方法
      animal.sleep()
      // 通过isFish的返回表达式animal is Fish,编译器认为此时animal为Fish类型,可以调用swim方法了
      if (isFish(animal)) {
        animal.swim()
      }
    }
  5. null保护

    可以通过var!的形式断言不为nullundefined,即去除类型中的nullundefined

    function fixed(name: string | null): string {
      function postfix(epithet: string) {
        return name!.charAt(0) + '.  the ' + epithet; // 断言name不为null
      }
      name = name || "Bob";
      return postfix("great");
    }

多态的this类型

在继承时,如果不使用this类型,很容易子类中返回了父类的类型,造成结果无法调用子类的方法。

class BasicCalculator {
  public constructor(public value: number = 0) {}
  public add(value: number): BasicCalculator {
    this.value += value
    return this
  }
 
  public multiply(value: number): BasicCalculator {
    this.value *= value
    return this
  }
}
 
class SuperCalculator extends BasicCalculator {
  public subtraction(value: number): SuperCalculator {
    this.value -= value
    return this
  }
}
 
let calc = new SuperCalculator()
let result = calc.add(1).multiply(2) // BasicCalculator,此时无法调用subtraction

如果使用this类型:

class BasicCalculator {
  public constructor(public value: number = 0) {}
  public add(value: number): this { // 子类调用这个继承的方法时,依然能正确返回子类的类型
    this.value += value
    return this
  }
 
  public multiply(value: number): this {
    this.value *= value
    return this
  }
}
 
class SuperCalculator extends BasicCalculator {
  public subtraction(value: number): this {
    this.value -= value
    return this
  }
}
 
let calc = new SuperCalculator()
let result = calc.add(1).multiply(2).subtraction(1) // SuperCalculator
 

类型转换

一种类型转换为另一种类型,实际就是类型别名用法的一种,只不过要经过运算。

由于是类型的转换,相当于类型参数化,所以是借鉴了泛型的写法。当然如果是转换指定类型也可以不用泛型。

递归用法

比如可以模拟出LinkedList:

interface Animal {
  name: string
}
 
type LinkedList<T> = T & { next: LinkedList<T> }
 
let animal: LinkedList<Animal>
 
let name = animal.name
let nextName = animal.next.name
let nextNextName = animal.next.next.name

其实从这里就能看出类型转换的本质就是利用泛型进行函数运算。同时交叉类型的本质就是mixsin新的属性。

映射类型

TS提供了很多默认的映射类型,用于常见的场景。

interface Point {
  x: number
  y: number
}
 
type ReadOnlyPoint = Readonly<Point>
type OptionalPoint = Partial<Point>
  • Readonly

    修改为只读

    type Readonly<T> = {
        readonly [P in keyof T]: T[P];
    };
  • Partial

    修改为可选

    type Partial<T> = {
        [P in keyof T]?: T[P];
    };
  • Pick

    只取类型的部分元素

    type Pick<T, K extends keyof T> = {
        [P in K]: T[P];
    };
    interface Point3D {
      x: number
      y: number
      z: number
    }
    type Point = Pick<Point3D,'x' | 'y'> // {x: number, y: number}
  • Omit

    Pick中互补

    Omit<T,K extends keyof any> = Pick<T,Exclude<keyof T,K>>
  • Record

    type Record<K extends keyof any, T> = {
        [P in K]: T;
    };

    Record的作用是简明的限制key的范围。

    keyof any的结果是string | number | symbol,所以K基本可以取任意字符串、数字和symbol

    type roles = 'tester' | 'developer' | 'manager'
    type RecordR = Record<roles,string>  // key的类型就是roles
    interface Point {readonly x: number,y: number}
    type PointKey = { [P in keyof Point]: string } // {readonly x: string,y: string}
    type RecordPoint = Record<keyof Point, string> // {x: string,y: string}
     

其他映射类型都是类似的,内部使用for .. in语法,用来遍历字面量类型的联合类型,然后获取key类型对应的索引类型。

注意:遍历keyof时会正确的拷贝修饰符,比如T的属性是readonly,那 P遍历时也会拷贝这个readonly,这叫同态映射,也有不同态的,比如RecordRecordP并不是从keyof T中读取。

自定义映射:

比如将Point改为属性可以取空值:

type NullablePoint = {
  [P in keyof Point]: Point[P] | null
}

更通用的写法:

type Nullable<T> = {
  [P in keyof T]: T[P] | null
}

更复杂的例子:

// 声明一个代理类型
type Proxy<T> = { 
  get(): T
  set(value: T): void
}
 
// 对某个类型进行转换,转换结果是对对象的值进行代理
type Proxify<T> = {
  [P in keyof T]: Proxy<T[P]> // 对值类型进一步映射
}
 
function proxify<T>(o: T): Proxify<T> {
    // 定义返回对象
  const result = {} as Proxify<T>
  for (let key in o) {
      // key保持不变,value变成Proxy类型
    result[key] = {
        // get和set对应的类型应该是key此时在T中对应的value类型,key的类型为Extract<keyof T, string>,所以value的类型是T[Extract<keyof T, string>]。从编译的角度应该这么理解,但是从运行的角度看,此时get和set对应的类型应该是key对应的索引类型:T[key]。但是TS是编译时,我们只需要从编译的角度统一考虑就好了。
      get(): T[Extract<keyof T, string>] { 
        return o[key]
      },
      set(value: T[Extract<keyof T, string>]) {
        o[key] = value
      },
    }
  }
  return result
}
 
// 上面代理的逆向过程
function unproxify<T>(o: Proxify<T>): T {
  const result = {} as T
  for (let key in o) {
    result[key] = o[key].get()
  }
  return result
}

条件类型

  • Exclude<T, U> — 从T中剔除可以赋值给U的类型。

    type Exclude<T, U> = T extends U ? never : T;

    这里就是利用了分配律。

    type Test = Exclude<'a'|'b'|'c','a'|'c'> 
    // 等价于
    Exclude<'a','a'|'c'> | Exclude<'b', 'a'|'c'> | Exclude<'c', 'a'|'c'>
    // 等价于
    never | 'b' | never
    // 结果
    'b'
  • Extract<T, U> — 提取T中可以赋值给U的类型。

    type Extract<T, U> = T extends U ? T : never;

    和上面相反,得到的就是true的联合。

  • NonNullable<T> — 从T中剔除nullundefined

    type NonNullable<T> = T extends null | undefined ? never : T;
    NonNullable<string | number | undefined>

    实际完全可以用Exclude实现

    Exclude<string | number | undefined,null | undefined>
  • ReturnType<T> — 获取函数返回值类型。

    function fn() {
      return { a: 'foo', b: 1 }
    }
     
    type FnReturnType = ReturnType<typeof fn> // {a: string,b: number}
    type F2ReturnType = ReturnType<() => number> // number
  • InstanceType<T> — 获取构造函数类型的实例类型。

    class关键字定义的都是构造函数,可通过typeof获取这个构造函数的类型

    class Circle {}
    type C = InstanceType<typeof Circle> // Circle

代码检查

主要包含2部分检查:

质量检查: Eslint

格式检查: Prettier

Eslint

项目中安装了Eslint,每次都需要执行命令或编译的时候才会进行检查,但是如果使用vscode,可以通过安装Eslint Extension来实现实时检查。

注意Eslint Extension并不能单独的执行检查,它也是调用全局或本项目的Eslint Package来实现的。

Eslint Package进行检查时,会调用当前项目的Eslint配置文件.eslintrc.js(有多个时,会合并生效)。

配置文件可手动编写,也可通过命令引导生成:

  • 全局Eslint

    eslint --init
  • 本地Eslint

    .\node_modules\.bin\eslint --init # 找到本地安装的eslint

ESLint也能格式化,但是Prettier更擅长。

Prettier

同样的Prettier Extension能够实时的检查并格式化,Prettier Package是通过命令的方式来检查。

不同于Eslint ExtensionPrettier Extension本身提供了Prettier的功能,如果全局(需要开启prettier.resolveGlobalModulestrue)或本地没有提供Prettier Package,就会使用扩展自带的版本。

配置文件:

  • 基于扩展的配置

    一般用于non-project文件。

  • 使用.prettierrc.js文件

    推荐使用,给项目提供配置文件显然更合理。拥有最高优先级。

    NOTE: If any local configuration file is present (i.e. .prettierrc) the VS Code settings will NOT be used.

Eslint 和 Prettier同时使用

同时使用EslintPrettier时,Prettier并不是单独的使用,而是作为Eslint的补充来运行。

也就是要让Prettier替代Eslint格式化部分的功能。

需要做2件事:

  1. 引入Prettier,以plugin的形式,需要安装eslint-plugin-prettiereslint-plugin-prettier并不包含Prettier,Prettier需要单独安装)
  2. 关闭Eslint中和Prettier格式化冲突的部分,只让Prettier生效,需要安装eslint-config-prettier

然后配置.eslintrc.js即可:

extends: ['plugin:prettier/recommended'],

此时Prettier生效,会按照Prettier的配置文件格式化。

AlloyTeam

使用Typescript配合EslintPrettier,需要的配置很多,包括.eslintrc.js.prettierrc.js,其中.eslintrc.js中需要配置相关解析器、插件、extends等等。

依然繁琐。

实际可以使用别人配置好的,比如AlloyTeam的配置,预定义好了一系列的配置,可以根据需要调整。

AlloyTeam提供了很多模式下的配置,比如Typescript,Vue,React等等。

Typescript为例:

npm install --save-dev eslint typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-alloy prettier

配置.eslintrc.js:

module.exports = {
  extends: [
    'alloy',
    'alloy/typescript',
  ],
  env: {
    // 你的环境变量(包含多个预定义的全局变量,不同的环境对应不同的预设全局变量,比如browser就有window,document等等)
    //
    // browser: true,
    // node: true,
    // mocha: true,
    // jest: true,
    // jquery: true
  },
  globals: {
    // 你的全局变量(设置为 false 表示它不允许被重新赋值)
    //
    // myGlobal: false
  },
  rules: {
    // 自定义你的规则
  },
};

rules可以参照Alloyteam config或者Eslint官网

然后直接配置.prettierrc.js即可:

// .prettierrc.js
module.exports = {
  // 一行最多 120 字符
  printWidth: 120,
  // 使用 2 个空格缩进
  tabWidth: 2,
  // 不使用缩进符,而使用空格
  useTabs: false,
  // 行尾需要有分号
  semi: true,
  // 使用单引号
  singleQuote: true,
  // 对象的 key 仅在必要时用引号
  quoteProps: 'as-needed',
  // jsx 不使用单引号,而使用双引号
  jsxSingleQuote: false,
  // 末尾需要有逗号
  trailingComma: 'all',
  // 大括号内的首尾需要空格
  bracketSpacing: true,
  // jsx 标签的反尖括号需要换行
  bracketSameLine: false,
  // 箭头函数,只有一个参数的时候,也需要括号
  arrowParens: 'always',
  // 每个文件格式化的范围是文件的全部内容
  rangeStart: 0,
  rangeEnd: Infinity,
  // 不需要写文件开头的 @prettier
  requirePragma: false,
  // 不需要自动在文件开头插入 @prettier
  insertPragma: false,
  // 使用默认的折行标准
  proseWrap: 'preserve',
  // 根据显示样式决定 html 要不要折行
  htmlWhitespaceSensitivity: 'css',
  // vue 文件中的 script 和 style 内不用缩进
  vueIndentScriptAndStyle: false,
  // 换行符使用 lf
  endOfLine: 'lf',
  // 格式化内嵌代码
  embeddedLanguageFormatting: 'auto',
};

编译选项

Typescript提供了很多编译选项,来控制编译过程。

tsc --init # 生成tsconfig.json文件

参考文档

部分说明:

基本格式:

// tsconfig.json
{
    "files": [],
    "extends": "",
    "include": [],
	"exclude": [],
	"references": [],
	"compilerOptions": {},
    "watchFile": {},
	"typeAcquisition": {}
}

上面的是Top level,其中compilerOptions最重要,大部分都是配置这个内容。

Files

设置需要编译的文件。

{
    "files": ["foo.ts","bar.ts"]
}

这个选项用的较少。一般都用include

**注意:**被引用的文件,就算不在files中也会被编译,因为依赖链中的内容都会被包含。

extends

配置文件的继承。

比如有一个基础配置,多个项目都通用,可以extends它,然后在它的基础上做调整。

// configs/base.json
{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}
// tsconfig.json
{
  "extends": "./configs/base",
  "files": ["main.ts", "supplemental.ts"]
}

include

可以利用wildcard characters来通配一组内容。

  • * 表示0个或多个
  • ?表示任意一个
  • **/ 表示任意嵌套目录

比如要包含src下的所有内容:

{
    "include": ["src/**/*"]
}

如果没有指定后缀,默认支持.ts,.tsx,.d.ts,在allowJstrue时,还支持.js,.jsx

exclude

exclude只针对include生效,也就是exclude只排除include中的内容。

同样的,如果被引用了(包括import/// <reference,或者使用了该文件的内容),或是在files中指定了,依然会被加入到编译范围。

{
    "include": ["src/**/*"],
    "exclude": ["src/tests/**/*"]
}

references

这个选项的主要作用是项目拆分,把部分功能/模块抽离出来,单独变成一个项目,然后引用这个项目,即把这个项目当作依赖。对于复杂度较高的项目很有用,特别是公共依赖部分。

参考资料

compilerOptions

编译选项,绝大部分时候需要考虑的部分,项目规范的体现。

Type Checking

类型检查部分

对于大部分选项,一般有3个值:

  • undefined: 警告
  • true: 选项生效
  • false: 不生效,检查报错
allowUnreachableCode
function fn(n: number) {
    if (n > 5) {
        return true
    } else {
        return false
    }
    return true // 这就是unreachable code
}

正常不需要设置,因为一般都会用到eslinteslint对unreachable code直接报错。所以如果想要允许,还需要同步设置eslint规则。

alwaysStrict

开启后,编译后的javascript都会写入use strict

exactOptionalPropertyTypes

开启后,对于?可选属性,将不再能赋undefined

interface UserDefaults {
    theme?: 'dark' | 'grey'
}
let userDefaults: UserDefaults = { theme: undefined } // 此时报错

建议不开启,可选属性,属性类型会被编译成:

原有类型 | undefined  // 这属于正常逻辑理解
noFallthroughCasesInSwitch

对于每一个case(除了最后一个或default)都要有breakreturn

建议开启。

let a = 6
 
switch (a) {
    case 0:
        console.log(0)
        break // 没有的时候会报错
    case 1:
        console.log(1)
        break
    case 6:
        console.log(6)
}
noImplicitAny

在某个类型没有指定或无法推断时,会被指定为any,这可能会造成错误。

function fn(s) {
  // No error?
  console.log(s.subtr(3)); // s被指定为any,但是实际允许可能报错
}
fn(42);

开启后:

function fn(s) {
  // Parameter 's' implicitly has an 'any' type. 
  console.log(s.subtr(3));
}

建议开启。

noImplicitOverride

对于继承后覆写的方法,强制要求使用override修饰,很有用。类似java中的@Override

class Album {
    public download() {}
}
 
class SharedAlbum extends Album {
    public override download() {}
}
 

建议开启。

noImplicitReturns

必须显示的指定return

建议开启。

noImplicitThis

this需要有明确的类型指向,也就是不允许隐式的当作any

class Rectangle {
  width: number;
  height: number;
 
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
 
  getAreaFunction() {
    return function () {
        // 这里的this是返回的函数的调用方,并不是当前实例,所以this的类型未知,隐式的被当作any了
      return this.width * this.height;
'this' implicitly has type 'any' because it does not have a type annotation.
'this' implicitly has type 'any' because it does not have a type annotation.
    };
  }
}
noPropertyAccessFromIndexSignature

对于没有明确的属性时,不能通过obj.key语法来访问,只能通过obj[key]

比如说:

interface Animal {
    name: string
    [index: string]: string
}

此时

function fn(animal: Animal): void {
    let name = animal.name
    let color = animal.color // 属性“color”来自索引签名,因此必须使用[“color”]访问它。ts(4111)
 
}

这个选项可以用来查看某个属性是否明确存在。

建议开启。

noUncheckedIndexedAccess
interface Animal {
    name: string
    [index: string]: string
}
declare const dog: Animal
// 开启前
const color = dog['color'] // color被推断为string
// 开启后
const color = dog['color'] // color被推断为string | undefined

开启后,未明确的属性,类型会添加undefined

noUnusedLocals

开启后,没有使用的本地变量会报错。

建议开启。

noUnusedParameters

开启后,没有使用的参数会报错。

建议开启

strict

严格模式,是一系列规则的整合。一当开启,一组规则就会被应用。

包括:

  • alwaysStrict
  • strictNullChecks
  • strictBindCallApply
  • strictFunctionTypes
  • strictPropertyInitialization
  • noImplicitAny
  • noImplicitThis
  • useUnknownInCatchVariables

开启后,如果想禁用其中某些规则,可以单独禁用。

建议开启。

strictBindCallApply

设置后,对于使用call,bind,apply调用函数时,能正确的检查参数是否符合要求。

function fn(x: string) {
    return parseInt(x, 10)
}
// 如果没有开启
fn.call(undefined, 10) // 不会报错
// 如果开启了
fn.call(undefined,'10') // 必须传string
strictFunctionTypes
function testFn(x: string): void {
    console.log(x.length)
}
 
type FnInterface = (y: string | number) => void
 
const fnImpl: FnInterface = fn

不开启的情况下,上述过程不会报错,但是此时运行fnImpl(10)就报错了。

开启后会报错。

/* 
不能将类型“(x: string) => number”分配给类型“FnInterface”。
  参数“x”和“y” 的类型不兼容。
    不能将类型“string | number”分配给类型“string”。
      不能将类型“number”分配给类型“string”。
*/

理论上涉及函数的地方都会有这种问题,但是遗憾的不是所有的地方都能探测到这个问题:

type FnType = {
    Fn(y: string | number): void //这种method写法无法探测到问题,Fn:(y: string | number): void,这种就可以
}
const fnImpl: FnType = { Fn: testFn } // 不会报错
strictNullChecks

如果开启,nullundefined类型将不是bottom类型,而是普通类型,也就是无法赋值给其他类型了。

let x: string = undefined // 报错 undefined无法分配给string类型

建议开启。

strictPropertyInitialization

开启后,所有的实例属性必须通过构造函数赋初始值。

class Cat {
    public name: string // 没有初始化表达式,此时必须在构造函数中赋值 
    public age = 0 // 有初始化表达式,相当于构造函数中使用了 this.age = 0
    public color: string | undefined // 有初始化表达式,等价于 color: string | undefined = undefined,等价于构造函数中this.color = undefined
    public constructor(name: string) {
        this.name = name
    }
}

建议开启。

useUnknownInCatchVariables

try...catch中捕获到的error使用unkown代替anyunkown是类型安全的any

function catchErr(arg: string) {
    try {
        let s = arg.length
        console.log(s)
    } catch (error) {
        error.dosome() // 不开启的情况下是any类型,此时不报错,但是any过于不安全,使用unkown替代更合适,此时需要先明确error的类型,然后再进行操作
        // 修改后
        if (error instanceof Error) {
            console.log(error.message)
        }
    }
}
 

建议开启。

Modules

涉及模块部分配置。

allowUmdGlobalAccess

对于UMD模块,既可以导出,也可以直接声明全局内容。

正常建议都使用模块化,也就是导入来引用UMD模块,但是某些情况下如果实在无法导入,就只能直接使用全局变量了,此时就需要开启这个配置。

baseUrl

用于配置额外的模块搜索路径(非相对模块搜索)。

通常配合paths使用。

也可用于简化模块路径,比如:

import xxxx from './src/a/b/module'

设置baseUrl:

{
    "complierOptions": {
        "baseUrl": './src/a'
    }
}

此时就可以:

import xxxx from 'b/module'
module

用于设置编译后使用的模块系统。

通常都是使用commonjs。也可以umd,esnext,es6/es2015,nodenext等等,现代一般使用nodenext

需要和moduleResolution配合使用。

moduleResolution

模块解析策略,classic官方不推荐使用。

关于nodenext选项,这个会根据环境比如package.json中的type字段或是文件扩展(.mts,.cts)决定将文件当作ESM模块还是CommonJS模块来解析,并应用对应的解析策略。

module选项配合使用。

一般可以2个都使用nodenext

TS在语法层面可以同时使用import,require风格,只要使用了对应的export即可。

noResolve

正常编译器遇到导入的模块,会加载该文件,以供后续编译,但是使用noResolve后,只有在命令行上写入的模块才会被加载。

// app.ts
import * as A from "moduleA" 
import * as B from "moduleB"
tsc app.ts moduleA.ts --noResolve # 此时只有moduleA被加载,import时能正常解析编译,moduleB会报找不到的错误
paths

配合baseUrl使用,用于路径映射。

{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
        "app/*": ["app/*"],
        "config/*": ["app/_config/*"],
        "environment/*": ["environments/*"],
        "shared/*": ["app/_shared/*"],
        "helpers/*": ["helpers/*"],
        "tests/*": ["tests/*"]
    },
}

此时:

improt XXX from 'app/module'

匹配到paths下的app/*,会去找src/app/module

resolveJsonModule

允许导入json格式的模块,导入后会自动推断类型。

{
    "repo": "TypeScript",
    "dry": false,
    "debug": false
}
import settings from './utils/settings.json'
 
const utilsSet = settings
 
// settings是类型为{repo: string, dry: boolean, debug: boolean}的值
rootDir

最主要的作用是缩短构建后的目录层级。

如果所有参与编译且有输出的文件都处于src目录下,那么编译输出时,完全可以不需要src这个目录,此时可以:

{
    "compilerOptions": {
        "rootDir": "./src"
    }
}

前提就是必须都在src目录,否则是不允许,且报错的。

rootDirs

可以将多个目录虚拟成一个根目录,在使用上仿佛就是一个目录一样。

 src
 └── views
     └── view1.ts (can import "./template1", "./view2`)
     └── view2.ts (can import "./template1", "./view1`)
 
 generated
 └── templates
         └── views
             └── template1.ts (can import "./view1", "./view2")
{
  "compilerOptions": {
    "rootDirs": ["src/views", "generated/templates/views"]
  }
}

此时可以将template1.ts看作在view1.ts同一层。

这不会影响到输出过程,输出后template1.js并不会真的和view1.js在同一层。

也无法通过这种”虚拟“手段绕过rootDir的限制。

typeRoots

typeRoots指定的目录下的所有包都是可见的,全局变量自动加入全局声明空间中了。

默认值是node_modules/@types

types

配合typeRoots使用,进一步明确可见的包,而不是所有的包。

{
  "compilerOptions": {
    "types": ["node", "jest", "express"]
  }
}

现在只有node_modules/node,node_modules/jest,node_modules/express可见的。

这并不会影响导入功能,就算不在typeRootstypes中都能正常导入使用。

Emit

生成相关文件。

declaration

用于设置是否生成.d.ts声明文件。

declarationDir

用于设置生成的声明文件的目录,否则会放在编译后的位置(同js)。

declarationMap

用于关联声明文件和原始ts文件,此时像vs code就可以直接跳转到原始文件处了。

非常有用,建议开启。

downlevelIteration

迭代器降级,这个选项是为了在较低版本的javascript上更好的模拟现代迭代器,比如es6中的for..of,展开语法等等。

如果不开启,可能就是简单粗暴的使用传统for循环来实现了,但是这有时候并不一定正确,比如for..of是循环一个个单位,而for循环是循环一个个元素,有时候多个元素才组成一个单位。

建议开启。

emitDeclarationOnly

仅仅生成.d.ts声明文件,不生成.js文件。

importHelpers

对于某些降级行为(编译成低版本javascript)有时候会生成大量辅助代码,这些辅助代码可能在多个模块中都使用了,相当于重复了,此时完全可以抽离成一个单独的模块,实际上tslib就是被抽离出来的辅助代码,使用importHelpers时,会自动引入tslib

importsNotUsedAsValues

对于import语句的处理。

比如你的导入语句,只导入了类型,那编译后这个导入语句实际就没有作用。

这个选项有3个值来进行控制:

  • remove: 删除导入语句
  • preserve: 保留,这可能导致副作用
  • error: 保留导入语句,但是会报错
inlineSourceMap

source map文件嵌入在js文件中。

有source map后可直接调试ts文件。

这和声明文件的source map不同,这是js和ts文件的映射。

inlineSources

将ts源文件的内容嵌入到source map中,放在sourcesContent属性中(source map是json格式)。

mapRoot

指定source map文件的位置,这样debugger就会从这里找map文件。

newLine

指定换行符使用crlf(windows)还是lf(linux)。正常不需要设置,会自动根据平台处理。

noEmit

不生成文件。有时候并不需要TypeScript来生成JavaScript,而是交给其他工具来做,比如bable

noEmitHelpers

不生成转换的辅助代码。前面importHelpers会引用tslib,来避免重复的辅助代码。这里是直接不生成,也不引用,前提是全局作用域下有。

noEmitOnError

如果出现错误,则不生成任何输出文件。

outDir

输出文件的位置。

preserveConstEnums

const枚举类,正常编译后会直接删除,它的实例会被索引值直接代替。

开启这个选项,可以在编译后保留源码,但是使用实例的地方依然会被编译成索引值,也就是源码压根没参与运行。

preserveValueImports

有时候TypeScript并不能探测到某个引用内容被使用了,比如在一些模板语法中。

import { Animal } from "./animal.js";
eval("console.log(new Animal().isDangerous())");

此时会认为这个是一个unused value,就会删除这个import

这个选项就是为了应对这种情况的。

对于没有使用的值和单纯的类型,在编译后都会省略掉,甚至直接删除整个导入语句。preserveValueImports可以避免误删,但是这也可能存在错误的保留了单纯的类型引用,因为有时候ts并不一定清楚这是一个值还是类型,如果是类型,又保留了下来,肯定报错。所以一定要让ts知道引用的是值还是类型。这涉及另一个选项isolatedModules,开启后,类型的导出必须用type修饰,这样能明确导入的是什么了。

removeComments

编译之后移除注释。

sourceMap

生成单独的source map。

sourceRoot

mapRoot用来指定map文件的位置,sourceRoot用来指定源文件的位置。

stripInternal

对于使用@Internal标注的声明,在生成声明文件时会忽略。

/**
 * Days available in a week
 * @internal
 */
export const daysInAWeek = 7;
 
/** Calculate how much someone earns in a week */
export function weeklySalary(dayRate: number) {
  return daysInAWeek * dayRate;
}

不开启时:

/**
 * Days available in a week
 * @internal
 */
export declare const daysInAWeek = 7;
/** Calculate how much someone earns in a week */
export declare function weeklySalary(dayRate: number): number;

开启后:

/** Calculate how much someone earns in a week */
export declare function weeklySalary(dayRate: number): number;

这只影响声明文件,对于输出js没有影响。

JavaScript Support

源文件中js文件的处理。

allowJs

相当于ts文件中可以直接使用js文件的内容。2个文件类型混用。

checkJs

配合allowJs使用,编译器同样会检查js文件是否符合ts规范。

Editor Support

编辑相关。

disableSizeLimit

正常TypeScript分配的内存是有限制的,打开这个选项,就没有限制了,这对于很大的项目来说有用。

plugins

引入插件,提供一些额外的编辑支持,比如sql的智能补全,sql语句错误信息。

Interop Constraints

allowSyntheticDefaultImports

当模块没有默认导出时,可以使用这选项来模拟合成默认导出。

比如:

import * as XXX from 'module'

开启后可以直接写成:

import XXX from 'module'

这只是编译器允许,编译生成Js时没有影响。

esModuleInterop

ts将所有模块都当作es模块来处理,包括CommonJs/AMD/UMD。

会存在一个问题:

比如CommonJs模块直接导出了一个函数对象:

// moment/index.js
function moment(arg){
  console.log(arg);
  
}
module.exports = moment
// moment/index.d.ts
export = moment
declare function moment(arg: string): void

此时如果使用:

// src/index.ts
import * as moment from 'moment'  

在es模块规范下,import * as xxx会被当作一个object。但是此时moment是一个function,产生冲突。

只能是:

// src/index.ts
import moment from 'moment' // es导入,导入默认值

但是这种写法在es中的含义是导入默认值,要想匹配CommonJs中的导出,需要开启esModuleInteropallowSyntheticDefaultImports

实际上对于这种情况还有一种办法:

import moment = require('moment')

但是不建议,最好统一用es语法,并配合配置文件实现。

forceConsistentCasingInFileNames

强制区分文件名大小写。

建议开启。

isolatedModules

开启后不允许有孤立的文件(直接暴露在全局声明空间中),也就是必须全部都是模块化文件。

并且在导出类型时必须跟修饰符type。这很重要,因为有时候ts并不知道这是值还是类型,能明确最好。

Compiler Diagnostics

编译分析,用于查看编译时发生了什么。

explainFiles

列出那些文件参与了编译,以及为什么参与。

generateCpuProfile

只能通过cli:

npm run tsc --generateCpuProfile tsc-output.cpuprofile

生成编译时的cpu信息。

listEmittedFiles

列出生成的文件。

listFiles

列出参与编译的文件,用explainFiles更好。

Projects

composite
  • 如果没有明确指定 rootDir,则默认为包含 tsconfig.json 文件的目录。
  • 所有实现的文件必须由 include 来匹配,或在 files 数组中指定。如果违反了这一约束,tsc 将告诉你哪些文件没有被指定。
  • declaration 默认为 true
disableReferencedProjectLoad

对于有多个子项目的,可以通过这个选项,禁止一次把所有的项目都自动加载进内存,而是采用动态加载的方式。对于项目很大时有用。