你的Node.js项目还在用“老式”模块?是时候升级到ESM了
如果你是一名Node.js开发者,或者你的团队正在维护一个中型以上的后端项目,你一定遇到过这样的场景:代码里混着require()和import,__dirname突然报错说“未定义”,或者某个模块加载失败时,你花了一个小时才发现是路径忘了加.js后缀。
这些看似琐碎的“技术债”,其实正在悄悄拖慢你的开发效率,甚至影响生产环境的稳定性。而这一切的源头,往往是因为你的项目还在使用CommonJS(CJS)模块规范。
为什么你的项目需要从CJS迁移到ESM?
在Node.js的世界里,CommonJS曾是默认的模块系统。但随着JavaScript生态的演进,ES Module(ESM)已经成为官方标准。Vite、Next.js、Deno、Bun等现代工具和运行时,都在全面拥抱ESM。
更重要的是,ESM带来了三大实实在在的好处:
- 静态分析
- Tree Shaking
- 更好的异步支持
await,让异步代码更简洁。简单说:ESM让代码更安全、更高效、更现代。如果你的项目还在用CJS,就像还在用IE6开发网页——不是不能用,但迟早要升级。
从CJS到ESM:一份可落地的手动转换指南
下面我们以最常见的server.js文件为例,手把手教你完成转换。别担心,虽然看起来步骤多,但大部分都是“体力活”,而且你可以分批完成。
第一步:修改package.json,开启ESM模式
这是最容易被忽略的一步。在你的package.json中添加:
{ "type": "module" } 或者,你也可以将文件后缀从.js改为.mjs,Node.js会自动识别为ESM模块。
第二步:搞定顶部的静态import
在ESM中,所有静态导入必须写在文件顶部。如果你之前用require,现在要换成import:
// 旧写法 const express = require('express'); const { config } = require('./config');// 新写法 import express from 'express'; import { config } from './config.js';
特别注意:本地模块路径必须加上.js后缀。这是ESM的硬性规定,也是初学者最容易踩的坑。
第三步:处理动态require(条件加载)
有些模块是在try-catch或if语句中按需加载的。这种情况不能用静态import,要用动态import():
// 旧写法 let db; try { db = require('./database'); } catch (e) { console.warn('数据库模块加载失败'); }// 新写法 let db; (async () => { try { const imported = await import('./database.js'); db = imported.default || imported; } catch (e) { console.warn('数据库模块加载失败'); } })();
动态import()返回的是一个Promise,需要用await或.then()处理。而且要注意,它返回的对象结构是{ default: ... },需要手动提取。
第四步:替换module.exports
这是最直观的一步。把module.exports换成export default或export:
// 旧写法 module.exports = { config };// 新写法 export default { config };
// 多个导出 // 旧写法 module.exports = { foo, bar };
// 新写法 export { foo, bar };
第五步:手动定义__dirname和__filename
ESM中不再有__dirname和__filename这两个全局变量。你需要自己“造”:
import { dirname } from 'path'; import { fileURLToPath } from 'url';const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
这段代码放在文件顶部,之后就可以像以前一样使用__dirname了。
第六步:重构“安全加载”类函数
很多项目里都有类似safeRequire的工具函数,内部使用require()。这类函数需要完全重写:
// 旧写法 function safeRequire(modulePath) { try { return require(modulePath); } catch (e) { return null; } }// 新写法 async function safeImport(modulePath) { try { const mod = await import(modulePath); return mod.default || mod; } catch (e) { console.warn(模块 ${modulePath} 加载失败); return null; } }
建议:如果这些模块不是真正可选的,最好直接改成静态import,放在文件顶部。这样更清晰,也更安全。
第七步:警惕循环依赖
ESM的静态分析机制对循环依赖非常敏感。如果你遇到“死循环”或“未定义”错误,可以尝试:
- 将循环依赖的模块改为动态
import()- 或者重构代码,打破循环
一般来说,循环依赖是代码设计的“坏味道”,建议从根本上解决。
验证你的转换是否成功
完成以上步骤后,不要急着上线。先做三件事:
- 1. 语法检查
node --check server.js,确保没有语法错误。- 2. 跑通测试
npm test,确保所有单元测试通过。- 3. 手动测试
要不要用自动化工具?
市面上有一些工具可以自动转换大部分CJS代码,比如jscodeshift配合转换脚本,或者Babel的@babel/preset-env。
但说实话,对于生产项目,我建议手动转换。原因有三:
- 自动工具无法处理复杂的动态加载逻辑 - 转换后的代码可读性差,不利于后续维护 - 你会在转换过程中发现代码中的“坏味道”,这是重构的好机会
写在最后:升级ESM,不只是技术选择
从CJS到ESM的迁移,表面上是一堆代码替换的“体力活”,但背后是对代码质量的一次全面体检。你会发现哪些模块依赖不合理,哪些函数设计有问题,哪些路径拼写有漏洞。
这正是BossAgents(左帮右臂)智能体公司擅长的事情。 我们帮助企业从代码审计、架构评估,到分批迁移、测试验证,全程护航。我们不是“一键迁移”的魔法师,而是陪你一起梳理代码、制定计划、落地上线的技术伙伴。
如果你们的Node.js项目还在CJS的“舒适区”里打转,或者正在为迁移ESM而头疼,不妨来找我们聊聊。我们帮你把“技术债”变成“技术资产”。
毕竟,好的代码架构,是企业持续交付的底气。
BossAgents