Node 模块机制不完全指北

1,512 阅读8分钟

引言


Node 中采用了 CommonJs 规范,通过 module.exports 和 require 来导出导入模块。在模块加载机制中,Node 采用延迟加载的策略,只有到使用到时,才会去加载,加载之后会被存入 cache 中。

面试中关于模块机制的常见问题


  • require 的加载机制?
  • 假设有 a.js、b.js 两个模块相互引用,会有什么问题?是否为陷入死循环?
  • a 模块中的 undeclaredVariable 变量在 b.js 中是否会被打印?
  • module.exports 与 exports 的区别
  • import 和 require 的区别
  • 模块在 require 的过程中是同步还是异步?

Node 模块分类


1. 核心模块

在 Node 中有一些用二进制发布的模块,称之为核心模块。核心模块只能通过模块名来引用,而不能通过文件路径来引用,即使已经存在一个与其同名的第三方模块,也会优先加载核心模块。

2. 文件模块

可以通过绝对路径从文件系统中加载非核心模块。

const module = require('../my_module/module');

通过路径加载模块时可以省略.js后缀,如果没有找到这个文件,Node 会在文件名后面加上.js再次查找路径。

3. 文件夹模块

const module = require('../my_module');

Node 会在指定的文件夹中查找模块。Node 会假设当前文件夹是一个包含了 package.json 的包定义。

  • 如果 package.json 存在就会解析文件,查找 main 属性,并将 main 属性作为入口点。
  • 如果 package.json 不存在,就会将包入口点假设为 index.js
4. 加载 node_modules 模块

如果在核心模块和文件模块都没有找到模块,Node 就会尝试在当前目录下的 node_modules 文件夹中查找该模块。

const myModule = require('myModule.js');
  1. 尝试找到文件 './node_modules/myModules.js'
  2. 如果没有找到,就会查找上一级目录 '../node_modules/myModules.js'
  3. 如果还是没有找到,就在往上一级目录,直到找到根目录或者找到为止
5. 缓存模块

模块在首次加载时,会被缓存起来,意味着如果模块名能解析为相同的文件名,那么在此调用时,都会返回同一模块。

// myModules.js
console.log('modules start initializing...');
modules.exports = function() {
    console.log(1);
}
console.log('modules end initializing...');
// 一次加载
var myModules = require('./myModules.js');
// 输出
// modules start initializing...
// modules end initializing...
// 两次加载
var myModules = require('./myModules.js');
var myModules = require('./myModules.js');
// 还是输出
// modules start initializing...
// modules end initializing...
// 意味着模块的初始化只会被初始化一次

require 模块加载机制


面试中可能会问道:“能说一下 Node require 的加载机制吗?”

Node 的模块加载机制,分为三个步骤:路径解析、文件定位、编译执行

Require模块加载机制.png

  1. 缓存模块:模块被加载之后会放入到缓存区,模块执行首先是先进行缓存加载,判断缓存中是否有值。
  2. 原生模块:仅次于缓存模块,原生模块被编译成二进制,省略了文件定位、编译执行,直接加载到了内存中。
  3. 文件模块:优先加载 .、..、/ 开头的,如果文件没有加上扩展名,会依次按照 .js、.json、.node 进行扩展名补足尝试,那么在尝试的过程中也是以同步阻塞模式来判断文件是否存在,从性能优化的角度来看待,.json、.node最好还是加上文件的扩展名。
  4. 文件夹模块:这种情况发生在文件模块查询的过程中,发现没有找到文件模块,但是路径是一个文件夹时,这时候会将目录作为一个包处理。先在包的根目录查找 package.json 文件夹,如果没有就会找index.js,如果有就会解析文件,找 main 属性描述的文件进行加载,如果加载文件没有加载到就会报错。
  5. node_modules模块:当上诉模块都没有找到的时候就会在当前目录的父目录的 node_modules 中查找如果没有找到就在在往上次目录中查找,直到找到或者找到根目录结束。

require 文件加载机制


关于文件模块的加载,大家可以直接看这张图,我就不多讲了,很清晰。

image.png

require 加载模块的时候是同步还是异步?


先回答问题,同步的! 但是面试官要是问你为什么是同步还是异步的呢? 其实这个答案并不是固定的,但是小伙伴们可以通过这几方面给面试官解释。

  1. 一个作为公共依赖的模块,当然想一次加载出来,同步更好
  2. 模块的个数往往是有限的,而且 Node.js 在 require 的时候会自动缓存已经加载的模块,再加上访问的都是本地文件,产生的IO开销几乎可以忽略。所以不需要异步,同步就好。

require加载模块会先运行目标文件


当在文件中 require 某个模块时,会从头开始先运行目标文件。

列子1
// a.js
let a = 1;
a = a + 1;
module.exports = a;
a = a + 1;
// main.js
const a = require('./a');
console.log(a); // 2

a.js导出了一个基础数据类型a,module.exports ,这时 a = 2,后面的 a 再次加一,并不会影响前面a的结果。

例子2
// b.js
let b = {
    x: 1,
    y: 2,
};
b.x = 3;
module.exports = b;
b.x = 4;
// main.js
const b = require('./b');
console.log(a.x); // 4

当a不是基础的数据类型,而是一个引用类型时,module.exports 后面的赋值 a.x = 4 生效。并且你可以在外部改变这个值。

// main.js
const b = require('./b');
console.log(b.x); // 4
b.x= 5;
console.log(b.x); // 5
当基础数据类型和引用数据类型出现不一致的结果

reqire时会从头到尾先运行目标文件,当 a 是基本数据类型时,运行到 module.exports 时将当前 a 的值赋值给 module.exports ,也就相当于在内存中创建了一个变量然后给它赋值为2,它的值就不会改变了,后面对 a 的修改并不会对它有影响。

当为引用数据类型时,赋值给 module.exports 是一个内存地址指向对象,后续对对象的修改也会反应到 module.exports 中。

模块被加载之后会被缓存起来


运行上面的列1、列2,在 main.js 中输出 rquire.cache 。 image.png

模块基于其文件名进行缓存(绝对路径),由于调用模块的位置的不同,模块可能被解析成不同的文件名(比如从 node_modules 目录加载),这样就不能保证 require('xx') 总能返回完全相同的对象。

此外,在不区分大小写的文件系统或操作系统中,被解析成不同的文件名可以指向同一文件,但缓存仍然会将它们视为不同的模块,并多次重新加载。 例如, require('./a') 和 require('./A') 返回两个不同的对象,而不会管 ./a 和 ./A 是否是相同的文件。

模块的循环引用


面试中可能会问道:“如果两个 js 文件,a.js 、b.js 在 a 中引用 b ,在 b 中引用 a ,会有问题吗?”

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);
// 运行结果
$ node main.js
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
  1. node main.js
  2. require a.js,load a.js,输出“a starting“
  3. a: exports.done = false,require b.js,load b.js
  4. 输出”b starting“,b: exports.done = false
  5. require a.js, 由于a.js没有执行完,将未完成的副本导出,所以 a = {done: false}
  6. 输出in b, a.done = false
  7. b: exports.done = true,输出b done,b.js执行完毕,返回a.js继续执行
  8. b = { done: true },输出in a, b.done = true,输出a done
  9. a.js 执行完毕,a = { done: true } ,返回 main.js 继续执行,require b.js
  10. 由于 b.js 已经被执行完毕,缓存中拿值,现在 a = { done: true }, b = { done: true }
  11. 输出in main, a.done = true, b.done = true

当main.js加载时a.js,则a.js依次加载b.js。此时,b.js尝试加载a.js。为了防止无限循环,将未完成的a.js导出对象副本返回到 b.js模块。b.js然后完成加载,并将其exports对象提供给a.js模块。

循环依赖的时候为什么不会无限循环引用


源码解析:有兴趣的同学可以看看这里

不会循环引用的大体思路是:

  1. require('./a.js');此时会调用 self.require(), 然后会走到module._load,在_load中会判断./a.js是否被load过,当然运行到这里,./a.js还没被 load 过,所以会走完整个load流程,直到_compile。
  2. 运行./a.js,运行到 exports.done = false 的时候,给 esports 增加了一个属性。此时的 exports={done: false}。
  3. 运行require('./b.js'),同 第 1 步。
  4. 运行./b.js,到require('./a.js')。此时走到_load函数的时候发现./a.js已经被load过了,所以会直接从_cache中返回。所以此时./a.js还没有运行完,exports = {done.false},那么返回的结果就是 in b, a.done = false;
  5. ./b.js全部运行完毕,回到./a.js中,继续向下运行,此时的./b.js的 exports={done:true}, 结果自然是in main, a.done=true, b.done=true

未定义变量引用问题

在这里面试中还会出现一个”在 a.js 中没有被定义的变量,在 b.js 可以访问吗?“。答案是可以的,我们看例子。

// a.js
let a = 1;
a = a + 1;
x = 10;
module.exports = a;
// b.js
let b = {
  x: 1,
  y: 2,
};
b.x = 3;
console.log(x); // 10
module.exports = b;
// main.js
const a = require('./a');
console.log(a); // 2

const b = require('./b');
console.log(b.x); // 4

发现在 b.js 总可以访问到 a.js 未定义的变量 x。还可以正常获取到值。原因很简单,因为 x 是一个未声明的变量,也就是一个挂在全局的变量,那么在其他地方当然是可以拿到的。

exports和module.exports的区别


在 node 的 js 模块里可以直接调用 exports 和 module 两个“全局”变量,但是 exports 是 module.exports 的一个引用。在 node 编译的过程中,会把js模块编译成如下形式:

// require 是对 Node.js 实现查找模块的 Module._load 实例的引用
// __finename 和 __dirname 是 Node.js 在查找该模块后找到的模块名称和模块绝对路径
(function(exports, require, module, __filename, __dirname){
  // ....
})
  • exports 是 module.exports 的一个引用
  • module.exports 初始化是一个{},exports 也只想这个{}
  • require 引用返回的是 module.exports,而不是 exports
  • exports.xxx = xxxx 相当于在导出对象上直接添加属性或者修改属性值,在调用模块直接可见
  • exports = xxx 为 exports 重新分配内存,将脱离 module.exports ,两者无关联。调用模块将不能访问。

参考