关于ts中declare关键字和.d.ts文件的使用

565 阅读12分钟

关于ts中declare关键字和.d.ts文件的使用

前言

在23年实习的时候,曾经对ts中.d.ts文件、namespace、declare进行了梳理,当时梳理的时候大多信息来自于网上已有的文章以及自己的测试,其实很多地方回过头看没有讲得特别清楚。最近在做一个node服务的时候,又遇到了关于类型声明的问题,又涉及到这一块的知识,回过头看发现依然没有掌握,并且网上的信息依然少得可怜,于是决定重新整理一篇发出来。

不过有一点就不重复讲了,在上述提到的之前写的文章中有提到过——ts的内容大多是为了进行类型检查,其并不会对最终生成的js代码造成影响(注意是大多数而不是全部!)

之前写的一篇文章

正文

首先先对几个专业术语进行解释

namespace命名空间

用于解决变量命名冲突而存在

没错,这就是namespace的作用,可以把他理解为我们编写的ES模块,在不同的js文件之间,其中定义的变量是不会产生冲突的,而在同一个文件中,如果需要用到同样的变量,那么为了解决变量命名冲突的问题,namespace怕是当仁不让了。

namespace space1 {
    export function sayName() {
        console.log("space1")
    }
}

namespace space2 {
    export function sayName() {
        console.log("space2")
    }
}

space1.sayName(); // space1
space2.sayName(); // space2

注意,我们可以将namespace当作一个单独的es模块去编写代码,和es模块一样的是,如果其中定义的内容需要在外部使用,我们需要显式的通过export关键字进行导出,当然了,namespace和es模块的区别在于其中无法使用export default默认导出,并且我们也无法在namespace中使用import进行模块导入,如果有需要的话我们可以在顶层进行导入即可。

有聪明的小伙伴可能会问,不是说ts的内容大多数都是为ts类型检查服务吗,其不会对js代码造成影响,为什么你这里会有输出嘞??

没错,有眼尖的小伙伴们发现了,namespace正是我们ts中为数不多会影响到最终生成代码的内容之一,我们会发现在namespace中定义的内容最终会被转换成可执行的js代码,并不同与interface、type等内容,其最终不会对最终产物造成影响。

这时候有人问啦,主播主播,你说的namespace貌似的确很强,但是相比于我直接声明一个对象space,space中有一个属性sayName是一个函数来说,还是太平平无奇了,请问有更优秀的地方吗?

有的有的,兄弟有的,直接通过对象来解决命名冲突问题的确可以,但是namespace几乎相当于一个ES模块一样,在其中不但可以导出内容,还能定义interface,type,甚至还能当做正常的函数作用域来执行,这点,对象做得到吗?

image-20250218014523741.png

namespace space1 {
    console.log("because you so beautiful kunkun")

    export interface Person {
        name: string;
    }

    export type PartialPerson = Partial<Person>;

    export class Pet {
        constructor(public name: string, public master: Person['name']) {}
    }

    export function sayName() {
        console.log("space1")
    }
}

这时候又有兄弟要问了,好,那我不用对象,抛开对interface和type等ts独有内容的支持,我用函数作用域不也可以实现?

let space3;

(function (space: any) {
    class Pet {
        constructor(public name: string, public master: string) {}

        sayName() {
            console.log(this.name)
        }
    }

    function sayName() {
        console.log("space3")
    }

    space.Pet = Pet;
    space.sayName = sayName;

    space3 = space;

})(space3 ?? {})

new space3.Pet("cola", "xiaoxin").sayName()     // cola
space3.sayName()    // space3

好好好,你小子抛开事实不谈而否认namespace的作用是吧(接受礼物 !== 我接受你)

image-20250218015414339.png

"阿甘!!!他*的!!他*的,你真是个天才!这是我听过最了不起的回答,你的智商一定有160!你真他*的有天赋,掘友阿甘!!"

我们看看上述定义的space1在经过编译后生成的代码

var space1;
(function (space1) {
    console.log("because you so beautiful kunkun");
    var Pet = /** @class */ (function () {
        function Pet(name, master) {
            this.name = name;
            this.master = master;
        }
        return Pet;
    }());
    space1.Pet = Pet;
    function sayName() {
        console.log("space1");
    }
    space1.sayName = sayName;
})(space1 || (space1 = {}));

没错,实际上namespace确实就借助函数作用域实现了其内部关于JS代码的逻辑,那么关于namespace的介绍我们就暂时告一段落!

不过很遗憾的是,在ES模块问世以后,namespace的方案就已经被替代了,ES模块化成为了更好的上位替代品

(我绝对没有戏耍各位的意思,有的话我只能说我很抱歉,下次还敢!)

interface合并特性

同一个作用域下的interface会被合并

很好理解,在我们写JS代码的时候,当我们使用let和const声明同一个变量的时候,我们往往会出现命名冲突的问题,但在interface中,其表现不是冲突或者覆盖,而是合并,如下示例,我们可以取到Person中对应的字段定义

interface Person {
    name: string;
}

interface Person {
    age: number;
}

type a = Person['age']; // string
type b = Person['name'];    // number

注意:type是不可以合并的哦,会报错的!


declare关键字

declare关键字的作用就是为TS补充缺失的定义

declare可以帮助我们补充ts类型的缺失,而declare还有一个作用就是:用declare标识的内容,必须都是定义而不包含具体实现

要注意的是:declare声明的内容都不会被编译成js产物

那么declare如何运用到实际当中呢?

通过declare声明某个变量或者函数的类型
  1. 声明某个变量的类型

    例如当我们在a文件中声明了全局变量bus,那么我们在b文件中应该可以正常访问,但是通常情况下我们是无法访问的,因为ts编译器会告知我们未找到bus的相关定义。

    那我们该如何告知ts编译器bus的类型呢?通过const bus: XXX = XXX来实现吗?(当然不是)

    image-20250219003415119.png

    那么我们此时就可以通过从外部引入bus的类型(假设为Bus)或者自己补充其类型也可以,然后我们通过declare var bus: Bus即可为标识符bus补充类型声明,此时就可以正常访问了

    即便是通过import引入了某个js文件中的内容,我们同样可以通过declare的方式来补充类型说明

    // 在test.js中export出了test函数,但是没有定义,所以通过declare可以补充定义
    import { test } from './test.js';
    
    declare function test(name: string): void;
    
    const a = test('haha')
    
  2. 声明函数的类型

    同理,当我们需要为一个ts无法查找到定义的函数补充类型时,我们可以通过declare function a(param1: string; param2: number;): void的方式来声明其类型而解决ts类型检查不通过的问题

通过declare声明namespace

或许看到这里的小伙伴会有疑惑:namespace不是直接用吗?而且再说了,不是说declare只能有定义而不能有具体实现?这是怎么个事嘞?

没错,declare只能定义而不能有具体实现,所以我们在通过declare声明namespace的时候是不可以带有任何具体实现的,当然了,这一点也是原则上的,因为不同的ts规则下有的不会报错。他的作用就是帮助ts类型进行命名空间的隔离。

  1. 不会产生任何js产物
  2. 内部无需export外部即可使用
其它

同时declare还可以为enum,interface,type补充声明,这点就不赘述了,毕竟通常都是直接定义的hh。

.d.ts文件

可以理解为(declare ts)原则上只包含定义而不包含实现的文件,并且最终不会被编译成任何产物,并且.d.ts只为TypeScript服务,其所有的代码,都不会对最终生成的产物造成影响

.d.ts文件会有两种特殊情况,分别是作为声明文件和作为模块文件存在,但是无论作为哪种文件存在,都符合上述的原则,而这两种文件有什么区别呢?

声明文件

当.d.ts文件作为声明文件存在时,会被作为全局脚本加载进编译器中,也就是说其中声明的所有内容,都可以在外界不需要从该文件中引入声明的内容即可使用,这一点可以从文章开头提到的我之前写的文章中看到:通过.d.ts方式声明interface极大的减少了我们对一个interface或者type的重复引用

模块文件

什么情况下会作为模块文件存在呢?当文件中顶级作用域使用了importexport关键字的时候,当前.d.ts文件会被作为模块文件解析。

而模块文件和声明文件有什么区别呢?

首先,模块文件丧失了声明文件无需引入文件即可使用定义的特点,也就是当我们需要使用作为模块文件中的.d.ts文件中定义的内容的时候,我们需要在对应的ts文件中显式引入。

不过特别的是,即便作为模块文件,.d.ts文件中的内容无需export也可以在别的文件import使用

为什么开头会说原则上只能定义而不能实现,因为貌似不同的ts规则下,对于在.d.ts中实现方法也不会报错,并且这些方法也可以成功被引用。但是因为最终.d.ts文件不会被编译成JS产物的特性,所以最终会无法访问到具体内容而报错。所以也倡导不在.d.ts中写任何JS产物需要用到的东西

.d.ts的使用

通常我们会把对一个模块的定义统一放到.d.ts文件之中,这里我把模块分为两种:

  1. 本地JS文件

    假设我们当前有一个文件叫做utils.js,其中导出了一些变量

    export function debounce(handle, delay) {
    	// something
    }
    
    export const store = {
    	// something
    }
    
    const d = new Date();
    
    export default d;
    

    那么当我们在ts中引入js文件时,会告知我们未找到该模块的类型声明文件

    image-20250219225229160.png

    那么此时我们只需要遵循[模块名].js --》 [模块名].d.ts的规则,就可以为其定义对应的.d.ts声明文件(注意要在同一个目录下!!这一点貌似没有明确的规定,不过细心的小伙伴应该会发现许多第三方库中同名的js文件往往跟着一个同名的.d.ts文件,而且若是不在一个目录下反正引入会报错,不知道怎么解决)

    export declare function debounce<T extends (...args: any[]) => void>(handle: T, delay: number): T;
    
    export declare const store: {
        set(key: string, value: any): void;
        get(key: string): any;
        remove(key: string): void;
    }
    
    declare const d: Date;
    
    export default d;
    // 或者用这种写法 export = d;
    

    这样就可以帮助我们在ts中使用本地js代码的类型声明了(当然了,其实.d.ts中可以无需使用declare字段,不过规范貌似需要,所以最好补上)

    image-20250219225801877.png

  2. 第三方库的类型补充

    当我们使用第三方库的时候,我们可以通过declare module '[模块名或者正则匹配]'的方式来声明模块,此时我们不需要遵循[模块名].js --》 [模块名].d.ts的规则也可以。这里我用阮一峰老师的例子展示吧:

    import { Foo as Bar } from "moduleA";
    
    // 为已有ts声明的模块拓展,此处利用了接口合并的特性为Foo拓展了custom属性,如果想要为已有ts声明的库进行拓展,记得要引入该库,例如开头的代码
    declare module "moduleA" {
      // declare文件中默认无需export,等价于export interface Foo extends Bar {...}
      interface Foo extends Bar {
        custom: {
          prop1: string;
        };
      }
    }
    
    // 通过正则进行匹配
    declare module "my-plugin-*" {
      interface PluginOptions {
        enabled: boolean;
        priority: number;
      }
    
      function initialize(options: PluginOptions): void;
        
      // 表示模块中默认导出的内容为initialize
      export = initialize;
    }
    

实际使用

最近我在写一个node项目的时候,我希望在Koa实例上添加自定义属性,那么此时就犯难了,因为原本的Koa库声明并没有为该实例预留自定义字段的声明,死活赋值不上,于是我通过自定义ts类型的方式解决了该问题

/**
 * 对koa库中Koa实例进行属性扩展
 */
import Koa from 'koa'

declare module 'koa' {
    // 应用配置
    interface StartOptions {
        name: string;
    }

    // Tip: 为Koa实例添加自定义属性,但是所有的属性都应该是可选的,不然在获取实例时无法正常赋值
    interface CustomPropertiesKoa extends Koa{
        // 应用配置
        options?: StartOptions;
        // 基础路径
        baseDir?: string;
        // 业务目录路径
        businessPath?: string;
    }
}

其实理想状态是拓展Koa类的类型说明,可惜class貌似没有interface合并的特性,就只能通过单独声明一个类型来解决了

import Koa, { CustomPropertiesKoa, StartOptions } from 'koa';
import Path, { sep } from 'path';

export function start(options?: StartOptions) {

    // 创建一个Koa实例
    const app: CustomPropertiesKoa = new Koa();

    // 应用配置
    app.options = options;
    // 基础路径
    app.baseDir = process.cwd();
    // 业务文件路径
    app.businessPath = Path.resolve(app.baseDir, `.${sep}app`);
}

这样就解决了我的问题

总结

  1. namespace可以看做一个ES模块,专门为变量隔离而存在的
  2. interface的合并特性可以帮助我们基于已有的类型进行拓展
  3. declare关键字只用于定义,其所有内容不会被编译成js产物
  4. declare声明module、namespace时,其中所有内容都是可直接引入或者通过命名空间名字进行访问的
  5. .d.ts文件的主要用途是用于为模块代码进行集中的类型说明,对于本地js文件和node_modules中的模块有不同的规则,前者需要遵循.d.ts的命名规则,后者需要在.d.ts中通过declare module '[模块名]'的方式进行说明

本次主要对一些常用到的点以及一些用法进行了说明,其实对于.d.ts的理解还有更多,例如我们通常情况下编译ts代码是只有js产物的,如果想要作为一个模块发布的话,使用者无法获得类型提示,这里就需要通过files、declaration属性帮助我们保留文件,还有三斜线指令等等,当然这些更多的可以移步到阮一峰老师的文章观看。

参考文章:

  1. declare 关键字 | 阮一峰 TypeScript 教程
  2. d.ts 类型声明文件 | 阮一峰 TypeScript 教程
  3. How Does The Declare Keyword Work In TypeScript?