前端代碼質量進階:自定義 eslint 規則校驗業務邏輯

自定義 eslint 規則校驗代碼業務邏輯

eslint 是 JavaScript 社區中主流的 lint 工具,提供的大量規則有效的保障了許多項目的代碼質量。本文將介紹如何經過自定義 eslint 檢查規則,校驗項目中特有的一些業務邏輯,如 i18n、特殊做用域、特殊 API 使用規範性等。

代碼靜態分析與 eslint

代碼靜態分意指是不須要實際執行代碼就能獲取到程序中的部分信息並加以使用,lint 就是其中一種常見的實踐,一般爲檢查代碼中錯誤的寫法或是不符合標準的代碼風格。許多編程語言都自帶 lint 工具,甚至直接將其植入到編譯器中。javascript

但這一重要的功能對於 JavaScript 來講倒是一大痛點,做爲動態且弱類型的語言 JavaScript 沒有編譯階段也就無從進行靜態分析,這致使程序錯誤只能在運行時被發現,部分錯誤很是低級例如variable is undefined。而當程序變得更爲複雜時,這類錯誤甚至難以在開發、測試階段暴露,只會在用戶實際使用的過程當中遇到,形成嚴重的後果。java

爲了彌補語言天生的弱點,社區開發出了一些 lint 工具,在所謂預編譯階段完成代碼的靜態分析檢查,而 eslint 就是其中的佼佼者。如今社區已經廣泛接受使用 eslint 做爲代碼規範工具,也延伸出了許多經常使用的規則與規則集。但實際上 eslint 拓展性極佳,咱們還能夠基於 eslint 提功的靜態分析能力對代碼進行業務邏輯的檢查,本文將講解一些筆者所在項目中的靜態分析實踐,以說明這一方案的適用場景和優缺點。node

eslint 基本原理

首先快速說明 eslint 工做的基本流程,幫助理解它將給咱們提供哪些方面的能力以及如何編寫咱們的自定義規則。git

配置規則與插件

eslint 主要依靠配置決定執行哪些規則的校驗,例如咱們能夠經過配置no-extra-semi決定是否須要寫分號,這類規則中不包含具體的業務邏輯,而是對全部項目通用,所以會被集成在 eslint 的內置規則中。github

而還有一些規則也不包含業務邏輯,但只在部分項目場景中使用,如 React 相關的大量規則,那麼顯然不該該集成在內置規則中,但也應該自成一個集合。這種狀況下 eslint 提供了另外一種規則單位——插件,能夠做爲多個同類規則的集合被引入到配置中。正則表達式

若是咱們準備自定義一些規則用於校驗項目中的業務邏輯,那麼也應該建立一套自用的插件,並將自用的規則都存放其中。推薦使用 eslint 的 yeoman generator 腳手架新建插件或規則,該腳手架可以生成插件項目的目錄結構、規則文件、文檔以及單元測試等模版,下文中咱們將經過示例理解這些文件的的做用。chrome

JavaScript 解析

如上文所說,要實現靜態分析則須要自建一個預編譯階段對代碼進行解析,eslint 也不例外。express

首先咱們看看大部分編譯器工做時的三個階段:npm

  1. 解析,將未經處理的代碼解析成更爲抽象的表達式,一般爲抽象語法樹,即 AST。
  2. 轉換,經過修改解析後的代碼表達式,將其轉換爲符合預期的新格式。
  3. 代碼生成,將轉換後的表達式生成爲新的目標代碼。

若是想快速的加深對編譯器工做原理的理解,推薦閱讀 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 節點類型,如ReturnStatementFunctionExpression。第二部分則是可選的: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 = /[\u3002\uff1b\uff0c\uff1a\u201c\u201d\uff08\uff09\u3001\uff1f\u300a\u300b]/;
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;將在瀏覽器端的做用域中執行,而又發現有同名變量foobar被定義在當前代碼中,則不會認爲這段代碼有錯誤,這種狀況下咱們就能夠嘗試自定義規則來對這個特殊場景作檢查:

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 使用的過程當中,咱們須要:

  1. 全部 action 依賴的 rule 都在rules屬性中被定義。
  2. 全部定義的 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: '<Col xs={6}>名稱:</Col>',
      errors: [
        {
          message: '名稱: contains Chinese, use t function to wrap it.',
          type: 'Literal',
        },
      ],
    },
  ],
});

核心的部分是require('eslint').RuleTester提供的單測框架 Class,傳入一些參數例如解析器配置以後就能夠實例化一個 ruleTester。實際執行時須要提供足夠的 valid 和 invalid 代碼場景,而且對 invalid 類型代碼報告的錯誤信息作斷言,當全部測試用例經過後,就能夠認爲規則的編寫符合預期了。

完整示例代碼

自定義 eslint 規則在咱們的實際項目中已經有所應用,示例中的實際完整規則代碼都存放在公網 Github 倉庫中,若是對文中跳過的細節實現感興趣能夠自行翻看。

相關文章
相關標籤/搜索