这篇文章目的是介绍如何创建一个 ESLint 插件和创建一个 ESLint rule, 用以帮助我们更深入的理解 ESLint 的运行原理, 并且在有必要时可以根据需求创建出一个完美满足自己需求的 Lint 规则.
插件目标
禁止项目中 setTimeout 的第二个参数是数字.
PS: 如果是数字的话, 很容易就成为魔鬼数字, 没有人知道为什么是这个数字, 这个数字有什么含义.
使用模板初始化项目:
1. 安装 NPM 包
ESLint 官方为了方便开发者开发插件, 提供了使用 Yeoman 模板 (generator-eslint).
对于 Yeoman 我们只需知道它是一个脚手架工具, 用于生成包含指定框架结构的工程化目录结构.
NPM install -g yo generator-eslint
2. 创建一个文件夹:
- mkdir eslint-plugin-demo
- cd eslint-plugin-demo
3. 命令行初始化 ESLint 插件的项目结构:
yo eslint:plugin
下面进入命令行交互流程, 流程结束后生成 ESLint 插件项目框架和文件.
- ? What is your name? OBKoro1
- ? What is the plugin ID? korolint // 这个插件的 ID 是什么
? Type a short description of this plugin: XX 公司的定制 ESLint rule // 输入这个插件的描述
- ? Does this plugin contain custom ESLint rules? Yes // 这个插件包含自定义 ESLint 规则吗?
- ? Does this plugin contain one or more processors? No // 这个插件包含一个或多个处理器吗
- // 处理器用于处理 JS 以外的文件 比如. vue 文件
- create package.JSON
create lib/index.JS
create README.md
现在可以看到在文件夹内生成了一些文件夹和文件, 但我们还需要创建规则具体细节的文件.
4. 创建规则
上一个命令行生成的是 ESLint 插件的项目模板, 这个命令行是生成 ESLint 插件具体规则的文件.
yo eslint:rule // 生成 eslint rule 的模板文件
创建规则命令行交互:
- ? What is your name? OBKoro1
- ? Where will this rule be published? (Use arrow keys) // 这个规则将在哪里发布?
- ESLint Core // 官方核心规则 (目前有 200 多个规则)
- ESLint Plugin // 选择 ESLint 插件
- ? What is the rule ID? settimeout-no-number // 规则的 ID
? Type a short description of this rule: setTimeout 第二个参数禁止是数字 // 输入该规则的描述
? Type a short example of the code that will fail: 占位 // 输入一个失败例子的代码
- create docs/rules/settimeout-no-number.md
- create lib/rules/settimeout-no-number.JS
- create tests/lib/rules/settimeout-no-number.JS
加了具体规则文件的项目结构
.
├── README.md
├── docs // 使用文档
│ └── rules // 所有规则的文档
│ └── settimeout-no-number.md // 具体规则文档
├── lib // eslint 规则开发
│ ├── index.JS 引入 + 导出 rules 文件夹的规则
│ └── rules // 此目录下可以构建多个规则
│ └── settimeout-no-number.JS // 规则细节
├── package.JSON
└── tests // 单元测试
└── lib
└── rules
└── settimeout-no-number.JS // 测试该规则的文件
4. 安装项目依赖
NPM install
以上是开发 ESLint 插件具体规则的准备工作, 下面先来看看 AST 和 ESLint 原理的相关知识, 为我们开发 ESLint rule 打一下基础.
AST-- 抽象语法树
AST 是: Abstract Syntax Tree 的简称, 中文叫做: 抽象语法树.
AST 的作用
将代码抽象成树状数据结构, 方便后续分析检测代码.
代码被解析成 AST 的样子
https://astexplorer.net/ 是一个工具网站: 它能查看代码被解析成 AST 的样子.
如下图: 在右侧选中一个值时, 左侧对应区域也变成高亮区域, 这样可以在 AST 中很方便的选中对应的代码.
AST 选择器:
下图中被圈起来的部分, 称为 AST selectors(选择器).
AST 选择器的作用: 使用代码通过选择器来选中特定的代码片段, 然后再对代码进行静态分析.
AST 选择器很多, ESLint 官方专门有一个仓库列出了所有类型的选择器: https://github.com/estree/estree
下文中开发 ESLint rule 就需要用到选择器, 等下用到了就懂了, 现在知道一下就好了.
ESLint 的运行原理
在开发规则之前, 我们需要 ESLint 是怎么运行的, 了解插件为什么需要这么写.
1. 将代码解析成 AST
ESLint 使用 JavaScript 解析器 Espree https://github.com/eslint/espree 把 JS 代码解析成 AST.
PS: 解析器: 是将代码解析成 AST 的工具, ES6,react,vue 都开发了对应的解析器所以 ESLint 能检测它们的, ESLint 也是因此一统前端 Lint 工具的.
2. 深度遍历 AST, 监听匹配过程.
在拿到 AST 之后, ESLint 会以 "从上至下" 再 "从下至上" 的顺序遍历每个选择器两次.
3. 触发监听选择器的 rule 回调
在深度遍历的过程中, 生效的每条规则都会对其中的某一个或多个选择器进行监听, 每当匹配到选择器, 监听该选择器的 rule, 都会触发对应的回调.
4. 具体的检测规则等细节内容.
开发规则
规则默认模板
打开 rule 生成的模板文件 lib/rules/settimeout-no-number.JS, 清理一下文件, 删掉不必要的选项:
- module.exports = {
- meta: {
- docs: {
- description: "setTimeout 第二个参数禁止是数字",
- },
- fixable: null, // 修复函数
- },
- // rule 核心
- create: function(context) {
- // 公共变量和函数应该在此定义
- return {
- // 返回事件钩子
- };
- }
- };
删掉的配置项, 有些是 ESLint 官方核心规则才是用到的配置项, 有些是暂时不必了解的配置, 需要用到的时候, 可以自行查阅 ESLint 文档
create 方法 - 监听选择器
上文 ESLint 原理第三部中提到的: 在深度遍历的过程中, 生效的每条规则都会对其中的某一个或多个选择器进行监听, 每当匹配到选择器, 监听该选择器的 rule, 都会触发对应的回调.
create 返回一个对象, 对象的属性设为选择器, ESLint 会收集这些选择器, 在 AST 遍历过程中会执行所有监听该选择器的回调.
- // rule 核心
- create: function(context) {
- // 公共变量和函数应该在此定义
- return {
- // 返回事件钩子
- Identifier: (node) => {
- // node 是选中的内容, 是我们监听的部分, 它的值参考 AST
- }
- };
- }
观察 AST:
创建一个 ESLint rule 需要观察代码解析成 AST, 选中你要检测的代码, 然后进行一些判断.
以下代码都是通过 https://astexplorer.net/ 在线解析的.
- setTimeout(()=>{
- console.log('settimeout')
- }, 1000)
rule 完整文件
- lib/rules/settimeout-no-number.JS:
- module.exports = {
- meta: {
- docs: {
- description: "setTimeout 第二个参数禁止是数字",
- },
- fixable: null, // 修复函数
- },
- // rule 核心
- create: function (context) {
- // 公共变量和函数应该在此定义
- return {
- // 返回事件钩子
- 'CallExpression': (node) => {
- if (node.callee.name !== 'setTimeout') return // 不是定时器即过滤
- const timeNode = node.arguments && node.arguments[1] // 获取第二个参数
- if (!timeNode) return // 没有第二个参数
- // 检测报错第二个参数是数字 报错
- if (timeNode.type === 'Literal' && typeof timeNode.value === 'number') {
- context.report({
- node,
- message: 'setTimeout 第二个参数禁止是数字'
- })
- }
- }
- };
- }
- };
context.report(): 这个方法是用来通知 ESLint 这段代码是警告或错误的, 用法如上. 在这里查看 context 和 context.report() 的文档.
规则写完了, 原理就是依据 AST 解析的结果, 做针对性的检测, 过滤出我们要选中的代码, 然后对代码的值进行逻辑判断.
可能现在会有点懵逼, 但是不要紧, 我们来写一下测试用例, 然后用 debugger 来看一下代码是怎么运行的.
测试用例:
测试文件 tests/lib/rules/settimeout-no-number.JS:
- /**
- * @fileoverview setTimeout 第二个参数禁止是数字
- * @author OBKoro1
- */
- "use strict";
- var rule = require("../../../lib/rules/settimeout-no-number"), // 引入 rule
- RuleTester = require("eslint").RuleTester;
- var ruleTester = new RuleTester({
- parserOptions: {
- ecmaVersion: 7, // 默认支持语法为 es5
- },
- });
- // 运行测试用例
- ruleTester.run("settimeout-no-number", rule, {
- // 正确的测试用例
- valid: [
- {
- code: 'let someNumber = 1000; setTimeout(()=>{ console.log(11) },someNumber)'
- },
- {
- code: 'setTimeout(()=>{ console.log(11) },someNumber)'
- }
- ],
- // 错误的测试用例
- invalid: [
- {
- code: 'setTimeout(()=>{ console.log(11) },1000)',
- errors: [{
- message: "setTimeout 第二个参数禁止是数字", // 与 rule 抛出的错误保持一致
- type: "CallExpression" // rule 监听的对应钩子
- }]
- }
- ]
- });
下面来学习一下怎么在 VSCode 中调试 node 文件, 用于观察 rule 是怎么运行的.
实际上打 console 的形式, 也是可以的, 但是在调试的时候打 console 实在是有点慢, 对于 node 这种节点来说, 信息也不全, 所以我还是比较推荐通过 debugger 的方式来调试 rule.
在 VSCode 中调试 node 文件
点击下图中的设置按钮, 将会打开一个文件 launch.JSON
在文件中填入如下内容, 用于调试 node 文件.
在 rule 文件中打 debugger 或者在代码行数那里点一下小红点.
点击图中的开始按钮, 进入 debugger
- {
- // 使用 IntelliSense 了解相关属性.
- // 悬停以查看现有属性的描述.
- // 欲了解更多信息, 请访问: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
- {
- "type": "node",
- "request": "launch",
- "name": "启动程序", // 调试界面的名称
- // 运行项目下的这个文件:
- "program": "${workspaceFolder}/tests/lib/rules/settimeout-no-number.js",
- "args": [] // node 文件的参数
- },
- // 下面是用于调试 package.JSON 的命令 之前可以用, 貌似 vscode 出了点 bug 导致现在用不了了
- {
- "name": "Launch via NPM",
- "type": "node",
- "request": "launch",
- "runtimeExecutable": "npm",
- "runtimeArgs": [
- "run-script", "dev" // 这里的 dev 就对应 package.JSON 中的 scripts 中的 dev
- ],
- "port": 9229 // 这个端口是调试的端口, 不是项目启动的端口
- },
- ]
- }
运行测试用例进入断点
在
lib/rules/settimeout-no-number.JS
中打一些 debugger
点击开始按钮, 以调试的形式运行测试文件
tests/lib/rules/settimeout-no-number.JS
开始调试 rule.
发布插件
eslint 插件都是以 NPM 包的形式来引用的, 所以需要把插件发布一下:
注册: 如果你还未注册 NPM 账号的话, 需要去注册 https://www.npmjs.com/signup 一下.
登录 NPM: NPM login
发布 NPM 包: NPM publish 即可, ESLint 已经把 package.JSON 弄好了.
集成到项目:
安装 NPM 包: NPM i eslint-plugin-korolint -D
常规的方法:
引入插件一条条写入规则
- // .eslintrc.JS
- module.exports = {
- plugins: [ 'korolint' ],
- rules: {
- "korolint/settimeout-no-number": "error"
- }
- }
extends 继承插件配置:
当规则比较多的时候, 用户一条条去写, 未免也太麻烦了, 所以 ESLint 可以继承插件的配置:
修改一下 lib/rules/index.JS 文件:
- 'use strict';
- var requireIndex = require('requireindex');
- const output = {
- rules: requireIndex(__dirname + '/rules'), // 导出所有规则
- configs: {
- // 导出自定义规则 在项目中直接引用
- koroRule: {
- plugins: ['korolint'], // 引入插件
- rules: {
- // 开启规则
- 'korolint/settimeout-no-number': 'error'
- }
- }
- }
- };
- module.exports = output;
使用方法:
使用 extends 来继承插件的配置, extends 不止这种继承方式, 即使你传入一个 NPM 包, 一个文件的相对路径地址, eslint 也能继承其中的配置.
- // .eslintrc.JS
- module.exports = {
- extends: [ 'plugin:korolint/koroRule' ] // 继承插件导出的配置
- }
PS : 这种使用方式, NPM 的包名不能为 eslint-plugin-xx-xx, 只能为 eslint-plugin-xx 否则会有报错, 被这个问题搞得头疼 o(╥﹏╥)o
扩展:
以上内容足够开发一个插件, 这里是一些扩展知识点.
遍历方向:
上文中说过: 在拿到 AST 之后, ESLint 会以 "从上至下" 再 "从下至上" 的顺序遍历每个选择器两次.
我们所监听的选择器默认会在 "从上至下" 的过程中触发, 如果需要在 "从下至上" 的过程中执行则需要添加: exit, 在上文中 CallExpression 就变为 CallExpression:exit.
注意: 一段代码解析后可能包含多次同一个选择器, 选择器的钩子也会多次触发.
fix 函数: 自动修复 rule 错误
修复效果:
- // 修复前
- setTimeout(() => {
- }, 1000)
- // 修复后 变量名故意写错 为了让用户去修改它
- const countNumber1 = 1000
- setTimeout(() => {
- }, countNumber2)
在 rule 的 meta 对象上打开修复功能:
- // rule 文件
- module.exports = {
- meta: {
- docs: {
- description: 'setTimeout 第二个参数禁止是数字'
- },
- fixable: 'code' // 打开修复功能
- }
- }
在 context.report() 上提供一个 fix 函数:
把上文的 context.report 修改一下, 增加一个 fix 方法即可, 更详细的介绍可以看一下文档.
- context.report({
- node,
- message: 'setTimeout 第二个参数禁止是数字',
- fix(fixer) {
- const numberValue = timeNode.value;
- const statementString = `const countNumber = ${numberValue}\n`
- return [
- // 修改数字为变量
- fixer.replaceTextRange(node.arguments[1].range, 'countNumber'),
- // 在 setTimeout 之前增加一行声明变量的代码 用户自行修改变量名
- fixer.insertTextBeforeRange(node.range, statementString),
- ];
- }
- });
项目地址:
https://github.com/OBKoro1/eslint-plugin-korolint
呼~ 这篇博客断断续续, 写了好几周, 终于完成了!
大家有看到这篇博客的话, 建议跟着博客的一起动手写一下, 动手实操一下比你 mark 一百篇文章都来的有用, 花不了很长时间的, 希望各位看完本文, 都能够更深入的了解到 ESLint 的运行原理.
觉得我的博客对你有帮助的话, 就关注一下 / 点个赞吧!
前端进阶积累 http://obkoro1.com/web_accumulate/ , 公众号, GitHub https://github.com/OBKoro1 ,wx:OBkoro1, 邮箱: obkoro1@foxmail.com
基友带我飞
ESLint 插件是向基友 yeyan1996 学习的, 在遇到问题的时候, 也是他指点我的, 特此感谢.
参考资料:
创建规则
ESLint 工作原理探讨 https://zhuanlan.zhihu.com/p/53680918
来源: https://www.cnblogs.com/OBkoro1/p/12005940.html