头图

从AST原理到ESlint实践

记得要微笑
English

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

阅读 1.4k
avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1.1k 声望
1.9k 粉丝
0 条评论
你知道吗?

avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1.1k 声望
1.9k 粉丝
文章目录
宣传栏