cover_image

圈复杂度在转转前端质量体系中的应用

李长毅 大转转FE
2025年01月23日 02:00
图片

背景

前端质量体系建设

目前在转转内,我们已经基本完成了前端质量体系的系统性建设,在整个质量体系内,我们按照类型将所有指标分为监控、工程规范、技术先进性三个方向,在这三个方向目前总共上线了11个指标,按照指标的重要程度不同,我们又将11个指标划分为了P1、P2、P3三个等级。

在工程规范分类中,代码规范、代码行数和圈复杂度都是P2类型的重要指标,今天的文章就为大家介绍一下“圈复杂度”相关的概念和检测实现方式。

圈复杂度相关介绍

概念

圈复杂度(Cyclomatic complexity,简写CC)也称为条件复杂度,是一种代码复杂度的衡量标准。由托马斯·J·麦凯布(Thomas J. McCabe, Sr.)于1976年提出,用来表示程序的复杂度。它可以用来衡量一个模块判定结构的复杂程度,数量上表现为线性独立路径条数,也可理解为覆盖所有的可能情况最少使用的测试用例数。圈复杂度大说明程序代码的判断逻辑复杂,可能质量低且难于测试和维护。程序的可能错误和高的圈复杂度有着很大关系。

业内衡量标准

代码复杂度低,代码不一定好,但代码复杂度高,代码一定不好。

一段程序的循环复杂度是其线性独立路径的数量。若程序中没有像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

  • 单纯讨论圈复杂度,它最小只能做到文件维度的检测。
  • 分析报告并不能直接指出比文件纬度更细的数据,不利于修改
  • 不能很好的利用 sonarQube 后台系统,推动所有人转到 Sonar 平台有一定的阻力

TyphonJS-ESComplex

TyphonJS-ESComplex 是一个 JavaScript 库,用于计算和分析 JavaScript 代码复杂性的工具

TyphonJS-ESComplex 主要专注于 JavaScript 文件整体的复杂度分析,包括圈复杂度等指标。它提供了关于整个文件结构复杂度的详细报告,帮助您评估整个文件的复杂度水平,对于整个文件的复杂度评估能够帮助开发人员识别整体代码结构中的问题,并进行相应的优化和改进。

为什么不选择TyphonJS-ESComplex

  • 检测维度为文件维度;检测指标较多。无法准确的定义出降低整体可维护度的修改方式,推进修改较为困难
  • 检测速度较慢,多数项目会超过 2min
  • 不支持 vue,并且已经停止维护,对于后续一些新语法的支持存在风险

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(functionNode)) {
      processedFunctions.add(functionNode);
      const result = calculateComplexity(functionNode);
      complexity += result.complexity;
      totalLines += result.totalLines;
    }
  }
}

其中,findFunctionDeclaration 方法会去递归查找整个文件的AST树,找到当前子函数对应的Node节点,从而计算它自身的圈复杂度:

// 在 AST 中查找函数声明节点
function findFunctionDeclaration(ast, functionName{
  let functionNode = null;

  function traverse(node{
    if (!isNode(node)) {
      return;
    }
    const keys = getVisitorKeys(node);
    if (keys.length >= 1) {
      for (let i = 0; i < keys.length; ++i) {
        const child = node[keys[i]];
        if (Array.isArray(child)) {
          // 递归寻找节点
        } else {
          // 判定函数节点
          const _statement = child;
          if (_statement) {
            if ((_statement.type === "FunctionDeclaration" || _statement.type === "FunctionExpression" || _statement.type === "ArrowFunctionExpression") && (_statement.name || _statement.id?.name) === functionName) {
              functionNode = _statement;
            } else if (_statement.type === "VariableDeclaration") {
              _statement.declarations.forEach(function(declaration{
                 ...
              });
            } else if (_statement.type === 'MethodDefinition' && _statement.key && _statement.key.name === functionName) {
                functionNode = _statement.value
            }
          }
          traverse(child, node);
        }
      }
    }
  }

  traverse(ast);
  return functionNode;
}

而在主函数或者子函数的复杂度计算中,我们需要遍历它的每一个AST节点,然后判断它是判定节点还是函数节点。

AST节点深度遍历:

为什么需要深度遍历?

因为我们一行简单的代码转换成AST对应的不只是一层,AST 其实就是树,但是每一个AST所对应的节点类型和子节点都不同,所以我们需要准确的找到每一个节点它的子节点和属性,这里使用最简单的方法,我们对每一个类型的节点所需要遍历的子节点名称进行管理,在遍历过程中,根据节点不同,对于子节点的寻找规则也不同,例如:

  {
    "AssignmentExpression": [
        "left",
        "right"
    ],
    "AssignmentPattern": [
        "left",
        "right"
    ],
    "ArrayExpression": [
        "elements"
    ],
    "ArrayPattern": [
        "elements"
    ],
    "ArrowFunctionExpression": [
        "params",
        "body"
    ],
    "AwaitExpression": [
        "argument"
    ],
    "BlockStatement": [
        "body"
    ],
    "BinaryExpression": [
        "left",
        "right"
    ],
    "BreakStatement": [
        "label"
    ]
    // ……
    // ……
}

使用方式

我们将上面的代码封装为了Eslint的自定义规则,使用时将我们的包进行安装后,就可以进行相应的嵌套函数圈复杂度的检测。

总结

经过上面两个维度圈复杂度的检测,我们将这个规则集成在了我们的质量检测系统,这是一套有转转独特规则的检测指标,因为其面向群体也是转转内的所有前端开发同学,并且会强制进行相应的代码评判。

而大家在对自己的代码进行圈复杂度优化的时候,可以直接使用Eslint内集成的单函数圈复杂度规则,只需要把控自身函数拆分的频率,多从代码写法角度进行优化,就可以达到很好的优化效果。

参考文献

https://baike.baidu.com/item/%E5%9C%88%E5%A4%8D%E6%9D%82%E5%BA%A6/828737

https://juejin.cn/post/6844903965792927751?searchId=2024022216432756DD0DDB3C37A2276D90


想了解更多转转公司的业务实践,点击关注下方的公众号吧!



前端 · 目录
上一篇亲测好用!揭秘这款前端开发提效神器下一篇转转前端覆盖率优化方案
继续滑动看下一个
大转转FE
向上滑动看下一个