[Nodejs学习之旅2-1] 模块机制

CommonJS模块详解
本文深入解析CommonJS模块规范,探讨其引入原因、模块定义、标识及特性,详细讲解Node.js中模块的加载机制,包括路径分析、文件定位、编译执行及缓存策略。

CommonJs规范


在介绍Nodejs的模块机制之前,我们得先了解一下CommonJS规范。因为Node应用是由模块组合而成,像我们经常会用到的underscoreloadashaxios等都是一个个的模块,这些模块都遵循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对象,它代表模块自身,而exportsmodule的属性。

// 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__变量的gettersetterclass是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暴露出来的gettersetter去获取和改变__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中引入一个模块会经历三步走策略:

  1. 路径分析
  2. 文件定位
  3. 编译执行

模块分类

Node中模块分为两种:核心模块(node内置)和文件模块(用户编写)。

我们常用的httpfsos等模块就属于核心模块,这部分模块因为在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_PATHHOME 目录下的.node_libraries.node_modules
  • 除此之外还有一个全局 module path,是当前 node 执行文件的目录。

文件定位

当命中缓存加载策略的时候,模块不再需要三步走策略,而是直接从缓存中读取。

处理完路径解析后,紧接着就是文件定位了,主要包括文件扩展名的分析、目录和包的处理。

文件扩展名分析

当标志符不包含文件扩展名时,Node会按照jsjsonnode的次序补足扩展名。 在尝试的过程中,需要以同步阻塞的方式判断文件是否存在,有可能会引起性能方面的问题。因此,对于.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对象上。

编译方式

各位有没有想过,为什么模块中默认会有requiremoduleexports三个变量供我们使用,除了这三个变量外,__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
复制代码

有些点得注意一下:

  1. 所有的exports收集到的属性和方法,都赋值给了Module.exports。前提这不要切断两者之间的引用关系。
  2. 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.exportsexports,导出对象请用module.exports,导出变量或者函数建议用exports

参考


《深入浅出Node.js》
nodejs模块中exports和module.exports的区别

转载于:https://juejin.im/post/5c83d662e51d4530ac5f2d58

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值