JavaScript-技巧高级教程-一-

38 阅读1小时+

JavaScript 技巧高级教程(一)

原文:Pro JavaScript Techniques

协议:CC BY-NC-SA 4.0

一、JavaScript 专业技术

欢迎来到专业 JavaScript 技术。这本书概述了 JavaScript 的现状,特别是当它应用于职业程序员时。谁是职业程序员?一个对 JavaScript(可能还有其他几种语言)有着坚实基础的人。您对 JavaScript 的广度和深度感兴趣。您想了解文档对象模型(DOM)等典型特性,但也想了解客户端上模型-视图-控制器(MVC) 的所有这些内容。更新的 API,新的特性和功能,以及代码的创造性应用是你在这里寻找的。

这是这本书的第二版。自 2006 年第一版问世以来,情况发生了很大变化。当时,JavaScript 正在经历一个有点痛苦的转变,从一种玩具脚本语言转变为一种对多种不同任务都有用且有效的语言。如果你愿意,那是 JavaScript 的青春期。现在,JavaScript 正处于另一个转变的末期:继续这个比喻,从青春期到成年期。JavaScript 的使用几乎无处不在,85%到 95%的网站,取决于你相信谁的统计,在他们的主页上有一些 JavaScript。许多人认为 JavaScript 是世界上最流行的编程语言(就经常使用它的人数而言)。但是比单纯的使用更重要的是有效性和能力。

JavaScript 已经从一种玩具语言(图像翻转!状态栏文本操作!)到一个有效但有限的工具(想想客户端表单验证),到它目前作为一种功能广泛的编程语言的地位,不再局限于仅仅是浏览器。程序员正在编写提供 MVC 功能的 JavaScript 工具,这一直是服务器的领域,以及复杂的数据可视化、模板库等等。这个清单还在继续。在过去,设计人员需要依赖. NET 或 Java Swing 客户端来为服务器端数据提供功能全面、丰富的接口,现在我们可以通过浏览器用 JavaScript 实现该应用。而且,使用 Node.js,我们有了 JavaScript 自己版本的虚拟机,一个可以运行任何数量的不同应用的可执行文件,所有这些都是用 JavaScript 编写的,不需要浏览器。

这一章将描述我们是如何来到这里的,以及我们要去哪里。它将着眼于促成 JavaScript 革命的浏览器技术(和流行度)的各种改进。JavaScript 本身的状态需要检查,因为我们想在看我们要去哪里之前知道我们在哪里。然后,当我们检查接下来的章节时,您将会看到职业 JavaScript 程序员需要知道什么才能名副其实。

我们是怎么到这里的?

在这本书的第一版,谷歌 Chrome 和 Mozilla Firefox 是相对较新的产品。Internet Explorer 6 和 7 独领风骚,第 8 版越来越受欢迎。几个因素共同推动了 JavaScript 的发展。

JavaScript 的大部分时间都依赖于浏览器。浏览器是 JavaScript 的运行时环境,程序员对 JavaScript 功能的访问高度依赖于访问该程序员网站的浏览器的品牌、型号和版本。到了 2005 年中期,90 年代的浏览器大战被 Internet Explorer 轻松拿下,浏览器开发陷入停滞。两种浏览器挑战了这种状况:Mozilla Firefox 和 Google Chrome。Firefox 是最早的网络浏览器之一 Netscape 的后代。Chrome 有谷歌的支持,这足以让它成为一个即时玩家。

但是这两种浏览器都做出了一些促进 JavaScript 革命的设计决策。第一个决定是支持万维网联盟实现各种标准。无论是处理 DOM、事件处理还是 Ajax ,Chrome 和 Firefox 通常都遵循规范并尽可能好地实现它。对于程序员来说,这意味着我们不必为 Firefox 和 Chrome 编写单独的代码。我们已经习惯于为 IE 和其他东西编写单独的代码,所以拥有分支代码本身并不新鲜。但是确保分支不会过于复杂是一种令人欣慰的解脱。

说到标准,Firefox 和 Chrome 也和欧洲计算机制造商协会(ECMA,现在称为 Ecma)一起做了很多工作。Ecma 是监管 JavaScript 的标准机构。(从技术角度来说,Ecma 负责 ECMAScript 标准,因为 JavaScript 是 Oracle 的商标,而且……嗯,我们其实并不关心这些细节,不是吗?我们将使用 JavaScript 来指代这种语言,使用 ECMAScript 来指代 JavaScript 实现所遵循的规范。ECMAScript 标准就像 IE 开发一样衰落了。随着真正的浏览器竞争的兴起,ECMAScript 标准再次被采用。ECMAScript 第 5 版(2009) 编纂了许多在十年(!)自上一版本标准以来。随着 2011 年 5.1 版本的推出,这个团队本身也充满了活力。未来是可以预见的,目前正在对标准的第 6 版和第 7 版进行大量的工作。

值得表扬的是,Chrome 也推动了 JavaScript 的更新。Chrome JavaScript 引擎名为 V8 ,是 Chrome 在 2008 年首次亮相时非常重要的一部分。Chrome 团队构建了一个比大多数 JavaScript 引擎更快的引擎,并在后续版本中保持了这一目标。事实上,V8 引擎令人印象深刻,它成为了 Node.js 的核心,这是一个独立于浏览器的 JavaScript 解释器。Node 最初是作为一个使用 JavaScript 作为主要应用语言的服务器,现在已经成为一个灵活的平台,可以运行任意数量的基于 JavaScript 的应用。

回到 Chrome:谷歌引入浏览器领域的另一项重大创新是常青树应用的概念。Chrome 的默认设置是自动为你更新浏览器,而不是下载一个单独的浏览器安装进行更新。虽然这种方法有时在公司界是一种痛苦,但对非公司的消费者来说却是一大福音。).如果你使用 Chrome(最近几年还使用了 Firefox),你的浏览器就是最新的,无需你做任何努力。虽然微软在通过 Windows Update 推送安全更新方面已经做了很长时间,但它并没有向 Internet Explorer 引入新功能,除非它们与新版本的 Windows 相结合。换句话说,IE 的更新来得很慢。Chrome 和 Firefox 总是拥有最新和最棒的功能,并且非常安全。

随着谷歌加紧开发 Chrome 的功能,其他浏览器制造商也在迎头赶上。有时这是以更愚蠢的方式出现的,比如 Firefox 采用了 Chrome 的版本编号。但这也导致了 Mozilla 和微软对 JavaScript 引擎冷眼相看。在过去的几年里,这两家浏览器制造商都大幅改进了他们的 JS 引擎,Chrome 的领先优势虽然强大,但不再是不可逾越的。

最后,微软已经(大部分)放弃了其经典的“拥抱和扩展”哲学,至少在 JavaScript 方面。在 IE 第 9 版中,微软实现了万维网联盟(W3C)事件处理,并标准化了它的 DOM 接口和 Ajax API。对于 JavaScript 的大多数标准特性,我们不再需要实现同一个代码的两个版本!(当然,遗留浏览器的遗留代码仍然是一个问题……)

这似乎是一剂灵丹妙药。JavaScript 比以往任何时候都要快。为各种不同的浏览器编写代码更容易。标准文档既描述了现实世界,又为未来的特性提供了有用的路线图。我们的大多数浏览器都是最新的。那么我们现在需要担心的是什么,未来的路在何方?

现代 JavaScript

使用 JavaScript 开发严肃的应用从未如此简单。我们已经与过去的糟糕时代彻底决裂,过去的时代是为多种浏览器分别编写代码,糟糕的标准没有得到很好的实现,缓慢的 JavaScript 引擎常常是事后才想到的。让我们看看现代 JavaScript 环境的状态。具体来说,我们将关注两个领域:现代浏览器和现代工具包。

现代 JavaScript 依赖于现代浏览器的理念。什么是现代浏览器 ?不同的组织用不同的方式描述它。谷歌表示,他们的应用支持当前和以前的主要浏览器版本。(有趣的是,据我们所知,Gmail 仍然可以在 IE9 上工作!)在一篇有趣的文章中,英国广播公司(BBC)网站的幕后人员透露,他们将定义为支持以下功能的现代浏览器:

  1. document.querySelector() / document.querySelectorAll()
  2. window.addEventListener()
  3. 存储 API ( localStoragesessionStorage)

jQuery 可能是网络上最流行的 JavaScript 库,它将其版本分为 1.x 系列和 2.x 系列,1 . x 系列支持 IE 6 及更高版本,2 . x 系列支持 IE 9 及更高版本等“现代”浏览器。毫无疑问,这是现代和古代的分界线。另外两大浏览器是常青树。虽然 Safari 和 Opera 不是常青树,但它们的更新速度比 IE 快,市场份额也不如 IE。

那么,现代浏览器的界限在哪里?唉,边界似乎徘徊在 Internet Explorer 版本 9 到 11 之间。但 IE 8 绝对是浏览器历史上的遥远一面。它不支持 ECMAScript 5 的大多数功能。它不包括用于 W3C 事件处理的 API。这个清单还在继续。所以当我们讨论现代浏览器时,我们至少会提到 Internet Explorer 9。我们的报道不会尽力支持古老的浏览器。在相关和简单的地方,我们会指出旧版本 IE 的 polyfills,但一般来说,我们的底线是 Internet Explorer 9。

图书馆的崛起

除了现代浏览器之外,我们还需要讨论 JavaScript 当前环境的另一个重要方面:库。在过去的 8 年里,JavaScript 库的数量和种类呈爆炸式增长。JavaScript 的 GitHub 库超过 80 万个;其中,近 900 颗恒星超过 1000 颗。JavaScript 库生态系统最初只是一些实用函数的集合,现在已经演变(有些混乱)成一个广阔的前景。

作为 JavaScript 开发人员,这对我们有什么影响?当然,有“库作为扩展”的模型,其中库提供额外的功能。想想像 Backbone 和 Angular 这样的 MVC 库(我们将在后面的章节中讨论),或者像 d3 或 Highcharts 这样的数据可视化库。但是 JavaScript 处于一个有趣的位置,因为库也可以为一些浏览器上的标准特性提供一个层次接口,而在其他浏览器上却不是。

长期以来,JavaScript 中可变实现特性的标准例子是事件处理。Internet Explorer 有自己的事件处理 API。其他浏览器通常遵循 W3C 的 API。各种库为事件处理提供了统一的实现,包括了两个世界的精华。其中一些库是独立的,但成功的库也规范了 Ajax、DOM 和许多其他跨浏览器实现的功能。

这些库中最流行的是 jQuery 。从一开始,jQuery 就是使用新 JavaScript 特性的首选库,而不用担心浏览器对这些特性的支持。因此,不使用 IE 或 W3C 的事件处理,您可以简单地使用 jQuery 的.on()函数,该函数包装了方差,提供了一个统一的接口。其他几个库也提供了类似的功能:Dojo、Ext JS、Prototype、YUI、MooTools 等等。这些工具包库旨在为开发人员标准化 API。

标准化不仅仅是提供简单的分支代码。这些库经常改善有缺陷的实现。一个函数的官方 API,版本之间可能变化不大,但是会有 bugs 有时这些错误会被修复,有时不会,有时修复会引入新的错误。在图书馆可以修复或解决这些问题的地方,他们做到了。例如,jQuery 1.11 包含六个以上的事件处理 API 问题修复程序。

一些库(特别是 jQuery)也对某些功能提供了新的或不同的解释。jQuery 选择器函数是这个库的核心,早于现在标准的querySelector()querySelectorAll()函数,它是在 JavaScript 中包含这些函数的驱动力。其他库提供对功能的访问,尽管底层实现非常不同。在本书的后面,我们将研究 Ajax 新的跨源资源共享(CORS )协议,该协议允许 Ajax 向最初服务页面的服务器之外的服务器发出请求。一些库已经实现了一个使用 CORS 的版本,但是在需要的地方退回到 JSON 和 padding (JSON-P)。

由于它们的实用性,一些库已经成为专业 JavaScript 程序员的标准开发工具包的一部分。它们的特性可能还没有被标准化为 JavaScript,但是它们是知识和功能的积累,这使得快速实现设计变得更加容易。然而,最近几年,你可以通过询问 jQuery(或另一个库)是否真的是在现代浏览器上开发所必需的,来获得相当多的点击量。考虑 BBC 的要求;如果有这三种方法,您肯定可以实现很大程度上类似 jQuery 的功能。但是 jQuery 还包括一个简化但扩展的 DOM 接口,它可以处理各种不同边缘情况下的错误,如果您需要支持 IE 8 或更早版本,jQuery 是您的主要选择。因此,专业的 JavaScript 程序员必须考虑项目的需求,并考虑是否值得冒险重新发明 jQuery(或另一个类似的库)提供的轮子。

不止是关于移动设备的说明

在早期的 JavaScript 和 web 开发书籍中,你会看到一个章节,也许是整个第,讲述如何处理移动浏览。移动浏览 在总浏览量中所占的份额很小,而且市场非常分散,似乎只有专家才会对移动开发感兴趣。现在不是这样的了。从这本书的第一版开始,移动网页浏览已经爆炸了,它是一个与桌面开发非常不同的野兽。考虑一些统计数据:根据各种来源,移动浏览占所有浏览的 20%到 30%。当你读到这篇文章时,它很可能代表了更多,因为自 iPhone 问世以来,它一直在增长。说到这里,超过 40%的移动浏览是通过 iOS Safari 完成的,尽管 Android 的浏览器和 Chrome for Android 正在取得进展(可能已经超过 Safari,取决于你相信谁的统计数据)。iOS 上的 Safari 和桌面上的 Safari 不一样,安卓 Chrome 对桌面 Chrome,手机火狐对桌面火狐也是一样。移动是主流。

移动设备上的浏览器带来了一系列新的挑战和机遇。移动设备通常比台式机更受限制(尽管这是另一个正在迅速缩小的差距)。相反,移动设备提供了新的功能(滑动事件、更精确的地理定位等)和新的交互习惯(用手代替鼠标,滑动滚动)。根据您的开发需求,您可能需要构建一个在移动和桌面上都好看的应用,或者为移动平台重新实现现有功能。

在过去的几年里,JavaScript 领域发生了巨大的变化。尽管 API 有一些标准化,但也有许多新的挑战。这对我们这些职业 JavaScript 程序员有什么影响?

我们将何去何从?

我们应该为自己制定一些标准。我们已经设定了一个:IE9 作为现代浏览器体验的基础。其他浏览器是常青树,不用担心。那么手机呢?虽然这个问题很复杂,但一般来说,iOS 6 和 Android 4.1 将成为我们的底线。移动计算比台式机更新得更快更频繁,因此我们对使用这些更新版本的移动操作系统充满信心。

也就是说,让我们暂时离题,讨论一下你的受众,而不是浏览器版本、操作系统或平台。虽然我们可以整天引用统计数据,但有价值的统计数据告诉你的是你的听众,而不是一般的听众。也许你正在为你的雇主设计,他已经标准化了 IE 10。或者你对应用的想法很大程度上依赖于只有 Chrome 提供的功能。或者可能甚至没有桌面版本,但你的目标是推出 iPads 和 Android 平板电脑。考虑你的目标受众。这本书被写得具有广泛的适用性,你的应用也可能如此。但是花时间担心之前提到的平板电脑应用的 IE9 漏洞是愚蠢的,不是吗?现在,回到我们的标准。

对于截图和测试,这本书一般会倾向于谷歌 Chrome。偶尔,我们会在 Firefox 或 Internet Explorer 上演示相关的代码。对于开发者来说,Chrome 是黄金标准——不一定是用户友好,但肯定是向程序员展示的信息。在后面的章节中,我们将研究各种可用的开发工具,不仅研究 Chrome,还研究 Firefox(有和没有 Firebug)和 IE。

作为标准库,我们会参考 jQuery。当然,还有很多选择,但是 jQuery 胜出有两个原因:首先,它是 web 上最流行的通用 JavaScript 库。其次,至少其中一位作者(John Resig)与 jQuery 有一点渊源,这使得另一位作者(约翰·帕克斯顿)不得不承认使用 jQuery 的重要性。在更新这本书的过程中,我们用 jQuery 的功能库替换了以前版本中的许多技术。在这些情况下,我们不愿意重新发明轮子。根据需要,我们将引用适当的 jQuery 功能。当然,我们也会讨论新的令人兴奋的技术!

在 JavaScript 自身发展的推动下,JavaScript IDEs 在过去几年中有了显著的更新。可能性不胜枚举,但有几个应用值得注意。John Resig 在他的开发环境中使用了高度定制的 vim 版本。约翰·帕克斯顿稍微懒一点,他选择使用 JetBrains 的优秀的 WebStorm ( http://www.jetbrains.com/webstorm/)作为他的 IDE。Adobe 提供开源、免费的支架 IDE ( http://brackets.io/),目前版本为 1.3。Eclipse 也是可用的,许多人已经报告了通过定制 SublimeText 或 Emacs 来完成他们的投标的积极结果。像往常一样,用你觉得最舒服的。

还有其他工具可以帮助 JavaScript 开发。我们将在本书的后面专门用一章来介绍它们,而不是在这里一一列举。这意味着这是一个很好的时间来给出一个未来的轮廓。

接下来

从第二章开始,我们将看看 JavaScript 语言的最新和最伟大之处。这意味着查看新的特性,比如那些通过Object类型可用的特性,但是也要重新检查一些旧的概念,比如引用、函数、作用域和闭包。我们将把所有这些都放在特性、功能和对象的标题下,但是它涵盖的内容不止这些。

第三章讨论了创建可重用代码。第二章跳过了 JavaScript 最大的新特性之一,即Object.create()方法,以及它对面向对象 JavaScript 代码的影响。因此,在这一章中,我们将花时间了解用 JavaScript 实现的Object.create()、函数构造函数、原型和面向对象的概念。

已经花了两章开发代码,我们应该开始考虑如何管理它。第四章向你展示了用于调试 JavaScript 代码的工具。我们首先研究浏览器及其开发工具。

第五章开始讨论 JavaScript 功能的一些常用领域。这里我们看一下文档对象模型。自上一版以来,DOM API 的复杂性增加了,但并没有真正变得更简单。但是有一些我们应该熟悉的新特性。

在第六章中,我们试图掌握事件。这里的大新闻是遵循 W3C 风格的事件 API 的标准化。这让我们有机会远离实用程序库,最终深入到事件 API 中,而不用担心浏览器之间的巨大差异。

JavaScript 的第一个非玩具应用之一是客户端表单验证。令人惊讶的是,浏览器制造商花了十多年的时间才想到除了捕获提交事件之外,还要增加表单验证的功能。当查看第七章中的 JavaScript 和表单验证时,我们会发现 HTML 和 JavaScript 都提供了一套全新的表单验证功能。

每个用 JavaScript 开发的人都花了一些时间介绍 Ajax 。随着跨源资源共享(CORS)的引入,Ajax 功能终于克服了最愚蠢的限制。

像 Yeoman、Bower、Git 和 Grunt 这样的命令行工具包含在 Web 制作工具中。这些工具将向我们展示如何快速添加所有需要的文件和文件夹。这样我们才能专注于发展。

第十章包括和检测。使用在前一章中获得的知识,我们现在开始看看是什么使 Angular 工作,以及如何实现单元测试和端到端测试。

最后,第十一章讨论了 JavaScript 的未来。ECMAScript 6 或多或少会在本书出版时被解决。ECMAScript 7 正在积极开发中。除了 JavaScript 的基本发展方向之外,我们还将看看您现在可以使用哪些特性。

摘要

这一章我们花了很多时间讨论 JavaScript 的一切:平台、历史、ide 等等。我们相信历史影响着现在。我们想要解释我们在哪里,以及我们是如何到达这里的,来帮助你理解为什么 JavaScript 是现在的样子。当然,我们计划用这本书的大部分来讨论 JavaScript 是如何工作的,特别是对于专业程序员来说。我们强烈认为,这本书涵盖了每个专业 JavaScript 程序员都应该熟悉的技术和 API。所以事不宜迟…

二、特性、函数和对象

对象是 JavaScript 的基本单位。事实上,JavaScript 中的一切都是对象,并且在面向对象的层次上进行交互。为了构建这种可靠的面向对象语言,JavaScript 包含了一系列特性,使其在基础和功能上都独一无二。

本章涵盖了 JavaScript 语言的一些最重要的方面,比如引用、作用域、闭包和上下文。这些不一定是语言的基石,但优雅的拱门,既支持又完善了 JavaScript。我们将深入研究将对象作为数据结构使用的工具。接下来深入探讨面向对象 JavaScript 的本质,包括对类和原型的讨论。最后,这一章探索了面向对象 JavaScript 的使用,包括对象的行为和如何创建新的对象。如果认真对待,这很可能是本书最重要的一章,因为它将彻底改变你看待 JavaScript 语言的方式。

语言特征

JavaScript 有许多特性,这些特性对于语言的发展至关重要。很少有其他语言像它一样。我们发现这些特性的结合恰到好处,造就了一种看似强大的语言。

参考值和数值

JavaScript 变量以两种方式保存数据:拷贝和引用。任何原始值都被复制到变量中。原语 是字符串、数字、布尔、空和未定义。原语最重要的特征是它们通过被赋值、复制、传递给函数以及从函数返回。

JavaScript 的其余部分依赖于引用。任何不包含上述原语值的变量都包含一个对对象的引用。一个引用是一个指向一个对象(或者数组,或者日期,或者你所拥有的东西)在内存中的位置的指针。实际的对象(数组、日期或其他)被称为引用对象 。这是一个非常强大的特性,在许多语言中都有。它允许某些效率:两个(或更多!)变量没有自己的对象副本;它们只是指同一个对象。通过一个引用对 referent 的更新反映在另一个引用中。通过维护对象的引用集,JavaScript 为您提供了更大的灵活性。清单 2-1 中显示了一个例子,其中两个变量指向同一个对象,通过一个引用对对象内容的修改反映在另一个引用中。

清单 2-1 。多个变量引用一个对象的例子

// Set obj to an empty object
// (Using {} is shorter than 'new Object()')
var obj = {};

// objRef now refers to the other object
var refToObj = obj;

// Modify a property in the original object
obj.onePropertytrue;

// We now see that the change is represented in both variables
// (Since they both refer to the same object)
console.log( obj.oneProperty === refToObj.oneProperty );

// This change goes both ways, since obj and refToObj are both references
refToObj.anotherProperty1;
console.log( obj.anotherProperty === refToObj.anotherProperty );

对象有两个特性:属性和方法。这些通常被统称为对象的成员 。属性包含对象的数据。属性可以是原语,也可以是对象本身。方法是作用于对象数据的函数。在 JavaScript 的一些讨论中,方法包含在属性集中。但是这种区别通常是有用的。

自修改 对象在 JavaScript 中非常少见。让我们看一个发生这种情况的常见例子。数组对象能够使用push方法向自身添加额外的项目。因为在数组对象的核心,值被存储为对象属性,结果是类似于清单 2-1 中所示的情况,其中一个对象被全局修改(导致多个变量的内容同时被改变)。这种情况的一个例子可以在清单 2-2 中找到。

清单 2-2 。自修改对象的示例

// Create an array of items
// (Similar to 2-1, using [] is shorter than 'new Array()')
var items = [ 'one', 'two', 'three' ];

// Create a reference to the array of items
var itemsRef = items;

// Add an item to the original array
items.push( 'four' );

// The length of each array should be the same,
// since they both point to the same array object
console.log( items.length == itemsRef.length );

重要的是要记住,引用只指向被引用对象,而不是另一个引用。例如,在 Perl 中,可以有一个指向另一个变量的引用点,该变量也是一个引用。然而,在 JavaScript 中,它沿着引用链向下遍历,只指向核心对象。这种情况的一个例子可以在清单 2-3 中看到,其中物理对象被改变,但是引用继续指向旧对象。

清单 2-3 。在保持完整性的同时更改对象的引用

// Set items to an array (object) of strings
var items = [ 'one', 'two', 'three' ];
// Set itemsRef to a reference to items
var itemsRef = items;

// Set items to equal a new object
items = [ 'new', 'array' ];

// items and itemsRef now point to different objects.
// items points to [ 'new', 'array' ]
// itemsRef points to [ 'one', 'two', 'three' ]
console.log( items !== itemsRef );

最后,让我们看一个奇怪的实例,您可能认为它会涉及引用,但实际上不会。当执行字符串连接时,结果总是一个新的字符串对象,而不是原始字符串的修改版本。因为字符串(像数字和布尔值)是原语,所以它们实际上不是引用对象,包含它们的变量也不是引用。这可以在清单 2-4 中看到。

清单 2-4 。对象修改示例产生一个新对象,而不是自修改对象

// Set item equal to a new string object
var item = 'test';

// itemRef now refers to the same string object
var itemRef = item;

// Concatenate some new text onto the string object
// NOTE: This creates a new object and does not modify
// the original object.
item += 'ing';

// The values of item and itemRef are NOT equal, as a whole
// new string object has been created
console.log( item != itemRef );

字符串通常特别容易混淆,因为它们的行为类似于对象。您可以通过调用new String来创建字符串的实例。字符串有类似于length的属性。字符串也有类似indexOftoUpperCase的方法。但是当与变量或函数交互时,字符串是非常原始的。

如果你是新手,推荐信可能是一个很难理解的话题。尽管如此,理解引用是如何工作的对于编写好的、干净的 JavaScript 代码是至关重要的。在接下来的几节中,我们将会看到一些特性,它们不一定是新的或者令人兴奋的,但是对于编写好的、干净的代码来说是很重要的。

范围

范围是 JavaScript 的一个棘手特性。大多数编程语言都有某种形式的作用域;不同之处在于该范围的持续时间。JavaScript 中只有两个作用域:函数作用域和全局作用域。这看似简单。函数有自己的作用域,但是块(比如whileiffor语句)没有。如果您来自块范围的语言,这可能看起来很奇怪。清单 2-5 展示了一个函数作用域代码含义的例子。

清单 2-5 。JavaScript 中变量作用域如何工作的例子

// Set a global variable, foo, equal to test
var foo = 'test';

// Within an if block
iftrue ) {
    // Set foo equal to 'new test'
    // NOTE: This still belongs to the global scope!
    var foo = 'new test';
}

// As we can see here, as foo is now equal to 'new test'
console.log( foo === 'new test' );

// Create a function that will modify the variable foo
function test() {
    var foo = 'old test';
}

// However, when called, 'foo' remains within the scope
// of the function
test();

// Which is confirmed, as foo is still equal to 'new test'
console.log( foo === 'new test' );

你会注意到在清单 2-5 的中,变量在全局范围内。在基于浏览器的 JavaScript 中,所有全局范围的变量实际上都是作为window对象的属性可见的。在其他环境中,会有一个全局上下文,所有全局范围的变量都属于这个上下文。

在清单 2-6 中,在test()函数的范围内,一个值被赋给变量foo。然而,在清单 2-6 中没有任何地方是变量的实际声明范围(使用var foo)。当foo变量没有被明确限定作用域时,它将被全局定义,即使它只打算在函数的上下文中使用。

清单 2-6 。隐式全局范围变量声明示例

// A function in which the value of foo is set
function test() {
    foo = 'test';
}

// Call the function to set the value of foo
test();

// We see that foo is now globally scoped
console.log( window.foo === 'test' );

JavaScript 的作用域通常是混乱的来源。如果您来自块范围的语言,这种混淆可能会导致意外的全局变量,如下所示。通常,关键字的不精确用法会加剧这种混淆。为了简单起见,专业的 JavaScript 程序员应该总是用var初始化变量,不管作用域是什么。这样,您的变量将具有您期望的范围,并且您可以避免意外的全局变量。

在函数中声明变量时,要注意提升的问题。函数中声明的任何变量都将其声明(而不是初始化时使用的值)提升到作用域的顶部。JavaScript 这样做是为了确保变量名在整个范围内都可用。

特别是当我们将范围与上下文和闭包的概念结合起来时,JavaScript 显示出它是一种强大的脚本语言。

语境

你的代码总是有某种形式的上下文(代码运行的范围)。上下文可能是一个强大的工具,对于面向对象的代码来说是必不可少的。这是其他语言的一个共同特征,但是 JavaScript 通常有一个微妙的不同之处。

您通过变量this访问上下文,该变量将始终引用代码在其中运行的上下文。回想一下,全局对象实际上是window对象的属性。这意味着即使在全球范围内,this仍将指代一个对象。清单 2-7 展示了一些使用上下文的简单例子。

清单 2-7 。在上下文中使用函数,然后将上下文切换到另一个变量的示例

function setFoo(fooInput) {
    this.foo = fooInput;
}

var foo = 5;
console.log( 'foo at the window level is set to: ' + foo ); 

var obj = {
    foo : 10
};

console.log( 'foo inside of obj is set to: ' + obj.foo );

// This will change window-level foo
setFoo( 15 );
console.log( 'foo at the window level is now set to: ' + foo );

// This will change the foo inside the object
obj.setFoo = setFoo;
obj.setFoo( 20 );
console.log( 'foo inside of obj is now set to: ' + obj.foo );

在清单 2-7 中,我们的setFoo函数看起来有点奇怪。我们通常不在通用的效用函数中使用this。知道我们最终将把setFoo附加到obj上,我们使用了this,这样我们可以访问obj的上下文。然而,这种方法并不是绝对必要的。JavaScript 有两种方法,允许您在任意指定的上下文中运行函数。清单 2-8 显示了两种方法,callapply,可以用来实现这一点。

清单 2-8 。改变函数上下文的例子

// A simple function that sets the color style of its context
function changeColor( color ) {
    this.style.color = color;
}

// Calling it on the window object, which fails, since it doesn't
// have a style object
changeColor('white' );

// Create a new div element, which will have a style object
var main = document.createElement('div');

// Set its color to black, using the call method
// The call method sets the context with the first argument
// and passes all the other arguments as arguments to the function
changeColor.call( main, 'black' );

//Check results using console.log
//The output should say 'black'
console.log(main.style.color);

// A function that sets the color on the body element
function setBodyColor() {
    // The apply method sets the context to the body element
    // with the first argument, and the second argument is an array
    // of arguments that gets passed to the function
    changeColor.apply( document.body, arguments );
}

// Set the background color of the body to black

setBodyColor('black' );

虽然上下文的用处可能不会马上显现出来,但是当我们很快看到面向对象时,它会变得更加清晰。

关闭

闭包 是一种手段,在父函数已经终止后,内部函数可以通过它来引用外部封闭函数中的变量。总之,这是技术上的定义。也许将闭包与上下文联系起来更有用。到目前为止,当我们定义了一个对象字面量,这个对象是开放的,可以修改。我们已经看到,我们可以随时向对象添加属性和函数。但是如果我们想要一个锁定的上下文呢?将值“保存”为默认值的上下文。没有我们提供的 API 就无法访问的上下文怎么办?这就是闭包所提供的:一个只能以我们选择的方式访问的上下文。

这个话题可以很强大,也很复杂。我们强烈推荐参考本节末尾提到的站点,因为它们有一些关于闭包的优秀信息。

让我们从查看两个简单的闭包的例子开始,如清单 2-9 所示。

清单 2-9 。闭包如何提高代码清晰度的两个例子

// Find the element with an ID of 'main'
var obj = document.getElementById('main');

// Change its border styling
obj.style.border'1px solid red';

// Initialize a callback that will occur in one second
setTimeout(function(){
    // Which will hide the object
    obj.style.display'none';
}, 1000);

// A generic function for displaying a delayed alert message
function delayedAlert( msg, time ) {
    // Initialize an enclosed callback
    setTimeout(function(){
        // Which utilizes the msg passed in from the enclosing function
        console.log( msg );
    }, time );
}
// Call the delayedAlert function with two arguments
delayedAlert('Welcome!', 2000 );

setTimeout的第一个函数调用显示了一个新的 JavaScript 开发人员经常遇到问题的实例。在新开发人员的程序中,这样的代码并不少见:

setTimeout('otherFunction()', 1000);

以至...

setTimeout('otherFunction(' + num + ',' + num2 + ')', 1000);

在这两个例子中,被调用的函数都表示为字符串。当您准备将代码投入生产时,这可能会导致缩小过程出现问题。通过使用闭包,您可以按照最初的意图调用函数、使用变量和传递参数。

使用闭包的概念,完全有可能绕过这些混乱的代码。清单 2-9 中的第一个例子很简单;有一个setTimeout回调在第一次调用后 1000 毫秒被调用,但仍然引用obj变量(它被全局定义为 ID 为main的元素)。定义的第二个函数delayedAlert展示了对发生的setTimeout混乱的解决方案,以及在函数范围内使用闭包的能力。

您应该会发现,当在代码中使用这样的简单闭包时,您所编写的内容会更加清晰,而不是变成一碗语法汤。

让我们看看闭包可能带来的有趣的副作用。在一些函数式编程语言中,有一个概念叫做匹配 ,这是一种为函数预先填充多个参数的方法,可以创建一个新的、更简单的函数。清单 2-10 有一个简单的 currying 示例,创建一个新函数,将一个参数预填充到另一个函数中。

清单 2-10 。使用闭包的函数 Currying 示例

// A function that generates a new function for adding numbers
function addGenerator( num ) {

    // Return a simple function for adding two numbers
    // with the first number borrowed from the generator
    return function( toAdd ) {
        return num + toAdd
    };

}
// addFive now contains a function that takes one argument,
// adds five to it, and returns the resulting number
var addFive = addGenerator( 5 );

// We can see here that the result of the addFive function is 9,
// when passed an argument of 4
console.log( addFive( 4 ) == 9 );

闭包可以解决另一个常见的 JavaScript 编码问题。新的 JavaScript 开发人员经常会不小心在全局范围内留下很多额外的变量。这通常被认为是不好的做法,因为这些额外的变量可能会悄悄地干扰其他库,导致混乱的问题发生。使用一个自执行的匿名函数,你可以隐藏所有正常的全局变量,不让其他代码看到,如清单 2-11 所示。

清单 2-11 。使用匿名函数隐藏全局变量的例子

// Create a new anonymous function, to use as a wrapper
(function(){
    // The variable that would normally be global
    var msg = 'Thanks for visiting! ';

    // Binding a new function to a global object
    window.onloadfunction(){
        // Which uses the 'hidden' variable
        console.log( msg );
    };

// Close off the anonymous function and execute it
})();

最后,让我们看看闭包会出现的一个问题。记住闭包允许你引用存在于父函数中的变量。但是,它不提供变量在创建时的值;它提供父函数中变量的最后一个值。在一个for循环中,你最常看到这种情况。有一个变量被用作迭代器(i)。在for循环中,新的函数正在被创建,它们利用闭包再次引用迭代器。问题是,当调用新的 closured 函数时,它们将引用迭代器的最后一个值(即数组中的最后一个位置),而不是您所期望的值。清单 2-12 展示了一个使用匿名函数诱导作用域的例子,创建一个期望闭包可能存在的实例。

清单 2-12 。使用匿名函数归纳创建多个闭包所需的范围的示例-使用函数

// An element with an ID of main
var obj = document.getElementById('main');

// An array of items to bind to
var items = ['click', 'keypress' ];

// Iterate through each of the items
forvar i = 0; i < items.length; i++ ) {
    // Use a self-executed anonymous function to induce scope
    (function(){
        // Remember the value within this scope
       // Each 'item' is unique.
      //Not relying on variables created in the parent context.
        var item = items[i];
        // Bind a function to the element
        obj['on' + item ] = function() {
            // item refers to a parent variable that has been successfully
            // scoped within the context of this for loop
            console.log('Thanks for your ' + item );
        };
    })();
}

我们将在面向对象代码的部分回到闭包,在那里它们将帮助我们实现私有属性。

闭包的概念不是一个容易理解的概念;我们花费了大量的时间和精力来真正理解闭包是多么强大。幸运的是,有一些很好的参考资料解释了闭包在 JavaScript 中是如何工作的:Richard Cornford 在http://jibbering.com/faq/faq_notes/closures.html写的“JavaScript Closures”,以及 Mozilla 开发者网络的另一个解释https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures

函数重载和类型检查

其他面向对象语言的一个共同特征是能够根据传入参数的类型或数量来重载函数以执行不同的行为。虽然这种能力不是 JavaScript 的语言特性,但是我们可以使用现有的能力来实现函数的重载。

我们的重载函数需要知道两件事:传入了多少个参数,以及传递了什么类型的参数。让我们先来看看所提供的参数数量。

在 JavaScript 的每个函数中,都有一个名为arguments的上下文变量,它充当一个类似数组的对象,包含传递给函数的所有参数。arguments对象不是真正的数组;它不与 Array 共享一个原型,也没有像pushindexOf那样的数组处理函数。它有位置数组访问(例如,arguments[2]返回第三个参数),还有一个length属性。在清单 2-13 中有两个这样的例子。

清单 2-13 。JavaScript 中函数重载的两个例子

// A simple function for sending a message
function sendMessage( msg, obj ) {
    // If both a message and an object are provided
    ifarguments.length === 2 ) {
        // Send the message to the object
        // (Assumes that obj has a log property!)
        obj.log( msg );
    } else {
        // Otherwise, assume that only a message was provided
        // So just display the default error message
        console.log( msg );
    }
}

// Both of these function calls work
sendMessage( 'Hello, World!' );
sendMessage( 'How are you?', console );

您可能想知道是否有一种方法可以让arguments对象使用数组的全部函数。用arguments本身是不可能的,但是可以创建一个arguments的副本,这是一个数组。通过从数组原型中调用slice方法,我们可以快速地将arguments对象复制到一个数组中,如清单 2-14 中的所示。

清单 2-14 。将参数转换为数组

function aFunction(x, y, z) {
    var argsArray = Array.prototype.slice.call( arguments, 0 );
    console.log( 'The last argument is: ' + argsArray.pop() );
}

// Will output 'The last argument is 3'.
aFunction( 1, 2, 3 );

我们将很快了解更多关于prototype房产的信息。目前,只要说原型允许我们以静态方式访问对象方法就足够了。

如果消息没有被定义会怎样?我们不仅需要检查一个论点的存在,还需要检查它的不存在。我们可以利用这样一个事实,即任何没有提供的参数都有一个值undefined。清单 2-15 显示了一个简单的函数,如果没有提供特定的参数,它会显示一条错误消息并提供一条默认消息。(注意,我们必须在这里使用typeof,因为否则,带有文字字符串“undefined”的参数将指示错误。)

清单 2-15 。显示错误消息和默认消息

function displayError( msg ) {
    // Check and make sure that msg is not undefined
    iftypeof msg === 'undefined' ) {
        // If it is, set a default message
        msg = 'An error occurred.';
    }

    // Display the message
    console.log( msg );
}

displayError();

使用typeof语句有助于将我们引入类型检查的主题。因为 JavaScript 是一种动态类型语言,所以这被证明是一个非常有用和重要的主题。有许多方法可以检查变量的类型;我们要看两个特别有用的。

检查对象类型的第一种方法是使用明显的typeof操作符。这个工具为我们提供了一个字符串名称,代表变量内容的类型。这种方法的一个例子可以在清单 2-16 中看到。

清单 2-16 。使用typeof确定对象类型的示例

var num = '50';
var arr = 'apples,oranges,pears';

// Check to see if our number is actually a string
iftypeof num === 'string' ) {
    // If it is, then parse a number out of it
    num = parseInt( num );
}

// Check to see if our array is actually a string
iftypeof arr == 'string' ) {
    // If that's the case, make an array, splitting on commas
    arr = arr.split( ',' );
}

typeof的优点是你不必知道测试变量的实际类型。除了对于 object 或 Array 类型的变量,或者像 User 这样的自定义对象,typeof只返回“Object ”,这使得很难区分具体的对象类型。接下来的两种确定变量类型的方法要求您针对特定的现有类型进行测试。

检查对象类型的第二种方法是使用instanceof操作符。这个操作符检查左操作数和右操作数的构造函数,这听起来可能比实际要复杂一点!看一下清单 2-17 ,展示了一个使用instanceof 的例子。

清单 2-17 。使用instanceof的例子

var today = new Date();
var re = /[a-z]+/i;

// These don't give us enough details
console.log('typeof today: ' + typeof today);
console.log('typeof re: ' + typeof re);

// Let's find out if the variables are of a more specific type
if (today instanceof Date) {
    console.log('today is an instance of a Date.');
}

if (re instanceof RegExp) {
    console.log( 're is an instance of a RegExp object.' ); 
}

在下一章,当我们看面向对象的 JavaScript 时,我们将讨论Object.isPrototypeOf()函数,它也有助于类型确定。

类型检查变量和验证参数数组的长度本质上是简单的概念,但可以用来提供复杂的方法,这些方法可以适应并为开发人员和代码用户提供更好的体验。当你需要特定的类型检查时(这是数组吗?是约会吗?特定类型的自定义对象?),我们建议创建一个自定义函数来确定类型。许多框架都有确定数组、日期等的便利函数。将这些代码封装到一个函数中,可以确保您有且只有一个地方可以检查该特定类型,而不是让检查代码分散在整个代码库中。

新对象工具

JavaScript 语言中最激动人心的发展之一是管理对象工具的扩展。正如我们将看到的,这些工具可以用于对象文字(更像数据结构)和对象实例。

目标

对象是 JavaScript 的基础。事实上,语言中的一切都是对象。这种语言的大部分力量来源于这个事实。在最基本的层面上,对象是作为属性的集合而存在的,就像你在其他语言中看到的散列结构一样。清单 2-18 展示了用一组属性创建一个对象的两个基本例子。

清单 2-18 。创建简单对象和设置属性的两个示例

// Creates a new Object object and stores it in 'obj'
var obj = new Object();

// Set some properties of the object to different values
obj.val5;
obj.clickfunction(){
    console.log('hello');
};

// Here is some equivalent code, using the {...} shorthand
// along with key-value pairs for defining properties
var obj = {

    // Set the property names and values using key/value pairs
    val: 5,
    click: function(){
        console.log('hello');
    }
};

事实上,除此之外,对象并没有太多的意义。然而,事情变得棘手的地方在于新对象的创建,尤其是那些继承了其他对象属性的对象。

修改对象

JavaScript 现在有三种方法可以帮助您控制对象是否可以被修改。我们将从最小到最大的限制尺度来看待它们。

默认情况下,JavaScript 中的对象可以随时修改。通过使用Object.preventExtensions(),你可以防止新的属性被添加到对象中。发生这种情况时,可以使用所有当前属性,但不能添加新属性。试图添加一个新属性将导致一个TypeError——或者会无声地失败;在严格模式下运行时,您更有可能看到该错误。清单 2-19 显示了一个例子。

清单 2-19 。使用Object.preventExtensions()的例子

// Creates a new object and stores it in 'obj'
var obj = {};

// Creates a new Object object using preventExtensions
var obj2 = Object.preventExtensions(obj);

// Generates TypeError when trying to define a new property
function makeTypeError(){
'use strict';

//Generates TypeError when trying to define a new property
Object.defineProperty(obj2, 'greeting',{value: 'Hello World'});
}

makeTypeError();

使用Object.seal(),你可以限制一个对象的能力,类似于你使用Object.preventExtensions()所做的。然而,与我们前面的例子不同,属性不能被删除或转换成访问器(getter 方法)。试图删除或添加属性也会导致TypeError。可以更新现有的可写属性,而不会导致错误。清单 2-20 显示了一个例子。

清单 2-20 。使用Object.seal()的例子

// Creates a new object and uses object.seal to restrict it
var obj = {};
obj.greeting'Welcome';
Object.seal(obj);

//Updating the existing writable property
//Cannot convert existing property to accessor, throws TypeErrors
obj.greeting'Hello World';
Object.defineProperty(obj, 'greeting', {get:function(){return 'Hello World'; } });

// Cannot delete property, fails silently
delete obj.greeting;

function makeTypeError(){
  'use strict';

  //Generates TypeError when trying to delete a property
  delete obj.greeting;

  //Can still update property
  obj.greeting'Welcome';
  console.log(obj.greeting);
}

makeTypeError();

在清单 2-21 中展示的Object.freeze(),是三种方法中最具限制性的。一旦被使用,对象就被认为是不可变的。不能添加、删除或更新属性。任何尝试都会导致TypeError。如果属性本身是一个对象,那么它是可以更新的。这叫做浅冻结。为了使对象完全不可变,其值包含对象的所有属性也必须被冻结。

清单 2-21 。使用Object.freeze()的例子

//Creates a new object with two properties. Second property is an object
var obj = {
  greeting: "Welcome",
  innerObj: {}
};

//Freeezes our obj
Object.freeze(obj);

//silently fails
obj.greeting'Hello World';

//innerObj can still be updated
obj.innerObj.greeting'Hello World';
console.log('obj.innerObj.greeting = ' + obj.innerObj.greeting);

//Cannot convert existing property to accessor
//Throws TypeError
Object.defineProperty(obj, 'greeting', {get:function(){return 'Hello World'; } });

// Cannot delete property, fails silently
delete obj.greeting;

function makeTypeError(){
  'use strict';
}

//Generates TypeError when trying to delete a property
delete obj.greeting;

//Freeze inner object
Object.freeze(obj.innerObj);

//innerObj is now frozen. Fails silently
obj.innerObj.greeting'Worked so far...';

function makeTypeError(){
      'use strict';
     //all attempts will throw TypeErrors

     delete obj.greeting;
     obj.innerObj.greeting'Worked so far...';
     obj.greeting"Welcome";

   };

   makeTypeError();

通过理解如何控制对象的可变性,您可以创建一定程度的一致性。例如,如果您有一个名为User的对象,您可以确定基于它的每个新对象都将与第一个对象具有相同的属性。任何可以在运行时添加的属性都将失败。

摘要

理解本章概述的概念的重要性不能低估。本章的前半部分让您很好地理解 JavaScript 的行为以及如何最好地使用它,这是全面掌握如何专业地使用 JavaScript 的起点。简单地理解对象的行为、引用的处理和范围的确定无疑会改变您编写 JavaScript 代码的方式。

基于这些技能,高级技术为我们提供了解决 JavaScript 问题的额外方法。理解范围和上下文导致了闭包的使用。研究如何在 JavaScript 中确定类型,使我们能够将函数重载添加到一种语言中,而这种语言本身并没有函数重载功能。然后我们花时间研究 JavaScript 中的一个基本类型:对象。对象类型中的各种新特性允许我们更好地控制我们创建的对象文字。这自然会导致下一章,我们开始构建自己的面向对象的 JavaScript。

三、创建可重用代码

在上一章的介绍中,我们讨论了作为 JavaScript 基本单位的对象。讨论完 JavaScript 对象字面量后,我们将用本章的大部分时间来研究这些对象如何与面向对象编程交互。在这里,JavaScript 存在于经典编程和 JavaScript 自身近乎独特的能力之间的紧张状态中。

从将代码组织成对象开始,我们将看看管理代码的其他模式。我们希望确保不污染全局名称空间,或者(过度)依赖全局变量。这意味着我们将从名称空间的讨论开始,但名称空间只是冰山一角,一些更新的调用模式可以帮助我们正确地保护我们的代码:模块,以及稍后立即调用的函数表达式 (IIFEs 或“iffies)。

一旦我们在单个文件中很好地组织了我们的代码,看看可用于管理多个 JavaScript 文件的工具是有意义的。当然,对于我们可能使用的一些库,我们可以依赖内容交付网络。但是我们也应该考虑加载我们自己的 JavaScript 文件的最佳方式,以免我们最终得到的 HTML 文件包含一个又一个脚本标签。

面向对象的 JavaScript

JavaScript 是一种原型语言,而不是经典语言。让我们先把这一点说清楚。Java 是一种经典语言,因为 Java 中的一切都需要一个类。另一方面,在 JavaScript 中,一切都有原型;因此它是原型。但是,正如道格拉斯·克洛克福特和其他人所说,它的原型性质是“矛盾的”。像一些不情愿的超级英雄一样,JavaScript 有时不想从其他编程语言的人群中脱颖而出,让它的能力大放异彩。好吧,让我们给它一个斗篷,看看会发生什么!

首先,我们重申一下,JavaScript 不是经典语言。有许多书籍、博客文章、指南、幻灯片和库试图在 JavaScript 上强加基于类的语言结构。我们欢迎您深入研究它们,但请记住,这样做时,尽管意图良好,但它们的作者是在试图将一个方钉钉进一个圆孔。我们在这里不打算这样做。本章不会讨论如何让 JavaScript 像 Java 一样运行。相反,我们将关注 JavaScript 与面向对象理论中概述的功能的交集,以及它如何有时达不到预期,有时又超出预期。

最终,我们为什么要使用面向对象编程?它提供了允许简化代码重用的使用模式,消除了重复劳动。此外,面向对象风格的编程有助于我们更深入地思考我们正在处理的代码。它提供了一个轮廓,一张地图,我们可以按照它来成功地实现。但这不是唯一的地图。JavaScript 的原型是一种相似但不同的方式来达到我们的目的。

先从原型本身说起。JavaScript 中的每种类型(对象、函数、日期等等)都有一个原型。ECMAScript 标准 规定这个属性是隐藏的,称为[[Prototype]]。到目前为止,您可以通过两种方式之一访问该属性:非标准的__proto__属性和prototype属性。起初,暴露__proto__并不可靠地跨浏览器可用,即使可用也不总是以相同的方式实现。[脚注:令人震惊的是,浏览器会以不同的方式实现 JavaScript 的关键部分!]与 ECMAScript 6(即将在您身边的浏览器中推出!),__proto__将成为类型的正式属性,并将可用于任何符合的实现。但未来还不是现在。

还可以访问某些类型的prototype属性。所有核心 JavaScript 类型(日期、字符串、数组等等)都有一个公共的prototype属性。任何从函数构造函数创建的 JavaScript 类型也有一个公共的prototype属性。但是这些类型的实例,不管是字符串、日期还是其他,都有而不是属性。这是因为prototype属性在实例上不可用。我们也不会在这里使用prototype属性,因为我们不会使用函数作为构造函数。我们将使用对象作为构造函数。

没错;我们将使用一个对象文字作为其他对象的基础。如果这听起来很像类和实例,那么有一些相似之处,但是,正如你所料,也有一些不同之处。考虑一个人对象,如清单 3-1 中的所示。

清单 3-1 。一个人对象

var Person = {
    firstName : 'John',
    lastName : 'Connolly',
    birthDate : new Date('1964-09-05'),
    gender: 'male',
    getAge : function() {
        var today = new Date();
        var diff = today.getTime() - this.birthDate.getTime();
        var year = 1000606024365.25;
        return Math.floor(diff / year);
    }
};

这里没什么特别的:一个人有名字、姓氏、性别、出生日期和计算年龄的方法。这个人是一个字面上的对象,不是我们认为的类。但是我们想把这个人当作一个类来使用。我们想创造更多的符合这个人提出的结构的物体。为了保持无类 JavaScript 和有类的面向对象语言之间的区别,我们将 Person 称为一种类型(类似于 Date、Array 和 RegExp 都是类型的方式)。我们想要创建 Person 类型的实例:为此,我们可以使用Object.create ( 清单 3-2 )。

清单 3-2 。创造人

var Person = {
    firstName : 'John',
    lastName : 'Connolly',
    birthDate : new Date( '1964-09-05' ),
    gender : 'male',
    getAge : function () {
        var today = new Date();
        var diff = today.getTime() - this.birthDate.getTime();
        var year = 1000606024365.25;
        return Math.floor( diff / year );
    },

    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender;
    }
};

var bob = Object.create( Person );
bob.firstName'Bob';
bob.lastName'Sabatelli';
bob.birthDatenew Date( '1969-06-07' );
console.log( bob.toString() );

已经从 Person 对象创建了一个实例。我们将 Person 的一个实例存储在变量bob中。没有课。但是在我们创建的 Person 对象和 Person 类型之间有一个链接。这个链接在[[Prototype]]属性 y 之上。如果你运行的是足够现代的浏览器(在我写这篇文章的时候,这个可以在 IE11、Firefox 27 和 Chrome 33 上运行),你可以在开发者工具中打开控制台,查看bob上的__proto__属性。您会注意到它指向 Person 对象。事实上,你可以通过检查bob.__proto__ === Person来测试这一点。

在 ECMAScript 5 中,Object.create被添加到 JavaScript 中,表面上是为了简化和阐明对象之间的关系,特别是哪些对象通过它们的原型相关联。但是在这样做的时候,它允许一个简单的,一步到位的创建对象之间的关系。这种关系非常类似于类和实例的面向对象思想。但是因为 JavaScript 没有类,所以我们只有相互之间有关系的对象。

这种关系通常被称为原型链。在 JavaScript 中,原型链是解析对象成员值的两个地方之一。也就是说,当你引用foo.barfoo[bar]时,JavaScript 引擎在两个潜在的地方查找bar的值:在foo本身上,或者在foo的原型链上。

在他关于面向对象 JavaScript 的三篇文章中,Kyle Simpson 就我们应该如何看待这个过程提出了一个优雅的观点。不要把bob与人的关系看作是实例与类的关系,或者孩子与父母的关系,我们应该把它看作是行为委托的一种情况。bob对象有自己的firstNamelastName,但它没有任何getAge功能。委托给某人。通过使用Object.create 建立委托关系。原型链是这种委托的机制,允许我们将行为委托给链上更远的东西。从bob的角度来看,当我们连续调用Object.create时,功能会累积,在附加功能上分层。

顺便说一下,你可能会担心你的浏览器不支持 ECMAScript 5,或者至少没有它的Object.create 版本。这不是问题;Object.create可以很容易地在任何带有 JavaScript 引擎的浏览器中填充,如清单 3-3 中的所示。

清单 3-3 。多孔填料

if ( typeof Object.create !== 'function' ) {
    Object.createfunction ( o ) {
        function F() {
        }
        F.prototype = o;
        return new F();
    };
}

最后,有些人不喜欢不断使用Object.create来创建对象。如果是这样的话,考虑一下对清单 3-4 中的 Person 对象的快速修改,它提供了一个生成更多 Person 的工厂方法。

清单 3-4 。使用工厂方法的 Person 对象

var Person = {
    firstName : 'John',
    lastName : 'Connolly',
    birthDate : new Date( '1964-09-05' ),
    gender : 'male',
    getAge : function () {
        var today = new Date();
        var diff = today.getTime() - this.birthDate.getTime();
        var year = 1000606024365.25;
        return Math.floor( diff / year );
    },

    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender;
    },

    extend : function ( config ) {
        var tmp = Object.create( this );
        forvar key in config ) {
            if ( config.hasOwnProperty( key ) ) {
                tmp[key] = config[key];
            }
        }
        return tmp;
    }
};

var bob = Person.extend( {
    firstName : 'Bob',
    lastName : 'Sabatelli',
    birthDate : new Date( '1969-06-07' )
} );

console.log( bob.toString() );

这里,extend函数封装了对Object.create的调用。当extend被调用时,它在内部调用Object.create。大概,extend是通过一个传入的配置对象调用的,这是一个相当典型的 JavaScript 使用模式。通过循环遍历tmp中的属性,extend函数还确保只有已经存在于tmp对象上的config的属性被扩展到新创建的tmp对象上。一旦我们将属性从config复制到tmp,我们就可以返回tmp,一个人的实例。

既然我们已经看到了在 JavaScript 中建立对象间关系的新风格,让我们看看它如何影响 JavaScript 与典型的面向对象概念的交互。

继承

到目前为止,最大的问题是继承。面向对象代码的主要目的是通过从一般的父类到更具体的子类来重用功能。我们已经看到,用Object.create 很容易在两个对象之间建立关系。我们可以简单地扩展这种用法来创建我们喜欢的任何类型的继承层次。(好吧,不管我们喜欢哪种单一继承层次结构。Object.create不允许多重继承。)记住我们是在委托行为的想法;当我们用Object.create创建子类时,它们将自己的一些行为委托给原型链上更高的类型。用Object.create继承更倾向于自底向上,而不是典型的自顶向下的面向对象风格。

继承其实很简单:使用Object.create。具体来说,使用Object.create在“父”类型和“子”类型之间创建一个关系。子类型可以添加功能、删除功能或覆盖现有功能。用一个参数调用Object.create,无论你决定哪个对象是你的“父”类型,返回值都是你决定的“子”类型。然后重复清单 3-4 中的模式,并使用extend方法(或重用Object.create!)来创建那个子类型(清单 3-5 )的实例。

清单 3-5 。人是老师的父母

var Person = {
    firstName : 'John',
    lastName : 'Connolly',
    birthDate : new Date( '1964-09-05' ),
    gender : 'male',
    getAge : function () {
        var today = new Date();
        var diff = today.getTime() - this.birthDate.getTime();
        var year = 1000606024365.25;
        return Math.floor( diff / year );
    },

    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender; 
    },

    extend : function ( config ) {
        var tmp = Object.create( this );
        forvar key in config ) {
            if ( config.hasOwnProperty( key ) ) {
                tmp[key] = config[key];
            }
        }
        return tmp;
    }
};

var TeacherPerson.extend( {
    job : 'teacher',
    subject : 'English Literature',
    yearsExp : 5,
    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender' ' + this.subject' teacher.';
    }
} );

var patty = Teacher.extend( {
    firstName : 'Patricia',
    lastName : 'Hannon',
    subject: 'chemistry',
    yearsExp : 20,
    gender : 'female'
} );

console.log( patty.toString() );

Object.create在老师的[[Prototype]]和人的[[Prototype]]之间建立了联系。如果您有前面提到的现代浏览器之一,您应该能够查看 Teacher 的__proto__属性,并看到它指向 Person。

在第二章的中,我们谈到了instanceof作为一种发现一个对象是否是一个类型的实例的方法。instanceof操作员不会在这里工作。它依赖于显式的prototype属性来跟踪对象与类型的关系。更简单地说,instanceof的右边操作数必须是一个函数(尽管很可能是一个函数构造函数)。左边的操作数必须是从函数构造函数创建的(虽然不一定是右边的函数构造函数)。那么我们如何判断一个对象是否是一个类型的实例呢?进入isPrototypeOf功能。

可以在任何对象上调用isPrototypeOf函数。它出现在所有 JavaScript 对象上,很像toString。在完成该类型角色的对象上调用它(到目前为止,在我们的例子中是 Person 或 Teacher),并向它传递完成实例角色的对象的参数(bobpatty)。因此,Teacher.isPrototypeOf(patty)将返回真实,如你所料。清单 3-6 提供了查看教师、人员、bobpatty的组合以及isPrototypeOf调用的代码。

清单 3-6isPrototypeOf()功能

var Person = {
    firstName : 'John',
    lastName : 'Connolly',
    birthDate : new Date( '1964-09-05' ),
    gender : 'male',
    getAge : function () {
        var today = new Date();
        var diff = today.getTime() - this.birthDate.getTime();
        var year = 1000606024365.25;
        return Math.floor( diff / year );
    },

    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender;
    },

    extend : function ( config ) {
        var tmp = Object.create( this );
        forvar key in config ) {
            if ( config.hasOwnProperty( key ) ) {
                tmp[key] = config[key];
            }
        }
        return tmp;
    }
};

var TeacherPerson.extend( {
    job : 'teacher',
    subject : 'English Literature',
    yearsExp : 5,
    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender' ' + this.subject' teacher.';
    }
} );

var bob = Person.extend( {
    firstName : 'Bob',
    lastName : 'Sabatelli',
    birthDate : new Date( '1969-06-07' )
} );

var patty = Teacher.extend( {
    firstName : 'Patricia',
    lastName : 'Hannon',
    subject: 'chemistry',
    yearsExp : 20,
    gender : 'female'
} );

console.log( 'Is bob an instance of Person? ' + Person.isPrototypeOf(bob) );          // true
console.log( 'Is bob an instance of Teacher? ' + Teacher.isPrototypeOf( bob ) );      // false
console.log( 'Is patty an instance of Teacher? ' + Teacher.isPrototypeOf( patty ) );  // true
console.log( 'Is patty an instance of Person? ' + Person.isPrototypeOf( patty ) );    // true

有一个伴随函数给isPrototypeOf;它名叫getPrototypeOf 。被称为Object.getPrototypeOf(obj),它返回一个对类型的引用,该类型是当前对象的基础。如前所述,您还可以查看(目前是非标准的,但很快将成为标准的)__proto__属性来获得相同的信息(清单 3-7 )。

清单 3-7 。getPrototypeOf

console.log( 'The prototype of bob is Person' + Object.getPrototypeOf( bob ) );

访问被覆盖的方法呢?当然,在子对象中重写父对象的方法是可能的。这种能力没有什么特别的,任何面向对象的系统都应该有这种能力。但是在大多数面向对象的系统中,被覆盖的方法可以通过类似于super的属性或访问器访问父方法。也就是说,当您正在重写一个方法时,您通常可以通过一个特殊的关键字调用您正在重写的方法。

我们这里没有那个。JavaScript 的基于原型的面向对象代码根本没有super()特性。一般来说,有三种方法可以解决这个问题。首先,您可以编写一些代码来重新实现super。这将涉及遍历原型链,可能用getPrototypeOf,在继承链中找到拥有你正在重写的方法的前一版本的对象。(请记住,您并不总是覆盖父类中的某些内容;它可能是来自“祖父”类的东西,或者是原型链上更远的东西。)那么您将需要某种方法来访问该方法,并使用传递给覆盖方法的相同参数集来调用它。这当然是可能的,但是它往往是丑陋的,同时也是非常低效的。

作为第二个解决方案,你可以显式调用父方法,如清单 3-8 所示。

清单 3-8 。再现功能的效果

var Person = {
    firstName : 'John',
    lastName : 'Connolly',
    birthDate : new Date( '1964-09-05' ),
    gender : 'male',
    getAge : function () {
        var today = new Date();
        var diff = today.getTime() - this.birthDate.getTime();
        var year = 1000606024365.25;
        return Math.floor( diff / year );
    },

    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender; 
    },

    extend : function ( config ) {
        var tmp = Object.create( this );
        forvar key in config ) {
            if ( config.hasOwnProperty( key ) ) {
                tmp[key] = config[key];
            }
        }
        return tmp;
    }
};

var TeacherPerson.extend( {
    job : 'teacher',
    subject : 'English Literature',
    yearsExp : 5,
    toString : function () {
        var originalStr = Person.toString.call(this);
        return originalStr + ' ' + this.subject' teacher.';
    }
} );

var patty = Teacher.extend( {
    firstName : 'Patricia',
    lastName : 'Hannon',
    subject: 'chemistry',
    yearsExp : 20,
    gender : 'female'
} );

console.log( patty.toString() );

请特别注意教师中的toString方法。您会注意到,教师的toString函数显式调用了个人的toString函数。许多面向对象的设计者会认为我们不应该硬编码人和老师之间的关系。但是作为达到目的的一种简单方法,这样做确实可以快速、灵活、有效地解决问题。另一方面,它不便于携带。这种方法只适用于与父对象有某种关系的对象。

第三种可能性是,我们根本不用担心我们是否有super。是的,JavaScript 这种语言缺乏super特性,这在许多其他面向对象语言中都存在。但是这个特性并不是面向对象代码的全部。也许在将来,JavaScript 会有一个带有适当功能的super关键字。(实际上,我们知道在 ECMAScript 6 中,对象有一个super属性。)但就目前而言,没有它我们也能过得相当好。

成员可见性

在面向对象的代码中,我们经常希望控制对象数据的可见性。我们的大多数成员,无论是函数还是属性,都是公共的,与 JavaScript 的实现保持一致。但是如果我们需要私有函数或者私有属性呢?JavaScript 没有简单直接的可见性修饰符(如“私有”、“受保护”或“公共”)来控制谁可以访问属性的成员。但是你可以有私人会员的效果。此外,你可以通过道格拉斯·克洛克福特所谓的特权函数为这些私有成员提供特殊的访问权限。

回想一下,JavaScript 只有两个作用域:全局作用域和当前执行函数的作用域。在前一章中,我们利用闭包利用了这一点,闭包是实现私有成员特权访问的关键部分。它是这样工作的:在构建对象的函数中使用var创建私有成员。(那些私有成员是函数还是属性由你决定。)在同一个作用域内,创建一个函数;它将隐含对私有数据的访问,因为函数和私有数据都属于同一个范围。将这个新函数添加到对象本身,使函数(而不是私有数据)成为公共的。因为该函数来自相同的作用域,所以它仍然可以间接访问该数据。详见清单 3-9 。

清单 3-9 。私人成员

var Person = {
    firstName : 'John',
    lastName : 'Connolly',
    birthDate : new Date( '1964-09-05' ),
    gender : 'male',
    getAge : function () {
        var today = new Date();
        var diff = today.getTime() - this.birthDate.getTime();
        var year = 1000606024365.25;
        return Math.floor( diff / year );
    },

    toString : function () {
        return this.firstName' ' + this.lastName' is a 'this.getAge() +
            ' year-old ' + this.gender;
    },

    extend : function ( config ) {
        var tmp = Object.create( this );

        forvar key in config ) {
            if ( config.hasOwnProperty( key ) ) {
                tmp[key] = config[key];
            }
        }

        // When was this object created?
        var creationTime = new Date();

        // An accessor, at the moment, it's private
        var getCreationTime = function() {
            return creationTime;
        };

        tmp.getCreationTime = getCreationTime; 
        return tmp;
    }
};

var TeacherPerson.extend( {
    job : 'teacher',
    subject : 'English Literature',
    yearsExp : 5,
    toString : function () {
        var originalStr = Person.toString.call(this);
        return originalStr + ' ' + this.subject' teacher.';
    }
} );

var patty = Teacher.extend( {
    firstName : 'Patricia',
    lastName : 'Hannon',
    subject: 'chemistry',
    yearsExp : 20,
    gender : 'female'
} );

console.log( patty.toString() ); 
console.log( 'The Teacher object was created at %s', patty.getCreationTime() );

如您所见,creationTime变量是extend函数的局部变量。它在该功能之外不可用。如果你在控制台上用console.dir检查人,你不会看到creationTime被列为人的公共属性。最初,getCreationTime也是如此。它是在与creationTime相同的作用域中创建的函数,因此该函数可以访问creationTime。使用简单赋值,我们将getCreationTime附加到我们返回的对象实例上。现在,getCreationTime是一个公共方法,可以特权访问creationTime中的私有数据。

一个小警告:这不是最有效的模式。每当您创建 Person 的实例或它的任何子类型时,您将创建一个全新的函数,它可以访问创建 Person 实例的调用extend的执行上下文。相比之下,当我们使用Object.create时,我们的公共函数是对我们传递给Object.create的类型的引用。在我们这里处理的小范围内,特权函数并不是特别低效。但是,如果您添加更多的特权方法,它们都将保留对该执行上下文的引用,并且都将是该特权方法的自己的实例。内存成本会迅速增加。谨慎使用特权方法,将它们留给需要严格访问控制的数据。否则,就接受 JavaScript 中的大多数数据都是公共的这一观点吧。

面向对象 JavaScript 的未来

我们忽略了 ECMAScript 6 给面向对象的 JavaScript 带来了一些变化。这些变化中最重要的是引入了一个有效的关键字classclass关键字将用于定义 JavaScript 类型(不是类,因为 JavaScript 仍然没有类!).它还将包括使用关键字extends创建继承关系的附带条件。最后,当在一个子类型中重写函数时,ECMAScript 6 留出super关键字来引用原型链上的函数版本。

然而,所有这些都是语法上的糖。当这些结构被 JavaScript 引擎分解后,它们被揭示为函数构造函数的使用。这些新特性实际上并没有建立新的功能:它们只是引入了一种其他面向对象语言的程序员更容易接受的习惯用法。更糟糕的是,他们继续掩盖 JavaScript 的一些最佳特性,试图让它符合其他语言对“真正的”面向对象语言应该是什么样子的概念。有时,JavaScript 似乎仍然有点羞于在使用其强大功能之前穿上斗篷和紧身衣。

封装 JavaScript

从面向对象的 JavaScript 向外发展,我们应该考虑如何组织我们的代码以便广泛重用。我们需要一套工具来正确封装我们的代码,防止意外使用全局上下文,以及使我们的代码可重用和可再分发的方法。让我们按顺序解决各种需求。

名称空间

到目前为止,我们已经声明了我们的类型(以及更早的函数和变量)是全局上下文的一部分。我们没有明确地这样做,但是由于我们没有将这些对象和变量声明为任何其他上下文的一部分。我们希望将函数、变量、对象、类型等封装到一个单独的上下文中,以便不依赖于全局上下文。为此,我们将(最初)依赖于名称空间。

名称空间并不是 JavaScript 独有的,但是,就像 JavaScript 中的许多东西一样,它们与您可能期望的有些不同。命名空间为变量和函数提供了上下文。当然,名称空间本身很可能是全局的。这是两害相权取其轻的方法。我们可以有一个属于窗口的变量,然后各种数据和功能属于那个变量,而不是有许多属于窗口的变量和函数。实现 很简单:使用一个对象文字来封装你想要从全局上下文中隐藏的代码(清单 3-10 )。

清单 3-10 。命名空间

// Namespaces example

var FOO = {};

// Add a variable
FOO.x10;

// Add a function
FOO.addEmUpfunction(x, y) {
    return x + y; 
};

名称空间最好用作封装无关代码的专用解决方案。如果我们试图在所有的代码中使用名称空间,随着它们增加越来越多的功能和数据,名称空间会很快变得难以使用。您可能会尝试在名称空间中设置名称空间,模拟包与 Java 一起工作的方式。Ext JS 库 很好地使用了这种技术,值得一提。但是他们也花了很多时间考虑如何组织他们的功能,什么代码属于什么名称空间或子名称空间,等等。大量使用名称空间是有代价的。

此外,名称空间名称是硬编码的 : FOO,在前面提到的库 Ext JS 中是Ext,在同样流行的 YUI 库中是YAHOO。这些名称空间实际上是这些库的保留字。如果两个或更多的库使用同一个名称空间(就像 jQuery 的使用$作为名称空间一样),会发生什么呢?潜在的冲突。JQuery 已经添加了显式代码来处理这种可能性,如果它到来的话。虽然这个问题在您自己的代码中不太可能出现,但这是一个必须考虑的可能性。在团队环境中尤其如此,在团队环境中,多个程序员可以访问名称空间,这增加了意外覆盖或删除另一个程序员的名称空间的可能性。

模块模式

我们有一些工具来改进我们使用名称空间的方式。我们可以使用模块模式,它将命名空间的生成封装在一个函数中。这允许多种改进,包括为名称空间包含的函数和数据建立基线,在生成器函数中使用私有变量,这可能使某些功能的实现更容易,以及简单地让函数生成名称空间,这意味着我们可以让 JavaScript 在运行时而不是编译时动态生成部分或全部名称空间。

模块可以根据您的喜好简单或复杂。清单 3-11 提供了一个非常简单的创建模块的例子。

清单 3-11 。创建一个模块

function getModule() {
    // Namespaces example
    var FOO = {};

    // Add a variable
    FOO.x10;

    // Add a function
    FOO.addEmUpfunction ( x, y ) {
        return x + y;
    };

    return FOO;
}

var myNamespace = getModule();

我们已经将名称空间代码封装在一个函数中。因此,当我们最初设置FOO对象时,它是getModule函数的私有对象。然后,我们可以将FOO返回给任何调用getModule的人,他们可以按照自己认为合适的方式使用封装的结构,包括随意命名。

这种模式的另一个优点是,我们可以再次利用我们的朋友闭包来设置只对名称空间私有的数据。如果我们的名称空间,我们封装的代码,需要有内部数据或内部函数,我们可以添加它们而不用担心公开它们(清单 3-12 )。

清单 3-12 。带有私有数据的模块

function getModule() {
    // Namespaces example
    var FOO = {};

    // Add a variable
    FOO.x10;

    // Add a function
    FOO.addEmUpfunction ( x, y ) {
        return x + y;
    };

    // A private variable
    var events = [];

    FOO.addEventfunction(eventName, target, fn) {
        events.push({eventName: eventName, target: target, fn: fn});
    };

    FOO.listEventsfunction(eventName) {
        return events.filter(function(evtObj) {
            return evtObj.eventName === eventName
        });
    };

    return FOO;
}

var myNamespace = getModule();

在这个例子中,我们实现了一个公共接口,用于添加某种带有addEvents的事件跟踪。稍后,我们可能希望通过listEvents按事件名称取回事件引用。但是实际的events集合是私有的,由我们提供给它的公共 API 管理,但是对直接访问是隐藏的。

像名称空间一样,模块也有两害相权取其轻的问题。我们用一个全局变量交换了我们的名称空间,换来了一个全局函数getModule。如果我们可以完全控制在全局名称空间中结束的内容,而不必使用全局范围的对象或函数来这样做,这不是很好吗?幸运的是,我们即将看到一种工具可以帮助我们做到这一点。

立即调用的函数表达式

如果我们想避免污染全局名称空间,函数是合乎逻辑的解决方案。函数在运行时会创建自己的执行上下文,它从属于全局名称空间,但与全局名称空间相隔离。当函数完成运行时,执行上下文可用于垃圾收集,并且专用于它的资源可以被回收。但是我们所有的函数要么是全局的,要么是名称空间的一部分,而名称空间本身就是全局的。我们希望有一个可以立即执行的函数,不需要命名,也不需要成为全局或其他名称空间或上下文的一部分。然后,在这个函数中,我们可以构建我们需要的模块。我们可以返回这样一个对象,导出它,并使它可用,但我们不需要一个公共函数来占用生成它的资源。这就是立即调用的函数表达式(life)背后的思想。

到目前为止,我们使用的所有函数都是函数声明。无论我们将它们定义为function funcName { ... }还是var funcName = function() { ... },我们都是在声明函数,将它们的用法留待以后使用。我们是否可以创建一个函数表达式,这个函数可以一次性创建并执行?答案是肯定的,但是这样做需要一定程度的语法勇气。

我们如何执行函数?通常,对于一个已命名的函数,我们打印函数的名称,然后在后面附加一些括号,表明我们想要执行与该名称相关的代码。我们不能对函数定义本身做同样的事情。结果将是一个语法错误,显然不是我们想要的。

但是我们可以将函数声明放在一组括号内,这是对解析器的一个提示,表明这不是一个语句,而是一个表达式。圆括号中不能包含语句,只能包含作为表达式计算的代码,这是我们希望从函数中得到的。我们还需要一点语法来实现这一点,另一组括号,通常在函数声明本身的末尾。清单 3-13 将阐明完整的语法。

清单 3-13 。立即调用的函数表达式

// A regular function
function foo() {
    console.log( 'Called foo!' );
}

// Function assignment
var bar = function () {
    console.log( 'Called bar!' );
};

// Function expression
(function () {
    console.log( 'This function was invoked immediately!' )
})();

// Alternate syntax
(function () {
    console.log( 'This function was ALSO invoked immediately!' )
}());

将前两个函数(函数声明)与后两个函数(函数表达式)进行比较。表达式用圆括号括起来,以便“表达式化”(或者,如果您愿意的话:“反声明”),然后使用第二组圆括号来调用表达式。俏皮!

顺便说一下,有各种各样的 JavaScript 语法粒子会导致 IIFEs:函数作为逻辑求值的组件,一元运算符作为函数声明的前缀,等等。“牛仔”本·阿尔曼关于生命的文章(http://benalman.com/news/2010/11/immediately-invoked-function-expression/)包含了关于有效语法的极好的细节,并深入到生命是如何工作的以及它们是如何形成的。

既然我们知道如何创造生活,我们该如何利用它呢?生命有许多应用,但我们在这里关心的是模块的生成。我们能把一个生命的结果捕捉到一个变量中吗?当然可以!所以我们可以将我们的模块生成器包装在一个生命中,并让它返回模块(清单 3-14 )。

清单 3-14 。生命模块生成器

var myModule = (function () {
    // A private variable
    var events = [];

    return {
        x : 10,
        addEmUp : function ( x, y ) {
            return x + y;
        },
        addEvent : function ( eventName, target, fn ) {
            events.push( {eventName : eventName, target : target, fn : fn} );
        },
        listEvents : function ( eventName ) {
            return events.filter( function ( evtObj ) {
                return evtObj.eventName === eventName
            } );
        }
    };

})();

在最后一个例子中,我们做了一些改动。首先,也是最简单的,我们现在在myModule而不是myNamespace中捕获我们工厂生活的输出。第二,我们不是创建一个对象然后返回它,而是直接返回对象。这简化了我们的代码,减少了为我们最终不会使用的对象保留空间。

IIFE 模式开辟了许多新的可能性,包括根据需要使用库或其他工具。函数表达式末尾的括号与常规函数调用中的括号相同。因此,我们可以将争论传递到我们的生活中,并在生活中使用它们。想象一下可以使用 jQuery 功能的生活(清单 3-15 )。

清单 3-15 。给生命传递论据

// Here, the $ refers to jQuery and jQuery only for the entire
// scope of the module
var myModule = (function ($) {
    // A private variable
    var events = [];

    return {
        x : 10,
        addEmUp : function ( x, y ) {
            return x + y;
        },
        addEvent : function ( eventName, target, fn ) {
            events.push( {eventName : eventName, target : target, fn : fn} );
            $( target ).on( eventName, fn );
        },
        listEvents : function ( eventName ) {
            return events.filter( function ( evtObj ) {
                return evtObj.eventName === eventName
            } );
        }
    };

})(jQuery); // Assumes that we had included jQuery earlier

我们将 jQuery 传递到我们的生活中,然后在整个生活中将其称为$。在内部,它在addEvent函数中被用来向 DOM 添加一个事件处理程序。(不用担心语法没有意义;这不是例子的核心!)

基于这段代码,您可能会想象一个系统,在这个系统中,由 IIFEs 生成的模块相互对话,来回传递参数并使用库,所有这些都不需要在全局级别上进行交互。事实上,这也是下一章的内容之一。

摘要

本章开始时摆在我们面前的问题是代码管理问题。我们如何按照良好的面向对象准则编写代码,以及如何封装代码以实现可重用性?在前一种情况下,我们专注于 JavaScript 的原型性质,用它来生成类似于类和实例的东西,但带有独特的 JavaScript 旋转。这种实现比试图强迫 JavaScript 像 C#或 Java 那样运行要简单得多。对于后一个需求,我们通过各种解决方案来封装我们的代码:名称空间、模块和立即调用的函数表达式。最终,这三者的结合为我们提供了最少使用全局环境的最佳解决方案。

四、调试 JavaScript 代码

有时候,并不是代码的编写,而是对代码的管理,让我们陷入困境,回到我们最喜欢的视频游戏。为什么它能在这台机器上工作,而不是在那台机器上?什么叫两倍等于(==)不好,三倍等于(===)好?为什么运行测试如此麻烦?我应该如何打包这些代码以供分发?我们被问题困扰,被与我们正在编写的代码没有直接关系的问题分心。

当然,我们不应该忽视这些问题。我们希望编写最高质量的代码,当我们做不到时,我们希望获得易于使用的调试工具。我们需要良好的测试覆盖率,无论是现在还是未来的重构。我们应该考虑我们的代码将如何分布。这就是本章的全部内容。

我们将从如何解决代码问题开始。我们希望成为完美的程序员,第一次就把所有东西都写对。但是我们都知道这在现实世界中是不会发生的。所以先从调试工具说起。

调试工具

所有现代浏览器都有某种形式的开发者工具包。即使是落后的 Internet Explorer 8 也有一个基本的调试器,尽管你需要管理员权限来安装它。我们现在所拥有的与使用各种alert()语句或者偶尔将 DOM 元素作为我们唯一依靠的开发时代相去甚远。

一般来说,开发人员的工具包会有以下实用程序:

  • 控制台:我们的应用的 JavaScript 便笺簿和日志位置的组合。
  • 调试器:长久以来困扰 JavaScript 开发人员的工具。
  • 一个 DOM 检查器:我们的大部分工作都集中在操作 DOM 上,右键选择 View Source 不会削减它。检查器应该反映 DOM 的当前状态(而不是原始源)。大多数 DOM 检查器都有一个基于树的视图,可以通过在检查器或页面中单击来选择 DOM 元素。
  • 一个网络分析器:告诉我请求了什么文件,实际上找到了哪些文件,以及下载它们花了多长时间。
  • 分析器:这些通常有些粗糙,但是它们比将一个调用包装在对new Date().getTime()的两个调用中要好。

还有一些扩展可以添加到浏览器中,为您提供超出浏览器内置功能的额外调试功能。例如,Postman ( http://getpostman.com)是 Chrome 的一个扩展,它可以让你创建任何 HTTP 请求并查看响应。另一个流行的扩展是 Firebug ( http://getfirebug.com),这是一个开源项目,它为 Firefox 添加了所有的开发工具,并且也可以拥有自己的一组扩展。

在本章中,我们将把通用工具集称为开发人员工具或开发人员工具包,除非讨论特定的浏览器工具集。

控制台

作为开发人员,控制台是我们花费大量时间的地方。控制台界面模仿了大多数应用上熟悉的日志级别:debuginfowarnerrorlog。通常,我们第一次遇到它是作为代码中alert()语句的替代,尤其是在调试的时候。在一些老版本的 IE 上,只支持log,但从 IE 11 开始,五个功能都支持。此外,控制台有一个dir()函数 ,它将为您提供一个递归的、基于树的对象接口。万一控制台不在您选择的平台上,尝试将清单 4-1 作为多项填充。

清单 4-1 。控制台聚合填充

if (!window.console) {
  window.console = {
    log : alert
  }
}

(显然,这只是log函数的多填充 。如果您要使用其他人,您必须单独添加他们。)

各个级别的输出变化很小。在 Chrome 或 Firefox 上,console.error包含了一个自动堆栈跟踪。其他浏览器(和原生 Firefox)只是添加一个图标并改变文本颜色来区分不同的级别。也许使用不同功能级别的主要原因是它们可以在所有三种主要浏览器上被过滤掉。清单 4-2 提供了一些测试代码 ,随后是来自各大浏览器的屏幕截图:Chrome、Firefox 和 Internet Explorer ( 图 4-1 到 4-3 )。

清单 4-2 。控制台级别

console.log( 'A basic log message.' );
console.debug( 'Debug level.' );
console.info( 'Info level.' );
console.warn( 'Warn level.' );
console.error( 'Error level (possibly with a stacktrace).' );

var person = {
  name : 'John Connelly',
  age : 56,
  title : 'Teacher',
  toString: function() {
    return this.name' is a 'this.age'-year-old ' + this.title'.';
  }
};

console.log( 'A person: ' );
console.dir( person );

console.log( 'Person object (implicit call to toString()): ' + person );
console.log( 'Person object as argument, similar to console.dir: ', person );

9781430263913_Fig04-01.jpg

图 4-1 。在 Chrome 40.0 中查看的测试代码

9781430263913_Fig04-02.jpg

。在 Firefox 35.0.1 中查看的测试代码

9781430263913_Fig04-03.jpg

图 4-3 。在 Internet Explorer 11.0 中查看测试代码

利用控制台功能

那么,使用这些控制台功能的最佳方式是什么呢?与 JavaScript 的许多特性一样,关键是一致性。您和您的团队应该在使用模式上达成一致,记住几件事:首先也是最重要的是,在您部署代码让全世界看到之前,应该删除所有的控制台语句。生产代码不需要包含控制台语句,并且移除控制台函数的调用非常容易(正如您将在本章后面看到的)。还要记住,调试,我们很快就会看到,可以取代一次性需求的日志记录。一般来说,使用控制台日志记录来获取关于应用状态的信息:它启动了吗?它能找到数据吗?各种复杂的物体是什么样子的?等等。您的日志记录将为您提供应用生命周期的编年史,以及应用变化状态的视图。如果您的应用是一条高速公路,良好的日志记录就相当于一种里程标——进度的指示,以及当问题不可避免地出现时从哪里开始搜索的通用指示器。

控制台也不仅仅是一个日志记录工具。这是一个 JavaScript 便笺本。控制台以单行模式启动,您可以逐行输入 JavaScript。如果你想输入多行代码,你可以切换到多行模式(通过 Firefox 和 IE 中的图标启用;在 Chrome 中,只需用 Shift+Enter 结束你的行。在单行模式下,您可以输入各种 JavaScript 语句,通过点击 Tab 或右箭头键享受自动完成。控制台还包括一个简单的历史记录,通过它,您可以使用上下箭头键向前和向后移动。控制台维护状态,因此在前面一行(或多行模式的运行)中定义的变量会一直存在,直到您重新加载页面。

这最后一个特征值得进一步研究。控制台拥有 JavaScript 解释器的全部当前状态。这是难以置信的强大。你加载 jQuery 了吗?然后你可以根据它的 API 输入命令。想在页面末尾检查变量的状态?或者也许你需要看看一个特定的动画是怎么回事?控制台是你的朋友。您可以调用函数、检查变量、操作 DOM 等等。想象一下,您输入的任何命令都被添加到刚刚完成的脚本中,并且可以访问它的所有状态。

控制台还有一个扩展的命令行 API。最初是由 Firebug 的优秀人员创建的,它的一些元素也已经移植到了其他浏览器上。现在 Chrome 和原生 Firefox 支持,但 Internet Explorer 不支持。这个 API 有许多有用的应用,我们全心全意地推荐在https://getfirebug.com/wiki/index.php/Command_Line_API查看细节。以下是一些亮点:

  • debug( functionName ):functionName 被调用时,调试器会在函数中的第一行代码前自动启动。
  • undebug( 函数名 ):停止调试已命名的函数。
  • include( url ):将远程脚本拉入页面。如果你想引入另一个调试库,或者不同地操作 DOM 的东西,或者诸如此类的东西,这非常方便。
  • monitor( functionName ):打开对命名函数的所有调用的日志记录;不影响console.*调用,而是为函数的每次调用插入一个对console.log的自定义调用。这将记录函数名、它的参数以及它们的值。
  • unmonitor( 函数名 ):关闭通过monitor()启用的所有函数调用的日志记录。
  • profile([ 标题 ]):打开 JavaScript profiler 您可以为这个概要文件传入一个可选的标题。
  • profileEnd():结束当前运行的配置文件并打印一份报告,可能带有调用配置文件中指定的标题。
  • getEventListeners(element):获取所提供元素的事件监听器。

多亏了控制台,我们开发人员有了一个全功能的工具来与我们的代码进行交互。我们可以记录应用状态的快照,并且一旦它完成加载,我们就可以与之交互。控制台也将在我们的下一个工具调试器中占据显著位置。

调试器

多年来,对 JavaScript 的一个打击是它不可能是一种“真正的”语言,因为它缺乏像调试器这样的工具。快进到现在,调试器是所有开发人员工具包的标准装备。所有当前的浏览器都有一个开发工具,可以让你检查你的应用和调试你的工作。让我们看看这些工具是如何工作的,从调试器开始。

调试器背后的想法很简单:作为开发人员,您需要暂停应用的执行并检查其当前状态。尽管我们可以通过明智地应用console.log语句来完成后一部分,但是如果没有调试器,我们就无法处理前一部分。暂停应用后,我们需要使用一些工具。我们需要一种方法来告诉调试器激活。在代码本身中,我们可以添加简单的语句debugger;来激活该行的调试器。如前所述,我们还可以从控制台调用debug命令,向它传递一个函数的名称,该函数在被调用时将启动调试器。但是选择调试器何时启动的最简单的方法是设置一个断点。

断点允许我们将 JavaScript 代码运行到某一点,然后将应用冻结在那里。当我们到达断点时,我们可以开始了解应用的当前状态。从这里我们可以看到变量的内容,这些变量的范围等等。此外,我们有一个导航菜单,其中至少包括四个选项:单步执行当前函数(深入堆栈一层),单步退出当前函数(运行当前堆栈框架直到完成,并在框架返回的点继续调试),单步执行当前函数(无需首先深入函数)和继续执行(运行直到完成或下一个断点)。

DOM 检查器

许多 JavaScript 应用对 DOM 的状态做了大量的更改——事实上,这些更改如此之多,以至于在加载页面后立即引用实际的 HTML 源代码通常是没有用的。DOM inspector 反映了 DOM 的当前状态(而不是页面加载时 DOM 的状态)。每当 DOM 发生变化时,它应该动态地即时更新。开发人员工具已经将 DOM 检查器作为一个标准特性。

网络分析仪

自从这本书的前一版以来,Ajax 已经从 JavaScript 的一个奇异特性变成了专业 JavaScript 程序员锦囊妙计中的一个标准工具。调试工具花了一段时间才跟上。现在,开发人员工具提供了几种跟踪 Ajax 请求的方法。一般来说,您应该能够在控制台或网络分析器上获得关于 Ajax 请求的信息。后者有更详细的接口。您应该能够对特定类型的请求进行排序(XHR/Ajax、脚本、图像、HTML 等等)。每个请求都应该有自己的条目,这些条目通常会提供关于请求状态(响应代码和响应消息)、请求去往何处(完整的 URL)、交换了多少数据以及请求花费了多长时间的信息。深入到单个请求,您可以看到请求和响应头、已处理数据的预览以及数据的原始视图(取决于数据类型)。例如,如果您的应用请求 JSON 格式的数据,网络分析器将告诉您原始数据(一个普通的字符串),并且可能通过 JSON 格式化程序传递该字符串,因此它可以向您显示请求的最终结果。图 4-4 显示的是 Chrome 40.0 中的网络分析仪,图 4-5 显示的是 Firefox 35.0.1 中的。

9781430263913_Fig04-04.jpg

图 4-4 。Chrome 40.0 中的网络分析仪

9781430263913_Fig04-05.jpg

图 4-5 。火狐 35.0.1 中的网络分析器

使用堆分析器和时间线,您可以检测桌面和移动设备上的内存泄漏。首先让我们看看时间线。

时间线

当您第一次注意到页面变慢时,时间线可以快速帮助您了解随着时间的推移您使用了多少内存。时间表中的功能在所有现代浏览器中都非常相似,所以为了简短起见,我们将重点放在 Chrome 上。

转到时间线面板并选中内存复选框。在那里,你可以点击左侧的录制按钮。这将开始记录应用的内存消耗。在记录时,以暴露内存泄漏的方式使用应用。停止记录,图表将会显示你在一段时间内使用了多少内存。

如果您发现随着时间的推移,您的应用正在使用内存,并且垃圾收集的水平从未下降,那么您有一个内存泄漏。

配置文件 ??

如果您发现确实存在内存泄漏,下一步就是查看分析器,并尝试了解发生了什么。

理解内存在浏览器中是如何工作的,以及它是如何被清理或垃圾收集的是很有帮助的。垃圾收集是在浏览器中自动处理的。这是浏览器查看所有已创建对象的过程。不再被引用的对象被删除,内存被回收。

现在所有的浏览器都内置了分析工具。这将让您看到随着时间的推移哪些对象使用了更多的内存。

图 4-6 显示了 Chrome 40.0 中的 Profiles 面板。

9781430263913_Fig04-06.jpg

图 4-6 。Chrome 40.0 中的配置文件面板

图 4-7 显示了 Firefox 35.0.1 中的等效面板,性能选项卡。

9781430263913_Fig04-07.jpg

图 4-7 。Firefox 35.0.1 中的个人资料面板

使用 profiler 是类似的,因为您需要浏览器来记录运行中的应用。在这种情况下,您拍摄了所谓的快照。Gmail 团队建议按以下顺序参加三次考试:

  1. 拍一张快照。
  2. 在您认为泄漏来自的地方执行操作。
  3. 拍摄第二张快照。
  4. 执行相同的操作。
  5. 拍第三张快照。
  6. 在快照 3 的摘要视图中筛选快照 1 和 2 中的对象。

此时,您可以开始看到所有仍在周围并可能占用内存的对象。现在,您应该能够看到哪些引用是剩余的,并处理掉它们。

那么什么是参考呢?通常,当一个对象有一个值是另一个对象的属性时,就会发生引用。清单 4-3 显示了一个例子。

清单 4-3 。创建对象引用

var myObject = {};
myObject.propertydocument.createElement('div');
mainDiv.appendChild(myObject.property);

这里的myObject.property 现在引用了新创建的div对象。appendChild方法可以毫无问题地使用它。如果在某个时候从 DOM 中删除了那个divmyObject仍然会有一个对div的引用,并且不会被垃圾收集。当对象不再持有引用时,它们会被自动垃圾回收。

移除引用的一种方法是使用delete关键字,如清单 4-4 所示。

清单 4-4 。删除对象引用

delete myObject.property;

摘要

正如你所看到的,现代浏览器有工具给你一个环境,帮助你完全理解你的应用。如果您确实看到了可以改进的地方,时间线可以显示一段时间内使用了多少内存。调试器可以帮助您在任何给定时间看到变量的值。使用 profiler 可以帮助您看到哪里正在泄漏内存,以及如何修复它。

五、文档对象模型

使用文档对象模型(DOM ) 是专业 JavaScript 程序员工具箱的一个关键组件。全面理解 DOM 脚本不仅有利于我们构建应用的范围,而且有利于这些应用的质量。像 JavaScript 的大多数特性一样,DOM 有一段曲折的历史。但是有了现代浏览器,不引人注目地操纵 DOM 并与之交互比以往任何时候都容易。了解如何使用这项技术以及如何最好地运用它,可以让您在开发下一个 web 应用时有一个良好的开端。

在这一章中,我们将讨论一些与 DOM 相关的主题。对于不熟悉 DOM 的读者,我们将从基础开始,浏览所有重要的概念。对于那些已经熟悉 DOM 的人,我们提供了一些很酷的技术,我们相信你会喜欢并开始在自己的网页中使用它们。

DOM 也处于十字路口。从历史上看,因为 DOM 接口更新与浏览器或 JavaScript 更新不同步,所以浏览器和 DOM 支持之间存在脱节。错误的实现加剧了这种脱节。流行的库如 jQuery 和 Dojo 就是为了解决这些问题而出现的。但是随着现代浏览器的出现,DOM 已经规范化,界面也有了很大的改善。我们需要解决的问题是,是使用库来帮助我们访问 DOM,还是用标准的 DOM 接口做所有的事情。

文档对象模型简介

最初,DOM 是作为一种在浏览器中表示 HTML 文档的部分的方式而创建的。使用 JavaScript,开发人员可以查看表单、锚点、图像和页面的其他组件,但不必查看整个页面。这有时被称为“遗留 DOM”或 0 级 DOM。最终,DOM 变成了一个接口,由 W3C 监管。从一开始,DOM 就已经成为 HTML 文档和 XML 文档的官方接口。它不一定是最快、最轻或最容易使用的接口,但它是最普遍的,在大多数编程语言(如 Java、Perl、PHP、Ruby、Python,当然还有 JavaScript)中都有实现。当我们使用 DOM 接口时,请记住,您学到的几乎所有东西都可以应用于 HTML 和 XML,尽管大多数时候我们会专门提到 HTML。

万维网联盟负责监督 DOM 规范。由于各种历史原因,DOM 规范的版本被标识为 DOM 级别 n 。目前的规范(截至发布时)是 DOM Level 4。这有时会令人困惑,因为 DOM 树本身可以有层次。我们将努力把 DOM 规范的版本称为 DOM 级别(用大写的 L ),然后用小写的 L 表示 DOM 树级别。

首先,我们应该快速讨论一下 HTML 文档的结构。因为这是一本关于 JavaScript 的书,而不是 HTML,所以我们将关注 HTML 文档对 JavaScript 的影响。让我们提出几个简单的原则:

  1. 我们的 HTML 文档应该以 HTML 5 doctype 开始。它们很简单:<!DOCTYPE html>。包含 doctype 可以防止浏览器陷入古怪模式,在这种模式下,浏览器的行为不太一致。
  2. 比起脚本块或内嵌脚本,我们更喜欢通过<script>标签包含单独的 JavaScript 文件。这使得开发(将 JavaScript 从 HTML 中分离出来)和管理更加容易。在极少数情况下,脚本块比包含文件更有意义。但是一般来说,更喜欢包含的文件。
  3. 我们的脚本标签应该出现在 HTML 文档的底部,紧接在结束的</body>标签之前。

第三项需要一些解释。通过将我们的<script>包含在页面底部,我们获得了几个优势。我们的 HTML 的大部分(如果不是全部的话)应该已经被加载了(以及相关的文件:图像、音频、视频、CSS 等等)。为什么这很重要?处理 JavaScript 代码会锁定页面的呈现!当 JavaScript 代码被解析时,浏览器不能呈现其他页面元素(有时当 JavaScript 代码正在运行时!).因此,只要有可能,我们应该等到最后一刻才加载 JavaScript 代码。此外,在移动或慢速连接场景中,获取和加载 JavaScript 可能比在桌面浏览器上慢。页面其余部分的早期加载意味着你的用户不会看到一个除了旋转器什么都没有的空白页面。这里的原则是,用户应该能够尽快看到页面已经加载的反馈。

没错。那么这个理想的 HTML 页面是什么样子的呢?查看清单 5-1 中的。

清单 5-1 。一个样本 HTML 文件

<!DOCTYPE html>
<html>
<head>
  <title>Introduction to the DOM</title>
</head>
<body>
<h1>Introduction to the DOM</h1>

<p id="intro" class="test">There are a number of reasons why the DOM is awesome; here are some:</p>
<ul id="items">
  <li id="everywhere">It can be found everywhere.</li>
  <li class="test">It’s easy to use.</li>
  <li class="test">It can help you to find what you want, really quickly.</li>
</ul>
<script src="01-sample.js"></script>
</body>
</html>

有时,在我们的例子中,我们会有一个内联脚本块。如果是这样,它将根据其功能显示在页面中。如果它的位置与其功能无关,我们将在页面底部放置脚本块,就像我们的脚本包含的一样。

DOM 结构

HTML 文档的结构在 DOM 中表示为一棵可导航的树。所有使用的术语都类似于系谱树(父母、子女、兄弟姐妹等等)。出于我们的目的,树的主干是文档节点,也称为文档元素。该元素包含指向其子节点的指针,反过来,每个子节点又包含指向其父节点、兄弟节点和子节点的指针。

DOM 使用特定的术语来指代 HTML 树中的不同对象。几乎 DOM 树中的所有东西都是节点:HTML 元素是节点,元素中的文本是节点,注释是节点,DOCTYPE 是节点,甚至属性也是节点!显然,我们需要能够区分这些节点,因此每个节点都有一个节点类型属性,适当地称为nodeType ( 表 5-1 )。我们可以查询这个属性来判断我们正在查看的是哪种类型的节点。如果你得到一个节点的引用,它将是一个节点类型的实例,实现所有的方法并拥有该类型的所有属性。

表 5-1 。节点类型及其常量值

|

节点名

|

节点类型值

| | --- | --- | | 元素 _ 节点 | one | | 属性 _ 注释(已弃用) | Two | | 正文 _ 节点 | three | | CDATA_SECTION_NODE(已弃用) | four | | 实体 _ 引用 _ 节点(已弃用) | five | | 实体节点(已弃用) | six | | 处理 _ 指令 _ 节点 | seven | | 评论 _ 节点 | eight | | 文档 _ 节点 | nine | | 文档类型节点 | Ten | | 文档 _ 片段 _ 节点 | Eleven | | 符号 _ 节点(已弃用) | Twelve |

标记为 deprecated 的节点类型将被取代并可能被删除,但这种可能性很小。他们可能仍然工作,因为他们已经使用了几年了。

正如您在表中看到的,节点有各种专门化,它们对应于 DOM 规范中的接口。特别感兴趣的是文档、元素、属性和文本。其中每个都有自己的实现类型:分别是文档、元素、属性和文本。

Image 注意属性是一个特例。在 DOM 级别 1、2 和 3 下,Attr 接口实现了节点接口。对于 DOM Level 4 来说,这不再是真的。值得庆幸的是,这更多的是一种常识性的改变。更多细节可以在属性部分找到。

一般来说,该文档关注的是将 HTML 文档作为一个整体来管理。文档中的每个标签都是一个元素,它本身被特化为特定的 HTML 元素类型(例如,HTMLLIElementHTMLFormElement)。元素的属性被表示为 Attr 的实例。元素中的任何纯文本都是文本节点,由文本类型表示。这些子类型不是从 Node 继承的所有类型,但它们是我们最有可能与之交互的类型。

给定我们的清单,让我们看看它的结构:从<!DOCTYPE html></html>的整个文档就是文档。doctype 本身就是 doctype 类型的一个实例。<html>...</html>元素是我们的首要元素。它包含了<head><body>标签的子元素。再深入一点,我们可以看到<body>中的<p>元素有两个属性,idclass。同一个<p>元素的内容只有一个文本节点。在各种 DOM 类型的实例之间的关系中,文档的层次结构是重复的。我们应该更详细地研究这些关系。

DOM 关系

让我们检查一个非常简单的文档片段,以显示节点之间的各种关系:

<p><strong>Hello</strong> how are you doing?</p>

这个代码片段的每个部分都分解成一个 DOM 节点,每个节点的指针都指向它的直接亲属(父节点、子节点、兄弟节点)。如果你要完全描绘出存在的关系,它看起来会像图 5-1 。代码片段的每个部分(圆形框表示元素,常规框表示文本节点)都与其可用的引用一起显示。

9781430263913_Fig05-01.jpg

图 5-1 。节点之间的关系

每个 DOM 节点都包含一个指针集合,可以用来引用它的亲戚。您将使用这些指针来学习如何在 DOM 中导航。所有可用的指针都显示在图 5-2 中。每个 DOM 节点上都有这些属性,它们都是指向另一个节点或其子类的指针。唯一的例外是childNodes (当前节点的所有子节点的集合)。当然,如果这些关系中有一个未定义,属性的值将为空(想象一个<img>标签,它既没有定义firstChild也没有定义lastChild)。

9781430263913_Fig05-02.jpg

图 5-2 。使用指针导航 DOM 树

只需使用不同的指针,就可以导航到页面上的任何元素或文本块。回想一下清单 5-1 ,它展示了一个典型的 HTML 页面。之前,我们从 JavaScript 类型的角度来看。现在我们将从 DOM 的角度来看它。

在示例文档中,文档节点是<html>元素。在 JavaScript 中访问这个元素很简单:document.documentElement直接引用<html>元素。根节点拥有用于导航的所有指针,就像任何其他 DOM 节点一样。使用这些指针,您可以开始浏览整个文档,导航到您想要的任何元素。例如,要获得<h1>元素,您可以使用以下代码:

// Does not work!
document.documentElement.firstChild.nextSibling.firstChild

但是我们遇到了一个主要障碍:DOM 指针可以指向文本节点和元素。我们的 JavaScript 语句实际上并没有指向<h1>元素;而是指向<title>元素。为什么会这样?这是因为 XML 中最棘手和最有争议的一个方面:空白。如果你注意到清单 5-1 中的,在<html><head>元素之间实际上有一条结束线,这被认为是空白,这意味着实际上首先有一个文本节点,而不是<head>元素。从中我们可以学到四点:

  • 当试图只使用指针浏览 DOM 时,编写漂亮、干净的 HTML 标记实际上会使事情变得非常混乱。
  • 只使用 DOM 指针来导航文档可能非常冗长且不切实际。
  • 事实上,DOM 指针显然非常脆弱,因为它们将 JavaScript 逻辑与 HTML 过于紧密地联系在一起。
  • 通常,您不需要直接访问文本节点,只需要访问它们周围的元素。

这就引出了一个问题:有没有更好的方法来查找文档中的元素?有,有!更准确的说:有!我们有两种主要的方法来访问页面中的元素。一方面,我们可以继续相对访问,有时称为 DOM 遍历。出于刚才列出的原因,我们将避免在一般 DOM 访问中使用这种方法。不过,当我们对访问特定元素有了更好的处理时,我们将在后面重新讨论 DOM 遍历。相反,我们将走第二条路,关注现代 DOM 接口提供的各种元素检索功能。

访问 DOM 元素

所有现代的 DOM 实现都包含几种方法,可以很容易地在页面中找到元素。将这些方法与一些定制函数结合使用,可以使 DOM 导航体验更加流畅。首先,让我们看看如何访问单个元素:

  • document.getElementById('everywhere'):这个方法只能在文档对象上运行,它在所有的中查找 ID 等于的所有元素。这是一个非常强大的功能,也是立即访问元素的最快方法。

getElementById方法使用提供的 ID 返回对 HTML 元素的引用,否则返回 null。返回的对象特别是元素类型的一个实例。我们将很快讨论如何处理这个元素。

Image 注意 getElementById与您想象的 HTML 文档一样工作:它浏览所有元素并找到一个具有指定值的属性id的元素。但是,如果您加载一个远程 XML 文档并使用getElementById(或者使用除 JavaScript 之外的任何语言的 DOM 实现),默认情况下它不使用id属性。这是故意的;XML 文档必须明确指定id属性是什么,通常使用 XML 定义或模式。

让我们继续参观元素访问函数。接下来的两个函数提供了对元素集合的访问:

  • getElementsByTagName('li'):这个方法可以在任何元素上运行,查找所有标签名为li的派生元素,并将其作为活动的NodeList返回(这几乎与数组相同)。
  • getElementsByClassName(' 测试 '):类似于getElementsByTag 名字,这个方法可以从元素的任何实例运行。它返回一个匹配元素的 live HTMLCollection

这两个函数允许我们一次访问多个元素。暂且抛开返回类型的区别,返回的集合是 live 。这意味着,如果修改了 DOM,并且这些修改将包含在集合中(或者将从集合中移除元素),那么集合将自动更新这些更改。很厉害!

奇怪的是,这两个函数相似的方法返回两种不同的类型。首先,让我们考虑简单的部分:两种类型都有类似数组的位置访问。也就是说,对于以下内容:

var lis = document.getElementsByTagName('li');

您可以通过lis[1]访问lis集合中的第二个列表项。两个集合都有一个length属性,它告诉您集合中有多少项。它们还有一个item方法,将访问的位置作为参数,并返回该位置的元素。item方法是一种按位置访问元素的函数方式。最后,两个集合都没有更高阶的数组方法,比如pushpopmapfilter

如果你想在你的HTMLCollectionNodeList上使用数组方法,你总是可以使用它们,如清单 5-2 所示。

清单 5-2NodeList s/ HTMLCollection s 上的数组函数

// A simple filtering function
// An Element's nodeName property is always the name of the underlying tag.
function filterForListItems(el) {
    return el.nodeName === 'LI';
}

var testElements = document.getElementsByClassName( 'test' );
console.log( 'There are ' + testElements.length' elements in testElements.');

// Generating an array from the elements gathered from testElements
// based on whether they pass the filtering proccess set up by filterForListItems
var liElements = Array.prototype.filter.call(testElements, filterForListItems);
console.log( 'There are ' +  liElements.length' elements in liElements.');

返回类型中方法之间的差异是由浏览器中 DOM 实现的不确定性造成的。在将来,两者都应该返回HTMLCollection实例,但是现在还不是时候。因为NodeList s 和HTMLCollection s 的访问模式实际上是相同的,我们不必太关心哪个方法返回哪个类型。

当使用getElementsByClassNamegetElementsByTagName时,值得记住的是它们不仅属于文档实例,也属于元素实例。当从文档中调用时,它们将对整个文档进行搜索。考虑到您的<head>部分将被搜索到<li>标签,或者您将在那里寻找具有类foo的元素。可以想象,这有点低效。想象你正在你的房子里寻找你的钥匙。你可能不会在冰箱里或浴室里寻找,因为它们不太可能是你忘记带钥匙的地方。所以你会在卧室、客厅、入口通道等地方寻找。尽可能将搜索范围限制在适当的包含元素上。看一下清单 5-3 ,它得到与清单 5-2 相同的结果,但是将它的范围限制在一个特定的父元素。

清单 5-3 。限制搜索范围

var ul = document.getElementById( 'items' );
var liElements = ul.getElementsByClassName( 'test' );
console.log( 'There are ' +  liElements.length' elements in liElements.');

Image 注意 document.getElementByIdgetElementsByClassNamegetElementsByTagName不同,在元素类型的实例上不可用。在文档或文档类型的实例上只有可用。**

这三种方法在所有现代浏览器中都可用,对于定位特定元素非常有帮助。回到之前我们试图寻找<h1>元素的例子,我们现在可以做以下事情:

document.getElementsByTagName('h1')[0];

这段代码保证能够工作,并且总是返回文档中的第一个<h1>元素。

通过 CSS 选择器寻找元素

作为一名 web 开发人员,您已经知道选择 HTML 元素的另一种方法:CSS 选择器。CSS 选择器是用于将 CSS 样式应用于一组元素的表达式。随着 CSS 标准的每次修订(1、2 和 3,有时也分别称为 CSS 1 级、2 级或 3 级),选择器规范中添加了更多的功能,因此开发人员可以更容易地找到他们需要的确切元素。浏览器有时提供 CSS 2 和 CSS 选择器的完整实现很慢,所以你可能不知道它们提供的一些很酷的新特性。这在现代浏览器中已经基本解决了。如果您对 CSS 中所有很酷的新特性感兴趣,我们建议您浏览 W3C 关于这个主题的页面:

  • CSS 1 选择器:http://www.w3.org/TR/CSS1/#basic-concepts
  • CSS 2.1 选择器:http://www.w3.org/TR/CSS21/selector.html
  • CSS 3 选择器:http://www.w3.org/TR/css3-selectors/

每个 CSS 选择器规范中可用的特性通常是相似的,因为每个后续版本也包含以前版本的所有特性。但是,每个版本都添加了许多新功能。例如,CSS 2.1 包含属性和子选择器,而 CSS 3 提供了额外的语言支持,通过属性类型选择和否定。对于现代浏览器,所有这些都是有效的 CSS 选择器:

  • #main <div> p:该表达式查找 ID 为main的元素、所有<div>元素的后代,然后是所有<p>元素的后代。所有这些都是一个合适的 CSS 1 选择器。
  • div.items > p:该表达式查找所有具有items类的<div>元素,然后定位所有子<p>元素。这是一个有效的 CSS 2 选择器。
  • div:not(.items):这将定位所有没有items类的<div>元素。这是一个有效的 CSS 3 选择器。

有两种方法可以通过 CSS 选择器访问元素:querySelectorquerySelectorAll。给querySelector一个有效的 CSS 选择器,它将返回第一个匹配该选择器的元素的引用。使用querySelectorAll时唯一改变的是你得到一个非活动的匹配元素的NodeList。(该列表不是实时的,因为实时列表会占用大量资源)。与getElementsByTagNamegetElementsByClassName一样,您可以从元素的任何实例中调用querySelectorquerySelectorAll。在可能的情况下,最好以这种方式限制搜索范围,以获得更高的效率和更快的回报。

我们现在有四种方法来访问元素。我们应该使用哪个?首先,对于单元素访问,document.getElementById应该总是最快的。但是对于多元素访问,或者如果您想要的元素没有 ID,可以考虑使用getElementsByTagName,然后是getElementsByClassName,然后是querySelectorAll。但是要记住,这只是考虑了速度。有时候,查询的方便性,或者匹配元素的准确性,甚至对实时集合的需求都比速度更重要。使用最适合您需求的方法。

等待 HTML DOM 加载

使用 HTML DOM 文档的困难之一是,JavaScript 代码可能会在 DOM 完全加载之前执行,这可能会导致代码中出现许多问题。浏览器内部的操作顺序如下所示:

  1. 解析 HTML。
  2. 加载外部样式表。
  3. 脚本在文档中被解析时被执行。
  4. HTML DOM 是完全构造的。
  5. 加载图像和外部内容。
  6. 页面加载完毕。

当然,所有这些很大程度上取决于 HTML 的结构。如果在加载 CSS 的<link>标签之前有一个<script>标签,那么 JavaScript 将在 CSS 加载之前加载。(顺便说一句,不要这么做。效率很低。)在实际构造 HTML DOM 之前,执行头部中的脚本并从外部文件加载。如前所述,这是一个严重的问题,因为在这两个地方执行的所有脚本都不能访问 DOM。这也是我们避免将脚本标签放在<head>部分的部分原因。但是,即使我们遵循最佳实践,在结束的<body>标签之前包含了我们的<script>标签,也有可能 DOM 还没有准备好被我们的 JavaScript 处理。幸运的是,这个问题有很多解决方法。

等待页面加载

到目前为止,最常见的技术是在执行任何 DOM 操作之前简单地等待整个页面加载。只需将一个在页面加载时触发的函数附加到窗口对象的 load 事件上,就可以使用这种技术。我们将在第六章中更详细地讨论这些事件。清单 5-4 展示了一个在页面加载完成后执行 DOM 相关代码的例子。

清单 5-4 。用于将回调附加到窗口load属性的addEventListener函数

// Wait until the page is loaded
// (Uses addEventListener, described in the next chapter)
window.addEventListener('load', function() {
    // Perform HTML DOM operations
    var theSquare = document.getElementById('square');
        theSquare.style.background'blue';
});

虽然这个操作可能是最简单的,但它总是最慢的。从加载操作的顺序来看,您会注意到正在加载的页面是最后一步。在所有具有src属性的元素下载完文件之前,load 事件不会触发。这意味着,如果您的页面有大量的图像、视频等,您的用户可能要等很长时间,直到 JavaScript 最终执行。另一方面,这是最向后兼容的解决方案。

等待合适的事件

如果你有更现代的浏览器,你可以查看DOMContentLoaded事件。当文档完全加载并解析后,将触发此事件。在我们的列表中,这大致匹配“HTML DOM 是完全构造的。”但是请记住,在该事件触发时,图像、样式表、视频、音频等可能还没有完全加载。如果需要在加载特定的图像或视频文件后触发代码,请考虑对该特定标记使用 load 事件。如果您需要等到所有具有src属性的元素都下载了它们的文件,请使用窗口加载事件。详情请看清单 5-5 。

清单 5-5 。使用DOMContentLoaded

document.addEventListener('DOMContentLoaded' functionHandler);

Internet Explorer 8 不支持DOMContentLoaded,但您可以查看文档上的就绪状态是否已更改。清单 5-6 展示了如何检测 DOM 是否以跨浏览器兼容的方式加载。

清单 5-6 。跨浏览器DOMContentLoaded

if(document.addEventListener){
      document.addEventListener('DOMContentLoaded', function(){
       document.removeEventListner('DOMContenLoded',arguments.callee);
})else if(document.attachEvent){
       document.attachEvent('onreadystatechange', function(){
      document.detachEvent('onreadystatechange', arguments.callee,); 
}

获取元素的内容

所有 DOM 元素都可以包含以下三种内容之一:文本、更多元素或文本和元素的混合。一般来说,最常见的情况是第一种和最后一种。在本节中,您将看到检索元素内容的常用方法。

获取元素的文本

对于不熟悉 DOM 的人来说,在元素中获取文本可能是最令人困惑的任务。然而,这也是一个在 HTML DOM 文档和 XML DOM 文档中都有效的任务,所以知道如何做将会很好地为您服务。在图 5-3 所示的示例 DOM 结构中,有一个根<p>元素,它包含一个<strong>元素和一个文本块。<strong>元素本身也包含一个文本块。

9781430263913_Fig05-03.jpg

图 5-3 。包含元素和文本的示例 DOM 结构

让我们看看如何获得这些元素的文本。<strong>元素是最容易开始的,因为它只包含一个文本节点,没有其他内容。

需要注意的是,在所有非基于 Mozilla 的浏览器中,有一个名为innerText的属性可以捕捉元素内部的文本。在这方面,它非常方便。不幸的是,因为它在浏览器市场的明显部分不起作用,并且它在 XML DOM 文档中不起作用,所以您仍然需要探索可行的替代方案。

获取一个元素的文本内容的技巧是,你需要记住文本并不直接包含在元素中;它包含在子文本节点中,这看起来有点奇怪。例如,清单 5-7 展示了如何使用 DOM 从元素内部提取文本;假设变量strongElem包含对<strong>元素的引用。

清单 5-7 。获取<strong>元素的文本内容

// Non-Mozilla Browsers:
strongElem.innerText

// All platforms including Non-Mozilla browsers:
strongElem.firstChild.nodeValue

既然您已经知道如何获取单个元素的文本内容,那么您需要看看如何获取<p>元素的组合文本内容。在这样做的时候,你也可以开发一个通用函数来获取任何元素的文本内容,不管它实际包含什么,如清单 5-8 所示。调用text( 元素 )将返回一个字符串,该字符串包含该元素及其包含的所有子元素的组合文本内容。

清单 5-8 。检索元素文本内容的通用函数

function text(e) {
    var t = '' ;
    // If an element was passed, get its children,
    // otherwise assume it's an array
    e = e.childNodes || e;

    // Look through all child nodes
    forvar j = 0; j < e.length; j++ ) {
        // If it’s not an element, append its text value
        // Otherwise, recurse through all the element's children
        t += e[j].nodeType != 1 ?
            e[j].nodeValuetext(e[j].childNodes);
    }

    // Return the matched text
    return t;
}

使用一个可以用来获取任何元素的文本内容的函数,您可以检索前面示例中使用的<p>元素的文本内容。这样做的代码看起来会像这样:

// Get the text contents of the <p> Element
var pElm = document.getElementsByTagName ('p');
console.log(text( pElem ));

这个函数特别好的一点是,它保证可以在 HTML 和 XML DOM 文档中工作,这意味着您现在有了一种一致的方法来检索任何元素的文本内容。

获取一个元素的 HTML

与获取元素内部的文本不同,获取元素的 HTML 是可以执行的最简单的 DOM 任务之一。多亏了 Internet Explorer 团队开发的一个特性,所有现代浏览器现在都在每个 HTML DOM 元素上包含了一个额外的属性:innerHTML。有了这个属性,你就可以获得一个元素中的所有 HTML 和文本。此外,使用 i nnerHTML属性非常快——通常比递归搜索元素的所有文本内容要快得多。然而,事情并非一帆风顺。由浏览器决定如何实现innerHTML属性,因为没有真正的标准,浏览器可以返回它认为有价值的任何内容。例如,在使用innerHTML属性时,您可能会遇到一些奇怪的错误:

  • 基于 Mozilla 的浏览器不会在innerHTML语句中返回<style>元素。
  • Internet Explorer 8 和更低版本返回的所有元素都是大写的,如果您希望保持一致性,这可能会令人沮丧。
  • innerHTML属性始终只作为 HTML DOM 文档元素的属性;试图在 XML DOM 文档上使用它将导致检索空值。

使用innerHTML属性很简单;访问属性会为您提供一个包含元素的 HTML 内容的字符串。如果元素不包含任何子元素而只包含文本,则返回的字符串将只包含文本。为了了解它是如何工作的,我们将检查图 5-2 中的两个元素:

// Get the innerHTML of the <strong> element
// Should return "Hello"
strongElem.innerHTML
// Get the innerHTML of  the <p> element
// Should return "<strong>Hello</strong> how are you doing?"
pElem.innerHTML

如果您确定您的元素只包含文本,那么这个方法可以非常简单地替代获取元素文本的复杂性。另一方面,能够检索元素的 HTML 内容意味着您可以构建一些利用就地编辑的很酷的动态应用;关于这个主题的更多信息可以在第十章中找到。

使用元素属性

除了检索元素的内容,获取和设置元素的属性值是最常见的操作之一。通常,元素具有的属性列表预加载了从元素本身的 XML 表示中收集的信息,并存储在关联数组中以供以后访问,如以下网页中 HTML 片段的示例所示:

<form name="myForm" action="/test.cgi" method="POST">
    ...
</form>

一旦加载到 DOM 和变量formElem中,HTML 表单元素就会有一个关联数组,从中可以收集名称/值属性对。结果看起来会像这样:

formElem.attributes = {
    name: 'myForm',
    action: '/test.cgi',
    method: 'POST'
};

使用 attributes 数组判断元素的属性是否存在应该是非常简单的,但是有一个问题:出于某种原因,Safari 不支持这个特性。只要 IE8 处于标准模式,Internet Explorer 版本 8 和更高版本都支持它。那么,如何才能发现一个属性是否存在呢?一种可能的方法是使用getAttribute函数(将在下一节介绍)并测试看看返回值是否为空,如清单 5-9 所示。

清单 5-9 。确定元素是否具有某种属性

function hasAttribute( elem, name ) {
    return elem.getAttribute(name) != null;
}

有了这个函数,并且知道了如何使用属性,现在就可以开始检索和设置属性值了。

获取和设置属性值

根据所使用的 DOM 文档类型,有两种方法可以从元素中检索属性数据。如果你想安全并且总是使用通用的 XML DOM 兼容方法,有getAttribute()setAttribute()。它们可以这样使用:

// Get an attribute
document.getElementById('everywhere').getAttribute('id');
// Set an attribute value
document.getElementsByTagName('input')[0].setAttribute('value', 'Your Name');

除了这个标准的getAttribute / setAttribute对,HTML DOM 文档还有一组额外的属性,作为属性的快速获取器/设置器。这些在现代 DOM 实现中普遍可用(但只保证用于 HTML DOM 文档),因此在编写简短代码时使用它们会给你带来很大的优势。以下示例显示了如何使用 DOM 属性来访问和设置 DOM 属性:

// Quickly get an attribute
document.getElementsByTagName('input')[0].value;

// Quickly set an attribute
document.getElementsByTagName('div')[0].id'main';

有几个奇怪的例子,你应该知道它们的属性。最常见的是访问类名属性。如果你直接引用一个类的名字,elem.className会让你设置和获取名字。然而,如果你使用的是get / setAttribute方法,你可以称之为getAttribute('class')。为了在所有浏览器中一致地使用类名,你必须使用elem.className来访问className属性,而不是使用更合适的名称getAttribute('class')。这个问题也出现在for属性上,该属性被重命名为htmlFor。此外,一些 CSS 属性也是如此:cssFloatcssText。出现这种特殊的命名约定是因为像classforfloattext这样的单词都是 JavaScript 中的保留字。

为了解决所有这些奇怪的情况并简化获取和设置正确属性的过程,您应该使用一个函数来处理所有这些细节。清单 5-10 显示了一个获取和设置元素属性值的函数。调用带有两个参数的函数,比如attr(element, id),返回该属性的值。调用带有三个参数的函数,比如attr(element, class, test),将设置属性的值并返回它的新值。

清单 5-10 。获取和设置元素属性的值

function attr(elem, name, value) {
    // Make sure that a valid name was provided
    if ( !name || name.constructor != Stringreturn '' ;

    // Figure out if the name is one of the weird naming cases
    name = { 'for': 'htmlFor', 'className': 'class' }[name] || name;

    // If the user is setting a value, also
    iftypeof value != 'undefined' ) {
        // Set the quick way first
        elem[name] = value;

        // If we can, use setAttribute
        if ( elem.setAttribute )
            elem.setAttribute(name,value);
    }

    // Return the value of the attribute
    return elem[name] || elem.getAttribute(name) || '';
}

拥有一个标准的方法来访问和更改属性,而不管它们的实现,这是一个强大的工具。清单 5-11 展示了一些例子,说明如何在一些常见的情况下使用attr函数来简化处理属性的过程。

清单 5-11 。使用attr函数设置和检索 DOM 元素的属性值

// Set the class for the first <h1> Element
attr( document.getElementByTagName('h1')[0], 'class', 'header' );

// Set the value for each <input> element
var input = document.getElementByTagName('input');
forvar i = 0; i < input.length; i++ ) {
    attr( input[i], 'value', ''  );
}

// Add a border to the <input> Element that has a name of 'invalid'
var input = document.getElementByTagName('input');
forvar i = 0; i < input.length; i++ ) {
    ifattr( input[i], 'name' ) == 'invalid' ) {
        input[i].style.border'2px solid red';
    }
}

到目前为止,我们只讨论了获取/设置 DOM 中常用的属性(ID、类、名称等等)。然而,一个非常方便的技巧是设置和获取非传统属性。例如,您可以添加一个新属性(只有通过访问元素的 DOM 版本才能看到),然后在以后再次检索它,所有这些都不需要修改文档的物理属性。例如,假设您想要一个条目的定义列表,并且每当单击一个术语时,都要展开定义。这个设置的 HTML 看起来类似于清单 5-12 。

清单 5-12 。带有定义列表的 HTML 文档,隐藏了定义

<html>
<head>
    <title>Expandable Definition List</title>
    <style>dddisplay: none; }</style>
</head>
<body>
    <h1>Expandable Definition List</h1>

    <dl>
        <dt>Cats</dt>
        <dd>A furry, friendly, creature.</dd>
        <dt>Dog</dt>
        <dd>Like to play and run around.</dd>
        <dt>Mice</dt>
        <dd>Cats like to eat them.</dd>
    </dl>
</body>
</html>

我们将在第六章中讨论更多关于事件的细节,但是现在我们将尽量保持我们的事件代码足够简单。接下来是一个快速脚本,允许您单击术语并显示(或隐藏)它们的定义。清单 5-13 显示了构建一个可扩展定义列表所需的代码。

清单 5-13 。允许动态切换到定义

// Wait until the DOM is Ready
document.addEventListener('DOMContentLoaded', addEventClickToTerms);

// Watch for a user click on the term
function addEventClickToTerms(){
     var dt = document.getElementsByTagName('dt');
      forvar i = 0; i < dt.length; i++ ) {
          dt[i].addEventListener('click', checkIfOpen);
      }
}

// See if the definition is already open or not
//Need two nextSiblings because the first sibling is a text node (the words that were clicked on).
//If it's never been clicked, the style will be blank ''. F it has been, the style will be 'none', so we check for both with an if statement.
function checkIfOpen(e){
    if(e.target.nextSibling.nextSibling.style.display == '' || e.target.nextSibling.nextSibling.style.display == 'none'){
        e.target.nextSibling.nextSibling.style.display'block';
    }else{
        e.target.nextSibling.nextSibling.style.display'none';
    }
}

既然您已经知道了如何遍历 DOM 以及如何检查和修改属性,那么您需要学习如何创建新的 DOM 元素,在需要的地方插入它们,以及删除不再需要的元素。

修改 DOM

通过了解如何修改 DOM,您可以做任何事情,从动态创建定制的 XML 文档到构建适应用户输入的动态表单;可能性几乎是无限的。修改 DOM 有三个步骤:首先你需要学习如何创建一个新元素,然后你需要学习如何将它插入 DOM,然后你需要学习如何再次移除它。

使用 DOM 创建节点

修改 DOM 的主要方法是createElement函数,它让您能够动态地创建新元素。然而,当您创建这个新元素时,它不会立即插入到 DOM 中(这是刚开始使用 DOM 的人容易混淆的一点)。首先,我们将着重于创建一个 DOM 元素。

createElement方法接受一个参数,即元素的标记名,并返回该元素的虚拟 DOM 表示——不包括属性或样式。如果您正在开发使用 XSLT 生成的 XHTML 页面的应用(或者如果应用是提供准确内容类型的 XHTML 页面),您必须记住您实际上使用的是 XML 文档,并且您的元素需要有正确的 XML 名称空间与之相关联。为了无缝地解决这个问题,您可以使用一个简单的函数来悄悄测试您正在使用的 HTML DOM 文档是否能够创建带有名称空间的新元素(XHTML DOM 文档的一个特性)。如果是这种情况,您必须用正确的 XHTML 名称空间创建一个新的 DOM 元素,如清单 5-14 所示。

清单 5-14 。创建新 DOM 元素的通用函数

function create( elem ) {
    return document.createElementNS ?
        document.createElementNS('http://www.w3.org/1999/xhtml', elem ) :
        document.createElement( elem );
}

例如,使用前面的函数,您可以创建一个简单的<div>元素,并向其附加一些附加信息:

var div = create('div');
div.className'items';
div.id'all';

此外,应该注意的是,有一个用于创建新文本节点的 DOM 方法,称为createTextNode。它接受一个参数,即您希望包含在节点中的文本,并返回创建的文本节点。

使用新创建的 DOM 元素和文本节点,现在可以将它们插入到 DOM 文档中需要它们的地方。

插入到 DOM 中

向 DOM 中插入内容令人困惑,有时感觉很笨拙,即使对于那些熟悉 DOM 的人来说也是如此。你的武器库中有两种功能可以用来完成工作。

第一个函数insertBefore ,允许您在另一个子元素之前插入一个元素。当您使用该函数时,它看起来像这样:

parentOfBeforeNode.insertBefore( nodeToInsert, beforeNode );

我们用来记住参数顺序的记忆方法是短语“你在第二个元素之前插入第一个元素。”

现在您有了一个在其他节点之前插入节点(包括元素和文本节点)的函数,您应该问自己:“如何将一个节点作为父节点的最后一个子节点插入?”你还可以使用另一个功能,叫做appendChild ,它可以让你做到这一点。在元素上调用appendChild,将指定的节点追加到子节点列表的末尾。使用函数看起来像这样:

parentElem.appendChild( nodeToInsert );

清单 5-15 是一个如何在应用中同时使用insertBeforeappendChild的例子。

清单 5-15 。在一个元素之前插入另一个元素的函数

document.addEventListener(DOMContentLoaded, 'addElement');

function addElement(){
 //Grab the ordered list that is in the document
 //Remember that getElementById returns an array like object

   var orderedList = document.getElementById('myList');

 //Create an <li>, add a text node then append it to <li>
 var li = document.createElement('li');
     li.appendChild(document.createTextNode('Thanks for visiting'));

 //element [0] is how we access what is inside the orderedList
  orderedList.insertBefore(li, orderedList[0]);
}

当您将这些信息“插入”DOM(使用insertBeforeappendChild)时,用户会立即看到这些信息。因此,您可以使用它来提供即时反馈。这在需要用户输入的交互式应用中尤其有用。

既然您已经看到了如何只使用基于 DOM 的方法来创建和插入节点,那么看看将内容注入 DOM 的其他方法应该会特别有用。

将 HTML 注入 DOM

一种比创建普通 DOM 元素并将其插入 DOM 更流行的技术是将 HTML 直接注入文档。实现这一点的最简单方法是使用前面讨论的 i nnerHTML方法。除了检索元素内部的 HTML 之外,它还是在元素内部设置 HTML 的一种方式。作为一个简单的例子,让我们假设你有一个空的<ol>元素,你想给它添加一些<li>;这样做的代码如下所示:

// Add some LIs to an OL element
document.getElementsByTagName('ol')[0].innerHTML"<li>Cats.</li><li>Dogs.</li><li>Mice.</li>";

这难道不比痴迷于创建大量 DOM 元素及其关联的文本节点简单得多吗?你会很高兴地知道(根据http://www.quirksmode.org)这也比使用 DOM 方法要快得多。然而,这并不完美——使用innerHTML注射方法存在许多棘手的问题:

  • 如前所述,innerHTML方法不存在于 XML DOM 文档中,这意味着您必须继续使用传统的 DOM 创建方法。
  • 使用客户端 XSLT 创建的 XHTML 文档没有innerHTML方法,因为它们也是纯 XML 文档。
  • i nnerHTML完全删除元素中已经存在的任何节点,这意味着没有办法方便地添加或插入之前的节点,就像我们使用纯 DOM 方法一样。

最后一点特别麻烦,因为在另一个元素之前插入或追加到子列表的末尾是一个特别有用的特性。让我们看看如何在清单 5-16 中使用我们之前使用的相同方法来完成。

清单 5-16 。向现有有序列表中添加新的 DOM 节点

document.addEventListener('DOMContentLoaded', activateButtons);

function activateButtons(){
    //ad event listeners to buttons
    var appendBtn = document.querySelector('#appendButon');
        appendBtn.addEventListener('click', appendNode);

    var addBtn = document.querySelector('#addButton');
        addBtn.addEventListener('click', addNode);
}

function appendNode(e){

    //get the <li>s that exist and make a new one.
    var listItems = document.getElementsByTagName('li');
    var newListItem = document.createElement('li');
        //append a new text node
        newListItem.appendChild(document.createTextNode('Mouse trap.'));

        //append to existing list as the new 4th item
        listItems[2].appendChild(newListItem);
}

function addNode(e){

    //get the whole list
     var orderedList = document.getElementById('myList');

     //get all the <li>s
    var listItems = document.getElementsByTagName('li');
    //make a new <li> and attach text node
    var newListItem = document.createElement('li');
        newListItem.appendChild(document.createTextNode('Zebra.'));
        //add to list, pushing the 2nd one down to 3rd
        orderedList.insertBefore(newListItem,listItems[1]);
}

通过这个例子,您可以看到对现有文档进行修改并不困难。但是,如果您想从另一个方向移动并从 DOM 中删除节点呢?和往常一样,还有另一种方法来处理这个问题。

从 DOM 中删除节点

从 DOM 中删除节点几乎与创建和插入节点一样频繁。例如,当您创建一个要求无限数量条目的动态表单时,允许用户能够删除他们不想再处理的页面部分就变得很重要。删除节点的能力被封装在一个函数中:removeChild。跟appendChild用的一样,但是效果相反。这个函数看起来像这样:

NodeParent.removeChild( NodeToRemove );

考虑到这一点,您可以创建两个独立的函数来快速删除节点。第一个删除单个节点,如清单 5-17 所示。

清单 5-17 。从 DOM 中删除一个节点的函数

// Remove a single Node from the DOM
function remove( elem ) {
    if ( elem ) elem.parentNode.removeChild( elem );
}

清单 5-18 展示了一个从一个元素中移除所有子节点的函数,只使用了一个对 DOM 元素的引用。

清单 5-18 。从一个元素中移除所有子节点的函数

// Remove all of an Element’s children from the DOM
function empty( elem ) {
    while ( elem.firstChild )
        remove( elem.firstChild );
}

作为一个例子,假设您想要删除您在上一节中添加的一个<li>,假设您已经给了用户足够的时间来查看<li>,并且它可以被删除。清单 5-19 显示了 JavaScript 代码,您可以使用它来执行这样的操作,从而创建一个理想的结果。

清单 5-19 。从 DOM 中移除单个元素或所有元素

// Remove the last <li> from an <ol>
var listItems = document.getElementsByTagName('li');
remove(listItems[2]);

// The preceding will convert this:
<ol>
    <li>Learn Javascript.</li>
    <li>???</li>
    <li>Profit!</li>
</ol>
// Into this:
<ol>

    <li>Learn Javascript.</li>
    <li>???</li>
</ol>

// If we were to run the empty() function instead of remove()
var orderedList = document.getElementById('myList');
empty(orderedList);
// It would simply empty out our <ol>, leaving:
<ol></ol>

处理 DOM 中的空白字符

让我们回到 HTML 文档的例子。以前,您试图定位单个的<h1>元素,但是由于额外的文本节点而遇到了困难。对于单个元素来说,这可能是可以接受的,但是如果您想在<h1>元素之后找到下一个元素呢?你仍然会碰到臭名昭著的空白错误,你需要做.nextSibling.nextSibling来跳过<h1 >和<p>元素之间的结束线。然而,并非一切都没了。有一种技术可以作为空白错误的解决方法,如清单 5-20 所示。这种特殊的技术从 DOM 文档中删除了所有只有空白的文本节点,使得遍历更加容易。这样做不会对 HTML 的呈现方式产生明显的影响,但是会让你更容易手动导航。应该注意,这个函数的结果不是永久的,每次加载 HTML 文档时都需要重新运行。

清单 5-20 。XML 文档中空白错误的解决方法

function cleanWhitespace( element ) {
    // If no element is provided, do the whole HTML document
    element = element || document;
    // Use the first child as a starting point
    var cur = element.firstChild;
    // Go until there are no more child nodes
    while ( cur != null ) {
        // If the node is a text node, and it contains nothing but whitespace
        if ( cur.nodeType == 3 && ! /\S/.test(cur.nodeValue) ) {
            // Remove the text node
            element.removeChild( cur );
        // Otherwise, if it’s an element
        } else if ( cur.nodeType == 1 ) {
             // Recurse down through the document
             cleanWhitespace( cur );
        }
        cur = cur.nextSibling; // Move through the child nodes
    }
}

假设您想在示例文档中使用这个函数来查找第一个<h1>元素之后的元素。这样做的代码看起来会像这样:

cleanWhitespace();
// Find the H1 Element
document.documentElement
    .firstChild         // Find the Head Element
    .nextSibling        // Find the <body> Element
    .firstChild         // Get the H1 Element
    .nextSibling        // Get the adjacent Paragraph

这种技术既有优点也有缺点。最大的好处是,当您试图导航 DOM 文档时,可以保持一定程度的理智。然而,考虑到您必须遍历每一个 DOM 元素和文本节点来寻找只包含空白的文本节点,这种技术特别慢。如果你有一个包含大量内容的文档,它会大大降低网站的加载速度。此外,每次向文档中注入新的 HTML 时,都需要重新扫描 DOM 的这一部分,确保没有添加额外的填充空格的文本节点。

这个函数的一个重要方面是节点类型的使用。可以通过检查节点的nodeType属性的特定值来确定节点的类型。在这一章的开始我们有一个类型列表。因此,您可以看到许多可能的值,但您最常遇到的三个值如下:

  • 元素(nodeType = 1):匹配 XML 文件中的大多数元素。例如,<li><a><p><body>元素的nodeType都是 1。
  • 文本(nodeType = 3):匹配文档中的所有文本段。当使用previousSiblingnextSibling在 DOM 结构中导航时,您会经常遇到元素内部和元素之间的文本片段。
  • Document ( nodeType = 9):匹配文档的根元素。例如,在 HTML 文档中,它是<html>元素。

此外,您可以使用常量来引用不同的 DOM 节点类型(在 IE 版和更高版本中)。例如,你可以只使用document.ELEMENT_NODEdocument.TEXT_NODEdocument.DOCUMENT_NODE,而不必记住 1、3 或 9。因为不断清理 DOM 的空白可能会很麻烦,所以您应该探索其他导航 DOM 结构的方法。

简单的 DOM 导航

使用纯 DOM 导航的原则(在每个可导航的方向上都有指针),您可以开发更适合您导航 HTML DOM 文档的功能。这个特殊的原则反映了这样一个事实,即大多数 web 开发人员只需要在 DOM 元素中导航,很少在兄弟文本节点中导航。为了帮助你,有许多有用的函数可以用来代替标准的previousSiblingnextSiblingfirstChildlastChildparentNode。清单 5-21 显示了一个函数,它返回当前元素之前的元素,如果没有找到之前的元素,则返回 null,类似于previousSibling元素属性。

清单 5-21 。查找与元素相关的前一个兄弟元素的函数

function prev( elem ) {
    do {
        elem = elem.previousSibling;
    } while ( elem && elem.nodeType != 1 );
    return elem;
}

清单 5-22 显示了一个返回当前元素下一个元素的函数,或者如果没有找到下一个元素,返回 null,类似于nextSibling元素属性。

清单 5-22 。查找与某个元素相关的下一个兄弟元素的函数

function next( elem ) {
    do {
        elem = elem.nextSibling;
    } while ( elem && elem.nodeType != 1 );
    return elem;
}

清单 5-23 显示了一个返回元素的第一个元素子元素的函数,类似于firstChild元素属性。

清单 5-23 。查找元素的第一个子元素的函数

function first( elem ) {
    elem = elem.firstChild;
    return elem && elem.nodeType != 1 ?
        next ( elem ) : elem;
}

清单 5-24 显示了一个返回一个元素的最后一个子元素的函数,类似于lastChild元素属性。

清单 5-24 。查找元素的最后一个子元素的函数

function last( elem ) {
    elem = elem.lastChild;
    return elem && elem.nodeType != 1 ?
        prev ( elem ) : elem;
}

清单 5-25 显示了一个返回元素父元素的函数,类似于parentNode元素属性。您可以选择提供一个数字来一次向上导航多个父代—例如,parent(elem,2)相当于parent(parent(elem))

清单 5-25 。查找元素父元素的函数

function parent( elem, num ) {
    num = num || 1;
    forvar i = 0; i < num; i++ )
        if ( elem != null ) elem = elem.parentNode;
    return elem;
}

使用这些新函数,您可以快速浏览 DOM 文档,而不必担心每个元素之间的文本。例如,如前所述,要查找<h1>元素旁边的元素,您现在可以执行以下操作:

// Find the Element next to the <h1> Element
next( first( document.body ) )

您应该注意这段代码中的两件事。第一,有了新的参照:document.body。所有现代浏览器都在 HTML DOM 文档的 body 参数中提供了对<body>元素的引用。你可以用它来使你的代码更短,更容易理解。您可能注意到的另一件事是,函数的编写方式非常违反直觉。通常,当您想到导航时,您可能会说,“从<body>元素开始,获取第一个元素,然后获取下一个元素”,但是就其物理书写方式而言,这似乎有些落后。

摘要

在这一章中,我们讨论了很多关于 DOM 是什么以及它是如何构造的。我们还讨论了节点之间的关系、节点类型以及如何使用 JavaScript 访问元素。当我们可以访问这些元素时,我们可以通过使用element.get/setAttribute()来改变它们的属性。我们还讨论了在 DOM 中创建和添加新节点、处理空白以及在 DOM 中导航。在下一章,我们将讨论 JavaScript 事件。