其他章节请看:

webpack 快速入门 系列

初步认识 webpack

webpack 是一种构建工具

webpack 是构建工具中的一种。

所谓​构建​,就是将资源转成浏览器可以识别的。比如我们用 less、es6 写代码,浏览器不能识别 less,也不支持 es6 的某些语法,这时我们可以通过构建工具将源码转成浏览器可以识别的 css 和 js。

webpack 是一种模块化解决方案

以前,前端只需要写几个html、css、js就能完成工作,现在前端做的项目更加复杂,在性能、体验、开发效率等其他方面,都对我们前端提出了更高的要求。

为了能按质按量的完成老板交代的任务,我们只能站在巨人的肩膀上,也就是引入第三方​模块​(或称为库、框架、包),然后快速组装我们的项目。

于是这就出现了一个项目依赖多个模块的场景,只有这些模块能相互通信,十分融洽的在一起,我们才能集中于一处发力把项目做好。

问题在于这些模块不能很好的相处。如何理解?我们可以简化上面的场景:现在我们有三个模块,moduleA 要使用 moduleB,moduleB 要使用 moduleC。如果需要我们自己维护这三个模块之间的依赖关系,可能就是有一点点麻烦;如果要维护数十个、上百个模块之间的依赖关系呢,可能就很困难了。

于是就出现了各种模块化解决方案。有人曾说 jQuery 之后前端最伟大的发明就是 requirejs,它是一个模块化开发的库;而 webpack 就是一种优秀的模块化解决方案。

webpack 官方定义

webpack 是一个现代 ​JavaScript​ 应用程序的静态​模块​打包工具 —— 官方定义

模块才是 webpack 的核心,所以下文先谈谈模块,再分析 webpack 模块化解决方案的原理。

浅谈模块

早期 js 是没有模块的概念,都是全局作用域,我们可能会这么写代码:

// a.js
var name = 'ph';
var age = '18';

// b.js
var name = 'lj';
var age = '21';

如果 html 页面同时引入 a.js 和 b.js,变量 name 和 age 就会相互覆盖。

为了避免覆盖,我们使用命名空间,可能会这么写:

// a.js
var nameSpaceA = {
name: 'ph',
age: '18'
}

// b.js
var nameSpaceB = {
name: 'lj',
age: '21'
}

虽然不会相互覆盖,但模块内部的变量没有得到保护,a 模块仍然可以更改 b 模块的变量。于是我们使用函数作用域:

// a.js
var nameSpaceA = (function(){
var name = 'ph';
var age = '18';
return {
name: name,
age: age,
}
}())

// b.js
var nameSpaceB = (function(){
var name = 'lj';
var age = '21';
return {
name: name,
age: age,
}
}())

这里使用了函数作用域、立即执行函数和命名空间,这就是早期模块的实现方式。更通俗的做法,例如 jQuery 会这么做:

// a.js
(function(window){
var name = 'ph';
var age = '18';
window.nameSpaceA = {
name: name,
age: age,
}
}(window))

之后又出现了各种模块的规范,比如 AMD,代表实现是 requirejs、CommonJS,它的流行得益于 Node 采用了这种方式等等。

终于 es6 带着官方的模块语法(import和export)来了。

模块化

模块化就是将复杂的系统拆分到不同的模块来编写。带来的好处有:

  • 重用。将一些通用的功能提取出来作为模块,需要使用该功能的地方只需要通过特定方式引入即可。
  • 解耦。将一个1万行的文件(模块)分解成10个1千行的文件,模块之间通过暴露的接口进行通信。
  • 作用域封装。模块之间不会相互影响。比如2个模块都有变量count,变量count不会被对方模块影响。

webpack 模块化解决方案的原理

下面我们通过一个项目,从代码层面上看一下 webpack 模块化解决方案的原理。

首先初始化项目,并安装依赖包。

// 创建项目
> mkdir webpack-example1
// 进入项目目录。有的控制台可能是: cd webpack-example1
> cd .\webpack-example1\
// 使用 npm 初始化项目(会自动生成 package.json)
> npm init -y
// 安装依赖包。虽然现在有 webpack 5,但笔者使用的是 webpack 4
// 因为有些构建功能所需要的 npm 包暂时不支持 webpack 5。
> npm i -D webpack@4
// 不安装 webpack-cli,运行时会报错,会提示需要安装 webpack-cli
> npm i -D webpack-cli@3

接着在 webpack-example1/src 文件夹下创建三个模块,模块之间的关系是 index 依赖 b,b 依赖 c,内容如下:

// index.js
import './b.js'
console.log('moduleA')

// b.js
import './c.js'
console.log('moduleB')

// c.js
console.log('moduleC')

执行 npx webpack,会将我们的脚本 src/index.js 作为入口起点,然后会生成 dist/main.js:

// webpack 默认是生产模式,这里通过参数指定为开发模式
webpack-example1> npx webpack --mode development
Hash: cb88f1c065314d7a6a2c
Version: webpack 4.46.0
Time: 73ms
Built at: 2021-05-10 4:06:03 ├F10: PM┤
Asset Size Chunks Chunk Names
main.js 4.81 KiB main [emitted] main
Entrypoint main = main.js
[./src/b.js] 39 bytes {main} [built]
[./src/c.js] 22 bytes {main} [built]
[./src/index.js] 39 bytes {main} [built]

​Tip​:Node 8.2/npm 5.2.0 以上版本提供的 npx 命令,可以运行 webpack 二进制文件(即 ./node_modules/.bin/webpack)

webpack-example1> .\node_modules\.bin\webpack
// 等于
webpack-example1> npx webpack

生成的 dist/main.js 就是打包后的文件(现在无需详细的看 main.js 的内容):

/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/ }
/******/ };
/******/
/******/ // define __esModule on exports
/******/ __webpack_require__.r = function(exports) {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/
/******/ // create a fake namespace object
/******/ // mode & 1: value is a module id, require it
/******/ // mode & 2: merge all properties of value into the ns
/******/ // mode & 4: return value when already ns object
/******/ // mode & 8|1: behave like require
/******/ __webpack_require__.t = function(value, mode) {
/******/ if(mode & 1) value = __webpack_require__(value);
/******/ if(mode & 8) return value;
/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/ var ns = Object.create(null);
/******/ __webpack_require__.r(ns);
/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/ return ns;
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = "./src/index.js");
/******/ })
/************************************************************************/
/******/ ({

/***/ "./src/b.js":
/*!******************!*\
!*** ./src/b.js ***!
\******************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./c.js */ \"./src/c.js\");\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_c_js__WEBPACK_IMPORTED_MODULE_0__);\n\r\nconsole.log('moduleB')\n\n//# sourceURL=webpack:///./src/b.js?");

/***/ }),

/***/ "./src/c.js":
/*!******************!*\
!*** ./src/c.js ***!
\******************/
/*! no static exports found */
/***/ (function(module, exports) {

eval("console.log('moduleC')\n\n//# sourceURL=webpack:///./src/c.js?");

/***/ }),

/***/ "./src/index.js":
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b.js */ \"./src/b.js\");\n\r\nconsole.log('moduleA')\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ })

/******/ });

只需要知道 main.js 与我们的源码是​等价​的。我们可以通过 node 运行 main.js 验证这个结论:

> node dist/main.js
moduleC
moduleB
moduleA

输出了三句文案。

​Tip​:你也可以创建一个 html 页面,通过 src 引用 dist/main.js,然后在浏览器的控制台下验证,输出内容应该也是这三句文案。

接着我们来看一下 webpack 模块化解决方案的原理。在此之前我们先优化一下 main.js,核心代码如下:

(function(modules){
// 模块缓存
var installedModules = {};
// 定义的 require() 方法,用于加载模块
// 与 nodejs 中的 require() 类似
function __webpack_require__(moduleId) {
// 如果缓存中有该模块,直接返回
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 创建一个新的模块,并放入缓存
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};

// 执行模块函数
// 并将 __webpack_require__ 作为参数传入模块,模块就能调用其他模块
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

// 标记此模块已经被加载
module.l = true;

// 返回模块的 exports
return module.exports;
}
...
// 加载入口模块
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
// b 模块
"./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./c.js */ \"./src/c.js\");\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_c_js__WEBPACK_IMPORTED_MODULE_0__);\n\r\nconsole.log('moduleB')\n\n//# sourceURL=webpack:///./src/b.js?");
}),
// c 模块
"./src/c.js": (function(module, exports) {
eval("console.log('moduleC')\n\n//# sourceURL=webpack:///./src/c.js?");
}),
// index 模块
"./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b.js */ \"./src/b.js\");\n\r\nconsole.log('moduleA')\n\n//# sourceURL=webpack:///./src/index.js?");
})
});

很显然,main.js 是一个立即执行函数。立即执行函数的实参是一个对象,里面包含了所有的模块,key 可以理解成模块名,value 则是准备就绪的模块。如果模块还需要引入其他模块,比如 index.js 依赖于 b.js,则会有形参 ​webpack_require。

现在我们大致理解了 webpack 模块化解决方案的原理:

  1. 根据入口文件分析所有依赖的模块,组装好,封装到一个对象中
  2. 将封装好的对象作为参数传给匿名函数执行
  3. 定义加载模块的方法(​webpack_require)
  4. 加载并执行入口模块(即入口文件)
  5. 依次加载执行依赖的其他模块

​Tip​:webpack 又被称为打包神器,笔者认为打包就是将多个模块整成一个;你也可以赋予打包其他含义,比如构建。

核心概念

webpack 中的核心概念有:

  • entry。指定 webpack 的入口,可以指定单入口或多入口
  • output。打包后输出的相关配置,例如指定输出目录等
  • mode。开发模式或生产模式
  • loader
  • plugin

前3个比较简单,loader 和 plugin 单独介绍

entry、output 和 mode 放在 loader 中一起介绍。

loader

根据 webpack 官方定义,webpack 在没有特殊配置的情况下,只识别 javascript。但我们的前端除了 javascript,还有 css、图片等其他资源。所以 webpack 提供了 loader 帮我们解决这个问题。

loader 是文件加载器,用于对模块的源代码进行转换,实现的是文件的转义和编译。例如需要将 es6 转成 es5,或者需要在 javascript 中引入 css 文件,就需要使用它。可以将它看作成​翻译官。

下面我们就使用 loader 处理 css 文件。

首先我们得创建 webpack 配置文件(webpack-example1/webpack.config.js),这样我们可以通过配置指定 loader、插件(plugin)等其他功能,更加灵活:

const path = require('path');

module.exports = {
// 给 webpack 指定入口
entry: './src/index.js',
// 输出
output: {
// 文件名
filename: 'main.js',
// 指定输出的路径。即当前文件所处目录的 dist 文件夹
path: path.resolve(__dirname, 'dist')
},
// loader 放这里
module: {
rules: [
{
// 匹配所有 .css 结尾的文件
test: /\.css$/i,
// 先经过 css-loader 处理,会将 css 文件翻译成 webpack 能识别的
// 接着用 style-loader 处理,也就是将 css 注入 DOM。
use: ["style-loader", "css-loader"]
},
]
},
// 指定为开发模式。webpack 提供了开发模式和生产模式
// 如果不指定 mode,打包时会在控制台提示缺省 mode,并默认指定为生产模式
mode: 'development'
};

​Tip​:配置文件参考 webpack v4 使用一个配置文件、css-loader

安装相关依赖包:

// 特意指定版本,否则可能由于不兼容而安装失败
> npm i -D css-loader@5 style-loader@2

在 src 下创建 a.css 和 index.html:

// a.css
body{color:red;}

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src='../dist/main.js'></script>
</head>
<body>
<p>我是红色吗</p>
</body>
</html>

设置一个运行 webpack 的快捷方式,需要修改 package.json 文件,在 npm scripts 中添加一个 npm 命令:

{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
// 新增
"build": "webpack"
},
}

运行 webpack 重新打包:

// 自定义命令通过”npm run + 命令“即可运行
> npm run build

最后通过浏览器打开 index.html,就可以看到页面有红色文字”我是红色吗“。

可能你会疑惑:为什么要在 index.js 中引入 a.css?其实你通过 c.js 引入 a.css 也是相同效果。

上文我们分析 webpack 原理时,知道 webpack 首先从入口文件开始,分析所有依赖的模块,最后打包生成一个文件,生成的这个文件与我们的源码是等价的。所以 a.css 必须要在依赖模块中,否则最终生成的这个文件就不会包含 a.css。

换句话说,如果我们的资源需要被 webpack 打包处理,那么该资源就得出现在依赖中。

​Tip​:webpack 中一切皆模块。webpack 除了能导入 js 文件,也能把 css、图片等其他资源都当作模块处理,只是需要相应的 loader 翻译一下即可。

plugin

loader 用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。包括:打包优化,资源管理,注入环境变量。

插件(plugin)可以帮助用户直接触及到编译过程。plugin 强调一个事件监听的能力,能在 webpack 内部监听一些事件,并且能改变一些文件打包后输出的结果。

目前我们需要自己创建一个 html 页面,然后引用打包后的资源,感觉不是很方便,于是我们可以使用 html-webpack-plugin 这个包通过 plugin 简化这一过程。

首先安装依赖包 npm i -D html-webpack-plugin@4

接着给 webpack.config.js 增加两处代码:

// 增加 +
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
// +
plugins: [
new HtmlWebpackPlugin()
]
};

再次打包,会发现 build 文件夹下多出了一个文件(index.html),内容如下:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Webpack App</title>
<meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body>
<script src="main.js"></script></body>
</html>

该文件自动引入打包后的资源(main.js)。浏览器访问这个页面(build/index.html),发现控制台正常输出,但页面是空白的。

如果我们需要在这个 html 页面中增加一些内容,比如一句话,可以配置一个模板。

修改 webpack.config.js,指定模板为 src/index.html:

plugins: [
new HtmlWebpackPlugin({
// 指定模板
template: 'src/index.html'
})
],

修改模板(src/index.html)内容:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=`, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<p>请查看控制台</p>
</body>
</html>

重新打包后:

> npm run build

> build
> webpack
// 打包会生成一个hash。以后会使用到。
Hash: 0751d9e63f9e32eac13d
// webpack 的版本是 4.46.0
Version: webpack 4.46.0
// 构建所花费的时间
Time: 396ms
Built at: 2021-05-11 7:56:19 ├F10: PM┤
// 下面3行4列是一个表格
// Asset,打包输出的资源(index.html 和 main.js)
// Size,输出资源。 main.js 的大小是 17.3Kb
// Chunks,main [发射]
// Chunk Names,main
Asset Size Chunks Chunk Names
index.html 307 bytes [emitted]
main.js 17.3 KiB main [emitted] main
Entrypoint main = main.js
[./node_modules/css-loader/dist/cjs.js!./src/a.css] 314 bytes {main} [built]
[./src/a.css] 322 bytes {main} [built]
[./src/b.js] 58 bytes {main} [built]
[./src/c.js] 22 bytes {main} [built]
[./src/index.js] 68 bytes {main} [built]
+ 2 hidden modules
Child HtmlWebpackCompiler:
1 asset
Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
[./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html] 560 bytes {HtmlWebpackPlugin_0} [built]

生成的 html 文件(dist/index.html)内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=`, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<p>请查看控制台</p>
<script src="main.js"></script></body>
</html>

这样,重新生成的 html 页面就以我们的文件为模板,并自动引入打包后的资源。

webpack-dev-server

webpack-dev-server 提供了一个简单的 web server,方便我们调试。

在 loader 这个示例上继续做如下修改:

// 安装依赖包
> npm i -D webpack-dev-server@3

// 修改配置文件 webpack.config.js
module.exports = {
devServer: {
// 默认打开浏览器
open: true,
// 告诉服务器从哪个目录中提供内容
// serve(服务) 所有来自项目根路径下 dist/ 目录的文件
contentBase: path.join(__dirname, 'dist'),
// 开启压缩 gzip
compress: true,
// 端口号
port: 9000,
},
};

// 修改 package.json,增加自定义命令
"scripts": {
// +
"dev": "webpack-dev-server"
},

执行 npm run dev 就会默认打开浏览器,页面就是 src/index.html。

启动 devServer 不会打包输出产物,也就是不会生成 dist 目录,而是存在于内存中。

修改 src 中的 html、js,保存后浏览器会​自动刷新​并显示最新效果,十分方便。

​注​:之前运行 npm run dev 报错,后来将 webpack-cli 从版本4改成版本3,然后就能正常启动服务了。

学习建议

不要执着于 API 和命令 —— API 当然也是需要看的哈。

因为 webpack 迭代速度比较快,api 也会相应的更新,以后 webpack 配置也会更简单好用。

其他章节请看:

webpack 快速入门 系列

作者:彭加李

但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。