1. Deux difficultés fondamentales dans la programmation asynchrone JavaScript
Les E/S asynchrones et basées sur les événements permettent à JavaScript monothread d'exécuter des fonctions d'accès au réseau et aux fichiers sans bloquer l'interface utilisateur et d'obtenir de meilleures performances sur le back-end. Cependant, le style asynchrone provoque également quelques problèmes. Le problème principal est :
.1. Les fonctions sont trop profondément imbriquées
Les appels asynchrones JavaScript sont basés sur des fonctions de rappel. Lorsque plusieurs transactions asynchrones ont des dépendances à plusieurs niveaux, les fonctions de rappel formeront une imbrication à plusieurs niveaux et le code deviendra
.
Structure pyramidale. Cela rend non seulement le code laid et difficile à comprendre, mais rend également le processus de débogage et de reconstruction plein de risques.
2. Gestion des exceptions
Les rappels imbriqués non seulement encombrent le code, mais rendent également la gestion des erreurs plus complexe. Ici, nous parlons principalement de gestion des exceptions.
2. Gestion des exceptions
Comme de nombreux langages modernes, JavaScript permet de lancer des exceptions puis de les intercepter à l'aide d'un bloc try/catch. Si l'exception levée n'est pas interceptée, la plupart des environnements JavaScript fournissent une trace de pile utile. Par exemple, le code suivant lève une exception car « { » est un objet JSON non valide.
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)
La trace de la pile nous indique non seulement où l'erreur a été générée, mais aussi où elle s'est mal passée à l'origine : la ligne 4 du code. Malheureusement, retracer l’origine d’une erreur asynchrone de haut en bas n’est pas toujours simple.
Il existe deux situations dans lesquelles des erreurs peuvent être générées dans la programmation asynchrone : les erreurs de fonction de rappel et les erreurs de fonction asynchrone.
1. Erreur de fonction de rappel
Que se passe-t-il si une erreur est générée par un rappel asynchrone ? Faisons d'abord un test.
setTimeout(function A() { setTimeout(function B() { setTimeout(function C() { throw new Error('Something terrible has happened!'); }, 0); }, 0); }, 0);
Le résultat de l'application ci-dessus est une trace de pile extrêmement brève.
Error: Something terrible has happened! at Timer.C (/AsyncJS/nestedErrors.js:4:13)
Attendez, qu'est-il arrivé à A et B ? Pourquoi n'apparaissent-ils pas dans la trace de la pile ? En effet, lorsque C est exécuté, le contexte de la fonction asynchrone n'existe plus et A et B ne sont pas dans la pile mémoire. Ces trois fonctions sont toutes exécutées directement à partir de la file d'attente des événements. Pour la même raison, les erreurs générées par les rappels asynchrones ne peuvent pas être détectées à l'aide de blocs try/catch. De plus, le retour dans la fonction de rappel perd également son sens.
try { setTimeout(function() { throw new Error('Catch me if you can!'); }, 0); } catch (e) { console.error(e); }
Vous voyez la question ici ? Le bloc try/catch capture ici uniquement les erreurs qui se produisent dans la fonction setTimeout elle-même. Étant donné que setTimeout exécute son rappel de manière asynchrone, les erreurs renvoyées par le rappel sont directement envoyées à l'application même si le délai est défini sur 0.
En général, même si une fonction qui utilise un rappel asynchrone est enveloppée dans un bloc d'instruction try/catch, elle est inutile. (Un cas particulier est que la fonction asynchrone fait vraiment quelque chose de manière synchrone et est sujette aux erreurs. Par exemple, fs.watch(file,callback) de Node est une telle fonction, qui générera une erreur lorsque le fichier cible n'existe pas. ) Pour cette raison, les rappels dans Node.js acceptent presque toujours une erreur comme premier argument, permettant au rappel lui-même de décider comment gérer l'erreur.
2. Erreur de fonction asynchrone
Étant donné que les fonctions asynchrones reviennent immédiatement, les erreurs qui se produisent dans les transactions asynchrones ne peuvent pas être détectées via try-catch et ne peuvent être résolues que par l'appelant fournissant des rappels de gestion des erreurs.
Par exemple, la fonction de rappel commune function (err, ...) {...} dans Node est la convention de gestion des erreurs dans Node : l'erreur est renvoyée comme premier paramètre réel de la fonction de rappel. Un autre exemple est la fonction onerror de l'objet FileReader en HTML5, qui sera utilisée pour gérer les erreurs lors de la lecture asynchrone des fichiers.
Par exemple, l'application Node ci-dessous tente de lire un fichier de manière asynchrone et est également responsable de la journalisation des erreurs (telles que « Le fichier n'existe pas »).
var fs = require('fs'); fs.readFile('fhgwgdz.txt', function(err, data) { if (err) { return console.error(err); }; console.log(data.toString('utf8')); });
Les bibliothèques JavaScript côté client sont un peu moins cohérentes, mais le modèle le plus courant consiste à spécifier un rappel distinct pour le succès et l'échec. Les méthodes Ajax de jQuery suivent ce modèle.
$.get('/data', { success: successHandler, failure: failureHandler });
Peu importe à quoi ressemble l'API, rappelez-vous toujours que les erreurs asynchrones provenant des rappels ne peuvent être gérées que dans le rappel.
3. Gestion des exceptions non interceptées
Si une exception est levée à partir d'un rappel, la personne qui a appelé le rappel est responsable de l'interception de l'exception. Mais que se passe-t-il si l’exception n’est jamais détectée ? À l'heure actuelle, différents environnements JavaScript ont des règles de jeu différentes...
1. Dans l'environnement du navigateur
Les navigateurs modernes afficheront ces exceptions non détectées dans la console du développeur, puis reviendront à la file d'attente des événements. Pour modifier ce comportement, attachez un gestionnaire à window.onerror. Si le gestionnaire windows.onerror renvoie true, le comportement de gestion des erreurs par défaut du navigateur peut être empêché.
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,想在前端生产环境得到应用还需要时间。
以上就是本文的全部内容,希望对大家的学习有所帮助。