准备了解一下 eslint
的原理,就先看一下最早一版 eslint
的实现吧。github
打了 tag
的最早的版本就是 0.0.2
了,提交记录是八年前了。
git clone git@github.com:eslint/eslint.git
并且 git checkout v0.0.2
,先看一下 package.json
。
{
"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"
}
主要涉及到 optimist
、astw
、esprima
,我们来依次了解一下。
optimist
主要作用就是帮我们解析命令行参数,我们来试验一下。
在根目录新建一个 cli.js
,并且赋予执行权限,执行 chmod x ./cli.js
,输入下边的内容:
#!/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
执行。
process
是 node
为我们提供的一个全局变量,可以拿到命令行参数 argv
。
然后执行 ./cli.js -w --hello 23 --no-ugly --name=test ./fils.js ./file2.js
,控制台会输出如下:
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
解析,我们就可以得到相应的 key
、value
键值对了。
esprima
可以做词法分析或者生成 AST
的语法树,直接看示例。
#!/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));
看一下输出:
代码语言:javascript复制词法分析
[
{ 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 })
,输出节点的时候可以帮我们输出源代码的位置,类似于下边的样子。
"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
节点还原为源代码。
#!/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));
});
看一下结果:
代码语言:javascript复制{
"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
过程中会得到这样一个节点。
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
是否是 ==
和 !=
就可以了。
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
。
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
事件名即可。
var ast = esprima.parse(text, { loc: true, range: true }),
walk = astw(ast);
walk(function(node) {
api.emit(node.type, node);
});
源码分析
先看一下代码目录:
代码语言:javascript复制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
的主逻辑:
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
参数。
function readConfig(options) {
var configLocation = path.resolve(
__dirname,
options.c || options.config || DEFAULT_CONFIG
);
return require(configLocation);
}
默认的 DEFAULT_CONFIG
路径是 ../config/jscheck.json
,内容如下:
{
"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)
主要就是两层循环,循环要检查的文件和上边的配置。
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
函数。
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
事件。
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
暴露出来的方法。
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
使用。