jQuery的Deferred对象概述

很久以来,JavaScript 开发者们习惯用回调函数的方式来执行一些任务。最常见的例子就是利用 addEventListener() 函数来添加一个回调函数, 用来在指定的事件(如 click keypress)被触发时,执行一系列的操作。回调函数简单有效——在逻辑并不复杂的时候。遗憾的是,一旦页面的复杂度增加,而你因此需要执行很多并行或串行的异步操作时,这些回调函数会让你的代码难以维护。

ECMAScript 2015(又名 ECMAScript 6) 引入了一个原生的方法来解决这类问题:promises。如果你还不清楚 promise 是什么,可以阅读这篇文章《Javascript Promise概述》。jQuery 则提供了独具一格的另一种 promises,叫做 Deferred 对象。而且 Deferred 对象的引入时间要比 ECMAScript 引入 promise 早了好几年。在这篇文章里,我会介绍 Deferred 对象和它试图解决的问题是什么。

Deferred对象简史

Deferred 对象是在 jQuery 1.5 中引入的,该对象提供了一系列的方法,可以将多个回调函数注册进一个回调队列里、调用回调队列,以及将同步或异步函数执行结果的成功还是失败传递给对应的处理函数。从那以后,Deferred 对象就成了讨论的话题, 其中不乏批评意见,这些观点也一直在变化。一些典型的批评的观点如《你并没有理解 Promise 》《论 Javascript 中的 Promise 以及 jQuery 是如何把它搞砸的》

Promise 对象 是和 Deferred 对象一起作为 jQuery 对 Promise 的一种实现。在 jQuery1.x 和 2.x 版本中, Deferred 对象遵守的是《CommonJS Promises 提案》中的约定,而 ECMAScript 原生 promises 方法的建立基础《Promises/A+ 提案》也是以这一提案书为根基衍生而来。所以就像我们一开始提到的,之所以 Deferred 对象没有遵循《Promises/A+ 提案》,是因为那时后者根本还没被构想出来。

由于 jQuery 扮演的先驱者的角色以及后向兼容性问题,jQuery1.x 和 2.x 里 promises 的使用方式和原生 Javascript 的用法并不一致。此外,由于 jQuery 自己在 promises 方面遵循了另外一套提案,这导致它无法兼容其他实现 promises 的库,比如 Q library

不过即将到来的 jQuery 3 改进了 同原生 promises(在 ECMAScript2015 中实现)的互操作性。虽然为了向后兼容,Deferred 对象的主要方法之一(then())的方法签名仍然会有些不同,但行为方面它已经同 ECMAScript 2015 标准更加一致。

jQuery中的回调函数

举一个例子来理解为什么我们需要用到 Deferred 对象。使用 jQuery 时,经常会用到它的 ajax 方法执行异步的数据请求操作。我们不妨假设你在开发一个页面,它能够发送 ajax 请求给 GitHub API,目的是读取一个用户的 Repository 列表、定位到最近更新一个 Repository,然后找到第一个名为“README.md”的文件并获取该文件的内容。所以根据以上描述,每一个请求只有在前一步完成后才能开始。换言之,这些请求必须依次执行

上面的描述可以转换成伪代码如下(注意我用的并不是真正的 Github API):

如你所见,使用回调函数的话,我们需要反复嵌套来让 ajax 请求按照我们希望的顺序执行。当代码里出现许多嵌套的回调函数,或者有很多彼此独立但需要将它们同步的回调时,我们往往把这种情形称作“回调地狱 ( callback hell )“。

为了稍微改善一下,你可以从我创建的匿名函数中提取出命名函数。但这帮助并不大,因为我们还是在回调的地狱中,依旧面对着回调嵌套和同步的难题。这时是 Deferred Promise 对象上场的时候了。

Deferred和Promise对象

Deferred 对象可以被用来执行异步操作,例如 Ajax 请求和动画的实现。在 jQuery 中,Promise对象是只能由Deferred对象或 jQuery 对象创建。它拥有 Deferred 对象的一部分方法:always(),done(), fail(), state()then()。我们在下一节会讲到这些方法和其他细节。

如果你来自于原生 Javascript 的世界,你可能会对这两个对象的存在感到迷惑:为什么 jQuery 有两个对象(Deferred Promise)而原生JS 只有一个(Promise)? 在我著作的书《jQuery 实践(第三版)》里有一个类比,可以用来解释这个问题。

Deferred 对象通常用在从异步操作返回结果的函数里(返回结果可能是 error,也可能为空)——即结果的生产者函数里。而返回结果后,你不想让读取结果的函数改变 Deferred 对象的状态(译者注:包括 Resolved 解析态,Rejected 拒绝态),这时就会用到 promise 对象——即 Promise 对象总在异步操作结果的消费者函数里被使用。

为了理清这个概念,我们假设你需要实现一个基于 promise 的timeout()函数(在本文稍后会展示这个例子的代码)。你的函数会等待指定的一段时间后返回(这里没有返回值),即一个生产者函数而这个函数的对应消费者们并不在乎操作的结果是成功(解析态 resolved)还是失败(拒绝态 rejected),而只关心他们需要在 Deferred 对象的操作成功、失败,或者收到进展通知后紧接着执行一些其他函数。此外,你还希望能确保消费者函数不会自行解析或拒绝 Deferred对象。为了达到这一目标,你必须在生产者函数timeout()中创建 Deferred 对象,并只返回它的 Promise 对象,而不是 Deferred对象本身。这样一来,除了timeout()函数之外就没有人能够调用到resolve()reject()进而改变 Deferred 对象的状态了。

在这个 StackOverflow 问题 里你可以了解到更多关于 jQuery 中 Deferred 和 Promise 对象的不同。

既然你已经了解里这两个对象,让我们来看一下它们都包含哪些方法。

Deferred对象的方法

Deferred 对象相当灵活并提供了你可能需要的所有方法,你可以通过调用 jQuery.Deferred() 像下面一样创建它:

或者,使用 $ 作为 jQuery 的简写:

创建完 Deferred 对象后,就可以使用它的一系列方法。处了已经被废弃的 removed 方法外,它们是:

  • always(callbacks[, callbacks, ..., callbacks]): 添加在该 Deferred 对象被解析或被拒绝时调用的处理函数
  • done(callbacks[, callbacks, ..., callbacks]): 添加在该 Deferred 对象被解析时调用的处理函数
  • fail(callbacks[, callbacks, ..., callbacks]): 添加在该 Deferred 对象被拒绝时调用的处理函数
  • notify([argument, ..., argument]):调用 Deferred 对象上的 progressCallbacks 处理函数并传递制定的参数
  • notifyWith(context[, argument, ..., argument]): 在制定的上下文中调用 progressCallbacks 处理函数并传递制定的参数。
  • progress(callbacks[, callbacks, ..., callbacks]): 添加在该 Deferred 对象产生进展通知时被调用的处理函数。
  • promise([target]): 返回 Deferred 对象的 promise 对象。
  • reject([argument, ..., argument]): 拒绝一个 Deferred 对象并以指定的参数调用所有的failCallbacks处理函数。
  • rejectWith(context[, argument, ..., argument]): 拒绝一个 Deferred 对象并在指定的上下文中以指定参数调用所有的failCallbacks处理函数。
  • resolve([argument, ..., argument]): 解析一个 Deferred 对象并以指定的参数调用所有的 doneCallbackswith 处理函数。
  • resolveWith(context[, argument, ..., argument]): 解析一个 Deferred 对象并在指定的上下文中以指定参数调用所有的doneCallbacks处理函数。
  • state(): 返回当前 Deferred 对象的状态。
  • then(resolvedCallback[, rejectedCallback[, progressCallback]]): 添加在该 Deferred 对象被解析、拒绝或收到进展通知时被调用的处理函数

从以上这写方法的描述中,我想突出强调一下 jQuery 文档和 ECMAScript 标准在术语上的不同。在 ECMAScript 中, 不论一个 promise 被完成 (fulfilled) 还是被拒绝 (rejected),我们都说它被解析 (resolved) 了。然而在 jQuery 的文档中,被解析这个词指的是 ECMAScript 标准中的完成 (fulfilled) 状态。

由于上面列出的方法太多, 这里无法一一详述。不过在下一节会有几个展示 Deferred Promise 用法的示例。第一个例子中我们会利用Deferred 对象重写“ jQuery 的回调函数”这一节的代码。第二个例子里我会阐明之前讨论的生产者消费者这个比喻。

利用 Deferred 依次执行 Ajax 请求

这一节我会利用Deferred对象和它提供的方法使“jQuery 的回调函数”这一节的代码更具有可读性。但在一头扎进代码之前,让我们先搞清楚一件事:在 Deferred 对象现有的方法中,我们需要的是哪些。

根据我们的需求及上文的方法列表,很明显我们既可以用 done() 也可以通过 then() 来处理操作成功的情况,考虑到很多人已经习惯了使用JS 的原生 Promise 对象,这个示例里我会用 then() 方法来实现。要注意 then() done() 这两者之间的一个重要区别是 then() 能够把接收到的值通过参数传递给后续的 then(),done(),fail() progress() 调用。

所以最后我们的代码应该像下面这样:

如你所见,由于我们能够把整个操作拆分成同在一个缩进层级的各个步骤,这段代码的可读性已经显著提高了。

创建一个基于 Promise 的 setTimeout 函数

你可能已经知道 setTimeout() 函数可以在延迟一个给定的时间后执行某个回调函数,只要你把时间和回调函数作为参数传给它。假设你想要在一秒钟后在控制台打印一条日志信息,你可以用它这样写:

如你所见,setTimeout 的第一个参数是要执行的回调函数,第二个参数是以毫秒为单位的等待时间。这个函数数年以来运转良好,但如果现在你需要在 Deferred 对象的方法链中引入一段时间的延时该怎么做呢?

下面的代码展示了如何用 jQuery 提供的 Promise 对象创建一个基于 promise 的 setTimeout(). 为了达到我们的目的,这里用到了 Deferred对象的 promise() 方法。

代码如下:

这段代码里定义了一个名为 timeout() 的函数,它包裹在 JS 原生的 setTimeout() 函数之外。

timeout() 里, 创建了一个 Deferred 对象来实现在延迟指定的毫秒数之后将 Deferred 对象解析(Resolve)的功能。这里 timeout() 函数是值的生产者,因此它负责创建 Deferred 对象并返回 Promise 对象。这样一来调用者(消费者)就不能再随意解析或拒绝 Deferred 对象。事实上,调用者只能通过 done() fail() 这样的方法来增加值返回时要执行的函数。

jQuery 1.x/2.x同 jQuery3 的区别

在第一个例子里,我们使用 Deferred 对象来查找名字包含“README.md”的文件, 但并没有考虑文件找不到的情况。这种情形可以被看成是操作失败,而当操作失败时,我们可能需要中断调用链的执行并直接跳到程序结尾。很自然地,为了实现这个目的,我们应该在找不到文件时抛出一个异常,并用 fail() 函数来捕获它,就像 Javascriopt 的 catch() 的用法一样。

在遵守 Promises/A 和 Promises/A+ 的库里(例如jQuery 3.x),抛出的异常会被转换成一个拒绝操作 (rejection),进而通过 fail() 方法添加的失败条件回调函数会被执行,且抛出的异常会作为参数传给这些函数。

在 jQuery 1.x 和 2.x中, 没有被捕获的异常会中断程序的执行。这两个版本允许抛出的异常向上冒泡,一般最终会到达 window.onerror。而如果没有定义异常的处理程序,异常信息就会被显示,同时程序也会停止运行。

为了更好的理解这一行为上的区别,让我们看一下从我书里摘出来的这一段代码:

jQuery 3.x 中, 这段代码会在控制台输出“第一个失败条件函数” 和 “第二个成功条件函数”。原因就像我前面提到的,抛出异常后的状态会被转换成拒绝操作进而失败条件回调函数一定会被执行。此外,一旦异常被处理(在这个例子里被失败条件回调函数传给了第二个then()),后面的成功条件函数就会被执行(这里是第三个 then() 里的成功条件函数)。

在 jQuery 1.x 和 2.x 中,除了第一个函数(抛出错误异常的那个)之外没有其他函数会被执行,所以你只会在控制台里看到“未处理的异常:一条错误信息。”

你可以到下面两个JSBin链接中查看它们的执行结果的不同:

为了更好的改善它同 ECMAScript2015 的兼容性,jQuery3.x 还给 Deferred Promise 对象增加了一个叫做 catch() 的新方法。它可以用来定义当 Deferred 对象被拒绝或 Promise 对象处于拒绝态时的处理函数。它的函数签名如下:

可以看出,这个方法不过是 then(null, rejectedCallback) 的一个快捷方式罢了。

总结

这篇文章里我介绍了 jQuery 实现的 promises。Promises 让我们能够摆脱那些用来同步异步函数的令人抓狂的技巧,同时避免我们陷入深层次的回调嵌套之中。

除了展示一些示例,我还介绍了 jQuery 3 在同原生 promises 互操作性上所做的改进。尽管我们强调了 jQuery 的老版本同ECMAScript2015 在 Promises 实现上有许多不同,Deferred 对象仍然是你工具箱里一件强有力的工具。作为一个职业开发人员,当项目的复杂度增加时,你会发现它总能派上用场。

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

任选一种支付方式

1 5 收藏 评论

关于作者:HansDo

游走于Web前后端,一直在野路子上摸索着。对美术和数学有心无力(・-・*),尽其所能做一个生产者。 个人主页 · 我的文章 · 18 ·    

可能感兴趣的话题



直接登录
跳到底部
返回顶部