ESLint 機制分析與簡單插件實踐

前言

代碼是寫給人看的,所以一份好的代碼,是要讓水平不一的閱讀者,都能夠理解代碼的本意。每個人的代碼風格是不可能完全相同的,例如在一個文件裏,有的以兩個空格做縮進,有的以四個空格做縮進,有的使用下劃線,有的使用駝峯,那麼它的閱讀體驗就會變得很差。

所以如何來對代碼進行約束,使團隊的代碼風格儘量統一,不產生更多的理解成本,是一個需要解決的問題。衆所周知,懶是社會生產力進步的源動力,所以...

在前端工程化的標準中有一項就是自動化,自動化當中就包括了代碼規範自動化。實現代碼規範自動化可以解放團隊生產力,提升團隊生產效率,balabla... 所以 ESlint、TSLint、StyleLint 這些工程化插件應運而生。

而最近在筆者團隊也在統一不同的項目之間的規範差異,相信大家也都遇到了大段飄紅的現象,今天咱來簡單探究一下背後涉及到的原理。

What is ESLint/Lint?

首先,提到 ESlint,應該會想到兩種東西,一個是 ESLint 的 npm 包,也就是我們 devDep 裏面的, 另一個是我們所安裝的比如 VSCode 的 ESLint 插件,那麼這兩個東西有什麼聯繫呢。

Npm 包:是實際的 lint 規則以及我們執行 lint 的時候,控制代碼如何去進行格式化的。

Vscode 插件: 實際指向我們項目的 /node_modules/eslint 或者全局的 eslint,通過 eslint 的規則,告訴 IDE,哪些地方需要飄紅。也就是說插件是在解析我們的打開的文件,同時和規則對比,是否存在 eslint 問題。以及可以通過我們的 IDE 配置,在不同的時機去執行我們的 lint,比如保存自動格式化。

總而言之, eslint 規則就是對我們的代碼風格和代碼中潛在的一些錯誤和不規範用法的一個約束,通過 npm 包的形式引入項目,同時通過 IDE 的插件,讀取 npm 包規則,對我們的代碼進行錯誤提示,

How to Use it?

如何在項目配置 ESlint 就不在本文贅述了。大多數腳手架其實都會給你初始化好基本的 ESlint。涉及到的工具不同可能會有些許的不一樣,不過都大差不差。這段講一下 ESLint 中的主要配置項。如果有興趣深一步研究,可以移步 eslint 的官網文檔 [1],對默認的規則集 [2] 感興趣 也可以移步 。

打開一個 eslintrc 文件, 一般來說,有幾個選項。這裏以 json 爲例,來簡單說明下每個字段。

{
    "extends"'', // 規則集繼承自某個規則集
    "root"'true', // 找到這後,不再向上級目錄尋找
    // 解析選項
    "parserOptions"{
        "ecmaVersion": 6, //  指定你想要使用的 ECMAScript 版本 3/5/6/7/8/9
        "sourceType""module", // 'script'(default) or 'module',標明你的代碼是模塊還是script
        "ecmaFeatures"{ // 是否支持某些feature,默認均爲false
            "globalReturn": true, //是否允許全局return
            'impliedStrict': true, //是否爲全局嚴格模式
            "jsx"true
        }
    },
    //自定義解析器,官方支持下列四種,也可以自己定義解析器。
    "parse""espree" | "esprima" | 'Babel-ESLint' | '@typescript-eslint/parser',
    "plugins"["a-plugin"], //第三方 插件a
    "processor""a-plugin/a-processor", // 制定處理器爲插件a的處理器
    "rules"{
        "eqeqeq""error"
    }
    // 指定一些全局變量,類似於global.d.ts的作用
    "globals"{
        "var1""writable",
        "var2""readonly"
    }
    // 忽略哪些文件
    "ignorePatterns"["src/**/*.test.ts""src/frontend/generated/*"]
}

eslint 支持以下幾種格式的配置文件,如果同一個目錄下有多個配置文件,ESLint 只會使用一個。優先級順序如下:

  1. .eslintrc.js

  2. .eslintrc.yaml

  3. .eslintrc.yml

  4. .eslintrc.json

  5. .eslintrc

  6. package.json

同時 eslint 也支持對每個目錄配置不一樣的規則,對於 mono 倉庫下,可能每個 repo 的 eslint 都有些許的區別,這個時候我們就可以採用下面的目錄格式,根目錄下存在基本規則,子 app 下存在特定的規則。子 rc 是對父 rc 的一個 override,但是如果我們在 app/.eslintrc.js 中設置了 root:true, 那麼對於 test.js, 父目錄中 rc 使用的規則,在 app 中不會生效。

packages
├── package.json
├──.eslintrc.js
├── lib
│  └── test.js
└─┬ app
  ├── .eslintrc.js
  └── test.js

Why does it work?

AST

他是爲什麼能夠生效的。這裏就要提到我們前端方方面面都要涉及到的 AST 了,感謝新時代。

ESLint 是基於抽象語法樹來進行工作的,ESLint 默認使用的編譯器 (parse) 是 Espree[3],通過它來解析我們的 JS 代碼生成 AST,基於 AST,我們就可以對我們的代碼進行檢查和修改了。

通常我們的 Babel 編譯分爲下圖這幾步,編譯 / 轉換 / 生成。ESlint 和它對比,只有第一步是一致的, 因爲我們只需要拿到 ast 中的部分信息,同時直接在源碼中進行提示和操作就行,並不需要 transform 和後續的生成代碼。

解析

現在我們通過 demo 來探究他背後的原理以及轉換的方式。首先,我們需要加載和解析我們的源代碼。這就是編譯器將我們的代碼轉換成 AST 樹的一個過程。因爲已經全面擁抱 typescript(主要是因爲 espree 沒有類型註解,我難受),所以本文使用 @typescript-eslint/parser來作爲我們的編譯器。這裏有個小坑,如果在 VSCode 安裝了 import cost 插件的話,他去解析這個 parser 會特別卡,所以可以暫時禁用。

const foo =   "anthony"
const bar = "dst"
import fs from 'fs';
import path from 'path';
import * as tsParser from  '@typescript-eslint/parser';


const filePath = path.resolve('./src/test.ts')
const text = fs.readFileSync(filePath, "utf8")
// 編譯成 AST 這裏是不是和eslint的配置項對上了,沒錯就是透傳而已
const ast = tsParser.parse(text,{ 
    comment: true, // 創建包含所有註釋的頂級註釋數組
    ecmaVersion: 6, // JS 版本
    // // 指定其他語言功能,
    // ecmaFeatures: { 
    //     jsx: true, // 啓用JSX解析
    //     globalReturn: true // 在全局範圍內啓用return(當sourceType爲“commonjs”時自動設置爲true)
    // }, 
    loc: true, // 將行/列位置信息附加到每個節點
    range: true, // 將範圍信息附加到每個節點
    tokens: true // 創建包含所有標記的頂級標記數組
})

然後我們將獲得的 ast 打印一下,簡單從下圖可以看到主要包含的內容。本地打印出來可能不太方便閱讀,也可以使用在線的工具 [4],將解析器設置爲@typescript-eslint/parser。相對於 espree 來說,ts 解析多出來的部分中,比較關鍵的就是右圖這段,決定我們如何去解析他的類型。

AST 就是記錄了讀取源文件之後的文本內容的各個單位的位置信息,這樣我們就可以通過操作 AST 修改需要修改的內容,然後再根據修改後的 AST 信息進行修改對應的文本內容。比如我們把上文中的 const 關鍵字修改成 let ,那麼我們就先對 AST 對應的const內容進行修改爲 let ,得到修改之後的 AST 數據,再根據修改後的 AST 數據去修改對應的文本內容。所謂的修改就是字符串替換,因爲我們已經知道了對應的位置信息。

SourceCode

但是根據上面我們可以看到,直接根據 ast 去查找然後比對替換,效率是很低的,而且嵌套比較深。這個時候 ESlint 是怎麼幹的呢?他生成了一個新的結構用於我們操作,也就是SourceCode。有興趣進一步探究可以自行查閱源碼的 sourcecode/source_code.js部分。簡單來說,就是構建了一個 SourceCode 實例,接受兩個參數,原文 text 和解析後的 ast,然後返回我們一個包含茫茫多方法的實例對象。

我們在 demo 項目中裝一個 eslint 然後引入 SourceCode,看看構造後的對象是個什麼玩意。

import { SourceCode } from 'eslint';
// ....

//
const sourceCode = new SourceCode(text,ast);
// 這打個斷點,看看sourceCode結構

  1. hasBOM:是否含有 unicode bom[6]

  2. lines:將我們的每一行切割,分行形成的一個 array

  3. linsStartIndices: 每行的開始位置

  4. tokenAndComments: token 和 comment 的一個有序集合。

  5. getText(node?: ESTree.Node, beforeCount?: number, afterCount?: number): string;

  6. isSpaceBetweenTokens(first: AST.Token, second: AST.Token): boolean; 兩個 token 間是否有空格。

  7. visitorKeys: 存在的 key 值。

好了 前置的一些知識我們已經介紹的差不多了。接下來 結合實際的 rules demo 來進行講解。

規則模版

相信如果有寫過 vscode 插件的同學應該對 Yeoman 不陌生,eslint 也有提供基於 Yeoman 的一套腳手架用於生成模版。

首先全局安裝 eslint 的腳手架,npm install -g yo generator-eslint,然後通過下面的一些交互式命令行操作來初始化我們的操作。

通過初始化,我們可以看到一個以下的文件的殼子, 我們在裏面添加一些我們上面所講到的東西。打開我們生成的規則模版文件,同時在裏面添加一些規則和提示(注意,這裏我的寫法不規範,我將兩種無關規則放在了一個規則文件裏)。

"use strict";
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: 'problem', // `problem``suggestion`, or `layout`
    docs: {
      description: "xxxx",
      recommended: false,
      url: null, // URL to the documentation page for this rule
    },
    messages:{
      temp:'不樣你用字面量作爲函數的參數傳入',
      novar: '不樣你用var聲明',
      noExport: '退出時執行這個'
    },
    fixable: 'code', // Or `code` or `whitespace`
    schema: [], // Add a schema if the rule has options
  },

  create(context) {
    // variables should be defined here
    const sourceCode = context.getSourceCode();
    return {
      ArrowFunctionExpression:(node)=>{
        if(node.callee.name !== 'abcd') return;
        if(!node.arguments) return;
        node.arguments.forEach((argNode,index)=>{
          argNode.type === "Literal" && context.report({
            node,
            messageId: 'temp',
            fix(fixer){
              const val = argNode.value;
              const statementString = `const val${index} = ${val}\n`;
              return [
                fixer.replaceTextRange(node.arguments[index].range, `val${index}`),
                fixer.insertTextBeforeRange(node.range, statementString)
              ]
            }
          })
        })
      },
      "Program:exit"(node) {
            context.report({
                node,
                messageId: "noExport",
            });
      },
      VariableDeclaration(node){
        if(node.kind === 'var') {
          context.report({
              node,
              messageId: 'novar',
              fix(fixer) {
                  const varToken = sourceCode.getFirstToken(node)
                  return fixer.replaceText(varToken, 'let')
              }
          })
      }
    }
    };
  },
};

關鍵函數

在這個 demo 裏面,我們看到幾個東西,一個是 create 函數的參數 context 以及他的返回值,還有就是context上提供的report方法 以及 report 接受的fix參數。這幾個加起來,形成了我們一條規則的校驗邏輯,通過遍歷,我們到了某個 ast 節點,如果某個 ast 節點滿足了我們所寫的某條規則,我們進行 report,同時提供一個修復函數,修復函數通過 token 或者 range 來決定對某處進行文本替換。

接下來,挨個來講解這些東西,首先是 context 的上下文形成,這個沒有什麼好說的,其實就是創建了一個對象,然後提供了一些一些方法,供我們在插件中訪問上下文使用,然後對於每個 rule 都在 createRuleListener 中都創建了一個 listener,這裏我們在後面串整體流程時還會再過一遍。

接着是 report 方法,簡單分析下這塊代碼,其實就是通過一系列的操作,然後往 lintingProblem 這個數組裏面推了一個 problem。這個 problem 包含一些錯誤信息,ast 信息等等。

最後是我們的 fix,我們上面用到的所有 replace 方法,其實都殊途同歸,最後回到了這裏,大道至簡,簡單的 slice 和 += 完成了我們的修復動作。

基本上一個插件涉及到的核心幾個東西,都簡單解釋了下。現在我們來串一串整體檢測和修復的流程,也就是源碼中linter.js中的runRules方法。

整體流程

我們在跑規則的時候,肯定需要的是對 ast 進行遍歷,同時做一些操作。首先做了一個什麼操作呢,調用了一個實例方法Traverser.traverse,傳入了ast和一個對象,包含enterleavevisitorKeys。這個函數的作用就是進行一個遞歸遍歷,同時在遍歷的時候通過 enter 和 leave 我們在隊列中存儲了兩個相同的節點,一個是進入時,一個是退出時,方便我們後續處理。這裏涉及到一個設計模式,訪問者模式(用於數據和操作解耦),通過在遍歷時加上 isEntering,可以讓我們決定是在進入時還是退出時執行訪問者邏輯。

接着我們需要把我們的所有規則都給像上面講的給創建成 ruleListener,然後在我們的 nodeQueue 後續遍歷時,觸發某些邏輯。當然,這裏大家可能都想到了訂閱發佈模式,這個也是在我們整個邏輯中比較重要的一環,遍歷時,通過 emit 推送消息,然後讓 ruleListener 決定是否需要執行某些邏輯,所以,我們需要對 Listener 訂閱上某些事件。

接下來,就是對我們的 nodeQueue 遍歷了,通過我們節點上打上的標,來決定是在執行進入邏輯還是離開邏輯。這裏我就不展開講具體的細節了,其實簡單理解就是通過 enter 和 leave 的時候去觸發不同的 visitor 的動作。

後語

限制於時間因素,本文成文比較倉促,可能會有一些知識點的缺失或者不對,敬請大家斧正。同時本文僅是初步的探索了其背後的原理,根據原理,後續可以做的一些例如 eslint 插件等等並沒有詳細的闡述。大家下來可以自行探索。

最後送大家一句話,linus 說的,也是我比較信奉的一句話。talk is cheap, show me the code,想了解一個東西,最好的辦法就是簡單實現它。我相信大家在解析完它的流程後,都能夠簡單實現一個 eslint 的小 demo,以及能夠上手寫一寫 eslint-plugin。

參考資料

[1]

官網文檔 : https://eslint.org/docs/latest/user-guide/configuring/configuration-files

[2]

默認的規則集: https://eslint.org/docs/latest/rules/

[3]

Espree: https://github.com/eslint/espree

[4]

在線的工具: https://astexplorer.net/

[5]

官網: https://eslint.org/docs/latest/developer-guide/working-with-rules

[6]

unicode bom: https://en.wikipedia.org/wiki/Byte_order_mark

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wYYDG7yU9h3-6DBYTCkuiA