Type script 入门实战笔记 - 快手资深前端技术专家,快手轻雀协作前端负责人 - 拉勾教育

经过前面入门课程的学习,我们了解了 TypeScript 所特有的类型,比如基础类型、字面量类型、类类型、联合类型和交叉类型等,也在前两讲的课程中学习了关于这些类型使用的一些高级技巧。

然而,在平时使用 TypeScript 编写代码的过程中,我们可能会遇到某些库没有提供类型声明、库的版本和类型声明不一致、没有注入全局变量类型等各种问题。因此,这一讲我们将学习 TypeScript 增强类型系统,这样上边提到的问题就能迎刃而解了。

在 TypeScript 中预留了一个增强类型的口子,使得我们可以方便地扩展原来的类型系统,以兼容 JavaScript 的代码。

增强类型系统

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

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

声明

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

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

declare 变量

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

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

  1. declare var val1: string;
  2. declare let val2: number;
  3. declare const val3: boolean;
  4. val1 = '1';
  5. val1 = '2';
  6. val2 = 1;
  7. val2 = '2';
  8. val3 = true;

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

当然, declare 关键字还可以用来声明函数、类、枚举类型,下面我们一起来看看。

声明函数

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

  1. declare function toString(x: number): string;
  2. const x = toString(1);

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

  1. declare function toString(x: number) {
  2. return String(x);
  3. };

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

声明类

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

另外,关于类的可见性修饰符我们也可以在此进行声明,下面看一个具体的示例:

  1. declare class Person {
  2. public name: string;
  3. private age: number;
  4. constructor(name: string);
  5. getAge(): number;
  6. }
  7. const person = new Person('Mike');
  8. person.name;
  9. person.age;
  10. person.getAge();

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

声明枚举

声明枚举只需要定义枚举的类型,并不需要定义枚举的值,如下示例:

  1. declare enum Direction {
  2. Up,
  3. Down,
  4. Left,
  5. Right,
  6. }
  7. const directions = [Direction.Up, Direction.Down, Direction.Left, Direction.Right];

在上述示例中的第 1~6 行,我们声明了在其他地方定义的枚举 Direction 类型结构,然后在第 8 行就可以直接访问枚举的成员了(这其实就是我们在 09 讲中学习的外部枚举,Ambient Enums)。

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

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

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

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

declare 模块

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

需要注意:目前,任何使用module关键字声明一个内部模块的地方,我们都应该使用namespace关键字进行替换。

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

  1. declare module 'lodash' {
  2. export function first<T extends unknown>(array: T[]): T;
  3. }
  4. import { first } from 'lodash';
  5. first([1, 2, 3]);

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

说明:关于声明文件的知识点,我们一会再介绍,目前只需要知道声明文件是一个以.d.ts为后缀的文件。

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

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

declare 文件

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

但是,因为 TypeScript 并不知道我们通过 import 导入的文件是什么类型,所以需要使用 declare 声明导入的文件类型,下面看一个具体的示例:

  1. declare module '*.jpg' {
  2. const src: string;
  3. export default src;
  4. }
  5. declare module '*.png' {
  6. const src: string;
  7. export default src;
  8. }

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

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

declare namespace

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

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

  1. declare namespace $ {
  2. const version: number;
  3. function ajax(settings?: any): void;
  4. }
  5. $.version;
  6. $.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 代表的实际意义,则需要看它所在的上下文。

接下来我们通过实际的使用和示例分析来学习声明的书写方式。

使用声明文件

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

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

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

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

使用 @types

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

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

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

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

类型合并

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

合并接口

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

  1. interface Person {
  2. name: string;
  3. }
  4. interface Person {
  5. age: number;
  6. }
  7. interface Person {
  8. name: string;
  9. age: number;
  10. }

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

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

  1. interface Person {
  2. age: string;
  3. }
  4. interface Person {
  5. age: number;
  6. }

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

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

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

  1. interface Obj {
  2. identity(val: any): any;
  3. }
  4. interface Obj {
  5. identity(val: number): number;
  6. }
  7. interface Obj {
  8. identity(val: boolean): boolean;
  9. }
  10. interface Obj {
  11. identity(val: boolean): boolean;
  12. identity(val: number): number;
  13. identity(val: any): any;
  14. }
  15. const obj: Obj = {
  16. identity(val: any) {
  17. return val;
  18. }
  19. };
  20. const t1 = obj.identity(1);
  21. const t2 = obj.identity(true);
  22. const t3 = obj.identity("t3");

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

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

  1. interface Obj {
  2. identity(val: boolean): boolean;
  3. }
  4. interface Obj {
  5. identity(val: number): number;
  6. }
  7. interface Obj {
  8. identity(val: any): any;
  9. }
  10. interface Obj {
  11. identity(val: any): any;
  12. identity(val: number): number;
  13. identity(val: boolean): boolean;
  14. }
  15. const obj: Obj = {
  16. identity(val: any) {
  17. return val;
  18. }
  19. };
  20. const t1 = obj.identity(1);
  21. const t2 = obj.identity(true);
  22. const t3 = obj.identity("t3");

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

合并 namespace

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

下面看一个具体的示例:

  1. namespace Person {
  2. const age = 18;
  3. export function getAge() {
  4. return age;
  5. }
  6. }
  7. namespace Person {
  8. export function getMyAge() {
  9. return age;
  10. }
  11. }

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

不可合并

介绍类类型时我们说过,定义一个类类型,相当于定义了一个类,又定义了一个类的类型。因此,对于类这个既是值又是类型的特殊对象不能合并。

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

扩充模块

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

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

下面看一个具体的示例:

  1. export class Person {}
  2. import { Person } from './person';
  3. declare module './person' {
  4. interface Person {
  5. greet: () => void;
  6. }
  7. }
  8. Person.prototype.greet = () => {
  9. console.log('Hi!');
  10. };

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

  1. // person.ts
  2. export class Person {}
  3. // index.ts
  4. import { Person } from './person';
  5. declare module './person' {
  6. interface Person {
  7. greet: () => void;
  8. }
  9. }
  10. - declare module './person' {
  11. - interface Person {
  12. - greet: () => void;
  13. - }
  14. - }
  15. + // TS2339: Property 'greet' does not exist on type 'Person'.
  16. Person.prototype.greet = () => {
  17. console.log('Hi!');
  18. };

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

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

扩充全局

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

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

  1. declare global {
  2. interface Array<T extends unknown> {
  3. getLen(): number;
  4. }
  5. }
  6. Array.prototype.getLen = function () {
  7. return this.length;
  8. };

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

小结与预告

这一讲我们学习了声明的基本语法和如何使用声明文件,同时还学习了如何扩展类型定义以及模块类型,掌握了这些技巧不但可以扩展增强原模块,还能修改原模块的类型定义。

在 14 讲中,我们将学习 TypeScript 官方提供的工具类型。通过使用这些内置工具类型,我们可以在不同的项目中轻松地组合出一些复杂的工具类型。

最后,如果你觉得本专栏有价值,欢迎分享给更多好友!