TypeScript 知识汇总(三)(3W 字长文)

2,686 阅读24分钟

文章使用的 TypeScript 版本为3.9.x,后续会根据 TypeScript 官方的更新继续添加内容,如果有的地方不一样可能是版本报错的问题,注意对应版本修改即可。

前言

该文章是笔者在学习 TypeScript 的笔记总结,期间寻求了许多资源,包括 TypeScript 的官方文档等多方面内容,由于技术原因,可能有很多总结错误或者不到位的地方,还请诸位及时指正,我会在第一时间作出修改。

文章中许多部分的展示顺序并不是按照教程顺序,只是对于同一类型的内容进行了分类处理,许多特性可能会提前使用,如果遇到不懂的地方可以先看后面内容。

下面内容接 TypeScript 知识汇总(二)(3W 字长文)

8.TypeScript 中的模块

与 ES6 一样,TypeScript 也引入了模块化的概念,TypeScript 也可以使用 ES6 中的 export、export default 和 import 导出和引入模块类的数据,从而实现模块化

ES6 标准与 Common.js 的区别

  • require: node 和 es6 都支持的引入
  • export 和 import: ES6 支持的导出引入,在浏览器和 node 中也不支持(node 8.x 版本以后已经支持),需要 babel 转换,而且在 node 中会被转换为 exports,但是在 TypeScipt 中使用编译出来的 JS 代码可以在 node 中运行,因为会被编译为 node 认识的 exports
  • module.exports 和 exports: 只有 node 支持的导出

注: ES6 的模块不是对象,import命令会被 JavaScript 引擎静态分析,在编译时就引入模块代码,而不是在代码运行时加载,所以无法实现条件加载

8.1 导出

8.1.1 导出声明

任何声明(比如变量、函数、类、类型别名或接口)都能够通过添加export关键字来导出

export interface StringValidator {
  isAcceptable(s: string): boolean
}
export const numberRegexp = /^[0-9]+$/

export class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s)
  }
}

8.1.2 导出语句

//上面的语句可以直接通过导出语句来写
const numberRegexp = /^[0-9]+$/
interface StringValidator {
  isAcceptable(s: string): boolean
}
class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s)
  }
}
export { ZipCodeValidator }
export { ZipCodeValidator as mainValidator } //as能够改变导出变量的名字,在外部接收时使用

8.1.3 默认导出

每个模块都可以有一个default导出,默认导出使用 default关键字标记,并且一个模块只能够有一个default导出。需要使用一种特殊的导入形式来导入 default导出。通过export default导出的值可以用任意变量进行接收

注:

  • 类和函数声明可以直接被标记为默认导出,标记为默认导出的类和函数的名字是可以省略的

    //ZipCodeValidator.ts
    export default class ZipCodeValidator {
      static numberRegexp = /^[0-9]+$/
      isAcceptable(s: string) {
        return s.length === 5 && ZipCodeValidator.numberRegexp.test(s)
      }
    }
    
    import validator from './ZipCodeValidator'
    let myValidator = new validator()
    
  • export default导出也可以是一个值

    //OneTwoThree.ts
    export default '123'
    
    import num from './OneTwoThree'
    console.log(num) // "123"
    

8.1.4 导出模块

TypeScript 提供了export =语法,export =语法定义一个模块的导出对象

注意:

  • 这里的对象一词指的是类、接口、命名空间、函数或枚举
  • 若使用export =导出一个模块,则必须使用 TypeScript 的特定语法import module = require("module")来导入此模块
//ZipCodeValidator.ts
let numberRegexp = /^[0-9]+$/
class ZipCodeValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s)
  }
}
export = ZipCodeValidator
import zip = require('./ZipCodeValidator')

// Some samples to try
let strings = ['Hello', '98052', '101']

// Validators to use
let validator = new zip()

// Show whether each string passed each validator
strings.forEach((s) => {
  console.log(
    `"${s}" - ${validator.isAcceptable(s) ? 'matches' : 'does not match'}`
  )
})

8.2 导入

模块的导入操作与导出一样简单,可以使用以下 import形式之一来导入其它模块中的导出内容

import { ZipCodeValidator } from './ZipCodeValidator'
let myValidator = new ZipCodeValidator()
//可以对导入内容重命名
import { ZipCodeValidator as ZCV } from './ZipCodeValidator'
let myValidator = new ZCV()
//将整个模块导入到一个变量,并通过它来访问模块的导出部分
import * as validator from './ZipCodeValidator'
let myValidator = new validator.ZipCodeValidator()
//导入默认模块
//可以对导入内容重命名
import ZCV from './ZipCodeValidator'
let myValidator = new ZCV()

当然,也可以直接使用import导入一个不需要进行赋值的模板,该模板会自动进行内部的代码

import './my-module.js'

8.2.1 动态导入

import 的导入导出默认是静态的,如果要动态的导入导出可以使用 ES6 新增的import()函数实现类似require()动态导入的功能

注:

  • 使用import()函数返回的是 Promise 对象
  • 如果是commonjs格式的模块需要我们手动调用default()方法获得默认导出
async function getTime(format: string) {
  const momment = await import('moment')
  return moment.default().format(format)
}
// 使用async的函数本身的返回值是一个Promise对象
getTime('L').then((res) => {
  console.log(res)
})

8.3 仅限类型导入和导出

import type { SomeThing } from './some-module.js'

export type { SomeThing }

该语法为 TypeScript 3.8 新增,像上面这样,只导入或导出某个特定类型,该声明仅用于类型注释,在运行时会被消除。

值得注意的是,类在运行时具有值,在设计时具有类型,并且使用上下文很敏感。使用导入类时,不能执行从该类扩展之类的操作,引入使用了import type后我们仅把其当作一个类型来使用。

import type { Component } from 'react'

interface ButtonProps {
  // ...
}

class Button extends Component<ButtonProps> {
  //               ~~~~~~~~~
  // error! 'Component' only refers to a type, but is being used as a value here.
  // ...
}

8.4 export = 和 import = require()

CommonJS 和 AMD 的环境里都有一个exports变量,这个变量包含了一个模块的所有导出内容。CommonJS 和 AMD 的exports都可以被赋值为一个对象, 这种情况下其作用就类似于 es6 语法里的默认导出,即 export default语法了。虽然作用相似,但是 export default 语法并不能兼容 CommonJS 和 AMD 的exports

为了支持 CommonJS 和 AMD 的exports, TypeScript 提供了export =语法。export =语法定义一个模块的导出对象。 这里的对象一词指的是类,接口,命名空间,函数或枚举

import module = require("module")也是 TypeScript 新增的一种导入格式,该格式的导入可以兼容所有的导入格式,但是注意如果是引入的 ES6 特有的导出会默认把导出的模块转换为对象(因为 module 只能够接受一个值,默认应该要获取到所有的导出),同时该对象会多一个__esModule值为true的属性(),而其他的所有属性会加载这个对象中

注: 即使使用的是export default在也会是同样的效果,不过会把默认导出添加到一个default属性上

注意:

  • export =在一个模块中只能使用一次,所以是与CommonJS一样基本都是用于导出一个对象出来
  • ES6import ... from ...的默认导出的语法不能作用在export =导出的对象,因为没有default对象,就像CommonJSmodule.exports一样(虽然最后是转换为这个),而ES6export default转换为CommonJS就是为其添加一个default属性
  • 若使用export =导出一个模块,则必须使用 TypeScript 的特定语法import module = require("module")来导入此模块
  • 除了import module = require("module")导入ES6的模块有区别之外,在导入 CommonJS 和 AMD 效果类似,如果在都支持的模块中(UMD 模块为代表),该导入相当于是导入了 ES6 模块中的default
// ZipCodeValidator.ts
let numberRegexp = /^[0-9]+$/
class ZipCodeValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s)
  }
}
export = ZipCodeValidator
// Test.ts
import zip = require('./ZipCodeValidator')

// Some samples to try
let strings = ['Hello', '98052', '101']

// Validators to use
let validator = new zip()

// Show whether each string passed each validator
strings.forEach((s) => {
  console.log(
    `"${s}" - ${validator.isAcceptable(s) ? 'matches' : 'does not match'}`
  )
})

8.4.1 生成模块代码

在之前说到的import module = require("module")的区别的原因是根据编译时指定的模块目标参数,编译器会生成相应的供 Node.js (CommonJS),Require.js (AMD),UMDSystemJSECMAScript 2015 native modules (ES6)模块加载系统使用的代码。如:

  • SimpleModule.ts

    import m = require('mod')
    export let t = m.something + 1
    
  • AMD / RequireJS SimpleModule.js

    define(['require', 'exports', './mod'], function (require, exports, mod_1) {
      exports.t = mod_1.something + 1
    })
    
  • CommonJS / Node SimpleModule.js

    let mod_1 = require('./mod')
    exports.t = mod_1.something + 1
    
  • UMD SimpleModule.js

    ;(function (factory) {
      if (typeof module === 'object' && typeof module.exports === 'object') {
        let v = factory(require, exports)
        if (v !== undefined) module.exports = v
      } else if (typeof define === 'function' && define.amd) {
        define(['require', 'exports', './mod'], factory)
      }
    })(function (require, exports) {
      let mod_1 = require('./mod')
      exports.t = mod_1.something + 1
    })
    
  • System SimpleModule.js

    System.register(['./mod'], function (exports_1) {
      let mod_1
      let t
      return {
        setters: [
          function (mod_1_1) {
            mod_1 = mod_1_1
          }
        ],
        execute: function () {
          exports_1('t', (t = mod_1.something + 1))
        }
      }
    })
    
  • Native ECMAScript 2015 modules SimpleModule.js

    import { something } from './mod'
    export let t = something + 1
    

8.4.2 可选的模块加载

有时候,你只想在某种条件下才加载某个模块。 在 TypeScript 里,使用下面的方式来实现它和其它的高级加载场景,我们可以直接调用模块加载器并且可以保证类型完全。

编译器会检测是否每个模块都会在生成的 JavaScript 中用到。 如果一个模块标识符只在类型注解部分使用,并且完全没有在表达式中使用时,就不会生成 require这个模块的代码。略掉没有用到的引用对性能提升是很有益的,并同时提供了选择性加载模块的能力

import a = require('./a') // 如果只写这句话是不会引入a模块的
console.log(a) // 必须要使用过才会真正引入

这种模式的核心是import id = require("...")语句可以让我们访问模块导出的类型。 模块加载器会被动态调用(通过 require),就像下面if代码块里那样。 它利用了省略引用的优化,所以模块只在被需要时加载。 为了让这个模块工作,一定要注意 import定义的标识符只能在表示类型处使用(不能在会转换成 JavaScript 的地方)

为了确保类型安全性,我们可以使用typeof关键字。 typeof关键字,当在表示类型的地方使用时,会得出一个类型值,这里就表示模块的类型

// 如下面这样就可以在node.js环境实现可选模块加载
declare function require(moduleName: string): any

import { ZipCodeValidator as Zip } from './ZipCodeValidator'

if (needZipValidation) {
  let ZipCodeValidator: typeof Zip = require('./ZipCodeValidator')
  let validator = new ZipCodeValidator()
  if (validator.isAcceptable('...')) {
    /* ... */
  }
}

8.5 模块转换问题

TypeScript 中默认是将所有代码转换为CommonJS模块代码,相对于模块有不同的代码转换规则

  • ES6 模块:

    • import * as ... from ...,这种写法是最接近CommonJSrequire的写法,将所有导出的模块装维一个对象,所以最后也会变为var ... = require('...')

    • import {...} from ...,同上一种一样,不过相当于是用了取对象符

    • import ... from ...,因为这种写法是取出 export 的默认导出,而默认导出其实是模块的一个叫作default的属性,所以也是用了取对象符var ... = require('...').default

      注意: 这样导入的模块一般是需要对应ES6export default语法的,因为要获取default属性,而使用的CommonJSexport =的写法是直接导出一整个对象,如果不给这些导出的对象设置default属性会得到undefined

    • export单独导入同CommonJS中的exports.xxx语法,只需要主要export default等同于exports.default = xxx

  • CommonJS 模块: 因为是转为这种语法的,所以没有兼容性可说

  • TypeScript 模块:

    • import ... = require('...'),等同于CommonJSrequire语法,只是可以支持 AMD 模块,而原生的require是不支持的
    • export =,等同于CommonJSmodule.exports =

9.命名空间

在代码量较大的情况下,为了避免各种变量命名相冲突,可以将类似功能的函数、类、接口等放置到命名空间中

在 TypeScript 中的命名空间中的对象、类、函数等可以通过 export 暴露出来通过命名空间名.类名等来使用

注意: 这个暴露是暴露在命名空间外,不是将其在模块中暴露出去

命名空间和模块的区别:

  • 命名空间: 内部模块,主要用于组织代码,避免命名冲突
  • 模块: TypeScript 的外部模块的简称,侧重代码的复用,一个模块里可能会有多个命名空间
namespace Validation {
  //通过namespace关键词创建一个命名空间
  export interface StringValidator {
    isAcceptable(s: string): boolean //类类型接口
  }

  const lettersRegexp = /^[A-Za-z]+$/
  const numberRegexp = /^[0-9]+$/

  export class LettersOnlyValidator implements StringValidator {
    //要在外部使用必须导出
    isAcceptable(s: string) {
      //函数内部可以不导出
      return lettersRegexp.test(s)
    }
  }

  export class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string) {
      return s.length === 5 && numberRegexp.test(s)
    }
  }
}

// Some samples to try
let strings = ['Hello', '98052', '101']

// 在外界就可以直接通过Validation.StringValidator访问命名空间内部导出的接口
let validators: { [s: string]: Validation.StringValidator } = {}
//上面接口的意思是一个对象,对象中的每个成员都是有isAcceptable接口方法的实例化对象
validators['ZIP code'] = new Validation.ZipCodeValidator()
validators['Letters only'] = new Validation.LettersOnlyValidator()

// Show whether each string passed each validator
for (let s of strings) {
  for (let name in validators) {
    console.log(
      `"${s}" - ${
        validators[name].isAcceptable(s) ? 'matches' : 'does not match'
      } ${name}`
    )
  }
}

9.1 多文件中的命名空间

如果命名空间相同,多个文件内部的代码会合并到同一个命名空间中,其实就是使用var声明字重复定义变量,如果内部没有导出的变量依然只能在内部使用,而暴露的变量就会合并

注: 如果导出变量有重名,后面的文件会覆盖掉前面的

  • 通过 export 和 import 进行使用

    //module.ts
    export namespace A {
      interface Animal {
        name: string
        eat(): void
      }
      export class Dog implements Animal {
        name: string
        constructor(theName: string) {
          this.name = theName
        }
        eat(): void {
          console.log(this.name + '吃狗粮')
        }
      }
    }
    
    // A在JS中就被转换为了一个对象
    import { A } from './module'
    let dog = new A.Dog('狗') //传入命名空间
    dog.eat()
    
  • 通过三斜线指令引入

    三斜线指令: 包含单个 XML 标签的单行注释,注释的内容会做为编译器指令使用,三斜线引用告诉编译器在编译过程中要引入的额外的文件

    注意: 三斜线指令仅可放在包含它的文件的最顶端。 一个三斜线指令的前面只能出现单行或多行注释,这包括其它的三斜线指令。 如果它们出现在一个语句或声明之后,那么它们会被当做普通的单行注释,并且不具有特殊的涵义

    这里只用///<reference path=""/>,其余用法在 TypeScript 中文文档 查看

    /// <reference path="..." />指令是三斜线指令中最常见的一种,它用于声明文件间的 依赖,三斜线引用告诉编译器在编译过程中要引入的额外的文件,也就是会引入对应 path 的文件

    //Validation.ts
    namespace Validation {
      export interface StringValidator {
        isAcceptable(s: string): boolean
      }
    }
    
    //LettersOnlyValidator.ts
    /// <reference path="Validation.ts" />
    namespace Validation {
      const lettersRegexp = /^[A-Za-z]+$/
      export class LettersOnlyValidator implements StringValidator {
        isAcceptable(s: string) {
          return lettersRegexp.test(s)
        }
      }
    }
    
    //ZipCodeValidator.ts
    /// <reference path="Validation.ts" />
    namespace Validation {
      const numberRegexp = /^[0-9]+$/
      export class ZipCodeValidator implements StringValidator {
        isAcceptable(s: string) {
          return s.length === 5 && numberRegexp.test(s)
        }
      }
    }
    
    /// <reference path="Validation.ts" />
    /// <reference path="LettersOnlyValidator.ts" />
    /// <reference path="ZipCodeValidator.ts" />
    
    // Some samples to try
    let strings = ['Hello', '98052', '101']
    
    // Validators to use
    let validators: { [s: string]: Validation.StringValidator } = {}
    validators['ZIP code'] = new Validation.ZipCodeValidator()
    validators['Letters only'] = new Validation.LettersOnlyValidator()
    
    // Show whether each string passed each validator
    for (let s of strings) {
      for (let name in validators) {
        console.log(
          `"${s}" - ${
            validators[name].isAcceptable(s) ? 'matches' : 'does not match'
          } ${name}`
        )
      }
    }
    

9.2 别名

别名是另一种简化命名空间操作的方法是使用import q = x.y.z给常用的对象起一个短的名字,不要与用来加载模块的import x = require('name')语法弄混了,这里的语法是为指定的符号创建一个别名

注: 可以用这种方法为任意标识符创建别名,也包括导入的模块中的对象

namespace Shapes {
  export namespace Polygons {
    export class Triangle {}
    export class Square {}
  }
}

import polygons = Shapes.Polygons //用polygons代替Shapes.Polygons,相当于C语言的define
let sq = new polygons.Square() // Same as "new Shapes.Polygons.Square()"

注意:并没有使用require关键字,而是直接使用导入符号的限定名赋值,与使用 var相似,但它还适用于类型和导入的具有命名空间含义的符号。 重要的是,对于值来讲, import会生成与原始符号不同的引用,所以改变别名的var值并不会影响原始变量的值

10.TypeScript 中的装饰器

装饰器是一种特殊类型的声明,它能够被附加到类声明,方法,属性或参数上,可以修改类的行为,通俗来讲装饰器就是一个方法,可以注入到类、方法、属性参数上来扩展类、属性、方法、参数的功能

装饰器已经是 ES7 的标准特性之一

常见的装饰器

  • 类装饰器
  • 属性装饰器
  • 方法装饰器
  • 参数装饰器

装饰器的写法

  • 普通装饰器(无法传参)
  • 装饰器工厂(可传参)

注意: 装饰器是一项实验性特性,因为装饰器只是个未来期待的用法,所以默认是不支持的,如果想要使用就要打开 tsconfig.json 中的experimentalDecorators,否则会报语法错误

命令行:

tsc --target ES5 --experimentalDecorators

tsconfig.json:

10.1 类装饰器

类装饰器在类声明之前被声明(紧跟着类声明),类装饰器应用于类构造函数,可以用来监视,修改或替换类定义,需要传入一个参数

10.1.1 普通装饰器

function logClass(target: any) {
  console.log(target)
  //target就是当前类,在声明装饰器的时候会被默认传入
  target.prototype.apiUrl = '动态扩展的属性'
  target.prototype.run = function () {
    console.log('动态扩展的方法')
  }
}

@logClass
class HttpClient {
  constructor() {}
  getData() {}
}
//这里必须要设置any,因为是装饰器动态加载的属性,所以在外部校验的时候并没有apiUrl属性和run方法
let http: any = new HttpClient()
console.log(http.apiUrl)
http.run()

10.1.2 装饰器工厂

如果要定制一个修饰器如何应用到一个声明上,需要写一个装饰器工厂函数。 装饰器工厂就是一个简单的函数,它返回一个表达式,以供装饰器在运行时调用

注: 装饰器工厂是将内部调用的函数作为真正的装饰器返回的,所以装饰器工厂需要和函数用法一样通过()来调用,内部可以接收参数

function color(value: string) {
  // 这是一个装饰器工厂
  return function (target: any) {
    //这是装饰器,这个装饰器就是上面普通装饰器默认传入的类
    // do something with "target" and "value"...
  }
}
function logClass(value: string) {
  return function (target: any) {
    console.log(target)
    console.log(value)
    target.prototype.apiUrl = value //将传入的参数进行赋值
  }
}

@logClass('hello world') //可传参数的装饰器
class HttpClient {
  constructor() {}
  getData() {}
}

let http: any = new HttpClient()
console.log(http.apiUrl)

10.1.3 类装饰器重构构造函数

类装饰器表达式会在运行时当作函数被调用,类的构造函数作为其唯一的参数 ,如果类装饰器返回一个值,它会使用提供的构造函数来替换类的声明,通过这种方法我们可以很轻松的继承和修改原来的父类,定义自己的属性和方法

注意: 如果要返回一个新的构造函数,必须注意处理好原来的原型链

/*
通过返回一个继承的类实现一个类的属性和方法的重构,换句话说就是在中间层有一个阻拦,然后返回的是一个新的继承了父类的类,这个类必须有父类的所有属性和方法,不然会报错
*/
function logClass(target: any) {
  // 返回一个继承原来类的新的类
  return class extends target {
    //可以当做是固定写法吧
    apiUrl: string = '我是修改后的数据'
    getData() {
      console.log(this.apiUrl)
    }
  }
}
//重构属性和方法
@logClass
class HttpClient {
  // 如果不在这声明TypeScript的检测器检测不出来,在下面的使用都会报错,可以使用接口的声明合并来消除
  constructor(public apiUrl = '我是构造函数中的数据') {}
  getData() {
    console.log(123)
  }
}
/*
    interface HttpClient {
      apiUrl: string
      getData(): void
    }
*/

let http: any = new HttpClient()
console.log(http.apiUrl) //我是修改后的数据
http.getData() //我是修改后的数据

10.1.4 装饰器求值

类中不同声明上的装饰器将按以下规定的顺序应用:

  1. 参数装饰器,然后依次是方法装饰器访问符装饰器,或属性装饰器应用到每个实例成员
  2. 参数装饰器,然后依次是方法装饰器访问符装饰器,或属性装饰器应用到每个静态成员
  3. 参数装饰器应用到构造函数
  4. 类装饰器应用到类

10.2 方法装饰器

方法装饰器声明在一个方法的声明之前(紧靠着方法声明)

注意:

  • 它会被应用到方法的属性描述符上,可以用来监视,修改或者替换方法定义
  • 方法装饰器不能用在声明文件( .d.ts),重载或者任何外部上下文(比如declare的类)中

方法装饰器被应用到方法的属性描述符上,可以用来监视,修改或替换方法定义,传入三个参数(都是自动传入的):

  • 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象

  • 成员的名字(只是个 string 类型的字符串,没有其余作用)

  • 成员的属性描述符,是一个对象,里面有真正的方法本身

    注: 如果代码输出目标版本小于ES5,属性描述符将会是undefined

注意:如果方法装饰器返回一个值,它会被用作方法的属性描述符,如果代码输出目标版本小于ES5返回值会被忽略

function get(value: any) {
  // PropertyDescriptor是TypeScript中内置的属性描述符的类型限定,包含了类型修辞符的所有属性
  return function (target: any, methodName: string, desc: PropertyDescriptor) {
    console.log(target) //HttpClient类
    console.log(methodName) //getData方法名,一个字符串
    console.log(desc) //描述符
    console.log(desc.value) //方法本身就在desc.value中
    target.url = 123 //也能改变原实例
  }
}

class HttpClient {
  public url: any | undefined
  constructor() {}
  @get('hello world')
  getData() {
    console.log(this.url)
  }
}

let http = new HttpClient()
console.log(http.url) //123
function get(value: any) {
  // PropertyDescriptor是TypeScript中内置的属性描述符的类型限定
  return function (target: any, methodName: string, desc: PropertyDescriptor) {
    let oMethod = desc.value
    desc.value = function (...args: any[]) {
      //因为用了方法装饰器,所以实际调用getData()方法的时候会调用desc.value来实现,通过赋值可以实现重构方法
      //原来的方法已经赋值给oMethod了,所以可以改变
      args = args.map(
        //这个段代码是将传入的参数全部转换为字符串
        (value: any): string => {
          return String(value)
        }
      )
      console.log(args) //因为方法重构了,所以原来的getData()中的代码无效了,调用时会打印转换后参数
      /*
            如果想依然能用原来的方法,那么写入下面的代码,相当于就是对原来的方法进行了扩展
        */
      oMethod.apply(target, args) //通过这种方法调用可以也实现原来的getData方法
    }
  }
}

class HttpClient {
  public url: any | undefined
  constructor() {}
  @get('hello world')
  getData(...args: any[]) {
    console.log(args) //[ '1', '2', '3', '4', '5', '6' ]
    console.log('我是getData中的方法')
  }
}

let http = new HttpClient()
http.getData(1, 2, 3, 4, 5, 6) //[ '1', '2', '3', '4', '5', '6' ]
function get(bool: boolean): any {
  return (target: any, prop: string, desc: PropertyDescriptor) => {
    // 通过返回值修改属性描述符
    return {
      value() {
        return 'not age'
      },
      enumerable: bool
    }
  }
}

class Test {
  constructor(public age: number) {}
  @get(false)
  public getAge() {
    return this.age
  }
}
const t = new Test(18)
console.log(t.getAge()) // not age,getAge()函数的值以及被修改了
for (const key in t) {
  console.log(key) // 只有age属性,如果上面@get传入的是true就还有getAge()方法
}

10.3.1 属性描述符

在 ES5 之前,JavaScript 没有内置的机制来指定或者检查对象某个属性(property)的特性(characteristics),比如某个属性是只读(readonly)的或者不能被枚举(enumerable)的。但是在 ES5 之后,JavaScript 被赋予了这个能力,所有的对象属性都可以通过属性描述符(Property Descriptor)来指定

interface obj {
  [key: string]: any
}
let myObject: obj = {}

Object.defineProperty(myObject, 'a', {
  value: 2,
  writable: true, // 可写
  configurable: true, // 可配置
  enumerable: true // 可遍历
})
// 上面的定义等同于 myObject.a = 2;
// 所以如果不需要修改这三个特性,我们不会用 `Object.defineProperty`

console.log(myObject.a) // 2

属性描述符的六个属性

  • value:属性值

  • writable:是否允许赋值,true 表示允许,否则该属性不允许赋值

    interface obj {
      [key: string]: any
    }
    let myObject: obj = {}
    
    Object.defineProperty(myObject, 'a', {
      value: 2,
      writable: false, // 不可写
      configurable: true,
      enumerable: true
    })
    
    myObject.a = 3 // 写入的值将会被忽略
    console.log(myObject.a) // 2
    
  • get:返回属性值的函数。如果为 undefined 则直接返回描述符中定义的 value

  • set:属性的赋值函数。如果为 undefined 则直接将赋值运算符右侧的值保存为属性值

    注:

    • 一旦同时使用了getset,需要一个中间变量存储真正的值。
    • setwritable:false是不能共存的。
  • configurable:如果为 true,则表示该属性可以重新使用(Object.defineProperty(...) )定义描述符,或者从属性的宿主删除。缺省为 true

    let myObject = {
      a: 2
    }
    
    Object.defineProperty(myObject, 'a', {
      value: 4,
      writable: true,
      configurable: false, // 不可配置!
      enumerable: true
    })
    
    console.log(myObject.a) // 4
    myObject.a = 5
    // 因为最开始writable时true,所以不会影响到赋值
    console.log(myObject.a) // 5
    
    Object.defineProperty(myObject, 'a', {
      value: 6,
      writable: true,
      configurable: true,
      enumerable: true
    }) // TypeError
    

    注: 一旦某个属性被指定为 configurable: false,那么就不能从新指定为configurable: true 了,这个操作是单向,不可逆的

    这个特性还会影响delete 操作的行为

    let myObject = {
      a: 2
    }
    
    Object.defineProperty(myObject, 'a', {
      value: 4,
      writable: true,
      configurable: false, // 不可配置!
      enumerable: true
    })
    delete myObject.a
    console.log(myObject.a) // 4
    
  • enumerable:如果为 true,则表示遍历宿主对象时,该属性可以被遍历到(比如 for..in 循环中)。缺省为 true

    interface obj {
      [key: string]: any
    }
    let myObject: obj = {}
    
    Object.defineProperty(
      myObject,
      'a',
      // make `a` enumerable, as normal
      { enumerable: true, value: 2 }
    )
    
    Object.defineProperty(
      myObject,
      'b',
      // make `b` NON-enumerable
      { enumerable: false, value: 3 }
    )
    console.log(myObject.b) // 3
    console.log('b' in myObject) // true
    myObject.hasOwnProperty('b') // true
    
    // .......
    // 无法被遍历到
    for (let k in myObject) {
      console.log(k, myObject[k])
    }
    // "a" 2
    
    myObject.propertyIsEnumerable('a') // true
    myObject.propertyIsEnumerable('b') // false
    
    Object.keys(myObject) // ["a"]
    Object.getOwnPropertyNames(myObject) // ["a", "b"]
    

    可以看出,enumerable: false 使得该属性从对象属性枚举操作中被隐藏,但Object.hasOwnProperty(...) 仍然可以检测到属性的存在。另外,Object.propertyIsEnumerable(..) 可以用来检测某个属性是否可枚举,Object.keys(...) 仅仅返回可枚举的属性,而Object.getOwnPropertyNames(...) 则返回该对象上的所有属性,包括不可枚举的

注: Object 有专门操作属性的方法,在这里就不再多讲了

10.3 方法参数装饰器

参数装饰器声明在一个参数声明之前(紧靠着参数声明)。 参数装饰器应用于类构造函数或方法声明。

注意: 参数装饰器不能用在声明文件(.d.ts),重载或其它外部上下文(比如 declare的类)里

参数装饰器被表达式会在运行时当作函数被调用,可以使用参数装饰器为类的原型增加一些元素数据,传入三个参数(都是自动传入的):

  • 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  • 方法的名字(只是个 string 类型的字符串,没有其余作用)
  • 参数在函数参数列表中的索引

注:

  • 参数装饰器只能用来监视一个方法的参数是否被传入
  • 参数装饰器的返回值会被忽略
//这个装饰器很少使用
function logParams(value: any) {
  return function (target: any, methodName: any, paramsIndex: any) {
    console.log(target)
    console.log(methodName) //getData
    console.log(paramsIndex) //1,因为value在下面是第二个参数
  }
}

class HttpClient {
  public url: any | undefined
  constructor() {}
  getData(index: any, @logParams('hello world') value: any) {
    console.log(index)
    console.log(value)
  }
}

let http: any = new HttpClient()
http.getData(0, '123') //我是修改后的数据

10.4 访问器装饰器

访问器装饰器声明在一个访问器的声明之前(紧靠着访问器声明)。 访问器装饰器应用于访问器的属性描述符并且可以用来监视,修改或替换一个访问器的定义。

注意:

  • 访问器装饰器不能用在声明文件中(.d.ts),或者任何外部上下文(比如 declare的类)里
  • TypeScript 不允许同时装饰一个成员的getset访问器。取而代之的是,一个成员的所有装饰的必须应用在文档顺序的第一个访问器上。这是因为,在装饰器应用于一个属性描述符时,它联合了getset访问器,而不是分开声明的

访问器装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数(都是自动传入的):

  • 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象

  • 成员的名字

  • 成员的属性描述符

    注: 如果代码输出目标版本小于ES5Property Descriptor将会是undefined

注意: 如果访问器装饰器返回一个值,它会被用作方法的属性描述符。如果代码输出目标版本小于ES5返回值会被忽略

function configurable(value: boolean) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    descriptor.configurable = value
  }
}

class Point {
  private _x: number
  private _y: number
  constructor(x: number, y: number) {
    this._x = x
    this._y = y
  }

  @configurable(false)
  get x() {
    return this._x
  }

  @configurable(false)
  get y() {
    return this._y
  }
}

10.5 属性装饰器

属性装饰器声明在一个属性声明之前(紧靠着属性声明)。

注意: 属性装饰器不能用在声明文件中(.d.ts),或者任何外部上下文(比如 declare的类)里。

属性装饰器表达式在运行时当作函数被调用,传入两个参数(都是自动传入的):

  • 对应静态成员来说是类的构造函数,对于实例成员来说是类的原型对象
  • 成员的名字

注: 属性描述符不会做为参数传入属性装饰器,这与 TypeScript 是如何初始化属性装饰器的有关。 因为目前没有办法在定义一个原型对象的成员时描述一个实例属性,并且没办法监视或修改一个属性的初始化方法。返回值也会被忽略。 因此,属性描述符只能用来监视类中是否声明了某个名字的属性

function logProperty(value: string) {
  return function (target: any, attr: string) {
    //target为实例化的成员对象,attr为下面紧挨着的属性
    console.log(target)
    console.log(attr)
    target[attr] = value //可以通过修饰器改变属性的值
  }
}

class HttpClient {
  @logProperty('hello world') //修饰器后面紧跟着对应要修饰的属性
  public url: string | undefined
  constructor() {}
  getData() {
    console.log(this.url)
  }
}

let http: any = new HttpClient()
http.getData() //hello world

10.5.5 返回值总结

  • 属性和方法参数装饰器的返回值会被忽略
  • 访问器和方法装饰器的返回值都会被用做方法的属性描述符(低于Es5版本会被忽略)
  • 类装饰器的返回值会返回一个新的构造函数

10.6 装饰器的执行顺序

我们可以对同一个对象使用多个装饰器,装饰器的执行顺序是从后往前执行的

  • 书写在同一行上

    @f @g x
    
  • 书写在多行上

    @f
    @g
    x
    

在 TypeScript 里,当多个装饰器应用在一个声明上时会进行如下步骤的操作:

  1. 由上至下依次对装饰器表达式求值。
  2. 求值的结果会被当作函数,由下至上依次调用。

简单的说就是: 如果是装饰器工厂修饰的(不是只有一个函数,是通过返回函数来实现),会从上到下按照代码的顺序先执行装饰器工厂生成装饰器,然后再从下往上执行装饰器

特别提醒: 如果方法和方法参数装饰器在同一个方法出现,参数装饰器先执行

function f() {
  console.log('f(): evaluated')
  return function (
    target,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    console.log('f(): called')
  }
}

function g() {
  console.log('g(): evaluated')
  return function (
    target,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    console.log('g(): called')
  }
}

class C {
  @f()
  @g()
  method() {}
}
# 在控制台中打印
f(): evaluated
g(): evaluated
g(): called
f(): called

11.Mixins 混入

11.1 对象的混入

和 JS 一样,TypeScript 中混入对象也是使用Object.assign()方法来实现,不多最后的结果会多了一个交叉类型的类型定义,同时包含了所有混入对象的属性

interface ObjectA {
  a: string
}

interface ObjectB {
  b: string
}

let A: ObjectA = {
  a: 'a'
}

let B: ObjectB = {
  b: 'b'
}

let AB: ObjectA & ObjectB = Object.assign(A, B) // 及时左边没有类型定义也会自动被定义为交叉类型
console.log(AB)

11.2 类的混入

对于类的混入,我们需要理解下面这个例子:

// Disposable Mixin
class Disposable {
  isDisposed: boolean
  dispose() {
    this.isDisposed = true
  }
}

// Activatable Mixin
class Activatable {
  isActive: boolean
  activate() {
    this.isActive = true
  }
  deactivate() {
    this.isActive = false
  }
}

class SmartObject implements Disposable, Activatable {
  constructor() {
    setInterval(() => console.log(this.isActive + ' : ' + this.isDisposed), 500)
  }

  interact() {
    this.activate()
  }

  // Disposable
  isDisposed: boolean = false
  dispose: () => void
  // Activatable
  isActive: boolean = false
  activate: () => void
  deactivate: () => void
}
applyMixins(SmartObject, [Disposable, Activatable])

let smartObj = new SmartObject()
setTimeout(() => smartObj.interact(), 1000)

////////////////////////////////////////
// In your runtime library somewhere
////////////////////////////////////////

function applyMixins(derivedCtor: any, baseCtors: any[]) {
  baseCtors.forEach((baseCtor) => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
      derivedCtor.prototype[name] = baseCtor.prototype[name]
    })
  })
}

代码里首先定义了两个类,它们将做为 mixins。 可以看到每个类都只定义了一个特定的行为或功能。 稍后我们使用它们来创建一个新类,同时具有这两种功能

// Disposable Mixin
class Disposable {
  isDisposed: boolean
  dispose() {
    this.isDisposed = true
  }
}

// Activatable Mixin
class Activatable {
  isActive: boolean
  activate() {
    this.isActive = true
  }
  deactivate() {
    this.isActive = false
  }
}

然后我们需要创建一个类来使用他们作为接口进行限制。没使用extends而是使用implements。 把类当成了接口,仅使用 Disposable 和 Activatable 的类型而非其实现。 这意味着我们需要在类里面实现接口。 但是这是我们在用 mixin 时想避免的。

我们可以这么做来达到目的,为将要 mixin 进来的属性方法创建出占位属性。 这告诉编译器这些成员在运行时是可用的。 这样就能使用 mixin 带来的便利,虽说需要提前定义一些占位属性。

class SmartObject implements Disposable, Activatable {
  constructor() {
    setInterval(() => console.log(this.isActive + ' : ' + this.isDisposed), 500)
  }

  interact() {
    this.activate()
  }

  // Disposable
  isDisposed: boolean = false
  dispose: () => void
  // Activatable
  isActive: boolean = false
  activate: () => void
  deactivate: () => void
}

创建帮助函数,帮我们做混入操作。 它会遍历 mixins 上的所有属性,并复制到目标上去,把之前的占位属性替换成真正的实现代码

function applyMixins(derivedCtor: any, baseCtors: any[]) {
  baseCtors.forEach((baseCtor) => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
      derivedCtor.prototype[name] = baseCtor.prototype[name]
    })
  })
}

最后,把 mixins 混入定义的类,完成全部实现部分

applyMixins(SmartObject, [Disposable, Activatable])

总结

想了想,最后还是加个总结吧,第一次使用 TypeScript 其实是在 3.1 版本发行的时候,当初由于对强类型语言没有什么经验(当初只学了 C,也没有学习 Java 等),对当时的我来说,从 JavaScript 直接转向 TypeScript 是件非常困难的事,所以这个汇总笔记的时间跨度其实还是比较大的,中间经过不断修修补补,也算是对 TypeScript 有了一定的理解。到如今我的项目中也都是使用 TypeScript 进行开发,也算是不妄我这么长的笔记吧(笑)。

最后的最后,如今 TypeScript 已经成为了前端的一大趋势,掌握 TypeScript 也逐渐变成了前端开发者们的基本技能,花一点时间对 TypeScript 进行深入了解能够写出更加符合规范的代码,对项目的开发与维护都有着极大的作用。 如果你对 TypeScript 有着自己的看法或笔记存在的不完备的地方,欢迎在评论区留言。

更多内容

TypeScript 知识汇总(一)(3W 字长文)

TypeScript 知识汇总(二)(3W 字长文)

TypeScript 知识汇总(三)(3W 字长文)