Webpack——模块打包

模块之于程序,就如同细胞之于生物体,是具有特定功能的组成单元。不同的模块负责不同的工作,它们以某种方式联系在一起,共同保证程序的正常运转。介绍Webpack如何对其进行打包以及合并,主要内容如下:

  • 不同模块的标准以及它们之间的区别;
  • 如何编写模块;
  • 模块打包的原理。

随着JavaScript语言的发展,社区中产生了很多模块标准。在认识这些标准的同时,也要了解其背后的思想。例如,它为什么会有这个特性,或者为什么要这样去实现。这对我们自己编写模块也会有所帮助。

1、CommonJS

CommonJS是由JavaScript社区于2009年提出的包含模块、文件、IO、控制台在内的一系列标准。Node.js的实现中采用了CommonJS标准的一部分,并在其基础上进行了一些调整。我们所说的CommonJS模块和Node.js中的实现并不完全一样,现在一般谈到CommonJS其实是Node.js中的版本,而非它的原始定义。

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

不仅如此,借助Node.js的包管理器,npm开发者还可以获取他人的代码库,或者把自己的代码发布上去供他人使用。这种可共享的传播方式使CommonJS在前端开发领域逐渐流行起来。

1.1、模块

CommonJS中规定每个文件是一个模块。将一个JavaScript文件直接通过script标签插入页面中与封装成CommonJS模块最大的不同在于,前者的顶层作用域是全局作用域,在进行变量及函数声明时会污染全局环境;而后者会形成一个属于模块自身的作用域,所有的变量及函数只有自己能访问,对外是不可见的。请看下面的例子:

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

这里有两个文件,在index.js中我们通过CommonJS的require函数加载calculator.js。运行之后控制台结果是“index.js”,说明calculator.js中的变量声明并不会影响index.js,可见每个模块是拥有各自的作用域的。

1.2、导出

导出是一个模块向外暴露自身的唯一方式。在CommonJS中,通过module.exports可以导出模块中的内容,如:

module.exports = {
    name: 'calculater',
    add: function(a, b) {
        return a + b;
    }
};

CommonJS模块内部会用一个module对象存放当前模块的信息,可以理解成在每个模块的最开始定义了以下对象:

var module = {...};
// 模块自身逻辑
module.exports = {...};

module.exports用来指定该模块要对外暴露哪些内容,在上面的代码中我们导出了一个对象,包含name和add两个属性。为了书写方便,CommonJS也支持另一种简化的导出方式——直接使用exports。

exports.name = 'calculater';
exports.add = function(a, b) {
    return a + b;
};

在实现效果上,这段代码和上面的module.exports没有任何不同。其内在机制是将exports指向module.exports,而module.exports在初始化时是一个空对象。我们可以简单地理解为,CommonJS在每个模块的首部默认添加了以下代码:

var module = {
    exports: {},
};
var exports = module.exports;

因此,为exports.add赋值相当于在module.exports对象上添加一个属性。

在使用exports时要注意一个问题,即不要直接给exports赋值,否则会导致其失效。如:

exports = {
    name: 'calculater'
};

以上代码中,由于对exports进行了赋值操作,使其指向了新的对象,而module.exports却仍然指向原来的空对象,因此name属性并不会被导出。

另一个在导出时容易犯的错误是不恰当地把module.exports与exports混用。

exports.add = function(a, b) {
    return a + b;
};
module.exports = {
    name: 'calculater'
};

上面的代码先通过exports导出了add属性,然后将module.exports重新赋值为另外一个对象。这会导致原本拥有add属性的对象丢失了,最后导出的只有name。

另外,要注意导出语句不代表模块的末尾,在module.exports或exports后面的代码依旧会照常执行。比如下面的console会在控制台上打出“end”:

module.exports = {
    name: 'calculater'
};
console.log('end');

1.3、导入

在CommonJS中使用require语法进行模块导入。如:

// calculator.js
module.exports = {
    add: function(a, b) {return a + b;}
};
// index.js
const calculator = require('./calculator.js');
const sum = calculator.add(2, 3);
console.log(sum); // 5

我们在index.js中导入了calculator模块,并调用了它的add函数。

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

  • 该模块未曾被加载过。这时会首先执行该模块,然后获取到该模块最终导出的内容。
  • 该模块已经被加载过。这时该模块的代码不会再次执行,而是直接获取该模块上一次导出的内容。

请看下面的例子:

// calculator.js
console.log('running calculator.js');
module.exports = {
    name: 'calculator',
    add: function(a, b) {
        return a + b;
    }
};
   
// index.js
const add = require('./calculator.js').add;
const sum = add(2, 3);
console.log('sum:', sum);
const moduleName = require('./calculator.js').name;
console.log('end');

控制台的输出结果如下:

running calculator.js
sum: 5
end

从结果可以看到,尽管我们有两个地方使用require导入了calculator.js,但其内部代码只执行了一遍。

我们前面提到,模块会有一个module对象用来存放其信息,这个对象中有一个属性loaded用于记录该模块是否被加载过。loaded的值默认为false,在模块第一次被加载和执行过后会置为true,后面再次加载时检查到module.loaded为true,则不会再次执行模块代码。

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

require('./task.js');

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

const moduleNames = ['foo.js', 'bar.js'];
moduleNames.forEach(name => {
    require('./' + name);
});

2、ES6 Module

JavaScript之父Brendan Eich在最初设计这门语言时,并没有包含模块的概念。基于越来越多的工程需求,为了使用模块化进行开发,JavaScript社区涌现出了多种模块标准,其中也包括CommonJS。一直到2015年6月,由TC39标准委员会正式发布了ES6(ECMAScript 6.0),自此JavaScript语言才具备了模块这一特性。

2.1、模块

请看下面的例子,我们使用ES6的方式改写前面的calculator.js和index.js。

// calculator.js
export default {
    name: 'calculator',
    add: function(a, b) {
        return a + b;
    }
};
// index.js
import calculator from './calculator.js';
const sum = calculator.add(2, 3);
console.log(sum); // 5

ES6 Module也是将每个文件作为一个模块,每个模块拥有自身的作用域,不同的是导入、导出语句。ES6版本将import和export作为保留关键字加入了进来(CommonJS中的module并不属于关键字)。

ES6 Module会自动采用严格模式,该模式在ES5(ECMAScript 5.0)中只是一个可选项。也就是说,以前我们可以通过选择是否在文件开始时加上use strict来控制严格模式,而在ES6 Module中不管开头是否有use strict都会采用严格模式。所以,在将原本是CommonJS的模块或任何未开启严格模式的代码改写为ES6 Module时要注意这一点。

1.2、导出

在ES6 Module中使用export命令来导出模块。export有两种形式:

  • 命名导出
  • 默认导出

一个模块可以有多个命名导出。它有两种不同的写法:

// 写法1
export const name = 'calculator';
export const add = function(a, b) { return a + b; };
// 写法2
const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add };

第1种写法是将变量的声明和导出写在一行;第2种则是先进行变量声明,再用同一个export语句导出。两种写法的效果是一样的。

在使用命名导出时,可以通过as关键字对变量重命名。如:

const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add as getSum }; // 在导入时即为 name 和 getSum

与命名导出不同,模块的默认导出只能有一个。如:

export default {
    name: 'calculator',
    add: function(a, b) {
        return a + b;
    }
};

我们可以将export default理解为对外输出了一个名为default的变量,因此不需要像命名导出一样进行变量声明,直接导出值即可。

// 导出字符串
export default 'This is calculator.js';
// 导出 class
export default class {...}
// 导出匿名函数
export default function() {...}

2.3、导入

ES6 Module中使用import语法导入模块。首先我们来看如何加载带有命名导出的模块,请看下面的例子:

// calculator.js
const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add };
   
// index.js
import { name, add } from './calculator.js';
add(2, 3);

加载带有命名导出的模块时,import后面要跟一对大括号来将导入的变量名包裹起来,并且这些变量名应该与导出的变量名完全一致。导入变量的效果相当于在当前作用域下声明了这些变量(name和add),并且不可对其进行更改,也就是所有导入的变量都是只读的。

与命名导出类似,我们可以通过as关键字对导入的变量重命名。如:

import { name, add as calculateSum } from './calculator.js';
calculateSum(2, 3);

在导入多个变量时,我们还可以采用整体导入的方式。如:

import * as calculator from './calculator.js';
console.log(calculator.add(2, 3));
console.log(calculator.name);

使用import * as 可以把所有导入的变量作为属性值添加到对象中,从而减少对当前作用域的影响。

接下来处理默认导出,请看下面这个例子:

// calculator.js
export default {
    name: 'calculator',
    add: function(a, b) { return a + b; }
};
   
// index.js
import myCalculator from './calculator.js';
calculator.add(2, 3);

对于默认导出来说,import后面直接跟变量名,并且这个名字可以自由指定(比如这里是myCalculator),它指代了calculator.js中默认导出的值。从原理上可以这样去理解:

import { default as myCalculator } from './calculator.js';

最后看一个两种导入方式混合起来的例子:

// index.js
import React, { Component } from 'react';

这里的React对应的是该模块的默认导出,而Component则是其命名导出中的一个变量。

**注意:**这里的React必须写在大括号前面,不能颠倒顺序,否则会提示语法错误。

2.4、复合写法

在工程中,有时需要把某一个模块导入之后立即导出,比如专门用来集合所有页面或组件的入口文件。此时可以采用复合写法:

export { name, add } from './calculator.js';

复合写法目前只支持被导入模块(这里的calculator.js)通过命名导出的方式暴露出来的变量,默认导出则没有对应的复合形式,只能将导入和导出拆开写。

import calculator from "./calculator.js ";
export default calculator;

3、CommonJS与ES6 Module的区别

分别介绍了CommonJS和ES6 Module两种形式的模块定义,在实际开发过程中我们经常会将二者混用,因此这里有必要对比一下它们各自的特性。

3.1、动态与静态

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

让我们先看一个CommonJS的例子:

// calculator.js
module.exports = { name: 'calculator' };
// index.js
const name = require('./calculator.js').name;

在上面介绍CommonJS的部分时我们提到过,模块A在加载模块B时(在上面的例子中是index.js加载calculator.js)会执行B中的代码,并将其module.exports对象作为require函数的返回值返回。require的模块路径可以动态指定,支持传入一个表达式,甚至可以通过if语句判断是否加载某个模块。因此,在CommonJS模块被执行前,我们并没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段。

针对同样的例子,我们再对比看下ES6 Module的写法:

// calculator.js
export const name = 'calculator';
// index.js
import { name } from './calculator.js';

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

  • 死代码检测和排除。我们可以用静态分析工具检测出哪些模块没有被调用过。比如,在引入工具类库时,工程中往往只用到了其中一部分组件或接口,但有可能会将其代码完整地加载进来。未被调用到的模块代码永远不会被执行,也就成了死代码。通过静态分析可以在打包时去掉这些未曾使用过的模块,以减小打包资源体积。
  • 模块变量类型检查。JavaScript属于动态类型语言,不会在代码执行前检查类型错误(比如对一个字符串类型的值进行函数调用)。ES6 Module的静态模块结构有助于确保模块之间传递的值或接口类型是正确的。
  • 编译器优化。在CommonJS等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,而ES6 Module支持直接导入变量,减少了引用层级,程序效率更高。

3.2、值复制与动态映射

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

上面的话可能有些难以理解,我们先来看一个例子,了解一下什么是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);
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的值也随之变化。并且ES6Module规定不能对导入的变量进行修改,当我们尝试去修改时它会抛出该变量只读的错误。

3.3、循环依赖

循环依赖是指模块A依赖于模块B,同时模块B依赖于模块A。比如下面这个例子:

// 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进行了导入,这里产生了循环依赖。需要注意的是,执行权并不会再交回foo.js,而是直接取其导出值,也就是module.exports。但由于foo.js未执行完毕,导出值在这时为默认的空对象,因此当bar.js执行到打印语句时,我们看到控制台中的value of foo就是一个空对象。
4)bar.js执行完毕,将执行权交回foo.js。5)foo.js从require语句继续向下执行,在控制台打印出value of bar(这个值是正确的),整个流程结束。

由上面可以看出,尽管循环依赖的模块均被执行了,但模块导入的值并不是我们想要的。因此如果在CommonJS中遇到循环依赖,我们将没有办法得到预想中的结果。

我们再从Webpack的实现角度来看,将上面的例子打包后,bundle中有这样一段非常重要的代码:

// The require function
function __webpack_require__(moduleId) {
  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: {}
  };
  ...
}

当index.js引用了foo.js之后,相当于执行了这个__webpack_require__函数,初始化了一个module对象并放入installedModules中。当bar.js再次引用foo.js时,又执行了该函数,但这次是直接从installedModules里面取值,此时它的module.exports是一个空对象。这就解释了上面在第3步看到的现象。

接下来我们使用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';
console.log('value of foo:', foo);
export default 'This is bar.js';
   
// index.js
import foo from './foo.js';

执行结果如下:

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

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

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

//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 = 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中导入的是值的副本,不会随着模块中原有值的变化而变化。
5)执行权回到index.js并调用foo函数,此时会依次执行foo→bar→foo,并在控制台输出正确的值。

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

4、加载其他类型的模块

前面我们介绍的主要是CommonJS和ES6 Module,除此之外,在开发中我们还有可能遇到其他类型的模块,目前AMD、UMD等模块使用的场景已经不多,但当遇到这类模块时我们仍然需要知道如何处理。

4.1、非模块化文件

非模块化文件指的是并不遵循任何一种模块标准的文件。如果你维护的是一个几年前的项目,那么里面极有可能有非模块化文件,最常见的就是在script标签中引入的jQuery及其各种插件。

如何使用Webpack打包这类文件呢?其实只要直接引入即可,如:

import './jquery.min.js';

这句代码会直接执行jquery.min.js。一般来说,jQuery这类库都是将其接口绑定在全局,因此无论是从script标签引入,还是使用Webpack打包的方式引入,其最终效果是一样的。

但假如我们引入的非模块化文件是以隐式全局变量声明的方式暴露其接口的,则会发生问题。如:

// 通过在顶层作用域声明变量的方式暴露接口
var calculator = {
    // ...
}

由于Webpack在打包时会为每一个文件包装一层函数作用域来避免全局污染,上面的代码将无法把calculator对象挂在全局,因此需要格外注意这种隐式全局变量声明。

4.2、AMD

AMD(Asynchronous Module Definition,异步模块定义)是由JavaScript社区提出的专注于支持浏览器端模块化的标准。从名字就可以看出它与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)。目前AMD在实际中已经用得越来越少,大多数开发者还是会选择CommonJS或ES6Module的形式。

4.3、UMD

我们已经介绍了很多模块形式,如CommonJS、ES6Module、AMD及非模块化文件,在很多时候工程中会用到其中两种形式甚至更多。有时对于一个库或者框架的开发者来说,如果面向的使用群体足够庞大,就需要考虑支持各种模块形式。

严格来说,UMD并不是一种模块标准,而是一组模块形式的集合。UMD的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境(当时ES6Module还未被提出)。

请看下面的例子:

// 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的形式导出。

4.4、加载npm模块

与Java、C++、Python等语言相比,JavaScript是一种缺乏标准库的语言。当开发者需要解决URL处理、日期解析这类很常见的问题时,很多时候只能自己动手来封装工具接口。而npm提供了这样一种方式,可以让开发者在其平台上找到由他人所开发和发布的库并安装到项目中,从而快速地解决问题,这就是npm作为包管理器为开发者带来的便利。

很多语言都有包管理器,比如Java的Maven,Ruby的gem。目前,JavaScript最主流的包管理器有两个——npm和yarn。两者的仓库是共通的,只是在使用上有所区别。截至目前,npm平台上已经有几十万个模块(也可称为包),并且这个数字每天都在增加,各种主流的框架类库都可以在npm平台上找到。作为开发者,每个人也都可以自己封装模块并上传到npm,通过这种方式来与他人共享代码。

那么如何从我们的本地工程安装和加载一个外部的npm模块呢?首先我们需要初始化一个npm工程,并通过npm来获取模块。下面以lodash这个库为例:

# 项目初始化
npm init –y
# 安装lodash
npm install lodash

执行了上面的命令之后,npm会将lodash安装在工程的node_modules目录下,并将对该模块的依赖信息记录在package.json中。

在使用时,加载一个npm模块的方式很简单,只需要引入包的名字即可:

// index.js
import _ from 'lodash';

Webpack在解析到这条语句时会自动去node_modules中寻找名为lodash的模块,而不需要我们写出从源文件index.js到node_modules中lodash的路径。

现在我们知道,在导入一个npm模块时,只要写明它的名字就可以了。那么在实际打包的过程中具体加载的是npm模块中的哪个JS文件呢?

每一个npm模块都有一个入口。当我们加载一个模块时,实际上就是加载该模块的入口文件。这个入口被维护在模块内部package.json文件的main字段中。

比如对于前面的lodash模块来说,它的package.json内容如下:

// ./node_modules/underscore/package.json
{
  "name": "lodash",
  ……
  "main": "lodash.js"
}

当加载该模块时,实际上加载的是node_modules/lodash/lodash.js。

除了直接加载模块以外,我们也可以通过/的形式单独加载模块内部的某个JS文件。如:

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

这样,Webpack最终只会打包node_modules/lodash/fp/all.js这个文件,而不会打包全部的lodash库,进而减小打包资源的体积。

5、模块打包原理

面对工程中成百上千个模块,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打包后将会成为如下形式(为了易读性,这里只展示代码的大体结构):

// 立即执行匿名函数
(function(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: function(module, exports) {
        // calculator.js 内容
    }
});

这是一个最简单的Webpack打包结果(bundle),但已经可以清晰地展示出它是如何将具有依赖关系的模块串联在一起的。上面的bundle分为以下几个部分:

  • 最外层匿名函数。它用来包裹整个bundle,并构成自身的作用域。
  • installedModules对象。每个模块只在第一次被加载的时候执行,之后其导出值就被存储到这个对象里面,当再次被加载的时候webpack会直接从这里取值,而不会重新执行该模块。
  • 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模块打包的奥秘。

6、总结

介绍了JavaScript模块,包括主流模块标准的定义,以及在Webpack中是如何进行模块打包的。

CommonJS和ES6 Module是目前使用较为广泛的模块标准。它们的主要区别在于前者是在运行时建立模块依赖关系,后者是在编译时建立;在模块导入方面,CommonJS导入的是值副本,ES6 Module导入的是只读的变量映射;ES6 Module通过其静态特性可以进行编译过程中的优化,并且具备处理循环依赖的能力。

你可能感兴趣的:(#,Node+Webpack,Web,webpack,前端,node.js)