TypeScript 命名空间与模块扩展

在上一节中,我们学习了如何使用 .d.ts 和 declare module 来为那些完全没有类型的原生 JavaScript 库从零开始手写 “类型说明书”。

但在现代企业级开发中,我们遇到的更多是另一种极其让人抓狂的场景:你下载了一个大厂维护的库(比如 axios 或 vue-router),它们本身自带了非常完美的 TypeScript 类型支持。但是,你们团队在二次封装时,给它追加了一些自定义属性(比如在 Axios 的请求配置里加上了 skipErrorHandler 字段)。

此时,TypeScript 会立刻翻脸不认人,并疯狂报错:

对象字面量只能指定已知属性,并且 “skipErrorHandler” 不在类型 “AxiosRequestConfig”中。

为了解决这个痛点,TypeScript 提供了一个更有破坏力也更加优雅的解决方案:命名空间(Namespace)与模块扩展(Module Augmentation)

TypeScript 命名空间的前世今生

在深入“扩展”之前,我们需要先认识一个 TypeScript 特有的关键字:namespace(命名空间)。

在早期的 JavaScript 中,并没有 import 或 export 这样的模块化概念。所有的变量全都堆积在全局作用域里,这样很容易引发 “命名冲突” 的灾难。为了解决这个问题,TypeScript 发明了 namespace,它的底层本质上就是一个立刻执行的闭包函数(IIFE),用来把变量包裹在一个对象里。

示例 1:传统的命名空间用法

// 定义一个名为 Utils 的命名空间
namespace Utils {
    // 必须使用 export 导出,外部才能访问
    export interface User {
        name: string;
        age: number;
    }

    export const formatName = (user: User) => {
        return "尊敬的用户:" + user.name;
    };
}

// 调用时,必须带上命名空间的前缀
const myUser: Utils.User = { name: "Jack", age: 20 };
console.log(Utils.formatName(myUser));

运行结果如下。

尊敬的用户:Jack

分析:

随着 ES6 模块化(ES Module)的全面普及,TypeScript 官方已经强烈不推荐使用 namespace 来组织真实的业务逻辑代码了。我们现在都是使用按需导入的方式,比如:

import { formatName } from "./utils";

但是,在编写类型声明文件(.d.ts)时,namespace 依然是绝对的王者。它经常被用来作为全局类型的 “收纳盒”,防止你的自定义接口污染全局作用域。

TypeScript 全局模块扩展 (declare global)

在上一节中,我们在普通的 “.d.ts” 文件里直接写了 interface Window,并成功扩展了全局的 Window 对象。

但是!只要你的 “.ts” 或 “.d.ts” 文件中出现了哪怕一行顶层的 import 或 export 语句,那么 TypeScript 就会立刻把这个文件视为一个局部模块(Module)。在这个文件里直接写的 interface Window,会被当成是一个仅在当前文件内有效的局部接口,根本无法污染到真正的全局。

这个时候,我们需要使用强悍的跨服广播机制:declare global。

示例 2:在局部模块中强行扩展全局对象

// 只要有这一句,当前文件就是一个局部模块
import { ref } from "vue";

// 强行打开全局作用域
declare global {
    // 扩展全局 Window 接口
    interface Window {
        myAppVersion: string;
        // 甚至可以注入命名空间来组织更复杂的全局变量
        Wechat: {
            sdkVersion: string;
            share: () => void;
        };
    }

    // 扩展原生 String 对象的原型链(面试高频考点)
    interface String {
        toTitleCase(): string;
    }
}

// 完美调用,拥有极致的代码提示
window.myAppVersion = "v1.0.0";
const title = "hello typescript".toTitleCase();

分析:

declare global 是现代前端框架二次封装时的绝对利器。当我们需要在 Vue 组件或者封装的网络请求模块里,为全局的 windowdocument 甚至是原生 ArrayString 挂载你自己的方法时,这层包裹必不可少。

在上面的例子中,我们给 String 接口追加了 toTitleCase() 方法,这只是在“类型层面”欺骗了 TypeScript 编译器,让它不再报错。

但是,TypeScript 的类型声明在编译成 JavaScript 后会被完全抹除。如果你直接运行 const title = "hello".toTitleCase(),浏览器会立刻抛出极其致命的运行时错误:

TypeError: "hello".toTitleCase is not a function

因为在真实的 JavaScript 引擎里,原生的 String.prototype 身上根本就没有这个方法!

因此,在扩展原生对象的原型链时,我们不仅要写类型声明,还必须在项目的入口文件(如 Vue/React 的 main.ts)中,补上它真实的 JavaScript 业务逻辑:

// 1. 类型声明 (global.d.ts)
declare global {
    interface String {
        toTitleCase(): string;
    }
}

// 2. 真实业务实现 (main.ts 的最顶部)
// 必须向真实的原型链上挂载这个方法,运行时才不会崩溃
String.prototype.toTitleCase = function() {
    // 将首字母转换为大写的具体逻辑
    return this.replace(/\b\w/g, c => c.toUpperCase());
};

// 3. 安全调用
const title = "hello typescript".toTitleCase();
console.log(title); // 运行结果: Hello Typescript

小伙伴们一定要牢记这句话:声明文件只负责骗过编译器,真实的逻辑还得靠 JavaScript 自己来扛。

TypeScript 实战:扩展第三方库 (Axios)

这才是咱们这一节中真正的重头戏。小伙伴们思考一下:如何在不修改源码的前提下,给 axios 原本完美的类型中 “塞入” 我们自己的业务字段呢?

其实,核心原理是利用 TypeScript 的 “接口合并(Interface Merging)” 的特性。 TypeScript 允许你在外部使用 declare module "包名",并在里面写上与原包中同名的 Interface,编译器在底层会自动把这两个 Interface 的属性合并在一起

假设你们公司要求在发起 Axios 请求时,可以传入一个 showLoading 属性来控制是否显示全局加载动画。

示例 3:给 Axios 的请求配置追加自定义属性

// src/typings/axios.d.ts

// 1. 必须先导入原版的 axios,以确保 TS 能找到原版类型的基准
import "axios";

// 2. 核心魔法:声明我们要对 "axios" 这个模块进行“扩展手术”
declare module "axios" {
    // 3. 找到 axios 源码中定义请求配置的那个核心接口名(通常通过按住 Ctrl 点击源码查看)
    // Axios 源码中叫 AxiosRequestConfig
    export interface AxiosRequestConfig {
        // 在这里追加我们业务自定义的属性
        showLoading?: boolean;
        skipErrorHandler?: boolean;
    }
}

编写完上述扩展后,在你的业务代码中:

import axios from "axios";

// 发起请求
axios.get("/api/user/info", {
    params: { id: 1001 },
    // 见证奇迹的时刻
    // 以前这里会爆红,现在不仅不报错,当你敲下 sh 的时候,编辑器会自动提示 showLoading!
    showLoading: true,
    skipErrorHandler: false
}).then(res => {
    console.log("请求成功");
});

分析:

这就是 TypeScript “接口合并(Interface Merging)” 特性的终极威力!

当我们在 declare module "axios" 中重新声明了 AxiosRequestConfig 接口后,TypeScript 编译器并不会覆盖掉原版源码中的接口,而是极其聪明地把我们新增的 showLoading 和 skipErrorHandler 这两个属性,悄悄合并到了原版的配置项中。这样一来,我们既享受了原版 Axios 提供的几百个原生属性的类型推导,又完美注入了团队专属的业务逻辑。

TypeScript 扩展 Vue 3 的全局属性

如果小伙伴们是一名 Vue 3 开发者,那么一定会遇到过这个问题:你在 main.ts 中通过 app.config.globalProperties.$http = axios 挂载了一个全局方法。但是在 “.vue” 文件的 <template> 或 Options API 中调用 this.$http 时,TypeScript 会报错拦截:

类型 “ComponentPublicInstance” 上不存在属性 “$http”。

实际上,Vue 3 官方专门为我们预留了一个扩展 “后门”,结合刚刚学到的 declare module,我们可以十分优雅地解决这个问题。

示例 4:为 Vue 3 注入全局类型

// src/typings/vue.d.ts

// 必须引入 vue 以便扩展
import "vue";
import type { AxiosInstance } from "axios";

// 扩展 vue 模块
declare module "vue" {
    // Vue 3 官方指定的全局属性扩展接口
    export interface ComponentCustomProperties {
        $http: AxiosInstance;
        $toast: (msg: string) => void;
    }
}

分析:

通过这种方式,我们巧妙地渗透到了 Vue 3 的底层类型系统中。当你在组件中键入 this.$ 时,你的专属全局方法会和 Vue 原生的 $router、$store 一同并列出现在代码提示的候选框中,开发体验直接拉满!

给站长反馈

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

邮箱:lvyenet@vip.qq.com

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