1. Two core difficulties in JavaScript asynchronous programming
Asynchronous I/O and event-driven enable single-threaded JavaScript to perform network and file access functions without blocking the UI, and achieve higher performance on the back end. However, the asynchronous style also causes some troubles. The core problem is:
1. Functions are nested too deeply
JavaScript asynchronous calls are based on callback functions. When multiple asynchronous transactions have multi-level dependencies, the callback functions will form multi-level nesting, and the code becomes
Pyramid structure. This not only makes the code ugly and difficult to understand, but also makes the process of debugging and reconstruction full of risks.
2. Exception handling
Nested callbacks not only clutter the code, but also make error handling more complex. Here we mainly talk about exception handling.
2. Exception handling
Like many modern languages, JavaScript allows exceptions to be thrown and subsequently caught using a try/catch block. If the thrown exception is uncaught, most JavaScript environments provide a useful stack trace. For example, the following code throws an exception because '{' is an invalid JSON object.
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)
The stack trace not only tells us where the error was thrown, but also where it originally went wrong: line 4 of code. Unfortunately, tracing the origin of an asynchronous error from the top down is not always straightforward.
There are two situations in which errors may be thrown in asynchronous programming: callback function errors and asynchronous function errors.
1. Callback function error
What happens if an error is thrown from an asynchronous callback? Let's do a test first.
setTimeout(function A() { setTimeout(function B() { setTimeout(function C() { throw new Error('Something terrible has happened!'); }, 0); }, 0); }, 0);
The result of the above application is an extremely brief stack trace.
Error: Something terrible has happened! at Timer.C (/AsyncJS/nestedErrors.js:4:13)
Wait, what happened to A and B? Why don't they appear in the stack trace? This is because when C is run, the context of the asynchronous function no longer exists, and A and B are not in the memory stack. These three functions are all run directly from the event queue. For the same reason, errors thrown from asynchronous callbacks cannot be caught using try/catch blocks. In addition, the return in the callback function also loses its meaning.
try { setTimeout(function() { throw new Error('Catch me if you can!'); }, 0); } catch (e) { console.error(e); }
See the question here? The try/catch block here only captures errors that occur within the setTimeout function itself. Because setTimeout runs its callback asynchronously, errors thrown by the callback flow directly to the application even if the delay is set to 0.
In general, even if a function that uses an asynchronous callback is wrapped in a try/catch statement block, it is useless. (A special case is that the asynchronous function is really doing something synchronously and is error-prone. For example, Node's fs.watch(file,callback) is such a function, which will throw an error when the target file does not exist.) Because of this, callbacks in Node.js almost always accept an error as their first argument, allowing the callback itself to decide how to handle the error.
2. Asynchronous function error
Since asynchronous functions return immediately, errors that occur in asynchronous transactions cannot be caught through try-catch and can only be solved by the caller providing error handling callbacks.
For example, the common function (err, ...) {...} callback function in Node is the convention for handling errors in Node: the error is returned as the first actual parameter of the callback function. Another example is the onerror function of the FileReader object in HTML5, which will be used to handle errors during asynchronous reading of files.
For example, the Node application below attempts to read a file asynchronously and is also responsible for logging any errors (such as "File does not exist").
var fs = require('fs'); fs.readFile('fhgwgdz.txt', function(err, data) { if (err) { return console.error(err); }; console.log(data.toString('utf8')); });
Client-side JavaScript libraries are a little less consistent, but the most common pattern is to specify a separate callback for success and failure. jQuery's Ajax methods follow this pattern.
$.get('/data', { success: successHandler, failure: failureHandler });
No matter what the API looks like, always remember that asynchronous errors originating from callbacks can only be handled inside the callback.
3. Handling of uncaught exceptions
If an exception is thrown from a callback, the person who called the callback is responsible for catching the exception. But what happens if the exception is never caught? At this time, different JavaScript environments have different game rules...
1. In the browser environment
Modern browsers will display those uncaught exceptions in the developer console and then return to the event queue. To modify this behavior, attach a handler to window.onerror. If the windows.onerror handler returns true, the browser's default error handling behavior can be prevented.
window.onerror = function(err) { return true; //彻底忽略所有错误 };
在成品应用中, 会考虑某种JavaScript 错误处理服务, 譬如Errorception。Errorception 提供了一个现成的windows.onerror 处理器,它向应用服务器报告所有未捕获的异常,接着应用服务器发送消息通知我们。
2. 在Node.js 环境中
在Node 环境中,window.onerror 的类似物就是process 对象的uncaughtException 事件。正常情况下,Node 应用会因未捕获的异常而立即退出。但只要至少还有一个uncaughtException 事件处理
器,Node 应用就会直接返回事件队列。
process.on('uncaughtException', function(err) { console.error(err); //避免了关停的命运! });
但是,自Node 0.8.4 起,uncaughtException 事件就被废弃了。据其文档所言,对异常处理而言,uncaughtException 是一种非常粗暴的机制,请勿使用uncaughtException,而应使用Domain 对象。
Domain 对象又是什么?你可能会这样问。Domain 对象是事件化对象,它将throw 转化为'error'事件。下面是一个例子。
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、Async.js
首先是Node中非常著名的Async.js,这个库能够在Node中展露头角,恐怕也得归功于Node统一的错误处理约定。
而在前端,一开始并没有形成这么统一的约定,因此使用Async.js的话可能需要对现有的库进行封装。
Async.js的其实就是给回调函数的几种常见使用模式加了一层包装。比如我们需要三个前后依赖的异步操作,采用纯回调函数写法如下:
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); });
可以看到,回调函数由原来的横向发展转变为纵向发展,同时错误被统一传递到最后的处理函数中。
其原理是,将函数数组中的后一个函数包装后作为前一个函数的末参数cb传入,同时要求:
每一个函数都应当执行其cb参数;cb的第一个参数用来传递错误。我们可以自己写一个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,依赖于“回调函数的第一个参数用来传递错误”这样的一个约定。
2、Promise方案
ES6的Promise来源于Promise/A+。使用Promise来进行异步流程控制,有几个需要注意的问题,
把前面提到的功能用Promise来实现,需要先包装异步函数,使之能返回一个Promise:
function toPromiseStyle(fn) { return (...args) => { return new Promise((resolve, reject) => { fn(...args, (err, result) => { if (err) reject(err); resolve(result); }) }); }; }
这个函数可以把符合下述规则的异步函数转换为返回Promise的函数:
回调函数的第一个参数用于传递错误,第二个参数用于传递正常的结果。接着就可以进行操作了:
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,都会为下一个then的onFullfilled函数(以及onRejected)的参数设定好值。
如此一来,return、try-catch/throw都可以使用了,但catch是以方法的形式出现,还是不尽如人意。
3、Generator方案
ES6引入的Generator可以理解为可在运行中转移控制权给其他代码,并在需要的时候返回继续执行的函数。利用Generator可以实现协程的功能。
将Generator与Promise结合,可以进一步将异步代码转化为同步风格:
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); } }
然而我们还需要一个可以自动运行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方案的一个官方认可,将之作为语言内置功能。
async function的缺点:
await只能在async function内部使用,因此一旦你写了几个async function,或者使用了依赖于async function的库,那你很可能会需要更多的async function。
目前处于提案阶段的async function还没有得到任何浏览器或Node.JS/io.js的支持。Babel转码器也需要打开实验选项,并且对于不支持Generator的浏览器来说,还需要引进一层厚厚的regenerator runtime,想在前端生产环境得到应用还需要时间。
以上就是本文的全部内容,希望对大家的学习有所帮助。