TypeScript 类型声明文件(.d.ts)

在实际开发中,当我们使用 npm install 命令安装了一个十年前纯 JavaScript 写的库,并且它没有提供 @types/xxx 类型包时,那么TypeScript 会因为找不到声明文件而疯狂报错。

无法找到模块 “ancient-library” 的声明文件。“/node_modules/ancient-library/index.js” 隐式拥有 "any" 类型。

为什么会这样呢?其实是因为很多老旧的开源库(比如早期的 jQuery、Lodash 等)全部都是使用原生 JavaScript 写的,它们的源码里根本没有任何类型信息。因此 TypeScript 在检查时,完全不知道这个库里面暴露出去了什么方法和变量,就会直接报错。

为了拯救这些 “无类型” 的 JavaScript 库,同时又不破坏它们原有的代码结构,TypeScript 引入了一个非常强大的设计:类型声明文件(.d.ts)。

TypeScript 类型声明文件

在 TypeScript 项目中,我们最常写的代码文件后缀是 “.ts”。但在一些开源库的源码,或者我们自己项目的根目录下,经常会看到以 “.d.ts” 结尾的文件。

需要注意的是,这两种文件是完全不一样的:

  • .ts 文件:包含真实的业务逻辑(可执行代码)。在执行 tsc 编译后,它会被转换成真实的 “.js” 文件。
  • .d.ts 文件:全称是 “Declaration TypeScript”。它只包含类型声明,绝对不能包含任何可执行的逻辑。在编译打包时,“.d.ts” 文件会被当作空气一样完全抹除,绝对不会生成对应的 “.js” 文件。

我们可以把 “.d.ts” 文件理解为一份类型说明书,它是专门用来向 TypeScript 编译器解释某个纯 JavaScript 文件里到底有哪些变量函数

TypeScript declare 关键字

假设我们需要在项目中引入了一个公司内部祖传的、使用纯 JavaScript 写的日期处理库 "old-date-utils"。

我们在业务代码中会这样去写:

// 报错:无法找到模块“old-date-utils”的声明文件。
import { formatDate } from "old-date-utils";

formatDate(new Date());

为了消除这个报错,我们需要在项目的 src 目录下(通常我们会专门建一个 src/typings 目录)新建一个文件,比如叫 “global.d.ts”。在这个文件里,我们可以使用 declare 关键字为第三方模块声明类型。

示例 1:为第三方模块声明类型

// src/typings/global.d.ts

// 告诉 TS 编译器,确实存在一个叫 "old-date-utils" 的模块,别报错了
declare module "old-date-utils" {
    // 在这里精准描述该模块对外暴露了哪些方法
    // 注意:这里只能写类型,绝对不能写函数的花括号实现
    export function formatDate(date: Date, format?: string): string;
    
    export function getDaysDiff(date1: Date, date2: Date): number;
}

分析:

当我们在 global.d.ts 文件中写下这段代码并保存后,再回到业务代码中,此时会发现那个烦人的红色波浪线不仅瞬间消失了,而且当鼠标悬停在 formatDate() 上时,竟然拥有了完美的智能类型提示!

这就是 declare module 的威力,它用于强行在 TypeScript 和纯 JavaScript 代码之间搭建了一座类型互通的桥梁。

TypeScript 声明非代码文件 (如 .vue、.png 等)

在现代前端工程(如 Vite 或 Webpack)中,我们经常需要在代码里直接 import 一张图片、一个 CSS 文件,甚至是一个 .vue 单文件组件。

但 TypeScript 的编译器天生只认识包含代码的文件(如 .ts、.js)。如果你尝试导入一张图片,它会立刻毫不留情地报错:

// 报错:找不到模块“./assets/logo.png”或其相应的类型声明。
import logo from "./assets/logo.png";

为了解决这个问题,我们同样可以利用 declare module 结合 “通配符(*)” 来搞定。这也是为什么在所有的 Vue 脚手架项目中,一定会自带一个 env.d.ts 文件的原因。

示例 2:为静态资源和 Vue 文件编写声明

// src/typings/global.d.ts

// 1. 告诉 TS:只要是导入以 .png 结尾的文件,统一把它当成一个普通的字符串模块
declare module "*.png" {
    const src: string;
    export default src;
}

// 2. 告诉 TS:只要是导入以 .css 结尾的文件,统一不要报错
declare module "*.css" {
    const classes: { readonly [key: string]: string };
    export default classes;
}

// 3. 告诉 TS:只要是导入以 .vue 结尾的文件,统一把它当成 Vue 的组件类型
declare module "*.vue" {
    import type { DefineComponent } from "vue";
    const component: DefineComponent<{}, {}, any>;
    export default component;
}

分析:

通过使用 *.png 或 *.vue 这样的通配符,我们相当于给编译器下达了一道“赦免令”:以后只要遇到这些特定后缀的文件,不管里面装的是什么,都请按照我指定的类型来理解。

有了这段声明的兜底,你在业务代码里就可以随心所欲地导入各种媒体资源和组件了,TypeScript 不仅不再报错,还会给出精准的类型提示。

TypeScript 为 Window 注入属性

除了使用 import 导入的模块之外,前端开发还有一个非常高频的痛点:全局注入的脚本变量。

假设你的项目接入了百度统计、微信 JS-SDK 等,它们往往是通过在 index.html 中插入一段 <script> 标签来初始化的。在运行时,它们会在全局 window 对象上挂载 _hmt 或 wx 对象。

但是在 TypeScript 文件中直接调用它们,绝对会当场翻车:

// 报错:找不到名称“_hmt”。
_hmt.push(["_trackEvent", "button", "click"]);

// 报错:类型“Window & typeof globalThis”上不存在属性“wx”。
window.wx.config({ ... });

此时,我们依然需要使用 “.d.ts” 声明文件来解决这个问题。

示例 3:声明全局变量与扩展 Window 对象

// src/typings/global.d.ts

// 注意:如果当前文件中包含任何顶层的 import 或 export,
// 整个文件就会变成一个局部模块。此时必须使用 declare global 才能扩展全局!

// 为了企业级项目的绝对安全,推荐统包在 declare global 中
declare global {
    // 1. 声明一个全局裸变量
    // 告诉 TS:在运行时的环境中,一定会存在一个叫 _hmt 的变量,它的类型是 any 数组
    var _hmt: any[];

    // 2. 扩展全局的 Window 接口
    // 这是一个极其硬核的技巧:在 TS 中,同名的 Interface 是会自动合并的
    // 所以我们在这里直接声明一个 Window 接口,给它追加一个 wx 属性
    interface Window {
        wx: {
            config: (options: object) => void;
            ready: (callback: () => void) => void;
        };
    }
}

// 强制把该文件变成一个模块(配合 declare global 使用的标准语法)
export {};

分析:

在现代工程中,为了防止声明文件因为意外引入 import 而变成局部模块导致全局声明失效,大厂的标准做法是使用 declare global { ... } 块将全局变量包裹起来,并在底部加上 export {}。这样无论文件怎么变,全局类型的扩展都绝对生效。

第三方类型包 (@types) 的底层逻辑

到这里,有小伙伴可能会问:“每次遇到没有类型的 JavaScript 库,都要我们自己手写 declare module,这样岂不是要累死?”

幸好,TypeScript 社区有一群无私的大神,他们建立了一个名为 DefinitelyTyped 的开源仓库。这群大神几乎把全世界所有知名的纯 JavaScript 库(如 jQuery、Lodash、Express),统统手写了一遍 “.d.ts” 声明文件,并发布到了 NPM 上。这就是我们在装包时经常看到的 @types/xxx。

假设我们要在项目中使用 lodash:

示例 4:优雅地使用社区类型包

# 1. 安装纯 JavaScript 逻辑包(生产依赖)
npm install lodash

# 2. 安装对应的类型声明包(开发依赖)
npm install @types/lodash -D

当执行完第二条命令后,TypeScript 会自动去 node_modules/@types/lodash 目录下读取由社区大神写好的 .d.ts 文件。

从那一刻起,虽然你导入的是用纯 JavaScript 写的 lodash,但在编辑器里,它已经表现得如同用原生 TypeScript 写出来的一样完美。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号