在计算机程序的开发过程中,随着程序代码越写越多,在一个文件里代码就会越来越长,越来越不容易维护。
为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。在Node环境中,一个.js文件就称之为一个模块(module)。
使用模块有什么好处?
最大的好处是大大提高了代码的可维护性。其次,编写代码不必从零开始。当一个模块编写完毕,就可以被其他地方引用。我们在编写程序的时候,也经常引用其他模块,包括Node内置的模块和来自第三方的模块。
使用模块还可以避免函数名和变量名冲突。相同名字的函数和变量完全可以分别存在不同的模块中,因此,我们自己在编写模块时,不必考虑名字会与其他模块冲突。
如果学习过RequireJs,就很容易明白这里的模块的概念,点击查看《2017年RequireJs学习教程》。
在上一节,我们编写了一个myNode.js文件,这个myNode.js文件就是一个模块,模块的名字就是文件名(去掉.js后缀),所以myNode.js文件就是名为myNode的模块。
我们把myNode.js改造一下,创建一个函数,这样我们就可以在其他地方调用这个函数:
4 | return 'this is a function of fn1' ; |
|
函数fn1()是我们在myNode模块中定义的,你可能注意到最后一行是一个奇怪的赋值语句,它的意思是,把函数fn1作为模块的输出暴露出去,这样其他模块就可以使用fn1函数了。
问题是其他模块怎么使用myNode模块的这个fn1函数呢?我们再编写一个main.js文件,调用myNode模块的fn1函数:
3 | var fn1 = require( './myNode' ); |
|
我们引入myNode模块用Node提供的require函数:
引入的模块作为变量保存在fn1变量中,那fn1变量到底是什么东西?其实变量fn1就是在myNode.js中我们用module.exports = fn1;输出的fn1函数。所以,main.js就成功地引用了myNode.js模块中定义的fn1()函数,接下来就可以直接使用它了。
在使用require()引入模块的时候,请注意模块的相对路径。因为main.js和myNode.js位于同一个目录,所以我们用了当前目录.:
如果只写模块名,则Node会依次在内置模块、全局模块和当前模块下查找myNode.js:
1 | var fn1 = require( 'myNode' ); |
|
这个时候可能会报错:
4 | Error: Cannot find module 'myNode' |
5 | at Function.Module._resolveFilename |
6 | at Function.Module._load |
8 | at Function.Module._load |
9 | at Function.Module.runMain |
|
遇到这个问题,我们需要检查:
1、模块名是否写对了;
2、模块文件是否存在;
3、相对路径是否写正确了。
顺便来了解下CommonJs的规范
这种模块加载机制被称为CommonJS规范。在这个规范下,每个.js文件都是一个模块,它们内部各自使用的变量名和函数名都互不冲突,例如,myNode.js和main.js中就算声明同一个变量,也但互不影响。
一个模块想要对外暴露变量(函数也是变量),可以用module.exports = variable;,一个模块要引用其他模块暴露的变量,用var ref = require('module_name');就拿到了引用模块的变量。
用module.exports输出的变量可以是函数、数组、json对象等。
相关推荐:《CommonJS和AMD/CMD区别详解》。
如果有兴趣,可以深入了解下模块原理
当我们编写JavaScript代码时,我们可以申明全局变量:
在浏览器中,大量使用全局变量可不好。如果你在a.js中使用了全局变量s,那么,在b.js中也使用全局变量s,将造成冲突,b.js中对s赋值会改变a.js的运行逻辑。
也就是说,JavaScript语言本身并没有一种模块机制来直接保证不同模块可以使用相同的变量名。
那Node.js是如何实现这一点的?
其实要实现“模块”这个功能,并不需要语法层面的支持。Node.js也并不会增加任何JavaScript语法。实现“模块”功能的奥妙就在于JavaScript是一种函数式编程语言,它支持闭包。如果我们把一段JavaScript代码用一个函数包装起来,这段代码的所有“全局”变量就变成了函数内部的局部变量。
请注意我们编写的myNode.js代码是这样的:
6 | console.log(s + ' ' + name + '!' ); |
|
Node.js加载了myNode.js后,它可以把代码包装一下,变成这样执行:
6 | console.log(s + ' ' + name + '!' ); |
|
这样一来,原来的全局变量s现在变成了匿名函数内部的局部变量。如果Node.js继续加载其他模块,这些模块中定义的“全局”变量s也互不干扰。
所以,Node利用JavaScript的函数式编程的特性,轻而易举地实现了模块的隔离。
但是,模块的输出module.exports怎么实现?
这个也很容易实现,Node可以先准备一个对象module:
06 | var load = function (module) { |
09 | console.log( 'Hello, ' + name + '!' ); |
14 | return module.exports; |
16 | var exported = load(module); |
18 | save(module, exported); |
|
可见,变量module是Node在加载js文件前准备的一个变量,并将其传入加载函数,我们在myNode.js中可以直接使用变量module原因就在于它实际上是函数的一个参数:
你可以在文件中尝试打印一下module变量,就可以在控制台中看到结果:
通过把参数module传递给load()函数,myNode.js就顺利地把一个变量传递给了Node执行环境,Node会把module变量保存到某个地方。
由于Node保存了所有导入的module,当我们用require()获取module时,Node找到对应的module,把这个module的exports变量返回,这样,另一个模块就顺利拿到了模块的输出:
1 | var fn1= require( './myNode' ); |
|
以上是Node实现JavaScript模块的一个简单的原理介绍。
module.exports和exports的区别
首先需要知道,通过上面的代码打印module,可以看出exports是module对象的一个属性。
很多时候,你会看到,在Node环境中,有两种方法可以在一个模块中输出变量:
方法一:对module.exports赋值:
4 | return 'this is a function of fn1' ; |
|
方法二:直接使用exports:
4 | return 'this is a function of fn1' ; |
|
但是你不可以直接对exports赋值:
如果你对上面的写法感到十分困惑,不要着急,我们来分析Node的加载机制:
首先,Node会把整个待加载的myNode.js文件放入一个包装函数load中执行。在执行这个load()函数前,Node准备好了module变量:
记住:load()函数最终返回module.exports:
1 | var load = function (exports, module) { |
8 | var exported = load(module.exports, module); |
|
也就是说,默认情况下,Node准备的exports变量和module.exports变量实际上是同一个变量,并且初始化为空对象{},于是,我们可以写:
1 | exports.foo = function () { return 'foo' ; }; |
2 | exports.bar = function () { return 'bar' ; }; |
|
或者是:
1 | module.exports.foo = function () { return 'foo' ; }; |
2 | module.exports.bar = function () { return 'bar' ; }; |
|
站在JavaScript变量传址的角度来讲,exports和module.exports默认都是一个空对象,并且exports=module.exports(指向同一个内存地址),如果使用:
就说明是exports对象重新指向了一个新的内存地址,这个时候module.exports指向的还是原来的地址,到时候nodeJs输出结果,module.exports还是为空。
但是如果使用:
就说明exports只是在对象内部添加了一个fn1属性,因为exports和module.exports指向同一个内存地址,所以module.exports中也会新增一个fn1属性,最后NodeJs输出结果。
还记得上面标红的字体吗?load()函数最终返回module.exports。
我个人建议使用module.exports = xxx的方式来输出模块变量,这样,我们只需要记忆一种方法。