初读《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 在浏览器中的执行过程:
在最外层匿名函数中初始化浏览器执行环境,包括定义
__webpack_module_cache__
对象、__webpack_require__
函数等,为模块的加载和执行做一些准备工作加载入口模块。每个 bundle 都有且只有一个入口模块,在上面示例中,index.js 是入口模块,在浏览器中会从他开始执行
执行模块代码。在
__webpack_require__
中判断即将加载的模块是否存在于__webpack_module_cache__
中。如果存在则返回缓存模块的 exports 对象,否则进行下一步新建一个模块 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 对象交出执行权)所有依赖的模块都执行完毕,说明递归调用完成,整个 bundle 运行结束
Webpack为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序与模块加载的顺序是完全一致的,这也是Webpack模块打包的奥秘