前言
大转转前端团队构建了一个以圈复杂度为核心指标的前端质量体系,通过对代码复杂度的衡量和检测,提升了代码质量和维护性。今日前端早读课文章由 @李长毅分享,公号:大转转 FE 授权。
正文从这开始~~
背景
前端质量体系建设
目前在转转内,我们已经基本完成了前端质量体系的系统性建设,在整个质量体系内,我们按照类型将所有指标分为监控、工程规范、技术先进性三个方向,在这三个方向目前总共上线了 11 个指标,按照指标的重要程度不同,我们又将 11 个指标划分为了 P1、P2、P3 三个等级。
在工程规范分类中,代码规范、代码行数和圈复杂度都是 P2 类型的重要指标,今天的文章就为大家介绍一下 “圈复杂度” 相关的概念和检测实现方式。
圈复杂度相关介绍
概念
圈复杂度(Cyclomatic complexity,简写 CC)也称为条件复杂度,是一种代码复杂度的衡量标准。由托马斯・J・麦凯布(Thomas J. McCabe, Sr.)于 1976 年提出,用来表示程序的复杂度。它可以用来衡量一个模块判定结构的复杂程度,数量上表现为线性独立路径条数,也可理解为覆盖所有的可能情况最少使用的测试用例数。圈复杂度大说明程序代码的判断逻辑复杂,可能质量低且难于测试和维护。程序的可能错误和高的圈复杂度有着很大关系。
【第3309期】转转搭建平台:魔方数据中心实战
业内衡量标准
代码复杂度低,代码不一定好,但代码复杂度高,代码一定不好。
一段程序的循环复杂度是其线性独立路径的数量。若程序中没有像 IF 指令或 FOR 循环的控制流程,因为程序中只有一个路径,其循环复杂度为 1,若程序中有一个 IF 指令,会有二个不同路径,分别对应 IF 条件成立及不成立的情形,因此循环复杂度为 2。
圈复杂度
|
代码状况
|
维护成本
|
1 - 10
|
清晰、结构化
|
低
|
10 - 20
|
复杂
|
中
|
20 - 30
|
非常复杂
|
高
|
>30
|
不可读
|
非常高
|
从上面的表格可以看出,圈复杂度和我们代码的可维护度息息相关。简单的说,我们对历史代码进行维护的时候,如果代码内因为条件判断存在大量的逻辑路径,维护起来一般都是非常困难的。
计算方法
圈复杂度的计算方式有很多种,下面为大家介绍其中比较典型的两个方法。
1、点边计算法
在介绍点边计算法之前,为大家普及一下其中使用到的图形概念:控制流程图。
图形概念 —— 控制流程图
控制流程图,是一个过程或程序的抽象表现,是用在编译器中的一个抽象数据结构,由编译器在内部维护,代表了一个程序执行过程中会遍历到的所有路径。它用图的形式表示一个过程内所有基本块执行的可能流向,也能反映一个过程的实时执行过程。
例如:
不同条件判断对应控制流程图
如果在控制流图中增加了一条从终点到起点的路径,整个流图形成了一个闭环。圈复杂度其实就是在这个闭环中线性独立回路的个数。
点边计算法计算公式
M = E − N + 2P
-
E:控制流图中边的数量
-
N:控制流图中的节点数量
-
P:独立组件的数目
E、N、P 对应示例
P 代表图中独立组件的数目,独立组件是什么意思呢?并不是指我们前端代码中常说的组件,来看看下面两个图,左侧为连通图,右侧为非连通图
独立组件示例
左侧的独立组件是 1,右侧是 2
但是因为我们从代码维度分析,并且我们后续的检测方式为函数维度的圈复杂度检测,所以可以忽略非连通情况,我们的检测内容都是连通的。
所以我们的公式可以简化为:
M = E − N + 2
2、节点判定法
第二种计算方式就是节点判定法
节点判定法是一个更直观的方法,因为圈复杂度所反映的是 “判定条件” 的数量,所以圈复杂度实际上就是等于判定节点的数量再加上 1,也即控制流图的区域数,对应的计算公式为:
M = P + 1
其中,P 为判定节点的个数,判定节点都有哪些,例如:
-
if 语句
-
while 语句
-
for 语句
-
case 语句
-
catch 语句
-
and 和 or 布尔操作(
||
&&
)
-
?:
三元运算符
对于多个条件的 Case 或者 if-elseIf-else 结构,统计节点的时候必须统计全部实际的判定节点数量,也就是说每个 else-if 语句,以及每一个 case 语句,都应该算是一个判定节点。
节点判定法计算举例:
例 1:在这个函数中,函数本身圈复杂度为 1,if 条件判断、&& 判断分别增加 1,所以最终函数圈复杂度为 3。
// 函数圈复杂度:3
function test(
a) {
let result = 1;
if (a > 0 && a < 10) {
result--;
}
return result;
}
例 2:在这个函数中,函数本身圈复杂度为 1,if 条件判断、for 循环、三目运算分别增加 1,swich 语句的两个 case 判断为 2,所以最终函数圈复杂度为 6。
// 函数圈复杂度:6
function test(a) {
let result = 1;
if (a > 0) {
result--;
}
for (let i = 0; i < 10; i++) {
result += 1;
}
switch (parseInt(result)) {
case 1:
result += 20;
break;
case 2:
result += 30;
break;
default:
result += 10;
break;
}
return result > 20 ? result : false;
}
在我们的质量检测系统中,最终定义的检测维度为函数维度,所以不存在特殊的多独立组件的场景,并且需要考虑代码修改难易度,所以我们在检测中使用的方式都是节点判定法。
圈复杂度的特性
圈复杂度与缺陷
一般来说,圈复杂度和缺陷个数有高度的正相关:圈复杂度最高的模块和方法,其缺陷个数也可能最多,当你的代码内存在大量逻辑判断时,往往会增加后续维护中 bug 产生的风险度。
圈复杂度与结构化测试
此外,它在测试提供测试用例时能够提供参考。一个好的用例设计一般会创建数量与被测代码圈复杂度值相等的测试用例,以此提升用例对代码的分支覆盖率。
圈复杂度与遗留代码
对于遗留代码的维护或重构,测量圈复杂度特别有价值。一般使用圈复杂度作为提升代码质量的切入点。
并且对于历史代码,我们可以基于时间变化维度来评估模块或函数的圈复杂度和对应增长值,并做出相应的改造决定,例如:
转转前端圈复杂度检测方式
指标分数计算方式
当前转转前端质量体系内的圈复杂度,是根据公司内所有前端项目进行了相应数据统计后评定得出,我们并没有直接按照业内最佳数值进行代码检测,因为我们需要考虑到目前所有项目的平均水平、代码修改的成本等问题,所以经过多次数据统计后,我们制定了如下的检测计算方式:
圈复杂度得分 = (单函数圈复杂度评分 + 嵌套函数圈复杂度评分) / 2
这里可以看到,我们将圈复杂度分为了两个维度,分别是 “单函数圈复杂度” 和 “嵌套函数圈复杂度”,之所以这样做的原因大家可以继续往下看,目前我们会将两个维度的圈复杂度检测结果取平均,最终得出相应分数,用来判定当前项目的圈复杂度得分。
其次就是可以发现我们的两个维度都与 “函数” 相关,为什么最终选择以函数为最小维度进行检测,主要原因就是需要考虑大家的改造成本,切合上面的 “节点判定法”,函数改造相对来说是最容易的。
单函数圈复杂度的评分,经过评定后我们分别使用 15、20、30 为一个单函数圈复杂度计算的阈值,当函数的圈复杂度高于这三个值的时候,分别会去扣除相应的分数
单函数圈复杂度
|
扣分情况
|
[0, 15]
|
达标
|
(15, 20]
|
-1
|
(20, 30]
|
-2
|
> 30
|
-4
|
嵌套函数圈复杂度的评分,因为其特殊性(大家可以往下看它具体的实现方式),它的评分规则是与整体的嵌套函数的行数相关,我们将函数的行数按照 100 为一档,每一档制定了相应的圈复杂度阈值,当超出阈值的时候会进行扣分,大概方式如下
嵌套函数总行数
|
嵌套函数圈复杂度扣分阈值
|
扣分情况
|
[0, 100]
|
25
|
-1
|
(100, 200]
|
35
|
-1
|
(200, 300]
|
54
|
-1
|
……
|
……
|
……
|
下面为大家分别介绍两个圈复杂度的检测方式:
单函数圈复杂度检测实现方式
在单函数的圈复杂度计算方面,因为其计算方式和业内圈复杂度计算方式相同,所以我们可以采用很多成熟的方案,其中比较典型的有下面几种检测方式:
ESLint - complexity(当前方案)
ESLint 的圈复杂度检测通常是针对函数级别的。ESLint 提供了规则来检测每个函数的复杂度,并根据函数中的条件、循环等因素计算圈复杂度。
为什么选择 Eslint
-
接入成本低
-
检测速度快
-
本地修改便捷
-
函数维度检测,修改成本低,比较容易接受
SonarQube
SonarQube 是一个用于代码质量管理的开源平台,旨在帮助开发团队通过静态代码分析、代码度量和代码检查来提高代码质量
SonarQube 在代码复杂度分析方面更倾向于全局视角,它可以对整个文件或项目进行代码质量分析,包括圈复杂度在内的多个指标。它提供了全局性的代码复杂度概览,帮助开发团队了解整个代码库的质量状况,通过综合考虑代码库中的所有函数、模块和文件,可以提供更全面的代码复杂度分析。
Sonar 的优点
-
有比较优秀的图形界面
-
查询能力强大,空指针、内存泄漏、漏洞等等
为什么不选择 SonarQube
TyphonJS-ESComplex
TyphonJS-ESComplex 是一个 JavaScript 库,用于计算和分析 JavaScript 代码复杂性的工具
TyphonJS-ESComplex 主要专注于 JavaScript 文件整体的复杂度分析,包括圈复杂度等指标。它提供了关于整个文件结构复杂度的详细报告,帮助您评估整个文件的复杂度水平,对于整个文件的复杂度评估能够帮助开发人员识别整体代码结构中的问题,并进行相应的优化和改进。
为什么不选择 TyphonJS-ESComplex
Eslint 中如何进行圈复杂度检测
有了 Eslint 的能力,我们可以非常容易的进行单函数复杂度的检测,只需要在规则中进行如下配置,并且不需要安装任何额外的包:
// [报错级别, 报错阈值]
rules: {
complexity: ['error', 15]
},
需要注意的是,Eslint 的检测中,是完全按照函数进行分隔,这就导致,在一些相互嵌套的函数中,它的检测结果会显得比较奇怪,比如下面的例子中,fn1 计算圈复杂度的时候,只会计算自己内部的逻辑,而不会包含 fn2 内部和 some 的回调函数中内部逻辑的圈复杂度,fn2 和 some 的回调函数又有自己的圈复杂度。
// fn1的圈复杂度不包含 some回调函数 和 fn2函数 内部逻辑
function fn1(list) {
// fn2 单独计算圈复杂度
const fn2 = (item) => {
return item > 10
}
// some 回调函数单独计算圈复杂度
const hasGreater = list.some(item => {
return fn2(item)
})
if (hasGreater) {
return 10
}
}
嵌套函数圈复杂度实现方式
为什么需要嵌套函数圈复杂度
在上面的圈复杂度实现中,我们以函数为维度进行了相关检测,从而导致一个问题,大家可以非常快速的进行一些函数拆分,从而达到降低圈复杂度的目的,但这样的操作与我们的预期并不相符,我们是想要通过圈复杂度检测的方式让大家提升部分代码的可读性、可维护性。
所以我们在 eslint 圈复杂度检测的基础上,开发了新的检测规则 —— 嵌套函数圈复杂度检测。
在这套基础上,我们将嵌套整体圈复杂度和行数关联,进行错误评判,因为每一个函数的嵌套层级是不定的,所以它所对应的代码量也是不定的,我们不能硬性的规定每一个函数所对应的圈复杂度阈值。
嵌套函数复杂度的检测逻辑
嵌套函数复杂度,顾名思义,我们会检测函数内所 “包含” 和 “调用” 的子函数,子函数内会继续检测他的嵌套函数,将所涉及到的函数圈复杂度相加,从而得到一个函数本身和它的所有嵌套函数的圈复杂度之和,这个和就是这个函数的嵌套函数复杂度。
实现技术选择
因为我们 “单函数圈复杂度” 选取的检测能力为 eslint 的 complexity 规则,所以我兜底检测的情况应该是在此基础上进行嵌套函数之间的 “单函数圈复杂度” 累加。所以应该如何实现我们的目的?
eslint 检测原理
原则上来说,只要我们有特定规则并且能够针对所有代码进行规则分析,那么我们就可以实现这个需求。
要做的其实是代码解析,前端对于代码解析和分析处理离不开 AST,我们也需要去借助 AST 的解析能力。之后是实用性,必须能够让大家本地进行检测。
所以最后我们选择开发一个 eslint 自定义规则,这样做的好处主要是,与单函数圈复杂度技术依赖相同,本地检测成本、接入成本低。
实现细节
嵌套函数复杂度计算流程:
计算方式
eslint 自定义规则实现:
因为我们的背景是以函数为维度进行,我们的出口可以是函数节点,但是与 eslint 圈复杂度规则不同的是,我们需要去深度遍历函数节点内的所有子函数和函数调用,并且对子函数本身我们需要去执行相同的逻辑,实际上整套逻辑是一个递归计算。
所以我们不能直接通过 create 输入我们的判定节点,而是需要在函数节点内进行 AST 深度遍历自己进行节点断定,其中整个规则的入口,应该是对应的函数,所以规则中 create 方法返回值是这样的:
return {
"FunctionDeclaration:exit": function onCodePathEnd(node) {
const functionName = astUtils.getFunctionNameWithKind(node);
computedComplexity(node, functionName);
},
"FunctionExpression:exit": function onCodePathEnd(node) {
const functionName = astUtils.getFunctionNameWithKind(node);
computedComplexity(node, functionName);
},
"ArrowFunctionExpression:exit": function onCodePathEnd(node) {
const functionName = astUtils.getFunctionNameWithKind(node);
computedComplexity(node, functionName);
}
};
判定节点管理
我们遍历整个函数的目的,是为了找到其中的判定节点,在 AST 中,对应的判定节点有这些:
// 所有需要增加圈复杂度的节点
const complexityTagType = [
"IfStatement", // if
"CatchClause", // 表示catch语句
"ConditionalExpression", // 表示条件运算符(三元)
"LogicalExpression", // 表示逻辑运算符(&&,||,!)
"ForStatement", // for
"ForInStatement",
"ForOfStatement",
"WhileStatement", // while
"DoWhileStatement",
"SwitchCase[test]" // switch
];
子函数查找:
因为我们是以主函数为入口检测,当我们寻找到子函数的注册、调用之后,子函数本身并不一定在父函数内,所以我们需要在程序 context 内寻找子函数,从而执行子函数的圈复杂度计算,进行累加:
if ((statement.type === "ExpressionStatement" && statement.expression.type === "CallExpression") || statement.type === "CallExpression") {
const calleeName = statement.callee?.name || statement.expression?.callee?.name || statement.expression?.callee?.property?.name;
if (calleeName) {
const functionNode = findFunctionDeclaration(context.getSourceCode().ast, calleeName);
if (functionNode && !processedFunctions.has