我花了三天把百万行级 server.js 从 CJS 迁到 ESM,这些坑你必须知道(CSDN版)

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. 充分测试:特别注意 Docker 环境下的路径问题
迁移过程中遇到的坑,本质上都是对 ESM 规范理解不够深入。希望这篇实战笔记能帮你少走弯路。

← 返回案例列表

Try 左帮右臂 Industrial Agent — your digital workforce

Available as a WeChat mini program, web app (ylxt.chat) and desktop client. Start from whichever fits your workflow.

🌐 Web App 💻 Desktop Download 📞 Call 189-8606-6876
WeChat mini program QR
Scan for the WeChat mini program
Share:
🤖 Try Now →
🤖
🎁