Skip to content

总览

  1. nodejs包机制,模块查找路径,缓存策略
  2. 异步编程思想和nodejs的事件轮询机制
  3. CommonJS,AMD包规范

nodejs包机制,模块查找路径,缓存策略

模块是nodejs中很重要的一个概念,在nodejs和es6之前,js是不存在模块的概念的,要想创建一个独立的库(最基本的,库内的变量不会污染全局作用域,并且变量也不能被外部访问防止库内的状态被修改)是通过函数的作用域机制来实现的,nodejs在实现模块时也使用到这种机制,但其模块的概念最重要地还是体现在模块加载,查找和缓存等机制上,模块机制使得nodejs得以成为工程化地解决方案,像代码的独立和分割,互相间的依赖关系,加载顺序,外部库的依赖等这些机制都是工程化项目必须具备的。

在nodejs中,一个文件就是一个模块,nodejs可以加载的文件有:.js,.json,.node,这三种文件都是作为模块来加载的。
加载模块时通常可以看到这几种写法
1,require('fs')
2,require('koa')
3,require('./lib')
4,require('./main.js')
分别讲解下这几种方式是如何加载的,下面说的当前目录指的是执行require的文件所在目录。
1,加载原生模块,加载时会优先查找原生模块,原生模块时编译好的二进制文件,加载速度非常快。
2,加载第三方模块,按模块查找机制来,先在当前目录下的node_modules内查找对应文件,若没有则查找对应目录,在目录中的判断机制同第三种加载方式中的讲解。若还没找到则找上级目录(执行require文件所在目录的上级目录)的node_modules,这样递归到根目录,最后在NODE_PATH中查找,若最后扔没找到,则会报'Cannot find module'错误。
3,这种加载方式在当前目录下优先查找同名文件有则加载,没文件则查找目录,在目录中是这样判断的:先判断package.json,main指定的入口文件,若不存package.json或main未定义或main定义的文件未找到,则查找index.js,若不存在index.js则查找index.json。
4,加载当前目录下的指定文件
require是动态加载模块的,这种机制使得可以做这种骚操作,比如可以在满足某种条件下才加载某个模块。

来张图更好看

下面这段是抄别人的,讲的比较详细,模块的加载机制当然和上面说的是一样的,这点没疑问:
NodeJS定义了一个特殊的node_modules目录用于存放模块。例如某个模块的绝对路径是/home/user/hello.js,在该模块中使用require('foo/bar')方式加载模块时,则NodeJS依次尝试使用以下路径。
/home/user/nodemodules/foo/bar
/home/nodemodules/foo/bar
/nodemodules/foo/bar
可以看出module path的生成规则为:从当前文件目录开始查找nodemodules目录;然后依次进入父目录,查找父目录下的nodemodules目录;依次迭代,直到根目录下的nodemodules目录。
NODEPATH环境变量
与PATH环境变量类似,NodeJS允许通过NODEPATH环境变量来指定额外的模块搜索路径。NODEPATH环境变量中包含一到多个目录路径,路径之间在Linux下使用:分隔,在Windows下使用;分隔。例如定义了以下NODEPATH环境变量:
NODEPATH=/home/user/lib:/home/lib
当使用require('foo/bar')的方式加载模块时,则NodeJS依次尝试以下路径
/home/user/lib/foo/bar
/home/lib/foo/bar

nodejs加载模块后,会立即执行其种的代码,这种机制可以用来初始化模块的状态,模块加载后,将会缓存起来,不会重复加载,这样再在其它地方require这个模块时,将会从缓存中取,并且模块已有初始化状态,不会再执行一遍其中的代码,这种机制使nodejs的模块机制非常高效,在循环依赖时也不会出现无限加载的情况。在开发时这种机制就不好玩了,修改了代码后无法立即看到效果,每次都要重新启动服务,效率低下,作为一个有底线的程序猿怎么可以干这种费时间的重复劳动呢,于是热部署库应运而生,下面介绍一个热部署库forever。其原理是检测到文件变化后,自动重启应用,很多开发框架也内置了这种机制,像eggjs做的更好,它会启动多个子进程,每个子进程依次重启,这样保证服务不会断掉。

这篇介绍模块机制的文章写得不错

异步编程思想和nodejs的事件轮询机制

由于js单线程的特点,使用js编程耗时任务时不能阻塞主线程而需要采用异步回调的方式来执行耗时任务。但nodejs并不是单线程的,它除了有一个执行逻辑的主线程外,其底层在处理耗时任务时还会开辟其它线程。在nodejs发展初期,由于js主线程采用了异步回调的编程模型,在编程体验上回调会带来像回调过深,出问题不好定位等等问题,于是promise规范应运而生,单单promise虽然能使nodejs编码不再像之前那么混乱,但还不是最优雅的解决方案,之后在es6规范里的generator,yield函数加上第三方库如co和promise的配合让nodejs编写异步代码能更优雅一些,最后es7的async/await加上对promise官方的支持才是nodejs异步编程最终最优雅的解决方案。这部分我们留到es6特性那章来介绍。
下面说说nodejs的事件轮询机制。这几个文章对nodejs的事件轮询机制都讲地不错,可以先过一遍。
Node.js的事件轮询Event Loop原理解释
理解Node.js的事件轮询
什么是事件循环(Event loop)?
这段也说的不错,可以学习下:
一次完整的 Event Loop 可以分为多个阶段(phase),依次是 poll、check、close callbacks、timers、I/O callbacks 、Idle。
由于 Node.js 是事件驱动的,每个事件的回调函数会被注册到 Event Loop 的不同阶段。比如 fs.readFile 的回调函数被添加到 I/O callbacks,setImmediate 的回调被添加到下一次 Loop 的 poll 阶段结束后,process.nextTick() 的回调被添加到当前 phase 结束后,下一个 phase 开始前。
不同异步方法的回调会在不同的 phase 被执行,掌握这一点很重要,否则就会因为调用顺序问题产生逻辑错误。
Event Loop 不断的循环,每一个阶段内都会同步执行所有在该阶段注册的回调函数。这也正是为什么我在网络 I/O 部分提到,不要在回调函数中调用阻塞方法,总是用异步的思想来进行耗时操作。一个耗时太久的回调函数可能会让 Event Loop 卡在某个阶段很久,新来的网络请求就无法被及时响应。

按照我个人的理解来说说nodejs的事件轮询,不会涉及到太底层的东西,只讲下它的设计思想和原理:
当你执行一段异步程序时,nodejs会将异步操作推入到一个轮询队列中(往细点讲,每种回调级别都不一样,因此会影响回调的执行顺序,通过对比setImmediate和nextTick可以看出这种差异),然后主线程继续往下执行,对之后的异步调用也执行如上操作,主线程在这段之间里称为事件生产者,所有的操作都执行完后,线程进入下一次轮询,轮询当前队列中的事件,此时它作为事件消费者,从队列中将事件取出执行,在遇到异步函数时又会执行和之前一样的操作。在这期间遇到耗时的io等操作任务,会开辟其对应线程,因此不会阻塞主线程的轮询,这个线程会持有操作完成后回调函数的句柄 ,在io操作完成后,又会将一个任务(包裹了之前任务的句柄)推入事件队列中,在下次轮询时由主线程调用,并执行接下来的逻辑,就这样nodejs将一个任务拆分成多块执行,但其业务逻辑还是整体的,只是在执行上不同传统线程模式而是将任务分开,看上去多此一举,但这种方式不会产生像传统线程操作时遇到耗时任务cpu操作要卡在那里和耗费资源的线程切换的情况。要知道,cpu执行效率是非常非常快的,如果任务不饱和,cpu的大部分时间其实都在等待io这些耗时操作,nodejs利用回调机制,充分地利用了cpu资源,这也是其一直被称道的可以处理大量并发任务的原因。
运行完下面这段code你就会明白,我绝对没胡说😅

js
// 先看一段正常的http服务端代码
var http = require('http');
http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/plain'});
  response.write("Hello World");
  response.end();
}).listen(8080, '127.0.0.1');
console.log('Server running on port 8080.');

// 然后在这几个地方放上这段神奇的代码试试 while(true) {}
var http = require('http');
http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/plain'});
  response.write("Hello World");
  response.end();
  // setTimeout(()=>{while(true) {}}) // 4.去掉注释试试
  // while(true) {} // 3.去掉注释试试
}).listen(8080, '127.0.0.1');
console.log('Server running on port 8080.');
// setTimeout(()=>{while(true) {}}) // 2.去掉注释试试
// while(true) {} // 1.去掉注释试试

上次说到事件轮询的setImmediate和process.nextTick,看这张图和这篇文章事件循环可以更清楚他们之间的执行顺序

下面上代码来玩一把。

js
process.nextTick(function(){
  console.log("延迟执行");
});
console.log("正常执行");

setImmediate(function(){
  console.log("延迟执行");
});
console.log("正常执行");
 
process.nextTick(function(){
  console.log("nextTick延迟")
});
setImmediate(function(){
  console.log("setImmediate延迟");
});
console.log("正常执行");

setImmediate(function(){
  console.log("setImmediate延迟");
});
process.nextTick(function(){
  console.log("nextTick延迟")
});
console.log("正常执行");
// nextTick()的回调函数执行的优先级要高于setImmediate(),
// process.nextTick()属于idle观察者,setImmediate()属于check观察者。在每一轮循环检查中,
// idle观察者先于I/O观察者,I/O观察者先于check观察者。
// 在具体实现上,process.nextTick()的回调函数保存在一个数组中,
// setImmediate()的结果则是保存在链表中。
// 在行为上,process.nextTick()在每轮循环中会将数组中的回调函数全部执行完。
// 而setImmediate()在每轮循环中执行链表中的一个回调函数。

//加入2个nextTick()的回调函数
process.nextTick(function(){
  console.log("nextTick延迟执行1");
});
process.nextTick(function(){
  console.log("nextTick延迟执行2");
});
//加入两个setImmediate()回调函数
setImmediate(function(){
  console.log("setImmediate延迟执行1");
  process.nextTick(function(){
    console.log("强势插入");
  });
});
setImmediate(function(){
  console.log("setImmediate延迟执行2");
});
console.log("正常执行");
// 当第一个setImmediate()的回调函数执行完后,并没有立即执行第二个,
// 而是进入了下一轮循环,再次按nextTick()优先,setImmediate()次后的顺序执行。
// 之所以这样设计,是为了保证每次循环能够较快的执行结束。
// 防止CPU占用过多而阻塞后续I/O调用的情况。

CommonJS,AMD包规范

在es6包规范未出来之前,nodejs确实需要一套包机制来管理项目,commonjs规范应用而生,nodejs采用了这套规范,require()函数用来加载模块,module代表了当前模块对象,exports导出供其它模块使用的内容。commonjs规范可以动态/按需加载,与es6的包机制不同,es6的import语句必须放在文件的最开始,而且import的文件会一次全部加载进来,无法按需加载,据说后面会出按需加载的规范,这种方式是为了能前后端都通用才这样设计的。commonjs在加载包时是同步执行的,当前模块依赖的包全部加载完才会往下执行,这种设计方式使它适合服务器端,由于服务器端的依赖都放在本地,加载速度非常快,等所有的包都加载完再执行业务也没什么问题,但这种机制使它就不适合浏览器这种需要远程下载包的环境了,若等到所有包都加载完再执行业务代码,遇到一个大文件的包或者网络不好,就会出现页面长时间没响应的情况,想想用户体验要糟糕到什么程度。所以在浏览器端需要使用异步加载的方式,Asynchronous Module Definition,即AMD规范,这种方式在加载完包时有一个回调函数来执行相关业务,requirejs和webpack都实现了AMD规范,通过requirejs或webpack打包之后,你就可以在浏览器端使用这种包机制了,这样管理前端代码岂不美滋滋😝

js
// commonjs的方式,文件就是模块
// maths.js
exports.add = function (x,y){
  return x+y;
}
// use.js
var math = require('math');
math.add(2, 3);

// amd的方式
// 使用define函数先定义模块 math.js
define(function (){
  return {
    add: function (x,y){
      return x+y;
    }
  };
});
// use.js
require(['math'], function (math) {
  math.add(2, 3);
});

模块这块,看看阮大神怎么说,分为了三章
这篇文章对模块系统介绍地更详细,还有UMD,我也是第一次听说
众多规范中还有一个CMD,了解下他们有什么区别
es6的模块机制介绍,看完让你神清气爽