我花了三天把百万行级 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. 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. 1. 先静态后动态
:优先处理顶层 require()
  1. 2. 工具辅助
:用 cjs-to-esm 处理 80% 的场景
  1. 3. 渐进迁移
:用 createRequire 桥接无法立即转换的部分
  1. 4. 充分测试
:特别注意 Docker 环境下的路径问题

迁移过程中遇到的坑,本质上都是对 ESM 规范理解不够深入。希望这篇实战笔记能帮你少走弯路。

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