跳到主要内容

增强类型系统

增强类型系统,顾名思义就是对 TypeScript 类型系统的增强。在 npm 中,有很多历史悠久的库都是使用 JavaScript 编写的,而 TypeScript 作为 JavaScript 的超集,设计目标之一就是能在 TypeScript 中安全、方便地使用 JavaScript 库。

TypeScript 相较于 JavaScript 而言,其一大特点就是类型。关于类型的定义方法,除了之前的内容之外,我们还可以通过以下方式扩展类型系统。

声明

那么,我们如何在 TypeScript 中安全地使用 JavaScript 的库呢?关键的步骤就是使用 TypeScript 中的一个 declare 关键字。

通过使用 declare 关键字,我们可以声明全局的变量、方法、类、对象。下面我们先说一下如何声明全局的变量。

declare 变量

在运行时,前端代码 <script> 标签会引入一个全局的库,再导入全局变量。此时,如果你想安全地使用全局变量,那么就需要对变量的类型进行声明。

声明变量的语法: declare (var|let|const) 变量名称: 变量类型 ,具体示例如下:

declare var val1: string;
declare let val2: number;
declare const val3: boolean;
val1 = '1';
val1 = '2';
val2 = 1;
val2 = '2'; // TS2322: Type 'string' is not assignable to type 'number'.
val3 = true; // TS2588: Cannot assign to 'val3' because it is a constant.

在上面的代码示例中,我们分别使用 var、let、const 声明了 3 种不同类型的变量。其中,使用 var、let 声明的变量是可以更改变量赋值的,而使用 const 声明的变量则不可以。同时,对于变量类型不正确的错误,TypeScript 也可以正常检测出来。

当然, declare 关键字还可以用来声明函数、类、枚举类型

声明函数

声明函数的语法与声明变量类型的语法相同,不同的是 declare 关键字后需要跟 function 关键字

declare function toString(x: number): string;
const x = toString(1); // => string

需要注意:使用 declare关键字时,我们不需要编写声明的变量、函数、类的具体实现(因为变量、函数、类在其他库中已经实现了),只需要声明其类型即可,如下示例:

// TS1183: An implementation cannot be declared in ambient contexts.
declare function toString(x: number) {
return String(x);
};

在上面的例子中,TypeScript 的报错信息提示:环境声明的上下文不需要实现。也就是说 declare 声明的所有类型只需要表明类型,不需要实现。

声明类

声明类时,我们只需要声明类的属性、方法的类型即可。

另外,关于类的可见性修饰符我们也可以在此进行声明

declare class Person {
public name: string;
private age: number;
constructor(name: string);
getAge(): number;
}
const person = new Person('Mike');
person.name; // => string
person.age; // TS2341: Property 'age' is private and only accessible within class 'Person'.
person.getAge(); // => number

在上面的例子中,我们声明了公共属性 name 以及私有属性 age,此时我们看到无法访问私有属性 age。另外,我们还声明了方法 getAge ,并且 getAge 的返回值是 number 类型,所以 Person 实例调用后返回的类型也是 number 类型。

声明枚举

声明枚举只需要定义枚举的类型,并不需要定义枚举的值

declare enum Direction {
Up,
Down,
Left,
Right,
}
const directions = [Direction.Up, Direction.Down, Direction.Left, Direction.Right];

在上述示例中的第 1~6 行,我们声明了在其他地方定义的枚举 Direction 类型结构,然后在第 8 行就可以直接访问枚举的成员了

**注意:声明枚举仅用于编译时的检查,编译完成后,声明文件中的内容在编译结果中会被删除, **相当于仅剩下面使用的语句

const directions = [Direction.Up, Direction.Down, Direction.Left, Direction.Right];

这里的 Direction 表示引入的全局变量。

除了声明变量、函数、类型、枚举之外,我们还可以使用 declare 增强文件、模块的类型系统。

declare 模块

在 JavaScript 还没有升级至 ES6 的时候,TypeScript 就提供了一种模块化方案,比如通过使用 module 关键字,我们就可以声明一个内部模块。但是由于 ES6 后来也使用了 module 关键字,为了兼容 ES6,所以 TypeScript 使用 namespace 替代了原来的 module,并更名为命名空间。

TypeScript 与 ES6 一样,任何包含顶级 import 或 export 的文件都会被当作一个模块。我们可以通过声明模块类型,为缺少 TypeScript 类型定义的三方库或者文件补齐类型定义,如下示例:

// lodash.d.ts
declare module 'lodash' {
export function first<T extends unknown>(array: T[]): T;
}
// index.ts
import { first } from 'lodash';
first([1, 2, 3]); // => number;

在上面的例子中,lodash.d.ts 声明了模块 lodash 导出的 first 方法,然后在 TypeScript 文件中使用了模块 lodash 中的 first 方法。

声明模块的语法: declare module '模块名' {}

在模块声明的内部,我们只需要使用 export 导出对应库的类、函数即可。

declare 文件

在使用 TypeScript 开发前端应用时,我们可以通过 import 关键字导入文件,比如先使用 import 导入图片文件,再通过 webpack 等工具处理导入的文件。

但是,因为 TypeScript 并不知道我们通过 import 导入的文件是什么类型,所以需要使用 declare 声明导入的文件类型

declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}

在上面的例子中,我们使用了 *.xxx 模块通配符匹配一类文件。

这里标记的图片文件的默认导出的类型是 string ,通过 import 使用图片资源时,TypeScript 会将导入的图片识别为 string 类型,因此也就可以把 import 的图片赋值给 的 src 属性,因为它们的类型都是 string,是匹配的。

declare namespace

不同于声明模块,命名空间一般用来表示具有很多子属性或者方法的全局对象变量。

我们可以将声明命名空间简单看作是声明一个更复杂的变量

declare namespace $ {
const version: number;
function ajax(settings?: any): void;
}
$.version; // => number
$.ajax();

在上面的例子中,因为我们声明了全局导入的 jQuery 变量 $,所以可以直接使用 $ 变量的 version 属性以及 ajax 方法。

在 TypeScript 中,我们还可以编写以 .d.ts 为后缀的声明文件来增强(补齐)类型系统。

声明文件

在 TypeScript 中,以 .d.ts 为后缀的文件为声明文件。如果你熟悉 C/C++,那么可以把它当作 .h 文件。 在声明文件时,我们只需要定义三方类库所暴露的 API 接口即可。

在 TypeScript 中,存在类型、值、命名空间这 3 个核心概念。

类型

  • 类型别名声明;

  • 接口声明;

  • 类声明;

  • 枚举声明;

  • 导入的类型声明。

上面的每一个声明都创建了一个类型名称。

值就是在运行时表达式可以赋予的值。

我们可以通过以下 6 种方式创建值:

  • var、let、const 声明;

  • namespace、module 包含值的声明;

  • 枚举声明;

  • 类声明;

  • 导入的值;

  • 函数声明。

命名空间

在命名空间中,我们也可以声明类型。比如 const x: A.B.C 这个声明,这里的类型 C 就是在 A.B 命名空间下的。

👁 说明:这种区别微妙而重要,这里的A.B可能代表一个值,也可能代表一个类型。

一个名称 A, 在 TypeScript 中可能表示一个类型、一个值,也可能是一个命名空间。通过类型、值、命名空间的组合,我们也就拥有了表达任意类型的能力。如果你想知道名称A 代表的实际意义,则需要看它所在的上下文。

Demo

使用声明文件

安装 TypeScript 依赖后,一般我们会顺带安装一个 lib.d.ts 声明文件,这个文件包含了 JavaScript 运行时以及 DOM 中各种全局变量的声明,如下示例:

// typescript/lib/lib.d.ts
/// <reference no-default-lib="true"/>
/// <reference lib="es5" />
/// <reference lib="dom" />
/// <reference lib="webworker.importscripts" />
/// <reference lib="scripthost" />

上面的示例其实就是 TypeScript 中的 lib.d.ts 文件的内容。

其中,/// 是 TypeScript 中三斜线指令,后面的内容类似于 XML 标签的语法,用来指代引用其他的声明文件。通过三斜线指令,我们可以更好地复用和拆分类型声明。no-default-lib="true" 表示这个文件是一个默认库。而最后 4 行的lib="..." 表示引用内部的库类型声明。

关于更多三斜线指令的内容,查看链接

使用 @types

为库编写类型声明非常耗费精力,且难以在多个项目中复用。Definitely Typed是最流行性的高质量 TypeScript 声明文件类库,正是因为有社区维护的这个声明文件类库,大大简化了 JavaScript 项目迁移 TypeScript 的难度。

目前,社区已经记录了 90% 的 JavaScript 库的类型声明,意味着如果我们想使用的库有社区维护的类型声明,那么就可以通过安装类型声明文件直接使用 JavaScript 编写的类库了。

具体操作:首先,点击这里的链接搜索你想要导入的类库的类型声明,如果有社区维护的声明文件。然后,我们只需要安装 @types/xxx 就可以在 TypeScript 中直接使用它了。

然而,因为 Definitely Typed 是由社区人员维护的,如果原来的三方库升级,那么 Definitely Typed 所导出的三方库的类型定义想要升级还需要经过 PR、发布的流程,就会导致无法与原库保持完全同步。针对这个问题,在 TypeScript 中,我们可以通过类型合并、扩充类型定义的技巧临时解决。

类型合并

在 TypeScript 中,相同的接口、命名空间会依据一定的规则进行合并。

合并接口

最简单、常见的声明合并是接口合并,下面我们看一个具体的示例:

interface Person {
name: string;
}
interface Person {
age: number;
}
// 相当于
interface Person {
name: string;
age: number;
}

在上面的例子中,我们展示了接口合并最简单的情况,这里的合并相当于把接口的属性放入了一个同名的接口中。

需要注意的是接口的非函数成员类型必须完全一样,如下示例:

interface Person {
age: string;
}
interface Person {
// TS2717: Subsequent property declarations must have the same type.
// Property 'age' must be of type 'string', but here has type 'number'.
age: number;
}

在上面的例子中,因为存在两个属性相同而类型不同的接口,所以编译器报了一个 ts(2717) 错误 。

对于函数成员而言,每个同名的函数声明都会被当作这个函数的重载。

需要注意的是后面声明的接口具有更高的优先级,下面看一个具体的示例:

interface Obj {
    identity(val: any): any;
}
interface Obj {
    identity(val: number): number;
}
interface Obj {
    identity(val: boolean): boolean;
}
// 相当于
interface Obj {
  identity(val: boolean): boolean;
  identity(val: number): number;
  identity(val: any): any;
}
const obj: Obj = {
    identity(val: any) {
        return val;
    }
};
const t1 = obj.identity(1); // => number
const t2 = obj.identity(true); // => boolean
const t3 = obj.identity("t3"); // => any

在上面的代码中,Obj 类型的 identity 函数成员有 3 个重载,与函数重载的顺序相同,声明在前面的重载类型会匹配。我们分开声明接口的 3 个函数成员,相当于 12~16 行的声明,因为后声明的接口具有更高的优先级,所以 t1、t2 的类型可以被重载为其对应的类型,而不是 any。

接下来我们更改一下顺序,再看看结果。

interface Obj {
identity(val: boolean): boolean;
}
interface Obj {
identity(val: number): number;
}
interface Obj {
identity(val: any): any;
}
// 相当于
interface Obj {
  identity(val: any): any;
  identity(val: number): number;
  identity(val: boolean): boolean;
}
const obj: Obj = {
identity(val: any) {
return val;
}
};
const t1 = obj.identity(1); // => any
const t2 = obj.identity(true); // => any
const t3 = obj.identity("t3"); // => any

在上面的代码中,identity 函数参数为 any 的重载在第一位,因此 t1、t2、t3 的返回值类型都被重载成了 any。

合并 namespace

合并 namespace 与合并接口类似,命名空间的合并也会合并其导出成员的属性。不同的是,非导出成员仅在原命名空间内可见。

下面看一个具体的示例:

namespace Person {
const age = 18;
export function getAge() {
return age;
}
}
namespace Person {
export function getMyAge() {
return age; // TS2304: Cannot find name 'age'.
}
}

在上面的例子,同名的命名空间 Person 中,有一个非导出的属性 age,在第二个命名空间 Person 中没有 age 属性却引用了 age,所以 TypeScript 报出了找不到 age 的错误。这是因为非导出成员仅在合并前的命名空间中可见,上例中即 16 行的命名空间中可以访问 age 属性。但是对于 812 行中的同名命名空间是不可以访问 age 属性的。

不可合并

我们在ts基础 中知道定义一个类类型,相当于定义了一个类,又定义了一个类的类型。因此,对于类这个既是值又是类型的特殊对象不能合并。

除了可以通过接口和命名空间合并的方式扩展原来声明的类型外,我们还可以通过扩展模块或扩展全局对象来增强类型系统。

扩充模块

JavaScript 是一门动态类型的语言,通过 prototype 我们可以很容易地扩展原来的对象。

但是,如果我们直接扩展导入对象的原型链属性,TypeScript 会提示没有该属性的错误,因此我们就需要扩展原模块的属性。

下面看一个具体的示例:

// person.ts
export class Person {}
// index.ts
import { Person } from './person';
declare module './person' {
interface Person {
greet: () => void;
}
}
Person.prototype.greet = () => {
console.log('Hi!');
};

在上面的例子中,我们声明了导入模块 person 中 Person 的属性,TypeScript 会与原模块的类型合并,通过这种方式我们可以扩展导入模块的类型。同时,我们为导入的 Person 类增加了原型链上的 greet 方法。

// person.ts
export class Person {}

// index.ts
import { Person } from './person';

declare module './person' {
interface Person {
  greet: () => void;
}
}

- declare module './person' {
-   interface Person {
-     greet: () => void;
-   }
- }

+ // TS2339: Property 'greet' does not exist on type 'Person'.
Person.prototype.greet = () => {
console.log('Hi!');
};

在上面的例子中,如果我们删除了扩展模块的声明,第 20 行则会报出 ts(2339) 不存在 greet 属性的类型错误。

对于导入的三方模块,我们同样可以使用这个方法扩充原模块的属性。

扩充全局

全局模块指的是不需要通过 import 导入即可使用的模块,如全局的 window、document 等。

对全局对象的扩充与对模块的扩充是一样的,下面看一个具体示例:

declare global {
interface Array<T extends unknown> {
getLen(): number;
}
}
Array.prototype.getLen = function () {
return this.length;
};

在上面的例子中,因为我们声明了全局的 Array 对象有一个 getLen 方法,所以为 Array 对象实现 getLen 方法时,TypeScript 不会报错。