promise 可简化延迟和异步计算。promise 表示尚未完成的操作。
各位开发者,请做好准备,迎接网页开发史上的关键时刻。
[鼓声开始]
promise 已出现在 JavaScript 中!
[烟花爆炸,彩纸飘飘,人群纷纷狂野]
此时,您属于以下某个类别:
- 大家在您身边欢呼喝彩,但不知道这一切究竟为什么会是那么活跃。也许你甚至搞不清楚“承诺”是什么。虽然你耸了耸肩,但闪烁着的纸张却让我们承载着它们的神采。如果是,也不用担心,我花了很长时间才弄明白为什么我应该关注它。您可能需要从开头开始。
- 您非常抓狂!时间刚好,对吧?您以前使用过这些 Promise,但让您困扰的是,所有实现的 API 都有一个略有不同的 API。官方 JavaScript 版本的 API 是什么?您可能需要从“术语”开始。
- 你早就知道这件事,你会嘲笑那些上来跳去的人,好像这是个新闻。您可以先自豪一把,然后直接查看 API 参考文档。
浏览器支持和 polyfill
如需将缺少完整 promise 实现的浏览器满足规范要求,或向其他浏览器和 Node.js 添加 promise,请查看 polyfill(经过 Gzip 压缩的 2k 大小)。
人们在忙什么呢?
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”。他把我关进监狱,强迫我复写状态和命运 100 遍,还写了一封担心的信给我的父母。尽管如此,我还是对很多术语混淆不清,以下是一些基本概念:
promise 可以是:
- 已执行 - 与 promise 相关的操作已成功
- rejected - 与 promise 相关的操作失败
- pending - 尚未满足或拒绝
- 已解决 - 已履行或拒绝
该规范还使用术语“thenable”来描述类似于 promise 的对象,其具有 then
方法。这个词让我想起前英格兰足球经理 Terry Venables,因此我会尽量少用以下词。
Promise 在 JavaScript 中受支持!
promise 以库的形式出现已有一段时间,例如:
上面的代码与 JavaScript promise 都有一个名为 Promises/A+ 的常见标准化行为。如果您是 jQuery 用户,则它们具有名为 Deferred 的类似行为。但是,Deferred 与 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 和弱化。在回调中执行一些操作(可能是异步),如果一切正常,则调用 resolve,否则调用 cancel。
与普通旧版 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 中是很好的做法,因为它们可在 Node.js 等非浏览器 JS 上下文中使用(它们是否在核心 API 中使用它们是另一个问题)。
虽然它们是 JavaScript 的功能,但 DOM 使用起来吧。实际上,采用异步成功/失败方法的所有新 DOM API 均使用 promise。配额管理、字体加载事件、ServiceWorker、Web MIDI、Streams 等都已实现这一点。
与其他库的兼容性
JavaScript promise API 会将 then()
方法的任何内容视为 promise(如 promise 中的 promise 或 thenable
)一样,因此,如果您使用返回 Q promise 的库也没关系,它可与新的 JavaScript promise 完美搭配。
不过,正如我之前提到的,jQuery 的 Deferred 有点...没用。幸运的是,您可以将其转换为标准 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,获取每个章节的标题和网址
- 为页面添加标题
- 提取每个章节
- 将故事添加到页面
- 停止旋转图标
...但如果此过程出现问题,也要告知用户。我们也想在那一刻停止旋转图标,否则它会不停地旋转、眩晕并撞上其他界面。
当然,您不会使用 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,该 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 的回调,并且还会跳过将 如果 如果 想要将 针对页面进行解析的回调。而是会移至 catch 回调。因此,如果前面的任一操作失败,“Failed to show 账户”部分将添加到页面。
与 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 的内容(具有 then()
方法),它将创建一个真正执行/拒绝的 Promise
。如果您传入任何其他值,例如Promise.resolve('Hello')
,它会创建一个在执行时用该值执行的 promise。如果调用时不带任何值(如上所示),它在执行时将返回“undefined”。
此外,还有 Promise.reject(val)
,它创建了一个 promise 在拒绝时返回赋予的值(或“undefined”)。
我们可以使用 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”变量。系统会对数组中的每个项调用我们的缩减回调。
第一次调用时,“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 的顺序相同。
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 的代码当作同步代码来编写,同时又不会阻塞主线程。您可以在my async functions article中了解详情。主流浏览器广泛支持 promise 和异步函数。如需了解详情,请参阅 MDN 的 Promise 和异步函数参考文档。
非常感谢 Anne van Kesteren、Domenic Denicola、Tom Ashworth、Remy Sharp、Addy Osmani、Arthur Evans 和 Yutaka Hirano 对这些内容进行校对并给出了修正/建议。
此外,还要感谢 Mathias Bynens 更新本文的各个部分。