从架构和源码角度重新认识 ESLint (二)

前言

在上一篇文章 从架构和源码角度重新认识 ESLint (一) 中,我主要从 ESLint 的使用和架构出发,介绍了 ESLint 核心的功能模块,以及怎么更好地利用 debug 模式,帮助我们去定位问题。

本篇文章将深入到源码,主要围绕 ESLint 的配置文件读取、加载和合并等逻辑,这部分的源码主要在

lib/cli-engine目录下,而该模块依赖了官方的 @eslint/eslintrc 包,实际上比较核心的配置处理逻辑全部封装在该包中。

本文的源码分析 ESLint 版本是 v8.57.0,目前官方版本已经到 9.x 版本,因为我的团队还在使用 8.x 版本,因此主要围绕 8.x 最后一个版本 8.57.0 进行源码解读。

下面开始进入正题。

CliEngine

我们首先回顾上一篇文章中的架构图:

cli-engine 作为配置处理的关键功能模块,它的调用处于比较上层的位置,而 lib/cli-engine 主要封装了一个 CliEngine类,导出给外部调用。

cli-engine最核心的配置处理逻辑主要集中在 @eslint/eslintrc 包中,它导出了三个关键的模块:

  • CascadingConfigArrayFactory,用户配置读取、加载、合并的类,是整个 ESLint 配置处理的核心入口
  • ModuleResolver,文件加载器
  • getUsedExtractedConfigs,从缓存中读取处理后的最终配置

下面看 CliEngine 源码:

js 复制代码
const {
  Legacy: {
    CascadingConfigArrayFactory,
    getUsedExtractedConfigs,
    ModuleResolver
  }
} = require("@eslint/eslintrc");

const builtInRules = require("../rules");
const loadRules = require("./load-rules");

const debug = require("debug")("eslint:cli-engine");

/** @type {WeakMap<CLIEngine, CLIEngineInternalSlots>} */
const internalSlotsMap = new WeakMap();

/**
 * Core CLI.
 */
class CLIEngine {

  /**
    * Creates a new instance of the core CLI engine.
    * @param {CLIEngineOptions} providedOptions The options for this instance.
    * @param {Object} [additionalData] Additional settings that are not CLIEngineOptions.
    * @param {Record<string,Plugin>|null} [additionalData.preloadedPlugins] Preloaded plugins.
  */
  constructor(providedOptions, { preloadedPlugins } = {}) {
    const options = Object.assign(
      Object.create(null),
      defaultOptions,
      { cwd: process.cwd() },
      providedOptions
    );

    // 读取项目的缓存文件
    const cacheFilePath = getCacheFile(
      options.cacheLocation || options.cacheFile,
      options.cwd
    );

    // 初始化 configArrayFactory 实例
    const configArrayFactory = new CascadingConfigArrayFactory({
      additionalPluginPool,
      baseConfig: options.baseConfig || null,
      cliConfig: createConfigDataFromOptions(options),
      cwd: options.cwd,
      ignorePath: options.ignorePath,
      resolvePluginsRelativeTo: options.resolvePluginsRelativeTo,
      rulePaths: options.rulePaths,
      specificConfigPath: options.configFile,
      useEslintrc: options.useEslintrc,
      builtInRules,
      loadRules,
      getEslintRecommendedConfig: () => require("@eslint/js").configs.recommended,
      getEslintAllConfig: () => require("@eslint/js").configs.all
    });

    // 创建缓存和 linter 实例
    const lintResultCache =
      options.cache ? new LintResultCache(cacheFilePath, options.cacheStrategy) : null;
    const linter = new Linter({ cwd: options.cwd });

    // 省略一些代码

    // setup special filter for fixes
    if (options.fix && options.fixTypes && options.fixTypes.length > 0) {
      debug(`Using fix types ${options.fixTypes}`);
      // 省略一些代码
    }
  }

  // Return Map<Rule>
  getRules() {
    // 通过一个 Map 存储所有的内置规则和用户配置的插件规则
  }

  /**
    * Executes the current configuration on an array of file and directory names.
    * @returns {LintReport} The results for all files that were linted.
  */
  executeOnFiles(patterns) {
    // 省略代码
  }

  /**
    * Executes the current configuration on text.
    * @returns {LintReport} The results for the linting.
  */
  executeOnText(text, filename, warnIgnored) {
    // 省略代码
  }

  /**
    * Returns a configuration object for the given file based on the CLI options.
    * @returns {ConfigData} A configuration object for the file.
  */
  getConfigForFile(filePath) {
    const { configArrayFactory, options } = internalSlotsMap.get(this);
    const absolutePath = path.resolve(options.cwd, filePath);

    if (directoryExists(absolutePath)) {
      throw Object.assign(
        new Error("'filePath' should not be a directory path."),
        { messageTemplate: "print-config-with-directory-path" }
      );
    }

    return configArrayFactory
      .getConfigArrayForFile(absolutePath)
      .extractConfig(absolutePath)
      .toCompatibleObjectAsConfigFileContent();
  }

  /**
    * Returns the formatter representing the given format or null if the `format` is not a string.
    * @returns {(FormatterFunction|null)} The formatter function or null if the `format` is not a string.
  */
  getFormatter(format) {
    // 省略代码
  }
}

CLIEngine.version = pkg.version;
CLIEngine.getFormatter = CLIEngine.prototype.getFormatter;

module.exports = {
  CLIEngine,

  /**
    * Get the internal slots of a given CLIEngine instance for tests.
    * @param {CLIEngine} instance The CLIEngine instance to get.
    * @returns {CLIEngineInternalSlots} The internal slots.
  */
  getCLIEngineInternalSlots(instance) {
    return internalSlotsMap.get(instance);
  }
};

删除了一些非核心或者本次源码解读不太关注的代码,除去注释后代码在 100 行左右。

我们重点关注以下几点:

  • 初始化 CliEngine 实例时会创建若干关键的功能模块实例:

    • configArrayFactory,需要重点关注的配置处理实例
    • lintResultCache ,ESLint lint 结果缓存实例,只有当配置中配置 cachetrue 时才创建,cli 通过 --cache 开启
    • linter,代码 lint 的核心类实例
  • CliEngine 封装的几个公开方法:

    • getRules,这个方法会将内置的规则和用户配置的规则合并,并返回一个 Map
    • executeOnFiles ,接受 glob 文件参数,核心就是读取文件范围源码,调用 linter.verify 对代码进行 lint,过程中会利用 lintResultCache 将上次结果进行缓存,下次 lint 时就可以尽量利用缓存提升 lint 速度
    • executeOnText,跟 executeOnFiles 方法功能类似,区别就是 executeOnText 接收的主要参数是源码字符串,在一些场景下可以脱离文件 IO 使用
    • getFormatter ,根据当前传入的参数获取 lint 结果 fomat 格式,根据 format 将结果输出到一个文本或者 JS 文件中,具体使用可以参考文档:--format,官方也支持自定义 formatter
  • CliEngine 实例化后会通过 WeakMap 将实例进行缓存,可以重复利用。毕竟一次 CliEngine 的实例初始化涉及到很多的文件 IO 操作,这个过程是比较耗时和占用 CPU 的,因此通过缓存一定程度解决这些问题

  • CliEngine 内部的一些执行日志,通过 DEBUG scope eslint:cli-engine开启

摘掉一些非核心代码,再去梳理 CliEngine 逻辑就相对清晰。当然一些详细的实现,例如 LintResultCache 是怎么做缓存的,什么时候缓存失效等,需要深入到这部分源码去看。这部分我个人还是很感兴趣的,后续可能会考虑再解读下这部分源码。

回到主线,接下来我们要深入到 CascadingConfigArrayFactory 的实现源码,它是 ESLint 配置处理最关键的类。它封装在 @eslint/eslintrc包中,所以我们需要切下进程。

CascadingConfigArrayFactory

在开始解读源码之前,我们先看下 ESLint 官方是怎么介绍 @eslint/eslintrc 该包的:

This repository contains the legacy ESLintRC configuration file format for ESLint. This package is not intended for use outside of the ESLint ecosystem. It is ESLint-specific and not intended for use in other programs.

Note: This package is frozen except for critical bug fixes as ESLint moves to a new config system.

大概意思:该仓库包含旧 ESLintRC 配置文件格式,虽然是 ESLint 的规范实现,但是该包不打算暴露给 ESLint 生态或者其他开发者使用。最后提示,目前包的状态只是修复在迁移到新的配置系统时遇到的问题,不再开发新功能。

要完全理解上面的几句话,要对 ESLint 新旧配置系统有一些了解。在 8.x 及以下版本 ESLint 配置是通过 .eslintrc.*(js、json、cjs、yml) 等文件格式实现的,这也就是上述所说的旧 ESLintRC 配置系统。在 ESLint 9.x 版本,它升级了新的配置系统,配置格式为:eslint.config.*(js、json、cjs) 。因此为了迁移到新的配置系统,将 8.x 之前版本 ESLintRC 配置处理逻辑进行抽象,也是解耦了新版 9.x 的代码为了兼容旧配置系统的复杂性。

在正式解读源码前,为了帮助大家更好地理解这个包的功能模块,先看一个大概的功能模块图:

CliEngine 消费的 CascadingConfigArrayFactory 是包中最顶层的抽象类型,下面还依赖 share 、config-array-factory 等模块:

  • share ,主要封装一些通用公共的模块,例如配置校验、模块加载器:

    • config-validator,主要校验 ESlint 的整份 ESLintRC 配置、规则配置、enviroment 配置、global 配置等
    • relative-module-resolver,通过 Node.js 内置的 Module.createRequire创建了一个模块加载器,这是 ESLint 内置默认的文件、插件等加载器,贯穿整个源码使用
    • 除此之外还有一些其它的处理规则告警级别、三方包名等 utils,这里不一一介绍
  • config-array-factory ,配置加载的核心实现,底层还依赖 config-array 做配置合并的逻辑,特别是对于嵌套的配置,例如用户配置了一个 share config a(下面会详细介绍),a 里面配置了一些插件和其他选项,a 的配置与项目配置的合并策略

因此我们解读的源码核心聚焦在上图的左边部分 config-array-factory 以及 config-array 。

share config 和插件

为了更好理解后面的内容,在 ESLint 中有两个概念需要讲清楚: share config(根据官方的说法引入) 和插件

在 ESLint 中有两种配置插件的方式:

js 复制代码
// eslintrc.js
module.export = {
  extends: ["plugin:@typescript-eslint/recommended"]
}

// or 
module.export = {
  plugins: ["@typescript-eslint"]
}

而上面代码示例中,第一种形式,是通过 share config 的方式配置,也就是 extends 配置项,此时通过

plugin:xxx注册,ESLint 背后加载时会将其当成一个插件处理。

那么 share config 使用场景是什么?

例如我现在有多个核心技术栈基本一致的项目,我想使得每个项目都享用同一份 ESLintRC 配置,里面包含了一些插件、规则告警级别以及其他通用配置等。这个时候,我们就可以使用 share config 抽离一个 NPM 包

arduino 复制代码
// eslint-config-share.js
module.export = {
  plugins: ["@typescript-eslint"],
  globals: {
    MyGlobal: true
  },
  rules: {
    semi: [2, "always"]
  }
}  

安装依赖后在项目中使用:

arduino 复制代码
// a 项目 .eslintrc.js
module.export = {
  plugins: ["share"],
} 

通过这种方式共享配置,后续迭代升级,所有项目只需要升级配置版本即可。这里有两个点需要注意:

  • 只有包名为 eslint-config-xxx才能直接通过 extends配置,而且可以忽略前面的 eslint-config
  • 如果要把插件当做 share config 注册,则需要使用 plugin:前缀,如上面例子所示

其加载方式、配置合并策略和一般的插件会有区别,大家平时在开发中需要注意,要能区分当前配置的是一个插件还是一个 share config,否则 ESLint 解析配置时会报错。

ESLint 的插件定位就是提供一系列规则集合,正常情况下不太会提供其他配置,当然你也可以把一个插件当成 share config 使用,在注册的时候需要注意。

讲清楚这两个概念后,我们进入下面的源码解读。

cascading-config-array-factory

先从 CascadingConfigArrayFactory 入口开始:

js 复制代码
// cascading-config-array-factory.js
const debug = debugOrig("eslintrc:cascading-config-array-factory");

/** @type {WeakMap<CascadingConfigArrayFactory, CascadingConfigArrayFactoryInternalSlots>} */
const internalSlotsMap = new WeakMap();

/**
 * This class provides the functionality that enumerates every file which is
 * matched by given glob patterns and that configuration.
 */
class CascadingConfigArrayFactory {

  /**
     * Initialize this enumerator.
     * @param {CascadingConfigArrayFactoryOptions} options The options.
     */
  constructor({
    additionalPluginPool = new Map(),
    baseConfig: baseConfigData = null,
    cliConfig: cliConfigData = null,
    cwd = process.cwd(),
    ignorePath,
    resolvePluginsRelativeTo,
    rulePaths = [],
    specificConfigPath = null,
    useEslintrc = true,
    builtInRules = new Map(),
    loadRules,
    resolver,
    eslintRecommendedPath,
    getEslintRecommendedConfig,
    eslintAllPath,
    getEslintAllConfig
  } = {}) {
    const configArrayFactory = new ConfigArrayFactory({
      additionalPluginPool,
      cwd,
      resolvePluginsRelativeTo,
      builtInRules,
      resolver,
      eslintRecommendedPath,
      getEslintRecommendedConfig,
      eslintAllPath,
      getEslintAllConfig
    });

    internalSlotsMap.set(this, {
      baseConfigArray: createBaseConfigArray({
        baseConfigData,
        configArrayFactory,
        cwd,
        rulePaths,
        loadRules
      }),
      baseConfigData,
      cliConfigArray: createCLIConfigArray({
        cliConfigData,
        configArrayFactory,
        cwd,
        ignorePath,
        specificConfigPath
      }),
      cliConfigData,
      configArrayFactory,
      configCache: new Map(),
      cwd,
      finalizeCache: new WeakMap(),
      ignorePath,
      rulePaths,
      specificConfigPath,
      useEslintrc,
      builtInRules,
      loadRules
    });
  }
  /**
    * Get the config array of a given file.
    * If `filePath` was not given, it returns the config which contains only
    * `baseConfigData` and `cliConfigData`.
    * @param {string} [filePath] The file path to a file.
    * @param {Object} [options] The options.
    * @param {boolean} [options.ignoreNotFoundError] If `true` then it doesn't throw `ConfigurationNotFoundError`.
    * @returns {ConfigArray} The config array of the file.
  */
  getConfigArrayForFile(filePath, { ignoreNotFoundError = false } = {}) {
    const {
      baseConfigArray,
      cliConfigArray,
      cwd
    } = internalSlotsMap.get(this);

    if (!filePath) {
      return new ConfigArray(...baseConfigArray, ...cliConfigArray);
    }

    const directoryPath = path.dirname(path.resolve(cwd, filePath));

    debug(`Load config files for ${directoryPath}.`);

    return this._finalizeConfigArray(
      this._loadConfigInAncestors(directoryPath),
      directoryPath,
      ignoreNotFoundError
    );
  }

  /**
    * Load and normalize config files from the ancestor directories.
    * @param {string} directoryPath The path to a leaf directory.
    * @param {boolean} configsExistInSubdirs `true` if configurations exist in subdirectories.
    * @returns {ConfigArray} The loaded config.
    * @private
  */
  _loadConfigInAncestors(directoryPath, configsExistInSubdirs = false) {
    // 省略代码
  }

  /**
    * Finalize a given config array.
    * Concatenate `--config` and other CLI options.
    * @param {ConfigArray} configArray The parent config array.
    * @param {string} directoryPath The path to the leaf directory to find config files.
    * @param {boolean} ignoreNotFoundError If `true` then it doesn't throw `ConfigurationNotFoundError`.
    * @returns {ConfigArray} The loaded config.
    * @private
  */
  _finalizeConfigArray(configArray, directoryPath, ignoreNotFoundError) {
    // 省略代码
  }
}

CascadingConfigArrayFactory 入口的作用是以下几点:

  • 实例化 configArrayFactory ,通过一个 WeakMap 缓存一些配置和上下文信息
  • 封装 getConfigArrayForFile 方法,根据传入的文件路径构造一个 configArrayFactory 实例,而构造 configArrayFactory 需要的配置两份关键配置,一份是用户配置,一份是 cli 参数传入的配置
  • 用户指定配置文件路径和不指定的处理方式,会有一些区别,也就是我们使用eslint . --config .eslintrc.json是否指定了 --config 参数的区别,指定了则通过私有方法 _finalizeConfigArray对配置再进行一次整合

在上面的逻辑中我们需要重点关注下 createBaseConfigArraycreateCLIConfigArray函数,这两个函数封装在 CascadingConfigArrayFactory 类之外的代码中:

js 复制代码
/**
 * Create the config array from `baseConfig` and `rulePaths`.
 * @param {CascadingConfigArrayFactoryInternalSlots} slots The slots.
 * @returns {ConfigArray} The config array of the base configs.
 */
function createBaseConfigArray({
  configArrayFactory,
  baseConfigData,
  rulePaths,
  cwd,
  loadRules
}) {
  const baseConfigArray = configArrayFactory.create(
    baseConfigData,
    { name: "BaseConfig" }
  );

  /*
     * Create the config array element for the default ignore patterns.
     * This element has `ignorePattern` property that ignores the default
     * patterns in the current working directory.
     */
  baseConfigArray.unshift(configArrayFactory.create(
    { ignorePatterns: IgnorePattern.DefaultPatterns },
    { name: "DefaultIgnorePattern" }
  )[0]);

  /*
     * Load rules `--rulesdir` option as a pseudo plugin.
     * Use a pseudo plugin to define rules of `--rulesdir`, so we can validate
     * the rule's options with only information in the config array.
     */
  if (rulePaths && rulePaths.length > 0) {
    baseConfigArray.push({
      type: "config",
      name: "--rulesdir",
      filePath: "",
      plugins: {
        "": new ConfigDependency({
          definition: {
            rules: rulePaths.reduce(
              (map, rulesPath) => Object.assign(
                map,
                loadRules(rulesPath, cwd)
              ),
              {}
            )
          },
          filePath: "",
          id: "",
          importerName: "--rulesdir",
          importerPath: ""
        })
      }
    });
  }

  return baseConfigArray;
}

/**
 * Create the config array from CLI options.
 * @param {CascadingConfigArrayFactoryInternalSlots} slots The slots.
 * @returns {ConfigArray} The config array of the base configs.
 */
function createCLIConfigArray({
  cliConfigData,
  configArrayFactory,
  cwd,
  ignorePath,
  specificConfigPath
}) {
  const cliConfigArray = configArrayFactory.create(
    cliConfigData,
    { name: "CLIOptions" }
  );

  cliConfigArray.unshift(
    ...(ignorePath
        ? configArrayFactory.loadESLintIgnore(ignorePath)
        : configArrayFactory.loadDefaultESLintIgnore())
  );

  if (specificConfigPath) {
    cliConfigArray.unshift(
      ...configArrayFactory.loadFile(
        specificConfigPath,
        { name: "--config", basePath: cwd }
      )
    );
  }

  return cliConfigArray;
}

createBaseConfigArray 主要是通过 configArrayFactory.create 创建了一份 baseConfigArray,并且往里面增加了 rulesdir 相关的配置,看注释是为了之后校验用的,下面看到这部分源码我们再展开。

createCLIConfigArray 同样背后也是依赖 configArrayFactory.create 创建一份配置,并且根据是否传入 --config 合并配置。

到这里,我们能从源码掌握的信息还是不多,所以我们需要继续下钻到 configArrayFactory 的源码。

config-array-factory

先直接上源码:

js 复制代码
// config-array-factory.js
const debug = debugOrig("eslintrc:config-array-factory");

/**
 * The factory of `ConfigArray` objects.
 */
class ConfigArrayFactory {

  /**
     * Initialize this instance.
     * @param {ConfigArrayFactoryOptions} [options] The map for additional plugins.
     */
  constructor({
    additionalPluginPool = new Map(),
    cwd = process.cwd(),
    resolvePluginsRelativeTo,
    builtInRules,
    resolver = ModuleResolver,
    eslintAllPath,
    getEslintAllConfig,
    eslintRecommendedPath,
    getEslintRecommendedConfig
  } = {}) {
    internalSlotsMap.set(this, {
      additionalPluginPool,
      cwd,
      resolvePluginsRelativeTo:
        resolvePluginsRelativeTo &&
        path.resolve(cwd, resolvePluginsRelativeTo),
      builtInRules,
      resolver,
      eslintAllPath,
      getEslintAllConfig,
      eslintRecommendedPath,
      getEslintRecommendedConfig
    });
  }

  /**
    * Create `ConfigArray` instance from a config data.
    * @param {ConfigData|null} configData The config data to create.
    * @param {Object} [options] The options.
    * @param {string} [options.basePath] The base path to resolve relative paths in `overrides[].files`, `overrides[].excludedFiles`, and `ignorePatterns`.
    * @param {string} [options.filePath] The path to this config data.
    * @param {string} [options.name] The config name.
    * @returns {ConfigArray} Loaded config.
  */
  create(configData, { basePath, filePath, name } = {}) {
    if (!configData) {
      return new ConfigArray();
    }

    const slots = internalSlotsMap.get(this);
    const ctx = createContext(slots, "config", name, filePath, basePath);
    const elements = this._normalizeConfigData(configData, ctx);

    return new ConfigArray(...elements);
  }

  /**
     * Load a config file.
     * @param {string} filePath The path to a config file.
     * @param {Object} [options] The options.
     * @param {string} [options.basePath] The base path to resolve relative paths in `overrides[].files`, `overrides[].excludedFiles`, and `ignorePatterns`.
     * @param {string} [options.name] The config name.
     * @returns {ConfigArray} Loaded config.
  */
  loadFile(filePath, { basePath, name } = {}) {
    const slots = internalSlotsMap.get(this);
    const ctx = createContext(slots, "config", name, filePath, basePath);

    return new ConfigArray(...this._loadConfigData(ctx));
  }

  /**
    * Load `.eslintignore` file.
    * @param {string} filePath The path to a `.eslintignore` file to load.
    * @returns {ConfigArray} Loaded config. An empty `ConfigArray` if any config doesn't exist.
  */
  loadESLintIgnore(filePath) {
    // 省略代码
  }

  /**
     * Load a given config file.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {IterableIterator<ConfigArrayElement>} Loaded config.
     * @private
  */
  _loadConfigData(ctx) {
    return this._normalizeConfigData(loadConfigFile(ctx.filePath), ctx);
  }

  /**
     * Normalize a given config to an array.
     * @param {ConfigData} configData The config data to normalize.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
     * @private
  */
  _normalizeConfigData(configData, ctx) {
    const validator = new ConfigValidator();

    validator.validateConfigSchema(configData, ctx.name || ctx.filePath);
    return this._normalizeObjectConfigData(configData, ctx);
  }

  /**
     * Normalize a given config to an array.
     * @param {ConfigData|OverrideConfigData} configData The config data to normalize.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
     * @private
  */
  *_normalizeObjectConfigData(configData, ctx) {
    // 省略代码
  }

  /**
     * Normalize a given config to an array.
     * @param {ConfigData} configData The config data to normalize.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
     * @private
  */
  *_normalizeObjectConfigDataBody(
    {
      // 省略代码
    },
    ctx
  ) {
    // 省略代码
  }

  /**
     * Load configs of an element in `extends`.
     * @param {string} extendName The name of a base config.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
     * @private
  */
  _loadExtends(extendName, ctx) {
    // 省略代码
  }

  /**
    * Load configs of an element in `extends`.
    * @param {string} extendName The name of a base config.
    * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
    * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
    * @private
  */
  _loadExtendedBuiltInConfig(extendName, ctx) {
    // 省略代码
  }

  /**
     * Load configs of an element in `extends`.
     * @param {string} extendName The name of a base config.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
     * @private
  */
  _loadExtendedPluginConfig(extendName, ctx) {
    // 省略代码
  }

  /**
     * Load configs of an element in `extends`.
     * @param {string} extendName The name of a base config.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
     * @private
  */
  _loadExtendedShareableConfig(extendName, ctx) {
    // 省略代码
  }

  /**
     * Load given plugins.
     * @param {string[]} names The plugin names to load.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {Record<string,DependentPlugin>} The loaded parser.
     * @private
  */
  _loadPlugins(names, ctx) {
    // 省略代码
  }

  /**
     * Load a given parser.
     * @param {string} nameOrPath The package name or the path to a parser file.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {DependentParser} The loaded parser.
     */
  _loadParser(nameOrPath, ctx) {
    // 省略代码
  }

  /**
     * Load a given plugin.
     * @param {string} name The plugin name to load.
     * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
     * @returns {DependentPlugin} The loaded plugin.
     * @private
     */
  _loadPlugin(name, ctx) {
    // 省略代码
  }
}

面对源码相对来说比较多的文件,其包含源码行数约 1000 多一点 ,在阅读时,我们不着急去关注细节,试图看懂所有的 API 实现,第一步我们需要理清楚它们之间的调用逻辑。然后再进行分析,我们可以发现,看起来该类实现的私有 API 比较多,但是基本分为三类:

  • 第一类,_loadXXX,plugin、parser、config 等的加载处理,大同小异
  • 第二类,_normalizeConfigXXX 等,主要是根据不同的配置参数和方式,然后调用上述的 _loadXXX API 加载模块
  • 其它,多数是非核心逻辑封装,一些特殊和边界场景的处理,可以先不关注

分完类,理清主要矛盾,我们发现它的逻辑基本清晰了,我们只需要看懂三类 API 的一两个例子具体实现,其它的就手到擒来了。

回到正题,我们从 create 入口出发,先理清调用链,也就是 CascadingConfigArrayFactory 对 configArrayFactory.create 的 API 调用。

看一个依赖调用关系图:

有了上面这个图,我们对 ConfigArrayFactory 实现的 API 之间调用逻辑关系就有一个非常清晰的认识。从图中调用关系结合源码我们可以知道:

  • 从 create API 出发,中间经过一系列 API 调用,最后其实创建的是一个 ConfigArray 实例
  • 各种 share config、plugins、parser 的加载最后创建的是 ConfigDepenency 实例
  • 在 normalize 配置时,会通过 share 模块导出的 ConfigValidator 对传入的配置进行校验
  • 加载 share config 也就是 _loadExtends 是相对复杂的,它是一个深度遍历递归加载的过程

有了清晰的依赖关系,我们开始深入到部分 API 的细节实现,值得关注的是以下几个 API:create、_normalizeObjectConfigData、_loadExtends 等。

首先看 create 方法:

js 复制代码
/**
  * Create `ConfigArray` instance from a config data.
  * @param {ConfigData|null} configData The config data to create.
  * @param {Object} [options] The options.
  * @param {string} [options.basePath] The base path to resolve relative paths in `overrides[].files`, `overrides[].excludedFiles`, and `ignorePatterns`.
  * @param {string} [options.filePath] The path to this config data.
  * @param {string} [options.name] The config name.
  * @returns {ConfigArray} Loaded config.
*/
create(configData, { basePath, filePath, name } = {}) {
  if (!configData) {
    return new ConfigArray();
  }

  const slots = internalSlotsMap.get(this);
  const ctx = createContext(slots, "config", name, filePath, basePath);
  const elements = this._normalizeConfigData(configData, ctx);

  return new ConfigArray(...elements);
}

它是整个配置创建的起点,逻辑比较简单,通过传入的配置,调用 _normalizeConfigData 进行配置处理和加载,最后创建 ConfigArray 实例。

_normalizeConfigData 经过层层调用最后核心是执行 _normalizeObjectConfigData 方法,当然 _normalizeConfigData 本身还包含了参数校验的逻辑,这部分比较容易理解,所以这里直接略过,感兴趣小伙伴可以自行去看。下面直接看 _normalizeObjectConfigData 的实现:

js 复制代码
/**
  * Normalize a given config to an array.
  * @param {ConfigData} configData The config data to normalize.
  * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
  * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
  * @private
*/
*_normalizeObjectConfigDataBody(
  {
    env,
    extends: extend,
    globals,
    ignorePatterns,
    noInlineConfig,
    parser: parserName,
    parserOptions,
    plugins: pluginList,
    processor,
    reportUnusedDisableDirectives,
    root,
    rules,
    settings,
    overrides: overrideList = []
  },
  ctx
) {
  const extendList = Array.isArray(extend) ? extend : [extend];
  const ignorePattern = ignorePatterns && new IgnorePattern(
    Array.isArray(ignorePatterns) ? ignorePatterns : [ignorePatterns],
    ctx.matchBasePath
  );

  // Flatten `extends`.
  for (const extendName of extendList.filter(Boolean)) {
    yield* this._loadExtends(extendName, ctx);
  }

  // Load parser & plugins.
  const parser = parserName && this._loadParser(parserName, ctx);
  const plugins = pluginList && this._loadPlugins(pluginList, ctx);

  // Yield pseudo config data for file extension processors.
  if (plugins) {
    yield* this._takeFileExtensionProcessors(plugins, ctx);
  }

  // Yield the config data except `extends` and `overrides`.
  yield {

    // Debug information.
    type: ctx.type,
    name: ctx.name,
    filePath: ctx.filePath,

    // Config data.
    criteria: null,
    env,
    globals,
    ignorePattern,
    noInlineConfig,
    parser,
    parserOptions,
    plugins,
    processor,
    reportUnusedDisableDirectives,
    root,
    rules,
    settings
  };

  // Flatten `overries`.
  for (let i = 0; i < overrideList.length; ++i) {
    yield* this._normalizeObjectConfigData(
      overrideList[i],
      { ...ctx, name: `${ctx.name}#overrides[${i}]` }
    );
  }
}

该函数是一个迭代器函数,因为像 plugin、parser、share config 的加载过程涉及到一些异步逻辑,因此构造配置设计成一个迭代器函数,能更好地控制整个配置处理流程。

逻辑不算复杂,主要是根据配置中的配置项,依次加载 extends、plugin、parser 等,但需要注意一个细节,extends 加载是最前面的,并且在加载完所有的 extends 配置,才加载 plugin 和 parser,这个地方下面我们单独通过例子详细讲。

最后,如果在项目中配置了 overrides,需要递归地进行配置拍平。这个配置在一般情况下用的比较少,使用场景为我们需要为不同的文件类型配置不同的插件、规则或者 parser ,例如下面这个例子:

json 复制代码
{
  "extends": "eslint:recommended",
  "rules": {
    "no-undef": "error"
  },
  "env": {
    "es6": true,
    "browser": true
  },
  "parserOptions": {
    "ecmaVersion": 10
  },
  "overrides": [
    {
      "files": [
        "*.ts"
      ],
      "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended"
      ],
      "rules": {
        "@typescript-eslint/no-unsafe-argument": "off",
        "@typescript-eslint/no-unsafe-member-access": "off"
      },
      "parserOptions": {
        "project": "tsconfig.json"
      }
    },
    {
      "files": [
        "*.js",
        "*.ts"
      ],
      "rules": {
        "no-unused-vars": "off",
        "max-classes-per-file": "off"
      }
    }
  ]
}

至于这里的配置合并策略,我会在 config-array 详细介绍。

最后是 _loadExtends:

js 复制代码
/**
  * Load configs of an element in `extends`.
  * @param {string} extendName The name of a base config.
  * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
  * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
  * @private
*/
_loadExtends(extendName, ctx) {
  debug("Loading {extends:%j} relative to %s", extendName, ctx.filePath);
  try {
    if (extendName.startsWith("eslint:")) {
      return this._loadExtendedBuiltInConfig(extendName, ctx);
    }
    if (extendName.startsWith("plugin:")) {
      return this._loadExtendedPluginConfig(extendName, ctx);
    }
    return this._loadExtendedShareableConfig(extendName, ctx);
  } catch (error) {
    error.message += `\nReferenced from: ${ctx.filePath || ctx.name}`;
    throw error;
  }
}

这里根据配置的方式,会有几种加载策略,比如 eslint:xxx表示,你配置的是 ESLint 内置的 share config,则通过 _loadExtendedBuiltInConfig 处理这部分加载逻辑。plugin:xxx表示配置是一个插件类型的 share config ,前面有提到过,所以 _loadExtendedPluginConfig 内部本质核心逻辑调用的是 _loadPlugin API。

而 _loadPlugin 的实现比较简单,其实类似于 _loadExtendedShareableConfig ,只是插件的加载不存在递归的情况,因此看完 _loadExtendedShareableConfig 实现, 其它的 _loadXXX 逻辑就基本知道了。

我们重点看 _loadExtendedShareableConfig 的实现:

js 复制代码
/**
  * Load configs of an element in `extends`.
  * @param {string} extendName The name of a base config.
  * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
  * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
  * @private
  */
_loadExtendedShareableConfig(extendName, ctx) {
  const { cwd, resolver } = internalSlotsMap.get(this);
  const relativeTo = ctx.filePath || path.join(cwd, "__placeholder__.js");
  let request;

  if (isFilePath(extendName)) {
    request = extendName;
  } else if (extendName.startsWith(".")) {
    request = `./${extendName}`; // For backward compatibility. A ton of tests depended on this behavior.
  } else {
    request = naming.normalizePackageName(
      extendName,
      "eslint-config"
    );
  }

  let filePath;

  try {
    filePath = resolver.resolve(request, relativeTo);
  } catch (error) {
    /* istanbul ignore else */
    if (error && error.code === "MODULE_NOT_FOUND") {
      throw configInvalidError(extendName, ctx.filePath, "extend-config-missing");
    }
    throw error;
  }

  writeDebugLogForLoading(request, relativeTo, filePath);
  return this._loadConfigData({
    ...ctx,
    filePath,
    name: `${ctx.name} >> ${request}`
  });
}

通过 config 的名字构造 request,再通过 resolver.resolve 加载模块,最后回到 _loadConfigData 递归加载配置流程。

为什么需要递归加载? 这里光看代码有点抽象,我们看一个例子:

js 复制代码
// eslint-config-share-1.js
module.export = {
  plugins: ["@typescript-eslint"],
  globals: {
    MyGlobal: true
  },
  rules: {
    semi: [2, "always"]
  }
}

// eslint-config-share-2.js
module.export = {
  extends: ["import"],
  rules: {
    "import/no-extraneous-dependencies": [
      "error", 
      {"devDependencies": ["**/*.test.js", "**/*.spec.js"]}]
  }
}

项目中使用两份配置:

js 复制代码
module.export = {
  extends: ["share1", "share2"],
  // 其它配置等
}

从这个例子出发,我们发现从项目的配置出发,通过 extends 我们可以不断套娃配置,嵌套的配置里面还是可以使用 extends、plugins 等配置项,因此加载 share config 是一个递归的过程

为什么要深度遍历?这里涉及到一个配置合并策略的问题:

  • 例如有两份 share config 同时配置了一个规则的不同选项,它们的值出现冲突时,哪个优先?
  • share config 配置项跟项目级别的配置项冲突,哪个优先?

这里的深度遍历构造配置项就是为了后续配置合并做准备,而这部分的配置合并策略是在 config-array 中实现,所以要搞清楚,需要去看 config-array 的源码。

config-array

首先看 ConfigArray 的实现:

js 复制代码
// config-array.js
/**
 * The Config Array.
 *
 * `ConfigArray` instance contains all settings, parsers, and plugins.
 * You need to call `ConfigArray#extractConfig(filePath)` method in order to
 * extract, merge and get only the config data which is related to an arbitrary
 * file.
 * @extends {Array<ConfigArrayElement>}
 */
class ConfigArray extends Array {

  /**
     * Get the plugin environments.
     * The returned map cannot be mutated.
     * @type {ReadonlyMap<string, Environment>} The plugin environments.
  */
  get pluginEnvironments() {
    return ensurePluginMemberMaps(this).envMap;
  }

  /**
     * Get the plugin processors.
     * The returned map cannot be mutated.
     * @type {ReadonlyMap<string, Processor>} The plugin processors.
     */
  get pluginProcessors() {
    return ensurePluginMemberMaps(this).processorMap;
  }

  /**
     * Get the plugin rules.
     * The returned map cannot be mutated.
     * @returns {ReadonlyMap<string, Rule>} The plugin rules.
     */
  get pluginRules() {
    return ensurePluginMemberMaps(this).ruleMap;
  }

  /**
     * Extract the config data which is related to a given file.
     * @param {string} filePath The absolute path to the target file.
     * @returns {ExtractedConfig} The extracted config data.
     */
  extractConfig(filePath) {
    const { cache } = internalSlotsMap.get(this);
    const indices = getMatchedIndices(this, filePath);
    const cacheKey = indices.join(",");

    if (!cache.has(cacheKey)) {
      cache.set(cacheKey, createConfig(this, indices));
    }

    return cache.get(cacheKey);
  }
}

移除了两个非核心逻辑的函数实现代码,看起来逻辑非常简单。到这里,我们终于大概能知道前面一系列的类都是 config-array-xxx 命名,因为 ConfigArray 本身是从 Array 继承出来的类,至于为什么需要设计成数组,我们需要再继续往下看。

ConfigArray 最核心的实现是 extractConfig API,根据注释,我们知道这个 API 的作用就是导出最终的 ESLint 配置数据。在这个 API 中,调用了两个关键的函数:getMatchedIndices 和 createConfig, 这两个函数封装在 ConfigArray 类之外,也是在 config-array.js 文件中。

getMatchedIndices 返回的 indices 除了作为配置缓存的 key,也是 createConfig 接收的参数之一。我们先看其实现:

js 复制代码
/**
 * Get the indices which are matched to a given file.
 * @param {ConfigArrayElement[]} elements The elements.
 * @param {string} filePath The path to a target file.
 * @returns {number[]} The indices.
 */
function getMatchedIndices(elements, filePath) {
  const indices = [];

  for (let i = elements.length - 1; i >= 0; --i) {
    const element = elements[i];

    if (!element.criteria || (filePath && element.criteria.test(filePath))) {
      indices.push(i);
    }
  }

  return indices;
}

代码实现逻辑不复杂,但是我们要完全看懂这段逻辑需要回顾下前面的源码,才能一步步揭开其神秘面纱。

首先是 elements 参数,它调用的时候传递的就是 ConfigArray 实例本身,前面我们读 ConfigArrayFactory 相关源码时知道 ConfigArrayFactory.create 创建的 ConfigArray 实例传入的是一系列配置对象,也就是 _normalizeObjectConfigDataBody 最后返回的结果:

scala 复制代码
_normalizeObjectConfigDataBody () {
  // 省略其它代码

  // Yield the config data except `extends` and `overrides`.
  yield {

    // Debug information.
    type: ctx.type,
    name: ctx.name,
    filePath: ctx.filePath,

    // Config data.
    criteria: null,
    env,
    globals,
    ignorePattern,
    noInlineConfig,
    parser,
    parserOptions,
    plugins,
    processor,
    reportUnusedDisableDirectives,
    root,
    rules,
    settings
  };
}

简单来说,这个 elements 数组存的是一个个相对完整的、处理后的 ESLint 配置对象,因为大多数场景下,当一个项目配置了 share config 或者 overrides 配置项,这个时候该数组是有多个数据项的。简单解释,一个 share config 是一个 ConfigArray 的数据项,项目本身的配置也是一个配置项;如果配置了 overrides ,那这个数组的每一项也是一个数据项。

那这里的 element.criteria 又是什么?继续回溯源码:

js 复制代码
// config-array-factory.js
/**
  * Normalize a given config to an array.
  * @param {ConfigData|OverrideConfigData} configData The config data to normalize.
  * @param {ConfigArrayFactoryLoadingContext} ctx The loading context.
  * @returns {IterableIterator<ConfigArrayElement>} The normalized config.
  * @private
  */
*_normalizeObjectConfigData(configData, ctx) {
  const { files, excludedFiles, ...configBody } = configData;
  const criteria = OverrideTester.create(
    files,
    excludedFiles,
    ctx.matchBasePath
  );
  const elements = this._normalizeObjectConfigDataBody(configBody, ctx);

  // Apply the criteria to every element.
  for (const element of elements) {

    /*
     * Merge the criteria.
     * This is for the `overrides` entries that came from the
     * configurations of `overrides[].extends`.
     */
    element.criteria = OverrideTester.and(criteria, element.criteria);

    /*
     * Remove `root` property to ignore `root` settings which came from
     * `extends` in `overrides`.
      */
    if (element.criteria) {
      element.root = void 0;
    }

    yield element;
  }
}

发现原来它是在 _normalizeObjectConfigData API 中加上的,我们需要看下 OverrideTester 在做什么,看注释应该是专门为了 overrides 配置场景的。因为是 overrides 是相对来说不常见的配置,这里就不详细解读代码,感兴趣的小伙伴可以自行去看,它封装在 config-array/override-tester.js 中。

直接说明其用途:其实就是当我们配置了 overrides,我们会配置这些插件或者规则时 for 哪些文件类型的,而 criteria 就是在导出配置时去匹配文件,匹配到的文件才会存下这份配置的索引,最后在 lint 时消费到这份配置里面的选项

到这里我们就搞清了 getMatchedIndices 的逻辑,下面继续看 createConfig:

js 复制代码
/**
 * Create the extracted config.
 * @param {ConfigArray} instance The config elements.
 * @param {number[]} indices The indices to use.
 * @returns {ExtractedConfig} The extracted config.
 */
function createConfig(instance, indices) {
  const config = new ExtractedConfig();
  const ignorePatterns = [];

  // Merge elements.
  for (const index of indices) {
    const element = instance[index];

    // Adopt the parser which was found at first.
    if (!config.parser && element.parser) {
      if (element.parser.error) {
        throw element.parser.error;
      }
      config.parser = element.parser;
    }

    // Adopt the processor which was found at first.
    if (!config.processor && element.processor) {
      config.processor = element.processor;
    }

    // Adopt the noInlineConfig which was found at first.
    if (config.noInlineConfig === void 0 && element.noInlineConfig !== void 0) {
      config.noInlineConfig = element.noInlineConfig;
      config.configNameOfNoInlineConfig = element.name;
    }

    // Adopt the reportUnusedDisableDirectives which was found at first.
    if (config.reportUnusedDisableDirectives === void 0 && element.reportUnusedDisableDirectives !== void 0) {
      config.reportUnusedDisableDirectives = element.reportUnusedDisableDirectives;
    }

    // Collect ignorePatterns
    if (element.ignorePattern) {
      ignorePatterns.push(element.ignorePattern);
    }

    // Merge others.
    mergeWithoutOverwrite(config.env, element.env);
    mergeWithoutOverwrite(config.globals, element.globals);
    mergeWithoutOverwrite(config.parserOptions, element.parserOptions);
    mergeWithoutOverwrite(config.settings, element.settings);
    mergePlugins(config.plugins, element.plugins);
    mergeRuleConfigs(config.rules, element.rules);
  }

  // Create the predicate function for ignore patterns.
  if (ignorePatterns.length > 0) {
    config.ignores = IgnorePattern.createIgnore(ignorePatterns.reverse());
  }

  return config;
}

这个函数实现了配置合并的核心策略,除了前面 parser、processor、ignorePatterns 等配置比较简单的处理策略,主要有三类相对复杂的合并策略:

  • mergeWithoutOverwrite,主要是处理 env、globals、parserOptions、settings 等对象类型配置的合并
  • mergePlugins,插件配置合并
  • mergePlugins,规则配置合并

看到这里,不知道大家有没有留意到一个问题,通过前面的源码我们了解到加载配置数组的方式,是深度遍历构造出来的,也就是说越底层的配置会在 elements 配置数组最前面,那这个合并策略看起来就是外部项目的配置优先级比里层 share config 、overrides 配置低,这看起来不太符合正常的设计,那用户怎么覆盖 share config 配置?

其实这里有个隐藏彩蛋,在 getMatchedIndices 中,其实在获取索引时,是倒序遍历:

js 复制代码
function getMatchedIndices(elements, filePath) {
  const indices = [];

  for (let i = elements.length - 1; i >= 0; --i) {
    // 省略代码
  }

  return indices;
}

因此这里的配置顺序就正好一来一回颠倒过来了,项目级别配置项优先级应该是最高的,而 ESLintRC 文件外层的配置也大于 overrides 配置。 而在合并配置时,使用倒序的索引数组得到的配置项进行配置合并。

下面我们来一一看三个合并参数的策略,首先是 mergeWithoutOverwrite:

js 复制代码
/**
 * Merge two objects.
 *
 * Assign every property values of `y` to `x` if `x` doesn't have the property.
 * If `x`'s property value is an object, it does recursive.
 * @param {Object} target The destination to merge
 * @param {Object|undefined} source The source to merge.
 * @returns {void}
 */
function mergeWithoutOverwrite(target, source) {
  if (!isNonNullObject(source)) {
    return;
  }

  for (const key of Object.keys(source)) {
    if (key === "__proto__") {
      continue;
    }

    if (isNonNullObject(target[key])) {
      mergeWithoutOverwrite(target[key], source[key]);
    } else if (target[key] === void 0) {
      if (isNonNullObject(source[key])) {
        target[key] = Array.isArray(source[key]) ? [] : {};
        mergeWithoutOverwrite(target[key], source[key]);
      } else if (source[key] !== void 0) {
        target[key] = source[key];
      }
    }
  }
}

递归的对类型为对象类型的配置进行合并,只要最外层已经配置过的属性,内层的配置都会被忽略,项目级别配置项优先级始终是最高的

mergePlugins 的合并逻辑也比较简单,不用考虑参数合并问题,合并时会检查是否出现配置相同插件而加载路径不一致的情况,也就是在上一篇文章中提到的错误之一:ESLint couldn't determine the plugin "XXX" uniquely,就是在这里抛出的错误:

js 复制代码
/**
 * Merge plugins.
 * `target`'s definition is prior to `source`'s.
 * @param {Record<string, DependentPlugin>} target The destination to merge
 * @param {Record<string, DependentPlugin>|undefined} source The source to merge.
 * @returns {void}
 */
function mergePlugins(target, source) {
  if (!isNonNullObject(source)) {
    return;
  }

  for (const key of Object.keys(source)) {
    if (key === "__proto__") {
      continue;
    }
    const targetValue = target[key];
    const sourceValue = source[key];

    // Adopt the plugin which was found at first.
    if (targetValue === void 0) {
      if (sourceValue.error) {
        throw sourceValue.error;
      }
      target[key] = sourceValue;
    } else if (sourceValue.filePath !== targetValue.filePath) {
      throw new PluginConflictError(key, [
        {
          filePath: targetValue.filePath,
          importerName: targetValue.importerName
        },
        {
          filePath: sourceValue.filePath,
          importerName: sourceValue.importerName
        }
      ]);
    }
  }
}

在合并插件之前,前面加载配置时已经将每个配置项的插件配置处理成 Record 形式,合并起来比较高效。

最后是规则合并策略 mergeRuleConfigs:

js 复制代码
/**
 * Merge rule configs.
 * `target`'s definition is prior to `source`'s.
 * @param {Record<string, Array>} target The destination to merge
 * @param {Record<string, RuleConf>|undefined} source The source to merge.
 * @returns {void}
 */
function mergeRuleConfigs(target, source) {
  if (!isNonNullObject(source)) {
    return;
  }

  for (const key of Object.keys(source)) {
    if (key === "__proto__") {
      continue;
    }
    const targetDef = target[key];
    const sourceDef = source[key];

    // Adopt the rule config which was found at first.
    if (targetDef === void 0) {
      if (Array.isArray(sourceDef)) {
        target[key] = [...sourceDef];
      } else {
        target[key] = [sourceDef];
      }

      /*
         * If the first found rule config is severity only and the current rule
         * config has options, merge the severity and the options.
         */
    } else if (
      targetDef.length === 1 &&
      Array.isArray(sourceDef) &&
      sourceDef.length >= 2
    ) {
      targetDef.push(...sourceDef.slice(1));
    }
  }
}

我们知道规则的配置有两种选择,一种是数组,一种是告警级别的字符串,在经过处理后,最后都会是数组。当配置项是数组时,第一个项是告警级别,最外层的配置永远是优先级更高的 ;第二个项是规则的选项,一般是一个对象,合并策略比较粗暴,最外层的配置对象直接覆盖里层的配置,没有对象的深度合并。

看一个例子:

js 复制代码
// eslint-config-share.js
module.export = {
  plugin: ["@typescript-eslint"],
  rules: {
    "import/no-extraneous-dependencies": [
      "error", 
      {"devDependencies": ["**/*.test.js", "**/*.spec.js"]}]
  },
  env: {
    browser: true,
    es6: true,
    node: true,
  },
}

// project A  .eslintrc.js
module.export = {
  extends: ["share"]  
  plugins: ["@typescript-eslint", "import"],
  rules: {
    "import/no-extraneous-dependencies": [
      "error", 
      {"includeInternal": true, "includeTypes": true }]
  },
  env: {
    browser: true,
    es6: true,
    node: false,
  },
}

上面的配置经过合并后,在 ESLint 内部变成了:

json 复制代码
{ 
  "plugins": {
      "@typescript-eslint": {
        // 省略一些字段
      },
      "import": {
        // 省略一些字段 
      }
  },
  "rules": {
    "import/no-extraneous-dependencies": [
      "error", 
      {"includeInternal": true, "includeTypes": true }]
  },
  "env": {
    "browser": true,
    "es6": true,
    "node": false,
  },
}

到这里,关于 ESLint 8.x 配置加载、配置合并等核心逻辑源码解读基本就结束了,当然这中间还有一些细节和小的功能模块没有一一解读。我个人读源码的方式,更多关注核心流程和个人感兴趣的部分,再选择性挑一些细节实现看,其它的细枝末节只要不影响我理解关键逻辑,一般后续遇到问题时时会倒过来再看。

这部分篇幅比较长,下面对一些关键信息进行小结。

小结

在看完 ESLint 配置加载和合并的源码,我个人始终觉得这部分的源码实现还是比较复杂的,一些细节不深入去看还是会出现一知半解的情况。

因此在 9.x 版本,ESLint 引入新的 Flat 配置系统 ,就是为了简化配置系统,让开发者更容易理解和使用,想了解更多细节的小伙伴可以去看关于 ESLint 新配置系统的 RFC:2019-config-simplification

以下几个点是我看完这部分源码觉得需要重点关注的:

  • CascadingConfigArrayFactory 作为 ESLint CliEngine 消费的实例,是整个配置加载模块的入口,它依赖了 ConfigArrayFactory
  • ConfigArrayFactory 类实现了配置加载的功能,它从 create 出发,经过层层的 normalize 逻辑,最后返回了一个标准的配置对象数组,也就是 ConfigArray 实例;因为 extends 和 overrides 配置方式,所以需要递归地创建配置对象、加载配置,这里采用的是深度遍历的算法
  • ConfigArray 本质是一个继承自 Array 的类,其核心的作用是合并多份配置为最终的配置,内部会根据不同的配置项和类型:对象、插件、规则等,实现了不同的合并算法;值得一提的是,为了保证配置优先级,通过 getMatchedIndices 函数倒序遍历配置数组,从而使得优先级高的项目配置在首位,最深层的配置在最后
  • 在阅读源码过程中,可以发现代码实现的多个功能模块,当创建一些开销大的实例包括 lint 结果都使用了缓存,这是我们在设计复杂、代码执行开销大的架构场景时值得参考的地方

最后

读这部分源码除了让我增加对 ESLint 内部实现的了解,也让我从优秀社区工具中学到一些编码和设计范式。

后续再遇到 ESLint 配置问题,除了学到更多的 debug 姿势,因为对源码的了解,也能更好地排查问题。

除此之外,读这部分源码,也让我学到了一些好的编程范式,例如为了更好传递全局共享的上下文参数,我们可以构造一个 context 对象,方便传递,这也是《重构》一书中提到的最佳实践之一

总是在设计的时候考虑缓存, 缓存能很大程度上帮我们提升工具运行的效率,避免无谓的 IO 开销。

Reference

技术之外

工作和代码之外,也需要生活。最近除了研究代码,也还在研究咖啡,尝试输出一些内容。所以,做了一个公众号,当然除了咖啡知识之外,也会分享一些读书笔记,对咖啡感兴趣,也爱读书的小伙伴可以关注下。

相关推荐
abc80021170342 小时前
前端Bug 修复手册
前端·bug
Best_Liu~2 小时前
el-table实现固定列,及解决固定列导致部分滚动条无法拖动的问题
前端·javascript·vue.js
_斯洛伐克3 小时前
下降npm版本
前端·vue.js
苏十八4 小时前
前端进阶:Vue.js
前端·javascript·vue.js·前端框架·npm·node.js·ecmascript
st紫月4 小时前
用MySQL+node+vue做一个学生信息管理系统(四):制作增加、删除、修改的组件和对应的路由
前端·vue.js·mysql
乐容5 小时前
vue3使用pinia中的actions,需要调用接口的话
前端·javascript·vue.js
似水明俊德5 小时前
ASP.NET Core Blazor 5:Blazor表单和数据
java·前端·javascript·html·asp.net
至天6 小时前
UniApp 中 Web/H5 正确使用反向代理解决跨域问题
前端·uni-app·vue3·vue2·vite·反向代理
与墨学长6 小时前
Rust破界:前端革新与Vite重构的深度透视(中)
开发语言·前端·rust·前端框架·wasm
H-J-L7 小时前
Web基础与HTTP协议
前端·http·php