摘要:自定義規則校驗代碼業務邏輯是社區中主流的工具,提供的大量規則有效的保障了許多項目的代碼質量。本文將介紹如何通過自定義檢查規則,校驗項目中特有的一些業務邏輯,如特殊作用域特殊使用規范性等。
自定義 eslint 規則校驗代碼業務邏輯
eslint 是 JavaScript 社區中主流的 lint 工具,提供的大量規則有效的保障了許多項目的代碼質量。本文將介紹如何通過自定義 eslint 檢查規則,校驗項目中特有的一些業務邏輯,如 i18n、特殊作用域、特殊 API 使用規范性等。代碼靜態分析與 eslint
代碼靜態分意指是不需要實際執行代碼就能獲取到程序中的部分信息并加以使用,lint 就是其中一種常見的實踐,通常為檢查代碼中錯誤的寫法或是不符合標準的代碼風格。許多編程語言都自帶 lint 工具,甚至直接將其植入到編譯器中。
但這一重要的功能對于 JavaScript 來說卻是一大痛點,作為動態且弱類型的語言 JavaScript 沒有編譯階段也就無從進行靜態分析,這導致程序錯誤只能在運行時被發現,部分錯誤非常低級例如variable is undefined。而當程序變得更為復雜時,這類錯誤甚至難以在開發、測試階段暴露,只會在用戶實際使用的過程中遇到,造成嚴重的后果。
為了彌補語言天生的弱點,社區開發出了一些 lint 工具,在所謂預編譯階段完成代碼的靜態分析檢查,而 eslint 就是其中的佼佼者。現在社區已經普遍接受使用 eslint 作為代碼規范工具,也延伸出了許多常用的規則與規則集。但實際上 eslint 拓展性極佳,我們還可以基于 eslint 提功的靜態分析能力對代碼進行業務邏輯的檢查,本文將講解一些筆者所在項目中的靜態分析實踐,以說明這一方案的適用場景和優缺點。
eslint 基本原理首先快速說明 eslint 工作的基本流程,幫助理解它將給我們提供哪些方面的能力以及如何編寫我們的自定義規則。
配置規則與插件eslint 主要依靠配置決定執行哪些規則的校驗,例如我們可以通過配置no-extra-semi決定是否需要寫分號,這類規則中不包含具體的業務邏輯,而是對所有項目通用,因此會被集成在 eslint 的內置規則中。
而還有一些規則也不包含業務邏輯,但只在部分項目場景中使用,如 React 相關的大量規則,那么顯然不應該集成在內置規則中,但也應該自成一個集合。這種情況下 eslint 提供了另一種規則單位——插件,可以作為多個同類規則的集合被引入到配置中。
如果我們準備自定義一些規則用于校驗項目中的業務邏輯,那么也應該創建一套自用的插件,并將自用的規則都存放其中。推薦使用 eslint 的 yeoman generator 腳手架新建插件或規則,該腳手架能夠生成插件項目的目錄結構、規則文件、文檔以及單元測試等模版,下文中我們將通過示例理解這些文件的的作用。
JavaScript 解析如上文所說,要實現靜態分析則需要自建一個預編譯階段對代碼進行解析,eslint 也不例外。
首先我們看看大部分編譯器工作時的三個階段:
解析,將未經處理的代碼解析成更為抽象的表達式,通常為抽象語法樹,即 AST。
轉換,通過修改解析后的代碼表達式,將其轉換為符合預期的新格式。
代碼生成,將轉換后的表達式生成為新的目標代碼。
如果想快速的加深對編譯器工作原理的理解,推薦閱讀 the-super-tiny-compiler。
對于 eslint 而言,主要是將 JavaScript 代碼解析為 AST 之后,再在遍歷 AST 的過程中對代碼進行各個規則的校驗。因此 eslint 也有一個解析器用于將原始代碼解析為特定的 AST,目前所使用的解析器是 eslint 基于 Acorn 開發的一個名為 Espree 的項目。而對于我們編寫自定義規則來說更關心的是解析器生成的 AST 節點的結構,在閱讀 eslint 文檔之后會了解到包括 Espree 在內的許多編譯器項目都需要一套 JavaScript 的 AST 規范,而為了保證規范的一致性以及實效性,社區共同維護了一套規范:estree。
在接下來講解規則編寫與執行的過程中,我們將直接引用 estree 的各種 AST 結構。
規則的執行eslint 中一般一個規則存放在一個文件中,以 module 的形式導出并掛載,其結構如下:
module.exports = { meta: { docs: { description: "disallow unnecessary semicolons", category: "Possible Errors", recommended: true, url: "https://eslint.org/docs/rules/no-extra-semi", }, fixable: "code", schema: [], // no options }, create: function(context) { return { // callback functions }; }, };
其中meta部分主要包括規則的描述、類別、文檔地址、修復方式以及配置下 schema 等信息,對于項目中自用的規則來說可以只填寫基本的描述和類別,其余選項在有需要時再根據文檔補充,并不會影響規則的檢驗邏輯。
而create則需要定義一個函數用于返回一個包含了遍歷規則的對象,并且該函數會接收context對象作為參數,context對象中除了包含report等報告錯誤的方法之外,還提供了許多幫助方法,可以簡化規則的編寫。下文中我們會通過幾個示例理解create函數的使用方式,但首先可以通過一段代碼建立初步的印象:
module.exports = { create: function(context) { // declare the state of the rule return { ReturnStatement: function(node) {}, "FunctionExpression:exit": function(node) {}, "ArrowFunctionExpression:exit": function(node) {}, }; }, };
在這段代碼中我們可以看到create返回的所謂“包含了遍歷規則的對象”的基本結構。對象的 value 均為一個接收當前 AST 節點的函數,而 key 則是 eslint 的節點 selector。selector 分為兩部分,第一部分為必須聲明的 AST 節點類型,如ReturnStatement和FunctionExpression。第二部分則是可選的:exit標示,因為在遍歷 AST 的過程中會以“從上至下”再“從下至上”的順序經過節點兩次,selector 默認會在下行的過程中執行對應的訪問函數,如果需要再上行的過程中執行,則需要添加:exit。
那么 eslint 解析出的 AST 有哪些節點類型,每種節點的數據結構又是什么,則需要通過查看上文提到的 estree 定義文檔進行了解。
適用場景與示例接下來我們會看到 eslint 自定義規則校驗的一些具體示例,但首先我們先要明確它的適用場景以及與一些常見代碼 QA 手段的異同。
適用場景我們可以通過以下方法判斷一個工具的質量:
工具質量 = 工具節省的時間 / 開發工具消耗的時間
對于靜態分析來說,要想提高“工具節省的時間”,應該要讓檢查的規則盡量覆蓋全局性的且經常發生的問題,如使用最為廣泛的檢查:是否使用了未定義的變量。同時還需要考慮當問題發生后 debug 所消耗的時間,例如有的項目有 i18n 需求,而在代碼的個別地方又直接使用了中文的字符串,雖然問題很小,但是人工測試覆蓋卻很麻煩,如果能夠通過工具進行覆蓋,那么原來用于 debug 的時間也應該歸入“工具節省的時間”當中。
另一方面則是對比“開發工具消耗的時間”,首先要強調通過靜態分析去對邏輯進行判斷,不論是學習成本還是實際編寫成本都較高,如果一類問題可以通過編寫簡單的單元測試進行覆蓋,那么應該優先考慮使用單元測試。但有的時候代碼邏輯對外部依賴較多,單元測試的開銷很大,例如我們有一段 e2e 測試的代碼,需要在目標瀏覽器環境中執行一段代碼,但是常規的 eslint 并不能判斷某個函數中的代碼實際執行在另一個作用域下,部分檢查就會失效,例如瀏覽器運行時引用的變量實際定義在本地運行時中,eslint 無法察覺。而如果通過單元測試覆蓋,則需要實際運行對應的 e2e 代碼,或者 mock 其執行環境的各種依賴,都是非常重的工作,取舍之下通過靜態分析覆蓋會事半功倍。
最后還需要考慮到使用體驗,許多編輯器都有 eslint 的集成插件,可以在編程的過程中實時檢測各個規則,在實時性方面遠強于單元測試等 QA 手段的使用體驗。
示例 1:i18n許多項目都有國際化的需求,因此項目中的文案需要避免直接使用中文,常見的方案包括用變量代替字符串或者使用全局的翻譯函數處理字符串,例如:
// 錯誤:直接只用中文字符串 console.log("中文"); // 使用變量 const currentLocale = "cn"; const T = { str_1: { cn: "中文", }, }; console.log(T.str_1[currentLocale]); // 使用翻譯函數處理 console.log(t("中文"));
如果出現了直接使用中文字符串的錯誤,其實在代碼運行過程中也不會有任何錯誤提示,只能靠 code review 和人工觀察測試來發現。我們嘗試自定義一條 eslint 規則解決它,此處假設項目中使用的是將所有中文內容存放在一個變量中,其余地方直接引用變量的方法。
const SYMBOL_REGEX = /[u3002uff1buff0cuff1au201cu201duff08uff09u3001uff1fu300au300b]/; const WORD_REGEX = /[u3400-u9FBF]/; function hasChinese(value) { return WORD_REGEX.test(value) || SYMBOL_REGEX.test(value); } module.exports = { create: function(context) { return { Literal: function(node) { const { value } = node; if (hasChinese(value)) { context.report({ node, message: "{{ str }} contains Chinese, move it to T constant.", data: { str: node.value, }, }); } }, }; }, };
在這段代碼中,我們在create里遍歷所有Literal類型節點,因為我們需要檢查的對象是所有字符串。根據 estree 的定義,我們會知道Literal類型階段結構如下:
interface Literal <: Expression { type: "Literal"; value: string | boolean | null | number | RegExp; }
那么需要做的就是判斷該節點的 value 是否包含中文,在這里我們用的是正則表達式進行判斷,當含有中文字符或標點時,就調用context.report方法報告一個錯誤。在應用這條規則之后,全局所有直接使用中文字符串的代碼都會報錯,只需要對統一存放中文的變量T所在的代碼部分禁用這條規則,就可以避免誤判。
在筆者所在項目中我們使用的是“通過翻譯函數處理”的方式,所以規則會更為復雜一些,需要判斷當前字符串的父節點是否為我們的翻譯函數,Espree 會在每個節點上都記錄對應的父節點信息,因此我們可以通過類似node.parent.callee.name === "t"這樣的方式進行判斷。不過實際情況中還需要做更安全、全面的判斷,例如正確識別這樣的使用方式t("你好" + "世界"),后一個字符串的父節點是加法運算符。
在這個示例中我們主要理解了遍歷函數的工作方式以及如何使用合理的節點類型實現需求,因此不再過度展開實際場景中的細節實現。不過相信讀者已經可以感受到寫一條自定義規則需要非常全面的考慮代碼中的各類場景,這也是為什么 eslint 要求自定義規則要遵循 TDD 的開發方式,用足夠多的單元測試保證規則使用時符合預期,在最后我們會介紹 eslint 提供的單測框架。
示例 2:特殊作用域首先構建一個場景用于展示這類規則:
不論是以及非常成熟的 Node.JS + selenium 體系還是較新的 headless chrome 生態,這類端到端工具一般都會提供在目標瀏覽器上執行一段 JavaScript 的能力,例如這樣:
client.execute( function(foo, bar) { document.title = foo + bar; }, ["foo", "bar"] );
client.execute方法接收兩個參數,第一個為在瀏覽器端執行的函數,第二個則是從當前代碼傳遞給執行函數的參數,而瀏覽器端也只能使用傳遞的參數而不能直接使用當前代碼中的變量。在這種場景下,很容易出現類似這樣的問題:
const foo = "foo"; const bar = "bar"; client.execute(function() { document.title = foo + bar; });
對于 eslint 來說并不知道document.title = foo + bar;將在瀏覽器端的作用域中執行,而又發現有同名變量foo和bar被定義在當前代碼中,則不會認為這段代碼有錯誤,這種情況下我們就可以嘗試自定義規則來對這個特殊場景做檢查:
module.exports = { create: function(context) { return { "Program:exit": function() { const globalScope = context.getScope(); const stack = globalScope.childScopes.slice(); while (stack.length) { const scope = stack.pop(); stack.push.apply(stack, scope.childScopes); if (scope.block.parent.callee.property.name === "execute") { const undefs = scope.through.forEach((ref) => context.report({ node: ref.identifier, message: ""{{name}}" is not defined.", data: ref.identifier, }) ); } } }, }; }, };
以上代碼中繼續省略一些過于細節的實現,例如判斷子作用域是否為client.execute的第一個參數以及將瀏覽器中的全局變量加入未定義變量的白名單等等,重點關注 eslint 為我們提供的一些幫助方法。
這次我們的節點選擇器為Program:exit,也就是下行完畢、開始上行完整的 AST 時執行我們的自定義檢查,Program類型的節點對應的是完整的源碼樹,在 eslint 中即是當前文件。
在檢查時,首先我們使用context.getScope獲取了當前正在遍歷的作用域,又由于我們處在Program節點中,這個作用域即為這個代碼文件中的最高作用域。之后我們構建一個棧,通過不斷地把 childScopes 壓入棧中在讀取出來的方式,實現遞歸的訪問到所有的子作用域。
之后在處理每個子作用域時,都做了一個簡單的判斷(同樣是簡化過后的版本),來確定該作用域是否為我們需要獨立判斷的client.execute方法中第一個函數內的作用域。
當找到該函數內的作用域之后,我們就可以使用scope對象上的各種方法進行判斷了。事實上作用域是靜態分析中較為復雜的部分,如果完全獨立的去判斷作用域中的引用等問題相對復雜,好在 eslint 對外暴露了 scope manager interface,讓我們可以最大程度的復用封裝好的各類作用域接口。
在 scope manager interface 中可以看到scope.through方法的描述:
The array of references which could not be resolved in this scope.
正是我們需要的!所以最后只需要簡單的遍歷scope.through返回的未定義引用數組,就可以找到該作用域下所有的未定義變量。
通過這個示例,可以看出 eslint 本身已經對許多常用需求做了高階的封裝,直接復用可以大大縮減“開發工具消耗的時間”。
示例 3:保證 API 使用規范繼續構建一個場景:假如我們在業務中我們有一個內部 API "Checker",用于校驗某些操作(action)是否可執行,而校驗的方式是判斷 action 對應的規則(rule)是否全部通過,代碼如下:
const checker = new Checker({ rules: { ruleA(value) {}, ruleB(value) {}, }, actions: { action1: ["ruleA", "ruleB"], action2: ["ruleB"], }, });
在 Checker 這個 API 使用的過程中,我們需要:
所有 action 依賴的 rule 都在rules屬性中被定義。
所有定義的 rule 都被 action 使用。
由于 action 和 rule 的關聯性只靠 action value 數組中的字符串名稱與 rule key 值保持一致來維護,所以第一條要求如果出了問題只能在運行時發現錯誤,而第二條要求甚至不會造成任何錯誤,但在長期的迭代下可能會遺留大量無用代碼。
當然這個場景我們很容易通過單元測試進行覆蓋,但如果 Checker 是一個在項目各種都會分散使用的 API,那么單元測試即使有一個通用的用例,也需要開發者手動導出 checker 再引入到測試代碼中去,這本身就存在一定遺漏的風險。
從開發體驗出發,我們也嘗試用 eslint 的自定義規則完成這個需求,實現一個實時的 Checker API 使用方式校驗。
首先我們需要在靜態分析階段分辨代碼中的一個 Class 是否為 Checker Class,從而進一步做校驗,單純從變量名稱判斷過于粗暴,容易發生誤判;而從 Class 來源分析很可能出現跨文件引用的情況,又過于復雜。所以我們借鑒一些編程語言中處理類似場景的做法,在需要編譯器特殊處理的地方加一些特殊的標記幫助編譯器定位,例如這樣:
// [action-checker] const checker = new Checker({});
在構造 checker 實例的前一行寫一個注釋// [action-checker],表明下一行開始的代碼是使用了 Checker API,在這基礎上,我們就可以開始編寫 eslint 規則:
const COMMENT_MARKER = "[action-checker]"; function getStartLine(node) { return node.loc.start.line; } module.exports = { create: function(context) { const sourceCode = context.getSourceCode(); const markerLines = {}; return { Program: function() { const comments = sourceCode.getAllComments(); comments.forEach((comment) => { if (comment.value.trim() === COMMENT_MARKER) { markerLines[getStartLine(comment)] = comment; } }); }, ObjectExpression: function(expressionNode) { const startLine = getStartLine(expressionNode); if (markLines[startLine - 1]) { // check actions and rules } }, }; }, };
在這個示例中,我們使用了context.getSourceCode獲取 sourceCode 對象,和上個例子中的 scope 類似,也是 eslint 封裝過后的接口,例如可以繼續通過sourceCode.getAllComments獲取代碼中的所有注釋。
為了實現通過注釋定位 checker 實例的目的,我們在markLines對象中存儲了帶有特殊標記的注釋的行數,獲取行數的方式則是node.loc.start.line。這里的loc也是 eslint 給各個 AST 節點增加的一個重要屬性,包含了節點對應代碼在源代碼中的坐標信息。
之后遍歷所有ObjectExpression類型節點,通過markLines中存儲的位置信息,確定某個ObjectExpression節點是否為我們需要校驗的 checker 對象,再根據 estree 中定義的ObjectExpression結構,找到我們需要的 actions values 和 rules keys 進行比較,此處不對細節處理做進一步展開。
這個示例說明注釋作為靜態分析中非常重要的元素有很好的利用價值,許多項目也提供從一定格式(例如 JSDoc)的注釋中直接生成文檔的功能,也是代碼靜態分析常見的應用,除了示例中用到的sourceCode.getAllComments可以獲取所有注釋,還提供sourceCode.getJSDocComment這樣只獲取 JSDoc 類型注釋的方法。
總而言之,基于 eslint 提供的強大框架,我們可以拓展出很多極大提高開發體驗和代碼質量的用法。
雜項 借鑒社區eslint 本身提供的功能很強但也很多,光從文檔中不一定能找到最適用的方法,而 eslint 本身已經有大量的 通用規則,很多時候直接從相近的規則中學習會更加有效。例如示例 2 中對作用域的判斷就是從社區的通用規則no-undef中借鑒了很多大部分思路。
TDD上文提到,靜態分析需要非常全面的考慮編譯器會遇到的各類代碼,但如果每次編寫規則都需要在一個很大的 code base 中進行測試效率也很低。因此 eslint 提倡用測試驅動開發的方式,先寫出對規則的預期結果,再實現規則。
如果通過上文提到的 eslint yeoman 腳手架新建一個規則模版,會自動生成一個對應的測試文件。以示例 1 為例,內容如下:
const rule = require("../../../lib/rules/use-t-function"); const RuleTester = require("eslint").RuleTester; const parserOptions = { ecmaVersion: 8, sourceType: "module", ecmaFeatures: { experimentalObjectRestSpread: true, jsx: true, }, }; const ruleTester = new RuleTester({ parserOptions }); ruleTester.run("use-t-function", rule, { valid: [ { code: "fn()" }, { code: ""This is not a chinese string."" }, { code: "t("名稱:")" }, { code: "t("一" + "二" + "三")" }, ], invalid: [ { code: "名稱:", errors: [ { message: "名稱: contains Chinese, use t function to wrap it.", type: "Literal", }, ], }, ], });
核心的部分是require("eslint").RuleTester提供的單測框架 Class,傳入一些參數例如解析器配置之后就可以實例化一個 ruleTester。實際執行時需要提供足夠的 valid 和 invalid 代碼場景,并且對 invalid 類型代碼報告的錯誤信息做斷言,當所有測試用例通過后,就可以認為規則的編寫符合預期了。
完整示例代碼自定義 eslint 規則在我們的實際項目中已經有所應用,示例中的實際完整規則代碼都存放在公網 Github 倉庫中,如果對文中跳過的細節實現感興趣可以自行翻看。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/107728.html
摘要:接入分為兩部分,其一是可視化編輯器,在官網上我們可以獲取該編輯器的安裝包,并通過的插件管理進行安裝。借助可視化編輯器,在整個過程中我們可以替換大部分手工編寫代碼的工作,進行一站式操作。,有趣實用的分布式架構頻道。本文根據 SOFAChannel#5 直播分享整理,主題:給研發工程師的代碼質量利器 —— 自動化測試框架 SOFAActs。回顧視頻以及 PPT 查看地址見文末。歡迎加入直播互動釘...
摘要:看到很多團隊和開源項目都在用代碼檢查工具,自己一直沒用過,最近加入了新團隊有項目在用,就想著研究一下。代碼校驗工具能夠讓你在寫代碼時避免一些低級的錯誤。同時,也有友好的文檔針對每一條規則。在上文提高的所有工具當中它對有著最好的支持。 看到很多團隊和開源項目都在用代碼檢查工具,自己一直沒用過,最近加入了新團隊有項目在用,就想著研究一下。看到sitepoint上的一篇2015年的文章覺得不...
閱讀 1907·2021-09-23 11:21
閱讀 1693·2019-08-29 17:27
閱讀 1052·2019-08-29 17:03
閱讀 718·2019-08-29 15:07
閱讀 1914·2019-08-29 11:13
閱讀 2373·2019-08-26 12:14
閱讀 904·2019-08-26 11:52
閱讀 1728·2019-08-23 17:09