由来
ES Modules 本身的兼容问题
JavaScript 的模块化给我们解决了很多问题,特别是标准规范提出的 ES Modules。
但是 ES Modules 本身就存在兼容性问题。我们没办法改变用户的浏览器使用情况,所以仍然需要解决模块化本身的兼容问题。
模块文件过多,网络请求频繁
另一个问题就是使用模块化开发会产生很多模块文件,通过网络请求获取文件会非常频繁,会影响应用的工作效率。
所有前端资源都需要模块化
第三个问题就是前端开发中,不仅仅只有 JS 文件需要模块化。随着应用的复杂,html 和 css 等资源也需要模块化。
但是它们的作用和用法和 Js 是完全不同的。
模块化设想
整个开发过程中,模块化是必要的。
我们需要借助原有的一些优秀方案来改进并实现模块化。让我们在开发阶段能够享受模块化带来的优势,而又不影响生产阶段所带来的影响。
我们可以先对这种工具提出一些设想:
新特性代码编译功能
源代码转换为生产代码,比如 ES6 转换为 ES5。这样就不影响生产环境的兼容问题。
模块化 JavaScript 打包功能
将散落的多个模块文件打包成一个完整的 js 文件。这样解决了频繁发生网络请求的问题。
支持不同类型的资源模块
支持不同种类的文件资源类型。比如样式文件、图片文件和字体文件。这样可以把它们都当作模块来使用。
这个设想是整个前端应用的模块化方案,而不再仅仅是 JavaScript 本身的模块化方案。
前面两个设想,可以通过构建系统来解决,但是第三个设想,是构建系统无法完成的,所以就有了模块打包工具。
概要
前端打包工具帮我们解决了上面的问题。
目前最主流的三个前端打包工具是 webpack、rollup 和 parcel。
下面拿最主流的 webpack 举例。
Webpack 自身具有模块打包器(Modules Bundler),它可以将模块化的 JavaScript 打包成一个文件的功能,还可以在打包过程中使用模块加载器(Modules Loader)来将有环境兼容问题的代码进行编译。
webpack 还具有代码拆分(Code Splitting)的能力,将所有的代码按照我们的需要去打包。这样就解决了将所有的模块打包到一起,产生的 js 文件体积过大的问题。我们可以将应用初次运行所必须的那些模块打包到一起,其他的那些模块单独存放。当程序运行过程中需要某个模块,再去异步加载该模块,从而实现增量(渐进式)加载。这样就无需担心文件太碎和文件太大这两个极端问题。
webpack 允许在 Js 中载入任意类型的文件。比如引入一个 css 文件,最终它会以 link 标签或 style 标签的形式去工作。
其他的模块打包工具也具有类似的功能。总之,所有的模块打包工具都以模块化为目标。这里所指的模块化,是比 JavaScript 模块化更为宏观的前端整体模块化。
Webpack
webpack 的想法比较先进、使用过程比较繁琐、文档比较晦涩难懂。最开始对开发者并不友好。
随着版本的不断跌进,webpack 越来越受欢迎,几乎可以覆盖所有的业务场景。
快速上手
下面通过一个简单的 Demo 来看一下 Webpack 的使用。
首先有一个如下的目录结构:
.
|____index.html
|____src
| |____index.js
| |____heading.js
index.html 内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Webpack - 快速上手</title>
</head>
<body>
<script type="module" src="./src/index.js"></script>
</body>
</html>
index.js 内容:
import createHeading from "./heading.js";
const heading = createHeading();
document.body.append(heading);
heading.js 内容:
export default () => {
const element = document.createElement("h2");
element.textContent = "Hello world";
element.addEventListener("click", () => {
alert("Hello webpack");
});
return element;
};
因为 module 受 CORS 影响,所以没办法直接在浏览器中打开 index.html。
需要通过服务来启动它,这里使用 serve,如果没有的话可以安装一下,为了方便使用,可以安装到全局。
npm i -g serve
然后在项目根目录启动服务。
serve .
在浏览器中可以正常运行,但是它会请求两个 js 文件,并且无法在不支持 es6 语法的环境下运行。
下面通过 webpack 来对应用打包。
初始化项目。
npm init -y
安装 webpack 和 webpack-cli。
npm i -D webpack webpack-cli
安装完成后尝试检查版本。
npx webpack --version
此时可以得到一个版本,说明安装成功。
4.43.0
下一步就是运行 webpack。
npx webpack
可以将 webpack 放到 npm scripts 中。
{
"scripts": {
"build": "webpack"
}
}
这样也可以通过 npm run build
的方式运行 webpack。
webpack4.x 支持零配置打包。在没有任何配置的情况下,默认以 src/index.js 为入口,寻找到所有依赖的 js 文件,打包成一个 mian.js 文件,并将它放置到项目根目录下的 dist 文件夹下。
修改 index.html 的 js 导入路径。
<script src="dist/main.js"></script>
一切运行正常,这样就是一个最简单的 webpack 应用。
配置文件
当项目的目录结构和约定不符合时,就需要自定义配置。
假设现在需求发生了变化。src 下的 index.js 重命名为 main.js 并作为入口文件。输出的位置为 output 目录下的 bundle.js 文件。那么就需要来编写配置。
webpack 的配置文件默认是项目根目录下的 webpack.config.js。
这个文件遵循 node.js 的 Commonjs 规范,默认导出一个对象。
入口文件的配置通过 entry 属性来设置,是一个字符串。
输出文件通过 output 属性来设置,是一个对象,对象又具有两个属性,filename 表示最终的文件名,path 表示输出路径。
const path = require("path");
module.exports = {
entry: "./src/main.js",
output: {
filename: "bundle.js",
// path 要求必须是绝对路径,不能是相对路径
path: path.join(__dirname, "output"),
},
};
这样就可以在 output 中生成 bundle.js。
打包结果运行原理
为了分析打包后的结果,在配置文件中将 mode 设置为 none,这样就是以最原始的方式打包代码。
// webpack.config.js
module.exports = {
mode: "none",
// other code
};
再次打包,得到 bundle.js 文件。
分析 bundle.js。
(function (modules) {
/** code... */
})([
function (module, __webpack_exports__, __webpack_require__) {
/** code... */
},
function (module, __webpack_exports__, __webpack_require__) {
/** code... */
},
]);
最外层是一个自动执行函数,接受一个 modules 参数,传入一个数组参数。
数组参数的每个成员都是一个函数,代表一个模块。也就是说我们编写的每个模块文件最终都会被包裹成这样一个函数,这样可以实现模块的私有作用域。
下面打开工作入口函数。
最开始是一个对象,用于缓存加载后的模块。
// The module cache
var installedModules = {};
接下来是定义了一个函数,用于加载模块。
// The require function
function __webpack_require__(moduleId) {
// ...
}
再之后会向 __webpack_require__ 身上挂载一些功能函数。
// expose the modules object (__webpack_modules__)
__webpack_require__.m = modules;
// expose the module cache
__webpack_require__.c = installedModules;
// ...
最后会返回一个函数调用。
// Load entry module and return exports
return __webpack_require__((__webpack_require__.s = 0));
传入的参数 0 就是模块的 ID,就是工作入口函数数组参数中下标为 0 的模块,也就是入口模块。
资源模块打包
webpack 并不仅仅是 JavaScript 打包工具,它是整个前端的打包工具。
所以除了 JavaScript 以外,还可以打包其他类型资源文件。
下面尝试一下 css 文件的打包。
在 src 下创建 main.css 文件。
body {
margin: 0 auto;
padding: 0 20px;
max-width: 800px;
background: #f4f8fb;
}
修改入口文件。
module.exports = {
entry: "./src/main.css",
// ... other code
};
得到错误:
ERROR in ./src/main.css 1:4
Module parse failed: Unexpected token (1:4)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
错误的意思是语法错误,因为它是按照 js 的语法来解析 main.css 文件的。
也就是说,webpack 中内置的 loader 只认识 js 文件,所有的资源文件都会被当作 js 来打包。如果要打包其他类型的资源文件,需要使用对应的 loader。
css 文件的加载器叫做 css-loader,把它安装下来。
npm i -D css-loader
然后修改配置。
module.exports = {
// ...other code
module: {
rules: [
{
test: /.css$/,
use: "css-loader",
},
],
},
};
module 是用于给不同的模块指定不同的 loader,rules 是具体的规则。
rules 是一个数组,其中每个元素都是一个规则对象。这个对象必须有 test 和 use 属性。test 用于匹配具体的文件,use 是指定所使用的 loader。
再次运行打包命令,启动 serve。
样式并没有生效。
原因是 css-loader 虽然会将样式打包,但并不会自动将它们应用到页面中。
这时可以再使用 style-loader 来配合 css-loader。
style-loader 的作用是将 css-loader 打包的结果以 style 标签的形式添加到页面上。
npm i -D style-loader
修改配置文件。
当一个类型的文件需要用到多个 loader 时,就需要将 use 属性修改为一个数组,传入多个 loader。
当有多个 loader 时,会按照从右到左、从后到前的顺序依次执行。
module.exports = {
// ...other code
module: {
rules: [
{
test: /.css$/,
use: ["style-loader", "css-loader"],
},
],
},
};
这样就实现了将样式打包并插入到 html 中。
loader 是 webpack 实现整个前端模块化的核心特性,借助于 loader 就可以加载任何类型的资源。
导入资源模块
上面的实例可以看出,webpack 是可以以 css 文件为入口的。也就是说 entry 并不一定必须是 js 文件。
但是几乎在所有情况下,webpack 的打包入口都应该是 js 文件。因为打包入口就是运行入口。
目前而言,前端应用的业务是由 JavaScript 驱动和主导的。
所以正确的做法是将 js 文件设置为入口文件,再从 js 文件中通过 import 语法导入其他文件。
对上面的例子进行改进。
将 entry 设置回 js。
entry: "./src/main.js";
在 main.js 中导入 main.css。
import "./main.css";
再次启动 serve,可以发现这样也是可以正常运行的。
传统的 Web 开发中,一直有一个理念,或者说是最佳实践,就是将结构、行为、样式相分离。
webpack 又建议我们将 css 文件直接导入 js 文件中,是不是这两种做法有所冲突呢?
实际上,webpack 不仅仅建议将 css 导入到 js 中,其他的资源文件也会导入到 js 中。
根据代码的需要动态载入资源。
需要资源的不是应用,而是此时编写的代码。
这就是 webpack 的哲学。
因为,JavaScript 驱动整个前端应用。
这么做的优势和必须的原因是:
- 逻辑合理,JS 确实需要这些资源文件。
- 确保上线资源不缺失,都是必须的。
学习一门新的事物,并不是知道了所有的用法就能提高。照着文档去做,每个人都可以。新事物的思想才是突破点。明白了新事物为什么这么设计,才算是学明白了。
文件资源加载器
webpack 社区提供了大量的 loader,基本上所有正常的需求有会有对应的 loader。
大部分的 loader 都是和 css-loader 类似,将其他模块的代码转换成 js 代码的方式去工作。但是,有些资源文件是没办法通过 js 的方式去表示的。比如图片和字体。
在项目的 src 目录中添加一张猫咪的图片,名字叫 cat.jpeg。
在 main.js 中导入并使用它。
import createHeading from "./heading.js";
import "./main.css";
import cat from "./cat.jpeg";
const heading = createHeading();
document.body.append(heading);
const img = new Image();
img.src = cat;
document.body.append(img);
文件类型的资源导出需要接收一下,因为它导出的是一个打包后的绝对路径。
打包文件需要使用 file-loader。
npm i -D file-loader
然后修改配置。
{
test: /.jpeg$/,
use: 'file-loader'
}
此时再次编译,就会发现在 output 文件夹下多出了一张名字很长的图片。
打开页面以后,发现图片并不能正常加载。
原因是 webpack 将打包后的图片名直接作为绝对路径使用,这样自然就是 404,所以还需要告诉他项目的输出目录在哪里。
在 output 中设置 publicPath。
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'output'),
publicPath: 'output/'
},
这样就可以正常工作了。
需要注意的是,publicPath 默认是空字符串,添加目录之后,一定要在最后面加上/。
可以打开 bundle.js 文件中查看打包后的代码,参数数组中最后面那个模块就是处理 jpeg 用的。
它里面会有一个字符串拼接。
__webpack_require__.p + "de90e7d71184c9bbee7d4f19508a6c83.jpeg";
其中 __webpack_require__.p
就是 publicPath 设置的值,所以必须加上/。
整个流程就是在 webpack 打包过程中碰到了导入的图片,然后在配置中匹配到这种文件类型,从而找到对应的加载器。文件加载器将该文件拷贝到输出目录,再把文件的路径当作图片模块的返回值返回。这样就可以拿到文件的访问路径。
URL 加载器
除了 file-loader 这种通过拷贝物理文件的形式处理文件以外,还有 Data URLs 的形式,这种形式也非常常见。
Data Url 是一种特殊的文件协议,可以直接表示一个文件。
传统的文件都是在服务器上存在一个文件,通过 url 的方式请求这个文件。Data Url 是一种可以直接表示文件内容的链接。这种 Url 的文本已经包含了文件的内容,使用这种 url 时就不会再去发送任何的 Url 请求。
data:[<mediatype>][;base64],<data>
// 协议:[<媒体类型>][;编码],<文件内容>
比如下面这段 url,就会被浏览器解析出时 html 的文本内容。
data:text/html;charset=UFT-8,<h1>html content</h1>
除了 html,图片和字体也可以被转换成 data url 的形式。图片无法使用二进制,可以使用 base64 对其编码,浏览器一样可以解析出来。
由于图片和字体的体积非常大,所以这样得到的 url 会非常长。
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASAB...
通过 data url,我们可以表示任何类型的文件。
在 webpack 中使用 data url 需要安装 url-loader。
npm i -D url-loader
将配置中的 file-loader 改为 url-loader。
{
test: /.jpeg$/,
// use: 'file-loader'
use: 'url-loader'
}
再次打包, output 中不再存在 jpeg 文件。
查看 bundle.js,发现最后一个模块导出的是一个 data url。
__webpack_exports__["default"] =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAg...";
这种模式非常适合图片提示非常小的情况。
如果图片体积非常大,就会导致打包出来的代码体积非常大,从而影响应用的运行速度。
最佳实践
小文件使用 Data Urls,减少请求次数。
大文件单独提取存放,提高加载速度。
url 支持通过配置区分不同的文件从而用不同的方式处理。
具体做法如下:
{
test: /.jpeg$/,
use: {
loader: 'url-loader',
options: {
limit: 10 * 1024
}
}
}
这样仍然使用 url-loader 来加载 jpeg 文件,但是当文件体积超过 limit 所配置的体积时,就会自动使用 file-loader 加载文件。
limit 的单位是字节,10 * 1024 就是 10M。
最终实现:
- 超出 10kb 文件单独提取存放
- 小于 10kb 文件转换为 Data URLs 嵌入代码中
通过这种方式加载文件的话,就需要同时安装 url-loader 和 file-loader。
常用加载器分类
加载器有点像一个工厂,对我们的代码进行加工。
常用的加载器有三种。
编译转换类
文件操作类
代码检查类
编译转换类
这种类型的加载器会将模块内容转换为 JavaScript 代码。像之前使用的 css-loader 就是将 css 代码转换为 js 模块。从而实现使用 JavaScript 运行 css。
文件操作类
这类加载器通常会将我们加载到的模块拷贝到输出目录,同时将文件的访问路径向外导出。file-loder 就是这种工作方式。
代码检查类
最后一种是对代码进行校验和检查的加载器,目的是为了统一代码风格,从而提高代码质量。不过这类加载器通常不会修改源代码。
打包 ES6
webpack 可以将 js 以模块的方式打包,所以可以处理 import 和 export 语法,但是并不能编译其他 ES6 语法。
如果需要在打包过程中同时处理 ES6 语法,就需要额外安装一个编译型 loader 来对代码进行转换。
常见的如 babel-loader。
由于 babel-loader 不包含 babel 的核心运行平台和语法转换插件。所以通常还需要额外安装@babel/core 和@babel/preset-env。
npm i -D babel-loader @babel/core @babel/preset-env
配置 babel。
{
test: /.js$/,
use: {
loader: "babel-loader",
options: {
presets: ['@babel/preset-env']
}
}
},
这样重新打包,就可以将 es6 最新语法特性转换为 es5。
webpack 只是打包工具,加载器可以用来编译转换代码。
加载资源的方式
在 webpack 中除了 import 可以触发模块的加载,webpack 中还提供了其他几种模块加载的方式。
遵循 ES Module 标准的 import 是最为常用的一种方式。
遵循 CommonJS 标准的 require 函数。
require 和 import 有所区别,在使用 require 导入 export 的默认导出时,需要通过 default 属性来获取。
// import createHeading from "./heading.js"
const createHeading = require("./heading.js").default;
遵循 AMD 标准的 define 函数和 require 函数,webpack 同样是支持的。
虽然 webpack 可以兼容多种标准,但是非必要情况下,一定不要在一个项目中混合使用多种标准。多种模块标准混合使用会降低项目的可维护性。
除了上面三种模块加载方式以外,Loader 加载的非 JavaScript 模块也会触发资源加载。比如样式代码中的@import 指令和 url 函数和 HTML 代码中图片标签的 src 属性。
但是 HTML 代码中一些其他的资源不会被打包,比如 a 标签的 href 属性。如果需要打包的话需要做一些配置。
总结一下,webpack 常见可以触发打包的资源有如下 5 种:
- 遵循 ES Moduel 标准的 import 声明
- 遵循 CommonJS 标准的 require 声明
- 遵循 AMD 标准的 define 函数和 require 函数
- 样式代码中@import 指令和 url 函数
- HTML 代码中图片标签的 src 属性
核心工作原理
webpack 的工作原理可以通过官网的图就可以一目了然。
项目中散落着各种各样的代码文件和资源文件。
webpack 会根据配置找到入口文件,并通过 import/require 等语句解析出该模块所依赖的其他模块。然后再解析每个模块所依赖的模块,最终形成了描述所有文件之间依赖关系的依赖树。
webpack 递归整颗依赖树,通过 rules 来找到不同模块对应的加载器进行加载,最终将加载的结果放入到 bundle.js 中。
整个过程中,loader 机制起到了非常重要的作用,是 webpack 的核心。
开发一个 Loader
假设一个场景,在 JavaScript 中导入 markdown 文档。导入的内容是转换过后的 html。
仍然使用之前演示的项目目录结构。
在 src 下创建并编写 note.md。
### 什么样的人学习效果最差?
1. 追求立竿见影、急于求成的人。
2. 学习全靠死记硬背的人。
3. 只有输入,没有输出的人。
4. 伸手党。碰到问题,第一反应是问别人,而不是通过自己的思考和努力,寻求解决方法。
5. 只见树木,不见森林。学习过程中,上来就死抠细节,不顾整体。结果就是迷失在细节中无法自拔。
6. 没有求知欲,不能进行主动式学习的人。
7. 没有系统性和结构化思维的人。他们学到的东西都无法形成知识体系结构和网络,是零散的。
8. 没有任何擅长领域的人。包括任何爱好。但凡有一样自己非常擅长的正常的爱好,就说明他经历过一个完整的学习过程,从而可以在新的领域中应用这份学习的经验。至少,他明白一点,学习的过程必然会经历一个阵痛期,他不会轻言放弃。
9. 有太多学习目标的人。目标太多,反而让自己无从开始,而导致长期原地踏步。
10. 不敢承认并面对自己不足的人。承认自己的不足,是学习的第一步。
11. 无法保持初学者心态,从而被旧知识和经验拖累的人。时代在变化,知识也在变化。以往的经验不一定适合当下。
12. 没有学习目标的人。我是指真正的目标,是可以执行并达成的目标,而不是「为了成为牛人」或「赚很多钱」这样的大而空的梦想。
13. 不懂得如何阅读的人。 这些人有一个典型的特征,就是希望从书中拣到自己需要的全部知识。这和伸手党其实无异。一本好书,是从来不会直接告诉你答案的。
14. 总是习惯给自己的失败找理由的人。「我不是不会,只是没学而已」,拜托,不管你有何种理由,也改变不了你不会的事实。学习是给你自己学,不是给别人学。
15. 学习目的不单纯的人。假装自己忙于学习,其实不知道该干什么。或者学习是为了给别人(父母、同学、朋友)看,假装自己很努力。
16. 拥有巨婴思维的人。期望环境适应自己,而不是自己去适应环境;期望书籍或视频可以让自己自动地看懂,而不是通过学习发现自己的未知。环境没有让自己舒服,那就找环境的事;如果书或视频课程自己没有看懂,就开始抱怨或者侮辱作者。从来不会反思自己,是不是哪里有知识的缺陷需要补足。这样的人不懂得选择适合自己的书或课程。
17. 觉得被我说中了,但视而不见的人。
18. 觉得被我说中的人,但还没有去尝试去行动去挑战就提前认为改变很难而给自己设置心理障碍的人。不要给自己设限。
19. 玻璃心严重的人。当别人指出他的不足或错误的方法时,他考虑的不是如何改正,而是在想,这人说话怎么这么不好听呢?
编写 main.js。
import note from "./note.md";
document.body.innerHTML += note;
我们希望导入的 note 是一个 html 字符串,可以直接被写到页面上。
然后在项目根目录下创建并编写 markdown-loader.js,用于处理 md 格式的文件。
每一个 loader 都默认导出一个函数,这个函数就是对资源文件的处理过程。
这个函数接受一个参数作为输入,返回一个值作为输出。
可以先尝试打印输入文本,再直接把输入返回。
module.exports = (source) => {
console.log(source);
return source;
};
编写 webpack.config.js 配置文件。webpack 配置文件中的 loader 和 node.js 导入模块是一样的,不一定必须是 node_modules 中的模块,也可以是项目中的模块。
{
test: /.md$/,
use: './markdown-loader'
},
运行 npm run build
,note.md 中的内容会被打引到控制台,但同时也会得到一个错误。
ERROR in ./src/note.md 1:0
Module parse failed: Unexpected character '#' (1:0)
File was processed with these loaders:
* ./markdown-loader.js
You may need an additional loader to handle the result of these loaders.
大致意思就是还需要一个额外的 loader 来帮助我们继续处理当前的结果。
loader 的工作过程就像是一个管道,可以在这个过程中使用多个 loader。但是有个要求,就是最后的那个 loader 的返回结果必须是一段 JavaScript。
既然知道了原因,那么解决办法有两个。
- 返回一段 JavaScript 代码。
- 借助其他 loader。
第一种办法比较简单,我们来实现一下。
安装一个将 md 文本转换为 html 字符串的模块。
npm i -D marked
修改 markdown-loader.js。
const marked = require("marked");
module.exports = (source) => {
return `module.exports = ${JSON.stringify(marked(source))}`;
// 或者使用 ES Module 语法
// return `export default ${JSON.stringify(marked(source))}`
};
其中使用 JSON.stringify 的原因就是防止 html 字符串中的空格、引号和换行符在拼接过程中发生语法错误。
再次打包,没有出现问题。
运行 serve,页面正常打开。
另一种方式是多个 loader 一起工作。
markdown-loader 只返回字符串,然后交由下一个 loader 来处理这个字符串,最终转换为 js 代码。
这里借助 html-loader。
npm i -D html-loader
修改 webpack-loader.js,直接返回字符串。
const marked = require("marked");
module.exports = (source) => marked(source);
修改 webpack.config.js 配置。
{
test: /.md$/,
use: ['html-loader', './markdown-loader']
}
这样也可以完成打包。
整个工作过程是,webpack 读取 md 文件中的内容,传递给 markdown-loader,markdown-loader 将内容转换为 html 字符串,交给 html-loader,html-loader 再将 html 字符串转换为 js 代码。
Loader 的功能和原理比较简单,就是负责资源文件从输入到输出的转换。
对于同一个资源文件可以依次使用多个 Loader。
Loader 和管道很相似,可以将当前 Loader 的结果处理完交给下一个 Loader 去处理。通过组合多个 Loader 来完成一个功能。
比如 css-loader 和 style-loader 的配合。
插件机制
插件的功能是增强 webpack 自动化的能力。
Loader 专注实现资源模块的加载,从而实现整个项目资源的打包。
Plugin 解决除了打包以外其他的自动化工作。
例如:
- 可以在打包之前清除 dist 目录。
- 拷贝静态文件到输出目录。
- 压缩打包结果输出的代码。
- ...
总之,plugin 帮助 webpack 实现了前端工程化中绝大部分工作。这也就导致了很多初学者认为前端工程化就是指 webpack。
自动清除输出目录插件
默认情况下,webpack 打包的结果会自动覆盖到原来的目录。但是在打包之前,dist 目录下就可能存在之前打包的遗留文件。再次打包只能覆盖同名文件,对于那些用不到的文件,会一直积累在 dist 目录中,非常不合理。
合理的做法就是在每次打包之前自动清除 dist 目录。
实现这个功能,需要借助 clean-webpack-plugin 插件。
npm i -D clean-webpack-plugin
在 webpack.config.js 中配置这个 plugin。
const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = {
mode: "none",
entry: "./src/main.js",
output: {
filename: "bundle.js",
path: path.join(__dirname, "dist"),
},
plugins: [new CleanWebpackPlugin()],
};
添加 plugin,先导入 plugin,往往是一个类。然后在配置对象中添加 plugins 属性,是一个数组,数组中的每一个元素都是一个 plugin。
这样再次构建时,就会把上一次的构建结果清理掉了。
自动生成 HTML 插件
之前在 html 中导入 js,都是通过硬编码将 bundle.js 写在 html 中。然后将 html 放置在项目的根目录下。
这么做有两个问题。
- 发布时需要同时发布 dist 文件夹下的文件和 index.html。还要确保项目上线后,index.html 中的路径引用是正确的。
- 当 webpack.config.js 中的 output 发生变化时,需要手动更改 index.html 中的引用。
解决这两个问题的办法就是通过 webpack 自动的生成 html 文件,也就是说让 webpack 参与到构建过程。
在构建过程中,webpack 知道生成了多少个 bundle,可以将它们全部注入到页面中。这样 html 也会被打包到 dist 目录下,上线时,只需要部署 dist 目录就可以了。
这样就解决了上面的两个问题。
实现这个功能需要借助 html-webpack-plugin 插件来完成。
npm i -D html-webpack-plugin
编写配置。
const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "none",
entry: "./src/main.js",
output: {
filename: "bundle.js",
path: path.join(__dirname, "dist"),
},
plugins: [new CleanWebpackPlugin(), new HtmlWebpackPlugin()],
};
再次运行打包命令,会在 dist 目录下看到一个 index.html,并且这个 html 中会引入正确的 js 路径。
通过选项自定义输出 htlm 内容
我们还需要在通过 html-webpack-plugin 生成的 html 中设置标题和一些基础的 DOM 结构。这些功能可以通过配置选项来实现。
比如设置标题为 Webpack Plugin Sample,设置一个 meta 标签,名字是 viewport,内容是 width=device-width。
new HtmlWebpackPlugin({
title: "Webpack Plugin Sample",
meta: {
viewport: "width=device-width",
},
});
这样打包之后的 index.html 就会根据配置发生改变。
这种方式适合需要少量自定义 html 的情况。
当需要大量自定义 html 时,另一种方式是提供一个 html 模板,根据 html 模板来生成页面。
在 src 下创建一个 index.html 文件作为模板。这个模板可以通过 ejs 模板拿到一些数据。htmlWebpackPlugin.options 就是 HtmlWebpackPlugin 的构造参数。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div class="app"></div>
</body>
</html>
修改配置,添加一个 template 选项。
new HtmlWebpackPlugin({
title: "Webpack Plugin Sample",
meta: {
viewport: "width=device-width",
},
template: "./src/index.html",
});
再次打包,就会以模板文件作为基础生成 html。
同时输出多个 html 文件
html-webpack-plugin 的实例可以生成一个 html 文件,如果需要生成多个 html 文件,可以创建多个 html-webpack-plugin 的实例。
比如再创建一个 about.html 页面。
[
new HtmlWebpackPlugin({
title: "Webpack Plugin Sample",
meta: {
viewport: "width=device-width",
},
template: "./src/index.html",
}),
new HtmlWebpackPlugin({
filename: "about.html",
}),
];
这样打包,dist 中还会多生成一个 about.html 文件。
静态文件拷贝插件
一般项目中会有一些文件不需要参与打包过程,但是需要在项目上线时用到。
这种文件一般存放在项目根目录下的 public 文件夹中,比如 favicon.ico 文件。
我们希望在打包时,将这些文件直接复制到 public 目录下。
针对这种需求,可以借助 copy-webpack-plugin 来实现。
npm i -D copy-webpack-plugin
在配置中添加依赖。
const CopyWebpackPlugin = require("copy-webpack-plugin");
module.exports = {
// ... other code
plugins: [
// ... other code
new CopyWebpackPlugin({
patterns: ["public"],
}),
],
};
CopyWebpackPlugin 的构造函数是一个配置对象,通过设置 patterns 属性来指定拷贝哪些文件。patterns 属性的值是数组,用于指定要拷贝的文件或文件夹。可以是文件目录名,也可以是通配符,或者配置对象的形式。
上面提到的 clean-webpack-plugin、html-webpack-plugin 和 copy-webpack-plugin 这几个插件都是通用型的插件。其实很多 plugin 都是通用型的,它们和框架无关。无论是使用 React 或者 Vue,都可能会用到它们。
对于常用的插件,应该去看一遍官方文档,了解具体用法。
除此之外,社区还提供了非常多的插件,没有必要去记忆它们的 API,只需要在项目中遇到具体的需求时,会使用 github 或者 npm 去搜索即可。
开发一个 Plugin
相比于 Loader,Plugin 拥有更宽的能力范围。
Loader 只能在加载模块的环节工作,而 Plugin 可以触及 webpack 工作的每一个环节。
plugin 的机制是通过钩子机制实现的。
在 webpack 工作过程中会有很多环节,为了便于插件扩展,webpack 给这些环节预留了钩子。当我们需要扩展功能时,可以朝这些钩子上去挂在任务。这和 web 应用中的事件机制很像。
钩子任务的要求是一个函数,或者是一个包含 apply 方法的对象。常见的做法是将插件定义为 class,然后通过 class 创建一个实例来使用。
下面实现一个可以清除 webpack 打包之后 bundle.js 中每一行的开头注释。
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap("MyPlugin", (compilation) => {
for (const name in compilation.assets) {
if (name.endsWith(".js")) {
const contents = compilation.assets[name].source();
const withoutComments = contents.replace(/\/\*\*+\*\//g, "");
compilation.assets[name] = {
source: () => withoutComments,
size: () => withoutComments.length,
};
}
}
});
}
}
apply 方法接收一个 compiler 参数,compiler 是一个对象,包含了 webpack 构建时所有的信息。
通过 compiler.hooks['钩子名'].tap 的方式注册钩子任务,此时我们需要在 webpack 编译完成,马上要把内容输出到 dist 目录的时候进行处理,所以 emit 很符合我们的需求。
tap 方法接收两个参数。第一个是插件的名称,第二个是挂载到钩子上的函数。函数接收 1 个 compilation 参数,可以理解为本次打包的上下文。
然后通过循环 compilation.assets 中的 key,来拿到所有的文件名,过滤出 js 文件,再通过 compilation.assets[name].source()的方式拿到资源内容,然后通过 replace 方法把内容中的注释去除掉。
最后重新覆盖 compilation.assets[name]的值。这个值是一个对象,必须包含 source 和 size 方法。source 返回的是资源内容,size 返回的是资源长度。
开发体验问题
上面介绍了很多 webpack 的 loader 和 plugin,但是在真正的开发过程中还远远不够。
因为我们在开发中的步骤大致上是:编写源代码 -> webpack 打包 -> 运行应用 -> 刷新浏览器。
这种流程周而复始的手动操作过于原始,如果实际开发中还是这么使用,必然会降低开发效率。
可以先对理想的开发环境作出设想:
- 以 Http Server 的方式运行。这样更符合线上环境的状态,还可以使用 Ajax 的 API。
- 自动编译+自动刷新。每次修改完源代码,不需要手动刷新。
- 提供 source map 支持。当出现错误时,可以快速定位到源代码位置。
自动编译
webpack 提供了 watch 工作模式,通过这种模式,就可以监听源文件变化,自动重新运行打包任务。
启动 watch 工作模式也非常简单,修改 scripts。
{
"build": "webpack --watch"
}
这样再次运行 npm run build
时,会一直阻塞命令行窗口,监听文件的变化,直到手动结束。
自动刷新浏览器
可以借助一个叫做 browser-sync 的插件来实现这个功能。
npm i -D browser-sync
启动这个服务。
npx browser-sync dist --files "**/*"
这样配合 webpack 的 watch 工作模式就实现了修改源代码,自动刷新浏览器。
但是这种方式有一些弊端。
- 操作麻烦。需要同时使用两个工具,开启两个命令行窗口。
- 效率降低。webpack 不停的将数据写入磁盘,browser-sync 再从资盘中读取文件。多出了两步文件读写操作。
所以还需要继续改善开发体验。
Dev Server
webpack 提供了一个工具,叫做 webpack dev server。它提供了用于开发的 Http Server,并且集成了「自动编译」和「自动刷新浏览器」等功能。
安装。
npm i -D webpack-dev-server
启动 webpack-dev-server。
npx webpack-dev-server
它会自动启动 webpack 的打包功能,并且将打包后的结果存放在内存中,并不会在项目中生成 dist 文件夹。然后它会启动一个 http 服务,将内存中的打包结果发送给浏览器。
可以传递 --open 的参数,可以在服务启动后自动打开浏览器。
静态资源访问
Dev Server 默认只会访问 webpack 打包输出的文件。
如果其他静态资源需要被访问,就需要告诉 webpack dev server。
在 webpack 的配置文件中,可以对 webpack dev server 进行配置。
比如指定其他的静态文件访问路径。
module.exports = {
// ...other code
devServer: {
contentBase: "./public",
},
};
contentBase 可以用来为开发服务器额外指定资源目录,可以是一个字符串,也可以是一个数组。
Server 代理 API
因为 dev server 启动的服务,域名是本地的,而后端接口往往是线上的。
当我们部署项目时,前端项目和后端项目可能是在同一个域名下。但是在开发过程中,就可能出现跨域请求的问题。
这种情况可以使用跨域资源共享(CORS)的方式来解决。如果接口支持 CORS,就不会出现这个问题。
但是并不是任何情况下 API 都支持 CORS。如果项目是同源部署,那么就没必要开启 CORS。所以还是经常出现开发阶段接口跨域问题。
解决这个问题最常见的办法就是通过 API 代理服务,将服务器的接口代理到本地。
webpack dev server 支持通过配置来添加代理服务。
比如代理 github 的 API。
module.exports = {
// ...other code
devServer: {
contentBase: "./public",
proxy: {
"/api": {
// http://localhost:8080/api/users -> https://api.github.com/api/users
target: "https://api.github.com",
// http://localhost:8080/api/users -> https://api.github.com/users
pathRewrite: {
"^/api": "",
},
// 不能使用 localhost:8080 作为请求 github 的主机名
changeOrigin: true,
},
},
},
};
proxy 对象的每一个属性都是一个代理规则。/api 就会匹配所有/api 开头的请求,转发到 target 对应的 API。
如果需要重写的话,使用 pathRewrite 属性,属性的值是一个正则,它会用属性的值替换掉属性名。
changeOrigin 是用来设置主机名的,因为有些接口是借助主机名来做一些逻辑判断。
Source Map
因为运行的代码是经过编译打包之后的代码,所以和源代码之间有很大的差异。这样会有两个问题。
- 调试应用困难
- 错误信息无法定位
原因是调试和报错都是基于运行代码的,而不是源代码。
Source Map 就是解决这类的问题的一个好办法。
它的作用就是映射源代码和生产代码之间的关系。通过 Source Map,可以通过生产代码逆向解析得到源代码。
source map 文件通常以.map 结尾,但内部其实是 json 格式。
拿 jQuery 的 source map 举例。
{
"version": 3,
"sources": ["jquery-3.4.1.js"],
"names": ["..."],
"mappings": ""
}
它具有几个属性。
version 表示 source map 的版本。
sources 是转换之前源文件的名称,它可能是多个文件,也可能是一个文件。
names 中是源代码中的变量名称。因为打包构建会将变量重命名。
mappings 属性是 map 的核心属性,它是通过 base64 VLQ 编码的字符串,记录了转换之后代码字符和转换之前代码字符的映射关系。
因为 source map 的用途主要是调试和定位错误,所以在生产环境没有太大的意义。
在编译后代码的最后一行添加注释,就可以通过浏览器的开发人员工具请求这个文件,并逆向解析出源代码。
//# sourceMappingURL=jquery-3.4.1.min.map
Source Map 解决了源代码与运行代码不一致产生的问题。
Webpack 配置 Source Map
webpack 中配置 source map 比较简单,只需要添加一个 devtool 属性。
module.exports = {
// ...other code
devtool: "source-map",
};
这样再次打包,就会在 dist 目录下生成一个 bundle.js.map 文件。
但是 webpack 提供了 12 种不同的 source map 模式,每种模式的效率和效果都是各不相同的。
总的来说,生成 source map 效果最好的模式,生成效率就最慢。而生成效率最快的模式,效果就最差。
eval 模式的 Source Map
12 种不同的 source map 模式在初次构建速度、重新编译速度、是否适合生产环境使用和生成 source map 的质量这四个维度都有所差异。
eval 是一个 JavaScript 的函数,可以运行 JavaScript 代码,这段代码默认运行在一个临时的虚拟机当中。
eval("console.log(123);");
// 123 VM63:1
VM63 就是临时虚拟机的名字。
而通过添加注释的方式,就可以设置对应的源代码文件名。
eval("console.log(123); //# sourceURL=./foo/bar.js");
// 123 ./foo/bar.js:1
webpack 中的 eval 模式就是使用这种方式。
它会把源代码用 eval 包裹起来,这种方式是生成 source map 最快的,甚至不需要 map 文件。同样它的质量也是最差的。当代码发生异常时,它不知道错误的行列信息。
devtool 模式对比
webpack 的配置文件除了导出一个配置对象外,还可以导出一个数组,数组的每个元素都是一个对象。
这种情况,webpack 在启动时就会使用所有的配置对象一起工作。
module.exports = [
{
entry: "./src/main.js",
output: {
filename: "a.js",
},
},
{
entry: "./src/main.js",
output: {
filename: "b.js",
},
},
];
上面这个配置就会生成一个 a.js 和一个 b.js。
根据这个原理,可以通过对 12 个 devtool 模式进行遍历,生成一个数组配置。
因为要看具体的效果,所以需要安装 html-webpack-plugin、babel-loader、@babel/core 和@babel/preset-env。
npm i -D html-webpack-plugin babel-loader @babel/core @babel/preset-env
配置。
const HtmlWebpackPlugin = require("html-webpack-plugin");
const allModes = [
"eval",
"cheap-eval-source-map",
"cheap-module-eval-source-map",
"eval-source-map",
"cheap-source-map",
"cheap-module-source-map",
"inline-cheap-source-map",
"inline-cheap-module-source-map",
"source-map",
"inline-source-map",
"hidden-source-map",
"nosources-source-map",
];
module.exports = allModes.map((devtoolMode) => {
return {
mode: "none",
entry: "./src/main.js",
output: {
filename: `js/${devtoolMode}.js`,
},
devtool: devtoolMode,
module: {
rules: [
{
test: /\.js$/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: `${devtoolMode}.html`,
}),
],
};
});
在 src/main.js 中故意写错。
document.body.innerHTML += "hello, world";
console.log1("main running~");
通过打包,会在 dist 目录下生成 12 个 html 和一个 js 文件夹,其中包含了 12 份 js。
然后通过 serve 工具启动这些页面,当 serve 启动的目录中没有 index.html 时,会启动一个文件列表页面。
serve dist
eval 模式
eval 模式在上面看过了,它不会生成 map 文件,而且只能定位到错误文件。
eval-source-map 模式
eval-source-map 模式在 eval 模式基础上生成了 source map 文件,可以定位到错误的行列信息。
cheap-eval-source-map 模式
阉割版 eval-source-map,可以定位行信息,而没有列信息。
cheap-module-eval-source-map 模式
cheap-module-eval-source-map 模式和 cheap-eval-source-map 模式很像,同样只可以定位到行信息。唯一的区别是它定位的代码是我们真正手写的 ES6 代码。而 cheap-eval-source-map 定位的是通过 babel 转译后的代码。
了解了这 4 种模式,基本上算是通盘了解了所有的模式,其他的模式都是将这 4 种进行排列组合。
- eval:是否使用 eval 执行模块代码
- cheap:Source Map 是否包含列信息
- module:是否能够得到 Loader 处理之前的源代码
inline-xx 模式
带有 inline 的模式,是将 source map 文件以 base64 Data Url 的形式放到注释中的。这种模式很少会用到,因为生成的代码体积会非常大。
hidden-source-map
hidden 模式会生成 source map 文件,但是并不会在打包的代码中加入 source map 的注释。通常在开发第三方包时会使用这种模式。这样当开发者需要使用 source 时手动加入 source map 的注释。
nosources-source-map
这种模式可以定位到错误的行列信息,但是不会看到源代码。通常在生产环境下使用,防止源代码暴露。
Source Map 模式的选择
source map 有很多种模式,但实际上我们能用到的模式只有那么几种。
开发模式 cheap-modue-eval-source-map
因为开发模式有几个特点。
- 通常情况下每行代码的长度都会有限制,比如 80 个字符、100 个字符或 120 个字符。列信息没有太大意义,所以选择 cheap。
- 我们通常都会使用一些框架来做开发,比如 React 和 Vue,它们编译后的代码和源代码差异比较大,需要来查看源代码。所以选择 module。
- 这种模式下,首次打包速度较慢,但是重写打包较快。我们开发时更多时候都是在频繁更新源代码,所以非常合适。
生产模式 none & nosource-source-map
这么做的好处就是防止懂技术的人拿到我们的源代码。source map 带来便利的同时也会带来安全隐患。
调试是开发阶段的事情,所有的问题都应该在开发阶段都找出来。而不是拿到线上让全民公测。
如果对代码没有信心,可以使用 nosource-source-map,这样可以找到报错位置,也不至于暴露源代码。
source map 的模式选择并不是绝对的,上面两个只是一个建议,在很多情况下都可以按照建议去使用。但是理解不同模式之间的差异也很重要,这样可以根据不同的环境,做出不同的选择。
自动刷新问题
webpack dev server 提供对开发者更友好的开发服务器。
但是自动刷新在某些场景下存在问题。
比如调试 input 中文本的样式,每次修改 input 的样式,input 中的文本内容都会丢失。然后只能自己再次输入文本进行测试。这样的开发体验并不友好。
当然也有解决办法:
- 在代码中写死 input 的内容。
- 额外代码实现刷新保存,刷新后读取。比如 localStorage。
这些办法都能解决这个问题,但并不是最好的办法,它们都要编写与业务无关的代码,属于有洞补洞的办法,并不能根治这个问题。
问题的核心是自动刷新浏览器导致页面状态丢失。
最理想的效果应该是在页面不刷新的前提下,模块代码也可以及时更新。
HMR 体验
HRM(Hot Module Relpacement),翻译过来就是模块热替换或者模块热更新。
计算机行业有个热拔插的名词,指的就是可以在一个正在运行的机器上随时插拔设备,不影响机器的运行状态,插上的设备也可以立即开始工作。比如电脑上的 USB 端口。
开启 HMR
HMR 的疑问
使用 HMR API
处理 JS 模块热替换
处理图片模块热替换
HMR 注意事项
生产环境优化
不同环境下的配置
不同环境下的配置文件
DefinePlugin
设置值。
const webpack = require("webpack");
new webpack.DefinePlugin({
// js 代码片段
API_BASE_URL: '"https://api.example.com"',
});
main.js
console.log(API_BASE_URL);
编译后可以直接得到这个值。
体验 Tree Shaking
摇树。
未引用代码(dead-code)
生产模式自动开启。
使用 Tree Shaking
不是选项,它是效果。
通过 optimization 属性配置。
usedExports 设为 true,就不会导出。标记「枯树叶」
minimize 设置为 true,就会把未导出成员移除。负责「摇掉」「枯树叶」和「枯树枝」。
合并模块
模块过多时,通过 concatenateModules 为 true,所有模块放在一个函数中。
也被称为 Scope Hoisting,webpack3 特性。
Tree-Shaking 与 babel
使用了 babel-loader 会让 tree shaking 失效。
tree shaking 的前提是使用 ES Modules。
babel-loader 可能会把代码从 ES Modules 转换成 CommonJS。
babel7 中,不会把 ES Modules 转换为 CommonJS。所以不会影响 tree shaking。
不确定的话,可以通过配置 babel-preset-env 来测试。
options: {
presets: [["@babel/preset-env", { modules: "commonjs" }]];
}
side Effects
副作用:模块执行时,除了导出成员,是否还做了其他事情。
sideEffects 一般用于 npm 包标记是否有副作用。
设置两个地方。
webpack 配置和 package.json,这两个地方作用是不一样的。
side Effects 注意事项
如果需要打包带有副作用的模块,可以在 package.json 中设置 sideEffects 为一个数组,里面配置需要打包的文件。
代码分割 Code Splitting
所有代码都会被打包到一起。
应用复杂之后, bundle 体积过大。
并不是每个模块在启动时都是必要的。
分包,按需加载。
Code Splitting 和 webpack 合并打包结果并不矛盾,物极必反。
开发时会有很多个模块,力度很细。
Http1.1 有很多缺陷
- 同域不能同时并行请求限制。
- ...
按照规则打包到不同的 bundle 中。
webpack 一般有两种方式实现代码分包:
多入口打包。
动态导入。
多入口打包
一个页面对应一个打包入口。
公共部分单独提取。
将 entry 定义成一个对象,不可以定义成数组。
属性名就是 js 名称,值是文件路径。
然后修改 output 的 filename,改成[name]的形式,这样就可以打包出多个不同名称的文件。
还需要创建多个 HtmlWebpackPlugin 对象。
但这时候会注入所有的 js 文件,不合理。
需要修改 HtmlWebpackPlugin 构造函数的 chunks 属性,指定为需要的 bundle。
提取公共模块 Split Chunks
不同入口中肯定会有公共模块。
通过配置可以实现提取公共模块。
optimization: {
splitChunks: {
chunks: "all";
}
}
动态导入 Dynamic Imports
需要用到某个模块,再去加载这个模块。
动态导入的模块会被自动分包。
相比通过多入口打包,更为灵活。
使用 es modules 的话,无需配置任何地方。
es modules 提供了 import 函数,返回一个 Promise,可以在 then 中获取 module 对象。
魔法注释 Magic Comments
默认动态导入,打包后的文件是序号。如果需要给 bundle 命名,需要使用魔法注释。
行内注释。
import(/* webpackChunkName: 'name' */ "./xx/xx");
MiniCssExtractPlugin
提取 CSS 到单个文件,实现 css 按需加载。
但是需要注意 css 体积,超过 150kb 左右,单独提取可能会更好。
OptimizeCssAssetsWebpackPlugin
压缩输出的 css 文件。
webpack 默认只压缩 js 文件。css 的压缩需要使用 optimize-css-assets-webpack-plugin。
压缩类依赖,最好是放到 optimization 的 minimizer 数组中。这样可以控制插件在不同环境下是否工作。
但是给 minimizer 设置了值之后,又会出现一个问题。webpack 之前帮我们默认压缩的 js 文件,现在又不会自动压缩了,所以还需要手动添加 js 压缩插件。
npm i -D terser-webpack-plugin
输出文件名 Hash
静态资源客户端缓存有个问题,就是修改了代码后需要等待过期时间才能让新代码生效。
生产模式下, 文件名使用 hash 值,就可以解决文件缓存问题。
hash 值有三种方式设置。
- [hash],项目级别的,每次修改文件,触发打包,所有文件名都会发生变化。
- [chunkhash],chunk 级别的,只有文件和文件 import 的文件和引用它的文件会重命名。
- contenthash,根据文件内容生成 hash,最适合。
指定 hash 长度,通过冒号(:)加上一个数字。8 位的 hash 非常合适。