书写声明文件

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2021-05-21

当使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全,接口提示等功能。

声明语句

当我们使用 jQuery 时,ts 编译器并不知道 $jQuery 是什么对象,什么类型。所以需要定义它的类型。

declare var jQuery: (selector: string) => any;

declare var 并没真正定义一个变量,只是定义了全局变量 jQuery 的类型,仅仅会用于编译时的检查,在编译结果中会被删除

声明语句有以下几种:

  • declare var: 声明全局变量
  • declare function: 声明全局方法
  • declare class: 声明全局类
  • declare enum: 声明全局枚举类型
  • declare namespace: 声明(含有子属性的)全局对象
  • interfacetype: 声明全局类型
  • export: 导出变量
  • export namespace: 导出(含有子属性的)对象
  • export default: ES6 默认导出。
  • export =: commonjs 导出模块。
  • export as namespace: UMD 库声明全局变量
  • declare global: 扩展全局变量
  • declare module: 扩展模块
  • /// <reference />: 三斜线指令

什么是声明文件

通常会把声明语句放到一个单独的文件中,以 .d.ts 为后缀,这就是声明文件。 一般来说,ts 会解析项目中所有 *.ts 文件,当然也包含以 d.ts 结尾的文件,所以当我们将类似 jQuery.d.ts 文件放到项目中,其他所有 *.ts 文件就都可以获得 jQuery 的类型定义了。

假如仍然无法解析,那么可以检查一下 tsconfig.json 中的 filesincludeexclude 配置,确保其包含了 jQuery.d.ts文件。

第三方声明文件

很多第三方库都由其官方帮我们定义好了,我们使用@types 去下载管理即可,可以在这个页面搜索你需要的声明文件。

npm install @types/jquery --save-dev

书写声明文件

当一个第三方库没有提供声明文件时,我们就需要自己书写声明文件了;在不同的场景下,声明文件的内容和使用方式会有所不同。库的使用场景主要有以下几种:

  • 全局变量
  • NPM包
  • UMD库
  • 直接扩展全局变量
  • 在npm包或UMD库中扩展全局变量
  • 模块插件。

全局库

通过<script>标签引入第三方库,注入全局变量。如上面提到的jQuery,就可以使用安装@type/jQuery来引用声明文件,也可以自己书写 .d.ts 文件,使用 declare 语句。

全局库是指能在全局命名空间下访问的(不需要使用任何形式的import)。许多库都是简单的暴露出一个或多个全局变量。 比如 jQuery。

当你查看全局库的源代码时,你通常会看到:

  • 顶级的var语句或function声明
  • 一个或多个赋值语句到window.someName
  • 假设DOM原始值像 documentwindow是存在的

1.declare var

用来定义一个全局变量的类型,同时还有 declare letdeclare const,需要注意的是,当我们使用 declare const 定义时,表示此时的全局变量是一个常量,不允许再去修改它的值了。

declare const jQuery: (selector: string) => any;

jQuery = function(selector) {
  return document.querySelector(selector);
}
// Error “Cannot assign to 'jQuery' because it is a constant or a read-only property.”

一般来说,全局变量都是禁止修改的常量,所以大部分情况都应该使用 const 而不是 varlet

另一个需要注意的是,声明语句中只能定义类型,切勿在声明语句中定义具体的实现。

declare const jQuery = function(selector) {
  return document.querySelector(selector);
}
// Error “An implementation cannot be declared in ambient contexts.”

2.declare function

用来定义全局函数,jQuery 其实就是一个函数,所以也可以用 function 来定义。

declare function jQuery(selector: string): any;
// 使用declare function 可以进行重载
declare function jQuery(domReadyCallback: () => any): any;

3.declare class

用来定义一个全局类。

declare class Animal {
  name: string;
  constructor(name: string)
}

// 使用
const cat = new Animal('Tom');

4.declare enum

用来定义枚举类型。

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

// 同样 declare enum 也是定义类型,而不是具体的值

5.declare namespace

比如,jQuery 既是一个函数,可以直接调用 jQuery('#foo'),又是一个对象,拥有子属性 jQuery.ajax('/api/get_something') ,那么我们可以组合多个声明语句。

declare function jQuery(selector: string): any;

declare namespace jQuery {
  function ajax(url: string, setting?: any): void;
}

使用 declare namespace 定义一个对象的属性的时候可以这样定义:

// 如果您的库具有公开在全局变量中的属性 把它们放在这里 您还应该在此处放置类型(接口和类型别名)
declare namespace myLib {
  // 这样可以写 'myLib.timeout = 50;'
  let timeout: number;

  // 这样可以访问 'myLib.version', 但不能更改
  const version: string;

  // 这样可以通过'let c = new myLib.Cat(42)'创建一些类
  // 或引用,例如 function f(c:myLib.Cat){...}
  class Cat {
    constructor(n: number);

    // 这样可以从 Cat 实例 c 中读取 c.age
    readonly age: number;

    // 这样可以通过 c.purr() 调用方法
    purr(): void;
  }

  // 定义这种,这样可以声明变量
  // 'var s: myLib.CatSettings = { weight: 5, name: "Maru" };'
  interface CatSettings {
    weight: number;
    name: string;
    tailLength?: number;
  }

  // 这样可以写 'const v: myLib.VetID = 42;'
  //  或者 'const v: myLib.VetID = "bob";'
  type VetID = string | number;

  // 这样可以调用 'myLib.checkCat(c)' 或者 'myLib.checkCat(c, v);'
  function checkCat(c: Cat, s?: VetID);
}

模块化库

模块库至少会包含下列具有代表性的条目之一:

  • 无条件的调用requiredefine
  • import * as a from 'b'; or export c;这样的声明
  • 赋值给exportsmodule.exports
  • 极少包含对windowglobal的赋值

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

  • package.json 中的 types 字段,或者在根目录有一个 index.d.ts

  • 发布到 @types 里,我们只需尝试安装一下对应的 @types 包就知道是否存在该声明文件,npm install @types/foo --save-dev

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

创建一个 types 目录,专门用来管理自己写的声明文件,将 foo 声明文件放到 types/foo/index.d.ts 中,这种方式需要配置 tsconfig.json 中的 pathsbaseUrl 字段。

{
  "compilerOptions": {
    "module": "commonjs",
    "baseUrl": "./",
    "paths": {
      "*": ["types/*"]
    }
  }
}

模块化库的声明文件主要有以下几种语法:

  • export:导出变量
  • export namespace: 导出(含有子属性)对象
  • export default: 默认导出
  • export =: commonjs 导出模块

1.export

在为 NPM 包书写声明文件时,使用 declare 不再会声明一个全局变量,而只会在当前文件声明一个局部变量,只有在声明文件中使用 export 导出,然后在使用方 import 导入后,才会应用到这些类型声明。

// 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;
}

2.混用 declare 和 export

我们也可以使用 declare 先声明多个变量,最后在使用 export 一次性导出。

// types/foo/index.d.ts

declare const name: string;
declare function getName(): string;
interface Options {
  data: any;
}

export { name, getName, Options }

3.export namespace

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

// types/foo/index.d.ts

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

// src/index.ts
import { foo } from 'foo';

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

4.export default

在 ES6 模块系统中,使用 export default 可以导出一些默认值,使用方可以用 import foo from 'foo' 导入。

// types/foo/index.d.ts
export default function foo(): string;

// src/index.ts
import foo from 'foo';
foo();

需要注意的是,在使用 export default 时只有 functionclassinterface 可以直接默认导出,其他的变量需要先使用 declare 定义,再默认导出。

5.export =

在 commonjs 规范中,我们用以下方式导出模块。

// 整体导出
module.exports = foo;
// 单个导出
exports.bar = bar;

在 ts 中,针对这种模块导出,有多种方式可以导入:

// 1、使用 require
// 整体导入
const foo = require('foo');
// 单个导入
const bar = require('foo').bar;


// 2. 使用 import from
// 整体导入
import * as foo from 'foo';
// 单个导入
import { bar } from 'foo';

// 3. 使用 import require,也是ts官方推荐的方式
// 整体导入
import foo = require('foo');
// 单个导入
import bar = foo.bar;

对于这种使用 commonjs 规范的库,如果要写声明文件就需要使用到 export = 这种语法了

// 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 里

UMD模块

既可以通过 <script> 标签引入,又可以通过 import 导入的库。

UMD模块会检查是否存在模块加载器环境。 这是非常形容观察到的模块,它们会像下面这样:

如果你在库的源码里看到了typeof definetypeof window,或typeof module这样的测试,尤其是在文件的顶端,那么它几乎就是一个UMD库。

UMD库的文档里经常会包含:

  • 通过require在Node.js里使用例子
  • “在浏览器里使用”的例子,展示如何使用 <script>标签去加载脚本。

相比于 NPM 包的类型声明文件,我们需要额外声明一个全局变量,为了实现这种方式, ts 提供了一个 export as namespace 语法。

1.export as namespace

一般使用 export as namespace 时,都是先有了 NPM 包的声明文件,再基于它添加一条 export as namespace,即可将声明好的一个变量声明为全局变量。

// types/foo/index.d.ts
export as namespace foo;
export = foo;

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

或者,与 export default 一起使用

// types/foo/index.d.ts
export as namespace foo;
export default foo;

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

如果模块能够作为函数调用:

var x = require("foo");
var y = x(42);
// 如果您正在为 super-greeter 编写文件,
// 则此文件应为 super-greeter/index.d.ts  并将其放置在与模块同名的文件夹中。

// 如果此模块是有公开全局变量 'myFuncLib' 的UMD模块,
// 可以在模块加载器环境之外加载 ,则需要在这里声明全局变量。
// 否则,请删除此声明。
export as namespace myFuncLib;

// 该声明指定该函数是文件中的导出对象
export = MyFunction;

// 本示例说明如何为您的函数产生多个重载 
declare function MyFunction(name: string): MyFunction.NamedReturnType;
declare function MyFunction(length: number): MyFunction.LengthReturnType;

// 如果您还想公开模块中的类型,则可以将它们放在此块中
// 该类型应如此处示例所示这样声明
declare namespace MyFunction {
  export interface LengthReturnType {
    width: number;
    height: number;
  }
  export interface NamedReturnType {
    firstName: string;
    lastName: string;
  }

  // 如果模块也具有属性,请在此处声明它们。 例如,此声明表明此代码是合法的:
  // import f = require('myFuncLibrary');
  // console.log(f.defaultName);
  export const defaultName: string;
  export let defaultLength: number;
}

如果模块能够使用new来构造:

var x = require("bar");
var y = new x("hello");
// 如果是公开全局变量请这样定义,否则删除这一行
export as namespace myClassLib;

// 该声明指定该函数是文件中的导出对象
export = MyClass;

// 在此类中编写模块的方法和属性
declare class MyClass {
  constructor(someParam?: string);

  someProperty: string[];

  myMethod(opts: MyClass.MyClassMethodOptions): number;
}

// 如果您还想公开模块中的类型,则可以将它们放在此块中
declare namespace MyClass {
  export interface MyClassMethodOptions {
    width?: number;
    height?: number;
  }
}

如果模块不能被调用或构造:

export as namespace myLib;

// 如果此模块具有方法,则将它们声明为如下:
export function myMethod(a: string): string;
export function myOtherMethod(a: number): number;

// 声明可用的类型
export interface someType {
  name: string;
  length: number;
  extras?: string[];
}

// 可以使用const,let或var声明模块的属性
export const myField: number;

// namespace 中声明拥有子属性的对象。
export namespace subProp {
  export function foo(): void;
}

// 如果这样定义 subProp 和 foo 就可以这使用
// import { subProp } from 'yourModule';
// subProp.foo();
// 或者
// import * as yourMod from 'yourModule';
// yourMod.subProp.foo();

全局插件

一个全局插件是全局代码,通过<script>标签引入第三方库后,改变一个全局对象的结构。比如,一些库往Array.prototypeString.prototype里添加新的方法。

比如可以如下调用:

var x = "hello, world";
console.log(x.startsWithHello());
// 编写原始类型的声明并添加新成员。
// 例如,这将添加一个'toBinaryString'方法,以将重载到内置数字类型。
interface Number {
  toBinaryString(opts?: MyLibrary.BinaryFormatOptions): string;
  toBinaryString(callback: MyLibrary.BinaryFormatCallback, opts?: MyLibrary.BinaryFormatOptions): string;
}

// 如果需要声明几种类型,请将它们放在名称空间中以避免向全局名称空间添加太多内容
declare namespace MyLibrary {
  type BinaryFormatCallback = (n: number) => string;
  interface BinaryFormatOptions {
    prefix?: string;
    padding: number;
  }
}

也可以使用 declare namespace 给已有的命名空间添加类型声明。

// types/jquery-plugin/index.d.ts
declare namespace JQuery {
  interface CustomOptions {
    bar: string;
  }
}

interface JQueryStatic {
  foo(options: JQuery.CustomOptions): string;
}

// src/index.ts
jQuery.foo({
  bar: ''
});

全局修改的模块

引用NPM包或UMD库后,改变一个全局变量的结构。通常来讲,它们与全局插件相似,但是需要require调用来激活它们的效果。

require("magic-string-time"); // 激活

var x = "hello, world";
console.log(x.startsWithHello());

对于一个NPM包或者UMD库的声明文件,只有 export 导出的类型声明才能被导入,所以对NPM包或UMD库,如果导入此库之后会扩展你全局变量,则需要使用另外一种语法 declare global

// types/foo/index.d.ts
declare global {
  interface String {
    prependHello(): string;
  }
}

export {};

注意即使此声明文件不需要导出任何东西,仍然需要使用export {}导出一个空对象,用来告诉编译器这是一个模块(NPM包或UMD库扩展)的声明文件,而不是一个全局变量(<script>引入直接修改)的声明文件。

模块插件 (扩展原有模块的类型)

通过 <script>import 导入后,改变另一个模块的结构。

有时通过 import 导入一个模块插件,可以改变另外一个原有模块的结构,比如 vuex 相较于 vue。此时如果原有模块已经有了类型声明,而插件模块没有类型声明,就会导致类型不完整,缺少插件部分的类型。ts 提供了 declare module 语法,它可以扩展原有模块的类型。

如果是需要扩展原有模块的话,需要在类型声明文件中先引用原有模块,再使用 declare module 扩展原有模块。

// types/moment-plugin/index.d.ts
// 导入此模块添加到的模块
import * as moment from 'moment';

// 也可以导入你所需要的其他模块
import * as other from 'anotherModule';

declare module 'moment' {
  export function foo(): moment.CalendarKey;

  // 还可以为原始模块已经存在的接口 通过重写接口 来新增属性
  export interface SomeModuleOptions {
    someModuleSetting?: string;
  }

  // 也可以声明新类型,并且新类型看起来就像在原始模块中一样
  export interface MyModulePluginOptions {
    size: number;
  }
}

通常这么使用:

// src/index.ts
import * as moment from 'moment';
import 'moment-plugin';

moment.foo();

declare module 也可用于在一个文件中一次性声明多个模块的类型。

// types/foo-bar.d.ts
declare module 'foo' {
  export interface Foo {
    foo: string;
  }
}

declare module 'bar' {
  export function bar(): string;
}

// src/index.ts
import { Foo } from 'foo';
import * as bar from 'bar';

let f: Foo;
bar.bar();

声明文件中的依赖

一个声明文件有时会依赖另一个声明文件中的类型,可以在声明文件中导入另一个声明文件中的类型。

1.依赖模块

import * as moment from 'moment';

declare module 'moment' {
  export function foo(): moment.CalendarKey;
}

2.依赖全局库

/// <reference types="someLib" />

function getThing(): someLib.thing;

3.依赖UMD库

如果你的全局库依赖于某个UMD模块,使用/// <reference types指令:

/// <reference types="moment" />

function getThing(): moment;

如果你的模块或UMD库依赖于一个UMD库,使用import语句:

import * as someLib from 'someLib';

4.三斜线指令

除了 import 外,还有一个语法可以用来导入另一个声明文件,那就是三斜线指令。虽然现在不常使用,但可能读文档的时候会遇到。

它与 import 的区别是,当且仅当在以下几个场景下,我们才需要使用三斜线指令替代 import

a. 当我们在书写一个全局变量的声明文件时

在全局变量的声明文件中,是不允许出现 importexport 关键字的,一旦出现了,那么就会被视为一个 NPM 包或 UMD 库,就不再是全局变量的声明文件了,故当我们在书写一个全局变量的声明文件时,如果需要引用另一个库的类型,那么就必须用三斜线指令了。

/// <reference types="jquery" />

declare function foo(options: JQuery.AjaxSettings): string;
// 书写全局变量的声明文件,却需要引用jquery库的类型

需要注意的是,/// 指令必须放在文件的最顶端,即三斜线指令的前面只允许出现单行或多行注释

b. 当我们需要依赖一个全局变量的声明文件时

/// <reference types="node" />

export function foo(p: NodeJS.Process): string;
// 由于引入的node中的类型都是全局变量的类型,它们没有办法通过import来引入

5.拆分声明文件

当我们的全局变量的声明文件太大时,可以通过拆分为多个文件,然后在一个入口文件中将它们一一引入,来提高代码的可维护性。

// 比如 jQuery 的声明文件就如下

/// <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 用于声明对另一个文件的依赖。

自动生成声明文件

如果库的源码本身是由ts写的,那么在使用 tsc 脚本将 ts 编译成 js 的时候,添加 declaration 选项,就可以同步生成 .d.ts 声明文件了。

# 在命令行中添加 --declaration / -d
tsc -d

或者在 tsconfig.json 中添加 declaration 选项:

{
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "lib",
    "declaration": true
  }
}

使用 tsc 自动生成声明文件时,每个 ts 文件都会对应一个 .d.ts 声明文件,这样的好处是,使用方不仅可以在使用 import foo from 'foo' 导入默认的模块时获得类型提示,还可以在导入一个子模块 import bar from 'foo/lib/bar' 时也获得对应的类型提示。

除了 declaration 选项外,还有几个选项也与自动生成声明文件有关:

  • declarationDir 设置生成 .d.ts 文件的目录。
  • declarationMap 对每个 .d.ts 文件,都生成对应的 .d.ts.map (sourcemap) 文件。
  • emitDeclarationOnly 仅生成 .d.ts 文件,不生成 .js 文件。

发布声明文件

1.将声明文件和源码放在一起

如果声明文件是通过 tsc 自动生成的,那么无需做任何其他配置,只需把编译好的文件也发布到 NPM 上,使用方就可以获取到类型提示了,如果是手动书写的声明文件,那么需要满足以下条件之一,才能被正确的识别:

  • package.json 中的 typestypings 字段指定一个类型声明文件地址
  • 在项目根目录下,编写一个 index.d.ts 文件。(如果没有指定 typestypings就会在根目录下寻找 index.d.ts 文件)
  • 针对入口文件(package.jsonmain字段指定的入口文件),编写一个同名不同后缀的 .d.ts文件。 (如果没有找到 index.d.ts文件就会寻找入口文件对应的同名不同后缀的 .d.ts文件)

有的库为了支持导入子模块,比如 import bar from 'foo/lib/bar' 就需要额外编写一个类型声明文件 lib/bar.d.tslib/bar/index.d.ts 这与自动生成声明文件类似,一个库中同时包含了多个类型声明文件。

2.将声明文件发布到 @types 下

与普通的 NPM 包不同,@types 统一由 Definitely Typed 管理的,要将声明文件发布到 @types 下,就需要给 Definitely Typed 创建一个 PR,其中包含了类型声明文件,测试代码,以及 tsconfig.json等。PR 需要符合它们的规范并通过测试,才能被合并,稍后会被发布到 @types 下。