十一、掌握异步任务
我希望大多数 web 开发人员都知道异步操作的概念。事实上,我已经在前面的章节中演示了一些这样的任务。不可否认,当这个概念在本章之前出现时,我选择了避免详细探讨。这正是本章的目标——涵盖 web 开发世界中异步任务的复杂性、挑战和重要性。尽管 jQuery 为管理异步任务提供了足够的支持,但是我将向您展示 jQuery 在这种情况下是多么的有限,web API 是如何超越 jQuery 的,以及 jQuery 是如何未能正确地实现一个基本的异步任务管理模式。
为了让我们在这一章中保持一致,让我们明确一下中心主题的精确定义。异步调用是带外处理的调用。还不清楚?抱歉,我们再试一次。异步调用不会立即返回请求的结果。嗯,这还是有点模糊,不是吗?好吧,不如这样:异步调用使你的代码更加难以管理和发展。最后一个是开玩笑的,但它可能是许多人持有的真实观点(而且理所应当如此)。
我不能用每个人都能理解的方式描述异步任务,这部分是我的错,但这表明用 JavaScript 管理这些类型的操作是多么困难。根据我的经验,正确使用和管理异步操作是 web 开发人员面临的最令人沮丧的任务之一。如果我提供的定义不够充分,也许一些真实世界的例子会澄清一些事情。
那么有哪些异步任务的实际例子呢?在日常的 web 开发中,我们会在哪里遇到这个概念呢?实际上有相当多的操作本质上是异步的。例如:
- 处理对 AJAX 请求的响应。
- 读取文件或数据块。
- 请求用户输入。
- 在浏览上下文之间传递消息。
最常见的异步任务涉及 AJAX 请求。可以理解,客户机/服务器通信是异步的。当您使用XMLHttpRequest
或fetch
从浏览器向服务器端点发送请求时,服务器可能会在几毫秒内做出响应,甚至可能在请求发起后几秒钟内做出响应。关键是,在您发送请求之后,您的应用不会简单地冻结,直到请求返回。代码继续执行。用户继续能够操作该页面。因此,您必须注册一个函数,一旦服务器正确地响应了您的请求,浏览器就会调用这个函数。
没有人确切地知道用于处理服务器响应的函数何时会被执行,这是“没问题的”您的应用和代码的结构必须考虑到这样一个事实,即这些数据在未来的某个未知时刻才会可用。事实上,一些逻辑可能依赖于这个响应,您的代码将需要相应地编写。管理单个请求或异步操作可能并不特别困难。但是在规模上,随着大量请求和其他异步任务同时进行,或者一系列异步任务相互依赖,这就变得非常棘手。
一般来说,API 受益于对异步操作的支持。考虑一个当用户选择要上传的文件时执行所提供的函数的库。该功能可以通过返回false
来阻止文件上传。但是如果函数必须委托给服务器端点来确定文件是否有效呢?也许必须在客户端对文件进行哈希处理,然后必须检查服务器以确保不存在重复的文件。这说明了两个异步操作:读取文件(生成散列),然后联系服务器检查重复项。如果这个库的 API 不支持这种类型的任务,那么集成就会受到一定程度的限制。
另一个例子:一个库维护一个联系人列表。用户可以通过<button>
删除联系人。在实际删除联系人之前显示确认对话框是很常见的。我们的库提供了一个在删除操作发生之前被调用的函数,如果这个函数返回false
,那么它可以被忽略。如果你想要一个确认对话框,在用户响应之前停止代码的执行,你可以使用浏览器内置的确认对话框,然后在用户选择取消操作时返回false
,但是本地的确认对话框非常简单难看。对于大多数项目来说,这不是一个理想的选择,所以你需要提供你自己风格的对话框,这将是无阻塞的。换句话说,库将需要考虑等待用户决定他们是否真的确定该文件应该被永久删除的异步特性。这只是在构建 API 时考虑异步支持的重要性的两个例子,但是还有更多。
本章讨论了处理异步调用的传统方法和一些相对较新的方法,但是我还介绍了另一种可以归类为“前沿”的解决方案本章包含一个新规范的原因是为了向您说明处理 web 的异步本质变得多么重要,以及 JavaScript 的维护者如何尽最大努力使一个传统上很难管理的概念变得更加容易。
回调:控制异步操作的传统方法
为异步任务提供支持的最常见方式是通过回调函数系统。让我们以联系人列表库为例,应用一个回调函数来说明这样一个事实:一个beforeDelete
处理函数可能需要请求用户确认联系人移除,这是一个异步操作(假设我们不依赖内置的window.confirm()
对话框)。我们的代码可能看起来像这样:
1 contactsHelper.register('beforeDelete', function(contact, callback) {
2 confirmModel.open(
3 'Delete contact ' + contact.name + '?',
4 function(result) {
5 callback({cancel: result.cancel});
6 });
7 });
当用户单击联系人旁边的删除按钮时,将调用传递给“beforeDelete”处理程序的函数。要删除的联系人和一个回调函数一起传递给这个函数。如果要忽略删除操作,必须将一个属性设置为true
的对象传递给这个回调函数。否则,callback
将被调用,其值为cancel
属性的false
。该库将在尝试删除联系人之前“等待”该呼叫。请注意,这种“等待”并不涉及阻塞 UI 线程,因此所有其他代码都可以继续执行。
我假设有一个带有open
功能的模态对话框组件,向用户显示删除确认对话框。用户输入的结果被传递到提供给open
函数的另一个回调函数中。如果用户点击对话框中的取消按钮,传递给这个回调函数的result
对象将包含一个值为true
的cancel
属性。此时,将调用传递给“beforeDelete”回调函数的回调函数,表明要删除的文件实际上不应该被删除。
注意前面的代码是如何依赖于许多不同的约定——许多非标准的约定。事实上,没有任何与回调函数相关的标准。传递给回调的一个或多个值是函数供应商定义的契约的一部分。在这种情况下,模式回调和“beforeDelete”回调之间的约定非常相似,但情况可能并不总是如此。虽然回调是一种简单且得到良好支持的处理异步结果的方法,但是这种方法的一些问题您可能已经很清楚了。
Node.js 和错误优先回调
我没有花太多时间讨论 Node.js,但它在本书中定期出现。第三章的非浏览器部分详细介绍了这个非常流行的基于 JavaScript 的服务器端系统。Node.js 长期依赖回调来支持跨 API 的异步行为。事实上,它已经普及了一种非常特殊的回调系统:“错误优先”回调。这种特殊的约定在 Node.js 中非常普遍,可以在许多主要的库中找到,比如 Express、 1 Socket。IO, 2 并请求。它可以说是所有回调系统中最“标准”的,尽管当然没有真正的标准,只是约定,尽管有些约定比其他的更受欢迎。
如您所料,错误优先回调要求将错误作为第一个参数传递给所提供的回调函数。通常,这个错误参数应该是一个Error
对象。从 1997 年发布第一个 ECMAScript 规范开始,Error
对象就一直是 JavaScript 的一部分。Error
对象可以在异常情况下抛出,或者作为描述应用错误的标准方式传递。使用错误优先回调,如果相关操作以某种方式失败,可以将一个Error
对象作为第一个参数传递给回调。如果操作成功,应该将null
作为第一个参数传递。这使得回调函数本身很容易确定操作的状态。如果相关任务没有失败,后续的参数将用于向回调函数提供相关信息。
如果你不完全清楚这一点,不要担心。在本节的其余部分中,您将看到错误优先回调的作用,并且当通过回调系统支持异步任务时,错误优先回调是发出错误信号或传递所请求信息的最优雅的方式。
用回调解决常见问题
让我们来看一个简单的模块示例,它要求用户输入电子邮件地址(这是一个异步操作):
1 function askForEmail(callback) {
2 promptForText('Enter email:', function(result) {
3 if (result.cancel) {
4 callback(new Error('User refused to supply email.'));
5 }
6 else {
7 callback(null, result.text);
8 }
9 })
10 }
11
12 askForEmail(function(err, email) {
13 if (err) {
14 console.error('Unable to get email: ' + err.message);
15 }
16 else {
17 // save the `email` with the user's account record
18 }
19 });
你能弄清楚前面代码的流程吗?当调用最终要求用户提供电子邮件地址的函数时,错误优先回调作为唯一的参数传入。如果用户拒绝提供,一个带有情况描述的Error
将作为第一个参数传递给我们的错误优先回调。回调记录了这一点并继续前进。否则,err
参数是null
,它向回调函数发出信号,表明我们确实收到了来自用户的有效响应——电子邮件地址——它包含在错误优先回调的第二个参数中。
回调的另一个实际用途是处理 AJAX 请求的结果。从 jQuery 的第一个版本开始,就可以在 AJAX 请求成功时提供回调函数。第九章演示了用 jQuery 获取请求(以及其他)。下面是第一个 GET 请求的另一个版本:
1 $.get('/my/name', function (name) {
2 console.log('my name is ' + name);
3 });
第二个参数是一个成功回调函数,如果请求成功,jQuery 将使用响应数据调用该函数。但是这个例子只处理成功。如果请求失败了怎么办?另一种编写成功和失败的方法是传递一个包含 URL、成功和失败回调函数的对象:
1 $.get({
2 url: '/my/name',
3 success: function(name) {
4 console.log('my name is ' + name);
5 },
6 error: function() {
7 console.error('Name request failed!');
8 }
9 });
AJAX 请求一章中的同一节演示了在没有 jQuery 的情况下进行这个调用。这种适用于所有浏览器的解决方案也依赖回调来表示成功和失败:
1 var xhr = new XMLHttpRequest();
2 xhr.open('GET', '/my/name');
3 xhr.onload = function() {
4 if (xhr.status >= 400) {
5 console.error('Name request failed!');
6 }
7 else {
8 console.log('my name is ' + xhr.responseText);
9 }
10 };
11 xhr.onerror = function() {
12 console.error('Name request failed!');
13 };
14 xhr.send();
如果请求已经从服务器发送了响应,则调用onload
回调。相反,如果请求无法发送,或者服务器无法响应,则使用onerror
回调。回调显然是注册异步任务结果的合理方式。对于简单的情况来说确实如此。但是对于更复杂的场景,回调系统变得不那么吸引人了。
承诺:异步复杂性的答案
在我讨论回调的替代方法之前,谨慎的做法可能是首先指出一些与依赖回调来管理异步任务相关的问题。上一节描述的回调系统的第一个基本缺陷在支持这种约定的每个方法或函数签名中都很明显。当调用利用回调来表示异步操作成功或失败的函数时,必须将此回调作为方法参数提供。该方法使用的任何输入值也必须作为参数传递。在这种情况下,您现在通过方法参数传递输入值并管理方法的输出。这就有点不直观和尴尬了。这个回调契约也排除了任何返回值。同样,所有工作都是通过方法参数完成的。
回调的另一个问题是:没有标准,只有约定。每当您发现自己需要调用一个异步执行某些逻辑的方法并期望回调来管理这个过程时,它可能会期望错误优先回调,但也可能不会。你怎么可能知道?由于没有回调的标准,您必须参考 API 文档并传递适当的回调。也许您必须与多个库接口,所有库都期望回调来管理异步结果,每个库都依赖于不同的回调方法约定。有些人可能期望错误优先回调。当调用所提供的回调函数时,其他函数可能会在别处包含一个错误或状态标志。有些甚至可能根本不考虑误差!
也许回调的最大问题在它们被强制使用时变得很明显。例如,考虑几个必须顺序运行的异步任务,每个后续任务都依赖于前一个任务的结果。为了演示这样一个场景,假设您需要向一个端点发送一个 AJAX 请求来加载用户 id 列表,然后必须向服务器发出一个请求来加载列表中第一个用户的个人信息。在这之后,用户的信息被显示在屏幕上进行编辑,最后修改后的记录被发送回服务器。整个过程包括四个异步任务,每个任务都依赖于前一个任务的结果。我们如何用回调来建模这个工作流?它并不漂亮,但可能看起来像这样:
1 function updateFirstUser() {
2 getUserIds(function(error, ids) {
3 if (!error) {
4 getUserInfo(ids[0], function(error, info) {
5 if (!error) {
6 displayUserInfo(info, function(error, newInfo) {
7 if (!error) {
8 updateUserInfo(id, info, function(error) {
9 if (!error) {
10 console.log('Record updated!');
11 }
12 else {
13 console.error(error);
14 }
15 });
16 }
17 else {
18 console.error(error);
19 }
20 });
21 }
22 else {
23 console.error(error);
24 }
25 });
26 }
27 else {
28 console.error(error);
29 }
30 });
31 }
32
33 updateFirstUser();
类似前面的代码通常被称为回调地狱。每个回调函数必须嵌套在前一个函数中,以便利用其结果。如您所见,回调系统的伸缩性不是很好。让我们看另一个例子来进一步证实这个结论。这一次,我们需要将在三个单独的 AJAX 请求中为一个产品提交的三个文件并发地发送到三个单独的端点。我们需要知道所有请求何时完成,以及这些请求中是否有一个或多个失败。不管结果如何,我们都需要通知用户结果。如果我们坚持使用错误优先回调,我们的解决方案有点脑筋急转弯:
1 function sendAllRequests() {
2 var successfulRequests = 0;
3
4 function handleCompletedRequest(error) {
5 if (error) {
6 console.error(error);
7 }
8 else if (++successfulRequests === 3) {
9 console.log('All requests were successful!');
10 }
11 }
12
13 sendFile('/file/docs', pdfManualFile, handleCompletedRequest);
14 sendFile('/file/images', previewImage, handleCompletedRequest);
15 sendFile('/file/video', howToUseVideo, handleCompletedRequest);
16 }
17
18 sendAllRequests();
这段代码并不可怕,但是我们必须创建自己的系统来跟踪这些并发操作的结果。如果我们必须跟踪三个以上的异步任务会怎么样?肯定有更好的办法!
第一个利用异步的标准化方法
依赖回调惯例带来的缺陷和低效常常促使开发人员寻找其他解决方案。当然,一些问题,以及这种异步处理方法中常见的样板文件,可以通过打包成一个更标准化的 API 来解决。Promises 规范定义了一个 API 来实现这个目标,甚至更多。
承诺已经在 JavaScript 前沿公开讨论了一段时间。第一个类似承诺的提议(我能找到的)是由克里斯·科瓦尔创造的。追溯到 2011 年中期,它描述了“可行的承诺”。 4 引言中的几行文字很好地展示了承诺的力量:
- 异步承诺松散地表示函数的最终结果。一个解析既可以用一个值“实现”,也可以用一个原因“拒绝”,以此类推,分别对应于同步返回值和抛出的异常。
这个松散的提议部分地用于形成 Promises/A+规范。 5 这个规范有很多实现,其中很多可以在各种 JavaScript 库中看到,比如蓝鸟、 6 Q、 7 和 rsvp.js. 8 但是也许更重要的实现出现在 ECMA- 262 第六版规范中。 9 记得从第三章中得知,ECMA-262 标准定义了 JavaScript 语言规范。该规范第 6 版于 2015 年正式完成。在撰写本文时,该标准中定义的 Promise 对象可以在所有现代浏览器中本机使用,但 Internet Explorer 除外。幸运的是,许多轻质多孔填料可以填补这一空白。
利用承诺简化异步操作
那么承诺到底是什么?您可以通读 ECMAScript 2015 或 A+规范,但像大多数正式语言规范一样,这些规范都有点枯燥和令人费解。首先,在 ECMAScript 的上下文中,承诺是用于管理异步操作结果的对象。它平滑了复杂应用中由传统的基于约定的回调留下的所有粗糙边缘。
既然承诺的首要目标已经很清楚了,让我们更深入地看看这个概念。更深入地探索承诺的第一个合乎逻辑的地方是通过多梅尼克·德尼科拉的“国家和命运”一文。 10 从这份文件中,我们得知承诺有三种状态:
- 挂起:相关操作结束之前的初始状态
- 已完成:承诺监控的关联操作已经完成,没有错误
- 拒绝:相关操作已达到错误条件
Domenic 接着定义了一个术语,它将“满足”和“拒绝”状态组合在一起:已解决。所以,一个承诺最初是悬而未决的,然后一旦达成就解决了。
本文件中还定义了两种不同的“命运”:
- 已解决:当一个承诺被履行或拒绝时,或者当它被重定向以遵循另一个承诺时,该承诺被解决。当将异步承诺返回操作链接在一起时,可以看到后一种情况的一个例子。(稍后将有更多内容。)
- 未解决:如您所料,这意味着相关的承诺尚未解决。
如果你能理解这些概念,你就非常接近掌握承诺,你会发现使用 A+和 ECMA-262 规范中定义的 API 要容易得多。
对承诺的剖析
JavaScript promise 只是通过构造一个符合 A+的Promise
对象的new
实例来创建的,例如 ECMAScript 2015 规范中详细描述的。Promise
构造函数接受一个参数:一个函数。这个函数本身有两个参数,这两个函数都赋予承诺一个确定的“命运”(如前一节所述)。这两个函数参数中的第一个是一个“完成的”函数。这将在关联的异步操作成功完成时调用。当调用“已完成”函数时,应该传递一个与约定任务的完成相关的值。例如,如果使用一个Promise
来监控一个 AJAX 请求,那么一旦请求成功完成,服务器响应就会被传递给这个“已完成”的函数。如前所述,当一个fulfilled
函数被调用时,承诺会呈现一个“履行”状态。
传递给Promise
构造函数的第二个参数是一个“拒绝”函数。当约定任务由于某种原因失败时,应该调用这个函数,描述失败的原因应该传递到这个被拒绝的函数中。通常,这将是一个Error
对象。如果在Promise
构造函数内部抛出异常,这将自动导致调用“拒绝”函数,抛出的Error
作为参数传递。回到 AJAX 请求示例,如果请求失败,应该调用“reject”函数,传递结果的字符串描述,或者可能是 HTTP 状态代码。当一个reject
函数被调用时,承诺会呈现一个“被拒绝”的状态,如前面给出的承诺状态列表中的第 3 条所述。
当一个函数返回一个Promise
时,调用者可以用几种不同的方式“观察”结果。处理约定返回值的最常见方式是在 promise 实例上调用一个then
方法。这个方法有两个参数,都是函数。如果履行了相关的承诺,则调用第一个功能参数。正如预期的那样,如果一个值与这个实现相关联(比如一个 AJAX 请求的服务器响应),它将被传递给第一个函数。如果承诺以某种方式失败,则调用第二个函数参数。如果你只对实现感兴趣,你可以省略第二个参数(尽管假设你的承诺会成功通常是不安全的)。此外,如果您只对承诺拒绝感兴趣,您可以指定一个值null
或undefined
,或者任何不被认为是“可调用”的值 11 作为第一个参数。除此之外,还可以让您专门关注错误情况,那就是对返回的Promise
调用catch
方法。这个catch
方法有一个参数:当/如果相关的 promise 出错时调用的函数。
ECMAScript 2015 Promise
对象包括其他几个有用的方法,但其中一个更有用的非实例方法是all()
,它允许您一次监控许多承诺。all
方法返回一个新的Promise
,如果所有被监控的承诺都被履行,则该新的Promise
被履行,或者一旦其中一个被监控的承诺被拒绝,则该方法被拒绝。Promise.race()
方法与Promise.all()
非常相似,不同的是race()
返回的Promise
在第一个被监控的Promise
完成时立即完成。它不会等待所有被监控的Promise
实例首先被完成。race()
的一个用途也适用于 AJAX 请求。假设您正在触发一个 AJAX 请求,该请求将相同的数据保存到多个冗余端点。重要的是一个请求的成功,在这种情况下,Promise.race()
比等待所有请求用Promise.all()
完成更合适也更有效。
简单的承诺示例
如果前一节不足以向您正确介绍 JavaScript 承诺,那么几个代码示例应该可以让您明白。前面,我提供了几个代码块,演示了使用回调处理异步任务结果。第一个概述了提示用户在对话框中输入电子邮件地址的功能——一个异步任务。错误优先回调系统用于处理成功和不成功的结果。同样的例子可以改写成利用承诺:
1 function askForEmail() {
2 return new Promise(function(fulfill, reject) {
3 promptForText('Enter email:', function(result) {
4 if (result.cancel) {
5 reject(new Error('User refused to supply email.'));
6 }
7 else {
8 fulfill(result.text);
9 }
10 });
11 });
12 }
13
14 askForEmail().then(
15 function fulfilled(emailAddress) {
16 // do something with the `emailAddress`...
17 },
18 function rejected(error) {
19 console.error('Unable to get email: ' + error.message);
20 }
21 );
在前面为支持承诺而重写的示例中,我们的代码更加具有声明性和直观性。askForEmail()
函数返回一个Promise
,描述“向用户索要电子邮件”任务的结果。当调用这个函数时,我们可以直观地处理提供的电子邮件地址和没有提供电子邮件的实例。注意,我们仍然假设promptForText()
函数 API 不变,但是如果这个函数也返回一个承诺,代码可以进一步简化:
1 function askForEmail() {
2 return promptForText('Enter email:');
3 }
4
5 askForEmail().then(
6 function fulfilled(emailAddress) {
7 // do something with the `emailAddress`...
8 },
9 function rejected(error) {
10 console.error('Unable to get email: ' + error.message);
11 }
12 );
如果promptForText()
返回一个Promise
,如果提供了地址,它应该将用户输入的电子邮件地址传递给完成的函数,或者如果用户没有输入电子邮件地址就关闭对话框,它应该将一个描述性错误传递给被拒绝的函数。这些实现细节在上面是不可见的,但是基于Promise
规范,这是我们可以预期的。
回调部分的另一个例子演示了由XMLHttpRequest
提供的onload
和onerror
回调。简单回顾一下,onload
在请求完成时被调用(不管服务器响应状态代码),而onerror
在请求由于某种原因(比如由于 CORS 或其他网络问题)未能完成时被调用。正如第九章提到的,Fetch API 带来了对XMLHttpRequest
的替代,它利用了特定的Promise
来表示 AJAX 请求的结果。我将很快深入到一个使用fetch
的更复杂的例子中,但是首先,让我们编写一个包装器来包装来自回调部分的XMLHttpRequest
调用,它使用 promises 提供了一个更优雅的接口:
1 function get(url) {
2 return new Promise(function(fulfill, reject) {
3 var xhr = new XMLHttpRequest();
4 xhr.open('GET', url);
5 xhr.onload = function() {
6 if (xhr.status >= 400) {
7 reject('Name request failed w/ status code ' + xhr.status);
8 }
9 else {
10 fulfill(xhr.responseText);
11 }
12 }
13 xhr.onerror = function() {
14 reject('Name request failed!');
15 }
16 xhr.send();
17 });
18 }
19
20 get('/my/name').then(
21 function fulfilled(name) {
22 console.log('Name is ' + name);
23 },
24 function rejected(error) {
25 console.error(error);
26 }
27 );
虽然用Promise
包装的XMLHttpRequest
并没有简化太多代码,但是它给了我们一个很好的机会来概括这个 GET 请求,这使得它更加可重用。此外,我们使用这个新的 GET 请求方法的代码很容易理解,可读性很好,也很优雅。成功和失败两种情况都很容易考虑,管理这种情况所需的逻辑封装在Promise
构造函数中。当然,如果没有Promise
,我们也可以创建类似的方法,但是这种异步任务处理机制是一种公认的 JavaScript 语言标准,这一事实使得它更有吸引力。
通过依赖 Fetch API,同样的 AJAX 请求逻辑可以更好地利用Promise
API(用于 Firefox、Chrome、Opera 和 Edge ):
1 function get(url) {
2 return fetch(url).then(
3 function fulfilled(response) {
4 if (response.ok) {
5 return response.text();
6 }
7 throw new Error('Request failed w/ status code ' + response.status);
8 }
9 );
10 }
11
12 get('/my/name').then(
13 function fulfilled(name) {
14 console.log('Name is ' + name);
15 },
16 function rejected(error) {
17 console.error(error);
18 }
19 );
在这里,我们已经能够在 promises 和fetch
的帮助下大大简化 GET name 请求。如果服务器在其响应中指示一个不成功的状态,或者如果请求根本没有发送,那么rejected
处理程序将被命中。否则,用响应文本(用户名)触发fulfilled
函数处理程序。困扰 XHR 版本的许多样板文件被完全避免了。
用承诺修复“回调地狱”
早些时候,我演示了回调的许多问题之一,它出现在涉及连续依赖异步任务的重要情况中。这个特定的例子需要检索系统中的所有用户 ID,然后检索第一个返回的用户 ID 的用户信息,然后在一个对话框中显示用于编辑的信息,然后用更新的用户信息回调服务器。这说明了四个独立但相互依赖的异步调用。处理这个问题的第一次尝试利用了几个嵌套的回调,这导致了金字塔式的代码解决方案——回调地狱。承诺是这个问题的一个优雅的解决方案,由于能够链接承诺,回调地狱被完全避免了。看一看利用了Promise
API 的重写解决方案:
1 function updateFirstUser() {
2 getUserIds()
3 .then(function(ids) {
4 return getUserInfo(ids[0]);
5 })
6 .then(function(info) {
7 return displayUserInfo(info);
8 })
9 .then(function(updatedInfo) {
10 return updateUserInfo(updatedInfo.id, updatedInfo);
11 })
12 .then(function() {
13 console.log('Record updated!');
14 })
15 .catch(function(error) {
16 console.error(error);
17 });
18 }
19
20 updateFirstUser();
这很容易理解!异步操作的流程可能也很明显。以防万一,我会告诉你的。我为四个then
块中的每一个贡献了一个完整的函数来处理特定的成功的异步操作。如果任何一个异步调用失败,最后的catch
块将被调用。注意,catch
不是 A+ Promise 规范的一部分,尽管它是 ECMAScript 2015 Promise
规范的一部分。
每个异步操作——getUserIds()
、getUserInfo()
、displayUserInfo()
和updateUserInfo()
——返回一个Promise
。每个异步操作返回的完成值Promise
可用于后续链接的then
块上的完成函数。没有更多的金字塔,没有更多的回调地狱,一个简单而优雅的方法来处理任何调用过程中的失败。
用承诺监控多个相关的异步任务
还记得本节开头的回调示例吗?该示例说明了一种并发处理三个独立端点的三个独立 AJAX 请求的方法。我们需要知道所有请求何时完成,以及是否有一个或多个请求失败。这个解决方案并不难看,但是很冗长,而且包含了大量的样板文件,如果我们经常遇到这种情况,这些文件可能会变得很麻烦。我推测这个问题一定有更好的解决方案,而且确实有!Promise
API 允许一个更加优雅的解决方案,特别是使用all
方法,它允许我们轻松地监控所有三个异步任务,并在它们都成功完成或其中一个失败时做出反应。看一看重写的承诺化代码:
1 function sendAllRequests() {
2 Promise.all([
3 sendFile('/file/docs', pdfManualFile, handleCompletedRequest),
4 sendFile('/file/images', previewImage, handleCompletedRequest),
5 sendFile('/file/video', howToUseVideo, handleCompletedRequest)
6 ]).then(
7 function fulfilled() {
8 console.log('All requests were successful!');
9 },
10 function rejected(error) {
11 console.error(error);
12 }
13 )
14 }
15
16 sendAllRequests();
前面的解决方案假设sendFile()
返回一个Promise
。有了这一点,监控这些请求变得更加直观,并且几乎没有回调示例中的所有样板文件和模糊性。Promise.all
获取一组Promise
实例并返回一个新的Promise
。当传递给all
的所有Promise
对象被满足时,这个新返回的Promise
被满足,或者如果这些传递的Promise
对象中的一个被拒绝,它被拒绝。这正是我们正在寻找的,而Promise
API 为我们提供了这种支持。
jQuery 的失信实现
本章中几乎所有的代码都专注于对 JavaScript 自带的异步任务的支持。本章的其余部分将遵循类似的模式。这主要是因为 jQuery 没有提供强大的异步支持。在这方面,ECMA- 262 标准远远领先于 jQuery。但是因为这本书旨在从以 jQuery 为中心的角度向那些人解释 web API 和 JavaScript,我觉得至少在这一部分提到 jQuery 是重要的,因为它确实支持承诺——但是不幸的是,这种支持在 2016 年 6 月之前的所有 jQuery 发布版本中都是不完整的和完全非标准的。虽然 promises 的问题在 jQuery 3.0 中已经得到了修复,但是 promises 在相当长的一段时间里仍然存在一些明显的缺陷。
jQuery 的 promise 实现中至少出现了两个严重的 bug。这两个缺陷使得承诺不规范,令人沮丧。第一个与错误处理有关。假设在第一个then
块的一部分,一个Error
被扔进了一个 promise 的已实现函数处理程序。为了捕捉这种问题,习惯上在后续的then
块上注册一个被拒绝的处理程序,链接到第一个then
块。请记住,每个then
区块返回一个新的承诺。您的代码可能如下所示:
1 someAsyncTask
2 .then(
3 function fulfilled() {
4 throw new Error('oops!');
5 }
6 )
7 .then(null, function rejected(error) {
8 console.error('Caught an error: ' + error.message);
9 });
使用 ECMA-262 Promise
API,前面的代码将向控制台打印一个错误日志,内容是“捕获到一个错误:哎呀!”但是,如果使用 jQuery 的deferred
构造、 12 来实现相同的模式,链接的拒绝处理程序将不会捕捉到错误。相反,它将保持不被捕捉。Valerio Gheri 在他的文章中对这个问题做了更详细的描述。如果您对 jQuery 的 promise 错误处理问题的更多细节感兴趣,并且不会在这里花太多时间,我会让您继续阅读。
jQuery 的 promise 实现的第二个主要问题是打破了预期的操作顺序。换句话说,jQuery 不是观察代码和 promise 处理程序的预期执行顺序,而是改变执行顺序以匹配代码在可执行源代码中出现的顺序。这是一个过于简单的解释,如果你想了解更多,可以看看 Valera Rozuvan 的“jQuery 失信图解”文章。 14 这里的教训很简单——避免 jQuery 的 promise 实现,除非你用的是最近的版本(3.0+)。多年来一直不规范,有缺陷。
本机浏览器支持
如前所述,Promise
API 被标准化为 ECMA-262 第六版的一部分。在撰写本文时,除了 Internet Explorer 之外,所有现代浏览器都在本机实现承诺。有许多 Promises/A+库可用(如 RSVP.js、Q 和 Bluebird),但我更喜欢一个小而集中的 polyfill 来为不兼容的浏览器(Internet Explorer)带来 Promises。为此,我强烈推荐 Stefan Penner 的小而有效的“es6-promise”15poly fill。
异步函数:异步任务的抽象
在 ECMA-262 第六版中标准化承诺的 TC39 小组致力于建立在现有Promise
API 基础上的相关规范。异步函数规范, 16 也称为 async/await,将成为 2017 年 ECMAScript 规范第 8 版的一部分。在撰写本章时,它正处于第 4 阶段,这是 TC39 规范接受过程的最后一个阶段。这意味着异步函数已经完成,并准备好与 JavaScript 的未来正式版本相关联。围绕异步函数似乎有很多动力和激情(这是理所当然的)。
异步函数提供了几个特性,使得处理异步操作变得非常容易。它们允许您像对待完全同步的代码一样对待异步代码,而不是迷失在大量的约定或特定于异步的 API 方法中。这允许您对异步代码使用与同步代码相同的传统结构和模式。需要捕捉异步方法调用中的错误?简单地将它包装在一个 try/catch 块中。想从异步函数返回值吗?去吧,还回来!一开始,异步函数的优雅有点令人惊讶,一旦它们变得更常用和更容易理解,web 开发将会受益匪浅。
承诺的问题
Promise
API 提供了一个令人耳目一新的突破,摆脱了回调地狱和所有其他与基于回调的异步任务处理约定相关的笨拙和低效。但是承诺并不能掩盖处理异步的过程。仅仅为我们提供了一个更优雅的 API——一个使管理异步比之前的替代方法更简单的 API。让我们看两个代码示例,一个处理两个非常相似的任务,一个是同步的,另一个是异步的:
1 function handleNewRecord(record) {
2 try {
3 var savedRecord = saveRecord(record);
4 showMessage('info', 'Record saved! ' + savedRecord);
5 }
6 catch(error) {
7 showMessage('error', 'Error saving!' + error.message);
8 }
9 }
10
11 handleNewRecord({name: 'Ray', state: 'Wisconsin'});
Note
已经省略了showMessage()
的实现,因为它对示例代码不重要。它旨在通过向用户显示消息来说明处理成功和错误的常用方法。
在前面的代码中,我们得到了某种类型的记录,然后在saveRecord
函数的帮助下“保存”该记录。在这种情况下,操作是同步的,实现不依赖于 AJAX 调用或其他一些带外处理。因此,我们能够使用熟悉的结构来处理对saveRecord
的调用结果。当saveRecord
被调用时,我们期望一个代表已保存记录的返回值。例如,在这一点上,我们可以通知用户记录已经保存。但是如果saveRecord
意外失败——假设它抛出了一个Error
——我们也有应对措施。传统的 try/catch 块是解决这种失败所需的全部。这是几乎所有开发人员都熟悉的基本模式。
但是假设saveRecord
函数是异步的。假设它确实从浏览器委托给了服务器端点。我们的代码,使用承诺,将不得不改为看起来像这样:
1 function handleNewRecord(record) {
2 saveRecord(record).then(
3 function fulfilled(savedRecord) {
4 showMessage('info', 'Record saved! ' + savedRecord);
5 },
6 function rejected(error) {
7 showMessage('error', 'Error saving!' + error.message);
8 }
9 );
10 }
11
12 handleNewRecord({name: 'Ray', state: 'Wisconsin'});
由于saveRecord
的异步特性,这段代码被重写以使用 promises,它并不太难理解或编写,但是它明显不同于前一个例子中熟悉的var savedRecord =
try/catch 块。随着我们在整个项目中遇到更多约定函数,直接依赖Promise
API 的负担变得更加明显。我们不再简单地使用熟悉的模式,而是不断地被迫考虑异步。我们必须以完全不同于同步代码的方式对待异步代码。真不幸。如果我们能够处理异步任务而不考虑异步部分就好了。。。。
异步函数来拯救
异步函数带来的主要资产是它们提供的几乎完全的抽象——以至于异步约定任务看起来是完全同步的。一开始好像很神奇。有一些事情需要注意,以免当一个异步函数对承诺的依赖通过抽象泄漏时,你被吸引到魔法中并变得沮丧。
让我们从一个非常简单且有点做作的例子开始(不要担心,我们很快就会看到来自承诺部分的真实例子)。首先,这里是我们最近讨论的saveRecord
例子,它是为了利用异步函数而编写的:
1 async function handleNewRecord(record) {
2 try {
3 var savedRecord = await saveRecord(record);
4 showMessage('info', 'Record saved! ' + savedRecord);
5 }
6 catch(error) {
7 showMessage('error', 'Error saving!' + error.message);
8 }
9 }
10
11 handleNewRecord({name: 'Ray', state: 'Wisconsin'});
我们是否只是将异步操作的结果赋给了一个变量,而没有使用then
块,并通过将调用包装在 try/catch 块中来处理错误?为什么,是的,我们做到了!这段代码看起来几乎与我们调用完全同步的saveRecord
函数的初始示例一模一样。在封面下,这都是承诺,但没有一个then
或甚至一个catch
块的痕迹。
前面,我演示了如何在Promise
API 的帮助下防止“回调地狱”。那一节中介绍的解决方案无疑是对传统的基于回调的方法的巨大改进,但是代码仍然有点陌生,当然我们显然被迫明确地处理我们正在调用许多相互依赖的异步调用的事实。我们的代码必须考虑到这一现实。异步函数则不是这样:
1 async function updateFirstUser() {
2 try {
3 var ids = await getUserIds(),
4 info = await getUserInfo(ids[0]),
5 updatedInfo = await displayUserInfo(info);
6
7 await updateUserInfo(updatedInfo.id, updatedInfo);
8 console.log('Record updated!');
9 }
10 catch(error) {
11 console.error(error);
12 }
13 }
14
15 updateFirstUser();
前面的代码明显比依赖于直接使用承诺的早期版本更加简洁和优雅。但是在承诺部分的下一部分中的代码呢?这是我转换回调示例的地方,该示例在三个单独的 AJAX 请求中同时向三个单独的端点发送、管理和监控为一个产品提交的三个文件。我使用了Promise.all
方法来简化代码。好吧,我们可以借助异步函数进一步简化。
但是请记住,在撰写本章时,异步函数仍然是 ECMA-262 的提案。它还不是任何正式规范的一部分(尽管很快就会成为)。和许多提议一样,异步函数从最初的提议版本开始有了一些变化。事实上,这个初始版本包含了一些语法上的好处,使得监控一组约定函数变得更加容易和优雅。让我们看一下使用最初的异步函数提议重写并发异步任务示例:
1 async function sendAllRequests() {
2 try {
3 // This is no longer valid syntax - do not use!
4 await* [
5 sendFile('/file/docs', pdfManualFile, handleCompletedRequest),
6 sendFile('/file/images', previewImage, handleCompletedRequest),
7 sendFile('/file/video', howToUseVideo, handleCompletedRequest)
8 ];
9 console.log('All requests were successful!');
10 }
11 catch(error) {
12 console.error(error);
13 }
14 }
15
16 sendAllRequests();
在异步函数提议开发的早期,await*
被作为Promise.all()
的别名。在 2014 年 4 月后的某个时候,这从提案中被删除了,显然是为了避免与 ECMAScript 第 6 版标准中的“生成器”规范中的一个关键字混淆。生成器规范中的yield*
关键字在外观上类似于await*
,但是两者的行为并不相似。因此,它被从提案中删除。用异步函数监控多个并发约定函数的适当方法需要利用Promise.all()
:
1 async function sendAllRequests() {
2 try {
3 await Promise.all([
4 sendFile('/file/docs', pdfManualFile, handleCompletedRequest),
5 sendFile('/file/images', previewImage, handleCompletedRequest),
6 sendFile('/file/video', howToUseVideo, handleCompletedRequest)
7 ]);
8 console.log('All requests were successful!');
9 }
10 catch(error) {
11 console.error(error);
12 }
13 }
14
15 sendAllRequests();
不幸的是,在这种特殊情况下,即使使用异步函数,我们仍然必须直接使用承诺,但这不会对解决方案的可读性或优雅性产生负面影响。但这是真的,异步函数并不完美——您仍然必须将函数定义为异步,并且您仍然必须在返回承诺的函数之前包含await
关键字,但语法比简单的Promise
API 要简单和优雅得多。您可以使用熟悉的和传统的模式来处理异步和非异步代码。对我来说这是一个非常明显的胜利。这是规范发展非常迅速的许多方式之一,它们建立在彼此的基础上,超过了 jQuery 的发展。
浏览器支持
遗憾的是,截至 2016 年 8 月,任何浏览器都不支持异步功能。但是这是意料之中的,因为这个提议仅仅是一个提议——它还不是任何正式 JavaScript 标准的一部分。这并不意味着在项目中使用异步函数之前必须等待浏览器的采用。由于异步函数提供了新的关键字,polyfill 不是合适的解决方案。相反,您将不得不使用一种工具,在构建时将您的异步函数编译成浏览器能够理解的东西。
有许多这样的工具能够将异步函数语法编译成跨浏览器的 JavaScript。Babel 就是这样一个工具,有许多 Babel 插件可以完成这项任务。讨论 Babel 或任何其他 JavaScript 编译工具超出了本书的范围,但我可以告诉你,大多数插件似乎都是将异步函数编译成 ECMAScript 2015 生成器函数。如果项目是基于浏览器的,那么必须将生成器函数编译成 ECMAScript 5 代码(因为并非所有现代浏览器都支持生成器函数)。Type Script 是另一个 JavaScript 编译工具,它执行许多与 Babel 相同的任务,但也支持许多非标准语言特性。Type Script 目前提供对异步函数的本机支持,但只在本机支持生成器函数的浏览器中提供。在未来的版本中,这个限制可能会被放宽。
标准化异步任务处理的未来
当我开始写这一章的时候,我打算用整整两个部分来讨论 ECMA 262 提案。这些提议——异步迭代器 18 和可观察的19——是为了进一步增强 JavaScript 异步任务处理而创建的。我最初计划用一个章节来介绍这些提议,并附上丰富的代码示例,但是由于一些原因,我最终决定不这样做。首先,这些提议还相当不成熟。异步迭代器是第二阶段的提议,Observable 只是在第一阶段。当这些建议很可能在过程中的某个时刻以意想不到的方式发生变化时,将它们包含在一本书里似乎并不合适。更糟糕的是,一项或两项提议都可能被撤回。目前这两种方案都没有完整的实现。这使得在试图演示这些概念的好处时,很难实际创建可运行的代码。尽管 Async Functions 也是一个提议,但由于它在 JavaScript 社区中的发展势头以及它在 stage 4 中的先进地位,它确实被选中了。
异步迭代器旨在简化使用熟悉的循环结构,比如一个for
循环,来迭代异步操作产生的项目集合。调用函数后,集合中的每一项都不是立即可用的。相反,随着循环的执行,异步函数内部的逻辑会逐步异步加载新项。提案库中一个直观的例子 20 展示了这个新概念如何允许我们使用一个for
循环来打印文件中的行。读取文件的过程是异步的,当for
循环请求时,我们的for
循环只尝试读取每个后续的行。如果循环终止,文件读取器也会终止。该提案将异步函数与 ECMAScript 2015 生成器函数配对。虽然我在本章中介绍了异步函数,但我有意省略了生成器函数。生成器函数对于处理异步任务确实很有用,但是在这种情况下使用它们是相当低级和笨拙的——不适合这本书,因为使用这种语言特性非常复杂。
可观测量更容易理解。这种模式的许多实现已经存在,包括 JavaScript 和其他语言。RxJS 可观测量提供了一种标准化的方法来筛选和关注数据流中的特定数据点。提案库中的一个示例 22 演示了如何使用可观察对象来监控所有浏览器键盘事件,以关注事件流中特定的按键组合。
尽管异步迭代器和 Observables 可能是 JavaScript 异步任务处理未来的一部分,但我已经展示了许多现在可以使用的 API。您不再需要依赖与特定库相关的约定或专有解决方案。JavaScript 继续发展,以标准化复杂操作的直观解决方案。对异步任务的支持只是许多这样的例子之一。
Footnotes 1
2
3
4
5
https://github.com/promises-aplus/promises-spec
6
https://github.com/petkaantonov/bluebird
7
https://github.com/kriskowal/q
8
https://github.com/tildeio/rsvp.js
9
www.ecma-international.org/ecma-262/6.0/#sec-promise-objects
10
https://github.com/domenic/promises-unwrapping/blob/master/docs/states-and-fates.md
11
www.ecma-international.org/ecma-262/6.0/#sec-iscallable
12
https://api.jquery.com/jquery.deferred/
13
14
http://valera-rozuvan.github.io/nintoku/jquery/promises/jquery-broken-promises-illustrated
15
https://github.com/stefanpenner/es6-promise
16
https://tc39.github.io/ecmascript-asyncawait/
17
https://tc39.github.io/process-document/
18
https://tc39.github.io/proposal-async-iteration/
19
https://tc39.github.io/proposal-observable/
20
https://github.com/tc39/proposal-async-iteration
21
https://github.com/ReactiveX/rxjs
22
https://github.com/zenparsing/es-observable
十二、常见的 JavaScript 工具函数
在第十二章中,我计划将 jQuery 的实用函数 1 与 ECMAScript 规范中定义的本地 API 进行匹配。本章中的大部分代码都可以在浏览器和服务器上运行(除了例子中的DOMParser
和任何对window
或document
的引用)。在现代浏览器中,jQuery 大部分时间都是不必要的。但是,即使 jQuery 节省了一些击键次数,能够运行自己的实用函数也是很重要的,即使您选择引入一个库来实现一些更复杂、支持更少的特性。毕竟,理解 jQuery 如何发挥其魔力会让您成为一名更全面、更有效的软件开发人员。
jQuery 可能会提供本章没有详细介绍的其他实用函数,但是我会介绍所有“重要”的函数。首先,我演示并解释了 jQuery 提供的处理字符串、日期、XML 和 JSON 的函数是如何在没有任何外部库的情况下复制的。接下来,我转到对检查变量类型有用的代码。例如,确定一个值是Object
、Array
、Function
还是某个特定的原始值。不要担心,我稍微详细说明了原始值,以防您不完全熟悉 JavaScript 中可用的每个原始值类型。jQuery 确实提供了检测值类型的函数。我回顾了这些,并向您展示了如何用普通 JS 执行相同的类型检查(以及更多的检查)。
我还解释了操作、创建和遍历对象以及数组。最后,我们一起来搞清楚函数——也就是说,如何在没有 jQuery 的 JavaScript 函数上执行各种重要且常见的操作。完成这一章后,你会觉得自己处理 JavaScript 对象、函数、数组和原语更加得心应手,不需要任何“外部”帮助。
使用日期、文本、XML 和 JSON
第一部分考虑在普通的传统 JavaScript 中复制 jQuery 的Date
和 string helper 函数的行为。但不仅仅是普通的字符串,JSON 和 XML 也是如此。尽管 jQuery 使得使用日期和字符串执行简单、常见的任务变得非常容易,但是您很快就会看到这个库在这个上下文中是多么不必要。
日期
jQuery 并不真正关注日期,尽管它可能贡献了一些有价值的实用函数,使一些更有用的Date
原型方法在跨浏览器时更加可靠。例如,Date.prototype
提供了许多方法,可以用来根据特定的地区设置特定日期的格式,比如Date.prototype.toLocaleString()
和Date.prototype.toLocateDateString()
。尽管这在当前的地区得到了很好的支持,但是传递可变地区的能力目前还没有得到浏览器的支持。此外,这些方法还允许根据一组特定的要求对日期进行格式化。假设您希望使用全名(而不是数字或缩写)打印当前月份。只需将一个选项对象(第二个参数)作为{month: 'long'}
传递给toLocaleString()
。但是,即使在现代浏览器中,这也没有得到很好的支持。
不,jQuery 不能帮助我们解决 JavaScript 中任何真正的Date
相关问题。相反,它只提供了一个方法来返回自 Unix 纪元(00:00:00 UTC,1970 年 1 月 1 日)以来的毫秒数。假设当前时间是 2016 年 6 月 4 日午夜。如果我们调用 jQuery 提供的这个实用函数来获得自 Unix 纪元以来的毫秒数,会怎么样呢?代码会是什么样子?
1 var currentTime = $.now();
在任何浏览器中,不使用 jQuery 也可以获得相同的值,只需要多几个字符:
1 var currentTime = new Date().getTime();
两个代码块中的currentTime
变量是相同的(假设两行在完全相同的时间点执行)。自从 ECMAScript 规范的第一个版本 2 第一版% 2C 1997 年 6 月. pdf)以来,getTime()
方法就在Date.prototype
上可用。但是,如果我们只依赖现代浏览器,即使没有 jQuery 也可以更优雅地进行同样的调用:
1 var currentTime = Date.now();
在 ECMAScript 规范的 5.1 版本中,now()
函数被添加到了Date
对象中。 3 事实上,最近版本的 jQuery 将$.now()
直接连接到Date.now()
。 4 但是,即使您非常不幸地需要支持古老的浏览器(也许是在一个不会消亡的遗留 web 应用中),正如您已经看到的,只需很少的额外工作,就可以实现相同的行为。
将 JSON 转换成 JavaScript 对象
在 jQuery 1 . 4 . 1 版本中,引入了一个 API 方法来解析 JSON 字符串,将其转换成适当的 JavaScript 表示形式(String
、Number
、Boolean
、Object
或Array
)。1.4.1 发布于 2010 年,比 Internet Explorer 8 发布晚了一年(后者能够原生解析 JSON 字符串)。然而,IE7 仍在使用,IE6 在某种程度上也是如此,两者都不能轻松地将 JSON 转换成 JavaScript。jQuery 的parseJSON()
方法旨在使这项任务在所有支持的浏览器中成为可能。考虑以下简单的 JSON 字符串:
1 {
2 "name": "Ray",
3 "id": 123
4 }
前面的 JSON 字符串作为一个jsonString
变量对我们的代码可用,当然,它是一个string
。可以使用 jQuery 的$.parseJSON()
方法将这个 JSON 字符串转换成 JavaScript 对象:
1 var user = $.parseJSON(jsonString);
2
3 // prints "Ray"
4 console.log(user.name);
5
6 // prints 123
7 console.log(user.id);
虽然$.parseJSON()
确实提供了一种优雅的方式将 JSON 字符串转换成合适的 JavaScript 值,但是多年来它一直是一种完全不必要的抽象。所有现代浏览器和 Node.js 的所有版本(以及前面提到的 Internet Explorer 8)都支持JSON
对象,该对象首先包含在 ECMAScript 5.1 中。 5 这里是没有 jQuery 的完全相同的解决方案:
1 var user = JSON.parse(jsonString);
2
3 // prints "Ray"
4 console.log(user.name);
5
6 // prints 123
7 console.log(user.id);
如果浏览器支持JSON
对象,jQuery 甚至会将所有 JSON 字符串解析工作交给JSON.parse()
。除了解析 JSON 字符串,本地的JSON
对象还可以通过stringify()
方法将 JavaScript 值转换成字符串。jQuery 的 API 中没有这样的抽象,以前也没有。
对于比 Internet Explorer 7 更早的浏览器(包括 Internet Explorer 7 ),至少有一种方法可以在不访问本地方法的情况下解析 JSON 字符串。最常见的方法是使用本机eval()
函数将 JSON 字符串解析成 JavaScript 值。尽管这确实可行,但由于使用eval()
的安全隐患,这是一个众所周知的坏主意。使用eval()
盲目地将字符串解析成 JavaScript 值需要执行底层代码。对于用户提供的字符串,这可能会导致灾难性的后果。为了应对这一现实,道格拉斯·克洛克福特在 2007 年创建了一个库——JSON . js6——也委托给了eval()
,但只是在验证了evaluate
的字符串是“安全的”之后。不管怎样,这些都不再需要了,因为不支持JSON
的浏览器并没有被广泛使用。
将 XML 字符串转换为文档
与将 JSON 字符串转换成 JavaScript 表示的$.parseJSON()
类似,jQuery 也在其公共 API 中定义了一个parseXML()
方法。jQuery 的$.parseXML()
将一个 XML 字符串转换成一个document
。这使您能够使用 jQuery 的选择器 API 来查询文档,就像查询 HTML 文档一样。例如,考虑一个直接向 Microsoft Azure REST API 端点发出请求的应用。如果 URL 包含无效的查询参数,Azure 服务将在响应中使用以下 XML 字符串进行响应: 7
1 <?xml version="1.0" encoding="utf-8"?>
2 <Error>
3 <Code>InvalidQueryParameterValue</Code>
4 <Message>Value for one of the query parameters specified in the request URI is\
5 invalid.</Message>
6 <QueryParameterName>popreceipt</QueryParameterName>
7 <QueryParameterValue>33537277-6a52-4a2b-b4eb-0f905051827b</QueryParameterValue>
8 <Reason>invalid receipt format</Reason>
9 </Error>
我为这一部分选择了 Azure 响应,因为我清楚地记得在将“upload to Azure”功能集成到 Fine Uploader 时处理 XML 字符串浏览器端的解析,Fine Uploader 必须解析响应,并报告错误代码和消息以供显示和记录。 8
假设我们想做同样的事情——提取错误消息的<Code>
和<Message>
部分。我们当然可以使用正则表达式自己解析字符串,但这不是一种理想的方法。目标是将 XML 字符串转换成适当的文档,使用 jQuery 很容易做到这一点:
1 // assuming we have the above XML string assigned to a var
2 var errorDocument = $.parseXML(azureErrorXmlString);
3
4 // code = "InvalidQueryParameterValue"
5 var code = $(errorDocument).find('Code').text();
6
7 // message = "Value for one of the query parameters..."
8 var message = $(errorDocument).find('Message').text();
注意,jQuery 实际上确实返回了一个document
作为$.parseXML()
的返回值,所以如果我们想要使用 jQuery 的 API 来解析文档,我们必须自己包装它。
您可能很高兴知道,我们可以在没有 jQuery 的所有现代浏览器中非常容易地完成所有这些工作:
1 // assuming we have the above XML string assigned to a var
2 var errorDocument = new DOMParser()
3 .parseFromString(azureErrorXmlString, 'application/xml');
4
5 // code = "InvalidQueryParameterValue"
6 var code = errorDocument.querySelector('Code').textContent;
7
8 // message = "Value for one of the query parameters..."
9 var message = errorDocument.querySelector('Message').textContent;
虽然相同的解决方案在古代浏览器中不那么优雅,但在今天这真的不是问题。DOMParser
接口被定义为 W3C DOM 解析和序列化规范 9 的一部分,该规范于 2012 年首次起草,截至 2016 年年中仍被列为“草案”。但是该文档中描述的行为已经在所有现代浏览器中实现了一段时间。这个特殊的规范旨在标准化已经在广泛的浏览器中实现的行为,类似于 CSS 对象模型规范。 10
也许您在对自己说,“为什么我必须包含 XML MIME 类型(application/xml)?”jQuery 的parseXML()
方法不要求包含这个。答案很简单,根据本机解析器的通用名称,答案可能已经很明显了。DOMParser
不仅仅是为了解析 XML 字符串而创建的。虽然这是目前支持最广泛的文档类型,但也支持将 SVG 字符串解析为 SVG 元素(image/svg+xml)和解析 HTML (text/html)。除了 Internet Explorer 9 之外,所有现代浏览器都支持后两种文档类型。DOMParser
比 jQuery API 中的任何单一产品都要强大得多,而且它有潜力在未来扩展以支持更多类型。
字处理
令人惊讶的是,一个看似重要且常见的字符串操作任务——从一行文本的开头和结尾修剪空白——直到 ECMAScript 5.1 才作为一种 JavaScript 方法得到支持。11Internet Explorer 9 是实现本规范中定义的String.prototype.trim()
方法的最老的浏览器。trim()
的姗姗来迟解释了 jQuery 从 1.0 版本开始提供自己的trim()
方法的一个主要原因。接下来的三个清单比较了 jQuery API 方法、String.protoype.trim()
原生方法和一个针对古代浏览器的变通方法:
1 // trimmed = 'some name'
2 var trimmed = $.trim(' some name ');
Listing 12-1.Trim a String: jQuery
1 // trimmed = 'some name'
2 var trimmed = (' some name ').trim();
Listing 12-2.Trim a String: JavaScript, Modern Browsers
1 // trimmed = 'some name'
2 var trimmed = (' some name ').replace(/^\s+|\s+$/g, '');
Listing 12-3.Trim a String: JavaScript, All Browsers
对于没有本地实现String.prototype.trim()
的浏览器,解决方法需要求助于正则表达式。前面的解决方法包含在 Fine Uploader 的许多跨浏览器工具方法中的一个方法中(这是必需的,因为该库过去一直支持 Internet Explorer 6 之类的浏览器。 12
$.
trim()
是 jQuery 提供的唯一与字符串相关的便利方法。你可以在 Mozilla Developer Network 的String
接口页面上阅读 JavaScript 提供的其他字符串操作和解析方法。 13 但这不会是我们在本章中最后一次深入探讨弦乐。
这是一种什么样的价值观?
JavaScript 的 ECMAScript 语言规范定义了两种通用数据类型:Object
和原语。从第七版标准开始,共有六种原始数据类型:null
、undefined
、Boolean
、Number
、String
和Symbol
。就非原始类型而言,Object
是一个,所有其他此类类型都继承自Object
。有许多复杂的 JavaScript 类型继承自Object. Array
和Function
就是两个这样的例子。有些类型比其他类型更容易被可靠地识别,但是所有的值都可以在没有第三方库的帮助下被识别。然而,即使您已经依赖这样一个库,本节也将帮助您理解您的库可能使用的一些逻辑,以便确定 JavaScript 值类型。由于这本书主要面向已经熟悉 jQuery 及其便利的 API 方法集合的开发人员,因此我将主要关注 jQuery 提供的现有解决方案,向您展示如何在 JavaScript 中识别值类型。
基元
jQuery 提供了两个 API 方法,可以用来解析原始值:$.isNumeric()
和$.
type()
。type()
API 方法提供了最有用和最期望的行为。它将返回一个单字(小写)字符串,标识所提供值的 JavaScript 类型。例如:
1 // true
2 $.type(3) === 'number';
3
4 // true
5 $.type('3') === 'string';
6
7 // true
8 $.type(null) === 'null';
9
10 // true
11 $.type(undefined) === 'undefined';
12
13 // true
14 $.type(false) === 'boolean';
15
16 // true (only supported in Chrome, Firefox, and Safari)
17 $.type(Symbol('mysymbol')) === 'symbol';
jQuery 的type()
方法也会为不常见的值产生预期的结果,如下所示:
1 // true
2 $.type(new Number(3)) === 'number';
3
4 // true
5 $.type(new String('3')) === 'string';
6
7 // true
8 $.type(new Boolean(false)) === 'boolean';
尽管使用构造函数创建String
、Number
或Boolean
是完全合法的,但这并不常见,而且这种做法并没有带来真正的好处。这也使得以这种方式构造的两个原语之间的比较变得困难。例如:
1 // both are true
2 3 === 3;
3 3 == 3;
4
5 // all are false
6 3 === new Number(3);
7 new Number(3) === new Number(3);
8 new Number(3) == new Number(3);
9
10
11 // both are true
12 'string' === 'string';
13 'string' == 'string';
14
15 // all are false
16 'string' === new String('string');
17 new String('string') === new String('string');
18 new String('string') == new String('string');
19
20
21 // both are true
22 false === false;
23 false == false;
24
25 // all are false
26 false === new Boolean(false);
27 new Boolean(false) === new Boolean(false);
28 new Boolean(false) == new Boolean(false);
事实上,除非在Boolean
实例上调用valueOf()
方法,否则用Boolean
构造函数创建的布尔值在条件中总是计算为true
。 14 多么直观啊!
那么,在没有 jQuery 的情况下,我们如何进行相同的原语类型比较呢?事实是,这些操作在所有浏览器中都是微不足道的(无论是现代的还是古代的)。看一看:
1 // true
2 typeof 3 === 'number';
3
4 // true
5 typeof '3' === 'string';
6
7 // true
8 typeof undefined === 'undefined';
9
10 // true
11 typeof false === 'boolean';
12
13 var someVal = null;
14 // true
15 someVal === null;
16
17 // true (only supported in Chrome, Firefox, and Safari)
18 typeof Symbol('mysymbol') === 'symbol';
在所有情况下,除了一种情况,我们可以利用typeof
关键字来提供与 jQuery 的type()
方法完全相同的结果。typeof
从 JavaScript 的第一个版本开始就已经是语言的一部分了。然而,自从这个第一版以来也存在一个小问题,这可以在用typeof
关键字评估null
时看到:
1 // false
2 typeof null === 'null';
3
4 // 'object'
5 typeof null;
如你所见,typeof
认为null
是一个Object
,奇怪的是。这在很大程度上被认为是 JavaScript 最初实现中的一个错误,出于向后兼容的原因,这个错误一直存在。但是 ECMAScript-262 语言规范提供了另一种解释。null
值 15 的定义被描述为“表示任何对象值有意缺失的原始值”在这方面,称这种类型为“对象”可能是恰当的。当我们看typeof NaN
的结果时,这个理论更有意义,它评估为“数”。当然,如果Number
的反义词评估为“数字”,那么有理由认为Object
的反义词评估为“对象”。就一致性而言,这非常有意义(至少对我来说)。不过,你可以自由地形成自己的观点。不管怎样,这种行为可能永远不会改变,因为解决这个问题的提议在过去已经被否决了。 16
jQuery 的$.isNumeric()
方法呢?嗯,这有点奇怪,或者至少最初看起来是这样。看一看:
1 // all true
2 $.isNumeric(3);
3 $.isNumeric('3');
4
5 // all false
6 $.isNumeric(NaN);
7 $.isNumeric(Infinity);
换句话说,如果一个值确实是一个Number
或者可以被强制为一个Number
,那么 jQuery 的isNumeric()
将返回true
。所谓“强制”,我的意思是像“3”这样的字符串可以很容易地转换成合适的Number
,例如,通过将它传递到parseInt()
。此外,通过使用双等号(==
)将字符串转换为Number
作为比较操作的一部分,可以将该字符串评估为合适的Number
,以便与另一个值进行比较。此外,确实是Number
s 但通常不被认为是“数字”的值(如NaN
和Infinity
)在被传入$.isNumeric()
时会产生一个false
返回值。在没有 jQuery 的情况下实现同样的行为需要一点思考(也许还要谷歌一下)。但是,在意识到我们在评估自己的价值时需要做出两个决定之后,我们就可以找到解决方案:
- 难道是
NaN
? - 是有限值吗?
诚然,大多数问题不太可能达到明确必须回答这两个具体问题的地步,但事实仍然是,这确实是我们需要确定的。幸运的是,从第一个版本开始,语言中就定义了两种方法,这两种方法可以让我们很容易地模仿 jQuery 的isNumeric()
的行为:
1 function isNumeric(maybeNumber) {
2 return !isNaN(parseFloat(maybeNumber))
3 && isFinite(maybeNumber);
4 }
5
6 // all true
7 isNumeric(3);
8 isNumeric('3');
9
10 // all false
11 isNumeric(NaN);
12 isNumeric(Infinity);
当确定一个值是否为NaN
时,需要添加parseFloat
来正确评估null
值。换句话说,如果maybeNumber
是null
,而我们忽略了parseFloat()
,那么isNaN()
将计算为false
,并且null
值将被错误地声明为“数字”。
数组
在所有的浏览器中,判断一个值是否是一个数组是很简单的事情,但是现代浏览器对这种判断的支持是非常优雅的。为此,我们有Array.isArray()
:
1 // both are true
2 Array.isArray([]);
3 Array.isArray(new Array());
4
5 // both are false
6 Array.isArray(3);
7 Array.isArray({});
在 ECMAScript-262 5.1 中,JavaScript Array
对象被赋予了一个isArray()
方法。 17 而 jQuery 在相当一段时间内也包含了类似的便利方法:$.isArray()
:
1 // both are true
2 $.isArray([]);
3 $.isArray(new Array());
4
5 // both are false
6 $.isArray(3);
7 $.isArray({});
但是从 2.0 开始的所有版本的 jQuery 在所有情况下都直接委托给本机Array.isArray()
。换句话说,$.isArray()
只是Array.isArray()
的别名。旧版本的 jQuery 也委托给了Array.isArray()
,但是是有条件的。如果Array.isArray()
不可用(在古代浏览器中会出现这种情况),jQuery 的type
方法用于确定值是否是一个“数组”但是我们可以复制相同的行为,支持所有没有 jQuery 的浏览器,甚至是古老的浏览器:
1 function isArray(value) {
2 return Array.isArray
3 ? Array.isArray(value)
4 : Object.prototype.toString.call(value) === '[object Array]';
5 }
6
7 // both are true
8 isArray([]);
9 isArray(new Array());
10
11 // both are false
12 isArray(3);
13 isArray({});
请记住,上述代码只有在您计划支持非常旧的浏览器(如 Internet Explorer 8)时才有用。否则,Array.isArray()
涵盖了所有可能的情况。但是让我们仔细看看上面例子的一部分,特别是Object.prototype.toString.call(value)
。你可能想知道为什么我们不在这里简单地使用typeof
。有点令人惊讶的是,typeof []
和typeof new Array()
产生一个结果“对象”。这在技术上是正确的——Array
是Object
的一种类型,因为它从Object.prototype
继承而来,“object”的类型值没有预期的那么具体。当然,更有效的结果是“数组”。但是,可悲的是,事实并非如此。
将 JavaScript 数组视为带有一些方便方法的对象通常会有所帮助。这或多或少是事实。深入数组,您会发现数组元素访问遵循与对象属性访问相同的模式。例如,访问数组中的第三个元素:myArray[2]
。并访问对象myObject[2]
中名为2
的属性。数组大多只是具有一些便利方法和一个length
属性的对象,typeof
反映了这一现实。通过使用call()
方法调用Object
原型上可用的toString()
方法,这允许我们将toString()
调用的上下文更改为数组文字,我们能够看到数组的“真实”类型。如果你对call
方法感到困惑,也不用担心。我在这一章的结尾会谈到这个问题。
目标
对象不是原始数据类型,部分原因是对象是可变的,而原始数据类型在大多数编程语言中通常是不可变的。但是 JavaScript 中的对象类型是所有非原始值的基本类型。函数是对象,数组也是对象!关于这两种值类型的更多内容。
在 JavaScript 中,对象只是键/值属性的“集合”。键可以是字符串,也可以是整数(被转换成字符串),甚至是Symbol
s(如果浏览器支持这种原始数据类型)。任何东西都是有效的属性值,甚至是另一个对象。
jQuery 的type
API 方法当然可以告诉我们一个值何时是对象,就像原生的typeof
关键字一样。但是请记住前面的章节中的typeof null === 'object'
和typeof [] === 'object'
。这些值被$.type()
标识为“null”和“array ”,这可能是您希望和期望的行为。那么,我们如何创建一个简单的跨浏览器函数,将任何“真实的”对象识别为一个对象,而忽略“数组对象”呢?这可以通过以下方式实现:
1 function isObject(value) {
2 return value !== null &&
3 Object.prototype.toString.call(value) === '[object Object]';
4 }
5
6 // both are true
7 isObject({});
8 isObject(new Object());
9
10 // both are false
11 isObject(null);
12 isObject([]);
除了$.type()
,jQuery 还有一些更有趣的方法用来识别特定种类的 JavaScript 对象。一个是$.isPlainObject()
,它应该确定一个特定的值是一个对象文字({}
)还是一个基本的Object
实例(new Object()
)。jQuery 的type()
方法在这种情况下并不合适,因为它会将 DOM 元素识别为对象(这在技术上是正确的)。尽管理论上isPlainObject()
很有用,但在我的整个职业生涯中,我很少需要做出这样的决定。此外,jQuery 的文档警告说,这种方法可能不可靠,可能会在某些浏览器中产生意想不到的结果。出于这些原因,我不打算进一步关注这个特定的 API 方法。可以说,如果您需要可靠地确定一个值是否是一个对象文字,您可能会发现重构您的问题是一个更好的途径。
jQuery 的isEmptyObject()
是另一个有趣的方法,有更多实际的用例。您经常会遇到这样的情况:您被传递了一个对象,并且需要确定该对象是否包含任何属性。也许“空”对象表示在查询 API 时没有可用的数据。jQuery 的isEmptyObject()
方法满足了这一需求:
1 // true
2 $.isEmptyObject({});
3
4 // false
5 $.isEmptyObject({name: 'Ray'});
对于所有浏览器,有一个简单(虽然不那么优雅)的解决方案可以在没有 jQuery 的情况下做出相同的决定。查看 jQuery 的isPlainObject()
实现,我们可以提取这个逻辑并为我们自己的项目创建一个微小的可重用函数,而不需要引入整个库:
1 function isEmptyObject(value) {
2 for (property in value) {
3 return false;
4 }
5 return true;
6 }
7
8 // true
9 isEmptyObject({});
10
11 // false
12 isEmptyObject({name: 'Ray'});
功能
除了一个特殊的例外,无论有没有 jQuery,函数都很容易识别。jQuery 提供了两种 API 方法,允许您确定 JavaScript 值是否是函数。一个是$.type()
,我已经在本节演示过几次了。另一个是$.isFunction()
,它简单地委托给$.type()
,实际上使它成为一个别名:
1 // all true
2 $.isFunction(function() {});
3 $.type(function() {}) === 'function';
4 $.isFunction(Object.prototype.toString);
5 $.isFunction(document.body.getAttribute);
6
7 // all false
8 $.isFunction({});
9 $.type({}) === 'function';
10 $.isFunction(3);
JavaScript 中的标准typeof
操作符产生完全相同的结果:
1 // all true
2 typeof function() {} === 'function';
3 typeof Object.prototype.toString === 'function';
4 typeof document.body.getAttribute === 'function';
5
6 // all false
7 typeof {} === 'function';
8 typeof 3 === 'function';
甚至 JavaScript 类也被 jQuery 和typeof
操作符识别为“函数”。这并不奇怪,因为 JavaScript 中没有“类”类型。ECMAScript 2015 类只不过是“语法糖”——一种创建从另一个对象继承的对象的更简单的方法。
jQuery 实际上不执行任何魔法来确定一个值是否是一个函数。这是真的,当试图在带有和不带有 jQuery 的古老浏览器中识别 web API 函数时,会产生意想不到的结果。早期版本的 Internet Explorer(版本 8 及更早版本)将标准 web API 函数误报为对象,例如那些在Element
和Window
接口上定义的函数。比如在 ie 8 中,$.isFunction(document.body.getAttribute)
是false
,同样是typeof document.body.getAttribute === 'function'
。显然这是一个函数,但 IE 似乎认为它是一个“对象”。这并非完全不正确——毕竟,函数是对象。他们继承自Object.prototype
。它们有时甚至被称为“功能对象”
让 JavaScript 对象屈从于你的意愿
正如您在上一节中了解到的,JavaScript 中的所有非原语值都继承自Object
。这使得Object
成为处理复杂数据时需要理解的最重要的值类型。一旦您能够将一个值识别为一个Object
——这是您在上一节中学到的——理解如何在没有任何第三方代码帮助的情况下复制、解析和创建对象是很有帮助的。在这里,我将向您展示 jQuery 如何允许您对对象执行各种重要而有用的操作,以及如何通过使用 JavaScript 提供的标准方法来获得完全相同的结果。在大多数情况下,与 jQuery 的 API 相比,这些“本地”解决方案同样或类似地优雅。但是当然,有些操作会无可否认地显示出 jQuery 的优雅。尽管如此,即使涵盖与基于库的解决方案相比似乎不太理想的原生解决方案也有很大的价值。本节中的所有代码将让你对 JavaScript 有更深入的了解,这反过来会给你更多的信心,让你成为一个更强大、更有能力的开发人员。毕竟这是 Beyond jQuery 的首要目标。
遍历键和值
让我们从一个包含许多属性的对象开始:
1 var user = {
2 name: 'Ray Nicholus',
3 address: '1313 Mockingbird Lane',
4 city: 'Mockingbird Heights',
5 state: 'California'
6 };
。。。我们有一个 HTML 表单需要接收这些值:
1 <form>
2 <input name='name'>
3 <input name='address'>
4 <input name='city'>
5 <input name='state'>
6 </form>
如您所见,每个表单字段在我们的初始对象中都有一个匹配的属性。这个特定的对象可能由 AJAX 调用提供,我们必须简单地获取结果对象数据,并用匹配的值填充表单。假设您事先对表单或对象一无所知,只知道对象将具有与表单字段上的name
属性相匹配的属性,并且这些表单字段可以使用它们的value
属性进行更新。这要求我们做到以下几点:
- 循环访问对象中的属性
- 将匹配的
<input>
的value
设置为等于对象属性值
在前面的章节中,你已经学习了如何使用 HTML 元素。在这里,我将向您展示如何遍历对象的属性,这样您就可以不用 jQuery 轻松解决这个问题。但是首先,让我们看看 jQuery 的解决方案:
1 $.each(user, function(property, value) {
2 $('form [name="' + property + '"]').val(value);
3 });
没有现代浏览器的 jQuery 和 IE8,我们可以很容易地解决这个问题:
1 for (var property in user) {
2 var value = user[property];
3 document.querySelector('FORM [name="' + property + '"]').value = value;
4 }
我们正在使用标准的for...in
循环,这一直是语言的一部分。我们的代码“限于”Internet Explorer 8 和更新版本的唯一原因是因为使用了querySelector
,但这似乎是一个合理的变通。请注意,在这种情况下,我们可以确保我们的对象只包含在该对象上定义的属性,而不包含在继承对象上定义的任何属性。但是如果这是一个问题,并且您只想检索专门属于这个user
对象的属性,那么您将需要使用一个额外的检查,您的代码将如下所示:
1 for (var property in user) {
2 if (user.hasOwnProperty(property)) {
3 var value = user[property];
4 document.querySelector('FORM [name="' + property + '"]').value = value;
5 }
6 }
每个Object
上都有的hasOwnProperty()
方法告诉我们一个给定的属性是否只属于源对象。从 ECMAScript 规范的第三版开始,它就成为了语言的一部分,所以在所有浏览器中使用它都是非常安全的。同样,在我们的例子中,这不是我们需要使用的东西,但是您可能会发现它在其他情况下很有用。
您可能会问自己,“为什么我没有在前面的 jQuery 例子中使用hasOwnProperty()
”答案是:你有!jQuery 的each()
API 方法在遍历对象属性时不调用hasOwnProperty()
。已经确定 call 在所有情况下在库中使用都太昂贵了,并且将这种“增强”添加到现有方法实现中所需的工作太多了。不幸的是,$.each()
的文档页面没有提醒用户他们可能不得不自己使用hasOwnProperty()
的事实。
JavaScript 提供了另一种迭代对象属性的标准方法,还有一个额外的好处:不需要hasOwnProperty()
(ever):
1 Object.keys(user).forEach(function(property) {
2 var value = user[property];
3 document.querySelector('FORM [name="' + property + '"]').value = value;
4 });
前面的代码使用了Object.keys()
,这是一种将对象中的所有属性转换成数组的方法。ECMAScript 5.1 中的Object
增加了这个方法, 19 在所有现代浏览器中都有。然后我们在所有的Array
对象上使用可用的forEach()
方法。下一节将详细介绍这个属性,但它也受到所有现代浏览器的支持。的确,这仍然比 jQuery 解决方案多一行(我告诉过你,不要担心代码行!),但是它消除了调用hasOwnProperty()
的需要,这最终可能会节省一些代码(如果你仍然关心这类事情的话)。
复制和合并对象
稍微扩展一下前面的例子,假设我们有关于同一个用户的两组非常不同的信息,我们需要将其中一组组合到另一组中,以形成包含所有用户数据的单个对象。我们的两个物体看起来像这样:
1 var userLocation = {
2 name: 'Ray Nicholus',
3 address: '1313 Mockingbird Lane',
4 city: 'Mockingbird Heights',
5 state: 'California'
6 };
7
8 var userPersonal = {
9 name: 'Ray Nicholus',
10 sex: 'male',
11 age: 35
12 };
我们想要的是一个对象——user
——包含来自userLocation
和userPersonal
对象的所有属性。组合后的对象需要看起来像这样:
1 var user = {
2 name: 'Ray Nicholus',
3 address: '1313 Mockingbird Lane',
4 city: 'Mockingbird Heights',
5 state: 'California',
6 sex: 'male',
7 age: 35
8 };
jQuery 提供了一个 API 方法来非常有效地处理这个问题- $.extend()
:
1 var user = $.extend(userLocation, userPersonal);
所以现在我们有了一个用户对象,它具有来自两个初始用户对象的属性。合并两个对象的能力在没有 jQuery 的情况下也是可能的(不足为奇——否则我为什么要开始描述对象合并呢?)虽然有一个非常优雅的方法,类似于 jQuery 的extend()
(我将很快演示它),但广泛的浏览器支持(目前)只有通过更冗长的解决方案才有可能。实际上,我们必须自己编写穷人的extend()
方法来支持所有浏览器:
1 function extend(first, second) {
2 for (var secondProp in second) {
3 var secondVal = second[secondProp];
4 first[secondProp] = secondVal;
5 }
6 return first;
7 }
8
9 var user = extend(userLocation, userPersonal);
另一种更简单的方法需要Object.assign()
,这是 ECMAScript 2015 中首次添加到语言中的: 20
1 var user = Object.assign(userLocation, userPersonal);
很棒,但是没有任何版本的 Internet Explorer 支持它。在 Internet Explorer 11 被 Microsoft Edge 完全取代之前,您将需要依赖 polyfill 来提供在所有浏览器中访问这一语言功能。不过不用担心,有几个非常小的 polyfills 就足够了。
对于所有前面的代码示例(jQuery 和非 jQuery ),一个有效的关注点是第一个对象userLocation
被修改为来自userPersonal
的属性。$.extend()
、我们的自定义extend()
方法和Object.assign()
的返回值是传递给每个函数的第一个对象。那么,我们如何在不修改任何一个对象的情况下创建一个包含两个合并的用户对象的新对象呢?简单:我们需要创建第一个对象的副本,然后将第二个对象合并到该副本中。
使用 jQuery 的extend
方法,我们可以简单地将第一个参数(“目标”对象)声明为一个空对象。由于$.extend()
采用可变数量的参数,我们可以指定两个初始用户对象作为后续参数:
1 var user = $.extend({}, userLocation, userPersonal);
我们之前编写的自定义函数extend
可以用来产生完全相同的结果。记住,我们需要创建第一个用户对象的副本,然后将第二个对象合并到这个副本中:
1 var user = extend(extend({}, userLocation), userPersonal);
嵌套的extend()
调用导致userLocation
的副本作为外部extend
的第一个参数,这正是我们基于两个原始用户对象创建一个全新对象所需要做的。
JavaScript 的最新版本Object.assign()
,和 jQuery 的extend
一样简洁优雅地解决了这个问题:
1 var user = Object.assign({}, userLocation, userPersonal);
用数组解决问题
与上一节关于对象的内容类似,在这一节中,您将学习如何复制、解析和创建数组。记住数组是对象。也就是说,它们继承了在Object.prototype
上定义的所有方法/属性。 21 但是Array
对象自己定义了一些独特的方法,比如forEach()
、map()
、concat()
、indexOf()
、find()
等等。我将向您介绍其中的一些方法,并演示如何复制一些流行的特定于数组的 jQuery API 方法的结果。
迭代数组项
在没有任何科学数据来支持这一断言(或寻找这种统计数据的动机)的情况下,我会说,根据我自己作为开发人员的经验,迭代数组中的项目是软件项目中最常见和最基本的模式之一。正如所料,jQuery 有一个迭代数组(和对象)的方法— $.each()
:
1 var languages = ['C', 'JavaScript', 'Go'];
2
3 $.each(languages, function(index, language) {
4 // ...
5 });
该循环将执行三次,对每个数组元素执行一次。对于每次迭代,index
将分别为:0
、1
和2
。类似地,language
值将是"C"
、"JavaScript"
和"Go"
。您可能很熟悉 jQuery,所以这并不奇怪。也许您已经知道如何不用 jQuery 迭代数组元素。但是,您可能不熟悉最现代的数组迭代方法,并且您可能不知道在处理数组时不应该使用的一种特殊类型的循环机制。我接下来会谈到所有这些。
当试图迭代任何东西时,许多开发人员可能会利用传统的for
循环。尤其是如果你有 C/C++或受 C 语言影响的语言的工作经验,比如 Java、PHP 或 Perl(仅举几个例子)。使用 C 循环迭代我们的语言数组所需的代码如下所示(适用于所有浏览器):
1 var languages = ['C', 'JavaScript', 'Go'];
2
3 for (var index = 0; index < languages.length; index++) {
4 var language = languages[index];
5 // ...
6 }
前面循环的另一个优点(除了熟悉之外)是浏览器支持。JavaScript 一直支持 c 循环。但是显然这种方法不如 jQuery 的each()
方法优雅。在我们的“本地”解决方案中有一些看似不必要的样板文件。幸运的是,ECMAScript 5 在Array.prototype
上定义了一个新方法:forEach()
。 22 它的行为与 jQuery 的each()
完全一样,只是有一个更吸引人的 API:
1 var languages = ['C', 'JavaScript', 'Go'];
2
3 languages.forEach(function(language) {
4 // ...
5 });
你能指出forEach()
比$.each()
提供的两个改进吗?
- 更直观的语法。您可以直接在数组上调用 loop 方法,而不是将数组传递给实用函数。
- “当前项”参数是传递给回调函数的第一个参数。反正这通常是你最感兴趣的。有时候,数组索引并不重要。
这两项改进还消除了冗余代码,使循环更具可读性。但是您可能想知道,“为什么不直接使用 for。。。在循环?”当然,这是上一节中处理对象时相当优雅的解决方案。但是使用 for 时存在一个隐患。。。当试图迭代Array
元素时,在循环中。for 遍历的元素的顺序。。。不保证在循环中。换句话说,你的理由是可以想象的。。。例如,in 循环可能在第一项之前遇到第二项。如果顺序很重要,这可能会在代码中导致意想不到的结果,这是一个合理的假设,因为数组是用来以特定的顺序存储数据的。另一个问题:对于。。。in 循环将包含属于目标对象(在本例中是一个数组对象)的所有属性。因此,如果一个自定义属性被添加到Array
实例中,它将意外地包含在索引值中。总之,不要用 for。。。在循环中迭代数组。还有很多其他更安全的选择。
一个更现代的本机循环机制是 for,它适用于除 IE 之外的现代浏览器。。。of 循环,首先在 ECMAScript 6 中定义: 23
1 var languages = ['C', 'JavaScript', 'Go'];
2
3 for (var language of languages) {
4 // ...
5 }
在这种情况下,languages
数组中的每个元素都将按照预期的顺序出现。这是因为。。。of 循环调用一个存在于Array.prototype
上的特殊迭代器方法。这个迭代器方法也是在 ES6、 24 中首次定义的,并且出现在其他可迭代类型上,包括Set
、Map
,甚至NodeList
(由querySelectorAll()
返回)。
定位特定项目
能够遍历数组中的项目为其他可能性打开了大门,比如过滤和搜索。本章讨论的所有方法——jQuery 和非 jQuery——都只是传统循环的包装器。但是这些更集中的函数使得搜索和过滤数组更加直观。
幸运的是,jQuery 将自己的两个 API 方法专用于搜索和过滤。首先说一下$.inArray()
,这是一个返回匹配元素索引的方法(如果找不到匹配,则返回–1)。考虑以下阵列:
1 var names = [
2 'Joe',
3 'Jane',
4 'Jen',
5 'Jim',
6 'Bill',
7 'Beth'
8 ];
如果我们想定位“Jen”的位置,我们可以很容易地使用 jQuery 的inArray()
方法:
1 // returns 2
2 $.inArray('Jen', names);
如果没有 jQuery,在不编写自己的助手函数的情况下,就没有在古代浏览器中搜索数组的好方法,例如:
1 function inArray(value, array) {
2 var foundIndex = -1;
3 for (var index = 0; index < array.length; index++) {
4 if (array[index] === value) {
5 foundIndex = index;
6 break;
7 }
8 }
9 return foundIndex;
10 }
11
12 // returns 2
13 inArray('Jen', names);
幸运的是,现代浏览器在Array.prototype
: 25 上使用 ES5 的indexOf()
方法要简单一些
1 // returns 2
2 names.indexOf('Jen');
就像Array.prototype.forEach()
,indexOf()
提供了比 jQuery 的$.inArray()
更优雅的接口,并解决了完全相同的问题。但是现代浏览器对数组的支持远不止于此。假设您不寻找特定的值,但是您需要收集一些关于您的阵列的信息。假设您想知道names
数组中是否至少有一个值以字母“B”开头。Array.prototype.some()
允许你传递一个函数来测试数组的元素。条件一满足,该方法就返回true
。否则,一旦所有数组项用尽,将返回false
:
1 // returns true
2 names.some(function(name) {
3 return name[0] === 'B';
4 });
请注意我们是如何将字符串文字作为数组来处理的。ECMAScript 5 首先规范了这种行为,所有现代浏览器都支持这种行为。但是字符串不是真正的数组,它是一个“伪数组”稍后会详细介绍。类似地,如果每个数组项都匹配通过的测试函数,Array.prototype.every()
将返回true
。这也是 ECMAScript 5 的一种方法,所有现代浏览器都支持。
ECMAScript 2015 提供了Array.prototype.findIndex()
26 和Array.prototype.find()
27 。前者将返回与通过的测试函数匹配的数组项的索引,后者将返回实际匹配的数组项,对于除 IE 之外的现代桌面浏览器和部分移动浏览器:
1 // returns 4
2 names.findIndex(function(name) {
3 return name[0] === 'B';
4 });
5
6 // returns "Bill"
7 names.find(function(name) {
8 return name[0] === 'B';
9 });
截至 2016 年年中,find()
和findIndex()
的浏览器支持有些有限。在已经拥挤的数组方法组中,ECMAScript 2016 提供了另一种方法来确定特定元素是否存在于数组中。我们可以使用Array.prototype.includes()
28 更优雅地判断一个或多个数组项是否为“Bill”,对于除 IE 和 Edge 之外的现代桌面浏览器,以及除 IE 之外的所有移动浏览器:
1 // returns true
2 names.includes("Bill");
可以说,这些新方法中有许多是不必要的,实际上只节省了一两行代码,但是它们的出现说明了这种语言在响应 jQuery 等库方面的快速发展。
jQuery 提供的另一个特定于数组的 API 方法是$.grep()
,它搜索一个数组并返回匹配一个测试函数的所有项。使用我们的names
数组,您可以使用$.grep()
来定位所有正好三个字符长的项目:
1 // returns ["Joe", "Jen", "Jim"]
2 $.grep(names, function(name) {
3 return name.length === 3;
4 });
ECMAScript 5 提供了一个Array.prototype.filter()
来解决现代浏览器中同样的问题:
1 // returns ["Joe", "Jen", "Jim"]
2 names.filter(function(name) {
3 return name.length === 3;
4 });
尽管 jQuery 版中已经有了很多这样的实用函数,但是 JavaScript 已经走过了很长的路。这种语言不仅提供了这些 jQuery 函数的本地实现,甚至还提供了额外的方法,这些方法提供了比 jQuery API 中的任何东西都更多的功能。
管理伪阵列
在 JavaScript 中,有真实的数组:
1 var realArray = ['a', 'b', 'c'];
。。。和“伪数组”:
1 var pseudoArray = {
2 0: 'a',
3 1: 'b',
4 2: 'c',
5 length: 3
6 };
还有一些本机伪数组,例如NodeList
、、 29 HTMLCollection
、 30 和FileList
、 31 等等。伪数组不是真正的数组,因为它们和Array
不在同一个原型链上。也就是说,它们没有从Array.prototype
继承任何东西,因为它们不是数组。事实上,它们只是普通的老物件。但是,由于它们的length
属性,在某些方面,您可以将它们视为一个数组。例如,您可以使用 C-loop 迭代它们的“元素”,就像您对任何真实数组所做的那样。
当在一个古老的浏览器中处理伪数组时,它不是从Array.prototype
继承的这个事实可能并不重要,因为无论如何你都需要使用一个传统的for
循环来迭代这些项。但是,假设您使用的是现代浏览器,并且您想让这个看起来非常像数组的对象表现得像数组一样。也许你想使用forEach()
,或者map()
,或者filter()
,或者我在这一节已经介绍过的任何其他数组方法。或者你需要把它传递给一个 API 方法,该方法需要一个真实的数组。
您可以在 jQuery 中使用$.makeArray();
将伪数组转换为真实数组
1 var realArray = $.makeArray(pseudoArray);
2
3 // now you can call all methods available on Array.prototype:
4 realArray.forEach(function(element, index) {
5 // ...
6 });
如果您想在不使用 jQuery 的情况下将伪数组转换为真正的数组,可以使用以下技巧:
1 var realArray = [].slice.call(pseudoArray);
2
3 // now you can call all methods available on Array.prototype:
4 realArray.forEach(function(element, index) {
5 // ...
6 });
真实世界的场景可能需要您选择一组元素并对它们进行迭代,或者甚至收集那些满足特定标准的元素。想象一下选择所有文本输入并排除那些空的:
1 var textInputs = document.querySelectorAll('INPUT[type="text"]');
2 var textInputsArray = [].slice.call(textInputs);
3 var nonEmptyFields = textInputsArray.filter(
4 function(input) {
5 return input.value.length > 0;
6 }
7 );
为什么会这样?Array.prototype
上的slice()
方法只期望它正在操作的对象具有数字属性和length
属性。因此,通过将对slice()
的调用的上下文更改为伪数组,我们最终得到了一个真正的数组作为交换。这也是有效的,因为Array.prototype.slice()
在没有参数的情况下被调用时,只是返回一个新的Array
。
前面的“技巧”看起来更像是一种黑客行为,尽管它可以可靠地跨浏览器工作。从 ECMAScript 2015 开始,我们在Array
对象上有了一个官方方法,将伪数组转换为真实数组:Array.from()
:
1 var realArray = Array.from(pseudoArray);
2
3 // now you can call all methods available on Array.prototype:
4 realArray.forEach(function(element, index) {
5 // ...
6 });
不那么令人讨厌,但不幸的是,Internet Explorer 或大多数移动浏览器都不支持这种方法。随着时间的推移,这将不再是一个问题。同时,如果您想在不使用 jQuery 的情况下使用伪数组,前面演示的跨浏览器代码是有效且简单的。
映射和合并
这里,看看这一系列的名字:
1 var names = [
2 'ray',
3 'kat',
4 'mark',
5 'emily'
6 ];
你注意到这些名字有什么问题吗?名称是英语中的专有名词,因此应该以大写字母开头,但是我们数组中的所有名称都以小写字母开头。也许这些名字不是用来展示的。解决这个问题的一种方法是创建一个新数组,每个名称都要有适当的大小写。jQuery 的map()
函数允许我们这样做——基于现有数组创建一个新数组:
1 // properNames will contain properly-cased names after execution
2 var properNames = $.map(names, function(name) {
3 return name[0].toUpperCase() + name.slice(1);
4 });
在古老的浏览器中,我们必须创建一个新的空数组,使用 C 循环迭代 names 数组的值,并将每个大小写正确的名字推送到新的properNames
数组中。这并不难,但是如果有更好的解决方案(不使用 jQuery)就更好了。ECMAScript 5 给了我们一个原生的解决方案:Array.prototype.map()
:32
1 // properNames will contain properly-cased names after execution
2 var properNames = names.map(function(name) {
3 return name[0].toUpperCase() + name.slice(1);
4 });
将一个数组映射到一个新数组显然是有用的,但是如果我们想要组合两个数组呢?和往常一样,jQuery 为这个特定的目的提供了一种方法:名为merge()
。让我们使用 jQuery 的merge()
方法来组合两个用户数组。这些数组中的每一个都代表来自不同系统的一组用户,我们希望在单个数组中考虑每一组用户。
这两个数组如下所示:
1 var users1 = [
2 {name: 'Ray'},
3 {name: 'Kat'},
4 {name: 'Mark'}
5 ];
6
7 var users2 = [
8 {name: 'Emily'},
9 {name: 'Joe'},
10 {name: 'Huang'}
11 ];
我们的 jQuery 代码如下:
1 // users1 will contain all users1 user and
2 // all users2 users after this completes
3 $.merge(users1, users2);
现在我们有了一个包含两组用户的数组,但是我们也修改了其中一个原始数组(users1
)。我们可能不想这么做。相反,创建一个合并了users1
和users2
内容的新数组可能更明智。虽然$.merge()
并没有考虑到这个特定的场景,但它仍然是可能的,尽管这个解决方案并不特别优雅:
1 // users3 will contain all users1 user and
2 // all users2 users after this completes
3 var users3 = $.merge($.merge([], users1), users2);
您也可以使用本机的Array.prototype.concat()
方法将两个数组合并成一个新数组:
1 // users3 will contain all users1 user and
2 // all users2 users after this completes
3 var users3 = users1.concat(users2);
这不仅比使用 jQuery 简单得多,而且所有浏览器都支持它。但是让我们说,为了便于讨论,你真的想将所有来自users2
的条目合并到users1
中。在这个特定的(可能不常见的)场景中,jQuery 的merge()
方法在简单性方面胜出,但是您仍然可以使用 TC39 小组提供的工具来完成这个任务,而不会有太大的麻烦: 33
1 // users1 will contain all users1 user and
2 // all users2 users after this completes
3 users2.forEach(function(user) {
4 users1.push(user);
5 });
通过使用标准的 C 循环代替Array.prototype.forEach()
,我们也可以让前面的代码在古老的浏览器中工作。
但是还有一个选择!ECMAScript 2015 定义了一个新的“spread”运算符 34 ,可用于(除其他外)组合两个数组,适用于除 IE 以外的浏览器:
1 // users3 will contain all users1 user and
2 // all users2 users after this completes
3 var users3 = [...users1, ...users2];
上述内容在功能上等同于我们之前使用的Array.prototype.concat()
。users3
数组包含其他两个用户数组的值,并且对users1
或users2
没有任何改变。
有用的函数技巧
有许多有趣的方法可以让 JavaScript 数组和对象为您解决问题并满足您项目的需求。在前面的章节中,我们已经走过了许多这样的例子。我已经介绍了一般的Object
和Array
对象,但是我还没有介绍一个更重要的Object
类型:function
s!没错,JavaScript 函数也是对象。
在这最后一节中,您将学习如何创建新函数、更改现有函数的上下文,甚至创建一个新函数来调用带有一组默认参数的旧函数。jQuery 为所有这些提供了一些支持,但是,和往常一样,有效地使用函数所需的所有功能都存在于底层语言(JavaScript)中。
关于 JavaScript 上下文的一句话
有许多重要的概念,JavaScript 和前端开发人员必须了解才能有效。虽然我的意图不是在本书中涵盖所有这些概念,但我已经演示了相当多的概念。在这一节中,我将关注另一个重要的语言特性:上下文。
在 JavaScript 中,在代码执行期间的任何给定时间,上下文决定了关键字this
的值。对于一个经常被混淆和误解的概念,这是一个非常简单的解释。一个常见的错误是混淆了范围和上下文,或者忽略了范围和上下文之间的区别。可以说,范围和上下文是两个完全不同的概念。上下文处理的是this
,而作用域描述的是在特定的点上哪些变量是可访问的。在 ECMAScript 2015 之前,JavaScript 只支持函数作用域。现在,随着const
和let
关键字的引入,块范围也终于可用了。
我认为解释上下文的最好方法是通过代码示例。接下来,我将举例说明三种不同的常见场景,其中this
的值不同。这并不意味着对上下文的完整和详尽的讨论,而是一些信息,以消除该主题的一些复杂性,并让您获得更好的整体理解。
在第一个例子中,函数中的值this
将是“全局”对象。在浏览器中,这个全局对象是window
,在服务器上(带有 Node.js)这个值是global
对象。
1 function printThis() {
2 console.log(this);
3 }
4
5 printThis();
该代码将把window
或global
对象记录到控制台。但是有一点问题。如果该代码在“严格”模式下执行,this
的值将为undefined
。要在严格模式下执行相同的代码,我们需要做的就是将“使用严格”pragma 添加到我们的printThis()
函数的顶部:
1 function printThis() {
2 'use strict';
3 console.log(this);
4 }
5
6 printThis();
而现在,undefined
登录到控制台。ECMAScript 5 中首次引入了严格模式。 35 虽然我不打算在本书中谈论更多,但可以说严格模式试图防范一些潜在的危险编码错误,例如依赖独立函数内的this
的值或试图覆盖arguments
伪数组。浏览器对严格模式的支持仅限于现代浏览器,Internet Explorer 9 除外。
JavaScript 中上下文的第二个例子涉及执行作为对象属性的函数。您认为下面的代码片段会打印到控制台上吗?
1 var person = {
2 name: 'Ray',
3 printName: function() {
4 console.log(this.name);
5 }
6 }
7
8 person.printName();
如果你猜对了“雷”。当一个函数属于一个对象时,该函数的上下文被绑定到父对象。在这种情况下,严格模式不会改变上下文。在某些方面,这与前面的例子没有什么不同。因为所有的“孤儿”函数都属于global
或window
对象,所以这些函数的上下文必然会绑定到window
/ global
对象,至少为了一致性。
上下文的第三个也是最后一个例子与构造函数有关。构造函数代表一个可以使用关键字new
创建的对象。考虑下面的构造函数和一些构建关联对象的新实例的代码。也许您可以猜到控制台上会打印出什么内容:
1 var Person = function(name) {
2 console.log(name + ' lives in the ' + this.country);
3 };
4 Person.prototype.country = 'United States';
5
6 var rayPerson = new Person('Ray');
剧透警告:执行前面的代码会将“Ray lives in the United States”打印到控制台上。但是为什么呢?Person
是一个“孤儿”函数,不是吗?是的,是的,如果我们从最后一行中删除了关键字new
,我们的代码片段将以var rayPerson = Person('Ray')
结束,而this.country
将是undefined
(当然,除非其他一些代码事先给window
/ global
对象添加了一个country
属性)。当一个函数被“构造”时,该函数的上下文被绑定到相应的对象。在本例中,这是一个Person
对象,在其原型链上有一个country
属性。
关于这个构造函数的另一个问题是:为什么构造函数名的第一个字母是大写的?这与范围完全无关,但仍然是一个公平的问题。按照惯例,构造函数的名字是大写的。当这个约定被一致地遵循时,开发人员就更容易区分应该使用new
关键字构造的对象和不应该构造的对象。如果一个应该使用new
创建的对象没有以这种方式创建,可能会出现意外的行为。
从旧函数创建新函数
了解上下文如何分配给一个函数的细节是有用的,但这些并不是一成不变的规则。不,一点也不。可以为任何函数任意指定上下文。首先,您可以从现有函数创建一个新函数,并使用备用上下文初始化这个新函数。jQuery 为此提供了$.proxy()
:
1 var person = {
2 name: 'Ray',
3 handleClick: function() {
4 console.log(this.name + ' was clicked');
5 }
6 }
7
8 $('#my-person').click(
9 $.proxy(person.handleClick, person)
10 );
当点击 ID 为“my-person”的元素(大概是一个<button>
)时,“Ray 被点击”将被打印到控制台。如果我们删除了$.proxy()
调用,结果将会是“undefined 被点击”。但是为什么呢?属于person
对象的handleClick()
函数不是应该默认接收person
作为它的上下文吗?是的,默认情况下会,但是handleClick()
不是普通的函数——它是一个事件处理器。浏览器会自动将所有事件处理程序的上下文设置为目标元素。在这种情况下,这将是“点击”的元素,这不是我们想要的。因此,我们需要创建一个新的函数,使用person
对象作为它的上下文,这样我们就可以在处理点击事件时记录这个人的名字。正如您之前看到的,jQuery 的proxy()
API 方法提供了实现这一点的方法。
在 ECMAScript 5 出现并创建了Function.prototype.bind()
方法之前,jQuery 的proxy()
方法非常有用和优雅。 36 在支持bind()
之前,需要一个令人眼花缭乱的 polyfill 来实现相同的行为,或者你可以简单地引入 jQuery 并继续为你的项目编码。幸运的是,所有现代浏览器都支持bind()
,对于现代浏览器来说,前面的例子可以在没有 jQuery 和包装器的情况下复制:
1 var person = {
2 name: 'Ray',
3 handleClick: function() {
4 console.log(this.name + ' was clicked');
5 }
6 };
7
8 document.getElementById('my-person').addEventListener(
9 'click',
10 person.handleClick.bind(person)
11 );
奇妙——我们可以改变事件处理程序的上下文来匹配持有这些函数的对象,反映我们对绑定到对象的函数属性的期望。但是如果我们需要做得更多呢?当处理函数被绑定时,如果我们需要向这个事件处理程序提供特定的参数怎么办?假设句柄还需要知道它所绑定的对象之外的环境的一些细节。在我们的例子中,person.handleClick()
函数还需要有一个当前登录用户的句柄。使用 jQuery 的proxy()
函数,我们可以简单地通过将用户信息作为附加参数传递给上下文参数之后来满足这个新需求:
1 var person = {
2 name: 'Ray',
3 handleClick: function(user) {
4 console.log(this.name + ' was clicked by ' + user);
5 }
6 };
7
8 $('#my-person').click(
9 $.proxy(person.handleClick, person, 'Kat')
10 );
我们也可以用Function.prototype.bind()
来做这件事!
1 var person = {
2 name: 'Ray',
3 handleClick: function(user) {
4 console.log(this.name + ' was clicked by ' + user);
5 }
6 };
7
8 document.getElementById('my-person').addEventListener(
9 'click',
10 person.handleClick.bind(person, 'Kat')
11 );
同样,与$.proxy()
示例一样,前面的代码将“Ray 被 Kat 点击”打印到控制台。我怀疑你们中的一些人想知道通常作为第一个参数传递给事件处理函数的Event
对象参数发生了什么。它仍然在那里,并且由浏览器传递给我们的事件处理程序。还记得我们用bind
创造的新功能吗?我们坚持让我们的新函数接收一个参数“Kat”这将始终是我们的新函数收到的第一个参数,使Event
对象排在第二位。如果我们在调用bind()
时传递了两个新参数,Event
将会是第三个参数。jQuery 的proxy()
方法也是如此。
用新上下文调用现有函数
除了创建一个全新的函数之外,还有一种方法可以改变上下文并增加参数。相反,您可能更喜欢按需调用原始函数,并在调用时指定新的上下文(和可选的参数)。jQuery 没有提供任何方法来实现这一点,这可能是因为 JavaScript 在早期就支持这种行为。
JavaScript 的第一个版本包含了Function.prototype.call()
,它允许使用可选的上下文调用任意函数。让我们重写第一个普通的 JavaScript person
事件处理程序,它用call
代替bind
:
1 var person = {
2 name: 'Ray',
3 handleClick: function() {
4 console.log(this.name + ' was clicked');
5 }
6 };
7
8 document.getElementById('my-person').addEventListener(
9 'click',
10 function() {
11 person.handleClick.call(person);
12 }
13 );
我们不是基于person.handleClick()
函数创建一个新函数,而是向addEventListener()
提供一个函数,当通过点击动作执行时,该函数调用上下文为person
的person.handleClick()
函数。结果和我们之前的bind
例子一样,但是方法有点不同。在引入bind
之前,以这种方式使用call
是使用 polyfill 或引入 jQuery 来利用$.proxy()
的一种替代方法。
与bind()
一样,call()
方法也提供了一种向目标函数提供额外参数的方式。因此,我们也可以使用call()
重写第二个bind()
示例:
1 var person = {
2 name: 'Ray',
3 handleClick: function(user) {
4 console.log(this.name + ' was clicked by ' + user);
5 }
6 };
7
8 document.getElementById('my-person').addEventListener(
9 'click',
10 function() {
11 person.handleClick.call(person, 'Kat');
12 }
13 );
与bind()
一样,call()
方法接受一个新的上下文作为第一个参数,并接受一组以逗号分隔的初始参数传递给目标函数。这个逗号分隔的列表成为目标函数可用的arguments
伪数组。在我们的例子中,传递给初始事件处理函数的参数被完全忽略了(对象Event
)——它没有传递给handleClick()
函数。这是call()
与bind()
的另一个不同之处。在需要将单个已知参数传递给目标函数的情况下,这是理想的。但是,如果中间函数必须充当目标函数的简单代理,传递逗号分隔的参数列表的要求就成了一个限制。
想象一个被设计用来记录函数调用的函数。它不想也不需要知道太多关于目标函数的信息。它的工作很简单:拦截函数调用,记录它,然后将控制传递给目标函数。为了正确地完成这一壮举,我们必须能够将整个arguments
伪数组传递给目标函数。为此,我们必须使用Function.protoype.apply()
,它允许我们传递一个数组或类似数组的对象,以及目标函数的上下文:
1 var person = {
2 name: 'Ray',
3 handleClick: function(user) {
4 console.log(this.name + ' was clicked by ' + user);
5 }
6 };
7
8 function logFunctionCall(targetFunction) {
9 // We need to pass only the "original" arguments, which means
10 // we have to slice off the first one passed to this function,
11 // and remember `arguments` is a pseudo-array!
12 var originalArguments = [].slice.call(arguments, 1);
13
14 console.log('Calling ' + targetFunction.toString() +
15 ' with ' + originalArguments);
16
17 targetFunction.apply(this, originalArguments);
18 }
19
20 document.getElementById('my-person').addEventListener(
21 'click',
22 logFunctionCall.bind(person, person.handleClick, 'Kat')
23 );
在我们最初的事件处理程序中,为了简单起见,我们使用bind()
来调用我们的中间日志记录函数。log()函数的上下文将是person
对象。这将使 log()函数更容易盲目地调用具有所需上下文的目标函数。当然,我们需要向handleClick()
函数提供当前用户名,以便将其传递给 log()函数,以及对handleClick()
函数的“引用”。
当调用logFunctionCall()
函数作为处理 click 事件的一部分时,目标函数源和和目标参数被记录到控制台,然后使用提供给原始调用函数的任何参数调用目标函数。在这种情况下,这将确保用户名“Kat”和代表点击事件的MouseEvent
对象实例都被传递给person.handleClick()
函数。由于logFunctionCall
的上下文已经绑定到了person
对象,我们可以在调用目标函数时使用this
作为所需的上下文。
最后一个例子比前面的一些例子稍微复杂一些,但是它说明了强大的 API 的一个重要的真实世界的用法,这个 API 对于 web 开发人员来说是天生可用的。您可以断言对函数、数组、对象和原语的完全控制,而无需任何库的帮助。对这种语言的简单理解就足够了!
Footnotes 1
https://api.jquery.com/category/utilities/
2
www.ecma-international.org/publications/files/ECMA-ST-ARCH/ECMA-262%2C
3
www.ecma-international.org/ecma-262/5.1/#sec-15.9.4.4
4
https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L453
5
www.ecma-international.org/ecma-262/5.1/#sec-15.12
6
https://github.com/douglascrockford/JSON-js/blob/master/json2.js
7
https://msdn.microsoft.com/en-us/library/azure/dd179382.aspx
8
9
10
https://www.w3.org/TR/cssom-1/
11
www.ecma-international.org/ecma-262/5.1/#sec-15.5.4.20
12
13
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String
14
www.ecma-international.org/ecma-262/5.1/#sec-15.6.4.3
15
www.ecma-international.org/ecma-262/5.1/#sec-4.3.11
16
http://wiki.ecmascript.org/doku.php?id=harmony%3Atypeof_null
17
www.ecma-international.org/ecma-262/5.1/#sec-15.4.3.2
18
https://bugs.jquery.com/ticket/5499
19
www.ecma-international.org/ecma-262/5.1/#sec-15.2.3.14
20
www.ecma-international.org/ecma-262/6.0/#sec-object.assign
21
www.ecma-international.org/ecma-262/6.0/#sec-properties-of-the-object-prototype-object
22
www.ecma-international.org/ecma-262/5.1/#sec-15.4.4.18
23
www.ecma-international.org/ecma-262/6.0/#sec-for-in-and-for-of-statements
24
www.ecma-international.org/ecma-262/6.0/#sec-symbol.iterator
25
www.ecma-international.org/ecma-262/5.1/#sec-15.4.4.14
26
www.ecma-international.org/ecma-262/6.0/#sec-array.prototype.findIndex
27
www.ecma-international.org/ecma-262/6.0/#sec-array.prototype.find
28
https://tc39.github.io/ecma262/#sec-array.prototype.includes
29
https://developer.mozilla.org/en-US/docs/Web/API/NodeList
30
https://developer.mozilla.org/en-US/docs/Web/API/HTMLCollection
31
https://developer.mozilla.org/en-US/docs/Web/API/FileList
32
www.ecma-international.org/ecma-262/5.1/#sec-15.4.4.19
33
www.ecma-international.org/memento/TC39.htm
34
www.ecma-international.org/ecma-262/6.0/#sec-array-initializer
35
www.ecma-international.org/ecma-262/5.1/#sec-10.1.1
36