前言
面试官: 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(webpack5.33.2之后默认使用importModule方法)以css文件为入口进行子编译,子编译流程跑完之后,最终会得到CssDependency- 然后
webpack会根据模块是否有dependencies,继续解析子依赖,当碰到CssDenpendcy的时候会先找到CssModuleFactory,然后通过CssModuleFactory.create创建一个css module- 当所有模块都处理完之后,会根据
MiniCssExtractPlugin插件内注册的renderManifesthookcallback,将当前chunk内所有的css module合并到一起,然后webpack会根据manifest创建assets- 最终
webpack会根据assets在生成最终的文件
本篇的主要目的不仅是为了面试的时候不被难倒,更是为了通过抽离css这个事,来了解webpack的构建流程,帮助我们对webpack有更深的了解,成为一个更好的webpack配置工程师
本篇的主要内容包括
webpack中样式处理方式webpack构建流程css文件提取原理
看完之后,你可以学到
webpack基础的构建流程pitchloader与行内loader的使用webpack插件的编写- 了解
webpackchild 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 {
color: red;
}
", "",{"version":3,"sources":["webpack://./src/app.css"],"names":[],"mappings":"AAAA;EACE,UAAU;AACZ","sourcesContent":[".wrap {
color: red;
}
"],"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 {
color: red;
}
", "",{"version":3,"sources":["webpack://./src/app.css"],"names":[],"mappings":"AAAA;EACE,UAAU;AACZ","sourcesContent":[".wrap {
color: red;
}
"],"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()
}
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陷入异步等待过程,子编译器根据cssentry开始编译,匹配到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流程,会触发renderManifesthook,通过mini-css-extract-plugin插件注册的renderManifesthookcallback会创建一个包含当前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就是利用renderManifesthook来从chunk中剥离css module生成最终的css assetwebpack最终在输出文件的时候,是以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原理同时也可以让我们在面试的过程中能够答出面试官满意的答案
