为第三方 npm 包编写声明文件

1. 如何查看当前包是否有现成的声明文件?

在我们尝试给一个 npm 包创建声明文件之前,需要先看看它的声明文件是否已经存在。一般来说,npm 包的声明文件可能存在于两个地方:

  1. 与该 npm 包绑定在一起。判断依据是 package.json 中有 types 字段,或者有一个 index.d.ts 声明文件。这种模式不需要额外安装其他包,是最为推荐的,所以以后我们自己创建 npm 包的时候,最好也将声明文件与 npm 包绑定在一起。
  2. 发布到 @types 里。我们只需要尝试安装一下对应的 @types 包就知道是否存在该声明文件,安装命令是 npm install @types/foo --save-dev。这种模式一般是由于 npm 包的维护者没有提供声明文件,所以只能由其他人将声明文件发布到 @types 里了。

2. 自己编写的声明文件放在哪儿?

假如以上两种方式都没有找到对应的声明文件,那么我们就需要自己为它写声明文件了。由于是通过 import 语句导入的模块,所以声明文件存放的位置也有所约束,一般有两种方案:

  1. 创建一个 node_modules/@types/foo/index.d.ts 文件,存放 foo 模块的声明文件。这种方式不需要额外的配置,但是 node_modules 目录不稳定,代码也没有被保存到仓库中,无法回溯版本,有不小心被删除的风险,故不太建议用这种方案,一般只用作临时测试。
  2. 创建一个 types 目录,专门用来管理自己写的声明文件,将 foo 的声明文件放到 types/foo/index.d.ts 中。这种方式需要配置下 tsconfig.json 中的 pathsbaseUrl 字段。

目录结构:

1
2
3
4
5
6
7
8
/path/to/project
├── src
| └── index.ts
├── types
| └── foo
| └── index.d.ts
└── tsconfig.json

tsconfig.json 内容:

1
2
3
4
5
6
7
8
9
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"*": ["types/*"]
}
}
}

如此配置之后,通过 import 导入 foo 的时候,也会去 types 目录下寻找对应的模块的声明文件了。

3. 编写声明文件

npm 包的声明文件主要有以下几种语法:

export§

npm 包的声明文件与全局变量的声明文件有很大区别。在 npm 包的声明文件中,使用 declare 不再会声明一个全局变量,而只会在当前文件中声明一个局部变量。只有在声明文件中使用 export 导出,然后在使用方 import 导入后,才会应用到这些类型声明。

export 的语法与普通的 ts 中的语法类似,区别仅在于声明文件中禁止定义具体的实现15

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// types/foo/index.d.ts

export const name: string;
export function getName(): string;
export class Animal {
constructor(name: string);
sayHi(): string;
}
export enum Directions {
Up,
Down,
Left,
Right
}
export interface Options {
data: any;
}

对应的导入和使用模块应该是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/index.ts

import { name, getName, Animal, Directions, Options } from 'foo';

console.log(name);
let myName = getName();
let cat = new Animal('Tom');
let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];
let options: Options = {
data: {
name: 'foo'
}
};

混用 declareexport§

我们也可以使用 declare 先声明多个变量,最后再用 export 一次性导出。上例的声明文件可以等价的改写为16

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// types/foo/index.d.ts

declare const name: string;
declare function getName(): string;
declare class Animal {
constructor(name: string);
sayHi(): string;
}
declare enum Directions {
Up,
Down,
Left,
Right
}
interface Options {
data: any;
}

export { name, getName, Animal, Directions, Options };

注意,与全局变量的声明文件类似,interface 前是不需要 declare 的。

export namespace§

declare namespace 类似,export namespace 用来导出一个拥有子属性的对象17

1
2
3
4
5
6
7
8
9
// types/foo/index.d.ts

export namespace foo {
const name: string;
namespace bar {
function baz(): string;
}
}

1
2
3
4
5
6
7
// src/index.ts

import { foo } from 'foo';

console.log(foo.name);
foo.bar.baz();

export default§

在 ES6 模块系统中,使用 export default 可以导出一个默认值,使用方可以用 import foo from 'foo' 而不是 import { foo } from 'foo' 来导入这个默认值。

在类型声明文件中,export default 用来导出默认值的类型18

1
2
3
4
// types/foo/index.d.ts

export default function foo(): string;

1
2
3
4
5
6
// src/index.ts

import foo from 'foo';

foo();

注意,只有 functionclassinterface 可以直接默认导出,其他的变量需要先定义出来,再默认导出19

1
2
3
4
5
6
7
8
9
10
// types/foo/index.d.ts

export default enum Directions {
// ERROR: Expression expected.
Up,
Down,
Left,
Right
}

上例中 export default enum 是错误的语法,需要使用 declare enum 定义出来,然后使用 export default 导出:

1
2
3
4
5
6
7
8
9
10
11
// types/foo/index.d.ts

declare enum Directions {
Up,
Down,
Left,
Right
}

export default Directions;

针对这种默认导出,我们一般会将导出语句放在整个声明文件的最前面20

1
2
3
4
5
6
7
8
9
10
11
// types/foo/index.d.ts

export default Directions;

declare enum Directions {
Up,
Down,
Left,
Right
}

export =§

在 commonjs 规范中,我们用以下方式来导出一个模块:

1
2
3
4
5
// 整体导出
module.exports = foo;
// 单个导出
exports.bar = bar;

在 ts 中,针对这种模块导出,有多种方式可以导入,第一种方式是 const ... = require

1
2
3
4
5
// 整体导入
const foo = require('foo');
// 单个导入
const bar = require('foo').bar;

第二种方式是 import ... from,注意针对整体导出,需要使用 import * as 来导入:

1
2
3
4
5
// 整体导入
import * as foo from 'foo';
// 单个导入
import { bar } from 'foo';

第三种方式是 import ... require,这也是 ts 官方推荐的方式:

1
2
3
4
5
// 整体导入
import foo = require('foo');
// 单个导入
import bar = foo.bar;

对于这种使用 commonjs 规范的库,假如要为它写类型声明文件的话,就需要使用到 export = 这种语法了21

1
2
3
4
5
6
7
8
9
// types/foo/index.d.ts

export = foo;

declare function foo(): string;
declare namespace foo {
const bar: number;
}

需要注意的是,上例中使用了 export = 之后,就不能再单个导出 export { bar } 了。所以我们通过声明合并,使用 declare namespace foo 来将 bar 合并到 foo 里。

准确地讲,export = 不仅可以用在声明文件中,也可以用在普通的 ts 文件中。实际上,import ... requireexport = 都是 ts 为了兼容 AMD 规范和 commonjs 规范而创立的新语法,由于并不常用也不推荐使用,所以这里就不详细介绍了,感兴趣的可以看官方文档

由于很多第三方库是 commonjs 规范的,所以声明文件也就不得不用到 export = 这种语法了。但是还是需要再强调下,相比与 export =,我们更推荐使用 ES6 标准的 export defaultexport