TypeScript 声明文件
1. 基本概念
TypeScript 的核心优势在于其静态类型系统。然而,我们开发的绝大多数项目都依赖于没有使用 TypeScript 编写的第三方 JavaScript 库(如 jQuery, Lodash 等)。为了让 TypeScript 编译器理解这些库的类型信息,从而提供代码补全、类型检查和智能提示,声明文件应运而生。
声明文件(通常以 .d.ts 为后缀)扮演着“类型说明书”的角色。它只包含类型声明,不包含任何具体的实现逻辑。它的核心作用是:
- 描述 JavaScript 库的形态: 它告诉 TypeScript 一个库里有哪些变量、函数、类和接口,以及它们的类型是什么。
- 实现类型安全: 使得 TypeScript 可以在编译时检查我们对第三方库的使用是否符合其预期的类型,从而捕获潜在的运行时错误。
- 改善开发体验: 为开发者提供精准的自动补全和接口提示。
// 如果没有 jQuery 的声明文件,TypeScript 无法理解 '$'
$("#foo");
// TS Error: Cannot find name '$'.
// 有了声明文件后,TypeScript 就知道 '$' 是一个函数,接受选择器并返回一个对象
declare var $: (selector: string) => any;
$("#foo"); // OK
在实际开发中,我们一般直接获取别人写好的声明文件。获取声明文件主要有两种途径:
-
与库捆绑 (Bundled): 越来越多的库在发布时会自带
.d.ts声明文件。这是最佳实践,因为类型定义与库代码版本完全同步。判断依据是库的package.json文件中是否包含types或typings字段。 -
DefinitelyTyped (@types): 对于没有自带声明文件的库,社区在 DefinitelyTyped 这个庞大的仓库中维护了成千上万个库的声明文件。我们可以通过 npm 的
@typesscope 来安装它们。
# 为 jquery 库安装社区维护的声明文件
npm install --save-dev @types/jquery
当 TypeScript 编译器在项目中遇到 import 'some-lib' 时,它会自动在 node_modules/some-lib 和 node_modules/@types/some-lib 中寻找类型定义。
2. 声明文件基础语法
当一个库既没有捆绑声明文件,也没有在 @types 中提供时,我们就需要自己动手编写。声明文件的语法核心是 declare 关键字。
declare: 告诉编译器“它存在”
declare 关键字用于向 TypeScript 编译器断言一个变量、函数或类已经存在于某个地方(如通过 <script> 标签在全局作用域中引入),并描述其类型。它只定义类型,不产生任何代码。
// 错误:在声明文件中提供了实现
// An implementation cannot be declared in ambient contexts.
declare const PI = 3.14;
// 正确:只声明类型
declare const PI: number;
全局声明
对于通过 <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,
}
命名空间:组织全局对象
如果一个全局变量是一个包含多个属性和方法的对象(例如 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 function 和 declare 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. 模块声明文件书写
对于通过 import 和 export 使用的现代模块化库(NPM 包),声明文件的写法完全不同。在模块声明文件中,顶层的 declare 不再需要,而是使用标准的 export 关键字。
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 };
默认导出 (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;
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);
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. 高级技巧与模式
扩展模块 (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(); // 现在这个方法是类型安全的
扩展全局类型 (declare global)
当一个模块在被导入时,会修改全局作用域(例如,给 window 或 String.prototype 添加属性),我们需要使用 declare global 来描述这些变化。
// my-lib/index.d.ts
// 即使文件不导出任何东西,也需要一个空的 export {}
// 这会将文件标记为一个模块,此时 declare global 才会生效
export {};
declare global {
interface String {
// 为全局的 String 类型添加一个新方法
toSafeString(): string;
}
}
模块解析与三斜线指令
三斜线指令 (///) 是 TypeScript 早期的模块依赖声明方式,现在已不推荐在应用代码中使用。但在声明文件中,它仍有两个重要的使用场景。
-
/// <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; -
/// <reference path="..." />: 用于将一个大型声明文件拆分成多个小文件。path指令引入一个文件并将其内容合并到当前声明上下文中。// jquery/index.d.ts /// <reference path="JQueryStatic.d.ts" /> /// <reference path="JQuery.d.ts" /> export = jQuery;
规则: 只有在编写全局声明文件时,或者需要依赖全局声明文件时,才应该使用三斜线指令。在其他所有情况下,都应使用 ES6 的 import 语法。
5. 工具与发布
自动生成声明文件
如果库本身就是用 TypeScript 编写的,那么生成声明文件是全自动的。只需在 tsconfig.json 中开启 declaration 选项。
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": false, // 如果为 true,则只生成 .d.ts 文件
"outDir": "dist"
}
}
运行 tsc 后,编译器会在 outDir 目录中为每个 .ts 文件生成对应的 .js 和 .d.ts 文件。
发布声明文件
- 与库捆绑: 这是首选方案。在
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"
}
}
- 发布到 @types: 如果为一个没有类型定义的第三方库编写了声明文件,并且原作者不接受合并请求,可以将其贡献给 DefinitelyTyped。提交一个 Pull Request,通过社区审核后,你的声明文件就会作为
@types/package-name发布到 npm 上。