Conflicting order in mini-css-extract-plugin

Build

在使用了 mini-css-extract-plugin 的项目中,有时会遇到如下的 Warning 输出:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order between:
* css xxx/css-loader/dist/cjs.js!./e1.css
* css xxx/css-loader/dist/cjs.js!./e2.css
* css xxx/css-loader/dist/cjs.js!./e3.css

tl;dr

上面这段 Warning 的实际含义是:

由于没法找到最合适的解,plugin 被迫使用了相对最优的解,将 e1.css 放到了编译产物中。在某些 chunk group 中,e2.csse3.css 这两个文件引用位置在 e1.css 之前;而在 plugin 将 e1.css 加入编译产物的时候,e2.csse3.css 还没有被添加。

换句话说,如果 e1.css 本来的用途是覆盖 e2.css 的内容,且覆盖依靠的是同优先级下 CSS 定义出现的先后顺序,那么在 plugin 编译完成之后,这个覆盖就会失效。

造成 Warning 的例子

以下通过一个直观的例子来说明 plugin 在何时可能会输出上述 Warning:

假设有两个文件 entry1.js

import './e1.css';
import './e2.css';

entry2.js

import './e2.css';
import './e1.css';

且 Webpack 配置了需要将 e1.csse2.css 打包到同一个文件中。如果 plugin 选择将 e1.css 放在 e2.css 的前面,那么就不满足 entry2.js 的使用顺序;反之则不满足 entry1.js 的。正是在这样的“矛盾”情况下,plugin 输出了一个 Warning,并选择了一个相对最优的解。

更好的 Warning

上面提到的 mini-css-extract-plugin Warning 并不是非常直观,也很难知道具体是哪一个 chunk group 出现了问题。新提交的 PR #465 以及 #468 对此做了优化。优化后的输出类似:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order. Following module has been added:
 * css xxx!./e1.css
despite it was not able to fulfill desired ordering with these modules:
 * css xxx!./e2.css
   - couldn't fulfill desired order of chunk group(s) entry2
   - while fulfilling desired order of chunk group(s) entry1

根据 Warning 的提示,只需要观察 entry2 中对应的代码,就可以找到不满足的引入顺序了。

是否需要关心

这个 Warning 是否需要关注,取决于 CSS 在项目中是如何被使用的:只要在项目的使用过程中,没有 CSS 是通过加载的顺序进行优先级覆盖的,那么就可以忽略 plugin 给出的 Warning;反之,如果有这样的情况,则一定需要根据 Warning 修正输出,防止编译导致的意外。

当然,最保险的做法是启用 CSS Module,从源头上保证各个模块间的 CSS 是不会相互覆盖的。

代码分析

以下从这个 Warning 入手,分析 plugin 是如何从 modules 生成最终的打包产物的。

观察 mini-css-extract-plugin 的源代码,不难找到这个 Warning 的具体输出代码。接下来,将针对这段代码所在的 renderContentAsset 进行分析,了解 plugin 生成打包产物的算法。

首先观察 renderContentAsset 函数最开始的比较语句

if (typeof chunkGroup.getModuleIndex2 === 'function') {
  // ...
} else {
  // ...
}

这里,chunkGroup.getModuleIndex2 是 Webpack 4 中的 API,在之前的版本中不存在(Webpack 3 及之前使用的是 extract-text-webpack-plugin)。接下来,重点关心 Webpack 4 对应的代码段。

首先看 moduleDependencies 变量的初始化(源码):

const moduleDependencies = new Map(modules.map((m) => [m, new Set()]));

moduleDependencies 为每一个 module 都定义了一个对应的空 Set(具体 Set 内的值会在后续填充)。

接下来看 modulesByChunkGroup 变量的定义(源码):

const modulesByChunkGroup = Array.from(chunk.groupsIterable, (cg) => {
  // ...
  return sortedModules;
});

可以看到,modulesByChunkGroup 本质上,是将所有的 chunk group(chunk.groupsIterable)转化成了对应的 sortedModules。这里 sortedModules 变量的定义为(源码):

const sortedModules = modules
  .map((m) => {
    return {
      module: m,
      index: cg.getModuleIndex2(m),
    };
  })
  // eslint-disable-next-line no-undefined
  .filter((item) => item.index !== undefined)
  .sort((a, b) => b.index - a.index)
  .map((item) => item.module);

简单来说,做了几件事情:

  1. modules 中不属于当前 chunk group(cg)的部分剔除,对应的是 .filter((item) => item.index !== undefined)
  2. 根据 module 在 chunk group 中实际出现的位置,按从后往前进行排序,对应的是 .sort((a, b) => b.index - a.index)

这里,越是先出现的 module 在最终的 sortedModules 数组中排的越靠后。换句话说,出现在数组最后的一个 module,没有任何前置的依赖(在该 chunk group 中,这个 module 是第一个被引入的);而理论上来说,数组的第一个 module 依赖了数组后面的所有 modules(从 CSS 的角度来说,这个模块出现在最后。在所有选择器优先级一样的前提下,这个 module 理论上可以对之前所有的 module 进行覆盖)。这里采用倒叙的方式组织数组,是为了后续可以方便的使用 Array.prototype.pop 函数去获取当前没有依赖的 module。

接下来,代码sortedModules 变量值进行了填充:

for (let i = 0; i < sortedModules.length; i++) {
  const set = moduleDependencies.get(sortedModules[i]);

  for (let j = i + 1; j < sortedModules.length; j++) {
    set.add(sortedModules[j]);
  }
}

如上所述,sortedModules 中后出现的 module 是先出现 module 的依赖。这里的 moduleDependencies 变量记录了各个 module 的所有依赖(不仅仅是当前 chunk group 的依赖,所有 chunk group 的依赖最终都会被写入到这个 Set 中),其中 key 是各个 module,而对应的 value 则是一个 Set,Set 中的每个元素都是当前这个 module 的依赖。

之后定义了 usedModulesunusedModulesFilter 函数(源码),目的是为了判断某一个 module 是否已经被当前的 plugin 使用了。

接下来的代码需要确保所有的 modules 都会根据某个具体的算法在最终的编译产物中被使用到。这里判断的方法就是 usedModules 是否包含了 modules 中所有的内容。

while (usedModules.size < modules.length) {
  let success = false;
  let bestMatch;
  let bestMatchDeps;
  // ...
}

至此,准备工作都做完了。接下来就是核心的部分:如何在各个 chunk group 中选取合适的 module,依次放到最终生成的 CSS 文件中。代码如下:

for (const list of modulesByChunkGroup) {
  // skip and remove already added modules
  while (list.length > 0 && usedModules.has(list[list.length - 1])) {
    list.pop();
  }

  // skip empty lists
  if (list.length !== 0) {
    const module = list[list.length - 1];
    const deps = moduleDependencies.get(module);
    // determine dependencies that are not yet included
    const failedDeps = Array.from(deps).filter(unusedModulesFilter);

    // store best match for fallback behavior
    if (!bestMatchDeps || bestMatchDeps.length > failedDeps.length) {
      bestMatch = list;
      bestMatchDeps = failedDeps;
    }

    if (failedDeps.length === 0) {
      // use this module and remove it from list
      usedModules.add(list.pop());
      success = true;
      break;
    }
  }
}

理想情况下,两个 CSS module 在最终编译产物中的先后顺序,应该和这两个 CSS module 在某一个 chunk group 中的先后顺序是一致的。如果顺序上无法保证一致,那么应该尽可能将不一致的情况降到最低。mini-css-extract-plugin 就是遵循这一个原则来对 module 进行排序的。在每一次的选取步骤中,算法都会依次遍历每一个 chunk group,做下面几个事情:

  1. 拿到当前该 chunk group 中没有依赖的那个 CSS module(const module = list[list.length - 1];);
  2. 判断这个拿到的 CSS module 是否有前置的依赖还没有被放到最终产物中去(const failedDeps = Array.from(deps).filter(unusedModulesFilter));
  3. 如果没有前置依赖了,那么这个 CSS module 就可以被“安全”的放到当前的编译产物中去(if (failedDeps.length === 0) { 对应的部分);
  4. 如果有不满足的前置依赖,那么就去寻找不满足情况最少的一个(bestMatchDeps.length > failedDeps.length),然后记下来(bestMatchDeps = failedDeps;

如果找到了没有前置依赖的 module,代码就直接 break,跳到下一次 while 循环中去了;如果没有找到完美匹配的情况,就会进入接下来的代码

if (!success) {
  // no module found => there is a conflict
  // use list with fewest failed deps
  // and emit a warning
  const fallbackModule = bestMatch.pop();
  if (!this.options.ignoreOrder) {
    compilation.warnings.push(
      new Error(
        `chunk ${chunk.name || chunk.id} [${pluginName}]\n` +
          'Conflicting order between:\n' +
          ` * ${fallbackModule.readableIdentifier(
            requestShortener
          )}\n` +
          `${bestMatchDeps
            .map((m) => ` * ${m.readableIdentifier(requestShortener)}`)
            .join('\n')}`
      )
    );
  }

  usedModules.add(fallbackModule);
}

上面代码中的 bestMatch 就是对应了最佳情况时候 modules 排序的数组,这里 fallbackModule = bestMatch.pop() 就可以拿到当前这个最佳情况的 chunk group 中,没有依赖的那个 CSS module。和之前代码中 module = list[list.length - 1] 拿到的数据是一样的。

同时,bestMatchDeps 对应的就是上面代码里的 failedDeps 数组,表示的是当前这个 fallbackModule 被选中时,有哪些该 module 的前置依赖并没有被事先放到编译产物中去。

接下来就是向 Webpack 输出 Warning 的代码了。再来看下面这段 Warning:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order between:
* css xxx/css-loader/dist/cjs.js!./e1.css
* css xxx/css-loader/dist/cjs.js!./e2.css
* css xxx/css-loader/dist/cjs.js!./e3.css

可知道,具体的含义是:由于没法找到最合适的解,plugin 被迫使用了相对最优的解,将 e1.css 放到了编译产物中。在某些 chunk group 中,e2.csse3.css 这两个文件引用位置在 e1.css 之前;而在 plugin 将 e1.css 加入编译产物的时候,e2.csse3.css 还没有被添加。