1. 導讀
你們是否也有過下面的想法?
- 重構一個項目還不如新開發一個項目...
- 這代碼是誰寫的,我真想...
你們的項目中是否也存在下面的問題?
- 單個項目也越來越龐大,團隊成員代碼風格不一致,無法對整體的代碼質量做全面的掌控
- 沒有一個准確的標准去衡量代碼結構復雜的程度,無法量化一個項目的代碼質量
- 重構代碼后無法立即量化重構后代碼質量是否提升
針對上面的問題,本文的主角 圈復雜度 重磅登場,本文將從圈復雜度原理出發,介紹圈復雜度的計算方法、如何降低代碼的圈復雜度,如何獲取圈復雜度,以及圈復雜度在公司項目的實踐應用。
2. 圈復雜度
2.1 定義
圈復雜度 (Cyclomatic complexity) 是一種代碼復雜度的衡量標准,也稱為條件復雜度或循環復雜度,它可以用來衡量一個模塊判定結構的復雜程度,數量上表現為獨立現行路徑條數,也可理解為覆蓋所有的可能情況最少使用的測試用例數。簡稱 CC 。其符號為 VG 或是 M 。
圈復雜度 在 1976 年由 Thomas J. McCabe, Sr. 提出。
圈復雜度大說明程序代碼的判斷邏輯復雜,可能質量低且難於測試和維護。程序的可能錯誤和高的圈復雜度有着很大關系。
2.2 衡量標准
代碼復雜度低,代碼不一定好,但代碼復雜度高,代碼一定不好。

3.1 控制流程圖
控制流程圖,是一個過程或程序的抽象表現,是用在編譯器中的一個抽象數據結構,由編譯器在內部維護,代表了一個程序執行過程中會遍歷到的所有路徑。它用圖的形式表示一個過程內所有基本塊執行的可能流向, 也能反映一個過程的實時執行過程。
下面是一些常見的控制流程:

3.2 節點判定法
有一個簡單的計算方法,圈復雜度實際上就是等於判定節點的數量再加上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; }
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 代替控制標記
我們經常會使用一個控制標記來標示當前程序運行到某一狀態,很多場景下,使用 break 和 return 可以代替這些標記並降低代碼復雜度。

4.4 用函數取代參數
setField 和 getField 函數就是典型的函數取代參數,如果么有 setField、getField 函數,我們可能需要一個很復雜的 setValue、getValue 來完成屬性賦值操作:

4.5 簡化條件判斷 - 逆向條件
某些復雜的條件判斷可能逆向思考后會變的更簡單。

4.6 簡化條件判斷 -合並條件
將復雜冗余的條件判斷進行合並。

4.7 簡化條件判斷 - 提取條件
將復雜難懂的條件進行語義化提取。

5. 圈復雜度檢測方法
5.1 eslint規則
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.
...
5.2 CLIEngine
我們可以借助 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); } } }
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 中的 line 、column 即函數的行、列位置
- 當前文件名稱:reports 結果中可以獲取當前掃描文件的絕對路徑 filePath ,通過下面的操作獲取真實文件名:
filePath.replace(process.cwd(), '').trim()
復制代碼
- 復雜度等級,根據函數的復雜度等級給出重構建議:


6.架構設計
將代碼復雜度檢測封裝成基礎包,根據自定義配置輸出檢測數據,供其他應用調用。
上面的展示了使用 eslint 獲取代碼復雜度的思路,下面我們要把它封裝為一個通用的工具,考慮到工具可能在不同場景下使用,例如:網頁版的分析報告、cli版的命令行工具,我們把通用的能力抽象出來以 npm包 的形式供其他應用使用。
在計算項目代碼復雜度之前,我們首先要具備一項基礎能力,代碼掃描,即我們要知道我們要對項目里的哪些文件做分析,首先 eslint 是具備這樣的能力的,我們也可以直接用 glob 來遍歷文件。但是他們都有一個缺點,就是 ignore 規則是不同的,這對於用戶來講是有一定學習成本的,因此我這里把手動封裝代碼掃描,使用通用的 npm ignore 規則,這樣代碼掃描就可以直接使用 .gitignore這樣的配置文件。另外,代碼掃描作為代碼分析的基礎能力,其他代碼分析也是可以公用的。
- 基礎能力
- 代碼掃描能力
- 復雜度檢測能力
- ...
- 應用
- 命令行工具
- 代碼分析報告
- ...

7. 基礎能力 - 代碼掃描
本文涉及的 npm 包和 cli命令源碼均可在我的開源項目 awesome-cli中查看。
awesome-cli 是我新建的一個開源項目:有趣又實用的命令行工具,后面會持續維護,敬請關注,歡迎 star。
代碼掃描(c-scan)源碼:github.com/ConardLi/aw…
代碼掃描是代碼分析的底層能力,它主要幫助我們拿到我們想要的文件路徑,應該滿足我們以下兩個需求:
- 我要得到什么類型的文件
- 我不想要哪些文件
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)源碼:github.com/ConardLi/aw…
代碼檢測基礎包應該具備以下幾個能力:
- 自定義掃描文件夾和類型
- 支持忽略文件
- 定義最小提醒代碼復雜度
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)源碼:github.com/ConardLi/aw…

9.1 指定最小提醒復雜度
可以觸發提醒的最小復雜度。
- 默認為 10
- 通過命令 conard cc --min=5 自定義
9.2 指定掃描參數
自定義掃描規則
- 掃描參數繼承自上面的 scan param
- 例如: conard cc --defalutIgnore=false
10. 應用 - 代碼復雜度報告
部分截圖來源於我們內部的項目質量監控平台,圈復雜度作為一項重要的指標,對於衡量項目代碼質量起着至關重要的作用。
代碼復雜復雜度變化趨勢
定時任務爬取代碼每日的代碼復雜度、代碼行數、函數個數,通過每日數據繪制代碼復雜度和代碼行數變化趨勢折線圖。


通過 [ 復雜度 / 代碼行數 ] 或 [ 復雜度 / 函數個數 ] 的變化趨勢,判斷項目發展是否健康。
- 比值若一直在上漲,說明你的代碼在變得越來越難以理解。這不僅使我們面臨意外的功能交互和缺陷的風險,由於我們在具有或多或少相關功能的模塊中所面臨的過多認知負擔,也很難重用代碼並進行修改和測試。(下圖1)
- 若比值在某個階段發生突變,說明這段期間迭代質量很差。(下圖2)

- 復雜度曲線圖可以很快的幫你更早的發現上面這兩個問題,發現它們后,你可能需要重構代碼。復雜性趨勢對於跟蹤你的代碼重構也很有用。復雜性趨勢的下降趨勢是一個好兆頭。這要么意味着您的代碼變得更簡單(例如,把 if-else 被重構為多態解決方案),要么代碼更少(將不相關的部分提取到了其他模塊中)。(下圖3)
- 代碼重構后,你還需要繼續探索復雜度變化趨勢。經常發生的事情是,我們花費大量的時間和精力來重構,無法解決根本原因,很快復雜度又滑回了原處。(下圖4)你可能覺得這是個例,但是有研究標明,在分析了數百個代碼庫后,發現出現這種情況的頻率很高。因此,時刻觀察代碼復雜度變化趨勢是有必要的。

代碼復雜度文件分布
統計各復雜度分布的函數數量。

代碼復雜度文件詳情
計算每個函數的代碼復雜度,從高到低依次列出高復雜度的文件分布,並給出重構建議。

實際開發中並不一定所有的代碼都需要被分析,例如打包產物、靜態資源文件等等,這些文件往往會誤導我們的分析結果,現在分析工具會默認忽略一些規則,例如:.gitignore文件、static目錄等等,實際這些規則還需要根據實際項目的情況去不斷完善,使分析結果變得更准確。
小結
希望看完本篇文章能對你有如下幫助:
- 理解圈復雜度的意義和計算方法
- 在項目中能實際應用圈復雜度提升項目質量
文中如有錯誤,歡迎在評論區指正,如果這篇文章幫助到了你,歡迎點贊和關注。
作者:ConardLi
鏈接:https://juejin.im/post/5da34216e51d4578502c24c5
來源:掘金