Promise 简化了延迟和异步计算。Promise 代表一个尚未完成的操作。
诸位开发同仁,请准备迎接 Web 开发史上的重大时刻。
[鼓声响起来]
Promise 已经来到了 JavaScript!
[璀璨的烟花在天上亮起,五彩纸屑纷纷落下,人们为之疯狂]
此时此刻,您会是下列几种人之一:
- 大家在周围欢呼,但您根本不知道这是为了什么。也许您甚至不知道这个 promise 到底是什么东西。您耸了耸肩,但肩上却感到了五彩纸屑的重量。假如是这样,请别担心,我花了很长时间才弄清楚它的重要性。您可能想从头开始了解。
- 您兴奋地挥出一拳!可算等到了!您之前已经用过这些 Promise,但让人苦恼的是,所有的实现都采用略有不同的 API。官方 JavaScript 版本的 API 是什么?您可能想从术语开始。
- 您对它已经有所了解了,对那些上蹿下跳的新人嗤之以鼻。花点时间享受下您的优越感,然后直接前往 API 参考吧。
有什么好大惊小怪的? #
JavaScript 是单线程的,这意味着不能同时运行两个脚本;它们必须依次运行。在浏览器中,JavaScript 与许多其他内容共享一个线程,而这些内容因浏览器而异。不过 JavaScript 通常与绘画、更新样式和处理用户操作(例如突出显示文本和与表单控件交互)在同一个队列中。这些事件中的活动会推迟其他事件。
人类则是多线程的。您可以使用多个手指打字,一边开车一边聊天。唯一的麻烦是打喷嚏,因为打喷嚏时必须暂停所有当前活动。这一点很烦人,尤其是当您开车在并试图聊天时。您不想写出黏糊糊的代码。
您可能已经在用事件和回调来解决这个问题了。事件如下:
var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {
// woo yey image loaded
});
img1.addEventListener('error', function() {
// argh everything's broken
});
这一点也不赖嘛。我们获取图像,添加几个侦听器,然后可以在调用其中一个侦听器前停止 JavaScript 的执行。
遗憾的是,在上面的例子中,事件可能在我们开始侦听它们之前就发生了,所以我们需要使用图像的 complete 属性来解决这个问题:
var img1 = document.querySelector('.img-1');
function loaded() {
// woo yey image loaded
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// argh everything's broken
});
这不会获取在我们有机会聆听它们之前就出错的图像;遗憾的是,DOM 并没有给我们提供解决的办法。此外,这还只是加载一张图片。如果我们想知道一组图像在何时加载,情况就会变得更加复杂。
事件并非万能 #
事件对可以在同一个对象上发生多次的活动是个好东西,比如 keyup
, touchstart
等。有了那些事件,就无需在添加侦听器前了解到底发生了什么事。但在涉及到异步成功/失败时,理想情况下,您需要用到下列代码:
img1.callThisIfLoadedOrWhenLoaded(function() {
// loaded
}).orIfFailedCallThis(function() {
// failed
});
// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// all loaded
}).orIfSomeFailedCallThis(function() {
// one or more failed
});
这就是 Promise 的作用,但命名更好了。假如 HTML 图像元素有一个返回 promise 的 ready 方法,我们可以这样做:
img1.ready()
.then(function() {
// loaded
}, function() {
// failed
});
// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
// all loaded
}, function() {
// one or more failed
});
从本质看,promise 有点像事件监听器,只不过:
- Promise 只能成功或失败一次。它不能成功或失败两次,也不能从成功切换到失败,反之亦然。
- 如果 Promise 成功或失败,并且您稍后添加成功/失败回调,则将调用正确的回调,即使该事件发生的时间更早。
这对于异步成功/失败非常有用,因为您对某些操作的确切时间没兴趣,反而对结果的反应更感兴趣。
Promise 术语 #
Domenic Denicola 校对阅读了本文的初稿,并评价我的术语根本不及格。他把我关了起来,罚我抄了 100 遍的《States and Fates》,还给我的父母写了一封信表示担忧。尽管如此,我仍然弄混了很多术语,但下面列出了基本知识:
Promise 可以是:
- 已完成- 与 promise 相关的操作成功
- 已拒绝- 与 promise 相关的操作失败
- 待定- 尚未完成或已拒绝
- 已解决- 已完成或已拒绝
本规范还使用术语 thenable 来描述一个拥有 then
方法的类 promise 对象。这个术语总是让我想起前英格兰足球经理 Terry Venables,所以我会尽量少用它。
Promise 来到了 JavaScript! #
Promise 已经以库的形式出现了一段时间,例如:
以上列出的库和 JavaScript Promise 共享一个通用的标准化行为,称之为 Promises/A+ 。如果您是 jQuery 用户,它们也有一个叫做 Deferreds 的类似行为。然而,Deferreds 与 Promise/A+ 并不兼容,这使得它们略有不同,而且用途更少,所以要小心。 jQuery 也有一个 Promise 类型,但它只是 Deferred 的一个子集,并且存在同样的问题。
尽管 promise 实现遵循标准化行为,但它们的整体 API 各不相同。 JavaScript promise 在 API 方面与 RSVP.js 类似。下面列出了创建 promise 的方法:
var promise = new Promise(function(resolve, reject) {
// do a thing, possibly async, then…
if (/* everything turned out fine */) {
resolve("Stuff worked!");
}
else {
reject(Error("It broke"));
}
});
promise 构造函数使用了一个参数,一个带有两个参数 resolve 和 reject 的回调。在回调中执行一些操作,比如异步;然后如果一切顺利,就调用 resolve,否则会调用 reject。
与 JavaScript 中的 throw
类似,使用 Error 对象拒绝是一种常见而非必须的做法。使用 Error 对象的好处在于它们可以捕获堆栈跟踪,从而更好地发挥调试工具的作用。
下面列出了使用 promise 的方法:
promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});
then()
有两个参数,一个成功案例的回调,另一个是失败案例的回调。这两者都是可选项,因此您只能为成功案例或失败案例添加回调。
JavaScript promise 在 DOM 中最初叫做 Futures,后来改名为 Promises,并最终进入了 JavaScript。在 JavaScript 中使用它们比在 DOM 中使用更好,因为它们可用于非浏览器 JS 上下文中,例如 Node.js(至于是否在其核心 API 中使用,则是另一个问题了)。
尽管它们是 JavaScript 的功能,但 DOM 也可以使用。事实上,所有具有异步成功/失败方法的新 DOM API 都将使用 Promise。这在 Quota Management 、Font Load Events 、ServiceWorker 、Web MIDI 和 Streams 等中已经很常见了。
浏览器支持和 polyfill #
现在,浏览器已经中实现了 promise。
从 Chrome 32、Opera 19、Firefox 29、Safari 8 和 Microsoft Edge 开始,promises 是默认开启的。
若要使缺乏完整 promise 实现的浏览器符合规范,或将 promise 添加到其他浏览器和 Node.js 中,请查看polyfill (2k gzipped)。
与其他库的兼容性 #
JavaScript promise API 会将任何使用 then()
方法的对象当做类 promise(或者在 promise 中叫 thenable
,唉)来处理,所以如果您使用返回 Q promise 的库,也没关系,它将与新的 JavaScript promise 良好兼容。
不过就像我之前说得那样,jQuery 的 Deferreds 的作用不大。但幸好您可以将它们转换为标准 promise,而这值得尽快完成:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
在这里,jQuery 的$.ajax
返回一个 Deferred。因为它使用了一个 then()
方法, Promise.resolve()
可以把它变成 JavaScript promise。但是,有时 deferred 会向其回调传递多个参数,例如:
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
而 JS promise 会忽略除第一个之外的所有参数:
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
值得庆幸的是,这通常是您想要的结果,或者至少可以让您访问想要的内容。另外请注意,jQuery 不遵循将 Error 对象传递给拒绝的约定。
将复杂的异步代码简单化 #
好吧,我们来写些代码。比方说,我们想要:
- 启动一个旋转器,表明正在加载的过程
- 为一个故事获取几个 JSON,为我们提供标题以及每章的 url
- 给页面添加标题
- 获取每一章
- 将故事添加到页面
- 停止旋转器
……但也要告诉用户中途是否出现问题。假如出现问题,我们也需要在那时停止旋转器,否则它会继续旋转,并与其他用户界面发生冲突。
当然,您不会使用 JavaScript 来传递故事,因为使用 HTML 会更快,但这种模式在处理 API 时很常见:多次获取数据,然后在所有数据获取完毕后执行操作。
首先,让我们处理从网络获取数据:
将 XMLHttpRequest Promise 化 #
如果可能采取向后兼容的方式,那么可以更新旧的 API 来使用 Promise。 XMLHttpRequest
是一个主要的候选对象,但同时让我们写一个简单的函数来发出 GET 请求:
function get(url) {
// Return a new promise.
return new Promise(function(resolve, reject) {
// Do the usual XHR stuff
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function() {
// This is called even on 404 etc
// so check the status
if (req.status == 200) {
// Resolve the promise with the response text
resolve(req.response);
}
else {
// Otherwise reject with the status text
// which will hopefully be a meaningful error
reject(Error(req.statusText));
}
};
// Handle network errors
req.onerror = function() {
reject(Error("Network Error"));
};
// Make the request
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 函数以使用 JSON responseType
,但同时也可以在 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()
依然会返回一个 promise,它获取一个 url,然后将响应解析为 JSON。
队列异步操作 #
您还可以链接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,然后我们请求其中第一个 URL。就是在这时候,promise 开始从简单的回调模式中脱颖而出。
您甚至可以生成一个获取章节的快捷方法:
var storyPromise;
function getChapter(i) {
storyPromise = storyPromise || getJSON('story.json');
return storyPromise.then(function(story) {
return getJSON(story.chapterUrls[i]);
})
}
// and using it is simple:
getChapter(0).then(function(chapter) {
console.log(chapter);
return getChapter(1);
}).then(function(chapter) {
console.log(chapter);
})
在调用 getChapter
之前,我们不用下载 story.json
。但接下来调用 getChapter
的时候,我们可以重复使用故事的 promise,所以只用获取一次 story.json
。Promise,真棒!
错误处理 #
正如之前看到的, then()
有两个参数,分别表示成功和失败(promises 的术语叫做完成和拒绝):
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);
})
这种差异很微妙,但非常有用。 当出现拒绝回调(或 catch()
,因为它是等效的)时,promise 拒绝会跳到下一个 then()
。这时会调用 then(func1, func2)
、func1
或 func2
,但不会同时调用两个。但是对于 then(func1).catch(func2)
,如果 func1
拒绝,两者都会被调用,因为它们是链中的单独步骤。采取以下措施:
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 时就会出现拒绝,此外,当在构造函数回调中引发错误时,也会暗示出现拒绝:
var jsonPromise = new Promise(function(resolve, reject) {
// JSON.parse throws an error if you feed it some
// invalid JSON, so this implicitly rejects:
resolve(JSON.parse("This ain't JSON"));
});
jsonPromise.then(function(data) {
// This never happens:
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
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 或用户离线),它将跳过所有后续的成功回调,其中包括尝试将响应解析为 JSON 的 getJSON()
,同时还会跳过将 chapter1.html 添加到页面的回调。相反,它移动到 catch 回调。因此,如果之前的任何操作均失败,那么”无法显示章节“将添加到页面中。
和 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'
您可能只想将 catch()
用于记录目的,而不从错误中恢复。为此,只需重新引发错误即可。我们可以在 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: for each url in story.chapterUrls, fetch & display
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the 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
不是异步感知的,所以章节会按照它们的下载的顺序出现,这基本上就是电影《低俗小说》的写作方式。可我们不是在写剧本,所以得解决这个问题。
创建序列 #
我们想把 chapterUrls
数组变成一个 promise 序列。可以使用 then()
实现:
// Start off with a promise that always resolves
var sequence = Promise.resolve();
// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
// Add these actions to the end of the sequence
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
})
这是我们第一次看到 Promise.resolve()
,它可创建一个可解析为您赋予它任何值的 promise。如果您传递给它一个 Promise
实例,它会返回该实例(**注意:**这是对规范的一个更改,某些实现尚未遵循)。如果您向它传递一些类 Promise
(拥有 then()
方法),它会创建一个真正的 Promise,以相同的方式完成/拒绝。如果您传入任何其他值,例如 Promise.resolve('Hello')
,它会创建一个满足该值的 promise。如果您不用任何值调用它,如上所述,它将以“未定义”来完成。
另外还有 Promise.reject(val)
,它创建了一个拒绝您赋予值(或未定义)的 promise。
我们可以使用 array.reduce
整理上面的代码:
// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Add these actions to the end of the 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) {
// Once the last chapter's promise is done…
return sequence.then(function() {
// …fetch the next chapter
return getJSON(chapterUrl);
}).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
好了,这就是同步版本的完全异步版本。但我们可以做得更好。目前页面的下载方式如下:
浏览器非常擅长一次下载多个内容,因此依次下载章节会损失性能。我们的目标是同时下载它们,并且全部下载完毕后进行处理。幸好有一个合适的 API:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all
使用一个数组的 promise,并所有 promise 成功完成后创建一个已完成的 promise。您会以传入的 promise 相同的顺序获得一系列结果(无论 promise 完成到什么程度)。
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Take an array of promises and wait on them all
return Promise.all(
// Map our array of chapter urls to
// an array of chapter json promises
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Now we have the chapters jsons in order! Loop through…
chapters.forEach(function(chapter) {
// …and add to the page
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
根据连接情况,这可能比逐个加载快几秒钟,并且比第一次尝试使用的代码更少。章节可以按任何顺序下载,但它们会以正确的顺序出现在屏幕上。
但是,我们仍然可以提高感知性能。当第一章下载完毕时,我们应该将其添加到页面中。这让用户可以在其余章节下载完毕之前开始阅读。当第三章到达时,我们不会将其添加到页面中,因为用户可能没有意识到第二章还未准备好。当第二章到来时,我们就可以添加第二章和第三章了,以此类推。
为此,我们同时为所有章节获取 JSON,然后创建一个序列将它们添加到文档中:
getJSON('story.json')
.then(function(story) {
addHtmlToPage(story.heading);
// Map our array of chapter urls to
// an array of chapter json promises.
// This makes sure they all download in parallel.
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// Use reduce to chain the promises together,
// adding content to the page for each chapter
return sequence
.then(function() {
// Wait for everything in the sequence so far,
// then wait for this chapter to arrive.
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
就这样,两全其美!交付所有内容所需的时间相同,但用户会更快地获得第一个内容。
在这个小例子中,所有章节大约在同一时间到达,但一次显示一个章节的优势将被更多、更大的章节放大。
使用 Node.js 样式的回调或事件执行上述操作大约会有两倍的代码量,但更重要的是,它的遵循难度更高。然而,Promise 的故事还没有结束,当与其他 ES6 特性结合时,它们会变得更加容易。
奖励回合:扩展能力 #
自从我最初写这篇文章以来,使用 Promise 的能力已经得到了极大的扩展。从 Chrome 55 开始,异步函数允许以同步方式编写基于 Promise 的代码,但不会阻塞主线程。您可以在我的异步函数一文中查看更多相关信息。主流浏览器都广泛支持 Promise 和 async 函数。您可以在 MDN 的 Promise 和 异步函数参考中找到详细信息。
非常感谢 Anne van Kesteren、Domenic Denicola、Tom Ashworth、Remy Sharp、Addy Osmani、Arthur Evans 和 Yutaka Hirano,他们校对了本文并提出了更正/建议。
另外,感谢 Mathias Bynens 对本文多个部分的更新工作。