CommonJS规范是如何在Node中实现的?让我们一探究竟!
前言
JavaScript
从最初的表单校验、网页特效到前端库和框架应用级别的开发,让我们见证了其伟大的变迁过程。在JavaScript
努力发展的过程中,慢慢的我们会发现,JavaScript
其实是缺乏一种模块机制的,如果仅仅通过<script>
标签的形式引入代码,未免显得杂乱无章,没有组织性。因此社区为其制定了相应的规范,CommonJS规范的提出就是其中一个重要的里程碑。
CommonJS模块规范
CommonJS
模块规范主要分为模块引用,模块定义,模块标识这三部分。
模块引用
在CommonJS
规范中,使用require()
方法引入模块,这个方法接受模块标识,用来引入一个模块API到当前上下文中,如:let path = require('path')
。
模块定义
在模块中,上下文提供了exports
对象来导出当前模块的方法和变量。模块中的module
对象代表自身,其中exports
就是module
的属性,即module
对象中包含exports
这个对象。在Node中,一个.js
文件就是一个模块,将方法或者变量挂载到exports
对象上作为其属性就可以导出。
// result.js
// 导出模块函数
exports.result = function() {
return 'success'
}
// message.js
// 导入模块函数
let message = require('./result')
console.log(message.result()) // success
模块标识
模块标识本质就是require()
方法的参数,并且该参数有一定的规范,即必须是符合小驼峰命名的字符串、以 .
或 ..
开头的相对路径或绝对路径。
Node的模块实现
Node在实现其模块化的过程中,并非完全按照CommonJS
实现,而是对模块规范进行一定的取舍,同时也增加了自身的特性。
在Node中引入模块,需要经历路径分析、文件定位、编译执行这三个步骤。
模块分类
在Node中模块分为两类,一类是Node提供的模块,即核心模块,如http
,path
等。另一类是用户编写的模块,即文件模块,如自定义模块或第三方模块express
,dayjs
等。
其中核心模块加载速度是最快的,因为核心模块在Node源代码的编译过程中,编译进了二进制执行文件,在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中是优先判断的。
而文件模块是在运行时动态加载的,需要上述完整的三个步骤,并且速度比核心模块慢。
优先从缓存加载
Node对引入过的模块都会进行缓存,以减少二次引入时的开销,相比于浏览器仅仅缓存文件来说,Node缓存的是编译和执行之后的对象。无论是核心模块还是文件模块,Node都会优先从缓存加载,但是核心模块的缓存检查要优于文件模块。
路径分析
上面提到require()
方法接受一个模块标识符作为参数,模块标识符具体分为:
- 核心模块,如
http
、fs
、path
等 .
或..
开始的相对路径文件模块- 以
/
开始的绝对路径模块 - 非路径形式的文件模块,如自定义模块或者一个第三方包
lodash
等
核心模块的优先级仅次于缓存加载,因为它已经被Node源代码编译为二进制代码,其加载过程最快。
路径形式的文件模块,Node在分析路径模块时,require()
方法会将路径转换为真实路径,以真实路径为索引,将编译后的结果放在缓存中,便于二次加载。因为文件模块给Node提供了具体的路径位置,所以在其查找过程中节约了大量的时间,加载速度仅次于核心模块。
自定义模块,这类模块的查找过程是最慢的,因为Node会根据模块路径来逐个查找相应的模块,模块路径是Node在定位文件模块的具体文件时制定的一种查找策略,具体表现为一个路径组成的数组。
如在Windows操作系统中,打印console.log(module.paths)
,其返回内容为:
[
'D:\\my-project\\read-book\\深入浅出Node.js\\node_modules',
'D:\\my-project\\read-book\\node_modules',
'D:\\my-project\\node_modules',
'D:\\node_modules'
]
从其返回的结果显示来看,路径的生成规则为:
- 当前文件目录下的
node_modules
目录 - 父目录下的
node_modules
目录 - 父目录的父目录下的
node_modules
目录 - 沿路径向上逐级递归,直到跟目录下的
node_modules
目录
可以看出,当前文件的路径越深,查找模块的时间越长,这也就说明了自定义模块加载速度慢的原因。
文件定位
require()
在分析标识符的过程中,会出现标识符中不包含文件扩展名的情况,此时,Node会按照.js
、.json
、.node
的次序补足扩展名,调用fs
模块同步阻塞式地判断文件是否存在。如果文件以.json
、.node
结尾,在传递给require()
的标识符中带上文件扩展名,会加快一点速度。
目录分析和包
如果Node没有查找到对应的文件,却是一个目录,Node会将其视为一个包。
Node在当前目录下查找package.json
,通过JSON.parse()
解析出包描述对象,取出main
属性指定的文件名进行定位。
如果指定的文件名错误或者没有package.json
文件,Node会将index
作为默认文件,依次查找index.js
、index.json
、index.node
文件。
如果在目录分析的过程中没有定位成功任何文件,自定义模块就会进行下一个模块路径的查找,如果模块路径数组都被遍历完毕,却没有查找到目标文件,就会抛出异常。
模块编译
在Node中,每个文件模块都是一个对象。
function Module(id, parent) {
this.id = id
this.exports = {}
this.parent = parent
if (parent && parent.children) {
parent.children.push(this)
}
this.filename = null
this.loaded = false
this.children = []
}
编译和执行是引入文件模块的最后一个阶段,定位到具体的文件之后,Node会新建一个模块对象,然后根据路径载入并编译。不同的文件名,载入的方法也不同。
.js
文件,通过fs
模块同步读取文件后进行编译执行.node
文件,这是用C/C++
编写的扩展文件,通过dlopen()
方法加载最后编译生成的文件.json
文件,通过fs
模块同步读取文件后,用JSON.parse()
解析返回的结果- 其余扩展名文件,都被当作
.js
文件载入
每一个编译成功的模块,都会将其文件路径作为索引缓存在Module._cache
对象上,以提高二次引入时的性能。
不同的文件扩展名,Node会调用不同的读取方式,例如.json
文件:
Module._extensions['.json'] = function(module, filename) {
var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ': ' + err.message;
throw err;
}
}
在Node中Module._extensions
会被赋值给require()
的extensions
属性。
在代码中访问require.extensions
就可以知道系统中已有的加载方式。
如打印console.log(require.extensions)
,其返回的内容为:
[Object: null prototype] {
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
}
每个模块中存在着require
、exports
、module
,__filename
、__dirname
,但是在具体的模块文件中并没有定义。
这是因为在编译过程中Node对获取到的JavaScript文件内容进行了头尾包装,在头部添加了(function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n})
。
例如一个模块文件就会被包装成这样:
(function (exports, require, module, __filename, __dirname) {
var math = require('math')
exports.area = function (radius) {
return Math.PI * radius * radius
}
})
因此,每个模块文件之间都进行了作用域的隔离,包装之后的代码会通过vm原生模块的runInThisContext()
方法执行,返回一个具体的function
对象,runInThisContext()
类似于eval
,只是具有明确的上下文,不会污染全局。
最后会将当前模块对象的exports
属性、require()
方法,module
(模块对象自身),还有在文件定位中得到的完整文件路径(__filename)
和文件目录(__dirname)
作为参数传递给这个function()
执行,之后模块的exports
属性被返回给了调用方,exports
属性上的任何方法和属性都可以被外部调用到。
我们在平时写代码时,会发现除了exports
对象之外,还存在着module.exports
,并且两者是相等的,但是他们的区别是,exports
对象是通过形参的方式传入的,直接赋值形参会改变形参的引用,但是并不能改变作用域外的值,如果要达到require
引入一个类的效果,请赋值给module.exports
对象。
综上所述,require
、exports
、module
这一完整的流程就是Node对CommonJS模块规范的实现。
参考资料: 书籍《深入浅出Node.js》
转载自:https://juejin.cn/post/7330106312448589851