JS 最强大的一面就是它能及其轻易地处理异步编程。作为因互联网而生的语言,JS 从一开始就必须能够响应点击或按键之类的用户交互行为。
Node.js 通过使用回调函数来代替事件,进一步推动了 JS 中的异步编程。
随着越来越多的程序开始使用异步编程,事件与回调函数已不足以支持开发者的所有需求。Promise正是为了解决这方面的问题。
Promise是异步编程的另一种选择,它的工作方式类似于在其他语言中延迟并在将来执行作业。一个Promise指定一些稍后要执行的代码(就像事件与回调函数一样),并且也明确标示了作业的代码是否执行成功。你能以成功处理或失败处理为基准,将Promise串联在一起。让代码更易理解、更易调试。
1.异步编程的背景
JS 引擎建立在单线程事件循环的概念上。单线程(Single-threaded)意味着同一时刻只能执行一段代码,与 Java 或 C++ 这种允许同时执行多段不同代码的多线程语言形成了反差。
多段代码可以同时访问或修改状态,维护并保护这些状态就变成了难题,这也是基于多线程的软件中出现 BUG 的常见原因之一。
JS 引擎在同一时刻只能执行一段代码,所以引擎无须留意那些“可能”运行的代码。代码会被放置在作业队列(job queue)中,每当一段代码准备被执行,它就会被添加到作业队列。
当JS引擎结束当前代码的执行后,事件循环就会执行队列中的下一个作业。事件循环(event loop)是JS引擎的一个内部处理线程,能监视代码的执行并管理作业队列。既然是一个队列,作业就会从队列中的第一个开始,依次运行到最后一个。
事件模型
当用户点击一个按钮或按下键盘上的一个键时,一个事件就被触发了,该事件可能会对此交互进行响应,从而将一个新的作业添加到作业队列的尾部。这就是 JS 关于异步编程的最基本形式。
事件处理程序直到事件发生后才会被执行,此时它会拥有合适的上下文。
1 | let btn = document.getElementById('my-btn') |
在此代码中,console.log('clicked')
直到 btn
被点击后才会被执行。当 btn
被点击,赋值给 onclick
的函数就被添加到作业队列的尾部,并在队列前部所有任务结束之后再执行。
事件可以很好地工作于简单的交互,但将多个分离的异步调用串联在一起却会很麻烦,因为必须追踪每个事件的事件对象。此外,还需要确保所有事件处理程序都能在事件第一次被触发之前被绑定完毕。
因此虽然在响应用户交互或类似的低频功能时,事件很有用,但它在面对更复杂的需求时仍然不够灵活。
回调模式
当 Node.js 被创建时,它通过普及回调函数编程模式提升了异步编程模型。
回调函数模式类似于事件模型,因为异步代码也会在后面的一个时间点才执行。不同之处在于需要调用的函数是作为参数传入的。
1 | readFile('example.txt', function (err, contents) { |
此例使用了 Node.js 惯例,即错误优先(error-first)的回调函数风格。
readFile()
函数用于读取磁盘中的文件,并在读取完毕后执行回调函数。如果存在错误,回调函数的 err
参数会是一个错误对象;否则 contents
参数就会以字符串形式包含文件内容。
使用回调函数模式,readFile()
会立即开始执行,并在开始读取磁盘时暂停。这意味着 console.log('Hi!')
会在 readFile()
被调用后立即进行输出,要早于 console.log(contents)
的打印操作。
当 readFile()
结束操作后,它会将回调函数以及相关参数作为一个新的作业添加到作业队列的尾部。在之前的作业全部结束后,该作业才会执行。
回调函数模式比事件模型更灵活,因为使用回调函数串联多个调用会相对容易。
1 | readFile('example.txt', function (err, contents) { |
在此代码中,对于 readFile()
的一次成功调用引出了另一个异步调用,即调用 writeFile()
函数。当 readFile()
执行结束后,它添加一个作业到作业队列,从而导致 writeFile()
在之后被调用。接下来, writeFile()
也会在执行结束后向队列添加一个作业。
这种模式运作的相当好,然而,当嵌套过多回调函数时,很可能会陷入回调地狱(callback hell)。
1 | method1(function (err, result) { |
像本例一样嵌套多个方法调用会创建错综复杂的代码,以至于难以理解与调试。
当想要实现更复杂的功能时,回调函数也会存在问题。比如让两个异步操作并行运行,并且在它们都结束后提醒你,或者同时启动两个异步操作,但只采用首个结束的结果。在这些情况下,就需要追踪多个回调函数并作清理操作。Promise能大幅度改善这种情况。
2.Promise 基础
Promise 是为异步操作的结果所准备的占位符。函数可以返回一个 Promise,而不必订阅一个事件或向函数传递一个回调参数。
1 | // readFile 承诺会在将来某个时间点完成 |
在此代码中, readFile()
实际上并未立即开始读取文件,这将在稍后发生。此函数反而会返回一个 Promise 对象以表示异步读取操作,因此你可以在将来再操作它。你能对结果进行操作的确切时刻,取决于Promise的生命周期是如何进行的。
Promise 的生命周期
每个 Promise 都会经历一个短暂的生命周期。初始为挂起态(pending state),这表示异步操作尚未结束。一个挂起的 Promise 也被认为是未决的(unsettled)。上个例子中的 Promise 在 readFile()
函数返回它的时候就是处在挂起态。
一旦异步操作结束,Promise 就会被认为是已决的(settled),并进入两种可能状态之一:
- 已完成(fulfilled):Promise 的异步操作已成功结束;
- 已拒绝(rejected):Promise 的异步操作未成功结束,可能是一个错误,或由其他原因导致。
内部的 [[PromiseState]]
属性会被设置为 'pending'
、'fulfilled'
或 'rejected'
,以反映 Promise 的状态。该属性并未在 Promise 对象上被暴露出来,因此无法以编程的方式判断 Promise 到底处于哪种状态。不过可以通过 then()
方法在 Promise 的状态改变时执行一些特定操作。
then()
方法在所有的 Promise 上都存在,并且接受两个参数。第一个参数是 Promise 被完成时要调用的函数,与异步操作关联的任何附加数据都会被传入这个完成函数。第二个参数则是 Promise 被拒绝时要调用的函数,与完成函数类似,拒绝函数会被传入与拒绝相关联的任何附加数据。
以这种方式实现 then()
方法的任何对象都被称为一个thenable。所有的 Promise 都是 thenable,反之则未必。
传递给 then()
的两个参数都是可选的,因此你可以监听完成与拒绝的任意组合形式。
1 | let promise = readFile('example.txt') |
这三个 then()
调用都操作在同一个 Promise 上。第一个调用同时监听了完成与失败;第二个调用只监听了完成,错误不会被报告;第三个则只监听了拒绝,并不报告成功信息。
Promise 也具有一个 catch()
方法,其行为等同于只传递拒绝处理函数给 then()
。例如,下面的 catch()
与 then()
调用是功能等效的:
1 | promise.catch(function (err) { |
then()
与 catch()
背后的意图是让你组合使用它们来正确处理异步操作的结果。此系统要优于事件与回调函数,因为它让操作是成功还是失败变得完全清晰。
即使完成或拒绝处理函数在Promise已经被解决之后才添加到作业队列,它们仍然会被执行。这允许你随时添加新的完成或拒绝处理函数,并保证它们会被调用。
1 | let promise = readFile('example.txt') |
在此代码中,完成处理函数又为同一个 Promise 添加了另一个完成处理函数。这个 Promise 此刻已经完成了,因此新的处理程序就被添加到任务队列,并在就绪时被调用。拒绝处理函数使用同样的方式工作。
创建未决的 Promise
新的 Promise 使用 Promise
构造器来创建。此构造器接受单个参数:一个被称为执行器(executor)的函数,包含初始化 Promise 的代码。
该执行器会被传递两个名为 resolve()
与 reject()
的函数作为参数。resolve()
函数在执行器成功结束时被调用,用于示意该 Promise 已经准备好被决议(resolved),而 reject()
函数则表明执行器的操作已失败。
1 | // Node.js 范例 |
在此例中,Node.js 原生的 fs.readFile()
异步调用被包装在一个 Promise 中. 执行器要么传递错误对象给 reject()
函数, 要么传递文件内容给 resolve()
函数。
执行器会在 readFile()
被调用时立即运行。当 resolve()
或 reject()
在执行器内部被调用时,一个作业被添加到作业队列中,以便决议(resolve)这个Promise。这被称为作业调度(job scheduling)。
在作业调度中,添加新作业到队列中是表示:“不要立刻执行这个作业, 但要在稍后执行它”.
Promise 工作方式与 setTimeout()
类似,Promise 的执行器会立即执行,早于源代码中在其之后的任何代码, 例如:
1 | let promise = new Promise(function(resolve, reject) { |
输出:
1 | Promise |
调用 resolve()
触发了一个异步操作。传递给 then()
与 catch()
的函数会异步地被执行,并且它们也被添加到了作业队列(先进队列再执行)。例如:
1 | let promise = new Promise(function(resolve, reject) { |
输出:
1 | Promise |
注:尽管 then()
的调用出现在 console.log('Hi!')
代码之前,它实际上稍后才会执行。这与执行器中的 console.log('Promise')
不同。这是因为完成处理函数与拒绝函数总是会在执行器的操作结束后才被添加到作业队列的尾部。
创建已决的 Promise
基于 Promise 执行器行为的动态本质,Promise 构造器就是创建未决的 Promise 的最好方式。
但若想让一个 Promise 代表一个已知的值,那么安排一个单纯传值给 resolve()
函数的作业并没有意义。相反,有两种方法可使用指定值来创建已决的 Promise
使用 Promise.resolve()
Promise.resolve()
方法接受单个参数并会返回一个处于完成态的 Promise。这意味着没有任何作业调度会发生,并且需要向 Promise 添加一个或更多的完成处理函数来提取这个参数值。例如:
1 | let promise = Promise.resolve(42) |
此代码创建了一个已完成的 Promise,因此完成处理函数就接受到 42 作为 value
参数。
若一个拒绝处理函数被添加到此 Promise,该拒绝处理函数将永不会被调用,因为此 Promise 绝不可能再是拒绝态。
使用 Promise.reject()
Promise.reject()
方法用于创建一个已拒绝的 Promise。此方法像 Promise.resolve()
一样工作,区别是被创建的 Promise 处于拒绝态。例如:
1 | let promise = Promise.reject(42) |
任何附加到这个 Promise 的拒绝处理函数都将会被调用,而完成处理函数则不会执行。
若你传递一个 Promise 给 Promise.resolve()
或 Promise.reject()
方法,该 Promise 会不做修改原样返回。
非 Promise 的 Thenable
Promise.resolve()
与 Promise.reject()
都能接受非 Promise 的 thenable 作为参数。当传入了非 Promise 的 thenable 时,这些方法会创建一个新的 Promise,此 Promise 会在 then()
函数之后被调用。
当一个对象拥有一个能接受 resolve
与 reject
参数的 then()
方法,该对象就会被认为是一个非 Promise 的 thenable。
1 | let thenable = { |
此例中的 thenable
对象,除了 then()
方法之外没有任何与 Promise 相关的特征。可以调用 Promise.then()
来将 thenable
转换为一个已完成的 Promise:
1 | let thenable = { |
在此例中,Promise.resolve()
调用了 thenable.then()
,确定了这个 thenable
的 Promise 状态:由于 resolve(42)
在 thenable.then()
方法内部被调用,这个 thenable
的 Promise 状态也就被设为已完成。
一个名为 p1
的新 Promise 被创建为完成态,并从 thenable 中接收到了值(此处为42),于是 p1
的完成处理函数就接收到一个值为 42 的参数。
使用 Promise.resolve()
,同样还能从一个 thenable 创建一个已拒绝的 Promise:
1 | let thenable = { |
此例类似于上例,区别是此处的 thenable
被拒绝了。当 thenable.then()
执行时,一个处于拒绝态的新 Promise 被创建,并伴随着一个值(42)。这个值此后会被传递给p1的拒绝处理函数。
Promise.resolve()
与 Promise.reject()
用类似方式工作,可以轻易处理非 Promise的 thenable。
执行器错误
如果在执行器内部抛出了错误,那么 Promise 的拒绝处理函数就会调用。例如:
1 | let promise = new Promise(function(resolve, reject) { |
此代码中,执行器故意抛出了一个错误。此处在每个执行器之内并没有显示的 try-catch
,因此错误就被捕捉并传递给了拒绝处理函数。这个例子等价于:
1 | let promise = new Promise(function(resolve, reject) { |
执行器处理程序捕捉了抛出的任何错误,以简化这种常见处理。但在执行器内抛出的错误仅当在拒绝处理函数时才会被报告,否则该错误就会被隐瞒。不过 JS 环境通过提供钩子(hook)来捕捉被拒绝的 Promise,从而解决了此问题。
3.全局的 Promise 拒绝处理
由于 Promise 的本质,判断一个 Promise 的拒绝是否已被处理并不直观:当一个 Promise 被拒绝时若缺少拒绝处理函数,就会静默失败。
1 | let rejected = Promise.reject(42) |
无论 Promise 是否已被解决,都可以在任何时候调用 then()
或 catch()
并使它们正确工作,这导致很难准确知道一个 Promise 何时会被处理。此例中的 Promise 被立刻拒绝,但它后来才被处理。
浏览器与 Node.js 通过变更来解决这个问题。
3.1 Node.js 的拒绝处理
在 Node.js 中,process
对象上存在两个关联到 Promise 的拒绝处理的事件:
unhandledRejection
:当一个 Promise 被拒绝、而在事件循环的一个轮次中没有任何拒绝处理函数被调用,该事件就会被触发;rejectionHandled
:若一个 Promise 被拒绝、并在事件循环的一个轮次之后再有拒绝处理函数被调用,该事件就会被触发。
这个两个事件旨在共同帮助识别已被拒绝但未曾被处理的 Promise。
unhandledRejection
事件处理函数接受的参数是拒绝原因(常常是一个错误对象)以及已被拒绝的 Promise。
1 | let rejected |
此例创建了一个带有错误对象的已被拒绝的 Promise,并监听了 unhandledRejection
事件。事件处理函数接收了该错误对象作为第一个参数,原 Promise 则是第二个参数。
rejectionHandled
事件处理函数则只有一个参数,即已被拒绝的 Promise。例如:
1 | let rejected |
此处的 rejectionHandled
事件在拒绝处理函数最终被调用时触发。若在 rejected
被创建后直接将拒绝处理函数附加到它上面,那么此事件不会被触发。因为立即附加的拒绝处理函数在 rejected
被创建的事件循环的同一个轮次内就会被调用,这样 rejectionHandled
就不会起作用。
为了正确追踪潜在的未被处理的拒绝,使用 rejectionHandled
与 unhandledRejection
事件就能保持包含这些 Promise 的一个列表,之后等待一段时间再检查此列表。例如:
1 | let possiblyUnhandledRejections = new Map(); |
对于未处理的拒绝,这只是个简单追踪器。它使用了一个 Map 来储存 Promise 及其拒绝原因。每个 Promise 都是键,而它的拒绝原因就是相关的值。每当 unhandledRejection
被触发,Promise 及其拒绝原因就会被添加到此 Map 中。
而每当 rejectionHandled
被触发,已被处理的 Promise 就会从这个 Map 中被移除。这样一来 possiblyUnhandledRejections
就会随着事件的调用而扩展或收缩。
3.2 浏览器的拒绝处理
浏览器同样能触发两个事件,来帮助识别未处理的拒绝。这两个事件会被 window
对象触发,并完全等效于 Node.js 的相关事件。
unhandledrejection
:当一个 Promise 被拒绝,而在事件循环的一个轮次中没有任何拒绝处理函数被调用,该事件就会被触发;rejectionHandled
:当一个 Promise 被拒绝,并在事件循环的一个轮次之后再有拒绝处理函数被调用,该事件就会被触发。
Node.js 的实现会传递分离的参数给事件处理函数,而浏览器事件的处理函数则只会接收到包含下列属性的一个对象:
type
:事件的名称('unhandledrejection'
或'rejectionHandled'
);promise
:被拒绝的 Promise 对象;reason
:Promise 中的拒绝值(拒绝原因)。
浏览器的实现中存在的另一个差异就是:拒绝值(reason
)在两种事件中都可用。例如:
1 | let rejected; |
以下代码在浏览器中追踪未被处理的拒绝,与 Node.js 的代码非常相似:
1 | let possiblyUnhandledRejections = new Map() |
这个实现与 Node.js 的实现几乎一模一样。使用了相同方法在 Map 中存储 Promise 及其拒绝值,并在此后进行检查。唯一的区别就是在事件处理函数中信息提取的方式。