写在开头
网络上大部分 Typescript 教程都在告诉大家如何使用类型体操更好的组织你的代码。
但是针对于声明文件(Declaration Files)的相关内容却是少之又少。
这篇文章中,我会带你着重讲述 TypeScript Declaration Files 的用法让你的 TS 功底更上一层。
TypeScript 模块解析规则
在开始之前,我们先来聊聊 TS 文件的加载策略。
掌握加载策略才会让我们实实在在的避免一些看起来毫无头绪的问题。
TS 中的加载策略分为两种方式,分别为相对路径和绝对路径两种方式。
首先我们来看看相对模块的加载方式:
TypeScript 将 TypeScript 源文件扩展名(.ts
、.tsx
和.d.ts
)覆盖在 Node 的解析逻辑上。同时TypeScript 还将使用package.json
named中的一个字段types
来镜像目的"main"
- 编译器将使用它来查找“主”定义文件以进行查阅。
比如这样一段代码:
// 假设当前执行路径为 /root/src/modulea
import { b } from './moduleb'
此时,TS 对于 ./moduleb
的加载方式其实是和 node 的模块加载机制比较类似:
- 首先寻找
/root/src/moduleb.ts
是否存在,如果存在使用该文件。 - 其次寻找
/root/src/moduleb.tsx
是否存在,如果存在使用该文件。 - 其次寻找
/root/src/moduleb.d.ts
是否存在,如果存在使用该文件。 - 其次寻找
/root/src/moduleB/package.json
,如果 package.json 中指定了一个types
属性的话那么会返回该文件。 - 如果上述仍然没有找到,之后会查找
/root/src/moduleB/index.ts
。 - 如果上述仍然没有找到,之后会查找
/root/src/moduleB/index.tsx
。 - 如果上述仍然没有找到,之后会查找
/root/src/moduleB/index.d.ts
。
可以看到 TS 中针对于相对路径查找的规范是和 nodejs 比较相似的,需要注意我在上边已经额外加粗了。
Ts 在寻找文件路径时,在某些条件下是会按照目录去查找 .d.ts
的。
非相对导入
在了解了相对路径的加载方式之后,我们来看看关于所谓的非相对导入是 TS 是如何解析的。
我们可以稍微回想一下平常在 nodejs 中对于非相对导入的模块是如何被 nodejs 解析的。没错,它们的规则大同小异。
比如下面这段代码:
// 假设当前文件所在路径为 /root/src/modulea
import { b } from 'moduleb'
/root/src/node_modules/moduleB.ts
/root/src/node_modules/moduleB.tsx
/root/src/node_modules/moduleB.d.ts
/root/src/node_modules/moduleB/package.json
(如果它指定了一个types
属性)/root/src/node_modules/@types/moduleB.d.ts
/root/src/node_modules/moduleB/index.ts
/root/src/node_modules/moduleB/index.tsx
/root/src/node_modules/moduleB/index.d.ts
typescript 针对于非相对导入的 moduleb 会按照以上路径去当前路径的 node_modules 中去查找,如果上述仍然未找到。
此时,TS 仍然会按照 node 的模块解析规则,继续向上进行目录查找,比如又会进入上层目录 /root/node_modules/moduleb.ts ...
进行查找,直到查找到顶层 node_modules 也就是最后一个查找的路径为 /node_modules/moduleB/index.d.ts
如果未找到则会抛出异常 can't find module 'moduleb'
。
上述查找规则是基于 tsconfig.json 中指定的moduleResolution:node
,当然还有classic
不过classic
规则是 TS 为了兼容老旧版本,现代代码中基本可以忽略这个模块查找规则。
解析 *.d.ts
声明
上边我们聊了聊 TS 中对于加载两种不同模块的方式,可是日常开发中,经常有这样一种场景。
比如,在 TS 项目中我们需要引入一些后缀为 png 的图片资源,那么此时 TS 是无法识别此模块的。
image.png
解决方法也非常简单,通常我们会在项目的根目录中也就是和 TsConfig.json 平级的任意目录中添加对应的声明文件 image.d.ts
:
image.png
可以看到,通过定义声明文件的方式解决了我们的问题。
可是,你有思考过按照上边的 typescript 对于模块的加载方式,它是怎么加载到我们声明的 image.d.ts
的吗?
这是一个有意思的问题,按照上边我们提到的模块加载机制要么按照相对模块机制查找,要么按照对应的 node 模块解析机制进行查找。
怎么会查找到定义在项目目录中的 image.d.ts
呢?
本质上我们引入任何模块时,加载机制无非就是我们上边提到的两种加载方式。
不过,这里有一个细小的点即是 ts 编译器会处理 tsconfig.json 的 file、include、exclude
对应目录下的所有 .d.ts 文件:
简单来说,ts 编译器首先会根据 tsconfig.json 中的上述三个字段来加载项目内的 d.ts
全局模块声明文件,自然由于 '.png' 文件会命中全局加载的 image.d.ts
中的 声明的 module
所以会找到对应的文件。
include 在未指定 file 配置下默认为**
,表示 tsc 解析的目录为当前 tsconfig.json 所在的项目文件夹。
关于 file、include、exclude 三者的区别我就不详细展开了,本质上都是针对于 TSC 编译器处理的范围。后续如果大伙有兴趣,我可以单独开一个 tsconfig.json 的文章去详细解释配置。
详解 typescript 声明文件
上边我们讲述了 TypeScript 是如何来加载我们的模块的,在了解了上述前置知识后。
让我们一起来看看编写一份声明文件必备的知识储备吧!
大多数同学的想法可能是“我又不编写库声明,学这个没什么用处。”
其实不是这样的,学会类型声明文件的编写并不仅仅是为了编写库声明。大多数时候,我们在日常业务中对于第三方库需要做一些自定一的扩展扩充。
大多数时候一些库提供的泛型参数其实并不能很好的满足我们的需求,所以利用 *.d.ts
扩展第三方库在业务中是非常常见的需求。
废话不多说了~我们正式进入正文。
什么是声明文件
为了照顾一些接触 TS 并不是很多的小伙伴,我们简单聊聊什么是 Typescript 声明文件。
通常我们将有关于一些全局变量或者引入的模块对应的类型声明语句存在一个单独的文件,这样的文件就被成为声明文件。
注意,声明文件一定要以
[name].d.ts
结尾。
比如我们在项目内定义一个 jquery.d.ts
时:
// src/jQuery.d.ts
// 定义全局变量 jQuery,它是一个方法
declare var jQuery: (selector: string) => any;
之后我们在项目内的 TS 文件中就可以在全局自由的使用声明的 jQuery
了:
jQuery('#root')
正常来说,ts 会解析项目中所有的 *.ts
文件,当然也包含以 .d.ts
结尾的文件。所以当我们将 jQuery.d.ts
放到项目中时,其他所有 *.ts
文件就都可以获得 jQuery
的类型定义了。
当然,上边我们提过到关于 tsc 文件的编译范围。所以如果找不到情况可以自行检查对应的files
、include
和exclude
配置。
全局变量
declare var
声明全局变量declare function
声明全局方法declare class
声明全局类declare enum
声明全局枚举类型declare namespace
声明(含有子属性的)全局对象interface 和 type
声明全局类型
上述罗列了 6 中全局声明的语句,我们可以通过 declare
关键字结合对应的类型,从而在任意 .d.ts
中进行全局类型的声明。
比如我们以 namespace 举例:
假设我们的业务代码中存在一个全局的模块对象 MyLib,它拥有一个名为 makeGreeting 的方法以及一个 numberOfGreetings 数字类型属性。
当我们想在 TS 文件中使用该 global 对象时:
image.png
TS 会告诉我们找不到
myLib
。
原因其实非常简单,typescript 文件中本质上是对于我们的代码进行静态类型检查。当我们使用一个没有类型定义的全局变量时,TS 会明确告知找不到该模块。
当然,我们可以选择在该文件内部对于该模块进行定义并且进行导出,Like this:
export namespace myLib {
export let makeGreeting: (string: string) => void
export let numberOfGreetings: number
}
let result = myLib.makeGreeting("hello, world");
console.log("The computed greeting is:" + result);
let count = myLib.numberOfGreetings;
上述的代码的确在模块文件内部定义了一个 myLib 的命名空间,在该文件中我们的确可以正常的使用 myLib。
可是,在别的模块文件中我们如果仍要使用 myLib 的话,也就意味着我们需要手动再次 import 该 namespace
。
这显然是不合理的,所以 TS 为我们提供了全局的文件声明 .d.ts
来解决这个问题。
我们可以通过在 ts 的编译范围内声明 [name].d.ts
来定义全局的对象的命名空间。 比如:
image.png
可以看到上图的右边,此时当我们使用 myLib
时, TS 可以正确的识别到他是 myLib 的命名空间 。
如果你的[name].d.ts
不生效,那么仔细检查你的tsconfig.json -> include
设置~
虽然说随着 ES6 的普及,ts 文件中的 namespcae 已经逐渐被淘汰掉了。
但是在类型声明文件中使用 declare namespace xxx
声明类似全局对象仍然是非常实用的方法。
声明合并
上边我们讲述了如何在类型声明文件中进行全局变量的声明,接下来其他部分之前我们先来聊聊 TS 中的声明合并。
接口自动合并
interface Props {
name: string;
}
interface Props {
age: 18;
}
const me: Props = {
name: 'wang.haoyu',
age: 18
}
上述的代码一目了然,在多个相同名称的 interface 中同名的 interface 声明会被自动合并。
但是需要注意的是,无论哪种声明合并必须遵循合并的属性的类型必须是唯一的,比如:
interface Props {
name: string;
}
// 后续属性声明必须属于同一类型。属性“name”的类型必须为“string”,但此处却为类型“18”
interface Props {
name: 18;
}
declare 合并
image.png
这里可以看到在右边的声明文件中进行了名为 axios 全局命名空间声明,同时在左边的文件中我们使用了 axios.Props
类型。
其实本质上就是相同命名空间内的接口合并,当然我们可以利用 declare 声明合并达到更多的效果。后续我们会详细提到。
Npm 包类型声明
接下来我们来看看关于 Npm 包类型的声明文件如何编写。
上述我们提到过 TS 是如何加载对应 npm 包的声明文件的。
现在我们假设一种场景下,我们目前使用了 axios 这个库。假设目前这个库并没有对应的类型声明文件,显然当我们在代码中引入这个库时候一定是会报错的。
此时,关于 Npm 包类型的声明会很好的帮助我们来解决这个问题:
首先我们在上述说到的,当我们在代码中执行
import axios from 'axios'
它会按照路径依次去查找,正常来说它会去 node_modules 下的各个路径区查找对应的模块。那么我们需要将自定义的声明文件书写在 node_modules 中去吗?
这显然是不合理的,因为 node_modules 中的目录是非常不稳定的。
此时,我们可以首先在 tsconfig.json 中配置对应的 alias 别名配置,达到引入 axios 时自动帮我们找到对应的 .d.ts
文件声明文件:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"axios": [
"types/axios.d.ts"
]
}
}
}
这里我们配置了寻找的别名。
之后,我们在项目的根目录(tsconfig.json
)平级新建一个 types/axios.d.ts
。
// axios.d.ts
// 利用 export 关键字导出 name 变量
export const name: string;
此时在项目中的任意文件,我们就可以使用导出的 name 变量:
import { name } from 'axios'
console.log(name) // string 类型的 name 变量
当然你可以为模块内添加对应各种各样的类型声明。
上述我们就实现了一个简单的模块定义文件,关于 npm 包类型的声明有以下几种语法需要和大家强调下:
export
导出变量export namespace
导出(含有子属性的)对象export default
ES6 默认导出export =
commonjs 导出模块
export 关键字
需要额外留意的是npm 包的声明文件与全局变量的声明文件有很大区别。
在 npm 包的声明文件中,使用 declare
不再会声明一个全局变量,而只会在当前文件中声明一个局部变量。只有在声明文件中使用 export
导出,然后在使用方 import
导入后,才会应用到这些类型声明。
export
的语法与普通的 ts 中的语法类似,需要注意的是d.ts
的声明文件中禁止定义具体的实现。
比如:
// types/axios/index.d.ts
// 导入变量
export const name: string;
// 导出函数
export function createInstance(): AxiosInstance;
// 导出接口 接口导出省略 export
interface AxiosInstance {
// ...
data: any;
}
// 导出 Class
export class Axios {
constructor(baseURL: string);
}
// 导出枚举
export enum Directions {
Up,
Down,
Left,
Right
}
此时我们在 TS 文件中就可以自由的使用这些导出的变量和类型了:
import { name, createInstance, AxiosInstance, Axios, Directions } from 'axios'
console.log(name) // string
// 通过 createInstance 返回 AxiosInstance 实例
const instance: AxiosInstance = createInstance()
new Axios('/')
const a = Directions.Up
混用 declare
和 export
上边我们提到过,在 npm 包的声明文件中,使用 declare
不再会声明一个全局变量,而只会在当前文件中声明一个局部变量。
同样上边的声明我们可以改成通过 declare + export 声明:
// types/axios/index.d.ts
// 变量
declare const name: string;
// 函数
declare function createInstance(): AxiosInstance;
// 接口 接口可以省略 export
interface AxiosInstance {
// ...
data: any;
}
// Class
declare class Axios {
constructor(baseURL: string);
}
// 枚举
enum Directions {
Up,
Down,
Left,
Right
}
export {
name, createInstance, AxiosInstance, Axios, Directions
}
export namespace
与 declare namespace
类似,export namespace
用来导出一个拥有子属性的对象:
// types/foo/index.d.ts
// 导出一个 Axios 的命名空间
export namespace Axios {
const name: string;
namespace AxiosInstance {
function getUrl(): string;
}
}
// xx.ts
import { Axios } from 'axios'
Axios.AxiosInstance.getUrl()
export default
在 ES6 模块系统中,使用export default
可以导出一个默认值,使用方可以用import foo from 'foo'
而不是import { foo } from 'foo'
来导入这个默认值。
同样,在类型声明文件中,我们可以通过 export default
用来导出默认值的类型。比如:
image.png
需要额外注意的是只有function
、class
和interface
可以直接默认导出,其他的变量需要先定义出来,再默认导出。
export =
当然,我们上述提到的都是关于 ESM 相关的类型声明文件。
TS 中的类型声明文件同样为我们提供了使用 export =
的 CJS 模块相关语法:
// types/axios.d.ts
export = axios
declare function axios(): void
import axios = require('axios')
可以看到上述的代码,我们通过 export = axios
定义了一个相关的 CJS 模块语法。
需要额外注意的是在 ts 中若要导入一个使用了export =
的模块时,必须使用TypeScript提供的特定语法import module = require("module")
。
在日常业务中,不可避免我们会碰到一些相关 commonjs 规范语法的模块,那么当我们需要扩充对应的模块或者为该模块声明定义文件时,就需要使用到上述的 export =
这种语法了。
当然,export =
这种语法不仅仅可以支持 cjs 模块。它也同样是 ts 为了 ADM 提出的模块兼容声明。有兴趣的朋友可以详细查阅官方文档。
扩展全局变量
在类型声明文件中对于全局变量的扩展非常简单,我们仅仅需要利用声明合并的方式即可对于全局变量进行扩展。
举个例子,假设我们想为 string 类型的变量扩展一个 hello 的方法。正常扩展后全局调用该方法 TS 是会提示错误的。
此时就需要我们通过类型定义文件来进行全局变量的扩展:
// types/index.d.ts 利用接口合并,扩展全局的 String 类型
// 为它添加一个名为 hello 的方法定义
interface String {
hello: () => void;
}
此后,我们就可以直接在全局中自由的调用该 hello 方法了:
'a'.hello()
在 Npm 包、UMD 中扩展全局变量
在声明文件中扩展全局变量利用合并声明的方式可以非常容易的进行扩展。
而在 Npm 包、UMD 的声明文件中如果我们想扩展全局变量那应该如何做呢。
上边我们说到过,任何声明文件中只要存在 export/import
关键字的话,该声明文件中的 declare 都会变成模块内的声明而非全局声明。
比如,我们在自己定义的 axios.d.ts 中:
// types/axios.d.ts
declare function axios(): string;
// 此时声明的 interface 为模块内部的String声明
declare interface String {
hello: () => void;
}
export default axios;
// index.ts
'a'.hello() // 类型“"a"”上不存在属性“hello”
此时内部声明的 String 接口扩展被认为是模块内部的接口拓展,我们在全局中使用是会提示错误的。
针对于 Npm 包中需要进行全局声明的话,TS 同样为我们提供了 declare global
来解决这个问题:
// types/axios.d.ts
declare function axios(): string;
// 模块内部通过 declare global 进行全局声明
// declare global 内部的声明语句相当于在全局进行声明
declare global {
interface String {
hello: () => void;
}
}
export default axios;
// index.ts
'a'.hello() // correct
扩展 Npm 包类型
大多数时候我们使用一些现成的第三方库时都已经有对应的类型声明文件了,但有些情况下我们需要对于第三方库中某些属性进行额外的扩展或者修改。
直接去修改 node_modules 中的第三方 TS 类型声明文件显然是不合理的,那么此时就需要我们通过类型声明文件扩展第三方库的声明。
同样 TypeScript 提供给了我们一种 declare module
的语法来进行模块的声明。
通常在我们可以利用 declare module
语法在进行新模块的声明的同时,也可以使用它来对于已有第三方库进行类型定义文件的扩展。
在进行模块扩展时,需要额外注意如果是需要扩展原有模块的话,需要在类型声明文件中先引用原有模块,再使用 declare module
扩展原有模块。
比如,通常我们在项目中使用 axios
库时,希望在请求的 config 中支持传递一些自定义的参数,从而在全局拦截器中进行拿到我们的自定义参数。
如果直接在 TS 文件下进行属性赋值和取值的话,TS 会抛出异常的:
image.png
同样,我们可以利用 declare module
来进行第三方 NPM 包的扩展,我们可以看到 axios 请求中第二个参数的类型为 AxiosRequestConfig
类型。
那么我们仅仅需要对于这个类型进行扩展就 OK 了:
image.png
此时,我们在回到刚才的代码中可以发现无论我们是取值还是赋值,TS 都可以很好的帮我们进行出类型推断。
当然,这只是一个非常简单的例子。但是这个场景我相信对于大家来说都非常常见,不过模块的扩展本质上大同小异~
三斜线指令
其实三斜线指令在是 TS 在早期版本中为了描述多个模块之间的相互依赖关系产生的语法。
目前,随着 ESM 模块语法的推广,官方也不再建议使用三斜线指令来声明模块依赖了。
但是目前来说三斜线指令的存在仍然有它独特的作用,接下来我们一起来看看。
/// <reference types="..." />
所谓 /// <reference types="..." />
是三斜线指令的一种声明方式,这个指令是用来声明依赖的。
表示该声明文件依赖了 types='...'
中对于 ...
的依赖,在进行了上述的声明后我们就可以在自己的声明文件中使用types='...'
中声明的变量了。
比如:
/// <reference types="jquery" />
上述代码中,我们在声明文件的开头使用了三斜线指令。那么此时我们就可以在接下来的文件中使用 jquery
声明文件中声明的变量了。
比如 jquery
中声明了对应的 declare namespace JQuery
,那么我们同样可以在自己的声明文件中使用这个依赖:
/// <reference types="jquery" />
declare function foo(options: JQuery.AjaxSettings): string;
通常,我们可以利用三斜线指令的 types
来声明对于全局变量的依赖,从而避免使用import
语句将声明文件变为局部模块。
主要特别注意的是,如果使用了三斜线指令引入一个模块时,比如:
/// <reference types="axios" />
因为 Axios 是一个模块,所以我们无法直接在声明文件中使用任何模块内部声明的变量。
之所以上边的用例能通过三斜线指令正常的使用 JQuery
全局变量,是因为在 jquery
的声明文件中声明了全局的 namespcae JQuery
。
/// <reference path="JQueryStatic.d.ts" />
当我们的全局变量的声明文件太大时,同样我们可以通过三斜线指令将该声明文件拆分为多个文件。
然后在一个入口文件中将它们一一引入,来提高代码的可维护性。
比如 jQuery
的声明文件就是这样:
// node_modules/@types/jquery/index.d.ts
/// <reference types="sizzle" />
/// <reference path="JQueryStatic.d.ts" />
/// <reference path="JQuery.d.ts" />
/// <reference path="misc.d.ts" />
/// <reference path="legacy.d.ts" />
export = jQuery;
其中用到了 types
和 path
两种不同的指令。它们的区别是:types
用于声明对另一个库的依赖,而 path
用于声明对另一个文件的依赖。
同时需要额外留意的是,在使用 path 进行文件拆分时每个单独的文件都是一个独立的文件模块系统。
比如上述的 JQuery 声明文件中,我们可以明显的看到 export = jQuery
在最终将 JQuery
以 CJS 的形式进行了导出,表示它是一个模块。
但是由于 /// <reference path="misc.d.ts" />
模块文件中声明了全局的 namespace JQuery
。
所以我们在代码中才可以正常的使用 JQuery
这个全局变量。
简单来说 jquery
根声明文件是一个模块,而它内部使用的三斜线指令引入的 /// <reference path="misc.d.ts" />
并非是一个模块而是声明了一个全局命名空间。
所以三斜线指令并不会引入入口是模块文件,而将依赖的模块也变为模块声明。