异步I/O、事件驱动使得单线程的JavaScript得以在不阻塞UI的情况下履行网络、文件访问功能,且使之在后端实现了较高的性能。但是异步风格也引来了1些麻烦,其中比较核心的问题是:
1、函数嵌套过深
JavaScript的异步调用基于回调函数,当多个异步事务多级依赖时,回调函数会构成多级的嵌套,代码变成
金字塔型结构。这不但使得代码变难看难懂,更使得调试、重构的进程充满风险。
2、异常处理
回调嵌套不单单是使代码变得杂乱,也使得毛病处理更复杂。这里主要讲讲异常处理。
像很多时兴的语言1样,JavaScript 也允许抛出异常,随后再用1个try/catch 语句块捕获。如果抛出的异常未被捕获,大多数JavaScript环境都会提供1个有用的堆栈轨迹。举个例子,下面这段代码由于'{'
为无效JSON 对象而抛出异常。
function JSONToObject(jsonStr) {
return JSON.parse(jsonStr);
}
var obj = JSONToObject('{');
//SyntaxError: Unexpected end of input
//at Object.parse (native)
//at JSONToObject (/AsyncJS/stackTrace.js:2:15)
//at Object.<anonymous> (/AsyncJS/stackTrace.js:4:11)
堆栈轨迹不但告知我们哪里抛出了毛病,而且说明了最初出错的地方:第4 行代码。遗憾的是,自顶向下地跟踪异步毛病起源其实不都这么直接了当。
异步编程中可能抛出毛病的情况有两种:回调函数毛病、异步函数毛病。
1、回调函数毛病
如果从异步回调中抛出毛病,会产生甚么事?让我们先来做个测试。
setTimeout(function A() {
setTimeout(function B() {
setTimeout(function C() {
throw new Error('Something terrible has happened!');
}, 0);
}, 0);
}, 0);
上述利用的结果是1条极为简短的堆栈轨迹。
Error: Something terrible has happened!
at Timer.C (/AsyncJS/nestedErrors.js:4:13)
等等,A 和B 产生了甚么事?为何它们没有出现在堆栈轨迹中?这是由于运行C 的时候,异步函数的上下文已不存在了,A 和B 其实不在内存堆栈里。这3 个函数都是从事件队列直接运行的。基于一样的理由,利用try/catch 语句块其实不能捕获从异步回调中抛出的毛病。另外回调函数中的return也失去了意义。
try {
setTimeout(function() {
throw new Error('Catch me if you can!');
}, 0);
} catch (e) {
console.error(e);
}
看到这里的问题了吗?这里的try/catch 语句块只捕获setTimeout函数本身内部产生的那些毛病。由于setTimeout 异步地运行其回调,所以即便延时设置为0,回调抛出的毛病也会直接流向利用程序。
总的来讲,取用异步回调的函数即便包装上try/catch 语句块,也只是无用之举。(特例是,该异步函数确切是在同步地做某些事且容易出错。例如,Node 的fs.watch(file,callback)
就是这样1个函数,它在目标文件不存在时会抛出1个毛病。)正由于此,Node.js 中的回调几近总是接受1个毛病作为其首个参数,这样就允许回调自己来决定如何处理这个毛病。
2、异步函数毛病
由于异步函数是立刻返回的,异步事务中产生的毛病是没法通过try-catch来捕捉的,只能采取由调用方提供毛病处理回调的方案来解决。
例如Node中常见的function (err, ...) {...}
回调函数,就是Node中处理毛病的约定:行将毛病作为回调函数的第1个实参返回。再比如HTML5中FileReader对象的onerror函数,会被用于处理异步读取文件进程中的毛病。
举个例子,下面这个Node 利用尝试异步地读取1个文件,还负责记录下任何毛病(如“文件不存在”)。
var fs = require('fs');
fs.readFile('fhgwgdz.txt', function(err, data) {
if (err) {
return console.error(err);
};
console.log(data.toString('utf8'));
});
客户端JavaScript 库的1致性要略微差些,不过最多见的模式是,针对成败这两种情形各规定1个单独的回调。jQuery 的Ajax 方法就遵守了这个模式。
$.get('/data', {
success: successHandler,
failure: failureHandler
});
不管API 形态像甚么,始终要记住的是,只能在回调内部处理源于回调的异步毛病。
如果是从回调中抛出异常的,则由那个调用了回调的人负责捕获该异常。但如果异常从未被捕获,又会怎样样?这时候,不同的JavaScript环境有着不同的游戏规则……
1. 在阅读器环境中
现代阅读器会在开发人员控制台显示那些未捕获的异常,接着返回事件队列。要想修改这类行动,可以给window.onerror
附加1个处理器。如果windows.onerror
处理器返回true,则能禁止阅读器的默许毛病处理行动。
window.onerror = function(err) {
return true; //完全疏忽所有毛病
};
在成品利用中, 会斟酌某种JavaScript 毛病处理服务, 比方Errorception。Errorception 提供了1个现成的windows.onerror 处理器,它向利用服务器报告所有未捕获的异常,接着利用服务器发送消息通知我们。
2. 在Node.js 环境中
在Node 环境中,window.onerror 的类似物就是process 对象的uncaughtException 事件。正常情况下,Node 利用会因未捕获的异常而立即退出。但只要最少还有1个uncaughtException 事件处理
器,Node 利用就会直接返回事件队列。
process.on('uncaughtException', function(err) {
console.error(err); //避免了关停的命运!
});
但是,自Node 0.8.4 起,uncaughtException 事件就被废弃了。据其文档所言,对异常处理而言,uncaughtException 是1种非常粗鲁的机制,请勿使用uncaughtException,而应使用Domain 对象。
Domain 对象又是甚么?你可能会这样问。Domain 对象是事件化对象,它将throw 转化为'error'
事件。下面是1个例子。
var myDomain = require('domain').create();
myDomain.run(function() {
setTimeout(function() {
throw new Error('Listen to me!')
}, 50);
});
myDomain.on('error', function(err) {
console.log('Error ignored!');
});
源于延时事件的throw 只是简单地触发了Domain 对象的毛病处理器。
Error ignored!
很奇妙,是否是?Domain 对象让throw 语句生动了很多。不管在阅读器端还是服务器端,全局的异常处理器都应被视作最后1根救命稻草。请仅在调试时才使用它。
下面对几种解决方案的讨论主要集中于上面提到的两个核心问题上,固然也会斟酌其他方面的因夙来评判其优缺点。
1、Async.js
首先是Node中非常著名的Async.js,这个库能够在Node中展露头角,恐怕也得归功于Node统1的毛病处理约定。
而在前端,1开始并没有构成这么统1的约定,因此使用Async.js的话可能需要对现有的库进行封装。
Async.js的其实就是给回调函数的几种常见使用模式加了1层包装。比如我们需要3个前后依赖的异步操作,采取纯回调函数写法以下:
asyncOpA(a, b, (err, result) => {
if (err) {
handleErrorA(err);
}
asyncOpB(c, result, (err, result) => {
if (err) {
handleErrorB(err);
}
asyncOpB(d, result, (err, result) => {
if (err) {
handlerErrorC(err);
}
finalOp(result);
});
});
});
如果我们采取async库来做:
async.waterfall([
(cb) => {
asyncOpA(a, b, (err, result) => {
cb(err, c, result);
});
},
(c, lastResult, cb) => {
asyncOpB(c, lastResult, (err, result) => {
cb(err, d, result);
})
},
(d, lastResult, cb) => {
asyncOpC(d, lastResult, (err, result) => {
cb(err, result);
});
}
], (err, finalResult) => {
if (err) {
handlerError(err);
}
finalOp(finalResult);
});
可以看到,回调函数由原来的横向发辗转变成纵向发展,同时毛病被统1传递到最后的处理函数中。
其原理是,将函数数组中的后1个函数包装后作为前1个函数的末参数cb传入,同时要求:
每个函数都应当履行其cb参数;cb的第1个参数用来传递毛病。我们可以自己写1个async.waterfall的实现:
let async = {
waterfall: (methods, finalCb = _emptyFunction) => {
if (!_isArray(methods)) {
return finalCb(new Error('First argument to waterfall must be an array of functions'));
}
if (!methods.length) {
return finalCb();
}
function wrap(n) {
if (n === methods.length) {
return finalCb;
}
return function (err, ...args) {
if (err) {
return finalCb(err);
}
methods[n](...args, wrap(n + 1));
}
}
wrap(0)(false);
}
};
Async.js还有series/parallel/whilst等多种流程控制方法,来实现常见的异步协作。
Async.js的问题:
在外在上仍然没有摆脱回调函数,只是将其从横向发展变成纵向,还是需要程序员熟练异步回调风格。
毛病处理上依然没有益用上try-catch和throw,依赖于“回调函数的第1个参数用来传递毛病”这样的1个约定。
2、Promise方案
ES6的Promise来源于Promise/A+。使用Promise来进行异步流程控制,有几个需要注意的问题,
把前面提到的功能用Promise来实现,需要先包装异步函数,使之能返回1个Promise:
function toPromiseStyle(fn) {
return (...args) => {
return new Promise((resolve, reject) => {
fn(...args, (err, result) => {
if (err) reject(err);
resolve(result);
})
});
};
}
这个函数可以把符合下述规则的异步函数转换为返回Promise的函数:
回调函数的第1个参数用于传递毛病,第2个参数用于传递正常的结果。接着就能够进行操作了:
let [opA, opB, opC] = [asyncOpA, asyncOpB, asyncOpC].map((fn) => toPromiseStyle(fn));
opA(a, b)
.then((res) => {
return opB(c, res);
})
.then((res) => {
return opC(d, res);
})
.then((res) => {
return finalOp(res);
})
.catch((err) => {
handleError(err);
});
通过Promise,原来明显的异步回调函数风格显得更像同步编程风格,我们只需要使用then方法将结果传递下去便可,同时return也有了相应的意义:
在每个then的onFullfilled函数(和onRejected)里的return,都会为下1个then的onFullfilled函数(和onRejected)的参数设定好值。
如此1来,return、try-catch/throw都可使用了,但catch是以方法的情势出现,还是不尽如人意。
3、Generator方案
ES6引入的Generator可以理解为可在运行中转移控制权给其他代码,并在需要的时候返回继续履行的函数。利用Generator可以实现协程的功能。
将Generator与Promise结合,可以进1步将异步代码转化为同步风格:
function* getResult() {
let res, a, b, c, d;
try {
res = yield opA(a, b);
res = yield opB(c, res);
res = yield opC(d);
return res;
} catch (err) {
return handleError(err);
}
}
但是我们还需要1个可以自动运行Generator的函数:
function spawn(genF, ...args) {
return new Promise((resolve, reject) => {
let gen = genF(...args);
function next(fn) {
try {
let r = fn();
if (r.done) {
resolve(r.value);
}
Promise.resolve(r.value)
.then((v) => {
next(() => {
return gen.next(v);
});
}).catch((err) => {
next(() => {
return gen.throw(err);
})
});
} catch (err) {
reject(err);
}
}
next(() => {
return gen.next(undefined);
});
});
}
用这个函数来调用Generator便可:
spawn(getResult)
.then((res) => {
finalOp(res);
})
.catch((err) => {
handleFinalOpError(err);
});
可见try-catch和return实际上已以其本来面貌回到了代码中,在代码情势上也已看不到异步风格的痕迹。
类似的功能有co/task.js等库实现。
4、ES7的async/await
ES7中将会引入async function和await关键字,利用这个功能,我们可以轻松写出同步风格的代码,
同时仍然可以利用原本的异步I/O机制。
采取async function,我们可以将之前的代码写成这样:
async function getResult() {
let res, a, b, c, d;
try {
res = await opA(a, b);
res = await opB(c, res);
res = await opC(d);
return res;
} catch (err) {
return handleError(err);
}
}
getResult();
和Generator & Promise方案看起来没有太大区分,只是关键字换了换。
实际上async function就是对Generator方案的1个官方认可,将之作为语言内置功能。
async function的缺点:
await只能在async function内部使用,因此1旦你写了几个async function,或使用了依赖于async function的库,那你极可能会需要更多的async function。
目前处于提案阶段的async function还没有得到任何阅读器或Node.JS/io.js的支持。Babel转码器也需要打开实验选项,并且对不支持Generator的阅读器来讲,还需要引进1层厚厚的regenerator runtime,想在前端生产环境得到利用还需要时间。
参考:
JavaScript异步编程解决方案笔记
《JavaScript异步编程》:有点僵硬不过应当准确的JS异步手册