原文链接:http://javascript.info/promise-chaining,translate with ❤️ by zhangbao.
现在回顾在《回调》一章里提到的问题。
我们有一系列的异步任务需要一个接一个地完成。例如,加载脚本。
如何很好编码?
Promise 提供了一些方法来做到这一点。
本章讲解 Promise 链式调用形式。
用它异步代码,看起来是这样的:
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000); // (*)
}).then(function(result) { // (**)
alert(result); // 1
return result * 2;
}).then(function(result) { // (***)
alert(result); // 2
return result * 2;
}).then(function(result) {
alert(result); // 4
return result * 2;
});
前者的返回结果经过 .then 处理器传递给后者。
流程如下:
最开始的 1 秒钟过后,我们用数值
1
resolve 了一个新 new 的 Promise (*)。然后
.then
处理函数被调用 (**)。返回值通过下一个
.then
处理器传递 (*)。……等等。
当结果值沿处理器链传递时,我们能看到这样一系列的 alert
调用: 1 → 2 → 4。
整个链之所有能顺利工作,是因为调用 primise.then
后会返回一个 Promise,因此我们可以继续使用 .then
来处理返回值。
当一个处理函数返回了一个值,就变成 Promise 的结果,下一个 .then
就被接着调用。
为了使表述更清晰,下面是调用链的开始情形:
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
}).then(function(result) {
alert(result);
return result * 2; // <-- (1)
}) // <-- (2)
// .then…
.then
的返回值是一个 Promise,这就是为什么我们可以在 (2)
处添加另一个 .then
方法进行处理。当在 (1)
处返回了值后,Promise 变成 resolved 状态,因此下一个处理函数携带这个值执行了。
不同于链式调用的是,技术上我们向一个 Promise 添加任意多的 .then
处理函数,类似:
let promise = new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
promise.then(function(result) {
alert(result); // 1
return result * 2;
});
但这是在做完全不同的事情了。下面是结构展示(对比上面的链式调用查看):
同一个 Promise 上的所有 .then
方法都会得到一样的结果,就是新建的 Promise 结果。因此上面的 alert
都会显示一样的内容:1
。它们之间并没有进行值传递。
在实践中,我们很少同时需要多个处理程序来处理一个 Promise,链式调用的使用频率要高得多。
返回 Promise
通常情况下,.then
的返回值会立即传递给下一个处理器,但有一个例外。
如果返回值是一个 Promise,那么进一步的执行将被暂停,直到它解决为止。在那之后,这个 Promise 的结果被传递给下一个 .then
处理器。
例如:
new Promise(function(resolve, reject) {
setTimeout(() => resolve(1), 1000);
}).then(function(result) {
alert(result); // 1
return new Promise((resolve, reject) => { // (*)
setTimeout(() => resolve(result * 2), 1000);
});
}).then(function(result) { // (**)
alert(result); // 2
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
}).then(function(result) {
alert(result); // 4
});
第一个 .then
在 (*)
处返回的 new Promise(...)
使用参数 1
,1 秒钟之后 resolve;然后在 (**)
处将结果(传递给 resolve()
的参数,这里指 result * 2
)传递给第二个 .then
处理器,在弹出 2
之后又做了同样的事情。
因此输出顺序是 1 → 2 → 4,但是现在加了 1
秒钟的延迟后才触发 alert
调用。
返回 Promise 允许我们建立一个异步操作链。
例子:loadScript
我们利用这个特性,写一个 loadScript
函数来一个一个地按顺序加载脚本:
loadScript("/article/promise-chaining/one.js")
.then(function(script) {
return loadScript("/article/promise-chaining/two.js");
})
.then(function(script) {
return loadScript("/article/promise-chaining/three.js");
})
.then(function(script) {
// 使用脚本中声明的函数
// 证明它们确实被加载了
one();
two();
three();
});
这里的每个 loadScript
调用都返回一个 Promise,当它 resolved 后接着执行下一个 .then
方法,然后就启动了下一个脚本的加载。因此脚本就一个接一个地加载了。
我们可以向链中添加更多的异步操作。需要注意的是,代码仍然是“扁平的”,是直下的,而不是向右的。没有“末日金字塔”的迹象。
技术上我们也可以在每个 Promise 之后直接书写 .then
方法,不用返回他们,像这样:
loadScript("/article/promise-chaining/one.js").then(function(script1) {
loadScript("/article/promise-chaining/two.js").then(function(script2) {
loadScript("/article/promise-chaining/three.js").then(function(script3) {
// 这个函数里能访问 script1, script2 和 script3 中声明的函数
one();
two();
three();
});
});
});
这段代码实现了一样的功能:按序加载了 3 个脚本。但是“逐渐往右拓展了”,因此就带来了回调函数中的问题,这时一套使用链式调用(在 .then
中返回 Promise)规避它。
有时直接写 .then
方法也行,因为内嵌的函数可以访问外部作用域(本例中,最内层回调函数可以访问所有脚本 scriptX
中声明的变量)。但这是一个例外,而不是一个规则。
Thenable 对象
严格来说,
.then
可以用来返回任意的“thenable”对象,它同样会被当做 Promise 对象对待。“thenable”对象是一个具有
.then
方法的对象。其思想是,第三方库可以实现自己的“Promise 兼容”对象。他们可以使用扩展的方法集,但也可以与本地 Promise 兼容,因为实现了.then``。
下面是一个 thenable 对象的例子:
```javascript class Thenable { constructor(num) { this.num = num; } then(resolve, reject) { alert(resolve); // function() { native code } // 使用参数 this.num2 在 1 秒钟之后 resolve setTimeout(() => resolve(this.num 2), 1000); // (**) } }
new Promise(resolve => resolve(1)) .then(result => { return new Thenable(result); // (*) }) .then(alert); // 1 秒钟以后弹出 2
>
> JavaScript 会检查 `(*)` 处 `.then` 处理器返回的对象:如果它有一个可调用方法 `.then`,那么就会使用原生的 resolve 和 reject 方法作为参数(类似于执行器),等到直到其中之一被调用。上例中,`1` 秒钟后`(**)` 处调用了 `resolve(2)` 后,结果在链中被进一步传递。
>
> 这个特性允许在 Promise 链上整合使用自定义对象,而不必继承自 Promise。
<a name="vb84gg"></a>
## 大点的例子:fetch
前端编程中,Promise 通常用于网络请求,我们来看一个扩展的例子。
我们将使用 [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) API 从远程服务器加载用户信息。该方法相当复杂,它有许多可选参数,但基本用法非常简单:
```javascript
let promise = fetch(url);
这会向一个网址发送网络请求,并返回一个 Promise。当远程服务器使用标头响应时,在下载完整响应之前,Promise 将使用 response
对象作为参数 resolve。
响应读取完毕后,就可以调用方法 response.text()
:改方法返回一个 resolved 状态的 Promise 对象,使用服务器返回的响应结果(文本形式)作为参数。
下例中发起了一个向 user.json
的请求,然后以文本形式,显示从服务器加载的数据:
fetch('/article/promise-chaining/user.json')
// 当从服务器得到响应后,就会调用下面的 ,then 方法
.then(function(response) {
// 当完成下载的时候
// response.text() 使用完整响应(文本形式)作为参数,返回一个新的 resolved 状态的 Promise 对象
return response.text();
})
.then(function(text) {
// ...这是请求文件里的内容
alert(text); // {"name": "iliakan", isAdmin: true}
});
还有一个 response.json()
方法,也是远程读取数据,不过会将响应数据解析为 JSON 对象。在我们这个例子中,这更加便捷,我们稍微修改下。
为了简洁,我们使用箭头函数:
// 与上面一样, 但是 response.json() 将服务器响应数据解析称为 JSON 对象了
fetch('/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => alert(user.name)); // iliakan
现在,我们可以对加载的用户信息做些处理。
例如,我们可以向 Github 发出一个用户信息的请求,并显示处该用户的头像:
// 像 user.json 文件请求数据
fetch('/article/promise-chaining/user.json')
// Load it as json
// 将响应数据处理成 JSON 对象
.then(response => response.json())
// 向 Github 发出户信息请求
.then(user => fetch(`https://api.github.com/users/${user.name}`))
// 将响应数据处理成 JSON 对象
.then(response => response.json())
// 显示头像图片(githubUser.avatar_url), 3 秒后消失(可以使用动画)
.then(githubUser => {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => img.remove(), 3000); // (*)
});
代码照常工作了,查看注释可以看到细节描述。然而,这里出现了一个潜在问题,是开始使用 Promise 的人的常犯的典型错误。
看看 (*)
处:在头像完成展示、移除之后,我们怎么能做什么吗?比如,在头像移除之后,需要显示一个用于编辑该用户或其他信息的表单。但到目前为止,还没有办法。
为了使这个链可扩展,我们需要返回一个在头像完成展示(即移除)后返回一个 resolved 状态的 Promise。
像这样:
fetch('/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => fetch(`https://api.github.com/users/${user.name}`))
.then(response => response.json())
.then(githubUser => new Promise(function(resolve, reject) {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
}))
// 在 3 秒钟后出发
.then(githubUser => alert(`Finished showing ${githubUser.name}`));
现在,在 setTimeout
中,img.remove()
后立即调用 resolve(githubUser)
,将控制权交给链上的下一个 .next
方法(使用传递过去的用户数据)。
通常,异步操作应始终返回 Promise。
这使得计划之后的行动成为可能。即使我们现在不打算延长链条,我们也可能在以后需要它。
最后,我们可以将代码拆分为可重用的函数:
function loadJson(url) {
return fetch(url)
.then(response => response.json());
}
function loadGithubUser(name) {
return fetch(`https://api.github.com/users/${name}`)
.then(response => response.json());
}
function showAvatar(githubUser) {
return new Promise(function(resolve, reject) {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
});
}
// Use them:
loadJson('/article/promise-chaining/user.json')
.then(user => loadGithubUser(user.name))
.then(showAvatar)
.then(githubUser => alert(`Finished showing ${githubUser.name}`));
// ...
错误处理
异步操作有时会失败:一旦错误发生,对应 Promise 变为 reject 了。例如,如果从服务器 fetch 失败,我们可以使用 .catch
方法来处理错误(rejection)。
Promise 链式调用在这方面的作为比较大,当一个 Promise reject 了,控制流就会跳入到链上最近的 reject 处理器中,这在实践中非常便捷。
例如,下面代码中的 URL 是错误的(没有这个请求地址),.catch
方法就捕获了这个请求错误:
fetch('https://no-such-server.blabla') // rejects
.then(response => response.json())
.catch(err => alert(err)) // TypeError: failed to fetch (the text may vary)
或者,服务器数据能够正常返回,但是返回的不是有效的 JSON 数据:
fetch('/') // 现在能够成功 fetch,服务器会成功响应
.then(response => response.json()) // rejects: 返回的是 HTML, 不是有效的 json
.catch(err => alert(err)) // SyntaxError: Unexpected token < in JSON at position 0
在下例中,我们添加 .catch
来处理头像加载和显示过程中的所有错误:
fetch('/article/promise-chaining/user.json')
.then(response => response.json())
.then(user => fetch(`https://api.github.com/users/${user.name}`))
.then(response => response.json())
.then(githubUser => new Promise(function(resolve, reject) {
let img = document.createElement('img');
img.src = githubUser.avatar_url;
img.className = "promise-avatar-example";
document.body.append(img);
setTimeout(() => {
img.remove();
resolve(githubUser);
}, 3000);
}))
.catch(error => alert(error.message));
这里的 .catch
不会触发,因为没有产生错误。但是如果上面的任何 Promise reject 了,它就会被执行。
隐式 try…catch
执行器代码和 Promise 处理器有一个“不可见的 try..catch
”包装块。如果错误发生,就会捕获并且作为 reject Promise 对待。
例如,这段代码:
new Promise(function(resolve, reject) {
throw new Error("Whoops!");
}).catch(alert); // Error: Whoops!
等同于:
new Promise(function(resolve, reject) {
reject(new Error("Whoops!"));
}).catch(alert); // Error: Whoops!
包围执行器的“不可见 try..catch
”自动捕获错误,并作为 reject Promise 对待。
不仅是在执行器里,在处理器里也是一样的。如果我们在 .then
处理函数中 throw
了,那也表示是一个 rejected Promise,因此控制流会流入到最近的错误处理器中。
这是一个例子:
new Promise(function(resolve, reject) {
resolve("ok");
}).then(function(result) {
throw new Error("Whoops!"); // rejects the promise
}).catch(alert); // Error: Whoops!
不只是 throw
,任何错误(包括编程错误)都会:
new Promise(function(resolve, reject) {
resolve("ok");
}).then(function(result) {
blabla(); // 没有这样的函数
}).catch(alert); // ReferenceError: blabla is not defined
作为一个副作用,最终的 .catch
不仅捕获了显式的 rejection,而且上面处理器中偶尔出现的错误也会被捕获。
重抛
我们注意到,.catch
表现的像 try..catch
。只要我们想,可以出现尽可能多的 .then
,然后最后使用一个 .catch
去捕获程序里所有的错误。
在常规的 try..catch
程序里,我们可以分析错误,如果不能处理的话,还可以重新抛出。对于 Promise,我们也可以这样。如果我们在 .catch
中使用了 throw
,那么控制流就会进入到最近的错误处理器中。如果我们处理错误并正常处理完成,那么控制流将继续进入到最近的 .then
处理器中。
在下例中,.catch
就成功地处理了错误:
// 执行: catch -> then
new Promise(function(resolve, reject) {
throw new Error("Whoops!");
}).catch(function(error) {
alert("The error is handled, continue normally");
}).then(() => alert("Next successful handler runs"));
这里的 .catch
块正常结束了,因此接着调用了下一个 .then
处理器;或者可以在 .catch
中返回一个值,是一样的。
在 .catch
块中分析错误,重新抛出也可以。
// 执行流: catch -> catch -> then
new Promise(function(resolve, reject) {
throw new Error("Whoops!");
}).catch(function(error) { // (*)
if (error instanceof URIError) {
// 处理它
} else {
alert("Can't handle such error");
// 重抛这个错误或者其他错误,会将控制流流入到下一个 .then 中
}
}).then(function() {
/* 不会执行到这里 */
}).catch(error => { // (**)
alert(`发生未知错误: ${error}`);
// 不返回任何值 => 执行按正常方式走
});
(*)
处处理器捕获了错误,但并不能处理,因为它不是 URIError
,因此再一次抛出它。执行流跳到链上的下一个 .catch
中,即 (**)
处。
fetch 错误时的处理例子
我们改进一下加载用户例子中的错误处理代码。
当请求不能发出时,fetch 会返回一个 rejected Promise。例如,远程服务器不可用,或者 URL 地址无效。但是如果远程服务器响应了 404 错误,甚至是 500 错误,那么它也被认为是有效响应。
如果在 (*)
处服务器返回了 500 错误的非 JSON 页面怎么办?如果在 (**)
处,用户不存在,Github 返回一个 404 错误页面怎么办?
fetch('no-such-user.json') // (*)
.then(response => response.json())
.then(user => fetch(`https://api.github.com/users/${user.name}`)) // (**)
.then(response => response.json())
.catch(alert); // SyntaxError: Unexpected token < in JSON at position 0
// ...
到目前为止,代码试图将响应加载为 JSON,并且终止于语法错误。运行上面的示例,就可以看到结果(因为 no-such-user.json
文件并不存在)。
这是不好的,因为错误就会从链上掉下来,没有细节:什么失败了,在哪里失败的——不知道。
我们再加一步:检查 response.status
状态码,如果不是200,就抛出一个错误。
class HttpError extends Error { // (1)
constructor(response) {
super(`${response.status} for ${response.url}`);
this.name = 'HttpError';
this.response = response;
}
}
function loadJson(url) { // (2)
return fetch(url)
.then(response => {
if (response.status == 200) {
return response.json();
} else {
throw new HttpError(response);
}
})
}
loadJson('no-such-user.json') // (3)
.catch(alert); // HttpError: 404 for .../no-such-user.json
我们创建了一个自定义 HTTP 错误类型,以此来与其他错误类型区分。除此之外,新错误类型的构造函数接收
response
对象作为参数,并将其保存在错误中。然后我们将请求和错误处理代码放到一个函数中,fetch url 并且把所有非 200 状态作为错误抛出。这很方便,因为我们经常需要这样的逻辑。
现在弹出来(alert)了更好的错误提示。
使用自定义错误类,可以让我们很容易地检查和书写错误处理代码。
例如,我们可以发出一个请求,然后如果我们得到 404——就要求用户修改信息。
下面的代码使用给定的用户名,从 Github 加载用户信息;如果用户不存在,那么就会要求重新输入正确名称:
function demoGithubUser() {
let name = prompt("Enter a name?", "iliakan");
return loadJson(`https://api.github.com/users/${name}`)
.then(user => {
alert(`Full name: ${user.name}.`); // (1)
return user;
})
.catch(err => {
if (err instanceof HttpError && err.response.status == 404) { // (2)
alert("No such user, please reenter.");
return demoGithubUser();
} else {
throw err;
}
});
}
demoGithubUser();
本例中:
如果
loadJson
返回了有效的用户数据,用户名就在(1)
处展示,并且返回数据对象,因此我们可以在链上添加更多的用户相关的操作行为。这样情况,下面的.catch
子句会被忽略,很简单。否则,如果错误发生,我们会在
(2)
处检查。如果是 HttpError 类型的错误,并且返回状态码是 404(Not Found),我们会要求用户重新输入;而对于其他错误——我们不知如何处理的,就会重新抛出。
未处理的 rejection
如果一个异常未被处理,会发生什么呢?例如,上例中,我们重抛了错误,如果我们在链的末端没有添加错误处理器,类似:
new Promise(function() {
noSuchFunction(); // Error here (no such function)
}); // 没有追加 .catch 处理器
或是这样:
// 一条 .then 链条,在尾部没有 .catch 处理器
new Promise(function() {
throw new Error("Whoops!");
}).then(function() {
// ...一些代码...
}).then(function() {
// ...另一些代码...
}).then(function() {
// ...但就是没有在尾部添加 .catch 处理器!
});
一旦发生错误的话,Promise 变为“rejected”状态,执行流会进入到最近的 rejection 处理器中。但在上面代码里没有提供这样的处理器,所以的错误被“卡住了”。
实际上,这通常是因为代码不好。 确实,怎么没有错误处理?
大多数 JavaScript 引擎会跟踪这种情况,并在这种情况下产生一个全局错误。我们可以在控制台中看到它。
在浏览器中,我们可以使用 unhandledrejection
来捕捉它:
window.addEventListener('unhandledrejection', function(event) {
// 事件对象包含两个特别的属性:
alert(event.promise); // [object Promise] - 产生错误的 Promise 对象
alert(event.reason); // Error: Whoops! - 未处理的错误对象
});
new Promise(function() {
throw new Error("Whoops!");
}); // 没有 .catch 处理错误
此事件作为 HTML 标准 一部分被描述。现在如果错误发生了,没有 .catch
的话,就会触发 unhandledrejection
处理器:event
中包含错误内容,我们可以利用这些信息来执行后续的某些操作。
通常这样的错误是不可恢复的,所以我们最好的办法是通知用户知道这个问题;如果可能,还要向服务器报告此错误。
在像 Node.js 这样的非浏览器环境中,还有其他类似的方法来跟踪未处理的错误。
总结
做下总结,.then/catch(handler)
返回一个新的 Promise,它根据处理程序的不同而改变。
如果返回一个值或者没有用
return
结束(等同于return undefined
),新的 Promise 变为 resolved 状态,最近的 resolve 处理器会用返回值作为参数被调用(.then
的第一个参数)。如果抛出错误,新的 Promise 对象变成 rejected 状态,最近的 rejection 处理器(
.then
的第二个参数或者.catch
)会使用错误对象作为参数被调用。如果返回了一个 Promise,JavaScript 会一直等待它解决了(settile),然后以同样的方式结果处理。
下图展示了 .then/.catch
中返回的 Promise 是怎样引发改变的:
处理程序更加局部的放大图片:
在上面的错误处理的例子中,.catch
始终处理链条上的最后一个。实践中,并不是每个Promise 链都有 .catch
。就像普通的代码并不总是被包装在 try..catch
中。
我们应该在我们想要处理错误的地方准确地放置 .catch
,并知道如何处理它们。使用自定义错误类可以帮助分析错误并重新抛出那些我们无法处理的错误。
对于超出我们范围之外的错误,我们使用 unhandledrejection
事件处理程序(用于浏览器,其他环境有类似的替代物)来处理。这些未知的错误通常是不可恢复的,所以我们应该做的就是通知用户,如果可能,还要向服务器报告此错误。
练习题
问题
一、Promise: then 和 catch
下面两段代码是等价的吗?也就是说,在任何环境下,对于任何处理器函数,它们的表现都是一致的。
promise.then(f1, f2);
和:
promise.then(f1).catch(f2);
二、setTimout 中的错误
下面代码中,你认为会触发 .catch
吗?解释下:
new Promise(function(resolve, reject) {
setTimeout(() => {
throw new Error("Whoops!");
}, 1000);
}).catch(alert);
答案
一、Promise: then 和 catch
简单的答案是:不,它们并不等价。
不同点是,如果在 f1
中发生了错误,这里总会被 .catch
处理:
promise
.then(f1)
.catch(f2);
但是这里就不行:
promise
.then(f1, f2);
因为错误是在链上传递的,而在第二段代码中,f1 之后不再有后续的链节点了。
也就是说,.then
中的结果/错误会传递到下一个 .then/catch
中。第一段代码中,有下面的 .catch
,而在第二段里——就没有,因此错误也就没有处理了。
二、setTimout 中的错误
答案是:不会。
new Promise(function(resolve, reject) {
setTimeout(() => {
throw new Error("Whoops!");
}, 1000);
}).catch(alert);
就像本章中说的,在函数代码中,存在“隐式的 try..catch
”。因此所有的同步错误都会被处理。
但是这里的错误不是在执行处理程序时生成的,而是在之后生成的,因此 Promise 无法处理它。
(完)