Webpack实战:入门、进阶与调优 学习笔记

933 阅读46分钟

本文内容来自 《Webpack实战:入门、进阶与调优》 一书。如果喜欢,请购买正版书,本文仅供个人学习。

一、简介

何为Webpack

Webpack是一个开源的JavaScript模块打包工具,其最核心的功能是解决模块之间的依赖,把各个模块按照特定的规则和顺序组织在一起,最终合并为一个JS文件(有时会有多个,这里讨论的只是最基本的情况)。这个过程就叫作模块打包

目前社区中比较流行的模块打包工具有Webpack、Rollup、Parcel等。

何为模块

在设计程序结构时,把所有代码都堆到一起是非常糟糕的做法。更好的组织方式是按照特定的功能将其拆分为多个代码段,每个代码段实现一个特定的目的。你可以对其进行独立的设计、开发和测试,最终通过接口来将它们组合在一起。这就是基本的模块化思想。

为什么JS会要模块化

随着技术的发展,JavaScript已经不仅仅用来实现简单的表单提交等功能,引入多个script文件到页面中逐渐成为一种常态,但我们发现这种做法有很多缺点

  • 需要手动维护JavaScript的加载顺序。页面的多个script之间通常会有依赖关系,但由于这种依赖关系是隐式的,除了添加注释以外很难清晰地指明谁依赖了谁,这样当页面中加载的文件过多时就很容易出现问题。

  • 每一个script标签都意味着需要向服务器请求一次静态资源,在HTTP 2还没出现的时期,建立连接的成本是很高的,过多的请求会严重拖慢网页的渲染速度

  • 在每个script标签中,顶层作用域即全局作用域,如果没有任何处理而直接在代码中进行变量或函数声明,就会造成全局作用域的污染

模块化则解决了上述的所有问题。

  • 通过导入和导出语句我们可以清晰地看到模块间的依赖关系

  • 模块可以借助工具来进行打包,在页面中只需要加载合并后的资源文件,减少了网络开销

  • 多个模块之间的作用域是隔离的,彼此不会有命名冲突。

webpack-dev-server

npm install webpack-dev-server -D

webpack-dev-server可以看作一个服务者,它的主要工作就是接收浏览器的请求,然后将资源返回。概括起来,它的作用就是启动一个本地服务,可以处理打包资源与静态文件的请求。

webpack-dev-server的两大职能:

  • 令Webpack进行模块打包,并处理打包结果的资源请求。
  • 作为普通的Web Server,处理静态资源文件请求。

直接用Webpack开发和使用webpack-dev-server有一个很大的区别,前者每次都会生成bundle.js,而webpack-dev-server只是将打包结果放在内存中,并不会写入实际的bundle.js,在每次webpack-dev-server接收到请求时都只是将内存中的打包结果返回给浏览器。

webpack-dev-server还有一项很便捷的特性就是live-reloading(自动刷新)

二、模块打包

模块之于程序,就如同细胞之于生物体,是具有特定功能的组成单元。不同的模块负责不同的工作,它们以某种方式联系在一起,共同保证程序的正常运转。

COMMONJS

CommonJS最初只为服务端而设计,直到有了Browserify——一个运行在Node.js环境下的模块打包工具,它可以将CommonJS模块打包为浏览器可以运行的单个文件。这意味着客户端的代码也可以遵循CommonJS标准来编写了。

导入

每个模块是拥有各自的作用域的。

// calculator.js
var name = 'calculator.js';
// index.js
var name= 'index.js';
require('./calculator.js');
console.log(name);  // index.js

当我们require一个模块时会有两种情况:

  • require的模块是第一次被加载。这时会首先执行该模块,然后导出内容。
  • require的模块曾被加载过。这时该模块的代码不会再次执行,而是直接导出上次执行后得到的结果
// calculator.js
console.log('running calculator.js');
module.exports = {
    name: 'calculator.js',
    add: function(a, b) {
        return a + b;
    }
}
// index.js
const add = require('./calculator.js').add;
const sum = add(2, 3);
connsole.log('sum:', sum);
const moduleName = require('./calculator.js').name;
console.log('end');

执行结果:
running calculator.js
sum: 5
end

有时我们加载一个模块,不需要获取其导出的内容,只是想要通过执行它而产生某种作用,比如把它的接口挂在全局对象上,此时直接使用require即可。

另外,require函数可以接收表达式,借助这个特性我们可以动态地指定模块加载路径。

导出

module.exports = {}
// 等价,不能混用
exports.name = ''
exports.add = function() {}

要注意导出语句不代表模块的末尾,在module.exports或exports后面的代码依旧会照常执行。

ES6 Module

ES6 Module会自动采用严格模式,这在ES5(ECMAScript 5.0)中是一个可选项。如果将原本是CommonJS的模块或任何未开启严格模式的代码改写为ES6 Module要注意这点。

COMMONJS 和 ES6 Module 的区别

1. 动态和静态

CommonJS与ES6 Module最本质的区别在于前者对模块依赖的解决是“动态的”,而后者是“静态的”。在这里“动态”的含义是,模块依赖关系的建立发生在代码运行阶段;而“静态”则是模块依赖关系的建立发生在代码编译阶段

ES6 Module的导入、导出语句都是声明式的,它不支持导入的路径是一个表达式,并且导入、导出语句必须位于模块的顶层作用域(比如不能放在if语句中)。 ES6 Module是一种静态的模块结构,在ES6代码的编译阶段就可以分析出模块的依赖关系。它相比于CommonJS来说具备以下几点优势:

  • 死代码检测和排除。我们可以用静态分析工具检测出哪些模块没有被调用过。比如,在引入工具类库时,工程中往往只用到了其中一部分组件或接口,但有可能会将其代码完整地加载进来。未被调用到的模块代码永远不会被执行,也就成为了死代码。通过静态分析可以在打包时去掉这些未曾使用过的模块,以减小打包资源体积。

  • 模块变量类型检查。JavaScript属于动态类型语言,不会在代码执行前检查类型错误(比如对一个字符串类型的值进行函数调用)。ES6 Module的静态模块结构有助于确保模块之间传递的值或接口类型是正确的。

  • 编译器优化。在CommonJS等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,而ES6 Module支持直接导入变量,减少了引用层级,程序效率更高。

2. 值拷贝和动态映射

在导入一个模块时,对于CommonJS来说获取的是一份导出值的拷贝;而在ES6Module中则是值的动态映射,并且这个映射是只读的

COMMONJS的值拷贝

// calculator.js
var count = 0;
module.exports = {
    count: count,
    add: function(a, b) {
        count += 1;
        return a + b;
    }
}
// index.js
var count = require('./calculator.js').count;
var add = require('./calculator.js').add;
console.log(count);  // 0 (这里的count是对calculator.js中 count 值的拷贝)
add(2, 3);  // 改变calculator.js中的count
console.log(count);  // 0 (calculator.js中变量值的改变不会对这里的拷贝值造成影响)
count += 1;
console.log(count);  // 1 (拷贝的值可以改变)

index.js中的count是对calculator.js中count的一份值拷贝,因此在调用add函数时,虽然更改了原本calculator.js中count的值,但是并不会对index.js中导入时创建的副本造成影响。

另一方面,在CommonJS中允许对导入的值进行更改。我们可以在index.js更改count和add,将其赋予新值。同样,由于是值的拷贝,这些操作不会影响calculator.js本身。

ES6 Module的动态映射

// calculator.js
let count = 0;
const add = function(a, b) {
    count += 1;
    return a + b;
}
export { count, add };

// index.js
import { count, add } from './calculator.js';
console.log(count);  // 0 (对calculator.js中 count 值的映射)
add(2, 3);
console.log(count);  // 1 (实时反映calculator.js中 count 值的变化)
// count += 1;       // 不可改变,会抛出SyntaxError: "count" is read-only

ES6 Module中导入的变量其实是对原有值的动态映射。index.js中的count是对calculator.js中的count值的实时反映,当我们通过调用add函数更改了calculator.js中count值时,index.js中count的值也随之变化。

我们不可以对ES6 Module导入的变量进行更改,可以将这种映射关系理解为一面镜子,从镜子里我们可以实时观察到原有的事物,但是并不可以操纵镜子中的影像。

3. 循环依赖

循环依赖是指模块A依赖于模块B,同时模块B依赖于模块A。

ES6 Module的循环依赖:
// a.js
import { foo } from './b.js'
foo();

// b.js
import { bar } from './a.js';
bar();

简单来说,A和B两个模块之间是否存在直接的循环依赖关系是很容易被发现的。但实际情况往往是A依赖于B,B依赖于C,C依赖于D,最后绕了一大圈,D又依赖于A。当中间模块太多时就很难发现A和B之间存在着隐式的循环依赖。

CommonJS 循环依赖的例子:
// foo.js
const bar = require('./bar.js');
console.log('value of bar:', bar);
module.exports = 'This is foo.js';

// bar.js
const foo = require('./foo.js');
console.log('value of foo:', foo);
module.exports = 'This is bar.js';

// index.js
require('./foo.js');

在这里,index.js是执行入口,它加载了foo.js,foo.js和bar.js之间存在循环依赖。让我们观察foo.js和bar.js中的代码,理想状态下我们希望二者都能导入正确的值,并在控制台上输出。

value of foo: This is foo.js
value of bar: This is bar.js

而当我们运行上面的代码时,实际输出却是:

value of foo: {}
value of bar: This is bar.js

为什么foo的值会是一个空对象呢?让我们从头梳理一下代码的实际执行顺序。

1> index.js导入了foo.js,此时开始执行foo.js中的代码。

2> foo.js的第1句导入了bar.js,这时foo.js不会继续向下执行,而是进入了bar.js内部。

3> 在bar.js中又对foo.js进行了require,这里产生了循环依赖。需要注意的是,执行权并不会再交回foo.js,而是直接取其导出值,也就是module.exports。但由于foo.js未执行完毕,导出值在这时为默认的空对象,因此当bar.js执行到打印语句时,我们看到控制台中的value of foo就是一个空对象。

4> bar.js执行完毕,将执行权交回foo.js。

5> foo.js从require语句继续向下执行,在控制台打印出value of bar(这个值是正确的),整个流程结束。

因此在CommonJS中,若遇到循环依赖我们没有办法得到预想中的结果。

使用ES6 Module的方式重写上面的例子:
// foo.js
import bar from './bar.js';
console.log('value of bar:', bar);
export default 'This is foo.js';

// bar.js
import foo from './foo.js';
connsole.log('value of foo:', foo);
export default 'This is bar.js';

// indnex.js
import foo from './foo.js';

执行结果如下:

value of foo: undefinned
value of bar: This is bar.js

很遗憾,在bar.js中同样无法得到foo.js正确的导出值,只不过和CommonJS默认导出一个空对象不同,这里获取到的是undefined

上面我们谈到,在导入一个模块时,CommonJS获取到的是值的拷贝,ES6 Module则是动态映射,那么我们能否利用ES6 Module的特性使其支持循环依赖呢?请看下面这个例子:

// index.js
import foo from './foo.js';
foo('index.js');

// foo.js
import bar from './bar.js';
function foo(invoker) {
    console.log(invoker + ' invokes foo.js');
    bar('foo.js');
}
export default foo;

// bar.js
import foo from './foo.js';
let invoked = false;
function bar(invoker) {
    if (!invoked) {  // 通过invoked变量阻止了一直循环调用
        invoked = true;
        console.log(invoker + ' invokes bar.js');
        foo('bar.js');
    }
}
export default bar;

上面代码的执行结果如下:

index.js invokes foo.js
foo.js invokes bar.js
bar.js invokes foo.js

可以看到,foo.js和bar.js这一对循环依赖的模块均获取到了正确的导出值。下面让我们分析一下代码的执行过程。

1)index.js作为入口导入了foo.js,此时开始执行foo.js中的代码。

2)从foo.js导入了bar.js,执行权交给bar.js。

3)在bar.js中一直执行到其结束,完成bar函数的定义。注意,此时由于foo.js还没执行完,foo的值现在仍然是undefined。

4)执行权回到foo.js继续执行直到其结束,完成foo函数的定义。由于ES6 Module动态映射的特性,此时在bar.js中foo的值已经从undefined成为了我们定义的函数,这是与CommonJS在解决循环依赖时的本质区别,CommonJS中导入的是值的拷贝,不会随着被夹在模块中原有值的变化而变化。

  1. 执行权回到index.js并调用foo函数,此时会依次执行foo→bar→foo,并在控制台打出正确的值。

由上面的例子可以看出,ES6 Module的特性使其可以更好地支持循环依赖,只是需要由开发者来保证当导入的值被使用时已经设置好正确的导出值。

加载其他类型的模块

非模块化文件

最常见的就是在script标签中引入的jQuery及其各种插件。

AMD

AMD 英文Asynchronous Module Definition(异步模块定义)的缩写。从名字就可以看出它与CommonJS和ES6 Module最大的区别在于它加载模块的方式是异步的

下面的例子展示了如何定义一个AMD模块。

define('getSum', ['calculator'], function(math) {
    return function(a, b) {
        console.log('sum: ' + calculator.add(a, b));
    }
});

在AMD中使用define函数来定义模块,它可以接受3个参数。第1个参数是当前模块的id,相当于模块名;第2个参数是当前模块的依赖,比如上面我们定义的getSum模块需要引入calculator模块作为依赖;第3个参数用来描述模块的导出值,可以是函数或对象。如果是函数则导出的是函数的返回值;如果是对象则直接导出对象本身。

和CommonJS类似,AMD也使用require函数来加载模块,只不过采用异步的形式。

require(['getSum'], function(getSum) {
    getSum(2, 3);
});

require的第1个参数指定了加载的模块,第2个参数是当加载完成后执行的回调函数。

通过AMD这种形式定义模块的好处在于其模块加载是非阻塞性的,当执行到require函数时并不会停下来去执行被加载的模块,而是继续执行require后面的代码,这使得模块加载操作并不会阻塞浏览器。

尽管AMD的设计理念很好,但与同步加载的模块标准相比其语法要更加冗长。另外其异步加载的方式并不如同步显得清晰,并且容易造成回调地狱(callback hell)。

CMD

在 Sea.js 中,所有 JavaScript 模块都遵循 CMD(Common Module Definition) 模块定义规范。该规范明确了模块的基本书写格式和基本交互规则。

AMD 是 RequireJS 在推广过程中对模块定义的规范化产出。 CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。

这些规范的目的都是为了 JavaScript 的模块化开发,特别是在浏览器端的。 目前这些规范的实现都能达成浏览器端模块化开发的目的。

AMD和CMD的区别:

  1. 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。

  2. CMD 推崇依赖就近,AMD 推崇依赖前置

// CMD
define(function(require, exports, module) {
    var a = require('./a');
    a.doSomething();  // 此处略去 100 行
    var b = require('./b');  // 依赖可以就近书写
    b.doSomething();
    // ...
});

// AMD 默认推荐的是
define(['./a', './b'], function(a, b) {  
    // 依赖必须一开始就写好
    a.doSomething();
    // 此处略去 100 行
    b.doSomething();
    ...
});

虽然 AMD 也支持 CMD 的写法,同时还支持将 require 作为依赖项传递,但 RequireJS 的作者默认是最喜欢上面的写法,也是官方文档里默认的模块定义写法。

  1. AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一。比如 AMD 里,require 分全局 require 和局部 require,都叫 require。CMD 里,没有全局 require,而是根据模块系统的完备性,提供 seajs.use 来实现模块系统的加载启动。CMD 里,每个 API 都简单纯粹。

SeaJS 和 RequireJS 的差异

UMD

UMD并不能说是一种模块标准,不如说它是一组模块形式的集合更准确。UMD的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境。

// calculator.js
(function (global, main) {
    // 根据当前环境采取不同的导出方式
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(...);
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = ...;
    } else {
        // 非模块化环境
        global.add = ...;
    }
}(this, function() {
    // 定义模块主体
    return {...}
}));

UMD其实就是根据当前全局对象中的值判断目前处于哪种模块环境。当前环境是AMD,就以AMD的形式导出;当前环境是CommonJS,就以CommonJS的形式导出。

需要注意的问题是,UMD模块一般都最先判断AMD环境,也就是全局下是否有define函数,而通过AMD定义的模块是无法使用CommonJS或ES6 Module的形式正确引入的。在Webpack中,由于它同时支持AMD及CommonJS,也许工程中的所有模块都是CommonJS,而UMD标准却发现当前有AMD环境,并使用了AMD方式导出,这会使得模块导入时出错。当需要这样做时,我们可以更改UMD模块中判断的顺序,使其以CommonJS的形式导出即可。

加载npm模块

目前,JavaScript最主流的包管理器有两个——npm和yarn。

import all from 'lodash/fp/all.js';
console.log('all', all);

这种情况下,Webpack最终只会打包node_modules/lodash/fp/all.js这个文件,而不会打包全部的lodash库,通过这种方式可以减小打包资源的体积

模块打包原理

webpack如何将所有模块有序地组织在一起,使用前面calculator的例子。

// index.js
const calculator = require('./calculator.js');
const sum = calculator.add(2, 3);
console.log('sum', sum);

// calculator.js
module.exports = {
    add: function(a, b) {
        return a + b;
    }
}

上面的代码经过Webpack打包后将会成为如下的形式(为了易读性这里只展示代码的大体结构):

// 立即执行匿名函数
(functionn(modules) {
    // 模块缓存
    var installedModules = {};
    // 实现require
    function __webpack_require__(moduleId) {
        ...
    }
    // 执行入口模块的加载
    return __webpack_require__(__webpack_require__.s = 0)
})({
    // modules: 以key-value的形式存储所有被打包的模块
    0: function(module, exports, __webpack_require__) {
        // 打包入口
        module.exports = __webpack_require__("3qiv");
    },
    "3qiv": function(module, exports, __webpack_require__) {
        // index.js内容呢
    },
    jkzz: functionn(module, exports, __webpack_require__) {
        // calculator.js内容
    }
});

上面的bundle分为以下几个部分:

  • 最外层立即执行匿名函数。它用来包裹整个bundle,并构成自身的作用域。

  • installedModules对象。每个模块只在第一次被加载的时候执行,之后其导出值就被存储到这个对象里面,当再次被加载的时候直接从这里取值,而不会重新执行。

  • __webpack_require__函数。对模块加载的实现,在浏览器中可以通过调用__webpack_require__(module_id)来完成模块导入。

  • modules对象。工程中所有产生了依赖关系的模块都会以key-value的形式放在这里。key可以理解为一个模块的id,由数字或者一个很短的hash字符串构成;value则是由一个匿名函数包裹的模块实体,匿名函数的参数则赋予了每个模块导出和导入的能力。

接下来让我们看看一个bundle是如何在浏览器中执行的。

1)在最外层的匿名函数中会初始化浏览器执行环境,包括定义installedModules对象、__webpack_require__函数等,为模块的加载和执行做一些准备工作。

2)加载入口模块。每个bundle都有且只有一个入口模块,在上面的示例中,index.js是入口模块,在浏览器中会从它开始执行。

3)执行模块代码。如果执行到了module.exports则记录下模块的导出值;如果中间遇到require函数(准确地说是__webpack_require__),则会暂时交出执行权,进入__webpack_require__函数体内进行加载其他模块的逻辑。

4)在__webpack_require__中会判断即将加载的模块是否存在于installedModules中。如果存在则直接取值,否则回到第3步,执行该模块的代码来获取导出值。

5)所有依赖的模块都已执行完毕,最后执行权又回到入口模块。当入口模块的代码执行到结尾,也就意味着整个bundle运行结束。不难看出,第3步和第4步是一个递归的过程。Webpack为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序与模块加载的顺序是完全一致的,这就是Webpack模块打包的奥秘。

不难看出,第3步和第4步是一个递归的过程。Webpack为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序与模块加载的顺序是完全一致的,这就是Webpack模块打包的奥秘。

三、资源输入输出

资源处理流程

在一切流程的最开始,我们需要指定一个或多个入口(entry),也就是告诉Webpack具体从源码目录下的哪个文件开始打包。如果把工程中各个模块的依赖关系当作一棵树,那么入口就是这棵依赖树的根

这些存在依赖关系的模块会在打包时被封装为一个chunk

chunk字面的意思是代码块,在Webpack中可以理解成被抽象和包装过后的一些模块。它就像一个装着很多文件的文件袋,里面的文件就是各个模块,Webpack在外面加了一层包裹,从而形成了chunk。根据具体配置不同,一个工程打包时可能会产生一个或多个chunk。

IMG_6345.JPG

从上面我们已经了解到,Webpack会从入口文件开始检索,并将具有依赖关系的模块生成一棵依赖树,最终得到一个chunk。由这个chunk得到的打包产物我们一般称之为bundle

IMG_6343.JPG

IMG_6346.JPG

在工程中可以定义多个入口,每一个入口都会产生一个结果资源。比如我们工程中有两个入口src/index.jssrc/lib.js,在一般情形下会打包生成dist/index.js和dist/lib.js,因此可以说,entry与bundle存在着对应关系。一个入口也可能产生多个chunk并最终生成多个bundle

个人理解:

chunk和bundle的关系:一般情况下,chunk和bundle是一一对应的,在不设置splitChunks的情况下,单入口文件一般对应一个chunk,最终生成一个浏览器可以直接运行的bundle(bundle.js),如果使用mini-css-extract-plugin插件,则又会生成一个bundle.css文件。

image.png

配置资源入口

Webpack通过 entry 配置项来共同决定入口文件的路径。在配置入口时,实际上做了两件事:

  • 确定入口模块位置,告诉Webpack从哪里开始进行打包。

  • 定义chunk name。如果工程只有一个入口,那么默认其chunk name为“main”;如果工程有多个入口,我们需要为每个入口定义chunk name,来作为该chunk的唯一标识。

  1. 单页应用
module.exports = {
    entry: './src/app.js'
}
  1. 多页应用
module.exports = {
    entry: {
        pageA: './src/pageA.js',
        pageB: './src/pageB.js'
    }
}

配置资源出口

const path = require('path');
module.exports = {
    entry: './src/app.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'assets'),
        publicPath: '/dist/',
    },
};

filename

控制输出资源的文件名,其形式为字符串。

const path = require('path');
module.exports = {
    entry: {
        app: './src/app.js',
        vendor: './src/vendor.js',
    },
    output: {
        filename: '[name].[chunkname].js',
        path: path.join(__dirname, 'dist')
    }
}

path

path可以指定资源输出的位置,要求值必须为绝对路径。

publicPath

publicPath是一个非常重要的配置项,并且容易与path相混淆。从功能上来说,path用来指定资源的输出位置,而publicPath则用来指定资源的请求位置。让我们详细解释这两个定义。

  • 输出位置:打包完成后资源产生的目录,一般将其指定为工程中的dist目录。

  • 请求位置:由JS或CSS所请求的间接资源路径。页面中的资源分为两种,一种是由HTML页面直接请求的,比如通过script标签加载的JS;另一种是由JS或CSS请求的,如异步加载的JS、从CSS请求的图片字体等。publicPath的作用就是指定这部分间接资源的请求位置。

四、预处理器(loader)

style-loader

npm install -D style-loader

css-loader

npm install -D css-loader

less-loader

npm install -D less less-loader

sass-loader

npm install -D sass-loader node-sass

file-loader

npm install -D file-loader

url-loader

npm install -D url-loader

babel-loader

npm install -D babel-loader @babel-core @babel-preset-env

配置babel-loader需要注意:

rules: [
    {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
            loader: 'babel-loader',
            options: {
                cacheDirectory: true,
                presets: [{
                    'env', {
                        module: false
                    }
                }]
            }
        }
    }
]

1)由于babel-loader通常属于对所有JS后缀文件设置的规则,所以需要在exclude中添加node_modules,否则会令babel-loader编译其中所有的模块,这将严重拖慢打包的速度,并且有可能改变第三方模块的原有行为。

2)对于babel-loader本身我们添加了cacheDirectory配置项,它会启用缓存机制,在重复打包未改变过的模块时防止二次编译,同样也会加快打包的速度。cacheDirectory可以接收一个字符串类型的路径来作为缓存路径,这个值也可以为true,此时其缓存目录会指向node_modules/.cache/babel-loader。

3)由于@babel/preset-env会将ES6 Module转化为CommonJS的形式,这会导致Webpack中的tree-shaking特性失效。将@babel/preset-env的modules配置项设置为false会禁用模块语句的转化,而将ES6 Module的语法交给Webpack本身处理。

ts-loader

npm install -D ts-loader typescript

tsconfig.json

{
    "compilerOptions": {
        "target": "es5",
        "sourceMap": true
    }
}

html-loader

rules: [
    {
        test: /\.html$/,
        use: 'html-loader'
    }
]

使用示例如下:

//header.html
<header>
    <h1>This is a Header.</h1>
</header>

//index.js
import headerHtml from './header.html';
document.write(headerHtml);

header.html将会转化为字符串,并通过document.write插入页面中。

handlebars-loader

npm install -D handlebars-loader handlebars

vue-loader

npm install -D vue-loader vue vue-template-compiler css-loader

自定义loader

我们将实现一个loader,它会为所有JS文件启用严格模式,也就是说它会在文件头部加上如下代码:

'use strict';
var loaderUtils = require('loader-utils');
var SourceNode = require('source-map').SourceNode;
var SourceMapConsumer = require('source-map').SourceMapConsumer;

module.exports = function(content, sourceMap) {
  var useStrictPrefix = '\'use strict\';\n\n';
  // 当文件输入和其依赖没有发生变化时,应该让loader直接使用缓存,而不是重复进行转换的工作。
  if (this.cacheable) {
    this.cacheable();
  }
  // source-map
  var options = loaderUtils.getOptions(this) || {};
  if (options.sourceMap && sourceMap) {
    var currentRequest = loaderUtils.getCurrentRequest(this);
    var node = SourceNode.fromStringWithSourceMap(
      content,
      new SourceMapConsumer(sourceMap)
    );
    node.prepend(useStrictPrefix);
    var result = node.toStringWithSourceMap({ file: currentRequest });
    var callback = this.async();
    callback(null, result.code, result.map.toJSON()); // 可以异步执行返回
  }
  return useStrictPrefix + content;
}

loader Api:

loader-utils 最常用的一种工具是获取传递给 loader 的 options

schema-utils 可以用 schema-utils 提供的工具,获取用于校验 options 的 JSON Schema 常量,从而校验 loader options。

this.async 异步回调 

this.callback 回调

this.cacheable 默认情况下,loader 的处理结果会被标记为可缓存。调用这个方法然后传入 false,可以关闭 loader 的缓存。

webpack 构建流程

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程 :

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。

  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译。

  3. 确定入口:根据配置中的 entry 找出所有的入口文件。

  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。

  5. 完成模块编译:在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系。

  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。

  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

小结

loader就像Webpack的翻译官。Webpack本身只能接受JavaScript,为了使其能够处理其他类型的资源,必须使用loader将资源转译为Webpack能够理解的形式。

在配置loader时,实际上定义的是模块规则(module.rules),它主要关注两件事:该规则对哪些模块生效(test、exclude、include配置),使用哪些loader(use配置)。loader可以是链式的,并且每一个都允许拥有自己的配置项。

loader本质上是一个函数。第一个loader的输入是源文件,之后所有loader的输入是上一个loader的输出,最后一个loader则直接输出给Webpack。

五、样式处理

分离样式文件

extract-text-webpack-plugin 适用于 webpack 4 之前的版本。

mini-css-extract-plugin 适用于 webpack 4 之后的版本。

它们都是用于提取样式到 CSS文件 的。

// webpack.config.js
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
    mode: 'development',
    entry: {
        foo: './src/scripts/foo.js',
        bar: './src/scripts/bar.js'
    },
    output: {
        filename: '[name].js'
    },
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: 'css-loader'
                })
            }
        ]
    },
    plugins: [
        new ExtractTextPlugin('[name].css')
    ]
};

mini-css-extract-plugin 最重要的特性是支持按需加载。

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    mode: 'development',
    entry: './app.js',
    output: {
        filename: '[name].js'
    },
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            publicPath: '../',
                        },
                        'css-loader'
                    }
                ]
            }
        ]
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: '[name].[contenthash].css', // 指定同步加载的css资源名
            chunkFileName: '[id].[contenthash].css' // 指定异步加载的css资源名
        })
    ]
};

样式预处理

Sass 和 Scss

Sass本身是对CSS的语法增强,它有两种语法,现在使用更多的是SCSS(对CSS3的扩充版本)。所以你会发现,在安装和配置loader时都是sass-loader,而实际的文件后缀是.scss。

sass-loader就是将SCSS语法编译为CSS,因此在使用时通常还要搭配css-loader和style-loader。这里我们除了sass-loader以外还要安装node-sass,node-sass是真正用来编译SCSS的,而sass-loader只是起到黏合的作用。

值得一提的是,假如我们想要在浏览器的调试工具里查看源码,需要分别为sass-loader和css-loader单独添加source map的配置项。

module: {
    rules: [
        {
            test: /\.scss/i,
            use: [
                'style-loader',
                {
                    loader: 'css-loader',
                    options: {
                        sourceMap: true
                    }
                },
                {
                    loader: 'sass-loader',
                    options: {
                        sourceMap: true
                    }
                }
            ]
        }
    ]
}

Less

PostCSS

  • autoprefixer - 自动前缀工具
npm install -D autoprefixer

在 postcss.config.js 中添加 autoprefixer。

const autoprefixer = require('autoprefixer');

module.exports = {
    plugins: [
        autoprefixer({
            grid: true,
            browsers: [
                '> 1%',
                'last 3 versions',
                'android 4.2',
                'ie 8'
            ]
        })
    ]
}
  • stylelint - CSS质量检测工具

使用stylelint可以检测出代码中的样式问题(语法错误,重复的属性等)。

const stylelint = require('stylelint');

module.exports = {
    plugins: [
        stylelint({
            config: {
                rules: {
                    'declaration-no-important': true
                }
            }
        })
    ]
}
  • CSSNext - 应用中可以使用最新的CSS语法特性

  • CSS Modules

六、代码分片

实现高性能应用其中重要的一点就是尽可能地让用户每次只加载必要的资源,优先级不太高的资源则采用延迟加载等技术渐进式地获取,这样可以保证页面的首屏速度

代码分片(code splitting)是Webpack作为打包工具所特有的一项技术,通过这项技术我们可以把代码按照特定的形式进行拆分,使用户不必一次全部加载,而是按需加载。

代码分片可以有效降低首屏加载资源的大小,但同时也会带来新的问题,比如我们应该对哪些模块进行分片、分片后的资源如何管理等,这些也是需要关注的。

通过入口划分代码

在Webpack中每个入口(entry)都将生成一个对应的资源文件,通过入口的配置我们可以进行一些简单有效的代码拆分。

对于Web应用来说通常会有一些库和工具是不常变动的,可以把它们放在一个单独的入口中,由该入口产生的资源不会经常更新,因此可以有效地利用客户端缓存,让用户不必在每次请求页面时都重新加载。

// webpack.config.js
entry: {
    app: './app.js',
    lib: ['lib-a', 'lib-b', 'lib-c']
}

// index.html
<script src="dist/lib.js"></script>
<script src="dist/app.js"></script>

这种拆分方法主要适合于那些将接口绑定在全局对象上的库,因为业务代码中的模块无法直接引用库中的模块,二者属于不同的依赖树。

对于多页面应用来说,我们也可以利用入口划分的方式拆分代码。 比如,为每一个页面创建一个入口,并放入只涉及该页面的代码,同时再创建一个入口来包含所有公共模块,并使每个页面都进行加载。但是这样仍会带来公共模块与业务模块处于不同依赖树的问题。另外,很多时候不是所有的页面都需要这些公共模块。比如A、B页面需要lib-a模块,C、D需要lib-b模块,通过手工的方式去配置和提取公共模块将会变得十分复杂。好在我们还可以使用Webpack专门提供的插件来解决这个问题。

CommonChunkPlugin

CommonsChunkPlugin是Webpack 4之前内部自带的插件(Webpack 4之后替换为了SplitChunks)。它可以将多个Chunk中公共的部分提取出来。公共模块的提取可以为项目带来几个收益:

  • 发过程中减少了重复模块打包,可以提升开发速度;
  • 减小整体资源体积;
  • 合理分片后的代码可以更有效地利用客户端缓存。 让我们先看一个简单的例子来直观地认识它。假设我们当前的项目中有foo.js和bar.js两个入口文件,并且都引入了react,下面是未使用CommonsChunkPlugin的配置:
// webpack.config.js
module.exports = {
    entry: {
        foo: './foo.js',
        bar: './bar.js',
    },
    output: {
        filename: '[name].js',
    },
};

// foo.js
import React from 'react';
document.write('foo.js', React.version);

// bar.js
import React from 'react';
document.write('bar.js', React.version);

看下打包结果:

IMG_6342.JPG

从资源体积可以看出,react被分别打包到了foo.js和bar.js中。

更改webpack.config.js,添加CommonsChunkPlugin。

module.exports = {
    entry: {
        foo: './foo.js',
        bar: './bar.js',
    },
    output: {
        filename: '[name].js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'commons', // 用于指定公共chunk的名字
            filename: 'commons.js', // 提取后的资源文件名
        })
    ],
};

IMG_6347.JPG

可以看到,产出的资源中多了commons.js,而foo.js和bar.js的体积从之前的72.1kB降为不到1kB,这就是把react及其依赖的模块都提到commons.js的结果。

最后,记得在页面中添加一个script标签来引入commons.js,并且注意,该JS一定要在其他JS之前引入。

提取vendor

虽然CommonsChunkPlugin主要用于提取多入口之间的公共模块,但这不代表对于单入口的应用就无法使用。我们仍然可以用它来提取第三方类库及业务中不常更新的模块,只需要单独为它们创建一个入口即可。

// webpack.config.js
const webpack = require('webpack');
module.exports = {
    entry: {
        app: './app.js',
        vendor: ['react'],
    },
    output: {
        filename: '[name].js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            filename: 'vendor.js',
        })
    ],
};

// app.js
import React from 'react';
document.write('app.js', React.version);

为了将react从app.js提取出来,我们在配置中加入了一个入口vendor,并使其只包含react,这样就把react变为了app和vendor这两个chunk所共有的模块。在插件内部配置中,我们将name指定为vendor,这样由CommonsChunkPlugin所产生的资源将覆盖原有的由vendor这个入口所产生的资源。

IMG_6348.JPG

设置提取范围

通过CommonsChunkPlugin中的chunks配置项可以规定从哪些入口中提取公共模块,请看下面的例子:

// webpack.config.js
module.exports = {
    entry: {
        a: './a.js',
        b: './b.js',
        c: './c.js',
    },
    output: {
        filename: '[name].js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'commons',
            filename: 'commons.js',
            chunks: ['a', 'b'],
        })
    ],
};

我们在chunks中配置了a和b,这意味着只会从a.js和b.js中提取公共模块。

IMG_6350.JPG

对于一个大型应用来说,拥有几十个页面是很正常的,这也就意味着会有几十个资源入口。这些入口所共享的模块也许会有些差异,在这种情况下,我们可以配置多个CommonsChunkPlugin,并为每个插件规定提取的范围,来更有效地进行提取。

设置提取规则

CommonsChunkPlugin的默认规则是只要一个模块被两个入口chunk所使用就会被提取出来,比如只要a和b用了react,react就会被提取出来。

然而现实情况是,有些时候我们不希望所有的公共模块都被提取出来,比如项目中一些组件或工具模块,虽然被多次引用,但是可能经常修改,如果将其和react这种库放在一起反而不利于客户端缓存。

此时我们可以通过CommonsChunkPlugin的minChunks配置项来设置提取的规则。该配置项非常灵活,支持多种输入形式。

(1)数字

minChunks可以接受一个数字,当设置minChunks为n时,只有该模块被n个入口同时引用才会进行提取。另外,这个阈值不会影响通过数组形式入口传入模块的提取。这个听上去不是很好理解,让我们看以下例子:

// webpack.config.js
const webpack = require('webpack');
module.exports = {
    entry: {
        foo: './foo.js',
        bar: './bar.js',
        vendor: ['react'],
    },
    output: {
        filename: '[name].js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            filename: 'vendor.js',
            minChunks: 3,
        })
    ],
};

我们令foo.js和bar.js共同引用一个util.js。

// foo.js
import React from 'react';
import './util';
document.write('foo.js', React.version);

// bar.js
import React from 'react';
import './util';
document.write('bar.js', React.version);

// util.js
console.log('util');

如果实际打包应该可以发现,由于我们设置minChunks为3,util.js并不会被提取到vendor.js中,然而react并不受这个的影响,仍然会出现在vendor.js中。这就是所说的数组形式入口的模块会照常提取。

(2)Infinity

设置为无穷代表提取的阈值无限高,也就是说所有模块都不会被提取。 这个配置项的意义有两个。第一个是和上面的情况类似,即我们只想让Webpack提取特定的几个模块,并将这些模块通过数组型入口传入,这样做的好处是提取哪些模块是完全可控的;另一个是我们指定minChunks为Infinity,为了生成一个没有任何模块而仅仅包含Webpack初始化环境的文件,这个文件我们通常称为manifest。

(3)函数

minChunks支持传入一个函数,它可以让我们更细粒度地控制公共模块。Webpack打包过程中的每个模块都会经过这个函数的处理,当函数的返回值是true时进行提取。

hash与长效缓存

使用CommonsChunkPlugin时,一个绕不开的问题就是hash与长效缓存。当我们使用该插件提取公共模块时,提取后的资源内部不仅仅是模块的代码,往往还包含Webpack的运行时(runtime)。Webpack的运行时指的是初始化环境的代码,如创建模块缓存对象、声明模块加载函数等。

在较早期的Webpack版本中,运行时内部也包含模块的id,并且这个id是以数字的方式不断累加的(比如第1个模块id是0,第2个模块id是1)。这会造成一个问题,即模块id的改变会导致运行时内部的代码发生变动,进一步影响chunk hash的生成。一般我们会使用chunk hash作为资源的版本号优化客户端的缓存,版本号改变会导致用户频繁地更新资源,即便它们的内容并没有发生变化也会更新。

这个问题解决的方案是:将运行时的代码单独提取出来。请看下面这个例子:

// webpack.config.js
const webpack = require('webpack');
module.exports = {
    entry: {
        app: './app.js',
        vendor: ['react'],
    },
    output: {
        filename: '[name].js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest',
        })
    ],
}

上面的配置中,通过添加了一个name为manifest的CommonsChunkPlugin来提取Webpack的运行时。

注意:manifest的CommonsChunkPlugin必须出现在最后,否则Webpack将无法正常提取模块。

CommonsChunkPlugin的不足

在提取公共模块方面,CommonsChunkPlugin可以满足很多场景的需求,但是它也有一些欠缺的地方。

1)一个CommonsChunkPlugin只能提取一个vendor,假如我们想提取多个vendor则需要配置多个插件,这会增加很多重复的配置代码。

2)前面我们提到的manifest实际上会使浏览器多加载一个资源,这对于页面渲染速度是不友好的。

3)由于内部设计上的一些缺陷,CommonsChunkPlugin在提取公共模块的时候会破坏掉原有Chunk中模块的依赖关系,导致难以进行更多的优化。比如在异步Chunk的场景下CommonsChunkPlugin并不会按照我们的预期正常工作。

// webpack.config.js
const webpack = require('webpack');
module.exports = {
    entry: './foo.js',
    output: {
        filename: 'foo.js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'commons',
            filename: 'commons.js',
        })
    ],
};

// foo.js
import React from 'react';
import('./bar.js');
document.write('foo.js', React.version);

// bar.js
import React from 'react';
document.write('bar.js', React.version);

IMG_6351.JPG

如上图,react仍在foo.js中,被没有按照我们预期被提取到commons.js里。

optimization.SplitChunks

optimization.SplitChunks(简称SplitChunks)是Webpack 4为了改进CommonsChunk-Plugin而重新设计和实现的代码分片特性。它不仅比CommonsChunkPlugin功能更加强大,还更简单易用。

比如我们前面异步加载的例子,在换成Webpack 4的SplitChunks之后,就可以自动提取出react了。

// webpack.config.js
module.exports = {
    entry: './foo.js',
    output: {
        filename: 'foo.js',
        publicPath: '/dist/',
    },
    mode: 'development',
    optimization: {
        splitChunks: {
            chunks: 'all',
        },
    },
};

// foo.js
import React from 'react';
import('./bar.js');
document.write('foo.js', React.version);

// bar.js
import React from 'react';
console.log('bar.js', React.version);

此处Webpack 4的配置与之前相比有两点不同:

  • 使用optimization.splitChunks替代了CommonsChunkPlugin,并指定了chunks的值为all,这个配置项的含义是,SplitChunks将会对所有的chunks生效(默认情况下,SplitChunks只对异步chunks生效,并且不需要配置)。

  • mode是Webpack 4中新增的配置项,可以针对当前是开发环境还是生产环境自动添加对应的一些Webpack配置。

IMG_6352.JPG

原本我们打包的结果应该是foo.js及0.foo.js(异步加载bar.js的结果,后面会介绍),但是由于SplitChunks的存在,又生成了一个vendors~main.foo.js,并且把react提取到了里面。

从命令式到声明式

在使用CommonsChunkPlugin的时候,我们大多数时候是通过配置项将特定入口中的特定模块提取出来,也就是更贴近命令式的方式。而SplitChunks的不同之处在于我们只需要设置一些提取条件,如提取的模式、提取模块的体积等,当某些模块达到这些条件后就会自动被提取出来。SplitChunks的使用更像是声明式的。

以下是SplitChunks默认情形下的提取条件:

  • 提取后的chunk可被共享或者来自node_modules目录。这一条很容易理解,被多次引用或处于node_modules中的模块更倾向于是通用模块,比较适合被提取出来。

  • 提取后的Javascript chunk体积大于30kB(压缩和gzip之前),CSS chunk体积大于50kB。这个也比较容易理解,如果提取后的资源体积太小,那么带来的优化效果也比较一般。

  • 按需加载过程中,并行请求的资源最大值小于等于5。按需加载指的是,通过动态插入script标签的方式加载脚本。我们一般不希望同时加载过多的资源,因为每一个请求都要花费建立链接和释放链接的成本,因此提取的规则只在并行请求不多的时候生效。

  • 在首次加载时,并行请求的资源数最大值小于等于3。和上一条类似,只不过在页面首次加载时往往对性能的要求更高,因此这里的默认阈值也更低。

通过前面的例子我们可以进一步解释这些条件。在从foo.js和bar.js提取react前,会对这些条件一一进行验证,只有满足了所有条件之后react才会被提取出来。下面我们进行一下比对:

  • react属于node_modules目录下的模块。

  • react的体积大于30kB。

  • 按需加载时的并行请求数量为1,为0.foo.js。

  • 首次加载时的并行请求数量为2,为foo.js和vendors-main.foo.js。之所以vendors-main.foo.js不算在第3条是因为它需要被添加在HTML的script标签中,在页面初始化的时候就会进行加载。

默认的异步提取

前面我们对SplitChunks添加了一个chunks:all的配置,这是为了提取foo.js和bar.js的公共模块。实际上SplitChunks不需要配置也能生效,但仅仅针对异步资源。请看下面的例子:

// webpack.config.js
module.exports = {
    entry: './foo.js',
    output: {
        filename: 'foo.js',
        publicPath: '/dist/',
    },
    mode: 'development',
};

// foo.js
import('./bar.js');
console.log('foo.js');

// bar.js
import lodash from 'lodash';
console.log(lodash.flatten([1, [2, 3]]));

IMG_6354.JPG

从结果来看,foo.js不仅产生了一个0.foo.js(原本的bar.js),还有一个1.foo.js,这里面包含的就是lodash的内容。

配置

为了更好地了解SplitChunks是怎样工作的,我们来看一下它的默认配置。

splitChunks: {
    chunks: "async",
    minSize: {
      javascript: 30000,
      style: 50000,
    },
    maxSize: 0,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10,
        },
        default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true,
        },
    },
},

(1)匹配模式

通过chunks我们可以配置SplitChunks的工作模式。它有3个可选值,分别为async(默认)initialall。async即只提取异步chunk,initial则只对入口chunk生效(如果配置了initial则上面异步的例子将失效),all则是两种模式同时开启。

(2)匹配条件

minSize、minChunks、maxAsyncRequests、maxInitialRequests都属于匹配条件。

(3)命名

配置项name默认为true,它意味着SplitChunks可以根据cacheGroups和作用范围自动为新生成的chunk命名,并以automaticNameDelimiter分隔。如vendorsab~c.js意思是cacheGroups为vendors,并且该chunk是由a、b、c三个入口chunk所产生的。

(4)cacheGroups

可以理解成分离chunks时的规则。默认情况下有两种规则——vendors和default。vendors用于提取所有node_modules中符合条件的模块,default则作用于被多次引用的模块。我们可以对这些规则进行增加或者修改,如果想要禁用某种规则,也可以直接将其置为false。当一个模块同时符合多个cacheGroups时,则根据其中的priority配置项确定优先级。

资源异步加载

资源异步加载主要解决的问题是,当模块数量过多、资源体积过大时,可以把一些暂时使用不到的模块延迟加载。这样使页面初次渲染的时候用户下载的资源尽可能小,后续的模块等到恰当的时机再去触发加载。因此一般也把这种方法叫作按需加载。

import()

与正常ES6中的import语法不同,通过import函数加载的模块及其依赖会被异步地进行加载,并返回一个Promise对象。

首先让我们看一个正常模块加载的例子。

// foo.js
import { add } from './bar.js';
console.log(add(2, 3));

// bar.js
export function add(a, b) {
    return a + b;
}

假设bar.js的资源体积很大,并且我们在页面初次渲染的时候并不需要使用它,就可以对它进行异步加载。

// foo.js
import('./bar.js').then(({ add }) => {
    console.log(add(2, 3));
});

// bar.js
export function add(a, b) {
    return a + b;
}

这里还需要我们更改一下Webpack的配置。

module.exports = {
    mode: 'development',
    entry: {
        foo: './foo.js'
    },
    output: {
        filename: '[name].js',
        publicPath: '/dist/',
    },
    devServer: {
        publicPath: '/dist/',
        port: 3000,
    },
};

首屏加载的JS资源地址是通过页面中的script标签来指定的,而间接资源(通过首屏JS再进一步加载的JS)的位置则要通过output.publicPath来指定。上面我们的import函数相当于使bar.js成为了一个间接资源,我们需要配置publicPath来告诉Webpack去哪里获取它。

IMG_6355.JPG

该技术实现的原理很简单,就是通过JavaScript在页面的head标签里插入一个script标签/dist/0.js,打开Chrome的Elements面板就可以看到。由于该标签在原本的HTML页面中并没有,因此我们称它是动态插入的。

import函数还有一个比较重要的特性。ES6 Module中要求import必须出现在代码的顶层作用域,而Webpack的import函数则可以在任何我们希望的时候调用。

这种异步加载方式可以赋予应用很强的动态特性,它经常被用来在用户切换到某些特定路由时去渲染相应组件,这样分离之后首屏加载的资源就会小很多。

异步chunk的配置

现在我们已经生成了异步资源,但我们会发现产生的资源名称都是数字id(如0.js),没有可读性。还需要通过一些Webpack的配置来为其添加有意义的名字,以便于管理。

还是上面的例子,我们修改一下foo.js及Webpack的配置。

// webpack.config.js
module.exports = {
    entry: {
        foo: './foo.js',
    },
    output: {
        publicPath: '/dist/',
        filename: '[name].js',
        chunkFilename: '[name].js',
    },
    mode: 'development',
};

// foo.js
import(/* webpackChunkName: "bar" */ './bar.js').then(({ add }) => {
    console.log(add(2, 3));
});

可以看到,我们在Webpack的配置中添加了output.chunkFilename,用来指定异步chunk的文件名。其命名规则与output.filename基本一致,不过由于异步chunk默认没有名字,其默认值是[id].js,这也是为什么我们在例子中看到的是0.js。如果有更多的异步chunk,则会依次产生1.js、2.js等。

在foo.js中,我们通过特有的注释来让Webpack获取到异步chunk的名字,并配置output.chunkFilename为[name].js

七、生产环境配置

环境配置的封装

生产环境的配置与开发环境有所不同,比如要设置mode、环境变量,为文件名添加chunk hash作为版本号等。如何让Webpack可以按照不同环境采用不同的配置呢?一般来说有以下两种方式。

1)使用相同的配置文件。 比如令Webpack不管在什么环境下打包都使用webpack.config.js,只是在构建开始前将当前所属环境作为一个变量传进去,然后在webpack.config.js中通过各种判断条件来决定具体使用哪个配置。比如:

// package.json
{
  ...
  "scripts": {
    "dev": "ENV=development webpack-dev-server",
    "build": "ENV=production webpack"
  },
}

// webpack.config.js
const ENV = process.env.ENV;
const isProd = ENV === 'production';
module.exports = {
  output: {
    filename: isProd ? 'bundle@[chunkhash].js' : 'bundle.js',
  },
  mode: ENV,
};

2)为不同环境创建各自的配置文件。比如,我们可以单独创建一个webpack.production.config.js,开发环境的则可以叫webpack.development.config.js。然后修改package.json。

{
  ...
  "scripts": {
    "dev": "webpack-dev-server --config=webpack.development.config.js",
    "build": "webpack --config=webpack.production.config.js"
  },
}

上面我们通过--config指定打包时使用的配置文件。但这种方法存在一个问题,即webpack.development.config.js 和 webpack.production.config.js肯定会有重复的部分,一改都要改,不利于维护。在这种情况下,可以将公共的配置提取出来,比如我们单独创建一个webpack.common.config.js。

开启production模式

mode: 'production'

环境变量

通常我们需要为生产环境和本地环境添加不同的环境变量,在Webpack中可以使用DefinePlugin进行设置。请看下面的例子:

// webpack.config.js
const webpack = require('webpack');
module.exports = {
    mode: 'production',
    entry: './app.js',
    output: {
        filename: 'bundle.js',
    },
    plugins: [
        new webpack.DefinePlugin({
            ENV: JSON.stringify('production'),
        })
    ],
};

// app.js
document.write(ENV);

上面的配置通过DefinePlugin设置了ENV环境变量,最终页面上输出的将会是字符串production。

如果启用了mode:production,则Webapck已经设置好了process.env.NODE_ENV,不需要再人为添加了。

source map

source map指的是将编译、打包、压缩后的代码映射回源代码的过程。经过Webpack打包压缩后的代码基本上已经不具备可读性,此时若代码抛出了一个错误,要想回溯它的调用栈是非常困难的。而有了source map,再加上浏览器调试工具(dev tools),要做到这一点就非常容易了。同时它对于线上问题的追查也有一定帮助。

JavaScript的source map的配置很简单,只要在webpack.config.js中添加devtool即可。

对于CSS、SCSS、Less来说,则需要添加额外的source map配置项。如下面例子所示:

const path = require('path');
module.exports = {
    // ...
    devtool: 'source-map',
    module: {
        rules: [
            {
                test: /\.scss$/,
                use: [
                    'style-loader',
                    {
                        loader: 'css-loader',
                        options: {
                            sourceMap: true,
                        },
                    }, {
                        loader: 'sass-loader',
                        options: {
                            sourceMap: true,
                        },
                    }
                ] ,
            }
        ],
    },
};

在开发环境中,cheap-module-eval-source-map通常是一个不错的选择,属于打包速度和源码信息还原程度的一个良好折中。在生产环境中,通常使用cheap-module-source-map

资源压缩

在将资源发布到线上环境前,我们通常都会进行代码压缩,或者叫uglify,意思是移除多余的空格、换行及执行不到的代码,缩短变量名,在执行结果不变的前提下将代码替换为更短的形式。一般正常的代码在uglify之后整体体积都将会显著缩小。同时,uglify之后的代码将基本上不可读,在一定程度上提升了代码的安全性。

压缩JavaScript

压缩JavaScript大多数时候使用的工具有两个,一个是UglifyJS(Webpack 3已集成),另一个是terser(Webpack 4已集成)。

从Webpack 4之后,这项配置被移到了config.optimization.minimize。下面是Webpack 4的示例(如果开启了mode:production,则不需要人为设置):

module.exports = {
    entry: './app.js',
    output: {
        filename: 'bundle.js',
    },
    optimization: {
        minimize: true,
    },
};

terser-webpack-plugin插件支持自定义配置。

const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
    //...
    optimization: {
        // 覆盖默认的 minimizer
        minimizer: [
            new TerserPlugin({
                test: /\.js(\?.*)?$/i,
                exclude: /\/excludes/,
            })
        ],
    },
}

如果你使用的是 webpack v5 或以上版本,不需要安装这个插件。webpack v5 自带最新的 terser-webpack-plugin

IMG_6356.JPG

压缩CSS

压缩CSS文件的前提是使用extract-text-webpack-plugin或mini-css-extract-plugin将样式提取出来,接着使用optimize-css-assets-webpack-plugin来进行压缩,这个插件本质上使用的是压缩器cssnano,当然我们也可以通过其配置进行切换。

const ExtractTextPlugin = require('extract-text-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
    // ...
    module: {
        rules: [
            {
                test: /\.css$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: 'css-loader',
                }),
            }
        ],
    },
    plugins: [new ExtractTextPlugin('style.css')],
    optimization: {
        minimizer: [new OptimizeCSSAssetsPlugin({
            // 生效范围,只压缩匹配到的资源
            assetNameRegExp: /\.optimize\.css$/g,
            // 压缩处理器,默认为 cssnano
            cssProcessor: require('cssnano'),
            // 压缩处理器的配置
            cssProcessorOptions: { discardComments: { removeAll: true } },
            // 是否展示 log
            canPrint: true,
        })],
    },
};
const TerserJSPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [ MiniCssExtractPlugin.loader, 'css-loader'],
            },
        ],
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename:  "/css/main.css",
        }),
    ],
    optimization: { //webpack4提供的优化项
        minimizer: [
    	    //压缩js
            new TerserJSPlugin({}), 
	    //压缩css
            new OptimizeCSSAssetsPlugin({})
	],
    },
};

缓存

缓存是指重复利用浏览器已经获取过的资源。合理地使用缓存是提升客户端性能的一个关键因素。具体的缓存策略(如指定缓存时间等)由服务器来决定,浏览器会在资源过期前一直使用本地缓存进行响应。

资源hash

我们通常使用chunkhash来作为文件版本号,因为它会为每一个chunk单独计算一个hash。

输出动态HTML

html-webpack-plugin会自动地将我们打包出来的资源名放入生成的index.html中,这样我们就不必手动地更新资源URL了。

bundle体积监控和分析

为了保证良好的用户体验,我们可以对打包输出的bundle体积进行持续的监控,以防止不必要的冗余模块被添加进来。

VS Code中有一个插件Import Cost可以帮助我们对引入模块的大小进行实时监测。

另外一个很有用的工具是webpack-bundle-analyzer,它能够帮助我们分析一个bundle的构成。使用方法也很简单,只要将其添加进plugins配置即可。

八、打包优化

HappyPack

HappyPack是一个通过多线程来提升Webpack打包速度的工具。

工作原理

在打包过程中有一项非常耗时的工作,就是使用loader将各种资源进行转译处理。最常见的包括使用babel-loader转译ES6+语法和ts-loader转译TypeScript。我们可以简单地将代码转译的工作流程概括如下:

1)从配置中获取打包入口;

2)匹配loader规则,并对入口模块进行转译;

3)对转译后的模块进行依赖查找(如a.js中加载了b.js和c.js);

4)对新找到的模块重复进行步骤2)和步骤3),直到没有新的依赖模块。

不难看出从步骤2)到步骤4)是一个递归的过程,Webpack需要一步步地获取更深层级的资源,然后逐个进行转译。这里的问题在于Webpack是单线程的,假设一个模块依赖于几个其他模块,Webpack必须对这些模块逐个进行转译。虽然这些转译任务彼此之间没有任何依赖关系,却必须串行地执行。HappyPack恰恰以此为切入点,它的核心特性是可以开启多个线程,并行地对不同模块进行转译,这样就可以充分利用本地的计算资源来提升打包速度。

HappyPack适用于那些转译任务比较重的工程,当我们把类似babel-loaderts-loader迁移到HappyPack之上后,一般都可以收到不错的效果,而对于其他的如sass-loader、less-loader本身消耗时间并不太多的工程则效果一般。

单个loader的优化

在实际使用时,要用HappyPack提供的loader来替换原有loader,并将原有的那个通过HappyPack插件传进去。

// 初始Webpack配置(使用HappyPack前)
module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['react'],
        },
      }
    ],
  },
};

// 使用HappyPack的配置
const HappyPack = require('happypack');
module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'happypack/loader',
      }
    ],
  },
  plugins: [
    new HappyPack({
      loaders: [
        {
          loader: 'babel-loader',
          options: {
            presets: ['react'],
          },
        }
      ],
    })
  ],
};

多个loader的优化

在使用HappyPack优化多个loader时,需要为每一个loader配置一个id,否则HappyPack无法知道rules与plugins如何一一对应。请看下面的例子,这里同时对babel-loader和ts-loader进行了Happypack的替换。

const HappyPack = require('happypack');
module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'happypack/loader?id=js',
      },
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: 'happypack/loader?id=ts',
      }
    ],
  },
  plugins: [
    new HappyPack({
      id: 'js',
      loaders: [{
        loader: 'babel-loader',
        options: {}, // babel options
      }],
    }),
    new HappyPack({
      id: 'ts',
      loaders: [{
        loader: 'ts-loader',
        options: {}, // ts options
      }],
    })
  ]
};

在使用多个HappyPack loader的同时也就意味着要插入多个HappyPack的插件,每个插件加上id来作为标识。同时我们也可以为每个插件设置具体不同的配置项,如使用的线程数、是否开启debug模式等。

缩小打包作用域

从宏观角度来看,提升性能的方法无非两种:增加资源或者缩小范围。增加资源就是指使用更多CPU和内存,用更多的计算能力来缩短执行任务的时间;缩小范围则是针对任务本身,比如去掉冗余的流程,尽量不做重复性的工作等。前面我们说的HappyPack属于增加资源,那么接下来我们再谈谈如何缩小范围。

  • exclude和include

  • noParse

有些库我们是希望Webpack完全不要去进行解析的,即不希望应用任何loader规则,库的内部也不会有对其他模块的依赖,那么这时可以使用noParse对其进行忽略。

module.exports = {
  //...
  module: {
    noParse: /lodash/,
  }
}

上面的配置将会忽略所有文件名中包含lodash的模块,这些模块仍然会被打包进资源文件,只不过Webpack不会对其进行任何解析。

  • IgnorePlugin

exclude和include是确定loader的规则范围,noParse是不去解析但仍会打包到bundle中。最后让我们再看一个插件IgnorePlugin,它可以完全排除一些模块,被排除的模块即便被引用了也不会被打包进资源文件中。

这对于排除一些库相关文件非常有用。一些由库产生的额外资源我们用不到但又无法去掉,因为引用的语句处于库文件的内部。比如,Moment.js是一个日期时间处理相关的库,为了做本地化它会加载很多语言包,对于我们来说一般用不到其他地区的语言包,但它们会占很多体积,这时就可以用IgnorePlugin来去掉。

plugins: [
  new webpack.IgnorePlugin({
    resourceRegExp: /^\.\/locale$/, // 匹配资源文件
    contextRegExp: /moment$/, // 匹配检索目录
  })
],
  • Cache

有些loader会有一个cache配置项,用来在编译代码后同时保存一份缓存,在执行下一次编译前会先检查源码文件是否有变化,如果没有就直接采用缓存,也就是上次编译的结果。这样相当于实际编译的只有变化了的文件,整体速度上会有一定提升。

动态链接库与DllPlugin

DllPlugin借鉴了动态链接库的这种思路,对于第三方模块或者一些不常变化的模块,可以将它们预先编译和打包,然后在项目实际构建过程中直接取用即可。

DllPlugin和Code Splitting有点类似,都可以用来提取公共模块,但本质上有一些区别。Code Splitting的思路是设置一些特定的规则并在打包的过程中根据这些规则提取模块;DllPlugin则是将vendor完全拆出来,有自己的一整套Webpack配置并独立打包,在实际工程构建时就不用再对它进行任何处理,直接取用即可。因此,理论上来说,DllPlugin会比Code Splitting在打包速度上更胜一筹,但也相应地增加了配置,以及资源管理的复杂度。 下面我们一步步来进行DllPlugin的配置。

vendor配置和打包

首先需要为动态链接库单独创建一个Webpack配置文件,比如命名为webpack.vendor.config.js(webpack.dll.config.js),用来区别工程本身的配置文件webpack.config.js。

// package.json
{
  ...
  "scripts": {
    "dll": "webpack --config webpack.vendor.config.js"
  },
}
// webpack.vendor.config.js
const path = require('path');
const webpack = require('webpack');
const dllAssetPath = path.join(__dirname, 'dll');
const dllLibraryName = 'dllExample';
module.exports = {
  entry: ['react'],
  output: {
    path: dllAssetPath,
    filename: 'vendor.js',
    library: dllLibraryName,
  },
  plugins: [
    new webpack.DllPlugin({
      name: dllLibraryName,
      path: path.join(dllAssetPath, 'manifest.json'),
    })
  ],
};

配置中的entry指定了把哪些模块打包为vendor。plugins的部分我们引入了Dll-Plugin,并添加了以下配置项。

  • name:导出的dll library的名字,它需要与output.library的值对应。
  • path:资源清单的绝对路径,业务代码打包时将会使用这个清单进行模块索引。

Tree shaking

ES6 Module依赖关系的构建是在代码编译时而非运行时。基于这项特性Webpack提供了tree shaking功能,它可以在打包过程中帮助我们检测工程中没有被引用过的模块,这部分代码将永远无法被执行到,因此也被称为“死代码”。Webpack会对这部分代码进行标记,并在资源压缩时将它们从最终的bundle中去掉。下面的例子简单展示了tree shaking是如何工作的。

// index.js
import { foo } from './util';
foo();

// util.js
export function foo() {
    console.log('foo');
}
export function bar() {  // 没有被任何其他模块引用,属于“死代码”
    console.log('bar');
}

在Webpack打包时会对bar()添加一个标记,在正常开发模式下它仍然存在,只是在生产环境的压缩那一步会被移除掉。

tree shaking有时可以使bundle体积显著减小,而实现tree shaking则需要一些前提条件。

  • ES6 Module

tree shaking只能对ES6 Module生效。有时我们会发现虽然只引用了某个库中的一个接口,却把整个库加载进来了,而bundle的体积并没有因为tree shaking而减小。这可能是由于该库是使用CommonJS的形式导出的,为了获得更好的兼容性,目前大部分的npm包还在使用CommonJS的形式。也有一些npm包同时提供了ES6 Module和CommonJS两种形式导出,我们应该尽可能使用ES6 Module形式的模块,这样tree shaking的效率更高。

  • 使用 Webpack 进行依赖关系构建

如果我们在工程中使用了babel-loader,那么一定要通过配置来禁用它的模块依赖解析。因为如果由babel-loader来做依赖解析,Webpack接收到的就都是转化过的CommonJS形式的模块,无法进行tree-shaking。禁用babel-loader模块依赖解析的配置示例如下:

module.exports = {
  // ...
  module: {
    rules: [{
      test: /\.js$/,
      exclude: /node_modules/,
      use: [{
        loader: 'babel-loader',
        options: {
          presets: [
            // 这里一定要加上 modules: false
            [@babel/preset-env, { modules: false }]
          ],
        },
      }],
    }],
  },
};
  • 使用压缩工具去除死代码

tree shaking本身只是为死代码添加上标记,真正去除死代码是通过压缩工具来进行的。使用我们前面介绍过的terser-webpack-plugin即可。在Webpack 4之后的版本中,将mode设置为production也可以达到相同的效果。

九、开发环境调优

Webpack开发效率插件

  • webpack-dashboard

Webpack每一次构建结束后都会在控制台输出一些打包相关的信息,但是这些信息是以列表的形式展示的,有时会显得不够直观。webpack-dashboard就是用来更好地展示这些信息的。

  • webpack-merge

  • speed-measure-webpack-plugin

  • size-plugin

模块热替换

Webpack则在live reload的基础上又进了一步,可以让代码在网页不刷新的前提下得到最新的改动,我们甚至不需要重新发起请求就能看到更新后的效果。这就是模块热替换功能(Hot Module Replacement,HMR)。

试想一个复杂的系统每改动一个地方都要经历资源重构建、网络请求、浏览器渲染等过程,怎么也要几秒甚至几十秒的时间才能完成;况且我们调试的页面可能位于很深的层级,每次还要通过一些人为操作才能验证结果,其效率是非常低下的。而HMR则可以在保留页面当前状态的前提下呈现出最新的改动,可以节省开发者大量的时间成本。

开启HMR

HMR是需要手动开启的,并且有一些必要条件。 首先我们要确保项目是基于webpack-dev-server或者webpack-dev-middle进行开发的,Webpack本身的命令行并不支持HMR。下面是一个使用webpack-dev-server开启HMR的例子。

const webpack = require('webpack');
module.exports = {
  // ...
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  devServer: {
    hot: true,
  },
};

调用HMR API有两种方式,一种是手动地添加这部分代码;另一种是借助一些现成的工具,比如react-hot-loader、vue-loader等。

如果应用的逻辑比较简单,我们可以直接手动添加代码来开启HMR。比如下面这个例子:

// index.js
import { add } from 'util.js';
add(2, 3);

if (module.hot) {
  module.hot.accept();
}

HMR原理

在开启HMR的状态下进行开发,你会发现资源的体积会比原本的大很多,这是因为Webpack为了实现HMR而注入了很多相关代码。

在本地开发环境下,浏览器是客户端,webpack-dev-server(WDS)相当于是我们的服务端。HMR的核心就是客户端从服务端拉取更新后的资源(准确地说,HMR拉取的不是整个资源文件,而是chunk diff,即chunk需要更新的部分)。

Webpack和Parcel的比较

IMG_6357.JPG

IMG_6358.JPG

参考文章

《Webpack实战:入门、进阶与调优》读书笔记