# typescript 进阶

# 泛型

泛型可以说一个泛指类型,在程序时并不清楚它具体是哪一个类型,这段程序被调用时才会给它指定具体类型。泛型定义时常使用<T>,当然使用<A>也可以;在泛型调用就得给它指定具体的类型比如<string>

# 泛型接口

泛型接口<T>常跟在接口名后面,一般调用处要指定具体类型。泛型可以一次使用多个,不止是泛型接口,下面的比如泛型类泛型函数等都可以使用多个泛型。

interface ITask<T, P> {
  name: T;
  age: P;
}
const task: ITask<string, number> = { name: "Bob", age: 20 };
1
2
3
4
5

# 泛型类

泛型类<T>常跟在类名后面,一般调用处要指定具体类型。

class Data<T> {
  constructor(private _item: T) {}
  get item(): T {
    return this._item;
  }
}
const data: Data<number> = new Data<number>(0);
1
2
3
4
5
6
7

# 泛型函数

泛型函数<T>常跟在函数名后面,一般调用处要指定具体类型。含有可以给一个变量类型注解为泛型函数,一般有两种,一种是花括号和冒号搭配,另一种是不用花括号而使用箭头函数。

function fun<T>(v1: T, v2: T) {
  console.log(`${v1}+${v2}`);
}
fun<string>("web", "app"); // 调用时加上<string>具体的限制类型
const func: { <T>(v1: T, v2: T): void } = fun; // 定义一个泛型函数
const func1: <T>(v1: T, v2: T) => void = fun; // 定义一个泛型函数
1
2
3
4
5
6

# 泛型约束

泛型还可以和extends搭配使用,表示该泛型受到约束

interface Itest {
  name: string;
}
class Data<T extends Itest> {
  constructor(private _item: T) {}
  get item(): T {
    return this._item;
  }
}
const data = new Data({ name: "Bob" });
1
2
3
4
5
6
7
8
9
10

继承接口、类、基本类型都可以

// 使用基本类型的联合类型来约束泛型
class Data<T extends number | string> {
  constructor(private _item: T) {}
  get item(): T {
    return this._item;
  }
}
const data: Data<number | string> = new Data("Bob");
1
2
3
4
5
6
7
8

# 泛型中的 keyof

在程序中使用一个泛型,一般都是直接用它本身,但难免不会去操作它的属性,如果它属性的类型各不相同,在外部调用这段程序时 ts 可能就不会做具体的类型推断了。使用keyof可以分解一个复杂类型,获取它属性上的各种类型。

interface Task {
  num: number;
  name: string;
  desc: string;
}
class AcceptTask {
  constructor(private _task: Task) {}
  // 需要根据key去取_task的属性里,key对应的值,但是_task的属性的类型都不一样
  // 所以可以使用keyof对Task进行分解,key就可以拿到属性的各种可能的情况
  public notify<T extends keyof Task>(key: T): Task[T] {
    return this._task[key];
  }
}
const status = new AcceptTask({ num: 1001, name: "task1", desc: "xxx" });
status.notify("name");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 命名空间

# 命名空间声明

命名空间(对应在 es6 里叫“内部模块”)可以将一些类或者函数等包裹起来,使用模块化的方式来组织代码,减少类或函数等在全局环境中暴露的机会。

命名空间使用namespace来声明的,并且还可以和export搭配使用,可以让命名空间内的一部分内容让外部使用。

view.ts

namespace ViewSpace {
  class A {}
  class B {}
  export class C {
    // 将C暴露给命名空间之外,外部就可以使用TaskSpace.C了
    constructor() {
      new A();
      new B();
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11

# 命名空间使用

在一个 namespace 中调用另一个 namespace,并且还要放在页面中,那么先使用tsc进行编译生成对应的 js 文件,然后每一个 js 文件都要用一个<script>存放,最后就可以在<script>中使用了。

box.ts

///<reference path='./view.ts'>
namespace BoxSpace {
  export class D {
    constructor() {
      new ViewSpace.C();
    }
  }
}
1
2
3
4
5
6
7
8

h.html

<html>
  <head>
    <script src="./dist/view.js"></script>
    <script src="./dist/box.js"></script>
  </head>
  <body>
    <script>
      new BoxSpace.D();
    </script>
  </body>
  <html></html>
</html>
1
2
3
4
5
6
7
8
9
10
11
12

其实可以将编译文件生成在一起,先不考虑使用 webpack,ts 可以自己配置编译生成一个 js。打开 tsconfig.json,将 module 修改为amd,然后加上"outFile": "./dist/bundle.js"。这样就只引用 bundle.js 就可以了。

注意:像以上命名空间相互引用的写法,最好要加上“声明”,例如///<reference path='./view.ts'>

# 命名空间里再定义命名空间

///<reference path='./view.ts'>
namespace BoxSpace {
  export namespace ButtonSpace {
    // 命名空间里再定义命名空间
    export class E {}
  }
  export class D {
    constructor() {
      new ViewSpace.C();
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

# import

# 配合 require.js 使用

其实命名空间使用的不多,主要还是使用import方式。跟上一节样,先不考虑使用 webpack,还是使用"module": "amd""outFile": "./dist/bundle.js",但在页面使用时会有问题define识别不了,得引 require.js 进来(用 cdn 形式)。然后在脚本中使用 require 的方式实例化 D,代码示例如下:

view.ts

class A {}
class B {}
export class C {
  constructor() {
    new A();
    new B();
    console.log("C");
  }
}
1
2
3
4
5
6
7
8
9

box.ts

import { C } from "./view";
export default class D {
  constructor() {
    new C();
    console.log("D");
  }
}
1
2
3
4
5
6
7

h.html

<html>
  <head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.js"></script>
    <script src="./dist/bundle.js"></script>
  </head>
  <body>
    <script>
      require(["box"], function (box) {
        new box.default();
      });
    </script>
  </body>
  <html></html>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 使用 webpack 打包

上一节使用的是"module": "amd""outFile": "./dist/bundle.js"再配合 require.js 来使用的,可以看到还要额外引入 require.js 会比较麻烦。

可以使用 webpack 将 ts 项目编译打包,可以查看webpack的配置和一些用法。比如要将上面的改为 webpack 打包,配置会非常的麻烦,如下:

  1. 首先安装 webpack 相关的东西:npm install --save-dev webpack@5.1.0npm install --save-dev webpack-cli@3.3.12npm install --save-dev typescript ts-loadernpm install --save-dev webpack-mergenpm install --save-dev html-webpack-pluginnpm install --save-dev clean-webpack-pluginnpm install --save-dev html-webpack-pluginnpm install --save-dev uglifyjs-webpack-plugin

  2. 上面的 view.ts 无需调整,box.ts 需要在尾部加一个new D();,h.html 要改名为 index.html 并且里面的 script 脚本全部删除。

  3. 配置项使用生产环境与开发环境分离的模式,一下分别是 webpack.common.js、webpack.dev.js、webpack.prod.js、package.json(部分)。

    const path = require("path");
    const { CleanWebpackPlugin } = require("clean-webpack-plugin");
    const HtmlWebpackPlugin = require("html-webpack-plugin");
    
    module.exports = {
      entry: "./src/box.ts", // 入口
      output: {
        filename: "bundle.js", // 出口的文件名
        path: path.resolve(__dirname, "dist"), // 处理输出位置
      },
      module: {
        rules: [
          {
            // 使用tsloader
            test: /\.tsx?$/,
            use: "ts-loader",
            exclude: /node_modules/,
          },
        ],
      },
      resolve: {
        extensions: [".tsx", ".ts", ".js"],
      },
      plugins: [
        new CleanWebpackPlugin({
          // 打包生成dist前会自动删除dist下的文件,使用npm脚本“rm -rf ./dist”也可以
          cleanOnceBeforeBuildPatterns: ["dist"],
        }),
        new HtmlWebpackPlugin({
          // 用于生成入口html(存于dist),然后会自动引入打包好的bundle.js
          title: "Production",
          template: "./index.html", // 模板,webpack生成html时用到的模板,比如模板里要挂载vue。
        }),
      ],
    };
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    const path = require("path");
    const { merge } = require("webpack-merge");
    const common = require("./webpack.common.js");
    
    module.exports = merge(common, {
      devtool: "inline-source-map", // 开发环境所使用的SourceMap
      devServer: {
        // webpack-dev-server
        contentBase: path.resolve(__dirname, "dist"), // 设置服务器访问的基本目录
        host: "localhost", // 服务器的ip地址
        port: 8080, // 端口号
        open: true, // webpack运行服务器时自动打开页面
      },
    });
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const merge = require("webpack-merge");
    const UglifyJSPlugin = require("uglifyjs-webpack-plugin");
    const common = require("./webpack.common.js");
    
    module.exports = merge(common, {
      devtool: "source-map", // 用于生产的SourceMap
      plugins: [
        // 代码混淆压缩,当然你也可以考虑使用BabelMinifyWebpackPlugin或ClosureCompilerPlugin
        new UglifyJSPlugin({
          sourceMap: true, // 用于生产的SourceMap
        }),
        // 为项目设置环境变量,指定环境为生产环境,打包出的bundle会小很多。
        // 也可以在package.json里加上--env.NODE_ENV=production,但是得把env传到webpack.prod.js里来,将对象改为函数
        new webpack.DefinePlugin({
          "process.env.NODE_ENV": JSON.stringify("production"),
        }),
      ],
    });
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    "scripts": {
        "serve": "webpack-dev-server --open --config webpack.dev.js --mode development",
        "build:dev": "webpack --open --config webpack.dev.js --mode development",
        "build:pro": "webpack --config webpack.prod.js --mode production"
    },
    
    1
    2
    3
    4
    5

# 编写*.d.ts 文件

在 ts 项目中引入一个 js 库,如果这个 js 库没有对应的ts 声明文件,那在使用时就会报错,但是运行时是正确(前提是你真的写对了),也就说静态类型检测不通过但实际上运行没问题。在编写ts 声明文件时,要对这个 js 库非常熟悉才能知道要声明成函数或者变量或者对象。

# 声明全局函数

jQuery.js 这个库在前端经常使用,最经典的就是$,它经常用于接受一个字符串返回一个对象,返回的对象包含各种方法。声明文件*.d.ts常用 declare 来声明 js 中提供的方法、变量等。

index.html

<html>
  <head>
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.js"></script>
  </head>

  <body></body>
  <html></html>
</html>
1
2
3
4
5
6
7
8

done.ts

$(function () {
  $("body").html("<div>666</div>");
});
1
2
3

jquery.d.ts

// 声明一个全局函数$(),其入参也是一个函数
declare function $(param: () => void): void;
// 声明一个全局函数$(),入参是一个字符串,返回一个对象,这个对象有个html属性(方法)
declare function $(
  selector: string
): {
  html: (param: string) => void;
};
1
2
3
4
5
6
7
8

# 搭配使用 interface

使用 jquery 中的一些方法,基本都要返回一个页面元素,这个页面元素其实是个对象,比如html(),可以使用 interface 来约束这个元素对象。还可以对$本身使用 interface,并用 interface 约束它的多种传值方式。

// jquery对象(页面元素)
interface IjqueryDom {
  html: (param: string) => IjqueryDom;
}
// $的几种用法,使用interface来约束
interface Ijquery {
  (param: () => void): void; // 第一种传值方式:递函数(立即运行)
  (selector: string): IjqueryDom; // 第二种传值方式:传递字符串用于获取页面元素
}
// 声明一个全局变量$
declare var $: Ijquery;
1
2
3
4
5
6
7
8
9
10
11

# 声明对象和类

在使用 jquery 可能会遇到$.fn.init();这种对象的对象的方法,可以搭配 namespace 来对对象进行声明,namespace 中还能嵌套 namespace

done.ts

$(function () {
  $("body").html("<div>666</div>");
  $.fn.init();
});
1
2
3
4

jquery.d.ts

// 声明一个全局函数$(),其入参也是一个函数
declare function $(param: () => void): void;
// 声明一个全局函数$(),入参是一个字符串,返回一个对象,这个对象有个html属性(方法)
declare function $(selector: string): IjqueryDom;
// 声明一个对象,有个属性fn也是对象,fn有个属性是个方法
declare namespace $ {
  namespace fn {
    function init(): IjqueryDom;
  }
}

// jquery对象,有个html属性(方法)
interface IjqueryDom {
  html: (param: string) => IjqueryDom;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# es6 模块的声明文件

去掉 html 中的<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.js"></script>,然后局部安装npm install jquery --save

es6 模块方式使用 jquery,那就是import $ from 'jquery';,但是这个jquery会报错“无法找到模块“jquery”的声明文件”,那么我们就需要对jquery这个 es6 模块进行声明文件的编写。

done.ts

import $ from "jquery";
$(function () {
  $("body").html("<div>666</div>");
  $.fn.init();
});
1
2
3
4
5

jquery.d.ts

// 声明一个es6的模块
declare module "jquery" {
  // jquery对象
  interface IjqueryDom {
    html: (param: string) => IjqueryDom;
  }
  // 这个模块中$是个方法
  function $(param: () => void): void;
  // 这个模块中$是个方法,重载
  function $(selector: string): IjqueryDom;
  // 这个模块中$还是个对象
  namespace $ {
    namespace fn {
      function init(): IjqueryDom;
    }
  }
  // 最后一定记得导出,而且都是export = xxx的形式
  export = $;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 装饰器

学习装饰器,必须清楚 es5 的构造函数与 es6 的的联系。

装饰器(Decorators)可以使用元编程的语法对类以及成员来进行监视、修改或替换。其实就是在类定义的时候,将类传到事先准备好的函数里,经过函数的运作,类就获得或者按照要求修改了一些东西。编译时动态修改了程序,这种方式叫做元编程。

装饰器在 ts 中是一项实验性特性,要使用它得先打开它的相关配置。打开 tsconfig.json 文件,加上"experimentalDecorators": true"emitDecoratorMetadata": true

装饰器的使用这样的,@expression,其中 expression 最后的求值结果必须是一个函数。如果对类进行了多个装饰器修饰,那么首先依次从上到下对装饰器表达式求值,然后求值的结果会被当作函数并从下到上依次调用。

function f() {
  console.log(1);
  return function <T>(constructor: T) {
    console.log(2);
  };
}
function g() {
  console.log(3);
  return function <T>(constructor: T) {
    console.log(4);
  };
}

@f()
@g()
class C {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

上面就是装饰器的例子,打印顺序是1 3 4 2,f()和 g()返回的函数会为 C 这个类作修饰。

# 对类装饰

对类使用装饰器,可以用来监视或修改类定义。在装饰器表达式里,类以构造函数形式作为其唯一的参数。装饰器表达式不返回值时,一般就是简单地往原型上添加内容;而如果是返回一个值,该值就是个匿名类并且会继承以前的类,也就是说匿名类会覆盖原类的同名属性和方法(包括 constructor 方法)。

在 ts 中构造函数形式可以是new(...args: any[]) => {},也可以是{ new(...args: any[]): {} },当然最简单的是Function。具体在装饰器中使用构造函数形时,传入的具体类型各不相同,那么有必要使用泛型,也就是T extends new(...args: any[]) => {}

// constructor是个构造函数(其实就是Greeter),但不清楚具体类型得用泛型(不然ts静态检查过不了)
function classDecorator<T extends new (...args: any[]) => {}>(constructor: T) {
  // 返回这个class会以继承的方式覆盖原类的方法和属性(主要影响同名的,不同名的就相当于新加内容)
  return class extends constructor {
    public newProperty = "new property";
    public hello = "override";
  };
}

@classDecorator
class Greeter {
  public property = "property";
  public hello: string;
  constructor(m: string) {
    this.hello = m;
  }
}
console.log(new Greeter("world"));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

上面输出的结果{ property: 'property', hello: 'override', newProperty: 'new property' },可能你会意外 hello 的值为什么是override而不是world,其实你需要补充一下 es6 的知识了。在继承的派生类里没有明写constructor()方法时,其实是隐藏调用了。比如上面的代码其实是下面这样一个形式:

// 部分代码,其他一样的省略
return class extends constructor {
  public newProperty = "new property";
  public hello = "override";
  constructor(...args: any[]) {
    super(...args);
  }
};
1
2
3
4
5
6
7
8

你再根据这段代码加上前面所说的装饰器修饰类时的规则就能明白 hello 为什么是override,首先public hello = "override"覆盖原有的public hello: string,然后constructor(...args: any[])覆盖原有的constructor(m: string),也就是说new Greeter("world")传进来的world是不会生效的。而如果改成下面的都就会生效(自己加上this.hello = args[0];):

// 部分代码,其他一样的省略
return class extends constructor {
  public newProperty = "new property";
  public hello = "override";
  constructor(...args: any[]) {
    super(...args);
    this.hello = args[0];
  }
};
1
2
3
4
5
6
7
8
9

# 对方法装饰

如果不需要对整个类进行装饰,可以考虑只对类的方法进行装饰。我们知道类里的方法,其实就是构造函数的原型上的方法(不理解的可以去看 es6 的类),那么对类的方法进行装饰,其实就是对原型对象的属性进行操作,是不是立马想到了Object.defineProperty,这个方法是操作目标对象的属性以及属性的特性。对类的方法进行装饰也是一样的,请看:

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }

  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}
// 对类的方法设置为不可枚举的
function enumerable(value: boolean) {
  // target是原型对象,propertyKey是属性名,descriptor是特性,跟Object.defineProperty类似
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 对访问器装饰

只需要私有属性的访问器进行装饰,写法与方法装饰器类似,只是descriptor不一样而已。但是要注意不允许同时装饰一个成员的 get 和 set 访问器,因为descriptor同时有这两个访问器。

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;
  }
}
function configurable(value: boolean) {
  // target是原型对象,propertyKey是属性名,descriptor是特性,跟Object.defineProperty类似
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.configurable = value;
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 对属性装饰

我们知道普通方法和访问器都可以在原型对象上进行操作,但是类中的属性是属于实例化对象的(需要搞清楚 es6 的类),那么就不能像装饰方法一样使用原型对象上的属性的descriptor,而是自定义一个descriptor修改好后然后返回它,从而达到修改属性特性的目的。

class Test {
  @propDescriptor(false)
  property: string = "name";
}
function propDescriptor(value: boolean) {
  // target是原型对象,propertyKey是属性名。注意,在target上不能操作实例属性
  return function (target: any, propertyKey: string): any {
    const descriptor: PropertyDescriptor = { writable: value };
    return descriptor;
  };
}
const test = new Test();
test.property = "test"; // 报错,不能对只读的属性进行修改。这样就达到了目的
1
2
3
4
5
6
7
8
9
10
11
12
13

# 对参数装饰

对类的构造方法或普通方法里的参数进行装饰,装饰器函数接收三个值,第一个是构造函数(方法是静态的)或者原型(普通方法),第二个是参数所在的方法名、第三个就是参数在参数列表中位置的索引。

class Test {
  func(name: string, @paramDescriptor(false) age: number) {}
}
function paramDescriptor(value: boolean) {
  // target是构造函数(方法是静态的)或者原型(普通方法),propertyKey是参数所在的方法名,descriptor是参数在参数列表中位置的索引
  return function (target: any, method: string, index: number): any {
    console.log("index", index);
  };
}
const test = new Test();
1
2
3
4
5
6
7
8
9
10

# 装饰器的小例子

const info: any = undefined;
function catchDescriptor(msg: string) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const fun = descriptor.value;
    // 用新函数包裹旧函数,并且统一做异常捕获处理
    descriptor.value = function () {
      try {
        fun();
      } catch (e) {
        console.log(msg);
      }
    };
  };
}
class Task {
  @catchDescriptor("info.name不存在")
  getTaskName() {
    return info.name;
  }
  @catchDescriptor("info.id不存在")
  getTaskId() {
    return info.id;
  }
}
const task = new Task();
task.getTaskId();
task.getTaskName();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27