ESLint 源码学习

273 阅读5分钟

说明:ESlint 是 JavaScript 相关开发的静态代码分析工具。本文通过源码层级介绍 ESLint 核心类/方法简要介绍ESLint工作流程。官方文档内容详实,建议先通读官方文档再阅读本文。

一、架构设计

(一) 核心类

ESLint 项目结构划分清晰,基本一个文件对应一个核心类,主要有:

  1. ESLint : 用于启动ESLint,封装处理/传递命令行参数的类。

  2. CLIEngine :提供代码检查(executeOnFiles、executeOnText)、获取配置信息(getConfigForFile)和自定义代码检验Rule (getRules)等接口。

  3. *Linter:具体执行AST解析代码(parse)、遍历处理规则(runRules)、代码检验的类

  4. SourceCode:保存原文件内容(text, lines, lineStartIndices)、AST数据结构(ast)的辅助类。

(二)扩展定义

扩展定义即用户可以自定义添加的内容,包括Rule(检验规则)、Plugin(规则集合)、extends(默认配置项)。这里重点说明官方文档中没有的内容。

1. Rule

其中create 返回监听函数对象,其中key为监听事件 或 节点:

  1. 事件:onCodePathStart, onCodePathEnd, onCodePathSegmentStart, onCodePathSegmentEnd, onCodePathSegmentLoop;
  2. 节点为 estree 中的节点类型,详情可查看 eslint-visitor-keys(或 estree定义),其中node对应词法分析中具体token
  3. 遍历顺序为:Code Path Analysis Details,结合事件举例请看下文Code Path
       module.exports = {
           meta: {
               type: "suggestion", // 规则的类型:"problem", "layout"
               messages: { // string or {}, 展示messageId
                  avoidName: "Avoid using variables named '{{ name }}'"
              },
               doc: {
                   description: "",
                   categrpy: "",
                   recommended: true, // 配置中 "extends": "eslint:recommended"启用
                   url: "" 
               },
               schema: [],
               fixable: "code", // 是否应用 fix 函数
               // context 为 linter.js 中 runRules 创建的上下文,下文详述
               create: function (context) {
                   // 返回遍历 AST 节点时触发的 节点类型 或 事件 定义的函数
                   return {
                       "onCodePathSegmentStart": function(segment, node){},
                       "ExpressionStatement:exit": function(node) {}
                   }
               }
           }
       }

2. Plugin

命名格式为 "eslint-plugin-" 的npm模块,在实际引用时可将"eslint-plugin-" 省略,具体内容可参考 eslint-plugin-vue

      module.exports = {
          // 开发者定义的所有rule
          rules: {
              'rule-name': require('./rules/test')
          },
          // 模块开发者定义的规则集
          config: {
              base: require('./config/base'),
              recommended: require('')
          },
          // 为不同文件定义处理函数,包括 preprocess, postprocess两部分
          // 具体在 linter.js 的 _verifyWithProcessor 函数中执行
          processors: {
              '.vue': require('./processor')
          },
          enviroments: {
              'setup-compiler-macros': {
                  globals: {
                      defineProps: 'readonly'
                  }
              }
          }
      }

3. Extends

配置应用的plugin对象的定义集,如 eslint:recommend, eslint:all

4. 全局配置文件,如 .eslintrc.js

// config-airbnb 中 './rules/react'
module.exports = {
  plugins: [
    'react',
  ],
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
  },
  rules: {
    'jsx-quotes': ['error', 'prefer-double'],
      ...
    },
    // 下文中 plugins 的共享配置
  settings: {
    'import/resolver': {
      node: {
        extensions: ['.js', '.jsx', '.json']
      }
    },
     ...
  }
}

二、运行流程

(一)整体流程

发现参考文章的图画的简单明了,此处直接引用。为方便个人记忆,增加了较详细的函数版本(各个类实例之间竟然是通过 WeakMap 映射的...)

(二)runRules

ESLint 核心步骤就是执行规则检查,下文通过 简化代码+注释 说明函数执行含义,主要步骤为:

  1. 按遍历顺序收集 node

  2. 遍历rule执行create函数,在emitter中绑定相应的监听事件

  3. 按收集node顺序遍历队列,并触发所有的监听事件

function runRules(sourceCode, configuredRules, ruleMapper, parserName, languageOptions, settings, filename, disableFixes, cwd, physicalFilename) {
    // 自定义事件监听/触发器 { on, emit, eventNames }
    const emitter = createEmitter();
    // 保存 node 遍历队列, 遍历顺序为上文的 Code Path Analysis Details
    const nodeQueue = [];
    let currentNode = sourceCode.ast;
    // 保存 node 的 enter 和 leave 位置
    Traverser.traverse(sourceCode.ast, {
        enter(node, parent) {
            node.parent = parent;
            nodeQueue.push({ isEntering: true, node });
        },
        leave(node) {
            nodeQueue.push({ isEntering: false, node });
        },
        // 触发的监听key
        visitorKeys: sourceCode.visitorKeys
    });

    // 定义ESLint 共享的Context
    const sharedTraversalContext = Object.freeze(
        Object.assign(
            Object.create(BASE_TRAVERSAL_CONTEXT),
            {
                getAncestors: () => getAncestors(currentNode),
                getDeclaredVariables: sourceCode.scopeManager.getDeclaredVariables.bind(sourceCode.scopeManager),
                getCwd: () => cwd,
                getFilename: () => filename,
                getPhysicalFilename: () => physicalFilename || filename,
                getScope: () => getScope(sourceCode.scopeManager, currentNode),
                // *重点
                getSourceCode: () => sourceCode,
                markVariableAsUsed: name => markVariableAsUsed(sourceCode.scopeManager, currentNode, languageOptions, name),
                parserOptions: {
                    ...languageOptions.parserOptions
                },
                parserPath: parserName,
                languageOptions,
                parserServices: sourceCode.parserServices,
                settings
            }
        )
    );

    // 保存规则检查的 problems
    const lintingProblems = [];
    // 遍历所有rule规则
    Object.keys(configuredRules).forEach(ruleId => {
        // ...
        const messageIds = rule.meta && rule.meta.messages;
        let reportTranslator = null;
        // 绑定单个 rule 上下文,包括id, option, report函数
        const ruleContext = Object.freeze(
            Object.assign(
                Object.create(sharedTraversalContext),
                {
                    id: ruleId,
                    options: getRuleOptions(configuredRules[ruleId]),
                    report(...args) {
                        if (reportTranslator === null) {
                            reportTranslator = createReportTranslator({
                                ruleId,
                                severity,
                                sourceCode,
                                messageIds,
                                disableFixes
                            });
                        }
                        const problem = reportTranslator(...args);
                        // ...
                        lintingProblems.push(problem);
                    }
                }
            )
        );
        
        // 执行 rule 的 create函数
        const ruleListeners = createRuleListeners(rule, ruleContext);
        
        function addRuleErrorHandler(ruleListener) {
            return function ruleErrorHandler(...listenerArgs) {
                try {
                    return ruleListener(...listenerArgs);
                } catch (e) {
                    e.ruleId = ruleId;
                    throw e;
                }
            };
        }

        // 设置单个 rule 所有的监听事件
        Object.keys(ruleListeners).forEach(selector => {
            const ruleListener = timing.enabled
                ? timing.time(ruleId, ruleListeners[selector])
                : ruleListeners[selector];

            emitter.on(selector, addRuleErrorHandler(ruleListener));
        });
    });

    const eventGenerator = nodeQueue[0].node.type === "Program"
        ? new CodePathAnalyzer(new NodeEventGenerator(emitter, { visitorKeys: sourceCode.visitorKeys, fallback: Traverser.getKeys }))
        : new NodeEventGenerator(emitter, { visitorKeys: sourceCode.visitorKeys, fallback: Traverser.getKeys });

    // * 按 AST 遍历顺序触发相应的监听事件:
    // 其中 CodePathAnalyzer 类的 enterNode 执行preprocess 函数(非processors定义的process), 处理codePath相关内容,
    // 并触发 onCodePathStart/onCodePathSegmentStart, 而后触发NodeEventGenerator 的 enterNode函数;
    // leaveNode 的postprocess触发 onCodePathStart/onCodePathSegmentStart 事件
    nodeQueue.forEach(traversalInfo => {
        currentNode = traversalInfo.node;
        try {
            if (traversalInfo.isEntering) {
                eventGenerator.enterNode(currentNode);
            } else {
                eventGenerator.leaveNode(currentNode);
            }
        } catch (err) {
            err.currentNode = currentNode;
            throw err;
        }
    });

    return lintingProblems;
}

(三)Code Path

代码展示事件顺序:

// onCodePathStart-------
// onCodePathSegmentStart=======
let i = 0;
// onCodePathSegmentEnd=======
// onCodePathSegmentStart=======
while(
// test: 判断语句
    i < 3
// onCodePathSegmentEnd=======
// onCodePathSegmentStart=======
) {
    i++;
// onCodePathSegmentLoop************
// onCodePathSegmentEnd=======
}

结合node遍历:

code-path:创建(更新)分支逻辑:preprocess, processCodePathToExit

// preprocess: 根据父节点类型更新路径(创建分支)
// processCodePathToExit: 根据节点类型更新路径
// 即构造成上图的路径图(多种情况,需要细化)

三、自定义rule

自定义消除代码中 console.time 的规则

//-----------------------------------------------------------------------
// Rule Definition
// 命名约定:
// 如果你的规则是禁止什么,加前缀 no-,比如 no-eval 禁用 eval(),no-debugger禁用debugger
// 如果你规则是强制包含什么,使用一个简短的名称,不带特殊的前缀。
// 在单词之间使用连字符
//-----------------------------------------------------------------------

/**
 * @type {import('eslint').Rule.RuleModule}
 * working with rules: https://cn.eslint.org/docs/developer-guide/working-with-rules
 */
module.exports = {
  meta: {
    type: "suggestion", // `problem`, `suggestion`, or `layout`
    docs: {
      description: "no console.time()",
      category: "Fill me in",
      recommended: false,
      url: null, // URL to the documentation page for this rule
    },
    // 如果没有 fixable 属性,即使规则实现了 fix 功能,ESLint 也不会进行修复
    // fixable: null, // Or `code` or `whitespace`
    fixable: "code",
    // 为options规则, 避免无效规则配置
    // 示例: 接受一个参数
    schema: [
      {
        type: "array", // 参数类型为数组,
        items: {
          type: "string", // 数组的每一项为字符串
        },
      },
    ], // Add a schema if the rule has options,
    // 报错信息描述
    messages: {
      avoidMethod: "conosole method '{{name}}' is forbidden.'",
    },
  },

  // context: https://cn.eslint.org/docs/user-guide/configuring#specifying-parser-options
  create(context) {
    // .parserOptions // 对应 .eslintrc.* 的 parserOptions 选项
    // .id // rule 的 ID
    // .options // 对应上文rule定义的 options
    // { "quotes": ["error", "double"] } => context.options[0] === "double"
    // .settings // 个屏幕共享的其阿米Setting
    // .parserPath // 定义 parser 的名称
    // .getAncestors() // 获取AST的父node数组
    // .getCwd() // 获取当前路径
    // .getDeclaredVariables(node) // 获取变量声明
    // .getFilename() // returns the filename associated with the source
    // .getPhysicalFilename() // 返回链接文件的完整路径

    // 返回一个SourceCode 对象: https://cn.eslint.org/docs/developer-guide/working-with-rules#contextgetsourcecode
    // .getSourceCode()
    // .markVariableAsUsed(name) // 在当前作用域内用给定的名称标记一个变量
    // 获取非 AST 的 comment部分
    // sourceCode.getAllComments()
    // sourceCode.getCommentsBefore(), .getCommentsAfter(), .getCommentsInside()

    // .getScope() // 返回当前遍历节点的 scope
    // ASTNode Type: https://cn.eslint.org/docs/developer-guide/working-with-rules#contextgetscope
    // ASTNode定义: https://github.com/estree/estree, 规范: https://hexdocs.pm/estree/ESTree.ClassDeclaration.html#content

    // .report(descriptor)
    // node:  节点
    // message: 传递消息
    /**
     * fix: function(fixer) {} // 修复函数: 文件内容会被解析为字符串text
     * insertTextAfter(nodeOrToken, text)
     * insertTextAfterRange(range, text)
     * insertTextBefore(nodeOrToken, text)
     * insertTextBeforeRange(range, text)
     * remove(nodeOrToken)
     * removeRange(range)
     * replaceText(nodeOrToken, text)
     * replaceTextRange(range, text)
     *
     * 返回一个fixing对象, 包含fixing对象的数组
     * */
    return {
      // visitor functions for different types of nodes
      // 1. 遍历节点(进入或退出时)
      // 2. 触发事件: onCodePathStart, onCodePathEnd, onCodePathSegmentStart, onCodePathSegmentEnd, onCodePathSegmentLoop
      // 以ast中类名为键名的选择函数
      "CallExpression MemberExpression": (node) => {
        // console.log("node", node);
        // console.log("context", context);
        // 如果在ast中满足一下条件,就用 context.report() 进行对外警告
        if (node.property.name === "time" && node.object.name === "console") {
          context.report({
            node,
            messageId: "avoidMethod",
            data: {
              name: "time",
            },
            // 修复: 返回更新后的 Array<string>
            // fix(fixer) {
            //   const fixes = []
            //   const domainNode = (node.type === "Literal" && node) ||
            //   (node.type === "TemplateLiteral" && node.quasis[0]);
            //   fixes.push( fixer.replaceTextRange(
            //     [domainNode.range[0] + 1, domainNode.range[1] - 1],
            //     "replace string"
            //   ))
            //   return  fixes
            // }
          });
        }
      },
      // 文件开始的 Program 节点的 start
      onCodePathStart: function (codePath, node) {
        console.log('onCodePathStart-------');
      },
      onCodePathEnd: function (codePath, node) {
        console.log('onCodePathEnd-------')
      },
      onCodePathSegmentStart: function (segment, node) {
        console.log('onCodePathSegmentStart=======')
      },
      onCodePathSegmentEnd: function (segment, node) {
        console.log('onCodePathSegmentEnd=======')
      },
      onCodePathSegmentLoop: function (segment,toSegment, node) {
        console.log('onCodePathSegmentLoop************')
      },
    };
  },
};

四、参考文档

  1. 官网
  2. Writing Resilient Components
  3. ESLint 工作原理探讨
  4. 浅析Eslint原理