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等运行时才会做的事情
-
注意TS并不会并不会将对应模块的
这两个任务对应了两个不同的配置字段
-
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
前面提到,开发者会根据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