前端代码质量-圈复杂度原理和实践

写程序时时刻记着,这个未来要维护你写的程序的人是一个有严重暴力倾向,而且知道你住在哪里的精神变态者。

1. 导读

大家是否也有过下面的想法?前端

  • 重构一个项目还不如新开发一个项目...
  • 这代码是谁写的,我真想...

大家的项目中是否也存在下面的问题?node

  • 单个项目也愈来愈庞大,团队成员代码风格不一致,没法对总体的代码质量作全面的掌控
  • 没有一个准确的标准去衡量代 码结构复杂的程度,没法量化一个项目的代码质量
  • 重构代码后没法当即量化重构后代码质量是否提高

针对上面的问题,本文的主角 圈复杂度 重磅登场,本文将从圈复杂度原理出发,介绍圈复杂度的计算方法、如何下降代码的圈复杂度,如何获取圈复杂度,以及圈复杂度在公司项目的实践应用。git

2. 圈复杂度

2.1 定义

圈复杂度 (Cyclomatic complexity) 是一种代码复杂度的衡量标准,也称为条件复杂度或循环复杂度,它能够用来衡量一个模块断定结构的复杂程度,数量上表现为独立现行路径条数,也可理解为覆盖全部的可能状况最少使用的测试用例数。简称 CC 。其符号为 VG 或是 M 。github

圈复杂度 在 1976 年由 Thomas J. McCabe, Sr. 提出。

圈复杂度大说明程序代码的判断逻辑复杂,可能质量低且难于测试和维护。程序的可能错误和高的圈复杂度有着很大关系。算法

2.2 衡量标准

代码复杂度低,代码不必定好,但代码复杂度高,代码必定很差。
圈复杂度 代码情况 可测性 维护成本
1 - 10 清晰、结构化
10 - 20 复杂
20 - 30 很是复杂
>30 不可读 不可测 很是高

3. 计算方法

3.1 控制流程图

控制流程图,是一个过程或程序的抽象表现,是用在编译器中的一个抽象数据结构,由编译器在内部维护,表明了一个程序执行过程当中会遍历到的全部路径。它用图的形式表示一个过程内全部基本块执行的可能流向, 也能反映一个过程的实时执行过程。npm

下面是一些常见的控制流程:数组

3.2 节点断定法

有一个简单的计算方法,圈复杂度实际上就是等于断定节点的数量再加上1。向上面提到的:if elseswitch casefor循环、三元运算符等等,都属于一个断定节点,例以下面的代码:微信

function testComplexity(*param*) {
    let result = 1;
    if (param > 0) {
        result--;
    }
    for (let i = 0; i < 10; i++) {
        result += Math.random();
    }
    switch (parseInt(result)) {
        case 1:
            result += 20;
            break;
        case 2:
            result += 30;
            break;
        default:
            result += 10;
            break;
    }
    return result > 20 ? result : result;
}

上面的代码中一共有1if语句,一个for循环,两个case语句,一个三元运算符,因此代码复杂度为 4+1+1=6。另外,须要注意的是 || 和 && 语句也会被算做一个断定节点,例以下面代码的代码复杂为3网络

function testComplexity(*param*) {
    let result = 1;
    if (param > 0 && param < 10) {
        result--;
    }
    return result;
}

3.3 点边计算法

M = E − N + 2P
  • E:控制流图中边的数量
  • N:控制流图中的节点数量
  • P:独立组件的数目

前两个,边和节点都是数据结构图中最基本的概念:数据结构

P表明图中独立组件的数目,独立组件是什么意思呢?来看看下面两个图,左侧为连通图,右侧为非连通图:

  • 连通图:对于图中任意两个顶点都是连通的

一个连通图即为图中的一个独立组件,因此左侧图中独立组件的数目为1,右侧则有两个独立组件。

对于咱们的代码转化而来的控制流程图,正常状况下全部节点都应该是连通的,除非你在某些节点以前执行了 return,显然这样的代码是错误的。因此每一个程序流程图的独立组件的数目都为1,因此上面的公式还能够简化为 M = E − N + 2

4. 下降代码的圈复杂度

咱们能够经过一些代码重构手段来下降代码的圈复杂度。

重构需谨慎,示例代码仅仅表明一种思想,实际代码要远远比示例代码复杂的多。

4.1 抽象配置

经过抽象配置将复杂的逻辑判断进行简化。例以下面的代码,根据用户的选择项执行相应的操做,重构后下降了代码复杂度,而且若是以后有新的选项,直接加入配置便可,而不须要再去深刻代码逻辑中进行改动:

4.2 单一职责 - 提炼函数

单一职责原则(SRP):每一个类都应该有一个单一的功能,一个类应该只有一个发生变化的缘由。

JavaScript 中,须要用到的类的场景并不太多,单一职责原则则是更多地运用在对象或者方法级别上面。

函数应该作一件事,作好这件事,只作这一件事。 — 代码整洁之道

关键是如何定义这 “一件事” ,如何将代码中的逻辑进行抽象,有效的提炼函数有利于下降代码复杂度和下降维护成本。

4.3 使用 break 和 return 代替控制标记

咱们常常会使用一个控制标记来标示当前程序运行到某一状态,不少场景下,使用 breakreturn 能够代替这些标记并下降代码复杂度。

4.4 用函数取代参数

setFieldgetField 函数就是典型的函数取代参数,若是么有 setField、getField 函数,咱们可能须要一个很复杂的 setValue、getValue 来完成属性赋值操做:

4.5 简化条件判断 - 逆向条件

某些复杂的条件判断可能逆向思考后会变的更简单。

4.6 简化条件判断 -合并条件

将复杂冗余的条件判断进行合并。

4.7 简化条件判断 - 提取条件

将复杂难懂的条件进行语义化提取。

5. 圈复杂度检测方法

5.1 eslint规则

eslint提供了检测代码圈复杂度的rules:

咱们将开启 rules 中的 complexity 规则,并将圈复杂度大于 0 的代码的 rule severity 设置为 warnerror

rules: {
        complexity: [
            'warn',
            { max: 0 }
        ]
    }

这样 eslint 就会自动检测出全部函数的代码复杂度,并输出一个相似下面的 message

Method 'testFunc' has a complexity of 12. Maximum allowed is 0
Async function has a complexity of 6. Maximum allowed is 0.
...

5.2 CLIEngine

咱们能够借助 eslintCLIEngine ,在本地使用自定义的 eslint 规则扫描代码,并获取扫描结果输出。

初始化 CLIEngine

const eslint = require('eslint');

const { CLIEngine } = eslint;

const cli = new CLIEngine({
    parserOptions: {
        ecmaVersion: 2018,
    },
    rules: {
        complexity: [
            'error',
            { max: 0 }
        ]
    }
});

使用 executeOnFiles 对指定文件进行扫描,并获取结果,过滤出全部 complexitymessage 信息。

const reports = cli.executeOnFiles(['.']).results;

for (let i = 0; i < reports.length; i++) {
    const { messages } = reports[i];
    for (let j = 0; j < messages.length; j++) {
        const { message, ruleId } = messages[j];
        if (ruleId === 'complexity') {
             console.log(message);
        }
    }
}

5.3 提取message

经过 eslint 的检测结果将有用的信息提取出来,先测试几个不一样类型的函数,看看 eslint 的检测结果:

function func1() {
    console.log(1);
}

const func2 = () => {
    console.log(2);
};

class TestClass {
    func3() {
        console.log(3);
    }
}

async function func4() {
    console.log(1);
}

执行结果:

Function 'func1' has a complexity of 1. Maximum allowed is 0.
Arrow function has a complexity of 1. Maximum allowed is 0.
Method 'func3' has a complexity of 1. Maximum allowed is 0.
Async function 'func4' has a complexity of 1. Maximum allowed is 0.

能够发现,除了前面的函数类型,以及后面的复杂度,其余都是相同的。

函数类型:

  • Function :普通函数
  • Arrow function : 箭头函数
  • Method : 类方法
  • Async function : 异步函数

截取方法类型:

const REG_FUNC_TYPE = /^(Method |Async function |Arrow function |Function )/g;

function getFunctionType(message) {
    let hasFuncType = REG_FUNC_TYPE.test(message);
    return hasFuncType && RegExp.$1;
}

将有用的部分提取出来:

const MESSAGE_PREFIX = 'Maximum allowed is 1.';
const MESSAGE_SUFFIX = 'has a complexity of ';

function getMain(message) {
    return message.replace(MESSAGE_PREFIX, '').replace(MESSAGE_SUFFIX, '');
}

提取方法名称:

function getFunctionName(message) {
    const main = getMain(message);
    let test = /'([a-zA-Z0-9_$]+)'/g.test(main);
    return test ? RegExp.$1 : '*';
}

截取代码复杂度:

function getComplexity(message) {
    const main = getMain(message);
    (/(\d+)\./g).test(main);
    return +RegExp.$1;
}

除了 message ,还有其余的有用信息:

  • 函数位置:获取 messages 中的 linecolumn 即函数的行、列位置
  • 当前文件名称:reports 结果中能够获取当前扫描文件的绝对路径 filePath ,经过下面的操做获取真实文件名:
filePath.replace(process.cwd(), '').trim()
  • 复杂度等级,根据函数的复杂度等级给出重构建议:
圈复杂度 代码情况 可测性 维护成本
1 - 10 清晰、结构化
10 - 20 复杂
20 - 30 很是复杂
>30 不可读 不可测 很是高
圈复杂度 代码情况
1 - 10 无需重构
11 - 15 建议重构
>15 强烈建议重构

6.架构设计

将代码复杂度检测封装成基础包,根据自定义配置输出检测数据,供其余应用调用。

上面的展现了使用 eslint 获取代码复杂度的思路,下面咱们要把它封装为一个通用的工具,考虑到工具可能在不一样场景下使用,例如:网页版的分析报告、cli版的命令行工具,咱们把通用的能力抽象出来以 npm包 的形式供其余应用使用。

在计算项目代码复杂度以前,咱们首先要具有一项基础能力,代码扫描,即咱们要知道咱们要对项目里的哪些文件作分析,首先 eslint 是具有这样的能力的,咱们也能够直接用 glob 来遍历文件。可是他们都有一个缺点,就是 ignore 规则是不一样的,这对于用户来说是有必定学习成本的,所以我这里把手动封装代码扫描,使用通用的 npm ignore 规则,这样代码扫描就能够直接使用 .gitignore这样的配置文件。另外,代码扫描做为代码分析的基础能力,其余代码分析也是能够公用的。

  • 基础能力

    • 代码扫描能力
    • 复杂度检测能力
    • ...
  • 应用

    • 命令行工具
    • 代码分析报告
    • ...

7. 基础能力 - 代码扫描

本文涉及的 npm 包和 cli命令源码都可在个人开源项目 awesome-cli中查看。

awesome-cli 是我新建的一个开源项目:有趣又实用的命令行工具,后面会持续维护,敬请关注,欢迎 star。

代码扫描(c-scan)源码:https://github.com/ConardLi/a...

代码扫描是代码分析的底层能力,它主要帮助咱们拿到咱们想要的文件路径,应该知足咱们如下两个需求:

  • 我要获得什么类型的文件
  • 我不想要哪些文件

7.1 使用

npm i c-scan --save

const scan = require('c-scan');
scan({
    extensions:'**/*.js',
    rootPath:'src',
    defalutIgnore:'true',
    ignoreRules:[],
    ignoreFileName:'.gitignore'
});

7.2 返回值

符合规则的文件路径数组:

7.3 参数

  • extensions

    • 扫描文件扩展名
    • 默认值:**/*.js
  • rootPath

    • 扫描文件路径
    • 默认值:.
  • defalutIgnore

    • 是否开启默认忽略(glob规则)
    • glob ignore规则为内部使用,为了统一ignore规则,自定义规则使用gitignore规则
    • 默认值:true
    • 默认开启的 glob ignore 规则:
const DEFAULT_IGNORE_PATTERNS = [
    'node_modules/**',
    'build/**',
    'dist/**',
    'output/**',
    'common_build/**'
];
  • ignoreRules

    • 自定义忽略规则(gitignore规则)
    • 默认值:[]
  • ignoreFileName

    • 自定义忽略规则配置文件路径(gitignore规则)
    • 默认值:.gitignore
    • 指定为null则不启用ignore配置文件

7.4 核心实现

基于 glob ,自定义 ignore 规则进行二次封装。

/**
 * 获取glob扫描的文件列表
 * @param {*} rootPath 跟路径
 * @param {*} extensions 扩展
 * @param {*} defalutIgnore 是否开启默认忽略
 */
function getGlobScan(rootPath, extensions, defalutIgnore) {
    return new Promise(resolve => {
        glob(`${rootPath}${extensions}`,
            { dot: true, ignore: defalutIgnore ? DEFAULT_IGNORE_PATTERNS : [] },
            (err, files) => {
                if (err) {
                    console.log(err);
                    process.exit(1);
                }
                resolve(files);
            });
    });
}

/**
 * 加载ignore配置文件,并处理成数组
 * @param {*} ignoreFileName 
 */
async function loadIgnorePatterns(ignoreFileName) {
    const ignorePath = path.resolve(process.cwd(), ignoreFileName);
    try {
        const ignores = fs.readFileSync(ignorePath, 'utf8');
        return ignores.split(/[\n\r]|\n\r/).filter(pattern => Boolean(pattern));
    } catch (e) {
        return [];
    }
}

/**
 * 根据ignore配置过滤文件列表
 * @param {*} files 
 * @param {*} ignorePatterns 
 * @param {*} cwd 
 */
function filterFilesByIgnore(files, ignorePatterns, ignoreRules, cwd = process.cwd()) {
    const ig = ignore().add([...ignorePatterns, ...ignoreRules]);
    const filtered = files
        .map(raw => (path.isAbsolute(raw) ? raw : path.resolve(cwd, raw)))
        .map(raw => path.relative(cwd, raw))
        .filter(filePath => !ig.ignores(filePath))
        .map(raw => path.resolve(cwd, raw));
    return filtered;
}

8. 基础能力 - 代码复杂度检测

代码复杂度检测(c-complexity)源码:https://github.com/ConardLi/a...

代码检测基础包应该具有如下几个能力:

  • 自定义扫描文件夹和类型
  • 支持忽略文件
  • 定义最小提醒代码复杂度

8.1 使用

npm i c-complexity --save

const cc = require('c-complexity');
cc({},10);

8.2 返回值

  • fileCount:文件数量
  • funcCount:函数数量
  • result:详细结果

    • funcType:函数类型
    • funcName;函数名称
    • position:详细位置(行列号)
    • fileName:文件相对路径
    • complexity:代码复杂度
    • advice:重构建议

8.3 参数

  • scanParam

    • 继承自上面代码扫描的参数
  • min

    • 最小提醒代码复杂度,默认为1

9. 应用 - 代码复杂度检测工具

代码复杂度检测(conard cc)源码:https://github.com/ConardLi/a...

9.1 指定最小提醒复杂度

能够触发提醒的最小复杂度。

  • 默认为 10
  • 经过命令 conard cc --min=5 自定义

9.2 指定扫描参数

自定义扫描规则

  • 扫描参数继承自上面的 scan param
  • 例如: conard cc --defalutIgnore=false

10. 应用 - 代码复杂度报告

部分截图来源于咱们内部的项目质量监控平台,圈复杂度做为一项重要的指标,对于衡量项目代码质量起着相当重要的做用。

代码复杂复杂度变化趋势

定时任务爬取代码每日的代码复杂度、代码行数、函数个数,经过每日数据绘制代码复杂度和代码行数变化趋势折线图。

经过 [ 复杂度 / 代码行数 ] 或 [ 复杂度 / 函数个数 ] 的变化趋势,判断项目发展是否健康。

  • 比值若一直在上涨,说明你的代码在变得愈来愈难以理解。这不只使咱们面临意外的功能交互和缺陷的风险,因为咱们在具备或多或少相关功能的模块中所面临的过多认知负担,也很难重用代码并进行修改和测试。(下图1)
  • 若比值在某个阶段发生突变,说明这段期间迭代质量不好。(下图2)

  • 复杂度曲线图能够很快的帮你更早的发现上面这两个问题,发现它们后,你可能须要重构代码。复杂性趋势对于跟踪你的代码重构也颇有用。复杂性趋势的降低趋势是一个好兆头。这要么意味着您的代码变得更简单(例如,把 if-else 被重构为多态解决方案),要么代码更少(将不相关的部分提取到了其余模块中)。(下图3)
  • 代码重构后,你还须要继续探索复杂度变化趋势。常常发生的事情是,咱们花费大量的时间和精力来重构,没法解决根本缘由,很快复杂度又滑回了原处。(下图4)你可能以为这是个例,可是有研究标明,在分析了数百个代码库后,发现出现这种状况的频率很高。所以,时刻观察代码复杂度变化趋势是有必要的。

代码复杂度文件分布

统计各复杂度分布的函数数量。

代码复杂度文件详情

计算每一个函数的代码复杂度,从高到低依次列出高复杂度的文件分布,并给出重构建议。

实际开发中并不必定全部的代码都须要被分析,例如打包产物、静态资源文件等等,这些文件每每会误导咱们的分析结果,如今分析工具会默认忽略一些规则,例如:.gitignore文件、static目录等等,实际这些规则还须要根据实际项目的状况去不断完善,使分析结果变得更准确。

参考

文章开头小丑图片来源于网络,若有侵权请联系我删除,其他图片均为本人原创图片。

小结

但愿看完本篇文章能对你有以下帮助:

  • 理解圈复杂度的意义和计算方法
  • 在项目中能实际应用圈复杂度提高项目质量

文中若有错误,欢迎在评论区指正,若是这篇文章帮助到了你,欢迎点赞和关注。

本文涉及的 npm 包和 cli命令源码都可在个人开源项目 awesome-cli中查看。

想阅读更多优质文章、可关注个人github博客,你的star✨、点赞和关注是我持续创做的动力!

推荐关注个人微信公众号【code秘密花园】,天天推送高质量文章,咱们一块儿交流成长。

相关文章
相关标签/搜索