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