从零到一详细教你自定义eslint规则和插件

5,604 阅读14分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情

上一篇文章详细地介绍了eslint如何使用和配置,如果还有不清楚的,建议先看一看这篇看完这篇文章,我不信你还对eslint一知半解,里面有关于规则和插件的详细讲解。知道了eslint如何使用和配置之后,是时候进阶一下,来看一下eslint自定义规则和插件是怎么玩的了。

耐心看完这篇文章,看完后你将会有如下收获:

  • 知道如何根据自己的需求去自定义自己的规则
  • 基本能看得懂其他开源的eslint插件的源码

什么是规则,什么是插件可能大家会弄混,这里简单解释一下,就是我们一般都是通过自定义插件来把我们自定义的规则暴露出来

为什么需要自定义插件

因为在不同的团队中,难免会遇到这样的情况,就是我们想制定的某些内部规范,在现有的官方和第三方规则rule里面都是没有的,这个时候,就只能自己动手,丰衣足食了。

举个栗子:假如项目里有一个公共方法叫publicMethod,调用的时候是这么调用的util.publicMethod,而你们团队规定调用的时候,必须要在这个方法上面加上一个特殊的注释,像下面这样:

// $team xxxx
util.publicMethod();

如果你没有写注释,或者注释不是以$team开头的,我们希望eslint可以给我们一个warning提醒一下,这个时候我们就可以自己去自定义一个规则来达到上面的效果了。

接下来,我们以上面的栗子作为需求,开发我们自己的eslint插件。

初始化插件的开发环境

创建插件开发环境最简单的方式是使用官方给我们提供的脚手架。

先全局安装两个包,执行如下命令:

npm i -g yo
npm i -g generator-eslint

上面的两个工具就是用来帮我们生成插件项目框架的,类似于vue-cli

再执行如下命令生成项目框架

yo eslint:plugin

这是一个交互式命令,需要你填写一些基本信息,如下

yo eslint:rule
? What is your name? 随便写个你的名字
? What is the plugin ID? 你的插件的id,推荐(eslint-plugin-xxx)的命名方式
? Type a short description of this plugin: 描述你的插件是干啥的
? Does this plugin contain custom ESLint rules? Yes
? Does this plugin contain one or more processors? No (这里我们用不到处理器,就直接选No)

执行完上面的命令后只是创建了插件架子,我们还需要在这个架子下创建一条规则,所以继续执行如下命令:

npx yo eslint:rule

这也是一个交互式命令,如下:

? What is your name? 还是你的名字
? Where will this rule be published? ESLint Plugin
? What is the rule ID? 你的规则id
? Type a short description of this rule: 你这条规则的描述
? Type a short example of the code that will fail: 这里写你这条规则校验不通过的案例代码

执行完上述命令后,会给我们生成如下的目录结构:

├── README.md 这是你的插件的描述文档,包括如何安装使用等
├── docs
│   └── rules 这是你的自定义规则的描述文档
│       └── my-custom-rules1.md
├── lib 源码目录
│   ├── index.js 插件的入口
│   └── rules 这里放你的每一条自定义规则
│       └── my-custom-rules1.js
├── package.json
├── pnpm-lock.yaml
└── tests 测试目录
    └── lib
        └── rules
            └── my-custom-rules1.js

其实你如果不想安装那个脚手架,就按照上面的目录结构自己创建也是可以的。

上面的操作,相信大家都看得懂了,其实就是跟着步骤做就可以了,这是eslint给我们规范好的开发插件的标准流程。

开始动手前你需要先了解的

eslint的自定义规则和自定义插件是要遵循官方给定的格式的

rule的基本格式

一个规则的源码基本格式如下,不要问为什么,因为它就是这样定的

module.exports = {
    // meta里面的元数据,对于我们自定义规则,其实只关心schema就行了
    meta: {
        // 规则的类型problem|suggestion|layout
        // problem: 这条规则识别的代码可能会导致错误或让人迷惑。应该优先解决这个问题
        // suggestion: 这条规则识别的代码不会导致错误,但是建议用更好的方式
        // layout: 表示这条规则主要关心像空格、分号等这种问题
        type: "suggestion",
        // 对于自定义规则,docs字段是非必须的
        docs: {
            description: "描述你的规则是干啥的",
            // 规则的分类,假如你把这条规则提交到eslint核心规则里,那eslint官网规则的首页会按照这个字段进行分类展示
            category: "Possible Errors",
            // 假如你把规则提交到eslint核心规则里
            // 且像这样extends: ['eslint:recommended']继承规则的时候,这个属性是true,就会启用这条规则
            recommended: true,
            // 你的规则使用文档的url
            url: "https://eslint.org/docs/rules/no-extra-semi"
        },
        // 标识这条规则是否可以修复,假如没有这属性,即使你在下面那个create方法里实现了fix功能,eslint也不会帮你修复
        fixable: "code",
        // 这里定义了这条规则需要的参数
        // 比如我们是这样使用带参数的rule的时候,rules: { myRule: ['error', param1, param2....]}
        // error后面的就是参数,而参数就是在这里定义的
        schema: []
    },
    create: function(context) {
        // 这是最重要的方法,我们对代码的校验就是在这里做的
        return {
            // callback functions
        };
    }
};

关于create方法

这里我们着重讲一下这个create方法,create方法返回的是一个对象,这个对象是干什么用的呢?我们得从AST讲起。为了让大家更容易理解,我尽量用通俗易懂的例子

eslint校验一个代码文件的时候,你可以简单理解成分为下面几个步骤:

  • 首先是读取这个文件的内容,比如读取a.js,你可以理解成读出来一个字符串。
  • 然后eslint会创建一个对象,假设是如下对象
const astObj = {
    type: 'Program', // 这个一段程序
    start: 0, // 从第0行开始
    end: 100, // 第100行结束
    body: [], // 这里存放后面具体解析出来的代码内容
    sourceType: 'module' // 这是一个模块
};
  • 然后就是一行行去解析这个字符串,比如第一行定义了一个变量let a = 1;,我们称之为VariableDeclaration(变量定义),此时会执行类似以下的操作
const curAst = {
    type: 'VariableDeclaration', // 标识这是一个变量定义的节点
    // ....后面还有很多字段
};
// 将当前解析好的节点push进去astObj的body里面
astObj.body.push(curAst);
  • 假设下一行遇到一个函数定义function test() {},此时会执行以下操作
const curAst = {
    type: 'FunctionDeclaration', // 标识这是一个函数定义的节点
    body: []
    // ...后面还有很多内容,且函数也有自己的body,eslint的解析器会继续递归解析函数体的内容,然后push到这个节点的body里
}
// 将当前解析好的节点push进去astObj的body里面
astObj.body.push(curAst);
  • 经过上面的不断递归解析操作,就构建出一棵ast抽象语法树了,具体转换后的结果可以自行到ast Explorer上自行尝试,如下图所示 eslint转换结果
  • 构建好ast之后,就可以对这棵树进行遍历了,遍历的时候,可以给这个遍历的方法传一个访问者对象,当遍历到某个类型的节点的时候,就会调用这个访问者对象对应的方法,类似下面的例子:
// 访问者对象,我们可以在这里定义我们想要处理的节点,当访问到这个节点的时候,就会调用响应的方法
const visitor = {
    // 当遍历的时候遇到type为FunctionDeclaration的节点的时候,就会调用这个方法,并把当前的节点传进来
    'FunctionDeclaration': function(node) {},
    // 当遍历的时候遇到type为CallExpression的节点的时候,就会调用这个方法,并把当前的节点传进来
    'CallExpression': function(node) {},
};
// astObj是上面构建出来的抽象语法树
traverseAst(astObj, visitor);

至此,关于ast的转换和遍历都讲完了,我相信大家应该理解了,接下来我们回到上面的那个create方法,其实上面的create方法返回的对象,就是我刚刚所说的访问者visitor对象。

假如说我的自定义规则想校验关于函数调用的语句如下:

obj.doSomething();

那这条语句对应的ast节点类型就是CallExpression,所以我们的create方法返回的visitor对象就可以像下面这样写

create(context) {
    return {
        // 当遇到函数调用的时候,就会调用此方法,并把当前节点传进来
        'CallExpression': function(node) {
            // 这里写具体的校验逻辑
        }
    }
}

大致的处理流程已经讲清楚了,当然,实际的ast转换遍历比我上面的讲的要复杂的多,但是原理基本都是类似的。

最后还要再讲一下create方法的入参context,这个context对象包含了我们这条自定义规则的上下文相关的信息,我只举几个常用的如下

  • parserOptions-编译器选项,就是我们.eslintrc.js里的那个
  • id-规则id
  • options-通过这个可以拿到规则传进来的参数
  • getFilename()-返回源文件文件名
  • getScope()- 返回当前遍历节点的作用域
  • getSourceCode()- 返回SourceCode对象,就是源码对象,很有用
  • report()- 当校验不通过的时候,通过这个方法输出错误信息

开始写真正的规则代码

通过上面的前置知识介绍,现在可以真正的开始写我们规则的代码了,回到我们刚刚通过脚手架创建的目录,打开lib/rules/yourRules.js,这个是你自定义规则的存放目录,后续要新增规则,直接再这个目录下创建就行。

我们先回顾一下我们的需求,我们要写一个校验方法调用的时候是否有加特定注释的规则,我们预期的规使用方式如下:

// .eslintrc.js
{
    rules: {
        'myCustomPlugin/myRule1': [
            'error',
            {
                objName: 'test', //这里配置对象名
                propNames: ['testCall'], //这里配置对象的方法名,调用这些方法必须写注释,否则规则会报错
                commentName: '$test', //这里是你要写的注释的开头,也就是说,当调用test.testCall()时,你必须写注释,且注释必须以$test开头才算合法
            }
        ]
    }
}

当你的代码里有以下语句的时候就会报错或者告警,因为没有加注释

test.testCall();

当你的代码像下面这样写,则会被我们的规则认为是合法的

// $test 这里随便写注释
test.testCall()

具体代码如下,详细解析看注释即可:

module.exports = {
    meta: {
        type: 'suggestion',
        docs: {
            description: "调用某些对象方法时需要加上注释",
        },
        // 我们需要使用这个规则的时候给我们传递参数,具体的参数含义可以看上面👆🏻
        schema: [{
            type: 'object',
            properties: {
                objName: {
                    type: 'string',
                },
                propNames: {
                    type: 'array',
                },
                commentName: {
                    type: 'string',
                }
            }
        }],
        // 报错或者告警的时候的提示语
        messages: {
            callWithoutComment: '"{{ callMethod }}"上需加上"{{ comment }}"开头的注释',
            // 带占位符的提示信息
            callWithoutRightComment: '"{{ callMethod }}"上的注释"{{ realComment }}"并非"{{ comment }}"开头' // 带占位符的提示信息
        }
    },
    create(context) {
        // 这里拿到规则传进来的参数
        const options = context.options[0];
        // 解构出对象名,方法名和注释名
        const {
            objName,
            propNames,
            commentName
        } = options;
        // 通过sourceCode可以拿到当前节点的很多信息,比如当前节点的注释
        const sourceCode = context.getSourceCode();
        // 以下是visitor对象,什么是visitor对象可以看看上面的介绍
        return {
            // 当有方法调用的时候,就会调用此方法且把当前节点传进来
            // 比如abc.doSometing这个语句就会调用此方法
            CallExpression(node) {
                // 比如是abc.doSomething
                // curObjName就是abc,curProName就是doSomething
                const curObjName = node.callee ? .object ? .name;
                const curPropName = node.callee ? .property ? .name;
                if (!objName || !propNames) {
                    return;
                }
                // 如果不是我们在规则参数里指定的对象名和方法名,则无需校验直接通过
                if (curObjName !== objName || !propNames ? .includes(curPropName)) {
                    return;
                }
                // 通过sourceCode获取当前这个语句的注释,是一个数组
                const comments = sourceCode.getCommentsBefore(node) || [];
                const curComment = comments.map(comment = >comment.value).join(' ').trim();
                // 假如没加注释,则报错
                if (curComment ? .trim() === '') {
                    context.report({
                        node,
                        messageId: 'callWithoutComment',
                        data: {
                            callMethod: `$ {
                                curObjName
                            }.$ {
                                curPropName
                            }`,
                            comment: commentName,
                        }
                    });
                    return;
                }
                // 假如加了注释,且是合法的注释,则通过
                if (curComment.includes(commentName)) {
                    return;
                }
                // 假如加了注释,但是不是合法的注释,也报错
                context.report({
                    node,
                    messageId: 'callWithoutRightComment',
                    data: {
                        callMethod: `$ {
                            curObjName
                        }.$ {
                            curPropName
                        }`,
                        comment: commentName,
                        realComment: curComment,
                    }
                });
            }
        };
    },
};

测试我们的代码

经过上面之后,我们已经把主要代码写完了,此时需要给我们的代码写一些测试案例,做点单元测试,打开test/lib/rules/yourRule.js,假如你后续新增了一条规则,直接在这个目录下增加相应的测试文件即可。

具体测试代码如下:

const rule = require("../../../lib/rules/yourRule"),
RuleTester = require("eslint").RuleTester;
// 这是传给我们的规则的参数
const options = [{
    objName: 'test',
    propNames: ['testCall', 'testCall2'],
    commentName: '$test',
}]
const ruleTester = new RuleTester();
ruleTester.run("youRule", rule, {
    // 这是合法的可以通过规则的案例
    valid: [{
        code: ` // $test
        test.testCall('xxx');`,
        // 传给规则的参数
        options
    },
    {
        code: ` // $test注释
        test.testCall2('xxx');`,
        // 传给规则的参数
        options
    },
    ],
    invalid: [
    {
        code: ` //
        test.testCall('xxx');`,
        errors: [{
            message: '"test.testCall"上需加上"$test"开头的注释',
            type: "CallExpression"
        }],
        options
    },
    {
        code: ` // $test xxx2
        console.log(100)
        test.testCall('xxx');`,
        errors: [{
            message: '"test.testCall"上需加上"$test"开头的注释',
            type: "CallExpression"
        }],
        options
    },
    {
        code: `
        console.log(100)
        test.testCall('xxx')
        // $note xxx3
        `,
        errors: [{
            message: '"test.testCall"上需加上"$test"开头的注释',
            type: "CallExpression"
        }],
        options
    },
    {
        code: `
        // xxxx
        test.testCall('xxx');
        `,
        errors: [{
            message: '"test.testCall"上的注释"xxxx"并非"$test"开头',
            type: "CallExpression"
        }],
        options
    },
    ],
});

此时就可以直接跑我们的测试案例的,你可以通过node xxx.js的方式跑,也可以通过vscode来debug。

假如你通过vscode的debug来跑,具体的操作顺序是

点击debug按钮->点创建launch.json->选择Node环境->将launch.json里面的program字段的路径改成你的测试代码的路径即可。

在项目里面使用你的插件

当你通过上面的步骤跑完测试案例没有问题之后,就可以将你的eslint发布到npm上了,具体如何发布,其实就是简单的两步。

npm login
#根据提示登录完之后
npm publish

发布了之后,你就可以在你的项目里安装使用你的插件了,比如你的自定义插件名叫eslint-plugin-myCustomPlugin

安装

npm i -D eslint-plugin-myCustomPlugin

使用

// .eslintrc.js
module.exports = {
    // 你的插件
    plugins: ['myCustomPlugin'],
    rules: {
        // 你的具体开启的规则
        'myCustomPlugin/myRule': [
            'error',
            // 你的规则参数
            {
                objName: 'test',
                propNames: 'testCall',
                commentName: '$test'
            }
        ],
    },
};

使用效果如下,可以看出,当代码里有test.testCall();但是右没有加$test开头的注释的时候,我们的规则就会给这行代码报错提示了: 具体使用效果

大功告成,完结撒花。等等,还没完,以上说的都是自定义规则,还没说自定义插件呢。

自定义插件

假如说我们自己定义了多条规则,但是像上面的.eslintrc.js里面要在rules里面一条条手动开启也太麻烦了,此时我们就可以通过插件的config选项去将我们的规则暴露出去了。

实际上自定义插件和自定义规则一样,也是要遵循eslint的固定模板的,如下所示:

插件必须要有一个rules对象,里面包含我们的自定义的规则

module.exports = {
    // 这是插件一定要有的字段
    // 里面的规则,就是刚刚我们上面写的规则
    rules: {
        "yourRule1": {
            create: function (context) {
                // 这里实现具体代码
            }
        },
    }
};

还有一个重要的选项configs,这是让我们在项目里面不用一条条开启规则的关键,如下所示

我们就是通过config去封装并开启好多个规则,暴露出去给别的项目使用的,直白点将,就是我们写好了一个.eslintrc.js,放到configs这个对象里,然后发布到npm上,让别人可以直接使用

module.exports = {
    // 里面的一个个config,其实就相当于一个个.eslintrc.js里面的配置
    myConfig: {
        plugins: ["myPlugin"],
        env: ["browser"],
        rules: {
            semi: "error",
            "myPlugin/my-rule": "error",
            "eslint-plugin-myPlugin/another-rule": "error"
        }
    },
    myOtherConfig: {
        plugins: ["myPlugin"],
        env: ["node"],
        rules: {
            "myPlugin/my-rule": "off",
            "eslint-plugin-myPlugin/another-rule": "off"
            "eslint-plugin-myPlugin/yet-another-rule": "error"
        }
    }
};

通过上面的介绍之后,再回到我们刚刚的插件项目的代码,我们打开lib/index.js这个文件,这就是自定义插件的源码文件。

可以看到现在只有两行代码,这两行代码是脚手架给我们生成的,仅仅是把自定义的规则暴露出去了,并没有设置configs字段

const requireIndex = require("requireindex");
// 在这里导入了我们上面写的自定义规则,并且一起导出去了
module.exports.rules = requireIndex(__dirname + "/rules");

我们改造一下上面的代码,如下所示

const requireIndex = require("requireindex");
// 在这里导入了我们上面写的自定义规则
const rules = requireIndex(__dirname + "/rules");
module.exports = {
    // rules是必须的
    rules,
    // 增加configs配置
    configs: {
        // 配置了这个之后,就可以在其他项目中像下面这样使用了
        // extends: ['plugin:pluginName/recommended']
        recommended: {
            // 需要注意,这里一定要有env字段,否则eslint会认为这个config不合法
            env: ['browser'],
            // 这里面其实就相当于一份你写好的.eslintrc.js文件
            // 你可以使用自己的规则,也可以在这里包含eslint的核心规则和第三方开源规则
            plugins: ['yourPluginName'],
            rules: {
                'youPluginName/yourRule1': ['error'],
                'youPluginName/yourRule2': ['error'],
            }
        }
    }
}

修改完之后,就可以发布到npm上了,这样一个自定义的插件就完成了,回到项目里修改使用方式:

原先没有configs的时候,我们是这样用的

module.exports = {
    // 你的插件
    plugins: ['myCustomPlugin'],
    rules: {
        // 你的具体开启的规则
        'myCustomPlugin/myRule': [
            'error',
            // 你的规则参数
            {
                objName: 'test',
                propNames: 'testCall',
                commentName: '$test'
            }
        ],
    },
};

现在你只需要像下面这样用,看起来就清爽多了,这样就算你自定义了很多条规则,使用起来也只需像下面一样一行搞定

module.exports = {
    // 你的插件
    extends: ['plugin:yourPluginName/recommended'],
};

总结

总的来说就是五步走:

  • 通过脚手架创建项目框架
  • 在规则目录下新增规则
  • 在create方法的返回对象里找到自己需要处理的ast节点,写相应的校验逻辑
  • 测试验证自己的逻辑
  • 如果有需要的话,在插件的源码文件里增加configs配置,批量暴露规则

其实关于自定义插件和自定义规则,核心的地方还是自定义规则,本质上自定义插件最核心的就是把自定义规则暴露出去。而自定义规则则需要你自己去看ast的字段,根据自己需要来写自己的规则代码。

相关文章推荐: