声明文件中存放的是一系列声明语句的集合,必须以**.d.ts**为后缀来命名。声明文件主要有两个作用:
- 当使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能;
- 在项目中把常用的声明类型提升到全局声明,减少重复声明。
1. 书写声明文件
当一个第三方库没有提供声明文件时,我们就需要自己书写声明文件了。书写一个声明文件并不是一件简单的事,在不同的场景下,声明文件的内容和使用方式会有所区别。
第三方库的应用场景主要有以下几种:
1.1 全局变量引入
通过<script>标签引入第三方库,注入全局变量。全局变量的声明文件主要有以下几种语法:
declare var/const/let声明全局变量declare function声明全局方法declare class声明全局类declare enum声明全局枚举类型declare namespace声明(含有子属性的)全局对象interface和type声明全局类型
1.1.1 declare var/const/let
在所有的声明语句中,declare var/const/let是最简单的,它能够用来定义一个全局变量的类型。
// jQuery.d.tsdeclare let jQuery: (selector: string) => any;// index.tsjQuery('#foo');// 使用 declare let 定义的 jQuery 类型,允许修改这个全局变量jQuery = function(selector) {return document.querySelector(selector);};
而当我们使用const定义时,表示此时的全局变量是一个常量,不允许再去修改它的值了:
// src/jQuery.d.tsdeclare const jQuery: (selector: string) => any;// index.tsjQuery('#foo');// 使用 declare const 定义的 jQuery 类型,禁止修改这个全局变量jQuery = function(selector) {return document.querySelector(selector);};// ERROR: Cannot assign to 'jQuery' because it is a constant or a read-only property.
一般来说,全局变量都是禁止修改的常量,所以大部分情况都应该使用const而不是var或let。
需要注意的是,声明语句中只能定义类型,切勿在声明语句中定义具体的实现:
// src/jQuery.d.tsdeclare const jQuery = function(selector) {return document.querySelector(selector);};// ERROR: An implementation cannot be declared in ambient contexts.
1.1.2 declare function
declare function用来定义全局函数的类型。jQuery其实就是一个函数,所以也可以用function来定义:
// jQuery.d.tsdeclare function jQuery(selector: string): any;// index.tsjQuery('#foo');
在函数类型的声明语句中,函数重载也是支持的:
// jQuery.d.tsdeclare function jQuery(selector: string): any;declare function jQuery(domReadyCallback: () => any): any;// index.tsjQuery('#foo');jQuery(function() {alert('Dom Ready!');});
1.1.3 declare class
当全局变量是一个类的时候,我们用declare class来定义它的类型:
// Animal.d.tsdeclare class Animal {name: string;constructor(name: string);sayHi(): string;}// index.tslet cat = new Animal('Tom');
同样的,declare class语句也只能用来定义类型,不能用来定义具体的实现,比如定义sayHi方法的具体实现则会报错:
// Animal.d.tsdeclare class Animal {name: string;constructor(name: string);sayHi() {return `My name is ${this.name}`;};// ERROR: An implementation cannot be declared in ambient contexts.}
1.1.4 declare enum
使用 declare enum 定义的枚举类型也称作外部枚举(Ambient Enums),举例如下:
// Directions.d.tsdeclare enum Directions {Up,Down,Left,Right}// index.tslet directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];
与其他全局变量的类型声明一致,declare enum仅用来定义类型,而不是具体的值。
Directions.d.ts仅仅会用于编译时的检查,声明文件里的内容在编译结果中会被删除。它编译结果是:
var directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];
其中Directions是由第三方库定义好的全局变量。
1.1.5 declare namespace
declare namespace用来表示全局变量是一个对象,包含很多子属性。
比如jQuery是一个全局变量,它是一个对象,提供了一个jQuery.ajax方法可以调用,那么我们就应该使用declare namespace jQuery来声明这个拥有多个子属性的全局变量。
// jQuery.d.tsdeclare namespace jQuery {function ajax(url: string, settings?: any): void;}// index.tsjQuery.ajax('/api/get_something');
注意,在declare namespace内部,我们直接使用function ajax来声明函数,而无需再使用declare来声明。类似的,也可以使用const,class,enum等语句:
// jQuery.d.tsdeclare namespace jQuery {function ajax(url: string, settings?: any): void;const version: number;class Event {blur(eventType: EventType): void}enum EventType {CustomClick}}// index.tsjQuery.ajax('/api/get_something');console.log(jQuery.version);const e = new jQuery.Event();e.blur(jQuery.EventType.CustomClick);
1.1.6 嵌套的 namespace
如果对象拥有深层的层级,则需要用嵌套的namespace来声明深层的属性的类型:
// jQuery.d.tsdeclare namespace jQuery {function ajax(url: string, settings?: any): void;namespace fn {function extend(object: any): void;}}// index.tsjQuery.ajax('/api/get_something');jQuery.fn.extend({check: function() {return this.each(function() {this.checked = true;});}});
假如jQuery下仅有fn这一个属性(没有ajax等其他属性或方法),则可以不需要嵌套namespace:
// jQuery.d.tsdeclare namespace jQuery.fn {function extend(object: any): void;}// index.tsjQuery.fn.extend({check: function() {return this.each(function() {this.checked = true;});}});
1.1.7 interface 和 type
除了全局变量之外,可能有一些类型我们也希望能暴露出来。在类型声明文件中,我们可以直接使用**interface**或**type**来声明一个全局的接口或类型,而无需**declare**声明:
// jQuery.d.tsinterface AjaxSettings {method?: 'GET' | 'POST';data?: any;}declare namespace jQuery {function ajax(url: string, settings?: AjaxSettings): void;}
这样的话,在其他文件中也可以使用这个接口或类型了:
// index.tslet settings: AjaxSettings = {method: 'POST',data: {name: 'foo'}};jQuery.ajax('/api/post_something', settings);
type与interface类似,不再赘述。
1.1.8 防止命名冲突
暴露在最外层的interface或type会作为全局类型作用于整个项目中,我们应该尽可能的减少全局变量或全局类型的数量。故最好将他们放到namespace下:
// jQuery.d.tsdeclare namespace jQuery {interface AjaxSettings {method?: 'GET' | 'POST'data?: any;}function ajax(url: string, settings?: AjaxSettings): void;}
注意,在使用这个interface的时候,也应该加上jQuery前缀:
// index.tslet settings: jQuery.AjaxSettings = {method: 'POST',data: {name: 'foo'}};jQuery.ajax('/api/post_something', settings);
namespace和interface的区别是,namespace是声明了一个变量,而interface声明的是一个类型,namespace中可能会用到interface类型。
1.1.9 声明合并
假如jQuery既是一个函数,可以直接被调用jQuery('#foo'),又是一个对象,拥有子属性jQuery.ajax()(事实确实如此),那么我们可以组合多个声明语句,它们会不冲突的合并起来:
// jQuery.d.tsdeclare function jQuery(selector: string): any;declare namespace jQuery {function ajax(url: string, settings?: any): void;}// index.tsjQuery('#foo');jQuery.ajax('/api/get_something');
1.2 ES Modules 引入
在ts的环境下,我们通过npm安装一个包后,还需要看看它的声明文件是否已经存在。一般来说,包的声明文件可能存在于两个地方:
与该包绑定在一起。判断依据是
package.json中有types字段,或者有一个index.d.ts声明文件。这种模式不需要额外安装其他包,是最为推荐的,所以以后我们自己创建包的时候,最好也将声明文件与npm包绑定在一起。发布到
@types里。我们只需要尝试安装一下对应的@types包就知道是否存在该声明文件,安装命令是npm install @types/foo -D。这种模式一般是由于包的维护者没有提供声明文件,所以只能由其他人将声明文件发布到@types里了。
假如以上两种方式都没有找到对应的声明文件,那么我们就需要自己为它写声明文件了。通常的做法是创建一个types目录,专门用来管理自己写的声明文件,将foo包的声明文件放到types/foo/index.d.ts中。
目录结构:
/path/to/project├── src| └── index.ts├── types| └── foo| └── index.d.ts└── tsconfig.json
ES Moudles包的声明文件主要有以下几种语法:
1.2.1 epxort
在**npm**包的声明文件中,使用**export**导出一个全局变量,该全局变量会被视为当前文件中的一个局部变量,然后在使用方**import**导入后,才会应用到这些类型声明。
export的语法与普通的ts中的语法类似,区别仅在于声明文件中禁止定义具体的实现:
// types/foo/index.d.tsexport 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;}
对应的导入和使用模块应该是这样:
// src/index.tsimport { 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'}};
我们也可以使用declare先声明多个变量,最后再用export一次性导出。上例的声明文件可以等价的改写为:
// types/foo/index.d.tsdeclare 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**的。
1.2.2 export namespace
与declare namespace类似,export namespace用来导出一个拥有子属性的对象:
// types/foo/index.d.tsexport namespace foo {const name: string;namespace bar {function baz(): string;}}// src/index.tsimport { foo } from 'foo';console.log(foo.name);foo.bar.baz();
1.2.3 export default
在类型声明文件中,export default用来导出默认值的类型:
// types/foo/index.d.tsexport default function foo(): string;// src/index.tsimport foo from 'foo';foo();
注意,只有function、class和interface可以直接默认导出,其他的变量需要先定义出来,再默认导出:
// types/foo/index.d.tsexport default enum Directions {// ERROR: Expression expected.Up,Down,Left,Right}
上例中export default enum是错误的语法,需要使用declare enum定义出来,然后使用export default导出:
// types/foo/index.d.tsdeclare enum Directions {Up,Down,Left,Right}export default Directions;
针对这种默认导出,我们一般会将导出语句放在整个声明文件的最前面:
// types/foo/index.d.tsexport default Directions;declare enum Directions {Up,Down,Left,Right}
1.3 CommonJS 引入
在CommonJS规范中,我们用以下方式来导出一个模块:
// 整体导出module.exports = foo;// 单个导出exports.bar = bar;
在ts中,针对这种模块导出,有多种方式可以导入,第一种方式是const ... = require:
// 整体导入const foo = require('foo');// 单个导入const bar = require('foo').bar;
第二种方式是import...from,注意针对整体导出,需要使用import * as来导入:
// 整体导入import * as foo from 'foo';// 单个导入import { bar } from 'foo';
第三种方式是import ... require,这也是ts官方推荐的方式:
// 整体导入import foo = require('foo');// 单个导入import bar = foo.bar;
对于这种使用CommonJS规范的库,假如要为它写类型声明文件的话,就需要使用到**export =**这种语法了:
// types/foo/index.d.tsexport = foo;declare function foo(): string;declare namespace foo {const bar: number;}
需要注意的是,上例中使用了export =之后,就不能再单个导出export { bar }了。所以我们通过声明合并,使用declare namespace foo来将bar合并到foo里。
由于很多第三方库是CommonJS规范的,所以声明文件也就不得不用到export =这种语法了。但是还是需要再强调下,相比与export =,我们更推荐使用ES6标准的export default和export。
1.4 UMD 引入
既可以通过<script>标签引入,又可以通过import导入的包,称为UMD包。对于UMD包,我们需要额外声明一个全局变量,为了实现这种方式,ts提供了一个新语法**export as namespace**。
一般使用export as namespace时,都是先有了npm包的声明文件,再基于它添加一条export as namespace语句,即可将声明好的一个局部变量声明为全局变量,举例如下:
// types/foo/index.d.tsexport = foo;export as namespace foo;declare function foo(): string;declare namespace foo {const bar: number;}
当然它也可以与export default一起使用:
// types/foo/index.d.tsexport default foo;export as namespace foo;declare function foo(): string;declare namespace foo {const bar: number;}
1.5 类型扩展
1.5.1 直接全局变量类型扩展
通过<script>标签引入的第三方库扩展了一个全局变量,可是此全局变量的类型却没有相应的更新过来,就会导致ts编译错误,此时就需要扩展全局变量的类型。比如扩展String类型:
interface String {prependHello(): string;}'foo'.prependHello();
通过声明合并,使用interface String即可给String添加属性或方法声明。
也可以使用declare namespace给已有的命名空间添加类型声明:
declare namespace JQuery {interface CustomOptions {bar: string;}}interface JQueryStatic {foo(options: JQuery.CustomOptions): string;}jQuery.foo({bar: ''});
1.5.2 模块化包中的全局变量类型扩展
对于模块化包,如果导入此包之后会扩展全局变量,则需要使用另一种语法在声明文件中扩展全局变量的类型,那就是declare global。
使用**declare global**可以在模块化方案的声明文件中扩展全局变量的类型:
// types/foo/index.d.tsdeclare global {interface String {prependHello(): string;}}export {};// src/index.ts'bar'.prependHello();
注意即使此声明文件不需要导出任何东西,仍然需要导出一个空对象,用来告诉编译器这是一个模块的声明文件,而不是一个全局变量的声明文件。
有时候我们定义全局变量类型的时候需要引入模块化包中定义的变量类型,我们可能会这么做:
import type { VNodeChild } from 'vue';declare type VueNode = VNodeChild;
由于我们当前的.d.ts文件使用了import/export语法,那么ts编译器就不会把declare type当成全局变量,那么我们就需要通过以下的方式声明全局变量:
import type { VNodeChild } from 'vue';declare global {declare type VueNode = VNodeChild;}
1.5.3 模块扩展变量类型
ts提供了一个语法**declare module**可以用来扩展原有模块的类型。
比如,我们想在moment的模块上增加一个自定义的方法:
// index.tsimport moment form 'moment';moment.myFunction = () => {}; // Error,类型"typeof moment"上不存在属性"myFunction"
这个时候我们可以通过declare module来给moment模块扩展类型:
// moment.d.tsdeclare module 'moment' {export function myFunction(): void}
需要注意的是,使用declare module,实际上是将模块名称moment引入了全局空间,即告诉ts编译器,存在一个叫moment的模块,想使用里面的名称,就import吧!
ts只支持js模块的导入导出,对于一些非js模块这时候就需要用通配符让ts把它们当做模块:
declare module '*.vue' {import type { DefineComponent } from 'vue';// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-typesconst component: DefineComponent<{}, {}, any>;export default component;}
2. 声明文件中的依赖
一个声明文件有时会依赖另一个声明文件中的类型,除了可以在声明文件中通过import导入另一个声明文件中的类型之外,还有一个语法也可以用来导入另一个声明文件,那就是三斜线指令。
类似于声明文件中的import,它可以用来导入另一个声明文件。与import的区别是,当且仅当在以下几个场景下,我们才需要使用三斜线指令替代import:
- 当我们在书写一个全局变量的声明文件时;
- 当我们需要依赖一个全局变量的声明文件时。
2.1 书写一个全局变量的声明文件
在全局变量的声明文件中,是不允许出现**import/export**关键字的。一旦出现了,那么它就会被视为一个模块化包的声明文件,就不再是全局变量的声明文件了。故当我们在书写一个全局变量的声明文件时,如果需要引用另一个库的类型,那么就必须用三斜线指令了:
// types/jquery-plugin/index.d.ts/// <reference types="jquery" />declare function foo(options: JQuery.AjaxSettings): string;// src/index.tsfoo({});
三斜线指令的语法如上,///后面使用xml的格式添加了对jquery类型的依赖,这样就可以在声明文件中使用JQuery.AjaxSettings类型了。
注意,三斜线指令必须放在文件的最顶端,三斜线指令的前面只允许出现单行或多行注释。
2.2 依赖一个全局变量的声明文件
在另一个场景下,当我们需要依赖一个全局变量的声明文件时,由于全局变量不支持通过import导入,当然也就必须使用三斜线指令来引入了:
// types/node-plugin/index.d.ts/// <reference types="node" />export function foo(p: NodeJS.Process): string;// src/index.tsimport { foo } from 'node-plugin';foo(global.process);
在上面的例子中,我们通过三斜线指令引入了node的类型,然后在声明文件中使用了NodeJS.Process这个类型。最后在使用到foo的时候,传入了node中的全局变量process。
由于引入的node中的类型都是全局变量的类型,它们是没有办法通过import来导入的,所以这种场景下也只能通过三斜线指令来引入了。
以上两种使用场景下,都是由于需要书写或需要依赖全局变量的声明文件,所以必须使用三斜线指令。在其他的一些不是必要使用三斜线指令的情况下,就都需要使用import来导入。
2.3 拆分声明文件
当我们的全局变量的声明文件太大时,可以通过拆分为多个文件,然后在一个入口文件中将它们一一引入,来提高代码的可维护性。比如jQuery的声明文件就是这样的:
// node_modules/@types/jquery/index.d.ts/// <reference types="sizzle" />/// <reference path="JQueryStatic.d.ts" />/// <reference path="JQuery.d.ts" />/// <reference path="misc.d.ts" />/// <reference path="legacy.d.ts" />export = jQuery;
其中用到了types和path两种不同的指令。它们的区别是:**types**用于声明对另一个库的依赖,而**path**用于声明对另一个文件的依赖。
上例中,sizzle是与jquery平行的另一个库,所以需要使用types="sizzle"来声明对它的依赖。而其他的三斜线指令就是将jquery的声明拆分到同级的不同的文件中了,然后在这个入口文件中使用path="foo"将它们一一引入。
3. 发布声明文件
当我们为一个库写好了声明文件之后,下一步就是将它发布出去了。此时有两种方案:
- 将声明文件和源码放在一起;
- 将声明文件发布到
@types下。
这两种方案中优先选择第一种方案。保持声明文件与源码在一起,使用时就不需要额外增加单独的声明文件库的依赖了,而且也能保证声明文件的版本与源码的版本保持一致。
仅当我们在给别人的仓库添加类型声明文件,但原作者不愿意合并pull request时,才需要使用第二种方案,将声明文件发布到@types下。
3.1 将声明文件和源码放在一起
如果声明文件是通过tsc自动生成的,那么无需做任何其他配置,只需要把编译好的文件也发布到npm上,使用方就可以获取到类型提示了。
如果是手动写的声明文件,那么需要满足以下条件之一,才能被正确的识别:
- 给
package.json中的types或typings字段指定一个类型声明文件地址; - 在项目根目录下,编写一个
index.d.ts文件; - 针对入口文件(
package.json中的main字段指定的入口文件),编写一个同名不同后缀的.d.ts文件。
第一种方式是给package.json中的types或typings字段指定一个类型声明文件地址。比如:
{"name": "foo","version": "1.0.0","main": "lib/index.js","types": "foo.d.ts",}
指定了types为foo.d.ts之后,导入此库的时候,就会去找foo.d.ts作为此库的类型声明文件了。
typings与types一样,只是另一种写法。
如果没有指定types或typings,那么就会在根目录下寻找index.d.ts文件,将它视为此库的类型声明文件。
如果没有找到index.d.ts文件,那么就会寻找入口文件(package.json中的main字段指定的入口文件)是否存在对应同名不同后缀的.d.ts文件。
比如package.json是这样的:
{"name": "foo","version": "1.0.0","main": "lib/index.js"}
就会先识别package.json中是否存在types或typings字段。发现不存在,那么就会寻找是否存在 index.d.ts文件。如果还是不存在,那么就会寻找是否存在lib/index.d.ts文件。假如说连lib/index.d.ts都不存在的话,就会被认为是一个没有提供类型声明文件的库了。
有的库为了支持导入子模块,比如import bar from 'foo/lib/bar',就需要额外再编写一个类型声明文件lib/bar.d.ts或者lib/bar/index.d.ts,这与自动生成声明文件类似,一个库中同时包含了多个类型声明文件。
3.2 将声明文件发布到@types下
如果我们是在给别人的仓库添加类型声明文件,但原作者不愿意合并pull request,那么就需要将声明文件发布到@types下。
与普通的npm模块不同,@types是统一由 DefinitelyTyped 管理的。要将声明文件发布到@types下,就需要给 DefinitelyTyped 创建一个pull-request,其中包含了类型声明文件,测试代码,以及tsconfig.json等。
pull-request需要符合它们的规范,并且通过测试,才能被合并,稍后就会被自动发布到@types下。
在 DefinitelyTyped 中创建一个新的类型声明,需要用到一些工具,DefinitelyTyped 的文档中已经有了详细的介绍,这里就不赘述了,以官方文档为准。
