Dark Dwarf Blog background

TypeScript 声明文件

TypeScript 声明文件

1. 基本概念

TypeScript 的核心优势在于其静态类型系统。然而,我们开发的绝大多数项目都依赖于没有使用 TypeScript 编写的第三方 JavaScript 库(如 jQuery, Lodash 等)。为了让 TypeScript 编译器理解这些库的类型信息,从而提供代码补全、类型检查和智能提示,声明文件应运而生。

声明文件(通常以 .d.ts 为后缀)扮演着“类型说明书”的角色。它只包含类型声明,不包含任何具体的实现逻辑。它的核心作用是:

  1. 描述 JavaScript 库的形态: 它告诉 TypeScript 一个库里有哪些变量、函数、类和接口,以及它们的类型是什么。
  2. 实现类型安全: 使得 TypeScript 可以在编译时检查我们对第三方库的使用是否符合其预期的类型,从而捕获潜在的运行时错误。
  3. 改善开发体验: 为开发者提供精准的自动补全和接口提示。
// 如果没有 jQuery 的声明文件,TypeScript 无法理解 '$'
$("#foo");
// TS Error: Cannot find name '$'.

// 有了声明文件后,TypeScript 就知道 '$' 是一个函数,接受选择器并返回一个对象
declare var $: (selector: string) => any;

$("#foo"); // OK

在实际开发中,我们一般直接获取别人写好的声明文件。获取声明文件主要有两种途径:

  1. 与库捆绑 (Bundled): 越来越多的库在发布时会自带 .d.ts 声明文件。这是最佳实践,因为类型定义与库代码版本完全同步。判断依据是库的 package.json 文件中是否包含 typestypings 字段。

  2. DefinitelyTyped (@types): 对于没有自带声明文件的库,社区在 DefinitelyTyped 这个庞大的仓库中维护了成千上万个库的声明文件。我们可以通过 npm 的 @types scope 来安装它们。

# 为 jquery 库安装社区维护的声明文件
npm install --save-dev @types/jquery

当 TypeScript 编译器在项目中遇到 import 'some-lib' 时,它会自动在 node_modules/some-libnode_modules/@types/some-lib 中寻找类型定义。

2. 声明文件基础语法

当一个库既没有捆绑声明文件,也没有在 @types 中提供时,我们就需要自己动手编写。声明文件的语法核心是 declare 关键字。

a.a. declare: 告诉编译器“它存在”

declare 关键字用于向 TypeScript 编译器断言一个变量、函数或类已经存在于某个地方(如通过 <script> 标签在全局作用域中引入),并描述其类型。它只定义类型,不产生任何代码

// 错误:在声明文件中提供了实现
// An implementation cannot be declared in ambient contexts.
declare const PI = 3.14;

// 正确:只声明类型
declare const PI: number;

b.b. 全局声明

对于通过 <script> 标签引入并创建全局变量的传统库(如旧版 jQuery),我们需要使用全局声明。

  • declare var/let/const: 声明一个全局变量。推荐使用 const,因为大多数全局库变量不应被修改。
// lib.d.ts
declare const myLibraryVersion: string;
  • declare function: 声明一个全局函数,支持函数重载。
// lib.d.ts
declare function greet(message: string): void;
declare function greet(person: { name: string }, message: string): void;
  • declare class: 声明一个全局类。
// lib.d.ts
declare class Greeter {
  constructor(greeting: string);
  greet(): void;
}
  • declare enum: 声明一个全局枚举(外部枚举)。
// lib.d.ts
declare enum LogLevel {
  Info,
  Warn,
  Error,
}

c.c. 命名空间:组织全局对象

如果一个全局变量是一个包含多个属性和方法的对象(例如 jQuery),我们可以使用 declare namespace 来组织其类型定义。

// jquery.d.ts
declare namespace jQuery {
  function ajax(url: string, settings?: any): void;
  const version: number;

  // 也可以嵌套命名空间
  namespace fn {
    function extend(obj: any): void;
  }
}

声明合并 (Declaration Merging) 是一个强大的特性。如果一个库既可以作为函数调用,又拥有静态属性,我们可以将 declare functiondeclare namespace 合并。

// jquery.d.ts
declare function jQuery(selector: string): any;
declare namespace jQuery {
  function ajax(url: string, settings?: any): void;
}

// 使用时
jQuery("#app"); // 作为函数调用
jQuery.ajax("/api/data"); // 访问其命名空间上的方法

3. 模块声明文件书写

对于通过 importexport 使用的现代模块化库(NPM 包),声明文件的写法完全不同。在模块声明文件中,顶层的 declare 不再需要,而是使用标准的 export 关键字。

a.a. ES 模块 (import/export)

为 ES 模块编写声明文件时,语法与标准的 TypeScript 模块几乎一致,只是没有实现体。

// my-lib/index.d.ts

// 导出一个常量
export const version: string;

// 导出一个函数
export function process(data: any): void;

// 导出一个接口
export interface Options {
  retries: number;
}

// 导出一个类
export class Client {
  constructor(apiKey: string);
  connect(): Promise<void>;
}

使用方通过 import 导入这些类型:

import { version, process, Options, Client } from "my-lib";

const opts: Options = { retries: 3 };

b.b. 默认导出 (export default)

如果库使用 export default 导出一个函数、类或对象,声明文件也应使用 export default

// my-lib/index.d.ts
export default function myCoolFunction(): string;

// 使用方
import myCoolFunction from "my-lib";

注意:enum, const 等不能直接 export default,需要先声明再导出。

// my-lib/index.d.ts
declare enum MyEnum {
  A,
  B,
}
export default MyEnum;

c.c. CommonJS 模块 (export =)

对于使用 module.exports = ... 语法的 CommonJS 模块,TypeScript 提供了 export = 语法来兼容。

// my-lib/index.d.ts
declare function myLib(options: any): void;
declare namespace myLib {
  export const version: string;
}

// 导出整个 myLib 对象
export = myLib;

使用方必须使用 TypeScript 特有的 import ... = require(...) 语法来导入。

import myLib = require("my-lib");
myLib({
  /* ... */
});
console.log(myLib.version);

d.d. UMD 模块 (export as namespace)

UMD (Universal Module Definition) 库既可以作为模块导入 (import),也可以在全局作用域中通过 <script> 标签使用。为了同时支持这两种用法,我们可以在模块声明文件的基础上,增加 export as namespace

// my-lib/index.d.ts
export function a(): void;
export function b(): void;

// 当通过 <script> 标签引入时,它会创建一个名为 `myLib` 的全局变量
export as namespace myLib;

这样,用户既可以 import { a, b } from 'my-lib',也可以在浏览器中直接使用 myLib.a()

4. 高级技巧与模式

a.a. 扩展模块 (declare module)

有时我们需要增强一个已存在的模块,比如为一个库添加插件。declare module 允许我们“进入”一个外部模块并扩展它。

// moment-plugin.d.ts
import 'moment'; // 必须先导入原模块

declare module 'moment' {
  // 在 moment 模块内部添加一个新的函数
  export function myPlugin(): string;
}

// 使用时
import *dts from 'moment';
import 'moment-plugin'; // 导入插件来应用类型扩展

moment.myPlugin(); // 现在这个方法是类型安全的

b.b. 扩展全局类型 (declare global)

当一个模块在被导入时,会修改全局作用域(例如,给 windowString.prototype 添加属性),我们需要使用 declare global 来描述这些变化。

// my-lib/index.d.ts

// 即使文件不导出任何东西,也需要一个空的 export {}
// 这会将文件标记为一个模块,此时 declare global 才会生效
export {};

declare global {
  interface String {
    // 为全局的 String 类型添加一个新方法
    toSafeString(): string;
  }
}

c.c. 模块解析与三斜线指令

三斜线指令 (///) 是 TypeScript 早期的模块依赖声明方式,现在已不推荐在应用代码中使用。但在声明文件中,它仍有两个重要的使用场景。

  1. /// <reference types="..." />: 用于声明对另一个类型库的依赖。这通常用在需要依赖全局类型定义(如 Node.js 的 @types/node)的声明文件中。

    // my-node-lib.d.ts
    /// <reference types="node" />
    import { EventEmitter } from "events";
    
    export function process(p: NodeJS.Process): void;
  2. /// <reference path="..." />: 用于将一个大型声明文件拆分成多个小文件。path 指令引入一个文件并将其内容合并到当前声明上下文中。

    // jquery/index.d.ts
    /// <reference path="JQueryStatic.d.ts" />
    /// <reference path="JQuery.d.ts" />
    export = jQuery;

规则: 只有在编写全局声明文件时,或者需要依赖全局声明文件时,才应该使用三斜线指令。在其他所有情况下,都应使用 ES6 的 import 语法。

5. 工具与发布

a.a. 自动生成声明文件

如果库本身就是用 TypeScript 编写的,那么生成声明文件是全自动的。只需在 tsconfig.json 中开启 declaration 选项。

{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": false, // 如果为 true,则只生成 .d.ts 文件
    "outDir": "dist"
  }
}

运行 tsc 后,编译器会在 outDir 目录中为每个 .ts 文件生成对应的 .js.d.ts 文件。

b.b. 发布声明文件

  1. 与库捆绑: 这是首选方案。在 package.json 中,通过 types 字段指向你的主声明文件。
{
  "name": "my-lib",
  "main": "dist/index.js",
  "types": "dist/index.d.ts"
}

TypeScript 编译器会自动找到并加载这个文件。如果你的包使用了 exports 字段来支持条件导出,也可以在其中指定类型路径:

"exports": {
  ".": {
    "import": "./dist/index.mjs",
    "require": "./dist/index.js",
    "types": "./dist/index.d.ts"
  }
}
  1. 发布到 @types: 如果为一个没有类型定义的第三方库编写了声明文件,并且原作者不接受合并请求,可以将其贡献给 DefinitelyTyped。提交一个 Pull Request,通过社区审核后,你的声明文件就会作为 @types/package-name 发布到 npm 上。