[修复 Webpack 官方 Bug] 提取 CSS 时的依赖图修正

用户头像
分一
关注
发布于: 2020 年 08 月 14 日
[修复 Webpack 官方 Bug] 提取CSS时的依赖图修正

阅读本文前推荐对 Webpack ModuleChunk 的概念做基本的了解。

同时也推荐对 webpack compilationwebpack plugin api做一定的了解。



一、引言

样式性能优化

在构建时有关样式基建中,支持三种拆包实践。其中针对多页应用 (MPA) 的场景,默认会开启一类最佳的拆包规范。拆包的目的是为了尽最大可能提升性能。



基于『css in js 』理念,Webpack 默认会把样式文件打包到 js 文件中进行异步加载。这样做的好处是节省了请求数,毕竟把多个文件压缩成了一个,只需要一次网络请求,但是这样做也会引入副作用:



1. 首先『css in js 』会导致样式文件的加载时机被推迟,对于骨架屏等非框架渲染的场景,样式在 dom 之后加载可能会造成闪屏,是非常不利于首屏渲染的优化的,对于这种情况,可以在构建时把样式文件直接内联到 head 中

2. 其次 『css in js 』也会导致不能充分利用客户端持久化缓存,造成额外的网络下载开销:假设样式 A 所在的 JS 文件中业务逻辑变化了,但是样式本身没有变化,那么样式 A 依旧会随业务逻辑的变化而重新请求。

所以针对第二种场景,我们需要进行规范化的拆包约定,把 JS Bundle 里不经常变化的部分样式(如 reset.css 等)提出来,生成独立与业务逻辑的 CSS 文件单独存储,同时也要把经常随业务变化的样式直接打入 JS Bundle 中,尽可能的利用 『css in js』的优势。



##构建时拆包约定

工程目录,我们只关注样式相关内容,可以简化如下:
├── src
│ ├── pages // 页面入口
│ │ └── pageA // 页面入口 A
│ │ ├── index.html // 页面入口模板
│ │ ├── index.js // 页面入口 JS 逻辑
│ │ └── index.scss // 页面业务样式,会经常发生变化
│ └── common
│ └── styles // 全局公共样式,通常很久才会变动一次
│ └── base.scss

目录拆包约定:

-构建时定义 common/styles 目录下的样式为全局公共样式,这部分不应该经常变化,在构建时,会把这个目录内的样式提取为单独的 css 文件,命名为 base.[contenthash].css,输出到 common 公共产物目录中,进行单独持久化缓存。

-构建时定义 pages 目录中存在各个业务页面入口,内部的私有样式为与业务强相关,不具备复用性,会跟随业务迭代升级。这部分样式在构建时,会打入对应页面入口的 JS Bundle 中,随业务一起做缓存。



所以一个典型的构建产物目录结构应该是如下的样子

resource
├ common
│ └── base.xxx.css // 全局样式
├ pages // 页面入口
│ └── pageA // 页面入口 A
│ └── index.xxx.js // 页面入口 index Bundle ,内含 JS 逻辑和业务样式(css in js)



二、拆包配置



构建时引入 mini-css-extract-plugin 插件来做 CSS样式提取,使用[官方标准的配置方法](https://github.com/webpack-contrib/mini-css-extract-plugin#extracting-all-css-in-a-single-file

)来实现上述拆包约定:



mini-css-extract-plugin 配置



首先配置 mini-css-extract-plugin ,指定提取 common/styles 目录下的样式为独立的 css 文件。

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
module: {
rules: [
{
// 提取 common/styles 中样式为单独的 css 文件
test: /\/common\/styles\/.*(css|scss|sass|less)$/ i,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
plugins: [new MiniCssExtractPlugin({
// 指定 base.css 生成到 common 目录中
chunkFilename: 'common/[name].[contenthash].css',
})]
};

splitChunks 配置



mini-css-extract-plugin 对样式的提取是以页面入口为单位。如上有关 mini-css-extract-plugin 的配置,虽然能够完成对 common/styles 目录中样式的提取,但是它会为每个页面入口分别都生成对应的 css 文件,而没有能力生成全局唯一的 css 文件。 我们额外引入 webpack splitChunks ,基于它提取公共 chunk 的能力,可以很方便的实现我们的目标:

optimization: {
splitChunks: {
cacheGroups: {
base: {
name: "base",
// 对 common/styles 中的样式进行提取,生成公共 chunk
test: /\/common\/styles\/.*(css|scss|sass|less)$/,
chunks: "all",
enforce: true
}
}
}
}



于是,在构建过程中,我们通过提取 common/styles 中样式,成功生成了全局唯一的公共样式文件 base.50289158.css。整个构建产物目录如下图:





三、问题分析

后续对产物进行说明时,方便起见,均省略其文件名的 contenthash 值



观察仔细的同学,可以从上图的构建结果中发现,生成 base.css 的同时,在它的同级目录下也生成了预期外的同名 js 文件 base.js (上图中因 base.css 和 base.js 文件内容不同而导致 contenthash 不同,但是两者同源)。



打开这个 base.js ,我们可以看到如下代码:



(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["base"],{

/***/ "./src/common/styles/base.scss":
/*!*************************************!*\
!*** ./src/common/styles/base.scss ***!
\*************************************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {
// extracted by mini-css-extract-plugin
/***/ })
}]);
//# sourceMappingURL=base.af983635.js.map

base.js 中声明了一个 base chunk,内部只包含一个对 base.scss 的 JS 空模块定义。这个定义是没有实际意义的,只会导致我们的构建中额外生成了一个没有用的 js 文件,页面执行时对它的加载就会导致多一次网络请求,显然这是不符合预期的。



经过调研,发现这个诡异的现象是 webpack 4 中设计 chunk 解析流程时的历史遗留问题(是一个设计缺陷,也可以认为是一个bug)。



webpack 4 要求所有的 module (不管是图片还是样式) 在构建时,经过 loader 加载后都必须返回一个 js 模块,所以我们在加载 base.scss 时也会生成对应的 js module (即 base.js 中定义的空模块),这个机制通常情况下不会发生问题,因为所有被引入的资源模块,总能找到它的入口父模块, webpack 通常会把这个生成的空模块打入它所对应的入口父模块的 chunk 中,所以不会产生额外的文件。



但是当我们使用 splitchunk 配合 mini-css-extract-plugin 提取单独的公共样式文件(base.css)时,这个样式文件(base.scss)在生成对应的 js 模块后,找不到对应的入口父模块(因为当前 chunk 是通过 splitchunks 产生,本身就是一个独立的 chunk,没有上文所述可以归属的入口父模块),webpack 只能为这个独立的 css chunk 单独生成一个额外的 js 文件。



这个问题在 webpack v5.0.0-beta.17 已经得到修复,webpack 5 会判断当 splitchunks 生成的独立 chunk 中若不包含 js 模块,则既不会为该 chunk 生成独立的 js 文件,也不会把它加入到页面依赖中。相关修复的 COMMIT可以参考[这次提交](https://github.com/webpack/webpack/commit/c5f94f3b6a79a88da9ed93b5f980830f496f4fad)。但是遗憾的是 webpack 4 目前并不打算修复这个问题。



考虑到 webpack 5 尚未正式发布,生态和稳定性都欠缺考验,我们还是回到当前 Webpack 4 中寻求解决方案。



假设我们把产物这个多余的文件( base.js) 手动删除(因为看起来这个文件中的定义模块没有任何功能逻辑,感性上对页面功能不应该产生影响),那么当页面运行时,会发现页面出现白屏了,页面入口的代码压根就没有被执行。



这是因为webpack 的页面启动模块,会把 base.js 对应的 chunk 视为必要依赖,若该 chunk 未加载,则后续业务逻辑不会被执行。我们打开页面启动(bootstrap)模块源码可以得到验证:

/******/ (function(modules) { // webpackBootstrap
// 中间代码省略

/******/ function checkDeferredModules() {
/******/ var result;
/******/ for(var i = 0; i < deferredModules.length; i++) {
// 这里 deferredModule 为入口所依赖的所有 chunks 声明,由下面的 deferredModules.push([0,"vendor","base"]); 决定。只有全部 chunks 都加载完毕,才能触发页面入口的业务逻辑。
/******/ var deferredModule = deferredModules[i];
/******/ var fulfilled = true;
// 确认所有依赖模块都已经加载完毕,则 fulfilled 保持为 true
/******/ for(var j = 1; j < deferredModule.length; j++) {
/******/ var depId = deferredModule[j];
/******/ if(installedChunks[depId] !== 0) fulfilled = false;
/******/ }
/******/ if(fulfilled) {
/******/ deferredModules.splice(i--, 1);
// 触发入口业务逻辑
/******/ result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
/******/ }
/******/ }
/******/ return result;
/******/ }
// 中间代码省略
// 获取所有挂载到 window.webpackJsonp 的 chunk
/******/ var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
// 加载挂载到 window.webpackJsonp 的 chunks,加载完成会标识 installedChunks 为 true
/******/ for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
/******/ var parentJsonpFunction = oldJsonpFunction;
/******/ // 这里声明了页面依赖的 chunks 有哪些,只有依赖都加载完毕才能执行页面后续逻辑
// 其中 base 对应了我们通过 splitchunks 抽取出来的 css chunk 即 base.js
// 0 对应页面入口 index.js,
// vendor 对应公共 JS vendor.js
/******/ deferredModules.push([0,"vendor","base"]);
/******/ // 这个方法会确认所有依赖模块都已经加载完毕,然后触发入口业务逻辑
/******/ return checkDeferredModules();
})({/* 这里传入页了面入口 index.js 和它的直接依赖模块 */ })

上面模板代码中最关键的部分为

deferredModules.push([0,"vendor","base"]);



其中 deferredModules 即用来声明页面依赖的 chunk:

  • 0 : 对应页面入口 index.js,

  • vendor: 对应公共 JS vendor.js

  • base: 对应了我们通过 splitchunks 抽取出来的 css chunk 即 base.js



声明中第三个 chunk base 是我们需要处理的对象,我们需要把这个依赖在依赖声明数组中去掉。我们需要找到这段页面入口的模板代码是如何生成的。



经过调研,发现上面给出的 Webpack 启动(bootstrap) 模块代码由 webpack 的 lib/web/JsonpMainTemplatePlugin 生成,启动模块中关键的依赖声明部分

deferredModules.push([0,"vendor","base"]); 的生成逻辑如下:

mainTemplate.hooks.startup.tap(
"JsonpMainTemplatePlugin",
(source, chunk, hash) => {
if (needEntryDeferringCode(chunk)) {
if (chunk.hasEntryModule()) {
const entries = [chunk.entryModule].filter(Boolean).map(m =>
[m.id].concat(
Array.from(chunk.groupsIterable)[0]
.chunks.filter(c => c !== chunk)
.map(c => c.id)
)
);
return Template.asString([
"// add entry module to deferred list",
`deferredModules.push(${entries
.map(e => JSON.stringify(e))
.join(", ")});`,
"// run deferred modules when ready",
"return checkDeferredModules();"
]);
} else {
return Template.asString([
"// run deferred modules from other chunks",
"checkDeferredModules();"
]);
}
}
return source;
}
);

可以看到,页面入口依赖是由 chunk.groupsIterable 的内部依赖决定。





通过上图中的调试情况,我们可以验证,入口对应 chunkGroup 中的确存在对 base chunk 的依赖。



我们需要做的事情则转换为:如何避免 base chunk 进入入口 chunkGroup 的依赖中。

四、通过修正 webpack 依赖图来解决问题



1. 确认要做的事情



如上,当前状态,我们产出了三个 bundle 产物。

  • base chunk:包含 base.js 以及 base.css

  • Index chunk:页面入口逻辑

  • vendor chunk:公共 JS 依赖



其中 base chunk 所对应的文件即为不应该出现的 Bundle 产物(base.js),我们需要把它从依赖图中去掉。



通过调试,可以看到该 base chunk 内部结构如下:





上图中可知,base 包含两种子模块。我们需要对他们进行分别处理:

  • CssModule:它与我们预期要生成的 base.css 文件相关,该对象的 content 属性内部保存了 base.css 的样式代码,需要保留;

  • NormalModule:它与空 js 模块(即上文中 base.js 中声明的空模块)相关,我们需要尽可能避免该模块单独生成 base.js 文件。



NormalModule 对象的 _source 属性用来保存该模块的 JS 代码,查看可以看到





该 NormalModule JS 内容为// extracted by mini-css-extract-plugin 与上文构建产物中 base.js 文件中空模块的代码一致。我们希望可以把这段注释的内容作为特征判断的依据,来区分当前 NormalModule 是否因 Css 文件提取而产生。若它是因 Css 提取而产生,我们需要阻止它单独生成 chunk 文件(即阻止它生成 base.js)。



为了验证这段注释代码确实可以稳定可靠的帮助我们判断的 NormalModule 类型,我们在 mini-css-extract-plugin 的对应 loader 中找到它的生成过程如下:

const pluginName = 'mini-css-extract-plugin';
let resultSource = // extracted by ${pluginName};
resultSource += options.hmr
? hotLoader(result, { context: this.context, options, locals })
: result;
return callback(null, resultSource);

通过上述代码,我们可以确认这段注释内容是稳定可控的。我们后续可以通过判断 Module 的内容(_source 属性)是否包含 extracted by mini-css-extract-plugin 关键字,来确认当前的 Module 是否为因提取样式而导致的副作用产物(空模块)。



由于需要保持 webpack 模块依赖图的完整性,我们不能简单的把这个 JS 副作用产物空模块从依赖图中直接删掉(删掉会导致依赖图不完整,运行页面时会造成模块加载错误),而只能采取折中的办法,把这个模块的内容插入到入口 js 文件(index.js)中,避免单独的 base.js 文件产出。



为了实现这个目标,我们在构建阶段需要做的事情如下:



  1. 找到所有由 splitchunks 优化导致产生的 chunk

  2. 判断该 chunk 包含的子模块中是否只存在 『 CssModule』类型模块和内容为// extracted by mini-css-extract-plugin 的『NormalModule』 类型模块,如果满足上述条件,则说明该 chunk 中实际上不含任何 JS 逻辑,为我们需要进行特殊处理(最终删除)的『问题 chunk』(上述 base chunk 即为这种类型的 chunk)

  3. 处理『问题 chunk』

1. 把『问题 chunk』 中的空 js 模块移动到它的源 chunk 中。概念解释:在上述例子中, base chunk 因 splitChunks 规则由入口 index chunk 分离产生, index chunk 即为 base chunk 的源。我们会把 base.js 文件的内容(空模块定义)插入到 index.js 中。

2. 把『问题 chunk』 在入口的 chunkGroup 中去掉。这样做可以去掉页面启动模块对 base chunk 的依赖,避免入口启动失败。

3. 阻止『问题 chunk』生成单独的 JS 文件(即阻止生成 base.js文件)



2. 编写 webpack plugin 处理依赖图



为了实现上述目标,我们需要编写自定义 webpack 插件来实现。

2.1 Webpack hooks 生命周期介绍

Webpack 中 compilation 对象负责推进具体的构建流程,compilation 对象上存在以下需要我们关注的生命周期:

  • seal:封存模块图的时机,这个阶段会由 module 生成 chunk,这个阶段之后会拒绝接收新的模块。

  • chunkAsset:由 chunk 生成 chunk asset 的时机。上述 base.css、base.js 等文件的内容,都为 chunk asset,都是这个阶段生成的。(这个阶段生成的是文件的内容,真实文件的产生则发生在 compiler 的 emit 阶段)

  • beforeChunkAssets: 发生在chunkAssets 前

  • additionalChunkAssets:发生在 chunkAssets 后。

2.2 plugin 逻辑注册

我们要选择合适的 webpack 生命周期(hooks)来注入我们的处理逻辑。



我们的 webpack plugin 要做的事情有两部分:



  1. 修改依赖图:

1. 找到 『问题 chunk』(对应上文 base chunk)

2. 把『问题 chunk』 中的 NormalModule 移动到 『入口 chunk』中(对应上文中 index chunk)。『问题 chunk』中的 NormalModule 必定都为空 js 模块。

3. 把 『问题 chunk』从页面入口的 chunkGroup 依赖中去掉,



  1. 需要阻止『问题 chunk』对空模块 js 文件(base.js) 的生成。



第一步,对依赖图的修改要在 seal 之后执行,否则我们无法拿到所有的 module 模块;

对依赖图的修改也要在 chunk 生成 chunk asset 之前执行,否则我们的修改无法及时影响到文件输出过程。

由此可以确认对依赖图的修改操作应该在 beforeChunkAssets 阶段 (该阶段在 seal 之后,chunkAssets 之前)执行。



第二步,阻止『问题 chunk』生成空模块文件(base.js)的操作,应该在 chunk assets 生成后进行,如果我们提前阻止了它的生成,那么同时会对 css 文件(base.css) 的生成产生干扰。所以第二步操作应该在additionalChunkAssets 阶段执行



核心处理代码如下

const CSS_MODULE_TYPE = 'css/mini-extract';

// 在 beforeChunkAssets 阶段注入处理逻辑
compilation.hooks.beforeChunkAssets.tap(pluginName, () => {
// 找到所有由 splitchunks 优化导致产生的 chunk
const splitChunks = compilation.chunks.filter(
thisChunk => thisChunk.chunkReason && thisChunk.chunkReason.includes('split chunk')
);

splitChunks.forEach(splitChunk => {
// 存储空 JS module,对应上述 base.js
const uselessModules = [];
// 存储不是 CssModule 的模块,可能是任意类型 Module
const noCssModules = [];
// 存储 Css Module
const cssModules = [];

Array.from(splitChunk.modulesIterable).forEach(mod => {
if (mod.type !== CSS_MODULE_TYPE) {
noCssModules.push(mod);
} else {
cssModules.push(mod);
}
});

noCssModules.forEach(nonCssMod => {
if (nonCssMod._source && nonCssMod._source._value === '// extracted by mini-css-extract-plugin') {
uselessModules.push(nonCssMod);
}
});

// 处理 『问题 chunk』
if (uselessModules.length === noCssModules.length) {
// 把『问题 chunk』中所有 JS 空 module 移动到 『源 chunk』,即上述 base chunk 中 NormalModule 空模块移动到 index chunk 中,从而把 base.js 的内容合并到 index.js 中
uselessModules.forEach(uselessModule => {
uselessModule.reasons.forEach(reason => {
reason.module.chunksIterable.forEach(previouslyConnectedChunk => {
splitChunk.moveModule(uselessModule, previouslyConnectedChunk);
});
});
});

splitChunk.groupsIterable.forEach(group => {
// 把『问题 chunk』从入口 chunk group 中删掉
group.removeChunk(splitChunk);
});
}
});
});
compilation.hooks.additionalChunkAssets.tap(pluginName, chunks => {
chunks.forEach(chunk => {
// problemChunkInfos 存储了所有的『问题 chunk』
if (this.problemChunkInfos[chunk.id]) {
chunk.files.forEach(file => {
if (path.extname(file) === '.js') {
// 组织生成空 JS 文件,对应上述 base.js
chunk.files.pop();
delete compilation.assets[file];
}
});
}
});
});
});
```


由此,我们初步完成 plugin 的开发,把它引入项目:

const Plugin = require('./webpack-plugin/mini-css-extract-useless-js-plugin');
// ... 省略
webpackOptions.plugins.push(new Plugin());

执行构建,查看 common 目录构建产物:





可以发现,common 下面已经不会出现 base.js,我们已经成功的阻止了它的生成。



然后打开页面入口 index.js 中查看,可以看到之前 base.js 中的空模块内容确实已经合并到 index.js 中了。





但是当我们打开页面进行调试时,却发现,页面中并没有加载 base.css 中的样式。由此推测,虽然目前生成了 base.css 文件,但是页面 html 文件中并没有对它的引用。



我们查看页面模板 HTML 代码,发现其中并没有包含对 base.css 的引用 link 标签。于是验证了这个结论。



<!doctype html><html lang="zh-cn"><head><meta charset="utf-8"><title></title><link rel="icon" href="//s3a.pstatp.com/toutiao/resource/ntoutiaoweb/static/image/favicon5995b44.ico" type="image/x-icon"><link rel="dns-prefetch" href="//s3.bytecdn.cn/"><meta name="screen-orientation" content="portrait"><meta name="x5-orientation" content="portrait"><meta name="format-detection" content="telephone=no"><meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,minimum-scale=1,maximum-scale=1,minimal-ui,viewport-fit=cover"><meta name="apple-mobile-web-app-capable" content="yes"></head><body><div id="root"></div><script type="text/javascript" src="//s3.pstatp.com/ies/aatestcss/dll/index.0e499cba.js"></script><script type="text/javascript" src="//s3.pstatp.com/ies/aatestcss/common/vendor.4ed04928.js"></script><script type="text/javascript" src="//s3.pstatp.com/ies/aatestcss/pages/home/index.dd8ee276.js"></script></body></html>

进行到这里,我们需要确认原来 HTML 模板中对 base.css 的依赖,是如何丢失的。



我们再重新梳理下之前处理『问题 chunkl』的思路



1. 把『问题 chunk』 中的 Normal Module 模块(即空 js 模块)插入到它的源 chunk 中。

2. 把『问题 chunk』 在入口的 chunkGroup 依赖中去掉。

3. 阻止『问题 chunk』对 base.js 的生成。

可以看到上述步骤中,我们在处理 base.js 相关逻辑后,直接删掉了入口对 base chunk 的依赖。但是实际上 base.css 文件的相关引用信息依然保存在 base chunk 中。



我们的页面入口此时与 base chunk 已经没有任何关系,所以虽然 base.css 依旧跟随 base chunk 生成了,但是入口拿不到任何 base.css 相关的引用信息,也就无法加载这个文件。



由此,我们尝试在对 base.js 的处理逻辑后,追加对 base.css 的相关处理,在处理依赖图时,把记录有 css 样式代码的 CssModule 也拿到 index chunk 中

compilation.hooks.beforeChunkAssets.tap(pluginName, () => {
// 代码省略 ...
// 把『问题 chunk』中所有 CSS module 移动到 『源 chunk』,即把上述 base.css 移动到 index chunk 中。因为这里如果不处理,则 html-webpack-plugin 处理 HTML 模板时,无法写入对 base.css 的引用。
cssModules.forEach(cssModule => {
cssModule.reasons.forEach(cssModuleReason => {
cssModuleReason.module.reasons.forEach(uselessModuleReason => {
uselessModuleReason.module.chunksIterable.forEach(originChunk => {
splitChunk.moveModule(cssModule, originChunk);
});
});
});
});
});



重新构建后,我们刷新页面,发现页面样式终于恢复了。



但是当我们回头查看构建产物,发现 base.css 消失了,同时页面入口目录 page/home 中多出了一个样式文件 index.css 。





我们点开 page/home/index.css 文件查看内容





发现本应该出现在 base.css 中的内容,出现在了 index.css 中。即 base.css 的文件名由 base.css 变成了 index.css,生成路径从 common 下移动到了页面入口所在目录,这样又回退到了未开启 splitchunk 的状态,显然是不满足我们的需求的。



此时我们需要查清楚,这个诡异的 index.css 到底是怎么产生的。



我们都知道,独立的 css 文件必定是由 mini-css-extract-plugin 提取产生,查看它的源码,我们可以看到 css 文件的生成逻辑:



compilation.chunkTemplate.hooks.renderManifest.tap(pluginName, (result, { chunk }) => {
const renderedModules = Array.from(chunk.modulesIterable).filter(module => module.type === MODULE_TYPE);
if (renderedModules.length > 0) {
result.push({
render: () => this.renderContentAsset(compilation, chunk, renderedModules, compilation.runtimeTemplate.requestShortener),
filenameTemplate: this.options.filename,
// path options 决定 css 文件名与输出目录的生成规则。这里由传入的 chunk 决定
pathOptions: {
chunk,
contentHashType: MODULE_TYPE
},
identifier: `${pluginName}.${chunk.id}`,
hash: chunk.contentHash[MODULE_TYPE]
});
}
});



mini-css-extract-plugin 针对 CssModule 的 css 文件生成过程,向 webpack compilation 对象注册了 manifest 声明(manifest 记录了一个 chunk 该怎么生成 chunk assets,即如何生成 css 文件)



webpack 中 compilation 会接收这个 manifest 声明,进行 Css 文件输出。



我们查看 webpack 中 compilation 的源码,找到 createChunkAssets 方法,可以看到产物路径(包含文件名)生成的语句:

file = this.getPath(filenameTemplate, fileManifest.pathOptions);



通过上面的源码分析,我们可以得出结论,Css 文件在生成时,其文件名和路径均由当前所在 Chunk 的 id 和路径决定,而与模块本身的 id 无关。



由于上面我们的 webpack plugin 逻辑中,在 base.css 文件生成之前,会把对应的记录有 CSS 样式代码的 CssModule 从 base chunk 移动到了 index chunk,所以 CssModule 在生成对应 CSS 文件时,会基于 index chunk 的上下文进行(而不是根据 base chunk),所以不管是文件名,还是输出路径,都与 index chunk 对齐。与上述产物特征表现保持一致。



而此时我们所需要的 css 的文件名和路径却都保存在 base chunk 中。



所以,我们需要让 css 文件的生成过程在 base chunk 中进行,然后在该 css 文件实际生成后(生成 bse.css后),再把生成的文件从 base chunk 中添加到 index chunk,以保证该文件的名称和路径符合我们的需要。



由上面分析,我们需要把之前对 CssModule 相关处理逻辑,从 beforeChunkAssets 阶段移动到 additionalChunkAssets 阶段,变更代码如下:

compilation.hooks.beforeChunkAssets.tap(pluginName, () => {
// 代码省略 。。。

// 代码块删除开始
// 把『问题 chunk』中所有 CSS module 移动到 『源 chunk』,
cssModules.forEach(cssModule => {
cssModule.reasons.forEach(cssModuleReason => {
cssModuleReason.module.reasons.forEach(uselessModuleReason => {
uselessModuleReason.module.chunksIterable.forEach(originChunk => {
splitChunk.moveModule(cssModule, originChunk);
});
});
});
});
// 代码块删除结束
});

compilation.hooks.additionalChunkAssets.tap(pluginName, chunks => {
chunks.forEach(chunk => {
// problemChunkInfos 存储了所有的『问题 chunk』
if (this.problemChunkInfos[chunk.id]) {
chunk.files.forEach(file => {
if (path.extname(file) === '.css') {
this.problemChunkInfos[chunk.id].originChunks.forEach(originChunk => {
// 把已经生成的 css 文件注入到入口模块中,html-webpack-plugin 在生成 html 时会去解析这里的依赖,从而自动生成对 css 文件的 link 引用标签。
originChunk.files.push(file);
});

} else if (path.extname(file) === '.js') {
// 组织生成空 JS 文件,对应上述 base.js
chunk.files.pop();
delete compilation.assets[file];
}
});
}
});
});
});


完成插件的最后修改后,我们进行构建,查看产物状态:





看到此时产物的目录结构已经符合预期,然后我们继续查看生成的 HTML 模板文件

<!doctype html><html lang="zh-cn"><head><meta charset="utf-8"><title></title><link rel="icon" href="//s3a.pstatp.com/toutiao/resource/ntoutiao_web/static/image/favicon_5995b44.ico" type="image/x-icon"><link rel="dns-prefetch" href="//s3.bytecdn.cn/"><meta name="screen-orientation" content="portrait"><meta name="x5-orientation" content="portrait"><meta name="format-detection" content="telephone=no"><meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,minimum-scale=1,maximum-scale=1,minimal-ui,viewport-fit=cover"><meta name="apple-mobile-web-app-capable" content="yes"><link href="//s3.pstatp.com/ies/aatestcss/common/base.50289158.css" rel="stylesheet"></head><body><div id="root"></div><script type="text/javascript" src="//s3.pstatp.com/ies/aatestcss/dll/index.0e499cba.js"></script><script type="text/javascript" src="//s3.pstatp.com/ies/aatestcss/common/vendor.4ed04928.js"></script><script type="text/javascript" src="//s3.pstatp.com/ies/aatestcss/pages/home/index.dd8ee276.js"></script></body></html>

可以看到 HTML 模板中已经成功生成了对 base.css 文件的引用。



然后我们回到本文开头提到的入口启动模块,确认启动模块的依赖状态。查看 index.js 代码:



/******/ var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
/******/ for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);/******/
/******/
/******/ // 可以看到,这里已经没有了 base chunk 的声明
/******/ deferredModules.push([0,"vendor"]);
/******/ // run deferred modules when ready
/******/ return checkDeferredModules();



可以看到,当前页面中只存在入口逻辑和 vendor 的 chunk依赖,base chunk 已经被成功删除。由此可以确认,问题得到完美解决。



五、插件源码

有关 webpack 插件的完整处理流程已提交至 Github 仓库,感兴趣的同学可以自取。



发布于: 2020 年 08 月 14 日 阅读数: 50
用户头像

分一

关注

还未添加个人签名 2019.05.29 加入

还未添加个人简介

评论

发布
暂无评论
[修复 Webpack 官方 Bug] 提取CSS时的依赖图修正