Promise 可简化延迟计算和异步计算。Promise 表示尚未完成的操作。
开发者们,请做好准备,迎接 Web 开发历史上的关键时刻。
[击鼓声起]
JavaScript 现已支持 Promise!
[烟花绽放,彩纸飘落,人群欢呼雀跃]
此时,您属于以下类别之一:
- 周围的人都在欢呼,但您不确定大家为何如此兴奋。你甚至可能不确定“承诺”是什么。你本想耸耸肩,但闪闪发光的纸张却沉甸甸地压在你的肩上。如果是这样,请不要担心,我花了很长时间才弄清楚为什么我应该关注这些内容。您可能希望从头开始。
- 你挥拳庆祝!来得正是时候,对吧?您之前使用过这些 Promise,但让您感到困扰的是,所有实现都具有略微不同的 API。官方 JavaScript 版本的 API 是什么?您可能需要先了解术语。
- 您已经知道这一点,并且嘲笑那些像听到新闻一样上蹿下跳的人。花点时间沉浸在自己的优越感中,然后直接前往 API 参考文档。
浏览器支持和 polyfill
如需让缺乏完整 promise 实现的浏览器符合规范,或向其他浏览器和 Node.js 添加 promise,请查看 polyfill(2k gzipped)。
有什么好大惊小怪的?
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 校对了本文的初稿,并给我的术语评分为“F”。他把我关了禁闭,强迫我抄写《States and Fates》100 遍,还给我父母写了一封信,表达了他的担忧。尽管如此,我还是经常混淆很多术语,但以下是基本术语:
承诺可以是:
- fulfilled - 与 promise 相关的操作已成功
- rejected - 与 promise 相关的操作失败
- pending - 尚未履行或拒绝
- settled - 已履行或已拒绝
规范还使用术语 thenable 来描述类似 promise 的对象,因为它具有 then 方法。这个词让我想起了前英格兰足球队主教练特里·维纳布尔斯,因此我会尽量少用这个词。
JavaScript 迎来 Promise!
Promise 已经以库的形式存在了一段时间,例如:
上述 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 中作为“Future”出现,后来重命名为“Promise”,最后移入 JavaScript。将它们放在 JavaScript 而不是 DOM 中非常棒,因为它们将在非浏览器 JS 上下文(例如 Node.js)中可用(它们是否在其核心 API 中使用它们是另一个问题)。
虽然它们是 JavaScript 功能,但 DOM 并不害怕使用它们。事实上,所有具有异步成功/失败方法的新 DOM API 都将使用 promise。 配额管理、字体加载事件、ServiceWorker、Web MIDI、流等功能已经实现了这一点。
与其他库的兼容性
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 会忽略除第一个之外的所有 promise:
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
幸运的是,这通常是您想要的结果,或者至少能让您访问所需的内容。另请注意,jQuery 不遵循将 Error 对象传递到拒绝中的惯例。
简化复杂的异步代码
好了,我们来编写一些代码。假设我们想要:
- 启动旋转图标以指示正在加载
- 获取故事的 JSON 数据,其中包含标题和每个章节的网址
- 为页面添加标题
- 提取每个章节
- 将故事添加到页面
- 停止旋转
…但也要在过程中出现问题时告知用户。我们还希望在此时停止旋转图标,否则它会一直旋转,让人眼花缭乱,并与其他一些界面发生冲突。
当然,您不会使用 JavaScript 来传送故事,以 HTML 形式传送更快,但在处理 API 时,这种模式非常常见:多次提取数据,然后在所有数据提取完成后执行某些操作。
首先,我们来处理从网络提取数据:
Promisifying XMLHttpRequest
如果能以向后兼容的方式实现,旧版 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,该 promise 会提取网址,然后将响应解析为 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 发出异步请求,该请求会返回一组要请求的网址,然后我们请求其中的第一个网址。此时,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 时,我们会重复使用 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 拒绝时,两者都会被调用,因为它们是链中的独立步骤。请看以下示例:
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 或用户离线),它将跳过所有后续成功回调,包括 getJSON() 中尝试将响应解析为 JSON 的回调,并且还会跳过将 chapter1.html 添加到页面的回调。而是会转到 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'
您可能只想 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';
})
但如何循环遍历章节网址并按顺序提取它们呢?以下内容无效:
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 的实例,它将直接返回该实例(注意:这是对规范的更改,某些实现尚未遵循)。如果您向其传递类似 promise 的内容(具有 then() 方法),它会创建一个以相同方式执行/拒绝的真正 Promise。如果您传入任何其他值(例如 Promise.resolve('Hello')),则会创建一个使用该值履行的 promise。如果您在调用时不提供任何值(如上所示),则会以“undefined”值执行。
还有 Promise.reject(val),它会创建一个 promise,该 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 和异步函数。 您可以在 MDN 的 Promise 和 async 函数参考文档中找到详细信息。
非常感谢 Anne van Kesteren、Domenic Denicola、Tom Ashworth、Remy Sharp、Addy Osmani、Arthur Evans 和 Yutaka Hirano,他们校对了本文并提出了更正/建议。
此外,还要感谢 Mathias Bynens 更新了本文的各个部分。