第2章 常用模块
2.1 Module
2.1.1 JavaScript的模块规范
JavaScript对模块规范的强调恰恰是其缺陷的体现,这主要是由历史原因造成的。在其他常见编程语言例如Java、 C++中,模块规范从未被如此刻意强调过,也没有分化出像JavaScript这般多样的标准。
目前流行的JavaScript模块规范有两种,分别是CommonJS和AMD,我们首先对这两者做一个简单的介绍。
1.CommonJS
CommonJS的目标很远大,它的愿景是将来JavaScript不仅仅会运行在浏览器内部,而是作为一门独立的编程语言在各种领域发挥作用,为此需要一种通用的模块规范。
CommonJS将每个文件都看作一个模块,模块内部定义的变量都是私有的,无法被其他模块使用,除非使用预定义的方法将内部的变量暴露出来(通过exports和require关键字来实现),CommonJS最为出名的实现就是Node.js。
CommonJS一个显著的特点就是模块的加载是同步的,就目前来说,受限于宽带速度,并不适用于浏览器中的JavaScript。
2.AMD
AMD是Asynchronous Module Definition的缩写,意思就是“异步模块定义”。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。依赖这个模块的代码定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。目前在前端流行的RequireJS就是AMD规范的一种实现。
此外,ES6中也提出了一种模块机制,我们会在第3章介绍。
2.1.2 require及其运行机制
我们已经提到了Node遵循CommonJS来规范,也就是使用require关键字来加载模块,下面是一个简单的例子:
代码2.1 定义一个简单的模块
这样就实现了一个自定义模块,该模块提供了一个接口(person),然后使用module.exports将该接口暴露给外部使用,外部的代码想要使用person.js中的方法,需要使用require关键字引入该接口。
注意:在引入自定义模块时省略相对路径“./”会导致错误。
如果一个模块包含了许多方法而开发者只用到其中的一小部分,可以只导入模块的一部分属性。
以上面的代码为例,如果只需要引入talk方法,那么代码可以写成:
require关键字并不依赖于exports,我们也可以加载一个没有暴露任何方法的模块,这相当于直接执行一个模块内部的代码,通常没什么意义。
1.重复引入
在C++中,通常使用#IFNDEF等关键字来避免头文件的重复引入,在Node中无须关心这个问题,因为Node默认先从缓存中加载模块,一个模块被第一次加载后,就会在缓存中维持一个副本,如果遇到重复加载的模块会直接提取缓存中的副本,也就是说在任何情况下每个模块都只在缓存中有一个实例。
关于Node中的模块机制,面试官可能问你一些很常见的问题,例如:
为什么在Node.js中,require()加载模块是同步而非异步?
如果回答因为遵守了CommonJS标准所以是同步加载,就有点耍滑头了。
由于没有标准答案,完全可以回答这是出于程序员的直觉,一个作为公共依赖的模块,自然要一步加载到位。
另一方面,由于模块的个数往往有限,且Node会自动缓存已经加载的模块,再加上访问的都是本地文件,产生的IO开销几乎可以忽略。
再有,Node程序运行在服务器端,很少遇到需要频繁重启服务的情况,那么就算在服务启动时在加载上花点时间(几秒)也没有什么影响。
2.require的缓存策略
Node会自动缓存经过require引入的文件,使得下次再引入不需要经过文件系统而是直接从缓存中读取。这种缓存是基于文件路径定位的,这表示即使有两个完全相同的文件,但它们位于不同的路径下,也会在缓存中维持两份。例如我们可以用下面的代码查看目前在缓存中的文件:
控制台输出如下(暂时忽略require.js本身的缓存):
上面输出的是module.js在缓存中的信息,我们可以在里面找到很多有用的信息,例如path表示的是模块引入时Node的查找路径,即从当前目录下的node_modules开始,一直到磁盘根目录为止。
2.1.3 require的隐患
当调用require加载一个模块时,模块内部的代码都会被调用,有时候这可能会带来隐藏的bug。例如下面的例子:
run.js除了加载一个模块之外没有进行任何操作,试着运行一下会发现会每隔一秒输出test字符串,同时run.js进程不会退出。
加载一个模块相当于执行模块内部的代码,在module.js中由于设置了一个不间断的定时器,导致run.js也会一直运行下去。
上面是一个极端的例子,设想一种情景,当你调用某个别人已经编写完成的模块时,明明所有的调用都已经结束,但调用者进程无论如何都不会退出,这很可能是被调用的模块内部有一个隐蔽的循环或者一个一直打开的数据库连接,这个问题在开发过程中可能不会被注意到或者不会被触发,如果真正到了生产环境,这种情况可能导致严重的内存泄露。
这一方面告诉我们要对引用未知的模块保持警惕,即使那个模块是你自己写的;另一方面也揭示了测试的重要性。
2.1.4 模块化与作用域
既然已经提到了模块化,我们就来谈谈作用域的问题,主要关注点在this上。
Node和JavaScript中的this指向有一些区别,其中Node控制台和脚本文件的策略也不一样。对于浏览器中的JavaScript来说,无论是在脚本或者是Chrome控制台中,其this的指向和行为都是一致的;而Node则不是这样。我们会分别进行介绍。
1.控制台中的this
首先是全局的this,分别在Node Repl和Chrome控制台中运行:
可以看出,在Node Repl环境中,全局的this指向global对象。
继续运行下面的代码:
在Node控制台中,全局变量会被挂载到global下。
2.脚本中的this
我们新建一个名为this.js的文件,在文件中添加如下代码:
运行node test.js,打印出的结果是一个空对象。
然后是下面的代码:
仍然全都是undefined,说明第一行代码定义的变量a并没有挂载在全局的this或者global对象。
然而如果声明变量时不使用var或者let关键字,例如下面的代码:
却可以正常打印出结果。
那么在Node脚本文件中定义的全局this又指向了什么呢?答案是module.exports。
总结一下,在Node repl环境中控制台的全局this和global可以看作是同一对象,而在脚本文件中,二者并不等价。
3.Node中的作用域种类
看完了上面的内容,接下来对Node中的各种作用域做一个总结。以下讨论的作用域内容仅限于脚本文件。
(1)全局作用域
如果一个变量没有用var、 let或者const之类的关键字修饰,那么它就是属于全局作用域,定义在全局作用域上的变量可以通过global对象访问到。
例如前面的例子:
变量a位于全局作用域中,即使是在不同的文件中也能访问到变量a。
(2)模块作用域
在代码文件顶层(不在任何方法,对象中)使用var、let或者const修饰的变量都位于模块作用域中,不同模块作用域之间的作用域是隔离的。
模块作用域中的this指向module.exports中,例如前面提到的:
我们在前面提到Node Repl和脚本文件执行会有不同的结果,这是因为Node会将所有的脚本文件包装成下面的这种形式。
(3)函数作用域
这个大家就很熟悉了,不再介绍。
(4)块级作用域
ES2015中引入的let关键字提供了块级作用域的支持,我们会在下一章介绍。