从 CommonJS 到 ESM:你的 Node.js 项目正在“掉队”吗?
还记得上次你部署一个新功能,结果整个服务突然崩溃,日志里全是 require is not defined 吗?或者,你花了一整天升级依赖包,却发现模块加载顺序完全乱套,连 __dirname 都找不到了?如果你是个 Node.js 开发者,这些场景肯定不陌生。
今天,越来越多的企业项目开始拥抱 ES Module(ESM)——这是 JavaScript 官方标准模块系统,也是现代 Node.js 生态的默认选择。但迁移过程,就像翻新一栋老房子:看起来简单,拆开墙才发现到处都是“雷”。特别是那些从 CommonJS(CJS)时代延续下来的老项目,require()、module.exports 和 __dirname 就像老房子的砖瓦,稍有不慎就会塌方。
如果你正在为这些问题头疼,别慌。这篇文章会带你一步步走过 CJS 到 ESM 的迁移之路,把那些最坑的细节都讲清楚。读完你会发现,这不仅是“升级”,更是让你的代码更健壮、更现代化的必经之路。
为什么你的“老代码”突然不能用了?
核心问题:CJS 和 ESM 是两套完全不同的模块系统
CommonJS(CJS)是 Node.js 诞生时就有的模块方案,它的核心是 require() 和 module.exports。你写的每一行 const x = require('y'),都是同步加载模块——这在服务器端没问题,但在浏览器端或者需要异步加载的场景下,就成了瓶颈。
ES Module(ESM)是 ECMAScript 官方标准,用 import 和 export 关键字。它支持静态分析(编译时就能知道依赖关系)、异步加载、还有更好的 tree-shaking(消除无用代码)。简单说,ESM 更安全、更高效、也更符合现代 JavaScript 的规范。
但问题来了:如果你在 package.json 里设置了 "type": "module",或者你的文件用了 .mjs 后缀,Node.js 就会强制使用 ESM 解析规则。这时候,你原本的 require() 调用会直接报错,__dirname 也会消失——因为 ESM 没有这些全局变量。
第一步:从 require 到 import,没那么简单
静态导入:最基础的转换
如果你只是在文件顶部用 require() 加载模块,比如:
// 老代码 const dotenv = require('dotenv'); const { dirname } = require('path'); 直接改成:
// 新代码 import dotenv from 'dotenv'; import { dirname } from 'path'; 但这里有个坑:本地模块必须加上 .js 扩展名。CJS 里你可以写 require('./config'),Node.js 会自动补全 .js。但在 ESM 里,你必须明确写 import config from './config.js'。少写一个 .js,就会报 ERR_MODULE_NOT_FOUND。
动态导入:if 语句里的 require 怎么办?
有些代码会在 try-catch 或条件语句里动态加载模块,比如:
let moduleName; try { moduleName = require('./optional-module'); } catch (e) { console.warn('模块加载失败:', e.message); } 这种场景,你必须用 import() 函数(注意,这是异步的):
let moduleName; (async () => { try { const imported = await import('./optional-module.js'); moduleName = imported.default || imported; } catch (e) { console.warn('模块加载失败:', e.message); } })(); 关键点:import() 返回的是一个 Promise,解析后得到的是一个模块对象,而不是直接的值。你通常需要访问 .default 属性才能获取到默认导出。
第二步:告别 module.exports,拥抱 export
CJS 里你经常这么写:
module.exports = { config, helper }; module.exports = someVariable; 在 ESM 里,这变成了:
export default { config, helper }; export default someVariable; 如果你有多个命名导出,比如:
// CJS module.exports = { foo, bar };// ESM export { foo, bar };
注意:ESM 的 export default 只能有一个。如果你之前用 module.exports 导出了一个对象,迁移时很可能需要拆分成多个命名导出,或者只保留一个默认导出。
第三步:__dirname 和 __filename 消失了?
这是迁移中最容易踩的坑。在 ESM 中,__dirname 和 __filename 不再存在。你需要自己从 import.meta.url 中提取:
import { dirname } from 'path'; import { fileURLToPath } from 'url';const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
这个代码看起来简单,但如果你在代码里大量使用了 __dirname(比如拼接路径、读取配置文件),你就得一个一个地方改。建议把这段定义放在文件顶部,然后全局替换 __dirname 的引用。
第四步:那些“聪明”的函数,比如 safeRequire
很多项目里都有这样的工具函数,用来安全地加载可选模块:
function safeRequire(modulePath, label) { try { const module = require(modulePath); console.log(✅ ${label}); return module; } catch (e) { console.log(❌ ${label}加载失败:, e.message); return null; } } 在 ESM 里,这个函数必须完全重构。因为 require() 被禁用了,你只能用异步的 import():
async function safeImport(modulePath, label, options = {}) { try { const mod = await import(modulePath); const module = mod.default || mod; if (options.initFn) { await options.initFn(module); } console.log(✅ ${label}); return module; } catch (e) { console.log(❌ ${label}加载失败:, e.message); return null; } } 注意:这个函数现在是异步的,所有调用它的地方也要改成 await 或者 .then()。
第五步:循环依赖——ESM 的“隐形杀手”
CJS 对循环依赖的处理比较“宽容”,因为它是同步加载的,遇到循环依赖时,会返回一个未完全初始化的对象。ESM 则严格得多,它会静态分析所有依赖,循环依赖会导致死锁或报错。
解决方案:
- 将循环依赖的模块改为动态
import()(见上面的异步加载)- 或者重构代码,消除循环依赖。比如把公共部分提取出来,或者用事件订阅模式代替直接引用
第六步:验证你的迁移是否成功
改完所有代码后,别急着上线。先做这三件事:
- 1. 语法检查
node --check server.js,确保没有语法错误- 2. 运行测试
npm test,看看现有测试是否全部通过- 3. 手动测试
如果遇到问题,最常见的错误就是:
- 路径忘记加
.js 扩展名import()
.default)- 异步代码没有用
await 或 .then() 处理迁移不是终点,而是起点
从 CJS 到 ESM 的迁移,表面上是改几个关键字,实际上是对整个项目模块化思维的重塑。这个过程可能会遇到各种“坑”,但每填一个坑,你的代码就会变得更健壮、更现代化。
但说实话,对于很多企业来说,手动迁移几百个文件的风险太高了。 一个疏忽,就可能导致线上服务中断。
这时候,你需要的是一支专业的团队来帮你做这件事。
让 BossAgents(左帮右臂)帮你搞定这一切
我们是一家专注于企业级 Node.js 智能体开发和现代化改造的公司。我们的“数字员工”可以:
- 自动化迁移
- 智能适配
__dirname 路径拼接等坑- 全面测试
- 持续集成
别再让你的项目“掉队”了。 点击下方链接,预约一次免费的项目评估。我们会在 24 小时内给你一份详细的迁移方案,包括风险点、工作量和预期收益。
BossAgents(左帮右臂)——让你的代码,跑得更快、更稳、更现代。
BossAgents