携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第17天,点击查看活动详情
自我介绍:大家好,我是吉帅振的网络日志(其他平台账号名字相同),互联网前端开发工程师,工作5年,去过上海和北京,经历创业公司,加入过阿里本地生活团队,现在郑州北游教育从事编程培训。
前言
在 TypeScript 中预留了一个增强类型的口子,使得我们可以方便地扩展原来的类型系统,以兼容 JavaScript 的代码。增强类型系统,顾名思义就是对 TypeScript 类型系统的增强。在 npm 中,有很多历史悠久的库都是使用 JavaScript 编写的,而 TypeScript 作为 JavaScript 的超集,设计目标之一就是能在 TypeScript 中安全、方便地使用 JavaScript 库。
声明
那么,我们如何在 TypeScript 中安全地使用 JavaScript 的库呢?关键的步骤就是使用 TypeScript 中的一个 declare 关键字。通过使用 declare 关键字,我们可以声明全局的变量、方法、类、对象。下面我们先说一下如何声明全局的变量。
declare 变量
在运行时,前端代码
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 行就可以直接访问枚举的成员了(这其实就是我们在 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 类型定义的三方库或者文件补齐类型定义,如下示例:
// 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 方法。说明:关于声明文件的知识点,我们一会再介绍,目前只需要知道声明文件是一个以.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; // => 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 代表的实际意义,则需要看它所在的上下文。
使用声明文件
安装 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
前面我们介绍了如何为 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 {
// 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 属性的。
不可合并
介绍类类型时我们说过,定义一个类类型,相当于定义了一个类,又定义了一个类的类型。因此,对于类这个既是值又是类型的特殊对象不能合并。除了可以通过接口和命名空间合并的方式扩展原来声明的类型外,我们还可以通过扩展模块或扩展全局对象来增强类型系统。
扩充模块
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 不会报错。
总结
声明的基本语法和如何使用声明文件,如何扩展类型定义以及模块类型,掌握了这些技巧不但可以扩展增强原模块,还能修改原模块的类型定义。