今天带着大家一起聊聊 @rollup/plugin-commonjs 对循环依赖的支持,文章大部分内容来自于 Github 的一个 PR,详细信息可以参考文末资料。
1. 什么是 CommonJS 循环依赖
Node.js 官网上给出了模块循环依赖的示例,并且解释了问题产生的原因(但并没有给出具体可行的解决方案):
When main.js loads a.js, then a.js in turn loads b.js. At that point, b.js tries to load a.js. In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module. b.js then finishes loading, and its exports object is provided to the a.js module.
简单说就是,为了防止模块载入的死循环,Node.js 在模块第一次载入后会把它的结果进行缓存 (可能是空对象),下一次再对它进行载入的时候会直接从缓存中取出结果 。所以在这种循环依赖情形下,不会有死循环,但是却会因为缓存造成模块没有按照预想的被导出。
2. Commonjs 循环依赖的几个典型示例
2.1 未执行完成模块输出值为 {}
假如 A.js 模块内容如下:
let b = require('./B');
console.log('A: before logging b');
console.log(b);
console.log('A: after logging b');
module.exports = {
A: 'this is a Object'
};
B.js 模块内容如下:
let a = require('./A');
console.log('B: before logging a');
console.log(a);
// A.js 模块未赋值完成,值为 {} 对象
console.log('B: after logging a');
module.exports = {
B: 'this is b Object'
};
此时运行 A.js 代码,输出结果如下:
B: before logging a
{}
B: after logging a
A: before logging b
{B: 'this is b Object'}
A: after logging b
想要解决这个问题有一个很简明的方法,那就是在循环依赖的每个模块中先导出自身,然后再导入其他模块,比如下面的示例:
module.exports = {
A: 'this is a Object'
};
let b = require('./B');
console.log('A: before log b');
console.log(b);
console.log('A: after log b');
下面是 B.js 的示例:
module.exports = {
B: 'this is b Object'
};
let a = require('./A');
console.log('B: before log a');
console.log(a);
console.log('B: after log a');
输出结果如下:
B: before log a
{A: 'this is a Object'}
B: after log a
A: before log b
{B: 'this is b Object'}
A: after log b
2.2 CommonJS 借助对象赋值实现 live-binding
有时候开发者希望导出的变量能够在之后通过对模块内值的改动影响到外部。
这部分内容可以阅读我的头条技术专栏《进击高级前端面试:模块化理论》
而 CommonJS 对于模块化的实现简单粗暴,就是通过立即执行函数实现作用域的封装,而 module 是立即执行函数的参数之一,是 require 执行前事先准备好的一个对象 。
JavaScript 中对象的赋值是引用传递,利用这个特性开发者就可以实现近似于 live binding 的效果。只要开发者 export 一个对象,那么对这个对象的修改,就会影响到所有 require 的值。
//a.js
let b = {a: 1}
setTimeout(() => {
b.a = 2;
}, 0);
module.exports = b;
下面是一个入口模块:
// 调用者
const a = require('./a.js');
console.log('instance', a);
setTimeout(() => {
console.log('a', a);
}, 1000 );
输出结果为:
instance {a: 1}
a {a: 2}
2.3 通过延迟函数调用实现 live-binding
上面示例借助的是 setTimeout 实现模块内部值的修改和外部 require 模块值的变更。其实除了 setTimeout 外,开发者甚至可以借助于函数调用等方式延迟模块值的获取,从而实现模块内部值变更对 require 模块结果的影响。
比如下面的示例:
// 入口模块
const dep = require('./dep.cjs');
console.log('dep 的值为:',dep)
exports.foo = 'foo';
exports.bar = 'bar';
// 对模块引用进行重新赋值
console.log(dep.getMain());
下面是被导入模块:
const main = require('./main.cjs');
console.log('require 后立即输出 main.cjs 的值为:',main)
// 此时获取到 main.cjs 未执行结束,为空对象 {}
exports.getMain = () => main;
// 通过函数调用等待入口模块的赋值
// 可获取到最新的模块的值
输出结果如下:
require 后立即输出 main.cjs 的值为: {}
dep 的值为: {getMain: [Function (anonymous)] }
{foo: 'foo', bar: 'bar'}
需要注意的是,以上代码 const main = require('./main.cjs') 没有解构,不然也不会反映到到外面。下面是修改后的 main.cjs 的代码:
const dep = require('./dep.cjs');
console.log('dep 的值为:',dep)
exports.foo = 'foo';
exports.bar = 'bar';
console.log('main.cjs 中获取的值',dep.getMain());
下面是 dep.cjs 的模块代码:
const {foo} = require('./main.cjs');
console.log('require 后立即输出 main.cjs 的值为:',foo)
exports.getMain = () => foo;
执行 main.cjs 的值后,输出结果如下:
require 后立即输出 main.cjs 的值为: undefined
dep 的值为: {getMain: [Function (anonymous)] }
main.cjs 中获取的值 undefined
关于这一点在我的另外一篇文章《为什么大家都说 CommonJS 无法实现 live-bindings?》中有重点论述,此处不再重点展开。
Webpack 实现 CommonJS 的 live-bindings 也是通过函数的方式,即 getter 函数。
3.@rollup/plugin-commonjs 对循环依赖的支持
2021 年 @rollup/plugin-commonjs 插件提交的一个 PR(
feat/commonjs-circular-dependencies )添加了对循环依赖的支持。
下面是一个在 Node.js 中有效但当时在 Rollup 插件中无效的示例:
// main.js
const dep = require('./dep.js');
exports.foo = 'foo';
console.log(dep.getMain().foo);
// dep.js
const main = require('./main.js');
exports.getMain = () => main;
当 main 导入 dep 时会立即执行文件,直到到达 require main 的位置。由于 main 尚未完成运行,dep 将接收 main 中 module.exports 的当前值,该值当前为 {} 对象。然后将 getMain 附加到其自己的 exports。
回到 main 中,当调用 dep.getMain() 时对象已被变量 foo 填充,因此 Node.js 能正常输出值 foo。然而在 @rollup/plugin-commonjs 插件的先前版本中,以上代码会失败,因为对于 ES 模块执行方式不同,并且 在执行模块之前不存在 module.exports 对象。
// @rollup/plugin-commonjs 以前版本的打包结果
var getMain = () => main;
var dep = {
getMain: getMain
};
var foo = 'foo';
// 此时 dep.getMain() 获取 main,但是 main 不存在
console.log(dep.getMain().foo);
// main 在这行代码才被赋值,但在上面一行代码要打印,报错
var main = {
foo: foo
};
此 PR 将通过以下方式修复此问题。首先,创建一个新的辅助模块 /dep.js?commonjs-exports(为上面的 dep.js 模块生成的代码),其内容如下:
// dep.js?commonjs-exports
var dep = {};
export {dep as __exports};
然后在转换后的 dep.js 版本中引用:
// 转换后的 dep.js 模块内容,这里做了精简
import {__exports as dep} from "/dep.js?commonjs-exports"
import main from "./main.js?commonjs-proxy";
var getMain = dep.getMain = () => main;
export {dep as __moduleExports, getMain, dep as default};
以上代码的要点是从 /dep.js?commonjs-exports 导入 __exports,而不是在本地创建,然后赋值给 exports 并重新导出。由于 Rollup 的工作方式,其会更早地创建 dep 的绑定,以便可以在循环依赖的所有模块中引用,从而与真正的 commonjs 模块的行为方式相匹配。
@rollup/plugin-commonjs 最终模拟了 CommonJS 规范的依赖循环引用行为,原理与上面充分利用模块导出为对象一致,最终将 commonjs 转化为 ES6。
当用户关闭
treeshake.moduleSideEffects 时,还需要对 Rollup 进行更改才能正常工作。此时,当模块从模块 bar 导入变量时(该变量本身从模块 foo 导入到 bar 中,然后使用常规导出而非重新导出再次导出到 bar 中),如果包含变量本身,则保证包含 bar 中的所有副作用。
生成的输出现在如下所示:
var main = {};
var dep = {};
dep.getMain = () => main;
main.foo = 'foo';
console.log(dep.getMain().foo);
dep 和 main 对象现在是 ` 在顶部创建 ` 以供所有后续代码引用。
需要注意的是,虽然 @rollup/plugin-commonjs 增加了对循环引用的支持,但它并没有增加对内联 require 语句的精确执行顺序的支持。由于一些具有循环引用的包也依赖于此,可能仍然无法工作。
但总体来说,开发者现在可以通过手动调整导入和执行顺序来循环依赖正常工作,这已经是一个很大的改进了。
参考资料
https://github.com/rollup/plugins/pull/658
https://zhuanlan.zhihu.com/p/422704350
https://maples7.com/2016/08/17/cyclic-dependencies-in-node-and-its-solution/
https://zhuanlan.zhihu.com/p/357607473
https://rollupjs.org/configuration-options/#treeshake-modulesideeffects