前端代碼質量進階:自定義 eslint 劃定規矩校驗營業邏輯

自定義 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 也不破例。

起首我們看看大部份編譯器事情時的三個階段:

  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 堆棧中,如果對文中跳過的細節完成感興趣可以自行翻看。

    原文作者:aryu
    原文地址: https://segmentfault.com/a/1190000014684778
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞