你的Node.js项目还在用“老式”模块?是时候升级到ESM了

你的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
:打包工具可以“摇掉”未使用的代码,让最终产物体积更小、加载更快。
  • 更好的异步支持
:ESM天然支持顶层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-catchif语句中按需加载的。这种情况不能用静态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 defaultexport

// 旧写法 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. 1. 语法检查
:运行node --check server.js,确保没有语法错误。
  1. 2. 跑通测试
:执行npm test,确保所有单元测试通过。
  1. 3. 手动测试
:启动服务,模拟几个核心业务场景,确保功能正常。

要不要用自动化工具?

市面上有一些工具可以自动转换大部分CJS代码,比如jscodeshift配合转换脚本,或者Babel的@babel/preset-env

但说实话,对于生产项目,我建议手动转换。原因有三:

  • 自动工具无法处理复杂的动态加载逻辑 - 转换后的代码可读性差,不利于后续维护 - 你会在转换过程中发现代码中的“坏味道”,这是重构的好机会

写在最后:升级ESM,不只是技术选择

从CJS到ESM的迁移,表面上是一堆代码替换的“体力活”,但背后是对代码质量的一次全面体检。你会发现哪些模块依赖不合理,哪些函数设计有问题,哪些路径拼写有漏洞。

这正是BossAgents(左帮右臂)智能体公司擅长的事情。 我们帮助企业从代码审计、架构评估,到分批迁移、测试验证,全程护航。我们不是“一键迁移”的魔法师,而是陪你一起梳理代码、制定计划、落地上线的技术伙伴。

如果你们的Node.js项目还在CJS的“舒适区”里打转,或者正在为迁移ESM而头疼,不妨来找我们聊聊。我们帮你把“技术债”变成“技术资产”。

毕竟,好的代码架构,是企业持续交付的底气。

← 返回案例列表
分享:
🤖 Try Now →
🤖
🎁