很多人写了多年 Node.js,对模块系统还是“会写但说不出”。真要问一句require和import到底有什么区别,为什么__dirname在 ESM 里用不了,或者为什么团队新项目用 ESM 之后构建反而报了一堆诡异的错——能答全的人并不多。
这里把 Node.js 的 CommonJS 和 ES Modules 掰碎了讲一遍。不光是语法差异,还会说清楚两种模块系统各自的加载机制、缓存策略、工程实践中的坑,以及在新老项目里怎么选、怎么迁移、怎么共存。无论你刚入门还是已经踩过不少坑,这篇文章都能帮你把这块拼图补齐。
1. 两种模块系统的设计思路
1.1 CommonJS 的历史定位
Node.js 在 2009 年诞生时,JavaScript 还没有一套通用的模块标准。浏览器的<script>标签只能靠全局变量协作,代码一旦规模上来,命名冲突和加载顺序就成了噩梦。Node.js 选择了服务端优先的路线,借鉴了 CommonJS 规范的设计思想,实现了require/module.exports这套同步模块加载机制。
CommonJS 的核心逻辑可以用一句话概括:运行时加载。当代码里执行require('./utils')时,Node.js 会同步地读取并执行utils.js这个文件的全部内容,然后把module.exports上挂载的东西返回给调用方。所以你在模块里写console.log,require的时候就会执行到;你在模块顶层写一个耗时很长的循环,require也会同步等着它跑完。
这套机制放到服务器端的场景里非常好用。文件都在本地磁盘上,同步读文件的耗时可以接受,而且代码执行顺序一目了然,调试时心智负担很低。Node.js 后来的十年高速发展,npm 生态的迅速膨胀,很大程度上都建立在 CommonJS 这套可靠的模块机制之上。
1.2 ES Modules 的标准化之路
ES Modules(简称 ESM)是 ECMAScript 官方标准定义的模块系统,从 ES2015 开始正式写入语言规范。它跟 CommonJS 最本质的区别是:它是静态的。import { readFile } from 'node:fs'这类语句在代码解析阶段就能确定依赖关系,不需要真正执行到那一行才能知道加载了什么。
这种静态性给 JavaScript 引擎和打包工具带来了巨大的优化空间。举个例子,import { isArray } from 'lodash-es'如果只用到isArray这一个函数,工具链可以在构建时做 tree-shaking,把lodash-es里没被用到的几百个其他函数都“摇掉”,打包体积能小一大截。CommonJS 因为模块和导出是运行时的对象,工具链很难静态分析出哪些属性实际上没被用到,tree-shaking 的效果大打折扣。
浏览器和服务器端共用同一套模块标准,这也是 ESM 的重要推动力。前端代码可以在浏览器原生跑,同一个模块文件拿到 Node.js 里也能跑,工具链还更容易做分析优化。对于语言标准来说,这是必然的方向。
1.3 为什么两种制度会长期并存
很多人的疑问是:既然 ESM 是标准、是未来,为什么不直接把 CommonJS 干掉?
现实原因是生态体量。npm 上存量的 CommonJS 包数以百万计,很多包甚至已经不再维护了,但它们依然被依赖树引用着。要求全社区一夜之间迁移到 ESM 不现实,所以 Node.js 采用了一个非常务实的策略:双轨并行。
目前在 Node.js 里,两种模块系统是和平共处的。CommonJS 有它十多年的存量优势,ESM 有它标准的未来身份,Node.js 则充当中间协调层,让两者尽量无缝地互相调用。理解清楚这个背景,后面面对具体技术细节时才不会被绕晕:为什么有一些“别扭”的交互方式存在?因为那是在兼容一个十年历史的生态和语言标准之间的妥协产物。
2. 语法、加载与缓存:核心机制深度拆解
2.1 导出导入语法对比
两种模块系统最表面的差异在语法上。CommonJS 用的是module.exports和require(),ESM 用的是export和import。
// CommonJS 导出 // utils.cjs const utils = { getVersion: () => '1.0.0', formatDate: (date) => date.toISOString() }; module.exports = utils; // CommonJS 导入 // main.cjs const utils = require('./utils.cjs'); console.log(utils.getVersion());// ESM 导出 // utils.mjs export const getVersion = () => '1.0.0'; export function formatDate(date) { return date.toISOString(); } // ESM 导入 // main.mjs import { getVersion, formatDate } from './utils.mjs'; console.log(getVersion());关键差异点在于,CommonJS 的module.exports是一个赋值语句,你可以在任何位置、任何时刻给它重新赋值;ESM 的export是静态声明,只能写在模块顶层,不能写在if块里:
// CommonJS —— 条件导出可以正常工作 // 但这一点恰恰是工具链无法静态分析的原因 const fs = require('node:fs'); if (process.env.NODE_ENV === 'production') { module.exports = require('./prod.js'); } else { module.exports = require('./dev.js'); }// ESM —— 这种写法直接语法报错 if (process.env.NODE_ENV === 'production') { export { default as app } from './prod.js'; // SyntaxError }这种“限制”是 ECMAScript 规范的有意设计。静态语法限制了你的自由,但换来的是引擎能在执行前提早知道模块的所有依赖,可以做循环依赖检测、只加载用到的导出、并行加载依赖模块。
2.2 加载时机的差异
CommonJS 是同步、运行时加载。require语句放在哪一行,就在哪一行触发文件加载和执行。这带来两个结果:
- 你可以用变量作为
require的参数,比如require(path),只要路径最终能解析到正确的文件即可。 - 模块代码的执行顺序,跟代码里
require调用的顺序完全一致。
ESM 则复杂得多。模块加载要经历三个阶段:构建(确定依赖关系图)、实例化(为模块创建作用域和绑定)、求值(执行模块代码)。依赖关系会根据文件的import语句静态确定,所以可以并行加载多个模块文件,效率更高;但代价是import语句不能拼接路径,路径必须在阶段一就全部确定。
对开发者来说,感知最明显的是行为表现层面:ESM 的模块加载可以异步并行,一个大项目冷启动时,ESM 的依赖加载往往比 CommonJS 更深层次串行更快;但 ESM 的模块代码执行顺序与import语句顺序是不保证严格一致的,因为引擎会先并行加载并实例化所有依赖,然后按依赖图结构执行求值。
2.3 缓存机制的差异
CommonJS 对模块有统一的缓存处理:每个模块文件第一次被require后,Node.js 会把它的module.exports缓存起来,后续所有require都会直接命中缓存,同一个模块永远不会执行两次。
// main.cjs const a = require('./counter.cjs'); // 模块执行一次,缓存记录 const b = require('./counter.cjs'); // 直接返回缓存,不会重新执行 console.log(a === b); // trueESM 也有模块缓存,但它的语义更严格:模块被解析后,它的导出绑定会被冻结在模块实例里,而且导出是活的(live binding)。什么意思?看这个例子:
// counter.mjs export let count = 0; export function increment() { count += 1; }// main.mjs import { count, increment } from './counter.mjs'; console.log(count); // 0 increment(); console.log(count); // 1 —— 这里拿到的是最新值,不是快照CommonJS 的module.exports是普通对象,你拿到的是导出的值的引用快照;ESM 的导入绑定是活的,导入方和导出方共享同一个变量的实时值。这在循环依赖的场景下会造成非常不同的表现,后面会专门展开说。
2.4 顶层 this 与作用域差异
CommonJS 模块里,顶层的this指向module.exports,很多老代码会直接利用this.foo = 1来定义导出。ESM 的顶层this是undefined,ESM 模块天生是严格模式,没有隐式全局、没有顶层arguments。
// hello.cjs this.say = function () { return 'hi from CJS'; }; module.exports.say(); // 能正常工作 // hello.mjs this.say = 1; // TypeError: Cannot set property 'say' of undefined写 ESM 时需要留意,顶层this被设计成undefined是为了跟浏览器端保持一致,同时避免模块间通过this互相污染全局作用域。这也是 ESM 更“现代”的一个重要体现。
3. 工程实操:类型声明、文件扩展名与互操作
3.1 package.json 的 type 字段决定一切
Node.js 判断一个.js文件到底走 CommonJS 还是 ESM,看的是它所在目录的package.json里的type字段。
| type 字段 | .js文件的默认解析方式 |
|---|---|
未声明或"type": "commonjs" | 按 CommonJS 解析 |
"type": "module" | 按 ESM 解析 |
{ "name": "my-project", "version": "1.0.0", "type": "module" }上面这样配好之后,项目里所有.js文件都会被当作 ESM 来解析。如果你在某个目录下想单独用 CommonJS 文件,把文件名改成.cjs就行,这个扩展名是强制以 CommonJS 方式加载的;反过来,.mjs扩展名则强制以 ESM 方式加载。
一个项目里.js、.cjs、.mjs三种后缀可以共存,Node.js 会根据后缀和type字段的优先级决定处理方式。实际工程中,我见过很多团队把type设为module后,老文件全部改成.cjs来平滑过渡——这个方案可行,但要注意改文件后缀后,代码里所有require调用的路径也要同步检查,不然很容易因为解析规则改变而出问题。
3.2 ESM 中引入 CommonJS 模块的套路
ESM 的import语句可以直接导入 CommonJS 模块。Node.js 做了兼容处理:import一个 CommonJS 模块时,模块的module.exports整体会被视为默认导出。
// cjs-module.cjs module.exports = { name: 'cjs-module', version: '1.0.0' };// main.mjs import cjsModule from './cjs-module.cjs'; console.log(cjsModule.name); // 'cjs-module' console.log(cjsModule.version); // '1.0.0'但这种兼容有个容易踩的坑:如果 CommonJS 模块的module.exports是原生类型(比如字符串、数字、函数),在 ESM 里导入时,默认导出的值就是那个原生类型。但如果你在 ESM 里用解构导入,就会拿不到值:
// cjs-fn.cjs module.exports = function add(a, b) { return a + b; };// main.mjs import add from './cjs-fn.cjs'; // 正常工作 import { add } from './cjs-fn.cjs'; // 报错:SyntaxError因为 CommonJS 模块没有真正的命名导出概念,Node.js 在把 CommonJS 转成 ESM 接口时,会做一次静态分析(基于 cjs-module-lexer 库),尝试识别module.exports = { name, version }这类对象字面量形式中的属性名,让 ESM 里可以用命名导入。但对于module.exports = function这种形式,只有默认导入可用。
这里要特别注意module.exports = {}这种单对象导出与exports.foo = bar这种逐属性导出之间的区别。逐属性导出可以被 cjs-module-lexer 识别并生成命名导出,但如果是先定义一个对象再整体赋值给module.exports,识别能力就取决于对象字面量的简单程度了。最稳妥的方案是:在 ESM 里引入 CommonJS 模块时,统一用默认导入拿module.exports整个对象,再从对象上取属性,绝对不会出错。
3.3 CommonJS 里动态加载 ESM 只有一种正解
CommonJS 的require不能同步加载 ESM 模块。原因很直接:ESM 支持顶层await,这导致模块加载过程可能是异步的,而同步的require无法处理异步流程。所以在 CommonJS 文件里加载 ESM,只能用动态import()函数,它会返回一个 Promise。
// main.cjs async function loadESM() { const { readFile } = await import('node:fs/promises'); const content = await readFile('./data.txt', 'utf8'); console.log(content); } loadESM().catch((err) => console.error(err));动态import()在 CommonJS 和 ESM 里都可以用,这是两种模块系统之间最重要的桥梁。在 CommonJS 项目里想渐进式迁移到 ESM,动态import()是非常实用的一步:不用一次性改完所有文件,可以在业务代码中按需加载 ESM 模块,等存量 CommonJS 文件慢慢减少,最终整体切换。
但注意不要在同步逻辑中直接await import(),这会阻塞事件循环。如果确实需要同步拿到模块内容,建议把异步加载部分放在模块初始化时提前执行,用状态标记等待模块就绪。
3.4 .cjs / .mjs 扩展名的实战定位
.cjs扩展名通常用于以下场景:
- 项目
type已经是module,但有少量文件仍然依赖 CommonJS 的require、__dirname等特性,不愿意重构。 - 某些旧依赖库要求必须以 CommonJS 方式加载,比如需要打补丁或动态修改
module.exports的老模块。 - 想在同一个项目里体验两种模块系统的差异,快速做实验。
.mjs扩展名则是在项目中启用 ESM 的最直接方式——不需要修改任何package.json,直接建一个.mjs文件就可以用import和export。
团队做全量迁移的时候,我的建议是优先改package.json的type为module,这样源码文件基本不用改名字后缀,只需要把代码里的require换成import,把module.exports换成export。只有遇到无法改成 ESM 的第三方依赖或工具脚本时,才把它们单独保留为.cjs。
3.5 createRequire:让 ESM 模块也能用上 require
ESM 模块里没有require、__dirname、__filename这些 CommonJS 环境里内置的全局变量。但有些第三方包内部对require有依赖,或者你想在 ESM 代码里加载一个只能通过路径拼接来查找的 CommonJS 插件,怎么办?
Node.js 提供了createRequire函数来从 ESM 模块中构建一个可用的require函数:
// main.mjs import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const packageJson = require('../package.json'); console.log(packageJson.version);createRequire(import.meta.url)会以当前模块的路径为基准,创建一个符合 CommonJS 规则的require函数。这个函数跟 ESM 模块里的import可以共存,你可以在同一个文件里既用import引入 ESM 模块,又用require加载 CommonJS 模块。
这在项目中集成旧的工具链时非常实用。比如某配置生成工具只提供 CommonJS 接口,而新项目本身是 ESM,直接import可能因为工具内部大量使用__dirname或动态require而报错——用createRequire绕过去,是最稳妥的兼容方案。
4. 常见问题排查实录:新老项目都会踩的坑
4.1 ESM 中如何获取 __dirname / __filename
这是从 CommonJS 迁移到 ESM 的开发者遇到频率最高的报错:__dirname is not defined。
ESM 里没有__dirname和__filename,替代方案是使用import.meta.url。这是一个表示当前模块文件 URL 的字符串,配合fileURLToPath函数可以转换为普通的文件路径:
// main.mjs import { fileURLToPath } from 'node:url'; import path from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); console.log(__filename); console.log(__dirname);如果你要读同目录下的其他资源文件,推荐用更简洁的方式:
import path from 'node:path'; import { fileURLToPath } from 'node:url'; const currentDir = path.dirname(fileURLToPath(import.meta.url)); const targetPath = path.join(currentDir, 'assets', 'logo.png');注意import.meta.url在 Windows 上会以file:///C:/...的 URL 形式呈现,直接用path模块拼接是错的,必须先用fileURLToPath转换成普通路径。我在实际项目中不止一次见过因为少做这一步导致跨平台路径错乱的案例。
4.2 JSON 文件导入为什么这么麻烦
CommonJS 里require('./data.json')能直接拿到解析后的对象,这是 Node.js 的默认扩展名支持之一。但 ESM 里不能直接用import导入 JSON 文件,这是早期 ESM 设计时的刻意取舍——官方模块标准对模块类型采取更严格的分类,JSON 不属于 JavaScript 模块。
Node.js 在后续版本中增加了 JSON 模块导入支持,但带着限制条件:
// main.mjs —— 需要 Node.js v17.5+,且要求显式声明断言 import data from './data.json' with { type: 'json' }; console.log(data.name);注意with { type: 'json' }是必要的,不然会抛ERR_IMPORT_ATTRIBUTE_MISSING。
如果 Node.js 版本太老,或者你不想在每处导入都加断言,最省事的方式是绕开import:
import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const data = require('./data.json'); console.log(data.name);这样既保留了 JSON 自动解析的能力,又不受 ESM 导入限制的约束。项目中需要读取配置文件、静态 JSON 资源的场景,用这个方式可以减少不少噪声。
4.3 循环依赖的两种命运
循环依赖在大型项目中非常容易出现。A 模块引用了 B 模块,B 模块又回头引用了 A 模块。两种模块系统对循环依赖的处理方式截然不同,这直接决定了你的代码在迁移后是否还能跑得起来。
CommonJS 是“部分容忍”。因为require是同步执行且边执行边填充导出对象的,循环依赖中先被加载的那个模块,在它的加载过程中遇到require对方时,对方模块虽然已经开始加载,但其module.exports可能还是初始的空对象。这时你在对方模块里取到的就不是最终导出的完整内容,而是“半成品”。
// a.cjs const b = require('./b.cjs'); console.log('a module, b is:', b.name); module.exports = { name: 'a' }; // b.cjs const a = require('./a.cjs'); console.log('b module, a is:', a.name); module.exports = { name: 'b' };这段代码执行顺序是先 a 后 b 的话,运行结果是 a 拿到的b.name是undefined,因为 b 模块在加载时还没走到module.exports = { name: 'b' }那一行。CommonJS 允许这样的循环依赖继续跑下去,只是导致运行时序上某些值是缺失的。
ESM 则完全不同。ESM 的导入导出是活的绑定,在模块实例化阶段,引擎就已经知道每个模块导出了哪些名字并建立了引用,所以循环依赖中,先执行的模块可以拿到对方模块的导出引用,只是对方模块的具体值还没计算出来。看起来像是“提前引用了变量”,但不会报错。
// a.mjs import { name as bName } from './b.mjs'; console.log('a module, b name is:', bName); export const name = 'a'; // b.mjs import { name as aName } from './a.mjs'; console.log('b module, a name is:', aName); export const name = 'b';ESM 循环依赖的坑更多在于:当你在模块顶层执行时,对方模块的绑定还处于未初始化状态,读取会直接抛ReferenceError: Cannot access 'xxx' before initialization。
处理循环依赖的工程建议:重构比“如何正确循环”更值得做。两个模块互相引用,通常意味着有公共的部分应被抽出来放在第三模块;或者其中一个模块只是在启动阶段读取对方,可以通过传入参数、事件回调等方式延迟依赖。无论你在 CommonJS 还是 ESM 下,循环依赖都不应该被当作一个需要“适配”的正常用法。
4.4 导入路径与运行位置的困惑
ESM 的import路径解析比 CommonJS 更严格。CommonJS 的require('./foo')没有写扩展名时,Node.js 会依次尝试.js、.json、.node等扩展名。但 ESM 的import './foo'则要求带上扩展名,至少官方模块解析规则里不推荐省略扩展名。
// main.mjs // 这种写法在 ESM 里会报错 import utils from './utils'; // 要么写成 import utils from './utils.js';为什么会有这种差异?因为浏览器的原生 ESM 也是这么规定的。浏览器端必须能根据 URL 精确定位文件资源,省略扩展名需要额外的解析逻辑,无法保证在浏览器环境的高效性和确定性。Node.js 的 ESM 解析器为了兼容浏览器标准,也采用了同样的规则。
这也意味着从 CommonJS 迁移到 ESM 时,所有省略扩展名的require('./utils')都要补上.js。如果项目中依赖关系很多,建议用 codemod 工具自动批量修改,手动改漏给运行时报错排查增加很大负担。
另一个高频问题是目录导入:
// CommonJS —— 可以 const service = require('./service'); // ESM —— 不行,默认不支持 import service from './service';ESM 不支持直接导入目录作为模块。你必须在目录里放一个package.json,把main字段指向具体入口文件,或用index.js命名入口文件。Node.js ESM 解析规范里不带“目录作为模块”的自动查找逻辑,很多从 CommonJS 迁移过来的项目会在这里吃瘪。
4.5 第三方包与类型声明的兼容性
工程中还有一类隐蔽问题:npm 包自身用的是 ESM 写的,但你的项目还是 CommonJS,require这个包会直接报错。Node.js 14 之后的版本对“require 一个 ESM 包”有实验性支持,但生产环境远不够稳妥。反过来也一样:项目是 ESM,引入某个 CommonJS 老包时,如果那个包的导出方式是奇葩的全局赋值或动态导出,Node.js 的静态分析可能识别不了,导致 ESM 里命名导入拿到undefined。
处理策略分几层:
- 优先选择同时发布 CommonJS 和 ESM 双格式的包,通常在包根目录的
package.json里通过exports字段配置双入口。 - 如果包只有 CommonJS 版本,在 ESM 里统一用默认导入或
createRequire。 - 如果包只有 ESM 版本而在 CommonJS 项目里用,考虑升级到支持
require(esm)的 Node.js 版本,或给项目引入构建步骤将 ESM 包转换为 CommonJS 产物。
TypeScript 环境下还需要额外注意:moduleResolution要配合 Node16 / NodeNext 或 Bundler 等模式,否则类型解析与实际模块解析不一致,会在打包时出现“类型推导正常、运行时模块找不到”的诡异问题。
4.6 顶层 await 的门槛
ESM 支持顶层await,在模块代码的顶层直接写await等待异步操作完成,这在 CommonJS 里是完全不可能的——require是同步的,顶层出现await会直接语法报错。
// config.mjs const config = await fetch('/api/config').then((res) => res.json()); export default config;使用顶层await的好处是模块初始化时能安全地等待异步资源就绪。副作用是:依赖这个模块的其他模块,也必须等await执行完成才能开始求值。如果滥用,会把原本可以并行的模块加载变成串行等待。我在一些项目里见过每个模块都来一个顶层await的情况,启动时间直线上升,排查时还很难定位是哪个模块拖慢了整体。
把握一个原则:顶层await只适合模块初始化必需的异步逻辑,比如读取配置、建立连接。如果只是某个函数内部偶尔需要异步,把它放在具体函数里await就好,不要在模块顶层放大范围。
5. 迁移路线与选型建议
5.1 新项目应该选 ESM 还是 CommonJS
新项目如果没有历史包袱,我的建议是直接上 ESM。理由不复杂:
- ESM 是 ECMAScript 的官方标准,JavaScript 生态的新库、新工具链都在朝 ESM 方向走。
- 静态导入导出让工具链能做 tree-shaking,打包体积有可见的优化空间。
- 浏览器端原生支持的模块标准也是 ESM,前后端可以共享模块定义方式,对同构项目友好。
- 长期维护的角度,选择标准方向比选择历史妥协方案更省心。
但也有一些场景适合继续用 CommonJS。比如你开发的是一个小插件或工具脚本,目标是最大范围的兼容老 Node.js 版本,老版本对 ESM 的支持可能不完全且需要实验标识;或者改写 ESM 需要把整个依赖链路里的包都确认兼容,风险敞口较大。
5.2 存量项目的渐进式迁移策略
存量项目从 CommonJS 迁移到 ESM,最忌讳的是“一刀切”。一个月黑风高的晚上把type字段改掉,第二天项目全报错,这种剧本在太多团队里真实上演过。
推荐分四步走:
第一步:盘点依赖。把项目中直接依赖的第三方包找出来,确认它们是否为 ESM 兼容。判断方法很简单:看包的package.json里exports字段,或者直接写一个小测试脚本用import试一下。
第二步:统一解析方式。把package.json的type改成module,然后把代码里所有.js文件里暂时改不了的文件,统一改成.cjs后缀。这个阶段的目标不是“改造文件内容”,而是“让项目先以混合模式跑起来”。.cjs文件内部继续用require,.js文件开始改用import。
第三步:逐文件改造。从底层工具函数开始,把.cjs文件逐个转成 ESM:require换成import,module.exports换成export,__dirname用import.meta.url替代。每改完一批就运行全量测试,确保行为没有回归。
第四步:清理。所有文件都变成 ESM 后,把type保留为module,删除.cjs残留,全局搜索确认没有require了(createRequire的动态require除外),项目就完成了整体迁移。
5.3 双格式库的发布建议
如果你在维护一个开源包或者公司的公共基础库,需要同时支持 CommonJS 和 ESM 两种消费方,发布双格式是最稳妥的方案。
{ "name": "my-lib", "version": "1.0.0", "main": "./dist/index.cjs", "module": "./dist/index.js", "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" } } }exports字段里的import和require是条件,Node.js 会根据调用方环境选择对应入口。构建时需要打包两次,一次输出 ESM 格式,一次输出 CommonJS 格式。工具链方面,tsup、rollup、unbuild都支持双格式输出,配置不算复杂。
这里有一个包装界最常犯的错误:双格式包内部如果跨文件互相引用,import条件分支里的文件必须保证引入路径也是 ESM 的,不能混进 CommonJS 格式的产物。如果构建时两个入口用的源文件不一致,运行时会因为格式不匹配产生各种神奇错误。建议发布前用两套环境分别测试:同一个测试用例,分别用require和import引入库的入口,两边都通过再发版。
6. 写在实践之后
在我自己参与的项目里,大部分都还在使用 CommonJS,也不乏一些已经全面切到 ESM 的新项目。我个人的体会是:模块系统本身不是技术难点,真正的难点在于周边生态的兼容性和团队习惯的切换。
如果你是在维护一个稳定运行的老项目,没有必要为了“追上趋势”而强行迁移。CommonJS 代码可以再稳定地跑很多年,Node.js 官方也承诺长期支持。但如果你在开新项目、写新库,或者老项目确实面临依赖更新困难、构建体积过大、无法复用新工具链的问题,ESM 会是一个更值得长期投入的方向。
最后分享一个小技巧:不管你项目用哪种模块系统,在本地环境里养成随手验证的习惯。遇到require和import混用报错时,先用几行最小代码复现问题,再对照项目的package.jsontype字段和文件后缀排查,大部分模块系统问题都能在五分钟内定位到根因。模块系统这种东西,原理清楚了,坑自然就少了。