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