博客 / 詳情

返回

從AST原理到ESlint實踐

AST(抽象語法樹)

為什麼要談AST?

如果你查看目前任何主流的項目中的devDependencies,會發現前些年的不計其數的插件誕生。我們歸納一下有:ES6轉譯、代碼壓縮、css預處理器、eslintprettier等。這些模塊很多都不會用到生產環境,但是它們在開發環境中起到很重要的作用,這些工具的誕生都是建立在了AST這個巨人的肩膀上。

image

什麼是AST?

It is a hierarchical program representation that presents source code structure according to the grammar of a programming language, each AST node corresponds to an item of a source code.

抽象語法樹(abstract syntax code,AST)是源代碼的抽象語法結構的樹狀表示,樹上的每個節點都表示源代碼中的一種結構,這所以説是抽象的,是因為抽象語法樹並不會表示出真實語法出現的每一個細節,比如説,嵌套括號被隱含在樹的結構中,並沒有以節點的形式呈現。抽象語法樹並不依賴於源語言的語法,也就是説語法分析階段所採用的上下文無文文法,因為在寫文法時,經常會對文法進行等價的轉換(消除左遞歸,回溯,二義性等),這樣會給文法分析引入一些多餘的成分,對後續階段造成不利影響,甚至會使合個階段變得混亂。因些,很多編譯器經常要獨立地構造語法分析樹,為前端,後端建立一個清晰的接口。
image

從純文本轉換成樹形結構的數據,也就是AST,每個條目和樹中的節點一一對應。

AST的流程

此部分將讓你瞭解到從源代碼到詞法分析生成tokens再到語法分析生成AST的整個流程。

從源代碼中怎麼得到AST呢?當下的編譯器幫着做了這件事,那編譯器是怎麼做的呢?

image

一款編譯器的編譯流程(將高級語言轉譯成二進制位)是很複雜的,但我們只需要關注詞法分析和語法分析,這兩步是從代碼生成AST的關鍵所在。

1158910-20201203142840253-1876335044.png

第一步,詞法分析器,也稱為掃描器,它會先對整個代碼進行掃描,當它遇到空格、操作符或特殊符號時,它決定一個單詞完成,將識別出的一個個單詞、操作符、符號等以對象的形式({type, value, range, loc })記錄在tokens數組中,註釋會另外存放在一個comments數組中。

16750e43f19f6f39_tplv-t2oaga2asx-watermark-1628559456392.awebp

比如var a = 1;@typescript-eslint/parser解析器生成的tokens如下:

tokens: [
    {
      "type": "Keyword",
      "value": "var",
      "range": [112, 115],
      "loc": {
        "start": {
          "line": 11,
          "column": 0
        },
        "end": {
          "line": 11,
          "column": 3
        }
      }
    },
    {
      "type": "Identifier",
      "value": "a",
      "range": [116, 117],
      "loc": {
        "start": {
          "line": 11,
          "column": 4
        },
        "end": {
          "line": 11,
          "column": 5
        }
      }
    },
    {
      "type": "Punctuator",
      "value": "=",
      "range": [118, 119],
      "loc": {
        "start": {
          "line": 11,
          "column": 6
        },
        "end": {
          "line": 11,
          "column": 7
        }
      }
    },
    {
      "type": "Numeric",
      "value": "1",
      "range": [120, 121],
      "loc": {
        "start": {
          "line": 11,
          "column": 8
        },
        "end": {
          "line": 11,
          "column": 9
        }
      }
    },
    {
      "type": "Punctuator",
      "value": ";",
      "range": [121, 122],
      "loc": {
        "start": {
          "line": 11,
          "column": 9
        },
        "end": {
          "line": 11,
          "column": 10
        }
      }
    }
]

第二步,語法分析器,也稱為解析器,將詞法分析得到的tokens數組轉換為樹形結構表示,驗證語言語法並拋出語法錯誤(如果發生這種情況)

16750e44ca7e6d2d_tplv-t2oaga2asx-watermark-1628559456393.awebp

var a = 1;tokens數組轉換為樹形結構如下所示:

{
  type: 'Program',
  body: [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a",
            "range": [
              116,
              117
            ],
            "loc": {
              "start": {
                "line": 11,
                "column": 4
              },
              "end": {
                "line": 11,
                "column": 5
              }
            }
          },
          "init": {
            "type": "Literal",
            "value": 1,
            "raw": "1",
            "range": [
              120,
              121
            ],
            "loc": {
              "start": {
                "line": 11,
                "column": 8
              },
              "end": {
                "line": 11,
                "column": 9
              }
            }
          },
          "range": [
            116,
            121
          ],
          "loc": {
            "start": {
              "line": 11,
              "column": 4
            },
            "end": {
              "line": 11,
              "column": 9
            }
          }
        }
      ],
      "kind": "var",
      "range": [
        112,
        122
      ],
      "loc": {
        "start": {
          "line": 11,
          "column": 0
        },
        "end": {
          "line": 11,
          "column": 10
        }
      }
    }
  ]
}

在生成樹時,解析器會剔除掉一些不必要的標記(例如冗餘括號),因此創建的“抽象語法樹”不是 100% 與源代碼匹配,但足以讓我們知道如何處理它。另一方面,完全覆蓋所有代碼結構的解析器生成的樹稱為“具體語法樹”

16750e44cecfbb2d_tplv-t2oaga2asx-watermark-1628559456405.awebp

編譯器拓展

想了解更多關於編譯器的知識? the-super-tiny-compiler,這是一個用JavaScript 編寫的編譯器。大概200行代碼,其背後的想法是將Lisp編譯成C語言,幾乎每行都有註釋。

16750e44d400efea_tplv-t2oaga2asx-watermark-1628559456406.awebp

LangSandbox,一個更好的項目,它説明了如何創造一門編程語言。當然,設計編程語言這樣的書市面上也一坨坨。所以,這項目更加深入,與the-super-tiny-compiler的項目將Lisp轉為C語言不同,這個項目你可以寫一個你自己的語言,並且將它編譯成C語言或者機器語言,最後運行它。

1_E7dHOaSFxtAnzjl7E2ajEg.png

能直接用三方庫來生成AST嗎? 當然可以!有一堆三方庫可以用。你可以訪問astexplorer,然後挑你喜歡的庫。astexplorer是一個很棒的網站,你可以在線玩轉AST,而且除了JavaScript 之外,它還包含許多其他語言AST

49005590-0e32b400-f1a2-11e8-8d94-a501140a187f-1628559456783.png

我想特別強調其中的一個,在我看來它是非常好的一個,babylon

49005704-5225b900-f1a2-11e8-8083-3c73464d5a78-1628559454340.png

它在 Babel 中使用,也許這也是它受歡迎的原因。因為它是由 Babel項目支持的,所以它會始終與最新的JS特性保持同步,可以大膽地使用。另外,它的API也非常的簡單,容易使用。

OK,現在您知道如何將代碼生成 AST,讓我們繼續討論現實中的用例。

我想談論的第一個用例是代碼轉譯,當然是 Babel

Babel is not a ‘tool for having ES6 support’. Well, it is, but it is far not only what it is about.

BabelES6/7/8 特性的支持有很多關聯,這就是我們經常使用它的原因。但它僅僅是一組插件,我們還可以將它用於代碼壓縮、React 相關的語法轉換(例如 JSX)、Flow 插件等。

49010480-4fc95c00-f1ae-11e8-8aa6-097d16c1c2db-1628559456783.png

Babel 是一個 JavaScript編譯器,它的編譯有三個階段:解析(parsing)、轉譯(transforming)、生成(generation)。你給 Babel一些 JavaScript 代碼,它修改代碼並生成新的代碼,它是如何修改代碼?沒錯!它構建 AST,遍歷它,根據babel-plugin修改它,然後從修改後的AST 生成新代碼。

image.png

讓我們在一個簡單的代碼示例中看到這一點。

49010863-7936b780-f1af-11e8-88f8-1ab083f3eafe-1628559454339.png

正如我之前提到的,Babel 使用 Babylon,所以,我們首先解析代碼生成AST,然後遍歷 AST 並反轉所有變量名稱,最後生成代碼。正如我們看到的,第一步(解析)和第三步(代碼生成)階段看起來很常見,每次都會做的。所以,Babel接管了這兩步,我們真正感興趣的是 AST 轉換(Babel-plugin修改)。

當開發 Babel-plugin 時,你只需要描述節點“visitors”,它會改變你的AST。將它加入你的babel插件列表中,設置你webpackbabel-loader配置或者.babelrc中的plugins即可

16750e45d9df4781-1628559456460

如果你想了解更多關於如何創建 babel-plugin,你可以查看 Babel-handbook。

49011621-daf82100-f1b1-11e8-93d1-8da5567c8279-1628559454339.png

AST 在 ESLint 中的運用

在正式寫 ESLint 插件前,你需要了解下 ESLint 的工作原理。其中 ESLint 使用方法大家應該都比較熟悉,這裏不做講解,不瞭解的可以點擊官方文檔 如何在項目中配置 ESLint。

在項目開發中,不同開發者書寫的源碼是各不相同的,那麼 ESLint 如何去分析每個人寫的源碼呢?

沒錯,就是 AST (Abstract Syntax Tree(抽象語法樹)),再祭上那張看了幾百遍的圖。

16db9a1e630b7329_tplv-t2oaga2asx-watermark-1628559456479.awebp

ESLint 中,默認使用 esprima 來解析 Javascript ,生成抽象語法樹,然後去 攔截 檢測是否符合我們規定的書寫方式,最後讓其展示報錯、警告或正常通過。 ESLint 的核心就是規則(rules),而定義規則的核心就是利用 AST 來做校驗。每條規則相互獨立,可以設置禁用off、警告warn⚠️和報錯error❌,當然還有正常通過不用給任何提示。

手把手教你寫Eslint插件

目標&涉及知識點

本文 ESLint 插件旨在校驗代碼註釋是否寫了註釋

  • 每個聲明式函數、函數表達式都需要註釋;
  • 每個interface頭部和字段都需要註釋;
  • 每個enum頭部和字段都需要註釋;
  • 每個type頭部都需要註釋;
  • ......

知識點

  • AST 抽象語法樹
  • ESLint
  • Mocha單元測試
  • Npm 發佈

腳手架搭建項目

這裏我們利用 yeomangenerator-eslint 來構建插件的腳手架代碼,安裝:

npm install -g yo generator-eslint

本地新建文件夾eslint-plugin-pony-comments

mkdir eslint-plugin-pony-comments
cd eslint-plugin-pony-comments

命令行初始化ESLint插件的項目結構:

yo eslint:plugin

下面進入命令行交互流程,流程結束後生成ESLint插件項目框架和文件

$ yo eslint:plugin
? What is your name? xxx // 作者
? What is the plugin ID? eslint-plugin-pony-comments // 插件名稱
? Type a short description of this plugin: 檢查代碼註釋 // 插件描述
? Does this plugin contain custom ESLint rules? (Y/n) Y
? Does this plugin contain custom ESLint rules? Yes // 這個插件是否包含自定義規則
? Does this plugin contain one or more processors? (y/N) N
? Does this plugin contain one or more processors? No // 該插件是否需要處理器
   create package.json
   create lib\index.js
   create README.md

此時文件的目錄結構為:

.
├── README.md
├── lib
│   ├── processors // 處理器,選擇不需要時沒有該目錄
│   ├── rules // 自定義規則目錄
│   └── index.js // 導出規則、處理器以及配置
├── package.json
└── tests
    ├── processors // 處理器,選擇不需要時沒有該目錄
    └── lib
        └── rules // 編寫規則的單元測試用例

安裝依賴:

npm install // 或者yarn

至此,環境搭建完畢。

創建規則

以實現”每個interface頭部和字段都需要註釋“為例創建規則,終端執行:

yo eslint:rule // 生成默認 eslint rule 模版文件

下面進入命令行交互流程:

$ yo eslint:rule
? What is your name? xxx // 作者
? Where will this rule be published? ESLint Plugin // 選擇生成插件模板
? What is the rule ID? no-interface-comments // 規則名稱
? Type a short description of this rule: 校驗interface註釋 // 規則描述
? Type a short example of the code that will fail: 
   create docs\rules\no-interface-comments.md     
   create lib\rules\no-interface-comments.js      
   create tests\lib\rules\no-interface-comments.js

此時項目結構為:

.
├── README.md
├── docs // 説明文檔
│   └── rules
│       └── no-interface-comments.md
├── lib // eslint 規則開發
│   ├── index.js
│   └── rules // 此目錄下可以構建多個規則,本文只拿一個規則來講解
│       └── no-interface-comments.js
├── package.json
└── tests // 單元測試
    └── lib
        └── rules
            └── no-interface-comments.js

ESLint 中的每個規則都有三個以其標識符命名的文件(例如,no-interface-comments)。

  • lib/rules目錄中:一個源文件(例如,no-interface-comments.js
  • tests/lib/rules目錄中:一個測試文件(例如,no-interface-comments.js
  • docs/rules目錄中:一個 Markdown 文檔文件(例如,no-interface-comments

在正式進入開發規則之前先來看看生成的規則模板 no-interface-comments.js

/**
 * @fileoverview no-interface-comments
 * @author xxx
 */
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

module.exports = {
    meta: {
        docs: {
            description: "no console.time()",
            category: "Fill me in",
            recommended: false
        },
        fixable: null,  // or "code" or "whitespace"
        schema: [
            // fill in your schema
        ]
    },

    create: function(context) {

        // variables should be defined here

        //----------------------------------------------------------------------
        // Helpers
        //----------------------------------------------------------------------

        // any helper functions should go here or else delete this section

        //----------------------------------------------------------------------
        // Public
        //----------------------------------------------------------------------

        return {

            // give me methods

        };
    }
};

這個文件給出了書寫規則的模版,一個規則對應一個可導出的 node 模塊,它由 metacreate 兩部分組成。

  • meta:代表了這條規則的元數據,如其類別,文檔,可接收的參數的 schema 等等。
  • create:如果説 meta 表達了我們想做什麼,那麼 create 則用表達了這條 rule 具體會怎麼分析代碼;

create 返回一個對象,其中最常見的鍵名AST抽象語法樹中的選擇器,在該選擇器中,我們可以獲取對應選中的內容,隨後我們可以針對選中的內容作一定的判斷,看是否滿足我們的規則。如果不滿足,可用 context.report 拋出問題,ESLint 會利用我們的配置對拋出的內容做不同的展示。詳情參考:context.report

在編寫no-interface-comments規則之前,我們在AST Explorer看看interface代碼解析成AST的結構是怎麼樣的?

image-20210810103517405.png

根據上面AST結構,我們創建兩個選擇器校驗代碼註釋,TSInterfaceDeclaration選擇器校驗interface頭部是否有註釋,TSPropertySignature選擇器校驗字段是否有註釋。遍歷AST可能需要用到以下API,詳情參考官網:

  • fixer.insertTextAfter(nodeOrToken, text) - 在給定的節點或標記之後插入文本
  • fixer.insertTextBefore(nodeOrToken, text) - 在給定的節點或標記之前插入文本
  • sourceCode.getAllComments() - 返回源代碼中所有註釋的數組
  • context.getSourceCode() - 獲取源代碼
/**
 * @fileoverview interface定義類型註釋校驗
 * @author xxx
 */
'use strict';

const {
  docsUrl,
  getLastEle,
  getAllComments,
  judgeNodeType,
  getComments,
  genHeadComments,
  report,
  isTailLineComments,
  getNodeStartColumn,
  genLineComments,
} = require('../utils');

module.exports = {
  meta: {
    /**
     * 規則的類型
     * "problem" 意味着規則正在識別將導致錯誤或可能導致混淆行為的代碼。開發人員應將此視為優先解決的問題。
     * "suggestion" 意味着規則正在確定可以以更好的方式完成的事情,但如果不更改代碼,則不會發生錯誤。
     * "layout" 意味着規則主要關心空格、分號、逗號和括號,程序的所有部分決定了代碼的外觀而不是它的執行方式。這些規則適用於 AST 中未指定的部分代碼。
     */
    type: 'layout',
    docs: {
      description: 'interface定義類型註釋校驗', // 規則描述
      category: 'Fill me in',
      recommended: true, // 是配置文件中的"extends": "eslint:recommended"屬性是否啓用規則
      url: 'https://github.com/Revelation2019/eslint-plugin-pony-comments/tree/main/docs/rules/no-interface-comments.md', // 該規則對應在github上的文檔介紹
    },
    fixable: 'whitespace',  // or "code" or "whitespace"
    schema: [ // 指定選項,比如'pony-comments/no-interface-comments: [2, 'always', { leadingCommentType: 'Block', propertyComments: { pos: 'lead', commentsType: 'Block'}}]'
      {
        'enum': ['always', 'never'],
      },
      {
        'type': 'object',
        'properties': {
          /** 
           * 是否需要頭部註釋
           * 'No':表示不需要頭部註釋
           * 'Line': 表示頭部需要單行註釋
           * 'Block':表示頭部需要多行註釋
           */
          'leadingCommentType': {
            'type': 'string',
          },
          /** 字段註釋採用單行還是多行註釋 */
          'propertyComments': {
            'type': 'object',
            'properties': {
              'pos': {
                'type': 'string', // lead || tail 表示註釋位置是行頭還是行尾
              },
              'commentsType': {
                'type': 'string', // No || Line || Block 表示註釋是單行還是多行,或者不需要註釋
              },
            },
          },
        },
        'additionalProperties': false,
      },
    ],
  },

  create: function(context) {
    // 獲取選項
    const options = context.options;
    const leadingCommentsType = options.length > 0 ? getLastEle(options).leadingCommentType : null;
    const propertyComments = options.length > 0 ? getLastEle(options).propertyComments : {};
    const { pos, commentsType } = propertyComments;
    /** 獲取所有的註釋節點 */
    const comments = getAllComments(context);
    // 有效的選項值
    const commentsTypeArr = ['No', 'Line', 'Block'];

    return {
      /** 校驗interface定義頭部註釋 */
      'TSInterfaceDeclaration': (node) => {
        /** 不需要頭部註釋 */
        if (leadingCommentsType === 'No' || !commentsTypeArr.includes(leadingCommentsType)) return;
        const { id } = node;
        const { name } = id;
        // 判斷interface的父節點是否是export
        if (judgeNodeType(node, 'ExportNamedDeclaration')) {
          /** export interface XXX {} */
          const { leading } = getComments(context, node.parent);
          if (!leading.length) {
            // 沒有頭部註釋,拋出斷言
            report(context, node.parent, '導出的類型頭部沒有註釋', genHeadComments(node.parent, name, leadingCommentsType));
          }
        } else {
          /** enum interface {} */
          const { leading } = getComments(context, node); // 獲取節點頭部和尾部註釋
          if (!leading.length) {
            // 沒有頭部註釋,拋出斷言
            report(context, node, '類型頭部沒有註釋', genHeadComments(node, name, leadingCommentsType));
          }
        }
      },
      /** 校驗interface定義字段註釋 */
      'TSPropertySignature': (node) => {
        if (commentsType === 'No' || !commentsTypeArr.includes(commentsType)) return;
        /** 避免 export const Main = (props: { name: string }) => {} */
        if (judgeNodeType(node, 'TSInterfaceBody')) {
          const { key } = node;
          const { name } = key;
          const { leading } = getComments(context, node); // 獲取節點頭部和尾部註釋
          const errorMsg = '類型定義的字段沒有註釋';
          if (isTailLineComments(comments, node) || (leading.length &&  getNodeStartColumn(getLastEle(leading)) === getNodeStartColumn(node))) {
            /** 
             * 節點尾部已有註釋 或者 頭部有註釋並且註釋開頭與節點開頭列數相同 
             * 這裏判斷節點開始位置column與註釋開頭位置column是因為getComments獲取到的頭部註釋可能是不是當前節點的,比如
             interface xxx { 
                 id: string; // id
                 name: string; // name
             } 
             leading拿到的是// id,但這個註釋不是name字段的
             */
            return;
          }
          // 根據選項報出斷言,並自動修復
          if (commentsType === 'Block' || (commentsType === 'Line' && pos === 'lead')) {
            // 自動添加行頭多行註釋
            report(context, node, errorMsg, genHeadComments(node, name, commentsType));
          } else {
            // 自動添加行尾單行註釋
            report(context, node, errorMsg, genLineComments(node, name));
          }
        }
      },
    };
  },
};

自動修復函數:

/**
 * @description 在函數頭部加上註釋
 * @param {Object} node 當前節點
 * @param {String} text 註釋內容
 * @returns
 */
const genHeadComments = (node, text, commentsType) => {
  if (!text) return null;
  const eol = require('os').EOL; // 獲取換行符,window是CRLF,linux是LF
  let content = '';
  if (commentsType && commentsType.toLowerCase === 'line') {
    content = `// ${text}${eol}`;
  } else if (commentsType && commentsType.toLowerCase === 'block') {
    content = `/** ${text} */${eol}`;
  } else {
    content = `/** ${text} */${eol}`;
  }
  return (fixer) => {
    return fixer.insertTextBefore(
      node,
      content,
    );
  };
};
/**
 * @description 生成行尾單行註釋
 * @param {Object} node 當前節點
 * @param {String} value 註釋內容
 * @returns
 */
const genLineComments = (node, value) => {
  return (fixer) => {
    return fixer.insertTextAfter(
      node,
      `// ${value}`,
    );
  };
};

至此,no-interface-comments規則編寫就基本完成了

插件中的配置

您可以通過在configs指定插件對應的規則狀態,當您要提供一些支持它的自定義規則時,這會很有用。參考官網

// lib/index.js
module.exports = {
    configs: {
        recommended: {
            plugins: ['pony-comments'],
            parserOptions: {
                sourceType: 'module',
                ecmaVersion: 2018,
            },
            rules: {
                'pony-comments/no-interface-comments': [2, 'always', { leadingCommentType: 'Block', propertyComments: { pos: 'tail', commentsType: 'Line' } }],
            }
        },
    }
};

插件規則將可以通過extends配置繼承:

{
    "extends": ["plugin:pony-comments/recommended"]
}

注意:請注意,默認情況下配置不會啓用任何插件規則,而是應視為獨立配置。這意味着您必須在plugins數組中指定您的插件名稱以及您要啓用的任何規則,這些規則是插件的一部分。任何插件規則都必須以短或長插件名稱作為前綴

創建處理器

處理器可以告訴 ESLint 如何處理 JavaScript 以外的文件,比如從其他類型的文件中提取 JavaScript 代碼,然後讓 ESLintJavaScript 代碼進行 lint,或者處理器可以出於某種目的在預處理中轉換 JavaScript 代碼。參考官網

// 在lib/index.js中導出自定義處理器,或者將其抽離
module.exports = {
    processors: {
        "markdown": {
            // takes text of the file and filename
            preprocess: function(text, filename) {
                // here, you can strip out any non-JS content
                // and split into multiple strings to lint

                return [ // return an array of code blocks to lint
                    { text: code1, filename: "0.js" },
                    { text: code2, filename: "1.js" },
                ];
            },

            // takes a Message[][] and filename
            postprocess: function(messages, filename) {
                // `messages` argument contains two-dimensional array of Message objects
                // where each top-level array item contains array of lint messages related
                // to the text that was returned in array from preprocess() method

                // you need to return a one-dimensional array of the messages you want to keep
                return [].concat(...messages);
            },

            supportsAutofix: true // (optional, defaults to false)
        }
    }
};

要在配置文件中指定處理器,請使用processor帶有插件名稱和處理器名稱的連接字符串的鍵(由斜槓)。例如,以下啓用pony-comments插件提供的markdown處理器:

{
    "plugins": ["pony-comments"],
    "processor": "pony-comments/markdown"
}

要為特定類型的文件指定處理器,請使用overrides鍵和processor鍵的組合。例如,以下使用處理器pony-comments/markdown處理*.md文件。

{
    "plugins": ["pony-comments"],
    "overrides": [
        {
            "files": ["*.md"],
            "processor": "pony-comments/markdown"
        }
    ]
}

處理器可能會生成命名代碼塊,例如0.js1.jsESLint 將這樣的命名代碼塊作為原始文件的子文件處理。您可以overridesconfig 部分為命名代碼塊指定其他配置。例如,以下strict代碼禁用.jsmarkdown 文件結尾的命名代碼塊的規則。

{
    "plugins": ["pony-comments"],
    "overrides": [
        {
            "files": ["*.md"],
            "processor": "pony-comments/markdown"
        },
        {
            "files": ["**/*.md/*.js"],
            "rules": {
                "strict": "off"
            }
        }
    ]
}

ESLint 檢查命名代碼塊的文件路徑,如果任何overrides條目與文件路徑不匹配,則忽略那些。一定要加的overrides,如果你想皮棉比其他命名代碼塊的條目*.js

文件擴展名處理器

如果處理器名稱以 開頭.,則 ESLint 將處理器作為文件擴展名處理器來處理,並自動將處理器應用於文件類型。人們不需要在他們的配置文件中指定文件擴展名的處理器。例如:

module.exports = {
    processors: {
        // This processor will be applied to `*.md` files automatically.
        // Also, people can use this processor as "plugin-id/.md" explicitly.
        ".md": {
            preprocess(text, filename) { /* ... */ },
            postprocess(messageLists, filename) { /* ... */ }
        }
    }
}

編寫單元測試

eslint.RuleTester是一個為 ESLint 規則編寫測試的實用程序。RuleTester構造函數接受一個可選的對象參數,它可以用來指定測試用例的默認值(官網)。例如,如果可以指定用@typescript-eslint/parser解析你的測試用例:

const ruleTester = new RuleTester({ parser: require.resolve('@typescript-eslint/parser') });

當需要解析.tsx文件時,就需要指定特定的解析器,比如@typescript-eslint/parser,因為eslint服務默認使用的解析器是esprima,它不支持對typescriptreact

如果在執行測試用例時報如下錯誤:

AssertionError [ERR_ASSERTION]: Parsers provided as strings to RuleTester must be absolute paths

這是因為解析器需要用絕對路徑 配置 ESLint RuleTester 以使用 Typescript Parser

/**
 * @fileoverview interface定義類型註釋校驗
 * @author xxx
 */
'use strict';

const rule = require('../../../lib/rules/no-interface-comments');

const RuleTester = require('eslint').RuleTester;
const ruleTester = new RuleTester({
  parser: require.resolve('@typescript-eslint/parser'),
  parserOptions: {
    ecmaVersion: 2018,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true,
    },
    comment: true,
    useJSXTextNode: true,
  },
});
ruleTester.run('no-interface-comments', rule, {
  // 有效測試用例
  valid: [
    {
      code: `
        export const Main = (props: { name: string }) => {}
      `,
      options: ['always', { leadingCommentType: 'Block', propertyComments: { pos: 'lead', commentsType: 'Block' } }],
    },
    {
      code: `
        /** 類型 */
        export interface IType {
          id: string; // id
          name: string; // 姓名
          age: number; // 年齡
        }
      `,
      options: ['always', { leadingCommentType: 'Block', propertyComments: { pos: 'tail', commentsType: 'Line' } }],
    },
    {
      code: `
        /** 類型 */
        interface IType {
          /** id */
          id: string;
          /** 姓名 */
          name: string;
          /** 年齡 */
          age: number;
        }
      `,
      options: ['always', { leadingCommentType: 'Block', propertyComments: { pos: 'lead', commentsType: 'Block' } }],
    },
  ],
  // 無效測試用例
  invalid: [
    {
      code: `
        export interface IType {
          /** id */
          id: string;
          /** 姓名 */
          name: string;
          /** 年齡 */
          age: number;
        }
      `,
      errors: [{
        message: 'interface頭部必須加上註釋',
        type: 'TSInterfaceDeclaration',
      }],
      options: ['always', { leadingCommentType: 'Block', propertyComments: { pos: 'lead', commentsType: 'Block' } }],
      output: `
        /** 類型 */
        export interface IType {
          /** id */
          id: string;
          /** 姓名 */
          name: string;
          /** 年齡 */
          age: number;
        }
      `,
    },
    {
      code: `
        /** 類型 */
        interface IType {
          id: string;
          name: string;
          age: number;
        }
      `,
      errors: [{
        message: 'interface字段必須加上註釋',
        type: 'TSPropertySignature',
      }],
      options: ['always', { leadingCommentType: 'Block', propertyComments: { pos: 'lead', commentsType: 'Block' } }],
      output: `
        /** 類型 */
        interface IType {
          /** id */
          id: string;
          /** 姓名 */
          name: string;
          /** 年齡 */
          age: number;
        }
      `,
    },
  ],
});

yarn test執行測試用例,控制枱輸出:

image-20210810235011763.png

github傳送門:https://github.com/Revelation...

在VSCode中調試測試用例

依照下面流程在工程創建launch.json,會在根目錄下生成.vscode目錄

image.png

launch.json默認內容如下:

{
  // 使用 IntelliSense 瞭解相關屬性。
  // 懸停以查看現有屬性的描述。
  // 欲瞭解更多信息,請訪問: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "pwa-node",
      "request": "launch",
      "name": "Launch Program",
      "skipFiles": [
        "<node_internals>/**"
      ],
      "program": "${workspaceFolder}\\lib\\index.js" // debugger默認啓動的腳本
    }
  ]
}

這裏我們想調試no-interface-comments規則的測試用例,需做如下更改:

"program": "${workspaceFolder}\\tests\\lib\\rules\\no-interface-comments.js"

然後,打斷點debugger就可以調試了

image.png

項目中使用

(1)安裝eslint-plugin-pony-comments

yarn add eslint-plugin-pony-comments -D

(2)配置.eslintrc.json

{
  "extends": "plugin:@casstime/inquiry-eslint/recommended",
  "parser": "@typescript-eslint/parser",
}

(3)重啓項目,重啓eslint服務,檢視效果如下

image.png

如果在yarn start啓動項目時報了很多未更改文件的校驗錯誤,這是因為create-react-app腳手架中默認配置了eslint-loader,在start或者build時會調用eslint去將所有文件檢視一遍,參考:https://v4.webpack.docschina....

image.png

針對以上問題,這裏提供兩種解決辦法:

  • 刪除node_modules/.cache文件夾,然後重啓項目。這是因為eslint-loader默認啓動cache,將 linting 結果緩存寫入./node_modules/.cache目錄,這對於在進行完整構建時減少 linting 時間特別有用,但可能影響到start或者buildeslint的檢視


    image.png


    image.png

  • 在根目錄下config-overrides.jsoverride函數中添加config.module.rules.splice(1, 1);,因為編寫代碼和提交代碼時就會檢視,不用在項目啓動或者構建時再檢視一遍,直接幹掉eslint-loader

發佈

(1)npm 賬户登錄

npm login --registry=倉庫鏡像
# 輸入用户名/密碼/郵箱

(2)執行構建

yarn build

(3)發佈

yarn publish --registry=倉庫鏡像

如果報如下錯誤,可能是使用的郵箱不對

image.png

課外知識:Lint 簡史

Lint 是為了解決代碼不嚴謹而導致各種問題的一種工具。比如 ===== 的混合使用會導致一些奇怪的問題。

JSLint 和 JSHint

2002年,Douglas Crockford 開發了可能是第一款針對 JavaScript 的語法檢測工具 —— JSLint,並於 2010 年開源。

JSLint 面市後,確實幫助許多 JavaScript 開發者節省了不少排查代碼錯誤的時間。但是 JSLint 的問題也很明顯—— 幾乎不可配置,所有的代碼風格和規則都是內置好的;再加上 Douglas Crockford 推崇道系「愛用不用」的優良傳統,不會向開發者妥協開放配置或者修改他覺得是對的規則。於是 Anton Kovalyov 吐槽:「JSLint 是讓你的代碼風格更像 Douglas Crockford 的而已」,並且在 2011 年 Fork 原項目開發了 JSHint。《Why I forked JSLint to JSHint》

JSHint 的特點就是可配置,同時文檔也相對完善,而且對開發者友好。很快大家就從 JSLint 轉向了 JSHint

ESLint 的誕生

後來幾年大家都將 JSHint 作為代碼檢測工具的首選,但轉折點在2013年,Zakas 發現 JSHint 無法滿足自己制定規則需求,並且和 Anton 討論後發現這根本不可能在JShint上實現,同時 Zakas 還設想發明一個基於 ASTlint。於是 2013年6月份,Zakas 發佈了全新 lint 工具——ESLint。《Introducing ESLint》

ESLint早期源碼:

var ast = esprima.parse(text, { loc: true, range: true }),
    walk = astw(ast);

walk(function(node) {
    api.emit(node.type, node);
});

return messages;

ESLint 的逆襲

ESLint 的出現並沒有撼動 JSHint 的霸主地位。由於前者是利用 AST 處理規則,用 Esprima 解析代碼,執行速度要比只需要一步搞定的 JSHint 慢很多;其次當時已經有許多編輯器對 JSHint 支持完善,生態足夠強大。真正讓 ESLint 逆襲的是 ECMAScript 6 的出現。

2015 年 6 月,ES2015 規範正式發佈。但是發佈後,市面上瀏覽器對最新標準的支持情況極其有限。如果想要提前體驗最新標準的語法,就得靠 Babel 之類的工具將代碼編譯成 ES5 甚至更低的版本,同時一些實驗性的特性也能靠 Babel 轉換。 但這時候的 JSHint 短期內無法提供支持,而 ESLint 卻只需要有合適的解析器就能繼續去 lint 檢查。Babel 團隊就為 ESLint 開發了一款替代默認解析器的工具,也就是現在我們所見到的 babel-eslint,它讓 ESLint 成為率先支持 ES6 語法的 lint 工具。

也是在 2015 年,React 的應用越來越廣泛,誕生不久的 JSX 也愈加流行。ESLint 本身也不支持 JSX 語法。但是因為可擴展性,eslint-plugin-react 的出現讓 ESLint 也能支持當時 React 特有的規則。

2016 年,JSCS 開發團隊認為 ESLint JSCS 實現原理太過相似,而且需要解決的問題也都一致,最終選擇合併到 ESLint,並停止 JSCS 的維護。

當前市場上主流的 lint 工具以及趨勢圖:

16dbe9fe3f3812f5_tplv-t2oaga2asx-watermark.awebp

從此 ESLint 一統江湖,成為替代 JSHint 的前端主流工具。

參考:

平庸前端碼農之蜕變 — AST

【AST篇】手把手教你寫Eslint插件

配置 ESLint RuleTester 以使用 Typescript Parser

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.