寫程序時時刻記着,這個未來要維護你寫的程序的人是一個有嚴重暴力傾向,而且知道你住在哪裏的精神變態者。
大家是否也有過下面的想法?前端
大家的項目中是否也存在下面的問題?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祕密花園】,天天推送高質量文章,咱們一塊兒交流成長。