Written by SJTU-XHW

Reference: MDN Doc && TypeScript Doc

本人学识有限,笔记难免有错,恳请读者能够批评指正,本人将不胜感激!


Chapter 1. 与 JS 比较:类型声明 与 作用域

TypeScript 是个静态类型语言,变量类型/对象成员/函数签名 在定义后就不能更改!

  • 定义变量时,请使用规范的类型声明定义方式:

    1
    2
    3
    4
    5
    6
    7
    /* Variable */
    var foo: number;
    var bar: string = "Hello, TypeScript!";
    /* Function */
    function toString(num: number): string {
    return String(num);
    }
  • 天大的福音:只声明变量、不赋值就使用会报错!这下不用担心讨厌的 undefined

    1
    2
    let x: number;
    console.log(x); // TypeError

    而且开启了编译选项 strictNullChecks 后,undefined 也是个独立的类型,不能赋给除了 undefined 类型以外的其他类型!

  • 不允许给变量中途赋予不同类型的值;

此外,比 JavaScript 更加暖心的操作是:TypeScript 全面支持 块级作用域定义在块级作用域内的变量、类型等等不再能被外界读到了!

Chapter 2. TypeScript 编译

TypeScript 不提供运行环境,全部交给 JavaScript,自己只提供转换为 JavaScript 的编译器 tsc,甚至这个编译器也是 JavaScript 的一个库,可以用 npm 安装。

编译时,会将类型声明和类型相关的代码全部删除,只留下能运行的 JavaScript 代码,并且不会改变 JavaScript 的运行结果。

因此,TypeScript 的类型检查只是编译时的类型检查,而不是运行时的类型检查。一旦代码编译为 JavaScript,运行时就不再检查类型了。

TypeScript 的编译器 tsc 的简单使用如下:

  • 安装:npm install -g typescript

  • 编译单个 TS 文件,或多个没有层次依赖关系的 TS 文件:tsc a.ts[, b.ts, ...]

    分别生成 a.js, b.js, ...

  • --outFile 编译为一个指定的 JavaScript:tsc a.ts[, b.ts, ...] --outFile XXX.js

  • --outDir 指定输出目录;

  • --target 指定编译 JavaScript 标准(建议 es2015 及以上);

如果项目更复杂一点,就需要 tsconfig.json 管理编译过程(考虑 Java 的 Gradle 和 C++ 的 CMakeLists)。其简单结构如下:

1
2
3
4
5
6
{
"files": ["file1.ts", "file2.ts"],
"compilerOptions": {
"outFile": "dist/app.js"
}
}

这时候直接运行 tsc 等价于原来的 tsc file1.ts file2.ts --outFile dist/app.js

更复杂的使用以后介绍。

Chapter 3. TypeScript 类型系统

3.1 新的辅助类型

和 JavaScript 不一样的是,TypeScript 提供了 3 种特殊类型:anyunknownnever,它们是为了配合静态类型系统更好的发挥功能而创造出来的。

3.1.1 Any 类型

当给变量声明 any 类型时,TypeScript 会关闭这个变量的类型检查,这个变量单独变为 “动态类型”

1
2
3
4
5
6
7
let x: any = "hello";

x(1); // OK
x.foo = 100; // OK
x = 1; // OK
x = "foo"; // OK
x = true; // OK

如你所见,肯定要避免使用 any 类型,不然你为什么不用 JavaScript?

频繁使用 any 会导致 TypeScript 丧失静态类型的优势,具体来说有几个坏处:

  • 干扰类型推断和编译前错误检查:

    1
    2
    3
    4
    5
    function add(x: any, y: any) {
    return x + y;
    }

    add(1, [1, 2, 3]); // OK
  • 静态类型污染:

    1
    2
    3
    4
    5
    6
    7
    let x: any = "hello";
    let y: number;

    y = x; // 不报错

    y * 123; // 不报错
    y.toFixed(); // 不报错

即使你不使用 any 类型,也要注意编译器可能自动推断 any 类型,这通常是因为开发者不良开发习惯所导致的 —— 声明、定义变量 / 定义函数 时,不进行类型标注

1
2
3
4
5
6
var x;
let y;

function add(x, y) {
return x + y;
}

以上变量、函数声明全部会被编译器推断为 any,从而干扰类型检查!

总之,对于 TypeScript 的 any 类型,请敬而远之!自己不写 any,也别让编译器推断出 any。这是编译器实在没法进行类型检查时候的下下策。

还是那句话,不然你为什么不直接用 JavaScript?

3.1.2 Unknown 类型

为了防止 any 的类型污染等问题,人们定义了一种比 any 类型严格的辅助类型 unknown,规则如下:

  • 允许给 unknown 类型变量赋予任何类型的值;
  • 不允许将 unknown 类型变量赋予其他确定类型(即除了 anyunknown)的值
  • 不允许使用 unknown 类型的方法、属性
  • 只能对 unknown 类型进行有限的运算:逻辑运算、判断相等运算、typeofinstanceof,其他运算均不可以;

违反以上规则,编译器会抛出错误。

但是 unknown 允许类型缩窄(比如一开始没法确定这个数据的类型,但是后面要处理时确定了,这种情况就不需要使用 any 了),如下:

1
2
3
4
5
let s: unknown = "hello";

if (typeof s === "string") {
s.length; // 正确
}

在作用域中,s 类型被缩窄为 string,变成了确定类型,就可以使用确定类型的一切方法和属性了。

总之,某些逻辑下,实在无法确定类型,应该优先使用 unknown 类型,避免 any 出现

3.1.3 Never 类型

从集合论的角度,人们定义了这个类型,含义是 空类型,可以赋给任何类型的变量。

你可能会好奇,那被赋予 never 类型的变量内部的值怎么办?

问出这个问题说明你还没有明白 never 的使用场景:在函数中,它标识控制流永远无法到达函数返回的时候;在变量中,它标识永远都不会用到该变量

例如:

1
2
3
function f(): never {
console.log("Hello, TypeScript!"); // 编译器会报错
}

就是错误的,因为函数能够执行到最后,返回的是 undefined 类型的对象,而不是空。

如果你只是想标识函数不返回值,请使用 undefined / void 作为返回类型:

1
2
3
function f(): void {
console.log("Hello, TypeScript!"); // Correct
}

这样才是正确使用方式:

1
2
3
4
function f(): never {
throw new Error("Test");
/* Control Never Reaches Here! */
}

3.2 新的基本类型 和 引用类型

复习一下 JavaScript 中有几种基本类型和引用类型:

booleanstringnumberbigintsymbolobject(狭义对象类型也是个基本类型,和广义对象类型 Object 不同,仅包含所有引用类型,如 Array 等内置引用类型)、undefinednull

补充 JavaScript 不常用的类型使用方法:

  • bigint在 JavaScript 中,使用 bigint 需要数字尾缀 n,例如 123n

3.2.1 TypeScript 的对象

这里得说明一下,对象(引用类型)的定义和 JavaScript 显著不同

因为 TypeScript 作为一种静态类型语言,不允许定以后更改数据成员。因此你这么写,编译器会报错:

1
2
3
4
5
var o1: Object = { foo: 0 };
var o2: object = { foo: 0 };

o1.foo; // TypeError
o2.foo; // TypeError

因为你定义的 o1/o2 类型是原生的 object/Object,都只有原生的属性和方法,foo 属性在赋值时就被抛弃了。

要想自定义对象的属性和方法,要么定义对象类型,要么定义 class(TypeScript 的类,后面讲)

3.2.2 TypeScript 值类型

TypeScript 规定,单个值也是一种类型,称为“值类型”,不常用,它常常用在联合 / 交叉类型中。

1
var name: "Hello";    // 只能被赋予 "Hello",其他内容都会报错。

3.2.3 TypeScript 常量类型

所有使用 const 关键字修饰的 TypeScript 的变量都是常量,它们不能被更改。

你可以把这个变量理解为 以初次赋予的值为值类型的变量

3.2.4 TypeScript 对象类型

3.2.4.1 使用方法

而 “对象类型” 则可以很方便地(无需定义接口 interface、类型 class)定义对象并指定临时类型,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var obj: {
x: number;
y: string;
testFunc1(a: number, b: boolean): string;
testFunc2: (c: string, d: number) => boolean; // 箭头函数式声明
} = {
x: 1,
y: "Hello",
testFunc1(a, b) {
return (b ? a.toString() : "");
},
testFunc2 = function(c, d) {
return c === d.parseInt();
}
}

3.2.4.2 对象类型的特性

一旦声明某个对象的对象类型后,赋值一定要分毫不差地按照类型来,否则编译器不会接受,即不允许以下行为:

  • 增添对象类型中不存在的字段、删除对象类型中已有的字段;
  • 定义+赋值时,少给一个成员赋值,或者给不存在的成员赋值;

但是它也允许一些特性以在合理的范围内支持灵活性:

  • 允许数据域添加 可选修饰符 ?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    type User = {
    firstName: string;
    lastName?: string;
    };
    // 等同于
    type User = {
    firstName: string;
    lastName: string | undefined; // 注意进行类型缩窄
    };
  • 允许数据域添加 只读修饰符 readonly

    1
    2
    3
    4
    5
    6
    7
    8
    type Point = {
    readonly x: number;
    readonly y: number;
    readonly resident: { // 只读数据域如果是对象,则允许更改数据域,不允许更改对象引用
    name: string;
    id: number;
    };
    };

    只读数据域和普通数据域间传递,与 C 的 const 修饰类型和普通类型的方法一样,不再赘述。

  • 允许 数据域索引

    1
    2
    3
    type TestObjT = {
    [property: string]: string;
    };

    TestObjT 的含义是,不管这个对象有多少属性,只要属性名为字符串,且属性值也是字符串,就符合这个类型声明,比如可以这么定义:

    1
    2
    3
    4
    5
    var tobj: TestObjT = {
    foo: "1",
    bar: "2",
    baz: "3"
    };

    此外,对象类型的数据域索引中 property 类型允许 stringnumbersymbol

    不过这个特性不建议使用,因为对类型的约束过于宽泛。

3.2.4.3 解构赋值 与 对象类型

和 python 的解构语法类似,

1
2
fig, axe = matplotlib.pyplot.subplots()
[a, _] = curStr.split('/')

TypeScript 的解构要求指定 对象类型,其实有两种语法:

1
2
3
4
// 将 obj **按序**解构到指定的变量,必须声明成对象的对象类型(type of obj)
<qualifiers> {var1, var2, ...}: <type of obj> = obj;
// 自定义解构顺序
<qualifiers> {objProp1: var1, objProp2: var2, ...}: <type of obj> = obj;

第一种语法很好理解,比如:

1
2
3
4
5
6
7
8
9
const {
id,
name,
price,
}: {
id: string;
name: string;
price: number;
} = product;

但我们发现 “自定义解构顺序” 的写法非常迷惑,因为大括号内的冒号不再指类型,而是指数据域的映射关系,例如很多新手会这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Shape = {
width: number;
height: number;
};

var obj: {
shape: Shape;
xpos: number;
} = {
shape: {width: 1, height: 1},
xpos: 0.0
};

var {shape: Shape, xpos: number} = obj;

最后一句话的含义是什么?如果你认为这是第一种语法,按序把 objshapexpos 属性赋给了外部的 shapexpos 变量,那么就大错特错了!

因为要牢记:解构语法中的冒号不再作为类型尾缀,而是作为数据域映射的含义,在这里,解构语法一旦出现冒号,就一定是第二条语法。

上面的最后一条语句的真正含义是,objshape 数据域赋给外围以 “Shape” 为变量名的变量,它的类型名也是 Shape(number 同理)

3.2.4.4 对象类型的结构类型原则(Structual Typing Principle)

只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structual typing)。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
var A: {
x: number;
} = {
x: 1
};
var B: {
x: number;
y: number;
} = {
x: 2,
y: 3
};

对象 B 满足 A 的结构特征,说明 B 的对象类型 兼容 A 的对象类型,因此 B 可以直接赋给 A,但反过来不行。

3.2.4.5 对象类型的最小可选属性规则

如果一个对象类型所有属性都由 可选修饰符 修饰,按语义理解的话,所有对象都会符合这个定义。

TypeScript 为了防止类型模糊,规定:

当一个对象类型所有属性都由 可选修饰符 修饰,那么要定义一个该类型的对象,至少要含有一个可选属性

1
2
3
4
5
6
7
8
9
10
11
12
13
type Options = {
a?: number;
b?: number;
c?: number;
};

const obj: Options = {
d: 123, // TypeError
};

const obj2: Options = {
a: 1; // Correct
}

3.2.5 TypeScript 的联合类型 与 交叉类型

联合类型 var x: A | B 表示 x 既可以是 A 类型,又可以是 B 类型;

这其实是暂时无法判断类型的最正确的处理方法。

它仅允许使用二者类型共同的属性或方法,否则编译器会抛出错误。必须使用 缩窄类型 才可以针对性地使用属性和方法。

交叉类型 var y: A & B 表示 x 必须既是 A 类型,又是 B 类型,最常见用作对象属性的临时合成

1
2
3
let x: number & string;    // 编译器认为无法取得,是 never 类型

let obj: { foo: string } & { bar: string }; // 具有 foo, bar 两个数据成员的对象类型

3.2.6 TypeScript 类型别名

TypeScript 中的关键字 type,现在完全可以理解为 C 的 typedef,就是定义一个类型(值类型、对象类型、联合类型、交叉类型,等等)的别名,例如:

1
2
3
4
5
6
7
8
9
10
11
12
type TA = {
x: number;
y: string;
testFunc1(a: number, b: boolean): string;
testFunc2: (c: string, d: number) => boolean;
};

type TB = TA & { z: boolean };

const obj: TB = {
/* Definitions */
};

注意:

  • 别名不允许重名!

  • 别名的有效范围是当前的块级作用域(例如定义在大括号内、函数内,在外面就没有效用了);

  • 别名支持使用表达式定义,例如:

    1
    2
    type World = "world";
    type Greeting = `hello ${World}`;
  • 别名是类型相关代码,因此在编译为 JavaScript 后会被完全清除。

3.2.7 TypeScript typeof 运算符

JavaScript 的 typeof 返回的是对应类型的字符串;

实际上,在 TypeScript 中保留了 JavaScript 的 typeof,又增添了一个新的 typeof 运算符,返回的是 TypeScript 类型(比如基本类型、值类型、对象类型等等),由于是类型,因此这种运算符不能用于值运算,只能放在类型推导中。

举个例子:

1
2
3
4
5
6
let a = 1;
let b: typeof a;

if (typeof a === "number") {
b = a;
}

第一个 typeof 是 TypeScript 新增的返回类型的 typeof,只能用于类型推导,参数不能是表达式(前面的类型别名可以)。在编译后会被完全清除;

第二个 typeof 是 JavaScript 原生的 typeof,返回的是字符串,可以用于值运算。

3.3 TypeScript 中的数组:基本类型和内置引用类型

3.3.1 定义和使用

这里和 JavaScript 动态类型语言不像,而是更接近 Java 的静态类型。

TypeScript 数组所有成员的类型必须相同,但是成员数量是不确定的不论是基本类型数组 type[] 还是 内置类型 Array<Type>

1
var arr: number[] = [1, 2, 3];

内置类型 Array 被改成了更像 Java 的泛型,方法和 JavaScript 原生都是一样的:

1
var arr: Array<string> = new Array(1, 2, 3);

和 JavaScript 一样,可以通过修改 length 属性直接增减成员。

由于长度不定,因此 TypeScript 的数组越界也不会报错,只会返回 undefined

Array 内置类型和 JavaScript 原生用法几乎一致,方法不再赘述。

3.3.2 只读数组

不仅是 JavaScript,大多数语言都认为数组及其他形式的对象都是 可变数据类型,因此把数组本身定义为 const 是不会阻止数组被修改的。我们以 C++ 为例:

1
2
3
4
5
6
7
8
9
10
11
12
class Test {
public:
Test(size_t s): arrSize(s), arr(new int[s] {0}) {}
~Test() {delete[] arr;}
private:
size_t arrSize;
int* arr;

void setter(int idx, int val) const {
arr[idx] = val; // OK!
}
};

setter 函数使用 const 修饰,也没能阻止数组被修改。

为了限制可变数据类型的修改问题,C++ 采用的是常量指针的方法,声明不允许修改:

1
2
/* 上面的 arr 声明改成: */
const int const* arr;

这样数组就不再可以被修改,上面的 setter 也会让编译器报错。

那么 TypeScript 是怎么做的?答案是加 readonly 关键字:

1
const arr: readonly number[] = [0, 1];

这样无论是使用删除、修改、新增数组成员的方法都会报错。我们可以将 readonly number[]number[] 视为两个类型。

为此,TypeScript 设计了泛型 Readonly<T>ReadonlyArray<T>,效果类似。

3.3.3 多维数组

和 Java 定义很像,例如:

1
2
3
4
var multi: number[][] = [
[1, 2, 3],
[23, 24, 25]
];

Java 写法:

1
2
3
4
5
6
int[][] multi = {
[1, 2, 3],
[23, 24, 25]
};
// Or
int[][] multi = new int[2][3];

3.4 TypeScript 元组

和 Python 的思想一样,允许在一个组内放入不同类型的数据。

一般情况下,元组的长度是确定的、每个元素的类型必须明确指定,因此越界访问、不按定义的类型来赋值都会报错

其类型定义的方法和数组不一样:

1
2
const a: number[] = [1, 2, 3];    // 数组
const b: [number, string, boolean] = [1, "2", true]; // 元组

有时候不能省略类型定义,特别是只有一个元素的时候,因为可能会被编译器误判为数组:

1
2
var a = [1];    // 编译器认为是数组
var b: [number] = [1];

不过 “元组长度是确定的”,而不是 “固定的”,是因为有 2 个例外:

  • 可选类型修饰符 ?只能位于元组类型列表尾部):

    1
    let a: [number, number?] = [1];
  • 扩展运算符(又称 REST 运算符),表示不限制数量的同类元素,可以不位于类型列表尾部,在一个类型声明中只能使用一次:

    1
    2
    3
    type t1 = [string, number, ...boolean[]];    // 注意,这不是数组。数组请用 ...<T>[][]
    type t2 = [string, ...boolean[], number];
    type t3 = [...boolean[], string, number];

如果没有使用扩展运算符,那么元组的长度就能确定,可以使用 length 进行长度推断

如果使用了扩展运算符,length 推断就会被看作数组。

3.5 TypeScript 函数

3.5.1 定义与使用

定义方法和 JavaScript 一样,但是类型声明是有讲究的。

普通定义方法这么加类型声明:

1
function hello(txt: string): void { /* ... */ }

赋值函数表达式这么加:

1
2
3
var hello: (txt: string) => void = function(txt) { /* ... */ };
// Or
var hello = function(txt: string): void { /* ... */ };

技巧:想要套用其他函数的签名,就用 typeof 运算符,如下

1
2
3
4
5
6
7
function add(x: number, y: number) {
return x + y;
}

const myAdd: typeof add = function (x, y) {
return x + y;
};

箭头函数这么加:

1
var hello = (txt: string): void => { /* ... */ };

由于函数也是一个对象,所以我们可以这么自定义函数的类型(在想要给函数加属性的时候有用):

1
2
3
4
5
6
7
8
9
10
11
12
function f(x: number, y: number): number {
return x + y;
};
f.version = "1.0";

// {
// (参数列表): 返回值
// }
let add: {
(x: number, y: number): number;
version: string;
} = f;

3.5.2 可选参数 与 默认值

TypeScript 在静态类型的同时允许参数可选、默认值(它们肯定不能同时使用):

1
2
3
4
5
6
7
8
9
10
11
12
let myFunc: (a: number, b?: number) => number;

myFunc = function (x, y) {
if (y === undefined) { // 用到可选参数时请判断
return x;
}
return x + y;
};

function createPoint(x: number = 0, y: number = 0): [number, number] {
return [x, y];
}

注意,传入 undefined 参数就能触发默认值(如果有的话)。

3.5.3 参数解构 (解包)

JavaScript 原生支持参数解构。在 TypeScript 中则需要声明类型才能使用。

要解构的参数大多数情况下,要么是对象,要么是数组 / 元组,例如:

1
2
3
4
5
6
7
function f([x, y]: [number, number]) {
// ...
}

function s({ a, b, c }: { a: number; b: number; c: number }) {
// ...
}

使用方法请类比 “对象类型” 一节的解构赋值,也要注意两类语法的区别。

3.5.4 剩余参数 (args)

使用之前在元组一节提到的 REST 运算符即可:

1
2
3
4
5
6
7
8
9
// rest 参数被包装成数组
function joinNumbers(...nums: number[]) {
// ...
}

// rest 参数被包装成元组
function f(...args: [boolean, number?]) {
// ...
}

如果 rest 参数被包装成元组,其中的元素也可以是可选参数。

3.5.5 只读参数

和 C++ 的 const 修饰参数一模一样。不多赘述:

1
2
3
4
function arraySum(arr: readonly number[]) {
// ...
arr[0] = 0; // 报错
}

3.5.6 函数重载

TypeScript 因为有类型,所以支持了 JavaScript 所不支持的 重载。

使用方法很简单,要注意的点和其他静态类型语言一样(避免签名模糊等问题)。

不过 TypeScript 中,如果能用联合类型避免重载,就用联合类型。

3.6 TypeScript 的类

TypeScript 作为真正的静态类型语言,和 JavaScript 相比,最大的差异是引入了真正的开发者可定义的类。

3.6.1 定义

下面的示例概括了 TypeScript 类基本的使用方法:

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
class TestClass {
x: number;
y: number;
z: string = "abc"; // 允许类内初始化
p!: number; // !修饰符告诉编译器该类型不为空,可以不在类内、构造函数内初始化
readonly q: number; // 和 Java 的 const 一样,仅允许初始化一次

// 类的构造函数名只能是 construtor,允许可选、默认参数
constructor(_x: number, _y: number, _q: number = 0) {
// 类内的 this 对象就和其他面向对象的静态语言一样含义。具体含义见章末
this.x = _x;
this.y = _y;
this.q = _q;
}

// 类中的方法不需要 function 修饰。但方法一定需要定义在类内
// 如果要定义在类外,就是在初始化为对象后再进行赋值了
add(a: number): number {
return a + this.x + this.y;
}
// 类中的构造函数、其他方法都允许重载
add(r: number, s: number, ...args: number[]): number {
const init: number = 0;
return this.x + this.y + r + s + args.reduce((acc, cur) => acc + cur, init);
}
}

3.6.2 类的可见性修饰符

TypeScript 的类更像 C++ 的结构体,不加可见性修饰符,默认 public

使用方法同其他面向对象的静态类型语言,略。

3.6.3 类的访问器

如果你学过 Python,那么恭喜,TypeScript 的访问器 setget 可以完全按照 Python 的装饰器 @setter@getter 理解。

定义方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class T {
private _name!: string;

// 访问器前加入 get / set 关键字
// 访问器的名字必须与要定义的属性名相同
get name(): string {
return this._name;
}

set name(value: string | number): void {
this._name = String(value);
}
}

// 类外访问,直接当作普通属性使用,但会调用 get 实时计算
var test: T = new T;
test.name = "Hello"; // 调用了 set 访问器
console.log(test.name); // 调用了 get 访问器

根据它们的用途,可以很显然地知道:

  • get 访问器不能有参数;
  • 如果一个属性不存在 set 访问器,那么这个参数只读,等价于被 readonly 修饰;

3.6.4 类的静态成员

和 Java、C++ 一样,使用 static 关键字修饰静态变量。

但是!和其他语言不太一样,TypeScript 的静态成员(属性和方法)不能由实例调用,只能由类名调用:

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass {
static x = 0;
static printX() {
console.log(MyClass.x);
}
}

var t: MyClass = new MyClass;
t.printX(); // TypeError
t.x; // TypeError
MyClass.printX(); // Correct
MyClass.x; // Correct

3.6.5 类的继承

和 Java 一样,TypeScript 使用 extend 关键字进行类的继承。

还是和 Java 一样,TypeScript 也有 super 关键字,可以 用来调用父类构造函数、调用父类中已被重写的方法等等

类继承时,不同可见性修饰符下对应数据域的继承原则与 Java、C++ 类似,但是有差别:

  • 父类 protected 的数据成员,子类 可以转为 public,但不能转为 private

  • 子类可以定义与父类同名不同类型的属性,但是如果希望它们的赋值关联,则需要 declare 关键字修饰;

3.6.6 抽象类

和 C++ / Java 一样,TypeScript 中的抽象类关键字也是 abstract、也不允许实例化、不允许 private 修饰、抽象成员不允许有实现。

同时,TypeScript 允许使用 abstract 修饰抽象类的数据成员(Java 和 C++ 做不到)、成员函数,强制要求子类(非抽象类)实现。

值得注意的是,一个类最多只能继承于一个抽象类(再多就应该用接口 interface 了)

3.6.7 FAQ: 类中的 this 在 TypeScript 里究竟是什么

我们知道,在 JavaScript 中,this 含义丰富:

  • 在方法中,this 表示该方法所属的对象;

  • 如果单独使用,this 表示全局对象;

  • 在函数中,this 表示全局对象(严格模式下为 undefined);

那么在 TypeScript 的类中,含义就和其他面向对象语言类似,是指 “该方法所在类的当前对象”。

实际上,这里还有几个问题没有解决:

  1. 在 JavaScript 中允许定义一个原型,充当面向对象编程的工具。它允许将一个对象的方法赋给另一个变量,赋予后,this 将跟随新的变量脱离原来的作用域。在 TypeScript 中,这种行为是怎样的?
  2. 如果在 JavaScript 的原型中使用闭包/箭头函数,那么在其中的 this 将不再指代当前对象,除非手动传递 this 的值。在 TypeScript 中的行为是怎样的?

针对第一个问题,TypeScript 为了防止改变函数语义(本来的 this 是类中对象,如果将函数赋给其他变量后 this 就成为了新变量作用域中的 this),允许类中方法第一参数声明 this 类型(就像 Python 的 self

1
2
3
4
5
6
7
class Test {
private tt = "Test";

test(this: Test): string {
return this.tt;
}
}

无论在哪里,只要 typescript 检测到第一个参数是 this,就会自动将当前环境 this 填充进去,并且不会在 JavaScript 中展现出来。

这样,如果赋给其他变量后,一旦涉及改变了 this 的函数调用,编译器会指出错误:

1
2
3
var b = Test.test;

b.(); // TypeError, 因为此时的 this 是全局对象

该参数可以省略(但省略后会回到 JavaScript 的行为)。

当然,你可以在构造函数中,使用 JavaScript 原生方法 bind 将方法与类的 this 环境绑定,这样无论怎么赋值,都是那个对象对应的方法了 —— 这也是最稳妥的做法。

对第二个问题,TypeScript 也需要担心语义问题,因此如果你打开了 noImplicitThis 编译选项,那么 TypeScript 编译器会直接报错。否则其行为和 JavaScript 一致。


最后补充一个 TypeScript 的小特性:

TypeScript 中的类能作为类型使用,例如:

1
2
3
4
5
6
7
8
class Box {
contents: string = "";

set(value: string): this {
this.contents = value;
return this;
}
}

3.7 TypeScript 的接口

3.7.1 定义和使用

TypeScript 的接口和 Java 非常类似:接口和抽象类一样抽象,方法全是抽象方法(不能有实现)、可省略所有的 abstract 关键字。但是属性不是常量属性(只读)而是抽象属性(不允许赋值),因为在 TypeScript 中 abstract 可以修饰数据成员。

如下由于接口算是个抽象类,因此接口间可以继承,和 Java 的接口继承一样,要注意冲突问题。

此外,接口还可以继承于 type(类型别名,它和接口的区别是什么?接下来再讨论)。

甚至接口还能继承于普通的 class。此时 class 中所有的属性、方法全部保持类型,变成对应的抽象属性、抽象方法。这里我们可以知道,被接口继承的类不能有 private/protected 修饰的成员,因为这样会导致 “无法实现的抽象成员” 的出现

3.7.2 接口合并

这只是个特性,并不希望你为了使用它而使用,只是在必须的时候才使用。因为这样会降低你的代码可读性。

  • 情况 1:如果你重复定义了接口,那么这两次定义的内容会合并:

    • 同名函数 -> 重载,同名属性 -> 联合类型,不同名的直接并列;
    • 如果有不能联合/重载的部分,编译器会抛出错误;
  • 情况 2:如果你对某几个不同名的接口使用了类型联合运算符,如下:

    1
    declare const s: Inter1 | Inter2;

    这就相当于显示将不同名的接口合并为一个接口 s,合并规则和同名接口合并相同;

3.7.3 接口 (Interface) 和类型别名 (type) 的异同

相同点:都能创建一个类型、定义方式极其类似。

不同点:

  • type 可以定义非对象类型,而 interface 只能定义对象类型;
  • interface 可以继承、合并,而 type 不行,重复定义同名 type 会报错;

  • type 内部不能使用 this 指代当前对象,interface 则可以;

总结:除去一些复杂的类型运算,其他情况优先使用 interface

3.8 TypeScript 的泛型

TypeScript 的泛型定义、使用方法与 Java 相同,可以用在类、函数、接口、类型别名、对象上,并且可以指定默认类型,如下:

1
2
3
4
5
6
7
8
9
10
11
class C<NumType = number> {
value!: NumType;
add!: (x: NumType, y: NumType) => NumType;
}

let foo = new C();

foo.value = 0;
foo.add = function (x, y) {
return x + y;
};

TypeScript 中,泛型的另一种常用方式是:作为类型参数的约束条件

如下:

1
2
3
4
5
6
function comp<T extends { length: number }>(a: T, b: T) {
if (a.length >= b.length) {
return a;
}
return b;
}

这样约束了 传入的类型必须是具有 length: number 字段的对象,减少了代码犯错的概率。

我们可以总结为:

1
<TypeParam extends ConstraintType>

表示类型参数必须是 ConstraintType 的子类型。

不过为了代码可读性,还是提出以下建议:

  • 尽量少用泛型:降低了代码可读性;
  • 泛型的类型参数越少越好;

3.9 TypeScript Enum 类型

3.9.1 定义与使用

和其他大多数静态语言一样,TypeScript 也有枚举类型。它的枚举类型更像 C++ 11 的 enum Class,定义如下:

1
2
3
4
5
enum Color {
Red,
Green,
Blue,
}
  • 值从 0 递增,但值不重要;
  • 是个类型也是值(变量不能和枚举类型同名),因此可以 Color.Red 这样调用,增强代码可读性;
  • 枚举值只读,可以是自身类型(Color)也可以是 number(因此枚举类型做参数时,传入一切 number 都不报错);

通常建议枚举类型前加 const 修饰,这样可以帮助编译器优化代码,提升性能:const enum

3.9.2 特性

  • 同名枚举类型会像同名接口的行为一样合并;

  • 允许字符串做枚举类型的值,要设置,则全员都设置,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const enum MediaTypes {
    JSON = "application/json",
    XML = "application/xml",
    }

    const url = "localhost";

    fetch(url, {
    headers: {
    Accept: MediaTypes.JSON,
    },
    }).then((response) => {
    // ...
    });
  • 字符串枚举可以由联合类型代表:function move(where: "Up" | "Down" | "Left" | "Right");

  • 在 C++ 的枚举类型中,有很多人抱怨不方便通过枚举值打印出枚举成员的字符串名。在 TypeScript 中就有方法:反向映射,我们调用 EnumClass[n] 就能得到索引为 n 的枚举成员的字符串名。

    这与 TypeScript 编译器将 enum 翻译成 JavaScript 的方式有关:

    1
    2
    3
    4
    enum Test {
    Apple,
    Banana
    }

    会被编译为:

    1
    2
    3
    4
    5
    6
    var Test;
    (function (Test) {
    // 相当于两句赋值:Test["Apple"] = 0, Test[0] = "Apple";
    Test[Test["Apple"] = 0] = "Apple";
    Test[Test["Banana"] = 1] = "Banana";
    })(Test || Test = {});

3.9.3 keyof 关键字与枚举类型

在 TypeScript 中,keyof 关键字有点类似 Python 的 __dict__ 属性,针对对象 / 类,返回对应类型的所有属性、方法名,不过 TypeScript 的返回是 “属性/方法名字符串组成的联合类型”(就是个字符串枚举类型!),而 Python 返回的是对象数组。

还有一点值得注意,TypeScript 枚举类型想要使用 keyof 获取成员名字符串联合类型,必须加 typeof

1
2
3
4
5
6
enum MyEnum {
A = "a",
B = "b",
}
// 'A'|'B'
type Foo = keyof typeof MyEnum;

否则会被编译器当作 number 类型:

1
2
3
// "toString" | "toFixed" | "toExponential" |
// "toPrecision" | "valueOf" | "toLocaleString"
type Foo = keyof MyEnum;

3.10 TypeScript 的类型断言

类型断言的存在,允许开发者在代码中“断言”某个值的类型,告诉编译器此处的值是什么类型(而不是改变这个值的类型)。TypeScript 一旦发现存在类型断言,并且 原类型 兼容 断言的类型,就不再对该值进行类型推断,而是直接采用断言给出的类型

再次强调:断言的前提是 原类型 兼容 断言的类型

由于 TypeScript 新版本支持了 React 的 JSX 语法,因此如果你要使用 JSX,则不能用旧语法(<assertType>value,这里的尖括号是真的尖括号),应该使用新语法:value as assertType

那么你可能会问,TypeScript 编译器的类型推断不是已经足够了吗?虽然它增强了语言灵活性,但让人来控制类型推断不会导致出错可能性增加?

确实!它不能乱用,否则会出大问题。

但是只要在适当的场合使用就能发挥它的优势。以下是常用场景:

3.10.1 代替明确的类型缩窄

早在 3.2.5 节中就提到,对于一些联合类型,我们必须使用类型缩窄,才能使用某个特定类型的方法,以此避免运行时的类型错误,如下:

1
2
3
4
5
6
7
8
function doSomething(): string | null {
// 可能返回 null,也可能返回 string
}
var test: string | null = doSomething();

// 类型缩窄
if (test !== null)
test.toUpperCase();

现在,假设 我们根据代码逻辑,明确知道 test 肯定不会为 null,但是编译器不知道,还以为可能是 null

这个时候我们可以明确告诉编译器,它不可能为 null,于是这么用就无需使用类型缩窄了:

1
2
3
var test: string | null = doSomething();

(test as string).toUpperCase();

3.10.2 直接量的常量断言

基本类型直接量

我们知道,TypeScript 的常量是不能被改变的量,那么常量声明语法 as const 就可以将基本类型直接量变为对应的值类型,例如:

1
var s = "TypeScript" as const;

这样在编译时,TypeScript 编译器会将 s 看作 "TypeScript" 值类型。这样有什么用呢?对 直接量 的常量断言,是看作对应的值类型。例如:

1
2
3
4
5
6
7
8
9
let s = "JavaScript";

type Lang = "JavaScript" | "TypeScript" | "Python";

function setLang(language: Lang) {
/* ... */
}

setLang(s); // TypeError

这里 Lang 类型是等价于枚举类型,如果传入普通字符串,即便值和枚举量一致,但编译器没法保证类型正确性。

所以我们可以这么做:

1
var s = "JavaScript" as const;

这样编译器会将 s 看作 "JavaScript" 值类型,保证以后也不会更改,因此修复了这个问题。当然也与直接用 const 等价:

1
const s = "JavaScript";

不过值得注意的是:常量断言不能用于 已被赋值(定义)的变量!因为 TypeScript 不允许中途更改变量的读写性质。

对象直接量

如果将 as const 类型断言修饰对象,那么和修饰基本类型直接量的行为就有些不同了。

也就是说,这个时候 const 修饰符和 as const 断言就有区别了!

  • const 修饰对象直接量时,只是不允许更改对象引用,但其中数据可更改(因为是可变数据类型);
  • as const 断言对象直接量时,其中所有数据域 / 成员都改为只读属性

例如:

  • 数组 / 元组直接量 -> 只读元组(同时确定了长度、类型 和值);

    1
    2
    3
    4
    5
    // a1 的类型推断为 number[]
    const a1 = [1, 2, 3];

    // a2 的类型推断为 readonly [1, 2, 3] (元组)
    const a2 = [1, 2, 3] as const;
  • 对象直接量 -> 数据域全部只读;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const v1 = {
    x: 1,
    y: 2,
    }; // 类型是 { x: number; y: number; },普通对象常量

    const v2 = {
    x: 1 as const,
    y: 2,
    }; // 类型是 { x: 1; y: number; },数据域直接量常量断言 的 对象常量

    const v3 = {
    x: 1,
    y: 2,
    } as const; // 类型是 { readonly x: 1; readonly y: 2; },常量断言 的 对象常量

那么这样做有什么用?举个例子:

1
2
3
4
5
6
function add(x: number, y: number) {
return x + y;
}

const nums: readonly number[] = [1, 2];
const total = add(...nums); // TypeError

如果我想将已知长度的数组解构传给函数,但 const 修饰,甚至是 readonly 修饰,也并不能说明这是个定长度为 2、定内容的数组,只能说明这个数组对象引用不会变,所以编译器认为不行。

这个时候使用 as const 断言就能提示编译器这个数组是只读元组,并且长度为 2,每个位置上的类型都是值类型,能作为参数传递而不出问题。

3.10.3 非空断言

和 “明确缩窄类型” 的作用很像,但非空断言只针对 “空类型的类型缩窄”。就是根据代码逻辑,明确知道某些对象的类型不可能为 null / undefined在节省不必要的判断的同时,让编译器不报错

但请注意!一定要确定 “肯定非空” 的逻辑!否则会出现运行时问题。

如果不能确保,就请通过手动检查来缩窄类型!

使用非空断言的语法就是前面提到的:尾缀非空断言运算符 !

举个例子:

1
const root = document.getElementById("root")!;

表示 document 中肯定有 ID 为 root 的元素,不用考虑空的情况了。

还有种场景是在之前提到过的,如果类中有些属性要在类外、构造函数外初始化,需要给该属性加非空断言,不然编译器认为没有初始化实例属性(如果你的属性定义没有 null 类型的话)而报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Point {
x: number; // TypeError
y: number | null;

constructor() {
// Empty
}
}

class Point {
x!: number; // Correct
y: number | null;

constructor() {
// Empty
}
}

3.10.4 类型断言函数

一类函数,如果它的作用是:“保证参数符合某种类型,不符合就抛出错误,符合就什么都不做”,那么就将这类函数称为 “类型断言函数”。

由于断言函数要么不返回(void),要么抛出错误(never),因此为了断言函数的语义清晰性,TypeScript 3.7 引入了断言函数声明写法,以判断字符串类型为例:

1
2
3
4
function isString(value: unknown): asserts value is string {
if (typeof value !== "string")
throw new Error("Not a string");
}

这样如果断言函数返回结果(不符合断言函数标准的行为),也会报错。

如果是判断真值(不为 falseundefinednull)的话,断言函数还能更简单地书写:

1
2
3
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}

这样这个函数就和 Python 的 assert 关键字行为类似了。


补充:TypeScript 的 is 关键字

TypeScript 中,is 除了充当类型断言函数的尾缀关系,还能作为类型判断函数(用于判断参数类型,返回值一定为 boolean 类型)的尾缀关系,例如:

1
2
3
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}

is 关键字也仅限于这两类判断函数的尾缀,以此更清晰地表示这类函数的逻辑。

Chapter 4. TypeScript 模块与命名空间

4.1 模块

4.1.1 定义

TypeScript 文件有 2 类:

  • 一种是全局的脚本文件。类似 shell 脚本,其中的变量、函数等等内容可以直接被其他接下来调用的所有文件访问到
  • 另一种是 TypeScript 模块。模块内部的变量、函数、类只在内部可见,对于模块外部是不可见的,这就相当于 C++ 这类语言的普通文件。

Q: 这两种文件如何区分?

A: 任何包含 import 或 export 语句的文件,就是一个模块(module)。相应地,如果文件不包含 export 语句,就是一个全局的脚本文件。

Q: 既然模块对外部具有封装性,那么它们间如何相互调用?

A: 答案很简单,模块暴露给外部的接口,必须用 export 命令声明;如果其他文件要使用模块的接口,必须用 import 命令来输入。

4.1.2 特性

  • export {}; 语句不会进行任何操作,但是这个文件的内部变量对外不再可见;
  • TypeScript 允许导入导出 type(类型别名),需要 export type <typename>
  • TypeScript 也允许 默认导出、命名导出,用法和 JavaScript 类似;
    • 但是导入类型别名时,需要加 type 关键字前缀
    • 导入接口、类时,和 JavaScript 普通对象的导入方法相同;

4.1.3 CommonJS 模块支持

众所周知,NodeJS 有专门的模块格式,与 ECMAScript 原生脚本不兼容。但是 TypeScript 兼容了 CommonJS 模块的导入:

  • import <name> = require("<moduleName>");
  • export = <exportObj>

4.1.4 编译时模块定位 (Module Resolution)

几乎所有要编译的语言,其源文件的编译都要考虑一个问题:如何组织模块间相互位置关系、如何找模块 / 库的位置。

C++ 中,要么你把源文件全部放在固定目录中,然后用相对路径手动引用。缺点是一改文件位置就要重写,非常麻烦;另一种方法是 使用 CMake/Makefile 之类的项目管理工具指定编译操作的过程。

在 TypeScript 中,也有两种方法。

引用时手动指定模块路径

我们在引用模块时可能会这么写:

1
2
3
4
5
import { TypeA } from "./a";    // 省略后缀名,则可查找 *.ts / *.d.ts / *.tsx
import "/mod";

import { Component } from "@angular/core";
import * as $ from "jquery";

前两种利用相对路径指定模块位置的方法是 相对模块表示法

后两种不带有路径信息地指定模块地方法是 非相对模块表示法

那么 TypeScript 如何查找以上手动指定的模块?由于 NodeJS 引入的 CommonJS 模块,因此有两种查找方式:一种称为 Classic 方法,另一种称为 Node 方法。可以使用编译参数 moduleResolution,指定使用哪一种方法。

对于 Classic 方法(原生 JavaScript、TypeScript 默认的方法),步骤就非常简单:

  • 对于相对模块表示法,按照当前模块的位置为基准路径计算相对路径

    例如 import { t } from "../t" 就从当前脚本,找上个目录里的 t.ts

  • 对于非相对模块表示法,会以当前模块位置为起点,层层向上查找目录

对于 Node 方法就有些复杂:

  • 对于相对模块表示法,例如 let x = require("./b"),会进行以下查找步骤:
    1. 找当前目录的 b.tsb.tsxb.d.ts
    2. 找当前目录的子目录是否有 package.json,这个文件中有无 types 字段,如果有则递归查找;
    3. 找当前目录的 名为 b 的子目录是否包括 index.ts/.tsx/.d.ts
  • 对于非相对模块表示法,例如 let x = require("b"),会进行以下查找步骤:
    1. 子目录 node_modules 是否包含 b.ts/.tsx/.d.ts
    2. 子目录 node_modules 中是否存在 package.json,是否有 types 字段,如果有则递归查找;
    3. 子目录 node_modules 中是否存在子目录 @types,如有,则查找其中的 b.d.ts
    4. 子目录 node_modules 中是否存在子目录 b,其中是否包括 index.ts/.tsx/.d.ts
    5. 进入上层目录,充分上述步骤,直至找到。

tsconfig.json 配置模块路径

TypeScript 允许开发者在tsconfig.json文件里面,手动指定脚本模块的路径,这样做在一些大型的、依赖关系复杂的项目中就比较方便。

有如下配置字段可以设置编译器的查找过程:

  • compilerOptions.baseUrl:指定脚本模块的基准目录(该项目的所有源文件的基准位置都被设置在此)。例如:

    1
    2
    3
    4
    5
    {
    "compilerOptions": {
    "baseUrl": "."
    }
    }

    表示将 tsconfig.json 所在的目录为基准目录;

  • compilerOptions.paths指定非相对路径表示法的模块与实际路径的映射。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "compilerOptions": {
    "baseUrl": ".",
    "paths": {
    "jquery": ["node_modules/jquery/dist/jquery"]
    }
    }
    }

    上例的 jquery 属性的值是一个数组,可以指定多个路径。如果第一个脚本路径不存在,那么就加载第二个路径,以此类推。

  • compilerOptions.rootDirs指定无论查找什么模块,必须要额外查找的其他目录。例如:

    1
    2
    3
    4
    5
    {
    "compilerOptions": {
    "rootDirs": ["src/zh", "src/de", "src/#{locale}"]
    }
    }

此外,如果你发现自己指定了一些路径,编译器就是找不到。那么你可以使用 --traceResolution 编译器选项,让编译器在命令行中打印搜索的路径,相当于一种调试。

4.2 命名空间

评价是和 C++ 的 namespace 很类似。使用也很像:

1
2
3
4
5
6
7
8
9
10
namespace Utils {
function isString(value: any) {
return typeof value === "string";
}

// 正确
isString("yes");
}

Utils.isString("no"); // 报错

哪怕在一个文件中,也必须 export 才能被外部使用:

1
2
3
4
5
6
7
8
9
10
11
12
namespace Utility {
export function log(msg: string) {
console.log(msg);
}
export function error(msg: string) {
console.error(msg);
}
}

// 正确
Utility.log("Call me");
Utility.error("maybe!");

命名空间本身能被 export、也允许合并(行为类似 interface);

Chapter 5. TypeScript 装饰器

TypeScript 曾有旧式语法,这里不再作介绍。本章只介绍 TypeScript 5.0 以后的新式装饰器。

如果你想使用旧式语法,请向编译器传递 --experimentalDecorators 参数。

5.1 概念

TypeScript 的装饰器的定义 和 Python 的装饰器的定义一致,和 Java 的装饰器类似。或者说,“装饰器” 的概念是跨语言的、抽象概念。主要内容是:

  • 前缀是 @,后面必须是一种特殊表达式。这个表达式要求:要么就是个函数名,要么表达式执行后返回一个函数
  • 这个通过后面表达式所得到的函数称为装饰器函数。装饰器函数可以接受所修饰对象的一些相关值作为参数,并且要么不返回值(装饰过程),要么返回新对象用来替换原来的目标对象(装饰对象)

装饰器的作用是,通过类似 “外部注入” 的方式,改变被装饰对象的行为

比如:

1
2
3
4
@Injectable
class A {
// ...
}

这样使用类 A 的时候,其行为就会被装饰器 Injectable 所改变。

5.2 源码定义

我们从概念中可知,要了解 TypeScript 装饰器怎么用、机制是什么,最主要看装饰器函数。它在 TypeScript 中的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Decorator = (
value: any,
context: {
kind: string;
name: string | symbol;
addInitializer?(initializer: () => void): void;
static?: boolean;
private?: boolean;
access: {
get?(): unknown;
set?(value: unknown): void;
};
}
) => void | ReplacementValue;

可以看到,Decorator 是一个函数类型,它接收两个参数:

  • value所装饰的对象,在使用 @Decorator 时,相当于语法糖,自动将修饰对象传递到此参数

  • context:上下文对象,看起来有很多内容,实际上是为了适应多种装饰器的类别而定义的(很多可选参数,只需要记住几种常用的装饰器类别就行);

    在 TypeScript 中,定义了一个原生接口 ClassXXXDecoratorContext,描述的就是 typeof Decorator.context

    • kind:装饰器类别,只有 6 种,分别对应了 6 种装饰器"class"(类装饰器)、"method"(方法装饰器)、"getter"(读装饰器)、"setter"(写装饰器)、"field"(属性装饰器)、"accessor"(访问装饰器);
    • name:所装饰对象的名称(例如类名、函数名、属性名等等);
    • addInitializer()添加被修饰对象初始化后的逻辑
    • private所装饰对象是不是一个类的私有成员
    • static所装饰对象是不是一个类的静态成员
    • access包含所修饰对象的访问器 getset

5.2.1 类装饰器

类装饰器是 context.kind 字段为 "class" 的装饰器,用来装饰 TypeScript 类

它只需定义装饰器 context 可选成员的 addInitializer(),用在类上就是 类完全定义后、构造函数前执行的 initializer 函数

如下:

1
2
3
4
5
6
7
8
type ClassDecorator = (
value: Function,
context: {
kind: "class";
name: string | undefined;
addInitializer(initializer: () => void): void;
}
) => Function | void;

常见的使用场景有:

  1. 向类中添加一个外部方法;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function Greeter(value, context) {
    if (context.kind === "class") {
    value.prototype.greet = function() {
    console.log("Hello!");
    };
    }
    }

    @Greeter
    class User {}

    var u = new User();
    u.greet(); // Hello!

    作用原理是,向传入其中的类调用 prototype 获取类的原型,添加方法 greet

  2. 替换被修饰类的构造函数(例如添加一些 side effects)/ 直接替换被修饰的类;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    function countInstances(value: any, context: any) {
    let instanceCount = 0;

    const wrapper = function (...args: any[]) {
    instanceCount++;
    const instance = new value(...args);
    instance.count = instanceCount;
    return instance;
    } as unknown as typeof MyClass;

    wrapper.prototype = value.prototype; // A
    return wrapper;
    }

    @countInstances
    class MyClass {}

    const inst1 = new MyClass();
    inst1 instanceof MyClass; // true
    inst1.count; // 1

    解释一下,因为 JavaScript 和 TypeScript 中的类型 class,只要是个构造函数就能用 class 声明。借助这个特性,我们能更换被修饰类的构造函数。

    在装饰器内定义一个闭包(新的构造函数,new 调用了原来构造函数),最后将函数的 prototype 属性统一,再返回就是新的构造函数了。

    这个时候,返回的构造函数就替换了原来的构造函数(也就是替换了类)。按这种思想,如果你不想用 prototype 这种原生 JavaScript 的内容,你还可以直接返回一个临时子类,来实现同样效果

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function countInstances(value: any, context: any) {
    let instanceCount = 0;

    return class extends value {
    constructor(...args: any[]) {
    super(...args);
    instanceCount++;
    this.count = instanceCount;
    }
    };
    }
  3. 更改被修饰类的创建行为(修改 new 的行为);

    我们注意到,JavaScript 的构造函数中,有 new 对象可供使用,new.target 表示使用 new 调用这个构造函数的目标对象。我们可以借此控制 new 的行为,例如禁止 new 新建实例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    function functionCallable(
    value as any, {kind} as any
    ) {
    if (kind === 'class') {
    return function (...args) {
    if (new.target !== undefined) {
    throw new TypeError('This function can’t be new-invoked');
    }
    return new value(...args);
    }
    }
    }

    @functionCallable
    class Person {
    constructor(name) {
    this.name = name;
    }
    }
    const robin = Person('Robin');
    robin.name // 'Robin'
  4. 在类的定义后、类实例初始化前追加行为;

    这里就需要使用到装饰器 addInitializer这个函数将在 类的定义结束后(不是实例的定义结束后!) 执行,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function addInitializeWrapper(name: string): Function {
    function wrapper(value: any, context: ClassDecoratorContext) {
    context.addInitializer(() => {
    console.log(name);
    });
    }
    return wrapper;
    }

    @addInitializeWrapper("Hello")
    class Test {

    };

    var test = new Test;
    var t2 = new Test;

    会在 Test 定义结束后打印一次 Hello

5.2.2 方法装饰器

方法装饰器是 context.kind 字段为 "method" 的装饰器,用来装饰 TypeScript 类中的方法。

需要指定 static(该方法是否在类中为静态方法)、private(该方法是否在类中为私有方法)、access.get(该方法的读访问器)、addInitializer(该方法定义后的初始化逻辑),如下:

1
2
3
4
5
6
7
8
9
10
11
type ClassMethodDecorator = (
value: Function,
context: {
kind: "method";
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown };
addInitializer(initializer: () => void): void;
}
) => Function | void;

常见使用场景有:

  1. 直接替换被修饰的方法;

    这种使用方法很简单,就是装饰器函数返回一个新的函数/方法就行,这就算替换了原来的方法,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    function replaceWithHello() {
    return function() {
    console.log("Hello!");
    }
    }

    class Test {
    name: string;

    constructor(n: string) {
    this.name = n;
    }

    @replaceWithHello
    echoName(): void {
    console.log(this.name);
    }
    }
  2. 给被修饰的方法添加额外逻辑(例如打印日志、延时执行、计时、绑定 this 环境等等);

    这里就举这 3 个例子。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 给被装饰方法添加日志
    function addLog(originMethod: Function, context: ClassMethodDecoratorContext) {
    const methodName = String(context.name);

    function wrapper(this: any, ...args: any[]) {
    console.log(`[DEBUG] Entering method: ${methodName}`);
    // 在原有作用域 this 下执行
    const result = originMethod.call(this, ...args);
    console.log(`[DEBUG] Exiting method: ${methodName}`);
    return result;
    }

    return wrapper;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 给被装饰方法延迟执行
    function delay(millisecond: number = 0) {
    // 无论在哪里,只要 typescript 检测到第一个参数是 this,就会自动将当前环境 this 填充进去,不在 JavaScript 中展现出来。
    function wrapper(this: any, value: Function, context: ClassMethodDecoratorContext) {
    if (context.kind === 'method') {
    return (...args: any[]) => {
    setTimeout(() => {
    // 和 call 一样,在指定作用域下执行
    value.apply(this, args);
    }, millisecond);
    };
    }
    }
    return wrapper;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 给被装饰方法自动绑定 this 环境,可以解决 “实例方法被赋给其他变量后语义改变 / 报错” 的问题
    function boundThis(value: Function, context: ClassMethodDecoratorContext) {
    const methodName = context.name;
    if (context.private) {
    throw new Error(`不能绑定私有方法 ${methodName as string}`);
    }
    context.addInitializer(function (this: any) {
    this[methodName] = this[methodName].bind(this);
    });
    }

5.2.3 属性装饰器

属性装饰器是 context.kind 字段为 "field" 的装饰器,用来装饰定义在类中的属性。

它需要指定 staticprivateaddInitializeraccess.getaccess.set(比方法装饰器多了写访问器);

另外它还有一个与其他装饰器不同点是,它要么不返回值,要么返回一个函数,该函数会自动执行,用来对所装饰属性进行初始化。该函数的参数是所装饰属性的初始值,该函数的返回值是该属性的最终值。

所以,这下你能明白属性装饰器的作用了吗?在初始化该属性时会触发一次属性装饰器。定义如下:

1
2
3
4
5
6
7
8
9
10
11
type ClassFieldDecorator = (
value: undefined,
context: {
kind: "field";
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown; set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => (initialValue: unknown) => unknown | void;

我们还注意到,valueundefined 类型,这意味着 属性装饰器不会自动将属性传给内部(因为没有必要,考虑赋给该属性的值 initialValue 即可)

常见的使用场景和类的访问器类似,不再赘述,只是介绍一个样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 初始化
function logged(value, context) {
const { kind, name } = context;
if (kind === "field") {
return function (initialValue) {
console.log(`[DEBUG] initializing ${name} with value ${initialValue}`);
return initialValue;
};
}
}

class Color {
@logged name = "green";
}

const color = new Color();
// "[DEBUG] initializing name with value green"

5.2.4 getter 装饰器、setter 装饰器

它们是 context.kind 字段为 "getter"/"setter" 的装饰器,是专门用来装饰类访问器的装饰器。

描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type ClassGetterDecorator = (
value: Function,
context: {
kind: "getter";
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown };
addInitializer(initializer: () => void): void;
}
) => Function | void;

type ClassSetterDecorator = (
value: Function,
context: {
kind: "setter";
name: string | symbol;
static: boolean;
private: boolean;
access: { set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => Function | void;

这两个装饰器要么不返回值,要么返回一个函数,取代原来的取值器或存值器。

为什么要对访问器进行装饰?比如懒加载的特性:

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
class C {
@lazy
get value() {
console.log("Calculating...");
return "Time Consuming Result";
}
}

function lazy(value: any, { kind, name }: any) {
if (kind === "getter") {
return function (this: any) {
const result = value.call(this);
Object.defineProperty(this, name, {
value: result,
writable: false,
});
return result;
};
}
return;
}

const inst = new C();
inst.value;
// Calculating...
// 'Time Consuming Result'
inst.value;
// 'Time Consuming Result'

还有一个 accessor 装饰器不常用,在这里不再赘述,有兴趣请查看官方文档。