本文是我翻译《JavaScript Concurrency》书籍的第三章 使用 Promises 实现同步,该书主要以 Promises、Generator、Web workers 等技术来讲解 JavaScript 并发编程方面的实践。
完整书籍翻译地址:https://github.com/yzsunlei/javascript_concurrency_translation。由于能力有限,肯定存在翻译不清楚甚至翻译错误的地方,欢迎朋友们提 issue 指出,感谢。
Promises 几年前就在 JavaScript 类库中实现了。这一切都始于 Promises/A+ 规范。这些类库的实现都有它们自己的形式,直到最近(确切地说是 ES6),Promises 规范才被 JavaScript 语言纳入。如标题那样 – 它帮助我们实现同步原则。
在本章中,我们将首先简单介绍 Promises 中各种术语,以便更容易理解本章的后面部分内容。然后,通过各种方式,我们将使用 Promises 来解决目前的一些问题,并让并发处理更容易。准备好了吗?
Promise 相关术语
在我们深入研究代码之前,让我们花一点时间确保我们牢牢掌握 Promises 有关的术语。有 Promise 实例,但是还有各种状态和方法。如果我们能够弄清楚 Promise 这些术语,那么后面的章节会更易理解。这些解释简短易懂,所以如果您已经使用过 Promises,您可以快速看下这些术语,就当复习下。
Promise
顾名思义,Promise 是一种承诺。将 Promise 视为尚不存在的值的代理。Promise 让我们更好的编写并发代码,因为我们知道值会在将来某个时刻存在,并且我们不必编写大量的状态检查样板代码。
状态(State)
Promises 总是处于以下三种状态之一:
• 等待:这是 Promise 创建后的第一个状态。它一直处于等待状态,直到它完成或被拒绝。
• 完成:该 Promise 值已经处理完成,并能为它提供 then()回调函数。
• 拒绝:处理 Promise 的值出了问题。现在没有数据。
Promise 状态的一个有趣特性是它们只转换一次。它们要么从等待状态到完成,要么从等待状态到被拒绝。一旦它们进行了这种状态转换,后面就会锁定在这种状态。
执行器(Executor)
执行器函数负责以某种方式解析值并将处于等待状态。创建 Promise 后立即调用此函数。它需要两个参数:resolver 函数和 rejector 函数。
解析器(Resolver)
解析器是一个作为参数传递给执行器函数的函数。实际上,这非常方便,因为我们可以将解析器函数传递给另一个函数,依此类推。调用解析器函数的位置并不重要,但是当它被调用时,Promise 会进入一个完成状态。状态的这种改变将触发 then()回调 – 这些我们将在后面看到。
拒绝器(Rejector)
拒绝器与解析器相似。它是传递给执行器函数的第二个参数,可以从任何地方调用。当它被调用时,Promise 从等待状态改变到拒绝状态。这种状态的改变将调用错误回调函数,如果有的话,会传递给 then()或 catch()。
Thenable
如果对象具有接受完成回调和拒绝回调作为参数的 then()方法,则该对象就是 Thenable。换句话说,Promise 是 Thenable。但是在某些情况下,我们可能希望实现特定的解析语义。
完成和拒绝 Promises
如果上一节刚刚介绍的几个术语听起来让你困惑,那别担心。从本节开始,我们将看到所有这些 Promises 术语的应用实践。在这里,我们将展示一些简单的 Promise 解决和拒绝的示例。
完成 Promises
解析器是一个函数,顾名思义,它完成了我们的 Promise。这不是完成 Promise 的唯一方法 – 我们将在后面探索更高级的方式。但到目前为止,这种方法是最常见的。它作为第一个参数传递给执行器函数。这意味着执行器可以通过简单地调用解析器直接完成 Promise。但这并不怎么实用,不是吗?
更常见的情况是 Promise 执行器函数设置即将发生的异步操作 – 例如拨打网络电话。然后,在这些异步操作的回调函数中,我们可以完成这个 Promise。在我们的代码中传递一个解析函数,刚开始可能感觉有点违反直觉,但是一旦我们开始使用它们就会发现很有意义。
解析器函数是一个相对 Promise 来说比较难懂的函数。它只能完成一次 Promise。我们可以调用解析器很多次,但只在第一次调用会改变 Promise 的状态。下面是一个图描述了 Promise 的可能状态; 它还显示了状态之间是如何变化的:
现在,我们来看一些 Promise 代码。在这里,我们将完成一个 promise,它会调用 then()完成回调函数:
// 我们的 Promise 使用的执行器函数。// 第一个参数是解析器函数,在 1 秒后调用完成 Promise。function executor(resolve) {setTimeout(resolve, 1000);
}
// 我们 Promise 的完成回调函数。// 这个简单地在我们的执行程序函数运行后,停止那个定时器。function fulfilled() {console.timeEnd('fulfillment');
}
// 创建 promise,并立即运行,// 然后启动一个定时器来查看调用完成函数需要多长时间。var promise = new Promise(executor);
promise.then(fulfilled);
console.time('fulfillment');
我们可以看到,解析器函数被调用时 fulfilled()函数会被调用。执行器实际上并不调用解析器。相反,它将解析器函数传递给另一个异步函数 – setTimeout()。执行器并不是我们试图去弄清楚的异步代码。可以将执行器视为一种协调程序,它编排异步操作并确定何时执行 Promise。
前面的示例未解析任何值。当某个操作的调用者需要确认它成功或失败时,这是一个有效的用例。相反,让我们这次尝试解析一个值,如下所示:
// 我们的 Promise 使用的执行函数。// 创建 Promise 后,设置延时一秒钟调用 "resolve()",// 并解析返回一个字符串值 - "done!"。function executor(resolve) {setTimeout(() => {resolve('done!');
}, 1000);
}
// 我们 Promise 的完成回调接受一个值参数。// 这个值将传递到解析器。function fulfilled(value) {console.log('resolved', value);
}
// 创建我们的 Promise,提供执行程序和完成回调函数。var promise = new Promise(executor);
promise.then(fulfilled);
我们可以看到这段代码与前面的例子非常相似。区别在于我们的解析器函数实际上是在传递给 setTimeout()的回调函数的闭包内调用的。这是因为我们正在解析一个字符串值。还有一个将被解析的参数值传递给我们的 fulfilled()函数。
拒绝 promises
Promise 执行器函数并不总是按期望进行,当出现问题时,我们需要拒绝 promise。这是从等待状态转换到另一个可能的状态。这不是进入一个完成状态而是进入一个被拒绝的状态。这会导致执行不同的回调,与完成回调函数是分开的。值得庆幸的是,拒绝 Promise 的机制与完成 Promise 非常相似。我们来看看这是如何实现的:
// 此执行器在延时一秒后拒绝 Promise。// 它使用拒绝回调函数来改变状态,// 并传递拒绝的参数值到回调函数。function executor(resolve, reject) {setTimeout(() => {reject('Failed');
}, 1000);
}
// 用作拒绝回调的函数。// 它接收提供拒绝的参数值。function rejected(reason) {console.error(reason);
}
// 创建 promise,并运行执行器。// 使用“catch()”方法来接收拒绝回调函数。var promise = new Promise(executor);
promise.catch(rejected);
这段代码看起来和在上一节中看到的代码非常相似。我们设置了超时,并且我们拒绝了它而不是完成它。这是使用 rejector 函数完成的,并作为第二个参数传递给执行器。
我们使用 catch()方法而不是 then()方法来设置拒绝回调函数。我们将在本章后面看到 then()方法如何用于同时处理完成和拒绝回调函数。此示例中的拒绝回调函数仅将失败原因打印出来。通常情况下提供此返回值很重要。当我们完成 promise 时,返回值也是常见的,尽管不是必需的。另一方面,对于拒绝函数,一般也很少有情况仅仅通过回调函数输出拒绝原因。
让我们看下另一个例子,它捕获执行器中抛出的异常,并为拒绝回调函数提供更有意义的报错原因:
// 此 promise 执行程序抛出错误,// 并调用拒绝回调函数输出错误信息。new Promise(() => {throw new Error('Problem executing promise');
}).catch((reason) => {console.error(reason);
});
// 此 promise 执行程序捕获错误,// 并调用拒绝回调函数输出更有意义的错误信息。new Promise((resolve, reject) => {
try {var size = this.name.length;} catch (error) {reject(error instanceof TypeError ? 'Missing"name"property' : error);
}
}).catch((reason) => {console.error(reason);
});
前一个例子中第一个 Promise 的有趣之处在于它确实改变了状态,即使我们没有使用 resolve()或 reject()明确地改变 promise 的状态。然而,最终改变 promise 的状态是很重要的; 我们将在下一节中探讨这个话题。
空 Promises
尽管事实上执行器函数传递了一个完成回调函数和拒绝回调函数,但并不保证 promise 将改变状态。有些情况下,promise 只是挂起,并没有触发完成回调也没有触发拒绝回调。这可能并没有什么问题,事实上,简单的 promises,就很容易发现和修复没有响应的 promises。然而,随着我们进入更复杂的场景后,一个 promise 的完成回调可以作为其他几个 promise 的回调结果。如果一个 promises 不能完成或拒绝,然后整个流程将崩溃。这种情况调试起来是非常麻烦的; 下面的图可以很清楚的看到这个情况:
在图中,我们可以看到哪个 promise 导致依赖的 promise 挂起,但通过调试代码来解决这个问题并不容易。现在让我们看看导致 promise 挂起的执行函数:
// 这个 promise 能够正常运行执行器函数。// 但“then()”回调函数永远不会被执行。new Promise(() => {console.log('executing promise');
}).then(() => {console.log('never called');
});
// 此时,我们并不知道 promise 出了什么问题
console.log('finished executing, promise hangs');
但是,是否有一种更安全的方式来处理这种不确定性呢?在我们的代码中,我们不需要挂起无需完成或拒绝的执行函数。让我们来实现一个执行器包装函数,像一个安全网那样让过长时间还没完成的 promises 执行拒绝回调函数。这将揭开解决不好处理的 promise 场景的神秘面纱:
//promise 执行器函数的包装器,// 在给定的超时时间后抛出错误。function executorWrapper(func, timeout) {
// 这是实际调用的函数。// 它需要解析器函数和拒绝器函数作为参数。return function executor(resolve, reject) {
// 设置我们的计时器。// 当时间到达时,我们可以使用超时消息拒绝 promise。var timer = setTimeout(() => {reject('Promise timed out after $ {timeout} MS');
}, timeout);
// 调用我们原来的执行器包装函数。// 我们实际上也包装了完成回调函数
// 和拒绝回调函数,所以当
// 执行者调用它们时,会清除定时器。func((value) => {clearTimeout(timer);
resolve(value);
}, (value) => {clearTimeout(timer);
reject(value);
});
};
}
// 这个 promise 执行后超时,// 超时错误消息传递给拒绝回调。new Promise(executorWrapper((resolve, reject) => {setTimeout(() => {resolve('done');
}, 2000);
}, 1000)).catch((reason) => {console.error(reason);
});
// 这个 promise 执行后按预期运行,// 在定时结束之前调用“resolve()”。new Promise(executorWrapper((resolve, reject) => {setTimeout(() => {resolve(true);
}, 500);
}, 1000)).then((value) => {console.log('resolved', value);
});
对 promises 作出改进
既然我们已经很好地理解了 promises 的执行机制,本节将详细介绍如何使用 promises 来解决特定问题。通常,这意味着当 promises 完成或被拒绝时,我们会达到我们某些目的。
我们将首先查看 JavaScript 解释器中的任务队列,以及这些对我们的解析回调函数的意义。然后,我们将考虑使用 promise 的结果数据,处理错误,创建更好的抽象来响应 promises,以及 thenables。让我们开始吧。
处理任务队列
JavaScript 任务队列的概念在“第 2 章,JavaScript 运行模型”中提到过。它的主要职责是初始化新的执行上下文堆栈。这是常见的任务队列。然而,还有另一种队列,这是专用于执行 promises 回调的。这意味着,如果他们都存在时,算法会从这些队列中选择一个任务执行。
Promises 具有内置的并发语义,而且有充分的理由。如果一个 promise 被用来确保某个值最终被解析,那么为对其作出响应的代码赋予高优先级是有意义的。否则,当值到达时,处理它的代码可能还要在其他任务后面等待很长的时间才能执行。让我们编写一些代码来演示下这些并发语义:
// 创建 5 个 promise,记录它们的执行时间,// 以及当他们对返回值做出响应的时间。for (let i = 0; i < 5; i++) {new Promise((resolve) => {console.log('execting promise');
resolve(i);
}).then((value) => {console.log('resolved', i);
});
}
// 在任何 promise 完成回调之前,这里会先被调用,// 因为堆栈任务需要在解释器进入 promise 解析回调队列之前完成,// 当前 5 个“then()”回调将被置后。console.log('done executing');
//→
//execting promise
//execting promise
// ...
//done executing
//resolved 1
//resolved 2
// ...
拒绝回调也遵循同样的语义。
使用 promise 的返回数据
到目前为止,我们已经在本章中看到了一些示例,其中解析器函数完成 promise 后并返回值。传递给此函数的值是最终传递给完成回调函数的值。通过让执行程序设置任何异步操作的方法,例如 setTimeout(),延时传递该值调用解析程序。但在这些例子中,调用者实际上并没有等待任何值;我们只使用 setTimeout()作为示例异步操作。让我们看一下我们实际上没有值的情况,异步网络请求需要获取到它:
// 用于从服务器获取资源的通用函数,// 返回一个 promise。function get(path) {return new Promise((resolve, reject) => {var request = new XMLHttpRequest();
//promise 解析数据加载后的 JSON 数据。request.addEventListener('load', (e) => {resolve(JSON.parse(e.target.responseText));
});
// 当请求出错时,promise 执行拒绝回调函数。request.addEventListener('error', (e) => {reject(e.target.statusText || '未知错误');
});
// 如果请求被中止时,我们调用完成回调函数
request.addEventListener('abort', resolve);
request.open('get', path);
request.send();});
}
// 我们可以直接附加我们的“then()”处理程序
// 到“get()”,因为它返回一个 promise。// 在解析之前,这里使用的值是一个真正的异步操作,// 因为必须发请求远程获取值。get('api.json').then((value) => {console.log('hello', value.hello);
});
使用像 get()这样的函数,它们不仅始终返回像 promise 一样的原生类型,而且还封装了一些让人讨厌的异步细节。在我们的代码中处理 XMLHttpRequest 对象并不令人愉快。我们已经简化了可以返回的各种情况。而不是总是必须为 load,error 和 abort 事件创建处理程序,我们只需要关心一个接口 – promise。这就是同步并发原则的全部内容。
错误回调
有两种方法可以对被拒绝的 promise 做出处理。换句话说,提供错误回调。第一种方法是使用 catch()方法,该方法使用单一回调函数。另一种方法是将被拒绝的回调函数作为 then()的第二个参数传递。
将 then()方法用来处理拒绝回调函数在某些情况下表现的更好,它应该被用来替代 catch()函数。第一个场景是编写 promises 和 thenable 对象可以互换的代码。catch()方法不是 thenable 必要的一部分。第二个场景是当我们建立回调链时,我们将在本章后面探讨。
让我们看一些代码,它们比较了两种为 promises 提供拒绝回调函数的方法:
// 这个 promise 执行器将随机执行完成回调或拒绝回调
function executor(resolve, reject) {
cnt++;
Math.round(Math.random()) ?
resolve(`fulfilled promise ${cnt}`) :
reject(`rejected promise ${cnt}`);
}
// 让“log()”和“error()”函数作为简单回调函数
var log = console.log.bind(console),
error = console.error.bind(console),
cnt = 0;
// 创建一个 promise,然后通过“catch()”方法传入拒绝回调。new Promise(executor).then(log).catch(error);
// 创建一个 promise,然后通过“then()”方法传入拒绝回调。new Promise(executor).then(log, error);
我们可以看到这两种方法实际上非常相似。在代码美观上,也没有哪个有真正的优势。然而,当涉及到使用 thenables 时,then()方法有一个优势,我们后面会看到。但是,由于我们实际上并没有以任何方式使用 promise 实例,除了添加回调之外,实际上没有必要担心 catch()和 then()用于注册拒绝回调。
始终响应
Promises 最终总是结束于完成状态或拒绝状态。我们通常为每个状态传入不同的回调函数。但是,我们很可能希望为这两个状态执行一些相同的操作。例如,如果使用 promise 的组件在 promise 等待时更改状态,我们要确保在完成或拒绝 promise 后清除状态。
我们可以用这样的方式编写代码:完成和拒绝状态的每个回调都去执行这些操作,或者他们每个都可以调用执行一些公用的清理函数。下面这种方式的示图:
将清理任务分配给 promise 是否有意义,而不是将其分配给其它个别结果?这样,在解析 promise 时运行的回调函数专注于它需要对值执行的操作,而拒绝回调则专注于处理错误。让我们看看是否可以使用 always()方法编写一些扩展 promises 的代码:
// 在 promise 原型上扩展使用“always()”方法。// 不管 promise 是完成还是拒绝,始终会调用给定的函数。Promise.prototype.always = function(func) {return this.then(func, func);
};
// 创建 promise 随机完成或被拒绝。var promise = new Promise((resolve, reject) => {Math.round(Math.random()) ?
resolve('fullfilled') : reject('rejected');
});
// 传递 promise 完成和拒绝回调。promise.then((value) => {console.log(value);
}, (reason) => {console.error(reason);
});
// 这个回调函数总是会在上面的回调执行之后调用。promise.always((value) => {console.log('cleaning up...');
});
请注意,在这里顺序很重要。如果我们在 then()之前调用 always(),那么函数仍然会运行,但它会在
回调提供给 then()之前运行。我们实际上可以在 then()之前和之后都调用 always(),以便在完成或拒绝回调
之前以及之后运行代码。
处理其他 promises
到目前为止,我们在本章中看到的大多数 promise 都是由执行程序函数直接完成的,或者是当值准备完成时从异步操作中调用解析器的结果。像这样传递回调函数实际上非常灵活。例如,执行程序甚至不必执行任何任务,除了将解析器函数存储在某处以便稍后调用它来解析 promise。
当我们发现自己处于需要多个值的更复杂的同步场景时,这可能特别有用,这些值已经被传递给调用者。如果我们有处理回调函数,我们就可以处理 promise。让我们看看,在存储代码的解析函数的多个 promises,使每一个 promise 都可以在后面处理:
// 存储一系列解析器函数的列表。var resolvers = [];
// 在执行器中创建 5 个新的 promise,
// 解析器被推到了“resolvers”数组。// 我们可以给每一个 promise 执行回调。for(let i = 0; i < 5; i++) {new Promise(() => {resolvers.push(resolve);
}).then((value) => {console.log(`resolved ${i + 1}`, value);
});
}
// 设置一个 2s 之后延时运行函数,// 当它运行时,我们遍历“解析器”数组中的每一个解析器函数,// 并且传入一个返回值来调用它。setTimeout(() => {for(resolver of resolvers) {resolver(true);
}
}, 2000);
正如这个例子所表明的那样,我们不必在 executor 函数内处理它们。事实上,我们甚至不需要在创建和设置执行程序和完成函数之后显式引用 promise 实例。解析器函数已存储在某处,它包含对 promise 的引用。
类 Promise 对象
Promise 类是一种原生的 JavaScript 类型。但是,我们并不总是需要创建新的 promise 实例来实现相同的同步操作。我们可以使用静态 Promise.resolve()方法来解析这些对象。让我们看看如何使用此方法:
//“Promise.resolve()”方法可以处理 thenable 对象。// 这是一个带有“then()”方法的类似于执行器的对象。// 这个执行器将随机完成或拒绝 promise。Promise.resolve({then: (resolve, reject) => {Math.round(Math.random()) ? resolve('fulfilled') : reject('rejected');
// 这个方法返回一个 promise,所以我们能够
// 设置已完成和被拒绝的回调函数。}}).then((value) => {console.log('resolved', value);
}, (reason) => {console.error('reason', reason);
});
我们将在本章的最后一节中再次讨论 Promise.resolve()方法,以了解更多用例。
建立回调链
我们在本章前面介绍的每种 promise 方法都会返回 promise。这允许我们在返回值上再次调用这些方法,从而产生 then().then()调用的链,依此类推。链式 promise 具有挑战性的一个方面是 promise 方法返回的是新实例。也就是说,我们将在本节中探讨 promise 在一定程度上的不变性。
随着我们的应用程序变得越来越大,并发性挑战随之增加。这意味着我们需要考虑更好的方法来利用原生同步语义,例如 promises。正如 JavaScript 中的任何其他原始值一样,我们可以将它们从函数传递给函数。我们必须以同样的方式处理 promises – 传递它们,并建立在回调函数链上。
Promises 只改变状态一次
Promise 初始时是等待状态,并且它们结束于已完成或被拒绝的状态。一旦 promise 转变为其中一种状态,它们就会锁定在这种状态。这有两个有趣的副作用。
首先,多次尝试完成或拒绝 promise 将被忽略。换句话说,解析器和拒绝器是幂等的 – 只有第一次调用对 promise 有影响。让我们看看这代码如何执行:
// 此执行器函数尝试解析 promise 两次,// 但完成的回调只调用一次。new Promise((resolve, reject) => {resolve('fulfilled');
resolve('fulfilled');
}).then((value) => {console.log('then', value);
});
// 这个执行器函数尝试拒绝 promise 两次,// 但拒绝的回调只调用一次。new Promise((resolve, reject) => {reject('rejected');
reject('rejected');
}).catch((reason) => {console.error('reason');
});
promises 仅改变状态一次的另一个含义是 promise 可以在添加完成或拒绝回调之前处理。竞争条件,例如这个,是并发编程的残酷现实。通常,回调函数会在创建时添加到 promise 中。由于 JavaScript 是运行到完成的,因此在添加回调之前,不会处理 promise 解析回调的任务队列。但是,如果 promise 立即在执行中解析怎么办?如果将回调添加到另一个 JavaScript 执行上下文的 promise 中会怎样?让我们看看是否可以用一些代码来更好地说明这些情况:
// 此执行器函数立即解析 promise。添加“then()”回调时,//promise 已经解析了。但回调函数仍然会使用已解析的值进行调用。new Promise((resolve, reject) => {resolve('done');
console.log('executor', 'resolved');
}).then((value) => {console.log('then', value);
});
// 创建一个立即解析的新 promise 执行器函数。var promise = new Promise((resolve, reject) => {resolve('done');
console.log('executor', 'resolved');
});
// 这个回调是 promise 解析后就立即执行了。promise.then((value) => {console.log('then 1', value);
});
// 此回调在 promise 解析后未添加到另一个的 promise 中,// 它仍然被立即调用并获得已解析的值。setTimeout(() => {promise.then((value) => {console.log('then 2', value);
});
}, 1000);
此代码说明了 promises 的一个非常重要的特性。无论何时将执行回调添加到 promise 中,无论是处于暂时挂起状态还是解析状态,使用 promise 的代码都不会更改。从表面上看,这似乎不是什么大不了的事。但是这种竞争条件检查的类型需要更多的并发代码来保护自己。相反,Promise 原生语法为我们处理这个问题,我们可以开始将异步值视为原始类型。
不可改变的 promises
promises 并非真正不可改变。它们改变状态,then()方法将回调函数添加到 promise。但是,有一些不可改变的 promises 特征值得在这里讨论,因为它们会在某些情况下影响我们的 promise 代码。
从技术上讲,then()方法实际上并没有改变 promise 对象。它创建了所谓的 promise 能力,它是一个引用 promise 的内部 JavaScript 记录,以及我们添加的函数。因此,它不是 JavaScript 语言中的真正语法。
这是一张图,说明当我们链接两个或更多 then()一起调用时会发生什么:
我们可以看到,then()方法不会返回与上下文一起调用的相同实例。相反,then()创建一个新的 promise 实例并返回它。让我们看一些代码,来进一步的说明当我们使用 then()将 promises 链接在一起时会发生的事情:
// 创建一个立即解析的 promise,// 并且存储在“promise1”中。var promise1 = new Promise((resolve, reject) => {resolve('fulfilled');
});
// 使用“promise1”的“then()”方法创建一个
// 新的 promise 实例,存储在“promise2”中。var promise2 = promise1.then((value) => {console.log('then 1', value);
//→then 1 fulfilled
});
// 为“promise2”创建一个“then()”回调。这实际上
// 创建第三个 promise 实例,但我们不用它做任何事情。promise2.then((value) => {console.log('then 2', value);
//→then 2 undefined
});
// 确信“promise1”和“promise2”实际上是不同的对象
console.log('equal', promise1 === promise2);
//→equal false
我们可以清楚地看到这两个创建 promise 的实例在这个例子中是独立的 promise 对象。值得指出的是第二个 promise 执行前时,一定是它执行了第一个 promise。但是,我们可以看到的是该值不会传递到第二个 promise。我们将在下一节中解决此问题。
有多少个 then()回调,就有多少个 promise 对象
正如我们在上一节中看到的那样,使用 then()创建的 promise 将绑定到它们的创建者。也就是说,当第一个 promise 完成时,绑定它的 promise 也会完成,依此类推。但是,我们也发现了一个小问题。已解析的值不会使其传递到第一个回调函数。这样做的原因是为响应 promise 解析而运行的每个回调都是第一个回调的返回值被送入第二个回调,依此类推。我们的第一个回调将值作为参数的原因是因为这在 promise 机制中显然会发生的。
我们来看看另一个 promise 链示例。这一次,我们将显式返回回调函数中的值:
// 创建一个新 promise 随机调用解析回调或拒绝回调。new Promise((resolve, reject) => {Math.round(Math.random()) ?
resolve('fulfilled') : reject('rejected');
}).then((value) => {
// 在完成原始 promise 时调用返回值,// 以防另一个 promise 链接到这一个。console.log('then 1', value);
return value;
}).catch((reason) => {
// 链接到第二个 promise,// 当拒绝回调时执行。console.error('catch 1', reason);
}).then((value) => {
// 链接到第三个 promise,// 按预期得到值,并返回值给任何下个 promise 回调使用。console.log('then 2', value);
return value;
}).catch((reason) => {
// 这里永不会被调用,// 拒绝回调不会通过 promise 链传递。console.error('catch 2', reason);
});
这看起来不错。我们可以看到已解析的值通过 promise 链传递。有一个异常 – 拒绝回调不会向后传递。相反,只有链中的第一个 promise 拒绝回调会执行。其余的 promise 回调只是完成,而不是拒绝。这意味着最后一个 catch()回调永远不会运行。
当我们以这种方式将 promise 链接在一起时,我们的执行回调函数需要能够处理错误条件。例如,已解析的值可能具有 error 属性,可以检查其具体问题。
promises 传递
在本节中,我们讲讲 promise 作为原始值的用法。我们经常用原始值做的事情是将它们作为参数传递给函数,并从函数中返回它们。promise 和其他原生语法之间的关键区别在于我们如何使用它们。其他值是始终都存在,而 promise 的值到未来某个时间点才存在。因此,我们需要通过回调函数定义一些操作过程,当值获得时去执行。
promises 的好处是用于提供这些回调函数的接口小巧且一致。当我们将值与将作用于它的代码耦合时,我们不需要再去自主创造同步机制。这些单元可以像任何其他值一样在我们的应用程序中运用,并且并发语义是常见的。这是几个 promise 函数相互传递的示图:
在这个函数堆栈调用结束时,我们得到一个完成几个 promise 的解析的 promise 对象。整个 promise 链是从第一个 promise 完成而开始的。比如何遍历 promise 链的机制更重要的是所有这些函数都可以自由使用这个 promise 传递的值而不影响其他函数。
在这里有两个并发原则。首先,我们通过执行异步操作仅只能处理该值一次; 每个回调函数都可以自由使用此解析值。其次,我们在抽象同步机制方面做得很好。换句话说,代码并没有带有很多重复代码。让我们看看传递 promise 的代码实际的样子:
// 简单实用的工具函数,// 将多个较小的函数组合成一个函数。function compose(...funcs) {return function(value) {
var result = value;
for(let func of funcs) {result = func(value);
}
return result;
};
}
// 接受一个 promise 或一个完成值。// 如果这是一个 promise,它添加了一个“then()”回调并返回一个新的 promise。// 否则,它会执行“update”并返回值。function updateFirstName(value) {if (value instanceof Promise) {return value.then(updateFirstName);
}
console.log('first name', value.first);
return value;
}
// 与上面的函数类似,// 只是它执行不同的 UI“update”。function updateLastName(value) {if (value instanceof Promise) {return value.then(updateLastName);
}
console.log('last name', value.last);
return value;
}
// 与上面的函数类似,除了它
// 只是它执行不同的 UI“update”。function updateAge(value) {if (value instanceof Promise) {return value.then(updateAge);
}
console.log('age', value.age);
return value;
}
// 一个 promise 对象,// 它在延时一秒钟之后,// 携带一个数据对象完成 promise。var promise = new Promise((resolve, reject) => {setTimeout(() => {
resolve({
first: 'John',
last: 'Smith',
age: 37
});
});
}, 1000);
// 我们组装一个“update()”函数来更新各种 UI 组件。var update = compose(
updateFirstName,
updateLastName,
updateAge
);
// 使用 promise 调用我们的更新函数。update(promise);
这里的关键函数是我们的更新函数 – updateFirstName(),updateLastName()和 updateAge()。他们非常灵活,接受一个 promise 或 promise 返回值。如果这些函数中的任何一个将 promise 作为参数,它们会通过添加 then()回调函数来返回新的 promise。请注意,它添加了相同的函数。updateFirstName()将添加 updateFirstName()作为回调。当回调触发时,它将与此次用于更新 UI 的普通对象一起使用。因此,promise 如果失败,我们可以继续更新 UI。
promise 检查每个函数都需要三行,这并不是非常突兀的。最终结果是易读且灵活的代码。顺序无关紧要; 我们可以用不同的顺序包装我们的 update()函数,并且 UI 组件都将以相同的方式更新。我们可以将普通对象直接传递给 update(),一切都会同样执行。看起来不像并发代码的并发代码是我们在这里取得的重大成功。
同步多个 promises
在本章前面,我们已经探究了单个 promise 实例,它解析一个值,触发回调,并可能传递给其他 promises 处理。在本节中,我们将介绍几种静态 Promise 方法,它们可以帮助我们处理需要同步多个 promise 值的情况。
首先,我们将处理我们开发的组件需要同步访问多个异步资源的情况。然后,我们将看一下不常见的情况,如异步操作在处理之前由于 UI 中发生的事件而变得没有意义。
等待 promises
在我们等待处理多个 promise 的情况下,也许是将多个数据源转换后提供给一个 UI 组件使用,我们可以使用 Promise.all()方法。它将 promise 实例的集合作为输入,并返回一个新的 promise 实例。仅当完成了所有输入的 promise 时,才会返回一个新实例。
then()函数是我们为 Promise 提供的创建新 promise 的回调。给出一组解析值作为输入。这些值对应于索引输入 promise 的位置。这是一个非常强大的同步机制,它可以帮助我们实现同步并发原则,因为它隐藏了所有的处理记录。
我们不需要几个回调,让每个回调都协调它们所绑定的 promise 状态,我们只需一个回调,它具有我们需要的所有解析数据。这个示例展示如何同步多个 promise:
// 用于发送“GET”HTTP 请求的工具函数,// 并返回带有已解析的数据的 promise。function get(path) {return new Promise((resolve, reject) => {var request = new XMLHttpRequest();
// 当数据加载时,完成解析了 JSON 数据的 promise
request.addEventListener('load', (e) => {resolve(JSON.parse(e.target.responseText));
});
// 当请求出错时,//promise 被适当的原因拒绝。request.addEventListener('error', (e) => {reject(e.target.statusText || 'unknown error');
});
// 如果请求被中止,我们继续完成处理请求
request.addEventListener('abort', resolve);
request.open('get', path);
request.send();});
}
// 保存我们的请求 promises。var requests = [];
// 发出 5 个 API 请求,并将相应的 5 个
//promise 放在“requests”数组中。for (let i = 0; i < 5; i++) {requests.push(get('api.json'));
}
// 使用“Promise.all()”让我们传入一个数组 promises,// 当所有 promise 完成时,返回一个已经完成的新 promise。// 我们的回调得到一个数组对应于 promises 的已解析值。Promise.all(requests).then((values) => {console.log('first', values.map(x => x[0]));
console.log('second', values.map(x => x[1]));
});
取消 promises
到目前为止,我们在本书中已看到的 XHR 请求具有中止请求的处理程序。这是因为我们可以手动中止请求并阻止任何 load 回调函数运行。需要此功能的典型场景是用户单击取消按钮,或导航到应用程序的其他部分,从而使请求变得毫无意义。
如果我们是要在抽象 promise 上更上一层楼,在同样的原则也适用。而一些可能发生的并发操作的执行让 promise 变得毫无意义。promises 和 XHR 请求的过程中之间的区别,是前者没有 abort()方法。最后我们要做的一件事是在我们的 promise 回调中开始引入可能并不必要的取消逻辑。
Promise.race()方法在这里可以帮助我们。顾名思义,该方法返回一个新的 promise,它由第一个要解析的输入 promise 决定。这可能你听的不多,但实现 Promise.race()的逻辑并不容易。它实际上是同步原则,隐藏了应用程序代码中的并发复杂性。我们来看看这个方法是怎么可以帮助我们处理因用户交互而取消的 promise:
// 用于取消数据请求的解析器函数。var cancelResolver;
// 一个简单的“常量”值,用于处理取消 promise
var CANCELED = {};
// 我们的 UI 组件
var buttonLoad = document.querySelector('button.load'),
buttonCancel = document.querySelector('button.cancel');
// 请求数据,返回一个 promise。function getDataPromise() {
// 创建取消 promise。// 执行器传入“resolve”函数为“cancelResolver”,// 所以它稍后可以被调用。var cancelPromise = new Promise((resolve) => {cancelResolver = resolve;});
// 我们实际想要的数据
// 这通常是一个 HTTP 请求,// 但我们在这里使用 setTimeout()简单模拟一下。var dataPromise = new Promise((resolve) => {setTimeout(() => {resolve({hello: 'world'});
}, 3000);
});
//“Promise.race()”方法返回一个新的 promise,// 并且无论输入 promise 是什么,它都可以完成处理
return Promise.race([cancelPromise, dataPromise]);
}
// 单击取消按钮时,我们使用
//“cancelResolver()”函数来处理取消 promise
buttonCancel.addEventListener('click', () => {cancelResolver(CANCELLED);
});
// 单击加载按钮时,我们使用
//“getDataPromise()”发出请求获取数据。buttonLoad.addEventListener('click', () => {
buttonLoad.disabled = true;
getDataPromise().then((value) => {
buttonLoad.disabled = false;
//promise 得到了执行,但那是因为
// 用户取消了请求。所以我们这里
// 通过返回 CANCELED“constant”退出。// 否则,我们有数据可以使用。if (Object.is(value, CANCELED)) {return value;}
console.log('loaded data', value);
});
});
作为练习,尝试想象一个更复杂的场景,其中 dataPromise 是由 Promise.all()创建的 promise。我们的
cancelResolver() 函数可以一次取消许多复杂的异步操作。
没有执行器的 promises
在最后一节中,我们将介绍 Promise.resolve()和 Promise.reject()方法。我们已经在本章前面看到 Promise.resolve()如何处理 thenable 对象。它还可以直接处理值或其他 promises。当我们实现一个可能同步也可能异步的函数时,这些方法会派上用场。这不是我们想要使用具有模糊并发语义函数的情况。
例如,这是一个可能同步也可能异步的函数,让人感到迷惑,几乎肯定会在以后出现错误:
// 一个示例函数,它可能从缓存中返回“value”,// 也可能通过“fetchs”异步获取值。function getData(value) {
// 如果它存在于缓存中,我们直接返回这个值
var index = getData.cache.indexOf(value);
if(index > -1) {return getData.cache[index];
}
// 否则,我们必须通过“fetch”异步获取它。// 这个“resolve()”调用通常是会在网络发起请求的回调函数
return new Promise((resolve) => {getData.cache.push(value);
resolve(value);
});
}
// 创建缓存。getData.cache = [];
console.log('getting foo', getData('foo'));
//→getting foo Promise
console.log('getting bar', getData('bar'));
//→getting bar Promise
console.log('getting foo', getData('foo'));
//→getting foo foo
我们可以看到最后一次调用返回的是缓存值,而不是一个 promise。这很直观,因为我们不需要通过 promise 获取最终的值,我们已经拥有这个值!问题是我们让使用 getData()函数的任何代码表现出不一致性。也就是说,调用 getData()的代码需要处理并发语义。此代码不是并发的。让我们通过引入 Promise.resolve()来改变它:
// 一个示例函数,它可能从缓存中返回“value”,// 也可能通过“fetchs”异步获取值。function getData(value) {
var cache = getData.cache;
// 如果这个函数没有缓存,// 那就拒绝 promise。if(!Array.isArray(cache)) {return Promise.reject('missing cache');
}
// 如果它存在于缓存中,// 我们直接使用缓存的值返回完成的 promise
var index = getData.cache.indexOf(value);
if (index > -1) {return Promise.resolve(getData.cache[index]);
}
// 否则,我们必须通过“fetch”异步获取它。// 这个“resolve()”调用通常是会在网络发起请求的回调函数
return new Promise((resolve) => {getData.cache.push(value);
resolve(value);
});
}
// 创建缓存。getData.cache = [];
// 每次调用“getData()”返回都是一致的。// 甚至当使用同步值时,// 它们仍然返回得到解析完成的 promise。getData('foo').then((value) => {console.log('getting foo', `“${value}”`);
}, (reason) => {console.error(reason);
});
getData('bar').then((value) => {console.log('getting bar', `“${value}”`);
}, (reason) => {console.error(reason);
});
getData('foo').then((value) => {console.log('getting foo', `“${value}”`);
}, (reason) => {console.error(reason);
});
这样更好。使用 Promise.resolve()和 Promise.reject(),任何使用 getData()的代码默认都是并发的,即使数据获取操作是同步的。
小结
本章介绍了 ES6 中引入的 Promise 对象的大量细节内容,以帮助 JavaScript 程序员处理困扰该语言多年的同步问题。大量的使用异步回调,这会产生回调地狱,因而我们要尽量避免它。
Promise 通过实现一个足以解决任何值的通用接口来帮助我们处理同步问题。promise 总是处于三种状态之一 – 等待,完成或拒绝,并且它们只会改变一次状态。当这些状态发生改变时,将触发回调。promise 有一个执行器函数,其作用是设置使用 promise 的异步操作 resolver 函数或 rejector 函数来改变 promise 的状态。
promise 带来的大部分价值在于它们如何帮助我们简化复杂的场景。因为,如果我们只需处理一个运行带有解析值回调的异步操作,那么使用 promises 就不值得。这是不常见的情况。常见的情况是几个异步操作,每个操作都需要解析返回值; 并且这些值需要同步处理和转换。Promises 有方法帮助我们这样做,因此,我们能够更好地将同步并发原则应用于我们的代码。
在下一章中,我们将介绍另一个新引入的语法 – Generator。与 promises 类似,生成器是帮助我们应用另一个并发原则的机制 – 保护。