【开发技巧】2.x实现循环引用时输出依赖栈

前言

前段时间在开发中偶然遇到了一次循环引用。对着输出的错误日志一顿找,问题当然是解决了。但是就觉得,不那么舒服。

你都已经报错了,我也知道是循环引用了,为啥不能直接告诉我呢?

网上也有一些循环依赖的静态查找方式,但其实Cocos已经对循环依赖做了处理,并不是所有的循环依赖都会导致错误。
于是周末研究了一下,给大家带来一个小小的解决方案~
如果暂时不想关心过程可以直接跳到后面的实现的完整代码部分~

一、分析

1. 翻车现场

循环引用的报错大概长这样:

如果你点击右上角的代码链接,就能够跳到最后出错的项目代码处,可能是extend、可能是文件顶层调用了某个类的静态函数…

:thinking:可是,这个位置常常不是真正发生循环引用的类。比如A -> B,B -> A,但是你可能在C类中报了个错…

如果你展开调用栈查看,可以找到一些指向__quick_compile__.js的代码:

这个__quick_compile__是什么呢?也不是我们代码里的呀?

2. 认识一下

2.1 __quick_compile__的作用

quick_compile__在代码依赖中扮演关键角色。当(本地预览)运行require的时候,其实是通过__quick_compile_project.require来实现的加载类:

:open_mouth:居然是这么关键的角色…

让我们点进__quick_compile__看看:

image

文件的开头可以看到几个非常长的变量。:face_with_monocle:就是这路径怎么看着这么熟悉?
:astonished:这不是我项目里的ts文件吗!

是的,__quick_compile__里,注册了游戏里 所有的类
Cocos会在代码文件变更的时候,动态更新这几个常量值,并维护它们之间的依赖关系。

游戏运行时,__quick_compile__会按照它 记录的顺序 ,加载 所有 的类。
并且执行的这个时机非常地早(cc.Game.prepare中会调用_loadPreviewScript)。

这就是为什么,只要 存在 循环依赖,游戏就进不去了:rofl:
也是为什么,在代码文件的顶层写复杂的代码会影响启动速度:smiling_imp:

2.2 require函数

再往下翻翻,就可以看到window.__quick_compile_project__的定义和reuqire函数。
报错信息中也可以定位到具体位置:

require入参是 依赖的模块路径当前模块的路径

require都干什么呢?

  1. 将我们写的require路径转换成编译后的路径
  2. 初始化依赖的模块

当初始化模块时,如果它也依赖于别的模块,就会递归进行初始化。循环依赖就发生在这里!

当我们使用调用栈定位报错位置时,指向的位置就是模块初始化:

image

:smirk:既然所有的依赖都是通过require函数实现的,也传入了路径,那是不是可以在这里做一些改变,来得到依赖链?

3. 给个方案

需要的数据都有了,就好办了。

问题1:怎么获得依赖链?

  1. 维护一个依赖路径栈
  2. 当调用require时,把依赖的模块路径入栈
  3. 依赖模块初始化完成后,出栈

问题2:怎么判断发生循环依赖?

  1. try catch调用初始化函数
  2. 发生错误时,判断当前模块路径是否在栈内。若是,则可能发生循环依赖。

可行性99.99% 搞呗:sunglasses:

二、实现

1. 找出__quick_compile__

:rofl:真正的难点总是在那0.01%。

前面提到,__quick_compile__是Cocos 动态生成 的。一想到编辑器是闭源的,该不会看不到这玩意吧…

大胆在Cocos引擎目录下搜索了一下,还好!虽然是动态生成,但是有模板文件!

我们在预览时执行的__quick_compile__,是在项目下的,改这个是没用的,会被动态生成覆盖掉!

image

:warning:注意:

修改__quick_compile__模板后,最好删除掉项目quick-scripts下的__quick_compile__,并重启Cocos编辑器,以保证Cocos按照新的模板重新生成一份。

2. 维护依赖链

const requireStack = [];
window.__quick_compile_project__ = {
    require: function (request, path) {
        // ...其他代码
        requireStack.push(requestPath);
        if (!requestModule.module && requestModule.func) {
            requestModule.func();
        }
        requireStack.pop();
        // ...其他代码
    }
}

在外部声明一个requireStack作为依赖栈,在初始化之前入栈,初始化之后出栈。

3. 判断发生循环依赖

const lastRequire = "";
const requireStack = [];
window.__quick_compile_project__ = {
    require: function (request, path) {
        // ...其他代码
        lastRequire = requestPath;
        requireStack.push(requestPath);
        if (!requestModule.module && requestModule.func) {
            try {
                requestModule.func();
            } catch (e) {
                let startIndex = requireStack.indexOf(lastRequire);
                if (startIndex >= 0) {
                    // 发生循环依赖
                } else {
                    throw e;
                }
            }
        }
        requireStack.pop();
        // ...其他代码
    }
}

只要依赖栈中存在当前依赖路径,即可认为发生了循环依赖。

:warning:注意:

这里增加了一个lastRequire变量。因为当循环依赖导致错误的时候,很可能并不是案发现场。
比如Class -> A -> B -> C -> A。报错代码会在C中,但这个时候A已经被出栈了,所以此时requestPath是C。
这会导致丢失最后的A,错误定位循环依赖关系。

4. 输出依赖栈

const lastRequire = "";
const requireStack = [];
window.__quick_compile_project__ = {
    require: function (request, path) {
        // ...其他代码
        lastRequire = requestPath;
        requireStack.push(requestPath);
        if (!requestModule.module && requestModule.func) {
            try {
                requestModule.func();
            } catch (e) {
                let startIndex = requireStack.indexOf(lastRequire);
                if (startIndex >= 0) {
                    // 发生循环依赖
                    let stack = requireStack.slice();
                    stack.push(lastRequire);

                    let beforeStack = stack.slice(0, startIndex);
                    let afterStack = stack.slice(startIndex);

                    let tipStr = beforeStack.join(" \n->") + "\n=====循环依赖起始位置=====\n->" + afterStack.join(" \n->");

                    console.error(e);
                    console.warn("疑似循环依赖:\n" + tipStr);
                } else {
                    throw e;
                }
            }
        }
        requireStack.pop();
        // ...其他代码
    }
}

完整输出整个requireStack即可,可以按照个人喜好调整输出模板,整个不重要:grin:

由于循环依赖并没有特定的Error,普通的循环依赖(比如在函数中依赖其他类)在运行中并不会导致卡死。所以还是要输出原本的错误信息,并且只能是疑似#狗头。

5. 额外处理(完整代码)

try catch之后,错误没有抛出,逻辑会继续执行。因为模块初始化失败,会导致逐层产生错误。所以我们再对错误做一点小处理~

const lastRequire = "";
const requireStack = [];
window.__quick_compile_project__ = {
    require: function (request, path) {
        // ...其他代码
        lastRequire = requestPath;
        requireStack.push(requestPath);
        if (!requestModule.module && requestModule.func) {
            try {
                requestModule.func();
            } catch (e) {
                if (e.message === "循环依赖") return;

                let startIndex = requireStack.indexOf(lastRequire);
                if (startIndex >= 0) {
                    let stack = requireStack.slice();
                    stack.push(lastRequire);

                    let beforeStack = stack.slice(0, startIndex);
                    let afterStack = stack.slice(startIndex);

                    let tipStr = beforeStack.join(" \n->") + "\n=====循环依赖起始位置=====\n->" + afterStack.join(" \n->");

                    console.error(e);
                    console.warn("疑似循环依赖:\n" + tipStr);
                    throw new Error("循环依赖");
                } else {
                    throw e;
                }
            }
        }
        requireStack.pop();
        // ...其他代码
    }
}

当发生循环依赖时,抛出一个新的错误,消息文本为“循环依赖”,实现阻断逻辑运行。并且后续捕获到这个错误就不再处理了。

三、效果

:grinning:现在舒服了~

四、总结

本方案本质是依赖于运行时报错来实现的循环依赖检测。所以优缺点也比较明显…

优点:准确定位真正会引发循环依赖错误的依赖链。

缺点:使用try catch会导致发生其他错误时,调用栈定位到__quick_compile__。

五、拓展阅读

为什么Cocos require一个模块的时候, 不用执行加载,而是执行初始化

Cocos为了避免循环依赖的发生(比如BaseView -> ViewTool,这样的两个类看起来就很容易循环引用:sweat_smile:),是有一套自己的机制的。

Cocos会在初始化时就把所有的模块都加载了(但不运行),并为模块声明一个空的对象。当模块初始化(运行)的时候,才会将导出的内容添加到这个对象中。

这样,只要你不是在模块的顶层直接发生循环依赖(比如继承、调用静态函数…),就不会导致循环引用!
比如你只是在BaseView的某个函数中,调用ViewTool实现通用功能,是完全没问题的:sunglasses:~


两个小广告:

广告1:开始尝试做公众号了,相关技术文章也会同步在公众号发布。内容专注于Cocos相关技术及信息。
公众号文章链接:https://mp.weixin.qq.com/s/OvEJHNkdbhW9w6eHCs1PYw
感兴趣的朋友可以点个关注~

广告2:公司还在持续招人,厦门延趣游戏,金九银十,提前抢跑#狗头!感兴趣dd!

7赞

我3.72在打包web端的时候 总是报循环引用的警告,一直没有管 好像没发现什么问题