JavaScript-模块化编程-全-

JavaScript 模块化编程(全)

原文:zh.annas-archive.org/md5/ce315ee42d61a540fb3e53220ccf276c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

目前市场上有很多 JavaScript 书籍,其中一些非常好。然而,大多数书籍都侧重于语言本身的各个方面或使用某些框架来构建 JavaScript 应用程序。在这本书中,我们将采取不同的方法,探讨基于模块创建 JavaScript 应用程序的架构设计,而不需要第三方框架。

创建一个好的应用程序不仅仅是编写好的代码,还涉及到应用程序的不同部分是如何工作以及它们之间如何相互交互的。一个好的应用程序的另一个特点是它能够轻松地进行维护、扩展和按需扩展。适当的模块化设计使我们能够无缝地实现我们应用程序中的所有这些目标。

在本书的开头,我将向你介绍 JavaScript 模块的基础以及良好模块化设计背后的概念。利用这些概念,我们将一步一步地一起构建应用程序,这样我们就可以将所学应用到实践中。我建议按照章节的顺序阅读,这样你可以轻松地跟随,并观察我们的设计是如何随着时间的推移而演变的。

我非常鼓励你和我一起开发应用程序的各个部分,因为这本书的目的是非常实用的,我希望你能够感受到自己是这个应用程序开发团队的一员。

正如现实世界一样,在开发阶段,我们将对代码库进行几次重构,以便在引入新概念时实施,并提高我们应用程序的质量。

请记住,我们将创建一个客户端应用程序,因此没有涉及服务器端代码。尽管如此,我们将讨论的许多关于模块和模块化架构的概念也可以应用于服务器端应用程序。

此外,鉴于这本书是关于模块化 JavaScript 应用程序设计的入门,我们将一起开发一个概念验证(POC)级的应用程序,这可以为你的项目提供一个良好的起点。

在实现方面,我采取了极简主义的方法,因此我们在开发过程中将使用非常少的第三方库。因此,你不必专注于学习新库以及如何使用它们,而是可以专注于应用程序的架构。一旦我们建立了坚实的基础,我们就可以根据需要将其他库纳入我们的设计中。

为了清晰起见,我避免编写花哨的代码,这样你就不必花太多时间试图理解代码的复杂性,而是关注整体图景以及各个部分是如何搭配和协同工作的。

注意,这本书是为那些对 JavaScript 语言有良好理解但希望了解更多关于 JavaScript 客户端应用程序设计的人准备的。如果您在语言本身上遇到困难,您始终可以参考在线资源和 JavaScript 社区。这个社区由许多聪明和乐于助人的人组成,他们愿意回答您的问题。就我个人而言,我对这个社区在多年来帮助我的职业成长感到非常感激。

我希望您会发现这本书信息丰富,通过看到模块化架构的好处,您将在自己的未来项目中使用本书中提出的概念。

本书涵盖内容

第一章, 什么是模块及其优势?,为您介绍了模块的概念以及它们如何帮助我们设计一个健壮且可扩展的应用程序。

第二章, 重要 JavaScript OOP 概念回顾,概述了 JavaScript 中一些重要的 OOP 概念,这些概念对于设计我们的应用程序中的模块是必要的。

第三章, 模块设计模式,介绍了一种在 JavaScript 中创建模块的非常常见的模式,并展示了如何实现此模式。

第四章, 设计简单模块,使用模块模式创建简单的模块,这些模块协同工作,形成我们应用程序的构建块。

第五章, 模块增强,展示了使用各种技术为我们的模块添加更多功能,以便我们可以进一步扩展它们的能力。

第六章, 克隆、继承和子模块,介绍了如何基于我们应用程序中的其他模块创建模块,以及一些增强我们模块的更多技术。

第七章, 基础、沙盒和核心模块,介绍了我们应用程序的一些主要组件,并演示了如何在我们的应用程序模块之间创建松散耦合。

第八章, 应用程序实现 – 整合所有内容,展示了如何应用我们所学到的关于模块化架构设计的所有概念,以便实现我们应用程序的所有组件。

第九章, 模块化应用程序设计和测试,介绍了如何使用纯 JavaScript 或第三方框架测试我们的应用程序模块。

第十章,企业级模块化设计,AMD,CommonJS 和 ES6 模块,介绍了 JavaScript 中可以用来设计模块的不同模块格式,以及如何使用这些格式在我们的应用程序中导入和导出模块。

您需要为这本书准备的东西

您需要任何现代浏览器(IE 9+、Chrome、Safari、Firefox)以及可以运行上述列表中任何现代浏览器的操作系统。

所需的第三方库将是 jQuery 1.8+。

对于后面的章节,以下是一些第三方库:

  • Jasmin 2+

  • Mocha(最新版本)

  • Chai(最新版本)

  • RequireJS(最新版本)

这本书适合谁

如果您是一位有经验的 JavaScript 开发者,您已经编写过 JavaScript 代码,但可能不是以模块化便携的方式,或者您正在寻找开发企业级 JavaScript 应用程序,那么这本书适合您。

预期您对 JavaScript 概念有基本理解,例如 OOP、原型继承和闭包。

约定

在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"请注意,我已经创建了一个名为MyObjDefinition的函数并调用了它"。

代码块设置如下:

function doAddition(num1, num2){
  return num1 + num2;
}

注意

警告或重要注意事项以如下框中的形式出现。

小贴士

小技巧和窍门如下所示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们非常重要,因为它帮助我们开发出您真正能从中受益的书籍。

要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。

如果您在某个主题领域有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些东西可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持选项卡上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

您也可以通过点击 Packt Publishing 网站书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录到您的 Packt 账户。

文件下载完成后,请确保使用最新版本的软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/ModularProgrammingwithJavaScript。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为 github.com/PacktPublishing/。请查看它们!

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误部分下的现有错误清单中。

要查看之前提交的错误清单,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。

盗版

互联网上对版权材料的盗版是一个持续存在的问题,所有媒体都存在。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您在这本书的任何方面遇到问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章:模块及其优势是什么?

在本章的第一部分,我将为你提供有关应用程序开发中模块化设计方法的概述,特别是与 JavaScript 应用程序相关的内容。

我还将提及应用程序架构的模块化风格与这种概念设计的现实生活示例之间的相似之处。

希望在你阅读的过程中,你能够至少与模块化设计方法的一些方面产生共鸣,并开始看到为什么这种组织代码的风格可以极为有益。

本章的主要目标是为你创造一个熟悉的环境,并让你在创建和组织代码时开始以模块化的方式思考。很快,你就会发现这种方法可以自然地发展成为一套定义良好的应用程序架构方法。

我们将本章的开头部分用于简要讨论我们如何根据专业化来组织我们的代码。然后,我们将探讨如何根据它们提供的功能来定义模块。

本章涵盖的主题包括:

  • 创建模块的简单规则

  • 模块的实际生活示例

  • 非模块化示例的审视

  • 以更模块化的方式重构

  • 以模块化方式进行设计

模块化编程的序言

多年前,当我上大学上第一门计算机编程课时,我发现自己在将代码组织成函数和类方面遇到了困难。我总是想知道我需要记住什么样的标准来判断一段代码是否属于一个函数、一个类或一个子类。我应该什么时候将一个函数分解成多个函数,或者将一个类分解成多个类?

当然,我熟悉一些规则和指南,例如“函数不应该太长或不应该做太多事情;类应该是一个数据类型的蓝图”等等。然而,这样的规则和指南对我来说似乎很抽象,我想要找到一个精确且适用于所有情况的规则。

随着我编程概念的深入和应用程序设计经验的增加,我能够编写更复杂的代码,并将我的代码更好地组织到函数和类中。

然而,尽管我的代码被组织成定义良好的函数和类,但这些函数和类在应用程序的不同部分仍然显得分散。当我需要修改应用程序的一部分时,我会担心这种变化会对其他部分以及整个应用程序的功能产生什么影响。

随着我的应用程序变得更大、更复杂,变化和改进的影响变得更加明显。如果应用程序的各个部分没有设计得当,会有更多的事情对应用程序产生不利影响。

基于浏览器的应用程序尤其容易受到这种影响,因为应用程序的不同部分可能会在浏览器中操作同一个元素,这会导致应用程序的其他部分出现意外的行为和效果。

另一方面,对应用程序进行小的改动本身就是一项挑战,因为找到进行这种小改动最佳位置并不总是非常明显。应用程序的每一部分都可能执行许多不同的活动,从操作 DOM 到写入 cookie,再到发起 AJAX 调用。

如果我能让应用程序的某一部分只负责一种功能类型会怎样?如果只有应用程序的一部分负责所有与 cookie 相关的功能会怎样?如果只有一部分会向服务器发起 AJAX 调用并提供其他应用程序部分返回的数据会怎样?

当我们设计函数和类以专门执行非常具体的任务时,我们也可以将这些函数和类捆绑在一起,作为专门的应用程序部分,负责提供特定的功能。关键点在于创建专门的代码包。

这意味着,我们对 cookie 的读写方式的变化只会发生在负责 cookie 操作的包中,这样的变化不会影响对服务器的 AJAX 调用方式。

如果我们将代码组织成专门的包(或者我们称之为模块),我们就可以轻松实现应用程序各个部分之间关注点和责任分离的目标。

但在我们能够将代码组织成模块之前,我们需要了解我们如何决定一块代码应该是一个模块。

创建模块的简单规则

我需要强调的是,模块化编程并不是某种难以掌握且难以实现的神奇和神秘的设计概念和模式。它实际上只是组织我们代码的一种实用方法,使得每一块代码只执行一个非常具体和专业的任务。

想法是,每个模块都是应用程序的一个松散耦合的部分,一个构建块,它与其他部分(和其他模块)一起创建了一个生态系统,即你的应用程序。

因此,这里有一个创建模块的简单规则:“如果你的应用程序的一部分提供了专门的功能,它可以被制作成一个模块,也可以在其他应用程序中重用。”

我之前提到,我在寻找一个“精确”的规则来帮助我组织应用程序代码,但正如我的经验所表明的,除了我上面提到的之外,没有这样的精确规则,而这实际上不是一个规则,而是一个指导方针。作为一个指导方针,在考虑一个模块或不是模块时有一定的灵活性。这最好在设计时间和应用程序演变过程中决定,因为应用程序的需求会随时间变化。

模块的真实生活示例

让我们考虑一个熟悉的模块化系统。你很可能在拥有电力并且周围墙壁上有许多电源插座的地方阅读这本书。这个系统使你能够将各种电器插入插座,而这些设备每一个都是设计来执行一个非常具体的任务的。

考虑插在这些插座中的电器:微波炉、电热水壶、洗衣机、烘干机等等。

这些设备都不在乎它们是否插在你家的电源插座或邻居家的电源插座上。它们被设计成在插入并接通电源时执行它们的具体任务和功能,无论它们在哪个房子里。

我们的应用程序模块应该遵循相同的理念。这意味着,无论它们在应用程序中的哪个位置插入,甚至无论它们插入到哪个应用程序中,它们都应该执行它们的具体任务,并且只执行它们的具体任务。

同样地,就像电器可以很容易地从墙上拔掉一样,代码模块也应该设计成可以轻松解耦并从你的应用程序中移除。

此外,由于移除一个电器不会影响你电系统中其他插上电器的功能,从你的应用程序中移除一个代码模块或一系列代码模块也不应该影响应用程序其他部分的功能。

这种解耦不应该对整个应用程序产生任何影响,除了可能只是失去由该特定模块或模块组在应用程序中提供的特定功能。

在这本书中,我们将探讨创建模块如何帮助我们设计更好的专用代码片段,这些代码片段可以轻松地插入和从我们的应用程序中拔出。我们还将看到模块化架构如何为整体提供更健壮和灵活的应用程序。

我们将发现这种类型的架构方法如何在许多方面为我们的应用程序基础带来巨大的优势,例如代码可用性、可维护性、可测试性以及更多。

我希望现在你已经足够好奇,至少考虑模块化编程,特别是 JavaScript 模块化编程,作为你未来应用程序设计的可能方法。

在接下来的章节中,我们将应用我们讨论过的关于电源插座和电器的相同原则,应用到我们的代码模块的设计和实现阶段。

看一个非模块化示例

让我们考虑一个非常简单的例子,看看这种(某种方式)专业化的模块化方法与非模块化方法有何不同。

我们首先以传统的方式编写几个函数,如下所示:

function doAddition(num1, num2){
  return num1 + num2;
}

function doSubtraction(num1, num2){
  var result = null;
  if(num1 > num2){
  result = num1 - num2;

  }else{
    result = num2 - num1; 
  }
  return result;
}

console.log(doAddition(3,2)); // displays 5

console.log(doSubtraction(3,2)); // displays 1

如您在上面的代码中所见,我们有两个独立的功能用于执行简单的加法和减法,它们之间没有关系,除了它们都操作于传入的两个数字(数值)之外。

如果我们在一个应用程序中实现了这些功能,然后在不同的应用程序中执行相同的操作,我们很可能会要么在该应用程序中从头开始重新编写相同的函数,要么将此应用程序中的代码复制/粘贴到另一个应用程序中。

如果我们现在决定在应用程序中使用相同的方法进行乘法、除法和其他相关计算,那会怎样呢?

好吧,一种方法就是继续像上面那样编写独立的函数,并将它们添加到我们的应用程序中。这种方法可能可行,并且能够完成任务,但可能不是最佳方式,因为随着代码的增长,它将变得更加混乱和杂乱。

通过使用这种方法,我们不仅会将大量可能与其他同名全局函数冲突的全局函数污染全局命名空间。我们最终还会得到一些散乱的代码片段,这些代码片段没有根据其功能和专业性打包在一起。

如果所有这些函数都进行一种或多种数学计算,并且这是它们共有的共性,那么我们是否可以创建一个(模块)来专门进行数学计算呢?

这将使我们能够拥有一个专门的包,无论它托管在哪个应用程序中,都会始终提供相同的专业功能。

让我们更进一步,假设我们在一个单独的 JavaScript 文件中创建了此包,该文件可以作为独立模块添加到任何应用程序中。

更好一点,如果这个模块只会在运行时添加(在客户端 Web 应用程序的情况下,从服务器请求)到应用程序中,并且只有在需要时才添加,那会怎样?

这种类型的实现将使我们能够在运行时根据需要加载代码的块、部分或模块,然后在应用程序不再需要它们时卸载它们。这将使我们能够减少应用程序在客户端的占用空间,同时按需提供所有必要的功能。

这种方法在带宽和资源有限的移动设备上也非常有用。

请放心,我确实打算在本书的后续章节中与您探讨所有这些可能性。

重构为更模块化的方法

让我们考虑重构之前查看的两个函数,并将它们组合在一个更专业的包(类或模块)中,称为CalculationHandler,如下所示:

function CalculationHandler(){
  CalculationHandler.result = null;
}

CalculationHandler.doAddition = function(num1, num2){
  return num1 + num2;
};

CalculationHandler.doSubtraction = function(num1, num2){

  if(num1 > num2){
    CalculationHandler.result = num1 - num2;

  }else{
    CalculationHandler.result = num2 - num1; 
  }
  return CalculationHandler.result;

};

console.log(CalculationHandler.doAddition(3,2)); // displays 5
console.log(CalculationHandler.doSubtraction(3,2)); // displays 1

正如你在“模块”中可以看到的(我在这里使用这个术语比较宽松;你将在后面的章节中看到原因),我正在使用一个函数对象,并向这个对象添加属性(方法)。这些方法执行与对象(模块)的整体功能相关的特定任务,例如加法和减法。

注意

关于我们模块的注意事项

如果你更熟悉 JavaScript 编程,你可能认为我创建这个模块的方式可能不是在 JavaScript 中创建真实模块的最佳方式,你是对的!但就目前而言,这里的主要思想是,任何执行特定任务的代码片段都可以被标记为模块,在大多数情况下。

然而,在 JavaScript 中编写更健壮和可扩展的模块肯定有更好的方法。例如,通过使用模块设计模式(Module Design Pattern)来创建模块可以做得更好,我们将在本书的后续章节中深入探讨这一点。

以模块化的方式进行设计

在设计应用的早期阶段,最重要的步骤之一是确定应用需要提供的功能。这当然基于应用的整体目的和你要设计的应用类型。

根据这样的要求,在设计阶段,你应该尝试将应用的整体功能(大局)分解成更小、更专业的部分。然后,你可以确定这些部分是否已经存在,无论是以第三方库的形式还是以你为其他应用编写的代码的形式。

如果你已经以模块化的方式设计了自己的可重用代码块(大多数第三方库也是这样设计的),那么将这些部分连接起来并在新应用中使用它们将会容易得多,也快得多。这就像把各种乐高积木组合起来创建一个游乐结构一样。

这种方法非常重要,并且非常适合敏捷开发环境。这使你能够在需要时以及当新的应用需求被定义时,工作在定义良好、专业的模块上。此外,当你基于模块创建代码时,你能够防止应用各个部分之间出现紧密耦合。

另一方面,这种方法允许不同的开发者独立地工作在相同应用的各个部分(模块)上。另一个优点是,模块可以在被添加到应用之前单独并在不同的环境中进行测试。

随着时间的推移和更多在模块化应用设计和实现方面的经验,你将更好地决定如何区分和设计你的模块。然而,认为你可以在第一次尝试中就提出应用中可能需要的所有模块的完整列表是不现实的。

这是因为应用程序会发展,需求会随时间变化。你可能需要创建新的模块,或修改现有的模块,或者决定使用不同的模块或库来适应需求的变化。

模块化设计的关键优势是它提供的灵活性。在模块化架构中处理上述所有情况要容易得多,并且需要付出更少的努力。它还将减轻添加、删除或修改模块可能对整个应用程序产生的影响。

在接下来的章节中,你将看到我们如何创建简单和复杂的模块,以及它们将以松散耦合的方式添加到我们的应用程序中。

你还将看到我们如何在需要时动态和按需加载这些模块到我们的应用程序中。

因此,让我们为进入未来应用程序设计的激动人心的旅程做好准备,使用模块化架构。

摘要

在本章中,我们试图概述模块化编程背后的概念,以及这些概念如何在 JavaScript 应用程序中特别使用。

我们看到,这种方法本质上基于创建执行非常特定任务的专用代码包。

我们还比较了现实生活中模块的设计方式以及我们的应用程序模块,以便将相似之处转化为我们自己的应用程序设计方法。

虽然术语“模块”可以在代码中指代不同的事物,但我们将在后续章节中将这个术语用于指代我们 JavaScript 应用程序设计方法中的一种编程和架构风格。

然而,在我们完全深入 JavaScript 模块化编程的更技术方面之前,回顾下一章中 JavaScript 面向对象编程的基础知识是个好主意。这将为我们前进过程中更技术性的章节建立一个坚实的基础。

第二章. JavaScript 重要 OOP 概念回顾

在我们开始创建和使用 JavaScript 中的模块之前,了解 JavaScript 中重要的 面向对象编程OOP)概念非常重要。我们将依赖这些概念来设计和实现我们后续章节中的应用程序模块。

虽然我在这章中的意图不是深入探讨这些概念,但我将尝试提供一个关于一些最重要相关主题的良好概述。

如果你觉得自己对 JavaScript 中的这些概念非常熟悉,你可以跳过这一章,直接进入下一章。

然而,如果你对 JavaScript 中的面向对象编程(OOP)不是很熟悉,即使你对经典面向对象语言中的类似概念很熟悉,我也鼓励你继续阅读,因为 JavaScript 中的情况略有不同。我认为你查看这些概念在 JavaScript 领域中会很有价值。

在本章中,我们将涵盖:

  • JavaScript 对象及其构造函数

  • this 关键字是什么以及它在不同上下文中的行为

  • 闭包及其用途

  • 在 JavaScript 中,继承

  • 原型链

以及其他相关主题。

JavaScript 对象

如果你以前编程过 JavaScript(正如我确信的那样,因为这本书是为中级到高级 JavaScript 程序员准备的),你肯定使用过对象,即使你没有意识到它们的内部工作原理。

如果你熟悉其他更经典的面向对象语言(如 C++、C# 或 Java),你可能会惊讶地发现,在 JavaScript 中没有正式的语法来定义类(至少直到 ECMAScript 6)。我想你会更加惊讶地发现,在没有正式语法的情况下,你仍然能够在 JavaScript 中创建类,并充分利用面向对象设计和架构的全部力量(甚至有人可能会争论这是一种更灵活的方式)。

注意

JavaScript 中的正式类定义

在 JavaScript 的新版本(ES6)中,新的语法允许你以更正式和结构化的方式定义类。

更多信息请访问以下链接:

es6-features.org/

你可以用许多不同的方式在 JavaScript 中创建对象,但我们将在本章中关注三种创建对象的方法。这些方法包括:

  • 使用原生对象类型构造函数

  • 使用对象字面量表示法

  • 使用构造函数

对象类型构造函数

在 JavaScript 中创建对象可能最简单的方法是创建一个 Object 类型的实例,然后添加我们需要的属性。

如你所知,Object 类型是 JavaScript 中的顶级对象(根对象),所有其他对象都是在幕后从这个类型创建的。这个对象具有许多默认为其定义的属性。基于此类型创建的每个对象都将继承这些默认属性,如 toStringvalueOfhasOwnProperty 等。

看看以下语法,它展示了使用 Object 构造函数创建对象并添加自定义属性的过程:

var myObj = new Object();

  myObj.value = "my first value";
  myObj.method = function(){
  return this.value;
};

console.log(myObj.method()); // displays "my first value"

这种创建对象的语法一度非常流行。然而,它不再被广泛使用,因为使用其他创建对象的方法可以更好地了解对象属性是如何打包的。此外,使用对象构造函数并不那么优化,因为解释器需要执行作用域解析来确定是否存在具有相同名称的本地构造函数,以便正确创建作用域链。

注意

关于作用域链

作用域链 是一个对象链,当查找属性的存在和值时,会查找这些对象的属性。

更多信息请访问以下链接:

blogs.msdn.microsoft.com/jscript/2007/07/26/scope-chain-of-jscript-fu

对象字面量表示法

使用 对象字面量表示法 创建对象可以很好地了解对象及其所有属性的概念视图。

这种方法也非常流行,用于将多个参数传递给函数,而不是一次传递一个参数。这种传递参数的方法允许我们将所有参数整齐地打包到一个对象中(通常是一个匿名对象),并将其作为一个参数传递给函数。

使用这种语法,JavaScript 中的对象可以像下面这样简单地创建:

var obj = {};

当然,这个对象没有任何功能,完全无用,但无论如何,它是一个有效的对象(单例)。

让我们使用这种方法创建另一个对象,如下所示:

var MyFirstObj = {
  myFirstValue : 2,
  mySecondValue : 5,

  addValues: function(){
    return this.myFirstValue + this.mySecondValue ;
  }
};

在上面的代码中,我们创建了一个初始化为对象的变量。我们这里的对象由两个值属性和一个简单的方法(方法属性)组成,用于添加这些值属性。

要与前面代码中的对象交互,我们可以使用持有(引用)该对象的变量 MyfirstObj,并使用点符号访问其成员,如下所示:

console.log( MyFirstObj.addValues()); // displays 7

函数对象

在 JavaScript 中,函数被视为一等对象。实际上,每次你处理函数时,你都是在处理一个对象。

在 JavaScript 中,我们并不总是将函数作为对象使用,但当我们将其作为对象使用时,我们可以将它们用作构造函数来创建其他对象。

函数作为对象构造函数(类)

让我们模仿上一节中用对象字面量编写的对象定义,并创建一个构造函数以实现相同的功能。考虑以下内容:

function MyObjDefinition(){
  var myFirstValue = 2;
  var mySecondValue = 5;

  this.addValues = function(){
    return myFirstValue + mySecondValue;
  };
}

var myFirstObj = new MyObjDefinition();

console.log( myFirstObj.addValues()); // displays 7 

注意,我已经创建了一个名为 MyObjDefinition 的函数,并这样命名是为了表明这个函数将被用作定义(类)来创建其他对象。

使用 new 关键字,我们可以创建这个对象的实例,并将其分配给 myFirstObj 变量。

如果您不习惯使用函数对象作为构造函数,那么此时您可能会想知道 MyObjDefinition 是一个函数、一个类还是一个对象?

嗯,MyObjDefinition 就是所有这些!我很快就会对此进行更深入的讲解。

函数作为静态对象

让我们修改前面的代码,以便我们可以看到 MyObjDefinition 如何更好地被视为一个对象。

// defining an object
function MyObjDefinition(){
  MyObjDefinition.myFirstValue = 2;
  MyObjDefinition.mySecondValue = 5; 
}

// adding a property to the object
MyObjDefinition.addValues = function(){

  return this.myFirstValue + this.mySecondValue;
};

// initializing the object by calling it as a function
MyObjDefinition();

如您所见,我们已经将内部的 addValues 方法移动到对象定义的外部,并使用点符号将其添加到 MyObjDefinition 对象作为属性。

我们可以使用这种语法的原因是,JavaScript 将 MyObjDefinition 函数视为既是函数又是对象。由于我们可以在 JavaScript 中动态地为对象分配属性,因此我们可以使用点符号将 addValues 函数作为属性分配给这个对象。

注意,我们像调用常规函数一样调用了我们的对象定义,以初始化 myFirstValuemySecondValue 属性的默认值。当然,我们可以这样做,因为 MyObjDefinition 也是一个函数。

我们还改变了函数定义内部的 myFirstValuemySecondValue 变量,分别改为 MyObjDefinition.myFirstValueMyObjDefinition.mySecondValue。这样 MyObjDefinition.addValues 就可以从函数定义外部访问它们。如果您不确定 this 关键字是什么,请不要担心,我们很快就会讨论它。

要添加两个值,我们仍然可以使用之前的相同语法:

console.log( MyObjDefinition.addValues()); // displays 7

注意,我们现在不能像之前那样使用 MyObjDefinition 作为构造函数,因为它现在正在作为一个静态对象运行。所以下面的代码会产生错误:

var anotherObj = new MyObjDefinition();
anotherObj.addValues(); // error

在这种情况下,当我们使用 new 关键字创建对象时,创建的对象并没有 MyObjDefinition 函数的属性。

这个重构代码的练习展示了在 JavaScript 中,函数可以根据使用情况同时作为函数和对象。

我喜欢 JavaScript 提供的灵活性,它允许我们定义对象,以及根据需要动态地向对象添加属性。然而,我明白为什么一些有更经典面向对象语言背景的程序员可能会觉得这种方法有点令人困惑。

我对这个问题的看法是,JavaScript 有自己的领域,最好在其领域内理解它,而不是试图将其放在经典面向对象语言的背景下看待。

对象字面量表示法与函数对象

虽然对象字面量表示法和函数对象语法都可以用来创建对象,但在某些情况下,一种方法比另一种方法更适合。

在 JavaScript 中,在脚本解析之后,程序中的所有函数声明都会提升到脚本的开头。这就是为什么你可以在代码中调用一个在定义之前出现的函数,如下面的例子所示:

var firstPerson = CreatePerson("Tom", "Software Developer");

function CreatePerson(personName, personJob){
  // creating an object instance, using object type
  var person = new Object();
  // we can also use an object literal instead as below
  // var person = {};
  person.name = personName;
  person.job = personJob;

  return person;
}

console.log(firstPerson.name); // displays "Tom" 

在前面的代码中,每次调用CreatePerson函数时,都会创建一个新的对象,对其进行增强,然后返回。这种方法也被称为对象工厂设计模式。

注意

JavaScript 中的设计模式

如果你不太熟悉设计模式,或者想更熟悉 JavaScript 中的设计模式,我强烈推荐以下资源:

精通 JavaScript 设计模式西蒙·蒂姆斯

注意到CreatePerson函数的调用是在实际函数定义之前发生的。由于函数提升,这段代码在运行时不会产生错误。在幕后,CreatePerson函数已经被提升到脚本的顶部,所以当调用这个函数时,解释器已经遇到了这个函数的声明。

由于这个机制,你不必担心函数调用是在函数声明之前还是之后发生。

这不适用于对象字面量,因为没有函数声明,因此没有提升。因此,所有对这些对象的调用都需要在对象定义之后发生。

考虑以下示例:

var Tom = {
  name: "Tom"
};
Tom.job = "SoftWare Developer";

console.log(Tom.job); // displays "Software Developer"
console.log(Tom.name); //  displays "Tom" 

在这里,我们首先使用对象字面量表示法定义一个对象,然后向它添加一个属性。一切正常,符合预期。

然而,如果我们试图在对象定义之前向这个对象添加一个属性,如下所示:

Tom.job = "SoftWare Developer"; // "TypeError: Cannot set property 'job' of undefined"

var Tom = {
  name: "Tom"
};

我们将遇到一个错误。

你可能也注意到了,在构造函数的情况下,例如我们例子中的Person函数,我们可以向构造函数传递参数,并使用不同的名字和不同的职位创建不同的Person实例。然而,当使用对象字面量表示法创建对象时,这是无法做到的。

如你现在所知,创建对象的这些不同方法适用于不同的目的。大多数时候,当我们需要创建多个对象实例时,我们使用构造函数,而当需要将参数(数据)打包传递到应用程序的不同部分时,我们使用对象字面量表示法。

使用对象字面量表示法创建的对象也最适合创建模块化代码,我们将在接下来的章节中看到很多。

让我们使事情变得更有趣一些,并重构前面的代码,这样我们就可以同时使用两种创建对象的方法。

看看以下内容:

function CreatePerson(personData){
  var person = {}; // using Object literal
  person.name = personData.Name;
  person.job = personData.Job;

  return person;
}

var personData = {
  Name: "Tom",
  Job: "Software Developer"
};

var firstPerson = new CreatePerson(personData); 

console.log(firstPerson.name); // displays "Tom"

在这里,我们使用对象字面量表示法创建了一个personData数据对象,然后将其作为包传递给CreatePerson构造函数以创建我们的firstPerson实例。

在接下来的章节中,我们将使用这两种对象创建方法来创建我们的模块。

"this"关键字

当我们在本章中查看静态对象时,我们看到了使用this关键字的一个示例。现在,我们将花一些时间来探讨this,了解它是什么以及它如何帮助我们编写更好的代码。

this关键字只是一个对对象的引用。然而,这个引用可以在不同的时间指向不同的对象,这取决于代码的执行上下文。

为了了解这意味着什么,让我们首先创建一个简单的构造函数:

function Person(){
  this.name = "Tom";
  this.job = "Software Developer";
}

在前面的代码中,this关键字具有全局上下文。因此,在Person函数内部,this指的是window对象(如果代码在浏览器中运行)。实际上,如果我们执行以下操作:

Person();

我们现在为window对象创建了两个属性,分别命名为namejob。我们可以通过运行以下代码来证明这一点:

console.log(window.name); // displays "Tom";
console.log(window.job); // displays "Software Developer";

请记住,向全局上下文(window对象)添加属性不是一个好主意,这会污染全局上下文,并增加命名冲突的可能性。这可能导致代码中出现意外的行为和难以追踪的奇怪错误。

但当用作以下情况时,this关键字可以接受一个全新的上下文,并引用一个完全不同的对象:

function CreatePerson(personData){
  this.name = personData.Name;
  this.job = personData.Job;
}

var personOneData = {
  Name: "Tom",
  Job: "Software Developer"
};

var firstPerson = new CreatePerson(personOneData);

console.log(firstPerson.name);// displays "Tom"
console.log(firstPerson.job); // displays "Software Developer" 

在这里,我们正在使用我们的CreatePerson构造函数创建一个新的Person对象。通过使用new关键字,创建了一个Person实例,变量firstPerson现在持有对这个实例的引用。这个人的实例name属性设置为Tomjob属性设置为Software Developer

注意,在这个对象内部,this关键字现在指的是这个实例。

让我们创建另一个Person对象定义,如下所示:

var personTwoData = {
  Name: "John",
  Job: "Software Architect"
};

并使用我们的构造函数来创建第二个人的实例。

一旦执行以下代码,this将指向第二个人的实例。

var secondPerson = new CreatePerson(personTwoData); 

我们可以检查第二个人的属性,如下所示:

console.log(secondPerson.name); // displays "John";
console.log(secondPerson.job); // displays "Software Architect"  

有时事情会变得更有趣,也更难弄清楚在不同情况下this的上下文是什么。

考虑以下示例:

var name = "The window global";

var myOwnObject = {

  name: "my Own Object",

  getName: function(){
    return this.name; 
  }
};

正如您在这个示例中看到的,我们使用对象字面量表示法定义了一个对象。这个对象被分配给myOwnObject变量,它的getName方法返回对象中name属性的值。因此,正如您可能预期的,在这个上下文中this指的是myOwnObject的上下文:

console.log(myOwnObject.getName()); // displays "my Own Object" 

然而,如果我们进行如下赋值操作:

// displays "The Window global"
console.log((myOwnObject.getName = myOwnObject.getName)()); 

这将产生以下结果:全局窗口。这个结果相当令人困惑。

当我们进行上述赋值时,只有函数从表达式的左侧被赋值到右侧,现在 this 指的是全局对象。请注意,在这种情况下,myOwnObject.getName 只是一个函数,如前所述,函数内的 this(不是对象实例)始终指向全局上下文,当在浏览器中执行时,产生结果,The window global

让我们考虑另一个对象定义,并在该对象内部创建一个内部函数(一个闭包,我们很快就会讨论),在这个场景中,this 的上下文可能不是您所期望的。

var name = "The window global";
var myOwnObject = {

  name: "my Own Object",
  getName: function(){
    return function(){
      return this.name; 
    };
  }
};

console.log(myOwnObject.getName()()); // displays "The window global"

由于最内层的函数是另一个匿名函数内部的匿名函数,因此最内层函数的上下文与宿主它的对象不同。因此,在这个上下文中,this 指的是全局上下文。

为了保留 myOwnObject 的上下文,我们可以在第一个内部函数中创建一个上下文,并让最内层函数访问这个上下文。因此,我们可以将我们的对象定义重写为:

var name = "The window global";
var myOwnObject = {

  name: "My Own Object",

  getName: function(){
    var that = this;
    return function(){
      return that.name; 
    };
  }
};

console.log(myOwnObject.getName()()); // displays "My Own Object"

使用这种方法,我们在第一个内部函数中创建了一个上下文,该上下文引用了我们的对象,然后最内层函数可以访问这个上下文,该上下文定义在其容器(第一个匿名函数)中。这导致最内层的匿名函数访问 myOwnObject 对象的上下文。

如所示,有时确定 this 所指的上下文可能有点挑战性,但通过实践和更多经验,您将变得更好。然而,在此之前,不要假设 this 的上下文,并确保您认为 this 所指的上下文确实是代码中为 this 设置的上下文。

闭包

既然我们已经简要讨论了与 this 相关的执行上下文,现在是时候谈谈闭包了。如果您在 JavaScript 方面没有太多经验,或者如果您来自更传统的面向对象语言,如 C++,您可能会觉得闭包的概念一开始有点难以理解。在本节中,我将尝试揭开这个概念的神秘面纱,并解释为什么闭包在我们的代码中非常有用。

闭包背后的主要思想是保留上下文,以及(主要是)内部函数如何保持其包含父级的上下文。

考虑以下简单的例子:

function setTestValue(value){

  var firstNum = value || 2;

  return function(secondNum){
    if(firstNum > secondNum){
      return firstNum;
    }else if(firstNum < secondNum){
      return secondNum;
    }else{
      return "=";
    } 
  };
}

var theNumberExaminer = setTestValue(6);
var result = theNumberExaminer(2); 
console.log(result); // displays 6

如您所见,最内层的函数 setTestValue 接收一个数字作为参数,并将其设置为稍后用于比较的起始值。

当这个函数执行时,它还返回一个匿名函数,其引用将被存储在 theNumberExaminer 变量中。然后,这个匿名函数被用来将其传递的值与在 setTestValue 函数中设置的起始值(firstNum)进行比较。

注意,我们只向 theNumberExaminer 传递了一个值 (2),以与起始数字进行比较。

问题是:theNumberExaminer 是如何访问传递给 setTestValue 函数的先前值的?

通常,当一个函数返回时,其执行上下文会被移除,因此与该函数执行上下文相关的所有值都会被销毁。然而,与闭包不同,情况略有不同。

在这里,内部匿名函数从 setTestValue 函数调用(setTestValue(6))中返回,以及其父函数的执行上下文。这使得匿名函数可以访问 firstNum 的值。

只要内部函数(匿名函数)没有被销毁,这种关系就会保持。父对象的作用域仍然保留在内存中,因为仍然有对内部函数的引用。

最重要的是,闭包允许内部函数作为其作用域链的一部分访问其父函数(对象)的执行上下文。当外部函数作用域中的值发生变化时,内部函数可以访问最新的值。

创建和使用闭包的一个优点可以在以下示例中展示:

function myClosedObject (){
  var privateValue = 5;

  function privateFunc (){
    privateValue *= 2;
    return privateValue ;
  }

  // privileged method 
  this.publicFunc = function(){

    return privateFunc();
  };
} 

var firstObj = new myClosedObject ();
console.log(firstObj.publicFunc()); // displays 10
console.log(firstObj.publicFunc()); // displays 20

在前面的代码中,我们首先创建了一个 myClosedObject 的实例,然后执行了这个实例的 publicFunc。这次调用将 privateValue 的值从 5 改变为 10

当我们再次调用这个方法时,privateValue 的值将变为 20。这是因为第一次调用 publicFunc 后,privateValue 的值已经被保留(多亏了创建的闭包)。第二次调用 publicFunc 使用 privateValue 的最新值(即 10)来进行计算,因此返回的值是 20

封装和作用域

如你所知,在 JavaScript 中,封装的概念与大多数经典面向对象语言的处理方式略有不同,因为我们实际上并没有为类提供一个正式的定义(ECMAScript 6 引入了正式的类定义)。

当我们在函数内部使用关键字 var 创建变量时,我们在这个函数内部创建了一个私有变量,因此变量的作用域被限制在函数内部。这也意味着,如果我们将函数用作构造函数,这些变量不会被复制到使用此构造函数创建的实例中。

此外,JavaScript 没有块作用域的概念;相反,它有函数作用域,因此函数内部声明的所有变量在整个函数块中都是可访问的(ECMAScript 6 引入了块作用域)。

让我们考虑以下函数声明:

function simpleFunc (){
  var firstValue = 1;
  var secondValue = 2;
  this.instanceValue = 100;

  for(var i =0; i<50; i++){
    var thirdValue = firstValue + secondValue + i;
  }

  // displays "The final value of thirdValue is:52" 
  console.log("The final value of thirdValue is:" + thirdValue);
}

simpleFunc();

如你所见,thirdValue 变量是在 for 循环块内部定义的,但我们可以在这个 for 循环结束后访问它,因为在 JavaScript 中,变量的作用域绑定到容器函数的作用域,而不是容器块的作用域。

当然,函数外部的代码无法访问这样的变量,如下所示:

console.log(simpleFunc.firstValue); // displays undefined

如预期的那样,上述代码将在控制台产生undefined

我们也无法访问this.instanceValue,因为在函数内部this引用的是窗口对象,如下所示:

console.log(simpleFunc.instanceValue); // displays undefined

如果我们将前面的函数用作构造函数并创建simpleFunc对象的实例会发生什么呢?

考虑以下内容:

var testObj = new simpleFunc();
console.log(testObj.firstValue);   // displays undefined
console.log(testObj.instanceValue); // displays 100 

如你所知,当我们使用this关键字定义一个变量时,它会复制到对象的实例中,因此testObj有一个它的副本,并且我们可以从外部代码中访问它。

然而,如果我们再进一步,在构造函数内部创建一个私有作用域(命名空间),那么构造函数内部的内部函数也将无法访问它。

考虑以下内容:

function simpleFunc(){
  var firstValue = 1;
  (function(){
    var secondValue = 2;
    this.instanceValue = 100;
    console.log(firstValue); // shows 1

  })();

  //console.log(secondValue); //produces an error

}

上述代码展示了在simpleFunc函数内部的一个立即调用的匿名函数(也称为IIFE),尽管这个函数可以访问其包含函数的执行上下文,但包含函数(simpleFunc)无法访问这个内部函数内部的变量和方法。

事实上,我们在simpleFunc函数内部创建了一个私有命名空间,这个命名空间对外界是完全隐藏的。

当我们创建simpleFunc的实例并尝试访问this.instanceValue时,情况也是如此,因为这个变量仅可以从内部匿名函数的作用域内访问。

这在下面展示:

var testObj = new simpleFunc();
console.log(testObj.instanceValue); // displays undefined 

如你所见,虽然 JavaScript 可能没有像经典面向对象语言那样的封装形式,但我们仍然可以创建私有作用域,并在该作用域内定义变量和方法,这些变量和方法从外部代码中不可访问。

在未来章节设计我们的应用程序模块时,我们将多次重新审视这个概念及其用法。

你可能也会问自己,“如果我想要创建一个提供公共方法的构造函数,这样我就可以通过这些公共方法访问构造函数的私有成员呢?”

让我们考虑以下构造函数:

function simpleFunc (){
  var privateValue = 1;
  this.readPrivateValue = function(){

    return privateValue;
  }; 
}

var testObj = new simpleFunc();
console.log(testObj.readPrivateValue());// displays 1

在这个构造函数中,我们创建了一个私有成员privateValue,它从外部世界无法访问。然而,我们创建了一个公共方法this.readPrivateValue,它可以被外部代码访问,并且可以访问这个私有成员的值。

因此,在这里,我们实现了两个目标。首先,我们保护了我们的私有成员,其次,我们仍然通过我们的公共方法提供了对这种私有成员的读取访问。

方法this.readPrivateValue可以被认为是一个特权方法,这意味着这个公共成员可以访问对象的私有成员。

继承

如果你熟悉像 C++、C#或 Java 这样的经典面向对象语言,你将非常熟悉继承的概念。在这些语言中,有两种继承类型:接口继承实现继承

然而,JavaScript 只支持实现继承,因为没有函数签名这个概念,它是接口继承所必需的。

在 JavaScript 中实现继承有多种方式,每种方式都有其优缺点。在本节中,我将介绍几种实现对象之间这种关系的方法,并简要说明每种方法的优缺点。

原型链

让我们先创建两个不同的构造函数,并在它们之间建立继承关系。

考虑以下两个函数对象:

function BaseType (){
  this.baseValue = 2;
}

BaseType.prototype.getBaseValue = function(){
  return this.baseValue;
};

function ChildType (){
  this.childTypeValue = 50;
}
// creating inheritance relationship
ChildType.prototype = new BaseType();

ChildType.prototype.getChildTypeValue = function(){
  return this.childTypeValue;
};

var childInstance = new ChildType();

console.log(childInstance.getBaseValue()); // displays 2
console.log(childInstance.getChildTypeValue()); // displays 50

在前面的代码中,我们创建了两个极其简单的构造函数。正如你所见,我们为它们中的每一个定义了一个简单的属性,这些属性是与每个对象相关的方法。然而,我们把这些简单的方法作为每个函数的 prototype 对象的属性来创建,而不是直接在构造函数上创建它们。

如果你熟悉 prototype 属性,你知道每个函数默认都有这个属性。这个属性的值是一个对象,它与使用构造函数创建的所有实例共享。

prototype 对象上创建方法(属性)而不是在构造函数本身上创建的优点是,通过这样做,所有使用构造函数创建的实例都共享这些方法。因此,这些实例不需要有它们自己的这些属性的副本,从而优化了我们的代码的性能和内存使用。

在前面的代码中,对于 BaseType,我们只是简单地在构造函数的 prototype 对象上增加了一个名为 getBaseValue 的方法,但对于 ChildType,我们做了点不同的事情。

我们首先创建了一个 BaseType 的实例,然后使用以下表达式将其赋值给 ChildType

ChildType.prototype = new BaseType(); 

在上述任务之后,ChildType.prototype 的值变成了 BaseType 的一个实例。这意味着这个 prototype 对象现在可以访问两个属性,baseValuegetBaseValue

最终结果是,ChildType 的实例可以访问两个属性,但不需要创建它们。

当我们运行以下行代码时:

console.log(childInstance.getBaseValue()); //displays 2

childInstance 变量可以使用 getBaseValue 方法返回 baseValue 变量的值。

当然,childInstance 也可以访问它自己的变量 childTypeValue。如果我们运行以下代码,将显示 50

console.log(childInstance.getChildTypeValue()); // displays 50

原型链中的属性查找

让我们更深入地考察 ChildInstance 如何访问 BaseType 的属性。

当我们尝试访问ChildInstance上的属性时,幕后会在这个实例本身上进行搜索,以查看该属性是否可用。如果属性未找到,则搜索将继续到ChildInstance对象的prototype对象。由于BaseType的实例是ChildInstance所属的prototype对象的值,搜索将继续在BaseType中进行。

但还有更多,BaseType的实例(它是ChildType所属的prototype对象的值)本身也有一个prototype对象,我们的ChildType原型与这个prototype对象有链接。这个原型对象有一个名为getBaseValue的属性。由于这个方法可以访问BaseType属性,它可以返回baseValue的值。

你可以想象这个查找是如何进行的,如下所示:

原型链中的属性查找

如你所见,为了找到实例属性,进行了相当多的搜索。此外,请记住,如果属性在BaseTypeprototype中未找到,搜索将继续到 JavaScript 中所有对象的父类的prototype对象,即Object类型。让我们谈谈它是如何工作的。

记住,每个函数都有一个prototype属性,其值是一个对象。这个prototype对象本身也有一个prototype属性,其值是Object类型的prototype对象。

正因如此,当我们调用BaseType.toString方法时,即使我们没有在BaseType或其prototype对象上定义这个方法,调用仍然成功并产生对象的字符串值。toString方法是在Object类型的prototype对象上定义的,因此对所有Object类型的子类都是可用的。

非常重要的是要记住,一旦找到正在搜索的属性,搜索就会停止,不会在prototype链中继续进行。

为了让它更清晰,让我们将我们的ChildType修改为具有一个名为getBaseValue的属性(方法)。这样做会导致所谓的遮蔽(或掩盖)在BaseType上的这个属性。

因此,如果我们修改ChildType的代码如下:

function ChildType (){
  this.childTypeValue = 50;
}
// creating inheritance relationship
ChildType.prototype = new BaseType();

ChildType.prototype.getChildTypeValue = function(){
  return this.childTypeValue;
};
ChildType.prototype.getBaseValue = function(){
  return this.childTypeValue;
};
var childInstance = new ChildType();
console.log(childInstance.getBaseValue()); // displays 50 

调用childInstance.getBaseValue现在将返回值50而不是2。这是因为一旦搜索到getBaseValue方法(属性)在ChildType上,它将不再继续搜索并执行此方法。当然,这将返回childTypeValue属性的值。

我们还需要注意的另一件事是,由于所有这些引用类型之间建立的关系,以下实例检查将返回true

console.log(childInstance instanceof Object);    // displays true
console.log(childInstance instanceof BaseType);  // displays true
console.log(childInstance instanceof ChildType); // displays true 

你可以使用这个测试来查看一个引用类型是否从另一个引用类型继承属性。

虽然prototype链有许多优点,并允许我们在基对象和子对象之间创建继承,但它有一个缺点,即任何对基对象引用类型属性的更改都会反映在子类的所有实例中。有时这可能不是期望的效果,你需要对此有所了解。

重置构造函数属性

每个prototype对象都有一个constructor属性。这个属性始终指向构造函数本身。当我们像之前代码中那样完全覆盖prototype属性时,如下所示:

ChildType.prototype = new BaseType();

prototype对象的constructor属性将引用父对象。这可以通过以下方式进行检查:

console.log(childInstance.constructor);

这将显示:

function BaseType(){
  this.baseValue = 2;
}

prototype对象被完全替换后,重置其constructor属性总是一个好主意,如下所示:

ChildType.prototype.constructor = ChildType;

这样prototype对象才能正确地指向正确的构造函数对象。

重置使我们能够正确地找到实例的构造函数对象,如下所示:

console.log(childInstance.constructor);

现在正确地报告以下内容:

function ChildType(){
  this.childTypeValue = 50;
}

我们将在本章后面进一步讨论constructor属性。

构造函数窃取

在 JavaScript 中创建继承的另一种方法是使用称为构造函数窃取的技术,这与其他面向对象语言中的经典继承类似。

考虑以下内容:

function BaseType(){
  this.baseValue = 2;
}
function ChildType(){
  BaseType.call(this);
}
ChildType.prototype.getBaseTypeValue = function(){
  return this.baseValue;
};
var instanceObj = new ChildType();

console.log(instanceObj.getBaseTypeValue());  // displays 2

在上面的代码中,我们使用call方法在ChildType的上下文中执行BaseType。这导致ChildType实例获得BaseType的所有属性副本。由于ChildType的每个实例现在都有自己的属性副本,因此修改BaseType的属性不会反映在子实例中。

然而,这种方法有其自身的问题。主要问题是由于我们没有将BaseType的实例分配给ChildTypeprototype对象,因此定义在BaseTypeprototype对象上的属性在ChildType的实例之间不会共享。这将导致在实现继承时不是最有效的方法,并且不允许在子实例之间共享父prototype对象的属性代码。

寄生组合继承

创建引用类型之间继承关系的另一种方法是使用我们已讨论的技术组合,同时消除其低效之处。这也是我创建继承的最喜欢的技术。

让我们修改我们之前看到的代码,以便我们可以实现这种方法:

function BaseType (){

  this.baseValue = 2;
  this.secondBaseValue = 99;
}

BaseType.prototype.getBaseValue = function(){
  return this.baseValue;
};

function ChildType (){
  BaseType.call(this);
  this.childTypeValue = 50;
}

// creating inheritance relationship
ChildType.prototype = BaseType.prototype;

ChildType.prototype.getChildTypeValue = function(){
  return this.childTypeValue;
};

var childInstance1 = new ChildType();
var childInstance2 = new ChildType();
childInstance1.baseValue = 100;
childInstance2.baseValue = 55;

console.log(childInstance1.getBaseValue()); //displays 100 
console.log(childInstance1.getChildTypeValue()); //displays 50
console.log(childInstance1.secondBaseValue); //displays 99

console.log(childInstance2.getBaseValue()); //displays 55 
console.log(childInstance2.getChildTypeValue()); //displays 50
console.log(childInstance2.secondBaseValue); // displays 99 

在这个最新的实现中,我们在ChildType构造函数中使用call方法来复制BaseType的所有属性。这为子对象实例提供了修改BaseType属性的能力,这只会影响特定的ChildType实例,并且不会反映在其他ChildType实例中。

在我们的例子中,这种继承类型是在ChildType构造函数中的以下语句中启动的:

BaseType.call(this); 

我们还像下面这样将BaseTypeprototype分配给了ChildType

ChildType.prototype = BaseType.prototype;

注意,我们只将BaseTypeprototype对象分配给了ChildTypeprototype对象,从而消除了对BaseType构造函数的第二次调用,这导致了更高效的代码。

这种方法还有优点,即允许我们在所有子对象实例之间共享在BaseTypeprototype对象上定义的所有属性。因此,并不是每个子对象实例都会有这样的属性副本,这反过来又导致了更好的代码效率和内存管理。

一旦我们创建了ChildType的实例,这个实例就可以访问从BaseType复制的属性以及定义在BaseTypeprototype对象上的所有共享属性。当然,每个实例也可以访问定义在ChildType构造函数和ChildType原型对象上的所有自己的属性。

寄生组合继承提供了之前讨论过的两种技术(原型链构造函数窃取)的优点,并且被许多经验丰富的 JavaScript 开发者广泛使用。

构造函数属性

JavaScript 中的每个对象都有一个constructor属性,该属性引用了用于创建该对象实例的构造函数对象。

例如,在所有函数中,constructor属性都引用了Function类型构造函数。我们可以通过执行以下语句来验证这一点:

console.log(ChildType.constructor); // references Function type constructor 

由于prototype属性的值也是一个对象,它也有一个constructor属性。然而,这个constructor属性引用的是对象(函数)本身。所以如果我们在这个属性之前BaseType.prototype对象赋值给ChildType.prototype,如下所示:

console.log(ChildType.prototype.constructor); // 
  references ChildType 

我们可以看到这个属性引用的是prototype所属的对象,在这种情况下是ChildType

如前所述,如果我们完全替换prototype对象,就像在实现我们之前的继承时使用的以下语句那样,我们可以:

ChildType.prototype = BaseType.prototype; 

我们覆盖了Childtypeprototype对象上constructor属性的值。

如果我们现在检查prototype对象的constructor属性,它将引用BaseType,而不是Childtype

console.log(ChildType.prototype.constructor) // references BaseType 

如前所述,我们需要重置prototype对象的constructor属性,如下所示:

ChildType.prototype.constructor = ChildType; 

请记住,重置constructor属性需要在重写prototype对象之后进行,而不是之前。否则,重写将完全删除constructor属性。

以下是如何实现这一点的示例:

ChildType.prototype = BaseType.prototype;
ChildType.prototype.constructor = ChildType; 

我鼓励你仔细检查本节中讨论的所有代码,以了解所有部分是如何联系在一起的,以及对象之间的继承是如何工作的。

本地继承支持

现在我们已经了解了创建构造函数之间继承的不同技术,考虑 JavaScript 中创建此类关系的本地支持是个好主意。

ECMAScript 5 通过Object.create()方法提供了原型继承

此方法接受两个参数。第一个参数是要用作新对象prototype(基对象)的对象。第二个参数是可选的,用于向新对象添加额外的属性。

考虑以下代码:

var BaseType = {
  firstValue: 20,
  secondValue: [3,4]
};
var ChildType1 = Object.create(BaseType);

ChildType1.secondValue.push(5);

ChildType1.getBaseTypeFirstValue = function(){
  return this.firstValue ;
};

var ChildType2 = Object.create(BaseType);
ChildType2.newProperty = 50; 

console.log(ChildType1.getBaseTypeFirstValue()); // displays 20
console.log(ChildType1.secondValue); // displays [3, 4, 5]

console.log(ChildType2.secondValue); // displays [3, 4, 5]
console.log(BaseType.secondValue); // displays [3, 4, 5] 

关于前面的代码,有一些有趣的观点需要你记住。正如你所见,我们使用了BaseType构造函数并将其传递给Object.create方法来创建我们的BaseType对象实例,并将其分配给ChildType1变量。

然后,我们在新对象(ChildType1)的secondValue属性数组中添加了一个新值。在下一行中,我们还向该实例添加了一个新方法getBaseTypeFirstValue

当我们使用Object.create方法创建第二个对象ChildType2并检查secondValue数组的值时,我们看到值如下所示:

console.log(ChildType2.secondValue); // displays [3, 4, 5]

这是因为修改后的属性secondValue在所有与基对象BaseType有继承关系的实例之间是共享的。因此,一个子实例对基对象属性所做的所有更改都会反映在所有实例中,以及基对象本身BaseType中。这在此处显示:

console.log(BaseType.secondValue); // displays [3, 4, 5]

这是一个需要记住的重要观点。

如前所述,我们还可以使用Object.create方法的第二个可选参数,在创建基对象和子对象之间的继承关系的同时,向子实例添加新属性。

考虑以下内容:

var BaseType = {
  firstValue: 20,
  secondValue: [3,4]
};
var ChildType = Object.create(BaseType, {
  optionalObject:{
    value: 50
  }
});

console.log(ChildType.optionalObject); // displays 50
console.log(ChildType.firstValue); // displays 20 

ChildType对象现在继承了BaseType对象的所有属性,并有一个新的属性optionalObject,这是针对子对象实例特有的。

尽管这是在 ECMAScript 5 中创建对象之间继承的本地方式,但我发现这种技术并不像寄生组合继承那样受欢迎。

与本章中我们讨论的其他实现继承的方法相比,我个人觉得这种方法有点过于冗长,但它允许我们精确地定义新属性的属性。

注意

你可以参考以下在线资源以获取有关Object.create方法的更多信息:

developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create

摘要

本章的目标是快速概述 JavaScript 中的一些面向对象概念。对这些概念有良好的理解非常重要,因为我们将使用它们在接下来的章节中实现我们的模块化架构。

在本章中,我们探讨了创建对象的各种方法,以及闭包、封装和继承等概念。

我所做的假设是,你们可能对 JavaScript 有相当丰富的经验,并且可能至少熟悉这些概念中的某些。

我的意图是提供“足够”的 JavaScript 面向对象概念回顾,以便你们为未来的章节做好准备。

有许多优质的资源可以帮助你们进一步扩展对面向对象 JavaScript 原则的了解。

在这个主题上,我个人的两个最爱是:

  • 《面向对象的 JavaScript》,作者:Stoyan StefanovKumar Chetan Sharma

  • 《专业 JavaScript Web 开发者指南》,作者:Nicholas C. Zakas

在下一章中,我们将开始关注一个流行的设计模式,即模块设计模式,以便我们为在应用程序中创建简单模块做好准备。

模块设计模式

现在我们已经回顾了上一章中的一些重要的 JavaScript OOP 概念,我们将利用其中讨论的一些技术来创建模块,并开始在应用程序中使用它们。

你可能熟悉设计模式这个术语,它本质上是一种将代码结构化为可重用解决方案的方法,用于解决常见的编程问题。在本章中,我们将专注于 JavaScript 中称为模块模式的特定设计模式。

我们将探讨如何使用此模式创建简单的模块,然后在接下来的章节中,我们将基于这些模块创建我们应用程序的模块。

模块模式是 JavaScript 中用于创建模块化应用程序最常用的模式之一。

本章我们将讨论以下主题:

  • 模块模式的结构

  • 模块模式中的内部私有作用域

  • 如何创建简单的模块

  • 如何创建模块工厂

第三章:模块模式

在上一章中,我们了解了如何在函数内部创建私有变量和命名空间。我们还探讨了如何实现私有作用域。与私有作用域相关的一些概念也可以应用于函数内部的单例对象。

单例对象是一个在应用程序中只会存在一个实例的对象。单例对象可以使用对象字面量表示法创建,我们已经在上一章中看到了一些例子。

考虑以下对象定义:

var mySingletonObj = {};

虽然前面的对象没有做任何事情,但实际上它是一个有效的对象,并且由于我们不能基于mySingletonObj创建其他对象,因此这个对象只能有一个实例。

让我们给这个对象添加一些值属性和方法(方法属性),看看我们如何从对象定义外部访问这些属性:

var mySingletonObj = {

  name: "Sasan",

  title: "Software Developer",

  getName: function(){

    return this.name;  
  },

  getTitle: function(){

    return this.title;  
  }

};
// displays "Sasan"
console.log(mySingletonObj.getName()); 

//  displays "Software Developer"
console.log(mySingletonObj.getTitle()); 

在上述对象定义中,我们创建了一个具有四个属性的单例对象。其中两个是值属性,两个是方法属性。

我们的方法属性可以访问我们的值属性,并返回它们的值。然而,我们也可以直接访问对象的属性,甚至可以从对象外部覆盖它们的值。

看看以下代码片段:

mySingletonObj.name = "John";
console.log(mySingletonObj.name); // displays "John"

这表明,尽管我们的代码中存在封装(我在这里使用这个术语比较宽松),但我们没有访问控制权,外部代码可以更改我们对象中属性的值。有时,这是不希望的。

让我们重构我们的代码,使用模块模式创建一个简单的模块。这将帮助我们实现封装以及在我们的单例对象中实现访问控制。

考虑以下内容:

var mySingletonObj = (function(){

    //private variables
    var name = "Sasan"; 
    var title = "Software Developer";

    //returning a Singleton
    return {

        // privileged method
        getName : function(){

            return name;
        },
        // privileged method
        getTitle: function(){

            return title;
        }            

    };

})();

在前面的代码中,我们创建了一个立即执行函数表达式IIFE),并将其返回值(即单例对象)赋值给变量mySingletonObj

返回的单例对象有两个方法可以访问容器函数的私有变量并返回相关的值。

如果我们尝试从返回的对象中访问函数变量目录,例如:

console.log(mySingletonObj.name); // displays undefined

我们无法这样做。这是因为name属性是容器函数的私有属性,它不存在于返回的匿名对象上。

然而,我们可以通过特权的getName方法访问这个属性,该方法定义在我们 IIFE 返回的单例对象上。

由于mySingletonObj变量引用了这个单例对象,如果我们执行以下代码:

console.log(mySingletonObj.getName()); // displays "Sasan"

我们能够访问分配给函数name属性的值。请注意,从 IIFE 返回的单例对象仍然可以访问包含它的匿名函数的上下文。这是可能的,因为我们在这里创建了一个闭包。当然,这里看到的函数的title属性也是如此:

// displays "Software Developer"
console.log(mySingletonObj.getTitle());

这种将我们的代码结构化为从函数内部返回的单例对象提供对容器函数对象私有成员(内部私有作用域)访问的方法,是 JavaScript 中模块模式的基础。

模块模式中的内部私有作用域

模块模式使我们能够在函数内部为我们的代码创建一个私有作用域,同时通过接口提供对这样一个私有作用域的受控访问。这个接口可以是返回的单例对象的形式。

在上一节中的代码示例中,mySingletonObj是访问我们 IIFE 私有作用域的接口。

如果我们在mySingletonObj上添加另一个具有与包含函数属性相同标识符的属性会发生什么?

好吧,mySingletonObj就像任何其他对象一样,我们可以向它添加属性,而不管它是否包含对象的属性。然而,在这里要记住的重要事情是,在返回的接口上分配或修改这样的属性对包含返回对象的函数中封装的私有变量没有任何影响。

让我们修改我们之前的模块并深入探讨一下:

var mySingletonObj = function() {
    //private variables
    var name = "Sasan";
    var title = "Software Developer";

    //returning a singleton
    return {
        name: 'Tom',

        // privileged method
        getOuterName: function() {
            return name;
        },

        // privileged method
        getInnerName: function() {
            return this.name;
        },

        // privileged method
        getTitle: function() {
            return title;
        }
    };
}();

如您所见,我们在 IIFE 返回的单例对象上添加了一个name属性,其标识符与函数对象上的name属性相同。我们还在我们的接口(IIFE 返回的单例对象)中用两个新方法替换了之前的一个方法。第一个方法getOuterName简单地返回name属性的值。

问题是,哪个name属性,函数的私有作用域中的那个,还是单例对象本身的name属性?

如果我们运行以下代码行:

console.log(mySingletonObj.name); // displays "Tom"

我们可以看到返回的值是单例对象本身的范围中的值;因此,显示的是Tom

然而,如果我们尝试使用接口上的方法属性访问相同的属性,将返回不同的值。请看以下代码片段:

console.log(mySingletonObj.getOuterName()); // displays "Sasan"

如您所见,从这个方法返回的值是接口外部作用域中name属性的值,即容器函数的作用域。这可能会相当令人困惑。

如果意图是返回单例对象内部(接口)定义的name属性值,我们需要使用this关键字指定属性。

看看我们添加到模块中的第二个新方法:

    getInnerName: function(){

        return this.name;
    }

在这种方法中,我们使用this关键字特别指定了name的上下文,它引用的是单例对象本身的上下文,而不是包含函数的上下文。因此,如果我们运行以下代码:

console.log(mySingletonObj.getInnerName()); // displays "Tom"

返回的是单例对象上下文中name的值。这是一个需要记住的重要区别。

在模块模式中向接口添加属性

现在我们已经看到了当我们使用模块模式时内部私有作用域是如何起作用的,让我们看看如果我们向之前示例中返回的单例对象添加新的动态属性会发生什么。

考虑以下代码行:

mySingletonObj.name = "Jack";

在这里,我们只是将一个新的属性动态地添加到我们的对象mySingletonObj中。这个属性恰好与对象上已经存在的属性具有相同的标识符。原始的name属性值会发生什么变化?

我们可以通过运行以下测试来找出答案:

console.log(mySingletonObj.name); // displays "Jack"
console.log(mySingletonObj.getOuterName()); // displays "Sasan"
console.log(mySingletonObj.getInnerName()); // displays "Jack"

如您所见,无论我们直接访问属性还是使用单例的方法返回属性的值,都会显示单例对象属性的新值。

另一方面,正如预期的那样,容器函数上下文中属性的值没有发生变化,尽管我们的单例对象确实可以访问这个上下文。

记住,当我们使用 JavaScript 中的模块模式并从中返回单例对象(作为包含对象/函数的接口)时,有两个上下文在起作用。

首先,容器函数的内部私有作用域,我们的单例对象可以通过闭包访问。

第二个是单例对象本身的上下文,就像 JavaScript 中的任何其他对象一样。当您使用 JavaScript 中的模块模式设计模块时,理解这两个上下文之间的区别很重要。

另一个需要记住的重要点是,之前展示的代码结构(模式)允许我们在私有命名空间中定义属性时同时拥有对象封装和访问控制。在这种情况下,匿名函数创建了一个命名空间,该命名空间返回一个匿名对象,或者更准确地说,返回对匿名对象(单例对象)的引用作为接口。

这种方法变得非常有价值,因为我们现在可以确信没有任何外部代码能够有意或无意地更改我们命名空间中属性的值。这种结构提供了控制哪些属性可以被外部代码访问,哪些属性被隐藏在私有作用域中并受到保护。

对象工厂模块

我们可以设计我们的模块成为非常专业的代码块,执行非常具体的任务,同时保护它们的内部不受外部代码干扰。到目前为止,我们已经查看了一个用于创建模块的非常简单的模式。我们可以进一步发展这个模式,并设计一个创建对象实例、向这些实例添加属性,然后将它们返回给模块外部代码的模块。

考虑以下模块:

var myCarFactoryModule = function() {

    var createdCars = [];

    function carFactory() {

        // could also use "var newCar = {}";  
        var newCar = new Object();

        newCar.type = arguments[0];
        newCar.color = arguments[1];
        newCar.gearType = arguments[2];
        newCar.cylinder = arguments[3];
        createdCars.push(newCar);
        return newCar;
    }
    return {

        // privileged method
        createCar: function(type, color, gearType, cylinder) {
            return carFactory(type, color, gearType, cylinder);
        },

        // privileged method
        getCarCount: function() {
            return createdCars.length;
        },

        // privileged method
        removeCar: function(index) {
            createdCars.splice(index, 1);
        }
    };
}();

如你所见,我们结合了两种模式:对象工厂模式和模块模式。

注意

JavaScript 中的设计模式

如果你不太熟悉设计模式,或者想要更熟悉 JavaScript 中的设计模式,我强烈推荐以下资源:

《精通 JavaScript 设计模式》Simon Timms 著。

当我们的函数myCarFactoryModule被调用时,根据传入的参数,我们创建一个汽车实例,然后将其分配给一个数组,这个数组是所有创建的汽车实例的存储库。我们设计这个模块的方式是,模块外部的代码无法访问创建汽车的函数,也无法访问汽车存储库。

让我们使用以下代码进行测试:

var myFirstCar = myCarFactoryModule.createCar("Sedan", "red", "automatic",4); // creates first instance of car

var mySecondCar = myCarFactoryModule.createCar("SUV", "Silver", "Standard",6); // creates second instance of car

console.log(myFirstCar.color); // displays "red"
console.log(mySecondCar.gearType); // displays "Standard"

var myTotalCars = myCarFactoryModule.getCarCount();
console.log(myTotalCars); //displays 2

myCarFactoryModule.removeCar(0); // removes the first care object

var myTotalCars = myCarFactoryModule.getCarCount();
console.log(myTotalCars); // displays 1

如你所见,外部代码可以调用我们的模块接口来创建汽车,也可以获取创建的汽车数量。如果需要,它还可以删除汽车。

在这个模块中有三个特权方法:createCargetCarCountremoveCar。你可以将这些方法视为模块内部(对外界隐藏)和外部代码之间的桥梁,外部代码依赖于模块的内部来实现一些特定的任务。

特权方法(单例接口对象的成员)被暴露给外部代码,以便为需要其服务的任何人提供模块的功能。

这种设计有一个明显的优势。正如你可能已经注意到的,我们可以修改模块内部的代码,并添加额外的功能到模块中,而不会影响应用程序的其他部分如何与我们的模块交互。

只要我们不更改提供外部代码访问模块功能的特权方法的名字或功能,这就是真的。

同时,我们可以在模块的接口中添加新的特权方法,或者修改单例对象中当前特权方法的内部结构,而不会影响模块本身的内部代码。

这使得我们可以在模块的公开部分(模块的接口)和模块的私有内部之间有一个很好的关注点和责任分离。

在模块之间创建松耦合

在模块化设计中,应用程序通常由许多模块创建。为了使这些模块能够协同工作,我们需要在它们之间创建耦合,而无需模块之间紧密依赖。

让我们基于几个简单的模块创建一个应用程序,并让这些模块以松耦合的方式相互交互。当然,我们现在将保持这个应用程序非常简单。在后面的章节中,我们将利用这个应用程序中的概念,在此基础上构建我们的完全模块化应用程序生态系统。

我在这里做出一个安全的假设,即我们的未来应用程序将包含许多独立的模块,每个模块都负责执行一个非常具体的任务。

我们将首先创建我们的核心应用程序模块,并将其命名为ApplicationInitModule。通常,运行应用程序的第一步是处理应用程序的初始化任务。我们的ApplicationInitModule将被设计来执行这项任务。

在以下代码片段中,当ApplicationInitModule启动时,它将依次启动所有注册的模块及其初始化例程。这个过程将负责整个应用程序的初始化例程。

看看下面的代码,看看我们的应用程序是如何设计的:

var ApplicationInitModule = function() {
    var registeredModules = [];

    return {
        registerModule: function(module) {
            registeredModules.push(module);
        },
        getAppModulesCount: function() {
            return registeredModules.length;
        },
        removeRegisteredModule: function(index) {
            registeredModules.splice(index, 1);
        },
        initializeAllModules: function() {
            for (var module in registeredModules) {
                registeredModules[module].initialize();
            }
        },
    };
}();

var GlobalApp = (function() {
    var registerModule = ApplicationInitModule.registerModule;
    return {
        registerModule: registerModule
    };
})();

var testModule1 = (function() {
    var self = {};
    var moduleName = "Module 1";

    self.initialize = function() {
        //displays "testmodule1 has been initialized!"
        console.log("testmodule1 has been initialized!");
        //displays "module name is: Module 1"
        console.log("module name is: " + moduleName);
    };

    (function() {
        GlobalApp.registerModule(self);
    })();

    return {
        initialize: self.initialize,
        getName: function() {
            return moduleName;
        }
    };
})();

var testModule2 = (function() {
    var moduleName = "Module 2";

    function initialize() {
        //displays "testmodule2 has been initialized!"
        console.log("testmodule2 has been initialized!");
    }
    return {
        initialize: initialize
    };
})();
GlobalApp.registerModule(testModule2);

正如你所见,在这个应用程序中发生了一些有趣的事情,但我们已经在本书中讨论了所有使用的技术。尽管如此,我仍将逐一解释每个模块的内部工作原理。

应用程序核心模块

代码首先定义ApplicationInitModule作为一个应用程序初始化模块。这个模块的目的是注册应用程序中所有可用的模块(将它们存储在数组中),然后在应用程序运行时初始化它们(一次一个)。

此模块还提供了一个接口,其中包含一些钩子,以便外部代码与之交互。正如你所见,有方法可以注册模块、获取应用程序中注册的所有模块的计数以进行初始化,以及从注册模块列表中删除模块;当然,还有一个方法可以初始化已注册到应用程序中的所有模块。

在这个模块中,我们使用 IIFE(立即执行函数表达式)返回一个对匿名单例对象的引用,这实际上是模块的接口。

我想要引起你注意的一个点是,这个模块的功能实际上是在其接口中定义的。然而,模块并不一定要以这种方式设计。你还可以看到,在前面的代码中展示的其他模块中,我没有使用这种方法。这样做是为了让你看到我们可以以各种方式实现模块模式。

应用程序中介模块

我们的 GlobalApp 模块也使用 IIFE 返回一个单例对象作为模块的接口。这个模块的整个目的就是作为 testModule1testModule2 和我们的核心模块 ApplicationInitModule 之间的中介(抽象层)。

我们这样设计应用程序是为了能够在我们的核心模块和其他已注册模块之间创建松散耦合。正如你所见,GlobalApp 被设计成一个非常薄的层。

这薄薄的一层允许我们随心所欲地更改我们的核心模块(ApplicationInitModule),甚至可以更改其与外部世界的接口,而不会影响依赖于该模块功能的其他模块。

在我们的设计中,唯一直接依赖于 ApplicationInitModule 接口的模块是我们的 GlobalApp 模块。这意味着,如果 ApplicationInitModule 的接口有任何更改,我们只需要修改我们的薄中介层 GlobalApp。应用程序中的所有其他模块将不受影响,因为它们仍然使用 GlobalApp 提供的相同薄层接口。

你将在本书的未来章节中看到,这个薄层被称为 沙盒,我们将使用沙盒化的概念来隔离我们的应用程序模块/组件与我们的应用程序核心模块。我们还将使用相同的技巧来隔离模块/组件彼此之间。现在,只需尝试熟悉一下前面应用程序中展示的模块隔离(沙盒化)的一般概念。

应用程序非核心模块

在我们的应用程序实现中,我们创建了两个简单的模块,它们将自己注册到核心模块中,实际上除了向世界宣布它们已经被初始化之外,什么也不做。

让我们更详细地看看这些模块是如何实现的。

testModule1 实现细节

testModule1 中,我们创建了一个名为 self 的空对象,该对象通过一个名为 initialize 的方法进行扩展。当我们的核心模块尝试初始化此模块时,将调用此方法。

此模块与核心模块的注册是通过一个内部 IIFE 实现的,该 IIFE 反过来调用我们的中介模块 GlobalApp 并传递对对象 self 的引用。这如下所示实现:

(function(){

    GlobalApp.registerModule(self);        

})(); 

当然,GlobalApp.registerModule 方法实际上是核心模块的方法 ApplicationInitModule.registerModule 的引用。然而,testModule1 并不知道这一点,它只知道 GlobalApp 提供的接口,即 GlobalApp.registerModule

我们还使用 IIFE 返回一个接口给这个模块,该接口可以通过 testModule1 变量访问。

注意,接口提供了两个属性。一个是模块的 self.initialize 方法的引用,另一个是 getName,它简单地返回封装和隐藏的 moduleName 变量的值。还要注意,moduleName 不是 self 对象上的属性。相反,它被实现为一个包含函数的属性。

testModule2 实现

testModule1 相比,我们的 testModule2 实现略有不同。如代码所示,我们在模块内部简单地定义了一个名为 initialize 的函数,该函数通过模块返回的接口间接暴露给外部代码。

在我们的 testModule2 中,moduleName 变量完全被封装起来,因为接口上没有定义任何方法来提供对这个变量的访问。

此外,没有内部 IIFE 将模块注册到我们的核心模块 ApplicationInitModule 中,因此我们需要在模块定义之外进行调用以完成此任务,如下所示:

GlobalApp.registerModule(testModule2);

注意,我们再次使用了我们的中介模块 GlobalApp 进行注册,并且我们没有直接在核心模块上调用相关方法。这允许我们仍然保持模块之间的松散耦合。

注意

testModule2 中使用的模式

我们实现 testModule2 的方式基于一种称为 揭示模块模式 的设计模式,其最简单的形式。这是一种非常流行的模块设计模式,但当然,实现模块的方式有很多种,正如我们之前所看到的。在接下来的章节中,我们还将看到更多实现模块的模式。为了更好地理解这种模式,请参考之前提到的 JavaScript 设计模式资源。

应用模块的自动初始化

到目前为止,我们已经看到我们的应用程序模块如何使用中介模块将自己注册到应用程序的核心模块中,而无需意识到核心模块的存在。

我们还注意到,模块之间的通信是通过模块提供给外界的接口完成的。只有这样的接口才能访问模块的内部和它们的内部私有作用域。

以下图表展示了我们的应用程序模块之间的关系,并提供了我们应用程序的整体视图:

应用模块的自动初始化

让我们看看核心模块中负责调用所有注册模块初始化方法的方法。

记住,每个注册到核心模块的应用模块的引用都已添加到数组 registeredModules 中。这在上面的代码片段中显示:

registerModule : function(module){

        registeredModules.push(module);
}

当我们在应用程序中调用 initializeAllModules 方法(在核心模块上)时,使用一个 for 循环来调用所有注册模块的 initialize 方法。具体做法如下:

initializeAllModules: function(){

    for(var module in registeredModules){

        registeredModules[module].initialize();

    }
};

如您所见,initializeAllModules在调用时并不知道每个已注册模块的initialize方法做了什么。它只知道在注册的模块上调用initialize方法,并让模块自己处理其初始化任务。

当我们模块化我们的代码时,这是一个非常重要的观点。每个模块只处理特定于该模块设计的任务,其他模块对如何在模块中执行此类任务没有任何了解。

这意味着,当我们的核心模块在一个或多个模块上调用initialize方法时,它并不直接参与每个模块的初始化任务。

是时候运行测试,看看应用程序初始化是如何进行的了。考虑以下内容:

// displays 
// "testmodule1 has been initialized!" 
// "module name is: Module 1"
// "testmodule2 has been initialized!" 
ApplicationInitModule.initializeAllModules();

当我们运行前面的代码时,我们可以看到我们的两个模块都报告它们已经成功初始化。

当然,我们已经以这种方式设计我们的应用程序,所有已注册的模块都确实有一个可访问的方法称为initialize。这允许我们使用一个数组和for循环来按顺序初始化它们。

模块初始化和设计考虑

在应用程序设计时,您决定模块应该如何初始化以及每个模块的初始化方法应该如何调用。重要的是,调用每个模块的初始化例程不应导致紧密耦合。

为了保持一致性以及便于维护,我通常将负责所有应用程序模块中初始化任务的函数命名为initializeinit。这允许我使用for循环按顺序调用所有已注册的模块,并要求模块相应地初始化自己。

请记住,只有在模块的接口(公共方法钩子)不更改其名称及其对外部代码的可访问性时,才能在模块之间创建松散耦合。

这意味着,例如,如果testModule1调用GlobalApp.registerModule方法来注册自身,它应该始终能够调用该方法进行注册。即使在GlobalApp对象内部如何进行注册的机制发生变化,这也应该是正确的。

在这里简单应用程序中看到的架构和设计为在应用程序模块之间创建松散耦合奠定了基础。这反过来又导致了对小型和大型应用程序都更加可扩展和可维护的实现。

您可以将模块接口视为模块之间的合同,这使得它们能够相互交互,而不管这些合同的内部实现细节如何。请注意,尽管应用程序中存在模块之间的合同,但它们之间没有直接的依赖关系。每个模块都可以自由决定如何实现其特定的功能来完成其设计要完成的任务。

只要模块提供了预期的功能和服务,情况就是如此。

这种方法将为我们的应用程序提供极大的灵活性和可扩展性。它允许我们以非常针对性和可管理的方式添加、删除和修改应用程序的各个部分,而不会影响应用程序的其他部分。

我们在这里讨论的基本架构概念是构成我们将在本书中构建的最终应用程序的构建块的概念。

摘要

在本章中,我们探讨了 JavaScript 中最受欢迎的设计模式之一:模块模式。

通过创建简单的模块,我们探讨了模块模式中内部私有作用域的各个方面,并看到了模块如何在不访问彼此私有作用域的受保护属性的情况下相互交互。

这种模式允许我们在对象和模块中创建封装和访问控制,同时为外部代码提供一个接口,以便利用为外部使用而实现的功能。

模块模式最重要的一个方面是它如何被用来为我们的整个应用程序创建模块化设计。这使我们能够在模块之间创建松散耦合,这些模块实际上是我们的应用程序的构建块。

在后面的章节中,我们将利用这种方法,逐步构建更复杂的模块,以创建一个健壮且易于维护的代码库,用于我们的应用程序。这些模块也可以根据需要轻松地在所有未来的应用程序中重用。

设计简单模块

在本章中,我们将专注于将我们在前几章中学到的概念应用到设计一些简单的模块中。

我们将首先分析应用的整体功能,然后将其分解成更小的功能组件。一旦我们决定了应用的功能组件,我们就会开始创建简单的模块来实现所需的功能。

本章旨在展示基于我们需求的应用生命周期初期的可能步骤。目标是了解使用模块如何帮助我们设计更好的架构,并感受模块化设计的实际优势。

本章中创建的简单模块将为本书中最终的应用提供基础,该应用将是一个工作的客户端单页应用。

本章我们将涵盖:

  • 在我们的设计中反映整体应用需求

  • 设计应用的主要部分

  • 为应用的主要部分创建专用模块

  • 模块间的协作

  • 使用对象定义来描述页面片段

  • 动态生成页面和页面片段

第四章:大局观

在我们开始任何编码之前,我们需要对应用的整体情况有一个很好的理解,包括需求是什么,以及需要哪些可能的功能组件来满足这些需求。

在应用设计的初期阶段,我们试图尽可能多地回答关于我们应用需求的问题,但我们始终应该努力不要被细节所束缚。

目标是确保大局正确,理解我们想要交付的内容,涉及的时间表以及可用的资源。基于这样的分析,我们可以开始为我们的应用创建一个可扩展、灵活和可扩展的架构设计。

我想提醒大家一个我在这里使用的重要词汇,可扩展性。应用能够轻松扩展的能力在适当的设计中非常重要。请记住,无论我们多么努力提前确定应用的所有需求,我们都不可能在开始时就预见所有这些需求。

需求会随时间变化,新的需求会被添加,旧的需求会被修改,甚至可能完全从应用的最终草案中删除。关键是要以能够适应所有这些变化而不对整体架构产生重大影响的方式来设计我们的应用。这正是模块化架构的优势所在,有助于减轻这种变化对整个应用可能产生的负面影响。

在牢记这些要点的同时,让我们来谈谈本书中我们将一起构建的应用。

我们的应用需求

我们的应用程序是一个简单但功能齐全的图片库应用程序。目标是向用户展示我们网站上美丽的图片目录。我们的网站访客可以点击每张图片以查看图片的全景,如果他们愿意,还可以将这些图片添加到他们的收藏夹中。

应用程序将有一个页眉,顶部的导航栏,中间的主要内容区域,页脚,当然还有标志。

如你所见,这里没有多少复杂的部分,但我向你保证,在底层有许多模块可以轻松地从本应用程序移植到许多其他更复杂的应用程序中。

让我们先创建一个应用程序的整体布局(线框图),看看整体情况是什么样的。

我们的应用程序需求

我已经在这个样图中确定了我们的索引页的主要部分。我们将创建模块来构建和更新这个页面,以及与之相关的其他部分和整个应用程序。

动态视图

我提到我们的 JavaScript 模块将为我们构建这个页面,我需要进一步解释这一点。

当我们创建模块来处理应用程序的功能部分时,我们将更进一步。我们还将创建专门在客户端动态构建我们页面(视图)的模块。

在这个设计中,我们只从服务器接收页面的大纲。页面将通过我们的视图生成模块根据发送到客户端的每个页面部分(片段)的对象定义来填充。

我们应用程序的整体架构基于单页应用程序(SPA)设计概念。如果你对这个术语不熟悉,其想法是,当我们浏览我们的应用程序页面(视图)时,我们不需要加载或构建从一个视图到下一个视图不改变的部分。我们只需在客户端动态更新视图的更改部分,同时保持其余视图不变。

由于我们在应用程序的视图中只进行有针对性的更改,因此我们视图的渲染将更加健壮和优化。

这也意味着在应用程序的初始加载之后,客户端将只从服务器请求应用程序更改的部分。因此,我们只会在带宽上传输页面片段,而不是整个页面。一般来说,当我们设计需要在有限带宽场景下运行的应用程序时,例如移动应用程序,这可以是一个很大的优势。

对于本书中的我们的应用程序,我们的页面片段作为对象定义传输到客户端,我们的视图生成专用模块将根据这样的对象定义渲染所需的视图。

你将看到,随着我们继续前进,我们如何在不久的将来实现这一点。

设计我们的 SPA 主要部分

通常在设计 SPA 应用程序时,我会创建一个核心应用程序代码库,在应用程序的初始加载阶段加载到浏览器中。这个代码库提供了与应用程序视图无关的应用程序级功能。应用程序核心由许多一起加载的模块组成。如果你熟悉 模型-视图-控制器MVC)或 模型-视图-通配符MV*)应用程序设计模式,这个核心本质上就是应用程序的控制器。

注意

MVC 和 MV 设计模式*

这些设计模式在代码中创造了很好的专业化和关注点的分离。理解这些模式对于创建良好的应用程序架构非常重要。虽然我会在适当的时候提到这些模式,但深入探讨这些模式超出了本书的范围。

我推荐以下资源以获取更多信息:

www.packtpub.com/application-development/mastering-javascript-design-patterns/

addyosmani.com/blog/understanding-mvc-and-mvp-for-javascript-and-backbone-developers/

应用程序的模式和视图也有它们自己的专用模块,其中一些在应用程序初始加载时加载,而另一些则在需要时动态加载。这种方法使得应用程序在浏览器中具有较小的占用空间,并且只加载所需的资源。

我通常还会尝试让我的应用程序的每一页在服务器端和客户端都是一个独立的模块(组件)。这提供了创建、修改或删除页面(组件)及其相关代码的能力,而不会影响应用程序的其他部分。

注意,我在这里使用“模块”这个词是一个通用术语,并不一定意味着使用模块模式构建的模块。我的意图是传达每个页面是应用程序中一个独立部分的观念,在这个部分中,可以使用一个或多个 JavaScript 模块来完成与该页面相关的工作。如果你不确定这究竟意味着什么,请放心,这很快就会变得清晰。

以下图展示了本书中应用程序的主要组件,按其特殊功能和设计进行了分类:

设计我们的 SPA 主要组件

如图所示,我们的整体设计基于三个主要部分:控制器视图模型。在本章的剩余部分,我们将讨论每个部分,并基于我们的模块化架构开始构建它们。

应用程序控制器

控制器是包含应用主要功能的部件。本质上,控制器模块是应用的“大脑”。它将包括提供应用级功能的应用级模块。这个部件还将负责初始化应用中的所有其他模块和组件,并使用松耦合方法将它们粘合在一起。

应用控制器模块

如前所述,考虑到我们在应用中控制器的作用,我们将基于专用模块来设计这个部件。这些模块共同构成了我们将称之为核心模块的内容。

请记住,由于我们正在使用针对核心的专用模块,每个模块都可以轻松修改或替换,而不会影响应用的其他部分,即除了与该模块相关的功能之外,不会影响其他模块。

当然,如果我们决定在未来的某个时刻需要额外的功能,我们也可以向核心添加更多模块。

控制器模块的集体功能提供了应用的核心功能。

根据我们应用的需求,我预计我们将在应用控制器中需要以下模块:

  • 页面更新模块

  • 存储处理模块

  • 通信处理模块

  • 工具模块

  • 消息处理模块

  • 记录处理模块

请记住,我们正在尽我们最大的努力猜测所需的模块。随着我们进一步实施,这个列表可能会随着时间的推移而变化。我们的想法是设计和实现我们认为我们现在需要的模块,以有一个起始基线。然而,模块列表、它们的函数以及我们为它们选择的名称可能会随着时间的推移而变化。

让我们先来探讨一下我们应用核心中每个模块的功能。

页面更新模块

这个模块负责构建应用中的 HTML 片段。它通过将传递给它的字符串注入到一个容器中来完成这项任务。这个字符串对应于需要渲染到预定义容器中的 HTML 元素。

我们将使用这个模块构建我们应用页面的各个部分,并根据需要动态更新它们。

存储处理模块

这个模块有特定的责任,即存储与应用相关的数据。这些数据可以存储在 cookies 中,或者存储在浏览器提供的其他存储设施中,例如 HTML 5 本地存储。

通信处理模块

所有与应用相关的通信都通过这个模块完成。这个模块主要设计用来使用 AJAX 调用与后端服务器通信。然而,这个模块使用的通信方法可能在未来的某个时刻不会仅限于 AJAX 调用。

工具模块

此模块负责为应用程序提供实用类型的功能。例如,它可以进行字符串操作、对象克隆或页面尺寸计算。

消息处理模块

随着应用程序中事件的发生,我们需要一种方式与用户沟通这些事件,并在应用程序的页面上显示消息。此模块专门负责这项任务。

日志处理模块

此模块提供与应用程序日志机制相关的所有功能。日志可以在客户端、服务器端或两者同时进行。

创建我们的第一个核心模块

现在我们已经整理出了核心所需的可能模块列表,让我们创建应用程序控制器的第一个模块,即 PageUpdater 模块。此模块应设计为能够动态更新页面的一部分。这部分可以小到页面上的文本,也可以大到整个显示的页面。对页面片段的更新可以是微不足道的,如更改字体大小或背景颜色,也可以是复杂的,如完全重构和重新渲染页面片段。

我们第一个模块的结构

考虑以下:

var PageUpdater = (function(){

    // module private function
    var insertHTMLTxt = function(containerID,newStructure){

        var theContainer = document.getElementById(containerID);
        theContainer.innerHTML = newStructure;
    };

    // module private function
    var applyElementCSS = function(elementID, className){

        var theElement = document.getElementById(elementID);
        theElement.className = className;
    };

    return{

        // privileged method
        updateElement : function(elemID, htmlTxt){

            insertHTMLTxt(elemID,htmlTxt);
        },

        // privileged method
        updateElementClass : function(elemId,className){

            if(!className){

                console.error('No class name has been provided, exiting module!');
            }
            applyElementCSS(elemId,className);
        }
    };

})();

上述代码实现了一个简单的模块,使我们能够对页面片段进行更新。让我们检查一下代码,看看它是如何组织的。

我们使用模块模式定义了一个 JavaScript 模块。IIFE 用于执行匿名函数中的代码,创建一个命名空间。从这个函数返回的是分配给我们的全局变量 PageUpdater 的对象。

在我们的立即执行函数表达式(IIFE)中,我们使用函数表达式定义了 insertHTMLTxtapplyElementCSS 方法属性。这两个方法被保留在我们主要容器函数的内部私有作用域中,因此外部代码无法访问它们。因此,我们保护它们免受意外和不希望的修改。

我们确实通过模块的接口提供了对这些方法的受控和间接访问,该接口是容器函数执行时返回的匿名对象。对这个返回对象的引用(我们的模块接口)被分配给 PageUpdater 变量。

因此,实际上这个变量(PageUpdater)真正引用的是以下对象:

{   
        // privileged method
        updateElement : function(elemID, htmlTxt){

            insertHTMLTxt(elemID,htmlTxt);
        },
        // privileged method
        updateElementClass : function(elemId,className){

            if(!className){

                console.error('No class name has been provided, exiting module!');
            }
            applyElementCSS(elemId,className);
        }
};

由于这是一个普通的 JavaScript 对象,我们可以从外部代码调用其方法,如下所示:

PageUpdater.updateElement("headerContainer",headerContainerDef.sectionHTML);

PageUpdater 对象的 updateElement 方法依次调用 insertHTMLTxt(elemID,htmlTxt),这是在我们容器函数内部执行实际幕后工作的方法。

此方法接收两个参数:容器元素的 id (containerID), 我们打算更新其内容,以及一个字符串 (newStructure), 它是将在容器元素内部渲染的 HTML 元素的字符串表示。

如果你想知道PageUpdater对象如何调用容器函数内部的方法以及两者之间是如何建立链接的,你需要想到一个词:闭包!

我们在第二章重要 JavaScript OOP 概念的回顾中讨论了闭包。如果你还记得,由于我们的 IIFE 返回的匿名对象是在容器函数内部定义的,它能够访问容器函数的内部私有作用域。这意味着PageUpdater能够访问在该作用域内部定义的所有私有变量和方法。这就是为什么从模块返回作为接口部分的那些方法被称为,特权方法

小贴士

对模块模式理解有困难?

我在这里花了一些时间深入解释我们的第一个简单模块。从现在开始,我将不会像之前那样详细解释创建我们模块(模块模式)所使用的模式。了解这个模式非常重要,因为应用中的其他模块都遵循类似的架构。

为了更好地理解模块模式,我建议做两件事。首先,复习第二章,重要 JavaScript OOP 概念的回顾和第三章,模块设计模式。其次,花些时间分析之前的代码,并再次阅读我的解释。我相信你很快就能很好地掌握这个模式。

注意,PageUpdater模块目前能够执行两项功能。它可以更新容器元素的innerHTML,同时也能够更新页面中元素的CSS类。

使用我们的第一个模块的功能

以下是我们通过调用其方法利用模块功能的一个示例:

PageUpdater.updateElement("footerContainer", footerContainerDef.sectionHTML);

PageUpdater.updateElementClass("footerParentContainer", "footerContainerClass_Test");

上述代码的第一行根据预定义的对象定义填充了我们的应用页脚部分,我们将在本章稍后讨论这一点。第二行代码更改了页脚容器的 CSS 类,以应用不同的背景颜色到这个页面片段。本章的代码中包含了该操作的 CSS 类定义。

正如你所见,我们让模块负责页面片段更新的机制,而我们只需要通过为这个模块提供的接口进行简单的调用。

将我们的模块方法映射到其接口

关于我们模块的接口,有一个重要的点需要我们注意,这个点很容易被忽略。

如果您还记得,在 第三章 中提到的 模块设计模式,我提到模块可以自由实现其完成任务的方式,并且这种实现可以随时间变化。然而,模块应该保持其对外部世界的接口一致性。当然,这是因为模块的接口是模块的接触点,其他应用程序组件正是通过这个接口与之交互。

注意我们为我们的 PageUpdater 模块定义了以下接口:

updateElementClass : function(elemId,className){

            if(!className){

                console.error('No class name has been provided, exiting module!');
            }
            applyElementCSS(elemId,className);
}

如您所见,对于外部世界来说,当需要将 CSS 类应用到元素上时,应在模块上调用 updateElementClass 方法。这个模块随后会在模块的定义中调用一个不同名称的方法,即 applyElementCSS

这种映射类型允许我们更改模块内部方法的名称,而不会影响外部代码对模块接口的影响。映射在模块的内部和公共接口之间提供了一个抽象层。

我们当前的模块是一个简单的模块,为我们的应用程序执行相对简单的事情,但它不必局限于我们在这里定义的内容。

随着我们在本书中的进展,我们将增强这个模块(以及其他模块)以执行更多任务。尽管如此,我们应始终牢记,这个模块是专门用于执行与更新我们应用程序页面相关的任务,而不涉及其他任何事情。毕竟,模块化设计背后的一个主要思想是每个模块只做一种类型的工作,这样我们才能坚持职责分离的概念。

注意

运行伴随应用程序的代码

您可以通过在浏览器中使用任何内置了网络服务器的 IDE 加载 index.html 页面来运行应用程序的代码。

请查看本章的伴随代码中的代码,并使用此模块根据您的意愿更新页面上的不同片段。本章中讨论的所有模块都可以在伴随代码的 js/Modules.js 文件中找到。

应用程序视图

我们应用程序的另一个主要部分是 视图 部分。正如其名所示,这部分处理应用程序所有页面和页面片段的视图。视图是用户在浏览器中实际看到的。

由于我们的应用程序基于 MV* 类型架构(结合模块化架构),我们的视图与控制器以及模型组件进行交互。

然而,我们将构建应用程序视图的方式与传统 MV* 应用程序略有不同。我们的应用程序视图将被设计为模块,因为这些模块专门用于创建视图,我们将称它们为 组件。这是为了区分具有视图的模块和那些只提供功能但没有直接关系到我们应用程序视图的模块。

关于组件,还有一点需要记住的是,它们可以实现自己的 MV*架构。这种实现方式可能你现在还不清楚,但我向你保证,在接下来的章节中一切都会更加清晰。

在本节中,我们将只关注如何构建index.html以及它是如何使用一些对象定义和我们在上一节中看到的控制器方法来填充的。

要创建我们的index.html页面,我们首先将构建这个页面的骨架,然后我们将动态修改这个骨架以生成其主要片段。

创建 index.html 页面骨架

我们应用程序的页面骨架在这个阶段被设计得尽可能简约。

看看以下页面结构:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta content="width=device-width, initial-scale=1.0" name="viewport">
    <title>Images Inc.</title>
    <link href="css/app.css" rel="stylesheet">
</head>
<body>
    <header class="headerContainerClass" id="headerContainer" role="banner">
    </header>
    <main class="clearfix mainPageContainerClass" id="mainPageContainer" role=
    "main"></main>
    <div class="footerContainerClass" id="footerParentContainer">
        <div class="footerlinksContainerClass" id="footerContainer"></div>
    </div>
    <script src="img/Modules.js" type="text/javascript">
    </script>
</body>
</html>

当我们在浏览器中渲染这个 HTML 标记时,我们将在浏览器中看到我们的index.html页面的以下骨架:

创建 index.html 页面骨架

注意,我们已经创建了三个主要容器:headerContainer(页面的顶部部分)、mainPageContainer(页面的中间部分)和footerContainer(页面的底部部分),它们是页面的三个主要片段。

我们将用我们需要的 HTML 元素填充这些页面片段中的每一个。

为头部创建对象定义

如果你记得,我提到我们将使用对象定义来定义我们应用程序中的页面片段。

由于这些对象定义彼此非常相似,我们将在本节中只检查其中一个,它与页面的头部片段相关。

这里是头部部分的对象定义:

var headerContainerDef = {
    sectionHTML: '<div class="logo_titleClass" >' +
        '<a href=""><img src="img/ImagesIncLogo.png" alt="Company Logo" style="max-height:100%;"></a>' +
        '<div class="siteTitleClass">Images Inc.</div>' + '</div>' +
        '<nav role="navigation" itemscope itemtype="https://schema.org/SiteNavigationElement">' +
        '<h1 class="hiddenClass">Main Navigation</h1>' +
        '<ul class="navmenuClass" >' +
        '<li><a href="#" class="active">Home</a></li>' +
        '<li><a href="#">Our Company</a></li>' +
        '<li><a href="#">Pricing</a></li>' +
        '<li><a href="#">Contact Us</a></li>' + '</ul>' + '</nav>'
};

如你所见,我们定义了一个对象字面量,目前它只包含一个属性,sectionHTML

这个属性包含一个字符串,它是页面头部片段的 HTML 元素的字符串表示。

请记住,按照目前的实现,我们有一个名为headerContainerDef的全局变量用于我们的对象定义。正如你所知,我们应该尽量避免在我们的代码中使用全局变量。我们将很快解决这个问题,但到目前为止,这是故意为之的。

现在我们已经创建了第一个对象定义,是时候为头部片段创建我们应用程序的第一个视图了。

动态生成头部片段

正如你之前看到的,我们应用程序的控制器有一个专门用于在页面上生成页面片段的模块,它被称为PageUpdater

考虑以下代码片段:

PageUpdater.updateElement("headerContainer", headerContainerDef.sectionHTML);

如你所见,我们使用了我们应用程序的PageUpdater模块,并将头部片段的 id 作为第一个参数传递给它的updateElement方法。这个方法的第二个参数是头部片段的对象定义。这种实现方式允许我们利用我们应用程序控制器的功能来渲染头部片段。

当然,我们可以使用相同的方法来创建页面的其他部分(片段),例如页脚,如下所示:

PageUpdater.updateElement("footerContainer", footerContainerDef.sectionHTML)

以下截图显示了当页面片段在页面上渲染后,我们应用的index.html页面是如何显示的:

动态生成标题片段

页面的主要内容区域(页面中间用红色标出),是用户在我们应用中浏览页面时将更新的区域。页面的页眉和页脚不会为应用中的任何页面重新渲染,因为我们的应用是基于 SPA 原则构建的。

如你所见,我使用了一些有趣的背景颜色来描绘页面的每个片段。我喜欢称这种页面片段的颜色编码为。

这种着色方案的原因是为了在我们的视觉设计中轻松区分页面的每个特定区域。当我们完成应用实现后,这个页面看起来会好很多,但就目前而言,这是一个很好的起点。

当然,如果你是在黑白介质中阅读这本书,你只能看到前图中明暗的阴影。

动态生成客户端应用的视图

我在我的应用中使用的一种技术,我称之为动态页面生成DPG)。

理念是每个页面主要片段都与一个对象定义相关联,在这个对象定义中定义了页面片段的特性。例如,在我们这个应用的设计中,我考虑了页面上的三个不同片段:标题片段、内容片段和页脚片段。

每个主要片段反过来又可以进一步细分为子片段,每个子片段可能或可能没有自己的独立对象定义。

另一方面,我们可能只需要一个对象定义来覆盖整个页面,这个定义将用于动态生成整个页面。

通常来说,如何将对象定义关联到页面片段或子片段的决定权在用户界面开发者手中。开发者通过考虑诸如片段或子片段需要更新的频率,或者页面片段是否需要独立于页面其他片段进行更新等因素来做出这些决定。当然,性能也在这些决定中扮演着重要角色。

也有时候,在服务器端构建某些页面片段比在客户端构建更稳健。

在我们的应用中,我们正在客户端动态构建所有页面片段。这样做是为了我们可以探索客户端模块和组件的完整功能。

注意

我们应用的外观和感觉

请注意,这个应用的目标是帮助你理解与 JavaScript 应用相关的模块化设计概念,因此重点不在于外观和感觉。

虽然我们将在前进的过程中改进应用程序的外观,但我相信你可以在自己的基础上进一步改进。我们的应用程序在一定程度上基于浏览器的视口进行响应,但确实需要更多的 CSS 精炼来使其完全响应。

然而,我确实认为这个应用程序对于我们的目的来说是一个最小可行产品(MVP)。请随意从本书配套网站上下载代码,并根据您的需求进行改进。此外,我仅在 Chrome 46.0 中测试了此应用程序,但一个生产级的应用程序需要在各种不同类型的浏览器和版本中进行测试。

应用程序模型

我们 MV* 实现的最后一部分是模型。这个部分的主要作用是存储应用程序级数据。在客户端应用程序中,此类数据可以存储在缓存中、在 cookies 中或在本地或会话存储中。

在这本书的主应用程序中,我们将使用大多数这样的机制来存储我们的应用程序数据。此外,作为一个一般原则,根据 MV* 架构的实现,模型数据的更改可以触发应用程序视图的更改。

非常重要的是要保护应用程序级数据免受意外覆盖和修改。在我们的实现中,我们将再次使用模块模式来创建一个模块,该模块将充当我们的应用程序模型,并为存储的数据提供良好的保护级别。

为我们的应用程序模型创建模块

考虑以下实现:

var GlobalData = (function(){

    var headerContainerDef = {

    sectionHTML :  '<div class="logo_titleClass" >' +
         '<a href=""><img src="img/ImagesIncLogo.png" alt="Company Logo" style="max-height:100%;"></a>' +
                '<div class="siteTitleClass">Images inc.</div>' +
        '</div>' +
        '<nav role="navigation" itemscope itemtype="https://schema.org/SiteNavigationElement">' +
            '<h1 class="hiddenClass">Main Navigation</h1>' +
            '<ul class="navmenuClass" >' +
                '<li><a href="#" class="active">Home</a></li>' +
                '<li><a href="#">Our Company</a></li>' +
                '<li><a href="#">Pricing</a></li>' +
                '<li><a href="#">Contact Us</a></li>' +
            '</ul>' +
        '</nav>' 
    };

    var footerContainerDef = {

        sectionHTML:'<div>' +
                   '<a href="#">Latest News</a>' +
                '</div>' +
                '<div>' +
                    '<a href="#">Services</a>' +
                '</div>' +
                '<div>' +
                    '<a href="#">Support</a>' +
                '</div>'
    };

    return {

            getHeaderHTMLTxt: function(){
                return headerContainerDef.sectionHTML;
            },

            getFooterHTMLTxt: function(){
                return footerContainerDef.sectionHTML;
            }
    };
})();

正如你所见,我们创建了一个 GlobalData 模块来保存我们的应用程序级数据。

我相信你现在一定非常熟悉这个机制是如何工作的了。我们创建了一个立即执行函数表达式(IIFE)来返回一个对象,作为我们私有命名空间(模块)的接口。这个对象提供了两个方法:getHeaderHTMLTxtgetFooterHTMLTxt

这些方法分别返回两个私有变量 headerContainerDeffooterContainerDef 的属性值。

注意,我们没有提供任何设置这些变量值的方法,并且由于它们是我们模块中的私有变量,我们已经创建了一定程度的封装。因此,保护我们的数据免受外部影响。

headerContainerDef 的属性值是头部片段中 HTML 元素的字符串表示。

这个字符串用于填充我们应用程序视图的头部片段,如下所示:

PageUpdater.updateElement("headerContainer", GlobalData.getHeaderHTMLTxt());

同样,footerContainerDef 的属性值是 Footer 片段中 HTML 元素的字符串表示。

这个字符串用于填充我们应用程序视图的页脚片段,如下所示:

PageUpdater.updateElement("footerContainer", GlobalData.getFooterHTMLTxt());

注意,当我们之前渲染头部和页脚片段时,我们使用了全局变量(如 headerContainerDef)来获取页面片段所需的字符串。

然而,在我们的新实现中,我们使用 GlobalData 接口方法来访问这些字符串。

这里需要记住的一个重要观点是我们已经实现了两个独立的模块(PageUpdaterGlobalData),它们共同工作以构建我们应用程序的构建块。随着我们进入下一章,我们将进一步利用应用程序模块之间的协作,并在此基础上实现我们应用程序的完整功能。

此外,请注意,我们的GlobalData模块仅负责向应用程序的其他部分提供数据,而PageUpdater仅负责更新页面片段。

另一个需要考虑的微妙之处是,我们可以根据需要更改两个模块的内部结构。但是,只要模块的接口不改变,它们仍然可以继续一起工作,而一个模块的内部更改不会对另一个模块的功能产生影响。

创建一个日志模块

作为进一步练习,我们将在本章中创建另一个模块。此模块将负责我们应用程序与日志消息相关的所有必要工作。

问题是,这个模块属于哪个主要应用程序部分?

要回答这个问题,我们需要进行简单的分析。首先,由于此模块没有与之关联的任何视图,我们需要将其视为功能模块而不是组件。

其次,此模块不会为我们存储任何应用程序数据。因此,此模块应属于我们应用程序的控制器部分。如前所述,由于我们将应用程序的控制器部分视为与核心模块相同,因此日志模块应属于核心模块。

记住,核心模块由许多较小的模块(子模块)组成,这些模块共同构建核心。

让我们按照以下方式创建我们的简单日志模块:

var LoggingHandler = (function(){

    // module private variables
    var defaultHelloMsg = "this is just to say Hello to the users!",
    theInterface ={};

    // privileged method 
    theInterface.logError = function(errorMsg){
        console.error(errorMsg);
    };
    // privileged method 
    theInterface.logInfo = function(infoMsg){

        if(!infoMsg){
            infoMsg = defaultHelloMsg;
        }
        console.log(infoMsg);
    };

    return theInterface;

})();

此模块定义结构与迄今为止我们所看到的其他模块定义几乎相同,但有一些细微差别。

我们在这里使用了不同的技术,并从 IIFE 返回了一个命名对象而不是匿名对象。我们把这个对象命名为theInterface。最初,这个对象是一个空对象,但后来我们通过添加两个方法属性来增强这个空对象。其中一个记录信息消息,另一个将错误消息记录到控制台。

如果没有信息消息传递给theInterface.logInfo方法,则会记录一个默认消息以问候用户。

下面是如何调用此模块接口上的方法:

LoggingHandler.logError("this is a test for logging errors!")
LoggingHandler.logInfo();

这就是我们从模块返回匿名对象时调用接口方法的方式。

我在这里使用了从模块返回命名对象的技术,以向您展示我们不必总是从 IIFE 返回匿名对象来创建模块接口,并且有不同方式实现模块模式。我们将在本书的后续章节中进一步探讨这些技术。

小贴士

我们模块的实际用途

由于我喜欢将概念与其实际方面结合起来进行展示,我建议您下载本章的配套代码。查看这些简单模块的实际运行情况,尽可能多地与代码互动。这将帮助您更加熟悉这些概念,并在您的脑海中巩固它们。我的建议同样适用于本书的所有其他章节。

摘要

在本章中,我们将讨论从模块的概念设计转向其实施的实践方面。我们首先审视了我们预期应用的高层次需求,然后考虑了可能需要的模块类型以满足这些需求。

我们简要讨论了 MV*设计模式,并结构化我们的应用以遵循此模式。然后,基于我们的模块在设计中扮演的功能和角色,我们将它们分类为控制器、视图或模型组件的一部分。

我们应用的前端视图是通过首先创建一个index.html页面骨架,然后基于对象定义填充其主要片段,使用核心模块生成的。

我们还为我们的应用创建了一个安全的全局数据存储库,作为我们架构中的模型组件。

还展示了模块如何协作以完成任务并提供应用级功能的一个示例。

在下一章中,我们将扩展我们的应用模块,并采用模块化设计方法进一步构建我们应用的基础组件。

模块增强

在上一章中,我们开始为我们的虚构Images Inc.网络应用程序创建简单的模块。在这一章中,我们将使用一种称为模块增强的方法,对我们的一个已构建的模块添加更多功能。

这种方法允许我们在不改变原始实现的情况下扩展我们的模块。有几种不同的技术可以用来实现模块增强,我们将在本章中介绍其中的一些。

当我们在有许多代码库贡献者的项目上工作时,模块增强可以非常有用。这类项目通常要求我们通过添加新代码和功能来扩展我们的模块,这些代码和功能是其他开发者已经开发的。

本章我们将涵盖的概念包括:

  • 模块增强背后的理念

  • 松散增强

  • 紧密增强

  • 生成我们应用程序的内容区域

  • 模块增强和脚本加载顺序

  • 不同增强技术的优缺点

第五章:模块增强的原则

正如你在上一章中看到的,我们创建了专门的模块来为我们执行一些特定的任务。然而,随着我们继续前进并进一步开发我们的应用程序,我们将需要从我们的模块中获得更多功能,我们还将需要额外的专用模块。

当你在处理一个大项目时,有许多开发者正在处理应用程序的不同部分是非常正常的。也有可能许多开发者一起工作在应用程序的同一部分,这需要一种无缝的方法来结合他们的努力以及他们添加到应用程序中的功能。

让我们以前一章中的GlobalData模块为例。这个模块负责存储和缓存应用程序级别的数据。然而,在其当前状态下,它只包含非常有限的应用程序级别数据。我们可以合理地假设,当不同的开发者正在处理应用程序的其他部分时,他们也需要在同一个模块中存储特定的应用程序级别数据。

做这件事的一种方法是通过手动修改GlobalData模块并添加更多数据和接口。但我们需要记住,修改模块的人越多,模块代码中出问题的可能性就越高,以及意外地以不理想的方式修改重要的应用程序级别数据。

另一方面,由于各种原因,原始模块文件可能无法被一些开发者访问,这可能会阻碍我们应用程序的协作开发。

如果我们能想出一个方法,让每个开发者都能在不修改或要求修改原始模块代码的情况下向原始模块添加所需的功能,那会怎么样呢?

模块增强使我们能够以非常稳健的方式做到这一点。正如其名所示,这个概念是关于向原始模块添加功能(属性),而不直接更改原始模块的代码。一般思路是我们可以在运行时导入原始模块并按需向其添加新功能。

实现模块增强

假设我们有一个名为ModuleA的模块,作为一个开发者,你想要向这个模块添加更多功能。然而,由于某种原因,你决定在一个完全独立的模块中实现这个新功能,然后动态地用这个新模块的所有数据和功能增强原始模块。你可以像下面这样实现:

var ModuleA = (function(coreModule){
    var someData = "this is some data to be used later";
    coreModule.someMethod = function(){
        return someData;
    };

    return coreModule;
})(ModuleA);

正如你所看到的,我们在这里再次使用了模块模式,因为我们的意图是以模块化的方式添加新功能。

在这个立即执行函数表达式(IIFE)中,返回了对coreModule对象的引用。然而,这里有一个重要的事情需要记住。我们将ModuleA作为参数传递给我们的匿名容器函数。此外,属性someMethod被添加到传入的coreModule中,实际上这是一个对ModuleA的引用。

因此,在这个函数执行结束时,ModuleA有一个新的属性方法someMethod,它可以访问someData变量的值。

在这里,我们有一个假设。我们假设ModuleA作为一个对象确实存在,如果不是这样,当我们运行前面的代码时,我们将得到一个执行错误。你很快就会看到我们如何解决这个问题,但就目前而言,让我们将这个增强概念应用到我们的应用程序中,以扩展ImagesInc_GlobalData模块。

注意

模块命名约定

随着我们越来越多地参与到应用程序中的模块实现,最好为我们的模块使用更具体的命名。一般来说,为我们的模块使用尽可能具体的名称是一个好主意。这最小化了我们的应用程序模块和可能加载到应用程序中的第三方模块之间的命名冲突的可能性。因此,随着我们继续前进,我们将为所有模块名添加ImagesInc_前缀,使名称更具体地反映我们的应用程序。

值得注意的是,一些开发者选择在代码中用全大写字母命名他们的模块,作为一种约定。在我们的应用程序中,我们不会使用这种约定。在你的编码实践中,是否使用全大写字母为你的模块命名,应该由你和你的团队决定。这样,你可以建立一套标准,所有团队成员都必须遵守。

简单增强 ImagesInc_GlobalData

如您所记得,我们使用 ImagesInc_GlobalData 模块(之前命名为 GlobalData)为我们存储应用程序级别的数据。此模块还提供了一些接口,以便应用程序的其他部分可以访问我们存储在此模块中的私有数据。

让我们创建另一个 JavaScript 文件,为这个模块添加更多数据和新的接口。我们可以将此文件命名为 Modules_2.js,并将其添加到在运行时由主页面加载的 JavaScript 文件列表中,如下所示:

<script type="text/javascript" src="img/Modules.js" ></script>
<script type="text/javascript" src="img/Modules_2.js" ></script>

如您所见,此文件被添加的方式与我们在 index.html 中添加任何其他 JavaScript 文件的方式相同。然而,在前面代码中需要注意的一点是两个模块文件被添加的顺序。这个顺序很重要,我们将在稍后进行更多讨论,但到目前为止,请记住在我们的应用程序中,ImageInc_GlobalData 模块(位于 Modules.js 中)首先被加载。然后,位于 Modules_2.js 中的代码将在之后加载,并为该模块添加更多功能。

我们需要在 Modules_2.js 中添加以下代码行:

(function(coreModule){

    coreModule.someText = "this is a test for module augmentation";
    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };

})(ImagesInc_GlobalData);

在这里,我们使用匿名函数通过 IIFE 创建了一个 命名空间。我们还通过参数将 ImagesInc_GlobalData 对象(模块)的引用传递给这个函数。

在这个匿名函数内部,我们向传入的对象引用 coreModule 添加了两个属性。这意味着我们的 ImagesInc_GlobalData 现在增加了两个新属性。让我们通过以下测试来验证这一点:

// displays "this is a test for module augmentation"
ImagesInc_GlobalData.getExtendedModuleMsg(); 

当我们在 ImagesInc_GlobalData 对象上调用 getExtendedModuleMsg 方法时,我们看到代码运行正常,预期的消息显示在控制台。

由于 ImagesInc_GlobalData 模块最初没有 getExtendedModuleMsg 方法,现在看起来它已经通过这些新属性进行了增强。

这个简单的例子演示了我们可以如何在不直接修改该模块中的代码的情况下增强我们的原始模块。我们还看到,增强可以在一个单独的文件中进行,这意味着不同的开发者可以在不直接访问原始模块的代码文件的情况下,为我们原始模块添加更多功能。

当然,这种类型的增强也可以在原始模块和增强代码都在同一文件中进行时完成。拥有选择我们想要采取的方法的灵活性是好的:要么将原始模块及其增强代码放在单独的文件中,要么放在同一文件中。

我们将很快更深入地讨论这种增强类型,甚至给它一个技术名称。

模块增强中事物的顺序

当我们将 Modules_2.js 文件添加到我们的 index.html 文件中时,我提到我们需要在加载 Modules.js 文件之后加载此文件,以确保一切正常工作。

这个原因并不难发现。如果这些文件的加载顺序被颠倒,以及随后的代码执行顺序,我们将尝试向一个尚不存在的对象(ImagesInc_GlobalData)添加属性,这将导致代码执行错误。

有一些方法可以解决这个问题,这把我们带到了下一个主题,松散增强

模块的松散增强

当我们尝试使用之前讨论的增强技术增强一个模块时,我们将模块的引用传递给代码的另一个部分,该部分负责执行增强工作。

我们如何向尚未加载或创建的对象添加功能?

当我们的模块(文件)以异步方式加载时,这个问题变得非常重要,我们无法确保在增强模块的代码之前加载我们的原始模块。

JavaScript 美丽而强大的一个方面是它能够在代码执行期间动态地向对象添加属性,任何时候都可以。这允许我们在模块加载之前添加功能或修改原始模块的实现,只要我们提供一个临时对象以替代模块。这个临时对象将在模块加载后(或者更准确地说,它变成了原始模块的一部分)被添加到原始模块中。

如果听起来很复杂,实际上它比你想象的要简单。让我们回顾一下之前的增强代码,并仔细检查一下:

(function(coreModule){

    coreModule.someText = "this is a test for module augmentation";
    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };

})(ImagesInc_GlobalData);

我提到,为了使增强正常工作,我们需要在原始模块加载后加载这段代码,这段代码负责增强 ImageInc_GlobalData 模块。否则,将抛出一个代码执行错误。虽然这一点仍然是正确的,但如果我们使用一个空对象来补偿 ImagesInc_GlobalData 在应用程序中尚未存在的情况,会怎样呢?

如您可能所知,我们可以如下调用一个函数:

someFunc(someParameter || someotherParameter);

当我们这样做时,我们是在告诉 JavaScript 解释器在调用 someFunc 时传递 someParameter,如果它有值的话;如果没有,就传递 someotherParameter 到函数中。这就是前面代码片段中 || 运算符的工作方式。

我们可以使用相同的技巧,并将一个空对象传递给我们的匿名函数,如下所示:

(ImagesInc_GlobalData || {});

我们告诉解释器在调用函数时传递我们的 ImagesInc_GlobalData 对象的引用,如果对象存在的话,或者传递一个空对象的引用,这个空对象将暂时替换我们的原始模块。

ImagesInc_GlobalData 的松散增强

让我们重新编写之前为 ImagesInc_GlobalData 编写的增强代码,如下所示:

var ImagesInc_GlobalData = (function(coreModule){

    coreModule.someText = "this is a test for loose module augmentation";
    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };

    return coreModule;

})(ImagesInc_GlobalData || {});

在前面的代码中,我们正在调用我们的匿名函数,并将 ImagesInc_GlobalData 的引用传递给它,如果 ImagesInc_GlobalData 存在的话。否则,我们将一个匿名空对象的引用传递到函数中。

我们增强的代码仍然向传入的对象添加新属性;然而,这次它将coreModule的引用返回给ImagesInc_GlobalData变量。

为了让一切正常工作,我们还需要按照以下方式修改我们的原始ImagesInc_GlobalData

var ImagesInc_GlobalData = (function(module){

    var headerContainerDef = {

    sectionHTML :  '<div class="logo_titleClass" >' +
                '<a href=""><img src="img/ImagesIncLogo.png" alt="Company Logo" style="max-height:100%;"></a>' +
                '<div class="siteTitleClass">Images Inc.</div>' +
        '</div>' +
        '<nav role="navigation" itemscope itemtype="https://schema.org/SiteNavigationElement">' +
            '<h1 class="hiddenClass">Main Navigation</h1>' +
            '<ul class="navmenuClass" >' +
                '<li><a href="#" class="active">Home</a></li>' +
                '<li><a href="#">Our Company</a></li>' +
                '<li><a href="#">Pricing</a></li>' +
                '<li><a href="#">Contact Us</a></li>' +
            '</ul>' +
        '</nav>' 
    };

    var footerContainerDef = {

        sectionHTML:'<div>' +
                   '<a href="#">Latest News</a>' +
                '</div>' +
                '<div>' +
                    '<a href="#">Services</a>' +
                '</div>' +
                '<div>' +
                    '<a href="#">Support</a>' +
                '</div>'
    };

    module.getHeaderHTMLTxt= function(){
 return headerContainerDef.sectionHTML;
 };

 module.getFooterHTMLTxt= function(){
 return footerContainerDef.sectionHTML;
 };

    return module;

})(ImagesInc_GlobalData || {});

如您所见,我们对代码做了一些修改。

如果您还记得,我们以前在原始模块中创建了一个匿名对象,并像这样返回其引用:

return {

            getHeaderHTMLTxt: function(){
                return headerContainerDef.sectionHTML;
            },

            getFooterHTMLTxt: function(){
                return footerContainerDef.sectionHTML;
            }
    };

然而,在我们的新增强实现中,我们直接将模块接口方法添加到传递给匿名函数的模块对象中。同时,传递给我们的匿名函数的模块对象要么是ImagesInc_GlobalData的引用,要么是空对象的引用。

这里还有一个需要注意的微妙之处。增强代码和我们的原始模块代码都返回了ImagesInc_GlobalData变量的对象引用,这一点非常重要。

为了解释这一点,我们需要深入探讨。在我们的应用程序中,有时模块代码和增强它的代码是异步加载的。这意味着我们无法提前确定哪个代码先被执行。当我们从原始模块和增强代码中返回我们的对象引用时,我们可以确信,无论代码执行顺序如何,我们的模块都会被正确增强。

在我们的例子中,如果全局命名空间中已经存在一个ImagesInc_GlobalData对象,我们就用新属性增强它;如果没有,我们就创建它并添加新属性。这就是为什么在这两种情况下(原始模块代码和增强代码),我们都会用以下调用执行我们的 IIFE:

(ImagesInc_GlobalData || {});

这使我们能够以非严格顺序创建或增强我们的模块,因此术语称为松散增强

当然,这种技术的最大优点是我们不必担心哪个文件先被加载;无论哪种方式,我们的模块都会按照预期创建和增强。

请记住,在这两种情况下,我们需要确保创建相同的全局变量ImagesInc_GlobalData,这样当我们检查全局命名空间中该对象的存在时,就能传入正确的引用。

是时候对我们的代码进行测试了。在做出之前提到的修改后,我们可以运行以下代码行:

// displays "this is a test for module augmentation"
ImagesInc_GlobalData.getExtendedModuleMsg(); 

如您所见,控制台显示了正确的消息。现在,让我们看看如果我们改变应用程序中 JavaScript 模块文件加载顺序会发生什么。

首先,我们在index.html文件中做出以下更改:

<script type="text/javascript" src="img/Modules_2.js" ></script>
<script type="text/javascript" src="img/Modules.js" ></script>

这是为了确保我们的增强代码首先被加载。然后我们执行与之前相同的代码行:

// displays "this is a test for module augmentation"
ImagesInc_GlobalData.getExtendedModuleMsg();

我们可以看到,无论哪个先加载和执行,我们的原始模块和增强代码都在按预期工作。

测试 ImagesInc_GlobalData 数据封装

我们可以通过运行以下代码来测试并查看封装和隐私在我们的模块中是如何被保留的。这可以通过以下代码实现:

try{
        console.log(ImagesInc_GlobalData.headerContainerDef.sectionHTML);

    }catch(e){
        ImagesInc_LoggingHandler.logError('could not access the property');
    }

这段代码将在控制台显示无法访问属性,这证实了我们的原始模块封装仍然有效,正如我们所期望的那样。

注意

关于项目代码的注意事项

像往常一样,我强烈建议您从本书的配套网站上下载本章的相关代码。在本章的代码中,我创建了一个名为AppTester.js的新文件,用于测试我们在修改和增强应用程序时的应用程序。我建议在开发阶段每次运行应用程序时都进行这种测试方法。这样我们可以确保我们在一个部分中的更改不会对应用程序的其他部分造成任何问题。这也与测试驱动开发TDD)方法相一致,但以一种非常基础的方式。

模块的紧密增强

到目前为止,在本章中,我们已经讨论了模块增强的一般概念,并且也介绍了松散增强技术。现在是时候探索在模块增强中使用的另一种技术,称为紧密增强

你可能会想知道紧密增强是否是松散增强的反面,你的想法是正确的,但有一些考虑因素,我们稍后会讨论。

当我们希望在文件加载和代码执行中强制执行一个特定的顺序时,我们会使用紧密增强来为我们的模块添加属性(功能),因此它相对不太灵活。这种增强通常用于确保原始模块中某个特定的属性对我们增强的代码是可用的。

ImagesInc_GlobalData 的紧密增强

考虑到上一节中用于ImagesInc_GlobalData模块的增强代码,当时正在使用松散增强。如前所述,由于我们将ImagesInc_GlobalData或一个空的匿名对象传递到我们的 IIFE 中,我们可以以我们喜欢的任何顺序加载我们的原始模块和增强代码。

这如下所示:

var ImagesInc_GlobalData = (function(coreModule){

    coreModule.someText = "this is a test for loose module augmentation";
    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };

    return coreModule;

})(ImagesInc_GlobalData || {});

然而,这也意味着,如果我们想覆盖原始模块的一个属性,这取决于什么代码先被加载和执行(原始模块或增强代码),我们的“覆盖”可能会被另一段代码无意中意外地覆盖。

为了更好地理解这一点,让我们创建另一个文件,Modules_3.js,并将以下代码添加到其中:

var ImagesInc_GlobalData = (function(coreModule){

    coreModule.someText = "this is a test for overriding module properties with loose augmentation";
    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };
    return coreModule;

})(ImagesInc_GlobalData || {});

此外,让我们按照以下方式在我们的应用程序中加载此文件:

<script type="text/javascript" async src="img/Modules_3.js" ></script>
<script type="text/javascript"   src="img/Modules_2.js" ></script>
<script type="text/javascript"   src="img/Modules.js" ></script>

在这里,我们正在加载两个文件(Modules_3.jsModules_2.js),它们增强了我们的原始模块。Modules_2.jsModules.js之前被加载,但Modules_3.js可以以任何顺序加载,因为我们为这个文件使用了<script>标签上的async属性。这个属性告诉浏览器以它可以的任何顺序加载文件。

这两个增强代码都向原始模块添加了相同的属性coreModule.someText。然而,根据哪个代码首先被加载和执行,控制台只会打印出两段文本中的一段。

我们可以通过执行以下代码来测试这一点:

ImagesInc_GlobalData.getExtendedModuleMsg();

控制台将显示以下之一:

  • 这是一个模块增强的测试(来自Modules_2.js

  • 这是一个使用松散增强覆盖模块属性的测试(来自Modules_3.js

请记住,在这种情况下,我们无法控制代码执行完成后coreModule.someText属性将具有哪个字符串值。这是因为我们不知道哪个增强器代码将被最后加载和执行。这也意味着,通过使用松散增强技术和异步加载,增强器代码的优先级是在运行时动态确定的,而不一定是我们所认为或期望的顺序。

基于此,如果我们打算让coreModule.someText的值被Modules_3.js中的代码覆盖,那么我们无法确保这种覆盖会发生。

注意

模拟异步脚本加载

为了模拟我们的增强器的异步加载,您可以在应用本章节所附代码中连续几次重新加载index.html页面。您会看到控制台显示的消息可能会不时地发生变化。显示的消息取决于浏览器首先加载的是哪个文件,Modules_2.js还是Modules_3.js

另一方面,紧密增强保证了代码执行的顺序,因此我们的模块是如何被增强的。通过使用这种技术,我们可以确信当模块属性被覆盖时,它将按照我们期望的顺序进行,结果将符合预期。

这种保证是由于我们没有选择,只能按正确的顺序加载我们的模块及其增强代码,否则将生成代码执行错误。

让我们通过修改Moduels_3.js中的代码来检查这一点:

var ImagesInc_GlobalData = (function(coreModule){

    if(!coreModule){
        ImagesInc_LoggingHandler.logError('coreModule was not found to be augmented!');
        alert('coreModule was not found to be augmented!');
        return false;

    }

    coreModule.someText = "this is a test for overriding module properties with TIGHT augmentation";
    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };

    return coreModule;

})(ImagesInc_GlobalData);

在这个版本的增强代码中,我们不再向 IIFE 传递一个空匿名对象的引用。因此,如果ImagesInc_GlobalData模块尚未被加载,我们无法使用任何新属性对其进行增强。

注意,在前面代码的开始处,我们正在检查coreModule是否存在,如果不存在,我们使用我们的ImagesInc_LoggingHandler模块将错误记录到控制台。我们还在浏览器中使用一个警告框来确保情况确实引起了用户的注意(在生产代码中尽量不要使用警告框,因为它看起来不够专业;我只是在演示时使用它以便于展示)。

加载ImagesInc_GlobalData增强代码

为了检查紧密增强如何强制执行脚本加载和代码执行的顺序,我们可以按以下方式更改我们的index.html

<script type="text/javascript"   src="img/Modules_3.js" ></script>
<script type="text/javascript"   src="img/Modules_2.js" ></script>
<script type="text/javascript"   src="img/Modules.js" ></script>

正如你所见,我们不再异步加载Modules_3.js文件,它将成为第一个被加载的模块相关文件。考虑到我们已修改此文件中的增强代码,使其仅在模块(ImagesInc_GlobalData)已存在于全局作用域时增强模块,当我们加载页面时,将记录错误消息并在浏览器中显示一个警告框

由于我们现在使用的是紧密增强技术,我们需要在Modules.jsModules_2.js之后加载这个增强代码。这是必要的,这样我们就可以确保ImagesInc_GlobalData模块(对象)已经存在于全局作用域中。

此外,由于我们的意图是使用Modules_3.js中的代码覆盖someText的值,而这个属性是由Modules_2.js中的增强代码添加到模块中的,因此我们需要首先加载Modules.jsModules_2.js。这是唯一能够保证someText的值按预期被覆盖的方法。因此,为了实现适当的覆盖,我们需要按照以下方式修改脚本的加载顺序:

<script type="text/javascript"   src="img/Modules.js" ></script>
<script type="text/javascript"   src="img/Modules_2.js" ></script>
<script type="text/javascript"   src="img/Modules_3.js" ></script>

我们在index.html文件中对脚本顺序的这种重新排列确保了覆盖模块的someText属性值将产生预期的结果。这当然是因为我们的原始模块首先被加载,然后使用Modules_2.js中的增强代码添加someText属性,最后,该属性的值被Module_3.js中的紧密增强代码覆盖。

紧密增强的注意事项

在本节的开头,我提到有几个关于紧密增强的注意事项我们需要记住。

首先,我们实际上并不需要创建一个全局变量来存储实现紧密增强技术的增强代码返回的值。

这是因为这种增强只能在模块已经存在于全局上下文的情况下发生。实际上,以下代码将和之前的版本一样工作得很好:

(function(coreModule){

    if(!coreModule){
        ImagesInc_LoggingHandler.logError('coreModule was not found to be augmented!');
        alert('coreModule was not found to be augmented!');
        return false;

    }

    coreModule.someText = "this is a test for overriding module properties with TIGHT augmentation!!!";
    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };

})(ImagesInc_GlobalData);

其次,在我们覆盖模块中已经存在的属性之前,我们可以通过将其存储在另一个属性中来保留属性的原始值。这使我们能够访问属性的原始值和覆盖值。

让我们在Modules_3.js文件中这样做,如下所示:

(function(coreModule){

    if(!coreModule){
        ImagesInc_LoggingHandler.logError('coreModule was not found to be augmented!');
        alert('coreModule was not found to be augmented!');
        return false;

    }

    coreModule.original_someText = coreModule.someText;

    coreModule.someText = "this is a test for overriding module properties with TIGHT augmentation!";

    coreModule.getExtendedModuleMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.someText);
    };
    coreModule.getExtendedModuleOriginalMsg = function(){
        ImagesInc_LoggingHandler.logInfo(coreModule.original_someText);
    };

})(ImagesInc_GlobalData);

我们可以通过在控制台中运行以下代码来获取someText属性的原始值:

// displays ""this is a test for module augmentation"
ImagesInc_GlobalData.getExtendedModuleOriginalMsg();

如你所见,不仅紧密增强技术是一种覆盖我们模块属性的好方法,它还允许我们保留我们属性的原始(先前)值,以防我们以后需要再次使用它们。

生成我们应用程序的内容区域

如果你已经阅读了本章的前几节,你现在对模块增强应该有相当好的理解。你也知道一些使用增强技术向我们的模块添加动态属性的技术。

目前,我们的应用程序(Images Inc.)有代码可以动态生成我们页面头部和尾部部分。但中间仍然有一个空白区域(内容区域),需要填充内容。

让我们充分利用本章学到的技术,并为我们的应用程序添加动态生成内容区域的功能。

注意,Modules_3.js中的重构增强代码现在如下所示:

(function(originalModule){

    if(!originalModule){
        ImagesInc_LoggingHandler.logError('originalModule was not found to be augmented!');
        return false;  
    }

    //object definition for the index.html content area
    originalModule.mainContentContainerDef = {
         sectionHTML: (function(){
                var htmlStr = "";

                for(var i=0; i<=15; i++){
                    htmlStr += '<div class="productDiv"></div>';
                }
                return htmlStr; 
            })()

    }; 

    originalModule.getContentAreaHTMLTxt= function(){
        return originalModule.mainContentContainerDef.sectionHTML;
    };

})(ImagesInc_GlobalData);

这里发生了一些事情。正如你所看到的,我们正在使用紧密增强来增强我们的ImagesInc_GlobalData模块。我们还为这个模块创建了一个新属性,originalModule.mainContentContainerDef,它包含页面内容区域的对象定义。由于内容区域使用重复的矩形结构来显示页面上的所需图像,我们使用了一个内部立即执行的函数表达式(IIFE),它创建了这个结构并将其存储在内容区域对象定义的sectionHTML属性中,作为一个字符串。

为了让外部代码能够访问这个字符串,并能够渲染我们index.html页面的内容区域,我们创建了originalModule.getContentAreaHTMLTxt方法。这个方法被添加为我们的原始ImagesInc_GlobalData模块的接口。

当我们现在加载应用程序的index.html页面时,它显示如下:

生成我们应用程序的内容区域

当然,目前我还在使用之前看到的颜色编码方案来为我们的各种页面片段着色。为了便于识别,图像框显示为粉色(如果你是在彩色书中阅读)。

注意

需要注意的一个警告

在我们的增强代码中,我们没有使用闭包,就在我们的模块中添加了mainContentContainerDef属性。因此,这个属性没有使用与headerContainerDeffooterContainerDef相同的封装和私有访问类型。这些属性在我们的原始模块中定义为私有变量,并且只能通过我们为外部使用创建的接口来访问。当然,我们使用闭包创建了这种私有访问。

如果你希望限制模块对这些内容的访问,最好在原始模块中定义属性(使用闭包),而不是使用增强技术将它们添加到模块中(除非你在增强代码中也使用闭包)。

摘要

在本章中,我们介绍了模块增强是什么,并探讨了几个不同的技术,称为松散增强和紧密增强,来实现模块增强。

我们看到了每种方法在不同情况下以及针对不同应用需求的使用方式,以及与每种技术相关的优缺点。

根据所使用的增强技术,还讨论了脚本应加载的顺序。

一旦我们很好地掌握了这些增强实现,我们就重构了我们应用程序代码的一部分,以增强ImagesInc_GlobalData模块。随后,我们生成了index.html页面的内容区域,并用容器填充它,这些容器将未来托管我们应用程序中的图像。

在下一章中,我们将探讨一些模块化设计中的更多技术,这些技术将使我们能够根据需要扩展和克隆我们的模块。

第六章 克隆、继承和子模块

在上一章中,我们探讨了如何使用松散和紧密增强来扩展和修改模块。

在本章中,我们将探讨一些其他技术,这些技术在我们处理模块时可能很有用,可以帮助我们扩展和修改模块的功能。根据你应用程序的需求以及你的个人偏好,这些技术中的某些或全部可能对你有用。

本章的开头将概述一些创建基于其他对象的某些方法,你可能已经熟悉。然后,我们将探讨如何使用对象克隆来创建其他对象的副本。

我们还将创建我们应用程序的核心模块之一,该模块将用于根据需要克隆对象。

在本章中,我们将涵盖:

  • 模块克隆及其使用场景

  • 克隆对象的多种方法

  • 使用继承扩展模块

  • 使用子模块向已创建的模块添加功能

  • 内部私有状态及其在扩展模块时的影响

克隆模块

一般而言,克隆对象,特别是克隆模块,是创建原始对象或模块的精确副本。

但在我们讨论克隆之前,让我们考虑一些我们可以创建基于其他对象的新对象的方法。

创建构造函数的实例

基于另一个对象创建对象的一种方法是通过创建构造函数的实例。

如你所知,当我们使用 JavaScript 中的构造函数创建一个对象实例时,我们使用new关键字。创建的实例位于与构造函数不同的内存地址。当我们将属性分配给构造函数的prototype属性时,构造函数的所有实例都将共享这些prototype对象的属性。

让我们来看一个简单的例子,并回顾基于构造函数创建对象。

考虑以下代码片段:

function testConstructor (){

    this.someValue = "Value in the constructor function";

}

testConstructor.prototype.testFunc = function(){

    console.log(this.someValue);
};

如你所见,我们已经向我们的构造函数的prototype对象添加了一个属性,它将this.someValue属性的值记录到控制台。

现在,我们创建我们构造函数的两个实例,如下所示:

var firstInstance = new testConstructor();
var secondInstance = new testConstructor();

接下来,我们在控制台中运行以下代码:

// displays "Value in the constructor function"
console.log(firstInstance.someValue);
// displays "Value in the constructor function"
console.log(secondInstance.someValue);

我们可以看到,someValue属性的相同值将显示在两个实例中。当然,如果我们像以下这样为每个实例的someValue属性更改值:

firstInstance.someValue = "value for the firstInstance";
secondInstance.someValue = "value for the secondInstance";

并记录属性的值:

// displays "Value for the firstInstance"
firstInstance.testFunc();

// displays "Value for the secondInstance"
secondInstance.testFunc();

每个实例的someValue属性值将不同。在这里,每个实例都有其自己的someValue属性副本,但它们都共享相同的方法(testFunc),因为所有位于prototype对象上的属性都是构造函数所有实例共享的。

我们还应该记住一件事。在创建构造函数的实例之后,如果我们向构造函数添加新的属性,这些实例将无法访问这些新属性。

考虑以下代码:

testConstructor.newProperty = "this is a new property but not shared";

// displays undefined
console.log(firstInstance.newProperty);

// displays undefined
console.log(secondInstance.newProperty);

如你所见,没有任何实例可以访问在构造函数 之后 添加到构造函数中的这个新属性。

如果你认为我们可以直接在构造函数本身上定义 prototype 对象上的属性(在我们的例子中是 testFunc),你是正确的,但有一个前提条件!

这样做意味着每次我们创建对象的实例时,我们都会创建不必要的属性副本,而这些属性对所有对象实例都是相同的。因此,我们会在代码中创建不必要的开销。

当我们拥有对所有构造函数实例(在大多数情况下,这适用于方法)都相同的属性时,这是一个好的实践,它们应该定义在 prototype 对象上,并由所有实例共享。我们在示例中通过将 testFunc 方法添加到 prototype 对象上,而不是在构造函数本身上定义它,来实现这一点。

当涉及到构造函数和基于这些对象创建其他对象(实例)时,我们还需要考虑另一件事。构造函数中的私有属性会发生什么?

让我们考虑以下代码片段:

function testConstructor (){
    this.someValue = "Value in the constructor function";
    var privateValue = "no instances will have a copy of me";
}

私有变量 privateValue 不会被复制到对象的实例中,也不会被共享。

我们可以如下进行测试:

var firstInstance = new testConstructor();
// displays undefined.
console.log(firstInstance.privateValue);

那么,我们如何从基于此对象创建的对象中访问这些私有属性呢?

当我们谈到克隆模块时,我们将讨论如何提供对这些私有成员的访问。

使用赋值运算符进行对象的复制

当我们在 JavaScript 中使用原始类型时,我们可以很容易地使用赋值运算符将一个变量的值复制到另一个变量,如下所示:

var testVar1 = "This is to be copied";
var testVar2 = testVar1;

在前面的代码中,我们只是将一个变量的字符串值赋给另一个变量,这里没有神秘之处,但这对两个对象来说是如何工作的呢?

让我们如下进行测试:

var testObj1 = { testValue : 5};
var testObj2 = testObj1;

testObj1.newValue  = "this is a new value";

// displays "this is a new value"
console.log(testObj2.newValue);

在这里,我们使用赋值运算符将 testObj1 赋值给 testObj2,这可能会看起来像是基于另一个对象创建了一个新对象。

然而,正如你所看到的,当我们为 testObj1 创建一个新属性时,即使在将 testObj1 赋值给 testObj2 之后,这个新属性对 testObj2 也是可用的。

原因在于,当我们使用赋值运算符将一个变量(该变量持有对对象的引用)的值赋给另一个变量时,我们实际上是将对象的内存地址赋给另一个变量。这导致两个变量都引用了同一个对象。因此,在上面的代码中,testObj1testObj2 都引用了同一个对象。

虽然有时这种结果可能是期望的,但有时我们需要创建一个与另一个对象完全相同的对象(复制品),但我们希望新对象与原始对象完全独立。那么我们该如何做呢?

实现这一目标的一种方法就是创建原始对象的克隆。

创建对象的克隆

当我们创建一个对象的克隆时,我们的目标是创建原始对象的精确复制品,而新创建的对象不应与原始对象有任何关联。

在 JavaScript 中克隆对象(与其他一些语言一样)并不像你想象的那么简单。虽然存在不同的克隆实现方式,但每种方法都有其优缺点。

如果我们将克隆视为将一个对象的全部属性复制到另一个对象,我们可以将克隆分为两种类型:

  • 浅克隆

  • 深度克隆

浅克隆和深克隆

浅克隆会复制原始对象的所有顶层属性,但如果这个对象包含其他引用类型,则只会复制这些引用类型的引用到新创建的克隆中,而不是实际的引用类型。这种类型的克隆用于当我们希望在新的对象中拥有原始类型的独立副本,但希望原始对象和新的克隆对象共享相同的引用类型时。

另一方面,深度克隆会将原始对象的全部原始类型属性复制到新对象中,同时在新对象中创建原始对象所有引用类型的副本。

重要的区别在于,在新的对象中创建了引用类型的真正副本,而不仅仅是引用(内存地址)。如果我们希望有两个没有任何关联的独立对象,这种类型的克隆是可取的。深度克隆通常被认为比浅克隆慢,并且消耗更多资源。

要实现任何一种克隆类型,我们可以利用以下方法之一:

  • 使用第三方库,如 jQuery 或 lodash

  • 使用 JSON 漏洞黑客技术

  • 创建我们自己的自定义克隆方法

使用外部库进行克隆

jQuery 提供了 extend 方法,使我们能够创建对象的浅克隆和深克隆。

extend 方法的通用格式如下:

jQuery.extend( target [, object1 ] [, objectN ] );

注意

虽然这种方法通常是一个好的克隆对象选项,但它确实有一些限制,例如处理内置 JavaScript 类型时。有关此方法的完整信息,请参阅以下 URL 的 jQuery 文档:

api.jquery.com/jQuery.extend/

另一个可用于对象克隆的好库是 lodash,它也提供了浅克隆和深克隆的功能。lodash 提供的克隆功能似乎适用于大多数情况。

在 lodash 中创建对象深度克隆的一般格式是:

var deep = _.cloneDeep(objects);

注意

然而,在使用 lodash 进行深度克隆时有一些局限性,例如处理 map 和 set。请参阅在线的 lodash 文档:

lodash.com/docs#cloneDeep

使用 JSON 利用进行克隆

使用一种称为 JSON 利用的技术(hack)来克隆对象是一种简单但有效的方法。

理念是将原始对象序列化为字符串,然后使用 JSON 方法将字符串解析为新对象。如下所示:

var cloneObj = (JSON.parse(JSON.stringify(originalObj)));

注意

这种方法使用简单,然而它的功能有限,尤其是在复制函数属性时。

要查看关于使用此技术的良好讨论,请参阅以下 URL:

esdiscuss.org/topic/deep-cloning-objects-defined-by-json

创建自定义克隆方法

当涉及到相对简单的对象克隆时,我们可以创建自己的自定义克隆方法。这种方法通常能满足我们的大部分需求。

考虑以下:

function clone(deep) {
    var newClonedObj = new this.constructor();
    for (var property in this){
        if (!deep){
            newClonedObj[property] = this[property];
        }else if (typeof this[property] == 'object'){
            newClonedObj[property] = this[property].clone(deep);
        }else{
            newClonedObj[property] = this[property];
        }
    }
    return newClonedObj;
}

这个函数接受一个布尔参数,用于执行对象的浅克隆或深克隆。

当需要深度克隆时,会对函数进行递归调用,以复制原始对象的一个属性(引用类型)的属性。注意以下克隆函数中的片段:

else if (typeof this[property] == 'object'){
      newClonedObj[property] = this[property].clone(deep);
}

当然,与之前讨论的方法一样,这种方法也有其局限性,例如处理闭包。然而,它在大多数情况下都做得相当不错。

在下一节中,我们将把这个函数添加到我们应用的一个核心模块中,以便在需要时可以在应用中使用它。

创建 ImagesInc_Utilites 模块

由于工具模块是实现我们的对象克隆代码的好地方,让我们按照以下方式创建我们的ImagesInc_Utilites模块:

var ImagesInc_Utilitizes = (function(){

    var clone = function clone(deep) {

        // create an instance of the object
        var newClonedObj = new this.constructor();

        //copy all properties from the original object
        for (var property in this){
            // if deep flag is not set, just do a shallow copy of properties
            if (!deep){ 
                if(this.hasOwnProperty(property)){
                    newClonedObj[property] = this[property];
                }
            // to make a deep copy, call the function recursively
            }else if (typeof this[property] == 'object' && this.hasOwnProperty(property)){
                newClonedObj[property] = this[property].clone(deep);
            }else if(this.hasOwnProperty(property)){
                //Just copy properties for non objects
                newClonedObj[property] = this[property];
            }
        }

        return newClonedObj;
    };

    // attach the clone function to Object prototype
    var initialize  = (function(){
        Object.prototype.clone = clone;
    })();

})();

在这个模块中,我们实现了我们的克隆函数,该函数负责将一个对象的属性复制到一个新对象中。我还对这个函数进行了一些修改,从你之前看到的版本中。这样做是为了它只会复制对象本身的属性,而不会复制其父对象的属性(如果有的话)。我们可以通过使用 JavaScript 的本地hasOwnProperty方法来实现这一点。

我们已经将克隆函数作为属性分配给了Object.prototype对象,因此应用中的所有对象都可以访问这个方法。

在 ImagesInc_Utilites 模块中测试我们的自定义克隆方法

让我们创建一个测试模块来查看我们的克隆机制是如何工作的。考虑以下代码:

var TestModule = (function(){

    var privateTestValue = "Test for cloning, this property is hidden";

    return {
        publicTestValue: privateTestValue + " but now showing it publicly",

        testFunc : function(){

            var anotherTest= "This property will be cloned";
            return anotherTest;
        },

        getPrivteValue : function(){

            return privateTestValue;
        },

        changePrivateVar : function(){
            privateTestValue = "the private value has been changed";

            return privateTestValue;

        },

        testArray : [1,2,3]
    };

})();

在这里,我们创建了一个简单的模块,它提供了一个受控访问其私有变量的公共接口。

现在,如果我们运行以下代码:

// creating a clone object
CloneModule = TestModule.clone(true);

我们可以创建原始模块TestModule的克隆。

我们可以按照以下方式运行一个简单的测试:

// displays "This property will be cloned"
console.log(CloneModule.testFunc());

如您所见,预期的输出已经显示。

为了验证所有属性都已从我们的原始模块复制到新模块中,我们可以检查两个模块中的所有属性,使用我们浏览器的调试器(我在这里使用的是 Chrome 的调试器)如下所示:

在 ImagesInc_Utilites 模块中测试我们的自定义克隆方法

我们克隆方法的一个重要方面

这个克隆方法还有一个你需要注意的有趣方面。

记住,我们的TestModule设计上有一个隐藏的属性,即privateTestValue。当我们进行克隆时,这个属性会发生什么变化?

好吧,最好的办法是运行一个测试。

在我们的TestModule中,我们有一个方法属性(changePrivateVar),它修改了这个隐藏的属性。所以如果我们运行以下代码:

// displays "the private value has been changed"
console.log(TestModule.changePrivateVar());

我们可以在TestModule中更改这个属性的值到the private value has been changed。现在,让我们看看CloneModule是否可以访问这个值,如果是的话,它是否会保留这个属性的旧值,或者这个值也会为我们的CloneModule而改变?

如果我们运行以下代码片段:

// displays "the private value has been changed"
console.log(CloneModule.getPrivteValue());

我们可以看到,不仅我们的CloneModule可以访问这个属性,而且这个模块的值已经改变。

这表明,由于我们的原始TestModule模块中的闭包,我们的CloneModule也可以访问这个模块的私有作用域,并且它也保留了闭包提供的作用域状态。

这可能,也可能不是我们想要的克隆模块的结果,这取决于我们如何克隆模块。然而,非常重要的一点是要记住,由于我们的原始模块中的闭包,我们的克隆结果与克隆没有嵌入闭包的对象的克隆结果略有不同。

注意

对我们的克隆方法进行更多测试

我在本书的配套代码中包含了一些模块克隆的测试。你可以在AppTester.js文件中看到这些测试。请查看,阅读注释,并修改代码以了解结果如何受到影响。

如你所见,当我们想要创建一个可以访问原始模块闭包上下文的原始模块的副本时,可以使用克隆。这反过来又为我们提供了访问原始模块私有成员的权限。

大多数时候,当我们克隆一个模块时,我们将其用作新模块的基模块。然后我们可以添加更多功能或修改新模块的现有功能,使用我们之前讨论的其他技术之一,例如松散或紧密增强。

这种方法允许我们扩展模块的克隆版本而不是原始模块,从而保护原始模块免受所有更改的影响,同时仍然可以访问原始模块的所有功能。

当然,克隆并不是我们实现这一点的唯一方法,但无论如何,它都是我们工具箱中另一个有价值的工具。

模块中的继承

在第二章中,我们讨论了继承是什么,并探讨了在对象之间创建继承关系的方法。在这里,我们将利用我们在那一章中学到的技术来创建模块间的继承。

通常,我们使用继承来利用我们的基础模块的功能,然后在子模块中添加新功能或修改现有功能。

创建模块间继承有多种方式,在本节中我们将探讨这两种方法之一。

使用__proto__对象的模块继承

在这种继承实现类型中,我们使用子模块的__proto__对象从父模块继承属性。

让我们考虑以下内容:

var Polygon_Module = (function() {

    var sides = 6;
    var name = "Polygon";
    var type = "2D";

    function getSides() {

        return sides;
    };

    function getName() {

        return name;    
    };

    function getType(){

        return type;
    };

    return {
        getSides: getSides,
        getName: getName,
        getType: getType
    };
})();

var Rectangle_Module = (function() {
    var Rectangle = {};
    var sides = 4;
    var name = "Rectangle";
    var color = "blue";

    Rectangle.__proto__ = Polygon_Module;

    Rectangle.getName = function(){
      return name;
    };

    Rectangle.getSides = function(){
      return sides;
    };

    Rectangle.getColor = function(){
        return color;
    };

  return {
      getName: Rectangle.getName,
      getSides: Rectangle.getSides,
      getType: Rectangle.getType
  };

})();

如你所见,我们在这里创建了两个模块:Polygon_Module,它是我们的继承关系中的父模块,以及Rectangle_Module,它是子模块。

在我们的Polygon_Module中,我们创建了私有变量和函数,这些变量和函数除了通过模块的接口外,对外部代码不可访问。

Rectangle_Module的设计方式使其从其父模块(Polygon_Module)继承了一些功能。然后它修改了一些继承的功能,并添加了自己的新功能。

以下代码行是创建两个模块之间继承关系的关键部分:

Rectangle.__proto__ = Polygon_Module;

如所示,我们已经从Polygon_Module传递了一个引用到Rectangle对象的__proto__对象。这使得Rectangle对象能够通过父模块的接口访问在父模块中暴露的所有属性。

让我们看看我们对模块运行以下测试时得到的结果:

console.log(Polygon_Module.getName()); //displays "Polygon"
console.log(Polygon_Module.getSides()); // displays 6
console.log(Rectangle_Module.getName()); // displays "Rectangle"
console.log(Rectangle_Module.getSides()); // displays 4
console.log(Rectangle_Module.getType()); // displays "2D"

上述测试表明,子模块Rectangle_Module具有从父模块继承的所有方法;此外,它还覆盖了一些继承的属性。

注意Rectangle_Module.getType()方法,它没有在子模块中定义或覆盖,但通过父模块的接口被子模块访问。

使用寄生组合的模块继承

另一种在对象之间创建继承的方法,你可能很熟悉,并在第二章中看到了一个例子,JavaScript OOP 重要概念回顾,即寄生组合继承。

如你所回忆,这个想法是我们使用基类的构造函数在子类的构造函数中创建子对象的一个实例。我们还使用基类的prototype对象来获取对基类prototype对象上暴露的所有属性的引用。

为了刷新你的记忆,让我们看一下以下示例:

var Polygon_Module2 = (function() {

    var sides = 6;
    var name = "Polygon";
    var type = "2D";

    function Polygon(){

        this.sides = sides;
        this.name = name;
        this.type = type;
    }

    Polygon.prototype.getSides = function(){

        return this.sides;
    };

    Polygon.prototype.getName = function(){

        return this.name;
    };

    Polygon.prototype.getType = function(){

        return this.type;
    };

    return {
        Polygon: Polygon,
    };
})();

var Rectangle_Module2 = (function(){
    var sides = 4;
    var name = "Rectangle";

    function Rectangle(){

        Polygon_Module2.Polygon.apply(this);
        this.sides = sides;
        this.name = name;
    }

    Rectangle.prototype = Polygon_Module2.Polygon.prototype;
    Rectangle.prototype.constructor = Rectangle;

    var RectangleInstance = new Rectangle();

    return {

        Rectangle: RectangleInstance
    };

})();

在我们模块的这个版本中,Polygon_Module2有一个名为Polygon的构造函数。我们所有的方法也都定义在Polygon类的prototype对象(对象)上。

Polygon_Module2模块还有一个匿名对象作为接口,它持有对Polygon类(对象)的引用。

在我们的子模块中,我们创建了一个名为Rectangle的另一个构造函数,它使用Polygon_Module2中可用的接口来借用Polygon构造函数,如下所示:

Polygon_Module2.Polygon.apply(this);

我们还将Rectangle对象的原型对象设置为指向Polygon.prototype对象,这样我们就可以访问在这个对象中定义的所有方法,如下所示:

Rectangle.prototype = Polygon_Module2.Polygon.prototype;
Rectangle.prototype.constructor = Rectangle;

当然,由于我们已经完全覆盖了Rectangle类的prototype对象,我们需要重置其constructor属性,使其指向正确的对象,在这种情况下是Rectangle

注意,我们已经创建了一个Rectangle对象的实例来启动两个对象之间的继承关系,并正确设置对象上下文。

让我们运行几个测试来验证继承关系,如下所示:

console.log(Rectangle_Module2.Rectangle.getName()); // displays "Rectangle"
console.log(Rectangle_Module2.Rectangle.getSides()); // displays 4
console.log(Rectangle_Module2.Rectangle.getType()); // displays "2D"

如您所见,测试产生了预期的结果。

决定模块继承方法

如果您在思考需要创建应用程序模块之间的继承关系时采取哪种方法(__proto__继承或寄生继承),在我看来,这大部分将取决于个人喜好。

然而,请注意,在我们的第一种方法(使用__proto__继承)中,我们不需要创建子模块的实例来创建继承关系。这意味着少一个函数调用,可能还有一点内存消耗减少,因为没有对象的实例被保存在内存中。

另一方面,请记住,较老的浏览器可能不支持设置对象的__proto__属性。

注意

关于继承的更多信息

如果您想了解更多关于 JavaScript 对象之间继承的一般信息,请参阅第二章,重要 JavaScript OOP 概念回顾

子模块

本章我们考虑的最后一种技术,它也允许我们扩展我们的模块,是使用子模块

子模块本质上是可以独立使用的模块,可以作为宿主模块的属性添加到另一个模块中。有各种方法可以将子模块添加到其他模块中,我们将在本节中介绍这两种方法之一。

让我们继续使用到目前为止一直在使用的形状主题,创建一个Shape模块。我们将把这个模块视为我们的宿主模块。这个模块是所有二维和三维形状的父模块,我们将把我们的Polygon模块添加到它里面。

使用动态属性添加子模块

将子模块作为动态属性添加到模块中很简单,因为我们可以给任何 JavaScript 对象添加动态属性,所以我们可以给宿主模块添加一个属性,该属性指向子模块。

考虑以下:

var Shape = (function(){

        var type = "Any 2D and 3D shape";

        function getType(){
            return type;  
        }

        return {

            getType: getType 
        };

})();

Shape.Polygon = (function() {

    var sides = 6;
    var name = "Polygon";
    var type = "2D";

    function getSides() {

        return sides;
    }

    function getName() {

        return name;    
    }

    function getType(){

        return type;
    }

    return {
        getSides: getSides,
        getName: getName,
        getType: getType
    };
})();

在这里,我们创建了一个子模块Shape.Polygon,并将其作为属性添加到我们的主模块Shape中。

当然,我们可以如下访问主模块和子模块:

console.log(Shape.getType()); // displays "Any 2D and 3D shape"
console.log(Shape.Polygon.getName()); // displays "Polygon"

这是将子模块添加到主模块的最简单方法,但它确实要求在我们可以将子模块添加到其中之前,主模块必须存在于作用域中。

使用异步属性添加子模块

使用异步属性将子模块添加到宿主模块的优点是更加灵活,这意味着在将子模块添加到宿主模块之前,宿主模块不需要被加载。

因此,子模块可以潜在地位于不同的文件中,并且可以在不同的时间(无论是宿主模块加载之前还是之后)以异步方式加载并添加到宿主模块中。

让我们看一下以下代码片段:

var Polygon_Module;

var Shape = (function(mainModule, subModule){

    var Polygon = mainModule.Polygon = mainModule.Polygon || subModule;

    Polygon.description = function(){

        return "submodule has been added to shape module";
    };

   return mainModule;

})(Shape || {}, Polygon_Module ||{});

console.log(Shape.Polygon.description());

这段代码负责将我们的子模块Polygon_Module添加到主模块Shape中。

如您所见,我们向 IIFE 传递了两个参数,一个用于主模块,另一个用于子模块。当它们中的任何一个在执行上下文中还不存在时,我们向 IIFE 传递一个空对象。

在 IIFE 内部,我们检查mainModule是否有属性Polygon,实际上这就是我们的子模块。如果属性确实存在,我们就使用它并向这个子模块添加一个新属性,称为description

如果代码执行上下文中不存在属性Polygon(子模块),我们使用传入的子模块,然后向其添加新的属性description

让我们检查一下我们是否可以按照以下方式访问子模块上的这个新属性:

console.log(Shape.Polygon.description()); // displays "submodule has been added to shape module"

现在,即使我们在执行上下文中将Shape模块(宿主模块)添加到子模块之后,我们仍然可以将子模块添加到这个模块中。我们还可以通过主(宿主)模块提供对子模块属性的访问。

考虑以下代码:

var Shape = (function(module){

        var type = "Any 2D and 3D shape";

        module.getType= function(){
            return type;  
        };

        return module;

})(Shape || {});

我们Shape模块的实现接受一个参数,这个参数可以是空对象或者一个已经存在的Shape模块的引用。这意味着,即使我们的Shape模块已经在之前的 IIFE 中创建,负责将Polygon_Module添加到Shape模块中,我们仍然能够重新定义它。

为了测试这一点,我们可以运行以下代码:

console.log(Shape.getType()); // displays "any 2D and 3D shape"

console.log(Shape.Polygon.description()); // displays "submodule has been added to shape module"

结果证实,无论在应用中哪个先被加载,我们都能访问Shape模块以及我们的子模块的属性。

你可能也会注意到,上述实现与我们之前讨论的松散增强模式非常相似。唯一的真正区别在于,我们将子模块作为一个完全独立的模块添加到我们的主要模块中,而不是仅仅通过添加新属性来增强主要模块。

摘要

在本章中,我们探讨了更多技术,这些技术使我们能够扩展和修改我们的模块。

我们考虑了如何使用各种克隆方法来创建我们模块的副本,并讨论了每种方法的优缺点。我们还探讨了如何在模块之间使用继承,以便子模块可以利用其父模块的功能,并在需要时覆盖其父模块的功能。

在本章的最后部分,我们讨论了子模块,并查看了几种向我们的主要(宿主)模块添加子模块的不同技术。

下一章将更多地关注我们应用程序的整体设计。我们将看到如何为我们的模块创建一个灵活的生态系统,这允许它们相互交互并协同工作,而无需相互依赖。

第七章:基础、沙盒和核心模块

到目前为止,在本章中,我们主要探讨了创建和增强模块的不同技术。然而,我们的焦点一直在于应用程序的较小部分。

在本章中,我们将从整体的角度审视我们的应用程序作为一个完整的运行体。我们将看到其各个部分如何组合在一起,以创建一个强大且灵活的生态系统,从而实现我们的最终目标,即一个可工作的单页应用程序(SPA)。

在阅读本章的不同部分时,请记住,应用程序的所有部分都旨在协同工作,同时仍然遵守模块化设计的可维护性和可扩展性原则。

在本章中,我们将涵盖:

  • 基础模块及其设计

  • 沙盒及其组件沙盒化的原则

  • 核心和相关的模块

  • 组件以及它们如何添加到应用程序中

  • 在我们的应用程序中,即插即用、渐进增强和优雅降级

注意,我们不会深入到事物的编码方面,因为我真的希望您专注于应用程序的架构,而不是被实现的细节所分散注意力。因此,与本章相关的书籍没有项目代码。

我还应该提到,本章和下一章中讨论的架构设计概念是基于我在观看关于该主题的一次演讲后,由Nicholas C. Zakas最初介绍给我的设计原则。他是我的最爱作者和演讲者。虽然我们的实现将略有偏离他的,但我们的架构的精髓将保持与他提出的架构设计相似。

应用程序架构概述

为了为我们的应用程序创建一个真正模块化的设计,我们需要将其分解成更小的功能部分,这样每个部分都将专门负责非常具体的工作。这使我们能够实现关注点和责任分离的原则。

以下图表展示了我们整体的应用程序设计:

应用程序架构概述

如您所见,我们的应用程序由四个主要部分组成:

  • 基础

  • 沙盒

  • 核心

  • 组件

同时,每个主要部分可能由其他更小的部分组成,这些部分被打包在一起以创建主要部分。

让我们从探索应用程序的每个主要部分并查看每个部分提供的具体功能开始。

在本书的用途中,从现在开始,当我提到组件时,我将指的是具有用户界面部分(视图)的模块,用户可以与之交互,例如头部组件。然而,当我提到模块时,我将指的是每个主要部分可能由其组成的具有功能性的模块,这些模块没有直接关联的视图。

随着我们继续前进,定义将变得更加清晰,但你可能决定在自己的项目中以不同的方式引用这些部分。这是可以的,只要它们背后的架构概念保持一致。

此外,时不时地,我会将我们的模块化设计架构称为框架,这应该被理解为将应用的所有各个部分作为一个整体一起考虑。

注意

关于框架这个术语

虽然我有时会使用框架这个术语,但我们的应用程序并不是为了创建通常所说的框架。至少不是在像AngularJSReact这样的框架中使用的意义上。

一般而言,我认为有时第三方框架被过度使用,虽然它们都提供了一些优点,但使用任何类型的第三方框架也都有一些缺点。

这本书的目的是向您展示您如何创建自己的架构设计,这样您就可以轻松地创建、组织和维护您的应用程序代码库,而无需第三方框架。

因此,我希望您将我们的实现视为一个架构设计概念,而不是一个框架。但如果您坚持,您可以将我们在这里设计和创建的内容称为讽刺框架。我说讽刺,因为它实际上并不是一个传统意义上的框架,因为它不强制使用特殊的语法或一系列规则和条例!

描述我们的实现的最佳方式可能是使用术语客户端模块化设计CMD),因为这种架构方法的目标是为我们的应用程序创建一个坚实的基础和灵活的生态系统,而不是作为一个框架。

基础模块

我们将开始探索设计的基础模块。正如其名所示,基础模块为我们应用程序提供最低级别的功能。

这是我们导入并利用第三方库和工具的功能的地方。这些库可以包括 jQuery、Dojo、MooTools 等等。

主要思想是我们可以轻松使用这些库提供的功能,而无需在我们的应用程序和使用的库之间创建紧密的依赖关系。

例如,考虑一下我们需要如何检测浏览器兼容性,以便将事件附加到页面上的元素,如下所示:

if (elem.addEventListener) {
    elem.addEventListener(event, callbackFunc);
} else if (elem.attachEvent) { // For IE 8 and earlier versions
    elem.attachEvent("on" + event, callbackFunc);
}

而我们本可以轻松地通过以下方式让 jQuery 处理这些复杂性:

$(elem).on(event,callbackFunc);

在这里,我们让 jQuery 处理浏览器兼容性问题,我们可以专注于应用程序中其他更重要的事情。另一个例子是在 jQuery 存在时才给我们的元素添加动画。这种方法允许我们在代码中实现渐进增强优雅降级技术。

我们也可以在我们的代码中同时使用这两种实现。这允许我们在 jQuery 没有正确加载时有一个回退方案。

因此,前面的代码可以写成以下形式:

if($) {
    $(elem).on(event,callbackFunc);

} else {
    if (elem.addEventListener) {
        elem.addEventListener(event, callbackFunc);
    } else  if (elem.attachEvent) {    // For IE 8 and earlier versions
        elem.attachEvent("on" + event, callbackFunc);
    }
}   

大多数情况下,最好将低级功能,例如浏览器兼容性问题,留给第三方库,并专注于在我们自己的代码中提供针对我们应用程序的定制功能。

请记住,我们在这里讨论的是第三方库和实用程序包,而不是第三方框架,因为我们的架构目标之一是消除或至少最小化对第三方框架的需求。

将通用库添加到基础模块

正如所述,我们的基础模块加载并提供通用库给我们的应用程序核心模块。这意味着只有核心模块知道代码中使用了哪些基础库,以及这些库的哪些特定功能被利用。

根据基础模块的设计,库要么在应用程序启动时加载,要么在稍后的时间动态加载。基础模块将每个加载和初始化的库的引用传递给核心模块。

由于只有核心模块知道应用程序中使用了哪些库以及使用程度,如果我们决定更改第三方库或它们在应用程序中的使用方式,那么只有核心模块受到影响,其他部分不受影响。

这意味着我们应用程序的其他所有部分将继续按原样工作,无论第三方库的变化如何。以这种方式实现我们的应用程序最小化了替换或删除第三方库对整个应用程序的影响。

当我们导入和使用新库以添加更多功能到我们的应用程序时,这也同样适用。

在下一章中,我们将更详细地探讨如何使用基础模块将 jQuery 添加到我们应用程序的核心模块。

以下是我们应用程序的基础模块如何向核心模块提供第三方库的描述:

将通用库添加到基础模块

沙盒模块

当我们在日常生活中谈论沙盒时,我们通常脑海中浮现的是一个装满沙子的盒子,我们允许孩子们在那里玩耍,做他们喜欢的一切混乱——并且希望只是在那个盒子里。

在我们的模块化架构中,我们利用同样的概念,为我们的组件创造可以玩耍的空间,并使其与其他应用程序的其他部分隔离。

通过以这种方式沙盒化我们的模块,我们消除了应用程序组件与核心模块之间的紧密耦合。

沙盒本质上是一个抽象层,也是我们组件与其他应用程序其他部分之间的一层薄薄的接口。

由于沙盒模块被设计为接口,并提供组件与其他应用程序之间的通信,它被视为合同,因此它不应改变。这样,我们的组件可以确信它们始终可以依赖与应用程序的一致通信层以及核心模块提供的一定功能级别。

这并不是说我们不能向沙盒添加新方法或功能;只是我们不能改变已经存在的内容,并且我们的组件已经依赖于此。

沙盒模块功能

当我们的组件在应用程序中加载时,无论是启动时还是动态加载的任何时间,它们都会获得沙盒模块的一个实例。

沙盒模块为我们组件提供以下功能:

  • 一致接口

  • 安全

  • 通信

  • 过滤

沙盒模块提供的每个服务对我们实现的模块化方面都至关重要。

让我们更详细地探讨每个方面。

沙盒作为一致的接口

正如所述,当核心模块为每个注册的组件提供一个沙盒模块实例时。

此沙盒实例随后充当应用程序其余部分的接口,为组件提供应用程序级别的功能。

当组件需要特定功能时,它不一定需要自己实现。当该功能的实现已经在应用程序中可用时,这一点是正确的。

例如,为了将事件处理器绑定到元素上,每个组件不需要提供自己的实现。组件可以直接请求沙盒模块,然后沙盒模块会请求核心模块将特定的事件处理器绑定到页面上特定的元素。

当然,组件也不必担心与事件绑定相关的浏览器兼容性问题,因为处理这些问题是核心模块的工作。

由于沙盒实例对每个注册的组件都是相同的,我们可以确信沙盒代码中的任何更改都是同时提供给所有组件的。这意味着新功能的添加、错误修复和沙盒模块中的修改只进行一次,然后以统一和一致的方式传播到所有使用沙盒模块的组件。

注意,对沙盒模块的任何更改仍应遵守此模块与应用程序组件之间的先前合同。

沙盒作为安全层

我们应用程序中的组件只知道沙盒模块,并且不允许(或无法)直接与其他应用程序部分通信。

这意味着沙盒确保框架的保护区域不能通过其接口被组件访问。这使得我们能够在核心和其他应用模块的上下文中控制组件被允许执行的操作类型。

沙盒作为通信层

由于沙盒模块是一个薄薄的接口层,它为组件提供了核心模块的公开接口,因此它是组件与整个应用之间唯一的通信路径。

也是通过沙盒模块,组件可以在应用中订阅和发布自定义事件。

注意,对于组件来说,只与整个应用的其他部分保持一条通信路径是很重要的,这样我们才能保持模块化设计的完整性。如果一个组件能够直接与其他组件或应用的其它部分通信,那么它可能会与这些部分紧密耦合,而我们当然希望避免这种紧密耦合。

沙盒作为过滤器

沙盒的设计应该以这种方式进行,不仅能够提供我们想要向组件公开的核心模块的功能,而且还能对组件对核心模块的调用进行简单的错误检查。

例如,考虑以下情况:

getElement : function(elementID){

    if(elementID && typeof elementID === "string"){
        return core.getElement(elementID);
    }else{
        core.log(3,"incorrect parameters passed in; from SandBox.getElement");
    }
}

上述方法已经通过沙盒模块提供给了我们的组件。这个方法进行简单的参数检查,以确保在沙盒模块请求核心模块执行工作之前,参数以及参数的正确类型已经被组件传递给它。

虽然核心模块可能有自己的错误检查,但在将核心模块卷入之前进行一些错误检查总是一个好主意。

实现多个沙盒模块实例

到目前为止,你可能想知道,为什么使用多个沙盒模块实例(每个组件一个)比所有组件使用同一个沙盒对象作为单例更好?

简短的回答是:更好的隔离和性能。我们将在接下来的章节中进一步探讨这个答案。

多个沙盒实例的优势

通常,一个模块通过一个单一的公共接口向外界提供其公开的功能。

然而,我们实现的沙盒模块是不同的。如前所述,我们创建了多个沙盒模块实例,更准确地说,每个组件一个实例。

以下总结了此设计背后的目标:

  • 将沙盒实例相互隔离

  • 为每个组件创建代码执行上下文

  • 性能提升

将沙盒实例相互隔离

在我们关于孩子在沙坑里玩耍的类比中,我提到这种隔离可以包含在盒子中可能产生的混乱。同样,我们希望任何沙盒模块实例可能造成的损害都被包含和隔离。

这意味着,如果我们的组件中有一个做了不希望做的事情,可能会在其沙盒实例中引起问题,这种混乱将被包含在该沙盒模块实例中。不利影响只会影响该组件的功能,而不会影响其他沙盒实例,或任何其他组件。

对于我们来说,设计我们的架构以最大限度地减少由组件问题引起的完全应用程序失败的可能性非常重要。

这至少允许我们的应用程序保持部分功能,这可能是比完全应用程序失败更可接受的结局。

例如,我们应用程序的页脚组件可能停止工作,但用户仍然可以查看我们的图像目录并将它们添加到收藏夹列表中。

创建代码执行上下文

当创建沙盒模块时,它为它的组件设置一个上下文对象。组件可以在需要时使用这个上下文对象来轻松引用正确的执行上下文。如果我们看一个例子,这可能会更容易理解。

考虑以下来自我们应用程序的代码片段:

handleMainContainerClicked: function (e) {

    if (e.target != e.currentTarget) {

        e.preventDefault();
        e.stopPropagation();

        if (e.target.tagName.toUpperCase() === 'IMG') {
            sandBox.contextObj.handleImageClick(e.target);

        } else if (e.target.tagName.toUpperCase() === 'A') {
            sandBox.contextObj.handelFavLinkClick(e.target);
        }

    }
}

上面的函数是在我们应用程序的内容组件中实现的。在这个函数内部,它是页面某些元素点击事件的处理程序,我们需要在内容组件中调用其他相关函数。

我们可以通过使用在组件首次初始化时设置在沙盒实例上的执行上下文来轻松做到这一点。

这是这样做的:

sandBox.contextObj = this;

在代码片段中,this 指的是我们应用程序的内容组件。因此,当我们需要在内容组件的上下文中执行任何函数时,我们只需引用正确的上下文,如下所示:

sandBox.contextObj.handelFavLinkClick(e.target);

如果你现在觉得这不太明白,不要担心。我将在下一章中进一步解释,届时我们将有机会查看它在代码中的完整实现。

目前,你需要记住的唯一一点是,我们可以使用每个沙盒模块的实例来保留它所属组件的引用。这使我们能够轻松访问该组件的执行上下文。

性能改进

当我们为每个组件创建沙盒实例时,我们也把沙盒实例的引用分配给组件视图所在的 DOM 元素。这使得我们在该容器内进行 DOM 操作变得更加高效,因为我们不需要遍历整个 DOM 树来找到组件视图容器内的目标 DOM 元素。

看看下面的代码片段:

getElementInContext : function(elementID){

    if(elementID && typeof elementID === "string"){
        return core.getChildOfParentByID(containerElemContext,elementID);

    }else{

        core.log(3,"incorrect parameters passed in; from SandBox.getElementInContext");
    }  

}

在这个片段中,我们正在尝试找到容器元素中的一个子元素,其中组件的视图已经被渲染。

当组件在核心中注册时,核心创建了一个沙盒实例,并返回一个包含沙盒实例的组件容器元素的引用。

例如,在我们应用的头部组件创建后,所有与头部相关的 DOM 元素都位于一个承载它们的 main div 元素容器内。头部组件的沙盒实例持有这个 div 容器的引用。

当头部组件需要访问与其视图相关的任何 DOM 元素时,它只需在其容器 div 元素内部查找所需的元素,而不是遍历整个 DOM 树来找到该元素。

这使得查找所需元素变得更快,因为我们不需要在整个 DOM 中搜索它。

如果你对此不完全清楚,我们将在下一章中再次介绍,当我们更深入地检查代码时。

现在,只需注意沙盒实例使我们能够快速直接地访问组件渲染的 DOM 元素,这反过来又允许进行与组件相关的更多优化的 DOM 操作任务。

核心模块

核心模块实际上是我们的应用程序的大脑。它是应用程序的重负载工作发生的地方,真正的魔法在这里发生。

核心模块负责实现应用级别的业务逻辑,并在组件需要相互通信时提供组件之间的桥梁。当然,同时保持应用的松耦合特性。

核心模块还利用了由基础模块加载的第三方库提供的功能,以创建一个功能一致的应用程序。

如果你熟悉名为模块-视图-控制器MVC)的架构设计模式,那么在我们应用中的核心模块就是这个设计模式中的控制器部分。

我们可以将核心模块的作用总结如下:

  • 作为应用程序的控制器

  • 在模块化组件之间提供通信桥梁

  • 初始化和销毁组件

  • 实现组件的即插即用能力

  • 提供集中处理错误的方法

  • 提供应用级别的可扩展性

  • 利用第三方库的功能

我们将很快检查所有这些方面,但在这样做之前,让我们谈谈在我们的应用中核心是如何构建的。

核心模块构建方法

建设核心模块有不同的方法,但其中最好的方法之一是以模块化的方式构建它。

我们可以通过构建一个主要的核心模块并使用诸如松散和紧密增强等技术来增强它来开始实现,这些技术我们已经在本书中介绍过。然而,通过子模块将大部分功能添加到核心模块中,可以更好地分离代码中的逻辑。

在本书主要应用的实现中,我们将使用上述所有技术来构建我们的核心模块。

下一章将深入探讨核心模块的实现,但到目前为止,我们将看看与其实现相关的一些事情。

考虑以下内容:

var ImagesInc_Core = (function(Core){

    var $ = Core.jQuery;
    var insertHTMLTxt = function(containerID,newStructure){
        var containerElem;
        if(typeof containerID === 'string'){

            containerElem = Core.getElement(containerID);
        }else if(typeof containerID === 'object'){

            containerElem = containerID;        
        }    
        Core.setInnerHTML(containerElem,newStructure);
    };
...

return Core;

})(ImagesInc_Core); // using tight augmentation

在前面的代码中,我们使用紧密增强向核心模块添加与 DOM 相关的功能。我们也可以通过附加一个子模块来向核心模块添加功能,如下所示:

// using simple sub-module augmentation
ImagesInc_Core.LoggingHandler = (function(){

    var self = {}, messageParam, colorParam;

    self.logMessage = function(severity, message,color) {    
        // if no severity number was possed in, then give the message and warn the user
        if(typeof severity === 'string'){
            message = severity;
            severity = 2;
        }

...

return {
        logMessage: self.logMessage,
        initialize: self.initialize
    };

})();

正如你所见,我们通过附加一个自包含的子模块来增强核心模块,这个子模块被添加为核心模块对象的新属性。

使用子模块增强核心模块,类似于使用乐高积木构建一个玩具屋,因为单独的积木被连接在一起以创建整个结构。

这意味着不仅应用程序的主要部分(如组件和沙盒)作为模块构建,而且核心模块本身也是基于较小的模块构建的。

这使我们能够轻松扩展我们的核心模块,同时提供移除或替换每个子模块的能力,而不会影响核心模块的其他部分。

当我们在下一章深入查看内部结构时,我们将更详细地检查核心模块的这种模块化实现。看看以下图片,以了解我们最终应用程序中核心模块的所有部分。这张图片描述了核心模块由独立的自包含子模块组成:

核心模块构建方法

当然,在部署时,我们可以将这些文件(子模块)合并并压缩成一个文件,但在开发过程中,这种子模块的分离提供了一个很好的核心模块各个部分的视觉表示。

现在我们已经对核心模块的构建有了很好的概述,让我们来检查这个模块为我们提供的功能。

核心模块功能

核心模块在我们的应用程序中扮演着不同的关键角色,并为所有其他模块提供基本服务。重要的是要注意,尽管核心模块本身由许多部分组成,但在提供以下功能时,它作为一个统一和紧密的整体发挥作用。

作为控制器

核心模块为应用中的所有组件提供应用级别的功能。例如,事件绑定和解除绑定发生在核心模块中,组件只需要请求核心处理这些任务即可。

考虑以下代码片段:

if(elem.addEventListener) {
    elem.addEventListener(event, callbackFunc);
    } else if(elem.attachEvent) {                  
        // For IE 8 and earlier versions
            elem.attachEvent("on" + event, callbackFunc);
    }
}

此代码在核心模块中实现,应用程序中的所有组件都通过其沙盒模块的实例调用此方法来将事件处理器绑定到元素上。

另一个例子可以是当组件需要向服务器发起一个AJAX调用时。这是核心模块,它会代替组件向服务器发起调用,并将结果返回给组件,或者根据返回的结果采取行动,例如在 AJAX 调用失败时记录错误。

我们还可以提及与应用程序相关的cookie本地存储功能,作为核心提供给所有注册组件的其他应用程序级功能之一。

请记住,实现应该设计成组件特定的功能,只有组件关心,应该在组件级别发生,而应用程序级的功能应该在核心模块中实现。

在某种程度上,每个组件在其组件级别实现了自己的 MVC 或 MV*设计,这与应用程序级实现是分开的。这一点将在我们查看下一章的实现代码时变得更加清晰。

提供通信桥梁

如您所忆,我们模块化架构的主要目标之一是提供组件之间的松散耦合。这意味着我们应用程序中的任何组件都不知道任何其他组件,也不依赖于任何其他组件。

然而,有时组件需要相互通信,或者一个组件中的事件或动作应该导致另一个组件发生变化,而组件之间却没有任何了解。

为了提供这样的功能,核心实现了一个观察者模式的变体,称为中介者模式

使用中介者模式,我们应用程序的组件可以通过抽象层注册和发布事件。

以下是如何实现中介者模式的描述:

提供通信桥梁

我们应用程序的核心模块充当中介者,它使组件(模块)能够订阅和发布事件,而无需了解彼此,从而仍然遵循组件和模块之间松散耦合的原则。

考虑以下代码片段:

sandBox.publishCustomEvent({
      type: 'support-Clicked',
      data: "support"
});

在这里,我们应用程序的页脚组件通过其沙盒模块的实例发布了一个自定义事件,support-Clicked。除此之外,它并不知道应用程序的其他部分可能正在监听此事件,以及它们将根据此事件采取什么行动。

另一方面,以下代码片段显示我们的NotificationHandler模块正在监听此事件,并在事件发布时采取行动,如下所示:

ImagesInc_Core.registerForCustomEvents("Notification",{
        'support-Clicked':this.handleSupportClick
}

如果组件已注册特定事件,核心模块负责通知所有已注册的组件和模块已发生某个事件。

初始化和销毁组件

在我们的架构设计中,我们可以在应用程序的启动阶段以及任何时间之后加载和卸载组件(模块)。

这相当稳健,因为它允许我们在需要时加载所需的内容,这有助于将设备上的资源消耗保持在最低,同时保持应用程序非常轻便和响应。这对于移动设备尤为重要。

当组件被加载(无论是应用程序启动还是其他任何时间)时,它首先将自己注册到核心模块,然后核心模块在需要初始化时调用组件的init方法。

请考虑以下代码片段,看看这是如何完成的:

ImagesInc_Core.registerComponent("footerContainer", "footer", function(sandBox){

    return {

        init: function(){
            try{
                sandBox.updateElement("footerContainer", ImagesInc_GlobalData.getFooterHTMLTxt());
                this.registerForEvents();
                sandBox.contextObj = this;
                sandBox.logMessage(1,'Footer component has been initialized...', 'blue');

            }catch(e){
                sandBox.logMessage(3,'Footer component has NOT been initialized correctly --> ' + e.message);
            }
 }, 

如前所述代码所示,每个组件在加载时都会调用核心模块的registerComponent方法。请注意,核心模块对其他应用程序组件被称为ImagesInc_Core

组件将其容器(例如footerContainer)的 ID 以及它自己的 ID(在前面代码中显示为footer)作为参数发送到核心模块的registerComponent方法。

它还发送一个引用到核心模块需要调用的函数,以创建组件的实例。然后,核心模块使用沙盒模块的实例调用此函数,沙盒模块是组件用于从此点开始与应用程序交互的接口。

这是核心模块中组件注册的方式:

mainCore.registerComponent = function(containerID, componentID, createFunc){
...
if(createFunc && typeof createFunc === 'function'){
    componentObj = createFunc(new SandBox(this,containerElem, componentID));
    if (componentObj.init && typeof componentObj.init === 'function' && componentObj.destroy && typeof componentObj.destroy === 'function') {

        componentObj.id = componentID;
        registeredComponents.push(componentObj); 
    }
...
};

如您所见,核心模块首先检查组件是否具有所需的方法,即initdestroy,如果是这样,则调用组件上的创建函数(createFunc)以创建组件的实例。

你可以将这个阶段视为组件与核心模块之间的握手阶段,以及组件与应用程序整体的握手。

如前所述,组件还需要有一个销毁方法,这允许核心模块禁用组件以处理事件,或者完全从应用程序中移除组件。

在应用程序的启动阶段,核心模块遍历所有已注册的组件,并在它们上调用init方法,如下所示:

for(var i=0; i < registeredComponents.length; i++){

  registeredComponents[i].init();
}

我们使用类似的方法来销毁(移除)应用程序中注册的所有组件,如下所示:

for(var i=lastIndex; i >= 0 ; i--){

     registeredComponents[i].destroy(removeFromDom);
}

我们将在下一章中查看这些操作的细节,并将更详细地检查代码。这里的要点是核心模块处理应用程序中所有组件的初始化和销毁,无论是作为一个集合还是个别处理。

提供即插即用功能

正如我们之前提到的,组件可以在浏览器加载应用程序的任何时候被加载和添加到应用程序中。

让我们看看组件是如何动态加载的,以及它是如何成为应用程序的一部分的。

组件的动态加载是通过利用一种机制来完成的,该机制首先检查组件是否已经加载到缓存中,如果没有,则在本地存储或PageDefinitions.js文件中查找其对象定义,然后随后加载它。

PageDefinitions.js 是一个文件,它保存了所有可以在应用程序中动态加载的组件的对象定义(作为 JavaScript 对象),除了启动阶段以外的任何时间。

如果我们愿意,我们也可以在这个文件中保存与任何组件相关的资源(资产)信息。一般来说,这个文件主要用于查找我们的动态资产的位置。

例如,在我们的应用程序中,我们将保留与“收藏”页面及其CSS文件位置相关的信息在PageDefinitions.js中。当用户导航到该页面时,内容组件将使用这些信息。

请看以下图表,以了解我们应用程序中动态组件加载机制的概述:

提供即插即用功能

如流程图所示,当需要将组件动态渲染到应用程序中时,核心模块会检查组件对象是否已经存在于缓存中。

如果组件对象已经存在于缓存中,那么除了渲染其视图之外,就没有其他需要做的事情了。

另一方面,如果组件对象不存在,核心模块首先会在本地存储中查找组件的对象定义。这个对象定义有一个属性,它保存了组件的 JavaScript 文件在服务器上的位置(路径)。

注意,我们正在使用本地存储作为此类对象定义的主要存储设施,而不是将它们保存在缓存中。这纯粹是为了使应用程序在浏览器中的占用空间尽可能小。如果有很多对象定义,使用本地存储尤为重要,因为这些定义可能会变得相当大,并消耗相当多的内存。

诚然,在我们的应用程序中,我们没有真正使用本地存储的需要,因为我们只有一个动态加载的组件。但我使用本地存储来向您展示,如果您决定在未来应用程序中使用这种机制,它将如何被利用。

重要的是,当核心找到组件文件的地址时,它将向服务器请求组件的.js(以及可能的.css)文件。

一旦加载并解析了组件所需的.js文件,组件就会将自己注册到核心模块,并且,就像应用程序中的任何其他组件一样,它会被赋予一个沙盒模块的实例。

如您所想象,这种方法为我们提供了一种非常稳健的方式来动态加载和激活应用程序的组件,这本质上包括了应用程序的即插即用功能。

该机制比这里所解释的要复杂一些,但这个简短的描述应该能提供一个关于幕后工作原理的良好概述。在下一章中,我们将更详细地探讨这个机制的实现,我相信在那个章节中所有细节都会对您更加清晰。

提供集中处理错误的方法

核心模块实现了一种集中记录所有类型消息的方法。这使应用程序模块和组件免于自己实现此类功能。它还提供了所有组件都可以以统一方式利用的日志记录机制增强功能。

注意,我们可能对日志记录机制进行的任何未来增强都将在一个地方完成,并且将同时提供给所有模块。

例如,假设我们希望将应用程序中的所有错误消息都记录在服务器上。这可能意味着对于每个错误,客户端都需要向服务器发起一个带有相关信息的 AJAX 调用。

如果每个模块都想自己完成这项任务,我们就必须为每个模块单独实现这样的功能。当然,一次性实现这种功能,然后以统一的方式提供给所有应用程序模块,这样做更有意义。

请查看以下截图,它显示了在调试模式下应用程序消息在 Chrome 调试工具中的记录情况:

提供集中处理错误的方法

如您所见,当应用程序加载时,所有模块以及应用程序中的所有组件都会被初始化,并且会显示相关的消息。

所有组件需要做的只是调用沙盒模块并传递消息,然后沙盒模块会依次将消息发送到核心模块。对于其他核心模块,它们可以直接使用核心模块的日志记录机制。

这里有一个代码片段,展示了页脚组件如何使用核心模块的日志记录机制记录其初始化消息:

sandBox.logMessage(1,'Footer component has been initialized...', 'blue');

要记录错误消息,页脚组件只需执行以下操作:

sandBox.logMessage(3,'Footer component has NOT been initialized correctly --> ' + e.message);

这比页脚组件实现自己的错误处理机制要简单得多。

如果我们决定将所有错误消息发送到服务器,模块仍然会调用沙盒模块,然后是核心模块。将负责将错误消息发送到服务器的将是核心模块。

以下代码片段展示了如何一个核心子模块向核心模块发送其初始化消息。这与组件记录消息的方式类似,但有一个区别。

ImagesInc_Core.log(1,"Utilities Module has been initialized...", "blue");

区别在于子模块直接将其消息发送到核心模块,而不是通过沙盒模块实例。这当然是因为子模块是核心模块的一部分,并且没有沙盒模块的实例。

此外,沙盒模块本身也使用核心模块提供的相同日志设施来记录其错误,如下所示:

Core.log(3,"incorrect parameters passed in; from SandBox.getElement ");

以下图像显示了错误消息在调试器中的显示方式:

提供处理错误的集中方法

在下一章中,我们将更深入地探讨我们的应用程序中日志机制是如何实现的,以及它是如何设计成核心模块的子模块的。

提供应用程序级别的可扩展性

从一开始,我们就基于模块化架构设计我们的应用程序。我们的模块化架构提供的一个优点是,在需要时可以轻松扩展我们应用程序的功能和能力。

正如你所见,我们使用各种技术增强了应用程序的功能,例如松散增强、紧密增强和子模块。

虽然我们的应用程序可能提供了相当多的功能,但合理地假设未来还需要更多的功能和能力。

例如,假设我们的一个或多个未来组件需要进行表单字段验证。通过扩展核心模块并提供功能给我们的组件,可以轻松地将此功能添加到我们的应用程序中。

核心模块可以通过导入基础模块中的验证库或自行实现功能来提供表单字段验证。然后,通过沙盒模块将此新功能提供给所有已注册的组件,所有组件都可以根据需要使用它。

事实上,我们的组件甚至不知道谁提供了这项新功能以及它是如何幕后工作的。他们需要知道的是,这项功能对他们来说是可用的,并且他们可以使用它来完成他们的验证任务。

能够提供应用程序级别的可扩展性是我们模块化设计的关键特性之一,也是核心模块负责的最重要任务之一。

利用第三方库

我们之前简要地讨论了核心模块的这项职责。正如所述,核心模块被设计成从基础模块请求第三方库。这些库被核心模块用于为应用程序的其他部分提供应用程序级别的功能。

这样的库导入通常发生在应用程序启动时间,但并不一定如此。

正如你在本章前面所看到的,我们的应用程序确实具有从服务器动态加载文件的能力。完全有可能在应用程序运行期间,基于某些应用程序需求或用户与应用程序的交互,我们需要动态地加载第三方库。

大多数时候,当第三方库对于应用程序的大部分功能不是必需的时候,我们会使用第三方库的动态加载。这允许我们保持应用程序的足迹小,这在移动设备上是一个重要的考虑因素。

在本书的应用程序中,我们并不使用这种第三方库的动态加载。然而,如果在未来的某个时刻你决定利用这一功能,代码中已经提供了所有相关的钩子。

让我们看看核心模块是如何使用基础模块将 jQuery 库导入到我们的应用程序中的。

考虑以下内容:

(function Core_initialize(){

        mainCore.debug = true;

        try{
            // get jQuery from the base module loader
            mainCore.jQuery = $ = ImagesInc_Base.getBaseModule();

        }catch(e){

            if(mainCore.debug){
                console.error('Base Module has not been defined!!!' );    
            };
        };

        if(mainCore.debug){
            console.log("%c Core Module has been initialized...", "color:blue");
        };

})();

在前面的代码片段中,在应用程序的启动阶段,我们的核心模块(核心模块的MainCore模块)向基础模块请求 jQuery。

这就像下面这样:

mainCore.jQuery = $ = ImagesInc_Base.getBaseModule();

注意,我们已经在这个调用中包裹了 try/catch 语句,以捕获在此操作过程中可能发生的任何错误。

让我们也看看基础模块是如何为 jQuery 提供对核心模块的引用的。

if(typeof jQuery !== 'undefined'){
    return jQuery;
}else{
    return null;
}

如你所见,由于 jQuery 在应用程序中以全局对象的形式加载,基础模块只是将这个全局对象的引用返回给核心模块。从那时起,jQuery 就被核心模块作为子模块来提供应用程序级别的功能。

以下代码片段展示了如何在应用程序中使用 jQuery 来提供所需的功能:

var ImagesInc_Core = (function(Core){
    var $ = Core.jQuery;
    Core.makeAjaxCall = function(url, theQuery, method, handler){

        if($ && Core.jQueryAjaxEngine && Core.jQueryAjaxEngine.makeAjaxCall){
            Core.jQueryAjaxEngine.makeAjaxCall(url, theQuery, method, handler);

        }else{        
            Core.log(3, "Cannot make Ajax call!; from makeAjaxCall")
        }
    };
…
})(ImagesInc_Core); // using tight augmentation

同样地,其他第三方库也可以在启动阶段加载到应用程序中,以增强我们的应用程序功能。

注意,其他模块或组件都不了解第三方库或它们是如何被利用的。只有核心模块了解这些库,并且它是唯一一个向其他应用程序模块和组件提供第三方库功能的模块。

以下图像显示了第三方库可以放置在我们的应用程序文件夹结构中的位置:

利用第三方库

组件

我们应用程序设计的最后一部分与组件相关。在本节中,我们将介绍组件是如何构建并集成到我们的应用程序中的。

记住,我们将组件视为具有视图的应用程序的一部分。通过视图,我指的是与 HTML 元素相关联的部分,这些元素在浏览器中渲染,并且用户可以直接与之交互。

组件可以像覆盖框那样简单。这样的简单组件可以作为另一个组件的一部分渲染,或者作为独立的组件本身渲染。

大多数时候,我们的组件是具有自己的 MVC 或 MV* 架构的独立模块,它们附加到应用程序上,并使用应用程序为它们提供的设施和功能。

在我们的设计中,我们创建了三个主要组件和一个名为 NotificationWidget 的小部件。这个小部件也是一个组件,但它根据用户与另一个组件的交互来加载和查看。在我们的应用程序中,当用户点击页脚组件中的 支持 链接时,NotificationWidget 被启动。由于这个小部件也可能由其他组件启动,因此我将其标记为小部件,但实际上,它也是一个具有自己的 MV* 实现的组件。

组件可以在其控制器中实现非常复杂的业务逻辑,或者具有非常简单的控制器,功能非常有限。

在我们的应用程序中,内容组件是一个具有相当复杂的控制器逻辑的组件的例子,而页脚则是一个相对简单的控制器组件。

还要记住,组件与应用程序其余部分之间的唯一桥梁或连接点是通过当组件将自己注册到核心时传递给它的一个沙盒实例。

我们之前讨论了在我们的架构中,组件可以在应用程序启动阶段加载,或者在任何时候动态加载。在我们的实现中,页眉、页脚和内容组件在启动阶段加载,而 NotificationWidget 在需要时动态加载。

让我们看看我们的一些较简单的组件之一,即页脚,看看它是如何实现的。

组件所需的方法

在我们的设计中,每个组件都需要实现两个必需的方法,以便注册并连接到核心模块和应用程序的其余部分。

这两个必需的方法被称为 initdestroy 方法。init 方法由核心模块调用以启动组件的初始化。另一方面,destroy 方法由核心模块调用,以禁用或完全从应用程序中移除组件。

考虑以下实现:

init: function(){
            try{
                sandBox.updateElement("footerContainer", ImagesInc_GlobalData.getFooterHTMLTxt());
                this.registerForEvents();
                sandBox.contextObj = this;
                sandBox.logMessage(1,'Footer component has been initialized...', 'blue');

            }catch(e){
                sandBox.logMessage(3,'Footer component has NOT been initialized correctly --> ' + e.message);
            }
}, 

        destroy: function(removeComponent){

            sandBox.contextObj.unregisterFromEvents();
            sandBox.unregisterAllCustomEvents();

            if(removeComponent){
                sandBox.removeComponent("footerContainer");
            }
            sandBox.logMessage(1,'Footer component has been destroyed...', "blue");
        }

正如你所见,在页脚组件的init方法中,用于视图的必需 HTML 字符串是从GlobalData对象中获取的。这个对象是一个应用级对象(应用模型),它包含应用级数据。然后,HTML 字符串通过沙盒模块实例传递给核心模块。

注意,核心模块负责在页面上渲染 HTML 元素,在这种情况下是页脚的视图。

这是在init方法的以下代码行中完成的:

sandBox.updateElement("footerContainer", ImagesInc_GlobalData.getFooterHTMLTxt());

对于这个特定的组件,我们在该方法中调用视图的渲染。然而,渲染并不总是需要在组件的init方法中完成。例如,对于NotificationWidget组件,渲染是在稍后的时间由应用调用另一个方法来完成的。因此,NotificationWidgetinit方法保持非常简单,如下所示:

init: function(){
            try{                               
                sandBox.contextObj = this;
                sandBox.logMessage(1,'Notification Widget component has been initialized...', 'blue');

            }catch(e){
                sandBox.logMessage(3,'Notification Widget has NOT been initialized correctly --> ' + e.message);
            }
 }

将事件绑定到组件元素

在页脚组件的init方法中,我们还为其视图相关的 HTML 元素注册了事件处理。这是通过调用组件的registerForEvents方法来完成的。在registerForEvents方法中,相关的事件处理程序被绑定到support链接上,如下所示:

registerForEvents : function(){
            …...................
            sandBox.addEventHandlerToElement("support","click", this.handleSupportClick);    

}

正如你所见,实际的事件绑定是在核心模块中完成的。

我之前提到,我们的组件通过使用中介者模式通过自定义事件与应用的其他部分松散耦合。让我们快速看一下这一点。

考虑以下代码片段:

handleSupportClick : function(e){

            sandBox.publishCustomEvent({
                type: 'support-Clicked',
                data: "support"
            });

            e.preventDefault();
            e.stopPropagation();
}

正如你所见,当点击支持链接时,页脚组件会发布一个自定义事件。然后,所有注册了这个自定义事件的、应用中的其他部分将通过核心模块被通知这个事件已经发生。我们将在下一章中更详细地讨论这个机制。

组件设计考虑因素

在本章中,我们没有对组件的实现细节进行过于深入的考察,因为我们在下一章将更详细地探讨它们。然而,我希望你考虑一下组件的一些概念性方面。

首先,所有组件与应用都是松散耦合的。它们与整个应用之间的唯一联系是通过在注册阶段传递给它们的沙盒实例。

其次,所有组件都负责实现它们自己的控制器。其他应用部分不涉及特定于单个组件的功能。

应用程序整体的任务,特别是核心模块的任务,是创建一个健壮的生态系统,其中组件可以利用已经实现的应用程序级别的功能。因此,组件不需要自己实现此类功能。这种方法使我们能够创建一个统一且松散耦合的应用程序,可以轻松维护和扩展以满足需求。

注意,每个组件都是应用程序的一个自包含部分,核心或应用程序的任何其他部分都不会意识到,也不依赖于组件的内部实现。这意味着组件可以自由决定自己的实现。

以这种方式,我们可以在整个应用程序的各个部分之间创建一个良好的关注点和责任分离级别。

当一个组件需要修改 DOM 时,它应该通过利用核心模块提供的功能来完成。因此,组件不需要自己实现此功能。此外,通过使用核心模块,我们能够防止应用程序的其他部分意外地在同一 DOM 元素级别上造成冲突,从而防止碰撞和应用级别问题。

在大多数情况下,合适的做法是组件只通过核心模块修改它们自己的容器中的 DOM 元素(而不是超出那个范围)。

应用程序架构考虑事项

让我们回顾一下本章中关于我们模块化架构的重要观点,并看看它们如何与我们的应用程序的各个部分相关,如下所示:

  • 所有第三方库都通过基础模块导入到应用程序中

  • 核心模块和基础模块是唯一知道已加载第三方库的应用程序部分

  • 所有浏览器兼容性问题都通过利用第三方库的功能在核心模块中处理

  • 应用程序中的每个组件都会获得一个沙盒模块的实例

  • 沙盒只知道核心,不知道应用程序的其他部分

  • 应用程序的各个部分之间不知道其他部分的内部实现

  • 可以在不影响整个应用程序的情况下向应用程序中添加、修改或删除组件

  • 组件只能调用它们自己的方法和通过它们的沙盒模块实例提供的它们的方法

  • 组件无法访问其自身容器之外的 DOM 元素

  • 组件不应创建全局对象

  • 每个模块和组件只执行有限的任务,并且只与其自身功能直接相关的事情

  • 应用程序中的所有组件都与应用程序的其他部分松散耦合

  • 组件只能通过发布-订阅模型(中介者模式)和它们的沙盒实例与其他组件和应用程序的其他部分进行通信,从而保持松散耦合架构

  • 每个组件对应用的理解非常有限,这是通过在组件注册阶段分配给它的沙盒模块实例实现的

以下可以被认为是模块化架构为我们提供的优势:

  • 一旦模块化架构生态系统被开发出来,它可以用于许多应用;因此,我们可以一次编码,多次使用

  • 应用中的每一部分都可以单独进行测试

  • 应用中的每一部分都可以由不同的人以模块化的方式实现

  • 第三方外部依赖可以通过核心模块进行控制和管理工作

  • 模块和组件可以被替换、修改或删除,而不会影响整个应用

  • 组件和模块可以在应用启动阶段之后根据需要动态加载

我希望您将上述观点视为本章的重要收获,并在我们深入探讨下一章中应用实现时牢记在心。

摘要

正如本章开头所提到的,我们对与我们的应用模块化实现相关的架构概念有了很好的概述。

我们讨论了应用的主要组成部分,并看到了它们如何组合在一起形成一个统一的生态系统。

我们还考察了各个组件在整体应用设计中的具体角色,并研究了我们如何在模块和组件之间创建良好的职责和关注点分离。

我们关注的一个重要方面是将我们的各种应用组件松散地耦合在一起,这既便于维护,也便于未来的扩展。

虽然本章展示了部分应用代码,但我们并没有深入分析代码和应用实现。这正是我们将在下一章中涵盖的内容,我们将更详细地了解幕后是如何操作的。

应用实现 – 整合所有内容

在上一章讨论了我们的模块化设计的架构概念之后,现在是时候看看实际的实现了。

本章的目标是检查代码,看看所有部分如何在真实的应用程序中相互配合并工作。

虽然我们不会讨论代码的每个方面,但每个主要部分将被分解成更小的部分,大部分细节都将进行解释。

我强烈建议您从本书附带的网站上下载与本章相关的项目代码,并随着我们逐步分析代码的细节来跟进。我在代码的不同部分使用了不同的技术,乍一看可能看起来不一致。然而,这是有意为之,以便您可以看到在不同情境下如何根据需要应用不同的技术。

此外,请记住,由于重点在于创建一个概念验证POC)应用程序,而不是生产质量的代码,因此应用程序的许多方面都可以进行改进。您将看到我们的模块化方法如何使我们能够创建一个易于维护、可扩展且健壮的应用程序。

在本章中,我们将:

  • 查看应用程序的主要模块

  • 检查应用程序中的组件是如何构建的

  • 讨论应用程序的整体架构

  • 查看我们的发布-订阅实现,它为我们的模块和组件提供了松散耦合

  • 实现一个简单的客户端路由器

但在检查实现之前,让我们先看看我们应用程序的最终视图。

第八章:用户对应用程序的视图

如果您使用具有内置 Web 服务器的 IDE 加载我们的项目中的index.html文件,您将在浏览器中看到以下主页面。此文件位于应用程序文件夹的根目录下。

我们的应用程序的用户视图

如您可能已经注意到的,与之前章节中的内容相比,我对应用程序的外观和感觉进行了改进。我还对项目中的代码进行了一些重构。

我之前提到,我们的应用程序在一定程度上是基于浏览器的视口进行响应式的。以下截图显示了 Chrome 开发者工具模拟器中应用程序的index.html页面(在 Windows 中为Ctrl + Shift + M),模拟其在 Apple iPad 上的渲染:

我们的应用程序的用户视图

应用功能

如您所回忆的,我们的应用程序旨在成为一个图像画廊类型的应用程序,显示图像列表。用户能够以各种不同的方式与应用程序进行交互。虽然从用户的角度来看,应用程序可能看起来非常简单,但它从头开始设计,易于扩展,因此可以根据需要添加更多功能。

让我们考虑我们应用程序功能的各种方面。

索引页

应用程序的主页(index.html)由三个主要组件组成:headerfootercontent

这些组件分别构建了主页的页眉、页脚和内容部分(页面片段),如前一个屏幕截图所示。

由于我们将应用程序设计为单页应用程序(SPA),因此导航到应用程序的不同页面只会更新内容区域的视图。

应用程序的页眉和页脚部分只渲染一次,即在index.html的初始加载时。我们 SPA 设计的最大优点是,每次查看不同页面时,无需完全重新渲染页面。因此,在浏览器中不会有页面闪烁。

在主页中,用户可以看到所有可用图像的完整目录。通过点击图像,用户可以看到其全尺寸。当用户点击每张图像下方的相关链接时,还可以将图像添加到或从收藏图像列表中删除。

用户还可以与应用程序的页眉和页脚进行交互,尽管在我们的原型中,页眉和页脚中的所有链接并未全部激活。

图像页面

当用户点击图像时,应用程序内容区域的视图会改变,以显示全图,如下所示:

图像页面

如果您查看浏览器的地址栏,当点击图像时,页面的 URL 会改变以反映图像的 ID。对于前面的图像,页面的 URL 将类似于:

http://127.0.0.1:49202/Image_9.jpg

这是您本地服务器的 URL 以及图像 ID。

收藏页面

在我们应用程序的主页中,用户可以点击每张图像底部的链接,将图像添加到其收藏图像列表中,如下所示:

收藏页面

一旦点击添加到收藏链接,链接的文本将更改为从收藏中删除,使用户能够从收藏图像列表中删除图像。

用户还可以通过点击页眉部分的收藏链接查看所有收藏的图像;这将用户带到应用程序的收藏页面,如下所示:

收藏页面

支持小部件

当用户点击应用程序页脚部分的支持链接时,将显示一个对话框,其中包含有关网站目录的信息。

此小部件(对话框)已被设计为动态加载,并在页面上渲染,但仅在点击支持链接时。这是应用程序中组件如何按需动态加载和渲染的一个示例。这使我们能够保持浏览器中应用程序的重量轻,并最小化其占用空间。

此小部件的设计也展示了在应用程序中动态组件的即插即用概念。

以下截图显示了该小部件在浏览器中的显示方式:

支持小部件

现在我们已经看过应用程序的外观,是时候检查其实施的机制,看看幕后是如何操作的。

应用程序实现

从一开始,我们在设计我们的应用程序时,意图创建一个模块化的生态系统,在这个生态系统中,不同的代码片段(模块)将被组合在一起,以创建一个功能齐全的应用程序。为此,我们的代码结构如下所示,具体请参考以下截图:

应用程序实现

如您所见,我们的应用程序已组织成三个主要文件夹:css,它托管我们所有的样式文件;Images,它存储应用程序目录中的所有图像;以及js,它包含我们应用程序的所有 JavaScript 代码。由于这种结构只是组织应用程序代码的一种方法,您可能决定以不同的方式组织代码。

js文件夹下,我们将我们的 JavaScript 文件进一步组织成三个主要子文件夹;Components包含与具有视图关联的模块相关的文件,Modules包含我们应用程序的控制器和模型模块,而Widgets托管与在应用程序中动态加载和渲染的组件相关的文件。

我们的Modules文件夹进一步细分为五个子文件夹;Base包含我们的第三方依赖和基础模块,Core托管我们应用程序的控制器代码(模块),而GlobalData包含我们应用程序的模型文件。

其他两个子文件夹SandBoxPageDefinitions分别包含用于动态加载组件的沙盒模块和对象定义文件。

在接下来的几节中,我们将检查我们子文件夹中的大多数文件中的代码。

index.html中加载我们的模块文件

在我们开始查看我们的 JavaScript 代码和模块之前,让我们更仔细地看看index.html文件,看看应用程序模块是如何在浏览器中加载的。

我们的index.html页面设计得只提供应用程序主页的最基本骨架,而所有其他部分(页眉、页脚和内容)在页面加载时动态构建。

一般而言,在某些情况下,在将页面的一部分发送到客户端之前在服务器上构建应用程序页面的某些部分更有意义,因为这种实现有时可以提供更好的性能。

在我们的方法中,我们正在客户端构建我们应用程序页面的所有部分。这样做是为了我们可以更容易地检查和应用我们应用程序设计中的不同概念。

我们的index.html文件的骨架由三个主要容器组成,如下所示:

<header id="headerContainer" role="banner" itemscope itemtype="https://schema.org/WPHeader" class="headerContainerClass">
</header>

<main id="mainPageContainer" role="main" class="clearfix mainPageContainerClass">
</main>

<div id="footerParentContainer" class="footerContainerClass" >
    <div id="footerContainer" class="footerlinksContainerClass">
    </div>
</div>

如您所见,我们有一个头部容器,它承载着我们的应用程序的header组件,一个主要容器,它承载着我们的应用程序的content组件,以及一个div,它是我们的footer组件的容器。

这些是我们index.html页面中的组件(容器),它们被传递给我们的组件,以便它们可以在其中渲染自己。

在页面主体元素的结束标签之前,我们包含了加载我们应用程序脚本的<script>标签。让我们看看:

在 index.html 中加载我们的模块文件

如您所见,需要加载相当多的脚本文件。我们可以通过合并(并压缩)一些文件,以及使用异步模块定义AMD)等格式,结合Require.js等库来加载我们的文件来改进这一点。然而,为了清晰起见,我们现在保持现状。

在本书的第十章中,我们将讨论如何使用 AMD 格式以更优化的方式组合和加载我们应用程序所需的文件。

以下脚本也已在我们的index.html文件中实现,该文件负责启动应用程序。

在 index.html 中加载我们的模块文件

我们将很快讨论应用程序启动的过程。请注意,我们首先初始化所有模块,然后是组件,最后,我们将页面的 URL 传递给ImagesInc_Core模块的handlePageChange方法。这样做是为了我们可以在应用程序的路由器中使用页面的 URL,这将在稍后进行深入探讨。

我强调,当你阅读这一章时,你可能需要将相关的代码加载到你的 IDE 中,这样你可以更容易地跟随。

基础模块实现

让我们从基础模块开始探索我们的应用程序模块。我们的Base文件夹包含两个 JavaScript 文件;Base.jsjquery-1.10.2.min.js

Base.js文件实现了ImagesInc_Base模块,该模块负责将我们讨论的上一章中的所有第三方库导入到应用程序中。目前,我们导入的唯一第三方库是 jQuery。

以下代码片段展示了这是如何实现的:

var ImagesInc_Base = (function(){
    function getBaseModule(){
        if(typeof  jQuery !== 'undefined'){
            return jQuery;
        }else{
            return null;
        }
    }
    return {        
      getBaseModule: getBaseModule  
    };    
})();

如您所见,我们使用 IIFE(立即执行函数表达式)将一个对象返回给全局变量ImagesInc_Base

在这个函数中,我们首先检查全局作用域中是否已定义了 jQuery 对象;如果是,则返回它。否则,返回null

我们的核心模块将使用此对象来利用 jQuery 库的功能。

这是在MainCore模块中实现的:

(function Core_initialize(){        
        mainCore.debug = true;        
        try{
            // get jQuery from the base module loader
            mainCore.jQuery = $ = ImagesInc_Base.getBaseModule();   
        }catch(e){            
            if(mainCore.debug){
                console.error('Base Module has not been defined!!!' );    
            }
        }        
        if(mainCore.debug){
            console.log("%c Core Module has been initialized...", "color:blue");
        }

})();

由于我们的MainCore模块正在初始化自身(使用 IIFE),它会向基础模块请求其基础库,然后将返回对象的引用分配给MainCore模块的$mainCore.jQuery属性。

注意,在当前实现中,我们只能导入一个基础库。这是为了保持实现简单,因为我们的应用程序只需要 jQuery。然而,我们也可以通过修改此代码来导入并使用一系列库。

核心模块实现

核心模块是我们应用程序中的重量级模块,它由几个子模块构建。此模块的功能也通过我们在前几章讨论的技术得到了增强和增强,例如紧密和松散增强。

下面是构成核心模块的所有子模块列表:

核心模块实现

我们不会涵盖此模块的所有相关代码,但我们将在本节中查看其重要部分。请注意,核心模块也是我们应用程序的控制者。

MainCore 模块实现

此模块提供了核心模块的主要功能,所有相关的核心子模块都附加到这个模块上,因此命名为MainCore.js

从现在开始,我将把这个模块称为MainCore

该模块已经被定义为以下内容:

var ImagesInc_Core = (function(mainCore){
var $ = null, registeredModules = [], registeredComponents = []....
...
...

})(ImagesInc_Core || {}); // using loose augmentation

正如你所见,我们在这个模块中使用了松散增强技术来增强其功能。

此模块还使用内部 IIFE 来初始化自身并导入我们的应用程序的基础库。当应用程序处于调试模式时,MainCore 将在加载后立即宣布它已经初始化。

如下代码片段所示:

(function Core_initialize(){
        mainCore.debug = true;        
        try{
            // get jQuery from the base module loader
            mainCore.jQuery = $ = ImagesInc_Base.getBaseModule();

        }catch(e){            
            if(mainCore.debug){
                console.error('Base Module has not been defined!!!' );    
            }
        }        
        if(mainCore.debug){
            console.log("%c Core Module has beeninitialized...", "color:blue");
        } 
})();

要在应用程序中开启或关闭调试模式,我们已经实现了一个方法,外部代码可以调用此方法:

mainCore.toggleDebug = function(){        
         mainCore.debug = !mainCore.debug;
         if(mainCore.debug){
            mainCore.log(1, "Application debug has been turned on...", "blue");
         }else{
             console.log("%c Application debug has been turned off...", "color:orange");
        }
};

注意,我们返回了 MainCore 模块的引用作为全局变量ImagesInc_Core,这允许外部代码通过该模块提供的接口与之交互。

增强 MainCore 模块

在设计 MainCore 模块时,我故意使用了各种增强技术。这样做是为了展示在模块链接中这些技术的实际应用方面。

使用紧密增强增强 MainCore

考虑以下增强:

// event related functionality augmentation
var ImagesInc_Core = (function(Core){
    var $ = Core.jQuery;
    var addEventHandlerToElem = function(elem,event,callbackFunc){

        if(!elem){            
            Core.log(3, 'elem is not passed in, from addEventHandlerToElem');
            throw new Error('Element not found');
        }
...

    Core.addEventHandlerToElement = addEventHandlerToElem;
    Core.removeEventHandlerFromElem = removeEventHandlerFromElem;
    Core.registerForCustomEvents = registerForCustomEvents;
 …..........
 return Core;        
})(ImagesInc_Core); // using tight augmentation

在这里,我们在处理 MainCore 模块中与事件相关的功能代码和与此模块相关的其他代码之间进行了逻辑分离。

注意,我们已经将 MainCore 模块(在应用程序中被称为 ImagesInc_Core)的引用传递给了实现此增强(使用紧密增强技术)的立即执行函数表达式(IIFE),然后根据需要向 MainCore 模块添加了新的属性。最后,将现在增强后的 MainCore 模块的引用返回给 ImagesInc_Core,这是提供对 MainCore 模块访问的全局变量。

我们在代码的另一部分再次使用相同的技巧,以增加更多功能来增强模块。请查看应用程序 MainCore.js 文件中相关的代码。

使用子模块增强 MainCore

让我们看看如何将子模块添加到 MainCore 模块的示例。为了向应用程序添加增强的日志记录功能,我们通过一个专门为应用程序记录消息的子模块来增强 MainCore 模块。

以下代码片段显示了此子模块是如何被 MainCore 模块使用的:

mainCore.log = function(severity,msg, color){

        // if the logging module has been loaded, then use its full functionality
        // otherwise just log a simple message
        if(mainCore.LoggingHandler && mainCore.LoggingHandler.logMessage){

            mainCore.LoggingHandler.logMessage(severity,msg,color);

        }else{
            if(severity === 3){
              color = "color:red;font-weight:bold"; 
            }
            console'log'", color);
        }
};

在此方法中,我们首先检查 LoggingHandler 对象(日志子模块)是否存在,以及该对象上的 logMessage 方法是否实现。如果这两个条件都满足,则将日志消息及其相关信息传递给此子模块。

另一方面,如果日志子模块或其所需的方法不存在,我们使用 mainCore 模块自己的简单日志记录机制在浏览器控制台中记录消息。

我们应该看看 LoggingHandler 子模块是如何实现并被添加到 MainCore 模块中的。

考虑以下代码片段:

// using simple sub-module augmentation
ImagesInc_Core.LoggingHandler = (function(){

    var self = {}, messageParam, colorParam;
    self.logMessage = function(severity, message,color) {

  …....

return {        
        logMessage: self.logMessage,
        initialize: self.initialize
    };    
})();

如所示,我们向 ImagesInc_Core 对象(MainCore 模块)添加了一个属性。当执行时,立即执行函数表达式(IIFE)将一个对象返回到这个属性(LoggingHandler)。这个对象包含两个方法,提供了与应用程序日志记录机制相关的所有功能。

虽然这两个方法的实现被隐藏在应用程序的其他部分之外,但应用程序的其他部分可以通过 MainCore 模块和相关接口与这个子模块进行交互。

注意,这个子模块可以很容易地被替换为不同的子模块或以我们希望的形式进行内部修改。然而,只要暴露的接口不改变,所有外部代码仍然能够使用这个子模块进行日志记录功能。

实际上,我们可以完全移除此子模块,而这对应用程序没有任何影响,除了应用程序的高级日志记录机制将不存在。这突显了我们应用程序中模块化设计的一些优点,例如即插即用渐进增强优雅降级

同样,我们也可以向 MainCore 模块添加其他子模块,以提供更多功能,而无需在应用程序中进行许多更改。这使得我们能够拥有一个灵活且易于维护的代码库。

在我们的应用程序中,还有其他子模块,它们附加到 MainCore 模块,并为应用程序提供附加功能。我们在上一章中讨论了这些子模块,但我会再次提及它们:

  • AjaxEngine: 该模块负责使用 jQuery AJAX 功能向服务器发出 AJAX 调用。

  • CookieHandler: 该模块负责应用程序中所有与 cookie 相关的操作,例如在浏览器中写入、读取和删除 cookie。

  • NotificationHandler: 该模块负责通过利用对话框组件向用户显示通知。

  • StorageHanlder: 该子模块实现了与浏览器本地存储相关的所有功能,例如在本地存储中存储、读取和删除对象。

  • Utilities: 该模块为应用程序提供辅助方法,例如合并两个对象的方法、检查一个对象是否为数组的方法、从服务器加载文件的方法等等。

在 MainCore 中注册组件

我们的应用程序中的每个组件都注册到 MainCore 模块。这允许通过SandBox模块的实例创建组件与应用程序之间的桥梁。我们很快就会讨论SandBox模块,但现在让我们看看应用程序(在 MainCore 模块中)中组件注册是如何进行的。

以下图表概述了此过程:

在 MainCore 中注册组件

考虑以下 MainCore 模块中的实现:

mainCore.registerComponent = function(containerID, componentID, createFunc){

        var containerElem, componentObj;

        // setting context for the sandbox
        if($){
            containerElem =  $("#" + containerID)[0];        
        }else{        
            containerElem = document.getElementById(containerID);
        }        
        if(createFunc && typeof createFunc === 'function'){            
            componentObj = createFunc(new SandBox(this,containerElem, componentID));
            //checking for required methods in component
            if (componentObj.init && typeof componentObj.init === 'function' && componentObj.destroy && typeof componentObj.destroy === 'function') {
                 componentObj.id = componentID;
                 registeredComponents.push(componentObj);

            }else{                
                this.log(3,"Component does not have necessary methods, thus not registered");
            }
        }else{            
            this.log(3,"no creator function on component, component not registered");
        } 
};

如您所见,当组件在加载时由组件调用此方法时,将三个参数传递到 MainCore 的mainCore.registerComponent方法:containerIDcomponentIDcreateFunc

containerID参数传递给此方法,以告知应用程序组件视图需要渲染到哪个容器(HTML 元素)。componentID是与应用程序注册的组件的 ID,createFunc是组件上的回调函数,mainCore.registerComponent方法调用它以创建组件的实例。

我们将在本章后面更深入地讨论组件的实例化。但就目前而言,让我们从 MainCore 模块的角度来看看这个机制。

在上述方法中,我们首先遍历 DOM 树以找到组件视图的容器元素,无论是使用 jQuery 还是直接使用 DOM API。

然后,我们检查是否已传入所需的createFunc参数,并且它是否是一个函数。如果是这样,我们就使用SandBox模块的实例(使用依赖注入)调用此组件的函数。请记住,每个组件都会获得SandBox模块的不同实例,这是组件与应用程序之间的桥梁。

这如下所示:

componentObj = createFunc(new SandBox(this,containerElem, componentID));

如果组件设计得当,此函数调用的结果是组件的实例,它被返回并存储在componentObj变量中。

此外,还有一些必需的方法,我们需要确保组件实例上存在:initdestroy方法。请注意,MainCore 不关心这些方法在组件中的实现方式或它们的功能,只关心它们的存在。这些方法的内部实现和它们的功能是组件本身的职责,每个组件可能以不同的方式实现这些方法。考虑以下代码片段:

//checking for required methods in component
if (componentObj.init && typeof componentObj.init === 'function' && componentObj.destroy && typeof componentObj.destroy === 'function') {

           componentObj.id = componentID;
           registeredComponents.push(componentObj);

}else{                
           this.log(3,"Component does not have necessary methods, thus not registered");
}

在注册阶段结束时,每个组件都会推送到registeredComponents数组,这是一个所有注册组件的集合。

我们使用这个组件数组在应用中做几件不同的事情。例如,当应用启动时,我们遍历这个数组中的所有组件,并对每个组件调用init方法。这是按照以下方式完成的:

mainCore.initializeAllComponents = function(){

        this.log(1,"Initializing all components...", "orange");

        try{            
            for(var i=0; i < registeredComponents.length; i++){          
                registeredComponents[i].init();
            }

        }catch(e){            
           this.log(3, 'APPLICATION CATASTROPHIC ERROR!' + e.name + ": " + e.message);
        }
        this.log(1,"All components have been initialized...", "orange");
};

如下所示,在index.html文件中调用 MainCore 模块的先前方法:

ImagesInc_Core.initializeAllComponents();

在启动阶段,当应用调用所有组件的init方法后,如果应用处于调试模式,我们将在控制台看到以下消息。

使用 MainCore 进行组件注册

MainCore 通过动态加载组件

如您所回忆的,我之前提到过,应用组件可以在应用启动之后加载。MainCore 模块提供了钩子和实现所需功能以实现这一点。

我认为在继续下面的解释之前,回到前面的章节并查看描述该机制的流程图是个好主意,这样你可以刷新你的记忆。

在我们的应用中,当用户点击页脚部分提供的支持链接时,会显示一个对话框。这个对话框提供了关于我们应用中的图片是从哪里获取的信息,以及用户是否希望访问该网站。

此小部件组件(notificationWidget)在应用最初启动时不会在index.html页面中加载;它仅在用户点击支持链接时加载。

然而,如果小部件已经在应用中预先加载(如果用户之前点击过支持链接),我们将在页面上重新渲染它。这是因为第一次加载此组件后,它将被保存在缓存中,即使用户通过点击其关闭按钮关闭对话框也是如此。

NotificationHandler子模块负责监听并处理支持链接的点击事件。这是通过使用发布-订阅机制(模式)来实现的,我们将在本章稍后讨论。

考虑以下代码片段:

self.handleSupportClick = function(){
        // name of the component when it registers itselft with core is used here
        NotificationWidgetObj = ImagesInc_Core.getComponentByID("notificationWidget");        
        if(!NotificationWidgetObj){            
            ImagesInc_Core.loadComponent(ImagesInc_GlobalData.getNoficationWidgetDefID(), self.renderWidget);

        }else{            
            self.renderWidget();
        }
   };

这个方法是NotificationHandler子模块中支持链接点击事件的回调函数。这个方法首先做的事情是询问应用程序(特别是 MainCore 模块)这个组件(小部件)是否已经被应用程序(特别是 MainCore 模块)之前加载并注册,如下所示:

NotificationWidgetObj = ImagesInc_Core.getComponentByID("notificationWidget");

如果是这样,这个方法会调用NotificationHandler.renderWidgetself.renderWidget)函数,然后它反过来在组件本身上调用renderWidget方法。请注意,组件本身负责其渲染,如下所示:

self.renderWidget = function(){        
       ImagesInc_Core.getComponentByID("notificationWidget").renderWidget();    
};

如果小部件之前没有被加载,该方法会要求 MainCore 模块首先加载小部件,然后调用NotificationHandler.renderWidget函数,这是作为self.renderWidget传入的回调函数。

这个调用如下所示:

ImagesInc_Core.loadComponent(ImagesInc_GlobalData.getNoficationWidgetDefID(), self.renderWidget);

现在控制权交给了 MainCore 模块来动态加载组件(小部件)。这是通过首先在浏览器的本地存储中查找组件的对象定义来完成的。

让我们先看看本地存储是如何填充组件对象定义的。

在本地存储中存储对象定义

应用程序已被设计为在浏览器的本地存储中查找所有动态加载组件的对象定义。

然而,这些定义实际上是在PageDefinitions.js文件中实现的。

PageDefinitions.js文件被加载时,它使用 IIFE 将它的对象定义存储在浏览器的本地存储中,如下所示:

(function(){

    var NotificationWidgetDefinition = {
        componentID: "notificationWidget",
        scriptFile: "NotificationWidget.js",
        scriptPath: "/js/Widgets/",
        cssFile: "NotificationWidget.css",
        cssPath: "css/"
    };

...    
    ImagesInc_Core.saveValueToLocalStorage(ImagesInc_GlobalData.getNoficationWidgetDefID(),NotificationWidgetDefinition);

...

})();

在浏览器本地存储填充了对象定义之后,文件将从浏览器缓存中删除,因为它可能很大,并且会消耗大量的内存。这种设计允许我们最小化浏览器缓存的使用,这在资源有限的移动设备上可能特别有趣。

注意notificationWidget的对象定义。在这个对象上,ScriptFile属性持有该组件的.js文件名,而ScriptPath属性存储该文件的路径。MainCore 模块使用这些信息从服务器查找并加载组件。

从本地存储获取组件的对象定义

现在你已经知道了本地存储是如何填充对象定义的,我们可以看看 MainCore 模块是如何从本地存储中获取notificationWidget对象定义并加载相关资源的。

考虑以下代码片段:

mainCore.loadComponent = function(ComponentDefID, callbackFunc){        
        // get the value of Component object defintion from storage
        var ComponentDef =  mainCore.getValueForKeyAsObjectFromStorage(ComponentDefID);
        loadedComponentcallbackFunc = callbackFunc;

        if(!ComponentDef){
            // if Component definition is not in the storage then the page object definitions probably needs to be loaded
            mainCore.loadPageDefinitionsFileAndCallBack(function(){mainCore.getComponentObjAndCallback(ComponentDefID, mainCore.loadComponentFilesAndInitializeWithCallBack);});

        }else{         
            mainCore.loadComponentFilesAndInitializeWithCallBack(ComponentDef);
        }
};

在这个方法中,首先检查浏览器中的本地存储(在幕后使用StorageHandler子模块)以查找组件的对象定义。如果找到了,那么这个方法会调用另一个方法来动态加载组件所需的资源,如下所示:

mainCore.loadComponentFilesAndInitializeWithCallBack(ComponentDef)

从服务器动态加载组件的资源

一旦从本地存储中提取了组件的对象定义,其所需的 .js.css 文件将通过以下方法从服务器加载:

mainCore.loadComponentFilesAndInitializeWithCallBack = function(pageDefinitionObj, callbackFunc){
...
    mainCore.loadJSfileFromObjDefAndCallBack(pageDefinitionObj.scriptFile, pageDefinitionObj.scriptPath, function(){mainCore.initializeComponent(pageDefinitionObj.componentID, loadedComponentcallbackFunc)});

...
    mainCore.loadCSSfileFromObjDef(pageDefinitionObj.cssFile, pageDefinitionObj.cssPath);
...

}

如您所见,此方法调用其他专门用于加载 .js.css 文件的方法,因为根据文件类型,将文件加载到应用程序中的机制不同。当然,.js 文件是组件代码所在的地方,而 .css 文件包含组件的样式相关信息。

请记住,在文件被加载到浏览器缓存后,回调函数将异步调用。

此外,请注意,MainCore 模块使用 Utilities 子模块(幕后操作)来处理从服务器请求和加载组件文件的技术细节。

组件的动态加载可能是应用程序中最复杂的操作,仅通过阅读本节可能难以完全理解。我强烈建议您下载应用程序的代码,并在运行应用程序时在此节中提到的方法中设置断点。这样做将帮助您完全理解动态加载组件的流程。

在组件被动态加载到应用程序后,它仍然需要向 MainCore 模块注册自己。我们将在本章的后面部分更详细地讨论这个过程。

MainCore 模块中的路由功能

由于我们的应用程序是一个单页应用程序,并且其视图需要根据用户的交互进行更改,因此我们需要实现客户端路由功能。这也意味着我们需要根据用户点击浏览器的后退和前进按钮来整合页面视图的更改。

在我们的应用程序中,我已使用 HTML5 历史 API 作为客户端路由的基础,但我们也可以使用像 History.js 这样的开源库。

让我们看看在 MainCore 模块中如何实现路由功能。

将 URL 添加到浏览器的历史对象中

考虑以下方法:

 var addToHistory = function(dataObj){        
        // if history object is supported
     if(!!(window.history && history.pushState)){   
         history.pushState(dataObj,dataObj.url, dataObj.url);
     }else{
         alert('Your browser needs to be upgraded to the latest version');
         Core.log(3, "History API is not supported; from addToHistory");
     }  
};

当应用程序需要在浏览器的历史对象中创建条目时,将调用此方法。例如,当点击页眉部分的主页链接时,在页眉组件中会调用以下回调函数:

handleHomeClick: function (e) {
     sandBox.loadPage("index.html");
     e.preventDefault();
     e.stopPropagation();
     sandBox.addToHistory({
          url: "index.html"
     });
},

如您所见,回调函数调用其 SandBox 实例的 addToHistory API,然后 SandBox 实例调用 MainCore 模块的 addToHistory 方法。匿名对象传递给此方法,然后浏览器的历史对象按以下方式更新:

var addToHistory = function(dataObj){

        // if history object is supported
        if(!!(window.history && history.pushState)){

            history.pushState(dataObj,dataObj.url, dataObj.url);            

        }else{

            alert('Your browser needs to be upgraded to the latest version');
            Core.log(3, "History API is not supported; from addToHistory");
        }        
}; 

将三个参数传递给历史对象的 pushState API。这些参数分别是;State 对象、TitleURL

在我们之前的示例中,以下对象将被作为第一个参数传递给此 API:

{
          url: "index.html"
} 

注意

注意,我们使用同一对象的url属性来设置其他参数。

如需了解更多关于浏览器历史 API 的信息,您可以参考以下链接:

developer.mozilla.org/en-US/docs/Web/API/History_API

从浏览器的历史对象中获取 URL

要从浏览器的历史对象中获取 URL 条目,我们首先将浏览器的popstate事件绑定到 MainCore 的getFromHistory方法,如下所示:

addEventHandlerToElem(window,'popstate',getFromHistory);

这意味着每当浏览器发生pop事件时,即用户点击浏览器的后退或前进按钮时,以下方法就会被调用:

 var getFromHistory = function(e){
        // if history object is supported
        if(!!(window.history && history.pushState)){
            if(e.state){
                Core.handlePageChange(e.state.url);
            }else if(e.originalEvent && e.originalEvent.state){ // to get the original event in case of jQuery
                Core.handlePageChange(e.originalEvent.state.url);
            }else{
                Core.log(2, "Could not get the state of event from history object");
            }            
        }else{            
            alert('Your browser needs to be upgraded to the latest version');
            Core.log(3, "History API is not supported; from getFromHistory");
        }        
};

此方法采取的主要行动是调用 MainCore 模块的handlePageChange方法。该方法反过来,在应用程序中发布一个page-Changed事件,如下所示:

Core.handlePageChange = function(pageURL){      
        Core.publishCustomEvent({
            type: 'page-Changed',
            data:pageURL
        });

};

当此事件发布时,所有注册了此事件的模块都会收到通知。模块可以根据发布的事件采取行动。

例如,content组件将updateContentPage方法绑定到该事件,如下所示:

'page-Changed': this.updateContentPage

为了更好地理解这一过程是如何工作的,我们还需要讨论应用程序中自定义事件和发布-订阅(观察者)模式的实现。

MainCore 中的发布-订阅实现

如前所述,我们应用程序设计的目标之一就是创建应用程序模块之间的松散耦合。

我们还讨论了这样一个事实:虽然我们的应用程序模块彼此之间并不了解,但它们需要能够以间接的方式相互通信。

这些目标可以通过利用中介者设计模式并在该模式周围实现发布-订阅机制来实现。

正如我们在上一章中看到的,在中介者模式中,应用程序的各个部分不会直接为自定义事件相互注册。相反,它们为这些事件注册,并通过一个中介部件来接收此类事件的广播通知。在我们的应用程序中,中介部件是我们的 MainCore 模块。

注意

如果您不熟悉中介者或发布-订阅模式,我强烈推荐阅读Simon Timms的《Mastering JavaScript Design Patterns》,该书讨论了许多流行的模式以及中介者模式。您可以在以下链接找到这本书:

www.packtpub.com/application-development/mastering-javascript-design-patterns

为自定义事件注册组件

MainCore 模块中的以下方法用于将组件注册为接收自定义事件:

var registerForCustomEvents = function (componentID,eventsObj) {  
    if (typeof componentID === 'string' && typeof eventsObj === 'object') {
        for(var i=0; i< Core.registeredComponents.length ; i++){            
            if(Core.registeredComponents[i].id === componentID){
                Core.registeredComponents[i].events = eventsObj;
                }
            }
        } else {
            Core.log(3,'Incorrect parameters passed in, from registerForCustomEvents');
        }
};

如您所见,此方法将传递给它的自定义事件对象与组件的 ID 注册,当事件发布时需要通知该组件。

例如,content组件注册了以下事件:

registerForCustomEvents : function(){          
    sandBox.registerForCustomEvents({
        'img-Clicked': this.updateContentPage, //  handles  image click
        'page-Changed': this.updateContentPage, // handles back and forward buttons
        'favorites-Clicked':this.updateContentPage
    });            
},

当上述任何事件被发布时,该事件的绑定事件处理器方法就会被调用。

注意,在上面的代码中,组件没有将任何组件 ID 传递给 SandBox 模块实例。然而,与该组件关联的 SandBox 模块实例按照以下方式将所需的组件 ID 发送到 MainCore 模块:

 registerForCustomEvents : function(eventsObj){            
    if(eventsObj && typeof eventsObj === "object"){
        Core.registerForCustomEvents(componentID,eventsObj);                 
    }else{                
        Core.log(3,"incorrect parameter passed in; from SandBox.registerForCustomEvents");
    }  
},

当我们在本章后面讨论 SandBox 模块时,我们将讨论 SandBox 实例如何知道组件 ID。

通过组件广播自定义事件

当组件需要在应用程序中发布自定义事件时,它们通过使用 MainCore 模块中的 publishCustomEvent 方法来实现。

考虑以下用于此方法的代码:

var publishCustomEvent = function(eventObj){      
    for(var i=0; i< Core.registeredComponents.length ; i++){ 
        if(Core.registeredComponents[i].events && Core.registeredComponents[i].events[eventObj.type]){
            Core.registeredComponents[i].eventseventObj.type;
        }
    }
};

如您所见,一个事件对象被传递到这个方法中。该方法遍历所有已注册此事件的组件,然后调用组件上与该事件关联的事件处理器。

组件可以使用它们的 SandBox 模块实例发布它们的事件。例如,应用程序的 header 组件广播应用程序中的收藏链接已被点击,如下所示:

handleFavouritesClick: function (e) {

            e.preventDefault();
            e.stopPropagation();

      ...     
            sandBox.publishCustomEvent({
                type: 'favourites-Clicked',
                data: "favourites"
            });
            sandBox.addToHistory({
                url: 'favourites' //update url in the browser
            });            
}

由于 content 组件已注册此事件,因此随后的调用 content 组件的 updateContentPage 方法。

总结来说,应用程序组件发布自定义事件,然后注册了这些自定义事件的组件通过 MainCore 模块得到通知,MainCore 模块调用与这些自定义事件关联的方法。因此,MainCore 模块在应用程序的所有组件之间充当了中介件。

这就结束了我们对核心模块的讨论和审查,但请记住,整个核心模块为应用程序提供了许多超出我们在此讨论的功能。所有此类功能要么在 MainCore 模块(MainCore.js)中实现,要么在其他子模块中实现,这些子模块共同构成了核心模块。

请记住,从组件的角度来看,所有应用程序功能都是由 SandBox 模块的一个实例提供的。

SandBox 模块实现

如前所述,我们应用程序中的组件不直接相互通信,也不直接与其他应用程序部分通信。组件与应用程序其余部分之间的唯一连接是通过分配给它们的 SandBox 模块实例。

SandBox 模块的构造函数

考虑以下代码片段:

var SandBox = function(Core,contextElem, componentSelector){
    var Core = Core, containerElemContext = contextElem, componentID = componentSelector;

    return{        
        getElement : function(elementID){            
            if(elementID && typeof elementID === "string"){
                return Core.getElement(elementID);

            }else{                
                Core.log(3,"incorrect parameters passed in; from SandBox.getElement ");
            }
        },
...    
};

如前述代码所示,SandBox 模块被创建为一个全局对象 SandBox,并且由 MainCore 模块通过构造函数传递了三个参数给它。Core 参数,是 MainCore 模块的引用;contextElem 参数,是组件视图所属的容器元素的引用;以及 componentSelector,它是沙盒组件的 ID。

例如,当header组件的SandBox实例被实例化时,它接收以下参数:作为第一个参数的核心模块引用,headerContainer,这是页面上header组件视图容器的 ID,作为第二个参数,以及header,这是header组件的 ID,作为第三个参数。

SandBox模块实例使用对核心模块的引用来获取核心模块提供的功能。

SandBox实例中设置组件的容器上下文

SandBox模块实例使用组件视图的容器元素的引用来设置组件 HTML 元素的 DOM 上下文。这样,当需要访问此容器内的 HTML 元素时,搜索可以从容器元素开始,而不是从页面的document元素开始。

看看下面的代码:

 getElementInContext : function(elementID){        
    if(elementID && typeof elementID === "string"){
        return Core.getChildOfParentByID(containerElemContext,elementID);
    }else{
        Core.log(3,"incorrect parameters passed in; from SandBox.getElementInContext");
    }            

},

在这里,SandBox模块实例正在调用核心模块的getChildOfParentByID方法。此方法接受两个参数,containerElemContextelementID。目标是搜索 DOM 树以找到传递进来的 ID 对应的元素。

使用容器元素的DOM上下文,我们可以从组件的容器开始搜索,而不是从 DOM 树的最顶层元素开始。这种方法使我们能够对找到组件元素进行更优化的搜索。

识别SandBox模块的实例

作为SandBox模块构造函数的第三个参数传入的组件 ID,用于标识SandBox模块的实例。核心模块使用这个 ID 来识别SandBox实例属于哪个组件。

例如,组件的 ID 在以下SandBox模块的方法中使用:

unregisterCustomEvent : function(eventType){

            if(eventType && typeof eventType === "string"){
                Core.unregisterCustomEvent(componentID,eventType);

            }else{

                Core.log(3,"incorrect parameter passed in; from SandBox.unregisterCustomEvent");
            } 
},

在上面的代码片段中,我们正在调用核心模块的unregisterCustomEvent方法,并传入参数componentID。这样核心模块就能知道哪个SandBox实例正在发起这个调用,进而知道哪个组件希望取消订阅自定义事件。

SandBox模块注意事项

关于SandBox模块还有一些其他事项需要注意。首先,SandBox模块可以被设计成只为组件提供核心模块功能的一个子集。这使我们能够控制组件可以访问哪些应用程序功能。其次,SandBox模块的所有方法都设计为进行非常基本的错误检查。这个过滤器将已通过基本级别验证的调用传播到核心模块。

请记住,SandBox模块应该只是一个位于组件和应用程序其余部分之间的薄层。因此,最好在SandBox模块级别不要进行广泛的验证。

什么是薄层,这取决于解释,但由于应用中可能存在许多 SandBox 模块的实例,最好将其保持得尽可能薄。

应用程序组件

考虑到我们的应用程序是一个 概念验证 类型的应用程序,我们只实现了四个组件;headerfootercontentnotificationWidget(我使用的是应用程序中使用的组件 ID)。

这些组件在应用程序的项目中的以下文件中实现,分别是 ImagesInc_Header.jsImagesInc_Footer.jsImagesInc_Content.jsNotificationWidget.js

在接下来的章节中,我将概述每个组件,但我强烈建议你查看本书附带的代码,以更好地理解这些组件是如何实现的。

请记住,我们的组件是模块,它们创建不同的应用程序视图。在 MVC 或 MV* 架构模式范式中,我们的组件包含它们自己的控制器,同时利用通过它们自己的 SandBox 模块实例提供的应用级控制器。

组件还实现了它们自己的视图,每个组件只了解其在应用中的视图。

虽然存在一个应用级模型(ImagesInc_GlobalData),并且我们的组件可以使用存储在这个应用模型中的数据,但每个组件也可以包含它自己的模型。因此,每个组件实现了它自己的 MVC 或 MV* 架构。

使用 MainCore 模块注册组件

我们已经从 MainCore 的角度讨论了组件是如何注册到 MainCore 模块的。现在是时候看看在组件级别是如何操作的。

无论组件是在应用启动阶段还是稍后加载,注册组件到应用中的机制总是相同的。

考虑以下代码片段:

ImagesInc_Core.registerComponent("mainPageContainer", "notificationWidget", function(sandBox){

...
    var widgetMainContainer, stockSnapURL = "https://stocksnap.io";

    return {        
        init: function(){
            try{                               
                sandBox.contextObj = this;
                sandBox.logMessage(1,'Notification Widget component has been initialized...', 'blue');

            }catch(e){
                sandBox.logMessage(3,'Notification Widget has NOT been initialized correctly --> ' + e.message);
            }
        },

        destroy: function(removeComponent){

            sandBox.contextObj.unregisterFromEvents();            
            if(removeComponent){
                sandBox.removeComponentFromDom("widgetContainer");
            }           
            sandBox.logMessage(1,'Notification Widget has been destroyed...', "blue");
        },
...

}

正如你所见,当一个组件的 .js 文件被加载时,例如之前展示的 notificationWidget,会调用 MainCore 模块的 registerComponent 方法。因此,在应用中加载任何组件文件之前,都需要先加载 MainCore 模块。

由于我们之前已经讨论了组件是如何被调用的,以下内容对你来说应该只是复习。

在前面的代码中,当 NotificationWidget.js 文件被加载时,会调用 MainCore 模块的 registerComponent 方法,并带有三个参数:mainPageContainer,这是承载组件视图的容器元素的 ID;notificationWidget,这是组件本身的 ID;以及第三个参数作为一个 callback 函数。当这个 callback 函数被 MainCore 模块调用时,它会接收到 SandBox 模块的一个实例。

注意,此回调函数的返回对象是组件的单例对象,它具有所需的 initdestroy 方法。当然,这些方法被应用程序用于初始化和销毁组件实例。当我们讨论 notificationWidget 组件时,我们将更详细地介绍这些方法。

这里还有一个需要注意的事项,那就是我们在回调函数中创建了一个闭包,因此只有从该函数返回的对象才能访问组件的私有变量。

标题组件

此组件使用 ID header 在 Core 模块中注册自己,从那时起,应用程序使用此 ID 来引用此组件。

与应用程序中的所有其他组件一样,此组件实现了所需的 initdestroy 方法。

header 组件也实现了用于其元素附加和移除事件处理程序的方法。handleFavouritesClick 方法是此组件中事件处理程序的一个示例。

考虑以下事件处理程序的实现:

handleFavouritesClick: function (e) {
            e.preventDefault();
            e.stopPropagation();
            var favoritedImagesArray = sandBox.getValueAsArrayFromCookie(ImagesInc_GlobalData.getFavCookieName());

            if(!favoritedImagesArray){ 
                alert('No favorites have been selected!');
                return;
            }            
            sandBox.publishCustomEvent({
                type: 'favorites-Clicked',
                data: "favorites"
            });
            sandBox.addToHistory({
                url: 'favorites' //update url in the browser
            });            
}

当点击 header 组件的 Favorites 链接时,会调用此方法。它首先检查浏览器 cookie 中是否存储了任何收藏的图片。然而,此检查不是由组件直接完成的,因为这个调用是针对该组件的 SandBox 模块实例进行的。

注意,header 组件并不知道检查 cookie 的机制是如何实现的,它也不需要自己实现此类功能。

如果在未来某个时刻改变了这种机制,这对该组件没有影响。组件将始终调用 SandBox 模块实例的相同方法,并让应用程序处理此操作。

还请注意,此方法更新浏览器中的 URL,并发布一个自定义事件,通知应用程序已发生特定事件。当然,组件通过 SandBox 模块实例利用应用程序的功能来完成所有这些任务。

页脚组件

footer 组件的设计与 header 组件非常相似,并且它使用 footer ID 在 Core 模块中注册自己。

组件中我们需要查看的一个方法是 handleSupportClick。当点击 footer 组件的 Support 链接时,会调用此方法。让我们检查此方法的实现,如下所示:

handleSupportClick : function(e){            

            sandBox.publishCustomEvent({
                type: 'support-Clicked',
                data: "support"

            });            

            e.preventDefault();
            e.stopPropagation();
}

如您所见,当点击链接时,会发布自定义事件 support-Clicked。如果您回忆起本章前面的某个部分,NotificationHandler 模块正在监听此事件,并在事件发生时采取必要的行动。

这又是一个示例,说明尽管这些应用程序的部分正在相互通信,但实际上它们并不了解彼此的存在。这些组件通过我们的发布-订阅实现松散耦合。

内容组件

这个组件可以被认为是我们的应用程序的主要组件,它负责改变内容区域视图。它使用content的 ID 向 Core 模块注册自己。

内容组件实现了所需的initdestroy方法以及许多其他方法。

我们将在这里查看一些重要的方法。

处理内容区域点击事件

当用户在应用程序中点击添加到收藏链接或当内容区域中的图像被点击时,content组件视图的父容器捕获事件并处理它。

事件处理绑定的父容器发生在content组件的init方法中,如下所示:

sandBox.addEventHandlerToParent("click", this.handleMainContainerClicked);

正如你所见,handleMainContainerClicked是处理这个点击事件的回调方法。

考虑这个方法是如何实现的:

handleMainContainerClicked: function (e) {
    if (e.target != e.currentTarget) {
        e.preventDefault();
        e.stopPropagation();

            if (e.target.tagName.toUpperCase() === 'IMG') {
                sandBox.contextObj.handleImageClick(e.target);
            } else if (e.target.tagName.toUpperCase() === 'A') {
                sandBox.contextObj.handelFavLinkClick(e.target);
            }
    }
}

如所示,我们检查点击事件是否发生在图像或锚标签上,并根据这个判断,将事件处理的其余部分委托给相关的方法。

处理添加到收藏链接的点击事件

handelFavLinkClick方法中,我们检查添加到收藏链接的状态,并将其文本更改为从收藏中移除。如果链接已经被点击,并且包含文本从收藏中移除,我们将文本重置为其默认值添加到收藏

我们还在浏览器的 cookie 中添加或删除收藏图像的 ID,因此当用户导航到收藏页面时,正确收藏的图像会在内容区域渲染。

考虑这个方法的实现:

handelFavLinkClick: function (elem) {
    var anchorState, parentNode, anchorID;
    anchorState = elem.getAttribute('data-state');
    anchorID = elem.getAttribute('id');
    parentNode = sandBox.getParentNode(elem);

    if (anchorState) {
        sandBox.removeValueFromCookie(favCookieName,anchorID);
        sandBox.updateElement(parentNode, sandBox.contextObj.getAnchorHTMLStr(anchorID));

    } else {
        sandBox.populateCookie(favCookieName,anchorID);
        sandBox.updateElement(parentNode, sandBox.contextObj.getAnchorHTMLStr(anchorID, true));
    }
    sandBox.publishCustomEvent({
        type: 'FavLink-Clicked',
        data: anchorID
    });
},

正如你所见,在这个方法中我们还有其他一件事情要做,那就是广播相关的自定义事件到应用程序。这允许所有注册了这个事件的程序的其他部分都能得知这个组件中发生的事件。

处理图像点击事件

当应用程序内容区域中的图像被点击时,将调用以下方法:

handleImageClick: function (elem) {
            var imgName;

            imgName = elem.getAttribute('data-name');
            sandBox.publishCustomEvent({
                type: 'img-Clicked',
                data: imgName
            });
            sandBox.addToHistory({
                url: imgName
            });
}

注意这个方法做了两件主要的事情。一是更新浏览器的历史对象,以便当用户点击浏览器的后退和前进按钮时,应用程序路由器可以正常工作。二是向应用程序广播消息img-Clicked

需要考虑的有趣点是,我们的content组件本身监听这个事件,并对此事件采取行动来更新页面视图,使用以下事件注册方法:

registerForCustomEvents : function(){

      sandBox.registerForCustomEvents({
            'img-Clicked': this.updateContentPage, 
       'page-Changed': this.updateContentPage,
             'favourites-Clicked':this.updateContentPage
      });            
}

正如你所见,当img-Clicked事件被content组件(它自身生成的)接收时,那么这个组件的updateContentPage方法就会被调用。

当然,我们可以在 handleImageClick 方法中直接更新页面的视图,但通过在应用程序级别广播事件,我们可以确保应用程序的所有部分,包括 content 组件本身,都可以在事件广播时采取行动。

内容区域生成方法

content 组件也有负责根据浏览器 URL 生成页面内容区域的方法。以下是对这些方法的总结:

  • buildFavouritesPage:此方法加载与收藏夹页面相关的 CSS 文件(使用 SandBox 模块实例),然后根据用户之前选择的收藏图像数量渲染此页面

  • buildIndexContentHTML:如名称所示,此方法负责为 index.html 页面的内容区域构建 HTML 标签

  • buildImagePageHTML:使用此方法渲染点击的图像的全视图

  • getAnchorHTMLStr:此方法为每个图像生成相关链接,以便根据链接的状态将图像添加或从收藏夹图像列表中删除

我强烈建议您查看此组件的代码,以了解这些方法是如何实现的。

NotificationWidget 组件

当这个组件加载时,它将自己注册为 notificationWidget 到核心模块。

我们之前讨论过这个组件,尽管其设计与应用程序中的所有其他组件类似,但也有一些不同之处。

如前所述,这个组件不是在应用程序启动阶段加载或渲染的。它仅在需要时根据用户与应用程序的交互动态加载和渲染。

NotificationWidget 的模型

这个组件的视图以字符串形式存储在组件本身中,如下所示:

var widgetInnerHTMLStr = '<div id="notificationMainContainer">' +
        '<h1 class="centerElem header">Thank you for visiting us.</h1>' +
        '<h3 class="centerElem header">All the images on this site are provided by <a href="https://stocksnap.io">stocksnap.io</a>.</h3>' +
        '<h3 class="centerElem header">We thank them and encourage you to visit their site.</h3>' +
        '<div class="buttonContainer">' +
            '<div class="button button-left" id="notification_visit">Visit stocksnap.io</div>' +
            '<div class="button button-right" id="notification_close">Close</div>' +
        '</div>' +
    '</div>';

这个字符串由组件传递给 MainCore 模块以渲染其视图。您很快就会看到这是如何完成的。

通知组件所需的方法

如前所述,每个组件都需要实现所需的方法:initdestroy

请看以下 notificationWidget 组件的 init 方法,如下所示:

 init: function(){
            try{                               
                sandBox.contextObj = this;
                sandBox.logMessage(1,'Notification Widget component has been initialized...', 'blue');

            }catch(e){
                sandBox.logMessage(3,'Notification Widget has NOT been initialized correctly --> ' + e.message);
            }
}

当应用程序调用组件的 init 方法,在这种情况下是 notificationWidget,就会向传递给组件的 SandBox 模块实例添加一个新属性。这个属性是 contextObj,其值设置为 this

在这个上下文中,this 是对组件本身的引用,并被分配给 SandBox 实例,这样我们就可以在需要时轻松访问组件的上下文。这个属性对于组件中的事件处理程序回调特别有用。

考虑以下来自 notificationWidget 组件的代码片段:

handleCloseClick : function(){
            sandBox.contextObj.unregisterFromEvents();
            sandBox.removeComponentFromDom("widgetContainer");
} 

当用户点击对话框的关闭按钮时,该按钮是notificationWidget组件的视图,此时会调用此回调函数。你可能想知道为什么我们不从回调函数中调用组件的内部方法,如下所示:

this.unregisterFromEvents();

为了使上述函数调用成功,回调函数需要在组件的上下文中运行。然而,由于this的上下文在代码执行时设置为调用此回调函数的对象,而不是组件,因此我们无法使用this调用组件的内部方法。

因此,通过在SandBox实例上设置属性contextObj,我们能够轻松访问组件的原始上下文并调用所需内部方法,如下所示:

sandBox.contextObj.unregisterFromEvents();

当然,我们也可以使用 JavaScript 的bind()方法来实现相同的作用域保留,但我选择了这种方法,这样你可以看到解决这个问题的另一种方式。

组件的destroy方法用于使其失效(组件上不会处理任何事件)或完全从 DOM 中移除组件。

考虑notificationWidgetdestroy方法实现,如下所示:

destroy: function(removeComponent){

            sandBox.contextObj.unregisterFromEvents();

            if(removeComponent){
                sandBox.removeComponentFromDom("widgetContainer");
            }

      sandBox.logMessage(1,'Notification Widget has been destroyed...', "blue");

},

根据传递给此方法的removeComponent标志(truefalse)的值,组件要么被禁用,要么完全从 DOM 中移除。

注意,在上面的代码中,组件使用其SandBox模块的实例从 DOM 中移除自身,并且它不需要自己实现此类功能。这使得组件只需专注于自己的专业任务,并利用应用程序(通过SandBox实例)提供的功能。这种设计也限制了组件对其自身世界之外的访问,因此我们可以最小化可能的 DOM 操作冲突,如前一章所述。

渲染notificationWidget

NotificationHandler调用组件的渲染方法时,该组件被渲染,如下所示:

ImagesInc_Core.getComponentByID("notificationWidget").renderWidget();

考虑此方法的实现,如下所示:

renderWidget : function(){

    var generatedWidget;

    generatedWidget = sandBox.createDocumentLevelComponent(widgetInnerHTMLStr);

    generatedWidget.id = "widgetContainer";
     sandBox.setElementContext(generatedWidget.id);
     this.registerForEvents();  
},

组件使用其SandBox模块实例上的方法创建一个文档级别的组件(即自身),如下代码行所示:

generatedWidget = sandBox.createDocumentLevelComponent(widgetInnerHTMLStr);

相应地,从SandBox模块实例中,调用以下核心模块的方法:

var createDocumentLevelComponent = function(compnentViewStr){
    var mainComponentContainer;

    mainComponentContainer =  document.createElement("DIV");
      mainComponentContainer.innerHTML = compnentViewStr;
      document.body.appendChild(mainComponentContainer);

    return mainComponentContainer;

};

并且从核心模块到组件对象的renderWidget方法传递了渲染组件的视图的引用。然后,渲染组件的视图被标记了一个 ID,并在SandBox模块实例上设置了其上下文,如下所示:

generatedWidget.id = "widgetContainer";
sandBox.setElementContext(generatedWidget.id);

注意,只有在这一阶段,组件才会注册事件。这与应用程序中的其他组件不同,它们在初始化时注册事件。原因是当调用此组件的 init 方法时,组件的视图尚未在页面上渲染。因此,没有 HTML 元素供此组件的视图附加事件。只有在组件视图渲染之后,我们才能将所需的事件处理程序附加到元素上。

此小部件是我们如何可以在启动阶段之后随时渲染和激活应用程序中组件的一个示例。

GlobalData 模块

如其名所示,此模块旨在存储应用程序级数据,并作为我们应用程序的模型。它还提供了公共方法,以便其他模块可以在此模块中获取和设置数据。此模块在应用程序中定义为 ImagesInc_GlobalData 全局变量。

下面的示例展示了模块存储的数据类型:

    var favCookieName = "Images_Inc", 
        pageDefintionsFile = "PageDefinitions.js", 
        pageDefinitionsFilePath = "js/Modules/PageDefinitions/",
...

此模块通过子模块 ImagesInc_PageSections 进行增强,该子模块存储与 headerfooter 组件相关的 HTML 标记字符串。

值得关注的是,这个子模块是如何添加到 GlobalData 模块的。

var ImagesInc_GlobalData = ImagesInc_GlobalData || null;

var ImagesInc_PageSections = (function(mainModule, subModule){
 // assigning the subModule if it is passed in and also augmenting sub-module
    var pageSections = mainModule.pageSections = mainModule.pageSections || subModule;

...

 })(ImagesInc_GlobalData || {}, ImagesInc_PageSections || {} ); // using Asynchronous sub-module

如您所见,我们正在使用异步子模块增强技术将子模块添加到 GlobalData 模块。

这种技术使我们能够以我们希望的方式按任何顺序加载 GlobalData 及其相关的子模块 (ImagesInc_PageSections),而不会影响模块的增强。与我们在应用程序中向 MainCore 模块添加子模块的方式相比,增强方式不同,因为它们只能在 MainCore 模块加载后添加。

注意,当我们执行 IIFE 时,如果子模块不存在于应用程序中,我们会创建它,如下所示:

(ImagesInc_GlobalData || {}, ImagesInc_PageSections || {} );

异步子模块增强还允许我们在子模块已在应用程序中存在的情况下对其进行增强。增强方式在下面的代码片段中显示:

var pageSections = mainModule.pageSections = mainModule.pageSections || subModule;

    pageSections.headerContainerDefObj = {

    sectionHTML :  '<div id="logoDiv" class="logo_titleClass" >' +
...

}

必须承认,这种技术比我们在应用程序其他地方使用的其他增强技术要复杂一些,但它确实提供了更大的灵活性。

我鼓励您查看与此子模块相关的代码(GlobalData_Sub.js),以更好地理解该技术是如何工作的。

摘要

在本章中,我们更详细地研究了我们应用程序的实现,并看到了所有组件是如何相互配合,在代码层面上创建我们的模块化生态系统的。

我们研究了如何通过利用不同的增强技术向 MainCore 模块添加子模块来创建 Core 模块(我们应用程序的控制器)。这种方法使我们能够轻松并以模块化的方式扩展应用程序的功能。

通过创建SandBox模块的实例,我们在组件和应用程序的其他部分之间建立了通信桥梁,同时保留了我们设计中的松耦合原则。

我们应用程序的一个重要方面是能够动态加载组件,我们讨论了这种功能是如何在我们的核心模块中实现的。

使用发布-订阅和中介模式,我们创建了一个事件处理机制,应用程序的所有部分都可以使用这个机制相互通信。这个机制也被用于我们的客户端路由器,以更改应用程序的视图。

在本章末尾,我们创建了一个全局模块来存储我们的应用程序级数据,并作为我们 MV*设计中的模型部分。

在下一章中,我们将讨论我们应用程序模块的测试,并了解单元测试如何帮助我们维护应用程序在开发和运营阶段的一致性。

第九章:模块化应用程序设计和测试

现在我们已经完成了应用程序的实现,是时候讨论如何对其进行测试了。当然,我们测试应用程序是为了确保一切按预期工作,并且未来的代码库更改不会破坏应用程序的功能。

在这里需要记住的一件事是,我们是在完成实现后编写测试的。然而,有时我们会在编写应用程序代码之前编写测试。

策略是我们首先编写测试,并预期它们会失败,因为没有代码实现。然后,随着我们实现应用程序,测试开始通过,我们可以确信实现的代码按预期运行。

你可以选择在项目中选择这种方法,即先实现后测试,但我想要强调的是,无论采用哪种方法,你都需要编写一些自动化测试!

考虑到这一点,在本章中,我们将探讨如何编写一些单元测试,以及我们的应用程序设计中模块化方法如何使编写自动化测试更加容易和可维护。为了简洁起见,我们只为两个模块编写单元测试,但讨论的原则也可以用于测试其他模块。

在本章中,我们将涵盖:

  • 如何单独测试我们的模块

  • 无需第三方框架编写测试

  • 使用第三方工具改进和简化我们的单元测试

  • 使用 Jasmine 作为测试框架

  • 使用 Mocha 及其相关断言库作为测试框架

编写自动化测试的优势

作为开发者,我们的主要关注点是编写能够产生预期功能和结果的代码。虽然这个原则是正确的,但我们实现最终实现目标的方式也非常重要。

一个设计得当的应用程序不仅关乎实现应用程序的最终目标,还应该关于实现一个易于扩展和维护的代码库。

采用模块化方法当然有助于我们实现这些目标,但随着我们对代码库进行更改,以及我们的应用程序经历其生命周期,我们需要确保应用程序的所有部分仍然正常工作。

当我们对应用程序的一部分进行更改时,我们需要确保更改不会对代码库的其他部分产生不利影响。当然,确保这一点的其中一种方法是对所有内容进行手动测试,并检查应用程序在所有设计情况下设计的每个方面。但这种方法不仅耗时,而且非常繁琐,因为我们需要为应用程序的每一次更改都通过相同的过程。

此外,特别是在大型项目中,其他开发者可能正在处理应用程序的各个部分。我们如何确保他们的更改不会对我们负责的应用程序部分产生不希望的影响?

自动化测试使我们能够检查我们从代码库中期望的功能,无论是针对特定代码片段还是整个应用程序。我们一次编写测试,然后可以随意多次运行它们,无论是代码库发生变化还是作为我们常规测试过程的一部分。

自动化测试的另一个优点是,随着我们实现代码,我们养成了考虑如何使用自动化测试来测试特定功能的好习惯。这种思维方式导致编写更好、更针对性和模块化的代码。

不同类型的自动化测试

自动化测试的类型有很多,但我们将考虑并讨论以下列出的三个测试类别:

  • 单元测试

  • 集成测试

  • 端到端测试

单元测试

单元测试通常设计为测试我们代码各个部分的功能,在隔离状态下进行。这通常意味着一次测试我们的函数和方法,以确保它们确实按照预期执行。

我们通常以可以验证我们的方法和代码各个部分在不同场景下的功能的方式编写此类测试。

编写单元测试有两种主要风格;测试驱动开发TDD)和行为驱动开发BDD)。

让我们对它们是什么以及它们如何不同有一个简化的概述。

TDD 单元测试

TDD 单元测试主要用于测试我们代码的实现。这是通过测试方法产生的实际结果与预期结果进行比较来完成的。

如果我们在代码实现之前编写测试,TDD 流程可以被视为以下循环:

  1. 编写单元测试

  2. 运行测试并预期它失败

  3. 编写使测试通过的代码

  4. 再次运行测试以确保测试通过

  5. 如有必要,重构代码

  6. 从第一步重新开始

如您所见,TDD 的目的是从项目的开始阶段实施,并贯穿整个项目生命周期。

BDD 单元测试

这种测试风格关注的是我们代码的预期行为,而不是其实现。

当我们使用 BDD 风格编写单元测试时,我们以类似自然句子的方式编写我们的断言。

例如,测试应该读作:“返回一个比前一个值增加 1 的值”。

BDD 也可以遵循 TDD 部分中显示的相同过程循环。

TDD 与 BDD

假设我们有一个计数器函数,当它第一次被调用时,它将返回值 1,之后每次调用都将返回前一个计数器的值加 1。

在 TDD 风格的单元测试编写中,我们测试函数是否以默认值(起始值)零初始化,因为这与函数第一次被调用时的非常第一次调用相关联。

这个细节(起始值是 0)是实现方面,TDD 风格的测试编写检查这样的实现细节。这也意味着,如果我们决定在我们的计数器函数中,默认值(起始值)应该更改为 2,我们的测试用例也需要相应地更改。

在 BDD 风格的单元测试编写中,我们不会检查函数第一次被调用时返回的值。我们只检查每次函数被调用时计数器是否增加了 1。这意味着,如果我们后来改变函数的起始值,它对函数的预期行为没有影响。我们的函数应该始终将前一个值增加 1,无论起始值是多少。

TDD 和 BDD 之间的区别非常微妙但很重要。

在本章中,我们将专注于以 BDD 风格编写单元测试。

测试覆盖率

写足够的单元测试以实现代码的 100%测试覆盖率是理想的。然而,在现实中,并不总是可能为代码的每个方面编写单元测试。

当然,我们编写的测试越多,我们预期的代码质量就越好,但每个项目的时间线也需要考虑。实际上,我们并不总是有足够的时间通过单元测试实现代码库的全面覆盖。然而,我们应该记住,从长远来看,当我们有更多的代码覆盖率时,我们在查找和修复错误上节省的时间会更多。

如果时间不足,我的建议是确保你至少为应用程序的核心部分编写单元测试,并覆盖 100%。然后,如果时间允许,将你的注意力转向其他非关键部分,并为这些部分尽可能多地编写单元测试。

这样,你可以确保应用程序核心的质量和完整性,并隔离非核心和非关键模块中可能的问题。

集成测试

这种类型的测试主要关注确保应用程序的不同部分可以正确协同工作。

当涉及不同的方法和模块以提供某种功能时,我们希望测试并查看这些组件之间的协作总和是否实现了所需的功能。

例如,一个函数可以从文件中读取一个字符串并将其传递给另一个函数,该函数根据字符串中的逗号分隔符创建一个数组。我们的集成测试将确保基于这两个函数一起读取和处理字符串,生成了正确的数组。

端到端测试

这些测试通常检查应用程序功能从开始到结束的流程,以确保整个应用程序按预期正常工作。

例如,为了测试基于页面表单提交的应用程序的正确行为,我们可以使用 AJAX 调用将表单值提交到服务器,从服务器获取结果,然后根据返回的值刷新应用程序的内容区域。通过检查最终结果,我们可以确信我们的应用程序按预期运行。

端到端(也称为E2E)测试通常在单元测试和集成测试通过后进行。

编写单元测试的简单开始

编写单元测试并不是关于使用最新和最好的单元测试工具和库。它只是关于在隔离状态下测试代码的小片段。我们应该始终牢记的一个简单事实是,单元测试的主要目标是确保我们代码的正确功能和完整性。即使您不熟悉任何单元测试工具,您仍然可以使用您在 JavaScript 中已有的技能编写自己的单元测试。

然而,正如您将在本章后面看到的那样,使用第三方工具和框架可以极大地帮助我们编写更好、更复杂的测试。

在本章的剩余部分,我们将针对两个子模块(CookieHandlerStorageHandler)进行目标测试,并为它们编写一些简单的单元测试。我们还将探讨我们的模块化架构如何帮助我们为每个模块编写有针对性的独立单元测试。

不使用任何单元测试框架编写单元测试

虽然我不建议在没有第三方库和框架的帮助下编写单元测试,但我的重点是让您开始编写单元测试,无论您是否使用测试框架。一旦开始,您将逐渐习惯这个过程,很快它就会成为您常规开发流程的一部分。

因此,作为一个起点,我们将使用纯 JavaScript 编写我们的单元测试。

将 AppTester 模块添加到我们的应用程序中

由于我们的客户端架构基于模块化设计,我们将继续采用这种方法,创建一个负责运行我们的单元测试的模块。

看看我是如何在这个项目中构建这个模块的。

将 AppTester 模块添加到我们的应用程序中

AppTester 模块位于 AppTester.js 文件中,并将其作为子模块添加到我们的主核心模块(ImagesInc_Core)中,如下所示:

// adding AppTester as a sub-module
ImagesInc_Core.AppTester = (function(){
    function runAllUnitTests(){
        var testModule;
        for(testModule in ImagesInc_Core.AppTester){

            if(typeof ImagesInc_Core.AppTester[testModule] === 'object'){
                // run tests
                ImagesInc_Core.AppTester[testModule].runAllTest(); 
            }
        }        
    }    
    function reportTestResults(totalNumOfTest, passedNum, failedNum){      
        var failTestMsgColor;        
        failTestMsgColor = failedNum ? 'red':'pink';

        ImagesInc_Core.log(1, 'Total number of tests run: ' + totalNumOfTest, 'orange');
        ImagesInc_Core.log(1, 'Number of Tests Passed: ' + passedNum, 'green');
        ImagesInc_Core.log(1, 'Number of Tests failed: ' + failedNum, failTestMsgColor);        
    }    
    return {
        runAllUnitTests: runAllUnitTests,
        reportTestResults: reportTestResults
    };            
})();

此子模块公开了两个方法:runAllUnitTests,它运行添加到其中的所有单元测试,以及reportTestResults,这是我们负责在运行时显示单元测试结果的单元测试报告器。

注意,我们正在使用核心模块的日志记录机制来报告我们的测试结果,这展示了我们的模块和子模块在不同上下文中的可重用性。

将单元测试套件添加到我们的测试运行器

我们将使用你现在非常熟悉的紧耦合增强技术,将我们的单元测试添加到测试运行器 AppTester 中。

一般而言,为每个模块创建至少一个包含该模块所有相关单元测试的测试文件是一个好主意。因此,在我们的项目中,我们有两个文件;CookieHandlerTester.jsStorageHandlerTester.js。正如其名称所暗示的,其中一个包含与 CookieHandler 子模块相关的所有单元测试,另一个包含与 StorageHandler 子模块相关的所有测试。

请记住,如果您创建了大量的单元测试(越多越好),您可以进一步将单元测试分解成更小的块和文件,这些块和文件专注于每个模块的不同功能。

在我们的应用程序中,由于我们只有有限数量的单元测试,我们已将它们全部保留在其相关文件中,每个模块一个文件。

在以下章节中,我们将只讨论其中之一,因为它们的结构非常相似。

CookieHandler 模块单元测试

与我们的 CookieHandler 子模块相关的单元测试套件被添加到 AppTester 模块作为一个对象属性。这使得我们的测试运行器(AppTester)能够轻松地遍历所有单元测试并执行它们。

考虑以下代码片段:

ImagesInc_Core.AppTester = (function(mainTestModule){

    if(!mainTestModule){
        ImagesInc_Core.log(3, 'main test module not found!');
        return false;  
    }    
    var CookieTester = mainTestModule.CookieHandlerTester = {};    
    var unitTests = [], totalErrors = 0, totalPasses = 0;

    //create a new value in the cookie
    unitTests[unitTests.length] = CookieTester.createCookie = function(name, value, decodeFlag){    
        if(!name){
            name = "testCreateCookie";
        }
        if(!value){
            value = "testing for cookie";
        }
        if(!decodeFlag){
            decodeFlag = false;
        }
...
return mainTestModule;

})(ImagesInc_Core.AppTester); // using tight augmentation

注意我们如何将一个对象作为属性添加到 AppTester 模块中。这个对象(测试套件)拥有所有相关的测试作为其属性。

var CookieTester = mainTestModule.CookieHandlerTester = {};

每个单独的单元测试都添加到该对象中,如下所示:

CookieTester.createCookie = function(name, value, decodeFlag){
...
}

数组 unitTests 用于在 AppTester 模块调用 cookieTester.runAllTests 方法时,通过循环运行所有测试,如下所示:

// run all unit tests
CookieTester.runAllTests = function (){    
        ImagesInc_Core.log(1, '*** RUNNING CookieHandler MODULE UNIT TESTS ***', 'orange');

        // run all unit tests
        for(var i=0; i< unitTests.length; i++){
          unitTests[i]();  
        }

        //** test for negative result
        // should not be able to find the value below in the cookie specified
        CookieTester.findValueInCookie("testCreateCookie", "some value!", true);
        unitTests.length++;
        // should not be able to add the value to the cookie as it will be a duplicate
        CookieTester.addValueToCookie("testCreateCookie","testing for cookie",false);
        unitTests.length++;        
        mainTestModule.reportTestResults(unitTests.length,totalPasses, totalErrors);
        CookieTester.cleanup();    
};

要运行单个单元测试,我们可以通过使用它们的名称标识符,直接在 CookieTester 对象上调用它们,如下所示:

CookieTester.findValueInCookie("testCreateCookie", "some value!", true);

正因如此,当定义时,每个单独的单元测试都被添加到 CookieTester 对象和 unitTests 数组中,如下所示:

unitTests[unitTests.length] = CookieTester.createCookie = function(name, value, decodeFlag)

我强烈建议您查看本章附带项目中与 AppTesterCookieHandlerTester 模块相关的代码,以了解它们是如何实现的。

运行 CookieHandler 单元测试

在我们的项目设置中,由于我们没有使用自动构建系统,我们可以将运行所有单元测试的功能添加到我们的 index.html 和核心模块中,以便在浏览器中加载 index.html 时运行。

当然,我们不想在生产环境中这样做。通常,一个好的开发环境会利用任务运行器,如 GruntGulp 来运行单元测试以及所有其他客户端应用程序构建任务,如代码检查、压缩等。

注意

如果您不熟悉这些任务运行器,请查看以下在线资源:

gruntjs.com/

gulpjs.com/

在我们的环境中,为了运行单元测试,我们在index.html文件中调用以下方法:

ImagesInc_Core.runAllUnitTests();

我们 MainCore 模块中的runAllUnitTests方法将使用AppTester模块来运行所有我们的单元测试。

考虑我们 MainCore 模块中的以下实现:

mainCore.runAllUnitTests = function(){         
        if(typeof ImagesInc_Core.AppTester !== 'undefined'){

            try{
                ImagesInc_Core.AppTester.runAllUnitTests();                
            }catch(e){
                mainCore.log(3, 'AppTester ERROR! ' + e.name + ": " + e.message);
            }
        }else{         
            mainCore.log(3, 'AppTester not available! ');
        }                  
};

在这里,我们首先检查AppTester模块是否存在,如果存在,我们就调用该模块上的runAllUnitTests方法。

AppTester模块中,代码会遍历所有单元测试套件(在我们的例子中,是CookieHandlerStorageHandler模块的单元测试),这些是AppTester对象上的属性。如下所示:

function runAllUnitTests(){
        var testModule;        
        for(testModule in ImagesInc_Core.AppTester){            
            if(typeof ImagesInc_Core.AppTester[testModule] === 'object'){
                // run tests
                ImagesInc_Core.AppTester[testModule].runAllTests(); 
            }
        }        
}

然后,这个方法会依次调用每个单元测试套件上的runAllTests方法,正如你之前看到的:

CookieTester.runAllTests = function (){
   ...
}

由于我们的模块化架构,我们可以为每个模块添加单独的单元测试套件到AppTester模块中;我们也可以移除它们。这是通过类似我们添加CookieHandler测试套件到AppTester的方式完成的,但通过移除与测试套件相关的属性。

当我们通过加载index.html运行单元测试时,我们将在浏览器控制台中看到以下输出(我使用的是 Chrome 开发工具)。

运行 CookieHandler 单元测试

我建议你尝试编写一些自己的单元测试并将它们添加到应用程序的测试套件中。

为此所需的所有设置已经在本章的配套代码中为你实现了。

运行单元测试后的清理

总是设计我们的单元测试,以便它们在完成后自行清理,这样对应用程序或环境所做的任何修改都会重置到其原始状态。

如果你查看我们两个单元测试套件的代码,每个套件中都有一个清理方法,它正是这样做的。

考虑以下CookieHandlerTester.js中的方法:

CookieTester.cleanup = function(){    
        ImagesInc_Core.CookieHandler.deleteAllCookies();
        totalErrors = 0; totalPasses = 0;    
};

如你所见,由于我们的单元测试向浏览器添加了一些 cookie,并对其进行了操作,我们想确保所有创建的 cookie 都被删除,并且环境被重置到原始状态。

CookieHandler.deleteAllCookies 方法负责清除浏览器中的所有 cookie。

使用第三方框架编写单元测试

虽然我们可以仅使用 JavaScript 而不使用第三方框架编写单元测试,但这需要大量的手动工作,即使只是编写一些简单的测试也是如此。

在上一节中,我们编写单元测试的方法是一个很好的练习,但我认为我们可以通过利用专门为此任务设计的第三方框架做得更好。这些框架使我们能够以更少的努力编写更复杂的测试。

在本节中,我将向你介绍两个非常流行的第三方框架,它们允许我们编写良好、干净且专业级的单元测试。

这里的目标是给你一个良好的起点,并提供每个库的一般概述,但我们不会深入探讨任何一个。尽管如此,我希望能够激发你对使用第三方单元测试库(框架)的兴趣,让你花些时间自己更好地了解它们,并在未来的项目中使用它们。

Jasmine 简介

Jasmine 将自己定义为“一个用于测试 JavaScript 代码的行为驱动开发框架。”

Jasmine 不需要 DOM,可以在服务器端和客户端编写和运行 JavaScript 单元测试。

设置Jasmine很简单,尤其是在客户端,并且以提供所有可能从专业级测试框架中需要的功能的方式打包。我认为你会发现语法非常直观且易于理解。

我个人非常喜欢 Jasmine,非常感谢其创造者为我们提供如此出色的工具。

注意

在本节中,我将使用 Jasmine 2.4,并鼓励你访问相关网站:jasmine.github.io/

设置 Jasmine

当你访问 Jasmine 的网站时,你会看到一个下载框架独立版本的链接,这是我们在这里将用于单元测试的版本。

下载 zip 文件并提取其内容后,你会看到以下结构:

设置 Jasmine

我们将为我们的测试使用类似的文件结构,但是不需要src文件夹,这是 Jasmine 加载以运行测试的源文件的位置。

在我们的测试中,我们将直接从它们目前在项目中的位置加载MainCore模块和CookieHandler模块。

lib文件夹是 Jasmine 本身的源代码存放的地方,而spec文件夹是我们将存储我们的测试规范的地方。

在我们的应用程序中,这是最终文件结构是如何实现的:

设置 Jasmine

为了在SpecRunner.html中加载我们的测试规范,我们需要对这个文件进行以下修改,如下所示:

设置 Jasmine

如你所见,我们已经将SpecRunner.html指向了应用程序中MainCoreCookieHandler模块的文件位置,而不是使用默认的 Jasmine src文件夹。

这就是我们为了在客户端应用程序中使用 Jasmine 设置和准备所需做的所有事情。

注意

为什么加载 MainCore.js?

正如你在 SpecRunner.html 中可能已经注意到的,我在加载 MainCore.jsCookieHandler.js 文件。这并不是必要的,只要 CookieHandler.js 返回一个全局对象,然后就可以在 CookieHandlerSpec.js 中使用它来运行测试。在这种情况下,CookieHandler 对象将作为一个独立的模块运行,这要归功于我们的模块化架构。然而,为了保持代码不变,我首先加载 MainCore 模块,然后使用 CookieHandler 作为 MainCore 的子模块;因此,这两个文件都需要被加载。

创建我们的 Jasmine spec 文件

要编写和运行我们的测试,我们首先需要创建我们的 spec 文件的结构。我们通过实现我们的测试套件来完成这项工作。

测试套件是通过 Jasmine 框架的 describe 函数构建的。这个函数接受两个参数,一个字符串用于描述测试套件,一个回调函数用于实现测试套件本身。如下所示:

describe("Testing cookieHandler Sub-module", function() {
...
}

在回调函数内部是我们编写测试 spec 的地方。请注意,我们也可以嵌套 describe 函数。这意味着我们可以使用一个 describe 函数来创建整个模块的测试套件,并在其中使用嵌套的 describe 函数来为模块的每个单独的方法创建测试套件。此外,在 describe 函数内部声明的任何变量都可以在该 describe 块内的所有 spec 中使用。

让我们看看 CookieHandler 子模块的第一个 spec:

describe("Testing cookieHandler Sub-module", function() {

    var cookieHandler = ImagesInc_Core.CookieHandler;  
    describe("createCookie Method", function() {
        it("should exist", function() {
            expect(cookieHandler.createCookie).toBeDefined();
        });
…
}

如你所见,我们使用了外部的 describe 函数来封装与 CookieHandler 子模块相关的所有单元测试。在这个内部,我们使用另一个 describe 函数来编写与该子模块的 createCookie 方法相关的相关 spec。it 函数是我们编写预期的地方,这些预期是我们对正在测试的方法的断言。

预期使用匹配器来实现实际值和预期值之间的比较。Jasmine 提供了一系列内置匹配器,但它也允许我们编写自己的自定义匹配器。

在上面的代码片段中,我们告诉 Jasmine 检查 cookieHandler.createCookie 是否已定义。

注意我们是如何使用传递给 describeit 方法的字符串来描述我们的测试的。当运行测试时,这些字符串应该像句子一样阅读,描述我们的测试是什么,以及运行此类测试时应期望得到什么样的结果。

注意

编写单元测试时需要注意的重要点

理想情况下,我们希望每个单元测试只测试一个功能,而不依赖于代码中的另一个功能。例如,如果我们想测试一个方法能否从 cookie 中读取,我们应只测试这一点,而不是先测试能否写入 cookie,然后再读取值,所有这些都在一个测试规范中完成。在我们的某些规范中,我们没有遵循这样的规则。为了消除这些类型的依赖,我们需要使用 间谍存根模拟,这些需要更深入地了解我们的测试框架,因为它们被认为是更高级的功能。由于这只是一个单元测试的介绍,这些框架的高级功能超出了本书的范围,但我强烈建议您自行进一步研究,因为这些功能在编写单元测试时非常有用。

运行我们的 Jasmine 单元测试

我们可以通过在浏览器中加载 SpecRunner.html 来运行我们的单元测试。当我们的测试完成运行后,我们将在浏览器窗口中看到以下结果:

运行我们的 Jasmine 单元测试

如您所见,测试套件中的句子告诉我们这组测试属于哪个方法,而断言中的句子告诉我们测试的目的。

我们的结果显示,所有测试都通过了。如果我们的某个测试失败,Jasmine 会如下通知我们:

运行我们的 Jasmine 单元测试

消息显示失败的测试以及与该测试相关的堆栈跟踪。这使我们能够快速识别失败的测试,并查看需要做什么来修复它。

进一步探索 Jasmine

Jasmine 是一个完整的 JavaScript 测试框架。我的目标是仅向您介绍它,并快速概述其一些功能。然而,Jasmine 提供了更多功能以满足您可能有的任何测试需求,例如使用 间谍存根模拟,以及支持异步测试。

我建议您首先查看我为本章代码中 CookieHandler 子模块创建的测试套件。然后,访问 Jasmine 网站,了解更多关于该框架的信息。我认为一旦您开始使用 Jasmine,您会对它的功能和易用性印象深刻。

注意

更多信息,请参阅以下链接:jasmine.github.io/2.4/introduction.html

Mocha 简介

Mocha 是另一个优秀的测试框架,它允许我们使用我们喜欢的任何断言库,并在这一方面提供了极大的灵活性。正如 Mocha 网站上所指示的,“如果它抛出错误,它就会工作!”。

我们将在测试中使用 Chai 断言库,我们很快就会讨论这个库。

Mocha 可以在服务器端和客户端运行,并且它支持 BDD 和 TDD 风格的测试。我们还可以使用不同的报告器与 Mocha 一起使用,例如 点阵列表

对于我们的目的,我们将专注于如何在浏览器中运行 Mocha。

设置 Mocha

要为在浏览器中运行我们的测试设置 Mocha,我们可以使用 Mocha 网站上提供的 sample.html 文件。

在此文件中,我们使用 内容分发网络CDN)加载 Mocha。我们也可以从 GitHub 下载框架,地址为:github.com/mochajs/mocha

对于我们的应用程序,我已经下载了 Mocha,并在项目中创建了以下结构:

设置 Mocha

在前面的屏幕截图中,MochaRunner.html 是我们的测试运行页面,它加载 Mocha、Chai(断言库)、我们的源文件(StorageHandlerSpec.js)以及相关的 spec 文件,storageHandlerSpec.js。此文件存储了所有我们的 Mocha 测试。

这就是我们的 MochaRunner.html 的设置方式:

设置 Mocha

如您所见,我们也在告诉 Mocha 我们将使用 BDD 风格的测试规范。

这就是我们实现 Mocha 简单设置所需做的所有事情。

Chai

Chai 是一个非常流行的断言库,它与 Mocha 集成良好,并提供不同的断言风格。在我看来,最好的断言风格之一是 Expect

我认为你会发现 Expect 语法与 Jasmine 断言语法相似,并且容易理解。

注意

要使用 Chai,您可以从以下位置下载代码(复制/粘贴)并将其添加到您的项目中:

chaijs.com/chai.js

如您之前所见,我已经在项目库中下载并安装了 Chai,并且它已准备好供我们使用。

创建我们的 Mocha 测试文件

Mocha spec 文件的设置与 Jasmine 的 spec 文件非常相似。我们使用全局的 describe 函数创建我们的测试套件,并将其传递一个包含我们断言代码的回调函数。

就像 Jasmine 一样,我们使用字符串来标识我们的测试,并使用 it 函数来编写我们的断言。同样,类似于 Jasmine,我们可以使用嵌套的 describe 块,并且在该块中定义的任何变量都对该块中的所有断言可用。

请查看以下来自我们的 storageHandlerSpec.js 文件中的代码片段,如下所示:

var expect = chai.expect;
describe("Testing storageHandler Sub-module", function() {
    var storageHandler = ImagesInc_Core.StorageHandler;

    describe("saveValueToLocalStorage Method", function() {
        it("should exit", function() {		 
         	expect(storageHandler.saveValueToLocalStorage).to.exit;
        });
….
});

注意,我们已经将变量 expect 设置在 chai.expect 全局对象上,然后这个变量被用于我们的断言中。

Chai 提供了一组很好的匹配器,我们可以在编写单元测试时使用。然而,Chai 提供的匹配器集合并不像 Jasmine 那样完整。

例如,要使用间谍(spies)、存根(stubs)和模拟(mocks),这些被认为是更高级的测试功能,我们需要使用不同的库,例如 Sinon

由于这些功能超出了本书的范围,我们在此不会探讨它们,但我鼓励您访问 Sinon 的网站以获取更多信息:sinonjs.org/

运行我们的 Mocha-Chai 单元测试

如果我们在浏览器中加载项目的 MochaRunner.html 文件,在所有测试运行完毕后,我们将看到以下测试结果显示:

运行我们的 Mocha-Chai 单元测试

当然,我们测试旁边的勾选标记表示测试已通过。

以下图像显示了我们的测试失败时的页面:

运行我们的 Mocha-Chai 单元测试

如您所见,为了报告相关错误,Mocha 使用我们传递给测试规范中 it 函数的字符串。

进一步探索 Mocha

将 Mocha 与 Chai 和 Sinon 结合使用,使我们能够创建一个健壮的测试框架。我们甚至可以通过在客户端构建系统中利用 Mocha 作为服务器端的一部分来更进一步。这使得我们可以将测试自动设置为构建步骤之一,使用 Node.js。此外,虽然这并非特定于 Mocha,但使用 GitHub 作为我们的源代码库使我们能够将 Mocha 测试上传到 GitHub,并在各种浏览器中自动运行我们的测试。

注意

您可以在以下网站上获取更多有关此功能的信息:

ci.testling.com/

摘要

在本章中,我们讨论了各种测试类型以及为什么持续测试我们的代码对于整个应用程序的完整性至关重要。实现持续测试的一种方法就是使用单元测试。

我们探讨了如何使用纯 JavaScript 编写一些简单的单元测试,并发现使用第三方测试框架可以使我们在应用程序中拥有更强大和健壮的测试,而且所需的工作量更少。

使用模块化架构,我们可以轻松地对我们的模块进行单独测试,并快速找到和修复代码中可能的问题。

我们还简要介绍了两个非常流行的开源测试框架,Jasmine 和 Mocha。然而,我们对这些框架提供的功能只是浅尝辄止,我鼓励您自己进一步探索它们。

本章中展示的所有测试都包含在本书附带的源代码中,我强烈建议您查看测试套件,并花些时间熟悉它们。

在下一章中,我们将探讨将模块加载到我们的应用程序中的不同方法,并了解我们如何使用行业最佳实践来管理模块依赖关系。

第十章。企业级模块化设计,AMD,CommonJS 和 ES6 模块

在本书的最后一章,我们将主要关注重构一些应用模块,以便我们能够以更稳健的方式加载和管理它们。

本章的标题提到了企业级,但实际上我们可以使用这里讨论的原则来应用于任何大小和类型的应用。在接下来的几节中,我将向您介绍创建和加载模块的各种方法,并讨论每种方法可能更适合的环境。您还将看到我们如何以更结构化的方式管理模块依赖。

请记住,本章仅作为此类技术的介绍,但希望它能激发您进一步研究的兴趣。

我相信在看到它们的益处后,您会在未来的项目中考虑这些模块化设计实现。

本章我们将涵盖的一些主题包括:

  • 为什么我们需要更好的模块加载设置

  • AMD 模块格式及其使用方法

  • 创建 AMD 模块的工具

  • CommonJS 模块格式

  • ECMAScript 6 模块

重新审视 index.html 文件

如果你还记得,在第八章中,我提到我们在index.html文件中使用了相当多的<script>标签来加载我们的应用模块。

为了刷新您的记忆,以下是我们之前的情况:

重新审视 index.html 文件

虽然这个实现是有效的,但它并不非常可扩展,因为对于每个模块文件,我们都需要在我们的页面上添加一个<script>标签。此外,通过查看正在加载的文件列表,我们无法确定每个模块或组件如何依赖于应用的其他部分(或多个部分)。

我们还必须在页面底部创建另一个<script>标签,以便启动应用,如下所示:

重新审视 index.html 文件

如果我们能消除在index.html中需要所有<script>标签和启动序列的需求,那就太好了。此外,实现一个能够指示和管理我们模块依赖的机制将非常有用。

好吧,我们很快就会实现所有这些。

以下截图展示了新方法将如何清理我们的index.html文件,并为我们提供更稳健的脚本加载能力:

重新审视 index.html 文件

如您所见,页面已经大大缩短,现在在index.html中只加载了一个 JavaScript 文件。这个文件是require.js,其data-main属性设置为js/config,如下所示:

<script  src="img/require.js" data-main="js/config"></script>

只需加载此脚本,我们就可以加载所有模块和组件,并启动应用。

我们将很快查看这个魔法是如何发生的,但在这样做之前,我们需要谈谈AMD模块。

介绍异步模块定义

异步模块定义AMD)格式是针对在浏览器中使用 JavaScript 创建模块的。这个格式提出了一种特定的模块定义方式,以便模块及其依赖项可以异步加载到浏览器中。

当创建和消费 AMD 模块时,你需要使用两个关键方法,这些是definerequire方法。

理念是我们使用全局的define方法创建一个模块,然后我们可以使用全局的require方法在其他代码部分(其他模块)中导入该模块。

使用 AMD 格式定义模块

下面是如何在 AMD 格式中定义模块的示例:

define('moduleOneId', ['dependency'], function(dependency) {

    return {
        sayHi: function(){
            console.log('Hi there!')
        }
    }
});

在前面的代码中,我们向全局的define方法传递了三个参数,这个方法是由 AMD 兼容的模块加载器提供的。

第一个参数moduleOneId是一个可选参数,它为模块分配一个 ID。大多数情况下,我们不使用这个参数,除了某些边缘情况或当使用非 AMD 的代码打包工具时,我们将其留空。

define函数的第二个参数是一个依赖项数组,它告诉 AMD 模块加载器在执行回调函数之前需要加载哪些模块(文件)。当然,传递给define方法的第三个参数是回调函数。

注意,我们从这个回调函数中返回一个对象。这个对象是我们正在定义的模块。在 AMD 格式中,回调函数也可以返回构造函数和函数。

导入 AMD 模块

要使用前面代码中定义的模块,我们可以通过使用全局的require函数来导入它。

考虑以下内容:

require(['moduleOneId'], function(moduleOne){
    moduleOne.sayHi();
});

这里,我们要求 AMD 模块加载器在执行回调函数之前加载依赖项moduleOneId

注意,我们也可以使用依赖项的路径而不是使用相关的 ID。例如,我们可以将前面的代码片段写成如下所示:

require(['folderPath/moduleOne.js'], function(moduleOne){
    moduleOne.sayHi();
});

AMD 格式在定义和加载我们的模块方面提供了极大的灵活性。它还消除了创建全局变量来定义模块的需求。如果需要,AMD 兼容的脚本加载器通常还提供懒加载我们的模块的能力。

有许多支持 AMD 格式的 Web 开发工具(库),但最受欢迎的是RequireJScurl.js

在本章的应用程序中,我们将使用RequireJS创建我们的 AMD 模块并加载它们的依赖项。

注意

对于 JavaScript 模块及其相关格式的深入讨论,我鼓励您访问 Addy Osmani 在addyosmani.com/writing-modular-js/上的一篇优秀的文章。

介绍 RequireJS

如前所述,浏览器中最受欢迎的 AMD 模块加载器之一是 RequireJS。在本节中,我们将学习如何使用这个工具创建和加载模块。

您也可以使用其相关优化器 r.js 在服务器端运行 RequireJS。优化器可以将所有我们的 AMD 模块打包到一个文件中,然后浏览器只需向服务器发出一个请求即可加载所有必要的模块。在这种情况下,当模块在服务器上打包时,使用 AMD 格式来管理模块之间的依赖关系。

或者,如果您正在使用服务器构建设置来处理客户端文件,您可能希望考虑使用 almond.js 来打包您的 AMD 模块。这是一个更小的库,但它没有 RequireJS 的动态加载功能。有关 almond.js 的更多信息,请访问以下网址:github.com/requirejs/almond/

由于我们项目中没有使用构建系统,因此在本章中,我将仅介绍如何使用 RequireJS 为浏览器服务。

下载 RequireJS

您可以从以下位置下载这个库:requirejs.org/docs/download.html#requirejs

您可以直接复制工具的压缩版本,并将其保存到您选择的文件位置。

创建和加载 AMD 模块的测试项目

为了帮助您更熟悉 RequireJS,我在本章相关的源代码中包含了一个名为 requireJsLearning 的附加项目。我鼓励您下载这个项目,并随着我们继续讨论 AMD 和 RequireJS 而跟随操作。

requireJsLearning 项目中,我已经将 RequireJS 库(require.js)保存在 libs 文件夹中,如下所示:

创建和加载 AMD 模块的测试项目

让我们在项目中创建一些简单的模块,并看看我们如何使用 RequireJS 加载和管理它们。

创建一个简单的 AMD 模块

我们首先在 modules 文件夹下的 person.js 文件中创建一个简单的 AMD 模块。

考虑以下:

define(['modules/stuff', 'jquery'], function(stuff, jq) {
    console.log(jq + "  --> Accessing jQuery from person");
    return {
        first: "Sam",
        last: "SamLastName",
        favorites: stuff.likes.join(' ')
    }
});

在前面的代码中,我们使用了 RequireJS 为我们提供的全局 define 方法,并向它传递了两个参数。第一个参数是一个包含两个值的数组。这个数组中的第一个值是另一个模块的路径,它是我们的 person 模块的依赖项。

注意,我们使用的是文件名,但没有 .js 扩展名。这是因为 RequireJS 默认假设扩展名为 .js

我们依赖数组中的第二个值是 jQuery。在这里我们没有指定 jQuery 的路径,因为它被用作全局变量。

注意

jQuery 和 AMD 格式

截至 1.7 版本,jQuery 支持 AMD 格式。但是,为了正确地将其作为 AMD 模块加载,我们只能将其放置在与我们的 config.js 文件相同的位置。由于我们希望将所有第三方库放在我们项目的 libs 文件夹下,我们需要进行一些配置。我们很快就会谈到这一点。

传递给 define 方法的第二个参数是在我们的 person 模块的依赖项加载后执行的回调函数。

如前述代码所示,这个回调函数接收两个参数,它们与为 person 模块定义的依赖列表一一对应。

我们还从这个回调函数中返回一个对象,即我们的 person 模块,它可以被其他模块消费。

消费我们的 person AMD 模块

由于 person 模块被创建为 AMD 模块,我们可以在其他模块中仅指定其路径作为依赖项来消费它。

看看我们的 main 模块(在 main.js 中)如何要求和使用 person 模块,如下所示:

// bootstrap file
require(['modules/person', 'jquery', 'person2'], function(person, $$, person2){

    console.log("Accessing Person --> person first + person last; from main -> " + person.first + ' ' + person.last);	
    console.log($$ + "  --> jquery from main");	
    console.log("Accessing person2 --> person2.first ; from main -> " + person2.first)
});

现在您应该对前面的语法更加熟悉了。我们正在告诉 RequireJS(使用 require 方法)加载依赖数组(require 方法的第一个参数)中列出的所有依赖项,然后将它们作为参数传递给回调函数。

RequireJS 将依次对服务器进行异步调用(每个模块一个调用)以下载所有依赖项。

加载依赖项的依赖项

注意到我们的 main 模块已将 person 模块列为依赖项。然而,person 模块本身也将 stuff 模块列为依赖项:

define(['modules/stuff', 'jquery'], function(stuff, jq) {
...
}

因此,存在一个需要解决的依赖链。当 RequireJS 查找 main 模块的依赖列表并看到 person 作为依赖项时,它会进一步查看 person 是否有自身的依赖项。如果有,它将首先加载这些依赖项。之后,person 模块被加载并传递给 main 模块。

我们可以这样思考这个链:

加载 stuff 模块 | 将其传递给 person 模块 | 加载 person 模块 | 然后将 person 模块传递给 main 模块。

这是对 RequireJS 如何根据依赖列表管理依赖项并按正确顺序加载模块的非常简化的看法。

注意

一个微小但重要的点

在前面的解释中,我提到了根据依赖列表的顺序加载模块。事实上,这些模块可以以不同的物理顺序加载,但需要记住的重要一点是,依赖列表中列出的所有模块都会被加载,并且它们中的代码会在调用 callback 函数之前执行。

加载和消费非 AMD 模块

我们还需要一种方法来加载项目中非 AMD 模块或非模块文件,RequireJS 提供了钩子来完成这项工作。

它还有许多插件,使我们能够加载 CSS 和文本文件,并提供许多其他功能。

在我们的 requireJsLearning 项目中,有三个模块(文件)没有使用 AMD 模块格式。这些是 person2person3person4。为了让 RequireJS 加载这样的模块并使它们可供其他模块使用,我们需要提供一些配置设置。

如果你查看我们项目中的 config.js 文件,你会看到以下代码:

require.config({
    deps: ['main'],
    paths: {
    //'jquery' : 'libs/jquery'// if loading from local directory
        'person2': 'modules/person2', // location to none AMD modules
        'person3': 'modules/person3',
        'person4': 'modules/person4',
        'jquery': "https://code.jquery.com/jquery-1.12.3.min" // loading from CDN
    },
    shim: {
        "person2": {
            "exports": "person2"
                // use this alias in the global scope and pass it to modules as dependency
        },
        "person3": {
            deps: ['person4'],
            // none AMD module, depending on another non AMD module
            "exports": "person3"
        }
    }
});

这里,我们正在将一个配置对象传递给 RequireJS 的 require.config 方法。这个对象的第一属性将 main 定义为一个依赖项(deps: ['main'])。记住,main 模块是启动我们应用程序的模块,但在 index.html 文件中,我们告诉 RequireJS 使用 data-main 属性加载 config.js,如下所示:

<script  src="img/require.js" data-main="js/config"></script>

当 RequireJS 加载 config.js 文件时,配置对象被传递给 require.config 方法。然后,RequireJS 确定它需要加载 main.js(通过查看此对象上的 deps: ['main'] 属性)。当然,反过来,通过查看 main 模块的依赖项列表,RequireJS 也会加载 main 模块的所有依赖项。

在配置对象中设置路径

在传递给 require.config 方法的配置对象中设置的下一个属性是 paths 属性。这个属性本身是一个对象,我们在这里定义了依赖项的路径。例如,请看以下片段:

'person2': 'modules/person2'

在这里,我们告诉 RequireJS,当它需要加载 person2 时,可以在 modules/person2.js 文件中找到它。记住,在这个属性中,我们不需要提供文件的扩展名。

为非 AMD 模块创建 shims

在传递给 require.config 方法的对象中,我们定义的下一个属性是 shim 属性。

shim 属性也是一个对象,用于为非 AMD 模块提供配置设置。

例如,请看以下代码片段中我们如何配置 person3 模块:

"person3": {
            deps: ['person4'],
            // none AMD module, depending on another non AMD module
            "exports": "person3"
}

我们告诉 RequireJS,当你想将 person3 模块作为依赖项加载时,首先需要加载 person4(使用 deps 属性),然后让消费模块使用关键字 person3 声明此模块作为依赖项。

考虑以下代码片段中 stuff 模块如何声明和消费 person3 作为依赖项:

define(['person3'], function(person3) {
    console.log(person3.first +
        "  --> Accessing person3.first from stuff");
    return {
        likes: ['Car', 'Bike', 'Scooter']
    };
});

这样,我们可以异步加载和消费非 AMD 模块,这要归功于 RequireJS。

为了确认一切按预期工作并且所有依赖项都已正确解决,我们可以在浏览器中加载 index.html。然后,我们应该在调试工具控制台看到以下消息(我使用的是 Chrome 浏览器):

为非 AMD 模块创建 shims

我鼓励你查看与此项目相关的代码(requireJsLearning)并使用它来了解如何使用 RequireJS 创建、加载和消费模块。

关于这个优秀的库以及它提供的许多不同选项的更深入信息,请访问 requirejs.org/

我希望这已经是一个关于 AMD 模块格式和 RequireJS 的良好介绍了。现在,让我们利用本节学到的知识,重构我们的主应用程序,以 AMD 方式加载我们的模块。

重构 Images Inc.应用程序以使用 AMD 格式

为了利用 RequireJS 为我们提供的功能,我们将采取两种不同的方法。首先,我们将把 Core 模块中的所有子模块都转换为 AMD 模块。

第二,我们将把应用程序中的所有其他文件,包括应用程序的组件,作为非 AMD 模块加载。这两种方法将为我们提供一个实际练习,以将我们对 RequireJS 和 AMD 模块的知识付诸实践。

将核心子模块修改为 AMD 模块

在本节中,我们将查看ImagesInc_Core.LoggingHandler子模块,并了解我们如何将其转换为 AMD 模块。应用程序中的所有其他子模块也可以按照相同的方法转换为 AMD 模块。

考虑在我们的应用程序中如何实现ImagesInc_Core.LoggingHandler子模块:

ImagesInc_Core.LoggingHandler = (function(){
...

return {

        logMessage: self.logMessage,
        initialize: self.initialize
    };
})();

我们之前使用 IIFE(立即执行函数表达式)来返回一个对象,作为ImagesInc_Core全局对象(即我们的 MainCore 模块)的一个属性。这个返回的对象是我们 IIFE 命名空间内代码的接口。

要将此子模块转换为 AMD 模块,我们只需将原本在 IIFE 内部的代码转移到由 RequireJS 提供的define方法中。

考虑以下代码片段:

define(['MainCore'], function(ImagesInc_Core){
...
    return ImagesInc_Core.LoggingHandler = {

        logMessage: self.logMessage,
        initialize: self.initialize
    }
}

注意,我们仍然从回调函数中返回一个对象,并且也将它分配给ImagesInc_Core对象的LoggingHandler属性。

这种方法使我们能够轻松地将我们的非 AMD 子模块转换为 AMD 模块,改动非常小。这主要是因为,从一开始,我们的架构就被设计为基于模块的架构。现在,多亏了 RequireJS,我们能够异步加载我们的子模块文件,而无需将它们作为<script>标签添加到我们的index.html文件中。

使用 RequireJS 加载我们的非 AMD 模块

我们将加载应用程序中的其余文件,包括 MainCore,作为非 AMD 模块。

你可能后来决定,你希望将这些模块也转换为 AMD 模块。我鼓励你这样做,因为这将是一个有价值的练习。然而,我的目标在这里是向你展示 AMD 和非 AMD 模块可以如何和谐地一起工作,而不会遇到太多麻烦。

如前所述,为了使用 RequireJS 加载非 AMD 模块,我们需要在应用程序的config文件的shim属性中定义它们。

记住,我们在index.html中加载config.js文件,如下所示:

<script src="img/require.js" data-main="js/config"></script>

config.js文件反过来将导致加载我们的main.js文件(因为它已被定义为config.js文件的依赖项),这完成了我们应用程序的引导。

是时候更仔细地查看我们应用程序的config.js文件了。

设置应用程序的config.js文件

我们应用程序的config.js文件由三个主要属性组成;deps属性,它定义了config文件的依赖项;paths属性,它将所有依赖项映射到它们的物理文件位置,以及shim属性,它用于定义我们应用程序中的非 AMD 模块。

查看下面的代码片段中的config文件:

require.config({
    deps: ['main'],
    paths:{
        'MainCore' : 'Modules/Core/MainCore',
        'Logger': 'Modules/Core/Logger',
        'AjaxEngine': 'Modules/Core/AjaxEngine',
        'CookieHandler': 'Modules/Core/CookieHandler',
        'NotificationHandler': 'Modules/Core/NotificationHandler',
        'StorageHandler': 'Modules/Core/StorageHandler',
        'Utilities': 'Modules/Core/Utilities',
        'SandBox' : 'Modules/SandBox/SandBox',
        'ImagesInc_Content': 'Components/ImagesInc_Content',
        'ImagesInc_Footer': 'Components/ImagesInc_Footer',
        'ImagesInc_Header': 'Components/ImagesInc_Header',
        'AppTester': 'Modules/AppTester/AppTester',
        'CookieHandlerTester': 'Modules/AppTester/CookieHandlerTester',
        'StorageHandlerTester': 'Modules/AppTester/StorageHandlerTester',
        'Base': 'Modules/Base/Base',
        'jquery': 'Modules/Base/jquery-1.10.2.min',
        'GlobalData_Sub': 'Modules/GlobalData/GlobalData_Sub',
        'GlobalData': 'Modules/GlobalData/GlobalData'	
    },

    shim:{

        'Base':{
            exports : 'Base'
        },

        'jquery':{
            exports : 'jquery' 

        },
...

});

我想现在你已经非常熟悉我们在文件中为应用程序设置的配置项类型了。基于这个配置,RequireJS 现在可以异步且按正确顺序加载我们的模块和文件。

这种实现使我们能够有一个组织良好、易于维护和可扩展的解决方案来加载我们的模块,而无需在index.html文件中列出所有模块。

使用 RequireJS 启动我们的应用程序

我们的main模块是负责启动我们应用程序的模块。

查看以下代码片段,看看这个模块是如何实现的:

// Application bootstrap file
var modulesToLoad = ['MainCore','Logger','AjaxEngine', 'CookieHandler', 'NotificationHandler', 'StorageHandler','Utilities','ImagesInc_Content','ImagesInc_Footer', 'ImagesInc_Header','SandBox', 'AppTester', 'CookieHandlerTester',
  'StorageHandlerTester', 'Base', 'jquery', 'GlobalData_Sub', 'GlobalData'];

require(modulesToLoad, function(ImagesInc_Core, Logger,AjaxEngine, CookieHandler, NotificationHandler, StorageHandler, Utilities, ImagesInc_Content, ImagesInc_Footer, ImagesInc_Header, SandBox, Base, jquery, GlobalData_Sub, GlobalData){
    //register StorageHandler with MainCore
    ImagesInc_Core.StorageHandler.register = (function(){
    ImagesInc_Core.registerModule(ImagesInc_Core.StorageHandler);      
    })();

    //add error handling to all methods of StorageHandler, in case localStorage not available
    if(ImagesInc_Core.Utilitizes){
    ImagesInc_Core.Utilitizes.addLocalStorageCheck(ImagesInc_Core.StorageHandler);
    }
    ImagesInc_Core.initializeAllModules();
    ImagesInc_Core.initializeAllComponents();
    ImagesInc_Core.handlePageChange(location.pathname);
});

如你所见,我们已经将我们应用程序的所有依赖项列在一个数组中。然后,我们使用了require方法来加载它们,并将它们传递给回调函数。

在这个回调函数内部,我们执行启动应用程序所需的代码。

如果你查看浏览器调试工具的网络标签页(我使用 Chrome),你会看到 RequireJS 如何加载我们应用程序所需的模块和文件,如下面的截图所示:

使用 RequireJS 启动我们的应用程序

如你所见,模块化架构可以帮助我们轻松地管理我们的应用程序组件及其相关依赖项。我建议你花些时间探索如何在你的应用程序中利用 AMD 模块的完整功能。

CommonJS

与 AMD 格式一样,CommonJS(也称为 CJS)是另一种定义 JavaScript 模块的格式,这些模块可以作为对象提供给任何依赖代码。与可以定义构造函数和函数的 AMD 模块不同,CJS 模块只能定义对象。

与采用浏览器优先方法的 AMD 格式不同,CommonJS 采用服务器优先方法。它还涵盖了更广泛的与服务器相关的问题,例如 io、文件系统等。

许多开发者(我也是其中之一)使用 AMD 格式针对浏览器模块,使用 CommonJS 针对服务器端模块。然而,你也可以为浏览器模块使用 CJS 格式。

支持 CJS 在浏览器中的某些工具包括:

让我们看看一个简单的例子,看看我们如何实现一个 CJS 格式的模块。

实现一个 CommonJS 模块

假设我们有一个名为moduleOne.js的文件。在这个文件内部,我们可以这样导出一个 CJS 模块:

exports.someFunc = function(){

    return console.log("I am some function");
}

在这里,exports是一个全局变量,包含模块提供给希望消费它的其他模块的对象。

另一方面,想象一下我们还有一个模块位于moduleTwo.js文件中,它与moduleOne.js位于同一目录下。

moduleTwo可以导入moduleOne并按如下方式消费它:

var firstModule = require('./moduleOne');
firstModule.someFunc();

exports.someApp = function(){
    return firstModule.someFunc();
}
exports.someOtherApp = function(){
    return console.log("I am some other app");
}

正如你所见,在这个模块中,我们使用 CJS 的require方法导入第一个模块(someFunc),然后我们可以使用它作为firstModule.someFunc()

moduleTwo.js中,我们还导出另一个名为someApp的模块,该模块可以被应用程序中的其他模块消费。请注意,在前面的例子中,我们实际上是从同一个文件导出了多个模块。

注意,与 AMD 格式中看到的不同,我们没有使用define方法来包装我们的模块,并从回调函数返回一个对象。

CJS 语法与 ECMAScript 6 定义模块的方式相似,我们将在下一节中探讨这一点。

关于 CJS 模块的更多信息,请参考以下在线资源:www.commonjs.org/

ECMAScript 6 模块

在 JavaScript 的新版本中,ECMAScript 6(也称为ES6)引入了原生模块。以下是一些这些模块最重要的方面:

  • 模块代码总是自动在严格模式下运行

  • 在模块顶层创建的变量不会被添加到全局作用域

  • 一个模块必须导出任何应该对外部代码可用的东西

  • 一个模块可以导入绑定(从其他模块导出的东西)

ES6 中模块的主要思想是让你完全控制模块内部对外部代码的访问权限,以及模块内部代码的执行时机。

让我们看看一个简单的 ES6 模块的例子。

定义一个 ES6 模块

我们可以定义一个 ES6 模块,要么在.js文件内部,要么在我们的.html页面中的<script>标签内部。

考虑以下代码片段,来自一个假想的simpleModule.js文件:

var name = "Tom";
// export function
export function sayHello(name) {
    alert("Hello " + name);
}
function sayName(){
	alert('My name is: ' + name);
}
function sayBye(name) {
    alert("Bye " + name);
}
export sayBye;

在前面的模块中,我们定义了三个函数以及一个模块变量。请注意,虽然变量name不在函数内部,但其作用域仍然局限于模块。这意味着模块外部代码无法访问它。

我们还使用了关键字export使我们的两个函数对模块的潜在消费者可用。正如你所见,我们可以在函数声明中使用export,就像sayHello函数的情况,或者作为引用,就像sayBye函数的情况。

要创建一个嵌入到.html文件中的模块,我们可以使用以下语法:

<script type="module">
    var modulePrivateVar = 2
    alert("The value in the module is: " + modulePrivateVar);
</script>

注意我们已经指定了脚本的typemodule,这导致浏览器将<script>标签内的代码视为 ES6 模块。

消费一个 ES6 模块

当 ES6 模块的外部代码需要访问模块暴露的内容时,需要使用绑定。

考虑我们如何获取第一个模块simpleModule所暴露的两个函数的访问权限,如下所示:

import { sayHello, sayBye } from "simpleModule.js";	
sayHello();
sayBye();
sayName(); // error, sayName was not exported

如所示,我们需要使用关键字import在模块的外部代码和模块导出的函数之间创建绑定。

此外,请注意,由于sayName没有被模块导出,我们无法访问它。

浏览器中 ES6 模块的加载顺序

当浏览器遇到以下标签时,浏览器中的 ES6 模块总是立即加载:

<script type="module" src="img/simpleModule.js">

然而,模块中的代码在文档完全解析之前不会执行。

模块也是按照它们出现的顺序执行的。这意味着在我们的先前的例子中,如果我们按以下顺序列出我们的模块:

<script type="module" src="img/simpleModule.js">
<script type="module">
    var modulePrivateVar = 2	
    alert("The value in the module is: " + modulePrivateVar);
</script>

simpleModule.js中的代码会在我们嵌入模块的代码之前执行。

由于模块也可以有自己的依赖项,它使用import语句(绑定)来定义,因此每个模块在执行其代码之前都会被完全解析。这是为了正确解决依赖项。

这意味着,如果浏览器在已下载的模块中遇到一个导入语句,它将首先下载依赖项,然后再执行该模块中的代码。这确保了在模块中的任何代码执行之前,所有模块及其依赖项都已加载。

ES6 模块的内容远不止我们在这里所涵盖的,因为这只是一个非常简短的介绍。我鼓励你自己进一步研究这个主题。网上有很多好的资源可以参考,我强烈推荐以下网站获取更多信息:

注意

关于我们的 Images Inc.应用程序的最终注意事项

总是回顾并提高我们应用程序代码的质量是一个好主意。因此,我对与本章相关的代码库进行了一些小的修改。最新项目的代码更加精致和经过代码检查。此外,我还通过修改相关的 CSS 文件,提高了应用程序在浏览器中的视觉响应性。

然而,任何项目中总有可以进一步改进的地方,这个应用程序也不例外。

尽管如此,我希望这个应用程序将为你的未来项目提供一个良好的起点。

摘要

由于这是我们应用程序实现的最后一个阶段,在这一章中,我们讨论了如何在浏览器中改进我们模块的依赖管理和加载。

通过重构我们应用程序的代码,我们将我们的 Core 子模块转换为 AMD 模块,然后使用 RequireJS 从服务器异步加载所有 JavaScript 文件。

我们看到了如何使用 RequireJS,这是一个主要的脚本加载库,来加载我们的 AMD 和非 AMD 模块。

我们还介绍了在 JavaScript 中定义模块的不同格式,并讨论了 AMD、CommonJS 和 ES6 模块。由于这本书主要关注在浏览器中使用 JavaScript,我们花了更多的时间来了解 AMD 模块,因为它们更适合这个目的。

在我们概述 CommonJS 和 ES6 模块时,我们指出了这两种格式之间的相似性,并看到了 ES6 模块如何在我们的代码中使用。

我希望你觉得这本书信息丰富,并且是学习使用 JavaScript 进行模块化编程的良好入门。

和往常一样,我们还有很多东西要学习,我祝愿你在未来的努力中取得巨大成功。

posted @ 2025-10-06 13:12  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报