一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 2 天,点击查看活动详情。
背景
前段时间在做需求的时候,踩了一个坑,在 vue 项目中,我 import 进来的 router 居然是个 undefined,当时还以为是自己犯了什么低级错误,把导出的变量写错了,仔细检查后发现并没有,后面一查才知道,原来是循环加载导致的问题。
circular-dependency-plugin
既然发现了问题,那就好解决了,只要追果溯因即可,我们可以使用 circular-dependency-plugin 插件,这个插件可以在构建的时候抛出循环的加载链路。
// webpack.config.js
const CircularDependencyPlugin = require('circular-dependency-plugin')
module.exports = {
plugins: [
new CircularDependencyPlugin({
// exclude detection of files based on a RegExp
exclude: /a.js|node_modules/,
// include specific files based on a RegExp
include: /dir/,
// add errors to webpack instead of warnings
failOnError: true,
// allow import cycles that include an asyncronous import,
// e.g. via import(/* webpackMode: "weak" */ './file.js')
allowAsyncCycles: false,
// set the current working directory for displaying module paths
cwd: process.cwd(),
})
]
}
复制代码
在配置中加上之后,要是项目中有循环的加载,在控制台就会抛出类似这样的错误,然后只需要根据各自的业务场景,去切断这条链路即可。
ERROR Circular dependency detected:
src\b.js -> src\a.js -> src\b.js
复制代码
问题解决了,那我们还得看看为什么会出现这样的情况,知其然还得知其所以然。
什么是循环加载
“循环加载”(
circular dependency
)指的是,a 脚本的执行依赖 b 脚本,而 b 脚本的执行又依赖 a 脚本,又或者是更长的链路。
循环加载本身就代表了项目中模块之间的强耦合,处理不好就会出现类似上面提到过的问题,但其实这是一种很难避免的情况,尤其是在大型的复杂项目中,所以模块的加载机制就必须考虑这种情况。对于 JavaScript 语言来说,目前最常见的两种模块格式 CommonJS 和 ES6,而它们处理的方式又是不同的。
CommonJS 的循环加载
CommonJS 有一个重要的特性就是执行时加载,就是在 require
时,模块就会被执行,如果这时候出现了循环加载的情况,那就只会输出已经执行的部分。
我们看看下面这段代码的执行过程:
// a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
//main.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
复制代码
执行的顺序是这样的:
- 在
main.js
中加载a.js
,然后执行a.js
中的代码 ; - 在
exports.done = false;
之后,又加载了b.js
,所以会先执行b.js
- 在
b.js
中,按顺序执行,只是执行到var a = require('./a.js');
时,能获取到的内容就只有之前a.js
中执行的第一行代码,即exports.done = false;
,所以在b.js
中加载的a.done
的值为 false; - 执行完
b.js
后,又回到a.js
执行之前未执行的部分,即从var b = require('./b.js');
继续开始执行,此时 b 已经是完全执行完的状态了,所以b.done
为 true,直到执行剩余部分; - 执行权继续回到
main.js
,此时var a = require('./a.js');
总算执行完了,开始执行第二行var b = require('./b.js');
,但此时并不会重新执行b.js
,而是输出之前执行b.js
的缓存结果,直到main.js
执行完毕。
执行 main.js
后输出的结果如下:
在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
复制代码
所以在 CommonJS 下,碰到了循环加载,输出的是已执行部分的值,而不是全部执行后的值。
ES6 模块的循环加载
与 CommonJS 不同的是 ,CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。所以并不会缓存执行的结果。
我们看看 ES6 模块的示例,我们首先执行 foo 模块的代码:
// foo
console.log('foo is running');
import {bar} from './bar'
console.log('bar = %j', bar);
setTimeout(() => console.log('bar = %j after 500 ms', bar), 500);
export let foo = false;
console.log('foo is finished');
// bar
console.log('bar is running');
import {foo} from './foo';
console.log('foo = %j', foo)
export let bar = false;
setTimeout(() => bar = true, 500);
console.log('bar is finished');
复制代码
执行顺序是这样的:
- 执行
foo
模块,由于import
的提升效果,先导入bar
模块; - 先输出
bar is running
,剩下的依次执行; - 执行权回到
foo
模块,先执行console.log('foo is running');
,再执行console.log('bar = %j', bar);
(因为第二句import
语句已经执行过了),然后其余语句依次执行; - 在
bar
模块中设置了定时器,在 500 ms 之后将bar = true
,所以在foo
模块中,500 ms 之后输出的bar
的值已经改变为 true,不再是 false;
所以执行结果是这样的:
bar is running
foo = undefined
bar is finished
foo is running
bar = false
foo is finished
bar = true after 500 ms
复制代码
我们首先运行的 foo 模块,为什么不是先输出 foo is running
而是先输出 bar is running
呢?因为 import 命令具有提升效果,会提升到整个模块的头部,首先执行。这种行为的本质是, import 命令是编译阶段执行的,在代码运行之前。
其次,如果发现依赖循环了,并不会继续加载这个依赖,而是认为它已经存在了,继续往下执行。但是使用不当依旧会让人产生不解的地方,比如输出 foo = undefined
的时候,明明觉得已经正常导入了模块,为什么却获取不到值呢?这就是循环依赖带来的一些问题,还是推荐用 circular-dependency-plugin 这个插件来发现这类问题。
总结
循环加载在一些大型复杂项目中是很难避免的事情,出了问题排查也比较困难,但是大家平时开发中还是可以多多注意下,有了这方面的理解,碰到这类问题时也能更快的解决问题。
以上便是我的浅薄理解,如有不对还望大家指正。