ESLint 插件开发基础

本文介绍 ESLint 插件开发相关基础知识,包括eslint的基础配置、插件规则的工作原理,创建一个eslint插件项目,从开发、测试到发布的整个流程。

使用与原理

先简单介绍 ESLint 的基础使用和工作原理。

配置简介

下面是 ESLint 配置文件的常见字段:

// .eslintrc.json
{
   parser :  babel-eslint ,
   parserOptions : {
     ecmaVersion : 2018,
     sourceType :  module ,
  },
   extends : [ eslint:recommended ],
   plugins : [ @byted/check-css-modules ],
   rules : {
     no-console :  off ,
     @byted/check-css-modules/no-unused-class : [2, {
         markAsUsed : [ usedClasseA ,  usedClasseB ],
         ignorePattern :  ^keyframes 
     }],
  },
   settings : {
     @byted/check-css-modules : {
       include :  **/*.module.less 
    }
  }
  // ...
}
复制代码
  • parser: 解析器,默认使用 Espree。如果你使用了 Babel 或者 TypeScript,则需要配置对应的解析器完成代码解析,比如 babel-eslint@typescript-eslint/parser
  • parserOptions: 解析器选项。配置 ESLint 如何解析 JS 代码,ecmaVersion 表示ECMAScript 语法版本,sourceType 表示模块类型,
  • plugins: 添加插件。可省略插件名称中的 eslint-plugin- 前缀,但 @scope 不能省略。添加插件后即可以在 rules 字段配置规则。
{
     plugins : [
         react , // 即 eslint-plugin-react
         @typescript-eslint , // 即 @typescript-eslint/eslint-plugin
         @byted/check-css-modules  // 即 @byted/eslint-plugin-check-css-modules
    ]
}
复制代码
  • rules: 关闭单条规则( off 0)、开启规则同时设置错误级别( warn 1 error 2)、设置配置项。
  • extends: 用于开启一系列预设的规则。这里可以添加两类预设:

    • 配置包。配置包用于专门导出共享的配置内容。配置 extends 时名称包含 eslint-config- 可省略,比如 airbnb 即为 eslint-config-airbnb
    • 插件包。插件包通常输出一系列规则,但同时还能导出一个或多个命名配置供用户选择,这些配置与配置包👆🏻的导出内容一致。配置到extends 时格式为 plugin:包名/配置名称 ,包名可省略 eslint-plugin- 前缀。
{
     plugins : [ react ],
     extends : [
         airbnb , // 即 eslint-config-airbnb
         plugin:react/recommended  // 即 eslint-plugin-react 中 exports.configs.recommended
    ],
}
复制代码
  • settings : 设置指定插件的配置选项,具体配置选项与插件定义相关。同样可省略 eslint-plugin- 前缀。

AST

ESLint 默认使用 espree 解析JS代码生成AST(Abstract Syntax Tree,抽象语法树),AST explorer 可查看生成的 AST:

Rule 如何工作

ESLint 每条规则都是独立的,并可以设置规则级别:关闭off/0、警告warn/1或错误error/2

每条规则即是如下👇🏻这样一个对象定义,create 方法返回不同 AST 节点类型的访问器函数,ESLint 遍历 AST 节点时会执行同名的访问器,访问器用于检测代码是否符合规则的期望,如果不满足就可以通过 context.report() 想 ESLint 报告问题,然后 ESLint 根据规则级别对上报的问题做不同的展示。

module.exports = {
    meta: {
        // ...
        messages: {
            unexpected:  Unexpected 'debugger' statement. 
        }
    },

    create(context) {

        return {
            DebuggerStatement(node) {
                context.report({
                    node,
                    messageId:  unexpected 
                });
            }
        };

    }
};
复制代码

以上面 ESLint no-debugger Rule 源码为例,开启 no-debugger 表示代码中禁止出现 debugger 语句。由于 debugger 语句解析后的 AST 节点类型为 DebuggerStatement,因此DebuggerStatement 的访问器中上报了 Unexpected 'debugger' statement. 消息。

ESLint 如何工作

ESLint 根据配置文件搜集所有已配置规则的访问器,ESLint 在遍历 AST 过程中会执行执行这些访问器,从而完成检测代码、发现问题与问题上报,并根据规则级别对上报的问题做不同的展示。

具体实现

示例插件

这里以本人的一个CSS Modules ESLint 插件为例,此插件中存在一个 no-unused-class 规则用于检查 CSS Modules 中是否存在未使用到的 classNames。它工作的基本原理为 eslint 遍历 AST Tree 过程中,插件处理const tyles fro``m 'xx.less' 语句对应的 AST Node,读到获取文件完整路径后读取对应的xx.less.d.ts文件中 className 类型声明,从而搜集到 CSS Modules 中导出的 classNames。而遍历到 styles.a 之类的表达式的 AST node 时,将对应的 className 标记为已使用。eslint 程序退出后报告所有未使用的 classNames。

module.exports = {
  plugins: ['@byted/check-css-modules'],
  rules: {
    // 校验是否存在未使用的className
     @byted/check-css-modules/no-unused-class : 'error',
    // 添加配置
     @byted/check-css-modules/no-unused-class : ['error', {
         markAsUsed : ['usedClasseA', 'usedClasseB'], // 对特定的className标记为已使用 
     }],
  },
};
复制代码

项目初始化

# 安装脚手架
npm install -g yo generator-eslint

# 创建项目目录
mkdir eslint-plugin-check-css-modules
cd eslint-plugin-check-css-modules

# 生成 eslint 插件项目
yo eslint:plugin

# 安装项目依赖
npm install

# 添加规则
yo eslint:rule
复制代码

默认初始化后项目并不支持 typescript,添加 typescript 配置文件后的项目结构如下:

├── node_modules
├── dist                               # 编译输出目录
├── lib                                # 工作目录
│   ├── rules                         # eslint规则
│   │    ├── index.ts
│   │    ├── ...        
│   │    └── no-unused-class.ts
│   ├── index.ts                       # 主入口
│   └── types.ts
├── tests                               # 测试用例目录
│   └── rules
│         └── no-unused-class.test.ts
├── package.json
├── tsconfig.json                       # 开发与测试时的ts配置文件
├── tsconfig.build.json                 # 编译时的ts配置文件
└── README.md
复制代码

相关库

  • estree:包含了所有 AST 节点的 ts 类型。
  • ts-node:Node 不能解析 typescript 格式写的测试用例。
  • json-schema:包含了 Rule.meta.schema 的 ts 类型。
  • anymatch:用于校验字符串与指定的模式(正则/glob/字符串)是否匹配,常用于include/exclude 的校验。

规则实现

在 【Rule 如何工作】部分已经介绍了规则定义需要导出 meta 字段和 create 方法。

module.exports = {
  meta: {
    type: null,
    docs: {
      description:  Ensures that any referenced class is exported by css files. ,
      recommended: false,
      url: null,
    },
    fixable: null,
    schema: [],
  },

  create(context) {
    // ...
    return {
      // visitor functions for different types of nodes
    };
  },
};
复制代码

meta

meta 属性包含了当前规则定义的元数据信息,下面介绍一些常用的选项,更多选项可查看这里

  • type: 设置规则的类型。可选值有:

    • problem :问题,需要优先解决;
    • suggestion :建议。表示可以有更好的处理方式;
    • layout :表示当前规则用于检测代码风格,如空格、分号等。
  • docs:设置规则的说明信息。

    • description:规则作用描述。
    • recommended:ESLint 配置文件添加 extends : eslint:recommended 是否启用当前规则。
    • url:规则文档访问链接。
  • fixable:执行 eslint --fix 时自动修复规则报告的问题。

  • schema: 规则设置独立配置选项,为避免无效的规则配置,这里可设置配置选项的校验规则。下面会具体介绍👇🏻。

schema

ESLint 内部通过 JSON Schema中文文档)来使用 schema 的配置,并校验 Rule 开启时实际传入的选项。

module.exports = {
  plugins: ['@byted/check-css-modules'],
  rules: {
     @byted/check-css-modules/no-unused-class : [2, {
         markAsUsed : ['usedClasseA', 'usedClasseB'], // 对特定的className标记为已使用
         ignorePattern : '^keyframes' // 对正则匹配到特定的className进行忽略((new RegExp(ignorePattern)).test(className)) 
     }],
  },
};
复制代码

比如上面示例代码中规则 @byted/check-css-modules/no-unused-class 可以传入两个选项 markAsUsedignorePattern 。对应的 meta.s``chema 配置为:

module.exports = {
  meta: {
    // ...
    schema: [
        {
            type:  object ,
            properties: {
                markAsUsed: { type:  array  },
                ignorePattern: { type:  string  },
            },
            additionalProperties: false, // 规则配置中仅允许存在 properties 定义的选项
        }
    ],
  },

  create(context) {
    return {
      // visitor functions for different types of nodes
    };
  },
};
复制代码

additionalProperties 也是常用的配置,表示是否允许添加 properties 内以外的属性。

create

通过此方法注册节点访问器完成代码校验,若不符合期望则上报或修复问题。

context

context 对象包含与规则上下文相关的信息,下面是几个常用属性和方法(详细了解查看这里):

  • options: 可获取 ESLint 所有插件或单条规则的配置选项。

以下面配置为例:

// .eslintrc.json
{
   plugins : [ @byted/check-css-modules ],
   rules : {
     @byted/check-css-modules/no-unused-class : [2, {
         markAsUsed : [ usedClasseA ,  usedClasseB ],
         ignorePattern :  ^keyframes 
     }],
  },
   settings : {
     @byted/check-css-modules : {
       include :  **/*.module.less ,
       exclude :  **/node_modules/**/* 
    }
  }
}
复制代码
// 获取规则配置
const markAsUsed = context.options[0]?.markAsUsed || [];
const ignorePattern = context.options[0]?.ignorePattern;
// 获取插件配置
const settings = context.settings[ '@byted/check-css-modules' ];
const { include, exclude } = settings || {};
复制代码
  • getFilename() : 返回源码关联的文件名。

  • getSourceCode():返回一个SourceCode对象,对象包含了一系列的属性和方法用于处理源代码(详细了解查看这里)。

  • report():用于上报或修复问题(详细参数可查看这里),下面是常见上报的示例👇🏻:

仅上报问题

module.exports = {
    meta: {
        messages: {
            avoidName:  Avoid using variables named '{{ name }}' ,
        },
    },
    create(context) {
        // ...
        return {
             Identifier (node) {
                if (node.name !==  foo ) return;
                // 方式1:常规上报
                context.report({
                    node: node,
                    message: `Avoid using variables named '${node.name}'`,
                });
                // 方式2:使用占位符
                context.report({
                    node: node,
                    message:  Avoid using variables named '{{ name }} ,
                    data: { name: node.name },
                });
                // 方式3:使用 messageIds
                context.report({
                    node: node,
                    messageId:  avoidName ,
                    data: { name: node.name },
                });
            }
        };
    }
};
复制代码

上报并修复问题

如果需要修复问题,需要添加fix方法。它的参数接收一个fixer对象,此对象包含了众多方法用于修复代码问题,比如 insertTextAfter 表示在指定定节点后插入文本👇🏻:

context.report({
    node: node,
    message:  Missing semicolon ,
    fix(fixer) {
        return fixer.insertTextAfter(node,  ; );
    }
});
复制代码

上报并提供建议

这种情况意味着自动修复可能更改功能或其他不可预测的问题。这里不深入介绍,详细了解可查看这里

result

result 是 create 方法的返回的一系列访问器,访问器的名称是节点类型名称、选择器或事件名称。

create: function(context: Rule.RuleContext) {
    // declare the state of the rule
    return {
        ReturnStatement: function(node: ESTree.Node) {
            // ...
        },
        // at a function expression node while going up:
         FunctionExpression:exit : checkLastSegment,
         ArrowFunctionExpression:exit : checkLastSegment,
        onCodePathStart: function (codePath, node) {
            // at the start of analyzing a code path
        },
        onCodePathEnd: function(codePath, node) {
            // at the end of analyzing a code path
        }
    };
}
复制代码

由于 ESLint 遍历 AST 的时候先向下遍历再向上遍历。所以

  • 如果 key 是节点类型或选择器,访问器会在ESLint 向下遍历时调用。如上面示例中的 ReturnStatement
  • 如果 key 是节点类型或选择器,且添加了 :exit,则访问器会在ESLint 向上遍历时调用。如上面示例中的 FunctionExpression:exitArrowFunctionExpression:exit
  • 如果 key 是事件名称,ESLint 会调用该访问器进行代码路径分析。如示例中的 onCodePathStartonCodePathEnd
选择器

AST 选择器用于匹配特定条件下的节点。

var foo = 1;
bar.baz();
复制代码

假设访问器 key 为Identifier(){} 将匹配到所有节点类型为Identifier 的节点。上面的示例代码中则匹配到 foobarbaz,如果仅仅想匹配到 foo。则可以设置访问器的key设置为 VariableDeclarator > Identifier ,因为 var 的AST 节点类型为 VariableDeclaratorfoovar 的下一个节点。

选择器的语言非常类似 CSS 选择器,更多选择器语言可查看这里

代码路径分析

代码路径分析是对条件分支语句、循环语句等做分析。这块个人感觉不太常用到。想了解可查看官方文档 code-path-analysis 部分的内容

测试

RuleTester

ESLint 提供了 RuleTester 工具简化规则测试。每组测试用例包含至少包含一个 有效用例和无效用例(见validinvalid),每个用例配置中可以设置运行测试的选项(如 parserOptionsfilename)下面是一个简单示例:

import { RuleTester } from 'eslint';
import rule from '../../lib/rules/no-unused-class';
import path from 'path';

const filename = path.resolve(__dirname, '../../files/foo.ts');
function test(config) {
    return Object.assign({
        parserOptions: {
            sourceType: 'module',
            ecmaVersion: 6,
            ecmaFeatures: { jsx: true },
        },
        filename,
    }, config);
};

const ruleTester = new RuleTester();
ruleTester.run('no-unused-class', rule, {
  valid: [
    test({
      code: `
        import s from './noUnusedClass1.module.less';
        export default Foo = () => (
          <div className={s.container}></div>
        );
      `,
    }),
    // 其他有效用例
  ],
  invalid: [
    test({
      code: `
        import s from './noUnusedClass1.module.less';
        export default Foo = () => (
          <div className={s.bar}></div>
        );
      `,
      // 无效用例必须包含 errors 以预测上报的问题
      errors: [
        {
          messageId: 'unusedClassName',
          data: { classNames: 'container' },
        }
      ]
    }),
    // 其他无效用例
  ],
});
复制代码

导出插件

ESLint 插件包需要在主入口文件规则配置。

Rules

rules 属性导出插件中的所有规则。

// lib/index.ts
const allRules = {
  'no-unused-class': require('./lib/rules/no-unused-class'),
  'no-undef-class': require('./lib/rules/no-undef-class'),
};

export.exports = {
    rules: allRules, // 必须
    configs: {/* ... */} // 非必须
}
复制代码

Configs

通常情况下配置包输出预设配置、插件包通常输出规则。如果一个插件包中的规则过多,需要一个个去添加/配置也是非常不便的事情。因此插件包也可以导出一个或多个命名配置供用户选择,使用时以 plugin:包名/配置名称 的格式(包名可省略 eslint-plugin- )添加到 ESLint 配置文件的 extends 字段中。这里以一个规则较多的插件 eslint-plugin-react 作为示例:

使用:

// .eslintrc.json
{
     plugins : [ react ],
     extends : [
         plugin:react/recommended  // 即 eslint-plugin-react 中 exports.configs.recommended
        //  plugin:react/all  // 即 eslint-plugin-react 中 exports.configs.all
    ],
}
复制代码

配置:

// lib/index.ts
const configs = {
  recommended: {
    recommended: {
      plugins: [
        'react',
      ],
      parserOptions: {
        ecmaFeatures: {
          jsx: true,
        },
      },
      rules: {
        'react/display-name': 2,
        'react/jsx-key': 2,
        'react/jsx-no-comment-textnodes': 2,
        // ...
      },
    },
    all: {
      plugins: [
        'react',
      ],
      parserOptions: {
        ecmaFeatures: {
          jsx: true,
        },
      },
      rules: activeRulesConfig,
    },
    // 其他预设配置
  },
};
export.exports = {
    rules: allRules,
    configs: configs
}
复制代码

打包

 scripts : {
    build :  npm run lint && npm run test && rm -rf dist && tsc -p tsconfig.build.json ,
    lint :  eslint . ,
    lint:fix :  eslint . --fix ,
    test :  mocha --require ts-node/register 'tests/**/*.test.ts' 
  },
复制代码

发布

 scripts : {
    // ...
    release:alpha :  npm version prerelease --preid=alpha && npm publish --tag alpha --registry=http://bnpm.byted.org ,
    release :  npm version patch && npm publish --registry=http://bnpm.byted.org 
  },
复制代码

推荐阅读 维护一个 npm 包

参考

分类:
前端
标签: