关于 fix-cyclic-deps
fix-cyclic-deps 是用于发现和处理 UI5 模块循环依赖的开发技能,也适用于现代化改造引入的依赖环。它分析依赖图,并在运行时可达性允许时,将选定的静态导入改为延迟 sap.ui.require 调用。
主要功能
- 01构建并检查 UI5 模块依赖图
- 02检测双节点及更长依赖环
- 03依据可达性和使用情况选择延迟边
- 04将静态导入改为延迟加载
- 05报告无法自动修复的依赖环
适用场景
- 01排查运行时返回 undefined 的模块
- 02检查 UI5 现代化改造后的循环依赖
- 03处理新增模块导入引起的依赖环
- 04记录需要手动重构的依赖环
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ ui5/ plugins-coding-agents --skill fix-cyclic-deps安装后在 Claude Code 中输入/fix-cyclic-deps确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/dependency-graph-analysis.mdscripts/detect-cycles.jsdetect-unsafe-lazy.jsdetect-unsafe-lazy.test.jsscripts/lib/graph-utils.js详细介绍
修复循环模块依赖
此技能用于检测和解决 UI5 现代化过程中出现的循环模块依赖。当现代化将全局命名空间访问转换为 sap.ui.define 导入时,新的依赖边可能会形成循环导入(A 导入 B,B 导入 A),导致 UI5 AMD 加载器对反向边模块返回 undefined。
修复方法:将反向边上的 sap.ui.define 依赖替换为在每个调用点使用延迟加载的 sap.ui.require("path/to/Module")(同步形式),从加载器缓存中获取已加载的模块,而不创建依赖边。
Linter 规则
| 规则 ID | 消息模式 | 此技能的操作 |
|---|---|---|
| (无 — 结构性问题) | 运行时:尽管导入路径正确,模块仍为 undefined | 检测依赖图中的循环,并将反向边转换为延迟加载的 sap.ui.require() |
此技能不会由 UI5 linter 规则触发。它用于解决模块依赖图中的结构性问题。在现代化工作流的最后修复阶段会触发此技能;当开发者在运行时遇到 undefined 模块时,也可以单独使用。
使用场景
- 在现代化工作流中:作为第 3 阶段第 3.3 步(最后一步),在第 3 阶段的所有其他步骤完成后执行。多个步骤会添加
sap.ui.define依赖边,从而可能形成循环 — 在最后运行才能基于最终依赖图进行处理。 - 单独使用:当模块在运行时返回
undefined,而导入路径正确时。 - 手动重构后:当开发者添加新的
sap.ui.define依赖后,意外得到undefined时。
循环的来源
三种现代化操作会添加新的 sap.ui.define 依赖边,从而可能引入循环:
1. fix-js-globals 案例 1c — 全局命名空间读取
将 var Helper = com.example.app.utils.Helper; 转换为 sap.ui.define 依赖。如果 Helper 已导入当前模块,添加反向边就会形成双节点循环。
2. fix-js-globals 案例 10 — jQuery.sap.declare/require 现代化
将旧版 jQuery.sap.declare / jQuery.sap.require 代码包装在 sap.ui.define 中。jQuery.sap.require 调用会变为依赖项,可能形成旧版同步加载器采用不同方式处理的循环。
3. modernize-test-starter — 测试文件依赖
之前通过全局命名空间链访问模块的测试文件,现在会获得正确的 sap.ui.define 导入。相互引用的测试工具文件可能形成循环。
背景 — 为什么循环会破坏 UI5 加载器
UI5 AMD 加载器通过深度优先遍历来解析 sap.ui.define 依赖。遇到循环时:
- 加载器开始加载模块 A
- A 依赖 B → 加载器开始加载 B
- B 依赖 A → 但 A 尚未完成
- 加载器对 A 的工厂结果(反向边)返回
undefined - B 的工厂收到
undefined,而它原本需要的是 A 的导出内容
**双节点循环(A↔B)**必定会导致问题。**更长的依赖链(A→B→C→A)**在运行时是否会出问题取决于加载顺序 — 它们属于潜在缺陷。
为什么同步的 sap.ui.require 能打破循环
同步形式 sap.ui.require("path/to/Module") 不会创建加载器依赖边。它只会从模块缓存中读取,不会触发加载。
关键注意事项:如果目标模块的工厂尚未执行,sap.ui.require(path) 会返回 undefined。因此,只有当目标模块可通过从当前入口点出发的纯静态路径访问,且该路径在惰性调用位置执行之前触发时,才是安全的。
当你移除静态边 A → B,并将其替换为 A 中某个函数内的 var B = sap.ui.require("B") 时,必须确认加载 A 的每个入口控制器都能访问 B。
sap.ui.require 的三种形式——了解它们的区别:
sap.ui.require("path/to/B")— 同步读取缓存。不触发加载。如果模块不在缓存中,则返回undefined。sap.ui.require(["path/to/B"], function(B) {...})— 异步加载并执行回调。会触发加载。sap.ui.requireSync("path/to/B")— 同步加载并返回结果。会触发加载。已弃用。
关键区别:
sap.ui.define(["path/to/B"], ...)— 创建加载器边 A→B(有循环风险)- 函数体内的
var B = sap.ui.require("path/to/B")— 不创建加载器边(如果 B 在缓存中,则安全)
检测算法
自动检测脚本
一个捆绑脚本可自动执行检测(构建图、验证、检测循环、统计使用次数、识别枢纽):
node <skill-dir>/scripts/detect-cycles.js <project-root>
该脚本会:
- 从
manifest.json中发现项目命名空间 - 扫描所有
.js文件(应用 + 测试),移除注释并解析sap.ui.define数组 - 构建依赖图(仅包含项目内部模块)
- 通过回退分析验证图的完整性
- 检测 2 节点循环和更长的链(Tarjan 的 SCC)
- 统计使用次数,并识别惰性侧(2 节点)或枢纽(3 个及以上节点)
输出为写入 stdout 的 JSON。使用此输出来指导修复阶段。如果无法使用该脚本,请参阅下方的手动流程以及 references/dependency-graph-analysis.md。
手动流程
步骤 1 — 发现项目命名空间
读取 manifest.json → sap.app.id → 例如 "com.example.myapp" → 转换为斜杠形式:"com/example/myapp"。这用于区分项目内部模块与 sap/* 框架依赖项。
步骤 2 — 构建依赖图
解析所有 .js 文件(应用源代码和测试)。对每个文件:
-
移除注释(
//和/* */),同时保留字符串内容。这样可以避免匹配注释掉的代码中的sap.ui.define。 -
查找
sap.ui.define,使用允许各个标记之间有任意空白的正则表达式:/sap\s*\.\s*ui\s*\.\s*define\s*\(/关键:更简单的
sap\.ui\.define\s*\(会漏掉sap.ui.define被拆分到多行的文件。真实代码库中确实存在这种写法。 -
提取依赖数组:从
define后面的(开始,查找[到],并正确处理括号深度。提取字符串字面量。 -
仅筛选项目内部模块(与命名空间匹配)。包含以
test-resources/为前缀的路径。 -
构建有向图:节点 = 模块路径(不含
.js),边 = 依赖关系。
排除项:框架依赖项(sap/*)、函数体中的 sap.ui.require 调用、字符串字面量(扩展名称、片段路径)。
步骤 2b — 验证图的完整性
构建图后,验证每个被引用的依赖也已解析。缺失的模块可能会掩盖完整的循环链。
对于每个被引用为依赖项但没有对应图节点的模块:
- 检查源文件是否存在。如果不存在,则跳过并附带信息提示。
- 如果文件存在,则运行回退分析:扫描
[...]数组中的项目命名空间字符串。如果找到,但主解析器未提取到任何内容,则将其合并到图中。 - 还要检查已找到
sap.ui.define但第一个参数不是数组的文件。运行相同的回退分析。
步骤 3 — 检测 2 节点循环
对于每条边 A→B,检查是否也存在 B→A。收集唯一的配对(去重)。
第 4 步 — 检测更长的链(3 个以上节点)
运行 Tarjan 的 SCC 算法。任何包含 3 个以上节点的 SCC 都是较长的循环链。完整算法伪代码请参见 references/dependency-graph-analysis.md。
2 节点循环的修复策略
决策:哪一侧采用延迟处理
阶段 1 — 运行时可达性检查
- 确定入口点:Component.js + 每个静态导入 A 或 B 的控制器。
- 对每个入口点遍历静态依赖图,检查 {A, B} 中哪些模块可达。
- 根据覆盖范围选择延迟侧:
- 如果从每个入口点都能通过 B 到达 A → 在 B 中将 A 设为延迟加载是安全的。
- 如果从每个入口点都能通过 A 到达 B → 在 A 中将 B 设为延迟加载是安全的。
- 如果两侧的覆盖范围都完整 → 进入阶段 2。
- 如果两侧的覆盖范围都不完整 → 还需要向控制器添加静态依赖(参见“追加到控制器的补救方案”)。
阶段 2 — 使用次数作为决胜条件
当两侧同样安全时:
- 统计使用次数:A 的代码主体中对 B 的引用数量,与 B 的代码主体中对 A 的引用数量(排除字符串、注释、
sap.ui.define数组)。 - 使用次数较少的一侧胜出:代码改动更少。
- 决胜条件 1:总依赖项更多的模块保留常规导入(编排器模式)。
- 决胜条件 2:按字母顺序靠前的一侧保留常规导入。
转换步骤
要在 A 中将 B 设为延迟加载(A 不再静态导入 B):
步骤 1 — 从 A 的依赖数组及相应参数中移除 B:
// Before:
sap.ui.define(["path/to/B", "path/to/C"], function(B, C) {
// After:
sap.ui.define(["path/to/C"], function(C) {
步骤 2 — 在每个使用位置添加延迟 require:
// Before:
someMethod: function() {
B.doSomething();
B.doSomethingElse();
}
// After:
someMethod: function() {
var B = sap.ui.require("path/to/B");
B.doSomething();
B.doSomethingElse();
}
每个函数使用一个 sap.ui.require — 每个使用 B 的函数都需要单独声明。
声明关键字:与周围代码风格一致(现代代码使用 const,旧版代码使用 var)。
步骤 3 — 清理:移除 var X = X; 形式的自赋值。如果 B 没有任何代码引用,只需从依赖项中移除,无需添加延迟 require。
特殊情况:模块已部分采用延迟加载
如果 A 已有 sap.ui.require("path/to/B") 调用,且 sap.ui.define 数组中仍有 B,请从 sap.ui.define 中移除 B,并确保所有剩余的使用位置都有延迟 require。不要重复添加现有调用。
更长的链(3 个以上节点)— 基于中心模块的自动修复
较长的链通过基于中心模块的方法自动修复。识别每个 SCC 中的中心模块,并将其创建循环的依赖项设为延迟加载。修复一个中心模块即可同时消除多条链。
为什么采用基于中心模块的方法?
中心模块位于多条循环路径的中心。与其在多个文件中修复依赖边,不如将中心模块中的少数依赖项改为延迟加载,一次性打破所有循环。
中心模块识别算法
对于每个包含 3 个以上节点的 SCC:
- 循环参与度得分:统计每个节点指向 SCC 中其他成员的出边数量。得分越高,将这些依赖项设为延迟加载所能打破的循环就越多。
- 选择中心模块:选择得分最高的节点。决胜条件:总依赖项更多者优先(编排器)。最终决胜条件:按字母顺序靠前者优先。
- 识别创建循环的依赖项:中心模块的
sap.ui.define依赖项中属于该 SCC 的部分。 - 应用与 2 节点循环相同的转换。
- 重新检测 SCC:如果仍有循环,则重复此过程。
决策:将中心模块的哪些依赖项设为延迟加载
将中心模块的所有 SCC 内部依赖项设为延迟加载。这样可确保打破所有经过中心模块的循环。如果中心模块的某个 SCC 内部依赖项没有任何使用位置,只需将其移除。
后备方案:报告至 MODERNIZATION-ISSUES.md
如果模块在函数体之外的顶层引用了创建循环的依赖项(此处的延迟 sap.ui.require 会返回 undefined),请报告:
### Cyclic Dependency Chain (unfixable automatically)
- **SCC nodes**: A, B, C, D
- **Hub identified**: A (3 outgoing SCC edges, 4 incoming)
- **Blocking reason**: A references B at module top level (line 15), outside any function body.
- **Suggested manual fix**: Restructure A to defer the B reference into a function body, or extract the top-level initialization into a separate non-cyclic module.
修复前后示例
示例 1 — 仅有 1 个使用位置的 2 节点循环(ModuleA ↔ ModuleB)
ModuleA 导入 ModuleB,ModuleB 导入 ModuleA。ModuleB 只在 1 个调用位置使用 ModuleA。
修复前(有问题):
// ModuleB.js
sap.ui.define([
"com/example/myapp/utils/ModuleA",
"sap/ui/thirdparty/jquery"
], function(ModuleA, jQuery) {
var ModuleB = {
handleStatus: function(aSelectedItems, sId) {
ModuleA.processStatus(aSelectedItems, sId); // undefined!
}
};
return ModuleB;
});
修复后:
// ModuleB.js
sap.ui.define([
"sap/ui/thirdparty/jquery"
], function(jQuery) {
var ModuleB = {
handleStatus: function(aSelectedItems, sId) {
var ModuleA = sap.ui.require("com/example/myapp/utils/ModuleA");
ModuleA.processStatus(aSelectedItems, sId);
}
};
return ModuleB;
});
示例 2 — 有多个使用位置的 2 节点循环(Orchestrator ↔ Helper)
Orchestrator 导入 Helper(使用 5 次),Helper 导入 Orchestrator(使用 25 次)。决策:在 Orchestrator 中将 Helper 设为延迟加载(需要修改的使用位置更少)。
修复前(有问题):
// Orchestrator.js
sap.ui.define([
"com/example/myapp/utils/Helper",
"com/example/myapp/utils/Validator"
], function(Helper, Validator) {
var Orchestrator = {
openDialog: function() { Helper.openDialog(); },
refreshAll: function() { Helper.refreshAll(); }
// ... 3 more Helper usage sites
};
return Orchestrator;
});
修复后:
// Orchestrator.js
sap.ui.define([
"com/example/myapp/utils/Validator"
], function(Validator) {
var Orchestrator = {
openDialog: function() {
var Helper = sap.ui.require("com/example/myapp/utils/Helper");
Helper.openDialog();
},
refreshAll: function() {
var Helper = sap.ui.require("com/example/myapp/utils/Helper");
Helper.refreshAll();
}
// ... each function gets its own lazy require
};
return Orchestrator;
});
示例 3 — 移除未使用的依赖
ModuleX 导入了 ModuleY,却从未在代码中引用它——只需移除这个无用的导入。
之前:
sap.ui.define([
"com/example/myapp/utils/ModuleY",
"com/example/myapp/utils/ModuleZ"
], function(ModuleY, ModuleZ) {
// ModuleY never used
之后:
sap.ui.define([
"com/example/myapp/utils/ModuleZ"
], function(ModuleZ) {
示例 4 — 通过 Hub 自动修复较长链(ModelManager Hub)
ModelManager 通过依赖 FilterHelper 和 ChartHelper 参与了 7 条循环链。Hub 分析结果:ModelManager 有 2 条指向 SCC 的出边,因此它是 hub。
之前(有问题):
// ModelManager.js
sap.ui.define(["sap/ui/thirdparty/jquery",
"com/example/myapp/utils/FilterHelper",
"com/example/myapp/utils/Payload",
"com/example/myapp/utils/ChartHelper",
"sap/base/Log"
], function(jQuery, FilterHelper, oPayload, ChartHelper, Log) {
var ModelManager = {
getFilterConfig: function() {
var oFilterConfig = FilterHelper; // undefined due to cycle!
},
getChartType: function() {
var oChartType = ChartHelper.getAnnotationType(); // undefined!
}
};
return ModelManager;
});
之后(已修复——两个 SCC 内部依赖都已改为延迟加载):
// ModelManager.js
sap.ui.define(["sap/ui/thirdparty/jquery",
"com/example/myapp/utils/Payload",
"sap/base/Log"
], function(jQuery, oPayload, Log) {
var ModelManager = {
getFilterConfig: function() {
var FilterHelper = sap.ui.require("com/example/myapp/utils/FilterHelper");
var oFilterConfig = FilterHelper;
},
getChartType: function() {
var ChartHelper = sap.ui.require("com/example/myapp/utils/ChartHelper");
var oChartType = ChartHelper.getAnnotationType();
}
};
return ModelManager;
});
从 1 个文件中移除 2 个依赖,便打破了全部 7 条循环链。注意:var oFilterConfig = FilterHelper; 是一种旧式别名模式——该技能会保留现有代码,只转换导入机制。
实施步骤
-
运行检测脚本,构建依赖图并检测所有循环:
node <skill-dir>/scripts/detect-cycles.js <project-root>JSON 输出包含
twoNodeCycles(含使用次数和延迟加载侧的判断)以及longerChains(含 hub 识别结果)。如果脚本不可用,请遵循上述手动流程。 -
检查输出:查看
warnings和errors数组,确认依赖图是否完整。继续之前先处理错误。 -
修复每个 2 节点循环(来自
twoNodeCycles[]):lazySide字段标识需要修改的模块- 从
sap.ui.define数组和函数参数中移除循环依赖 - 在每个使用位置添加
var Module = sap.ui.require("path/to/Module") - 清理残留内容
-
通过 hub 修复较长链(来自
longerChains[]):hub和hubInternalDeps字段标识需要延迟加载的模块和依赖- 如果所有使用位置都位于函数体内 → 应用延迟加载转换
- 如果有任何使用位置位于模块顶层 → 报告至 MODERNIZATION-ISSUES.md
- 重新运行 SCC 检测——如果仍有循环,则重复处理
-
验证——重新运行循环检测:
node <skill-dir>/scripts/detect-cycles.js <project-root>输出应显示
twoNodeCycles: []和longerChains: []。然后运行npx @ui5/linter --details进行回归检查。 -
验证静态覆盖情况:
node <skill-dir>/scripts/detect-unsafe-lazy.js <project-root>检查每个
sap.ui.require("M")中的 M,确保每个入口控制器都能通过静态链访问到它。如果存在发现项,请应用下方的“追加到控制器的补救方法”。重复运行,直到unsafeCount: 0。只有两个脚本都报告 0 个问题,技能才算完成。
追加到控制器的补救方法
当 detect-unsafe-lazy.js 报告有未覆盖的入口时,将延迟加载目标追加到控制器的 sap.ui.define 数组中,作为仅用于加载的副作用导入:
// Before — controller does NOT statically import DialogHelper
sap.ui.define([
"com/example/myapp/utils/ActionHandler",
"sap/ui/core/mvc/Controller"
], function(ActionHandler, Controller) {
// ActionHandler has lazy require to DialogHelper
// → sap.ui.require("DialogHelper") returns undefined
// After — append as load-only dep (no factory param)
sap.ui.define([
"com/example/myapp/utils/ActionHandler",
"sap/ui/core/mvc/Controller",
"com/example/myapp/utils/DialogHelper"
], function(ActionHandler, Controller /* no DialogHelper param */) {
// DialogHelper now in cache → lazy require works
规则:
- 追加到依赖数组末尾,不添加对应的工厂参数
- 如果
staticUncovered列出 3 个或更多控制器,则改为将依赖添加到BaseController.js或Component.js - 每次修复后重新运行
detect-unsafe-lazy.js,确认问题已解决
注意事项 — 关键规则
-
在函数体内部延迟 require:将
var B = sap.ui.require("path/to/B")放在使用 B 的函数内部,而不是模块顶层。在定义时,B 可能尚未缓存。 -
每个函数只 require 一次:每个使用该模块的函数体都需要自己的声明。变量的作用域为函数级。
-
仅限项目内部模块:框架依赖项(
sap/*)不会导致项目级循环依赖。只处理与项目命名空间匹配的依赖项。 -
字符串字面量不算使用:日志消息、
.extend()类名、片段路径都是字符串,不是代码引用。不要统计或修改它们。 -
注释掉的代码不算使用:
//或/* */内的行不算使用。 -
参数对齐:移除索引 N 处的依赖项 → 移除索引 N 处的函数参数。仅当被移除的依赖项有对应参数时才进行调整。
-
现有的延迟 require 不算依赖边:函数体中的
sap.ui.require("path/to/X")不会产生加载器依赖边。只有sap.ui.define数组条目才算依赖边。 -
幂等:重复运行是安全的。第二次运行时,循环依赖已被打破,不会进行任何更改。
-
包含测试文件:依赖图必须包含测试文件。
modernize-test-starter添加sap.ui.define依赖项后,测试文件也可能参与循环依赖。 -
不要改动异步
sap.ui.require:异步形式sap.ui.require(["path/to/B"], function(B) {...})会产生加载器依赖边。此技能仅使用同步的单字符串形式。 -
多行
sap.ui.define模式:正则表达式必须允许sap、.、ui、.、define、(之间出现任意空白或换行。较简单的正则表达式会漏掉拆分的模式,导致循环依赖链被隐藏。 -
解析前移除注释:搜索
sap.ui.define前,始终先移除注释(同时正确处理字符串字面量)。文件可能会在注释中将其用作章节标记。 -
验证图的完整性:检查每个引用的依赖项是否都已解析。对主要解析器未找到任何内容的文件运行备用分析。在检测循环依赖前,合并发现的依赖项。
相关技能
- fix-js-globals:案例 1c 和案例 10 是产生循环依赖的新依赖边的主要来源。请在
fix-js-globals完成后运行fix-cyclic-deps。 - modernize-test-starter:测试文件现代化会添加
sap.ui.define依赖项,这些依赖项可能导致测试工具之间,或测试模块与应用模块之间出现循环依赖。 - modernize-ui5-app:负责协调阶段顺序的上级工作流。此技能作为第 3 阶段第 3 步(最终步骤)运行。