写程序时时刻记着,这个未来要维护你写的程序的人是一个有严重暴力倾向,而且知道你住在哪里的精神变态者。
大家是否也有过下面的想法?前端
大家的项目中是否也存在下面的问题?node
针对上面的问题,本文的主角 圈复杂度
重磅登场,本文将从圈复杂度原理出发,介绍圈复杂度的计算方法、如何下降代码的圈复杂度,如何获取圈复杂度,以及圈复杂度在公司项目的实践应用。git
圈复杂度 (Cyclomatic complexity) 是一种代码复杂度的衡量标准,也称为条件复杂度或循环复杂度,它能够用来衡量一个模块断定结构的复杂程度,数量上表现为独立现行路径条数,也可理解为覆盖全部的可能状况最少使用的测试用例数。简称 CC 。其符号为 VG 或是 M 。github
圈复杂度 在 1976 年由 Thomas J. McCabe, Sr. 提出。
圈复杂度大说明程序代码的判断逻辑复杂,可能质量低且难于测试和维护。程序的可能错误和高的圈复杂度有着很大关系。算法
代码复杂度低,代码不必定好,但代码复杂度高,代码必定很差。
圈复杂度 | 代码情况 | 可测性 | 维护成本 |
---|---|---|---|
1 - 10 | 清晰、结构化 | 高 | 低 |
10 - 20 | 复杂 | 中 | 中 |
20 - 30 | 很是复杂 | 低 | 高 |
>30 | 不可读 | 不可测 | 很是高 |
控制流程图,是一个过程或程序的抽象表现,是用在编译器中的一个抽象数据结构,由编译器在内部维护,表明了一个程序执行过程当中会遍历到的全部路径。它用图的形式表示一个过程内全部基本块执行的可能流向, 也能反映一个过程的实时执行过程。npm
下面是一些常见的控制流程:数组
有一个简单的计算方法,圈复杂度实际上就是等于断定节点的数量再加上1。向上面提到的:if else
、switch case
、 for
循环、三元运算符等等,都属于一个断定节点,例以下面的代码:微信
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; }
上面的代码中一共有1
个if
语句,一个for
循环,两个case
语句,一个三元运算符,因此代码复杂度为 4+1+1=6
。另外,须要注意的是 || 和 &&
语句也会被算做一个断定节点,例以下面代码的代码复杂为3
:网络
function testComplexity(*param*) { let result = 1; if (param > 0 && param < 10) { result--; } return result; }
M = E − N + 2P
前两个,边和节点都是数据结构图中最基本的概念:数据结构
P表明图中独立组件的数目,独立组件是什么意思呢?来看看下面两个图,左侧为连通图,右侧为非连通图:
一个连通图即为图中的一个独立组件,因此左侧图中独立组件的数目为1,右侧则有两个独立组件。
对于咱们的代码转化而来的控制流程图,正常状况下全部节点都应该是连通的,除非你在某些节点以前执行了 return
,显然这样的代码是错误的。因此每一个程序流程图的独立组件的数目都为1,因此上面的公式还能够简化为 M = E − N + 2
。
咱们能够经过一些代码重构手段来下降代码的圈复杂度。
重构需谨慎,示例代码仅仅表明一种思想,实际代码要远远比示例代码复杂的多。
经过抽象配置将复杂的逻辑判断进行简化。例以下面的代码,根据用户的选择项执行相应的操做,重构后下降了代码复杂度,而且若是以后有新的选项,直接加入配置便可,而不须要再去深刻代码逻辑中进行改动:
单一职责原则(SRP)
:每一个类都应该有一个单一的功能,一个类应该只有一个发生变化的缘由。
在 JavaScript
中,须要用到的类的场景并不太多,单一职责原则则是更多地运用在对象或者方法级别上面。
函数应该作一件事,作好这件事,只作这一件事。 — 代码整洁之道
关键是如何定义这 “一件事” ,如何将代码中的逻辑进行抽象,有效的提炼函数有利于下降代码复杂度和下降维护成本。
咱们常常会使用一个控制标记来标示当前程序运行到某一状态,不少场景下,使用 break
和 return
能够代替这些标记并下降代码复杂度。
setField
和 getField
函数就是典型的函数取代参数,若是么有 setField、getField
函数,咱们可能须要一个很复杂的 setValue、getValue
来完成属性赋值操做:
某些复杂的条件判断可能逆向思考后会变的更简单。
将复杂冗余的条件判断进行合并。
将复杂难懂的条件进行语义化提取。
eslint
提供了检测代码圈复杂度的rules
:
咱们将开启 rules
中的 complexity
规则,并将圈复杂度大于 0
的代码的 rule severity
设置为 warn
或 error
。
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. ...
咱们能够借助 eslint
的 CLIEngine
,在本地使用自定义的 eslint
规则扫描代码,并获取扫描结果输出。
初始化 CLIEngine
:
const eslint = require('eslint'); const { CLIEngine } = eslint; const cli = new CLIEngine({ parserOptions: { ecmaVersion: 2018, }, rules: { complexity: [ 'error', { max: 0 } ] } });
使用 executeOnFiles
对指定文件进行扫描,并获取结果,过滤出全部 complexity
的 message
信息。
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); } } }
经过 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
中的 line
、column
即函数的行、列位置reports
结果中能够获取当前扫描文件的绝对路径 filePath
,经过下面的操做获取真实文件名:filePath.replace(process.cwd(), '').trim()
圈复杂度 | 代码情况 | 可测性 | 维护成本 |
---|---|---|---|
1 - 10 | 清晰、结构化 | 高 | 低 |
10 - 20 | 复杂 | 中 | 中 |
20 - 30 | 很是复杂 | 低 | 高 |
>30 | 不可读 | 不可测 | 很是高 |
圈复杂度 | 代码情况 |
---|---|
1 - 10 | 无需重构 |
11 - 15 | 建议重构 |
>15 | 强烈建议重构 |
将代码复杂度检测封装成基础包,根据自定义配置输出检测数据,供其余应用调用。
上面的展现了使用 eslint
获取代码复杂度的思路,下面咱们要把它封装为一个通用的工具,考虑到工具可能在不一样场景下使用,例如:网页版的分析报告、cli版的命令行工具,咱们把通用的能力抽象出来以 npm包
的形式供其余应用使用。
在计算项目代码复杂度以前,咱们首先要具有一项基础能力,代码扫描,即咱们要知道咱们要对项目里的哪些文件作分析,首先 eslint
是具有这样的能力的,咱们也能够直接用 glob
来遍历文件。可是他们都有一个缺点,就是 ignore
规则是不一样的,这对于用户来说是有必定学习成本的,所以我这里把手动封装代码扫描,使用通用的 npm ignore
规则,这样代码扫描就能够直接使用 .gitignore
这样的配置文件。另外,代码扫描做为代码分析的基础能力,其余代码分析也是能够公用的。
基础能力
应用
本文涉及的 npm
包和 cli
命令源码都可在个人开源项目 awesome-cli中查看。
awesome-cli 是我新建的一个开源项目:有趣又实用的命令行工具,后面会持续维护,敬请关注,欢迎 star。
代码扫描(c-scan
)源码:https://github.com/ConardLi/a...
代码扫描是代码分析的底层能力,它主要帮助咱们拿到咱们想要的文件路径,应该知足咱们如下两个需求:
npm i c-scan --save const scan = require('c-scan'); scan({ extensions:'**/*.js', rootPath:'src', defalutIgnore:'true', ignoreRules:[], ignoreFileName:'.gitignore' });
符合规则的文件路径数组:
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
配置文件基于 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; }
代码复杂度检测(c-complexity
)源码:https://github.com/ConardLi/a...
代码检测基础包应该具有如下几个能力:
npm i c-complexity --save const cc = require('c-complexity'); cc({},10);
result:详细结果
scanParam
min
代码复杂度检测(conard cc
)源码:https://github.com/ConardLi/a...
能够触发提醒的最小复杂度。
10
conard cc --min=5
自定义自定义扫描规则
scan param
conard cc --defalutIgnore=false
部分截图来源于咱们内部的项目质量监控平台,圈复杂度做为一项重要的指标,对于衡量项目代码质量起着相当重要的做用。
定时任务爬取代码每日的代码复杂度、代码行数、函数个数,经过每日数据绘制代码复杂度和代码行数变化趋势折线图。
经过 [ 复杂度 / 代码行数 ] 或 [ 复杂度 / 函数个数 ] 的变化趋势,判断项目发展是否健康。
统计各复杂度分布的函数数量。
计算每一个函数的代码复杂度,从高到低依次列出高复杂度的文件分布,并给出重构建议。
实际开发中并不必定全部的代码都须要被分析,例如打包产物、静态资源文件等等,这些文件每每会误导咱们的分析结果,如今分析工具会默认忽略一些规则,例如:.gitignore文件、static目录等等,实际这些规则还须要根据实际项目的状况去不断完善,使分析结果变得更准确。
文章开头小丑图片来源于网络,若有侵权请联系我删除,其他图片均为本人原创图片。
但愿看完本篇文章能对你有以下帮助:
文中若有错误,欢迎在评论区指正,若是这篇文章帮助到了你,欢迎点赞和关注。
本文涉及的 npm
包和 cli
命令源码都可在个人开源项目 awesome-cli中查看。
想阅读更多优质文章、可关注个人github博客,你的star✨、点赞和关注是我持续创做的动力!
推荐关注个人微信公众号【code秘密花园】,天天推送高质量文章,咱们一块儿交流成长。