读书笔记--深入浅出Node.js

Node.js 是一个基于 Chrome V8 引擎的 JavaScript __运行环境__。

Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。

—— Node.js中文网

特点

异步 I/O

在Web端,过去大多数是同步的方式编写程序,这种串行调用下层应用数据的过程中充斥着串行的等待时间,如果采用多线程来解决这种串行等待,又或多或少地显得小题大做。在Node中,语言层面即可天然并行的特性在这种场景中显得十分有效。
例如读取文件。

var fs = require('fs');
fs.readFile('/etc/passwd1', (err, data) => {
  if (err) throw err;
  console.log('passwd1: ' + data);
});
fs.readFile('/etc/passwd2', (err, data) => {
  if (err) throw err;
  console.log('passwd2: ' + data);
});

对于同步I/O而言,它们的耗时是两个任务耗时之和,而对于异步I/O来说,耗时取决最慢的那个文件读取时间。

事件与回调函数

因为在 JavaScript 中,函数是一等公民,可以将函数作为对象传递给方法作为实参进行调用。

var http = require('http');
var querystring = require('querystring');

http
  .createServer(function(req, res) {
    var postData = '';
    req.setEncoding('utf8');

    req.on('data', function(trunk) {
      postData += trunk;
    });

    req.on('end', function() {
      res.end(postData);
    });
  })
  .listen(8080);
console.log('服务器启动?成');

单线程

优点是不用像多线程那样处处在意共享状态的问题,没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。
缺点是无法利用多核 CPU,错误会引起整个应用推出(应用的健壮性值得考验),大量计算占用 CPU 导致无法继续调用异步 I/O。

HTML5 定制了 Web Workers 的标准,Web Workers 能够创建工作线程来进行计算,以解决 JavaScript 大计算阻塞 UI 渲染的问题,工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作线程不能访问到主线程中的 UI。

Node 采用了与 Web Workers 相同思路来解决单线程中大计算量的问题:child_process。子进程的出现,意味着 Node 可以从容地应对单线程在健壮性和无法利用多核 CPU 方面的问题。Node还可以通过编写C/C++扩展的方式更高效地利用CPU,将一些V8不能做到性能极致的地方通过C/C++实现。

跨平台

兼容Windows和*nix平台主要得益于Node在架构层面的改动,它在操作系统与Node上层模块系统之间构建了一层平台层架构,即libuv。

模块

模块分为 核心模块文件模块

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

文件模块是用户编写的模块。在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

在模块中,上下文提供require()方法来引入外部模块,提供exports对象用于导出变量或方法,还存在一个module对象,它代表模块自身,而exportsmodule的属性,因此exports 只能以 exports.a = 1; exports.b = 2; 不能以 exports = { key: value } 的形式导出,通过 module.exports 则两种方式都可以。导出的同名属性或函数,后面的覆盖前面。

// math.js
exports.add = function() {
  var sum = 0,
    i = 0,
    args = arguments,
    l = args.length;
  while (i < l) {
    sum += args[i++];
  }
  return sum;
};


// index.js
var math = require('./math')

var result = math.add(2, 3)
console.log(result) // 5

模块加载

缓存

与前端浏览器会缓存静态脚本文件以提高性能一样,Node对引入过的模块都会进行缓存,以减少二次引入时的开销。不通的地方在于,浏览器仅仅就缓存文件,而Node缓存的是编译和执行后的对象。

不论是核心模块还是文件模块, require() 方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级的。不同之处在于核心模块的检查先于文件模块的缓存检查。

路径分析

require() 接收一个标识符作为参数,在Node实现中,正是基于这也一个标识符进行模块查找,主要分为以下几类:

  1. httppathfile 这种核心模块。核心模块的优先级仅次于缓存加载,它在Node的源代码编译过程中已经编译为二进制代码,其加载速度最快。
  2. ... 或者 / 开始的路径文件模块。首次引入后将编译执行后的结果存放到缓存中。加载速度次于核心模块。
  3. 非路径形式的自定义文件模块。通过模块路径查找,这种方式是最耗时的。

    模块路径是Node在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组,与JavaScript的原型链或作用域链的查找方式十分相似。生成规则如下:

    1. 当前目录下的 node_modules 目录
    2. 父目录下的 node_modules 目录
    3. 父目录的父目录下的 node_modules 目录
    4. 逐级递归,直到根目录下的 node_modules 目录

文件定位

在第一次引入文件模块的时候有下面两个细节需要注意:

  • 文件扩展名分析
    当没有指定扩展名,Node 按照 .js.json.node 的次序尝试补足扩展名。在尝试过程中,需要调用fs模块同步阻塞式的判断文件是否存在。因为Node是单线程的,所以这里是一个会引起性能问题的地方。所以除了.js文件,都最好是加上文件扩展名。
  • 目录分析和包
    读取package.json文件,通过main属性指定的文件进行定位(如果文件名缺少扩展名,将会进入扩展名分析的步骤),如果main属性指定的文件名错误,或者没有package.json文件,将会依次查找index.jsindex.jsonindex.node

编译执行

编译执行是引入文件模块的最后一个阶段。根据上面的步骤定位到具体文件后,Node会新建一个模块对象,然后根据路径载入并编译(对于不同的文件扩展名,其载入的方法也有所不同)。每一个编译成功的模块,都会将其文件路径作为索引(将编译执行后的结果作为值)缓存在Module._cache对象上,以提高二次引入的性能。

  • .js文件通过fs模块同步读取文件后编译执行。
  • .json文件通过fs模块同步读取后,用JSON.parse()解析返回结果。
  • .node文件是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。

参考

深入浅出Node.js


achuan9
37 声望3 粉丝

lalala~