TypeScript 的声明文件的使用与编写

该文章根据 CC-BY-4.0 协议发表,转载请遵循该协议。
本文地址:https://fenying.net/post/2016/09/19/how-to-write-typescript-definitions/

文章目录

本文分享个人编写 TypeScript 声明文件的经验。

1. 什么是声明文件?

TypeScript 是 JavaScript 的超集,相比 JavaScript,其最关键的功能是静态类型 检查 (Type Guard)。然而 JavaScript 本身是没有静态类型检查功能的,TypeScript 编译器也仅提供了 ECMAScript 标准里的标准库类型声明,只能识别 TypeScript 代码 里的类型。

那么 TypeScript 中如何引用一个 JavaScript 文件呢?例如使用 lodash,async 等 著名的 JavaScript 第三方库。答案是通过声明文件(Declaration Files)

这和 C/C++ 的 *.h 头文件(Header files)非常相似:当你在 C/C++ 程序中引 用了一个第三方库(.lib/.dll/.so/.a/.la)时,C/C++ 编译器无法自动地识别库内 导出名称和函数类型签名等,这就需要使用头文件进行接口声明了。

同理地,TypeScript 的声明文件是一个以 .d.ts 为后缀的 TypeScript 代码文件, 但它的作用是描述一个 JavaScript 模块(广义上的)内所有导出接口的类型信息。

为了简洁,下面把 声明文件 简称为 Definition

1.1. 网页上引用非模块化的 JavaScript 文件里的名称

1// <script src="sample-00.js"></script>
2// 通过 script 标签引入名称到 JS 的全局命名空间中。
3var name = "Mick";
4
5function test(inStr) {
6
7    return inStr.substr(0, 4);
8}

在另一个 TypeScript 文件里引用里面的名称,不可用

1// File: test-01.ts
2console.log(name); // 编译报错,name 不存在。
3console.log(test("hello")); // 编译报错,test 不存在。

因为 TypeScript 不能从纯 JavaScript 文件里摘取类型信息,所以 TypeScript 的 编译器根本不知道变量 name 的存在。这一点和 C/C++ 非常相似,而解决方法也几乎 一致:使用一个 Definition,把这个变量声明写进去,让其它需要使用这个变量的文件引用。

1// File sample-00.d.ts
2declare let name: string;
3declare let test: (inStr: string) => string;

在 TypeScript 文件里使用 三斜线指令 引用这个文件:

1// File: test-01.ts
2/// <reference path="./sample-00.d.ts">
3console.log(name); // 编译通过。
4console.log(test("hello")); // 编译通过。

1.2. 使用第三方库

第三方库 async 也是纯 JavaScript 库,没有类型信息。要在 TypeScript 中使用, 可以到 DefinitelyTyped 组织的 GitHub 仓库里面下载一份 async.d.ts 文件,将之引用进来。

 1// File: test-02.ts
 2/// <reference path="./async.d.ts">
 3import async = require("async");
 4
 5async.series([
 6
 7    function(next: ErrorCallback): void {
 8
 9        console.log(1);
10        next();
11    },
12
13    function(next: ErrorCallback): void {
14
15        console.log(2);
16        next();
17    },
18
19    function(next: ErrorCallback): void {
20
21        console.log(3);
22        next();
23    }
24
25], function(err?: Error): void {
26
27    if (err) {
28
29        console.log(err);
30
31        return;
32    }
33
34    console.log("Done");
35
36});

但是一个个库都去下载对应的 Definition ,实在太麻烦了,也不方便管理,所以我们可以 使用 DefinitelyTyped 组织提供的声明管理器——typings。

2. 使用 typings 声明管理器

2.1. 安装与基本使用

typings 是一个用 Node.js 编写的工具,托管在 NPM 仓库里,通过下面的命令可以安装

1npm install typings -g

现在我们要安装 async 库的 Definition 就简单了,直接一句命令行

1typings install dt~async --global

提示:install 命令可以缩写为 i,且可以一次安装多个 Definition 。

参数 --global 意义请参考我另一篇文章《TypeScript 的两种声明文件写法的 区别和根本意义》。 --global 可简写为 -G

这样, async 库的 Definition 就会被安装到 ./typings/globals/async/index.d.ts

可以自由地使用 async 库的 Definition 了。

如果你觉得这个路径太长了,可以使用 ./typings/index.d.ts 这个文件。这是一个 统一索引文件,使用 typings 工具安装的所有 Definition 都会被引用添加到这个 文件里,所以通过引用这个文件,就可以轻松引用所有安装过的 Definition !

2.2. Definition 的源

还有,安装 Definition 命令里的 dt~async 是什么东西?async 当然是一个库的 名称。那 dt 呢?其实 dt 是指,表示这个 Definition 的来源。目前绝大 多数的库 Definition 都是托管在 DefinitelyTyped 项目的 GitHub 仓库里面的,所以 使用 dt~库名称 可以找到绝大部分库的 Definition 。

如果你不确定某个库 Definition 的源,可以使用下面的命令查找

1typings search --name 库准确名称

一个输出例子是:

1$ typings search --name jquery
2Viewing 1 of 1
3
4NAME   SOURCE HOMEPAGE           DESCRIPTION VERSIONS UPDATED
5jquery dt     http://jquery.com/             1        2016-09-08T20:32:39.000Z

可以看出,jquery 库 Definition 信息是存在的,对应的 源(SOURCE) 是 dt

2.3. 安装某个库特定版本的 Definition

2016 年 9 月初,很多人发现通过 typings 安装的 env~node 在 TS 编译输出为 ES5 标准的情况下不可用了,编译报错。原因是 DefinitelyTyped 库将 env~node 的最新 版本更新为 6.0 版本,只支持 ES6 标准了。这导致很多编译目标为 ES5 甚至 ES3 的项目 都因为无法识别里面的 ES6 标准元素而出错。

解决方案是安装特定的兼容分支即可,如何安装特定版本的 Definition 呢?首先,通过 typings 工具的 info 命令查看某个库声明的分支信息。例如:

1$ typings info env~node --versions
2TAG                   VERSION LOCATION                                                            UPDATED
36.0.0+20160902022231  6.0.0   github:types/env-node/6#30804787ed04e4d475046ef0335bef502f492da0    2016-09-02T02:22:31.000Z
44.0.0+20160902022231  4.0.0   github:types/env-node/4#30804787ed04e4d475046ef0335bef502f492da0    2016-09-02T02:22:31.000Z
50.12.0+20160902022231 0.12.0  github:types/env-node/0.12#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
60.11.0+20160902022231 0.11.0  github:types/env-node/0.11#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
70.10.0+20160902022231 0.10.0  github:types/env-node/0.10#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
80.8.0+20160902022231  0.8.0   github:types/env-node/0.8#30804787ed04e4d475046ef0335bef502f492da0  2016-09-02T02:22:31.000Z

可以看到 env~node 有 6 个分支(Tag),对应 Node.js 的不同版本。

这些分支对 Node.js 是版本号,但对于 typings 它们都是分支,而不是版本!

然后通过

1typings i env~node#4.0.0+20160902022231 --global

就安装好了。

2.4. 从 GitHub 仓库安装 Definition

可以使用 typings 从指定的 GitHub 仓库里下载安装 Definition

命令格式有两种:

1# 文件式
2typings i github:用户名/项目名称/文件路径 --global

1# 仓库式
2typings i github:用户名/项目名称 --global

2.4.1. 直接安装仓库里的某个文件作为 Definition

1# 文件式
2# 安装这个文件的最新 commit 版本
3typings i github:DefinitelyTyped/DefinitelyTyped/express/express.d.ts --global

2.4.2. 使用特定 commit 版本作为 Definition

1# 文件式
2# 安装这个文件的 commit=5fd6d6b4eaabda87d19ad13b7d6709443617ddd8 的版本
3typings i github:DefinitelyTyped/DefinitelyTyped/express/express.d.ts#5fd6d6b4eaabda87d19ad13b7d6709443617ddd8 --global

2.4.3. 使用专用的 GitHub 仓库

假设我为一个叫 ABCDEFG 的库写了一个 Definition,现在我要把它发布到 GitHub 上作 为 typings 源。那么先建立一个 GitHub 项目,名字随意,这里假设是 https://github.com/sample/abcdefg-typings

把 Definition 取名为 index.d.ts,再添加一个文件 typings.json,内容如下:

1{
2  "name": "abcdefg",
3  "main": "index.d.ts",
4  "version": "0.1.0-demo"
5}

index.d.tstypings.json 两个文件提交到 GitHub 的 sample/abcdefg-typings 仓库。现在,我们可以通过下面的命令安装了。

1# 仓库式
2# 安装这个仓库的最新 commit 版本
3typings i github:sample/abcdefg-typings --global

安装成功后可以看到控制台提示

1typings WARN badlocation "github:sample/abcdefg-typings" is mutable and may change, consider specifying a commit hash
2[email protected]
3`-- (No dependencies)

那句警告的意思是建议使用一个 commit ID,这个就随意了。

2.5. 使用 typings.json 管理 Definition

看了上面的用法,为了更方便的管理一个项目依赖的 Definition (比如更新版本), typings 需要使用一个名为 typings.json 文件来记录我们安装过的 Definition 。

先初始化它,

1typings init

这个命令初始化了 typings.json 文件,内容是一个空的 Definition 依赖记录表:

1{
2  "dependencies": {}
3}

现在我们来安装 Definition ,并记录到表中:

1typings i env~node dt~async --global --save

后面的 --save(可简写为 -S) 会将 Definition 信息添加到 Definition 依赖记录表, 比如现在的 typings.json 文件内容如下:

1{
2  "dependencies": {},
3  "globalDependencies": {
4    "async": "registry:dt/async#2.0.1+20160804113311",
5    "node": "registry:dt/node#6.0.0+20160915134512"
6  }
7}

这样,发布项目时或者上传代码到 GitHub 的时候,typings 目录就可有可无了,需要的 时候直接一句 typings i 就完成了 Definition 的安装。需要注意的是,typings 默认安装最新版本的 Definition,如果你不想每次都安装最新的,可以通过 2.4. 从 GitHub 仓库安装 Definition 的方法解决。

3. 编写 Definition

前面讲了很多关于如何使用 Definition 的内容,那都是“用”,下面来讲讲如何自己写 一个 Definition。

3.1 Node.js 与 NPM 模块

NPM 在某个项目内本地安装的模块都在项目的 ./node_modules 目录下,一个模块一个 目录,以模块名称为目录名。

对于一个 NPM 模块,通过里面的 package.json 文件的 main 字段可以指定其默认的 入口文件。在 Node.js 里通过 require("模块名称") 引用的就是这个默认的入口 文件。如果未指定 package.json 文件的 main 字段,但是存在 index.js 文件, 那么 index.js 也会被当成默认的入口文件。

除此之外,在 Node.js 里面还可以单独引用 NPM 模块的其中一个文件,而不只是通过 默认入口文件引用模块。例如:

1var sample = require("sample");
2var lib1 = require("sample/lib1");
3var lib2 = require("sample/lib2");

现在假设这三个文件的代码如下,我们将在后面以这三个文件为基础编写 Definition:

 1// File: ./node_modules/sample/index.js
 2var abc = 321;
 3exports.setABC = function(abcValue) {
 4    abc = abcValue;
 5};
 6exports.getABC = function() {
 7    return abc;
 8};
 9
10exports.defaultABC = abc;
 1// File: ./node_modules/sample/lib1.js
 2var Hello = (function () {
 3    function Hello(a) {
 4        this.valueA = a;
 5    }
 6    Object.defineProperty(Hello.prototype, "a", {
 7        get: function () {
 8            return this.valueA;
 9        },
10        enumerable: true,
11        configurable: true
12    });
13    Hello.initClass = function () {
14        Hello.instCount = 0;
15    };
16    /**
17     * 假设这是一个重载函数,支持多种调用方式
18     */
19    Hello.prototype.setup = function (x, b) {
20        if (b === void 0) { b = null; }
21        return false;
22    };
23    return Hello;
24}());
25exports.Hello = Hello;
 1// File: ./node_modules/sample/lib2.js
 2
 3var randStrSeed = "abcdefghijklmnopqrstuvwxyz0123456789";
 4
 5function randomString(length) {
 6
 7    var ret = "";
 8
 9    while (length-- > 0) {
10
11        ret += randStrSeed[Math.floor(Math.random() * randStrSeed.length)];
12    }
13
14    return ret;
15}
16
17module.exports = randomString;

这是三个典型的模块类型,第一个导出了变量和函数,第二个导出了一个类,第三个则将 一个函数作为一个模块导出。

现在我们以这三个文件为例,分别以模块导出声明 (External Module Definition)全局类型声明(Global Type Definition) 两种写法编写 Definition。

3.2. 全局类型声明写法

假如上面的3个文件同属一个模块 sample,但是它并不是我们自己在 NPM 上发布的, 即是说我们无权给它添加内建 Definition。所以我们用全局类型声明写法。

这是一个不是很复杂的模块,那么我们用一个 .d.ts 文件就可以了。

第一个文件是模块的入口文件,可以直接当成模块 sample。定义如下:

 1declare module "sample" {
 2
 3    // 导出函数 setABC
 4    export function setABC(abcValue: number): void;
 5
 6    // 导出函数 getABC
 7    export function getABC(): number;
 8
 9    // 导出变量 defaultABC
10    export let defaultABC: number;
11}

第二个文件是导出了两个类,可以当成模块 “sample/lib1”。下面来看看如何导出类。

这个类里面有构造函数,有静态方法,有普通方法,有属性,也有静态属性,还有 getter。

类有两种声明编写方式:标准式分离式

标准式很直接,就是像 C/C++ 的头文件里声明类一样只写类声明不写实现:

 1declare module "sample/lib1" {
 2
 3    export class Hello {
 4
 5        private valueA;
 6
 7        b: number;
 8
 9        static instCount: number;
10
11        a: number;
12
13        constructor(a: number);
14
15        static initClass(): void;
16
17        /**
18        * 假设这是一个重载函数,支持多种调用方式
19        */
20        setup(name: string): boolean;
21
22        setup(name: string, age: number): boolean;
23    }
24}

但是这种写法也有不便的地方,比如扩展类不方便——JavaScript允许你随时扩展一个类的原型 对象实现对类的扩展,或者随时给类添加静态成员。标准式写法很难实现扩展,因为你无法 重复声明一个类。

所以下面我们来看看所谓的分离式声明。在这之前我们要理解,JS 的类是用函数实现的, 即是说 JS 的类本质上就是一个构造函数 + Prototype。Prototype 的成员就是类的成员; 而类的静态方法就是这个构造函数对象本身的成员方法。

因此我们可以分开写这两者的声明:

 1declare module "sample/lib1" {
 2
 3    /**
 4     * 在分离式写法里面,一个类的 Prototype 的声明是一个直接以类名称为名的
 5     * interface。我们把成员函数和变量/getter/setter 都行写在 prototype
 6     * 的接口里面。
 7     *
 8     * 注意:类原型的 interface 取名与类名一致。 
 9     */
10    export interface Hello {
11
12        /**
13         * 接口里面只写类的 public 属性
14         */
15        "b": number;
16
17        /**
18         * Getter/Setter 直接成属性即可。
19         */
20        "a": number;
21
22        /**
23         * 重载函数的声明写法
24         */ 
25        setup(name: string): boolean;
26        setup(name: string, age: number): boolean;
27    }
28
29    /**
30     * 在分离式写法里面,一个类的构造函数对象也是一个 interface ,但是对
31     * 其命名无具体要求,合理即可。
32     * 
33     * 把类的静态方法和属性都写在这里面。
34     */
35    export interface HelloConstructor {
36
37        /**
38         * 静态属性
39         */
40        "instCount": number;
41
42        /**
43         * 静态方法
44         */
45        initClass(): void;
46
47        /**
48         * 构造函数!
49         * 使用 new 代替 constructor,并声明其返回值类型是该类的Prototype。
50         */
51        new(a: number): Hello;
52    }
53
54    /**
55     * 将 Hello 覆盖声明为 HelloConstructor。
56     * 
57     * 这样,在需要作为类使用的时候它就是 HelloConstructor,
58     * 需要作为接口使用的时候就是 Hello(原型接口)。
59     */
60    export let Hello: HelloConstructor;
61}

如上,就是导出类的两种姿势~

接着看第三个文件,直接将一个函数作为模块导出,也是很简单的。

1declare module "sample/lib2" {
2
3    let randomString: (length: number) => string;
4
5    export = randomString;
6}

最后把 3 个模块的声明合并成一个文件 sample.d.ts,在文件里用三斜线指令引用即可。

3.3. 模块导出声明写法

模块导出声明写法里面不用注明是哪个模块,一般给每个导出的文件都配备一个以 .d.ts 为后缀的 Definition。

  • 文件 ./node_modules/sample/index.d.ts

    1// File: ./node_modules/sample/index.d.ts
    2// 导出函数 setABC
    3export declare function setABC(abcValue: number): void;
    4
    5// 导出函数 getABC
    6export declare function getABC(): number;
    7
    8// 导出变量 defaultABC
    9export declare let defaultABC: number;
    
  • 文件 ./node_modules/sample/lib1.d.ts

     1// File: ./node_modules/sample/lib1.d.ts
     2
     3export class Hello {
     4
     5    private valueA;
     6
     7    b: number;
     8
     9    static instCount: number;
    10
    11    a: number;
    12
    13    constructor(a: number);
    14
    15    static initClass(): void;
    16
    17    /**
    18    * 假设这是一个重载函数,支持多种调用方式
    19    */
    20    setup(name: string): boolean;
    21
    22    setup(name: string, age: number): boolean;
    23}
    
  • 文件 ./node_modules/sample/lib2.d.ts

    1// File: ./node_modules/sample/lib2.d.ts
    2
    3let randomString: (length: number) => string;
    4
    5export = randomString;
    

3.4. 如何确定现有类的声明接口名称?

以确认 String 类的声明接口名称为例。

在 TypeScript 源码的 lib.d.ts 里面可以找到:

1declare var String: StringConstructor;

这就是 String 类的构造函数了,即是说 StringConstructor 定义了 String 的静态方法。

使用如 Visual Studio Code 的编辑器,在里面随意打开一个 *.ts 文件, 然后输入比如 String ,鼠标移动上去,可以看到类型定义。

然后查看 StringConstructor 的定义:

 1/*
 2 * 全局类/对象的声明都是在 lib.d.ts 里面定义的,即是说 TypeScript 通常会
 3 * 默认引用一个 lib.d.ts 文件,所以这里面的内容无需引用声明即可使用。
 4 *
 5 * 也正因此 StringConstructor 不需要 declare 和 export。
 6 *
 7 */
 8interface StringConstructor {
 9    new (value?: any): String;
10    (value?: any): string;
11    prototype: String;
12    fromCharCode(...codes: number[]): string;
13}

这里可以看出,String 类的构造函数的声明是接口 StringConstructor, 而其 String.prototype 的声明是接口 String,显然用了分离式写法。

1interface String {
2
3    //...
4}

3.5. 扩展 JavaScript 全局类/对象

前面我们实现了一个模块的声明文件。

以 langext 的代码为例,试图为 JS 原生的 String 类添加一个 random 静态方法。

如果直接写:

1String.random = function(len: number): string {
2
3    return '...';
4};

是无法通过编译的,因为 TS 的类型检查,根据既有的 String 类定义,发现 random 不是 String 类的静态成员。

解决方法是使用一个声明文件,在里面写:

1interface StringConstructor {
2
3    random(length: number): string;
4}

然后引用这个定义文件即可。

这是利用了 TS 的 interface 可分离定义特性,同名的 interface,只要字段定义不冲突 就可以分开定义。【参考 4.2 节】

4. 编写 Definition 的注意事项

4.1. 不要使用内层 declare

只能在 Definition 的顶层使用 declare,比如下面的写法都是错误的:

 1declare module "sample" {
 2
 3    // 此处应当使用 export
 4    declare let a: string;
 5}
 6
 7declare namespace Sample {
 8
 9    // 此处应当使用 export
10    declare let a: string;
11}

4.2. 避免全局污染

虽然全局声明写法允许你引入名称到全局命名空间中,但这也意味着,引入的顶层名称 都是全局的。所以应该将所有的模块内导出的元素都放进模块或者命名空间内:

 1declare module "sample" {
 2
 3    /**
 4     * 仅可通过 import { Person } from "sample" 访问。
 5     */
 6    export interface Person {
 7
 8        name: string;
 9    }
10}
11
12declare namespace Sample {
13
14    export interface Animal {
15
16        type: string;
17    }
18}

而不是

1/**
2 * 无需 import 即可使用,即全局的
3 */
4interface Person {
5
6    name: string;
7}

不过以下情况例外:

  1. 当在扩展全局对象/类的时候,允许这么写

    1interface StringConstructor {
    2
    3    random(length: number): string;
    4}
    
  2. 当确实引入了新的全局名称时,比如 script 里的全局变量

    1declare let globalName: string;
    

4.3. 注意声明冲突

module 和 namespace 都是可以重复声明的——但是里面的元素不能冲突。具体如下:

 1declare module "sample" {
 2
 3    export let name: string;
 4
 5    export interface ABC {
 6
 7        value: string;
 8    }
 9}
10
11declare module "sample" {
12
13    // 冲突,因为 sample 模块里已经有了导出变量 name
14    export let name: string;
15
16    // 不冲突,因为两个内容不重复的重名 interface 可以合并。
17    export interface ABC {
18
19        
20        name: string;
21    }
22}
23
24declare module "sample" {
25
26    // 冲突,因为前面的 sample.ABC 里面已经定义了 value 字段。
27    export interface ABC {
28
29        
30        value: string;
31    }
32}

4.4. 模块名称要区分大小写!

这一点对于 Windows 上的 Node.js 开发人员很致命!因为在 Windows 下文件名不区分 大小写,所以你不区分大小写都可以成功引用模块, 但是,Node.js 并不认为仅仅名称大小写不一致的两个文件是同一个模块!

这将导致一个严重的后果——同一个模块被初始化为不同名称(大小写不一致)的多个实例, 导致各处引用的不是同一个实例,从而造成数据不同步。

comments powered by Disqus