JavaScript 是单线程的,这意味着任何两句代码都不能同时运行,它们得一个接一个来。在浏览器中,JavaScript 和其他任务共享一个线程,不同的浏览器略有差异,但大体上这些和 JavaScript 共享线程的任务包括重绘、更新样式、用户交互等,所有这些任务操作都会阻塞其他任务。
一、事件的不足
对于那些执行时间很长,并且长时间占用线程的代码,我们通常使用异步来执行,但是又如何判断其是否执行完毕或者失败呢?我们通常使用事件监听,但事件监听只能监听绑定之后发生的事件,但有可能你写绑定事件代码之前该事件就已经发生,这样你就无法检测。下面进行说明:
你应该会用事件加回调的办法来处理这类情况:
var img1 = document.querySelector(‘.img-1‘); img1.addEventListener(‘load‘, function() { // 啊哈图片加载完成 }); img1.addEventListener(‘error‘, function() { // 哎哟出问题了 });
这样加载图片就不会占据线程。我们添加几个监听函数,请求图片,然后 JavaScript 就停止运行了,直到触发某个监听函数。
上面的例子中唯一的问题是,事件有可能在我们绑定监听器之前就已经发生,所以我们先要检查图片的“complete”属性:
var img1 = document.querySelector(‘.img-1‘); function loaded() { // 啊哈图片加载完成 } if (img1.complete) { loaded(); } else { img1.addEventListener(‘load‘, loaded); } img1.addEventListener(‘error‘, function() { // 哎哟出问题了 });
这样还不够,如果在添加监听函数之前图片加载发生错误,我们的监听函数还是白费,不幸的是 DOM 也没有为这个需求提供解决办法。而且,这还只是处理一张图片的情况,如果有一堆图片要处理那就更麻烦了。
事件机制最适合处理同一个对象上反复发生的事情—— keyup、touchstart 等等。你不需要考虑当绑定监听器之前所发生的事情,当碰到异步请求成功/失败的时候,你想要的通常是这样:
img1.callThisIfLoadedOrWhenLoaded(function() { // 加载完成 }).orIfFailedCallThis(function() { // 加载失败 }); // 以及…… whenAllTheseHaveLoaded([img1, img2]).callThis(function() { // 全部加载完成 }).orIfSomeFailedCallThis(function() { // 一个或多个加载失败 });
这就是 Promise。如果 HTML 图片元素有一个“ready()”方法的话,我们就可以这样:
img1.ready().then(function() { // 加载完成 }, function() { // 加载失败 }); // 以及…… Promise.all([img1.ready(), img2.ready()]).then(function() { // 全部加载完成 }, function() { // 一个或多个加载失败 });
基本上 Promise 还是有点像事件回调的,除了:
这些特性非常适合处理异步操作的成功/失败情景,你无需再担心事件发生的时间点,而只需对其做出响应。
二、promise使回调函数和异步操作彻底分离
看了上述所讲,感觉promise和回调函数作用差不多,但对于多层嵌套的回调,在代码组织上确实优雅很多。
网页的交互越来越复杂,JavaScript 的异步操作也随之越来越多。如常见的 ajax 请求,需要在请求完成时响应操作,请求通常是异步的,请求的过程中用户还能进行其他的操作,不会对页面进行阻塞,这种异步的交互效果对用户来说是挺有友好的。但是对于开发者来说,要大量处理这种操作,就很不友好了。异步请求完成的操作必须预先定义在回调函数中,等到请求完成就必须调用这个函数。这种非线性的异步编程方式会让开发者很不适应,同时也带来了诸多的不便,增加了代码的耦合度和复杂性,代码的组织上也会很不优雅,大大降低了代码的可维护性。情况再复杂点,如果一个操作要等到多个异步 ajax 请求的完成才能进行,就会出现回调函数嵌套的情况,如果需要嵌套好几层,那你就只能自求多福了。
先看看下面这个常见的异步函数。
var showMsg = function(){
setTimeout(function(){
alert( ‘hello’ );
}, 5000 );
};
如果要给该函数添加回调,通常会这么干。
var showMsg = function( callback ){
setTimeout(function(){
alert( ‘hello’ );
// 此处添加回调
callback();
}, 5000 );
};
如果是使用 easy.js 的 Promise,添加回调的方法就会优雅多了,前提是需要将原函数封装成一个 promise 实例。
var showMsg = function(){
// 构造promise实例
var promise = new E.Promise();
setTimeout(function(){
alert( ‘hello’ );
// 改变promise的状态
promise.resolve( ‘done’ );
}, 5000 );
// 返回promise实例
return promise;
};
将一个普通的函数封装成一个 promise 实例,有3个关键步骤,第一步是在函数内部构造一个 promise 实例,第二步是部署函数执行完去改变 promise 的状态为已完成,第三步就是返回这个 promise 实例。每个 promise 实例都有3种状态,分别为 pending(未完成)、resolved(已完成,成功)、rejected(已拒绝,失败)。下面再来看看如何添加回调。
showMsg().then(function( str ){
// 回调添加到这里来了
callback( str );
});
这样就将回调函数和原来的异步函数彻底的分离了,从代码组织上看,优雅了很多。resolve 接受一个参数,该参数就可以轻松实现将数据传送给使用 then 方法添加的回调中。
对于 ajax 请求,easy.js 直接将 ajax 方法封装成了 promise 对象,可以直接添加 then 方法来回调。
E.ajax({
url : ‘test1.php’,
type : ‘GET’
})
then(function(){
// 添加请求成功的回调
}, function(){
// 添加请求失败的回调
});
then 方法接受2个函数作为参数,第一个函数是已完成的回调,第二个就是已失败的回调。
如果有上面提到的多个 ajax 请求的情况呢?那么就要用到 when 这个方法了。该方法可以接受多个 promise 实例作为参数。
var requests = E.when(E.ajax({
url : ‘test1.php’,
type : ‘GET’
}), E.ajax({
url : ‘test2.php’,
type : ‘GET’
}));
requests.then(function( arg1, arg2 ){
console.log( ‘success:’ + arg1[0] + arg2[0] );
}, function( arg1, arg2 ){
console.log( ‘failure:’ + arg1 + arg2 );
});
when 方法是将多个 promise 实例存到一个数组中,等到该数组的所有 promise 实例都是已完成状态才去执行已完成的回调,一旦有一个实例是已拒绝的状态,则立即执行已拒绝的回调。
三、promise中你可能不知道的事情
1.then()返回promise
// Exhibit A var p = new Promise(/*...*/); p.then(func1); p.then(func2);
// Exhibit B var p = new Promise(/*...*/); p.then(func1) .then(func2);
若func1执行错误,A情况下,func2会正常执行,但B情况下,func2不会执行
2.then()中的回调函数必须返回参数
3.只有上一层错误才能被抛出
// Exhibit A new Promise(function(resolve, reject) { resolve("hello world"); }) .then( function(str) { throw new Error("uh oh"); }, undefined ) .then( undefined, function(error) { alert(error); } );
// Exhibit B new Promise(function(resolve, reject) { resolve("hello world"); }) .then( function(str) { throw new Error("uh oh"); }, function(error) { alert(error); } );
A情况下错误会抛出,B情况下不会
4.错误如果没有再次抛出,将被视作已经修复,会继续执行then()
var p = new Promise(function(resolve, reject) { reject(new Error("pebkac")); }); p.then( undefined, function(error) { } ) .then( function(str) { alert("I am saved!"); }, function(error) { alert("Bad computer!"); } );
5.promises也可以被中途终止
var p = new Promise(/*...*/); p.then(function(str) { if(!loggedIn) { return new Promise(/*...*/); } }) .then(function(str) { alert("Done."); })
只要在then()中加入return new Promise(/*...*/);
6.promise中的resolve()函数不是立刻执行的
function runme() { var i = 0; new Promise(function(resolve) { resolve(); }) .then(function() { i += 2; }); alert(i); }
上述代码执行结果不一定是2,因为你觉得resolve是同步的,会立刻执行。但是你错了!promise规定所有调用都必须是异步,所以当执行到alert(i)时,i可能还没被修改!
四、应用
OK,现在我们来写点实际的代码。假设我们想要:
……这个过程中如果发生什么错误了要通知用户,并且把加载指示停掉,不然它就会不停转下去,令人眼晕,或者搞坏界面什么的。
当然了,你不会用 JavaScript 去这么繁琐地显示一篇文章,直接输出 HTML 要快得多,不过这个流程是非常典型的 API 请求模式:获取多个数据,当它们全部完成之后再做一些事情。
首先,搞定从网络加载数据的步骤:
只要能保持向后兼容,现有 API 都会更新以支持 Promise,XMLHttpRequest
是重点考虑对象之一。不过现在我们先来写个 GET 请求:
function get(url) { // 返回一个新的 Promise return new Promise(function(resolve, reject) { // 经典 XHR 操作 var req = new XMLHttpRequest(); req.open(‘GET‘, url); req.onload = function() { // 当发生 404 等状况的时候调用此函数 // 所以先检查状态码 if (req.status == 200) { // 以响应文本为结果,完成此 Promise resolve(req.response); } else { // 否则就以状态码为结果否定掉此 Promise // (提供一个有意义的 Error 对象) reject(Error(req.statusText)); } }; // 网络异常的处理方法 req.onerror = function() { reject(Error("Network Error")); }; // 发出请求 req.send(); }); }
现在可以调用它了:
get(‘story.json‘).then(function(response) { console.log("Success!", response); }, function(error) { console.error("Failed!", error); });
链式调用
“then”的故事还没完,你可以把这些“then”串联起来修改结果或者添加进行更多异步操作。
你可以对结果做些修改然后返回一个新值:
var promise = new Promise(function(resolve, reject) { resolve(1); }); promise.then(function(val) { console.log(val); // 1 return val + 2; }).then(function(val) { console.log(val); // 3 });
回到前面的代码:
get(‘story.json‘).then(function(response) { console.log("Success!", response); });
收到的响应是一个纯文本的 JSON,我们可以修改 get 函数,设置 responseType
为 JSON 来指定服务器响应格式,也可以在 Promise 的世界里搞定这个问题:
get(‘story.json‘).then(function(response) { return JSON.parse(response); }).then(function(response) { console.log("Yey JSON!", response); });
既然 JSON.parse
只接收一个参数,并返回转换后的结果,我们还可以再精简一下:
get(‘story.json‘).then(JSON.parse).then(function(response) { console.log("Yey JSON!", response); });
事实上,我们可以把getJSON
函数写得超级简单:
function getJSON(url) { return get(url).then(JSON.parse); }
getJSON
会返回一个获取 JSON 并加以解析的 Promise。
你也可以把“then”串联起来依次执行异步操作。
当你从“then”的回调函数返回的时候,这里有点小魔法。如果你返回一个值,它就会被传给下一个“then”的回调;而如果你返回一个“类 Promise”的对象,则下一个“then”就会等待这个 Promise 明确结束(成功/失败)才会执行。例如:
getJSON(‘story.json‘).then(function(story) { return getJSON(story.chapterUrls[0]); }).then(function(chapter1) { console.log("Got chapter 1!", chapter1); });
这里我们发起一个对“story.json”的异步请求,返回给我们更多 URL,然后我们会请求其中的第一个。Promise 开始首次显现出相较事件回调的优越性了。你甚至可以写一个抓取章节内容的独立函数:
var storyPromise; function getChapter(i) { storyPromise = storyPromise || getJSON(‘story.json‘); return storyPromise.then(function(story) { return getJSON(story.chapterUrls[i]); }) } // 用起来非常简单: getChapter(0).then(function(chapter) { console.log(chapter); return getChapter(1); }).then(function(chapter) { console.log(chapter); });
我们一开始并不加载 story.json,直到第一次 getChapter
,而以后每次 getChapter
的时候都可以重用已经加载完成的 story Promise,所以 story.json 只需要请求一次。Promise 好棒!
前面已经看到,“then”接受两个参数,一个处理成功,一个处理失败(或者说肯定和否定,按 Promise 术语):
get(‘story.json‘).then(function(response) { console.log("Success!", response); }, function(error) { console.log("Failed!", error); });
你还可以使用“catch”:
get(‘story.json‘).then(function(response) { console.log("Success!", response); }).catch(function(error) { console.log("Failed!", error); });
这里的 catch 并无任何特殊之处,只是 比then(undefined, func)
的语法更直观一点而已。注意上面两段代码的行为不仅相同,后者相当于:
get(‘story.json‘).then(function(response) { console.log("Success!", response); }).then(undefined, function(error) { console.log("Failed!", error); });
差异不大,但意义非凡。Promise 被否定之后会跳转到之后第一个配置了否定回调的 then(或 catch,一样的)。对于 then(func1, func2)
来说,必会调用 func1
或func2
之一,但绝不会两个都调用。而 then(func1).catch(func2)
这样,如果 func1
返回否定的话 func2
也会被调用,因为他们是链式调用中独立的两个步骤。看下面这段代码:
asyncThing1().then(function() { return asyncThing2(); }).then(function() { return asyncThing3(); }).catch(function(err) { return asyncRecovery1(); }).then(function() { return asyncThing4(); }, function(err) { return asyncRecovery2(); }).catch(function(err) { console.log("Don‘t worry about it"); }).then(function() { console.log("All done!"); });
这段流程非常像 JavaScript 的 try/catch 组合,“try”代码块中发生的错误会立即跳转到“catch”代码块。这是上面那段代码的流程图(我最爱流程图了):
绿线是肯定的 Promise 流程,红线是否定的 Promise 流程。
Promise 的否定回调可以由 Promise.reject() 触发,也可以由构造器回调中抛出的错误触发:
var jsonPromise = new Promise(function(resolve, reject) { // 如果数据格式不对的话 JSON.parse 会抛出错误 // 可以作为隐性的否定结果: resolve(JSON.parse("This ain‘t JSON")); }); jsonPromise.then(function(data) { // 永远不会发生: console.log("It worked!", data); }).catch(function(err) { // 这才是真相: console.log("It failed!", err); });
这意味着你可以把所有 Promise 相关操作都放在它的构造函数回调中进行,这样发生任何错误都能捕捉到并且触发 Promise 否定。
“then”回调中抛出的错误也一样:
get(‘/‘).then(JSON.parse).then(function() { // This never happens, ‘/‘ is an HTML page, not JSON // so JSON.parse throws console.log("It worked!", data); }).catch(function(err) { // Instead, this happens: console.log("It failed!", err); });
回到我们的故事和章节,我们用 catch
来捕捉错误并显示给用户:
getJSON(‘story.json‘).then(function(story) { return getJSON(story.chapterUrls[0]); }).then(function(chapter1) { addHtmlToPage(chapter1.html); }).catch(function() { addTextToPage("Failed to show chapter"); }).then(function() { document.querySelector(‘.spinner‘).style.display = ‘none‘; });
如果请求 story.chapterUrls[0]
失败(http 500 或者用户掉线什么的)了,它会跳过之后所有针对成功的回调,包括 getJSON
中将响应解析为 JSON 的回调,和这里把第一张内容添加到页面里的回调。JavaScript 的执行会进入 catch 回调。结果就是前面任何章节请求出错,页面上都会显示“Failed to show chapter”。
和 JavaScript 的 try/catch 一样,捕捉到错误之后,接下来的代码会继续执行,按计划把加载指示器给停掉。上面的代码就是下面这段的非阻塞异步版:
try { var story = getJSONSync(‘story.json‘); var chapter1 = getJSONSync(story.chapterUrls[0]); addHtmlToPage(chapter1.html); } catch (e) { addTextToPage("Failed to show chapter"); } document.querySelector(‘.spinner‘).style.display = ‘none‘;
如果只是要捕捉异常做记录输出而不打算在用户界面上对错误进行反馈的话,只要抛出 Error 就行了,这一步可以放在 getJSON
中:
function getJSON(url) { return get(url).then(JSON.parse).catch(function(err) { console.log("getJSON failed for", url, err); throw err; }); }
现在我们已经搞定第一章了,接下来搞定全部章节。
异步的思维方式并不符合直觉,如果你觉得起步困难,那就试试先写个同步的方法,就像这个:
try { var story = getJSONSync(‘story.json‘); addHtmlToPage(story.heading); story.chapterUrls.forEach(function(chapterUrl) { var chapter = getJSONSync(chapterUrl); addHtmlToPage(chapter.html); }); addTextToPage("All done"); } catch (err) { addTextToPage("Argh, broken: " + err.message); } document.querySelector(‘.spinner‘).style.display = ‘none‘;
它执行起来完全正常!不过它是同步的,在加载内容时会卡住整个浏览器。要让它异步工作的话,我们用 then 把它们一个接一个串起来:
getJSON(‘story.json‘).then(function(story) { addHtmlToPage(story.heading); // TODO: 获取并显示 story.chapterUrls 中的每个 url }).then(function() { // 全部完成啦! addTextToPage("All done"); }).catch(function(err) { // 如果过程中有任何不对劲的地方 addTextToPage("Argh, broken: " + err.message); }).then(function() { // 无论如何要把 spinner 隐藏掉 document.querySelector(‘.spinner‘).style.display = ‘none‘; });
那么我们如何遍历章节的 URL 并且依次请求?这样是不行的:
story.chapterUrls.forEach(function(chapterUrl) { // Fetch chapter getJSON(chapterUrl).then(function(chapter) { // and add it to the page addHtmlToPage(chapter.html); }); });
“forEach” 没有对异步操作的支持,所以我们的故事章节会按照它们加载完成的顺序显示,基本上《低俗小说》就是这么写出来的。我们不写低俗小说,所以得修正它:
我们要把章节 URL 数组转换成 Promise 的序列,还是用 then
:
// 从一个完成状态的 Promise 开始 var sequence = Promise.resolve(); // 遍历所有章节的 url story.chapterUrls.forEach(function(chapterUrl) { // 从 sequence 开始把操作接龙起来 sequence = sequence.then(function() { return getJSON(chapterUrl); }).then(function(chapter) { addHtmlToPage(chapter.html); }); });
这是我们第一次用到 Promise.resolve
,它会依据你传的任何值返回一个 Promise。如果你传给它一个类 Promise 对象(带有 then
方法),它会生成一个带有同样肯定/否定回调的 Promise,基本上就是克隆。如果传给它任何别的值,如Promise.resolve(‘Hello‘)
,它会创建一个以这个值为完成结果的 Promise,如果不传任何值,则以 undefined 为完成结果。
还有一个对应的 Promise.reject(val)
,会创建以你传入的参数(或 undefined)为否定结果的 Promise。
我们可以用 array.reduce
精简一下上面的代码:
// 遍历所有章节的 url story.chapterUrls.reduce(function(sequence, chapterUrl) { // 从 sequence 开始把操作接龙起来 return sequence.then(function() { return getJSON(chapterUrl); }).then(function(chapter) { addHtmlToPage(chapter.html); }); }, Promise.resolve());
它和前面的例子功能一样,但是不需要显式声明 sequence
变量。reduce
回调会依次应用在每个数组元素上,第一轮里的“sequence”是 Promise.resolve()
,之后的调用里“sequence”就是上次函数执行的的结果。array.reduce
非常适合用于把一组值归并处理为一个值,正是我们现在对 Promise 的用法。
汇总上面的代码:
getJSON(‘story.json‘).then(function(story) { addHtmlToPage(story.heading); return story.chapterUrls.reduce(function(sequence, chapterUrl) { // 当前一个章节的 Promise 完成之后…… return sequence.then(function() { // ……获取下一章 return getJSON(chapterUrl); }).then(function(chapter) { // 并添加到页面 addHtmlToPage(chapter.html); }); }, Promise.resolve()); }).then(function() { // 现在全部完成了! addTextToPage("All done"); }).catch(function(err) { // 如果过程中发生任何错误 addTextToPage("Argh, broken: " + err.message); }).then(function() { // 保证 spinner 最终会隐藏 document.querySelector(‘.spinner‘).style.display = ‘none‘; });
查看代码运行示例,前面的同步代码改造成了完全异步的版本。我们还可以更进一步。现在页面加载的效果是这样:
浏览器很擅长同时加载多个文件,我们这种一个接一个下载章节的方法非常低效率。我们希望同时下载所有章节,全部完成后一次搞定,正好就有这么个 API:
Promise.all(arrayOfPromises).then(function(arrayOfResults) { //... });
Promise.all
接受一个 Promise 数组为参数,创建一个当所有 Promise 都完成之后就完成的 Promise,它的完成结果是一个数组,包含了所有先前传入的那些 Promise 的完成结果,顺序和将它们传入的数组顺序一致。
getJSON(‘story.json‘).then(function(story) { addHtmlToPage(story.heading); // 接受一个 Promise 数组并等待他们全部完成 return Promise.all( // 把章节 URL 数组转换成对应的 Promise 数组 story.chapterUrls.map(getJSON) ); }).then(function(chapters) { // 现在我们有了顺序的章节 JSON,遍历它们…… chapters.forEach(function(chapter) { // ……并添加到页面中 addHtmlToPage(chapter.html); }); addTextToPage("All done"); }).catch(function(err) { // 捕获过程中的任何错误 addTextToPage("Argh, broken: " + err.message); }).then(function() { document.querySelector(‘.spinner‘).style.display = ‘none‘; });
根据连接状况,改进的代码会比顺序加载方式提速数秒(查看示例),甚至代码行数也更少。章节加载完成的顺序不确定,但它们显示在页面上的顺序准确无误。
然而这样还是有提高空间。当第一章内容加载完毕我们可以立即填进页面,这样用户可以在其他加载任务尚未完成之前就开始阅读;当第三章到达的时候我们不动声色,第二章也到达之后我们再把第二章和第三章内容填入页面,以此类推。
为了达到这样的效果,我们同时请求所有的章节内容,然后创建一个序列依次将其填入页面:
getJSON(‘story.json‘).then(function(story) { addHtmlToPage(story.heading); // 把章节 URL 数组转换成对应的 Promise 数组 // 这样就可以并行加载它们 return story.chapterUrls.map(getJSON) .reduce(function(sequence, chapterPromise) { // 使用 reduce 把这些 Promise 接龙 // 以及将章节内容添加到页面 return sequence.then(function() { // 等待当前 sequence 中所有章节和本章节的数据到达 return chapterPromise; }).then(function(chapter) { addHtmlToPage(chapter.html); }); }, Promise.resolve()); }).then(function() { addTextToPage("All done"); }).catch(function(err) { // 捕获过程中的任何错误 addTextToPage("Argh, broken: " + err.message); }).then(function() { document.querySelector(‘.spinner‘).style.display = ‘none‘; });
鱼与熊掌兼得!加载所有内容的时间未变,但用户可以更早看到第一章。
这个小例子中各部分章节加载差不多同时完成,逐章显示的策略在章节内容很多的时候优势将会更加显著。
上面的代码如果用 Node.js 风格的回调或者事件机制实现的话代码量大约要翻一倍,更重要的是可读性也不如此例。然而,Promise 的厉害不止于此,和其他 ES6 的新功能结合起来还能更加高效……
除非额外注明,Chrome、Opera 和 Firefox(nightly)均支持下列所有方法。这个 polyfill 则在所有浏览器内实现了同样的接口。
Promise.resolve(promise);
promise.constructor == Promise
)Promise.resolve(thenable);
Promise.resolve(obj);
Promise.reject(obj);
Promise.all(array);
Promise.race(array);
备注:我不大确定这个接口是否有用,我更倾向于一个 Promise.all
的对立方法,仅当所有数组元素全部给出否定的时候才抛出否定结果
new Promise(function(resolve, reject) {});
resolve(thenable)
resolve(obj)
obj
作为肯定结果完成reject(obj)
obj
作为否定结果完成。出于一致性和调试(如栈追踪)方便,obj
应该是一个 Error 对象的实例。构造器的回调函数中抛出的错误会被立即传递给 reject()
。promise.then(onFulfilled, onRejected)
onFulfilled
。 当 promise 以否定结束时会调用onRejected
。 这两个参数都是可选的,当任意一个未定义时,对它的调用会跳转到 then 链的下一个 onFulfilled
/onRejected
上。 这两个回调函数均只接受一个参数,肯定结果或者否定原因。 当 Promise.resolve
肯定结束之后,then
会返回一个新的 Promise,这个 Promise 相当于你从 onFulfilled
/onRejected
中返回的值。如果回调中抛出任何错误,返回的 Promise 也会以此错误作为否定结果结束。promise.catch(onRejected)
promise.then(undefined, onRejected)
的语法糖。
原文:http://www.cnblogs.com/shytong/p/4985014.html