Webpack实战:入门、进阶与调优(第2版)
上QQ阅读APP看书,第一时间看更新

2.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在前端开发领域逐渐流行起来。

2.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,可见每个模块是拥有各自的作用域的。

2.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');

在实际使用中,为了提高可读性,不建议采用上面的写法,而是应该将module.exports及exports语句放在模块的末尾。

2.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);
});