eslint-v0.0.2做了什么

2022-09-23 13:13:28 浏览数 (2)

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

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

代码语言:javascript复制
{
  "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 ,输入下边的内容:

代码语言:javascript复制
#!/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,控制台会输出如下:

代码语言:javascript复制
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 的语法树,直接看示例。

代码语言:javascript复制
#!/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 }),输出节点的时候可以帮我们输出源代码的位置,类似于下边的样子。

代码语言:javascript复制
"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 节点还原为源代码。

代码语言:javascript复制
#!/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 过程中会得到这样一个节点。

代码语言:javascript复制
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 是否是 ==!= 就可以了。

代码语言:javascript复制
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

代码语言:javascript复制
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 事件名即可。

代码语言:javascript复制
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 的主逻辑:

代码语言:javascript复制
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 参数。

代码语言:javascript复制
function readConfig(options) {
  var configLocation = path.resolve(
    __dirname,
    options.c || options.config || DEFAULT_CONFIG
  );
  return require(configLocation);
}

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

代码语言:javascript复制
{
    "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) 主要就是两层循环,循环要检查的文件和上边的配置。

代码语言:javascript复制
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 函数。

代码语言:javascript复制
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 事件。

代码语言:javascript复制
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 暴露出来的方法。

代码语言:javascript复制
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 使用。

0 人点赞