TypeScript 的聲明文件的使用與編寫


https://fenying.net/2016/09/19/typings-for-typescript/

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

網頁上引用非模塊化的 JavaScript 文件里的名稱

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

在另一個 TypeScript 文件里引用里面的名稱,不可用

1
2
3
// File: test-01.ts
console.log(name); // 編譯報錯,name 不存在。
console.log(test("hello")); // 編譯報錯,test 不存在。

因為 TypeScript 不能從純 JavaScript 文件里摘取類型信息,所以 TypeScript 的編譯器根本不知道變量 name 的存在。這一點和 C/C++ 非常相似,而解決方法也幾乎一致:使用一個 Definition,把這個變量聲明寫進去,讓其它需要使用這個變量的文件引用。

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

在 TypeScript 文件里使用 三斜線指令 引用這個文件:

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

使用第三方庫

第三方庫 async 也是純 JavaScript 庫,沒有類型信息。要在 TypeScript 中使用,可以到 DefinitelyTyped 組織的 GitHub 倉庫里面下載一份 async.d.ts 文件,將之引用進來。

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

但是一個個庫都去下載對應的 Definition ,實在太麻煩了,也不方便管理,所以我們可以使用 DefinitelyTyped 組織提供的聲明管理器——typings。

使用 typings 聲明管理器

安裝與基本使用

typings 是一個用 Node.js 編寫的工具,托管在 NPM 倉庫里,通過下面的命令可以安裝

1
npm install typings -g

現在我們要安裝 async 庫的 Definition 就簡單了,直接一句命令行

1
typings 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 !

Definition 的源

還有,安裝 Definition 命令里的 dt~async 是什么東西?async 當然是一個庫的名稱。那 dt 呢?其實 dt 是指,表示這個 Definition 的來源。目前絕大多數的庫 Definition 都是托管在 DefinitelyTyped 項目的 GitHub 倉庫里面的,所以使用 dt~庫名稱 可以找到絕大部分庫的 Definition 。

如果你不確定某個庫 Definition 的源,可以使用下面的命令查找

1
typings search --name 庫准確名稱

一個輸出例子是:

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

可以看出,jquery 庫 Definition 信息是存在的,對應的 源(SOURCE) 是 dt

安裝某個庫特定版本的 Definition

2016 年 9 月初,很多人發現通過 typings 安裝的 env~node 在 TS 編譯輸出為 ES5 標准的情況下不可用了,編譯報錯。原因是 DefinitelyTyped 庫將 env~node 的最新版本更新為 6.0 版本,只支持 ES6 標准了。這導致很多編譯目標為 ES5 甚至 ES3 的項目都因為無法識別里面的 ES6 標准元素而出錯。

解決方案是安裝特定的兼容分支即可,如何安裝特定版本的 Definition 呢?首先,通過 typings 工具的 info 命令查看某個庫聲明的分支信息。例如:

1
2
3
4
5
6
7
8
$ typings info env~node --versions
TAG VERSION LOCATION UPDATED
6.0.0+20160902022231 6.0.0 github:types/env-node/6#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
4.0.0+20160902022231 4.0.0 github:types/env-node/4#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
0.12.0+20160902022231 0.12.0 github:types/env-node/0.12#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
0.11.0+20160902022231 0.11.0 github:types/env-node/0.11#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
0.10.0+20160902022231 0.10.0 github:types/env-node/0.10#30804787ed04e4d475046ef0335bef502f492da0 2016-09-02T02:22:31.000Z
0.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 它們都是分支,而不是版本!

然后通過

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

就安裝好了。

從 GitHub 倉庫安裝 Definition

可以使用 typings 從指定的 GitHub 倉庫里下載安裝 Definition

命令格式有兩種:

1
2
# 文件式
typings i github:用戶名/項目名稱/文件路徑 --global

1
2
# 倉庫式
typings i github:用戶名/項目名稱 --global

直接安裝倉庫里的某個文件作為 Definition

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

使用特定 commit 版本作為 Definition

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

使用專用的 GitHub 倉庫

假設我為一個叫 ABCDEFG 的庫寫了一個 Definition,現在我要把它發布到 GitHub 上作為 typings 源。那么先建立一個 GitHub 項目,名字隨意,這里假設是 https://github.com/sample/abcdefg-typings

把 Definition 取名為 index.d.ts,再添加一個文件 typings.json,內容如下:

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

將 index.d.ts 和 typings.json 兩個文件提交到 GitHub 的 sample/abcdefg-typings 倉庫。現在,我們可以通過下面的命令安裝了。

1
2
3
# 倉庫式
# 安裝這個倉庫的最新 commit 版本
typings i github:sample/abcdefg-typings --global

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

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

那句警告的意思是建議使用一個 commit ID,這個就隨意了。

使用 typings.json 管理 Definition

看了上面的用法,為了更方便的管理一個項目依賴的 Definition (比如更新版本),typings 需要使用一個名為 typings.json文件來記錄我們安裝過的 Definition 。

先初始化它,

1
typings init

這個命令初始化了 typings.json 文件,內容是一個空的 Definition 依賴記錄表:

1
2
3
{
"dependencies": {}
}

現在我們來安裝 Definition ,並記錄到表中:

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

后面的 --save(可簡寫為 -S) 會將 Definition 信息添加到 Definition 依賴記錄表,比如現在的 typings.json 文件內容如下:

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

這樣,發布項目時或者上傳代碼到 GitHub 的時候,typings 目錄就可有可無了,需要的時候直接一句 typings i 就完成了 Definition 的安裝。需要注意的是,typings 默認安裝最新版本的 Definition,如果你不想每次都安裝最新的,可以通過 2.4. 從 GitHub 倉庫安裝 Definition 的方法解決。

編寫 Definition

前面講了很多關於如何使用 Definition 的內容,那都是“用”,下面來講講如何自己寫一個 Definition。

###1 Node.js 與 NPM 模塊

NPM 在某個項目內本地安裝的模塊都在項目的 ./node_modules 目錄下,一個模塊一個目錄,以模塊名稱為目錄名。

對於一個 NPM 模塊,通過里面的 package.json 文件的 main 字段可以指定其默認的入口文件。在 Node.js 里通過 require("模塊名稱") 引用的就是這個默認的入口文件。如果未指定 package.json 文件的 main 字段,但是存在 index.js 文件,那么 index.js 也會被當成默認的入口文件。

除此之外,在 Node.js 里面還可以單獨引用 NPM 模塊的其中一個文件,而不只是通過默認入口文件引用模塊。例如:

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

現在假設這三個文件的代碼如下,我們將在后面以這三個文件為基礎編寫 Definition:

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

這是三個典型的模塊類型,第一個導出了變量和函數,第二個導出了一個類,第三個則將一個函數作為一個模塊導出。

現在我們以這三個文件為例,分別以模塊導出聲明 (External Module Definition) 和 全局類型聲明(Global Type Definition) 兩種寫法編寫 Definition。

全局類型聲明寫法

假如上面的3個文件同屬一個模塊 sample,但是它並不是我們自己在 NPM 上發布的,即是說我們無權給它添加內建 Definition。所以我們用全局類型聲明寫法。

這是一個不是很復雜的模塊,那么我們用一個 .d.ts 文件就可以了。

第一個文件是模塊的入口文件,可以直接當成模塊 sample。定義如下:

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

第二個文件是導出了兩個類,可以當成模塊 “sample/lib1”。下面來看看如何導出類。

這個類里面有構造函數,有靜態方法,有普通方法,有屬性,也有靜態屬性,還有 getter。

類有兩種聲明編寫方式:標准式分離式

標准式很直接,就是像 C/C++ 的頭文件里聲明類一樣只寫類聲明不寫實現:

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

但是這種寫法也有不便的地方,比如擴展類不方便——JavaScript允許你隨時擴展一個類的原型對象實現對類的擴展,或者隨時給類添加靜態成員。標准式寫法很難實現擴展,因為你無法重復聲明一個類。

所以下面我們來看看所謂的分離式聲明。在這之前我們要理解,JS 的類是用函數實現的,即是說 JS 的類本質上就是一個構造函數 + Prototype。Prototype 的成員就是類的成員;而類的靜態方法就是這個構造函數對象本身的成員方法。

因此我們可以分開寫這兩者的聲明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
declare module "sample/lib1" {
 
/**
* 在分離式寫法里面,一個類的 Prototype 的聲明是一個直接以類名稱為名的
* interface。我們把成員函數和變量/getter/setter 都行寫在 prototype
* 的接口里面。
*
* 注意:類原型的 interface 取名與類名一致。
*/
export interface Hello {
 
/**
* 接口里面只寫類的 public 屬性
*/
"b": number;
 
/**
* Getter/Setter 直接成屬性即可。
*/
"a": number;
 
/**
* 重載函數的聲明寫法
*/
setup(name: string): boolean;
setup(name: string, age: number): boolean;
}
 
/**
* 在分離式寫法里面,一個類的構造函數對象也是一個 interface ,但是對
* 其命名無具體要求,合理即可。
*
* 把類的靜態方法和屬性都寫在這里面。
*/
export interface HelloConstructor {
 
/**
* 靜態屬性
*/
"instCount": number;
 
/**
* 靜態方法
*/
initClass(): void;
 
/**
* 構造函數!
* 使用 new 代替 constructor,並聲明其返回值類型是該類的Prototype。
*/
new(a: number): Hello;
}
 
/**
* 將 Hello 覆蓋聲明為 HelloConstructor。
*
* 這樣,在需要作為類使用的時候它就是 HelloConstructor,
* 需要作為接口使用的時候就是 Hello(原型接口)。
*/
export let Hello: HelloConstructor;
}

如上,就是導出類的兩種姿勢~

接着看第三個文件,直接將一個函數作為模塊導出,也是很簡單的。

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

最后把 3 個模塊的聲明合並成一個文件 sample.d.ts,在文件里用三斜線指令引用即可。

模塊導出聲明寫法

模塊導出聲明寫法里面不用注明是哪個模塊,一般給每個導出的文件都配備一個以 .d.ts 為后綴的 Definition。

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

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

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

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

如何確定現有類的聲明接口名稱?

以確認 String 類的聲明接口名稱為例。

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

1
declare var String: StringConstructor;

這就是 String 類的構造函數了,即是說 StringConstructor 定義了 String 的靜態方法。

使用如 Visual Studio Code 的編輯器,在里面隨意打開一個 *.ts 文件,然后輸入比如 String ,鼠標移動上去,可以看到類型定義。

然后查看 StringConstructor 的定義:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* 全局類/對象的聲明都是在 lib.d.ts 里面定義的,即是說 TypeScript 通常會
* 默認引用一個 lib.d.ts 文件,所以這里面的內容無需引用聲明即可使用。
*
* 也正因此 StringConstructor 不需要 declare 和 export。
*
*/
interface StringConstructor {
new (value?: any): String;
(value?: any): string;
prototype: String;
fromCharCode(...codes: number[]): string;
}

這里可以看出,String 類的構造函數的聲明是接口 StringConstructor,而其 String.prototype 的聲明是接口 String,顯然用了分離式寫法。

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

擴展 JavaScript 全局類/對象

前面我們實現了一個模塊的聲明文件。

以 langext 的代碼為例,試圖為 JS 原生的 String 類添加一個 random 靜態方法。

如果直接寫:

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

是無法通過編譯的,因為 TS 的類型檢查,根據既有的 String 類定義,發現 random 不是 String 類的靜態成員。

解決方法是使用一個聲明文件,在里面寫:

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

然后引用這個定義文件即可。

這是利用了 TS 的 interface 可分離定義特性,同名的 interface,只要字段定義不沖突就可以分開定義。【參考 4.2 節】

編寫 Definition 的注意事項

不要使用內層 declare

只能在 Definition 的頂層使用 declare,比如下面的寫法都是錯誤的:

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

避免全局污染

雖然全局聲明寫法允許你引入名稱到全局命名空間中,但這也意味着,引入的頂層名稱都是全局的。所以應該將所有的模塊內導出的元素都放進模塊或者命名空間內:

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

而不是

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

不過以下情況例外:

  1. 當在擴展全局對象/類的時候,允許這么寫

    1
    2
    3
    4
    interface StringConstructor {
     
    random(length: number): string;
    }
  2. 當確實引入了新的全局名稱時,比如 script 里的全局變量

    1
    declare let globalName: string;

注意聲明沖突

module 和 namespace 都是可以重復聲明的——但是里面的元素不能沖突。具體如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
declare module "sample" {
 
export let name: string;
 
export interface ABC {
 
value: string;
}
}
 
declare module "sample" {
 
// 沖突,因為 sample 模塊里已經有了導出變量 name
export let name: string;
 
// 不沖突,因為兩個內容不重復的重名 interface 可以合並。
export interface ABC {
 
 
name: string;
}
}
 
declare module "sample" {
 
// 沖突,因為前面的 sample.ABC 里面已經定義了 value 字段。
export interface ABC {
 
 
value: string;
}
}

模塊名稱要區分大小寫!

這一點對於 Windows 上的 Node.js 開發人員很致命!因為在 Windows 下文件名不區分大小寫,所以你不區分大小寫都可以成功引用模塊,但是,Node.js 並不認為僅僅名稱大小寫不一致的兩個文件是同一個模塊!

這將導致一個嚴重的后果——同一個模塊被初始化為不同名稱(大小寫不一致)的多個實例,導致各處引用的不是同一個實例,從而造成數據不同步。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM