查看: 927|回复: 3

Node.js CommonJS与ESM混用适配及报错解决

[复制链接]
发表于 昨天 15:00 | 显示全部楼层 |阅读模式
Node.js 进入 CommonJS 与 ESM 并存的阶段后,npm 包新旧规范混杂,项目里经常出现 require() of ES Module not supported、Cannot use import statement in a CommonJS module、默认导出丢失、命名导出不存在等问题。原文的落地思路是先把 type 字段和两条模块加载规则定清楚,再分别处理 ESM 引 CJS、CJS 引 ESM 两种场景,最后用 TS 配置和目录隔离降低维护成本。

一、冲突根源:CJS 与 ESM 的核心差异

CommonJS 使用 require() 导入、module.exports 或 exports 导出,运行时动态加载,文件后缀可省略并默认 .js,顶层可直接使用 module、exports、require、__dirname、__filename。ESM 使用 import 或 import() 导入,使用 export default 或 export 具名导出,编译期静态解析并支持 Tree-Shaking,导入文件必须补全 .js 或 .json 后缀,顶层没有内置的 __dirname、__filename、require,需要手动兼容。

Node.js 有两条不能绕过的混合规则:CJS 可以引入 ESM,但必须使用动态 import(),不能直接 require ESM;ESM 可以直接引入 CJS,但 CJS 的默认导出会被统一挂载到 module.exports;当前文件按哪种模块规范解析,由 package.json 的 type 字段决定。

二、type 字段决定 .js 文件的模块类型

package.json 没有 type 字段,或 type 取 commonjs 时,所有 .js 文件默认按 CommonJS 解析,支持 require、module.exports,不支持顶层 import/export,直接写 import 会报错。type 取 module 时,所有 .js 文件默认按 ESM 解析,强制使用 import/export,禁止直接使用 require,并支持顶层 await、静态模块解析,文件导入必须补全后缀。

三、场景一:ESM 项目引入老旧 CJS 包

新项目使用 ESM 规范,但依赖大量 CommonJS 第三方包时,属于原生兼容场景,不需要额外配置,ESM 可以直接 import CJS 模块。
  1. import axios from 'axios';
  2. import * as lodash from 'lodash';
复制代码

如果 ESM 中需要用到 CJS 的 __dirname、__filename、require,可以手动兼容:
  1. import { fileURLToPath } from 'url';
  2. import { dirname, join } from 'path';
  3. import { createRequire } from 'module';
  4. const __filename = fileURLToPath(import.meta.url);
  5. const __dirname = dirname(__filename);
  6. const require = createRequire(import.meta.url);
  7. const oldModule = require('./commonjs-old.js');
复制代码

这里通过 fileURLToPath 和 dirname 还原 __dirname,通过 createRequire 在 ESM 中启用 CJS 导入能力。

四、场景二:CJS 项目引入新版纯 ESM 包

老项目基于 CommonJS,升级依赖后第三方包变成纯 ESM,直接 require 会报 require() of ES Module not supported。原因是 CJS 运行时不支持同步加载静态 ESM 模块,Node 禁止同步 require ESM。解决方案是在 CJS 中使用动态 import() 异步加载。
  1. async function loadESM() {
  2.   const esmModule = await import('new-esm-package');
  3.   console.log(esmModule.default);
  4. }
  5. loadESM();
复制代码

这种写法会把模块加载变成异步流程,调用方需要按 Promise 处理返回结果,不能在同步代码路径里直接拿到 ESM 导出值。

五、全局混合兼容配置

中大型项目存在 CJS 旧业务与 ESM 新功能长期并存时,原文推荐新项目、迭代中项目统一开启 type: module,通过兼容代码适配老旧 CJS 依赖,而不是反向降级。核心 package.json 配置如下:
  1. {
  2.   "type": "module",
  3.   "main": "./index.js",
  4.   "module": "./index.js",
  5.   "exports": {
  6.     ".": "./index.js"
  7.   }
  8. }
复制代码

ESM 强制要求文件后缀。不想修改源码时,可配置别名兼容,vite、webpack、node 均可适配,Node 原生可通过自定义 resolve 规避。工程上更推荐目录隔离:/src 全部使用 ESM 规范,使用 import/export;/legacy 存放老旧 CJS 代码,通过异步 import 被 src 层调用。这样能彻底规避混用混乱。

六、TS 项目混合模块适配

TypeScript 项目是模块混用重灾区,只需修改 tsconfig.json 即可适配双规范:
  1. {
  2.   "compilerOptions": {
  3.     "module": "ESNext",
  4.     "target": "ES2022",
  5.     "moduleResolution": "NodeNext",
  6.     "allowSyntheticDefaultImports": true,
  7.     "esModuleInterop": true
  8.   }
  9. }
复制代码

其中 module 输出 ESM 规范代码,target 兼容 Node18+,moduleResolution 适配 ESM 解析规则,allowSyntheticDefaultImports 与 esModuleInterop 用于抹平 CJS 与 ESM 默认导出差异,避免导入 CJS 模块时默认导出为 undefined。

七、高频报错与根治方案

报错 1:require() of ES Module not supported。原因是 CJS 模块同步 require 纯 ESM 包;解决方式是替换为 await import() 异步动态导入。

报错 2:Cannot use import statement in a CommonJS module。原因是文件没有 type: module,被 Node 识别为 CJS,无法使用 import;解决方式是在根目录 package.json 中添加 type 为 module。

报错 3:导入 CJS 模块,默认导出为 undefined。原因是 CJS 模块导出挂载在 module.exports,与 ESM 解析规则存在差异;解决方式是开启 esModuleInterop,或使用 import * as xxx 整体导入。

报错 4:相对路径导入提示模块找不到。原因是 ESM 必须补全 .js/.json 后缀;解决方式是统一添加文件后缀,或配置构建工具自动补全。

八、企业级混合模块开发最佳实践

新项目统一 ESM,Vite、TS、Vue3、React 项目强制开启 type: module,跟随现代规范;老项目渐进式迁移,CJS 老项目不整体重构,新页面新逻辑全部使用 ESM,通过动态 import 互通;单个文件内不允许同时出现 require 和 import,语法保持统一;版本兜底最低 Node16+,推荐 Node18 LTS;TS 必开 esModuleInterop,抹平双模块导出差异;新旧模块目录分离,降低维护成本与报错概率。

总结起来,Node.js 模块混合使用的核心本质是:ESM 向下兼容 CJS,CJS 无法向上同步兼容 ESM。ESM 项目引 CJS 直接 import,手动兼容 __dirname/require 即可;CJS 项目引 ESM 必须使用异步 await import(),禁止同步 require。配合 type 配置、TS 兼容配置和目录隔离规范,可以解决新旧项目迭代中的常见模块混用报错。
回复

使用道具 举报

发表于 昨天 19:00 | 显示全部楼层

Re: Node.js CommonJS与ESM混用适配及报错解决

这篇把 CommonJS 和 ESM 的两条加载规则、type 字段的影响,以及两个方向的混用场景讲得挺清楚,尤其是 ESM 里用 createRequire 兼容旧 CJS、CJS 里用动态 import 加载纯 ESM 这两段,基本覆盖了日常最容易踩的坑。比较认同新项目和迭代中项目统一开 type: module、再通过 legacy 目录隔离旧 CJS 代码的思路,比到处打补丁更好维护。 有个小提醒:CJS 里用动态 import 后,异步边界会往上传染,启动初始化流程要注意顺序,别在同步路径里等 ESM 的导出值;另外 ESM 引 CJS 时默认导出和命名导出的表现容易让人误判,接入第三方包时最好先确认它实际暴露的形态。TS 部分用 NodeNext 后,相对导入补扩展名的规则也会跟 ESM 对齐,和文中“ESM 强制后缀”是同一件事,配置时一起检查会更稳。整体思路很实用。
回复 支持 反对

使用道具 举报

发表于 昨天 19:10 | 显示全部楼层

Re: Node.js CommonJS与ESM混用适配及报错解决

写得很系统,type 字段和两条加载规则确实是判断依据,先确定当前文件按哪种模块规范解析,再谈适配,这个顺序是对的。补几点实际踩过的坑,供参考。 一是 CJS 引 ESM 用动态 import(),除了异步化问题,还会影响启动流程。配置加载、CLI 入口、测试前置脚本这些通常希望在同步阶段完成,改成 await 之后要检查调用链,必要时把入口改成 async 包装,否则容易出现初始化顺序错乱。 二是 exports 字段一旦写了,就会覆盖 main 的解析行为,且条件顺序很关键。想同时支持两种用法,最好显式区分: “exports”: { “.”: { “import”: “./index.mjs”, “require”: “./index.cjs”, “types”: “./index.d.ts” } } 否则容易出现 import 到一份、require 到另一份,两边的状态不共享,单例、缓存、全局注册表全部失效,这就是常说的双包危害。内部有状态的包尤其要小心。 三是 TS 里 moduleResolution 设成 NodeNext 后,相对导入必须写 .js 后缀,哪怕源文件是 .ts,这一点最容易让人卡住。建议在开启前先确认构建工具链的解析配置,不然编译能过、运行报错,排查成本很高。 四是目录隔离,确实是最省心的方案。另外可以在 legacy 目录放一个 package.
回复 支持 反对

使用道具 举报

发表于 昨天 19:20 | 显示全部楼层

Re: Node.js CommonJS与ESM混用适配及报错解决

这篇整理得挺清楚,先把 package.json 的 type 字段和两条混合加载规则讲明白,后面再看 ESM 引 CJS、CJS 引 ESM 就不会乱。尤其是 ESM 里用 fileURLToPath、dirname 和 createRequire 补回 __dirname、__filename、require 这段,很实用;CJS 引纯 ESM 只能走动态 import() 也点到关键了,调用方要按 Promise 处理,不能当同步加载用。 工程上的目录隔离思路也很稳,/src 全 ESM、/legacy 放老 CJS,再通过异步 import 调用,比长期在同一目录里混写更好维护。TS 配置里 moduleResolution 用 NodeNext、再开 esModuleInterop 和 allowSyntheticDefaultImports,基本能覆盖不少默认导出丢失的问题。想请教一下,如果老项目短期拆不干净,legacy 层被 src 异步加载时,是否建议统一封装一个 loadLegacy 之类的入口来集中处理缓存和错误,避免每个调用点都散着写 import()?
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-26 20:09 , Processed in 0.026152 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部