什么是nodejs
node是基于chrome的V8引擎,提供JavaScript能够在服务端运行的环境及能力。node保留了JavaScript在浏览器中众多熟悉的接口,同时保持了JavaScript语言本身的单线程、异步I/O,事件循环及回调机制等特性。
为什么是异步I/O
JavaScript的执行机制
同步任务:
JavaScript是一门单线程语言,这就意味着JavaScript代码在JS引擎中解释和执行只能是同步顺序执行的,称之为主线程,这就好比机场只有一个安检通道,而恰好节假日出行旅客比较多,那就避免不了排队,要是碰到某个旅客行李比较多,就得花上更多的等待时间。在浏览器中,JavaScript和UI又是共用一个线程,那么JavaScript的长时间执行会阻塞后续的代码执行以及UI的渲染及响应中断,这样给用户的体验是极其糟糕的。而在Node中,JavaScript长时间占用CPU也将导致后续异步I/O的调用以及已完成的异步I/O的回调执行,影响执行效率。
异步任务:
前面说到JavaScript单线程同步执行的会造成阻塞,而异步操作恰好能够解决同步执行所带来的阻塞问题。除了js主线程,还存在其他的工作线程,以及任务队列(task queue),主线程每执行异步请求时,会将异步调用加入线程池处理,主线程并不会等待异步请求结果,而继续向下执行。在异步请求有结果时,会将异步调用的后续回调放入任务队列,主线程在执行完所有同步任务时,会读取任务队列中的回调,进入主线程执行,如此循环往复,也就是事件循环(Event Loop)。
Node的异步I/O
Node实现异步I/O涉及到事件循环、I/O观察者、请求对象和I/O线程池等因素。
事件循环:
在进程启动时,node会创建一个主循环,用于询问是否有待处理事件以及处理每一个异步事件后续的回调。每执行一次循环体的过程称为Tick。如果有待处理事件及相关回调,则取出事件并执行对应回调。如下图是事件循环模型。

观察者:
前面提到的事件循环中正是通过观察者判断是否有事件需要处理。这个过程就如同饭店的厨房,厨房一轮一轮地烹制菜肴,具体制作取决于收银员收到的客人的下单。在做完一轮菜肴,就询问是否还有需要烹制的菜。在此过程中,收银员就是对应的观察者,收到的客人点单就是对应的回调函数。每个事件循环可以有一个或多个观察者,每个观察者可能对应多个事件。
请求对象:
在调用异步I/O后,到I/O操作完成并执行对应回调的过程中,存在一个中间产物——请求对象。它是一个状态集,保存了当前异步I/O的所有状态。以fs.readFile()为例,该方法调用Node的核心模块,进而调用C++内建模块执行对应操作。在这个过程中,node底层会将传入的参数、执行的方法以及回调函数封装成一个FSReqWrap请求对象,将回调函数赋给oncomplete_sym属性上,并将其放入线程池等待执行。而此时JavaScript线程会继续执行后续操作。
执行回调:
当线程池中I/O操作完成,会将执行结果赋给result属性,同时会将该请求对象加入到I/O观察者队列中。在事件循环过程中,询问I/O观察者是否有事件待处理,如果存在,则取出请求对象中的result作为结果参数,oncomplete_sym属性作为回调执行。

小结
上述无论是浏览器上JavaScript与UI共用线程带来的UI渲染及响应阻塞所造成的用户体验差,还是在node中单线程同步编程上I/O造成CPU的资源利用率低,使得异步I/O在node中是至关重要的。值得注意的是,node所谓的单线程,仅仅是JavaScript执行是单线程的,node本身是多线程的。node通过事件驱动的方式触发事件调用,并将该事件分配给相应工作线程处理,无需等待操作完成,继续监听后续事件请求,从而不会带来阻塞,而通过事件循环不停的访问事件队列,并执行相应回调。
异步编程
前文描述了Node通过事件循环实现异步突破了单线程执行同步任务造成阻塞性能瓶颈,在性能上带来了显著的提升。有了异步,则必有异步编程。而异步编程往往避免不了回调函数和层级嵌套。
异步编程的优劣
过去提升单线程同步I/O的性能的方式多用多线程甚至是多进程,但是调度多线程上下文的切换开销、以及编程中涉及到的锁、同步等复杂业务逻辑造成了较大的开发和调试成本。而Node基于事件驱动的非阻塞I/O模型使得CPU和I/O互不依赖,充分利用资源。然而凡是都有两面性,异步编程带来高性能的同时,也带来一些难点。
异步编程带来的难点:
- 层级嵌套过深,造成回调地狱
- 异常处理
- 异步转同步
异步编程解决方案
- 事件发布/订阅模式
- Promise/Deferred模式
- 流程控制库
事件发布/订阅模式
说起异步编程,离不开回调机制,而最简单的回调示例如下
function fn(callback) {
setTimeout(function() {
// fn中的todo
callback();
}, 1000);
}
function defer() {
// 延迟执行
// todo
}
fn(defer);
在浏览器中,给Dom结构的绑定点击事件监听就是事件发布/订阅模式的一种,是回调函数的事件化。只有触发点击,事件才会被执行,正是实现了异步编程达到一定条件才执行的目的。
<body>
<button class="btn">点击</button>
<script>
let oBtn = document.getElementsByClassName("btn")[0];
oBtn.addEventListener("click", click);
function click() {
console.log("点击事件");
}
</script>
</body>
在Node自身提供的events模块是发布/订阅模式的一个简单实现。它由addListener/on()、once()、removeListener()、removeAllListeners()、emit()等基本事件监听模式的方法实现。代码如下:
var events = require("events");
var emitter = new events.EventEmitter();
emitter.on("func", function(val) {
console.log(val);
});
emitter.emit("func", "something");
从上面看来,事件发布/订阅模式好像并不涉及同步、异步调用的问题,但在Node中,多数API是异步调用,而emit调用也多数是伴随事件循环而异步触发的。随着业务的增长,多个异步场景情况下,我们依旧可以用发布订阅模式完成。
现有如下场景,小明、小红、小李三个人随堂练习册落家里了,需要回家取,老师需要等待三人都取回课本之后才开始随堂练习。三人取回课本的顺序并无关联且无法保证。下面将三人取练习册抽象为文件读取,随堂练习为最终的回调,代码如下
下面为回调函数实现的多异步协作
var fs = require('fs')
var count = 0
var results = {}
function cb(results) {
// 开始随堂练习
console.log(results)
}
function done(key,value){
results[key] = value
count++;
if(count === 3){
cb(results);
}
}
fs.readFile('./data/小明.txt','utf-8',function(err,data){
//小明取回练习册
done('小明',data)
})
fs.readFile('./data/小红.txt','utf-8',function(err,data){
//小红取回练习册
done('小红',data)
})
fs.readFile('./data/小李.txt','utf-8',function(err,data){
//小李取回练习册
done('小李',data)
})
第二天又出现同样的情况,但是老师觉得昨天太浪费时间,所以今天只要有任意两个人取回练习册就开始随堂练习。在这样的情况下,需要重写done方法里面的回调调用,随着业务繁杂,done函数的耦合度会增加,扩展性受限。
下面为事件发布/订阅以及偏函数实现事件和监听器的多对多
var fs = require('fs');
var events = require("events");
var emitter = new events.EventEmitter();
function cb(results) {
// 开始随堂练习
console.log(results);
}
function after(times,callback){
var count = 0,
results = {};
return function(key,value){
results[key] = value;
count++;
if(count === times){
callback(results)
}
}
}
var done1 = after(3,cb); //三人均取回则执行最终回调
var done2 = after(2,cb); //任意两人取回则执行最终回调
emitter.on('done',done1); //第一天
emitter.on('done',done2); //第二天
fs.readFile('./data/小明.txt','utf-8',function(err,data){
//小明取回练习册
emitter.emit('done','小明',data)
})
fs.readFile('./data/小红.txt','utf-8',function(err,data){
//小红取回练习册
emitter.emit('done','小红',data)
})
fs.readFile('./data/小李.txt','utf-8',function(err,data){
//小李取回练习册
emitter.emit('done','小李',data)
})
Promise/Deferred模式
使用事件发布/订阅模式,需要提前设定目标事件。Promise/Deferred模式能够先执行异步调用,延迟传递回调。
Deferred对象是JQuery中回调函数的解决方案,传统ajax调用如下:
$.get('./api/getUserInfo',{
success:onSuccess,
error:onError,
complete:onComplete
})
jQuery 1.5版本后,重写了ajax部分,返回结果是一个Deferred对象,进而可以链式调用,并对事件加入多个处理事件,示例如下:
$.get('./api/getUserInfo')
.success(onSuccess)
.error(onError)
.complete(onComplete);
接下来我们通过继承Node的events模块来做ajax链式调用的一个简单实现。
var util = require("util");
var events = require("events");
var promise = function() {
events.EventEmitter.call(this);
};
util.inherits(promise, events.EventEmitter);
//promise继承events模块
promise.prototype.success = function(fn) {
if (typeof fn === "function") {
this.on("success", function(data) {
fn(data);
});
}
return this;
};
promise.prototype.error = function(fn) {
if (typeof fn === "function") {
this.on("error", function(err) {
fn(err);
});
}
return this
};
var Deferred = function() {
this.promise = new promise();
};
Deferred.prototype.success = function(data) {
this.promise.emit("success", data);
};
Deferred.prototype.error = function(err) {
this.promise.emit("error", err);
};
function myAjax(path) {
var deferred = new Deferred();
//假设这里$.get是JQuery1.5版本之前的普通调用
$.get(path, {
success: function() {
//some others
deferred.error();//通过deferred的success事件触发promise的事件
},
error: function() {
//some others
deferred.success();//通过deferred的success事件触发promise的事件
}
});
return deferred.promise;//返回promise对象,而promise对象的方法仍然返回对象本身,实现链式调用
}
function onSuccess() {
//something
}
function onError() {
//something
}
myAjax("./api")
.success(onSuccess)
.error(onError);
上述示例只是对链式调用原理的一个实现理解,实际实现复杂的多。当遇到多异步相互关联调用时,链式调用可以以一种同步化的方式编写,给开发带来理想的编程体验,代码风格相对优雅。
前文提到的无论是事件发布/订阅能解决单个异步调用或者多个无关联异步调用的问题,而在现实开发中,存在着多个异步调用相互依赖,某个异步调用的执行条件依赖前一个或多个异步调用的返回结果。
存在这样一个场景,有A、B、C三个文件,A中存着B文件的地址,B中存着C文件的地址,而C文件中存有我需要的数据,但是我只知道A文件的地址,对B、C一概不知。
最简单的实现就是通过回调机制,代码如下:
var fs = require('fs')
var url_A = './a.txt';
function handlerData(data){
//处理数据
}
fs.readFile(url_A,'utf-8',function(url_B){
fs.readFile(url_B,'utf-8',function(url_C){
fs.readFile(url_C,'utf-8',function(data){
handlerData(data)
}
}
})
由于每个异步调用都层层依赖,需要依次执行,所以必须通过回调嵌套,当嵌套层级过多,代码的可读性就非常糟糕,影响编程体验,也就是造成所谓的'回调地狱'。而在实际中,需要对异步操作进行封装,通过普通函数封装则是如下所示:
var fs = require('fs')
var url_A = './a.txt';
function handlerData(data){
//处理数据
}
var readA = function(url_A){
fs.readFile(url_A,'utf-8',readB);
}
var readB = function(url){
fs.readFile(url,'utf-8',readC);
}
var readC =function(url){
fs.readFile(url,'utf-8',handlerData);
}
如果使用事件发布/订阅,则会是下面这个场景:
var fs = require('fs');
var events = require("events");
var emitter = new events.EventEmitter();
var url_A = './a.txt';
handlerData(data){
//处理数据
}
emitter.on('readA',function(url){
fs.readFile(url,'utf-8',function(data){
emitter.emit('readB',data)
});
})
emitter.on('readB',function(url){
fs.readFile(url,'utf-8',function(data){
emitter.emit('readC',data)
});
})
emitter.on('readC',function(url){
fs.readFile(url_A,'utf-8',function(data){
handlerData(data);
});
})
emitter.emit('readA',url_A);
相比普通回调的封装写法,事件发布的方式代码量明显增加了,这显然不是我们想要的效果。到这里无论是普通函数还是事件发布,在处理相关联异步调用时,都避免不了回调嵌套,都得将回调预先设定,并提前传入异步调用中。说到这,你是否想到之前ajax的链式调用了呢?
实际开发中,绝大多数的异步调用的状态无非只有三种,unfulfilled(未完成)、resolve(完成)、reject(失败),而在状态更改之后通过then接收各个状态的回调。状态的更改如下所示:

基于之前模拟ajax的链式调用修改后的Promise/Deferred模型代码如下:
var fs = require('fs');
var promise = function(){
this.queue = [];
}
promise.prototype.then = function(res,rej){
var handler = {};
if(typeof res === 'function'){
handler.success = res
}
if(typeof rej === 'function'){
handler.error = rej
}
this.queue.push(handler);
return this;
}
var Deferred = function(){
this.promise = new promise();
}
Deferred.prototype.resolve = function(data){
var handler;
while((handler = this.promise.queue.shift())){
if(handler && handler.success){
var result = handler.success(data);
if(result && result instanceof promise){
result.queue = this.promise.queue;
this.promise = result;
return
}
}
}
}
Deferred.prototype.reject = function(){
var handler;
while((handler = this.promise.queue.shift())){
if(handler && handler.error){
var result = handler.error(data);
if(result && result instanceof promise){
result.queue = this.promise.queue;
this.promise = result;
return
}
}
}
}
var url_A = './a.txt';
function handlerData(data){
//处理数据
}
var read = function(url){
var deferred = new Deferred();
fs.readFile(url,'utf-8',function(err,data){
if(err){
return deferred.reject(err)
}
deferred.resolve(data)
})
return deferred.promise;
}
read(url_A)
.then(function(url_B){
return read(url_B)
})
.then(function(url_C){
return read(url_C)
})
.then(function(data_C){
handlerData(data_C)
})
上述示例主要用链式调用改写关联异步调用带来的回调嵌套问题,以及对Promise/Deferred模式实现链式调用的原理的研究。值得注意的是在上述模式中,then中的回调仍然是同步的,而实际开发中,我们希望then中回调也是一个异步的。
在ES6中的Promise对象给异步提供了统一的接口,每一个异步调用返回一个Promise对象,通过该对象的then属性添加回调,实现和上述示例一样的链式调用,其内部实现原理类似。对于Promise的实现原理请移步(待定)
var fs = require('fs')
var readFile = function(url){
return new Promise(function(res,rej){
fs.readFile(url,'utf-8',function(err,data){
if(err){
return rej(err)
}
res(data)
})
})
}
readFile(url_A)
.then(function(url_B){
return readFile(url_B);
})
.then(function(url_C){
return readFile(url_C);
})
.then(function(data){
handlerData(data);
})
流程控制库
尾触发/next
除了事件和promise,有一类方法叫尾触发,其关键就在于next函数。而尾触发应用最多的就是express。next函数的作用就是将执行权传递给下一个中间件,确保左右注册的中间件被一个接一个地执行。面对网络请求时,中间件就像一连串的过滤层,每个中间件处理请求对象,通过next()将请求对象传递给下一个中间件,这样所有的中间件形成了一个处理队列。
下面是express的一个node服务
var app = express();
app.use(function(req,res){
//some todo
})
app.use('./api',function(req,res){
//some todo
})
http.createServer(app).listen(3000, function(){
console.log('Express server listening on 3000');
})
很显然,上述的express()会返回一个处理函数,而将处理函数作为3000端口网络请求的入口函数。其实这个入口函数,说到底也是一个中间件,所以它也将是如下形式:
function(req,res){
//some todo
}
同时app中存在一个存放中间件的数组stack,请求会依次经过这些中间件处理,基于上述特点,我做一个简单实现,其代码如下:
function express(){
var stack = [];
var entry = function(req,res){
// some todo
}
//这里给函数赋予其他方法
entry.use = function(route,fn){
stack.push({
route,
handler:fn
})
return this
}
//实例化时接受中间件以参数形式传入,优先放入队列
for(var i = 0;i < arguments.length; i++){
entry.use(arguments[i])
}
return entry;
}
上述代码通过use方法注册中间件,而让中间件依次执行的关键就在于next函数,next函数的具体实现如下:
function express(){
var stack = [];
var entry = function(req,res){
var count = 0; //通过闭包,将count作为计数器在next中传递
var next = function(){
while(count < stack.length){
var task = stack[count++];
// match ===> task.route 做一些路由相关的匹配
task.handler(req,res,next) //将next函数作为参数传递给下一个中间件
}
}
// some todo
next()
}
return entry;
}
以上主要解释了express通过尾触发的机制实现对中间件的依次调用的原理,但是尾触发模式并不能通过并行多个异步提升业务效率,本质上仍旧是同步化的执行,只是对业务流程的拆解为简介、单一的处理模块。想要实现并行的效果,仍需要配合事件或者promise完成。
async流程控制模块
async模块提供了多种方法用于处理异步的各种协作模式。
- 异步的串行执行
async提供了series()方法实现多异步的串行执行,我们依旧用读取两个文件为例:
const async =require('async');
var fs = require('fs');
async.series([
function(callback){
fs.readFile('./a.txt','utf-8',callback);
},
function(callback){
fs.readFile('./b.txt','utf-8',callback);
}
],function(err,result){
//result ===> a.txt 和 b.txt
})
- 异步的并行执行
在绝大多数情况,我们希望通过异步调用的并行来提升性能,async提供了parallel()方法实现多异步的并行执行,该方法实现的效果和promise的all方法类似,我们依旧用小明、小红、小李取练习册为例:
var fs = require('fs');
const async =require('async');
async.parallel([
function(callback){
fs.readFile('./小明.txt','utf-8',callback);
},
function(callback){
fs.readFile('./小红.txt','utf-8',callback);
},
function(callback){
fs.readFile('./小李.txt','utf-8',callback);
},
],function(err,result){
//result ===> ./小明.txt 和 ./小红.txt 和 ./小李.txt
})
- 异步调用的依赖处理
当前一个调用的结果是后一个的输入时,这种情况往往会引起回调嵌套,在实际开发中也遇到的最多,async提供了waterfall()方法实现多异步的并行执行,我们A、B、C文件依赖读取为例:
var fs = require('fs');
const async =require('async');
async.waterfall([
function(callback){
fs.readFile('./A.txt','utf-8',function(err,data){
callback(err,data)
});
},
function(url_B,callback){
//url_B ===> 上一个异步的结果
fs.readFile(url_B,'utf-8',function(err,data){
callback(err,data)
});
},
function(url_C,callback){
fs.readFile(url_C,'utf-8',function(err,data){
callback(err,data)
});
},
],function(err,result){
//some todo
})
上述三种处理异步协作的方式传入的回调callback由模块通过高阶函数的方式注入的,回调执行时会将结果保存起来,在最终的回调中以数组形式传入。这里对异常的处理,只要有一个出现异常,则将异常作为第一个参数返回给最终回调。除了以上几种流程控制库,还有很多成熟的流程控制库,在这不做过多介绍。
小结
以上主要介绍了几种异步编程的解决方案,尽管异步编程本质上避免不了回调机制,但是通过事件、Promise/Deferered模式或者流程控制库均能在一定程度上以一种同步的编程方式解决回调嵌套的问题,在保持异步的性能优势的同时,提升编程体验。