再谈模块化:Node中ESM与CJS的解析策略

本文讨论了Node.js中ESM与CJS的模块解析策略,强调了tsconfig.json中的module和moduleResolution字段对模块解析的影响。通过分析Node对两种模块标准的支持,指出了配置不匹配导致的错误,并提供了解决方案,包括将moduleResolution修改为Node16以确保正确的解析算法。最后,强调了在使用ESM时必须在导入语句中包含文件扩展名。

前情提要:从ts-node报错发现了大坑

彼时我正在开发一个内网npm包,用于在开发时生成TS接口类型。

该包中的代码(以及生成的代码,因为生成的代码为纯类型声明)并不会在浏览器环境中运行,也不会包含在产物中,因此可以看作是纯Node工具

在开发时,我尝试使用ts-node进行调试,但出现了以下错误:

当时的我很疑惑,明明是ts-node为何会不支持.ts的拓展名呢?

经查阅,我发现ts-node的运行过程其实是先使用tsc编译成JS后再使用Node运行,在过程中会读取项目的tsconfig.json中的配置项。

因此,只需要分别运行tsc编译和node执行两个阶段就可以找出到底在哪里出了问题。

尝试复现问题

我尝试新建一个最简单的环境来复现问题本身:

  1. 使用npm init新建一个npm项目,安装typescript和ts-node两个包
  2. 编写index.tsmoduleA.ts两个文件模拟模块导入导出
  3. 使用tsc编译并使用Node执行
// moduleA.ts
export const a = "this is moduleA";

// index.ts
import { a } from "./moduleA";

console.log(a);

使用tsc编译后单独执行和直接使用ts-node都正常,运行效果是一样的:

package.json中设置type: module后(指定全局为ESM模块),运行ts-node出现了和上面一样的报错:

而单独使用tsc+node运行报错则不一样,提示为模块类型不兼容(在ESM模块中使用了CJS语法):

因此可以判定为ts-node的内部执行代码与ESM模块兼容性有问题,可以通过下面两种方法修复:

  • 项目整体修改为CommonJS模块(兼容CJS和ESM语法导入),需要修改项目npm和ts配置,完成下面两个步骤:

    1. 修改package.json中的type字段为CommonJS或留空
    2. 修改tsconfig.jsonmodule字段为CommonJS
  • 开启ts-node的ESM模式,无需修改项目配置,以下操作二选一即可:

    • 修改命令为ts-node --esmts-node-esm
    • 修改tsconfig.json"ts-node".esm字段为true

修复后就可以解决上面的报错(新报错是由于没有修改tsconfig.json中的module):

出现新问题:找不到模块

修改了上述问题后,在原项目(非新建项目)中出现了下面的问题:找不到源码中导出的某个模块

尝试在新建项目中复现类似错误,我们需要还原原项目中的配置(除了package.json中的type: module外的TS配置):

// tsconfig.json
{
  "extends": "@tsconfig/recommended/tsconfig.json",
  "exclude": ["./node_modules/**/*", "./dist/**/*", "./build/**/*"],
  "include": ["./src/**/*", "./tsconfig.json", "./wagas.config.ts"],
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "src/*": ["./src/*"]
    },
    "outDir": "./build",
    "typeRoots": ["src/types"],
    "module": "ES2022",
    "moduleResolution": "Node10",
    "target": "ES6"
  },
  "ts-node": {
    "esm": true,
    "experimentalResolver": true
  }
}

在新项目的tsconfig.json中设置module: esnext后,成功复现了原项目错误:

说明确实是module字段相关的模块解析导致了这个错误。这才是本文的重头戏,也引出了后面关于Node和TS中模块解析策略的讨论。

背景知识:ESM与CJS

在开始之前,先简单地谈谈JS模块化是什么。

前端包管理器 - npm 一文中,我简单提到了当下JS语言的两种主流模块化方案——CommonJS(CJS)和ES Module(ESM)。

它们主要的区别如下(详情可以到文章中去了解):

CJSESM
标准来源社区ECMA Script 标准委员会
实现层次应用层语言标准
本质JS对象(值拷贝)符号引用(引用拷贝)

但是,稍了解前端工具链生态的同学可能就会有疑问:我明明网页的最终产物都使用webpack打包成一整个js文件后放在<script>标签里引用,这和模块也没关系呀?

JS应用的两种环境

虽说我们的页面代码最终是运行在浏览器环境中的<script>脚本,但整个开发工具链(包括包管理器npm、构建工具webpack等)都是运行在NodeJS环境中的

graph TD
    subgraph NodeJS环境
    NodeJS[("NodeJS")]
    NPM[("NPM")]
    Webpack[("Webpack")]
    Modules[("Node Modules")]
    JSCode[("JS代码解释与执行")]
    ResolveModules[("解析模块")]

    NodeJS -->|管理工具链| NPM
    NodeJS -->|构建工具| Webpack
    NPM -->|管理| Modules
    NodeJS -->|执行| JSCode
    NodeJS -->|解析| ResolveModules
    end

    subgraph 浏览器环境
    Browser[("浏览器")]
    ScriptTag[("<script>脚本")]
    JSContext[("JS上下文")]

    Browser -->|运行| ScriptTag
    ScriptTag -->|包含| JSContext
    end

所谓NPM,管理的正是Node Package,即Node环境下的包而非浏览器环境下的包,浏览器环境中一个HTML对应一个完整的JS上下文,并不存在“包”的概念(但有“模块”的概念,这里暂时按下不表)。

作为运行环境,Node除了要负责JS代码的解释与执行外,还要负责解析模块(或者说找到并读取模块文件)。比如对于下面这个import语句,Node就需要先找到它在哪里,并以正确的格式去读取它:

import { debounce } from "lodash";

如何解析模块

解析模块的过程是从解析模块标识符(specifier)开始的,比如 import A from 'a' 中的 'a' 就是标识符(specifier)。

寻址路径

一般而言,Node会按照 路径读取 → node_modules目录寻找 → 上层 node_modules目录寻找 → ... 的方式去寻找对应模块的文件(详见npm博客)。

graph TD
    Start[开始解析模块] --> Path[路径读取]
    Path --> NodeModules[在当前目录的node_modules中寻找]
    NodeModules --> UpperNodeModules[在上层目录的node_modules中寻找]
    UpperNodeModules --> Continue[继续向上查找...]

加载器选择

NodeJS在v13.2.0版本后,正式支持了ESM模块解析,因此实际上Node中存在两套不同的Module Loader,分别处理CJS和ESM模块。

因此在取得标识符后,Node需要根据模块策略类型决定使用哪个Loader来加载模块:

Loader匹配条件实现细节
CommonJS具有 cjs 拓展名
最临近的
package.jsontypecommonjs
不支持异步导入,纯应用层实现,支持直接修改
支持目录模块(读取
index )和缺省拓展名
支持读取任何拓展名的文件,都会被CJS Loader处理为JS脚本
(Node23新增)支持从CJS读取同步ESM模块,得到一个Module对象
ESM具有 mjs 拓展名
最临近的
package.jsontypemodule
根据语言标准实现,默认是异步的
不支持直接修改,只能使用loader-hooks修改
不支持模糊导入,如目录导入、不含拓展名的导入
JSON导入必须根据规范声明
with {type: "JSON"}
只支持js, mjs和cjs拓展名被处理为JS脚本(包括TS)
支持读取CJS模块

值得注意的是,在未命中任何匹配条件时(如直接运行一个 .js 文件),NodeJS会采用以下的兜底策略进行判断:

  • 对于v22.7.0+版本,Node会对文件进行一次静态分析,如果扫描到ESM语法(如 import 等)就会直接使用ESM Loader
  • 对于剩余情况,Node会尝试先使用CommonJS Loader加载模块,如果加载失败才会尝试使用ESM Loader加载

回到现象

所以为什么在声明 type: module 后,下面的代码就会报错呢?

import { sum } from "./sum";
console.log("Hello World");
console.log(sum(1, 2, 3, 4, 5));

根据上面的模块加载逻辑,我们可知在声明了 type: module 后,Node会默认使用ESM Loader来加载模块,这个加载器不会尝试补全拓展名,因此导致了找不到模块的错误。

与TS & Node配置的关系

洋洋洒洒说了一大堆,心中还是有很多疑问,没有从更高层次将这些问题连接起来:

  • Node的解析策略和TS项目中的配置有什么关系呢?
  • 其中每项配置分别起了什么作用呢?
  • 它们的影响范围和优先级是什么样的?

先要明确TS本身与Node的关系:

  1. TS并不直接运行代码,直接运行代码的是Node,TS只负责编译和提供LSP

众所周知TS给自身的定义是JS的“超集”,但这只是语法上的定义,实际上看来TS更像是JS的“类型插件”,在JS运行层上加了一层额外的“类型分析层”,从TS代码编译成JS实际上是在做“类型擦除”就可以看出,真实代码运行时其实不需要任何类型信息。

而Node本身只接受JS代码,并不接受TS代码,因此我们就可以明确tsc和node各自的职权范围:

  • tsc负责将.ts文件翻译成.js文件,并将类型信息通过LSP传递给IDE,配置影响产物格式和IDE行为

    • tsc一般包含类型检查和类型擦除两个步骤,有些构建工具(如esbuild)则通过只做类型擦除来加快编译过程
    • tsc只提供编译,不提供打包相关功能,如果需要打包则需要结合使用其他工具(如webpack、rollup等),因此生产环境下一般不推荐直接使用tsc构建浏览器产物
  • node负责执行编译得到的.js文件中的代码,配置影响运行时行为(如模块读取策略)

综上所述,我们可以得到以下结论:

  • node配置(如package.json、拓展名等)可以决定node如何解析和执行模块
  • ts配置用于确保tsc构建出的产物和IDE类型提示能和实际运行环境匹配

tsconfig.json中的模块相关参数

根据官方资料tsconfig.json中直接与模块解析相关的参数就只有下面几个:

  • module :指定编译后文件使用的模块体系,如导入导出语句
  • moduleResolution :指定当前项目使用哪种模块解析策略
  • esModuleInterop :允许CJS模块使用ESM默认导出

一个常见误区是,target字段并不会影响模块解析策略,只会影响生成的JS语法特性范围。

不同的模块策略

一般来讲,直接修改 module 参数即可修改模块行为, moduleResolution 配置会随之取合适的值。如有特殊定制需求,如配合包管理器使用,也可以手动定义 moduleResolution

module 策略对应 moduleResolution 策略产物行为(直接由module影响)LSP行为(由resolution间接影响)
CommonJS(默认值)node10 / node输出CJS模块使用CJS Loader,支持目录、缺省拓展名导入
ES6/ES2015classic

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字段优先级会更高

该字段最直观的影响就是改变产物中导入导出语句的格式,接下来将以这段代码为例展示不同产物

export const a = "this is moduleA";
  • CommonJS:默认值,表示编译产物使用CJS模块标准
  • ES[*]:表示使用ESM模块标准,允许的值为Next|6|2015|2020|2022,根据运行时宿主和语法特性要求环境选择不同的标准:

    • ES2015 = ES6标准,表示基础ESM语法支持,Node v12以上版本均支持
    • ES2020添加了对import.metaexport from语句的支持
    • ES2022添加对顶层await语句的支持
  • Node16:表示使用Node16+使用的模块解析算法,同时支持ESM和CJS,具体输出由pacakge.json决定,详见后文
  • 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, node16nodenext外的任意值使用RequireJS算法,不应在任何不使用RequireJS/AMD的项目中使用
node10 / node (常用)commonjsNode v12以前版本使用的模块解析算法,几乎忠实还原了大部分打包工具的查找算法,支持如嵌套查询node_modules、使用index.js替代目录和省略.js拓展名等重要特性。但由于Node v12以后引入了ESM支持和新的解析规则,在现代Node项目中并非好选择
node16 (现在node项目)node16在Node v12+中同时支持了ESM和CJS,且每种模块都有自己的解析算法。为了确认文件的模块类型,在ESM import中省略拓展名和index.js文件代替目录特性不再被支持(因为.mjs.cjs是模块类型判据),但CJS require仍支持(因为只支持CJS模块)
nodenextnodenext带next的选项将始终与最新标准同步,目前是node16
bundlerTypescript团队为了在新版Node中还原大部分打包器所使用的查找算法的努力,它还原了node10的大部分行为(require解析算法)

package.json中的模块相关参数

除了type字段可以指定项目内的隐式模块类型外,package.json中还有一系列字段可以确认项目作为包暴露给外部的模块类型和入口

  • 目标为node v12+(使用现代解析策略)时,推荐使用条件导出(conditional exports)配置,详见下文
  • 目标为老版本node(仅支持CJS)时,推荐使用传统的mainmodule字段指定入口,注意这些字段在新版本中可用,但不推荐,且**在****exports*字段存在时会被覆盖

    • main字段为包的主入口,如有module字段则是CJS入口
    • module字段为包的ESM入口

条件导出 exports 字段

所谓条件导出(conditional exports)是指根据导入语句的类型(CJS还是ESM)和路径去指定特定的导出文件入口。

  • 导入语句类型:通过importrequire来分别定义ESM和CJS入口
// package.json
{
  "exports": {
    "import": "./index-module.js",
    "require": "./index-require.cjs"
  },
  "type": "module"
}
  • 指定路径(path):如import a from "pkg/xxx"这样的语句
{
  "exports": {
    ".": "./index.js",
    "./feature.js": {
      "node": "./feature-node.js",
      "default": "./feature.js"
    }
  }
}
  • 嵌套定义:上面的路径就是一种嵌套定义,除此之外也支持其他类型的嵌套定义
{
  "exports": {
    "node": {
      "import": "./feature-node.mjs",
      "require": "./feature-node.cjs"
    },
    "default": "./feature.mjs"
  }
}

ESM与CJS的互操作性

然而在实际生产中,ESM和CJS并非水火不容的关系,更多情况下它们存在复杂的相互调用关系。

事实上,由于CJS在很长一段时间内都是社区的事实标准,所以尽管ESM目前大行其道,但很多古老的依赖仍是CJS规范,因此很多即便模块规范为ESM的项目也免不了有一些CJS的依赖。

下表反映了我测试得到的ESM和CJS的互操作性结果:

Import 语句Export 语句面向场景解决方案
ESM importCJS module.exportsESM Moduleimport * as pkg from '' import pkg from ''
CJS requireESM exportCJS ModuleForbidden
Dynamic Import (import())CJS module.exportsBothimport()
ESM exportBoth

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,但moduleResolutionnode10;即要求产物为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模块输出。

// tsconfig.json: 修改module会自动匹配对应的算法
{
  "compileOptions": {
    "module": "Node16"
  }
}

修改后,我们还需要给我们的路径引入添加上拓展名:

需要注意的是,这里的.js会自动匹配所有类型的扩展名,也可以被Typescript正确处理。

修改后,我们使用ts-node和tsc+node分别运行验证,均正常工作:

更新于 2024.12

最近又开始捣鼓tsconfig相关的配置,主要集中在语法范围标准这一块,包括 libtarget 这些配置,让我自然而然地想到了之前处理过的 module 配置,就想着攒一套可以用于现代工程开发的 TS 配置,就回来看了看,结果就发现了新问题。

ts-node-esm 在 Node v17+ 版本失效

我在node v20上尝试复现博客内容,在做了以下工作:

  1. 设置 type=module,告诉 Node 这个文件夹内都是ESM优先
  2. 配置 ts-node 支持 ESM

结果还是得到了下面的错误:

我在Github上找到了相关的Issue,发现是Node 16以上版本会存在该问题。

于是我尝试降级到Node16,果然就可以正常运行了。

为什么Node解析ESM

标题: 再谈模块化:Node中ESM与CJS的解析策略

作者: ChlorineC

创建于: 2024-02-19 15:54:00

更新于: 2025-01-05 09:03:00

版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。

Share:

Related Posts

View All Posts »

Typescript 类型声明 All-in-one

TypeScript 的类型声明文件 .d.ts 提供了 JavaScript 代码的附加元信息,支持类型提示和自动补全。使用 DefinitelyTyped 仓库可以为没有类型声明的 npm 包提供类型支持,类型定义可通过 type 和 interface 进行,declare 语法用于为已有的 JS 变量或函数添加类型。三斜杠指令用于引入额外的文件依赖。

Bun 1.0 初体验体验:这口包子香吗?

Bun 1.0是一个新的JavaScript运行时,作为Node.js的替代品,提供更快的包管理和开发解决方案。尽管目前在生产环境中尚不成熟,但它集成了多种工具,支持统一模块标准和Web API,具有显著的性能优势。Bun的包管理器速度比pnpm快4倍,支持原生JSX和TS,且具备构建工具的潜力。整体来看,Bun在运行时和工具链上均有加速效果,但仍需进一步发展以满足生产需求。

【译&补】使用ref回调替代useRef吧

在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。