前言
面试官: webpack
生产环境构建时为什么要将css
文件提取成单独的文件?
我:基于性能考虑,如可以进行缓存控制
面试官:还有吗?
我:基于可读性考虑,独立的
css
文件更方便代码的阅读与调试
面试官:那你有了解过css
是怎么提取成单独文件的吗?
我:嗯...?
看完本篇之后,希望小伙伴面试的时候碰到这个问题时你的回答是这样的
面试官: webpack
生产环境构建时为什么要将css
文件提取成单独的文件?
你会这么回答
- 更好的缓存,当 CSS 和 JS 分开时,浏览器可以缓存 CSS 文件并重复使用,而不必重新加载,也不用因为js内容的变化,导致css缓存失效
- 更快的渲染速度,浏览器是同时可以并行加载多个静态资源,当我们将css从js中抽离出来时,能够加快js的加载与解析速度,最终加快页面的渲染速度
- 更好的代码可读性,独立的
css
文件更方便代码的阅读与调试
面试官: 那你有了解过css
是怎么提取成单独文件的吗?
你会这么回答
- 有了解过,提取
css
的时候,我们一般会使用mini-css-extract-plugin
这个库提供的loader
与plugin
结合使用,达到提取css
文件的目的- 而
mini-css-extract-plugin
这个插件的原理是
MiniCssExtractPlugin
插件会先注册CssModuleFactory
与CssDependency
- 然后在
MiniCssExtractPlugin.loader
使用child compiler
(webpack
5.33.2之后默认使用importModule方法)以css
文件为入口进行子编译,子编译流程跑完之后,最终会得到CssDependency
- 然后
webpack
会根据模块是否有dependencies
,继续解析子依赖,当碰到CssDenpendcy
的时候会先找到CssModuleFactory
,然后通过CssModuleFactory.create
创建一个css module
- 当所有模块都处理完之后,会根据
MiniCssExtractPlugin
插件内注册的renderManifest
hook
callback
,将当前chunk
内所有的css module
合并到一起,然后webpack
会根据manifest
创建assets
- 最终
webpack
会根据assets
在生成最终的文件
本篇的主要目的不仅是为了面试的时候不被难倒,更是为了通过抽离css
这个事,来了解webpack
的构建流程,帮助我们对webpack
有更深的了解,成为一个更好的webpack
配置工程师
本篇的主要内容包括
webpack
中样式处理方式webpack
构建流程css
文件提取原理
看完之后,你可以学到
webpack
基础的构建流程pitch
loader
与行内loader
的使用webpack
插件的编写- 了解
webpack
child compiler
如何处理css
开发环境
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
},
{
loader: 'postcss-loader',
}
]
},
]
}
}
样式先经过postcss-loader
处理,然后在经过css-loader
处理,最后在通过style-loader
处理,以style
标签的形式插入到html
中
生产环境
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{
- loader: 'style-loader',
+ loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
},
{
loader: 'postcss-loader',
}
]
},
],
},
plugins: [
+ new MiniCssExtractPlugin(
+ {
+ filename: 'css/[name].[contenthash].css',
+ chunkFilename: 'css/[name].[contenthash].css',
+ experimentalUseImportModule: false
+ }
+ )
]
}
将开发环境使用style-loader
替换成MinicssExtractPlugin.loader
,并且添加MinicssExtractPlugin
插件,最终webpack
构建的结果会包含单独的css
文件,这是为什么?继续往下看
css-loader原理
在看mini-css-extract-plugin
插件的作用之前,先简单看下css-loader
的原理
首先webpack
是无法处理css
文件的,只有添加了对应的loader
比如,css-loader
。css
文件经过loader
处理之后,将css
转化为webpack
能够解析的javascript
才不会报错
比如
.wrap {
color: red;
}
经过css-loader
处理后
// 最终css-loader处理后返回的内容
// Imports
import ___CSS_LOADER_API_SOURCEMAP_IMPORT___ from "../node_modules/.pnpm/css-loader@6.7.3_webpack@5.79.0/node_modules/css-loader/dist/runtime/sourceMaps.js";
import ___CSS_LOADER_API_IMPORT___ from "../node_modules/.pnpm/css-loader@6.7.3_webpack@5.79.0/node_modules/css-loader/dist/runtime/api.js";
var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(___CSS_LOADER_API_SOURCEMAP_IMPORT___);
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".wrap {\n color: red;\n}\n", "",{"version":3,"sources":["webpack://./src/app.css"],"names":[],"mappings":"AAAA;EACE,UAAU;AACZ","sourcesContent":[".wrap {\n color: red;\n}\n"],"sourceRoot":""}]);
// Exports
export default ___CSS_LOADER_EXPORT___;
从产物我们可以看到
css-loader
会将css
处理成字符串css
模块经过css-loader
处理之后,返回的内容变成了一个js
模块
最终webpack
输出的产物(关闭压缩与scope hosting)
/******/ (function() { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ 410:
/***/ (function(module, __unused_webpack___webpack_exports__, __webpack_require__) {
/* harmony import */ var _node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_sourceMaps_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(912);
/* harmony import */ var _node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_sourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_sourceMaps_js__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var _node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(568);
/* harmony import */ var _node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__);
// Imports
var ___CSS_LOADER_EXPORT___ = _node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default()((_node_modules_pnpm_css_loader_6_7_3_webpack_5_79_0_node_modules_css_loader_dist_runtime_sourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default()));
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".wrap {\n color: red;\n}\n", "",{"version":3,"sources":["webpack://./src/app.css"],"names":[],"mappings":"AAAA;EACE,UAAU;AACZ","sourcesContent":[".wrap {\n color: red;\n}\n"],"sourceRoot":""}]);
// Exports
/* unused harmony default export */ var __WEBPACK_DEFAULT_EXPORT__ = ((/* unused pure expression or super */ null && (___CSS_LOADER_EXPORT___)));
/***/ }),
/***/ 568:
/***/ (function(module) {
module.exports = function (cssWithMappingToString) {};
/***/ }),
/***/ 912:
/***/ (function(module) {
module.exports = function (item) {};
/***/ })
/******/ });
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
!function() {
/* harmony import */ var _app_css__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(410);
document.write(1111);
}();
/******/ })()
;
//# sourceMappingURL=app.014afe2d9ceb7dcded8a.js.map
从上面生成的代码可以看到只经过css-loader
处理,在生成环境是无法正常加载样式的,因为没有用style
处理,也没有被提取成单独的css
文件
webpack构建流程
在了解webpack
提取css
样式文件的原理前,我们需要先对webpack
构建流程有一个初步的了解,只有了解了webpack
构建流程,才能掌握webpack
提取css
的原理
示例代码
import { foo} from './foo'
document.write(foo)
export const foo = 1
我们看下webpack
是怎么解析js文件,从entry
(这里是index.js)到所有依赖的模块解析完成的过程,以normalModule
为例,如下图所示
详细流程图
伪代码如下所示
const { entry, options, context } = this;
const dep = EntryPlugin.createDependency(entry, options);
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});
addEntry从entry开始解析,然后会调用addModuleTree开始构建依赖数
addModuleTree({ context, dependency, contextInfo }, callback) {
const Dep = dependency.constructor;
// dependencyFactories会根据保存创建依赖模块的构造函数
// 比如EntryDependency=>normalModuleFactory
// 比如HarmonyImportSideEffectDependency => normalModuleFactory
// 比如HarmonyImportSpecifierDependency => normalModuleFactory
const moduleFactory = this.dependencyFactories.get(Dep);
// 通过模块工厂函数创建module实例
moduleFactory.create()
// 通过loader处理,将所有的资源转化成js
runLoaders()
// loader处理完之后,在通过parse成ast
NormalModule.parser.parse()
// 最后遍历ast,找到import require这样的dependency
parser.hooks.import.tap(
"HarmonyImportDependencyParserPlugin",
(statement, source) => {
const sideEffectDep = new HarmonyImportSideEffectDependency(
source,
parser.state.lastHarmonyImportOrder,
assertions
);
parser.state.module.addDependency(sideEffectDep);
}
// 最后在遍历模块的依赖,又回到前面的根据依赖找模块工厂函数,然后开始创建模块,解析模块,一直到所有模块解析完
processModuleDependencies()
// 当所有的模块解析结束之后,就要生成模块内容
codeGeneration()
// 生成模块内容的时候,最终又会通过依赖,来找依赖模版构造函数
const constructor = dependency.constructor;
// 比如 HarmonyImportSideEffectDependency => HarmonyImportSideEffectDependencyTemplate
// 比如 HarmonyImportSpecifierDependency => HarmonyImportSpecifierDependencyTemplate
const template = generateContext.dependencyTemplates.get(constructor);
// 最后创建assets,根据assets生成最终的文件
createChunkAssets()
}
import
语句解析成ast
之后的数据
根据ast
, 解析dependency
index.js模块的depedencies
foo.js模块的depedencies
从上面的伪代码我们可以知道webpack
内部是怎么创建模块,解析模块并最终生成模块代码的,简单来说就是import
or require
的文件当成一个依赖,而根据这个依赖会生成一个对应的module
实例,最后在生成模块代码的时候,又会根据依赖模版构造函数生成模块内容,所以dependency
、moduleFactory
、DependencyTemplate
都是密切关联的
css提取原理
了解了webpack
的基本构建流程之后,我们现在来看mini-css-extract-plugin
插件是如何将所有的css
文件提取出来,并根据chunk
来进行合并css
内容
案例代码
.wrap {
color: red
}
import './index.css'
document.wirte(111)
{
mode: 'production',
devtool: 'source-map',
output: {
path: path.join(__dirname, '../dist'),
filename: 'js/[name].[chunkhash].js',
chunkFilename: 'chunk/[name].[chunkhash].js',
publicPath: './'
},
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
},
]
},
]
},
optimization: {
minimize: false,
concatenateModules: false,
},
entry: {
app: path.join(__dirname, '../src/index')
},
plugins: [
new MiniCssExtractPlugin(
{
filename: 'css/[name].[contenthash].css',
chunkFilename: 'css/[name].[contenthash].css',
experimentalUseImportModule: false
}
)
]
}
css
提取原理是(以上面的案例为例,且不考虑importModule
场景)
- 通过
mini-css-extract-plugin
的picth loader
先匹配到index.css文件,然后创建一个child compiler
,child compiler
指定index.css文件为entry
文件 - 父
compiler
陷入异步等待过程,子编译器根据css
entry
开始编译,匹配到css-loader
(entrycss
使用了行内loader
并禁用了rules内的loader
匹配),经过css-loader
处理之后,继续进行编译,一直到child compiler
编译流程结束 - 进入子编译器执行成功之后的
callback
,根据子编译流程的结果构造cssDependency
,然后通过this._module.addDependency(new cssDependency())
api将cssDependency
添加到index.css模块的dependency
中,然后调用callback(null,export {};
); 阻断后续loader
执行也就是css-loader
执行 - 继续父
compiler
编译流程,index.css编译结束,有一个cssDependency
依赖,然后根据cssDependency
依赖找到cssModuleFactory
,然后通过cssModuleFactory
创建css module
实例,调用css module
上的build方法构建css module
,最终css module
没有dependencies
,所有模块解析完成 - 进入
createAssets
流程,会触发renderManifest
hook
,通过mini-css-extract-plugin
插件注册的renderManifest
hook
callback
会创建一个包含当前chunk
内所有css module
的render
方法 - 最终通过遍历
manifest
,生成一个css asset
,一个js asset
提取流程如下图所示
下面是css module
创建,及css asset
创建的伪代码过程
创建css module
伪代码如下所示
// mini-css-extract-plugin处理逻辑
// 定义CssModule、CssDependency、CssModuleFactory、CssDependencyTemplate
// CssModule 用于生产css module实例
// CssDependency 用于构建css dependency
class CssModule {}
class CssDependency{}
class CssModuleFactory {
create({
dependencies: [dependency]
}, callback) {
callback(
undefined, new CssModule( /** @type {CssDependency} */dependency));
}
}
compilation.dependencyFactories.set(CssDependency, new CssModuleFactory());
class CssDependencyTemplate {
apply() {}
}
compilation.dependencyTemplates.set(CssDependency, new CssDependencyTemplate());
// index.css匹配到.css相关的loader,也就是先进入mini-css-extract-plugin.loader
// 跳过importModule处理模块的方式
// 创建子编译器,子编译器,会继承父compiler的大部分hook及插件,然后在子编译器依赖树构建完成之后,会将assets赋值到父compiler的assets上,才能最终输出文件
const childCompiler = this._compilation.createChildCompiler(`${MiniCssExtractPlugin.pluginName} ${request}`, outputOptions);
// 指定css文件为entry,注意路径带有!!前缀,禁止匹配rules中的loader
EntryOptionPlugin.applyEntryOption(childCompiler, this.context, {
child: {
library: {
type: "commonjs2"
},
// request /node_modules/css-loader/dist/cjs.js!/Users/wangks/Documents/f/github-project/webpack-time-consuming/src/index.css
import: [`!!${request}`]
}
});
childCompiler.hooks.compilation.tap(MiniCssExtractPlugin.pluginName,
compilation => {
compilation.hooks.processAssets.tap(MiniCssExtractPlugin.pluginName, () => {
source = compilation.assets[childFilename] && compilation.assets[childFilename].source();
// 主动删除子编译器产生的assets,避免子编译器编译结束之后,进行assets赋值
compilation.chunks.forEach(chunk => {
chunk.files.forEach(file => {
compilation.deleteAsset(file);
});
});
});
});
// 对css文件作为entry的子编译器开始进行编译
childCompiler.runAsChild((error, entries, compilation) => {
// 子编译流程结束,依赖树构建完成
// 创建CssDependency
const CssDependency = MiniCssExtractPlugin.getCssDependency(webpack);
// 并赋值给当前模块的dependencies中,便于解析出css module
this._module.addDependency(lastDep = new CssDependency( /** @type {Dependency} */
dependency, /** @type {Dependency} */
dependency.context, count))
// 返回空对象,阻断后续loader执行
callback(null, `export {};`);
})
注意点:
- 子编译器是以
css
文件作为entry进行编译 - 子编译处理入口
css
的时候,因为带来!!前缀,所以不会在匹配到自身的loader
处理逻辑 mini-css-extract-plugin.loader
是一个pitch loader
,当子编译结束之后,将cssDependency
添加到_module.addDependency
,调用callback阻断后续loader
处理流程
简单理解就是当父compiler
解析js文件的时候,js中发现有引用css
文件,那么会先将css
文件当成普通的nomarlModule
,然后经过mini-css-extract-plugin.loader
处理后,这个nomarlNodule
会得到cssDependency
,然后在根据cssDependency
继续在父compiler
创建出css module
实例
主compiler
处理完之后的modules
合集,如下图所示
第一个module
实例是index.js对应的normalmodule
实例
第二个module
实例是index.css对应的normalmodule
实例,但是内容为空
第三个module
实例是index.css对应的css module
实例,内容就是css
文件的内容
这样经过mini-css-extract-plugin
插件处理之后,css
样式就被单独提取出来了,且最后的index.css对应的normalmodule
实例因为内容为空,会被干掉
那么mini-css-extract-plugin
是怎么处理,将index.css对应的normalmodule
实例变为空,且创建出新的css module
实例的
这是css module
创建的过程,那么最终所有的css module
是怎么生成到一个文件的,以本篇的例子为例继续分析源码
创建css asset
// 最后创建assets,根据assets生成最终的文件
createChunkAssets()
// 进入mini-plugin renderManifest逻辑
compilation.hooks.renderManifest.tap(pluginName,
(result, {
chunk
}) => {
// 过滤css module
const renderedModules = Array.from(this.getChunkModules(chunk, chunkGraph)).filter(module => module.type === MODULE_TYPE);
// 如果chunk中包含呢css module,则向数组中push一个对象
result.push({
// 根据manifest生成asset的时候,会调用render方法,决定asset的内容
render: () => renderContentAsset(compiler, compilation, chunk, renderedModules, compilation.runtimeTemplate.requestShortener, filenameTemplate, {
contentHashType: MODULE_TYPE,
chunk
})
});
renderContentAsset(compiler, compilation, chunk, modules, requestShortener, filenameTemplate, pathData) {
const usedModules = this.sortModules(compilation, chunk, modules, requestShortener);
const {
ConcatSource,
SourceMapSource,
RawSource
} = compiler.webpack.sources;
const source = new ConcatSource();
const externalsSource = new ConcatSource();
// 合并module内容
for (const module of usedModules) {
let content = module.content.toString();
content = content.replace(new RegExp(ABSOLUTE_PUBLIC_PATH, "g"), "");
content = content.replace(new RegExp(BASE_URI, "g"), baseUriReplacement);
if (module.sourceMap) {
source.add(new SourceMapSource(content, readableIdentifier, module.sourceMap.toString()));
} else {
source.add(new RawSource(content));
}
}
return new ConcatSource(externalsSource, source);
}
}
// 进入javascriptModulePlugin的renderManifest callback内
compilation.hooks.renderManifest.tap(PLUGIN_NAME, (result, options) => {
result.push({
render: () =>
this.renderMain(
{
hash,
chunk,
dependencyTemplates,
runtimeTemplate,
moduleGraph,
chunkGraph,
codeGenerationResults,
strictMode: runtimeTemplate.isModule()
},
hooks,
compilation
)
});
renderMain() {
const allModules = Array.from(
chunkGraph.getOrderedChunkModulesIterableBySourceType(
chunk,
"javascript",
compareModulesByIdentifier
) || []
);
...
return iife ? new ConcatSource(finalSource, ";") : finalSource;
}
})
const manifest = this.hooks.renderManifest.call([], options)
// 遍历manifest,也就是之前hook callback内传入的result,根据manifest生成最终的asset
asyncLib.forEach(
// 两个manifest,一个是包含css module的asset,一个是包含js module的asset
manifest,
(fileManifest, callback) => {
source = fileManifest.render();
this.emitAsset(file, source, assetInfo);
})
总结起来
- 根据
chunk
生成manifest,然后在根据manifest生成asset
mini-css-extract-plugin
就是利用renderManifest
hook
来从chunk
中剥离css module
生成最终的css asset
webpack
最终在输出文件的时候,是以assets
来生成文件
注意点:
chunk
与asset
不一定是一对一的关系
遍历chunk
从下图看到,本例只有一个chunk
,这一个chunk
包含2个module
实例,一个是normalmodule
,一个是css module
本例中可以看到renderManifest
hook
执行完之后,获得的result包含两个值,一个是生成css asset
,一个是生成js asset
下图中可以看到,生成js asset
的时候,css module
被过滤了
总结
使用 webpack
提取 css
是一种优化 web 应用程序性能的有效方式。当我们使用许多 css
库和框架时,这些库和框架通常会包含大量的 css
代码,导致页面加载速度变慢。通过使用 webpack
将 css
打包成一个单独的文件,我们可以减少页面加载时间,并提高用户体验。
本篇不仅讲述了webpack
提取css
的原理,其实也讲到了最基础的webpack
的通用构建流程,pitch loader
的运用,webpack plugin
的运用,所以弄懂mini-css-extract-plugin
插件相关的原理能够帮助我们更深的了解webpack
原理同时也可以让我们在面试的过程中能够答出面试官满意的答案