# webpack 原理与实战

# 开篇词:Webpack 现代化前端应用的基石

我用阿里云盘分享了「Webpack 原理与实践」,你可以不限速下载 🚀 复制这段内容打开「阿里云盘」App 即可获取 链接:https://www.aliyundrive.com/s/VvgBCxV4GX7 (opens new window)

Webpack 核心特性:基本特性配置方式工作模式基本工作原理Loader 机制插件机制

完全掌握 Webpack 的基本使用,理解 Webpack 打包过程和打包结果的工作原理,同时也能够自己开发 Webpack 的 Loader 和插件。

Webpack 高阶内容:

包括 Source Map、模块热替换(HMR)机制、Proxy、Webpack Dev Server 等周边技能的使用,以及 Tree-shaking、sideEffects、Code Spliting 等高级特性的实践,再有就是常用优化插件、三种 hash 的最佳实践、打包速度优化,以便于你能更熟练地使用 Webpack 的高级特性,为开发效率添砖加瓦。

其他同类优秀方案:Rollup、Parcel。希望通过这个模块的介绍,让你能够了解到一些 Webpack 同类的优秀方案,以及它们设计上的不同,这些都能够让你在工作中应对不同的项目、不同的需求时可以有更多的选择。

# 第 01 讲:Webpack 究竟解决了什么问题?

模块化的演进过程

1.Stage 1 - 文件划分方式

最早我们会基于文件划分的方式实现模块化,也就是 Web 最原始的模块系统。具体做法是将每个功能及其相关状态数据各自单独放到不同的 JS 文件中,约定每个文件是一个独立的模块。使用某个模块将这个模块引入到页面中,一个 script 标签对应一个模块,然后直接调用模块中的成员(变量 / 函数)。

缺点:

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

总之,这种原始“模块化”的实现方式完全依靠约定实现,一旦项目规模变大,这种约定就会暴露出种种问题,非常不可靠,所以我们需要尽可能解决这个过程中暴露出来的问题。

**2.Stage 2 - 命名空间方式

后来,我们约定每个模块只暴露一个全局对象,所有模块成员都挂载到这个全局对象中,具体做法是在第一阶段的基础上,通过将每个模块“包裹”为一个全局对象的形式实现,这种方式就好像是为模块内的成员添加了“命名空间”,所以我们又称之为命名空间方式。

3. Stage 3 – IIFE

使用立即执行函数表达式(IIFE,Immediately-Invoked Function Expression)为模块提供私有空间。具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现。

这种方式带来了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问,这就解决了前面所提到的全局作用域污染和命名冲突的问题。

4. Stage 4 - IIFE 依赖参数

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

模块加载的问题

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

最明显的问题就是:模块的加载。在这几种方式中虽然都解决了模块代码的组织问题,但模块加载的问题却被忽略了,我们都是通过 script 标签的方式直接在页面中引入的这些模块,这意味着模块的加载并不受代码的控制,时间久了维护起来会十分麻烦。试想一下,如果你的代码需要用到某个模块,如果 HTML 中忘记引入这个模块,又或是代码中移除了某个模块的使用,而 HTML 还忘记删除该模块的引用,都会引起很多问题和不必要的麻烦。

更为理想的方式应该是在页面中引入一个 JS 入口文件,其余用到的模块可以通过代码控制,按需加载进来。

模块化规范的出现

除了模块加载的问题以外,目前这几种通过约定实现模块化的方式,不同的开发者在实施的过程中会出现一些细微的差别,因此,为了统一不同开发者、不同项目之间的差异,我们就需要制定一个行业标准去规范模块化的实现方式。

再接合我们刚刚提到的模块加载的问题,我们现在的需求就是两点:

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

提到模块化规范,你可能会想到 CommonJS 规范,它是 Node.js 中所遵循的模块规范,该规范约定,一个文件就是一个模块,每个模块都有单独的作用域,通过 module.exports 导出成员,再通过 require 函数载入模块。现如今的前端开发者应该对其有所了解,但是如果我们想要在浏览器端直接使用这个规范,那就会出现一些新的问题。

如果你对 Node.js 的模块加载机制有所了解,那么你应该知道,CommonJS 约定的是以同步的方式加载模块,因为 Node.js 执行机制是在启动时加载模块,执行过程中只是使用模块,所以这种方式不会有问题。但是如果要在浏览器端使用同步的加载模式,就会引起大量的同步模式请求,导致应用运行效率低下。

所以在早期制定前端模块化标准时,并没有直接选择 CommonJS 规范,而是专门为浏览器端重新设计了一个规范,叫做 AMD ( Asynchronous Module Definition) 规范,即异步模块定义规范。同期还推出了一个非常出名的库,叫做 Require.js,它除了实现了 AMD 模块化规范,本身也是一个非常强大的模块加载器。

在 AMD 规范中约定每个模块通过 define() 函数定义,这个函数默认可以接收两个参数,第一个参数是一个数组,用于声明此模块的依赖项;第二个参数是一个函数,参数与前面的依赖项一一对应,每一项分别对应依赖项模块的导出成员,这个函数的作用就是为当前模块提供一个私有空间。如果在当前模块中需要向外部导出成员,可以通过 return 的方式实现。

除此之外,Require.js 还提供了一个 require() 函数用于自动加载模块,用法与 define() 函数类似,区别在于 require() 只能用来载入模块,而 define() 还可以定义模块。当 Require.js 需要加载一个模块时,内部就会自动创建 script 标签去请求并执行相应模块的代码。

目前绝大多数第三方库都支持 AMD 规范,但是它使用起来相对复杂,而且当项目中模块划分过于细致时,就会出现同一个页面对 js 文件的请求次数过多的情况,从而导致效率降低。在当时的环境背景下,AMD 规范为前端模块化提供了一个标准,但这只是一种妥协的实现方式,并不能成为最终的解决方案。

同期出现的规范还有淘宝的 Sea.js,只不过它实现的是另外一个标准,叫作 CMD,这个标准类似于 CommonJS,在使用上基本和 Require.js 相同,可以算上是重复的轮子。但随着前端技术的发展,Sea.js 后来也被 Require.js 兼容了。如果你感兴趣可以课后了解一下 Seajs 官网。

模块化的标准规范

尽管上面介绍的这些方式和标准都已经实现了模块化,但是都仍然存在一些让开发者难以接受的问题。

随着技术的发展,JavaScript 的标准逐渐走向完善,可以说,如今的前端模块化已经发展得非常成熟了,而且对前端模块化规范的最佳实践方式也基本实现了统一。

  • 在 Node.js 环境中,我们遵循 CommonJS 规范来组织模块。
  • 在浏览器环境中,我们遵循 ES Modules 规范。

而且在最新的 Node.js 提案中表示,Node 环境也会逐渐趋向于 ES Modules 规范,也就是说作为现阶段的前端开发者,应该重点掌握 ES Modules 规范。

因为 CommonJS 属于内置模块系统,所以在 Node.js 环境中使用时不存在环境支持问题,只需要直接遵循标准使用 require 和 module 即可。

但是对于 ES Modules 规范来说,情况会相对复杂一些。我们知道 ES Modules 是 ECMAScript 2015(ES6)中才定义的模块系统,也就是说它是近几年才制定的标准,所以肯定会存在环境兼容的问题。在这个标准刚推出的时候,几乎所有主流的浏览器都不支持。但是随着 Webpack 等一系列打包工具的流行,这一规范才开始逐渐被普及。

经过 5 年的迭代, ES Modules 已发展成为现今最主流的前端模块化标准。相比于 AMD 这种社区提出的开发规范,ES Modules 是在语言层面实现的模块化,因此它的标准更为完善也更为合理。而且目前绝大多数浏览器都已经开始能够原生支持 ES Modules 这个特性了,所以说在未来几年,它还会有更好的发展,短期内应该不会有新的轮子出现了。

综上所述,如何在不同的环境中去更好的使用 ES Modules 将是你重点考虑的问题。

ES Modules 特性

那对于 ES Modules 的学习,可以从两个维度入手。首先,你需要了解它作为一个规范或者说标准,到底约定了哪些特性和语法;其次,你需要学习如何通过一些工具和方案去解决运行环境兼容带来的问题。

针对 ES Modules 本身的一些特性本课时不做赘述,你可以参考:

模块打包工具的出现

模块化可以帮助我们更好地解决复杂应用开发过程中的代码组织问题,但是随着模块化思想的引入,我们的前端应用又会产生了一些新的问题,比如:

  • 首先,我们所使用的 ES Modules 模块系统本身就存在环境兼容问题。尽管现如今主流浏览器的最新版本都支持这一特性,但是目前还无法保证用户的浏览器使用情况。所以我们还需要解决兼容问题。

  • 其次,模块化的方式划分出来的模块文件过多,而前端应用又运行在浏览器中,每一个文件都需要单独从服务器请求回来。零散的模块文件必然会导致浏览器的频繁发送网络请求,影响应用的工作效率。

  • 最后,谈一下在实现 JS 模块化的基础上的发散。随着应用日益复杂,在前端应用开发过程中不仅仅只有 JavaScript 代码需要模块化,HTML 和 CSS 这些资源文件也会面临需要被模块化的问题。而且从宏观角度来看,这些文件也都应该看作前端应用中的一个模块,只不过这些模块的种类和用途跟 JavaScript 不同。

对于开发过程而言,模块化肯定是必要的,所以我们需要在前面所说的模块化实现的基础之上引入更好的方案或者工具,去解决上面提出的 3 个问题,让我们的应用在开发阶段继续享受模块化带来的优势,又不必担心模块化对生产环境所产生的影响。

接下来我们先对这个更好的方案或者工具提出一些设想:

  • 第一,它需要具备编译代码的能力,也就是将我们开发阶段编写的那些包含新特性的代码转换为能够兼容大多数环境的代码,解决我们所面临的环境兼容问题。

  • 第二,能够将散落的模块再打包到一起,这样就解决了浏览器频繁请求模块文件的问题。这里需要注意,只是在开发阶段才需要模块化的文件划分,因为它能够帮我们更好地组织代码,到了实际运行阶段,这种划分就没有必要了。

  • 第三,它需要支持不同种类的前端模块类型,也就是说可以将开发过程中涉及的样式、图片、字体等所有资源文件都作为模块使用,这样我们就拥有了一个统一的模块化方案,所有资源文件的加载都可以通过代码控制,与业务代码统一维护,更为合理。

针对上面第一、第二个设想,我们可以借助 Gulp 之类的构建系统配合一些编译工具和插件去实现,但是对于第三个可以对不同种类资源进行模块化的设想,就很难通过这种方式去解决了,所以就有了我们接下来要介绍的主题:前端模块打包工具。

# 写在最后

本课时重点介绍了前端模块化的发展过程和最终的统一的 ES Modules 标准,这些都是我们深入学习 Webpack 前必须要掌握的内容,同时也是现代前端开发者必不可少的基础储备,请你务必要掌握。

学到这里,你可能会有这样的疑问,本课时的内容是否偏离了主题?但其实我想传达的思想是,虽然 Webpack 发展到今天,它的功能已经非常强大了,但依然改变不了它是一个模块化解决方案的初衷。你可以看到, Webpack 官方的 Slogan 仍然是:A bundler for javascript and friends(一个 JavaScript 和周边的打包工具)。

从另外一个角度来看,Webpack 从一个“打包工具”,发展成现在开发者眼中对整个前端项目的“构建系统”,表面上似乎只是称呼发生了变化,但是这背后却透露出来一个信号:模块化思想是非常伟大的,伟大到可以帮你“统治”前端整个项目。这也足以见得模块化思想背后还有很多值得我们思考的内容。

总的来说,我们可以把 Webpack 看作现代化前端应用的“管家”,这个“管家”所践行的核心理论就是“模块化”,也就是说 Webpack 以模块化思想为核心,帮助开发者更好的管理整个前端工程

# 第 02 讲:如何使用 Webpack 实现模块化打包?

# webpack 配置智能提示

默认 VSCode 并不知道 Webpack 配置对象的类型,我们通过 import 的方式导入 Webpack 模块中的 Configuration 类型,然后根据类型注释的方式将变量标注为这个类型,这样我们在编写这个对象的内部结构时就可以有正确的智能提示了,具体代码如下所示:

需要注意的是:我们添加的 import 语句只是为了导入 Webpack 配置对象的类型,这样做的目的是为了标注 config 对象的类型,从而实现智能提示。在配置完成后一定要记得注释掉这段辅助代码,因为在 Node.js 环境中默认还不支持 import 语句,如果执行这段代码会出现错误

没有智能提示的效果,如下所示

加上类型标注实现智能提示的效果,如下所示:

使用 import 语句导入 Configuration 类型的方式固然好理解,但是在不同的环境中还是会有各种各样的问题,例如我们这里在 Node.js 环境中,就必须要额外注释掉这个导入类型的语句,才能正常工作。

所以我一般的做法是直接在类型注释中使用 import 动态导入类型,具体代码如下:

这种方式同样也可以实现载入类型,而且相比于在代码中通过 import 语句导入类型更为方便,也更为合理。

不过需要注意一点,这种导入类型的方式并不是 ES Modules 中的 Dynamic Imports,而是 TypeScript 中提供特性。虽然我们这里只是一个 JavaScript 文件,但是在 VSCode 中的类型系统都是基于 TypeScript 的,所以可以直接按照这种方式使用,详细信息你可以参考这种 import-types 的文档

其次,这种 @type 类型注释的方式是基于 JSDoc 实现的。JSDoc 中类型注释的用法还有很多,详细可以参考 官方文档中对 @type 标签的介绍

# Webpack 工作模式

Webpack 4 新增了一个工作模式的用法,这种用法大大简化了 Webpack 配置的复杂程度。你可以把它理解为针对不同环境的几组预设配置:

  • production 模式下,启动内置优化插件,自动优化打包结果,打包速度偏慢;

  • development 模式下,自动优化打包速度,添加一些调试过程中的辅助插件;

  • none 模式下,运行最原始的打包,不做任何额外处理。

针对工作模式的选项,如果你没有配置一个明确的值,打包过程中命令行终端会打印一个对应的配置警告。在这种情况下 Webpack 将默认使用 production 模式去工作。

production 模式下 Webpack 内部会自动启动一些优化插件,例如,自动压缩打包后的代码。这对实际生产环境是非常友好的,但是打包的结果就无法阅读了。

修改 Webpack 工作模式的方式有两种:

  • 通过 CLI --mode 参数传入;

  • 通过配置文件设置 mode 属性。

上述三种 Webpack 工作模式的详细差异我们不再赘述了,你可以在官方文档中查看:https://webpack.js.org/configuration/mode/ (opens new window)

# 打包结果运行原理

最后,我们来一起学习 Webpack 打包后生成的 bundle.js 文件,深入了解 Webpack 是如何把这些模块合并到一起,而且还能正常工作的。

为了更好的理解打包后的代码,我们先将 Webpack 工作模式设置为 none,这样 Webpack 就会按照最原始的状态进行打包,所得到的结果更容易理解和阅读。

按照 none 模式打包完成后,我们打开最终生成的 bundle.js 文件,如下图所示:

我们可以先把代码全部折叠起来,以便于了解整体的结构,如下图所示:

TIPS: -VSCode 中折叠代码的快捷键是 Ctrl + K,Ctrl + 0 (macOS:Command + K,Command + 0)

整体生成的代码其实就是一个立即执行函数,这个函数是 Webpack 工作入口(webpackBootstrap),它接收一个 modules 参数,调用时传入了一个数组。

展开这个数组,里面的元素均是参数列表相同的函数。这里的函数对应的就是我们源代码中的模块,也就是说每个模块最终被包裹到了这样一个函数中,从而实现模块私有作用域,如下图所示:

我们再来展开 Webpack 工作入口函数,如下图所示:

这个函数内部并不复杂,而且注释也很清晰,最开始定义了一个 installedModules 对象用于存放或者缓存加载过的模块。紧接着定义了一个 require 函数,顾名思义,这个函数是用来加载模块的。再往后就是在 require 函数上挂载了一些其他的数据和工具函数,这些暂时不用关心。

这个函数执行到最后调用了 require 函数,传入的模块 id 为 0,开始加载模块。模块 id 实际上就是模块数组的元素下标,也就是说这里开始加载源代码中所谓的入口模块,如下图所示:

为了更好的理解 bundle.js 的执行过程,你可以把它运行到浏览器中,然后通过 Chrome 的 Devtools 单步调试一下

# 总结

最后我来总结一下本课时的重点,你也可以通过这几个重点反思一下掌握与否:

1.Webpack 是如何满足模块化打包需求的。

2.Webpack 打包的配置方式以及一个可以实现配置文件智能提示的小技巧。

3.Webpack 工作模式特性的作用。

4.通过 Webpack 打包后的结果是如何运行起来的?

# 第 03 讲:如何通过 Loader 实现特殊资源加载?

首先,我们尝试通过 Webpack 打包项目中的一个 CSS 文件,由此开始探索 Webpack 是如何加载资源模块的?

在下面这个案例中,我们在项目的 src 目录下添加一个普通的样式文件 main.css,具体结构和样式代码如下所示:

 └─ 03-webpack-loader ························ sample root dir
    ├── src ·································· source dir
+   │   └── main.css ························· main styles
    ├── package.json ························· package file
    └── webpack.config.js ···················· webpack config file
/* ./src/main.css */
body {
  margin: 0 auto;
  padding: 0 20px;
  max-width: 800px;
  background: #f4f8fb;
}

然后将 Webpack 配置中的入口文件路径指定为 main.css 的文件路径,让 Webpack 直接打包 CSS 资源文件,具体配置如下所示:

module.exports = {
  // 样式文件路径
  entry: "./src/main.css",
  output: {
    filename: "bundle.js",
  },
};

你可能会好奇:Webpack 的打包入口不是应该是一个 JS 文件吗?为什么这里配置成了一个 CSS 文件呢?

其实 Webpack 并没有强制要求我们必须以 JS 文件作为打包入口,只是在绝大多数情况下,我们会用 JS 文件作为打包入口,因为 JS 文件才是程序的逻辑入口,以 JS 文件作为入口相对更合理。

那么,我们这里为什么要使用 CSS 文件作为入口呢?其实就是单纯地为了尝试使用 Webpack 直接去打包 CSS 文件,关于同时打包 JS 和 CSS 的操作,待会儿会详细介绍。

配置完成过后回到命令行终端再次运行 Webpack 打包命令,此时你会发现命令行报出了一个模块解析错误,如下所示:

错误信息大体的意思是说,在解析模块过程中遇到了非法字符,而且错误出现的位置就是在我们的 CSS 文件中。

出现这个错误的原因是因为 Webpack 内部默认只能够处理 JS 模块代码,也就是说在打包过程中,它默认把所有遇到的文件都当作 JavaScript 代码进行解析,但是此处我们让 Webpack 处理的是 CSS 代码,而 CSS 代码是不符合 JavaScript 语法的,所以自然会报出模块解析错误。

为了佐证 Webpack 默认只能够按照 JavaScript 语法解析模块,你可以尝试将 main.css 文件中的代码修改为一段 JavaScript 代码,然后重新运行 Webpack 打包来看一下结果。具体操作如下:

    /* ./src/main.css */
  console.log('This is a style sheet.')
  /* 只是为了证明 Webpack 默认按照 JS 语法解析模块 */

注意:这里在 CSS 中编写 JS 代码只是为了证实我们的观点,并不是真的要这样使用。

我们再次回到前面提到的错误描述中,如下所示:

这里有一个非常重要的提示:You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. (我们需要用适当的加载器来处理这种文件类型,而当前并没有配置一个可以用来处理此文件的加载器)。

根据这个错误说明,我们发现 Webpack 是用 Loader(加载器)来处理每个模块的,而内部默认的 Loader 只能处理 JS 模块,如果需要加载其他类型的模块就需要配置不同的 Loader。这也就引出了我们今天的主角:Loader。

加载器的使用方式

需要的是一个可以加载 CSS 模块的 Loader,最常用到的是 css-loader。我们需要通过 npm 先去安装这个 Loader,然后在配置文件中添加对应的配置,具体操作和配置如下所示:

在配置对象的 module 属性中添加一个 rules 数组。这个数组就是我们针对资源模块的加载规则配置,其中的每个规则对象都需要设置两个属性:

  • 首先是 test 属性,它是一个正则表达式,用来匹配打包过程中所遇到文件路径,这里我们是以 .css 结尾;

  • 然后是 use 属性,它用来指定匹配到的文件需要使用的 loader,这里用到的是 css-loader。

配置完成过后,我们回到命令行终端重新运行打包命令,打包过程就不会再出现错误了,因为这时 CSS 文件会交给 css-loader 处理过后再由 Webpack 打包。

样式模块加载的问题

此时,如果你尝试在页面中使用这里输出的 bundle.js 文件,你会发现刚刚的这个 main.css 模块并没有工作。

如果你之前有些经验,可能知道这个问题的解法,其实很简单,只需要再额外添加一个 style-loader,样式就可以正常工作了。

不过只有解法没有原因不是我们的风格。下面我们来分析产生这个问题的真正原因,首先,我们找到刚刚生成的 bundle.js 文件,因为这个文件是 Webpack 打包后的结果,所有的模块都应该在这个文件中出现。

由于默认打包入口在 Webpack 输出的结果中就是第一个模块,所以我们只需要看第一个模块目前是什么样的,如下图所示:

仔细阅读这个文件,你会发现 css-loader 的作用是将 CSS 模块转换为一个 JS 模块,具体的实现方法是将我们的 CSS 代码 push 到一个数组中,这个数组是由 css-loader 内部的一个模块提供的,但是整个过程并没有任何地方使用到了这个数组。

因此这里样式没有生效的原因是: css-loader 只会把 CSS 模块加载到 JS 代码中,而并不会使用这个模块。

所以这里我们还需要在 css-loader 的基础上再使用一个 style-loader,把 css-loader 转换后的结果通过 style 标签追加到页面上。

安装完 style-loader 之后,我们将配置文件中的 use 属性修改为一个数组,将 style-loader 也放进去。这里需要注意的是,一旦配置多个 Loader,执行顺序是从后往前执行的,所以这里一定要将 css-loader 放在最后,因为必须要 css-loader 先把 CSS 代码转换为 JS 模块,才可以正常打包,具体配置如下:

配置完成之后,再次回到命令行重新打包,此时 bundle.js 文件中会额外多出两个模块。篇幅的关系,我们这里不再仔细解读。style-loader 的作用总结一句话就是,将 css-loader 中所加载到的所有样式模块,通过创建 style 标签的方式添加到页面上

以上就是我们对 Loader 使用的一些探索,Loader 是 Webpack 实现整个前端模块化的核心。因为只有通过不同的 Loader,Webpack 才可以实现任何类型资源的加载。

通过 JS 加载资源模块

正如刚刚所提到的,一般 Webpack 打包的入口还是 JavaScript。因为从某种程度上来说,打包入口就是应用的运行入口,而目前前端应用中的业务是由 JS 驱动的,所以更合理的做法还是把 JS 文件作为打包的入口,然后在 JS 代码中通过 import 语句去加载 CSS 文件。

即便是通过 JS 代码去加载的 CSS 模块,css-loader 和 style-loader 仍然可以正常工作。因为 Webpack 在打包过程中会循环遍历每个模块,然后根据配置将每个遇到的模块交给对应的 Loader 去处理,最后再将处理完的结果打包到一起。

为什么要在 JS 中加载其他资源

说到这里,你可能会产生疑惑:Webpack 为什么要在 JS 中载入 CSS 呢?不是应该将样式和行为分离么?

其实 Webpack 不仅是建议我们在 JavaScript 中引入 CSS,还会建议我们在代码中引入当前业务所需要的任意资源文件。因为真正需要这个资源的并不是整个应用,而是你此时正在编写的代码。这就是 Webpack 的设计哲学。

可能你乍一想好像不太容易理解,那你可以做一个假设:假设我们在开发页面上的某个局部功能时,需要用到一个样式模块和一个图片文件。如果你还是将这些资源文件单独引入到 HTML 中,然后再到 JS 中添加对应的逻辑代码。试想一下,如果后期这个局部功能不用了,你就需要同时删除 JS 中的代码和 HTML 中的资源文件引入,也就是同时需要维护这两条线。而如果你遵照 Webpack 的这种设计,所有资源的加载都是由 JS 代码控制,后期也就只需要维护 JS 代码这一条线了。

所以说,通过 JavaScript 代码去引入资源文件,或者说是建立 JavaScript 和资源文件的依赖关系,具有明显的优势。因为 JavaScript 代码本身负责完成整个应用的业务功能,放大来说就是驱动了整个前端应用,而 JavaScript 代码在实现业务功能的过程中需要用到样式、图片等资源文件。如果建立这种依赖关系:

  • 一来逻辑上比较合理,因为 JS 确实需要这些资源文件配合才能实现整体功能;

  • 二来配合 Webpack 这类工具的打包,能确保在上线时,资源不会缺失,而且都是必要的。

名称 链接
file-loader https://webpack.js.org/loaders/file-loader
url-loader https://webpack.js.org/loaders/url-loader
babel-loader https://webpack.js.org/loaders/babel-loader
style-loader https://webpack.js.org/loaders/style-loader
css-loader https://webpack.js.org/loaders/css-loader
sass-loader https://webpack.js.org/loaders/sass-loader
postcss-loader https://webpack.js.org/loaders/postcss-loader
eslint-loader https://github.com/webpack-contrib/eslint-loader
vue-loader https://github.com/vuejs/vue-loader

# 开发一个 Loader

这里我的需求是开发一个可以加载 markdown 文件的加载器,以便可以在代码中直接导入 md 文件。我们都应该知道 markdown 一般是需要转换为 html 之后再呈现到页面上的,所以我希望导入 md 文件后,直接得到 markdown 转换后的 html 字符串,如下图所示:

由于这里需要直观地演示,我就不再单独创建一个 npm 模块,而是就直接在项目根目录下创建一个 markdown-loader.js 文件,完成后你可以把这个模块发布到 npm 上作为一个独立的模块使用。

项目结构与核心代码如下所示:

每个 Webpack 的 Loader 都需要导出一个函数,这个函数就是我们这个 Loader 对资源的处理过程,它的输入就是加载到的资源文件内容,输出就是我们加工后的结果。我们通过 source 参数接收输入,通过返回值输出。这里我们先尝试打印一下 source,然后在函数的内部直接返回一个字符串 hello loader ~,具体代码如下所示:

完成以后,我们回到 Webpack 配置文件中添加一个加载器规则,这里匹配到的扩展名是 .md,使用的加载器就是我们刚刚编写的这个 markdown-loader.js 模块,具体代码如下所示:

TIPS:这里的 use 中不仅可以使用模块名称,还可以使用模块文件路径,这点与 Node 中的 require 函数是一样的。

配置完成后,我们再次打开命令行终端运行打包命令,如下图所示:

打包过程中命令行确实打印出来了我们所导入的 Markdown 文件内容,这就意味着 Loader 函数的参数确实是文件的内容。

但同时也报出了一个解析错误,说的是: You may need an additional loader to handle the result of these loaders.(我们可能还需要一个额外的加载器来处理当前加载器的结果)。

那这究竟是为什么呢?其实 Webpack 加载资源文件的过程类似于一个工作管道,你可以在这个过程中依次使用多个 Loader,但是最终这个管道结束过后的结果必须是一段标准的 JS 代码字符串。

所以我们这里才会出现上面提到的错误提示,那解决的办法也就很明显了:

  • 直接在这个 Loader 的最后返回一段 JS 代码字符串;

  • 再找一个合适的加载器,在后面接着处理我们这里得到的结果。

先来尝试第一种办法。回到 markdown-loader 中,我们将返回的字符串内容修改为 console.log('hello loader~'),然后再次运行打包,此时 Webpack 就不再会报错了,代码如下所示:

那此时打包的结果是怎样的呢?我们打开输出的 bundle.js,找到最后一个模块(因为这个 md 文件是后引入的),如下图所示:

这个模块里面非常简单,就是把我们刚刚返回的字符串直接拼接到了该模块中。这也解释了刚刚 Loader 管道最后必须返回 JS 代码的原因,因为如果随便返回一个内容,放到这里语法就不通过了。

实现 Loader 的逻辑

了解了 Loader 大致的工作机制过后,我们再回到 markdown-loader.js 中,接着完成我的需求。这里需要安装一个能够将 Markdown 解析为 HTML 的模块,叫作 marked。

安装完成后,我们在 markdown-loader.js 中导入这个模块,然后使用这个模块去解析我们的 source。这里解析完的结果就是一段 HTML 字符串,如果我们直接返回的话同样会面临 Webpack 无法解析模块的问题,正确的做法是把这段 HTML 字符串拼接为一段 JS 代码。

此时我们希望返回的代码是通过 module.exports 导出这段 HTML 字符串,这样外界导入模块时就可以接收到这个 HTML 字符串了。如果只是简单地拼接,那 HTML 中的换行和引号就都可能会造成语法错误,所以我这里使用了一个小技巧,具体操作如下所示:

先通过 JSON.stringify() 将字段字符串转换为标准的 JSON 字符串,然后再参与拼接,这样就不会有问题了。

我们回到命令行再次运行打包,打包后的结果就是我们所需要的了。

除了 module.exports 这种方式,Webpack 还允许我们在返回的代码中使用 ES Modules 的方式导出,例如,我们这里将 module.exports 修改为 export default,然后运行打包,结果同样是可以的,Webpack 内部会自动转换 ES Modules 代码。

多个 Loader 的配合

我们还可以尝试一下刚刚说的第二种思路,就是在我们这个 markdown-loader 中直接返回 HTML 字符串,然后交给下一个 Loader 处理。这就涉及多个 Loader 相互配合工作的情况了。

我们回到代码中,这里我们直接返回 marked 解析后的 HTML,代码如下所示:

然后我们再安装一个处理 HTML 的 Loader,叫作 html-loader,代码如下所示:

安装完成过后回到配置文件,这里同样把 use 属性修改为一个数组,以便依次使用多个 Loader。不过同样需要注意,这里的执行顺序是从后往前,也就是说我们应该把先执行的 markdown-loader 放在后面,html-loader 放在前面。

完成以后我们回到命令行终端再次打包,这里的打包结果仍然是可以的。

至此,我们就完成了这个 markdown-loader 模块,其实整个过程重点在于 Loader 的工作原理和实现方式。

# 写在最后

总体来说,Loader 机制是 Webpack 最核心的机制,因为正是有了 Loader 机制,Webpack 才能足以支撑整个前端项目模块化的大梁,实现通过 Webpack 去加载任何你想要加载的资源。

# 第 04 讲:如何利用插件机制横向扩展 Webpack 的构建能力?

上回说到 Webpack 的 Loader 机制,今天我要跟你分享 Webpack 的另外一个重要的核心特性:插件机制。

Webpack 插件机制的目的是为了增强 Webpack 在项目自动化构建方面的能力。通过上一讲的介绍你应该知道,Loader 就是负责完成项目中各种各样资源模块的加载,从而实现整体项目的模块化,而 Plugin 则是用来解决项目中除了资源模块打包以外的其他自动化工作,所以说 Plugin 的能力范围更广,用途自然也就更多。

我在这里先介绍几个插件最常见的应用场景:

  • 实现自动在打包之前清除 dist 目录(上次的打包结果);

  • 自动生成应用所需要的 HTML 文件;

  • 根据不同环境为代码注入类似 API 地址这种可能变化的部分;

  • 拷贝不需要参与打包的资源文件到输出目录;

  • 压缩 Webpack 打包完成后输出的文件;

  • 自动发布打包结果到服务器实现自动部署。

总之,有了 Plugin 的 Webpack 几乎“无所不能”。借助插件,我们就可以轻松实现前端工程化中绝大多数经常用到的功能,这也正是很多初学者会认为 “Webpack 就是前端工程化,或者前端工程化就是 Webpack” 的原因。

那接下来我们通过一些常用插件的使用,具体聊聊 Webpack 的插件机制,最后再通过开发一个自己的插件,去理解插件的工作原理。

通过之前的尝试,你可能已经发现,Webpack 每次打包的结果都是直接覆盖到 dist 目录。而在打包之前,dist 目录中就可能已经存入了一些在上一次打包操作时遗留的文件,当我们再次打包时,只能覆盖掉同名文件,而那些已经移除的资源文件就会一直累积在里面,最终导致部署上线时出现多余文件,这显然非常不合理。

更为合理的做法就是在每次完整打包之前,自动清理 dist 目录,这样每次打包过后,dist 目录中就只会存在那些必要的文件。

$ npm install clean-webpack-plugin --save-dev

安装过后,我们回到 Webpack 的配置文件中,然后导入 clean-webpack-plugin 插件,这个插件模块导出了一个叫作 CleanWebpackPlugin 的成员,我们先把它解构出来,具体代码如下。

const { CleanWebpackPlugin } = require("clean-webpack-plugin");

回到配置对象中,添加一个 plugins 属性,这个属性就是专门用来配置插件的地方,它是一个数组,添加一个插件就是在这个数组中添加一个元素。

绝大多数插件模块导出的都是一个类型,我们这里的 CleanWebpackPlugin 也不例外,使用它,就是通过这个类型创建一个实例,放入 plugins 数组中,具体代码如下:

完成以后我们来测试一下 clean-webpack-plugin 插件的效果。回到命令行终端,再次运行 Webpack 打包,此时之前的打包结果就不会存在了,dist 目录中存放的就都是我们本次打包的结果。

在这里,我只是希望通过这个非常简单的插件带你体验一下 Webpack 插件的使用。一般来说,当我们有了某个自动化的需求过后,可以先去找到一个合适的插件,然后安装这个插件,最后将它配置到 Webpack 配置对象的 plugins 数组中,这个过程唯一有可能不一样的地方就是,有的插件可能需要有一些配置参数。

用于生成 HTML 的插件

除了自动清理 dist 目录,我们还有一个非常常见的需求,就是自动生成使用打包结果的 HTML,所谓使用打包结果指的是在 HTML 中自动注入 Webpack 打包生成的 bundle。

在使用接下来这个插件之前,我们的 HTML 文件一般都是通过硬编码的方式,单独存放在项目根目录下的,这种方式有两个问题: 项目发布时,我们需要同时发布根目录下的 HTML 文件和 dist 目录中所有的打包结果,非常麻烦,而且上线过后还要确保 HTML 代码中的资源文件路径是正确的。 如果打包结果输出的目录或者文件名称发生变化,那 HTML 代码中所对应的 script 标签也需要我们手动修改路径。

解决这两个问题最好的办法就是让 Webpack 在打包的同时,自动生成对应的 HTML 文件,让 HTML 文件也参与到整个项目的构建过程。这样的话,在构建过程中,Webpack 就可以自动将打包的 bundle 文件引入到页面中。

相比于之前写死 HTML 文件的方式,自动生成 HTML 的优势在于:

  • HTML 也输出到 dist 目录中了,上线时我们只需要把 dist 目录发布出去就可以了;

  • HTML 中的 script 标签是自动引入的,所以可以确保资源文件的路径是正常的。

具体的实现方式就需要借助于 html-webpack-plugin 插件来实现,这个插件也是一个第三方的 npm 模块,我们这里同样需要单独安装这个模块,具体操作如下:

npm install html-webpack-plugin --save-dev

安装完成过后,回到配置文件,载入这个模块,不同于 clean-webpack-plugin,html-webpack-plugin 插件默认导出的就是插件类型,不需要再解构内部成员,具体如下:

const HtmlWebpackPlugin = require("html-webpack-plugin");

有了这个类型过后,回到配置对象的 plugins 属性中,同样需要添加一下这个类型的实例对象,完成这个插件的使用,具体配置代码如下:

最后我们回到命令行终端,再次运行打包命令,此时打包过程中就会自动生成一个 index.html 文件到 dist 目录。我们找到这个文件,可以看到文件中的内容就是一段使用了 bundle.js 的空白 HTML,具体结果如下:

至此,Webpack 就可以动态生成应用所需的 HTML 文件了,但是这里仍然存在一些需要改进的地方:

  • 对于生成的 HTML 文件,页面 title 必须要修改;

  • 很多时候还需要我们自定义页面的一些 meta 标签和一些基础的 DOM 结构。

也就是说,还需要我们能够充分自定义这个插件最终输出的 HTML 文件。

如果只是简单的自定义,我们可以通过修改 HtmlWebpackPlugin 的参数来实现。

我们回到 Webpack 的配置文件中,这里我们给 HtmlWebpackPlugin 构造函数传入一个对象参数,用于指定配置选项。其中,title 属性设置的是 HTML 的标题,我们把它设置为 Webpack Plugin Simple。meta 属性需要以对象的形式设置页面中的元数据标签,这里我们尝试为页面添加一个 viewport 设置,具体代码如下:

完成以后回到命令行终端,再次打包,然后我们再来看一下生成的 HTML 文件,此时这里的 title 和 meta 标签就会根据配置生成,具体结果如下:

如果需要对 HTML 进行大量的自定义,更好的做法是在源代码中添加一个用于生成 HTML 的模板,然后让 html-webpack-plugin 插件根据这个模板去生成页面文件。

我们这里在 src 目录下新建一个 index.html 文件作为 HTML 文件的模板,然后根据我们的需要在这个文件中添加相应的元素。对于模板中动态的内容,可以使用 Lodash 模板语法输出,模板中可以通过 htmlWebpackPlugin.options 访问这个插件的配置数据,例如我们这里输出配置中的 title 属性,具体代码如下:

有了模板文件过后,回到配置文件中,我们通过 HtmlWebpackPlugin 的 template 属性指定所使用的模板,具体配置如下:

完成以后我们回到命令行终端,运行打包命令,然后再来看一下生成的 HTML 文件,此时 HTML 中就都是根据模板生成的内容了,具体结果如下:

至此,你应该了解了如何通过 html-webpack-plugin 自定义输出 HTML 文件内容。

关于 html-webpack-plugin 插件,除了自定义输出文件的内容,同时输出多个 HTML 文件也是一个非常常见的需求,除非我们的应用是一个单页应用程序,否则一定需要输出多个 HTML 文件。

如果需要同时输出多个 HTML 文件,其实也非常简单,我们回到配置文件中,这里通过 HtmlWebpackPlugin 创建的对象就是用于生成 index.html 的,那我们完全可以再创建一个新的实例对象,用于创建额外的 HTML 文件。

例如,这里我们再来添加一个 HtmlWebpackPlugin 实例用于创建一个 about.html 的页面文件,我们需要通过 filename 指定输出文件名,这个属性的默认值是 index.html,我们把它设置为 about.html,具体配置如下:

完成以后我们再次回到命令行终端,运行打包命令,然后我们展开 dist 目录,此时 dist 目录中就同时生成了 index.html 和 about.html 两个页面文件。

根据这个尝试我们就应该知道,如果需要创建多个页面,就需要在插件列表中加入多个 HtmlWebpackPlugin 的实例对象,让每个对象负责一个页面文件的生成。

当然了,对于同时输出多个 HTML,一般我们还会配合 Webpack 多入口打包的用法,这样就可以让不同的 HTML 使用不同的打包结果。不过关于多入口打包的用法不在本课时的讨论范畴内,我们后面再进行介绍。

用于复制文件的插件

在我们的项目中一般还有一些不需要参与构建的静态文件,那它们最终也需要发布到线上,例如网站的 favicon、robots.txt 等。

一般我们建议,把这类文件统一放在项目根目录下的 public 或者 static 目录中,我们希望 Webpack 在打包时一并将这个目录下所有的文件复制到输出目录。

对于这种需求,我们可以使用 copy-webpack-plugin 插件来帮我们实现。

同理,我们需要先安装一下 copy-webpack-plugin 插件,安装完成过后,回到配置文件中,导入这个插件类型。然后同样在 plugins 属性中添加一个这个类型的实例,具体代码如下:

这个插件类型的构造函数需要我们传入一个字符串数组,用于指定需要拷贝的文件路径。它可以是一个通配符,也可以是一个目录或者文件的相对路径。我们这里传入的是 public 目录,表示将这个目录下所有文件全部拷贝到输出目录中。当然了,你还可以在这个数组中继续添加其它路径,这样它在工作时可以同时拷贝。

配置完成以后回到命令行终端,再次运行 Webpack,此时 public 目录下的文件就会同时拷贝到输出目录中。

# 开发一个插件

通过前面的介绍,我们知道相比于 Loader,插件的能力范围更宽,因为 Loader 只是在模块的加载环节工作,而插件的作用范围几乎可以触及 Webpack 工作的每一个环节

那么,这种插件机制是如何实现的呢?

其实说起来也非常简单,Webpack 的插件机制就是我们在软件开发中最常见的钩子机制。

钩子机制也特别容易理解,它有点类似于 Web 中的事件。在 Webpack 整个工作过程会有很多环节,为了便于插件的扩展,Webpack 几乎在每一个环节都埋下了一个钩子。这样我们在开发插件的时候,通过往这些不同节点上挂载不同的任务,就可以轻松扩展 Webpack 的能力。

具体有哪些预先定义好的钩子,我们可以参考官方文档的 API:

  • Compiler Hooks

  • Compilation Hooks

  • JavascriptParser Hooks

接下来,我们来开发一个自己的插件,看看具体如何往这些钩子上挂载任务。

这里我的需求是,希望我们开发的这个插件能够自动清除 Webpack 打包结果中的注释,这样一来,我们的 bundle.js 将更容易阅读,如下图所示:

那这里我们同样在项目根目录下添加一个单独的 JS 文件。

└─ 04-webpack-plugins ······················· sample root dir
    ├── public ······························· static dir
    │   └── favicon.ico ······················ static file
    ├── src ·································· source dir
    │   └── main.js ·························· entry module
    ├── package.json ························· package file
+   ├── remove-comments-plugin.js ············ my plugin
    └── webpack.config.js ···················· webpack config file

Webpack 要求我们的插件必须是一个函数或者是一个包含 apply 方法的对象,一般我们都会定义一个类型,在这个类型中定义 apply 方法。然后在使用时,再通过这个类型来创建一个实例对象去使用这个插件。

所以我们这里定义一个 RemoveCommentsPlugin 类型,然后在这个类型中定义一个 apply 方法,这个方法会在 Webpack 启动时被调用,它接收一个 compiler 对象参数,这个对象是 Webpack 工作过程中最核心的对象,里面包含了我们此次构建的所有配置信息,我们就是通过这个对象去注册钩子函数,具体代码如下:

20211109172838

知道这些过后,还需要明确我们这个任务的执行时机,也就是到底应该把这个任务挂载到哪个钩子上。

我们的需求是删除 bundle.js 中的注释,也就是说只有当 Webpack 需要生成的 bundle.js 文件内容明确过后才可能实施。

那根据 API 文档中的介绍,我们找到一个叫作 emit 的钩子,这个钩子会在 Webpack 即将向输出目录输出文件时执行,非常符合我们的需求。

我们回到代码中,通过 compiler 对象的 hooks 属性访问到 emit 钩子,再通过 tap 方法注册一个钩子函数,这个方法接收两个参数:

  • 第一个是插件的名称,我们这里的插件名称是 RemoveCommentsPlugin;

  • 第二个是要挂载到这个钩子上的函数;

根据 API 文档中的提示,这里我们在这个函数中接收一个 compilation 对象参数,这个对象可以理解为此次运行打包的上下文,所有打包过程中产生的结果,都会放到这个对象中。

我们可以使用这个对象中的 assets 属性获取即将写入输出目录的资源文件信息,它是一个对象,我们这里通过 for in 去遍历这个对象,其中键就是每个文件的名称,我们尝试把它打印出来,具体代码如下:

完成以后,我们将这个插件应用到 Webpack 的配置中,然后回到命令行重新打包,此时打包过程就会打印我们输出的文件名称,代码如下:

我们再回到代码中,来打印一下每个资源文件的内容,文件内容需要通过遍历的值对象中的 source 方法获取,具体代码如下:

回到命令行,再次打包,此时输出的文件内容也可以正常被打印。

能够拿到文件名和文件内容后,我们回到代码中。这里需要先判断文件名是不是以 .js 结尾,因为 Webpack 打包还有可能输出别的文件,而我们的需求只需要处理 JS 文件。

那如果是 JS 文件,我们将文件内容得到,再通过正则替换的方式移除掉代码中的注释,最后覆盖掉 compilation.assets 中对应的对象,在覆盖的对象中,我们同样暴露一个 source 方法用来返回新的内容。另外还需要再暴露一个 size 方法,用来返回内容大小,这是 Webpack 内部要求的格式,具体代码如下:

完成以后回到命令行终端,再次打包,打包完成过后,我们再来看一下 bundle.js,此时 bundle.js 中每行开头的注释就都被移除了。

以上就是我们实现一个移除注释插件的过程,通过这个过程我们了解了:插件都是通过往 Webpack 生命周期的钩子中挂载任务函数实现的。

# 第 05 讲:探索 Webpack 运行机制与核心工作原理

那这里我们先来快速理解一下 Webpack 打包的核心工作过程。我们以一个普通的前端项目为例,项目中一般都会散落着各种各样的代码及资源文件,如下图所示:

比如 JS、CSS、图片、字体等,这些文件在 Webpack 的思想中都属于当前项目中的一个模块。Webpack 可以通过打包,将它们最终聚集到一起。Webpack 在整个打包的过程中:

  • 通过 Loader 处理特殊类型资源的加载,例如加载样式、图片;
  • 通过 Plugin 实现各种自动化的构建任务,例如自动压缩、自动发布;

具体来看打包的过程,Webpack 启动后,会根据我们的配置,找到项目中的某个指定文件(一般这个文件都会是一个 JS 文件)作为入口。然后顺着入口文件中的代码,根据代码中出现的 import(ES Modules)或者是 require(CommonJS)之类的语句,解析推断出来这个文件所依赖的资源模块,然后再分别去解析每个资源模块的依赖,周而复始,最后形成整个项目中所有用到的文件之间的依赖关系树,下面这个动画生动的演示了这个过程:

有了这个依赖关系树过后, Webpack 会遍历(递归)这个依赖树,找到每个节点对应的资源文件,然后根据配置选项中的 Loader 配置,交给对应的 Loader 去加载这个模块,最后将加载的结果放入 bundle.js(打包结果)中,从而实现整个项目的打包,具体操作可以参考下面的动画:

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

整个打包过程中,Loader 机制起了很重要的作用,因为如果没有 Loader 的话,Webpack 就无法实现各种各样类型的资源文件加载,那 Webpack 也就只能算是一个用来合并 JS 模块代码的工具了。

至于自定义插件机制,在 04 课时中我们也详细介绍过了,它并不会影响 Webpack 的核心工作过程,只是 Webpack 为了提供一个强大的扩展能力,它为整个工作过程的每个环节都预制了一个钩子,也就是说我们可以通过插件往 Webpack 工作过程的任意环节植入一些自定义的任务,从而扩展 Webpack 打包功能以外的能力。

至此,你就已经了解到了 Webpack 的核心工作过程。

# 工作原理剖析

对于一般的应用开发过程来说,了解以上这些内容基本上就足以应对了,但如果你想了解 Webpack 整个工作过程的细节,那么你就需要更深入地了解刚刚说到的每一个环节,它们落实到代码层面到底做了些什么,或者说是如何实现的。

在这种情况下,我们就必须有针对性的去“查阅”Webpack 的源代码了。注意这里我们强调的是“查阅”,而不是“阅读”。

至于为什么要扣这个字眼,我们先卖个关子,今天的最后再来解释。

既然是“查阅”,我们就必须要有个线索,否则挨个去看每个源代码,就是无意义的阅读。这里我们的思路就是顺着前面刚刚介绍的 Webpack 打包的核心工作过程,提炼出关键环节,然后再有针对性地去找每个环节的实现方式就可以了。

这里我们先提炼出 Webpack 核心工作过程中的关键环节,明确“查阅”源码的思路:

1.Webpack CLI 启动打包流程; 2.载入 Webpack 核心模块,创建 Compiler 对象; 3.使用 Compiler 对象开始编译整个项目; 4.从入口文件开始,解析模块依赖,形成依赖关系树; 5.递归依赖树,将每个模块交给对应的 Loader 处理; 6.合并 Loader 处理完的结果,将打包结果输出到 dist 目录;

接下来我们将对上述关键环节中的部分内容进行详细介绍。

# 一、Webpack CLI

从 Webpack 4 开始 Webpack 的 CLI 部分就被单独抽到了 webpack-cli 模块中,目的是为了增强 Webpack 本身的灵活性。所以这一部分的内容我们需要找到 webpack-cli 所对应的源码。

我们这里分析的是 v3.3.11 版本的 webpack-cli,你可以参考该版本的源码固定链接。

P.S. 目前 webpack-cli 官方仓库默认分支下的代码不是 3.x 版本的。

Webpack CLI 的作用就是将 CLI 参数和 Webpack 配置文件中的配置整合,得到一个完整的配置对象。

这部分操作在 webpack-cli 的入口文件 bin/cli.js 中,这个文件中内容比较多,我们这里只截取部分核心代码,你可以对照截图中的行号找到源代码中对应的位置。

首先,Webpack CLI 会通过 yargs 模块解析 CLI 参数,所谓 CLI 参数指的就是我们在运行 webpack 命令时通过命令行传入的参数,例如 --mode=production,具体位置如下:

紧接着后面,调用了 bin/utils/convert-argv.js 模块,将得到的命令行参数转换为 Webpack 的配置选项对象,具体操作如下:

在 convert-argv.js 工作过程中,首先为传递过来的命令行参数设置了默认值,然后判断了命令行参数中是否指定了一个具体的配置文件路径,如果指定了就加载指定配置文件,反之则需要根据默认配置文件加载规则找到配置文件,具体代码如下:

找到配置文件过后,将配置文件中的配置和 CLI 参数中的配置合并,如果出现重复的情况,会优先使用 CLI 参数,最终得到一个完整的配置选项。

有了配置选项过后,开始载入 Webpack 核心模块,传入配置选项,创建 Compiler 对象,这个 Compiler 对象就是整个 Webpack 工作过程中最核心的对象了,负责完成整个项目的构建工作。

二、创建 Compiler 对象 随着 Webpack CLI 载入 Webpack 核心模块,整个执行过程就到了 Webpack 模块中,所以这一部分的代码需要回到 Webpack 模块中,我这里分析的是 v4.43.0 版本的 Webpack,可参考这个版本的源代码的固定链接。

同样,这里我们需要找到这个模块的入口文件,也就是 lib/webpack.js 文件。这个文件导出的是一个用于创建 Compiler 的函数,具体如下:

在这个函数中,首先校验了外部传递过来的 options 参数是否符合要求,紧接着判断了 options 的类型。

根据这个函数中的代码,我们发现 options 不仅仅可以是一个对象,还可以是一个数组。如果我们传入的是一个数组,那么 Webpack 内部创建的就是一个 MultiCompiler,也就是说 Webpack 应该支持同时开启多路打包,配置数组中的每一个成员就是一个独立的配置选项。而如果我们传入的是普通的对象,就会按照我们最熟悉的方式创建一个 Compiler 对象,进行单线打包。

我们顺着主线接着往下看,如下图所示:在创建了 Compiler 对象过后,Webpack 就开始注册我们配置中的每一个插件了,因为再往后 Webpack 工作过程的生命周期就要开始了,所以必须先注册,这样才能确保插件中的每一个钩子都能被命中。

三、开始构建 完成 Compiler 对象的创建过后,紧接着这里的代码开始判断配置选项中是否启用了监视模式,具体操作如下:

如果是监视模式就调用 Compiler 对象的 watch 方法,以监视模式启动构建,但这不是我们主要关心的主线。 如果不是监视模式就调用 Compiler 对象的 run 方法,开始构建整个应用。 这个 run 方法定义在 Compiler 类型中,具体文件在 webpack 模块下的 lib/Compiler.js 中,代码位置如下:

这个方法内部就是先触发了 beforeRun 和 run 两个钩子,然后最关键的是调用了当前对象的 compile 方法,真正开始编译整个项目,具体代码位置如下:

compile 方法内部主要就是创建了一个 Compilation 对象,这个对象我们在 04 课时中有提到,Compilation 字面意思是“合集”,实际上,你就可以理解为一次构建过程中的上下文对象,里面包含了这次构建中全部的资源和信息。

创建完 Compilation 对象过后,紧接着触发了一个叫作 make 的钩子,进入整个构建过程最核心的 make 阶段。

四、make 阶段 make 阶段主体的目标就是:根据 entry 配置找到入口模块,开始依次递归出所有依赖,形成依赖关系树,然后将递归到的每个模块交给不同的 Loader 处理。

由于这个阶段的调用过程并不像之前一样,直接调用某个对象的某个方法,而是采用事件触发机制,让外部监听这个 make 事件的地方开始执行,所以从这里往后的代码可能找起来会费点劲儿。

这里我简单提示一下:想要知道这个事件触发后,哪些地方会开始执行,前提是得知道哪里注册了这个叫作 make 的事件。

Webpack 的插件系统是基于官方自己的 Tapable 库实现的,我们想要知道在哪里注册了某个事件,必须要知道如何注册的事件。Tapable 的注册方式具体如下:

所以,我们只需要通过开发工具搜索源代码中的 make.tap,就应该能够找到事件注册的位置,具体操作如下:

这里搜索到了六个插件中都注册了 make 事件,这些插件实际上是前面创建 Compiler 对象的时候创建的,刚刚因为没有影响,所以我们就忽略了:

因为我们默认使用的就是单一入口打包的方式,所以这里最终会执行其中的 SingleEntryPlugin。

这个插件中调用了 Compilation 对象的 addEntry 方法,开始解析我们源代码中的入口文件,以此开始“顺藤摸瓜”式的寻找。

因为时间的关系,我在这里就不再带你继续一步一步的往后探索每一个阶段了,你可以顺着我们刚刚的思路,私下继续去尝试。

对于 make 阶段后续的流程,这里我们概括一下:

  • 1.SingleEntryPlugin 中调用了 Compilation 对象的 addEntry 方法,开始解析入口;
  • 2.addEntry 方法中又调用了 _addModuleChain 方法,将入口模块添加到模块依赖列表中;
  • 3.紧接着通过 Compilation 对象的 buildModule 方法进行模块构建;
  • 4.buildModule 方法中执行具体的 Loader,处理特殊资源加载;
  • 5.build 完成过后,通过 acorn 库生成模块代码的 AST 语法树;
  • 6.根据语法树分析这个模块是否还有依赖的模块,如果有则继续循环 build 每个依赖;
  • 7.所有依赖解析完成,build 阶段结束;
  • 8.最后合并生成需要输出的 bundle.js 写入 dist 目录。

# 写在最后

今天的内容,我算是抛砖引玉,围绕着 Webpack 打包的核心工作过程,单独通过查阅对应源码的方式,深入理解了一下 Webpack “前半程”的工作原理,其实我重点想跟你分享的是如何通过查阅源码来增强对技术理解的方式。

最后再来解释一下,为什么我要说查阅源代码而不是阅读源代码。

其实我是想说,现在市面上很多的资料动不动就跟你说:“你一定要阅读 XXX 项目的源码”。而当你真的相信了这种说法,自己花费时间去尝试阅读源码的时候,大多数结果都是感觉看不懂,即使是你耗费了很多的时间,把 XXX 项目的源码啃下来了,结果仍然感觉没有太大进步。我不敢说这种“啃源码”的方式一点意义没有,只是投入回报比太低了。

原因是,很多时候你都是为了证明你看过源码而去看的源码,这种目的就决定了,你的效率和效果都不会很好。为什么这么说呢?

因为我们阅读源代码的目的,很多时候都是为了去看某个问题在内部具体是如何实现的。这种情况下我们都是带着问题去看的源码,这种阅读源码的情况越多,积累的就更多,解决问题的能力自然就可以得到提升。

打个比方,源码就好像是牛津词典一样,你是应该没事抱着它一页一页的读,还是应该遇到不懂的单词再去查呢?答案显而易见。

所以说,学习任何东西都需要一个清晰的目标,带着问题,一边学习一边找答案,而不是漫无目的学到哪里是哪里。

# 第 06 讲:如何使用 Dev Server 提高你的本地开发效率?

# 热更新

具体的用法也非常简单,就是在启动 Webpack 时,添加一个 --watch 的 CLI 参数,这样的话,Webpack 就会以监视模式启动运行。在打包完成过后,CLI 不会立即退出,它会等待文件变化再次工作,直到我们手动结束它或是出现不可控的异常。

在 watch 模式下我们就只需专注编码,不必再去手动完成编译工作了,相比于原始手动操作的方式,有了很明显的进步。

我们还可以再开启另外一个命令行终端,同时以 HTTP 形式运行我们的应用,然后打开浏览器去预览应用。

我们可以将浏览器移至屏幕的左侧,然后将编辑器移至右侧,此时我们尝试修改源代码,保存过后,以 watch 模式工作的 Webpack 就会自动重新打包,然后我们就可以在浏览器中刷新页面查看最新的结果,具体效果如下图所示:

那此时我们的开发体验就是:修改代码 → Webpack 自动打包 → 手动刷新浏览器 → 预览运行结果。

P.S. 这里我使用的静态文件服务器是一个 npm 模块,叫作 serve。

此时距离我们的目标状态还差一点,如果浏览器能够在 Webpack 打包过后自动刷新,那我们的开发体验将会更好一些。

如果你已经了解过一个叫作 BrowserSync 的工具,你应该知道 BrowserSync 就可以帮我们实现文件变化过后浏览器自动刷新的功能。

所以,我们就可以使用 BrowserSync 工具替换 serve 工具,启动 HTTP 服务,这里还需要同时监听 dist 目录下文件的变化,具体命令如下:

启动过后,我们回到编辑器,然后尝试修改源文件,保存完成以后浏览器就会自动刷新,显示最新结果。

它的原理就是 Webpack 监视源代码变化,自动打包源代码到 dist 中,而 dist 中文件的变化又被 BrowserSync 监听了,从而实现自动编译并且自动刷新浏览器的功能,整个过程由两个工具分别监视不同的内容。

这种 watch 模式 + BrowserSync 虽然也实现了我们的需求,但是这种方法有很多弊端:

  • 操作烦琐,我们需要同时使用两个工具,那么需要了解的内容就会更多,学习成本大大提高;
  • 效率低下,因为整个过程中, Webpack 会将文件写入磁盘,BrowserSync 再进行读取。过程中涉及大量磁盘读写操作,必然会导致效率低下。

所以这只能算是“曲线救国”,并不完美,我们仍然需要继续改善。

# Webpack Dev Server

webpack-dev-server 是 Webpack 官方推出的一款开发工具,根据它的名字我们就应该知道,它提供了一个开发服务器,并且将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起。

Webpack 官方推出 webpack-dev-server 这款工具的初衷,就是为了提高开发者日常的开发效率,使用这个工具就可以解决我在开头所提出的问题。而且它是一个高度集成的工具,使用起来十分的方便。

webpack-dev-server 同样也是一个独立的 npm 模块,所以我们需要通过 npm 将 webpack-dev-server 作为项目的开发依赖安装。安装完成过后,这个模块为我们提供了一个叫作 webpack-dev-server 的 CLI 程序,我们同样可以直接通过 npx 直接去运行这个 CLI,或者把它定义到 npm scripts 中,具体操作如下:

运行 webpack-dev-server 这个命令时,它内部会启动一个 HTTP Server,为打包的结果提供静态文件服务,并且自动使用 Webpack 打包我们的应用,然后监听源代码的变化,一旦文件发生变化,它会立即重新打包,大致流程如下:

不过这里需要注意的是,webpack-dev-server 为了提高工作速率,它并没有将打包结果写入到磁盘中,而是暂时存放在内存中,内部的 HTTP Server 也是从内存中读取这些文件的。这样一来,就会减少很多不必要的磁盘读写操作,大大提高了整体的构建效率。

我们还可以为 webpack-dev-server 命令传入一个 --open 的参数,用于自动唤起浏览器打开我们的应用。打开浏览器过后,此时如果你有两块屏幕,就可以把浏览器放到另外一块屏幕上,然后体验一边编码,一边即时预览的开发环境了。

# 配置选项

Webpack 配置对象中可以有一个叫作 devServer 的属性,专门用来为 webpack-dev-server 提供配置,具体如下:

具体有哪些配置我们就不在这里一一介绍了,你可以在它的官网中找到相对应的说明文档。

接下来我们来看几个 webpack-dev-server 的常用功能。

# 静态资源访问

webpack-dev-server 默认会将构建结果和输出文件全部作为开发服务器的资源文件,也就是说,只要通过 Webpack 打包能够输出的文件都可以直接被访问到。但是如果你还有一些没有参与打包的静态文件也需要作为开发服务器的资源被访问,那你就需要额外通过配置“告诉” webpack-dev-server。

具体的方法就是在 webpack-dev-server 的配置对象中添加一个对应的配置。我们回到配置文件中,找到 devServer 属性,它的类型是一个对象,我们可以通过这个 devServer 对象的 contentBase 属性指定额外的静态资源路径。这个 contentBase 属性可以是一个字符串或者数组,也就是说你可以配置一个或者多个路径。具体配置如下:

我们这里将这个路径设置为项目中的 public 目录。可能有人会有疑问,之前我们在使用插件的时候已经将这个目录通过 copy-webpack-plugin 输出到了输出目录,按照刚刚的说法,所有输出的文件都可以直接被 serve,也就是能直接访问到,按道理应该不需要再作为开发服务器的静态资源路径了。

确实是这样的,而且如果你能想到这一点,也就证明你真正理解了 webpack-dev-server 的文件加载规则。

但是在实际使用 Webpack 时,我们一般都会把 copy-webpack-plugin 这种插件留在上线前的那一次打包中使用,而开发过程中一般不会用它。因为在开发过程中,我们会频繁重复执行打包任务,假设这个目录下需要拷贝的文件比较多,如果每次都需要执行这个插件,那打包过程开销就会比较大,每次构建的速度也就自然会降低。

至于如何实现某些插件只在生产模式打包时使用,是额外的话题,所以具体的操作方式会在 10 课时中详细介绍。这里我们先移除 CopyWebpackPlugin,确保这里的打包不会输出 public 目录中的静态资源文件,然后回到命令行再次执行 webpack-dev-server。

启动过后,我们打开浏览器,这里我们访问的页面文件和 bundle.js 文件均来自于打包结果。我们再尝试访问 favicon.ico,因为这个文件已经没有参与打包了,所以这个文件必然来源于 contentBase 中配置的目录了。

# Proxy 代理

由于 webpack-dev-server 是一个本地开发服务器,所以我们的应用在开发阶段是独立运行在 localhost 的一个端口上,而后端服务又是运行在另外一个地址上。但是最终上线过后,我们的应用一般又会和后端服务部署到同源地址下。

那这样就会出现一个非常常见的问题:在实际生产环境中能够直接访问的 API,回到我们的开发环境后,再次访问这些 API 就会产生跨域请求问题。

可能有人会说,我们可以用跨域资源共享(CORS)解决这个问题。确实如此,如果我们请求的后端 API 支持 CORS,那这个问题就不成立了。但是并不是每种情况下服务端的 API 都支持 CORS。如果前后端应用是同源部署,也就是协议 / 域名 / 端口一致,那这种情况下,根本没必要开启 CORS,所以跨域请求的问题仍然是不可避免的。

那解决这种开发阶段跨域请求问题最好的办法,就是在开发服务器中配置一个后端 API 的代理服务,也就是把后端接口服务代理到本地的开发服务地址。

webpack-dev-server 就支持直接通过配置的方式,添加代理服务。接下来,我们来看一下它的具体用法。

这里我们假定 GitHub 的 API 就是我们应用的后端服务,那我们的目标就是将 GitHub API 代理到本地开发服务器中。

我们可以先在浏览器中尝试访问其中的一个接口,具体结果如下图:

GitHub API 的 Endpoint 都是在根目录下,也就是说不同的 Endpoint 只是 URL 中的路径部分不同,例如 https://api.github.com/users 和 https://api.github.com/events。

知道 API 地址的规则过后,我们回到配置文件中,在 devServer 配置属性中添加一个 proxy 属性,这个属性值需要是一个对象,对象中的每个属性就是一个代理规则配置。

属性的名称是需要被代理的请求路径前缀,一般为了辨别,我都会设置为 /api。值是所对应的代理规则配置,我们将代理目标地址设置为 https://api.github.com,具体代码如下:

那此时我们请求 http://localhost:8080/api/users ,就相当于请求了 https://api.github.com/api/users。

而我们真正希望请求的地址是 https://api.github.com/users,所以对于代理路径开头的 /api 我们要重写掉。我们可以添加一个 pathRewrite 属性来实现代理路径重写,重写规则就是把路径中开头的 /api 替换为空,pathRewrite 最终会以正则的方式来替换请求路径。

这样我们代理的地址就正常了。

除此之外,我们还需设置一个 changeOrigin 属性为 true。这是因为默认代理服务器会以我们实际在浏览器中请求的主机名,也就是 localhost:8080 作为代理请求中的主机名。而一般服务器需要根据请求的主机名判断是哪个网站的请求,那 localhost:8080 这个主机名,对于 GitHub 的服务器来说,肯定无法正常请求,所以需要修改。

将代理规则配置的 changeOrigin 属性设置为 true,就会以实际代理请求地址中的主机名去请求,也就是我们正常请求这个地址的主机名是什么,实际请求 GitHub 时就会设置成什么。

完成以后,打开命令行终端,运行 webpack-dev-server。然后打开浏览器,这里我们直接尝试请求 http://localhost:8080/api/users,得到的就是 GitHub 的用户数据。 因为这个地址已经被代理到了 GitHub 的用户数据接口。

此时,我们就可以回到代码中使用代理后的本地同源地址去请求后端接口,而不必担心出现跨域问题了。

# 第 07 讲:如何配置 Webpack SourceMap 的最佳实践?

# Source Map 简介

目前很多第三方库在发布的文件中都会同时提供一个 .map 后缀的 Source Map 文件。例如 jQuery。我们可以打开它的 Source Map 文件看一下,如下图所示:

这是一个 JSON 格式的文件,为了更容易阅读,我提前对该文件进行了格式化。这个 JSON 里面记录的就是转换后和转换前代码之间的映射关系,主要存在以下几个属性:

  • version 是指定所使用的 Source Map 标准版本;

  • sources 中记录的是转换前的源文件名称,因为有可能出现多个文件打包转换为一个文件的情况,所以这里是一个数组;

  • names 是源代码中使用的一些成员名称,我们都知道一般压缩代码时会将我们开发阶段编写的有意义的变量名替换为一些简短的字符,这个属性中记录的就是原始的名称;

  • mappings 属性,这个属性最为关键,它是一个叫作 base64-VLQ 编码的字符串,里面记录的信息就是转换后代码中的字符与转换前代码中的字符之间的映射关系,具体如下图所示:

一般我们会在转换后的代码中通过添加一行注释的方式来去引入 Source Map 文件。不过这个特性只是用于开发调试的,所以最新版本的 jQuery 已经去除了引入 Source Map 的注释,我们需要手动添加回来,这里我们在最后一行添加 //# sourceMappingURL=jquery-3.4.1.min.map,具体效果如下:

这样我们在 Chrome 浏览器中如果打开了开发人员工具,它就会自动请求这个文件,然后根据这个文件的内容逆向解析出来源代码,以便于调试。同时因为有了映射关系,所以代码中如果出现了错误,也就能自动定位找到源代码中的位置了。

我们回到浏览器中,打开开发人员工具,找到 Source 面板,这里我们就能看到转换前的 jQuery 源代码了,具体效果如下图所示:

我们还可以添加一个断点,然后刷新页面,进行单步调试,此时调试过程中使用的就是源代码而不是压缩过后的代码,具体效果如下图所示:

# Webpack 中配置 Source Map

然后打开命令行终端,运行 Webpack 打包。打包完成过后,我们打开 dist 目录,此时这个目录中就会生成我们 bundle.js 的 Source Map 文件,与此同时 bundle.js 中也会通过注释引入这个 Source Map 文件,具体如下图所示:

我们再回到命令行,通过 serve 工具把打包结果运行起来,然后打开浏览器,再打开开发人员工具,此时我们就可以直接定位到错误所在的位置了。当然如果需要调试,这里也可以直接调试源代码。

Webpack 中的 devtool 配置,除了可以使用 source-map 这个值,它还支持很多其他的选项,具体的我们可以参考文档中的不同模式的对比表。

上表分别从初次构建速度、监视模式重新构建速度、是否适合生成环境使用,以及 Source Map 的质量,这四个维度去横向对比了不同的 Source Map 模式之间的差异。

# Eval 模式

首先来看 eval 模式。在去具体了解 Webpack eval 模式的 Source Map 之前,我们需要先了解一下 JavaScript 中 eval 的一些特点。

eval 其实指的是 JavaScript 中的一个函数,可以用来运行字符串中的 JavaScript 代码。例如下面这段代码,字符串中的 console.log("foo~") 就会作为一段 JavaScript 代码被执行:

在默认情况下,这段代码运行在一个临时的虚拟机环境中,我们在控制台中就能够看到:

其实我们可以通过 sourceURL 来声明这段代码所属文件路径,接下来我们再来尝试在执行的 JavaScript 字符串中添加一个 sourceURL 的声明,具体操作如下:

具体就是在 eval 函数执行的字符串代码中添加一个注释,注释的格式:# sourceURL=./path/to/file.js,这样的话这段代码就会执行在指定路径下。

在了解了 eval 函数可以通过 sourceURL 指定代码所属文件路径这个特点过后,我们再来尝试使用这个叫作 eval 模式的 Source Map。

我们回到 Webpack 的配置文件中,将 devtool 属性设置为 eval,具体如下:

然后我们回到命令行终端再次运行打包,打包过后,找到生成的 bundle.js 文件,你会发现每个模块中的代码都被包裹到了一个 eval 函数中,而且每段模块代码的最后都会通过 sourceURL 的方式声明这个模块对应的源文件路径,具体如下:

那此时如果我们回到浏览器运行这里的 bundle.js,一旦出现错误,浏览器的控制台就可以定位到具体是哪个模块中的代码,具体效果如下:

但是当你点击控制台中的文件名打开这个文件后,看到的却是打包后的模块代码,而并非我们真正的源代码,具体如下:

综上所述,在 eval 模式下,Webpack 会将每个模块转换后的代码都放到 eval 函数中执行,并且通过 sourceURL 声明对应的文件路径,这样浏览器就能知道某一行代码到底是在源代码的哪个文件中。

因为在 eval 模式下并不会生成 Source Map 文件,所以它的构建速度最快,但是缺点同样明显:它只能定位源代码的文件路径,无法知道具体的行列信息。

# 案例准备工作

为了可以更好地对比不同模式的 Source Map 之间的差异,这里我们使用一个新项目,同时创建出不同模式下的打包结果,通过具体实验来横向对比它们之间的差异。

在这个案例中,项目中只有两个 JS 模块,在 main.js 中,我故意加入了一个运行时错误,具体项目结构和部分代码如下:

然后我们打开 Webpack 的配置文件,在这个文件中定义一个数组,数组中每一个成员都是 devtool 配置取值的一种,具体代码如下:

在上一课时中我们也提到过,Webpack 的配置文件除了可以导出一个配置对象,还可以导出一个数组,数组中每一个元素就是一个单独的打包配置,那这样就可以在一次打包过程中同时执行多个打包任务。

例如,我们这里导出一个数组,然后在这个数组中添加两个打包配置,它们的 entry 都是 src 中的 main.js,不过它们输出的文件名不同,具体代码如下:

这么配置的话,再次打包就会有两个打包子任务工作,我们的 dist 中生成的结果也就是两个文件,具体结果如下:

了解了 Webpack 这种配置用法过后,我们再次回到配置文件中,遍历刚刚定义的数组,为每一个模式单独创建一个打包配置,这样就可以一次性生成所有模式下的不同结果,这比我们一个一个去试验的效率更高,而且对比起来也更明显。

具体配置代码如下:

这里简单解释一下这个配置中的部分配置用意:

  • 1.定义 devtool 属性,它就是当前所遍历的模式名称;
  • 2.将 mode 设置为 none,确保 Webpack 内部不做额外处理;
  • 3.设置打包入口和输出文件名称,打包入口都是 src/main.js,输出文件名称我们就放在 js 目录中,以模式名称命名,至于为什么放在单独目录中,你可以在接下来的内容中找到答案;
  • 4.为 js 文件配置一个 babel-loader,配置 babel-loader 的目的是稍后能够辨别其中一类模式的差异。
  • 5.配置一个 html-webpack-plugin,也就是为每个打包任务生成一个 HTML 文件,通过前面的内容,我们知道 html-webpack-plugin 可以生成使用打包结果的 HTML,接下来我们就是通过这些 HTML 在浏览器中进行尝试。

配置完成以后,我们再次回到命令行终端运行打包,那此时这个打包过程就自动生成了不同模式下的打包结果,具体结果如下图所示:

然后我们通过 serve 把结果运行起来,打开浏览器,此时我们能够在页面中看到每一个使用不同模式 Source Map 的 HTML 文件,具体如下图:

那如果刚刚没有把 JS 文件输出到单独目录中,这里的文件就会非常多,导致 HTML 文件寻找起来特别麻烦。

# 不同模式的对比

有了不同模式下生成的结果过后,我们就可以仔细去对比不同 Source Map 模式之间的具体差异了。其实也没必要真的一个一个去看,这里我先带你看几个比较典型的模式,然后找出它们的规律,这样你就再也不用头大了。

首先 eval 模式,这个模式刚刚已经单独看过了,它就是将模块代码放到 eval 函数中执行,并且通过 sourceURL 标注所属文件路径,在这种模式下没有 Source Map 文件,所以只能定位是哪个文件出错,具体效果如下图:

然后我们再来看一个叫作 eval-source-map 的模式,这个模式也是使用 eval 函数执行模块代码,不过这里有所不同的是,eval-source-map 模式除了定位文件,还可以定位具体的行列信息。相比于 eval 模式,它能够生成 Source Map 文件,可以反推出源代码,具体效果如下:

紧接着我们再来看一个叫作 cheap-eval-source-map 的模式。根据这个模式的名字就能推断出一些信息,它就是在 eval-source-map 基础上添加了一个 cheap,也就是便宜的,或者叫廉价的。用计算机行业的常用说法,就是阉割版的 eval-source-map,因为它虽然也生成了 Source Map 文件,但是这种模式下的 Source Map 只能定位到行,而定位不到列,所以在效果上差了一点点,但是构建速度会提升很多,具体效果如下图:

接下来再看一个叫作 cheap-module-eval-source-map 的模式。慢慢地我们就发现 Webpack 中这些模式的名字不是随意的,好像都有某种规律。这里就是在 cheap-eval-source-map 的基础上多了一个 module,具体效果如下图:

这种模式同样也只能定位到行,它的特点相比于 cheap-eval-source-map 并不明显 ,如果你没有发现差异,可以再去看看上一种模式,仔细做一个对比,相信对比之后你会发现,cheap-module-eval-source-map 中定位的源代码与我们编写的源代码是一模一样的,而 cheap-eval-source-map 模式中定位的源代码是经过 ES6 转换后的结果,具体对比如下(左图是 cheap-eval-source-map):

这也是为什么之前我要给 JS 文件配置 Loader 的原因:因为这种名字中带有 module 的模式,解析出来的源代码是没有经过 Loader 加工的,而名字中不带 module 的模式,解析出来的源代码是经过 Loader 加工后的结果。也就是说如果我们想要还原一模一样的源代码,就需要选择 cheap-module-eval-source-map 模式。

了解了这些过后,你基本上就算通盘了解了 Webpack 中所有 Source Map 模式之间的差异,因为其它的模式无外乎就是这几个特点的排列组合罢了。

例如,我们再来看一个 cheap-source-map 模式,这个模式的名字中没有 eval,意味着它没用 eval 执行代码,而名字中没有 module,意味着 Source Map 反推出来的是 Loader 处理后的代码,有 cheap 表示只能定位源代码的行号。

那以上就是我们在日常开发过程中经常用到的几种 Source Map 模式,你在尝试的时候一定要注意:找规律很重要。

除此之外,还有几个特殊一点的模式,我们单独介绍一下:

  • inline-source-map 模式

它跟普通的 source-map 效果相同,只不过这种模式下 Source Map 文件不是以物理文件存在,而是以 data URLs 的方式出现在代码中。我们前面遇到的 eval-source-map 也是这种 inline 的方式。

  • hidden-source-map 模式

在这个模式下,我们在开发工具中看不到 Source Map 的效果,但是它也确实生成了 Source Map 文件,这就跟 jQuery 一样,虽然生成了 Source Map 文件,但是代码中并没有引用对应的 Source Map 文件,开发者可以自己选择使用。

  • nosources-source-map 模式:

在这个模式下,我们能看到错误出现的位置(包含行列位置),但是点进去却看不到源代码。这是为了保护源代码在生产环境中不暴露。

# 写在最后

这里再分享一下我个人开发时的选择,供你参考。

首先开发过程中(开发环境),我会选择 cheap-module-eval-source-map,原因有以下三点:

  • 我使用框架的情况会比较多,以 React 和 Vue.js 为例,无论是 JSX 还是 vue 单文件组件,Loader 转换后差别都很大,我需要调试 Loader 转换前的源代码。

  • 一般情况下,我编写的代码每行不会超过 80 个字符,对我而言能够定位到行到位置就够了,而且省略列信息还可以提升构建速度。

  • 虽然在这种模式下启动打包会比较慢,但大多数时间内我使用的 webpack-dev-server 都是在监视模式下重新打包,它重新打包的速度非常快。

综上所述,开发环境下我会选择 cheap-module-eval-source-map。

至于发布前的打包,也就是生产环境的打包,我选择 none,它不会生成 Source Map。原因很简单:

  • 首先,Source Map 会暴露我的源代码到生产环境。如果没有控制 Source Map 文件访问权限的话,但凡是有点技术的人都可以很容易的复原项目中涉及的绝大多数源代码,这非常不合理也不安全,我想很多人可能都忽略了这个问题。
  • 其次,调试应该是开发阶段的事情,你应该在开发阶段就尽可能找到所有问题和隐患,而不是到了生产环境中再去全民公测。如果你对自己的代码实在没有信心,我建议你选择 nosources-source-map 模式,这样出现错误可以定位到源码位置,也不至于暴露源码。

除此之外,我还要强调一点,Source Map 并不是 Webpack 特有的功能,它们两者的关系只是:Webpack 支持 Source Map。大多数的构建或者编译工具也都支持 Source Map。希望你不要把它们二者捆绑到一起,混为一谈。

# 第 08 讲:如何让你的模块支持热替换(HMR)机制?

# 模块热替换(HMR)

# 开启 HMR

使用这个特性最简单的方式就是,在运行 webpack-dev-server 命令时,通过 --hot 参数去开启这个特性。

或者也可以在配置文件中通过添加对应的配置来开启这个功能。那我们这里打开配置文件,这里需要配置两个地方:

  • 首先需要将 devServer 对象中的 hot 属性设置为 true;

  • 然后需要载入一个插件,这个插件是 webpack 内置的一个插件,所以我们先导入 webpack 模块,有了这个模块过后,这里使用的是一个叫作 HotModuleReplacementPlugin 的插件。

具体配置代码如下:

配置完成以后,我们打开命令行终端,运行 webpack-dev-server,启动开发服务器。那接下来你就可以来体验 HMR 了。

我们回到开发工具中,这里我们先来尝试修改一下 CSS 文件。样式文件修改保存过后,确实能够以不刷新的形式更新到页面中。

然后我们再来尝试一下修改 JS 文件。保存过后你会发现,这里的页面依然自动刷新了,好像并没有之前所说 HMR 的体验。

为了再次确认,你可以尝试先在页面中的编辑器里随意添加一些文字,然后修改代码,保存过后你就会看到页面自动刷新,页面中的状态也就丢失了

那这是为什么呢?为什么 CSS 文件热替换没出现问题,而到了 JS 这块就不行了呢?我们又该如何去实现其他类型模块的热替换呢?

# HMR 的疑问

通过之前的体验我们发现模块热替换确实提供了非常友好的体验,但是当我们自己去尝试开启 HMR 过后,效果却不尽如人意。

很明显:HMR 并不像 Webpack 的其他特性一样可以开箱即用,需要有一些额外的操作。

具体来说,Webpack 中的 HMR 需要我们手动通过代码去处理,当模块更新过后该,如何把更新后的模块替换到页面中。

Q1:可能你会问,为什么我们开启 HMR 过后,样式文件的修改就可以直接热更新呢?我们好像也没有手动处理样式模块的更新啊?

A1:这是因为样式文件是经过 Loader 处理的,在 style-loader 中就已经自动处理了样式文件的热更新,所以就不需要我们额外手动去处理了。

Q2:那你可能会想,凭什么样式就可以自动处理,而我们的脚本就需要自己手动处理呢?

A2:这个原因也很简单,因为样式模块更新过后,只需要把更新后的 CSS 及时替换到页面中,它就可以覆盖掉之前的样式,从而实现更新。

而我们所编写的 JavaScript 模块是没有任何规律的,你可能导出的是一个对象,也可能导出的是一个字符串,还可能导出的是一个函数,使用时也各不相同。所以 Webpack 面对这些毫无规律的 JS 模块,根本不知道该怎么处理更新后的模块,也就无法直接实现一个可以通用所有情况的模块替换方案。

那这就是为什么样式文件可以直接热更新,而 JS 文件更新后页面还是回退到自动刷新的原因。

Q3:那可能还有一些平时使用 vue-cli 或者 create-react-app 这种框架脚手架工具的人会说,“我的项目就没有手动处理,JavaScript 代码照样可以热替换,也没你说的那么麻烦”。

A3:这是因为你使用的是框架,使用框架开发时,我们项目中的每个文件就有了规律,例如 React 中要求每个模块导出的必须是一个函数或者类,那这样就可以有通用的替换办法,所以这些工具内部都已经帮你实现了通用的替换操作,自然就不需要手动处理了。

当然如果你之前没有接触过这样的工具,那你可以忽略这一条,这也并不影响后面的理解。

综上所述,我们还是需要自己手动通过代码来处理,当 JavaScript 模块更新过后,该如何将更新后的模块替换到页面中。

# HMR APIs

HotModuleReplacementPlugin 为我们的 JavaScript 提供了一套用于处理 HMR 的 API,我们需要在我们自己的代码中,使用这套 API 将更新后的模块替换到正在运行的页面中。

接下来我们回到代码中,尝试通过 HMR 的 API 手动处理模块更新后的热替换。

这里我们打开 main.js,具体代码如下:

这是 Webpack 打包的入口文件,正常情况下,在这个文件中会加载一些其他模块。正是因为在 main.js 中使用了这些模块,所以一旦这些模块更新了过后,我们在 main.js 中就必须重新使用更新后的模块。

所以说,我们需要在这个文件中添加一些额外的代码,去处理它所依赖的这些模块更新后的热替换逻辑。

对于开启 HMR 特性的环境中,我们可以访问到全局的 module 对象中的 hot 成员,这个成员是一个对象,这个对象就是 HMR API 的核心对象,它提供了一个 accept 方法,用于注册当某个模块更新后的处理函数。accept 方法第一个参数接收的就是所监视的依赖模块路径,第二个参数就是依赖模块更新后的处理函数。

那我们这里先尝试注册 ./editor 模块更新过后的处理函数,第一个参数就是 editor 模块的路径,第二个参数则需要我们传入一个函数,然后在这个函数中打印一个消息,具体代码如下:

完成过后,我们打开命令行终端再次启动 webpack-dev-server 命令,然后回到浏览器,打开开发人员工具。

此时,如果我们修改了 editor 模块,保存过后,浏览器的控制台中就会自动打印我们上面在代码中添加的消息,而且浏览器也不会自动刷新了。

那也就是说一旦这个模块的更新被我们手动处理了,就不会触发自动刷新;反之,如果没有手动处理,热替换会自动 fallback(回退)到自动刷新。

# JS 模块热替换

了解了这个 HMR API 的作用过后,接下来需要考虑的就是:具体如何实现 editor 模块的热替换。

这个模块导出的是一个 createEditor 函数,我们先正常把它打印到控制台,然后在模块更新后的处理函数中再打印一次,具体代码如下:

这个时候如果你再次修改 editor 模块,保存过后,你就会发现当模块更新后,我们这里拿到的 createEditor 函数也就更新为了最新的结果,具体结果如下图所示:

既然模块文件更新后 createEditor 函数可以自动更新,那剩下的就好办了。我们这里使用 createEditor 函数是用来创建一个界面元素的,那模块一旦更新了,这个元素也就需要重新创建,所以我们这里先移除原来的元素,然后再调用更新后的 createEditor 函数,创建一个新的元素追加到页面中,具体代码如下:

但如果只是这样实现的话,一次热替换结束后,第二次就没法再实现热替换了。因为第二次执行这个函数的时候,editor 变量指向的元素已经在上一次执行时被移除了,所以我们这里还应该记录下来每次热替换创建的新元素,以便于下一次热替换时的操作,具体代码如下:

完成以后,我们再来尝试修改 editor 模块,此时就应该是正常的热替换效果了,具体效果如下图:

# 热替换的状态保持

此时,如果我们尝试在界面上输入一些内容(形成页面操作状态),然后回到代码中再次修改 editor 模块。那此时你仍然会发现问题,由于热替换时,把界面上之前的编辑器元素移除了,替换成了一个新的元素,所以页面上之前的状态同样会丢失。

这也就证明我们的热替换操作还需要改进,我们必须在替换时把状态保留下来。

我们回到 main.js 中,要想保留这个状态也很简单,就是在替换前先拿到编辑器中的内容,然后替换后在放回去就行了。那因为我这里使用的是可编辑元素,而不是文本框,所以我们需要通过 innerHTML 拿到之前编辑的内容,然后设置到更新后创建的新元素中,具体代码如下:

这样就可以解决界面状态保存的问题了。

至此,对于 editor 模块的热替换逻辑就算是全部实现了。通过这个过程你应该能够发现,为什么 Webpack 需要我们自己处理 JS 模块的热更新了:因为不同的模块有不同的情况,不同的情况,在这里处理时肯定也是不同的。就好像,我们这里是一个文本编辑器应用,所以需要保留状态,如果不是这种类型那就不需要这样做。所以说 Webpack 没法提供一个通用的 JS 模块替换方案。

# 图片模块热替换

相比于 JavaScript 模块热替换,图片的热替换逻辑就简单多了,这里我们快速来看一下。

我们同样通过 module.hot.accept 注册这个图片模块的热替换处理函数,在这个函数中,我们只需要重新给图片元素的 src 设置更新后的图片路径就可以了。因为图片修改过后图片的文件名会发生变化,而这里我们就可以直接得到更新后的路径,所以重新设置图片的 src 就能实现图片热替换,具体代码如下:

# 常见问题

如果你刚开始使用 Webpack 的 HMR 特性,肯定会遇到一些问题,接下来我分享几个最容易发生的问题。

第一个问题,如果处理热替换的代码(处理函数)中有错误,结果也会导致自动刷新。例如我们这里在处理函数中故意加入一个运行时错误,代码如下:

直接测试你会发现 HMR 不会正常工作,而且根本看不到异常,效果如下图:

这是因为 HMR 过程报错导致 HMR 失败,HMR 失败过后,会自动回退到自动刷新,页面一旦自动刷新,控制台中的错误信息就会被清除,这样的话,如果不是很明显的错误,就很难被发现。

在这种情况下,我们可以使用 hotOnly 的方式来解决,因为现在使用的 hot 方式,如果热替换失败就会自动回退使用自动刷新,而 hotOnly 的情况下并不会使用自动刷新。

我们回到配置文件中,这里我们将 devServer 中的 hot 等于 true 修改为 hotOnly 等于 true,具体代码如下:

配置完成以后,重新启动 webpack-dev-server。此时我们再去修改代码,无论是否处理了这个代码模块的热替换逻辑,浏览器都不会自动刷新了,这样的话,热替换逻辑中的错误信息就可以直接看到了,具体效果如下图:

第二个问题,对于使用了 HMR API 的代码,如果我们在没有开启 HMR 功能的情况下运行 Webpack 打包,此时运行环境中就会报出 Cannot read property 'accept' of undefined 的错误,具体错误信息如下:

原因是 module.hot 是 HMR 插件提供的成员,没有开启这个插件,自然也就没有这个对象。

解决办法也很简单,与我们在业务代码中判断 API 兼容一样,我们先判断是否存在这个对象,然后再去使用就可以了,具体代码如下:

除此之外,可能你还有一个问题:我们在代码中写了很多与业务功能本身无关的代码,会不会对生产环境有影响?

那这个问题的答案很简单,我通过一个简单的操作来帮你解答,我们回到配置文件中,确保已经将热替换特性关闭,并且移除掉了 HotModuleReplacementPlugin 插件,然后打开命令行终端,正常运行一下 Webpack 打包,打包过后,我们找到打包生成的 bundle.js 文件,然后找到里面 main.js 对应的模块,具体结果如下图:

# 写在最后

以上就是我们对 Webpack 模块热替换特性做的一些探索,整体下来可能你会觉得 HMR 比较麻烦,需要写一些额外的代码,甚至觉得不如不用。

我个人的看法是利大于弊,这个道理就像是为什么现在的开发者都愿意写单元测试一样,对于长期开发的项目而言,这点额外的工作不算什么,而且如果你能为自己的代码设计出一些规律,那你也可以实现一个通用替换方案。

那当然,如果你是使用 React 或者 Vue.js 这类的框架开发,那么使用 HMR 功能会更加简单,因为大部分框架都有成熟的 HMR 方案,你只需要使用就可以了。但是如果你是使用纯原生 JavaScript 开发,那 HMR 功能使用起来相对就会麻烦一点。这也正是为什么大部分人都喜欢选择集成式框架的原因。

关于框架的 HMR,因为在大多数情况下是开箱即用的,所以这里不做过多介绍,详细可以参考:

# 第 09 讲:Tree Shaking 和 sideEffects

  • Tree Shaking:未引用代码
  • sideEffects:配置标识代码是否有副作用,从而提供更大的压缩空间

# Tree Shaking

Tree Shaking 翻译过来的意思就是“摇树”。伴随着摇树的动作,树上的枯树枝和树叶就会掉落下来。

我们这里要介绍的 Tree-shaking 也是同样的道理,不过通过 Tree-shaking “摇掉”的是代码中那些没有用到的部分,这部分没有用的代码更专业的说法应该叫作未引用代码(dead-code)。

Tree-shaking 最早是 Rollup 中推出的一个特性,Webpack 从 2.0 过后开始支持这个特性。

我们使用 Webpack 生产模式打包的优化过程中,就使用自动开启这个功能,以此来检测我们代码中的未引用代码,然后自动移除它们。

我们可以先来体验一下这个功能的效果,这里我的源代码非常简单,只有两个文件。

其中 components.js 中导出了一些函数,这些函数各自模拟了一个组件,具体代码如下:

其中 Button 组件函数中,在 return 过后还有一个 console.log() 语句,很明显这句代码永远都不会被执行,所以这个 console.log() 就属于未引用代码。

在 main.js 文件中只是导入了 compnents.js,具体代码如下:

但是注意这里导入 components 模块时,我们只提取了模块中的 Button 成员,那这就导致 components 模块中很多地方都不会被用到,那这些地方就是冗余的,具体冗余部分如下:

去除冗余代码是生产环境优化中一个很重要的工作,Webpack 的 Tree-shaking 功能就很好地实现了这一点。

我们打开命令行终端,这里我们尝试以 production 模式运行打包,具体命令如下:

  $ npx webpack --mode=production

Webpack 的 Tree-shaking 特性在生产模式下会自动开启。打包完成以后我们打开输出的 bundle.js,具体结果如下:

通过搜索你会发现,components 模块中冗余的代码根本没有输出。这就是经过 Tree-shaking 处理过后的效果。

试想一下,如果我们在项目中引入 Lodash 这种工具库,大部分情况下我们只会使用其中的某几个工具函数,而其他没有用到的部分就是冗余代码。通过 Tree-shaking 就可以极大地减少最终打包后 bundle 的体积。

需要注意的是,Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果,这组功能在生产模式下都会自动启用,所以使用生产模式打包就会有 Tree-shaking 的效果。

# 开启 Tree Shaking

由于目前官方文档中对于 Tree-shaking 的介绍有点混乱,所以我们这里再来介绍一下在其他模式下,如何一步一步手动开启 Tree-shaking。通过这个过程,还可以顺便了解 Tree-shaking 的工作过程和 Webpack 其他的一些优化功能。

这里还是上述的案例结构,我们再次运行 Webpack 打包,不过这一次我们不再使用 production 模式,而是使用 none,也就是不开启任何内置功能和插件,具体命令如下:

  $ npx webpack --mode=none

打包完成过后,我们再次找到输出的 bundle.js 文件,具体结果如下:

这里的打包结果跟我们在第二讲中分析的是一样的,源代码中的一个模块对应这里的一个函数。

我们这里注意一下 components 对应的这个模块,虽然外部没有使用这里的 Link 函数和 Heading 函数,但是仍然导出了它们,具体如下图所示:

显然这种导出是没有任何意义的。

明确目前打包结果的状态过后,我们打开 Webpack 的配置文件,在配置对象中添加一个 optimization 属性,这个属性用来集中配置 Webpack 内置优化功能,它的值也是一个对象。

在 optimization 对象中我们可以先开启一个 usedExports 选项,表示在输出结果中只导出外部使用了的成员,具体配置代码如下:

配置完成后,重新打包,然后我们再来看一下输出的 bundle.js,具体结果如下图:

此时你会发现 components 模块所对应的函数,就不再导出 Link 和 Heading 这两个函数了,那它们对应的代码就变成了未引用代码。而且如果你使用的是 VS Code,会发现 VS Code 将这两个函数名的颜色变淡了,这是为了表示它们未被引用。

对于这种未引用代码,如果我们开启压缩代码功能,就可以自动压缩掉这些没有用到的代码。

我们可以回到配置文件中,尝试在 optimization 配置中开启 minimize,具体配置如下:

然后再次回到命令行重新运行打包,具体结果如下图所示:

仔细查看打包结果,你会发现,Link 和 Heading 这些未引用代码都被自动移除了。

这就是 Tree-shaking 的实现,整个过程用到了 Webpack 的两个优化功能:

  • usedExports - 打包结果中只导出外部用到的成员;

  • minimize - 压缩打包结果。 如果把我们的代码看成一棵大树,那你可以这样理解:

  • usedExports 的作用就是标记树上哪些是枯树枝、枯树叶;

  • minimize 的作用就是负责把枯树枝、枯树叶摇下来。

# 合并模块(扩展)(concatenateModules )

除了 usedExports 选项之外,我们还可以使用一个 concatenateModules 选项继续优化输出。

普通打包只是将一个模块最终放入一个单独的函数中,如果我们的模块很多,就意味着在输出结果中会有很多的模块函数。

concatenateModules 配置的作用就是尽可能将所有模块合并到一起输出到一个函数中,这样既提升了运行效率,又减少了代码的体积

我们回到配置文件中,这里我们在 optimization 属性中开启 concatenateModules。同时,为了更好地看到效果,我们先关闭 minimize,具体配置如下:

然后回到命令行终端再次运行打包。那此时 bundle.js 中就不再是一个模块对应一个函数了,而是把所有的模块都放到了一个函数中,具体结果如下:

这个特性又被称为 Scope Hoisting,也就是作用域提升,它是 Webpack 3.0 中添加的一个特性。

如果再配合 minimize 选项,打包结果的体积又会减小很多。

# 结合 babel-loader 的问题

因为早期的 Webpack 发展非常快,那变化也就比较多,所以当我们去找资料时,得到的结果不一定适用于当前我们所使用的版本。而 Tree-shaking 的资料更是如此,很多资料中都表示“为 JS 模块配置 babel-loader,会导致 Tree-shaking 失效”。

针对这个问题,这里我统一说明一下:

首先你需要明确一点:Tree-shaking 实现的前提是 ES Modules,也就是说:最终交给 Webpack 打包的代码,必须是使用 ES Modules 的方式来组织的模块化。

为什么这么说呢?

我们都知道 Webpack 在打包所有的模块代码之前,先是将模块根据配置交给不同的 Loader 处理,最后再将 Loader 处理的结果打包到一起。

很多时候,我们为了更好的兼容性,会选择使用 babel-loader 去转换我们源代码中的一些 ECMAScript 的新特性。而 Babel 在转换 JS 代码时,很有可能处理掉我们代码中的 ES Modules 部分,把它们转换成 CommonJS 的方式,如下图所示:

当然了,Babel 具体会不会处理 ES Modules 代码,取决于我们有没有为它配置使用转换 ES Modules 的插件。

很多时候,我们为 Babel 配置的都是一个 preset(预设插件集合),而不是某些具体的插件。例如,目前市面上使用最多的 @babel/preset-env,这个预设里面就有转换 ES Modules 的插件。所以当我们使用这个预设时,代码中的 ES Modules 部分就会被转换成 CommonJS 方式。那 Webpack 再去打包时,拿到的就是以 CommonJS 方式组织的代码了,所以 Tree-shaking 不能生效。

那我们这里具体来尝试一下。为了可以更容易分辨结果,我们只开启 usedExports,完整配置如下:

配置完成过后,我们打开命令行终端,运行 Webpack 打包命令,然后再找到 bundle.js,具体结果如下:

仔细查看你会发现,结果并不是像刚刚说的那样,这里 usedExports 功能仍然正常工作了,此时,如果我们压缩代码,这些未引用的代码依然会被移除。这也就说明 Tree-shaking 并没有失效。

那到底是怎么回事呢?为什么很多资料都说 babel-loader 会导致 Tree-shaking 失效,但当我们实际尝试后又发现并没有失效?

其实,这是因为在最新版本(8.x)的 babel-loader 中,已经自动帮我们关闭了对 ES Modules 转换的插件,你可以参考对应版本 babel-loader 的源码,核心代码如下:

通过查阅 babel-loader 模块的源码,我们发现它已经在 injectCaller 函数中标识了当前环境支持 ES Modules。

然后再找到我们所使用的 @babal/preset-env 模块源码,部分核心代码如下:

在这个模块中,根据环境标识自动禁用了对 ES Modules 的转换插件,所以经过 babel-loader 处理后的代码默认仍然是 ES Modules,那 Webpack 最终打包得到的还是 ES Modules 代码,Tree-shaking 自然也就可以正常工作了。

我们也可以在 babel-loader 的配置中强制开启 ES Modules 转换插件来试一下,具体配置如下:

给 Babel preset 添加配置的方式比较特别,这里很多人都会配错,一定要注意。它需要把预设数组中的成员定义成一个数组,然后这个数组中的第一个成员就是所使用的 preset 的名称,第二个成员就是给这个 preset 定义的配置对象。

我们在这个对象中将 modules 属性设置为 "commonjs",默认这个属性是 auto,也就是根据环境判断是否开启 ES Modules 插件,我们设置为 commonjs 就表示我们强制使用 Babel 的 ES Modules 插件把代码中的 ES Modules 转换为 CommonJS。

完成以后,我们再次打开命令行终端,运行 Webpack 打包。然后找到 bundle.js,结果如下:

此时,你就会发现 usedExports 没法生效了。即便我们开启压缩代码,Tree-shaking 也会失效。

总结一下,这里通过实验发现,最新版本的 babel-loader 并不会导致 Tree-shaking 失效。如果你不确定现在使用的 babel-loader 会不会导致这个问题,最简单的办法就是在配置中将 @babel/preset-env 的 modules 属性设置为 false,确保不会转换 ES Modules,也就确保了 Tree-shaking 的前提。

# sideEffects

Webpack 4 中新增了一个 sideEffects 特性,它允许我们通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间。

TIPS:模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情。

这个特性一般只有我们去开发一个 npm 模块时才会用到。因为官网把对 sideEffects 特性的介绍跟 Tree-shaking 混到了一起,所以很多人误认为它们之间是因果关系,其实它们没有什么太大的关系。

这里我先设计一个 sideEffects 能够发挥效果的场景,案例具体结构如下:

基于上一个案例的基础上,我们把 components 模块拆分出多个组件文件,然后在 components/index.js 中集中导出,以便于外界集中导入,具体 index.js 代码如下:

这也是我们经常见到一种同类文件的组织方式。另外,在每个组件中,我们都添加了一个 console 操作(副作用代码),具体代码如下:

那这样就会出现一个问题,虽然我们在这里只是希望载入 Button 模块,但实际上载入的是 components/index.js,而 index.js 中又载入了这个目录中全部的组件模块,这就会导致所有组件模块都会被加载执行。

我们打开命令行终端,尝试运行打包,打包完成过后找到打包结果,具体结果如下:

根据打包结果发现,所有的组件模块都被打包进了 bundle.js。

此时如果我们开启 Tree-shaking 特性(只设置 useExports),这里没有用到的导出成员其实最终也可以被移除,打包效果如下:

但是由于这些成员所属的模块中有副作用代码,所以就导致最终 Tree-shaking 过后,这些模块并不会被完全移除。

可能你会认为这些代码应该保留下来,而实际情况是,这些模块内的副作用代码一般都是为这个模块服务的,例如这里我添加的 console.log,就是希望表示一下当前这个模块被加载了。但是最终整个模块都没用到,也就没必要留下这些副作用代码了。

所以说,Tree-Shaking 只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects 特殊性

# sideEffect 作用

我们打开 Webpack 的配置文件,在 optimization 中开启 sideEffects 特性,具体配置如下:

TIPS:注意这个特性在 production 模式下同样会自动开启。

那此时 Webpack 在打包某个模块之前,会先检查这个模块所属的 package.json 中的 sideEffects 标识,以此来判断这个模块是否有副作用,如果没有副作用的话,这些没用到的模块就不再被打包。换句话说,即便这些没有用到的模块中存在一些副作用代码,我们也可以通过 package.json 中的 sideEffects 去强制声明没有副作用。

那我们打开项目 package.json 添加一个 sideEffects 字段,把它设置为 false,具体代码如下:

这样就表示我们这个项目中的所有代码都没有副作用,让 Webpack 放心大胆地去“干”。

完成以后我们再次运行打包,然后同样找到打包输出的 bundle.js 文件,结果如下:

此时那些没有用到的模块就彻底不会被打包进来了。那这就是 sideEffects 的作用。

这里设置了两个地方:

  • webpack.config.js 中的 sideEffects 用来开启这个功能;
  • package.json 中的 sideEffects 用来标识我们的代码没有副作用。

目前很多第三方的库或者框架都已经使用了 sideEffects 标识,所以我们再也不用担心为了一个小功能引入一个很大体积的库了。例如,某个 UI 组件库中只有一两个组件会用到,那只要它支持 sideEffects,你就可以放心大胆的直接用了。

# sideEffect 注意

使用 sideEffects 这个功能的前提是确定你的代码没有副作用,或者副作用代码没有全局影响,否则打包时就会误删掉你那些有意义的副作用代码。

例如,我这里准备的 extend.js 模块:

在这个模块中并没有导出任何成员,仅仅是在 Number 的原型上挂载了一个 pad 方法,用来为数字添加前面的导零,这是一种很早以前常见的基于原型的扩展方法。

我们回到 main.js 中去导入 extend 模块,具体代码如下:

因为这个模块确实没有导出任何成员,所以这里也就不需要提取任何成员。导入过后就可以使用它为 Number 提供扩展方法了。

这里为 Number 类型做扩展的操作就是 extend 模块对全局产生的副作用。

此时如果我们还是通过 package.json 标识我们代码没有副作用,那么再次打包过后,就会出现问题。我们可以找到打包结果,如下图所示:

我们看到,对 Number 的扩展模块并不会打包进来。

缺少了对 Number 的扩展操作,我们的代码再去运行的时候,就会出现错误。这种扩展的操作属于对全局产生的副作用。

这种基于原型的扩展方式,在很多 Polyfill 库中都会大量出现,比较常见的有 es6-promise,这种模块都属于典型的副作用模块。

除此之外,我们在 JS 中直接载入的 CSS 模块,也都属于副作用模块,同样会面临这种问题。

所以说不是所有的副作用都应该被移除,有一些必要的副作用需要保留下来。

最好的办法就是在 package.json 中的 sideEffects 字段中标识需要保留副作用的模块路径(可以使用通配符),具体配置如下:

这样 Webpack 的 sideEffects 就不会忽略确实有必要的副作用模块了。

# 写在最后

最后我们来总结一下,今天介绍到了两个 Webpack 中的高级特性,分别是 Tree-shaking 和 sideEffects。

Tree-shaking 的本身没有太多需要你理解和思考的地方,你只需要了解它的效果,以及相关的配置即可。

而 sideEffects 可能需要你花点时间去理解一下,重点就是想明白哪些副作用代码是可以随着模块的移除而移除,哪些又是不可以移除的。总结下来其实也很简单:对全局有影响的副作用代码不能移除,而只是对模块有影响的副作用代码就可以移除。

总之不管是 Tree-shaking 还是 sideEffects,我个人认为,它们都是为了弥补 JavaScript 早期在模块系统设计上的不足。随着 Webpack 这类技术的发展,JavaScript 的模块化确实越来越好用,也越来越合理。

除此之外,我还想强调一点,当你对这些特性有了一定的了解之后,就应该意识到:尽可能不要写影响全局的副作用代码。

# 第 10 讲:Code Splitting(分块打包)

# All in One 的弊端

通过 Webpack 实现前端项目整体模块化的优势固然明显,但是它也会存在一些弊端:它最终会将我们所有的代码打包到一起。试想一下,如果我们的应用非常复杂,模块非常多,那么这种 All in One 的方式就会导致打包的结果过大,甚至超过 4 ~ 5M。

在绝大多数的情况下,应用刚开始工作时,并不是所有的模块都是必需的。如果这些模块全部被打包到一起,即便应用只需要一两个模块工作,也必须先把 bundle.js 整体加载进来,而且前端应用一般都是运行在浏览器端,这也就意味着应用的响应速度会受到影响,也会浪费大量的流量和带宽

所以这种 All in One 的方式并不合理,更为合理的方案是把打包的结果按照一定的规则分离到多个 bundle 中,然后根据应用的运行需要按需加载。这样就可以降低启动成本,提高响应速度。

可能你会联想到我们在开篇词中讲过,Webpack 就是通过把项目中散落的模块打包到一起,从而提高加载效率,那么为什么这里又要分离?这不是自相矛盾吗?

其实这并不矛盾,只是物极必反罢了。Web 应用中的资源受环境所限,太大不行,太碎更不行。因为我们开发过程中划分模块的颗粒度一般都会非常的细,很多时候一个模块只是提供了一个小工具函数,并不能形成一个完整的功能单元。

如果我们不将这些资源模块打包,直接按照开发过程中划分的模块颗粒度进行加载,那么运行一个小小的功能,就需要加载非常多的资源模块。

再者,目前主流的 HTTP 1.1 本身就存在一些缺陷,例如:

  • 同一个域名下的并行请求是有限制的;
  • 每次请求本身都会有一定的延迟;
  • 每次请求除了传输内容,还有额外的请求头,大量请求的情况下,这些请求头加在一起也会浪费流量和带宽。

综上所述,模块打包肯定是必要的,但当应用体积越来越大时,我们也要学会变通。

# Code Splitting (代码分割)

为了解决打包结果过大导致的问题,Webpack 设计了一种分包功能:Code Splitting(代码分割)

Code Splitting 通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle 中,从而降低应用的启动成本,提高响应速度。

Webpack 实现分包的方式主要有两种:

  • 根据业务不同配置多个打包入口,输出多个打包结果;
  • 结合 ES Modules 的动态导入(Dynamic Imports)特性,按需加载模块。

# 多入口打包

多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是一个页面对应一个打包入口,对于不同页面间公用的部分,再提取到公共的结果中。

Webpack 配置多入口打包的方式非常简单,这里我准备了一个相应的示例 (opens new window),具体结构如下:

实例代码:

这个示例中有两个页面,分别是 index 和 album。代码组织的逻辑也很简单:

  • index.js 负责实现 index 页面功能逻辑;
  • album.js 负责实现 album 页面功能逻辑;
  • global.css 是公用的样式文件;
  • fetch.js 是一个公用的模块,负责请求 API。

我们回到配置文件中,这里我们尝试为这个案例配置多入口打包,具体配置如下:

一般 entry 属性中只会配置一个打包入口,如果我们需要配置多个入口,可以把 entry 定义成一个对象。

注意:这里 entry 是定义为对象而不是数组,如果是数组的话就是把多个文件打包到一起,还是一个入口。

在这个对象中一个属性就是一个入口,属性名称就是这个入口的名称,值就是这个入口对应的文件路径。那我们这里配置的就是 index 和 album 页面所对应的 JS 文件路径。

一旦我们的入口配置为多入口形式,那输出文件名也需要修改,因为两个入口就有两个打包结果,不能都叫 bundle.js。我们可以在这里使用 [name] 这种占位符来输出动态的文件名,[name] 最终会被替换为入口的名称。

除此之外,在配置中还通过 html-webpack-plugin 分别为 index 和 album 页面生成了对应的 HTML 文件。

完成配置之后,我们就可以打开命令行终端,运行 Webpack 打包,那此次打包会有两个入口。打包完成后,我们找到输出目录,这里就能看到两个入口文件各自的打包结果了,如下图所示:

但是这里还有一个小问题,我们打开任意一个输出的 HTML 文件,具体结果如下图:

你就会发现 index 和 album 两个打包结果都被页面载入了,而我们希望的是每个页面只使用它对应的那个输出结果。

所以这里还需要修改配置文件,我们回到配置文件中,找到输出 HTML 的插件,默认这个插件会自动注入所有的打包结果,如果需要指定所使用的 bundle,我们可以通过 HtmlWebpackPlugin 的 chunks 属性来设置。我们分别为两个页面配置使用不同的 chunk,具体配置如下:

TIPS:每个打包入口都会形成一个独立的 chunk(块)。

完成以后我们再次回到命令行终端,然后运行打包

这一次打包的结果就完全正常了。

那这就是配置多入口打包的方法,以及如何指定在 HTML 中注入的 bundle。

# 提取公共模块

多入口打包本身非常容易理解和使用,但是它也存在一个小问题,就是不同的入口中一定会存在一些公共使用的模块,如果按照目前这种多入口打包的方式,就会出现多个打包结果中有相同的模块的情况。

例如我们上述案例中,index 入口和 album 入口中就共同使用了 global.css 和 fetch.js 这两个公共的模块。这里是因为我们的示例比较简单,所以重复的影响没有那么大,但是如果我们公共使用的是 jQuery 或者 Vue.js 这些体积较大的模块,那影响就会比较大,不利于公共模块的缓存。

所以我们还需要把这些公共的模块提取到一个单独的 bundle 中。Webpack 中实现公共模块提取非常简单,我们只需要在优化配置中开启 splitChunks 功能就可以了,具体配置如下:

我们回到配置文件中,这里在 optimization 属性中添加 splitChunks 属性,那这个属性的值是一个对象,这个对象需要配置一个 chunks 属性,我们这里将它设置为 all,表示所有公共模块都可以被提取。

完成以后我们打开命令行终端,再次运行 Webpack 打包,打包结果如下图:

此时在我们的 dist 下就会额外生成一个 JS 文件,在这个文件中就是 index 和 album 中公共的模块部分了。

除此之外,splitChunks 还支持很多高级的用法,可以实现各种各样的分包策略,这些我们可以在文档 (opens new window)中找到对应的介绍。

# 动态导入

除了多入口打包的方式,Code Splitting 更常见的实现方式还是结合 ES Modules 的动态导入特性,从而实现按需加载。

按需加载是开发浏览器应用中一个非常常见的需求。一般我们常说的按需加载指的是加载数据或者加载图片,但是我们这里所说的按需加载,指的是在应用运行过程中,需要某个资源模块时,才去加载这个模块。这种方式极大地降低了应用启动时需要加载的资源体积提高了应用的响应速度,同时也节省了带宽和流量

Webpack 中支持使用动态导入的方式实现模块的按需加载,而且所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包。

相比于多入口的方式,动态导入更为灵活,因为我们可以通过代码中的逻辑去控制需不需要加载某个模块,或者什么时候加载某个模块。而且我们分包的目的中,很重要的一点就是让模块实现按需加载,从而提高应用的响应速度。

接下来,我们具体来看如何使用动态导入特性,这里我已经设计了一个可以发挥按需加载作用的场景,具体效果如下图所示:

在这个应用的主体区域,如果我们访问的是首页,它显示的是一个文章列表,如果我们访问的是相册页,它显示的就是相册列表。

回到代码中,我们来看目前的实现方式,具体结构如下:

文章列表对应的是这里的 posts 组件,而相册列表对应的是 album 组件。我在打包入口(index.js)中同时导入了这两个模块,然后根据页面锚点的变化决定显示哪个组件,核心代码如下:

在这种情况下,就可能产生资源浪费。试想一下:如果用户只需要访问其中一个页面,那么加载另外一个页面对应的组件就是浪费。

如果我们采用动态导入的方式,就不会产生浪费的问题了,因为所有的组件都是惰性加载,只有用到的时候才会去加载。具体实现代码如下:

P.S. 为了动态导入模块,可以将 import 关键字作为函数调用。当以这种方式使用时,import 函数返回一个 Promise 对象。这就是 ES Modules 标准中的 Dynamic Imports

这里我们先移除 import 这种静态导入,然后在需要使用组件的地方通过 import 函数导入指定路径,那这个方法返回的是一个 Promise。在这个 Promise 的 then 方法中我们能够拿到模块对象。由于我们这里的 posts 和 album 模块是以默认成员导出,所以我们需要解构模块对象中的 default,先拿到导出成员,然后再正常使用这个导出成员。

完成以后,Webpack Dev Server 自动重新打包,我们再次回到浏览器,此时应用仍然是可以正常工作的。

那我们再回到命令行终端,重新运行打包,然后看看此时的打包结果具体是怎样的。打包完成以后我们打开 dist 目录,具体结果如下图所示:

此时 dist 目录下就会额外多出三个 JS 文件,其中有两个文件是动态导入的模块,另外一个文件是动态导入模块中公共的模块,这三个文件就是由动态导入自动分包产生的。

以上就是动态导入在 Webpack 中的使用。整个过程我们无需额外配置任何地方,只需要按照 ES Modules 动态导入的方式去导入模块就可以了,Webpack 内部会自动处理分包和按需加载。

如果你使用的是 Vue.js 之类的 SPA 开发框架的话,那你项目中路由映射的组件就可以通过这种动态导入的方式实现按需加载,从而实现分包。

# 魔法注释 (bundle 命名)

默认通过动态导入产生的 bundle 文件,它的 name 就是一个序号,这并没有什么不好,因为大多数时候,在生产环境中我们根本不用关心资源文件的名称。

但是如果你还是需要给这些 bundle 命名的话,就可以使用 Webpack 所特有的魔法注释去实现。具体方式如下:

所谓魔法注释,就是在 import 函数的形式参数位置,添加一个行内注释,这个注释有一个特定的格式:webpackChunkName: 'chunk-name',这样就可以给分包的 chunk 起名字了。chunk-name

完成过后,我们再次打开命令行终端,运行 Webpack 打包,那此时我们生成 bundle 的 name 就会使用刚刚注释中提供的名称了:

除此之外,魔法注释还有个特殊用途:如果你的 chunkName 相同的话,那相同的 chunkName 最终就会被打包到一起,例如我们这里可以把这两个 chunkName 都设置为 components,然后再次运行打包,那此时这两个模块都会被打包到一个文件中,具体操作如下图所示:

借助这个特点,你就可以根据自己的实际情况,灵活组织动态加载的模块了。

# 写在最后

最后我们来总结一下今天的核心内容,我们介绍了为什么要进行分包,以及 Webpack Code Splitting 的两种实现方式,分别是多入口打包动态导入,其中动态导入会更常用到。

# 第 11 讲:如何优化 Webpack 的构建速度和打包结果

我们分别尝试一下通过这两种方式,为开发环境和生产环境创建不同配置。

首先我们来看在配置文件中添加判断的方式。我们回到配置文件中,Webpack 配置文件还支持导出一个函数,然后在函数中返回所需要的配置对象。这个函数可以接收两个参数,第一个是 env,是我们通过 CLI 传递的环境名参数,第二个是 argv,是运行 CLI 过程中的所有参数。具体代码如下:

那我们就可以借助这个特点,为开发环境和生产环境创建不同配置。我先将不同模式下公共的配置定义为一个 config 对象,具体代码如下:

然后通过判断,再为 config 对象添加不同环境下的特殊配置。具体如下:

例如这里,我们判断 env 等于 development(开发模式)的时候,我们将 mode 设置为 development,将 devtool 设置为 cheap-eval-module-source-map;而当 env 等于 production(生产模式)时,我们又将 mode 和 devtool 设置为生产模式下需要的值。

当然,你还可以分别为不同模式设置其他不同的属性、插件,这也都是类似的。

通过这种方式完成配置过后,我们打开命令行终端,这里我们再去执行 webpack 命令时就可以通过 --env 参数去指定具体的环境名称,从而实现在不同环境中使用不同的配置。

那这就是通过在 Webpack 配置文件导出的函数中对环境进行判断,从而实现不同环境对应不同配置。这种方式是 Webpack 建议的方式。

你也可以直接定义环境变量,然后在全局判断环境变量,根据环境变量的不同导出不同配置。这种方式也是类似的,这里我们就不做过多介绍了。

# 不同环境的配置文件

通过判断环境名参数返回不同配置对象的方式只适用于中小型项目,因为一旦项目变得复杂,我们的配置也会一起变得复杂起来。所以对于大型的项目来说,还是建议使用不同环境对应不同配置文件的方式来实现。

一般在这种方式下,项目中最少会有三个 webpack 的配置文件。其中两个用来分别适配开发环境和生产环境,另外一个则是公共配置。因为开发环境和生产环境的配置并不是完全不同的,所以需要一个公共文件来抽象两者相同的配置。具体配置文件结构如下:

首先我们在项目根目录下新建一个 webpack.common.js,在这个文件中导出不同模式下的公共配置;然后再来创建一个 webpack.dev.js 和一个 webpack.prod.js 分别定义开发和生产环境特殊的配置。

在不同环境的具体配置中我们先导入公共配置对象,然后这里可以使用 Object.assign 方法把公共配置对象复制到具体环境的配置对象中,并且同时去覆盖其中的一些配置。具体如下:

如果你熟悉 Object.assign 方法,就应该知道,这个方法会完全覆盖掉前一个对象中的同名属性。这个特点对于普通值类型属性的覆盖都没有什么问题。但是像配置中的 plugins 这种数组,我们只是希望在原有公共配置的插件基础上添加一些插件,那 Object.assign 就做不到了。

所以我们需要更合适的方法来合并这里的配置与公共的配置。你可以使用 Lodash 提供的 merge 函数来实现,不过社区中提供了更为专业的模块 webpack-merge,它专门用来满足我们这里合并 Webpack 配置的需求。

我们可以先通过 npm 安装一下 webpack-merge 模块。具体命令如下:

  npm i webpack-merge --save-dev
  # or yarn add webpack-merge --dev

安装完成过后我们回到配置文件中,这里先载入这个模块。那这个模块导出的就是一个 merge 函数,我们使用这个函数来合并这里的配置与公共的配置。具体代码如下:

使用 webpack-merge 过后,我们这里的配置对象就可以跟普通的 webpack 配置一样,需要什么就配置什么,merge 函数内部会自动处理合并的逻辑。

分别配置完成过后,我们再次回到命令行终端,然后尝试运行 webpack 打包。不过因为这里已经没有默认的配置文件了,所以我们需要通过 --config 参数来指定我们所使用的配置文件路径。例如:

  webpack --config webpack.prod.js

当然,如果你觉得这样操作让我们的命令变得更复杂了,那你可以把这个构建命令定义到 npm scripts 中,方便使用。

# 生产模式下的优化插件

在 Webpack 4 中新增的 production 模式下,内部就自动开启了很多通用的优化功能。对于使用者而言,开箱即用是非常方便的,但是对于学习者而言,这种开箱即用会导致我们忽略掉很多需要了解的东西。以至于出现问题无从下手。

如果你想要深入了解 Webpack 的使用,我建议你去单独研究每一个配置背后的作用。这里我们先一起学习 production 模式下几个主要的优化功能,顺便了解一下 Webpack 如何优化打包结果。

# DefinePlugin

首先是 DefinePlugin,DefinePlugin 是用来为我们代码中注入全局成员的。在 production 模式下,默认通过这个插件往代码中注入了一个 process.env.NODE_ENV。很多第三方模块都是通过这个成员去判断运行环境,从而决定是否执行例如打印日志之类的操作。

这里我们来单独使用一下这个插件。我们回到配置文件中,DefinePlugin 是一个内置的插件,所以我们先导入 webpack 模块,然后再到 plugins 中添加这个插件。这个插件的构造函数接收一个对象参数,对象中的成员都可以被注入到代码中。具体代码如下:

例如我们这里通过 DefinePlugin 定义一个 API_BASE_URL,用来为我们的代码注入 API 服务地址,它的值是一个字符串。

然后我们回到代码中打印这个 API_BASE_URL。具体代码如下:

完成以后我们打开控制台,然后运行 webpack 打包。打包完成过后我们找到打包的结果,然后找到 main.js 对应的模块。具体结果如下:

这里我们发现 DefinePlugin 其实就是把我们配置的字符串内容直接替换到了代码中,而目前这个字符串的内容为 https://api.example.com,字符串中并没有包含引号,所以替换进来语法自然有问题。

正确的做法是传入一个字符串字面量语句。具体实现如下:

这样代码内的 API_BASE_URL 就会被替换为 "https://api.example.com"。具体结果如下:

另外,这里有一个非常常用的小技巧,如果我们需要注入的是一个值,就可以通过 JSON.stringify 的方式来得到表示这个值的字面量。这样就不容易出错了。具体实现如下:

DefinePlugin 的作用虽然简单,但是却非常有用,我们可以用它在代码中注入一些可能变化的值。

# Mini CSS Extract Plugin

对于 CSS 文件的打包,一般我们会使用 style-loader 进行处理,这种处理方式最终的打包结果就是 CSS 代码会内嵌到 JS 代码中。

mini-css-extract-plugin 是一个可以将 CSS 代码从打包结果中提取出来的插件,它的使用非常简单,同样也需要先通过 npm 安装一下这个插件。具体命令如下:

  npm i mini-css-extract-plugin --save-dev

安装完成过后,我们回到 Webpack 的配置文件。具体配置如下:

我们这里先导入这个插件模块,导入过后我们就可以将这个插件添加到配置对象的 plugins 数组中了。这样 Mini CSS Extract Plugin 在工作时就会自动提取代码中的 CSS 了。

除此以外,Mini CSS Extract Plugin 还需要我们使用 MiniCssExtractPlugin 中提供的 loader 去替换掉 style-loader,以此来捕获到所有的样式。

这样的话,打包过后,样式就会存放在独立的文件中,直接通过 link 标签引入页面。

不过这里需要注意的是,如果你的 CSS 体积不是很大的话,提取到单个文件中,效果可能适得其反,因为单独的文件就需要单独请求一次。个人经验是如果 CSS 超过 200KB 才需要考虑是否提取出来,作为单独的文件。

# Optimize CSS Assets Webpack Plugin

使用了 Mini CSS Extract Plugin 过后,样式就被提取到单独的 CSS 文件中了。但是这里同样有一个小问题。

我们回到命令行,这里我们以生产模式运行打包。那按照之前的了解,生产模式下会自动压缩输出的结果,我们可以打开打包生成的 JS 文件。具体结果如下:

然后我们再打开输出的样式文件。具体结果如下:

这里我们发现 JavaScript 文件正常被压缩了,而样式文件并没有被压缩。

这是因为,Webpack 内置的压缩插件仅仅是针对 JS 文件的压缩,其他资源文件的压缩都需要额外的插件。

Webpack 官方推荐了一个 Optimize CSS Assets Webpack Plugin (opens new window) 插件。我们可以使用这个插件来压缩我们的样式文件。

我们回到命令行,先来安装这个插件,具体命令如下:

  npm i optimize-css-assets-webpack-plugin --save-dev

安装完成过后,我们回到配置文件中,添加对应的配置。具体代码如下:

这里同样先导入这个插件,导入完成以后我们把这个插件添加到 plugins 数组中。

那此时我们再次回到命令行运行打包。

打包完成过后,我们的样式文件就会以压缩格式输出了。具体结果如下:

不过这里还有个额外的小点,可能你会在这个插件的官方文档中发现,文档中的这个插件并不是配置在 plugins 数组中的,而是添加到了 optimization 对象中的 minimizer 属性中。具体如下:

这是为什么呢?

其实也很简单,如果我们配置到 plugins 属性中,那么这个插件在任何情况下都会工作。而配置到 minimizer 中,就只会在 minimize 特性开启时才工作。

所以 Webpack 建议像这种压缩插件,应该我们配置到 minimizer 中,便于 minimize 选项的统一控制。

但是这么配置也有个缺点,此时我们再次运行生产模式打包,打包完成后再来看一眼输出的 JS 文件,此时你会发现,原本可以自动压缩的 JS,现在却不能压缩了。具体 JS 的输出结果如下:

那这是因为我们设置了 minimizer,Webpack 认为我们需要使用自定义压缩器插件,那内部的 JS 压缩器就会被覆盖掉。我们必须手动再添加回来。

内置的 JS 压缩插件叫作 terser-webpack-plugin,我们回到命令行手动安装一下这个模块。

  npm i terser-webpack-plugin --save-dev

安装完成过后,这里我们再手动添加这个模块到 minimizer 配置当中。具体代码如下:

精选:

  • 每种资源最终只需要使用一个 minimizer,不回涉及到多个压缩器同时压缩同一个资源的情况,也就不会有先后执行的问题

  • webpack5 可以用 '...' 把默认的 minimizer 展开,方便很多了

参考仓库:

# 第 12 讲:如何选择打包工具:Rollup 和 Webpack?

今天我要跟你介绍另外一款同样十分优秀的打包工具:Rollup。

Rollup 是一款 ES Modules 打包器。它也可以将项目中散落的细小模块打包为整块代码,从而使得这些划分的模块可以更好地运行在浏览器环境或者 Node.js 环境。

从作用上来看,Rollup 与 Webpack 非常类似。不过相比于 Webpack,Rollup 要小巧的多。因为 Webpack 在配合一些插件的使用下,几乎可以完成开发过程中绝大多数前端工程化的工作。而 Rollup 可以说仅仅是一个 ES Modules 打包器,没有更多其他的功能了。

例如,在 Webpack 中支持 HMR 这种对开发过程十分友好的功能,而在 Rollup 中就没有办法完全支持。

Rollup 诞生的目的并不是要与 Webpack 这样的工具全面竞争。它的初衷只是希望能够提供一个高效的 ES Modules 打包器,充分利用 ES Modules 的各项特性,构建出结构扁平,性能出众的类库。

至于它的其他特点和优势,我们需要上手过后才能深入了解。

# 快速上手

这里我准备了一个简单的示例,具体结构如下:

  .
├── src
│   ├── index.js
│   ├── logger.js
│   └── messages.js
└── package.json

在这个示例的源代码中我准备了三个文件,并且使用 ES Modules 组织的代码模块化。部分代码如下:

// ./src/messages.js
export default {
  hi: "Hey Guys, I am zce~",
};
// ./src/logger.js
export const log = (msg) => {
  console.log("---------- INFO ----------");
  console.log(msg);
  console.log("--------------------------");
};
export const error = (msg) => {
  console.error("---------- ERROR ----------");
  console.error(msg);
  console.error("---------------------------");
};
// ./src/index.js
import { log } from "./logger";
import messages from "./messages";
log(messages.hi);

如上述代码所示,其中:

  • messages.js 文件中以默认导出的方式导出了一个对象;
  • logger.js 文件中单个导出了两个函数成员;
  • 最后在 index.js 文件中导入了这两个模块,并且使用了它们。

接下来,我们尝试使用 Rollup 完成这个示例应用的打包。这里需要先通过 npm 安装 rollup 这个模块。具体命令如下:

  $ npm i rollup --save-dev

安装完成过后,rollup 这个模块同样会在 node_modules/.bin 目录中为我们提供一个 CLI 程序,我们就可以通过这个 CLI 去使用 Rollup 打包。具体命令如下:

  $ npx rollup

P.S. 对于 node_modules/.bin 目录下的 CLI,我们可以使用 npx 命令或者 yarn 命令直接启动。

执行 rollup 命令,在不传递任何参数的情况下,这个命令会自动打印出它的帮助信息。具体如下图:

在这个帮助信息的一开始,就已经告诉我们 rollup 命令的正确用法了:我们应该通过参数指定一个打包入口文件。正确命令如下:

  $ npx rollup ./src/index.js

这里指定的打包入口是 src/index.js 文件。再次执行 rollup 命令,具体执行结果如下:

根据控制台的输出结果,我们发现 Rollup 直接将打包结果打印到控制台中了。

当然,正常情况下我们还是需要将打包结果输出到一个文件中。具体就是通过 CLI 的 --file 参数指定输出文件路径,具体命令如下:

  $ npx rollup ./src/index.js --file ./dist/bundle.js

这样打包的结果就会输出到文件中。

完成以后,我们找到 Rollup 打包输出的文件,具体结果如下:

在这个文件中我们的第一印象就是,Rollup 打包结果惊人的简洁,基本上就跟我们手写的代码一样。相比于 Webpack 大量的引导代码和一堆的模块函数,这里的输出结果没有任何多余代码,就是把打包过程中的各个模块按照依赖顺序,先后拼接到了一起。

而且我们仔细观察打包结果,你会发现,在我们输出的结果中只会保留那些用到的部分,对于未引用部分都没有输出。这是因为 Rollup 默认会自动开启 Tree-shaking 优化输出结果,Tree-shaking 的概念最早也就是 Rollup 这个工具提出的。

# 配置文件

Rollup 同样支持以配置文件的方式去配置打包过程中的各项参数,我们可以在项目的根目录下新建一个 rollup.config.js 的配置文件。具体结构如下:

 .
 ├── src
 │   ├── index.js
 │   ├── logger.js
 │   └── messages.js
 ├── package.json
+└── rollup.config.js

这个文件虽然同样是运行在 Node.js 环境中,但是 Rollup 会额外处理配置文件,所以在 rollup.config.js 中我们可以直接使用 ES Modules 标准。具体代码如下:

// ./rollup.config.js
export default {
  input: "src/index.js",
  output: {
    file: "dist/bundle.js",
    format: "es", // 输出格式
  },
};

这个文件中需要导出一个配置对象,在这个对象中我们可以通过 input 属性指定打包的入口文件路径,通过 output 指定输出相关配置,output 属性是一个对象,在 output 对象中可以使用 file 属性指定输出的文件名,format 属性指定输出代码的格式。

完成以后,我们回到命令行,再次执行 rollup 命令,不过需要注意的是,这里需要通过 --config 参数来表明使用项目中的配置文件。你也可以通过这个参数来指定不同的配置文件名称。具体命令如下:

  $ npx rollup --config # 使用默认配置文件
  $ npx rollup --config rollup.prod.js # 指定配置文件路径

# 输出格式

Rollup 打包支持多种输出格式,这里我们回到配置文件中,配置同时输出所有格式下的文件,具体配置如下:

// ./rollup.config.js
// 所有 Rollup 支持的格式
const formats = ["es", "amd", "cjs", "iife", "umd", "system"];
export default formats.map((format) => ({
  input: "src/index.js",
  output: {
    file: `dist/bundle.${format}.js`,
    format,
  },
}));

在这个配置当中我们导出了一个数组,数组中的每个成员都是一个单独的打包配置,这样 Rollup 就会分别按照每个配置单独打包。这一点与 Webpack 非常相似。

配置完成之后,我们回到命令行终端,再次运行 Rollup 打包。那这次打包过后,dist 目录下就会生成不同格式的输出结果,如下图所示:

你可以自己依次去了解一下每种格式的输出结果,其实不同的输出格式大都是为了适配不同的运行环境,并没有什么需要额外理解的地方。

# 使用插件

Rollup 自身的功能就只是 ES Modules 模块的合并,如果有更高级的要求,例如加载其他类型的资源文件或者支持导入 CommonJS 模块,又或是编译 ES 新特性,这些额外的需求 Rollup 同样支持使用插件去扩展实现。

Webpack 中划分了 Loader、Plugin 和 Minimizer 三种扩展方式,而插件是 Rollup 的唯一的扩展方式。

这里我们先来尝试使用一个可以让我们在代码中导入 JSON 文件的插件:@rollup/plugin-json,通过这个过程来了解如何在 Rollup 中使用插件。

首先我们需要将 @rollup/plugin-json 作为项目的开发依赖安装进来。具体安装命令:

  $ npm i @rollup/plugin-json --save-dev

安装完成过后,我们打开配置文件。由于 rollup 的配置文件中可以直接使用 ES Modules,所以我们这里使用 import 导入这个插件模块。具体代码如下:

// ./rollup.config.js
import json from "@rollup/plugin-json";
export default {
  input: "src/index.js",
  output: {
    file: "dist/bundle.js",
    format: "es",
  },
  plugins: [json()],
};

@rollup/plugin-json 模块的默认导出就是一个插件函数。我们可以将这个函数的调用结果添加到配置对象的 plugins 数组中,注意这里是将调用结果放到数组中,而不是将这个函数直接放进去。

配置好这个插件过后,我们就可以在代码中通过 import 导入 json 文件了。我们回到 index.js 文件中,这里我们尝试通过 import 导入 package.json,具体代码如下:

// ./src/index.js
import { name, version } from "../package.json";
console.log(name, version);

那这个 JSON 文件中的每一个属性都会作为单独的导出成员。我们可以提取一下 JSON 中的 name 和 version,然后把它打印出来。

完成以后,我们打开命令行终端,再次运行 Rollup 打包。打包完成以后,我们找到输出的 bundle.js,具体结果如下:

此时你就能看到,package.json 中的 name 和 version 正常被打包进来了,而且其他没用到的属性也都被 Tree-shaking 移除掉了。

以上就是 Rollup 中插件的使用。

# 加载 NPM 模块

Rollup 默认只能够按照文件路径的方式加载本地的模块文件,对于 node_modules 目录中的第三方模块,并不能像 Webpack 一样,直接通过模块名称直接导入。

为了抹平这个差异,Rollup 给出了一个 @rollup/plugin-node-resolve 插件,通过使用这个插件,我们就可以在代码中直接使用模块名称导入模块了。

同样,我们需要先安装这个插件,具体命令如下:

  $ npm i @rollup/plugin-node-resolve --save-dev

安装完成过后,打开配置文件,这里同样导入插件函数,然后把它配置到 plugins 数组中。具体配置如下:

// ./rollup.config.js
import json from "@rollup/plugin-json";
import resolve from "@rollup/plugin-node-resolve";
export default {
  input: "src/index.js",
  output: {
    file: "dist/bundle.js",
    format: "es",
  },
  plugins: [json(), resolve()],
};

完成以后我们就可以回到代码中直接导入 node_modules 中的第三方模块了。例如:

// ./src/index.js
import { camelCase } from "lodash-es";
console.log(camelCase("hello rollup"));

这里我导入的是我提前安装好的一个 lodash-es 模块,这个模块就是常用的 lodash 模块的 ESM 版本。导入过后我们就可以使用这个模块所提供的工具方法了。

P.S. 相比于普通的 lodash,lodash-es 可以更好地支持 Tree-shaking。

完成过后我们再次打开命令行终端,运行 Rollup 打包,此时 lodash 就能够打包到我们的 bundle.js 中了。

这里使用 Lodash 的 ESM 版本而不是 Lodash 普通版本的原因是 Rollup 默认只能处理 ESM 模块。如果要使用普通版本则需要额外处理。

# 加载 CommonJS 模块

由于 Rollup 设计的是只处理 ES Modules 模块的打包,所以如果在代码中导入 CommonJS 模块,默认是不被支持的。但是目前大量的 NPM 模块还是使用 CommonJS 方式导出成员,所以为了兼容这些模块。官方给出了一个插件,叫作 @rollup/plugin-commonjs

这个插件在用法上跟前面两个插件是一样的,我就不单独演示了。我们直接看一下这个插件的效果。这里我添加了一个 cjs-module.js 文件,具体代码如下:

// ./src/cjs-module.js
module.exports = {
  foo: "bar",
};

这个文件中使用 CommonJS 的方式导出了一个对象。然后回到入口文件中通过 ES Modules 的方式导入,具体代码如下:

// ./src/index.js
// 导入 CommonJS 模块成员
import cjs from "./cjs-module";
// 使用模块成员
console.log(cjs); // cjs => { foo: 'bar' }

入口文件导入的结果就是 cjs-module.js 中导出的对象了。

# Code Splitting

Rollup 的最新版本中已经开始支持代码拆分了。我们同样可以使用符合 ES Modules 标准的动态导入方式实现模块的按需加载。例如:

// ./src/index.js
// 动态导入的模块会自动分包
import("./logger").then(({ log }) => {
  log("code splitting~");
});

Rollup 内部也会处理代码拆分。不过按照之前的配置方式,这里直接打包会报出一个错误:

出现这个错误的原因是:在 Rollup 在分包过后会输出多个 JS 文件,需要我们在配置中指定输出的目录,而不是一个具体的文件名,具体配置如下:

// ./rollup.config.js
export default {
  input: "src/index.js",
  output: {
    // file: 'dist/bundle.js', // code splitting 输出的是多个文件
    dir: "dist",
    format: "es",
  },
};

这里我们将 output 配置中的 file 选项删掉,取而代之的是添加一个 dir 选项,把它设置为 dist,也就是输出到 dist 目录中。

这样的话,再次打包就可以正常输出了。具体输出结果如下:

这次打包过程中,Rollup 就会自动提取动态导入的模块到单独的 JS 文件中了。

# 输出格式问题

目前采用的输出格式是 es,所以自动分包过后,得到的代码还是使用 ES Modules 实现的动态模块加载,具体输出结果如下:

很明显,这种方式的代码仍然会存在环境兼容性问题:如果在低版本浏览器,这种输出结果是无法正常执行的。

解决这个问题的办法就是修改 Rollup 打包输出的格式。目前所有支持动态导入的输出格式中,只有 amd 和 system 两种格式打包的结果适合于浏览器环境。

所以在这种情况下,我们可以选择以 amd 或者 system 格式输出。这里我们以 amd 为例,这里我们先将 Rollup 配置中的 format 设置为 amd。具体配置如下:

// ./rollup.config.js
export default {
  input: "src/index.js",
  output: {
    dir: "dist",
    format: "amd",
  },
};

这样的话,再次打包输出的结果就是采用 AMD 标准组织的代码了,具体如下:

需要注意一点,这种 AMD 标准在浏览器中也不是直接支持的,也就是说我们还是需要使用一个支持这个标准的库来加载这些输出的模块,例如 Require.js,具体使用方式参考:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>AMD Format output</title>
  </head>
  <body>
    <script
      src="https://unpkg.com/requirejs@2.3.6/require.js"
      data-main="dist/index.js"
    ></script>
  </body>
</html>

P.S. 本课时中所有的案例源代码:https://github.com/zce/rollup-demo

# 写在最后

通过以上的探索,我们发现 Rollup 确实有它的优势:

  • 输出结果更加扁平,执行效率更高;
  • 自动移除未引用代码;
  • 打包结果依然完全可读。

但是它的缺点也同样明显:

  • 加载非 ESM 的第三方模块比较复杂;
  • 因为模块最终都被打包到全局中,所以无法实现 HMR(模块热替代);
  • 浏览器环境中,代码拆分功能必须使用 Require.js 这样的 AMD 库。

综合以上特点,我们发现如果我们开发的是一个应用程序,需要大量引用第三方模块,同时还需要 HMR 提升开发体验,而且应用过大就必须要分包。那这些需求 Rollup 都无法满足。

而如果我们是开发一个 JavaScript 框架或者库,那这些优点就特别有必要,而缺点呢几乎也都可以忽略,所以在很多像 React 或者 Vue 之类的框架中都是使用的 Rollup 作为模块打包器,而并非 Webpack。

但是到目前为止,开源社区中大多数人还是希望这两个工具共同存在,并且能够相互支持和借鉴,原因很简单:让更专业的工具完成更专业的事情。

总结一下:Webpack 大而全,Rollup 小而美。

在对它们的选择上,我的基本原则是:应用开发使用 Webpack,类库或者框架开发使用 Rollup。

不过这并不是绝对的标准,只是经验法则。因为 Rollup 也可用于构建绝大多数应用程序,而 Webpack 同样也可以构建类库或者框架。

另外随着近几年 Webpack 的发展,Rollup 中的很多优势几乎已经抹平了,所以这种对比慢慢地也就没有太大意义了。

更多资料:

Vue3 跟随发布的 vite (opens new window)

# 第 13 讲:如何使用 Parcel 零配置完成打包任务?

今天我要带你了解一个近两年非常火的打包工具:Parcel。

Parcel 是一款完全零配置的前端打包器,它提供了 “傻瓜式” 的使用体验,我们只需了解它提供的几个简单的命令,就可以直接使用它去构建我们的前端应用程序了。

下面我们直接来看具体如何去使用 Parcel。

# 快速上手

这里我们先创建一个空目录,然后通过 npm init 初始化一个项目中的 package.json 文件。

完成以后我们就可以安装 Parcel 模块了,具体命令如下:

  $ npm install parcel-bundler --save-dev

这里需要注意 Parcel 的 npm 模块名称叫作 parcel-bundler,我们同样应该将它安装到项目的开发依赖中。

安装完成过后,parcel-bundler 模块就在 node_modules/.bin 目录中提供了一个叫作 parcel 的 CLI 程序,后续我们就是使用这个 CLI 程序执行应用打包。

既然是打包应用代码,那我们这里就得先有代码。我们回到项目中创建一些必需的文件,结构如下:

.
├── src
│   ├── index.html
│   ├── logger.js
│   └── main.js
└── package.json

首先在根目录下新建一个 src 目录,用于存放开发阶段编写的源代码,同时创建两个 JS 文件,分别是 logger.js 和 main.js,然后再创建一个 index.html 文件,这个 index.html 文件会将是 Parcel 打包的入口文件。

虽然 Parcel 跟 Webpack 一样都支持以任意类型文件作为打包入口,不过 Parcel 官方还是建议我们使用 HTML 文件作为入口。官方的理由是 HTML 是应用在浏览器端运行时的入口。

那在这个 HTML 入口文件中,我们可以像平时一样去编写代码,也可以正常去引用资源。在它里面引用的资源,最终都会被 Parcel 打包到一起。

我们这里先尝试在 index.html 中引入 main.js 脚本文件,具体代码如下:

<!-- ./src/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Parcel Tutorials</title>
  </head>
  <body>
    <script src="main.js"></script>
  </body>
</html>

紧接着,在 main.js 中按照 ES Modules 的方式导入 logger.js 中的成员,具体代码如下:

// ./src/main.js
import { log } from "./logger";
log("hello parcel");
// ./src/logger.js
export const log = (msg) => {
  console.log("---------- INFO ----------");
  console.log(msg);
};

Parcel 同样支持对 ES Modules 模块的打包。

完成以后,我们打开命令行终端,然后使用 npx 去运行 node_modules 目录下的 parcel 命令。具体命令如下:

  $ npx parcel src/index.html

parcel 命令需要我们传入打包入口文件路径,那我们这里就应该是 src/index.html。

此时如果执行这个命令,Parcel 就会根据这里传入的参数,先找到 index.html,然后在根据 HTML 中的 script 标签,找到 main.js,最后再顺着 import 语句找到 logger.js 模块,从而完成整体打包。

回车执行过后,这里我们发现 Parcel 这个命令不仅仅帮我们打包了应用,而且还同时开启了一个开发服务器,这就跟 Webpack Dev Server 一样。

我们打开这个地址,启动浏览器,然后打开开发人员工具。Parcel 同样支持自动刷新这样的功能。具体效果如下:

以上就是 Parcel 的基本使用,相比于 Webpack,Parcel 在使用上的确简化了很多。

# 模块热替换

如果你需要的是模块热替换的体验,Parcel 中也可以支持。我们回到 main.js 文件中,这里同样需要使用 HMR 的 API。具体代码如下:

// ./src/main.js
import { log } from "./logger";
log("hello parcel");
// HMR API
if (module.hot) {
  module.hot.accept(() => {
    console.log("HMR~");
  });
}

我们需要先判断一下 module.hot 对象是否存在,如果存在则证明当前环境可以使用 HMR 的 API,那我们就可以使用 module.hot.accept 方法去处理热替换。

不过这里的 accept 方法与 Webpack 提供的 HMR 有点不太一样,Webpack 中的 accept 方法支持接收两个参数,用来处理指定的模块更新后的逻辑。

而这里 Parcel 提供的 accept 只需要接收一个回调参数,作用就是当前模块更新或者所依赖的模块更新过后自动执行传入的回调函数,这相比于之前 Webpack 中的用法要简单很多。

关于模块更新后的处理逻辑,这里我们就不再过多介绍了,你可以参考我们在 08 课时 Webpack HMR 中的介绍。

# 自动安装依赖

除了热替换,Parcel 还支持一个非常友好的功能:自动安装依赖。试想一下,你正在开发一个应用的过程中,突然需要使用某个第三方模块,那此时你就需要先停止正在运行的 Dev Server,然后再去安装这个模块,安装完成过后再重新启动 Dev Server。有了自动安装依赖的功能就不必如此麻烦了。

我们回到 main.js 文件中,假设我们这里想要使用一下 jquery。虽然我们并没有安装这个模块,但是因为有了自动安装依赖功能,我们这里只管正常导入,正常使用就好了。具体效果如下:

在文件保存过后,Parcel 会自动去安装刚刚导入的模块包,极大程度地避免手动操作。

# 其他类型资源加载

除此以外,Parcel 同样支持加载其他类型的资源模块,而且相比于其他的打包器,在 Parcel 中加载其他类型的资源模块同样是零配置的。

例如我们这里再来添加一个 style.css 的样式文件,并且在这个文件中添加一些简单的样式,具体如下:

 .
 ├── src
 │   ├── index.html
 │   ├── logger.js
 │   ├── main.js
+│   └── style.css
 └── package.json

然后回到 main.js 中通过 import 导入这个样式文件,具体如下:

// ./src/main.js
import { log } from "./logger";
import "./style.css";
log("hello parcel");

保存过后,样式文件可以立即生效。效果如下:

你会发现,导入样式的操作,整个过程我们并没有停下来做额外的事情。

总之,Parcel 希望给开发者的体验就是想做什么,只管去做,其他额外的事情就交给工具来处理。

# 动态导入

另外,Parcel 同样支持直接使用动态导入,内部也会自动处理代码拆分,我们也一起来尝试一下。

这里我们先将静态导入的 jQuery 注释掉。然后使用动态导入的方式导入 jQuery 模块。具体代码如下:

// ./src/main.js
// import $ from 'jquery'
import { log } from "./logger";
log("hello parcel");
import("jquery").then(($) => {
  $(document.body).append("<h1>Hello Parcel</h1>");
});

import 函数返回的就是一个 Promise 对象,在这个 Promise 对象 then 方法的回调中,我们就能够拿到导入的模块对象了,然后我们就可以把使用 jQuery 的代码移到这个回调函数中。

保存过后,回到浏览器,找到开发人员工具的 Network 面板,这里就能够看到拆分出来的 jquery 所对应的 bundle 文件请求了。具体效果如下图:

那以上基本上就是 Parcel 最常用的一些特性了,使用上根本没有任何难度,从头到尾我们都只是执行了一个 Parcel 命令。

# 生产模式打包

接下来我们来看,Parcel 如何以生产模式打包。生产模式打包,具体命令如下:

  $ npx parcel build  src/index.html

我们只需要执行 parcel build 然后跟上打包入口文件路径,就可以以生产模式运行打包了。

这里补充一点,相同体量的项目打包,Parcel 的构建速度会比 Webpack 快很多。因为 Parcel 内部使用的是多进程同时工作,充分发挥了多核 CPU 的性能。

P.S. Webpack 中也可以使用一个叫作 happypack 的插件实现这一点。

那我们这里再来看一下输出的打包结果,具体结果如下:

此时,dist 目录下就都是本次打包的结果了。这里的输出文件也都会被压缩,而且样式代码也会被单独提取到单个文件中。

那这就是 Parcel 的体验,整体体验下来就是一个感觉:舒服,因为它在使用上真的太简单了。

# 写在最后

  • 真正做到了完全零配置,对项目没有任何的侵入;
  • 自动安装依赖,开发过程更专注;
  • 构建速度更快,因为内部使用了多进程同时工作,能够充分发挥多核 CPU 的效率。

但是目前看来,如果你去观察开发者的实际使用情况,绝大多数项目的打包还是会选择 Webpack。个人认为原因有两点:

  • 1.Webpack 生态更好,扩展更丰富,出现问题容易解决;
  • 2.随着这两年的发展,Webpack 越来越好用,开发者也越来越熟悉。

所以,Parcel 这样的工具对于开发者而言,我们去了解它,也就是为了保持对新鲜技术和工具的敏感度,从而更好地把握技术趋势和走向,仅此而已。

# 加餐:Vue3 到底带来了哪些变化

在 4 月 22 日的直播中,我就 “Vue.js 3.0 到底带来了哪些变化?” 这个话题,分享了一些自己的看法。在这里我做了一篇内容梳理,希望对你有所帮助。如果你想要了解直播当天的详细内容,可以观看本课时的视频或者音频内容。

今天的内容会分为以下五个部分:

  • Composition APIs;
  • 设计动机 / 核⼼优势;
  • 基于 Webpack 构建;
  • Vue CLI experimental;
  • Official Libraries。

首先,我们先回顾一下 Vue 的发展历程:

  • 2018-09-30:Vue.js 2.0 两周年,公开宣布 Vue.js 3.0 开发计划;
  • 2019-10-05:Vue.js 3.0 公开源代码;
  • 2019-12-20:Vue.js 发布 3.0-alpha.0 版本;
  • 2020-04-17:Vue.js 发布 3.0-beta.1 版本;
  • 2020-01-05:vue-cli-plugin-vue-next v0.0.2;
  • 2020-02-18:vue-router-next v4.0.0-alpha.0;
  • 2020-03-14:eslint-plugin-vue v7.0.0-alpha.0;
  • 2020-03-15:vuex v4.0.0-alpha.1;
  • 2020-04-12:vue-test-utils-next v2.0.0-alpha.1。

这里我希望你要了解:

  • 新版本发布固然有它的优势,但是并不一定所有的企业都会立即采用;
  • 新版本的发布不代表老版本已经一无是处,版本的迭代更新是一个必然发展状态,但它会带动起来一些周边的生态发展。

# 快速体验 Composition APIs

vs. Options APIs

如下图所示:

Vue.js 3.0 核心优势

  • 没有 this,没烦恼
  • 更好的类型推导能力(Typescript)
  • 更友好的 Tree-shaking 支持(渐进式体验)
  • 更大的代码压缩空间
  • 更灵活的逻辑复用能力

# 逻辑复用案例

对于逻辑复用这块我们可以通过几个小案例来体会一下。

案例一、常用功能性状态复用:

import { ref } from "vue";

const useToggle = (initialState = false) => {
  const on = ref(initialState);
  const toggle = (value) => {
    on.value = typeof value === "undefined" ? !on.value : value;
  };
  return { on, toggle };
};

案例二、获取数据逻辑复用:

import { ref, onMounted } from "vue";

const useUsers = (query) => {
  const users = ref([]);

  onMounted(async () => {
    this.users = await UserService.fetch(query);
  });

  return { users };
};

案例三、BOM API 封装:

import { ref, onMounted, onUnmounted } from "vue";

const useWindowSize = () => {
  const width = ref(window.innerWidth);
  const height = ref(window.innerHeight);

  const update = (e) => {
    width.value = window.innerWidth;
    height.value = window.innerHeight;
  };

  onMounted(() => window.addEventListener("resize", update));
  onUnmounted(() => window.removeEventListener("resize", update));

  return { width, height };
};

# 基于 Webpack 构建

由于 Vue CLI 自身还没有很好地支持 Vue.js 3.0 版本,所以对于 3.0 项目的构建,我们还是需要直接使用 Webpack 构建。这里我们分享一个基于 Webpack 构建 Vue.js 3.0 的基本操作

以下是具体命令行操作:

  // 创建项目目录
  $ mkdir vue-next-sample

  // 初始化 package.json 文件,管理项目依赖
  $ npm init -y

  // 安装 Vue,js 3.0 模块
  $ npm i vue@next

  // 安装 webpack 相关模块
  $ npm i webpack webpack-cli webpack-dev-server --save-dev

  // 安装一些需要用到的 webpack 插件
  $ npm i html-webpack-plugin mini-css-extract-plugin css-loader --save-dev

  // 安装 Vue.js 单文件组件的加载器
  $ npm i vue-loader@next @vue/compiler-sfc --save-dev

项目结构设计如下:

└─ vue-next-sample ····························· project root
   ├─ public ··································· static dir
   │  └─ index.html ···························· index template
   ├─ src ······································ source dir
   │  ├─ App.vue ······························· root component (sfc)
   │  └─ main.js ······························· app entry
   ├─ package.json ····························· package file
   └─ webpack.config.js ·······

其中 Webpack 的核心配置如下:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");

module.exports = (env) => ({
  mode: env.production ? "production" : "development",
  entry: "./src/main.js",
  output: {
    filename: "bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: "vue-loader",
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      title: "Vue.js 3.0 Beta",
      template: "public/index.html",
    }),
  ],
});

# 基于 Vue CLI experimental

Vue CLI 对 Vue.js 3.0 的支持目前是以一个**插件(vue-cli-plugin-vue-next)**的形式实现的,目前属于实验阶段(experimental)。

具体使用方法如下:

  // install vue-cli globally
  $ npm install @vue/cli --global

  // create boilerplate app by vue-cli
  $ vue create vue-next-app

  // into cwd
  $ cd vue-next-app

  // add vue-cli-plugin-vue-next
  $ vue add vue-next

  // running
  $ npm run serve

这里你需要注意,千万不要在成熟项⽬中使⽤这个插件直接升级项目,这会导致很多问题,后面 Vue 官方会给出 2.x 项目升级到 3.0 的迁移工具,到时候再视情况决定是否使用。

# 结合 Official Libraries

最后这一块内容是关于官方的几个库的最新版本,以及如何去结合 Vue.js 3.0 使用:

# Vue Router

vue-router一直以来是使用 Vue.js 开发 SPA 类型应用必不可少的,针对 3.0,vue-router 也有一些调整。

注册(定义)路由的用法:

import { createRouter, createWebHistory } from "vue-router";
import Home from "../views/Home.vue";

export default createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes: [
    {
      path: "/",
      name: "Home",
      component: Home,
    },
    {
      path: "/about/:slug?",
      name: "About",
      component: () =>
        import(/* webpackChunkName: "about" */ "../views/About.vue"),
    },
  ],
});

组件中获取当前路由信息的方法:

import { useRoute } from "vue-router";

export default {
  name: "Home",
  setup() {
    const route = useRoute();
    console.log(route);
    // route => { path: {...}, name: {...}, params: {...}, query: {...}, hash: {...}, ...}
  },
};

# Vuex

对于 Vuex, API 改动是最小的,基本上没有什么变化。

创建 Store 的方法:

import { createStore } from "vuex";

export default createStore({
  state: {},
  getters: {},
  mutations: {},
  actions: {},
  modules: {},
  plugins: [],
});

方式一:组件中使用 Store 的(跟 2.x 一样)(key 名可缩写,这里就不用省略写法了):

import { mapGetters, mapActions } from "vuex";

export default {
  name: "Counter",
  computed: mapGetters({
    count: "count",
  }),
  methods: mapActions({
    increment: "increment",
    incrementAsync: "incrementAsync",
    decrement: "decrement",
    decrementAsync: "decrementAsync",
  }),
};

方式二:使用 useStore API (推荐)

import { computed } from "vue";
import { useStore } from "vuex";

export default {
  name: "Counter",
  setup() {
    const store = useStore();
    const count = computed(() => store.getters.count);

    const increment = () => store.dispatch("increment");
    const incrementAsync = () => store.dispatch("incrementAsync");
    const decrement = () => store.dispactch("decrement");
    const decrementAsync = () => store.dispatch("decrementAsync");

    return {
      count,
      increment,
      incrementAsync,
      decrement,
      decrementAsync,
    };
  },
};

除此之外,Vue.js 官方还给出了目前官方的一些周边库的状态,下表为:Official Libraries Vue 3.0 Support Status

| Project | Status | | vue-router | Alpha [Proposed RFCs]github (opens new window) [npm] | | vuex | Alpha, with same API GitHub (opens new window) [npm] | | vue-class-component | Alpha [GitHub](github. com/vuejs/vue-class-component/tree/next) [npm] | | vue-cli | Experimental support via vue-cli-plugin-vue-next | | eslint-plugin-vue | Alpha GitHub (opens new window) [npm] | | vue-test-utils | Pre-alpha GitHub (opens new window) [npm] | | vue-devtools | WIP | | jsx | WIP |

# 总结

配置 名称集合:

esm:import/export

commonjs:require/modules.export

entry:入口

output:出口

filename: 打包后文件名

loader 配置规则:

module: {
  rules: [
    {
      test: /\.css$/,
      use: "css-loader",
    },
  ];
}

clean-webpack-plugin: 每次打包前清除之前的 dist 文件

html-webpack-plugin: 用于生产 HTML 插件

webpack-dev-server:热更新

  devServer: {
    hot: true, // 热更新(HMR)
    // hotOnly: true,  只使用 HMR,不会 fallback,到 报错信息,不会重新自动更新到之前的
    contentBase: path.join(__dirname, 'dist'),
    // contentBase: 'public', // 静态资源访问
    compress: true,
    port: 9000
  },
  plugins: [
    // HMR 特性所需要的的插件
    new webpack.HotModuleReplacementPlugin()
  ]

proxy: 代理

  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: {
          '^/api': '' // 替换掉代理地址中的 /api 为 空
        },
        changeOrigin: true, // 确保请求 Github 的主机名就是:api.github.com,支持跨域
      }
    }
  }

source map: 打包后是否产生源代码

例如,我们再来看一个 cheap-source-map 模式,这个模式的名字中没有 eval,意味着它没用 eval 执行代码,而名字中没有 module,意味着 Source Map 反推出来的是 Loader 处理后的代码,有 cheap 表示只能定位源代码的行号。

Tree Shaking: 未引用代码,用于打包去掉未引用代码,减少体积

  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,

    // 模块合并,提示运行效率,减少代码体积
    concatenateModules: true,

    // 压缩输出结果
    minimize: true
  }

首先你需要明确一点:Tree-shaking 实现的前提是 ES Modules,也就是说:最终交给 Webpack 打包的代码,必须是使用 ES Modules 的方式来组织的模块化。

为什么很多资料都说 babel-loader 会导致 Tree-shaking 失效,但当我们实际尝试后又发现并没有失效?

其实,这是因为在最新版本(8.x)的 babel-loader 中,已经自动帮我们关闭了对 ES Modules 转换的插件

sideEffects: 副作用,Tree-shaking 只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects 特性了。

// webpack.config.js
optimization: {
  sideEffects: true
}

// package.json
"sideEffects": false, // 关闭代码副作用,没有用到的代码彻底不会打包

总结: 对全局有影响的副作用代码不能移除,而只是对模块有影响的副作用代码就可以移除。

Code Splitting(分块打包)

  • 多入口打包

  • 提取公共模块

  • 动态导入 判断动态导入

  • 魔法注释: 注释打包后的文件名

优化插件

  • Define Plugin:注入全局成员, 例如:process.env.NODE_ENV

  • Mini CSS Extract Plugin:单独提取 css 代码

  • Optimize CSS Assets Webpack Plugin: 压缩样式文件,webpack 默认只会压缩 js 代码

Parcel 零配置完成打包任务

后续有继续加入完善 webpack 的知识点,Webpack 官方文档 (opens new window)

Webpack优化