最近在开发一个 eslint 的插件,开发的过程中学习了一下 eslint 的原理,也梳理了 eslint 插件的开发,将这些内容总结在这里,希望能给到需要的人一些帮助
概览
What:
ESLint是一个用来识别 JavaScript 并且按照规则给出报告的代码检测工具,使用它可以避免低级错误和统一代码的风格。
Why:
为什么要使用 eslint 就不用我说了吧,相信大家都能体会到👀。统一团队开发规范、避免一些语法错误、为开发提效等等。。。
How:
知道了是什么和为什么,那 eslint 是怎么工作的呢?我们怎么去做一些扩展和定制来满足团队的开发需要呢?比如 lynx 语法的特殊性,我们可以通过 eslint 来提前规避一些不好的写法或坑。
这些就是我今天重点分享的内容。
Eslint 的主要功能是检测和修复,ESLint 是如何做到“读懂”你的代码甚至修复代码的呢,就像你看不懂某一门语言,可以通过翻译器翻译一下。同样的 eslint 也是通过 espree 这个解析器,将 js 代码解析成可以进行节点分析的 AST (抽象语法树),了解 Babel 或者 Webpack 同学应该对 AST 很熟悉吧。
抽象语法树(
abstract syntax code,AST)是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构。抽象语法树并不依赖于源语言的语法,在语法分析阶段所采用的是上下文无文文法。
AST 生成流程:
the-super-tiny-compiler ,js 写的一个精简编译器,200来行,每一行都有注释,感兴趣的同学可以看看。
babel 流程:
Babel 实现最新 js 语法也是通过 AST 做的代码分析转化来做的Polyfill。
- 一个小故事:Lint 简史
-
- Lint 工具热度变化图
JSLint 出现最早,于 2010 年开源,由于其几乎不可配置,所以就出现了可配置的 JSHint,很快大家就从 JSLint 转向了 JSHint。
ESLint 在 2013年发布的,之前一直都平平无奇,但转折点就在 ES6 规范发布的时候,由于浏览器对 es6 的支持有限,需要通过 babel 做转译, 但这时候的 JSHint 短期内无法提供支持,而 ESLint 是基于 AST 做的检测,只需要有合适的解析器就能做lint 检查。这时Babel 团队就为 ESLint 开发了一款替代默认解析器的工具,也就是现在我们所见到的 babel-eslint,它让 ESLint 成为率先支持 ES6 语法的 lint 工具。
也是在 2015 年,随着React 的应用越来越广泛,诞生不久的 JSX 也愈加流行,ESLint 本身也不支持 JSX 语法。但是因为可扩展性,eslint-plugin-react 的出现让 ESLint 也能支持当时 React 特有的规则。之后 ESlint 的热度就一路突飞猛进。
最终 ESLint 一统江湖。
启发: 可扩展性的重要性、AST 的重要。
浅析 eslint 原理
Eslint 在项目里的使用
主要用在三个地方,编码时、precommit、pipeline 。
- 编码时可以结合 vscode 的 eslint 插件来做到实时检测,从而为开发提效。
- precommit实现是在 git 的 hooks precommit 里做的 lint 检测。
-
Pipeline 主要在一些 CI/CD 工具提供的配置里加上运行 lint 检测的代码来做的检测。
对 commit 和 pipeline 做检测时,本质上都是执行一个命令行的命令
npm run lint
对于到 packages 里的 script 就是执行 eslint 命令
执行命令要生效就需要项目里有 eslint 的配置,这样 eslint 才能读取到配置后做具体的校验。
先看一份简单的 eslint 配置:
/** .eslintrc.js */
module.exports = {
// 继承已有的规则
extends: [
"plugin:react/recommended",
// eslint-config-airbnb
"airbnb"
],
plugins: [
// eslint-plugin-config
"react",
],
// 默认是 Espree
parser: "@typescript-eslint/parser",
// 解析器选项
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: "latest",
},
rules: {
"no-console": 2,
}
}
配置的重点就是 extends 和 plugins:
- 其中 extends 就是继承别的 eslint 配置,相当于使用一些 lint 校验的最佳实践,extends 里的每一个字段对应的都是一个 eslint-config。
- Plugins 是 eslint 规则定义的具体实现,想要做规则的扩展就需要去编写插件,config 里具体做校验的也是每一个 plugin 里的 rule。
一个 Plugin 导出的内容:
Rules 就是具体每一个规则的内容,configs 是一个推荐的配置,对应的也是一个个的 eslint 配置。
eslint 整体原理
下面就深入了解一下 eslint 原理:
命令行执行 eslint --ext .tsx src 时发生了什么?
- 首先进入 eslint 的入口文件 ./bin/eslint.js ,判断若无异常和不是 init 则调用 lib/cli 下的 execute() 函数进行代码的检验
// ./bin/eslint.js 主要内容
(async function main() {
if (process.argv.includes("--init")) {
// ...
return
}
process.exitCode = await require("../lib/cli").execute(
process.argv,
process.argv.includes("--stdin") ? await readStdin() : null,
true
);
}()).catch(onFatalError)
execute函数根据传递来的参数做不同的处理,若是对文件进行 lint 检验,则会调用 eslint 对象下的 lintFiles 进行 lint 检验
const ActiveESLint = usingFlatConfig ? FlatESLint : ESLint;
// 根据命令行参数初始化 eslint 对象
const engine = new ActiveESLint(await translateOptions(options, usingFlatConfig ? "flat" : "eslintrc"));
let results;
results = await engine.lintFiles(files);
- lintFiles 方法重点在于调用
cliEngine.executeOnFiles(patterns)。
async lintFiles(patterns) {
// ...
return processCLIEngineLintReport(
cliEngine,
cliEngine.executeOnFiles(patterns)
);
}
- executeOnFiles 里通过一个 for 循环遍历每一个文件,然后调用 verifyText 方法对每个文件进行 lint 校验
// executeOnFiles 方法
executeOnFiles(patterns) {
// ...
let results = [];
for (const { config, filePath, ignored } of fileEnumerator.iterateFiles(patterns)) {
// ....
const result = verifyText({
text: fs.readFileSync(filePath, "utf8"),
filePath,
config,
cwd,
fix,
allowInlineConfig,
reportUnusedDisableDirectives,
fileEnumerator,
linter
});
results.push(result);
// ....
}
// ...
return {
results,
// ....
}
}
verifyText 函数参数中的 config 就是 eslint 配置文件的信息,那这个配置信息是怎么读取来的呢?
实现比较复杂,总结如下:
经过前面的步骤之后,基本上我们已经获取了所有需要的配置,接下来就会进入检验流程。
- 现在具体来看 verifyText 是怎么做校验的。
verifyText 主要调用 verifyAndFix,verifyAndFix 调用 verify 函数进行校验和 SourceCodeFixer.applyFixes 来进行 fix ,fix 其实就是个字符替换。
do {
passNumber++;
messages = this.verify(currentText, config, options);
fixedResult = SourceCodeFixer.applyFixes(currentText, messages, shouldFix);
if (messages.length === 1 && messages[0].fatal) {
break;
}
// keep track if any fixes were ever applied - important for return value
fixed = fixed || fixedResult.fixed;
// update to use the fixed output instead of the original text
currentText = fixedResult.output;
} while (
fixedResult.fixed &&
passNumber < MAX_AUTOFIX_PASSES // MAX_AUTOFIX_PASSES = 10
);
这里为啥要重复 MAX_AUTOFIX_PASSES (默认 10)次呢?
因为多个 fix 之间的 range ,也就是替换的范围可能是有重叠的,如果有重叠就放到下一次来修复,这样 while 循环最多修复 10 次,如果还有 fix 没修复就不修了。
下面来看看 verify 函数:
verify(textOrSourceCode, config, filenameOrOptions) {
if (options.preprocess || options.postprocess) {
return this._distinguishSuppressedMessages(this._verifyWithProcessor(textOrSourceCode, config, options));
}
return this._distinguishSuppressedMessages(this._verifyWithoutProcessors(textOrSourceCode, config, options));
}
_verifyWithProcessor的处理逻辑:
其中 preprocess 和 postprocess 是在插件中定义的 processors 里读取来的。
Processors 在插件中定义的:
module.exports = {
processors: {
".txt": {
preprocess: function(text, filename) {
return [ // return an array of code blocks to lint
{ text: code1, filename: "0.js" },
{ text: code2, filename: "1.js" },
];
},
postprocess: function(messages, filename) {
return [].concat(...messages);
}
}
}
};
processor 的作用就是将非 js 文件的 js 提取出来, 对检测完成后的结果做一些处理。比如对于 vue 的文件,里面除了 js 代码还有 html、css 等,想要对 vue 文件做 lint 校验,就需要使用 processor 将 vue 文件里的 js 代码单独提取出来。
_verifyWithoutProcessors 函数处理逻辑如下:
- 确定 parser
const emitter = createEmitter();
let parserName = DEFAULT_PARSER_NAME;
let parser = espree;
if (typeof config.parser === "object" && config.parser !== null) {
parserName = config.parser.filePath;
parser = config.parser.definition;
} else if (typeof config.parser === "string") {
// ...
parserName = config.parser;
parser = slots.parserMap.get(config.parser);
}
- 使用 parser 解析 text 获得 AST,AST 保存在 sourceCode 里
const parseResult = parse(
text,
languageOptions,
options.filename
);
const sourceCode = parseResult.sourceCode;
```js
- 调用 runRules 进行检验
```js
lintingProblems = runRules(
sourceCode,
configuredRules,
ruleId => getRule(slots, ruleId),
parserName,
languageOptions,
settings,
options.filename,
options.disableFixes,
slots.cwd,
providedOptions.physicalFilename
);
runRules 怎么运行 rule 来实现检验呢?
先简单看一下 rule 的组成:
一个 rule 导出一个对象,其中 meta 是一些配置信息,create 返回一个对象,里面是一个个 AST 节点的选择器和对应的访问函数。
runRules 处理逻辑:
- 使用 Traverser.traverse 遍历 AST,依次从上到下,从下到上将遍历到的 node push 进 nodeQueue
const nodeQueue = [];
Traverser.traverse(sourceCode.ast, {
enter(node, parent) {
node.parent = parent;
nodeQueue.push({ isEntering: true, node });
},
leave(node) {
nodeQueue.push({ isEntering: false, node });
},
visitorKeys: sourceCode.visitorKeys
});
- 遍历所有规则,将规则中的所有选择器添加为侦听器。就是创建一个对象,以 rules 里的节点选择器作为 key,对应的函数作为回调来做的监听。
const lintingProblems = [];
Object.keys(configuredRules).forEach(ruleId => {
// 规则对象
const rule = ruleMapper(ruleId);
// 具体规则里 create 返回的对象,包括一个个用来访问节点的方法
const ruleListeners = timing.enabled ? timing.time(ruleId, createRuleListeners)(rule, ruleContext) : createRuleListeners(rule, ruleContext);
function addRuleErrorHandler(ruleListener) {
...
}
// 将规则中的所有选择器添加为侦听器
Object.keys(ruleListeners).forEach(selector => {
const ruleListener = timing.enabled
? timing.time(ruleId, ruleListeners[selector])
: ruleListeners[selector];
emitter.on(
selector,
addRuleErrorHandler(ruleListener)
);
});
});
遍历的过程中会传入 ruleContext(ruleContext 里包括一些配置和方法),rule 里可以拿到,然后可以通过 context.report() 上报错误。
function createRuleListeners(rule, ruleContext) {
try {
return rule.create(ruleContext);
} catch (ex) {
//...
}
}
- 遍历 nodeQueue 中的每个节点,对于有注册侦听器的节点,触发侦听器回调。
nodeQueue.forEach(traversalInfo => {
currentNode = traversalInfo.node;
try {
if (traversalInfo.isEntering) {
eventGenerator.enterNode(currentNode);
} else {
eventGenerator.leaveNode(currentNode);
}
} catch (err) {
err.currentNode = currentNode;
throw err;
}
});
-
最后将得到的检验结果
lintingProblems返回。
-
调用
applyDisableDirectives根据代码里的 eslint 注释配置来过滤 lintingProblems。
总流程:
以上就是 eslint 的原理了,对于由于内容较多,插件开发就放到下一篇文章讲解,感兴趣的掘友可以关注一下我,后续持续更新文章。