超越-jQuery-全-
超越 jQuery(全)
一、jQuery 的压倒性魔力
多年来,业余和专业 web 开发人员都在使用 jQuery 来减轻将库或 web 应用推向市场的负担。从某种意义上说,jQuery 已经成为 web 开发不可或缺的一部分。即使在撰写本文时,jQuery 仍被绝大多数公共网站引用, 1 远远超过其他任何库。
许多开发人员似乎认为 jQuery 是一个默认需求。普遍的想法是:如果你在开发一个库或者一个 web 应用,你必须依赖 jQuery。jQuery 被视为这个神奇的黑匣子,可以解决 web 开发的所有难题。这是一个易于理解的框架,甚至允许新手快速整理他们的想法。
专业人士也倾向于对 jQuery 投入很多。毕竟这是我们新手时候用的。很舒服。我们理解。我们信任它。这些年来,它为我们提供了良好的服务。您不必(过多)考虑 DOM、浏览器错误或跨浏览器行为。jQuery 为我们解决了所有这些问题。。。不是吗?
不可否认,jQuery 确实有点神奇。事实上,它允许几乎任何技能水平的开发人员创建有用的东西。但是代价是什么呢?只考虑通过玫瑰色的 jQuery 透镜来理解 web。如果遇到 jQuery 没有正确抽象的底层行为怎么办?遇到 jQuery 的 bug 怎么办?如果您只是不能(被允许)使用 jQuery 怎么办?这类似于一个城市居民被扔进了西伯利亚的苔原。在这种情况下,你会害怕,迷失方向,准备不足。
尽管违背自己的意愿被带到异国他乡的可能性很小,但未来没有 jQuery 的可能性要大得多。如果您没有很好地掌握 DOM、web API 和 JavaScript,您最终会感觉有点像一个冷漠而困惑的都市人,试图在西伯利亚广袤无垠的陌生环境中生存。
Beyond jQuery 的一个目标是揭开这个看似无处不在的前端库的神秘面纱。摆脱对 jQuery 的盲目依赖的好处将变得显而易见。在本书的结尾,鉴于你对浏览器 API 和 JavaScript 的新理解,你将有能力作为一名 web 开发人员进一步成长。
本章探究了开发人员依赖 jQuery 的原因,以及他们继续依赖 jQuery 的原因。您将会看到为什么完全依赖一个单一的库会在您的知识中产生缺口,并阻止您作为开发人员的发展。我将讨论为什么对 web 和 JavaScript 的片面理解对开发人员来说是一个潜在的危险困境。有了这些知识,你就能更好地理解更多地依靠自己对基本面的扎实理解的好处。
为什么我们一直在使用 jQuery?
在探索我们应该如何(以及为什么)考虑从我们的工具箱中移除 jQuery 之前,我们应该首先理解 jQuery 为什么会存在。为什么这么多年来无数的 web 开发人员依赖这个库?为什么它一直是网站和应用的核心组件?为什么它继续如此普遍?为什么我们一直在使用 jQuery?我们都有自己的理由,而且确实有许多理由。最重要的是,jQuery 已经被证明提供了一个低门槛的入口。换句话说,即使是业余或偶尔的开发人员也发现,它允许他们几乎没有阻力地实现一个概念或想法。
简单
假设您正在阅读这本书,那么您已经对 jQuery 有所了解。你可能已经在某个项目中使用过它,不管它有多大。因此,让我们来探究为什么这个库对于所有技能水平的开发人员来说都如此容易使用。
最重要的是,jQuery 的 API 是直观的。想给一个元素添加一个 CSS 类?就用addClass()
的方法。需要发送帖子请求吗?就用post()
的方法。隐藏一个元素就像将它作为参数传递给 jQuery 的hide()
方法一样简单。
jQuery 的魅力在它极其简单的 API 中显而易见。它允许那些之前对浏览器或 JavaScript 知之甚少的人创建一些有趣且有用的东西。这对那些只涉足 web 开发的人来说非常有吸引力,甚至可以说是最合适的。反过来,jQuery 的简单性对于专业的 web 开发人员来说也是一个潜在的危险。如果你还不相信这是真的,我们将在后面更深入地探讨这个理论。
社区
关于堆栈溢出(在撰写本书时),120 万个问题被标记为 JavaScript 问题,75 万个被标记为 jQuery 问题。jQuery 是栈溢出中第六受欢迎的标签。下一个最受欢迎的前端库是 AngularJS in a distant 21st place,它只有 20 万个标记问题。有趣的是,200,000 个问题被标记为 jQuery 而不是 JavaScript。在许多情况下,jQuery 并不被视为 JavaScript 库。事实上,它被视为 JavaScript 的替代品。一种无需处理底层语言或 API 就能解决浏览器问题的方法。虽然有些人可能不清楚 jQuery 与 JavaScript 的关系,但是 jQuery 并不缺少愿意并准备好为这个库提供建议的开发人员。在栈溢出的 750,000 个 jQuery 标记的问题中,550,000 个(74%)包含至少一个投票赞成的答案。
截至 2016 年年中,jQuery 仍然是公共网站中使用最多的 JavaScript 库。事实上,70%的公共网站都在某种程度上依赖于 jQuery。第二个最受欢迎的库是 Bootstrap,仅在 13%的公共网站中使用。有了这个令人印象深刻的市场份额,当然有相当一部分用户对这个主题有一些工作知识。
除了 Stack Overflow 上的 jQuery 标签和大量致力于为投资于该技术的人提供建议的不同论坛和网站之外,jQuery 的网站还有自己的活跃用户论坛。帮助是很容易找到的,你可能遇到的任何问题都可能已经得到解决并经过长时间的讨论。大型成熟社区的现实是依赖任何软件库的一个吸引人的理由。
习惯
针对 jQuery 初学者的大量示例、博客文章和论坛是那些 web 开发新手选择这个库来帮助他们的项目的原因之一。但是经验丰富的开发人员呢?为什么他们继续在项目中使用 jQuery?一个完美的、有经验的开发者曾经是一个业余爱好者。作为业余爱好者,他们很可能已经接受了 jQuery。现在,有了多个项目,jQuery 已经证明了自己。而且就算已经注意到了库中的一些瑕疵,也很好理解。
对于经验丰富的开发人员来说,jQuery 足够一致和可靠。它已经成为发展过程的一部分。一个强大的社区也是有经验的开发人员意识到的一个好处,这也是坚持使用这样一个值得信赖的工具的另一个原因。
jQuery 是我们编写任何东西的先决条件。我们无意识地引入它,部分是因为我们已经习惯性地接受了这样的训练。我们被训练成认为这是每个 web 应用的重要组成部分。习惯很难打破,尤其是那些已经产生积极结果的习惯。
软件开发可能会令人紧张和沮丧。一个典型的开发人员每天都要与无数的变量搏斗。看起来,没有什么问题是容易解决的。工具、过程或结果的一致性和可预测性是非常需要和罕见的。你能责怪 web 开发社区这么长时间依赖于像 jQuery 这样一致可靠的工具吗?
高雅
你听过有人说 DOM 很丑或者 JavaScript 有缺陷并且布满了定时炸弹吗?也许你自己也这样认为。虽然美在很大程度上是主观的,但这似乎是一个令人惊讶的普遍想法,尤其是在那些在 web 开发生涯后期继续使用 jQuery 的经验丰富的开发人员中。
原生浏览器 API 和 JavaScript 经常被认为不够优雅,这显然促使开发人员使用这个库。其思想是,没有 jQuery 的帮助,简单的问题很难解决。不相信我?问一些和你一起工作的开发人员。他们为什么使用 jQuery?期待听到创建优雅简洁的代码来应对常见问题是多么简单。正如本节前面所讨论的,API 本身是直观和优雅的。
但是 jQuery 的优雅不仅仅是一个可预测的 API。围绕 API 设计的可用性考虑进一步证明了这种优雅的说法。
以方法链接为例,它允许开发人员非常容易地将同一元素上的许多操作联系在一起,而没有重复或临时变量创建的负担。假设您想要选择一组元素,然后向所有元素添加一个类,最后向初始元素集的一个更具体的子集添加一个类。通过利用 jQuery API 提供的优雅的方法链,可以非常容易地完成所有这些工作。清单 1-1 通过向包含“字母表”类的所有元素添加一类“下划线”来演示这一点。然后,它只选择包含一类“元音”的“alphabet”元素的子元素,最后用一类“bold”对它们进行注释,同时隐藏任何本身也包含一类“a”的“元音”元素的子元素:
1 $('.alphabet').addClass('underline')
2 .find('.vowels').addClass('bold')
3 .find('.a').hide();
Listing 1-1.
jQuery method chaining
我发现许多开发人员倾向于纠结于 JavaScript 中的异步操作,比如 ajax 请求。发出这些请求并不困难,但是考虑到调用的异步性质,处理结果对一些人来说是令人沮丧的。jQuery 通过将响应处理函数与发送底层请求的函数调用完美地绑定在一起,稍微简化了这一点。清单 1-2 发送一个检索用户名的请求,提供对服务器响应结果的简单访问。
1 $.get('name/123', function(theName) {
2 console.log(theName);
3 });
Listing 1-2.jQuery GET request
Note
除非开发人员工具已打开,否则控制台对象在 Internet Explorer 9 和更低版本中不可用。此外,前面的示例不处理错误响应,只处理成功。
有了这些“美”,我们很容易忘记努力的其他属性,比如表现。在前面的例子中是否有潜在的效率地雷?是的,但是这些可能一开始很难识别。我将在后面的章节中详细讨论这一点。
害怕
jQuery 让一切变得更简单——web 开发很难。没有一些帮助,你无法开发一个可靠的 web 应用或库。如果没有 jQuery,很难保证你的应用在所有浏览器中都可以正常运行。web API 实现在不同的浏览器之间有很大的不同。反正你需要的所有好的插件都依赖于 jQuery。这些都是盲目依赖 jQuery 的常见借口,都是基于恐惧。由于对未知的恐惧,我们都依赖 jQuery。我们将 DOM 视为一个神秘且不可预测的黑匣子,布满了严重的错误。我们担心跨浏览器的实现差异。
jQuery 的创始人 John Resig 早在 2009 年就有一个著名的结论“DOM 一团糟”。 3 在 web 历史的那一刻,ie 6 和 7 几乎占据了浏览器市场的 60%。 4 考虑到这一点,很难反驳雷西格先生当时的说法。DOM 确实是一只可怕而多变的野兽,当时最流行的浏览器都有非常糟糕和有限的内置工具。如果我们把时间追溯到 2006 年 8 月,也就是 jQuery 创建和首次发布的时候,会怎么样呢?当时,Internet Explorer 的最新版本是版本 6。令人难以置信的是,IE6(及更老版本)占所有浏览器使用量的 83%。在这一点上,web API 非常不成熟,浏览器的稳定性远低于我们在当前时代的预期,而且当时的浏览器对标准的遵从也不一致。
除了不成熟的开发工具、不同的 web API 实现和不直观的 DOM 之外,浏览器肯定会有问题。与任何其他复杂的代码包类似,浏览器也不能幸免于错误。jQuery 在历史上承诺了广泛的浏览器漏洞解决方案。许多年来,在标准遵守和质量控制方面,网络似乎类似于蛮荒的西部。不难理解为什么一个旨在规范浏览器的库如此受欢迎。再也不用担心跨浏览器支持了。甚至不用担心跨浏览器测试。jQuery 将为您完成所有繁重的工作,因此您可以完全专注于开发有趣且有用的 web 应用和库。还是可以?虽然 jQuery 有望将您从浏览器中的所有问题和复杂性中解放出来,但现实却有所不同。
拐杖只是暂时的
JavaScript 库通常是有用的工具。它们有助于您构建一个有用且可靠的 web 应用,或者甚至是另一个库。它们节省你的时间和按键。它们充当你、你的代码和浏览器之间的缓冲,填补空白并规范行为。从另一个意义上来说,这些库可以起到拐杖的作用。他们帮助没有经验和没有受过教育的开发人员,实际上没有教给他们任何关于底层复杂性的东西。尽管这本书的基调有时可能会暗示不是这样,但像 jQuery 这样的库本质上并不坏。只有当你的学习没有超越图书馆时,它们才是限制性的。
jQuery 总是能节省你的时间吗?它总是让你的 web 开发体验变得更容易吗?与核心库或其插件相关的约定是直观的吗?它真的解决了你所有的问题吗,或者它也许制造了一些新的问题?您是否花时间思考过该库附带的语法和约定?有没有更好的解决方案,或者 jQuery 真的修补了开发人员经常陷入的所有可用性漏洞?大多数情况下,jQuery 的 API 赏心悦目,非常直观。当然,这并不总是正确的。图书馆中肯定有令人不愉快的部分。让我们举一个 jQuery 的优雅和必要性的例子。
jQuery 并不能完全保护你免受浏览器的困扰。这不是任何图书馆的现实目标。除此之外,jQuery 只是一个库,一个工具,一个助手。它并不意味着取代整个浏览器栈。有些问题甚至最好用 CSS 或静态 HTML 来解决。但是对于使用 jQuery 的开发人员来说,这是与浏览器交互的唯一方式。对于不知情的开发人员来说,使用 jQuery 的 API 编写最少的 HTML 并对标记进行任何调整都是完全合理的。或者,使用 jQuery 生成所有标记可能会更容易。您可以使用 jQuery 创建元素,然后轻松地将它们插入到页面中。
不在 CSS 文件中声明样式,倾向于使用$(element).css('fontWeight', 'bold')
。虽然非常方便,但这是一种非常不可维护的生成内联样式的方法。对于新的开发人员来说,关注点分离的重要性可能不是很明显。jQuery 神奇的无所不包的 API 让我们很容易忽略可用的原生工具。当你盲目地依赖一个整体的抽象时,HTML、CSS 和 JavaScript 的适当角色并不总是在等式中出现。这个库不仅仅是一些人的工具,它是工具。它是 web 开发的全部和最终目标。您将会看到为什么这是一种危险的思路,尤其是对于专业的和有抱负的开发人员。
事实上,jQuery 是许多人的拐杖。它没有融合到浏览器中,只是作为一个补充。经验丰富、知识渊博的开发人员实际上可能更喜欢使用 jQuery,这当然没有错。但对其他人来说,它只是一个道具。那些刚接触 web 开发的人通常会拿起拐杖,蹒跚而行一段时间。但最终,拐杖从他们身下拔出,他们倒下了。
你是机械师,不是司机
关于堆栈溢出的一个流行问题是“在学习 jQuery 之前学习 JavaScript 是个好主意吗?”这个问题的一个特殊答案提供了一些奇怪的建议。这位投稿人在他的回答中继续说道“如果你打算使用一个像 jQuery 这样的框架,你真的不需要太专注于学习 HTML DOM 的细节。只需按照 jQuery 方式’做事,然后根据需要尽可能多地获取 HTML DOM。” 8 尽管这种想法在回答这个问题的其他人(也许是更有经验的开发人员)中并不常见。我自己也曾是一名没有经验的 web 开发新手,还记得这种思路是如何被那些进入基于浏览器的前端编码的混乱世界的人所接受的。
在我写的题为“你不需要 jQuery”的一系列博客文章中, 9 一位评论者提供了一个惊人的类比,概述了博客(和这本书)的目标之一。
“我试图指导我的同行的一件事是,你不能在浇注地基之前就在地基上竖起墙。事实上,你应该平整地面,铺设功能性管道(测试),然后在开始建造结构之前浇筑地基。这涉及到在为任何端点(浏览器)构建时理解你的工具的核心(HTML、CSS、JS),10Lawrence Francell)。
换句话说,对于一个稳定、持久的应用或库,您必须很好地理解您的工具是如何工作的。达不到这一点,你就不是开发者。事实上,你是一个图书馆集成者。
很难反驳评论者的合理建议,但是 jQuery 惊人的魔力有时似乎会蒙蔽我们。这并不意味着我们是“糟糕的”开发者。事实上,这甚至可能不是我们的错。我们注定要走阻力最小的路。这实际上是一个经过充分研究和记录的心理学理论,被称为“最小努力原则”(来自乔治·金斯利·齐夫的《人类行为和最小努力原则》(Addison-Wesley Press,1949)。
唱反调,也许我们可以用另一个例子来反驳前面引用的类比。我们大多数人可能每天都在开车。但是我们中有多少人能够诊断出典型内燃机的问题呢?有多少人会做除了换轮胎以外的事情?这些问题的答案可能是“很少”
我们真的需要成为称职的汽车修理工才能开车吗?不,当然不是。对我们许多人来说,驾驶不是一种职业。相反,这是我们所依赖的一种便利。我们没有时间去了解汽车的每一个细节,也不应该去了解。汽车的存在是为了简化我们的生活,节省我们的时间。汽车制造商不希望他们的客户是汽车修理工。出于显而易见的原因,他们设计产品时考虑的是普通人,以确保尽可能多的人可以使用他们的汽车。
我们可以把开车比作开浏览器吗?作为软件开发人员,我们真的需要理解网络的基础吗?正如你在上面的堆栈溢出回答中看到的,有些人可能会说不。但重要的是要明白我们不是驱动程序。我们是机械师和设计师,不是用户。
矮化生长
当你在没有 jQuery 和拐杖的情况下被推进一个新项目时会发生什么?如果您的能力停留在库的 API 的边缘,您的选择是有限的。你在这种抽象中投入太多,危及你超越它的能力。你真的希望你所有的项目都依赖于一段代码吗?短期来看,这似乎不是问题。展望未来,这条道路的可行性变得值得怀疑。
总的来说,软件开发和技术的前景是不断变化的。作为开发人员,我们不仅理解这一点,而且欣然接受。这使得我们的工作既有挑战性又有趣。使用 jQuery 或任何其他库都没有问题。但是通过使用这些作为拐杖,我们不再是软件开发者。我们是 jQuery 程序员。
作为一名新的开发人员,您的目标不一定要围绕敏捷性。在这个早期阶段学习基础知识是至关重要的。随着你的项目和职业的发展,通过适应你的环境——浏览器——你将自己放在一个更好的位置来做出好的决定。只有在您牢固掌握了基础知识并更好地理解了 web 开发的最基本形式之后,您才应该专注于选择和学习一种工具来加速您的开发过程。
这个建议并不是专门针对软件开发的。你还记得你第一次学数学的时候吗?你完成的所有练习都可以用计算器轻松解决。最有可能的是,你被严格禁止使用计算器(我知道我是)。为什么呢?计算器更快更准确。简单来说,在这个阶段,目标不是速度。对数学基础的透彻理解是最重要的。一旦你理解了计算器是如何执行这些任务的,你就可以在将来解决更复杂的问题时选择使用或不使用它。理解基本原理可以确保你不会被工具束缚住。
当我们的选择库逐渐变得过时,或者被人从我们身边拿走,我们盲目的依赖会阻止我们前进。这种不幸情况的例子比比皆是。在 JavaWorld 最近的一篇文章中,作者引用了“你应该使用 jQuery 的 6 个理由”原因是值得怀疑的,因为作者显然对浏览器栈缺乏基本的理解。这一点在诸如“jQuery 是 HTML5 的主要组成部分”这样的说法中尤其明显,它将 JavaScript 与文档标记规范混为一谈。这篇文章中另一个令人不安的引用是:“jQuery 页面加载速度更快。”作为开发人员,正是这种过度简化导致我们认为工作的复杂性是理所当然的。假装一个库就能驯服像浏览器这样的野兽,只会让我们陷入一场最终会失败的令人沮丧的斗争。
捷径的代价(真实故事)
接下来是一个真实的故事,一个真正的 web 开发人员走了真正的捷径(真的)。他只关注短期,关注让他的工作更容易。他主要关心的是取悦项目经理。学习基础知识对他来说是浪费时间。他的目标是尽可能快地写出代码并完成一长串功能。那个开发者就是我。
jQuery 让一切变得更简单。没有它,你无法开发一个可靠的 web 应用。如果没有 jQuery,很难保证你的应用在所有浏览器中都可以正常运行。不同浏览器之间的 DOM API 实现差别很大。反正你需要的所有好的插件都依赖于 jQuery。我相信所有这些借口,甚至更多。有些甚至一度是很好的借口。
一个新的方向,一个新的网络开发者
回到我 web 开发生涯的早期,我正从专门的服务器端工作转型过来。我被分配到 Jennings,一个基于网络的记者制作工具。我没有专业的 HTML、CSS 或 JavaScript 经验。退一步说,我缺乏前端技能。
团队中没有人对 web 开发感到满意。我们都是菜鸟,以前的后端开发人员徒劳地努力在这个新的环境中理解我们的知识。期限是严格的,目标是崇高的。看起来我们都需要一些帮助——也许是一种让我们的工作变得轻松一点的工具。没有时间学习。我们要写一个应用!
捷径和我自己发育不良的成长
我第一次接触 JavaScript 和 web 是通过 jQuery。事实上,我甚至懒得学习适当的 JavaScript。我不知道 web API 是什么样子,也不知道如何直接处理 DOM。jQuery 为我做了一切。当我后来在没有 jQuery 拐杖的情况下完成一个项目时,我知识上的这一巨大差距赶上了我。我被迫学习适当的 web 开发,并且我从未回头。
在 Jennings 之后,jQuery 是所有未来项目中的一个需求(对我来说)。这是必须的,因为我不知道任何其他方法来驯服浏览器。这在当时似乎并不罕见。事实上,它不是。在大多数应用和库中,jQuery 是一个预期的依赖项。我盲目的信仰并不是一个明显的障碍。
在某种程度上,当我搜索插件来解决项目中的常见问题时,这种盲目依赖的一些问题变得很明显。jQuery 本身是一个有用的库,但是它只解决核心的、底层的问题。如果你想支持更高级的特性,比如模态对话框,你需要自己写或者找一个已经解决了问题的插件。
自然,我专门寻找 jQuery 插件来填补项目中的漏洞。事实上,我回避任何不依赖于它的东西。我不相信任何不使用这个神奇盒子的插件。jQuery 解决了我所有的问题,让我跨浏览器开发变得很容易。我为什么要相信一个没有达到同样启蒙水平的开发人员的工作呢?
过了一会儿,很明显这些 jQuery 插件的质量低得惊人。现实是,jQuery 的低准入门槛是一把双刃剑。有时候很容易快速写出有用的东西。但是更容易的是快速编写不可维护的容易出错的代码!我发现很多插件都写得很差。我对 web 开发的新手知识使我很难整理和解决我在使用这些 jQuery 插件库时遇到的问题。挫败感袭来,我作为开发人员的基础开始出现裂缝。
但是写得很差的库中的错误和低效仅仅暴露了冰山一角。这些插件甚至 jQuery 核心中的抽象漏洞泛滥,我几乎无法理解。为什么我不能用 jQuery 触发在 jQuery 之外创建的自定义事件处理程序?jQuery 支持定制事件,为什么不能呢?这是我在从事一个既依赖 jQuery 又依赖 Prototype 的项目时遇到的一个具体问题,Prototype 是一个具有类似目标的替代 JavaScript web 框架。我天真地认为我可以使用 jQuery 轻松地触发与 Prototype 绑定的自定义事件处理程序——没那么幸运。
再以文件上传为例。有人会认为使用 jQuery 上传文件就像在请求中包含文件作为data
一样简单。并非如此。如果这样做,jQuery 将尝试对文件进行 URL 编码。在令人沮丧的大量阅读和实验之后,我了解到必须将两个模糊的属性设置为false
,以确保 jQuery 不会在请求发送之前试图修改文件。
开发人员盲目依赖这个库时还会遇到另一个问题:使用 jQuery 在旧浏览器中发送跨域请求是不直观的。当使用一个旨在消除 web API 差异并允许轻松管理旧浏览器的库时,这是一个令人惊讶的认识。我将在第九章中讨论所有这些以及更多内容。
jQuery 的属性处理实用函数在这个库的生命周期中发生了巨大的变化。让我们考虑一个常见的任务作为例子:确定复选框的状态。在早期版本的 jQuery 中,通过 jQuery 的正确方法是使用attr()
方法。如果复选框被选中,对$(checkboxEl).attr('checked')
的简单调用将返回true
。否则,它将返回false
。对于一个经验丰富的 JavaScript 开发人员来说,这本身就是一个奇怪的行为,但是我们将把这些细节留到第五章中。
对于专注于 jQuery 的开发人员来说,jQuery API 的这一部分变得更加糟糕。在 jQuery 的更高版本中,相同的调用将返回 checkbox 元素的checked
属性的值(该值不会随着复选框的选中和取消选中而自然改变)。虽然这实际上是正确的行为,因为它正确地反映了元素的实际属性,但在重大更改后,我感到很困惑。由于对 jQuery 的过度依赖,我没有很好地掌握 HTML。我不明白为什么我后来不得不依赖 jQuery 的prop()
方法来获取复选框的当前状态,尽管旧的行为或attr()
方法在技术上是不正确的。
我掉进了一个陷阱,这是许多新的、偶然的和业余的 web 开发人员都会掉进的陷阱。如果我先花时间理解 JavaScript 和浏览器提供的 API,我会省去很多麻烦。事情的正确顺序是这样的:
- 学习 JavaScript。
- 学习浏览器的 API。
- 学习 jQuery(或任何其他跨项目可能需要的框架/库)。
许多人从第三点开始,把第一点和第二点推迟到更晚的时间(或者永远不要)。如果您不理解 jQuery 实际上为您做了什么,那么随着泄漏的抽象从木制品中出来,将会有许多令人沮丧的日子。如果你想有效地成长为一名 web 开发人员,这是一个你必须避免的陷阱——这个陷阱阻碍我作为 web 开发人员的职业生涯的时间比我希望的要长。
挑战:不允许 jQuery!
2012 年初,我开始更换 Widen Collective 中的 Java 小程序上传器, 12 Widen 的旗舰数字资产管理 SaaS 产品。在浏览器中处理 Java 变成了一场噩梦,我们渴望迁移到原生的 JavaScript/HTML 解决方案。我首先研究了 jQuery 文件上传(当时最流行的上传库, 13 ),但是由于启动和运行它需要大量的依赖项,以及缺乏内聚的文档,所以被推迟了。因此,在我的 web 开发生涯中,我第一次选择了一个非 jQuery 解决方案,一开始我有点迷茫。
我决定用这个库来替换我们的 Java applet uploader,这个库当时叫做 valums/file- uploader(由于它在 GitHub 上的位置)。它是独一无二的,因为它是完全独立的。起初我有点怀疑,因为我被训练对 jQuery 生态系统抱有很大的信心,但我对能够轻松集成插件感到惊喜。
然而,插件已经年久失修。它不再被积极维护,需要解决一些错误和调整功能,以使其为 Widen Collective 的生产做好准备。尽管所需的工作并不多,但由于我在 JavaScript、HTML 和 CSS 知识方面的巨大差距,我花了大量时间来解决这些问题。我将我的一些更改推回到一个分叉的 GitHub 存储库中。我的代码是草率的和有缺陷的,但是它是足够的。 14
我的努力显然被图书馆的创建者 Andrew Valums 注意到了,他问我是否有兴趣维护这个图书馆。尽管我在 jQuery 之外没有什么实践经验,但我抓住了这个机会并接受了。我现在是一个大型且非常流行的非 jQuery 插件的唯一维护者,该插件将被重新命名为 Fine Uploader。
当我在 2012 年年中接管大型跨浏览器文件上传库 Fine Uploader 的维护和开发时,我的第一反应是使用 jQuery 全部重写,因为那会让我的生活更轻松(我以为)。现有的用户社区非常反对将任何第三方依赖带入库中,所以我被迫使用原生 web API 和普通 JavaScript。 十五
最初,我的经验不足无疑减缓了 Fine Uploader 的发展。我被迫获得对核心概念的专家级理解。我编写了自己的小垫片来解释 web API 和 JavaScript 的跨浏览器差异。我花了大量时间阅读和实验。随着时间的推移,我成功地摆脱了对 jQuery 压迫性魔力的盲目依赖。我不需要 jQuery,你也不需要。
专注于实现,而不是魔术
jQuery 的魔力及其简化 web 应用开发的承诺非常诱人。但是我们已经讨论了如何以及为什么您可以通过首先了解您的环境而成为一名更强的开发人员。通过遵循正确的路线来学习你的交易:首先是 JavaScript、HTML、CSS 和 web API。以后再担心图书馆。
让我坦白地告诉你,对于一个以前一无所知的新手网站开发者来说,现在扮演一个聪明的“我什么都见过”的开发者,这确实是既有趣又超现实的。但是我可以非常自信地说,如果您更熟悉 web 开发的基础知识,那么您就可以更好地决定何时需要使用 jQuery,何时不需要。知识和经验给了你做出这个选择的自由,并用事实来证明它。你不会永久附属于任何图书馆。你有选择。
不要躲在工具后面——拥有自己的代码。成为 web 开发人员和教师,而不是 jQuery 开发人员和图书馆用户。说“我不再需要 jQuery 了”是一种解放。我自己能行!”而且是真心实意的。不要养成走捷径的习惯。开始走上一条你作为专业人士可以引以为豪的轨迹。不要把学习基础知识推迟到以后,因为以后永远不会发生。避免当你选择的库无法保护你免受浏览器攻击时的无助感。你不可能现实地期望在你的整个职业生涯中躲在抽象层的后面。基本面是推动你前进的基石,让你掌握自己的交易。
Footnotes 1
https://w3techs.com/technologies/history_overview/javascript_library/all/y
2
http://w3techs.com/technologies/history_overview/javascript_library/all/y
3
http://ejohn.org/blog/the-dom-is-a-mess/
4
www.w3counter.com/globalstats.php?year=2009&month=1
5
www.onestat.com/html/aboutus_pressbox44-mozilla-firefox-has-slightly-increased.html
6
7
8
http://stackoverflow.com/a/841292/486979
9
http://blog.garstasio.com/you-dont-need-jquery/
10
http://blog.garstasio.com/you-dont-need-jquery/why-not/#comment-1799026169
11
www.javaworld.com/article/2078613/java-web-development/6-reasons-you-should-be-using-jquery.html
12
www.widen.com/digital-asset-management-software/
13
https://github.com/blueimp/jQuery-File-Upload
14
https://github.com/FineUploader/fine-uploader/compare/82c8d5b0c383738ed84c771e90dbf202bd3acd68…55b3ca6e9f7a18fd3adc5ba7537124ae12b63e71
15
https://github.com/FineUploader/fine-uploader/issues/326
二、你不再需要 jQuery 了
本章的主要目的是解释为什么像你这样的 web 开发人员在开发库或应用时应该或不应该使用 jQuery。例如,在选择基于浏览器的依赖项时,文件大小是一个常见的考虑因素。我将介绍这个属性的重要性,并确定它如何影响您使用这个库的决定。包括对文件大小参数的探索,jQuery 的有用性将被进一步分析。作为这一特殊探索工作的一部分,我将把开发人员选择使用 jQuery 的常见原因与同样的开发人员由于这一选择而可能遇到的问题进行对比。我甚至可能简要地调查和讨论其他可能用来代替 jQuery 的库,甚至推动它走向过时,尽管这将是有限的。第三方代码的焦点将集中在采用更小、更集中的库和垫片上。浏览器提供的本地功能的未来也将是讨论的焦点。
完成本章后,您将能够更好地决定 jQuery 是否应该成为您当前或未来项目的一部分。你对这样一个库的重要性的理解将变得清晰,许多常见的毫无价值的借口将被驳斥。你也将被赋予选择的权力。如果您确实希望在一个目标远大的复杂项目上获得一些帮助,jQuery 永远不是您唯一的选择。如果您真的决定放弃 jQuery,那么 web 开发的未来,就不断发展的原生浏览器工具而言,将会给你信心。本章标题中的“不再”一词有双重含义。您不再需要 jQuery,因为 web API 和 JavaScript 已经得到了充分的发展,可以省略包装器库,而采用更接近金属的方法。你不再需要 jQuery 了,因为读完这本书后,你作为一名 web 开发人员的信心和知识也会得到充分的发展。
需要与想要
需求和欲望之间的斗争并不局限于软件开发,但是在计划一个 web 项目的时候,这是一个需要特别注意的冲突。通常,当我们做出关于依赖、ide 和构建工具的决定时,我们的选择更侧重于想要而不是需要。为什么我们中的一些人选择 WebStorm 而不是 vim?当然,vim 为我们提供了开发全栈 web 应用所需的一切,但我们可能会更喜欢 WebStorm,因为它有着华丽的 UI 和出色的可用性和直观性。为什么不使用 Make 或 shell 脚本来代替 grunt 或 gulp?我们可以使用 Makefile 定义任务来自动化项目构建系统的各个方面,但是 grunt 提供了一组更直观的约定和集成,JavaScript 开发人员可以轻松掌握。
我们需要的往往被我们想要的所压倒。新的开发人员通常更有动力在每一个项目中,每一次都产生可见的进展,这是最重要的。新兴的程序员旨在证明自己,并在追求认可和自信的过程中,利用他们可以从工具中获得的任何帮助。我知道这是真的,因为我自己也曾经是一名新的开发人员,并且在我的许多同行身上观察到了同样的品质。作为一个更有经验的开发人员,我现在对我的工具集有了一个更简约的方法。我在其他一些人身上看到了同样的心态,但是很多人似乎继续把精力集中在制造代码和特性上。
有些人从熟练的理解和应用中得到满足。但大多数人似乎对采用新的尖端高级工具更感兴趣,这些工具有望比传统工具走得更远。接近金属的解决方案被认为是原始的、脆弱的和不必要的复杂。他们多年的存在被一种抽象所掩盖,这种抽象宣称比旧工具更强大,更容易使用。维护一套适度的工具对一些人来说是令人钦佩的,但通常不是目标。
将 jQuery 引入到项目中通常是一种需求或者一种没有根据的需要。它神奇的名声更多地来自于传说,而不是对需求与欲望的客观分析。事实是,这不是魔法。jQuery 虽然潜在地优雅且有用,但它只不过是 web API 的包装器和 JavaScript 的扩展。这是一种抽象、简化和方便的机制。但毫无疑问,真正的力量来自底层语言和浏览器自带的工具。虽然 jQuery 在某些方面确实很有帮助,但我们并不真的需要 jQuery。当然,许多其他抽象也是如此。尽管这一章的标题可能暗示了别的意思,但这里的目标并不是对语义吹毛求疵。
可接受使用论点的两个方面
本书的目标不是宣布 jQuery 为“不受欢迎的人”。我的意图不是挑 jQuery 的毛病,而是讲授浏览器的原生工具,并为您提供开发 web 项目的信心,而不会感到无助地依赖于库。因此,让我们坦率地讨论一下什么时候可以接受在项目中使用 jQuery 的“魔力”,什么时候不可以。让我们先把必要性放在一边,把注意力更多地放在需求上。通过正确理解 jQuery 是可接受选择的实例,您将能够在规划未来项目时做出正确的决定。
什么时候可以用?
如果您非常熟悉前端 web 开发,并且只是想编写更优雅的代码,无可否认,没有太多好的理由来避免 jQuery 成为项目依赖。这并不意味着你一定要使用它,但是如果你愿意的话,你也可以这样做。如果您也对 jQuery 感到满意,并且对 jQuery 的神奇之处非常熟悉,那么无论如何,请继续使用它。
“古老”浏览器的某些方面可能会让 jQuery,或者至少是库的某些模块变得有价值。让我们来定义一个比 Internet Explorer 9 更老的浏览器。任何不是古代浏览器的东西都可以被认为是现代浏览器。我将在下一章更多地讨论古代、现代和常青浏览器。
与现代浏览器相比,古代浏览器的 API 有很大的不同。以事件处理为例。在 Internet Explorer 8 及更早版本中,事件处理程序必须用attachEvent()
方法注册,传递给attachEvent()
的事件名称必须以“on”为前缀。另外,Event
对象的一些重要属性和方法也是非标准的。输入元素“更改”事件不会冒泡,并且完全不支持事件捕获。
这些浏览器在 API 和功能支持方面也有很多不足之处。古代的浏览器缺乏 CSS3 选择器支持。在Array
原型上缺少有用的indexOf
方法。非常老的浏览器不能本地解析或创建 JSON,并且缺乏一种容易区分元素和对象的方法。这些只是古代浏览器面临的一些挑战。在某些情况下,当这些浏览器得到普遍支持时,jQuery 尤其重要。如果您处在一个不寻常且不幸的位置,需要对这样一个旧浏览器的支持,jQuery 可能是一个不错的库。
从大型遗留项目中提取 jQuery 通常没有什么好处。如果企业 web 应用已经放弃了对老式浏览器的支持,那么尝试消除不必要的基于浏览器的依赖可能会很有诱惑力。我不止一次发现自己处于这种情况。从我的经验来看,随着时间的推移,像这样的大型多用途无所不包的库往往会在一个复杂的项目中变得根深蒂固。也许对应用进行有计划的重大重写是去除这些类型的整体依赖性的一个谨慎的借口,但是如果做不到这一点,很可能会使这样的工作毫无结果。除非您的前端自动化测试套件非常全面,否则您可能会发现移除的风险远远超过将库留在原处的任何可察觉的缺点。
当为前端代码编写单元测试时——您应该总是编写测试——jQuery 是一个可接受的依赖。在测试环境中,性能和页面加载时间不是显著的因素,文件大小也不是。事实上,使用一些高级语言或抽象来编写单元测试有一些明显的好处。一个普遍的想法是,单元测试不仅应该用来测试你的代码,还应该根据预期的行为来记录它。一个优雅简洁的测试框架当然会使测试更容易维护,最重要的是,可读性更好。
最后,在一次性项目中使用一点帮助并不可耻。在 web 开发领域没有任何职业抱负的人从事一个小而简单的项目可能不会因为依赖 jQuery 来加速这个过程而放弃任何东西。如果你不是一个开发人员,并且需要启动并运行一个 WordPress 站点,jQuery 可能是一个值得注意的资产。这种情况下,汽车和司机的类比成立。在这种情况下,你是司机,不是机械师。浏览器仅仅是一种便利,而不是你交易的核心工具。
什么时候应该避免使用它?
如果您的项目只支持现代浏览器,尤其是 evergreen 浏览器,那么您可能会发现即使没有包装器库提供的便利,也很容易做到。随着浏览器的发展,web API 和 JavaScript 也在发展。随着相关规范的发展,像 jQuery 这样的库所提供的高级便利设施很快就会在现代浏览器中以本地方式呈现出来。例如,在没有 jQuery 的addClass()
、removeClass()
和hasClass()
方法的情况下,添加、删除和检查 CSS 类的能力在以前是一件苦差事。但是 web 规范赶上来了,现在用add()
、remove()
和contains()
方法为每个元素提供了一个原生的classList
属性。这也许是 jQuery 对 web 规范产生强大影响的一个例子。随着浏览器原生 API 的不断推进,jQuery 的必要性也在降低。不要在新项目中引入多余的依赖,而是考虑依靠浏览器的力量。
当编写一个通用的可重用库时,尤其是开源库,你的直觉应该是将对第三方的依赖降到最低。您的库的依赖项也成为您的用户的依赖项。由于 jQuery 目前无处不在,您可能认为在任何导出的代码中使用它都是安全的。很可能,使用您的库的项目已经在使用 jQuery 了。但是如果他们不是呢?一个有眼光的 web 开发者会为了使用你的库而引入一个大的可传递的客户端依赖吗?也许不是。随着 web 的发展和开发人员选择摆脱这些类型的抽象,这种情况将变得更加普遍。就我个人而言,我会跳过有不必要依赖项的库,根据我从我维护的大型 JavaScript 库的用户那里收到的反馈,我不认为这种情况是唯一的。作为一名库开发人员,您的工作是解决复杂的问题,并将它们打包到一个与您正在解决的问题的大小和范围成比例的盒子中。
应用的性能可能是拒绝某些依赖项的另一个原因,尤其是像 jQuery 这样复杂的依赖项。作为一个如此成熟和受欢迎的库的用户,您自然会认为代码库的最基本和最常见的部分都经过了大量的优化。在这种情况下,效率是可以预期的。当然,也许一些更复杂和更少使用的函数有一些性能影响。但是所有基本的便利方法都应该是高性能的。不幸的是,对于 jQuery 来说,情况并不总是这样。
以hide()
方法为例,说明隐藏在表面之下的潜在性能问题。这看起来是一个简单而有效的操作。其实这样做比较简单。一种方法是在文档中定义一个专有的 CSS 类名,与样式display: none
相关联。不使用 CSS 类,也许可以将一个隐藏的属性绑定到这个样式。在hide()
上,向元素添加类或属性。在show()
上,取下它。这导致了一个简单问题的简单而有效的解决方案。然而,jQuery 对一个本应简单的问题的解决方案却相当复杂且效率低下。
jQuery 实现hide()
方法的一个主要性能瓶颈是由于使用了getComputedStyle()
,这是一个 web API 方法,它计算元素的实际样式集,考虑 CSS 文件、<style>
元素以及对元素的style
属性的内联或 JavaScript 修改。在某些情况下使用getComputedStyle()
是合适的,但是隐藏一个元素可能不是其中之一。在 jQuery 的hide()
实现中使用这种方法会带来严重的性能问题。基准测试 1 表明,这种方法比简单地通过属性定义样式并将元素上的属性设置为隐藏要慢大约 90 倍。即使对于经验丰富的开发人员来说,这个特定的性能问题也可能是一个意想不到的问题。jQuery 中围绕 CSS 支持的使用还有其他类似的问题,这将在第七章中详细介绍。
jQuery 的hide()
方法的性能问题如此严重,以至于在 3.0 版本中实现被显著简化,消除了这个特殊的性能瓶颈。尽管如此,对于任何使用 jQuery 2.x 或 1.x 的开发人员来说,问题仍然存在,3.0 中对hide()
的改变如此剧烈,以至于在一个严重依赖这种方法的大型项目中,对于一些人来说,迁移到 jQuery 3.0 可能需要一点工作。这是一个很好的例子,说明盲目相信无所不包的库会让你误入歧途。
如果您想保持对代码性能的最终控制,那么在引入这种类型的库之前,您应该三思而行,以免意外遇到其他效率瓶颈。当然,一些性能问题可能更多地与您对库的使用有关,而不是其他。但是,使用 jQuery 在不知不觉中编写低效的代码仍然非常简单。请考虑下面的代码清单,它循环遍历一组包含 CSS 类“red”的元素,并删除任何包含属性值为“bar”的属性“foo”的元素:
1 $('.red').each(function() {
2 if($(this).attr('foo') === 'bar') {
3 $(this).remove();
4 }
5 });
Listing 2-1.Removing Elements with jQuery: Naïve Approach
前面的代码当然可以工作,但是它有一些值得注意的性能问题。开发新手和 jQuery 用户如果没有很好地理解 CSS 选择器和循环大量元素的含义,可能不知道有一种更简单、更有效的方法可以解决同样的问题。对于同样的问题,这里有一个性能更好、更优雅的解决方案:
1 $('.red[foo="bar"]').remove();
对于一个小文档,这两种方法的执行时间没有明显的不同。但是,如果文档包含大量 CSS 类为“red”的元素,比如说 200 个,那么第一种方法的后果是很明显的。前一种解决方案比使用复杂 CSS 选择器 2 —使用 jQuery 1.11.2 的 Chrome 42 的一行解决方案大约慢六倍。).
如果您知道在 API 中应该避免哪些方法,以及何时应该避免,那么您仍然可以使用 jQuery 编写高性能的代码。浏览器的 API 也是如此,但是库经常提供一种虚假的安全感。直接使用 web API 的代码在意图上更加明确和具体。另一方面,jQuery 提供了一个更高级的、看似神奇的 API,它掩盖了实现的许多细节,并掩盖了潜在的性能折衷。我们经常不想忽略抽象的便利,但是你必须这么做。如果您想编写可靠且高效的代码,您不仅要理解 jQuery 本身(如果您选择使用它),还要理解 jQuery 如何利用 web API。在这里,盲目的信仰会以多种形式出现问题。
可能需要避免 jQuery 的另一个考虑因素是页面加载时间。对 jQuery 的ready()
方法的过度依赖就是一个例子。只有在文档中的所有元素都被加载到页面上之后,ready()
方法才会执行传递的函数。这对实际的页面加载时间没有显著的影响,但是它确实影响了感知的页面加载时间。通常,任何由 jQuery 的ready()
方法执行的代码都被导入到文档的顶部(通常在<head>
元素中)。如果所有脚本都在文档顶部加载,这可能会导致页面呈现明显延迟,因为脚本必须在元素之前加载和执行。只要有可能,推荐的方法是将所有脚本加载到文档的底部。这使得页面加载速度更快,因为文档元素比其他任何东西都要先加载。如果你遵循这个惯例,使用$.ready()
就没有必要了。jQuery 的ready()
方法被广泛使用,甚至经常出现在 jQuery 学习网站的示例代码中。这是另一个例子,您最好理解所有可能的选项(比如在页面底部加载脚本),而不是盲目依赖 jQuery 提供的便利方法,比如ready()
。
与页面加载时间有点关系的是文件大小。我指的是为了在页面加载时完全呈现,页面必须加载的任何资源的大小(以字节为单位)。反对依赖 jQuery 等库的一个常见理由是文件大小。现实情况是,带宽是有限的,浏览器在页面加载时下载的所有客户端依赖项都会消耗一部分带宽。如果您的用户都有一个 60 Mbps 的下行管道,那么您的应用下载的脚本可能不会对页面加载时间产生任何明显的影响。但是如果你的用户没有那么幸运呢?如果他们只能访问最大下行速率为 6 Mbps 的 DSL 呢?如果你的目标是移动设备呢?在这种情况下,下行带宽可能不会超过 4 Mbps。在发展中国家,您的用户可能只能访问 Edge,其峰值速度约为 400 Kbps。你考虑到你所有的用户了吗?
jQuery 的大小对您和您的用户来说可能重要,也可能不重要。如果您决定从 CDN 加载 jQuery,那么完全避免往返的可能性更大。因为这个库非常受欢迎,所以很多用户可能已经在浏览器中缓存了来自另一个应用的 jQuery。但这肯定不能保证。大量使用中的 jQuery 版本使得项目所依赖的特定版本不太可能被大多数用户缓存。生产运行时依赖第三方服务器也有潜在的缺点。如果该服务器遇到技术问题,即使您控制下的所有服务器都按预期运行,您的应用也可能因此瘫痪。
如果您自己或通过私有 CDN 托管 jQuery,那么您可以更好地控制如何提供服务,以及从哪里提供服务(考虑用户的位置)。或者,您可能担心单个 HTTP 请求的开销,并选择将 jQuery 与所有其他页面资源结合起来作为对单个请求的响应。结合 GZIP 压缩,这是一个不错的策略。但是当您的用户群依赖于极低带宽的连接时,保持您的资源列表较小仍然是最重要的。如果第一页的加载花费了大量的时间,你可能会失去一个潜在的客户。
公平地说,我应该提到 jQuery 1.8 在项目源代码中公开了一个构建任务,它允许 jQuery 被“定制”构建,排除了特定项目可能不需要的任何模块。这可能否定了文件大小参数。但是一个问题仍然存在:新的和没有经验的开发人员真的知道他们需要 jQuery 的哪些部分吗?大多数开发人员知道创建 jQuery 定制版本的能力吗?这两个问题的答案很可能都是“不”。不幸的是,创建定制构建的能力隐藏在 jQuery 源代码树的构建文件中。为了使用它,您必须下载整个存储库,安装构建 jQuery 所需的开发依赖项,搜索构建文件或浏览项目 GitHub 存储库中的 README.md 以获得说明,并使用他们的 grunt 构建工具运行任务,排除任何不需要的模块。有了所有这些步骤,大多数依赖 jQuery 的开发人员不太可能使用面向用户的下载页面上提供的完整构建文件以外的任何东西。
所有这些都揭示了在开发流行的单体库时所做的妥协。毫无疑问,从一开始,jQuery 的开发就投入了大量的精力和细节。但是它可能没有考虑到你或者你的边缘情况,甚至你的目标。是不是既要方便又要快捷?这两个目标可能相互矛盾,这取决于您的工作流和库的预期用途。你想要无缝的文件上传还是最小的占用空间?这两个都不是 jQuery 的目标。jQuery 和其他拥有庞大用户群的大型库一样,必须非常小心地关注特性和工作流的最大公约数。
应该使用其他库吗?
本书的一个目标是推动你去除对 jQuery 的依赖。但是我提供给单片包装器的唯一选择是直接连接到浏览器的本机 API。这当然是一个令人钦佩的目标,从某种意义上说,浏览器是我们开发所有前端项目所需要的。但实际上,我们可能需要更多的帮助,以减少在整合过于复杂的东西时不可避免的焦虑。假设我们的目标是现代浏览器,考虑到网络的当前状态,这是合理的。但是,即使是“现代的”浏览器,如果有一个进化了的 API,也可能对我们的项目所需要的一些强大的特性提供不一致的支持。如果有某种方法可以在所有现代浏览器上一致地使用现代 web 和 JavaScript 特性,而不用包装整个堆栈就好了。。。。
大包装纸上的小垫片
web 开发中有一个概念描述了一种非常特殊的库,即回归库。这是一个新名词,你可能从未听说过,因为这是我自己创造的。回归库是大型包装库的合理替代品。虽然它们通常很小(尽管不总是如此),但它们真正的吸引力从名字上就很明显——它们是递减的。尽管大多数库随着时间的推移在大小和特性集方面都有所发展,但退步的库会退化。回归库的最终目标是消失,被浏览器的原生 API 完全取代。
回归库通常被称为垫片或聚合填充。他们不提供任何新的 API。他们的工作是在不兼容的浏览器中临时填补标准化 API 的缺失实现。这些库让我们专注于本地工具。没有任何抽象会模糊我们的理解,隐藏网络的真实本质。Polyfill 代码通常构造为只有在浏览器不包含匹配的本机实现时才使用。如果浏览器包含适当的本机代码,则库直接委托给浏览器。
常用的聚合填充的一个例子是道格拉斯·克洛克福特的 json2 库。 3 它为浏览器中的JSON
对象贡献了一个实现,该实现不包含它们自己的本地实现。正如所料,json2.js 的 API 与 ECMAScript 5 规范中标准化的 JSON API 是一对一的匹配。 4 该规范描述了一个 JavaScript 对象,该对象包含将 JSON 字符串转换成 JavaScript 对象以及将 JavaScript 对象转换回 JSON 字符串的方法。当序列化和反序列化数据作为与 JSON 感知端点通信的一部分时,这些方法非常有用和重要。Json2.js 确保这个 API 在没有实现这个特定 ECMAScript 5 规范的旧浏览器中可用(比如 Internet Explorer 7)。
还有不少其他流行的 shims 比如 webcomponents.js,和 fetch。它们的名字很好地表明了它们负责修补的本机 API。目前,这两种聚合填充都有助于实现前沿规范。Webcomponents.js 为没有完全实现 Webcomponents 浏览器规范的浏览器(指目前除 Chrome 以外的所有浏览器)贡献了补丁。Fetch 允许开发者使用 WHATWG 创建的新 fetch 规范, 5 最终取代了XMLHttpRequest
。其中一些将在本书的后面部分探讨,比如下一章。
编写您自己的垫片
当我们想要在我们的项目中使用 web API 和 JavaScript 的一些令人兴奋的新的前沿特性,并保持对各种浏览器的支持,并确保我们的依赖关系的足迹尽可能小且暂时时,我们转向回归库。但是聚合填充物到底是什么样子的呢?如何着手创建这样一个图书馆?当您发现自己需要在一个较旧的浏览器中使用一个常见且有用的本机 API 方法,而没有现成的 polyfill 可供您使用时,这些问题比学术问题更实际。创建自己的聚合填充可能没有您想象的那么复杂。不相信我?让我们现在就创建一个。
以 JavaScript Array
s 中可用的find()
方法为例,它是 ECMAScript 2015 规范的一部分。Array.find
返回数组中满足给定条件的条目。虽然这听起来相当有用,但所有版本的 Internet Explorer 都缺少浏览器支持。但是我们可以通过编写自己的填充程序在所有浏览器中使用这种方法,如清单 2-2 所示。
1 if (!Array.prototype.find) {
2 Array.prototype.find =
3 function(callback, ctx) {
4 for (var i = 0; i < this.length; i++) {
5 var el = this[i];
6 if (callback.call(ctx, el, i, this)) {
7 return this[i];
8 }
9 }
10 };
11 }
Listing 2-2.Conditionally Creating an Array.prototype.find Shim
如果(且仅当)浏览器没有本地实现Array.prototype.find
,前面的代码将注册一个实现。因此,有了这个垫片,你可以在任何浏览器中使用 ECMAScript 2015 规范中提出的Array.prototype.find
,甚至是 Internet Explorer 6!本质上,shim 就像一个本机实现一样,将迭代数组中的所有项,直到找到满足传递的谓词函数的项,或者直到用完了要检查的元素。对于数组中的每个元素,调用传递的谓词函数,传递当前数组元素、当前数组索引,最后传递整个数组。请注意,ctx
参数是可选的,它允许调用代码指定谓词函数要使用的替换值(也称为上下文)。如果省略了这个上下文参数,那么传递的谓词函数的实际上下文将是“全局对象”,如果这段代码在浏览器中执行,那么它恰好是window
对象。如果谓词函数返回“真”值,则数组元素满足谓词函数。shim 将返回满足谓词的元素,如果没有元素是令人满意的,则返回 undefined。
使用我们的 shim,清单 2-3 中的函数返回数组中名称属性为“foobar”的元素。这恰好是数组中的第三个元素。
1 function findFoo() {
2 return [
3 {name: 'one'},
4 {name: 'two'},
5 {name: 'foobar'},
6 {name: 'four'}
7 ].find(function(el) {
8 return el.name === 'foobar';
9 });
10 }
Listing 2-3.Using the Array.prototype.find Shim
最后一句话
jQuery 不是 web 开发未来的一部分,但其他大型且当前流行的库或 JavaScript 框架也不是。图书馆来来去去;浏览器的 API 和 JavaScript 会比它们都长寿。web 的未来,以及您作为 web 开发人员的职业生涯,都被编入了 web 和 ECMAScript 规范中。这些规范正在迅速发展——它们正在迅速赶上库。常见问题的本地解决方案通常会提高性能并增加便利性。
使用 jQuery 这样的包装器库并没有什么错。然而,您不仅要正确理解 jQuery 本身所依赖的代码,还要正确理解您选择使用它的原因。您并不真的需要 jQuery,但是如果您仍然想使用它,一定要注意在哪些情况下使用它是可以接受的,以及在哪些情况下您可能想考虑放弃这个特定的抽象。
Footnotes 1
http://jsperf.com/jquery-hide-vs-set-attr
2
http://jsperf.com/jquery-loop-vs-complex-selector
3
https://github.com/douglascrockford/JSON-js
4
www.ecma-international.org/publications/files/ECMA-ST/Ecma-262.pdf
5
6
http://people.mozilla.org/~jorendorff/es6-draft.html#sec-array.prototype.find
三、理解 Web API 和“普通”JavaScript
在我们进一步探索浏览器、JavaScript 和 jQuery 的奥秘之前,有一些重要的概念和术语需要讨论。如果您的计划是更好地理解浏览器中所有可用的不同本机工具,那么您必须了解这些工具的历史以及它们之间的相互关系。
浏览器可以分为几个不同的类别。在本书和其他地方,你会听到很多用来描述这些类别的术语,比如现代浏览器和常青树浏览器——这些将在本章中详细讨论。仔细观察这些分类的必要性将揭示为什么一些分组是移动的目标,并且潜在地具有可疑的重要性。除了基于浏览器的 JavaScript 之外,由于 Node.js,您甚至可以了解如何在浏览器之外使用这种语言,比如在服务器上。
web API 和 JavaScript 语言都是本书讨论的主题。在介绍这两个项目的复杂语法和用法之前,您需要清楚它们的作用和重要性。我将详细介绍 web API 和 JavaScript 的定义,以及这两个基本概念之间的关系。本章的另一个重要目标是说明这两种技术是如何受到标准化的影响的。制定这些标准的组织将被详细介绍。完成本章后,您将会非常熟悉构成本机浏览器堆栈的各种规范。
关于浏览器你需要知道的一切
最初(1990 年),现代互联网的创始人之一蒂姆·伯纳斯·李开发了第一个网络浏览器 Nexus。1993 年,第一个完全图形化的浏览器 Mosaic 紧随其后。1994 年和 1995 年,分别发布了网景 Navigator 和微软 Internet Explorer 1.0。到 20 世纪 90 年代中期,Netscape 和 Explorer 几乎占据了所有常用的浏览器。它们都提供了一套快速增长的专有特性,使自己与众不同,但也有利于极性而不是标准化。这在一定程度上导致了后来工具(如 jQuery)的流行,这些工具允许 web 开发人员更有效地针对多种浏览器。虽然 Nexus、Mosaic 和当时的其他类似浏览器相对短暂,并且不受用户和开发者的青睐,但 Netscape 和 Explorer 开创了一个浏览器激烈竞争的时代。
以今天的标准来看,早期的网络是缺乏创意和原始的。在浏览器端,网络上只有静态内容。信息从服务器一次加载一整页,即使只需要更新页面的一小部分。单击锚链接时,HTTP GET 请求被发送到服务器,服务器用下一页的内容作出响应——页眉、正文、页脚等等。这不利于提供出色的用户体验,也不利于充分利用当时非常有限的带宽。然后在 20 世纪 90 年代后期出现了 Java 小程序和 Flash,它们允许开发人员创建动态的浏览器内应用。然而,这两种技术都需要在浏览器上安装第三方软件。早在万维网联盟制定官方标准之前,微软就允许开发人员创建一个页面,通过向服务器发送请求,返回一个文档的片段,可以部分更新该页面。然后,这个片段用于替换现有内容或创建附加内容,而不改变页面的其余部分。这就是通常所说的 ajax 请求,它的发明为本地浏览器带来了动态内容创建。微软对这一概念的实现是在 20 世纪 90 年代末,Flash 和 Java 小程序出现后不久引入的。尽管 Ajax 请求最初是在 1999 年左右引入的,但直到 2002 年包含在 Mozilla Firefox 浏览器中,它们才出现在其他地方,并且直到 2006 年它们仍然是非标准的。这是一个网络标准化远远落后于对更现代功能的渴望的时代。
尽管 Internet Explorer 及其专有功能在相当长一段时间内主导了浏览器市场,但显着的竞争在 21 世纪初到来。Mozilla Firefox 是微软产品的第一个可行对手。免费开源浏览器的推出让微软完全措手不及,并开创了一个新的网络时代。Firefox 的 Gecko 引擎是第一个挑战微软 Trident 的引擎。Gecko 发布几年后,苹果开发了 WebKit 渲染引擎来支持其 Safari 浏览器。WebKit 最初是 Konqueror 浏览器使用的 KHTML 渲染引擎的一个分支,Konqueror 浏览器是一种用于 Linux 桌面的浏览器。不久之后,谷歌开发了自己的浏览器 Chrome,也使用了苹果的 WebKit 引擎。后来,谷歌创建了自己的渲染引擎——Blink——它本身是 WebKit 的一个分支,类似于苹果最初对 KDE KHTML 引擎的分支。有趣的是:Opera 是一个小众浏览器,大部分时间都依赖于自己开发的“Presto”渲染引擎,在 2013 年转向了 Chrome 的“Blink”引擎。虽然 Firefox 最初在 Windows 上占据了相当大的市场份额,Safari 在 OS X 上也是如此,但 Chrome 在推出后不久就开始上升到跨操作系统的主导地位。Chrome 的成功可以归功于它的快速发展和对 web 标准化的重大影响。这是一个正式规范开始匹配并影响浏览器开发的时代。有了成熟的标准和相对可靠的浏览器质量保证,像 jQuery 这样的库开始变得不那么重要了。
我刚刚概述的历史描述了许多不同的移动和桌面浏览器,像大多数东西一样,可以以许多不同的方式进行分类。在本书的上下文中,将使用一组合理的类别来说明它们的现代性、可移植性和可更新性。在下面几节中,您将熟悉一些更常见的浏览器类别。我还将评论所有当前可用的浏览器的状态,并提供一些在考虑这些类别的浏览器时要考虑的警告。
古代浏览器
古代浏览器,也称为遗留浏览器,通常被认为是微软的 Internet Explorer 的旧版本。在撰写本书时,2016 年年中,古老的浏览器是比 Internet Explorer 9 更老的浏览器。Explorer 7 通常被认为是最古老的浏览器,无论出于何种目的,即使是 Internet Explorer 10 和更低版本也不再受微软支持,只有在大量用户无法升级的情况下,您的新 web 应用才会支持它。IE6、Mosaic、Netscape 和其他类似的浏览器不仅过时,而且大部分都没有使用过。它们不属于任何一套当前使用的浏览器,所以在本书未来的讨论中我们不会考虑它们。截至 2016 年 6 月,令人欣慰的是,在整个被测网络中,古代浏览器仅占当前使用浏览器的 1%左右。 1
老式浏览器是所有浏览器类别中最不受欢迎的。它们有许多缺点,这使得它们很难开发和支持,并且通常被认为与更现代的选择相比非常慢。他们对 DOM API 和其他相关 web APIs 的支持是原始的。由于当时缺乏现代规范,它们支持有限的 JavaScript 便利方法。它们中的许多,尤其是 Internet Explorer 6,充斥着显著而严重的布局错误。由于这些原因,古老的浏览器已经失宠,取而代之的是更加稳定、高效和方便的选择。
但是当使用年龄作为关键属性对浏览器进行分类时,我们必须小心。古代的浏览器代表了一个移动的目标。几年后,今天的新浏览器可能会被认为是古老的浏览器。在不久的将来,当一个古老的浏览器的市场份额实际下降到 0 时,它将被视为死亡。这种类型的分类不是特别实用,但是对于将那些在任何情况下都可以不用 jQuery 合理处理的浏览器与那些不能的浏览器分开来说,这种分类可以说是足够有效的。然而,未来的章节将探索其他更有效的技术,通过程序性特征检测的实践来区分有能力和无能力的浏览器。
现代浏览器
这个现代的形容词可以用来描述所有比那些被认为古老的浏览器更新的浏览器。在撰写本文时,现代浏览器都是比 Internet Explorer 9 更新的浏览器,包括 Internet Explorer 9。这个列表还包括 Chrome、Firefox 和 Safari 版本 6+。但是这种现代分类法与古代分类法有一个共同的特点。这个类别的后缘是一个移动的目标。现在现代的东西,几年后可能就是古代的了。与古代相似,现代只是在阅读这本书时用作上下文。在描述浏览器对一段代码的支持时,我会经常用到这些术语。
与古老的浏览器相比,现代浏览器更容易使用,这是因为它们拥有相对先进的开发工具、web API 和 JavaScript 支持以及稳定性。它们说明了一组无需像 jQuery 这样的包装器库的帮助就可以很好地解决的浏览器。以编程方式识别这类浏览器的冲动非常强烈。如果我们可以轻松地确定我们是在处理 Internet Explorer 9 还是 8,或者 Safari 8 还是 5,那么也许我们可以定义两个离散的代码路径——一个用于古代浏览器,另一个用于现代浏览器。但是你要忍住这种冲动。
这种主要基于年龄的对浏览器的笼统分类是没有意义的。更糟糕的是识别这一类别的浏览器,并根据浏览器的用户代理标识字符串做出所有的代码路径决策。正确的方法是测试浏览器的 API 实现是否有特定的功能,然后为该特定的功能选择合适的代码路径。我想明确这一点,以确保我迄今提出的分类在适当的背景下被看到,而不是被提升到比它们应得的更高的重要性水平。
Note
用户代理字符串是标识特定类型和版本的 web 浏览器的一系列字符。您可以通过 JavaScript 检查 navigator 对象的 userAgent 属性来获取浏览器的 UA 字符串。
常青树浏览器
还有第三类浏览器,它是永恒的,并且在不断发展。常青树浏览器是网络的未来。属于这一类的浏览器无需任何用户干预就能自行更新。虽然它们都有版本,但大多数使用 evergreen 浏览器的人可能不知道他们浏览器的当前版本号(除了 Internet Explorer 10+和 Safari)。更新是完全透明的,即使是重大版本升级。这允许浏览器无缝地发展其 web API 和 JavaScript 支持。用户通常没有(容易地)保留在旧版本上的选项。这使得这些浏览器能够更快地实现新的 web 规范,并确保整个用户群是最新的。整个生态系统都赢了。
目前,Chrome、Firefox、Safari、Opera、Internet Explorer 10+和 Microsoft Edge 被认为是常青树浏览器。这个概念非常流行,并占据了今天大多数可用的浏览器。将一个浏览器版本与一组特定的操作系统版本或“服务包”捆绑在一起的模式,随着 Internet Explorer 9 和 Windows Vista 的出现,大部分已经消亡。微软最新重新设计的浏览器名为 Microsoft Edge,更像其他传统的 evergreen 浏览器,因为与微软早期开发的产品相比,版本号没有那么突出。随着 evergreen 浏览器接管 web,我们可以比以往任何时候都更快地利用快速发展的规范、安全改进和错误修复。在这个世界上,需要一个库来填补浏览器的空白变得不那么重要了。
移动浏览器
至少在美国,桌面浏览器仍然占网络流量的大部分。然而,目前的测量显示,移动浏览器的使用正在上升,而桌面流量正在下降。截至 2016 年 6 月,移动/平板设备约占网络流量的 42%, 2 ,其余大部分来自桌面浏览器。移动设备使用的稳步上升,以及“移动第一”的一贯(也是重要的)口号,?? 揭示了移动浏览器与常青树浏览器一样是网络未来的重要组成部分。可以说,移动网络仍处于起步阶段,但它不能也不应该被忽视。
在某些方面,许多移动浏览器也是非常常青的浏览器,因为它们中的一部分会自动更新,无需用户干预。就像在桌面上一样,这种行为允许移动浏览快速发展,并确保用户始终拥有他们选择的最新版本的浏览器。但是自动更新浏览器的好处在历史上是与物理设备的能力联系在一起的。例如,运行 Android 2.x 的旧手机可能无法处理 4.x 版本,从而无法使用最新版本的移动 Chrome。同样的问题也存在于其他移动平台上,比如停留在过时的 iOS 版本上的老款苹果设备。
移动领域在很大程度上被运行 iOS 的苹果 iPhone 和 iPad 以及众多运行谷歌 Android 操作系统的其他设备所主导。尽管微软已经开始对他们的 Windows Phone 操作系统有所了解。对于本书的目的(以及大多数其他内容)来说,Research in Motion 的黑莓操作系统无关紧要,因为它在移动网络流量中所占的份额很小,而且还在不断下降。不管使用什么移动设备,请记住,本书中用于替换 jQuery 的代码和技术将同样适用于所有现代/常青树浏览器,因此在这种情况下,除了性能考虑之外,移动和桌面之间的区别并不特别重要。
尽管移动网络越来越普及,但目前的情况并不乐观。移动浏览器带来了独特的挑战。与桌面系统相比,外形和电池方面的考虑使我们代码的性能结果更加明显。尤其是移动浏览器,相对于更大尺寸的浏览器来说,还不太成熟。伴随这种不成熟而来的是各种浏览器之间规范支持的不一致性。将手机浏览器与桌面浏览器进行比较时,这一点往往更加明显。Chrome 和 Safari 是在移动和桌面设备上都存在的浏览器的两个例子。虽然这些浏览器可能在多个平台上共享相同的名称,但它们的目标各不相同,从而导致不同的体验。
在某些情况下,由于非常独特的移动性问题,比如数据使用,web 规范的公共部分表现不同。以 HTML5 <video
>元素上的autoplay
布尔属性为例,它将确保相关视频在加载后立即开始播放。 5 桌面浏览器都支持这个标准特性,但是在移动端的支持有点不同。运行在 iOS 上的 Safari 不会观察到这一属性,以确保自动播放的视频不会对用户有限(且相对昂贵)的移动数据消耗产生不利影响。 6 还有其他类似的例子,移动浏览器的独特环境可能会导致意想不到的实施差距。在编写“移动优先”的库和应用时,必须考虑这一现实。
非浏览器
这一节可能看起来有点不合适,但是为了完整起见,我认为至少涉及另一个 JavaScript 蓬勃发展的环境是很重要的。由于 Node.js 的存在,两端都使用 JavaScript 的全栈软件开发成为可能,node . js 是一种使用 Chrome JavaScript 引擎的服务器端运行时。 7 服务器端 JavaScript 开发不包含任何类型的 web API,原因显而易见。因此,尽管贯穿本书的基于 web 的讨论(有很多)并不适用于 Node.js 世界,但许多纯 JavaScript 领域确实超越了浏览器。
如果您不太清楚特定于浏览器的 JavaScript 和语言规范中规定的 JavaScript(以及可在浏览器之外使用的 JavaScript)之间的区别,稍后当我比较、对比和定义 web API 和 JavaScript 语言时,会对此进行更多的讨论。这里重要的一点是理解本书的一些内容实际上适用于基于服务器的 JavaScript 开发。在很大程度上,服务器上的 JavaScript 为您提供了相对最新的语言规范支持,这意味着本书中大多数非特定于浏览器的示例也可以在服务器上使用。
这个 Web API 是什么,为什么它很重要?
我在整本书中都提到了 web API,并将继续这样做。但是术语 web API 不是一个标准术语,甚至不是一个常用术语,所以有必要做一些解释来消除任何潜在的歧义。简单地说,web API 指的是所有 JavaScript 方法和对象,这些方法和对象专门允许开发人员以编程方式处理和操作浏览器。这个通用的浏览器 API 由两个不同的部分组成:一个是文档对象模型(DOM ) API,它是附加到 HTML 文档节点的一组方法和属性,第二个是仅在浏览器环境中可用但不直接与 HTML 相关的其他方法和函数的集合。如果这些简洁的定义仍然有点模糊,没必要担心。我保证在本节的后面会更详细地解释 DOM 和非 DOM APIs。
我希望到现在为止,您对术语 web API 以及它与浏览器的关系已经比较熟悉了。但仍有一个问题:你为什么要在乎?除了它是本书大部分章节和概念的关键属性之外,它也是 web 开发人员可用的最重要的工具。web API 提供了为最终用户甚至其他开发人员创建特别定制的动态体验所必需的一切。它在不断地快速发展,因此 web 注定最终会取代已安装的应用。作为一名专业开发人员,您对 web API 的理解(或缺乏理解)将对您有效设计和开发丰富复杂的 web 应用和库的能力产生重大影响。这本书的目标是灌输这种现实,并教你如何不仅很好地利用 web API 来代替 jQuery,而且更好地理解浏览器环境,以便你可以更有效地使用包装了这种原生 API 的库。
DOM API
DOM 是一组用于表示 HTML 文档的方法和对象。这些表示通常(但不仅限于)使用 web 上最常见的语言:JavaScript 来表达。DOM 提供了镜像文档中元素的 JavaScript 对象。它允许创建、定位、操作和描述元素。这种语言绑定在所有 HTML 元素中公开了许多潜在的控制点。例如,DOM API 在 DOM 的Element
接口上定义了一个className
属性。这个特定的属性允许编程读取和更改任何元素的 CSS class
属性。所有其他 HMTL 元素,比如锚点(HTMLAnchorElement
)、<div>
元素(HTMLDivElement
)和<span>
元素(HTMLSpanElement
),都继承自Element
接口,因此也在它们的 JavaScript 对象表示中包含了className
属性。
上一个className
示例中展示的元素层次结构是需要理解的重要内容。特定元素上可用的公共属性和方法通常继承自更常见的元素类型。EventTarget
类型位于链的顶端,所有其他 HTML 节点的表示都继承自它。EventTarget
类型定义了注册事件处理程序的方法,所有其他 HTML 项目都会继承这些方法。一个Node
是一个EventTarget
的子类型,所有其他元素也从它继承而来。这个Node
接口提供了克隆 HTML 项目和定位兄弟节点的方法,以及其他行为。Node
的子类型包括Element
和CharacterData. Element
对象,如您所料,所有节点都可以用标准化的 HTML 标签来表示,例如<div>
和<span>. CharacterData
项可以是文档中的文本或注释。
除了对每个元素的控制之外,还可以使用 JavaScript 对整个文档进行操作。事实上,文档有一个特殊的表示,恰当地命名为Document
接口。一个Document
对象继承自Node
(如果您还记得的话,它继承自基类——EventTarget
)。例如,文档包含允许检查与标记关联的所有样式表的属性。许多重要的方法也是可用的,比如一种便于创建新的 HTML 元素的方法。请注意,没有任何东西是从Document
继承的。出于本书讨论的目的,浏览器的document
对象上的所有属性和方法也可以被认为是 DOM 规范的一部分。图 3-1 显示了 DOM 元素的层次结构。
img/A430213_1_En_3_Fig1_HTML.jpg)
图 3-1。
DOM element hierarchy
所有这些类型、行为、属性、方法和关系都是标准 DOM 规范的一部分。 8 这个标准最初是由万维网联盟(W3C)在 1998 年创建的,称为 DOM Level 1。传统上有两种处理 DOM 的特定标准途径:DOM Core 和 DOM HTML。正如规范摘要所指出的,DOM Core 是“一个平台和语言中立的接口,允许程序和脚本动态地访问和更新文档的内容、结构和风格。”在撰写本文时,最新的此类标准(2004 年末成为推荐标准)是 DOM Level 3 Core,【10】,它定义了元素的新属性,比如可以用来读取或设置节点文本的textContent
属性。
DOM HTML 规范摘要听起来类似于 DOM Core,但实际上有点不同。它声称该规范是“一个平台和语言中立的接口,允许程序和脚本动态访问和更新 HTML 4.01 和 XHTML 1.0 文档的内容和结构”(强调由我添加)。换句话说,正如您所料,DOM 核心规范定义了所有文档共有的核心功能,DOM HTML 规范对这一核心规范进行了一点扩展,提供了一个更加特定于 HTML 的 API。DOM HTML 规范定义了元素的公共属性,比如id
、className
和title
。在撰写本书时,最新的 DOM HTML 标准是 DOM Level 2 HTML,它在 2003 年末成为一个推荐标准。 11
还有其他相关的标准,比如选择器 API 12 ,正如你所料,它涵盖了选择元素。例如,querySelector
和querySelectorAll
方法在Document
和Element
接口上都有定义,以允许使用选择器规范中定义的 CSS 选择器字符串选择文档中的元素(目前在第 4 级。 13 另一个相关的规范是 UI 事件规范, 14 定义了原生 DOM 事件,比如鼠标和键盘事件。DOM4 规范试图集合所有这些标准以及更多标准。 15
然后是最广为人知的标准 HTML5,它在 2014 年末成为了一个推荐标准。 16 它是最新的 DOM 规范之一,继承了 DOM4 的目标以及许多其他与 DOM 无关的规范(我将在下一节谈到)。在 DOM 的上下文中,HTML5 定义了新元素(如<section>
、<footer>
和<header>
)、新属性(如placeholder
和required
)和新元素方法和属性(如图像元素的naturalWidth
和naturalHeight
属性)。当然,这只是一小部分变化。W3C 维护了一个文档,该文档相当详细地描述了 HTML5 带来的变化。 17 目前,最新进行中的规范是 HTML 5.2,也是由万维网联盟策划的。HTML 5.1 和 5.2 为 DOM 带来了一些更新的元素。这些新元素中最值得注意的是<图片>、 18 ,它允许指定多个图像源,并向浏览器提示要加载哪个图像。例如,图片来源可以与浏览器窗口大小或像素密度相关联。
简而言之,DOM API 提供了一种使用 JavaScript 读取、更新、遍历和创建文档元素的方法。元素本身及其属性也是由这个规范族定义的。web 中的真正力量部分是由 DOM APIs 定义的。没有它,动态 web 应用就不会以当前的形式存在。唯一的选择可能是嵌入式 Flash 或 Java 小程序——由于现代 DOM API 的强大功能,这两种技术很快就会过时。让我们明确另一件事:jQuery 构建在 DOM API 之上。没有 DOM API,jQuery 也不会存在。jQuery 主要是 DOM API 的包装器,提供了一定程度的抽象。Beyond jQuery 的很大一部分致力于在有或没有 jQuery 帮助的情况下使用 DOM APIs。
其他一切(非 DOM)
除了 DOM API 之外,还有另一组特定于浏览器的 API,它们组成了附加到浏览器的window
对象的所有属性。浏览器“窗口”包含一个 HTML 文档和document
对象(由 DOM API 定义)。这个窗口可以通过 JavaScript window
对象——一个全局变量——以编程方式访问。尽管 DOM API 定义了附加到document
对象的所有东西,但是附加到window
对象的所有东西都是由大量其他规范定义的。例如,文件 API19定义了一组用于在浏览器中读取、写入和标识文件的方法和属性,由两个接口表示:Blob
和File
。两个接口定义都可以在window
对象上获得。
绑定到Window
接口的另一个众所周知的 API 规范是XMLHttpRequest
, 20 ,它定义了一组用于通过 HTTP 与服务器异步通信的方法和属性。除了新的 DOM API 特性,HTML5 标准还定义了一大堆与window
相关的属性。一个例子是History
接口、 21 ,其提供对浏览器历史的编程访问。这在window
上显示为一个history
对象。还有一个例子是Storage
接口, 22 ,它包括在window
上表示为sessionStorage
和localStorage
,用于管理浏览器中少量数据的临时存储。
虽然上一节中讨论的关于 DOM 的 HTML 5.1 规范也在非 DOM APIs 的发展中发挥了作用,但它的作用比 HTML 5 标准小得多。在 W3C HTML 5.1 规范的当前版本中,最值得注意的非 DOM 参考是由另一个标准组织 WHATWG 起草的fetch
API、 23 。这就给我们带来了一个关于这个相对较新的现象的简短讨论:相互竞争的网络标准。一方面,我们有 W3C,它从 1994 年就开始为网络制定标准。它是由万维网的发明者蒂姆·伯纳斯·李领导的。我们今天使用和喜爱的 web 规范的起源是由 W3C 正式标准化的。W3C 出现十年后,网络超文本应用技术工作组(WHATWG)成立了。
WHATWG 提倡一种“生活标准”,一种与版本号或“级别”无关的标准。例如,它们没有 HTML5 或 HTML 5.1 规范,只是有一个随时更新的 HTML 规范。该组织自己也起草了一些原创的新标准,比如之前提到的Fetch
API,以及Notifications
API、 25 使 web 应用能够向用户显示通知。根据 FAQ 页面,该小组是出于对“W3C 对 XHTML 的指导、对 HTML 缺乏兴趣以及明显无视现实世界作者的需求”的不满而创建的。WHATWG 看起来确实是 W3C 的健康制衡力量,显然促进了网络的更快发展,这当然是件好事。
JavaScript:不太优雅的 jQuery 版本?
引入 jQuery 的一个常见原因是为了弥补底层语言 JavaScript 本身的不足。这是最无聊的借口之一。仅仅为了稍微好一点的遍历对象属性和数组元素的方法,而引入像 jQuery 这样的第三方依赖有点过分。事实上,有了forEach
和Object.keys()
的存在,这是完全没有必要的,这两个在现代浏览器中都有。或者也许你认为$.inArray()
是一个重要的实用功能。事实是,自从 Internet Explorer 9——Array.prototype.indexOf
成为语言的一部分以来,最优雅的解决方案是使用“普通的”JavaScript。当然,在本书中还会有更多明显的例子。
在前端开发人员中,尤其是那些对 web 开发了解有限的人,在编写客户端应用时,通常认为有两种“语言”可供选择:jQuery 或 JavaScript。对于经验丰富的 web 开发人员来说,这组选项中的缺陷是显而易见的。这两种“语言”实际上只有一种是语言。事实上,JavaScript 是一种标准化的语言,而 jQuery 只是提供了一组实用方法,旨在使 JavaScript 在各种浏览器中解决常见问题变得更容易、更优雅。jQuery 只不过是 web API 包装方法的集合。
在开发 web 应用时,JavaScript 无处不在且不可避免,随着 Node.js 的出现,JavaScript 现在也是服务器上的一个可行选项。在接下来的部分中,我将解释 JavaScript 作为一种语言在 web 开发环境中的重要性。Beyond jQuery 没有一个明确的目标来深入研究语言语法和核心概念,如继承和范围,尽管如果与 jQuery 提供的抽象层有明确的联系,这些形式的语言细节可能会在整本书中不时出现。相反,您将了解 JavaScript 与 web API 的联系。类似于我们之前对 web API 的讨论,该语言的历史和标准化也将被探究。
Note
实际上,JavaScript 在技术上是可以避免的,尤其是在 WebAssembly 出现之后,但是这个标准还处于起步阶段。如果你用编译成 WebAssembly 的非传统前端语言编写,假设 WebAssembly 是可靠的(目前情况并非如此),那么你很可能不会受到 JavaScript 的攻击。但除此之外,它仍然是相当重要和不可避免的。
语言与 Web API
JavaScript 是 web API 不可或缺的组成部分。以 DOM 为例。虽然浏览器 DOM 通常用 C 或 C++实现,并打包为布局引擎(如 Safari 的 WebKit 和 Chrome 的 Blink),但 DOM 最常见的操作是使用 JavaScript。例如,考虑一下使用 DOM 元素属性。为此,DOM Level 1 中描述了三种属性相关的方法:getAttribute
、、 27 、、setAttribute
、、 28 、removeAttribute
。 29 另外 DOM Level 2 提供了hasAttribute
。 30 这四个方法都是在Element
接口中定义的,在 JavaScript 中有相应的(也是众所周知的)实现。给定任何 HTML 元素,您都可以在 JavaScript 中读取和操作它的属性,就像这些规范中定义的那样。第五章将会包含更多关于属性的细节。
除了 DOM 之外,当与 web API 中不依赖于 DOM 的部分进行交互时,也使用 JavaScript,例如 web 消息传递 API, 31 ,它是 W3C html 5 规范的一部分。Web 消息传递 API 为不同的浏览上下文提供了一种通过消息传递相互通信的方式。这为不同领域的两个iframe
之间的通信,甚至是浏览器主 UI 线程和 Web Worker 线程之间的通信打开了一个简单的途径。 32 该规范定义了一个MessageEvent
接口, 33 ,允许客户端监听传递的消息。在 JavaScript 中,这个事件对象在所有现代浏览器中实现,并允许开发人员使用在window
、document
和element
对象上可用的addEventListener
方法来监听消息。这些对象从EventTarget
接口获得这个方法,你可能还记得本章前面的内容,它是顶级接口,许多其他本地浏览器对象都是从这个接口继承而来的。我将在第九章中更详细地介绍事件处理。作为第八章的一部分,我们会更详细地介绍 Web 消息 API。
尽管 JavaScript 是使用特定于浏览器的本地 API 的关键,但作为一种语言,它不能与 web 规范本身相混淆。JavaScript 用于在浏览器中与这些 web 规范的实现进行交互,但是语言本身有自己的规范:ECMAScript, 34 ,我将在下一节对此进行更多的讨论。请注意,它并不依赖于 web,尽管它可以在所有的 Web 浏览器中实现。在某些方面,web API 建立在 JavaScript API 提供的基础之上。Array
s、 35 Object
s、 36 Function
s、 37 以及布尔和字符串 38 等原语都在 JavaScript 规范中定义,在浏览器中(以及其他环境中)可用。ECMAScript 规范的这些核心元素被进一步定义为具有额外的属性。例如,Array
s 包含一个检索特定项目索引的方法,实现为indexOf
。【39】Function
接口包含apply
40 或call
41 方法,这些方法可以很容易地调用具有备用上下文(值为this
) 42 的函数以及传递参数。第十二章包含了大量与特定于 JavaScript 的实用函数相关的细节,将它们与 jQuery 的高级包装方法进行了比较。
历史和标准化
JavaScript 的故事从 Brendan Eich 开始,他在 1995 年作为 Netscape 的员工,在十天内开发了该语言的第一个工作版本。一种在 Netscape Navigator 中运行的脚本语言将被创建,并被要求“像 Java 一样”。艾希被任命承担这一愿景,并使之成为现实。结果是混合了 C,Self, 43 和 Scheme, 44 和一点 Java 的味道。45JavaScript 化身的更多细节可以在 Brendan Eich 的博客中找到。 46
在正式的标准化过程建立之前,JavaScript 实际上是一种专有语言,只被 Netscape 在其旗舰浏览器中使用。但在 Netscape Navigator 中实现该语言后不久,微软创建了自己的实现 JScript,它首先在 Internet Explorer 3 中引入。JScript 和 Netscape 的 JavaScript 在名称上很相似。微软选择这个名字是为了避免 Java 商标的拥有者可能引起的任何商标纠纷, 47 当时是 Sun Microsystems。
在 JScript 出现后不久,一个正式的语言规范就被起草并在后来被采用。但是缺乏标准化,即使是在相对较短的时间内,已经对网络造成了明显的影响。1996 年末,Netscape 与欧洲计算机制造商协会(ECMA)接洽,以创建一个正式的语言规范。这在一定程度上是由 Netscape Navigator 和 Microsoft Internet Explorer 3 之间的语言实现差异引起的。第一个规范于 1997 年 6 月完成,命名为 ECMA-262,也称为 ECMAScript。该规范目前由 ECMA 技术委员会 39(也称为 TC39)制定,该委员会是一个受委托发展和维护该语言的个人团体。TC39 的成员包括重量级人物,如道格拉斯·克洛克福特、布伦丹·艾奇和耶胡达·卡茨(他也是 jQuery 基金会的成员)。
ECMAScript 语言规范的第一个版本发布于 1997 年,名为“ECMAScript- 262,第一版”在撰写本文时,第 7 版刚刚完成。尽管在语言的整个生命周期中,新规范的发布是不一致的,但从第 6 版开始,每年发布更新规范的概念似乎越来越流行。为了证明这一目标,该规范的第 6 版也被命名为“ECMAScript 2015”,第 7 版被命名为“ECMAScript 2016”,并假设第 8 版将是“ECMAScript 2017”,以此类推。一组“和谐”规范也是讨论的焦点,这个术语的关键似乎与规范的一般顺序有关。版本 4 和更早的版本是不和谐的,而版本 5 和更早的版本是和谐的,这一时期的规范被命名为“和谐”应该注意的是,规范的第 4 版实际上从未完成。相反,它被整合到 ECMAScript 5 中。和谐, 49 在这个上下文中,可能指的是目标和需求,比如“成为更好的写作语言”,以及“保持语言对于临时开发人员的愉悦”语言规范的和谐运动的另一个目标是“建立在 ES5 严格模式的基础上,以避免过多的模式。”换句话说,简化和可用性给语言带来了和谐。在撰写本文时,ECMAScript 3 的浏览器支持包括现有的所有浏览器。所有“现代”浏览器都完全支持 ECMAScript 5。ECMAScript 2015 目前在所有浏览器的大多数当前版本中都有不错的支持。ECMAScript 2016 目前的支持是不稳定的,但当然会随着时间的推移而改善。
Footnotes 1
https://www.w3counter.com/globalstats.php?year=2016&month=6
2
http://gs.statcounter.com/#all-comparison-ww-monthly-201404-201606
3
http://stratechery.com/2015/mobile-first/
4
www.gartner.com/newsroom/id/2944819
5
www.w3.org/TR/html5/embedded-content-0.html#attr-media-autoplay
6
7
8
9
www.w3.org/TR/1998/REC-DOM-Level-1-19981001/
10
www.w3.org/TR/DOM-Level-3-Core/
11
www.w3.org/TR/DOM-Level-2-HTML/
12
http://dev.w3.org/2006/webapi/selectors-api2/
13
http://dev.w3.org/csswg/selectors-4/
14
https://dvcs.w3.org/hg/dom3events/raw-file/tip/html/DOM3-Events.html
15
16
17
18
www.w3.org/html/wg/drafts/html/master/semantics.html#the-picture-element
19
20
21
www.w3.org/TR/html5/browsers.html#the-history-interface
22
www.w3.org/TR/html5/browsers.html#the-history-interface
23
24
www.w3.org/Consortium/facts#history
25
https://notifications.spec.whatwg.org
26
https://wiki.whatwg.org/wiki/FAQ#What_is_the_WHATWG.3F
27
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#method-getAttribute
28
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#method-setAttribute
29
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#method-removeAttribute
30
www.w3.org/TR/DOM-Level-2-Core/core.html#ID-ElHasAttr
31
www.w3.org/TR/DOM-Level-2-Core/core.html#ID-ElHasAttr
32
33
www.w3.org/TR/webmessaging/#the-messageevent-interfaces
34
35
www.ecma-international.org/ecma-262/5.1/#sec-15.4
36
www.ecma-international.org/ecma-262/5.1/#sec-15.2
37
www.ecma-international.org/ecma-262/5.1/#sec-15.3
38
www.ecma-international.org/ecma-262/5.1/#sec-4.3.2
39
www.ecma-international.org/ecma-262/5.1/#sec-15.4.4.14
40
www.ecma-international.org/ecma-262/5.1/#sec-15.3.4.3
41
www.ecma-international.org/ecma-262/5.1/#sec-15.3.4.4
42
www.ecma-international.org/ecma-262/5.1/#sec-10.3
43
http://handbook.selflanguage.org/4.5/intro.html
44
www.scheme.com/tspl4/intro.html
45
www.oracle.com/technetwork/topics/newtojava/downloads/index.html
46
https://brendaneich.com/2008/04/popularity/
47
http://yuiblog.com/blog/2007/01/24/video-crockford-tjpl/
48
www.ecma-international.org/memento/TC39.htm
49
http://wiki.ecmascript.org/doku.php?id=harmony%3Aharmony
四、查找 HTML 元素
您遇到过多少次这样的项目:仅仅使用 jQuery 来执行看似微不足道的元素选择?你写了多少次$('#myElement')
或者$('.myElement')
?如果您的大多数(或所有)项目都依赖于 jQuery,那么您可能没有意识到这样一个事实,即您不需要 jQuery 来选择元素!借助普通的 ole boring web API,这项任务相当简单。与 jQuery 非常相似,有大量的例子(如本书)展示了如何恰当地利用浏览器的能力来快速选择文档中的任何元素。所有这些都不是什么秘密,但是 jQuery 的普及性让许多人相信找到元素的唯一合理的方法是借助万能的美元符号。没有什么比这更偏离事实了,当你继续读下去,所有这些都会变得清晰。
这里描述的所有选择元素的方法在所有现代浏览器中都受支持。事实上,许多在古代浏览器中也受支持。换句话说,除非您正在支持一个老化的遗留 web 应用,否则即使是最复杂的用于选择元素的本地解决方案也可以在没有任何库的帮助下使用。对于那些仍然依赖于过去浏览器的旧应用来说呢?您可以用几行代码轻松复制任何缺少的本机方法。事实上,我将提供一些简单直观的解决方案来帮助您填补古代浏览器中的重要空白。
尽管 jQuery 确实为您节省了一些击键次数,但是您将会牺牲性能。这种类型的高级抽象比直接依赖原生 API 要慢,这是可以理解的。那么,如果您希望既有 jQuery 的便利性,又没有巨大的依赖性和潜在的性能问题,该怎么办呢?很简单,在 web API 周围创建你自己的非常薄的包装器来节省你的一些击键。在我们结束本章之前,我将用一些示例代码来探索这种可能性。
核心元素选择器
在关于在文档中查找 HTML 元素这一主题的第一部分中,我讨论了通过使用一些更传统的元素属性来选择元素,比如 ID、类和标记名。在这里,我通过例子比较了 jQuery 中的元素选择和“普通”JavaScript,这些例子利用各种 web API 规范中的功能直接与 DOM 交互。完成本节之后,您将有必要的信心和理解来使用最常见的方法在 DOM 中选择元素——完全不依赖 jQuery。
本能冲动
W3C HTML4 规范 1 将id
属性定义为在文档内定义的所有 id 中必须是唯一的。规范的这一部分继续描述它的主要用途, 2 比如元素选择和使用锚链接导航到页面的其他部分。DOM Level 1 规范定义了HTMLElement
接口、 3 ,所有其他元素都从该接口继承。在这个接口中定义了id
属性,它直接连接到在标记中相应元素上定义的id
属性。
例如,考虑以下标记:
1 <div id="my-element-id"></div>
<div>
元素的id
属性也可以通过元素的 JavaScript 表示来访问。这是由元素对象的id
属性公开的:
1 // `theDiv` is the <div> from our sample HMTL above
2 theDiv.id === 'my-element-id'; // returns true
框架
在 jQuery-land 中,获取<div>
元素对象的句柄类似于清单 4-1 。
1 // returns a jQuery object with 1 element -
2 // the <div> from our sample HMTL above
3 var result = $('#my-element-id');
4
5 // assuming our element has been found in the document
6 result.is('#my-element-id'); // returns true
Listing 4-1.Select by ID: jQuery
在 jQuery 示例中,我们使用 ID 选择器字符串,它最初是在 W3C CSS1 规范中定义的。 4 这次选择尝试返回的 jQuery 对象是一个伪数组(第十二章对此有更详细的讨论)。这个伪数组包含文档中这个元素的对象表示。
Web API
在没有 jQuery 帮助的情况下选择完全相同的元素非常容易,事实上实现这一点的代码看起来非常相似。使用 web API 通过 ID 选择元素有两种不同的方式。第一个这样的方法涉及到使用在Document
接口上定义的getElementById
方法,它首先在 DOM Level 2 Core 规范中正式化。 5 现有的所有浏览器都支持这种方法(最早在 Internet Explorer 5.5 中实现):
1 // returns the matching HTMLElement - the <div> from our sample
2 var result = document.getElementById('my-element-id');
3
4 // assuming our element has been found in the document
5 result.id === 'my-element-id'; // returns true
第二种方法利用了querySelector
方法,该方法首先在 W3C 选择器 API Level 1 规范中的文档和元素接口上定义。 6 记住定义了id
属性的HTMLElement
接口继承自Element
接口,所以Element
s 也有一个id
属性。所有现代浏览器都有querySelector
方法,包括 Internet Explorer 8。在清单 4-2 中,您将开始注意到本地方法和 jQuery 快捷方式之间的一些明显的相似之处。
1 // returns the matching HTMLElement - the <div> from our sample
2 var result = document.querySelector('#my-element-id');
3
4 // assuming our element has been found in the document
5 result.id === 'my-element-id'; // returns true
Listing 4-2.Select by ID: Web API, Modern Browsers and Internet Explorer 8
Performance Note
querySelector
比getElementById
、、和、稍慢,但是随着浏览器 JavaScript 引擎的发展,这种性能差距正在缩小。
班级
与 IDs 的关注点相反,类属性并不唯一地标识文档中的元素。相反,传统上使用类在应用的上下文中将元素作为一个整体进行语义分组。虽然 id 当然可以用于通过 CSS 设计元素的样式,但是这个角色通常与类属性联系在一起。元素也可以被分配多个类名,而它们只限于一个 ID(原因很明显)。HTML 4.01 规范对类属性的角色进行了更详细的描述。 8 第五章更详细地讨论了元素属性的使用。
一般来说,有效的 CSS 类不区分大小写,只能包含字母数字字符或连字符或下划线,并且不能以一个数字或两个连字符或一个连字符和一个数字开头。这些规则也适用于 id,以及用于通过 CSS 定位元素的其他元素属性。你可以阅读 CSS 2.1 规范中 CSS 选择器的所有允许值。 9
例如,考虑以下标记:
1 <span class="some-class"></span>
通过对象的className
属性上的元素的 JavaScript 表示,也可以访问span
元素的class
属性。注意这里的不一致性——属性名是class
,而对应的Element
属性是className
。这是因为class
是许多语言中的保留字,例如 JavaScript(甚至晚于 ECMAScript 5.1 版规范), 10 这就是为什么在元素的 JavaScript 表示中存在备用名称。例如:
1 // `elementObject` is the <span> in our sample markup above
2 elementObject.className === 'some-class'; // returns true
框架
在 jQuery 中按类选择元素看起来非常类似于用来选择 ID 的方法。事实上,jQuery 中的所有元素选择都遵循相同的模式:
1 // Returns a jQuery object with 0 elements (element not found)
2 // or all elements with the 'some-class' class attribute.
3 var result = $('.some-class');
4
5 // assuming our element has been found in the document
6 result.is('.some-class'); // returns true
如果文档中碰巧有三个不同元素的类名为some-class
,那么result
jQuery 对象将有三个条目,每个条目对应一个匹配。
Web API
与 IDs 一样,使用 web API 通过类名选择元素有几种不同的方法。我将演示其中的两个——都可以在现代浏览器和 Internet Explorer 8 中使用(最后一个例子)。清单 4-3 是性能最好的,但是清单 4-4 显然是最优雅的。
1 // Returns an HTMLCollection containing all matching elements,
2 // which is empty if there are no matches.
3 var result = anyElement.getElementsByClassName('some-class');
4
5 // assuming our element has been found in the document
6 result[0].className === 'some-class'; // returns true
Listing 4-3.Select by Class: Web API, Modern Browsers
getElementById
和getElementsByClassName
之间第一个值得注意的区别是,后者返回一个类似数组的对象,包含所有匹配的元素,而不是单个元素。记住,一个文档可以包含许多共享相同类名的元素。您可能还会注意到另一个差异,在提供的简单示例中,这个差异可能不是特别明显。getElementsByClassName
方法在Document
界面上可用,就像getElementById
一样。但是,在 W3C DOM4 规范中,它也被定义为元素接口上的方法。 11 这意味着在文档中指定一个元素来查找类名匹配时,可以将查询限制在特定的元素子集。当在特定元素上执行时,只检查后代元素的匹配。这允许更集中和更有效的 DOM 遍历。
getElementsByClassName
方法的返回值是一个 HTMLCollection,这是一个伪数组,它提供了有序的数字属性(0,1,2,.。。),每个匹配元素一个,还有一个length
属性和一些其他方法(用处有限)。一件HTMLCollection
最显著的特点是它是一件活的收藏品。也就是说,它会自动更新,以匹配它所代表的 DOM 中的底层元素。例如,如果包含在返回的HTMLCollection
中的一个元素从 DOM 中移除,那么它也将从范围内的任何HTMLCollection
中移除。注意,getElementsByClassName 是在 W3C DOM4 规范中定义的。
第二种方法,如清单 4-4 所示,通过类名选择元素,涉及到前面演示的querySelector
的一个表亲。
1 // Returns a NodeList containing all matching elements,
2 // which is empty if there are no matches.
3 var result = anyElement.querySelectorAll('.some-class');
4
5 // assuming our element has been found in the document
6 result[0].className === 'some-class'; // returns true
Listing 4-4.Select by Class: Web API, Modern Browsers and Internet Explorer 8
像getElementsByClassName
,querySelectorAll
在一个类似数组的对象中返回所有匹配。不过,差异也就到此为止了。例如,querySelectorAll
返回一个NodeList
对象,这是一个首先在 W3C DOM Level 3 核心规范中正式定义的接口。 12 NodeList
与HTMLCollection
有一个重要的区别:它不是一个“活”的系列。因此,如果一个包含在NodeList
中的匹配元素从 DOM 中移除,它不会从任何NodeList
中移除。
根据 W3C 选择器 API,querySelectorAll
在Document
接口和Element
接口上都是可用的(就像getElementsByClassName
)。13querySelector
方法也可以在查找具有特定类名的元素时使用,它将只返回第一个匹配的元素,这在某些情况下实际上可能是可取的。无论哪种情况,都必须传递 CSS 选择器字符串。在查找类名时,我们必须包含一个“.”前缀,它最早是在 CSS 1 规范中描述的, 14 ,尽管更多的细节包含在后来的 CSS 2.1 规范中。 15 虽然getElementsByClassName
在 IE8 中不可用,但是您也可以通过简单地将 CSS 类选择器字符串传递给querySelectorAll
方法,在浏览器中通过类名定位元素。
如果您必须支持 Internet Explorer 7 或更早的版本,通过类名选择元素的方法可能有点麻烦。因为这种类型的支持正在迅速失宠,所以我选择忽略丑陋且低效的遗留解决方案(jQuery 无论如何都必须依赖它)。你可以看看我维护的一个库是如何解决 16 支持古代浏览器的问题的。
元素标签
任何元素最基本的属性就是它的名字。早在 1993 年,由蒂姆·伯纳斯·李部分起草的 IETF HTML 规范中, 17 一个有效的元素/标签名可以“由一个字母后跟多达 33 个字母、数字、句点或连字符组成”该规范继续说“名称不区分大小写”在这个文档中还定义了少量的元素,比如锚标记(<a>
)、段落标记(<p>
)和用于提供联系信息的<address>
元素。自从这个第一个规范以来,已经添加了更多的元素,例如在相对较新的 HTML5 规范中添加的<video>
18 和<audio>
19。
尽管定制元素没有被浏览器明确禁止,但是在 Web 组件规范出现之前,没有什么动机去创建它们。 20 Web Components 是一个规范集合,其中一个是自定义元素规范, 21 详细说明了创建新的HTMLElement
的方法,这些新的HTMLElement
具有自己的 API 和属性,甚至是现有元素的扩展——例如 ajax 表单自定义元素, 22 扩展并添加了原生<form>
的功能。
为了设置我们的示例,考虑以下非常简单的 HTML 块:
1 <code>System.out.println("Hello world!");</code>
如果给你一个元素引用,你可以通过tagName
属性很容易地确定元素的名称,这个属性是在元素接口上定义的,是 DOM Level 1 Core 的一部分: 23
1 // `elementObject` is the <code> element from our above HTML
2 elementObject.tagName === 'code'; // returns true
框架
可以预见的是,通过将 CSS 元素选择器传递到$
或jQuery
函数中,可以方便地通过 jQuery 选择元素:
1 // Returns a jQuery object with 0 elements (element not found)
2 // or all elements with a matching tag name.
3 var result = $('code');
4
5 // assuming our element has been found in the document
6 result.is('code'); // returns true
这里没什么神奇的。事实上,元素名称选择器字符串的语法是在第一个 CSS 规范中定义的。jQuery 只是为本地方法提供了一个简单的别名,这些本地方法可以通过标签名来选择元素,接下来将对此进行探讨。
Web API
让我们从通过直接与本地 web API 交互来快速查看通过标记名选择元素的传统方法开始:
1 // Returns a HTMLCollection containing all matching elements,
2 // which is empty if there are no matches.
3 var result = anyElement.getElementsByTagName('code');
4
5 // assuming our element has been found in the document
6 result[0].tagName === 'code'; // returns true
前面的方法早在 DOM Level 1 Core 就已经有了,而且和getElementsByClass- Name
一样,在文档接口 25 和元素接口上都有。 26 所以,这种方法在现有的所有浏览器上都是可用的。
如你所料,更“现代”的方法包括querySelector
或querySelectorAll
:
1 // Returns a NodeList containing all matching elements,
2 // which is empty if there are no matches.
3 var result = anyElement.querySelectorAll('code');
4
5 // assuming our element has been found in the document
6 result[0].tagName === 'code'; // returns true
7
8 // -OR-
9
10 // ...you can use this if you know there is only one <code>
11 // element to find, or if you only care about the first.
12 // Returns true.
13 anyElement.querySelector('code').tagName === 'code';
目前在getElementsByTagName
和querySelectorAll(tagName)
之间存在潜在的显著性能差异。 27 使用querySelectorAll
的性能后果显然是由于getElementsByTagName
返回 DOM 中匹配元素的动态集合(一个 HTMLCollection),而querySelectorAll
返回静态集合(一个 NodeList)。后者需要遍历 DOM 中的所有元素,而前者返回缓存的匹配元素,然后在访问列表时查询文档更新。 28 这种性能差异类似于getElementsByClassName
对query- SelectorAll(classSelector)
出于同样的原因。
伪类
虽然在 CSS 规范的最新版本中,伪类的流行和数量有了很大的增长,但是伪类从 CSS 规范的最早版本就已经存在了。 29 伪类是向选择器字符串或元素组添加状态的关键字。例如,锚选择器字符串上的:visited
伪类将指向用户已经访问过的任何链接。另一个例子是,:focus
伪类将把被确定为具有焦点的元素作为目标,比如用户当前正在交互的文本输入字段。在下面的例子中,我们将使用后者,因为出于隐私考虑,浏览器会阻止 JavaScript 中的程序选择器访问被访问的链接。 30
为了设置我们的示例,让我们创建一个带有几个文本输入的简单表单,并假设用户点击了(或跳转到)最后一个文本输入(名为“company”)。这最后一个输入将是“聚焦”的输入:
1 <form>
2 <label>Full Name
3 <input name="full-name">
4 </label>
5 <label>Company
6 <input name="company">
7 </label>
8 </form>
框架
假设我们想使用 jQuery 选择当前关注的输入:
1 // Return value will be a jQuery object containing the
2 // "company" input element
3 var focusedInputs = $('INPUT:focus');
前面同样是一个标准化的 CSS 选择器字符串。我们使用了一个带有伪类修饰符的标签名选择器。jQuery 并没有为我们做什么特别的事情。事实上,它只是简单地直接委托给 web API。
Web API
请考虑以下几点:
1 // Return value will be the "company" text input field element
2 var companyInput = document.querySelector('INPUT:focus');
这段代码避免了所有与通过 jQuery 过滤调用相关的开销。如果我们使用 jQuery(就像我们在前面的例子中所做的那样),querySelectorAll
将被 jQuery 的选择器代码用完全相同的选择器字符串在内部调用。由于一次只能有一个元素有焦点,querySelector
比querySelectorAll
更合适。它也更快一点,因为同样的原因,任何一种getElementsBy
方法都比querySelectorAll
快。
根据元素之间的关系选择元素
有了一些在头脑中记忆犹新的选择元素的基本方法,我们就可以通过元素选择器进入下一步了。以下部分介绍了如何根据元素与其他元素的关系来选择元素。我们将研究定位子元素和子元素、子元素的父元素以及其他元素的兄弟元素。DOM 被组织成树状结构。考虑到这一点,能够在考虑关系的情况下导航这种节点层次结构通常是有利的。正如我们在核心选择器一节中已经看到的那样,在没有 jQuery 的情况下,根据元素之间的关系查找元素相当简单,而且性能更好。
父母和孩子
从我们对 DOM API 的讨论中可以看出,Element
是Node
的一种特定类型。如果一个Node
或Element
是一个“叶”节点,那么它可以没有孩子。否则,它将有一个或多个直接子级。但是文档中的每个Node
或Element
都只有一个直接父级。嗯,差不多了。这个规则有两个例外:一个出现在<html>
标签(HTMLHtmlElement
) 31 中,它是文档中的根Element
,因此没有父Element
(尽管它有一个父Node
: document
)。这就把我们带到了第二个异常,即既没有父对象Node
也没有父对象Element
的document
对象(Document
)32。是根Node
。
清单 4-5 显示了一个简单的 HMTL 片段。
1 <div>
2 <a href="http://fineuploader.com">
3 <span>Go to Fine Uploader</span>
4 </a>
5 <p>Some text</p>
6 Some other text
7 </div>
Listing 4-5.Example Markup for Parent/Children Traversal Examples
在下面的代码示例中,将区分目标子/父Node
s 和Element
s。如果这种区分还不清楚,首先要理解清单 4-5 是由Element
类型对象组成的,例如<div>
、<a>
、<span>
和<p>
。这些Element
也是Node
,因为Element
接口是Node
接口的子类型。但是片段的“转到精细上传器”、“一些文本”和“一些其他文本”部分不是Element
,但是它们是Node
,更具体地说,它们是Text
项。Text
接口 33 是CharacterData
接口 34 的一个子类型,它本身实现了Node
接口。
框架
jQuery 的 API 包括一个parent
方法。为了简单起见,我们假设“当前 jQuery 对象”只表示一个元素。当在这个对象上调用parent
方法时,产生的 jQuery 对象将包含父对象Element
,或者在极少数情况下,包含不是Element
的父对象Node
。参见清单 4-6 。
1 // Assuming $a is a reference to the anchor in our example HTML,
2 // $result will contain the <div> above it.
3 var $result = $a.parent();
4
5 // Assuming $span is a reference to the <span> in our example HTML,
6 // the first parent() call references the <a> element, and the
7 // $result will contain the <div> root element.
8 var $result = $span.parent().parent();
9
10 // Assuming someText is a reference to the "Some text" Text node,
11 // the result will contain the <p> element in our example HTML.
12 // Note: selecting a text node requires locating the node in the result of
13 // using the `contents()` method, as illustrated in the next code block.
14 var $result = $someText.parent();
Listing 4-6.
Get Parent Element/Node:
jQuery
为了定位子元素,jQuery 提供了一个children()
方法,该方法将返回给定元素的所有直接子元素Element
。您还可以使用 CSS 2.1 W3C 规范中标准化的子选择器选择给定引用元素的子元素。 35 但是由于children()
只会返回Element
s,所以我们必须使用 jQuery 的contents()
API 方法来获取任何不同时属于Element
s 的Node
s,比如Text
节点。同样,为了保持简单,清单 4-7 假设我们示例中的引用 jQuery 对象只引用 DOM 中的一个特定元素。
1 // Assuming $div is a jQuery object containing the <div> in our example HTML,
2 // $result will contain 2 elements: <a> and <p>.
3 var $result = $div.children();
4
5 // $result contains the <p> element in the sample markup
6 var $result = $('DIV > P');
7
8 // Again, assuming $div refers to the <div> in our example markup,
9 // $result will contain 3 nodes: <a>, <p>, and "Some other text".
10 var $result = $div.contents();
11
12 // Assuming $a refers to the <a> element in our example markup,
13 // $result will contains 1 element: <span>.
14 var $result = $a.children();
15
16 // This returns the exact same elements as the previous example.
17 var $result = $('A > *')
Listing 4-7.Get Child Elements and/or Child Nodes: jQuery
Web API
在大多数情况下,不使用 jQuery 来定位元素/节点的父元素很简单。DOM Level 2 Core 是第一个在Node
接口上定义一个parentNode
属性的规范, 36 正如您所料,它被设置为引用元素的父元素Node
。当然,这个值可以是一个Element
或任何其他类型的节点。后来,在随后的 W3C DOM4 规范中,一个parentElement
属性被添加到了Node
接口中。 37 这个属性永远是一个Element
。如果引用Node
的父级是除Element
之外的某种类型的Node
,那么parentElement
将是null
。但是大多数情况下,parentElement
和parentNode
会是相同的,除非参考节点是<html>
,在这种情况下parentNode
会是document
,parentElement
当然会是null
。一般来说,特别是由于广泛的浏览器支持,parentNode
属性是最好的选择,但是parentElement
几乎一样安全。参见清单 4-8 。
1 // Assuming "a" is the <a> element in our HTML example,
2 // "result" will be the the <div> above it.
3 var result = a.parentNode;
4
5 // Assuming "span" is the <span> element in our HTML example,
6 // the first parentNode is the <a>, while "result" is the <div>
7 // at the root of our example markup.
8 var result = span.parentNode.parentNode;
9
10 // Assuming "someText" is the "Some text" Text node in our HTML example,
11 // "result" will be the the <p> that contains it.
12 var result = someText.parentNode;
Listing 4-8.
Get Parent Element/Node:
Web API
使用 web API 有许多不同的方法来定位元素的直接子元素。接下来我将演示两种这样的方法,并简要讨论第三种方法。在所有现代浏览器中定位元素子元素的最简单也是最常见的方法是使用ParentNode
接口上的children
属性。 38 ParentNode
被定义为由Element
和Document
接口共同实现,尽管它通常只在Element
接口上实现。它适用于可能有孩子的Node
。它最初是在 W3C DOM4 规范 39 中定义的,只在现代浏览器中可用。ParentNode.children
返回一个HTMLCollection
中引用Node
的所有子节点,您可能还记得本章前面的内容,它代表了一个Element
的“活动”集合:
1 // Assuming "div" is an Element object containing the <div> in our example HTML,
2 // result will contain an HTMLCollection holding 2 elements: <a> and <p>.
3 var result = div.children;
第二种用于定位孩子Element
s 的方法包括使用querySelectorAll
和 CSS 2 子选择器。这种方法允许我们支持 Internet Explorer 8,以及所有现代浏览器。记住,querySelectorAll
返回一个NodeList
,它不同于HTMLCollection
,因为它是元素的“静态”集合。在这种情况下,集合包含父节点Node
的所有Element
子节点:
1 // The result will contain a NodeList holding 2 elements: <a> and <p>
2 // from our HTML fragment above.
3 var result = document.querySelectorAll('DIV > *');
4
5 // The result will be all <p> children of the <div>, which, in this case
6 // is only one element: <p>Some text</p>.
7 var result = document.querySelectorAll('DIV > P');
使用 web API 选择孩子的第三个选项涉及到了Node
接口上的childNodes
属性。 41 这个属性是在最初的 W3C DOM Level 1 核心规范中声明的。 42 结果是所有浏览器都支持,甚至是古代的。属性childNodes
将显示所有子节点Node
,甚至Text
和Comment
节点。您可以简单地通过迭代结果来过滤掉集合中的非Element
对象,忽略任何具有不等于1
的nodeType
属性 43 的对象。这个nodeType
属性也是在最初的Node
接口规范中定义的:
1 // Assuming "div" is an Element object containing the <div> in
2 // our example HTML, result will contain a NodeList
3 // holding 3 Nodes: <a>, <p>, and "Some other text".
4 var result = div.childNodes;
给定一个父节点Node
,你也可以分别通过恰当命名的firstChild
和lastChild
属性定位第一个和最后一个子节点。这两个属性在最初的Node
接口规范中就已经存在,并且它们引用子Node
s,所以第一个或最后一个子元素可能是Text Node
或HTMLDivElement
。firstChild
属性可以作为第四种方法的一部分,用来获取父Node
的子对象。这种方法将在下面的兄弟元素选择一节中讨论。
同科
如果它们共享同一个直系父代,那么它们就是兄弟姐妹。它们可能是相邻的兄弟姐妹(彼此紧挨着)或“一般的”兄弟姐妹(不一定紧挨着)。有多种方法可以在兄弟Node
中查找和导航。虽然我将介绍如何使用 jQuery 来实现这一点以供参考,但是您将会看到在没有 jQuery 的情况下实现这一点是多么容易。清单 4-9 将被用作所有演示代码的参考点。
1 <div id="parent">
2 <a href="https://github.com/rnicholus">GitHub</a>
3 <span>Span text</span>
4 <p>Paragraph text</p>
5 <div>Div text</div>
6 Text node
7 </div>
Listing 4-9.Working with Siblings: Markup for Following Demos
框架
为了找到给定Element
的所有兄弟Element
,jQuery 提供了一个siblings
方法作为其 API 的一部分。对于遍历给定Element
的兄弟节点,也有next()
和prev()
方法。为了简单起见,我将简单回顾一下我们是如何使用 jQuery 来查找和遍历给定元素的兄弟元素的,从清单 4-10 开始。
1 // $result will be a jQuery object that contains <a>, <span>, <p>,
2 // and <div> elements inside of the #parent <div>.
3 var $result = $('SPAN').siblings();
4
5 // $result will be a jQuery object that contains the <a> element
6 // that precedes the <span>.
7 var $result = $('SPAN').prev();
8
9 // The first next() refers to the <p>, and the 2nd next()
10 // refers to the <div>Div text</div> element, which is also
11 // the element contained in the jQuery $result object.
12 var $result = $('SPAN').next().next();
13
14 // The first next() refers to the <p>, and the 2nd next()
15 // refers to the <div>Div text</div> element. The final next()
16 // does not reference any element, since the final Node in the
17 // fragment is a Text Node, and not an element. So, the $result
18 // is an empty jQuery object.
19 var $result = $('SPAN').next().next().next();
Listing 4-10.Find and Traverse Through Siblings: jQuery
您还可以在 jQuery 中使用 CSS 同级选择器,我们将在下一节中对此进行探讨。jQuery 实际上允许标准化的 W3C CSS 选择器字符串用于这一操作和其他操作。
Web API
为了反映 jQuery API 提供的行为,我将讨论以下与兄弟遍历和发现相关的主题:
- 定位特定
Element
或Node
的所有兄弟。 - 浏览特定
Element
或Node
的前面和后面的兄弟。 - 使用 CSS 选择器定位一个
Element
的普通兄弟和相邻兄弟。 - 使用
Node
接口上的兄弟属性定位子节点。
定位另一个Element
的所有兄弟Element
的最简单方法是使用 CSS3 通用兄弟选择器。 44 这种方法可以追溯到 Internet Explorer 8,并为您提供所有同级Element
的NodeList
,W3C CSS2 规范定义了一个“相邻”同级选择器, 45 ,它只选择与引用元素之后出现的选择器相匹配的第一个Element
。清单 4-11 展示了这里描述的两个兄弟选择器。
1 // "result" contains a NodeList of all siblings that occur after the <span>
2 // in our example HMTL at the start of this section. These siblings are
3 // the <p> and the <div> elements.
4 var result = document.querySelectorAll('#parent > SPAN ∼ *');
5
6 // Another general sibling selector that specifically targets any
7 // subsequent siblings of the <span> that are <div>s. In our case,
8 // there is only one such element - <div>Div text</div>. The
9 // "result" variable is a NodeList containing this one element.
10 var result = document.querySelectorAll('#parent > SPAN ∼ DIV');
11
12 // This is an adjacent sibling selector in action. It will target
13 // the first sibling after the <span>. So, "result", is the same
14 // as in the previous general sibling selector example.
15 var result = document.querySelector('#parent > SPAN + *');
Listing 4-11.
Find Siblings Using
CSS Selectors
: Web API, Modern Browsers, and Internet Explorer 8
您会注意到,通用同级选择器(∾)不选择引用元素之前的任何元素,只选择引用元素之后的元素。如果您确实需要考虑引用元素之前的任何兄弟元素,您将需要使用首先在 W3C DOM Level 1 Core46中定义的Node.previousSibling
属性,或者使用首先在 W3C 元素遍历规范中定义的ElementTraversal
接口 47 的一部分previousElementSibling
属性。 48
ElementTraversal
是由任何也实现了Element
接口的对象实现的接口。简单地说,所有 DOM 元素都有一个previousElementSibling
属性。清单 4-12 展示了这一点。
1 // Find all siblings that follow the <span> in our example HTML
2 var allSiblings = document.querySelectorAll('#parent > SPAN ∼ *');
3
4 // Converts the allSiblings NodeList into an Array.
5 allSiblings = [].slice.call(allSiblings);
6
7 var currentElement = document.querySelector('#parent > SPAN');
8
9 // This loop executes until we run out of previous siblings,
10 // starting with the sibling before the <span>. Each sibling
11 // is added to the allSiblings array. After this loop is complete,
12 // the allSiblings array will contain all siblings of the <span>
13 // (before and after).
14 do {
15 currentElement = currentElement.previousElementSibling;
16 currentElement && allSiblings.unshift(currentElement);
17 } while (currentElement);
Listing 4-12.
Find Both Preceding and
Subsequent
Siblings of a Reference Element: Web API, Modern Browsers
Note
另一种方法是选择引用元素的父元素,然后收集其子元素,忽略引用元素。本节中的代码是专门为演示一些标准 CSS 选择器和元素属性而创建的。
对于 Internet Explorer 8 支持,您必须使用Node.previousSibling
而不是Element.previousElementSibling
。这是因为在 9 以前的任何版本的 Explorer 中都不支持元素遍历规范。该属性返回任何Node
,因此如果您只想接受Element
s,您将需要确保添加一个nodeType
属性检查。参见清单 4-13 。
1 var allSiblings = document.querySelectorAll('#parent > SPAN ∼ *');
2
3 // Converts the allSiblings NodeList into an Array.
4 var allSiblings = [].slice.call(allSiblings);
5
6 var currentElement = document.querySelector('#parent > SPAN');
7
8 do {
9 currentElement = currentElement.previousSibling;
10 // This differs from the previous example in that we must
11 // exclude non-Element Nodes by examining the nodeType property.
12 if (currentElement && currentElement.nodeType === 1) {
13 allSiblings.unshift(currentElement);
14 }
15 } while (currentElement);
Listing 4-13.
Find Both Preceding and Subsequent Siblings of a Reference Element: Web API, Modern Browsers, and Internet Explorer
8
web API 还在Node
接口上公开了一个nextSibling
属性,在ElementTraversal
接口上公开了一个nextElementSibling
属性。 49 如清单 4-14 所示,浏览器对这些属性的支持与它们“以前的”表亲相同。
1 // The first nextSibling refers to the <p>, and the 2nd nextSibling
2 // refers to the <div>Div text</div> element. The final nextSibling
3 // refers to the "Text node" Text Node, since nextSibling targets
4 // any type of Node. So, the result is this Text Node.
5 var result = document.querySelector('SPAN')
6 .nextSibling.nextSibling.nextSibling;
7
8 // Same as the above example, but the final nextElementSibling returns null,
9 // since the last Node in the example markup is not an Element. There are only
10 // 2 Element siblings following the <span>. Note that nextElementSibling
11 // is not available in ancient browsers.
12 var result = document.querySelector('SPAN')
13 .nextElementSibling.nextElementSibling.nextElementSibling;
Listing 4-14.Traverse Through All Subsequent Siblings: Web API, Modern Browsers, and Internet Explorer 8
除了上一节概述的使用 web API 选择孩子的方法之外,还有另一个这样的选项,在任何浏览器中只选择父Node
的Element
孩子。这包括获取父元素Node
的firstChild
,定位第一个子元素的兄弟元素Node
,然后使用每个Node
的nextSibling
属性继续遍历所有兄弟元素,直到没有剩余的兄弟元素。最后,为了排除所有非元素的兄弟元素(比如Text Node
s),只需检查每个Node
的nodeType
属性,如果Node
更具体地说是一个Element
,那么这个属性的值就是1
。这就是 jQuery 实现其children
方法的方式,至少在库的 1.x 版本中是这样。这种实现选择可能是因为Node
接口上的所有这些属性都有广泛的浏览器支持,甚至在古老的浏览器中。然而,现代浏览器支持更简单的方法,所以刚刚描述的路径实际上仅从学术或历史角度相关。
祖先和后代
为了说明祖先/后代Node
的关系,让我们从一个简短的 HTML 片段开始:
1 <body>
2 <div>
3 <span>random text</span>
4 <ul>
5 <li>
6 <span>item 1</span>
7 </li>
8 <li>
9 <a href="#some-content">item 2</a>
10 </li>
11 </ul>
12 </div>
13 </body>
一个元素的祖先是在 DOM 中出现在它之前的任何元素。也就是说,它的父母、其父母的父母(或祖父母)、其父母的父母的父母(曾祖父母),等等。在前面的 HTML 片段中,锚元素的祖先包括它的直接父元素(<li>
),以及<ul>
、<div>
,最后是<body>
元素。相反,一个元素的后代包括它的子元素、子元素的子元素等等。在前面的标记中,<ul>
元素有四个后代:两个<li>
元素、<span>
和<a>
。
框架
jQuery 的 API 提供了一种方法来检索元素的所有祖先- parents()
:
1 // Using our HTML example, $result is a jQuery object that
2 // contains the following elements: <li>, <ul>,
3 // <div>, and <body>
4 var $result = $('A').parents();
但是,如果您只想检索匹配特定条件的第一个祖先,该怎么办呢?在我们的例子中,假设我们只寻找也是一个<div>
的<a>
的第一个祖先。为此,我们将使用 jQuery 的closest()
方法。jQuery 通过强力实现closest()
——通过检查引用Node
的每个父节点:
1 // Using our HTML example, $result is a jQuery object that
2 // contains the <div> element.
3 var $result = $('A').closest('DIV');
为了定位后代,您可以使用 jQuery 的find()
方法:
1 // Using our HTML example, $result is a jQuery object that
2 // contains the following elements: both <li>s, the <span>,
3 // and the <a>.
4 var $result = $('UL').find('*');
5
6 // $result is a jQuery object that contains the <span>
7 // under the first <li>.
8 var $result = $('UL').find('SPAN');
Web API
原生 web 不提供返回元素所有祖先的单一 API 方法。如果你的项目需要这样做,你可以利用Node.parentNode
属性 50 或Node.parentElement
,通过一个简单的循环来累积这些Node
。记住,后者只针对一种特定类型的Node
:一种Element
。这通常是我们想要的,所以我们将在例子中使用parentElement
。参见清单 4-15 。
1 // When this code is complete, "ancestors" will contain all
2 // ancestors of the anchor element: <li>, <ul>,
3 // <div>, and <body>
4 var currentNode = document.getElementsByTagName('A')[0],
5 ancestors = [];
6
7 while (currentNode.parentElement) {
8 ancestors.push(currentNode.parentElement);
9 currentNode = currentNode.parentElement;
10 }
Listing 4-15.
Retrieve All Element Ancestors:
Web API
, Any Browser
我们已经知道,jQuery 提供了一种方法,允许我们轻松地找到元素的第一个匹配祖先,closest
。web API 在Element
接口上有类似的方法,也叫closest. Element.closest()
51 是 WHATWG DOM“生活标准”的一部分。 52 这个方法的行为和 jQuery 的closest()
完全一样。截至 2016 年年中,任何版本的 Internet Explorer 和 Microsoft Edge 都不支持这种方法,但 Chrome、Firefox 和 Safari 9 支持这种方法。在下一个例子中,我将演示如何使用 web API 的closest()
方法,我甚至为没有本机支持的浏览器提供了一个简单的后备。让我们再次使用我们的示例标记,并尝试定位<a>
的最近祖先,即<div>
。参见清单 4-16 和 4-17 。
Note
你可能还记得第三章的内容,WHATWG 开发了一套网络规范,与传统的 W3C 规范略有不同。
1 function closest(referenceEl, closestSelector) {
2 // use Element.closest if it is supported
3 if (referenceEl.closest) {
4 return referenceEl.closest(closestSelector);
5 }
6
7 // ...otherwise use brute force (like jQuery)
8 // To find a match for our closestSelector, we must use the
9 // Element.matches method, which is still vendor-prefixed
10 // in some browsers.
11 var matches = Element.prototype.matches ||
12 Element.prototype.msMatchesSelector ||
13 Element.prototype.webkitMatchesSelector,
14
15 currentEl = referenceEl;
16
17 while (currentEl) {
18 if (matches.call(currentEl, closestSelector)) {
19 return currentEl;
20 }
21 currentEl = currentEl.parentElement;
22 }
23
24 return null;
25 }
26
27 // "result" is the <div> that exists before the <a>
28 var result = document.querySelector('A').closest('DIV');
Listing 4-16.
Retrieve Closest Element Ancestor
: Web API, All Modern Browsers Except IE and Edge
1 function closest(referenceEl, closestSelector) {
2 // use Element.closest if it is supported
3 if (referenceEl.closest) {
4 return referenceEl.closest(closestSelector);
5 }
6
7 // ...otherwise use brute force (like jQuery)
8
9 // To find a match for our closestSelector, we must use the
10 // Element.matches method, which is still vendor-prefixed
11 // in some browsers.
12 var matches = Element.prototype.matches ||
13 Element.prototype.msMatchesSelector ||
14 Element.prototype.webkitMatchesSelector,
15
16 currentEl = referenceEl;
17
18 while (currentEl) {
19 if (matches.call(currentEl, closestSelector)) {
20 return currentEl;
21 }
22 currentEl = currentEl.parentElement;
23 }
24
25 return null;
26 }
27
28 // "result" is the <div> that exists before the <a>
29 var result = closest(document.querySelector('A'), 'DIV');
Listing 4-17.Retrieve Closest Element Ancestor: Web API, All Modern Browsers
请注意,跨浏览器解决方案使用了Element.matches
、、 53 、,这也是 WHATWG 在其 DOM living 规范中定义的。如果调用该方法的元素与传递的 CSS 选择器匹配,该方法将返回true
。一些浏览器,即 IE 和 Safari,仍然实现与旧版本规范一致的命名约定以及特定于供应商的前缀。我已经在我的例子中说明了这些。
前面的解决方案可能不够优雅,但它更好地利用了浏览器的固有功能。jQuery 的closest()
函数总是使用最原始的蛮力方式,即使浏览器原生支持Element.closest
。
使用 web API 查找后代就像使用 jQuery 一样简单(清单 4-18 )。
1 // Using our HTML example, result is a NodeList that
2 // contains the following elements: the two <li>s, <span>,
3 // and <a>.
4 var result = document.querySelectorAll('UL *');
5
6 // "result" is a NodeList that contains the <span>
7 // under the first <li>.
8 var result = document.querySelectorAll('UL SPAN');
Listing 4-18.
Retrieve Element Descendants
: Web API, Modern Browsers, and Internet Explorer 8
掌握高级元素选择
下面是一些更高级的方法,用于选择更具体的元素或元素组。虽然 jQuery 提供了 API 方法来处理每种场景,但是您会看到现代 web 规范也提供了相同的支持,这意味着对于这些示例,在现代浏览器中不需要 jQuery。这里的 Web API 解决方案将主要涉及各种 CSS3 选择器的使用, 54 也可以从 jQuery 中使用。
所有现代浏览器都支持本节中的所有本地示例。在某些情况下,我还会谈到如何在古老的浏览器中使用 web API 来实现相同的目标。如果您发现自己需要以下一些选择器来支持一个古老的浏览器,那么在理解如何使用浏览器的本地工具来解决这个问题之后,您可能会放弃引入 jQuery。或者不是,但至少该解决方案将揭示 jQuery 的内部工作原理,如果您坚持将其作为核心工具集的一部分,这仍然是有益的。
排除元素
尽管排除集合中特定匹配的能力是 jQuery API 的一部分,但是我们还将看到如何使用另一个适当命名的伪类来获得相同的结果。在我们深入研究代码之前,让我们考虑下面的 HTML 片段:
1 <ul role="menu">
2 <li>choice 1</li>
3 <li class="active">choice 2</li>
4 <li>choice 3</li>
5 </ul>
想象这是某种菜单,有三个项目可供选择。第二个项目“选项 2”当前被选中。如果您想方便地收集所有未选中的菜单项,该怎么办?
框架
jQuery 的 API 提供了一个not()
方法,该方法将从原始元素集中删除任何匹配选择器的元素:
1 // $result is a jQuery object that contains all
2 // `<li>`s that are not "active" (the first and last).
3 var $result = $('UL LI').not('.active');
虽然前面的例子是惯用的 jQuery,但是您不必使用not()
函数。相反,您可以使用 CSS3 选择器,这将在下面讨论。
Web API
现代浏览器的原生解决方案可以说和 jQuery 的一样优雅,当然也一样简单。下面,我们使用 W3C CSS3 否定伪类 55 来定位非活动列表项。没有库开销,所以这当然比 jQuery 的实现更有性能:
1 // "result" is a NodeList that contains all
2 // `<li>`s that are not "active" (the first and last).
3 var result = document.querySelectorAll('UL LI:not(.active)');
但是如果我们仍然需要支持 Internet Explorer 8,不幸的是它不支持否定伪类选择器呢?好吧,这个解决方案并不优雅,但是如果我们需要一个快速的解决方案并且不想引入一个大的库,这个解决方案也不是特别困难:
1 var allItems = document.querySelectorAll('UL LI'),
2 result = [];
3
4 // "result" will be an Array that contains all
5 // `<li>`s that are not "active" (the first and last).
6 for (var i = 0; i < allItems.length; i++) {
7 if (allItems[i].className !== 'active') {
8 result.push(allItems[i]);
9 }
10 }
前面的解决方案仍然比 jQuery 实现的not()
方法性能更好。 56
多重选择器
假设您想要选择几组不同的元素。考虑下面的 HTML 片段:
1 <div id="link-container">
2 <a href="https://github.com/rnicholus">GitHub</a>
3 </div>
4 <ol>
5 <li>one</li>
6 <li>two</li>
7 </ol>
8 <span class="my-name">Ray Nicholus</span>
如果您想选择“link-container”和“my-name”元素以及有序列表,该怎么办?我们还假设您想要在没有循环的情况下完成这一任务——只需一行简单的代码。
框架
jQuery 允许您通过提供一个长的逗号分隔的 CSS 选择器字符串来选择多个不相关的元素:
1 // $result is a jQuery object that contains 3 elements -
2 // the <div>, <ol> and the <span> from this section's
3 // HTML fragment.
4 var $result = $('#link-container, .my-name, OL');
Web API
使用 web API,不使用 jQuery 也可以获得完全相同的结果。该解决方案看起来与 jQuery 解决方案极其相似。在这种情况下以及许多其他情况下,jQuery 只是 web API 的一个非常薄的包装器。jQuery 完全支持并充分利用 CSS 规范。选择多个不相关的元素组的能力一直是 CSS 规范的一部分。由于 jQuery 支持标准的 CSS 选择器字符串,所以 jQuery 方法看起来与本地路径几乎相同:
1 // "result" is a NodeList that contains 3 elements -
2 // the <div>, <ol> and the <span> from this section's
3 // HTML fragment.
4 var result = document.querySelectorAll('#link-container, .my-name, OL');
元素类别和修饰符
jQuery 的 API 提供了不少自己专有的 CSS 伪类选择器,比如:button
、:submit
和:password
。事实上,jQuery 的文档建议不要使用这些非标准选择器,因为事实上还有更高性能的选择——标准化的 CSS 选择器。例如,:button
伪类的 jQuery API 文档包含以下警告:
- 因为:button 是一个 jQuery 扩展,不是 CSS 规范的一部分,所以使用:button 的查询无法利用原生 DOM querySelectorAll()方法提供的性能提升。
我将演示如何使用querySelectorAll
模拟 jQuery 自己的一些伪类的行为。这些解决方案(如清单 4-19 和 4-20 所示)将比使用非标准的 jQuery 选择器更高效。我们将从:button
、:submit
、:password
和:file
开始。
1 // "result" will contain a NodeList of all <button> and
2 // <input type="button"> elements in the document, just like
3 // jQuery's :button pseudo-class.
4 var result = document.querySelectorAll('BUTTON, INPUT[type="button"]');
1 // "result" will contain a NodeList of all <button type="submit"> and
2 // <input type="submit"> elements in the document, just like
3 // jQuery's :submit pseudo-class.
4 var result = document.querySelectorAll(
5 'BUTTON[type="submit"], INPUT[type="submit"]'
6 );
Listing 4-19.Implementing jQuery’s :button Pseudo-class: Web API, Modern Browsers, and Internet Explorer 8
清单 4-21 和 4-22 中的原生解决方案有点冗长,但并不特别复杂,而且肯定比 jQuery 的:submit
性能更好。你可以看到相同的性能差异between jQuery’s :button
选择器和本机解决方案更多: 58
1 // "result" will contain a NodeList of all <input type="password">
2 // elements in the document, just like jQuery's :password pseudo-class.
3 var result = document.querySelectorAll('INPUT[type="password"]');
Listing 4-21.Implementing jQuery’s :password Pseudo-class: Web API, Modern Browsers, and Internet Explorer 8
1 // "result" will contain a NodeList of all <input type="file">
2 // elements in the document, just as jQuery's :file pseudo-class.
3 var result = document.querySelectorAll('INPUT[type="file"]');
Listing 4-22.
Implementing jQuery’s
:file Pseudo-class: Web API, Modern Browsers, and Internet Explorer 8
甚至这个相当简单的原生 CSS 选择器也比 jQuery 的非标准:file
伪类快得多。 59 性能损失真的值得你在代码里省几个字符吗?
jQuery 还提供了一个非标准的:first
伪类选择器。如您所料,它会过滤查询结果集中除第一个匹配之外的所有匹配。考虑以下标记:
1 <div>one</div>
2 <div>two</div>
3 <div>three</div>
假设我们想要选择这个片段中的第一个<div>
。使用 jQuery,我们的代码看起来会像这样:
1 // $result is a jQuery object containing
2 // the first <div> in our example markup.
3 var $result = $('DIV:first');
4
5 // same as above, but perhaps more idiomatic jQuery.
6 var $result = $('DIV').first();
与 jQuery 的原始实现相比,本机解决方案出奇地简单,而且性能异常出色:
1 // result is the first <div> in our example markup.
2 var result = document.querySelector('DIV');
由于querySelector
返回选择器字符串的第一个匹配,这实际上是 jQuery 的:first
伪类或first()
API 方法的一个非常优雅的替代方法。在 jQuery 的武库中,您会发现许多其他专有的 CSS 选择器,它们在 web API 中有直接的替代方法。
$(选择器)的简单替换
在本章中,您已经看到了许多元素选择方法,包括将 CSS 选择器字符串传递给jQuery
函数(别名为$
)。本地解决方案通常包含传递给querySelector
或querySelectorAll
的相同选择器字符串。在所有条件相同的情况下,假设我们只使用有效的 CSS 选择器字符串,我们可以用一个本地解决方案来替换 jQuery 函数,这个本地解决方案不仅易于连接,而且比 jQuery 性能更高。
如果我们只关注选择器支持,并且只需要对现代浏览器的支持,我们可以通过完全放弃 jQuery 并用一个令人惊讶的简洁的本地替代来代替它,如清单 4-23 所示。
1 window.$ = function(selector) {
2 return document.querySelectorAll(selector);
3 };
4
5 // examples that use our replacement
6 $('.some-class');
7 $('#some-id');
8 $('.some-parent > .some-child');
9 $('UL LI:not(.active)');
Listing 4-23.Native Replacement for jQuery Function: All Modern Browsers, Internet Explorer 8 for CSS2 Selectors
当对比使用 jQuery 和 web API 选择这些相同的元素时,一些更复杂的选择器中看到的性能优势存在的原因与前面描述的相同。让我们看看上面代码中的子选择器。我们的原生解决方案无疑比 jQuery、 61 更快,并且两者之间的语法完全相同。在这里,我们没有因为放弃 jQuery 而放弃任何东西,并且获得了性能和更精简的页面——这是本章值得注意的主题。
Footnotes 1
www.w3.org/TR/REC-html40/cover.html
2
www.w3.org/TR/REC-html40/struct/global.html#adef-id
3
www.w3.org/TR/REC-DOM-Level-1/level-one-html.html#ID-58190037
4
www.w3.org/TR/REC-CSS1/#id-as-selector
5
www.w3.org/TR/DOM-Level-2-Core/core.html#ID-getElBId
6
www.w3.org/TR/selectors-api/#queryselector
7
http://jsperf.com/getelementbyid-vs-queryselector/11
8
www.w3.org/TR/html401/struct/global.html#adef-class
9
www.w3.org/TR/CSS21/syndata.html#characters
10
www.ecma-international.org/ecma-262/5.1/#sec-7.6.1.2
11
www.w3.org/TR/2015/WD-dom-20150428/#dom-document-getelementsbyclassname
12
www.w3.org/TR/DOM-Level-3-Core/core.html#ID-536297177
13
www.w3.org/TR/2007/WD-selectors-api-20071221/#documentselector
14
www.w3.org/TR/REC-CSS1/#class-as-selector
15
www.w3.org/TR/CSS21/selector.html#class-html
16
https://github.com/FineUploader/fine-uploader/blob/5.2.1/client/js/util.js#L107
17
www.w3.org/MarkUp/draft-ietf-iiir-html-01.txt
18
www.w3.org/TR/html5/embedded-content-0.html#the-video-element
19
www.w3.org/TR/html5/embedded-content-0.html#the-audio-element
20
www.w3.org/wiki/WebComponents/
21
www.w3.org/TR/custom-elements/
22
https://github.com/rnicholus/ajax-form
23
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#ID-1950641247
24
www.w3.org/TR/REC-CSS1/#basic-concepts
25
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#i-Document
26
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#ID-745549614
27
https://jsperf.com/queryselectorall-vs-getelementsbytagname
28
www.nczonline.net/blog/2010/09/28/why-is-getelementsbytagname-faster-that-queryselectorall/
29
www.w3.org/TR/CSS1/#anchor-pseudo-classes
30
www.w3.org/TR/selectors-api/#privacy
31
www.w3.org/TR/html5/semantics.html#the-html-element
32
www.w3.org/TR/html5/dom.html#the-document-object
33
www.w3.org/TR/DOM-Level-3-Core/core.html#ID-1312295772
34
www.w3.org/TR/DOM-Level-3-Core/core.html#ID-FF21A306
35
http://www.w3.org/TR/CSS21/selector.html#child-selectors
36
www.w3.org/TR/DOM-Level-2-Core/core.html#ID-1060184317
37
www.w3.org/TR/2015/WD-dom-20150428/#node
38
www.w3.org/TR/2015/WD-dom-20150428/#parentnode
39
www.w3.org/TR/2015/WD-dom-20150428/
40
http://www.w3.org/TR/CSS21/selector.html#child-selectors
41
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#ID-1950641247
42
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html
43
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#ID-1950641247
44
www.w3.org/TR/css3-selectors/#general-sibling-combinators
45
www.w3.org/TR/CSS21/selector.html#adjacent-selectors
46
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#ID-1950641247
47
www.w3.org/TR/ElementTraversal/#attribute-previousElementSibling
48
www.w3.org/TR/ElementTraversal/
49
www.w3.org/TR/ElementTraversal/#attribute-nextElementSibling
50
www.w3.org/TR/DOM-Level-2-Core/core.html#ID-1060184317
51
https://dom.spec.whatwg.org/#dom-element-closestselectors
52
53
https://dom.spec.whatwg.org/#dom-element-matchesselectors
54
55
www.w3.org/TR/css3-selectors/#negation
56
http://jsperf.com/jquery-not-vs-looping-through-results1
57
www.w3.org/TR/REC-CSS1/#grouping
58
http://jsperf.com/jquery-submit-vs-queryselectorall
59
http://jsperf.com/jquery-file-vs-queryselectorall
60
http://jsperf.com/jquery-first-vs-queryselector
61
http://jsperf.com/jquery-select-children-vs-native-replacement
五、使用和理解 HTML 元素属性
在第五章,你要准备好全面、深入、无拘无束地讨论与元素属性相关的一切。你在第四章学到的一切都将被证明是有用的,当你在你的属性之旅中应用这些知识的时候。我将确保您对属性有正确的理解,它们是如何产生并成为 HTML 的一部分的,以及它们如何适应 web API。此外,您将学习如何使用属性来定位 DOM 元素。虽然这在第四章中有所涉及,但你会发现这里的覆盖面要广泛得多。最后,我将深入研究属性,并演示如何在任何 DOM 元素上读取、添加和更新它们。还将包括关于data-
和class
属性的特殊部分。
除了几个例外,本章中的大多数 web API 代码都完全支持所有现代浏览器,甚至在许多情况下支持 Internet Explorer 8。完成本章后,您不仅会对属性有一个完整的理解,您还会有信心阅读它们、修改它们,并使用它们在所有浏览器中选择元素,即使是像 Internet Explorer 8 这样古老的浏览器。请继续阅读,继续探索超越 jQuery 的道路!
什么是属性?
从声明的角度来说,HTML 元素由三部分组成:名称、内容和属性,最后两部分是可选的。看一下下面这个简单的片段,我在解释这三个部分的时候会引用它。
1 <form action="/rest/login.php" method="POST">
2 <input name="username" required>
3 <input type="password" name="password" required>
4 </form>
在该标记中,您可以看到三个元素标签:一个<form>
和两个<input>
。<form>
元素的标签名为“form”。事实上,tagName
是 DOM 中实现了Element
接口的每个对象都有的属性。这个属性被标准化为 W3C 的 DOM Level 2 核心规范的一部分。 1 在前面的 HTML 中,表示为HTMLFormElement
对象的<form>
元素、 2 有一个值为“form”的tagName
属性。这两个<input>
元素被表示为HTMLInputElement
对象、 3 ,不出所料,它们每个都有tagName
的“输入”值。
内容是元素的第二部分,描述作为元素后代的任何其他节点。我的例子<form>
有两个<input>
元素作为内容,而两个<input>
元素没有内容。事实上,<input>
元素不允许有任何内容。这种限制很可能是因为<input>
元素最初是在 HTML 2 规范中引入的, 4 只是在 HTML 3 官方标准文档中首次明确提到。 5
A note about my example form markup
通常,您会希望将每个表单字段与一个<label>
相关联,该字段包含一个带有字段显示名称的文本节点。此外,提交按钮通常是谨慎的,但我将所有这些都从前面的标记中去掉了,以保持简单并专注于属性的讨论。
属性是元素的第三个也是最后一个部分,也是可选的,它提供了一种直接在标记中注释元素的方法。您可以使用它们来提供数据或状态。例如,上面的<form>
元素包含两个这样的属性:action
和method
,它们一起告诉表单在提交表单时向“/rest/login.php”服务器端点(action
)发送 POST 请求(method
)。第一个输入具有“用户名”的属性name
,第二个输入具有“密码”的属性name
。当服务器解析表单 submit 时,这些信息用于构造请求并将这些元素与它们的值联系起来。尽管在前面的 HTML 中并不明显,但您甚至可以创建自己的专有属性,并在代码中引用它们,以便将状态或数据与标记中的元素相关联。虽然没有严格要求,但更标准的方法是使用data-
属性,这将在本章后面提到。
除了提供数据或状态,一些属性还用于定义多用途元素的特定行为。请看前面片段中第二个输入的type
属性,这是一个例子。这个type
属性将第二个输入定义为密码输入,它通知浏览器屏蔽用户输入到这个字段中的任何字符。第一个输入可以包括一个值为“text”的type
属性,但这不是必需的,因为所有的<input>
元素都是默认的文本输入。这个缺省值在 HTML 出现时就已经存在了,在规范的早期草案中就可以看到。属性强加的行为的另一个例子可以在前面的两个输入中看到。注意每个输入上的required
属性——这是给任何支持constraints API
的浏览器的一个信号,如果用户将这些字段中的任何一个留空,它将阻止表单提交。 7
历史和标准化
属性一直是 HTML 的一部分,在第一个详述 HTML 标签的文档中有所描述,该文档由蒂姆·伯纳斯·李于 1992 年撰写。在这篇文章中,Berners-Lee 描述了今天在 HTML 中使用的两种通用类型的属性——布尔型和变量型——这两种属性我稍后会进一步阐述。调用属性的段落在文档的开头附近:
- 有些标签带有参数,称为属性。属性在标签后给出,用空格隔开。某些属性仅仅因为它们的存在而产生影响,其他的属性后面有一个等号和一个值。
Berners-Lee 继续提到了一些这样的属性,以锚标签的href
、type
和name
属性为例。不过请注意,<a>
元素上的name
属性不再可用,因为它在 HTML5 规范中被删除了。自从 HTML 的第一次描述以来,元素属性的数量和重要性都大大增加了。
任何 HTML 规范都不支持也从未正式支持过未绑定的自定义属性。但是,从 HTML5 规范开始,您可以在自己选择的属性名前面加上“data-”(稍后将详细介绍)。但是,如果您想在标记中引入一个纯粹的自定义属性,比如“myproject-uuid ”,您当然可以这样做。该页面将正常呈现,并且在您的浏览器的开发人员工具控制台中不会出现错误。一切都会好的。唯一的缺点是您的文档将无法通过验证,因为它将包含非标准属性——任何公认的 HTML 标准中都没有提到的属性。非标准定制属性实际上相当常见,甚至在流行的 JavaScript 框架中也很普遍,比如 AngularJS,它严重依赖非标准定制属性来促进与元素指令的通信。
最新推荐的 HTML 规范—HTML 5—定义了四种不同类型的属性。 10 一种通常被称为“布尔属性”的类型 11 被表示为没有任何显式值的元素属性。以常见于<input>
元素的required
属性为例(参见上一节的 HTML 片段)。如规范所述,“元素上布尔属性的存在代表真值,属性的缺失代表假值。”HTML 5.1 标准化的hidden
属性 12 指示浏览器不呈现任何带有该属性的元素,这是第一种类型的另一个例子。
第二种类型的属性被描述为“无引号的”一个鲜为人知的事实是,如果属性值不包含空格、等号、尖括号(<
和>
)或空字符串(以及其他不太重要的字符限制),您可以省略属性值两边的引号。因此,上一节中的 HTML 片段可以重写如下:
1 <form action=/rest/login.php method=POST>
2 <input name=username required>
3 <input type=password name=password required>
4 </form>
HTML 元素属性的最后两种类型非常相似:单引号和双引号。两者是相似的,因为它们在很大程度上有相同的限制,并且比不带引号的属性更常见,尽管双引号属性可以说是所有类型中最常见的。与未加引号的属性值相反,用单引号或双引号括起来的属性值可能包含空格、等号、尖括号或空字符串。最新的 W3C 规范中描述属性值的部分只提到它们不能包含任何模糊的&字符。“与号字符”是一个&
符号,后跟一个标准化的 ASCII 字符代码,以分号(;
)结束。“不明确的&字符”是指这个 ASCII 字符代码与规范的命名字符引用 13 部分中定义的任何字符代码都不匹配。
属性和特性有什么不同?
现在您已经对什么是元素属性有了很好的理解,但是您可能仍然对它们与元素“属性”的关系感到困惑,特别是如果您已经使用 jQuery 一段时间的话。 14 在一个非常基本的层面上,属性和属性彼此完全不同。虽然属性是在元素的标记中的 HTML 级别声明的,但是属性是在元素的对象表示中声明和更新的。例如,考虑以下元素:
1 <div class="bold">I'm a bold element</div>
<div>
有一个值为“bold”的class
属性。但是我们也可以在这个元素上设置属性。假设我们想将属性index
的值设为0
:
1 <div class="bold">I'm a bold element</div>
2
3 <script>
4 document.querySelector('.bold').index = 0;
5 </script>
在执行了前面的片段之后,我们的<div>
现在有了一个值为“bold”的class
属性和一个值为0
的index
属性。该属性是在底层 JavaScript 对象上设置的,在本例中,该对象是HTMLDivElement
15 接口的一个实现。像我们的index
这样的元素对象属性也被称为“expando”属性,这只是一种对非标准元素对象属性进行分类的简洁方法。请理解,并非所有元素属性都是 expando 属性。如果这还不完全清楚,不要担心。在本节结束之前,我将更多地讨论标准化元素的属性。
尽管属性和特性在概念上和语法上是不同的,但在某些情况下它们是紧密联系在一起的。事实上,所有标准化的元素属性都在元素的对象表示中定义了相应的属性。在大多数情况下,每个标准属性和属性对共享相同的值。除了一种情况之外,所有的属性和特性都使用相同的名称。这些标准化属性的特殊之处在于,您可以在不接触标记的情况下更新它们。更新相应的元素对象的属性值将导致浏览器更新文档中的属性值,更新属性值将依次增加元素的属性值。让我们来看一个简单的例子,我们定义了一个以href
开头的锚链接,然后使用 JavaScript 更新锚指向一个不同的位置:
1 <a href="http://www.widen.com/blog/">Read the Widen blog</a>
2
3 <script>
4 document.querySelector('A').href = 'http://www.widen.com/blog/ray-nicholus';
5 </script>
执行上述代码块中的脚本后,锚点现在出现在文档中,如下所示:
1 <a href="http://www.widen.com/blog/ray-nicholus">Read the Widen blog</a>
在这种情况下,HTMLAnchorElement
, 16 是<a>
的对象表示,在其原型上定义了一个href
属性,该属性直接连接到元素标签上的href
属性。这个href
属性实际上是从URLUtils
接口 17 继承而来的,HTMLAnchorElement
对象也实现了这个接口。URLUtils
是 WHATWG URL 生活标准 18 规范中正式定义的接口。
还有许多其他具有连接属性的元素属性,例如id
(所有元素)、action
(表单元素)和src
(脚本元素),等等。请记住,HTML 规范中出现的所有属性都属于这一类。但是有一些特殊情况和要点需要考虑。首先,class
属性有点不同,对应的属性名不是class
,而是className
。这是因为“类”在许多语言中都是保留字,比如 JavaScript。 19 关于class
属性的更多内容稍后介绍。还要记住,单选按钮和复选框输入元素共有的checked
属性最初只连接到相应的元素属性值。考虑下面的代码来更清楚地演示这种限制:
1 <input type="checkbox" checked>
2
3 <script>
4 // this does not remove the checked attribute
5 document.querySelector('INPUT').checked = false;
6 </script>
在执行了前面的脚本之后,您可能会期望从 input 元素中删除属性checked
,因为这也会发生在其他布尔属性上,比如required
和disabled
。然而,checked
属性仍然保留在元素上,即使属性值已经被更改为false
并且复选框确实没有被选中。
“自定义”属性,即未在任何公认规范中定义的属性,不会以任何方式链接到元素对象上类似命名的属性。为匹配非标准属性而创建的任何属性也被视为 expando 属性。
使用属性查找元素
基于第四章的类和 ID 选择器的例子,本节将提供一个更全面的使用 web API 选择任何和所有属性的指南。虽然 ID 和 class 属性的选择通常是使用特定于这两种类型属性的选择器语法来完成的,但是您也可以使用本章中介绍的更通用的属性选择方法。在某些情况下,当寻找遵循已知 ID 或类模式的多个元素时,这里展示的一些通用但强大的属性选择器是最合适的。
为了保持一致性和便于参考,本节将提供 jQuery 示例。但是不使用 jQuery 也可以用多种方式选择属性,只需使用querySelector
或querySelectorAll
即可。由于属性选择器最初是作为 W3C CSS 2 规范的一部分引入的, 20 从 Internet Explorer 8 开始,这里所有简单的 web API 示例(但不是所有更复杂的示例)都受到支持!您真的不需要 jQuery 来编写简单但功能强大的属性选择器。
使用属性名查找元素
我将很快详细介绍值,但是让我们首先关注属性名。为什么您可能只想关注属性名呢?也许有很多原因:
- 定位
disabled
元素或required
表单字段。 - 查找一个或多个包含自定义属性的元素,该属性以某种方式对这些元素进行分组。
- 定位文档中的无效标记,例如没有
src
属性的<img>
元素。
下面的 jQuery 和 web API 示例将围绕上面的#1 展开。为此,将使用一个小的 HTML 片段作为参考:
1 <form action="/submitHandler.php" method="POST">
2 <input name="first-name">
3 <input name="last-name" required>
4 <input type="email" name="email" required>
5 <button disabled>submit</button>
6 </form>
框架
有一种方法可以在 jQuery 中选择给定属性的元素,那就是将有效的 CSS 2+属性选择器字符串传递给 jQuery 函数:
1 var $result = $('[required], [disabled]');
前面的代码将产生一个包含“姓氏”和“电子邮件”元素的$result
jQuery 对象,以及被禁用的提交<button>
。为了防止选择器字符串中的逗号给你造成一些困惑,我在前一章的多元素选择器一节中介绍了这一点。这个 jQuery 代码完全依赖于幕后的 web API。
Web API
如同第四章中的许多本地解决方案一样,使用属性名定位元素所需的代码与您刚刚看到的 jQuery 解决方案惊人地相似(如清单 5-1 所示)。
1 var result = document.querySelectorAll('[required], [disabled]');
Listing 5-1.Selecting by Attribute Name: Web API, All Modern Browsers, and Internet Explorer 8
与 jQuery 示例类似,前面的代码将使用包含“姓氏”和“电子邮件”输入的NodeList
填充result
变量,以及禁用的提交按钮。
虽然disabled
和required
是布尔属性,但是即使我们给它们赋值,前面的代码也会产生相同的结果。属性选择器只是匹配属性名——值(或缺少值)无关紧要。这意味着您可以轻松地在文档中找到分配了 CSS 类的所有元素。例如,清单 5-2 显示了一个简单的属性选择器。
1 var result = document.querySelectorAll('[class]');
Listing 5-2.Selecting All Elements with a Class Attribute: Modern Browsers and Internet Explorer 8
给定以下 HTML:
1 <div class="bold">I'm bold</div>
2 <span>I'm not</span>
。。。前面选择器中的result
变量将产生一个元素:<div>
。但是要注意,简单地给<span>
添加一个空的class
属性可能会导致一个意外的结果集:
1 <div class="bold">I'm bold</div>
2 <span class>I'm not</span>
尽管没有给<span>
分配任何 CSS 类,但是属性class
的存在意味着我们的选择器将它和<div>
一起包含在结果集中。这可能不是我们想要的。这不是选择器 API 的缺陷,但是准确理解属性名选择器的工作方式是很重要的。注意,如果您没有牢牢掌握这个 CSS 选择器,使用 jQuery 也会遇到同样的“问题”。
使用属性名和值查找元素
有时,仅通过属性名定位一个元素或一组元素是不够的。例如,您可能想要定位所有密码输入字段,在这种情况下,您需要找到所有具有“password”属性的<input>
元素。或者您可能需要定位链接到特定端点的所有锚元素,在这种情况下,您需要键入所有<a>
元素的href
属性的期望值。
为了设置我们的 jQuery 和 web API 示例,让我们使用下面的 HTML 并声明我们的目标是定位链接到 ajax 表单 web 组件文档页面的所有锚点:
1 <section>
2 <h2>web components</h2>
3 <ul>
4 <li>
5 <a href="http://file-input.raynicholus.com/">file-input</a>
6 </li>
7 <li>
8 <a href="http://ajax-form.raynicholus.com/">ajax-form</a>
9 </li>
10 </ul>
11 </section>
12 <section>
13 <h2>no-dependency libraries</h2>
14 <ul>
15 <li>
16 <a href="http://ajax-form.raynicholus.com/">ajax-form</a>
17 </li>
18 <li>
19 <a href="http://fineuploader.com/">Fine Uploader</a>
20 </li>
21 </ul>
22 </section>
框架
为了找到所有指向 ajax 表单库页面的锚元素,我们将使用一个标准化的 CSS 选择器字符串传递到jQuery
函数中,就像我们以前多次看到的那样:
1 var $result = $('A[href="http://ajax-form.raynicholus.com/"]');
前面的选择器返回一个 jQuery 对象,该对象包含示例标记中的两个 ajax 形式的HTMLAnchorElement
对象。
Web API
您已经看到了在使用 jQuery 时,如何要求标准 CSS 选择器根据属性名和值进行选择,因此,当试图在没有 jQuery 的情况下查找特定的锚元素时,同样的选择器当然是最合适的。正如您在大多数其他元素选择示例中看到的那样,这里的解决方案几乎与 jQuery 方法相同,但效率更高:
1 var result =
2 document.querySelectorAll('A[href="http://ajax-form.raynicholus.com/"]');
result
变量是一个NodeList
,包含本节开头的示例 HTML 中的两个 ajax 表单锚。请注意,我将属性名称/值选择器与标记名称选择器结合在一起。这确保了可能包含非标准href
属性的任何其他元素都被忽略(以及任何<link>
元素),因为我们只关心锚链接。
还记得“按属性名选择”一节中的空类属性示例吗?在我们用 CSS 类搜索所有元素的过程中,我们无法用简单的属性名选择器忽略空的class
属性。但是如果我们将一个属性名称/值选择器与第四章的排除选择器配对,如清单 5-3 所示,我们可以有效地过滤掉空的class
属性。
1 var result = document.querySelectorAll('[class]:not([class=""]');
Listing 5-3.Find Anchors with Specific href Attributes: Web API, Modern Browsers
使用初始空类属性示例一节中的示例 HTML,前面的代码块result
包含一个NodeList
,该代码块只包含属性为“bold”的<div>
。属性为空的<span>
已被成功跳过。
通配符和模糊属性选择器的威力
属性选择器部分的最后一部分关注更高级的用例。在这一节中,我演示了四个非常强大的属性选择器技巧,它们也很容易理解,并且在所有现代浏览器以及 Internet Explorer 8 中都得到支持。您已经(多次)看到的 jQuery 和 web API 选择器代码之间的模式将在最后一组示例中继续。因此,让我们放弃 jQuery 和 web API 代码片段,因为在讨论元素选择器时,它们大多是多余的。如果您真的想以“jQuery 方式”运行下面的例子,只需用$()
替换document.querySelectorAll()
,并做好代码运行速度变慢的准备。
寻找特定的字符
还记得属性名和值部分的例子吗?我们希望定位文档中指向特定端点的锚链接。但是如果我们不关心整个 URL 呢?如果我们只关心领域呢?考虑下面的 HTML 片段:
1 <a href="http://fineuploader.com/">home page</a>
2 <a href="http://fineuploader.com/demos">demos</a>
3 <a href="http://docs.fineuploader.com/">docs</a>
4 <a href="http://fineuploader.com/purchase">purchase</a>
如果我们想在 http://fineuploader.com
定位所有锚链接,实例子串属性选择器,首先在 W3C CSS 3 规范中标准化, 21 允许我们这样做:
1 var result =
2 document.querySelectorAll('A[href*="http://fineuploader.com"]');
上面的result
变量是一个节点列表,包含除第三个之外的所有锚链接。这是为什么?我们正在寻找一个包含字符串"
http://fineuploader.com
"
的 href 属性。第三个锚链接不包含此字符串。也许这不是我们的意图,我们只是想找到所有的锚链接,以某种方式指向 fineuploader.com。简单!
1 var result =
2 document.querySelectorAll('A[href*="fineuploader.com"]');
寻找特定的单词
也许我们需要在属性值中定位一个特定的“单词”,而不是寻找字符组。例如,我们可以使用这个属性词选择器编写一个替代的 CSS 类选择器。考虑下面的 HTML 片段:
1 <div class="one two three">1 2 3</div>
2 <div class="onetwothree">123</div>
假设我们只想找到 CSS 类为“two”的元素。除了我在第四章中演示的 CSS 类选择器之外,我们还可以利用一个特殊的属性选择器来完成这个任务:
1 var result = document.querySelectorAll('[class∼=two]');
result
变量是一个包含一个条目的NodeList
——样本元素集合中的第一个<div>
——这正是我们要寻找的。但是为什么我们需要创建另一个类选择器呢?我们不知道,前面的例子也不太实际,尽管它很好地说明了这个选择器的行为。一个更现实的例子可能是在元素title
属性中定位一个特定的单词。考虑这组元素:
1 <a href="https://github.com/rnicholus/frame-grab.js"
2 title="frame-grab repo">frame-grab GitHub repo</a>
3
4 <a href="https://github.com/rnicholus/frame-grab.js/blob/master/docs/api.md"
5 title="frame-grab docs">frame-grab documentation</a>
6
7 <a href="https://www.youtube.com/watch?v=hHBhP03JHIQ"
8 title="frame-grab + fine-uploader">Video frame uploader</a>
9
10 <img src="https://travis-ci.org/rnicholus/frame-grab.js.svg?branch=master"
11 title="frame-grab build status">
12
13 <a href="https://foo.bar/subframe-grabber"
14 title="window-subframe-grabber">
15 Locates all iframes inside of a given iframe</a>
想象一下,这两个链接和一个图像,显然都与帧抓取库相关,存在于一个大文档中许多其他不相关的链接和图像之间。但是我们只想找到那些与帧抓取库直接相关的资源。我们不能使用子串属性选择器来选择“frame-grab.js ”,因为并不是所有的元素都包含带有“frame-grab.js”的href
或src
属性。我们也不想把重点放在短语“帧抓取”上,因为这将包括最后一个链接,它与帧抓取库无关。相反,我们需要选择所有具有包含特定短语“帧抓取”的title
属性的元素。
1 var result = document.querySelectorAll('[title∼=frame-grab]');
result
是一个NodeList
,它包含了 HTML 样本中除最后一个锚链接之外的所有元素,这正是我们要寻找的结果。
以开头或结尾的属性值。。。
需要注意的最后一组有用的高级属性选择器允许您在文档中定位属性值以一个或多个特定字符开头或结尾的元素。从实用的角度来说,也许您现在想知道为什么这样的选择器会有用。正如我们以前多次做过的那样,让我们从一点 HTML 开始,然后讨论这两个属性选择器对我们的重要性:
1 <img id="dancing-cat" srcimg/dancing-cat.gif">
2 <img id="still-cat" srcimg/still-cat.png">
3 <img id="dancing-zebra" src="dancing-zebra.gif">
4 <a href="#dancing-zebra">watch the zebra dance</a>
5 <a href="/logout">logout</a>
该片段很可能出现在一个大文档中,出现在许多其他锚链接和图像中,但是可以被认为是许多这样的元素的代表。假设我们想在这个文档中找到两件东西:
- 所有 GIF 图片。
- 引用当前页面上元素的所有锚点。
令人耳目一新的现实是,我们可以在不依赖任何第三方的情况下同时实现这两个目标:
1 var result = document.querySelectorAll('A[href^="#"], [src$=".gif"]');
前面的选择器使用第四章中介绍的多重选择器语法,分别组合了一个“开始于”和一个“结束于”属性值选择器。我们的“starts with”选择器以任何带有以散列标记开始的href
属性的锚元素为目标,这将只包括引用当前页面的锚。第二个选择器关注属性值以“.”结尾的元素。gif”。这将包括对 GIF 图像的引用,假设图像 URL 以预期的扩展名结尾。
读取和修改元素属性
所以现在您确切地知道了什么是属性(以及什么不是属性),并且您非常熟悉通过属性名称和值来选择元素。我要讨论的属性的最后一个方面包括读取和更新元素的属性,以及创建新的属性。您将发现解析、添加、删除和更改属性的适当方法可能取决于属性的类型。在这最后一节中,我将介绍三种不同类型的属性:类属性、数据属性以及所有其他通用的本地和定制属性。
类别属性
到目前为止,元素类属性似乎是“超越 jQuery”中的一个热门话题。第四章详细讨论了它们,我在本章前面的属性与属性部分提到了class
属性如何不同于它的元素属性名,我甚至在最近的用属性查找元素部分展示了如何使用类属性选择元素。好了,我们又在这里讨论阶级了。但是这次,我将向您展示如何读取特定元素的类,以及添加、切换和删除元素的类。
阅读课
“读取和修改属性”一节中的所有情况都假设您已经有了特定元素的句柄。因此,既然我们已经有了一个元素,也许我们想知道它与什么特定的 CSS 类相关联。或者也许我们只是想找出它是否与某个特定的类相关联。本节将研究这两种需求。
让我们从一个实际元素开始,以供参考:
1 <p class="product-name out-of-stock manual-tool">saw</p>
假设这个元素是一个大型文档中许多其他工具中的一个特定工具的名称。假设我们想知道关于我们已经登陆的特定工具元素的两件事情:
- 这种工具有存货吗?
- 这是手动工具还是电动工具?
jQuery 提供的解决方案利用了它的hasClass
API 方法:
1 var inStock = !$toolEl.hasClass('out-of-stock');
2 var type = $toolEl.hasClass('manual-tool') ? 'manual' : 'power';
布尔变量inStock
将被设置为值false
,因为该元素包含一个“缺货”类。而type
是“手动的”,因为存在一个“手动工具”类。这里没有惊喜。
但是我们不想用 jQuery!那么,如果没有它,我们怎么做呢?幸运的是,由于Element
接口上的classList
属性,现代 web API 提供了一个同样优雅的解决方案。22WHATWG 网络标准组织最初起草了classList
的规范,W3C 也将其包含在其 DOM4 文档 23 中。
注意,classList
属性是一个DomTokenList
。24DomTokenList
接口包含四个值得注意的方法,我将在本节中逐一演示。您将很快看到如何使用classList
对元素的class
属性执行各种操作,但是首先我将关注这样一个方法:contains
。 25 为了确定特定元素是否包含特定 CSS 类,DOM API 在classList
对象上提供了一个直观的属性:contains
。
1 var inStock = !toolEl.classList.contains('out-of-stock');
2 var type = toolEl.classList.contains('manual-tool') ? 'manual' : 'power';
前面的代码与 jQuery 示例相同——只需用classList.contains
替换hasClass
,就能获得性能优势! 26
如果您需要支持旧的浏览器,您将需要求助于正则表达式来确定您的目标元素是否包含某个类。幸运的是,这也相当简单(适用于任何浏览器):
1 var hasClass = function(el, className) {
2 return new RegExp('(^|\\s)' + className + '(\\s|$)').test(el.className);
3 };
4 var inStock = !hasClass(toolEl, 'out-of-stock');
5 var type = hasClass(toolEl, 'manual-tool') ? 'manual' : 'power';
无论您是否使用 jQuery,如果您想要获得与一个元素相关联的所有 CSS 类的列表,您必须直接访问Element
对象上的class
属性或className
属性。在这两种情况下,该值将是附加到该元素的所有 CSS 类的以空格分隔的字符串。
添加和删除类
接下来,我们有一个元素,我们需要删除“红色”类,并添加一个“蓝色”类:
1 <p class="red">I'm red. Make me blue!</p>
我们都知道,addClass
和removeClass
jQuery 函数分别用于在元素中添加和删除 CSS 类:
1 $pEl.removeClass('red').addClass('blue');
jQuery 解决方案非常漂亮,我们可以在一行代码中完成所有工作,而不会牺牲可读性。没有 jQuery 我们能做同样的事情吗?好吧,web API 方法有点冗长,链接不是它构建的,但它也一样简单。非 jQuery 解决方案也更快, 27 并且适用于除 IE9 之外的所有现代浏览器:
1 pEl.classList.remove('red');
2 pEl.classList.add('blue');
classList
再一次出手相救。也许你在对自己说,“原生解决方案让我多键入几个字符。这会大大影响我的工作效率。”真的吗?如果您频繁地通过 JavaScript 添加和删除类,那么多几个字符就会对敏捷性产生严重的负面影响,那么也许是时候重新评估您的应用设计了。
卡在支持 IE9 及更老版本?覆盖世界上所有浏览器的解决方案类似于上一节中的contains
的后备方案:
1 var removeClass = function(el, className) {
2 el.className =
3 el.className.replace(new RegExp('(^|\\s)' + className + '(\\s|$)'), ' ');
4 };
5 removeClass(pEl, 'red');
6 pEl.className += ' blue';
这比addClass(...)
和removeClass(...)
难多了。幸运的是,classList
是标准化的,是 jQuery 的类操作的合适替代品。
切换类别
假设我们有一个元素,我们想切换它的可见性,也许是为了响应一个按钮的点击。我稍后将介绍事件,所以让我们只关注切换该元素的可见性所需的逻辑:
1 <section class="hide">
2 <h1>User Info</h1>
3 </section>
jQuery 提供了大家熟悉的toggleClass()
方法,用法如下:
1 // removes "hide" class
2 $sectionEl.toggleClass('hide');
3
4 // re-adds "hide" class
5 $sectionEl.toggleClass('hide');
如果您使用的是现代浏览器(IE9 除外),没有 jQuery 也一样简单:
1 // removes "hide" class
2 sectionEl.classList.toggle('hide');
3
4 // re-adds "hide" class
5 sectionEl.classList.toggle('hide');
IE9 和更老版本的解决方案有点麻烦,但仍然可行。它包括检查该类是否存在,然后根据当前状态添加或删除它:
1 var toggleClass = function(el, className) {
2 var pattern = new RegExp('(^|\\s)' + className + '(\\s|$)');
3 if (pattern.test(el.className)) {
4 el.className = el.className.replace(pattern, ' ');
5 }
6 else {
7 el.className += ' ' + className;
8 }
9 };
10
11 // removes "hide" class
12 toggleClass(sectionEl, 'hide');
13
14 // re-adds "hide" class
15 toggleClass(sectionEl, 'hide');
请注意,您可以稍微重构前面的代码示例,直接引用前面代码示例中的 hasClass()和 removeClass()方法。
数据属性
尽管 CSS 类属性通常用于设计元素的样式,但是正如您所料,数据属性用于将数据附加到元素上。数据属性必须以“data-”为前缀,并且可以包含与特定元素相关联的字符串数据。任何有效的属性值都是可以接受的。虽然可以构造和使用非标准的元素属性,但是 W3C HTML5 规范声明自定义属性实际上应该是数据属性。 28
还有其他方法可以将更复杂的数据附加到元素上。第六章介绍了数据属性、HTML5 dataset
对象、元素数据的历史以及 jQuery 在解决这个问题中的作用,还有更多与元素数据相关的内容。
使用其他标准和自定义属性
正如您已经看到的,class
属性是特殊的属性,需要更具体的方法来正确地操作和读取它们。事实上,class
属性是两种“特殊”属性中的一种,data-
是另一种。但是所有其他的元素属性呢?我们如何才能最好地与他们合作?本节包括读取、写入、删除和创建标准和自定义属性。您可能已经熟悉并习惯了 jQuery 对这些任务的支持,但是您将会看到使用浏览器的功能处理属性是多么容易。
读取属性
让我们从一个简单的输入元素开始,它包括一个布尔属性和一个标准字符串值属性:
1 <input type="password" name="user-password" required>
假设我们得到了这个元素,我们想要两个问题的答案:
- 这个元素是什么类型的
<input>
? - 这
<input>
是必填字段吗?
这是 jQuery 在减轻开发人员负担方面失败的地方之一。虽然读取属性值很简单,但是没有专门的 API 方法来检测特定元素上属性的存在。虽然使用 jQuery 仍然可以做到这一点,但是这个解决方案不是很直观,可能需要库的新手做一些 web 搜索:
1 // returns "password"
2 $inputEl.attr('type');
3
4 // returns "true"
5 $inputEl.is('[required]');
jQuery 没有定义一个hasAttr
方法。相反,您必须使用 CSS 属性名称选择器来检查元素。web API 确实提供了这些便利,而且从 Internet Explorer 8:
1 // returns "password"
2 inputEl.getAttribute('type');
3
4 // returns "true"
5 inputEl.hasAttribute('required');
早在 1997 年,作为 W3C DOM Level 1 核心规范的一部分,getAttribute
方法首次在Element
接口上定义。 29 和hasAttribute
在 3 年后的 2000 年,在 DOM Level 2 核心规范中被添加到相同的接口。 30
我们可以让 jQuery 例子的后半部分更直观一点,只需跳出 jQuery 对象,直接在底层Element
上操作:
1 // returns "true"
2 $inputEl[0].hasAttribute('required');
因此,无论出于什么原因,如果您坚持使用 jQuery,可以将前面的例子视为一种更直接的方法来确定元素是否包含特定属性。作为一个额外的收获,你会发现在这里尽可能地绕过 jQuery 比完全依赖库更有效。
修改属性
我们在文档中有一个特定的<input>
元素的句柄,该元素如下所示:
1 <input name="temp" required>
我们想以三种方式修改这个HTMLInputElement
:
- 使其成为“电子邮件”输入字段。
- 确保它不是必需的。
- 将其重命名为“userEmail”。
jQuery 要求我们使用attr()
添加和更改属性,使用removeAttr()
删除属性来解决这个问题:
1 $inputEl
2 .attr('type', 'email') // #1
3 .removeAttr('required') // #2
4 .attr('name', 'userEmail'); // #3
没有 jQuery,我们的解决方案看起来几乎是一样的,并且具有同样广泛的浏览器支持。从 W3C 的 DOM Level 1 核心规范开始,Element
接口被定义为具有setAttribute
方法。 32 用这个方法,我们可以改变和添加元素属性,就像 jQuery 的attr()
方法一样。为了移除属性,我们使用了removeAttribute()
,这是在 DOM Level 1 Core 的Element
接口上定义的另一个方法。 33 通过这两种方法,我们可以很容易地修改我们的输入元素,如前所述:
1 inputEl.setAttribute('type', 'email'); // #1
2 inputEl.removeAttribute('required'); // #2
3 inputEl.setAttribute('name', 'userEmail'); // #3
除了缺乏链接支持之外,原生方法和依赖 jQuery 的方法一样直观。这是一个 web 标准已经足够的领域,而 jQuery 只提供了很小的便利性优势。正如您在本节中所看到的,在没有任何库帮助的情况下,处理属性通常非常容易。
Footnotes 1
www.w3.org/TR/2000/REC-DOM-Level-2-Core-20001113/core.html#ID-104682815
2
www.w3.org/TR/html5/forms.html#the-form-element
3
www.w3.org/TR/html5/forms.html#the-input-element
4
www.w3.org/MarkUp/html-spec/html-spec_toc.html
5
www.w3.org/MarkUp/html3/input.html
6
www.w3.org/MarkUp/HTMLPlus/htmlplus_41.html
7
www.w3.org/TR/html5/forms.html#constraints
8
www.w3.org/History/19921103-hypertext/hypertext/WWW/MarkUp/Tags.html
9
www.w3.org/TR/html5/text-level-semantics.html#the-a-element
10
www.w3.org/TR/html5/syntax.html#attributes-0
11
www.w3.org/TR/html5/single-page.html#boolean-attributes
12
www.w3.org/TR/html51/editing.html#the-hidden-attribute
13
www.w3.org/TR/html5/syntax.html#named-character-references
14
http://blog.jquery.com/2011/05/03/jquery-16-released/
15
www.w3.org/TR/html5/grouping-content.html#the-div-element
16
www.w3.org/TR/html51/semantics.html#the-a-element
17
https://url.spec.whatwg.org/#urlutils
18
19
www.ecma-international.org/ecma-262/6.0/#sec-keywords
20
www.w3.org/TR/CSS2/selector.html#attribute-selectors
21
www.w3.org/TR/css3-selectors/#attribute-substrings
22
https://dom.spec.whatwg.org/#dom-element-classlist
23
www.w3.org/TR/dom/#dom-element-classlist
24
https://dom.spec.whatwg.org/#domtokenlist
25
https://dom.spec.whatwg.org/#dom-domtokenlist-contains
26
http://jsperf.com/classlist-contains-vs-hasclass
27
http://jsperf.com/jquery-addclass-removeclass-vs-dom-classlist
28
www.w3.org/TR/html5/dom.html#embedding-custom-non-visible-data-with-the-data-%2A-attributes
29
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#ID-666EE0F9
30
www.w3.org/TR/DOM-Level-2-Core/core.html#ID-666EE0F9
31
http://jsperf.com/hasattribute-vs-jquery-is
32
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#method-setAttribute
33
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#method-removeAttribute
六、HTML 元素数据存储和检索
在第五章中,我在讨论 HTML 元素属性的三种类型时提到了数据属性。关于将任何类型的数据连接到文档元素的所有内容,请不要再看了。所有的细节都在这一章中,这一章也将建立在第五章的通用属性和元素属性上。在演示将重要的数据结构连接到标签的过程中,还将简要介绍 JavaScript 对象。
当你继续阅读这一章的时候,你会理解为什么将数据连接到你的文档元素既重要又潜在棘手。和往常一样,我将向您展示如何将数据附加到元素上,然后最初使用 jQuery 读回数据。但最重要的是,您将看到如何在没有 jQuery 的情况下完成所有这些工作。我还将解释 jQuery 如何利用 web API 和 JavaScript 来提供对元素数据的支持。
管理元素数据的未来令人兴奋。我将向您展示 web API 和 JavaScript 如何准备在不久的将来超越 jQuery 对所有浏览器的元素数据支持。这种“未来派”的原生支持已经可以在许多浏览器上使用。我一定会包含丰富的代码示例,详细说明如何在您的项目中利用这种内置的支持。
为什么要将数据附加到元素上?
尤其是在现代 web 应用中,确实需要将数据与页面上的元素联系起来。在这一节中,我们将探索将自定义数据附加到标记的常见原因,以及通常是如何在较高层次上完成的。您将会看到,有许多原因使您发现跟踪数据和元素是有用的。
跟踪状态
也许您正在为一个房地产经纪人维护一个页面,其中包含一个当前市场上的房产表。大概,您会希望将它们从最受欢迎到最不受欢迎进行排序,并且您可能希望能够通过直接在页面上拖放来调整这个顺序:
1 <table>
2 <thead>
3 <tr>
4 <th>Address</th>
5 <th>Price</th>
6 </tr>
7 </thead>
8 <tbody>
9 <tr>
10 <td>6911 Mangrove Ln Madison, WI</td>
11 <td>$10,000,000</td>
12 </tr>
13 <tr>
14 <td>1313 Mockingbird Ln Mockingbird Heights, CA</td>
15 <td>$100,000</td>
16 </tr>
17 </tbody>
18 </table>
在移动一行之后,或者甚至作为初始标记的一部分,您可能希望用表中的原始索引来注释每一行。这可能用于在不调用服务器的情况下恢复任何更改。这里最合适的是一个data-
或自定义属性(分别为data-original-idx
或original-idx
)。
您可能还想跟踪元素的初始样式信息,如尺寸。如果您允许用户动态地调整元素的宽度和高度,您可能会希望有一种简单的方法来重置这些尺寸,以防用户改变主意。您可以在元素旁边存储初始维度,可能使用data-
属性。
连接元件
看似不同的元素很可能需要相互了解。例如,两个元素,其中一个元素的可见性由另一个元素的可见性决定。换句话说,如果一个元素可见,另一个元素不可见。这些元素如何以这种方式共存?答:通过维护彼此的引用。
这个场景有许多可能的解决方案。一种是将每个元素的伙伴元素的 CSS 选择器字符串嵌入到一个data-
或自定义属性中。假设这是合理的,并且有唯一的选择器可用,这可能是最佳选择。如果这是不可能的,那么您将需要维护一个Node
对象的地图。借助 JavaScript 对象,这可以通过几种不同的方式来实现。稍后会详细介绍。
将模型直接存储在元素中
您在页面上有一个用户列表:
1 <ul>
2 <li>jack</li>
3 <li>jill</li>
4 <li>jim</li>
5 </ul>
现在,您可能希望将一些公共属性与这些用户元素相关联,以供页面上的其他 JavaScript 组件使用。例如:用户的年龄、ID 和电子邮件地址。这可能最好指定为 JSON,并且可以通过一个data-
或自定义属性直接附加到用户元素。您可能会发现,这样的解决方案不适合不重要的数据,比如 JavaScript objects/JSON。在这种情况下,在一个“普通”的 JavaScript 对象中为标识元素及其数据的惟一键配对更合适。你可以在本章的后面阅读更多关于这种方法的内容。
将数据与元素配对的常见陷阱
由于快速发展的 web 和 JavaScript 规范,将数据与元素配对变得越来越容易。别担心,我很快会谈到细节。但是即使有了这些进步,生活也不简单。如果这种新的权力没有被负责任地使用,仍然有可能出现麻烦。当然,在现代网络和 JavaScript 出现之前,生活要困难得多。将普通数据附加到元素上是使用原始方法完成的。存储复杂的数据,比如其他的Node
可能会导致内存泄漏。本节涵盖了所有这些内容。
内存泄漏
当将两个(或更多)元素连接在一起时,本能反应是简单地将对其他元素的引用存储在某个公共 JavaScript 对象中。例如,考虑以下标记:
1 <ul>
2 <li>Ford</li>
3 <li>Chevy</li>
4 <li>Mercedes</li>
5 </ul>
这些汽车类型中的每一种都会响应单击事件,当其中一辆汽车被单击时,被单击的汽车必须具有突出的样式,而所有其他汽车必须变得不那么突出。实现这一点的一种方法是将对所有元素的引用存储在一个 JavaScript 数组中,并在单击其中一个列表项时遍历数组中的所有元素。被单击的项目必须是红色的,而其他项目应该设置为默认颜色。我们的 JavaScript 可能看起来像这样:
1 var standOutOnClick = function(el) {
2 el.onclick = function() {
3 for (var i = 0; i < el.typeEls.length; i++) {
4 var currentEl = el.typeEls[i];
5 if (el === currentEl) {
6 currentEl.style.color = 'red';
7 }
8 else {
9 currentEl.style.color = '';
10 }
11 }
12 };
13 },
14 setupCarTypeEls = function() {
15 var carTypes = [],
16 carTypeEls = document.getElementsByTagName('LI');
17
18 for (var i = 0; i < carTypeEls.length; i++) {
19 var thisCarType = carTypeEls[i];
20 thisCarType.typeEls = carTypes;
21 carTypes.push(thisCarType);
22 standOutOnClick(thisCarType);
23 }
24 };
25
26 setupCarTypeEls();
Don’t use inline event handlers
在前面的例子中,我通过元素的onclick
属性为元素分配了一个点击处理程序。这被称为内联事件处理程序,您应该避免这样做。因为我还没有涉及到事件,所以我采用了这种快捷方式来使代码示例尽可能简单,但是您不应该在代码中使用内联事件处理程序赋值。要了解更多关于如何在没有 jQuery 的情况下正确处理事件的信息,请看第十章。
Avoid inline style assignment
在前面的例子中,我通过改变元素的style
属性的color
值来改变<li>
的颜色。我采用这种快捷方式是为了让示例尽可能简单,但是您应该在代码中尽量避免这种类型的样式赋值。正确的方法包括为每个元素删除或添加 CSS 类,并在样式表中为这些特定的类定义适当的样式/颜色。有关如何在没有 jQuery 的情况下正确操作元素 CSS 类的更多信息,请参见第五章。
尽管存在一个很大的隐藏问题,但上述代码可以在所有可用的浏览器中运行,包括 Internet Explorer 6。它演示了一个循环引用,涉及一个 DOM 对象(<li>
元素的 JavaScript 表示)和一个“普通”JavaScript 对象(carTypeEls
数组)。每个<li>
引用carTypeEls
数组,而数组又引用<li>
元素。这是一个很好的例子,说明了 Internet Explorer 6 和 7 中存在的大量内存泄漏。这种泄漏非常严重,以至于即使在页面刷新之后,内存也可能无人认领。幸运的是,微软在 Internet Explorer 8 中修复了这个问题,但这展示了与 HTML 元素一起存储数据的一些早期挑战。
管理数据
对于少量的数据,您可以利用data-
属性或其他定制属性。但是如果需要存储大量数据呢?您也许可以将数据附加到元素的自定义属性上。这被称为 expando 属性。这在前面的例子中有所说明。为了避免潜在的内存泄漏,您可以选择将数据与关联元素的选择器字符串一起存储在 JavaScript 对象中。这确保了对元素的引用是“弱”的。不幸的是,这两种方法都不是特别直观,您会觉得要么是在重新发明轮子,要么是在编写蹩脚的脆弱代码。肯定有更容易的路线。
话说回来,什么是“微不足道”的数据量?什么时候属性变成了不太可行的存储和检索机制?对于以前没有遇到过这个问题的开发人员来说,大量的方法可能有点让人不知所措。可以简单地对所有实例使用 expando 属性吗?与其他方法相比,一种方法的缺点和优点是什么?不要担心,在本章的最后两节中,您不仅会了解在存储元素数据时如何以及何时使用特定的方法,而且还会学习如何轻松有效地做到这一点。
使用适用于所有浏览器的解决方案
尽管在新规范中有一些非常好的方法来读取和跟踪元素数据,比如 ECMAScript 2015 和 HTML5,但我意识到浏览器对其中一些 API 的支持还不够全面。在相对较短的时间内,这些新工具将在绝大多数正在使用的浏览器中实现。在此之前,您应该了解如何使用最常用的 API 来完成这些相同的任务。在某些情况下,本节中描述的方法可能会经受住时间的考验,即使 web 标准在继续发展,它们仍然是最合适和最简单的。
使用数据属性存储少量数据
首先出现在 W3C HTML5 规范中的数据属性, 2 是现有标准的一个例子,它足够简单,可以在所有当前的浏览器中使用。它的简单性和灵活性使得它可以在未来的 web 规范中得到利用。事实上,由于 HTML5 规范中定义了一个相对较新的Element
接口属性,数据属性已经变得更加强大了。
HTML5 规范将data-
属性声明为自定义属性。这两者是一体的。唯一有效的定制属性是一个data-
属性。该规范对data-
属性描述如下:
- 自定义数据属性是名称空间中的属性,其名称以字符串“data-”开头,连字符后至少有一个字符。。。。
该规范还赋予了data-
属性一个特定的用途。它们“旨在存储页面或应用专用的自定义数据,对于这些数据没有更合适的属性或元素。”因此,如果您需要描述锚链接的标题,请使用title
属性。 3 如果您必须为一个段落定义一种不同于在<html>
元素上为文档其余部分定义的语言,您应该使用lang
属性。 4
但是如果您需要为一个<img>
存储一个替代 URL,当图像通过键盘获得焦点或者当用户用一个定点设备(比如鼠标)将鼠标悬停在图像上时,就会使用这个 URL。在这种情况下,没有标准属性来存储这些信息。因此,我们必须利用自定义数据属性:
1 <img src="default.png"
2 data-zoom-url="default-zoomed.png"
3 alt="default image">
聚焦/悬停时显示的图像存储在data-zoom-url
属性中。如果我们想要用场景变化的偏移来注释一个<video>
,我们可以遵循相同的方法:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
根据我们绑定到元素的data-scene- offsets
自定义属性,前面的视频在 9 秒、22 秒和 38 秒标记处改变场景。
定义一个不符合 HTML5 规范中定义的data-
约定的定制元素不会产生严重的后果。浏览器不会抱怨或者无法呈现你的文档。但是您将失去利用基于这个约定的 API 的任何未来部分的能力,包括dataset
属性。稍后将详细介绍该特定属性。
用 jQuery 读取和更新数据属性
现在我们有了一种通过标记用一些数据来注释元素的方法,那么我们如何在代码中读取这些数据呢?如果您熟悉 jQuery,您可能已经知道了data()
API 方法。以防细节有点模糊,看看下面的例子:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
2
3 <script>
4 // offsets value will be "9,22,38"
5 var offsets = $('VIDEO').data('sceneOffsets');
6 </script>
注意,我们必须通过引用属性名的唯一部分来访问data-
属性的值,作为一个驼峰式字符串。更改数据属性的值非常类似:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
2
3 <script>
4 // Does NOT update the attribute. Updates jQuery
5 // internal data store instead.
6 $('VIDEO').data('sceneOffsets', '1,2,3');
7 </script>
请注意,jQuery 的data()
方法有些奇特和出乎意料的地方。当试图通过这个方法更新data-
属性时,似乎什么也没发生。也就是说,data-scene-offsets
属性值在文档中保持不变。相反,jQuery 将这个值和所有后续值存储在一个 JavaScript 数据存储中。这种实现有几个缺点:
- 我们的标记现在与元素的数据不同步。
- 我们对元素数据所做的任何更改只有 jQuery 可以访问。
虽然这种实现有一些很好的理由,但在这种情况下似乎很不幸。
使用 Web API 读取和更新数据属性
稍后,我将描述一种使用 JavaScript 读取和更新data-
属性的更现代的方法,它与 jQuery 的data()
方法一样优雅,但是没有缺点。同时,让我们探索一种适用于任何浏览器的解决方案:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
2
3 <script>
4 // offsets value will be "9,22,38"
5 var offsets = document.getElementsByTagName('VIDEO')[0]
6 .getAttribute('data-scene-offsets');
7 </script>
我们已经在前一章的“读取属性”部分看到过这一点。当然,data-
属性只是一个元素属性,所以我们可以在任何使用getAttribute()
的浏览器中轻松读取它。
正如您所料,在没有 jQuery 的情况下更新data-
属性会使用setAttribute()
方法,这要归功于 web API 的Element
接口:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
2
3 <script>
4 // updates the element's data attribute value to "1,2,3"
5 document.getElementsByTagName('VIDEO')[0]
6 .setAttribute('data-scene-offsets', '1,2,3');
7 </script>
在这种情况下,这种原始而有效的方法比 jQuery 的data()
方法有两个好处:
- 我们的标记总是与元素的数据同步。
- 任何 JavaScript 都可以访问我们对元素数据所做的任何更改。
因此,在这种情况下,本地解决方案可能是更好的路线。
复杂元素数据存储和检索
简单元素数据由一个短字符串组成,如短语、单词或字符或数字的短序列。也许甚至一个小的 JSON 对象或数组也可以被认为是简单的。但是复杂的数据呢?复杂数据到底是什么?
还记得本章前面内存泄漏部分的汽车列表吗?我演示了一种链接单个列表项元素的方法,这样我们可以很容易地突出显示被单击的项,同时使列表中的其他项不那么突出。我们将 HTML 元素的 JavaScript 表示与其他元素相关联。这当然可以被认为是“复杂”的数据。
如果我们用<video>
标签扩展前面的例子,可以演示复杂元素数据的另一个例子。除了场景偏移,我们还需要记录每个场景的简短描述,以及标题和位置。我们在这里描述的是需要一个适当的 JavaScript 对象,而不是存储为属性值的单个文本字符串。
我在内存泄漏一节中提出的解决方案涉及 expando 属性的使用,这在一定程度上导致了旧浏览器中的内存泄漏。尽管这个漏洞已经在所有现代浏览器中得到修补,但是不鼓励 expando 属性,以任何非标准方式修改元素的 JavaScript 表示也是如此。我之前详述的视频数据场景数据太多,无法存储在一个data-
属性中。当然,我们也不应该求助于 expando 属性。因此,将这些类型的复杂数据与元素相关联的正确方法是维护一个 JavaScript 对象,该对象通过一个data-
属性链接到一个或多个元素。这是 jQuery 采用的方法,没有 jQuery 我们也可以很容易地做到。
熟悉的 jQuery 方法
正如您可能已经猜到的,jQuery 解决方案涉及到了data()
方法:
1 $('VIDEO').data('scenes', [
2 {
3 offset: 9,
4 title: 'intro',
5 description: 'introducing the characters',
6 location: 'living room'
7 },
8 {
9 offset: 22,
10 title: 'the problem',
11 description: 'characters have some issues',
12 location: 'the park'
13 },
14 {
15 offset: 38,
16 title: 'the resolution',
17 description: 'characters resolve their issues',
18 location: 'the cemetery'
19 }
20 ]);
现在,如果我们想查找第二个场景的标题:
1 // variable will have a value of 'the problem'
2 var sceneTwoTitle = $('VIDEO').data('scenes')[1].title;
jQuery 在内部缓存对象中维护我们提供的数组。每个缓存对象都有一个“索引”,这个索引存储为 jQuery 添加到HTMLVideoElement
对象的 expando 属性的值,该对象是一个<video>
标记的 JavaScript 表示。
使用更自然的方法
在决定如何将复杂数据绑定到本节中的元素时,我们必须意识到我们的三个目标:
- 不是 jQuery。
- 必须适用于所有浏览器。
- 没有 expando 属性。
我们可以通过模仿 jQuery 存储元素数据的方法来实现前两个目标。为了尊重第三点,我们必须对 jQuery 的方法进行一些调整。换句话说,我们必须通过一个简单的data-
属性而不是 expando 属性将我们的元素绑定到底层 JavaScript 对象:
1 var cache = [],
2 setData = function(el, key, data) {
3 var cacheIdx = el.getAttribute('data-cache-idx'),
4 cacheEntry = cache[cacheIdx] || {};
5
6 cacheEntry[key] = data;
7 if (cacheIdx == null) {
8 cacheIdx = cache.push(cacheEntry) - 1;
9 el.setAttribute('data-cache-idx', cacheIdx);
10 }
11 };
12
13 setData(document.getElementsByTagName('VIDEO')[0],
14 'scenes', [
15 {
16 offset: 9,
17 title: 'intro',
18 description: 'introducing the characters',
19 location: 'living room'
20 },
21 {
22 offset: 22,
23 title: 'the problem',
24 description: 'characters have some issues',
25 location: 'the park'
26 },
27 {
28 offset: 38,
29 title: 'the resolution',
30 description: 'characters resolve their issues',
31 location: 'the cemetery'
32 }
33 ]);
这是怎么回事?首先,我创建了一个方便的方法(setData
函数)来处理数据与特定元素的关联,并创建了一个数组(cache
)来保存所有元素的数据。已经设置了setData
函数来接受元素、数据键和数据对象,而cache
数组为每个元素保存一个 JavaScript 对象,数据附加到(可能)多个键属性。
当处理一个调用时,我们首先检查元素是否已经绑定到我们的cache
中的数据。如果是,我们使用存储在元素的data-cache-idx
属性中的数组索引在cache
中查找现有的数据对象,然后向该对象添加一个包含传递数据的新属性。否则,我们将创建一个新的对象,该对象被初始化为包含传递的数据和传递的键。如果这个元素在cache
中还没有条目,那么还必须创建一个data-cache-idx
属性,其索引为cache
中的新对象。
与 jQuery 解决方案一样,我们希望查找第二个场景的标题,只需多一点代码就可以完成:
1 var cacheIdx = document.getElementsByTagName('VIDEO')[0]
2 .getAttribute('data-cache-idx');
3
4 // variable will have a value of 'the problem'
5 var sceneTwoTitle = cache[cacheIdx].scenes[1].title;
我们可以很容易地为我们的setData()
创建一个getData()
函数,使存储和查找元素数据更加直观。但是这个全浏览器非 jQuery 解决方案出奇的简单。对于一个更优雅的面向更现代浏览器的非 jQuery 方法,请查看下一节,在那里我将演示dataset
元素属性和WeakMap
API。
当从 DOM 中移除元素时,从缓存中移除数据
我刚才演示的方法的一个潜在问题是缓存将无限增长。当相应的元素从 DOM 中移除时,从缓存中移除项目将是有用的。理想情况下,我们可以简单地“监听”DOM 元素移除“事件”,并相应地从缓存中撤销元素。幸运的是,这在大多数现代浏览器中都是可以实现的,这要归功于MutationObserver
,它是 WHATWG 维护的一个 web 标准,是其 DOM 规范的一部分。5ie 9 和 10 都是钉子户,但是 polyfill 填补了 6 这两个缺口。在MutationObserver
之前,仍然有通过“突变事件”观察 DOM 变化的能力,但是这些被证明是非常低效的,并且不再是任何活动规范的一部分。我刚才提到的聚合填充可以追溯到 IE10 和 ie9 中的突变事件。
突变观察器允许在检测到任何 DOM 元素(或其子元素或后代元素)的任何变化时执行回调函数。这正是我们正在寻找的。更具体地说,当附加到缓存项的 DOM 元素被删除时,我们希望得到通知,以便清理缓存。以缓存示例中的<video>
元素为例。请记住,我们在缓存对象中存储了一些关于视频中出现的各种场景的数据。当<video>
被删除时,缓存条目也应该被删除,以防止我们的缓存不必要地增长。使用突变观察器,我们的代码可能看起来像这样:
1 var videoEl = document.querySelector('video'),
2 observer = new MutationObserver(function(mutations) {
3 var wasVideoRemoved = mutations.some(function(mutation) {
4 return mutation.removedNodes.some(function(removedNode) {
5 return removedNode === videoEl;
6 });
7 });
8
9 if (wasVideoRemoved) {
10 var cacheIdx = videoEl.getAttribute('data-cache-idx');
11 cache.splice(cacheIdx, 1);
12 observer.disconnect();
13 }
14 });
15
16 observer.observe(videoEl.parentNode, {childList: true});
在那里,我们的视频的父元素的子元素的所有变化都被观察到。如果我们直接观察视频元素,当它被删除时,我们不会被通知。传递给我们的观察者的childList
配置选项确保了每当我们的视频或它的任何兄弟被改变时,我们得到通知。当我们的回调函数被调用时,如果我们的视频元素被删除,我们将删除缓存中相应的条目,然后断开我们的突变观察器,因为我们不再需要它。更多关于MutationObserver
、、??、、?? 的信息,请看 Mozilla 开发者网络。
元素数据的未来
在没有 jQuery 的情况下,在所有浏览器中存储琐碎或复杂的数据并不特别困难,但也不是很优雅。对我们来说幸运的是,web 发展很快,两个新的 API 的存在应该会使我们的代码更漂亮,甚至可能更有性能。我将向您展示如何使用 HTML5 dataset
属性管理简单的元素数据,以及如何使用 ECMAScript 2015 集合管理复杂的数据。请记住,本节中的所有内容仅适用于最新的浏览器。在这两种情况下,都不能选择比 Internet Explorer 11 更早的版本。在很短的时间内,随着“现代浏览器”定义的演变以及 Internet Explorer 9 和 10 的退出,所有常见的浏览器都将得到支持。
HTML5 数据集属性
HTML5 规范在 2014 年 10 月成为推荐标准,它在HTMLElement
接口上定义了一个新的属性:dataset
。 8 把这个新属性想象成任何元素对象上都可用的 JavaScript 对象。事实上,它是一个对象,更确切地说是一个DOMStringMap
对象, 9 也在 HTML5 规范中定义。添加到dataset
对象中的任何属性都被反映为文档中元素标签上的data-
属性。您还可以通过检查元素的dataset
对象上的相应属性来读取元素标签上定义的任何data-
属性。在这方面,HTMLElement.dataset
提供了所有你喜欢的关于 jQuery 的data()
方法的行为。这是一种向元素读写数据的直观方式,没有缺点。因为对dataset
对象属性的更改总是与元素的标记同步,反之亦然,这个新的标准属性是处理琐碎元素数据的完美方式。
Element.dataset
目前在“现代”浏览器的子集上可用——不支持 Internet Explorer 9 和 10,但可以使用聚合填充,如 https://www.npmjs.com/package/dataset
。查看下面的代码示例时,请记住这一点。对于我们的第一个演示,让我们重写在前面关于使用 web API 读取和更新data-
属性的部分中显示的第一个代码块:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
2
3 <script>
4 // offsets value will be "9,22,38"
5 var offsets = document.querySelector('VIDEO').dataset.sceneOffsets;
6 </script>
在这里,我们将前面的例子简化了很多。注意我们必须如何使用骆驼格形式的data-
属性。可以说,dataset
模型比 jQuery 的data()
方法更直观。我们将所有数据视为对象的属性,这正是 jQuery 在内部表示这些数据的方式。但是当使用 jQuery 的 API 时,我们应该调用函数,将键作为字符串参数传递。
看看第二个代码示例的更现代版本,它演示了如何更改元素或向元素添加数据:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
2
3 <script>
4 // updates the element's data attribute value to "1,2,3"
5 document.querySelector('VIDEO').dataset.sceneOffsets = '1,2,3';
6 </script>
元素数据和相关的data-
属性已经更新,所有这些都是用一行简单而优雅的代码完成的。但是我们可以做得更多!因为dataset
是一个 JavaScript 对象,我们可以很容易地从我们的元素中移除数据,就像我们从任何其他 JavaScript 对象中移除属性一样:
1 <video src="my-video.mp4" data-scene-offsets="9,22,38">
2
3 <script>
4 // removes the element's data-scene-offsets attribute
5 delete document.querySelector('VIDEO').dataset.sceneOffsets;
6 </script>
您现在可以看到dataset
实际上是如何超越 jQuery 的data()
方法的便利性的。
利用 ECMAScript 2015 WeakMap 系列
您已经知道如何利用最新的 web 技术将琐碎的数据连接到元素。但是复杂的数据呢?我们可以利用前面的例子,但是也许最新最棒的 web 规范会给我们带来更优雅的解决方案,也许是更直观的解决这类问题的完美方法。
ECMAScript 2015 带来了一个名为 a WeakMap
的新系列。 10 一个WeakMap
可以包含对象的键和任何值——元素、对象、原语等等。在这个新的集合中,键被“弱”持有。这意味着如果没有其他对象引用它们,它们就有资格被浏览器进行垃圾回收。这允许我们安全地使用引用元素作为键!
虽然 WeakMap 仅在最新和最棒的浏览器(Internet Explorer 11+,Chrome 36+,Safari 7.1+)以及 Firefox 6+中受支持,但它提供了一种异常简单的方法来将 HTML 元素与数据相关联。还记得前面演示的全浏览器代码示例吗?让我们使用WeakMap
开始重写它们:
1 var cache = new WeakMap();
2 cache.set(document.querySelector('VIDEO'), {scenes: [
3 {
4 offset: 9,
5 title: 'intro',
6 description: 'introducing the characters',
7 location: 'living room'
8 },
9 {
10 offset: 22,
11 title: 'the problem',
12 description: 'characters have some issues',
13 location: 'the park'
14 },
15 {
16 offset: 38,
17 title: 'the resolution',
18 description: 'characters resolve their issues',
19 location: 'the cemetery'
20 }
21 ]});
多亏了WeakMap
,我们已经成功地消除了早期非 jQuery 示例中的所有样板文件。这种方法的优雅程度相当于 jQuery 的data()
方法,我之前也演示过。查找数据同样简单:
1 // variable will have a value of 'the problem'
2 var sceneTwoTitle = cache.get(document.querySelector('VIDEO')).scenes[1].title;
最后,我们可以通过一个简单的 API 调用来删除我们不想再跟踪的元素,从而进行自我清理:
1 cache.delete(document.querySelector('VIDEO'));
一旦从 DOM 中删除了元素,假设没有其他对该元素的引用,视频元素就应该有资格被浏览器进行垃圾收集。由于 WeakMap 持有的视频元素引用很弱,这本身并不能防止垃圾收集。因为一旦视频元素不再存在于 DOM 中,它就会自动从 WeakMap 中删除,所以我们甚至不需要显式删除这个条目。
没有 jQuery 的 web 看起来非常强大。
Footnotes 1
https://msdn.microsoft.com/en-us/library/dd361842(VS.85).aspx
2
www.w3.org/TR/html5/dom.html#embedding-custom-non-visible-data-with-the-data-%2A-attributes
3
www.w3.org/TR/html4/struct/global.html#edef-TITLE
4
www.w3.org/TR/html4/struct/dirlang.html#adef-lang
5
https://dom.spec.whatwg.org/#mutation-observers
6
https://github.com/webcomponents/webcomponentsjs/blob/v0.7.20/MutationObserver.js
7
https://developer.mozilla.org/en/docs/Web/API/MutationObserver
8
www.w3.org/TR/html5/dom.html#dom-dataset
9
www.w3.org/TR/html5/infrastructure.html#domstringmap-0
10
www.ecma-international.org/ecma-262/6.0/#sec-weakmap-objects
七、样式化元素
如果你习惯于使用 jQuery 的css()
方法来处理文档中的样式,这一章就是为你准备的。我当然可以理解对 API 这一神奇方面的盲目依赖。在 jQuery 的帮助下,调整尺寸、颜色、不透明度和任何其他可以想象的样式都非常容易。不幸的是,这种简单性有时要付出巨大的代价。
jQuery 支持其易于使用的 CSS API 的内部代码有一些值得注意的性能问题。如果您重视效率,并且希望为用户提供最佳体验,那么您应该学习如何使用 web API 正确地操作和读取元素样式。不要依赖“一刀切”的方法,您应该通过绕过 jQuery 的抽象来选择最精简的方法。
您可以继续依赖 jQuery,或者完全放弃它,采用更“自然”的编程方法。但是除了使用哪些 JavaScript 方法和属性之外,还有一些概念需要注意。考虑到 JavaScript 并不总是在文档中定义样式的最佳方式的可能性。除了 HTML 和 JavaScript,浏览器还提供了第三个有价值的工具:样式表。
这本书旨在让你更好地理解浏览器本身提供的选项,每一章都基于你的新知识。在这一章中,你将会学到一些关于使用元素样式的新东西,不管有没有 JavaScript。从这一章你将学到足够的东西来理解什么时候在样式表中使用 CSS 规则来定位元素,而不是每次都求助于 JavaScript。得益于前几章,您对选择器和属性的丰富知识将使这变得更加容易。
有三种方法可以设置元素的样式
在我深入研究与实际调整和从文档中的元素读取样式信息相关的示例和细节之前,首先弄清楚几个关键概念是很重要的。在这一章中,我向你展示了三种不同的处理元素样式的方法。第一种包括直接在标记中管理样式——这是不推荐的,但是可能的。另一种方法是对Element
对象的标准化属性进行修改——如果您打算按需读取或更新样式,可以选择这种方法。最后,我将在样式表中编写 CSS 规则作为第三种选择。
内嵌样式
几章前,我向您介绍了class
属性。尽管该属性通常用于设计元素的样式,但它也用于对元素进行选择和分类。本节介绍了style
属性,它专门用于调整元素的外观。这个属性不是新的;它于 1996 年作为第一个正式的 W3C CSS 规范的一部分首次引入。 1
假设您有一个非常简单的文档,只有几个标题元素和相关内容。你已经决定每个<h2>
应该是蓝色的,每个<h3>
应该是绿色的。作为一名 web 开发新手,或者对样式选项了解不多的开发人员,您可以选择使用style
属性来设置这些标题的颜色,该属性在所有元素上都可用,如清单 7-1 所示。
1 <h1>Fake News</h1>
2 <div>Welcome to fakenews.com. All of the news that's unfit to print.</div>
3
4 <h2 style="color: blue">World</h2>
5
6 <h3 style="color: green">Valdimir Putin takes up knitting</h3>
7 <div>The infamous leader of Russia appears to be mellowing with age as he reportedly joined a local knitting group in Moscow.</div>
8
9 <h2 style="color: blue">Science</h2>
10
11 <h3 style="color: green">Sun goes on vacation, moon fills in</h3>
12 <div>Fed up after over 4 billion years without a day off, the sun headed off to the Andromeda galaxy for a few weeks of rest and relaxation.</div>
Listing 7-1.Setting Styles Using the Style Attribute
在这个例子中,你可以看到标题是如何按照你的要求着色的。您可以在单个元素上放置多种样式,只需用分号分隔这些样式。例如,假设我们不仅要将每个<h2>
涂成蓝色,还要通过加粗来确保它们更加突出:
1 <h2 style="color: blue; font-weight: bold">World</h2>
2
3 ...
4
5 <h2 style="color: blue; font-weight: bold">Science</h2>
任何标准化的样式 2 都可以应用于任何元素,只需使用前面代码片段中所示的style
属性。但是还有其他方法来设计你的元素,你很快就会知道,为什么有人会选择这种特殊的方法呢?首先,直接在标记中指定元素的样式似乎是一种直观而合理的方法。但是使用style
属性很可能是出于懒惰或天真。很明显,用这种方式为元素指定样式是多么容易。
使用style
属性对文档进行样式化,也称为内联样式化,是您几乎应该避免的事情。尽管这种做法简单直观,但有很多原因会让你感到悲伤。首先,内联样式给你的标记增加了相当多的噪音。除了内容、标签和其他属性,现在您有了style
属性——可能是许多元素的属性。这些属性很可能包含许多分号分隔的样式。随着您的文档开始增长并变得更加复杂,这种干扰会变得更加明显。
除了弄乱文档之外,直接在标记中的每个元素上定义样式还会妨碍您轻松地重新设置页面的外观。假设一个设计者看了一下前面的代码,告诉您“绿色”和“蓝色”的颜色值有点太“普通”,应该替换为稍微不同的颜色。然后,设计人员为您提供新颜色的十六进制代码,这种调整需要更改文档中所有<h2>
和<h3>
元素的style
属性。这是不遵循软件开发的“不要重复自己”原则 3 的常见后果。过度使用style
属性会导致维护噩梦。
通过style
属性在文档中定义样式也是一个潜在的安全风险。如果您打算实现一个内容安全策略,在最基本(也是最安全的)策略定义中,严格禁止使用属性来设计元素的样式。强大的内容安全策略,也称为 CSP,现在变得越来越普遍,因为所有现代浏览器(除了 IE9)都至少支持该规范的初始版本。 5
最后,在页面中加入style
属性或者<style>
元素,可以包含不同的 CSS 规则集,会导致更多的开销。如果需要更改某个样式,那么下次用户加载页面时,浏览器必须重新获取整个文档。如果您的样式是在更具体的位置定义的,在您的标记之外,可以引入样式更改,同时仍然允许从浏览器的缓存中获取页面的一部分,避免不必要的服务器往返。
我强烈建议避免使用style
属性。还有其他更合适的选择。最初看得见的好处被困难所掩盖,这些困难将在今后成为残酷的现实。
直接在元素对象上使用样式
元素的对象表示上的style
属性最初是在 2000 年作为 DOM Level 2 的一部分引入的。 6 它被定义为一个新的ElementCSSInlineStyle
接口的唯一属性。Element
接口实现了ElementCSSInlineStyle
,它允许使用 JavaScript 以编程方式设计元素。所有 CSS 属性,比如opacity
和color
,都可以作为相关联的CSSStyleDeclaration
7 实例上的属性来访问,在那里它们可以被读取或更新。
如果对样式属性的所有讨论都不清楚,那么再看一下上一节的代码示例。清单 7-2 利用所有Element
对象上可用的style
属性重写了它。
1 <h1>Fake News</h1>
2 <div>Welcome to fakenews.com. All of the news that's unfit to print.</div>
3
4 <h2>World</h2>
5
6 <h3>Valdimir Putin takes up knitting</h3>
7 <div>The infamous leader of Russia appears to be mellowing with age as he report edly joined a local knitting group in Moscow.</div>
8
9 <h2>Science</h2>
10
11 <h3>Sun goes on vacation, moon fills in</h3>
12 <div>Fed up after over 4 billion years without a day off, the sun headed off to the Andromeda galaxy for a few weeks of rest and relaxation.</div>
13
14 <script>
15 var headings = document.querySelectorAll('h2, h3');
16
17 for (var i = 0; i < headings.length; i++) {
18 if (headings[i].tagName === 'H2') {
19 headings[i].style.color = 'blue';
20 }
21 else {
22 headings[i].style.color = 'green';
23 }
24 }
25 </script>
Listing 7-2.Setting Styles Using the style Property
: All Modern Browsers and Internet Explorer 8
这似乎有点笨拙,但它说明了如何使用 web API 以编程方式更新样式。
在上一节中,我扩展了初始代码片段,以说明如何在单个元素上定义多种样式。让我们看看如何使用style
属性来实现这一点:
1 <h2>World</h2>
2
3 ...
4
5 <h2>Science</h2>
6
7 <script>
8 var headings = document.querySelectorAll('h2');
9
10 for (var i = 0; i < headings.length; i++) {
11 headings[i].style.color = 'blue';
12 headings[i].style.fontWeight = 'bold';
13 }
14 </script>
请注意,font-weight
CSS 样式名称已经转换为 camel case,这是完全合法的,但是我们仍然可以使用虚线名称来更改这种样式,如果我们真的想这样做的话:headings[i].style['font-weight'] = 'bold'
。
我们还没有完成。还有一种方法可以使用style
属性在一个 HTML 元素上设置多种样式。CSSStyleDeclaration
接口定义了一个特殊的属性:cssText
。这允许您对关联的元素读写多种样式。值字符串看起来就像一组用分号分隔的 CSS 规则,如清单 7-3 所示。
1 <h2>World</h2>
2
3 ...
4
5 <h2>Science</h2>
6
7 <script>
8 var headings = document.querySelectorAll('h2');
9
10 for (var i = 0; i < headings.length; i++) {
11 headings[i].style.cssText = 'color: blue; font-weight: bold';
12 }
13 </script>
Listing 7-3.Setting Multiple Styles Using the
style.cssText Property
: All Modern Browsers and Internet Explorer 8
为什么您可能想要在一个元素(或多个元素)上使用style
属性?也许您正在编写一个 JavaScript 库,需要根据环境或用户输入对一些元素进行一些快速调整。为这些样式创建和依赖特定于库的样式表可能不方便。此外,使用此方法设置的样式通常会覆盖先前在元素上设置的任何其他样式,这可能是您的意图。
但是要小心过度使用这种力量。以这种方式设置的样式很难通过样式表规则覆盖。这可能是你的意图,但也可能不是。如果不是这样,并且您希望允许样式表轻松地对样式进行调整,那么您可能希望避免使用style
属性(或内联样式)来更改样式。最后,使用style
属性会使跟踪样式变化变得非常困难,并且会弄乱您的 JavaScript。您的代码专注于设置特定的元素样式似乎不太自然。这应该是一种罕见的做法。正如您将在下一节看到的,这项工作更适合样式表。
样式表
JavaScript 并不是解决浏览器样式问题的唯一方法。这甚至可能不是改变元素外观的最佳方式。浏览器提供了一种专门的机制来设计文档的样式:样式表。通过这个媒介,你可以在专用文件中为你的 web 文档定义所有的 CSS 样式,封装在一个特定的 HTML 元素中,或者甚至通过 JavaScript 按需将它们添加到文档中。在本节中,我将演示这三种处理样式的方法。
首先在 W3C CSS 1 规范中定义的<style>
元素, 8 让我们将整个文档的所有样式分组到一个方便的位置。清单 7-4 是对之前代码片段的重写,这一次添加了来自HTMLStyleElement
的样式。
1 <style>
2 h2 { color: blue; }
3 h3 { color: green; }
4 </style>
5
6 <h1>Fake News</h1>
7 <div>Welcome to fakenews.com. All of the news that's unfit to print.</div>
8
9 <h2>World</h2>
10
11 <h3>Valdimir Putin takes up knitting</h3>
12 <div>The infamous leader of Russia appears to be mellowing with age as he report edly joined a local knitting group in Moscow.<div>
13
14 <h2>Science</h2>
15
16 <h3>Sun goes on vacation, moon fills in</h3>
17 <div>Fed up after over 4 billion years without a day off, the sun headed off to the Andromeda galaxy for a few weeks of rest and relaxation.<div>
Listing 7-4.Setting Styles
Using the <style> Element: All Browsers
如您所见,前一节中用于样式化这些元素的所有 JavaScript 代码完全被两行 CSS 所取代。这不仅是一个更有效的解决方案,而且更加优雅和简单。如果我们想要添加额外的样式,我们可以很容易地将它们包含在现有的样式中,用分号分隔:
1 <style>
2 h2 {
3 color: blue;
4 font-weight: bold;
5 }
6 h3 {
7 color: green;
8 font-weight: bold;
9 }
10 </style>
11 ...
前面的样式甚至可以使用多重选择器的功能进行一点改进,您在前面已经了解过了:
1 <style>
2 h2, h3 { font-weight: bold; }
3 h2 { color: blue; }
4 h3 { color: green; }
5 </style>
6 ...
将样式塞进一个<style>
元素对于一小组规则来说可能没问题,但是对于一个复杂的文档来说可能不太理想。也许您甚至希望在文档/页面之间共享样式。在每个 HTML 文档中复制这些样式似乎不是一种可伸缩的方法。幸运的是,有一种更好的方法——样式表——如清单 7-5 和 7-6 所示。
1 h2 { color: blue; }
2 h3 { color: green; }
Listing 7-5.styles.css External
Style Sheet: All Browsers
1 <link href="styles.css" rel="style sheet">
2
3 <h1>Fake News</h1>
4 <div>Welcome to fakenews.com. All of the news that's unfit to print.</div>
5
6 <h2>World</h2>
7 ...
Listing 7-6.
index.html Setting Styles
Using an External CSS Style Sheet File: All Browsers
我们在这里定义了两个文件:styles.css 和 index.html。第一个存放我们的样式表,第二个包含我们的标记。在我们的索引文件中,我们可以简单地通过<link>
元素引用 styles.css 文件来引入所有这些样式,这可以在 HTML 2.0 规范中看到。 9 这对于你们很多人来说可能不是什么新知识,但是当你习惯于使用 jQuery 这样的工具时,很容易忽略全局,jQuery 有时似乎是所有浏览器问题的解决方案。
完全依赖任何形式的 JavaScript(包括通过 jQuery 的 API)来设计您的标记是不合适的。级联样式表就是为此而存在的。但这并不意味着永远不会出现直接通过 JavaScript 动态改变样式的情况。也许您已经构建了一个 web 应用,允许您的用户创建他们自己的自定义登录页面。你的用户需要用斜体显示所有的副标题。要轻松做到这一点,您可以使用CSSStyleSheet
接口上的insertRule
方法以编程方式将 CSS 规则添加到文档中:
1 // This grabs the first loaded style sheet on the current page.
2 // This also assumes the first style sheet is appropriate here.
3 var sheet = document.style Sheets[0]
4
5 sheet.insertRule(
6 'h2 { font-style: italic; }', sheet.cssRules.length - 1
7 )
前面的例子将创建一个新的样式,用斜体显示所有的<h2>
元素。该规则将被追加到样式表的末尾。style sheet
变量可以引用我们为这些动态样式按需创建的<style>
元素,或者甚至是使用<link>
标签导入的现有样式表。如果你需要支持 Internet Explorer 8,你必须使用addRule
,如果它是在浏览器的 DOM API 实现中定义的。
与只使用 JavaScript 的解决方案相比,使用样式表几乎总是更好的方法。即便如此,采取整体方法,根据情况将 JavaScript、HTML 和样式表合并到您的解决方案中,通常也是可以接受的。
既然您对可能性有了更完整的理解,那么您就能更好地在自己的项目中做出正确的决策。本章的其余部分将致力于更具体的样式化情况。按照 Beyond jQuery 的惯例,我使用熟悉的 jQuery 方法作为参考,后面是丰富的 web API 示例,作为备选方案讨论的一部分。
获取和设置通用样式
在描述和演示了向 HTML 元素添加样式的几种不同方法之后,现在是时候更深入地研究 CSS 了。如果您熟悉 jQuery(如果您正在阅读这本书,您可能已经熟悉了),那么您已经知道在使用 jQuery 时,通常有一条调整文档外观的捷径。我将提供一个演示,以供参考。但是浏览器栈提供的原生路由要丰富得多。在这一节中,您将看到如何在没有 jQuery 帮助的情况下正确地获取样式并动态地设置它们。
要设置下面的 jQuery 和非 jQuery 演示,让我们从一个简单的 HTML 片段开始:
1 <button>cookies</button>
2 <button>ice cream</button>
3 <button>candy</button>
假设您想在按钮被单击(或通过键盘选择)后对其进行稍微不同的样式化。被点击的按钮应该以某种方式来表示它已经被选中。我还没有介绍事件处理程序(尽管我将在后面的章节中介绍),所以只要假设已经存在一个函数,并且每当按钮被选中时,相关的按钮元素都作为参数传递进来。您的工作是通过将所选按钮的背景和边框颜色更改为蓝色,并将按钮文本更改为白色来填充该函数的实现。
为了演示阅读样式(并进一步演示如何设置它们),考虑一个已经被样式化为 box 的元素。每当这个框被点击,它变得稍微不透明,直到它完全消失。同样,假设每次单击盒子时都会向您传递一个函数。你的工作是每当这个函数被调用时,增加 10%的不透明度。在本节中,我将向您介绍这两种解决方案,从(可能)熟悉的 jQuery 方法开始。
使用 jQuery
jQuery 是一个非常受欢迎的 JavaScript 库,被太多的开发人员所依赖,它有责任(依我拙见)教这些开发人员调整元素样式的正确方法。不幸的是,它没有这样做。甚至 jQuery 学习中心关于样式化的文章 10 也只是简单地提到了如何正确地样式化元素,根本没有任何关于这种技术的真实演示。原因很简单:惯用的 jQuery 经常与最佳实践不一致。这个事实是这本书的几个灵感之一。让我们看看大多数关注 jQuery 的开发人员是如何解决这个问题的:
1 function onSelected($selectedButton) {
2 $selectedButton.css({
3 color: 'white',
4 backgroundColor: 'blue',
5 borderColor: 'blue'
6 });
7 }
当向元素写入样式时,css
方法充当了HTMLElement
接口上的style
属性的包装器。毫无疑问,这很优雅,但这真的是正确的方法吗?答案当然是“不”。我之前讨论过这个问题。当然,前面描述的方法并不是使用 jQuery 解决这个问题的唯一方法,但它是 jQuery 开发人员中最常见的方法。
现在,让我们看看如何使用 jQuery 解决第二个问题:
1 function onClicked($clickedBox) {
2 var currentOpacity = $clickedBox.css('opacity');
3
4 if (currentOpacity > 0) {
5 $clickedBox.css('opacity', currentOpacity - 0.1);
6 }
7 }
可惜 jQuery 的css
API 方法效率相当低。每次调用这个方法来查找一个样式都需要 jQuery 利用window
对象上的getComputedStyle()
方法,这在第一次调用之后是完全没有必要的,并且给这个解决方案增加了大量的处理开销。
不使用 jQuery
解决第一个问题的正确方法是将 CSS 规则包含在外部样式表中,并使用最少的 JavaScript 触发这些规则。请记住,我们正在寻找一个按钮的风格,当它被选中/按下时,突出。当按钮被按下时,我们可以期望调用一个函数,并将元素作为参数传递。
让我们从在样式表中为被按下的按钮定义样式开始,如清单 7-7 所示。
1 button.selected {
2 color: white;
3 background-color: blue;
4 border-color: blue;
5 }
Listing 7-7.
styles.css Pressed Button Styles
: All Browsers
当按钮被按下时,我们需要做的就是向元素添加一个 CSS 类来触发 styles.css 文件中定义的样式。现在我们需要实现将“selected”类添加到该按钮的函数,以便触发样式表中定义的样式规则:
1 function onSelected(selectedButton) {
2 selectedButton.className += ' selected';
3 }
接下来是一行代码,用于导入 CSS 文件、我们的 button 元素和函数,该函数在被调用时触发按钮上先前定义的样式规则:
1 <link rel="style sheet" href="styles.css">
2 <script src="button-handler.js"></script>
3 <button>demo button</button>
这种方法有几个优点。首先,它展示了关注点的分离。换句话说,显示规则属于样式表,行为属于 JavaScript 文件,内容属于 HTML 文件。这种分离使得维护更加简单,潜在的风险也更小。它还确保了,例如,如果调整了样式,浏览器会继续缓存 HTML 和 JavaScript 文件。如果所有这些逻辑都被塞进一个 HTML 文件中,那么不管更改的范围有多大,整个文件都必须由浏览器重新下载。
将这些样式绑定到 CSS 类并在外部样式表中定义的另一个优点是,这些样式可以在本文档或任何其他文档中方便地重用。惯用的 jQuery 方法让我们一遍又一遍地复制和粘贴相同的样式,因为我们是内联定义它们的。
第二种情况呢?记住,我们希望每次点击时增加 10%的不透明度。同样,我们得到了一个函数,每当单击 box 元素时都会调用这个函数:
1 function onClicked(clickedBox) {
2 var currentOpacity = clickedBox.style.opacity ||
3 getComputedStyle(clickedBox, null).opacity;
4
5 if (currentOpacity > 0) {
6 clickedBox.style.opacity = currentOpacity - 0.1;
7 }
8 }]
我们优化的非 jQuery 方法代码多一点,但比惯用的 jQuery 解决方案快得多。 11 这里,当元素的style
属性上没有定义样式时,我们只利用对getComputedStyle
12 的昂贵调用。getComputedStyle
不仅通过检查元素的style
属性,还通过查看任何可用的样式表来确定元素的实际样式。因此,这个操作可能有点昂贵,所以我们避免它,除非绝对必要。
设置和确定元素可见性
显示和隐藏元素是 web 开发中的一个常见问题。这些任务可能并不简单,但是通过编程来确定一个元素是否可见往往更加复杂。传统上,元素可见性是开发人员要处理的一个令人困惑的问题。但不一定要这样。处理元素可见性有两种方法:您一直使用的方法(使用 jQuery)和正确的方法(不使用 jQuery)。您将看到 jQuery 在这种情况下是多么低效,以及这如何说明为什么盲目相信这种类型的软件库是危险的。
典型的 jQuery 方法
使用 jQuery 显示、隐藏和确定元素可见性的好处是简单。你很快就会发现,这是唯一的好处。但是现在,让我们把重点放在这个优势上。
用 jQuery 隐藏和显示元素几乎总是分别使用show()
和hide()
API 方法来完成。没有必要创建一个 HTML 片段来演示这些方法,所以让我们来深入研究几个代码示例:
1 // hide an element
2 $element.hide();
3
4 // show it again
5 $element.show();
这些代码都不需要进一步阐述。真正需要进一步检查的是实际执行这些操作的底层代码。不幸的是,这两种方法都使用了window.getComputedStyle
,这是上一节讨论的方法。在某些情况下,尤其是使用hide()
,getComputedStyle()
可能会被多次调用。这会产生严重的性能后果。为什么仅仅隐藏或显示一个 DOM 元素就需要如此强大的处理能力?在很大程度上,这两个常用 API 方法下面的所有聪明但通常不必要的代码都是为了处理样式边缘情况,否则很难显示或隐藏目标元素。正如我之前所说,元素可见性不一定是一个复杂的问题。通过采用一种更简单的方法,我们可以避免 jQuery 隐藏和显示元素所需的所有 CPU 周期。在下一节中,我将讨论解决这个问题的“本地 web 方法”。
如果我们需要弄清楚一个特定的元素是否被隐藏了呢?jQuery 也让这变得非常简单:
1 // is the element visible?
2 $element.is(':visible');
3
4 // conversely, is the element hidden?
5 $element.is(':hidden');
jQuery 决定发明几个新的伪类来表示元素可见性。即使是 jQuery 的创造者 John Resig,也详细讲述了这种新的创新 jQuery 混合物的有用性。 13 但是就像show()
、hide()
和css()
API 方法一样,这两个非标准的伪类都相当慢。同样,他们再次委托给window.getComputedStyle()
,有时每次调用多次。
在下一节中,我将概述几种显示和隐藏元素以及确定元素可见性的非 jQuery 方法。本机方法和 jQuery 方法之间的性能差异也将包括在内,至少可以说,这些差异是显著的。
原生 Web 方法
最终,jQuery 长期以来切换元素可见性的方法异常复杂,这导致了潜在的严重性能问题。重新思考这个问题后,很明显最简单的方法是最好和最有效的方法。jQuery 3.0 发行说明甚至建议使用与适当的 CSS 相关联的类名来显示或隐藏元素。
jQuery 中隐藏、显示和评估元素可见性的简单性非常引人注目。在这一部分,您可能希望我说这样的话,“没有 jQuery 做所有这些有点困难”,或者“没有 jQuery 有一个简单的方法来解决这个问题,但是它需要使用最先进的浏览器。”实际上,在任何没有 jQuery 的浏览器中显示、隐藏和确定元素可见性都非常容易。jQuery 开发人员可能希望您相信这些是要解决的复杂问题,并且您需要 jQuery 来解决它们,但是这些都不是真的。在这一节中,我将演示一些简单的约定,它们将产生简单的解决方案。
有许多方法可以隐藏一个元素。一些非常规的方法包括将元素的opacity
设置为 0,或者将position
设置为“absolute”并将其放置在可见页面之外。这些和其他类似的方法可能是有效的,但是它们通常被认为是“拼凑的”因此,在试图隐藏元素时,通常不鼓励使用这些方法。请不要这样做;有更好的方法。
更合理的方法是将元素的display
style 属性设置为“none”。正如您已经了解到的,有许多不同的方法来调整元素的样式。但是您也知道了最好的方法是在外部样式表中定义这种样式。因此,最好的解决方案可能是在样式表中定义一个定制的 CSS 类或属性,为这个选择器包含一个display: none
样式,然后在需要隐藏时将相关的类或属性添加到这个元素中。
那么,我们应该选择哪个呢?属性还是 CSS 类?这真的重要吗?W3C HTML5 规范定义了一个hidden
布尔属性, 14 ,正如您所料,它允许您通过将该属性添加到元素中来隐藏元素。这种标准化的属性不仅允许您轻松隐藏元素,还增强了标记的语义,并为所有屏幕阅读器提供了有用的提示。 15 没错,它甚至让你的元素更易接近。因为hidden
属性是正式规范的一部分,它不仅仅是一个约定——它代表了处理元素可见性的标准化方法。
此时,您可能正在检查哪些浏览器支持该属性。让我给你省点事吧——不是全部。事实上,微软直到 Internet Explorer 11 才首次支持hidden
属性。幸运的是,标准化hidden
属性的 polyfill 非常简单和优雅:只需将清单 7-8 中所示的规则添加到您的全局样式表中。
1 [hidden] { display: none; }
Listing 7-8.Polyfill for Standardized Hidden Attribute
: All Browsers
Making sure your element is always hidden
native hidden
属性将一个元素标记为“不相关”,这并不总是意味着该元素对眼睛是不可见的。例如,如果一个元素有一个显式声明的display
样式,比如display: block
,那么原生的hidden
属性不会将它从视图中移除。此外,简单地为该属性包含前面的“polyfill”并不总是能确保元素从视图中隐藏。这是由于 W3C 的 CSS2 规范中概述的特异性规则。 16 特异性决定了与一个元素相关联的几个竞争样式中的哪一个“胜出”例如,如果一个display: block
规则指向具有更高特异性的相同元素,那么该元素将保持可见。如果您希望任何具有hidden
属性的元素永远不可见,那么您必须在样式表中利用以下规则:
1 [hidden] { display: none !important; }
给定前面的单行聚合填充,您可以使用以下 JavaScript 行隐藏任何浏览器中的任何元素:
1 element.setAttribute('hidden', '');
隐藏元素不可能更简单、更优雅或更高效。这种方法比 jQuery 的hide()
API 方法快得多。事实上,jQuery 的hide()
方法要慢 25 倍以上! 17 没有理由继续使用 jQuery 来隐藏元素。
由于隐藏元素的最简单和最有效的方法是添加属性,所以您可能不会惊讶地发现,只需通过删除相同的属性就可以显示相同的元素:
1 element.removeAttribute('hidden');
因为我们遵循这个惯例——添加一个属性来隐藏元素,然后删除它来再次显示元素——确定元素的可见性很简单。我们所需要做的就是检查元素中是否存在这个属性,这在所有著名的浏览器中都是一个简单的操作:
1 // the element is hidden if this returns true
2 element.hasAttribute('hidden');
是的,真的很简单。
确定任何元素的宽度和高度
在我回顾 jQuery 如何允许您检查元素的宽度和高度,以及如何在不使用 DOM 抽象的情况下轻松做到这一点之前,您需要理解一些基本概念。智能计算任何元素的宽度和高度所需的最关键的规范是盒子模型。 18
每个元素都是一个盒子。再说一次:每个元素都是一个盒子。这很简单,但对于许多 web 开发人员来说却非常令人惊讶。一旦你从这种认识的最初震惊中走出来,下一步就是理解一个元素的盒子是如何被分割的。这被称为盒子模型。看图 7-1 ,来自万维网联盟 CSS 2.1 规范的盒子模型图。
img/A430213_1_En_7_Fig1_HTML.jpg)
图 7-1。
The box model. Copyright 2015 W3C. License available at www.w3.org/Consortium/Legal/2015/copyright-software-and-document
.
正如您所看到的,一个元素,也是一个框,由四个“层”组成:内容、填充、边框和边距。简单地说,元素的内容、填充和边框用于确定其高度和宽度。边距不被视为元素“尺寸”的一部分,它们只是将其他元素推开,而不是影响元素的高度和宽度。你如何测量宽度和高度很大程度上取决于你关心的是前三层中的哪一层。一般来说,元素的维度可以考虑这三个层(内容层和填充层)的子集,也可以考虑所有三个层。jQuery 采用了不同的方法,只考虑内容维度。接下来会有更多。
使用 jQuery 检查元素
就像 web API 一样——在下一节中描述——有许多方法可以使用 jQuery 的 API 发现元素的维度。你可能已经知道一些甚至所有这些方法。您可能没有意识到 jQuery 内置元素维方法的糟糕性能——大多数开发人员都没有意识到。你为什么要这么做?这些方法是如此简单和优雅,以至于在性能方面付出巨大代价的可能性并不常见。您可能相信这个关键的库不会以任何明显的方式降低应用的效率。但是你错了。
最明显的 API 方法是width()
和height()
。还记得图 7-1 中的箱型图吗?这两个 jQuery 方法只测量元素框的“内容”部分。这听起来是一种合理的行为,但它不一定是完整的表示,因为内容只占元素实际宽度和高度的一部分。记住,边距是盒子模型中唯一不直接影响元素可见宽度和高度的元素。还要记住,jQuery 并不神奇——它必须将所有 API 方法委托给 web API。web API 没有提供一种简单的方法来确定元素内容的维度。因此,jQuery 必须执行一些令人不快的操作来确定这些值,结果牺牲了性能。当我在下一节演示如何使用 web API 计算元素的宽度和高度时,我将向您展示 jQuery 的其他宽度和高度 API 方法实际上是多么低效。
浏览器本身提供的选项
尽管 jQuery 的width
和height
是流行的方法,但是在任何 web 规范中都找不到类似的方法或属性对。这些方法的吸引力可能与它们暗示性的名字有关。
为了更好地说明本节中的代码,我将从一个简单的元素开始,它占据了盒子模型所有四个部分的空间:
1 <style>
2 .box {
3 padding: 10px;
4 margin: 5px;
5 border: 3px solid;
6 display: inline-block;
7 }
8 </style>
9 <span class="box">a box</span>
内容的宽度和高度+填充
要获得前一个框的宽度或高度,仅考虑内容和填充值,我们可以使用Element
界面上的clientWidth
19 和clientHeight
20 属性。这些可以与 jQuery 的innerWidth()
和innerHeight()
API 方法相媲美,但是 web API 比 jQuery 的解决方案具有显著的性能优势。 21 原生解快十倍左右!
这些属性首先在 W3C 起草的级联样式表对象模型(CSSOM)视图规范中定义。截至 2016 年年中,CSSOM 规范还不是一个推荐标准——事实上,它只是一个工作草案。但是这两个Element
属性,以及本规范中表示的许多其他项目,已经被浏览器支持了很长时间。例如,Element.clientWidth
和Element.clientHeight
属性从 Internet Explorer 6 开始就一直受到支持,然而它们目前只在这个工作草案规范中定义。这似乎有点奇怪,不是吗?确实如此,但是 CSSOM 规范是一个特殊的规范。它的存在主要是为了编纂和正式标准化长期存在的 CSS 相关的浏览器行为。Element.clientWidth
和Element.clientHeight
就是两个这样的例子,但你也会在本节中看到其他例子。
清单 7-9 显示了clientWidth
和clientHeight
在我们之前的标记中的<span>
上返回了什么。
1 // returns 38
2 document.querySelector('.box').clientHeight;
3
4 // returns 55
5 document.querySelector('.box').clientWidth;
Listing 7-9.Find width/height of Content + Padding: Web API, Modern Browsers, and Internet Explorer 8
请注意,前面的返回值可能会因浏览器而略有不同,因为默认字体和样式也可能会因浏览器而略有不同。这将最终导致元素内容大小的微小变化,这是意料之中的。
这里还有一些你可能没有意识到的东西。注意到附加在我们的<span>
元素上的display: inline- block
样式了吗?将其拆下,再次检查clientWidth
和clientHeight
的返回值。如果没有这个样式,这两个属性都报告一个值0
。默认情况下,所有浏览器都将<span>
元素呈现为display: inline
,内联元素总是将0
报告为它们的clientWidth
和clientHeight
。使用这些属性时,请记住这一点。注意,浮动一个默认的行内元素也将允许您以这种方式计算宽度和高度。
作为比较,jQuery 的width()
和height()
方法分别返回35
和18
。请记住,这些方法只考虑元素的内容,忽略填充、边框和边距。
内容的宽度和高度+填充+边框
如果在报告元素的宽度和高度时需要包含边框,该怎么办?也就是内容、填充、边框?简单—使用HTMLElement.offsetWidth
23 和HTMLElement.offsetHeight
。 24 这两个属性都可以与 jQuery 的outerWidth()
和outerHeight()
方法相媲美,如清单 7-10 所示。
1 // returns 44
2 document.querySelector('.box').offsetHeight;
3
4 // returns 61
5 document.querySelector('.box').offsetWidth;
Listing 7-10.Find width/height of Content + Padding + Border: Web API, Modern Browsers, and Internet Explorer 8
正如所料,这些值比clientHeight
和clientWidth
报告的值稍大,因为我们也考虑了边界。事实上,每个值正好大 6 个像素。这是预期的,因为在我们的<style>
元素中定义了每边 3 个像素的边框。
同样,由于浏览器对元素内容进行样式化的方式不同,上面的返回值可能会因浏览器而略有不同。另外,offsetHeight
和offsetWidth
不需要display: inline-block
——它们不会报告内联元素的零高度和宽度。
关于样式元素还有很多要讨论的,但是这本书讲的更多。我已经为您提供了一些重要的概念,这些概念将使您在面临其他与样式相关的挑战时不再依赖 jQuery。
Footnotes 1
www.w3.org/TR/REC-CSS1/#containment-in-html
2
3
4
https://developer.mozilla.org/en-US/docs/Web/Security/CSP/Introducing_Content_Security_Policy
5
www.w3.org/TR/2012/CR-CSP-20121115/
6
www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-ElementCSSInlineStyle
7
www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSStyleDeclaration
8
9
www.w3.org/MarkUp/html-spec/html-spec_toc.html#SEC5.2.4
10
https://learn.jquery.com/using-jquery-core/css-styling-dimensions/
11
http://jsperf.com/jquery-css-vs-optimized-non-jquery-approach3
12
www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSview-getComputedStyle
13
http://ejohn.org/blog/selectors-that-people-actually-use/
14
www.w3.org/TR/html5/editing.html#the-hidden-attribute
15
www.html5accessibility.com/tests/hidden2013.html
16
www.w3.org/TR/CSS2/cascade.html#specificity
17
http://jsperf.com/jquery-hide-vs-setattribute-hidden
18
19
www.w3.org/TR/cssom-view/#dom-element-clientwidth
20
www.w3.org/TR/cssom-view/#dom-element-clientheight
21
http://jsperf.com/innerheight-vs-element-clientheight
22
23
www.w3.org/TR/cssom-view/#dom-htmlelement-offsetwidth
24
www.w3.org/TR/cssom-view/#dom-htmlelement-offsetheight
八、DOM 操作
web API 最令人困惑和误解的方面之一与 DOM 操作有关。我怀疑您已经习惯了通过 jQuery 使用 DOM 元素。但是在这方面有必要继续依赖一个库吗?在本章中,我将向您展示如何在没有第三方代码帮助的情况下创建、更新和移动元素和元素内容。您将会体会到在几乎所有浏览器中使用 DOM 是多么容易。
DOM:Web 开发的核心组件
你很可能听说过 DOM。这是 web 开发领域的一个常用术语。但是也许 DOM 对你来说仍然是一个非常神秘的词。DOM 到底是什么,为什么它如此重要?jQuery 如何以及为什么将 DOM API 从开发人员手中抽象出来?
正如 W3C 维护的早期文档之一所述, 1 “文档对象模型的历史,即众所周知的 DOM,与 JavaScript 和 JScript 脚本语言的起源紧密相关。”这个模型表达了一个 API,通常用 JavaScript 实现,用于 web 浏览器,允许对 HTML 文档进行编程访问。除了处理属性、选择元素和存储数据之外,DOM API 还提供了创建新内容、删除元素和在文档中移动现有元素的方法。DOM API 的这些特定方面是本章的主要焦点。
jQuery 的存在是因为 DOM API
好吧,这不是创建 jQuery 的唯一原因,但肯定是原因之一。jQuery 的创始人 John Resig 在 2009 年雅虎的一次演讲中称 DOM 为“一团乱麻”。因此,这可能会让我们对 jQuery 要解决的问题有所了解。在 Resig 演讲的几年前,jQuery 1.0 发布了(2006 年),它包含了大约 25 种特定于 DOM 操作的方法。这约占整个空气污染指数的 17%。即使 API 已经发展到可以处理 web API 的所有方面,DOM 操纵函数仍然只占 15%。
当然,jQuery 为使用 DOM 提供了一些优雅的解决方案。但是它们真的有必要吗?当然不是!DOM 还“破”吗?在我看来不是。DOM API 可能有些粗糙,但是它非常容易使用,非常可靠。没有 jQuery 的帮助,您可以轻松地操作 DOM。
DOM API 没有坏,只是被误解了
在瑞西格在雅虎的演讲中,他说“几乎每一个 DOM 方法在某些浏览器中都以某种方式被破坏了。”尽管这有点夸张,但在 2009 年可能还是有一定道理的。然而,当前的浏览器时代描绘了一幅截然不同的画面。浏览器还是会有 bug,但是所有软件都是这样,甚至 jQuery 也是。jQuery 团队显然已经知道这个库不再是抵御浏览器漏洞的屏障,这一点从他们对特定于浏览器的问题的回应中可以明显看出。 4
jQuery 的主要目标之一一直是保护开发人员免受 DOM API 的影响。为什么呢?因为它在历史上一直是一团糟。当试图以编程方式更新页面标记时,它也被视为一条混乱的路径。这有点道理,但只是因为许多开发人员没有花时间学习 DOM API。正如你将在本章看到的,使用 DOM 并不像你想象的那么难,并且在所有流行的浏览器中都得到了很好的支持。jQuery 的 API 所提供的便利是毋庸置疑的。一旦您对 jQuery 本身所依赖的底层 DOM API 方法有了更好的理解,您甚至可以更好地使用 jQuery,如果您继续使用它的话。
移动和复制元素
在第一部分中,我将重点关注移动和复制现有的元素。您将学习如何在 DOM 中的任何地方插入元素,改变相邻元素的顺序,以及克隆一个元素。我将演示在Document
、、 5 、、 6 、、 7 、等接口上出现的方法。您将看到如何使用 jQuery 执行基本的 DOM 操作,然后是单独使用 DOM API 的相同任务的解释和演示。
在 DOM 中移动元素
我将从我的一个专利(正在申请中的)样本文档开始这一部分。为了主要关注可演示方法的功能,我将保持简单明了。不可否认,这导致了一个有些做作的例子,但我觉得它将很容易让我们在 DOM 操纵的上下文中比较 jQuery 和 DOM API。
我们这个超级简单的文档由冰淇淋的几个不同类别和属性组成:口味、类型和一个未分配类型和口味的部分。这份文件代表了冰淇淋店顾客的一些选择。使用这个标记,我们将解决几个“问题”,首先是 jQuery,然后是普通的旧 DOM API。
第一个挑战是根据受欢迎程度,按降序对口味和种类进行重新排序。巧克力是最受欢迎的口味,其次是香草和草莓。我们必须改变口味列表项的顺序来反映它们的流行程度,但是类型列表已经有了正确的顺序。
第二,我们真的想首先向读者展示冰淇淋的种类,然后是口味。目前的订单,首先包括口味,众所周知不太理想,因为我们的客户希望在决定口味之前先被告知类型。
最后,我们需要将“未分配”部分中的项目分配到适当的类别中。“Rocky road”是一种没有香草那么受欢迎,但比草莓更受欢迎的口味。“gelato”是其中最不受欢迎的一种:
1 <body>
2 <h2>Flavors</h2>
3 <ul class="flavors">
4 <li>chocolate</li>
5 <li>strawberry</li>
6 <li>vanilla</li>
7 </ul>
8
9 <h2>Types</h2>
10 <ul class="types">
11 <li>frozen yogurt</li>
12 <li>custard</li>
13 <li>Italian ice</li>
14 </ul>
15
16 <ul class="unassigned">
17 <li>rocky road</li>
18 <li>gelato</li>
19 </ul>
20 </body>
解决了上述问题后,我们的文档应该是这样的:
1 <body>
2 <h2>Types</h2>
3 <ul class="types">
4 <li>frozen yogurt</li>
5 <li>Italian ice</li>
6 <li>custard</li>
7 <li>gelato</li>
8 </ul>
9
10 <h2>Flavors</h2>
11 <ul class="flavors">
12 <li>chocolate</li>
13 <li>vanilla</li>
14 <li>rocky road</li>
15 <li>strawberry</li>
16 </ul>
17
18 <ul class="unassigned">
19 </ul>
20 </body>
使用 jQuery Mobile 元素
为了正确排列口味,“香草”必须移到“巧克力”之后。为此,我们必须利用 jQuery 的after()
API 方法:
1 var $flavors = $('.flavors'),
2 $chocolate = $flavors.find('li').eq(0),
3 $vanilla = $flavors.find('li').eq(2);
4
5 $chocolate.after($vanilla);
对于我们的第二个挑战,我们必须将“类型”列表和标题(<h2>
)移到“口味”列表之前。我们可以利用这一事实,即这意味着标题和列表必须是<body>
元素中的第一组子元素。首先,我们使用prependTo()
方法在<body>
前添加“类型”标题,然后在新移动的标题后插入“类型”列表,再次使用 jQuery 的after()
方法:
1 var $typesHeading = $('h2').eq(1);
2
3 $typesHeading.prependTo('body');
4 $typesHeading.after($('.types'));
最后,我们需要将未赋值的“rocky road”移到口味列表中“strawberry”的正上方,将“gelato”移到“types”列表的末尾。对于第一步,我们可以再次使用 jQuery 的after()
方法。对于第二步,我们将对“gelato”元素使用appendTo
方法,将其作为“types”列表中的最后一个子元素插入:
1 var $unassigned = $('.unassigned'),
2 $rockyRoad = $unassigned.find('li').eq(0),
3 $gelato = $unassigned.find('li').eq(1);
4
5 $vanilla.after($rockyRoad);
6 $gelato.appendTo($('.types'));
前面的解决方案都不是特别优雅或直观。当然有可能想出更吸引人的例子来解决这些问题,但是我认为这种尝试在 jQuery 开发人员中很常见。我们也可以利用一些 jQuery 专有的伪类,比如:first
和:last
,但是我们已经知道这些选项是多么低效。
DOM API 对元素重新排序的解决方案
为了在没有 jQuery 的情况下对我们的冰淇淋店页面进行适当的调整,我将引入两个新的 DOM API 方法。你还会看到一些选择器和其他 DOM API 方法,这些在第四章中已经讨论过了。令人惊讶的是,本节中的所有代码都可以在所有现代浏览器和 Internet Explorer 8 中运行!在我们开始之前,我并没有忘记这个示例冰淇淋店标记令人眼花缭乱的本质,但是它允许我简洁地演示一些 DOM 操作操作,而不会陷入与手头问题无关的细节中。也就是说,让我们开始吧。
请记住,我们的第一个任务是将“香草”元素移动到“草莓”元素之前。为了实现这一点,我们可以使用insertBefore()
方法、 8 ,这是作为 W3C 的 DOM Level 2 核心规范的一部分添加到Node
接口中的。可以想象,这个方法允许我们在 DOM 中将一个元素移动到另一个元素之前。因为这在Node
接口上是可用的,我们有能力移动 DOM 中的任何东西,甚至是一个Text
或Comment
节点!看看我们是如何移动这个元素的——我将在下面的代码片段之后立即解释发生了什么:
1 var flavors = document.querySelector('.flavors'),
2 strawberry = flavors.children[1],
3 vanilla = flavors.children[2];
4
5 flavors.insertBefore(vanilla, strawberry);
在前面代码的顶部,我只是选择了移动操作所需的元素。最后一行是最重要的一行。因为insertBefore()
方法是在Node
对象的prototype
上定义的,所以我们必须在实现这个接口的 DOM 对象上调用insertBefore()
。事实上,这个元素必须是我们正在移动的Node
的父元素。因为我们正在移动“vanilla”<li>
元素,所以我们可以使用它的父元素——“flavors”<ul>
。
传递给insertBefore()
的第一个参数是我们想要重新定位的元素:“普通”列表项。第二个参数是“参考节点”这是在移动操作之后将成为我们的目标元素的下一个兄弟的Node
(“香草”<li>
)。因为我们想将“香草”放在“草莓”之前,“草莓”<li>
是我们的引用节点。
我们已经对口味进行了重新排序,但是我们仍然需要将口味标题和列表移动到文档的顶部。我们也可以用insertBefore()
方法轻松实现这个目标:
1 var headings = document.querySelectorAll('h2'),
2 flavorsHeading = headings[0],
3 typesHeading = headings[1],
4 typesList = document.querySelector('.types');
5
6 document.body.insertBefore(typesHeading, flavorsHeading);
7 document.body.insertBefore(typesList, flavorsHeading);
Note
关于前面代码清单中的这行代码——document . body . insert before(typesHeading,flavors heading)——其行为就像早期 jQuery 代码清单中的$typesHeading.prependTo('body ')。为什么呢?因为 flavorsHeading 恰好是 document.body 的第一个子级。
我们逻辑的核心包含在前面代码的最后两行中。首先,我们将“类型”移到文档的顶部。这个标题的父元素是<body>
元素,我们可以使用document.body
很容易地选择它。当然,我们的目标元素是“类型”标题。我们希望将它移到“flavors”<h2>
之前,这样它就成为了我们的参考元素。
第二个insertBefore()
将冰淇淋类型的<ul>
移动到最近移动的标题之后。同样,<body>
是我们的母元素。因为我们需要将这个列表移到“口味”标题之前,所以这又是我们的引用节点。
我们最后的任务是将未赋值的元素移动到它们各自的列表中。为了实现这一点,我们将再次使用insertBefore()
,但是您也将看到一个新的方法在起作用。W3C DOM Level 1 规范是一个相当老的规范,它首先定义了一个appendChild() method on the Node
接口。 9 当我们结束练习时,这个方法会对我们有些用处:
1 flavors.insertBefore(
2 document.querySelector('.unassigned > li'), strawberry);
3
4 document.querySelector('.types').appendChild(
5 document.querySelector('.unassigned > li'));
在第一条语句中,我们将“rocky road”元素从 unassigned 列表移到 flavors 列表中。正如所料,口味列表是我们的父元素。目标是 unassigned 列表的第一个列表项子,恰好是“rocky road”<li>
。引用节点是 flavors 列表中的 strawberry 项,因为我们希望将“rocky road”移动到该元素之前。
我们还想将未分配的“gelato”列表项移动到类型列表的末尾。最简单的方法是使用appendChild()
。与insertBefore()
方法一样,appendChild()
希望在我们计划移动的节点的父节点上被调用——“类型”列表。appendChild()
方法只接受一个参数——将成为父元素的最后一个子元素的元素。此时,“gelato”项是未赋值列表中的第一个<li>
子元素,因此我们可以使用与在insertBefore()
语句中定位目标元素相同的选择器。
这一切出乎意料的简单,不是吗?DOM API 可能不像许多人说的那样可怕!
制作元素的副本
为了演示使用 jQuery 和 DOM API 克隆元素的各种方法,请考虑以下标记:
1 <ol class="numbers">
2 <li>one</li>
3 <li>two</li>
4 </ol>
DOM API 提供了一种克隆<ol>
及其子对象的方法,以及一种只克隆<ol>
而不克隆其任何子对象/内容的方法。前者称为深度克隆,后者称为浅层克隆。jQuery 只提供了一种深度克隆的方法。
在 jQuery-land 中,我们必须使用$.clone()
:
1 // deep clone: return value is an exact copy
2 $('.numbers').clone();
如果您希望 jQuery 克隆元素上的任何数据和事件侦听器,您可以选择将布尔参数传递给前面的clone()
。但是要注意,jQuery 只会复制事件侦听器和通过 jQuery 附加到元素的数据。任何在 jQuery API 之外添加的侦听器和数据都将丢失。
DOM API 在Node
接口上提供了一个类似命名的方法cloneNode()
。它最初被标准化为 DOM Level 2 Core 的一部分, 10 ,这在 2000 年成为 W3C 的推荐标准。因此,任何浏览器都支持cloneNode()
。由于我使用了querySelector()
,下一个例子仅限于 Internet Explorer 8 和更高版本(尽管这几乎不是一个有问题的限制):
1 // shallow clone: return value is an empty <ol class="numbers">
2 document.querySelector('.numbers').cloneNode();
3
4 // deep clone: return value is an exact copy of the tree
5 document.querySelector('.numbers').cloneNode(true);
在这两种情况下,元素副本将包含标记中定义的所有内容,甚至类名和任何其他属性,如内联样式。事件侦听器不会包含在副本中,也不会在元素的 JavaScript 对象表示中专门设置任何属性。换句话说,cloneNode()
只复制你看到的东西:标记。
无论您使用的是 jQuery 还是 DOM API,由cloneNode()
创建的副本都不会添加到文档中。您需要使用本节前面演示的方法之一自己完成这项工作。
组成你自己的元素
既然我们已经探讨了移动和应对元素,那么如何创建和删除它们呢?您将看到这些常见问题是如何用 jQuery 解决的,以及如何用 DOM API 轻松解决它们。与上一节一样,这里的所有 DOM API 代码都可以在所有现代浏览器中工作,并且大多数代码在 Internet Explorer 8 中也受支持。
为了更好地演示最后一节中概述的所有概念,我将基于上一节中修改过的示例文档来演示移动元素。使用 jQuery 和裸 DOM API,我将向您展示如何对我们的示例文档执行各种操作,如下所示:
- 加入一些新的冰淇淋口味。
- 移除一些现有类型。
- 对我们的文档进行简单的文本调整。
- 将文档的部分内容读入一个字符串,以便保存。
- 创建一个新的部分来进一步分类我们的冰淇淋。
创建和删除元素
假设我们有几个新口味添加到我们的列表:开心果和那不勒斯。这些当然属于“口味”部分。为了完成这项任务,我们需要创建两个新的<li>
元素,其中的Text Node
包含这两种新口味的名称。简单地将这些新风格添加到列表的末尾是很好的,这样我们就可以专注于创建有代表性的元素。我们还想从类型列表的末尾删除“gelato”类型,因为我们不再销售 gelato 冰淇淋。
使用 jQuery 创建元素非常容易,由于链接,我们可以在两行中添加这两个元素:
1 var $flavors = $('.flavors');
2
3 // add two new flavors
4 $('<li>pistachio</li>').appendTo($flavors);
5 $('<li>neapolitan</li>').appendTo($flavors);
移除一个元素也不是很困难:
1 // remove the "gelato" type
2 $('.types li:last').remove();
这里我们使用了 CSS 选择器,部分是专有的。带有“types”CSS 类的元素下面的最后一个<li>
将从文档中删除。这正好是我们的“冰淇淋”类型。:last
pseduo-class 是特定于 jQuery 的,因此性能不是特别好。有一个我们可以使用的本地 CSS pseduo 类,您马上就会看到,但是许多 jQuery 开发人员可能不知道它的存在,因为 jQuery API 提供了这个专有的替代方法作为其 API 文档的一部分。
我们如何用 DOM API 达到同样的结果?根据所需的浏览器支持,我们可能有几个选项。虽然新的浏览器可能比旧的浏览器允许更优雅的选项,但情况并不总是这样,在所有现代浏览器(甚至旧的浏览器)中,这些操作都相对简单,不依赖于 jQuery。
我们可以将我们的两种新口味添加到“口味”列表的末尾,总共两行,就像 jQuery 解决方案一样,尽管这两行稍长一些:
1 var flavors = document.querySelector('.flavors');
2
3 // add two new flavors
4 flavors.insertAdjacentHTML('beforeend', '<li>pistachio</li>')
5 flavors.insertAdjacentHTML('beforeend', '<li>neapolitan</li>')
在前面的代码中,我使用了呈现在Element
接口原型上的insertAdjacentHTML
方法 11 。虽然这种方法可能已经在浏览器中存在多年,但它只是在 2014 年起草的 W3C DOM 解析和序列化规范 12 中首次标准化。
把“gelato”从我们的类型列表中去掉怎么样?在最新的浏览器中,我们有最优雅的解决方案:
1 // remove the "gelato" type
2 document.querySelector('.types li:last-child').remove();
前面的代码与 jQuery 解决方案非常相似,但有一些明显的不同。首先,我当然是使用querySelector
来定位要移除的元素。第二,我使用了:last-child
CSS3 伪类 13 选择器。出现在ChildNode
界面上的remove()
方法相对较新,仅在微软 Edge、Chrome、Firefox 和 Safari 7 中受支持。任何版本的 Internet Explorer 都不支持它,苹果 iOS 浏览器也不支持它。这种方法首先由 WHATWG 定义为其 DOM 生活标准 14 的一部分,尤其是我们在浏览器支持方面的限制因素。
幸运的是,我们有一个覆盖所有现代浏览器的解决方案,只需要多一点代码:
1 var gelato = document.querySelector('.types li:last-child');
2
3 // remove the "gelato" type
4 gelato.parentNode.removeChild(gelato);
我把ChildNode.remove()
换成了Node.removeChild()
,它从 DOM Level 1 Core、 15 开始就存在了,所以它在所有浏览器上都受支持。当然,要删除子节点,我们需要首先访问父节点。幸运的是,这真的很容易做到,正如你在第四章中学到的。在这种情况下,限制我们使用现代浏览器的代码是:last-child
CSS3 伪类,它在 Internet Explorer 8 中不可用。
为了支持 IE8,你必须用document.querySelectorAll('.types li')[3]
替换选择器。如果您不想硬编码 gelato 元素的索引,您必须将querySelectorAll()
的结果移入一个变量,并通过检查该变量的length
属性来访问返回集合中的最后一个元素。
文本内容
就元素文本而言,有两个工作流需要处理:更新和解析。虽然 jQuery 提供了一种特定的方法来完成这两项任务,但是 DOM API 提供了两种方法——两种方法都有不同的行为来满足不同的需求。在这一节中,我将演示 jQuery 的text()
方法、本机textContent
属性和本机innerText
属性。当我们对冰淇淋类型和口味的文档进行更改,然后将结果文档输出为文本时,您将看到这些不同之处。
首先,让我们检查 jQuery 的text()
方法,它允许我们读取和更新文档中的文本。请注意,我们的一种类型——“意大利冰”——以大写字母开头。其他类型或口味都没有这个特点。尽管“Italian”是一个恰当的形容词,通常应该以大写字母“I”开头,但让我们对其进行修改,以与我们的其他类型和口味保持一致:
1 $('.types li').eq(1).text('italian ice');
您可能已经知道,元素的文本可以简单地通过传递新文本作为text()
方法的参数来更新。这正是我所做的,为了使这种冰淇淋的情况正常化。如果我们使用 jQuery 的text()
方法输出修改后的文档会是什么样子?像这样:
1 "
2 Types
3
4 frozen yogurt
5 italian ice
6 custard
7 gelato
8
9
10 Flavors
11
12 chocolate
13 vanilla
14 rocky road
15 strawberry
16
17 "
添加了引号以显示输出的开始和结束位置。它们不是实际文本的一部分。注意,这个输出反映了我们的标记的结构。这可以通过检查文本的缩进以及文档末尾的换行符来验证。输出结束前的一系列换行符说明了空的“未赋值”列表。您将看到这个输出如何反映 DOM API 提供的两个本地文本操作属性之一的输出。
用于读取和更新文本的 DOM 元素有两个公共属性:textContent
和innerText
。这两种属性各有优缺点,但是它们的存在使得在处理文本时比单独使用 jQuery 的text()
方法更加灵活。接下来,我将这两个属性相互进行比较,并与 jQuery 的text()
方法进行对比,这样就可以清楚地知道什么时候应该选择其中一个。
我们先来考察一下textContent
,它被添加到了 W3C 的 DOM Level 3 Core 的Node
接口中)。 16 该属性允许在所有现代浏览器中读取和更新元素文本。将“意大利冰”列表项的文本改为“意大利冰”就像 jQuery 的text()
方法一样简单:
1 document.querySelectorAll('.types li')[1].textContent = 'italian ice';
textContent
属性不仅匹配 jQuery 的text()
方法在写入文本时的行为,它在读取文本时的功能也与 jQuery 完全一样。以我们之前的例子为例,在修改了“Italian ice”类型之后,我们输出了整个冰激凌文档。DOM API 的textContent
属性的输出与 jQuery 的text()
完全匹配:
1 "
2 Types
3
4 frozen yogurt
5 italian ice
6 custard
7 gelato
8
9
10 Flavors
11
12 chocolate
13 vanilla
14 rocky road
15 strawberry
16
17 "
如您所见,textContent
输出元素及其后代中的文本,并按照文档标记的结构进行格式化,就像 jQuery 的text()
一样。
第二个可用的属性是innerText
,它在HTMLElement
接口上可用,尽管有点奇怪,因为它还不是任何正式 web 规范的一部分。然而,它被所有浏览器的所有版本支持,除了 Firefox,它直到版本 45 才添加支持。 17 尽管innerText
还没有标准化,但是已经有了一个初步的提案草案 18 由 Mozilla 的 Robert O'Callahan 创建。
使用innerText
将“意大利冰”更改为“意大利冰”与textContent
或 jQuery 的text()
没有太大区别,除了增加了对 Internet Explorer 8 的支持以及缺少对 45:
1 document.querySelectorAll('.types li')[1].innerText = 'italian ice';
那么,如果我们试图使用innerText
输出我们的文档,会发生什么呢?您将看到结果看起来与从textContent
和 jQuery 的text()
获得的结果略有不同:
1 "Types
2
3 frozen yogurt
4 italian ice
5 custard
6 gelato
7 Flavors
8
9 chocolate
10 vanilla
11 rocky road
12 strawberry"
最初,前面的输出可能看起来有点奇怪,但是如果您理解它所代表的含义,它实际上是完全有意义的。我希望您将前面列出的修改过的文档中的标记粘贴到浏览器中,将呈现的结果复制到系统的剪贴板中,然后将其粘贴到文本编辑器中。您会注意到粘贴的文本与这里列出的输出格式相同。正如草案规范所描述的,innerText
“返回一个元素的‘渲染文本’。”
有一次有人问我“在处理读取元素文本时,有没有一个通用的解决方案来使用所有浏览器都支持的 web APIs?”嗯,那要看你的要求了。如果 textContent 的行为是适当的,并且您只需要现代浏览器支持,那么这可能是您的最佳选择。但是如前所述,确实存在 innerText 更合适的情况。jQuery 的 text()的行为类似于 textContent,因此,如果您想要反映 jQuery 的行为,并且需要支持所有现代浏览器,包括旧版本的 Firefox,这是支持 textContent 的另一个原因。
丰富的内容
HTML 只不过是按照一组 web 规范定义的约定格式化的文本。当我们需要序列化或反序列化文档或文档的一部分时,这种现实是有用的。当接收到响应 HTTP 请求的服务器生成的标记时,可能会发生 HTML 的反序列化。在这种情况下,响应中的 HTML 必须插入到 DOM 中的适当位置。我将演示这个特定的场景,并讨论如何在 DOM API 中可用的几种方法的帮助下完成这个过程。也许这个服务器生成的标记必须返回给服务器,并在以某种方式修改后保留下来供以后使用。这也可以用 DOM API 来完成,您将在最后一节中看到如何完成。
jQuery 提供了一种读写 HTML 的方法。这是使用名副其实的html()
函数完成的。首先,假设我们已经从服务器收到了一个 HTML 字符串,我们需要将它插入到我们的文档中。为了与本章的主题保持一致,这个标记代表了冰淇淋店页面的一个全新部分。我们只需要将它插入到现有的部分之后。来自我们服务器的标记只是一长串 HTML,比如“
容器
- cone
- cup
”。这个 HTML 字符串将被存储在一个名为container
的变量中。在这里,您可以看到如何使用 jQuery 将它插入到我们文档的末尾:
1 $('<div>').html(container).appendTo('body');
首先,我们创建一个新的<div>
,它与 DOM 断开连接,然后我们将这个断开连接的<div>
的内容设置为来自服务器的 HTML,最后这个元素被添加到我们的冰激凌商店页面的末尾。在以各种方式修改了我们的页面之后,我们现在想要将标记发送回我们的服务器,这也可以使用 jQuery 的html()
方法来完成:
1 var contents = $('body').html();
2 // ...send `contents` to server
jQuery-less DOM API 路线稍逊一筹,但仍然非常简单并得到广泛支持。为了读写相同的标记,我们将使用在Element
接口上定义的innerHTML
属性。这一特性虽然在所有可以想象的浏览器中都得到支持,但直到最近才实现标准化。innerHTML
最初是微软的 Internet Explorer 专有扩展,但现在是 W3C DOM 解析和序列化规范的一部分。 19
我们可以使用innerHTML
将服务器生成的 HTML 添加到页面的末尾:
1 var div = document.createElement('div');
2 div.innerHTML = container;
3 document.body.appendChild(div);
Document
接口的createElement
方法由 W3C 的 DOM Level 1 Core 20 规范提供,这意味着它在任何浏览器中都受支持。为持久性服务器端读回我们文档的标记也使用了innerHTML
,它和 jQuery 的html()
方法一样优雅:
1 var contents = document.body.innerHTML;
2 // ...send `contents` to server
在这个实例中,DOM API 比 jQuery 更灵活一些;它提供了更多的选择。例如,标准化的Element.outerHTML
属性将在读取或更新 HTML 时考虑引用元素。相反,innerHTML
只涉及引用元素的后代。如果我在上面的“添加一个字符串”演示中使用了outerHTML
,那么文档中的所有内容,包括<body>
元素,都将被替换为新的<div>
包装的冰淇淋容器部分。在最后一个 DOM API 示例中,我们读回了文档的内容,如果我们使用了outerHTML
,那么<body>
元素就会包含在 stringified-html 中。根据您的要求,这可能是可取的。
虽然我肯定没有展示 DOM API 提供的所有属性和方法,但我想说的是,浏览器已经为 DOM 操作提供了足够多的合理和直观的本机支持。
Footnotes 1
www.w3.org/2002/07/26-dom-article.html
2
http://ejohn.org/blog/the-dom-is-a-mess/
3
https://github.com/jquery/jquery/issues
4
https://github.com/jquery/jquery/issues/2679#issuecomment-152289474
5
https://developer.mozilla.org/en-US/docs/Web/API/Document
6
https://developer.mozilla.org/en-US/docs/Web/API/Element
7
https://developer.mozilla.org/en-US/docs/Web/API/Node
8
www.w3.org/TR/2000/REC-DOM-Level-2-Core-20001113/core.html#ID-952280727
9
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#ID-184E7107
10
www.w3.org/TR/DOM-Level-2-Core/core.html#ID-3A0ED0A4
11
12
https://w3c.github.io/DOM-Parsing/
13
www.w3.org/TR/css3-selectors/#last-child-pseudo
14
https://dom.spec.whatwg.org/#dom-childnode-remove
15
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#method-removeChild
16
www.w3.org/TR/DOM-Level-3-Core/core.html#Node3-textContent
17
https://developer.mozilla.org/en-US/Firefox/Releases/45
18
https://rocallahan.github.io/innerText-spec/index.html
19
www.w3.org/TR/DOM-Parsing/#widl-Element-innerHTML
20
www.w3.org/TR/REC-DOM-Level-1/level-one-core.html#method-createElement
九、AJAX 请求:动态数据和页面更新
AJAX,即异步 JavaScript 和 XML,是 web API 提供的一个特性,它允许在不重新加载整个页面的情况下从服务器更新或检索数据。浏览器最初没有这种功能。没有这个特性的时代标志着 web 的婴儿期,随之而来的是不太理想的用户体验,导致大量冗余字节在客户机和服务器之间循环。这种原始模式的低效率由于互联网带宽受到当今标准的极大限制而变得更加复杂。早在 1999 年,当微软首次将XMLHTTP
作为 ActiveX 控件引入 Internet Explorer 5.0 时, 1 大约 95%的互联网用户受到 56 Kbps 或更慢的拨号连接的限制。 2
是在微软的 Internet Explorer 浏览器中实现的专有 JavaScript 对象,它代表了 web 开发技术和用户体验的巨大飞跃。它是第一个全功能的内置传输,用于集中客户端/服务器通信,允许在不替换整个页面的情况下进行更新。以前,即使页面上只有一小部分数据发生了变化,也必须重新加载整个页面。这种新传输的初始 API 与其现代的标准化表亲相匹配:XMLHttpRequest
。本质上,这个对象允许开发人员构造一个new
传输实例,向任何端点(在同一个域上)发送 GET、POST、PUT、PATCH 或 DELETE 请求,然后以编程方式检索服务器响应的状态和消息体。尽管老化的XMLHttpRequest
最终将被 Fetch API3所取代,但它在没有对手的情况下茁壮成长,并且在大约 15 年的时间里基本没有变化。
掌握 AJAX 通信的概念
在处理 AJAX 通信时,理解几个关键概念至关重要:
- 异步操作。
- 超文本传输协议,也称为 HTTP。
- JSON、URL 编码和多部分格式编码。
- 同源政策。
除了对 web 套接字的介绍之外,前两项将在本节中直接讨论(web 套接字不像其他一些概念那样重要,但仍然有潜在的用处)。列表中的最后两个将在本章稍后讨论。
异步很难
根据我在 AJAX 通信方面的丰富经验,以及对其他开发人员在 web API 方面的观察,这个特性最吸引人的地方也是最令人困惑的地方。JavaScript 对异步操作的抽象不如其他更传统的语言,比如 Java。除了对发生在带外的任务(比如 AJAX 请求)缺乏直观的本地支持之外,目前有三种不同的常见方法来处理这些类型的异步操作。这些方法包括回调、承诺和异步函数。尽管对异步操作的本机支持已经随着时间的推移而改进,但大多数开发人员仍然必须显式地处理这些类型的任务,这可能是一个挑战,因为它通常需要相应地构造所有周围的代码。这通常会使软件开发人员处理异步调用的工作变得笨拙,并导致代码变得复杂。这当然增加了风险,并可能给底层应用带来更多的错误。
回调将在本章中演示,承诺也是如此。承诺和回调都在第十一章中有更详细的介绍,还有异步函数,这是 ECMAScript 2017 规范中定义的一个功能,旨在使处理异步操作(如 AJAX 请求)变得异常简单。然而,一些开发人员没有使用异步函数的奢侈(由于截至 2016 年缺乏当前的浏览器支持),因此处理 AJAX 请求的现实仍然是,您必须接受它们的异步特性,而不是躲避它。起初,这很令人费解。即使在您成功地掌握了这个概念之后,也要预料到在不太重要的情况下会经常遇到挫折,比如在处理嵌套的异步请求时。如果这一点在之前的经历中还不清楚,当你完成这一章的时候,你甚至会意识到这种复杂性。尽管如此,在处理 AJAX 请求时,这个概念可能是最重要的。
超文本传送协议
用于浏览器和服务器之间通信的主要协议当然是 HTTP,它代表超文本传输协议。Web 之父蒂姆·伯纳斯·李于 1991 年创建了第一个官方 HTTP 规范 4 。第一个版本是和 HTML 一起设计的,第一个 web 浏览器只有一个方法:GET。当浏览器请求一个页面时,将发送一个 GET 请求,服务器将使用构成请求页面的 HTML 进行响应,然后 web 浏览器将呈现该页面。在 AJAX 作为补充规范被引入之前,HTTP 主要局限于这个工作流。
尽管 HTTP 最初只有一个方法——GET——但随着时间的推移,又增加了几个。目前,HEAD、POST、PUT、DELETE 和 PATCH 都是当前规范第 2 版的一部分,该规范由互联网工程任务组(IETF)维护为 RFC 7540。 5 GET 请求应该有一个空的消息体(请求有效负载),以及一个描述请求 URI(通用资源指示符)中引用的资源的响应。这是一种“安全”的方法,因此在处理该请求时,服务器端不会对资源进行任何更改。HEAD 与 GET 非常相似,只是它返回一个空的消息体。然而,HEAD 是有用的,因为它包括一个响应头—Content-Length
—其值等于请求被 GET 所传输的字节数。例如,这对于在不实际返回整个文件的情况下检查文件的大小很有用。头,正如你所料,也是一个“安全”的方法。
DELETE、PUT、POST 和 PATCH 是不安全的,因为它们可能会更改服务器上的相关资源。在这四个“不安全”的方法中,有两个——PUT 和 DELETE——被认为是幂等的,这意味着即使它们被多次调用,它们也将总是产生相同的结果。PUT 通常用于替换资源,而 DELETE 显然用于删除资源。PUT 请求应该有一个描述更新的资源内容的消息体,而 DELETE 不应该有有效负载。POST 不同于 PUT,它将创建一个新的资源。最后,补丁,一种相对较新的 HTTP 请求方法, 6 允许以非常特定的方式修改资源。这个请求的消息体准确地描述了应该如何修改资源。PATCH 不同于 PUT 方法,因为它不完全替换引用的资源。
所有 AJAX 请求都将使用其中一种方法与服务器进行动态通信。请注意,旧浏览器可能不支持补丁等新方法。在本章的后面,我将更详细地介绍如何正确使用这些方法,以及如何将 AJAX 请求规范与这些方法结合使用,以生成高度动态的 web 应用。
预期和意外反应
理解客户机和服务器之间的通信协议是一个非常重要的概念。当然,发送请求是其中的一部分,但是对这些请求的响应也同样重要。请求有消息头和可选的消息体(有效负载),而响应由三部分组成:响应消息体、消息头和状态代码。状态代码对于响应是唯一的,并且通常可以在底层传输实例上访问(比如XMLHttpRequest
或fetch
)。状态代码通常是三位数,通常可以根据最高有效位进行分类。200 级状态代码表示成功,300 用于重定向,而 400 级和 500 级状态表示某种错误。这些都在 RFC 2616 中有详细的正式定义。 7
正如用try
/ catch
块处理代码异常很重要一样,处理异常的 AJAX 响应也同样重要。虽然 200 级的响应通常是预期的,或者至少是期望的,但是您还必须考虑意外的或者不期望的响应,比如 400 级和 500 级,或者甚至是状态为0
的响应(如果请求由于网络错误而终止或者服务器返回完全空的响应,这种情况可能会发生)。我观察到,简单地忽略异常情况似乎很常见,这不仅限于 HTTP 响应的处理。其实我自己也为此感到内疚。
Web 套接字
与传统的 AJAX 请求相比,Web 套接字是一个相对较新的 web API 特性。它们于 2011 年由 IETF(互联网工程任务组)在 RFC 6455 8 中首次标准化,目前受到除 Internet Explorer 9 之外的所有现代浏览器的支持。Web 套接字在许多方面不同于纯粹的 HTTP 请求,最显著的是它们的生存期。尽管 HTTP 请求的寿命通常很短,但 web 套接字连接意味着在应用实例或网页的生命周期内保持开放。web 套接字连接以 HTTP 请求开始,这是初始握手所必需的。但是在这个握手完成之后,客户机和服务器就可以随意交换数据了,无论它们同意什么格式。这个 web 套接字协议允许客户端和服务器之间真正的实时通信。尽管本章没有更深入地探讨 web 套接字,但我觉得至少提到它们是有用的,因为它们确实说明了 JavaScript 发起的异步通信的另一种方法。
发送获取、发布、删除、上传和修补请求
jQuery 通过恰当命名的ajax()
方法为 AJAX 请求提供了一流的支持。通过这种方法,您可以发送任何类型的 AJAX 请求,但是 jQuery 也为一些标准化的 HTTP 请求方法提供了别名——比如get()
和post()
——这样可以节省一些击键的时间。web API 提供了两个对象,XMLHttpRequest
和fetch
,用于从浏览器向服务器发送任何类型的异步请求。所有浏览器都支持XMLHttpRequest
,但fetch
相对较新,并非所有现代浏览器都支持,尽管有一种实心 polyfill 可为所有浏览器提供支持。
使用 jQuery 向服务器端点发出一个简单的 GET 请求,带有一个简单的响应处理程序,如下所示:
1 $.get('/my/name').then(
2 function success(name) {
3 console.log('my name is ' + name);
4 },
5 function failure() {
6 console.error('Name request failed!');
7 }
8 );
Note
除非开发人员工具已打开,否则在 Internet Explorer 9 和更早版本中,console
对象不可用。
在前面的代码中,我们向“/my/name”服务器端点发送一个 GET 请求,并期待一个包含名称值的明文响应,然后我们将它打印到浏览器控制台。如果请求失败(比如服务器返回一个错误状态代码),就会调用failure
函数。在这种情况下,我使用 jQuery 的ajax()
方法及其别名返回的promise-like object (or “thenable”)
。jQuery 提供了几种处理响应的方法,但是我将特别关注前面演示的那种。第十一章讲述了更多关于承诺的内容,这是 JavaScript 的标准化部分。
同样的请求,在没有 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
。不过,这可能是一个错误响应,所以我们必须通过查看响应状态代码来确认。如果请求在某个非常低的级别失败,比如由于 CORS 错误,就会调用onerror
。onload
属性是我们可以轻松设置响应处理程序的地方。从这里,我们可以确保响应已经完成,然后可以确定请求是否成功,并获得响应数据的句柄。所有这些都可以在我们创建并分配给xhr
变量的XMLHttpRequest
实例中获得。从前面的代码中,您可能会对 web API 不支持像 jQuery 这样的承诺感到有点失望。这是真的,直到 WHATWG 创建了fetch
API 9 。Fetch API 为老化的XMLHttpRequest
传输提供了一个现代的本地替代品,目前它受到 Firefox、Chrome、Opera 和 Microsoft Edge 的支持,Safari 的支持也即将到来。让我们用fetch
来看看这个例子:
1 fetch('/my/name').then(function(response) {
2 if (response.ok) {
3 return response.text();
4 }
5 else {
6 throw new Error();
7 }
8 }).then(
9 function success(name) {
10 console.log('my name is ' + name);
11 },
12 function failure() {
13 console.error('Name request failed!');
14 }
15 );
代码不仅包含了 promises 规范,还删除了很多常见于XMLHttpRequest
的样板文件。在这一章中,你会看到更多关于fetch
的例子。请注意,除了方法说明符之外,前面的任何示例对于 HEAD 请求都是相同的。您还会注意到fetch
返回一个Promise
,类似于 jQuery 的ajax()
。不同之处在于,当执行非成功状态代码时,jQuery 会执行 error 函数。fetch
仅当发生低级网络错误时,才会像XMLHttpRequest
一样执行此操作。但是,当服务器返回一个带有错误代码的响应时,我们可以通过传递给第一个 promise 处理程序的Response
对象上的ok
属性,通过抛出一个异常来触发我们的错误函数。
许多通过 jQuery 学习 web 开发的开发人员可能认为,当您调用$.ajax()
方法时,这个库正在做一些神奇而复杂的事情。这与事实相去甚远。所有繁重的工作都由浏览器通过XMLHttpRequest
对象来完成。jQuery 的ajax()
只是对XMLHttpRequest
的包装。使用浏览器对 AJAX 请求的内置支持并不困难,您马上就会看到这一点。即使是跨来源的请求,没有 jQuery 也不简单——您将看到没有 jQuery 它们实际上是如何变得更容易。
发送发布请求
我已经演示了如何使用 jQuery、XMLHttpRequest
和fetch
从服务器端点获取信息。但是其他一些可用的 HTTP 方法呢?假设您想向服务器添加一个新名称,而不是获取一个名称。对于这种情况,最合适的方法是 POST。要添加的名称将包含在我们的请求有效负载中,这是发送帖子时包含此类数据的常见位置。为了简单起见,我们将使用文本/普通的 MIME 类型来发送名称(我将在本章后面介绍更高级的编码技术)。我在这里也将省略响应处理代码,以便我们可以专注于关键概念:
1 $.ajax({
2 method: 'POST',
3 url: '/user/name',
4 contentType: 'text/plain',
5 data: 'Mr. Ed'
6 });
我们使用 jQuery 的通用ajax()
方法,所以我们可以指定各种参数,比如请求的Content-Type
头。这将发送一个 POST 请求,其明文正文包含文本“Mr. Ed”。在这种情况下,我们必须显式指定Content-Type
,因为 jQuery 会将这个头设置为“application/x-www-form-urlencoded ”,这不是我们想要的。
使用XMLHttpRequest
的相同 POST 请求如下所示:
1 var xhr = new XMLHttpRequest();
2 xhr.open('POST', '/user/name');
3 xhr.send('Mr. Ed');
不使用 jQuery 发送这个请求实际上需要更少的代码行。默认情况下,XMLHttpRequest
10将Content-Type
设置为“text/plain ”,因此我们不需要弄乱任何请求头。我们可以方便地将请求体作为参数传递给send
方法,如前所述。
如果你的目标是接受最新最好的网络标准,你可以试着用fetch
来发送这篇文章:
1 fetch('/user/name', {
2 method: 'POST',
3 body: 'Mr. Ed'
4 });
发送这个请求看起来类似于 jQuery,但是没有太多的样板文件。像XMLHttpRequest
,fetch
基于请求负载智能地设置请求Content-Type
(在某些情况下),所以我们不需要指定这个请求头。 11
发送上传请求
POST 请求通常用于创建新资源,而 PUT 请求通常用于替换现有资源。例如,PUT 更适合于替换现有产品的信息。请求的 URI 标识要用位于主体中的新信息替换的资源。为了简单地说明使用 jQuery、XMLHttpRequest
和fetch
发送 PUT 请求,我将演示更新现有用户记录的手机号码:
1 $.ajax({
2 method: 'PUT',
3 url: '/user/1',
4 contentType: 'text/plain',
5 data: //complete user record including new mobile number
6 });
这个使用 jQuery 的 PUT 请求看起来与之前展示的 POST 几乎相同,除了method
属性。这个用户是通过他们的 ID 来标识的,这个 ID 恰好是 1。看到用XMLHttpRequest
发送 PUT 与前面的例子相似,您可能不会感到惊讶:
1 var xhr = new XMLHttpRequest();
2 xhr.open('PUT', '/user/1');
3 xhr.send(/* complete user record including new mobile number */);
正如所料,Fetch API 提供了最简洁的方法:
1 fetch('/user/1', {
2 method: 'PUT',
3 body: //complete user record including new mobile number
4 });
发送删除请求
删除请求类似于放置请求,因为要操作的资源是在请求 URI 中指定的。主要区别在于,尽管 RFC 7231 没有明确规定,但删除请求通常不包含消息体。IETF 文档提到了消息体实际上给请求者带来问题的可能性: 13
- 删除请求消息中的负载没有定义的语义;在删除请求中发送有效负载正文可能会导致一些现有的实现拒绝该请求。
这意味着,除了要操作的资源之外,指定参数的唯一安全方法是将它们作为查询参数包含在请求 URI 中。除了这种例外情况,删除请求以与 put 相同的方式发送。和 PUT 请求一样,DELETE 请求也是幂等的。请记住,幂等请求无论被调用多少次,其行为都是一样的。如果删除同一资源的多次调用导致例如删除不同的资源,这将是非常令人惊讶的。
使用 jQuery 删除资源的请求如下所示:
1 $.ajax('/user/1', {method: 'DELETE'});
同样的简单请求,使用XMLHttpRequest
,只需要两行额外的代码就可以实现:
1 var xhr = new XMLHttpRequest();
2 xhr.open('DELETE', '/user/1');
3 xhr.send();
最后,我们可以使用非常优雅的 Fetch API 在许多现代浏览器中本地发送这个删除请求(或者在任何带有对fetch
(在 GitHub 上维护) 14 的 polyfill 的浏览器中)
1 fetch('/user/1', {method: 'DELETE'});
用fetch
发送这个请求就和$.ajax()
一样简单;我们可以很容易地在一行中写出全部内容,而不会失去可读性。
发送补丁请求
如前所述,补丁请求在 HTTP 场景中相对较新,用于更新现有资源的一部分。以我们之前的 PUT 请求为例,我们只想更新用户的手机号码,但是还必须在我们的 PUT 请求中包含所有其他用户数据。对于小记录,这可能没问题,但是对于大记录,这可能是对带宽的浪费。一种方法可能是为用户数据的每个部分定义特定的端点,或者根据 URI 查询参数确定要更新的数据,但这只会使我们的 API 变得混乱。对于这种情况,最好使用补丁请求。
让我们重新看看 PUT 示例,我们需要更新一个现有用户的手机号码,这次用的是 PATCH。jQuery 方法——使用简单的基于明文的键值消息体来指示要随新属性值一起更改的属性——如下所示:
1 $.ajax({
2 method: 'PATCH',
3 url: '/user/1',
4 contentType: 'text/plain',
5 data: 'mobile: 555-5555'
6 });
Note
如果您的底层模型是 JSON,那么发送一个 JSON 补丁文档更合适。 15 但是我们还没怎么谈 JSON。我将在这一章的后面讨论这个问题。
请记住,我们可以使用我们为补丁请求主体选择的任何格式来指定要更新的数据,只要客户机和服务器达成一致。如果我们更喜欢使用XMLHttpRequest
,同样的请求看起来像这样:
1 var xhr = new XMLHttpRequest();
2 xhr.open('PATCH', '/user/1');
3 xhr.send('mobile: 555-5555');
为了完整起见,我将向您展示如何使用 Fetch API 发送完全相同的请求:
1 fetch('/user/1', {
2 method: 'PATCH',
3 body: 'mobile: 555-5555'
4 });
编码请求和读取编码的响应
在上一节中,我谈到了所有编码方法中最简单的一种——text/plain——它是用于构成 HTTP 请求或响应的无格式文本的多用途 Internet 邮件扩展(MIME)。纯文本的简单是福也是祸。也就是说,它很容易使用,但只适合非常小和简单的情况。缺乏任何标准化的结构限制了它的表现力和实用性。对于更复杂(也更常见)的请求,有更合适的编码类型。在本节中,我将讨论另外三种 MIME 类型:“application/x-www-form-urlencoded”、“application/json”和“multipart/form-data”。在本节结束时,您不仅会熟悉这些额外的编码方法,还会理解如何在没有 jQuery 的情况下编码和解码消息,尤其是在发送 HTTP 请求和解析响应时。
URL 编码
URL 编码可以发生在请求的 URL 中,也可以发生在请求/响应的消息体中。这种编码方案的 MIME 类型是“application/x-www-form-urlencoded”,数据由简单的键/值对组成。每个键和值由等号(=
)分隔,而每对键和值由&符号(&
)分隔。但是编码算法远不止这些。键和值可以进一步编码,这取决于它们的字符构成。非 ASCII 字符以及一些保留字符被替换为百分号(%
),后跟字符相关字节的十六进制值。W3C HTML5 规范的 HTML 表单部分对此做了进一步的定义。 16 这个描述可能有点过于简单,但对于本章的目的来说,它足够恰当和全面。如果您想了解更多关于这种 MIME 类型的编码算法,请看看规范,虽然它有点枯燥,可能需要一些阅读才能正确解析。
对于 GET 和 DELETE 请求,URL 编码的数据应该包含在 URI 的末尾,因为这些请求方法通常不应该包含有效负载。对于所有其他请求,消息体是放置 URL 编码数据的最合适位置。在这种情况下,请求或响应必须包含“application/x-www-form-urlencoded”的Content-Type
头,这是编码方案的 MIME 类型。URL 编码的消息预计相对较小,尤其是在处理 GET 和 DELETE 请求时,因为现实世界中浏览器和服务器上存在 URI 长度限制。 17 虽然这种编码方式比 text/plain 更优雅,但是缺乏层次性意味着这些消息在某种程度上也限制了它们的表现力。但是,可以使用括号将子属性绑定到父属性。例如,具有一组子键/值对(即“child1”和“child2”)的值的“parent”键可以编码为“parent[child 1]= child 1 val&parent[child 2]= child 2 val”。事实上,这就是 jQuery 在将 JavaScript 对象编码成 URL 编码的字符串时所做的事情。
jQuery 的 API 提供了一个函数,该函数获取一个对象并将其转换为 URL 编码的字符串:$.param
。例如,如果我们想将一对简单的键/值对编码成一个 URL 编码的字符串,我们的代码应该是这样的:
1 $.param({
2 key1: 'some value',
3 'key 2': 'another value'
4 });
这一行将产生一个字符串“key 1 = some+value & key+2 = other+value”。application/x-www-form-urlencoded MIME 类型的规范声明空格是保留字符,应该转换为“加号”字符。然而,在实践中,ASCII 字符代码也是可以接受的。因此,同样的一对键/值对也可以表示为“key1 =某个%20 值& key % 202 =另一个% 20 值”。当我用 web API 介绍 URL 编码时,您将会看到一个这样的例子。
如果我们想要创建一个具有三个属性的新用户——姓名、地址和电话——我们可以向我们的服务器发送一个 POST 请求,其中包含 URL 编码的请求正文,其中包含新用户的信息。对于 jQuery,请求看起来像这样:
1 $.ajax({
2 method: 'POST',
3 url: '/user',
4 data: {
5 name: 'Mr. Ed',
6 address: '1313 Mockingbird Lane',
7 phone: '555-555-5555'
8 }
9 });
jQuery 确实让这变得相对直观,因为它允许您传入一个描述新用户的 JavaScript 对象。没有必要使用$.param
方法。如前所述,jQuery 的$.ajax()
API 方法假设一个“application/x-www-form-urlencoded”的Content-Type
,它将您的data
属性的值编码为自动匹配这个假设。在这种情况下,您根本不必考虑编码或编码类型。
尽管 web API 确实要求您了解编码类型,并且要求您在发送请求之前对数据进行编码,但是这些任务并不太复杂。我已经向您展示了 jQuery 如何允许您使用$.param()
将一个文本字符串编码为“application/x-www-form-urlencoded”——并且您可以使用全局名称空间上可用的encodeURI()
和encodeURIComponent()
方法在没有 jQuery 的情况下完成同样的工作。这些方法在 ECMAScript 规范中定义,并且自 1999 年完成的 ECMA-262 第三版规范、 18 以来就已经可用。
encodeURI()
和encodeURIComponent()
都执行相同的常规任务——URL 编码一个字符串。但是它们各自决定了字符串的哪一部分编码有所不同,所以它们与特定的用例联系在一起。encodeURI()
意在用于一个完整的 URL,比如或一串由一个&符号(&
)分隔的键值对,比如“first=ray & last=nicholus”。然而,encodeURIComponent()
只用于需要 URL 编码的单个值,比如“雷·尼科尔斯”或“艾德先生”。如果您使用encodeURIComponent()
对本段前面列出的完整 URL 进行编码,那么冒号、正斜杠、问号和&符号都将被 URL 编码,这可能不是您想要的(除非整个 URL 本身就是一个查询参数)。
回顾一下本节中使用 jQuery 的简单 URL 编码示例,我们可以使用 web API 使用encodeURI()
对相同的数据进行编码:
1 encodeURI('key1=some value&key 2=another value');
关于encodeURI
的输出,它产生“key 1 = some % 20 value&key % 202 = another % 20 value”。首先,请注意,jQuery 用加号(+
)替换空格,encodeURI()
(和encodeURI-Component
)用“%20”替换空格。这是完全正确的,但也是一个显著的区别。其次,jQuery 允许将数据编码为 JavaScript 对象,encodeURI()
要求用等号(=
)将键与值分开,用 and 符号(&
)将键/值对分开。更进一步,我们可以复制之前发送的相同 POST 请求,添加一个新名称,首先使用XMLHttpRequest
:
1 var xhr = new XMLHttpRequest(),
2 data = encodeURI(
3 'name=Mr. Ed&address=1313 Mockingbird Lane&phone=555-555-5555');
4 xhr.open('POST', '/user');
5 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
6 xhr.send(data);
XMLHTTPRequest
路由和 jQuery 的$.ajax()
路由的另一个显著区别是,我们还必须设置请求头的Content-Type
头。jQuery 默认为我们设置,需要让服务器知道如何解码请求数据。幸运的是,XMLHttpRequest
提供了一种设置请求头的方法——名副其实的setRequestHeader()
。
我们可以从 Fetch API 中获得一些相同的好处,但是我们仍然需要自己编码。不用担心——这可以很容易地完成:
1 var data =
2 encodeURI('name=Mr. Ed&address=1313 Mockingbird Lane&phone=555-555-5555');
3 fetch('/user', {
4 method: 'POST',
5 headers: {'Content-Type': 'application/x-www-form-urlencoded'},
6 body: data
7 });
与XMLHttpRequest
一样,Content-Type
头也必须在这里指定,因为fetch
发起的带有String
正文的请求的默认Content-Type
也是“文本/普通”。但是,Fetch API 允许 AJAX 请求以更优雅、更简洁的形式构造,类似于 jQuery 已经提供了一段时间的解决方案。Fetch
在 Firefox,Chrome,Opera,和 Edge 都支持,有一个开放的案例增加了对 Safari 的支持。 19 在不久的将来,XMLHttpRequest
将成为历史的神器,与 jQuery 的 AJAX 支持相匹敌的fetch
将成为 AJAX 传输的首选。
JSON 编码
JavaScript 对象符号,更好的说法是 JSON,被认为是一种“数据交换语言”(如 json.org 上所描述的)。如果这个晦涩的描述让你有点困惑,不要难过——这不是一个特别有用的总结。可以把 JSON 想象成一个转化成字符串的 JavaScript 对象。还有一点需要解释,但这在我看来是一个合理的高层次定义。在 web 开发中,如果基于浏览器的客户端希望轻松地将 JavaScript 对象发送到服务器端点,这将非常有用。反过来,服务器可以响应来自这个客户机的请求,并在响应体中提供 JSON,客户机可以很容易地将其转换成 JavaScript 对象,以便进行简单的编程解析和操作。虽然“application/x-www-form-urlencoded”要求数据以平面格式表示(或者用非标准括号符号表示父/子关系),但“application/json”允许数据以层次格式表示。一个键可以有许多子键,这些子键也可以有子键。从这个意义上说,JSON 比 URL 编码的数据更具表现力,而 URL 编码的数据本身比纯文本更具表现力和结构化。
如果您还不熟悉这种通用的数据格式,让我们用 JSON 表示一个用户:
1 {
2 "name": "Mr. Ed",
3 "address": "1313 Mockingbird Lane",
4 "phone": {
5 "home": "555-555-5555"
6 "mobile": "444-444-4444"
7 }
8 }
注意,JSON 让我们能够轻松地表达子键,这是明文和 URL 编码的字符串所缺乏的特性。Ed 先生有两个电话号码,使用 JSON,我们可以很好地将这两个号码与父“电话”属性关联起来。从上一节扩展我们的 AJAX 示例,让我们使用 jQuery 向我们的服务器添加一个新的名称记录,这次使用 JSON 编码的有效负载:
1 $.ajax({
2 method: 'POST',
3 url: '/user',
4 contentType: 'application/json',
5 data: JSON.stringify({
6 name: 'Mr. Ed',
7 address: '1313 Mockingbird Lane',
8 phone: {
9 home: '555-555-5555',
10 mobile: '444-444-4444'
11 }
12 });
13 });
请注意,我们的代码看起来比前一个 URL 编码的示例要难看一些。有两个原因,一个是我们必须通过一个contentType
属性显式地指定Content-Type
头,这个抽象实际上没有太大的帮助。其次,我们必须离开舒适熟悉的 jQuery API,将 JavaScript 对象转换成 JSON。因为 jQuery 没有提供实现这一点的 API 方法(这很奇怪,因为它提供了一种将 JSON 字符串转换成 object - $的方法。parseJSON()),我们必须利用JSON
对象,它已经被标准化为 ECMAScript 规范的一部分。JSON
对象提供了两种将 JSON 字符串转换成 JavaScript 对象的方法。它最早出现在 ECMAScript 5.1 规范中, 20 ,这意味着它在 Node.js 以及所有现代浏览器中都受到支持,包括 Internet Explorer 8。在前面的 jQuery POST 示例中使用的JSON.stringify()
方法获取用户记录,该记录表示为一个 JavaScript 对象,并将其转换为适当的 JSON 字符串。在我们将记录发送到服务器之前,这是必需的。
如果您想发送一个接收 JSON 数据的 GET 请求,那么使用get
JSON
就可以很简单地做到:
1 $.getJSON('/user/1', function(user) {
2 // do something with this user JavaScript object
3 });
不使用 jQuery 发送之前的 POST 请求怎么办?首先,用XMLHttpRequest
:
1 var xhr = new XMLHttpRequest(),
2 data = JSON.stringify({
3 name: 'Mr. Ed',
4 address: '1313 Mockingbird Lane',
5 phone: {
6 home: '555-555-5555',
7 mobile: '444-444-4444'
8 }
9 });
10 xhr.open('POST', '/user');
11 xhr.setRequestHeader('Content-Type', 'application/json');
12 xhr.send(data);
XHR 没什么好惊讶的。这个尝试看起来非常类似于我们在上一节中发送的 URL 编码的 POST 请求,除了字符串化我们的 JavaScript 对象和设置适当的Content-Type
头。正如您已经看到的,无论我们是否使用 jQuery,我们都必须解决相同的问题。
但是发送 JSON 编码的请求只是所需知识的一半。我们也必须准备好接收和解析 JSON 响应。看起来就像这样:
1 var xhr = new XMLHttpRequest();
2 xhr.open('GET', '/user/1');
3 xhr.onload = function() {
4 var user = JSON.parse(xhr.responseText);
5 // do something with this user JavaScript object
6 };
7 xhr.send();
请注意,我们发送了一个获取用户数据的请求,并期望服务器以 JSON 编码的字符串形式返回该记录。在前面的代码中,我们利用了onload
函数,该函数将在请求完成时被调用。此时,我们可以通过 XHR 实例上的responseText
属性获取响应体。要将它转换成合适的 JavaScript 对象,我们必须使用 JSON 对象的另一个方法— parse()
。在现代浏览器中(除了 Internet Explorer),用XMLHttpRequest
接收 JSON 数据甚至更容易:
1 var xhr = new XMLHttpRequest();
2 xhr.open('GET', '/user/1');
3 xhr.onload = function() {
4 var user = xhr.response;
5 // do something with this user JavaScript object
6 };
7 xhr.send();
前面的例子假设服务器已经包含了“application/json”的Content-Type
头。这让XMLHttpRequest
知道如何处理响应数据。它最终将其转换成一个 JavaScript 对象,并使转换后的值在我们的XMLHttpRequest
实例的response
属性上可用。
最后,我们可以使用 Fetch API 将这个新的用户记录添加到我们的服务器,同样使用 JSON 编码的请求:
1 fetch('/user', {
2 method: 'POST',
3 headers: {'Content-Type': 'application/json'},
4 body: JSON.stringify({
5 name: 'Mr. Ed',
6 address: '1313 Mockingbird Lane',
7 phone: {
8 home: '555-555-5555',
9 mobile: '444-444-4444'
10 }
11 });
12 });
这并不奇怪,也很简单,但是从我们的服务器收到 JSON 编码的响应怎么办?让我们使用fetch
发送一个简单的 GET 请求来获取一个用户记录,期望我们的服务器将响应一个编码为 JSON 字符串的用户记录:
1 fetch('/user/1').then(function(request) {
2 return request.json();
3 }).then(function(userRecord) {
4 // do something with this user JavaScript object
5 });
Fetch API 为我们约定的回调提供了一个Request
对象。 21 因为我们期待 JSON,所以我们在这个对象上调用json()
方法,它本身返回一个Promise
。注意,json()
方法实际上是在Body
接口上定义的。22Request
对象实现了Body
接口,所以我们在这里可以访问两个接口上的方法。通过返回该承诺,我们可以链接另一个约定的处理程序,并期望在最后一次成功回调中接收响应有效负载的 JavaScript 对象表示作为参数。现在我们有了来自服务器的用户记录。挺简单大方的!同样,如果承诺仍然有点含糊不清,不要担心——我会在第十一章中详细介绍。
有趣的是,ECMAScript 2016 提供了使用替代语法的能力,使上述(任何其他代码示例)更加优雅。下面,我用“箭头函数”重写了前面的例子:
1 fetch('/user/1')
2 .then(request => request.json())
3 .then(userRecord => {
4 // do something with this userRecord object
5 });
箭头函数超出了本书的范围,但是我认为向还不知道这种语言特性的读者指出这一点会很好。注意,并不是所有的浏览器都支持箭头函数,所以您可能需要使用一个编译时编译器来将这些函数转换成“传统的”函数。一些 JavaScript 编译器包括 TypeScript、Closure 编译器、Babel 和 Bublè。其中一些会在本书后面提到,比如第十一章。
多部分编码
另一种通常与 HTML 表单提交相关的常见编码方案是 multipart/- form-data,也称为 multipart 编码。IETF 在 RFC 2388 中正式定义了这种数据传递方法的算法。 23 该算法在 HTML 表单上下文中的使用由 W3C 在 HTML 规范中进一步描述。多部分/格式数据消息中的非 ASCII 字符不必转义。相反,消息的每一部分都被分割成字段,每个字段都包含在一个多部分边界内。边界由浏览器生成的唯一 ID 分隔,并且保证它们在请求中的所有其他数据中是唯一的,因为浏览器分析请求中的数据以确保不会生成冲突的 ID。多部分编码消息中的字段通常是 HTML <form>
字段。每个字段都位于其自己的多部分边界内。每个边界内部都有一个标题,其中存放着关于字段的元数据(如名称/键),还有一个正文(其中存放着字段值)。
考虑以下形式:
1 <form action="my/server" method="POST" enctype="multipart/form-data">
2 <label>First Name:
3 <input name="first">
4 </label>
5
6 <label>Last Name:
7 <input name="last">
8 </label>
9
10 <button>Submit</button>
11 </form>
当点击提交按钮时,任何输入的数据都将被提交到服务器。假设用户在第一个文本输入中输入“Ray”,在第二个文本输入中输入“Nicholus”。点击提交后,请求正文可能如下所示:
1 -----------------------------1686536745986416462127721994
2 Content-Disposition: form-data; name="first"
3
4 Ray
5 -----------------------------1686536745986416462127721994
6 Content-Disposition: form-data; name="last"
7
8 Nicholus
9 -----------------------------1686536745986416462127721994--
服务器知道如何通过在请求体中查找标记每个部分的惟一 ID 来找到每个表单字段。这个 ID 由浏览器包含在Content-Type
头中,在本例中是“multipart/form-data;边界=————————1686536745986416462127721994”。请注意,邮件正文的 MIME 类型由多部分边界 ID 用分号分隔。
但是 HTML 表单提交并不是我们希望使用 multipart/form-data MIME 类型编码请求消息的唯一实例。因为这种 MIME 类型在所有服务器端语言中实现起来都很简单,所以它可能是从客户端向服务器传输键/值对的安全选择。但是,最重要的是,多部分编码非常适合将键/值对与二进制数据(如文件)混合在一起。我将在下一节中讨论更多关于上传文件的内容。
那么我们如何使用 jQuery 的$.ajax()
方法发送多部分编码的请求呢?很快您就会看到,这很难看,而且 jQuery 通常提供的抽象层在这种情况下是不完整的,因为无论如何您都必须直接委托给 web API。继续前面的一些例子,让我们向我们的服务器发送一个新的用户记录——一个由用户名、地址和电话号码组成的记录:
1 var formData = new FormData();
2 formData.append('name', 'Mr. Ed');
3 formData.append('address', '1313 Mockingbird Lane');
4 formData.append('phone', '555-555-5555');
5
6 $.ajax({
7 method: 'POST',
8 url: '/user',
9 contentType: false,
10 processData: false,
11 data: formData
12 });
要发送一个多部分编码的 AJAX 请求,我们必须发送一个包含我们的键/值对的FormData
对象,浏览器会处理剩下的事情。这里没有 jQuery 抽象;您必须直接使用 web API 的FormData
。请注意,Internet Explorer 9 不支持FormData
。缺乏抽象是 jQuery 的一个漏洞,尽管FormData
相对直观且非常强大。事实上,您可以向它传递一个<form>
元素,键/值对就会为您创建,并准备好异步提交到您的服务器。Mozilla Developer Network 在FormData
上有一篇很棒的文章。更多细节你应该读一下。
用 jQuery 发送 MPE 请求的最大问题是必须设置模糊的选项才能让它工作。processData: false
?这到底是什么意思?如果不设置这个选项,jQuery 会尝试将FormData
转换成 URL 编码的字符串。至于contentType: false
,这是确保 jQuery 不插入自己的内容类型头所必需的。请记住引言部分,浏览器必须为您指定内容类型,因为它包含服务器解析请求时使用的计算出的多部分边界 ID。
同样的请求和普通的旧的XMLHttpRequest
没有什么不同,坦率地说,并不比 jQuery 的解决方案更直观:
1 var formData = new FormData(),
2 xhr = new XMLHttpRequest();
3
4 formData.append('name', 'Mr. Ed');
5 formData.append('address', '1313 Mockingbird Lane');
6 formData.append('phone', '555-555-5555');
7
8 xhr.open('POST', '/user');
9 xhr.send(formData);
事实上,使用 XHR 会产生更少的代码,我们不必包含无意义的选项,比如contentType: false
和processData: false
。正如所料,Fetch API 甚至更简单:
1 var formData = new FormData();
2 formData.append('name', 'Mr. Ed');
3 formData.append('address', '1313 Mockingbird Lane');
4 formData.append('phone', '555-555-5555');
5
6 fetch('/user', {
7 method: 'POST',
8 body: formData
9 });
看到了吗?如果你能稍微看看 jQuery 之外的东西,你会发现 web API 并不总是像有些人说的那样可怕。在这种情况下,jQuery 的 API 就显得不够优雅。
上传和操作文件
异步文件上传,这个我颇有经验的话题, 26 是 jQuery 经常无法有效包装 web API 并为用户提供证明使用该库的体验的又一个例子。这确实是一个复杂的主题,虽然我不能在这里涵盖与文件上传相关的所有内容,但我一定会解释基本知识,并展示如何在现代浏览器和古代浏览器中使用 jQuery、XMLHttpRequest
和fetch
上传文件。在这种特殊且有点不寻常的情况下,请注意 Internet Explorer 9 被排除在“现代浏览器”的定义之外。这样做的原因很快就会清楚了。
在古代浏览器中上传文件
在我们开始在旧浏览器中上传文件之前,让我们定义一个非常重要的术语:浏览上下文。例如,浏览上下文可以是window
或iframe
。因此,如果我们有一个window
,并且在这个window
中有一个iframe
,我们就有两个浏览上下文:父window
和子iframe
。
在古代浏览器(包括 Internet Explorer 9)中上传文件的唯一方法是在<form>
中包含一个<input type="file">
元素并提交这个表单。默认情况下,服务器对表单提交的响应会替换当前的浏览上下文。当使用高度动态的单页面 web 应用时,这是不可接受的。我们需要能够在旧浏览器中上传文件,并且仍然保持对当前浏览上下文的完全控制。不幸的是,没有办法阻止表单提交替换当前的浏览上下文。但是我们当然可以创建一个子浏览上下文,在这里我们提交表单,然后监视这个浏览上下文,通过监听更改来确定我们的文件何时被上传。
这种方法很容易实现,只需让表单指向文档中的一个<iframe>
。为了确定文件何时完成上传,将一个“onload”事件处理程序附加到<iframe>
。为了演示这种方法,我们需要做一些假设,以使这相对不那么痛苦。首先,假设我们的主浏览上下文包含如下所示的标记片段:
1 <form action="/upload"
2 method="POST"
3 enctype="multipart/form-data"
4 target="uploader">
5
6 <input type="file" name="file">
7
8 </form>
9
10 <iframe name="uploader" style="display: none;"></iframe>
请注意,enctype
属性被设置为“多部分/表单数据”。您可能还记得上一节,带有文件输入元素的表单必须生成一个多部分编码的请求,以便将文件字节正确地传递给服务器。
第二个假设:我们有一个函数——upload()
——当用户通过 file input
元素选择一个文件时,这个函数被调用。我现在不打算讨论这个具体的细节,因为我们还没有讨论事件处理。我在第十章中讨论事件。
好的,那么我们如何用 jQuery 实现这一点呢?像这样:
1 function upload() {
2 var $iframe = $('IFRAME'),
3 $form = $('FORM');
4
5 $iframe.on('load', function() {
6 alert('file uploaded!')
7 });
8
9 $form.submit();
10 }
如果需要的话,我们可以用 JavaScript/jQuery 完成更多的工作,比如设置target
属性。如果我们只使用一个文件输入元素,我们也可以动态地创建表单并将文件输入移动到表单中。但是这些都是不必要的,因为我们的标记已经包含了我们需要的一切。jQuery 为我们节省了多少工作量和复杂性?让我们看一下非 jQuery 版本进行比较:
1 function upload() {
2 var iframe = document.getElementsByTagName('IFRAME')[0],
3 form = document.getElementsByTagName('FORM')[0]
4
5 iframe.onload = function() {
6 alert('file uploaded!');
7 }
8
9 form.submit();
10 }
jQuery 并没有为我们做多少事情。web API 解决方案几乎与最初的 jQuery 代码相同。在这两种情况下,我们都必须选择 iframe 和表单,附加一个onload
处理程序,在上传完成后执行一些操作,然后提交表单。在这两种情况下,我们的主要浏览上下文/窗口保持不变。服务器的响应被埋在我们隐藏的<iframe>
里。相当整洁!
在现代浏览器中上传文件
有一种更现代的方式来异步上传文件,这在所有现代浏览器中都是可能的,除了 Internet Explorer 9。由于文件 API、 27 和XMLHttpRequest
Level 2(在 API 方面对原始规范的一个小的、不间断的更新),通过 JavaScript 上传文件的能力是可能的。web API 的这两个元素都是由 W3C 规范标准化的。jQuery 并没有让上传文件变得更容易。将文件上传到浏览器的现代本地 API 优雅、易用且功能强大。jQuery 并没有试图在这里提供一个抽象层,实际上让文件上传变得有些尴尬。
典型的工作流程包括以下步骤:
- 用户通过
<input type="file" multiple>
元素选择一个或多个文件。请注意,multiple
布尔属性允许用户选择多个文件,前提是浏览器支持该属性。 - JavaScript 用于指定一个“更改”事件监听器,当用户选择一个或多个文件时会调用该监听器。
- 当调用“change”监听器时,从
<input type="file">
元素获取一个或多个文件。这些作为File
对象、 29 提供,它们扩展了Blob
接口。 30 - 使用您选择的 AJAX 传输上传
File
对象。
因为我们还没有涉及到事件,所以假设有一个函数存在,当被调用时,它发出信号,表明我们的用户已经选择了我们正在监视的<input type="file">
上的一个或多个文件。目标是上传这些文件。为了保持这个例子的重点和简单,还假设用户只能选择一个文件。这意味着我们的<input type="file">
元素将不包含multiple
布尔属性。在我刚才描述的环境中,可以使用 jQuery 在现代浏览器(除了 IE9)中上传文件,如下所示:
1 function onFileInputChange() {
2 var file = $('INPUT[type="file"]')[0].files[0];
3
4 $.ajax({
5 method: 'POST',
6 url: '/uploads',
7 contentType: false,
8 processData: false,
9 data: file
10 });
11 }
前面的代码将向“/uploads”端点发送一个 POST 请求,请求体将包含用户选择的文件的字节。同样,我们必须使用模糊的contentType: false
选项来确保 jQuery 不处理 Content-Type 头,以便浏览器可以设置它来反映文件的 MIME 类型。另外,processData: false
是防止 jQuery 对File
对象进行编码所必需的,这样会破坏我们试图上传的文件。我们也可以将文件包含在一个FormData
对象中,然后上传。如果我们需要在一个请求中上传多个文件,或者如果我们想在文件旁边轻松地包含其他表单数据,这将是一个更好的选择。
没有 jQuery,使用XMLHttpRequest
,文件上传其实简单多了:
1 function onFileInputChange() {
2 var file = document.querySelector('INPUT[type="file"]').files[0],
3 xhr = new XMLHttpRequest();
4
5 xhr.open('POST', '/uploads');
6 xhr.send(file);
7 }
与 jQuery 示例一样,我们从文件输入的files
属性中获取选择的File
,作为文件 API 的一部分,并通过将它传递给send()
方法将其发送到我们的端点,该方法从XMLHttpRequest
级别 2 开始支持Blob
。
使用 Fetch API 也可以上传文件。让我们来看看:
1 function onFileInputChange() {
2 var file = document.querySelector('INPUT[type="file"]').files[0];
3
4 fetch('/uploads', {
5 method: 'POST',
6 body: file
7 });
8 }
读取和创建文件
一般来说,熟悉 jQuery 的开发人员经常试图用 jQuery 解决他们所有的前端开发问题。他们有时看不到图书馆之外的网络。当开发人员变得依赖于这个安全网时,如果问题无法通过 jQuery 解决,通常会导致沮丧。这就是我在第一章中提到的压迫性魔法。您刚刚看到了 jQuery 在上传文件时,充其量几乎不提供任何帮助。假设您想要读取一个文件,或者甚至创建一个新文件或者修改一个现有文件以发送到服务器端点?这是 jQuery 完全没有覆盖的领域。对于读取文件,你必须依赖于FileReader
接口, 31 ,它被定义为文件 API 的一部分。在浏览器端创建“文件”需要使用Blob
构造函数。 32
最简单的FileReader
例子是向控制台读取一个文本文件,这个例子足以满足这里的演示目的。假设用户通过<input type"file">
选择了这个文本文件,文本File
对象被发送给一个函数进行输出。读取该文件并将其输出到开发人员工具控制台所需的代码包括以下代码:
1 function onTextFileSelected(file) {
2 var reader = new FileReader();
3
4 reader.onload = function() {
5 console.log(reader.result);
6 }
7
8 reader.readAsText(file);
9 }
不需要 jQuery,甚至不可能读取文件。你为什么需要它?读取文件相当容易。假设您想获取同一个文本文件,然后在将它上传到服务器之前,在文件末尾添加一些文本。令人惊讶的是,这也非常简单:
1 function onTextFileSelected(file) {
2 var modifiedFile = new Blob([file, 'hi there!'], {type: 'text/plain'});
3 // ...send modifiedFile to uploader
4 }
这里的modifiedFile
是所选文件的副本,文本为“你好!”加到最后。这是在总共一行代码中完成的。
跨域通信:一个重要的话题
随着越来越多的逻辑被卸载到浏览器,web 应用从多个 API 提取数据变得越来越常见,其中一些 API 作为第三方服务的一部分存在。一个很好的例子是一个 web 应用(像 Fine Uploader) 33 直接上传文件到亚马逊 Web 服务(AWS)简单存储服务(S3)桶。服务器到服务器的跨域请求很简单,没有任何限制。但是对于从浏览器发起的跨域请求,情况就不一样了。对于想开发一个从浏览器直接向 S3 发送文件的 web 应用的开发人员来说,有一个障碍:同源策略。 34 该策略对 JavaScript 发起的请求进行限制。更具体地说,禁止域间的XMLHttpRequest
请求。比如从 https://mywebapp.com
向 https://api.github.com
发送请求,因为同源策略被浏览器阻止。虽然这种限制是为了增加安全性,但这似乎是一个主要的限制因素。如果不首先通过域 A 上的服务器,如何从域 A 向域 B 发出合法请求呢?接下来的两节将介绍实现这一目标的两种具体方法。
早期(JSONP)
同源策略防止脚本在其当前浏览上下文的域之外发起请求。虽然这涵盖了 AJAX 传输,比如XMLHttpRequest
,但是像<a>
、<img>
和<script>
这样的元素不受同源策略的约束。带填充的 JavaScript 对象表示法(JSONP)利用了这些异常中的一种,允许脚本发出跨源 GET 请求。
如果您不熟悉 JSONP,这个名称可能会有点误导。这里实际上根本不涉及 JSON。一个很常见的误解是,当客户端发起 JSONP 调用时,JSON 必须从服务器返回,但这并不正确。相反,服务器返回一个函数调用,这不是有效的 JSON。
JSONP 本质上只是一个丑陋的黑客,它利用了从服务器加载内容的<script>
标签不受同源策略约束的事实。为了正常工作,客户端和服务器端需要合作并理解约定。您只需要将一个<script>
标签的src
属性指向一个支持 JSONP 的端点,并包含一个现有全局函数的名称作为查询参数。然后,服务器必须构造一个字符串表示,当浏览器执行该字符串表示时,它将调用全局函数,传入请求的数据。
在 jQuery 中利用这种 JSONP 方法实际上非常容易。假设我们想从不同域中的服务器获取用户信息:
1 $.ajax('http://jsonp-aware-endpoint.com/user/1', {
2 jsonp: 'callback',
3 dataType: 'jsonp'
4 }).then(function(response) {
5 // handle user info from server
6 });
jQuery 负责为我们创建<script>
标签,还创建和跟踪一个全局函数。当收到来自服务器的响应后调用全局函数时,jQuery 会将它传递给前面提到的响应处理程序。这实际上是一个非常好的抽象。在没有 jQuery 的情况下完成同样的任务当然是可能的,但是没有那么好:
1 window.myJsonpCallback = function(data) {
2 // handle user info from server
3 };
4
5 var scriptEl = document.createElement('script');
6 scriptEl.setAttribute('src',
7 'http://jsonp-aware-endpoint.com/user/1?callback=myJsonpCallback');
8 document.body.appendChild(scriptEl);
既然您有了这个新发现的知识,我建议您忘记它,并完全避免使用 JSONP。事实证明这是一个潜在的安全问题。 35 还有,在现代浏览器中,CORS 是一条好得多的路线。你很幸运:CORS 是下一小节的特色。对 JSONP 的解释主要是作为一个历史教训,并说明在 web 规范的现代发展之前,jQuery 是多么有用和重要。
现代(CORS)
CORS 是跨源资源共享的缩写,是从浏览器发送域间 AJAX 请求的更现代的方式。CORS 实际上是一个相当复杂的话题,即使是经验丰富的 web 开发人员也很容易误解。虽然 W3C 规范 36 可能很难解析,但是 Mozilla 开发者网络有一个很好的解释。 37 我在这里只打算触及一些 CORS 的概念,但是如果你想更详细地了解这个主题,MDN 的文章是有用的。
对 CORS 有了合理的理解,在现代浏览器中通过 JavaScript 发送跨来源的 AJAX 请求并不特别困难。不幸的是,在 Internet Explorer 8 和 9 中,这个过程并不容易。在 IE7 和更早的版本中,跨源 AJAX 请求只能通过 JSONP 实现,并且您被限制在这些浏览器中获取请求(因为这是 JSONP 的固有限制)。在所有非 JSONP 的情况下,jQuery 不提供任何帮助。
对于现代浏览器,所有的工作都委托给服务器代码。浏览器在客户端为你做一切必要的事情。在最基本的情况下,当使用 jQuery 的ajax()
API 方法,或者直接使用 web API 的XMLHttpRequest
传输,甚至使用 Fetch API 时,现代浏览器中跨来源 AJAX 请求的代码与同源 AJAX 请求是相同的。所以,我就不在这里展示了。
CORS 请求可以分为两种不同的类型:简单请求和非简单请求。简单请求由 GET、HEAD 和 POST 请求组成,内容类型为“text/plain”或“application/x-www-form-urlencoded”。“简单”请求中不允许使用非标准头,如“X-”头。这些 CORS 请求由浏览器发送,带有包含发送域的Origin
报头。服务器必须确认来自这个来源的请求是可接受的。否则,请求失败。非简单请求包括 PUT、PATCH 和 DELETE 请求,以及其他内容类型,比如“application/json”。此外,正如您刚刚了解到的,非标准头会将 CORS 请求标记为“不简单”事实上,例如,如果 GET 或 POST 请求包含非标准的请求头,那么它也可能是不简单的。
非简单的 CORS 请求必须由浏览器“预先检查”。预检是浏览器在发送基本请求之前发送的选项请求。如果服务器正确地确认了预检,浏览器将发送底层/原始请求。非简单的跨源请求,例如带有 X-header 的 PUT 或 POST/GET 请求,不能从 CORS 规范之前的浏览器发送。因此,对于这些类型的请求,预检的概念被写入规范中,以确保服务器在没有明确选择的情况下不会接收这些类型的非简单跨来源的基于浏览器的请求。换句话说,如果您不想允许这些类型的请求,您不必对您的服务器进行任何更改。浏览器首先发送的预检请求将会失败,并且浏览器永远不会发送底层请求。
还有一点很重要,那就是跨源 AJAX 请求默认不发送 cookies。您必须在XMLHttpRequest
传输上设置withCredentials
标志。例如:
1 $.ajax('http://someotherdomain.com', {
2 method: 'POST',
3 contentType: 'text/plain',
4 data: 'sometext',
5 beforeSend: function(xmlHttpRequest) {
6 xmlHttpRequest.withCredentials = true;
7 }
8 });
jQuery 在这里提供了一些有漏洞的抽象。我们必须在 jQuery 管理的底层xmlHttpRequest
上设置withCredentials
属性。这也可以通过向 xhrFields 设置对象添加值为 true 的 withCredentials 属性来实现。ajax 方法文档中提到了这一点,但是可能很难定位,除非您确切知道在哪里查找。web API 路径是熟悉的,正如所料,我们必须设置withCredentials
标志,以确保 cookies 被发送到我们的服务器端点:
1 var xhr = new XMLHttpRequest();
2 xhr.open('POST', 'http://someotherdomain.com');
3 xhr.withCredentials = true;
4 xhr.setRequestHeader('Content-Type', 'text/plain');
5 xhr.send('sometext');
Fetch API 使得跨源 AJAX 请求的凭证发送变得更加简单:
1 fetch('http://someotherdomain.com', {
2 method: 'POST',
3 headers: {
4 'Content-Type': 'text/plain'
5 },
6 credentials: 'include'
7 });
这里使用的credentials
选项确保任何凭证(比如 cookies)都与 CORS 请求一起发送。注意,即使对于同源请求,fetch
在默认情况下也不会向服务器端点发送 cookies。对于同源请求,您必须包含一个credentials: 'same-origin'
选项,以确保fetch
随请求一起发送 cookies。credentials
选项的默认值是“省略”,这就是为什么默认情况下fetch
不发送带有任何请求的 cookies。
当我们需要在 IE8 或 IE9 中发送跨域 AJAX 请求时,jQuery 实际上变得令人头疼。如果您将 jQuery 用于此目的,那么您实际上是在试图将一个方钉装进一个圆孔中。要理解为什么 jQuery 不适合 IE9 和 IE8 中的跨源请求,考虑几个底层要点很重要:
- IE8 和 IE9 中的跨源 AJAX 请求只能使用 IE 专有的
XDomainRequest
传输来发送。我将把为什么这是 IE 开发团队的一个巨大错误的咆哮留到另一本书里。无论如何,XDomainRequest
是XMLHttpRequest
的精简版本,在 IE8 和 IE9 中进行跨源 AJAX 请求时必须使用它。对这种传输有很大的限制,比如除了 POST 和 GET 请求之外不能发送任何东西,以及缺少设置请求头或访问响应头的 API 方法。 - jQuery 的
ajax()
方法(以及所有相关的别名)只是XMLHttpRequest
的包装器。它对XMLHttpRequest
有很强的依赖性。我在这一章的前面提到了这一点,但是根据上下文,在这里再次指出这一点是有用的。
因此,在 IE8/9 中需要使用XDomainRequest
来发送跨原点请求,但是jQuery.ajax()
被硬编码为使用XMLHttpRequest
。这是一个问题,在 jQuery 的上下文中解决它并不容易。幸运的是,对于那些坚决使用 jQuery 进行这种调用的人来说,有几个插件可以在这方面“修复”jQuery。本质上,插件必须通过$.ajaxTransport()
方法覆盖 jQuery 的 AJAX 请求发送/处理逻辑。
当试图在旧浏览器中发送跨来源 AJAX 请求时,不要与 jQuery 较劲,坚持使用 web API。以下代码演示了一种简单的方法来确定是否需要使用XDomainRequest
而不是XMLHttpRequest
(仅在需要时使用):
1 if (new XMLHttpRequest().withCredentials === undefined) {
2 var xdr = new XDomainRequest();
3 xdr.open('POST', 'http://someotherdomain.com');
4 xdr.send('sometext');
5 }
native web 不仅为启动 AJAX 请求提供了一个合理的 API,使用XMLHttpRequest
时更是如此,在这种情况下,它有时甚至比 jQuery 更直观,尤其是在发送一些跨来源的 AJAX 请求时。
Footnotes 1
https://blogs.msdn.microsoft.com/ie/2006/01/23/native-xmlhttprequest-object/
2
www.websiteoptimization.com/bw/0403/
3
4
www.w3.org/Protocols/HTTP/AsImplemented.html
5
https://httpwg.github.io/specs/rfc7540.html
6
https://tools.ietf.org/html/rfc5789
7
www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10
8
https://tools.ietf.org/html/rfc6455
9
10
www.w3.org/TR/XMLHttpRequest#dom-xmlhttprequest-send
11
https://fetch.spec.whatwg.org/#body-mixin
12
https://tools.ietf.org/html/rfc7231
13
https://tools.ietf.org/html/rfc7231#section-4.3.5
14
https://github.com/github/fetch
15
https://tools.ietf.org/html/rfc6902
16
www.w3.org/TR/html5/forms.html%23application/x-www-form-urlencoded-encoding-algorithm
17
http://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers
18
19
https://bugs.webkit.org/show_bug.cgi?id=151937
20
www.ecma-international.org/ecma-262/5.1/#sec-15.12
21
https://fetch.spec.whatwg.org/#request-class
22
https://fetch.spec.whatwg.org/#body
23
24
www.w3.org/TR/html5/forms.html#multipart-form-data
25
https://developer.mozilla.org/en-US/docs/Web/API/FormData
26
27
28
www.w3.org/TR/XMLHttpRequest2/
29
www.w3.org/TR/FileAPI/#dfn-file
30
www.w3.org/TR/FileAPI/#dfn-Blob
31
www.w3.org/TR/FileAPI/#dfn-filereader
32
www.w3.org/TR/FileAPI/#dfn-Blob
33
34
https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
35
http://security.stackexchange.com/a/23439
36
37
https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
38
十、浏览器事件
对页面上的变化做出反应是现代 web 应用开发的一个重要部分。虽然这在某种程度上可以通过锚链接和表单提交按钮来实现,但是事件系统的引入使得编写适应用户输入的代码成为可能,而无需重新加载或更改当前页面。您可能还会看到这是如何补充发送 AJAX 请求的能力的。自从 Internet Explorer 版本 4(引入了微软的 Trident 布局引擎)和 Netscape Navigator 版本 2(以及第一个 JavaScript 实现)以来,为了在浏览器中提供更动态的用户体验,可以侦听 DOM 事件。这个模型的第一次实现非常有限。但是随着浏览器通过标准化的发展,事件系统也在发展。今天,我们有了一个相当优雅的本地 API 来监听和触发标准化的 DOM 事件和定制事件。在本章中,你会看到如何在现代浏览器中利用事件来完成各种任务。
虽然现代 Web 提供了一套强大而直观的方法来处理事件,但情况并非总是如此。历史上这一不幸的时刻,再加上 Internet Explorer 和其他浏览器在事件 API 方面缺乏对等性,使得 jQuery 成为处理所有流行浏览器中的事件的绝佳库。除了规范化事件之外,jQuery 还通过支持事件委托和一次性事件监听器绑定来提供额外的帮助。不再需要 jQuery 来规范化浏览器之间的重大事件 API 实现差异,但它仍然通过额外有用的功能提供了额外的便利。在本章中,我还将向您展示如何简单地依靠本地 web API 来镜像最重要的与事件相关的 jQuery 特性。
在接下来的几节中,您将学习如何创建、触发和监听标准浏览器事件和自定义事件。jQuery 方法将与原生的 web API 方法进行比较,您会觉得自己处理事件比使用 jQuery 更舒服。至少,即使你决定在你的项目中继续使用 jQuery 进行事件处理,本章也会让你对浏览器提供的事件系统有一个全面的了解。在这一章中,我将主要关注现代浏览器,但是最后的一节将包括一些有用的信息,这些信息将帮助您理解 events API 在古代浏览器中是如何工作的,以及它与现代系统有何不同。
事件是如何工作的?
在我介绍使用事件解决常见问题之前,我认为谨慎的做法是首先概述浏览器事件是如何“工作”的这个事件系统遵循基本的发布-订阅模式,但是浏览器事件远不止于此。首先,浏览器事件有多种分类。两个最广泛的类别被称为“习俗”和“本土”(我)。
“本地”浏览器事件可以进一步分配给子组,例如鼠标事件、键盘事件和触摸事件(仅举几个例子)。除了事件类型之外,浏览器事件还有一个独特的属性:将它们分发给注册的侦听器的过程。事实上,浏览器事件可以通过两种不同的方式在页面上传播。个人听众也可以以不同的方式影响这些事件。除了事件类型之外,我还将在本节中解释事件传播。完成第一部分后,您将对浏览器事件的核心概念有一个很好的理解。这将允许您有效地遵循概述事件 API 的更具体用途的后续部分。
事件类型:自定义和本机
为了开始我对浏览器事件的全面介绍,我现在将向您介绍所有事件都适合的两个高级类别:“自定义”和“本地”本地事件是在官方 web 规范中定义的事件,例如由 WHATWG 或 W3C 维护的事件。在 W3C 维护的 DOM Level 3 UI 事件规范 1 中可以找到大多数事件的列表。请注意,这不是一个详尽的列表;它只包含今天可用事件的子集。一些本地事件包括“click”,当 DOM 元素通过定点设备或键盘激活时,浏览器触发的鼠标事件。另一个常见的事件是“load”,当一个<img>
、文档、window
或<iframe>
(以及其他)成功加载时,就会触发这个事件。还有很多其他的本地活动。在 Mozilla Developer Network events 页面上可以看到一个很好的资源,它提供了所有当前可用的本地 DOM 事件的列表。 2
如您所料,自定义事件是专为特定应用或库创建的非标准事件。它们可以按需创建,以支持基于事件的动态工作流。例如,考虑一个文件上传库,它希望在文件上传开始时触发一个事件,然后在文件上传完成时触发另一个事件。就在上传开始之后(或者可能就在之前),库可能想要触发一个“uploadStart”事件,然后在文件成功上传到服务器上之后触发“uploadComplete”事件。如果文件上传过早结束,它甚至会触发“uploadError”事件。确实没有任何本地事件提供这种情况所需的语义,所以自定义事件是最好的解决方案。幸运的是,DOM API 确实提供了触发定制事件的方法。尽管在一些没有聚合填充的浏览器中触发自定义事件有点不优雅,但这种情况正在改变。稍后会详细介绍。
不使用 jQuery 创建和触发的自定义事件可以使用 jQuery 的事件 API 进行观察和处理。然而,在处理定制事件时,jQuery 有一个有趣的限制,这是在 jQuery 文档中找不到的。如果不使用 jQuery 的事件 API,就无法观察和处理用 jQuery 的事件 API 创建和触发的自定义事件。换句话说,由 jQuery 创建的定制事件完全是专有的和非标准的。这样做的原因其实很简单。虽然 jQuery 可以为本地 DOM 事件触发特定元素上的所有事件处理程序,但是对于自定义事件来说却不可能,也不可能查询特定的 HTML 元素以获取其附加的事件侦听器。因此,jQuery 自定义事件只能由 jQuery 自定义事件处理程序使用。
有一个对象将自定义事件和本地事件联系在一起。这个中心对象是由 W3C 制定的规范中描述的the Event
对象、 3 。每一个 DOM 事件,无论是自定义的还是本地的,都由一个Event
对象表示,这个对象本身有许多用于识别和控制事件的属性和方法。例如,type
属性使自定义或本地事件的名称可用。一个“点击”事件在其对应的Event
对象上有一个“点击”的type
。同一个Event
对象实例还将包含一个stopPropagation()
方法,调用该方法可以防止点击事件被进一步传播到页面上的其他侦听器。
事件传播:冒泡与捕获
在 Web 的早期,Netscape 提供了一种在整个 DOM 中分散事件的方法——事件捕获,而 Internet Explorer 提供了一种相反的过程——事件冒泡。在标准化之前,浏览器本质上是在实现特性时做出自己专有的选择,这就是导致这两种不同方法的原因。这一切在 2000 年 W3C 起草 DOM Level 2 Events 规范时都改变了。这个文档描述了一个包括事件捕获和冒泡的事件模型。所有遵循该规范的浏览器都遵循在 DOM 中分发事件的过程。目前,所有现代浏览器都实现了 DOM Level 2 事件。仅支持事件冒泡的古老浏览器将在本章末尾介绍。
在所有现代浏览器中,根据 DOM Level 2 Events 规范,当一个 DOM 事件被创建时,捕获阶段就开始了。假设事件在被触发后的某个时刻没有被取消,它从window
开始,接着是document
,向下传播,以触发事件的元素结束。捕获阶段完成后,冒泡阶段开始。从这个目标元素开始,事件在 DOM 中“冒泡”,触及每个祖先,直到事件被取消或再次触及window
。
如果关于事件进展的描述仍然有点混乱,让我用一个简单的演示来解释一下。考虑下面的 HTML 文档:
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <title>event propagation demo</title>
5 </head>
6 <body>
7 <section>
8 <h1>nested divs</h1>
9 <div>one
10 <div>child of one
11 <div>child of child of one</div>
12 </div>
13 </div>
14 </section>
15 </body>
16 </html>
假设点击了<div>child of child of one</div>
元素。单击事件在 DOM 中采用以下路径:
捕获阶段
window
document
<html>
<body>
<section>
<div>one
<div>child of one
<div>child of child of one
起泡阶段
<div>child of child of one
<div>child of one
<div>one
<section>
<body>
<html>
document
window
那么,什么时候关注捕获阶段而不是冒泡阶段是合适的,或者反之亦然?最常见的选择是在冒泡阶段拦截事件。压倒性地关注冒泡阶段的一个原因是由于历史原因。在 Internet Explorer 9 之前,这是唯一可用的阶段。随着标准化和现代浏览器的出现,这不再是一个障碍。除了缺乏对古代浏览器中捕获的支持,jQuery 也缺乏对这一阶段的支持。这也许是它不是特别受欢迎的另一个原因,冒泡是默认的选择。但不可否认,事件冒泡的概念比捕捉更直观。设想一个在 DOM 树中向上移动的事件,从创建该事件的元素开始,似乎比以创建它的事件结束的事件更明智一些。事实上,在描述浏览器事件模型时很少讨论捕获。在使用 web API 监听事件时,将处理程序附加到事件冒泡阶段也是默认行为。
尽管事件冒泡阶段通常是首选,但在某些情况下,捕获是更好的(或唯一的)选择。利用捕获阶段似乎有性能优势。因为事件捕获发生在冒泡之前,这似乎是有意义的。Basecamp,一个基于网络的项目管理应用,已经利用事件捕获来提高他们项目的性能,例如 5 。使用捕获阶段的另一个原因:事件委托给“聚焦” 6 和“模糊” 7 事件。虽然这些事件不会冒泡,但是通过挂钩到捕获阶段,处理程序委托是可能的。我将在本章后面详细介绍事件委托。
捕获还可以用于对冒泡阶段中被取消的事件做出反应。取消事件的原因有很多,也就是说,为了防止它到达任何后续的事件处理程序。事件几乎总是在冒泡阶段被取消。我将在本章稍后讨论这个问题,但是现在想象一下在你的 web 应用中一个第三方库取消的点击事件。如果您仍然需要在另一个处理程序中访问该事件,您可以在捕获阶段在元素上注册一个处理程序。
jQuery 不幸地选择了人工冒泡事件。换句话说,当通过库触发事件时,它会计算预期的冒泡路径,并在该路径中的每个元素上触发处理程序。jQuery 没有利用浏览器提供的对冒泡和捕获的本地支持。这无疑增加了库的复杂性和膨胀,并可能带来性能后果。
创建和触发 DOM 事件
为了演示在有和没有 jQuery 的情况下触发 DOM 事件,让我们使用下面的 HTML 片段:
1 <div>
2 <button type="button">do something</button>
3 </div>
4
5 <form method="POST" action="/user">
6 <label>Enter user name:
7 <input name="user">
8 </label>
9 <button type="submit">submit</button>
10 </form>
用 jQuery 触发 DOM 事件
jQuery 的 events API 包含两个方法,允许创建 DOM 事件并在整个 DOM 中传播:trigger
和triggerHandler
。最常用的方法是trigger
。它允许创建一个事件,并通过冒泡传播到原始元素的所有祖先。请记住,jQuery 人为地冒泡所有事件,它不支持事件捕获。triggerHandler
方法与trigger
的不同之处在于,它只在被调用的元素上执行事件处理程序;事件不会冒泡到祖先元素。jQuery 的triggerHandler
在其他一些方面也不同于trigger
,但是我提供的定义对于本节来说已经足够了。
在接下来的几个清单中,我使用 jQuery 的trigger
方法来:
- 以两种方式提交表单。
- 聚焦文本输入。
- 将焦点从输入元素上移开。
1 // submits the form
2 $('FORM').trigger('submit');
3
4 // submits the form by clicking the button
5 $('BUTTON[type="submit"]').trigger('click');
6
7 // focuses the text input
8 $('INPUT').trigger('focus');
9
10 // removes focus from the text input
11 $('INPUT').trigger('blur');
Listing 10-1.Triggering DOM Events: jQuery
公平地说,还有第二种方法可以用 jQuery 触发相同的事件,即使用库的 API 中定义的这些事件的别名。
1 // submits the form
2 $('FORM').submit();
3
4 // submits the form by clicking the button
5 $('BUTTON[type="submit"]').click();
6
7 // focuses the text input
8 $('INPUT').focus();
9
10 // removes focus from the text input
11 $('INPUT').blur();
Listing 10-2.Another Way of Triggering DOM Events: jQuery
如果我们想点击出现在表单前的按钮,但不想触发任何附加到祖先元素的点击处理程序,该怎么办?假设父节点<div>
包含一个我们不想在这个实例中触发的点击处理程序。有了 jQuery,我们可以使用triggerHandler()
来完成这项工作,如清单 10-3 所示。
1 // clicks the first button - the click event does not bubble
2 $('BUTTON[type="button"]').triggerHandler('click');
Listing 10-3.Triggering DOM Events without Bubbling: jQuery
Web API DOM 事件
有两三种方法可以在不使用 jQuery 的情况下触发刚才演示的相同事件。(有时候)有选择是好的。无论如何,使用 web API 触发上述事件的最简单方法是调用目标元素上相应的本地方法。清单 10-4 显示了与清单 10-2 非常相似的代码。
1 // submits the form
2 document.querySelector('FORM').submit();
3
4 // submits the form by clicking the button
5 document.querySelector('BUTTON[type="submit"]').click();
6
7 // focuses the text input
8 document.querySelector('INPUT').focus();
9
10 // removes focus from the text input
11 document.querySelector('INPUT').blur();
Listing 10-4.Triggering DOM Events: Web API, All Modern Browsers, and Internet Explorer 8
由于使用了querySelector()
,前面的代码只限于 IE8 和更新的版本,但是考虑到当前浏览器支持的年份和状态,这已经足够了。从HTMLElement
继承而来的所有 DOM 元素对象都可以使用click()
、focus()
和blur()
方法。8submit()
方法只对<form>
元素可用,因为它是在HTMLFormElement
接口上定义的。 9 用这些方法触发“点击”和“提交”事件时,它们会冒泡。根据 W3C 规范, 10 “模糊”和“聚焦”事件不会冒泡,但它们可用于编码为利用捕获阶段的事件处理程序。
前面的事件也可以通过使用document
上可用的Event()
构造函数或the createEvent()
方法来创建。 11 除了任何版本的 Internet Explorer 之外,所有现代浏览器都支持前者。在下一个代码演示中,我将向您展示如何以编程方式确定是否支持Event
构造函数,然后返回到触发事件的备选路径。也许您想知道为什么您甚至需要使用不同于这里概述的简单方法来触发事件。如果你想以某种方式改变事件的默认行为,需要构造一个Event
对象。例如,为了模仿 jQuery 的triggerHandler()
方法的行为并防止事件冒泡,我们必须在构造“click”事件时将特定的配置属性传递给它。您将在清单 10-5 的末尾看到这一点,它展示了触发事件的第二种方法。
1 var clickEvent;
2
3 // If the `Event` constructor function is not supported,
4 // fall back to `createEvent` method.
5 if (typeof Event === 'function') {
6 clickEvent = new Event('click', {bubbles: false});
7 }
8 else {
9 clickEvent = document.createEvent('Event');
10 clickEvent.initEvent('click', false, true);
11 }
12
13 document.querySelector('BUTTON[type="button"]')
14 .dispatchEvent(clickEvent);
Listing 10-5.
Triggering DOM
Events
without Bubbling: Web API, All Modern Browsers
在清单中,当我们必须返回到initEvent()
时,第二个参数是bubbles
,如果我们不想让事件冒泡,必须将它设置为false
。第三个参数设置为 true,表示该事件确实是可取消的。换句话说,可以使用 event 对象上的 preventDefault()方法来阻止与该事件相关联的任何默认浏览器操作。我将在本章的后面解释取消事件。Event
构造函数提供了一种更优雅的方式,使用一组对象属性来设置这个选项和其他选项。一旦 Internet Explorer 11 寿终正寝,我们可以专注于Event
的构造者,忘记initEvent()
的存在。但在此之前,如果您必须用特殊的配置选项构造事件,前面的检查将允许您选择正确的路径。
创建和触发自定义事件
请记住,自定义事件是那些没有作为公认的 web 规范的一部分进行标准化的事件,例如由 W3C 和 WHATWG 维护的事件。让我们想象一个场景,我们正在编写一个第三方库,处理从图片库中添加和删除项目。当我们的库被集成到一个更大的应用中时,我们需要提供一种简单的方法来通知任何侦听器我们的库添加或删除了一个项目。在这种情况下,我们的库将包装图片库,这样我们只需通过触发祖先元素可以观察到的事件,就可以发出删除或添加的信号。这里没有合适的标准化 DOM 事件,所以我们需要创建自己的事件,一个自定义事件。与删除图像相关联的自定义事件将被恰当地命名为“image-removed ”,并且需要包含被删除图像的 ID。
jQuery 自定义事件
让我们首先使用 jQuery 触发这个事件。我们假设我们已经有了一个由库控制的元素的句柄。我们的事件将由以下特定元素触发:
1 // Triggers a custom "image-removed" element,
2 // which bubbles up to ancestor elements.
3 $libraryElement.trigger('image-removed', {id: 1});
这看起来与用于触发原生 DOM 事件的代码相同,而且确实如此。jQuery 有一个简单、优雅和一致的 API 来触发所有类型的事件。但是这里有一个问题——在我们的 jQuery 库之外监听这个事件的代码也必须使用 jQuery 来观察这个事件。这是 jQuery 自定义事件系统的一个限制。如果我们的库的用户正在使用其他库,或者即使用户不想在这个库之外使用 jQuery,这也没有什么关系。也许我们的用户不清楚必须使用 jQuery 来监听这个事件。他们被迫只依靠 jQuery 和 jQuery 来接受来自我们库的消息。
使用 Web API 触发自定义事件
用 web API 触发定制事件就像触发本地 DOM 事件一样。这里的区别在于创建自定义事件,尽管过程和 API 仍然非常相似。
1 var event = new CustomEvent('image-removed', {
2 bubbles: true,
3 detail: {id: 1}
4 });
5 libraryElement.dispatchEvent(event);
在这里,我们可以轻松地创建自定义事件,触发它,并确保它冒泡到祖先元素。因此,我们的“图像移除”事件在我们的图书馆之外是可观察到的。我们还在事件有效负载的detail
属性中传递了图像 ID。稍后将详细介绍如何访问这些数据。但这里有一个问题:这在任何版本的 Internet Explorer 中都不适用。不幸的是,正如我在上一节提到的,Explorer 不支持Event
构造函数。因此,我们必须退回到以下跨浏览器支持方法:
1 var event = document.createEvent('CustomEvent');
2 event.initCustomEvent('image-removed', false, true, {id: 1});
3 libraryElement.dispatchEvent(event);
我们必须创建一个“客户事件”,而不是创建一个“事件”,正如我们在前面的部分中尝试在 Internet Explorer 中触发本机 DOM 事件时所做的那样这公开了一个在CustomEvent
接口上定义的initCustomEvent()
方法。这个特殊的方法允许我们将定制数据和这个事件一起传递,比如我们的图像 ID。
前面的代码目前(截至 2016 年年中)在所有现代浏览器中都能工作,但是一旦CustomEvent
构造函数在所有浏览器中得到支持,这种情况可能会改变。它可能会从任何未来的浏览器版本中删除。为了使我们的代码经得起未来的考验,并且仍然确保它在 Internet Explorer 中工作,我们需要检查CustomEvent
构造函数的存在,就像我们在上一节中对Event
构造函数所做的那样:
1 var event;
2
3 // If the `CustomEvent` constructor function is not supported,
4 // fall back to `createEvent` method.
5 if (typeof CustomEvent === 'function') {
6 event = new CustomEvent('image-removed', {
7 bubbles: true,
8 detail: {id: 1}
9 });
10 }
11 else {
12 event = document.createEvent('CustomEvent');
13 event.initCustomEvent('image-removed', false, true, {
14 id: 1
15 });
16 }
17
18 libraryElement.dispatchEvent(event);
在 Internet Explorer 逐渐过时,微软 Edge 取而代之之后,你可以独占使用CustomEvent
构造函数,前面的代码将不再需要。
监听(和不监听)事件通知
触发事件是在 DOM 中传递消息的一个重要部分,但是这些事件通过相应的侦听器提供了更多的价值。在这一节中,我将介绍如何处理 DOM 和定制事件。您可能已经熟悉了用 jQuery 注册事件观察器的过程,但是我将首先演示这是如何完成的,这样当完全依赖于 web API 时,区别就很明显了。
当用户改变页面视图时,resize 事件处理程序对于调整复杂的应用可能很重要。当用户调整浏览器大小时,这个“resize”事件在window
上被触发,它将为我们提供一个演示注册和注销事件监听器的好方法。
jQuery 事件处理程序
jQuery 的on
API 方法提供了观察元素上触发的 DOM 和定制事件所需的一切:
1 $(window).on('resize', function() {
2 // react to new window size
3 });
如果在将来的某个时候,我们不再关心窗口的大小,我们可以使用 jQuery 的适当命名的off
处理程序来删除这个处理程序,但是这比添加一个新的侦听器要简单得多。我们有两个选择:
- 移除所有调整事件监听器(简单)。
- 只移除我们的 resize 事件监听器(有点难)。
让我们先来看看选项 1:
1 // remove all resize listeners - usually a bad idea
2 $(window).off('resize');
第一个选项非常简单,但是我们冒着给页面上仍然依赖窗口大小调整事件的其他代码带来问题的风险。换句话说,选项 1 通常是一个糟糕的选择。这就给我们留下了选项 2,它要求我们存储一个对处理函数的引用,并将其提供给 jQuery,这样它就可以只解除对侦听器的绑定。因此,我们需要重写之前的事件侦听器调用,以便以后可以轻松地注销我们的侦听器:
1 var resizeHandler = function() {
2 // react to new window size
3 };
4
5 $(window).on('resize', resizeHandler);
6
7 // ...later
8 // remove only our resize handler
9 $(window).off('resize', resizeHandler);
将一个事件侦听器绑定到一个特定的元素,然后在以后不再需要它时移除它,这通常就足够了,但是我们可能会遇到这样的情况,一个事件处理程序只需要一次。在第一次执行之后,它可以而且应该被删除。也许我们有一个元素,一旦被点击,就会以这样一种方式改变状态,以至于后续的点击是不谨慎的。jQuery 为这种情况提供了一个one
API 方法:
1 $(someElement).one('click', function() {
2 // handle click event
3 });
执行附加的处理函数后,将不再观察到 click 事件。
使用 Web API 观察事件
自古以来(几乎如此),有两种简单的方法可以将事件处理程序附加到特定的 DOM 元素,这两种方法都可以被认为是“内联的”第一种,如清单 10-6 所示,包括将事件处理函数作为元素的属性值包含在文档标记中:
1 <button onclick="handleButtonClick()">click me</button>
Listing 10-6.
Inline Event Handler
:
Web API
, All Browsers
这种方法有几个问题。首先,它需要一个全局handleButtonClick()
函数。如果您有许多按钮或其他元素需要特定的点击处理函数,您将得到一个混乱的全局名称空间。应该始终限制全局变量和函数,以防止冲突和对内部逻辑的不受控制的访问。因此,这种类型的内联事件处理程序是朝着错误方向迈出的一步。
第二个不好的原因是:它需要在同一个文件中混合 JavaScript 和 HTML。一般来说,这是不鼓励的,因为它违背了关注点分离的原则。也就是说,内容属于 HTML 文件,行为属于 JavaScript 文件。这隔离了代码,从而降低了更改的风险,并且缓存得到了改进,因为对 JavaScript 的更改不会使标记文件无效,反之亦然。
注册同一个 click 事件的另一种方法需要将一个处理函数附加到元素的相应事件属性:
1 buttonEl.onclick = function() {
2 // handle button click
3 };
虽然这种方法比基于 HTML 的事件处理程序稍好,但由于我们没有被强制绑定到全局函数,所以它仍然不是最佳的解决方案。不能为同一元素上的同一事件指定基于属性的事件处理程序和元素属性处理程序。最后指定的处理程序将有效地移除给定事件类型的元素上的任何其他内联事件处理程序。事实上,对于给定元素上的给定事件,只能指定一个 total inline 事件处理程序。对于现代 web 应用来说,这可能是一个大问题,因为在同一个页面上存在多个不协调的模块是很常见的。也许不止一个模块需要将同一类型的事件处理程序附加到同一元素上。对于内联事件处理程序,这是不可能的。
从 Internet Explorer 9 开始,EventTarget
接口上就有了一个addEventListener()
方法。所有的Element
对象都实现了这个接口,就像Window
(在其他 DOM 对象中)一样。EventTarget
接口首先出现在 W3C DOM Level 2 Events 规范中, 12 和addEventListener()
方法是这个接口初始版本的一部分。使用这种方法可以注册自定义和 DOM 事件,语法与 jQuery 的on()
方法非常相似。继续按钮示例:
1 buttonEl.addEventListener('click', function() {
2 // handle button click
3 });
这个解决方案没有任何困扰内联事件处理程序的问题。不需要绑定到全局函数。您可以将任意数量的不同点击处理程序附加到这个按钮元素上。处理程序是纯 JavaScript 附带的,所以它可能只存在于 JavaScript 文件中。在上一节中,我提醒过您如何用 jQuery 将“resize”事件绑定到window
。使用现代 web API 的相同处理程序如下所示:
1 window.addEventListener('resize', function() {
2 // react to new window size
3 });
这看起来很像我们用 jQuery 绑定到同一个事件的例子,除了一个更长的事件绑定方法(on()
对addEventListener()
)。您可能很高兴知道,如果我们在某个时候需要解绑处理程序,有一个适当命名的 web API 方法可以解绑我们的处理程序。EventTarget
接口还定义了一个removeEventListener()
方法。removeEventListener()
方法与 jQuery 的off
有一个显著的不同:没有办法从特定元素中移除给定类型的所有事件侦听器。也许这是一件好事。因此,为了删除我们的window
“resize”处理程序,我们必须像这样构造我们的代码:
1 var resizeHandler = function() {
2 // react to new window size
3 };
4
5 window.addEventListener('resize', resizeHandler);
6
7 // ...later
8 // remove only our resize handler
9 window.removeEventListener('resize', resizeHandler);
还记得我们用 jQuery 的one
API 方法创建的一次性点击处理程序吗?web API 中存在类似的东西吗?好消息:是的!坏消息:这是一个相当新的添加(添加到 WHATWG 的 DOM live standard(https://DOM . spec . WHATWG . org/# interface-event target)中)。截至 2016 年年中,只有 Firefox 提供支持,但这是一个令人兴奋的功能:
1 someElement.addEventListener('click', function(event) {
2 // handle click event
3 }, { once: true });
为了获得更好的跨浏览器支持,特别是因为没有任何优雅的方式来以编程方式确定对侦听器选项的支持,请改用以下方式:
1 var clickHandler = function() {
2 // handle click event
3 // ...then unregister handler
4 someElement.removeEventListener('click', clickHandler);
5 };
6 someElement.addEventListener('click', clickHandler);
在附加的 handler 函数被执行后,click 事件将不再被观察到,就像 jQuery 的one()
方法一样。有更好的方法来解决这个问题,但是这个解决方案只利用了你在本书中学到的知识。在完成本书之后,尤其是在阅读了 JavaScript 工具之后,您可能会发现一种更优雅的方法。
控制事件传播
在前几节中,我已经向您展示了如何触发和观察事件,但是有时您需要做的不仅仅是创建或监听事件。有时,您需要影响冒泡/捕获阶段,或者甚至将数据附加到事件上,以便让后续的侦听器可以使用它。
作为一个虚构的例子(但在一个由不了解 web 的项目经理操纵的非常扭曲的 web 应用中可能是现实的),假设您被要求阻止用户选择整个页面上的任何文本或图像。你怎么能做到这一点?也许通过某种方式干扰一些鼠标事件。但是哪一个事件,如何发生?也许要集中精力的事件是“点击”。如果这是你的第一个猜测,你很接近,但不太正确。
根据 W3C DOM Level 3 Events 规范,“mousedown”事件 13 启动拖动或文本选择操作作为其默认动作。因此,我们必须防止“鼠标按下”事件的默认动作。我们可以通过简单地在window
上注册一个“mousedown”事件监听器,并调用Event
对象上的preventDefault()
方法来防止使用 jQuery 或纯 web API 在整个页面上选择/拖动文本和图像,一旦我们的处理程序被执行,这个对象就会被传递给我们的处理程序:
1 $(window).on('mousedown', function(event) {
2 event.preventDefault();
3 });
4
5 // ...or...
6 $(window).mousedown(function(event) {
7 event.preventDefault();
8 });
防止默认事件操作- web API -现代浏览器
1 window.addEventListener('mousedown', function(event) {
2 event.preventDefault();
3 });
jQuery 方法与仅依赖于本地 web API 的方法几乎相同。无论哪种方式,我们都满足了要求:不能在页面上选择或拖动文本或图像。
这可能是开始讨论Event
对象的好时机。之前,当 jQuery 或浏览器执行一个Event
实例时,它被传递给我们的事件处理函数。W3C DOM4 规范中定义了本地Event
接口。 14 当创建自定义或本地 DOM 事件时,浏览器会创建一个Event
实例,并在捕获和冒泡阶段将其传递给每个注册的侦听器。
传递给每个侦听器的事件对象包含许多属性,例如描述相关事件的属性,例如:
- 事件类型(单击、鼠标按下、焦点)
- 创建事件的元素
- 当前事件阶段(捕获或冒泡)
- 处于冒泡或捕获阶段的当前元素
还有其他类似的属性,但该列表代表了更值得注意的属性的一个很好的样本。除了描述事件的属性之外,还有许多允许控制事件的方法。一个这样的方法是preventDefault()
,正如我刚才演示的。但是还有其他的,我很快就会谈到。
jQuery 有自己版本的Event
接口(当然)。 15 根据 jQuery 的文档,他们的事件对象“根据 W3C 标准对事件对象进行规范化”这对古代的浏览器可能很有用。但是对于现代浏览器来说,就不是这样了。在很大程度上,除了一些属性和方法之外,这两个接口非常相似。
接下来的两个示例演示了如何防止特定事件到达其他已注册的事件处理程序。为了防止单击事件到达后续 DOM 节点上的任何事件处理程序,只需在传递的Event
对象上调用stopPropagation()
。该方法存在于 jQuery Event
接口和标准化 web API Event
接口中:
1 $someElement.click(function(event) {
2 event.stopPropagation();
3 });
4
5 // ...or...
6
7 $someElement.on('click', function(event) {
8 event.stopPropagation();
9 });
使用 jQuery,您可以用stopPropagation()
阻止事件冒泡,但是您不能在捕获阶段阻止事件,除非您遵从 web API,如清单 10-7 所示。
1 // stop propagation during capturing phase
2 someElement.addEventListener('click', function(event) {
3 event.stopPropagation();
4 }, true);
5
6 // stop propagation during bubbling phase
7 someElement.addEventListener('click', function(event) {
8 event.stopPropagation();
9 });
Listing 10-7.Stop a Click Event from Propagating: Web API, Modern Browsers
web API 能够在捕获或冒泡阶段停止事件传播。但是stopPropagation()
不会阻止事件到达同一元素上的任何后续侦听器。对于这个任务,stopImmediatePropagation()
事件方法是可用的,它阻止事件到达任何进一步的处理程序,不管它们是在当前 DOM 节点还是后续节点上注册的。同样,jQuery(清单 10-8 )和 web API(清单 10-9 )共享相同的方法名,但是 jQuery 一如既往地被限制在冒泡阶段。
1 $someElement.on('click', function(event) {
2 event.stopImmediatePropagation();
3 });
Listing 10-8.
Stop a Click Event from Reaching Any Other Handlers:
jQuery
1 someElement.addEventListener('click', function(event) {
2 event.stopImmediatePropagation();
3 });
Listing 10-9.Stop a Click Event from Reaching Any Other Handlers: Web API, Modern Browsers
请注意,jQuery 和 web API 都提供了一种快捷方式来阻止事件的默认操作,并阻止事件到达后续 DOM 节点上的处理程序。通过在事件处理程序中返回false
,可以有效地调用event.preventDefault()
和event.stopPropagation()
。
将数据传递给事件处理程序
有时,与事件相关的标准数据是不够的。事件处理程序可能需要关于它们正在处理的事件的更具体的信息。还记得我前面详述的“uploadError”自定义事件吗?这是从嵌入在页面上的库中触发的,而“uploadError”事件的存在是为了向库外的侦听器提供有关文件上传失败的信息。假设我们使用的文件上传库附加到一个容器元素,我们的应用包装这个容器元素并注册一个“uploadError”事件处理程序。当一个特定的文件上传失败时,这个事件被触发,我们的处理程序向用户显示一条信息性消息。为了定制此消息,我们需要失败文件的名称。上传库可以将文件名传递给我们在Event
对象中的处理程序。
首先,让我们回顾一下如何使用 jQuery 将数据传递给事件处理程序:
1 // send the failed filename w/ an error event
2 $uploaderElement.trigger('uploadError', {
3 filename: 'picture.jpeg'
4 });
5
6 // ...and this is a listener for the event
7 $uploaderParent.on('uploadError', function(event, data) {
8 showAlert('Failed to upload ' + data.filename);
9 });
jQuery 通过传递给处理程序的第二个参数使传递给trigger()
函数的对象对任何事件监听器都可用。这样,我们可以访问传递的对象的任何属性。
为了用 web API 达到同样的结果,我们将利用CustomElement
及其内置的处理数据的能力:
1 // send the failed filename w/ an error event
2 var event = new CustomEvent('uploadError', {
3 bubbles: true,
4 detail: {filename: 'picture.jpeg'}
5 });
6 uploaderElement.dispatchEvent(event);
7
8 // ...and this is a listener for the event
9 uploaderParent.addEventListener('uploadError', function(event) {
10 showAlert('Failed to upload ' + event.detail.filename);
11 });
这不像 jQuery 解决方案那样简洁,但至少在除 IE 之外的所有现代浏览器中是有效的。对于更跨浏览器的解决方案,您可以依赖旧的自定义事件 API,如前所述。在下面的演示中,我将只关注旧的 API,但是我鼓励您阅读前面提到的一种更经得起未来考验的创建CustomEvent
实例的方法:
1 // send the failed filename w/ an error event
2 var event = document.createEvent('CustomEvent');
3 event.initCustomEvent('uploadError', true, true, {
4 filename: 'picture.jpeg'
5 });
6 uploaderElement.dispatchEvent(event);
7
8 // ...and this is a listener for the event
9 uploaderParent.addEventListener('uploadError', function(event) {
10 showAlert('Failed to upload ' + event.detail.filename);
11 });
在这两种情况下,附加到CustomEvent
的数据都可以通过标准化的detail
属性提供给我们的侦听器。 16 通过CustomEvent
构造函数,在创建新实例时传递的对象的detail
属性上提供该数据。这个对象上的detail
属性与我们的侦听器可用的CustomEvent
对象上的detail
属性相匹配,这很好而且一致。当使用旧的事件创建 API 设置我们的“uploadError”事件时,我们的侦听器仍然可以访问这个相同的detail
属性,但是它被隐藏在传递给initCustomEvent()
的大量参数中。根据我的经验,任何超过两个参数的东西都是令人困惑和不直观的。这并不是一个不常见的偏好,这可以解释为什么更现代的CustomEvent
构造函数只要求两个参数,第二个参数是一个提供所有定制配置和数据的对象。
事件委托:功能强大且未被充分利用
有很多次,我都在回避一个非常重要的话题:事件委派。简而言之,事件委托涉及将单个事件处理程序附加到顶级元素,目的是处理从后代元素冒泡出来的事件。此顶级元素中的事件处理程序逻辑可能包含基于事件目标元素(首先接收事件的元素)而不同的代码路径。但是为什么要这样做呢?为什么不直接将特定的事件处理程序附加到适当的元素上呢?
已经讨论得令人生厌的一个原因是委托事件处理程序的潜在性能优势。通过绑定单个事件处理程序来节省 CPU 周期,该处理程序负责监视许多后代元素上的事件,而不是查询每个元素并将专用的处理程序函数直接附加到每个元素。这个理论很有道理,当然也是真的。但是,从 CPU 周期的角度来看,这里真正节省了多少时间呢?我想这个问题的答案是:视情况而定。首先,这取决于您打算监控多少个元素。
很难想象一个常见的场景,其中委托事件处理既是可取的,又对性能有害。这种做法已经流行起来,部分是因为预期的性能原因,也是因为能够将事件处理代码集中到一个特定的根元素,而不是分散到整个 DOM。以 React 为例。React 是一个 JavaScript 库,专门关注典型模型视图控制器 web 应用的“视图”部分。在事件处理方面,React 实现了一个有趣的抽象: 17
- React 实际上并不将事件处理程序附加到节点本身。当 React 启动时,它开始使用单个事件监听器监听顶层的所有事件。
换句话说,附加到带有 React 的元素的所有事件处理程序都被提升为公共父元素上的单个委托事件处理程序。也许您仍然看不到适合委托事件处理程序的实例。在本节的其余部分,我将重点关注一个简单的例子,它展示了事件委托的强大功能。
假设您有一个充满列表项的列表,每个列表项都有一个从列表中删除项的按钮。您可以为每个列表项的按钮附加一个单击处理程序。但是,循环遍历所有按钮元素并为每个元素附加完全相同的点击处理函数,这难道不是一种错误的方法吗?你可能会认为这不是不合理的,甚至很容易做到。但是,如果在初始页面加载之后,新的项目可以动态地添加到这个列表中呢?现在,在添加新的列表项后,给每个新的列表项附加一个新的事件处理程序变得不那么吸引人了。
这里的最佳解决方案是使用事件委托。换句话说,将一个单击处理程序附加到列表元素。当单击列表项元素中的任何删除按钮时,事件将冒泡到列表元素。此时,您的一个事件处理程序将被触发,通过检查事件对象,您可以很容易地确定哪个列表项被单击,并通过删除关联的列表项做出适当的响应。在这一节中,我们使用一些文本来使我们的删除按钮更容易访问,以及关闭/删除 Ionicons 网站 18 中的图标来增强我们按钮的外观。
此类列表的 HTML 可能如下所示:
1 <link href="http://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
2 rel="stylesheet">
3 <ul id="cars-list">
4 <li>Honda
5 <button>
6 <span>delete</span>
7 <span class="ion-close-circled"></span>
8 </button>
9 </li>
10 <li>Toyota
11 <button>
12 <span>delete</span>
13 <span class="ion-close-circled"></span>
14 </button>
15 </li>
16 <li>Kia
17 <button>
18 <span>delete</span>
19 <span class="ion-close-circled"></span>
20 </button>
21 </li>
22 <li>Ford
23 <button>
24 <span>delete</span>
25 <span class="ion-close-circled"></span>
26 </button>
27 </li>
28 </ul>
使用 jQuery,我们可以使用click
别名将一个点击处理程序附加到<ul>
上,并通过检查事件对象来删除适当的汽车列表项:
1 $('#cars-list').on('click', 'button', function() {
2 $(this).closest('li').remove();
3 });
但是等等,我们根本不需要检查事件对象!jQuery 通过将事件处理函数的上下文(this
)设置为 click 元素目标,提供了一个很好的特性。注意,这个点击事件可能指向“delete”span 元素或“x”图标,这取决于用户选择了这些元素中的哪一个。无论哪种情况,我们只对点击<button>
或其子节点感兴趣。jQuery 确保我们的事件处理程序只有在这种情况下才会被调用,此时我们可以使用 jQuery 的closest()
方法找到关联的<li>
并将其从 DOM 中移除,如清单 10-10 所示。
1 document.querySelector('#cars-list')
2 .addEventListener('click', function(event) {
3 if (event.target.closest('BUTTON')) {
4 var li = event.target.closest('LI');
5 li.parentElement.removeChild(li);
6 }
7 });
Listing 10-10.
Delegated Event Handling
: Web API, All Modern Browsers (with closest Shim)
上面的纯 web API 解决方案比 jQuery 的 API 要罗嗦一点(好吧,罗嗦很多)。但是它展示了如何在没有 jQuery 的情况下完成一些常见的目标:
- 将 click 事件处理程序附加到元素。
- 检查一个
Event
对象,关注仅由我们的<button>
元素之一触发的事件。 - 移除与点击的删除按钮相关联的
<li>
。
我们在这里利用Element.closest
很容易地找到父<li>
并确定事件目标的父是否确实是一个<button>
,所有这些都不需要明确处理事件目标可能在<button>
或<li>
之下的多个层次的事实。由于 Internet Explorer、Microsoft Edge(至少从版本 13 开始)或 iOS Safari 和 Android 的旧版本不支持Element.closest
,如果您需要可靠的跨浏览器支持,您将需要使用第四章中演示的垫片。
与 jQuery 相比,这可能看起来有点不雅,这可能确实是真的。但是请记住,这本书的使命不一定是强迫你避开 jQuery 或任何其他库,而是向你展示如何在没有第三方依赖的帮助下自己解决同样的问题。从这些练习和演示中获得的知识将通过提供对 web API 的洞察力来增强您作为 web 开发人员的能力,并允许您在决定您的项目是否将受益于一些外部帮助时做出更好的决策。也许你会选择引入小而集中的填充(比如前面演示的Element.closest
polyfill ),而不是依赖 jQuery 这样的大型库。
处理和触发键盘事件
如您所料,键盘事件是当用户按下键盘上的一个键时浏览器触发的本地 DOM 事件。就像所有其他事件一样,键盘事件会经历捕获和冒泡两个阶段。目标元素是按下键时聚焦的元素。你可能想知道为什么我专门为键盘事件开辟了一个特殊的部分。很快您就会看到,键事件与前面讨论的其他 DOM 和定制事件有些不同。另外,键盘事件比其他 DOM 事件更难处理。
这主要是由于最容易被误解的多键盘事件类型,以及用于标识不同浏览器支持的按键的令人困惑的事件属性阵列。不要担心——在本节之后,您将对键盘事件有一个相当好的理解。您将完全理解何时使用三种类型的键盘事件,如何识别按下的键,甚至如何将这些知识用于解决实际问题。
三种类型的键盘事件
键盘上的每个键都不是独立的事件类型。相反,键盘触发的动作被附加到三种可能的键盘特定事件类型之一:
- 击键
- 好好享受吧
- 键击器
在按下的键被释放之前,浏览器触发“keydown”事件。如果该键被按住,它可能会重复触发,并且对于键盘上的任何键(甚至 Shift/Ctrl/Command/other)都会触发。虽然有些键在被按住时不会触发多个“keydown”事件,例如 Shift/Command/Option/Function。jQuery 在其 API 中提供了一个keydown
别名来处理这些事件:
1 $(document).keydown(function(event) {
2 // do something with this event
3 });
同样的事件可以在没有 jQuery 的情况下处理(对于 web API 和现代浏览器):
1 document.addEventListener('keydown', function(event) {
2 // do something with this event
3 });
在按下位置的一个键(任何键)被释放后,浏览器触发一个“keyup”事件。处理该事件的逻辑与“keydown”相同,只需在刚才给出的代码示例中用“keyup”替换“keydown”即可。事实上,“keypress”事件也是如此,它是最终的键盘事件类型。“keypress”事件与“keydown”非常相似,当按下的键处于按下位置时,也会触发该事件。唯一的区别是“按键”事件只为可打印字符触发。例如,按下“a”、“Enter”或“1”键将触发“按键”事件。相反,“Shift”、“Command”和箭头键不会导致“keypress”事件。
识别按下的按键
假设我们正在构建一个模态对话框。如果我们对话框的用户按下“Esc”键,我们将关闭对话框。为了实现这一点,我们需要做几件事:
- 侦听文档上的“keydown”事件。
- 确定“keydown”事件是否对应于“Esc”键。
- 如果“keydown”事件是按“Esc”的结果,则关闭模式对话框。
如果我们使用 jQuery,我们的代码看起来会像这样:
1 $(document).keydown(function(event) {
2 if (event.which === 27) {
3 // close the dialog...
4 }
5 });
数字 27 对应于 ESC 键的键码。 19 我们可以在没有 jQuery 的情况下使用相同的关键代码(用于 web API 和现代浏览器),方法是查看我们的处理程序接收到的KeyboardEvent
上的which
属性:
1 document.addEventListener('keydown', function(event) {
2 if (event.which === 27) {
3 // close the dialog...
4 }
5 });
但是 web API 在这方面开始超越 jQuery。W3C 维护的 UI 事件规范 20 定义了一个关于KeyboardEvent
: key
的新属性。 21 当一个键被按下时,KeyboardEvent
(在支持的浏览器中)将包含一个key
属性,其值对应于被按下的确切键(对于可打印字符)或描述被按下的键的标准化字符串(对于不可打印字符)。例如,如果按下“a”键,相应的KeyboardEvent
上的key
属性将包含值“a”。在我们的例子中,Esc 键被表示为字符串“Escape”。这个值以及其他不可打印字符的key
值在 DOM Level 3 Events 规范中定义, 22 也由 W3C 维护。如果我们能够使用这个key
属性,我们的代码将如清单 10-11 所示。
1 document.addEventListener('keydown', function(event) {
2 if (event.key === 'Escape') {
3 // close the dialog...
4 }
5 });
Listing 10-11.Closing a Modal Dialog on Esc: Web API, All Modern Browsers Except Safari
目前,Safari 是唯一不支持这种KeyboardEvent
属性的现代浏览器。随着 WebKit 引擎的发展,这种情况可能会改变。与此同时,在 Safari 更新之前,您可能会考虑继续使用which
属性。
用 Web API 制作可访问的图像转盘键盘
掌握键盘事件的一个重要好处是:可访问性。可访问的 web 应用是那些具有不同需求的人可以容易地使用的应用。也许最常见的可访问性考虑包括确保那些不能使用定点设备的人能够完全有效地导航 web 应用。在某些情况下,这需要监听键盘事件并做出适当的响应。
假设您正在构建一个图像轮播库。图像 URL 的数组被传递到 carousel,第一个图像呈现在全屏模式对话框中,用户可以通过单击当前图像任一侧的按钮来移动到下一个或上一个图像。使用键盘在图像中循环成为可能允许那些不能使用定点设备的人使用转盘。它还为那些不想使用鼠标或触控板的人增加了便利。
为了简单起见,假设我们的图片库 HTML 模板如下所示:
1 <div class="image-gallery">
2 <button class="previous" type="button">Previous</button>
3 <img>
4 <button class="next" type="button">Next</button>
5 </div>
当按钮被点击时,JavaScript 循环显示图像,如下所示(适用于现代浏览器):
1 // assuming we have an array of images in an `images` var
2 var currentImageIndex = 0;
3 var container = document.querySelector('.image-gallery');
4 var updateImg = function() {
5 container.querySelector('IMG').src =
6 images[currentImageIndex];
7 };
8 var moveToPreviousImage = function() {
9 if (currentImageIndex === 0) {
10 currentImageIndex = images.length - 1;
11 }
12 else {
13 currentImageIndex--;
14 }
15 updateImg();
16 };
17 var moveToNextImage = function() {
18 if (currentImageIndex === images.length - 1) {
19 currentImageIndex = 0;
20 }
21 else {
22 currentImageIndex++;
23 }
24 updateImg();
25 };
26
27 updateImg();
28
29 container.querySelector('.previous')
30 .addEventListener('click', function() {
31 moveToPreviousImage();
32 });
33
34 container.querySelector('.next')
35 .addEventListener('click', function() {
36 moveToNextImage();
37 });
如果我们想让用户用左右箭头键在图像组中移动,我们可以将键盘事件处理程序附加到左右箭头上,并适当地委托给现有的moveToPreviousImage()
和moveToNextImage()
函数:
1 // add this after the block of code above:
2 document.addEventListener('keydown', function(event) {
3 // left arrow
4 if (event.which === 37) {
5 event.preventDefault();
6 moveToPreviousImage();
7 }
8 // right arrow
9 else if (event.which === 39) {
10 event.preventDefault();
11 moveToNextImage();
12 }
13 });
添加的event.preventDefault()
确保了我们的箭头键只改变这个上下文中的图像,而不提供任何不需要的默认动作,比如滚动页面。在我们的例子中,不清楚我们的转盘何时不再使用,但是我们可能会提供一些机制来关闭转盘。一旦传送带关闭,不要忘记使用removeEventListener()
来注销 keydown 事件处理程序。您需要重构事件侦听器代码,以便将逻辑移到独立的函数中。通过将“keydown”事件类型作为第一个参数,将事件监听器函数变量作为第二个参数传递给removeEventListener()
,这将使取消注册 keydown 处理程序变得容易。有关使用removeEventListener()
的更多信息,请查看前面关于观察事件的章节。
确定某物何时装载
作为一名 web 开发人员,您可能会在某个时候想到以下问题:
- 什么时候页面上的所有元素都完全加载并使用应用的样式呈现?
- 什么时候所有的静态标记都放在页面上了?
- 页面上的特定元素何时被完全加载?什么时候元素加载失败?
所有这些问题的答案都在浏览器的本地事件系统中。W3C UI Events 规范中定义的“加载”事件, 23 允许我们确定元素或页面何时被加载。还有一些其他的相关事件,比如“DOMContentLoaded”和“beforeunload”。我将在本节中讨论这两个问题。
什么时候页面上的所有元素都完全加载并使用应用的样式呈现?
为了回答这个特殊的问题,我们可以依靠由window
对象触发的“load”事件。此事件将在以下时间后触发:
- 所有标记都已放置在页面上。
- 所有样式表都已加载。
- 所有的
<img>
元素都已加载。 - 所有的
<iframe>
元素都已完全加载。
jQuery 为“load”事件提供了一个别名,类似于许多其他 DOM 事件:
1 $(window).load(function() {
2 // page is fully rendered
3 });
但是您可以(也应该)使用通用的 on()方法,并传入事件的名称——“load”——特别是因为 load()别名已被弃用,并在 jQuery 3.0 中被删除。下面是同一个侦听器,但没有不推荐使用的别名:
1 $(window).on('load', function() {
2 // page is fully rendered
3 });
web API 解决方案看起来几乎与前面的 jQuery 代码一模一样。我们正在使用所有现代浏览器都可用的addEventListener()
,并传递事件的名称,然后在页面加载后调用一个回调函数:
1 window.addEventListener('load', function() {
2 // page is fully rendered
3 });
但是我们为什么要关心这个事件呢?为什么知道页面何时完全加载很重要?对 load 事件最常见的理解是,应该使用它来确定何时执行 DOM 操作是安全的。这在技术上是正确的,但是等待“load”事件可能是不必要的。在操作文档之前,您真的需要确保所有的图像、样式表和 iframes 都已经加载了吗?可能不会。
什么时候所有的静态标记都放在页面上了?
这里我们可以问的另一个问题是:我可以安全地操作 DOM 的最早时间点是什么时候?这个问题的答案和这个标题中的问题是一样的:等待浏览器触发“DOMContentLoaded”事件。此事件在所有标记都放置在页面上后触发,这意味着它通常比“加载”早得多。
jQuery 提供了一个“就绪”函数,它反映了本机“DOMContentLoaded”的行为。但是在幕后,它在现代浏览器中委托给“DOMContentLoaded”本身。下面是您确定页面何时可以与 jQuery 交互的方法:
1 $(document).ready(function() {
2 // markup is on the page
3 });
你甚至可能熟悉速记版本:
1 $(function() {
2 // markup is on the page
3 });
因为 jQuery 只是利用现代浏览器中浏览器的本地“DOMContentLoaded”事件来提供它的ready
API 方法,所以我们可以使用“DOMContentLoaded”和addEventListener()
来构建我们自己的ready
:
1 document.addEventListener('DOMContentLoaded', function() {
2 // markup is on the page
3 });
请注意,您可能希望确保将注册“DOMContentLoaded”事件处理程序的脚本放在任何样式表<link>
标记之前,因为加载这些样式表会阻止任何脚本执行,并延长“DOMContentLoaded”事件,直到定义的样式表完全加载完毕。
页面上的特定元素何时被完全加载?什么时候加载失败了?
除了window
,加载事件还与许多元素相关联,例如<img>
、<link>
和<script>
。这个事件在window
之外最常见的用途是确定特定图像何时被加载。适当命名的“错误”事件用于表示加载图像失败(例如,<link>
或<script>
)。
正如您所料,jQuery 的 API 中有“load”和“error”事件的别名,但这两个别名都被弃用,并在 jQuery 3.0 中从库中删除。因此,要确定 jQuery 是否加载了图像,我们只需依靠 on()方法。我们的代码应该是这样的:
1 $('IMG').on('load', function() {
2 // image has successfully loaded
3 });
4
5 $('IMG').on('error', function() {
6 // image has failed to load
7 });
jQuery 中使用的事件名称和浏览器本地事件系统中使用的事件名称之间存在一对一的映射。正如您所料,jQuery 依赖浏览器的“load”和“error”事件来分别表示成功和失败。因此,通过向addEventListener()
注册这些事件,可以在没有 jQuery 的情况下达到相同的目的:
1 document.querySelector('IMG').addEventListener('load', function() {
2 // image has successfully loaded
3 });
4
5 document.querySelector('IMG').addEventListener('error', function() {
6 // image has failed to load
7 });
正如我们以前多次看到的,jQuery 和现代浏览器的 web API 之间的语法惊人地相似。
防止用户意外离开当前页面
把自己想象成一个用户(我们不可能总是开发者)。您正在填写一系列(长)表单域。完成表格需要十分钟,但你终于完成了。还有。。。然后。。。你。。。意外地。。。关闭。。。浏览器选项卡。你所有的工作都没了!将“填表”替换为“写文档”或“画图”不管情况如何,这是一个悲剧性的转折。作为一个开发者,你怎样才能把你的用户从这个错误中拯救出来?你能吗?你可以!
“beforeunload”事件在当前页面卸载之前在window
上触发。通过观察此事件,您可以强制用户确认他们确实想要离开当前页面,或者关闭浏览器,或者重新加载页面。他们将看到一个确认对话框,如果他们选择取消,他们将安全地停留在当前页面。
在 jQuery-land 中,您可以使用on
API 方法观察这个事件,并在确认对话框中为用户返回一条消息:
1 $(window).on('beforeunload', function() {
2 return 'Are you sure you want to unload the page?';
3 });
请注意,并非每个浏览器都会显示此特定消息。有些总是显示硬编码的消息,jQuery 对此无能为力。
web API 方法是相似的,但是我们必须处理在不同浏览器之间实现这个事件的一个小差别:
1 window.addEventListener('beforeunload', function(event) {
2 var message = 'Are you sure you want to unload the page?';
3 event.returnValue = message;
4 return message;
5 });
一些浏览器接受处理函数的返回值作为显示给用户的文本,而另一些浏览器采用更非标准的方法,要求在事件的returnValue
属性上设置该消息。尽管如此,这并不是一个可以跳过的铁环。无论哪种方式都很简单。
历史课:古代浏览器支持
在事件章节的最后一节,我描述了 jQuery 是 web 应用必需的库的时代。本节仅适用于古代浏览器。当普遍支持 Internet Explorer 8 时,web API 在某些情况下有点混乱。在处理浏览器的事件系统时尤其如此。在这一节中,我将讨论如何在古老的浏览器中管理、观察和触发事件。因为对古代浏览器的担心变得不那么重要了,所有这些都更像是一堂历史课,而不是教程。请在阅读本节时记住这一点,因为它并不打算成为超老式浏览器中事件处理的全面指南。
用于监听事件的 API 是非标准的
请看下面的代码片段,它注册了一个点击事件:
1 someElement.attachEvent('onclick', function() {
2 // do something with the click event...
3 });
您会注意到这与现代浏览器方法之间的两个明显区别:
- 我们依靠的是
attachEvent
而不是addEventListener
。 - 点击事件名称包括前缀“on”。
方法attachEvent()
24是微软 ie 浏览器的专利。事实上,直到(包括)Internet Explorer 10,它仍然受到技术支持。attachEvent()
从未成为任何官方标准的一部分。除非你必须支持 IE8 或更高版本,否则完全避免使用attachEvent()
。W3C 标准化的addEventListener()
为观察事件提供了更加优雅和全面的解决方案。
也许您想知道如何根据当前浏览器的功能以编程方式使用正确的事件处理方法。如果你是专门为现代浏览器开发应用,这不是一个问题。但是,如果出于某种原因,您必须使用 IE8(或更早版本)等古老的浏览器,您可以使用以下代码在任何浏览器中注册一个事件:
1 function registerHandler(target, type, callback) {
2 var listenerMethod = target.addEventListener
3 || target.attachEvent,
4
5 eventName = target.addEventListener
6 ? type
7 : 'on' + type;
8
9 listenerMethod(eventName, callback);
10 }
11
12 // example use
13 registerHandler(someElement, 'click', function() {
14 // do something with the click event...
15 });
而如果你想在一个古老的浏览器中移除一个事件处理程序,你必须使用detachEvent()
而不是removeEventListener()
。detachEvent()是另一种非标准的专有 web API 方法。如果您正在寻找跨浏览器删除事件侦听器的方法,请尝试以下方法:
1 function unregisterHandler(target, type, callback) {
2 var removeMethod = target.removeEventListener
3 || target.detachEvent,
4
5 eventName = target.removeEventListener
6 ? type
7 : 'on' + type;
8
9 removeMethod(eventName, callback);
10 }
11
12 // example use
13 unregisterHandler(someElement, 'click', someEventHandlerFunction);
表单字段更改事件是一个雷区
非常旧版本的 Internet Explorer 有一些严重的变更事件缺陷。下面是你可能遇到的两个大问题(如果你还没有遇到的话):
- 旧版本 IE 中的更改事件不会冒泡。
- 在旧版本的 IE 中,复选框和单选按钮可能根本不会触发更改事件。
请记住,在很长一段时间内将 jQuery 与 IE7 和 ie8 一起使用时,第二个问题也会重现。据我所知,jQuery 的当前版本确实很好地解决了这个问题。但是这又一次提醒我们,jQuery 并不是没有缺陷。
要解决变更事件问题,您必须将一个变更处理程序直接附加到您想要监视的任何表单域,因为事件委托是不可能的。为了解决复选框和单选按钮的难题,最好的办法可能是将单击处理程序直接附加到单选/复选框字段(或者将处理程序附加到父元素并利用事件委托),而不要依赖于 change 事件的发生。
Note
通常,回车键会触发一个点击事件(例如在一个按钮上)。换句话说,点击事件不仅仅是由定点设备触发的。出于可访问性的原因,通过键盘激活一个元素也会触发一个点击事件。在复选框或单选按钮输入元素的情况下,“enter”键不会激活表单域。相反,需要“空格键”来激活复选框或单选按钮并触发单击事件。
事件对象也是非标准的
在老版本的浏览器中,Event
对象实例的一些属性有些不同。例如,虽然现代浏览器中的事件目标可以通过检查Event
实例的target
属性来找到,但 IE8 和更早版本包含了该元素的不同属性:srcElement
。跨浏览器事件处理函数的相关部分可能如下所示:
1 function myEventHandler(event) {
2 var target = event.target || event.srcElement
3 // ...
4 }
在现代浏览器中,event.target
将会是 truthy,它缩短了刚刚给出的条件求值。但是在 IE8 和更早的版本中,Event
对象实例将不包含target
属性,所以target
变量将是Event
上srcElement
属性的值。
在控制事件方面,stopPropagation()
方法在 IE8 和更早版本的Event
对象实例上不可用。如果您想阻止事件冒泡,您必须在Event
实例上设置非标准的cancelBubble
属性。跨浏览器解决方案如下所示:
1 function myEventHandler(event) {
2 if (event.stopPropgation) {
3 event.stopPropagation();
4 }
5 else {
6 event.cancelBubble = true;
7 }
8 }
IE8 和更老的版本也没有stopImmediatePropagation()
方法。没有太多的方法可以解决这个限制。然而,我个人并不认为缺乏这种方法是一个大问题。使用stopImmediatePropagation()
对我来说就像是一种代码味道,因为这个调用的行为完全依赖于多个事件处理程序附加到相关元素的顺序。
本章的要点是:在没有 jQuery 的现代浏览器中处理事件非常简单,但是如果您不幸支持 Internet Explorer 8 或更早版本,可以考虑使用这里演示的跨浏览器功能,或者引入一个可靠的事件库来满足更复杂的事件处理需求。
Footnotes 1
www.w3.org/TR/DOM-Level-3-Events
2
https://developer.mozilla.org/en-US/docs/Web/Events
3
www.w3.org/TR/uievents/#h-event-interfaces
4
www.w3.org/TR/DOM-Level-2-Events/
5
https://signalvnoise.com/posts/3137-using-event-capturing-to-improve-basecamp-page-load-times
6
www.w3.org/TR/uievents/#event-type-focus
7
www.w3.org/TR/uievents/#event-type-blur
8
www.w3.org/TR/html5/dom.html#htmlelement
9
www.w3.org/TR/html5/forms.html#the-form-element
10
www.w3.org/TR/DOM-Level-2-Events/
11
www.w3.org/TR/DOM-Level-3-Events/#widl-DocumentEvent-createEvent
12
www.w3.org/TR/DOM-Level-2-Events/
13
www.w3.org/TR/DOM-Level-3-Events/#event-type-mousedown
14
www.w3.org/TR/dom/#interface-event
15
https://api.jquery.com/category/events/event-object/
16
https://dom.spec.whatwg.org/#dom-customevent-detail
17
18
19
20
21
www.w3.org/TR/uievents/#widl-KeyboardEvent-key
22
www.w3.org/TR/DOM-Level-3-Events-key/
23
www.w3.org/TR/uievents/#event-type-load
24
https://msdn.microsoft.com/en-us/library/ms536343(VS.85).aspx
十一、掌握异步任务
我希望大多数 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
www.ecma-international.org/ecma-262/5.1/#sec-15.3.4.5
十三、后记
这很有趣,但是,可悲的是,超越 jQuery 最终还是要结束。已经投入了大量的时间和研究来向您展示浏览器和 JavaScript 本身中存在 jQuery 的可行替代方案。事实上,甚至我在整理这本书时偶然发现了一些新知识。即使您决定依赖一个熟悉且方便的选项,比如 jQuery,您现在也能更好地理解这个特定的抽象是如何工作的。这些知识不仅可以为您的下一个项目做好准备,还可以帮助您解决在仍然与 jQuery 相关的现有项目中遇到的性能或行为问题。
正如我在本书开篇指出的,我们都是软件开发人员、web 开发人员和 JavaScript 开发人员,而不是 jQuery、Zepto、AngularJS 或(在此插入您喜欢的库或框架)开发人员。图书馆来了又走,但是网络和将它结合在一起的语言将会存在更久。
我们该如何利用这些新发现的知识呢?我知道我要做什么,但你知道吗?对一些人来说,你的下一步行动可能是显而易见的。对其他人来说,就没那么多了。可能不清楚你的选择是什么,或者你应该如何进行当前和未来的项目。你应该改变什么吗?你有什么问题是本书中没有回答的呢?您如何跟上不断发展的形势?
这最后一章为超越 jQuery 之后的生活提供了一些建议和指导。
现在怎么办?
如果你在阅读本书时对 JavaScript 和 web API 的了解只有我写这本书时的一半,那么你可能有很多东西要思考。对于这本书的内容和基调,你可以有很多选择,也可能有很多不同的反应。实际上,我认为读者有四条共同的前进道路。虽然有些人可能会选择其中一条道路,但其他人可能会发现这一部分概述了本书完成后的四个连续阶段。
选项/阶段 1:忽略一切
也许你仍然目中无人,甚至读完了整本书(或者你只是浏览了一下)。语言和 DOM API 都很难看。jQuery 仍然是最好的选择。我们都有权表达自己的观点。使用 jQuery 并没有错,只要不是在不知道其他选择的情况下做出的选择。除非这仅仅是一个以开悟结束的过程的第一阶段,否则不太可能有太多的事情会改变你的想法。我只能希望你至少从这本书里受益了一点点。也许你学会了一个新的 CSS 选择器,或者对承诺有了更好的理解。不管怎样,如果 Beyond jQuery 为您提供了一些新知识,让您成为一名更强的开发人员,那么我当然很高兴。
选项/阶段 2:将 jQuery 从所有项目中剥离出来
一些读者可能有冲动采取与选项 1 相反的方法。取代自由放任的态度,你可以决定对你所有的活动(和非活动)项目进行重大的重构。您的口头禅可能是“删除所有的 jQuery,用普通的 JS 替换。”我当然可以尊重这种观点(我自己也一度有这种感觉),但我会建议你退后一步,深呼吸,先思考一下这个决定的后果。你的项目有一套全面的自动化测试吗?你有相当多的空闲时间吗?你没有其他更重要的事情要做了吗?如果其中一个或多个问题的答案是否定的,那么我建议你不要节外生枝,对现有的代码库进行大量不必要的修改——除非下面至少有一个是正确的:
- 这个项目相当小。
- 您实际上没有任何(或许多)用户,这更像是让您开始走普通 JS 道路的学术练习。
- 由于其他原因,一个主要的重构已经在进行中,或者你已经计划好了。
- 在对项目进行概要分析之后,您当前对 jQuery 的使用导致了显著的性能问题。
如果这些描述了你的情况,祝你编码愉快,并祝你好运!Beyond jQuery 将成为您探索过程中的有用参考。
选项/阶段 3:在一个角落里来回摇晃,哭泣
“我不能或者不应该再使用 jQuery 了?!"对于许多人来说,jQuery 是他们整个职业生涯中每个 web 项目的核心组件。一想到要放弃这样一个可靠和熟悉的帮手,对一些人来说可能是毁灭性的。但是不用担心,你不必放弃 jQuery,正如我在本书中已经多次提到的,除非你真的想放弃。因此,如果你更喜欢脱离自己,一点一点地慢慢转向更接近金属的解决方案,那就尽管去做吧。jQuery(可能)不会去任何地方。让这一阶段成为你走向读后过程中最后也是最重要的一步时的一小段颠簸。
选项/阶段 4:决定变得更有洞察力
如果你在这里结束(或开始),那么我已经完成了我用这本书设定的目标之一。它不仅仅是那些希望超越特定库的人的指南——它的目的还在于让你开始更批判性地思考库、框架和浏览器端的依赖关系。就个人而言,我发现这种思维方式阻止了我(尽管不一定是我周围的人)在新项目中不必要地复杂化依赖树。这种心态帮助我变得更节俭、更谨慎,并迫使我花更多的时间思考,花更少的时间编码。我不确定这是可以教授的,但是也许,通过足够的灌输,你可以自己采用这种精神状态。
Web 开发的未来
当然,确切地预测软件开发的未来是不可能的,尤其是因为技术在不断发展,通用工作流变化得如此之快和频繁。不过,想想我们的世界在遥远的未来会是什么样子还是很有趣的。当你思考(并经历)web 开发的未来时,我认为记住一套可靠的最佳实践是很重要的。你不能因为有人告诉你就简单地遵循一套实践。你自己必须相信他们。
有些人会说“最佳实践”会随着时间的推移而发展,这在某种程度上是正确的。虽然在开发一个复杂的项目时,有一套既定的目标和指导方针是很重要的,但是很少有硬性的规则,并且在决定什么样的最佳实践最合适时,上下文是关键。放弃既定的最佳实践可能有很多原因。一旦你决定遵守一套特定的指导方针,重要的是不要仅仅因为方便(或懒惰)而抛弃它们。抄近路“仅此一次”是很容易的,尤其是当软件技术领域以当前极快的速度发展时,你将面临令人眼花缭乱的选择。以下是我喜欢遵循的一些公认的“最佳实践”,其中一些在本书中有所提及:
- 保持关注点的分离,不一定是在 HTML、CSS 和 JavaScript 之间,而是在行为和角色方面。尽管我承认这种特殊的最佳实践有各种各样的解释。比如 React 有没有违背这个原则?既然 vue.js 在 HTML、CSS 和 JavaScript 之间保持了更清晰的分离,那么它是不是更好的选择?严格地说,在代码的上下文中,关注点不一定需要按照文件来划分。虽然我确实相信这个特殊的例子说明了这一点,但这可能是另一本书的一个更大的争论。
- 开发专注于做一件事的小组件,并把它做好。
- 保持依赖的轻松和集中。谨慎使用外部依赖。并且要有眼光。
- 在依赖任何依赖项之前,先探索它的代码库。设计的好吗?保养的很好?考得好?该依赖关系的开发人员是否认同同一套最佳实践?
- 使用依赖项时,优先使用垫片和聚合填充,而不是库和框架。这并不是说所有的库和框架都不好,应该避免,但我倾向于更小、更专注的库和框架。
- 只要可行,随时随地编写自动化测试。这将减少重构的风险和痛苦。一些开发人员坚持认为测试驱动开发是编写测试的唯一方法,但是我不同意。根据我的经验,TDD 可能让人不知所措,而且并不适合所有情况。重要的是你写的测试可靠全面,不一定是你怎么写的。
当然,还有其他的,但是这代表了我遵循的最著名的最佳实践的列表,它们对我很有帮助。但是为什么我在“Web 开发的未来”一节中花了这么多时间来讨论最佳实践呢?简单:我觉得有一套指导原则来让你在不断变化的新技术中导航是很重要的。
正如我在本章(和本书)中多次提到的,网络技术似乎正在以令人难以置信的速度变化和发展。我自己发现很难跟上最新的发展。但是我们关注的进步往往局限于库和框架。这些通常是我们不断发展的行业中最“有趣”的方面。正如 web 和 JavaScript 规范是 jQuery 发挥其魔力的基石一样,所有最新的华丽的库和框架也是如此。随着标准的发展,库和框架也能够随之发展。Web 的未来在于 W3C、WHATWG 和 ECMAScript 规范。遵循最新的库和框架,但也要密切关注标准。对现有规范的补充和新规范的诞生将使您和其他人能够为复杂问题创建更强大、高效和高性能的解决方案。
进一步阅读和参考
学习永远不会停止,尤其是在我们这个行业。无论何时,当您想重温 DOM 操作、Ajax 请求、一般的 JavaScript 或这里涉及的任何其他主题时,Beyond jQuery 可以而且应该成为您的新参考。但这本书不能成为你唯一的参考。它从来都不是 web 和 JavaScript 的详尽来源。这本书的重点一直是 web API 和语言与 jQuery 重叠的领域。这使得大范围的材料被讨论,但无可否认的是,也有其他主题不得不被排除在外。
对于本书中未涉及的所有内容,我会推荐一组资源,因为我自己也非常依赖它们。下面的一些内容也是为了让你的 web 开发和 JavaScript 知识保持最新,这是一本书所不能提供的。对于每一个推荐的资源,我都解释了所有值得注意的限制和优势。
官方网站和语言规范
规范本身——WHATWG、 1 W3C、 2 和 ECMA-2623——在 web API 和 JavaScript 方面掌握了最多的信息。它们包含了令人难以置信的大量细节,可以被视为本书中讨论的所有原生 API 的真实来源。我在第三章中介绍了这些标准文档。不幸的是,这些文档对大多数人来说也有点难以理解。如果你真的想了解某个特定方法或特性的每一个细节,并且愿意并且能够花时间去破译这些文档中的深奥语言,那么官方规范是一个理想的资源。不然还有更合适的选择。
TC39 小组负责管理 JavaScript 规范,维护着一个 GitHub 组织。 4 在那里,他们记录着自己的会议笔记、议程和提案。这是一个了解 ECMA-262 最新进展的好地方,有时提案和会议记录比官方标准文档本身更容易理解。在这个组织中,我最喜欢的参考资料是建议书存储库中的 README.md 文件 5 。 6 在那里,你可以看到每个提案的最新进展,从阶段 1 到阶段 3。这个文档甚至链接到一个 0 阶段提案的列表,它提供了关于语言遥远未来的可能性的洞察力。
Mozilla 开发者网络
毫无疑问,Mozilla Developer Network 是官方标准文档的最清晰、最简洁的替代品。如果我在寻找一个特定的 web API 或 JavaScript 方法的细节,这通常是我的第一参考选择。除了易于解析的语言和代码示例之外,MDN 还包括几乎所有界面的浏览器支持矩阵。对于跨浏览器不太支持的 API 项,MDN 通常包含一个聚合填充的代码,您可以简单地将它放入项目中。如果您真的想看一看底层的规范文档,每一页都包括一个表格,其中详细说明了某个特定特性首次推出的时间,以及到官方文档的链接。Mozilla Developer Network 被许多人(包括我在内)认为是当今 web 开发人员最好的综合参考资料。
Caniuse.com
如果你正在寻找浏览器支持某个特定功能、一组相关功能,甚至一组不相关功能的更多细节,caniuse.com 是最终的资源。对于任何特定的界面或功能,caniuse.com 以表格的形式描述了浏览器支持,每个流行的浏览器都有单独的一列。对每个特性的支持都可以追溯到古代的浏览器(甚至是 Internet Explorer 6)。表中还注明了例外情况。以 FileReader API 为例——can use 包括链接到一篇文章(由我撰写) 8 的注释,该文章描述了在 iOS 8 中实现该 API 时引入的关键 bug。每个功能都跟踪这些类型的问题和其他相关资源,例如 MDN 页面的链接、规范文档、特定于浏览器的支持文档,甚至重要的特定于浏览器的实施说明。例如,在 SVG favicons 支持表上,有一条注释指出 Firefox 要求所提供资源的 MIME 类型为“image/svg+xml”。
Caniuse 还允许您按浏览器、功能类别和浏览器类型(桌面、移动等)对信息进行分组。一个简洁的功能是将支持表呈现为一个条形图,它根据每个浏览器版本的相对使用情况来确定特定浏览器下每个浏览器版本条的高度。并且如果需要,该相对浏览器版本数据也可以是区域特定的。虽然我更喜欢 MDN 对 web 和 JavaScript 特性的易懂解释,但在浏览器支持信息方面,caniuse 是无可匹敌的。
堆栈溢出
我们大多数人已经在堆栈溢出上花费了相当多的时间。毕竟,似乎大多数软件开发问题,当输入你选择的搜索引擎时,会在结果列表的顶部显示堆栈溢出。一个严肃的问答网站也是如此,它有一套严格的规则和一个致力于执行这些规则的社区。SO 社区保持的这种纯粹和完美的水平经常让新的或临时的贡献者感到沮丧,但它也使该网站成为所有编程的惊人有效的资源。
甚至看似微不足道的话题也会在堆栈溢出上表现出来,比如这个问题,“你遇到过的源代码中最好的注释是什么?”但是要注意的是,这些天这种性质的新问题令人皱眉。尽管如此,如果您(令人惊讶地)无法找到关于堆栈溢出的不寻常编程问题的现有答案,庞大的社区可以回答您的格式良好的查询。对于 javascript 相关的问题尤其如此,因为“JavaScript”是网站上使用最多的标签。
推特
就了解网络开发的最新进展而言,Twitter 是我的头号资源。我个人并不关注 Twitter 上的许多开发人员,但我仍然设法淹没在似乎永无止境的有趣讨论、关于新 web 和 JavaScript 特性的新闻以及让 web 的未来看起来更像现在的著名库之中。Twitter 是一个经常被忽视的传播和吸收微小软件相关信息的媒介。根据我的经验,一些更有趣的与网络开发相关的讨论发生在 Twitter 上。简单地追随最有影响力或最多产的开发者并不重要。如果你能找到一些愿意分享有趣观点和文章的工程师,你会发现你的知识范围会增长得相当快。
无论您选择更新哪种资源,或者探索现有的 API,最重要的目标是不断学习。第一步是超越 jQuery,但不要止步于此。安逸是停滞的标志。如果你已经习惯了自己的方式,不愿意保持开放的心态,并且拒绝考虑超越你当前的工具和过程,那么作为一个软件开发人员,你将会停止成长。
Footnotes 1
2
3
www.ecma-international.org/publications/standards/Ecma-262.htm
4
5
https://github.com/tc39/proposals/blob/master/README.md
6
https://github.com/tc39/proposals
7
https://developer.mozilla.org/en-US/
8
http://blog.fineuploader.com/2014/09/10/ios8-presents-serious-issues-that-prevent-file-uploading/
9
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步