复杂的异步代码变得简单了
OK,现在我们来写点实际的代码。假设我们想要:
- 显示一个加载指示图标
- 加载一篇小说的 JSON,包含小说名和每一章内容的 URL。
- 在页面中填上小说名
- 加载所有章节正文
- 在页面中添加章节正文
- 停止加载指示
……这个过程中如果发生什么错误了要通知用户,并且把加载指示停掉,不然它就会不停转下去,令人眼晕,或者搞坏界面什么的。
当然了,你不会用 JavaScript 去这么繁琐地显示一篇文章,直接输出 HTML 要快得多,不过这个流程是非常典型的 API 请求模式:获取多个数据,当它们全部完成之后再做一些事情。
首先,搞定从网络加载数据的步骤:
将 Promise 用于 XMLHttpRequest
只要能保持向后兼容,现有 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); });
点击这里查看代码。现在我们不需要手敲 XMLHttpRequest
就可以直接发起 HTTP 请求,这样感觉好多了,能少看一次这个狂驼峰命名的 XMLHttpRequest
我就多快乐一点。
链式调用
“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 流程。
JavaScript 异常和 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 的新功能结合起来还能更加高效……