添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
侧边栏

TypeScript模块机制

发布于 | 分类于 前端 / 模块化

在之前的一篇 文章 提到了NodeJS中CommonJS和ESModule混用的问题。

现在的前端项目中,还可能存在TS和JS的混用,叠加上各种模块机制的混用,导致整个项目比较复杂,以至于在开发过程中,可能会遇见很多不同的错误,这些错误大多数是没有弄懂TS的模块机制导致的。

本文将整理TypeScript中的模块系统,以及模块混用时的一些常见问题。

参考

这两篇官方文档,非常建议大家详细阅读一下。

TypeScript的两个任务

让我们思考一下,一份TS代码最终会在哪里运行

  • esbuild Bun Deno 可以作为TS的运行时 runtime ,直接运行TS代码
  • 浏览器 或者 NodeJS 无法直接运行TS代码,需要通过 tsc 编译之后才能被加载和运行
  • ts-node 内置了 tsc 的步骤,将TS代码转成JS代码后使用 NodeJS 运行,这种工具也被称作转义加载器 transpiling loader
  • 一些打包器如 webpack ,可以直接处理TS代码,并生成打包bundle文件

这些TS代码最终运行的环境(运行时和打包器)也被成为 主机 host

类型检测与编译

可以将上面的host分为两大类

  • 使用ts编写NodeJS程序,完成某些特定的脚本任务
  • 使用ts编写前端业务逻辑,最后通过webpack、vite等构建前端应用

我们暂时忽略使用 Deno 等TS的运行时、或者 ts-node 等转义加载器直接运行TS的场景,只考虑TS会被编译的场景。

如果是编写NodeJS程序,那么整个程序需要被tsc编译后,才能够被NodeJS运行。TS在这个时候需要承担 类型检测 编译TS为JS代码 的任务。

如果是编写前端应用,整个程序会交给打包器webapck等构建,TS只需要提供 类型检测 的任务。

需要明确的是,TS的主要目标是为JavaScript代码添加静态类型检查, 提前在编译阶段发现并捕获代码的运行时错误 ,而不是作为一种构建工具。

那么,当TS在遇到 import 语句的时候,到底会干什么呢?

  • 对于 类型检查 任务,TS需要知道如何加载这个文件中的类型信息,从而提供类型检查的能力

  • 对于 编译 任务,TS需要将编写的模块语法,转换成构建目标支持的模块语法

    • 注意TS并不会并不会将对应模块的 js 或者 ts 代码引入进来,这个是webpack等打包器、或者NodeJS等运行时才会做的事情

这两个任务对应了两个不同的配置字段

  • module 对应编译任务的模块语法转换
  • moduleResolution 对于类型检查的模块解析

模块类型module

由于存在多种Host,开发者需要通过配置来告诉TS的编译器的规则。TS通过 module 字段来判断模块文件采用的模块系统,该字段

  • 告知TS编译器如何检测每个文件的模块类型,允许不同类型的模块互相导入,以及是否提供像 import.meta 和顶层 await 这样的特性。
  • 告诉Host最终最终产出的 JavaScript 的模块格式,比如是ESM还是CJS
    • 如果是使用tsc编译,tsc也会根据该字段编译出对应的js产物

下面例举了一些常见的 module 字段取值。

nodenext

目前与 node16 反映了 Node.js v16+ 的模块系统,它支持 ES 模块和 CJS 模块并排使用,具有特定的互操作性和检测规则

module 配置项为 nodenext 的时候,TypeScript会采用与与 NodeJS 一样的模块检测机制

  • .mts / .mjs / .d.mts 会被当做ESM模块
  • .cts / .cjs / .d.cts 会被当做CJS模块
  • .ts / .tsx / .js / .jsx / .d.ts 会根据最近的package.json中的 type 字段来判断,如果为 module ,则会被当做ESM模块;否则会被当做CJS模块

当这些文件被tsc编译成的js代码的模块类型,也会根据上面的规则讲文件内的代码编译成对应的模块语法。

因此如果ts代码只是运行的NodeJS环境下, nodenext 将会是最适合的 module 配置值。

esnext或者commonjs

此外module还有另外一些常用了配置项

  • esnext : 目前与 es2022 相同,反映了最新的 ECMAScript 规范,以及预计将包含在即将到来的规范版本中的与模块相关的 Stage 3+ 提案,
  • commonjs , system , amd , 和 umd : 每个都以命名的模块系统发出一切,并假设一切都可以成功地导入到那个模块系统中。这些不再推荐用于新项目

大多数 TypeScript 文件都是使用 ESM 语法( import export 语句)编写的,而不考虑输出格式

不修改模块路径

TS编译器并不知道最终TS代码的运行环境,也不知道开发者编写的 import 代码,最终会被哪个Host来处理。

因此,TypeScript有一条规则: 模块路径字符串 module specifier 会不会被处理

关于这条规则,TypeScript小组的开发负责人 RyanCavanaugh 在某个issue下也给出了回复 TypeScript doesn't modify JavaScript code you write

TS不会做、也不会提供相关配置项,用于修改模块的路径字符串

这个规则的原因,在后面 模块解析 的部分会进一步说明,先简单看一个例子试一下

即使根本不存在 ./m3.xxx 这个模块,tsc会给错误,但还是会编译出下面的内容( module:commonjs ),不会修改模块的路径

即使module为 esnext 等,tsc还是会输出如下所示的js代码

从这个角度也可以看出,ts只关心import的模块类型,并不关心模块的运行时代码。

因此,TS的编译是比较有限的

  • 将ts语法编译成对应版本的js代码,由 target 字段指定
  • import 等模块相关的语法转成其他的语法代码 require 之类的模块代码转换

TS的模块语法

要知道TS如何编译模块语法,就得先知道TS支持哪些模块语法。Typescript中的 import export ,与ES6的 ESM 模块语法基本相似

除此之外,TS的模块系统还有一些扩展用法

import type

除了导出了实际的值(如变量和方法),TS还支持导出 类型

可以通过 import 引入模块的内容,包括模块导出的类型

对于某个类型而言,即可以通过 import 导入,也可以通过 import type 导入,这两者有什么区别呢?

typescript 3.8 文档提到

导入类型只导入要用于类型注释和声明的声明。它总是被完全擦除,所以在运行时没有残留。

类似地,export type只提供了一个可以用于类型上下文的导出,并且也会从TypeScript的输出中删除

也就是说 import type 完全是非运行时的,只是用来进行类型声明和检查,在打包后运行时是完全不存在的。

因此,像 enum 这些运行时相关的类型就不能通过 import type 来引入,否则编译时就会报错。

环境模块

参考:

如果一个文件不带有任何顶级的 import 或者 export 声明,那么它的内容被视为当前ts项目中全局可见的,也被称作环境模块 Ambient Modules

比如下面这段测试代码,在一个 global.ts 中声明了一个类型 GlobalTypeA ,由于该文件中没有使用 import export 字段,因此就是一个全局模块。

index.ts 中,就可以直接使用这个 global.ts 文件中定义的类型 GlobalTypeA ,而无须显式引入。

这是因为 TypeScript 默认采用了一种叫做“全局模块”的模块解析策略,这种解析策略是为了 兼容 早期的 JavaScript 开发方式。

在早期的 JavaScript 中,并没有模块系统,所有的变量和函数都是在全局作用域下定义的。

为了与这种方式保持一致,TypeScript 允许在没有显式声明模块的文件中,定义的变量和函数可以被其他文件访问,就像它们是在全局作用域下定义的一样。

根据全局变量一样,全局类型也会可能 污染全局作用域 ,下图演示两个全局类型冲突的情况

解决办法是将某个文件内添加 export 或者 import ,通过类型覆盖避免冲突。

在现代的 JavaScript 开发中,通常会使用模块系统来组织代码,以避免全局作用域的污染,并且提供更好的封装和可维护性。

因此,推荐的做法是 尽可能地使用模块化的方式来组织 TypeScript 代码 。也就是说,即使是在没有显式声明模块的文件中也可以使用 export import 来明确指定文件之间的依赖关系,从而避免全局命名空间的冲突和不确定性。

环境模块中的普通类型可以使用,因为在编译时会被忽略;但是全局模块中的值、以及枚举等运行时相关的类型,则不能被使用,否则在构建时会报变量未定义的错误。

allowJs

由于js庞大的模块生态,在某些情况下,我们不得不混用js和ts文件,比如在一个新的ts文件中引入旧的js文件模块中的API。

要在ts中加载js,首先需要设置 allowJs 为true,该字段允许ts加载js模块,否则会提示 TS7016: Could not find a declaration file for module

allowJs 为true时,还可以通过设置 checkJs 为true,借助ts编译器对js文件进行类型检查,从而在js文件中发现潜在的类型错误。

js中本身没有类型,ts编译器如何发现潜在错误呢?

虽然 js 本身并没有显式的类型系统,但是 ts 编译器会尝试根据变量的使用情况、函数的参数和返回值等上下文信息来推断类型,并且根据这些推断的类型进行 静态类型检查

具体来说,ts 编译器会根据以下几个方面来进行类型推断和检查:

  • 类型推断:ts 编译器会根据变量的赋值表达式、函数参数、函数返回值等上下文信息来推断变量的类型。例如,如果一个变量被赋值为一个字符串,那么 ts 就会推断它的类型为字符串类型。

  • JSDoc 注释:ts 中可以使用 JSDoc 注释来提供类型信息。ts 编译器会尝试解析 JSDoc 注释中的类型信息,并根据这些信息进行类型检查。

  • 类型注解:在 ts 文件中,你也可以使用 ts 类型注解来明确指定变量的类型。例如,你可以通过 /** @type {string} */ 注释来明确指定一个变量的类型为字符串类型。

不过类型检测需要一定的时间,如果是大量校验安装在 node_modules 中的第三方库,可能会导致很多无意义的js文件被检查, maxNodeModuleJsDepth 可以限制检测的层级。

兼容CJS

目前,绝大多数TS文件都是使用 ESM 语法( import export 语句)编写的。

由于历史原因,JS生态中存在大量CJS的模块。如果在TS中引入CJS模块,有下面两个特定的语法

esModuleInterop

要在ts中引入这种 CJS 的模块,可以通过下面这种方式引入

这种引入的方式并不是很优雅,ts提供了一个配置项 esModuleInterop ,当将该选项设置为 true 时,TS就支持像下面这种方式来导入CJS模块了

export =

此外,为了兼容CJS和AMD的模块系统中的 exports 变量,ts还支持 export = 方式。

export = 是一种默认导出的方式,它允许将一个值或对象作为整个模块的默认导出。

这种方式等价于CJS中的 module.exports 一致

如果要引入 export = 导出的模块,需要通过 import = require()

需要注意的是这种写法只能在 tsconfig.json 配置了 "module": "commonjs" 的情况下使用,如果配置的是 esnext 等字段,会出现如下提示

上面这两种特殊的兼容的写法,建议只在非用不可的场景下使用(比如某个强依赖的第三方库,只提供了CJS的支持)。

对于这种ESM模块与CJS模块混用的做法,不同的host对于其支持也不尽相同

  • 纯ESM模块,比如浏览器,不支持CJS模块
  • 打包器,一般同时支持ESM和CJS互相调用,并最终构建bundle文件
  • NodeJS,CJS需要动态引入ESM的模块;ESM可以直接引入 module.exports ,具体参考: NodeJS中CommonJS和ESModule混用的问题

不携带.ts后缀

在浏览器或者NodeJS中使用ESM,import模块时需要显式携带文件的后缀

  • 浏览器本身需要通过网络请求加载服务器上面的文件资源,肯定是需要完整的文件路径
  • Node.js早期的一个设计缺陷就是 require 会自动推断后缀、尝试添加index等默认行为,导致模块解析过于复杂;因此在支持ESM时,修复了这些缺陷,也需要手动指定文件后缀。

但是在ts中,引入ts模块时,并不能添加文件后缀 .ts ,否则反而会得到如下错误提示

从编译的角度思考一下,对于下面这段ts代码,如果添加了 ts 后缀

在由于ts不会修改模块的路径字符串,最终输入的JS代码中还是会包含如下内容

这显然是会报错的,js找不到这个名字为 m4.ts 的模块,这也是为什么 .ts 在默认情况下是不能编写的原因。

既然不能写后缀,那我就把这个后缀去掉就可以吗?去掉后缀,重新tsc编译

那么这段代码可以运行吗?

显然也是不行的, module:"nodenext" 构建的是ESM模块,NodeJS要求ESM模块引入本地模块时必须知道文件后缀,否则会提示

Error [ERR_MODULE_NOT_FOUND]: Cannot find module

WTF?加也不行,不加也不行?

要让编译后的代码可以被NodeJS ESM正常运行,可行的方法是:将ts代码添加 .js 后缀

WTFFF?

现在编译后的js代码可以正常执行了,但目录下都没有 m4.js 这个文件,只有 m4.ts 这个文件,为什么ts不会报错,可以正常解析呢?

这个 ./m4.js ,就可以看做是开发者为了构建目标 module:"nodenext" 编写的模块路径,TS是如何理解这个路径,在不报错的情况下,还可以加载对应文件的类型呢。

看起来我们需要深入学习TS的模块解析。

模块解析moduleResolution

模块解析 module resolution ,指的是如何根据模块字符串加载模块文件。

上面这个明明存在的 ./m4.ts 模块会发出警告,而明明不存在的 ./m4.js 文件模块却可以正常解析,就可以看做是TypeScript的一个特殊的模块解析规则。

不同Host的模块解析

参考: tc39 module 文档

虽然 ECMAScript 规范定义了ESM模块,给出了如何解析和解释 import 语句 export 的规则,但并没有定义如何进行模块解析;相反地,它将这个实现留给了host。

因此,很多运行时和打包器,特别是那些想要同时支持 ESM CJS 的,都各自实现了自己的模块解析,不同 host 的模块解析有很大差异。

举个例子

  • NodeJS为了解决历史问题,严格要求ESM模块必须写上文件后缀 .js .mjs
  • 而在 webpack rollup 等打包器中,这些后缀是可以通过一些配置完全可以省略的

由于TypeScript编译器不修改模块路径,开发者需要 自己控制 模块的路径编写方式,这样才能保证编译后的js代码可以正常运行。

这要求开发者必须根据配置的 module ,用合适的语法编写模块的引入路径,比如是 commonjs 就可以不写js文件后缀,而 esnext 的就要写js文件后缀。

这也是为什么上面在ts文件中,要编写 ./m4.js 的原因,只有这样,编译后的js代码才可以在NodeJS的ESM模块下运行。

由于TypeScript的最主要目的是是 提前在编译阶段发现并捕获代码的运行时错误 ,因此TypeScript必须要 理解 这些开发者为 module 编写的各种模块路径,这样TS才能够加载这些文件里面的类型,并进行类型检测。

因此TS会尝试根据构建目标Host, 模拟 对应Host的模块解析方式,来解析import等模块路径。

这个模拟过程是很重要的,即使我们最终并不需要通过TS来编译JS文件。

比如我们正在用webpack编写ts开发的前端应用,在开发过程中,我们

  • 会通过ts-loader等加载ts文件并最终将应用渲染在页面上进行调试;这要求我们引入的模块路径必须按照webpack能够解析的方式进行,可能是配置 reslove.extensions 忽略扩展名,或者是配置 reslove.alias 支持 @/components 等方式
  • 此外我们还需要依赖ts的类型提示,获得更好的开发体验;这要求ts必须能够理解文件中编写的模块路径

理解这个先后顺序非常关键,我们可以不使用ts,但我们要通过webpack(或者其他打包器,这个不重要)来构建前端应用,因此按照对应打包器要求的模块解析方式编写路径是必须的。

在这个基础上,我们使用了ts,并希望获得类型提示,由于Host解析路径方式是无法变更的,因此只有让TS来适配,即让TS来 模拟 对应的打包器(host)的模块解析。

moduleResolution

参考: moduleResolution官方文档

前面提到,开发者会根据Host的模块解析规则来编写模块路径,以NodeJs作为Host为例, module 配置项决定了开发者编写模块路径的方式。

同时,TS会 模拟 Host的模块解析,这些模块路径去加载类型,方便进行类型分析。

那么能不能根据直接 module 配置项对应的模块格式,来模拟对应的Host(这里也就是NodeJs)模块解析的方式呢。

答案是不行的,单纯根据 module 字段,并无法推断出具体的host,比如同一种模块格式如 ESM ,在NodeJS中跟在webpack中的模块解析方式也是不一样的。

由于不同Host的模块解析本身可能就有冲突通用的模块解析,所以内置一套模块解析规则并无法覆盖全部的情况。

因此TS还提供了一个 moduleResolution 配置项,让开发者告诉TS编译器应该模拟哪种Host的模块解析方式。

  • classic ,TS自己的早期的默认模块解析方式,即将被弃用,不建议使用

  • node10 ,模拟NodeJS早期的require模块解析方式,不推荐在新项目使用

  • nodenext ,按照新版本 node 的方式,判断当前项目是ESM还是CJS,然后寻找对应模块

  • bundler ,一些打包器如webpack、rollup会使用 package.json 中的 "exports" "imports" 字段来进行模块解析,这种模块解析模式为针对打包器的代码提供了一个基本算法

一些具体的规则

OK,让我们来看看TypeScript是如何解析这个不存在的 ./m4.js 文件的。

正如我们反复提到的: TS的首要任务是为JavaScript代码添加静态类型检查 ,在查找一个模块文件时,TS只会去解析对应的类型文件。

具体来说,TypeScript 编译器在查找模块文件时,会进行 文件扩展名替换 ,会按照 m4.ts m4.tsx m4.d.ts m4.js 顺序进行解析:

即使明确地写了 import './m4.js' ,TypeScript 编译器仍会首先查找 m4.ts m4.tsx m4.d.ts 这些文件。

如果这些 .ts 文件都不存在,那么编译器就会尝试查找 m4.js 文件。

即使 m4.js 文件不存在,TypeScript 编译器也不会报错,因为它期望在构建过程中,入口文件的 .ts 文件被编译为 .js 文件后,其导入的 m4.js 自然也就存在了。

所以在上面的这个例子中,尽管工作目录下只有 m4.ts 文件,没有 m4.js 文件,但 TypeScript 编译器认为这没有问题,因为 m4.ts 将被编译为 m4.js

当然,如果模块文件真的无法被解析,TypeScript 编译器最终还是会报错的。

TypeScript也会根据定义的的 moduleResolution ,来模拟对应Host的模块解析机制。

即:将 import / export / require 等语句中的字符串字面值解析为磁盘上的文件,方便TS进行类型检测,如果开发者编写的路径,无法被TS解析出来,TS就会抛出 Cannot find module 的错误。

TS具体要模拟的一些模块解析规则,包括

  • 文件扩展名替换,比如上面演示的 m4.ts m4.tsx m4.d.ts m4.js 的等过程
  • 忽略后缀
  • 目录模块,将 ./mod 解析为 ./mod/index
  • paths 路径别名
  • baseUrl
  • package.json 中的 exports 字段

根据不同的 moduleResolution ,这些规则可能有不同的处理方式,具体的解析规则可以参考 官方文档 ,这里不再赘述。

需要单独提一下的是 paths 字段。

webpack vite 等打包器有一个路径别名 resolve.alias 的配置项,允许在代码中编写诸如 @/components 的模块路径

在这种情况下,如果想要TS也能够找到具体的模块文件,可以在 tsonfig.json 配置了 paths