模块以及模块加载机制

在Node.js中,以模块为单位划分功能,通过一个完整的模块加载机制使得开发人员可以将应用程序划分为多个不同的部分。模块的使用可以提高代码重用率,提高应用程序的开发效率,而且开发人员可以根据具体的需求引入第三方模块或者自定义模块到应用程序中

  • 先计算模块路径
  • 如果模块在缓存里面,取出缓存
  • 是否为内置模块,如果是返回内置模块
  • 加载模块
  • 输出模块的exports属性即可
// require 其实内部调用 Module._load 方法
Module._load = function(request, parent, isMain) {
  //  计算绝对路径
  var filename = Module._resolveFilename(request, parent);

  //  第一步:如果有缓存,取出缓存
  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;

  // 第二步:是否为内置模块
  if (NativeModule.exists(filename)) {
    return NativeModule.require(filename);
  }
  
  /********************************这里注意了**************************/
  // 第三步:生成模块实例,存入缓存
  // 这里的Module就是我们上面的1.1定义的Module
  var module = new Module(filename, parent);
  Module._cache[filename] = module;

  /********************************这里注意了**************************/
  // 第四步:加载模块
  // 下面的module.load实际上是Module原型上有一个方法叫Module.prototype.load
  try {
    module.load(filename);
    hadException = false;
  } finally {
    if (hadException) {
      delete Module._cache[filename];
    }
  }

  // 第五步:输出模块的exports属性
  return module.exports;
};

加载模块时,为什么每个模块都有__dirname,__filename属性呢?

(function (exports, require, module, __filename, __dirname) {
  // 模块源码
  // 假如模块代码如下
  var math = require('math');
  exports.area = function(radius){
      return Math.PI * radius * radius
  }
});

在module.load()中,对其进行了包装,传入了__filename, __dirname

exports.xxx=xxx和Module.exports={}区别

exports其实就是键值对的赋值方式,Module.exports 输出的是一个对象

var load = function (exports, module) {
    // hello.js的文件内容
    ...
    // load函数返回:
    return module.exports;
};

var exportes = load(module.exports, module);

也就是说,默认情况下,Node准备的exports变量和module.exports变量实际上是同一个变量,并且初始化为空对象

node环境下的事件循环机制

  • Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。
  • Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。
  • Node.js 的包管理器 npm,是全球最大的开源库生态系统。

事件是一种通过监听事件或状态的变化而执行回调函数的流程控制方法

清除node_modules包缓存 node_modules .cache_数组

对于Node中异步I/O调用,从发起调用到内核执行完I/O操作的过渡过程中存在一种中间产物请求对象.

第一阶段:异步调用

在Javascript层面代码会调用C++核心模块,核心模块会调用内建模块通过libuv进行系统调用.创建一个请求对象并将入参和当前方法等所有状态都封装在请求对象,然后被推入到『 线程池 』中等待执行. 然后异步调用的第一阶段结束

第二阶段:线程池阶段

JavaScript 主线程继续执行后续操作, 直至所有非阻塞操作都处理完. 因为 I/O 操作在线程池中等到处理, 所以也不会阻塞主线程.

当线程池有空余线程时, 等待的 I/O 操作会被处理. 当 I/O 操作完成, 获取的结果保存在请求对象的 result 属性上. 操作系统会来检查线程池是否有处理完的请求, 如果有, 就把请求对象加入到 『 事件队列 』中.

第三阶段:事件循环阶段

在主线程的所有非阻塞代码都处理完后, 『 事件循环 』开始逐个处理事件队列中的事件. 保存在请求对象的 result 属性被取出, 作为与请求绑定的回调函数的参数. 然后执行回调来完成此 I/O 操作.

事件循环是一个典型的生产者/消费者模型。异步I/O,网络请求等则是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

生产者和消费者问题是线程模型中的经典问题:生产者和消费者在同一时间段内共用同一个存储空间,生产者往存储空间中添加产品,消费者从存储空间中取走产品,当存储空间为空时,消费者阻塞,当存储空间满时,生产者阻塞。

node中事件循环的实现是依靠的libuv引擎。我们知道node选择chrome v8引擎作为js解释器,v8引擎将js代码分析后去调用对应的node api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上node中的事件循环存在于libuv引擎中。

事件循环模型

 ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段...

  • timers: 这个阶段执行定时器队列中的回调如 setTimeout() 和 setInterval()。
  • I/O callbacks: 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和setImmediate()的回调。
  • idle, prepare: 这个阶段仅在内部使用,可以不必理会。
  • poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
  • check: setImmediate()的回调会在这个阶段执行。
  • close callbacks: 例如socket.on('close', ...)这种close事件的回调。

当个v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段。poll阶段的执行逻辑如下: 先查看poll queue中是否有事件,有任务就按先进先出的顺序依次执行回调。 当queue为空时,会检查是否有setImmediate()的callback,如果有就进入check阶段执行这些callback。但同时也会检查是否有到期的timer,如果有,就把这些到期的timer的callback按照调用顺序放到timer queue中,之后循环会进入timer阶段执行queue中的 callback。 这两者的顺序是不固定的,收到代码运行的环境的影响。如果两者的queue都是空的,那么loop会在poll阶段停留,直到有一个i/o事件返回,循环会进入i/o callback阶段并立即执行这个事件的callback。

EventEmitter模块的事件机制

大多数 Node.js 核心 API 都采用惯用的异步事件驱动架构,其中某些类型的对象(触发器)会周期性地触发命名事件来调用函数对象(监听器)。例如,net.Server 对象会在每次有新连接时触发事件;fs.ReadStream 会在文件被打开时触发事件;流对象 会在数据可读时触发事件。所有能触发事件的对象都是 EventEmitter 类的实例。

const events = require('events');

console.log(events);

// 创建 eventEmitter 对象
const eventEmitter = new events.EventEmitter();

eventEmitter.on('save', function(name) {
  console.log('save', name);
});

eventEmitter.emit('save', 'save name');

EventEmitter 相当于一个中介,记录了那些触发后的回调是什么,然后一一去执行注册事件的回调,通过emit去触发,所有的on方法监听的对象都会触发

清除node_modules包缓存 node_modules .cache_数组_02

class EventEmitter {
  constructor() {
    this._events = {}; // 保存所有的事件
  }
  on(eventName, callback) {
    if (!this._events[eventName]) {
      this._events[eventName] = [];
    }
    this._events[eventName].push(callback);
  }
  emit(eventName, ...arg) {
    // 如果on方法监听了多个事件的话,依次执行代码
    if (this._events[eventName]) {
      for (let i = 0; i < this._events[eventName].length; i++) {
        this._events[eventName][i](...arg);
      }
    }
  }
}
    /*
     这里是其他情况,如果list监听的不是一个函数的话,而是一个数组的话,比如on监听的多个相同的事件名的时候,则遍历该数组,同样判断,如果该数组的任何一项等于被删除掉的 listener函数的话,
     或者数组中的任何一项的key===listener 等于被删除掉的listener函数的话,使用 originalListener = list[i].listener; 保存起来,同样使用 position = i; 保存该对应的位置。跳出for循环。
     2. if (position < 0) 如果该 position 小于0的话,直接返回。说明没有要删除的事件。
     3. 如果position === 0的话,则删除数组中的第一个元素,使用 list.shift(),移除数组中的第一个元素。
     4. 否则的话调用 spliceOne 方法,删除数组list中的对应位置的元素,该spliceOne方法代码如下:
       function spliceOne(list, index) {
         for (; index + 1 < list.length; index++)
           list[index] = list[index + 1];
         list.pop();
       }
    */
    position = -1;

    for (i = list.length - 1; i >= 0; i--) {
      if (list[i] === listener || list[i].listener === listener) {
        originalListener = list[i].listener;
        position = i;
        break;
      }
    }

    if (position < 0)
      return this;

    if (position === 0)
      list.shift();
    else {
      spliceOne(list, position);
    }
    // 如果list.length === 1 的话,events[type] = list[0]. 
    if (list.length === 1)
      events[type] = list[0];

    if (events.removeListener !== undefined)
      this.emit('removeListener', type, originalListener || listener);
  }

  return this;
};

EventEmitter.prototype.off = EventEmitter.prototype.removeListener;
Promise

什么是回调地狱?

函数作为参数层层嵌套,代码耦合,不易复用,可读性差,不易维护

Promise 利用了三大技术手段来解决回调地狱

  • 回调函数延迟绑定。

  • 返回值穿透。

  • 错误冒泡

什么是 Generator?

function* gen(name) {
  yield "打印:" + name + "!";
}

  • 调用 gen() 后,程序会阻塞住,不会执行任何语句。
  • 调用 g.next() 后,程序继续执行,直到遇到 yield 程序暂停。
  • next 方法返回一个对象, 有两个属性: value 和 done。value 为当前 yield 后面的结果,done 表示是否执行完,遇到了return 后,done 会由false变为true

生成器实现机制——协程

协程是比线程更小的一种执行单元,你可以认为是轻量级的线程,协程处在线程的环境中,一个线程可以存在多个协程,可以将协程理解为线程中的一个个任务。不像进程和线程,协程并不受操作系统的管理,而是被具体的应用程序代码所控制

协程为什么比较快?

  • 在切换的时候,寄存器需要保存和加载的数据量比较小。
  • 高速缓存可以有效利用
  • 没有用户模式到内核模式的切换操作

co 和 generator

const readThunk = (name) => {
    return (callback) => {
        callback(null, name)
    }
  }

  const gen = function* () {
    const data1 = yield readThunk('小明')
    console.log(data1.toString())
    const data2 = yield readThunk('小张')
    console.log(data2.toString)
  }

  let g = gen();
// 第一步: 由于进场是暂停的,我们调用next,让它开始执行。
// next返回值中有一个value值,这个value是yield后面的结果,放在这里也就是是thunk函数生成的定制化函数,里面需要传一个回调函数作为参数
g.next().value((err, data1) => {
  // 第二步: 拿到上一次得到的结果,调用next, 将结果作为参数传入,程序继续执行。
  // 同理,value传入回调
  g.next(data1).value((err, data2) => {
    g.next(data2);
  })
})

打印结果如下:

小明
小张

readThunk 它的核心逻辑是接收一定的参数,生产出定制化的函数,然后使用定制化的函数去完成功能

采用co

是针对 thunk 函数和Promise两种Generator异步操作的一次性执行完毕做了封装

const co = require('co');
let g = gen();
co(g).then(res =>{
  console.log(res);
})

async/await

采用generator(生成器)通过递归自迭代的方式

function _asyncToGenerator(fn) {
  return function() {
    var self = this,
      args = arguments;
    // 将返回值promise化
    return new Promise(function(resolve, reject) {
      // 获取迭代器实例
      var gen = fn.apply(self, args);
      // 执行下一步
      function _next(value) {
        asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'next', value);
      }
      // 抛出异常
      function _throw(err) {
        asyncGeneratorStep(gen, resolve, reject, _next, _throw, 'throw', err);
      }
      // 第一次触发
      _next(undefined);
    });
  };
}
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
  try {
    var info = gen[key](arg);
    var value = info.value;
  } catch (error) {
    reject(error);
    return;
  }
  if (info.done) {
    // 迭代器完成,将返回值(return)保存起来
    resolve(value);
  } else {
    // -- 这行代码就是精髓 --
    // 将所有值promise化
    // 比如 yield 1
    // const a = Promise.resolve(1) a 是一个 promise
    // const b = Promise.resolve(a) b 是一个 promise
    // 可以做到统一 promise 输出
    // 当 promise 执行完之后再执行下一步
    // 递归调用 next 函数,直到 done == true
    Promise.resolve(value).then(_next, _throw);
  }
}

通过测试

const asyncFunc = _asyncToGenerator(function* () {
  console.log(1);
  yield new Promise(resolve => {
    setTimeout(() => {
      resolve();
      console.log('sleep 1s');
    }, 1000);
  });
  console.log(2);
  const a = yield Promise.resolve('a');
  console.log(3);
  const b = yield Promise.resolve('b');
  const c = yield Promise.resolve('c');
  return [a, b, c];
})

asyncFunc().then(res => {
  console.log(res)
});

与async/await对比

const func = async () => {
  console.log(1)
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve()
      console.log('sleep 1s')
    }, 1000)
  })
  console.log(2)
  const a = await Promise.resolve('a')
  console.log(3)
  const b = await Promise.resolve('b')
  const c = await Promise.resolve('c')
  return [a, b, c]
}

func().then(res => {
  console.log(res)
})

V8的垃圾回收机制
console.log(process.memoryUsage())

打印结果为

Object {rss: 21192704, heapTotal: 5259264, heapUsed: 2446440, external: 778254}

heapTotal 和 heapUsed 代表V8的内存使用情况。 external代表V8管理的,绑定到Javascript的C++对象的内存使用情况。 rss, 驻留集大小, 是给这个进程分配了多少物理内存(占总分配内存的一部分) 这些物理内存中包含堆,栈,和代码段

JavaScript内存机制

基本数据类型通过栈存储,引用数据类型用堆存储

64位系统下V8只能分配是1.4GB内存, 32位系统下是0.7GB。

V8 官方是这样形容的:因为1.5GB的垃圾回收堆内存,V8需要花费50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起Javascript线程暂停执行的事件,JS代码执行会一直没有响应,造成应用卡顿,在这样的花销下,应用的性能和影响力都会直线下降。

对于栈内存,通过指针来保存当前的执行状态,执行完后,指针下移,对应的空间就会被回收。

V8 为什么要给它设置内存上限?明明我的机器大几十G的内存,只能让我用这么一点?
原因如下:

  • js是单线程执行的
  • 在进行垃圾回收的时候,会暂停js的执行,垃圾回收又是比较耗时的

在V8中,V8把堆内存分成了两部分进行处理——新生代内存老生代内存

新生代内存

新生代内存是用来存储存活时间比较短的对象,64 位和 32 位系统下分别为 32MB 和 16MB

清除node_modules包缓存 node_modules .cache_其他_03

新生代中的对象主要通过Scavenge算法进行垃圾回收。这是一种采用复制的方式实现的垃圾回收算法。它将堆内存一份为二,存在两个半空间。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的半空间称为From空间,处于闲置状态的空间称为To空间
清除node_modules包缓存 node_modules .cache_事件循环_04

当进行垃圾回收时,V8 将From部分的对象检查一遍,如果是存活对象那么复制到To内存中(在To内存中按照顺序从头放置的,由于堆内存需要进行连续分配,解决空间内存碎片的产生),如果是非存活对象直接回收即可。

当所有的From中的存活对象按照顺序进入到To内存之后,From 和 To 两者的角色对调,From现在被闲置,To为正在使用,如此循环。

当一个对象经过多次复制依然存活,它将会被认为是生命周期较长的对象。这种新生代中生命周期较长的对象随后会被移到老生代中

老生代

老生代主要采取的是标记清除的垃圾回收算法。与Scavenge复制活着的对象不同,标记清除算法在标记阶段遍历堆中的所有对象,并标记活着的对象,只清理死亡对象。活对象在新生代中只占叫小部分,死对象在老生代中只占较小部分,这是为什么采用标记清除算法的原因

为了解决碎片问题,标记整理被提出来。就是在对象被标记死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存

增量标记

如果老生代的任务过重,V8在进行垃圾回收的时候,不可避免要堵塞任务的执行,标记任务分为很多小的部分完成,标志任务完成后再进入内存整理阶段,类似于React Fiber的思路

造成内存泄漏的原因

闭包 或者 全局变量的引用

  1. 内存泄漏(Memory Leak)指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。
  2. 如果内存泄漏的位置比较关键,那么随着处理的进行可能持有越来越多的无用内存,这些无用的内存变多会引起服务器响应速度变慢。
  3. 严重的情况下导致内存达到某个极限(可能是进程的上限,如 v8 的上限;也可能是系统可提供的内存上限)会使得应用程序崩溃

排查方法

使用 heapdump 保存内存快照时,只会有 Node.js 环境中的对象,不会受到干扰(如果使用 node-inspector 的话,快照中会有前端的变量干扰),然后用chrome浏览器,查看当前内存快照和上一个内存快照的差异

清除node_modules包缓存 node_modules .cache_事件循环_05

Buffer模块

所谓缓冲区Buffer,就是 "临时存贮区" 的意思,是暂时存放输入输出数据的一段内存。Buffer对象的内存分配不是在V8的堆内存中,在Node的C++层面实现内存的申请

为了高效的使用申请来得内存,Node中采用slab分配机制,slab是一种动态内存管理机制,Node以8kb为界限来来区分Buffer为大对象还是小对象,如果是小于8kb就是小Buffer,大于8kb就是大Buffer,如果是大Buffer,由C++底层的SlowBuffer来给Buffer对象提供空间

Buffer.alloc和Buffer.allocUnsafe的区别

Buffer.allocUnsafe创建的 Buffer 实例的底层内存是未初始化的。 新创建的 Buffer 的内容是未知的,可能包含敏感数据。 使用 Buffer.alloc() 可以创建以零初始化的 Buffer 实例

不同进程间通信
  • 使用共享内存,信号量。这种方式可以通过 child_process 模块实现。

  • 使用套接。这种方式可以使用 net,http,websocket 模块实现,还可以使用 socket.io 来实现(推荐)。

  • 使用共享文件。这种方式通过监听文件的变化来实现,不过效率不理想(不推荐)。

  • 使用订阅发布,响应式数据库。通过 Redis 这些数据库,并利用它们的特性进行多进程通信

创建子进程的方法大致有:

  • spawn(): 启动一个子进程来执行命令
  • exec(): 启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况
  • execFlie(): 启动一个子进程来执行可执行文件
  • fork(): 与spawn()类似,不同电在于它创建Node子进程需要执行js文件
  • spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性设置超时时间,一旦创建的进程超过设定的时间就会被杀死
  • exec()与execFile()不同的是,exec()适合执行已有命令,execFile()适合执行文件。

node子进程被杀死,然后自动重启代码

在创建子进程的时候就让子进程监听exit事件,如果被杀死就重新fork一下

var createWorker = function(){
    var worker = fork(__dirname + 'worker.js')
    worker.on('exit', function(){
        console.log('Worker' + worker.pid + 'exited');
        // 如果退出就创建新的worker
        createWorker()
    })
}