Libon

Basic Principles of ESLint

15 分钟 #JavaScript#eslint
ESLint 插件基本原理

ToC

ESLint 的规则

ESLint 的规则配置方法主要分两种,一种是直接设置规则的 error warn off, 一种就是以数组的形式来设置规则的开启条件,以及需要附加的配置项,比如:

1
{
2
rules: {
3
// 代码结尾的分号
4
semi: 'off', // 值有 `error` 或 `2`、`warn` 或 `1`、`off` 或 `0`
5
// 字符串的单、双引号
6
quotes: ['error', 'single'] // 或是以数组的形式来进行配置规则的选项,选项还可以以对象的形式呈现
7
}
8
}

rules 中的每一个 key 即表示一条风格规则,我们可以思考一下如何去实现这些规则约束。

ESLint 的核心规则

先看一下简单的规则: no-with:

1
module.exports = {
2
meta: {
3
// 包含规则的元数据
4
// 指示规则的类型,值为 "problem"、"suggestion" 或 "layout"
5
type: "suggestion",
6
7
docs: {
8
// 对 ESLint 核心规则来说是必需的
9
description: "disallow `with` statements", // 提供规则的简短描述在规则首页展示
29 collapsed lines
10
// category (string) 指定规则在规则首页处于的分类
11
recommended: true, // 配置文件中的 "extends": "ESLint:recommended"属性是否启用该规则
12
url: "https://ESLint.org/docs/rules/no-with", // 指定可以访问完整文档的 url
13
},
14
15
// fixable // 如果没有 fixable 属性,即使规则实现了 fix 功能,ESLint 也不会进行修复。如果规则不是可修复的,就省略 fixable 属性。
16
17
schema: [], // 指定该选项 这样的 ESLint 可以避免无效的规则配置
18
19
// deprecated (boolean) 表明规则是已被弃用。如果规则尚未被弃用,你可以省略 deprecated 属性。
20
21
messages: {
22
unexpectedWith: "Unexpected use of 'with' statement.",
23
},
24
},
25
// create (function) 返回一个对象,其中包含了 ESLint 在遍历 js 代码的抽象语法树 AST (ESTree 定义的 AST) 时,用来访问节点的方法。
26
create(context) {
27
// 如果一个 key 是个节点类型或 selector,在 向下 遍历树时,ESLint 调用 visitor 函数
28
// 如果一个 key 是个节点类型或 selector,并带有 :exit,在 向上 遍历树时,ESLint 调用 visitor 函数
29
// 如果一个 key 是个事件名字,ESLint 为代码路径分析调用 handler 函数
30
// selector 类型可以到 estree 查找
31
return {
32
// 入参为节点node
33
WithStatement(node) {
34
context.report({ node, messageId: "unexpectedWith" });
35
},
36
};
37
},
38
};

规则由两部分组成: meta create;

meta

meta 对象是描述规则的元数据, 包括了规则的 类型 文档 是否可修复 等信息.

create

create 函数返回一个对象包含了 ESLint 在遍历 JavaScript 代码的抽象语法树(AST) (ESTree 定义的 AST) 时, 用来访问节点的方法, 入参为该节点.

ESLint 命令的执行

在 package.json 里配置 bin

1
{
2
"name": "eslint",
3
"bin": {
4
"eslint": "bin/eslint.js"
5
},
6
"scripts": {
7
// ...
8
}
9
}
1
// 代码有删减
2
(async function main() {
3
// 如果在执行的时候带有 --init 参数则进行初始化 eslint 的配置
4
if (process.argv.includes("--init")) {
5
// `eslint --init` has been moved to `@eslint/create-config`
6
console.warn(
7
"You can also run this command directly using 'npm init @eslint/config'."
8
);
9
14 collapsed lines
10
const spawn = require("cross-spawn");
11
spawn.sync("npm", ["init", "@eslint/config"], {
12
encoding: "utf8",
13
stdio: "inherit",
14
});
15
return;
16
}
17
18
// 没有附带 --init 参数则检查本地代码
19
process.exitCode = await require("../lib/cli").execute(
20
process.argv,
21
process.argv.includes("--stdin") ? await readStdin() : null
22
);
23
})();

ESLint 执行的调用栈

eslint 的主要代码执行逻辑流程如下:

  1. 解析命令行参数,校验参数正确与否及打印相关信息;
  2. 根据配置实例一个 engine 对象 CLIEngine 实例;
  3. engine.executeOnFiles 读取源代码进行检查,返回报错信息和修复结果。
1
const cli = {
2
/**
3
* Executes the CLI based on an array of arguments that is passed in.
4
* @param {string|Array|Object} args The arguments to process.
5
* @param {string} [text] The text to lint (used for TTY).
6
* @returns {Promise<number>} The exit code for the operation.
7
*/
8
async execute(args, text) {
9
if (Array.isArray(args)) {
136 collapsed lines
10
debug("CLI args: %o", args.slice(2));
11
}
12
13
/** @type {ParsedCLIOptions} */
14
let options;
15
16
try {
17
options = CLIOptions.parse(args);
18
} catch (error) {
19
log.error(error.message);
20
return 2;
21
}
22
23
const files = options._;
24
const useStdin = typeof text === "string";
25
26
if (options.help) {
27
log.info(CLIOptions.generateHelp());
28
return 0;
29
}
30
if (options.version) {
31
log.info(RuntimeInfo.version());
32
return 0;
33
}
34
if (options.envInfo) {
35
try {
36
log.info(RuntimeInfo.environment());
37
return 0;
38
} catch (err) {
39
log.error(err.message);
40
return 2;
41
}
42
}
43
44
if (options.printConfig) {
45
if (files.length) {
46
log.error(
47
"The --print-config option must be used with exactly one file name."
48
);
49
return 2;
50
}
51
if (useStdin) {
52
log.error(
53
"The --print-config option is not available for piped-in code."
54
);
55
return 2;
56
}
57
58
const engine = new ESLint(translateOptions(options));
59
const fileConfig = await engine.calculateConfigForFile(
60
options.printConfig
61
);
62
63
log.info(JSON.stringify(fileConfig, null, " "));
64
return 0;
65
}
66
67
debug(`Running on ${useStdin ? "text" : "files"}`);
68
69
if (options.fix && options.fixDryRun) {
70
log.error(
71
"The --fix option and the --fix-dry-run option cannot be used together."
72
);
73
return 2;
74
}
75
if (useStdin && options.fix) {
76
log.error(
77
"The --fix option is not available for piped-in code; use --fix-dry-run instead."
78
);
79
return 2;
80
}
81
if (options.fixType && !options.fix && !options.fixDryRun) {
82
log.error(
83
"The --fix-type option requires either --fix or --fix-dry-run."
84
);
85
return 2;
86
}
87
88
const engine = new ESLint(translateOptions(options));
89
let results;
90
91
if (useStdin) {
92
results = await engine.lintText(text, {
93
filePath: options.stdinFilename,
94
warnIgnored: true,
95
});
96
} else {
97
results = await engine.lintFiles(files);
98
}
99
100
if (options.fix) {
101
debug("Fix mode enabled - applying fixes");
102
await ESLint.outputFixes(results);
103
}
104
105
let resultsToPrint = results;
106
107
if (options.quiet) {
108
debug("Quiet mode enabled - filtering out warnings");
109
resultsToPrint = ESLint.getErrorResults(resultsToPrint);
110
}
111
112
if (
113
await printResults(
114
engine,
115
resultsToPrint,
116
options.format,
117
options.outputFile
118
)
119
) {
120
// Errors and warnings from the original unfiltered results should determine the exit code
121
const { errorCount, fatalErrorCount, warningCount } =
122
countErrors(results);
123
124
const tooManyWarnings =
125
options.maxWarnings >= 0 && warningCount > options.maxWarnings;
126
const shouldExitForFatalErrors =
127
options.exitOnFatalError && fatalErrorCount > 0;
128
129
if (!errorCount && tooManyWarnings) {
130
log.error(
131
"ESLint found too many warnings (maximum: %s).",
132
options.maxWarnings
133
);
134
}
135
136
if (shouldExitForFatalErrors) {
137
return 2;
138
}
139
140
return errorCount || tooManyWarnings ? 1 : 0;
141
}
142
143
return 2;
144
},
145
};

可以看到 eslint 实际上是在执行 engine.lintFiles(files) 方法:

1
async lintFiles(patterns) {
2
if (!isNonEmptyString(patterns) && !isArrayOfNonEmptyString(patterns)) {
3
throw new Error("'patterns' must be a non-empty string or an array of non-empty strings");
4
}
5
const { cliEngine } = privateMembersMap.get(this);
6
7
return processCLIEngineLintReport(
8
cliEngine,
9
cliEngine.executeOnFiles(patterns)
2 collapsed lines
10
);
11
}

engine.lintFiles(files) 方法内部则是在执行 executeOnFiles(patterns) 方法来进行文件内容的校验.

executeOnFiles(patterns) 函数

executeOnFiles(patterns) 函数主要作用是对一组文件和目录名称执行当前配置. 看一下它做了什么:

1
executeOnFiles(patterns) {
2
const {
3
cacheFilePath,
4
fileEnumerator,
5
lastConfigArrays,
6
lintResultCache,
7
linter,
8
options: {
9
allowInlineConfig,
112 collapsed lines
10
cache,
11
cwd,
12
fix,
13
reportUnusedDisableDirectives
14
}
15
} = internalSlotsMap.get(this);
16
const results = [];
17
const startTime = Date.now();
18
19
// Clear the last used config arrays.
20
lastConfigArrays.length = 0;
21
22
// Delete cache file; should this do here?
23
if (!cache) {
24
try {
25
fs.unlinkSync(cacheFilePath);
26
} catch (error) {
27
const errorCode = error && error.code;
28
29
// Ignore errors when no such file exists or file system is read only (and cache file does not exist)
30
if (errorCode !== "ENOENT" && !(errorCode === "EROFS" && !fs.existsSync(cacheFilePath))) {
31
throw error;
32
}
33
}
34
}
35
36
// Iterate source code files.
37
for (const { config, filePath, ignored } of fileEnumerator.iterateFiles(patterns)) {
38
if (ignored) {
39
results.push(createIgnoreResult(filePath, cwd));
40
continue;
41
}
42
43
/*
44
* Store used configs for:
45
* - this method uses to collect used deprecated rules.
46
* - `getRules()` method uses to collect all loaded rules.
47
* - `--fix-type` option uses to get the loaded rule's meta data.
48
*/
49
if (!lastConfigArrays.includes(config)) {
50
lastConfigArrays.push(config);
51
}
52
53
// Skip if there is cached result.
54
if (lintResultCache) {
55
const cachedResult =
56
lintResultCache.getCachedLintResults(filePath, config);
57
58
if (cachedResult) {
59
const hadMessages =
60
cachedResult.messages &&
61
cachedResult.messages.length > 0;
62
63
if (hadMessages && fix) {
64
debug(`Reprocessing cached file to allow autofix: ${filePath}`);
65
} else {
66
debug(`Skipping file since it hasn't changed: ${filePath}`);
67
results.push(cachedResult);
68
continue;
69
}
70
}
71
}
72
73
// Do lint.
74
const result = verifyText({
75
text: fs.readFileSync(filePath, "utf8"),
76
filePath,
77
config,
78
cwd,
79
fix,
80
allowInlineConfig,
81
reportUnusedDisableDirectives,
82
fileEnumerator,
83
linter
84
});
85
86
results.push(result);
87
88
/*
89
* Store the lint result in the LintResultCache.
90
* NOTE: The LintResultCache will remove the file source and any
91
* other properties that are difficult to serialize, and will
92
* hydrate those properties back in on future lint runs.
93
*/
94
if (lintResultCache) {
95
lintResultCache.setCachedLintResults(filePath, config, result);
96
}
97
}
98
99
// Persist the cache to disk.
100
if (lintResultCache) {
101
lintResultCache.reconcile();
102
}
103
104
debug(`Linting complete in: ${Date.now() - startTime}ms`);
105
let usedDeprecatedRules;
106
107
return {
108
results,
109
...calculateStatsPerRun(results),
110
111
// Initialize it lazily because CLI and `ESLint` API don't use it.
112
get usedDeprecatedRules() {
113
if (!usedDeprecatedRules) {
114
usedDeprecatedRules = Array.from(
115
iterateRuleDeprecationWarnings(lastConfigArrays)
116
);
117
}
118
return usedDeprecatedRules;
119
}
120
};
121
}

verifyText() 函数

verifyText() 则是调用了 linter.verifyAndFix() 函数.

verifyAndFix() 函数主要作用是对一组文件和目录名称执行当前配置

这个函数是核心函数,顾名思义 verify & fix 代码核心处理逻辑是通过一个 do while 循环控制;以下两个条件会打断循环

  1. 没有更多可以被 fix 的代码了
  2. 循环超过十次
  3. 其中 verify 函数对源代码文件进行代码检查,从规则维度返回检查结果数组
  4. applyFixes 函数拿到上一步的返回,去 fix 代码
  5. 如果设置了可以 fix,那么使用 fix 之后的结果 代替原本的 text

在 verify 过程中,会调用 parse 函数,把代码转换成 AST

1
// 默认的ast解析是espree
2
const espree = require("espree");
3
4
let parserName = DEFAULT_PARSER_NAME; // 'espree'
5
let parser = espree;

parse 函数会返回两种结果

最终会调用 runRules() 函数

这个函数是代码检查和修复的核心方法,会对代码进行规则校验。

  1. 创建一个 eventEmitter 实例。是 eslint 自己实现的很简单的一个事件触发类 on 监听 emit 触发;
  2. 递归遍历 AST,深度优先搜索,把节点添加到 nodeQueue。一个 node 放入两次,类似于 A->B->C->…->C->B->A;
  3. 遍历 rules,调用 rule.create()(rules 中提到的 meta 和 create 函数) 拿到事件(selector)映射表,添加事件监听。
  4. 包装一个 ruleContext 对象,会通过参数,传给 rule.create(),其中包含 report() 函数,每个 rule 的 handler 都会执行这个函数,抛出问题;
  5. 调用 rule.create(ruleContext), 遍历其返回的对象,添加事件监听;(如果需要 lint 计时,则调用 process.hrtime()计时);
  6. 遍历 nodeQueue,触发当前节点事件的回调,调用 NodeEventGenerator 实例里面的函数,触发 emitter.emit()。
1
// 1. 创建一个 eventEmitter 实例。是eslint自己实现的很简单的一个事件触发类 on监听 emit触发
2
const emitter = createEmitter();
3
4
// 2. 递归遍历 AST,把节点添加到 nodeQueue。一个node放入两次 A->B->C->...->C->B->A
5
Traverser.traverse(sourceCode.ast, {
6
enter(node, parent) {
7
node.parent = parent;
8
nodeQueue.push({ isEntering: true, node });
9
},
107 collapsed lines
10
leave(node) {
11
nodeQueue.push({ isEntering: false, node });
12
},
13
visitorKeys: sourceCode.visitorKeys,
14
});
15
16
// 3. 遍历 rules,调用 rule.create() 拿到事件(selector)映射表,添加事件监听。
17
// (这里的 configuredRules 是我们在 .eslintrc.json 设置的 rules)
18
Object.keys(configuredRules).forEach((ruleId) => {
19
const severity = ConfigOps.getRuleSeverity(configuredRules[ruleId]);
20
21
// 通过ruleId拿到每个规则对应的一个对象,里面有两部分 meta & create 见 【编写rule】
22
const rule = ruleMapper(ruleId);
23
24
// ....
25
26
const messageIds = rule.meta && rule.meta.messages;
27
let reportTranslator = null;
28
// 这个对象比较重要,会传给 每个规则里的 rule.create函数
29
const ruleContext = Object.freeze(
30
Object.assign(Object.create(sharedTraversalContext), {
31
id: ruleId,
32
options: getRuleOptions(configuredRules[ruleId]),
33
// 每个rule的 handler 都会执行这个函数,抛出问题
34
report(...args) {
35
if (reportTranslator === null) {
36
reportTranslator = createReportTranslator({
37
ruleId,
38
severity,
39
sourceCode,
40
messageIds,
41
disableFixes,
42
});
43
}
44
const problem = reportTranslator(...args);
45
// 省略一堆错误校验
46
// ....
47
// 省略一堆错误校验
48
49
// lint的结果
50
lintingProblems.push(problem);
51
},
52
})
53
);
54
// 包装了一下,其实就是 执行 rule.create(ruleContext);
55
// rule.create(ruleContext) 会返回一个对象,key就是事件名称
56
const ruleListeners = createRuleListeners(rule, ruleContext);
57
58
/**
59
* 在错误信息中加入ruleId
60
* @param {Function} ruleListener 监听到每个node,然后对应的方法rule.create(ruleContext)返回的对象中对应key的value
61
* @returns {Function} ruleListener wrapped in error handler
62
*/
63
function addRuleErrorHandler(ruleListener) {
64
return function ruleErrorHandler(...listenerArgs) {
65
try {
66
return ruleListener(...listenerArgs);
67
} catch (e) {
68
e.ruleId = ruleId;
69
throw e;
70
}
71
};
72
}
73
74
// 遍历 rule.create(ruleContext) 返回的对象,添加事件监听
75
Object.keys(ruleListeners).forEach((selector) => {
76
const ruleListener = timing.enabled
77
? timing.time(ruleId, ruleListeners[selector]) // 调用process.hrtime()计时
78
: ruleListeners[selector];
79
// 对每一个 selector 进行监听,添加 callback
80
emitter.on(selector, addRuleErrorHandler(ruleListener));
81
});
82
});
83
84
// 只有顶层node类型是Program才进行代码路径分析
85
const eventGenerator =
86
nodeQueue[0].node.type === "Program"
87
? new CodePathAnalyzer(
88
new NodeEventGenerator(emitter, {
89
visitorKeys: sourceCode.visitorKeys,
90
fallback: Traverser.getKeys,
91
})
92
)
93
: new NodeEventGenerator(emitter, {
94
visitorKeys: sourceCode.visitorKeys,
95
fallback: Traverser.getKeys,
96
});
97
98
// 4. 遍历 nodeQueue,触发当前节点事件的回调。
99
// 这个 nodeQueue 是前面push进所有的node,分为 入口 和 离开
100
nodeQueue.forEach((traversalInfo) => {
101
currentNode = traversalInfo.node;
102
try {
103
if (traversalInfo.isEntering) {
104
// 调用 NodeEventGenerator 实例里面的函数
105
// 在这里触发 emitter.emit()
106
eventGenerator.enterNode(currentNode);
107
} else {
108
eventGenerator.leaveNode(currentNode);
109
}
110
} catch (err) {
111
err.currentNode = currentNode;
112
throw err;
113
}
114
});
115
// lint的结果
116
return lintingProblems;

执行节点匹配 NodeEventGenerator

在该类里面,会根据前面 nodeQueque 分别调用 进入节点和离开节点,来区分不同的调用时机。

1
// 进入节点 把这个node的父节点push进去
2
enterNode(node) {
3
if (node.parent) {
4
this.currentAncestry.unshift(node.parent);
5
}
6
this.applySelectors(node, false);
7
}
8
// 离开节点
9
leaveNode(node) {
11 collapsed lines
10
this.applySelectors(node, true);
11
this.currentAncestry.shift();
12
}
13
// 进入还是离开 都执行的这个函数
14
// 调用这个函数,如果节点匹配,那么就触发事件
15
applySelector(node, selector) {
16
if (esquery.matches(node, selector.parsedSelector, this.currentAncestry, this.esqueryOptions)) {
17
// 触发事件,执行 handler
18
this.emitter.emit(selector.rawSelector, node);
19
}
20
}

总体运行机制

概括来说就是,ESLint 会遍历前面说到的 AST,然后在遍历到「不同的节点」或者「特定的时机」的时候,触发相应的处理函数,然后在函数中,可以抛出错误,给出提示。


  1. ESLint - Working with Rules
  2. ESLint 是如何工作的
  3. 浅析Eslint原理
  4. ESLint 工作原理探讨

CD ..