【Webpack】前端工程化之Webpack与模块化开发

目 录

前言

车同轨,书同文,行同伦。 ------《礼记·中庸》

模块化开发

Webpack是一个JavaScript应用的静态模块化打包工具,它最早的出发点就是去实践前端方向的模块化开发,解决如何在前端项目中更高效地管理和维护项目中的每一个资源问题。在Webpack的理念中,前端项目中的任何资源都可以作为一个模块,任何模块都可以经过Loader机制的处理,最终再被打包到一起。

既如此,说到webpack,就不得不cue一下模块化了。

模块化: 随着前端应用的日益复杂化,我们的项目已经逐渐膨胀到了不得不花大量时间去管理的程度。而模块化就是一种最主流的项目组织方式,它通过把复杂的代码按照功能划分为不同的模块单独维护,从而提高开发效率,降低维护成本。

关于模块化的发展,其实是有几个代表阶段:

Stage1 - 文件划分方式

早期是基于文件划分的方式实现模块化开发,这也是web最原始的模块系统。

具体做法:将每个功能及其相关状态数据各自单独放到不同的JS文件中,约定每个文件是一个独立的模块。如果要使用某个模块,就将这个模块引入到页面中,一个script标签对应一个模块,然后直接调用模块中的成员(变量/函数)。

bash 复制代码
|------module-a.js
|------module-b.js
|------index.html
javascript 复制代码
// module-a.js
function foo() {
  console.log("moduleA#foo");
}
javascript 复制代码
// module-b.js
let name = "aDiao";
const data = "moduleB#foo";
javascript 复制代码
// index.html
  <body>
    <script src="moudle-a.js"></script>
    <script src="moudle-b.js"></script>
    <script>
      foo();
      console.log(name);
      console.log(data);
      name = "aDiao#Ya";
      console.log("===", name);
      data = "index#html";
      console.log("===", data);
    </script>
  </body>

从上面的demo中可以看到,这样写会出现一些问题:

  • 模块直接在全局工作,大量模块成员污染全局作用域;
  • 没有私有空间,所有模块内的成员都可以在模块外部被访问或者修改;
  • 一旦模块增多,容易产生命名冲突;
  • 无法管理模块与模块之间的依赖关系;
  • 在维护的过程中,也很难分辨每个成员所属的模块。

Stage2 - 命名空间方式

后来,我们约定每个模块只暴露一个全局对象,所有模块成员都挂载到这个全局对象中。

具体做法是在第一阶段的基础上,通过将每个模块"包裹"为一个全局对象的形式实现,这种方式就好像是为模块内的成员添加了"命名空间",所以我们又称之为命名空间方式。

bash 复制代码
|------module-a.js
|------module-b.js
|------index.html
javascript 复制代码
// module-a.js
window.moduleA = {
  method1: function () {
    console.log("moduleA#method1");
  },
};
javascript 复制代码
// module-b.js
window.moduleB = {
  data: "ImModuleB",
  method1: function () {
    console.log("moduleB#method1");
  },
};
javascript 复制代码
// index.html
  <body>
    <script src="moudle-a.js"></script>
    <script src="moudle-b.js"></script>
    <script>
      moduleA.method1();
      moduleB.method1();
      moduleA.data = "ImModuleA";
      console.log(moduleA.data);
      console.log(moduleB.data);
    </script>
  </body>

这种方式只是解决了命名冲突的问题,但其他问题仍然存在。

Stage3 - IIFE(立即调用函数表达式)

立即调用函数表达式(IIFE)是一个在定义时就会立即执行的 JavaScript 函数。

它是一种设计模式,也被称为自执行匿名函数,主要包含两部分:

  • 第一部分是一个具有词法作用域的匿名函数,并且用圆括号运算符 () 运算符闭合起来。这样不但阻止了外界访问自执行匿名函数中的变量,而且不会污染全局作用域。
  • 第二部分创建了一个立即执行函数表达式 (),通过它,JavaScript 引擎将立即执行该函数。

使用IIFE给模块提供私有空间,避免污染全局命名空间。

具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现。

bash 复制代码
|------module-a.js
|------module-b.js
|------index.html
javascript 复制代码
// module-a.js
(function () {
  var name = "module-a";
  function method1() {
    console.log(name + "#method1");
  }
  window.moduleA = {
    method1: method1,
  };
})();
javascript 复制代码
// module-b.js
(function () {
  var name = "module-b";
  function method1() {
    console.log(name + "#method1");
  }
  window.moduleB = {
    method1: method1,
  };
})();
javascript 复制代码
// index.html
  <body>
    <script src="moudle-a.js"></script>
    <script src="moudle-b.js"></script>
    <script>
      moduleA.method1();
      moduleB.method1();
      console.log(name);
    </script>
  </body>

这种方式将成员私有化,私有成员只能在模块成员内通过闭包的形式访问,解决了全局作用域污染和命名冲突的问题。

Stage 4 - IIFE 依赖参数

在IIFE的基础上,还可以利用IIFE参数作为依赖声明使用,让每一个模块之间的依赖关系变得更加明显。

javascript 复制代码
// module-a.js
(function ($) {
  var name = "module-a";
  function method1() {
    console.log(name + "#method1");
    $(".box").animate({ width: "200px" });
  }
  window.moduleA = {
    method1: method1,
  };
})(jQuery);
html 复制代码
// index.html
  <style>
    .box {
      width: 100px;
      height: 100px;
      background-color: pink;
    }
  </style>
  <body>
    <div class="box"></div>
    <script src="https://unpkg.com/jquery"></script>
    <script src="moudle-a.js"></script>
    <script src="moudle-b.js"></script>
    <script>
      moduleA.method1();
      moduleB.method1();
    </script>
  </body>

模块化的标准规范

以上4个阶段是早期的开发者,在没有工具和规范的情况下对模块化的实现方式,虽然解决了很多在前端领域实现模块化的问题,但仍然存在一些没有解决的问题。

其中,比较明显的问题:

  • 模块化的加载。 上面都是通过script标签的方式直接在页面中引入这些模块,时间久了维护起来会十分麻烦。比如,某个代码需要用到某个模块,如果html中忘记引入这个模块,或者代码中移除了某个模块,但是html中忘记删除该模块的引用等,都会引起很多问题和麻烦。
  • 模块化的规范。 上面几种方式,不同的开发者在实现的过程中都会出现一些细微的差别,为了统一不同开发者、不同项目之间的差异,需要指定一个行业标准来规范模块化的实现方式。

由此,结合上述的问题,现在的需求就是:

  • 一个统一的模块化标准规范
  • 一个可以自动加载模块的基础库

说到模块化规范,在历史的长河中,出现了前端五大模块化规范(我知道滴有五种~)

  1. CommonJS规范:最初提出来是在浏览器以外的地方使用,并且当时命名为ServerJS,后来为了体现它的广泛性,更名为CommonJS,也可以简称为CJS

    • 该规范约定,一个文件就是一个模块,每个模块都有单独的作用域,通过 exports 或者 module.exports 导出需要暴露的内容,然后通过 require 方法同步加载所依赖的模块。
    • CommonJS模块的加载是同步的,需要等模块加载完毕后,后面的逻辑才会执行,这个在服务器不会有什么问题,因为服务器加载的是本地JS文件,速度会很快。但如果在浏览器端加载,需要先从服务端下载下来,然后再加载运行,会造成浏览器线程阻塞。
  2. AMD规范:即异步模块定义规范,主要是为浏览器环境设计的,推崇依赖前置,也就是提前执行(预执行),在模块使用之前就已经执行完毕。

    • 在 AMD 规范中约定每个模块通过 define() 函数定义,这个函数默认可以接收两个参数,第一个参数是一个数组,用来声明这个模块的依赖项;第二个参数是一个函数,参数与前面的依赖项一一对应,每一项分别对应依赖项模块的导出成员,这个函数的作用就是为当前模块提供一个私有空间。如果在当前模块中需要向外部导出成员,可以通过 return 的方式实现,然后通过 require 语句加载模块。
    • 实现 AMD 规范的库主要是 require.jscurl.js。原生的 JavaScript 环境并不支持异步加载的方式,require.js 提供了一种机制来异步加载模块,并且可以在加载完成后执行回调函数。
  3. CMD规范:应用于浏览器的一种模块化规范,也是通过异步加载模块的,要解决的问题与 AMD 一样,只不过是对依赖模块的执行时机不同 ,推崇就近依赖、延迟执行,目前也很少使用了。

    • 在CMD规范中,通过全局函数 define 定义模块,这个函数接受一个 factory 参数,可以是一个函数,也可以是一个对象或字符串;当 factory 是函数时,接收三个参数,function(require, exports, module)require 函数用来获取其他模块提供的接口require(模块标识ID);exports 对象用来向外提供模块接口;module 对象存储了与当前模块相关联的属性和方法。
    • CMD从语法上分析,结合了AMD模块定义的特点,同时又沿用了CommonJs 模块导入和导出的特点
  4. UMD规范:UMD是AMD和CommonJS的糅合。

    • UMD的实现:先判断是否支持Node.js模块(exports是否存在),存在则使用Node.js模块模式;再判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块;前两个都不存在,则将模块公开到全局(window或global)。
  5. ESModule规范:ESM是ECMAScript 2015 (ES6)中才定义的模块系统,存在环境兼容问题。它的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

    • 在ESM规范 中,使用 import 引用模块,使用 export 导出模块。默认情况下,Node.js 是不支持 import 语法的,通过 babelES6 模块 编译为 ES5CommonJS。因此 Babel 实际上是将 import/export 翻译成 Node.js 支持的 require/exports
    • ESM的解析过程可以划分为三个阶段:(1)构建,根据地址查找JS文件,并且下载,将其解析为模块记录。(2)实例化,对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址。 (3)运行,运行代码,计算值,并将值填充到内存地址中。

模块化可以帮助我们更好地解决复杂应用开发过程中的代码组织问题,但是也会产生新的问题:

  • 模块化的方式划分出来的模块文件过多,而前端应用又运行在浏览器中,每一个文件都需要单独从服务器请求回来。零散的模块文件必然会导致浏览器的频繁发送网络请求,影响应用的工作效率。【将散落的模块打包到一起】
  • 随着应用日益复杂,在前端应用开发过程中不仅仅只有 JavaScript 代码需要模块化,HTMLCSS 这些资源文件也会面临需要被模块化的问题。而且从宏观角度来看,这些文件也都应该看作前端应用中的一个模块,只不过这些模块的种类和用途跟 JavaScript 不同。【支持不同种类的前端资源模块】

针对这些问题,我们可使用前端模块打包工具来解决。

使用Webpack实现模块化打包

  • Webpack 作为一个模块打包工具,可以解决模块化代码打包的问题,将零散的JavaScript代码打包到一个JS文件中。
  • 对于有环境兼容问题的代码,Webpack 可以在打包过程中通过 Loader 机制对其实现编译转换,然后再进行打包。
  • 对于不同类型的前端模块类型,Webpack 支持在 JavaScript 中以模块化的方式载入任意类型的资源文件,例如,我们可以通过 Webpack 实现在 JavaScript 中加载 CSS 文件,被加载的 CSS 文件将会通过 style 标签的方式工作。
  • Webpack 还具备代码拆分的能力,它能够将应用中所有的模块按照我们的需要分块打包。这样一来,就不用担心全部代码打包到一起,产生单个文件过大,导致加载慢的问题。我们可以把应用初次加载所必需的模块打包到一起,其他的模块再单独打包,等到应用工作过程中实际需要用到某个模块,再异步加载该模块,实现增量加载或者叫作渐进式加载,非常适合现代化的大型 Web 应用。

安装Webpack

1、初始化项目

bash 复制代码
npm init --yes

2、安装Webpack(如果是webpack4.0以上版本,需要安装Webpack-cli)

bash 复制代码
npm i webpack  webpack-cli  --save-dev

3、查看Webpack版本信息

bash 复制代码
npx webpack --version

Webpack基本配置

  • 在项目根目录添加Webpack的配置文件 webpack.config.js
javascript 复制代码
module.exports = {};

1. 配置入口文件

入口文件就是应用程序的起点,webpack在解析代码的时候,会先找到入口文件,从入口文件开始递归解析入口文件中所有的依赖项,构建依赖图。

javascript 复制代码
// 单入口
module.exports = {
  entry: "./src/main.js",
};
// 多入口:当需要创建多个 bundle 时,可以配置多个入口点。
module.exports = {
  entry: {
    pageOne: './src/pageOne/index.js',
    pageTwo: './src/pageTwo/index.js',
    pageThree: './src/pageThree/index.js',
  },
};

2. 配置输出

通过配置output属性,告诉webpack在哪里输出它所创建的bundle,以及如何命名这些文件。

javascript 复制代码
const path = require('path');

module.exports = {
  entry: "./src/main.js",
  output: {
      path: path.resolve(__dirname, 'dist'),
      filename: "bundle.js",
  },
};

3. 配置loader

loader用来转换某些类型的模块,负责完成项目中各种各样资源模块的加载。因为webpack默认只能打包处理JS类型的文件,无法处理其它非JS类型的文件。如果想要处理非JS类型的文件,需要手动安装一些合适的第三方loader加载器。比如将样式表(CSS)、图片、JSON 或 TypeScript 文件转换为 JavaScript 模块。

javascript 复制代码
const path = require('path');

module.exports = {
  entry: "./src/main.js",
  output: {
      path: path.resolve(__dirname, 'dist'),
      filename: "bundle.js",
  },
  module: {
    rules: [
    	{ test: /\.css$/, use: 'css-loader' },
	    { test: /\.ts$/, use: 'ts-loader' },
    ],
  },
};

4. 配置plugin

plugin可以用来执行范围更广的任务,增强webpack在项目自动化构建方面的能力。比如:

  • 打包之前自动清除上次打包的dist文件;
  • 自动生成应用所需要的html文件;
  • 自动压缩webpack打包完成后输出的文件;
  • 自动发布打包结果到服务器实现自动部署...
javascript 复制代码
const path = require('path');

module.exports = {
  entry: "./src/main.js",
  output: {
      path: path.resolve(__dirname, 'dist'),
      filename: "bundle.js",
  },
  module: {
    rules: [
    	{ test: /\.css$/, use: 'css-loader' },
	    { test: /\.ts$/, use: 'ts-loader' },
    ],
  },
  plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
};

5. 配置工作模式

通过设置 mode 参数,来选择不同的工作模式:production【启动内置优化插件,自动优化打包结果,打包速度偏慢】、development【自动优化打包速度,添加一些调试过程中的辅助插件】、none【运行最原始的打包,不做任何额外处理】,可以启用 webpack 内置在相应环境下的优化。其默认值为 production。

javascript 复制代码
const path = require('path');

module.exports = {
  mode: "development",
  entry: "./src/main.js",
  output: {
      path: path.resolve(__dirname, 'dist'),
      filename: "bundle.js",
  },
  module: {
    rules: [
    	{ test: /\.css$/, use: 'css-loader' },
	    { test: /\.ts$/, use: 'ts-loader' },
    ],
  },
  plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
};

Webpack构建流程

webpack打包的大致过程: 根据配置找到指定的入口文件,从入口文件开始,根据代码中出现的 import/require 解析这个文件所依赖的资源模块,然后再分别解析每个资源模块的依赖,构建依赖关系树,然后递归遍历这个依赖树,找到每个节点对应的资源文件,把不同类型的模块交给对应的Loader 处理,处理完成后打包到一起 (bundle.js) 。

注意: 对于依赖模块中无法通过JS代码表示的资源模块,如图片、字体文件等,一般Loader会把它们单独作为资源文件拷贝到输出目录中,然后将这个资源文件所对应的访问路径作为这个模块的导出成员,暴露给外部。

浅看一下 webpack@5.92.1webpack-cli@5.1.4 的源码 ~

运行webpack命令时,把通过命令行传入的参数转换为webpack的配置选项对象,根据命令行参数加载指定的配置文件,载入webpack核心模块,传入配置选项,创建Compiler编译器对象。【webpack-cli@5.1.4】

javascript 复制代码
// webpack-cli/bin/cli.js
...
// 初始化并执行runCLI
const runCLI = require("../lib/bootstrap");
...
runCLI(process.argv);
javascript 复制代码
// webpack-cli/lib/bootstrap.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
// eslint-disable-next-line @typescript-eslint/no-var-requires
const WebpackCLI = require("./webpack-cli");
// // 创建Webpack CLI的一个新实例,并使用给定的参数运行CLI
const runCLI = async (args) => {
    const cli = new WebpackCLI();
    try {
        await cli.run(args);
    }
    catch (error) {
        cli.logger.error(error);
        process.exit(2);
    }
};
module.exports = runCLI;
javascript 复制代码
// webpack-cli/lib/webpack-cli.js
...
	// 运行 CLI,解析命令行参数并执行相应的命令。
    async run(args, parseOptions) {
    	...
    	    // 如果命令是构建或监听命令,则执行相应的Webpack配置和构建流程。
            if (isBuildCommandUsed || isWatchCommandUsed) {
                await this.makeCommand(isBuildCommandUsed ? buildCommandOptions : watchCommandOptions, async () => {
                	// 加载Webpack配置。
                    this.webpack = await this.loadWebpack();
                    // 获取内置选项。
                    return this.getBuiltInOptions();
                }, async (entries, options) => {
                	// 如果有入口文件,则将其合并到Webpack配置的入口选项中。
                    if (entries.length > 0) {
                        options.entry = [...entries, ...(options.entry || [])];
                    }
                    // 运行Webpack构建/监听流程。
                    await this.runWebpack(options, isWatchCommandUsed);
                });
            }
    	...
    }
    ...
    // 异步执行 Webpack 打包过程的核心方法
    async runWebpack(options, isWatchCommand) {
    	// 初始化Webpack编译器实例。
    	let compiler;
    	...
        // 创建Webpack编译器实例。
        compiler = await this.createCompiler(options, callback);
        ...
    }
    ...
    // 配置和初始化Webpack编译器
    async createCompiler(options, callback) {
    	...
    	// 加载Webpack配置
    	let config = await this.loadConfig(options);
    	// 构建Webpack配置
    	config = await this.buildConfig(config, options);
    	let compiler;
    	try {
      		// 根据传入的配置选项数据,初始化Webpack编译器,如果提供了回调函数,则在编译完成后调用
      		compiler = this.webpack(
        	config.options,
        	callback
          		? (error, stats) => {
              		if (error && this.isValidationError(error)) {
                		this.logger.error(error.message);
                		process.exit(2);
              		}
              		callback(error, stats);
            	}
          		: callback
      		);
      	// @ts-expect-error error type assertion
    	} catch (error) {
      		// 处理初始化Webpack编译器时的错误
      		...
    	}
    	return compiler;
    }
...

从Webpack模块的入口文件出发,首先会检查options参数是否符合webpack配置要求,然后判断options的类型,创建单个或多个编译器。

javascript 复制代码
// webpack/lib/webpack.js
...
/**
 * 根据提供的配置选项创建并运行 Webpack 编译器,可以接受单个配置对象或多个配置对象的数组。
 * 如果提供了回调函数,则会根据配置运行编译器,并在编译完成后执行回调。
 * 如果配置中设置了监听模式(watch),则会进入监听模式。
 */
const webpack = (
	(options, callback) => {
		// 用于创建编译器实例并配置监听模式。
		const create = () => {
			// // 检查配置选项是否符合 Webpack 配置 schema
			if (!asArray(options).every(webpackOptionsSchemaCheck)) {
				// 如果配置不通过 schema 检查,则报告错误
				getValidateSchema()(webpackOptionsSchema, options);
				// 使用 util.deprecate 标记已弃用的功能,并提供错误消息
				util.deprecate(
					() => {},
					"webpack bug: Pre-compiled schema reports error while real schema is happy. This has performance drawbacks.",
					"DEP_WEBPACK_PRE_COMPILED_SCHEMA_INVALID"
				)();
			}
			let compiler;
			let watch = false;
			let watchOptions;
			// 根据 options 是否为数组,创建单个或多个编译器
			if (Array.isArray(options)) {
				/**
				 * 创建一个处理多个Webpack配置的编译器实例
				 * createMultiCompiler()内部还是通过遍历options,创建单个编译器实例合并成数组做处理。
				 */
				compiler = createMultiCompiler(options,options);
				watch = options.some(options => options.watch);
				watchOptions = options.map(options => options.watchOptions || {});
			} else {
				// 创建一个处理单个Webpack配置的编译器实例
				const webpackOptions = options;
				compiler = createCompiler(webpackOptions);
				watch = webpackOptions.watch;
				watchOptions = webpackOptions.watchOptions || {};
			}
			return { compiler, watch, watchOptions };
		};
		if (callback) {...}
	}
);

module.exports = webpack;

在创建单个Compiler对象的时候,webpack会注册配置中的插件。

javascript 复制代码
// webpack/lib/webpack.js
...
/**
 * 创建单个Webpack编译器实例。
 * @param {number} [compilerIndex] index of compiler
 * @returns {Compiler} a compiler
 */
const createCompiler = (rawOptions, compilerIndex) => {
	...
	const compiler = new Compiler(options.context,options);
	// 初始化Node环境插件,设置编译器的运行环境。
	new NodeEnvironmentPlugin({
		infrastructureLogging: options.infrastructureLogging
	}).apply(compiler);
	// 遍历并应用配置中的插件列表。
	if (Array.isArray(options.plugins)) {
		for (const plugin of options.plugins) {
			if (typeof plugin === "function") {
				plugin.call(compiler, compiler);
			} else if (plugin) {
				plugin.apply(compiler);
			}
		}
	}
	...
	// 触发编译器的environment和afterEnvironment钩子,在编译开始前进行环境相关的初始化。
	compiler.hooks.environment.call();
	compiler.hooks.afterEnvironment.call();
	// 这里创建内置插件
	new WebpackOptionsApply().process(options, compiler);
	compiler.hooks.initialize.call();
	return compiler;
};
...

创建完Compiler对象之后,会判断配置选项中是否开启监视模式。

  • 如果是监视模式就调用Compiler对象的watch方法,用监视模式启动构建。
  • 如果不是监视模式就调用Compiler对象的run方法,开始构建整个应用。
javascript 复制代码
// webpack/lib/webpack.js
...
/**
 * 根据提供的配置选项创建并运行 Webpack 编译器,可以接受单个配置对象或多个配置对象的数组。
 * 如果提供了回调函数,则会根据配置运行编译器,并在编译完成后执行回调。
 * 如果配置中设置了监听模式(watch),则会进入监听模式。
 */
const webpack = (
  (options, callback) => {
    // 用于创建编译器实例并配置监听模式。
    const create = () => {... };
    if (callback) {
      try {
      	// 创建Webpack编译器和监听配置
        const { compiler, watch, watchOptions } = create();
        // 如果设置了监听模式,则直接开始监听
        if (watch) {
          compiler.watch(watchOptions, callback);
        } else {
          // 在非监听模式下执行编译,开始构建整个应用,在编译完成后关闭编译器
          compiler.run((err, stats) => {
            compiler.close(err2 => {
              callback(err || err2,stats);
            });
          });
        }
        // 返回webpack编译器实例
        return compiler;
      } catch (err) {
        // 如果在处理过程中出现错误,就延迟调用回调函数,并传递错误
        process.nextTick(() => callback(err));
        // 返回null,处理过程中出现错误
        return null;
      }
    } else {
      // 创建webpack编译器实例,但不执行编译或者监听
      const { compiler, watch } = create();
      if (watch) {
        util.deprecate(
          () => { },
          "A 'callback' argument needs to be provided to the 'webpack(options, callback)' function when the 'watch' option is set. There is no way to handle the 'watch' option without a callback.",
          "DEP_WEBPACK_WATCH_WITHOUT_CALLBACK"
        )();
      }
      return compiler;
    }
  }
);

module.exports = webpack;

Compiler内部先触发beforeRun和run两个钩子,然后调用 this.compile(onCompiled); 开始编译整个项目。

javascript 复制代码
// webpack/lib/Compiler.js
...
class Compiler {
	constructor(context, options = ({})) {...}
	...
	// 执行编译过程
	run(callback) {
		if (this.running) {
			return callback(new ConcurrentCompilationError());
		}
		const finalCallback = (err, stats) => {...}
		const startTime = Date.now();
		this.running = true;
		const onCompiled = (err, _compilation) => {...}
		// 执行编译的函数。
		const run = () => {
			// 调用beforeRun钩子。
			this.hooks.beforeRun.callAsync(this, err => {
				if (err) return finalCallback(err);
				// 调用run钩子。
				this.hooks.run.callAsync(this, err => {
					if (err) return finalCallback(err);
					// 读取记录。
					this.readRecords(err => {
						if (err) return finalCallback(err);
						// 开始编译。
						this.compile(onCompiled);
					});
				});
			});
		};
		// 如果当前处于空闲状态,则先结束缓存的空闲状态,然后开始运行。
		if (this.idle) {
			this.cache.endIdle(err => {
				if (err) return finalCallback(err);
				this.idle = false;
				run();
			});
		} else {
			// 如果不处于空闲状态,直接开始运行。
			run();
		}
	}
	...
}
module.exports = Compiler;

调用 this.compile(onCompiled); 方法内部主要是创建一个Compilation对象,包含本次构建中全部的资源和信息。

javascript 复制代码
// webpack/lib/Compiler.js
...
/**
 * 编译器编译方法:负责执行编译的各个阶段,包括预编译、编译、制作、完成制作、完成编译等步骤。
 */
compile(callback) {
	// 初始化编译参数
	const params = this.newCompilationParams();
	// 在编译前执行自定义钩子,异步调用。
	this.hooks.beforeCompile.callAsync(params, err => {
		// 如果钩子执行出错,直接返回错误回调。
		if (err) return callback(err);
		// 执行编译阶段的钩子。
		this.hooks.compile.call(params);
		// 创建新的编译实例,里面包含这次构建中全部的资源信息
		const compilation = this.newCompilation(params);
		// 获取编译器的日志记录器,用于记录特定于编译器的日志。
		const logger = compilation.getLogger("webpack.Compiler");
		// 记录make阶段的开始时间。
		logger.time("make hook");
		// 执行make阶段的钩子,异步调用。
		this.hooks.make.callAsync(compilation, err => {
			// 记录make阶段的结束时间。
			logger.timeEnd("make hook");
			// 如果钩子执行出错,直接返回错误回调。
			if (err) return callback(err);
			// 记录完成make阶段的开始时间。
			logger.time("finish make hook");
			// 执行完成make阶段的钩子,异步调用。
			this.hooks.finishMake.callAsync(compilation, err => {...});
	});
}
...

创建完Compilation之后,触发make钩子【事件触发机制】,根据入口文件配置找到入口模块,开始递归遍历所有的依赖,形成依赖关系树。

make钩子是在编译过程中生成新的模块、依赖关系、chunk等。这个阶段的代码执行是通过事件触发机制,让外部监听这个make事件的地方开始执行的。如果要知道哪些地方会开始执行,就需要找到哪个地方注册了make事件。

javascript 复制代码
// webpack/lib/Compiler.js
...
// 调用 make 钩子,在编译过程中生成新的模块和 chunk
this.hooks.make.callAsync(compilation, (err) => {
  logger.timeEnd("make hook"); // 结束 "make hook" 计时器
  if (err) return callback(err); // 如果出现错误,调用回调函数并传递错误

  logger.time("finish make hook"); // 开始 "finish make hook" 计时器
  // 调用 finishMake 钩子,在 make 钩子完成后执行的逻辑
  this.hooks.finishMake.callAsync(compilation, (err) => {
    logger.timeEnd("finish make hook"); // 结束 "finish make hook" 计时器
    if (err) return callback(err); // 如果出现错误,调用回调函数并传递错误

    // 使用 process.nextTick 来确保在事件循环的下一轮执行
    process.nextTick(() => {
      logger.time("finish compilation"); // 开始 "finish compilation" 计时器
      // 调用 compilation.finish,完成编译过程,准备生成最终的输出
      compilation.finish((err) => {
        logger.timeEnd("finish compilation"); // 结束 "finish compilation" 计时器
        if (err) return callback(err); // 如果出现错误,调用回调函数并传递错误

        logger.time("seal compilation"); // 开始 "seal compilation" 计时器
        // 调用 compilation.seal,封闭编译结果,使其不可更改
        compilation.seal((err) => {
          logger.timeEnd("seal compilation"); // 结束 "seal compilation" 计时器
          if (err) return callback(err); // 如果出现错误,调用回调函数并传递错误

          logger.time("afterCompile hook"); // 开始 "afterCompile hook" 计时器
          // 调用 afterCompile 钩子,用于执行编译完成后的逻辑
          this.hooks.afterCompile.callAsync(compilation, (err) => {
            logger.timeEnd("afterCompile hook"); // 结束 "afterCompile hook" 计时器
            if (err) return callback(err); // 如果出现错误,调用回调函数并传递错误

            // 如果整个过程成功完成,调用回调函数并传递编译结果
            return callback(null, compilation);
          });
        });
      });
    });
  });
});
...

webpack官方通过自己的Tapable库实现事件注册,可以在VSCode全局搜索 make.tap 来找到事件的注册位置。

javascript 复制代码
// VSCode可能无法搜索node_modules里面的内容,可以在setting.json里添加代码:
{
    "search.exclude": {
        "**/node_modules":false
    },
    "search.useIgnoreFiles":false
}

然后就搜索到了7个插件中都注册了make事件,这些插件都是前面创建Compiler对象的时候创建的【去看createCompiler代码】。

根据内置插件,找到入口文件的处理插件 EntryPlugin ,内部调用了 compilation.addEntry() 传入上下文、入口依赖和选项等参数,开始解析入口文件。

javascript 复制代码
// webpack/lib/EntryPlugin.js
class EntryPlugin {
...
/**
 * 此方法主要是在webpack编译器上应用EntryPlugin插件。通过监听编译和make阶段的钩子来设置入口依赖项和添加入口点到编译。
 */
apply(compiler) {
	// 在编译阶段注册一个钩子,用于设置入口依赖的工厂。
	compiler.hooks.compilation.tap(
		"EntryPlugin",
		(compilation, { normalModuleFactory }) => {
			// 将EntryDependency依赖的工厂设置为normalModuleFactory。
			compilation.dependencyFactories.set(
				EntryDependency,
				normalModuleFactory
			);
		}
	);
	const { entry, options, context } = this;
	// 创建一个入口依赖项。
	const dep = EntryPlugin.createDependency(entry, options);
	// 在make阶段注册一个钩子,用于向编译添加入口点。
	compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
		// 添加入口点到编译,传入上下文、入口依赖和选项。
		compilation.addEntry(context, dep, options, err => {
			// 回调函数处理错误或完成添加。
			callback(err);
		});
	});
}
...
}

找到 addEntry() 方法,可以发现里面通过addModuleTree() ,将入口模块添加到模块依赖列表中。

javascript 复制代码
// webpack/lib/Compilation.js
class Compilation {
	...
	// 向webpack配置中的特定上下文添加一个新的入口项。
	addEntry(context, entry, optionsOrName, callback) {
		...
		// 调用内部方法添加入口项到指定的dependencies中。
		this._addEntryItem(context, entry, "dependencies", options, callback);
	}
	...
	// 添加入口项。
	_addEntryItem(context, entry, target, options, callback) {
		...
		// // 添加模块树,并处理结果。
		this.addModuleTree(
			{
				context,
				dependency: entry,
				contextInfo: entryData.options.layer
					? { issuerLayer: entryData.options.layer }
					: undefined
			},
			(err, module) => {...}
		);
	}
	...
	// 根据传入的依赖信息创建一个新的模块,添加模块树。
	addModuleTree({ context, dependency, contextInfo }, callback) {
		// // 验证 dependency 是否为有效对象
		if (typeof dependency !== "object" || dependency === null || !dependency.constructor) {
			return callback(
				new WebpackError("Parameter 'dependency' must be a Dependency")
			);
		}
		// 从 dependency 中获取构造函数
		const Dep = dependency.constructor;
		// 尝试从工厂中获取对应的模块创建函数
		const moduleFactory = this.dependencyFactories.get(Dep);
		// 如果没有找到对应的工厂,返回错误
		if (!moduleFactory) {
			return callback(
				new WebpackError(
					`No dependency factory available for this dependency type: ${dependency.constructor.name}`
				)
			);
		}
		// 处理模块的创建过程
		this.handleModuleCreation(
			{
				factory: moduleFactory,
				dependencies: [dependency],
				originModule: null,
				contextInfo,
				context
			},
			(err, result) => {...}
		);
	}
}

handleModuleCreation 里调用 _handleModuleBuildAndDependencies, 其内部通过Compiler对象的buildModule来进行模块构建。

javascript 复制代码
// webpack/lib/Compilation.js
...
// 模块构建,处理模块的解析、工厂调用、依赖注入和模块图的更新。
handleModuleCreation(
  {
    factory,
    dependencies,
    originModule,
    contextInfo,
    context,
    recursive = true,
    connectOrigin = recursive,
    checkCycle = !recursive
  },
  callback
) {
  // 获取模块图实例
  const moduleGraph = this.moduleGraph;
  // 根据当前是否启用 profiling 创建对应的模块profile
  const currentProfile = this.profile ? new ModuleProfile() : undefined;
  // 实例化模块的过程,包括解析依赖和执行工厂函数
  this.factorizeModule(
    {
      currentProfile,
      factory,
      dependencies,
      factoryResult: true,
      originModule,
      contextInfo,
      context
    },
    (err, factoryResult) => {
      // 处理工厂结果中的依赖信息
      const applyFactoryResultDependencies = () => {...};
      ...
      // 获取工厂函数返回的模块实例
      const newModule = factoryResult.module;
      ...
      // 添加模块到模块图
      this.addModule(newModule, (err, _module) => {
        if (err) {
          applyFactoryResultDependencies();
          if (!err.module) {
            err.module = _module;
          }
          this.errors.push(err);

          return callback(err);
        }
        // 处理模块的不安全缓存逻辑
        const module = _module;
        if (
          this._unsafeCache &&
          factoryResult.cacheable !== false &&
          module.restoreFromUnsafeCache &&
          this._unsafeCachePredicate(module)
        ) {...
        }
        ...
        // 继续处理模块的构建和依赖
        this._handleModuleBuildAndDependencies(
          originModule,
          module,
          recursive,
          checkCycle,
          callback
        );
      });
    }
  );
}
...
// 处理模块构建及其依赖
_handleModuleBuildAndDependencies(
  originModule, // 原始模块,触发构建的起点
  module, // 当前要处理的模块
  recursive, // 是否递归处理依赖
  checkCycle, // 是否检查循环依赖
  callback // 构建完成后的回调函数
) {
  // 检查在另一个构建过程中是否触发了构建,以避免循环依赖
  let creatingModuleDuringBuildSet = undefined;
  ...
  // 构建模块,buildModule方法中执行具体的Loader,处理特殊资源的加载。
  this.buildModule(module, err => {
    if (creatingModuleDuringBuildSet !== undefined) {
      // 如果在构建过程中添加了模块,构建完成后移除
      creatingModuleDuringBuildSet.delete(module);
    }
    if (err) {
      // 如果构建过程中出现错误,将错误与模块关联并添加到错误列表
      if (!err.module) {
        err.module = module;
      }
      this.errors.push(err);
      return callback(err); // 返回错误
    }
    if (!recursive) {
      // 如果不需要递归处理依赖,直接处理当前模块的依赖
      this.processModuleDependenciesNonRecursive(module);
      callback(null, module); // 回调函数,无错误,返回模块
      return;
    }
    // 为了避免循环依赖导致的死锁,检查是否已经在处理依赖队列中
    if (this.processDependenciesQueue.isProcessing(module)) {
      return callback(null, module); // 如果已经在处理中,直接返回模块
    }
    // 递归处理模块的依赖
    this.processModuleDependencies(module, err => {
      if (err) {
        return callback(err); // 如果处理依赖时出现错误,返回错误
      }
      callback(null, module); // 否则,回调函数,无错误,返回模块
    });
  });
}

然后再回到EntryPlugin类的apply方法里,有一段代码是将 EntryDependency 类与 normalModuleFactory 关联起来。这意味着当遇到 EntryDependency 类型的依赖时,将使用 normalModuleFactory 来创建对应的模块。

javascript 复制代码
compiler.hooks.compilation.tap(
	"EntryPlugin",
	(compilation, { normalModuleFactory }) => {
		compilation.dependencyFactories.set(EntryDependency,normalModuleFactory);
});

normalModuleFactory 主要是负责创建处理 JavaScript 模块的 NormalModule 实例。在 Webpack 的构建过程中,normalModuleFactory 用来生成模块对象,这些对象随后会经过一系列的处理步骤,包括解析、编译、优化等。

normalModuleFactory 里通过createParser() 创建一个新的解析器实例, getParser() 获取一个特定类型的模块的解析器,来解析模块构成抽象语法树AST。

javascript 复制代码
// webpack/lib/NormalModuleFactory.js
...
getParser(type, parserOptions = EMPTY_PARSER_OPTIONS) {
  let cache = this.parserCache.get(type);

  if (cache === undefined) {
    cache = new WeakMap();
    this.parserCache.set(type, cache);
  }

  let parser = cache.get(parserOptions);

  if (parser === undefined) {
    parser = this.createParser(type, parserOptions);
    cache.set(parserOptions, parser);
  }

  return parser;
}

/**
 * @param {string} type type
 * @param {ParserOptions} parserOptions parser options
 * @returns {Parser} parser
 */
createParser(type, parserOptions = {}) {
  parserOptions = mergeGlobalOptions(
    this._globalParserOptions,
    type,
    parserOptions
  );
  const parser = this.hooks.createParser.for(type).call(parserOptions);
  if (!parser) {
    throw new Error(`No parser registered for ${type}`);
  }
  this.hooks.parser.for(type).call(parser, parserOptions);
  return parser;
}
...

根据语法树分析模块是否还有对应的依赖模块,如果有的话就会继续循环构建每个依赖,直到所有的依赖解完成,构建阶段结束。

最后会合并生成需要输出的bundle.js到dist目录。

Webpack热更新

webpack中的模块热替换,就是说我们在程序运行的时候,修改了某个模块内容,如果没有使用模块热替换,就需要刷新整个应用程序来实现更新,并且刷新后页面中的状态信息都会丢失;如果使用模块热替换,就可以只用把变更的模块替换到应用程序里,不用完全刷新整个应用。

在webpack中主要是通过开启 HotModuleReplacementPlugin 这个插件来开启模块热更新。

在HMR运行的时候,通过执行 webpack-dev-server 命令,开启两个服务器 express serversocket serverexpress server 主要负责提供静态资源的服务,打包后的资源直接被浏览器请求和解析;socket server 是一个websocket长连接,主要是监听对应模块发生变化后,生成两个补丁文件,并且推送给浏览器端。

当某个文件或者模块发生变化,webpack通过监听这个文件或者模块对应的唯一hash值的变化,来判断文件是否需要重新编译打包,重新编译之后会再生成文件/模块对应的hash值,作为下次热更新的标识。之后服务端会通过socket server向浏览器端推送变更消息,消息内容主要是两个补丁文件和新的hash值。浏览器拿到两个新的文件后,通过HMR runtime机制,加载这两个文件,并且针对修改的模块进行更新。

Webpack打包优化

1. 提高打包速度

(1)优化Loader(让webpack拥有了加载和解析非js文件的能力):在使用loader时,可以通过配置include、exclude、test属性来匹配文件,通过include、exclude规定哪些匹配应用loader,优化Loader的文件搜索范围

javascript 复制代码
module.exports = {
  module:{
    rules:[{
      // js文件才使用babel
      test:/\.js$/,
      loader:'babel-loader',
      // 只在src文件夹下查找
      include:[resolve('src')],
      // 不会去查找的路径
      exclude:/node_modules/
    }]
  }
}

(2)HappyPack:因为webpack在打包的过程中是单线程的,在执行过程中可能会遇到很多需要耗时间编译的任务,HappyPack可以将Loader的同步执行转换为并行的。

javascript 复制代码
module:{
  loaders:[
    {
      test:/\.js$/,
      include:[resolve('src')],
      exclude:/node_modules/,
      // id后面的内容对应下面
      loader:'happypack/loader?id=happybabel'
    }
  ]
},
plugins:[
  new HappyPack({
    id:'happybabel',
    loaders:['babel-loader?cacheDirectory'],
    // 开启4个线程
    threads:4
  })
]

(3)DllPlugin:DllPlugin可以将特定的类库提前打包然后引入。这种方式可以减少导包类库的次数,只有当类库更新版本才会需要重新打包。

javascript 复制代码
// 打包一个Dll库
module.exports = {
  entry:{
    // 想统一打包的类库
    vendor:['react']
  },
  output:{
    path:path.join(__dirname,'dist'),
    filename:'[name].dll.js',
    library:'[name]-[hash]'
  },
  plugins:[
    new webpack.DllPlugin({
      //name必须和output.library一致
      name:'[name]-[hash]',
      path:path.resolve(__dirname,"./dll/[name].mainfest.json")
    })
  ]
}

// 引入Dll库
module.exports = {
  ...
    plugins:[
    new webpack.DllReferencePlugin({
      context:__dirname,
      // manifest就是之前打包出来的json文件
      manifest:require('./dist/vendor-manifest.json')
    })
    ]
}

(4)Code Splitting:代码分割,把项目中的资源模块按照我们设计的规则打包到不同的bundle中。实现方式有两种:多入口打包;动态导入。

javascript 复制代码
// 多入口打包-webpack.config.js
module.exports = {
  entry: {
    index: './src/index.js',
    main: './src/main.js'
  },
  output: {
    filename: '[name].bundle.js'
  },
  optimization:{
  	splitChunks:{
  		chunks:'all'
  	}
  }
};
// 动态导入-在入口文件里写
const update = () => {
  const hash = window.location.hash || '#posts'
  const mainElement = document.querySelector('.main')
  mainElement.innerHTML = ''
  switch (hash) {
    case '#posts':
      import('./components/posts').then(posts => {
        mainElement.appendChild(posts())
      })
      break
    case '#about':
      import('./components/about').then(about => {
        mainElement.appendChild(about())
      })
      break
    default:
  }
}
window.addEventListener('hashchange', update)
update()

2. 减少webpack打包体积

(1)使用插件压缩代码:CSS代码压缩 CssMinimizerPlugin、HTML代码压缩 HtmlWebpackPlugin、文件大小压缩 ComepressionPlugin 、图片压缩...

javascript 复制代码
const { HtmlWebpackPlugin } = require('html-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: '[name]_[hash].[ext]',
              outputPath: 'images/',
            },
          },
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: {
                progressive: true,
                quality: 65,
              },
              optipng: {
                enabled: false,
              },
              pngquant: {
                quality: '65-90',
                speed: 4,
              },
              gifsicle: {
                interlaced: false,
              },
              webp: {
                quality: 75,
              },
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      // HtmlWebpackPlugin 的配置项
      minify: {
        minifyCSS: false,
        collapseWhitespace: false,
        removeComments: true,
      },
    }),
    new CompressionPlugin({
      test: /\.(css|js)$/,
      threshold: 500,
      minRatio: 0.7,
      algorithm: 'gzip',
    }),
  ],
  optimization: {
    minimize: true,
    minimizer: [
      new CssMinimizerPlugin({
        parallel: true,
      }),
    ],
  },
};

(2)Scope Hoisting:能分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。

javascript 复制代码
module.exports = {
	optimization:{
		concatenateModules:true,  // 尽可能将所有模块合并到一起输出到一个函数中
	}
}

(3)Tree Shaking:可以"摇掉"项目中没有被引用的代码。webpack的Tree-shaking特性在生产环境下会自动开启。在其他环境下,通过如下配置:

javascript 复制代码
module.exports = {
	optimization:{
		usedExports:true,  // 打包结果中指导处外部用到的成员
		minimize:true,  // 压缩打包结果
	}
}

(4)sideEffects:通过配置标识我们的代码是否有副作用(模块执行的时候除了导出成员是否还做了其他的事情),从而决定是否要完整移除没有用到的模块。在生产环境下会自动开启。

javascript 复制代码
module.exports = {
	optimization:{
		sideEffects:true  // 判断模块是否有副作用,是否需要被打包。
	}
}

以上就是我学习Webpack的知识笔记,如有误,请指正!

学习链接
JavaScript模块化七日谈
一文吃透 Webpack核心原理
前端模块化开发那点历史
「前端工程四部曲」模块化的前世今生

相关推荐
学不会•31 分钟前
css数据不固定情况下,循环加不同背景颜色
前端·javascript·html
活宝小娜3 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点3 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow3 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o3 小时前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app
开心工作室_kaic4 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
刚刚好ā4 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
沉默璇年5 小时前
react中useMemo的使用场景
前端·react.js·前端框架
yqcoder5 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
2401_882727576 小时前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架