CommonJs规范
在介绍Nodejs的模块机制之前,我们得先了解一下CommonJS规范。因为Node应用是由模块组合而成,像我们经常会用到的underscore
、loadash
、axios
等都是一个个的模块,这些模块都遵循CommonJS
模块规范。
为何要引入CommonJs规范
CommonJs
官网有这样一句话:
The official JavaScript specification defines APIs for some objects that are useful for building browser-based applications. However, the spec does not define a standard library that is useful for building a broader range of applications.
翻译过来就是JavaScript只提供了一些API给我们,但却并没有提供相关的标准库让我们去组织代码,构建大型应用,即没有模块(包)管理机制。
CommonJS的出现正好填补了JS在server-side
的空缺,让js不再仅仅只是能运行浏览器中,给了我们构建大型应用的可能。
CommonJS模块规范
CommonJS定义一个模块主要分为三个部分:模块引用、模块定义、模块标识
模块引用
CommonJS模块的上下文中提供require()
方法用来引入我们所需要的模块。
let math = require('math')
复制代码
模块定义
与引入功能所用到require
相对应,模块上下文提供了exports
对象用于导出方法或者变量。另外,模块中还有一个module
对象,它代表模块自身,而exports
是module
的属性。
// a.js
let __age__ = '18'
class Bird {
constructor (name) {
this.name = name
}
say () {
console.log("I can fly")
}
set age (age) {
__age__ = age
}
get age () {
return __age__
}
}
//exports.Bird = Bird 等价于下面代码
module.exports.Bird = Bird
复制代码
模块标识
模块标识是我们传递给rquire()
方法的参数,它可以是以下三种形式:
- 符合小驼峰命名的字符串
- 以./..开头的相对路径
- 绝对路径
ps:当通过路径引入时可以不加文件名后缀。
总结起来就是,遵循CommonJS
规范的模块中需要通过module
对象提供的exports
属性来对外暴露接口。当加载某个模块时,需要通过require
进行引用,其实就是加载这个模块暴露的module.exports
属性。
模块特性
CommonJS规定每个文件就是一个模块,模块有自己的作用域,域内的函数,变量,类都是私有,对外不可见的,下面在上面a.js
的基础上新加一个b.js
来验证这一点:
如上面a.js文件所示,模块定义了一个__age__
变量以及一个Bird
类,Bird
类中除了constructor
(构造函数)和fly()
方法外,还有获取和设置__age__
变量的getter
和setter
,class
是ES6的语法,不明白的朋友还请先看一下阮一峰的Class 的基本语法.
定义好a模块之后,我们在b模块引入它:
// b.js
const {Bird} = require('./a.js')
let foo = new Bird('foo')
foo.say() // I can fly
console.log(__age__) // Error,initialName is not defined
console.log(foo.age) // 18
foo.age = 30
console.log(foo.age) // 30
复制代码
b模块在引入a模块之后,获取到了a模块中定义的Bird
类,然后去调用实例的say()
方法,成功打印出了信息。但是当访问__age__
时却会发现报错,我们只能通过Bird
暴露出来的getter
和setter
去获取和改变__age__
,原因上面已经提到了:模块的代码都是运行在它自己的作用域中,不会污染全局作用域。
那么有什么办法去访问到吗?办法是有的,如果我们想在模块间共享某一变量,那么必须把他定义为global对象的一个属性,但是并不推荐这样做:
// a.js
global.__age__ = '18' //修改
class Bird {
constructor (name) {
this.name = name
}
say () {
console.log("I can fly")
}
set age (age) {
__age__ = age
}
get age () {
return __age__
}
}
module.exports.Bird = Bird
复制代码
CommonJS模块的特点总结如下:
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
第一点上面代码已经验证了,大家可以自己去验证下后面的两点。
模块加载机制
在Node中引入一个模块会经历三步走策略:
- 路径分析
- 文件定位
- 编译执行
模块分类
Node中模块分为两种:核心模块(node内置)和文件模块(用户编写)。
我们常用的http
,fs
,os
等模块就属于核心模块,这部分模块因为在node源代码编译的过程中就编译进了二进制执行文件。在Node进程启动时,这部分代码就已经被加载到内存里面了。当引入内置模块时,无须经历路径分析和文件定位两个步骤,并且在路径分析中优先判断,所以加载速度很快。
而文件模块在加载时,需要完成路径分析、文件定位、编译执行过程,速度会比原生模块慢。
ps:核心模块又分为两部分,C/C++编写的和Javascript编写的,前者在源码的src
目录下,后者则在lib
目录下。(lib/*.js)(其中lib/internal部分不提供给文件模块)
缓存策略
为减少二次开销,node会对已经引入的模块进行缓存。当下一次引入相同模块时,会直接从缓存中读取编译和执行后的对象,不再执行我们上面提到的三步走策略。这策略对所有模块适用,有着最高优先级,稍有不同的是核心模块的缓存查找会先于文件模块。
路径分析
当模块第一次引入时,首先做的就是路径分析。
ps:这里的路径并不包含文件名,指的是文件名前面的部分。
路径分析其实就是去分析我们传入require()方法的参数,即模块标识。
// 核心模块
const http = require('http')
// 路径形式的文件模块
const a = rquire('./a.js')
// 自定义模块(非路径形式的特殊文件模块)
const b = require('b')
复制代码
解析规则如下:
- 若用户传入的标识符与某个核心模块相同,如http,那么最后加载的只会是核心模块,即用户自定义模块不可与核心模块重名
- 以.或..开头的标识符号,都会被当作文件模块解析。require()方法会将其转化为真实路径,并将真实路径作为索引,将编译执行的结果放到缓存中。
- 对于非路径形式的自定义模块(特殊的文件模块),它既不是核心模块,也不是路径形式的文件模块,它有可能是一个包或者一个文件,这类查找最为费时。
路径解析的复杂程度很大程度影响到了模块的加载,根据解析规则我们可以总结出不同类型模块加载速度的快慢:
核心模块 > 路径形式的文件模块 > 自定义模块(非路径形式的特殊文件模块)
自定义模块比较特殊,因为它既非核心模块,又没有带路径,所以就得用另一种方式去获取到它的路径--模块路径。
模块路径是一个路径组成的数组,我们可以在自己电脑的随意目录下创建一个module_path.js
文件,里面代码如下:
console.log(module.paths)
复制代码
OR
在某个目录下打开CMD(命令行提示符)如下操作:
$ node
> console.log(module.paths)
复制代码
下面是我电脑打印出来的结果:
[ '/Users/liby/Desktop/project/linyibin/repl/node_modules',
'/Users/liby/Desktop/project/linyibin/node_modules',
'/Users/liby/Desktop/project/node_modules',
'/Users/liby/Desktop/node_modules',
'/Users/liby/node_modules',
'/Users/node_modules',
'/node_modules',
'/Users/liby/.node_modules',
'/Users/liby/.node_libraries',
'/usr/local/lib/node' ]
复制代码
模块路径的生成规则如下(从上往下):
- 当前文件目录下的
node_modules
目录 - 父目录下的
node_modules
目录 - 父目录的父目录下的
node_modules
目录 - 一层层向上递归,直到根目录下的
node_modules
目录 - 如果在环境变量中设置了
HOME
目录和NODE_PATH
目录的话,整个路径还包含NODE_PATH
和HOME
目录下的.node_libraries
与.node_modules
- 除此之外还有一个全局
module path
,是当前 node 执行文件的目录。
文件定位
当命中缓存加载策略的时候,模块不再需要三步走策略,而是直接从缓存中读取。
处理完路径解析后,紧接着就是文件定位了,主要包括文件扩展名的分析、目录和包的处理。
文件扩展名分析
当标志符不包含文件扩展名时,Node会按照js
,json
,node
的次序补足扩展名。 在尝试的过程中,需要以同步阻塞的方式判断文件是否存在,有可能会引起性能方面的问题。因此,对于.node和.json文件,在传入标志符的时候加上扩展名,会加快一点速度。
目录分析和包
当require()
在处理标识符的过程中,如果分析完文件扩展名后没有发现对应的文件,那么它会将之当成一个目录来进行查找,这种情况通常出现在自定义模块查找和逐个模块路径查找时。
包是对模块的进一步组织,主要将某些独立的功能封装起来,主要包括bao包结构和包描述文件。下一个章节中会进行详细介绍。
对于包的查找,Node会在目录下查找package.json
文件(包描述文件),通过JSON.parse解析出其中的包描述对象,获取main属性进行文件定位,如果缺少扩展名,则先进行文件扩展名分析。
如果该目录下压根就没有package.json
,或者没有包描述对象没有main属性,Node就将index作为默认文件名,进行文件扩展名分析。
如果在当前目录走完上述流程依旧没找到对应模块,则会根据模块路径进行递归,然后依次重复上述流程,如果模块路径遍历完还没找到,则会抛出查找失败的异常。
编译执行
执行完文件定位后,Node会新建一个模块对象,然后根据路径载入并编译。
载入方式
对于不同的文件扩展名,Node载入的方式也不同:
- .js文件,通过
fs
模块同步读取文件后编译执行 - .node文件,由
c/c++
编写的扩展文件,通过dlopen()
加载 - .json,用
fs
模块同步读取后通过JSON.parse()
解析结果 - 其他扩展名文件,被当作
js
文件载入
为提高二次引用的效率,已编译成功的模块会以其路径为索引缓存在module._cache
对象上。
编译方式
各位有没有想过,为什么模块中默认会有require
、module
、exports
三个变量供我们使用,除了这三个变量外,__filename
, __dirname
也可以不经定义而直接使用?
实际上,这是因为在加载模块的时候Node对获取到的文件内容进行首尾包装,通常会被包装成下面的样子(用上面我们定义过的a.js举例子):
// 隔离了作用域
(function (exports, require, module, __filename, __dirname) {
let a = require('./a.js')
let lyb = new Bird('lyb')
exports.say = lyb.say
})
复制代码
首尾包装之后的代码需要通过vm
模块的runInThisContext
()方法(类似与eval
)执行。vm模块是node.js提供在V8虚拟机中编译和运行的工具,node.js中的模块内部实现就是通过此模块完成。
vm
模块的runInThisContext
()方法提供了一个安全的沙箱环境,提供了独立的上下文环境,避免了全局污染(与eval不同所在)。执行完毕后,模块的exports属性会被返回给调用方,这也是我们能够访问到模块导出的方法、对象、变量的原因,代码如下:
'use strict';
const vm = require('vm');
let code =
`(function(require) {
const http = require('http');
http.createServer( (request, response) => {
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end('Hello World\\n');
}).listen(8124);
console.log('Server running at http://127.0.0.1:8124/');
})`;
vm.runInThisContext(code)(require);
复制代码
module.exports与exports
为什么模块里面有module.exports
又有exports
,那导出一个对象我该用哪种方法呢,这两种方法又有什么不同?
其实,exports只是module.exports的一个引用,最终返回给调用方的是module.exports而不是exports, 相当于在模块第一行有以下代码:
// 只是在开始时建立的引用,后面如果导出方法不正确会切断引用,就会造成我们意想不到的结果
exports = modules.exports
复制代码
有些点得注意一下:
- 所有的
exports
收集到的属性和方法,都赋值给了Module.exports
。前提这不要切断两者之间的引用关系。 exports
只是module.exports
的一个引用,当exports
指向变了,那么exports
新指向的对象是不会被导出的,反之,如果exports
指向变了,那么exports
也会失效。
第二点可以如下验证:
/**
第一种情况
**/
// a.js
module.exports = {} // module.exports指向已改变
exports.a = 1
// b.js
let a = require('./a.js')
console.log(a) // {}
/**
第二种情况
**/
// a.js
exports = {'name': 'liby'} // exports指向已改变
// b.js
let a = require('./a.js')
console.log(a) // {}
复制代码
总结:不要在同一文件里面混用
module.exports
和exports
,导出对象请用module.exports
,导出变量或者函数建议用exports
。
参考
《深入浅出Node.js》
nodejs模块中exports和module.exports的区别