webpack模块化原理解析(五)—— webpack对循环依赖的处理

一般开发不容易遇到循环依赖的情况,可随着项目达到一定的复杂度后,尤其是依赖关系复杂的大项目,很容易出现循环依赖的情况(我就遇到那么一次,而且还 debug 了很久很久),所以今后在编码过程中,要加强这方面的意识。

这篇文章先说下循环依赖的概念、循环依赖在commonjs 和 ES Modules的表现,最后再说一下webpack 对循环依赖的处理。

何谓循环依赖

所谓循环依赖,即比如a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。而实际上在Node.js 官网就给出循环依赖的例子:

Commonjs 规范

a.js:

1
2
3
4
5
6
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');

b.js:

1
2
3
4
5
6
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');

main.js:

1
2
3
4
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

先不使用webpack打包,先在 Node.js 环境下运行:

1
2
3
4
5
6
7
8
9
10
$ node main.js

main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done=true, b.done=true

在这个例子中,并没有出现死循环的现象,这是由于 commonjs 规范的两个特性。第一,运行时加载;第二,缓存已加载模块。以下是整个流程分析过程:

  1. 执行 main.js,打印main starting
  2. 加载 a.js,a.js 打印a starting,导出 a.done 为 false
  3. a.js 加载 b.js,由此开始执行b.js,打印b starting,导出 b.done 为 false
  4. b.js 加载 a.js,因为此前 a.js 已经加载完毕,这里 b.js 读取的是 a.js 的缓存内容,程序并没有跑回去 a.js。
  5. 读取a.js缓存为 a.done = false,打印in b, a.done = false,接着导出 b.done 为 true,打印b done,完成 b.js 的流程
  6. 回到 a.js 流程,b.done 为 true,打印in a, b.done = true,导出 a.done 为 false,打印a done
  7. 回到 main.js 流程,因为 b.js 已经被加载,故这里不重复执行 b.js。
  8. main.js 打印 in main, a.done = true, b.done = true

ES Modules 规范

1
2
3
4
5
6
7
8
9
10
11
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

在 node 环境下执行

1
2
3
4
$ node --experimental-modules a.mjs
(node:53995) ExperimentalWarning: The ESM module loader is experimental.
b.mjs
ReferenceError: foo is not defined

这个例子是取自于阮一峰大神 ECMAScript 6 入门 教程。在这个 ES Modules 的例子中,出现了报错提示ReferenceError: foo is not defined,提示foo变量未定义,这是为什么呢?

以下是阮大神的解释:

首先,执行a.mjs以后,引擎发现它加载了b.mjs,因此会优先执行b.mjs,然后再执行a.mjs。接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

说实话,这段话我是反反复复看了很久,研读了很久,一直没搞懂接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。这话的意思。

后来直到我把 a.mjs 改为:

1
2
3
4
5
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export var foo = 'foo';

运行结果变为:

1
2
3
4
5
6
$ node --experimental-modules a.mjs
(node:56529) ExperimentalWarning: The ESM module loader is experimental.
b.mjs
undefined
a.mjs
bar

区别在于,定义 foo 从 let 改为 var,实现了foo 变量提升,这时候打印 foo 为 undefined

这时候再结合ES Modules 的特性:ES Modules 模块输出的是值的引用,输出接口动态绑定,在编译时执行。这样是不是可以得出这样的结论:

更改后的例子:执行 a.mjs,第一句马上加载 b.mjs,这时的a.mjs 被引擎解析为创建了变量提升后的 foo变量的引用,输出 foo变量,foo 变量为 undefined;然后 b.mjs 中 把 a.mjs 导出的引用赋值给了 foo;

更改前的例子:执行 a.mjs,第一句马上加载 b.mjs,由于 foo变量使用 let 定义,引擎解析创建了没有任何变量的引用,不输出任何变量;在 b.mjs 把“没有任何变量“赋值给了 foo,这里 foo 当然未定义,所以报错提示 foo 未定义。

webpack 对循环依赖的处理

好了,说完循环依赖的概念,那么对于webpack 进行项目构建的的项目,webpack 是否能够检测循环依赖呢?

1
2
3
4
5
6
7
8
9
$ ../../node_modules/.bin/webpack
Hash: 6d7e8b3d767ab90792a7
Version: webpack 3.4.0
Time: 51ms
Asset Size Chunks Chunk Names
bundle.js 3.55 kB 0 [emitted] main
[0] ./a.js 163 bytes {0} [built]
[1] ./b.js 163 bytes {0} [built]
[2] ./index.js 560 bytes {0} [built]

答案是没有的。而且将打包代码执行,其执行结果跟上面的一模一样。失望的 webpack,居然检测不了循环加载。在这里举例的是 commonjs 例子,ES Modules 经试验也展现同样的结果。

但方法总比困难多,在这里推荐使用 webpack 插件 circular-dependency-plugin ,能够检测所有存在循环依赖的地方,尽早检测错误,省去大量 debug 的时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
➜ ../../node_modules/.bin/webpack
Hash: 6d7e8b3d767ab90792a7
Version: webpack 3.4.0
Time: 51ms
Asset Size Chunks Chunk Names
bundle.js 3.55 kB 0 [emitted] main
[0] ./a.js 163 bytes {0} [built]
[1] ./b.js 163 bytes {0} [built]
[2] ./index.js 560 bytes {0} [built]

ERROR in Circular dependency detected:
a.js -> b.js -> a.js

ERROR in Circular dependency detected:
b.js -> a.js -> b.js