1. JavaScript 非同期プログラミングにおける 2 つの主要な問題
非同期 I/O とイベント ドリブンにより、シングルスレッド JavaScript が UI をブロックすることなくネットワークおよびファイル アクセス機能を実行できるようになり、バックエンドでより高いパフォーマンスを実現できます。ただし、非同期スタイルはいくつかの問題も引き起こします。主な問題は次のとおりです。
1. 関数のネストが深すぎます
JavaScript の非同期呼び出しはコールバック関数に基づいています。複数の非同期トランザクションにマルチレベルの依存関係がある場合、コールバック関数はマルチレベルのネストを形成し、コードは
になります。
ピラミッド構造。これにより、コードが見苦しく理解しにくくなるだけでなく、デバッグと再構築のプロセスが危険に満ちたものになります。
2. 例外処理
ネストされたコールバックはコードを乱雑にするだけでなく、エラー処理をより複雑にします。ここでは主に例外処理について説明します。
2. 例外処理
多くの現代言語と同様、JavaScript では例外をスローし、その後 try/catch ブロックを使用してキャッチすることができます。スローされた例外がキャッチされなかった場合、ほとんどの JavaScript 環境では有用なスタック トレースが提供されます。たとえば、次のコードは、「{」が無効な 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 行目) も示します。残念ながら、非同期エラーの原因を上から下に追跡することは必ずしも簡単ではありません。
非同期プログラミングでは、コールバック関数エラーと非同期関数エラーという 2 つの状況でエラーがスローされる可能性があります。
1. コールバック関数エラー
非同期コールバックからエラーがスローされた場合はどうなりますか?まずはテストをしてみましょう。
setTimeout(function A() { setTimeout(function B() { setTimeout(function C() { throw new Error('Something terrible has happened!'); }, 0); }, 0); }, 0);
上記のアプリケーションの結果は、非常に短いスタック トレースです。
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) はそのような関数であり、ターゲット ファイルが存在しない場合にエラーをスローします。 ) このため、Node.js のコールバックはほとんどの場合、最初の引数としてエラーを受け入れ、コールバック自体がエラーの処理方法を決定できるようになります。
2. 非同期関数エラー
非同期関数はすぐに戻るため、非同期トランザクションで発生するエラーは try-catch では捕捉できず、呼び出し元がエラー処理コールバックを提供することによってのみ解決できます。
たとえば、Node の共通関数 (err, ...) {...} コールバック関数は、Node でエラーを処理するための規則です。エラーはコールバック関数の最初の実パラメータとして返されます。もう 1 つの例は、HTML5 の FileReader オブジェクトの onerror 関数です。これは、ファイルの非同期読み取り中のエラーを処理するために使用されます。
たとえば、以下の Node アプリケーションはファイルを非同期で読み取ろうとし、エラー (「ファイルが存在しない」など) をログに記録する責任もあります。
var fs = require('fs'); fs.readFile('fhgwgdz.txt', function(err, data) { if (err) { return console.error(err); }; console.log(data.toString('utf8')); });
クライアント側の JavaScript ライブラリは一貫性が少し劣りますが、最も一般的なパターンは、成功と失敗に個別のコールバックを指定することです。 jQuery の Ajax メソッドはこのパターンに従います。
$.get('/data', { success: successHandler, failure: failureHandler });
API がどのようなものであっても、コールバックから発生する非同期エラーはコールバック内でのみ処理できることを常に覚えておいてください。
3. キャッチされなかった例外の処理
コールバックから例外がスローされた場合、コールバックを呼び出した人が例外をキャッチする責任があります。しかし、例外がまったくキャッチされなかった場合はどうなるでしょうか?現時点では、JavaScript 環境ごとにゲーム ルールも異なります...
1. ブラウザ環境の場合
最新のブラウザは、これらのキャッチされなかった例外を開発者コンソールに表示し、イベント キューに戻ります。この動作を変更するには、ハンドラーを window.onerror にアタッチします。 windows.onerror ハンドラーが true を返す場合、ブラウザーのデフォルトのエラー処理動作を防ぐことができます。
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,想在前端生产环境得到应用还需要时间。
以上就是本文的全部内容,希望对大家的学习有所帮助。