两张动画带你了解循环加载

一起养成写作习惯!这是我参与「掘金日新计划 · 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);
复制代码

执行的顺序是这样的:

  1. main.js 中加载 a.js,然后执行 a.js 中的代码 ;
  2. exports.done = false;之后,又加载了 b.js ,所以会先执行 b.js
  3. b.js 中,按顺序执行,只是执行到 var a = require('./a.js');时,能获取到的内容就只有之前 a.js 中执行的第一行代码,即 exports.done = false;,所以在 b.js 中加载的 a.done 的值为 false
  4. 执行完 b.js 后,又回到 a.js 执行之前未执行的部分,即从 var b = require('./b.js');继续开始执行,此时 b 已经是完全执行完的状态了,所以 b.donetrue,直到执行剩余部分;
  5. 执行权继续回到 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');
复制代码

执行顺序是这样的:

  1. 执行 foo 模块,由于 import 的提升效果,先导入 bar 模块;
  2. 先输出 bar is running,剩下的依次执行;
  3. 执行权回到 foo 模块,先执行 console.log('foo is running');,再执行 console.log('bar = %j', bar);(因为第二句 import 语句已经执行过了),然后其余语句依次执行;
  4. 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 这个插件来发现这类问题。

总结

循环加载在一些大型复杂项目中是很难避免的事情,出了问题排查也比较困难,但是大家平时开发中还是可以多多注意下,有了这方面的理解,碰到这类问题时也能更快的解决问题。

以上便是我的浅薄理解,如有不对还望大家指正。

猜你喜欢

转载自juejin.im/post/7085284817377427493