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对象上作为其属性就可以导出。

js 复制代码
// result.js
// 导出模块函数
exports.result = function() {
  return 'success'
}
js 复制代码
// message.js
// 导入模块函数
let message = require('./result')
console.log(message.result()) // success

模块标识

模块标识本质就是require()方法的参数,并且该参数有一定的规范,即必须是符合小驼峰命名的字符串 . .. 开头的相对路径或绝对路径

Node的模块实现

Node在实现其模块化的过程中,并非完全按照CommonJS实现,而是对模块规范进行一定的取舍,同时也增加了自身的特性。

在Node中引入模块,需要经历路径分析、文件定位、编译执行这三个步骤。

模块分类

在Node中模块分为两类,一类是Node提供的模块,即核心模块,如httppath等。另一类是用户编写的模块,即文件模块,如自定义模块或第三方模块expressdayjs等。

其中核心模块加载速度是最快的,因为核心模块在Node源代码的编译过程中,编译进了二进制执行文件,在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中是优先判断的。

文件模块是在运行时动态加载的,需要上述完整的三个步骤,并且速度比核心模块慢。

优先从缓存加载

Node对引入过的模块都会进行缓存,以减少二次引入时的开销,相比于浏览器仅仅缓存文件来说,Node缓存的是编译和执行之后的对象。无论是核心模块还是文件模块,Node都会优先从缓存加载,但是核心模块的缓存检查要优于文件模块。

路径分析

上面提到require()方法接受一个模块标识符作为参数,模块标识符具体分为:

  • 核心模块,如httpfspath
  • ...开始的相对路径文件模块
  • /开始的绝对路径模块
  • 非路径形式的文件模块,如自定义模块或者一个第三方包lodash

核心模块的优先级仅次于缓存加载,因为它已经被Node源代码编译为二进制代码,其加载过程最快。

路径形式的文件模块 ,Node在分析路径模块时,require()方法会将路径转换为真实路径,以真实路径为索引,将编译后的结果放在缓存中,便于二次加载。因为文件模块给Node提供了具体的路径位置,所以在其查找过程中节约了大量的时间,加载速度仅次于核心模块。

自定义模块,这类模块的查找过程是最慢的,因为Node会根据模块路径来逐个查找相应的模块,模块路径是Node在定位文件模块的具体文件时制定的一种查找策略,具体表现为一个路径组成的数组。

如在Windows操作系统中,打印console.log(module.paths) ,其返回内容为:

js 复制代码
[
  '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.jsindex.json index.node文件。

如果在目录分析的过程中没有定位成功任何文件,自定义模块就会进行下一个模块路径的查找,如果模块路径数组都被遍历完毕,却没有查找到目标文件,就会抛出异常。

模块编译

在Node中,每个文件模块都是一个对象。

js 复制代码
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文件:

js 复制代码
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),其返回的内容为:

js 复制代码
[Object: null prototype] {
  '.js': [Function (anonymous)],
  '.json': [Function (anonymous)],
  '.node': [Function (anonymous)]
}

每个模块中存在着requireexportsmodule__filename__dirname,但是在具体的模块文件中并没有定义。

这是因为在编译过程中Node对获取到的JavaScript文件内容进行了头尾包装,在头部添加了(function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n})

例如一个模块文件就会被包装成这样:

js 复制代码
(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对象。

综上所述,requireexportsmodule这一完整的流程就是Node对CommonJS模块规范的实现。

参考资料: 书籍《深入浅出Node.js》

相关推荐
谢尔登7 分钟前
Webpack 和 Vite 的区别
前端·webpack·node.js
谢尔登7 分钟前
【Webpack】Tree Shaking
前端·webpack·node.js
纳尼亚awsl37 分钟前
无限滚动组件封装(vue+vant)
前端·javascript·vue.js
八了个戒42 分钟前
【TypeScript入坑】TypeScript 的复杂类型「Interface 接口、class类、Enum枚举、Generics泛型、类型断言」
开发语言·前端·javascript·面试·typescript
蓝莓味柯基1 小时前
React——点击事件函数调用问题
前端·javascript·react.js
一嘴一个橘子1 小时前
js 将二进制文件流,下载为excel文件
javascript
Sam90291 小时前
【Webpack--013】SourceMap源码映射设置
前端·webpack·node.js
Jinuss2 小时前
npm的作用域介绍
npm·node.js
小兔崽子去哪了2 小时前
Element plus 图片手动上传与回显
前端·javascript·vue.js
A阳俊yi2 小时前
Vue(13)——router-link
前端·javascript·vue.js