浅析Eslint原理

666 阅读5分钟

一、开始

Eslint 是日常工作中不可少的代码检测工具,本文简单介绍几个核心概念和 Eslint 的工作原理。Eslint 的官方文档写的比较详细,建议先阅读其文档后再读本文。

二、核心概念

Eslint 的核心概念包括Ruleplugin等。

1. Rule

关于规则官方文档介绍的很详细。Eslint 官方的ruleslib/rules目录下,看一个具体的rule——no-var

no-var规则就是禁止使用var声明变量,而是用letconst。下面是它的的代码。

可以看到它是一个对象,其包含meta对象和create函数。meta对象即为它的元信息,create函数则是rule运行时的方法。当检测到错误时,会调用context.report抛出错误,它的参数中包含一个fix方法,就是修复函数。

module.exports = {
  meta: {
    type: "suggestion",

    docs: {
      description: "require `let` or `const` instead of `var`",
      recommended: false,
      url: "https://eslint.org/docs/rules/no-var"
    },

    schema: [],
    fixable: "code",

    messages: {
      unexpectedVar: "Unexpected var, use let or const instead."
    }
  },

  create(context) {
    const sourceCode = context.getSourceCode();

    function hasSelfReferenceInTDZ(declarator) {
      if (!declarator.init) {
        return false;
      }
      const variables = context.getDeclaredVariables(declarator);

      return variables.some(hasReferenceInTDZ(declarator.init));
    }

    function canFix(node) {
      const variables = context.getDeclaredVariables(node);
      const scopeNode = getScopeNode(node);

      if (node.parent.type === "SwitchCase" ||
        node.declarations.some(hasSelfReferenceInTDZ) ||
        variables.some(isGlobal) ||
        variables.some(isRedeclared) ||
        variables.some(isUsedFromOutsideOf(scopeNode)) ||
        variables.some(hasNameDisallowedForLetDeclarations)
      ) {
        return false;
      }

      if (astUtils.isInLoop(node)) {
        if (variables.some(isReferencedInClosure)) {
          return false;
        }
        if (!isLoopAssignee(node) && !isDeclarationInitialized(node)) {
          return false;
        }
      }

      if (
        !isLoopAssignee(node) &&
        !(node.parent.type === "ForStatement" && node.parent.init === node) &&
        !astUtils.STATEMENT_LIST_PARENTS.has(node.parent.type)
      ) {

        // If the declaration is not in a block, e.g. `if (foo) var bar = 1;`, then it can't be fixed.
        return false;
      }

      return true;
    }

    /**
     * Reports a given variable declaration node.
     * @param {ASTNode} node A variable declaration node to report.
     * @returns {void}
     */
    function report(node) {
      context.report({
        node,
        messageId: "unexpectedVar",

        fix(fixer) {
          const varToken = sourceCode.getFirstToken(node, { filter: t => t.value === "var" });

          return canFix(node)
            ? fixer.replaceText(varToken, "let")
            : null;
        }
      });
    }

    return {
      "VariableDeclaration:exit"(node) {
        if (node.kind === "var") {
          report(node);
        }
      }
    };
  }
};

Rule 是 Eslint 的重要组成部分,有时间的话可以多看下其他 Rule 是怎样实现的。

2. Plugin

插件可以看作是第三方规则的集合,每个插件是一个命名格式为 eslint-plugin-<plugin-name>npm模块,看下eslint-plugin-vue的实现。

它包含了

  • rules,一系列规则
  • configs,为插件提供不同的配置,我们可以这样使用extends:['plugin:vue/recommended'],也就是使用其中一个config
  • processors,为.vue文件提供特殊的处理方法
  • environments,声明额外的环境
module.exports = {
  rules: {
    'array-bracket-newline': require('./rules/array-bracket-newline'),
    'array-bracket-spacing': require('./rules/array-bracket-spacing'),
  // ...
  },
  configs: {
    base: require('./configs/base'),
    essential: require('./configs/essential'),
    'no-layout-rules': require('./configs/no-layout-rules'),
    recommended: require('./configs/recommended'),
    'strongly-recommended': require('./configs/strongly-recommended'),
    'vue3-essential': require('./configs/vue3-essential'),
    'vue3-recommended': require('./configs/vue3-recommended'),
    'vue3-strongly-recommended': require('./configs/vue3-strongly-recommended')
  },
  processors: {
    '.vue': require('./processor')
  },
  environments: {
    'setup-compiler-macros': {
            globals: {
            defineProps: 'readonly',
            defineEmits: 'readonly',
            defineExpose: 'readonly',
            withDefaults: 'readonly'
        }
    }
  }
}

3. extends

extends是 Eslint 的一个配置项,它支持的类型有:

  • eslint:recommendedeslint:all,Eslint 的默认配置
  • 可共享配置的名称,以eslint-config-开头,比如eslint-config-standard
  • 插件,比如plugin:vue-plugin-react/recommended,或者省略vue-plugin-,即plugin:react/recommended
  • 配置文件的路径,比如./node_modules/coding-standard/eslintDefaults.js

extents中的每一项内容最终都指向了一个和 ESLint 本身配置规则相同的对象。

4. CodePath

Rule不仅可以针对每个Node.type写规则,还可以针对onCodePathStart/onCodePathEnd做相应的判断,涉及到的一个概念就是CodePathCodePath就是对代码结构做一定的拆分,在一些较为复杂的Rule中,只判断Node.type较为困难,这时可以对codepath判断。

下面是官网的一个例子:

if (a && b) {
  foo();
}
bar();

上图可以看作一个大的CodePath,可以分为5段,每段称为CodePathSegment

下面是CodePath的运用案例:

检查代码是否可达:

function isReachable(segment) {
  return segment.reachable;
}

module.exports = function(context) {
  var codePathStack = [];

  return {
    // Stores CodePath objects.
    "onCodePathStart": function(codePath) {
      codePathStack.push(codePath);
    },
    "onCodePathEnd": function(codePath) {
      codePathStack.pop();
    },

    // Checks reachable or not.
    "ExpressionStatement": function(node) {
      var codePath = codePathStack[codePathStack.length - 1];

      // Checks the current code path segments.
      if (!codePath.currentSegments.some(isReachable)) {
        context.report({message: "Unreachable!", node: node});
      }
    }
  };
};

三、运行机制

1. 总体

直接看流程图就可以了,注意eslint-disable-line这种在行内注释来忽略规则的处理,会在applyDisableDirectives中执行。

2. runRules

runRules是比较重要的内容,下面分析下其过程。

function runRules(sourceCode, configuredRules, ruleMapper, parserName, languageOptions, settings, filename, disableFixes, cwd, physicalFilename) {
  const emitter = createEmitter();
  const nodeQueue = [];
  let currentNode = sourceCode.ast;

  Traverser.traverse(sourceCode.ast, {
    enter(node, parent) {
      node.parent = parent;
      nodeQueue.push({ isEntering: true, node });
    },
    leave(node) {
      nodeQueue.push({ isEntering: false, node });
    },
    visitorKeys: sourceCode.visitorKeys
  });

  /*
   * Create a frozen object with the ruleContext properties and methods that are shared by all rules.
   * All rule contexts will inherit from this object. This avoids the performance penalty of copying all the
   * properties once for each rule.
   */
  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
      }
    )
  );

  const lintingProblems = [];

  Object.keys(configuredRules).forEach(ruleId => {
    const severity = ConfigOps.getRuleSeverity(configuredRules[ruleId]);

    // not load disabled rules
    if (severity === 0) {
      return;
    }

    const rule = ruleMapper(ruleId);

    if (!rule) {
      lintingProblems.push(createLintingProblem({ ruleId }));
      return;
    }

    const messageIds = rule.meta && rule.meta.messages;
    let reportTranslator = null;
    const ruleContext = Object.freeze(
      Object.assign(
        Object.create(sharedTraversalContext),
        {
          id: ruleId,
          options: getRuleOptions(configuredRules[ruleId]),
          report(...args) {

            /*
             * Create a report translator lazily.
             * In a vast majority of cases, any given rule reports zero errors on a given
             * piece of code. Creating a translator lazily avoids the performance cost of
             * creating a new translator function for each rule that usually doesn't get
             * called.
             *
             * Using lazy report translators improves end-to-end performance by about 3%
             * with Node 8.4.0.
             */
            if (reportTranslator === null) {
              reportTranslator = createReportTranslator({
                ruleId,
                severity,
                sourceCode,
                messageIds,
                disableFixes
              });
            }
            const problem = reportTranslator(...args);

            if (problem.fix && !(rule.meta && rule.meta.fixable)) {
              throw new Error("Fixable rules must set the `meta.fixable` property to \"code\" or \"whitespace\".");
            }
            if (problem.suggestions && !(rule.meta && rule.meta.hasSuggestions === true)) {
              if (rule.meta && rule.meta.docs && typeof rule.meta.docs.suggestion !== "undefined") {

                // Encourage migration from the former property name.
                throw new Error("Rules with suggestions must set the `meta.hasSuggestions` property to `true`. `meta.docs.suggestion` is ignored by ESLint.");
              }
              throw new Error("Rules with suggestions must set the `meta.hasSuggestions` property to `true`.");
            }
            lintingProblems.push(problem);
          }
        }
      )
    );

    const ruleListeners = createRuleListeners(rule, ruleContext);

    /**
     * Include `ruleId` in error logs
     * @param {Function} ruleListener A rule method that listens for a node.
     * @returns {Function} ruleListener wrapped in error handler
     */
    function addRuleErrorHandler(ruleListener) {
      return function ruleErrorHandler(...listenerArgs) {
        try {
          return ruleListener(...listenerArgs);
        } catch (e) {
          e.ruleId = ruleId;
          throw e;
        }
      };
    }

    // add all the selectors from the rule as listeners
    Object.keys(ruleListeners).forEach(selector => {
      const ruleListener = timing.enabled
        ? timing.time(ruleId, ruleListeners[selector])
        : ruleListeners[selector];

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

  // only run code path analyzer if the top level node is "Program", skip otherwise
  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 });

  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;
}
  1. 利用Traverser.traversesourceCode.ast进行递归遍历,将node放进nodeQueue中。nodeQueue是一个队列,一个node会被放入两次,一次是进入的时候,一次是离开的时候,结构类似:A->B-C->...->C->B-A
  2. 新建sharedTraversalContext,是rule的通用的上下文对象。
  3. 遍历configuredRules,对每一个rule创建ruleContextruleContext上包含report方法,report方法运行时会收集错误到lintingProblems中。
  4. 对每一个rule,执行它的create方法,得到返回值ruleListeners,其keynode节点,value是回调函数。对ruleListeners进行遍历,利用发布订阅模式,将node节点和对应的回调函数收集到emitter中。
  5. 遍历nodeQueue,触发emitteremit,也就是执行node节点上的回调。
  6. 返回lintingProblems

runRules利用了发布订阅模式、访问器模式,用一句话总结就是,一开始是收集代码中的node,然后收集rule的回调,最后遍历node,执行回调。

四、相关资料

  1. ESLint 工作原理探讨
  2. Code Path Analysis Details