精通-JavaScript-设计模式-全-

精通 JavaScript 设计模式(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

JavaScript 正逐渐成为世界上最流行的语言之一。然而,它作为一种玩具语言的历史意味着开发人员往往忽视良好的设计。设计模式是一个很好的工具,可以提出一些经过验证的解决方案。

本书内容

本书分为两个主要部分,每个部分包含多个章节。本书的第一部分,我们将称之为第一部分,涵盖了 GoF 书中的经典设计模式。

第一章,Designing for Fun and Profit,介绍了设计模式是什么,以及为什么我们有兴趣使用设计模式。我们还将谈谈 JavaScript 的一些历史,以便让您了解历史背景。

第二章,Organizing Code,探讨了如何创建用于组织代码的经典结构,如命名空间、模块和类,因为 JavaScript 缺乏这些构造作为一等公民。

第三章,Creational Patterns,涵盖了《四人组模式》书中概述的创建模式。我们将讨论这些模式如何适用于 JavaScript,而不是《四人组》书写作时流行的语言。

第四章,Structural Patterns,研究了创建模式。我们将检查《四人组模式》书中的结构模式。

第五章,Behavioral Patterns,讨论了行为模式。这些是《四人组模式》书中我们将研究的最后一组模式。这些模式控制了将类连接在一起的不同方式。

第二部分着眼于 GoF 书中未涵盖或特定于 JavaScript 的模式。

第六章,Functional Programming,涵盖了函数式编程语言中的一些模式。我们将看看如何在 JavaScript 中使用这些模式来改进代码。

第七章,Reactive Programming,探讨了 JavaScript 中回调模型编程所涉及的问题。它提出了响应式编程,一种基于流的事件方法,作为可能的解决方案。

第八章,Application Patterns,研究了创建单页面应用程序的各种模式。我们将提供清晰度,并探讨如何使用使用每种现有模式的库,以及创建我们自己的轻量级框架。

第九章,Web Patterns,探讨了一些适用于 Web 应用程序的模式。我们还将研究一些关于将代码部署到远程运行时(如浏览器)的模式。

第十章,Messaging Patterns,涵盖了消息传递是一种强大的通信技术,可在应用程序内部甚至之间进行通信。在本章中,我们将研究一些关于消息传递的常见结构,并讨论为什么消息传递如此有用。

第十一章,Microservices,涵盖了微服务,这是一种以惊人的速度增长的方法。本章探讨了这种编程方法背后的思想,并建议在使用这种方法构建时要牢记的一些模式。

第十二章,Patterns for Testing,讨论了构建软件的困难,以及构建优质软件的双重困难。本章提供了一些模式,可以使测试过程变得更容易一些。

第十三章 , 高级模式 , 解释了一些模式,比如面向方面的编程在 JavaScript 中很少被应用。我们将探讨这些模式如何在 JavaScript 中应用以及是否应该应用它们。

第十四章 , ECMAScript-2015/2016 今日解决方案 , 涵盖了一些工具,允许您在今天使用未来版本 JavaScript 的功能。我们将研究微软的 TypeScript 以及 Traceur。

本书所需内容

本书不需要专门的软件。JavaScript 可以在所有现代浏览器上运行。有用于驱动各种工具的独立 JavaScript 引擎,如 C++(V8)和 Java(Rhino),这些工具包括 Node.js,CouchDB,甚至 Elasticsearch。这些模式可以应用于这些技术中的任何一个。

本书适合对象

本书非常适合希望在 JavaScript 中获得面向对象编程专业知识和 ES-2015 新功能以提高网页开发技能并构建专业质量网页应用的开发者。

约定

在本书中,您会发现一些区分不同信息种类的文本样式。以下是一些这些样式的示例和它们的含义解释。

文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄会以以下形式显示: "您会注意到我们明确定义了 name 字段。"

代码块设置如下:

let Castle = function(name){
 this.name = name;
}
Castle.prototype.build = function(){ console.log(this.name);}
let instance1 = new Castle("Winterfell");
instance1.build();

当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:

let Castle = function(name){
 this.name = name;
}
 **Castle.prototype.build = function(){ console.log(this.name);}** 

let instance1 = new Castle("Winterfell");
instance1.build();

任何命令行输入或输出都以以下形式书写:

ls -1| cut -d \. -f 2 -s | sort |uniq

新术语重要词汇以粗体显示。屏幕上看到的词语,例如菜单或对话框中的词语,会以这样的形式出现在文本中: "要访问它们,有一个菜单项,位于工具 | Chrome 中的开发者工具 | 工具 | Firefox 中的 Web 开发者 下。"

注意

警告或重要提示会以这样的形式出现在一个框中。

提示

提示和技巧会出现在这样的形式中。

第一章:为了乐趣和利润而设计

JavaScript 是一种不断发展的语言,从诞生以来已经走过了很长的路。可能比其他任何一种编程语言都更随着万维网的发展而成长和变化。本书的主题是探索如何使用良好的设计原则编写 JavaScript。本书的前言包含了对书中各节的详细解释。

在本章的前半部分,我们将探讨 JavaScript 的历史,以及它如何成为今天重要的语言。随着 JavaScript 的发展和重要性的增长,对其构建应用严格方法的需求也在增长。设计模式可以是一个非常有用的工具,帮助开发可维护的代码。本章的后半部分将专门讨论设计模式的理论。最后,我们将简要地看一下反模式。

本章的主题如下:

  • JavaScript 的历史

  • 什么是设计模式?

  • 反模式

通往 JavaScript 的道路

我们永远不会知道语言最初是如何产生的。它是从一系列在梳理仪式中发出的咕哝声和喉音慢慢演变而来的吗?也许它是为了让母亲和她们的后代进行交流而发展起来的。这两种理论都是不可能证明的。在那个重要的时期,没有人在场观察我们的祖先。事实上,缺乏经验证据导致巴黎语言学会禁止进一步讨论这个话题,认为它不适合进行严肃的研究。

早期

幸运的是,编程语言在最近的历史中得到了发展,我们能够看到它们的成长和变化。JavaScript 是现代编程语言中历史最有趣的之一。在 1995 年 5 月的 10 天里,网景的一名程序员写下了现代 JavaScript 的基础。

当时,网景正参与与微软的浏览器战争。网景的愿景远不止于开发一个浏览器。他们想要创建一个完整的分布式操作系统,利用 Sun Microsystems 最近发布的 Java 编程语言。Java 是 C++的更现代的替代品。然而,网景没有对 Visual Basic 有所回应。Visual Basic 是一种更容易使用的编程语言,面向经验较少的开发人员。它避开了 C 和 C++编程中的一些困难,如内存管理。Visual Basic 也避免了严格的类型检查,总体上更加灵活。下面是 JavaScript 的时间线图:

早期

Brendan Eich 被委托开发网景的回应 VB。该项目最初被命名为 Mocha,但在网景 2.0 测试版发布之前被更名为 LiveScript。到全面发布时,Mocha/LiveScript 已更名为 JavaScript,以便与 Java 小程序集成。Java 小程序是在浏览器中运行的小型应用程序。它们与浏览器本身有不同的安全模型,因此在与浏览器和本地系统的交互方面受到限制。如今很少见到小程序,因为它们的许多功能已经成为浏览器的一部分。当时 Java 正处于流行的浪潮中,与之有关的任何关系都被夸大了。

多年来,这个名称引起了很多混淆。JavaScript 是一种与 Java 非常不同的语言。JavaScript 是一种解释性语言,具有松散的类型,主要在浏览器上运行。Java 是一种编译成字节码的语言,然后在 Java 虚拟机上执行。它在许多场景中都有适用性,从浏览器(通过 Java 小程序的使用)到服务器(Tomcat,JBoss 等),到完整的桌面应用程序(Eclipse,OpenOffice 等)。在大多数外行人的想法中,混淆仍然存在。

JavaScript 原来真的非常有用,可以与 Web 浏览器进行交互。很快,微软也将 JavaScript 引入其 Internet Explorer 中,以补充 VBScript。微软的实现被称为 JScript。

到 1996 年末,很明显 JavaScript 将成为不久的将来的获胜网络语言。为了限制实现之间的语言偏差,Sun 和 Netscape 开始与欧洲计算机制造商协会ECMA)合作,制定了未来版本的 JavaScript 需要遵守的标准。标准很快发布(从标准组织的运作速度来看,非常快),于 1997 年 7 月发布。如果你还没有看到 JavaScript 的足够多的名称,标准版本被称为ECMAScript,这个名称在一些圈子里仍然存在。

不幸的是,标准只规定了 JavaScript 的核心部分。在浏览器战争激烈的同时,很明显,任何坚持只使用 JavaScript 基本实现的供应商都会很快被抛在后面。与此同时,还在进行大量工作,以建立浏览器的标准文档对象模型DOM)。DOM 实际上是一个可以使用 JavaScript 进行操作的网页 API。

多年来,每个 JavaScript 脚本都会尝试确定其运行的浏览器。这将决定如何处理 DOM 中的元素,因为每个浏览器之间存在显著的差异。执行简单操作所需的代码混乱程度是传奇的。我记得曾经阅读过一篇为期一年的 20 篇系列文章,介绍如何开发一个在 Internet Explorer 和 Netscape Navigator 上都能工作的Dynamic HTMLDHTML)下拉菜单。现在,可以通过纯 CSS 实现相同的功能,甚至无需使用 JavaScript。

注意

DHTML 在 20 世纪 90 年代末和 21 世纪初是一个流行的术语。它实际上指的是在客户端执行某种动态内容的任何网页。随着 JavaScript 的流行,几乎每个页面都变得动态化,这个术语已经不再使用。

幸运的是,JavaScript 标准化的努力在幕后继续进行。ECMAScript 的第 2 版和第 3 版分别在 1998 年和 1999 年发布。看起来各方对 JavaScript 感兴趣的各方可能终于达成了一些共识。2000 年初开始了 ECMAScript 4 的工作,这将是一个重大的新版本。

暂停

然后,灾难来临了。ECMAScript 工作中涉及的各个团体对 JavaScript 的发展方向存在重大分歧。微软似乎对标准化工作失去了兴趣。这在一定程度上是可以理解的,因为那个时候 Netscape 自毁了,Internet Explorer 成为了事实上的标准。微软实现了 ECMAScript 4 的部分内容,但并非全部。其他人实现了更全面的支持,但由于市场领导者不支持,开发人员也不愿意使用它们。

多年过去了,没有达成共识,也没有新的 ECMAScript 发布。然而,正如经常发生的那样,互联网的发展无法被主要参与者之间的意见分歧所阻止。诸如 jQuery,Prototype,Dojo 和 Mootools 等库弥合了浏览器之间的主要差异,使跨浏览器开发变得更加容易。与此同时,应用程序中使用的 JavaScript 数量大幅增加。

GMail 的方式

转折点也许是 2004 年 Google 发布 GMail 应用程序。尽管异步 JavaScript 和 XMLAJAX)背后的技术在 GMail 发布时已经存在了大约五年,但它并没有被广泛使用。当 GMail 发布时,我完全被它的流畅度所震撼。我们已经习惯了避免完整重新加载的应用程序,但在当时,这是一场革命。为了使这样的应用程序工作,需要大量的 JavaScript。

AJAX 是一种通过客户端从服务器检索小数据块而不是刷新整个页面的方法。这种技术允许更具交互性的页面,避免了完整页面重新加载的冲击。

GMail 的流行是一个正在酝酿已久的变革的触发器。不断增加的 JavaScript 接受度和标准化推动我们超越了 JavaScript 作为一种合适语言的临界点。直到那时,JavaScript 的使用大多用于对页面进行微小更改和验证表单输入。我和人们开玩笑说,在 JavaScript 的早期,唯一使用的函数名称是Validate()

诸如 GMail 这样对 AJAX 有很大依赖并且避免完整页面重新加载的应用程序被称为单页面应用SPA。通过最小化页面内容的更改,用户可以获得更流畅的体验。通过仅传输JavaScript 对象表示JSON)负载而不是 HTML,还可以最小化所需的带宽。这使得应用程序看起来更加敏捷。近年来,关于简化 SPA 创建的框架取得了巨大进步。AngularJS,backbone.js 和 ember 都是模型视图控制器风格的框架。它们在过去两三年里获得了极大的流行,并提供了一些有趣的模式使用。这些框架是多年来一些非常聪明的人对 JavaScript 最佳实践进行实验的演变。

JSON 是 JavaScript 的一种人类可读的序列化格式。近年来它变得非常流行,因为它比以前流行的 XML 格式更容易和不那么繁琐。它缺少 XML 的许多伴随技术和严格的语法规则,但在简单性方面弥补了这一点。

与使用 JavaScript 的框架同时,语言本身也在不断发展。2015 年发布了一个备受瞩目的 JavaScript 新版本,这个版本已经在开发了一些年头。最初被称为 ECMAScript 6,最终的名称变成了 ECMAScript-2015。它带来了一些对生态系统的重大改进。浏览器供应商们正在争相采用这一标准。由于向代码库添加新的语言特性的复杂性,再加上并非所有人都在浏览器的前沿,一些其他编译成 JavaScript 的语言正在变得流行。CoffeeScript 是一种类似 Python 的语言,旨在提高 JavaScript 的可读性和简洁性。由 Google 开发的 Dart 被谷歌推广为 JavaScript 的最终替代品。它的构造解决了传统 JavaScript 中不可能的一些优化。在 Dart 运行时足够流行之前,谷歌提供了一个 Dart 到 JavaScript 的转换器。TypeScript 是微软的一个项目,它向 JavaScript 添加了一些 ECMAScript-2015 甚至一些 ECMAScript-201X 的语法,以及一个有趣的类型系统。它旨在解决大型 JavaScript 项目所面临的一些问题。

讨论 JavaScript 历史的目的有两个:首先,重要的是要记住语言不是在真空中发展的。人类语言和计算机编程语言都会根据使用环境而发生变异。人们普遍认为因纽特人有很多词来描述“雪”,因为在他们的环境中雪是如此普遍。这可能是真的,也可能不是,这取决于你对这个词的定义,以及谁构成了因纽特人。然而,在狭窄领域中,有很多例子表明特定领域的词汇会不断演变以满足精确定义的要求。我们只需看一下专业烹饪店,就会看到许多我们这样的外行人会称之为平底锅的各种变体。

萨皮尔-沃夫假说是语言学领域内的一种假设,它认为语言不仅受到使用环境的影响,而且语言也会影响其环境。也被称为语言相对论,该理论认为一个人的认知过程会因语言的构造方式而有所不同。认知心理学家基思·陈提出了一个引人入胜的例子。在一次观看量极高的 TED 演讲中,陈博士提出了一种有力的正相关关系,即缺乏未来时态的语言与高储蓄率之间存在着强烈的正相关关系(www.ted.com/talks/keith_chen_could_your_language_affect_your_ability_to_save_money/transcript)。陈博士得出的假设是,当你的语言没有很强的将现在和未来联系起来的意识时,这会导致更加鲁莽的行为。

因此,了解 JavaScript 的历史将使人更好地理解何时何地使用 JavaScript。

我探索 JavaScript 历史的第二个原因是,看到如此受欢迎的工具如何迅速地发展是非常迷人的。在撰写本文时,距离 JavaScript 首次构建已经大约 20 年了,它的流行程度增长迅猛。还有什么比在一个不断发展的语言中工作更令人兴奋的呢?

JavaScript 无处不在

自 GMail 革命以来,JavaScript 已经大幅增长。重新燃起的浏览器战争,将 Internet Explorer 和 Edge 对抗 Chrome 和 Firefox,导致了构建许多非常快速的 JavaScript 解释器。全新的优化技术已经部署,不足以看到 JavaScript 编译为机器本地代码以获得额外的性能。然而,随着 JavaScript 的速度增加,使用它构建的应用程序的复杂性也在增加。

JavaScript 不再仅仅是用于操作浏览器的语言。流行的 Chrome 浏览器背后的 JavaScript 引擎已经被提取出来,现在是许多有趣项目的核心,比如 Node.js。Node.js 最初是一种高度异步的编写服务器端应用程序的方法。它已经大大发展,并有一个非常活跃的社区支持。使用 Node.js 运行时已经构建了各种各样的应用程序。从构建工具到编辑器都是基于 Node.js 构建的。最近,微软 Edge 的 JavaScript 引擎 ChakraCore 也开源,并可以嵌入 Node.js 作为 Google 的 V8 的替代品。Firefox 的等效物 SpiderMonkey 也是开源的,并正在进入更多的工具中。

JavaScript 甚至可以用来控制微控制器。Johnny-Five 框架是非常流行的 Arduino 的编程框架。它为编程这些设备带来了比传统的低级语言更简单的方法。使用 JavaScript 和 Arduino 打开了一系列可能性,从构建机器人到与现实世界的传感器进行交互。

所有主要的智能手机平台(iOS、Android 和 Windows Phone)都有使用 JavaScript 构建应用程序的选项。平板电脑领域也大同小异,支持使用 JavaScript 进行编程。甚至最新版本的 Windows 提供了使用 JavaScript 构建应用程序的机制。这个插图展示了 JavaScript 可能的一些事情:

JavaScript 无处不在

JavaScript 正在成为世界上最重要的语言之一。尽管语言使用统计数据 notoriously difficult to calculate,但每一个试图开发排名的来源都将 JavaScript 列在前十名中:

语言指数 JavaScript 的排名
Langpop.com 4
Statisticbrain.com 4
Codeval.com 6
TIOBE 8

更有趣的是,大多数这些排名表明 JavaScript 的使用正在上升。

长话短说,JavaScript 将在未来几年成为一种重要的语言。越来越多的应用程序是用 JavaScript 编写的,它是任何类型的 Web 开发的通用语言。流行的 Stack Overflow 网站的开发者 Jeff Atwood 创建了 Atwood's Law,关于 JavaScript 的广泛应用:

"任何可以用 JavaScript 编写的应用程序,最终都会用 JavaScript 编写" - Atwood's Law, Jeff Atwood

这一观点一次又一次地被证明是正确的。现在有编译器、电子表格、文字处理器——你说的都有——都是用 JavaScript 编写的。

随着使用 JavaScript 的应用程序变得越来越复杂,开发人员可能会遇到许多与传统编程语言中相同的问题:我们如何编写这个应用程序以适应变化?

这引出了对应用程序进行适当设计的需求。我们不能再简单地把一堆 JavaScript 放入一个文件中,然后希望它能正常工作。我们也不能依赖于 jQuery 等库来拯救自己。库只能提供额外的功能,对应用程序的结构没有任何贡献。现在必须要注意如何构建应用程序以使其具有可扩展性和适应性。现实世界是不断变化的,任何不能适应变化世界的应用程序都可能被抛在脑后。设计模式在构建适应性强的应用程序方面提供了一些指导,这些应用程序可以随着业务需求的变化而变化。

什么是设计模式?

在大多数情况下,想法只适用于一个地方。例如,在烹饪中添加花生酱确实只是一个好主意,而在缝纫中不是。然而,偶尔也可能会发现一个好主意在原始用途之外也有适用性。这就是设计模式背后的故事。

1977 年,克里斯托弗·亚历山大、Sara Ishikawa 和 Murray Silverstein 撰写了一本关于城市规划中所谓的设计模式的重要书籍,名为《模式语言:城镇、建筑、建筑》。

这本书描述了一种用于讨论设计共性的语言。在书中,模式被描述如下:

“这种语言的元素被称为模式实体。每个模式描述了我们环境中反复出现的问题,然后以这样一种方式描述了解决这个问题的核心,以便您可以使用这个解决方案一百万次,而不必两次以相同的方式做。” ——克里斯托弗·亚历山大

这些设计模式是如何布局城市以提供城市和乡村生活的混合,或者如何在住宅区中建造环路道路作为交通缓和措施的,如下图所示。

什么是设计模式?

即使对于那些对城市规划不感兴趣的人来说,这本书也提出了一些关于如何构建我们的世界以促进健康社会的迷人想法。

Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 利用克里斯托弗·亚历山大和其他作者的作品作为灵感来源,写了一本名为《设计模式:可重用面向对象软件的元素》的书。当一本书在计算机科学课程中非常有影响力时,通常会被赋予一个昵称。例如,大多数计算机科学毕业生会知道,如果你谈论《龙书》(《编译原理》, 1986),你指的是哪本书。在企业软件中,《蓝皮书》是众所周知的埃里克·埃文斯关于领域驱动设计的书。设计模式书是如此重要,以至于通常被称为 GoF 书,或者四人帮书,因为它有四位作者。

这本书概述了 23 种用于面向对象设计的模式。它将这些模式分为三大类:

  • 创建:这些模式概述了对象可以被创建和它们的生命周期如何被管理的多种方式

  • 行为:这些模式描述了对象如何相互交互

  • 结构:这些模式描述了向现有对象添加功能的各种不同方式

设计模式的目的不是指导你如何构建软件,而是提供解决常见问题的方法。例如,许多应用程序需要提供某种撤销功能。这个问题在文本编辑器、绘图程序甚至电子邮件客户端中都很常见。解决这个问题已经做过很多次了,因此有一个通用的解决方案会很好。命令模式提供了这样一个通用解决方案。它建议跟踪应用程序中执行的所有操作,作为命令的实例。这个命令将有前进和后退操作。每次处理一个命令时,它都会被放入队列中。当需要撤销一个命令时,只需简单地从命令队列中弹出顶部的命令并执行其撤销操作。

设计模式提供了一些关于如何解决常见问题的提示,比如撤销问题。它们是从解决同一个问题的数百次迭代中提炼出来的。设计模式可能并不完全是你所面临问题的正确解决方案,但至少应该提供一些指导,以更轻松地实现解决方案。

注意

我的一位顾问朋友曾经告诉我一个关于在新公司开始任务的故事。经理告诉他们,他认为团队没有太多工作要做,因为他们早早地为开发人员购买了《设计模式》一书,并且他们实现了每一个设计模式。我的朋友听到这个消息很高兴,因为他按小时收费。错误应用设计模式支付了他的长子大学教育的大部分费用。

自《设计模式》一书出版以来,有大量文献涉及列举和描述设计模式。有关特定领域的设计模式的书籍,也有涉及大型企业系统模式的书籍。维基百科的软件设计模式类别包含了 130 种不同的设计模式。然而,我认为许多条目并不是真正的设计模式,而是编程范式。

在大多数情况下,设计模式是简单的构造,不需要复杂的库支持。虽然大多数语言都有模式库,但你不需要花大量金钱购买这些库。根据需要实现模式。拥有一本昂贵的库会让你盲目地应用模式,只是为了证明花了钱。即使你有钱,我也不知道有任何用于提供模式支持的 JavaScript 库。当然,GitHub 上有大量有趣的 JavaScript 项目,所以可能有一些我不知道的库。

有人建议设计模式应该是自发的。也就是说,通过以智能的方式编写软件,可以看到模式从实现中出现。我认为这可能是一个准确的说法,但它忽略了通过试错来实现这些实现的实际成本。了解设计模式的人更有可能早期发现自发的模式。教导初级程序员有关模式是一个非常有用的练习。早期了解可以应用哪种模式或模式作为一种捷径。完整的解决方案可以更早地到达,并且减少错误。

反模式

如果在良好的软件设计中可以找到常见模式,那么在糟糕的软件设计中也可以找到模式吗?当然可以!有许多方法可以做错事情,但大多数都已经做过。要以前所未有的方式搞砸,需要真正的创造力。

可惜的是,很难记住多年来人们犯过的所有错误。在许多重大项目结束时,团队会坐下来撰写一份名为“经验教训”的文件。这份文件包含了项目中可能出现的问题,甚至可能概述了如何避免这些问题的建议。不幸的是,这些文件只在项目结束时才被制作。到那时,许多关键人员已经离开,剩下的人必须试图记住项目早期的经验教训,这可能是几年前的事了。最好在项目进行过程中制作这份文件。

一旦完成,文件就会被归档,准备供下一个项目使用。至少,这是理论。大部分情况下,文件被归档后就再也没有被使用过。很难创造出全球适用的经验教训。所学到的经验教训往往只对当前项目或完全相同的项目有用,而这几乎不会发生。

然而,通过查看来自各种项目的多份文件,模式开始显现。正是通过这种方法,威廉·布朗、拉斐尔·马尔沃、斯基普·麦考密克和汤姆·莫布雷,合称为“新生四杰”,参照了最初的四杰,撰写了关于反模式的最初著作。这本书《反模式:重构软件、架构和危机项目》不仅概述了代码问题,还概述了围绕代码的管理过程中的反模式。

概述的模式包括一些幽默命名的模式,如“Blob 和 Lava Flow”。Blob,也被称为上帝对象,是指一个对象不断增长,承担了应用程序逻辑的大部分责任。Lava Flow 是一种随着项目变老而出现的模式,没有人知道代码是否仍在使用。开发人员不敢删除代码,因为它可能在某处被使用,或者将来可能会再次有用。书中还描述了许多其他值得探索的模式。与模式一样,反模式也是从编写代码中出现的,但在这种情况下,是失控的代码。

本书不涵盖 JavaScript 反模式,但值得记住的是,过度应用设计模式是一种反模式之一。

摘要

设计模式有着丰富而有趣的历史。从最初作为帮助描述如何构建结构以让人们共同生活的工具,它们已经发展成适用于许多领域。

自从将设计模式应用于编程的开创性工作以来已经过去了十年。此后,已经开发出了大量新的模式。其中一些模式是通用模式,如《设计模式》一书中概述的那些,但更多的是非常具体的模式,专为在狭窄领域中使用而设计。

JavaScript 也有着有趣的历史,正在迎来成熟。随着服务器端 JavaScript 的兴起和大型 JavaScript 应用程序变得普遍,构建 JavaScript 应用程序需要更多的细心。在大多数现代 JavaScript 代码中很少看到模式被正确地应用。

依靠设计模式提供的教导来构建现代 JavaScript 模式,可以让我们兼得两全。正如艾萨克·牛顿所说:

“如果我看得更远一些,那是因为我站在巨人的肩膀上。”

模式为我们提供了易于访问的支持。

在下一章中,我们将探讨一些在 JavaScript 中构建结构的技术。JavaScript 的继承系统与大多数其他面向对象的语言不同,这为我们提供了机会和限制。我们将看到如何在 JavaScript 世界中构建类和模块。

第一部分:经典设计模式

组织代码

创造模式

结构模式

行为模式

第二章:组织代码

在本章中,我们将看看如何将 JavaScript 代码组织成可重用、可理解的代码块。语言本身并不适合这种模块化,但多年来出现了许多组织 JavaScript 代码的方法。本章将论证需要拆分代码,然后逐步介绍创建 JavaScript 模块的方法。

我们将涵盖以下主题:

  • 全局范围

  • 对象

  • 原型继承

  • ECMAScript 2015 类

代码块

任何人学习编程的第一件事就是无处不在的 Hello World 应用程序。这个简单的应用程序将“hello world”的某种变体打印到屏幕上。取决于你问的人,hello world 这个短语可以追溯到 20 世纪 70 年代初,当时它被用来演示 B 编程语言,甚至可以追溯到 1967 年,当时它出现在 BCL 编程指南中。在这样一个简单的应用程序中,无需担心代码的结构。事实上,在许多编程语言中,hello world 根本不需要结构。

对于 Ruby,情况如下:

 **#!/usr/bin/ruby** 

 **puts "hello world"** 

对于 JavaScript(通过 Node.js),情况如下:

 **#!/usr/local/bin/node** 

 **console.log("Hello world")** 

最初使用极其简单的技术来编程现代计算机。许多最初的计算机在解决问题时都被硬编码。它们不像我们今天拥有的通用计算机那样。相反,它们被构建为仅解决一个问题,例如解码加密文本。存储程序计算机最早是在 1940 年代末开发的。

最初用于编程这些计算机的语言通常非常复杂,通常与二进制密切相关。最终,创建了越来越高级的抽象,使编程更加易于访问。随着这些语言在 50 年代和 60 年代开始形成,很快就显而易见地需要一些方法来划分大块代码。

部分原因是为了保持程序员的理智,他们无法一次记住整个大型程序。然而,创建可重用的模块也允许在应用程序内部甚至在应用程序之间共享代码。最初的解决方案是利用语句,它们跳转程序的流程控制从一个地方到另一个地方。多年来,这些 GOTO 语句被大量依赖。对于一个不断受到有关使用 GOTO 语句的警告的现代程序员来说,这似乎是疯狂的。然而,直到第一批编程语言出现几年后,结构化编程才发展成为取代 GOTO 语法的形式。

结构化编程基于 Böhm-Jacopini 定理,该定理指出有一类相当大的问题,其答案可以使用三个非常简单的构造来计算:

  • 子程序的顺序执行

  • 两个子程序的条件执行

  • 重复执行子程序,直到条件为真

敏锐的读者会认识到这些构造是正常的执行流程,一个分支或if语句和一个循环。

Fortran 是最早的语言之一,最初构建时没有支持结构化编程。然而,结构化编程很快被采纳,因为它有助于避免意大利面式代码。

Fortran 中的代码被组织成模块。模块是松散耦合的过程集合。对于那些来自现代面向对象语言的人来说,最接近的概念可能是模块就像一个只包含静态方法的类。

模块对于将代码分成逻辑分组非常有用。但是,它并没有为实际应用程序提供任何结构。面向对象语言的结构,即类和子类,可以追溯到 Ole-Johan Dahl 和 Kristen Nygaard 在 1967 年撰写的一篇论文。这篇论文将成为 Simula-67 的基础,这是第一种支持面向对象编程的语言。

虽然 Simula-67 是第一种具有类的语言,但与早期面向对象编程相关的最多讨论的语言是 Smalltalk。这种语言是在 20 世纪 70 年代在著名的施乐帕洛阿尔托研究中心PARC)秘密开发的。它于 1980 年作为 Smalltalk-80 向公众发布(似乎所有具有历史意义的编程语言都以发布年份作为版本号的前缀)。Smalltalk 带来的是语言中的一切都是对象,甚至像 3 这样的文字数字也可以对它们执行操作。

几乎每种现代编程语言都有一些类的概念来组织代码。通常,这些类将属于一个称为命名空间或模块的更高级结构。通过使用这些结构,即使是非常大的程序也可以分成可管理和可理解的块。

尽管类和模块具有丰富的历史和明显的实用性,JavaScript 直到最近才支持它们作为一流构造。要理解为什么,只需简单地回顾一下 JavaScript 的历史,从第一章,为了乐趣和利润而设计,并意识到对于其最初的目的来说,拥有这样的构造将是多余的。类是注定要失败的 ECMAScript 4 标准的一部分,它们最终成为了 ECMAScript 2015 标准的一部分。

在本章中,我们将探讨一些在 JavaScript 中重新创建其他现代编程语言中的经典类结构的方法。

全局范围有什么问题?

在基于浏览器的 JavaScript 中,您创建的每个对象都分配给全局范围。对于浏览器,这个对象简单地称为window。通过在您喜欢的浏览器中打开开发控制台,可以很容易地看到这种行为。

提示

打开开发控制台

现代浏览器内置了一些非常先进的调试和审计工具。要访问它们,有一个菜单项,位于工具 | Chrome 开发者工具 | 工具 | Firefox Web 开发者下,以及直接在菜单下方的F12 开发者工具在 Internet Explorer 中。还存在用于访问工具的键盘快捷键。在 Windows 和 Linux 上,F12是标准的,在 OSX 上,使用Option + Command + I

开发工具中有一个控制台窗口,可以直接访问当前页面的 JavaScript。这是一个非常方便的地方,可以测试小代码片段或访问页面的 JavaScript。

打开控制台后,输入以下代码:

> var words = "hello world"
> console.log(window.words);

这将导致hello world打印到控制台。通过全局声明单词,它会自动附加到顶层容器:window。

在 Node.js 中,情况有些不同。以这种方式分配变量实际上会将其附加到当前模块。不包括var对象将会将变量附加到global对象上。

多年来,您可能听说过使用全局变量是一件坏事。这是因为全局变量很容易被其他代码污染。

考虑一个非常常见的变量名,比如index。在任何规模可观的应用程序中,这个变量名可能会在多个地方使用。当任一代码片段使用该变量时,它会导致另一代码片段出现意外结果。重用变量是可能的,甚至在内存非常有限的系统中也可能很有用,比如嵌入式系统,但在大多数应用程序中,在单个范围内重用变量以表示不同的含义是难以理解的,也是错误的根源。

使用全局作用域变量的应用程序也容易受到其他代码的攻击。从其他代码改变全局变量的状态是微不足道的,这可能会使攻击者暴露登录信息等机密信息。

最后,全局变量给应用程序增加了很多复杂性。将变量的范围减小到代码的一小部分可以让开发人员更容易理解变量的使用方式。当范围是全局时,对该变量的更改可能会影响到代码的其他部分。对变量的简单更改可能会影响整个应用程序。

一般来说,应该避免使用全局变量。

JavaScript 中的对象

JavaScript 是一种面向对象的语言,但大多数人在使用它时并不会充分利用其面向对象的特性。JavaScript 使用混合对象模型,它既有原始值,也有对象。JavaScript 有五种原始类型:

  • 未定义

  • 空值

  • 布尔值

  • 字符串

  • 数字

在这五个值中,只有两个是我们期望的对象。另外三个,布尔值、字符串和数字都有包装版本,它们是对象:Boolean、String 和 Number。它们以大写字母开头进行区分。这与 Java 使用的模型相同,是对象和原始值的混合。

JavaScript 还会根据需要对原始值进行装箱和未装箱。

在这段代码中,您可以看到 JavaScript 原始值的装箱和未装箱版本在工作:

var numberOne = new Number(1);
var numberTwo = 2;
typeof numberOne; //returns 'object'
typeof numberTwo; //returns 'number'
var numberThree = numberOne + numberTwo;
typeof numberThree; //returns 'number'

在 JavaScript 中创建对象是微不足道的。可以在这段代码中看到在 JavaScript 中创建对象的过程:

var objectOne = {};
typeof objectOne; //returns 'object'
var objectTwo = new Object();
typeof objectTwo; //returns 'object'

因为 JavaScript 是一种动态语言,向对象添加属性也非常容易。甚至可以在创建对象之后进行。这段代码创建了对象:

var objectOne = { value: 7 };
var objectTwo = {};
objectTwo.value = 7;

对象包含数据和功能。到目前为止,我们只看到了数据部分。幸运的是,在 JavaScript 中,函数是一等对象。函数可以传递并且函数可以分配给变量。让我们尝试向我们在这段代码中创建的对象添加一些函数:

var functionObject = {};
functionObject.doThings = function() {
  console.log("hello world");
}
functionObject.doThings(); //writes "hello world" to the console

这种语法有点痛苦,一次分配一个对象。让我们看看是否可以改进创建对象的语法:

var functionObject = {
  doThings: function() {
    console.log("hello world");
  }
}
functionObject.doThings();//writes "hello world" to the console

这种语法看起来,至少对我来说,是一种更清晰、更传统的构建对象的方式。当然,可以以这种方式在对象中混合数据和功能:

var functionObject = {
  greeting: "hello world",
  doThings: function() {
    console.log(this.greeting);
  }
}
functionObject.doThings();//prints hello world

在这段代码中有几点需要注意。首先,对象中的不同项使用逗号而不是分号分隔。那些来自其他语言如 C#或 Java 的人可能会犯这个错误。下一个值得注意的是,我们需要使用this限定符来从doThings函数内部访问greeting变量。如果我们在对象中有多个函数,情况也是如此,如下所示:

var functionObject = {
  greeting: "hello world",
  doThings: function() {
    console.log(this.greeting);
    this.doOtherThings();
  },
  doOtherThings: function() {
    console.log(this.greeting.split("").reverse().join(""));
  }
}
functionObject.doThings();//prints hello world then dlrow olleh

this关键字在 JavaScript 中的行为与您从其他 C 语法语言中所期望的不同。this绑定到函数的所有者中。但是,函数的所有者有时并不是您所期望的。在前面的示例中,this绑定到functionObject对象,但是如果函数在对象之外声明,这将指向全局对象。在某些情况下,通常是事件处理程序,this会重新绑定到触发事件的对象。

让我们看一下以下代码:

var target = document.getElementById("someId");
target.addEventListener("click", function() {
  console.log(this);
}, false);

this采用目标的值。熟悉this的值可能是 JavaScript 中最棘手的事情之一。

ECMAScript-2015 引入了let关键字,可以替代var关键字来声明变量。let使用块级作用域,这是大多数语言中常用的作用域。让我们看一个它们之间的例子:

for(var varScoped =0; varScoped <10; varScoped++)
{
  console.log(varScoped);
}
console.log(varScoped +10);
for(let letScoped =0; letScoped<10; letScoped++)
{
  console.log(letScoped);
}
console.log(letScoped+10);

使用 var 作用域版本,您可以看到变量在块外继续存在。这是因为在幕后,varScoped的声明被提升到代码块的开头。在代码的let作用域版本中,letScoped仅在for循环内部作用域,因此一旦离开循环,letScoped就变为未定义。在使用letvar的选择时,我们倾向于始终使用let。有些情况下,您确实希望使用 var 作用域,但这些情况寥寥无几。

我们已经建立了一个相当完整的模型,来展示如何在 JavaScript 中构建对象。但是,对象并不等同于类。对象是类的实例。如果我们想要创建多个functionObject对象的实例,我们就没那么幸运了。尝试这样做将导致错误。在 Node.js 的情况下,错误将如下所示:

let obj = new functionObject();
TypeError: object is not a function
  at repl:1:11
  at REPLServer.self.eval (repl.js:110:21)
  at repl.js:249:20
  at REPLServer.self.eval (repl.js:122:7)
  at Interface.<anonymous> (repl.js:239:12)
  at Interface.EventEmitter.emit (events.js:95:17)
  at Interface._onLine (readline.js:202:10)
  at Interface._line (readline.js:531:8)
  at Interface._ttyWrite (readline.js:760:14)
  at ReadStream.onkeypress (readline.js:99:10)

这里的堆栈跟踪显示了一个名为repl的模块中的错误。这是在启动 Node.js 时默认加载的读取-执行-打印循环。

每次需要一个新实例时,都必须重新构建对象。为了避免这种情况,我们可以使用函数来定义对象,就像这样:

let ThingDoer = function(){
  this.greeting = "hello world";
  this.doThings = function() {
    console.log(this.greeting);
    this.doOtherThings();
  };
  this.doOtherThings = function() {
    console.log(this.greeting.split("").reverse().join(""));
  };
}
let instance = new ThingDoer();
instance.doThings(); //prints hello world then dlrow olleh

这种语法允许定义构造函数,并从该函数创建新对象。没有返回值的构造函数是在创建对象时调用的函数。在 JavaScript 中,构造函数实际上返回创建的对象。您甚至可以通过将它们作为初始函数的一部分来使用构造函数来分配内部属性,就像这样:

let ThingDoer = function(greeting){
  this.greeting = greeting;
  this.doThings = function() {
    console.log(this.greeting);
  };
}
let instance = new ThingDoer("hello universe");
instance.doThings();

给我建立一个原型

如前所述,直到最近,JavaScript 没有支持创建真正的类。虽然 ECMAScript-2015 为类带来了一些语法糖,但底层的对象系统仍然与过去一样,因此看到我们如何在没有这些语法糖的情况下创建对象仍然具有指导意义。使用前一节中的结构创建的对象有一个相当大的缺点:创建多个对象不仅耗时,而且占用内存。以相同方式创建的每个对象都是完全独立的。这意味着用于保存函数定义的内存不会在所有实例之间共享。更有趣的是,您甚至可以重新定义类的单个实例,而不改变所有实例。这在这段代码中得到了证明:

let Castle = function(name){
  this.name = name;
  this.build = function() {
    console.log(this.name);
  };
}
let instance1 = new Castle("Winterfell");
let instance2 = new Castle("Harrenhall");
instance1.build = function(){ console.log("Moat Cailin");}
instance1.build(); //prints "Moat Cailin"
instance2.build(); //prints "Harrenhall" to the console

以这种方式改变单个实例的功能,或者实际上是以任何已定义的对象的方式,被称为monkey patching。人们对这是否是一种良好的做法存在分歧。在处理库代码时,它肯定是有用的,但它会带来很大的混乱。通常认为更好的做法是扩展现有类。

在没有适当的类系统的情况下,JavaScript 当然没有继承的概念。但是,它确实有一个原型。在 JavaScript 中,对象在最基本的层面上是一个键和值的关联数组。对象上的每个属性或函数都简单地定义为这个数组的一部分。您甚至可以通过使用数组语法访问对象的成员来看到这一点,就像这里所示的那样:

let thing = { a: 7};
console.log(thing["a"]);

提示

使用数组语法访问对象的成员可以是一种非常方便的方法,可以避免使用 eval 函数。例如,如果我有一个名为funcName的字符串,我想在对象obj1上调用它,那么我可以这样做obj1[funcName](),而不是使用可能危险的 eval 调用。Eval 允许执行任意代码。在页面上允许这样做意味着攻击者可能能够在其他人的浏览器上输入恶意脚本。

当创建对象时,它的定义是从原型继承的。奇怪的是,每个原型也是一个对象,所以甚至原型也有原型。好吧,除了作为顶级原型的对象。将函数附加到原型的优势在于只创建一个函数的副本;节省内存。原型有一些复杂性,但您肯定可以在不了解它们的情况下生存。要使用原型,您只需将函数分配给它,如下所示:

let Castle = function(name){
  this.name = name;
}
Castle.prototype.build = function(){ console.log(this.name);}
let instance1 = new Castle("Winterfell");
instance1.build();

需要注意的一件事是只有函数分配给原型。诸如name之类的实例变量仍然分配给实例。由于这些对每个实例都是唯一的,因此对内存使用没有真正的影响。

在许多方面,原型语言比基于类的继承模型更强大。

如果以后更改对象的原型,则共享该原型的所有对象都将使用新函数进行更新。这消除了关于猴子打字的一些担忧。此行为的示例如下:

let Castle = function(name){
  this.name = name;
}
Castle.prototype.build = function(){
  console.log(this.name);
}
let instance1 = new Castle("Winterfell");
Castle.prototype.build = function(){
  console.log(this.name.replace("Winterfell", "Moat Cailin"));
}
instance1.build();//prints "Moat Cailin" to the console

在构建对象时,您应该确保尽可能利用原型对象。

现在我们知道了原型,JavaScript 中构建对象的另一种方法是使用Object.create函数。这是 ECMAScript 5 中引入的新语法。语法如下:

Object.create(prototype [, propertiesObject ] )

创建语法将基于给定的原型构建一个新对象。您还可以传递一个propertiesObject对象,该对象描述了创建的对象上的附加字段。这些描述符包括许多可选字段:

  • 可写:这决定了字段是否可写

  • 可配置:这决定了文件是否应该从对象中移除或在创建后支持进一步配置

  • 可枚举:这决定了属性在对象属性枚举期间是否可以被列出

  • :这决定了字段的默认值

还可以在描述符中分配getset函数,这些函数充当其他内部属性的 getter 和 setter。

使用object.create为我们的城堡,我们可以像这样使用Object.create构建一个实例:

let instance3 = Object.create(Castle.prototype, {name: { value: "Winterfell", writable: false}});
instance3.build();
instance3.name="Highgarden";
instance3.build();

您会注意到我们明确定义了name字段。Object.create绕过了构造函数,因此我们在前面的代码中描述的初始赋值不会被调用。您可能还注意到writeable设置为false。其结果是对name的重新分配为Highgarden没有效果。输出如下:

Winterfell
Winterfell

继承

对象的一个好处是可以构建更复杂的对象。这是一个常见的模式,用于任何数量的事情。JavaScript 中没有继承,因为它是原型的性质。但是,您可以将一个原型中的函数组合到另一个原型中。

假设我们有一个名为Castle的基类,并且我们想将其定制为一个更具体的类Winterfell。我们可以通过首先将所有属性从Castle原型复制到Winterfell原型来实现。可以这样做:

let Castle = function(){};
Castle.prototype.build = function(){console.log("Castle built");}

let Winterfell = function(){};
Winterfell.prototype.build = Castle.prototype.build;
Winterfell.prototype.addGodsWood = function(){}
let winterfell = new Winterfell();
winterfell.build(); //prints "Castle built" to the console

当然,这是一种非常痛苦的构建对象的方式。您被迫确切地知道基类有哪些函数来复制它们。可以像这样天真地抽象化:

function clone(source, destination) {
  for(var attr in source.prototype){ destination.prototype[attr] = source.prototype[attr];}
}

如果你对对象图表感兴趣,这显示了Winterfell在这个图表中如何扩展Castle

继承

这可以很简单地使用如下:

let Castle = function(){};
Castle.prototype.build = function(){console.log("Castle built");}

let Winterfell = function(){};
clone(Castle, Winterfell);
let winterfell = new Winterfell();
winterfell.build();

我们说这是天真的,因为它没有考虑到许多潜在的失败条件。一个完整的实现是相当广泛的。jQuery 库提供了一个名为extend的函数,它以健壮的方式实现了原型继承。它大约有 50 行代码,处理深层复制和空值。这个函数在 jQuery 内部被广泛使用,但它也可以成为你自己代码中非常有用的函数。我们提到原型继承比传统的继承方法更强大。这是因为可以从许多基类中混合和匹配位来创建一个新的类。大多数现代语言只支持单一继承:一个类只能有一个直接的父类。有一些语言支持多重继承,然而,这是一种在运行时决定调用哪个版本的方法时增加了很多复杂性的做法。原型继承通过在组装时强制选择方法来避免许多这些问题。

以这种方式组合对象允许从两个或更多不同的基类中获取属性。有许多时候这是很有用的。例如,代表狼的类可能从描述狗的类和描述四足动物的另一个类中获取一些属性。

通过使用以这种方式构建的类,我们可以满足几乎所有构建类系统包括继承的要求。然而,继承是一种非常强的耦合形式。在几乎所有情况下,最好避免继承,而选择一种更松散的耦合形式。这将允许类在对系统的其余部分影响最小的情况下被替换或更改。

模块

现在我们有了一个完整的类系统,很好地解决了之前讨论过的全局命名空间问题。同样,JavaScript 没有对命名空间的一流支持,但我们可以很容易地将功能隔离到等同于命名空间的东西中。在 JavaScript 中有许多不同的创建模块的方法。我们将从最简单的开始,并随着进展逐渐添加一些功能。

首先,我们只需要将一个对象附加到全局命名空间。这个对象将包含我们的根命名空间。我们将命名我们的命名空间为Westeros;代码看起来就像这样:

Westeros = {}

这个对象默认附加到顶层对象,所以我们不需要做更多的事情。一个典型的用法是首先检查对象是否已经存在,然后使用该版本而不是重新分配变量。这允许你将你的定义分散在许多文件中。理论上,你可以在每个文件中定义一个单一的类,然后在交付给客户或在应用程序中使用之前,在构建过程的一部分将它们全部汇集在一起。这个简短的形式是:

Westeros = Westeros || {}

一旦我们有了这个对象,只需要将我们的类分配为该对象的属性。如果我们继续使用Castle对象,那么它看起来会像这样:

let Westeros = Westeros || {};
Westeros.Castle = function(name){this.name = name}; //constructor
Westeros.Castle.prototype.Build = function(){console.log("Castle built: " +  this.name)};

如果我们想要构建一个多于单层深度的命名空间层次结构,也很容易实现,就像这段代码中所示的那样:

let Westeros = Westeros || {};
Westeros.Structures = Westeros.Structures || {};
Westeros.Structures.Castle = function(name){ this.name = name}; //constructor
Westeros.Structures.Castle.prototype.Build = function(){console.log("Castle built: " +  this.name)};

这个类可以被实例化并且以类似于之前例子的方式使用:

let winterfell = new Westeros.Structures.Castle("Winterfell");
winterfell.Build();

当然,使用 JavaScript 有多种构建相同代码结构的方法。构建前面的代码的一种简单方法是利用创建并立即执行函数的能力:

let Castle = (function () {
  function Castle(name) {
    this.name = name;
  }
  Castle.prototype.Build = function () {
    console.log("Castle built: " + this.name);
  };
  return Castle;
})();
Westros.Structures.Castle = Castle;

这段代码似乎比之前的代码示例要长一些,但由于其分层性质,我觉得更容易理解。我们可以像前面的代码中所示的那样,在相同的结构中使用它们来创建一个新的城堡:

let winterfell = new Westeros.Structures.Castle("Winterfell");
winterfell.Build();

使用这种结构进行继承也相对容易。如果我们定义了一个BaseStructure类,它是所有结构的祖先,那么使用它会像这样:

let BaseStructure = (function () {
  function BaseStructure() {
  }
  return BaseStructure;
})();
Structures.BaseStructure = BaseStructure;
let Castle = (function (_super) {
  **__extends(Castle, _super);** 

  function Castle(name) {
    this.name = name;
    _super.call(this);
  }
  Castle.prototype.Build = function () {
    console.log("Castle built: " + this.name);
  };
  return Castle;
})(BaseStructure);

您会注意到,当闭包被评估时,基本结构被传递到Castle对象中。代码中的高亮行使用了一个叫做__extends的辅助方法。这个方法负责将函数从基本原型复制到派生类中。这段特定的代码是由 TypeScript 编译器生成的,它还生成了一个看起来像这样的extends方法:

let __extends = this.__extends || function (d, b) {
  for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
  function __() { this.constructor = d; }
  __.prototype = b.prototype;
  d.prototype = new __();
};

我们可以继续使用我们采用的相当巧妙的闭包语法来实现整个模块。如下所示:

var Westeros;
(function (Westeros) {
  (function (Structures) {
    let Castle = (function () {
      function Castle(name) {
        this.name = name;
      }
       Castle.prototype.Build = function () {
         console.log("Castle built " + this.name);
       };
       return Castle;
     })();
     Structures.Castle = Castle;
  })(Westeros.Structures || (Westeros.Structures = {}));
  var Structures = Westeros.Structures;
})(Westeros || (Westeros = {}));

在这个结构中,您可以看到我们之前探讨过的创建模块的相同代码。在单个模块中定义多个类也相对容易。这可以在这段代码中看到:

var Westeros;
(function (Westeros) {
  (function (Structures) {
    let Castle = (function () {
      function Castle(name) {
        this.name = name;
      }
      Castle.prototype.Build = function () {
        console.log("Castle built: " + this.name);
        var w = new Wall();
      };
      return Castle;
    })();
    Structures.Castle = Castle;
 **var Wall = (function () {** 

 **function Wall() {** 

 **console.log("Wall constructed");** 

 **}** 

 **return Wall;** 

 **})();** 

 **Structures.Wall = Wall;** 

  })(Westeros.Structures || (Westeros.Structures = {}));
  var Structures = Westeros.Structures;
})(Westeros || (Westeros = {}));

高亮代码在模块内创建了第二个类。在每个文件中定义一个类也是完全允许的。因为代码在盲目重新分配之前检查Westeros的当前值,所以我们可以安全地将模块定义分割成多个文件。

高亮部分的最后一行显示了在闭包之外暴露类。如果我们想要创建只在模块内部可用的私有类,那么我们只需要排除那一行。这实际上被称为揭示模块模式。我们只暴露需要全局可用的类。尽可能将功能保持在全局命名空间之外是一个很好的做法。

ECMAScript 2015 类和模块

到目前为止,我们已经看到在 ECMAScript-2015 之前的 JavaScript 中完全可以构建类甚至模块。显然,这种语法比如 C#或 Java 等语言更复杂。幸运的是,ECMAScript-2015 为创建类提供了一些语法糖的支持:

class Castle extends Westeros.Structures.BaseStructure {
  constructor(name, allegience) {
    super(name);
    ...
  }
  Build() {
    ...
    super.Build();
  }
}

ECMAScript-2015 还为 JavaScript 带来了一个经过深思熟虑的模块系统。还有一些用于创建模块的语法糖,看起来像这样:

module 'Westeros' {
  export function Rule(rulerName, house) {
    ...
    return "Long live " + rulerName + " of house " + house;
  }
}

由于模块可以包含函数,当然也可以包含类。ECMAScript-2015 还定义了模块导入语法和支持从远程位置检索模块。导入模块看起来像这样:

import westeros from 'Westeros';
module JSON from 'http://json.org/modules/json2.js';
westeros.Rule("Rob Stark", "Stark");

一些这种语法糖在任何支持完整 ECMAScript-2015 的环境中都是可用的。在撰写本文时,所有主要浏览器供应商对 ECMAScript-2015 的类部分都有很好的支持,所以几乎没有理由不使用它,除非你不得不支持古老的浏览器。

最佳实践和故障排除

在理想的世界中,每个人都可以在从一开始就制定标准的绿地项目上工作。然而,情况并非如此。经常情况下,你可能会发现自己处于一个遗留系统的一部分,其中有一堆非模块化的 JavaScript 代码。

在这些情况下,简单地忽略非模块化的代码直到真正需要升级它可能是有利的。尽管 JavaScript 很受欢迎,但 JavaScript 的许多工具仍然不够成熟,这使得很难依赖编译器来找出 JavaScript 重构引入的错误。自动重构工具也受到 JavaScript 动态特性的复杂性的影响。然而,对于新代码,正确使用模块化的 JavaScript 可以非常有助于避免命名空间冲突并提高可测试性。

如何安排 JavaScript 是一个有趣的问题。从网页的角度来看,我采取了将 JavaScript 与网页保持一致的方法。因此,每个页面都有一个关联的 JavaScript 文件,负责该页面的功能。此外,页面之间共同的组件,比如网格控件,被放置在一个单独的文件中。在编译时,所有文件都被合并成一个单独的 JavaScript 文件。这有助于在保持小型代码文件的同时减少浏览器向服务器发出的请求次数。

总结

有人说计算机科学中只有两件真正困难的事情。这些问题的具体内容因说话者而异。经常是一些与缓存失效和命名有关的变体。如何组织代码是其中很大一部分的命名问题。

作为一个团体,我们似乎已经坚定地接受了命名空间和类的概念。正如我们所见,JavaScript 中没有直接支持这两个概念。然而,有无数种方法可以解决这个问题,其中一些方法实际上提供的功能比传统的命名空间/类系统更强大。

JavaScript 的主要问题是要避免用大量名称相似但不相关的对象污染全局命名空间。将 JavaScript 封装成模块是朝着编写可维护和可重用代码的关键步骤。

随着我们的前进,我们会看到许多复杂的接口排列在 JavaScript 的世界中变得更加简单。原型继承,起初似乎很困难,但它是简化设计模式的巨大工具。

第三章:创建模式

在上一章中,我们详细研究了如何构建一个类。在本章中,我们将研究如何创建类的实例。表面上看,这似乎是一个简单的问题,但我们如何创建类的实例可能非常重要。

我们非常努力地创建我们的代码,使其尽可能解耦。确保类对其他类的依赖最小是构建一个可以随着使用软件的人的需求变化而流畅变化的系统的关键。允许类之间关系过于紧密意味着变化会像涟漪一样在它们之间传播。

一个涟漪并不是一个巨大的问题,但随着你不断引入更多的变化,涟漪会累积并产生干涉模式。很快,曾经平静的表面就变成了无法辨认的添加和破坏节点的混乱。我们的应用程序中也会出现同样的问题:变化会放大并以意想不到的方式相互作用。我们经常忽视耦合的一个地方就是在对象的创建中:

let Westeros;
(function (Westeros) {
  let Ruler = (function () {
    function Ruler() {
      this.house = new Westeros.Houses.Targaryen();
    }
    return Ruler;
  })();
  Westeros.Ruler = Ruler;
})(Westeros || (Westeros = {}));

在这个类中,你可以看到统治者的家与Targaryen类紧密耦合。如果这种情况发生改变,那么这种紧密耦合就必须在很多地方进行改变。本章讨论了一些模式,这些模式最初是在《设计模式:可复用面向对象软件的元素》一书中提出的。这些模式的目标是改善应用程序中的耦合程度,并增加代码重用的机会。这些模式如下:

  • 抽象工厂

  • 建造者

  • 工厂方法

  • 单例

  • 原型

当然,并非所有这些都适用于 JavaScript,但随着我们逐步了解创建模式,我们会了解到这一切。

抽象工厂

这里介绍的第一个模式是一种创建对象套件的方法,而不需要知道对象的具体类型。让我们继续使用前一节中介绍的统治王国的系统。

对于所讨论的王国,统治家族的更换频率相当高。很可能在更换家族时会有一定程度的战斗和斗争,但我们暂且不予理会。每个家族都会以不同的方式统治王国。有些人看重和平与宁静,以仁慈的领导者统治,而另一些则以铁腕统治。一个王国的统治对于一个人来说太大了,所以国王会将一些决定交给一个叫做国王之手的副手。国王也会在一些事务上得到一个由一些精明的领主和贵妇组成的议会的建议。

我们描述的类的图表如下:

抽象工厂

提示

统一建模语言UML)是由对象管理组开发的标准化语言,用于描述计算机系统。该语言中有用于创建用户交互图、序列图和状态机等的词汇。对于本书的目的,我们最感兴趣的是类图,它描述了一组类之间的关系。

整个 UML 类图词汇量很大,超出了本书的范围。然而,维基百科上的文章en.wikipedia.org/wiki/Class_diagram以及 Derek Banas 的优秀视频教程www.youtube.com/watch?v=3cmzqZzwNDM都是很好的介绍。

问题在于,由于统治家族甚至统治家族成员经常变动,与 Targaryen 或 Lannister 等具体家族耦合会使我们的应用程序变得脆弱。脆弱的应用程序在不断变化的世界中表现不佳。

解决这个问题的方法是利用抽象工厂模式。抽象工厂声明了一个接口,用于创建与统治家族相关的各种类。

这种模式的类图相当令人生畏:

抽象工厂

抽象工厂类可能对统治家族的各个实现有多个。这些被称为具体工厂,它们每个都将实现抽象工厂提供的接口。具体工厂将返回各种统治类的具体实现。这些具体类被称为产品。

让我们首先看一下抽象工厂接口的代码。

没有代码?实际上确实如此。JavaScript 的动态特性消除了描述类所需的接口的需要。我们将直接创建类,而不是使用接口:

抽象工厂

JavaScript 不使用接口,而是相信您提供的类实现了所有适当的方法。在运行时,解释器将尝试调用您请求的方法,并且如果找到该方法,就会调用它。解释器只是假设如果您的类实现了该方法,那么它就是该类。这就是所谓的鸭子类型

注意

鸭子类型

鸭子类型的名称来源于 Alex Martelli 在 2000 年发布的一篇文章,他在comp.lang.python新闻组中写道:

换句话说,不要检查它是否是一只鸭子:检查它是否像一只鸭子一样嘎嘎叫,像一只鸭子一样走路,等等,具体取决于您需要用来玩语言游戏的鸭子行为的子集。

我喜欢 Martelli 可能是从Monty Python and the Holy Grail的巫师狩猎片段中借用了这个术语。虽然我找不到任何证据,但我认为这很可能,因为 Python 编程语言的名称就来自于 Monty Python。

鸭子类型是动态语言中的强大工具,可以大大减少实现类层次结构的开销。然而,它确实引入了一些不确定性。如果两个类实现了具有根本不同含义的同名方法,那么就无法知道调用的是正确的方法。例如,考虑以下代码:

class Boxer{
  function punch(){}
}
class TicketMachine{
  function punch(){}
}

这两个类都有一个punch()方法,但显然意义不同。JavaScript 解释器不知道它们是不同的类,并且会愉快地在任何一个类上调用 punch,即使其中一个没有意义。

一些动态语言支持一种通用方法,当调用未定义的方法时会调用该方法。例如,Ruby 有missing_method,在许多情况下都被证明非常有用。截至目前,JavaScript 目前不支持missing_method。然而,ECMAScript 2016,即 ECMAScript 2015 的后续版本,定义了一个称为Proxy的新构造,它将支持动态包装对象,借助它可以实现一个等价的missing_method

实现

为了演示抽象工厂的实现,我们首先需要一个King类的实现。这段代码提供了该实现:

let KingJoffery= (function () {
  function KingJoffery() {
  }
  KingJoffery.prototype.makeDecision = function () {
    …
  };
  KingJoffery.prototype.marry = function () {
    …
  };
  return KingJoffery;
})();

注意

这段代码不包括第二章组织代码中建议的模块结构。在每个示例中包含模块代码是乏味的,你们都是聪明的人,所以如果你们要真正使用它,就知道把它放在模块中。完全模块化的代码可以在分发的源代码中找到。

这只是一个普通的具体类,实际上可以包含任何实现细节。我们还需要一个同样不起眼的HandOfTheKing类的实现:

let LordTywin = (function () {
  function LordTywin() {
  }
  LordTywin.prototype.makeDecision = function () {
  };
  return LordTywin;
})();

具体的工厂方法如下:

let LannisterFactory = (function () {
  function LannisterFactory() {
  }
  LannisterFactory.prototype.getKing = function () {
    return new KingJoffery();
  };
  LannisterFactory.prototype.getHandOfTheKing = function ()
  {
    return new LordTywin();
  };
  return LannisterFactory;
})();

这段代码只是实例化所需类的新实例并返回它们。不同统治家族的另一种实现将遵循相同的一般形式,可能如下所示:

let TargaryenFactory = (function () {
  function TargaryenFactory() {
  }
  TargaryenFactory.prototype.getKing = function () {
    return new KingAerys();
  };
  TargaryenFactory.prototype.getHandOfTheKing = function () {
    return new LordConnington();
  };
  return TargaryenFactory;
})();

在 JavaScript 中实现抽象工厂比其他语言要容易得多。然而,这样做的代价是失去了编译器检查,它强制要求对工厂或产品进行完整的实现。随着我们继续学习其他模式,你会注意到这是一个常见的主题。在静态类型语言中有很多管道的模式要简单得多,但会增加运行时失败的风险。适当的单元测试或 JavaScript 编译器可以缓解这种情况。

要使用抽象工厂,我们首先需要一个需要使用某个统治家族的类:

let CourtSession = (function () {
  function CourtSession(abstractFactory) {
    this.abstractFactory = abstractFactory;
    this.COMPLAINT_THRESHOLD = 10;
  }
  CourtSession.prototype.complaintPresented = function (complaint) {
    if (complaint.severity < this.COMPLAINT_THRESHOLD) {
      this.abstractFactory.getHandOfTheKing().makeDecision();
    } else
    this.abstractFactory.getKing().makeDecision();
  };
  return CourtSession;
})();

现在我们可以调用这个CourtSession类,并根据传入的工厂注入不同的功能:

let courtSession1 = new CourtSession(new TargaryenFactory());
courtSession1.complaintPresented({ severity: 8 });
courtSession1.complaintPresented({ severity: 12 });

let courtSession2 = new CourtSession(new LannisterFactory());
courtSession2.complaintPresented({ severity: 8 });
courtSession2.complaintPresented({ severity: 12 });

尽管静态语言和 JavaScript 之间存在差异,但这种模式在 JavaScript 应用程序中仍然适用且有用。创建一组共同工作的对象对于许多情况都是有用的;每当一组对象需要协作提供功能但可能需要整体替换时。当试图确保一组对象一起使用而不进行替换时,这也可能是一个有用的模式。

建造者

在我们的虚构世界中,有时需要构建一些相当复杂的类。这些类包含了根据构建方式不同的接口实现。为了简化这些类的构建并将构建类的知识封装在消费者之外,可以使用建造者。多个具体建造者降低了实现中构造函数的复杂性。当需要新的建造者时,不需要添加构造函数,只需要插入一个新的建造者。

锦标赛是一个复杂类的例子。每个锦标赛都有一个复杂的设置,涉及事件、参与者和奖品。这些锦标赛的大部分设置都是相似的:每一个都有比武、射箭和混战。从代码中的多个位置创建锦标赛意味着构建锦标赛的责任被分散。如果需要更改初始化代码,那么必须在许多不同的地方进行更改。

通过使用构建器模式,可以避免这个问题,因为它集中了构建对象所需的逻辑。不同的具体构建器可以插入到构建器中,以构建不同的复杂对象。构建器模式中各个类之间的关系如下所示:

构建器

实施

让我们进去看一些代码。首先,我们将创建一些实用类,它们将表示比赛的各个部分,如下面的代码所示:

let Event = (function () {
  function Event(name) {
    this.name = name;
  }
  return Event;
})();
Westeros.Event = Event;

let Prize = (function () {
  function Prize(name) {
    this.name = name;
  }
  return Prize;
})();
Westeros.Prize = Prize;

let Attendee = (function () {
  function Attendee(name) {
    this.name = name;
  }
  return Attendee;
})();
Westeros.Attendee = Attendee;

比赛本身是一个非常简单的类,因为我们不需要显式地分配任何公共属性:

let Tournament = (function () {
  this.Events = [];
  function Tournament() {
  }
  return Tournament;
})();
Westeros.Tournament = Tournament;

我们将实现两个创建不同比赛的构建器。下面的代码中可以看到:

let LannisterTournamentBuilder = (function () {
  function LannisterTournamentBuilder() {
  }
  LannisterTournamentBuilder.prototype.build = function () {
    var tournament = new Tournament();
    tournament.events.push(new Event("Joust"));
    tournament.events.push(new Event("Melee"));
    tournament.attendees.push(new Attendee("Jamie"));
    tournament.prizes.push(new Prize("Gold"));
    tournament.prizes.push(new Prize("More Gold"));
    return tournament;
  };
  return LannisterTournamentBuilder;
})();
Westeros.LannisterTournamentBuilder = LannisterTournamentBuilder;

let BaratheonTournamentBuilder = (function () {
  function BaratheonTournamentBuilder() {
  }
  BaratheonTournamentBuilder.prototype.build = function () {
    let tournament = new Tournament();
    tournament.events.push(new Event("Joust"));
    tournament.events.push(new Event("Melee"));
    tournament.attendees.push(new Attendee("Stannis"));
    tournament.attendees.push(new Attendee("Robert"));
    return tournament;
  };
  return BaratheonTournamentBuilder;
})();
Westeros.BaratheonTournamentBuilder = BaratheonTournamentBuilder;

最后,导演,或者我们称之为TournamentBuilder,只需拿起一个构建器并执行它:

let TournamentBuilder = (function () {
  function TournamentBuilder() {
  }
  TournamentBuilder.prototype.build = function (builder) {
    return builder.build();
  };
  return TournamentBuilder;
})();
Westeros.TournamentBuilder = TournamentBuilder;

再次,您会看到 JavaScript 的实现比传统的实现要简单得多,因为不需要接口。

构建器不需要返回一个完全实现的对象。这意味着您可以创建一个部分填充对象的构建器,然后允许对象传递给另一个构建器来完成。一个很好的现实世界类比可能是汽车的制造过程。在装配线上的每个工位都只组装汽车的一部分,然后将其传递给下一个工位组装另一部分。这种方法允许将构建对象的工作分配给几个具有有限责任的类。在我们上面的例子中,我们可以有一个负责填充事件的构建器,另一个负责填充参与者的构建器。

在 JavaScript 的原型扩展模型中,构建器模式是否仍然有意义?我认为是的。仍然存在需要根据不同的方法创建复杂对象的情况。

工厂方法

我们已经看过抽象工厂和构建器。抽象工厂构建了一组相关的类,而构建器使用不同的策略创建复杂对象。工厂方法模式允许类请求接口的新实例,而不是类决定使用接口的哪个实现。工厂可能使用某种策略来选择要返回的实现:

工厂方法

有时,这种策略只是接受一个字符串参数或检查一些全局设置来充当开关。

实施

在我们的 Westworld 示例世界中,有很多时候我们希望将实现的选择推迟到工厂。就像现实世界一样,Westworld 拥有丰富多彩的宗教文化,有数十种不同的宗教崇拜各种各样的神。在每种宗教中祈祷时,必须遵循不同的规则。有些宗教要求献祭,而其他宗教只要求给予礼物。祈祷类不想知道所有不同的宗教以及如何构建它们。

让我们开始创建一些不同的神,可以向他们献祷。这段代码创建了三个神,包括一个默认的神,如果没有指定其他神,祷告就会落在他身上:

let WateryGod = (function () {
  function WateryGod() {
  }
  WateryGod.prototype.prayTo = function () {
  };
  return WateryGod;
})();
Religion.WateryGod = WateryGod;
let AncientGods = (function () {
  function AncientGods() {
  }
  AncientGods.prototype.prayTo = function () {
  };
  return AncientGods;
})();
Religion.AncientGods = AncientGods;

let DefaultGod = (function () {
  function DefaultGod() {
  }
  DefaultGod.prototype.prayTo = function () {
  };
  return DefaultGod;
})();
Religion.DefaultGod = DefaultGod;

我避免了为每个神明的任何实现细节。您可以想象任何您想要填充prayTo方法的传统。也没有必要确保每个神都实现了IGod接口。接下来,我们需要一个工厂,负责构建不同的神:

let GodFactory = (function () {
  function GodFactory() {
  }
  GodFactory.Build = function (godName) {
    if (godName === "watery")
      return new WateryGod();
    if (godName === "ancient")
      return new AncientGods();
    return new DefaultGod();
  };
  return GodFactory;
})();

您可以看到,在这个例子中,我们接受一个简单的字符串来决定如何创建一个神。它可以通过全局或更复杂的对象来完成。在 Westeros 的一些多神教中,神明有明确定的角色,如勇气之神、美丽之神或其他方面的神。必须祈祷的神不仅由宗教决定,还由祈祷的目的决定。我们可以用GodDeterminant类来表示这一点,如下所示:

let GodDeterminant = (function () {
  function GodDeterminant(religionName, prayerPurpose) {
    this.religionName = religionName;
    this.prayerPurpose = prayerPurpose;
  }
  return GodDeterminant;
})();

工厂将被更新以接受这个类,而不是简单的字符串。

最后,最后一步是看看这个工厂将如何被使用。这很简单,我们只需要传入一个表示我们希望观察的宗教的字符串,工厂将构造正确的神并返回它。这段代码演示了如何调用工厂:

let Prayer = (function () {
  function Prayer() {
  }
  Prayer.prototype.pray = function (godName) {
  GodFactory.Build(godName).prayTo();
  };
  return Prayer;
})();

再次,JavaScript 中肯定需要这样的模式。有很多时候,将实例化与使用分开是有用的。由于关注点的分离和注入假工厂以允许测试Prayer也很容易,测试实例化也非常简单。

继续创建不带接口的更简单模式的趋势,我们可以忽略模式的接口部分,直接使用类型,这要归功于鸭子类型。

工厂方法是一种非常有用的模式:它允许类将实例化的实现选择推迟到另一个类。当存在多个类似的实现时,这种模式非常有用,比如策略模式(参见第五章 ,行为模式),并且通常与抽象工厂模式一起使用。工厂方法用于在抽象工厂的具体实现中构建具体对象。抽象工厂模式可能包含多个工厂方法。工厂方法无疑是一种在 JavaScript 领域仍然适用的模式。

单例

单例模式可能是最常被滥用的模式。它也是近年来不受青睐的模式。为了看到为什么人们开始建议不要使用单例模式,让我们看看这个模式是如何工作的。

当需要全局变量时使用单例是可取的,但单例提供了防止意外创建复杂对象的保护。它还允许推迟对象实例化直到第一次使用。

单例的 UML 图如下所示:

单例

这显然是一种非常简单的模式。单例充当类的实例的包装器,单例本身作为全局变量存在。在访问实例时,我们只需向单例请求包装类的当前实例。如果类在单例中尚不存在,通常会在那时创建一个新实例。

实施

在我们在维斯特洛大陆的持续示例中,我们需要找到一个只能有一个东西的情况。不幸的是,这是一个经常发生冲突和敌对的土地,所以我最初想到的将国王作为单例的想法根本行不通。这也意味着我们不能利用其他明显的候选人(首都,王后,将军等)。然而,在维斯特洛大陆的最北部,有一堵巨大的墙,用来阻挡一位古老的敌人。这样的墙只有一堵,将其放在全局范围内应该没有问题。

让我们继续在 JavaScript 中创建一个单例:

let Westros;
(function (Westeros) {
  var Wall = (function () {
 **function Wall() {** 

 **this.height = 0;** 

 **if (Wall._instance)** 

 **return Wall._instance;** 

 **Wall._instance = this;** 

 **}** 

    Wall.prototype.setHeight = function (height) {
      this.height = height;
    };
    Wall.prototype.getStatus = function () {
      console.log("Wall is " + this.height + " meters tall");
    };
 **Wall.getInstance = function () {** 

 **if (!Wall._instance) {** 

 **Wall._instance = new Wall();** 

 **}** 

 **return Wall._instance;** 

 **};** 

    Wall._instance = null;
    return Wall;
  })();
  Westeros.Wall = Wall;
})(Westeros || (Westeros = {}));

该代码创建了墙的轻量级表示。单例在两个突出显示的部分中进行了演示。在像 C#或 Java 这样的语言中,我们通常会将构造函数设置为私有,以便只能通过静态方法getInstance来调用它。然而,在 JavaScript 中我们没有这个能力:构造函数不能是私有的。因此,我们尽力而为,从构造函数中返回当前实例。这可能看起来很奇怪,但在我们构造类的方式中,构造函数与任何其他方法没有区别,因此可以从中返回一些东西。

在第二个突出部分中,我们将静态变量_instance设置为 Wall 的新实例(如果还没有)。如果_instance已经存在,我们将返回它。在 C#和 Java 中,这个函数需要一些复杂的锁定逻辑,以避免两个不同的线程同时尝试访问实例时出现竞争条件。幸运的是,在 JavaScript 中不需要担心这个问题,因为多线程的情况不同。

缺点

单例在过去几年中声名狼藉。它们实际上是被吹捧的全局变量。正如我们讨论过的,全局变量是不合理的,可能导致许多错误。它们也很难通过单元测试进行测试,因为实例的创建不能轻易被覆盖,测试运行器中的任何并行性都可能引入难以诊断的竞争条件。我对它们最大的担忧是单例承担了太多的责任。它们不仅控制自己,还控制它们的实例化。这是对单一责任原则的明显违反。几乎每一个可以使用单例解决的问题,都可以通过其他机制更好地解决。

JavaScript 使问题变得更糟。由于构造函数的限制,无法创建单例的清晰实现。这与单例的一般问题结合在一起,使我建议在 JavaScript 中应避免使用单例模式。

原型

本章中的最后一个创建模式是原型模式。也许这个名字听起来很熟悉。它确实应该:这是 JavaScript 支持继承的机制。

我们研究了用于继承的原型,但原型的适用性不一定局限于继承。复制现有对象可以是一个非常有用的模式。有许多情况下,能够复制构造对象是很方便的。例如,通过保存利用某种克隆创建的先前实例,很容易地维护对象状态的历史。

实施

在维斯特洛,我们发现家庭成员经常非常相似;正如谚语所说:“有其父必有其子”。随着每一代的诞生,通过复制和修改现有家庭成员来创建新一代比从头开始建造要容易得多。

在第二章中,组织代码,我们看了如何复制现有对象,并介绍了一个非常简单的克隆代码:

function clone(source, destination) {
  for(var attr in source.prototype){
    destination.prototype[attr] = source.prototype[attr];}
}

这段代码可以很容易地改变,以便在类内部使用,返回自身的副本:

var Westeros;
(function (Westeros) {
  (function (Families) {
    var Lannister = (function () {
      function Lannister() {
      }
      **Lannister.prototype.clone = function () {** 

 **var clone = new Lannister();** 

 **for (var attr in this) {** 

 **clone[attr] = this[attr];** 

 **}** 

 **return clone;** 

 **};** 

      return Lannister;
    })();
    Families.Lannister = Lannister;
  })(Westeros.Families || (Westeros.Families = {}));
  var Families = Westeros.Families;
})(Westeros || (Westeros = {}));

代码的突出部分是修改后的克隆方法。它可以这样使用:

let jamie = new Westeros.Families.Lannister();
jamie.swordSkills = 9;
jamie.charm = 6;
jamie.wealth = 10;

let tyrion = jamie.clone();
tyrion.charm = 10;
//tyrion.wealth == 10
//tyrion.swordSkill == 9

原型模式允许只构造一次复杂对象,然后克隆成任意数量的仅略有不同的对象。如果源对象不复杂,那么采用克隆方法就没有太多好处。在使用原型方法时,必须注意依赖对象。克隆是否应该是深层的?

原型显然是一个有用的模式,也是 JavaScript 从一开始就形成的一个组成部分。因此,它肯定是任何规模可观的 JavaScript 应用程序中会看到一些使用的模式。

提示和技巧

创建模式允许在创建对象时实现特定行为。在许多情况下,比如工厂,它们提供了可以放置横切逻辑的扩展点。也就是说,适用于许多不同类型对象的逻辑。如果你想要在整个应用程序中注入日志,那么能够连接到工厂是非常有用的。

尽管这些创建模式非常有用,但不应该经常使用。您的大部分对象实例化仍应该是改进对象的正常方法。虽然当你有了新的工具时,把一切都视为钉子是很诱人的,但事实是每种情况都需要有一个具体的策略。所有这些模式都比简单使用new更复杂,而复杂的代码更容易出现错误。尽量使用new

总结

本章介绍了创建对象的多种不同策略。这些方法提供了对创建对象的典型方法的抽象。抽象工厂提供了构建可互换的工具包或相关对象集合的方法。建造者模式提供了解决参数问题的解决方案。它使得构建大型复杂对象变得更加容易。工厂方法是抽象工厂的有用补充,允许通过静态工厂创建不同的实现。单例是一种提供整个解决方案可用的类的单个副本的模式。这是迄今为止我们所见过的唯一一个在现代软件中存在一些适用性问题的模式。原型模式是 JavaScript 中常用的一种模式,用于基于其他现有对象构建对象。

我们将在下一章继续对经典设计模式进行考察,重点关注结构模式。

第四章:结构模式

在上一章中,我们探讨了多种创建对象的方法,以便优化重用。在本章中,我们将研究结构模式;这些模式关注于通过描述对象可以相互交互的简单方式来简化设计。

再次,我们将限制自己只研究 GoF 书中描述的模式。自 GoF 出版以来,已经确定了许多其他有趣的结构模式,我们将在本书的第二部分中进行研究。

我们将在这里研究的模式有:

  • 适配器

  • 桥接

  • 组合

  • 装饰器

  • 外观

  • 享元

  • 代理

我们将再次讨论多年前描述的模式是否仍然适用于不同的语言和不同的时代。

适配器

有时需要将圆销子放入方孔中。如果你曾经玩过儿童的形状分类玩具,你可能会发现实际上可以把圆销子放入方孔中。孔并没有完全填满,把销子放进去可能会很困难:

适配器

为了改善销子的适配,可以使用适配器。这个适配器完全填满了孔,结果非常完美:

适配器

在软件中经常需要类似的方法。我们可能需要使用一个不完全符合所需接口的类。该类可能缺少方法,或者可能有我们希望隐藏的额外方法。在处理第三方代码时经常会出现这种情况。为了使其符合您代码中所需的接口,可能需要使用适配器。

适配器的类图非常简单,如下所示:

适配器

实现的接口看起来不符合我们在代码中想要的样子。通常的解决方法是简单地重构实现,使其看起来符合我们的要求。然而,有一些可能的原因无法这样做。也许实现存在于我们无法访问的第三方代码中。还有可能实现在应用程序的其他地方使用,接口正好符合我们的要求。

适配器类是一个薄薄的代码片段,实现所需的接口。它通常包装实现类的私有副本,并通过代理调用它。适配器模式经常用于改变代码的抽象级别。让我们来看一个快速的例子。

实施

在 Westeros 的土地上,许多贸易和旅行都是通过船只进行的。乘船旅行不仅比步行或骑马更危险,而且由于风暴和海盗的不断出现,也更加危险。这些船只不是皇家加勒比公司用来在加勒比海周游的那种船只;它们是粗糙的东西,看起来更像是 15 世纪欧洲探险家所驾驶的。

虽然我知道船只存在,但我对它们的工作原理或如何操纵船只几乎一无所知。我想很多人和我一样。如果我们看看 Westeros 的船只接口,它看起来很吓人:

interface Ship{
  SetRudderAngleTo(angle: number);
  SetSailConfiguration(configuration: SailConfiguration);
  SetSailAngle(sailId: number, sailAngle: number);
  GetCurrentBearing(): number;
  GetCurrentSpeedEstimate(): number;
  ShiftCrewWeightTo(weightToShift: number, locationId: number);
}

我真的希望有一个更简单的接口,可以抽象掉所有繁琐的细节。理想情况下是这样的:

interface SimpleShip{
  TurnLeft();
  TurnRight();
  GoForward();
}

这看起来像是我可能会弄清楚的东西,即使我住在离最近的海洋有 1000 公里远的城市。简而言之,我想要的是对船只进行更高级别的抽象。为了将船只转换为 SimpleShip,我们需要一个适配器。

适配器将具有 SimpleShip 的接口,但它将在 Ship 的包装实例上执行操作。代码可能看起来像这样:

let ShipAdapter = (function () {
  function ShipAdapter() {
    this._ship = new Ship();
  }
  ShipAdapter.prototype.TurnLeft = function () {
    this._ship.SetRudderAngleTo(-30);
    this._ship.SetSailAngle(3, 12);
  };
  ShipAdapter.prototype.TurnRight = function () {
    this._ship.SetRudderAngleTo(30);
    this._ship.SetSailAngle(5, -9);
  };
  ShipAdapter.prototype.GoForward = function () {
    //do something else to the _ship
  };
  return ShipAdapter;
})();

实际上,这些功能会更加复杂,但这并不重要,因为我们有一个简单的接口来展示给世界。所呈现的接口也可以设置为限制对基础类型的某些方法的访问。在构建库代码时,适配器可用于隐藏内部方法,只向最终用户呈现所需的有限功能。

使用这种模式,代码可能看起来像这样:

var ship = new ShipAdapter();
ship.GoForward();
ship.TurnLeft();

你可能不想在客户端类的名称中使用适配器,因为它泄露了一些关于底层实现的信息。客户端不应该知道它们正在与适配器交谈。

适配器本身可能会变得非常复杂,以调整一个接口到另一个接口。为了避免创建非常复杂的适配器,必须小心。构建几个适配器是完全可以想象的,一个在另一个之上。如果发现适配器变得太大,那么最好停下来检查适配器是否遵循单一责任原则。也就是说,确保每个类只负责一件事。一个从数据库中查找用户的类不应该包含向这些用户发送电子邮件的功能。这责任太大了。复杂的适配器可以被复合对象替换,这将在本章后面探讨。

从测试的角度来看,适配器可以用来完全包装第三方依赖。在这种情况下,它们提供了一个可以挂接测试的地方。单元测试应该避免测试库,但它们可以确保适配器代理了正确的调用。

适配器是简化代码接口的非常强大的模式。调整接口以更好地满足需求在无数地方都是有用的。这种模式在 JavaScript 中肯定很有用。用 JavaScript 编写的应用程序往往会使用大量的小型库。通过将这些库封装在适配器中,我能够限制我直接与库交互的地方的数量;这意味着可以轻松替换这些库。

适配器模式可以稍微修改,以在许多不同的实现上提供一致的接口。这通常被称为桥接模式。

桥接

桥梁模式将适配器模式提升到一个新的水平。给定一个接口,我们可以构建多个适配器,每个适配器都充当到不同实现的中介。

我遇到的一个很好的例子是,处理两个提供几乎相同功能并且在故障转移配置中使用的不同服务。两个服务都没有提供应用程序所需的确切接口,并且两个服务提供不同的 API。为了简化代码,编写适配器以提供一致的接口。适配器实现一致的接口并提供填充,以便可以一致地调用每个 API。再举一个形状分类器的比喻,我们可以想象我们有各种不同的销子,我们想用它们来填充方形孔。每个适配器填补了缺失的部分,并帮助我们得到一个良好的适配:

Bridge

桥梁是一个非常有用的模式。让我们来看看如何实现它:

Bridge

在前面的图表中显示的适配器位于实现和所需接口之间。它们修改实现以适应所需的接口。

实现

我们已经讨论过,在维斯特洛大陆上,人们信仰多种不同的宗教。每个宗教都有不同的祈祷和献祭方式。在正确的时间进行正确的祈祷有很多复杂性,我们希望避免暴露这种复杂性。相反,我们将编写一系列可以简化祈祷的适配器。

我们需要的第一件事是一些不同的神,我们可以向他们祈祷:

class OldGods {
  prayTo(sacrifice) {
    console.log("We Old Gods hear your prayer");
  }
}
Religion.OldGods = OldGods;
class DrownedGod {
  prayTo(humanSacrifice) {
    console.log("*BUBBLE* GURGLE");
  }
}
Religion.DrownedGod = DrownedGod;
class SevenGods {
  prayTo(prayerPurpose) {
    console.log("Sorry there are a lot of us, it gets confusing here. Did you pray for something?");
  }
}
Religion.SevenGods = SevenGods;

这些类应该看起来很熟悉,因为它们基本上是在上一章中找到的相同类,它们被用作工厂方法的示例。但是,您可能会注意到,每种宗教的prayTo方法的签名略有不同。当构建像这里的伪代码中所示的一致接口时,这可能会成为一个问题:

interface God
{
  prayTo():void;
}

那么让我们插入一些适配器,作为我们拥有的类和我们想要的签名之间的桥梁:

class OldGodsAdapter {
  constructor() {
    this._oldGods = new OldGods();
  }
  prayTo() {
    let sacrifice = new Sacrifice();
    this._oldGods.prayTo(sacrifice);
  }
}
Religion.OldGodsAdapter = OldGodsAdapter;
class DrownedGodAdapter {
  constructor() {
    this._drownedGod = new DrownedGod();
  }
  prayTo() {
    let sacrifice = new HumanSacrifice();
    this._drownedGod.prayTo(sacrifice);
  }
}
Religion.DrownedGodAdapter = DrownedGodAdapter;
class SevenGodsAdapter {
  constructor() {
    this.prayerPurposeProvider = new PrayerPurposeProvider();
    this._sevenGods = new SevenGods();
  }
  prayTo() {
    this._sevenGods.prayTo(this.prayerPurposeProvider.GetPurpose());
  }
}
Religion.SevenGodsAdapter = SevenGodsAdapter;
class PrayerPurposeProvider {
  GetPurpose() { }
  }
Religion.PrayerPurposeProvider = PrayerPurposeProvider;

这些适配器中的每一个都实现了我们想要的God接口,并抽象了处理三种不同接口的复杂性,每种接口对应一个神。

要使用桥梁模式,我们可以编写如下代码:

let god1 = new Religion.SevenGodsAdapter();
let god2 = new Religion.DrownedGodAdapter();
let god3 = new Religion.OldGodsAdapter();

let gods = [god1, god2, god3];
for(let i =0; i<gods.length; i++){
  gods[i].praryTo();
}

这段代码使用桥梁为众神提供一致的接口,以便它们可以被视为平等的。

在这种情况下,我们只是包装了单个神并通过代理方法调用它们。适配器可以包装多个对象,这是另一个有用的地方可以使用适配器。如果需要编排一系列复杂的对象,那么适配器可以承担一些责任,为其他类提供更简单的接口。

你可以想象桥梁模式是多么有用。它可以与上一章介绍的工厂方法模式很好地结合使用。

这种模式在 JavaScript 中仍然非常有用。正如我在本节开始时提到的,它对于以一致的方式处理不同的 API 非常有用。我已经用它来交换不同的第三方组件,比如不同的图形库或电话系统集成点。如果您正在使用 JavaScript 在移动平台上构建应用程序,那么桥梁模式将成为您的好朋友,可以帮助您清晰地分离通用代码和特定于平台的代码。因为 JavaScript 中没有接口,所以桥梁模式比其他语言中的适配器更接近 JavaScript。实际上,它基本上是一样的。

桥梁还可以使测试变得更容易。我们可以实现一个虚拟桥梁,并使用它来确保对桥梁的调用是正确的。

组合

在上一章中,我提到我们希望避免对象之间的紧密耦合。继承是一种非常强的耦合形式,我建议使用组合代替。组合模式是这种情况的一个特例,其中组合被视为可与组件互换。让我们探讨一下组合模式的工作原理。

以下类图包含了构建复合组件的两种不同方式:

Composite

在第一个中,复合组件由各种组件的固定数量构建。第二个组件是由一个不确定长度的集合构建的。在这两种情况下,父组合中包含的组件可以与组合的类型相同。因此,一个组合可以包含其自身类型的实例。

组合模式的关键特征是组件与其子组件的可互换性。因此,如果我们有一个实现了IComponent的组合,那么组合的所有组件也将实现IComponent。这可能最好通过一个例子来说明。

例子

树结构在计算中非常有用。事实证明,分层树可以表示许多事物。树由一系列节点和边组成,是循环的。在二叉树中,每个节点包含左右子节点,直到我们到达称为叶子的终端节点。

虽然维斯特洛的生活很艰难,但也有机会享受宗教节日或婚礼等事物。在这些活动中,通常会大量享用美味食物。这些食物的食谱与您自己的食谱一样。像烤苹果这样的简单菜肴包含一系列成分:

  • 烘焙苹果

  • 蜂蜜

  • 黄油

  • 坚果

这些成分中的每一个都实现了一个我们称之为IIngredient的接口。更复杂的食谱包含更多的成分,但除此之外,更复杂的食谱可能包含复杂的成分,这些成分本身是由其他成分制成的。

在维斯特洛南部,一道受欢迎的菜肴是一种甜点,与我们所说的提拉米苏非常相似。这是一个复杂的食谱,其中包含以下成分:

  • 奶油

  • 蛋糕

  • 打发奶油

  • 咖啡

当然,奶油本身是由以下成分制成的:

  • 牛奶

  • 鸡蛋

  • 香草

奶油是一个复合成分,咖啡和蛋糕也是。

组合对象上的操作通常会通过代理传递给所有包含的对象。

实现

这段代码展示了一个简单的成分,即叶子节点:

class SimpleIngredient {
  constructor(name, calories, ironContent, vitaminCContent) {
    this.name = name;
    this.calories = calories;
    this.ironContent = ironContent;
    this.vitaminCContent = vitaminCContent;
  }
  GetName() {
    return this.name;
  }
  GetCalories() {
    return this.calories;
  }
  GetIronContent() {
    return this.ironContent;
  }
  GetVitaminCContent() {
    return this.vitaminCContent;
  }
}

它可以与具有成分列表的复合成分互换使用:

class CompoundIngredient {
  constructor(name) {
    this.name = name;
    this.ingredients = new Array();
  }
  AddIngredient(ingredient) {
    this.ingredients.push(ingredient);
  }
  GetName() {
    return this.name;
  }
  GetCalories() {
    let total = 0;
    for (let i = 0; i < this.ingredients.length; i++) {
      total += this.ingredients[i].GetCalories();
    }
    return total;
  }
  GetIronContent() {
    let total = 0;
    for (let i = 0; i < this.ingredients.length; i++) {
      total += this.ingredients[i].GetIronContent();
    }
    return total;
  }
  GetVitaminCContent() {
    let total = 0;
    for (let i = 0; i < this.ingredients.length; i++) {
      total += this.ingredients[i].GetVitaminCContent();
    }
    return total;
  }
}

复合成分循环遍历其内部成分,并对每个成分执行相同的操作。当然,由于原型模型,无需定义接口。

要使用这种复合成分,我们可以这样做:

let egg = new SimpleIngredient("Egg", 155, 6, 0);
let milk = new SimpleIngredient("Milk", 42, 0, 0);
let sugar = new SimpleIngredient("Sugar", 387, 0,0);
let rice = new SimpleIngredient("Rice", 370, 8, 0);

let ricePudding = new CompoundIngredient("Rice Pudding");
ricePudding.AddIngredient(egg);
ricePudding.AddIngredient(rice);
ricePudding.AddIngredient(milk);
ricePudding.AddIngredient(sugar);

console.log("A serving of rice pudding contains:");
console.log(ricePudding.GetCalories() + " calories");

当然,这只显示了模式的一部分。我们可以将米布丁用作更复杂食谱的成分:米布丁馅饼(在维斯特洛有一些奇怪的食物)。由于简单和复合版本的成分具有相同的接口,调用者不需要知道两种成分类型之间有任何区别。

组合是 JavaScript 代码中广泛使用的模式,用于处理 HTML 元素,因为它们是树结构。例如,jQuery 库提供了一个通用接口,如果您选择了单个元素或一组元素。当调用函数时,实际上是在所有子元素上调用,例如:

$("a").hide()

这将隐藏页面上的所有链接,而不管调用$("a")实际找到多少元素。组合是 JavaScript 开发中非常有用的模式。

装饰者

装饰器模式用于包装和增强现有类。使用装饰器模式是对现有组件进行子类化的替代方法。子类化通常是一个编译时操作,是一种紧密耦合。这意味着一旦子类化完成,就无法在运行时进行更改。在存在许多可能的子类化可以组合的情况下,子类化的组合数量会激增。让我们看一个例子。

Westeros 骑士所穿的盔甲可以是非常可配置的。盔甲可以以多种不同的风格制作:鳞甲、板甲、锁子甲等等。除了盔甲的风格之外,还有各种不同的面罩、膝盖和肘部关节,当然还有颜色。由板甲和面罩组成的盔甲的行为与带有面罩的锁子甲是不同的。然而,你可以看到,存在大量可能的组合;明显太多的组合无法显式编码。

我们所做的是使用装饰器模式实现不同风格的盔甲。装饰器使用与适配器和桥接模式类似的理论,它包装另一个实例并通过代理调用。然而,装饰器模式通过将要包装的实例传递给它来在运行时执行重定向。通常,装饰器将作为一些方法的简单传递,对于其他方法,它将进行一些修改。这些修改可能仅限于在将调用传递给包装实例之前执行附加操作,也可能会改变传入的参数。装饰器模式的 UML 表示如下图所示:

Decorator

这允许对装饰器修改哪些方法进行非常精细的控制,哪些方法保持为简单的传递。让我们来看一下 JavaScript 中该模式的实现。

实施

在这段代码中,我们有一个基类BasicArmor,然后由ChainMail类进行装饰:

class BasicArmor {
  CalculateDamageFromHit(hit) {
    return hit.Strength * .2;
  }
  GetArmorIntegrity() {
    return 1;
  }
}

class ChainMail {
  constructor(decoratedArmor) {
    this.decoratedArmor = decoratedArmor;
  }
  CalculateDamageFromHit(hit) {
    hit.Strength = hit.Strength * .8;
    return this.decoratedArmor.CalculateDamageFromHit(hit);
  }
  GetArmorIntegrity() {
    return .9 * this.decoratedArmor.GetArmorIntegrity();
  }
}

ChainMail装甲接受符合接口的装甲实例,例如:

export interface IArmor{
  CalculateDamageFromHit(hit: Hit):number;
  GetArmorIntegrity():number;
}

该实例被包装并通过代理调用。GetArmorIntegiry方法修改了基础类的结果,而CalculateDamageFromHit修改了传递给装饰类的参数。这个ChainMail类本身可以被装饰多层装饰器,直到实际为每个方法调用调用了一长串方法。当然,这种行为对外部调用者来说是不可见的。

要使用这个装甲装饰器,请看下面的代码:

let armor = new ChainMail(new Westeros.Armor.BasicArmor());
console.log(armor.CalculateDamageFromHit({Location: "head", Weapon: "Sock filled with pennies", Strength: 12}));

利用 JavaScript 重写类的单个方法来实现这种模式是很诱人的。事实上,在本节的早期草案中,我本打算建议这样做。然而,这样做在语法上很混乱,不是一种常见的做法。编程时最重要的事情之一是要记住代码必须是可维护的,不仅是对你自己,也是对其他人。复杂性会导致混乱,混乱会导致错误。

装饰器模式是一种对继承过于限制的情况非常有价值的模式。这些情况在 JavaScript 中仍然存在,因此该模式仍然有用。

Façade

Façade 模式是适配器模式的一种特殊情况,它在一组类上提供了简化的接口。我在适配器模式的部分提到过这样的情景,但只在SimpleShip类的上下文中。这个想法可以扩展到提供一个抽象,围绕一组类或整个子系统。Façade 模式在 UML 形式上看起来像下面的图表:

Façade

实施

如果我们将之前的SimpleShip扩展为整个舰队,我们就有了一个创建外观的绝佳示例。如果操纵一艘单独的船很困难,那么指挥整个舰队将更加困难。需要大量微妙的操作,必须对单独的船只下达命令。除了单独的船只外,还必须有一位舰队上将,并且需要在船只之间协调以分发补给。所有这些都可以被抽象化。如果我们有一系列代表舰队方面的类,比如这些:

let Ship = (function () {
  function Ship() {
  }
  Ship.prototype.TurnLeft = function () {
  };
  Ship.prototype.TurnRight = function () {
  };
  Ship.prototype.GoForward = function () {
  };
  return Ship;
})();
Transportation.Ship = Ship;

let Admiral = (function () {
  function Admiral() {
  }
  return Admiral;
})();
Transportation.Admiral = Admiral;

let SupplyCoordinator = (function () {
  function SupplyCoordinator() {
  }
  return SupplyCoordinator;
})();
Transportation.SupplyCoordinator = SupplyCoordinator;

那么我们可以构建一个外观,如下所示:

let Fleet = (function () {
   function Fleet() {
  }
  Fleet.prototype.setDestination = function (destination) {
    **//pass commands to a series of ships, admirals and whoever else needs it** 

  };

  Fleet.prototype.resupply = function () {
  };

  Fleet.prototype.attack = function (destination) {
    **//attack a city** 

  };
  return Fleet;
})();

外观在处理 API 时非常有用。在细粒度 API 周围使用外观可以创建一个更简单的接口。API 的抽象级别可以提高,使其更符合应用程序的工作方式。例如,如果您正在与 Azure blob 存储 API 交互,您可以将抽象级别从处理单个文件提高到处理文件集。而不是编写以下内容:

$.ajax({method: "PUT",
url: "https://settings.blob.core.windows.net/container/set1",
data: "setting data 1"});

$.ajax({method: "PUT",
url: "https://settings.blob.core.windows.net/container/set2",
data: "setting data 2"});

$.ajax({method: "PUT",
url: "https://settings.blob.core.windows.net/container/set3",
data: "setting data 3"});

可以编写一个外观,封装所有这些调用并提供一个接口,如下所示:

public interface SettingSaver{
  Save(settings: Settings); //preceding code in this method
  Retrieve():Settings;
}

如您所见,外观在 JavaScript 中仍然很有用,并且应该是您工具箱中保留的模式。

蝇量级

拳击中有一个 49-52 公斤之间的轻量级级别,被称为蝇量级。这是最后一个建立的级别之一,我想它之所以被命名为蝇量级,是因为其中的拳击手很小,就像苍蝇一样。

蝇量级模式用于对象实例非常多,而这些实例之间只有轻微差异的情况。在这种情况下,大量通常指的是大约 10,000 个对象,而不是 50 个对象。然而,实例数量的截止点高度依赖于创建对象的成本。

在某些情况下,对象可能非常昂贵,系统在超载之前只需要少数对象。在这种情况下,引入蝇量级在较小数量上将是有益的。为每个对象维护一个完整的对象会消耗大量内存。似乎大部分内存也被浪费地消耗掉了,因为大多数实例的字段具有相同的值。蝇量级提供了一种通过仅跟踪与每个实例中的某个原型不同的值来压缩这些数据的方法。

JavaScript 的原型模型非常适合这种情况。我们可以简单地将最常见的值分配给原型,并在需要时覆盖各个实例。让我们看一个例子。

实施

再次回到维斯特洛(你是否为我选择了一个单一的主要问题领域感到高兴?),我们发现军队中充满了装备不足的战斗人员。在这些人中,从将军的角度来看,实际上没有太大的区别。当然,每个人都有自己的生活、抱负和梦想,但在将军眼中,他们都已经成为简单的战斗自动机。将军只关心士兵们打得多好,他们是否健康,是否吃饱。我们可以在这段代码中看到简单的字段集:

let Soldier = (function () {
  function Soldier() {
    this.Health = 10;
    this.FightingAbility = 5;
    this.Hunger = 0;
  }
  return Soldier;
})();

当然,对于一支由 10,000 名士兵组成的军队,跟踪所有这些需要相当多的内存。让我们采用另一种方法并使用一个类:

class Soldier {
  constructor() {
    this.Health = 10;
    this.FightingAbility = 5;
    this.Hunger = 0;
  }
}

使用这种方法,我们可以将对士兵健康的所有请求推迟到原型。设置值也很容易:

let soldier1 = new Soldier();
let soldier2 = new Soldier();
console.log(soldier1.Health); //10
soldier1.Health = 7;
console.log(soldier1.Health); //7
console.log(soldier2.Health); //10
delete soldier1.Health;
console.log(soldier1.Health); //10

您会注意到我们调用删除来删除属性覆盖,并将值返回到父值。

代理

本章介绍的最后一个模式是代理。在前一节中,我提到创建对象是昂贵的,我们希望避免创建过多的对象。代理模式提供了一种控制昂贵对象的创建和使用的方法。代理模式的 UML 如下图所示:

代理

正如你所看到的,代理模式反映了实际实例的接口。它被替换为所有客户端中的实例,并且通常包装类的私有实例。代理模式可以在许多地方发挥作用:

  • 昂贵对象的延迟实例化

  • 保护秘密数据

  • 远程方法调用的存根

  • 在方法调用之前或之后插入额外的操作

通常一个对象实例化是昂贵的,我们不希望在实际使用之前就创建实例。在这种情况下,代理可以检查它的内部实例,并且如果尚未初始化,则在传递方法调用之前创建它。这被称为延迟实例化。

如果一个类在设计时没有考虑到安全性,但现在需要一些安全性,可以通过使用代理来提供。代理将检查调用,并且只有在安全检查通过的情况下才会传递方法调用。

代理可以简单地提供一个接口,用于调用其他地方调用的方法。事实上,这正是许多网络套接字库的功能,将调用代理回到 Web 服务器。

最后,可能有些情况下,将一些功能插入到方法调用中是有用的。这可能是参数日志记录,参数验证,结果更改,或者其他任何事情。

实现

让我们看一个需要方法拦截的 Westeros 示例。通常情况下,液体的计量单位在这片土地的一边和另一边差异很大。在北方,人们可能会买一品脱啤酒,而在南方,人们会用龙来购买。这导致了很多混乱和代码重复,但可以通过包装关心计量的类来解决。

例如,这段代码是用于估算运输液体所需的桶数的桶计算器:

class BarrelCalculator {
  calculateNumberNeeded(volume) {
    return Math.ceil(volume / 157);
  }
}

尽管它没有很好的文档记录,但这个版本以品脱作为体积参数。创建一个代理来处理转换:

class DragonBarrelCalculator {
  calculateNumberNeeded(volume) {
    if (this._barrelCalculator == null)
      this._barrelCalculator = new BarrelCalculator();
    return this._barrelCalculator.calculateNumberNeeded(volume * .77);
  }
}

同样,我们可能为基于品脱的桶计算器创建另一个代理:

class PintBarrelCalculator {
  calculateNumberNeeded(volume) {
    if (this._barrelCalculator == null)
      this._barrelCalculator = new BarrelCalculator();
    return this._barrelCalculator.calculateNumberNeeded(volume * 1.2);
  }
}

这个代理类为我们做了单位转换,并帮助减轻了一些关于单位的混乱。一些语言,比如 F#,支持单位的概念。实际上,它是一种类型系统,覆盖在简单的数据类型上,如整数,防止程序员犯错,比如将表示品脱的数字加到表示升的数字上。在 JavaScript 中,没有这样的能力。然而,使用 JS-Quantities(gentooboontoo.github.io/js-quantities/)这样的库是一个选择。如果你看一下,你会发现语法非常痛苦。这是因为 JavaScript 不允许操作符重载。看到像将一个空数组添加到另一个空数组一样奇怪的事情(结果是一个空字符串),也许我们可以感谢 JavaScript 不支持操作符重载。

如果我们想要防止在有品脱而认为有龙时意外使用错误类型的计算器,那么我们可以停止使用原始类型,并为数量使用一种类型,一种类似于贫穷人的计量单位:

class PintUnit {
  constructor(unit, quantity) {
    this.quanity = quantity;
  }
}

这可以作为代理中的一个保护使用:

class PintBarrelCalculator {
  calculateNumberNeeded(volume) {
    if(PintUnit.prototype == Object.getPrototypeOf(volume))
      //throw some sort of error or compensate
    if (this._barrelCalculator == null)
      this._barrelCalculator = new BarrelCalculator();
    return this._barrelCalculator.calculateNumberNeeded(volume * 1.2);
  }
}

正如你所看到的,我们最终得到了基本上与 JS-Quantities 相同的东西,但是以更 ES6 的形式。

代理模式在 JavaScript 中绝对是一个有用的模式。我已经提到它在生成存根时被 Web 套接字库使用,但它在无数其他位置也很有用。

提示和技巧

本章介绍的许多模式提供了抽象功能和塑造接口的方法。请记住,每一层抽象都会引入成本。函数调用会变慢,但对于需要理解您的代码的人来说,这也更加令人困惑。工具可以帮助一点,但跟踪一个函数调用穿过九层抽象从来都不是一件有趣的事情。

同时要小心在外观模式中做得太多。很容易将外观转化为一个完全成熟的管理类,这很容易变成一个负责协调和执行一切的上帝对象。

总结

在本章中,我们已经看了一些用于构造对象之间交互的模式。它们中的一些模式相互之间相当相似,但它们在 JavaScript 中都很有用,尽管桥接模式实际上被简化为适配器。在下一章中,我们将通过查看行为模式来完成对原始 GoF 模式的考察。

第五章:行为模式

在上一章中,我们看了描述对象如何构建以便简化交互的结构模式。

在本章中,我们将看一下 GoF 模式的最后一个,也是最大的分组:行为模式。这些模式提供了关于对象如何共享数据或者从不同的角度来看,数据如何在对象之间流动的指导。

我们将要看的模式如下:

  • 责任链

  • 命令

  • 解释器

  • 迭代器

  • 中介者

  • 备忘录

  • 观察者

  • 状态

  • 策略

  • 模板方法

  • 访问者

再次,有许多最近确定的模式可能很好地被归类为行为模式。我们将推迟到以后的章节再来看这些模式,而是继续使用 GoF 模式。

责任链

我们可以将对象上的函数调用看作是向该对象发送消息。事实上,这种消息传递的思维方式可以追溯到 Smalltalk 的时代。责任链模式描述了一种方法,其中消息从一个类传递到另一个类。一个类可以对消息进行操作,也可以将其传递给链中的下一个成员。根据实现,可以对消息传递应用一些不同的规则。在某些情况下,只允许链中的第一个匹配链接对消息进行操作。在其他情况下,每个匹配的链接都对消息进行操作。有时允许链接停止处理,甚至在消息继续传递下去时改变消息:

责任链

让我们看看我们常用的例子中是否能找到这种模式的一个很好的例子:维斯特洛大陆。

实现

在维斯特洛大陆,法律制度几乎不存在。当然有法律,甚至有执行它们的城市警卫,但司法系统很少。这片土地的法律实际上是由国王和他的顾问决定的。有时间和金钱的人可以向国王请愿,国王会听取他们的投诉并作出裁决。这个裁决就是法律。当然,任何整天听农民的投诉的国王都会发疯。因此,许多案件在传到国王耳朵之前就被他的顾问们抓住并解决了。

为了在代码中表示这一点,我们需要首先考虑责任链将如何工作。投诉进来,从能够解决问题的最低可能的人开始。如果那个人不能或不愿解决问题,它就会上升到统治阶级的更高级成员。最终问题会达到国王,他是争端的最终仲裁者。我们可以把他看作是默认的争端解决者,当一切都失败时才会被召唤。责任链在下图中可见:

实施

我们将从一个描述可能听取投诉的接口开始:

export interface ComplaintListener{
  IsAbleToResolveComplaint(complaint: Complaint): boolean;
  ListenToComplaint(complaint: Complaint): string;
}

接口需要两个方法。第一个是一个简单的检查,看看类是否能够解决给定的投诉。第二个是听取和解决投诉。接下来,我们需要描述什么构成了投诉:

var Complaint = (function () {
  function Complaint() {
    this.ComplainingParty = "";
    this.ComplaintAbout = "";
    this.Complaint = "";
  }
  return Complaint;
})();

接下来,我们需要一些不同的类来实现ComplaintListener,并且能够解决投诉:

class ClerkOfTheCourt {
  IsInterestedInComplaint(complaint) {
    //decide if this is a complaint which can be solved by the clerk
    if(isInterested())
      return true;
    return false;
  }
  ListenToComplaint(complaint) {
    //perform some operation
    //return solution to the complaint
    return "";
  }
}
JudicialSystem.ClerkOfTheCourt = ClerkOfTheCourt;
class King {
  IsInterestedInComplaint(complaint) {
    return true;//king is the final member in the chain so must return true
  }
  ListenToComplaint(complaint) {
    //perform some operation
    //return solution to the complaint
    return "";
  }
}
JudicialSystem.King = King;

这些类中的每一个都实现了解决投诉的不同方法。我们需要将它们链接在一起,确保国王处于默认位置。这可以在这段代码中看到:

class ComplaintResolver {
  constructor() {
    this.complaintListeners = new Array();
     this.complaintListeners.push(new ClerkOfTheCourt());
     this.complaintListeners.push(new King());
  }
  ResolveComplaint(complaint) {
    for (var i = 0; i < this.complaintListeners.length; i++) {
      if         (this.complaintListeners[i].IsInterestedInComplaint(complaint)) {
        return this.complaintListeners[i].ListenToComplaint(complaint);
      }
    }
  }
}

这段代码将逐个遍历每个监听器,直到找到一个对听取投诉感兴趣的监听器。在这个版本中,结果会立即返回,停止任何进一步的处理。这种模式有多种变体,其中多个监听器可以触发,甚至允许监听器改变下一个监听器的参数。以下图表显示了多个配置的监听器:

实施

责任链在 JavaScript 中是一个非常有用的模式。在基于浏览器的 JavaScript 中,触发的事件会经过一条责任链。例如,您可以将多个监听器附加到链接的单击事件上,每个监听器都会触发,最后是默认的导航监听器。很可能您在大部分代码中都在使用责任链,甚至自己都不知道。

命令

命令模式是一种封装方法参数、当前对象状态以及要调用的方法的方法。实际上,命令模式将调用方法所需的一切打包到一个很好的包中,可以在以后的某个日期调用。使用这种方法,可以发出命令,并等到以后再决定哪段代码将执行该命令。然后可以将此包排队或甚至序列化以供以后执行。具有单一的命令执行点还允许轻松添加功能,如撤消或命令记录。

这种模式可能有点难以想象,所以让我们把它分解成其组成部分:

命令

命令消息

命令模式的第一个组件是,可预测地,命令本身。正如我提到的,命令封装了调用方法所需的一切。这包括方法名、参数和任何全局状态。可以想象,在每个命令中跟踪全局状态是非常困难的。如果全局状态在命令创建后发生变化会发生什么?这个困境是使用全局状态的另一个原因,它是有问题的,应该避免使用。

设置命令有几种选择。在简单的一端,只需要跟踪一个函数和一组参数。因为 JavaScript 中函数是一等对象,它们可以很容易地保存到对象中。我们还可以将函数的参数保存到一个简单的数组中。让我们使用这种非常简单的方法构建一个命令。

命令的延迟性质在维斯特洛大陆中有一个明显的隐喻。在维斯特洛大陆中没有快速通信的方法。最好的方法是将小消息附加到鸟上并释放它们。这些鸟倾向于想要返回自己的家,因此每个领主在自己的家中饲养一些鸟,当它们成年时,将它们发送给其他可能希望与他们交流的领主。领主们保留一群鸟并记录哪只鸟将飞往哪个其他领主。维斯特洛国王通过这种方法向他忠诚的领主发送了许多命令。

国王发送的命令包含了领主所需的所有指令。命令可能是像带领你的部队这样的东西,而该命令的参数可能是部队的数量、位置和命令必须执行的日期。

在 JavaScript 中,最简单的表示方法是通过数组:

var simpleCommand = new Array();
simpleCommand.push(new LordInstructions().BringTroops);
simpleCommand.push("King's Landing");
simpleCommand.push(500);
simpleCommand.push(new Date());

这个数组可以随意传递和调用。要调用它,可以使用一个通用函数:

simpleCommand0;

如您所见,这个函数只适用于具有三个参数的命令。当然,您可以将其扩展到任意数量:

simpleCommand0;

附加参数是未定义的,但函数不使用它们,因此没有任何不良影响。当然,这绝不是一个优雅的解决方案。

为每种类型的命令构建一个类是可取的。这样可以确保正确的参数已被提供,并且可以轻松区分集合中的不同类型的命令。通常,命令使用祈使句命名,因为它们是指令。例如,BringTroops、Surrender、SendSupplies 等。

让我们将我们丑陋的简单命令转换成一个合适的类:

class BringTroopsCommand {
  constructor(location, numberOfTroops, when) {
    this._location = location;
    this._numberOfTroops = numberOfTroops;
    this._when = when;
  }
  Execute() {
    var receiver = new LordInstructions();
    receiver.BringTroops(this._location, this._numberOfTroops, this._when);
  }
}

我们可能希望实现一些逻辑来确保传递给构造函数的参数是正确的。这将确保命令在创建时失败,而不是在执行时失败。在执行期间可能会延迟,甚至可能延迟几天。验证可能不完美,但即使它只能捕捉到一小部分错误,也是有帮助的。

正如前面提到的,这些命令可以保存在内存中以供以后使用,甚至可以写入磁盘。

调用者

调用者是命令模式的一部分,指示命令执行其指令。调用者实际上可以是任何东西:定时事件,用户交互,或者只是流程中的下一步都可能触发调用。在前面的部分中执行simpleCommand命令时,我们在扮演调用者的角色。在更严格的命令中,调用者可能看起来像下面这样:

command.Execute()

如您所见,调用命令非常容易。命令可以立即调用,也可以在以后的某个时间调用。一种流行的方法是将命令的执行推迟到事件循环的末尾。这可以在节点中完成:

process.nextTick(function(){command.Execute();});

函数process.nextTick将命令的执行推迟到事件循环的末尾,以便在下次进程没有事情可做时执行。

接收者

命令模式中的最后一个组件是接收者。这是命令执行的目标。在我们的例子中,我们创建了一个名为LordInstructions的接收者:

class LordInstructions {
  BringTroops(location, numberOfTroops, when) {
    console.log(`You have been instructed to bring ${numberOfTroops} troops to ${location} by ${when}`);
  }
}

接收者知道如何执行命令推迟的操作。实际上,接收者可能是任何类,而不必有任何特殊之处。

这些组件共同构成了命令模式。客户端将生成一个命令,将其传递给一个调用者,该调用者可以延迟命令的执行或立即执行,然后命令将作用于接收者。

在构建撤销堆栈的情况下,命令是特殊的,因为它们既有Execute方法,也有Undo方法。一个将应用程序状态推进,另一个将其推回。要执行撤销,只需从撤销堆栈中弹出命令,执行Undo函数,并将其推到重做堆栈上。对于重做,从重做中弹出,执行Execute,并推到撤销堆栈上。就是这么简单,尽管必须确保所有状态变化都是通过命令执行的。

《设计模式》一书概述了命令模式的一组稍微复杂的玩家。这在很大程度上是由于我们在 JavaScript 中避免了接口的依赖。由于 JavaScript 中的原型继承模型,该模式变得简单得多。

命令模式是一个非常有用的模式,用于推迟执行某段代码。我们将在《第十章 消息模式》中实际探讨命令模式和一些有用的伴生模式。

解释器

解释器模式是一种有趣的模式,因为它允许你创建自己的语言。这可能听起来有点疯狂,我们已经在写 JavaScript 了,为什么还要创建一个新的语言?自《设计模式》一书以来,领域特定语言(DSL)已经有了一些复兴。有些情况下,创建一个特定于某一需求的语言是非常有用的。例如,结构化查询语言(SQL)非常擅长描述对关系数据库的查询。同样,正则表达式已被证明在解析和操作文本方面非常有效。

有许多情况下,能够创建一个简单的语言是有用的。这才是关键:一个简单的语言。一旦语言变得更加复杂,优势很快就会因为创建实际上是一个编译器的困难而丧失。

这种模式与我们到目前为止看到的模式不同,因为它没有真正由模式定义的类结构。你可以按照自己的意愿设计你的语言解释器。

示例

对于我们的示例,让我们定义一种语言,用于描述维斯特洛大陆上的历史战斗。这种语言必须简单易懂,便于文职人员编写。我们将从创建一个简单的语法开始:

(aggressor -> battle ground <- defender) -> victor

在这里,你可以看到我们只是写出了一个相当不错的语法,让人们描述战斗。罗伯特·拜拉席恩和雷加·坦格利安在三叉戟河之间的战斗将如下所示:

(Robert Baratheon -> River Trident <- RhaegarTargaryen) -> Robert Baratheon

使用这种语法,我们希望构建一些能够查询战斗列表的代码。为了做到这一点,我们将依赖于正则表达式。对于大多数语言来说,这不是一个好的方法,因为语法太复杂。在这种情况下,人们可能希望创建一个词法分析器和一个解析器,并构建语法树,然而,到了那个时候,你可能会希望重新审视一下是否创建 DSL 真的是一个好主意。对于我们的语言,语法非常简单,所以我们可以使用正则表达式。

实现

我们首先为战斗建立一个 JavaScript 数据模型,如下所示:

class Battle {
  constructor(battleGround, agressor, defender, victor) {
    this.battleGround = battleGround;
    this.agressor = agressor;
    this.defender = defender;
    this.victor = victor;
  }
}

接下来我们需要一个解析器:

class Parser {
  constructor(battleText) {
    this.battleText = battleText;
    this.currentIndex = 0;
    this.battleList = battleText.split("\n");
  }
  nextBattle() {
   if (!this.battleList[0])
     return null;
    var segments = this.battleList[0].match(/\((.+?)\s?->\s?(.+?)\s?<-\s?(.+?)\s?->\s?(.+)/);
    return new Battle(segments[2], segments[1], segments[3], segments[4]);
  }
}

最好不要太在意那个正则表达式。然而,这个类确实接受一系列战斗(每行一个),并使用next Battle,允许解析它们。要使用这个类,我们只需要做以下操作:

var text = "(Robert Baratheon -> River Trident <- RhaegarTargaryen) -> Robert Baratheon";
var p = new Parser(text);
p.nextBattle()

这将是输出:

{
  battleGround: 'River Trident',
  agressor: 'Robert Baratheon',
  defender: 'RhaegarTargaryen)',
  victor: 'Robert Baratheon'
}

现在可以像查询 JavaScript 中的任何其他结构一样查询这个数据结构了。

正如我之前提到的,实现这种模式没有固定的方式,因此在前面的代码中所做的实现只是提供了一个例子。你的实现很可能会看起来非常不同,这也是可以的。

解释器在 JavaScript 中可能是一个有用的模式。然而,在大多数情况下,这是一个相当少用的模式。JavaScript 中解释的最佳示例是编译为 CSS 的语言。

迭代器

遍历对象集合是一个非常常见的问题。以至于许多语言都提供了专门的构造来遍历集合。例如,C#有foreach循环,Python 有for x in。这些循环构造经常建立在迭代器之上。迭代器是一种模式,提供了一种简单的方法,按顺序选择集合中的下一个项目。

迭代器的接口如下:

interface Iterator{
  next();
}

实现

在维斯特洛大陆,有一个众所周知的人们排队等候王位的序列,以备国王不幸去世的情况。我们可以在这个集合上设置一个方便的迭代器,如果统治者去世,只需简单地调用next

class KingSuccession {
  constructor(inLineForThrone) {
    this.inLineForThrone = inLineForThrone;
    this.pointer = 0;
  }
  next() {
    return this.inLineForThrone[this.pointer++];
  }
}

这是用一个数组初始化的,然后我们可以调用它:

var king = new KingSuccession(["Robert Baratheon" ,"JofferyBaratheon", "TommenBaratheon"]);
king.next() //'Robert Baratheon'
king.next() //'JofferyBaratheon'
king.next() //'TommenBaratheon'

迭代器的一个有趣的应用是不仅仅迭代一个固定的集合。例如,迭代器可以用来生成无限集合的顺序成员,比如斐波那契序列:

class FibonacciIterator {
  constructor() {
    this.previous = 1;
    this.beforePrevious = 1;
  }
  next() {
    var current = this.previous + this.beforePrevious;
    this.beforePrevious = this.previous;
    this.previous = current;
    return current;
  }
}

这样使用:

var fib = new FibonacciIterator()
fib.next() //2
fib.next() //3
fib.next() //5
fib.next() //8
fib.next() //13
fib.next() //21

迭代器是方便的构造,允许探索不仅仅是数组,而且是任何集合,甚至是任何生成的列表。有很多地方可以大量使用这个。

ECMAScript 2015 迭代器

迭代器是如此有用,以至于它们实际上是 JavaScript 下一代的一部分。ECMAScript 2015 中使用的迭代器模式是一个返回包含donevalue的对象的单个方法。当迭代器在集合的末尾时,donetrue。ECMAScript 2015 迭代器的好处是 JavaScript 中的数组集合将支持迭代器。这开辟了一种新的语法,可以在很大程度上取代for循环:

var kings = new KingSuccession(["Robert Baratheon" ,"JofferyBaratheon", "TommenBaratheon"]);
for(var king of kings){
  //act on members of kings
}

迭代器是 JavaScript 长期以来一直缺少的一种语法上的美好。ECMAScript-2015 的另一个很棒的特性是生成器。这实际上是一个内置的迭代器工厂。我们的斐波那契序列可以重写如下:

function* FibonacciGenerator (){
  var previous = 1;
  var beforePrevious = 1;
  while(true){
    var current = previous + beforePrevious;
    beforePrevious = previous;
    previous = current;
    yield current;
  }
}

这样使用:

var fib = new FibonacciGenerator()
fib.next().value //2
fib.next().value //3
fib.next().value //5
fib.next().value //8
fib.next().value //13
fib.next().value //21

中介者

在类中管理多对多关系可能是一个复杂的前景。让我们考虑一个包含多个控件的表单,每个控件都想在执行操作之前知道页面上的其他控件是否有效。不幸的是,让每个控件都知道其他控件会创建一个维护噩梦。每次添加一个新控件,都需要修改每个其他控件。

中介者将坐在各种组件之间,并作为一个单一的地方,可以进行消息路由的更改。通过这样做,中介者简化了维护代码所需的复杂工作。在表单控件的情况下,中介者很可能是表单本身。中介者的作用很像现实生活中的中介者,澄清和路由各方之间的信息交流:

中介者

实现

在维斯特洛大陆,经常需要中介者。中介者经常会死去,但我相信这不会发生在我们的例子中。

在维斯特洛大陆有许多伟大的家族拥有大城堡和广阔的土地。次要领主们向大家族宣誓效忠,形成联盟,经常通过婚姻得到支持。

在协调各家族的时候,大领主将充当中介者,来回传递信息并解决他们之间可能发生的任何争端。

在这个例子中,我们将大大简化各家之间的通信,并说所有消息都通过大领主传递。在这种情况下,我们将使用史塔克家作为我们的大领主。他们有许多其他家族与他们交谈。每个家族看起来大致如下:

class Karstark {
  constructor(greatLord) {
    this.greatLord = greatLord;
  }
  receiveMessage(message) {
  }
  sendMessage(message) {
    this.greatLord.routeMessage(message);
  }
}

它们有两个函数,一个接收来自第三方的消息,一个发送消息给他们的大领主,这是在实例化时设置的。HouseStark类如下所示:

class HouseStark {
  constructor() {
    this.karstark = new Karstark(this);
    this.bolton = new Bolton(this);
    this.frey = new Frey(this);
    this.umber = new Umber(this);
  }
  routeMessage(message) {
  }
}

通过HouseStark类传递所有消息,其他各个家族不需要关心它们的消息是如何路由的。这个责任被交给了HouseStark,它充当了中介。

中介者最适合用于通信既复杂又明确定义的情况。如果通信不复杂,那么中介者会增加额外的复杂性。如果通信不明确定义,那么在一个地方对通信规则进行编码就变得困难。

在 JavaScript 中,简化多对多对象之间的通信肯定是有用的。我实际上认为在许多方面,jQuery 充当了中介者。在页面上操作一组项目时,它通过抽象掉代码需要准确知道页面上哪些对象正在被更改来简化通信。例如:

$(".error").slideToggle();

jQuery 是切换页面上所有具有error类的元素的可见性的简写吗?

备忘录

在命令模式的部分,我们简要讨论了撤销操作的能力。创建可逆命令并非总是可能的。对于许多操作,没有明显的逆向操作可以恢复原始状态。例如,想象一下对一个数字进行平方的代码:

class SquareCommand {
  constructor(numberToSquare) {
    this.numberToSquare = numberToSquare;
  }
  Execute() {
    this.numberToSquare *= this.numberToSquare;
  }
}

给这段代码-9 将得到 81,但给它 9 也将得到 81。没有办法在没有额外信息的情况下撤销这个命令。

备忘录模式提供了一种恢复对象状态到先前状态的方法。备忘录记录了变量先前的值,并提供了恢复它们的功能。为每个命令保留一个备忘录可以轻松恢复不可逆转的命令。

除了撤销堆栈之外,还有许多情况下,具有回滚对象状态的能力是有用的。例如,进行假设分析需要对状态进行一些假设性的更改,然后观察事物如何变化。这些更改通常不是永久性的,因此可以使用备忘录模式进行回滚,或者如果项目是可取的,可以保留下来。备忘录模式的图表可以在这里看到:

备忘录

典型的备忘录实现涉及三个角色:

  • 原始对象:原始对象保存某种状态并提供生成新备忘录的接口。

  • 看护者:这是模式的客户端,它请求获取新备忘录并管理何时进行恢复。

  • 备忘录:这是原始对象保存状态的表示。这可以持久化到存储中以便进行回滚。

将备忘录模式的成员想象成老板和秘书做笔记可能会有所帮助。老板(看护者)向秘书(原始对象)口述备忘录,秘书在记事本(备忘录)上写下笔记。偶尔老板可能会要求秘书划掉他刚刚写的内容。

与备忘录模式相关的看护者的参与可以有所不同。在某些实现中,原始对象在其状态发生变化时会生成一个新的备忘录。这通常被称为写时复制,因为会创建状态的新副本并应用变化。旧版本可以保存到备忘录中。

实施

在维斯特洛大陆上有许多预言者,他们是未来的预言者。他们通过使用魔法来窥视未来,并检查当前的某些变化将如何在未来发挥作用。通常需要进行许多略有不同起始条件的预测。在设置起始条件时,备忘录模式是非常宝贵的。

我们从一个世界状态开始,它提供了某个特定起点的世界状态信息:

class WorldState {
  constructor(numberOfKings, currentKingInKingsLanding, season) {
    this.numberOfKings = numberOfKings;
    this.currentKingInKingsLanding = currentKingInKingsLanding;
    this.season = season;
  }
}

这个WorldState类负责跟踪构成世界的所有条件。每当对起始条件进行更改时,应用程序都会修改它。因为这个世界状态包含了应用程序的所有状态,所以它可以被用作备忘录。我们可以将这个对象序列化并保存到磁盘上,或者发送回某个历史服务器。

接下来我们需要一个类,它提供与备忘录相同的状态,并允许创建和恢复备忘录。在我们的示例中,我们将其称为WorldStateProvider

class WorldStateProvider {
  saveMemento() {
    return new WorldState(this.numberOfKings, this.currentKingInKingsLanding, this.season);
  }
  restoreMemento(memento) {
    this.numberOfKings = memento.numberOfKings;
    this.currentKingInKingsLanding = memento.currentKingInKingsLanding;
    this.season = memento.season;
  }
}

最后,我们需要一个预言者的客户端,我们将称之为Soothsayer

class Soothsayer {
  constructor() {
    this.startingPoints = [];
    this.currentState = new WorldStateProvider();
  }
  setInitialConditions(numberOfKings, currentKingInKingsLanding, season) {
    this.currentState.numberOfKings = numberOfKings;
    this.currentState.currentKingInKingsLanding = currentKingInKingsLanding;
    this.currentState.season = season;
  }
  alterNumberOfKingsAndForetell(numberOfKings) {
    this.startingPoints.push(this.currentState.saveMemento());
    this.currentState.numberOfKings = numberOfKings;
  }
  alterSeasonAndForetell(season) {
    this.startingPoints.push(this.currentState.saveMemento());
    this.currentState.season = season;
  }
  alterCurrentKingInKingsLandingAndForetell(currentKingInKingsLanding) {
    this.startingPoints.push(this.currentState.saveMemento());
    this.currentState.currentKingInKingsLanding = currentKingInKingsLanding;
    //run some sort of prediction
  }
  tryADifferentChange() {
    this.currentState.restoreMemento(this.startingPoints.pop());
  }
}

这个类提供了一些方便的方法,它们改变了世界的状态,然后运行了一个预言。这些方法中的每一个都将先前的状态推入历史数组startingPoints。还有一个方法tryADifferentChange,它撤销了先前的状态更改,准备运行另一个预言。撤销是通过加载存储在数组中的备忘录来执行的。

尽管客户端 JavaScript 应用有很高的血统,但提供撤销功能却非常罕见。我相信这其中有各种原因,但大部分原因可能是人们并不期望有这样的功能。然而,在大多数桌面应用程序中,撤销功能是被期望的。我想,随着客户端应用程序在功能上不断增强,撤销功能将变得更加重要。当这种情况发生时,备忘录模式是实现撤销堆栈的一种绝妙方式。

观察者

观察者模式可能是 JavaScript 世界中使用最多的模式。这种模式特别在现代单页应用程序中使用;它是提供模型视图视图模型MVVM)功能的各种库的重要组成部分。我们将在第七章中详细探讨这些模式,响应式编程

经常有必要知道对象的值何时发生了变化。为了做到这一点,您可以用 getter 和 setter 包装感兴趣的属性:

class GetterSetter {
  GetProperty() {
    return this._property;
  }
  SetProperty(value) {
    this._property = value;
  }
}

setter 函数现在可以增加对其他对值发生变化感兴趣的对象的调用:

SetProperty(value) {
  var temp = this._property;
  this._property = value;
  this._listener.Event(value, temp);
}

现在,这个 setter 将通知监听器属性已发生变化。在这种情况下,旧值和新值都已包括在内。这并不是必要的,因为监听器可以负责跟踪先前的值。

观察者模式概括和规范了这个想法。观察者模式允许感兴趣的各方订阅变化通知,而不是只有一个调用监听器的单个调用。多个订阅者可以在下图中看到:

Observer

实施

维斯特洛的法庭是一个充满阴谋和诡计的地方。控制谁坐在王位上,以及他们的行动,是一个复杂的游戏。权力的游戏中的许多玩家雇佣了许多间谍来发现其他人的行动。这些间谍经常被多个玩家雇佣,并必须向所有玩家报告他们所发现的情况。

间谍是使用观察者模式的理想场所。在我们的特定示例中,被雇佣的间谍是国王的官方医生,玩家们非常关心给这位患病的国王开了多少止痛药。知道这一点可以让玩家提前知道国王可能何时去世 - 这是一个非常有用的信息。

间谍看起来像下面这样:

class Spy {
  constructor() {
    this._partiesToNotify = [];
  }
  Subscribe(subscriber) {
    this._partiesToNotify.push(subscriber);
  }
  Unsubscribe(subscriber) {
    this._partiesToNotify.remove(subscriber);
  }
  SetPainKillers(painKillers) {
    this._painKillers = painKillers;
    for (var i = 0; i < this._partiesToNotify.length; i++) {
      this._partiesToNotifyi;
    }
  }
}

在其他语言中,订阅者通常必须遵守某个接口,观察者只会调用接口方法。这种负担在 JavaScript 中不存在,事实上,我们只给Spy类一个函数。这意味着订阅者不需要严格的接口。这是一个例子:

class Player {
  OnKingPainKillerChange(newPainKillerAmount) {
    //perform some action
  }
}

可以这样使用:

let s = new Spy();
let p = new Player();
s.Subscribe(p.OnKingPainKillerChange); //p is now a subscriber
s.SetPainKillers(12); //s will notify all subscribers

这提供了一种非常简单和高效的构建观察者的方法。订阅者使订阅者与可观察对象解耦。

观察者模式也可以应用于方法和属性。通过这样做,可以提供用于发生附加行为的钩子。这是为 JavaScript 库提供插件基础设施的常见方法。

在浏览器中,DOM 中各种项目上的所有事件监听器都是使用观察者模式实现的。例如,使用流行的 jQuery 库,可以通过以下方式订阅页面上所有按钮的click事件:

$("body").on("click", "button", function(){/*do something*/})

即使在纯 JavaScript 中,相同的模式也适用:

let buttons = document.getElementsByTagName("button");
for(let i =0; i< buttons.length; i++)
{
  buttons[i].onclick = function(){/*do something*/}
}

显然,观察者模式在处理 JavaScript 时非常有用。没有必要以任何重大方式改变模式。

状态

状态机在计算机编程中是一个非常有用的设备。不幸的是,大多数程序员并不经常使用它们。我相信对状态机的一些反对意见至少部分是因为许多人将它们实现为一个巨大的if语句,如下所示:

function (action, amount) {
  if (this.state == "overdrawn" && action == "withdraw") {
    this.state = "on hold";
  }
  if (this.state == "on hold" && action != "deposit") {
    this.state = "on hold";
  }
  if (this.state == "good standing" && action == "withdraw" && amount <= this.balance) {
    this.balance -= amount;
  }
  if (this.state == "good standing" && action == "withdraw" && amount >this.balance) {
    this.balance -= amount;
    this.state = "overdrawn";
  }
};

这只是一个可能更长的示例。这样长的if语句很难调试,而且容易出错。只需翻转一个大于号就足以大大改变if语句的工作方式。

不要使用单个巨大的if语句块,我们可以利用状态模式。状态模式的特点是有一个状态管理器,它抽象了内部状态,并将消息代理到适当的状态,该状态实现为一个类。所有状态内部的逻辑和状态转换的控制都由各个状态类管理。状态管理器模式可以在以下图表中看到:

State

将状态分为每个状态一个类允许更小的代码块进行调试,并且使测试变得更容易。

状态管理器的接口非常简单,通常只提供与各个状态通信所需的方法。管理器还可以包含一些共享状态变量。

实施

正如在if语句示例中所暗示的,维斯特洛有一个银行系统。其中大部分集中在布拉沃斯岛上。那里的银行业务与这里的银行业务基本相同,包括账户、存款和取款。管理银行账户的状态涉及监视所有交易并根据交易改变银行账户的状态。

让我们来看看管理布拉沃斯银行账户所需的一些代码。首先是状态管理器:

class BankAccountManager {
  constructor() {
    this.currentState = new GoodStandingState(this);
  }
  Deposit(amount) {
    this.currentState.Deposit(amount);
  }
  Withdraw(amount) {
    this.currentState.Withdraw(amount);
  }
  addToBalance(amount) {
    this.balance += amount;
  }
  getBalance() {
    return this.balance;
  }
  moveToState(newState) {
    this.currentState = newState;
  }
}

BankAccountManager类提供了当前余额和当前状态的状态。为了保护余额,它提供了一个用于读取余额的辅助工具,另一个用于增加余额。在真实的银行应用程序中,我更希望设置余额的功能比这个更有保护性。在这个BankManager版本中,操作当前状态的能力对状态是可访问的。它们有责任改变状态。这个功能可以集中在管理器中,但这会增加添加新状态的复杂性。

我们已经为银行账户确定了三种简单的状态:OverdrawnOnHoldGoodStanding。每个状态在该状态下负责处理取款和存款。GoodStandingstate类如下所示:

class GoodStandingState {
  constructor(manager) {
    this.manager = manager;
  }
  Deposit(amount) {
    this.manager.addToBalance(amount);
  }
  Withdraw(amount) {
    if (this.manager.getBalance() < amount) {
      this.manager.moveToState(new OverdrawnState(this.manager));
    }
    this.manager.addToBalance(-1 * amount);
  }
}

OverdrawnState类如下所示:

class OverdrawnState {
  constructor(manager) {
    this.manager = manager;
  }
  Deposit(amount) {
    this.manager.addToBalance(amount);
    if (this.manager.getBalance() > 0) {
      this.manager.moveToState(new GoodStandingState(this.manager));
    }
  }
  Withdraw(amount) {
    this.manager.moveToState(new OnHold(this.manager));
    throw "Cannot withdraw money from an already overdrawn bank account";
  }
}

最后,OnHold状态如下所示:

class OnHold {
  constructor(manager) {
    this.manager = manager;
  }
  Deposit(amount) {
    this.manager.addToBalance(amount);
    throw "Your account is on hold and you must attend the bank to resolve the issue";
  }
  Withdraw(amount) {
    throw "Your account is on hold and you must attend the bank to resolve the issue";
  }
}

您可以看到,我们已经成功地将混乱的if语句的所有逻辑重现在一些简单的类中。这里的代码量看起来比if语句要多得多,但从长远来看,将代码封装到单独的类中将会得到回报。

在 JavaScript 中有很多机会可以利用这种模式。跟踪状态是大多数应用程序中的典型问题。当状态之间的转换很复杂时,将其封装在状态模式中是简化事情的一种方法。还可以通过按顺序注册事件来构建简单的工作流程。这样做的一个好接口可能是流畅的,这样你就可以注册以下状态:

goodStandingState
.on("withdraw")
.when(function(manager){return manager.balance > 0;})
  .transitionTo("goodStanding")
.when(function(manager){return mangaer.balance <=0;})
  .transitionTo("overdrawn");

策略

有人说过有很多种方法可以剥猫皮。我明智地从未研究过有多少种方法。在计算机编程中,算法也经常如此。通常有许多版本的算法,它们在内存使用和 CPU 使用之间进行权衡。有时会有不同的方法提供不同级别的保真度。例如,在智能手机上执行地理定位通常使用三种不同的数据来源之一:

  • GPS 芯片

  • 手机三角定位

  • 附近的 WiFi 点

使用 GPS 芯片提供了最高级别的保真度,但也是最慢的,需要最多的电池。查看附近的 WiFi 点需要非常少的能量,速度非常快,但提供的保真度较低。

策略模式提供了一种以透明方式交换这些策略的方法。在传统的继承模型中,每个策略都会实现相同的接口,这将允许任何策略进行交换。下图显示了可以进行交换的多个策略:

策略

选择正确的策略可以通过多种不同的方式来完成。最简单的方法是静态选择策略。这可以通过配置变量或甚至硬编码来完成。这种方法最适合策略变化不频繁或特定于单个客户或用户的情况。

或者可以对要运行策略的数据集进行分析,然后选择合适的策略。如果已知策略 A 在数据传入时比策略 B 更好,那么可以首先运行一个快速的分析传播的算法,然后选择适当的策略。

如果特定算法在某种类型的数据上失败,这也可以在选择策略时考虑进去。在 Web 应用程序中,这可以用于根据数据的形状调用不同的 API。它还可以用于在 API 端点之一宕机时提供备用机制。

另一种有趣的方法是使用渐进增强。首先运行最快且最不准确的算法以提供快速的用户反馈。同时也运行一个较慢的算法,当它完成时,优越的结果将用于替换现有的结果。这种方法经常用于上面概述的 GPS 情况。您可能会注意到,在移动设备上使用地图时,地图加载后一会儿您的位置会更新;这是渐进增强的一个例子。

最后,策略可以完全随机选择。这听起来像是一种奇怪的方法,但在比较两种不同策略的性能时可能会有用。在这种情况下,将收集关于每种方法的表现如何的统计数据,并进行分析以选择最佳策略。策略模式可以成为 A/B 测试的基础。

选择要使用的策略可以是应用工厂模式的绝佳地方。

实施

在维斯特洛大陆,没有飞机、火车或汽车,但仍然有各种不同的旅行方式。人们可以步行、骑马、乘船航行,甚至可以坐船沿河而下。每种方式都有不同的优点和缺点,但最终它们都能把一个人从 A 点带到 B 点。接口可能看起来像下面这样:

export interface ITravelMethod{
  Travel(source: string, destination: string) : TravelResult;
}

旅行结果向调用者传达了一些关于旅行方式的信息。在我们的情况下,我们追踪旅行需要多长时间,风险是什么,以及费用是多少:

class TravelResult {
  constructor(durationInDays, probabilityOfDeath, cost) {
    this.durationInDays = durationInDays;
    this.probabilityOfDeath = probabilityOfDeath;
    this.cost = cost;
  }
}

在这种情况下,我们可能希望有一个额外的方法来预测一些风险,以便自动选择策略。

实现策略就像下面这样简单:

class SeaGoingVessel {
  Travel(source, destination) {
    return new TravelResult(15, .25, 500);
  }
}

class Horse {
  Travel(source, destination) {
    return new TravelResult(30, .25, 50);
  }
}

class Walk {
  Travel(source, destination) {
    return new TravelResult(150, .55, 0);
  }
}

在策略模式的传统实现中,每个策略的方法签名应该相同。在 JavaScript 中,函数的多余参数会被忽略,缺少的参数可以给出默认值,因此有更多的灵活性。

显然,实际实现中风险、成本和持续时间的实际计算不会硬编码。要使用这些方法,只需要做以下操作:

var currentMoney = getCurrentMoney();
var strat;
if (currentMoney> 500)
  strat = new SeaGoingVessel();
else if (currentMoney> 50)
  strat = new Horse();
else
  strat = new Walk();
var travelResult = strat.Travel();

为了提高这种策略的抽象级别,我们可以用更一般的名称替换具体的策略,描述我们要优化的内容:

var currentMoney = getCurrentMoney();
var strat;
if (currentMoney> 500)
  strat = new FavorFastestAndSafestStrategy();
else
  strat = new FavorCheapest();
var travelResult = strat.Travel();

策略模式在 JavaScript 中是一个非常有用的模式。我们能够使这种方法比在不使用原型继承的语言中更简单:不需要接口。我们不需要从不同的策略中返回相同形状的对象。只要调用者有点意识到返回的对象可能有额外的字段,这是一个完全合理的,虽然难以维护的方法。

模板方法

策略模式允许用一个互补的算法替换整个算法。经常替换整个算法是过度的:绝大部分算法在每个策略中仍然保持相同,只有特定部分有轻微的变化。

模板方法模式是一种方法,允许共享算法的一些部分,并使用不同的方法实现其他部分。这些外包部分可以由方法家族中的任何一个方法来实现:

模板方法

模板类实现了算法的部分,并将其他部分留作抽象,以便稍后由扩展它的类来覆盖。继承层次结构可以有几层深,每个级别都实现了模板类的更多部分。

提示

抽象类是包含抽象方法的类。抽象方法只是没有方法体的方法。抽象类不能直接使用,必须由另一个实现抽象方法的类来扩展。抽象类可以扩展另一个抽象类,以便不需要所有方法都由扩展类实现。

这种方法将渐进增强的原则应用到算法中。我们越来越接近一个完全实现的算法,同时建立一个有趣的继承树。模板方法有助于将相同的代码保持在一个位置,同时允许一些偏差。部分实现的链可以在下图中看到:

模板方法

重写留作抽象的方法是面向对象编程的一个典型部分。很可能你经常使用这种模式,甚至没有意识到它有一个名字。

实现

我已经被知情人告知,有许多不同的酿造啤酒的方法。这些啤酒在选择原料和生产方法上有所不同。事实上,啤酒甚至不需要含有啤酒花 - 它可以由任意数量的谷物制成。然而,所有啤酒之间都存在相似之处。它们都是通过发酵过程制作的,所有合格的啤酒都含有一定的酒精含量。

在维斯特洛有许多自豪地制作顶级啤酒的工匠。我们想将他们的工艺描述为一组类,每个类描述一种不同的酿造啤酒的方法。我们从一个简化的酿造啤酒的实现开始:

class BasicBeer {
  Create() {
    this.AddIngredients();
    this.Stir();
    this.Ferment();
    this.Test();
    if (this.TestingPassed()) {
      this.Distribute();
    }
  }
  AddIngredients() {
    throw "Add ingredients needs to be implemented";
  }
  Stir() {
    //stir 15 times with a wooden spoon
  }
  Ferment() {
    //let stand for 30 days
  }
  Test() {
    //draw off a cup of beer and taste it
  }
  TestingPassed() {
    throw "Conditions to pass a test must be implemented";
  }
  Distribute() {
    //place beer in 50L casks
  }
}

由于 JavaScript 中没有抽象的概念,我们已经为必须被覆盖的各种方法添加了异常。剩下的方法可以更改,但不是必须的。树莓啤酒的实现如下所示:

class RaspberryBeer extends BasicBeer {
  AddIngredients() {
    **//add ingredients, probably including raspberries** 

  }
  TestingPassed() {
    **//beer must be reddish and taste of raspberries** 

  }
}

在这个阶段可能会进行更具体的树莓啤酒的子类化。

在 JavaScript 中,模板方法仍然是一个相当有用的模式。在创建类时有一些额外的语法糖,但这并不是我们在之前章节中没有见过的。我唯一要提醒的是,模板方法使用继承,因此将继承类与父类紧密耦合。这通常不是一种理想的状态。

访问者

本节中的最后一个模式是访问者模式。访问者提供了一种将算法与其操作的对象结构解耦的方法。如果我们想对不同类型的对象集合执行某些操作,并且根据对象类型执行不同的操作,通常需要使用大量的if语句。

让我们立刻在维斯特洛进行一个示例。一个军队由几个不同类别的战斗人员组成(重要的是我们要政治正确,因为维斯特洛有许多著名的女战士)。然而,军队的每个成员都实现了一个名为IMemberOfArmy的假设接口:

interface IMemberOfArmy{
  printName();
}

这个的简单实现可能是这样的:

class Knight {
  constructor() {
    this._type = "Westeros.Army.Knight";
  }
  printName() {
    console.log("Knight");
  }
  visit(visitor) {
    visitor.visit(this);
  }
}

现在我们有了这些不同类型的集合,我们可以使用if语句只在骑士上调用printName函数:

var collection = [];
collection.push(new Knight());
collection.push(new FootSoldier());
collection.push(new Lord());
collection.push(new Archer());

for (let i = 0; i<collection.length; i++) {
  if (typeof (collection[i]) == 'Knight')
    collection[i].printName();
  else
    console.log("Not a knight");
}

除非你运行这段代码,你实际上会发现我们得到的只是以下内容:

Not a knight
Not a knight
Not a knight
Not a knight

这是因为,尽管一个对象是骑士,但它仍然是一个对象,typeof在所有情况下都会返回对象。

另一种方法是使用instanceof而不是typeof

var collection = [];
collection.push(new Knight());
collection.push(new FootSoldier());
collection.push(new Lord());
collection.push(new Archer());

for (var i = 0; i < collection.length; i++) {
  if (collection[i] instanceof Knight)
    collection[i].printName();
  else
    console.log("No match");
}

实例方法的方法在遇到使用Object.create语法的人时效果很好:

collection.push(Object.create(Knight));

尽管是骑士,当被问及是否是Knight的实例时,它将返回false

这对我们来说是一个问题。访问者模式使问题变得更加严重,因为它要求语言支持方法重载。JavaScript 实际上并不支持这一点。可以使用各种技巧来使 JavaScript 在某种程度上意识到重载的方法,但通常的建议是根本不要费心,而是创建具有不同名称的方法。

然而,我们还不要放弃这种模式;它是一个有用的模式。我们需要一种可靠地区分一种类型和另一种类型的方法。最简单的方法是在类上定义一个表示其类型的变量:

var Knight = (function () {
  function Knight() {
    this._type = "Knight";
  }
  Knight.prototype.printName = function () {
    console.log("Knight");
  };
  return Knight;
})();

有了新的_type变量,我们现在可以伪造真正的方法覆盖:

var collection = [];
collection.push(new Knight());
collection.push(new FootSoldier());
collection.push(new Lord());
collection.push(new Archer());

for (vari = 0; i<collection.length; i++) {
  if (collection[i]._type == 'Knight')
    collection[i].printName();
  else
    console.log("No match");
}

有了这种方法,我们现在可以实现一个访问者。第一步是扩展我们军队的各种成员,使其具有一个接受访问者并应用它的通用方法:

var Knight = (function () {
  function Knight() {
    this._type = "Knight";
  }
  Knight.prototype.printName = function () {
    console.log("Knight");
  };
  **Knight.prototype.visit = function (visitor) {** 

 **visitor.visit(this);** 

 **};** 

  return Knight;
})();

现在我们需要构建一个访问者。这段代码近似于我们在前面的代码中的if语句:

varSelectiveNamePrinterVisitor = (function () {
  function SelectiveNamePrinterVisitor() {
  }
  SelectiveNamePrinterVisitor.prototype.Visit = function (memberOfArmy) {
    if (memberOfArmy._type == "Knight") {
      this.VisitKnight(memberOfArmy);
    } else {
      console.log("Not a knight");
    }
  };

  SelectiveNamePrinterVisitor.prototype.VisitKnight = function (memberOfArmy) {
    memberOfArmy.printName();
  };
  return SelectiveNamePrinterVisitor;
})();

这个访问者将被用作下面这样:

var collection = [];
collection.push(new Knight());
collection.push(new FootSoldier());
collection.push(new Lord());
collection.push(new Archer());
var visitor = new SelectiveNamePrinterVisitor();
for (vari = 0; i<collection.length; i++) {
  collection[i].visit(visitor);
}

正如您所看到的,我们已经将集合中项目的类型的决定推迟到了访问者。这将项目本身与访问者解耦,如下图所示:

Visitor

如果我们允许访问者决定对访问对象调用哪些方法,那么就需要一些技巧。如果我们可以为访问对象提供一个恒定的接口,那么访问者只需要调用接口方法。然而,这将逻辑从访问者移到被访问的对象中,这与对象不应该知道自己是访问者的一部分的想法相矛盾。

是否值得忍受这种欺诈行为,这实际上是一个练习。就我个人而言,我倾向于避免在 JavaScript 中使用访问者模式,因为使其工作的要求很复杂且不明显。

提示和技巧

以下是一些关于本章中一些模式的简短提示:

  • 在实现解释器模式时,您可能会被诱惑使用 JavaScript 本身作为您的 DSL,然后使用eval函数来执行代码。这实际上是一个非常危险的想法,因为eval会带来整个安全问题的世界。在 JavaScript 中使用eval通常被认为是非常不好的做法。

  • 如果您发现自己需要审计项目中的数据更改,则可以轻松地修改备忘录模式以适应。您不仅可以跟踪状态更改,还可以跟踪更改的时间和更改者。将这些备忘录保存到磁盘的某个地方,可以让您回溯并快速构建指向更改对象的审计日志。

  • 观察者模式因为监听器没有正确注销而导致内存泄漏而臭名昭著。即使在 JavaScript 这样的内存管理环境中,这种情况也可能发生。要警惕未能取消观察者。

总结

在本章中,我们已经看过了一堆行为模式。其中一些模式,比如观察者和迭代器,几乎每天都会用到,而另一些模式,比如解释器,你可能在整个职业生涯中只会用到几次。了解这些模式应该有助于您找到常见问题的明确定义解决方案。

大多数模式都直接适用于 JavaScript,其中一些模式,比如策略模式,在动态语言中变得更加强大。我们发现的唯一有一些限制的模式是访问者模式。缺乏静态类和多态性使得这个模式难以实现,而不破坏适当的关注点分离。

这些并不是存在的所有行为模式。编程社区在过去的二十年里一直在基于 GoF 书中的思想并识别新的模式。本书的其余部分致力于这些新识别的模式。解决方案可能是非常古老的,但直到最近才被普遍认为是常见解决方案。就我而言,这是书开始变得非常有趣的地方,因为我们开始研究不太知名和更具 JavaScript 特色的模式。

第二部分。其他模式

函数式编程

响应式编程

应用程序模式

Web 模式

消息模式

微服务

测试模式

高级模式

ECMAScript-2015/2016 解决方案今天

在第一部分中,我们专注于 GoF 书中最初确定的模式,这些模式是软件设计模式背后的最初动力。在本书的这一部分中,我们将超越这些模式,看看与函数式编程相关的模式,用于构建整个应用程序的大规模模式,专门用于 Web 的模式以及消息模式。此外,我们将研究测试模式和一些非常有趣的高级模式。最后,我们将看看如何在今天就能获得 JavaScript 下一个版本的许多功能。

第六章:函数式编程

函数式编程是一种与我们迄今为止专注的重度面向对象方法不同的开发方法。面向对象编程是解决许多问题的绝佳工具,但也存在一些问题。在面向对象的上下文中进行并行编程是困难的,因为状态可能会被不同的线程改变,产生未知的副作用。函数式编程不允许状态或可变变量。函数在函数式编程中充当主要的构建块。在过去可能使用变量的地方现在将使用函数。

即使在单线程程序中,函数也可能具有改变全局状态的副作用。这意味着,当调用一个未知的函数时,它可能改变程序的整个流程。这使得调试程序变得非常困难。

JavaScript 并不是一种函数式编程语言,但我们仍然可以将一些函数式原则应用到我们的代码中。我们将研究函数式空间中的许多模式:

  • 函数传递

  • 过滤器和管道

  • 累加器

  • 备忘录

  • 不可变性

  • 延迟实例化

函数式函数是无副作用的

函数式编程的核心原则之一是函数不应改变状态。函数内部的局部值可以被设置,但函数外部的任何东西都不可以改变。这种方法对于使代码更易维护非常有用。不再需要担心将数组传递给函数会对其内容造成混乱。特别是在使用不受控制的库时,这是一个问题。

JavaScript 内部没有机制可以阻止您改变全局状态。相反,您必须依赖开发人员编写无副作用的函数。这可能很困难,也可能不是,这取决于团队的成熟度。

也许并不希望将应用程序中的所有代码都放入函数中,但尽可能地分离是可取的。有一种称为命令查询分离的模式建议方法应该分为两类。要么是读取值的函数,要么是设置值的命令。二者不可兼得。保持方法按此分类有助于调试和代码重用。

无副作用函数的一个结果是,它们可以使用相同的输入被调用任意次数,结果都将是相同的。此外,由于没有状态的改变,多次调用函数不会产生任何不良副作用,除了使其运行速度变慢。

函数传递

在函数式编程语言中,函数是一等公民。函数可以赋值给变量并像处理其他变量一样传递。这并不是完全陌生的概念。即使像 C 这样的语言也有可以像其他变量一样处理的函数指针。C#有委托,在更近期的版本中有 lambda。最新版本的 Java 也添加了对 lambda 的支持,因为它们被证明非常有用。

JavaScript 允许将函数视为变量,甚至作为对象和字符串。这样,JavaScript 在本质上是函数式的。

由于 JavaScript 的单线程特性,回调是一种常见的约定,你几乎可以在任何地方找到它们。考虑在网页上的稍后时间调用一个函数。这是通过在 window 对象上设置超时来实现的,就像这样:

setTimeout(function(){alert("Hello from the past")}, 5 * 1000);

设置超时函数的参数是要调用的函数和以毫秒为单位的延迟时间。

无论您在哪种 JavaScript 环境中工作,几乎不可能避免以回调函数的形式使用函数。Node.js 的异步处理模型高度依赖于能够调用函数并传递一些内容以便在以后的某个日期完成。在浏览器中调用外部资源也依赖于回调来通知调用者某些异步操作已完成。在基本的 JavaScript 中,这看起来像这样:

let xmlhttp = new XMLHttpRequest()
xmlhttp.onreadystatechange = function()
if (xmlhttp.readyState==4 && xmlhttp.status==200){
  //process returned data
}
};
xmlhttp.open("GET", http://some.external.resource, true);
xmlhttp.send();

您可能会注意到我们在发送请求之前就分配了onreadystatechange函数。这是因为稍后分配可能会导致服务器在函数附加到准备状态更改之前做出响应的竞争条件。在这种情况下,我们使用内联函数来处理返回的数据。因为函数是一等公民,我们可以将其更改为以下形式:

let xmlhttp;
function requestData(){
  xmlhttp = new XMLHttpRequest()
  xmlhttp.onreadystatechange=processData;
  xmlhttp.open("GET", http://some.external.resource, true);
  xmlhttp.send();
}

function processData(){
  if (xmlhttp.readyState==4 &&xmlhttp.status==200){
    //process returned data
  }
}

这通常是一种更清晰的方法,避免在另一个函数中执行复杂的处理。

但是,您可能更熟悉 jQuery 版本,它看起来像这样:

$.getJSON('http://some.external.resource', function(json){
  //process returned data
});

在这种情况下,处理准备状态变化的模板已经为您处理了。如果请求数据失败,甚至还为您提供了便利:

$.ajax('http://some.external.resource',
  { success: function(json){
      //process returned data
    },
    error: function(){
      //process failure
    },
    dataType: "json"
});

在这种情况下,我们将一个对象传递给ajax调用,该对象定义了许多属性。在这些属性中,成功和失败的函数回调是其中之一。将多个函数传递到另一个函数中的这种方法表明了为类提供扩展点的一种很好的方式。

很可能您以前已经看到过这种模式的使用,甚至没有意识到。将函数作为选项对象的一部分传递给构造函数是 JavaScript 库中提供扩展挂钩的常用方法。在上一章中,第五章,行为模式中,我们看到了对函数的一些处理,当将函数传递给观察者时。

实施

在维斯特洛,旅游业几乎不存在。有很多困难,如强盗杀害游客和游客卷入地区冲突。尽管如此,一些有远见的人已经开始宣传维斯特洛斯的大巡回之旅,他们将带领有能力的人游览所有主要景点。从国王之地到艾利,再到多恩的巨大山脉-这次旅行将覆盖一切。事实上,旅游局中一个相当数学倾向的成员已经开始称其为哈密顿之旅,因为它到达每个地方一次。

HamiltonianTour类提供了一个选项对象,允许定义一个选项对象。该对象包含可以附加回调的各种位置。在我们的情况下,它的接口看起来可能是以下样子:

export class HamiltonianTourOptions{
  onTourStart: Function;
  onEntryToAttraction: Function;
  onExitFromAttraction: Function;
  onTourCompletion: Function;
}

完整的HamiltonianTour类如下所示:

class HamiltonianTour {
  constructor(options) {
    this.options = options;
  }
  StartTour() {
    if (this.options.onTourStart && typeof (this.options.onTourStart) === "function")
      this.options.onTourStart();
      this.VisitAttraction("King's Landing");
      this.VisitAttraction("Winterfell");
      this.VisitAttraction("Mountains of Dorne");
      this.VisitAttraction("Eyrie");
    if (this.options.onTourCompletion && typeof (this.options.onTourCompletion) === "function")
      this.options.onTourCompletion();
  }
  VisitAttraction(AttractionName) {
    if (this.options.onEntryToAttraction && typeof (this.options.onEntryToAttraction) === "function")
      this.options.onEntryToAttraction(AttractionName);
      //do whatever one does in a Attraction
    if (this.options.onExitFromAttraction && typeof (this.options.onExitFromAttraction) === "function")
      this.options.onExitFromAttraction(AttractionName);
  }
}

您可以在突出显示的代码中看到我们如何检查选项,然后根据需要执行回调。只需简单地执行以下操作即可使用:

var tour = new HamiltonianTour({
  onEntryToAttraction: function(cityname){console.log("I'm delighted to be in " + cityname)}});
      tour.StartTour();

运行此代码的输出将如下所示:

I'm delighted to be in King's Landing
I'm delighted to be in Winterfell
I'm delighted to be in Mountains of Dorne
I'm delighted to be in Eyrie

在 JavaScript 中传递函数是解决许多问题的好方法,并且在 jQuery 等库和 express 等框架中被广泛使用。它是如此普遍地被采用,以至于使用它会增加代码的可读性障碍。

过滤器和管道

如果您对 Unix 命令行或者在较小程度上对 Windows 命令行有所了解,那么您可能已经使用过管道。管道由|字符表示,它是“获取程序 A 的输出并将其放入程序 B”的简写。这个相对简单的想法使得 Unix 命令行非常强大。例如,如果您想要列出目录中的所有文件,然后对它们进行排序并过滤出以字母bg开头并以f结尾的文件,那么命令可能如下所示:

ls|sort|grep "^[gb].*f$"

ls命令列出所有文件和目录,sort命令对它们进行排序,grep命令匹配文件名与正则表达式。在 Ubuntu 的/etc目录中运行这个命令会得到类似以下的结果:

 **stimms@ubuntu1:/etc$ ls|sort|grep "^[gb].*f$"** 

blkid.conf
bogofilter.cf
brltty.conf
gai.conf
gconf
groff
gssapi_mech.conf

一些函数式编程语言,如 F#,提供了在函数之间进行管道传递的特殊语法。在 F#中,可以通过以下方式对列表进行偶数过滤:

[1..10] |>List.filter (fun n -> n% 2 = 0);;

这种语法看起来非常漂亮,特别是在长链式函数中使用时。例如,将一个数字转换为浮点数,然后对其进行平方根运算,最后四舍五入,看起来会像下面这样:

10.5 |> float |>Math.Sqrt |>Math.Round

这比 C 风格的语法更清晰,后者看起来会像下面这样:

Math.Round(Math.Sqrt((float)10.5))

不幸的是,JavaScript 没有使用巧妙的 F#风格语法编写管道的能力,但是我们仍然可以通过方法链接来改进前面代码中显示的普通方法。

JavaScript 中的所有内容都是对象,这意味着我们可以通过向现有对象添加功能来改进它们的外观。对对象集合进行操作是函数式编程提供一些强大功能的领域。让我们首先向数组对象添加一个简单的过滤方法。您可以将这些查询视为以函数式方式编写的 SQL 数据库查询。

实现

我们希望提供一个对数组的每个成员进行匹配并返回一组结果的函数:

Array.prototype.where = function (inclusionTest) {
  let results = [];
  for (let i = 0; i<this.length; i++) {
    if (inclusionTest(this[i]))
      results.push(this[i]);
  }
  return results;
};

这个看起来相当简单的函数允许我们快速过滤一个数组:

var items = [1,2,3,4,5,6,7,8,9,10];
items.where(function(thing){ return thing % 2 ==0;});

我们返回的也是一个对象,这种情况下是一个数组对象。我们可以继续像下面这样链式调用方法:

items.where(function(thing){ return thing % 2 ==0;})
  .where(function(thing){ return thing % 3 == 0;});

结果是一个只包含数字 6 的数组,因为它是 1 到 10 之间唯一既是偶数又可被三整除的数字。返回原始对象的修改版本而不改变原始对象的方法称为流畅接口。通过不改变原始的项目数组,我们为变量引入了一定程度的不可变性。

如果我们向数组扩展库添加另一个函数,我们就可以开始看到这些管道有多么有用:

Array.prototype.select=function(projection){
  let results = [];
  for(let i = 0; i<this.length;i++){
    results.push(projection(this[i]));
  }
  return results;
};

这个扩展允许根据任意投影函数对原始项目进行投影。给定一组包含 ID 和名称的对象,我们可以使用我们的流畅扩展到数组来执行复杂的操作:

let children = [{ id: 1, Name: "Rob" },
{ id: 2, Name: "Sansa" },
{ id: 3, Name: "Arya" },
{ id: 4, Name: "Brandon" },
{ id: 5, Name: "Rickon" }];
let filteredChildren = children.where(function (x) {
  return x.id % 2 == 0;
}).select(function (x) {
  return x.Name;
});

这段代码将构建一个新数组,其中只包含具有偶数 ID 的子项,而不是完整的对象,数组将只包含它们的名称:SansaBrandon。对于熟悉.Net 的人来说,这些函数可能看起来非常熟悉。.Net 上的语言集成查询LINQ)库提供了类似命名的受函数启发的函数,用于操作集合。

以这种方式链接函数既更容易理解,也更容易构建,因为避免了临时变量,代码更加简洁。考虑使用循环和临时变量重新实现前面的示例:

let children = [{ id: 1, Name: "Rob" },
{ id: 2, Name: "Sansa" },
{ id: 3, Name: "Arya" },
{ id: 4, Name: "Brandon" },
{ id: 5, Name: "Rickon" }];
let evenIds = [];
for(let i=0; i<children.length;i++)
{
  if(children[i].id%2==0)
    evenIds.push(children[i]);
}
let names = [];
for(let i=0; i< evenIds.length;i++)
{
  names.push(evenIds[i].name);
}

许多 JavaScript 库,比如 d3,都是为了鼓励这种编程方式而构建的。起初,遵循这种约定创建的代码似乎很糟糕,因为行长非常长。我认为这是行长不是一个很好的衡量复杂性的工具,而不是这种方法的实际问题。

累加器

我们已经研究了一些简单的数组函数,它们为数组添加了过滤和管道。另一个有用的工具是累加器。累加器通过对集合进行迭代来帮助构建单个结果。许多常见的操作,比如对数组元素求和,都可以使用累加器来实现,而不是使用循环。

递归在函数式编程语言中很受欢迎,其中许多语言实际上提供了一种称为“尾递归优化”的优化。支持这一点的语言为使用递归的函数提供了优化,其中堆栈帧被重用。这是非常高效的,可以轻松地替代大多数循环。关于 JavaScript 解释器是否支持尾递归优化的细节还不清楚。在大多数情况下,似乎并不支持,但我们仍然可以利用递归。

for循环的问题在于循环中的控制流是可变的。考虑这个相当容易犯的错误:

let result = "";
let multiArray = [[1,2,3], ["a", "b", "c"]];
for(vari=0; i<multiArray.length; i++)
  for(var j=0; i<multiArray[i].length; j++)
    result += multiArray[i][j];

你发现错误了吗?我尝试了几次才得到一个可行的版本,我才发现了问题。问题在于第二个循环中的循环计数器,它应该是这样的:

let result = "";
let multiArray = [[1,2,3], ["a", "b", "c"]];
for(let i=0; i<multiArray.length; i++)
  for(let j=0; j<multiArray[i].length; j++)
    result +=multiArray[i][j];

显然,通过更好的变量命名可以在一定程度上缓解这个问题,但我们希望完全避免这个问题。

相反,我们可以利用累加器,这是一个将集合中的多个值组合成单个值的工具。我们错过了 Westeros 的一些模式,所以让我们回到我们的神话般的例子。战争花费了大量的金钱,但幸运的是有大量的农民来交税,为领主们的王位之争提供资金。

实施

我们的农民由一个简单的模型代表,看起来像下面这样:

let peasants = [
  {name: "Jory Cassel", taxesOwed: 11, bankBalance: 50},
  {name: "VardisEgen", taxesOwed: 15, bankBalance: 20}];

在这组农民中,我们有一个看起来像下面这样的累加器:

TaxCollector.prototype.collect = function (items, value, projection) {
  if (items.length> 1)
    return projection(items[0]) + this.collect(items.slice(1), value, projection);
  return projection(items[0]);
};

这段代码接受一个项目列表,一个累加器值,以及一个将值投影到累加中的函数。

投影函数看起来像下面这样:

function (item) {
  return Math.min(item.moneyOwed, item.bankBalance);
}

为了激活这个函数,我们只需要传入一个累加器的初始值以及数组和投影。激活值会有所不同,但往往是一个身份;在字符串累加器的情况下是一个空字符串,在数学累加器的情况下是 0 或 1。

每次通过累加器,我们都会缩小我们操作的数组的大小。所有这些都是在没有一个可变变量的情况下完成的。

内部累积可以是任何你喜欢的函数:字符串追加,加法,或者更复杂的东西。累加器有点像访问者模式,只是在累加器内部修改集合中的值是不被赞同的。记住,函数式编程是无副作用的。

记忆化

不要与记忆混淆,记忆化是一个特定术语,用于保留函数中先前计算的值。

正如我们之前看到的,无副作用的函数可以被多次调用而不会引起问题。与此相对的是,函数也可以被调用的次数少于需要的次数。考虑一个复杂或者至少耗时的数学运算的昂贵函数。我们知道函数的结果完全取决于函数的输入。因此,相同的输入将始终产生相同的输出。那么,为什么我们需要多次调用函数呢?如果我们保存函数的输出,我们可以检索到它,而不是重新进行耗时的数学运算。

以空间换时间是一个经典的计算科学问题。通过缓存结果,我们可以使应用程序更快,但会消耗更多的内存。决定何时进行缓存,何时简单地重新计算结果是一个困难的问题。

实施

在维斯特洛大陆,被称为大师的学者们长期以来对一个数字序列产生了浓厚的兴趣,这个序列似乎在自然界中频繁出现。一个奇怪的巧合是,他们称这个序列为斐波那契数列。它的定义是将序列中的前两个项相加以得到下一个项。这个序列的起始项被定义为 0、1、1。所以要得到下一个项,我们只需将 1 和 1 相加得到 2。下一个项将 2 和 1 相加得到 3,依此类推。找到序列的任意成员需要找到前两个成员,因此可能需要进行一些计算。

在我们的世界中,我们已经发现了一个避免大部分计算的封闭形式,但在维斯特洛还没有做出这样的发现。

一个朴素的方法是简单地计算每个项,如下所示:

let Fibonacci = (function () {
  function Fibonacci() {
  }
  Fibonacci.prototype.NaieveFib = function (n) {
    if (n == 0)
      return 0;
    if (n <= 2)
      return 1;
    return this.NaieveFib(n - 1) + this.NaieveFib(n - 2);
  };
  return Fibonacci;
})();

这个解决方案对于小数字(比如 10)非常快。然而,对于更大的数字,比如大于 40,速度会明显变慢。这是因为基本情况被调用了 102,334,155 次。

让我们看看是否可以通过备忘录一些值来改善情况:

let Fibonacci = (function () {
  function Fibonacci() {
    this.memoizedValues = [];
  }

  Fibonacci.prototype.MemetoFib = function (n) {
    if (n == 0)
      return 0;
    if (n <= 2)
      return 1;
    if (!this. memoizedValues[n])
      this. memoizedValues[n] = this.MemetoFib(n - 1) + this.MemetoFib(n - 2);
    return this. memoizedValues[n];
  };
  return Fibonacci;
})();

我们刚刚对我们遇到的每个项目进行了备忘录。事实证明,对于这个算法,我们存储了n+1个项目,这是一个相当不错的折衷。没有备忘录,计算第 40 个斐波那契数需要 963 毫秒,而备忘录版本只需要 11 毫秒。当函数变得更复杂时,差异会更加明显。备忘录版本的斐波那契数列 140 只需要 12 毫秒,而朴素版本……嗯,已经过了一天,它还在运行。

备忘录的最大优点是,对具有相同参数的函数的后续调用将非常快,因为结果已经计算过了。

在我们的例子中,只需要一个非常小的缓存。在更复杂的例子中,很难知道缓存应该有多大,或者一个值需要重新计算的频率。理想情况下,您的缓存应该足够大,以至于总是有足够的空间来放更多的结果。然而,这可能并不现实,需要做出艰难的决定,即哪些缓存成员应该被移除以节省空间。有很多方法可以执行缓存失效。有人说,缓存失效是计算科学中最棘手的问题之一,原因是我们实际上在试图预测未来。如果有人完善了一种预测未来的方法,那么他们很可能会将自己的技能应用于比缓存失效更重要的领域。两个选择是依赖于最近最少使用的缓存成员或最不经常使用的成员。问题的形状可能决定了更好的策略。

备忘录是加速需要多次执行的计算或者有共同子计算的计算的一个奇妙工具。人们可以将备忘录视为缓存的一种特殊情况,这是在构建网络服务器或浏览器时常用的技术。在更复杂的 JavaScript 应用程序中探索备忘录是值得的。

不变性

函数式编程的基石之一是所谓的变量只能被赋值一次。这就是不变性。ECMAScript 2015 支持一个新关键字,constconst关键字可以像var一样使用,只是用const赋值的变量将是不可变的。例如,以下代码显示了一个变量和一个常量,它们都以相同的方式被操作:

let numberOfQueens = 1;
const numberOfKings = 1;
numberOfQueens++;
numberOfKings++;
console.log(numberOfQueens);
console.log(numberOfKings);

运行的输出如下:

2
1

正如你所看到的,常数和变量的结果是不同的。

如果您使用的是不支持const的旧浏览器,那么const对您来说将不可用。一个可能的解决方法是使用更广泛采用的Object.freeze功能:

let consts = Object.freeze({ pi : 3.141});
consts.pi = 7;
console.log(consts.pi);//outputs 3.141

正如您所看到的,这里的语法并不是很用户友好。另一个问题是,尝试对已分配的const进行赋值只会静默失败,而不是抛出错误。以这种方式静默失败绝对不是一种理想的行为;应该抛出完整的异常。如果启用了严格模式,ECMAScript 5 中添加了更严格的解析模式,并且实际上会抛出异常:

"use strict";
var consts = Object.freeze({ pi : 3.141});
consts.pi = 7;

前面的代码将抛出以下错误:

consts.pi = 7;
          ^
TypeError: Cannot assign to read only property 'pi' of #<Object>

另一种方法是我们之前提到的object.Create语法。在创建对象的属性时,可以指定writable: false来使属性不可变:

var t = Object.create(Object.prototype,
{ value: { writable: false,
  value: 10}
});
t.value = 7;
console.log(t.value);//prints 10

然而,即使在严格模式下,当尝试写入不可写属性时也不会抛出异常。因此,我认为const关键字并不完美地实现了不可变对象。最好使用 freeze。

延迟实例化

如果您进入一个高端咖啡店并点一杯过于复杂的饮料(大杯奶茶拿铁,3 泵,脱脂牛奶,少水,无泡沫,超热,有人吗?),那么这种饮料将是临时制作的,而不是提前制作的。即使咖啡店知道当天会有哪些订单,他们也不会提前制作所有的饮料。首先,因为这会导致大量的毁坏、冷却的饮料,其次,如果他们必须等待当天所有订单完成,第一个顾客要等很长时间才能拿到他们的订单。

咖啡店遵循按需制作饮料的方法。他们在点单时制作饮料。我们可以通过使用一种称为延迟实例化或延迟初始化的技术来将类似的方法应用到我们的代码中。

考虑一个昂贵的创建对象;也就是说,创建对象需要很长时间。如果我们不确定对象的值是否需要,我们可以推迟到以后再完全创建它。

实施

让我们来看一个例子。Westeros 并不是很喜欢昂贵的咖啡店,但他们确实喜欢好的面包店。这家面包店提前接受不同种类的面包请求,然后一旦有订单,就会一次性烘烤所有面包。然而,创建面包对象是一个昂贵的操作,所以我们希望推迟到有人来取面包时再进行:

class Bakery {
  constructor() {
    this.requiredBreads = [];
  }
  orderBreadType(breadType) {
    this.requiredBreads.push(breadType);
  }
}

首先,我们创建一个要根据需要创建的面包类型列表。通过订购面包类型,这个列表会被追加:

var Bakery = (function () {
  function Bakery() {
    this.requiredBreads = [];
  }
  Bakery.prototype.orderBreadType = function (breadType) {
    this.requiredBreads.push(breadType);
  };

这样就可以快速地将面包添加到所需的面包列表中,而不必为每个面包的创建付出代价。

现在当调用pickUpBread时,我们将实际创建面包:

pickUpBread(breadType) {
  console.log("Picup of bread " + breadType + " requested");
  if (!this.breads) {
    this.createBreads();
  }
  for (var i = 0; i < this.breads.length; i++) {
    if (this.breads[i].breadType == breadType)
      return this.breads[i];
  }
}
createBreads() {
  this.breads = [];
  for (var i = 0; i < this.requiredBreads.length; i++) {
    this.breads.push(new Bread(this.requiredBreads[i]));
  }
}

在这里,我们调用了一系列操作:

let bakery = new Westeros.FoodSuppliers.Bakery();
bakery.orderBreadType("Brioche");
bakery.orderBreadType("Anadama bread");
bakery.orderBreadType("Chapati");
bakery.orderBreadType("Focaccia");

console.log(bakery.pickUpBread("Brioche").breadType + "picked up");

这将导致以下结果:

Pickup of bread Brioche requested.
Bread Brioche created.
Bread Anadama bread created.
Bread Chapati created.
Bread Focaccia created.
Brioche picked up

您可以看到实际面包的收集是在取货后进行的。

延迟实例化可以用来简化异步编程。Promise 是简化 JavaScript 中常见的回调的一种方法。Promise 是一个包含状态和结果的对象。首次调用时,promise 处于未解决状态;一旦async操作完成,状态就会更新为完成,并填充结果。您可以将结果视为延迟实例化。我们将在第九章 Web Patterns中更详细地讨论 promise 和 promise 库。

懒惰可以节省大量时间,因为创建昂贵的对象最终可能根本不会被使用。

提示和技巧

尽管回调是处理 JavaScript 中异步方法的标准方式,但它们很容易变得混乱。有许多方法可以解决这种混乱的代码:promise 库提供了一种更流畅的处理回调的方式,未来版本的 JavaScript 可能会采用类似于 C# async/await语法的方法。

我真的很喜欢累加器,但它们在内存使用方面可能效率低下。缺乏尾递归意味着每次通过都会增加另一个堆栈帧,因此这种方法可能会导致内存压力。在这种情况下,所有事情都是在内存和代码可维护性之间进行权衡。

总结

JavaScript 不是一种函数式编程语言。这并不是说不可能将一些函数式编程的思想应用到它上面。这些方法可以使代码更清晰、更易于调试。有些人甚至可能会认为问题的数量会减少,尽管我从未见过任何令人信服的研究。

在本章中,我们研究了六种不同的模式。延迟实例化、记忆化和不可变性都是创建模式。函数传递既是结构模式,也是行为模式。累加器也是行为模式。过滤器和管道实际上并不属于 GoF 的任何一类,因此可以将它们视为一种样式模式。

在下一章中,我们将研究一些在应用程序中划分逻辑和呈现的模式。随着 JavaScript 应用程序的增长,这些模式变得更加重要。

第七章:响应式编程

我曾经读过一本书,书中提到牛顿在观察芦苇周围的河流时想出了微积分的概念。我从未能找到其他支持这一说法的来源。然而,这是一个很好的形象。微积分涉及理解系统随时间变化的状态。大多数开发人员在日常工作中很少需要处理微积分。然而,他们必须处理系统的变化。毕竟,一个完全不变的系统是相当无聊的。

在过去几年中,关于将变化视为一系列事件的不同想法已经出现 - 就像牛顿所观察到的那条河流一样。给定一个起始位置和一系列事件,应该可以找出系统的状态。事实上,这就是使用事件存储的想法。我们不是将聚合的最终状态保存在数据库中,而是跟踪已应用于该聚合的所有事件。通过重放这一系列事件,我们可以重新创建聚合的当前状态。这似乎是一种存储对象状态的绕圈方式,但实际上对于许多情况非常有用。例如,一个断开连接的系统,比如手机应用程序在手机未连接到网络时,使用事件存储可以更容易地与其他事件合并,而不仅仅是保留最终状态。对于审计场景,它也非常有用,因为可以通过简单地在时间索引处停止重放来将系统拉回到任何时间点的状态。你有多少次被问到,“为什么系统处于这种状态?”,而你无法回答?有了事件存储,答案应该很容易确定。

在本章中,我们将涵盖以下主题:

  • 应用状态变化

  • 过滤流

  • 合并流

  • 用于多路复用的流

应用状态变化

在应用程序中,我们可以将所有事件发生的事情视为类似的事件流。用户点击按钮?事件。用户的鼠标进入某个区域?事件。时钟滴答?事件。在前端和后端应用程序中,事件是触发状态变化的事物。你可能已经在使用事件监听器进行事件处理。考虑将点击处理程序附加到按钮上:

var item = document.getElementById("item1");
item. addEventListener("click", function(event){ /*do something */ });

在这段代码中,我们已经将处理程序附加到了click事件上。这是相当简单的代码,但是想象一下当我们添加条件时,这段代码的复杂性会如何迅速增加,比如“在点击后忽略 500 毫秒内的额外点击,以防止人们双击”和“如果按住Ctrl键时点击按钮,则触发不同的事件”。响应式编程或函数式响应式编程通过使用流提供了这些复杂交互场景的简单解决方案。让我们探讨一下你的代码如何从利用响应式编程中受益。

想要简单地思考事件流的最简单方法不是考虑你以前在编程中可能使用过的流,而是考虑数组。假设你有一个包含一系列数字的数组:

[1, 4, 6, 9, 34, 56, 77, 1, 2, 3, 6, 10]

现在你想要过滤这个数组,只显示偶数。在现代 JavaScript 中,可以通过数组的filter函数轻松实现这一点:

[1, 4, 6, 9, 34, 56, 77, 1, 2, 3, 6, 10].filter((x)=>x%2==0) =>
[4, 6, 34, 56, 2, 6, 10]

可以在这里看到一个图形表示:

流

这里的过滤功能保持不变,无论数组中有十个项目还是一万个项目。现在,如果源数组不断添加新项目,我们希望通过将任何新的偶数项目插入到依赖数组中来保持其最新状态。为此,我们可以使用类似装饰器的模式来钩入数组的add函数。使用装饰器,我们可以调用过滤方法,如果找到匹配项,就将其添加到过滤后的数组中。

实际上,流是对未来事件集合的可观察对象。可以使用流操作解决许多有趣的问题。让我们从一个简单的问题开始:处理点击。这个问题非常简单,表面上似乎没有使用流的优势。别担心,随着我们的深入,我们会让它变得更加困难。

在大部分情况下,本书避免使用任何特定的 JavaScript 库。这是因为模式应该能够在不需要太多仪式的情况下轻松实现。然而,在这种情况下,我们实际上要使用一个库,因为流的实现有一些细微之处,我们希望有一些语法上的美感。如果你想看看如何实现基本的流,那么你可以基于第五章中概述的观察者模式进行实现。

JavaScript 中有许多流库,如 Reactive.js、Bacon.js 和 RxJS 等。每个库都有各种优点和缺点,但具体细节超出了本书的范围。在本书中,我们将使用 JavaScript 的 Reactive Extensions,其源代码可以在 GitHub 上找到github.com/Reactive-Extensions/RxJS

让我们从一个简短的 HTML 代码开始:

<body>
  <button id="button"> Click Me!</button>
  <span id="output"></span>
</body>

接下来,让我们添加一个快速的点击计数器:

<script>
  var counter = 0;
  var button = document.getElementById('button');
  var source = Rx.Observable.fromEvent(button, 'click');
  var subscription = source.subscribe(function (e) {
    counter++;
    output.innerHTML = "Clicked " + counter + " time" + (counter > 1 ? "s" : "");
  });
</script>

在这里,你可以看到我们正在从按钮的click事件创建一个新的事件流。新创建的流通常被称为元流。每当从源流中发出事件时,它会自动被操作和发布到元流中。我们订阅了这个流并增加一个计数器。如果我们只想对偶数事件做出反应,我们可以通过向流订阅第二个函数来实现:

var incrementSubscription = source.subscribe(() => counter++);
var subscription = source.filter(x=>counter%2==0).subscribe(function (e) {
  output.innerHTML = "Clicked " + counter + " time" +(counter > 1 ? "s" : "");
});

在这里,你可以看到我们正在对流应用过滤器,以使计数器与更新屏幕的函数不同。但是,将计数器保留在流之外感觉有些不好,对吧?很可能,每隔一次点击增加一次并不是这个函数的目标。更有可能的是,我们只想在双击时运行一个函数。

这是用传统方法很难做到的,然而这些复杂的交互可以很容易地通过流来实现。您可以看到我们如何在这段代码中解决这个问题:

source.buffer(() => source.debounce(250))
.map((list) => list.length)
.filter((x) => x >= 2)
.subscribe((x)=> {
  counter++;
  output.innerHTML = "Clicked " + counter + " time" + (counter > 1 ? "s" : "");
});

在这里,我们获取点击流并使用防抖动来缓冲流以生成缓冲区的边界。防抖动是硬件世界的一个术语,意味着我们将一个嘈杂的信号清理成一个单一的事件。当按下物理按钮时,通常会有一些额外的高或低信号,而不是我们想要的单点信号。实际上,我们消除了在一个窗口内发生的重复信号。在这种情况下,我们等待250毫秒,然后触发一个事件以移动到一个新的缓冲区。缓冲区包含在防抖期间触发的所有事件,并将它们的列表传递给链中的下一个函数。map 函数生成一个以列表长度为内容的新流。接下来,我们过滤流,只显示值为 2 或更多的事件,即两次点击或更多。事件流看起来像下面的图表:

Streams

使用传统的事件监听器和回调执行相同的逻辑将会非常困难。人们很容易想象出一个更复杂的工作流程,这将失控。FRP 允许更简化的方式来处理事件。

过滤流

正如我们在前面的部分中看到的,可以过滤事件流,并从中产生一个新的事件流。您可能熟悉能够过滤数组中的项目。ES5 引入了一些新的数组运算符,如filtersome。其中的第一个产生一个只包含符合过滤规则的元素的新数组。Some是一个类似的函数,如果数组的任何元素匹配,则简单返回true。这些相同类型的函数也支持在流上,以及您可能熟悉的来自函数式语言的函数,如 First 和 Last。除了对数组有意义的函数之外,还有许多基于时间序列的函数,当您考虑到流存在于时间中时,这些函数更有意义。

我们已经看到了防抖动,这是一个基于时间的过滤器的例子。防抖动的另一个非常简单的应用是防止用户双击提交按钮的恼人错误。考虑一下使用流的代码有多简单:

Rx.Observable.FromEvent(button, "click")
.debounce(1000).subscribe((x)=>doSomething());

您可能还会发现像 Sample 这样的函数,它从时间窗口生成一组事件。当我们处理可能产生大量事件的可观察对象时,这是一个非常方便的函数。考虑一下我们维斯特洛斯的示例。

不幸的是,维斯特洛是一个相当暴力的地方,人们似乎以不愉快的方式死去。有这么多人死去,我们不可能每个人都关注,所以我们只想对数据进行抽样并收集一些死因。

为了模拟这个传入的流,我们将从一个数组开始,类似于以下内容:

var deaths = 
  {
    Name:"Stannis",
    Cause: "Cold"
  },
  {
    Name: "Tyrion",
    Cause: "Stabbing"
  },
…
}

提示

您可以看到我们正在使用数组来模拟事件流。这可以用任何流来完成,并且是一个非常简单的测试复杂代码的方法。您可以在数组中构建一个事件流,然后以适当的延迟发布它们,从而准确地表示从文件系统到用户交互的事件流的任何内容。

现在我们需要将我们的数组转换为事件流。幸运的是,有一些使用from方法的快捷方式可以做到这一点。这将简单地返回一个立即执行的流。我们希望假装我们有一个定期分布的事件流,或者在我们相当阴郁的情况下,死亡。这可以通过使用 RxJS 的两种方法来实现:intervalzipinterval创建一个定期间隔的事件流。zip匹配来自两个流的事件对。这两种方法一起将以定期间隔发出新的事件流:

function generateDeathsStream(deaths) {
  return Rx.Observable.from(deaths).zip(Rx.Observable.interval(500), (death,_)=>death);
}

在这段代码中,我们将死亡数组与每500毫秒触发一次的间隔流进行了合并。因为我们对间隔事件不是特别感兴趣,所以我们简单地丢弃了它,并将数组中的项目进行了投影。

现在我们可以通过简单地取样本然后订阅它来对这个流进行取样。在这里,我们每1500毫秒取样一次:

generateDeathsStream(deaths).sample(1500).subscribe((item) => { /*do something */ });

你可以有任意多个订阅者订阅一个流,所以如果你想进行一些取样,以及可能一些聚合函数,比如简单地计算事件的数量,你可以通过有几个订阅者来实现。

Var counter = 0;
generateDeathsStream(deaths).subscribe((item) => { counter++ });

合并流

我们已经看到了zip函数,它将事件一对一地合并以创建一个新的流,但还有许多其他合并流的方法。一个非常简单的例子可能是一个页面,它有几个代码路径,它们都想执行类似的操作。也许我们有几个动作,所有这些动作都会导致状态消息被更新:

var button1 = document.getElementById("button1");
var button2 = document.getElementById("button2");
var button3 = document.getElementById("button3");
var button1Stream = Rx.Observable.fromEvent(button1, 'click');
var button2Stream = Rx.Observable.fromEvent(button2, 'click');
var button3Stream = Rx.Observable.fromEvent(button3, 'click');
var messageStream = Rx.Observable.merge(button1Stream, button2Stream, button3Stream);
messageStream.subscribe(function (x) { return console.log(x.type + " on " + x.srcElement.id); });

在这段代码中,你可以看到各种流被传递到合并函数中,然后产生了合并后的流:

流

合并流虽然有用,但这段代码似乎并不比直接调用事件处理程序更好,实际上它比必要的代码还要长。然而,考虑到状态消息的来源不仅仅是按钮推送。我们可能还希望异步事件也写出信息。例如,向服务器发送请求可能还想添加状态信息。另一个很棒的应用可能是使用在后台运行并使用消息与主线程通信的 web worker。对于基于 web 的 JavaScript 应用程序,这是我们实现多线程应用程序的方式。让我们看看它是什么样子。

首先,我们可以从 worker 角色创建一个流。在我们的示例中,worker 只是计算斐波那契数列。我们在页面上添加了第四个按钮,并触发了 worker 进程:

var worker = Rx.DOM.fromWorker("worker.js");
button4Stream.subscribe(function (_) {  
  worker.onNext({ cmd: "start", number: 35 });
});

现在我们可以订阅合并后的流,并将其与所有先前的流结合起来:

var messageStream = Rx.Observable.merge(button1Stream, button2Stream, button3Stream, worker);
messageStream.subscribe(function (x) {  
  appendToOutput(x.type + (x.srcElement.id === undefined ? " with " + x.data : " on " + x.srcElement.id));
}, function (err) { return appendToOutput(err, true); });

这一切看起来非常好,但我们不想一次给用户提供数十个通知。我们可以通过使用与之前看到的相同的间隔 zip 模式来限制事件流,以便一次只显示一个 toast。在这段代码中,我们用调用 toast 显示库来替换我们的appendToOutput方法:

var messageStream = Rx.Observable.merge(button1Stream, button2Stream, button3Stream, worker);
var intervalStream = Rx.Observable.interval(5000);
messageStream.zip(intervalStream, function (x, _) { return x;})
  .subscribe(function (x) {  
    toastr.info(x.type + (x.srcElement.id === undefined ? " with " + x.data : " on " + x.srcElement.id));
  }, function (err) { return toastr.error(err); });

正如你所看到的,这个功能的代码很简短,易于理解,但包含了大量的功能。# 多路复用流在 Westeros 国王的议会中,没有人能够在权力地位上升到一定程度而不擅长建立间谍网络。通常,最好的间谍是那些能够最快做出反应的人。同样,我们可能有一些代码可以选择调用许多不同的服务中的一个来完成相同的任务。一个很好的例子是信用卡处理器:我们使用哪个处理器并不重要,因为它们几乎都是一样的。

为了实现这一点,我们可以启动多个 HTTP 请求到每个服务。如果我们将每个请求放入一个流中,我们可以使用它来选择最快响应的处理器,然后使用该处理器执行其余的操作。使用 RxJS,这看起来像下面这样:

var processors = Rx.Observable.amb(processorStream1, processorStream2);

甚至可以在amb调用中包含一个超时,以处理处理器没有及时响应的情况。# 提示和技巧可以应用于流的不同函数有很多。如果你决定在 JavaScript 中使用 RxJS 库进行 FRP 需求,许多常见的函数已经为你实现了。更复杂的函数通常可以编写为包含函数链,因此在编写自己的函数之前,尝试想出一种通过链式调用来创建所需功能的方法。

在 JavaScript 中,经常会出现跨网络的异步调用失败。网络是不可靠的,移动网络尤其如此。在大多数情况下,当网络失败时,我们的应用程序也会失败。流提供了一个简单的解决方法,允许您轻松重试失败的订阅。在 RxJS 中,这种方法被称为“重试”。将其插入到任何可观察链中,可以使其更具抗网络故障的能力。

总结

函数式响应式编程在服务器端和客户端的不同应用中有许多用途。在客户端,它可以用于将大量事件整合成数据流,实现复杂的交互。它也可以用于最简单的事情,比如防止用户双击按钮。仅仅使用流来处理所有数据变化并没有太大的成本。它们非常易于测试,并且对性能影响很小。

FRP 最美好的一点也许是它提高了抽象级别。您不必处理繁琐的流程代码,而是可以专注于应用程序的逻辑流。

第八章 应用程序模式

到目前为止,我们已经花了大量时间研究用于解决局部问题的模式,也就是说,仅涉及少数类而不是整个应用程序的问题。这些模式的范围很窄。它们通常只涉及两三个类,并且在任何给定的应用程序中可能只使用一次。您可以想象一下,还有更大规模的模式适用于整个应用程序。您可以将“工具栏”视为一个通用模式,它在应用程序中的许多地方使用。而且,它是在许多应用程序中使用的模式,以赋予它们相似的外观和感觉。模式可以帮助指导整个应用程序的组装方式。在本章中,我们将研究一系列我称之为 MV家族的模式。这个家族包括 MVC、MVVM、MVP,甚至 PAC。就像它们的名字一样,这些模式本身非常相似。本章将涵盖这些模式,并展示如何或者是否可以将它们应用到 JavaScript 中。我们还将特别关注这些模式之间的区别。在本章结束时,您应该能够在鸡尾酒会上以 MVP 与 MVC 的微妙差异为题向客人讲解。涉及的主题将如下:+ 模型视图模式的历史+ 模型视图控制器+ 模型视图呈现器+ 模型视图视图模型# 首先,一些历史在应用程序内部分离关注点是一个非常重要的想法。我们生活在一个复杂且不断变化的世界中。这意味着不仅几乎不可能制定一个完全符合用户需求的计算机程序,而且用户需求是一个不断变化的迷宫。再加上一个事实,即用户 A 的理想程序与用户 B 的理想程序完全不同,我们肯定会陷入混乱。我们的应用程序需要像我们换袜子一样频繁地更改:至少每年一次。分层应用程序和维护模块化可以减少变更的影响。每个层次对其他层次了解得越少越好。在层次之间保持简单的接口可以减少一个层次的变更对另一个层次的影响的机会。如果您曾经仔细观察过高质量的尼龙制品(如热气球、降落伞或昂贵的夹克),您可能会注意到这种织物似乎形成了微小的方块。这是因为每隔几毫米,都会添加一根粗的加强线到织物中,形成交叉网格图案。如果织物被撕裂,那么撕裂将会被加强线停止或至少减缓。这限制了损坏的范围,并防止其扩散。应用程序中的层次和模块与限制变更的影响传播是完全相同的。在本书的前几章中,我们谈到了开创性的语言 Smalltalk。它是使类著名的语言。像许多这些模式一样,原始的 MV模式,模型 视图 控制器MVC),在被识别之前就已经被使用。尽管很难证明,但 MVC 最初是在 20 世纪 70 年代末由挪威计算机科学家 Trygve Reenskaug 在访问传奇般的施乐 PARC 期间提出的。在 20 世纪 80 年代,该模式在 Smalltalk 应用程序中得到了广泛使用。然而,直到 1988 年,该模式才在一篇名为《使用模型-视图-控制器用户界面范式的烹饪书》的文章中得到更正式的记录,作者是 Glenn E. Krasner 和 Stephen T. Pope。# 模型 视图 控制器 MVC 是一种用于创建丰富、交互式用户界面的模式:这正是在网络上变得越来越受欢迎的界面类型。敏锐的读者已经发现,该模式由三个主要组件组成:模型、视图和控制器。您可以在这个插图中看到信息在这些组件之间的流动:模型 视图 控制器

前面的图表显示了 MVC 中三个组件之间的关系。

模型包含程序的状态。在许多应用程序中,该模型以某种形式包含在数据库中。模型可以从数据库等持久存储重新生成,也可以是瞬态的。理想情况下,模型是该模式中唯一可变的部分。视图和控制器都不与任何状态相关联。

对于一个简单的登录屏幕,模型可能如下所示:

class LoginModel{
  UserName: string;
  Password: string;
  RememberMe: bool;
  LoginSuccessful: bool;
  LoginErrorMessage: string;
}

您会注意到,我们不仅为用户显示的输入字段设置了字段,还为登录状态设置了字段。用户可能不会注意到这一点,但它仍然是应用程序状态的一部分。

模型通常被建模为信息的简单容器。通常,模型中没有真正的功能。它只包含数据字段,也可能包含验证。在一些 MVC 模式的实现中,模型还包含有关字段的元数据,如验证规则。

裸对象模式是典型 MVC 模式的偏离。它通过大量的业务信息以及有关数据显示和编辑的提示来增强模型。甚至包含了将模型持久化到存储的方法。

裸对象模式中的视图是从这些模型自动生成的。控制器也是通过检查模型自动生成的。这将逻辑集中在显示和操作应用程序状态上,并使开发人员无需编写自己的视图和控制器。因此,虽然视图和控制器仍然存在,但它们不是实际的对象,而是从模型动态创建的。

已经成功地使用了该模式部署了几个系统。一些批评涌现,主要是关于如何从仅仅模型生成吸引人的用户界面以及如何正确协调多个视图。

在 Reenskaug 的博士论文《呈现裸对象》的序言中,他建议裸对象模式实际上比野生 MVC 的大多数派生更接近他最初对 MVC 的愿景。

当模型发生变化时,更新会传达给视图。通常通过使用观察者模式来实现。模型通常不知道控制器或视图。第一个只是告诉它要改变的东西,第二个只通过观察者模式更新,所以模型并不直接知道它。

视图基本上做您所期望的事情:将模型状态传达给目标。我不敢说视图必须是模型的视觉或图形表示,因为视图经常被传达到另一台计算机,并且可能以 XML、JSON 或其他数据格式的形式存在。在大多数情况下,特别是与 JavaScript 相关的情况,视图将是一个图形对象。在 Web 环境中,这通常是由浏览器呈现的 HTML。JavaScript 在手机和桌面上也越来越受欢迎,因此视图也可以是手机或桌面上的屏幕。

上述段落中的模型的视图可能如下图所示:

模型视图控制器

在没有使用观察者模式的情况下,视图可能会定期轮询模型以查找更改。在这种情况下,视图可能必须保持状态的表示,或者至少一个版本号。重要的是,视图不要单方面更新此状态,而不将更新传递给控制器,否则模型和视图中的副本将不同步。

最后,模型的状态由控制器更新。控制器通常包含更新模型字段的所有逻辑和业务规则。我们登录页面的简单控制器可能如下所示:

class LoginController {
  constructor(model) {
    this.model = model;
  }
  Login(userName, password, rememberMe) {
    this.model.UserName = userName;
    this.model.Password = password;
    this.model.RememberMe = rememberMe;
    if (this.checkPassword(userName, password))
      this.model.LoginSuccessful;
    else {
      this.model.LoginSuccessful = false;
      this.model.LoginErrorMessage = "Incorrect username or password";
    }
  }
};

控制器知道模型的存在,并且通常也知道视图的存在。它协调两者。控制器可能负责初始化多个视图。例如,单个控制器可以提供模型所有实例的列表视图,以及仅提供详细信息的视图。在许多系统中,控制器将对模型进行创建、读取、更新和删除(CRUD)操作。控制器负责选择正确的视图,并建立模型和视图之间的通信。

当需要对应用程序进行更改时,代码的位置应立即显而易见。例如:

更改 位置
屏幕上的元素间距不合适,更改间距。 视图
由于密码验证中的逻辑错误,没有用户能够登录。 控制器
要添加的新字段。 所有层

注意

Presentation-Abstraction-ControlPAC)是另一种利用三个组件的模式。在这种情况下,它的目标是描述一组封装的三元组的层次结构,更符合我们对世界的思考方式。控制,类似于 MVC 控制器,将交互传递到封装组件的层次结构中,从而允许信息在组件之间流动。抽象类似于模型,但可能只代表对于特定 PAC 而言重要的一些字段,而不是整个模型。最后,演示文稿实际上与视图相同。

PAC 的分层性质允许对组件进行并行处理,这意味着它可以成为当今多处理器系统中强大的工具。

您可能会注意到最后一个需要对应用程序的所有层进行更改。这种责任的多重位置是裸对象模式试图通过动态创建视图和控制器来解决的。MVC 模式通过根据用户交互中的角色将代码分割成位置。这意味着单个数据字段存在于所有层中,如图所示:

模型视图控制器

有些人可能会称这为横切关注点,但实际上它并没有涵盖足够多的应用程序部分才能被称为这样。数据访问和日志记录是横切关注点,因为它们是普遍存在的,难以集中化。这种领域在不同层之间的普遍存在并不是一个主要问题。但是,如果这让你感到困扰,那么你可能是使用裸对象模式的理想候选人。

让我们开始构建一些 JavaScript 中表示 MVC 的代码。

MVC 代码

让我们从一个简单的场景开始,我们可以应用 MVC。不幸的是,维斯特洛几乎没有计算机,可能是由于缺乏电力。因此,使用维斯特洛作为示例应用结构模式是困难的。遗憾的是,我们将不得不退一步,谈论一个控制维斯特洛的应用程序。让我们假设它是一个 Web 应用程序,并在客户端实现整个 MVC。

可以通过在客户端和服务器之间分割模型、视图和控制器来实现 MVC。通常,控制器会位于服务器上,并提供视图所知的 API。模型作为通信方法,既用于驻留在 Web 浏览器上的视图,也用于数据存储,可能是某种形式的数据库。在需要在客户端上进行一些额外控制的情况下,通常也会将控制器在服务器和客户端之间分割。

在我们的例子中,我们想创建一个控制城堡属性的屏幕。幸运的是,你很幸运,这不是一本关于使用 HTML 设计用户界面的书,因为我肯定会失败。我们将用图片代替 HTML:

MVC 代码

在大多数情况下,视图只是为最终用户提供一组控件和数据。在这个例子中,视图需要知道如何调用控制器上的保存函数。让我们来设置一下:

class CreateCastleView {
  constructor(document, controller, model, validationResult) {
    this.document = document;
    this.controller = controller;
    this.model = model;
    this.validationResult = validationResult;
    this.document.getElementById("saveButton").addEventListener("click", () => this.saveCastle());
    this.document.getElementById("castleName").value = model.name;
    this.document.getElementById("description").value = model.description;
    this.document.getElementById("outerWallThickness").value = model.outerWallThickness;
    this.document.getElementById("numberOfTowers").value = model.numberOfTowers;
    this.document.getElementById("moat").value = model.moat;
  }
  saveCastle() {
    var data = {
      name: this.document.getElementById("castleName").value,
      description: this.document.getElementById("description").value,
      outerWallThickness: this.document.getElementById("outerWallThickness").value,
      numberOfTowers: this.document.getElementById("numberOfTowers").value,
      moat: this.document.getElementById("moat").value
    };
    this.controller.saveCastle(data);
  }
}

您会注意到这个视图的构造函数包含对文档和控制器的引用。文档包含 HTML 和样式,由 CSS 提供。我们可以不传递对文档的引用,但以这种方式注入文档可以更容易地进行可测试性。我们将在后面的章节中更多地讨论可测试性。它还允许在单个页面上多次重用视图,而不必担心两个实例之间的冲突。

构造函数还包含对模型的引用,用于根据需要向页面字段添加数据。最后,构造函数还引用了一个错误集合。这允许将控制器的验证错误传递回视图进行处理。我们已经设置了验证结果为一个包装集合,看起来像下面这样:

class ValidationResult{
  public IsValid: boolean;
  public Errors: Array<String>;
  public constructor(){
    this.Errors = new Array<String>();
  }
}

这里的唯一功能是按钮的onclick方法绑定到调用控制器上的保存。我们不是将大量参数传递给控制器上的saveCastle函数,而是构建一个轻量级对象并传递进去。这使得代码更易读,特别是在一些参数是可选的情况下。视图中没有真正的工作,所有输入直接传递给控制器。

控制器包含应用程序的真正功能:

class Controller {
  constructor(document) {
    this.document = document;
  }
  createCastle() {
    this.setView(new CreateCastleView(this.document, this));
  }
  saveCastle(data) {
    var validationResult = this.validate(data);
    if (validationResult.IsValid) {
      //save castle to storage
      this.saveCastleSuccess(data);
    }
    else {
      this.setView(new CreateCastleView(this.document, this, data, validationResult));
    }
  }
  saveCastleSuccess(data) {
    this.setView(new CreateCastleSuccess(this.document, this, data));
  }
  setView(view) {
    //send the view to the browser
  }
  validate(model) {
    var validationResult = new validationResult();
    if (!model.name || model.name === "") {
      validationResult.IsValid = false;
      validationResult.Errors.push("Name is required");
    }
    return;
  }
}

这里的控制器做了很多事情。首先,它有一个setView函数,指示浏览器将给定的视图设置为当前视图。这可能是通过使用模板来完成的。这个工作的机制对于模式来说并不重要,所以我会留给你的想象力。

接下来,控制器实现了一个validate方法。该方法检查模型是否有效。一些验证可能在客户端执行,比如测试邮政编码的格式,但其他验证需要与服务器通信。如果用户名必须是唯一的,那么在客户端没有合理的方法在不与服务器通信的情况下进行测试。在某些情况下,验证功能可能存在于模型中而不是控制器中。

设置各种不同视图的方法也在控制器中找到。在这种情况下,我们有一个创建城堡的视图,然后成功和失败的视图。失败情况只返回相同的视图,并附加一系列验证错误。成功情况返回一个全新的视图。

将模型保存到某种持久存储的逻辑也位于控制器中。再次强调,实现这一点不如看到与存储系统通信的逻辑位于控制器中重要。

MVC 中的最后一个字母是模型。在这种情况下,它是一个非常轻量级的模型:

class Model {
  constructor(name, description, outerWallThickness, numberOfTowers, moat) {
    this.name = name;
    this.description = description;
    this.outerWallThickness = outerWallThickness;
    this.numberOfTowers = numberOfTowers;
    this.moat = moat;
  }
}

正如你所看到的,它只是跟踪构成应用程序状态的变量。

这种模式中的关注点得到了很好的分离,使得相对容易进行更改。

模型视图 Presenter

模型视图 PresenterMVP)模式与 MVC 非常相似。这是在微软世界中相当知名的模式,通常用于构建 WPF 和 Silverlight 应用程序。它也可以在纯 JavaScript 中使用。关键区别在于系统不同部分的交互方式以及它们的责任范围。

第一个区别是,使用 Presenter 时,Presenter 和 View 之间存在一对一的映射关系。这意味着在 MVC 模式中存在于控制器中的逻辑,用于选择正确的视图进行渲染,不存在。或者说它存在于模式关注范围之外的更高级别。选择正确的 Presenter 可能由路由工具处理。这样的路由器将检查参数并提供最佳的 Presenter 选择。MVP 模式中的信息流可以在这里看到:

Model View Presenter

Presenter 既知道 View 又知道 Model,但 View 不知道 Model,Model 也不知道 View。所有通信都通过 Presenter 传递。

Presenter 模式通常以大量的双向分发为特征。点击将在 Presenter 中触发,然后 Presenter 将更新模型并更新视图。前面的图表表明输入首先通过视图传递。在 MVP 模式的被动版本中,视图与消息几乎没有交互,因为它们被传递到 Presenter。然而,还有一种称为活动 MVP 的变体,允许视图包含一些额外的逻辑。

这种活跃版本的 MVP 对于 Web 情况可能更有用。它允许在视图中添加验证和其他简单逻辑。这减少了需要从客户端传递回 Web 服务器的请求数量。

让我们更新现有的代码示例,使用 MVP 而不是 MVC。

MVP 代码

让我们从视图开始:

class CreateCastleView {
  constructor(document, presenter) {
    this.document = document;
    this.presenter = presenter;
    this.document.getElementById("saveButton").addEventListener("click", this.saveCastle);
  }
  setCastleName(name) {
    this.document.getElementById("castleName").value = name;
  }
  getCastleName() {
    return this.document.getElementById("castleName").value;
  }
  setDescription(description) {
    this.document.getElementById("description").value = description;
  }
  getDescription() {
    return this.document.getElementById("description").value;
  }
  setOuterWallThickness(outerWallThickness) {
    this.document.getElementById("outerWallThickness").value = outerWallThickness;
  }
  getOuterWallThickness() {
    return this.document.getElementById("outerWallThickness").value;
  }
  setNumberOfTowers(numberOfTowers) {
    this.document.getElementById("numberOfTowers").value = numberOfTowers;
  }
  getNumberOfTowers() {
    return parseInt(this.document.getElementById("numberOfTowers").value);
  }
  setMoat(moat) {
    this.document.getElementById("moat").value = moat;
  }
  getMoat() {
    return this.document.getElementById("moat").value;
  }
  setValid(validationResult) {
  }
  saveCastle() {
    this.presenter.saveCastle();
  }
}

如你所见,视图的构造函数不再接受对模型的引用。这是因为 MVP 中的视图不知道使用的是哪个模型。这些信息由 Presenter 抽象掉。保留对 Presenter 的引用仍然在构造函数中,以便向 Presenter 发送消息。

没有模型的情况下,公共 setter 和 getter 方法的数量增加了。这些 setter 允许 Presenter 更新视图的状态。getter 提供了一个抽象,隐藏了视图存储状态的方式,并为 Presenter 提供了获取信息的途径。saveCastle函数不再向 Presenter 传递任何值。

Presenter 的代码如下:

class CreateCastlePresenter {
  constructor(document) {
    this.document = document;
    this.model = new CreateCastleModel();
    this.view = new CreateCastleView(document, this);
  }
  saveCastle() {
    var data = {
      name: this.view.getCastleName(),
      description: this.view.getDescription(),
      outerWallThickness: this.view.getOuterWallThickness(),
      numberOfTowers: this.view.getNumberOfTowers(),
      moat: this.view.getMoat()
    };
    var validationResult = this.validate(data);
    if (validationResult.IsValid) {
      //write to the model
      this.saveCastleSuccess(data);
    }
    else {
      this.view.setValid(validationResult);
    }
  }
  saveCastleSuccess(data) {
    //redirect to different presenter
  }
  validate(model) {
    var validationResult = new validationResult();
    if (!model.name || model.name === "") {
      validationResult.IsValid = false;
      validationResult.Errors.push("Name is required");
    }
    return;
  }
}

你可以看到,视图现在以持久的方式在 Presenter 中被引用。saveCastle方法调用视图以获取其数值。然而,Presenter 确保使用视图的公共方法而不是直接引用文档。saveCastle方法更新了模型。如果存在验证错误,它将回调视图以更新IsValid标志。这是我之前提到的双重分发的一个例子。

最后,模型与以前一样保持不变。我们将验证逻辑保留在 Presenter 中。在哪个级别进行验证,模型还是 Presenter,不如在整个应用程序中一致地进行验证更重要。

MVP 模式再次是构建用户界面的一个相当有用的模式。视图和模型之间更大的分离创建了一个更严格的 API,允许更好地适应变化。然而,这是以更多的代码为代价的。更多的代码意味着更多的错误机会。

模型视图视图模型

我们将在本章中看到的最后一种模式是模型视图视图模型模式,更常被称为 MVVM。到现在为止,这种模式应该已经相当熟悉了。再次,你可以在这个插图中看到组件之间的信息流:

模型视图视图模型

你可以看到这里许多相同的构造已经回来了,但它们之间的通信有些不同。

在这种变化中,以前的控制器和 Presenter 现在是视图模型。就像 MVC 和 MVP 一样,大部分逻辑都在中心组件中,这里是视图模型。MVVM 中的模型本身实际上非常简单。通常,它只是一个简单地保存数据的信封。验证是在视图模型中完成的。

就像 MVP 一样,视图完全不知道模型的存在。不同之处在于,在 MVP 中,视图知道自己在与某个中间类交谈。它调用方法而不是简单地设置值。在 MVVM 中,视图认为视图模型就是它的视图。它不是调用像saveCastle这样的操作并传递数据,或者等待数据被请求,而是在字段发生变化时更新视图模型上的字段。实际上,视图上的字段与视图模型绑定。视图模型可以通过将这些值代理到模型,或者等到调用保存等类似操作时传递数据。

同样地,对视图模型的更改应立即在视图中反映出来。一个视图可能有多个视图模型。这些视图模型中的每一个可能会向视图推送更新,或者通过视图向其推送更改。

让我们来看一个真正基本的实现,然后我们将讨论如何使其更好。

MVVM 代码

天真的视图实现,坦率地说,是一团糟:

var CreateCastleView = (function () {
  function CreateCastleView(document, viewModel) {
    this.document = document;
    this.viewModel = viewModel;
    var _this = this;
    this.document.getElementById("saveButton").addEventListener("click", function () {
    return _this.saveCastle();
  });
  this.document.getElementById("name").addEventListener("change", this.nameChangedInView);
  this.document.getElementById("description").addEventListener("change", this.descriptionChangedInView);
  this.document.getElementById("outerWallThickness").addEventListener("change", this.outerWallThicknessChangedInView);
  this.document.getElementById("numberOfTowers").addEventListener("change", this.numberOfTowersChangedInView);
  this.document.getElementById("moat").addEventListener("change",this.moatChangedInView);
}
CreateCastleView.prototype.nameChangedInView = function (name) {
  this.viewModel.nameChangedInView(name);
};

CreateCastleView.prototype.nameChangedInViewModel = function (name) {
  this.document.getElementById("name").value = name;
};
//snipped more of the same
CreateCastleView.prototype.isValidChangedInViewModel = function (validationResult) {
  this.document.getElementById("validationWarning").innerHtml = validationResult.Errors;
  this.document.getElementById("validationWarning").className = "visible";
};
CreateCastleView.prototype.saveCastle = function () {
  this.viewModel.saveCastle();
};
return CreateCastleView;
})();
CastleDesign.CreateCastleView = CreateCastleView;

这是高度重复的,每个属性都必须被代理回ViewModel。我截断了大部分代码,但总共有大约 70 行。视图模型内部的代码同样糟糕:

var CreateCastleViewModel = (function () {
  function CreateCastleViewModel(document) {
    this.document = document;
    this.model = new CreateCastleModel();
    this.view = new CreateCastleView(document, this);
  }
  CreateCastleViewModel.prototype.nameChangedInView = function (name) {
    this.name = name;
  };

  CreateCastleViewModel.prototype.nameChangedInViewModel = function (name) {
    this.view.nameChangedInViewModel(name);
  };
  //snip
  CreateCastleViewModel.prototype.saveCastle = function () {
    var validationResult = this.validate();
    if (validationResult.IsValid) {
      //write to the model
      this.saveCastleSuccess();
    } else {
      this.view.isValidChangedInViewModel(validationResult);
    }
  };

  CreateCastleViewModel.prototype.saveCastleSuccess = function () {
    //do whatever is needed when save is successful.
    //Possibly update the view model
  };

  CreateCastleViewModel.prototype.validate = function () {
    var validationResult = new validationResult();
    if (!this.name || this.name === "") {
      validationResult.IsValid = false;
        validationResult.Errors.push("Name is required");
    }
    return;
  };
  return CreateCastleViewModel;
})();

看一眼这段代码就会让你望而却步。它的设置方式会鼓励复制粘贴编程:这是引入代码错误的绝佳方式。我真希望有更好的方法来在模型和视图之间传递变化。

在模型和视图之间传递变化的更好方法

你可能已经注意到,有许多 MVVM 风格的 JavaScript 框架。显然,如果它们遵循我们在前一节中描述的方法,它们就不会被广泛采用。相反,它们遵循两种不同的方法之一。

第一种方法被称为脏检查。在这种方法中,与视图模型的每次交互之后,我们都会遍历其所有属性,寻找变化。当发现变化时,视图中的相关值将被更新为新值。对于视图中值的更改,操作都附加到所有控件上。然后这些操作会触发对视图模型的更新。

对于大型模型来说,这种方法可能会很慢,因为遍历大型模型的所有属性是昂贵的。可以导致模型变化的事情很多,而且没有真正的方法来判断是否通过更改另一个字段来改变模型中的一个远程字段,而不去验证它。但好处是,脏检查允许你使用普通的 JavaScript 对象。不需要像以前那样编写代码。而另一种方法——容器对象则不然。

使用容器对象提供了一个特殊的接口,用于包装现有对象,以便直接观察对象的变化。基本上这是观察者模式的应用,但是动态应用,因此底层对象并不知道自己正在被观察。间谍模式,也许?

这里举个例子可能会有所帮助。假设我们现在使用的是之前的模型对象:

var CreateCastleModel = (function () {
  function CreateCastleModel(name, description, outerWallThickness, numberOfTowers, moat) {
    this.name = name;
    this.description = description;
    this.outerWallThickness = outerWallThickness;
    this.numberOfTowers = numberOfTowers;
    this.moat = moat;
  }
  return CreateCastleModel;
})();

然后,model.name不再是一个简单的字符串,我们会在其周围包装一些函数。在 Knockout 库的情况下,代码如下所示:

var CreateCastleModel = (function () {
  function CreateCastleModel(name, description, outerWallThickness, numberOfTowers, moat) {
    this.name = ko.observable(name);
    this.description = ko.observable(description);
    this.outerWallThickness = ko.observable(outerWallThickness);
    this.numberOfTowers = ko.observable(numberOfTowers);
    this.moat = ko.observable(moat);
  }
  return CreateCastleModel;
})();

在上面的代码中,模型的各种属性都被包装成可观察的。这意味着它们现在必须以不同的方式访问:

var model = new CreateCastleModel();
model.name("Winterfell"); //set
model.name(); //get

这种方法显然给你的代码增加了一些摩擦,并且使得更改框架相当复杂。

当前的 MVVM 框架在处理容器对象与脏检查方面存在分歧。AngularJS 使用脏检查,而 Backbone、Ember 和 Knockout 都使用容器对象。目前还没有明显的赢家。

观察视图变化

幸运的是,Web 上 MV*模式的普及以及观察模型变化的困难并没有被忽视。你可能期待我说这将在 ECMAScript-2015 中得到解决,因为这是我的正常做法。奇怪的是,解决所有这些问题的Object.observe,是 ECMAScript-2016 讨论中的一个功能。然而,在撰写本文时,至少有一个主要浏览器已经支持它。

可以像下面这样使用:

var model = { };
Object.observe(model, function(changes){
  changes.forEach(function(change) {
    console.log("A " + change.type + " occured on " +  change.name + ".");
    if(change.type=="update")
      console.log("\tOld value was " + change.oldValue );
  });
});
model.item = 7;
model.item = 8;
delete model.item;

通过这个简单的接口来监视对象的变化,可以消除大型 MV框架提供的大部分逻辑。为 MV编写自己的功能将会更容易,实际上可能根本不需要使用外部框架。

提示和技巧

各种 MV*模式的不同层不一定都在浏览器上,也不一定都需要用 JavaScript 编写。许多流行的框架允许在服务器上维护模型,并使用 JSON 进行通信。

Object.observe可能还没有在所有浏览器上可用,但有一些 polyfill 可以用来创建类似的接口。性能不如原生实现好,但仍然可用。

总结

将关注点分离到多个层次可以确保应用程序的变化像防撕裂一样被隔离。各种 MV*模式允许在图形应用程序中分离关注点。各种模式之间的差异在于责任如何分离以及信息如何通信。

在下一章中,我们将探讨一些模式和技术,以改善开发和部署 JavaScript 到 Web 的体验。

第九章:网页模式

Node.js 的崛起证明了 JavaScript 在 Web 服务器上有一席之地,甚至是非常高吞吐量的服务器。毫无疑问,JavaScript 的传统仍然在客户端进行编程的浏览器中。

在本章中,我们将探讨一些模式,以提高客户端 JavaScript 的性能和实用性。我不确定所有这些是否都可以被认为是严格意义上的模式。然而,它们是重要的,值得一提。

本章我们将讨论以下概念:

  • 发送 JavaScript

  • 插件

  • 多线程

  • 断路器模式

  • 退避

  • 承诺

发送 JavaScript

将 JavaScript 传输给客户端似乎是一个简单的命题:只要您可以将代码传输给客户端,那么传输方式并不重要,对吗?嗯,不完全是这样。实际上,在将 JavaScript 发送到浏览器时需要考虑一些事情。

合并文件

在第二章中,组织代码,我们讨论了如何使用 JavaScript 构建对象,尽管对此的看法有所不同。我认为,将我的 JavaScript 或任何面向对象的代码组织成一个类对应一个文件的形式是一个好习惯。通过这样做,可以轻松找到代码。没有人需要在 9000 行长的 JavaScript 文件中寻找那个方法。它还允许建立一个层次结构,再次实现良好的代码组织。然而,对于开发人员的良好组织并不一定对计算机来说是良好的组织。在我们的情况下,拥有大量小文件实际上是非常有害的。要了解为什么,您需要了解一些关于浏览器如何请求和接收内容的知识。

当您在浏览器的地址栏中输入 URL 并按下Enter时,会发生一系列级联事件。首先,浏览器会要求操作系统将网站名称解析为 IP 地址。在 Windows 和 Linux(以及 OSX)上使用标准 C 库函数gethostbyname。此函数将检查本地 DNS 缓存,以查看名称到地址的映射是否已知。如果是,则返回该信息。如果不是,则计算机会向其上一级的 DNS 服务器发出请求。通常,这是由 ISP 提供的 DNS 服务器,但在较大的网络上也可能是本地 DNS 服务器。可以在此处看到 DNS 服务器之间的查询路径:

合并文件

如果服务器上不存在记录,则请求会在一系列 DNS 服务器之间传播,以尝试找到知道该域的服务器。最终,传播会停止在根服务器处。这些根服务器是查询的终点 - 如果它们不知道谁负责域的 DNS 信息,那么查找将被视为失败。

一旦浏览器有了站点的地址,它就会打开一个连接并发送对文档的请求。如果没有提供文档,则发送/。如果连接是安全的,则此时执行 SSL/TSL 的协商。建立加密连接会有一些计算开销,但这正在慢慢得到解决。

服务器将以 HTML 的形式做出响应。当浏览器接收到这个 HTML 时,它开始处理它;浏览器在整个 HTML 文档被下载之前并不等待。如果浏览器遇到一个外部于 HTML 的资源,它将启动一个新的请求,打开另一个连接到 Web 服务器并下载该资源。对于单个域的最大连接数是有限制的,以防止 Web 服务器被淹没。还应该提到,建立到 Web 服务器的新连接会带来开销。Web 客户端和服务器之间的数据流可以在这个插图中看到:

合并文件

应该限制与 Web 服务器的连接,以避免重复支付连接设置成本。这带我们来到我们的第一个概念:合并文件。

如果您已经遵循了在 JavaScript 中利用命名空间和类的建议,那么将所有 JavaScript 放在单个文件中就是一个微不足道的步骤。只需要将文件连接在一起,一切应该继续像往常一样工作。可能需要稍微注意一下包含的顺序,但通常不需要。

我们之前编写的代码基本上是每个模式一个文件。如果需要使用多个模式,那么我们可以简单地将文件连接在一起。例如,组合的生成器和工厂方法模式可能如下所示:

var Westeros;
(function (Westeros) {
  (function (Religion) {
      …
  })(Westeros.Religion || (Westeros.Religion = {}));
  var Religion = Westeros.Religion;
})(Westeros || (Westeros = {}));
(function (Westeros) {
  var Tournament = (function () {
    function Tournament() {
  }
  return Tournament;
})();
Westeros.Tournament = Tournament;
…
})();
Westeros.Attendee = Attendee;
})(Westeros || (Westeros = {}));

可能会出现一个问题,即应该一次组合和加载多少 JavaScript。这是一个令人惊讶地难以回答的问题。一方面,希望在用户首次访问站点时为整个站点加载所有 JavaScript。这意味着用户最初会付出代价,但在浏览站点时不必下载任何额外的 JavaScript。这是因为浏览器将缓存脚本并重复使用它,而不是再次从服务器下载。然而,如果用户只访问站点上的一小部分页面,那么他们将加载许多不需要的 JavaScript。

另一方面,拆分 JavaScript 意味着额外的页面访问会因检索额外的 JavaScript 文件而产生惩罚。这两种方法之间存在一个平衡点。脚本可以被组织成映射到网站不同部分的块。这可能是再次使用适当的命名空间的地方。每个命名空间可以合并到一个文件中,然后在用户访问站点的那部分时加载。

最终,唯一有意义的方法是维护关于用户如何在站点上移动的统计信息。根据这些信息,可以建立一个找到平衡点的最佳策略。

缩小

将 JavaScript 合并到单个文件中解决了限制请求数量的问题。然而,每个请求可能仍然很大。我们再次面临一个问题,即如何使代码对人类快速可读与对计算机快速可读之间的分歧。

我们人类喜欢描述性的变量名,丰富的空格和适当的缩进。计算机不在乎描述性名称,空格或适当的缩进。事实上,这些东西会增加文件的大小,从而减慢代码的阅读速度。

缩小是一个将人类可读的代码转换为更小但等效的代码的编译步骤。外部变量的名称保持不变,因为缩小器无法知道其他代码可能依赖于变量名称保持不变。

例如,如果我们从第四章的组合代码开始,结构模式,压缩后的代码如下所示:

var Westros;(function(Westros){(function(Food){var SimpleIngredient=(function(){function SimpleIngredient(name,calories,ironContent,vitaminCContent){this.name=name;this.calories=calories;this.ironContent=ironContent;this.vitaminCContent=vitaminCContent}SimpleIngredient.prototype.GetName=function(){return this.name};SimpleIngredient.prototype.GetCalories=function(){return this.calories};SimpleIngredient.prototype.GetIronContent=function(){return this.ironContent};SimpleIngredient.prototype.GetVitaminCContent=function(){return this.vitaminCContent};return SimpleIngredient})();Food.SimpleIngredient=SimpleIngredient;var CompoundIngredient=(function(){function CompoundIngredient(name){this.name=name;this.ingredients=new Array()}CompoundIngredient.prototype.AddIngredient=function(ingredient){this.ingredients.push(ingredient)};CompoundIngredient.prototype.GetName=function(){return this.name};CompoundIngredient.prototype.GetCalories=function(){var total=0;for(var i=0;i<this.ingredients.length;i++){total+=this.ingredients[i].GetCalories()}return total};CompoundIngredient.prototype.GetIronContent=function(){var total=0;for(var i=0;i<this.ingredients.length;i++){total+=this.ingredients[i].GetIronContent()}return total};CompoundIngredient.prototype.GetVitaminCContent=function(){var total=0;for(var i=0;i<this.ingredients.length;i++){total+=this.ingredients[i].GetVitaminCContent()}return total};return CompoundIngredient})();Food.CompoundIngredient=CompoundIngredient})(Westros.Food||(Westros.Food={}));var Food=Westros.Food})(Westros||(Westros={}));

您会注意到所有空格已被移除,并且任何内部变量都已被替换为较小的版本。与此同时,您可以看到一些众所周知的变量名保持不变。

缩小使这段特定代码节省了 40%。使用 gzip 对服务器的内容流进行压缩是一种流行的方法,是无损压缩。这意味着压缩和未压缩之间存在完美的双射。另一方面,缩小是一种有损压缩。一旦进行了缩小,就无法仅从缩小的代码中恢复到未缩小的代码。

注意

您可以在betterexplained.com/articles/how-to-optimize-your-site-with-gzip-compression/了解更多关于 gzip 压缩的信息。

如果需要返回到原始代码,则可以使用源映射。源映射是提供从一种代码格式到另一种代码格式的转换的文件。它可以被现代浏览器中的调试工具加载,允许您调试原始代码而不是难以理解的压缩代码。多个源映射可以组合在一起,以允许从压缩代码到未压缩的 JavaScript 到 TypeScript 的转换。

有许多工具可用于构建压缩和合并的 JavaScript。Gulp 和 Grunt 是构建 JavaScript 资产管道的基于 JavaScript 的工具。这两个工具都调用外部工具(如 Uglify)来执行实际工作。Gulp 和 Grunt 相当于 GNU Make 或 Ant。

内容交付网络

最终交付的技巧是利用内容交付网络CDN)。CDN 是分布式主机网络,其唯一目的是提供静态内容。就像浏览器在网站页面之间缓存 JavaScript 一样,它也会缓存在多个 Web 服务器之间共享的 JavaScript。因此,如果您的网站使用 jQuery,从知名 CDN(如code.jquery.com/或 Microsoft 的 ASP.net CDN)获取 jQuery 可能会更快,因为它已经被缓存。从 CDN 获取还意味着内容来自不同的域,并且不计入对服务器的有限连接。引用 CDN 就像将脚本标签的源设置为指向 CDN 一样简单。

再次,需要收集一些指标,以查看是更好使用 CDN 还是将库简单地合并到 JavaScript 包中。此类指标的示例可能包括执行额外 DNS 查找所需的时间以及下载大小的差异。最佳方法是使用浏览器中的时间 API。

将 JavaScript 分发到浏览器的长短是需要实验的。测试多种方法并测量结果将为最终用户带来最佳结果。

插件

在野外有许多令人印象深刻的 JavaScript 库。对我来说,改变了我对 JavaScript 的看法的库是 jQuery。对于其他人来说,可能是其他流行的库,比如 MooTool、Dojo、Prototype 或 YUI。然而,jQuery 在流行度上迅速增长,并且在撰写本文时,已经赢得了 JavaScript 库之争。互联网上前一万个网站中有 78.5%使用了某个版本的 jQuery。其他库甚至没有超过 1%。

许多开发人员已经决定在这些基础库的基础上实现自己的库,以插件的形式。插件通常修改库暴露的原型并添加额外的功能。语法是这样的,对于最终开发人员来说,它看起来就像是核心库的一部分。

构建插件的方式取决于您要扩展的库。尽管如此,让我们看看如何为 jQuery 构建插件,然后为我最喜欢的库之一 d3 构建插件。我们将看看是否可以提取一些共同点。

jQuery

jQuery 的核心是名为sizzle.js的 CSS 选择器库。正是 sizzle 负责 jQuery 可以使用 CSS3 选择器在页面上选择项目的所有非常聪明的方法。使用 jQuery 在页面上选择元素如下:

$(":input").css("background-color", "blue");

在这里,返回了一个 jQuery 对象。jQuery 对象的行为很像,尽管不完全像数组。这是通过在 jQuery 对象上创建一系列从 0 到 n-1 的键来实现的,其中 n 是选择器匹配的元素数量。这实际上非常聪明,因为它使得可以像访问数组一样访问器:

$($(":input")[2]).css("background-color", "blue");

虽然提供了大量额外的功能,但索引处的项目是普通的 HTML 元素,而不是用 jQuery 包装的,因此使用第二个$()

对于 jQuery 插件,我们通常希望使我们的插件扩展这个 jQuery 对象。因为它在每次选择器被触发时动态创建,我们实际上扩展了一个名为$.fn的对象。这个对象被用作创建所有 jQuery 对象的基础。因此,创建一个插件,将页面上所有输入框中的文本转换为大写,名义上就像下面这样简单:

$.fn.yeller = function(){
  this.each(function(_, item){
    $(item).val($(item).val().toUpperCase());
    return this;
  });
};

这个插件特别适用于发布到公告板,以及每当我的老板填写表格时。该插件遍历选择器选择的所有对象,并将它们的内容转换为大写。它也返回这个。通过这样做,我们允许链接额外的函数。您可以这样使用该函数:

$(function(){$("input").yeller();});

这在很大程度上取决于$变量被赋值给 jQuery。这并不总是这样,因为$是 JavaScript 库中一个常用的变量,可能是因为它是唯一一个既不是字母也不是数字,也没有特殊含义的字符。

为了解决这个问题,我们可以使用立即执行的函数,就像我们在第二章中所做的那样,组织代码

(function($){
  $.fn.yeller2 = function(){
    this.each(function(_, item){
      $(item).val($(item).val().toUpperCase());
      return this;
    });
  };
})(jQuery);

这里的额外优势是,如果我们的代码需要辅助函数或私有变量,它们可以在同一个函数内设置。您还可以传入所需的任何选项。jQuery 提供了一个非常有用的$.extend函数,它可以在对象之间复制属性,非常适合用于将一组默认选项与传入的选项扩展在一起。我们在之前的章节中详细讨论过这一点。

jQuery 插件文档建议尽量减少对 jQuery 对象的污染。这是为了避免多个插件之间使用相同名称而产生冲突。他们的解决方案是有一个单一的函数,具有不同的行为,取决于传入的参数。例如,jQuery UI 插件就使用了这种方法来创建对话框:

$(«.dialog»).dialog(«open»);
$(«.dialog»).dialog(«close»);

我更喜欢这样调用:

$(«.dialog»).dialog().open();
$(«.dialog»).dialog().close();

使用动态语言,实际上并没有太大的区别,但我更喜欢有良好命名的函数,可以通过工具发现,而不是魔术字符串。

d3

d3 是一个用于创建和操作可视化的优秀 JavaScript 库。大多数情况下,人们会将 d3 与可伸缩矢量图形一起使用,以制作像 Mike Bostock 的这个六边形图表一样的图形:

d3

d3 试图不对其创建的可视化类型发表意见。因此,它没有内置支持创建条形图等内容。然而,有一系列插件可以添加到 d3 中,以实现各种各样的图表,包括前面图中显示的六边形图表。

更重要的是,jQuery d3 强调创建可链接的函数。例如,这段代码是创建柱状图的片段。您可以看到所有的属性都是通过链接设置的:

var svg = d3.select(containerId).append("svg")
var bar = svg.selectAll("g").data(data).enter().append("g");
bar.append("rect")
.attr("height", yScale.rangeBand()).attr("fill", function (d, _) {
  return colorScale.getColor(d);
})
.attr("stroke", function (d, _) {
  return colorScale.getColor(d);
})
.attr("y", function (d, i) {
  return yScale(d.Id) + margins.height;
})

d3的核心是d3对象。该对象下挂了许多用于布局、比例尺、几何等的命名空间。除了整个命名空间,还有用于数组操作和从外部源加载数据的函数。

d3创建一个插件的开始是决定我们将在代码中插入的位置。让我们构建一个创建新颜色比例尺的插件。颜色比例尺用于将一组值的定义域映射到一组颜色的值域。例如,我们可能希望将以下四个值的定义域映射到四种颜色的值域:

d3

让我们插入一个函数来提供一个新的颜色比例尺,这种情况下支持分组元素。比例尺是一个将定义域映射到值域的函数。对于颜色比例尺,值域是一组颜色。一个例子可能是一个函数,将所有偶数映射到红色,所有奇数映射到白色。在表格上使用这个比例尺会产生斑马条纹:

d3.scale.groupedColorScale = function () {
  var domain, range;

  function scale(x) {
    var rangeIndex = 0;
    domain.forEach(function (item, index) {
      if (item.indexOf(x) > 0)
        rangeIndex = index;
    });
    return range[rangeIndex];
  }

  scale.domain = function (x) {
    if (!arguments.length)
      return domain;
    domain = x;
    return scale;
  };

  scale.range = function (x) {
    if (!arguments.length)
      return range;
    range = x;
    return scale;
  };
  return scale;
};

我们只需将这个插件附加到现有的d3.scale对象上。这可以通过简单地给定一个数组作为定义域和一个数组作为值域来使用:

var s = d3.scale.groupedColorScale().domain([[1, 2, 3], [4, 5]]).range(["#111111", "#222222"]);
s(3); //#111111
s(4); //#222222

这个简单的插件扩展了 d3 的比例尺功能。我们可以替换现有的功能,甚至包装它,使得对现有功能的调用可以通过我们的插件代理。

插件通常并不难构建,但它们在不同的库中可能有所不同。重要的是要注意库中现有变量名,以免覆盖它们,甚至覆盖其他插件提供的功能。一些建议使用字符串前缀来避免覆盖。

如果库在设计时考虑到了这一点,可能会有更多的地方可以挂接。一个流行的方法是提供一个选项对象,其中包含用于挂接我们自己的函数作为事件处理程序的可选字段。如果没有提供任何内容,函数将继续正常运行。

同时做两件事情-多线程

同时做两件事情是困难的。多年来,计算机世界的解决方案要么是使用多个进程,要么是使用多个线程。由于不同操作系统上的实现差异,两者之间的区别模糊不清,但线程通常是进程的轻量级版本。浏览器上的 JavaScript 不支持这两种方法。

在浏览器上历史上并没有真正需要多线程。JavaScript 被用来操作用户界面。在操作用户界面时,即使在其他语言和窗口环境中,也只允许一个线程同时操作。这避免了对用户来说非常明显的竞争条件。

然而,随着 JavaScript 在流行度上的增长,越来越复杂的软件被编写以在浏览器内运行。有时,这些软件确实可以从在后台执行复杂计算中受益。

Web workers 为浏览器提供了一种同时进行两件事情的机制。虽然这是一个相当新的创新,但 Web workers 现在在主流浏览器中得到了很好的支持。实际上,工作线程是一个可以使用消息与主线程通信的后台线程。Web workers 必须在单个 JavaScript 文件中自包含。

使用 Web workers 相当容易。我们将回顾一下之前几章中我们看过的斐波那契数列的例子。工作进程监听消息如下:

self.addEventListener('message', function(e) {
  var data = e.data;
  if(data.cmd == 'startCalculation'){
    self.postMessage({event: 'calculationStarted'});
    var result = fib(data.parameters.number);
    self.postMessage({event: 'calculationComplete', result: result});
  };
}, false);

在这里,每当收到一个startCalculation消息时,我们就会启动一个新的fib实例。fib只是之前的朴素实现。

主线程从外部文件加载工作进程,并附加了一些监听器:

function startThread(){
  worker =  new Worker("worker.js");
  worker.addEventListener('message', function(message) {
    logEvent(message.data.event);
    if(message.data.event == "calculationComplete"){
      writeResult(message.data.result);
    }
    if(message.data.event == "calculationStarted"){
      document.getElementById("result").innerHTML = "working";
    }
  });
};

为了开始计算,只需要发送一个命令:

worker.postMessage({cmd: 'startCalculation', parameters: { number: 40}});

在这里,我们传递我们想要计算的序列中的项的编号。当计算在后台运行时,主线程可以自由地做任何它想做的事情。当从工作线程接收到消息时,它被放入正常的事件循环中进行处理:

同时做两件事情-多线程

如果你需要在 JavaScript 中进行耗时的计算,Web workers 可能对你有用。

如果你通过 Node.js 使用服务器端 JavaScript,那么进行多任务处理有不同的方法。Node.js 提供了分叉子进程的能力,并提供了一个与 Web worker 类似的接口来在子进程和父进程之间通信。不过这种方法会分叉整个进程,比使用轻量级线程更加消耗资源。

在 Node.js 中还存在一些其他工具,可以创建轻量级的后台工作进程。这些可能更接近于 Web 端存在的情况,而不是分叉子进程。

断路器模式

系统,即使是设计最好的系统,也会失败。系统越大、分布越广,失败的可能性就越高。许多大型系统,如 Netflix 或 Google,都内置了大量冗余。冗余并不会减少组件失败的可能性,但它们提供了备用方案。切换到备用方案通常对最终用户是透明的。

断路器模式是提供这种冗余的系统的常见组件。假设您的应用程序每五秒查询一次外部数据源,也许您正在轮询一些预计会发生变化的数据。当此轮询失败时会发生什么?在许多情况下,失败被简单地忽略,轮询继续进行。这实际上是客户端端的一个相当好的行为,因为数据更新并不总是至关重要。在某些情况下,失败会导致应用程序立即重试请求。在紧密的循环中重试服务器请求对客户端和服务器都可能有问题。客户端可能因为在循环中请求数据而变得无响应。

在服务器端,一个试图从失败中恢复的系统每五秒钟就会受到可能是成千上万的客户端的冲击。如果失败是由系统过载造成的,那么继续查询它只会使情况变得更糟。

断路器模式在达到一定数量的失败后停止尝试与正在失败的系统通信。基本上,重复的失败导致断路器被打开,应用程序停止查询。您可以在这个插图中看到断路器的一般模式:

断路器模式

对于服务器来说,随着失败的积累,客户端数量的减少为其提供了一些喘息的空间来恢复。请求风暴进来并使系统崩溃的可能性被最小化。

当然,我们希望断路器在某个时候重置,以便恢复服务。对此有两种方法,一种是客户端定期轮询(比以前频率低)并重置断路器,另一种是外部系统向其客户端通信服务已恢复。

退避

断路器模式的一个变种是使用某种形式的退避,而不是完全切断与服务器的通信。这是许多数据库供应商和云提供商建议的一种方法。如果我们最初的轮询间隔为五秒,那么当检测到失败时,将间隔更改为每 10 秒一次。重复这个过程,使用越来越长的间隔。

当请求重新开始工作时,更改时间间隔的模式将被颠倒。请求会越来越接近,直到恢复原始的轮询间隔。

监视外部资源可用性的状态是使用后台工作角色的理想场所。这项工作并不复杂,但它完全与主事件循环无关。

这再次减少了对外部资源的负载,为其提供了更多的喘息空间。它还使客户端不会因过多的轮询而负担过重。

使用 jQuery 的ajax函数的示例如下:

$.ajax({
  url : 'someurl',
  type : 'POST',
  data :  ....,
  tryCount : 0,
  retryLimit : 3,
  success : function(json) {
    //do something
  },
  error : function(xhr, textStatus, errorThrown ) {
    if (textStatus == 'timeout') {
      this.tryCount++;
      **if (this.tryCount <= this.retryLimit) {** 

 **//try again** 

 **$.ajax(this);** 

 **return;** 

      }
      return;
    }
    if (xhr.status == 500) {
      //handle error
    } else {
      //handle error
    }
  }
});

您可以看到,突出显示的部分重新尝试查询。

这种退避方式实际上在以太网中用于避免重复的数据包碰撞。

降级的应用程序行为

您的应用程序呼叫外部资源很可能有很好的理由。退避并不查询数据源是完全合理的,但仍然希望用户能够与网站进行交互。解决这个问题的一个方法是降低应用程序的行为。

例如,如果您的应用程序显示实时股票报价信息,但提供股票信息的系统出现故障,那么可以替换为一个不太实时的服务。现代浏览器有许多不同的技术,允许在客户端计算机上存储少量数据。这个存储空间非常适合缓存一些旧版本的数据,以防最新版本不可用。

即使在应用程序向服务器发送数据的情况下,也可以降级行为。通常可以在本地保存数据更新,然后在服务恢复时一起发送它们。当用户离开页面时,任何后台工作都将终止。如果用户再也不返回网站,那么他们排队发送到服务器的任何更新都将丢失。

注意

一个警告:如果您采取这种方法,最好警告用户他们的数据已经过时,特别是如果您的应用程序是股票交易应用程序。

承诺模式

我之前说过 JavaScript 是单线程的。这并不完全准确。JavaScript 中有一个单一的事件循环。用长时间运行的进程阻塞这个事件循环被认为是不好的形式。当您的贪婪算法占用所有 CPU 周期时,其他事情就无法发生了。

当您在 JavaScript 中启动异步函数,比如从远程服务器获取数据时,很多活动都发生在不同的线程中。成功或失败处理程序函数在主事件线程中执行。这也是成功处理程序被编写为函数的部分原因:它允许它们在不同的上下文之间轻松传递。

因此,确实有一些活动是以异步、并行的方式发生的。当async方法完成后,结果将传递给我们提供的处理程序,并且处理程序将被放入事件队列中,在事件循环重复时下次被接收。通常,事件循环每秒运行数百次或数千次,具体取决于每次迭代需要做多少工作。

从语法上讲,我们将消息处理程序编写为函数并将其挂钩:

var xmlhttp = new XMLHttpRequest(); 
xmlhttp.onreadystatechange = function() { 
  if (xmlhttp.readyState === 4){ 
    alert(xmlhttp.readyState); 
  }
;};

如果情况简单,这是合理的。然而,如果您想要对回调的结果执行一些额外的异步操作,那么您最终会得到嵌套的回调。如果需要添加错误处理,也是使用回调来完成。等待多个回调返回并协调您的响应的复杂性会迅速上升。

承诺模式提供了一些语法帮助来清理异步困难。如果我们采取一个常见的异步操作,比如使用 jQuery 通过 XMLHttp 请求检索数据,那么代码会同时接受错误和成功函数。它可能看起来像下面这样:

$.ajax("/some/url",
{ success: function(data, status){},
  error: function(jqXHR, status){}
});

使用承诺而不是会使代码看起来更像下面这样:

$.ajax("/some/url").then(successFunction, errorFunction);

在这种情况下,$.ajax方法返回一个包含值和状态的承诺对象。当异步调用完成时,该值将被填充。状态提供了有关请求当前状态的一些信息:它是否已完成,是否成功?

承诺还有许多在其上调用的函数。then()函数接受一个成功和一个错误函数,并返回一个额外的承诺。如果成功函数同步运行,那么承诺会立即返回为已实现。否则,它将保持在一个工作状态中,即待定状态,直到异步成功触发为止。

在我看来,jQuery 实现承诺的方法并不理想。他们的错误处理没有正确区分承诺未能实现和已失败但已处理的承诺。这使得 jQuery 的承诺与承诺的一般概念不兼容。例如,无法执行以下操作:

$.ajax("/some/url").then(
  function(data, status){},
  function(jqXHR, status){
    //handle the error here and return a new promise
  }
).then(/*continue*/);

尽管错误已经被处理并返回了一个新的承诺,但处理将会终止。如果函数能够被写成以下形式将会更好:

$.ajax("/some/url").then(function(data, status){})
.catch(function(jqXHR, status){
  //handle the error here and return a new promise
})
.then(/*continue*/);

关于在 jQuery 和其他库中实现承诺的讨论很多。由于这些讨论,当前提出的承诺规范与 jQuery 的承诺规范不同,并且不兼容。Promises/A+是许多承诺库(如when.js和 Q)满足的认证。它也构成了 ECMAScript-2015 所带来的承诺规范的基础。

承诺为同步和异步函数之间提供了桥梁,实际上将异步函数转换为可以像同步函数一样操作的东西。

如果承诺听起来很像我们在前几章看到的惰性评估模式,那么你完全正确。承诺是使用惰性评估构建的,对它们调用的操作被排队在对象内部,而不是立即评估。这是函数模式的一个很好的应用,甚至可以实现以下场景:

when(function(){return 2+2;})
.delay(1000)
.then(function(promise){ console.log(promise());})

承诺极大地简化了 JavaScript 中的异步编程,并且应该被考虑用于任何在性质上是高度异步的项目中。

提示和技巧

ECMAScript 2015 的承诺在大多数浏览器上得到了很好的支持。如果需要支持旧版浏览器,那么有一些很棒的 shim 可以添加功能而不会增加太多开销。

当检查从远程服务器检索 JavaScript 的性能时,大多数现代浏览器都提供了工具来查看资源加载的时间轴。这个时间轴将显示浏览器何时在等待脚本下载,以及何时在解析脚本。使用这个时间轴可以进行实验,找到加载脚本或一系列脚本的最佳方式。

摘要

在本章中,我们看了一些改进 JavaScript 开发体验的模式或方法。我们关注了一些关于传递到浏览器的问题。我们还看了如何针对一些库实现插件并推断出一般的实践。接下来,我们看了如何在 JavaScript 中处理后台进程。断路器被建议作为保持远程资源获取的方法。最后,我们研究了承诺如何改进异步代码的编写。

在下一章中,我们将花费更多的时间来研究消息模式。我们已经稍微了解了如何使用 web worker,但在下一节中我们将大量扩展。

第十章:消息模式

当 Smalltalk,第一个真正的面向对象的编程语言,首次开发时,类之间的通信被设想为消息。不知何故,我们已经偏离了这个纯粹的消息理念。我们稍微谈到了函数式编程如何避免副作用,同样,基于消息的系统也是如此。

消息还可以实现令人印象深刻的可伸缩性,因为消息可以传播到数十甚至数百台计算机。在单个应用程序中,消息传递促进了低耦合和测试的便利性。

在本章中,我们将看一些与消息相关的模式。在本章结束时,您应该知道消息是如何工作的。当我第一次了解消息时,我想用它重写一切。

我们将涵盖以下主题:

  • 消息到底是什么?

  • 命令

  • 事件

  • 请求-回复

  • 发布-订阅

  • 扇出

  • 死信队列

  • 消息重播

  • 管道和过滤器

消息到底是什么?

在最简单的定义中,消息是一组相关的数据位,它们一起具有一定的含义。消息的命名方式提供了一些额外的含义。例如,AddUserRenameUser消息可能具有以下字段:

  • 用户 ID

  • 用户名

但是,这些字段存在于命名容器内的事实赋予了它们不同的含义。

消息通常与应用程序中的某个操作或业务中的某个操作相关。消息包含接收者执行操作所需的所有信息。在RenameUser消息的情况下,消息包含足够的信息,以便任何跟踪用户 ID 和用户名之间关系的组件更新其用户名值。

许多消息系统,特别是在应用程序边界之间通信的系统,还定义了信封。信封上有元数据,可以帮助消息审计、路由和安全性。信封上的信息不是业务流程的一部分,而是基础设施的一部分。因此,在信封上有安全注释是可以的,因为安全性存在于正常业务工作流程之外,并由应用程序的不同部分拥有。信封上的内容看起来像下图所示的内容:

消息到底是什么?

消息应该被封闭,以便在创建后不能对其进行更改。这使得诸如审计和重播等操作更加容易。

消息可以用于在单个进程内进行通信,也可以用于应用程序之间进行通信。在大多数情况下,在应用程序内部发送消息和应用程序之间发送消息没有区别。一个区别是同步处理的处理。在单个进程内,消息可以以同步方式处理。这意味着主要处理在继续之前会等待消息的处理完成。

在异步场景中,消息的处理可能会在以后的某个时间发生。有时,这个时间可能是遥远的未来。当调用外部服务器时,异步肯定是正确的方法——这是由于与网络 I/O 相关的固有延迟。即使在单个进程内,JavaScript 的单线程特性也鼓励使用异步消息传递。在使用异步消息传递时,需要额外的注意和关注,因为一些针对同步消息传递所做的假设不再安全。例如,假设消息将按照发送顺序进行回复不再安全。

消息有两种不同的类型:命令和事件。命令指示发生的事情,而事件通知发生的事情。

命令

命令只是系统的一部分向另一部分发出的指令。这是一条消息,因此实际上只是一个简单的数据传输对象。如果回想一下在第五章中介绍的命令模式,行为模式,这正是它所使用的。

作为惯例,命令使用命令式命名。格式通常是<动词><对象>。因此,一个命令可能被称为InvadeCity。通常,在命名命令时,您希望避免使用通用名称,而是专注于导致命令的确切原因。

例如,考虑一个更改用户地址的命令。您可能会简单地称该命令为ChangeAddress,但这样做并没有添加任何额外的信息。更好的做法是深入挖掘并查看为什么要更改地址。是因为人搬家了,还是原始地址输入错误了?意图与实际数据更改一样重要。例如,由于错误而更改地址可能会触发与搬家的人不同的行为。搬家的用户可以收到搬家礼物,而更正地址的用户则不会。

消息应该具有业务含义的组件,以增加它们的效用。在复杂业务中定义消息以及它们如何构造是一个独立的研究领域。有兴趣的人可能会对领域驱动设计DDD)感兴趣。

命令是针对特定组件的指令,用于给其下达执行任务的指示。

在浏览器的上下文中,你可以认为命令是在按钮上触发的点击。命令被转换为事件,而事件则传递给你的事件监听器。

只有一个端点应该接收特定的命令。这意味着只有一个组件负责执行动作。一旦一个命令被多个端点执行,就会引入任意数量的竞争条件。如果其中一个端点接受了命令,而另一个将其拒绝为无效呢?即使在发出了几个几乎相同的命令的情况下,它们也不应该被聚合。例如,从国王发送一个命令给他的所有将军应该给每个将军发送一个命令。

因为只有一个端点可以接收命令,所以该端点有可能验证甚至取消命令。命令的取消不应对应用程序的其余部分产生影响。

当执行了一个命令,就可能发布一个或多个事件。

事件

事件是一种特殊的消息,用于通知发生了某事。试图更改或取消事件是没有意义的,因为它只是通知发生了某事。除非你拥有一辆德洛雷安,否则你无法改变过去。

事件的命名约定是使用过去时。你可能会看到命令中单词顺序的颠倒,因此一旦InvadeCity命令成功,我们可能会得到CityInvaded

与命令不同,事件可以被任意数量的组件接收。这种方法不会产生真正的竞争条件。由于没有消息处理程序可以更改消息或干扰其他副本消息的传递,每个处理程序都与其他处理程序隔离开来。

你可能对事件有所了解,因为你做过用户界面工作。当用户点击按钮时,事件就会“触发”。实际上,事件会广播给一系列监听器。你可以通过连接到该事件来订阅消息:

document.getElementById("button1").addEventListener("click", doSomething);

浏览器中的事件并不完全符合我在前面段落中给出的事件定义。这是因为浏览器中的事件处理程序可以取消事件并阻止其传播到下一个处理程序。也就是说,当同一消息有一系列事件处理程序时,其中一个可以完全消耗该消息,不将其传递给后续处理程序。这样的方法当然有其用处,但也会引入一些混乱。幸运的是,对于 UI 消息,处理程序的数量通常相当少。

在某些系统中,事件可能具有多态性质。也就是说,如果我有一个名为IsHiredSalary的事件,当有人被聘用为有薪角色时会触发该事件,我可以将其作为消息IsHired的后代。这样做可以让订阅了IsHiredSalaryIsHired的处理程序在接收到IsHiredSalary事件时都被触发。JavaScript 并没有真正意义上的多态性,因此这样的东西并不特别有用。你可以添加一个消息字段来代替多态性,但看起来有些混乱:

var IsHiredSalary = { __name: "isHiredSalary",
  __alsoCall: ["isHired"],
  employeeId: 77,
  …
}

在这种情况下,我使用__来表示信封中的字段。你也可以构建具有消息和信封的单独字段的消息,这并不那么重要。

让我们来看一个简单的操作,比如创建用户,以便我们可以看到命令和事件是如何交互的:

事件

在这里,用户输入数据到表单并提交。Web 服务器接收输入,验证它,如果正确,创建一个命令。现在命令被发送到命令处理程序。命令处理程序执行一些操作,也许写入数据库,然后发布一个事件,被多个事件监听器消费。这些事件监听器可能发送确认电子邮件,通知系统管理员,或者执行任何数量的操作。

所有这些看起来很熟悉,因为系统已经包含了命令和事件。不同之处在于,我们现在明确地对命令和事件进行建模。

请求-响应

您将在消息传递中看到的最简单的模式是请求-响应模式。也称为请求-响应,这是一种检索由应用程序的另一部分拥有的数据的方法。

在许多情况下,发送命令是一个异步操作。命令被触发后,应用程序流程会继续进行。因此,没有简单的方法来执行诸如按 ID 查找记录之类的操作。相反,需要发送一个命令来检索记录,然后等待相关事件的返回。正常的工作流程如下图所示:

请求-响应

大多数事件可以被任意数量的监听器订阅。虽然可能对请求-响应模式有多个事件监听器,但这不太可能,也可能不可取。

我们可以在这里实现一个非常简单的请求-响应模式。在维斯特洛,发送及时消息存在一些问题。没有电力,通过乌鸦的腿传递消息是唯一可以快速实现的远距离传递消息的方法。因此我们有了一个乌鸦消息系统。

我们将从构建我们称之为总线开始。总线只是消息的分发机制。它可以在进程中实现,就像我们在这里做的一样,也可以在进程外实现。如果在进程外实现,有许多选项,从轻量级消息队列 0mq,到更全面的消息系统 RabbitMQ,再到建立在数据库和云端的各种系统。这些系统在消息的可靠性和持久性方面表现出一些不同的行为。重要的是要对消息分发系统的工作方式进行一些研究,因为它们可能决定应用程序的构建方式。它们还实现了不同的方法来处理应用程序的基本不可靠性:

class CrowMailBus {
  constructor(requestor) {
    this.requestor = requestor;
    this.responder = new CrowMailResponder(this);
  }
  Send(message) {
    if (message.__from == "requestor") {
      this.responder.processMessage(message);
    }
    else {
      this.requestor.processMessage(message);
    }
  }
}

一个潜在的问题是客户端接收消息的顺序不一定是发送消息的顺序。为了解决这个问题,通常会包含某种相关 ID。当事件被触发时,它会包含来自发送方的已知 ID,以便使用正确的事件处理程序。

这个总线是一个非常天真的总线,因为它的路由是硬编码的。一个真正的总线可能允许发送者指定交付的终点地址。或者,接收者可以注册自己对特定类型的消息感兴趣。然后总线将负责进行一些有限的路由来指导消息。我们的总线甚至以它处理的消息命名 - 这显然不是一种可扩展的方法。

接下来我们将实现请求者。请求者只包含两种方法:一个用于发送请求,另一个用于从总线接收响应:

class CrowMailRequestor {
  Request() {
    var message = { __messageDate: new Date(),
    __from: "requestor",
    __corrolationId: Math.random(),
    body: "Hello there. What is the square root of 9?" };
    var bus = new CrowMailBus(this);
    bus.Send(message);
    console.log("message sent!");
  }
  processMessage(message) {
    console.dir(message);
  }
}

处理消息函数目前只是记录响应,但在实际情况下可能会执行更多操作,比如更新 UI 或分派另一个消息。相关 ID 对于理解回复与发送消息的关联非常宝贵。

最后,响应者只是接收消息并用另一条消息回复。

class CrowMailResponder {
  constructor(bus) {
    this.bus = bus;
  }
  processMessage(message) {
    var response = { __messageDate: new Date(),
    __from: "responder",
    __corrolationId: message.__corrolationId,
    body: "Okay invaded." };
    this.bus.Send(response);
    console.log("Reply sent");
  }
}

我们示例中的一切都是同步的,但要使其异步化只需要更换总线。如果我们在 node 中工作,可以使用process.nextTick来实现这一点,它只是将一个函数推迟到事件循环的下一次。如果我们在 web 上下文中,那么可以使用 web workers 在另一个线程中进行处理。实际上,启动 web worker 时,与其来回通信采用消息的形式:

class CrowMailBus {
  constructor(requestor) {
    this.requestor = requestor;
    this.responder = new CrowMailResponder(this);
  }
  Send(message) {
    if (message.__from == "requestor") {
      process.nextTick(() => this.responder.processMessage(message));
    }
    else {
      process.nextTick(() => this.requestor.processMessage(message));
    }
  }
}

这种方法现在允许其他代码在消息被处理之前运行。如果我们在每次总线发送后编织一些打印语句,那么我们会得到以下输出:

Request sent!
Reply sent
{ __messageDate: Mon Aug 11 2014 22:43:07 GMT-0600 (MDT),
  __from: 'responder',
  __corrolationId: 0.5604551520664245,
  body: 'Okay, invaded.' }

你可以看到打印语句在消息处理之前执行,因为该处理发生在下一次迭代中。

发布-订阅

在本章的其他地方,我已经提到了发布-订阅模型。发布-订阅是将事件与处理代码解耦的强大工具。

模式的关键在于,作为消息发布者,我的责任应该在我发送消息后立即结束。我不应该知道谁在监听消息或他们将对消息做什么。只要我履行了生成正确格式的消息的合同,其他事情就不重要了。

监听者有责任注册对消息类型的兴趣。当然,您希望注册某种安全性来阻止注册恶意服务。

我们可以更新我们的服务总线来做更多事情,完成路由和发送多个消息的工作。让我们将新方法称为Publish而不是Send。我们将保留Send来执行发送功能:

发布-订阅

我们在上一节中使用的乌鸦邮件类比在这里开始崩溃,因为没有办法使用乌鸦广播消息。乌鸦太小,无法携带大型横幅,而且很难训练它们进行天空书写。我不愿意完全放弃乌鸦的想法,所以让我们假设存在一种乌鸦广播中心。在这里发送消息允许将其传播给许多已注册更新的感兴趣的各方。这个中心将更多或更少地与总线同义。

我们将编写我们的路由器,使其作为消息名称的函数。可以使用消息的任何属性来路由消息。例如,监听器可以订阅所有名为invoicePaid的消息,其中amount字段大于$10000。将这种逻辑添加到总线中会减慢它的速度,并且使调试变得更加困难。实际上,这更多地属于业务流程编排引擎的领域,而不是总线。我们将继续进行而不涉及这种复杂性。

首先要设置的是订阅发布消息的能力:

CrowMailBus.prototype.Subscribe = function (messageName, subscriber) {
  this.responders.push({ messageName: messageName, subscriber: subscriber });
};

Subscribe函数只是添加一个消息处理程序和要消费的消息的名称。响应者数组只是一个处理程序数组。

当消息发布时,我们遍历数组并触发已注册该名称消息的每个处理程序:

Publish(message) {
  for (let i = 0; i < this.responders.length; i++) {
    if (this.responders[i].messageName == message.__messageName) {
      (function (b) {
        process.nextTick(() => b.subscriber.processMessage(message));
      })(this.responders[i]);
    }
  }
}

这里的执行被推迟到下一个 tick。这是通过使用闭包来确保正确作用域的变量被传递的。现在我们可以改变我们的CrowMailResponder来使用新的Publish方法而不是Send

processMessage(message) {
  var response = { __messageDate: new Date(),
  __from: "responder",
  __corrolationId: message.__corrolationId,
  __messageName: "SquareRootFound",
  body: "Pretty sure it is 3." };
  this.bus.Publish(response);
  console.log("Reply published");
}

与之前允许CrowMailRequestor对象创建自己的总线不同,我们需要修改它以接受外部的bus实例。我们只需将其分配给CrowMailRequestor中的一个本地变量。同样,CrowMailResponder也应该接收bus的实例。

为了利用这一点,我们只需要创建一个新的总线实例并将其传递给请求者:

var bus = new CrowMailBus();
bus.Subscribe("KingdomInvaded", new TestResponder1());
bus.Subscribe("KingdomInvaded", new TestResponder2());
var requestor = new CrowMailRequestor(bus);
requestor.Request();

在这里,我们还传递了另外两个对KingdomInvaded消息感兴趣的响应者。它们看起来像这样:

var TestResponder1 = (function () {
  function TestResponder1() {}
  TestResponder1.prototype.processMessage = function (message) {
    console.log("Test responder 1: got a message");
  };
  return TestResponder1;
})();

现在运行这段代码将得到以下结果:

Message sent!
Reply published
Test responder 1: got a message
Test responder 2: got a message
Crow mail responder: got a message

您可以看到消息是使用Send发送的。响应者或处理程序完成其工作并发布消息,该消息传递给每个订阅者。

有一些很棒的 JavaScript 库可以使发布和订阅变得更加容易。我最喜欢的之一是 Radio.js。它没有外部依赖项,其名称是发布订阅的一个很好的比喻。我们可以像这样重写我们之前的订阅示例:

radio("KingdomInvalid").subscribe(new TestResponder1().processMessage);
radio("KingdomInvalid").subscribe(new TestResponder2().processMessage);

然后使用以下方法发布消息:

radio("KingdomInvalid").broadcast(message);

扇出和扇入

发布订阅模式的一个很好的用途是让您将问题传播到许多不同的节点。摩尔定律一直是关于每平方单位的晶体管数量翻倍的。如果您一直关注处理器的时钟速度,您可能已经注意到在过去十年里时钟速度实际上没有发生任何显著变化。事实上,时钟速度现在比 2005 年还要低。

这并不是说处理器比以前“慢”。每个时钟周期中执行的工作量已经增加。核心数量也有所增加。现在看到单核处理器已经不再是常态;即使在手机中,双核处理器也变得很常见。拥有能够同时执行多项任务的计算机已经成为规则,而不是例外。

与此同时,云计算正在蓬勃发展。您直接购买的计算机比云中可租用的计算机更快。云计算的优势在于您可以轻松地扩展它。轻松地提供一百甚至一千台计算机来组成一个云提供商。

编写能够利用多个核心的软件是我们这个时代的伟大计算问题。直接处理线程是灾难的开始。锁定和争用对于大多数开发人员来说都太困难了:包括我在内!对于某些类别的问题,它们可以很容易地分解为子问题并进行分布。有些人将这类问题称为“令人尴尬地可并行化”。

消息传递提供了一个从问题中通信输入和输出的机制。如果我们有一个这样容易并行化的问题,比如搜索,那么我们将输入打包成一个消息。在这种情况下,它将包含我们的搜索词。消息还可能包含要搜索的文档集。如果我们有 10,000 个文档,那么我们可以将搜索空间分成四个包含 2500 个文档的集合。我们将发布五条消息,其中包含搜索词和要搜索的文档范围,如下所示:

扇出和扇入

不同的搜索节点将接收消息并执行搜索。然后将结果发送回一个节点,该节点将收集消息并将它们合并成一个。这将返回给客户端。

当然,这有点过于简化了。接收节点本身可能会维护一个它们负责的文档列表。这将防止原始发布节点必须了解任何有关其搜索的文档。搜索结果甚至可以直接返回给执行组装的客户端。

即使在浏览器中,扇出和扇入方法也可以通过使用 Web Workers 将计算分布到多个核心上。一个简单的例子可能是创建一种药水。一种药水可能包含许多成分,可以组合成最终产品。组合成分是相当复杂的计算,因此我们希望将这个过程分配给多个工作者。

我们从一个包含combine()方法和一个complete()函数的合并器开始,一旦所有分布的成分都被合并,就会调用该函数:

class Combiner {
  constructor() {
    this.waitingForChunks = 0;
  }
  combine(ingredients) {
    console.log("Starting combination");
    if (ingredients.length > 10) {
      for (let i = 0; i < Math.ceil(ingredients.length / 2); i++) {
        this.waitingForChunks++;
        console.log("Dispatched chunks count at: " + this.waitingForChunks);
        var worker = new Worker("FanOutInWebWorker.js");
        worker.addEventListener('message', (message) => this.complete(message));
        worker.postMessage({ ingredients: ingredients.slice(i, i * 2) });
      }
    }
  }
  complete(message) {
    this.waitingForChunks--;
    console.log("Outstanding chunks count at: " + this.waitingForChunks);
    if (this.waitingForChunks == 0)
      console.log("All chunks received");
  }
};

为了跟踪未完成的工作人员数量,我们使用一个简单的计数器。由于主要的代码部分是单线程的,我们不会出现竞争条件的风险。一旦计数器显示没有剩余的工作人员,我们可以采取必要的步骤。Web 工作者如下所示:

self.addEventListener('message', function (e) {
  var data = e.data;
  var ingredients = data.ingredients;
  combinedIngredient = new Westeros.Potion.CombinedIngredient();
  for (let i = 0; i < ingredients.length; i++) {
    combinedIngredient.Add(ingredients[i]);
  }
  console.log("calculating combination");
  setTimeout(combinationComplete, 2000);
}, false);

function combinationComplete() {
  console.log("combination complete");
  (self).postMessage({ event: 'combinationComplete', result: combinedIngredient });
}

在这种情况下,我们只需设置一个超时来模拟组合配料所需的复杂计算。

分配给多个节点的子问题不必是相同的问题。但是,它们应该足够复杂,以至于将它们分配出去的成本节省不会被发送消息的开销所消耗。

死信队列

无论我多努力,我都还没有写出任何不包含错误的重要代码块。我也没有很好地预测用户对我的应用程序做的各种疯狂的事情。为什么有人会连续点击那个链接 73 次?我永远不会知道。

在消息传递场景中处理故障非常容易。故障策略的核心是接受错误。我们有异常是有原因的,花费所有时间来预测和捕获异常是适得其反的。你不可避免地会花时间为从未发生的错误构建捕获,并错过频繁发生的错误。

在异步系统中,错误不需要在发生时立即处理。相反,导致错误的消息可以被放在一边,以便稍后由实际人员检查。消息存储在死信或错误队列中。从那里,消息在被纠正或处理程序被纠正后可以很容易地重新处理。理想情况下,消息处理程序被更改以处理表现出导致错误的任何属性的消息。这可以防止未来的错误,而且比修复生成消息的任何内容更可取,因为无法保证系统中其他具有相同问题的消息不会潜伏在其他地方。消息通过队列和错误队列的工作流程如下:

死信队列

随着越来越多的错误被捕获和修复,消息处理程序的质量也在提高。拥有消息错误队列可以确保不会错过任何重要的东西,比如“购买西蒙的书”消息。这意味着达到正确系统的进展是一个马拉松,而不是短跑。在正确测试之前,没有必要急于将修复推向生产。朝着正确系统的进展是持续而可靠的。

使用死信队列还可以改善对间歇性错误的捕捉。这些错误是由外部资源不可用或不正确导致的。想象一下一个调用外部 Web 服务的处理程序。在传统系统中,Web 服务的故障保证了消息处理程序的故障。然而,在基于消息的系统中,一旦命令到达队列的前端,就可以将其移回输入队列的末尾并在下次到达队列前端时再次尝试。在信封上,我们记录消息被出列(处理)的次数。一旦这个出列计数达到一个限制,比如五次,那么消息才会被移动到真正的错误队列中。

这种方法通过平滑处理小错误并阻止它们变成大错误来提高系统的整体质量。实际上,队列提供了故障隔离,防止小错误溢出并成为可能对整个系统产生影响的大错误。

消息重播

当开发人员处理产生错误的一组消息时,重新处理消息的能力也很有用。开发人员可以对死信队列进行快照,并在调试模式下反复处理,直到正确处理消息。消息的快照也可以成为消息处理程序的一部分测试。

即使没有错误,每天发送到服务的消息也代表用户的正常工作流程。这些消息可以在进入系统时镜像到审计队列中。审计队列中的数据可以用于测试。如果引入了新功能,那么可以回放正常的一天工作量,以确保正确行为或性能没有降级。

当然,如果审计队列包含每条消息的列表,那么理解应用程序如何达到当前状态就变得微不足道了。经常有人通过插入大量自定义代码或使用触发器和审计表来实现历史。这两种方法都不如消息传递在理解数据不仅发生了什么变化,还发生了为什么变化方面做得好。再次考虑地址更改的情况,如果没有消息传递,我们很可能永远不会知道为什么用户的地址与前一天不同。

保持系统数据变更的良好历史记录需要大量存储空间,但通过允许审计员查看每次变更是如何以及为什么进行的,这个成本是很容易支付的。良好构建的消息还允许历史记录包含用户进行更改的意图。

虽然在单个进程中实现这种消息传递系统是可能的,但却很困难。确保消息在发生错误时被正确保存是困难的,因为处理消息的整个过程可能会崩溃,带走内部消息总线。实际上,如果重放消息听起来值得调查,那么外部消息总线就是解决方案。

管道和过滤器

我之前提到过消息应该被视为不可变的。这并不是说消息不能被重新广播并更改一些属性,甚至作为一种新类型的消息进行广播。事实上,许多消息处理程序可能会消耗一个事件,然后在执行了一些任务后发布一个新事件。

举个例子,你可以考虑向系统添加新用户的工作流程:

Pipes and filters

在这种情况下,CreateUser命令触发了UserCreated事件。这个事件被许多不同的服务消耗。其中一个服务将用户信息传递给一些特定的联盟。当这个服务运行时,它会发布自己的一系列事件,每个事件都是为了接收新用户的详细信息的联盟。这些事件可能反过来被其他服务消耗,这些服务可能触发它们自己的事件。通过这种方式,变化可以在整个应用程序中传播。然而,没有一个服务知道比它启动和发布的事件更多的信息。这个系统耦合度很低。插入新功能是微不足道的,甚至删除功能也很容易:肯定比单片系统容易得多。

使用消息传递和自治组件构建的系统经常被称为使用面向服务的架构SOA)或微服务。关于 SOA 和微服务之间是否有任何区别,仍然存在很多争论。

消息的更改和重新广播可以被视为管道或过滤器。一个服务可以像管道一样将消息代理给其他消费者,也可以像过滤器一样有选择地重新发布消息。

消息版本控制

随着系统的发展,消息中包含的信息也可能会发生变化。在我们的用户创建示例中,我们可能最初要求姓名和电子邮件地址。然而,市场部门希望能够发送给琼斯先生或琼斯夫人的电子邮件,所以我们还需要收集用户的头衔。这就是消息版本控制派上用场的地方。

现在我们可以创建一个扩展之前消息的新消息。该消息可以包含额外的字段,并可能使用版本号或日期进行命名。因此,像CreateUser这样的消息可能会变成CreateUserV1CreateUser20140101。之前我提到过多态消息。这是一种消息版本控制的方法。新消息扩展了旧消息,因此所有旧消息处理程序仍然会触发。然而,我们也谈到了 JavaScript 中没有真正的多态能力。

另一个选择是使用升级消息处理程序。这些处理程序将接收新消息的版本并将其修改为旧版本。显然,新消息需要至少包含与旧版本相同的数据,或者具有允许将一种消息类型转换为另一种消息类型的数据。

考虑一个看起来像下面这样的 v1 消息:

class CreateUserv1Message implements IMessage{
  __messageName: string
  UserName: string;
  FirstName: string;
  LastName: string;
  EMail: string;
}

考虑一个扩展了用户标题的 v2 消息:

class CreateUserv2Message extends CreateUserv1Message implements IMessage{
  UserTitle: string;
}

然后我们可以编写一个非常简单的升级器或降级器,看起来像下面这样:

var CreateUserv2tov1Downgrader = (function () {
  function CreateUserv2tov1Downgrader (bus) {
    this.bus = bus;
  }
  CreateUserv2tov1Downgrader.prototype.processMessage = function (message) {
    message.__messageName = "CreateUserv1Message";
    delete message.UserTitle;
    this.bus.publish(message);
  };
  return CreateUserv2tov1Downgrader;
})();

您可以看到,我们只是修改消息并重新广播它。

提示和技巧

消息在两个不同系统之间创建了一个明确定义的接口。定义消息应该由两个团队的成员共同完成。建立一个共同的语言可能会很棘手,特别是因为术语在不同的业务部门之间被重载。销售部门认为的客户可能与运输部门认为的客户完全不同。领域驱动设计提供了一些关于如何建立边界以避免混淆术语的提示。

现有大量的队列技术可用。它们每个都有关于可靠性、持久性和速度的许多不同属性。其中一些队列支持通过 HTTP 读写 JSON:这对于那些有兴趣构建 JavaScript 应用程序的人来说是理想的。哪种队列适合您的应用程序是一个需要进行一些研究的话题。

总结

消息传递及其相关模式是一个庞大的主题。深入研究消息会让您接触到领域驱动设计DDD)和命令查询职责分离CQRS),以及涉及高性能计算解决方案。

有大量的研究和讨论正在进行,以找到构建大型系统的最佳方法。消息传递是一种可能的解决方案,它避免了创建难以维护和易于更改的大块代码。消息传递在系统中提供了自然的边界,消息本身为一致的 API 提供了支持。

并非每个应用程序都受益于消息传递。构建这样一个松散耦合的应用程序会增加额外的开销。协作型应用程序、那些特别不希望丢失数据的应用程序以及那些受益于强大历史故事的应用程序都是消息传递的良好候选者。在大多数情况下,标准的 CRUD 应用程序就足够了。然而,了解消息传递模式仍然是值得的,因为它们会提供替代思路。

在本章中,我们看了一些不同的消息模式以及它们如何应用于常见场景。还探讨了命令和事件之间的区别。

在下一章中,我们将探讨一些使测试代码变得更容易的模式。测试非常重要,所以请继续阅读!

第十一章:微服务

现在似乎没有一本编程书籍是完整的,没有至少提到微服务的一些内容。为了避免这本书被指责为不符合规范的出版物,我们在微服务上包含了一章。

微服务被宣传为解决单块应用程序的问题的解决方案。很可能你处理过的每个应用程序都是单块应用程序:也就是说,应用程序有一个单一的逻辑可执行文件,并且可能分成诸如用户界面、服务或应用程序层和数据存储等层。许多应用程序中,这些层可能是一个网页、一个服务器端应用程序和一个数据库。单块应用程序有它们的问题,我相信你已经遇到过。

维护单块应用程序很快就变成了限制变化影响的练习。在这样的应用程序中,经常会发生对应用程序的一个看似孤立的角落的更改对应用程序的其他部分产生意外影响。尽管有许多模式和方法来描述良好隔离的组件,但在单块应用程序中,这些往往会被抛在一边。通常我们会采取捷径,这可能会节省时间,但将来会让我们的生活变得糟糕。

单块应用程序也很难扩展。因为我们倾向于只有三层,我们受限于扩展这些层中的每一层。如果中间层变慢,我们可以添加更多的应用服务器,或者如果 Web 层滞后,我们可以添加更多的 Web 服务器。如果数据库变慢,那么我们可以增加数据库服务器的性能。这些扩展方法都是非常大的操作。如果应用程序中唯一慢的部分是注册新用户,那么我们真的没有办法简单地扩展那个组件。这意味着不经常使用的组件(可以称为冷或凉组件)必须能够随着整个应用程序的扩展而扩展。这种扩展并不是免费的。

考虑到从单个 Web 服务器扩展到多个 Web 服务器会引入在多个 Web 服务器之间共享会话的问题。如果我们将应用程序分成多个服务,每个服务都作为数据的真实来源,那么我们可以独立地扩展这些部分。一个用于登录用户的服务,另一个用于保存和检索他们的偏好,另一个用于发送有关被遗弃的购物车的提醒电子邮件,每个服务负责自己的功能和数据。每个服务都是一个独立的应用程序,可以在单独的机器上运行。实际上,我们已经将我们的单块应用程序分片成了许多应用程序。每个服务不仅具有隔离的功能,而且还具有自己的数据存储,并且可以使用自己的技术来实现。单块应用程序和微服务之间的区别可以在这里看到:

微服务

应用程序更多地是通过组合服务来编写,而不是编写单一的单块应用程序。应用程序的用户界面甚至可以通过请求一些服务提供的可视组件来创建,然后由某种形式的组合服务插入到复合用户界面中。

Node.js 以只使用所需组件构建应用程序的轻量级方法,使其成为构建轻量级微服务的理想平台。许多微服务部署大量使用 HTTP 在服务之间进行通信,而其他则更多地依赖于消息系统,如 RabbitMQ 或 ZeroMQ。这两种通信方法可以在部署中混合使用。可以根据使用 HTTP 对仅进行查询的服务进行技术分割,并使用消息对执行某些操作的服务进行技术分割。这是因为消息比发送 HTTP 请求更可靠(取决于您的消息系统和配置)。

虽然看起来我们在系统中引入了大量复杂性,但这种复杂性在现代工具的管理下更容易处理。存在非常好的工具来管理分布式日志文件和监视应用程序的性能问题。通过容器化技术,隔离和运行许多应用程序比以往任何时候都更容易。

微服务可能不是解决我们所有维护和可扩展性问题的方法,但它们肯定是一个值得考虑的方法。在本章中,我们将探讨一些可能有助于使用微服务的模式:

  • 外观

  • 聚合服务

  • 管道

  • 消息升级器

  • 服务选择器

  • 故障模式

由于微服务是一个相对较新的发展,随着越来越多的应用程序采用微服务方法创建,可能会出现更多的模式。微服务方法与面向服务的体系结构(SOA)之间存在一些相似之处。这意味着 SOA 世界中可能有一些适用于微服务世界的模式。

外观

如果您觉得认识这个模式的名字,那么您是正确的。我们在第四章中讨论过这个模式,结构模式。在该模式的应用中,我们创建了一个可以指导多个其他类行动的类,提供了一个更简单的 API。我们的例子是一个指挥官指挥一支舰队。在微服务世界中,我们可以简单地用服务的概念取代类的概念。毕竟,服务的功能与微服务并没有太大的不同-它们都执行单个动作。

我们可以利用外观来协调使用多个其他服务。这种模式是本章中许多其他模式的基础模式。协调服务可能很困难,但通过将它们放在外观后面,我们可以使整个应用程序变得更简单。让我们考虑一个发送电子邮件的服务。发送电子邮件是一个相当复杂的过程,可能涉及许多其他服务:用户名到电子邮件地址的转换器,反恶意软件扫描器,垃圾邮件检查器,为各种电子邮件客户端格式化电子邮件正文的格式化器等等。

大多数想要发送电子邮件的客户并不想关注所有这些其他服务,因此可以放置一个外观电子邮件发送服务,它负责协调其他服务。协调模式可以在这里看到:

外观

服务选择器

与外观类似的是服务选择器模式。在这种模式中,我们有一个服务作为其他服务的前端。根据到达的消息,可以选择不同的服务来响应初始请求。这种模式在升级场景和实验中很有用。如果您正在推出一个新的服务,并希望确保它在负载下能正常运行,那么您可以利用服务选择器模式将一小部分生产流量引导到新服务,同时密切监视它。另一个应用可能是将特定的客户或客户组引导到不同的服务。区分因素可以是任何东西,从将为您的服务付费的人引导到更快的终端,到将来自某些国家的流量引导到特定国家的服务。服务选择器模式可以在这个插图中看到:

服务选择器

聚合服务

在微服务架构中,数据由单个服务拥有,但有许多时候我们可能需要一次从许多不同的来源检索数据。再次考虑一下在维斯特洛大陆的小议会成员。他们可能有许多通报者,从他们那里收集有关王国运作的信息。您可以将每个通报者视为其自己的微服务。

提示

通报者是微服务的一个很好的比喻,因为每个通报者都是独立的,并且拥有自己的数据。服务也可能会偶尔失败,就像通报者可能会被捕获和终止一样。消息在通报者之间传递,就像在一组微服务之间传递一样。每个通报者对其他通报者的工作知之甚少,甚至不知道他们是谁——这种抽象对微服务也适用。

使用聚合服务模式,我们要求一组节点中的每一个执行某些操作或返回某些数据。这是一个相当常见的模式,即使在微服务世界之外也是如此,它是外观模式甚至适配器模式的特例。聚合器从其他一些服务请求信息,然后等待它们返回。一旦所有数据都返回了,聚合器可能执行一些额外的任务,比如总结数据或计算记录。然后将信息传递回给调用者。聚合器可以在这个插图中看到:

聚合服务

这种模式可能还有一些处理返回缓慢的服务或服务失败的规定。聚合器服务可能返回部分结果,或者在其中一个子服务达到超时时,从缓存返回数据。在某些架构中,聚合器可以返回部分结果,然后在可用时向调用者返回其他数据。

管道

管道是微服务连接模式的另一个例子。如果您曾经在NIX 系统上使用过 shell,那么您肯定已经将一个命令的输出传递给另一个命令。NIX 系统上的程序,如 ls、sort、uniq 和 grep,都是设计用来执行单一任务的;它们的强大之处在于能够将这些工具链接在一起构建相当复杂的工作流程。例如,这个命令:

 **ls -1| cut -d \. -f 2 -s | sort |uniq** 

这个命令将列出当前目录中所有唯一的文件扩展名。它通过获取文件列表,然后剪切它们并获取扩展名来实现这一点;然后对其进行排序,最后传递给uniq,以删除重复项。虽然我不建议为排序或去重等琐碎操作创建微服务,但您可能有一系列服务,逐渐积累更多信息。

让我们想象一个查询服务,返回一组公司记录:

 **| Company Id| Name | Address | City | Postal Code | Phone Number |** 

这条记录是由我们的公司查找服务返回的。现在我们可以将这条记录传递给我们的销售会计服务,该服务将向记录中添加销售总额:

 **| Company Id| Name | Address | City | Postal Code | Phone Number | 2016 orders Total |** 

现在该记录可以传递给销售估算服务,该服务将进一步增强记录,估算 2017 年的销售额:

 **| Company Id| Name | Address | City | Postal Code | Phone Number | 2016 orders Total | 2017 Sales Estimate |** 

这种渐进式增强也可以通过一个服务来逆转,该服务可以剥离不应呈现给用户的信息。记录现在可能变成以下内容:

 **| Name | Address | City | Postal Code | Phone Number | 2016 orders Total | 2017 Sales Estimate |** 

在这里,我们删除了公司标识符,因为这是一个内部标识符。微服务管道应该是双向的,这样信息量就可以通过管道中的每个步骤传递,然后再通过每个步骤传递回来。这为服务提供了两次操作数据的机会,可以根据需要对数据进行操作。这与许多 Web 服务器中使用的方法相同,其中诸如 PHP 之类的模块被允许对请求和响应进行操作。管道可以在这里看到示例:

管道

消息升级器

对于一些单片应用程序来说,升级是最高风险的活动之一。要做到这一点,您基本上需要一次性升级整个应用程序。即使是中等规模的应用程序,也有太多方面需要合理测试。因此,在某个时候,您只需要从旧系统切换到新系统。采用微服务方法,可以为每个单独的服务进行切换。较小的服务意味着风险可以分散在很长时间内,如果出现问题,错误的来源可以更快地被定位:单一的新组件。

问题在于仍在与升级服务的旧版本进行通信的服务。我们如何继续为这些服务提供服务,而无需更新所有这些服务呢?如果服务的接口保持不变,比如我们的服务计算地球上两点之间的距离,我们将其从使用简单的毕达哥拉斯方法更改为使用哈弗赛恩(一种在球面上找到两个点之间距离的公式),那么可能不需要对输入和输出格式进行更改。然而,通常情况下,这种方法对我们来说是不可用的,因为消息格式必须更改。即使在前面的例子中,也有可能更改输出消息。哈弗赛恩比毕达哥拉斯方法更准确,因此我们可能需要更多的有效数字,需要更大的数据类型。有两种很好的方法来处理这个问题:

  1. 继续使用我们服务的旧版本和新版本。然后,我们可以在时间允许的情况下慢慢将客户服务迁移到新服务。这种方法存在问题:我们现在需要维护更多的代码。此外,如果我们更改服务的原因是无法继续运行它(安全问题,终止依赖服务等),那么我们就陷入了某种僵局。

  2. 升级消息并传递它们。在这种方法中,我们采用旧的消息格式并将其升级到新的格式。这是通过另一个服务来完成的。这个服务的责任是接收旧的消息格式并发出新的消息格式。在另一端,您可能需要一个等效的服务来将消息降级为旧服务的预期输出格式。

升级服务应该有一个有限的寿命。理想情况下,我们希望尽快对依赖于已弃用服务的服务进行更新。微服务的小代码占用量,加上快速部署服务的能力,应该使这些类型的升级比单片方法所期望的更容易。一个示例消息升级器服务可以在这里看到:

消息升级器

失败模式

在本章中,我们已经提到了一些处理微服务故障的方法。然而,还有一些更有趣的方法值得考虑。其中之一是服务降级。

服务降级

这种模式也可以称为优雅降级,与渐进增强有关。让我们回顾一下用哈弗赛恩等效替换毕达哥拉斯距离函数的例子。如果哈弗赛恩服务由于某种原因而关闭,那么可以使用不太苛刻的函数代替它,而对用户几乎没有影响。事实上,他们可能根本没有注意到。用户拥有更糟糕的服务版本并不理想,但肯定比简单地向用户显示错误消息更可取。当哈弗赛恩服务恢复正常时,我们可以停止使用较差的服务。我们可以有多个级别的备用方案,允许多个不同的服务失败,同时我们继续向最终用户呈现一个完全功能的应用程序。

这种退化形式的另一个很好的应用是退回到更昂贵的服务。我曾经有一个发送短信的应用程序。确实很重要这些消息实际上被发送。我们大部分时间都使用我们首选的短信网关提供商,但是,如果我们的首选服务不可用,这是我们密切监视的情况,那么我们就会切换到使用另一个提供商。

消息存储

我们已经在查询服务和实际执行某些持久数据更改的服务之间划分了一些区别。当这些更新服务之一失败时,仍然需要在将来的某个时间运行数据更改代码。将这些请求存储在消息队列中可以让它们稍后运行,而不会有丢失任何非常重要的消息的风险。通常,当消息引发异常时,它会被返回到处理队列,可以进行重试。

有一句古老的谚语说,疯狂就是一遍又一遍地做同样的事情,却期待不同的结果。然而,有许多瞬态错误可以通过简单地再次执行相同的操作来解决。数据库死锁就是一个很好的例子。您的事务可能会被终止以解决死锁问题,在这种情况下,再次执行它实际上是推荐的方法。然而,不能无限次重试消息,因此最好选择一些相对较小的重试次数,比如三次或五次。一旦达到这个数字,消息就可以被发送到死信或毒消息队列。

毒消息,或者有些人称之为死信,是因为实际合理的原因而失败的消息。保留这些消息非常重要,不仅用于调试目的,还因为这些消息可能代表客户订单或医疗记录的更改:这些都是您不能承受丢失的数据。一旦消息处理程序已经被纠正,这些消息可以被重放,就好像错误从未发生过一样。存储队列和消息重新处理器可以在这里看到:

消息存储

消息重放

虽然不是一个真正的生产模式,但围绕所有更改数据的服务构建基于消息的架构的一个副作用是,您可以获取消息以便在生产环境之外稍后重放。能够重放消息对于调试多个服务之间复杂交互非常方便,因为消息几乎包含了设置与生产环境完全相同的跟踪环境所需的所有信息。重放功能对于必须能够审计系统中的任何数据更改的环境也非常有用。还有其他方法来满足此类审计要求,但非常可靠的消息日志简直是一种乐趣。

消息处理的幂等性

我们将讨论的最后一个失败模式是消息处理的幂等性。随着系统规模的扩大,几乎可以肯定,微服务架构将跨越许多计算机。由于容器的重要性日益增长,这更是肯定的,容器本质上可以被视为计算机。在分布式系统中的计算机之间的通信是不可靠的;因此,消息可能会被传递多次。为了处理这种可能性,人们可能希望使消息处理具有幂等性。

提示

关于分布式计算的不可靠性,我强烈推荐阅读 Arnon Rotem-Gal-Oz 的《分布式计算谬误解释》一文,网址为rgoarchitects.com/Files/fallacies.pdf

幂等性意味着一条消息可以被处理多次而不改变结果。这可能比人们意识到的更难实现,特别是对于那些本质上是非事务性的服务,比如发送电子邮件。在这些情况下,可能需要将已发送电子邮件的记录写入数据库。在某些情况下,电子邮件可能会被发送多次,但在电子邮件发送和记录写入之间的关键部分崩溃的情况是不太可能的。必须做出决定:是更好地多次发送电子邮件,还是根本不发送?

提示和技巧

如果你把微服务看作一个类,把微服务网络看作一个应用程序,那么很快就会发现,我们在本书中看到的许多模式同样适用于微服务。服务发现可能与依赖注入是同义词。单例、装饰器、代理;所有这些模式在微服务世界中同样适用,就像它们在进程边界内一样。

需要记住的一件事是,许多这些模式有点啰嗦,来回传送大量数据。在一个进程内,传递数据指针是没有额外开销的。但对于微服务来说情况并非如此。通过网络通信很可能会带来性能损失。

总结

微服务是一个令人着迷的想法,很可能在未来几年内得以实现。现在还为时过早,无法确定这是否只是在正确解决软件工程问题的道路上又一个错误转折,还是朝着正确方向迈出的重要一步。在本章中,我们探讨了一些模式,这些模式可能在你踏上微服务世界的旅程时会有所帮助。由于我们只是处于微服务成为主流的初期阶段,这一章的模式很可能会很快过时,并被发现不够优化。在开发过程中保持警惕,了解更大的画面是非常明智的。

第十二章:测试模式

在整本书中,我们一直在强调 JavaScript 不再是一个我们无法做有用事情的玩具语言。现在就有真实世界的软件是用 JavaScript 编写的,未来十年使用 JavaScript 的应用程序的比例只可能增长。

随着真实软件的出现,对正确性的担忧也随之而来。手动测试软件是痛苦的,而且容易出错。编写自动运行并测试应用程序各个方面的单元测试和集成测试要便宜得多,也更容易。

有无数工具可用于测试 JavaScript,从测试运行器到测试框架;这个生态系统非常丰富。在本章中,我们将尽量保持一种几乎不依赖特定工具的测试方法。本书不关心哪个框架是最好或最友好的。有一些普遍的模式适用于整个测试过程。我们将涉及一些具体的工具,但只是为了避免编写所有自己的测试工具而采取的捷径。

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

  • 虚假对象

  • 猴子补丁

  • 与用户界面交互

测试金字塔

作为计算机程序员,我们通常是高度分析性的人。这意味着我们总是在努力对概念进行分类和理解。这导致我们开发了一些非常有趣的全球技术,可以应用于计算机编程之外。例如,敏捷开发在一般社会中也有应用,但可以追溯到计算机。甚至可以说,模式的概念之所以如此流行,很大程度上是因为它被计算机程序员在其他生活领域中使用。

这种分类的愿望导致了将测试代码划分为许多不同类型的测试的概念。我见过从单元测试一直到工作流测试和 GUI 测试等多达八种不同类型的测试。这可能有些过度。更常见的是考虑有三种不同类型的测试:单元测试、集成测试和用户界面测试:

测试金字塔

单元测试构成了金字塔的基础。它们数量最多,编写起来最容易,并且在提供错误信息时最细粒度。单元测试中的错误将帮助您找到具有错误的单个方法。随着金字塔的上升,测试数量随着粒度的减少而减少,而每个测试的复杂性则增加。在更高的层次上,当一个测试失败时,我们可能只能说:“在向系统添加订单时出现了问题”。

通过单元测试在小范围内进行测试

对许多人来说,单元测试是一个陌生的概念。这是可以理解的,因为在许多学校里,这个话题都没有得到很好的教授。我知道我在计算机科学的六年高等教育中从未听说过它。这是不幸的,因为交付高质量的产品是任何项目中非常重要的一部分。

对于了解单元测试的人来说,采用单元测试存在一个很大的障碍。经理,甚至开发人员经常认为单元测试和自动化测试整体上都是浪费时间。毕竟,你不能把一个单元测试交给客户,大多数客户也不在乎他们的产品是否经过了正确的单元测试。

单元测试的定义非常困难。它与集成测试非常接近,人们很容易在两者之间来回切换。在开创性的书籍《单元测试的艺术》中,作者罗伊·奥舍罗夫定义了单元测试为:

单元测试是一个自动化的代码片段,它调用系统中的一个工作单元,然后检查关于该工作单元行为的一个假设。

一个工作单元的确切大小存在一些争议。有些人将其限制在一个函数或一个类,而其他人允许一个工作单元跨越多个类。我倾向于认为跨越多个类的工作单元实际上可以分解成更小、可测试的单元。

单元测试的关键在于它测试了一个小的功能片段,并且以可重复、自动化的方式快速测试了功能。一个人编写的单元测试应该很容易地被团队中的任何其他成员运行。

对于单元测试,我们希望测试小的功能片段,因为我们相信如果系统的所有组件都正确工作,那么整个系统也会工作。这并不是全部真相。模块之间的通信和单元内的功能一样容易出错。这就是为什么我们希望在几个层面上编写测试。单元测试检查我们正在编写的代码是否正确。集成测试测试应用程序中的整个工作流程,并且会发现单元之间的交互问题。

测试驱动开发方法建议在编写代码的同时编写测试。虽然这给了我们很大的信心,我们正在编写的代码是正确的,但真正的优势在于它有助于推动良好的架构。当代码之间存在太多的相互依赖时,要测试它就比良好分离的模块化代码要困难得多。开发人员编写的许多代码永远不会被任何人阅读。单元测试为开发人员提供了一种有用的方式,即使在他们知道没有人会再次看到他们的代码的情况下,也可以使他们走上正确的道路。没有比告诉人们他们将受到检查更好的方式来生产高质量的产品,即使检查者可能是一个自动化测试。

测试可以在开发新代码时运行,也可以在构建机器上以自动方式运行。如果每次开发人员提交更改时,整个项目都会被构建和测试,那么可以提供一些保证,即新提交的代码是正确的。有时构建会中断,这将是一个标志,表明刚刚添加的内容存在错误。通常,出现错误的代码可能甚至与更改的代码不相邻。修改后的返回值可能会在系统中传播,并在完全意想不到的地方显现出来。没有人可以一次记住比最琐碎的系统更多的东西。测试充当了第二记忆,检查和重新检查以前做出的假设。

一旦发生错误,立即中断构建可以缩短在代码中出现错误和发现并修复错误之间的时间。理想情况下,问题仍然在开发人员的脑海中,因此修复可以很容易地找到。如果错误直到几个月后才被发现,开发人员肯定会忘记当时正在做什么。开发人员甚至可能不在现场帮助解决问题,而是让从未见过代码的人来解决问题。

安排-执行-断言

在为任何代码构建测试时,一个非常常见的方法是遵循安排-执行-断言的步骤。这描述了单元测试中发生的不同步骤。

我们要做的第一件事是设置一个测试场景(安排)。这一步可以包括许多操作,并且可能涉及放置虚假对象来模拟真实对象,以及创建被测试主题的新实例。如果您发现您的测试设置代码很长或者很复杂,那很可能是一种异味,您应该考虑重构您的代码。如前一节所述,测试有助于驱动正确性和架构。难以编写的测试表明架构不够模块化。

一旦测试设置好了,下一步就是实际执行我们想要测试的函数(执行)。执行步骤通常非常简短,在许多情况下不超过一行代码。

最后一部分是检查函数的结果或世界的状态是否符合您的期望(断言)。

一个非常简单的例子可能是一个城堡建造者:

class CastleBuilder {
  buildCastle(size) {
    var castle = new Castle();
    castle.size = size;
    return castle;
  }
}

这个类只是简单地构建一个特定大小的新城堡。我们想确保没有什么欺骗行为,并且当我们建造一个大小为10的城堡时,我们得到的是一个大小为10的城堡:

function When_building_a_castle_size_should_be_correctly_set() {
  var castleBuilder = new CastleBuilder();
  var expectedSize = 10;
  var builtCastle = castleBuilder.buildCastle(10);
  assertEqual(expectedSize, builtCastle.size);
}

断言

您可能已经注意到,在上一个示例中,我们使用了一个名为assertEquals的函数。断言是一个测试,当它失败时会抛出异常。目前在 JavaScript 中没有内置的断言功能,尽管有一个提案正在进行中以添加它。

幸运的是,构建一个断言非常简单:

function assertEqual(expected, actual){
  if(expected !== actual)
  throw "Got " + actual + " but expected " + expected;
}

在错误中提及实际值和期望值是有帮助的。

存在大量的断言库。Node.js 附带了一个,创造性地称为assert.js。如果您最终在 JavaScript 中使用测试框架,很可能它也包含一个断言库。

虚假对象

如果我们将应用程序中对象之间的相互依赖关系视为图形,很快就会发现有许多节点依赖于不止一个,而是许多其他对象。试图测试具有许多依赖关系的对象是具有挑战性的。每个依赖对象都必须被构建并包含在测试中。当这些依赖与网络或文件系统等外部资源进行交互时,问题变得棘手。很快我们就会一次性测试整个系统。这是一种合法的测试策略,称为集成测试,但我们真正感兴趣的是确保单个类的功能是正确的。

集成测试的执行速度往往比单元测试慢。

测试的主体可能有一个庞大的依赖图,这使得测试变得困难。你可以在这里看到一个例子:

虚假对象

我们需要找到一种方法来隔离被测试的类,这样我们就不必重新创建所有的依赖关系,包括网络。我们可以将这种方法看作是向我们的代码添加防护墙。我们将插入防护墙,阻止测试从一个类流向多个类。这些防护墙类似于油轮维持隔离以限制泄漏的影响并保持重量分布,如下所示:

虚假对象

图片由www.reactivemanifesto.org/提供。

为此,我们可以使用虚假对象来代替真实对象,虚假对象具有一组有限的功能。我们将看看三种创建虚假对象的不同方法。

首先是一个非常巧妙命名的测试间谍。

测试间谍

间谍是一种包装对象的方法,记录该方法的输入和输出以及调用次数。通过包装调用,可以检查函数的确切输入和输出。当不事先知道函数的确切输入时,可以使用测试间谍。

在其他语言中,构建测试间谍需要反射,可能会相当复杂。实际上,我们可以用不超过几行代码来制作一个基本的测试间谍。让我们来试验一下。

首先,我们需要一个要拦截的类:

var SpyUpon = (function () {
  function SpyUpon() {
  }
  SpyUpon.prototype.write = function (toWrite) {
    console.log(toWrite);
  };
  return SpyUpon;
})();

现在我们想要监视这个函数。因为在 JavaScript 中,函数是一等对象,我们可以简单地重新调整SpyUpon对象:

var spyUpon = new SpyUpon();
spyUpon._write = spyUpon.write;
spyUpon.write = function (arg1) {
  console.log("intercepted");
  this.called = true;
  this.args = arguments;
  this.result = this._write(arg1, arg2, arg3, arg4, arg5);
  return this.result;
};

在这里,我们接受现有的函数并给它一个新名字。然后我们创建一个新的函数,调用重命名的函数并记录一些东西。函数被调用后,我们可以检查各种属性:

console.log(spyUpon.write("hello world"));
console.log(spyUpon.called);
console.log(spyUpon.args);
console.log(spyUpon.result);

在 node 中运行这段代码,我们得到以下结果:

hello world
7
true
{ '0': 'hello world' }
7

使用这种技术,可以深入了解函数的使用方式。有许多库支持以比我们这里的简单版本更强大的方式创建测试间谍。有些提供记录异常、调用次数和每次调用的参数的工具。

存根

存根是另一个虚假对象的例子。当被测试的主体中有一些需要满足返回值对象的依赖关系时,我们可以使用存根。它们也可以用来提供防护,阻止计算密集型或依赖 I/O 的函数运行。

存根可以以与我们实现间谍相同的方式实现。我们只需要拦截对方法的调用,并用我们编写的版本替换它。然而,对于存根,我们实际上不调用被替换的函数。保留被替换函数可能是有用的,以防我们需要恢复存根类的功能。

让我们从一个对象开始,该对象的部分功能依赖于另一个对象:

class Knight {
  constructor(credentialFactory) {
    this.credentialFactory = credentialFactory;
  }
  presentCredentials(toRoyalty) {
    console.log("Presenting credentials to " + toRoyalty);
    toRoyalty.send(this.credentialFactory.Create());
    return {};
  }
}

这个骑士对象在其构造函数中接受一个credentialFactory参数。通过传入对象,我们将依赖性外部化,并将创建credentialFactory的责任从骑士身上移除。我们之前已经看到了这种控制反转的方式,我们将在下一章中更详细地讨论它。这使得我们的代码更模块化,测试更容易。

现在,当我们想要测试骑士而不用担心凭证工厂的工作方式时,我们可以使用一个虚假对象,这里是一个存根:

class StubCredentialFactory {
  constructor() {
    this.callCounter = 0;
  }
  Create() {
    //manually create a credential
  };
}

这个存根是一个非常简单的存根,只是返回一个标准的新凭证。如果需要多次调用,存根可以变得相当复杂。例如,我们可以将我们简单的存根重写为以下形式:

class StubCredentialFactory {
  constructor() {
    this.callCounter = 0;
  }
  Create() {
    if (this.callCounter == 0)
      return new SimpleCredential();
    if (this.callCounter == 1)
      return new CredentialWithSeal();
    if (this.callCounter == 2)
      return null;
    this.callCounter++;
  }
}

这个存根的版本在每次调用时返回不同类型的凭据。第三次调用时返回 null。由于我们使用了控制反转来设置类,编写测试就像下面这样简单:

var knight = new Knight(new StubCredentialFactory());
knight.presentCredentials("Queen Cersei");

我们现在可以执行测试:

var knight = new Knight(new StubCredentialFactory());
var credentials = knight.presentCredentials("Lord Snow");
assert(credentials.type === "SimpleCredentials");
credentials = knight.presentCredentials("Queen Cersei");
assert(credentials.type === "CredentialWithSeal");
credentials = knight.presentCredentials("Lord Stark");
assert(credentials == null);

由于 JavaScript 中没有硬类型系统,我们可以构建存根而不必担心实现接口。也不需要存根整个对象,只需要我们感兴趣的函数。

模拟

最后一种虚假对象是模拟。模拟和存根之间的区别在于验证的位置。对于存根,我们的测试必须在行动之后检查状态是否正确。对于模拟对象,测试断言的责任落在模拟对象本身。模拟是另一个有用的地方,可以利用模拟库。但是,我们也可以简单地构建相同类型的东西:

class MockCredentialFactory {
  constructor() {
    this.timesCalled = 0;
  }
  Create() {
    this.timesCalled++;
  }
  Verify() {
    assert(this.timesCalled == 3);
  }
}

这个mockCredentialsFactory类承担了验证正确函数是否被调用的责任。这是一种非常简单的模拟方法,可以像这样使用:

var credentialFactory = new MockCredentialFactory();
var knight = new Knight(credentialFactory);
var credentials = knight.presentCredentials("Lord Snow");
credentials = knight.presentCredentials("Queen Cersei");
credentials = knight.presentCredentials("Lord Stark");
credentialFactory.Verify();

这是一个静态模拟,每次使用时都保持相同的行为。可以构建作为录音设备的模拟。您可以指示模拟对象期望某些行为,然后让它自动播放它们。

这个语法取自模拟库 Sinon 的文档。它看起来像下面这样:

var mock = sinon.mock(myAPI);
mock.expects("method").once().throws();

Monkey patching

我们已经看到了在 JavaScript 中创建虚假对象的许多方法。在创建间谍时,我们使用了一种称为monkey patching的方法。Monkey patching 允许您通过替换其函数来动态更改对象的行为。我们可以使用这种方法,而无需回到完全虚假的对象。使用这种方法可以单独更改任何现有对象的行为。这包括字符串和数组等内置对象。

与用户界面交互

今天使用的大量 JavaScript 用于客户端,并用于与屏幕上可见的元素进行交互。与页面交互通过称为文档对象模型DOM)的页面模型进行。

页面上的每个元素都在 DOM 中表示。每当页面发生更改时,DOM 都会更新。如果我们向页面添加段落,那么 DOM 中就会添加一个段落。因此,如果我们的 JavaScript 代码添加了一个段落,检查它是否这样做只是检查 DOM 的一个函数。

不幸的是,这要求 DOM 实际上存在,并且以与实际页面相同的方式形成。有许多方法可以针对页面进行测试。

浏览器测试

最天真的方法就是简单地自动化浏览器。有一些项目可以帮助完成这项任务。可以自动化完整的浏览器,如 Firefox、Internet Explorer 或 Chrome,也可以选择一个无头浏览器。完整的浏览器方法要求测试机器上安装了浏览器,并且机器正在运行具有可用桌面的模式。

许多基于 Unix 的构建服务器不会被设置为显示桌面,因为大多数构建任务不需要。即使您的构建机器是 Windows 机器,构建帐户经常以无法打开窗口的模式运行。在我看来,使用完整浏览器进行测试也有破坏的倾向。会出现微妙的时间问题,并且测试很容易被浏览器的意外更改打断。经常需要手动干预来解决浏览器陷入不正确状态的问题。

幸运的是,已经努力将 Web 浏览器的图形部分与 DOM 和 JavaScript 解耦。对于 Chrome,这一举措已经导致了 PhantomJS,而对于 Firefox 则是 SlimerJS。

通常,需要完整浏览器的测试需要浏览器在多个页面之间进行导航。无头浏览器通过 API 提供了这一功能。我倾向于将这种规模的测试视为集成测试,而不是单元测试。

使用 PhantomJS 和位于浏览器顶部的 CasperJS 库进行的典型测试可能如下所示:

var casper = require('casper').create();
casper.start('http://google.com', function() {
  assert.false($("#gbqfq").attr("aria-haspopup"));
  $("#gbqfq").val("redis");
  assert.true($("#gbqfq").attr("aria-haspopup"));
});

这将测试在 Google 搜索框中输入值是否会将aria-haspopup属性从false更改为true

以这种方式测试会在很大程度上依赖于 DOM 不会发生太大变化。根据用于在页面上查找元素的选择器,页面样式的简单更改可能会破坏每个测试。我喜欢通过不使用 CSS 属性来选择元素,而是使用 ID 或者更好的是 data-*属性,将这种测试与页面的外观分开。当我们测试现有页面时,我们可能没有这样的奢侈,但对于新页面来说,这是一个很好的计划。

伪造 DOM

大部分时间,我们不需要完整的页面 DOM 来执行测试。我们需要测试的页面元素是页面上的一个部分,而不是整个页面。存在许多倡议可以通过纯 JavaScript 创建文档的一部分。例如,jsdom是一种通过注入 HTML 字符串并接收一个虚假窗口的方法。

在这个例子中,稍作修改,他们创建了一些 HTML 元素,加载了 JavaScript,并测试其是否正确返回:

var jsdom = require("jsdom");
jsdom.env( '<p><a class="the-link" ref="https://github.com/tmpvar/jsdom">jsdom!</a></p>',["http://code.jquery.com/jquery.js"],
  function (errors, window) {
    assert.equal(window.$("a.the-link").text(), "jsdom!");
  }
);

如果你的 JavaScript 专注于页面的一个小部分,也许你正在构建自定义控件或 Web 组件,那么这是一种理想的方法。

包装操作

处理图形 JavaScript 的最终方法是停止直接与页面上的元素进行交互。这是当今许多更受欢迎的 JavaScript 框架采用的方法。一个简单地更新 JavaScript 模型,然后通过某种 MV*模式更新页面。我们在前几章中详细讨论了这种方法。

在这种情况下,测试变得非常容易。我们可以通过在运行代码之前构建模型状态,然后测试运行代码后的模型状态是否符合预期来测试复杂的 JavaScript。

例如,我们可以有一个如下所示的模型:

class PageModel{
  titleVisible: boolean;
  users: Array<User>;
}

对于它的测试代码可能看起来就像下面这样简单:

var model = new PageModel();
model.titleVisible = false;
var controller = new UserListPageController(model);
controller.AddUser(new User());
assert.true(model.titleVisible);

当页面上的所有内容都被操作时,通过与模型的绑定,我们可以确信模型中的更改是否正确地更新了页面。

有人会争辩说我们只是把问题转移了。现在错误的唯一可能性是 HTML 和模型之间的绑定是否不正确。因此,我们还需要测试是否已将绑定正确应用到 HTML。这需要更简单地进行更高级别的测试。我们可以通过更高级别的测试覆盖更多内容,尽管会牺牲知道错误发生的确切位置。

你永远无法测试应用程序的所有内容,但是未经测试的表面越小,越好。

技巧和窍门

我见过一些人通过添加注释来分隔安排-执行-断言:

function testMapping(){
  //Arrange
  …
  //Act
  …
  //Assert
  …
}

你将会因为为每个测试输入这些注释而累得手指骨折。相反,我只是用空行分隔它们。分隔清晰,任何了解安排-执行-断言的人都会立即认识到你正在做什么。你会看到本章中的示例代码以这种方式分隔。

有无数的 JavaScript 测试库可用,可以让你的生活更轻松。选择一个可能取决于你的偏好风格。如果你喜欢 gherkin 风格的语法,那么 cuumber.js 可能适合你。否则,尝试 mocha,可以单独使用,也可以与 chai BDD 风格断言库一起使用,这也相当不错。还有一些针对 Angular 应用程序的测试框架,比如 Protractor(尽管你可以通过一些工作来测试其他框架)。我建议花一天时间玩一下,找到适合你的点。

在编写测试时,我倾向于以一种明显表明它们是测试而不是生产代码的方式命名它们。对于大多数 JavaScript,我遵循驼峰命名约定,比如testMapping。然而,对于测试方法,我遇到了一个下划线命名模式When_building_a_castle_size_should_be_correctly_set。这样测试就更像是一个规范。其他人对命名有不同的方法,没有“正确”的答案,所以请随意尝试。

总结

生产高质量产品总是需要广泛和重复的测试;这正是计算机真正擅长的事情。尽可能自动化。

测试 JavaScript 代码是一个新兴的事物。围绕它的工具,模拟对象,甚至运行测试的工具都在不断变化。能够使用诸如 Node.js 之类的工具快速运行测试,而不必启动整个浏览器,这非常有帮助。这个领域在未来几年内只会得到改善。我很期待看到它带来的变化。

在下一章中,我们将看一些 JavaScript 中的高级模式,这些模式可能不是每天都想使用,但非常方便。

第十三章:高级模式

当我给这一章命名时,我犹豫不决,高级模式。这并不是关于比其他模式更复杂或复杂的模式。这是关于你不经常使用的模式。坦率地说,来自静态编程语言背景的一些模式看起来有些疯狂。尽管如此,它们是完全有效的模式,并且在各大项目中都在使用。

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

  • 依赖注入

  • 实时后处理

  • 面向方面的编程

依赖注入

我们在本书中一直在讨论的一个主题是使你的代码模块化的重要性。小类更容易测试,提供更好的重用,并促进团队更好的协作。模块化,松散耦合的代码更容易维护,因为变更可以受限。你可能还记得我们之前使用的一个 ripstop 的例子。

在这种模块化代码中,我们看到了很多控制反转。类通过创建者传递额外的类来插入功能。这将一些子类的工作责任移交给了父类。对于小项目来说,这是一个相当合理的方法。随着项目变得更加复杂和依赖图变得更加复杂,手动注入功能变得越来越困难。我们仍然在整个代码库中创建对象,将它们传递给创建的对象,因此耦合问题仍然存在,我们只是将它提升到了更高的级别。

如果我们将对象创建视为一项服务,那么这个问题的解决方案就呈现出来了。我们可以将对象创建推迟到一个中心位置。这使我们能够在一个地方简单轻松地更改给定接口的实现。它还允许我们控制对象的生命周期,以便我们可以重用对象或在每次使用时重新创建它们。如果我们需要用另一个实现替换接口的一个实现,那么我们可以确信只需要在一个位置进行更改。因为新的实现仍然满足合同,也就是接口,那么使用接口的所有类都可以对更改保持无知。

更重要的是,通过集中对象创建,更容易构造依赖于其他对象的对象。如果我们查看诸如UserManager变量的模块的依赖图,很明显它有许多依赖关系。这些依赖关系可能还有其他依赖关系等等。要构建一个UserManager变量,我们不仅需要传递数据库,还需要ConnectionStringProviderCredentialProviderConfigFileConnectionStringReader。天哪,要创建所有这些实例将是一项艰巨的工作。相反,我们可以在注册表中注册每个接口的实现,然后只需去注册表查找如何创建它们。这可以自动化,依赖关系会自动注入到所有依赖项中,无需显式创建任何依赖项。这种解决依赖关系的方法通常被称为“解决传递闭包”。

依赖注入框架处理构造对象的责任。在应用程序设置时,依赖注入框架使用名称和对象的组合进行初始化。从这个组合中,它创建一个注册表或容器。通过容器构造对象时,容器查看构造函数的签名,并尝试满足构造函数中的参数。以下是依赖图的示例:

依赖注入

在诸如 C#或 Java 等更静态类型的语言中,依赖注入框架很常见。它们通常通过使用反射来工作,反射是一种使用代码从其他代码中提取结构信息的方法。在构建容器时,我们指定一个接口和一个或多个可以满足该接口的具体类。当然,使用接口和反射执行依赖注入需要语言支持接口和内省。

在 JavaScript 中无法做到这一点。JavaScript 既没有直接的内省,也没有传统的对象继承模型。一种常见的方法是使用变量名来解决依赖问题。考虑一个具有以下构造函数的类:

var UserManager = (function () {
  function UserManager(database, userEmailer) {
    this.database = database;
    this.userEmailer = userEmailer;
  }
  return UserManager;
})();

构造函数接受两个非常具体命名的参数。当我们通过依赖注入构造这个类时,这两个参数通过查看容器中注册的名称并将它们传递到构造函数中来满足。然而,没有内省,我们如何提取参数的名称,以便知道传递到构造函数中的内容呢?

解决方案实际上非常简单。在 JavaScript 中,任何函数的原始文本都可以通过简单地调用toString来获得。因此,对于前面代码中给出的构造函数,我们可以这样做:

UserManager.toString()

现在我们可以解析返回的字符串以提取参数的名称。必须小心地解析文本,但这是可能的。流行的 JavaScript 框架 Angular 实际上使用这种方法来进行其依赖注入。结果仍然相对预格式。解析实际上只需要进行一次,并且结果被缓存,因此不会产生额外的开销。

我不会详细介绍如何实际实现依赖注入,因为这相当乏味。在解析函数时,你可以使用字符串匹配算法进行解析,也可以为 JavaScript 语法构建词法分析器和解析器。第一种解决方案似乎更容易,但更好的决定可能是尝试为代码构建一个简单的语法树,然后进行注入。幸运的是,整个方法体可以被视为一个单一的标记,因此比构建一个完全成熟的解析器要容易得多。

如果你愿意对依赖注入框架的用户施加不同的语法,甚至可以创建自己的语法。Angular 2.0 依赖注入框架di.js支持自定义语法,用于表示应该注入对象的位置以及表示哪些对象满足某些要求。

将其用作需要注入一些代码的类,看起来像这段代码,取自di.js示例页面:

@Inject(CoffeeMaker, Skillet, Stove, Fridge, Dishwasher)
export class Kitchen {
  constructor(coffeeMaker, skillet, stove, fridge, dishwasher) {
    this.coffeeMaker = coffeeMaker;
    this.skillet = skillet;
    this.stove = stove;
    this.fridge = fridge;
    this.dishwasher = dishwasher;
  }
}

CoffeeMaker实例可能看起来像以下代码:

@Provide(CoffeeMaker)
@Inject(Filter, Container)
export class BodumCoffeeMaker{
  constructor(filter, container){
  …
  }
}

你可能也注意到了,这个例子使用了class关键字。这是因为该项目非常前瞻,需要使用traceur.js来提供 ES6 类支持。我们将在下一章学习traceur.js文件。

实时后处理

现在应该明显了,在 JavaScript 中运行toString函数是执行任务的有效方式。这似乎很奇怪,但实际上,编写发出其他代码的代码与 Lisp 一样古老,甚至可能更古老。当我第一次了解 AngularJS 中依赖注入的工作原理时,我对这种 hack 感到恶心,但对解决方案的创造力印象深刻。

如果可以通过解释代码来进行依赖注入,那么我们还能做些什么呢?答案是:相当多。首先想到的是,你可以编写特定领域的语言。

我们在第五章中讨论了 DSL,行为模式,甚至创建了一个非常简单的 DSL。通过加载和重写 JavaScript 的能力,我们可以利用接近 JavaScript 但不完全兼容的语法。在解释 DSL 时,我们的解释器会写出转换代码为实际 JavaScript 所需的额外标记。

我一直喜欢 TypeScript 的一个很好的特性是,标记为 public 的构造函数参数会自动转换为对象的属性。例如,以下是 TypeScript 代码:

class Axe{
  constructor(public handleLength, public headHeight){}
}

编译为以下代码:

var Axe = (function () {
  function Axe(handleLength, headHeight) {
    this.handleLength = handleLength;
    this.headHeight = headHeight;
  }
  return Axe;
})();

我们可以在我们的 DSL 中做类似的事情。从以下Axe定义开始:

class Axe{
  constructor(handleLength, /*public*/ headHeight){}
}

我们在这里使用了注释来表示headHeight应该是公共的。与 TypeScript 版本不同,我们希望我们的源代码是有效的 JavaScript。因为注释包含在toString函数中,这样做完全没问题。

接下来要做的事情是实际上从中发出新的 JavaScript。我采取了一种天真的方法,并使用了正则表达式。这种方法很快就会失控,可能只适用于Axe类中格式良好的 JavaScript:

function publicParameters(func){
  var stringRepresentation = func.toString();
  var parameterString = stringRepresentation.match(/^function .*\((.*)\)/)[1];
  var parameters = parameterString.split(",");
  var setterString = "";
  for(var i = 0; i < parameters.length; i++){
    if(parameters[i].indexOf("public") >= 0){
      var parameterName = parameters[i].split('/')[parameters[i].split('/').length-1].trim();
      setterString += "this." +  parameterName + " = " + parameterName + ";\n";
    }
  }
  var functionParts = stringRepresentation.match(/(^.*{)([\s\S]*)/);
  return functionParts[1] + setterString + functionParts[2];
}

console.log(publicParameters(Axe));

在这里,我们提取函数的参数并检查具有public注释的参数。此函数的结果可以传回到 eval 中,用于当前对象的使用,或者如果我们在预处理器中使用此函数,则可以写入文件。通常不鼓励在 JavaScript 中使用 eval。

使用这种处理方式可以做很多不同的事情。即使没有字符串后处理,我们也可以通过包装方法来探索一些有趣的编程概念。

面向方面的编程

软件的模块化是一个很好的特性,本书的大部分内容都是关于模块化及其优势。然而,软件还有一些跨整个系统的特性。安全性就是一个很好的例子。

我们希望在应用程序的所有模块中都有类似的安全代码,以检查人们是否实际上被授权执行某些操作。所以如果我们有这样的一个函数:

var GoldTransfer = (function () {
  function GoldTransfer() {
  }
  GoldTransfer.prototype.SendPaymentOfGold = function (amountOfGold, destination) {
    var user = Security.GetCurrentUser();
    if (Security.IsAuthorized(user, "SendPaymentOfGold")) {
      //send actual payment
    } else {
      return { success: 0, message: "Unauthorized" };
    }
  };
  return GoldTransfer;
})();

我们可以看到有相当多的代码来检查用户是否被授权。这个相同的样板代码在应用程序的其他地方也被使用。事实上,由于这是一个高安全性的应用程序,安全检查在每个公共函数中都有。一切都很好,直到我们需要对常见的安全代码进行更改。这个更改需要在应用程序的每一个公共函数中进行。我们可以重构我们的应用程序,但事实仍然存在:我们需要在每个公共方法中至少有一些代码来执行安全检查。这被称为横切关注点。

在大多数大型应用程序中,还存在其他横切关注点。日志记录是一个很好的例子,数据库访问和性能检测也是如此。面向方面的编程AOP)提供了一种通过编织过程来最小化重复代码的方式。

方面是一段可以拦截方法调用并改变它们的代码。在.Net 平台上有一个叫做 PostSharp 的工具可以进行方面编织,在 Java 平台上有一个叫做 AspectJ 的工具。这些工具可以钩入构建管道,并在代码被转换为指令后修改代码。这允许在需要的地方注入代码。源代码看起来没有改变,但编译输出现在包括对方面的调用。方面通过被注入到现有代码中来解决横切关注点。在这里,你可以看到通过编织器将一个方面应用到一个方法:

面向方面的编程

当然,在大多数 JavaScript 工作流程中,我们没有设计时编译步骤的奢侈。幸运的是,我们已经看到了一些方法,可以让我们使用 JavaScript 实现横切。我们需要的第一件事是包装我们在测试章节中看到的方法。第二个是本章前面提到的tostring能力。

对于 JavaScript 已经存在一些 AOP 库,可能是一个值得探索的好选择。然而,我们可以在这里实现一个简单的拦截器。首先让我们决定请求注入的语法。我们将使用之前的注释的想法来表示需要拦截的方法。我们只需要将方法中的第一行作为注释,写上aspect(<aspect 的名称>)

首先,我们将采用稍微修改过的与之前相同的GoldTransfer类的版本:

class GoldTransfer {
  SendPaymentOfGold(amountOfGold, destination) {
    var user = Security.GetCurrentUser();
    if (Security.IsAuthorized(user, "SendPaymentOfGold")) {
    }
    else {
     return { success: 0, message: "Unauthorized" };
    }
  }
}

我们已经剥离了以前存在的所有安全性内容,并添加了一个控制台日志,以便我们可以看到它实际上是如何工作的。接下来,我们需要一个方面来编织进去:

class ToWeaveIn {
   BeforeCall() {
    console.log("Before!");
  }
  AfterCall() {
    console.log("After!");
  }
}

为此,我们使用一个简单的类,其中有一个BeforeCall和一个AfterCall方法,一个在原始方法之前调用,一个在原始方法之后调用。在这种情况下,我们不需要使用 eval,所以拦截更安全:

function weave(toWeave, toWeaveIn, toWeaveInName) {
  for (var property in toWeave.prototype) {
    var stringRepresentation = toWeave.prototype[property].toString();
    console.log(stringRepresentation);
    if (stringRepresentation.indexOf("@aspect(" + toWeaveInName + ")")>= 0) {
      toWeave.prototype[property + "_wrapped"] = toWeave.prototype[property];
      toWeave.prototype[property] = function () {
      toWeaveIn.BeforeCall();
      toWeave.prototype[property + "_wrapped"]();
      toWeaveIn.AfterCall();
    };
    }
  }
}

这个拦截器可以很容易地修改为一个快捷方式,并在调用主方法体之前返回一些内容。它也可以被改变,以便通过简单跟踪包装方法的输出,然后在AfterCall方法中修改函数的输出。

这是一个相当轻量级的 AOP 示例。对于 JavaScript AOP 已经存在一些框架,但也许最好的方法是利用预编译器或宏语言。

混入

正如我们在本书的早期看到的那样,JavaScript 的继承模式与 C#和 Java 等语言中典型的模式不同。JavaScript 使用原型继承,允许轻松地向类添加函数,并且可以从多个来源添加。原型继承允许以类似于备受诟病的多重继承的方式从多个来源添加方法。多重继承的主要批评是很难理解在某种情况下将调用哪个方法的重载。在原型继承模型中,这个问题在一定程度上得到了缓解。因此,我们可以放心地使用从多个来源添加功能的方法,这被称为 mixin。

Mixin 是一段代码,可以添加到现有类中以扩展其功能。它们在需要在不同的类之间共享函数的场景中最有意义,其中继承关系过于强大。

让我们想象一种情景,这种功能会很方便。在维斯特洛大陆,死亡并不总是像我们的世界那样永久。然而,那些从死者中复活的人可能并不完全与他们活着时一样。虽然PersonReanimatedPerson之间共享了很多功能,但它们之间并没有足够的继承关系。在这段代码中,您可以看到 underscore 的extend函数用于向我们的两个人类添加 mixin。虽然可以在没有underscore的情况下做到这一点,但正如前面提到的,使用库会使一些复杂的边缘情况变得方便:

var _ = require("underscore");
export class Person{
}
export class ReanimatedPerson{
}
export class RideHorseMixin{
  public Ride(){
    console.log("I'm on a horse!");
  }
}

var person = new Person();
var reanimatedPerson = new ReanimatedPerson();
_.extend(person, new RideHorseMixin());
_.extend(reanimatedPerson, new RideHorseMixin());

person.Ride();
reanimatedPerson.Ride();

Mixin 提供了一个在不同对象之间共享功能的机制,但会污染原型结构。

通过宏预处理代码并不是一个新的想法。对于 C 和 C++来说,这是非常流行的。事实上,如果你看一下 Linux 的 Gnu 工具的一些源代码,它们几乎完全是用宏编写的。宏因难以理解和调试而臭名昭著。有一段时间,像 Java 和 C#这样的新创建的语言之所以不支持宏,正是因为这个原因。

话虽如此,甚至像 Rust 和 Julia 这样的最新语言也重新引入了宏的概念。这些语言受到了 Scheme 语言的宏的影响,Scheme 是 Lisp 的一个方言。C 宏和 Lisp/Scheme 宏的区别在于,C 版本是文本的,而 Lisp/Scheme 版本是结构的。这意味着 C 宏只是被赞美的查找/替换工具,而 Scheme 宏则意识到它们周围的抽象语法树AST),使它们更加强大。

Scheme 的 AST 比 JavaScript 的简单得多。尽管如此,有一个非常有趣的项目叫做Sweet.js,它试图为 JavaScript 创建结构宏。

Sweet.js插入到 JavaScript 构建管道中,并使用一个或多个宏修改 JavaScript 源代码。有许多完整的 JavaScript 转译器,即生成 JavaScript 的编译器。这些编译器在多个项目之间共享代码时存在问题。它们的代码差异很大,几乎没有真正的共享方式。Sweet.js支持在单个步骤中扩展多个宏。这允许更好地共享代码。可重用的部分更小,更容易一起运行。

Sweet.js的一个简单示例如下:

let var = macro {
  rule { [$var (,) ...] = $obj:expr } => {
    var i = 0;
    var arr = $obj;
    $(var $var = arr[i++]) (;) ...
  }

  rule { $id } => {
    var $id
  }
}

这里的宏提供了 ECMAScript-2015 风格的解构器,将数组分割成三个字段。该宏匹配数组赋值和常规赋值。对于常规赋值,宏只是返回标识,而对于数组的赋值,它将分解文本并替换它。

例如,如果您在以下内容上运行它:

var [foo, bar, baz] = arr;

然后,结果将是以下内容:

var i = 0;
var arr$2 = arr;
var foo = arr$2[i++];
var bar = arr$2[i++];
var baz = arr$2[i++];

这只是一个宏的例子。宏的威力真的非常壮观。宏可以创建一个全新的语言或改变非常微小的东西。它们可以很容易地插入以适应任何需求。

技巧和窍门

使用基于名称的依赖注入允许名称之间发生冲突。为了避免冲突,值得在注入的参数前加上特殊字符。例如,AngularJS 使用$符号来表示一个注入的术语。

在本章中,我多次提到了 JavaScript 构建流水线。我们不得不构建一种解释性语言可能看起来有些奇怪。然而,从构建 JavaScript 可能会产生某些优化和流程改进。有许多工具可以用于帮助构建 JavaScript。像 Grunt 和 Gulp 这样的工具专门设计用于执行 JavaScript 和 Web 任务,但您也可以利用传统的构建工具,如 Rake、Ant,甚至是 Make。

总结

在本章中,我们涵盖了许多高级 JavaScript 模式。在这些模式中,我相信依赖注入和宏对我们最有用。您可能并不一定希望在每个项目中都使用它们。当面对问题时,仅仅意识到可能的解决方案可能会改变您对问题的处理方式。

在本书中,我广泛讨论了 JavaScript 的下一个版本。然而,您不需要等到将来才能使用这些工具。今天,有方法可以将较新版本的 JavaScript 编译成当前版本的 JavaScript。最后一章将探讨一些这样的工具和技术。

第十四章:ECMAScript-2015/2016 今天的解决方案

在本书中,我无法计算提到 JavaScript 即将推出的版本的次数,可以放心,这个数字很大。令人有些沮丧的是,语言没有跟上应用程序开发人员的要求。我们讨论过的许多方法在 JavaScript 的新版本中变得不再必要。然而,有一些方法可以让下一个版本的 JavaScript 在今天就能运行。

在本章中,我们将重点讨论其中的一些:

  • TypeScript

  • BabelJS

TypeScript

编译成 JavaScript 的语言并不少。CoffeeScript 可能是这些语言中最知名的一个例子,尽管将 Java 编译成 JavaScript 的 Google Web Toolkit 也曾经非常流行。微软在 2012 年发布了一种名为 TypeScript 的语言,以设计成 JavaScript 的超集,就像 C++是 C 的超集一样。这意味着所有语法上有效的 JavaScript 代码也是 TypeScript 代码。

微软自身在一些较大的网络属性中大量使用 TypeScript。Office 365 和 Visual Studio Online 都有大量用 TypeScript 编写的代码库。这些项目实际上早于 TypeScript 很长时间。据报道,从 JavaScript 过渡到 TypeScript 相当容易,因为它是 JavaScript 的超集。

TypeScript 的设计目标之一是尽可能与 ECMAScript-2015 和未来版本兼容。这意味着 TypeScript 支持 ECMAScript-2016 的一些特性,尽管当然不是全部,以及 ECMAScript-2015 的大部分特性。TypeScript 部分支持的 ECMAScript-2016 的两个重要特性是装饰器和 async/await。

装饰器

在早些章节中,我们探讨了面向方面的编程AOP)。使用 AOP,我们用拦截器包装函数。装饰器提供了一种简单的方法来做到这一点。假设我们有一个在维斯特洛传递消息的类。显然,那里没有电话或互联网,因此消息是通过乌鸦传递的。如果我们能监视这些消息将会非常有帮助。我们的CrowMessenger类看起来像下面这样:

class CrowMessenger {
  @spy
  public SendMessage(message: string) {
    console.log(`Send message is ${message}`);
  }
}
var c = new CrowMessenger();
var r = c.SendMessage("Attack at dawn");

您可能会注意到SendMessage方法上的@spy注释。这只是另一个拦截和包装函数的函数。在 spy 内部,我们可以访问函数描述符。正如您在以下代码中所看到的,我们获取描述符并操纵它以捕获发送到CrowMessenger类的参数:

function spy(target: any, key: string, descriptor?: any) {
  if(descriptor === undefined) {
    descriptor = Object.getOwnPropertyDescriptor(target, key);
  }
  var originalMethod = descriptor.value;

  descriptor.value =  function (...args: any[]) {
    var arguments = args.map(a => JSON.stringify(a)).join();
    var result = originalMethod.apply(this, args);
    console.log(`Message sent was: ${arguments}`);
    return result;
  }
  return descriptor;
}

间谍显然对于测试函数非常有用。我们不仅可以在这里监视值,还可以替换函数的输入和输出。考虑以下内容:

descriptor.value =  function (...args: any[]) {
  var arguments = args.map(a => JSON.stringify(a)).join();
  **var result = "Retreat at once";** 

  console.log(`Message sent was: ${arguments}`);
  return result;
}

装饰器可以用于除 AOP 之外的其他目的。例如,您可以将对象的属性注释为可序列化,并使用注释来控制自定义 JSON 序列化。我怀疑随着装饰器的支持,装饰器将变得更加有用和强大。已经有 Angular 2.0 在大量使用装饰器。

异步/等待

在第七章中,反应式编程,我们谈到了 JavaScript 编程的回调性质使代码非常混乱。尝试将一系列异步事件链接在一起时,这一点表现得更加明显。我们很快陷入了一个看起来像下面这样的代码陷阱:

$.post("someurl", function(){
  $.post("someotherurl", function(){
    $.get("yetanotherurl", function(){
      navigator.geolocation.getCurrentPosition(function(location){
        ...
      })
    })
  })
})

这段代码不仅难以阅读,而且几乎不可能理解。从 C#借鉴的异步/等待语法允许以更简洁的方式编写代码。在幕后,使用(或滥用,如果您愿意)生成器来创建真正的异步/等待的印象。让我们看一个例子。在前面的代码中,我们使用了返回客户端位置的地理位置 API。它是异步的,因为它与用户的机器进行一些 IO 以获取真实世界的位置。我们的规范要求我们获取用户的位置,将其发送回服务器,然后获取图像:

navigator.geolocation.getCurrentPosition(function(location){
  $.post("/post/url", function(result){
    $.get("/get/url", function(){
   });
  });
});

如果我们现在引入异步/等待,代码可以变成以下形式:

async function getPosition(){
  return await navigator.geolocation.getCurrentPosition();
}
async function postUrl(geoLocationResult){
  return await $.post("/post/url");
}
async function getUrl(postResult){
  return await $.get("/get/url");
}
async function performAction(){
  var position = await getPosition();
  var postResult = await postUrl(position);
  var getResult = await getUrl(postResult);
}

这段代码假设所有async响应都返回包含状态和结果的 promise 构造。事实上,大多数async操作并不返回 promise,但有库和工具可以将回调转换为 promise。正如您所看到的,这种语法比回调混乱要清晰得多,更容易理解。

类型

除了我们在前一节中提到的 ECMAScript-2016 功能之外,TypeScript 还具有一个非常有趣的类型系统。JavaScript 最好的部分之一是它是一种动态类型语言。我们反复看到,不受类型负担的好处节省了我们的时间和代码。TypeScript 中的类型系统允许您根据需要使用尽可能多或尽可能少的类型。您可以使用以下语法声明变量的类型:

var a_number: number;
var a_string: string;
var an_html_element: HTMLElement;

一旦变量被分配了一个类型,TypeScript 编译器将使用它不仅来检查该变量的使用情况,还将推断出可能从该类派生的其他类型。例如,考虑以下代码:

var numbers: Array<number> = [];
numbers.push(7);
numbers.push(9);
var unknown = numbers.pop();

在这里,TypeScript 编译器将知道unknown是一个数字。如果您尝试将其用作其他类型,比如以下字符串:

console.log(unknown.substr(0,1));

然后编译器会抛出一个错误。然而,你不需要为任何变量分配类型。这意味着你可以调整类型检查的程度。虽然听起来很奇怪,但实际上这是一个很好的解决方案,可以在不失去 JavaScript 的灵活性的情况下引入类型检查的严谨性。类型只在编译期间强制执行,一旦代码编译成 JavaScript,与字段相关的类型信息的任何提示都会消失。因此,生成的 JavaScript 实际上非常干净。

如果你对类型系统感兴趣,知道逆变等词汇,并且可以讨论逐渐类型的各个层次,那么 TypeScript 的类型系统可能值得你花时间去研究。

本书中的所有示例最初都是用 TypeScript 编写的,然后编译成 JavaScript。这样做是为了提高代码的准确性,通常也是为了让我不那么频繁地搞砸。我非常偏袒,但我认为 TypeScript 做得非常好,肯定比纯 JavaScript 写得好。

未来版本的 JavaScript 中不支持类型。因此,即使未来版本的 JavaScript 带来了许多变化,我仍然相信 TypeScript 在提供编译时类型检查方面有其存在的价值。每当我写 TypeScript 时,类型检查器总是让我惊讶,因为它多次帮我避免了愚蠢的错误。

BabelJS

TypeScript 的另一种选择是使用 BabelJS 编译器。这是一个开源项目,用于将 ECMAScript-2015 及更高版本转换为等效的 ECMAScript 5 JavaScript。ECMAScript-2015 中的许多更改都是语法上的美化,因此它们实际上可以表示为 ECMAScript 5 JavaScript,尽管不像那么简洁或令人愉悦。我们已经看到在 ES 5 中使用类似类的结构。BabelJS 是用 JavaScript 编写的,这意味着可以直接在网页上从 ECMAScript-2015 编译到 ES 5。当然,与编译器的趋势一样,BabelJS 的源代码使用了 ES 6 构造,因此必须使用 BabelJS 来编译 BabelJS。

在撰写本文时,BabelJS 支持的 ES6 函数列表非常广泛:

  • 箭头函数

  • 计算属性名称

  • 默认参数

  • 解构赋值

  • 迭代器和 for of

  • 生成器理解

  • 生成器

  • 模块

  • 数字文字

  • 属性方法赋值

  • 对象初始化程序简写

  • 剩余参数

  • 扩展

  • 模板文字

  • 承诺

BabelJS 是一个多用途的 JavaScript 编译器,因此编译 ES-2015 代码只是它可以做的许多事情之一。有许多插件提供各种有趣的功能。例如,“内联环境变量”插件插入编译时变量,允许根据环境进行条件编译。

已经有大量关于这些功能如何工作的文档可用,因此我们不会详细介绍它们。

如果您已经安装了 node 和 npm,那么设置 Babel JS 就是一个相当简单的练习:

 **npm install –g babel-cli** 

这将创建一个 BabelJS 二进制文件,可以进行编译,如下所示:

 **babel  input.js --o output.js** 

对于大多数用例,您将希望使用构建工具,如 Gulp 或 Grunt,它们可以一次编译多个文件,并执行任意数量的后编译步骤。

到目前为止,你应该已经厌倦了阅读关于在 JavaScript 中创建类的不同方法。不幸的是,你是我写这本书的人,所以让我们看一个最后的例子。我们将使用之前的城堡例子。

BabelJS 不支持文件内的模块。相反,文件被视为模块,这允许以一种类似于require.js的方式动态加载模块。因此,我们将从我们的堡垒中删除模块定义,只使用类。TypeScript 中存在但 ES 6 中不存在的另一个功能是使用public作为参数前缀,使其成为类的公共属性。相反,我们使用export指令。

一旦我们做出了这些更改,源 ES6 文件看起来像这样:

export class BaseStructure {
  constructor() {
    console.log("Structure built");
  }
}

export class Castle extends BaseStructure {
  constructor(name){
    this.name = name;
    super();
  }
  Build(){
    console.log("Castle built: " + this.name);
  }
}

生成的 ES 5 JavaScript 看起来像这样:

"use strict";

var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();

Object.defineProperty(exports, "__esModule", {
  value: true
});

function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeofcall === "function") ? call : self; }

function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var BaseStructure = exports.BaseStructure = function BaseStructure() {
  _classCallCheck(this, BaseStructure);
  console.log("Structure built");
};

var Castle = exports.Castle = function (_BaseStructure) {
  _inherits(Castle, _BaseStructure);
  function Castle(name) {
    _classCallCheck(this, Castle);
    var _this = _possibleConstructorReturn(this, Object.getPrototypeOf(Castle).call(this));
    _this.name = name;
    return _this;
  }
  _createClass(Castle, [{
    key: "Build",
    value: function Build() {
      console.log("Castle built: " + this.name);
    }
  }]);
  return Castle;
}(BaseStructure);

立即就会发现,BabelJS 生成的代码不如 TypeScript 中的代码干净。您可能还注意到有一些辅助函数用于处理继承场景。还有许多提到"use strict";。这是对 JavaScript 引擎的指示,它应该以严格模式运行。

严格模式阻止了许多危险的 JavaScript 实践。例如,在一些 JavaScript 解释器中,可以在不事先声明变量的情况下使用它是合法的:

x = 22;

如果x之前未声明,这将抛出错误:

var x = 22;

不允许在对象中复制属性,也不允许重复声明参数。还有许多其他实践方法,"use strict";会将其视为错误。我认为"use strict";类似于将所有警告视为错误。它可能不像 GCC 中的-Werror那样完整,但在新的 JavaScript 代码库中使用严格模式仍然是一个好主意。BabelJS 只是为您强制执行这一点。

默认参数

ES 6 中一个不是很重要但确实很好的功能是默认参数的引入。在 JavaScript 中一直可以调用函数而不指定所有参数。参数只是从左到右填充,直到没有更多的值,并且所有剩余的参数都被赋予 undefined。

默认参数允许为未填充的参数设置一个值,而不是 undefined:

function CreateFeast(meat, drink = "wine"){
  console.log("The meat is: " + meat);
  console.log("The drink is: " + drink);
}
CreateFeast("Boar", "Beer");
CreateFeast("Venison");

这将输出以下内容:

The meat is: Boar
The drink is: Beer
The meat is: Venison
The drink is: wine

生成的 JavaScript 代码实际上非常简单:

"use strict";
function CreateFeast(meat) {
  var drink = arguments.length <= 1 || arguments[1] === undefined ? "wine" : arguments[1];
  console.log("The meat is: " + meat);
  console.log("The drink is: " + drink);
}
CreateFeast("Boar", "Beer");
CreateFeast("Venison");

模板文字

表面上看,模板文字似乎是解决 JavaScript 中缺乏字符串插值的解决方案。在某些语言中,比如 Ruby 和 Python,您可以直接将周围代码中的替换插入到字符串中,而无需将它们传递给某种字符串格式化函数。例如,在 Ruby 中,您可以执行以下操作:

name= "Stannis";
print "The one true king is ${name}"

这将把${name}参数绑定到周围范围内的名称。

ES6 支持模板文字,允许在 JavaScript 中实现类似的功能:

var name = "Stannis";
console.log(`The one true king is ${name}`);

可能很难看到,但该字符串实际上是用反引号而不是引号括起来的。要绑定到作用域的标记由${}表示。在大括号内,您可以放置复杂的表达式,例如:

var army1Size = 5000;
var army2Size = 3578;
console.log(`The surviving army will be ${army1Size > army2Size ? "Army 1": "Army 2"}`);

这段代码的 BabelJS 编译版本只是简单地用字符串拼接来替代字符串插值:

var army1Size = 5000;
var army2Size = 3578;
console.log(("The surviving army will be " + (army1Size > army2Size ? "Army 1" : "Army 2")));

模板文字还解决了许多其他问题。模板文字内部的换行符是合法的,这意味着您可以使用模板文字来创建多行字符串。

考虑到多行字符串的想法,模板文字似乎对构建特定领域语言很有用:这是我们已经多次看到的一个主题。DSL 可以嵌入到模板文字中,然后从外部插入值。例如,可以使用它来保存 HTML 字符串(当然是 DSL)并从模型中插入值。这些可能取代今天使用的一些模板工具。

使用 let 进行块绑定

JavaScript 中的变量作用域很奇怪。如果在块内定义变量,比如在if语句内部,那么该变量仍然可以在块外部使用。例如,看下面的代码:

if(true)
{
  var outside = 9;
}
console.log(outside);

这段代码将打印9,即使外部变量显然超出了范围。至少如果你假设 JavaScript 像其他 C 语法语言一样支持块级作用域,那么它就超出了范围。JavaScript 中的作用域实际上是函数级的。在iffor循环语句附加的代码块中声明的变量被提升到函数的开头。这意味着它们在整个函数的范围内保持有效。

ES 6 引入了一个新关键字let,它将变量的作用域限制在块级。这种类型的变量非常适合在循环中使用,或者在if语句中保持正确的变量值。Traceur 实现了对块级作用域变量的支持。然而,由于性能影响,目前该支持是实验性的。

考虑以下代码:

if(true)
{
  var outside = 9;
  et inside = 7;
}
console.log(outside);
console.log(inside);

这将编译为以下内容:

var inside$__0;
if (true) {
  var outside = 9;
  inside$__0 = 7;
}
console.log(outside);
console.log(inside);

您可以看到内部变量被替换为重命名的变量。一旦离开代码块,变量就不再被替换。运行这段代码时,当console.log方法发生时,内部变量将报告为未定义。

在生产中

BabelJS 是一个非常强大的工具,可以在今天复制下一个版本的 JavaScript 的许多结构和特性。然而,生成的代码永远不会像原生支持这些结构那样高效。值得对生成的代码进行基准测试,以确保它继续满足项目的性能要求。

技巧和窍门

JavaScript 中有两个优秀的库可以在集合功能上进行函数式操作:Underscore.js 和 Lo-Dash。与 TypeScript 或 BabelJS 结合使用时,它们具有非常愉快的语法,并提供了巨大的功能。

例如,使用 Underscore 查找满足条件的集合成员的所有成员看起来像下面这样:

_.filter(collection, (item) => item.Id > 3);

这段代码将找到所有 ID 大于3的项目。

这两个库中的任何一个都是我在新项目中添加的第一件事。Underscore 实际上已经与 backbone.js 捆绑在一起,这是一个 MVVM 框架。

Grunt 和 Gulp 的任务用于编译用 TypeScript 或 BabelJS 编写的代码。当然,微软的开发工具链中也对 TypeScript 有很好的支持,尽管 BabelJS 目前没有直接支持。

总结

随着 JavaScript 功能的扩展,对第三方框架甚至转译器的需求开始减少。语言本身取代了许多这些工具。像 jQuery 这样的工具的最终目标是它们不再需要,因为它们已经被吸收到生态系统中。多年来,Web 浏览器的速度一直无法跟上人们愿望变化的速度。

AngularJS 的下一个版本背后有很大的努力,但正在努力使新组件与即将到来的 Web 组件标准保持一致。Web 组件不会完全取代 AngularJS,但 Angular 最终将简单地增强 Web 组件。

当然,认为不需要任何框架或工具的想法是荒谬的。总会有新的解决问题的方法和新的库和框架出现。人们对如何解决问题的看法也会有所不同。这就是为什么市场上存在各种各样的 MVVM 框架的原因。

如果您使用 ES6 构造来处理 JavaScript,那么工作将会更加愉快。有几种可能的方法来做到这一点,哪种方法最适合您的具体问题是需要更仔细调查的问题。

posted @ 2024-05-22 12:08  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报