Webpack 读书笔记


初读《Webpack实战:入门、进阶与调优(第2版)》后记录

何为 Webpack

Webpack 是一个开源的 JaveScript 模块打包工具,其核心的功能是解决模块之间的依赖,把各个模块按照特定的规则和顺序组织在一起,最终合并为一个 JS 文件(有时会有多个)

为什么需要 Webpack

之前工作开发维护的旧框架用的是前端石器时代的 JQuery 框架,没有模块打包的概念,用的是最原始的 <script><link> 手动引入文件的方式,每次版本迭代更新需要手动清除浏览器缓存(Ctrl + F5 强制刷新)或者页面设置不缓存,不管哪个做法都是不合理的而且显得很 stupid。另外,该项目是混合开发项目,经常在打包成 APP 后遇到一些机器不能兼容 ES6 写法而导致程序无法正常运行,恼人的是这样的错误还不容易排查,因为一般发生在用户的 APP 载体需要真机调试。

初读完这本书如获至宝,倒不完全是因为读完之后感觉对 Webpack 的认知更深了,更多是因为有切身经历的参考,更加明白那段前端巨变历史的成因。

说回 Webpack…

前面说到 Webpack 是模块打包工具,模块打包工具(module bundler)的任务就是解决模块间的依赖,使其打包后的结果能运行在浏览器上。它的工作方式主要分为两种:

  • 将存在依赖关系的模块按照特定规则合并为单个JS文件,一次全部加载进页面中。

  • 将存在依赖关系的模块按照特定规则合并为单个JS文件,一次全部加载进页面中。

目前社区中比较流行的模块打包工具有Webpack、Vite、Parcel、Rollup等。

Webpack具备以下几点优势:

  • Webpack 默认支持多种模块标准,包括 AMD、CommonJS 以及最新的 ES6 模块

  • Webpack 有完备的代码分片解决方案。从字面意思去理解,它可以分割打包后的资源,在首屏只加载必要的部分,将不太重要的功能放到后面动态加载。这对于资源体积较大的应用来说尤为重要,可以有效地减小资源体积,提升首页渲染速度

  • Webpack 可以处理各种类型的资源。除了 JavaScript 以外,Webpack 还可以处理样式、模板,甚至图片等,而开发者需要做的仅仅是导入它们,比如可以从 JavaScript 文件导入一个 CSS 或者 PNG

  • Webpack 社区庞大,更新速度快,轮子丰富

安装

官网

需先安装 Node.js

全局安装 Webpack 的好处是 npm 会帮我们绑定一个命令行环境变量,一次安装、处处运行;本地安装 Webpack 则会添加其为项目中的依赖,只能在项目内部使用。

  • 如果选择全局安装,那么在与他人进行项目协作的时候,由于每个人系统中的 Webpack 版本不同,可能会导致输出结果不一致。

  • 部分依赖于 Webpack 的插件会调用项目中 Webpack 的内部模块,这种情况下仍然需要在项目本地安装 Webpack,而如果全局和本地都有,则容易造成混淆。

基于以上两点,我们一般选择在工程内部安装 Webpack

首先创建进入一个目录并初始化 npm,然后在本地安装 webpack,接着安装 webpack-cli(webpack 是核心模块,webpack-cli 则是命令行工具)

mkdir webpack-demo
cd webpack-demo
npm init -y
npm install webpack webpack-cli --save-dev

ls 查看 webpack-demo 可以看到

node_modules/  package.json  package-lock.json
  • node_modules 是用来存放包管理工具下载安装的包的文件夹

  • package.json 是用于工具的配置,存储所有已安装软件包的名称和版本信息

  • package-lock.json 会固化当前安装的每个软件包的版本,当运行 npm install时,npm 会使用这些确切的版本

深入理解 package.json 文件与 package-lock.json 文件

起步

创建以下目录结构、文件和内容(示例摘抄自文档)

webpack-demo
  |- package.json
  |- package-lock.json
+ |- index.html
+ |- index.js
+ |- add-content.js

index.js

import addContent from './add-content.js';
document.write('My first Webpack app.<br />');
addContent();

add-content.js

export default function() {
    document.write('Hello world!');
}

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>起步</title>
  </head>
  <body>
    <!-- 留意下这里的 src -->
    <script src="./dist/main.js"></script>
  </body>
</html>

终端输入打包命令

npx webpack --entry=./index.js --mode=development

可以看到项目新生成 dist/main.js

/*
 * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
 * This devtool is neither made for production nor for readable output files.
 * It uses "eval()" calls to create a separate source file in the browser devtools.
 * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
 * or disable the default devtool with "devtool: false".
 * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
 */
/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({

/***/ "./add-content.js":
/*!************************!*\
  !*** ./add-content.js ***!
  \************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"default\": () => (/* export default binding */ __WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ function __WEBPACK_DEFAULT_EXPORT__() {\r\n    document.write('Hello world!');\r\n}\n\n//# sourceURL=webpack://webpack-demo/./add-content.js?");

/***/ }),

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _add_content_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./add-content.js */ \"./add-content.js\");\n\r\ndocument.write('My first Webpack app.<br />');\r\n(0,_add_content_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])();\n\n//# sourceURL=webpack://webpack-demo/./index.js?");

/***/ })

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/ 	
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
/******/ 		if (cachedModule !== undefined) {
/******/ 			return cachedModule.exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			// no module.id needed
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/ 	
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 	
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/ 	
/************************************************************************/
/******/ 	/* webpack/runtime/define property getters */
/******/ 	(() => {
/******/ 		// define getter functions for harmony exports
/******/ 		__webpack_require__.d = (exports, definition) => {
/******/ 			for(var key in definition) {
/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 				}
/******/ 			}
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
/******/ 	(() => {
/******/ 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/make namespace object */
/******/ 	(() => {
/******/ 		// define __esModule on exports
/******/ 		__webpack_require__.r = (exports) => {
/******/ 			if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ 				Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ 			}
/******/ 			Object.defineProperty(exports, '__esModule', { value: true });
/******/ 		};
/******/ 	})();
/******/ 	
/************************************************************************/
/******/ 	
/******/ 	// startup
/******/ 	// Load entry module and return exports
/******/ 	// This entry module can't be inlined because the eval devtool is used.
/******/ 	var __webpack_exports__ = __webpack_require__("./index.js");
/******/ 	
/******/ })()
;

本地打开 index.html 文件(浏览器地址是这样显示的:file:///E:/WEB/webpack-demo/index.html),会看到页面内容

My first Webpack app.
Hello world!

js 文件成功引入

再次执行打包命令(这里修改了 mode 的值)

npx webpack --entry=./index.js --mode=production

可以看到 dist/main.js 文件内容变化了 ⬇

(()=>{"use strict";document.write("My first Webpack app.<br />"),document.write("Hello world!")})();

可以看见 dist/main.js 内容精简了,再刷新 index.html 页面,网页并没有变化。

上面在终端输入命令的行为就叫做打包。那么,是否每次打包都要如此繁琐的输入参数指令呢?

使用 npm scripts

注意到 package.json,默认内容类似下面

{
  "name": "webpack-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack": "^5.91.0",
    "webpack-cli": "^5.1.4"
  }
}

scripts 是 npm 供的脚本命令功能,在这里我们可以直接使用由模块添加的指令(比如用webpack取代之前的 npx webpack )

编辑 package.json

……
  "scripts": {
    "build": "webpack --entry=./index.js --mode=development",
  },
……

在终端输入

npm run build

可见打包结果与前面一致

使用配置文件

前面用到了 --entry--mode 参数,除此之外,Webpack 拥有非常多的配置项以及相对应的命令行参数,可以通过 npx webpack -h 查看。

当项目需要越来越多的配置时,就要往命令中添加更多的参数,那么到后期维护起来就会相当困难。为了解决这个问题,可以把这些参数改为对象的形式专门放在一个配置文件里,在 Webpack 每次打包的时候读取该配置文件即可。

Webpack 的默认配置文件为 webpack.config.js(也可以使用其他文件名,需要使用命令行参数指定)。

在工程目录下创建 webpack.config.js,并添加如下代码:

webpack-demo
  |- package.json
  |- package-lock.json
  |- index.html
  |- index.js
  |- add-content.js
+ |- webpack.config.js

webpack.config.js

module.exports = {
    entry: './index.js',
    mode: 'development',
}

现在我们可以去掉 package.json 中配置的打包参数了:

……
  "scripts": {
    "build": "webpack"
  },
……

为了验证最终效果,我们再对 add-content.js 的内容稍加修改:

export default function() {
    document.write('I\'m using a config file!');
}

执行 npm run build 命令,Webpack 就会预先读取 webpack.config.js,然后打包。完成之后我们刷新 index.html 进行验证

My first Webpack app.
I'm using a config file!

可见打包生效

webpack-dev-server

归纳前面的操作

编写代码 => 执行 build 命令 -> 刷新页面

这样的效率并不高,还好 Webpack 社区已经为我们提供了一个便捷的本地开发工具 —— webpack-dev-server

安装 webpack-dev-server

npm install webpack-dev-server -D

安装指令中的-D参数是将webpack-dev-server作为工程的devDependencies(开发环境依赖)记录在package.json中。这样做是因为webpack-dev-server仅仅在本地开发时才会用到,在生产环境中并不需要它,所以放在devDependencies中是比较恰当的。

启动 webpack-dev-server

在package.json中添加一个dev指令:

……
  "scripts": {
    "build": "webpack",
    "dev": "webpack-dev-server"
  },
……

配置 webpack-dev-server

编辑webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './index.js',
    mode: 'development',
    plugins: [
        new HtmlWebpackPlugin({
            template: 'index.html'
        }),
    ],
    devServer: {
        static: {
            directory: path.join(__dirname, 'dist'),
        },
        port: 9000
    },
}

devServer 对象是专门用来放 webpack-dev-server 配置的。webpack-dev-server 可以看作一个服务者,它的主要工作就是接收浏览器的请求,然后将资源返回。当服务启动时,它会先让 Webpack 进行模块打包并将资源准备好(在示例中就是 dist/main.js)。当 webpack-dev-server 接收到浏览器的资源请求时,它会首先进行 URL 地址校验。如果该地址是资源服务地址(上面配置的 publicPath),webpack-dev-server 就会从 Webpack 的打包结果中寻找该资源并返回给浏览器。反之,如果请求地址不属于资源服务地址,则直接读取硬盘中的源文件并将其返回。

这里要动手实践一下,因为版本不同有些地方用法也改了,以上示例与原文示例是有偏差的,应当参照官网写法。另外这里用了 HtmlWebpackPlugin 插件还修改了一些代码以在浏览器能实时预览。

终端执行 npm run dev,可以发现开启 9000 端口

……
<i> [webpack-dev-server] Loopback: http://localhost:9000/
……

浏览器打开 http://localhost:9000/,再修改代码保存可以看到浏览器会自动更新。

模块打包

CommonJS

导出

// 写法1
module.exports = {
    name: 'calculater',
    add: function(a, b) {
        return a + b;
    }
};

// 写法2
exports.name = 'calculater';
exports.add = function(a, b) {
    return a + b;
};

注意

  • ❌不要混用两种写法
exports.add = function(a, b) {
    return a + b;
};
module.exports = {
    name: 'calculater'
};

// 最后导出的只有name
  • ❌不要直接给 exports 赋值
exports = {
    name: 'calculater'
};

// 由于对exports进行了赋值操作,使其指向了新的对象,而module.exports却仍然指向原来的空对象,因此name属性并不会被导出

导入

在 CommonJS 中使用 require 语法进行模块导入

当我们使用 require 导入一个模块时会有两种情况:

  • 该模块未曾被加载过。这时会首先执行该模块,然后获取到该模块最终导出的内容。

  • 该模块已经被加载过。这时该模块的代码不会再次执行,而是直接获取该模块上一次导出的内容。

ES6 Module

导出

命名导出

// 写法1
export const name = 'calculator';
export const add = function(a, b) { return a + b; };

// 写法2
const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add };

注意:使用命名导出时,可以通过 as 关键字对变量重命名

const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add as getSum }; // 在导入时即为 name 和 getSum

默认导出

模块的默认导出只能有一个

export default {
    name: 'calculator',
    add: function(a, b) {
        return a + b;
    }
};

// 下面如果在同一个文件导出会报错
export default {
    otherInfo: ''
};

导入

// 具名导出的导入
import { name, add as calculateSum } from './calculator.js';

// 默认导出的导入
import myCalculator from './calculator.js';

// 两种导入方式混合
import React, { Component } from 'react';   // 这里的React必须写在大括号前面,不能颠倒顺序,否则会提示语法错误

复合写法

导入之后立即导出,只支持具名导出

export { name, add } from './calculator.js';

CommonJS 与 ES6 Module 的区别

模块 模块依赖 值获取 循环依赖
CommonJS 动态,建立在运行阶段 “静态”,值复制 空对象
ES6 Module 静态,建立在编译阶段 动态(映射) undefined

其他类型模块

  • 非模块化文件

    像 script 标签引入的其他文件,直接引入即可,如

import './jquery.min.js';
  • AMD

AMD(Asynchronous Module Definition,异步模块定义)是由 JavaScript 社区提出的专注于支持浏览器端模块化的标准

在 AMD 中使用 define 函数来定义模块,它可以接收3个参数。第1个参数是当前模块的id,相当于模块名;第2个参数是当前模块的依赖;第3个参数用来描述模块的导出值,可以是函数或对象。如果是函数则导出的是函数的返回值;如果是对象则直接导出对象本身

define('getSum', ['calculator'], function(math) {
    return function(a, b) {
        console.log('sum: ' + calculator.add(a, b));
    }
});

AMD 使用 require 函数来加载模块,第1个参数指定了加载的模块,第2个参数是当加载完成后执行的回调函数

require(['getSum'], function(getSum) {
    getSum(2, 3);
});
  • UMD

严格来说,UMD 并不是一种模块标准,而是一组模块形式的集合。UMD 的全称是 Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是 CommonJS、AMD,还是非模块化的环境(当时 ES6 Module 还未被提出)

  • npm 模块

包管理工具:npm、yarn、pnpm

在使用时,加载一个npm模块的方式很简单,只需要引入包的名字即可。

// index.js
import _ from 'lodash';

除了直接加载模块以外,我们也可以通过 <package_name>/<path> 的形式单独加载模块内部的某个JS文件。如:

import all from 'lodash/fp/all.js';

这样,Webpack最终只会打包 node_modules/lodash/fp/all.js 这个文件,而不会打包全部的 lodash 库,进而减小打包资源的体积。

打包原理

下面代码摘自上文

/*
 * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
 * This devtool is neither made for production nor for readable output files.
 * It uses "eval()" calls to create a separate source file in the browser devtools.
 * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
 * or disable the default devtool with "devtool: false".
 * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
 */
/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({

/***/ "./add-content.js":
/*!************************!*\
  !*** ./add-content.js ***!
  \************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"default\": () => (/* export default binding */ __WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ function __WEBPACK_DEFAULT_EXPORT__() {\r\n    document.write('Hello world!');\r\n}\n\n//# sourceURL=webpack://webpack-demo/./add-content.js?");

/***/ }),

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _add_content_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./add-content.js */ \"./add-content.js\");\n\r\ndocument.write('My first Webpack app.<br />');\r\n(0,_add_content_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])();\n\n//# sourceURL=webpack://webpack-demo/./index.js?");

/***/ })

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/ 	
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
/******/ 		if (cachedModule !== undefined) {
/******/ 			return cachedModule.exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			// no module.id needed
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/ 	
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 	
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/ 	
/************************************************************************/
/******/ 	/* webpack/runtime/define property getters */
/******/ 	(() => {
/******/ 		// define getter functions for harmony exports
/******/ 		__webpack_require__.d = (exports, definition) => {
/******/ 			for(var key in definition) {
/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 				}
/******/ 			}
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
/******/ 	(() => {
/******/ 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/make namespace object */
/******/ 	(() => {
/******/ 		// define __esModule on exports
/******/ 		__webpack_require__.r = (exports) => {
/******/ 			if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ 				Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ 			}
/******/ 			Object.defineProperty(exports, '__esModule', { value: true });
/******/ 		};
/******/ 	})();
/******/ 	
/************************************************************************/
/******/ 	
/******/ 	// startup
/******/ 	// Load entry module and return exports
/******/ 	// This entry module can't be inlined because the eval devtool is used.
/******/ 	var __webpack_exports__ = __webpack_require__("./index.js");
/******/ 	
/******/ })()
;

上面的 bundle(先理解为打包结果,后面再解释)分为以下几个部分:

  • 最外层匿名函数。它用来包裹整个 bundle,并构成自身的作用域

  • __webpack_modules__ 对象。工程中所有产生了依赖关系的模块都会以 key(文件路径)-value(文件内容) 的形式放在这里,它包含了打包后的所有模块

  • __webpack_module_cache__ 对象。每个模块只在第一次被加载的时候执行,之后其导出值就被存储在这个对象里面,当再次被加载的时候 Webpack 会直接从这里取值,而不会重新执行该模块

  • __webpack_require__ 函数。对模块加载的实现,接收一个参数 moduleId(文件路径)

bundle 在浏览器中的执行过程:

  1. 在最外层匿名函数中初始化浏览器执行环境,包括定义 __webpack_module_cache__ 对象、__webpack_require__ 函数等,为模块的加载和执行做一些准备工作

  2. 加载入口模块。每个 bundle 都有且只有一个入口模块,在上面示例中,index.js 是入口模块,在浏览器中会从他开始执行

  3. 执行模块代码。在 __webpack_require__ 中判断即将加载的模块是否存在于 __webpack_module_cache__ 中。如果存在则返回缓存模块的 exports 对象,否则进行下一步

  4. 新建一个模块 module 放入缓存对象 __webpack_module_cache__,执行文件路径对应的模块函数,执行完模块后返回该模块 exports 对象(这里其实是一个递归过程,以上面为例,假设现在执行权在 index.js,index.js ‘require’ add-content.js 即把执行权交由 add-content.js,这时对于 add-content.js 又重新执行第3步,由于第一次引用并不存在于缓存模块,因此接着执行第4步,add-content.js 没有 require 文件,所以返回该模块 exports 对象交出执行权)

  5. 所有依赖的模块都执行完毕,说明递归调用完成,整个 bundle 运行结束

Webpack为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序与模块加载的顺序是完全一致的,这也是Webpack模块打包的奥秘


文章作者: April-cl
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 April-cl !
  目录