从架构和源码角度重新认识 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

技术之外

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

相关推荐
恋猫de小郭10 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅17 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606117 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了17 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅18 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅18 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅18 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment18 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅19 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊19 小时前
jwt介绍
前端