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) 变量名称: 变量类型 ,具体示例如下:
declare var val1: string;
declare let val2: number;
declare const val3: boolean;
val1 = '1';
val1 = '2';
val2 = 1;
val2 = '2';
val3 = true;
在上面的代码示例中,我们分别使用 var、let、const 声明了 3 种不同类型的变量。其中,使用 var、let 声明的变量是可以更改变量赋值的,而使用 const 声明的变量则不可以。同时,对于变量类型不正确的错误,TypeScript 也可以正常检测出来。
当然, declare 关键字还可以用来声明函数、类、枚举类型,下面我们一起来看看。
声明函数
声明函数的语法与声明变量类型的语法相同,不同的是 declare 关键字后需要跟 function 关键字,如下示例:
declare function toString(x: number): string;
const x = toString(1);
需要注意:使用 declare关键字时,我们不需要编写声明的变量、函数、类的具体实现(因为变量、函数、类在其他库中已经实现了),只需要声明其类型即可,如下示例:
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;
person.age;
person.getAge();
在上面的例子中,我们声明了公共属性 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 行就可以直接访问枚举的成员了(这其实就是我们在 09 讲中学习的外部枚举,Ambient Enums)。
注意:声明枚举仅用于编译时的检查,编译完成后,声明文件中的内容在编译结果中会被删除, 相当于仅剩下面使用的语句:
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 类型定义的三方库或者文件补齐类型定义,如下示例:
declare module 'lodash' {
export function first<T extends unknown>(array: T[]): T;
}
import { first } from 'lodash';
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 声明导入的文件类型,下面看一个具体的示例:
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;
$.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 中,相同的接口、命名空间会依据一定的规则进行合并。
合并接口
最简单、常见的声明合并是接口合并,下面我们看一个具体的示例:
interface Person {
name: string;
}
interface Person {
age: number;
}
interface Person {
name: string;
age: number;
}
在上面的例子中,我们展示了接口合并最简单的情况,这里的合并相当于把接口的属性放入了一个同名的接口中。
需要注意的是接口的非函数成员类型必须完全一样,如下示例:
interface Person {
age: string;
}
interface Person {
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);
const t2 = obj.identity(true);
const t3 = obj.identity("t3");
在上面的代码中,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);
const t2 = obj.identity(true);
const t3 = obj.identity("t3");
在上面的代码中,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;
}
}
在上面的例子,同名的命名空间 Person 中,有一个非导出的属性 age,在第二个命名空间 Person 中没有 age 属性却引用了 age,所以 TypeScript 报出了找不到 age 的错误。这是因为非导出成员仅在合并前的命名空间中可见,上例中即 1~6 行的命名空间中可以访问 age 属性。但是对于 8~12 行中的同名命名空间是不可以访问 age 属性的。
不可合并
介绍类类型时我们说过,定义一个类类型,相当于定义了一个类,又定义了一个类的类型。因此,对于类这个既是值又是类型的特殊对象不能合并。
除了可以通过接口和命名空间合并的方式扩展原来声明的类型外,我们还可以通过扩展模块或扩展全局对象来增强类型系统。
扩充模块
JavaScript 是一门动态类型的语言,通过 prototype 我们可以很容易地扩展原来的对象。
但是,如果我们直接扩展导入对象的原型链属性,TypeScript 会提示没有该属性的错误,因此我们就需要扩展原模块的属性。
下面看一个具体的示例:
export class Person {}
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 不会报错。
小结与预告
这一讲我们学习了声明的基本语法和如何使用声明文件,同时还学习了如何扩展类型定义以及模块类型,掌握了这些技巧不但可以扩展增强原模块,还能修改原模块的类型定义。
在 14 讲中,我们将学习 TypeScript 官方提供的工具类型。通过使用这些内置工具类型,我们可以在不同的项目中轻松地组合出一些复杂的工具类型。
最后,如果你觉得本专栏有价值,欢迎分享给更多好友!