webpack4原理与实践上

27 阅读1小时+

1. Webpack 究竟解决了什么问题

Webpack最初的目标就是实现前端项目的模块化,也就是说它所解决的问题是如何在前端项目中更高效地管理和维护项目中的每一个资源.

1.1 模块化的演进过程

随着互联网的深入发展,前端技术标准发生了巨大的变化。早期的前端技术标准根本没有预料到前端行业会有今天这个规模,所以在设计上存在很多缺陷,导致我们现在去实现前端模块化时会遇到诸多问题。虽然说,如今绝大部分问题都已经被一些标准或者工具解决了,但在这个演进过程中依然有很多东西值得我们思考和学习,所以接下来我想先介绍一下前端方向落实模块化的几个代表阶段。

1.1.1. Stage 1 - 文件划分方式

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

└─ stage-1
    ├── module-a.js
    ├── module-b.js
    └── index.html
// module-a.js 
function foo () {
   console.log('moduleA#foo') 
}
// module-b.js 
var data = 'something'
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Stage 1</title>
</head>
<body>
  <script src="module-a.js"></script>
  <script src="module-b.js"></script>
  <script>
    // 直接使用全局成员
    foo() // 可能存在命名冲突
    console.log(data)
    data = 'other' // 数据可能会被修改
  </script>
</body>
</html>

缺点:

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

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

1.1.2 Stage 2 – 命名空间方式

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

// module-a.js
window.moduleA = {
  method1: function () {
    console.log('moduleA#method1')
  }
}
// module-b.js
window.moduleB = {
  data: 'something'
  method1: function () {
    console.log('moduleB#method1')
  }
}
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Stage 2</title>
</head>
<body>
  <script src="module-a.js"></script>
  <script src="module-b.js"></script>
  <script>
    moduleA.method1()
    moduleB.method1()
    // 模块成员依然可以被修改
    moduleA.data = 'foo'
  </script>
</body>
</html>

这种命名空间的方式只是解决了命名冲突的问题,但是其它问题依旧存在。

1.1.3 Stage 3 – IIFE

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

// module-a.js
;(function () {
  var name = 'module-a'

  function method1 () {
    console.log(name + '#method1')
  }

  window.moduleA = {
    method1: method1
  }
})()
// module-b.js
;(function () {
  var name = 'module-b'

  function method1 () {
    console.log(name + '#method1')
  }

  window.moduleB = {
    method1: method1
  }
})()

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

1.1.4 Stage 4 - IIFE 依赖参数

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

// module-a.js
;(function ($) { // 通过参数明显表明这个模块的依赖
  var name = 'module-a'

  function method1 () {
    console.log(name + '#method1')
    $('body').animate({ margin: '200px' })
  }

  window.moduleA = {
    method1: method1
  }
})(jQuery)

1.2 模块加载的问题

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

<!DOCTYPE html>
<html>
<head>
  <title>Evolution</title>
</head>
<body>
  <script src="https://unpkg.com/jquery"></script>
  <script src="module-a.js"></script>
  <script src="module-b.js"></script>
  <script>
    moduleA.method1()
    moduleB.method1()
  </script>
</body>
</html>

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

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

1.2.1 模块化规范的出现

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

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

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

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

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

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

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

img

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

img

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

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

img

1.2.2 模块化的标准规范

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

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

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

img

而且在最新的 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 将是你重点考虑的问题。

1.3 ES Modules 特性

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

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

img

1.4. 模块打包工具的出现

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

  • 首先,我们所使用的 ES Modules 模块系统本身就存在环境兼容问题。尽管现如今主流浏览器的最新版本都支持这一特性,但是目前还无法保证用户的浏览器使用情况。所以我们还需要解决兼容问题。
  • 其次,模块化的方式划分出来的模块文件过多,而前端应用又运行在浏览器中,每一个文件都需要单独从服务器请求回来。零散的模块文件必然会导致浏览器的频繁发送网络请求,影响应用的工作效率。
  • 最后,谈一下在实现 JS 模块化的基础上的发散。随着应用日益复杂,在前端应用开发过程中不仅仅只有 JavaScript 代码需要模块化,HTML 和 CSS 这些资源文件也会面临需要被模块化的问题。而且从宏观角度来看,这些文件也都应该看作前端应用中的一个模块,只不过这些模块的种类和用途跟 JavaScript 不同。

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

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

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

img

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

img

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

img

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

2. 如何使用 Webpack 实现模块化打包

对模块化打包方案或工具的设想或者说是诉求:

  • 能够将散落的模块打包到一起
  • 能够编译代码中的新特性
  • 能够支持不同种类的前端资源模块

1.png

目前,前端领域有一些工具能够很好的满足以上这 3 个需求,其中最为主流的就是 Webpack、Parcel 和 Rollup

以 Webpack 为例:

  • Webpack 作为一个模块打包工具,本身就可以解决模块化代码打包的问题,将零散的 JavaScript 代码打包到一个 JS 文件中。
  • 对于有环境兼容问题的代码,Webpack 可以在打包过程中通过 Loader 机制对其实现编译转换,然后再进行打包。
  • 对于不同类型的前端模块类型,Webpack 支持在 JavaScript 中以模块化的方式载入任意类型的资源文件,例如,可以通过 Webpack 实现在 JavaScript 中加载 CSS 文件,被加载的 CSS 文件将会通过 style 标签的方式工作。

除此之外,Webpack 还具备代码拆分的能力,它能够将应用中所有的模块按照我们的需要分块打包。这样一来,就不用担心全部代码打包到一起,产生单个文件过大,导致加载慢的问题。我们可以把应用初次加载所必需的模块打包到一起,其他的模块再单独打包,等到应用工作过程中实际需要用到某个模块,再异步加载该模块,实现增量加载,或者叫作渐进式加载,非常适合现代化的大型 Web 应用。

当然,除了 Webpack,其他的打包工具也都类似,总之,所有的打包工具都是以实现模块化为目标,让我们可以在开发阶段更好的享受模块化带来的优势,同时又不必担心模块化在生产环境中产生新的问题。

2.1 Webpack 快速上手

Webpack 作为目前最主流的前端模块打包器,提供了一整套前端项目模块化方案,而不仅仅局限于对 JavaScript 的模块化。通过Webpack,可以轻松的对前端项目开发过程中涉及的所有资源进行模块化。

因为 Webpack 的设计思想比较先进,起初的使用过程比较烦琐,再加上文档也晦涩难懂,所以在最开始的时候,Webpack 对开发者并不友好,但是随着版本的迭代,官方文档的不断更新,目前 Webpack 对开发者已经非常友好了。此外,随着 React 和 Vue.js 这类框架的普及,Webpack 也随之受到了越来越多的关注,现阶段可以覆盖绝大多数现代 Web 应用的开发过程。

接下来将通过一个案例,快速了解 Webpack 的基本使用,具体操作如下所示:

└─ 02-configuation
   ├── src
   │   ├── heading.js
   │   └── index.js
   └── index.html
// ./src/heading.js
export default () => {
  const element = document.createElement('h2')
  element.textContent = 'Hello webpack'
  element.addEventListener('click', () => alert('Hello webpack'))
  return element
}
// ./src/index.js
import createHeading from './heading.js'
const heading = createHeading()
document.body.append(heading)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Webpack - 快速上手</title>
</head>
<body>
  <script type="module" src="src/index.js"></script>
</body>
</html>

P.S. type="module" 这种用法是 ES Modules 中提出的标准,用来区分加载的是一个普通 JS 脚本还是一个模块

在上面这个案例中,创建了两个 JS 文件,其中 heading.js 中以 ES Modules 的方式导出了一个创建元素的函数,然后在 index.js 中导入 heading.js 并使用了这个模块,最后在 html 文件中通过 script 标签,以模块化的方式引入了 index.js,

按照 ES Modules 的标准,这里的 index.html 可以直接在浏览器中正常工作,但是对于不支持 ES Modules 标准的浏览器,直接使用就会出现错误,所以需要使用 Webpack 这样的工具,将这里按照模块化方式拆分的 JS 代码再次打包到一起。

接下来尝试引入 Webpack 去处理上述案例中的 JS 模块打包。由于 Webpack 是一个 npm 工具模块,所以先初始化一个 package.json 文件,用来管理 npm 依赖版本,完成之后,再来安装 Webpack 的核心模块以及它的 CLI 模块,具体操作如下:

$ npm init --yes
$ npm i webpack webpack-cli --save-dev

P.S. webpack 是 Webpack 的核心模块,webpack-cli 是 Webpack 的 CLI 程序,用来在命令行中调用 Webpack。

安装完成之后,webpack-cli 所提供的 CLI 程序就会出现在 node_modules/.bin 目录当中,可以通过 npx 快速找到 CLI 并运行它,具体操作如下:

$ npx webpack --version
v4.42.1

P.S. npx 是 npm 5.2 以后新增的一个命令,可以用来更方便的执行远程模块或者项目 node_modules 中的 CLI 程序。

这里我们使用的 Webpack 版本是 v4.42.1,有了 Webpack 后,就可以直接运行 webpack 命令来打包 JS 模块代码,具体操作如下:

$ npx webpack

这个命令在执行的过程中,Webpack 会自动从 src/index.js 文件开始打包,然后根据代码中的模块导入操作,自动将所有用到的模块代码打包到一起。

完成之后,控制台会提示:顺着 index.js 有两个 JS 文件被打包到了一起。与之对应的就是项目的根目录下多出了一个 dist 目录,打包结果就存放在这个目录下的 main.js 文件中,具体操作如下图所示:

2.png

这里我们回到 index.html 中修改引入文件的路径,由于打包后的代码就不会再有 import 和 export 了,所以可以删除 type="module"。再次回到浏览器中,查看这个页面,这时代码仍然可以正常工作,index.html 的代码如下所示:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Webpack - 快速上手</title>
</head>
<body>
  <script src="dist/main.js"></script>
</body>
</html>

也可以将 Webpack 命令定义到 npm scripts 中,这样每次使用起来会更加方便,具体如下:

{
  "name": "01-getting-started",
  "version": "0.1.0",
  "main": "n/a",
  "author": "zce <w@zce.me> (https://zce.me)",
  "license": "MIT",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "webpack": "^4.42.1",
    "webpack-cli": "^3.3.11"
  }
}

对于 Webpack 最基本的使用,总结下来就是:先安装 webpack 相关的 npm 包,然后使用 webpack-cli 所提供的命令行工具进行打包

2.2 配置 Webpack 的打包过程

Webpack 4 以后的版本支持零配置的方式直接启动打包,整个过程会按照约定将 src/index.js 作为打包入口,最终打包的结果会存放到 dist/main.js 中。

但很多时候需要自定义这些路径约定,例如,在下面这个案例中,需要它的打包入口是 src/main.js,那此时通过配置文件的方式修改 Webpack 的默认配置,在项目的根目录下添加一个 webpack.config.js,具体结构如下:

 └─ 02-configuation
    ├── src
    │ ├── heading.js
    │ └── main.js
    ├── index.html
    ├── package.json
+   └── webpack.config.js ···················· Webpack 配置文件

webpack.config.js 是一个运行在 Node.js 环境中的 JS 文件,也就是说需要按照 CommonJS 的方式编写代码,这个文件可以导出一个对象,可以通过所导出对象的属性完成相应的配置选项。

这里先尝试添加一个 entry 属性,这个属性的作用是指定 Webpack 打包的入口文件路径。将其设置为 src/main.js,具体代码如下所示:

// ./webpack.config.js
module.exports = {
  entry: './src/main.js'
}

配置完成之后,回到命令行终端重新运行打包命令,此时 Webpack 就会从 src/main.js 文件开始打包。

除了 entry 的配置以外,还可以通过 output 属性设置输出文件的位置。output 属性的值必须是一个对象,通过这个对象的 filename 指定输出文件的文件名称,path 指定输出的目录,具体代码如下所示:

// ./webpack.config.js
const path = require('path')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'output')
  }
}

TIPS:webpack.config.js 是运行在 Node.js 环境中的代码,所以直接可以使用 path 之类的 Node.js 内置模块

由于 Webpack 支持的配置有很多,篇幅的关系,这里我们就不一一介绍了,详细的文档你可以在 Webpack 的官网中找到:webpack.js.org/configurati…

2.2.1 让配置文件支持智能提示

VSCode 对于代码的自动提示是根据成员的类型推断出来的,换句话说,如果 VSCode 知道当前变量的类型,就可以给出正确的智能提示。即便你没有使用 TypeScript 这种类型友好的语言,也可以通过类型注释的方式去标注变量的类型。

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

// ./webpack.config.js
import { Configuration } from 'webpack'

/**
 * @type {Configuration}
 */
const config = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  }
}

module.exports = config

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

// ./webpack.config.js

// 一定记得运行 Webpack 前先注释掉这里。
// import { Configuration } from 'webpack' 

/**
 * @type {Configuration}
 */
const config = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  }
}

module.exports = config

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

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

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

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

// ./webpack.config.js
/** @type {import('webpack').Configuration} */
const config = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  }
}
module.exports = config

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

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

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

2.3.Webpack 工作模式

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

  • production 模式下,启动内置优化插件,自动优化打包结果,打包速度偏慢
  • development 模式下,自动优化打包速度,添加一些调试过程中的辅助插件
  • none 模式下,运行最原始的打包,不做任何额外处理

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

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

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

  • 通过 CLI --mode 参数传入
  • 通过配置文件设置 mode 属性

上述三种 Webpack 工作模式的详细差异可以在官方文档中查看:webpack.js.org/configurati…

2.4 打包结果运行原理

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

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

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

3.png

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

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

4.png

整体生成的代码其实就是一个立即执行函数,这个函数是 Webpack 工作入口(webpackBootstrap),

它接收一个 modules 参数,调用时传入了一个数组(webpack5不一样)。

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

5.png

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

6.png

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

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

7.png

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

总结

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

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

    能够将散落的模块打包到一起

    能够编译代码中的新特性

    能够支持不同种类的前端资源模块

    Webpack 作为一个模块打包工具,本身就可以解决模块化代码打包的问题,将零散的 JavaScript 代码打包到一个 JS 文件中

    对于有环境兼容问题的代码,Webpack 可以在打包过程中通过 Loader 机制对其实现编译转换,然后再进行打包

    对于不同类型的前端模块类型,Webpack 支持在 JavaScript 中以模块化的方式载入任意类型的资源文件,例如,可以通过 Webpack 实现在 JavaScript 中加载 CSS 文件,被加载的 CSS 文件将会通过 style 标签的方式工作

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

    // 一个可以实现配置文件智能提示的小技巧
    /** @type {import('webpack').Configuration} */
    const path = require('path')
    
    module.exports = {
      entry: './src/main.js',
      output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'output')
      }
    }
    
  3. Webpack 工作模式特性的作用

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

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

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

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

参考:

3.如何通过 Loader 实现特殊资源加载

本节的内容是如何通过 Loader 机制实现特殊资源加载,通过开发一个 Loader 深入理解 Webpack Loader 机制的原理

Webpack 想要实现的是整个前端项目的模块化,项目中的各种资源(包括 CSS 文件、图片等)都应该属于需要被管理的模块。换句话说, Webpack 不仅是 JavaScript 模块打包工具,还是整个前端项目(前端工程)的模块打包工具。也就是说,可以通过 Webpack 去管理前端项目中任意类型的资源文件

因为 Webpack 实现不同种类资源模块加载的核心就是 Loader,所以今天我来和你聊聊 Webpack 的 Loader 机制。

3.1 如何加载资源模块

首先,尝试通过 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 打包命令,此时会发现命令行报出了一个模块解析错误,如下所示:

w1.png

错误信息大体的意思是说,在解析模块过程中遇到了非法字符,而且错误出现的位置就是在我们的 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 语法解析模块 */

w2.png

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

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

w3.png

这里有一个非常重要的提示: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。

w5.png

3.2 加载器的使用方式

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

$ npm install css-loader --save-dev 
# or yarn add css-loader --dev
// ./webpack.config.js
module.exports = {
  entry: './src/main.css',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/, // 根据打包过程中所遇到文件路径匹配是否使用这个 loader
        use: 'css-loader' // 指定具体的 loader
      }
    ]
  }
}

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

  • 首先是 test 属性,它是一个正则表达式,用来匹配打包过程中所遇到文件路径,这里我们是以 .css 结尾;
  • 然后是 use 属性,它用来指定匹配到的文件需要使用的 loader,这里用到的是 css-loader。

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

w6.png

3.2.1 样式模块加载的问题

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

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

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

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

w7.png

仔细阅读这个文件,你会发现 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 模块,才可以正常打包,具体配置如下:

// ./webpack.config.js
module.exports = {
  entry: './src/main.css',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        // 对同一个模块使用多个 loader,注意顺序
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  }
}

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

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

3.2.2 通过 JS 加载资源模块

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

 └─ 03-webpack-loader ······················· sample root dir
    ├── src ································· source dir
    │   ├── style.css ······················· style module
+   │   └── main.js ························· entry module
    ├── package.json ························ package file
    └── webpack.config.js ··················· webpack config file
// ./src/main.js
import './style.css'

// app logic...
console.log('App starts running~')

w8.png

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

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

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

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

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

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

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

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

3.3 常用的加载器

  • style-loader: 将css添加到DOM的内联样式标签style里
  • css-loader :允许将css文件通过require的方式引入,并返回css代码
  • less-loader: 处理less
  • sass-loader: 处理sass
  • postcss-loader: 用postcss来处理CSS
  • file-loader: 分发文件到output目录并返回相对路径
  • url-loader: 和file-loader类似,但是当文件小于设定的limit时可以返回一个Data Url
  • html-minify-loader: 压缩HTML
  • babel-loader :用babel来转换ES6文件到ES
名称链接用法
file-loaderwebpack.js.org/loaders/fil…将文件上的 import / require() 解析为url,并将文件发送到输出目录
url-loaderwebpack.js.org/loaders/url…和file-loader类似,但是当文件小于设定的limit时可以返回一个Data Url
babel-loaderwebpack.js.org/loaders/bab…使用 Babelwebpack 转译 JavaScript 文件
style-loaderwebpack.js.org/loaders/sty…将css添加到DOM的内联样式标签style里
css-loaderwebpack.js.org/loaders/css…允许将css文件通过require的方式引入,并返回css代码
sass-loaderwebpack.js.org/loaders/sas…加载 Sass/SCSS 文件并将他们编译为 CSS
less-loaderwebpack.docschina.org/loaders/les…处理less
postcss-loaderwebpack.js.org/loaders/pos…使用 PostCSS 处理 CSS 的 loader
eslint-loadergithub.com/webpack-con…Webpack的ESlint加载器(已弃用)
vue-loadergithub.com/vuejs/vue-l…Vue单文件组件的webpack加载器
html-minify-loaderv4.webpack.js.org/loaders/htm…压缩HTML

3.4 开发一个 Loader

Loader 作为 Webpack 的核心机制,内部的工作原理却非常简单

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

w9.png

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

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

 └─ 03-webpack-loader ······················· sample root dir
    ├── src ································· source dir
    │   ├── about.md ························ markdown module
    │   └── main.js ························· entry module
    ├── package.json ························ package file
+   ├── markdown-loader.js ·················· markdown loader
    └── webpack.config.js ··················· webpack config file
<!-- ./src/about.md -->
# About

this is a markdown file.
// ./src/main.js
import about from './about.md'

console.log(about)
// 希望 about => '<h1>About</h1><p>this is a markdown file.</p>'

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

// ./markdown-loader.js
module.exports = source => {
  // 加载到的模块内容 => '# About\n\nthis is a markdown file.'
  console.log(source)
  // 返回值就是最终被打包的内容
  return 'hello loader ~'
}

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

// ./webpack.config.js
module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.md$/,
        // 直接使用相对路径
        use: './markdown-loader'
      }
    ]
  }
}

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

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

w10.png

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

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

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

w11.png

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

  • 直接在这个 Loader 的最后返回一段 JS 代码字符串;
  • 再找一个合适的加载器,在后面接着处理我们这里得到的结果。

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

// ./markdown-loader.js
module.exports = source => {
  // 加载到的模块内容 => '# About\n\nthis is a markdown file.'
  console.log(source)
  // 返回值就是最终被打包的内容
  // return 'hello loader ~'
  return 'console.log("hello loader ~")'
}

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

w12.png

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

3.4.1 实现 Loader 的逻辑

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

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

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

// ./markdown-loader.js
const marked = require('marked')

module.exports = source => {
  // 1. 将 markdown 转换为 html 字符串
  const html = marked(source)
  // html => '<h1>About</h1><p>this is a markdown file.</p>'
  // 2. 将 html 字符串拼接为一段导出字符串的 JS 代码
  const code = `module.exports = ${JSON.stringify(html)}`
  return code 
  // code => 'export default "<h1>About</h1><p>this is a markdown file.</p>"'
}

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

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

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

// ./markdown-loader.js
const marked = require('marked')

module.exports = source => {
  const html = marked(source)
  // const code = `module.exports = ${JSON.stringify(html)}`
  const code = `export default ${JSON.stringify(html)}`
  return code 
}

3.4.2 多个 Loader 的配合

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

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

// ./markdown-loader.js
const marked = require('marked')

module.exports = source => {
  // 1. 将 markdown 转换为 html 字符串
  const html = marked(source)
  return html
}

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

// ./webpack.config.js
module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.md$/,
        use: [
          'html-loader',
          './markdown-loader'
        ]
      }
    ]
  }
}

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

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

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

总结

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

4. 利用插件机制横向扩展 Webpack 的构建能力

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

插件几个最常见的应用场景:

  • 实现自动在打包之前清除 dist 目录(上次的打包结果)
  • 自动生成应用所需要的 HTML 文件
  • 根据不同环境为代码注入类似 API 地址这种可能变化的部分
  • 拷贝不需要参与打包的资源文件到输出目录
  • 压缩 Webpack 打包完成后输出的文件
  • 自动发布打包结果到服务器实现自动部署

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

4.1 体验插件机制

这里先体验几个最常见的插件,首先第一个就是用来自动清除输出目录的插件。

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

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

clean-webpack-plugin 这个插件就很好的实现了这一需求。它是一个第三方的 npm 包,需要先通过 npm 安装一下,具体操作如下:

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

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

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

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

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

// ./webpack.config.js

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

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin()
  ]
}

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

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

4.2 用于生成 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 属性中,同样需要添加一下这个类型的实例对象,完成这个插件的使用,具体配置代码如下:

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin()
  ]
}

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

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

  • 对于生成的 HTML 文件,页面 title 必须要修改;
  • 很多时候还需要我们自定义页面的一些 meta 标签和一些基础的 DOM 结构。

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

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

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

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      meta: {
        viewport: 'width=device-width'
      }
    })
  ]
}

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

2.png

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

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

<!-- ./src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
  <div class="container">
    <h1>页面上的基础结构</h1>
    <div id="root"></div>
  </div>
</body>
</html>

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

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      template: './src/index.html'
    })
  ]
}

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

3.png

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

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

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

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

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    // 用于生成 index.html
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      template: './src/index.html'
    }),
    // 用于生成 about.html
    new HtmlWebpackPlugin({
      filename: 'about.html'
    })
  ]
}

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

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

当然了,对于同时输出多个 HTML,一般我们还会配合 Webpack 多入口打包的用法,这样就可以让不同的 HTML 使用不同的打包结果。

4.3 用于复制文件的插件

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

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

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

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

// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'Webpack Plugin Sample',
      template: './src/index.html'
    }),
    new CopyWebpackPlugin({
      patterns: ['public'] // 需要拷贝的目录或者路径通配符
    })
  ]
}

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

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

4.4 开发一个插件

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

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

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

4.gif

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

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

5.png

那这里我们同样在项目根目录下添加一个单独的 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 工作过程中最核心的对象,里面包含了我们此次构建的所有配置信息,我们就是通过这个对象去注册钩子函数,具体代码如下:

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply (compiler) {
    console.log('RemoveCommentsPlugin 启动')
    // compiler => 包含了我们此次构建的所有配置信息
  }
}

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

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

6.png

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

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

  • 第一个是插件的名称,我们这里的插件名称是 RemoveCommentsPlugin;
  • 第二个是要挂载到这个钩子上的函数;

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

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

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply (compiler) {
    compiler.hooks.emit.tap('RemoveCommentsPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        console.log(name) // 输出文件名称
      }
    })
  }
}

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

7.png

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

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply (compiler) {
    compiler.hooks.emit.tap('RemoveCommentsPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        // console.log(name)
        console.log(compilation.assets[name].source()) // 输出文件内容
      }
    })
  }
}

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

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

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

// ./remove-comments-plugin.js
class RemoveCommentsPlugin {
  apply (compiler) {
    compiler.hooks.emit.tap('RemoveCommentsPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        if (name.endsWith('.js')) {
          const contents = compilation.assets[name].source()
          const noComments = contents.replace(/\/\*{2,}\/\s?/g, '')
          compilation.assets[name] = {
            source: () => noComments,
            size: () => noComments.length
          }
        }
      }
    })
  }
}

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

8.png

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

总结

除此之外,社区中还提供了成百上千的插件,你并不需要也不可能全部认识。当你遇到一些具体的构建需求时,再去提炼你需求中的关键词然后搜索它们,例如,我想要压缩输出的图片,我会搜索 imagemin webpack plugin。虽然说每个插件的作用不尽相同,但是在用法上基本都是类似的。

想请教一个问题。面头条遇到的一个面试题。plugin能完成loader的功能吗? 根据这两节的学习,loader是在加载过程中,拿到源文件内容,再进行一些处理,最后转化成js代码。plugin也能够在合适的时机拿到源文件内容,并通过一些操作,最后覆盖源文件。plugin是loader的扩展,完成loader无法完成的功能。但仍然无法确定plugin是否能完成loader的功能,如果可以,是否有必要干掉loader?

理论上是可行的,比如对 CSS 文件进行处理,Plugin 就可以在 Loader 执行之前先处理 CSS 文件。但是这种方式太麻烦,并不符合 Webpack 的设计

5. 探索 Webpack运行机制与核心工作原理

5.1 工作过程简介

其实 Webpack 官网首屏就已经很清楚地描述了它的工作原理,如下图所示:

1.png

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

2.png

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

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

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

3.gif

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

4小.gif

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

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

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

5.2 工作原理剖析

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

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

5.2.1 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,具体位置如下:

5.png

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

image.png

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

image (1).png

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

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

image (2).png

5.2.2 创建 Compiler 对象

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

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

image (3).png

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

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

image (4).png

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

image (5).png

5.2.3 开始构建

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

image (6).png

  • 如果是监视模式就调用 Compiler 对象的 watch 方法,以监视模式启动构建,但这不是我们主要关心的主线。
  • 如果不是监视模式就调用 Compiler 对象的 run 方法,开始构建整个应用。

这个 run 方法定义在 Compiler 类型中,具体文件在 webpack 模块下的 lib/Compiler.js 中,代码位置如下:

image (7).png

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

image (8).png

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

image (9).png

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

5.2.4 make 阶段

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

image (10).png

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

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

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

image (11).png

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

image (12).png

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

image (13).png

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

image (14).png

这个插件中调用了 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 目录

6.如何使用 Dev Server 提高你的本地开发效率

因为“编写源代码 → Webpack 打包 → 运行应用 → 浏览器查看”这种周而复始的开发方式过于原始,在实际开发过程中,如果还是按照这种方式来工作,开发效率必然会十分低下。

那究竟该如何提高我们的开发效率呢?

这里先对一个较为理想的开发环境做出设想:

  • 首先,它必须能够使用 HTTP 服务运行而不是文件形式预览。这样的话,一来更接近生产环境状态,二来我们的项目可能需要使用 AJAX 之类的 API,以文件形式访问会产生诸多问题。
  • 其次,在我们修改完代码过后,Webpack 能够自动完成构建,然后浏览器可以即时显示最新的运行结果,这样就大大减少了开发过程中额外的重复操作,同时也会让我们更加专注,效率自然得到提升。
  • 最后,它还需要能提供 Source Map 支持。这样一来,运行过程中出现的错误就可以快速定位到源代码中的位置,而不是打包后结果中的位置,更便于我们快速定位错误、调试应用。

对于以上的这些需求 Webpack 都已经提供了相对应的功能,其中部分功能需要用到一些周边的工具,具体效果如下:

理想的环境.gif

所以,今天我们的主题就是:学习如何增强使用 Webpack 的开发体验。

6.1 Webpack 自动编译

正如前面所讲,如果我们每次修改完代码,都是通过命令行手动重复运行 Webpack 命令,从而得到最新的打包结果,那么这样的操作过程根本没有任何开发体验可言。

针对上述这个问题,我们可以使用 Webpack CLI 提供的另外一种 watch 工作模式来解决。

在这种模式下,Webpack 完成初次构建过后,项目中的源文件会被监视,一旦发生任何改动,Webpack 都会自动重新运行打包任务。

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

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

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

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

image.png

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

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

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

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

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

# 可以先通过 npm 全局安装 browser-sync 模块,然后再使用这个模块
$ npm install browser-sync --global
$ browser-sync dist --watch

# 或者也可以使用 npx 直接使用远端模块
$ npx browser-sync dist --watch

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

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

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

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

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

6.2 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
$ npm install webpack-dev-server --save-dev
# 运行 webpack-dev-server
$ npx webpack-dev-server

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

4.png

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

3.png

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

6.2.1 配置选项

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

// ./webpack.config.js
const path = require('path')

module.exports = {
  // ...
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    compress: true,
    port: 9000
    // ...
    // 详细配置文档:https://webpack.js.org/configuration/dev-server/
  }
}

具体有配置可以在它的官网中找到相对应的说明文档。

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

6.2.2 静态资源访问

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

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

// ./webpack.config.js
module.exports = {
  // ...
  devServer: {
    contentBase: 'public'
  }
}

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

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

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

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

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

image (3).png

6.2.3 Proxy 代理

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

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

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

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

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

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

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

image (4).png

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

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

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

// ./webpack.config.js
module.exports = {
  // ...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.github.com'
      }
    }
  }
}

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

2.png

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

// ./webpack.config.js
module.exports = {
  // ...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: {
          '^/api': '' // 替换掉代理地址中的 /api
        }
      }
    }
  }
}

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

图片1.png

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

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

// ./webpack.config.js
module.exports = {
  // ...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://api.github.com',
        pathRewrite: {
          '^/api': '' // 替换掉代理地址中的 /api
        },
        changeOrigin: true // 确保请求 GitHub 的主机名就是:api.github.com
      }
    }
  }
}

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

image (7).png

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

7.如何配置 Webpack SourceMap 的最佳实践

上一讲我们介绍了如何使用 Webpack Dev Server 提高开发效率,今天我们来介绍需要编译的前端项目该如何调试。

通过构建或者编译之类的操作,我们将开发阶段编写的源代码转换为能够在生产环境中运行的代码,这种进步同时也意味着我们实际运行的代码和我们真正编写的代码之间存在很大的差异。

在这种情况下,如果需要调试我们的应用,或是应用运行的过程中出现意料之外的错误,那我们将无从下手。因为无论是调试还是报错,都是基于构建后的代码进行的,我们只能看到错误信息在构建后代码中具体的位置,却很难直接定位到源代码中对应的位置。

所以我们今天来聊聊如何借助工具解决现代化前端应用的调试问题。

7.1 Source Map 简介

Source Map(源代码地图)就是解决此类问题最好的办法,从它的名字就能够看出它的作用:映射转换后的代码与源代码之间的关系。一段转换后的代码,通过转换过程中生成的 Source Map 文件就可以逆向解析得到对应的源代码。

image (1).png

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

image (2).png

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

  • version 是指定所使用的 Source Map 标准版本
  • sources 中记录的是转换前的源文件名称,因为有可能出现多个文件打包转换为一个文件的情况,所以这里是一个数组
  • names 是源代码中使用的一些成员名称,我们都知道一般压缩代码时会将我们开发阶段编写的有意义的变量名替换为一些简短的字符,这个属性中记录的就是原始的名称
  • mappings 属性,这个属性最为关键,它是一个叫作 base64-VLQ 编码的字符串,里面记录的信息就是转换后代码中的字符与转换前代码中的字符之间的映射关系,具体如下图所示:

image (3).png

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

image (4).png

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

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

image (5).png

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

image (6).png

7.2 Webpack 中配置 Source Map

使用 Webpack 打包的过程,同样支持为打包结果生成对应的 Source Map。用法上也很简单,不过它提供了很多不同模式,导致大部分初学者操作起来可能会比较懵。那接下来我们就一起研究一下在 Webpack 中如何开启 Source Map,然后再来了解一下几种不同的 Source Map 模式之间存在哪些差异。

回到配置文件中,这里要使用的配置属性叫作 devtool。这个属性就是用来配置开发过程中的辅助工具,也就是与 Source Map 相关的一些功能。我们可以先将这个属性设置为 source-map,具体代码如下:

// ./webpack.config.js
module.exports = {
  devtool: 'source-map' // source map 设置
}

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

image (7).png

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

source-map.gif

如果你只是需要使用 Source Map 的话,操作到这里就已经实现了。但是只会使用这种最普通的 Source Map 模式还远远不够。

为什么这么说呢?

因为现阶段 Webpack 支持的 Source Map 模式有很多种。每种模式下所生成的 Source Map 效果和生成速度都不一样。显然,效果好的一般生成速度会比较慢,而生成速度快的一般就没有什么效果。

那具体哪种 Source Map 模式才是最好呢?这里我们还需要继续去探索。

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

1.png

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

7.3 Eval 模式

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

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

const code = 'console.log("foo~")'
eval(code) // 将 code 中的字符串作为 JS 代码执行

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

image (8).png

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

image (9).png

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

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

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

// ./webpack.config.js
module.exports = {
  devtool: 'eval'
}

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

image (10).png

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

image (11).png

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

image (12).png

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

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

7.4 案例准备工作

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

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

└─ 07-devtool-diff
   ├── src
   │   ├── heading.js
   │   └── main.js
   ├── package.json
   └── webpack.config.js
// ./src/main.js
import createHeading from './heading.js'
const heading = createHeading()
document.body.append(heading)
console.log('main.js running')
// 运行时错误
console.log111('main.js running')

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

const allDevtoolModes = [
  '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- '
]

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

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

// ./webpack.config.js
module.exports = [
  {
    entry: './src/main.js',
    output: {
      filename: 'output1.js'
    }
  },
  {
    entry: './src/main.js',
    output: {
      filename: 'output2.js'
    }
  }
]

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

image (13).png

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

具体配置代码如下:

// ./webpack.config.js
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(item => ({
  devtool: item,
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: `js/${item}.js`
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: `${item}.html`
    })
  ]
}))

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

  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 在浏览器中进行尝试。

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

image (14).png

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

image (15).png

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

7.5 不同模式的对比

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

7.5.1 eval 模式:

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

image (16).png

7.5.2 eval-source-map 的模式:

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

eval-source-map.gif

7.5.3 cheap-eval-source-map 的模式:

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

cheap-eval-source-map.gif

7.5.4 cheap-module-eval-source-map 的模式:

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

cheap-module-eval-source-map.gif

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

image (17).png

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

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

7.5.5 cheap-source-map 模式:

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

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

7.5.6 inline-source-map 模式:

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

7.5.7 hidden-source-map 模式:

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

7.5.8 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

8. 如何让你的模块支持热替换(HMR)机制

使用 Webpack Dev Server 就可以让我们在开发过程中专注编码,因为它可以自动监视我们代码的变化然后自动进行打包,最后通过自动刷新的方式同步到浏览器以便于我们即时预览,效果如下:

live-reloading.gif

8.1 自动刷新的问题

但是当你实际去使用 Webpack Dev Server 自动刷新的特性去完成具体的开发任务时,你会发现还是有一些不舒服的地方。

例如,这里是一个编辑器应用,我想要即时调试这个编辑器中内容文本的样式。那正常的操作肯定是我先尝试在编辑器里面去添加一些文本,作为展示样例,再回到开发工具中,找到控制编辑器样式的 CSS 文件,然后进行编辑,具体操作如下:

live-reloading-issue.gif

那这时候我们就能够发现问题了:当我们修改完编辑器文本对应的样式过后,原本想着可以即时看到最新的界面效果,但是这时编辑器中的内容却没有了。

这时就不得不再次回到应用中再来添加一些测试文本,查看样式效果。那如果修改后还是对样式不满意的话,你还需要继续调整样式,调整之后又会面临文本内容丢失的问题。那久而久之你就会发现,自动刷新这个功能还是有点鸡肋,并没有想象的那么好用。

出现这个问题的原因,是因为我们每次修改完代码,Webpack 都可以监视到变化,然后自动打包,再通知浏览器自动刷新,一旦页面整体刷新,那页面中的任何操作状态都将会丢失,所以才会出现我们上面所看到的情况。

但是总会想到一些应对方法,例如:

  • 回到代码中先写死一个文本内容到编辑器中,这样即便页面刷新,也不会丢失。
  • 通过代码将编辑器中的内容及时保存到 LocalStorage 之类的地方,刷新过后再取回来。

确实这些都是好办法,但是也存在一些不足,因为它们都是典型的“有漏补漏”的操作,并不能根治自动刷新导致页面状态丢失问题,而且这些方法都需要去编写一些与业务本身无关的辅助代码,不利于维护。

更好的办法自然是能够实现在页面不刷新的情况下,代码也可以及时的更新到浏览器的页面中,重新执行,避免页面状态丢失。针对这个需求,Webpack 同样可以满足。

8.2 模块热替换(HMR)

HMR 全称 Hot Module Replacement,翻译过来叫作“模块热替换”或“模块热更新”。

计算机行业经常听到一个叫作热拔插的名词,指的就是我们可以在一个正在运行的机器上随时插拔设备,机器的运行状态不会受插拔的影响,而且插上去的设备可以立即工作,例如我们电脑上的 USB 端口就可以热拔插。

模块热替换中的“热”和这里提到的“热拔插”是相同的意思,都是指在运行过程中的即时变化。

Webpack 中的模块热替换,指的是我们可以在应用运行过程中,实时的去替换掉应用中的某个模块,而应用的运行状态不会因此而改变。例如,我们在应用运行过程中修改了某个模块,通过自动刷新会导致整个应用的整体刷新,那页面中的状态信息都会丢失;而如果使用的是 HMR,就可以实现只将修改的模块实时替换至应用中,不必完全刷新整个应用。

对比一下使用热更新和使用自动刷新两种方式之间的体验差异,我们尝试在项目中开启 HMR 特性,具体效果如下图所示:

hmr-experience.gif

有了 HMR 支持后,我们同样先在页面中随意添加一些内容,也就是为页面制造一些运行状态,然后我们回到开发工具中,再来尝试修改文本的样式,保存过后页面并没有整体刷新,而且我们能立即看到最新的样式。这种体验相对于自动刷新会友好很多。

HMR 对于项目中其他代码文件的修改,也可以有相同的热更新体验。你可以再去尝试修改一下 JS 文件,保存过后,浏览器中同样不会刷新页面,而是直接执行了你刚刚修改的这个模块,具体效果如下:

hmr-experience-js.gif

不仅如此,对于非文本文件同样也可以使用热更新。例如这个案例中显示的 Logo 图片,如果你在外部修改了它,同样也可以及时更新到浏览器中。

那这就是 HMR 的作用和体验,HMR 可以算是 Webpack 中最为强大的特性之一,而且也是最受欢迎的特性,因为它确实极大程度地提高了开发者的工作效率。

8.2.1 开启 HMR

对于 HMR 这种强大的功能而言,使用起来并不算特别复杂。接下了解一下如何去实现项目中的 HMR。

HMR 已经集成在了 webpack 模块中了,所以不需要再单独安装什么模块。

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

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

  • 首先需要将 devServer 对象中的 hot 属性设置为 true
  • 然后需要载入一个插件,这个插件是 webpack 内置的一个插件,所以我们先导入 webpack 模块,有了这个模块过后,这里使用的是一个叫作 HotModuleReplacementPlugin 的插件

具体配置代码如下:

// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ...
  devServer: {
    // 开启 HMR 特性,如果资源不支持 HMR 会 fallback 到 live reloading
    hot: true
    // 只使用 HMR,不会 fallback 到 live reloading
    // hotOnly: true
  },
  plugins: [
    // ...
    // HMR 特性所需要的插件
    new webpack.HotModuleReplacementPlugin()
  ]
}

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

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

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

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

js-live-reloading.gif

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

8.2.2 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 模块更新过后,该如何将更新后的模块替换到页面中。

8.2.3 HMR APIs

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

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

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

// ./src/main.js
import createEditor from './editor'
import logo from './icon.png'
import './global.css'

const img = new Image()
img.src = logo
document.body.appendChild(img)

const editor = createEditor()
document.body.appendChild(editor)

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

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

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

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

// ./main.js

// ... 原本的业务代码

module.hot.accept('./editor', () => {
  // 当 ./editor.js 更新,自动执行此函数
  console.log('editor 更新了~~')
})

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

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

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

8.2.4 JS 模块热替换

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

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

// ./main.js
import createEditor from './editor'

// ... 原本的业务代码

console.log(createEditor)
module.hot.accept('./editor', () => {
  console.log(createEditor)
})

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

image.png

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

// ./main.js
import createEditor from './editor'

const editor = createEditor()
document.body.appendChild(editor)

// ... 原本的业务代码

// HMR -----------------------------------
module.hot.accept('./editor', () => {
  document.body.removeChild(editor) // 移除之前创建的元素
  const newEditor = createEditor() // 用新模块创建新元素
  document.body.appendChild(newEditor)
})

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

// ./main.js
import createEditor from './editor'

const editor = createEditor()
document.body.appendChild(editor)

// ... 原本的业务代码

// HMR -----------------------------------
let lastEditor = editor
module.hot.accept('./editor', () => {
  document.body.removeChild(lastEditor) // 移除之前创建的元素
  lastEditor = createEditor() // 用新模块创建新元素
  document.body.appendChild(lastEditor)
})

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

module-hot.gif

8.2.5 热替换的状态保持

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

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

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

// ./main.js
import createEditor from './editor'

const editor = createEditor()
document.body.appendChild(editor)

// ... 原本的业务代码

// HMR --------------------------------
let lastEditor = editor
module.hot.accept('./editor', () => {
  // 当 editor.js 更新,自动执行此函数
  // 临时记录更新前编辑器内容
  const value = lastEditor.innerHTML
  // 移除更新前的元素
  document.body.removeChild(lastEditor)
  // 创建新的编辑器
  // 此时 createEditor 已经是更新过后的函数了
  lastEditor = createEditor()
  // 还原编辑器内容
  lastEditor.innerHTML = value
  // 追加到页面
  document.body.appendChild(lastEditor)
})

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

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

8.2.6 图片模块热替换

相比于 JavaScript 模块热替换,图片的热替换逻辑就简单多了

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

// ./src/main.js
import logo from './icon.png'
// ... 其他代码
module.hot.accept('./icon.png', () => {
  // 当 icon.png 更新后执行
  // 重写设置 src 会触发图片元素重新加载,从而局部更新图片
  img.src = logo
})

8.2.7 常见问题

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

8.2.7.1 第一个问题:

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

// ./src/main.js
// ... 其他代码
module.hot.accept('./editor', () => {
  // 刻意造成运行异常
  undefined.foo()
})

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

hmr-error.gif

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

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

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

// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ...
  devServer: {
    // 只使用 HMR,不会 fallback 到 live reloading
    hotOnly: true
  },
  plugins: [
    // ...
    // HMR 特性所需要的插件
    new webpack.HotModuleReplacementPlugin()
  ]
}

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

image (1).png

8.2.7.2 第二个问题:

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

image (2).png

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

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

// HMR -----------------------------------
if (module.hot) { // 确保有 HMR API 对象
  module.hot.accept('./editor', () => {
    // ...
  })
}

8.2.7.3 第三个问题:

我们在代码中写了很多与业务功能本身无关的代码,会不会对生产环境有影响?

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

image (3).png

你会发现之前我们编写的处理热替换的代码都被移除掉了,只剩下一个 if (false) 的空判断,这种没有意义的判断,在压缩过后也会自动去掉,所以根本不会对生产环境有任何影响。

8.3 关于框架的 HMR

关于框架的 HMR,因为在大多数情况下是开箱即用的,详细参考: