Mako - 循环依赖检测

2024-09-04 by stormslowly

循环依赖是代码中隐藏的风险,一些无意的改动就可能触发,并只能在运行时发现。 Mako 内置循环依赖检测,让代码中的循环依赖尽快发现,排除风险。

文中用到的 demo 的示例代码

大家多多少少碰到过循环依赖的问题,比如变量明明导出了,但是为什么拿到的是 undefined, 或者 ReferenceError: Cannot access 'foo' before initialization。最后一查,查半天,严重影响上班摸鱼时间,最后发现是循环依赖,所以对循环依赖深恶痛绝,欲除之而后快。 但时候有时候发现,有些项目也有循环依赖,但是也没有任何问题,仿佛它又是人畜无害的。 这些问题背后到底是为什么呢?

循环依赖到底是怎么回事

从一个不报错的循环依赖开始

function foo(){
  bar()
}
function bar(){
}

function x(){
  y()
}
function y(){
}

one-big-module

假设这4个函数的实现日益复杂,需要拆成两个模块,每个模块 2 个函数。那么有 3 个拆法。

  1. {foo, bar}, {x, y}
  2. {foo, x}, { bar, y } 最后一种青年可能会这么拆分 { foo, y }, {bar, x} 那么依赖关系就变成下图,循环依赖就形成了。

circlular-modules

但是这个循环依赖是可以正常工作的完全没有问题

//index.js
import { foo } from './m1';
foo();
// m1.js
import {bar} from "./m2"
export function y() {
  console.log("y called")
}
export function foo(){
  bar()
}
// m2.js
import {y} from "./m1"
export function bar(){
  console.log("bar called")
}
export function x(){
  y()
}
Building with mako for development...
Warning Circular Dependencies: "case1/m1.js" -> "case1/m2.js" -> "case1/m1.js"
dist/index.js       9.09 kB map: 10.59 kB
$ node dist/index.js
bar called

为什么没有出错呢?这里有需要简单的了解下模块的编译产物的结构 m1 模块编译完是这样的

// m1.js
__mako_require__.e(exports, {  // step 1
    foo: function() {
        return foo;
    },
    y: function() {
        return y;
    }
});
var _m2 = __mako_require__("m2.js"); // step 2
function y() {
    console.log("y called");
}
function foo() {
    _m2.bar();
}

m2 也有类似的结构

__mako_require__.e(exports, { //step 3
    bar: function() {
        return bar;
    },
    x: function() {
        return x;
    }
});
var _m1 = __mako_require__("m1.js"); //step 4
function bar() {
    console.log("bar called");
}
function x() {
    _m1.y();
}

当我们引用 foo 的时候,JavaScript 引擎执行是这样的

执行顺序是讲起来非常的拗口,上面这么复杂的执行逻辑可以用代码合并的方式来简化,即在 m1 文件中合并进m2 文件;当在 m2 文件合并进 m1 时,因为 m1 在上下文中已经存在就不用合并了,合并完成,最终代码类似如下。

// import {bar} from "./m2" 合并进以下代码
export function bar(){
  console.log("bar called")
}
export function x(){
  y()
}

// m1 原本的代码
export function y() {
  console.log("y called")
}
export function foo(){
  bar()
}

最后foo自然能正常调用执行。

至此我们可以有下面的结论:

  1. 不经意的不合理的模块划分容易形成循环依赖
  2. 提升机制(hoisting)让循环依赖能拿到正确的值,运行时不会报错。

会出现 undefined 的循环依赖

那我们来看另外一个 case

import { bar } from "./m2";
export var y = "theY";
export var foo = "foo + " + bar;
import { y } from "./m1";
export var bar = "bar + " + y;
export var x = "theX " + y;

大家可以猜猜,打印 foo 结果是啥? 我们用上面合并代码的方式来分析这个循环依赖,得到这样的代码:

// import { bar } from "./m2" 的合并展开
export var bar = "bar + " + y;
export var x = "theX " + y;

// m1.js 原本代码
export var y = "theY";
export var foo = "foo + " + bar;

bar变量定义的时候,y变量因为提升( hoist ) 当前值是 undefined,所以 bar值为"bar + undefined", 那么 最终的 foo 的值 "foo + bar + undefined"

实际运行:

Building with mako for development...
Warning Circular Dependencies: "case2/m1.js" -> "case2/m2.js" -> "case2/m1.js"
dist/index.js       9.66 kB │ map: 10.77 kB
✓ Built in 69ms
$ node dist/index.js
case2 foo + bar + undefined

所以一样是因为提升,这次就在循环依赖的模块中出现了 bug,示例中只是两个模块形成的循环依赖,如果由更多的模块形成更大的循环,要查出这个问题就没有这么容易了。

再来个报错的循环依赖

import { bar } from "./m2";
export const y = "theY";
export var foo = "foo + " + bar;
import { y } from "./m1";
export var bar = "bar + " + y;
export var x = "theX " + y;

直接看执行结果

Building with mako for development...
LoopDetected: "case3/m1.js" -> "case3/m2.js" -> "case3/m1.js"
dist/index.js       9.71 kB map: 10.84 kB
 Built in 67ms
Complete!
$ node dist/index.js
/demo/dist/index.js:31
                    return y;
                    ^
ReferenceError: Cannot access 'y' before initialization

继续用“合并”法分析下

// import { bar } from "./m2" 的合并展开
// y 的 临时死区起始点
export var bar = "bar + " + y;
export var x = "theX " + y;

// m1.js 原本代码
// y 的 临时死区起结束
export const y = "theY";
export var foo = "foo + " + bar;

由于这里 y变量使用 const定义,那么形成了一个暂时性死区( TDZ),bary的死区中访问y,自然就报错了。这就是某些循环依赖会报错的原因了。

至此,我们总结下

  1. 如果循环依赖的相互引用的函数,因为函数的提升,循环依赖不会报错。
  2. 如果循环依赖的是var 变量,因为抬升 (hoist), 循环依赖里面会出现 undefined 的现象。
  3. 因为 const/letTDZ ,循环依赖里面就会有 Cannot access before initialization 的报错

所以循环依赖要不就没有错误,要不就是运行时错误。所以它就是代码中一个隐藏的炸弹,一个不经意的新增 export/import 形成了循环依赖,就很有容易流到生产环境中去。前置发现循环依赖并解决就很要必要。

Mako 来帮你发现循环依赖

Mako 内置了循环依赖检测功能,默认已经开启,构建时会打印出项目中所有的循环依赖。

Warning Circular Dependencies: "case1/m1.js" -> "case1/m2.js" -> "case1/m1.js"
Warning Circular Dependencies: "case2/m1.js" -> "case2/m2.js" -> "case2/m1.js"
Warning Circular Dependencies: "case3/m1.js" -> "case3/m2.js" -> "case3/m1.js"

如何配置

Umi 在开启 Mako 后,默认开启循环依赖检测功能;如果你直接使用 @umijs/mako 的需要增加配置项。

{
  "experimental": {
    "detectCircularDependence": {
      "ignores": ["node_modules"], // 需要忽略的循环依赖正则表达,默认配置 ["node_modules"]
      "graphviz": false
    }
  }
}
Edit this page on GitHub