eslint-v0.0.2做了什么

准备了解一下 eslint 的原理,就先看一下最早一版 eslint 的实现吧。github 打了 tag 的最早的版本就是 0.0.2 了,提交记录是八年前了。

git clone git@github.com:eslint/eslint.git 并且 git checkout v0.0.2 ,先看一下 package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"name": "jscheck",
"version": "0.0.2",
"author": "Nicholas C. Zakas <nicholas+npm@nczconsulting.com>",
"description": "An AST-based pattern checker for JavaScript.",
"main": "./lib/jscheck.js",
"bin": {
"jscheck": "./bin/jscheck.js"
},
"scripts": {
"ctest": "istanbul cover --print both vows -- --spec ./tests/*/*/*.js",
"test": "vows -- --spec ./tests/*/*/*.js"
},
"repository": "",
"dependencies": {
"optimist": "*",
"astw": "*",
"esprima": "*"
},
"devDependencies": {
"vows": "~0.7.0",
"istanbul": "~0.1.10",
"sinon": "*"
},
"keywords": [
"ast",
"lint",
"javascript",
"ecmascript"
],
"preferGlobal": true,
"license": "BSD"
}

主要涉及到 optimistastwesprima ,我们来依次了解一下。

optimist

主要作用就是帮我们解析命令行参数,我们来试验一下。

在根目录新建一个 cli.js ,并且赋予执行权限,执行 chmod +x ./cli.js ,输入下边的内容:

1
2
3
4
5
6
#!/usr/bin/env node
var optimist = require("optimist");
console.log('argv 收到的参数')
console.log(process.argv);
console.log('optimist 解析后的参数')
console.log(optimist.parse(process.argv.slice(2)));

#!/usr/bin/env node 指明使用 node 执行当前脚本,就可以直接使用 ./cli.js 执行命令,而不需要使用 node ./cli.js 执行。

processnode 为我们提供的一个全局变量,可以拿到命令行参数 argv

然后执行 ./cli.js -w --hello 23 --no-ugly --name=test ./fils.js ./file2.js,控制台会输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
argv 收到的参数
[
'/Users/wangliang/.nvm/versions/node/v14.17.3/bin/node',
'/Users/wangliang/windliang/eslint/cli.js',
'-w',
'--hello',
'23',
'--no-ugly',
'--name=test',
'./fils.js',
'./file2.js'
]
optimist 解析后的参数
{
_: [ './fils.js', './file2.js' ],
w: true,
hello: 23,
ugly: false,
name: 'test',
'$0': '../../.nvm/versions/node/v14.17.3/bin/node ./cli.js'
}

可以看到 argv[0]node 的路径,argv[1] 是要执行脚本的路径,从 argv[2] 开始是我们要的参数,所以代码里我们执行了 argv.slice(2)

通过 optimist 解析,我们就可以得到相应的 keyvalue 键值对了。

esprima

可以做词法分析或者生成 AST 的语法树,直接看示例。

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env node

var esprima = require("esprima");
var program = `const answer = 42;
if(answer == 5){console.log(answer)}
`;

console.log(`词法分析`);
console.log(esprima.tokenize(program));

console.log(`AST 语法树`);
console.log(JSON.stringify(esprima.parseScript(program), null, 2));

看一下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
词法分析
[
{ type: 'Keyword', value: 'const' },
{ type: 'Identifier', value: 'answer' },
{ type: 'Punctuator', value: '=' },
{ type: 'Numeric', value: '42' },
{ type: 'Punctuator', value: ';' },
{ type: 'Keyword', value: 'if' },
{ type: 'Punctuator', value: '(' },
{ type: 'Identifier', value: 'answer' },
{ type: 'Punctuator', value: '==' },
{ type: 'Numeric', value: '5' },
{ type: 'Punctuator', value: ')' },
{ type: 'Punctuator', value: '{' },
{ type: 'Identifier', value: 'console' },
{ type: 'Punctuator', value: '.' },
{ type: 'Identifier', value: 'log' },
{ type: 'Punctuator', value: '(' },
{ type: 'Identifier', value: 'answer' },
{ type: 'Punctuator', value: ')' },
{ type: 'Punctuator', value: '}' }
]
AST 语法树
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "answer"
},
"init": {
"type": "Literal",
"value": 42,
"raw": "42"
}
}
],
"kind": "const"
},
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"operator": "==",
"left": {
"type": "Identifier",
"name": "answer"
},
"right": {
"type": "Literal",
"value": 5,
"raw": "5"
}
},
"consequent": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"computed": false,
"object": {
"type": "Identifier",
"name": "console"
},
"property": {
"type": "Identifier",
"name": "log"
}
},
"arguments": [
{
"type": "Identifier",
"name": "answer"
}
]
}
}
]
},
"alternate": null
}
],
"sourceType": "script"
}

此外,解析 Ast 语法树的时候为我们提供了 range 参数和 loc 参数,esprima.parseScript(program, { loc: true, range: true }),输出节点的时候可以帮我们输出源代码的位置,类似于下边的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "answer",
"range": [
6,
12
],
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 12
}
}
},

astw

ast walk,输入源代码或者 AST 对象,然后调用 walk 方法传入回调,会帮我们依次遍历 ast 的节点,同样看个例子就明白了。

为了更好的看出输出的结果,我们引入 escodegen 库,可以将遍历的 ast 节点还原为源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env node

var astw = require("astw");
var esprima = require("esprima");
var program = `const answer = 42;
if(answer == 5){console.log(answer)}
`;

console.log(JSON.stringify(esprima.parseScript(program), null, 2));
var walk = astw(program);
var deparse = require("escodegen").generate;
let count = 1;
walk(function (node) {
var src = deparse(node);
console.log(count++, node.type + " :: " + JSON.stringify(src));
});

看一下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "answer"
},
"init": {
"type": "Literal",
"value": 42,
"raw": "42"
}
}
],
"kind": "const"
},
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"operator": "==",
"left": {
"type": "Identifier",
"name": "answer"
},
"right": {
"type": "Literal",
"value": 5,
"raw": "5"
}
},
"consequent": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"computed": false,
"object": {
"type": "Identifier",
"name": "console"
},
"property": {
"type": "Identifier",
"name": "log"
}
},
"arguments": [
{
"type": "Identifier",
"name": "answer"
}
]
}
}
]
},
"alternate": null
}
],
"sourceType": "script"
}
1 Identifier :: "answer"
2 Literal :: "42"
3 VariableDeclarator :: "answer = 42"
4 VariableDeclaration :: "const answer = 42;"
5 Identifier :: "answer"
6 Literal :: "5"
7 BinaryExpression :: "answer == 5"
8 Identifier :: "console"
9 Identifier :: "log"
10 MemberExpression :: "console.log"
11 Identifier :: "answer"
12 CallExpression :: "console.log(answer)"
13 ExpressionStatement :: "console.log(answer);"
14 BlockStatement :: "{\n console.log(answer);\n}"
15 IfStatement :: "if (answer == 5) {\n console.log(answer);\n}"
16 Program :: "const answer = 42;\nif (answer == 5) {\n console.log(answer);\n}"

可以看到 walk 方法会帮助我们从内到外的遍历 AST 的节点,通过回调将当前节点返回。

原理

知道了 AST 树,我们其实就可以实现最简单的 Eslint 检查了,比如最常见的是否使用了 ===

举个例子,对于 answer == 42; 我们在 walk 过程中会得到这样一个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Node {
type: 'BinaryExpression',
start: 22,
end: 33,
left: Node {
type: 'Identifier',
start: 22,
end: 28,
name: 'answer',
parent: [Circular *1]
},
operator: '==',
right: Node {
type: 'Literal',
start: 32,
end: 33,
value: 5,
raw: '5',
parent: [Circular *1]
},
parent: Node {
type: 'IfStatement',
start: 19,
end: 55,
test: [Circular *1],
consequent: Node { type: 'BlockStatement', start: 34, end: 55, body: [Array] },
alternate: null,
parent: Node {
type: 'Program',
start: 0,
end: 56,
body: [Array],
sourceType: 'script'
}
}
}

根据这个 ast 的节点,首先判断 type 是不是 BinaryExpression,然后再判断 operator 是否是 ==!= 就可以了。

1
2
3
4
5
6
7
if(node.type === 'BinaryExpression'){
if (operator === "==") {
输出(node, "Unexpected use of ==, use === instead.");
} else if (operator === "!=") {
输出(node, "Unexpected use of !=, use !== instead.");
}
}

对于单一的规则很好实现,但把多个规则整合起来,并且便于用户扩展就是个学问了,这里学习一下 eslint 是怎么整合的。

EventEmitter 库

一个 Ast 节点对应一个要处理的规则,每遍历一个节点,就去处理相应的规则。这里使用了订阅/发布的设计模式,node.js 提供了 events.EventEmitter 库供我们使用。

我们只需要遍历所有规则列表,然后调用 on 方法,订阅相关事件,事件名就是 node.type,比如上边介绍的 BinaryExpression

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var EventEmitter = require("events").EventEmitter;
...
var api = Object.create(new EventEmitter()),
...

Object.keys(config.rules).forEach(function(key) {

var ruleCreator = rules.get(key),
rule;

if (ruleCreator) {
rule = ruleCreator(new RuleContext(key, api));

// add all the node types as listeners
Object.keys(rule).forEach(function(nodeType) {
api.on(nodeType, rule[nodeType]);
});
} else {
throw new Error("Definition for rule '" + key + "' was not found.");
}
});

然后在调用 astw 库的 walk 方法的时候 emit 一下 node.type 事件名即可。

1
2
3
4
5
6
var ast = esprima.parse(text, { loc: true, range: true }),
walk = astw(ast);

walk(function(node) {
api.emit(node.type, node);
});

源码分析

先看一下代码目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
eslint
├── LICENSE
├── README.md
├── bin
│ └── jscheck.js //入口文件,调用 cli.js 的 execute
├── config
│ └── jscheck.json //eslint 配置文件,定义检测哪些规则
├── lib
│ ├── cli.js // 主函数
│ ├── jscheck.js // 提供 verify 方法
│ ├── reporters
│ │ └── compact.js // 格式化输出的内容
│ ├── rule-context.js // 将 jsCheack 对象的方法提过给 rule 调用
│ ├── rules // 预制的规则
│ │ ├── camelcase.js
│ │ ├── curly.js
│ │ ├── eqeqeq.js
│ │ ├── no-bitwise.js
│ │ ├── no-console.js
│ │ ├── no-debugger.js
│ │ ├── no-empty.js
│ │ ├── no-eval.js
│ │ └── no-with.js
│ └── rules.js // 读取 rule 规则
├── package-lock.json
├── package.json
└── tests
└── lib
└── rules
├── camelcase.js
├── no-bitwise.js
├── no-debugger.js
├── no-eval.js
└── no-with.js

看一下 lib/cli.js 的主逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
execute: function (argv, callback) {
var options = optimist.parse(argv),
files = options._,
config;
if (options.h || options.help) {
} else {
config = readConfig(options);

// TODO: Figure out correct option vs. config for this
// load rules
if (options.rules) { // 用户传入自定义的 rules
rules.load(options.rules);
}

if (files.length) {
processFiles(files, config);
} else {
console.log("No files!");
}
}
},

其中 readConfig 就是读取了配置文件,为用户提供了 c/config 参数。

1
2
3
4
5
6
7
function readConfig(options) {
var configLocation = path.resolve(
__dirname,
options.c || options.config || DEFAULT_CONFIG
);
return require(configLocation);
}

默认的 DEFAULT_CONFIG 路径是 ../config/jscheck.json ,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"rules": {
"no-bitwise": 1,
"no-eval": 1,
"no-with": 1,
"no-empty": 1,
"no-debugger": 1,
"no-console": 1,

"camelcase": 1,
"eqeqeq": 1,
"curly": 1
}
}

processFiles(files, config) 主要就是两层循环,循环要检查的文件和上边的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function processFiles(files, config) {
var fullFileList = [];

// 如果是目录的话,继续递归去添加
files.forEach(function (file) {
if (isDirectory(file)) {
fullFileList = fullFileList.concat(getFiles(file));
} else {
fullFileList.push(file);
}
});

// 遍历文件
fullFileList.forEach(function (file) {
processFile(file, config);
});
}

看一下 processFile 函数。

1
2
3
4
5
6
7
8
function processFile(filename, config) {
// 读取文件
var text = fs.readFileSync(path.resolve(filename), "utf8"),
// 检查文件
messages = jscheck.verify(text, config);

console.log(reporter(jscheck, messages, filename, config));
}

verify 就是核心逻辑了,调用了 on 事件和 emit 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
api.verify = function (text, config) {
// reset
this.removeAllListeners();
messages = [];

// enable appropriate rules
Object.keys(config.rules).forEach(function (key) {
var ruleCreator = rules.get(key),
rule;
if (ruleCreator) {
// 将 js api 的 context 传给 rule
rule = ruleCreator(new RuleContext(key, api));

// add all the node types as listeners
// rule 规则
Object.keys(rule).forEach(function (nodeType) {
api.on(nodeType, rule[nodeType]);
});
} else {
throw new Error("Definition for rule '" + key + "' was not found.");
}
});

// save config so rules can access as necessary
currentConfig = config;
currentText = text;

/*
* Each node has a type property. Whenever a particular type of node is found,
* an event is fired. This allows any listeners to automatically be informed
* that this type of node has been found and react accordingly.
*/
var ast = esprima.parse(text, { loc: true, range: true }),
walk = astw(ast);

walk(function (node) {
api.emit(node.type, node);
});

return messages;
};

其中 ruleCreator 就是某个规则对应的内容比如下边的 curly.js 文件。

其中,上边的 new RuleContext(key, api) 就是生成了下边的 context,提过了 report 等其他方法。

这样用户自定义 rule 的时候,通过 context 就可以调用 eslint 暴露出来的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module.exports = function (context) {
return {
IfStatement: function (node) {
if (node.consequent.type !== "BlockStatement") {
context.report(node, "Expected { after 'if' condition.");
}

if (node.alternate && node.alternate.type !== "BlockStatement") {
context.report(node, "Expected { after 'else'.");
}
},

WhileStatement: function (node) {
if (node.body.type !== "BlockStatement") {
context.report(node, "Expected { after 'while' condition.");
}
},

ForStatement: function (node) {
if (node.body.type !== "BlockStatement") {
context.report(node, "Expected { after 'for' condition.");
}
},
};
};

上边就是 eslint v0.0.2 的全部代码了,更细节的内容可以在本地 git clone git@github.com:eslint/eslint.git 并且 git checkout v0.0.2 看。

核心原理就是通过 AST 语法树来进行相应的检查,然后通过 EventEmitter 进行组织调用,使用 RuleContext 将一些方法暴露出来供 rule 使用。

未来会继续总结前端相关的文章,感谢关注支持:

前端工程化发展历史

2021年从零开发前端项目指南

windliang wechat