Rollup打包CommonJS模块循环引用报错分析与解决

作者:桌面漫游时间:2026-10-25 18:57:16浏览:214

本文分析Rollup打包CommonJS模块时因循环引用导致的运行错误。通过对比CJS与ESM模块加载机制,解释为何转换后变量变为undefined。针对涉及大量CJS模块的项目,建议考虑使用Webpack作为替代方案。

引言

近期在基于TypeScript与Rollup的项目开发中,执行打包测试时遭遇异常,生成的构建文件无法正常运行。具体报错信息如下所示:

    throw new ERR_INVALID_ARG_TYPE('superCtor', 'Function', superCtor);
    ^
TypeError [ERR_INVALID_ARG_TYPE]: The "superCtor" argument must be of type function. Received undefined

该错误信息初看较为晦涩,日常开发中较少直接遇到。定位至报错代码行,发现涉及如下逻辑:

util$3.inherits(Duplex$1, _stream_readable);

此处传入的 _stream_readable 参数预期应为 undefined 类型的构造函数,但实际获取到的是 undefined,从而触发了类型检查错误。

初步怀疑是Rollup配置存在缺陷,经搜索引擎查询及GitHub Issue排查,确认这属于Rollup在处理特定场景下的已知问题。经过对多个相关Issue的梳理,最终明确了报错的根本原因。

为清晰阐述该问题,首先需了解其发生的背景条件:

背景1

Rollup原生并不支持CommonJS (CJS) 模块格式。若要打包CJS代码,必须依赖 @rollup/plugin-node-resolve 和 @rollup/plugin-commonjs 两个插件,并在打包过程中将CJS模块转换为ES Modules (ESM)。由于CJS与ESM在处理循环引用时的机制不同,这直接导致了行为差异。

背景2

在Node.js环境中,readable stream与duplex stream模块之间存在循环引用关系。具体而言,Duplex(定义于 _stream_duplex.js)继承自Readable(定义于 _stream_readable.js)。然而,ReadableState(同样定义于 _stream_readable.js)内部包含针对Duplex类型的检查逻辑,导致执行过程中引入了 _stream_duplex.js,从而形成循环引用结构。

那么,CJS与ESM在处理循环引用时的具体差异是什么?又是如何最终导致上述错误的呢?

经过进一步研究与Demo验证,理清了两种模块系统的核心区别,并借此复习了模块系统的基础知识。

commonjs

提及CJS,其显著特点是灵活性。由于CJS模块是在运行时加载的,模块的名称和路径既可以是常量,也可以是动态表达式。这种特性使得CJS模块无法进行Tree Shaking优化,因为只有在JS实际执行时才能确定具体引入了哪个模块。

首次调用require加载模块后,会执行该模块的全部脚本,并将结果缓存。后续再次引入该模块时,直接读取缓存结果。因此,即使首次导入后原模块内容发生变化,再次导入的值仍保持不变。

因此,当遇到循环引用时,CJS这种读取缓存的机制虽然避免了无限递归,但可能引发不易察觉的错误,例如:

//a.js
const bar = require("./b.js");function foo() {  bar();  console.log("执行完毕");}module.exports = foo
foo();
//b.js
const foo = require("./a.js")
function bar(){
  foo()
}
module.exports = bar

执行a.js会直接报错 TypeError: foo is not a function

a 先加载 b,随后 b 又加载 a。此时,a 尚未有任何执行结果,因此输出结果为 null。这意味着对于 b.js 而言,变量 foo 的值等于 null。后续调用 foo() 时便会报错。

若在a.js第一行就导出foo,可避免此问题,但不推荐在实际代码中如此编写。若确实需要使用循环引用,只需确保require的对象已被实际导出即可。

es modules

在ESM模块加载机制中,import语句是静态执行的,而export则是动态绑定的。这意味着JS引擎会对import语句进行提升处理,无论import语句书写位置如何,它总是最先执行,并递归加载所有导入的模块。遇到已加载过的模块时直接跳过,整个过程表现为深度优先遍历。

动态绑定指的是export导出的接口与其对应的值是动态关联的,运行时从模块内部实时获取值。

因此,ESM模块加载机制本身并不关心是否出现循环引用,它仅生成指向被加载模块的引用。开发者需自行保证在真正取值时能够获取到正确的值。

若不注意,ESM中的循环引用也可能导致令人困惑的结果,例如:

//foo.mjs
console.log('foo is running');import {bar} from './bar.mjs'console.log('bar = %j', bar);setTimeout(() => console.log('bar = %j after 500 ms', bar), 500);export var foo = false;console.log('foo is finished');

//bar.mjs
console.log('bar is running');import {foo} from './foo.mjs';console.log('foo = %j', foo)export var bar = false;setTimeout(() => bar = true, 500);console.log('bar is finished');

执行 node foo.mjs 结果如下

bar is running foo = undefined bar is finished foo is running bar = false foo is finished bar = true after 500 ms

可以看到 bar.mjs 中输出了 foo = undefined,,但我们在 foo.mjs 中确实导出了 foo。。

为什么会这样呢?仔细分析这一句 export var foo = false,由于 var 存在变量提升机制,我们确实导出了 foo,但 foo 的值尚未被初始化。因此在 bar.mjs 中,foo 的值为 undefined。如果将其改为 export let foo = false,那么执行 foo.mjs 就会直接报错:

ReferenceError: Cannot access 'foo' before initialization

这也提醒了我们使用 let/const 替代 var,否则可能会出现难以预测的情况。

总结

导致Rollup打包问题的根本原因在于:打包过程中Rollup将CJS模块转换为ESM。由于ESM会跳过之前已加载过的模块,实际引入的变量变成了undefined,导致最终生成的代码中存在undefined的变量,从而引发运行时错误。

该问题至今尚未得到有效解决。对于涉及大量CommonJS模块的项目,建议使用Webpack进行打包。

以上就是Rollup打包引发的对JS模块循环引用的思考的详细内容,更多关于Rollup打包JS模块循环的资料请关注本站其它相关文章!

作者最新文章