模块化打包工具的由来

模块化确实是很好的解决了我们在复杂应用开发中的代码组织问题,但随着我们引入模块化,我们的应用又会产生一些新的问题。

第一个就是我们所使用的ES Modules这样一款模块系统本身就存在环境兼容问题,尽管现如今主流浏览器最新版本都已经支持这样一个特性了。

但是我们目前还没办法做到统一所有用户浏览器的使用情况,所以我们还是需要去解决兼容问题。

其次就是通过模块化的方式,划分出的模块文件会比较多,而我们前端应用又是运行在浏览器当中的。

那每一个我们在应用中所需要的文件,都需要从服务器当中请求回来,这些零散的模块文件就必将会导致浏览器频繁发出请求,从而影响我们应用的工作效率。

第三点其实也不能算是问题,应该是我们在实现js模块化基础之上的一个发散,就是我们在前端应用开发过程当中,不仅仅只有JavaScript代码需要模块化。因为随着我们应用的日益复杂,我们的html,css这些资源文件同样也会面临相同的问题。

而且从宏观角度来看,这些文件也都可以看作为前端应用当中的一个模块,只是这些模块的种类和用途跟我们的JavaScript是不同的。

对于整个开发过程而言,模块化肯定是有必要的,只是我们需要在原有的基础之上引入更好的方案或者工具去解决上面这样几个问题或者是需求,让我们开发者在应用的开发阶段可以继续享受模块化所带来的优势又不必担心模块化对生产环境所产生的一些影响。

接下来我们就对这个所谓的更好的方案或工具去提出一些设想,我们希望他们能够满足我们的这些设想。

首先第一点我们希望这样一个工具能够帮我们编译我们的代码,就是将我们开发阶段将我们包含新特性的代码直接去转换为能够兼容绝大多数环境的代码,这样一来我们所面临的环境兼容问题也就不存在了。

其次就是能够将我们散落的这些模块文件再次打包到一起,这就解决了我们浏览器当中频繁对模块文件发出请求的问题。

至于模块化文件划分,我们只是在开发阶段需要他,因为它能够更好的帮我们组织代码,但是对于运行环境,实际上是没有必要的,所以说可以选择在开发阶段通过模块化的方式去编写。

在生产阶段我们还是把他们打包到同一个文件中,最后还需要支持不同种类的前端资源类型。

这样就可以把前端开发过程当中所涉及到的,样式、图片、字体等等所有资源文件都当做模块去使用,那对于我们整个前端应用来讲的话就有了一个统一的模块化方案了。

因为我们之前介绍的那些模块化方案实际上只是针对于JavaScript的模块化方案,现在我们是想强调,针对于整个前端应用的模块化方案,这些资源呢?

我们有了这种模块化方案之后可以通过代码去控制,就可以与我们的业务代码统一去维护,这样对于整个应用来讲的话会更加合理一些。

针对于前面两个需求我们完全可以借助于之前所了解过的一些构建系统去配合一些编译工具就可以实现,但是呢对于最后一个需求,我们就很难通过这种方式去接解决了,所以说就有了接下来我们介绍的一个主题,也就是前端模块打包工具。

模块打包工具

前端领域目前有一些工具就很好的解决了以上这几个问题,其中最为主流的就是webpack,parcel 和 rollup。

我们以webpack为例,他的一些核心特性就很好的满足了上面我们所说的那些需求。

首先webpack作为一个模块打包工具(Module Bundler)他本身就可以解决我们模块化javascript代码打包的一些问题,通过webpack就可以将一些零散的模块代码打包到同一个js文件中。

对于代码中那些有环境兼容问题的代码我们就可以在打包的过程当中通过模块加载器(Loader)对其进行编译转换。

其次,webpack还具备代码拆分(Code Splitting)的理念,它能够将应用当中所有的代码都按照我们的需要去打包。

这样一来我们就不用担心代码全部打包到一起产生这个文件比较大的一个问题。

我们可以把应用加载过程中初次运行的时候所必须的那些模块打包到一起,那对于其他的那些模块我们再单独存放。等应用工作过程中实际需要某个模块我们再异步去加载这个模块从而实现增量加载或者叫渐进式加载,这样的话我们就不用担心文件太碎或者是文件太大,这两个极端的问题。

最后对于前段模块类型的问题webpack支持我们在javascript中以模块化的方式去载入任意类型的资源文件,例如我们在webpack当中就可以通过javascript去直接import一个css文件。这些css文件最终会通过style标签的形式去工作。

其他类型的文件也可以有类似的这种方式去实现,这就是webpack去解决了我们上面说的这些需求。其他的打包工具也都是类似的。

总之来说,所有的打包工具都是以模块化为目标,但是这里我们所说的模块化是对整个前端项目的模块化,也就是比我们之前说的js模块化要更为宏观一些。

可以让我们在开发阶段,更好的去享受模块化所带来的的优势,同时又不比担心模块化对生产环境所产生的一些影响。这就是模块化工具的一个作用。

Webpack 快速上手

webpack 作为目前最主流的代码打包工具,他提供了一整套的前端项目模块化方案,而不仅仅是局限于对JavaScript的模块化。

通过webpack提供的前端模块化方案,我们就可以很轻松的对我们前端项目开发过程中涉及到的所有的资源进行模块化,因为webpack的想法比较先进,而且早之前的使用过程也比较繁琐,再加上他的文档也比较晦涩难懂,所以说在最开始的时候他显得对我们开发者不是十分友好。

但是随着版本的迭代官网的文档也在不断的更新,目前webpack已经非常受欢迎,基本上可以说覆盖了绝大多数现代化的web开发应用项目。

接下来我们就通过一个小案例,先来了解一下webpack的一个基本使用。

我们有一个项目目录,目录中有个src文件夹,src中有两个文件 index.js 和 heading.js, 在src同级有一个index.html文件。

大致的作用也非常简单,我们在heading.js中默认导出一个用于创建元素的函数。

export default () => {
    const element = document.createElement('h2');
    element.textContent = 'Hello word';
    element.addEventListener('click', () => {

    })
    return element;
}

在index.js中去导入这个模块并且使用了他。

import createHeading from './heading.js';

const heading = createHeading();

document.body.append(heading);

除此之外呢,我们在项目的跟目录下有个index.html,在这个文件中我们通过script标签以模块化的方式去引入了index.js

<body>
    <script type="module" src="src/index.js"></script>
</body>

我们打开命令行,通过http-server . 的工具把他运行起来, (可以全局安装http-server模块)。

http-server .

我们可以看到可以正常的工作。没有什么问题。

接下来我们尝试引入webpack去处理我们的js模块。由于webpack是一个npm的工具模块,所以我们先通过yarn init的方式去初始化一个package.json

yarn init

完成过后我们再去安装一下webpack所需要的核心模块以及他对应的cli模块, 通过--dev把他指定为开发依赖。

yarn add webpack webpack-cli --dev

安装过后,webpack-cli程序所提供的cli程序就会出现在我们node_modules的bin目录中,可以通过yarn命令快速找到这样的一个cli并且去运行他。通过yarn webpack --version去执行一下。

yarn webpack --version

有了这样一个webpack之后呢,我们就可以通过他去帮我们打包我们在src下面的js代码了,具体的使用方式就是通过webpack命令去打包。执行yarn webpack命令之后webpack就会自动从src下面的index.js开始打包。

yarn webpack

完成过后控制台会提醒我们,顺着index.js有两个js文件被打包到了一起,与之对应的就是我们在项目的跟目录会多出一个dist目录,我们打包的结果就会存放在这个目录中的一个main.js当中。

这里我们回到index.html中,把js脚本文件的路径修改成dist/main.js。由于打包过程会把import和export转换掉,所以说我们已经不需要type="module"这样一种模块化的方式引入。

<body>
-    <script type="module" src="src/index.js"></script>
+    <script src="dist/main.js"></script>
</body>

完成过后,我们再次启动服务,回到浏览器刷新一下。

http-server .

这里我们的应用仍然可以正常工作。

当然如果每次都通过yarn去运行webpack会比较麻烦的话,可以把webpack这个命令放到package.json当中的script当中,这样就可以直接为我们项目去定义一个build的任务。

"script": {
    "build": "webpack"
}

build任务我们使用webpack去完成就可以了,在命令行执行 yarn build

yarn build

以上就是针对webpack的基本使用。

Webpack 配置文件

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

但很多时候我们都需要自定义这样的路径,例如我们的入口文件是src/main.js, 这时我们就需要去为webpack添加专门的配置文件,具体的做法就是在项目的跟目录下添加一个webpack.config.js的一个文件。

这个文件是一个运行在node环境的js文件,也就说我们需要按照CommonJS的方式去编写代码。

这个文件可以去导出一个对象, 我们通过导出对象的属性就可以完成相应的配置选项,例如我们添加一个叫做entry的属性,那这个属性的作用就是指定我们webpack打包入口文件的路径。我们将其设置为src下面的main.js。

这里需要注意的是这个entry的属性,如果是一个相对路径的话,这个前面的./ 是不可以省略的。

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

这时候我们再去命令行运行yarn build查看效果

yarn build

我们还可以通过output配置我们输出文件的位置, 这个属性要求值是一个对象,可以通过对象中的filename去指定输出文件的名称。例如我们设置成bundle.js

再去运行yarn build, 就可以看到,dist/bundle.js已经正常生成。

我们还可以在output中添加一个path属性去指定我们输出文件所在的目录, 这个属性需要是一个绝对路径,我们需要借助node中的path模块帮我们转换。这里我们也说到了,这是node环境中运行的代码,所以我们可以直接去载入path模块,然后根据path.join(__dirname, 'dist'), 这就可以得到output的完整路径。我们再次运行yarn build就可以看到在项目中生成了output/bundle.js。

const path = require('path');

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

Webpack 工作模式

webpack新增了一个工作模式的用法,这种用法大大简化了webpack配置的复杂程度,可以理解成针对不用环境的几组预设的配置,我们回到开发环境中,重新去运行webpack的打包命令。

打包过程中命令行会报出一个命令行警告,大致的意思就是说我们没有去设置一个叫做mode的属性,webpack会默认使用production的模式去工作。

在这个模式下webpack会自动启动一些优化插件,例如自动帮我们代码压缩,这对实际生产环境是非常友好的,但是对打包的结果我们没办法去阅读。

我们可以通过cli去指定打包的模式,具体的用法就是给webpack的命令去传入一个--mode的参数,这个属性我们有三种取值,默认是production,默认production会自动的去启动优化,优化我们的打包结果。

这里我们再尝试一下development,也就是开发模式。开发模式webpack会自动去优化打包的速度,他会去添加一些调试过程需要的服务到我们的代码当中,这些我们后面在介绍调试的时候会单独再介绍。

yarn webpack --mode=development

除此之外还有一个node模式,在node模式下,webpack就是运行最原始状态的打包,他不会去做任何额外的处理,目前我们的工作模式只有这三种。具体这三种的差异我们可以在官方文档中找到。

yarn webpack --mode=none

除了我们通过cli参数去指定工作模式,我们还可以在webpack的配置文件当中去设置工作模式,具体做法就是我们在配置文件的配置目录中去添加一个mode属性,这样的话webpack命令就会根据我们配置中的模式,去工作了,我们就不需要再去指定--mode的参数了。

Webpack 打包结果运行原理

我们来一起解读一下通过webpack打包过后的结果,为了可以更好的理解我们打包过后的代码,我们先将webpack的工作模式设置成node。

const path = require('path');

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

这样的话就是以最原始的状态去打包,我们回到命令行终端,再次去运行webpack的命令。去执行打包。

yarn webpack

完成过后我们去打开生成的bundle.js文件, 我们先把我们整体的结构折叠起来以便于我们更好的对结构了解。快捷键是ctrl + k 和 ctrl + 0。

/******/ (function(modules) { // 接收参数位置
/******/ })
/******/ ([ // 调用位置
/******/ ]);

。我们可以看到整体生成的代码是一个立即执行函数。这个函数是webpack的工作入口。接收一个叫做modules的参数。调用的时候我们传入了一个数组。

/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/***/ })
/******/ ]);

展开这个数组,数组中的每一个参数都是一个参数列表相同的函数。这里的函数对应的就是我们源代码中的模块,也就是说我们的每一个模块最终都会被包裹到这样的一个函数中,从而去实现模块的私有作用域。

/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);


const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])();

document.body.append(heading);

/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = (() => {
    const element = document.createElement('h2');
    element.textContent = 'Hello word';
    element.addEventListener('click', () => {

    })
    return element;
});

/***/ })
/******/ ]);

我们再来看一下webpack的工作入口函数,这个函数内部并不复杂,而且注释也非常清晰,最开始先定义了一个对象(installedModules),用于去存放或者叫缓存我们加载过的模块。

然后紧接着定义了一个webpack_require函数,顾名思义,这个函数就是用来加载模块的,在往后就是向webpack_require这个函数上挂载了一些数据,和一些工具函数。这些并不重要。

我们接着往后看,这个函数执行到最后调用了调用了require函数,传入了一个webpack_require.s = 0开始去加载我们的模块,这个地方的模块id呢实际上就是我们上面的模块数组当中的元素下标,也就是说这里才开始加载我们在源代码当中所谓的入口模块。

/******/ (function(modules) { // webpackBootstrap
/******/     // The module cache
/******/     var installedModules = {};
/******/
/******/     // The require function
/******/     function __webpack_require__(moduleId) {
/******/     }
/******/
/******/
/******/     // expose the modules object (__webpack_modules__)
/******/     __webpack_require__.m = modules;
/******/
/******/     // expose the module cache
/******/     __webpack_require__.c = installedModules;
/******/
/******/     // define getter function for harmony exports
/******/     __webpack_require__.d = function(exports, name, getter) {
/******/     };
/******/
/******/     // define __esModule on exports
/******/     __webpack_require__.r = function(exports) {
/******/     };
/******/
/******/     // create a fake namespace object
/******/     // mode & 1: value is a module id, require it
/******/     // mode & 2: merge all properties of value into the ns
/******/     // mode & 4: return value when already ns object
/******/     // mode & 8|1: behave like require
/******/     __webpack_require__.t = function(value, mode) {
/******/     };
/******/
/******/     // getDefaultExport function for compatibility with non-harmony modules
/******/     __webpack_require__.n = function(module) {
/******/     };
/******/
/******/     // Object.prototype.hasOwnProperty.call
/******/     __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/     // __webpack_public_path__
/******/     __webpack_require__.p = "";
/******/
/******/
/******/     // Load entry module and return exports
/******/     return __webpack_require__(__webpack_require__.s = 0);
/******/ })

为了可以更好的理解他的执行过程我们可以让他运行起来,然后我们通过浏览器的开发工具去单步调试一下。打开命令行终端,我们通过http-server去启动浏览器。

http-server ./

打开我们的开发工具,在source面板中找到bundle.js这样一个文件,在这个文件当中我们找到他的入口函数(installedModules位置),打上断点。刷新页面,启动调试那这个函数在一开始运行的时候接收到的参数实际上就是我们两个模块所对应的两个函数。我们直接向下执行,对于不重要的环节直接跳过。

在最后部分加载了我们id为0的模块,然后我们进入到这个webpack_require函数中。

webpack_require内部先判断了这个模块有没有被加载过,如果加载了就从缓存里面读,如果没有就创建一个新的对象。创建过后开始调用这个模块对应的函数,把我们刚刚创建的模块对象(module), 导出成员对象(module.exports),webpack_require函数作为参数传入进去。这样的话我们在我们模块的内部就可以使用module.exports去导出成员。通过webpack的webpack_require去载入模块。

/******/     function __webpack_require__(moduleId) {
/******/
/******/         // Check if module is in cache
/******/         if(installedModules[moduleId]) {
/******/             return installedModules[moduleId].exports;
/******/         }
/******/         // Create a new module (and put it into the cache)
/******/         var module = installedModules[moduleId] = {
/******/             i: moduleId,
/******/             l: false,
/******/             exports: {}
/******/         };
/******/
/******/         // Execute the module function
/******/         modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/         // Flag the module as loaded
/******/         module.l = true;
/******/
/******/         // Return the exports of the module
/******/         return module.exports;
/******/     }

进入到模块中,发现,在模块内部先去调用了一个webpack_require.r函数,这个函数的作用非常简单,就是给我们导出对象上面添加一个标记。我们去看一下。

/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);


const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])();

document.body.append(heading);

/***/ }),

函数webpack_require.r,可以发现实际上就是在我们导出对象上定义了一个__esModule的标记,定义过后我们这个导出对象上就有了这样一个标记,用来对外界表明我们这是一个ES Module。

/******/     // define __esModule on exports
/******/     __webpack_require__.r = function(exports) {
/******/         if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/             Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/         }
/******/         Object.defineProperty(exports, '__esModule', { value: true });
/******/     };

然后再紧接着往下,这时候又调用了webpack_require函数,此时传入的id是1,也就是说用来去加载第一个模块。

/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);


const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])();

document.body.append(heading);

/***/ }),

这个模块就是我们在代码当中export的那个heading,然后再去以相同的道理去执行heading这个模块, 最后将heading模块导出的这个整体的对象通过return函数给他return回去。

/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = (() => {
    const element = document.createElement('h2');
    element.textContent = 'Hello word';
    element.addEventListener('click', () => {

    })
    return element;
});

/***/ })

module.exports是一个对象,因为我们ES Module里面默认导出是放在default里面,这时候我们把模块导出的对象拿到。然后访问里面的default,然后调用default函数,内部还是调用我们内部模块的那些代码,最终将我们创建完的元素拿到了,最后append到我们的body上面。

/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);


const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])();

document.body.append(heading);

/***/ }),

这就是一个大致的执行过程。有了这样一个运行过程其实我们应该了解了,webpack其实大包过后的代码并不会特别的复杂,只是说帮我们把我们所有的模块放到了同一个文件当中,除了放到同一个文件当中他还的提供一个基础代码让我们这么模块与模块之间相互依赖的关系还可以保持原有的那样一个状态,这个实际上就是我们前面所看到的webpack bootstrap的作用。

以上就是我们webpack打包过后代码的一个工作的原理,下面贴出全部代码

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

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);


const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])();

document.body.append(heading);

/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = (() => {
    const element = document.createElement('h2');
    element.textContent = 'Hello word';
    element.addEventListener('click', () => {

    })
    return element;
});

/***/ })
/******/ ]);

Webpack 资源模块加载

通过探索我们知道可以把css文件作为打包的入口,不过webpack的打包入口一般还是JavaScript, 因为他的打包入口从某种程度来说可以算是我们应用的运行入口。

而就目前而言,前端应用当中的业务是由JavaScript去驱动的,我们只是尝试一下,正确的做法还是把js文件作为打包的入口。然后在js代码当中通过import的方式去引入css文件。

这样的话css-loader仍然可以正常工作,我们再来尝试一下。我们在js中import我们的css文件

import createHeading from './heading.js';

import './style.css';

const heading = createHeading();

document.body.append(heading);

然后在webpack.config.js中配置css的loader 这里注意,css-loader 和 style-loader 要使用yarn安装到项目中。

yarn add css-loader style-loader --dev

webpack中的loader需要配置到config的module中。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            }
        ]
    }
}

完成之后我们打开命令行终端,运行一下打包命令。启动项目之后,我们发现,我们的样式是可以生效的。

传统模式开发我们是将文件单独分开单独引入,可是webpack确要求我们在js当中去引入css这到底是为什么呢。

其实webpack不仅仅是建议我们在webpack当中去引入css,而是建议我们我们在编写代码当中去引入任何你当前代码需要的资源文件。

因为真正需要这个资源的不是应用,而是此时正在编写的代码,是代码想要正常工作就必须要去加载对应的资源,这就是webpack的哲学,可能一开始不太容易理解,可以对比一下,假设我们的样式还是单独的去引入到页面当中,如果我们的代码更新了,不再去需要这个样式资源了,那又会怎么样。

所以说通过JavaScript的代码去引入文件或者建立我们js和文件之间的依赖关系是有一个很明显的优势的。

JavaScript的代码本身是负责完成整个业务的功能,放大来看就是驱动了我们整个前端应用,在实现业务功能的过程当中可能需要用到样式或者图片等等一系列的资源文件。

如果建立了这种依赖关系,一来逻辑上比较合理,因为我们的JS确实需要这些资源文件的配合才能实现对应的功能,二来我们可以保证上线时资源文件不缺失,而且每一个上线的文件都是必要的。

学习一个新事物不是学习用法就能提高,因为这些东西按照文档基本上谁都可以,很多时候这些新事物的思想才是突破点,能够搞明白这些新事物为什么这样设计,基本上才是出道了。

Webpack 文件资源加载器

目前webpack社区提供了非常多的资源加载器,基本上开发者能想到的合理的需求都有对应的loader,接下来我们来尝试一些非常有代表性的loader。

首先是文件资源加载器

大多数文件加载器都类似于css-loader,都是将资源模块转换为js代码的实现方式去工作,但是呢还有一些我们经常用到的资源文件,例如我们项目当中用到的图片或者字体,这些文件是没办法通过js的方式去表示的。

对于这类的资源文件,我们需要用到文件的资源加载器,也就是file-loader。

那文件资源加载器究竟是如何工作的?首先我们在项目中添加一张普通的图片文件,假设这张图片就是我们在实现某个功能时候需要的资源,按照webpack的思想,我们也应该在用到这个资源的地方去通过import 的方式去导入这张图片。让webpack去处理资源的加载。

我们这里需要接收一下模块文件的默认导出,我们导出的内容就是这张文件的资源路径。

我们创建一个img元素,把我们资源的路径也就是src设置成文件,最后再将这个元素append到body中

import createHeading from './heading.js';

import './style.css';

import icon from './icon.png';

const heading = createHeading();

document.body.append(heading);

const img = new Image();

img.src = icon;

document.body.append(img);

因为我们导入了一个webpack不能识别的资源,所以我们需要修改webpack配置,首先需要安装额外的加载器file-loader

yarn add file-loader --dev

安装完成过后我们修改webpack的配置文件,为png文件添加一个单独的加载规则配置,test属性设置以.png结尾,use属性设置为刚安装的file-loader, 这样的话webpack在打包的时候就会以file-loader去处理图片文件啦。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    }
}

打包过后(yarn webpack) 我们发现dist目录中多了一个图片文件,这个文件就是我们刚刚在代码中导入的图片,不过文件名称发生了改变,这个问题以后再进行介绍。

这张图片在bundle中是如何体现的呢?打开文件之后我们发现,比较简单,只是把我们刚刚生成的文件名称导出了。

/* 6 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = (__webpack_require__.p + "e177e3436b8f0b3cfff0fd836ea3472c.png");

/***/ })

然后我们看下入口木模块, 这里直接使用了导出的文件路径(__webpack_require__(6))
img.src = _icon_png__WEBPACK_IMPORTED_MODULE_2__["default"];

/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
/* harmony import */ var _style_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
/* harmony import */ var _style_css__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_style_css__WEBPACK_IMPORTED_MODULE_1__);
/* harmony import */ var _icon_png__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6);

const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])();

document.body.append(heading);

const img = new Image();

img.src = _icon_png__WEBPACK_IMPORTED_MODULE_2__["default"];

document.body.append(img);

/***/ })

我们启动这个应用,打开浏览器,发现图片并不能正常的加载,打开控制台终端我们可以发现,是直接加载了网站根目录的图片,而我们网站根目录并没有这个图片,所以没有找到。我们的图片应该在dist目录当中。

这个问题是由于我们的index.html并没有生成到dist目录,而是放在了项目的跟目录,所以这里把项目的跟目录作为了网站的跟目录,而webpack会认为所有打包的结果都会放在网站的跟目录下面,所以就造成了这样一个问题。

解决的方法非常简单,我们就是通过配置文件去告诉webpack,打包过后的文件最终在网站当中的位置,具体的做法就是在我们的配置文件当中的output位置,添加一个publicPath。

这个属性的默认值是一个空字符串,表示的就是网站的跟目录,我们这里因为我们生成的文件是放在dist目录下,所以我们设置为dist/。注意这里的斜线不能省略。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    }
}

完成以后我们重新打包,我们打开打包后的文件,找到图片对应的位置,我们发现,这一次在文件名称前面拼接了一个变量。

/* 6 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = (__webpack_require__.p + "e177e3436b8f0b3cfff0fd836ea3472c.png");

/***/ })

这个变量是在webpack内部的代码所提供的。我们可以在运行代码中找到这个变量,可以发现,这个变量就是我们在配置中设置的publicPath(__webpack_require__.p = "dist/";)

这也就解释了为什么public当中最后的/不能省略的原因。

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

解决了这个问题之后我们来重新运行这个应用,回到浏览器,刷新一下页面,此时浏览器中的内容就能正常工作了。

此时我们再来总结一下文件加载器的工作过程。

webpack在打包时遇到了我们的图片文件,然后根据我们配置文件中的配置,拼配到对应的文件加载器,此时文件加载器就开始工作了,那他先是将我们导入的这个文件拷贝到输出的目录,然后再将我们文件拷贝到输出目录的那个路径作为当前这个模块的返回值返回,这样对于我们的应用来说,所需要的这个资源就被发布出来了,同时我们也可以通过模块的导出成员拿到这个资源的访问路径。

Webpack url加载器

除了file-loader这种通过copy物理文件的形式去处理文件资源以外还有一种通过date url的形式去表示文件。这种方式也非常常见。

Data URLs是一种特殊的url协议,可以用来直接去表示一个文件,传统的url要求服务器上有一个对应的文件,然后通过请求这个地址,得到服务器上对应的文件。

而Data URLs是一种当前url就可以去表示文件内容的方式,也就是说这种url当中的文本就已经包含了文件的内容。我们在使用这种url 的时候就不会再去发送任何的http请求。

data:[mediatype][;base64],<data>

data: -> 协议
[mediatype][;base64], -> 媒体类型和编码
<data> -> 文件内容

例如我们下面给出的这一段Data URLs浏览器可以根据这个url解析出来这是一个html类型的文件内容,编码是url-8,内容是一段包含h1的html代码。

data:text/html;charset=UTF-8,<h1>html content</h1>

如果是图片或者字体这一类无法通过文本去表示的2进制类型的文件。可以通过将文件的内容进行base64编码,以base64编码过后的结果也就是一个字符串,去表示这个文件内容。例如下面这样一段Data URLs

data:image/png;base64,iVBORw0KGgoAAAANSUhE...SuQmCC

这个url就是表示了一个png类型的文件, 文件的编码是base64,后面就是我们这张图片的base64编码。

当然了一般情况下我们base64的编码会比较长,浏览器同样可以解析出对应的文件。

在webpack打包静态资源模块时,我们同样可以使用这种方式去实现,那通过data urs我们就可以以代码的形式去表示任何类型的文件。具体做法就是我们需要用到一个专门的加载器,叫做 url-loader

yarn add url-loader --dev

在webpack的配置文件中找到之前的file-loader,将其修改为url-loader

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'url-loader'
            }
        ]
    }
}

此时webpack再去打包时,再遇到.png文件就会使用url-loader将其转换为Data URLs的形式。我们重新打包,可以发现,dist目录下已经没有png文件了,打开bundle.js文件查看。可以发现在最后的文件模块中,导出的不再是之前的文件路径,而是一个完整的Data URLs。

/* 6 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = ("data:image/png;base64,iVBORw0KGgoAAAANSUh...AAAABJRU5ErkJggg==");

/***/ })

我们刚刚也了解到了,因为这个Data URLs当中就已经包含了文件内容,所以说我们就不需要再有独立的物理文件了。我们可以尝试着把这个完整的Data URLs复制下来,打开浏览器去尝试访问这个地址,这张图片同样可以正常显示出来。

我们启动服务,可以发现,同样可以正常显示。

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAAA9zQYyAAAAAXNSR0IArs4c6QAAGSlJREFUeAHtXQl4VEW2/rOvhIQshDUhoCC7iDIsIoqiMIg64jCgjjjOqJ86LrxR1DeOjk99j/cEFxQV0AEURfYlrIIIIyDKFiFsISEJhOx7urN259Vp6NDp3N7v7fStW+f7On2X2s5//q7UrTp1rl9WUXUzhAgEOEHAnxM9hBoCARMCgtCCCFwhIAjNlTmFMoLQggNcISAIzZU5hTKC0IIDXCEgCM2VOYUygtCCA1whIAjNlTmFMoLQggNcISAIzZU5hTKC0IIDXCEgCM2VOYUygtCCA1whIAjNlTmFMoLQggNcISAIzZU5hTKC0IIDXCEgCM2VOYUygtCCA1whIAjNlTmFMoLQggNcISAIzZU5hTKC0IIDXCEgCM2VOYUygtCCA1whIAjNlTmFMoLQggNcISAIzZU5hTKC0IIDXCEgCM2VOYUygQICeRAoKSpETnYWCvMvobiwABXlZaiurkJ9XR2aGhtNlQQGBSEkNBQdOkQhOqYT4jsnonOXrkhKTkFcQmd5GqLxUgSh3STAhZxsnDh2BMfTjuDk8TToaqrdLOlytkhG8v6DhmDgkGHscz16JCV7VJ5WM/uJCP7Omz7nfBb27NqOH3fvRHlZqfMZ3UjZKTYOo8eNxy3j70RSrxQ3StBmFkFoB3ZvqK/Hru1b2CcVOVmZDlIrczu5dx+Mv3MybpswEcEhIcpUwkmpgtA2DFlbq8f2TeuRum4lKisqbKTy7uXomBhMvu/3mDD5HoSFhXu3cpXUJghtZSiDwYCtG9di9dfLPB4XWxUt22lEZAc88OAjmDjld/D3FxNVlsAKQlugcTr9OBZ//B5orKwGSUrpjb88/QL69h+ohuZ6pY2C0AxmmlpbsvBj7Ny6ySugy13JHZOmYObjT4vxNQNW84TOu5CDuW+/DpqGU7P0TO6FWa++gW49ktSshsdt1zSh9+zcjoUfzQPNZPAgtGjz+DOzMHb8BB7UcUsHzRJ61fIlWPnVErdA8/VM0x5+FFNnPOLrzVSkfZojdHNzM3vwex87Nm9QBFBfKfTOyffisaeeg5+fn680ySvt0BShjUYj3v+fN3Hg3z94Bdz2rmTU2Fvx3OzXNDW1p6lJzIUfztUMmenHtH/vbiz66L32/l15tX7NEHrF0s/Z8vVmr4LrC5XRVOSKZV/4QlO80gZNEPq7LRuxZsWXXgHUFytZ880y1c6xu4on94TOOncW//p0vqu4cJf+i08+xPnMDO70slaIa0Lr9TrMe+cNNF5xsLdWXkvnhAFhQZjwLFwT+rMP3jXtIOHZgK7oVnApD/RgzLNwS+hDB/ebnvIdGS8wMMBRElXcd9brbt+e73H45wOq0MmdRnJJaFrKpjGjI4kIC0NTk8FRMlXcpzn2gADnzEnY8LLcb20c5xCwzuXj52tXfGXaqGqvmaHBwdAzLzuexGAwOqVOUUE+1n673Km0akvEHaFLS4qxYfU3Du0Qzhx5aBlcq7KRYVRWWsKd+twResOqb9gwosmuoeJjolFWVWU3De83adaDsOJNuCI07f1zZjWwpKKSNzu6pc/ObamoqvSN/ZJuKSCRiStC04ZWRw87AWwPnpaHGpYcIKxS162yvKT6Y24ITZtbv2fhBhyJgc0GCLmKwK5tm0HY8SLcEProoYPc/fv0BsloyHHs8M/eqMordXBD6L27dngFMKUrCQjw/kIPbUXjRbggtE5Xg0M/7VO9Ta7vdy2e/cP9XteDsCMMeRAuCJ2edlT1Dkhd4+Pw9tN/xuSbRyK6Q6RXuUVTeIQhD8IFoU/8qm5jEIHnvvA0oiIiEMJWMKdNGO91bp0QhPY65jYrpLC2apUwFnzxXUbmHokJLSpMvf0Wr/fS6SrvFMzgqb6HpsUUtQaJod54zrNPol9yT7M9TN9E8kfuvqvVNaVPcrPPczFLpHpCn2c7UtQowSya/5xnn8Cw666VbP5948aie+ervbZkIpkvZmWoE0tLGFRP6Fz2Ggi1iWmY8fxTGN6/n82mk5+2t2c81IilNYCqJ/SlvIvWOvn0OT34ffDiszZ7ZsvGjxoyEDdfP9jykqLHasNSCgzVE7q4MF9KL5+8RlNzn7w6C/1Tkp1u3wsPTUNEWKjT6T1JWFJU4El2n8irekLT26bUIIP6pGDh319EUpdEl5qbwFxdn5s+1aU87iZW+r0x7rbLlXyqJ3RVle+7gk65ZQzmz37e7am4SWNGYsxQ5YceasDSEblVT2i9zne35dO03MszH8RLj0xHoIc+Gq/86SFQb62k1Or1ShbvlbJVT2iDg90pXkFRopJe3bpg8WsvYfLYURJ3Xb/UMTICrz/xqNMbYV2vAS0vCHUnr6/kUT+hfdCXd9qE2/D5P2aDSC2nDLm2D56f8YCcRbYqiwe/aPEm2VYm9eyEHvheZMOLoYx4Ssl9t45FRm4eNu75UakqVF2uILQM5gsJDsLMuydh+p3j4Y3ANX97eBpK2JL//rQTMrSeryJUP+Rob3OMHjoIy996DQ//doJXyEz6UpSkN598DANcmM9ub5y8Vb/ood1EmhZHnrz/HqdW/Nyswm620JBgzJ31DGbN+wgns7LtptXSTUFoF62d3DURj/9uCsYOG+JiTvmTR4aH4b3/+CtmzZ2PdEFqE8CC0E7y7LpeSZgx8Q6Mu2GoT72Ih5bF32e+IX9fsBgHj590Uht+kwlCO7DtyMEDGZFvx/V9r3GQsv1uk/fe/zK/6ne//Bab9qp/b6UnSApCS6AX2zEKE0f/BlNuGQ1yKFKD0G7x2TNnIKV7F3z07VoWa0Ob8UcEoa+wlRzuRw4egAm/uZH5TQxiK3IBauBxmzY+cPutbPajF16Z/xlKK7UXv0/ThCZfi+H9++LW4cPYQ95gUERSHoRmYJa9+QrmfPEV9qal86CS0zpojtDdEuJx44B+GM2c54exOBhEah4lKiIcbz46A4fOnMPcleuRX1rOo5ptdOKe0F06xWBE/2sxkM1SDGY9V9ekpDYg8HxheN8+WPLyc1i79wCWbt+NuoYGntUF14SeNGIYXpru/UhEvsaYEPZ8MH38WNw96ia8smgZjmfl+FoTZWsPt0vfZMQnpkyUDSgeCopkc9ZvPfYQCBtehVtC337DEHRk40ghrREgTAgbXoVbQt/Yz3cXQtqbTDxjwy2h+/Xs1t688dn6+/bgFxtuCR3j5QiePsteiYZ1ivJudFOJJih2iVtCK4aYKNinEeCW0OXVfATwVoI9ZVX8YsMtoU/n5inBBS7KPHOBX2y4JfQvpzO4IJ8SSvCMDbeE3nk4DZU69QdOkZvQhAlhw6twS+h69t6QRan8vN1JLgISJoQNr8ItoclgqQcOcd0buUpK6pkJE56Fa0KT4d5ZvtrkacazEZ3RjbztCAvehWtvOzKekb0K+cO1qaae+t4xIzDptnFgu1zpFtfSbGxGUUUljmVkYf2PB3Ey5wLX+pqV457QZkXJoPS5eeQIdAjn32npyNlMPP/ufLP6mvnmfshhbUl9Xb31JS7PtaKntfE0R+gyjWwc1Yqemif0hcIiawy4PNeKntbG01wPfaFAG4TOyS+0trUmzjVH6NPZuZow7FmNzGpYG1NzhD52JoNFFTJY48DVefalApRVaS/IDBlRc4Sura/HKc576cOnznD1A3VFGc0RmsDZc/iYKxipLu0PnOtnzyCaJPSOAz+bVhDtAaPWe4Vl5Th6Wv0voXcXf00SmoIYHjzBZyzlrft+cpcLXOTTJKHJcsu3fMeFAS2VqGdhvtbs2mN5SXPHmiX0sbPn8GtGJlcG38iCnZdXVXOlk6vKaJbQBNRnqze4ipfPptfV1uGrzTt8tn3eapimCZ3Geuit+w96C2tF61m8PlWTAc6tQdU0oQmMBSvXobJGZ42Lqs7P5ORqfuxsNpjqCR3kYcByGnP+16KlZjxU901DjX988oUs05CeYukL4Kme0BERER7j+NPxdHy1RZ3jzzlLliOvqNhjDKgAObCUpSEeFKJ6QickdvVA/atZP1uzEbsPHb16QQVHi9Zuwve/HJGtpZ27qD+Io+oJndSrtywGbW5uxpsLl+DIKXWssq3e+QOWpm6TRXdzIT2TU8yHqv1WPaEHDB4qG/iNTU146cNPcODXE7KVqURBK7bvwvtfr5K9aDmxlL1xThaoekIPHX4T5HyYqatvwMvzF/rsdN7HK9eaXqzppH2dTkYYEpZqF9UTOiIiEjeNHCOrHchf+u3FyzCXvWqYem1fkAoWTXXWvI/wzbZdijRnxKib2UOh+uNGq57QZN17HpiuiJHX7d6LJ95+F5kX2zdaJ83CzHz9Hfx84pQiepownKoMhoo12EbBXBC6V+9rMPqW22yo6Nll2sr0p3/OwfwVa6Cvq/OsMBdzkyvof368CH97bwFKWNAYpWTMuPFI7t1HqeK9Wq5fVlF1s1drVKiy8rJSzHryUdRUK7f1KJq95uKBO27F1PHjEMFekaaUEJG/3vodNu3djwaFAytGdojCvE//hZhOsUqp49VyuSE0oXbo4H7MeeNVxQGMDA/DHSNuxMTRI0Dv1ZZLjI0NmPfNGqT+eABNTd7Z9/jyP/8bN9w0Ui4V2r0cLoYcZhSHjxiFmU88Yz5V7LtGXwsaXz/+1v+hsaocTfoaGJkvMs1luyLNLO6ege1xNOiq0VhRhqaaKvz7SJrXyPzok3/lisyEPXex7X5771TTru4vF3/iCrfcTtvMZkToY6y/PL728/cHfeAfyGJCsqCQpg8rnpHdRHj6Nl7OQ9faS/74l6cw6R7+XhvNHaGJIFPun4bOiV2wYN4c6PXe9aSjXpc+gG9M91n/YMLDI/DUrNkYMXqs9S0uzrkkNFmGDNb7mr74fMEHprE1F9byUAkakj321HOIS+jsYUm+m51bQhPkZLjZb7yDk8ePYe23y/HrkUMuj3N913TOtYyGPUNuuBH3/X4G+g+Sz03Audq9n4qrWQ5H8JWWFOPgvr04kXYU2VnnUFJU6BTB/RNjEWD6xCEgPgZ+YSFASDD8QoOxIVEe5yhz22cW56CcjbFNQuPtugaALcc317KHx+JyGApK2KcURvaREiIw/ZCTU/pg0JBhuGn0zYiNi5dKyuU1TRHa2oJNbFlbr6tBPZtpsJQaNv5Nb6pAprEGWQYdau2Mh9foGLlllD+HN6Dcz/HDYjgC0CsgEr39IzEgMBqR7Pk+JCQE4Wz5OjCQ63+8dtHWNKEtkWGPcjiiL8L+6jycrCsBPdbZkt5hHRETdHlh5ZX8ClvJ3Lo+PyEKNQGXZ1NP1JSgztxb2ymNUl8XFofRkd0wLDyBxXdjsysaFc0Tuon9Wz+gy8PWyvMoaao10UDPtmUVsfh3DWypO6JjFBJ790KARa83KS4ZLyQPM6Wtzz6DhtxMp4Yu9jhGQ4WgLj0Res1AU7LCej3+eHybxQ+rGSUX81HNVhEDg4MQ170bwiLb7taJCwzDXVHJGMnIHeTH1TKDPfha7mma0Gm1xVhRdhqlV4hMqDSyV1acO5oGo8VKXViHCKQMHdwCGhFl+eC7WnppY60ODXnZaCouYAssrvl7+AUFIyiuM4K7JcM/IqqljgW5aVhXdDVuyCW2Q73cIrZ1QEAgUq4fiOCwsJY8lgexjNh/6NQXQ8ISLC9zf6zJwVaFoR7LS08hrbZt8POKYjbcsCAzMaC2Wod69gbWkIjLLxtqbDZiQ1EWZnbrbyKIf1gEQvsMANiHyG1kK36m74Z6NDexl1ya5qVZUlp0YUT0Dw5hD5ZsFMwI7B/RwVSG5Z+apgZsLcluuUTtKS8objmnA4OhCWWM4Im9klpdN5/Qj/TjomOM0PF4MLY/ogPkHeub6/G1b80ROr22FF+UHke1gc0eSEgTW8KWEqPVqt6m4ixM79IXIf4BrZITuenjiaQWn281dqZX07GlxjZFNjrh/Uf/hTLzD+Cx2IEYwMbZvIumBlmbKjLxQdFh5Fy8gOxf05F78jTrfWta2djIlrGlJCCwNXGrWC+6oyRHKqlH15oYeddbDDWoMOu6zRUYDfYeXc2pgBr24/2g6Ag2MP15F00Qmsy+rDQdmyozUZx7EfnnsqBjEUirS8txPu0E6ixJbdUTmwngJ/GAtaYwA9Y9tzm9u9/fl11AaaPVOJz8QSSkmQ19XJHNTP+lJekWD5qu5FZHWu4JTdNxnxWn4ceay7tOSvIutbIMOQyVXspvuXb533vLacuBPzkcWUlevQ77K1qXZ5XE5dPV7EdiLSYnJ4mpOBf5bCp2H5vR+bT4GCN12yGMdb1qPG9rJTVqYafNS1nPfFR/+Y1QRFbrBz7K2tTAHtwciC3XUCkCOijK5u1DlYU4X+vKBgX3SHmMzbcvYbjwKFwTem1FBg7UuNaD+rNZCCkx2ljgSK8pwyn2kUNWSfTOVK6th0Kp/xrOtuMnhsvq8rPOJldNOm4JfVhXgG1sscRVsUUSew9gKws8J0aWvhJHqtpOI1L7bQ6DAlo/qLqq646qbBy+8t/L1by+mp5LQtOK37Iy9145YWtGwUDzyTaExtH5bDztidjqnalMo419hX5WMy/u1L+MPSQWN+rdyeqTebgk9Oclx1FrdM/BPpB50UlJI3m92RCaa6AZD3elpKEWu9nshi1pYKuXUhLEVhk9ldrmJnxe6tuRolzRkTtC01N8Zr37DkNBzGNNSsivw55sZ3PSNDftjqwrOgeDjelCKq/RyhvQXEdwqHRbzfed/c5ieJlngZzN46vpuCK03tiINeXu95RkpGAbhG5k/sj2hLziUtnqoatSy5awNxdn283WwGJAS0mQTISmsgk3HcNP7cIVoXdU5ZhWxTwxShDF25BYyNBXO34Zz/rCTDSa/TacbMSWkvPQGewTiXxJpCRYxtggOmMDdjL81C7cEJrGgrurbY9DnTUUzXKEsrgb1kJDDtoQYE/Km+qxqyzXXpJW92iYsbbwXKtrbU5Ymtqa1svzlIbcWYND5Q128311LghHNQs3hN5bdZE9CNrv6Zw1VFiHth5wlLe20nEvvbogw2nf6L3lF1HEHgjtSR3z8pPyL7HVRntlObpHD9J7qjzvFBzVo+R9bggt50NNeJR0FM6qUul9fJYGyqmrxi9Vl1cmLa9LHRP5HUl1qfSija02OirP0f19Li5EOSrP2/e5IPT5+koUNkmPM90BNCI6WjIbOTPZWgK3zLDKCaKmVRfjrN7xbEylDUJHRHe0rFK2Y8KR8FSrcEHog7qrzkVyGCKIzUWHR7UddhjYGJq2QDmSY4ys5xyQ1RnS17HXzdHGAmux1T7rdO6ey42nu+1wJx8XhD5VJ/1v2R1AzHmi4qSjcZZedM43xB5hc2urcbCywFyVze8SG3Gpo+KUddRXAk+bSsp8Q/WErmLO6/mNbWcBPMUpKp4I3dYPmTbQki+1I/mhjB742vaulM+ZVUWaVakskf6hdkyQ/rE5apOz9wlPwlWNonpCn613PARwxzD0zpGouE6SWWmDQLPRvusm+Ruvk5iSK2fO+9+V5kqWa3nxUgZbpJFYPQxlMarDIqUfWi3ze3qsFK6etstRftUT+lKD/L2zGbS4Hl3Nh62+61k43SL2OmJHsoWtAFovmmxkm2tpk609KcsvhM5GxP54Fr7AG6Ikrkq2X/WELlBguGEGnHrCqFjpXrqEjaXLC6XdPc359Wxel0htlnq2PL7RwfJ4TVkFCjKv5jHnpe9QFofD1n8Ny3RyHCuJqxzts1WG6gldqLDrY2IKi5chsf2KAL10NsshqWnYYbjSI9OmWnsOTDXl5bhw6gwbaUj14H7o2ifFlh1lv640rrI3+EqBqie0rlme1UFbAJMDUEJyTxu3mxmpM3HxdAYMVrE8zBmKG2uxpyzPtJnW1sMgOfAXZGUj58Rpm878sd0SEcbGz94SpXFVSg/p/UZK1aZAufRvXGmJ7dYFejazUWVjkaOSBaepKa9Apy6d0alrIgvV1dpPmXa0UPwO2lRrKTSvTePlsksFbF+j7VkFInJnGwFlLMuT89gbuMrZXnNZqid0nZecabr1uwaNaemSjkIEJpGz+EIeitnYOpxmItjCTGh4uCkO3XE21fdWaRFq6lkcU7b7pK5Gb/qB1Ol0DlceyQEpacB1l19vYbaaF769havcqqie0PYc4+UEi8bRSYP6Izf9FGgu2qawqTa6bzeNzcytb4SwuHVUZ0CQ983kLVxba+z5merH0J5D4HwJtN+QCNYxId75TG6mjIyJRvKQgaBlbiHOI+D9n77zbfPJlNRTd+/bB0Q4epAz2NjA6m7j/dlObnoIje1K70Fpu1LpbrlayScI7aaloxPi2Bx1NEpZzOayfPZQ1+iZYzwROSYxwRT3meI/C3EPAUFo93Az5aKgNPFJPRDXozubASllsfLKTCt8zpLbnw1hIjt2RAe2eEMLJkRqIZ4hIAjtGX6m3H7+fugYH2f60AUKml6n15uCQZYXtHX2j0nsjNjuXREi455AGdTgoghBaAXMSIsx9Glm4W6lCB0aGS7IrADuVKSY5VAIWFFs+yAgCN0+uItaFUJAEFohYEWx7YOAIHT74C5qVQgBQWiFgBXFtg8CgtDtg7uoVSEEBKEVAlYU2z4I/D8KA6R0fIUKRAAAAABJRU5ErkJggg==

这种方式十分适合项目当中体积比较小的资源,因为如果说体积过大的话就会造成打包结果非常大,从而影响运行速度。

最佳的实践方式应该是对项目中的小文件通过url-loader转换成Data URLs然后在代码当中去嵌入,从而减少应用发送请求次数。对于较大的文件仍然通过传统的file-loader方式以单个文件方式去存放,从而提高应用的加载速度。

url-loader支持通过配置选项的方式去实现我们刚刚说的这种最佳实践的方式,在配置文件中,具体的做法就是将url-loader字符串配置方式修改为一个对象的配置方式,对象中使用loader定义url-loader,然后额外添加一个options属性用来去为他添加一些配置选项,其实每一个需要配置的loader都是通过这种方式去配置的。

这里为url-loader添加一个叫做limit的属性,将其设置为 10kb(10 * 1024), 这里的单位是字节,所以需要乘以1024。

这样我们url-loader就会只将10kb以下的文件转换成Data URLs,将超过10kb的文件,仍然会交给file-loader去处理。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 10 * 1024
                    }
                }
            }
        ]
    }
}

重新进行打包,可以测试效果,如果是小于10kb的图片就会转换为Data URLs嵌入代码中去工作,如果是超过10kb的图片就会单独存放。这种方式就是实现我们刚刚所说的最佳实践了。

这里特别需要注意的是,如果按照这种方式去使用url-loader的话就一定要同时安装file-loader因为url-loader对于超出的文件还是会调用file-loader这个模块。例如我们删除掉file-loader重新打包,那么命令行就会报一个找不到file-loader的错误,这里需要注意。

yarn remove file-loader --dev
yarn webpack

Webpack 常用加载器分类

资源加载器有点像我们工厂里面的车间,是用来处理和加工我们打包过程中所遇到的资源文件,那除了以上介绍到的加载器,社区当中还有许多其他的加载器。

对于常用的loader之后我们基本都会用到,但是在这之前呢为了可以更好的了解他们我们先将loader大致分为三类,做一个归纳。

首先就是最常见的编译转换类型的加载器,这种类型的loader会把我们加载到的资源模块转换为JavaScript代码,例如之前用到的css-loader,就是将css代码转换为了bundle当中一个js模块,从而去实现通过JavaScript去运行我们的css。

其次就是文件操作类型的文件加载器,通常文件类型的加载器都会把加载到的资源模块拷贝到输出目录,同时又将这个文件的访问路径向外导出,例如之前用的file-loader就是一个非常典型的文件操作加载器。

最后还有一种针对代码质量检查的加载器,就是对我们所加载到的资源文件,一般是代码,去进行校验的一种加载器(eslint-loader),那这种加载器呢他的目的是为了统一我们的代码风格,从而去提高代码质量,那这种类型加载器一般不会去修改我们生产环境的代码。

以上三种就是我们针对于常用的loader做的一个归纳,后续我们在接触到一个loader过后呢,我们需要先去明确他到底是一个什么样的加载器,那他的特点是什么,使用又需要注意什么。

Webpack 与ES2015

由于webpack默认就可以处理我们代码中的import和export,所以很自然的会有人认为,webpack会自动编译ES6的代码,实则不然,webpack仅仅是对于模块去完成打包工作,所以说他才会对我们代码中的import和export做一些相应的转换,除此之外它并不能转换我们代码中其他的ES6代码。

我们可以将main.js中的var修改为const,执行打包,打开bundle.js可以发现,const并没有被webpack额外处理。

如果我们需要webpack在打包过程中同时处理其他ES6特性的转换,我们需要为js文件配置一个额外的编译型loader,例如最常见的就是babel-loader。

我们先通过yarn去安装babel-loader, 由于babel-loader需要依赖额外的babel核心模块,所以我们需要安装@babel/core模块和用于完成具体特性转换插件的一个集合叫做@babel/preset-env。

yarn add babel-loader @babel/core @babel/preset-env --dev

安装完成过后我们回到我们的配置文件中,这里我们为我们的js文件指定加载器为babel-loader, 这样的话babel-loader就会取代默认的加载器,在打包过程当中呢,就会帮我们处理我们代码当中的一些新特性了。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.js$/,
                use: 'babel-loader'
            }
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'url-loader'
            }
        ]
    }
}

回到命令行当中,再次运行打包命令,打包过后我们查看bundle.js可以发现,const特性仍然没有被转换。其实这个原因我们前面已经介绍过了,因为babel严格上来说只是转换ES代码的一个平台。我们需要基于babel这样一个平台去通过插件来去转换我们代码当中一些具体的特性。

所以我们需要为babel去配置他需要使用的插件,回到配置文件当中,这里我们给babel-loader传入相应的配置就可以了,这里我们直接使用preset-env这样一个插件集合,因为这个集合当中就已经包含了全部的ES最新特性。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'url-loader'
            }
        ]
    }
}

完成以后我们再次回到命令行终端,重新打包,打包之后我们再去找到我们生成的代码,此时我们代码中的ES2015的一些新特性都被转换了。

总结一下就是,webpack默认只是一个打包工具,他不会去处理我们代码当中一些ES6或者更高版本的一些新特性,如果说你需要去处理这些新特性,我们可以通过为我们js代码单独去配置单独的加载器去实现。

Webpack加载资源的方式

除了代码中的import可以去触发模块的加载,webpack中还提供了其他几种方式,具体就是以下这几种。

首先第一个就是我们一直在用的也是最常用到的一种方式就是遵循ES Module标准的import声明。

import heading from './heading.js';
import icon from './icon.png';

其次就是遵循CommonJS标准的require函数,不过如果你去通过require函数去载入一个ES Module的话,那你需要注意,对于ES Module的默认导出,我们需要通过导入,也就是require这个函数导入的结果的default属性去获取。

const heading = require('./heading.js').default;
const icon = require('./icon.png');

最后呢,遵循AMD标准的define函数和require函数,webpack也同样是支持的。

define(['./heading.js', './icon.png', './style.css'], (createHeading, icon) => {
    const heading = createHeading();
    const img = new Image();
    img.src = icon;
    document.body.append(heading);
    document.body.append(icon);
});

require(['./heading.js', './icon.png', './style.css'], (createHeading, icon) => {
    const heading = createHeading();
    const img = new Image();
    img.src = icon;
    document.body.append(heading);
    document.body.append(icon);
})

那换句话说webpack兼容多种模块化标准,不过以个人经验来说,除非必要的情况,否则一定不要在项目中去混合使用这些标准,如果混合用的话,可能会大大降低我们项目的可维护性,我们每个项目只需要去统一使用一个统一的标准就可以了。

除了JavaScript代码中的这三种方式以外,还有一些加载器他在工作时也会去处理我们所加载到的资源当中一些导入的模块,例如我们css-loader加载的css文件(@import指令和url函数)

在这样的文件当中,我们import指令和部分属性中的url函数,他们也会去触发相应的资源模块加载。

@import '';

还有像html-loader去加载的html文件当中的一些src属性也会去触发相应的模块加载,针对于这两种方式我们需要一起去尝试一下。

首先我们先来尝试一下在样式文件当中导入资源模块, 我们现在入口文件导入样式文件。

main.js

import './main.css';

在main.css中我们通过background-image添加一张背景图片

body {
    min-height: 100vh;
    background-image: url(background.png);
    background-size: cover;
}

webpack在遇到css文件时会使用css-loader进行处理,处理的时候发现css中有引入图片,就会将图片作为一个资源模块加入到打包过程。webpack会根据配置文件当中,针对于遇到的文件去找到相应的loader,此时这张图片是一张png图片,就会将这个图片交给url-loader去处理。

打包过后,启动服务,可以发现图片是可以正常显示的。这就表示在css-loader加载的样式文件当中url函数确实可以触发模块的加载。

样式文件当中除了属性文件使用的url函数还有import指令,他同样支持加载其他的样式资源模块。新建一个reset.css文件,随便写点什么,然后在main.css中引入该文件。

@import url(reset.css);
body {
    min-height: 100vh;
    background-image: url(background.png);
    background-size: cover;
}

重新打包运行,可以发现reset.css已经工作了,这也就意味着,刚刚的reset.css确实被打包进了我们bundle文件。

以上就是css-loader再去加载样式时,样式文件当中会去触发文件加载的两种方式。

接下来我们再来看看html文件当中加载资源的一些方式,因为html文件当中也会有引用其他文件的可能性,例如img标签的src,所以说我们这里需要再去尝试一下。

我们在项目中添加一个src/footer.html文件,在这个文件中我们只是通过简单的img标签去引入一张图片。

<footer>
    <img src="better.png" />
</footer>

完成以后我们再回到打包入口当中。我们需要导入footer.html文件,这样他才能参与我们webpack打包的过程,我们同样使用import去打包这个文件, 不过html文件默认会将html代码作为默认导出,所以我们这里需要接收一下导出的字符串。然后我们将它通过document.write的方式将它输出到页面。

import './main.css';

import footer from './footer.html';

document.write(footer);

那这个html模块准备好了过后,我们还需要为这个html模块配置对应的loader,否则webpack是没办法认识html模块的,我们通过yarn去安装一个html-loader

yarn add html-loader --dev

安装之后打开webpack的配置文件,为扩展名为html的文件去配置使用这个loader, 完成之后打包重启。可以看到html代码中的img标签所对应的图片可以正常显示出来。这就说明html文件当中src属性也可以去触发资源模块的加载。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'url-loader'
            },
            {
                test: /.html$/,
                use: 'html-loader'
            }
        ]
    }
}

但是我们在html文件当中不仅仅只有img的src需要去依赖资源,其他的标签也有可能需要一些资源的依赖,例如a标签的href属性。

比如a标签点击需要去加载应用当中的一个文件,这个地方我们就来尝试一下,我们回到html中去添加一个a标签, 然后我们将这个a标签的href属性指向一个文件资源。

<footer>
    <!-- <img src="better.png" /> -->
    <a href="better.png">download png</a>
</footer>

我们重新打包,启动服务,回到浏览器,刷新过后我们发现去点击这个a标签过后我们确找不到这个a标签所对应的这个文件。针对于这个问题是因为html-loader默认只会去处理img标签的src属性,如果我们需要其他标签的一些属性也能够触发打包的话,我们可以去相应的一些配置

具体的做法就是给html-loader添加一个attrs的一个属性, 也就是我们html加载的时候对页面上的属性做额外的处理,这个属性默认当中只有img:src,也就是表示img标签的src属性。

那我们就根据这样一个使用的规范,我们去添加一个a:href属性,让他去能支持我们a标签的href属性。

const path = require('path');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'url-loader'
            },
            {
                test: /.html$/,
                use: {
                    loader: 'html-loader',
                    options: {
                        attrs: ['img:src', 'a:href']
                    }
                }
            }
        ]
    }
}

完成以后我们再次回到命令行中运行打包。在打包的结果中我们就可以看到a标签用到的资源已经参与了打包。回到浏览器刷新页面,点击a标签就可以正常找到依赖文件。

通过刚刚的这些尝试我们发现,几乎我们在代码当中所有需要引用到的资源,就是有引用资源的可能性的地方,都会被webpack找出来,然后根据我们的配置,交给不同的loader去处理,最后将处理的结果整体打包到输出目录。webpack就是通过这样一个特点去实现我们整个项目的模块化。

Webpack 核心工作原理

webpack官网的首屏图片就已经很清楚的描述了他的工作原理,这里我们来简单理解一下webpack打包的核心工作过程。

我们以一个普通的前端项目为例,在我们的项目中一般都会散落着各种各样代码及资源文件,webpack会根据我们的配置找到其中的一个文件作为打包的入口,一般情况这个文件都会是一个JavaScript文件。

然后他会顺着我们入口文件当中的代码根据代码中出现的import或者require之类的语句解析推断出来这个文件所依赖的资源模块,然后分别去解析每个资源模块,对应的依赖,最后就形成了整个项目中所有用到文件之间的一个依赖关系的一个依赖树。

有了这个依赖关系树过后webpack会遍历,或者更准确的说法叫递归这个依赖树然后找到每个节点所对应的资源文件。最后再根据我们配置文件当中的属性去找到这个模块所对应的加载器,然后交给对应的加载器去加载这个模块。

最后会将加载到的结果放到bundle.js也就是我们的打包结果当中,从而去实现我们整个项目的打包,整个过程当中,loader的机制其实起了很重要的一个作用,因为如果没有loader的话他就没有办法去实现各种各样的资源文件的加载,那对于webpack来说他也就只能算是一个用来去打包或者是合并js模块代码的一个工具了。

Webpack 开发一个Loader

接下来我们来开发一个loader,通过这个过程,我们在来深入的了解loader的工作原理。

这里我们的需求是一个markdown-loader, 需求是有了这样一个加载器之后就可以在代码当中直接去导入markdown文件。

main.js

import about from './about.md';
console.log(about);

about.md

# 关于我

我是隐冬

我们都知道,markdown文件一般都是被转换为html过后再去呈现到页面上的,所以说这里希望我们导入的markdown文件得到的结果就是markdown转换过后的html字符串。console.log(about);

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

每个webpack-loader都需要去导出一个函数,这个函数就是我们这个loader对我们所加载到的资源一个处理过程,输入就是我们所加载到的资源文件的内容,输出就是我们此次加工过后的结果。

我们通过source参数去接收输入,通过我们的返回值去输出。这里我们先尝试打印一下source, 然后直接去返回一个字符串hello,我们看下结果。

module.exports = source => {
    console.log(source);
    return 'hello';
}

完成以后我们回到webpack的配置文件当中,去添加一个加载器的规则配置, 这里我们配置的扩展名就是.md,我们所使用的加载器就是我们刚刚编写的markdown-loader模块。

这里可以看出我们use属性不仅可以使用模块的名称,其实对于模块的文件路径也是可以的,这一点与node当中的require函数是一样的。所以说我们这里直接使用相对路径去找到这个markdown-loader。

const path = require('path');

module.exports = {
    mode: 'none',
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.md$/,
                use: './markdown-loader'
            }
        ]
    }
}

配置好过后我们直接回到命令行进行打包。

yarn webpack

打包过程当中命令行确实打印出来了我们所导入的markdonw文件内容,这也就意味着我们的source确实是所导入的文件内容,但是呢同时也爆出了一个解析错误,说的意思就是我们还需要一个额外的我们还需要一个额外的加载器去处理我们当前的加载结果。这是为什么呢?

其实webpack加载资源的过程有点类似一个工作管道,可以在这个过程当中依次使用多个loader,但是要求我们最终这个管道工作过后的结果必须是一段JavaScript代码,由于我们这里返回的内容不是一个标准的JavaScrit代码,所以才会出现这样的错误提示。

知道这个错误的原因之后解决的办法其实也就很明显了,要么就是我们这个loader直接去返回一段标准的JavaScript代码,要么就是我们再去找一个合适的加载器,接着去处理我们这里返回的结果。

这里我们先尝试第一种办法,回到markdow-loader当中,这里我们将返回的这个字符串修改为console.log("hello"), 这也就是一段标准的JavaScript代码。

module.exports = source => {
    console.log(source);
    return 'console.log("hello")';
}

然后我们再次运行打包。此时可以发现,打包过程中就不会再报错了。我们来看一下打包过后的结果是什么样的。打开bundle.js找到最后一个模块。

这里其实也非常简单,webpack打包的时候就是把我们刚刚的loader加载过后的结果,也就是返回的那个字符串,直接拼接到我们这个模块当中了。这也就解释了刚刚为什么说loader管道最后为什么要返回JavaScript代码的原因。

/* 1 */
/***/ (function(module, exports) {

console.log("hello")

/***/ })

因为如果说你随便返回一个内容的话,放到这里语法就可能不通过,知道了这些过后呢,我们再回到loader当中,然后接着去完成我们刚刚的需求,这里我们先去安装一个markdown解析的模块,叫做marked。

yarn add marked --dev

安装完成过后呢,我们再回到代码当中,去导入这个模块,然后我们在我们的加载器当中去使用这个模块,去解析来自参数当中的这个source,这里我们的返回值就是一段html字符串。也就是转换过后的结果。

这里如果我们直接返回html的话就会面临刚刚同样的问题。正确的做法就是把这段html变成一段JavaScript代码。

这里我们希望是把这段html作为我们当前这个模块的导出的字符串,也就是希望通过module.export=这样一个字符串。但是如果我们只是简单的拼接的话那html当中存在的换行符还有一些引号拼接到一起就可能造成语法上的错误,所以这里使用一个小技巧。

通过JSON.stringify先将这个字符串转换成一个标准的JSON形式字符串,那此时他内部的引号及换行符都会被转译过来,然后我们再参与拼接,那这样的话就不会有问题了。

const marked = require('marked');
module.exports = source => {
    // console.log(source);
    // return 'console.log("hello")';
    const html = marked(source);
    return `module.exports = ${JSON.stringify(html)}`
}

我们回到命令行,再次运行打包。然后再来看下打包的结果。

此时我们看到的结果就是我们所需要的了。

/* 1 */
/***/ (function(module, exports) {

module.exports = "<h1 id=\"关于我\">关于我</h1>\n<p>我是隐冬</p>\n"

/***/ })

当然了,除了module.exports这种方式以外,那webpack还允许我们在返回的代码当中直接去使用ES Module的方式去导出,例如我们将module.export=修改为export default 以 ES Moudle的方式去导出后面的字符串。

const marked = require('marked');
module.exports = source => {
    // console.log(source);
    // return 'console.log("hello")';
    const html = marked(source);
    // return `module.exports = ${JSON.stringify(html)}`
    return `export default ${JSON.stringify(html)}`
}

然后运行打包,结果同样也是可以的,webpack内部会自动转换我们导出的这段代码当中的ES Module代码。

/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = ("<h1 id=\"关于我\">关于我</h1>\n<p>我是隐冬</p>\n");

/***/ })

这里我们就通过了第一种方式解决了我们所看到的那样一个错误,接下来我们再来尝试一下刚刚所说的第二种方法,那就是在我们markdown-loader当中去返回一个html字符串。然后我们交给下一个loader去处理这个html的字符串。这里我们直接返回marked解析过后的html,然后呢我们再去安装一个用于去处理html加载的loader,叫做html-loader

const marked = require('marked');
module.exports = source => {
    // console.log(source);
    // return 'console.log("hello")';
    const html = marked(source);
    // return `module.exports = ${JSON.stringify(html)}`
    // return `export default ${JSON.stringify(html)}`
    return html;
}
yarn add html-loader --dev

安装完成过后我们回到配置文件当中。这里我们把use属性修改为一个数组。这样我们的loader工作过程当中就会依次使用多个loader了。不过这里需要注意就是他的执行顺序是从数组的后面往前面,也就是说我们应该把先执行的loader放在数组的后面。

const path = require('path');

module.exports = {
    mode: 'none',
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.md$/,
                use: ['html-loader', './markdown-loader']
            }
        ]
    }
}

完成以后我们再次打包,查看bundle.js,依然是可以的。

/* 1 */
/***/ (function(module, exports) {

module.exports = "<h1 id=\"关于我\">关于我</h1>\n<p>我是隐冬</p>\n"

/***/ })

我们markdown处理完的结果是一个html的字符串,这个html字符串交给了下一个loader也就是html-loader,这个loader又把他转换成了一个导出这个字符串的一个js代码,这样的话,我们webpack再去打包的时候就可以正常的工作了。

通过以上的这些个尝试我们就发现了,loader内部的一个工作原理其实非常简单,就是一个从输入到输出之间的一个转换,除此之外呢我们还了解了loader实际上是一种管道的概念,我们可以将我们此次这个loader的结果交给下一个loader去处理,然后通过多个loader去完成一个功能。

例如我们之前使用的css-loader和style-loader之前的一个配合,包括我们后面还会使用到的包括sass和less这种loader他们也需要去配合刚刚我们说的这两种loader,这个呢就是我们loader工作管道这样一个特性。

插件机制介绍

插件机制是webpack另外一个核心特性,他的目的是为了增强webpack在项目自动化方面的能力,那我们都知道,loader就是负责实现我们项目中各种各样资源模块的加载, 从而去实现整体项目的打包,而plugin则是用来解决项目中除了资源加载以外,其他的一些自动化的工作。

例如plugin可以帮我们去实现自动在打包之前清除dist目录,也就是我们上一次打包的结果。又或是他可以用来帮我们去copy那些不需要参与打包的资源文件到输出目录,又或是他可以用来帮我们压缩我们打包结果输出的代码。

总之,有了plugin呢webpack几乎无所不能的实现了前端工程化当中绝大多数经常用到的工作,这也是很多初学者会有webpack就是前端工程化的这种理解的原因。

接下来我们一起来学习下webpack插件机制以及这个过程中经常遇到的插件,最后我们再来开发一个自己的插件去理解他的工作原理。

Webpack 自动清除输出目录插件

了解了插件的基本作用过后,接下来我们再来体验几个最常见的插件,然后通过这个工程去了解如何使用插件。那首先第一个就是用来自动清除输出目录的插件。

clean-webpack-plugin

通过之前的演示你可能已经发现,webpack每次打包的结果都是覆盖到dist目录,而在打包之前,dist中可能已经存在一些之前的遗留文件,那我们再次打包,他可能只能覆盖那些同名的文件,对于其他那些已经移除的资源文件就会一直积累在里面,非常不合理。

更为合理的做法就是每次打包之前,自动去清理dist目录,那这样的话dist中就只会保留那些我们需要的文件。

clean-webpack-plugin就很好的实现了这样一个需求,他是一个第三方的插件,我们需要先安装他

yarn add clean-webpack-plugin --dev

安装过后我们回到webpack配置文件,然后去导入这个插件。这个webpack模块导出了一个叫做clean-webpack-plugin的成员,我们把它解构出来,然后回到配置对象当中。

那这里使用插件我们需要为配置对象添加一个plugins属性,那这个属性就是专门用来去配置插件的地方,那他是一个数组,我们去添加一个数组,就是在这个数组中去添加一个元素。

绝大多数插件模块导出的都是一个类型,我们这里的clean-webpack-plugin也不例外,所以我们使用他就是通过这个类型去创建一个实例,然后将这个实例放入到plugins这个数组当中。

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

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin()
    ]
}

完成以后我们再来尝试一下,回到命令行打包,此时发现,之前那些打包的结果就不会存在了,dist中就都是我们本次打包的结果,非常的干净。

Webpack 自动生成HTML插件

html-webpack-plugin

除了清理dist目录以外,还有一个非常常见的需求就是自动去生成使用打包结果的html,在这之前我们的html都是通过硬编码的方式,单独去存放在项目的跟目录下的,那这种方式有两个问题。

第一就是我们在项目发布时,我们需要用时去发布跟目录下的html文件和dist目录下所有的打包结果,这样的话相对麻烦一些。而且我们上线过后还需要去确保html代码当中路径,引用都是正确的。

第二个问题就是,如果说我们输出的目录或者是输出的文件名也就是我们打包结果的配置发生了变化,那html代码当中script标签所引用的那个路径也就需要我们手动的去修改,这是硬编码的方式存在的两个问题。

解决这两个问题最好的办法就是通过webpack自动去生成我们的html文件,也就是让html也自动参与到html构建的过程中去,那在构建过程中webpack知道生成了多少个bundle,会自动将这些打包的bundle添加到我们的页面当中。这样的话一来我们的html他也输出到了dist目录,上线时我们只需要把dist目录发布出去就可以了,二来我们html当中对于bundle的引用他是动态的注入进来的,不需要我们手动的去硬编码。所以他可以确保路径的引用是正常的。

具体的实现方式我们需要去借助一个叫做html-webpack-plugin的插件去实现,这个插件同样也是一个第三方模块,这里我们同样也需要单独去安装这个模块。

yarn html-webpack-plugin --dev

安装完成过后我们回到配置文件当中,然后载入这个模块。这里不同于clean-webpacl-plugin那html-webpack-plugin默认导出的就是一个插件的类型,我们不需要解构他内部的成员。

有了这个类型过后我们就可以回到配置文件的plugins属性当中,然后去添加一个这个类型的实例对象。这样的话就完成了我们这个插件的配置。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin()
    ]
}

最后我们回到命令行终端,再次运行打包命令。此时我们的打包过程中就会自动生成一个index.html的一个文件,输出到dist目录。我们找到这个文件。

这个文件中的内容就是一段使用了我们bundle.js的一个空白的html,不过呢,这里的路径还是有一点问题,正确的路径应该是当前目录下的bundle.js而我们这里确生成了dist/bundle.js。

这是因为之前我们去尝试其他特性的时候我们去把output属性当中的publicPath设置成了dist,那现在呢我们的html是自动生成到了dist目录,所以我们就不再需要这样一个配置了,我们回到配置文件中去删除这样一个配置。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin()
    ]
}

删除完成过后我们再次重新打包。打包完成过后我们的index.html当中对于bundle引用的路径已经正常了。至此我们就不再去需要跟目录下写死的html文件了,以后我们html文件都是通过自动取生成出来的。

但是这里仍然存在一些需要改进的地方,首先就是我们对于默认生成的html的标题是必须要修改的,另外我们很多时候还需要自定义页面中的一些原数据标签和基础的DOM结构,对于简单的自定义的话我们可以通过修改html-webpack-plugin这个插件一些属性来去实现,我们回到webpack的配置文件当中。

这里我们给html-webpack-plugin这个构造函数去传入一个对象参数,用于去指定我们的配置选项,那title属性就是用来设置我们html的标题。我们这里设置为webpack-plugin-simple。

meta属性可以以对象的属性设置页面中的一些原数据标签,例如我们尝试为页面添加一个viewport的设置。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            }
        })
    ]
}

完成以后我们回到命令行重新打包,我们看下生成的html文件,此时这里的title和mate就根据我们配置文件当中的配置去生成。

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

我们在src目录下新建一个index.html的html模板,然后我们可以根据我们的需要在这个文件中添加一些相应的元素,对于模板当中我们希望动态输出的一些内容我们可以使用loadsh模板语法的方式去输出。这里我们可以通过htmlWebpackPlugin.options这个属性去访问到我们这个插件的配置数据, 那配置数据当中的title我们就可以直接输出出来。

当然htmlWebpackPlugin这个变量实际上是他内部提供的一个变量,也可以通过另外的属性去添加一些自定义的变量。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
    <script src="dist/"></script>
</body>
</html>

有了这个模板文件过后呢,我们回到配置文件当中,我们通过template属性去指定我们所使用的模板为src/index.html文件

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            },
            template: './src/index.html'
        })
    ]
}

再次打包,我们看下所生成的html文件,此时我们看到的html内容就是根据我们刚刚的模板去动态生成的了,以上就是我们自定义输出html内容的一些方式。

除了自定义输出文件的内容,同时去输出多个页面文件也是一个非常常见的需求,除非说我们的应用是一个单一页面应用程序,否则的话我们就一定需要多个html文件。

如果我们需要输出多个html文件其实也非常简单,我们回到配置文件当中。

这里我们刚刚通过html-webpack-plugin创建的对象,他是用于去生成index.html这个文件的。

那我们完全可以再去通过这个类型创建一个新的实例对象,用于去创建额外的html文件。

例如我们这里再去添加一个新的实例,然后用于去创建一个叫做about.html的页面文件,我们可以通过filename去指定输出的文件名,这个属性的默认值是index.html, 我们这里需要设置为about.html。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            },
            template: './src/index.html'
        }),
        new HtmlWebpackPlugin({
            filename: 'about.html'
        })
    ]
}

重新打包, 此时dist目录下就同时生成了index.html和about.html两个页面文件。

根据这样一个尝试我们就知道,如果说我们需要去创建多个页面,那我们就可以在插件列表当中去加入多个htmlWebpackPlugin实例的对象,每个对象呢就是用来去负责生成一个页面文件的。

Webpack 插件使用总结

copy-webpack-plugin

在我们的项目中一般还有一些不需要参与构建的静态文件,那他们最终也需要发布到线上,他们最终也会发布到线上,例如我们网站的favicon.ico, 一般我会把这一类的文件统一放在项目根目录下的public目录当中。我们希望webpack在打包时可以一并将他们复制到输出目录。

对于这种需求我们可以借助于copy-webpack-plugin去实现。同样我们需要先安装这个插件。

准备public/favicon.icon文件。

yarn add copy-webpack-plugin --dev

安装完成过后我们回到配置文件当中,再去导入这个插件的类型。最后我们同样在这个plugins属性当中去添加这个类型的实例。

那这个类型的构造函数他要求我们传入一个数组,用于去指定我们需要copy的文件路径,那他可以是一个通配符,也可以是一个目录或者是文件的相对路径。

我们这里传入的是一个public/**目录。表示在打包时会将public目录下所有的文件全部拷贝到输出目录。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            },
            template: './src/index.html'
        }),
        new HtmlWebpackPlugin({
            filename: 'about.html'
        }),
        new CopyWebpackPlugin([
            'public/**'
        ])
    ]
}

完成以后我们重新打包,打包完成过后呢我们public目录下所有的文件就会同时copy到输出目录了。

总结

至此我们就了解了几个非常常用的插件,那这些插件呢一般都适用于任何类型的项目。不管说你的项目中有没有使用框架或者是使用了哪一个框架。那他们都基本上会用到。

所以之后最好能仔细去过一遍这些插件的官方说明,然后去看看他们还可以有哪些特别的用法,做到心中有数。

除此之外社区当中还提供了成百上千的插件,你并不需要全部认识,在你有一些特殊的需求时,再去提炼你需求当中的一些关键词然后去github上去搜索他们。

例如我们想要去压缩输出的图片,那我会去搜索imagemin webpack plugin。

虽然每个插件的作用不尽相同,但是他们在用法上几乎都是类似的。

Webpack 开发一个插件

通过前面的介绍我们知道相比于loader来说plugin能力范围相对会更宽一些,因为loader他只是在加载模块的环节去工作。而插件的作用范围几乎可以触及到webpack工作的每一个环节。

这样的插件机制究竟是如何实现的呢,其实说起来也非常简单。webpack的插件机制其实就是我们再软件开发过程中最长见到的,钩子机制。

那钩子机制也特别容易理解,有点类似我们web当中的事件。那在webpack的工作过程中会有很多的环节。为了便于插件的扩展。webpack几乎给每一个环节都埋下了一个钩子。

那这样的话我们再去开发插件的时候,我们就可以通过往这些不同的节点上面去挂载不同的任务,可以轻松地去扩展webpack的能力。

具体有哪些预先定义好的钩子,我们可以参考官方的API文档,接下来我们来定义一个插件,来看具体如何往这些钩子上去挂载任务。

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

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

这里我们的需求是希望这个插件可以用来去清除webpack打包生成的js当中那些没有必要的注释,那这样一来的话我们bundle.js当中去除了这些注释之后就可以更加容易阅读。有了这个需求过后呢我们需要明确我们这个任务的执行时机,也就是我们要把这个任务挂载到哪个钩子上。

我们的需求是删除bundle.js当中的注释,也就是说我们只有当webpack需要生成这个bundle.js文件的内容明确了过后,我们才可以实施相应的动作,那我们回到webpack的官网。我们找到他的API文档。

在API文档当中我们找到一个叫做emit的钩子,根据文档当中的提示我们发现这个钩子在webpack即将要往输出目录输出文件时执行。非常符合我们的需求。

我们回到代码当中,我们通过compiler当中的hooks属性我们去访问到emit钩子。然后我们通过tap方法去注册一个钩子函数。

这个方法接收两个参数,第一个参数是插件的名称。我们这里是MyPlugin,第二个就是我们需要挂载到这个钩子上的函数。这里我们可以在函数中接收一个complation的对象参数,这个对象可以理解成此次打包过程的上下文。

我们所有打包过程中产生的结果都会放到这个对象中。我们这里使用一下这个对象的assets属性。然后去获取我们即将想入到目录文件中的资源信息。complation.assets;

那他是一个对象,我们这里通过for in 去遍历这个对象。那这个对象当中的键就是每一个文件的名称,我们尝试把他打印出来。然后将这个插件我们应用到我们的配置当中。通过 new MyPlugin的方式把他应用起来。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
class MyPlugin {
    apply(compiler) {
        console.log('MyPlugin 启动');

        compiler.hooks.emit.tap('MyPlugin', complation => {
            for (const name in complation.assets) {
                console.log(name);
            }
        })
    }
}

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            },
            template: './src/index.html'
        }),
        new HtmlWebpackPlugin({
            filename: 'about.html'
        }),
        new CopyWebpackPlugin([
            'public/**'
        ]),
        new MyPlugin()
    ]
}

然后回到命令行中再次运行打包。此时我们打包过程中就会输出出来我们打包的文件名称。那我们再回到代码当中我们再来尝试一下打印每一个资源文件的内容。

那文件的内容我们是要通过这个文件当中的值的source方法来去获取。

我们通过assets然后访问到具体属性的值,然后通过source方法拿到他对应的内容。

class MyPlugin {
    apply(compiler) {
        console.log('MyPlugin 启动');

        compiler.hooks.emit.tap('MyPlugin', complation => {
            for (const name in complation.assets) {
                console.log(assets[name].source());
            }
        })
    }
}

然后回到命令行,再次打包。此时呢打包过程中我们输出的文件内容也可以正常被打印。

可以拿到文件名和内容过后呢。我们再回到代码当中。我们要去判断我们的文件是否以.js结尾,因为我们这里的需求只是去处理js文件。所以如果不是js文件我们就不需要去处理他。

这里如果是js文件的话我们将文件的内容得到。然后我们通过正则的方式去替换掉我们代码当中对应的注释,这里需要注意的是我们正则要以全局模式去替换,将这个替换的结果我们需要去覆盖到原有的内容当中,我们要去覆盖complation当中的assets里面所对应的那个属性。

那这个属性的值我们同样去暴露一个source方法用来去返回我们这个新的内容。除此之外我们还需要一个size方法,用来去返回这个内容的大小,这个方法是webpack内部要求的一个必须的方法。

class MyPlugin {
    apply(compiler) {
        // console.log('MyPlugin 启动');

        compiler.hooks.emit.tap('MyPlugin', complation => {
            for (const name in complation.assets) {
                // console.log(assets[name].source());
                if (name.endsWith('.js')) {
                    const contents = complation.assets[name].source();
                    const withoutComments = contents.replace(/\/*\**\*\//g, '');
                    complation.assets[name] = {
                        source: () => withoutComments,
                        size: () => withoutComments.length
                    }
                }
            }
        })
    }
}

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

以上就是我们实现的一个移除webpack注释插件的一个过程,通过这个过程我们了解了,插件是通过往webpack生命周期里面的一些钩子函数里面去挂载我们任务函数来去实现的。

当然如果你需要深入去了解插件机制,可能需要去理解一些webpack底层的实现原理,那这些在我们的文档当中其实并没有详细的介绍。

所以你需要通过去阅读源代码来了解他们,关于源代码的一些其他信息或者是运行的底层原理,这些我们后面再来介绍。

Webpack 开发体验问题

在此之前我们已经了解了一些webpack的相关概念和一些基本的用法,但是如果以目前的状态去应对日常的开发工作还远远不够,那是因为编写源代码,再通过webpack打包,然后运行应用,最后刷新浏览器这周周而复始的方式过于原始。

如果说我们在实际的开发过程中还按照这种方式去使用必然会大大降低我们的开发效率。那究竟该如何去提高我们的开发效率呢,那这里呢我们先对我们理想的开发环境做出设想。

首先我们希望这样一个开发环境他必须能够使用http的服务去运行而不是以文件的形式去预览,那这样我们一来可以更加接近生产环境的状态,二来呢我们可能需要去使用ajax之类的一些api,那这些api呢,文件的形式去访问他是不被支持的。

其次我们希望这样一个环境中,去修改源代码过后,webpack就可以自动帮我们完成构建,然后我们的浏览器可以及时的显示最新的结果,那这样的话就可以大大的减少我们在开发过程中额外的那些重复操作。

最后我们还需要这样一个环境它能够去提供sourceMap支持,那这样依赖的话我们运行过程中一旦出现了错误就可以根据错误的堆栈信息快速定位到源代码当中的位置,便于我们调试应用。

那对于以上这些需求呢webpack都已经有了相应的功能去实现了,那接下来我们一起去重点了解,具体如何去增强我们使用webpack的开发体验。

Webpack 自动编译

用命令行手动重复去使用webpack命令从而去得到最新的打包结果,这种办法特别的麻烦,我们也可以使用webpack-cli提供的一种,watch的工作模式去解决这个问题,如果说你之前了解过一些其他的构建工具。

那你应该对这种模式并不陌生,那在这种模式下面,我们项目下的源文件会被监视, 一旦这些文件发生变化,那他就会自动重新去运行我们的打包任务。

具体的用法也非常简单,就是我们再去启动webpack命令时我们添加一个--watch的参数,那这样的话webpack就会以监视的模式去运行。

yarn webpack --watch

那在打包完成过后呢,我们的cli不会立即退出,他会等待文件文件的变化,然后再次的工作一直到我们手动结束这个cli。

那在这种模式下面我们就只需要专注编码,不比再去手动的完成这些工作了。那这里我们可以在在开启一个新的命令行终端,同时以http的形式去运行我们的应用。

http-server ./dist

然后我们打开浏览器去预览我们的应用。

我们可以将浏览器移至我们屏幕的右侧,然后将编辑器移至左侧,那此时我们就可以尝试修改源代码,保存过后我们以观察模式工作的webpack就会自动重新打包,打包完成过后我们就可以到浏览器中取刷新页面,然后去查看最新的页面结果。

Webpack 自动刷新浏览器

通过watch模式启动webpack,当文件内容发生改变时,webpack就会自动编译,然后我们回到浏览器,手动刷新后就可以看到运行结果。

那如果说流浏览器能在编译过后自动去刷新,那我们的开发体验呢将会更好一些,那如果之前你已经了解过一个叫做,browser-sync的工具,那你就会知道这个工具呢就会帮我们实现这个自动刷新的功能。

yarn add --global browser-sync

那这里呢,我已经在全局范围安装了这个工具,我们这里先结束掉http-server启动的这个http服务,然后我们使用browser-sync去启动http服务。这里同时我们还要监听dist文件下的文件变化。

browser-sync dist --files "**/*"

那启动过后呢我们再回到编辑器当中,然后我们尝试修改源文件。那保存过后我们发现浏览器自动刷新然后去显示最新的结果。

那他的原理就是webpack自动打包我们的源代码到dist当中,那dist的文件变化又被browser-sync监听了,从而去实现了我们的自动编译并且自动刷新浏览器。

不过这种方式去解决有很多弊端,第一就是我们操作上太麻烦了,因为我们需要同时使用两个工具,第二就是我们再开发效率上会有一些降低,因为这个过程中webpack会不断将文件写入磁盘,然后browser-sync再把它从磁盘中读出来,那这个过程中,一次就会多出两步的磁盘读写操作。

所以说我们还需要继续去改善这个开发体验。

Webpack Dev Server

那根据他的名字我们就应该知道,他提供了一个开发服务器,并且他将自动编译和自动刷新浏览器等一系列对开发非常友好的功能呢都全部集成在了一起。

那我们这呢可以使用这个工具直接去解决我们之前的问题。那因为这是一个高度集成的工具,所以说他使用起来也非常的简单,我们这里打开命令行,然后我们通过yarn将webpack-dev-server作为我们项目的开发依赖去安装。

yarn add webpack-dev-server --dev

安装完成过后这个模块为我们提供了一个webpack-dev-server的cli程序。我们同样可以用过yarn去运行这个cli。或者可以把它定义到npm scripts中。

yarn webpack-dev-server

我们去运行这个命令,那他内部就会自动去使用webpack去打包我们的应用。并且会启动一个http-server去运行我们的打包结果。

在运行过后他还会监听我们的代码变化,一旦我们的源文件发生变化,他就会自动立即重新打包。那这一点和我们的watch模式是一样的。

不过这里我们也需要注意,webpack-dev-server为了提高工作效率他并没有将打包结果写入到磁盘中,根据我们左侧文件数我们可以发现并没有出现dist目录。他是将打包结果暂时存放在内存当中,而内部的http-server也就是从内存当中把这些文件读出来,然后发送给浏览器。那这样一来他就会减少很多磁盘不必要的读写操作,从而大大提高我们的构建效率。

这里我们还可以为这个命令传入一个--open的参数,用于自动唤起我们的浏览器,去打开我们的运行地址。

yarn webpack-dev-server --open

那打开浏览器过后呢,此时如果说你有两块屏幕的话就可以把浏览器放到另外一块屏幕当中,然后我们可以实现这种一边编码,一边及时预览的开发环境。

Webpack 静态资源访问

那此时我们的开发体验就是我们修改完文件会自动打包输出到浏览器,那也就是只要通过webpack打包能够输出的文件都可以正常被访问到。

但是如果你还有一些静态文件也需要作为开发服务器的资源被访问的话,那你就需要额外的去告诉webpack-dev-server。

具体的方法就是在我们webpack的配置文件当中去添加一个对应的配置,我们回到配置文件当中。

那这里我们在配置对象当中去添加一个dev-server的属性,那这个属性书专门用来为webpack-dev-server去指定相关的配置选项。

那我们可以通过这个配置对象的contentBase属性来去指定额外的静态资源路径。那这个属性可以是一个字符串或者是一个数组, 也就是说我们可以配置一个或者是多个路径。我们这里将这个路径设置为项目根目录中的public目录。

可能有人会有疑问,因为之前我们已经通过插件将这个目录输出到了我们输出目录。那按照刚刚的说法,我们所有输出的文件都可以直接被server也就是直接可以在浏览器端访问到。那按道理来讲这里这些文件就不需要再作为开发服务器的额外的资源路径了。

事实情况确实如此,如果你能这么想那也证明你确实理解了这样一个点,但是呢,我们在实际去使用webpack的时候我们一般都会把copy-webpack-plugin这样的插件留在上线前的那次打包中使用。那在平时的开发过程中我们一般不会去使用它,那这是因为在开发过程中我们会频重复执行打包任务。那假设我们需要copy的文件比较多,或者是比较大,那如果说我们每次都去执行这个插件的话,那我们打包过程中的开销就会比较大那速度自然也就会降低了。

那由于这是额外的话题,具体的操作方式具体怎么样让我们在开发阶段不去使用copy-webpack-plugin然后在上线前那一刻我们再去使用这种插件,这种操作方式我们在后续再来介绍。这里我们先注释掉copy-webpack-plugin。

这样确保我们在打包过程中不会再去输出public目录中的静态资源文件。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    devServer: {
        contentBase: './public',
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            },
            template: './src/index.html'
        }),
        new HtmlWebpackPlugin({
            filename: 'about.html'
        }),
        // new CopyWebpackPlugin(['public'])
    ]
}

然后我们回到命令行,再次执行webpack-dev-server 那启动过后我们此次public目录当中并没有被copy到我们输出目录。

那如果说我们webpack只去加载那些我们打包生成的文件的话,那我们publc目录中的文件应该是访问不到的,但是我们通过contentBase已经将他指定为了额外的资源路径,所以说我们是可以访问到的,我们打开浏览器。

这里我们去访问的页面文件,包括我们再来访问的bundle.js文件都是来源于打包结果当中。

然后我们再去尝试一下访问favicon.ico, 那这个文件就是来源于我们contentBase中所配置的public目录了,除此之外呢,像这个other.html他也是这个目录当中所指定的文件。

以上就是contentBase他可以用来去为webpack-dev-server额外去指定一个静态资源目录的操作方式。

Webpack 代理

webpack-dev-server在启动服务时创建的是一个本地服务,访问地址一般为localhost + 端口号,而最终上线过后呢我们的应用一般又和我们的API会部署到同源地址下面。

那这样的话就会有一个非常常见的问题,那就是我们在实际生产环境当中我们可以直接去访问API, 但是回到我们开发环境当中就会产生跨域请求问题。

那可能有人会说我们可以使用跨域资源共享的方式去解决这个问题,事实也确实如此,我们如果请求的这个API, 支持CORS的话那我们这个问题就不成立了。

但是并不是每种情况下我们的服务端的API都一定要支持CORS的,如果说我们前后端同源部署的话也就是我们的域名,协议和端口是一致的话,那这种情况下我们根本没有必要去开启CORS。

所以以上这个问题还是经常会出现。那解决这个问题最好的办法就是在我们开发服务器去配置代理服务也就是把我们的接口服务代理到本地的开发服务地址。

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

我们这里的目标就是将github的api代理到我们本地的开发服务器当中。我们先在浏览器尝试访问一下其中的一个接口。

github的接口的Endpoint一般都是在根目录下,例如我们这里所使用的user

https://api.github.com/users

Endpoint可以理解为接口端点,入口。

知道了接口的地址之后我们回到配置文件当中,这里我们在devServer当中去添加一个proxy属性。

那这个属性就是专门用来添加代理服务配置的,这个属性是个对象,其中每一个属性就是一个代理规则的配置。属性的名称就是我们需要被代理的请求路径前缀,也就是我们请求以哪一个地址开始,就会走代理请求,一般为了辨别,我都会将其设置为/api, 也就是我们请求我们开发服务器中的/api开头的这种地址,我们都会让他代理到我们的接口当中。

那他的值是为这个前缀所匹配到的代理规则配置。我们将代理目标设置为https://api.github.com, 也就是说当我们请求/那我们的代理目标就是api.github.com这样一个地址。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist')
    },
    devServer: {
        contentBase: './public',
        proxy: {
            '/api': {
                target: 'https://api.github.com'
            }
        }
    },
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            },
            template: './src/index.html'
        }),
        new HtmlWebpackPlugin({
            filename: 'about.html'
        }),
        // new CopyWebpackPlugin(['public'])
    ]
}

那此时如果我们去请求http://localhost:8080就是webpack-dev-server的这个地址。然后加上/api/users我们就相当于请求了https://api.github.com/api/users。意思是我们请求的路径是什么,他最终代理的这个地址,路径是会完全一致的。

而我们需要请求的这个接口地址呢,实际上在api.github.com/users 并没有 /api/users, 所以我们对于代理路径中的/api我们需要重写的方式把他去掉。

我们可以添加一个pathRewrite属性,来去实现代理路径的重写。那重写规则就是把我们路径中以/api开头的这段字符串替换为空字符串,因为pathRewrite这个属性最终会以正则的方式去替换我们请求的路径。所以说我们这里已^表示开头。

除此之外我们还需要设置changeOrigin属性为true,这是因为默认我们代理服务器会以我们实际在浏览器当中请求的主机名,就是localhost:8080作为代理请求的主机名。

也就是我们在浏览器端对我们代理过后这个接口发起请求,那这个请求背后肯定还需要请求到github的那个服务器,那请求的过程中会带一个主机名,那这个主机名默认情况下使用的是我们用户在浏览器端发起请求的这个主机名也就是localhost:8080。

而一般情况下,服务器那头是要根据主机名去判断我这台,因为你一个请求请求到服务器过后,服务器一般会有多个网站,那他会根据主机名去判断,这个请求是属于哪个网站从而把这个请求指派到对应的网站。

那localhost:8080对于github的服务器来说肯定是不认识的,所以我们这里需要去修改。

那changeOrigin=true的这种情况下就会以实际我们代理请求这次发生的过程中的主机名去请求。那我们请求github的这个地址,我们真正请求的应该是api.github.com这样一个地址,所以说他的主机名肯定就会保持原有状态。

那这个时候呢,我们就不用在关心我们最终把他代理成了什么样。我们只需要正常的去请求就可以了。

{
    contentBase: './public',
    proxy: {
        '/api': {
            target: 'https://api.github.com'.
            pathRewrite: {
                '^/api': ''
            },
            changeOrigin: true
        }
    }
}

那完成以后我们再回到命令行终端,然后去运行webpack-dev-server, 然后我们再去打开浏览器。

yarn webpack-dev-server

那这里我们直接去尝试请求localhost:8080/api/users

localhost:8080/api/users

请求完成过后我们看到,此时这个地址就被代理到了我们github的用户数据接口。

那我们这么地方可以再次回到代码当中来去使用这个代理过后的地址去请求接口。这种地址就不用再去担心跨域问题,因为他是同源地址。

可能在这个过程当中针对于changeOrigin 也就是那个主机名那块可能会有一些只是了解了前端基础的同学会不会特别清楚。这个原因是因为我们在http里面有一些相关的知识点。可能之前没有了解过,可以再去查一下就是host也就是主机名相关的一些概念,就可以解决这些问题了。

Source Map

通过构建编译之类的操作我们可以将开发阶段的源代码转换为能够在生产环境当中运行的代码。这是一种进步,但是这种进步的同时也就意味着我们在实际生产环境当中运行的代码与我们开发阶段所编写的代码之间会有很大的差异。

那在这种情况下如果我们需要去调试我们的应用,又或是我们运行应用的过程中出现了意料之外的错误,那我们将无从下手。

这是因为我们无论是调试还是报错,那他都是基于转换过后的代码来运行的。那Source Map就是解决这一类问题最好的一个办法。

他的名字就已经表述了他的作用叫做源代码地图,那他就是用来映射我们转换过后的代码与源代码的关系,那一段转换过后的代码呢我们通过转换过程当中生成的这个source map文件就可以逆向得到源代码。

那目前很多第三方的库再去发布的文件当中都会有一个.map后缀的source map文件,那例如jQuery,我们可以打开jQuery的source map文件然后看一下,这是一个JSON格式的文件,为了更容易阅读我们格式化一下。

那这个JSON文件里面记录的就是我们转换过后的代码与转换之前代码之间的映射关系。主要有这么几个属性我们简单来看一下。

首先就是version这个属性指的是我们当前这个文件所使用的的source map标准的版本。

然后就是source属性,那这个属性记录的就是我们转换之前源文件的名称。那因为很有可能是多个文件合并,转换成了一个文件,所以说这里的属性是一个数组。

再然后就是names属性,这个属性指的是我们的源代码当中使用的一些成员名称,我们都知道,在压缩代码时我们会将开发阶段编写的那些有意义的变量去替换为一些简短的字符从而去压缩我们整体代码的体积。那这个属性中记录的就是我们原始对应的那些名称。

最后是mappings属性, 那这个属性其实是我们整个source map文件的核心属性。那他是一个base64-vl编码的字符串。那这个字符串他记录的信息就是我们转换过后代码当中的字符与我们转换之前所对应的映射关系。

{
    "version": 3,
    "file": "jquery.min.js",
    "sources": [
        "jquery.js"
    ],
    "names": [
        "window",
        "undefined",
        "readyList",
        "rootjQuery",
        "core_strundefined",
        "location",
        "document",
        "docElem",
        "documentElement",
        "_jQuery",
        "jQuery",
        "_$",
        "$",
        "class2type"
        ...
    ],
    "mappings": ";;;CAaA,SAAWA,EAAQC,GAOnB,GAECC,GAGAC,EAIAC,QAA2BH,GAG3BI,EAAWL,EAAOK,SAClBC,EAAWN,EAAOM,SAClBC,EAAUD,EAASE,gBAGnBC,EAAUT,EAAOU,OAGjBC,EAAKX,EAAOY,EAGZC,KAGAC,
    ...
    "
}

有了这样一个文件过后一般我们会在转换过后的代码当中通过添加一行注释的方式来去引入这个source map文件。

不过呢,source map这个特性只是帮助开发者更容易去调试和定位错误的,所以说他对生产环境其实没有什么太大的意义。

在最新版的jQuery当中他已经去除了引用source map的注释,我们这里想要去尝试的话需要手动的添加回来。这里我们在jQuery.min.js这个文件当中最后一行我们去添加一个这样的注释。这个注释有一个特定的格式。

就是以#开头,然后source mapping url = 我们jQuery的map文件

//# sourceMappingURL = jquery-3.4.1.min.map

那这样的话,我们在浏览器当中如果说打开了开发人员工具的话,那开发人员工具去加载到的这个js文件最后有这么一行注释他就会自动去请求这个source map文件。

然后根据这个文件的内容逆向解析出来我们对应的源代码。以便于我们的调试,同时因为有了映射的关系所以说我们源代码当中如果说出现了错误那也就很容易能定位到源代码当中对应的位置了。

那我们这里回到浏览器。然后我们打开开发人员工具找到source面板,那在source面板我们就能看到转换之前的jquery源代码。

他也可以显示在里面,那我们这里还可以在我们的代码当中添加一个断点,然后我们刷新页面。这个时候我们就可以单步去调试整个jquery的源代码。

那此时调试过程当中使用的就不再是我们之前所使用的的压缩过后的代码,而是真正意义上的源代码,这就是source map的一个作用。

那这里简单总结一下,source map的作用解决的就是我们在前端方向引入了构建编译之类的概念过后导致我们前端编写的源代码与运行的代码之间不一样所产生的那些调试的问题。

Webpack 配置source map

webpack可以为我们的打包结果生成对应的source map文件,那用法上也非常简单,不过他提供了很多不同的模式。那这就导致大部分的初学者可能会比较懵。

那接下来我们一起来研究一下,webpack中如何去配置使用source map以及他几种不同模式之间的差异,那我们回到配置文件当中。

这哭我们需要使用的一个配置属性叫做devTool 这个属性就是用来配置我们开发过程中的辅助工具,也就是与source map 相关的一些功能配置。

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
    },
    devtool: 'source-map',
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            meta: {
                viewport: 'width=device-width'
            },
            template: './src/index.html'
        }),
        new HtmlWebpackPlugin({
            filename: 'about.html'
        }),
        new CopyWebpackPlugin([
            'public/**'
        ])
    ]
}

这里我们可以直接将这个属性设置为source-map, 然后我们打开命令行终端,运行webpack打包。

打包完成过后我们打开所生成的dist目录, 可以发现在这个目录中生成了一个对应的bundle.js.map文件。而且我们打开bundle.js找到这个文件的最后那这个文件的最后也通过注释的方式引入了这个source-map文件。

/******/ (function(modules) { // webpackBootstrap
/******/ })
/************************************************************************/
/******/ ([
/******/ ]);
//# sourceMappingURL=bundle.js.map

这里我们再回到命令行终端,运行服务打开浏览器查看。此时如果js存在错误,我们就可以通过console中的提示直接定位错误所在的源代码当中的位置。当然了,如果你需要去调试源代码的时候,这里我们也就可以直接去调试我们所编写的源代码了。

如果说你只是需要去使用source map的话,那这里就已经可以实现了。但是呢,如果你只是这么去使用的话,那实际的效果呢就会差的比较远。

那为什么这么说呢,因为截止到目前,webpack对source map的风格支持,支持了很多种(12)种。也就是说他有很多实现方式。那每种方式所生成的source map 效果以及生成source map的速度,都是不一样的。

很简单也很明显的一个道理就是效果最好的,一般他的生成速度也就会最慢。而速度最快的,一般生成出来的这个source map文件也就没有什么效果。那具体哪种方式才是最好或者说最适合我们的,那这里我们还需要继续去探索。

Webpack eval 模式的 Source Map

可以使用source-map这个值来支持很多其他的模式, 那具体的我们可以参考文档当中有一个不同模式之间的一个对比表。

这张表中分别从初次构建速度,监视模式,重新打包速度还有就是是否适合在生产环境中使用以及所生成的source map的质量这四个维度去对比了这些不同方式之间的一些差异。

那表格当中的对比呢可能不够鲜明。所以说接下来我们配合表格中的介绍,然后通过具体的尝试来去体会这些不同模式间的差异。从而找到适合自己的最佳实践。

首先我们来看一个叫做eval的模式。

eval他是js当中的一个函数,他可以用来去运行我们字符串当中的js代码,这里我们可以尝试一下。

eval('console.log(123)');

默认情况下这段代码他会运行在一个临时的虚拟机环境中。我们可以通过source url来去声明这段代码所处的文件路径,我们这里再来尝试执行以下。不过我们在这段js代码字符串当中去添加一段注释内容。

就是//# sourceURL=./foo/bar 然后我们执行,此时呢我们这段代他所运行的这个环境呢就是./foo/bar.js。

这也就意味着,意味着我们可以通过sourceURL来去改变我们通过eval执行的这段代码所处的这样一个环境的名称,其实他还是运行在虚拟机环境中。只不过他告诉了执行引擎。这段代码所属的这个文件路径。这只是一个标识而已。

eval('console.log(123) //# sourceURL=./foo/bar.js');

了解了这样一个特点过后我们回到配置文件当中,这里我们将devtool属性设置为eval,也就是使用eval模式,然后我们回到命令行终端。再次运行打包。

那打包完成过后呢,我们运行一下这个应用。然后回到浏览器,刷新一下页面。

那此时根据控制台的提示,我们就能找到这个错误所出现的文件,但是当我们打开这个文件,我们看到的却是打包过后的模块代码。

那这是为什么呢,因为在这种模式下,他会将我们每个模块所转换过后的代码都放在eval函数当中去执行,并且在这个eval函数执行的字符串最后通过sourceURL的方式去说明所对应的文件路径。

那这样的话,浏览器在通过eval去执行这段代码的时候就知道这段代码所对应的源代码是哪一个文件。那从而去实现我们定位错误所出现的文件,只能取定位文件。

那这种模式下他不会去生成source map文件,也就是说,它实际上跟source-map没有什么太大的关系,所以说他的构建速度也就是最快的。但是呢他的效果也就很简单,他只能定位我们源代码文件的名称。而不知道具体的行列信息。

Webpack devtool 模式对比

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

我们回到项目中,目前我们的项目中只有两个js模块,其中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 allModes = [
    'eval',
    'cheap-eval-source-map',
    'cheap-module-eval-source-map',
    'eval-source-map',
    'cheap-module-source-map',
    'inline-cheap-source-map',
    'inline-cheap-module-source-map',
    'source-map',
    'inlie-source-map',
    'hidden-source-map',
    'nosource-source-map',
]

webpack的配置对象可以是一个数组,数组中给的每个元素就是一个单独的打包配置。这样一来我们就可以在一次打包过程中同时去执行多个打包任务。

例如我们这里导出一个数组,然后我们再数组当中添加两个打包配置。我们将它的entry属性,也就是打包的入口都设置为src/main.js,不过他们的输出文件的文件名是不同的。

module.exports = [
    {
        entry: './src/main.js',
        output: {
            filename: 'a.js'
        }
    },
    {
        entry: './src/main.js',
        output: {
            filename: 'b.js'
        }
    }
]

完成以后我们进行打包,这一次打包过程就会有两个子任务进行工作,而我们dist文件夹中生成的结果也就是两个文件。

那了解了webpack的这种用法之后我们就回到配置文件当中。删除刚刚的尝试代码,这里我们通过遍历,去为每一种模式单独去创建一个打包配置,那这样的话我们就可以在一次打包当中同时去生成所有模式下的不同结果。这样的话比我们一个一个去试验要更快一些,也会更明显一些。

每个配置项之中我们先去定义devtool属性,这个属性的值就是我们刚刚遍历的这个名称。然后我们将mode设置为none,那这样就确保webpack内部不会去做额外的处理,那紧接着我们再来设置这个任务的打包入口,以及他输出文件的名称。这里我们将输出文件的名称设置为js目录下,以模式名称命名的一个js文件。

为什么这个文件要放在js目录中,继续往下看。

再下面我们为这个js模块配置一个babel-loader,这里配置babel-loader的目的是接下来我们对比当中能够辨别其中一类模式的差异。

最后我们再来配置一个html-webpack-plugin, 也就是为我们每一个打包任务去生成一个html文件。通过前面的了解我们知道,html-webpack-plugin可以为我们生成使用我们打包结果的html,待会我们就是通过这些html在浏览器当中去尝试这些不同的打包结果。

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

const allModes = [
    'eval',
    'cheap-eval-source-map',
    'cheap-module-eval-source-map',
    'eval-source-map',
    'cheap-module-source-map',
    'inline-cheap-source-map',
    'inline-cheap-module-source-map',
    'source-map',
    'inlie-source-map',
    'hidden-source-map',
    'nosource-source-map',
];

module.exports = allModes.map(item => {
    return {
        mode: 'none',
        devtool: item,
        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`
            })
        ]
    }
})

配置完成过后我们再次打开命令行终端。运行打包。此时这个打包过程就会生成所有模式下的打包结果,然后我们再启动服务,打开浏览器,那此时我们就能在页面中看到所有不同模式下的html,如果刚刚我们没有把js文件单独输出到一个目录的话,这里的文件就会非常的多,我们看起来就不会那么的容易。

有了这样一个页面过后我们接下来就可以在这个里面去单独对比不同模式之间的差异了。

有了这些不同模式下的打包结果过后,那接下来我们就可以一个一个仔细去对比了,我们这里先看几个比较典型的模式, 然后找出他们之间的关系。

  1. 首先我们看下eval模式。

这个模式刚刚我们已经单独看过了,他就是将我们的模块代码放到eval函数当中去执行。并且通过sourceURL去标注我们这个模块文件的路径。这种模式下并没有生成对用的source-map他只能定位我们是哪一个文件出了错误。

  1. 接下来我们看下eval-source-map模式。

那这个模式同样也是使用eval函数去执行模块代码,不过这里有所不同的是他除了可以帮我们定位我们错误出现的文件。还可以定位到具体的行和列信息,因为在这种模式下,相比于eval他生成了source-map。

  1. cheap-eval-source-map

这个模式的名字我们差不多就可以推断出一些信息,他其实就是在刚刚eval-source-map的基础之上加了一个cheap,也就是廉价的,那用计算机行业经常说的一个词就是阉割版的source-map。

为什么这么说呢,因为他虽然也生成了source-map,但这种模式下的source-map他只能帮我们定位到行,而没有列的信息。那也就是少了一点效果。少了一点效果所以生成速度自然也就会快很多。

  1. cheap-module-eval-source-map

根据这个名字我们慢慢就发现了,webpack这些模式的名字好像不是乱起的。他好像有某种规律,这里其实就是刚刚的模式基础之上多了一个module,在这种模式下的特点可能乍一看不会那么明显。因为他也就只能定位到行。

那我们把刚刚cheap-eval-source-map的模式仔细进行一个对比。通过对比会发现,cheap-module-eval-source-map定位源代码跟我们编写的源代码是一模一样的。而cheap-eval-source-map他显示的是我们经过babel转换过后的结果。

那这样的话这两者之间的差异也就出来了。这也就是为什么之前配置的时候会给js文件单独配一个loader的原因。因为带有module这种模式下他解析出来的源代码是没有经过loader加工的我们真正手写的那些源代码。而不带module是加工过后的一个结果。

如果我们想要和我们真正手写代码一样的源代码的话,那我们就需要选择cheap-module-eval-source-map这种模式。

了解了以上这些模式过后,基本上就算是通盘了解了所有的模式,因为其他的模式无外乎就是把这几个特点再次排列组合罢了。例如下面的。cheap-source-map

  1. cheap-source-map

没有eval,也就爱意味着没有用eval的方式去执行模块代码,没有module的话也就意味着他反过来的源代码是loader处理过后的代码。

另外还有几个模式,首先第一个看下inline-source-map

  1. inline-source-map

那他跟普通的source-map其实效果上是一样的,只不过source-map的模式下,他的source-map文件是以物理文件的方式存在,而我们inline-source-map的话他使用的是dataurl的方式去将我们的source-map以dataurl嵌入到我们的代码当中。

那我们之前遇到的eval-source-map他其实也是使用这种行内的方式把我们的source-map嵌入进来
,那这种方式实际上,个人觉得是最不可能用到的,因为他把source-map放到源代码当中过后那这时候就导致这个代码的体积会变大很多。

  1. hidden-source-map

那除此之外还有一个叫做hidden-source-map的模式,那这个模式下我们在开发工具中是看不到source-map的效果的。但是我们回到开发工具当中去找一下这个文件我们会发现,他确实是生成了source-map文件。

那这就跟jq是一样的,在构建过程当中,生成了source-map文件,但是他在代码当中并没有通过注释的方式去引入这个文件。所以说我们在开发工具中看不到效果。

那这个模式实际上是我们在开发一些第三方包的时候会比较有用,我们需要去生成source-map但是呢我们不想在我们的代码当中直接去引用他们,那一旦在使用我们这个包的开发者出现了问题,他可以再把这个source-map再引入回来,或者通过其他的方式去使用source-map。

source-map还有很多其他的使用方式,通过http的响应头也可以去使用,那这些我们就不在这里扩展了。

  1. nosources-source-map

然后回过来我们再看,最后还有一个叫做nosources-source-map的source-map模式,那这个模式下,我们能看到错误出现的位置,但是我们点击这个错误信息,我们点进去过后是看不到源代码的。

那这个nosource指的就是没有源代码,但是呢他同样给我们提供了行列信息,那这样的话对于我们来讲的话,我们还是结合我们自己编写的源代码还是可以找到错误出现的位置。只是我们在开发工具当中看不到源代码而已。

这是为了在生产环境当中去保护我们的源代码不会被暴露的这样一种情况,那这是nosource-source-map这个模式。

那以上我们介绍了很多种的source-map也做了一些具体的对比,通过这些对比,大家要能总结出来这个source-map里面这几个核心关键词他们的一些特点,然后对于其他几个模式我们没有介绍到的,你就很容易能知道他们一些特点了。

那可能了解很多这些模式过后呢对大家来讲的话最痛苦的一件事情就是选择一个合适的source-map模式,那这个我们再接着来看。

Webpack 选择 Source Map 模式

虽然webpack支持各种各样的SourceMap模式,但是其实我们掌握他们的特点过后呢我们发现一般我们再应用开发时也就只会用到其中的几种。根本就没有选择上需要纠结的点。

那这里呢我来介绍一下我个人在开发时的一些选择,那首先先说开发过程中。也就是开发环境下。

在开发环境下我会选择cheap-module-eval-source-map那具体的原因有三点。

第一就是我编写代码的风格一般会要求每一行代码不会超过80个字符,那对我而言的话能够帮我定位到行也就够了。

因为每一行里面最多也就80个字符,我能够很容易找到对应的位置。

然后第二就是我使用框架的情况会比较多,那我们以react和vue来说,无论是我们使用jsx还是vue的单文件组件,那loader转换过后的代码和我们转换之前都会有很大的差别,那我这里需要去调试我转换
之前的源代码,而不是转换过后的,所以我们要选择有module的这种方式。

那第三点就是虽然这种cheap-module-eval-source-map他的启动就是打包启动速度会比较慢一些,但是呢大多说时间我都是在使用webpack-dev-server以监视模式去重新打包,而不是每次都启动打包。

所以说这种模式下他重新打包速度比较快,那这也是我选择他的一个原因。

那综上所述在开发环境我选择就是cheap-module-eval-source-map

至于发布前的打包,也就是我们针对于生产环境的打包,那我会选择none,也就是不生成source-map。

那原因也很简单,因为source-map会暴露我源代码到生产环境,那这样的话但凡是有一点技术的人都可以很容易去复原我项目当中绝大多数的源代码,而这一点其实被很多开发者可能都忽略掉了。他们只是认为source-map能够带来便利,但是呢这个便利的同时也会有一些隐患,所以这个点是要注意的。

那其次我个人认为调试和报错,找错误这些都应该是开发阶段的事情,你应该在开发阶段就尽可能把所有的问题和隐患都找出来,而不是到了生产环境让全民去帮你公测。所以说这种情况我就尽量避免在生产环境去使用source-map。

那如果说你对你的代码实在是没有信心的话,那我建议你选择nosources-source-map模式,那这样的话出现错误的话在控制台当中就可以找到源代码对应的位置,但是不至于向外暴露你的源代码内容。

当然了,这个过程中我的这些选择实际上也没有绝对,那我们去理解这些模式之间差异的目的,就是为了可以让我们在不同环境当中,然后快速去选择一个合适的模式,而不是去寻求一个通用的法则。

在开发行业,没有绝对的通用法则。

## Webpack 自动刷新的问题

在此之前呢我们已经简单了解了webpack-dev-server的一些基本用法和特性,那他主要就是为我们使用webpack构建的项目提供了一个比较友好的开发环境,和一个可以用来调试的开发服务器。那使用webpack-dev-server就可以让我们的开发过程更加专注于编码。

因为他可以监视到我们代码变化,然后自动进行打包,最后再通过自动刷新的方式,同步到浏览器,以便于我们可以及时的预览。

但是当你实际去使用这样的特性去弯沉具体的开发任务时那你会发现,这里还是会有一些不舒服的地方。

那例如这里是一个编辑器的应用,我想要在这里能够及时去调试编辑器中文本内容的样式,正常的操作肯定是先尝试在编辑器中添加一些文本作为展示样例。

然后我们回到开发工具当中找到控制这个编辑器样式的css文件。在这个编辑器样式文件中我们简单添加一些样式。那这个时候我们就能发现问题了,当我们修改完样式过后呢,原本想着可以及时看到最新的界面效果,但是这个时候我们编辑器中给的内容确没有了,那这里我们不得不再来编辑器中再去添加一些文本,如果说此时你对我们的样式还是不满意的话,那我们还需要继续来去调整样式,而且调整完了过后又会面临刚刚文本内容丢失这样一个问题。

那久而久之的话你就会发现,自动刷新这样一个功能还是很鸡肋,他并没有我们想象中那么好用。那这是因为我们每次修改完代码,webpack监视到文件变化就会自动打包,然后自动刷新到浏览器。

那一旦页面整体刷新,那页面中之前的任何操作状态都会丢失,所以说就会出现,刚刚我们所看到的这样一个情况。但是呢,聪明的人一般都会有一些小办法,例如我们可以在代码当中先去写死一个文本到我们的编辑器当中。那这样的话,即便是我们页面刷新也不会有丢失的这种情况出现。

那又或是我们通过一些额外的代码,把我们的内容先保存到临时存储中,然后刷新过后我们再去取回来。总之就是你有问题,我有办法。

那确实这些都是好办法,但是又都不是特别的好,因为这些都是典型的有洞补洞的操作,并不能根治我们页面刷新过后导致的页面数据丢失的这样一个问题。

而且这些方法都需要我们去编写一些跟我们业务本身无关的一些代码,那更好的办法自然是能够在页面不刷新的这种情况下我们代码也可以及时的更新进去。

那针对于这样的需求,webpack同样也可以满足,那接下来我们就一起去了解一下webpack当中如果去在页面不刷新的情况下及时的去更新我们的代码模块。

Webpack HMR 体验

HRM全称是Hot Module Replacement那翻译过来叫做,模块热替换或者叫做模块热更新。

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

那模块热替换的这个热,和我们刚刚提到的热拔插实际上是一个道理,他们都是在运行过程中的即时变化,那webpack中的模块热替换指的就是可以在应用程序运行的过程中实时的去替换掉我们应用中的某个模块。而我们应用的运行状态不会因此而改变。

例如我们在应用程序的运行过程中,我们修改了某个模块,那通过自动刷新就会导致我们应用整体的刷新,那应用中的状态信息呢,都会丢失掉。

而如果我们这个地方使用的是热替换的话,我们就可以实现,只将刚刚修改的这个模块,实时的去替换到应用当中,不必去完全刷新应用。

那这里我们可以先来对比一下使用热更新和使用自动刷新这两种方式的体验差异。

那屏幕显示的这个项目我们已经开启了HMR这个特性,那这里我们同样先在我们的页面当中随意去添加一些内容,也就是为我们的页面去制造一些运行的状态。

然后我们回到开发工具当中,这里我们尝试去修改我们文本的样式,我们先将它的颜色修改为红色,那保存过后呢,我们就可以立即看到新的样式结果。而我们的页面并没有整体的刷新。

那这种体验是非常友好的。对于项目中其他代码文件的修改,也可以有相同的热替换的这样一种体验。那我们这里再来尝试修改一下js文件。我们随意去修改一行js代码,然后保存。那此时呢我们浏览器当中,也没有刷新页面,而是直接执行了刚刚我们修改的这个模块。

那不仅如此,对于项目当中那些非文本文件,同样也可以使用热更新,那例如我们所显示的背景图片,我们通过简单的画图板来去修改一下这个图片。保存过后呢,我们浏览器当中同样也可以及时更新过来我们最新的这张图片,而我们整个应用的运行状态呢,也没有因此而发生变化。

那这就是HMR的作用和他的一个体验。

HMR可以算是webpack中最强大的特性之一,同时他也是最受欢迎的特性。因为他确实极大程度的提高了开发者的工作效率,所以说我们接下来要重点来去看,如何去使用HMR

Webpack 开启 HMR

热更新这么强大的功能而言,他的使用并不算特别的复杂,接下来我们就一起来了解下具体如何去使用HMR。

HMR已经集成在了webpack-dev-server工具当中,所以说我们就不需要再去单独安装什么模块了。

那使用这个特性呢,我们需要去运行webpack-dev-server这个命令时通过--hot这样一个参数去开启这样一个特性,或者也可以在配置文件当中去添加对应的配置来去开启这样一个功能。

我们打开配置文件。这里我们需要配置的地方有两个,第一个我们要将dev-server中的hot属性设置为true。

然后我们需要载入一个插件,那这个插件是webpack内置的一个插件,所以我们这里先去导入webpack模块,那有了这个模块过后呢,我们这里使用的是这个模块当中的hot-module-replacement-plugin这样一个插件。

我们把这个插件配置进来。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: 'development'
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    devtool: 'source-map',
    devServer: {
        hot: true
    }
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            template: './src/index.html'
        }),
        new webpack.HotModuleReplacementPlugin()
    ]
}

配置完成过后我们打开命令行终端,因为我们已经开启了HMR,所以我们这里直接去运行webpack-dev-server 然后去启动weboack开发的服务器。

yarn webpack-dev-server

接下来我们就可以在浏览器去体验HMR带来的优势了,我们回到开发工具当中,这里我们先尝试修改一下样式文件,那保存过后这个样式模块就可以以热更新的方式直接去作用到我们的页面当中了。

然后呢我们再来尝试修改一下js模块文件,我们在js中随便console.log一句话,此时我们就会发现,这里的页面却自动刷新了。并没有刚刚我们所看到的那种热更新的体验。

我们再在页面的编辑器当中去添加一些文字,这样的话我们可以更容易去确认是否有刷新,完成过后我们再回到开发工具当中。修改文件,我们发现页面真的刷新了。

我们可以发现样式文件有变动可以实现热更新,但是js文件的修改就失去了作用,那具体我们该如何去实现所有模块资源的热替换,我们接着往下看。

Webpack HMR 的疑问

我们发现了模块热替换确实提供了非常友好的开发体验,但是当我们自己尝试开启HMR过后呢,我们发现效果确不尽如人意,那这里是因为HMR他并不像webpack其他的特性一样可以开箱即用。

也就是说我们的HMR还需要我们做一些额外的操作才能够可以正常工作,那webpack中的HMR需要我们手动通过代码去处理当模块更新过后我们需要如何把更新过后的模块去替换到我们运行的页面当中。

那可能会有人问,为什么当我开启了HMR过后我们的样式文件就可以直接去热更新,我们好像也没有手动的去处理样式模块的更新。

这是因为样式文件是经过loader处理的,在style-loader里面就已经自动处理了样式文件的热更新,所以说我们就不需要我们自己去额外做手动的操作。

可能你会想,凭什么样式文件就可以自动处理,而我的脚本文件就需要我们自己手动处理呢,这个原因也很简单,因为样式模块更新过后呢,他只需要把更新过后的css及时的替换到页面当中,他就可以覆盖掉之前的样式,从而实现样式文件的更新。

而我们所编写的javaSciript的模块他是没有任何规律的,因为你可能在一个模块中导出的是一个对象,也有可能是一个字符串,还有可能导出的是一个函数,那我们对导出的这个成员我们的使用也是各不相同的。

所以说webpack面对这些毫无规律的js模块,他就根本不知道,如何去处理更新过后的模块,那也就没有办法帮你实现一个通用所有模块的替换方案。

这就是为什么样式文件可以直接热更新,而js文件更新过后我们页面还是自动刷新的原因。

那可能有一些使用过vue-cli或者是create-react-app的一些脚手架工具的人来说,他会觉得,我的项目当中并没有手动的去处理我们js模块的更新,我的代码照样可以做热替换,没有我们刚刚说的这么麻烦。

那这是因为你使用的是框架,那我使用框架开发时,我们项目中每个文件他自然就有了规律,因为框架他提供的就是一些规则,例如我们再react模块中要求每一个模块必须要去导出一个函数,或者是导出一个类。

那有了这样一个规律那就可能会有通用的替换办法,例如每一个文件导出的都是一个函数的话那他就自动的把这个函数再拿回来再去执行一下。

而且这些工具内部都已经帮你提供好了这种通用的HMR替换模块,所以说我们就不需要自己手动处理了,如果你之前没有接触过这样的工具,也无所谓,你可以忽略掉这一条。这并不影响我们后面的理解。

综上所述,我们还需要自己手动处理当JS模块更新过后我们需要去做的事情。

Webpack 使用 HMR API

Hot-module-replacement-plugin为我们js提供了一套用于去处理HMR的api,我们需要在自己的代码中去使用这套api来去处理当某一个模块更新过后应该如何替换掉当前正在运行的页面当中。

接下来我们一起回到代码当中,尝试通过HMR的api来去手动处理模块更新过后的热替换,我们打开js。

这是我们打包的入口文件。也就是在这个文件当中才开始去加载其他的模块,那就是因为这个模块当中使用了这些导入的模块,那一但当这些模块更新了过后,我们就必须要去重新使用这些模块。

所以说我们要在这个模块中去处理他所依赖的这些模块更新过后的热替换。

在这套API当中,他为我们的module对象提供了一个hot属性,那这个属性也是一个对象,他就是我们HMR API的核心对象,那他提供了一个accept方法,用于去注册,当我们某一个模块更新过后的处理函数。

那这个方法的第一个参数接收的就是我们依赖模块的路径,第二个参数就是依赖路径更新过后的处理函数。

我们这里先来尝试注册一下当我们的editor模块更新过后的处理函数。那这里第一个参数就是editor模块的路径,然后第二个参数呢,我们传入一个函数。然后在这个函数中我们去打印一个消息,表示一个editor模块更新了。

import createEditor from './editor';
import background from './better.png';
import './global.css';

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

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

module.hot.accept('./editor', () => {
    console.log('editor 模块更新了,需要这里手动处理');
});

完成过后我们打开命令行启动webpak-dev-server回到浏览器打开开发人员工具。

那这个时候我们就可以尝试回到开发工具中去修改editor这个模块中的代码。那此时浏览器的控制台中就会打印我们刚刚所打印的那个消息,而且也就不会再去触发自动刷新了。

那也就是说一旦这个模块的更新被我们这样手动的处理了,那他就不会去触发自动刷新,反之如果我们没有手动处理这个模块的热替换。那HMR就会自动forback到自动刷新,从而导致我们页面刷新。

Webpack 处理 JS 模块热替换

那了解了这个API的作用过后我们就需要去考虑,具体该怎样实现editor模块这个热替换的逻辑,那这个模块导出的是一个函数,我们这里先直接打印到控制台当中,然后在模块更新过后我们再去打印一次。

import createEditor from './editor';
import background from './better.png';
import './global.css';

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

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

console.log(createEditor);
module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理');
    console.log(createEditor);
});

那这个时候我们回到我们的editor.js当中,我们尝试去修改这个模块,然后保存,此时你会发现,当我们的模块更新过后我们这里拿到的函数也就更新为了最新的结果。

那知道这样一个特点就好办了,因为我们这里是使用了这个函数去创建一个界面的元素,那一但当这个函数更新了,我们界面上这个元素也应该被重新创建。所以说我们这里先直接去移除原来的元素,然后我们再去调用createEditor这个函数去创建一个新的元素,然后追加到页面中。

那这样的话就相当我们的界面重新工作了。

并且我们这里还需要去记录下来我们新创建的这个元素,把他放在变量当中,否则的话我们下一次热替换的时候就找不到这一次所创建的这个元素了。

import createEditor from './editor';
import background from './better.png';
import './global.css';

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

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

let lastEditor = editor;
module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理');
    // console.log(createEditor);
    document.body.removeChild(lastEditor);
    const newEditor = createEditor();
    document.body.appendChild(newEditor);
    lastEditor = newEditor;
});

完成以后我们再来尝试修改editor模块。保存过后我们回到浏览器当中来查看一下这里呢,界面当中的元素确实立即更新为了我们最新的结果。

我们再尝试在界面上输入一些内容,再修改editor模块,保存,此时你就会发现和我们之前同样的问题。

那由于热替换时我们把界面上之前的编辑器元素已经移除掉了,那我们之前所输入的状态自然也就丢失掉了,然后替换为了一个新的元素,所以说我们界面上的这些状态都会丢失。

这也就证明我们的热替换操作还需要去改进,那我们必须在要替换原来的元素之前先把他的状态保留下来。

那想要保留我们编辑器中的状态也非常简单,我们就是把编辑器当中之前的内容给他存下来,然后在替换过后我们再把它放回去就好了。

因为这里我们用的是一个可编辑元素,并不是一个文本框,所以我需要通过innerHTML拿到我们之前所添加的内容,然后我们在创建新元素过后再把它设置到新元素当中,那这样的话就可以解决我们这个文本框状态的丢失问题。

let lastEditor = editor;
module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理');
    // console.log(createEditor);
    const value = lastEditor.innerHTML;
    document.body.removeChild(lastEditor);
    const newEditor = createEditor();
    newEditor.innerHTML = value;
    document.body.appendChild(newEditor);
    lastEditor = newEditor;
});

我们再次回到浏览器输入一些内容,再来修改editor模块,此时这个模块就可以以热替换的方式工作,而不用担心我们状态丢失问题,因为我们模块重新工作之后我们已经把上一次的状态记录下来。

那这就是我们针对于js模块热替换的一个处理过程,注意这不是一个通用的方式,这只适用于我们当前的editor.js模块。

那通过这样一个过程我们就应该能够发现为什么webpack的HMR需要我们自己去处理js模块的热更新,因为不同的模块有不同的逻辑,不同的业务逻辑又导致我们在这他的处理过程肯定也是不同的。

那我们这里是一个文本编辑器,所以我们需要去保留状态,那如果说这里不是这种类型的,那就不需要这么做。

所以说webpack根本没有办法去提供一个通用的替换方案。

Webpack 处理图片模块热替换

图片模块的热替换逻辑就会简单的多,我们快速来看一下。

我们同样通过module.hot.accept这个方法去注册一下这个图片模块的热替换处理函数,那在这个函数当中我们只需要将图片元素的src设置为新的图片路径就可以了。

因为在图片修改过后我们的图片文件名呢是会发生变化的,而我们这里拿到的就是更新之后的文件名。所以说我们直接重新设置图片元素的src就可以实现图片的热替换。

那以上就是我们针对两种不同类型资源的热替换的处理过程,可能你会觉得比较麻烦因为我们需要去写一些额外的代码,甚至有人觉得我们不如不用,那我个人的想法是利大于弊。

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

那当然如果说你使用的是框架去开发的话,那使用HMR将十分简单,因为大部分框架当中都有成熟的HMR方案,那你只需要去使用就可以了,但是我们这里使用的是纯原声的方式去做的开发。所以说HMR使用起来相对会麻烦一点,那这也正是为什么大部分人都喜欢选择集成式框架的原因,因为足够简单。

Webpack HMR 注意事项

刚开始去使用HMR肯定会遇到一些问题,下面我们来看一下最有可能发生的问题和大家容易产生疑惑的地方。

首先大家容易出问题的地方就是,如果说我们处理热替换的代码有错误,那就不容易发现,结果会导致页面自动刷新,而自动刷新过后,页面中的错误信息已经被清除了,这样一来我们就不容易发现到底是哪里出错了。

这种情况推荐大家使用hot only的方式来去解决,因为我们默认使用的hot方式如果说热替换失败,那他就自动会回退去使用自动刷新这样一个功能,而hot only他就不会去使用自动刷新。

配置文件当中,这里我们将dev-server当中的hot: true修改为hotOnly: true。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: 'development'
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    devtool: 'source-map',
    devServer: {
        hotOnly: true
    }
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            template: './src/index.html'
        }),
        new webpack.HotModuleReplacementPlugin()
    ]
}

重启服务,此时再去修改代码,无论代码是否被处理了模块的热替换浏览器都不会去自动刷新了。

那这样的话我们这些错误信息就可以很容易的看到了。

第二个问题就是如果我们在代码中使用了HMR提供的API,但是我们在启动dev-server的时候没有开启HMR的选项,那此时我们再运行环境中就会报出一个accept undefined的错误。

原因是因为module.hot对象是HMR插件所提供的,我们没有开启这个插件,所以也就没有这个对象。

解决的办法也非常简单,就跟我们在业务代码中去判断API兼容一样,我们应该先去判断是否存在module.hot这个对象然后再去使用它。这样的话就可以解决我们这样的一个问题。

可能还会有一个疑问,那就是我们再我们的代码当中写了很多与业务功能本身无关的代码,那这会不会有影响。

那这个答案也很简单,我们通过一个简单的尝试来验证下,我们回到配置文件当中,那这里我们确保我们已经将热替换功能关闭了。并且我们已经移除了热替换的插件(plugin)。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    mode: 'development'
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        // publicPath: 'dist/'
    },
    devtool: 'source-map',
    devServer: {
        // hotOnly: true
    }
    module: {
        rules: [
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Webpack Plugin Sample',
            template: './src/index.html'
        }),
        // new webpack.HotModuleReplacementPlugin()
    ]
}

然后我们打开命令行终端,运行一下webpack打包。

yarn webpack

打包完成过后我们找到生成的bundle.js文件,然后我们找到对应的那个模块,找到过后你就会发现其实我们代码当中编辑的那些处理热替换的代码都已经被移除掉了,只剩下一个if(false) {} 的空判断。

这种没有意义的判断在我们代码压缩过后也会自动去掉,所以说他根本不会影响我们生产环境当中的运行状态。

至此我们对HMR的一个使用基本上就完全了解了。

Webpack 生产环境优化

前面所了解到的一些用法和特性都是为了可以让我们在开发阶段拥有更好的开发体验,而这些体验提高的同事我们的打包结果也会随之变得越来越臃肿。

那这是因为在这个过程中webpack为了实现这些特性他会自动往打包结果中添加一些额外的内容,例如我们之前所使用到的source-map和HMR, 他们都会往输出结果中添加额外的代码来去实现各自的功能。

但是这些额外的代码对于生产环境来讲是冗余的,因为生产环境和开发环境是有很大的差异。

在生产环境中我们强调的是以更少量,更高效的代码去完成业务功能。也就是我们会更注重运行效率,而在开发环境中我们会只注重开发效率。

那针对于这个问题,webpack4+当中就推出了mode用法,那他为我们提供了不同模式下的一些预设的配置,那其中生产模式中就已经包括了很多我们在生产环境中所需要的优化配置。

那同时webpack也建议我们为不同的工作环境去创建不同的配置,以便于让我们的打包结果可以适用于不同的环境。

那接下来我们一起来探索一下生产环境中有哪些值得我们优化的地方,以及一些注意事项。

Webpack 不同环境下的配置

下面我们先来尝试为不同的工作环境去创建不同的webpack配置,那创建不同的环境配置的方式主要有两种。

第一种就是在我们的配置文件当中去添加相应的判断条件,然后根据环境的判断条件不同导出不同的配置。

第二种就是为我们不同的对应一个配置文件。那这种就确保我们每一个环境下面都会有一个对应的环境配置文件。

那我们分别来尝试下这两种方式下如何为我们开发环境和生产环境去创建不同的配置。

  1. 首先来看我们在配置文件当中来添加判断的这种方式。

我们回到配置文件当中,webpack的配置文件还支持导出一个函数,然后在这个函数当中去返回我们所需要的配置对象。

那这个函数可以接受到两个参数,第一个是env也就是我们通过cli传递的环境名参数

第二个是argv,那这个是指我们运行cli过程中所传递的所有参数,那我们就可以借助这样一个特点来去实现为我们的开发环境和生产环境去分别返回不同的配置。

我们先将这里的开发模式配置定义在config这样一个变量当中。

然后我们再去判断一下env是不是等于production,这里我们约定的生产环境的env就是production。

如果说是生产环境的话我们这里就将mode属性的字段设置为production,然后我们再将devtool设置为false,也就是禁用掉source-map,最后我们再来添加cleanWebpackPlugin和CopyWebpackPlugin这两个插件。

那这两个插件我们之前介绍的时候也说到了他实际上在开发阶段可以省略的插件,他是在上线打包之前才有他实际的价值。

这里我们使用的是ES6扩展运算符的方式把这两个插件和之前所有的插件放在一起去创建一个新的数组。

const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = (env, argv) => {
    const config = {
        entry: './src/main.js',
        output: {
            filename: 'bundle.js',
            path: path.join(__dirname, 'dist'),
            publicPath: 'dist/'
        },
        devtool: 'cheap-eval-module-source-map',
        devServer: {
            hot: true,
            contentBase: 'public'
        },
        module: {
            rules: [
                {
                    test: /.js$/,
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: ['@babel/preset-env']
                        }
                    }
                }
                {
                    test: /.css$/,
                    use: [
                        'style-loader',
                        'css-loader'
                    ]
                },
                {
                    test: /.png$/,
                    use: 'url-loader'
                },
                {
                    test: /.html$/,
                    use: {
                        loader: 'html-loader',
                        options: {
                            attrs: ['img:src', 'a:href']
                        }
                    }
                }
            ]
        },
        plugins: [
            new HtmlWebpackPlugin({
                title: 'webpack',
                template: './src/index.html'
            }),
            new webpack.HotModuleReplacementPlugin(),
        ]
    }
    if (env === 'production') {
        config.mode = 'production';
        config.devtool = false;
        config.plugins = [
            ...config.plugins,
            new CleanWebpackPlugin(),
            new CopyWebpackPlugin(['public'])
        ]
    }
    return config;
}

完成以后我们打开命令行终端,我们先尝试直接去运行webpack

yarn webpack

此时我们并没有传递任何参数,这里我们的webpack就会以开发模式运行打包,打包完成过后我们可以展开dist目录,此时目录中并不会有public目录copy过来的文件。

然后我们再回到命令行,我们这里运行一下webpack --env production。那这个时候就相当于给webpack传递了一个env参数, 这个参数的值是production。

yarn webpack --env producton

那我们的配置文件接收到这样一个参数他就会返回生产模式下的配置,那也就意味着此时我们webpack会以生产模式运行打包。

那我们这些额外的插件也就会工作起来,这里我们就能看到public下的文件已经被copy到dist目录了。

那这就是我们通过在导出函数中对环境进行判断从而实现为不同的环境导出不同的配置,当然你也可以在全局去判断环境变量直接导出不同的配置,这样也是可以的。

Webpack 不同环境的配置文件

通过判断环境名参数去返回不同的配置对象这种方式只适用于中小型项目,因为一旦项目变得复杂那我们的配置文件也会一起变得复杂起来。

所以说对于大型的项目我们还是建议大家使用不同环境去对应不同配置文件的方式来实现,一般在这种方式下面我们项目当中至少会有三个webpack配置文件。

其中两个是用来适配不同的环境的,另外一个是一个公共的配置,因为我们的开发环境和生产环境并不是所有的配置都完全不同,说一说我们需要一个公共的文件来去抽象两者之间相同的配置。

我们具体来看,首先我们在项目的跟目录下去新建一个webpack.common.js, 那在这个文件当中我们把刚刚复制的公共配置粘贴进来。

module.exports = {
    entry: './src/main.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'dist'),
        publicPath: 'dist/'
    },
    devtool: 'cheap-eval-module-source-map',
    devServer: {
        hot: true,
        contentBase: 'public'
    },
    module: {
        rules: [
            {
                test: /.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
            {
                test: /.css$/,
                use: [
                    'style-loader',
                    'css-loader'
                ]
            },
            {
                test: /.png$/,
                use: 'url-loader'
            },
            {
                test: /.html$/,
                use: {
                    loader: 'html-loader',
                    options: {
                        attrs: ['img:src', 'a:href']
                    }
                }
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'webpack',
            template: './src/index.html'
        }),
        new webpack.HotModuleReplacementPlugin(),
    ]
}

然后我们再去新建一个webpack.dev.js和一个webpack.prod.js分别去用来为我们的开发和生产环境去定义特殊的配置。

在生产环境的配置当中(webpack.prod.js)我们先去导入公共的配置对象,这里我们可以使用Object.assign方法把我们公共配置对象复制到我们这里的配置对象当中,并且我们可以通过最后一个对象去覆盖掉这个公共配置当中的一些配置。

但是熟悉Object.assign这个方法的人都应该知道,这个方法是全完覆盖掉前一个对象当中的同名属性,那这样一个特点对应我们普通的值类型属性覆盖都没有什么问题,但是像我们配置当中的plugins这种数组,那我们是希望是可以在公共配置的原有基础之上我们去添加一两个插件。

const common = require('./webpack.common');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = Object.assign({}, common, {
    mode: 'production',
    plugins: [
        new CleanWebpackPlugin(),
        new CopyWebpackPlugin()
    ]
})

而Object.assgin这个方法呢,他会导致我们这里的特殊配置会覆盖掉公共配置,所以说Object.assign是不合适的,那这里我们就需要一个更合适的方法去合并这里的配置和公共的配置。

你可以使用loadash所提供的merge方法来去实现,不过社区当中提供了更为专业的webpack-merge这样一个模块。

那这个模块呢他可以专门用来满足这里合并webpack配置的这样一个需求。我们需要安装这样一个模块。

yarn webpack-merge --dev

那安装完成过后我们回到配置文件当中,我们先去载入这样一个模块,这个模块导出的就是一个merge函数。

我们这里使用这个函数来去合并我们这里的配置和公共的配置,使用webpack-merge这个模块过后呢我们这里所配置的这个对象他就可以跟普通的webpack配置一样,需要什么就配置什么。

merge函数的内部会自动去处理合并的逻辑。

const common = require('./webpack.common');
const merge = require('webpack-merge');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = merge(common, {
    mode: 'production',
    plugins: [
        new CleanWebpackPlugin(),
        new CopyWebpackPlugin()
    ]
})

同理webpack.dev.js这样一个js文件当中也可以通过这样一个方式来去实现一些额外的配置,这里我们就不重复尝试了。

完成过后我们再次回到命令行终端然后尝试运行webpack打包。

不过这里因为我们已经没有了默认的配置文件,所以这里我们运行webpack时需要通过--config这样一个参数来去指定我们所使用的配置文件也就是我们刚刚的webpack.prod.js。

yarn webpack --config webpack.prod.js

那此时我们就可以以生产环境这种模式的配置去打包我们的应用了。

那当然如果你觉得这样去使用的话我们的命令变得复杂了,那你同样可以把这个构建的命令定义到package.json的script当中,方便我们的使用。

Webpack DefinePlugin

在webpack4x中新增的production模式下面内部就自动开启了很多通用的优化功能。

对于使用者而言,这种开箱即用的体验是非常方便的,但是对于学习者而言这种开箱即用他会导致我们忽略掉很多需要了解的东西。以致于我们出现问题之后无从下手。

如果说我们需要深入了解webpack的使用那我建议你可以去单独研究一下每一个配置背后的作用,那我们这里先一起来学习一下其中几个主要的优化配置。顺便去了解一下webpack是如何优化我们的打包结果的。

首先第一个是一个插件叫做define-plugin, 那define-plugin是用来为我们的代码去注入全局成员的。

在production模式下,默认这个插件就会启用起来并且往我们的代码当中注入了一个process.env.NODE_ENV这样一个常量。

很多第三方的模块都是通过这个成员去判断当前的运行环境,从而去决定是否去执行例如打印日志这样一些操作。

那这里我们先来单独使用一下这个插件。我们回到配置文件当中,那define-plugin是一个内置的插件所以说我们先要导入webpack模块。

然后我们再到plugins这个数组当中去添加一下这个插件,那这个插件他的构造函数接收的是一个对象,这个对象中每一个键值都会被注入到我们的代码当中。

例如我们这里在这个对象当中去定义一个API_BASE_URL的一个值,用来为我们的代码去注入我们的api服务地址,那他的值是一个字符串我们这里使用https://api.github.com。

const webpack = require('webpack');

module.exports = {
    mode: 'node',
    entry: './src/main.js',
    output: {
        filename: 'bundle.js'
    },
    plugins: [
        new webpack.DefinePlugin({
            API_BASE_URL: 'https://api.github.com'
        })
    ]
}

然后我们回到我们的代码当中, 简单的来吧这个API_BASE_URL打印出来,完成以后我们打开命令行终端。然后运行webpack打包,找到打包结果,找到刚刚打印的位置。

这里我们发现,define-plugin其实就是把我们注入成员的值直接替换到了代码当中,而我们刚刚设置的值呢,内容就是https://api.github.com,字符串中并没有包含引号,所以说我们这里替换进来是没有引号的。

其实define-plugin的设计并不是只是用来帮我们替换一个数据进来,我们这所传递的字符串内容他要求的实际上是一个代码片段,也就是一段符合js语法的代码,所以说我们这样去传的话是不对的。

那正确的做法是传入一个字符串,这个字符串的内容呢就是一个我们js代码中的字符串字面量语句。

当然了,如果说你需要注入其他的代码也是可以的。

const webpack = require('webpack');

module.exports = {
    mode: 'node',
    entry: './src/main.js',
    output: {
        filename: 'bundle.js'
    },
    plugins: [
        new webpack.DefinePlugin({
            API_BASE_URL: '"https://api.github.com"'
        })
    ]
}

完成以后我们再来查看打包结果,就会发现变成我们想要的样子了。另外呢这里还有一个非常常用的小技巧。

就是如果说我们需要注入的是一个值的话,那我们可以先通过JSON.stringfiy的方式来去将这个值去转换成一个表示这个值的代码片段,那这样的话就不会错了。

const webpack = require('webpack');

module.exports = {
    mode: 'node',
    entry: './src/main.js',
    output: {
        filename: 'bundle.js'
    },
    plugins: [
        new webpack.DefinePlugin({
            API_BASE_URL: JSON.stringify('https://api.github.com')
        })
    ]
}

那这个插件的作用非常简单, 但是他确非常有用,那我们可以用它为我们的代码去注入一些可能会发生变化的值,例如我们刚刚使用的河中API的根路径,那我们的开发环境和生产环境他们的路径肯定是不一样的,我们就可以借助于define-plugin去注入我们想要的api路径。

Webpack 体验 Tree Shaking

Tree-shaking的字面意思就是摇树。

一般伴随着摇树这样一个动作我们树上的这些枯树枝和树叶就会掉落下来。

那我们这里要说的Tree-shaking也是相同的道理,不过我们这里摇掉的使我们代码当中那些没有用到的部分,那这部分代码更专业的说叫未引用代码(dead-code)。

webpack生产模式优化中就有这么一个非常有用的功能,那他可以自动检测出我们代码中那些未引用的代码,然后移除掉他们,那我们这里先来体验一下这样一个功能的效果。

我们这里的代码也非常简单只有两个文件,其中components.js文件中导出了一些函数, 然后每一个函数分别模拟了一个组件。

其中button组件函数中,在return过后还执行了一个console.log语句,很明显这就属于未引用代码。

export const Button = () => {
    return document.createElement('button')
    console.log('dead-code');
}

export const Link = () => {
    return document.createElement('a')
}

export const Heading = level => {
    return document.createElement('h' + level)
}

除此之外还有一个index.js文件,那index.js文件当中就是导入了components,但是需要注意的是我们这只是导入了components当中的button这样一个成员。

这也就会导致我们代码当中,特别是components里面会有很多的地方都用不到,那这些用不到的地方对于我们打包过后的结果就是冗余的。

取出冗余代码是我们生产环境优化当中非常重要的一个工作,而webpack的Tree-shaking就很好的实现了这样一点。

import { Button } from './components'

document.body.appendChild(Button())

我们尝试以production的模式去运行打包

yarn webpack --mode production

打包完成过后我们打开bundle.js我们可以发现这些冗余的代码根本就没有输出,那这就是tree-shaking这样一个特性,工作过后的一个效果。

那tree-shaking这个功能会在生产模式下自动取开启。

Webpack 使用 Tree Shaking

需要注意的是tree-shaking并不是webpack中的某一个配置选项,他是一组功能搭配使用过后的效果。

那这组功能会在生产模式下自动开启,但是由于目前官方文档当中对于tree-shaking的介绍有点混乱,所以我们这里再来介绍一下它在其他模式下如何一步一步手动的去开启。

顺便通过这样一个过程我们去了解tree-shaking的工作过程,以及一些其他的优化功能,这里我们还是以刚刚相同的一个项目。

我们再次回到命令行终端,运行webpack打包,不过这一次我们不再使用production模式而是使用none,也就是我们不开启任何内置功能和插件。

yarn webpack

打包完成过后我们来找到输出的bundle.js文件,这里的打包结果和我们之前所看到是一样的。也就是我们一个模块会对应这里的一个函数。

这里我们需要注意看一下components.js这样一个模块所对应的函数,这里的link函数和heading函数虽然外部并没有使用,但我们这里仍然是导出了。

很明显这些导出是没有意义的,我们可以借助一些优化功能把他们去掉,我们打开webpack的配置文件。

这里我们在配置文件中添加一个optimization的属性。那这个属性呢就是集中去配置webpack内部的一些优化功能的。

在这个属性当中我们可以先开启一个叫做usedExports选项,表示我们在输出结果中只导出那些外部使用了的成员。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    optimization: {
        usedExports: true
    }
}

完成过后我们重新回到命令行打包,我们再来看下输出的bundle.js。

此时就会发现,components模块所对应的这个函数中就不再会去导出link和heading这两个函数了,而且我们vscode也非常友好的讲这两个函数的字体变淡,表示他们未被使用。

此时我们就可以去开启webpack的代码压缩功能,去压缩掉这些没有用到的代码。

我们再回到配置文件当中,这里我们再optimization中取开启minimize,然后我们回到命令行再次打包。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    optimization: {
        usedExports: true,
        minimize: true
    }
}

此时我们bundle.js当中这些未引用的代码就都被移除掉了,这就是tree-shaking的实现。整个过程我们用到了两个优化功能,一个是usedExports另一个是minimize。

如果说真的把我们的代码看做一棵大树的话,那你可以理解成usedExports就是用来在这个大树上标记哪些是枯树叶枯树枝,然后minimize就是负责把这些枯树叶树枝全都摇下来。

Webpack 合并模块

除了usedExports以外,我们还可以使用一个concatenteModules属性去继续优化我们的输出,普通的打包结果是将我们每一个模块最终放在一个单独的函数当中,这样的话如果我们的模块很多也就意味着我们在输出结果中会有很多这样的模块函数。

我们回到配置文件当中,这里开启concatenateModules, 为了可以更好的看到效果我们这里先去关掉minimize,然后我们重新打包。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    optimization: {
        usedExports: true,
        concatenateModules: true,
        // minimize: true
    }
}

此时我们bundle.js当中就不再是一个模块对应一个函数了,而是把所有的模块都放到了同一个函数当中,那concatnateModules这样一个配置的作用呢就是尽可能将所有的模块全部合并到一起然后输出到一个函数中。

这样的话既提升了运行效率又减少了代码体积。那这个特性又被称之为Scope Hoisting 也就是作用域提升,他是webpack3中去添加的一个特性,如果说此时我们再去配合minimize那这样的话我们的代码体积就会又减小很多。

Webpack Tree Shaking 与 Babel

由于早期webpack早期发展非常快,那变化也就比较多,所以当我们去找资料时我们得到的结果并不一定适用于我们当前所使用的版本,对于tree-shaking的资料更是如此。

很多资料中都表示如果我们使用了babel-loader就会导致tree-shaking失效。针对于这个问题,这里我们来统一说明一下。

首先大家需要明确一点的是tree-shaking的实现,他的前提是必须要使用ES Modules去组织我们的代码,也就是我们交给webpack去处理的代码他必须还是使用ES Modules的方式来去实现的模块化。

那为什么这么说呢,我们都应该知道,webpack在打包所有模块之前,他先是将模块根据配置交给不同的loader去处理,最后再将所有loader处理过后的结果打包到一起。

我们为了转换我们代码当中ECMAScript的新特性很多时候我们都会选择babel-loader去处理我们的js。

而在babel转换我们的代码时就有可能处理掉我们代码当中ES Modules 把他们转换成CommonJS,当然了,这取决于我们有没有使用转换ES Modules的插件。

例如在我们的项目当中,我们所使用的的@babel/preset-env这个插件集合,它里面就有这么个插件,所以说大preset-env这个插件集合工作的时候,我们代码当中ES Modules的部分就应该会被他转换成CommonJS的方式。

那webpack再去打包时,他拿到的代码就是以CommonJS的方式组织的代码,所以说tree-shaking他就不能生效。我们具体来尝试一下。

需要注意,我们这为了可以更容易分别结果,我们只开启usedExports, 然后我们重新打包,查看bundle.js。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    modules: {
        rules: [
            {
                test: /\.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
        ]
    },
    optimization: {
        usedExports: true,
        // concatenateModules: true,
        // minimize: true
    }
}

这里你会发现我们的结果并不是像刚刚说的那样,这里usedExports功能正常的工作了,这也就说明如果我们开启压缩代码的话,那这些为引用的代码依然会被移除。那tree-shaking并没有失效。

这是因为在最新版本的babel-loader当中就已经自动帮我们关闭了ES Modules转换的插件,我们可以在node_modules当中先去找到babel-loader的模块,我们可以看一下他的源代码,他在injectcaller这个文件中已经标识了我们当前的环境是支持ES Modules的。

然后我们再去找到我们所使用的到的preset-env这个模块,在200多行可以发现,这里根据我们injectcaller中的标识禁用了ES Module的转换。

所以说webpack最终打包时他得到的还是ES Modules的代码,那tree-shaking自然也就可以正常工作了,当然了,我们这里只是定位的找到了源代码当中相关的一些信息。如果你需要仔细了解这个东西的话,那你可以再去翻看一下babel的源代码。

那我们这里也可以尝试在babel的preset配置当中强制去开启这个插件来去试一下。不过给preset添加配置的方式比较特别,很多人都容易配错。所以一定要注意。

那他需要把我们预设这个数组中的成员再次定义成一个数组,然后这个数组当中的第一个成员就是我们所使用的的preset的名称。第二个成员就是我们给这个preset定义的对象,这里不能搞错,是数组套数组。

这里我们将这个对象的modules属性设置为commonjs,那默认这个属性值是auto 也就是根据环境去判断是否开启ES Module插件。

那我们这里将它设置为commonjs也就表示我们需要强制使用babel的ES Modules插件把我们代码当中的ES Moudle转换为commonjs。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    modules: {
        rules: [
            {
                test: /\.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            ['@babel/preset-env', { modules: 'commonjs'}],
                        ]
                    }
                }
            }
        ]
    },
    optimization: {
        usedExports: true,
        // concatenateModules: true,
        // minimize: true
    }
}

我们打包后查看你就会发现我们刚刚所配置的usedExports就没有办法生效了。即便我们再去开启压缩代码,那tree-shaking也是没有办法正常工作的。

总结一下,我们这里通过实验发现,最新版本的babel-loader并不会导致tree-shaking失效,如果说你不确定,最简单的办法就是在配置文件当中将preset-env当中的这个modules这个数值为false,这样就确保我们这个preset-env里面不会再去开启ES Module转换的插件。

那这样就同时确保了我们tree-shaking的一个工作的前提。

['@babel/preset-env', { modules: false}]

另外呢我们刚刚这样一个探索的过程也值得你仔细再去琢磨一下,因为通过这样的探索,你能够了解到很多知其所以然的内容。

Webpack sideEffects

weboack4中还新增了一个叫做sideEffects的新特性。

它允许我们通过配置的方式去标识我们的代码是否有副作用。从而为tree-shaking提供更大的压缩空间。

副作用是指模块执行时候除了导出成员是否还做了一些其他的事情,那这样一个特性他一般只有我们在去开发一个npm模块时才会用到。

但是因为官网当中把sideEffects的介绍跟tree-shaking混到了一起,所以很多人误认为他俩是因果关系。其实他俩真的没有那么大的关系。

那我们这先把side Effects搞明白,也就能理解为什么了。

这里我们先设计一下能够让side Effects 发挥效果的一个场景,我们基于刚刚这个案例的基础之上,我们把components拆分出了多个组件文件。然后在index.js当中我们集中导出,便于外界导入。

这是一种非常常见的同类文件组织方式。我们在回到入口文件当中去导入components当中的button成员。

那这样的话就会出现一个问题,因为我们在这载入的是components目录下的这个index.js, 那index.js当中又载入了所有的组件模块。

那这就会导致我们只想载入button组件,但是所有的组件模块都会被加载执行。

查看打包结果之后你会发现所有组件的模块确实都被打包了,那side effects特性就可以用来解决此类问题。

我们打开webpack的配置文件,然后在optimization中去开启这个属性 sideEffects: true,注意这个特性在production模式下同样也会自动开启。

{
    optimization: {
        sideEffects: true
        // usedExports: true,
        // concatenateModules: true,
        // minimize: true
    }
}

我们开启这个特性过后,webpack在打包时他就会先检查当前代码所属的这个package.json当中有没有sideEffects的标识,以此来判断这个模块是否有副作用。

如果说这个模块没有副作用,那这些没有用到的模块就不再会打包。我们可以打开我们的package.json,然后我们尝试去添加一个sideEffects的字段,我们把它设置为false。

{
    "sideEffects": false
}

那这样的话就标识我们当前这个package.json所影响的这个项目,他当中所有的代码都没有副作用。那一但这些没有用到的模块他没有副作用了,他就会被移除掉。

我们打包过后查看打包的bundle.js文件, 那此时我们那些没有用到的模块就不再会被打包进来了,这就是sideEffects的作用。

注意我们这里设置了两个地方,我们先在webpack的配置当中去开启了sideEffects,他是用来去开启这个功能,而在package.json当中我们添加sideEffects他是用来标识我们的代码是没有副作用的,他俩不是一个意思,不要弄混了。

Webpack sideEffects 注意事项

使用sideEffects这个功能的前提就是确定你的代码没有副作用。否则的话在webpack打包时就会误删掉那些有副作用的代码。

例如我们准备了一个extend.js这样一个文件。在这个文件当中我们并没有向外导出任何成员。他仅仅是在Number这个对象的原型上挂载了一个pad方法,用来为数字去添加前面的导0。

Number.prototype.pad = functuon(size) {
    let result = this + '';
    while (result.lengtj < size>) {
        result = '0' + result
    }
    return result;
}

这是一种非常常见的基于原型的扩展方法,我们回到入口文件去导入这个extend.js, 我们导入了这个模块过后我们就可以使用他为Number所提供的扩展方法。

import './extend.js';

console.log((8).pad(3));

这里为Number做扩展方法的这样一个操作就属于我们extend这个模块的副作用,因为在导入了这个模块过后,我们的Number的原型上就多了一个方法,这就是副作用。

那此时如果我们还标识我们项目当中所有代码都没有副作用的话,那打包之后我们就会发现,我们刚刚的扩展操作他是不会被打包进来的。因为他是副作用代码。但是你在你的配置当中已经声明了没有副作用。所以说他们就被移除掉了。

那除此之外呢还有我们再代码当中载入的css模块,他也都属于副作用模块,同样会面临刚刚这样一种问题。

那解决的办法就是在我们的package.json当中去关掉副作用,或者是标识一下我们当前这个项目当中哪一些文件是有副作用的,那这样的话webpack就不会去忽略这些有副作用的模块了。

我们可以打开package.json,我们吧sideEffects的false改成一个数组。然后我们再去添加一下extend.js这个文件的路径,还有我们global.css这个文件的路径,当然了这里我们可以使用路径通配符的方式来去配置。*.css。

{
    "sideEffects": [
        "./src/extend.js",
        "./src/global.css"
    ]
}

此时我们再来找到打包结果我们就会发现,这个有副作用的两个模块也会被同时打包进来了。

那以上就是我们对webpack内置的一些优化属性的一些介绍,总之这些特性呢,他都是为了弥补javascript早起在设计上的一些遗留问题,那随着像webpack这一类技术的发展,javascript确实越来越好。

Webpack 代码分割

通过webpack实现前端项目整体模块化的优势固然很明显,但是他同样存在一些笔端,那就是我们项目当中所有的代码最终都会被打包到一起,那试想一下如果说我们的应用非常复杂,模块非常多的话那我们的打包结果就会特别的大,很多时候超过2-3M也是非常常见的事。

而实施情况是,大多数时候我们在应用开始工作时并不是我们所有的模块都是必须要加载进来的。但是呢,这些模块又被全部打包到一起,那我们需要任何一个模块都必须把整体加载下来过后才能使用。

而我们的应用呢,一般又是运行在浏览器端,那这就意味着我们会浪费掉很多的流量和带宽,那更为合理的方案呢就是把我们的打包结果按照一定的规则去分离到多个bundle当中,然后根据我们应用的运行需要按需去加载这些模块。

这样的话我们就可以大大提高我们应用的响应速度以及他的运行效率。

那可能有人会想起来我们在一开始的时候说过,webpack就是把我们项目中散落的模块合并到一起从而去提高运行效率。

那我们这里又在说他应该把他分离开,那这两个说法是不是自相矛盾呢,其实这并不是矛盾,只是物极必反而已。

那资源太大了不行,太碎了也不行,我们项目中划分的这种模块的颗粒度一般都会非常的细,那很多时候我们一个模块只是提供了一个小小的工具函数,它并不能形成一个完整的功能单元。

那如果我们不把这些散落的模块合并到一起,那就有可能我们再去运行一个小小的功能时就会加载非常多的模块。

而我们目前这种主流的HTTP1.1版本,它本身就有很多缺陷,例如我们并不能同时对同一个域名下发起很多次的并行请求,而且我们一次的请求呢他都会有一定的延迟。

另外我们每次请求除了传输具体的内容以外还会有额外的请求头和响应头,那当我们有大量的这种请求情况下,那这些请求头响应头加在一起也是很大的浪费。

那综上所述,模块化打包肯定是有必要的,不过呢,在我们的应用越来越大过后,我们也要慢慢的开始学会变通。

那为了解决这样的问题,webpack呢他支持一种分包的功能,你也可以把这种功能称之为代码分割。

他通过把我们的模块按照我们所设计的一个规则打包到不同的bundle当中,从而去提高我们应用的响应速度。

目前呢webpack去实现分包的方式主要有两种,那第一种就是我们根据我们的业务去配置不同的打包入口,也就是我们会有同时多个打包入口同时打包,那这时候就会输出多个打包结果。

那第二种呢就是采用ES Module的动态导入这样一个功能去实现模块的按需加载,那这个时候呢,我们webpack他也会自动的把我们动态导入的这个模块单独的输出到一个bundle当中。

那接下来我们来具体来看这两种方式。

Webpack 多入口打包

多入口打包一般适用于传统的多页应用程序,那最常见的划分规则就是一个页面去对应一个打包入口。

那对于不同页面之间的公共部分,再去提取到公共的结果当中。

那这种方式呢使用起来非常简单。我们回到项目中具体来看。我们准备了一个多页应用的示例,我们这里有两个页面,分别是index和album页面。

那代码的组织逻辑也非常简单,index.js负责实现index页面所有功能。而album.js负责实现album页面所有功能。

global.css和fetch.js都是公共部分,下面我们尝试为这个案例配置多个打包入口。

一般我们配置文件当中的entry属性他只会配置一个文件名路径,也就是说我们只会配置一个打包入口,那如果我们需要配置多个入口的话,我们可以把entry定义成一个对象,那需要注意的是这里是一个对象,而不是数组。

因为如果定义成数组的话,那他就是把多个文件打包到一起,那对于整个应用来讲的话还是一个入口。

那我们这里需要的是多入口所以我们配置成一个对象。那在这个对象中一个属性就是一路入口。那我们属性名就是这个入口的名称。然后值就是这个入口所对应的文件路径。

那我们这里配置的就是index和album这两个js所对应的文件路径,那一但我们这里配置为多入口,那我们输出的文件名也需要修改,那这俩两个入口也就意味着会有两个打包结果,我们不能都叫bundle.js。

所以说我们这里可以为我们filename属性去添加一个[name]这种占位符的方式来去动态输出文件名。

那么[name]最终就会被替换成入口的名称,那在我们这就是index和album。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            index: './src/index.js',
            album: './src/album.js',
        },
        output: {
            filename: `[name].bundle.js`,
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            })
            new HtmlWebpackPlugin({
                template: './src/album.html',
                filename: `album.html`
            })
        ]
    }
})

那我们这次打包就会有两个入口,完成以后我们找到输出的目录,那在输出的目录我们就能看到两个入口各自打包过后的结果了。

但是这里呢,还是会有一个小问题,我们打开任意一个输出的html文件,这时候你就会发现这两个打包结果他都被页面同时载入了。

而我们希望的是,一个页面只使用他对应的那个输出结果,所以说这里我们还需要继续去修改配置文件。

那我们回到配置文件当中,我们找到输出html的插件, 那之前我们就介绍过,这个插件他默认就会输出一个自动注入所有打包结果的html。

[
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
        template: './src/index.html',
        filename: `index.html`
    })
    new HtmlWebpackPlugin({
        template: './src/album.html',
        filename: `album.html`
    })
]

那如果说我们需要指定我们输出的html他所使用的bundle, 那我们就可以使用chunk属性来去设置。

那我们每一个打包入口呢他就会形成一个独立的chunk,那我们在这分别为这两个页面配置不同的chunk。

[
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
        template: './src/index.html',
        filename: `index.html`,
        chunk: ['index']
    })
    new HtmlWebpackPlugin({
        template: './src/album.html',
        filename: `album.html`,
        chunk: ['album']
    })
]

完成以后再次重新打包,那我们这次的打包结果呢他就完全正常了。

那以上就是我们配置多入口的打包方式,以及我们如何在输出的html当中指定我们需要注入的bundle。

Webpack 提取公共模块

多入口打包本身非常容易理解也非常容易使用,但是他也存在一个小小的问题,那就是我们再不同的打包入口当中他一定会有那么一些公共的部分。

那按照之前这种多入口的打包方式,就会出现我们再不同的打包结果当中会有相同的模块出现。例如在我们这里index入口和album入口中就共同使用了global.css和fetch.js这两个公共的模块。

那这里是因为我们的示例比较简单,所以说重复的影响不会有那么大,但是如果说我们共同使用的是jQuery或者是Vue这种体积比较大的模块,那影响的话就会特别的大。

所以说我们需要把这些公共的模块去提取到一个单独的bundle当中。

那webpack当中实现公共模块提取的方式也非常简单,我们只需要在优化配置当中去开启一个叫做splitChunks的一个功能就可以了。

我们回到配置文件当中,我们再optimization中添加splitChunks属性,那这个属性他需要我们配置一个chunks属性,然后我们将这个chunks属性设置为all,就表示我们会把所有的公共模块都提取到单独的bundle当中。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            index: './src/index.js',
            album: './src/album.js',
        },
        output: {
            filename: `[name].bundle.js`,
        },
        optimization: {
            splitChunks: {
                chunks: 'all'
            }
        }
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`,
                chunk: ['index']
            })
            new HtmlWebpackPlugin({
                template: './src/album.html',
                filename: `album.html`,
                chunk: ['album']
            })
        ]
    }
})

打包过后我们的dist目录下就会生成额外的一个js文件,在这个文件当中就是我们index和album这两个入口公共的模块部分了。

Webpack 动态导入

按需加载是我们开发浏览器应用当中一个非常常见的需求,那一般我们常说的按需加载指的是加载数据。那我们这里所说的按需加载呢,指的是我们再应用运行过程中需要某个模块时我们才去加载这个模块。

那这种方式呢,可以极大地节省我们的带宽和流量。

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

那相比于多入口的这种方式动态导入他更为灵活,因为我们可以通过代码的逻辑去控制我们需不需要加载某个模块,或者是我们什么时候加载某个模块。

而我们分包的目的中就有很重要的一点就是,要让模块实现按需加载,来提高应用的响应速度。我们具体来看如何使用。

这里我们已经设计好了一个可以发挥按需加载作用的场景,在这个页面的主体区域,如果我们访问的是文章页的话,我们得到的就是一个文章列表,如果我们访问的是相册页面,我们显示的就是相册列表。

回到代码当中我们来看下他的实现方式,目前我们文章列表所对应的就是post组件,而相册列表对应的就是album组件,我们在打包入口当中同时导入这两个模块。

然后这里的逻辑就是当我们锚点发生变化时,我们去根据锚点的值决定要去显示哪个组件。

import posts from './posts/posts';
import album from './album/album';

const render = () => {
    const hash = locaton.hash || '#posts';

    const mainElement = document.querySelector('.main');

    mainElement.innerHTML = '';

    if (hash === '#posts') {
        mainElement.appendChild(post());
    } else if (hash === '#album') {
        mainElement.appendChild(album());
    }
}

render();

window.addEventListener('hashchange', render);

那这里就会存在浪费的可能性,是想一下,如果说用户他打开我们的应用过后只是访问了其中的一个页面。那另外一个页面所对应的这个组件的加载就是浪费。

所以说我们这里如果是动态导入组件,那就不会存在浪费的问题了。我们这里可以先注释掉静态导入。

动态导入使用的就是ES Module标准当中的动态导入,我们在需要动态导入的地方通过import这个函数,然后导入我们指定的路径。

那这个方法返回的就是一个Promise,然后在这个Promise的then方法中我们就可以拿到模块对象。

由于我们这里使用的是默认导出,所以我们这需要解构我们模块对象的default,我们把它放在posts这个变量当中,拿到这个成员过后我们再来使用这个成员去创建界面上的元素。

同理我们的album组件也应该是如此。

// import posts from './posts/posts';
// import album from './album/album';

const render = () => {
    const hash = locaton.hash || '#posts';

    const mainElement = document.querySelector('.main');

    mainElement.innerHTML = '';

    if (hash === '#posts') {
        // mainElement.appendChild(posts());
        import('./posts/posts').then(({ default: posts}) => {
            mainElement.appendChild(posts());
        })
    } else if (hash === '#album') {
        // mainElement.appendChild(album());
        import('./album/album').then(({ default: album}) => {
            mainElement.appendChild(album());
        })
    }
}

render();

window.addEventListener('hashchange', render);

完成以后我们在此回到浏览器,此时我们页面仍然可以正常工作,我们回到开发工具当中,重新运行打包,然后去看看此时我们打包的结果是什么样子的。

打包完成过后我们打开输出的dist目录,在此时我们的dist目录下就会多出三个js文件,那这三个js文件呢实际上就是由动态导入,自动分包所产生的。

那这三个文件分别是我们刚刚导入的两个模块以及这两个模板当中公共的部分所提取出来的bundle, 那这就是动态导入在webpack当中的一个使用。

那整个过程我们无需配置任何一个地方只需要按照ES Module动态导入成员的方式去导入模块就可以了。那webpack内部呢会自动处理分包和按需加载。

那如果说你使用的是单页应用开发框架,比如react或者vue的话,那在你项目当中的路由映射组件就可以通过这种动态导入的方式实现按需加载。

Webpack 魔法注释

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

但是说如果你还是需要给这些bundle命名的话,那你可以使用webpack所特有的模板注释来去实现。

那具体的使用方式就是在调用import函数的参数位置我们去添加一个行内注释,那这个注释有一个特定的格式,就是通过/* webpackChunkName: 名称 */ 那这样的话我们就可以给分包所产生的bundle起上名字了。

// import posts from './posts/posts';
// import album from './album/album';

const render = () => {
    const hash = locaton.hash || '#posts';

    const mainElement = document.querySelector('.main');

    mainElement.innerHTML = '';

    if (hash === '#posts') {
        // mainElement.appendChild(posts());
        import(/* webpackChunkName: posts */'./posts/posts').then(({ default: posts}) => {
            mainElement.appendChild(posts());
        })
    } else if (hash === '#album') {
        // mainElement.appendChild(album());
        import(/* webpackChunkName: album */'./album/album').then(({ default: album}) => {
            mainElement.appendChild(album());
        })
    }
}

render();

window.addEventListener('hashchange', render);

我们重新打包,那我们生成的bundle文件他的name就会使用我们刚刚注释当中所提供的名称了。

那如果说你的chunkName是相同的话,那相同的chunkName最终就会被打包到一起。那例如我们这里可以把这两个chunk的chunkName设置为components, 这样的话他们就一致了。然后我们再次运行打包。

// import posts from './posts/posts';
// import album from './album/album';

const render = () => {
    const hash = locaton.hash || '#posts';

    const mainElement = document.querySelector('.main');

    mainElement.innerHTML = '';

    if (hash === '#posts') {
        // mainElement.appendChild(posts());
        import(/* webpackChunkName: components */'./posts/posts').then(({ default: posts}) => {
            mainElement.appendChild(posts());
        })
    } else if (hash === '#album') {
        // mainElement.appendChild(album());
        import(/* webpackChunkName: components */'./album/album').then(({ default: album}) => {
            mainElement.appendChild(album());
        })
    }
}

render();

window.addEventListener('hashchange', render);

那此时呢这两个模块他都会被打包到components.bundle.js这样一个文件当中。

那借助于这样一个特点,你就可以根据自己的实际情况灵活组织我们动态加载的模块所输出的文件了。

Webpack MiniCssExtractPlugin

MiniCssExtractPlugin他是一个可以将css代码从打包结果当中提取出来的插件,那通过这个插件我们就可以时间css模块的按需加载。

他的使用也非常简单,我们回到项目当中,我们先安装一下这个插件。

yarn add mini-css-extract-plugin

我们打开webpack的配置文件,这里我们需要先导入这个插件的模块, 那导入过后我们就可以将这个插件添加到配置对象的plugins数组当中。

那这样的话MiniCssExtractPlugin他在工作时就会自动提取我们代码当中的css到一个单独文件当中。

那除此以外呢,目前我们所使用的样式模块,他是先交给css-loader去解析,然后再交给style-loader去处理。这里的style-loader他的作用就是将我们样式代码通过style标签的方式注入到页面当中,从而使样式可以工作。

那使用MiniCssExtractPlugin的话,我们的样式就会单独存放到文件当中也就不需要style标签,而是直接通过link的方式去引入。所以说这里我们就不再需要style-loader了。

取而代之我们所使用的是MiniCssExtractPlugin当中所提供的一个loader,来去实现我们样式文件通过link标签的方式去注入。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            main: './src/index.js',
        },
        output: {
            filename: `[name].bundle.js`,
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        MiniCssExtractPlugin.loader,
                        // 'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            }),
            new MiniCssExtractPlugin()
        ]
    }
})

完成以后我们重新打包,完成过后我们就可以在dist目录中看到提取出来的样式文件了。

不过这里我们需要注意一点,如果说样式文件他的体积不是很大的话,那提取他到单个文件当中,那效果可能会适得其反。

我个人的经验是如果说css文件超过了150kb左右才需要考虑是否将他提取到单独文件当中。

否则的话,其实css嵌入到代码当中他减少了一次请求,效果可能会更好。

Webpack OptimizeCssAssetsWebpackPlugin

使用了MiniCssExtractPlugin过后样式文件就可以被提取到单独的css文件当中了,但是这里同样会有一个小问题。

我们回到命令行,这里我们尝试以生产模式去运行打包。

yarn webpack --mode production

那按照之前的了解,在生产模式下webpack会自动去压缩输出的结果。但是我们这里打开输出的样式文件会发现我们样式文件根本没有任何的变化。

那这是因为,webpack内置的压缩插件,他紧紧是针对于js文件的压缩,那对于其他资源文件压缩,都需要额外的插件去支持。

webpack官方推荐了一个optimize-css-assets-webpack-plugin, 我们可以使用这个插件来去压缩我们的样式文件。

那我们先来安装一下这个插件。

yarn add optimize-css-assets-webpack-plugin

回到配置文件当中,我们需要先导入这个插件,导入完成过后呢,我们去把这个插件添加到配置对象的plugins数组当中。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            main: './src/index.js',
        },
        output: {
            filename: `[name].bundle.js`,
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        MiniCssExtractPlugin.loader,
                        // 'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            }),
            new MiniCssExtractPlugin(),
            new OptimizeCssAssetsWebpackPlugin()
        ]
    }
})

我们再次使用生产模式打包, 那这次打包完成过后呢,我们的样式文件就以压缩文件的格式去输出了。

不过这里还有一个额外的小点,可能大家在官方文档当中会发现文档当中这个插件并不是配置在plugins数组当中的。而是添加到了optimization属性当中的minimize属性当中。

那这是为什么呢?其实也非常简单,如果说我们把这个插件配置到plugins数组当中,那这个插件在任何情况下都会正常工作。而配置在minimize数组当中的话,那只会在minimize这样一个特性开启时才会工作。

所以说webpack建议,像这种压缩类的插件我们应该配置到minimize数组当中,以便于我们可以通过minimize这个选项去统一控制。

那这里我们尝试把这个插件移至到我们的optimization属性当中的minimize数组当中。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            main: './src/index.js',
        },
        output: {
            filename: `[name].bundle.js`,
        },
        optimization: {
            minimize: [
                new OptimizeCssAssetsWebpackPlugin()
            ]
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        MiniCssExtractPlugin.loader,
                        // 'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            }),
            new MiniCssExtractPlugin(),
            new OptimizeCssAssetsWebpackPlugin()
        ]
    }
})

重新打包,那此时如果说我们没有开启压缩这个功能的话,那这个插件他就不会工作。反之如果说我们以生产模式打包,那么minimize属性就会自动开启,那这个压缩插件就会自动工作。我们的样式文件也就会被压缩。

但是这么配置也有一个小小的缺点,我们可以看一下输出的js文件,那这时候你会发现,原本可以自动压缩的js,这次确不能自动压缩了。

那这是因为我们这里设置了minimize这个数组,webpack认为我们如果配置了这个数组,那就是要去自定义所使用的的压缩器插件。那内部的js压缩器就会被覆盖掉,所以说我们这里需要再手动的把他添加回来。

那内置的js压缩插件叫做terser-webpack-plugin,我们需要安装这个模块,

yarn add terser-webpack-plugin --dev

安装完成过后我们再来把这个插件手动的去添加到minimize这个数组当中

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            main: './src/index.js',
        },
        output: {
            filename: `[name].bundle.js`,
        },
        optimization: {
            minimize: [
                new OptimizeCssAssetsWebpackPlugin(),
                new TerserWebpackPlugin()
            ]
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        MiniCssExtractPlugin.loader,
                        // 'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            }),
            new MiniCssExtractPlugin(),
            new OptimizeCssAssetsWebpackPlugin()
        ]
    }
})

那这样的话,如果我们再以生产模式运行打包,然后js文件,css文件都可以正常被压缩了。那如果说我们以普通模式打包,也就是不开启压缩的话,那他也就不会以压缩的形式输出了。

Webpack 输出文件名 Hash

一般我们去部署前端的资源文件时,都会启用服务器的静态资源缓存,那这样的话对应用户的浏览器而言,他就可以缓存住我们应用当中的静态资源。那后续就不再需要请求服务器,得到这些静态资源文件了。

那这样,整体我们应用的响应速度就有一个大幅度的提升。

不过呢,开启静态资源的客户端缓存,他也会有一些小小的问题,那如果说我们在缓存策略中我们的缓存失效时间设置的过短的话,那效果就不是特别明显。

那如果说我们把过期时间设置的比较长,那一但说我们在这个过程中应用发生了更新重新部署过后又没有办法及时更新到客户端。

那为了解决这样一个问题我们建议在生产模式下,我们需要给输出的文件名当中添加Hash值,那这样的话,一旦我们的资源文件,发生改变,那我们的文件名称也可以跟着一起去变化。

那对于客户端而言,全新的文件名就是全新的请求,那也就没有缓存的问题,那这样的话我们就可以把服务端缓存策略的缓存时间设置的非常长,也就不用担心文件更新过后的问题。

webpack中的filename属性和绝大多数插件的filename属性,都支持通过占位符的方式来去为文件名设置hash,不过这里支持三种hash效果各不相同。那这里我们来分别尝试一下。

首先就是最普通的hash我们可以通过[hash]然后去拿到,那这个hash它实际上是整个项目级别的,也就是说一旦项目当中有任何一个地方发生改动,那我们这一次打包过程当中的hash值都会发生变化。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            main: './src/index.js',
        },
        output: {
            filename: `[name]-[hash].bundle.js`,
        },
        optimization: {
            minimize: [
                new OptimizeCssAssetsWebpackPlugin(),
                new TerserWebpackPlugin()
            ]
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        MiniCssExtractPlugin.loader,
                        // 'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            }),
            new MiniCssExtractPlugin({
                filename: '[name]-[hash].bundle.css'
            }),
        ]
    }
})

我们这里可以在任意一个代码当中做下修改,然后尝试重新打包,那此时你就会发现,我们的hash值全部发生改变了。

其次呢是chunkhash,那这个hash是chunk级别的,也就是在我们打包过程中,只要是同一路的打包,那chunkhash他都是相同的。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            main: './src/index.js',
        },
        output: {
            filename: `[name]-[chunkhash].bundle.js`,
        },
        optimization: {
            minimize: [
                new OptimizeCssAssetsWebpackPlugin(),
                new TerserWebpackPlugin()
            ]
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        MiniCssExtractPlugin.loader,
                        // 'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            }),
            new MiniCssExtractPlugin({
                filename: '[name]-[chunkhash].bundle.css'
            }),
        ]
    }
})

那我们这里虽然只配置了一个打包入口index,但是呢在我们的代码当中通过动态导入的方式又分别形成了两路chunk分别是posts和album。

那样式文件是从这个代码当中单独提取出来的,所以说他并不是单独的chunk,所以我们这所看到的结果呢就是main和posts还是album他们三者chunkhash各不相同。

而我们css和所对应的js文件,他们二者的chunkhash是完全一样的。因为他们是同一路。

那这里我们现在index当中尝试做一些修改,然后重新打包。那这时候你会发现,只有main.bundle的文件名发生了变化,其他的文件都没有变。

然后我们再尝试在posts.js文件中做一些修改,那此次我们posts所输出的js和css都会发生变化,因为我们刚刚说过了,他们是属于同一个chunk。

至于main.bundle他也会发生变化的原因是因为我们posts所生成的这个js文件和css文件他的文件名发生变化,那我们再入口文件中去引入他们的路径也会发生变化。所以说mian.chunk他算是一个被动的改变。

那相比于普通的hash,chunkhash的控制要更精确一点。

最后还有一个contenthash,那这个hash他其实是文件级别的hash,他其实是再根据输出文件的内容生成的hash值。也就是说只要是不同的文件,他就有不同的hash值。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
module.exports = allModes.map(item => {
    return {
        mode: 'none',
        entry: {
            main: './src/index.js',
        },
        output: {
            filename: `[name]-[contenthash].bundle.js`,
        },
        optimization: {
            minimize: [
                new OptimizeCssAssetsWebpackPlugin(),
                new TerserWebpackPlugin()
            ]
        },
        module: {
            rules: [
                {
                    test: /\.css$/,
                    use: [
                        MiniCssExtractPlugin.loader,
                        // 'style-loader',
                        'css-loader'
                    ]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: './src/index.html',
                filename: `index.html`
            }),
            new MiniCssExtractPlugin({
                filename: '[name]-[contenthash].bundle.css'
            }),
        ]
    }
})

那这里我们先尝试在index当中做一些修改,然后重新打包,那此时同样只有main.bundle文件名发生了变化。

然后我们再去修改posts.css,那这时候你会发现,posts所对应的样式文件的文件名就发生了变化,而main.bundle同样也是因为路径的原因才被动更新。

那相比于前两者,contenthash应该是解决缓存问题最好的方式了,因为他精确的定位到了文件级别的hash,那只有当这个文件发生了变化才有可能去更新掉他的这个文件名,那这个实际上是最适合我们去解决缓存问题的。

那另外如果说你觉得我们这个20位长度的hash太长的话,那webpack还允许我们去指定hash的长度,我们可以在占位符里面通过冒号跟一个数组([:8])的方式来去指定我们hash的长度,我们这里设置长度为8。

new MiniCssExtractPlugin({
    filename: '[name]-[contenthash:8].bundle.css'
})

那总的来说我个人觉得如果说是控制缓存的话,8位的contenthash应该是最好的选择了。

转载须知

如转载必须标明文章出处文章名称文章作者,格式如下:

转自:【致前端 - https://madaozhijian.com】 webpack打包工具介绍4.x  "隐冬"