本文参考
Javascript模块化编程(一):模块的写法
Javascript模块化编程(二):AMD规范
Javascript模块化编程(三):require.js的用法
随着网站逐渐变成"互联网应用程序",嵌入网页的Javascript代码越来越庞大,越来越复杂。网页越来越像桌面程序,需要一个团队分工协作、进度管理、单元测试等等......开发者不得不使用软件工程的方法,管理网页的业务逻辑。Javascript模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。但是,Javascript不是一种模块化编程语言,它不支持"类"(class),更遑论"模块"(module)了。(正在制定中的ECMAScript标准第六版,将正式支持"类"和"模块",但还需要很长时间才能投入实用。)Javascript社区做了很多努力,在现有的运行环境中,实现"模块"的效果。本文总结了当前"Javascript模块化编程"的最佳实践,说明如何投入实用。虽然这不是初级教程,但是只要稍稍了解Javascript的基本语法,就能看懂。
一、原始写法
模块就是实现特定功能的一组方法。只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。
function m1(){
//...
}
function m2(){
//...
}
上面的函数m1()和m2(),组成一个模块。使用的时候,直接调用就行了。这种做法的缺点很明显:"污染"了全局变量,无法保证不与其他模块发生变量名冲突,而且模块成员之间看不出直接关系。
二、对象写法
为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面。
var module1 = new Object({
_count : 0,
m1 : function (){
//...
},
m2 : function (){
//...
}
});
上面的函数m1()和m2(),都封装在module1对象里。使用的时候,就是调用这个对象的属性。module1.m1();
但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。module1._count = 5;
其实这种写法在ES5中又叫命名空间,参考 JS命名空间的使用
var MYNAMESPACE = MYNAMESPACE || {};
MYNAMESPACE.person = function(name) {
this.name = name;
};
MYNAMESPACE.person.prototype.getName = function() {
return this.name;
};
// 使用方法
var p = new MYNAMESPACE.person("doc");
p.getName();
在没有类似 AMD 这样的模块化的格式和规范时,在前端的 JavaScript 中,命名空间是是一个非常棒的实践,用于避免全局变量污染以及用于组织代码模块的逻辑性,扩展性,可读性和可维护性。通常选择一个顶级的应用命名空间并且这个命名空间(即对象)是唯一一个挂载到全局对象上的对象(在浏览器中是 window,在 Node.js 应用中是 global)。当选择了应用的顶级命名空间,嵌套命名空间可被用于一个模块化的架构。例如,考虑下面:
var myMasterNS = myMasterNS || {};
myMasterNS.mySubNS = myMasterNS.mySubNS || {};
myMasterNS.mySubNS.someFunction = function(){
//插入逻辑
};
这里我们声明了一个名为 myMasterNS 的简单全局对象,又分配了一个子命名空间对象作为原来命名空间的一个属性,这个例子中是 mySubNS。现在我们能够在顶级命名空间下实现任何所需的功能,并且任何后面的子命名空间都不会污染全局作用域。请注意,这不是使用 JavaScript 实现命名空间的唯一模式。我在这个例子中选择它,是因为这种模式的简单和可读性。
三、立即执行函数写法
使用"立即执行函数"(Immediately-Invoked Function Expression,IIFE),可以达到不暴露私有成员的目的。
var module1 = (function(){
var _count = 0;
var m1 = function(){
//...
};
var m2 = function(){
//...
};
return {
m1 : m1,
m2 : m2
};
})();
使用上面的写法,外部代码无法读取内部的_count变量。console.info(module1._count); //undefined
module1就是Javascript模块的基本写法。下面,再对这种写法进行加工。
四、放大模式
如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用"放大模式"(augmentation)。
var module1 = (function (mod){
mod.m3 = function () {
//...
};
return mod;
})(module1);
上面的代码为module1模块添加了一个新方法m3(),然后返回新的module1模块。
五、宽放大模式(Loose augmentation)
在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上一节的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用"宽放大模式"。
var module1 = ( function (mod){
//...
return mod;
})(window.module1 || {});
与"放大模式"相比,"宽放大模式"就是"立即执行函数"的参数可以是空对象。
六、输入全局变量
独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。为了在模块内部调用全局变量,必须显式地将其他变量输入模块。
var module1 = (function ($, YAHOO) {
//...
})(jQuery, YAHOO);
上面的module1模块需要使用jQuery库和YUI库,就把这两个库(其实是两个模块)当作参数输入module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。这方面更多的讨论,参见Ben Cherry的著名文章《JavaScript Module Pattern: In-Depth》。
七、模块的规范
先想一想,为什么模块很重要?因为有了模块,我们就可以更方便地使用别人的代码,想要什么功能,就加载什么模块。但是,这样做有一个前提,那就是大家必须以同样的方式编写模块,否则你有你的写法,我有我的写法,岂不是乱了套!考虑到Javascript模块现在还没有官方规范,这一点就更重要了。目前,通行的Javascript模块规范共有两种:CommonJS和AMD。我主要介绍AMD,但是要先从CommonJS讲起。
八、CommonJS
2009年,美国程序员Ryan Dahl创造了node.js项目,将javascript语言用于服务器端编程。这标志"Javascript模块化编程"正式诞生。因为老实说,在浏览器环境下,没有模块也不是特别大的问题,毕竟网页程序的复杂性有限;但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。
node.js的模块系统,就是参照CommonJS规范实现的。在CommonJS中,有一个全局性方法require(),用于加载模块。假定有一个数学模块math.js,就可以像下面这样加载。
var math = require('math');
然后,就可以调用模块提供的方法:
var math = require('math');
math.add(2,3); // 5
关于CommonJs后文详述
九、浏览器环境
有了服务器端模块以后,很自然地,大家就想要客户端模块。而且最好两者能够兼容,一个模块不用修改,在服务器和浏览器都可以运行。但是,由于一个重大的局限,使得CommonJS规范不适用于浏览器环境。还是上一节的代码,如果在浏览器中运行,会有一个很大的问题,你能看出来吗?
var math = require('math');
math.add(2, 3);
第二行math.add(2, 3),在第一行require('math')之后运行,因此必须等math.js加载完成。也就是说,如果加载时间很长,整个应用就会停在那里等。这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于"假死"状态。因此,浏览器端的模块,不能采用"同步加载"(synchronous),只能采用"异步加载"(asynchronous)。这就是AMD规范诞生的背景。
十、AMD
AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。AMD也采用require()语句加载模块,但是不同于CommonJS,它要求两个参数:
require([module], callback);
第一个参数[module],是一个数组,里面的成员就是要加载的模块;第二个参数callback,则是加载成功之后的回调函数。如果将前面的代码改写成AMD形式,就是下面这样:
require(['math'], function (math) {
math.add(2, 3);
});
math.add()与math模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD比较适合浏览器环境。目前,主要有两个Javascript库实现了AMD规范:require.js和curl.js。本系列的第三部分,将通过介绍require.js,进一步讲解AMD的用法,以及如何将模块化编程投入实战。
十一、为什么要用require.js?
最早的时候,所有Javascript代码都写在一个文件里面,只要加载这一个文件就够了。后来,代码越来越多,一个文件不够了,必须分成多个文件,依次加载。下面的网页代码,相信很多人都见过。
<script src="1.js"></script>
<script src="2.js"></script>
<script src="3.js"></script>
<script src="4.js"></script>
<script src="5.js"></script>
<script src="6.js"></script>
这段代码依次加载多个js文件。这样的写法有很大的缺点。首先,加载的时候,浏览器会停止网页渲染,加载文件越多,网页失去响应的时间就会越长;其次,由于js文件之间存在依赖关系,因此必须严格保证加载顺序(比如上例的1.js要在2.js的前面),依赖性最大的模块一定要放到最后加载,当依赖关系很复杂的时候,代码的编写和维护都会变得困难。require.js的诞生,就是为了解决这两个问题:
(1)实现js文件的异步加载,避免网页失去响应;
(2)管理模块之间的依赖性,便于代码的编写和维护。
十二、require.js的加载
使用require.js的第一步,是先去官方网站下载最新版本。下载后,假定把它放在js子目录下面,就可以加载了。
<script src="js/require.js"></script>
有人可能会想到,加载这个文件,也可能造成网页失去响应。解决办法有两个,一个是把它放在网页底部加载,另一个是写成下面这样:
<script src="js/require.js" defer async="true" ></script>
async属性表明这个文件需要异步加载,避免网页失去响应。IE不支持这个属性,只支持defer,所以把defer也写上。加载require.js以后,下一步就要加载我们自己的代码了。假定我们自己的代码文件是main.js,也放在js目录下面。那么,只需要写成下面这样就行了:
<script src="js/require.js" data-main="js/main"></script>
data-main属性的作用是,指定网页程序的主模块。在上例中,就是js目录下面的main.js,这个文件会第一个被require.js加载。由于require.js默认的文件后缀名是js,所以可以把main.js简写成main。
十三、require.js主模块的写法
上一节的main.js,我把它称为"主模块",意思是整个网页的入口代码。它有点像C语言的main()函数,所有代码都从这儿开始运行。下面就来看,怎么写main.js。如果我们的代码不依赖任何其他模块,那么可以直接写入javascript代码。
// main.js
alert("加载成功!");
但这样的话,就没必要使用require.js了。真正常见的情况是,主模块依赖于其他模块,这时就要使用AMD规范定义的的require()函数。
// main.js
require(['moduleA', 'moduleB', 'moduleC'], function (moduleA, moduleB, moduleC){
// some code here
});
require()函数接受两个参数。第一个参数是一个数组,表示所依赖的模块,上例就是['moduleA', 'moduleB', 'moduleC'],即主模块依赖这三个模块;第二个参数是一个回调函数,当前面指定的模块都加载成功后,它将被调用。加载的模块会以参数形式传入该函数,从而在回调函数内部就可以使用这些模块。require()异步加载moduleA,moduleB和moduleC,浏览器不会失去响应;它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性的问题。
更多细节参考
http://www.requirejs.cn
【JavaScript】RequireJS模块化之HelloWorld
十四、CMD
参考
浅析JS模块规范:AMD,CMD,CommonJS
AMD 和 CMD 的区别有哪些?
AMD虽然实现了异步加载,但是开始就把所有依赖写出来是不符合书写的逻辑顺序的,能不能像commonJS那样用的时候再require,而且还支持异步加载后再执行呢?CMD (Common Module Definition), 是seajs推崇的规范,CMD则是依赖就近,用的时候再require。它写起来是这样的:
define(function(require, exports, module) {
var clock = require('clock');
clock.start();
});
AMD和CMD最大的区别是对依赖模块的执行时机处理不同,而不是加载的时机或者方式不同,二者皆为异步加载模块。
AMD依赖前置,js可以方便知道依赖模块是谁,立即加载;而CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块,这也是很多人诟病CMD的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略。
十五、UMD
UMD是AMD和CommonJS的糅合。AMD模块以浏览器第一的原则发展,异步加载模块。CommonJS模块以服务器第一原则发展,选择同步加载,它的模块无需包装(unwrapped modules)。这迫使人们又想出另一个更通用的模式UMD (Universal Module Definition)。希望解决跨平台的解决方案。
UMD先判断是否支持Node.js的模块(exports)是否存在,存在则使用Node.js模块模式。在判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。
(function (window, factory) {
if (typeof exports === 'object') {
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
define(factory);
} else {
window.eventUtil = factory();
}
})(this, function () {
//module ...
});
十六、JavaScript模块化编程探索
CommonJS团队定义了module格式来解决JavaScript作用域问题,这样确保了每一个module都在自己的命名空间下执行。根据CommonJS的规范,每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。CommonJS给出2个工具来实现模块之间的依赖:
- require() 用于在当前作用域引入已有的模块
- module object 用于从当前作用域导出一些东东
那就先搞一个Hello world的小栗子来试下吧!新建一个项目文件夹吧,虽然项目很小。。。起名commonjs,在里边新建2个JavaScript文件,分别命名为world.js和salute.js,代码如下:
// salute.js 打招呼
var MySalute = "Hello";
module.exports = MySalute;
/*注意上下是分别写在2个文件js文件里哦*/
// world.js
var MySalute = require("./salute");
var Result = MySalute + " world!";
console.log(Result);
然后无知的我有新建了一个demo.html,(想要在浏览器里打开看看是什么样子)内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script type="text/javascript" src="world.js"></script>
</head>
<body>
</body>
</html>
结果在浏览器中打开,查看控制台大失所望,报了一个错误world.js:2 Uncaught ReferenceError: require is not defined
发现浏览器不兼容CommonJS的根本原因,在于缺少四个Node.js环境的变量:
- module
- exports
- require
- global
只要能够提供这四个变量,浏览器就能加载 CommonJS 模块,问题是可以解决的,但是好像并不怎么好玩,有兴趣的朋友可以去阮老师博客里逛逛啊,浏览器加载 CommonJS 模块的原理与实现,里面还讲了Browserify的原理。
这里使用命令行node world.js
就可以了。
十七、Node.js中的模块和包
参考《Node.js开发指南 ByVoid》 Page34
模块(Module)和包(Package)是 Node.js 最重要的支柱。开发一个具有一定规模的程序不可能只用一个文件,通常需要把各个功能拆分、封装,然后组合起来,模块正是为了实现这种方式而诞生的。在浏览器 JavaScript 中,脚本模块的拆分和组合通常使用 HTML 的script 标签来实现。Node.js 提供了 require 函数来调用其他模块,而且模块都是基于文件的,机制十分简单。
Node.js 的模块和包机制的实现参照了 CommonJS 的标准,但并未完全遵循。不过两者的区别并不大,一般来说你大可不必担心,只有当你试图制作一个除了支持 Node.js之外还要支持其他平台的模块或包的时候才需要仔细研究。通常,两者没有直接冲突的地方。我们经常把 Node.js 的模块和包相提并论,因为模块和包是没有本质区别的,两个概念也时常混用。如果要辨析,那么可以把包理解成是实现了某个功能模块的集合,用于发布和维护。对使用者来说,模块和包的区别是透明的,因此经常不作区分。本节中我们会详细介绍:
1.什么是模块
模块是 Node.js 应用程序的基本组成部分,文件和模块是一一对应的。换言之,一个Node.js 文件就是一个模块,这个文件可能是 JavaScript 代码、JSON 或者编译过的 C/C++ 扩展。在前面章节的例子中,我们曾经用到了 var http = require('http')
, 其中 http是 Node.js 的一个核心模块,其内部是用 C++ 实现的,外部用 JavaScript 封装。我们通过require 函数获取了这个模块,然后才能使用其中的对象。
2.创建模块
在 Node.js 中,创建一个模块非常简单,因为一个文件就是一个模块,我们要关注的问题仅仅在于如何在其他文件中获取这个模块。Node.js 提供了 exports 和 require 两个对象,其中 exports 是模块公开的接口, require 用于从外部获取一个模块的接口,即所获取模块的 exports 对象。让我们以一个例子来了解模块。创建一个 module.js 的文件,内容是:
//module.js
var name;
exports.setName = function(thyName) {
name = thyName;
};
exports.sayHello = function() {
console.log('Hello ' + name);
};
在同一目录下创建 getmodule.js,内容是:
//getmodule.js
var myModule = require('./module');
myModule.setName('BYVoid');
myModule.sayHello();
运行node getmodule.js,结果是:
Hello BYVoid
在以上示例中,module.js 通过 exports 对象把 setName 和 sayHello 作为模块的访问接口,在 getmodule.js 中通过 require('./module')
加载这个模块,然后就可以直接访问 module.js 中 exports 对象的成员函数了。这种接口封装方式比许多语言要简洁得多,同时也不失优雅,未引入违反语义的特性,符合传统的编程逻辑。在这个基础上,我们可以构建大型的应用程序,npm 提供的上万个模块都是通过这种简单的方式搭建起来的。
这里有个疑问,可以参考exports 和 module.exports 的区别或module.exports与exports??关于exports的总结
- module.exports 初始值为一个空对象 {}
- exports 是指向的 module.exports 的引用
- require() 返回的是 module.exports 而不是 exports
我们经常看到这样的写法:exports = module.exports = somethings
上面的代码等价于:
module.exports = somethings
exports = module.exports
原理很简单,即 module.exports 指向新的对象时,exports 断开了与 module.exports 的引用,那么通过 exports = module.exports 让 exports 重新指向 module.exports 即可。
3.单次加载
上面这个例子有点类似于创建一个对象,但实际上和对象又有本质的区别,因为require 不会重复加载模块,也就是说无论调用多少次 require, 获得的模块都是同一个。我们在 getmodule.js 的基础上稍作修改:
//loadmodule.js
var hello1 = require('./module');
hello1.setName('BYVoid');
var hello2 = require('./module');
hello2.setName('BYVoid 2');
hello1.sayHello();
运行后发现输出结果是 Hello BYVoid 2 ,这是因为变量 hello1 和 hello2 指向的是同一个实例,因此 hello1.setName 的结果被 hello2.setName 覆盖,最终输出结果是由后者决定的。
- 覆盖 exports
有时候我们只是想把一个对象封装到模块中,例如:
//singleobject.js
function Hello() {
var name;
this.setName = function (thyName) {
name = thyName;
};
this.sayHello = function () {
console.log('Hello ' + name);
};
};
exports.Hello = Hello;
此时我们在其他文件中需要通过 require('./singleobject').Hello 来获取Hello 对象,这略显冗余,可以用下面方法稍微简化:
//hello.js
function Hello() {
var name;
this.setName = function(thyName) {
name = thyName;
};
this.sayHello = function() {
console.log('Hello ' + name);
};
};
module.exports = Hello;
这样就可以直接获得这个对象了:
//gethello.js
var Hello = require('./hello');
hello = new Hello();
hello.setName('BYVoid');
hello.sayHello();
注意,模块接口的唯一变化是使用 module.exports = Hello 代替了 exports.Hello=Hello 。在外部引用该模块时,其接口对象就是要输出的 Hello 对象本身,而不是原先的exports 。事实上, exports 本身仅仅是一个普通的空对象,即 {} ,它专门用来声明接口,本质上是通过它为模块闭包的内部建立了一个有限的访问接口。因为它没有任何特殊的地方,所以可以用其他东西来代替,譬如我们上面例子中的 Hello 对象。
注意,不可以通过对 exports 直接赋值代替对 module.exports 赋值。exports 实际上只是一个和 module.exports 指向同一个对象的变量,它本身会在模块执行结束后释放,但 module 不会,因此只能通过指定module.exports 来改变访问接口。
5.创建包
包是在模块基础上更深一步的抽象,Node.js 的包类似于 C/C++ 的函数库或者 Java/.Net的类库。它将某个独立的功能封装起来,用于发布、更新、依赖管理和版本控制。Node.js 根据 CommonJS 规范实现了包机制,开发了 npm来解决包的发布和获取需求。Node.js 的包是一个目录,其中包含一个 JSON 格式的包说明文件 package.json。严格符合 CommonJS 规范的包应该具备以下特征:
package.json 必须在包的顶层目录下;
二进制文件应该在 bin 目录下;
JavaScript 代码应该在 lib 目录下;
文档应该在 doc 目录下;
单元测试应该在 test 目录下。
Node.js 对包的要求并没有这么严格,只要顶层目录下有 package.json,并符合一些规范即可。当然为了提高兼容性,我们还是建议你在制作包的时候,严格遵守 CommonJS 规范。
模块与文件是一一对应的。文件不仅可以是 JavaScript 代码或二进制代码,还可以是一个文件夹。最简单的包,就是一个作为文件夹的模块。下面我们来看一个例子,建立一个叫做 somepackage 的文件夹,在其中创建 index.js,内容如下:
//somepackage/index.js
exports.hello = function() {
console.log('Hello.');
};
然后在 somepackage 之外建立 getpackage.js,内容如下:
//getpackage.js
var somePackage = require('./somepackage');
somePackage.hello();
运行 node getpackage.js,控制台将输出结果 Hello. 。我们使用这种方法可以把文件夹封装为一个模块,即所谓的包。包通常是一些模块的集合,在模块的基础上提供了更高层的抽象,相当于提供了一些固定接口的函数库。通过定制package.json,我们可以创建更复杂、更完善、更符合规范的包用于发布。
在前面例子中的 somepackage 文件夹下,我们创建一个叫做 package.json 的文件,内容如下所示:
{
"main" : "./lib/interface.js"
}
然后将 index.js 重命名为 interface.js 并放入 lib 子文件夹下。以同样的方式再次调用这个包,依然可以正常使用。Node.js 在调用某个包时,会首先检查包中 package.json 文件的 main 字段,将其作为包的接口模块,如果 package.json 或 main 字段不存在,会尝试寻找 index.js 或 index.node 作为包的接口。
package.json 是 CommonJS 规定的用来描述包的文件,完全符合规范的 package.json 文件应该含有以下字段。
name :包的名称,必须是唯一的,由小写英文字母、数字和下划线组成,不能包含空格。
description :包的简要说明。
version :符合语义化版本识别规范的版本字符串。
keywords :关键字数组,通常用于搜索。
maintainers :维护者数组,每个元素要包含 name 、 email (可选)、 web (可选)字段。
contributors :贡献者数组,格式与 maintainers 相同。包的作者应该是贡献者数组的第一个元素。
bugs :提交bug的地址,可以是网址或者电子邮件地址。
licenses :许可证数组,每个元素要包含 type (许可证的名称)和 url (链接到许可证文本的地址)字段。
repositories :仓库托管地址数组,每个元素要包含 type (仓库的类型,如 git )、url (仓库的地址)和 path (相对于仓库的路径,可选)字段。
dependencies :包的依赖,一个关联数组,由包名称和版本号组成。
下面是一个完全符合 CommonJS 规范的 package.json 示例:
{
"name": "mypackage",
"description": "Sample package for CommonJS. This package demonstrates the required
elements of a CommonJS package.",
"version": "0.7.0",
"keywords": [
"package",
"example"
],
"maintainers": [
{
"name": "Bill Smith",
"email": "bills@example.com",
}
],
"contributors": [
{
"name": "BYVoid",
"web": "http://www.byvoid.com/"
}
],
"bugs": {
"mail": "dev@example.com",
"web": "http://www.example.com/bugs"
},
"licenses": [
{
"type": "GPLv2",
"url": "http://www.example.org/licenses/gpl.html"
}
],
"repositories": [
{
"type": "git",
"url": "http://github.com/BYVoid/mypackage.git"
}
],
"dependencies": {
"webkit": "1.2",
"ssl": {
"gnutls": ["1.0", "2.0"],
"openssl": "0.9.8"
}
}
}
十六、Node.js模块加载机制
参考 《Node.js开发指南 ByVoid》Page 132
Node.js 的模块可以分为两大类,一类是核心模块,另一类是文件模块。核心模块就是Node.js 标准 API 中提供的模块,如 fs 、 http 、 net 、 vm 等,这些都是由 Node.js 官方提供的模块,编译成了二进制代码。我们可以直接通过 require 获取核心模块,例如require('fs') 。核心模块拥有最高的加载优先级,换言之如果有模块与其命名冲突,Node.js 总是会加载核心模块。文件模块则是存储为单独的文件(或文件夹)的模块,可能是 JavaScript 代码、JSON 或编译好的 C/C++ 代码。文件模块的加载方法相对复杂,但十分灵活,尤其是和 npm 结合使用时。在不显式指定文件模块扩展名的时候,Node.js 会分别试图加上.js、.json 和 .node扩展名。.js 是 JavaScript 代码,.json 是 JSON 格式的文本,.node 是编译好的 C/C++ 代码。
文件模块的加载有两种方式,一种是按路径加载,一种是查找 node_modules 文件夹。如果 require 参数以“ / ”开头,那么就以绝对路径的方式查找模块名称,例如 require('/home/byvoid/module') 将会按照优先级依次尝试加载 /home/byvoid/module.js、/home/byvoid/module.json 和 /home/byvoid/module.node。如果 require 参数以“ ./ ”或“ ../ ”开头,那么则以相对路径的方式来查找模块,这种方式在应用中是最常见的。例如前面的例子中我们用了 require('./hello') 来加载同一文件夹下的hello.js。
如果 require 参数不以“ / ”、“ ./ ”或“ ../ ”开头,而该模块又不是核心模块,那么就要通过查找 node_modules 加载模块了。我们使用npm获取的包通常就是以这种方式加载的。在某个目录下执行命令 npm install express,你会发现出现了一个叫做node_modules的目录,里面的结构大概如图 6-1 所示。
在 node_modules 目录的外面一层,我们可以直接使用 require('express') 来代替require('./node_modules/express') 。这是Node.js模块加载的一个重要特性:通过查找 node_modules 目录来加载模块。当 require 遇到一个既不是核心模块,又不是以路径形式表示的模块名称时,会试图在当前目录下的 node_modules 目录中来查找是不是有这样一个模块。如果没有找到,则会在当前目录的上一层中的 node_modules 目录中继续查找,反复执行这一过程,直到遇到根目录为止。举个例子,我们要在 /home/byvoid/develop/foo.js 中使用 require('bar.js') 命令,Node.js会依次查找:
/home/byvoid/develop/node_modules/bar.js
/home/byvoid/node_modules/bar.js
/home/node_modules/bar.js
/node_modules/bar.js
为什么要这样做呢?因为通常一个工程内会有一些子目录,当子目录内的文件需要访问到工程共同依赖的模块时,就需要向父目录上溯了。比如说工程的目录结构如下:
|- project
|- app.js
|- models
|- ...
|- views
|- ...
|- controllers
|- index_controller.js
|- error_controller.js
|- ...
|- node_modules
|- express
我们不仅要在 project 目录下的 app.js 中使用 require('express') ,而且可能要在controllers 子目录下的index_controller.js 中也使用 require('express') ,这时就需要向父目录上溯一层才能找到 node_modules 中的 express 了。