Node.js 百万行级 server.js 从 CJS 迁移 ESM:踩坑实录与实战方案
背景
凌晨两点,CI 流水线第三次炸了:
SyntaxError: Cannot use import statement outside a module 这个扛着整个业务核心逻辑的 server.js,用 CommonJS 格式跑了三年,从几百行膨胀到上万行。require() 嵌在 try-catch 里、藏在条件判断里、甚至出现在循环里。
我翻过身边十几个 Node.js 项目的 server.js,超过 70% 还在用 CJS。而 Node.js 22 早已原生支持 ESM。问题从来不是"要不要迁",而是"怎么迁才不踩坑"。
核心差异:运行时解析 vs 编译时静态分析
CJS 是运行时解析,ESM 是编译时静态分析。ESM 在文件加载前就要确定所有依赖关系,这意味着:
- 不能在
if 语句里写 import- 不能在函数内部写静态
import- 不能动态控制模块加载路径
坑一:__dirname 消失
CJS 里 __dirname 自带,ESM 里直接消失。替代方案:
import { dirname } from 'path'; import { fileURLToPath } from 'url';const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
踩坑实录:有人把 __dirname 替换成 process.cwd(),结果在 Docker 容器里找不到配置文件。process.cwd() 是进程工作目录,__dirname 是文件所在目录,两者在容器环境中经常不一致。
坑二:本地模块路径必须加 .js 扩展名
// CJS- 自动解析 .js/.json/.node const logger = require('./server/utils/logger');
// ESM
- 必须写全 import logger from './server/utils/logger.js';
漏掉扩展名,本地能跑但 CI 直接报错。
坑三:内置模块导出结构不同
// 正确写法 import as http from 'http'; import as fs from 'fs';// 错误写法(会报 undefined) import http from 'http';
内置模块在 ESM 里没有默认导出,必须用 as 语法。
静态 require 转换:90% 的情况
大部分 require() 在文件顶层,转换最简单:
// 转换前 const logger = require('./server/utils/logger'); const { createServer } = require('./server/utils/http');// 转换后 import logger from './server/utils/logger.js'; import { createServer } from './server/utils/http.js';
动态 require 转换:真正的难点
藏在 try-catch 里的 require() 是迁移最大障碍:
// 原始代码 let paymentModule; try { paymentModule = require('./modules/payment'); } catch (e) { console.warn('Payment module not available'); } ESM 的静态 import 不支持 try-catch,解决方案是动态 import():
let paymentModule; try { paymentModule = await import('./modules/payment.js'); } catch (e) { console.warn('Payment module not available'); } 注意:动态 import() 返回的是 Promise,需要 await,且返回的是模块命名空间对象,不是模块本身。
条件加载的替代方案
// 原始代码- 条件加载 const module = process.env.FEATURE_FLAG ? require('./feature-a') : require('./feature-b');
// 方案一:动态 import(推荐) const module = process.env.FEATURE_FLAG ? await import('./feature-a.js') : await import('./feature-b.js');
// 方案二:保留 CJS 文件,通过 createRequire 桥接 import { createRequire } from 'module'; const require = createRequire(import.meta.url); const module = process.env.FEATURE_FLAG ? require('./feature-a') : require('./feature-b');
createRequire 是过渡期的最佳方案,但长期来看应该全部转为动态 import()。
批量迁移工具链
手动替换效率太低,推荐使用工具链:
#- 1. 检测项目中的 CJS 依赖 npx cjs-to-esm --analyze server.js
2. 自动转换(处理 80% 的静态 require)
npx cjs-to-esm --convert server.js
3. 验证转换结果
node --check server.js
实测数据:一个 12000 行的 server.js,工具自动转换 92% 的 require() 调用,剩余 8% 需要手动处理(主要是动态加载和循环中的 require())。
迁移检查清单
| 检查项 | 说明 | |--------|------| | __dirname 替换 | 全部改为 import.meta.url 方案 | | 文件扩展名 | 本地模块路径加 .js | | 内置模块 | 用 import as 语法 | | 动态 require | 改为 await import() | | module.exports | 改为 export | | __filename | 同 __dirname 处理 | | JSON 导入 | 加 assert { type: 'json' } |
总结
CJS 到 ESM 的迁移不是简单的字符串替换,核心在于理解运行时解析与编译时静态分析的本质差异。对于百万行级项目:
- 1. 先静态后动态
require()- 2. 工具辅助
cjs-to-esm 处理 80% 的场景- 3. 渐进迁移
createRequire 桥接无法立即转换的部分- 4. 充分测试
迁移过程中遇到的坑,本质上都是对 ESM 规范理解不够深入。希望这篇实战笔记能帮你少走弯路。
BossAgents