再谈模块化:Node中ESM与CJS的解析策略
前情提要:从ts-node报错发现了大坑
彼时我正在开发一个内网npm包,用于在开发时生成TS接口类型。
该包中的代码(以及生成的代码,因为生成的代码为纯类型声明)并不会在浏览器环境中运行,也不会包含在产物中,因此可以看作是纯Node工具。
在开发时,我尝试使用ts-node进行调试,但出现了以下错误:
当时的我很疑惑,明明是ts-node为何会不支持
.ts
的拓展名呢?
经查阅,我发现ts-node的运行过程其实是先使用tsc编译成JS后再使用Node运行,在过程中会读取项目的tsconfig.json
中的配置项。
因此,只需要分别运行tsc编译和node执行两个阶段就可以找出到底在哪里出了问题。
尝试复现问题
我尝试新建一个最简单的环境来复现问题本身:
- 使用
npm init
新建一个npm项目,安装typescript和ts-node两个包 - 编写
index.ts
和moduleA.ts
两个文件模拟模块导入导出 - 使用
tsc
编译并使用Node执行
1 | // moduleA.ts |
使用tsc编译后单独执行和直接使用ts-node都正常,运行效果是一样的:
在package.json
中设置type: module
后(指定全局为ESM模块),运行ts-node出现了和上面一样的报错:
而单独使用tsc+node运行报错则不一样,提示为模块类型不兼容:
因此可以判定为ts-node的内部执行代码与ESM模块兼容性有问题,可以通过开启ts-node的ESM模式或将整体项目模块改为CJS解决(Web TS项目使用CJS问题不大,但如果是npm项目则需要慎重考虑)
- 项目整体修改为CommonJS模块(兼容CJS和ESM语法导入),需要修改项目npm和ts配置
- 修改
package.json
中的type
字段为CommonJS或留空 - 修改
tsconfig.json
的module
字段为CommonJS
- 修改
- 开启ts-node的ESM模式,无需修改项目配置,以下操作二选一即可
- 修改命令为
ts-node --esm
或ts-node-esm
- 修改
tsconfig.json
中"ts-node".esm
字段为true
- 修改命令为
修复后就可以解决上面的报错(新报错是由于没有修改tsconfig.json
中的module
):
出现新问题:找不到模块
修改了上述问题后,在原项目(非新建项目)中出现了下面的问题:找不到源码中导出的某个模块。
尝试在新建项目中复现类似错误,我们需要还原原项目中的配置(除了package.json
中的type: module
外的TS配置),高亮行为重点:
1 | // tsconfig.json |
在新项目的tsconfig.json
中设置module: esnext
后,成功复现了原项目错误:
说明确实是module
字段相关的模块解析导致了这个错误。这才是本文的重头戏,也引出了后面关于Node和TS中模块解析策略的讨论。
背景知识:ESM与CJS
在开始之前,先简单地谈谈JS模块化是什么。
在过去与未来的npm一文中,我简单提到了当下JS语言的两种主流模块化方案——CommonJS(CJS)和ES Module(ESM)。
它们主要的区别如下(详情可以到文章中去了解):
CJS | ESM | |
---|---|---|
标准来源 | 社区 | ECMA Script 标准委员会 |
实现层次 | 应用层 | 语言标准 |
本质 | JS对象(值拷贝) | 符号引用(引用拷贝) |
但是,稍了解前端工具链生态的同学可能就会有疑问:我明明网页的最终产物都使用webpack打包成一整个js文件后放在<script>
标签里引用,这和模块也没关系呀?
JS应用的两种环境
虽说我们的页面代码最终是运行在浏览器环境中的<script>
脚本,但整个开发工具链(包括包管理器npm、构建工具webpack等)都是运行在NodeJS环境中的
所谓NPM,管理的正是Node Package,即Node环境下的包而非浏览器环境下的包,浏览器环境中一个HTML对应一个完整的JS上下文,并不存在“包”的概念(但有“模块”的概念,这里暂时按下不表)。
作为运行环境,Node除了要负责JS代码的解释与执行外,还要负责解析模块(或者说找到并读取模块文件)。比如对于下面这个import
语句,Node就需要先找到它在哪里,并以正确的格式去读取它:
1 | import { debounce } from 'lodash' |
一般而言,Node会按照 路径读取 → node_modules
目录寻找 → 上层 node_modules
目录寻找 → … 的方式去寻找对应模块的文件(详见npm博客)。
在找到对应模块后,剩下的问题就是以什么格式去读取模块的内容。
Node对两种标准的支持
NodeJS从一开始是只支持CJS标准的,代表就是其标志性的require()
和module.exports
语法,在ESM规范的逐渐推广后也逐渐添加了相关支持:
在v13.2.0版本后,Node正式支持了ESM标准,可以通过以下方式启用:
- 在
package.json
中指定type: module
即可认定全局为ESM模块(可以用.cjs
表明CJS模块) - 使用
.mjs
拓展名表明该文件为ESM模块
在认定为ESM模块的文件中,就可以使用ESM语法(export
和import
)了,但需要注意的是在一个文件内不能同时使用ESM和CJS语法,只能选择一个使用。
与TS & Node配置的关系
洋洋洒洒说了一大堆,心中还是有很多疑问,没有从更高层次将这些问题连接起来:
- Node的解析策略和TS项目中的配置有什么关系呢?
- 其中每项配置分别起了什么作用呢?
- 它们的影响范围和优先级是什么样的?
先要明确TS本身与Node的关系:
众所周知TS给自身的定义是JS的“超集”,但这只是语法上的定义,实际上看来TS更像是JS的“类型插件”,在JS运行层上加了一层额外的“类型分析层”,从TS代码编译成JS实际上是在做“类型擦除”就可以看出,真实代码运行时其实不需要任何类型信息。
而Node本身只接受JS代码,并不接受TS代码,因此我们就可以明确tsc和node各自的职权范围:
- tsc负责将
.ts
文件翻译成.js
文件,配置影响编译过程行为- tsc一般包含类型检查和类型擦除两个步骤,有些构建工具(如esbuild)则通过只做类型擦除来加快编译过程
- tsc只提供编译,不提供打包相关功能,如果需要打包则需要结合使用其他工具(如webpack、rollup等),因此tsc不适合直接在非Node环境下使用
- node负责执行编译得到的
.js
文件中的代码,配置影响运行时行为(如模块读取策略)
(各自配置影响范围)
tsconfig.json中的模块相关参数
根据官方资料 ,tsconfig.json
中与模块解析相关的参数就只有下面几个:
module
字段:指定编译后文件使用的模块体系,如导入导出语句moduleResolution
字段:指定当前项目使用哪种模块解析策略,在LSP中也会生效
需要注意的是,target
字段并不会影响模块解析策略,只会影响生成的JS语法特性范围。
module
字段
参考阅读 —— TS官方给出的JS模块哲学:https://www.typescriptlang.org/docs/handbook/modules/theory.html#the-module-output-format
TS支持多种模块标准,包括不同版本的CJS和ESM,设置该字段有助于TS编译器理解宿主环境(host runtime,如Node)使用什么类型的模块,它应该输出什么样的模块。
⭐需要注意的是,修改
module
字段也会隐式修改moduleResolution
字段,但显式地设置moduleResolution
字段优先级会更高
该字段最直观的影响就是改变产物中导入导出语句的格式,接下来将以这段代码为例展示不同产物:
1 | export const a = 'this is moduleA' |
CommonJS
:默认值,表示编译产物使用CJS模块标准
ES[*]
:表示使用ESM模块标准,允许的值为Next|6|2015|2020|2022,根据运行时宿主和语法特性要求环境选择不同的标准:- ES2015 = ES6标准,表示基础ESM语法支持,Node v12以上版本均支持
- ES2020添加了对
import.meta
和export from
语句的支持 - ES2022添加对顶层
await
语句的支持
Node16
:表示使用Node16+使用的模块解析算法,同时支持ESM和CJS,具体输出由pacakge.json
决定,详见后文(图1为 type=module时,而图2为 type=commonjs时)
NodeNext
/ESNext
:这些带有XXNext的选项表示始终与最新版本同步,注意这将引入不确定性(因为Next的目标将随Typescript版本变化而变化),目前的最新目标如下:- NodeNext = Node16
- ESNext = ES2022
moduleResolution
字段
上面了解到module
字段用于告知编译器运行环境期待得到什么样的模块,moduleResolution
字段则用于告诉编译器运行环境使用什么算法来解析和读取模块路径说明符(如import a from "xxx"
中的xxx
就是模块路径说明符)
⭐需要注意的是,Typescript本身并不真的负责读取和解析模块,这部分交给运行时去实现(如Node、Deno等),这也解释了为什么tsc不会修改**模块路径说明符**。
目前,Typescript支持以下可选项作为moduleResolution
的值, “对应的module”是指在取得这些值时moduleResolution
将取得对应的默认值。
选项 | 对应的module | 算法说明 |
---|---|---|
classic | 除了commonjs , node16 和nodenext 外的任意值 | 使用RequireJS算法,不应在任何不使用RequireJS/AMD的项目中使用 |
node10 / node (常用) | commonjs | Node v12以前版本使用的模块解析算法,几乎忠实还原了大部分打包工具的查找算法,支持如嵌套查询node_modules 、使用index.js 替代目录和省略.js 拓展名等重要特性。但由于Node v12以后引入了ESM支持和新的解析规则,在现代Node项目中并非好选择。 |
node16 (新项目常用) | node16 | 在Node v12+中同时支持了ESM和CJS,且每种模块都有自己的解析算法。为了确认文件的模块类型,在ESM import中省略拓展名(因为.mjs 和.cjs 是模块类型判据)和index.js 文件代替目录特性不再被支持,但CJS require仍支持(因为只支持CJS模块) |
nodenext | nodenext | 带next的选项将始终与最新标准同步,目前是node16 |
bundler | Typescript团队为了在新版Node中还原大部分打包器所使用的查找算法的努力,它还原了node10的大部分行为(require解析算法) |
package.json中的模块相关参数
除了type
字段可以指定项目内的隐式模块类型外,package.json
中还有一系列字段可以确认项目作为包暴露给外部的模块类型和入口:
- 目标为node v12+(使用现代解析策略)时,推荐使用条件导出(conditional exports)配置,详见下文
- 目标为老版本node(仅支持CJS)时,推荐使用传统的
main
和module
字段指定入口,注意这些字段在新版本中可用,但不推荐,且**在exports
**字段存在时会被覆盖。main
字段为包的主入口,如有module
字段则是CJS入口module
字段为包的ESM入口
条件导出 exports
字段
所谓条件导出(conditional exports)是指根据导入语句的类型(CJS还是ESM)和路径去指定特定的导出文件入口。
- 导入语句类型:通过
import
和require
来分别定义ESM和CJS入口
1 | // package.json |
- 指定路径(path):如
import a from "pkg/xxx"
这样的语句
1 | { |
- 嵌套定义:上面的路径就是一种嵌套定义,除此之外也支持其他类型的嵌套定义
1 | { |
ESM与CJS的互操作性
然而在实际生产中,ESM和CJS并非水火不容的关系,更多情况下它们存在复杂的相互调用关系。
事实上,由于CJS在很长一段时间内都是社区的事实标准,所以尽管ESM目前大行其道,但很多古老的依赖仍是CJS规范,因此很多即便模块规范为ESM的项目也免不了有一些CJS的依赖。
下表反映了我测试得到的ESM和CJS的互操作性结果:
Import 语句 | Export 语句 | 面向场景 | 解决方案 |
---|---|---|---|
ESM import | CJS module.exports | ESM Module | import * as pkg from ''``import pkg from '' |
CJS require | ESM export | CJS Module | Forbidden |
Dynamic Import
(import() ) | CJS module.exports | Both | import() |
ESM export | Both |
Dynamic Import动态导入
由于ESM模块规范中的import
语句是静态导入(在过去与未来的npm一文中详细谈到过),和require
的实现逻辑不同,因此无法使用require
语句去引用ESM包。
为了解决这一问题,ECMA Script标准ES2020引入了动态导入(dynamic import)语句,它的实现是动态的、异步的,行为与require
更相似,因此可以实现CJS和ESM通吃。
⭐需要注意的是,ESM的
import()
结果是module,获取默认导出要用.default
才能拿到对象
下图为在CJS中使用Dynamic Import(import()
)的代码和效果:
回顾与总结
最后,我们回到问题本身来,看看到底是什么引起了这个错误?如何修复这个错误?
回顾前文,该问题背后的运行环境如下:
package.json
中设置type: module
,即该项目全局默认为ESM模块tsconfig.json
中设置module
为ES2022,但moduleResolution
为node10
;即要求产物为ESM模块规范,但解析算法为node10的CJS算法
为什么会产生这个问题
⭐Node解析ESM模块必需使用Node16规范算法,引用时不能省略拓展名
其实看完环境配置后答案已经呼之欲出了,让我们回顾之前moduleResolution
中对node v12后如何同时支持ESM和CJS的解析算法的叙述,可以得到以下分析过程:
- 在Node v12+的解析规范中,由于
import
语句需要同时支持CJS和ESM,而判断该文件是什么类型的信息与拓展名相关,因此在Node v12+(规范node16)中文件拓展名不能省略 - 在新版规范中,
require
语句算法并没有修改,因此还是沿用之前的方案(规范node10),可以省略拓展名,以index.js
替代目录等特性仍可以继续使用(这也是最开始默认为CJS时没问题的原因) - 项目配置了
type: module
,则全局的JS文件都默认为ESM,使用ESM解析算法解析,即实际解析行为一定是Node16
- 本项目中因为贪图省事(也是因为思维惯性),用
node10
算法去匹配ESNext
模块规范,导致TS在编译时没有检查出错误,而Node在解析ESM模块的时候更是一脸懵逼,直接报错了
如何修复这个Bug
既然我们已经知道了是moduleResolution
解析算法不匹配的原因,就只需要把算法修改为正确的Node16
即可,在该模式下可以匹配正确的Node算法,并根据配置自动处理ESM和CJS模块输出。
1 | // tsconfig.json: 修改module会自动匹配对应的算法 |
修改后,我们还需要给我们的路径引入添加上拓展名:
需要注意的是,这里的.js
会自动匹配所有类型的扩展名,也可以被Typescript正确处理。
修改后,我们使用ts-node和tsc+node分别运行验证,均正常工作:
- 标题: 再谈模块化:Node中ESM与CJS的解析策略
- 作者: ChlorineC
- 创建于 : 2024-02-19 01:07:06
- 更新于 : 2024-10-18 18:03:42
- 链接: https://chlorinec.top/2024/02/18/Development/how-node-resolve-modules/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。