面试官:生产环境构建时为什么要提取css文件?

8,021 阅读10分钟

前言

面试官: webpack生产环境构建时为什么要将css文件提取成单独的文件?

我:基于性能考虑,如可以进行缓存控制

面试官:还有吗?

我:基于可读性考虑,独立的css文件更方便代码的阅读与调试

面试官:那你有了解过css是怎么提取成单独文件的吗?

我:嗯...?

看完本篇之后,希望小伙伴面试的时候碰到这个问题时你的回答是这样的

面试官: webpack生产环境构建时为什么要将css文件提取成单独的文件?

你会这么回答

  • 更好的缓存,当 CSS 和 JS 分开时,浏览器可以缓存 CSS 文件并重复使用,而不必重新加载,也不用因为js内容的变化,导致css缓存失效
  • 更快的渲染速度,浏览器是同时可以并行加载多个静态资源,当我们将css从js中抽离出来时,能够加快js的加载与解析速度,最终加快页面的渲染速度
  • 更好的代码可读性,独立的css文件更方便代码的阅读与调试

面试官: 那你有了解过css是怎么提取成单独文件的吗?

你会这么回答

  • 有了解过,提取css的时候,我们一般会使用mini-css-extract-plugin这个库提供的loaderplugin结合使用,达到提取css文件的目的
  • mini-css-extract-plugin这个插件的原理是
    • MiniCssExtractPlugin插件会先注册CssModuleFactoryCssDependency
    • 然后在MiniCssExtractPlugin.loader使用child compiler(webpack5.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-loadercss文件经过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为例,如下图所示 image.png

详细流程图

专用导出页.png

伪代码如下所示

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之后的数据 image.png

根据ast, 解析dependency image.png

index.js模块的depedencies image.png

foo.js模块的depedencies image.png 从上面的伪代码我们可以知道webpack内部是怎么创建模块,解析模块并最终生成模块代码的,简单来说就是import or require的文件当成一个依赖,而根据这个依赖会生成一个对应的module实例,最后在生成模块代码的时候,又会根据依赖模版构造函数生成模块内容,所以dependencymoduleFactoryDependencyTemplate都是密切关联的

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-pluginpicth loader先匹配到index.css文件,然后创建一个child compilerchild compiler指定index.css文件为entry文件
  • compiler陷入异步等待过程,子编译器根据css entry开始编译,匹配到css-loader(entry css使用了行内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 modulerender方法
  • 最终通过遍历manifest,生成一个css asset,一个js asset

提取流程如下图所示 专用导出页 (2).png

下面是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 {};`);
})

image.png 注意点:

  • 子编译器是以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合集,如下图所示 image.png 第一个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来生成文件

注意点:

  • chunkasset不一定是一对一的关系

遍历chunk从下图看到,本例只有一个chunk,这一个chunk包含2个module实例,一个是normalmodule,一个是css module image.png

本例中可以看到renderManifest hook执行完之后,获得的result包含两个值,一个是生成css asset,一个是生成js asset image.png

下图中可以看到,生成js asset的时候,css module被过滤了 image.png

总结

使用 webpack提取 css 是一种优化 web 应用程序性能的有效方式。当我们使用许多 css 库和框架时,这些库和框架通常会包含大量的 css 代码,导致页面加载速度变慢。通过使用 webpackcss 打包成一个单独的文件,我们可以减少页面加载时间,并提高用户体验。

本篇不仅讲述了webpack提取css的原理,其实也讲到了最基础的webpack的通用构建流程,pitch loader的运用,webpack plugin的运用,所以弄懂mini-css-extract-plugin插件相关的原理能够帮助我们更深的了解webpack原理同时也可以让我们在面试的过程中能够答出面试官满意的答案