关于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,甚至还能当做正常的函数作用域来执行,这点,对象做得到吗?
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的作用是吧(接受礼物 !== 我接受你)
"阿甘!!!他*的!!他*的,你真是个天才!这是我听过最了不起的回答,你的智商一定有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声明某个变量或者函数的类型
-
声明某个变量的类型
例如当我们在a文件中声明了全局变量bus,那么我们在b文件中应该可以正常访问,但是通常情况下我们是无法访问的,因为ts编译器会告知我们未找到bus的相关定义。
那我们该如何告知ts编译器bus的类型呢?通过
const bus: XXX = XXX来实现吗?(当然不是)那么我们此时就可以通过从外部引入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') -
声明函数的类型
同理,当我们需要为一个ts无法查找到定义的函数补充类型时,我们可以通过
declare function a(param1: string; param2: number;): void的方式来声明其类型而解决ts类型检查不通过的问题
通过declare声明namespace
或许看到这里的小伙伴会有疑惑:namespace不是直接用吗?而且再说了,不是说declare只能有定义而不能有具体实现?这是怎么个事嘞?
没错,declare只能定义而不能有具体实现,所以我们在通过declare声明namespace的时候是不可以带有任何具体实现的,当然了,这一点也是原则上的,因为不同的ts规则下有的不会报错。他的作用就是帮助ts类型进行命名空间的隔离。
- 不会产生任何js产物
- 内部无需export外部即可使用
其它
同时declare还可以为enum,interface,type补充声明,这点就不赘述了,毕竟通常都是直接定义的hh。
.d.ts文件
可以理解为(declare ts)原则上只包含定义而不包含实现的文件,并且最终不会被编译成任何产物,并且.d.ts只为TypeScript服务,其所有的代码,都不会对最终生成的产物造成影响
.d.ts文件会有两种特殊情况,分别是作为声明文件和作为模块文件存在,但是无论作为哪种文件存在,都符合上述的原则,而这两种文件有什么区别呢?
声明文件
当.d.ts文件作为声明文件存在时,会被作为全局脚本加载进编译器中,也就是说其中声明的所有内容,都可以在外界不需要从该文件中引入声明的内容即可使用,这一点可以从文章开头提到的我之前写的文章中看到:通过.d.ts方式声明interface极大的减少了我们对一个interface或者type的重复引用
模块文件
什么情况下会作为模块文件存在呢?当文件中顶级作用域使用了import和export关键字的时候,当前.d.ts文件会被作为模块文件解析。
而模块文件和声明文件有什么区别呢?
首先,模块文件丧失了声明文件无需引入文件即可使用定义的特点,也就是当我们需要使用作为模块文件中的.d.ts文件中定义的内容的时候,我们需要在对应的ts文件中显式引入。
不过特别的是,即便作为模块文件,.d.ts文件中的内容无需export也可以在别的文件import使用
为什么开头会说原则上只能定义而不能实现,因为貌似不同的ts规则下,对于在.d.ts中实现方法也不会报错,并且这些方法也可以成功被引用。但是因为最终.d.ts文件不会被编译成JS产物的特性,所以最终会无法访问到具体内容而报错。所以也倡导不在.d.ts中写任何JS产物需要用到的东西。
.d.ts的使用
通常我们会把对一个模块的定义统一放到.d.ts文件之中,这里我把模块分为两种:
-
本地JS文件
假设我们当前有一个文件叫做utils.js,其中导出了一些变量
export function debounce(handle, delay) { // something } export const store = { // something } const d = new Date(); export default d;那么当我们在ts中引入js文件时,会告知我们未找到该模块的类型声明文件
那么此时我们只需要遵循
[模块名].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字段,不过规范貌似需要,所以最好补上)
-
第三方库的类型补充
当我们使用第三方库的时候,我们可以通过
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`);
}
这样就解决了我的问题
总结
- namespace可以看做一个ES模块,专门为变量隔离而存在的
- interface的合并特性可以帮助我们基于已有的类型进行拓展
- declare关键字只用于定义,其所有内容不会被编译成js产物
- declare声明module、namespace时,其中所有内容都是可直接引入或者通过命名空间名字进行访问的
- .d.ts文件的主要用途是用于为模块代码进行集中的类型说明,对于本地js文件和node_modules中的模块有不同的规则,前者需要遵循.d.ts的命名规则,后者需要在.d.ts中通过declare module '[模块名]'的方式进行说明
本次主要对一些常用到的点以及一些用法进行了说明,其实对于.d.ts的理解还有更多,例如我们通常情况下编译ts代码是只有js产物的,如果想要作为一个模块发布的话,使用者无法获得类型提示,这里就需要通过files、declaration属性帮助我们保留文件,还有三斜线指令等等,当然这些更多的可以移步到阮一峰老师的文章观看。
参考文章: