1. 打包工具的历史

模块化很好的解决了复杂应用开发中的代码组织问题,但随着引入模块化,又会产生一些新的问题。所使用的ES Modules本身就存在环境兼容问题,尽管现如今主流浏览器最新版本都已经支持这一特性。但是目前还没办法做到统一所有用户浏览器的使用情况,所以还需要解决兼容问题。其次通过模块化的方式,划分出的模块文件比较多,前端应用又是运行在浏览器当中的。应用中所需要的每一个文件,都需要从服务器中请求回来,这些零散的模块文件必将导致浏览器频繁请求,从而影响应用的工作效率。

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

首先希望有一个工具能够编译代码,就是将开发阶段包含新特性的代码直接转换为能够兼容绝大多数环境的代码,这样一来环境兼容问题也就不存在了。其次是能够将散落的模块文件打包到一起,这就解决了浏览器中频繁对模块文件发出请求的问题。

至于模块化文件划分,只是在开发阶段需要他,因为它能够更好的组织代码对于运行环境实际上是没有必要的,所以可以选择在开发阶段通过模块化的方式去编写。在生产阶段还是打包到同一个文件中,最后还需要支持不同种类的前端资源类型,这样就可以把前端开发过程当中所涉及到的样式、图片、字体等所有资源文件都当做模块使用,对于整个前端应用来讲就有了一个统一的模块化方案了。

前端领域目前有一些工具很好的解决了以上这几个问题,其中最为主流的就是webpackparcelrollup。以webpack为例,一些核心特性就很好的满足了上面所说的需求。

首先webpack作为一个模块打包工具(Module Bundler)他本身就可以解决模块化js代码打包的问题,通过webpack可以将一些零散的模块代码打包到同一个js文件中。对于代码中那些有环境兼容问题的代码可以在打包的过程中通过模块加载器(Loader)对其进行编译转换。其次,webpack还具备代码拆分(Code Splitting)的理念,能够将应用中所有的代码都按照需要进行打包。这样一来就不用担心代码全部打包到一起文件较大的问题了。

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

webpack支持在js中以模块化的方式载入任意类型的资源文件,例如在webpack当中可以通过js直接import一个css文件。这些css文件最终会通过style标签的形式工作,其他类型的文件也可以有类似的这种方式去实现。

2. 快速上手

webpack 作为目前最主流的代码打包工具提供了一整套的前端项目模块化方案而不仅仅局限于对js的模块化。通过webpack提供的前端模块化方案,可以很轻松的对前端项目涉及到的所有的资源进行模块化。

这里有一个项目,目录中有个src文件夹,src中有两个文件 index.jsheading.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 .

可以看到正常的工作。下来引入webpack处理js模块。首先以通过yarn init的方式去初始化一个package.json

yarn init

完成过后安装webpack所需要的核心模块以及对应的cli模块。

yarn add webpack webpack-cli --dev

有了webpack之后就可以打包src下面的js代码了。执行yarn webpack命令webpack会自动从src下面的index.js开始打包。

yarn webpack

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

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

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

再次启动服务,应用仍然可以正常工作。

http-server .

可以把webpack命令放到package.json中的script,通过yarn build打包。

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

3. 配置文件

webpack4.0后的版本支持零配置打包,整个打包过程会按约定将src/index.js作为入口结果存放在dist/main.js中。很多时候需要自定义路径,例如入口文件是src/main.js,这就需要为webpack添加配置文件,在项目的跟目录添加webpack.config.js文件即可。这个文件运行在node环境也就说需要按照Commonjs的方式编写代码。

文件导出一个对象, 通过导出对象的属性可以完成相应的配置选项,例如entry属性指定webpack打包入口文件的路径。可以将其设置为./src/main.js

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

可以通过output配置输出文件的位置,属性值是一个对象,对象中的filename指定输出文件的名称,path属性指定输出文件的目录需要是一个绝对路径。

const path = require('path');

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

运行yarn build就在项目中生成了dist/bundle.js

4. 工作模式

webpack新增了工作模式简化了webpack配置的复杂度,可以理解成针对不用环境的几组预设的配置,webpack可以设置一个mode属性,如不设置默认会使用production模式工作。在这个模式下webpack会自动启动一些优化插件,例如代码压缩。

可以在webpack启动时传入--mode的参数,这个属性有三种取值,默认是production,还有development也就是开发模式。开发模式webpack会自动优化打包的速度,会添加一些调试过程需要的服务到代码中。

yarn webpack --mode=development

node模式就是运行最原始状态的打包,不会去任何额外的处理。

yarn webpack --mode=none

除了通过cli参数指定工作模式,还可以在webpack的配置文件中设置工作模式,在配置文件的配置中添加mode属性就可以了。

const path = require('path');

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

5. 打包结果分析

首先先将webpack的工作模式设置成node。这样就是以最原始的状态打包。

const path = require('path');

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

完成过后打开生成的bundle.js文件,可以把整体结构折叠起来以便于对结构了解。快捷键是ctrl + kctrl + 0

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

/******/ (function(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__函数上挂载了一些数据和一些工具函数。

这个函数执行到最后调用了__webpack_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);
/******/ })

__webpack_require__内部先判断了这个模块有没有被加载过,如果加载了就从缓存里面读,如果没有就创建一个新的对象。创建过后开始调用这个模块对应的函数,把刚刚创建的模块对象(module),导出成员对象(module.exports),__webpack_require__函数作为参数传入进去。这样的话在模块的内部就可以使用module.exports导出成员,通过__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函数,这个函数的作用是给导出对象添加一个标记,用来对外界表明这是一个ES Module

/* 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函数。

/******/     // 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__函数,此时传入的id1,也就是说用来加载第一个模块。

/* 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);

/***/ }),

这个模块就是代码中exportheading,以相同的道理执行heading模块,将heading模块导出的对象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函数将创建完的元素拿到appendbody上面。

/* 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的作用。

打包的全部代码如下。

/******/ (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;
});

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

6. 模块依赖方式

css文件也可以作为打包的入口,不过webpack的打包入口一般还是js,打包入口从某种程度来说可以算是应用的运行入口。就目前而言前端应用中的业务是由js驱动的,可以在js代码当中通过import的方式引入css文件。

import createHeading from './heading.js';

import './style.css';

const heading = createHeading();

document.body.append(heading);

在webpack.config.js中配置cssloadercss-loaderstyle-loader 需要安装到项目中。然后将loader需要配置到configmodule中。

yarn add css-loader style-loader --dev
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,甚至编写代码中引入资源都可以在js中印日。因为真正需要资源的不是应用,而是正在编写的代码,代码想要正常工作就必须要加载对应的资源,这就是webpack的哲学。一开始可能不太容易理解,换种方式理解假设样式单独引入到页面中,如果代码更新了不再需要这个样式资源了,是不是需要手动的删除。通过js的代码引入文件或者建立js和文件之间的依赖关系是有明显优势的。

js代码本身是负责完成整个业务的功能,放大来看就是驱动了整个前端应用,在实现业务功能的过程当中可能需要用到样式或图片等一系列的资源文件。如果建立了这种依赖关系,一来逻辑上比较合理,因为js确实需要这些资源文件的配合才能实现对应的功能,二来可以保证上线时资源文件不缺失,而且每一个上线的文件都是必要的。

7. 文件资源加载器

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

大多数文件加载器都类似于css-loader,是将资源模块转换为js代码的实现方式进行工作,但是有一些经常用到的资源文件例如图片或字体这些文件是没办法通过js表示的。对于这类的资源文件,需要用到文件的资源加载器也就是file-loader

在项目中添加一张普通的图片文件,通过import 的方式导入这张图片。接收模块文件的默认导出也就是文件的资源路径,创建img元素把src设置成文件,最后将元素appendbody中。

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配置。为png文件添加一个单独的加载规则配置,test属性设置.png结尾,use属性设置为file-loader,这样webpack打包的时候就会用file-loader处理图片文件了。

yarn add file-loader --dev
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'
            }
        ]
    }
}

打包过后dist目录中会多出一个图片文件,这个文件就是代码中导入的图片,不过文件名称发生了改变。文件模块代码只是把生成的文件名称导出了。

/* 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/斜线不能省略。

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/";)

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

8. url加载器

file-loader这种通过copy文件的形式处理文件资源外还有一种通过Data URLs的形式表示文件。Data URLs是一种特殊的url协议,可以直接表示文件,传统的url要求服务器上有对应的文件,然后通过地址,得到服务器上对应的文件。而Data URLs本身就是文件内容,在使用这种url的时候不会再去发送任何的http请求,比如常见的base64格式。

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

data:表示协议,[mediatype][;base64]表示媒体类型和编码,&lt;data&gt;则是具体的文件内容。例如下面给出的Data URLs,浏览器可以根据这个url解析出html类型的文件内容,编码是url-8,内容是一段包含h1html代码。

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

如果是图片或者字体这一类无法通过文本表示的2进制类型的文件,可以通过将文件的内容进行base64编码,以编码后的结果也就是字符串表示这个文件内容。这里url就是表示了一个png类型的文件,编码是base64,再后面就是图片的base64编码。

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

当然一般情况下base64的编码会比较长,这就导致编码过后的资源体积要比原始资源大,不过优点是浏览器可以直接解析出文件内容,不需要再向服务器发送请求。

webpack在打包静态资源模块时,就可以使用这种方式去实现,通过Data URLs以代码的形式表示任何类型的文件,需要用到一个专门的加载器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的形式。打开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中已经包含了文件内容,所以dist中也就不存在独立的.png物理文件了。

这种方式十分适合项目当中体积比较小的资源,如果体积过大会造成打包结果非常大从而影响运行速度。最佳的实践方式是对项目中的小文件通过url-loader转换成Data URLs然后在代码中嵌入,从而减少应用发送请求次数。对于较大的文件仍然通过传统的file-loader方式以单个文件方式存放,从而提高应用的加载速度。

url-loader支持通过配置选项的方式设置转换的最大文件,将url-loader字符串配置方式修改为对象的配置方式,对象中使用loader定义url-loader,然后额外添加options属性为其添加一些配置选项。这里为url-loader添加limit的属性,将其设置为 10kb(10 * 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
                    }
                }
            }
        ]
    }
}

9. babel-loader

webpack默认就可以处理代码中的importexport,所以很自然的会有人认为,webpack会自动编译ES6的代码,实则不然,webpack仅仅完成模块打包工作,会对代码中的importexport做一些相应的转换,除此之外它并不能转换代码中其他的ES6代码。如果需要webpack在打包过程中同时处理其他ES6特性,需要为js文件配置一个额外的加载器babel-loader

首先需要安装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'
            }
        ]
    }
}

还需要为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'
            }
        ]
    }
}

10. 加载资源

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兼容多种模块化标准,除非必要的情况否则不要在项目中去混合使用这些标准,每个项目使用一个标准就可以了。

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

@import '';

html-loader加载的html文件中的一些src属性也会触发相应的模块加载。

main.js

import './main.css';

main.css

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

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

reset.css

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

html文件中也会引用其他文件例如img标签的srcsrc/footer.html

<footer>
    <img src="better.png" />
</footer>
yarn add html-loader --dev

配置文件中为扩展名为html的文件配置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: {
                    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-loader默认只会处理img标签的src属性,如果需要其他标签的一些属性也能够触发打包可以额外做一些配置,具体的做法就是给html-loader添加attrs属性,也就是html加载的时候对页面上的属性做额外的处理。比如添加一个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标签用到的资源已经参与了打包。

11. 工作原理

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

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

有了依赖关系树后webpack会递归这个依赖树然后找到每个节点对应的资源文件。最后再根据配置文件中的属性找到这个模块所对应的加载器,然后交给加载器去加载这个模块。

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

12. 开发一个Loader

markdown-loader,需求是有了这个加载器后就可以在代码当中直接导入markdown文件。

main.js

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

about.md

# 关于我

我是隐冬

markdown文件一般是要被转换为html后呈现到页面上的,所以说这里希望导入的markdown文件得到的结果是markdown转换过后的html字符串。

在项目的根目录创建markdown-loader.js文件,webpack-loader需要去导出一个函数,这个函数就是loader对所加载到资源的处理过程,入参是加载到的资源文件的内容,输出是加工过后的结果。通过source接收输入,通过返回值输出。

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

webpack的配置文件中添加加载器的规则配置,扩展名就是.md使用的加载器是我编写的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'
            }
        ]
    }
}

webpack加载资源的过程类似工作管道,可以在这个过程中依次使用多个loader,但是最终这个管道工作过后的结果必须是一段javascript代码,markdow-loader中,将返回的字符串修改为console.log("hello")标准的js代码。

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

webpack打包的时候就是把loader返回的字符串拼接到模块当中了。

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

console.log("hello")

/***/ })

安装markdown解析的模块marked

yarn add marked --dev

在加载器当中使用这个模块去解析来自参数中的source,这里返回值就是一段html字符串也就是转换过后的结果。正确的做法就是把这段html变成一段javascript代码。

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的方式去导出。

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放在数组的后面。

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']
            }
        ]
    }
}

完成后打包依然是可以的。

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

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

/***/ })

loader不建议使能用剪头函数会拿不到上下文的this

官方推荐使用loader-utils工具处理loader.query

const loaderUtils = require('loader-utils');
module.exports = function(source) {
    const options = loaderUtils.getOptions(this);
    console.log(options);
    return source;
}

this.callback可以返回更多内容用于替代return

module.exports = function(source) {
    const options = loaderUtils.getOptions(this);
    const result = "2123";
    this.callback(null, result);
}

this.async用于处理异步。

module.exports = function(source) {
    const options = loaderUtils.getOptions(this);
    // 定义异步callback
    const callback = this.async();
    setTimeout(() => {
        const result = "2123";
        callback(null, result);
    });
}

resolveLoader可以用于webpack配置loader的简写,当配置文件里面使用模块,先去node_modules里面找,如果找不到就去后面路径上面找。

resolveLoader: {
    modules: ["node_modules", "./loaders"],
}

13. 插件机制介绍

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

例如plugin可以实现在打包之前清除dist目录,还可以copy不需要参与打包的资源文件到输出目录,又或是压缩打包结果输出的代码。总之,有了插件webpack几乎无所不能的实现了前端工程化中绝大多数工作,这也是很多初学者会把webpack理解成前端工程化的原因。

接下来体验几个常见的插件。

1. clean-webpack-plugin

自动清除输出目录,webpack每次打包的结果都是覆盖到dist目录而在打包之前dist中可能已经存在一些之前的遗留文件,再次打包可能只覆盖那些同名的文件,对于其他已经移除的资源文件会一直积累在dist里面非常不合理。合理的做法是每次打包前自动清理dist目录,这样的话dist中就只会保留需要的文件。

clean-webpack-plugin就很好的实现了这样一个需求。

yarn add clean-webpack-plugin --dev

webpack配置文件中导入这个插件,插件中导出了一个CleanWebpackPlugin的成员可以解构出来,webpack使用插件需要为配置对象添加plugins属性,值是一个数组里面每一个成员就是一个插件实例。绝大多数插件模块导出的都是一个类型,使用插件就是通过类型创建实例,然后将实例放入到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()
    ]
}

2. html-webpack-plugin

在之前html都是通过硬编码的方式单独存放在项目的跟目录下的这种方式有两个问题,第一项目发布时需要发布跟目录下的html文件和dist目录下所有的打包结果,相对麻烦一些。而且上线过后还需要确保html代码当中路径引用都是正确的。第二个如果输出的目录或输出的文件名也就是打包结果的配置发生了变化,那html代码当中script标签所引用的路径也要手动修改。

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

需要借助html-webpack-plugin插件。

yarn html-webpack-plugin --dev

配置文件中载入这个模块html-webpack-plugin默认导出的就是一个插件的类型,不需要解构他内部的成员。

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()
    ]
}

打包过后dist目录中生成了index.html文件,这里引入的bundle.js路径是可以通过output属性中的publicPath进行修改的,可以删除这个配置。这样打包之后index.htmlbundle的引用就标成了/bundle.js

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()
    ]
}

对于默认生成的html的标题是可以修改的,页面中的一些原数据标签和基础的DOM结构也是可以定义的,对于简单的自定义可以通过修改html-webpack-plugin插件传入的参数属性实现。html-webpack-plugin构造函数可以传入一个对象参数,用于指定配置选项,title属性就是用来设置html的标题。meta属性可以设置页面中的一些原数据标签。

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文件进行大量自定义,最好的做法就是在原代码中添加一个用于生成html文件的一个模板,然后让html-webpack-plugin插件根据模板生成页面。

对于模板中动态输出的内容可以使用loadsh模板语法的方式去输出。通过htmlWebpackPlugin.options属性去访问到插件的配置数据,htmlWebpackPlugin变量实际上是内部提供的变量,也可以通过另外的属性添加一些自定义的变量。

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

配置文件当中通过template属性指定所使用的模板文件。

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文件,通过filename指定输出的文件名,这个属性的默认值是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'
        }),
        new HtmlWebpackPlugin({
            filename: 'about.html'
        })
    ]
}

如果说需要创建多个页面,就可以在插件列表当中加入多个htmlWebpackPlugin实例的对象,每个对象就是用来负责生成一个页面文件的。

3. copy-webpack-plugin

项目中一般有一些不需要参与构建的静态文件最终也需要发布到线上,例如网站的favicon.ico,一般会把这一类文件统一放在项目根目录下的public目录中,希望webpack在打包时可以将他们复制到输出目录。对于这种需求可以借助copy-webpack-plugin实现。

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/**'
        ])
    ]
}

至此就了解了几个非常常用的插件,这些插件一般都适用于任何类型的项目,最好能仔细过一遍这些插件的官方说明,然后看看他们还可以有哪些特别的用法,做到心中有数。

除此之外社区当中还提供了成百上千的插件,并不需要全部认识,在有一些特殊的需求时,提炼需求中的一些关键词然后去github上去搜索他们,例如想要压缩输出的图片可以搜索imagemin webpack plugin

14. 开发一个插件

webpack的插件机制其实就是软件开发过程中最长见到的钩子机制。webpack要求的插件必须是一个函数,或者是一个包含apply方法的对象,一般会把插件定义为一个类型,在类型中定义apply方法。

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

这里的需求是希望这个插件可以用来去清除webpack打包生成的js中没必要的注释,有了这个需求需要明确这个任务的执行时机,也就是要把这个任务挂载到哪个钩子上。

需求是删除bundle.js中的注释,也就是说当bundle.js文件内容明确后才可以实施相应的动作,在webpack的官网的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方法来获取文件内容。

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文件将文件的内容得到然后通过正则的方式替换掉代码当中对应的注释,将替换的结果覆盖到原有的内容当中,要覆盖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每一行开头的注释就被移除掉了,以上就是实现移除webpack注释插件的过程,通过这个过程了解,插件是通过往webpack生命周期里面的一些钩子函数里面挂载任务函数来去实现的。如果需要深入了解插件机制,可能需要理解一些webpack底层的实现原理,通过去阅读源代码来了解他们。

15. 开发体验问题

在此之前已经了解了一些webpack的相关概念和一些基本的用法,但是以目前的状态去应对日常的开发工作还远远不够,编写源代码再通过webpack打包然后运行应用,最后刷新浏览器这种方式过于原始。如果实际的开发过程中还按照这种方式去使用必然会大大降低开发效率。

希望开发环境必须能够使用http的服务运行而不是以文件的形式预览,这样一来可以更加接近生产环境的状态,而且使用ajax之类的一些api也需要服务器环境。其次希望这个环境在修改源代码后webpack可以自动完成构建,然后浏览器可以及时的显示最新的结果,这样的话就可以大大的减少在开发过程中额外的重复操作。

最后还需要能够去提供sourceMap支持,运行过程中一旦出现了错误就根据错误的堆栈信息快速定位到源代码当中的位置,便于调试应用。

1. 自动编译

用命令行手动重复去使用webpack命令从而去得到最新的打包结果,这种办法特别的麻烦可以使用webpack-cli提供的watch工作模式解决这个问题。这种模式项目下的源文件会被监视,一旦这些文件发生变化,会自动重新运行打包任务。

用法非常简单,就是启动webpack时添加--watch参数。

yarn webpack --watch

可以再开启一个新的命令行终端以http的形式运行应用。

http-server ./dist

此时修改源代码webpack就会自动重新打包,可以刷新页面看到最新的页面结果。

2. 自动刷新浏览器

如果流浏览器能在编译过后自动去刷新,开发体验将会更好一些,browser-sync工具就会实现自动刷新的功能。

yarn add --global browser-sync

使用browser-sync启动http服务同时要监听dist文件下的文件变化。此时修改源文件保存过后浏览器会自动刷新然后显示最新的结果。

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

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

3. 开发服务器

Webpack Dev Server提供了一个开发服务器,并且将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起。这是一个高度集成的工具使用起来非常的简单。

yarn add webpack-dev-server --dev
yarn webpack-dev-server

运行命令内部会自动使用webpack打包我应用并且会启动一个http-server去运行打包结果。还会监听代码变化,一旦源文件发生变化就会自动打包,这一点和watch模式是一样的。

webpack-dev-server为了提高工作效率并没有将打包结果写入到磁盘中,是将打包结果暂时存放在内存中,内部的http-server也是从内存中把这些文件读出来发送给浏览器,这样一来就会减少很多磁盘不必要的读写操作,从而大大提高构建效率。

这个命令可以传入--open参数,用于自动唤起浏览器,打开运行地址。

yarn webpack-dev-server --open

如果有两块屏幕就可以把浏览器放到另外一块屏幕中,一边编码,一边及时预览开发环境。

4. 静态资源访问

静态文件需要作为开发服务器的资源被访问需要额外的去告诉webpack-dev-server,具体的方法就是在webpack的配置文件当中添加对应的配置。在配置对象当中添加dev-server的属性这个属性专门用来为webpack-dev-server指定相关的配置选项。

配置对象的contentBase属性用来指定静态资源路径,可以是一个字符串或者是一个数组,也就是说可以配置一个或者是多个路径,这里设置为public目录。

之前通过copy-webpack-plugin插件将public目录输出到了输出目录,正常所有输出的文件都应该可以直接被server也就是直接在浏览器端访问到。按道理来讲这些文件不需要再作为开发服务器的额外的资源路径了,但是在实际使用webpack的时候一般都会把copy-webpack-plugin这插件留在上线前的那次打包中。在平时的开发过程中一般不会去使用它,因为在开发过程中会重复执行打包任务。假设copy的文件比较多或者是比较大,每次执行插件的话打包过程中的开销就会比较大速度自然也就会降低了。

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'])
    ]
}

5. API代理

webpack-dev-server在启动服务时创建的是一个本地服务,访问地址一般为localhost:端口号,而最终上线过后应用一般又和API会部署到同源地址下面。这样的话就会有一个非常常见的问题,在实际生产环境当中可以直接访问API,开发环境中就会产生跨域请求问题。

解决这个问题最好的办法就是在开发服务器配置代理服务也就是把接口服务代理到本地的开发服务地址,webpack-dev-server支持通过配置的方式添加代理服务。

这里的目标就是将githubapi代理到本地的开发服务器当中。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/api/users就相当于请求了https://api.github.com/api/users,意思是请求的路径是什么,最终代理的这个地址路径是完全一致的。

实际上在api.github.com/users中并没有/api/users所以对于代理路径中的/api需要去掉,可以添加pathRewrite属性,来去实现代理路径的重写。重写规则就是把路径中以/api开头的这段字符串替换为空字符串,pathRewrite会以正则的方式替换请求的路径。

还需要设置changeOrigin属性为true,请求的过程中会带一个主机名这个主机名默认情况下使用的是用户在浏览器端发起请求的这个主机名也就是localhost:8080。一般情况下服务器那头是要根据主机名去判断这个请求是属于哪个网站从而把这个请求指派到对应的网站。localhost:8080对于github的服务器来说是不认识的,所以这里需要changeOrigin去修改修改。changeOrigin=true会以代理请求的主机名去请求。请求github这个地址真正请求的应该是api.github.com这样地址,所以主机名会保持原有状态。

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

针对于changeOrigin可能不会特别清楚,这是因为在http里面有一些相关的知识点可能之前没有了解过,可以查一下host也就是主机名相关的概念就可以理解了。

16. Source Map

通过构建编译之类的操作可以将开发阶段的源代码转换为能够在生产环境中运行的代码,但是这种进步的同时也意味着在实际生产环境中运行的代码与开发阶段所编写的代码之间会有很大的差异。在这种情况下如果需要调试应用,又或是运行应用的过程中出现了意料之外的错误,将无从下手。Source Map就是解决这一类问题最好的办法。

Source Map是用来映射转换过后的代码与源代码的关系,一段转换过后的代码通过转换过程中生成的这个source map文件可以逆向得到源代码。目前很多第三方库中都会有一个.map后缀的source map文件,这是一个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特性只是帮助开发者更容易调试和定位错误所以对生产环境其实没有什么太大的意义。

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

在浏览器中如果打开了开发人员工具加载到的这个js文件最后发现这行注释就会自动请求这个source map文件。然后根据这个文件的内容逆向解析出对应的源代码以便于调试,同时因为有了映射的关系所以说源代码当中如果说出现了错误也很容易定位到源代码当中对应的位置。

1. 配置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,打包完成过后生成的dist目录可以发现生成了一个对应的bundle.js.map文件。而且bundle.js文件的最后也通过注释的方式引入了这个source-map文件。

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

截止到目前webpacksource map的风格支持了12种,每种方式所生成的source map 效果以及生成source map的速度,都是不一样的。很简单也很明显的一个道理就是效果最好的生成速度也就会最慢。而速度最快的生成出来的这个source map文件也就没有什么效果。

2. eval

evaljs的函数,可以用来运行字符串中的js代码。

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

默认情况下这段代码会运行在一个临时的虚拟机环境中。可以通过source url声明这段代码所处的文件路径。比如添加一段注释内容就是//# 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没有什么太大的关系,所以说他的构建速度也就是最快的效果也就很简单,只能定位源代码文件的名称不知道具体的行列信息。

3. devtool

main.js当中故意加入了一个运行时的错误console.log111

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的配置对象可以是一个数组,数组中给的每个元素就是一个单独的打包配置。这样一来就可以在一次打包过程中同时执行多个打包任务。可以通过遍历为每一种模式单独去创建一个打包配置,这样的话就可以在一次打包中同时生成所有模式下的不同结果,方便对比使用。

每个配置项中先定义devtool属性,属性的值就是遍历的名称。将mode设置为none确保webpack内部不会去做额外的处理,紧接着设置任务的打包入口,以及输出文件的名称。这里将输出文件的名称设置为模式名称命名的js文件。再下面为js模块配置babel-loader,目的是接下来对比中能够辨别其中一类模式的差异。最后再配置一个html-webpack-plugin,也就是为每个打包任务生成一个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。有了这些不同模式下的打包结果就可以一个一个仔细对比。

1. eval。

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

2. eval-source-map

这个模式同样也是使用eval函数执行模块代码,不同的是除了可以定位错误出现的文件。还可以定位到具体的行列信息,这种模式下相比于eval生成了source-map

3. cheap-eval-source-map

这个模式就是在eval-source-map的基础之上加了一个cheap,就是阉割版的source-map。虽然也生成了source-map,但这种模式下的source-map只能定位到行而没有列的信息。少了一点效果所以生成速度自然就会快很多。

4. cheap-module-eval-source-map

这其是cheap-eval-source-map模式基础上多了一个modulecheap-module-eval-source-map定位源代码跟编写的源代码是一模一样的。而cheap-eval-source-map显示的是经过babel转换过后的结果。如果想要和手写代码一样的源代码,需要选择cheap-module-eval-source-map这种模式。

5. cheap-source-map

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

6. inline-source-map

和普通的source-map效果上是一样的,只不过source-map的模式下文件是以物理文件的方式存在,而inline-source-map使用的是dataurl的方式去将source-mapdataurl嵌入到代码中。之前遇到的eval-source-map其实也是使用这种行内的方式把source-map嵌入进来,会导致这个代码的体积会变大很多。

7. hidden-source-map

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

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

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

8. nosources-source-map

这个模式下能看到错误出现的位置,但是点击错误信息是看不到源代码的。nosource指的就是没有源代码,但是提供了行列信息,这样结合编写的源代码还是可以找到错误出现的位置。只是在开发工具当中看不到源代码而已。这是为了在生产环境中保护源代码不会被暴露的一种情况。

4. 选择 Source Map

虽然webpack支持各种各样的SourceMap模式,但是一般应用开发时也只会用到其中的几种。

1. 开发模式

在开发环境建议选择cheap-module-eval-source-map,一般编写代码的风格要求每一行代码不会超过80个字符,能够定位到行也就够了因为每一行里面最多也就80个字符,很容易找到对应的位置。第二就是使用框架的情况会比较多,以reactvue来说,无论是使用jsx还是vue的单文件组件,loader转换过后的代码和转换之前都会有很大的差别,这里需要调试转换之前的源代码,而不是转换过后的,所以要选择有module的方式。

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

2. 生产模式

生产环境的打包交易选择选择none也就是不生成source-mapsource-map会暴露源代码到生产环境,这样的话但凡有一点技术的人都可以很容易复原项目中绝大多数的源代码,这个点是要注意的。

其次调试和找错误这些都应该是开发阶段的事情,应该在开发阶段就尽可能把所有的问题和隐患都找出来,而不是到了生产环境让全民去公测。

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

## 17. HMR

实际开发中自动刷新并没有想象中那么好用因为每次修改完代码,webpack监视到文件变化就会自动打包,然后自动刷新到浏览器,一旦页面整体刷新,那页面中之前的任何操作状态都会丢失,有些人一般会有一些小办法,例如可以在代码中写死一个文本到编辑器中。这样的话即便页面刷新也不会有丢失的情况,又或是通过一些额外的代码,把内容先保存到临时存储中,然后刷新过后再取回来。

这些都是好办法但是又都不是特别的好,因为这些都是典型的有洞补洞的操作,并不能根治页面刷新过后导致的页面数据丢失的问题。而且这些方法都需要编写一些跟业务本身无关的代码,更好的办法自然是能够在页面不刷新的这种情况下代码也可以及时的更新。针对这样的需求webpack给出了解决方案。

HRM(Hot Module Replacement)翻译过来叫做模块热替换或者叫模块热更新。计算机行业经常听到一个叫做热拔插的名词,指的就是可以在正在运行的机器上随时插拔设备而机器的运行状态不会受插拔设备的影响。例如电脑设备上的USB端口。

webpack中的模块热替换指的就是可以在应用程序运行的过程中实时的替换掉应用中的某个模块。而应用的运行状态不会因此改变。HMRwebpack中最强大的特性之一,同时也是最受欢迎的特性。

1. 开启 HMR

HMR已经集成在了webpack-dev-server工具中,使用这个特性需要运行webpack-dev-server命令时通过--hot参数开启,也可以在配置文件中添加对应的配置来开启。

打开配置文件需要配置的地方有两个,第一个将dev-server中的hot属性设置为true。第二是载入一个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()
    ]
}

回到开发工具当中修改样式文件,保存过后样式模块就以热更新的方式直接作用到页面中了。但是修改js模块页面还是自动刷新的并没有热更新的体验。

样式文件在style-loader里面就已经自动处理了热更新,不需要额外做手动的操作。而js文件比较复杂,在一个模块中导出的可能是对象,也可能是字符串,还有可能是函数,导出的成员在使用上可能各不相同的。所以webpack对毫无规律的js模块,并不知道如何处理更新过后的模块,也就没有办法实现通用的替换方案。webpack中的HMR需要手动通过代码处理当js模块更新过后需要如何把更新后的模块替换到运行的页面当中。

可能对使用过vue-clicreate-react-app脚手架工具的人来说,会觉得项目中并没有手动处理js模块的更新,代码照样可以做热替换,这是因为使用框架开发时,项目中每个文件有规律,框架提供的是一些规则,例如react模块要求每一个模块必须要导出一个函数,或者是导出一个类。有了这个规律就可能会有通用的替换办法,例如每一个文件导出的都是一个函数的话那就自动把这个函数执行一下。这些工具内部都已经提供好了通用的HMR替换模块,不需要自己手动处理。

综上还需要自己手动处理当js模块更新过后需要做的事情。

Hot-module-replacement-plugin为js提供了一套用于处理HMRapi,需要在自己的代码中使用这套api来去处理当某个模块更新后应该如何替换。

打开入口文件,这个文中使用了导入的模块,一但导入的模块更新就必须重新使用这些模块。

module对象有个hot的对象属性是HMR API的核心对象,提供了一个accept方法用于注册某个模块更新过后的处理函数,方法的第一个参数接收的是依赖模块的路径,第二个参数是更新过后的处理函数。这里注册一下当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模块代码的时候浏览器的控制台中就会打印消息,而且也不会自动刷新了,也就是说一旦这个模块的更新被手动的处理了,就不会触发自动刷新。

editor模块导出的是一个函数,而且这这个函数是创建界面的元素,一但这个函数更新了,界面元素也应该被重新创建。所以这里先直接移除原来的元素,然后再调用createEditor创建新的元素追加到页面中。

这里还需要保留之前的状态,在更新之后将状态回填进去,因为这里用的是一个可编辑元素,可以通过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;
});

这就是针对于js模块热替换的一个处理过程,注意这不是一个通用的方式,这只适用于当前的editor.js模块。通过这个过程能够发现为什么webpackHMR需要自己处理js模块的热更新,因为不同的模块有不同的逻辑,不同的业务逻辑又导致处理过程肯定也是不同的,webpack并没有办法提供一个通用的替换方案。

图片模块的热替换逻辑就简单的多,同样通过module.hot.accept方法注册处理函数,在函数中只需要将图片元素的src设置为新的图片路径就可以了。

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

2. 注意事项

如果处理热替换的代码有错误是不容易发现的,错误结果会导致页面自动刷新,自动刷新过后页面中的错误信息也会被清除了,这样一来就不容易发现到底是哪里出错了。

这种情况推荐使用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插件提供的,没有开启这个插件也就没有这个对象。解决的办法非常简单,在业务代码应该先判断是否存在module.hot对象再去使用它。

可能会有个疑问,代码当中写了很多与业务功能本身无关的代码会不会有影响。webpack是处理过这个问题的,打包过后生成的bundle.js文件中处理热替换的代码都会被移除掉了,只剩下一个if(false) {}的空判断。这种没有意义的判断在代码压缩过后也会自动去掉,所以说不会影响生产环境中的运行状态。

18. 不同环境下的配置

创建不同的环境配置的方式主要有两种。第一种是在配置文件中添加相应的判断条件,然后根据环境导出不同的配置。第二种是为不同的环境对应一个配置文件,确保每一个环境下面都会有一个对应的配置文件。

首先来看配置文件中添加判断的方式,webpack的配置文件支持导出一个函数,在函数中返回所需要的配置对象。函数接收两个参数,第一个是env也就是通过cli传递的环境名参数,第二个是argv,指运行cli过程中所传递的所有参数。

可以将开发模式配置定义在config变量中,再去判断一下env是不是等于production,这里约定生产环境的env就是production。如果是生产环境的就将mode属性的字段设置为production,然后再将devtool设置为false,再添加cleanWebpackPluginCopyWebpackPlugin这两个插件。

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;
}
yarn webpack

yarn webpack --env producton

通过判断环境名参数返回不同的配置对象这种方式只适用于中小型项目,因为一旦项目变得复杂配置文件也会变得复杂起来。所以对于大型的项目还是建议使用不同环境对应不同配置文件的方式实现,一般这种方式至少会有三个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.jswebpack.prod.js分别去用来为开发和生产定义特殊的配置。

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

yarn webpack-merge --dev
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文件当也可以通过这样一个方式实现一些额外的配置这里就不重复了。

运行webpack时需要通过--config参数指定所使用的配置文件。

yarn webpack --config webpack.prod.js

19. DefinePlugin

webpack4x中新增的production模式内部自动开启了很多通用的优化功能。第一个是一个叫做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其实就是把注入成员的值直接替换到了代码中。define-plugin的设计并只是用来替换数据,所传递的字符串实际上是一个代码片段,也就是一段符合js语法的代码。

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')
        })
    ]
}

20. 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文件中导入了components,只是导入了components当中的button这成员。这就会导致代码中很多的地方用不到,这些用不到的地方对于打包结果是冗余的,Tree-shaking的作用就是移除这些冗余的代码。

import { Button } from './components'

document.body.appendChild(Button())
yarn webpack --mode production

打包完成bundle.js中可以看到冗余的代码根本就没有输出,tree-shaking这个功能会在生产模式下自动开启。

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

回到命令行终端运行webpack打包,不过这一次使用none也就是不开启任何内置功能和插件。打包完成过后输出的bundle.js文件中link函数和heading函数虽然外部并没有使用,但仍然是导出了。

yarn webpack

很明显这些导出是没有意义的,可以借助一些优化功能去掉,打开webpack的配置文件添加optimization的属性。这个属性是集中配置webpack内部的一些优化功能。可以先开启usedExports选项,表示在输出结果中只导出那些外部使用了的成员。

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

此时components模块所对应的函数就不会导出linkheading两个函数了,可以开启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就是负责把这些枯树叶树枝全都摇下来。

普通的打包结果是将每一个模块放在一个单独的函数中,如果模块很多也就意味着在输出结果中会有很多模块函数。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-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的部分会被转换成Commonjswebpack再去打包时拿到的代码是以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也就表示需要强制使用babelES 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也是没有办法正常工作的。

21. sideEffects

weboack4中还新增了一个叫做sideEffects的新特性。允许通过配置的方式标识代码是否有副作用,从而为tree-shaking提供更大的压缩空间。副作用是指模块执行时候除了导出成员是否还做了一些其他的事情,这样一个特性一般只有在开发npm模块时才会用到。

因为官网中把sideEffects的介绍跟tree-shaking混到了一起,所以很多人误认为他俩是因果关系。其实他俩真的没有那么大的关系。

这里设计一下能够让side Effects发挥效果的一个场景,基于上面的案例把components拆分出了多个组件文件。然后在index.js中集中导出便于外界导入。回到入口文件中导入components中的button成员。

这样就会出现一个问题,因为在这载入的是components目录下的index.jsindex.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
}

打包过后查看bundle.js文件,此时那些没有用到的模块就不再会被打包进来了,这就是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.jsonsideEffectsfalse改成一个数组。然后添加extend.js文件路径,还有global.css文件的路径,当然了这里可以使用路径通配符的方式来去配置。*.css

{
    "sideEffects": [
        "./src/extend.js",
        "./src/global.css"
    ]
}

22. 代码分割

合理的打包方案应该是把打包结果按照一定的规则分离到多个bundle当中,根据应用的运行按需加载这些模块。这样的话就可以大大提高应用的响应速度以及运行效率。为了解决这样的问题webpack支持分包的功能,通过把模块按照所设计的规则打包到不同的bundle当中,从而提高应用的响应速度。

目前webpack实现分包的方式主要有两种,第一种是根据业务去配置不同的打包入口,也就是会有多个打包入口同时打包,这时候就会输出多个打包结果。第二种是采用ES Module的动态导入功能实现模块的按需加载,这个时候webpack会自动的把需要动态导入的这个模块单独的输出到一个bundle中。

1. 多入口打包

多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是一个页面对应一个打包入口,对于不同页面之间的公共部分,再去提取到公共的结果当中。这种方式使用起来非常简单。

这里有两个页面分别是indexalbumindex.js负责实现index页面所有功能,album.js负责实现album页面所有功能,global.cssfetch.js都是公共部分。

一般配置文件中的entry属性只会配置一个文件名路径,也就是说只会配置一个打包入口,如果我们配置多个入口的话,可以把entry定义成一个对象,对象中一个属性就是一路入口,那我们属性名就是这个入口的名称,值就是这个入口所对应的文件路径。

一但这里配置为多入口,输出的文件名也需要修改,两个入口也就意味着会有两个打包结果,可以为filename属性添加[name]动态输出文件名最终就会被替换成入口的名称indexalbum

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所使用的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']
    })
]

2. 提取公共模块

多入口打包本身非常容易理解也非常容易使用,但是也存在一个小问题,不同的打包入口中一定会有一些公共的部分。例如index入口和album入口中就共同使用了global.cssfetch.js这两个公共的模块。所以需要把这些公共的模块提取到一个单独的bundle当中。webpack中实现公共模块提取的方式非常简单,只需要在优化配置中开启splitChunks的功能就可以了。

配置文件中在optimization中添加splitChunks属性,这个属性需要配置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文件,在这个文件中就是indexalbum这两个入口公共的模块部分了。

3. 动态导入

按需加载是开发浏览器应用中非常常见的需求,一般常说的按需加载指的是加载数据。那这里所说的按需加载指的是应用运行过程中需要某个模块时才去加载这个模块,这种方式可以极大地节省我们的带宽和流量。

webpack中支持使用动态导入的方式实现模块的按需加载,所有动态导入的模块都会自动被提取到单独的bundle中从而实现分包,相比于多入口的这种方式动态导入他更为灵活,可以通过代码的逻辑控制需不需要加载某个模块,或者是什么时候加载某个模块。分包的目的就是要让模块实现按需加载来提高应用的响应速度。

这里设计好了一个可以发挥按需加载作用的场景,在这个页面的主体区域,如果访问的是文章页的话得到的就是一个文章列表,如果访问的是相册页面显示的就是相册列表。文章列表所对应的是post组件,相册列表对应的是album组件,在打包入口中同时导入这两个模块。这里的逻辑是当锚点发生变化时,根据锚点的值决定显示哪个组件。

动态导入使用的就是ES Module标准当中的动态导入,需要动态导入的地方通过import函数导入指定的路径。这个方法返回的是一个Promise,在Promisethen方法中就可以拿到模块对象。

// 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目录会多出三个js文件,这三个js文件实际上就是由动态导入自动分包所产生的。这三个文件分别是刚刚导入的两个模块以及这两个模板当中公共的部分所提取出来的bundle。整个过程无需配置任何一个地方只需要按照ES Module动态导入成员的方式去导入模块就可以了webpack内部会自动处理分包和按需加载。

4. 魔法注释

默认通过动态导入产生的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);

如果chunkName是相同的,那相同的chunkName最终就会被打包到一起。

// 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);

23. 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嵌入到代码当中减少了一次请求效果可能会更好。

24. 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选项统一控制。

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()
        ]
    }
})

25. 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'
            }),
        ]
    }
})

其次是chunkhash,这个hashchunk级别的,只要是同一路的打包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分别是postsalbum。样式文件是从代码中单独提取出来的,所以他并不是单独的chunkmainpostsalbum三者chunkhash各不相同。

css和所对应的js文件他们二者的chunkhash是完全一样的因为他们是同一路。当index发生修改重新打包会发现,只有main.bundle文件名发生了变化,其他的文件都没有变。在posts.js文件中做一些修改,输出的jscss都会发生变化,因为他们是属于同一个chunk

至于main.bundle也发生变化的原因是posts所生成的js文件和css文件的文件名发生了变化,入口文件中引入他们的路径也会发生变化所以mian.chunk算是被动的改变。

contenthash是文件级别的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'
            }),
        ]
    }
})

那相比于前两者contenthash是解决缓存问题最好的方式,因为他精确的定位到了文件级别的hash,只有当这个文件发生了变化才有可能更新文件名,实际上是最适合解决缓存问题的。

webpack允许指定hash的长度,可以在占位符里面通过冒号跟一个数组[:8]的方式指定hash的长度。

new MiniCssExtractPlugin({
    filename: '[name]-[contenthash:8].bundle.css'
})

总的来说如果是控制缓存8位的contenthash应该是最好的选择了。

26. 使用技巧

polyfill会把js注入到全局变量中,污染全局,适合在项目中,不适合在插件和工具库中使用。plugin-transform-runtime以闭包的方式注入不会影响全局,适合工具库。

npm i @babel/plugin-transform-runtime --save;
npm i @babel/runtime --save;
{
    loader: "babel-loader",
    options: {
    plugin: ["@babel/plugin-transform-runtime", {
        corejs: 
    }]
    }
}

27. 手写打包原理

出家门和口岸buidile.js文件,引入fs模块用于处理文件,创建modules函数用于分析模块内容。

const fs = require('fs');
const modules = function(entry) { // 接收一个入口文件
    const content = fs.readFileSync(entry, 'utf-8');
    console.log(content);
}

modules("./src/index.js");

这里分析的是./src/index.js文件,可以打印其中的内容看下结果、

首先需要拿到文件中的依赖模块,不推荐使用字符串截取,引入的模块名越多就会越麻烦,字符串截取并不灵活,这里推荐使用@babel/parserbabel的工具,来分析内部的语法,返回的是一个ast抽象语法树。

npm install @babel/parser  --save
const fs = require('fs');
const parser = require('@babel/parser');

const modules = filename => {
    const content = fs.readFileSync(filename, 'utf-8');
    const Ast = parser.parse(content, {
        sourceType: "module"
    });
    console.log(Ast.program.body);
}

modules('./index.js');

接下来可以根据body里面的分析结果,遍历出所有引入的模块,babel推荐@babel/traverse模块来处理。

const fs = require('fs');
const path = require('path');
const traverse = require("@babel/traverse").default;
const parser = require('@babel/parser');

const modules = filename => {
    const content = fs.readFileSync(filename, 'utf-8');
    const Ast = parser.parse(content, {
        sourceType: "module"
    });
    const dependencies = {};
    traverse(Ast, {
        ImportDeclaration({node}) {
            const newfile = './' + path.join(path.dirname(filename), node.source.value);
            // dependencies.push(node.source.value);
            dependencies[node.source.value] = newfile;
        }
    });
    console.log(dependencies);
}

modules('./index.js');

可以将代码处理成浏览器可运行的代码,需要借助@babel/core@babel/preset-envast语法树转换成合适的代码。

const babel = require('@babel/core');
const { code } = babel.transformFromAst(Ast, null, {
    presets: ['@babel/preset-env']
});
return {
    entry,
    dependencies,
    code
}

大概思路基本清晰了,下面整体安排一下。使用modules函数引入filename文件,通过fs.readFileSync读取文件内容,将文件内容使用@babel/parser转换成ast,通过traverse遍历出依赖的模块存放在dependencies对象中,最后将ast转换为ES5的代码。

const fs = require('fs');
const path = require('path');
const traverse = require("@babel/traverse").default;
const parser = require('@babel/parser');
const babel = require('@babel/core');

const modules = filename => {
    const content = fs.readFileSync(filename, 'utf-8');
    const Ast = parser.parse(content, {
        sourceType: "module"
    });
    const dependencies = {};
    traverse(Ast, {
        ImportDeclaration({node}) {
            const newfile = './' + path.join(path.dirname(filename), node.source.value);
            // dependencies.push(node.source.value);
            dependencies[node.source.value] = newfile;
        }
    });

    const { code } = babel.transformFromAst(Ast, null, {
        presets: ['@babel/preset-env']
    });
    return {
        filename,
        dependencies,
        code
    }
}

const info = modules('./index.js');

把项目中的所有依赖都分析出来。allModules函数运行的时候会调用modules函数获取到源代码和依赖的模块,通过遍历将依赖的模块依次再传入modules函数,最终可以得到虽有的模块源代码以及他们之间的依赖关系。

const allModules(filename) {
    const entryModule = modules(filename);
    // 使用队列存储所有
    const yilaiArr = [entryModule];
    for (let i = 0; i < yilaiArr.length; i++) {
        const item = yilaiArr[i];
        const { dependencies } = item;
        if (dependencies) {
            for (ket k in dependencies) {
                yilaiArr.push(modules(dependencies[j]));
            }
        }
    }
    const obj = {};
    yilaiArr.forEach(item => {
        obj[item.filename] = {
            dependencies: item.dependencies,
            code: item.code
        }
    })
    return obj;
}

在最外层getCode函数中传入入口文件,getCode函数会将文件路径传递给allModules,从而获取所有模块和模块源代码,最后在getCode函数中返回一段可执行的js用于代码启动。

const getCode = function(filename) {
    consyt bundleInfo = JSON.stringfiy(allModules(filename));
    return `
        (function(all)) {
            function require(module) {
                function localPath(path) {
                    return require(all[module].dependencies[path]);
                }
                const exports = {};
                (function(require, code){
                    eval(code);
                })(localPath, exports, all[module].code);
                return exports;
            }
            // 需要用字符串包裹一下参数
            require('${filename}');
        })(${bundleInfo})
    `;
}
const info = getCode('./src/index.js');

转载须知

如转载必须标明文章出处文章名称文章作者,格式如下:

转自:【致前端 - zhiqianduan.com】 webpack4x打包工具  "隐冬"
请输入评论...