這篇文章目的是介紹如何創(chuàng)建一個(gè)ESLint插件和創(chuàng)建一個(gè)ESLint rule,用以幫助我們更深入的理解ESLint的運(yùn)行原理,并且在有必要時(shí)可以根據(jù)需求創(chuàng)建出一個(gè)完美滿足自己需求的Lint規(guī)則。
插件目標(biāo)
禁止項(xiàng)目中setTimeout的第二個(gè)參數(shù)是數(shù)字。
PS: 如果是數(shù)字的話,很容易就成為魔鬼數(shù)字,沒(méi)有人知道為什么是這個(gè)數(shù)字, 這個(gè)數(shù)字有什么含義。
使用模板初始化項(xiàng)目:
1. 安裝NPM包
ESLint官方為了方便開(kāi)發(fā)者開(kāi)發(fā)插件,提供了使用Yeoman模板(generator-eslint)。
對(duì)于Yeoman我們只需知道它是一個(gè)腳手架工具,用于生成包含指定框架結(jié)構(gòu)的工程化目錄結(jié)構(gòu)。
npm install -g yo generator-eslint
2. 創(chuàng)建一個(gè)文件夾:
mkdir eslint-plugin-demo
cd eslint-plugin-demo
3. 命令行初始化ESLint插件的項(xiàng)目結(jié)構(gòu):
yo eslint:plugin
下面進(jìn)入命令行交互流程,流程結(jié)束后生成ESLint插件項(xiàng)目框架和文件。
? What is your name? OBKoro1
? What is the plugin ID? korolint // 這個(gè)插件的ID是什么
? Type a short description of this plugin: XX公司的定制ESLint rule // 輸入這個(gè)插件的描述
? Does this plugin contain custom ESLint rules? Yes // 這個(gè)插件包含自定義ESLint規(guī)則嗎?
? Does this plugin contain one or more processors? No // 這個(gè)插件包含一個(gè)或多個(gè)處理器嗎
// 處理器用于處理js以外的文件 比如.vue文件
create package.json
create lib/index.js
create README.md
現(xiàn)在可以看到在文件夾內(nèi)生成了一些文件夾和文件,但我們還需要?jiǎng)?chuàng)建規(guī)則具體細(xì)節(jié)的文件。
4. 創(chuàng)建規(guī)則
上一個(gè)命令行生成的是ESLint插件的項(xiàng)目模板,這個(gè)命令行是生成ESLint插件具體規(guī)則的文件。
yo eslint:rule // 生成 eslint rule的模板文件
創(chuàng)建規(guī)則命令行交互:
? What is your name? OBKoro1
? Where will this rule be published? (Use arrow keys) // 這個(gè)規(guī)則將在哪里發(fā)布?
? ESLint Core // 官方核心規(guī)則 (目前有200多個(gè)規(guī)則)
ESLint Plugin // 選擇ESLint插件
? What is the rule ID? settimeout-no-number // 規(guī)則的ID
? Type a short description of this rule: setTimeout 第二個(gè)參數(shù)禁止是數(shù)字 // 輸入該規(guī)則的描述
? Type a short example of the code that will fail: 占位 // 輸入一個(gè)失敗例子的代碼
create docs/rules/settimeout-no-number.md
create lib/rules/settimeout-no-number.js
create tests/lib/rules/settimeout-no-number.js
加了具體規(guī)則文件的項(xiàng)目結(jié)構(gòu)
.
├── README.md
├── docs // 使用文檔
│ └── rules // 所有規(guī)則的文檔
│ └── settimeout-no-number.md // 具體規(guī)則文檔
├── lib // eslint 規(guī)則開(kāi)發(fā)
│ ├── index.js 引入+導(dǎo)出rules文件夾的規(guī)則
│ └── rules // 此目錄下可以構(gòu)建多個(gè)規(guī)則
│ └── settimeout-no-number.js // 規(guī)則細(xì)節(jié)
├── package.json
└── tests // 單元測(cè)試
└── lib
└── rules
└── settimeout-no-number.js // 測(cè)試該規(guī)則的文件
4. 安裝項(xiàng)目依賴
npm install
以上是開(kāi)發(fā)ESLint插件具體規(guī)則的準(zhǔn)備工作,下面先來(lái)看看AST和ESLint原理的相關(guān)知識(shí),為我們開(kāi)發(fā)ESLint rule 打一下基礎(chǔ)。
AST——抽象語(yǔ)法樹(shù)
AST是: Abstract Syntax Tree的簡(jiǎn)稱,中文叫做:抽象語(yǔ)法樹(shù)。
AST的作用
將代碼抽象成樹(shù)狀數(shù)據(jù)結(jié)構(gòu),方便后續(xù)分析檢測(cè)代碼。
代碼被解析成AST的樣子
astexplorer.net是一個(gè)工具網(wǎng)站:它能查看代碼被解析成AST的樣子。
如下圖:在右側(cè)選中一個(gè)值時(shí),左側(cè)對(duì)應(yīng)區(qū)域也變成高亮區(qū)域,這樣可以在AST中很方便的選中對(duì)應(yīng)的代碼。
AST 選擇器:
下圖中被圈起來(lái)的部分,稱為AST selectors(選擇器)。
AST 選擇器的作用:使用代碼通過(guò)選擇器來(lái)選中特定的代碼片段,然后再對(duì)代碼進(jìn)行靜態(tài)分析。
AST 選擇器很多,ESLint官方專門有一個(gè)倉(cāng)庫(kù)列出了所有類型的選擇器: estree
下文中開(kāi)發(fā)ESLint rule就需要用到選擇器,等下用到了就懂了,現(xiàn)在知道一下就好了。
ESLint的運(yùn)行原理
在開(kāi)發(fā)規(guī)則之前,我們需要ESLint是怎么運(yùn)行的,了解插件為什么需要這么寫。
1. 將代碼解析成AST
ESLint使用JavaScript解析器Espree把JS代碼解析成AST。
PS:解析器:是將代碼解析成AST的工具,ES6、react、vue都開(kāi)發(fā)了對(duì)應(yīng)的解析器所以ESLint能檢測(cè)它們的,ESLint也是因此一統(tǒng)前端Lint工具的。
2. 深度遍歷AST,監(jiān)聽(tīng)匹配過(guò)程。
在拿到AST之后,ESLint會(huì)以"從上至下"再"從下至上"的順序遍歷每個(gè)選擇器兩次。
3. 觸發(fā)監(jiān)聽(tīng)選擇器的rule回調(diào)
在深度遍歷的過(guò)程中,生效的每條規(guī)則都會(huì)對(duì)其中的某一個(gè)或多個(gè)選擇器進(jìn)行監(jiān)聽(tīng),每當(dāng)匹配到選擇器,監(jiān)聽(tīng)該選擇器的rule,都會(huì)觸發(fā)對(duì)應(yīng)的回調(diào)。
4. 具體的檢測(cè)規(guī)則等細(xì)節(jié)內(nèi)容。
開(kāi)發(fā)規(guī)則
規(guī)則默認(rèn)模板
打開(kāi)rule生成的模板文件lib/rules/settimeout-no-number.js, 清理一下文件,刪掉不必要的選項(xiàng):
module.exports = {
meta: {
docs: {
description: "setTimeout 第二個(gè)參數(shù)禁止是數(shù)字",
},
fixable: null, // 修復(fù)函數(shù)
},
// rule 核心
create: function(context) {
// 公共變量和函數(shù)應(yīng)該在此定義
return {
// 返回事件鉤子
};
}
};
刪掉的配置項(xiàng),有些是ESLint官方核心規(guī)則才是用到的配置項(xiàng),有些是暫時(shí)不必了解的配置,需要用到的時(shí)候,可以自行查閱ESLint 文檔
create方法-監(jiān)聽(tīng)選擇器
上文ESLint原理第三部中提到的:在深度遍歷的過(guò)程中,生效的每條規(guī)則都會(huì)對(duì)其中的某一個(gè)或多個(gè)選擇器進(jìn)行監(jiān)聽(tīng),每當(dāng)匹配到選擇器,監(jiān)聽(tīng)該選擇器的rule,都會(huì)觸發(fā)對(duì)應(yīng)的回調(diào)。
create返回一個(gè)對(duì)象,對(duì)象的屬性設(shè)為選擇器,ESLint會(huì)收集這些選擇器,在AST遍歷過(guò)程中會(huì)執(zhí)行所有監(jiān)聽(tīng)該選擇器的回調(diào)。
// rule 核心
create: function(context) {
// 公共變量和函數(shù)應(yīng)該在此定義
return {
// 返回事件鉤子
Identifier: (node) => {
// node是選中的內(nèi)容,是我們監(jiān)聽(tīng)的部分, 它的值參考AST
}
};
}
觀察AST:
創(chuàng)建一個(gè)ESLint rule需要觀察代碼解析成AST,選中你要檢測(cè)的代碼,然后進(jìn)行一些判斷。
以下代碼都是通過(guò)astexplorer.net在線解析的。
setTimeout(()=>{
console.log('settimeout')
}, 1000)
rule完整文件
lib/rules/settimeout-no-number.js:
module.exports = {
meta: {
docs: {
description: "setTimeout 第二個(gè)參數(shù)禁止是數(shù)字",
},
fixable: null, // 修復(fù)函數(shù)
},
// rule 核心
create: function (context) {
// 公共變量和函數(shù)應(yīng)該在此定義
return {
// 返回事件鉤子
'CallExpression': (node) => {
if (node.callee.name !== 'setTimeout') return // 不是定時(shí)器即過(guò)濾
const timeNode = node.arguments && node.arguments[1] // 獲取第二個(gè)參數(shù)
if (!timeNode) return // 沒(méi)有第二個(gè)參數(shù)
// 檢測(cè)報(bào)錯(cuò)第二個(gè)參數(shù)是數(shù)字 報(bào)錯(cuò)
if (timeNode.type === 'Literal' && typeof timeNode.value === 'number') {
context.report({
node,
message: 'setTimeout第二個(gè)參數(shù)禁止是數(shù)字'
})
}
}
};
}
};
context.report():這個(gè)方法是用來(lái)通知ESLint這段代碼是警告或錯(cuò)誤的,用法如上。在這里查看context和context.report()的文檔。
規(guī)則寫完了,原理就是依據(jù)AST解析的結(jié)果,做針對(duì)性的檢測(cè),過(guò)濾出我們要選中的代碼,然后對(duì)代碼的值進(jìn)行邏輯判斷。
可能現(xiàn)在會(huì)有點(diǎn)懵逼,但是不要緊,我們來(lái)寫一下測(cè)試用例,然后用debugger來(lái)看一下代碼是怎么運(yùn)行的。
測(cè)試用例:
測(cè)試文件tests/lib/rules/settimeout-no-number.js:
/**
* @fileoverview setTimeout 第二個(gè)參數(shù)禁止是數(shù)字
* @author OBKoro1
*/
"use strict";
var rule = require("../../../lib/rules/settimeout-no-number"), // 引入rule
RuleTester = require("eslint").RuleTester;
var ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 7, // 默認(rèn)支持語(yǔ)法為es5
},
});
// 運(yùn)行測(cè)試用例
ruleTester.run("settimeout-no-number", rule, {
// 正確的測(cè)試用例
valid: [
{
code: 'let someNumber = 1000; setTimeout(()=>{ console.log(11) },someNumber)'
},
{
code: 'setTimeout(()=>{ console.log(11) },someNumber)'
}
],
// 錯(cuò)誤的測(cè)試用例
invalid: [
{
code: 'setTimeout(()=>{ console.log(11) },1000)',
errors: [{
message: "setTimeout第二個(gè)參數(shù)禁止是數(shù)字", // 與rule拋出的錯(cuò)誤保持一致
type: "CallExpression" // rule監(jiān)聽(tīng)的對(duì)應(yīng)鉤子
}]
}
]
});
下面來(lái)學(xué)習(xí)一下怎么在VSCode中調(diào)試node文件,用于觀察rule是怎么運(yùn)行的。
實(shí)際上打console的形式,也是可以的,但是在調(diào)試的時(shí)候打console實(shí)在是有點(diǎn)慢,對(duì)于node這種節(jié)點(diǎn)來(lái)說(shuō),信息也不全,所以我還是比較推薦通過(guò)debugger的方式來(lái)調(diào)試rule。
在VSCode中調(diào)試node文件
- 點(diǎn)擊下圖中的設(shè)置按鈕, 將會(huì)打開(kāi)一個(gè)文件
launch.json - 在文件中填入如下內(nèi)容,用于調(diào)試node文件。
- 在
rule文件中打debugger或者在代碼行數(shù)那里點(diǎn)一下小紅點(diǎn)。 - 點(diǎn)擊圖中的開(kāi)始按鈕,進(jìn)入
debugger
{
// 使用 IntelliSense 了解相關(guān)屬性。
// 懸停以查看現(xiàn)有屬性的描述。
// 欲了解更多信息,請(qǐng)?jiān)L問(wèn): https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "啟動(dòng)程序", // 調(diào)試界面的名稱
// 運(yùn)行項(xiàng)目下的這個(gè)文件:
"program": "${workspaceFolder}/tests/lib/rules/settimeout-no-number.js",
"args": [] // node 文件的參數(shù)
},
// 下面是用于調(diào)試package.json的命令 之前可以用,貌似vscode出了點(diǎn)bug導(dǎo)致現(xiàn)在用不了了
{
"name": "Launch via NPM",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script", "dev" //這里的dev就對(duì)應(yīng)package.json中的scripts中的dev
],
"port": 9229 //這個(gè)端口是調(diào)試的端口,不是項(xiàng)目啟動(dòng)的端口
},
]
}
運(yùn)行測(cè)試用例進(jìn)入斷點(diǎn)
- 在
lib/rules/settimeout-no-number.js中打一些debugger - 點(diǎn)擊開(kāi)始按鈕,以調(diào)試的形式運(yùn)行測(cè)試文件
tests/lib/rules/settimeout-no-number.js - 開(kāi)始調(diào)試
rule。
發(fā)布插件
eslint插件都是以npm包的形式來(lái)引用的,所以需要把插件發(fā)布一下:
注冊(cè):如果你還未注冊(cè)npm賬號(hào)的話,需要去注冊(cè)一下。
登錄npm:
npm login發(fā)布
npm包:npm publish即可,ESLint已經(jīng)把package.json弄好了。
集成到項(xiàng)目:
安裝npm包:npm i eslint-plugin-korolint -D
- 常規(guī)的方法:
引入插件一條條寫入規(guī)則
// .eslintrc.js
module.exports = {
plugins: [ 'korolint' ],
rules: {
"korolint/settimeout-no-number": "error"
}
}
-
extends繼承插件配置:
當(dāng)規(guī)則比較多的時(shí)候,用戶一條條去寫,未免也太麻煩了,所以ESLint可以繼承插件的配置:
修改一下lib/rules/index.js文件:
'use strict';
var requireIndex = require('requireindex');
const output = {
rules: requireIndex(__dirname + '/rules'), // 導(dǎo)出所有規(guī)則
configs: {
// 導(dǎo)出自定義規(guī)則 在項(xiàng)目中直接引用
koroRule: {
plugins: ['korolint'], // 引入插件
rules: {
// 開(kāi)啟規(guī)則
'korolint/settimeout-no-number': 'error'
}
}
}
};
module.exports = output;
使用方法:
使用extends來(lái)繼承插件的配置,extends不止這種繼承方式,即使你傳入一個(gè)npm包,一個(gè)文件的相對(duì)路徑地址,eslint也能繼承其中的配置。
// .eslintrc.js
module.exports = {
extends: [ 'plugin:korolint/koroRule' ] // 繼承插件導(dǎo)出的配置
}
PS : 這種使用方式, npm的包名不能為eslint-plugin-xx-xx,只能為eslint-plugin-xx否則會(huì)有報(bào)錯(cuò),被這個(gè)問(wèn)題搞得頭疼o(╥﹏╥)o
擴(kuò)展:
以上內(nèi)容足夠開(kāi)發(fā)一個(gè)插件,這里是一些擴(kuò)展知識(shí)點(diǎn)。
遍歷方向:
上文中說(shuō)過(guò): 在拿到AST之后,ESLint會(huì)以"從上至下"再"從下至上"的順序遍歷每個(gè)選擇器兩次。
我們所監(jiān)聽(tīng)的選擇器默認(rèn)會(huì)在"從上至下"的過(guò)程中觸發(fā),如果需要在"從下至上"的過(guò)程中執(zhí)行則需要添加:exit,在上文中CallExpression就變?yōu)?code>CallExpression:exit。
注意:一段代碼解析后可能包含多次同一個(gè)選擇器,選擇器的鉤子也會(huì)多次觸發(fā)。
fix函數(shù):自動(dòng)修復(fù)rule錯(cuò)誤
修復(fù)效果:
// 修復(fù)前
setTimeout(() => {
}, 1000)
// 修復(fù)后 變量名故意寫錯(cuò) 為了讓用戶去修改它
const countNumber1 = 1000
setTimeout(() => {
}, countNumber2)
- 在rule的meta對(duì)象上打開(kāi)修復(fù)功能:
// rule文件
module.exports = {
meta: {
docs: {
description: 'setTimeout 第二個(gè)參數(shù)禁止是數(shù)字'
},
fixable: 'code' // 打開(kāi)修復(fù)功能
}
}
- 在
context.report()上提供一個(gè)fix函數(shù):
把上文的context.report修改一下,增加一個(gè)fix方法即可,更詳細(xì)的介紹可以看一下文檔。
context.report({
node,
message: 'setTimeout第二個(gè)參數(shù)禁止是數(shù)字',
fix(fixer) {
const numberValue = timeNode.value;
const statementString = `const countNumber1 = ${numberValue}\n`
return [
// 修改數(shù)字為變量 變量名故意寫錯(cuò) 為了讓用戶去修改它
fixer.replaceTextRange(node.arguments[1].range, 'countNumber2'),
// 在setTimeout之前增加一行聲明變量的代碼 用戶自行修改變量名
fixer.insertTextBeforeRange(node.range, statementString)
];
}
});
項(xiàng)目地址:
呼~(yú) 這篇博客斷斷續(xù)續(xù),寫了好幾周,終于完成了!
大家有看到這篇博客的話,建議跟著博客的一起動(dòng)手寫一下,動(dòng)手實(shí)操一下比你mark一百篇文章都來(lái)的有用,花不了很長(zhǎng)時(shí)間的,希望各位看完本文,都能夠更深入的了解到ESLint的運(yùn)行原理。
覺(jué)得我的博客對(duì)你有幫助的話,就關(guān)注一下/點(diǎn)個(gè)贊吧!
前端進(jìn)階積累、公眾號(hào)、GitHub、wx:OBkoro1、郵箱:obkoro1@foxmail.com
基友帶我飛
ESLint插件是向基友yeyan1996學(xué)習(xí)的,在遇到問(wèn)題的時(shí)候,也是他指點(diǎn)我的,特此感謝。
參考資料: