JavaScript-应用设计-全-
JavaScript 应用设计(全)
原文:JavaScript Application Design
译者:飞龙
第一部分:构建过程
本书的第一部分致力于构建过程,并提供了对 Grunt 的实用介绍。你将了解构建过程的理论和实践中的为什么、如何以及是什么。
在第一章中,我们将探讨构建第一哲学包含的内容:构建过程和应用程序复杂性管理。然后,我们将开始处理我们的第一个构建任务,使用 lint 来防止代码中的语法错误。
第二章完全是关于构建任务的。你将了解构成构建的各种任务,如何配置它们,以及如何创建自己的任务。在每种情况下,我们将先探讨理论,然后通过使用 Grunt 的实际示例进行讲解。
在第三章中,我们将学习如何在保持敏感信息安全的同时配置应用程序环境。我们将讨论开发环境的工作流程,你将学习如何自动化构建步骤本身。
第四章随后描述了在发布我们的应用程序时需要考虑的更多任务,例如资产优化和管理文档。你将了解如何通过持续集成来保持代码质量,我们还将演示如何将应用程序部署到生产环境。
第一章:构建第一的介绍
本章涵盖
-
识别现代应用程序设计中的问题
-
定义构建第一
-
构建过程
-
在应用程序中管理复杂性
正确开发一个应用程序可能很困难。这需要规划。我曾在周末创建过应用程序,但这并不意味着它们设计得很好。即兴创作对于丢弃原型和验证想法来说很棒;然而,构建一个可维护的应用程序需要一个计划,这个计划是当前你心中的功能以及你可能在不久的将来添加的功能的粘合剂。我参与过无数的项目,其中应用程序的前端并没有达到其全部潜力。
最终,我意识到后端服务通常有一个专门负责其规划、设计和概览的架构师——而且通常不是一个架构师,而是一个整个团队。前端开发的情况则截然不同,开发者被期望制作一个应用程序的工作草图原型,然后被要求运行它,希望原型能够在生产实施中幸存。前端开发需要与后端开发一样多的对架构规划和设计的投入。
那些从互联网上复制几段代码,粘贴到我们的页面,然后就算完成的日子已经一去不复返了。将 JavaScript 代码随意拼凑在一起的做法已经不符合现代标准了。JavaScript 现在处于核心位置。我们有众多框架和库可供选择,这些可以帮助你通过编写小型组件而不是单体应用程序来组织你的代码。可维护性不是你可以随心所欲添加到代码库中的东西;这是你必须构建到应用程序中,并且从设计之初就要遵循的哲学。
如果没有内置可维护性,最终你会达到一个点,无法再向塔中添加任何更多部件。代码会变得复杂,错误变得越来越难以追踪。重构意味着暂停产品开发,而企业无法承担这种损失。发布计划必须保持,让塔楼倒塌是不可接受的,所以我们妥协。
1.1. 当事情出错时
你可能想要将新功能部署到生产环境中,以便人们可以尝试它。你需要执行多少步骤才能做到这一点?八个?五个?你为什么要冒在常规任务中犯错的风险,比如部署?部署应该和本地构建你的应用程序没有区别。一步。就是这样。
不幸的是,这很少是标准做法。你是否遇到过像我一样不得不手动执行许多这些步骤的挑战性位置?当然,你可以一步编译应用程序,或者你可能使用不需要预编译的解释型服务器端语言。也许后来你需要更新数据库到最新版本。你可能甚至为这些更新创建了一个脚本,然后你登录到数据库服务器,上传文件,并自行运行模式更新。
很好,你已经更新了数据库;然而,有些地方不对劲,应用程序正在抛出错误。你看看时钟。你的应用程序已经宕机超过 10 分钟了。这应该是一个简单的更新。你检查日志;你忘记将那个新变量添加到配置文件中。真愚蠢!你立即添加它,嘟囔着与代码库搏斗的话。你忘记在部署前更改配置文件;你忘记在部署到生产前更新它!
这听起来像是一个熟悉的仪式吗?不用担心,这不幸是一种常见的疾病,通过不同的应用程序传播。考虑以下描述的危机场景。
1.1.1. 如何在 45 分钟内损失 172,222 美元
我敢打赌,你会认为损失近五亿美元是一个严重的问题,这正是 Knight Capital 所遭遇的.^([1]) 他们开发了一个新功能,允许股票交易员参与所谓的零售流动性计划(RLP)。RLP 功能旨在取代一个已经停用近九年的未使用功能,称为 Power Peg(PP)。RLP 代码重用了一个标志,该标志用于激活 PP 代码。他们在添加 RLP 时移除了 Power Peg 功能,所以一切看起来都很顺利。或者至少他们认为看起来很顺利,直到他们切换开关的那一刻。
¹ 更多关于 Knight Capital 的信息,请参阅
bevacqua.io/bf/knight。
部署没有正式流程,由一名技术人员手动执行。这个人忘记将代码更改部署到他们八台服务器中的一台,这意味着在第八台服务器的情况下,PP 代码而不是 RLP 功能会在激活标志后面。直到一周后他们打开标志,激活了除一台以外的所有服务器的 RLP,而另一台服务器上则是九年前的 Power Peg 功能,他们才意识到有问题。
通过第八台服务器路由的订单触发了 PP 代码而不是 RLP。结果,错误的订单类型被发送到了交易中心。试图修正这种情况反而使问题更加严重,因为他们从拥有 RLP 代码的服务器上移除了 RLP 代码。简而言之,他们在不到一个小时的时间里损失了大约 4.6 亿美元。当你考虑到他们只需要建立一个更正式的构建流程来避免他们的崩溃时,整个情况感觉是荒谬的、不负责任的,并且事后看来,很容易避免。当然,这是一个极端案例,但它大胆地说明了这一点。一个自动化的流程会提高防止或至少更早发现人为错误的可能性。
1.1.2. 首先构建
在这本书中,我的目标是教你“首先构建”的设计哲学,即在编写任何代码之前设计干净、结构良好和可测试的应用程序。你将了解流程自动化,这将减少人为错误的可能性,例如导致 Knight Capital 破产的错误。首先构建是使你能够设计干净、结构良好和可测试的应用程序的基础,这些应用程序易于维护和重构。这是首先构建的两个基本方面:流程自动化和设计。
为了教你“先构建”的方法,这本书将向你展示一些技术,这些技术将提高你的软件质量以及你的 Web 开发工作流程。在第一部分中,我们将从学习如何建立适合现代 Web 应用程序开发的构建流程开始。然后,你将了解提高日常开发效率的最佳实践,例如在代码更改时运行任务,通过输入单个命令从终端部署应用程序,以及一旦应用程序投入生产后监控其状态。
书的第二部分——管理复杂性和设计——侧重于应用程序质量。在这里,我通过比较目前可用的不同选项,为你介绍如何编写更模块化的 JavaScript 组件。JavaScript 中的异步流程往往会变得复杂和冗长,这就是为什么我准备了一章,你将在其中学习编写更干净的异步代码,同时了解你可以使用哪些不同的工具来改进代码。以 Backbone 作为你的首选入门药物,你将了解足够的 JavaScript MVC 知识,以开始你的客户端 MVC 之路。我提到可测试的应用程序很重要,虽然模块化是正确方向上的一个很好的第一步,但测试值得单独一章。最后一章剖析了一个流行的 API 设计理念 REST(表示状态传输),帮助你设计自己的,同时深入到服务器端的应用程序架构,但始终关注前端。在查看一个 Build First 可以通过自动化流程避免的危机场景之后,我们将开始探索构建流程。
1.1.3. 初始化仪式
复杂的设置流程,例如当新团队成员加入时,也是你可能在自动化部门有所欠缺的迹象。令我痛苦的是,我参与过一些项目,第一次让开发环境工作需要花费一周时间。在你甚至开始理解代码做什么之前,需要整整一周。
下载大约 60GB 的数据库备份,创建一个配置你从未听说过的数据库,例如排序,然后运行一系列不太起作用的模式升级脚本。一旦你弄明白了这一点,你可能想要通过在你的环境中安装特定且极其过时的编解码器来修补你的 Windows Media Player,这感觉就像是将一头猪塞进一个填充的冰箱一样徒劳。
最后,当你拿一杯咖啡的时候,尝试一次性编译 130 多个项目单体。哦,但你忘了安装外部依赖项;这就解释了问题所在。不,等等,你还需要编译一个 C++程序,这样编解码器才能再次工作。再次编译,又过去了 20 分钟。仍然失败?射击。四处问问,也许?嗯,没有人真正知道。他们所有人刚开始时都经历了这个过程,并且从他们的记忆中抹去了这段经历。查看维基百科?当然,但信息散落在各处。这里那里有一些信息,但它们没有解决你的具体问题。
公司从未有过正式的初始化工作流程,随着事情开始堆积,组合起来变得越来越困难。他们不得不处理巨大的备份、升级、编解码器、网站所需的多个服务,以及每次更改分号时编译项目需要半小时。如果他们从一开始就自动化这些步骤,就像我们在“先构建”中将要做的那样,过程就会更加顺畅。
骑士资本公司的灾难和过于复杂的设置故事有一个共同点:如果他们提前规划并自动化构建和部署流程,他们的问题本可以避免。提前规划和自动化围绕应用程序的流程是“先构建”哲学的基本方面,你将在下一节中了解到这一点。
1.2. 使用“先构建”提前规划
在骑士资本公司的案例中,他们忘记将代码部署到生产 Web 服务器之一,如果有一个单步部署流程可以自动将代码部署到整个 Web 农场,就足以拯救公司免于破产。这个案例中的更深层次问题是代码质量,因为他们几乎有 10 年在代码库中闲置未使用的代码片段。
一个不提供任何功能增益的完整重构对产品经理来说没有吸引力;他们的目标是改善可见的、面向消费者的产品,而不是底层软件。相反,你可以通过逐步改进代码库和重构你接触到的代码,编写覆盖重构功能的测试,以及将遗留代码包装在接口中,以便以后可以重构,来持续提高你项目中代码的平均质量。
然而,重构本身并不能解决问题。从项目一开始就深深植入项目中的良好设计,比试图将其作为事后想法附加到不良结构上更有可能坚持下去。设计是本书的另一个基本方面,与之前提到的构建流程并列。
在我们深入探索 Build First 的未知领域之前,我想提到这不仅仅是一套只适用于 JavaScript 的原则。在很大程度上,人们通常将这些原则与后端语言相关联,例如 Java、C#或 PHP,但在这里我将它们应用于 JavaScript 应用程序的开发过程。正如我之前提到的,客户端代码往往没有得到应有的关爱和尊重。这通常意味着代码损坏,因为我们缺乏适当的测试,或者代码库难以阅读和维护。因此,产品(和开发者生产力)受到影响。
当谈到 JavaScript 时,鉴于解释型语言不需要编译器,新手开发者可能会认为这已经足够理由完全放弃构建过程。走这条路的问题在于,他们将在黑暗中射击:开发者不知道代码是否工作,直到它在浏览器中执行,也不知道它是否按预期工作。稍后,他们可能会发现自己手动部署到托管环境,并远程登录以调整一些配置设置以使其工作。
1.2.1. Build First 的核心原则
Build First 方法的核心是鼓励不仅建立构建过程,还要有清晰的应用程序设计。以下列表从高层次展示了采用 Build First 方法能给我们带来什么:
-
由于没有人为交互,因此降低了错误倾向
-
通过自动化重复性任务提高生产力
-
模块化、可扩展的应用程序设计
-
通过缩减复杂性提高可测试性和可维护性
-
符合性能最佳实践的发布版本
-
在发布前始终测试过的部署代码
观察图 1.1 图 1.1,从顶部行开始向下看,你可以看到
-
构建过程:这是您以自动化的方式编译和测试应用程序的地方。构建可以旨在促进持续开发,或调整以实现发布时的最大性能。
-
设计:您将在这里花费大部分时间,在编码的同时增强架构。在此过程中,您可能会重构代码并更新测试以确保组件按预期工作。每当您不是调整构建过程或准备部署时,您将设计和迭代应用程序的代码库。
-
部署和环境:这些涉及自动化发布过程和配置不同的托管环境。部署过程负责将您的更改传递到托管环境,而环境配置定义了环境以及与之交互的服务或数据库,从高层次上讲。
图 1.1. Build First 的四个关注领域的高级视图:构建过程、设计、部署和环境

如图 1.1 所示,Build First 应用程序有两个主要组件:围绕项目的流程,如构建和部署应用程序,以及应用程序代码的设计和质量,这些在您开发新功能时每天都会迭代改进。这两者同等重要,并且它们相互依赖以繁荣发展。如果您的应用程序设计不足,良好的流程也不会有任何好处。同样,没有良好的构建和部署流程的帮助,良好的设计也无法在之前描述的危机中生存。
与“先构建”方法一样,本书分为两部分。在第一部分中,我们查看构建过程(针对开发或发布进行优化)和部署过程,以及环境及其配置方式。第二部分深入探讨应用程序本身,并帮助我们设计出清晰简洁的模块化设计。它还引导我们了解在构建现代应用程序时必须考虑的实际设计因素。
在接下来的两个部分中,您将概述本书各部分讨论的概念。
1.3. 构建过程
构建过程旨在自动化重复性任务,例如安装依赖项、编译代码、运行单元测试以及执行任何其他重要功能。能够一次性执行所有必需任务的能力,称为单步构建,至关重要,因为它揭示了强大的机会。一旦您设置了单步构建,就可以根据需要多次执行它,而结果不会改变。这个特性被称为幂等性:无论您调用操作多少次,结果都将相同。
图 1.2 更详细地说明了自动化构建和部署过程所包含的步骤。
图 1.2. Build First 过程中的流程高级视图

自动化构建过程的优缺点
自动化构建过程最重要的优势可能是可以按需频繁部署。一旦新功能准备好,就立即向人类提供最新功能,这使我们能够通过反馈循环来获得更好的洞察力,从而更好地了解我们应该构建的产品。
设置自动化流程的主要缺点是您需要花费时间来构建流程,在您开始看到实际好处之前,但好处——如自动化测试、更高的代码质量、更精简的开发工作流程和更安全的部署流程——远远超过了构建该流程所付出的努力。一般来说,您只需设置一次流程,然后可以根据需要多次回放它,并在过程中进行一些调整。
构建
图 1.2 的顶部放大了构建过程工作流程中的构建部分(如图图 1.1 所示),详细说明了你在旨在开发或发布时的关注点。如果你旨在开发,你将希望最大化你的调试能力,我敢打赌你会喜欢一个知道何时执行自身部分而无需你采取任何行动的构建。这被称为持续开发(CD),你将在第三章中了解到它。构建的发布分发不关心 CD,但你将想要花时间优化你的资产,以便它们在生产环境中尽可能快地运行。
部署
图 1.2 的底部放大了部署过程(最初在图 1.1 中显示),它将调试或发布分发(我在书中称为具有特定目的的独立流程)部署到托管环境中。
此软件包将与特定环境的配置(它保护秘密,如数据库连接字符串和 API 密钥,并在第三章中讨论)一起工作,以提供服务。
第一部分致力于构建“构建优先”的方面:
-
第二章解释了构建任务,教你如何使用 Grunt 编写任务并配置它们,Grunt 是你将在第一部分中用作构建工具的任务运行器。
-
第三章涵盖了环境、如何安全地配置你的应用程序以及开发工作流程。
-
第四章讨论了你在发布构建期间应执行的任务。然后你将了解部署、在每次推送到版本控制时运行测试以及生产监控。
构建过程的好处
完成第一部分后,你将能够自信地在自己的应用程序上执行以下操作:
-
自动化重复性任务,如编译、压缩和测试
-
构建图标精灵图,以便将图标图形的 HTTP 请求减少到单个请求。这类精灵技术在第二章中有所讨论,以及其他 HTTP 1.x 优化技巧,作为提高页面速度和应用交付性能的手段。
-
无需费力即可启动新环境,并忽略开发与生产之间的区别
-
当相关文件更改时自动重启 Web 服务器和重新编译资产
-
支持多个环境,具有灵活的单步部署
Build First 方法在处理繁琐的任务时消除了人工劳动,同时从一开始就提高了您的生产力。Build First 认可构建过程对于迭代塑造可维护应用程序的重要性。应用程序本身也是通过迭代减少其复杂性来构建的。
清洁的应用程序设计和架构在本书的第二部分中得到了解决,该部分涵盖了应用程序内的复杂性管理,以及以提升质量标准为重点的设计考虑因素。让我们接下来回顾一下。
1.4. 处理应用程序复杂性和设计
模块化、管理依赖关系、理解异步流程、仔细遵循正确的模式以及测试,如果希望您的代码在特定规模下工作,这些都是至关重要的,无论使用哪种语言。在第二部分中,您将学习不同的概念、技术和模式,以应用于您的应用程序,使它们更加模块化、专注、可测试和可维护。图 1.3,从上到下观看,显示了我们在第二部分中将要遵循的进展。
图 1.3. 在第二部分中讨论的应用程序设计和开发关注点

模块化
您将学习如何将应用程序分解成组件,将这些组件分解成模块,然后编写简洁的函数,这些函数在模块内部具有单一目的。模块可以来自外部包,由第三方开发,您也可以自行开发它们。外部包应由包管理器处理,它代表您处理版本和更新,从而消除了手动下载依赖项(如 jQuery)的需要,并自动化了此过程。
如您将在第五章中学习到的,模块在代码中表明它们的依赖关系(它们所依赖的模块),而不是从全局命名空间中获取;这提高了自包含性。模块化系统将利用这些信息,能够解决所有这些依赖关系;它将让您免于维护长列表的<script>标签,以确保您的应用程序正确运行。
设计
您将了解关注点分离以及如何通过遵循模型-视图-控制器模式来以分层方式设计您的应用程序,进一步加强您应用程序的模块化。我将在第七章中告诉您关于共享渲染的内容,这是一种在服务器端首先渲染视图的技术,然后让客户端为同一单页应用程序的后续请求进行视图渲染。
异步代码
我将向您介绍不同类型的异步代码流技术,使用回调、Promise、生成器和事件,并帮助您驯服异步野兽。
测试实践
在第五章中,我们讨论了关于模块化的一切,了解闭包和模块模式,讨论模块系统和包管理器,并尝试确定每个解决方案的优势。第六章深入探讨了 JavaScript 中的异步编程。你将学习如何避免编写一周后让你困惑的回调汤,然后你将了解 ES6 中即将到来的 Promise 模式和生成器 API。
第七章致力于模式和最佳实践,例如如何最佳地开发代码,jQuery 是否是你正确的选择,以及如何编写可以在客户端和服务器端使用的 JavaScript 代码。然后我们将探讨 Backbone MVC 框架。请记住,Backbone 是我将用来向你介绍 JavaScript 中 MVC 的工具,但绝不是你实现这一目标的唯一工具。
在第八章中,我们将讨论测试解决方案、自动化以及大量单元测试客户端 JavaScript 的实际示例。你将学习如何通过测试特定组件来在单元级别开发 JavaScript 测试,并通过测试整个应用程序来在集成级别开发测试。
本书以关于 REST API 设计的章节结束,讨论在前端消费 REST API 的含义,以及一个充分利用 REST 的建议结构。
实际设计考虑因素
本书旨在让你思考在构建真实应用程序时做出的实际设计考虑,以及深思熟虑地决定最适合工作的最佳工具,同时关注你流程和应用程序本身的质量。当你开始构建一个应用程序时,你首先确定范围,选择技术堆栈,并组成一个最小可行构建过程。然后你开始构建应用程序,可能使用 MVC 架构,并在浏览器和服务器之间共享视图渲染引擎,这是我们将在第七章中讨论的内容。在第九章中,你将学习如何组装 API 的重要部分,并学习如何定义将被服务器端视图控制器和 REST API 使用的后端服务。
图 1.4 是典型“先构建”应用程序可能组织的概述。
图 1.4. 语境化架构考虑因素

构建过程
从左上角开始,图 1.4 概述了你可以如何通过决定如何组织代码库来开始构建一个构建过程,这有助于为你的架构提供一个起点。定义模块化应用程序架构是可维护代码库的关键,正如你在第五章中将会观察到的。然后,通过实施提供持续开发、集成和部署能力的自动化流程来巩固架构。
设计和 REST API
设计应用程序本身,包括一个可以有效地提高可维护性的 REST API,只有通过识别具有明确目的的清晰组件,使它们正交(这意味着它们不会在特定关注点上争夺资源)。在第九章中,我们将探讨一种多级应用程序设计方法,这可以帮助你通过严格定义层以及这些层之间的通信路径,快速地将 Web 界面从你的数据和业务逻辑中隔离出来。
战斗测试
一旦设计和构建了构建过程和架构,战斗测试就是你在可靠性问题上会全身心投入的地方。在这里,你将集成持续集成,其中测试在每次推送到你的版本控制系统时执行,甚至可能是持续部署,每天进行多次生产部署。最后,讨论了容错问题,如日志记录、监控和集群。这些内容在第四章中简要介绍,有助于使你的生产环境更加健壮,或者(在最坏的情况下)在事情出错时警告你。
在整个过程中,你将编写测试、调整构建过程,并微调代码。这将是一个极好的实验,让你测试“先构建”的理念。现在是时候让你感到舒适,并开始学习关于“先构建”哲学的具体内容了。
1.5. 深入了解“先构建”
质量是“先构建”的基石,采取的每一项措施都是为了实现一个简单的目标:提高你代码及其周围结构的质量。在本节中,你将了解代码质量以及如何在命令行中设置代码质量工具 lint。衡量代码质量是编写结构良好应用程序的良好第一步。如果你尽早开始这样做,你的代码库将很容易符合一定的质量标准,这就是我们为什么从一开始就做这件事的原因。
在第二章中,一旦你了解了 lint,我将向你介绍 Grunt,这是你将在整本书中使用来组合和自动化构建过程的构建工具。使用 Grunt 允许你在构建过程中运行代码质量检查,这意味着你不会忘记它们。
Grunt:达到目的的手段
Grunt 在 第一部分 中被广泛使用,并在 第二部分 的某些部分中使用,以驱动我们的构建过程。我选择 Grunt 是因为它是一个流行的工具,易于教授,并且能满足大多数需求:
-
它完全支持 Windows 系统。
-
不需要太多的 JavaScript 知识,并且学习起来和运行起来都很简单。
需要理解的是,Grunt 只是一个达到目的的手段,一个能够让你轻松组合本书中描述的构建过程的工具。这并不意味着 Grunt 是完成这项工作的绝对最佳工具,为了使这一点清晰,我已经整理了 Grunt 与其他两个工具的比较:*npm,它是一个可以充当轻量级构建工具的包管理器,以及 *Gulp,一个以代码驱动的构建工具,它与 Grunt 有几个共同约定。
如果你对手动构建工具(如 Gulp)或使用 npm run 作为构建系统感兴趣,那么你应该阅读 附录 C 中关于选择自己的构建工具的主题,它涵盖了选择构建工具的内容。
Lint 是一个代码质量工具,非常适合保持解释程序(如用 JavaScript 编写的程序)的秩序。你不需要打开浏览器来检查代码是否有语法错误,你可以在命令行中执行 lint 程序。它可以告诉你代码中可能存在的问题,例如未声明的变量、缺少分号或语法错误。尽管如此,lint 并不是魔杖:它不会检测代码中的逻辑问题,它只会警告你关于语法和风格错误。
1.5.1. 保持代码质量
Lint 可以用来确定给定的代码片段是否包含任何语法错误。它还强制执行一组 JavaScript 编码最佳实践规则,我们将在 第二部分 的开头,在 第五章 中介绍模块化和依赖关系管理时进行讨论。
大约 10 年前,Douglas Crockford 发布了 JSLint,这是一个严格的工具,可以检查代码并告诉我们代码中所有的小错误。Linting 的存在是为了帮助我们提高代码的整体质量。一个 lint 程序可以直接从命令行告诉你代码片段或文件列表中可能存在的问题,并且这个额外的优点是,你甚至不需要执行代码就能了解它的错误。这个过程在处理 JavaScript 代码时尤其有用,因为 lint 工具将充当某种编译器,确保你的代码在其知识范围内可以被 JavaScript 引擎解释。
在另一个层面上,linters(给 lint 程序起的名字)可以被配置为警告你关于过于复杂的代码,例如包含太多行的函数,可能使其他人困惑的复杂结构(例如 with 块、new 语句,或者在 JavaScript 中过于积极地使用 this),或者类似的代码风格检查。以下代码片段是一个例子(在线样本中列为 ch01/01_lint-sample):
function compose_ticks_count (start) {
start || start = 1;
this.counter = start;
return function (time) {
ticks = +new Date;
return ticks + '_' + this.counter++
}
}
在这个小片段中,有很多问题很容易看出,但它们可能并不那么容易发现。当通过 JSLint 分析时,你会得到预期和有趣的结果。它会抱怨你必须在使用变量之前声明它们,或者你遗漏了分号。根据你使用的 lint 工具,它可能会抱怨你对 this 关键词的使用。大多数 linters 也会抱怨你使用 || 而不是使用更易读的 if 语句的方式。你可以在网上 lint 这个样本.^([2]) 图 1.5 展示了 Crockford 工具的输出。
² 访问
jslint.com以获取在线样本。这是 Crockford 维护的原始 JavaScript linter。
图 1.5. 代码片段中发现的 lint 错误。

对于编译型语言,这些错误会在你尝试编译代码时被捕获,你不需要任何 lint 工具。然而,在 JavaScript 中,由于语言的动态性,没有编译器。这无疑是强大的,但与编译语言相比,更容易出错,编译语言甚至不允许你首先执行代码。
与编译不同,JavaScript 代码由像 V8(如 Google Chrome 中所见)或 SpiderMonkey(Mozilla Firefox 的引擎)这样的引擎进行解释。在其他引擎确实会编译 JavaScript 代码的情况下,最著名的是 V8 引擎,你无法从浏览器之外受益于它们的静态代码分析.^([3]) 动态语言如 JS 的一个明显缺点是,当你执行代码时,你无法确定代码是否真的能工作。虽然这是真的,但你可以使用 lint 工具极大地减少这种不确定性。此外,JSLint 建议我们避免某些编码风格实践,例如使用 eval, 留下未声明的变量,省略块语句中的大括号,等等。
³ 你可以在控制台中看到 Node.js,这是一个在 V8 上运行的 JavaScript 服务器端平台,但实际上,当 V8 检测到语法问题时,你的程序已经崩溃,那时就太晚了。无论如何,最好先进行 lint。
您是否在最后一段代码片段函数中发现了潜在的问题?请查看相关的代码示例(第一章, 01_lint-sample)以验证您的答案!提示:问题在于重复。修复后的版本也包含在源代码示例中;请确保查看所有这些好东西。
理解本书附带源代码
本书附带源代码中包含许多信息要点,包括一个经过调整的代码检查示例函数,该函数通过了代码检查,并进行了全面注释,以便您理解对其所做的更改。示例还进一步解释说,代码检查工具并非万无一失。
书中的其他代码示例包含类似的建议和信息要点,所以请务必查看它们!示例按章节组织,并按与书中相同的顺序出现。书中只简要讨论了一些示例,但所有相关的代码示例都进行了全面文档化,并准备好使用。
这种差异的原因在于,有时我想解释一个主题,但涉及到的代码可能太多,无法包含在书中。在这种情况下,我不想偏离问题的概念太远,但仍然希望您拥有这些代码。这样,您在阅读书籍时可以专注于学习,在浏览代码示例时可以专注于实验。
代码检查通常被称为在编写 JavaScript 时应该设置的第一项测试。当代码检查工具失败时,单元测试就派上用场了。这并不是说使用代码检查工具是不必要的,而是说仅仅进行代码检查是不够的!单元测试有助于确保您的代码按预期运行。单元测试在第八章中讨论,您将学习如何为第二部分中开发的代码编写测试,[第二部分]是专门用于编写模块化、可维护和可测试的 JavaScript 代码的。
接下来,您将从零开始构建一个构建过程。您将从设置一个用于检查代码的任务开始,然后从命令行运行它,类似于使用编译器时的过程;您将学会每次更改时都运行构建,并查看代码是否仍然“编译”通过代码检查器。第三章将教您如何让构建过程自动运行,这样您就不必重复这样做,但暂时这样做是可以的。
您如何在命令行中直接使用 JSLint 之类的代码检查工具?很高兴您提出了这个问题。
1.5.2. 命令行中的代码检查
将任务添加到构建过程的最常见方法之一是使用命令行执行该任务。如果您从命令行执行任务,那么将其集成到构建过程中会很容易。您将使用 JSHint^([4])来检查您的软件。
⁴ 关于 JSHint 的更多信息,请参阅
jshint.com。
JSHint 是一个命令行工具,用于检查 JavaScript 文件和代码片段。它是用 Node.js 编写的,Node.js 是一个用于使用 JavaScript 开发应用程序的平台。如果你需要 Node.js 基础知识的快速概述,请参阅附录 A,我在其中解释了模块是什么以及它们是如何工作的。如果你想要对 Node.js 进行更深入的分析,请参阅 Mike Cantelon 等人所著的《Node.js in Action》(Manning,2013)。了解这一点在下一章使用我们选择的构建工具 Grunt 时也将非常有用。
Node.js 解释
Node 是一个相对较新的平台,你现在可能已经听说过它了。它最初于 2009 年发布,遵循事件驱动和单线程模式,这转化为高性能的并发请求处理。在这方面,它与 Nginx 的设计相似,Nginx 是一个高度可扩展的多用途且非常流行的反向代理服务器,旨在提供静态内容并将其他请求管道传输到应用服务器(如 Node)。
考虑到 Node.js 主要是服务器端的 JavaScript(大部分情况下),它被赞扬为前端工程师特别容易采用。它还使得将前端从后端完全抽象出来成为可能,仅通过数据和 REST API 接口进行交互,例如你将在第九章中学习设计和构建的接口。
^a 关于从后端抽象前端的信息,请参阅
bevacqua.io/bf/node-frontend。
Node.js 和 JSHint 安装
这里是安装 Node.js 和 JSHint 命令行界面(CLI)工具的步骤。附录 A 中还提供了替代的 Node.js 安装方法和故障排除信息。
1. 按照图 1.6 所示,访问
nodejs.org,并点击 INSTALL 按钮下载最新版本的 Node。图 1.6.
nodejs.org网站
2. 执行下载的文件,并按照安装说明进行操作。
你将免费获得一个命令行工具 npm(Node 包管理器),因为它与 Node 一起捆绑。这个包管理器 npm 可以从你的终端用来安装、发布和管理你的 node 项目的模块。模块可以按项目安装,或者全局安装,这使得它们更容易在终端中直接访问。实际上,两者的区别在于全局安装的包被放在一个在 PATH 环境变量中的文件夹里,而那些不是的则被放在你执行命令时的同一文件夹中的 node_modules 文件夹里。为了保持项目自包含,本地安装总是首选。但在你希望系统范围内使用,如 JSHint 检查器这样的实用工具时,全局安装更为合适。-g 修饰符告诉 npm 全局安装 jshint。这样,你就可以在命令行上使用 jshint。
1. 打开你喜欢的终端窗口并执行
npm install -g jshint,如图 1.7 所示。如果失败,你可能需要使用sudo获取提升的权限;例如,sudo npm install -g jshint.。图 1.7. 通过 npm 安装 jshint
2. 运行
jshint --version.应该会输出jshint程序的版本号,如图 1.8 所示。可能版本不同,因为活跃开发中的模块版本经常变化。图 1.8. 验证
jshint在你的终端中是否工作
下一个部分将解释如何检查你的代码。
检查你的代码
现在,你应该已经在你的系统上安装了 jshint,并在你的终端中可以访问,正如你已验证的那样。要使用 JSHint 检查你的代码,你可以使用 cd 命令切换到你的项目根目录,然后输入 jshint .(点号告诉 JSHint 检查当前文件夹中的所有文件)。如果操作耗时过长,你可能需要添加 --exclude node_modules 选项;这样你将只检查你自己的代码,并忽略通过 npm install 安装的第三方代码。
命令完成后,你会得到一个详细报告,指示你的代码状态。如果你的代码有任何问题,该工具将报告每个问题的预期结果和行号。然后它将以错误代码退出,允许你在检查失败时“中断构建”。每当构建任务未能产生预期的输出时,整个过程应该被终止。这带来了一系列好处,因为它防止了在出现问题时继续工作,拒绝完成构建直到你修复任何问题。图 1.9 展示了检查代码片段的结果。
图 1.9. 从终端使用 JSHint 检查

一旦 JSHint 设置完成,你可能会想就此结束,因为那似乎是你唯一要完成的任务;然而,如果你想要添加额外的构建任务,这样做并不会很好地扩展。你可能想在构建过程中包含一个单元测试步骤;这会变成一个问题,因为你现在至少需要运行两个命令:jshint和另一个执行测试的命令。这样扩展并不好。想象一下,记得使用jshint以及半打其他带有参数的命令。这将非常繁琐,难以记住,且容易出错。你不想损失五亿美元,对吧?
然后,你最好开始整理你的构建任务,因为即使你现在只有一个,很快你将会有十几个!编写构建过程有助于你从自动化的角度思考,并且它将通过避免重复步骤来帮助你节省时间。
每种语言都有自己的构建工具集,你可以使用。大多数语言都有一个突出的工具,其采用率远高于其他工具。当谈到 JavaScript 构建系统时,Grunt 是最受欢迎的工具之一,拥有数千个插件(帮助你完成构建任务)可供选择。如果你正在为另一种语言编写构建过程,你可能需要研究自己的。尽管本书中的构建任务是用 JavaScript 编写的并使用了 Grunt,但我描述的原则几乎适用于任何语言和构建工具。
翻到第二章,看看你如何可以将 JSHint 集成到 Grunt 中,作为你开始通过构建过程领域的实际操作之旅。
1.6. 摘要
本章作为概述,介绍了你在本书其余部分将要深入探讨的概念。以下是本章你所学到的重点内容:
-
现代 JavaScript 应用程序开发由于缺乏对设计和架构的关注而存在问题。
-
构建优先是一个能够实现自动化流程和维护性应用程序设计的解决方案,并鼓励你思考你正在构建的内容。
-
你学习了 lint,并通过 lint 运行代码,在不使用浏览器的情况下提高了其代码质量。
-
在第一部分中,你将了解所有关于构建过程、部署和环境配置的内容。你将使用 Grunt 来开发构建,并在附录 C 中了解你可以使用的其他工具。
-
第二部分致力于应用程序设计中的复杂性。模块化、异步代码流、应用程序和 API 设计以及可测试性都扮演着角色,它们在第二部分中汇集在一起。
你几乎还没有触及使用“构建优先”方法进行应用程序设计所能实现的一切!我们还有很多内容要覆盖!让我们转到第二章,在那里我们将讨论你在构建过程中可能需要执行的最常见任务,并使用 Grunt 进行实现示例。
第二章. 构建任务和流程的组成
本章涵盖
-
理解构建中应该发生什么
-
了解关键构建任务
-
使用 Grunt 运行关键任务
-
使用 Grunt 配置构建流程
-
创建你自己的 Grunt 任务
在上一章中,你快速了解了“先构建”方法的样子,并浏览了一个代码检查任务。在本章中,我们将讨论常见的构建任务和一些更高级的任务。我将揭示每个任务的使用案例和背后的原因,并探讨如何在 Grunt 中实现它们。学习理论可能听起来很枯燥,但如果你使用的是除了 Grunt 之外的任务运行器,那么这尤其重要,我相信你最终会这样做的。
Grunt 是一个配置驱动的构建工具,它可以帮助你轻松地设置复杂的任务——如果你知道你在做什么。使用 Grunt,你可以组合工作流程,例如我在第一章中描述的,这些工作流程可以调整以提高开发效率或优化发布。同样,Grunt 还有助于部署流程,你将在第四章中分析这些流程。
本章重点介绍构建任务,而不是试图教你关于 Grunt 的所有内容。只要你理解其目标背后的概念,你就可以学习使用新的工具,但如果你不理解这些基本概念,你就无法正确地学习使用其他工具。如果你想更深入地了解 Grunt 本身,请查看附录 B。阅读该附录对于理解本章内容并非至关重要;然而,它确实定义了你在第一部分中将要使用的 Grunt 功能。
我们将本章开始于对 Grunt 及其核心概念的快速介绍;然后你将在本章的剩余部分学习关于构建任务和使用一些不同的工具。我们将探讨预处理(按照 Manning MOS)任务,例如将代码编译成另一种语言,后处理(按照 Manning MOS)任务,例如资产压缩和图像精灵化,以及代码完整性任务,例如运行 JavaScript 单元测试和检查 CSS 代码。然后你将学习如何在 Grunt 中编写自己的构建任务,并查看一个编写自己的数据库模式更新任务案例研究,包括回滚!
让我们开始吧!
2.1. 介绍 Grunt
Grunt^([1]) 是一个任务运行器,它帮助你执行命令、运行 JavaScript 代码,并使用完全用 JavaScript 编写的配置来配置不同的任务。Grunt 从 Ant 中借用了其构建概念,并允许你使用 JavaScript 定义你的流程。
¹ 在
bevacqua.io/bf/grunt上了解更多关于 Grunt 的信息。你还应该查看附录 B。
图 2.1 从高层次上剖析了 Grunt,展示了如何配置它以及定义构建任务中的关键角色。
图 2.1. 一瞥 Grunt:任务和目标在配置中结合。

-
任务执行一个动作。
-
目标帮助定义任务的上下文。
-
任务配置允许你确定特定任务-目标组合的选项。
Grunt 任务在 JavaScript 中配置,大部分配置可以通过传递一个对象给grunt.initConfig方法来完成,描述受任务影响的文件,并传入一些选项来调整特定任务目标的行为了。
在单元测试任务的情况下,你可能只为本地开发运行几个测试,或者你可能在产品发布前执行所有测试。
图 2.2 展示了 JavaScript 代码中任务配置的样子,详细说明了grunt.initConfig方法和其约定。在列举文件时可以使用通配符,使用这些模式称为globbing;我们将在 2.2.2 节中详细探讨 globbing。
图 2.2. 代码中解释的 Grunt 任务配置。每个任务和任务目标都是单独配置的。

任务可以从插件中导入,这些插件是包含一个或多个 Grunt 任务的 Node 模块(设计良好且自包含的代码块)。你只需要确定要应用给它们的配置,然后就可以了;任务本身由插件处理。你将在本章中大量使用插件.^([2])
² 你可以在网上搜索 Grunt 插件,地址为
gruntjs.com/plugins。
你也可以创建自己的任务,正如你将在 2.4 节和 2.5 节中调查的那样。Grunt 附带一个名为grunt的 CLI(命令行界面),它提供了一个简单的界面,可以直接从命令行执行构建任务。让我们来安装它。
2.1.1. 安装 Grunt
你应该已经从第一章中 JSHint 代码检查工具的安装中安装了npm,这是 Node 附带的包管理器。开始使用 Grunt 很简单。在你的终端中输入以下内容,它将为你安装grunt^([3]) CLI:
³ 在
bevacqua.io/bf/grunt了解更多关于 Grunt 的信息。
npm install -g grunt-cli
-g标志表示包应该全局安装;这让你可以在终端中执行grunt,无论当前工作目录是什么。
查找配套的注释示例
检查配套源代码中的完整工作示例。你可以在 ch02 目录下的 01_intro-to-grunt 文件夹中找到它。本章的其余示例也可以在 ch02 目录中找到。其中大部分包含代码注释,可以帮助你理解示例,如果你在理解示例上有困难的话。
你还需要采取一个额外的步骤,即创建一个 package.json 清单文件。这些文件描述 Node 项目。它们指明了项目依赖的包列表,以及诸如项目名称、版本、描述和主页等元数据。为了让 Grunt 在你的项目中工作,你需要将其添加为 package.json 中的开发依赖。它应该是一个开发依赖,因为你不会在其他任何地方使用 Grunt,除了你的本地开发环境。你可以创建一个包含以下 JSON 代码的最小 package.json 文件,并将其放置在你的项目根目录中:
{}
那就足够了。Node 包管理器 (npm) 可以将依赖项添加到你的 package.json 中,只要文件存在并且包含一个有效的 JSON 对象,即使它是一个空的 {} 对象。
本地安装 Grunt
接下来,你需要安装 grunt 包。这次,-g 修饰符将不起作用,因为它需要是本地安装,而不是全局安装^([4))——这就是你创建 package.json 文件的原因。使用 --save-dev 修饰符来表示模块是一个开发依赖。
⁴ Grunt 要求你为 Grunt 包和任何任务插件进行本地安装。这确保了你的代码可以在不同的机器上工作,因为你不能在 package.json 清单中包含全局包。
命令看起来是这样的:npm install --save-dev grunt。在 npm 完成安装后,你的 package.json 清单将如下所示:
{
"devDependencies": {
"grunt": "~0.4.1"
}
此外,Grunt 模块将被安装到你的项目内部的 node_modules 目录中。这个目录将包含你作为 Grunt 设置一部分使用的所有模块,并且它们也会在包清单中声明。
创建 Gruntfile.js 文件
最后一步是创建一个 Gruntfile.js 文件。Grunt 使用此文件来加载任何可用的任务,并使用任何所需的参数来配置它们。以下代码显示了最小的 Gruntfile.js 模块:
module.exports = function (grunt) {
grunt.registerTask('default', []); // register a default task alias
};
请注意关于这个看起来无辜的文件的一些事项。Grunt 文件是遵循 CommonJS 模块规范的 Node 模块^([5)),因此你每个文件中编写的代码不会立即对他人可用。本地的 module 对象是隐式对象,而不是像浏览器中的 window 这样的全局对象。当你导入其他模块时,你得到的是在 module.exports 中公开的接口。
⁵ 在
bevacqua.io/bf/commonjs阅读 Common.JS 模块规范。
Node 模块
你可以在 附录 A 中了解更多关于 Common.JS 的信息,这是 Node.js 模块背后的规范,它涵盖了这些模块。它也将在 第五章 中讨论,当我们谈到模块化时。附录 B 扩展了 附录 A,以加强你对 Grunt 的理解。
在前面的代码片段中,grunt.registerTask 行告诉 Grunt 定义一个默认任务,当你在命令行中不带任何参数运行 grunt 时将执行此任务。数组指示一个任务别名,如果存在,将运行数组中命名的所有任务。例如,['lint', 'build'] 将运行代码风格检查任务,然后运行构建任务。
在这个阶段运行 grunt 命令不会产生任何效果,因为你注册的唯一任务是一个空的任务别名。你肯定迫不及待地想要设置你的第一个 Grunt 任务,那么让我们开始吧。
2.1.2. 设置你的第一个 Grunt 任务
设置 Grunt 任务的第一个步骤是安装一个执行你所需功能的插件;然后你添加配置到代码中,你就可以运行任务了。
Grunt 插件通常以 npm 模块的形式分发,这是某人发布的 JavaScript 代码片段,以便你可以使用。我们将首先安装 JSHint 插件,这将允许你使用 Grunt 运行 JSHint。请注意,你在 第一章 中安装的 jshint CLI 工具在这里是完全不必要的;Grunt 插件包含了你运行任务所需的所有内容,无需 jshint CLI。以下命令将从 npm 注册表中获取 JSHint Grunt 插件,将其安装到 node_modules 目录,并将其添加到你的 package.json 文件中作为开发依赖项:
npm install --save-dev grunt-contrib-jshint
接下来,你需要调整你的 Gruntfile 文件,告诉 Grunt 对其自身进行代码风格检查,因为它是 JavaScript 代码。你还需要告诉它加载 JSHint 插件包,该包包含设置代码风格检查的任务,并更新你的 default 任务,这样你就可以在命令行中使用 grunt 对代码进行代码风格检查。以下列表(在代码示例中命名为 ch02/01_intro-to-grunt)展示了如何配置你的 Gruntfile。
列表 2.1. 示例 Gruntfile.js

每当你安装一个包时,你都需要在 Gruntfile 中加载它,使用 grunt.loadNpmTasks,就像在 列表 2.1 中那样。它会加载包中的任务,以便你可以配置和执行它们。然后你需要配置这些任务,这可以通过将一个对象传递给 grunt.initConfig 来完成。你使用的每个任务插件都需要配置,我会在我们逐一介绍它们时教你如何配置每个插件。最后,我已经更新了 default 别名以运行 jshint 任务。默认别名定义了当 grunt 在没有任务参数的情况下执行时将执行哪些任务。以下是输出结果的截图。
图 2.3. 我们的第一个 Grunt 任务及其输出。我们的代码是经过代码风格检查的,这意味着它不包含任何语法错误。

2.1.3. 使用 Grunt 管理构建过程
你几乎处于与第一章结束时的相同位置,当时你可以对你的 JavaScript 进行 lint 检查,只是你现在没有这样做。Grunt 将帮助你构建一个完整的构建过程,这是“先构建”哲学的核心。你可以相对轻松地专注于不同的任务,取决于你是为本地开发或诊断构建,还是构建人类最终将消费的最终产品。让我们检查一下构建任务中发现的几个属性。
你设置的 lint 任务将成为一个更强大的构建的基础,因为你在阅读本书的第一部分第一部分时扩展了你的理解。这个任务无意中展示了构建任务的一个基本属性:在绝大多数情况下,它们将是幂等的——任务的重复执行不应该产生不同的结果。在 lint 任务的情况下,这可能意味着每次都会得到相同的警告,只要你没有更改源代码。更常见的是,构建任务是一个或多个提供的输入文件的函数。幂等性属性,结合你不应该手动执行任何操作的事实,转化为更一致的结果。
创建工作流程和持续开发
你构建中的任务旨在遵循一组明确定义的步骤以实现一个特定的目标,例如准备发布构建。正如第一章中提到的,这被称为工作流程。对于特定的工作流程,某些任务可能是可选的,而其他任务可能是关键的。例如,你没有优化图像的动机,所以在你的本地开发环境中,它们会变得更小。因为它不会带来任何明显的性能提升,所以在那种情况下跳过那个任务是完全可以接受的。无论你的工作流程是为开发还是发布而设计的,你可能都想确保留意 lint 任务的问题。
图 2.4 将帮助你理解构建过程中涉及的开发、发布和部署部分:它们如何相互关联,以及它们在组合不同的工作流程时如何结合在一起。
图 2.4. 构建和部署流程中的关注点分离

开发流程
只需看一眼图的最上面一行,你就可以看到生产力和监视更改是开发流程的关键方面,而在发布流程中,它们是完全不必要的,甚至可能是一个障碍。你也可能注意到,这两个流程都会产生一个构建的应用程序,尽管在开发过程中构建的应用程序是为了持续开发,我们将在第三章中深入探讨。
发布流程
在发布流程中,我们关注性能优化和构建一个经过良好测试的应用程序。在这里,我们将运行一个略微修改后的开发流程,其中减少应用程序的字节大小是首要任务。
部署流程
部署流程根本不构建应用程序。相反,它重用了在其他两个流程中准备好的构建分发,并将其交付到托管环境中。你将在第四章中了解有关部署流程的所有内容。
任何合理的构建流程都需要在每一步都实现自动化;否则,你将无法达到提高生产力和减少错误倾向的目标。在开发过程中,你应该在文本编辑器和浏览器之间切换,而无需自己执行构建。这被称为持续开发,因为进入外壳并输入一些命令来编译应用程序所带来的摩擦被消除了。你将在第三章中学习如何使用文件监视和其他机制来完成这项任务。部署应用程序应该与构建流程分开,但也应该实现自动化;这使你能够一步构建和部署应用程序。同样,提供应用程序服务也应该严格与构建过程分开。
在下一节中,我们将深入探讨使用 Grunt 的构建任务。具体来说,我们将从预处理任务开始,例如将 LESS(一种可以编译为 CSS 的语言)转换为 CSS,以及后处理任务,如捆绑和压缩,这些任务有助于你优化和微调以发布。
2.2. 预处理和静态资产优化
每当我们谈论构建一个 Web 应用程序时,我们都需要谈论预处理。通常,你会发现自己在使用互联网浏览器原生不支持的语言,因为它们通过提供普通 CSS(如供应商前缀)、HTML 或 JavaScript 所不具备的功能,帮助你绕开重复性工作。
这里的目的不是让你学习 LESS,一个在下一节中引入的 CSS 预处理器,甚至也不是学习 CSS。有很好的资源专门教授这些。目的是让你意识到使用预处理语言的明显好处。预处理并不只是关于 CSS。预处理器帮助将一种语言中的源代码转换为各种目标语言。例如,更强大和更具表现力的 LESS 语言可以在构建时转换为原生 CSS。使用预处理器的理由可能各不相同,但它们可以归类为提高生产力、减少重复或拥有更愉悦的语法。
后处理任务,如压缩和打包,主要是为了优化构建以供发布,但它们与预处理紧密相关,以至于它们都属于同一个话题。我们将讨论预处理,使用 LESS,然后我们将涉猎 globbing,这是在 Grunt 中使用的文件路径模式匹配机制,在我们继续到打包和压缩之前,这将调整你的应用程序的性能,以便适合人类消费。
到本节结束时,你将更清楚地了解如何使用不同的、更合适的语言对资产进行预处理,以及如何进行后处理,以提高性能,使人类体验更加顺畅。
2.2.1. 讨论预处理
在当今的 Web 开发中,语言预处理器相当普遍。除非你过去十年一直住在山洞里,否则你可能明白预处理器可以帮助你编写更干净的代码,就像你在第一章中第一次学习 lint 时那样,但需要额外的工作才能变得有用。简单来说,当你用一种翻译成另一种语言的编程语言编写代码时,预处理就是翻译步骤。
你可能出于几个原因不想用目标语言编写代码:可能是因为它太重复了,太容易出错,或者你只是不喜欢那种语言。这就是这些高级语言发挥作用的地方,它们调整以保持你的代码简洁简单。然而,在高级语言中编写代码是有代价的:浏览器不理解它们。因此,你将在前端开发中遇到的最常见的构建任务之一就是将代码编译成浏览器能理解的东西,即 JavaScript 和 CSS 样式。
有时,预处理器也会比 Web 的“原生”语言(HTML、CSS 和 JavaScript)提供实际的好处。例如,几个 CSS 预处理器提供了必要的工具,这样你就不需要针对每个浏览器。通过消除这些浏览器不一致性,预处理语言提高了你的生产力,并使你的工作不那么繁琐。
少即是多
以 LESS 为例。LESS 是一种强大的语言,它允许你使用遵循 DRY(不要重复自己)原则的应用程序设计变体来编写代码,因为它可以帮助你编写更少的重复代码。在纯 CSS 中,你经常会一遍又一遍地重复自己,为所有不同的供应商前缀编写相同的值,以最大化你想要应用的风格规则的浏览器支持。
为了说明这一点,以border-radius CSS 属性为例,每当你要为具有圆角边框的元素设置样式时都会使用它。以下是如何使用纯 CSS 编写它们的示例。
列表 2.2. 纯 CSS 中的圆角边框

对于一次性规则来说可能还可以,但对于像 border-radius 这样的属性,编写这样的纯 CSS 很快就会变得不可接受,因为这种情况太常见了。LESS 允许你以更易于编写、阅读和维护的方式编码。在这个用例中,你可以设置一个 .border-radius 可重用函数,代码可能如下所示。
列表 2.3. 使用 LESS 实现圆角边框

LESS 和类似工具通过允许你重用 CSS 代码片段来提高你的生产力。
LESS 的 DRY 比更多的 WET 更好
一旦你需要在一个以上的地方使用 border-radius 属性,你将享受到不重复编写一切(WET)的好处。通过遵循 DRY 原则,你避免了每次需要指定边框时都列出所有四个属性。相反,你可以通过重用 .border-radius LESS 混合来声明边框。
预处理在精益开发工作流程中扮演着关键角色:现在你不需要在每个你想使用此规则的地方使用所有供应商前缀,你可以在一个地方更新前缀,使你的代码更易于维护。如果你想干净地分离静态规则和影响它们的变量,LESS 使你可以更进一步。没有 LESS,一个典型的 CSS 设计样式表摘录可能看起来像以下代码:
a {
background-color: #FFC;
}
blockquote {
background-color: #333;
color: #FFC;
}
LESS 允许你使用变量,这样你就不必在各个地方复制粘贴颜色。适当地命名这些变量也有助于你通过扫描样式表轻松地识别颜色。
使用 LESS 变量
使用 LESS,你可以为颜色设置变量,避免潜在的错误,例如在一个地方更新颜色但忘记更新其他出现的地方。这也使你可以将颜色和其他设计变量元素放在一起。以下代码展示了使用 LESS 可能的样子:

这样你可以保持你的代码 DRY,正如我在 第 2.2 节 开头提到的。遵循“不要重复自己”的原则在这里特别有用,因为它可以避免复制粘贴颜色代码,并节省你因误输入而可能遇到的麻烦。此外,像 LESS(SASS、Stylus 等语言)这样的语言提供了推导其他颜色的函数,例如更深的绿色、更透明的白色以及其他有趣的颜色数学。
现在,让我们将注意力转向在 Grunt 任务中将 LESS 代码编译成 CSS。
2.2.2. 执行 LESS
如我们在本章前面讨论的,Grunt 任务由两个不同的组件组成——任务和配置:
-
任务 本身是最重要的单个组件:这是当你运行构建时 Grunt 将要执行代码,通常你可以找到一个插件来完成你需要的功能。
-
配置 是你可以传递给
grunt.initConfig的一个对象。几乎每个 Grunt 任务都需要配置。
随着你继续阅读本章的其余部分,你将看到如何设置每种情况下的配置。为了使用 Grunt 编译 LESS 文件以便直接提供 CSS,你将使用grunt-contrib-less包。还记得你安装 JSHint 插件的时候吗?这里也是同样的情况!只是包名改变了,因为你现在将使用不同的插件。要安装它,请在你的终端中运行以下命令:
npm install grunt-contrib-less --save-dev
此插件提供了一个名为less的任务,你可以在Gruntfile.js中这样加载它:
grunt.loadNpmTasks('grunt-contrib-less');
从现在起,为了简洁,我将在示例中省略npm install和grunt.loadNpmTasks部分。你仍然需要运行npm install来获取包并加载你的 Gruntfile 中的插件!无论如何,你可以在配套的源代码文件中找到每种情况的完整示例。
设置构建任务很简单:你指定输出文件名并提供用于生成 CSS 文件的源路径。此示例可以在代码示例的 ch02/02_less-task 中找到。
grunt.initConfig({
less: {
compile: {
files: {
'build/css/compiled.css': 'public/css/layout.less'
}
}
}
});
执行任务的最后一部分是从命令行调用grunt。在这种情况下,你的终端中的grunt less应该可以解决问题。明确声明目标通常推荐。在这种情况下,你可以通过键入grunt less:compile来实现。如果不提供目标名称,则所有目标都会执行。
Grunt 配置的一致性
在我们继续之前,我想提到使用 Grunt 时你会喜欢的一个小优点。任务配置模式在任务之间跳跃时变化不大,尤其是在使用 Grunt 团队本身支持的任务时。即使在npm上找到的,它们在配置方面也相当一致。正如你将在本章中了解到的那样,我将向你展示的不同任务配置方式相似,即使它们以灵活的方式提供了广泛的操作。
在 Grunt 中运行less:compile构建目标现在会将layout.less编译成compiled.css。你也可以声明一个输入文件数组,而不仅仅使用一个。这将生成一个捆绑文件,其中将包含所有 LESS 输入文件的 CSS。我们稍后会详细介绍捆绑;请耐心等待。以下列表是一个示例。
列表 2.4. 声明一个输入文件数组
grunt.initConfig({
less: {
compile: {
files: {
'build/css/compiled.css': [
'public/css/layout.less',
'public/css/components.less',
'public/css/views/foo.less',
'public/css/views/bar.less'
]
}
}
}
});
单独列出每个文件是可以的,但你也可以使用一种称为 globbing 的模式语言,避免列举数百个文件,正如我接下来要解释的。
掌握 globbing 模式
你可以通过使用 Grunt 的 globbing 功能进一步改进前面代码中的配置,这是一个叫做 globbing 的额外好处。Globbing^([6])是一种文件路径匹配机制,它将帮助你使用文件路径模式包含或排除文件。它特别有用,因为你不必维护你的资产文件夹中所有文件的列表,这有助于你避免常见的错误,例如忘记将新的样式表添加到列表中。
⁶ Grunt 网站有关于 Globbing 如何工作的宝贵见解。访问
bevacqua.io/bf/globbing。
如果你想从构建任务中排除单个文件,例如第三方提供的文件,Globbing 可能会很有用。以下代码展示了你可能发现有用的几个 Globbing 模式:
[
'public/*.less',
'public/**/*.less',
'!public/vendor/**/*.less'
]
注意以下关于前面代码的内容:
-
第一个模式将匹配
public文件夹中任何具有 LESS 扩展名的文件。 -
第二个模式做的是同样的事情,只不过它通过特殊的
**模式匹配public的任何嵌套级别的子文件夹中的文件。 -
如你所猜,最后一个模式与第二个模式的工作方式相同,只不过开头的
!表示匹配的文件应该从结果中排除。
Globbing 模式按照它们呈现的顺序工作,并且可以与常规文件路径混合使用。Globbing 模式将产生包含所有匹配文件路径的数组。
考虑到 Globbing 模式,我们的最新 less:compile 配置可能需要进一步重构,成为一个简化的版本:
grunt.initConfig({
less: {
compile: {
files: {
'build/css/compiled.css': 'public/css/**/*.less'
}
}
}
});
在继续之前,让我提醒你,在这个特定的情况下,less 是构建任务,compile 是该任务的构建目标,它提供了针对该目标的特定配置。你可以通过向 less 对象添加其他属性,像你在 compile 任务目标配置中传递给 initConfig 一样,轻松地为 less 任务提供不同的目标。例如,你可以有一个 compile_mobile 目标,它将为移动设备创建 CSS 资产,以及一个 compile_desktop 目标,它将为桌面浏览器编译资产。
应该注意的是,作为使用此任务编译 LESS 的副作用,你的 CSS 将被捆绑到一个单独的文件中,无论源代码中使用了多少个文件。因此,现在让我们来看看资产捆绑,这是一个后处理任务,可以帮助你通过减少对网站的 HTTP 请求量来提高网站的性能。
2.2.3. 静态资产捆绑
我已经暗示了捆绑能完成什么,你可能在开始这次有启发性的冒险之前就已经听说过它。如果你之前从未听说过捆绑,那也没有关系;这是一个容易理解的概念。
资产捆绑 是在将内容交给客户之前将其全部组合在一起的一个时髦名称。这就像去商店买一个单独的杂货项目和回家,然后再次回到商店去清单上的另一个项目,一次又一次地回到商店,而是一次去商店并一次性购买所有杂货。
在单个 HTTP 响应中发送所有内容可以减少事务性网络成本,并使每个人都受益。负载可能会变得更大,但它可以节省客户端许多不必要的网络往返到您的服务器,这会产生相关的网络成本,例如延迟、TCP 和 TLS 握手等。如果您想了解更多关于底层互联网协议(TCP、TLS、UDP、HTTP 等)的信息,我强烈推荐 Ilya Grigorik 的《高性能浏览器网络》(O’Reilly Media,2013 年)。
换句话说,资产捆绑实际上是将每个文件附加到前一个文件的末尾。通过这种方式,您可以将所有 CSS 或所有 JavaScript 捆绑在一起。更少的 HTTP 请求带来更好的性能,这就是为什么静态资产捆绑构建步骤值得考虑的原因。图 2.5 考察了使用捆绑和不使用捆绑的网站与人类交互,以及它们如何影响网络连接。
图 2.5. 使用资产捆绑减少 HTTP 请求的数量

如图中所示,在捆绑之前,浏览器必须发出更多的 HTTP 请求来获取网站资源,而捆绑之后,只需一个请求就足以获取每个捆绑包(包含构成您应用程序逻辑的许多不同文件)。
许多预处理器都包括将您的资产捆绑到单个文件中的选项,您在less:compile的演示中已经体验过这一点,当时您将许多资产捆绑到一个文件中。
野外的捆绑
使用grunt-contrib-concat包,您可以轻松设置构建目标,使用我之前描述的全局模式组合任意数量的文件,并且以一种您可能已经熟悉的方式。在整个书中,术语连接和捆绑是互换使用的。以下列表(在配套代码示例中列为 ch02/03_bundle-task)描述了如何配置concat任务。
列表 2.5. 配置连接任务

毫不奇怪,concat:js任务将public/js文件夹(及其子文件夹,递归)中的所有文件捆绑在一起,并将结果写入build/js/bundle.js,如指示。从一项任务到另一项任务的过渡如此自然,有时您甚至不会相信它是多么容易。
在构建过程中处理静态资产时,还有一点需要注意,那就是压缩。让我们继续讨论这个话题。
2.2.4. 静态资产压缩
最小化类似于连接,因为它最终试图减轻网络连接的负担,但它采用了一种不同的方法。而不是将所有文件混合在一起,最小化包括删除空白、缩短变量名以及优化代码的语法树,以生成一个文件,虽然功能上与您所写的内容等效,但文件大小将显著减小,代价是几乎无法阅读。这种缩小是为了满足您提高性能的目标,如图 2.6 中所述。
图 2.6. 使用资产最小化减少 HTTP 响应长度

如图中所示,您静态资源的最小化版本要小得多,从而实现更快的下载。当与您选择的服务器端平台上的 GZip^([7])压缩结合使用时,最小化包的大小将大大小于源代码。
⁷ 访问
bevacqua.io/bf/gzip获取有关在您最喜欢的后端服务器上启用 GZip 压缩的更多信息。
混淆的副作用可能会让您觉得它“足够安全”,以至于您可以将其中的任何内容放入 JavaScript 代码中,因为这将使其更难阅读,但无论您如何混淆客户端代码;如果他们足够努力,人们总是可以解码您在其中所做的操作。相应的,永远不要信任客户端,而是将敏感代码放在您的后端。
打包可以与最小化结合使用,因为它们是完全正交的(这意味着它们不会相互冲突)。一个将文件组合在一起,另一个减少每个文件的占用空间,但这两个任务配合得很好,因为它们在功能上不重叠。
打包和最小化可以按任意顺序执行,结果将大致相同:一个适合发布的单个压缩文件,几乎对您的开发工作没有帮助。虽然最小化和打包对于您面向人类的应用程序无疑很重要,但它们会妨碍我们在日常开发流程中追求的富有成效的持续开发方法,因为它们会使调试变得更加困难。这就是为什么在您的构建过程中将这些任务明确分离出来很重要,这样您就可以在适当的环境中执行它们,而不会阻碍您的开发效率。
查看资产最小化示例
让我们来看一个资产最小化示例(在示例中标记为 ch02/04_minify-task),然后您可以为真实的人类提供服务。存在许多资产最小化选项。在您的示例中,您将使用grunt-contrib-uglify包来最小化 JavaScript 文件。在从 npm 安装并加载插件后,您可以按照以下列表进行设置。
列表 2.6. 资产最小化配置

这种设置将帮助压缩cobra.js,执行grunt uglify:cobra。如果你想要压缩之前步骤中打包的内容,进一步提高应用程序的性能怎么办?这涉及到取列表 2.6 中创建的连接文件并进行压缩,如下所示(在示例中标记为 ch02/05_bundle-then-minify)。
列表 2.7. 打包后的资产压缩

将这两个步骤结合起来,就是按顺序运行这两个任务。为此,你可能需要使用grunt命令grunt concat:js uglify:bundle,但这也可能是一个引入任务别名的理想场景。
任务别名是一组任何数量的任务,这些任务通常作为同一步骤的一部分执行,并且彼此相关。别名中的任务最好相互依赖,以产生更有意义的输出,这样会使它们更容易跟踪和更具语义性。任务别名也非常适合声明工作流程。
在 Grunt 中,你可以轻松地在单行中设置任务别名,如下所示。你还可以提供一个可选的描述参数;当执行grunt –-help时,这将显示出来,但它主要用于描述为什么创建了这个别名,对于浏览你代码的开发者来说很有帮助:
grunt.registerTask('js', 'Concatenate and minify static JavaScript assets',
['concat:js', 'uglify:bundle']);
现在,你可以将assets视为任何其他 Grunt 任务,并且运行grunt assets将执行连接和压缩操作。
我有一个额外的任务,你可以在构建静态资产时实现,以改善应用程序的性能。这与打包类似,但它涵盖了图像。这个操作的结果是精灵图,这是一个比压缩或连接更早的概念。
2.2.5. 实现图像精灵
精灵图是通过获取许多图像并构建一个包含所有这些图像的大文件来实现的。你不需要为每个单独的文件引用,而是使用background-position、width和height CSS 属性来选择你想要的精灵图中的图像。将图像精灵想象成是针对图像的资产打包。
精灵图是一种技术在多年前起源于游戏开发,至今仍在使用。许多图形被压缩到单个图像中,显著提高了游戏性能。在网页领域,精灵图对于图标或任何类型的小图像最有用。
自己维护精灵图和相关的 CSS 是一项工作。尤其是如果你是在剪切和粘贴,保持图标和精灵图同步是繁琐的。这就是 Grunt 作为闪耀的骑士,准备拯救世界的时刻。在设置图像精灵时,npm为你提供了开始自动化 CSS 精灵图生成过程的选项。为了这个自包含的示例,我将参考grunt-spritesmith Grunt 插件。如果你在安装插件时遇到问题,请参考代码示例进行故障排除。其配置类似于你已经习惯的配置:
grunt.initConfig({
sprite: {
icons: {
src: 'public/img/icons/*.png',
destImg: 'build/img/icons.png',
destCSS: 'build/css/icons.css'
}
}
});
到现在为止,你可以安全地假设src属性可以接受任何类型的通配符模式。destImg和destCSS属性将映射到精灵图将生成的文件,以及用于在 HTML 中渲染精灵图像的 CSS 文件。一旦你有了 CSS 和新建的精灵图,你只需通过创建 HTML 元素并将不同的精灵 CSS 类分配给这些元素,就可以简单地给你的网站添加图标。CSS 用于“裁剪”图像的不同部分,有效地只取用于所需图标的图像相关部分。
网络上的感知性能
我无法强调资产打包、最小化和甚至精灵化在发布构建中扮演的重要角色。在当今的 Web 应用中,图像通常占据了大部分的文件大小。使用这些技术减少对服务器的请求次数,可以立即提供性能提升,而不需要更昂贵的硬件。通过最小化和/或压缩响应内容,减少其字节大小也可以达到类似的效果。
速度很重要
速度是网络的基本、决定性因素。响应性,或者至少是感知到的响应性,对用户体验(UX)有着巨大的影响。感知到的响应性现在比以往任何时候都更重要;这是用户感知到的速度,尽管技术上可能需要更长的时间来满足请求。只要你对他们的操作显示即时的反馈,用户就会感知到你的应用程序“更快”。这就是你每天在 Facebook 或 Twitter 上看到的情况,当你提交一条新帖子时,它立即被添加到列表中,尽管其数据仍在发送到他们的服务器。
许多实验已经证明了提供快速可靠服务的重要性。特别是由谷歌和亚马逊分别进行的两个实验。
在 2006 年,玛丽莎·梅耶(Marissa Mayer)是谷歌的用户体验副总裁。她在收集了一组用户反馈后进行了一项实验,这些用户希望在他们的搜索结果中看到更多结果。实验将每页搜索结果的数量增加到 30 个。每页结果数量增加的实验组的客户流量和收入下降了 20%。
马里萨解释说,他们发现了一个未受控制的变量。包含 10 个结果页面生成耗时 0.4 秒。包含 30 个结果页面生成耗时 0.9 秒。半秒的延迟导致了 20%的流量下降。半秒的延迟摧毁了用户满意度.^([8])
⁸ 你可以在这里找到关于这个主题的详细文章:
bevacqua.io/bf/speed-matters。
亚马逊进行了一个类似的实验,在拆分测试中故意逐渐延迟他们网站的响应速度。即使是微小的延迟也会导致销售额的显著下降。
判断感知响应速度与实际速度
在光谱的另一端,我们遇到了感知速度。通过向用户交互提供即时反馈(仿佛动作已经成功),即使任务本身可能需要几秒钟来处理,你也可以提高感知速度。这种快进方式总是受到人类的欢迎。
现在我们已经讨论了加快网络访问您资产的速度,以及与编译这些资产相关的构建任务,以及不同方法和技术的性能影响,让我们放慢脚步,开始谈谈代码质量。到目前为止,我们只稍微关注了您代码的质量,所以让我们转向您应该执行的那种任务。您对预处理和后处理任务有很好的了解,知道它们是如何工作的,以及如何应用它们。
我们首先在第一章第一章中讨论了代码质量,当时您将代码检查集成到构建中。如果您想保留幂等性属性,清理自己的工作很重要。同样,检查代码和运行测试对于保持您的代码质量标准至关重要。
现在,让我们深入一点,了解如何更好地将这些任务集成到实际的构建过程中。
2.3. 设置代码完整性
在考虑代码完整性时,请记住几个任务:
-
首先,我们应该谈论的是清理自己的工作。每当我们的构建开始时,它们应该清理它们生成的构建工件。这有助于我们实现幂等性,即多次执行构建总是产生相同的结果。
-
我们将再次讨论代码检查,补充我们在第一章末尾所探讨的内容,确保我们在运行构建时代码不包含任何语法错误。
-
我们将简要讨论设置测试运行器,以便您可以自动化代码测试,我们将在未来的章节中介绍。
2.3.1. 清理工作目录
在您完成构建后,您的工作目录通常会处于脏状态,因为您将生成不属于源代码的内容。您想要确保每次运行构建时工作目录始终处于相同的状态,以便每次都能得到相同的结果。为了确保这一点,通常在运行任何其他任务之前清理生成文件是一个很好的做法。
工作目录
工作目录是开发期间代码库根目录的时髦说法。通常最好使用子目录来聚合构建的编译结果,例如名为build的目录。这有助于您将源代码与构建工件保持干净分离。
在您的发布完成后,您的服务器将使用构建的结果,您不应该通过执行另一个发布之外的方式更改其输出。在部署完成后运行构建任务会像手动执行这些任务一样糟糕,因为您会重新引入人为因素。一般来说,如果某件事感觉不够干净,那么它可能还不够干净,应该进行修改。
隔离构建输出
当我们谈论代码完整性时,我认为强调您可能已经从我所展示的示例中注意到的某事很重要。我强烈建议您遵循严格分离构建生成内容与源代码的做法。将生成内容放在build目录中就足够了。好处包括能够毫不犹豫地删除生成内容,能够轻松地使用 globbing 模式忽略文件夹,在一个地方浏览生成内容,也许更重要的是,确保您不会意外删除源代码。
生成内容但每次运行时都会清理现有构建工件的任务是乐意于幂等的:无限次运行它们不会影响其行为;结果始终相同。清理步骤是构建任务成为幂等的必要属性,赋予它们始终产生相同输出的一致性。话虽如此,让我们看看清理任务配置在 Grunt 中可能是什么样子。您将使用grunt-contrib-clean包,它提供了一个您可以使用clean任务。这个任务(在示例中可用为 ch02/07_clean-task)是尽可能简单的:您提供目标名称,然后您可以使用 globbing 模式删除您指定的特定文件或整个文件夹。以下代码是一个示例:

前两个例子build/js和build/css展示了如何简单地将生成内容挑选出来并移除,只要它明显与源代码分开。另一方面,第三个例子展示了当源代码与构建生成的内容位于同一目录时,情况会变得多么混乱。此外,如果你将生成内容隔离到一个文件夹中,那么你可以更方便地将其从版本控制系统排除。
2.3.2. 代码检查,代码检查,代码检查!
我们已经在上一章中讨论了代码检查的好处,但让我们再次看看你的代码检查任务的配置。记住你在这里使用的是grunt-contrib-jshint包。你可以按照以下代码(示例 ch02/08_lint-task)进行配置:
grunt.initConfig({
jshint: {
client: [
'public/js/**/*.js',
'!public/js/vendor'
]
}
});
考虑第三方(别人的)代码时,重要的是要将其视为我们努力范围之外的内容。你不会对第三方代码进行单元测试。同样,检查他们的代码也不是你的工作。如果你没有将生成的内容放在单独的文件夹中,你还需要从你的 JSHint 配置文件中排除它。这就是严格将构建工件与一般大众(你的源文件)分开的另一个好处。
代码检查通常被认为是维护 JavaScript 代码质量合理水平的第一道防线。你应该仍然在代码检查的基础上编写单元测试,原因我将在下面解释,而且,你猜对了,有一个任务就是为了这个。
2.3.3. 自动化单元测试
在构建过程中自动化的最重要步骤之一是单元测试。单元测试确保你的代码库中的各个组件按预期工作。开发一个经过良好测试的应用程序的流行流程如下:
-
为你想要实现(或更改)的东西编写测试。
-
运行这些测试并观察它们失败。
-
实现你的代码更改。
-
再次运行测试。
如果测试失败,继续编码直到所有测试通过,最后再回头编写新的测试。这个过程被称为测试驱动开发(TDD)。我们将在第八章中更深入地探讨单元测试。这是一个值得更专门讨论的话题,因此我们将推迟关于设置 Grunt 任务以运行单元测试的讨论。
目前的主要经验教训是单元测试必须自动化。不经常运行的测试几乎毫无用处,因此构建过程应该在部署之前以及在你本地构建期间触发它们。考虑到这一点,你也会希望你的单元测试尽可能快地运行,以免影响构建的性能。一个常见的原则是“尽早测试;经常测试”。
备注
我们迄今为止看到的不同包只暴露了一个你可以使用的 Grunt 任务,但这并不是 Grunt 本身强加的限制。你可以根据需要将尽可能多的自定义任务包含在你的包中。这通常是由包作者有意为之。npm 包通常在设计上是模块化的,因为它们被设计成只做一件事,而且做得非常好。
你在本章的大部分时间里都在学习如何使用其他人编写的构建任务。现在让我们转向编写自己的构建任务,这在当你找到的现有任务插件在 npm 上不能满足你的需求时非常有用。
2.4. 编写你的第一个构建任务
尽管 Grunt 有一个活跃的社区,提供了许多高质量的 npm 模块,但你肯定需要编写自己的任务。让我们通过一个示例来了解这个过程。我们已经介绍了从 npm 加载的任务和设置任务别名。创建任务最简单的方法是使用 grunt.registerTask 方法。实际上,这正是我们在查看压缩时在 2.2.4 节中注册别名时使用的方法,但此时你将传递一个函数而不是任务列表。
以下列表(可在 samples 中的 ch02/09_timestamp-task 找到)展示了如何创建一个简单的构建任务,该任务创建一个带有时间戳的文件,你可以在应用程序的其他地方将其用作唯一标识符。
列表 2.8. 时间戳任务

默认情况下,时间戳将创建在名为 .timestamp 的文件中;然而,由于你使用了 this.options,用户可以在配置任务时提供另一个文件名来更改它,如下面的代码所示:
grunt.initConfig({
timestamp: {
options: {
file: 'your/file/path'
}
}
});
实际上,这就是编写自定义构建任务的唯一要求。Grunt 有一个广泛的 API,它抽象出常见功能,使你能够轻松地处理配置、执行 I/O 操作、执行任务以及异步执行任务。幸运的是,API 有很好的文档,所以请在他们的网站上查看。^([[9)]]
⁹ 你可以在
bevacqua.io/bf/grunt找到 Grunt 的文档。
若要对 Grunt 进行全面分析,请前往附录 B。timestamp 任务非常简单。让我们看看你可能想要实现的实际 Grunt 任务。
2.5. 案例研究:数据库任务
正如你所看到的,开发自己的构建任务并不复杂;然而,在着手自己重新发明轮子之前,确定你的任务运行器(在我们的案例中是 Grunt)是否已经为你的任务开发了,这是很重要的!大多数任务运行器都提供某种插件搜索引擎,所以确保在坐下来编写自己的任务之前在网上查找。现在,让我们看看数据库模式更新的案例,以及你如何帮助自动化它们。
数据库案例研究代码
注意,这本书的文本中没有包含这个特定案例的代码。相反,你可以在配套的代码列表中找到一个完全工作的示例,标记为 ch02/10_mysql-tasks.^([a])
^a 数据库任务的代码样本可以在网上找到,地址为
bevacqua.io/bf/db-tasks。
在你看代码之前,阅读这本书的这一部分,以了解代码是什么,它做什么,以及为什么。
数据库迁移是那些设置起来很复杂的任务之一,但一旦设置好,你就会 wonder 你是如何在没有自动化过程的情况下管理应用程序的。
通用概念是,你从一个为应用程序设计的原始数据库模式开始。随着时间的推移,你可能会对模式进行调整:也许你会添加一个表,删除不必要的字段,更改约束等等。
这些模式更新往往是不加掩饰地手工完成的,通常以它们太敏感而无法自动化的借口。我们手工完成它们,浪费了大量时间。在这个过程中很容易出错,浪费更多时间。不用说,这对大型开发团队来说变得难以忍受。
双向模式更改
我建议一套自动化的任务应该优雅地处理双向迁移:升级和回滚。如果你足够小心地构建它们,你甚至可以将它们集成到自动化流程中。这种思考方式是,你应该只在这些任务中应用这些模式更改,而永远不要直接在数据库上操作。当你采用这种思考方式时,请考虑两个额外的任务:从头创建数据库,并使用数据填充以帮助你的开发工作流程。这些任务将允许你直接从命令行管理数据库,轻松创建新实例,更改模式,用数据填充,并回滚更改。
图 2.7 总结了这些步骤,将它们作为 Grunt 任务进行整合,并解释了它们如何与特定的数据库交互。
图 2.7 显示了所提出的任务与数据库实例的交互。

仔细观察这张图,你会注意到它有一个流程:
-
一次创建数据库。
-
每当有新的模式更新时运行模式更新脚本。
-
一次性在你的开发数据库中种下种子。
-
在出现问题时,运行回滚脚本作为额外的安全层。
使用 db_create,你可以创建一个数据库实例,这就足够了。它不应该在数据库已存在的情况下重新创建,以避免错误。它目前不会写入任何内容到模式中:表、视图、过程等都是下一步的内容。
db_upgrade 任务将运行尚未执行的升级脚本。你将想要检查本章的配套源代码来了解它是如何工作的。^([10)] 简单来说,你创建一个表来跟踪已应用的升级脚本;然后检查是否存在未应用的脚本并执行它们,在执行过程中更新跟踪记录。
[1]。
有一个备份计划
当事情出错时,db_rollback 将执行最后一个应用的升级脚本,并执行其降级对应脚本。然后它通过删除最后一条记录来更新跟踪表,这样你就可以通过使用这两个任务在模式中有效地来回升级和回滚。请注意,虽然db_upgrade执行所有未应用的升级脚本,但db_rollback只降级最后一个仍然应用的脚本。
最后,db_seed 任务用于在开发环境中插入你可以操作的记录。这个任务对于通过仅运行 Grunt 任务来使新开发者轻松设置工作环境至关重要。这些任务看起来可能像图 2.7 中的那些。
到目前为止,你应该已经足够熟悉,可以查看数据库任务的完整文档代码列表(在示例中为 ch02/10_mysql-tasks),并了解它如何实现。^([11)]
^(11) 你可以查看[第二章的代码示例,并寻找名为 10_mysql-tasks 的那个示例。]
在接下来的章节中,你将看到不同的方法来配置此类任务,以避免直接依赖于配置文件。相反,你将学习如何使用环境变量和加密的 JSON 配置文件来存储你的环境配置。
2.6. 摘要
你已经学到了很多关于构建任务的知识!让我们快速回顾一下:
-
构建过程应该促进生产一个完全配置的环境所需的一切,使其准备好并能够完成其工作。
-
构建中的不同任务被清楚地分开,并且类似任务被分组在任务目标下。
-
构成构建的主要任务包括静态资产编译和优化、代码风格检查以及运行单元测试。
-
你已经学会了如何编写自己的构建任务,并研究了如何自动处理数据库模式更新。
借助你获得的知识,我们将在接下来的两个章节中转换方向,扩展你对如何针对不同环境(即本地开发和发布服务器)的理解,你将学习可以应用于最大化生产力和性能的最佳实践。
第三章. 掌握环境和开发工作流程
本章涵盖
-
创建构建版本和工作流程
-
设置应用程序环境
-
构建安全的环境配置
-
自动化首次设置
-
使用 Grunt 进行持续开发
我们在上一章中讨论了在构建过程中应该做什么和不应该做什么。我们涵盖了构建任务,并在其中配置了不同的目标。我还暗示了你的工作流程如何根据你为调试或发布版本构建应用程序而有所不同;这些基于目标环境的调试或发布目标的构建工作流程差异被称为构建版本。
理解开发、预发布和生产环境以及构建版本之间的交互对于创建一个无论在何种环境下都可以使用的构建过程至关重要,这允许你在与最终用户所见环境忠诚的设置中开发你的应用程序,同时仍然可以轻松地进行调试。此外,这种理解将使你能够创建中间层环境,这对于强大的部署机制至关重要,我们将在下一章中讨论。
在本章中,我们将从了解我们所说的环境和版本开始,我将提出一个典型的配置,它应该适用于大多数用例,其中你将拥有
-
本地开发环境,用于日常改进应用程序
-
预发布或测试环境,专门用于确保不会因部署到生产环境而产生问题
-
生产环境,这是客户可以访问的环境
然后,我们将探讨在配置应用程序时在不同上下文中采取的不同方法。你将学习如何自动化通常繁琐的首次设置,然后使用 Grunt 设置一个持续的开发工作流程,让我们开始吧。
3.1. 应用程序环境
在上一章中,我们谈到了一些关于环境的内容,但我们没有详细说明在设置新环境时你有哪些选择,以及它们之间有何不同。
开发环境 是你大部分时间所在的地方,你在这里使用本地 Web 服务器进行工作,该服务器通常配置得允许调试、读取堆栈跟踪和更容易地获取诊断信息,比其他环境都要方便。开发环境也是与开发者及其编写的源代码最接近的环境。在这个环境中使用的应用程序几乎总是使用 调试版本 构建,这相当于设置一个标志,允许你开启某些功能,例如调试符号、增加日志记录(或日志详细程度)等。
测试环境 是你确保在托管环境中一切工作正常,并且可以自信地部署到生产环境,而不用担心任何东西会出错的地方。在 生产环境 中,你几乎总是希望为发布版本构建,因为该构建流程将设计为优化你的应用程序,并尽可能多地从你的静态资源中挤出字节。
现在我们来看看如何为这些环境中的每一个配置你的构建版本,调整版本输出以满足你的特定目标:要么是调试,要么是发布。
3.1.1. 配置构建版本
为了帮助理解构建版本,可以将应用程序构建比作在面包房工作。当你准备蛋糕的混合物时,你可能会有许多不同的模具来盛放面糊。你可以使用标准的圆形蛋糕模具、方形烤盘、长面包模具,或者你所能找到的任何东西。这些模具就像开发环境中的工具,你的厨房。原料总是相同的:面粉、黄油、糖、一点盐、可可粉、鸡蛋和半杯牛奶。你用来构建蛋糕的原料类似于你应用程序中的资源。
此外,原料被组合成一份食谱,指示如何将它们混合在一起:何时、以何种数量、以及你应该在冰箱中存放多长时间,以便在以定义良好的温度放入烤箱之前获得良好的质地。选择不同的食谱可能会导致蛋糕更加松软或更加酥脆,就像选择不同的版本会导致应用程序更容易调试或性能更好。
当你尝试不同的方法来组合你的混合物时,你可能会改变原料(你的资源),甚至可能会改变食谱(你的版本),但你仍然会在你的厨房(开发环境)中完成工作。
最终你会在烘焙方面变得更好,并参加比赛。你将在不同的环境中(一个新环境)获得专业工具,遵循指南,并期望使用你拥有的材料烘焙蛋糕。你可能自己选择配料,你可能选择使用糖浆给蛋糕最后的点缀,你可能想要比你在自己厨房里多煮一会儿。这些对食谱的改变受你工作环境的影响,因为它可能会影响你决定使用哪种食谱,但你仍然可以在你认为合适的任何环境中使用任何食谱!
注意,构建分发被限制在调试或发布,尽管你可以配置任意数量的不同环境来使用这些分发中的任何一个,只要你认为有必要。环境与构建分发之间没有一对一的关系。你可能为每个环境都有一个首选的分发,但这并不意味着这个偏好是固定不变的。例如,在你的开发环境中,你通常会使用调试分发,因为这会在你的日常活动中带来更高的生产力。然而,你可能会偶尔在开发环境中尝试发布分发,以确保它在任何环境中都能按预期工作,然后再部署到生产环境。
确定使用哪个构建分发
在任何厨房里准备好烘焙蛋糕几乎是不可能的:不同的烤箱、平底锅和煎锅可能不是你感到舒适的工具。同样,构建过程对它针对的环境控制很少。但你可以根据目标环境的用途确定适当的构建分发;要么
-
调试目的,你的目标是快速开发和调试你的应用程序
-
发布目的,其中你的目标是性能和可用性
这些目的决定了你的构建分发。在你的开发环境中,你会使用一个更适合满足你的开发需求的分发,这主要归结于发现问题和解决它们。这就是调试分发。在本章的后面部分,你将了解如何改进流程,以超越简单的调试,并实现真正的持续开发,即当涉及任务的代码发生变化时,运行特定的构建任务。
图 3.1 展示了构建分发如何回答关于你想要完成的目标类型的问题,使用配置来定义构建流程。
图 3.1. 构建分发及其如何定义你的构建流程以实现特定目标

生产环境的构建分发
在光谱的另一端,远离开发环境,是生产环境。回到我们的烘焙类比,在这种情况下,你将追求高端、高质量的蛋糕,这是付费客户所喜爱的,并且只能使用你拥有的最佳食谱来烘焙。生产是最终将应用程序提供给真实最终用户的环境,操作他们提供的数据。
这与开发环境形成对比,在开发环境中,你应该主要使用假数据,尽管这些数据在外观上与真实客户数据相似。生产环境很少会使用除发布外的其他发行版。这个发行版通常将性能视为最重要的因素,正如你在第二章中看到的,这可能意味着压缩和捆绑静态资源,从你的图标中生成精灵图,以及优化你的图像,但我们将这些主题留到第四章中讨论。尽管生产环境不应该使用调试构建,但你确实应该确保发布构建过程在你的开发环境中能够正常工作。
为预发布环境构建发行版
在开发和生产之间,你可能会有一个预发布环境;其目标是在尽可能的情况下,复制生产环境中使用的配置(尽管不会影响用户数据或与生产中使用的服务交互)。预发布环境通常托管在除本地机器以外的其他地方。想象一下你是一名面包师:你可能希望制作出能够达到一定质量的蛋糕,无论你在哪个厨房工作。
预发布环境可能涉及在除了你自己的厨房以外的其他地方工作,但也不会在餐厅的厨房里。也许你想为朋友制作一份礼物,所以你使用她的厨房。预发布环境试图将生产和开发带到中间地带,这意味着它们试图尽可能接近这两个环境。为此,它们可能会定期获取生产数据库的精选版本(通过精选,我的意思是敏感数据,如信用卡或密码,必须被删除或清空)。你将为这个环境选择一个发行版,基于你要测试的内容,但通常默认为发布,因为这样更接近生产环境。
拥有预发布环境的真正目的是为了允许质量保证(QA)工程师、产品所有者和其他人在应用程序上线到生产之前对其进行测试。鉴于预发布基本上与生产相同,只是对最终用户不可访问,你的团队能够快速识别即将发布的版本中的问题,而不会影响生产环境,并且可以确信它将在托管环境中按预期工作。
让我们暂时沉浸在代码中,考虑一下如何使用发行版来处理构建配置,以便你的构建任务能够充分体现它们所属的构建流程(调试或发布)。
Grunt 任务中的发行版
在第二章中,我们讨论了一些构建任务及其配置,但它们大多是独立的,不是流程的一部分。通过构建发行版,你可以通过为每个任务分配在给定构建流程中使用它的意图来改进你的构建过程。你是追求调试质量还是更小的文件大小和更少的 HTTP 请求?好吧,如果你开始在 Grunt 任务和别名中使用命名约定,答案将对你来说更容易推断。
作为一条一般性规则,我建议你根据任务目标所针对的发行版来命名你的构建目标为debug或release。通用任务,如JSHint,不需要遵循此约定,你仍然可以给你的目标命名,例如jshint:client、jshint:server和jshint:support。你可以使用support目标来处理剩余的代码库,这些代码与服务器或客户端无关,但主要与构建或部署相关。
考虑到这个约定,你可能会看到一系列任务,如jade:debug和less:debug,然后你可以将这些任务捆绑在一起,创建一个build:debug别名。同样,这也适用于发布,清楚地分离你的代码和思维中的构建流程。以下列表(sample 03/01_distribution-config)展示了在代码中这会是什么样子。
列表 3.1. 分布式构建配置


使用这种分离,很容易创建别名来为任意发行版构建应用程序。这里有一些示例别名:
grunt.registerTask('build:debug', ['jshint', 'less:debug', 'jade:debug']);
grunt.registerTask('build:release', ['jshint', 'less:release',
'jade:release']);
你可以在配套的源代码仓库中查找完整的代码列表示例。记住,这些是按章节组织的,所以请在第三章下查找 01_distribution-config 文件夹。
这为你提供了一个很好的基础来构建。你可以对这些流程中的每一个进行迭代,可能重用任务,例如本例中的jshint,向两个发行版或其中一个添加更多任务,或者如果它只适用于一个流程,那么可能只添加到其中一个。例如,你可能会希望在发布流程中保留更新变更日志的任务,因为要发布的产品可能会在调试构建中发生变化,你需要伴随你的部署提供关于所有引入的变更的文档。我们将在本章后面回到这个话题,查看调试发行版特定的任务。发布特定的任务在第四章(kindle_split_015.html#ch04)中进行分析。
现在你已经了解了构建发行版是什么以及它们是如何定义构建过程中创建的不同流程的;让我们将注意力转向每个环境中的应用配置,或者我称之为环境级配置。
3.1.2. 环境级别配置
环境配置与构建分发是分开的,区别是明显的:构建分发决定了你的应用程序应该如何构建。它们不应该在应用程序本身中承担任何重量,而只影响构建过程,或者更具体地说,你遵循的构建流程。相比之下,环境配置是特定于环境的。
环境级别配置:它包括什么?
在接下来的内容中,每当我在本章中提到配置时,我指的是环境级别的配置,除非另有说明。通过环境级别配置,我指的是如下值
-
数据库连接字符串
-
API 认证凭证
-
会话加密密钥
-
您的 Web 服务器监听 HTTP 请求的端口
这类配置值往往包含大量敏感数据。我强烈建议不要将这类秘密与代码库中的其他部分一起以纯文本形式打包。开发者不应直接访问服务,例如您的数据库,因此不应访问用户数据。这也成为了一个攻击向量:访问您的代码库意味着可以访问您的数据库或 API 密钥,最可怕的是,可以访问您的客户数据。
在这方面,一个很好的经验法则是将你的应用程序开发得就像你在开发开源软件一样。你不会把敏感的 API 密钥和数据库连接字符串推送到你公开可用的开源存储库中,对吧?
图 3.2 展示了您的应用程序如何结合构建分发输出和环境配置来提供服务。
图 3.2. 环境级别配置——环境、配置和分发在一个应用程序中的结合。环境配置包括密钥凭证以及可能在不同环境中变化的任何其他配置。

构建流程
如您在图左侧所示,调试和发布分发只影响构建本身,而环境配置将在构建执行后直接影响应用程序,无论是调试还是发布。
环境级别配置
应用程序配置必须是特定于环境的。这些环境变量不应与仅影响构建过程的构建分发混淆。应用程序配置指的是小(通常是敏感)的数据片段,例如数据库连接字符串、API 密钥、加密密钥、日志详细程度等。
尽管分发通常不包含敏感数据,但环境级别配置通常包含。例如,一个环境配置可能包含对数据库实例、API 服务(如 Twitter 的 REST API)的访问凭证,或者可能是用于通过 IMAP 发送电子邮件的用户名和密码。
但并非所有环境配置都是敏感的,或者泄露后构成安全威胁。例如,应用程序的监听端口和日志详细程度级别,这些决定了您的日志记录器应该有多详细,都是特定环境的,但它们在本质上并不敏感。话虽如此,您没有理由将“安全”配置与敏感配置区别对待,除非您可能包含与安全变量(如应用程序的监听端口)相关的配置默认值。您绝对不应该对敏感数据这样做。
目前您将专注于开发环境,并在下一章中继续讨论预发布和生成环境。
3.1.3. 开发环境有什么特别之处?
与其他环境相比,本地开发有什么不同之处?嗯,很多,理想情况下,并不多。两个最显著的区别是,这是您将花费大部分时间的环境,而且如果某些东西停止工作,您可以随时修复它,而且没有人会注意到。相比之下,您应该在生产环境中花费很少的时间,因为这可能意味着人们不会使用您的产品,如果某些东西停止工作,那也不会很好。我们将在下一章中讨论减轻和监控发布级环境问题的措施。
Build First 方法在开发环境中带来了一系列的好处,这也是本章的重点。我们将讨论在开发过程中非常有帮助的工具和机制。让我们把乐趣留到最后;我们首先需要讨论配置问题。我们将探讨如何以合理的方式管理、读取和存储环境级配置的敏感数据,以免将您的秘密暴露给潜在的入侵者。
3.2. 配置环境
到目前为止,您已经确定将敏感配置以纯文本形式提交到您的存储库存在安全风险。在本节中,我们将介绍如何从不同的来源管理配置,例如文件、数据库或应用程序内存。同时,您将探索保护配置数据的不同方法。请注意,我即将提供的信息并不仅限于 Node.js。我选择这个平台是因为我需要给您提供一个具体的例子,说明如何配置环境级变量,并且因为这是一本 JavaScript 书籍。话虽如此,我们将讨论的环境配置方法可以应用于您喜欢的任何服务器端平台上的应用程序。
特定环境的变量
环境配置会改变任何可能根据你运行应用程序的环境而改变的变量。例如,你可能需要带有凭证的变量来发送电子邮件,你可能还希望允许在调试环境中将所有电子邮件发送到通配符账户。你消费的服务的 API 密钥通常也是按环境变化的。环境配置是存放所有这些设置和凭证的地方,这样你可以为每个环境调整它们。
我经常不情愿地参与那些羞于启齿的项目,这些项目违反了这一配置原则,将所有环境的配置直接放在了他们的仓库中。开发、预发布、生产——它们都是公平的游戏。每个环境的配置都保存在一个单独的文件中,例如包含“开发”字符串的配置决定了使用哪个文件。这样做有多个问题:
-
首先,我必须强调,不要直接将凭证打包到你的仓库中,放入你的实际环境配置中。这正是属于环境级配置的东西。
-
第二,你不应该需要为每个环境重复配置值,实际上是在多个不同的文件中维护相同的值;这将导致 WET(Write Every Time)代码。当你想要向应用程序添加新的环境或配置值时,它扩展性不好。
我还参与过配置繁琐的项目:你会得到一个全新的代码库,四处询问以获取一些凭证来开始,然后将它们输入到一个配置文件中。如果你需要部署,那么你必须手动更改这些相同的值,以适应你部署到的环境。在前一种情况下,至少你不必每次更改环境时都更改配置来使应用程序工作。你会更改一个魔法字符串,将其设置为“预发布”,然后它就会工作。
你怎么能不与每个人分享一切就采用这种方法呢?你可能会认为这不是什么大问题;你不会一夜之间开源你的项目。但如果你这样想,你就完全错过了重点。给每个人访问你生产环境中可能敏感信息的权限不是好的做法。而且没有必要——这种配置应该属于那个环境,不应该放在其他地方。
开源软件
在开源项目中进行实验,这是我强烈鼓励你尝试的事情,这极大地帮助我在保护敏感数据的技术和措施上随着时间的推移得到了显著提升。我开始思考“如果陌生人下载了我的代码怎么办?”这样的问题,这让我对将代码推送到我的仓库时什么可以接受,什么不可以接受有了更清晰的认识。
让我们通过讨论瀑布配置来开始我们的环境配置讨论,然后我们将介绍你可以用来保护它的不同方法,即加密和环境变量。
3.2.1. 在瀑布中存储配置
瀑布是一种存储配置的方法。它就像选择一个优先级,这个优先级决定了这些存储在合并时的顺序。瀑布之所以有用,是因为它帮助你的配置被分散在不同的地方,但仍然是一个整体的一部分。有几个地方可以定义你的配置;例如
-
直接在代码库中的纯文本,仅用于不违反你安全的数据
-
在加密文件中;它的目的是安全地分发配置。
-
在机器级别,设置操作系统环境变量
-
在进程级别向你的应用程序传递命令行参数
请记住,无论你在哪个级别配置环境,你都是在配置环境;因此,所有配置源都必须始终从应用程序的单一点访问。这个配置根服务应该小心确定在提供请求的值时哪个来源最重要。在上面的列表中,我从最低到最高优先级对几个潜在的配置来源进行了排序。例如,设置端口号的命令行参数将覆盖存储在存储库中纯文本文件中的端口号。
显然,这些并不是我们存储配置的唯一地方,但它们为任何应用程序提供了一个很好的起点。我知道我严重地破坏了纯文本,但有一个纯 JSON 文件来设置绝对基本设置,例如环境名称和端口号是可以的。让我们称这个文件为defaults.json:
{
"NODE_ENV": "development",
"PORT": 80
}
这在纯文本方面是完全合理的。我也鼓励保留第二个纯文本文件,你可能称之为user.json,以保存你可能想要使用但不必提交修改默认值的个人配置。user.json文件在需要快速使用不同配置进行测试时也非常有用:
{
"PORT": 3000
}
只要加密,敏感的配置就可以被检查到源控制中。我提倡使用这种配置来在开发者之间共享环境默认值。理由是,你不必每次默认值更改时都重新分发 JSON 文件,而是一次性分发解密安全文件的密钥,每次更改都会被检查到源控制中,开发者可以使用他们已有的密钥来解密。
我应该提到,为了最大化安全性,每个加密配置文件应使用不同的私钥。这在处理每个环境一个文件的情况下尤为重要,因为任何环境的泄露都会造成混乱;此外,如果私钥只在一个地方使用,更换密钥也会更容易。
你有几种不同的方式可以在你的环境中安全地分发配置;我们将在下面介绍其中几种。第一种是通过加密,我们将通过一个具体的例子来介绍如何安全地加密配置文件。第二种选择是不将环境配置文件与代码库一起分发,而是仅在目标环境中存储配置。让我们从加密安全开始。
3.2.2. 使用加密来强化环境配置安全性
为了在代码库中安全地传输配置,你需要采取一些安全措施。首先,你不应该将解密后的配置文件提交到源代码控制,因为这会违背加密的全部目的。对于加密密钥也是如此:你应该将它们保存在安全的地方,最好是完全不在云端——也许可以放在 U 盘上。你应该在仓库中共享的是这些文件的加密版本以及简单的命令行工具,用于解密或更新它们的加密副本。图 3.3 描述了这一流程。
图 3.3. 使用私有 RSA 密钥进行配置加密和解密流程

为了这个目的,你可以设置几个文件夹。例如,使用env/private,你将在这里保存已解密的未加密数据,以及env/secure来存储加密文件。因为env/private文件夹包含敏感数据,所以它不应该提交到源代码控制系统。相反,你将通过其他方式分发加密密钥;例如,物理地将其交给相关人员。然后,仓库将包含工具(在你的情况下是 Grunt 任务)来使用相应的 RSA(一种加密算法)密钥加密和解密每个特定的文件。你将使用三个不同的 Grunt 任务进行加密。第一个将生成私钥;其他两个将使用该私钥加密和解密你的配置。
RSA 加密示例
我编写了一个完全工作的示例,可在附带的源代码列表中找到,名为 02_rsa-config-encryption,^([a])在 ch03 目录下。在该示例中,你将使用我编写的grunt-pemcrypt包,该包简化了处理安全配置文件加密和解密所需的任务。我们不会深入代码本身,因为它相当直观,并且有很好的文档记录。
^a 代码示例可在网上找到,地址为
bevacqua.io/bf/secure-config。
为了回顾 RSA 加密
-
创建一个私钥;不要与任何人分享。
-
使用它来加密你的敏感文件。
-
将加密文件与代码库一起传输。
-
当你需要更新安全文件时,更新明文文件并重新加密它。
-
当其他人复制你的代码库时,除非你给他们提供密钥,否则他们无法访问加密配置。
在下一节中,让我们看看采取替代路线的优缺点:不加密你的环境级配置,也不将其(以及你的敏感机密)与你的应用程序代码库的其他部分一起分发。
3.2.3. 在操作系统级别设置环境级配置
当涉及到发布环境(预发布、生产以及介于两者之间的任何环境)时,你可能希望在环境中直接配置敏感值,并使其远离你的代码库。将你的配置从代码库中移除,使你能够在无需全面重新部署的情况下更改它。使用系统级环境变量是做到这一点的好方法。
这是我从与基于云的托管解决方案(如 Heroku)合作中获得的,它设置起来很方便。使用环境变量的额外好处是,你不需要接触代码库来改变其行为。缺点是,与你的先前方法类似,当你第一次克隆仓库时,你无法访问大多数配置。那个缺点的例外是任何未受保护默认值,例如开发环境监听端口。然而,那个缺点也是采取这条路线的目标:无法将新克隆的仓库直接部署到生产环境之一。
加密文件存储和环境级配置之间的区别在于,完全不与你的代码库共享任何内容更为安全,即使它是加密的。但采用环境变量方法的缺点是,你仍然需要将配置放置在那里。
在下一章中,我将介绍 Heroku,这是一个云托管平台即服务(PaaS)提供商,它使你在云中托管 Web 应用变得与执行git push一样简单。Heroku 使用环境变量进行环境配置,并且他们详细记录了他们的理念(关于 Web 应用构建、架构和扩展)并在一个名为 12factor.net 的网站上发布,每个人都应该阅读。
¹ 12 Factor 是一个关于稳健应用开发的优秀指南。请在此处查看
bevacqua.io/bf/12factor。
对于本地开发,你仍然会使用一个不会提交到源控制的 JSON 文件,它包含了你之前章节中会放入安全 JSON 文件的内容。以下是一个示例环境 JSON 文件:
{
"NODE_ENV": "development",
"PORT": 8080,
"SOME_API_SECRET": "zE1nMDDqkzDbSDX4fS5acCpllk0W9",
"SOME_API_KEY": "IYOxBMFi34Rkzce7kY4h0GqI"
}
如果你想要为你的项目的新贡献者提供你本地使用的环境文件副本,考虑为该单个文件(development配置)采用加密方法,而对于托管环境(那些不是本地于你的开发机器的)采用环境变量方法以最大化安全性。
对于托管解决方案(如 staging 或 production),采取不同的方法。Heroku 提供了一个命令行界面,这使得设置环境变量变得容易。2 以下示例中,你可以将环境设置为 staging,这样你的代码就可以调整到该环境——例如,增加日志记录,但主要与生产环境相同:
² 在
bevacqua.io/bf/heroku-cli了解更多关于使用 Heroku 配置 Node.js 环境的信息。
heroku config:add NODE_ENV=staging
命令行应该对值的选择有最后的决定权,这使得启用对环境的小幅修改变得容易,例如设置端口或执行模式(调试或发布)。以下是一个示例,它覆盖了端口和环境:
NODE_ENV=production PORT=3000 node app.js–
最后,让我们回顾一下如何以有意义的方式将所有不同的配置源(环境变量、文本文件和命令行参数)组合在一起。
3.2.4. 将配置作为代码中的瀑布合并
你现在可以将所有这些合并成 JavaScript 代码片段。考虑到我们有多懒,让我们不要写太多代码来完成这个任务。
有一个名为 nconf 的 npm 模块,用于合并配置源,无论你使用什么:JSON 文件、JavaScript 对象、环境变量、进程参数等等。以下代码是一个示例(在示例中标记为 ch03/03_merging-config),展示了如何配置 nconf 以使用 3.2.2 节 中的纯 JSON 文件。请注意,虽然代码列表中的配置源顺序可能看起来有些不合常理,但 nconf 会根据“先来先服务”的原则优先处理配置:
var nconf = require('nconf');
nconf.argv();
nconf.env();
nconf.file('dev', 'development.json');
module.exports = nconf.get.bind(nconf);
在设置好此模块后,你可以使用它从任何存储中获取配置值,按照出现的顺序:
-
首先,
nconf.argv()将命令行参数的优先级置于所有其他内容之上,因为它是我们添加的第一个源。例如,使用node app --PORT 80执行应用程序意味着 PORT 变量将被分配该值,无论其他来源的配置如何。 -
nconf.env()行指示nconf从环境中获取配置。例如,执行PORT=80 node app将端口设置为80,而PORT=80 node app --PORT 3000将端口设置为3000,因为命令行参数的优先级高于环境变量。 -
最后,
nconf.file()行从 JSON 文件中拉取最不重要的值:这些值将被环境变量和命令行参数覆盖!如果你提供了一个命令行参数,例如--PORT 80,那么你开发 JSON 文件中的"PORT": 3000就不重要了;你仍然会使用端口 80。再次强调,你将在附带的源代码中找到一个完整的示例,也详细说明了如何在使用 Heroku 路线时使用nconf。这将在下一章中非常有用,所以我建议你把这一章读到结尾,然后如果你还没有看过,就熟悉一下代码示例。
现在你已经知道如何正确配置构建和环境,我们将继续到最后几个部分。在进入持续开发之前,让我强调一下在第一次设置环境时的一些最佳实践。
3.3. 自动化繁琐的首次设置任务
当你第一次设置环境时,你必须考虑你在做什么,并且你需要自动化任何可以自动化的东西。原因:如果你不自动化,这会直接转化为新来者更多的工 作。预先自动化这些任务的另一个原因纯粹是因为你可以这样做。
在开始时,一次自动化一小部分简单的事情是很简单的。然而,随着项目的开发,这样做变得越来越令人畏惧和不可能。你的同事可能在这个时候反对这样做,而设置一个工作环境可能需要你一周的时间。我过去在一个极其庞大的项目上遇到过这种情况,管理层对此表示可以接受。设置本地开发环境涉及
-
阅读一系列令人畏惧的、写得不好的维基文章
-
手动安装依赖项
-
手动应用模式更新
-
在获取最新代码后,每天早上手动应用这些更新
-
安装音频编解码器,甚至专有软件,例如特定版本的 Windows Media Player
一周后,我所展示的只是一个“有点工作”的环境。在那之后的三个星期,我找到了另一份工作,因为我无法忍受那个项目中手动、繁重的工作。这个问题的驱动因素是改变应用程序构建方式很难,没有直接和自动化的流程来设置新环境可能会在以后变得极其昂贵,实际上变得如此繁琐,以至于你都不想麻烦去改变它。我在那次经历中感到的挫败感是推动我提出“先构建”这一构建导向方法的主要动机之一,这也是我在这本书中大力倡导的方法。
在第二章中,我们介绍了如何自动化我们的构建过程,你甚至学会了如何自动创建、配置和更新一个 MySQL 数据库实例(在示例中的 ch02/10_mysql-tasks 目录下)。正如你在示例代码中所看到的,设置数据库初始化是复杂的,但它也可以很有成就感:你不需要为新合作伙伴提供除了代码仓库和几条指令让他们执行 Grunt 任务之外的其他任何东西。
³ 数据库配置任务示例可以在
bevacqua.io/bf/db-tasks找到。
我们详细讨论了在配置方面可以采取的措施,在这方面,当你设置新的开发环境时,你所需要做的就是获取解密密钥(存储在某个安全的地方)并运行一个 Grunt 任务。首次设置不应该比设置你的环境配置需要更多的手动劳动;它应该那么简单。
好的,你已经处理好了所有环境、发行版、配置和自动化,包括繁琐的首次设置。现在是时候享受本章开头承诺的乐趣了!接下来是持续开发!
3.4. 工作在持续开发中
持续开发是指能够在代码库中不间断地工作,当我提到中断时,我并不是指那些烦人的项目经理询问你在忙什么,或者同事请求帮助解决他们似乎找不到的 bug。当我提到中断时,我指的是那些慢慢侵蚀你工作日的工作,比如每次你的应用程序发生变化时都要重新执行node。即使现在,有了你新搭建的构建过程,你每次文件更改时都必须亲自运行它吗?不可能!你没有那么多时间。你将使用另一个任务来完成这个工作。
然后还有一些小事情,比如保存你的更改和刷新你的浏览器。你将通过让工具来做这些事情来摆脱这些烦恼。在“先构建”系统中,重复的例行公事并不怎么有威望。让我们看看你能从你的工作流程中自动化多少。这并不是为了证明你可以自动化任何事情;相反,好处在于你可以花更多的时间做有意义的事情:思考和摆弄代码。
你将要采取的第一个步骤是投资一个好的手表(从字面上讲——在你的最喜欢的任务运行器中使用手表任务),这将允许你在保存文件更改时自动重启构建过程。
3.4.1. 不浪费时间,使用监视器!
如果你像我一样,你每隔几秒钟就会保存或切换标签。你不能每次更改注释或逗号时都运行完整的构建;那会浪费你大量的时间。然而,许多人这样做,因为他们还没有找到更好的方法。你正在阅读这本书,所以你领先了一步。恭喜你。
Grunt 最有用的插件之一无疑是 grunt-contrib-watch。这个插件会监控你的文件系统中的代码变化,并运行受这些代码变化影响的任务。每当文件变化影响你的构建任务之一时,你应该再次执行该任务。这是持续开发的一个支柱,因为你不需要做任何事情;构建过程会根据需要自动运行。让我们来看一个快速示例:
watch: {
rebuild: {
tasks: ['build:debug'],
files: ['public/**/*']
}
}
通过这个示例,称为 04_watch-task,并在代码示例中的 ch03 目录下找到,你可以在任何文件在 public 文件夹中更改或创建时重新运行整个构建过程。现在你再也不必担心不断运行构建;它可以自动运行!
但即使这种方法也不是最有效的方法,因为这将运行所有的构建任务,即使是没有受更改文件影响的任务。例如,如果你编辑了一个 LESS 文件,这并不重要;任何与 JavaScript 相关的任务,如 jshint,也会运行,因为它们也是构建的一部分。为了纠正这种行为,你应该将 watch 分解成多个目标:每个可能受文件更改影响的构建任务一个。以下列表简要展示了我在说什么。
列表 3.2. 将 watch 分解成多个目标
watch: {
less: {
tasks: ['less:debug'],
files: ['public/css/**/*.less']
},
lint_client: {
tasks: ['jshint:client'],
files: ['public/js/**/*.js']
},
lint_server: {
tasks: ['jshint:server'],
files: ['srv/**/*.js']
}
}
这样分解你的监控可能看起来有些繁琐,但这样做是非常值得的。它会加快你的持续开发流程,因为你会进入一种模式,即你构建的内容就是发生变化的内容,而不是盲目地重建一切。你可以在代码列表中找到一个完全工作的示例,标记为 ch03/05_better-watch-closely.^([4])
⁴ 你可以在网上找到代码示例,地址为
bevacqua.io/bf/watch-out。
观察你构建中的此类变化是很好的,但如果你能进一步扩展,观察你的 Node 应用程序的变化呢?嗯,实际上你可以,也应该这样做。围坐在一起,让我们来谈谈 nodemon。
3.4.2. 监控 Node 应用的变化
在持续开发领域,你应尽可能避免无休止地重复任何内容,而是保持 DRY(Don't Repeat Yourself)原则,而不是 WET(Write Everything Twice)。你刚刚看到了这样做的好处——不需要每次有变化时都运行构建。现在,你将为 Node 使用相同的快捷方式。
把 nodemon 命令想象成使用 node 命令,只不过它会监控变化并重新启动你的应用程序,再次运行 node,这样你就不必亲自做了。要安装它,使用 npm,并带上 -g 修饰符,这样它就会全局安装,便于从命令行访问:
npm install -g nodemon
现在,你可以运行 nodemon app.js,而不是 node app.js。默认情况下,nodemon 监控 *.js 文件,但你可能希望进一步限制。在这种情况下,你可以提供一个 .nodemonignore 文件,它的工作方式与 .gitignore 类似,并允许你忽略 nodemon 不需要监控的文件。以下是一个示例
# package control
./node_modules/*
# build artifacts
./bin/*
# ignore client-side js
./src/client/*
# ignore tests
./test/*
使用 grunt watch 并在另一个终端中运行 nodemon app.js 虽然确实比通过 Grunt 同时运行它们要快一些,但这多出来的开销。然而,运行单个命令就足够方便,不需要打开两个终端窗口,这可能会抵消引入的额外开销。一般来说,速度(分别运行)和便利性(在 Grunt 下运行它们)之间有一个权衡。我个人更喜欢便利性,不需要单独执行额外的命令。
接下来,我们将探讨如何将 nodemon 集成到 Grunt 中。
结合 watch 和 nodemon
在你将 nodemon 集成到 Grunt 之前,你需要解决一个问题,那就是 nodemon 和 watch 都是 阻塞任务:这些任务永远不会结束;它们会坐着等待你的代码发生变化。Grunt 按顺序运行任务,在你可以运行另一个任务之前,会等待当前任务结束。但如果它们两个都不结束,另一个就无法开始!
为了解决这个问题,你可以使用 grunt-concurrent,它将为每个你提供的任务启动一个新的进程,让你成为一个更快乐的极客。通过 grunt-nodemon 可以轻松地将 nodemon 通过 Grunt 运行。以下是一个示例。
列表 3.3. 使用 Grunt 的 nodemon
nodemon: {
dev: {
script: 'app.js'
}
},
concurrent: {
dev: {
tasks: ['nodemon', 'watch']
}
}
这个例子也在配套的源代码列表中,命名为 06_nodemon(在 第三章 下。)在这一章中,你改进了事件的顺序,因为你的更改被保存了,但你仍然在进行保存!
让我们简要谈谈保存更改的问题。
3.4.3. 关心文本编辑器的编辑器
选择合适的编辑器对你的日常工作效率至关重要,而效率可以转化为幸福感。花时间学习你选择的编辑器的细节。当你第一次发现自己正在观看一个关于文本编辑器快捷键的 YouTube 视频时,你可能会觉得自己很古怪,但这将是值得的时间。你大部分时间都在使用代码编辑工具,所以你最好学会如何利用这些编辑器提供的功能。
幸运的是,现在大多数编辑器都提供了一种机制来自动保存你的更改。一开始可能会觉得有点奇怪,但当你习惯了,你会爱上它,并且永远不会回头。我个人喜欢 Sublime Text,这是我打这些字的编辑器,也是我大部分写作时使用的编辑器。如果你使用的是 Mac,TextMate 似乎是一个可行的选择。其他选项包括 WebStorm,这是一个专门针对 Web 开发的 IDE,然后是 vim,对于那些敢于学习使用其复杂、快捷键密集的用户界面的人来说。
我提到的所有编辑器都支持自动保存;如果你使用的编辑器不支持,我强烈建议你切换到一个支持自动保存的编辑器。一开始你可能会感到不舒服,但使用你新的文本编辑器后,你很快就会开始给我写感谢信。
让我们以关于 LiveReload 技术的讨论来结束,以及你如何从中受益。
3.4.4. 浏览器刷新是如此 Y2K
LiveReload 是一种理解你无法浪费宝贵时间在每次变化时刷新浏览器的技术。它利用了浏览器中可用的实时通信技术——WebSocket(它非常棒)。通过使用 WebSocket,LiveReload 可以决定是否需要对你的 CSS 应用小改动,或者当 HTML 发生变化时执行完整的页面刷新。
启用它相当简单,简单到我们没有任何借口不在这个时候去做它。它包含在grunt-contrib-watch中,因此设置起来就像添加一个watch目标一样简单,如下面的列表所示。
列表 3.4. 启用 LiveReload
watch: {
livereload: {
options: {
livereload: true
},
files: [
'public/**/*.{css,js}',
'views/**/*.html'
]
}
}
接下来,你需要安装浏览器扩展并启用它。现在,在调试应用程序时,你再也不需要自己刷新浏览器了。还有一个现成的例子^([5])供你参考(在代码示例中标记为 ch03/07_livereload),其中包含了所有必要的设置说明,但启动起来非常简单。
⁵ 使用以下代码示例查看 LiveReload 的实际操作:
bevacqua.io/bf/livereload。
3.5. 摘要
你已经完成了环境和开发工作流程的快速入门课程!以下是本章教学内容的快速回顾:
-
调试和发布版本以不同的方式影响你的构建流程;调试旨在捕捉错误和持续开发,而发布旨在监控和速度优化,正如你将在下一章中看到的。
-
你的应用程序应该配置得让秘密不会进入源代码,同时也应该提供足够的灵活性,以便根据你运行的环境进行配置。
-
我们已经介绍了持续开发和如何通过使用
watch任务来重新构建你的应用程序以及nodemon在更改后重启它来获得好处,以及选择合适的文本编辑工具的重要性。
在下一章中,我们将更详细地介绍你可以考虑用于发布构建的性能优化,什么是持续集成以及如何利用它来获得优势,你应该如何监控应用程序中的分析,以及最后如何将你的应用程序部署到托管环境,如预发布和生产环境。
第四章. 发布、部署和监控
本章涵盖
-
理解发布流程和预部署任务
-
部署到 Heroku
-
使用 Travis 进行持续集成
-
理解持续部署
我们已经涵盖了构建过程,你可以执行的一些常见构建任务(以及如何使用 Grunt 来完成这些任务),以及从高层次上讲,环境和配置。我们详细讨论了开发环境,但这只是故事的一半。开发环境是你将花费大部分时间工作的地方,因为你将有一个系统在位,这样你就可以为发布准备你的应用程序,将其部署到人类可以访问的平台,然后监控应用程序状态。多亏了“先构建”的心态,你将自动化我刚才提到的流程,避免重复、人为错误,并在测试的同时节省时间,正如我在第一章中承诺的那样。
持续集成(CI)平台将通过确保你的测试在托管环境中通过,来帮助部署更健壮的构建到生产环境中。正如你将在本章后面看到的那样,CI 会在你每次向版本控制系统(VCS)推送时远程测试你的代码库。构建自动化(和持续开发)对于保持你日常开发工作的生产力和效率至关重要。同样,拥有一个易于执行的流程可以确保你可以根据需要频繁地部署你的应用程序,而不用担心执行半小时的令人尴尬的手动任务集。
到本章结束时,你将准备好执行安全、连续的部署,这在精神上与持续开发相似。它们都旨在减少重复工作和减少人为错误。发布流程有几个阶段,我们将在本书中遵循:
-
第一步是构建过程,在发布分发下进行。
-
一旦构建完成,你将运行测试以确保最近的变化没有破坏构建。在开发过程中,应通过使用代码检查程序来持续解决小的语法问题。
-
如果测试成功,你可能会进入预部署操作,例如更新版本号和发布变更日志。
-
之后,你将调查部署选项,例如云托管选项和持续集成平台。
图 4.1 描述了这个提出的发布和部署流程。当你查看这张图时,请记住我提出的先部署到预发布环境,以确保在上线到生产环境之前,托管环境中的所有事情都按预期工作。
图 4.1. 提出的发布和部署流程

你面前还有很长的路要走;让我们先从讨论发布和部署流程开始。你将在 4.2 节中详细了解预部署操作。然后在 4.3 节中,我将告诉你所有关于部署的事情,你将学习如何将应用程序部署到 Heroku。4.4 节涵盖了持续集成以及你可以用来让 CI 为你做繁重工作的工具。
4.1. 发布你的应用程序
当你准备发布你的应用程序时,你希望将网络的最佳实践放在你的餐盘上。在第二章中,我们讨论了压缩,为了更好的性能缩小你的资源,以及连接,将文件合并在一起以减少 HTTP 请求的数量,这些你肯定希望包含在你的发布构建中。这些通过将可读的开发者源代码捆绑成包含源代码中所有内容的单个文件来提高 Web 应用程序的用户体验,但这些文件是压缩的,以便加快下载速度。在那一章中,我们还介绍了精灵图和精灵,包含许多图像的大文件。这些也会用于调试分发,仅仅是因为它们允许你将调试和发布更紧密地结合在一起,而不是那么不同。否则,你需要在调试 CSS 中引用单个图标,然后在发布中 somehow 引用精灵图和每个图标的定位,这违背了将两个构建流程统一并重复自己的目的,打破了 DRY 原则。
压缩、连接、精灵化——发布流程中还有什么其他内容吗?在本节中,我们将介绍图像优化和资源缓存;然后我们将继续讨论部署流程、语义版本控制和轻松更新变更日志。
4.1.1. 图像优化
连接和压缩的 JavaScript 和 CSS 文件并不能完全说明问题。通常情况下,图片代表了网页下载量的大部分,这意味着它们比其他任何静态资源都更需要优化。在第二章中,你已经做了一部分优化工作,当时你学习了如何使用不同的图片生成精灵图,这与文本文件中连接的工作方式类似,即将多个文件合并成一个。另一种优化方法是压缩,通过缩短变量名和其他微优化来减少脚本和样式表文件的内容。在图片的世界里,你有各种方法来压缩文件,这通常可以带来 9%到 80%的增益,通常超过 50%。幸运的是,对于某些 Grunt 包,就像我们逐渐习惯的那样,它们在这方面为我们做了大量的工作。
其中一个包是 grunt-contrib-imagemin, 它正好做了你想要的事情:对不同格式的图片如 PNG、GIF 和 JPG 进行图像压缩。在深入探讨之前,我将简要介绍它可以帮助你优化的两个方面:无损压缩和交错。
无损图像压缩
无损图像压缩与 JavaScript 最小化类似,其任务是移除图像原始二进制数据中的不重要的数据位。重要的是要注意,无损压缩不会改变图像的外观,而仅仅是其二进制表示。无损压缩的唯一结果是图像尺寸更小,看起来与较大的图像完全相同。幸运的是,有更聪明的人已经花费时间开发出为我们执行高级图像压缩的工具。你可以指定图像的路径,并让它们的算法进行处理。此外,grunt-contrib-imagemin配置了这些低级程序的正确参数,因此你不需要这样做。请注意,与有损压缩相比,无损压缩产生的字节节省较少;然而,当你不能承受任何图像质量损失时,它是非常好的。当你能够承受图像质量损失(而且大多数时候损失几乎不明显)时,你应该使用有损图像压缩。
有损图像压缩
有损压缩是一种图像压缩技术,在重新编码图像时应用不精确的近似(也称为部分数据丢弃),这比无损压缩获得的字节节省更多(高达 90%的节省),其中移除的信息通常是元数据,如地理位置、相机类型等。grunt-contrib-imagemin包默认使用有损压缩,除了无损压缩外,还用于移除不必要的元数据。如果你只想使用无损压缩,你应该考虑直接使用imagemin包。
交错图像
你将要研究的另一个图像优化任务是交错。^([1]) 交错图像的大小比普通图像大,但这些增加的字节通常是非常值得的,因为它们可以提升感知性能。即使图像可能需要更长的时间来完成下载,但它将比普通图像更快地开始渲染。渐进式图像的工作方式正如其名。它们首先渲染图像中像素的最小视图,这大致看起来像是你的完整图像,然后随着更多数据被传输到浏览器,它们会逐步增强(直到全质量图像可用)。
¹ 通过访问
bevacqua.io/bf/interlacing了解更多关于交错如何提升感知性能的信息。还有一个动画 GIF 更好地解释了交错图像的工作原理。
传统上,图像是从上到下、以全质量加载的,这转化为更快的下载时间但较慢的感知渲染。查看整个图像所需的时间等于完成时间。在渐进式渲染模式下,人类感知到的体验更快,因为他们不需要等待那么长时间才能看到整个图像的(混乱的)视图。
设置 grunt-contrib-imagemin
设置 grunt-contrib-imagemin,幸运的是,就像我们之前讨论的其他任务一样简单。记住,重要的是要了解任务做什么,以及何时以及如何应用它们。以下列表配置在发布构建过程中优化 *.jpg 图片。
列表 4.1. 发布构建过程中的图像优化
imagemin: {
release: {
files: [{
expand: true,
src: 'build/img/**/*.jpg'
}],
options: {
progressive: true // progressive jpgs
}
}
}
列表 4.1 不需要任何额外的配置来压缩图片;这是默认完成的。一个完整的工作示例可以在本章的配套源代码中找到,标记为 ch04/01_image-optimization,包括 debug 和 release 分发版本的完整构建工作流程。现在你已经让网络对人类漫无目的地漫游变得稍微好一些,你可以将注意力转向静态资源缓存。
4.1.2. 静态资源缓存
如果你对这个术语不熟悉,可以把 缓存 想象成从图书馆复印历史书籍。与其每次想阅读它们时都去图书馆,你可能会更喜欢打印几页,带回家,然后随时阅读,而无需再次访问图书馆。
网络中的缓存比从图书馆借阅的书籍的复印要复杂,但这应该能让你抓住其精髓。
Expires 头部信息
你应该绝对遵循的最佳实践之一是使用 Expires 头部信息来处理你的静态资源。根据 HTTP 协议,这个头部信息告诉浏览器,如果资源至少请求过一次(并且因此被缓存),并且缓存的版本尚未过时,则不要再次请求该资源。Expires 头部信息中的过期日期决定了缓存的版本何时不再被认为是有效的,并且资产需要重新下载。一个示例 Expires 头部信息可能是 Expires: Tue, 25 Dec 2012 16:00:00 GMT.
这既是一个令人惊叹的实践,也是一个糟糕的实践。对于人类来说,这是一个令人惊叹的实践,因为他们在访问你的页面之一后,不需要重新下载浏览器缓存中存储的资源,从而节省了请求和时间。对于我们这些开发者来说,这是一个糟糕的实践,因为即使你部署了资产的变化,人类也不会再下载它们。
为了解决这种不便,并使 Expires 头部信息变得有用,你可以在部署对资产进行更改时,将哈希值附加到它们的名称上,这会迫使浏览器重新下载文件,因为从所有目的来看,这是一个与它们之前缓存中的文件不同的文件。
哈希
一个 哈希 是一个函数,它返回一个固定长度的值,这是数据的编码表示。在你的情况下,哈希可以从资产内容和它的最后修改日期计算得出。这样一个哈希可能是 a38cbf9e. 虽然看起来是随机的,但实际上没有随机性。这会违背使用 Expires 头部信息的目的,因为文件总是有不同的名称,并且每次都会再次请求。
一旦计算出一个哈希值,您可以使用它作为页面中的查询字符串参数,例如 /all.js?_=a38cbf9e,或者将其附加到文件名上,例如 /a38cbf9e.all.js。另外,您还可以将哈希值添加到 ETag 头部。选择正确的方法取决于您的需求。如果您处理的是静态资源,如 JavaScript 资源,那么您可能更倾向于对文件名(或其查询字符串)进行哈希处理,并使用 Expires 头部。如果您处理的是动态内容,则建议在 ETag 中设置哈希值。
使用 Last-Modified 或 ETag 头部
ETag 头部唯一标识资源的一个版本。同样,Last-Modified 标识资源的最后修改日期。如果您使用这两个头部中的任何一个,那么您应该在 cache-control 头部中使用 max-age 修饰符,而不是 Expires 头部。这种组合可以实现更软的缓存,因为用户代理可以确定是否应该使用缓存的副本,或者是否需要再次请求资源。以下示例展示了如何结合 ETag 和 cache-control 头部:
ETag: a38cbf9e
Cache-Control: public, max-age=3600
Last-Modified 头部作为 ETag 头部的替代,出于方便考虑。在这里,我们没有指定一个唯一标识的 ETag,而是通过设置修改日期来实现相同的唯一性:
Last-Modified: Tue, 25 Dec 2012 16:00:00 GMT
Cache-Control: public, max-age=3600
让我们来看看您如何使用 Grunt 为您的文件名创建哈希值,然后可以安全地使用这些哈希值设置远期 Expires 头部。
使用 Grunt 进行缓存破坏
在您的构建过程中,您几乎无法设置 HTTP 头部,因为这些必须在每个响应中发送出去,而不是静态确定。但您可以做的就是在 grunt-rev 的帮助下为您的资产分配哈希值。这个包将为您的每个资产计算哈希值,然后重命名它们,将相应的哈希值附加到原始名称上。例如,public/js/all.js 将被更改为类似 public/js/1be2cd73.all.js 的内容,其中 1be2cd73 将是 all.js 内容计算出的哈希值。从这个任务中产生的一个问题是,现在您的视图不会引用正确的资产,因为它们已经被带有哈希值的前缀重命名了。为了解决这个问题,您可以使用 grunt-usemin 包,该包会在您的 HTML 和 CSS 中查找静态资产引用,并使用更新的文件名刷新它们。这正是您所需要的。相关的 Grunt 配置如下所示(在示例中标记为 ch04/02_asset-hashing)。
列表 4.2. 更新文件名
rev: {
release: {
files: {
src: ['build/**/*.{css,js,png}']
}
}
},
usemin: {
html: ['build/**/*.html'],
css: ['build/**/*.css']
}
请记住,在debug流程中,你不需要这两项任务中的任何一项,因为这些优化在开发过程中对你没有任何好处,所以可能适当地将它们的目标命名为release,以使这种区别更加明确。然而,usemin任务是以一种方式编写的,Grunt 目标具有特殊含义。css和html目标分别用于配置你想要使用哈希文件名更新的 CSS 和 HTML 文件,但像release这样的目标将被usemin忽略。
我们将要介绍的下一个技术涉及在样式标签中内联 CSS,以避免对 CSS 的渲染阻塞请求,从而实现更快的页面加载。
4.1.3. 内联关键“折叠以上”CSS
浏览器在遇到需要下载的 CSS 资源时会阻止渲染。然而,多年来我们一直教导对方将 CSS 放在页面顶部(在<head>中),这样用户就不会看到未样式化的内容的闪光(简称 FOUC)。内联技术旨在通过避免 FOUC 来提高页面加载速度,而不会损害用户体验。只有在你同时在服务器端和客户端渲染视图的情况下,这种技术才能有效,正如我们在第七章中探讨的那样。
要实现这个功能,你必须做几件不同的事情:
-
首先,你需要确定“折叠以上”的 CSS;这些是在首次加载时正确渲染页面可见元素所需的样式。
-
一旦我们确定了在“折叠以上”有效使用的样式(那些浏览器需要正确渲染页面并避免 FOUC 的样式),你需要在页面的
<head>中用<style>标签内联它们。 -
最后,现在所需的样式已经内联在
<style>标签中,你可以通过在onload事件触发后延迟请求来消除对 CSS 样式表的渲染阻塞请求,使用 JavaScript。 -
自然地,你不会想让用户在 JavaScript 关闭的情况下陷入困境,并且因为我们作为网络的好公民,你也会使用一个后备的
<noscript>标签来确保渲染阻塞请求无论如何都会执行。
如你所注意到的,这是一个复杂且容易出错的流程,就像第一章中的案例研究一样,Knight Capital 由于人为错误损失了五亿美元。如果出了问题,对你来说可能不会那么灾难性,但自动化这个流程几乎是强制性的:每次你的样式或标记发生变化时,都需要做太多工作!
让我们学习如何使用grunt-critical自动化这个过程。
让 Grunt 做重活
使用grunt-critical来完成这个任务非常简单,尽管它确实提供了大量的配置选项。在下面的代码中,你会找到简单用例的配置。在这种情况下,你正在从页面中提取关键 CSS 并在构建后内联这些样式,在<style>标签内。critical会进一步延迟其他样式的加载,以避免阻塞渲染,并且它还添加了<noscript>回退标签,以供禁用 JavaScript 的用户使用:
critical: {
example: {
options: {
base: './',
css: [
'page.css'
]
},
src: 'views/page.html',
dest: 'build/page.html'
}
}
你可能已经熟悉所有提供的选择项,它们都是文件路径。基本选项表示在查找绝对资源路径(如/page.css)时应使用的根目录。一旦你设置 Grunt 为你执行内联操作,请记住要提供升级后的 HTML 文件,而不是预构建的文件。
在切换到自动化部署的热水温泉之前,你需要反思在每次部署之前测试发布构建的重要性,以降低温泉位于活跃火山区的可能性。
4.1.4. 部署前的测试
在你进入部署阶段,甚至是我们即将探讨的预部署阶段之前,你需要测试你的发布构建。当未来有部署计划时,测试发布构建变得很重要,因为你想要确保你的应用程序按预期运行,或者至少按你编写的测试预期运行。
在本书的下一部分,我们将深入探讨应用程序测试的地下世界,并详细考察两种测试类型(尽管存在许多其他类型):
-
单元测试:在这里,你通过隔离应用程序的各个组件来测试它们,确保组件在单独运行时工作正常。
-
集成(或端到端)测试:这涉及一系列经过单元测试的组件,并测试它们之间的交互,确保它们能够适当通信。
在你开始测试实践和示例之前,还需要一段时间。我们将在第八章中讨论测试实践并展示示例。第八章。请记住,在部署之前,你需要测试你的应用程序,以降低将故障构建发送到托管环境之一的风险,尤其是如果该环境是生产环境。让我们讨论一些在测试后但部署前你可以执行的任务。
4.2. 预部署操作
一旦你为发布准备了一个构建并对其进行了仔细的测试,你就可以部署了。但在我们跳入部署温泉之前,我想先提几个重要的预部署任务。
图 4.2 是部署流程的概述,以及构建前的操作,这些操作可以被认为是部署就绪的。它还展示了你将如何逐步将更新部署到不同的环境中,确保最大程度的可预测性。
图 4.2. 部署前的版本控制和渐进式部署滚动。在预发布阶段由 QA 团队进行测试,确保在生产部署前具有稳健性。

部署前操作
-
语义版本控制: 这有助于跟踪有意义的应用程序版本。语义版本格式类似于
MAJOR.MINOR.PATCH-BUILD。这个标准有助于在管理依赖关系时避免混淆。如果你想对托管环境(如生产环境)上当前部署的代码有任何控制权,保持应用程序版本化是很重要的。它使你能够在事情出错时回滚到旧版本。考虑到这相当容易设置,并且考虑到没有准备好部署失败的成本很高,版本控制变得是理所当然的。 -
变更日志: 变更日志是记录了项目历史中所有变更的列表,根据它们引入的版本进行划分(这也是为什么保持版本很重要的一部分)并进一步细分为错误修复、破坏性变更和新功能。按照惯例,
git仓库中的变更日志通常放置在项目根目录,命名为CHANGELOG.txt或使用你偏好的任何扩展名(例如,Markdown 的md,^([2)),这是一个文本到 HTML 的转换工具)。² Markdown 格式是一种易于阅读、编写和转换为 HTML 的纯文本表示。阅读 2004 年介绍 Markdown 的原始文章,请访问
bevacqua.io/bf/markdown。
我们将在稍后深入探讨如何更好地分配你的变更日志维护时间,但首先让我们来探讨语义版本控制的细节。
4.2.1. 语义版本控制
由于你使用 Node,你可能对语义版本控制这个术语很熟悉。npm为所有包使用语义版本控制^([3)),因为它是一种强大的规范,用于管理不同 Node 模块之间的依赖关系解析。因为每个你生产的 Node 应用程序都已经有一个package.json,考虑到它们包含语义版本,你将在部署前使用这些来标记你的发布。
你可以在
bevacqua.io/bf/semver了解更多关于语义版本控制的信息。
当我谈论版本控制时,我指的是更新包版本并在你的版本控制系统(VCS)中创建一个标签(你可以参考的版本历史中的时刻)。在为你的发布编号时,你可以设置任何你想要的方案,但重要的是你不应该覆盖一个发布;你不应该使用相同的版本号发布两个版本。为了确保这种唯一性,我决定使用 Grunt 在每次构建后(无论分发情况如何)自动增加构建号,并在执行部署时增加补丁号。主要版本更改是故意手动进行的,因为这些可能引入破坏性更改。同样,对于次要版本更改也适用,因为新功能通常在新的次要版本中引入。
使用 Grunt,你可以使用 grunt-bump 包来执行这些版本增量(从现在起称为增量)。它很容易配置,它会为你进行版本标记,甚至还会自动将更改提交到 package.json 文件。以下是一个示例:
bump: {
options: {
commit: true,
createTag: true,
push: true
}
}
这些实际上是此任务提供的默认值。它们足够合理,以至于你根本不需要进行配置。任务将增加 package.json 中找到的版本,精确提交该文件并附带相关消息,然后在 git 中创建一个标签,最后将这些更改推送到 origin 远程。如果你关闭所有三个选项,任务只会更新你的包版本。示例 ch04/03_version-bump 展示了这种行为在实际中的应用。
一旦版本控制问题得到解决,你将想要设置一个变更日志,列出自上次发布以来发生了什么变化。让我们来考虑一下这一点。
4.2.2. 使用变更日志
当新版本发布时,你可能习惯于阅读你感兴趣的产品(尤其是游戏,在其文化中变更日志有很强的存在感)的变更日志,但你是否曾经自己维护过变更日志?这并不像你想象的那么困难。
设置一个变更日志——作为一个帮助跟踪随时间变化所做的更改的内部文档——即使你不向消费者展示,也可能对你的项目是一个积极的补充。
如果你有任何透明度政策,或者你不喜欢让人类处于黑暗中,那么维护一个变更日志几乎成为强制性的。你不应该在构建发布版本时更新变更日志,因为你可能想要为调试目的生成一个发布构建。同样,你也不应该在测试之前更新它们。如果测试失败,那么变更日志就会与最后一个发布就绪的构建不同步。然后你将需要在你生成通过所有测试的构建后更新变更日志。然后,并且只有那时,你才能更新变更日志以反映自上次部署以来所做的更改。
将变更日志合并在一起通常很困难,因为你可能会忘记自上次发布以来发生了什么变化,而且你不想通过查看git版本历史来找出哪些变化值得写入变更日志。同样,每次更改时手动更新它也很繁琐,如果你完全沉浸在某个状态中,可能会忘记这样做。一个更好的选择可能是设置grunt-conventional-changelog,并让它为你生成变更日志。那时你只需提交消息,按照惯例,这些消息以fix开头用于修复错误,以feat开头用于引入新功能,或者以BREAKING开头用于破坏向后兼容性。此外,这个包将允许你在它完成自己的解析和更新后手动编辑变更日志。
就配置而言,这个任务不需要任何设置。以下是一些示例提交消息:
git commit -m "fix: buffer overflows, closes #17"
git commit -m "feat: reticulate splines for geodesic cape, closes #23"
git commit -m "feat: added product detail view"
git commit -m "BREAKING: removed POST /api/v1/users/:id/kill endpoint"
4.2.3. 更新变更日志
bump-only和bump-commit任务允许你在不提交任何更改的情况下增加版本号,这样你就可以更新你的变更日志(如你将在下一分钟看到的)。最后,你应该使用bump-commit一次性将package.json和CHANGELOG.txt检查到同一个提交中。一旦你配置了bump任务以同时提交变更日志,你现在可以使用以下别名一次性更新你的构建版本和变更日志。你可以在示例中找到一个使用grunt-conventional-changelog的例子,列在 ch04/04_conventional-changelog。
grunt.registerTask('notes', ['bump-only', 'changelog', 'bump-commit']);
现在你已经完成了发布构建,测试通过,并且你已经更新了变更日志。你现在准备好将应用程序部署到托管环境中,从那里你可以提供服务。在过去,通过手动上传构建包到生产服务器来部署应用程序相当普遍。你已经从那些美好的旧时光中走了很长的路,部署工具以及应用程序托管平台都变得更好了。
接下来,让我们深入了解 Heroku,这是一个平台即服务(PaaS)提供商,它使你能够轻松地从命令行部署你的应用程序。
4.3. 部署到 Heroku
设置部署流程可能像准备寿司一样困难,也可能像订购外卖一样简单;这完全取决于你对部署的控制程度。在光谱的一端,你有像亚马逊的基础设施即服务(IaaS)平台这样的服务,在那里你对托管环境有完全的控制权。你可以选择你喜欢的操作系统,选择你想要的处理能力,随意配置它,在上面安装东西,然后处理整个 SysOps 的重活,比如保护应用程序免受攻击,设置代理,选择保证正常运行时间的部署策略,以及从头开始配置大多数一切。
在另一端,有一些服务您无需做任何事情,例如那些由域名注册商如 GoDaddy 提供的解决方案。在这些解决方案中,您通常选择一个主题,填充几页静态内容,然后就可以完成;其余的一切都由您来完成。
为了本书的目的,我研究了如何解释如何在亚马逊上托管应用程序的可能性,但最终我得出结论,这将超出范围。话虽如此,我将在本节的末尾提到一种您可以自己探索这种替代方案的方法。
我决定选择 Heroku(尽管还有类似的替代方案,如 DigitalOcean),它不像在亚马逊网络服务(AWS)上设置实例那样复杂,但与使用网站生成器相比,它相当复杂。Heroku 通过轻松地让您从命令行配置和部署应用程序到其平台上的托管环境,简化了您的生活。正如我之前提到的,Heroku 是一个平台即服务(PaaS)提供商,无论您的语言或缺乏服务器管理知识,您都可以在其平台上托管您的应用程序。在本节中,我们将逐步介绍如何将一个简单应用程序部署到 Heroku。
在撰写本文时,Heroku 提供了一个允许您免费托管应用程序的层级。让我们从这里开始。您可以在配套源代码中找到这些说明^([4])。
⁴ 您可以在网上找到 Heroku 部署示例,链接为
bevacqua.io/bf/heroku。1. 访问
id.heroku.com/signup/devcenter,并输入您的电子邮件。2. 接下来,您需要遵循的下一个手动步骤是安装他们的工具包,这是一系列命令行程序,可以帮助您管理在 Heroku 上托管的您的应用程序。您可以在
toolbelt.heroku.com找到它,然后按照网站上的说明运行heroku login,该说明同样可以在该网站上找到。3. 然后您需要一个
Procfile,这是一个描述您的应用程序在操作系统上运行的进程的文件。
Heroku 对 Procfile 的定义如下。请注意,此过程还有一些额外的步骤,可以在接下来的几段中找到。
Procfile
Procfile 是一个名为 Procfile 的文本文件,放置在您的应用程序根目录中,列出了应用程序中的进程类型。每个进程类型都是当该进程类型的实例(在 Heroku 的术语中称为 dyno)启动时执行的命令的声明。您可以使用 Procfile 声明各种进程类型,例如多种类型的工人、一个单例进程(如时钟)或 Twitter 流式 API 的消费者。
简而言之,对于大多数设计良好的 Node 应用程序,Procfile 将类似于以下代码:
web: node app.js
就应用程序而言,你追求的是最基本的要求,因为这是部署到 Heroku 的感觉。app.js 可以小到以下 JavaScript 片段(ch04/05_heroku-deployments):
var http = require('http');
var app = http.createServer(handler);
app.listen(process.env.PORT || 3000);
function handler (req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('It\'s alive!');
}
注意你使用 process.env.PORT || 3000,因为 Heroku 将为你提供应用程序应监听并暴露在名为 PORT 的环境变量上的端口。
然后,你使用 3000 进行本地开发。现在,这里还有一些额外的步骤要执行:
1. 一旦你坐在项目根目录下,请在终端中执行以下命令以初始化一个
git仓库:
git init git add . git commit -m "init"2. 接下来使用
heroku create.在 Heroku 上创建应用程序。这是一次性操作。到目前为止,你的终端应该看起来类似于图 4.3。
图 4.3. 使用他们的 CLI 在 Heroku 上创建一个应用
在你想要进行的每次部署中,你可以使用 git push heroku master 将更改推送到 heroku 远程。这将触发一个部署,看起来就像图 4.4。
图 4.4. 部署到 Heroku——就像 git push 一样简单

如果你想在浏览器中打开应用程序,请使用以下命令:
heroku open
关于 Heroku 和 PaaS 提供商有一个注意事项。在部署构建结果时,没有简单的解决方案。你不应该将构建工件包含在你的仓库中,因为这可能会导致不希望的结果,例如在更改某些内容后忘记重新构建。你也不应该过于依赖他们在平台上的构建,因为构建应该是本地或集成平台上进行的,而不是在应用程序服务器本身上,因为这会降低你的应用程序性能。
4.3.1. 部署构建
问题在于你不应该将构建结果放入版本控制中,因为那些是你源代码的输出。相反,你应该在部署前进行构建,并将构建结果与你的其他代码一起部署。大多数 PaaS 提供商不提供很多替代方案。例如,Heroku 在你向其远程推送时会从 Git 获取部署,但你不想将构建工件包含在版本控制中,这成为一个问题。解决方案:将 Heroku 视为任何持续集成平台(更多内容请参阅第 4.4 节),并允许 Heroku 在其服务器上构建你的应用程序。
Heroku 通常不会为 Node 项目安装 devDependencies,因为它使用 npm install –-production,你需要使用自定义构建包来解决这个问题。构建包是你使用的语言和 Heroku 平台之间的接口,它们是一系列 shell 脚本。使用以下命令创建具有自定义 Grunt 启用的构建包的应用程序很容易,其中 thing 是你在 Heroku 上的应用程序名称:
heroku create thing --buildpack https://github.com/mbuchetics/heroku-
buildpack-nodejs-grunt.git
一旦您使用自定义构建包创建了应用程序,您就可以像平常一样推送,这将触发 Heroku 服务器上的构建。您最后需要设置的是 heroku 任务:
grunt.registerTask('heroku', ['jshint']);
如果构建失败,Heroku 将终止部署,保持之前部署的应用程序不受失败构建的影响。在附带的示例中有详细的解释,列在 ch04/06_heroku-grunt 中,它将指导您如何设置此功能。
让我们看看如何将多个环境整合到单个 Heroku 应用程序中。
4.3.2. 管理环境
如果您想在 Heroku 上设置多个环境,例如 staging 和 production,可以使用不同的 git 远程端点来实现。使用 CLI 创建一个除 heroku 之外的远程:
⁵ Heroku 提供了关于管理多个环境的建议。请访问
bevacqua.io/bf/heroku-environments。
heroku create --remote staging
现在您应该使用 git push staging master 而不是 git push heroku master,同样,您现在需要明确告诉 heroku 使用特定的远程,例如使用 heroku config:set FOO=bar --remote staging 而不是 heroku config:set FOO=bar。请记住,环境配置是针对特定环境的,应该这样处理,因此环境之间不应共享第三方服务的 API 密钥、数据库凭证或任何一般认证数据。
现在您可以直接从命令行配置和部署到特定环境,是时候了解一种称为持续集成的实践了,这将有助于提高整体代码质量。如果您想了解如何将应用程序部署到亚马逊网络服务,可以遵循附带的源代码中的小型指南^([6))(在示例中标记为 ch04/07_aws-deployments)。
⁶ 通过这个代码示例了解如何将应用程序部署到 AWS 的部署过程
bevacqua.io/bf/aws。
4.4. 持续集成
Martin Fowler 是持续集成最著名的倡导者之一。用他自己的话说,^([7]),Fowler 如下描述 CI。
⁷ 在
bevacqua.io/bf/integration阅读 Fowler 关于持续集成的完整文章。
持续集成
是一种软件开发实践,团队成员经常集成他们的工作;通常每个人至少每天集成一次,导致每天有多次集成。每次集成都会通过自动构建(包括测试)来验证,以尽可能快地检测集成错误。许多团队发现这种方法可以显著减少集成问题,并允许团队更快地开发出具有凝聚力的软件。
此外,他还诱惑我们在尽可能接近生产环境的环境中运行测试套件。这意味着,当涉及到测试你的应用程序时,你的最佳选择是在云中做,就像你做托管一样。CI 平台如 Travis-CI 提供诸如构建错误通知和访问完整的构建日志等功能,详细记录了构建(及其测试)期间发生的一切。
我提到了 Travis-CI;让我们看看我们如何设置自己,以便在每次向我们的仓库提交时,都可以远程将其构建添加到其平台上的队列中。然后 Travis-CI 构建服务器将逐个处理这个队列,运行我们的构建并通知我们结果。
4.4.1. 使用 Travis 的托管 CI
持续集成意味着在远程服务器(尽可能接近生产环境)上运行测试,希望捕捉到那些否则会进入普通用户的错误。一旦你正确配置了它,Travis-CI(Circle-CI 是另一个)就是一个你可以远程获取构建结果的 CI 平台。如果构建成功,你甚至都不会注意到。如果构建失败,你会收到一封电子邮件通知,告诉你有人破坏了你的构建(哎呀!)。稍后,当后续的推送修复了构建时,你将收到另一封通知,告诉你修复了什么。此外,你还可以在 Travis 网站上访问完整的构建日志,这在找出构建失败的原因时总是很有帮助。图 4.5 展示了这样一个电子邮件通知。
图 4.5. 一个典型的 Travis 构建修复通知

在这个时代,设置持续集成(CI)几乎太简单了。你首先需要做的是在项目根目录下创建一个 .travis.yml 文件。在这个文件中,你需要声明你使用的语言,在你的情况下被标识为 node_js,你正在测试的运行时版本,以及一系列在集成测试之前、期间和之后执行的脚本。为了说明,这样的文件可能看起来像以下代码:
language: node_js
node_js:
- "0.10"
before_install:
- npm install -g grunt-cli script: - grunt ci --verbose --stack
配置 Travis 和 Grunt
在执行你的测试之前,你需要通过 npm 安装 Grunt 的命令行界面 grunt-cli。你需要在集成测试服务器上安装它,就像你需要在你的开发环境中安装它一样,以便运行 Grunt 任务。你可以使用 before_install 部分来安装 CLI。
然后剩下的就是为 Grunt 设置一个 ci 任务。ci 任务可以运行 jshint 来减轻语法错误,就像你已经在你的新式持续开发工作流程中,每次有东西改变时在本地做的那样。你应该配置 ci 任务,在用 jshint 检查你的代码的基础上,运行单元和集成测试。
在持续集成(CI)中,真正的价值在于远程服务器构建你的整个应用程序,并对代码库应用你的测试(包括代码风格检查),确保你不会依赖于未提交到版本控制中的文件,或者你可能已经安装但未在代码库中提供的本地依赖。
你可能想亲自尝试这个示例,我强烈推荐这样做,因为它对于渴望部署的人来说是一个很好的练习。你可以在附带的示例仓库中找到我详细说明的指令集,该仓库名为 08_ci-by-example,位于 ch04 目录下。完成这些后,你还可以了解持续部署,这是一种可能或可能不适合你的工作流程的实践,但无论如何,你应该完全了解它。
⁸ 你可以在网上找到完整的代码示例,地址为
bevacqua.io/bf/travis。
4.4.2. 持续部署
Travis 平台支持持续部署到 Heroku.^([9]) 持续部署是一种说法,即每次你推送到版本控制时,你也会在 CI 服务器上触发一个构建作业(正如你在上一节中开启 Travis CI 集成时所做的)。当这些构建成功时,CI 服务器会代表你将应用程序部署到你选择的发布环境中。
⁹ 在
docs.travis-ci.com/user/deployment/heroku/上阅读关于 Travis 持续部署到 Heroku 的文章。
在我的经验中,持续部署是一把双刃剑。当它们起作用时,你将进入一个充满喜悦和减少繁琐部署的世界,其中通过构建和测试集成周期是推向生产环境的充分验证。但你必须自信地认为你已经有了足够的测试来合理地捕获错误。一个安全的赌注可能是先启用对预发布环境的持续部署,而不是直接对生产环境进行部署。然后,你将确保预发布环境中没有问题,并执行生产环境的部署。这个工作流程看起来像图 4.6。
图 4.6. 建议的持续部署流程

启用对 Heroku 的持续部署需要做些工作。你需要从 Heroku 获取一个 API 密钥,然后对其进行加密,并将加密数据配置到.travis.yml文件中。现在,我已经表达了对直接部署到生产环境的担忧,所以这个步骤就留给你自己来完成。如果你选择这样做,请访问bevacqua.io/bf/travis-heroku获取说明。
我们在本章的大部分内容中都在讨论部署,这是好事。现在你终于可以关注你的应用程序在生产环境中的整体状态以及特定请求的监控选项了。你还将检查日志记录、调试和灾难追踪的方法。
4.5. 监控和诊断
生产应用程序的监控与拥有忠诚的客户一样重要。如果你不重视应用程序的可用性,你的客户也不会欣赏你。这意味着你不能不监控你的生产服务器。通过监控,我指的是保留访问日志(谁访问了什么,何时以及从哪里访问),以及错误日志(出了什么问题),也许更重要的是,设置警报,以便在事情“意料之外”出错时立即通知你。“意料之外”并不是打字错误;你应该预料到事情会出错,并尽可能为这些情况做好准备。你的企业可能不需要像 Netflix 所倡导的那样有一支猴子军队四处游荡,随机终止实例和服务,以确保他们的服务器能够在没有影响最终用户消费其服务的情况下可靠和一致地承受故障,如硬件故障。但他们的建议,如下引用,仍然适用于大多数软件开发工作。
^((10) 了解 Netflix 的混沌猴子服务,一个混沌服务,请访问
bevacqua.io/bf/netflix).
引自 Netflix 博客
如果我们不经常测试在失败中取得成功的能力,那么在关键时刻——在意外停机时,它可能就不会起作用。
然而,你如何计划失败呢?嗯,这就是令人难过的地方;你做的任何事情都无法阻止失败。每个人都有停机时间,即使是像微软、谷歌、Facebook 和 Twitter 这样的巨头也不例外。你可以计划得再多,但你的应用程序最终还是会失败。你能做的是开发一个模块化架构,能够处理服务崩溃和实例故障。如果你能实现这种模块化,那么当单个模块停止工作时,它的影响就不会那么大了,因为其余部分仍然可以完美地工作。我们将在第五章中开发模块化和单一责任原则(SRP)的概念,该章专门介绍模块化设计和 Node.js 平台的快速入门。
拳击俱乐部的第一条规则是,你不说关于拳击俱乐部的事情。抱歉,电影不对。应用程序监控的第一条规则是,当发生坏事时,你记录事情并设置通知。让我们来探讨一个可能的实现方案。
4.5.1. 日志记录和通知
我相信你已经非常习惯在前端使用 console.log 来检查变量,甚至可能将其作为调试机制,用它来确定哪些代码路径正在被跟踪,并帮助你定位错误。在服务器端,你有标准输出和标准错误流,两者都记录到你的终端窗口。这些传输(stdout 和 stderr;稍后会更详细地介绍传输!)对于开发很有用,但如果你无法捕获在托管环境中传输给它们的任何内容,那么它们对你几乎毫无用处,因为你无法在自己的终端中监控这些过程。
Heroku 有一种机制可以捕获你的进程的标准输出,因此你可以稍后访问它。它还提供了扩展该行为的附加组件。Heroku 附加组件提供了许多必需的配套服务,例如数据库、电子邮件、缓存、监控和其他资源。大多数日志附加组件都会允许你设置过滤和通知;然而,我建议不要利用 Heroku 的日志记录功能,因为这会过于平台特定,并且可能会严重限制你迁移到另一个 PaaS 提供商的能力。自己处理日志并不那么困难,你很快就会看到这样做的好处。
Winston 用于日志记录
我并不是特别喜欢利用 Heroku 的日志记录功能,因为它通过假设将内容写入标准输出就足以满足你的日志跟踪需求,从而将你的代码库绑定到他们的基础设施上。一个更持久和灵活的方法是使用多传输记录器,而不是写入 stdout。传输决定了你试图记录的信息会发生什么。一个传输可能会记录到一个文件,写入数据库记录,发送电子邮件,或者将推送通知发送到你的手机。在多传输记录器中,你可以同时使用这些功能中的许多,但你仍然会使用相同的 API 来执行记录。添加或删除传输不会影响你编写日志语句的方式。
Node 有几个流行的日志库,我选择了 winston,因为它具有你在记录器中寻找的所有功能:日志级别、上下文、多个传输、简单的 API 和社区支持。此外,它易于扩展,人们已经为几乎所有你可能需要的功能编写了传输。
默认情况下,winston 使用 Console 传输,这与直接使用 stdout 相同。但你也可以设置它来使用其他传输,例如将日志记录到数据库或日志管理服务。后者在灵活性方面尤为突出,它们提供了一个平台,你可以选择在重要事件发生时接收通知,而无需更改你的应用程序中的任何内容。
使用像 winston 这样的日志记录解决方案是平台无关的。你的代码不会依赖于托管平台来捕获标准输出才能工作。要开始使用 winston,你必须通过同名包来安装它:
npm install --save winston
使用 --save 与使用 --save-dev
在这种情况下,你应该使用--save标志而不是--save-dev,因为winston不是一个像你之前玩过的 Grunt 包那样的仅用于构建的包。当向npm提供--save标志时,该包将被添加到你的package.json文件中的dependencies部分。
一旦你安装了winston,你就可以立即使用它,只需将logger放在你之前放置console的地方:
var logger = require('winston');
logger.info('east coast clear as day');
logger.error('west coast not looking so hot.');
你可能已经习惯了console是一个全局变量的想法。根据我的经验,在这种场景下使用全局变量并没有错,这也是我允许自己使用全局变量的两种情况之一(另一个是nconf,如我在第三章中提到的)。我喜欢在一个单独的文件中设置所有全局变量(即使只有两个),这样我可以在调用一个在模块或 Node 中未定义的东西时快速扫描它,并找出发生了什么。一个说明性的globals.js可能如下所示:
var nconf = require('nconf');
global.conf = nconf.get.bind(nconf);
global.logger = require('./logger.js');
我还建议保留一个单独的文件,你可以在这里定义你的日志记录器的传输方式。让我们从使用File传输以及默认的Console传输开始。这将是在前面的代码片段中引用的logger.js文件:
var logger = require('winston');
var api = module.exports = {};
var levels = ['debug', 'info', 'warn', 'error'];
levels.forEach(function(level){
api[level] = logger[level].bind(logger);
});
logger.add(logger.transports.File, { filename: 'persistent.log' });
现在,每当你执行logger.debug时,你都会将调试消息记录到终端和文件中。虽然很方便,但其他传输方式提供了更多的灵活性和可靠性,这正是我们将要介绍的几个传输方式的特点:winston-mail将允许你在发生某些事情时发送电子邮件(在需要发送电子邮件的水平上),winston-pushover可以直接在手机上发送通知,而winston-mongodb是许多传统日志传输方式之一,你可以在数据库中写入记录。
一旦你仔细检查了示例列表,你将更好地了解如何根据我提出的建议将配置、日志记录和全局变量结合起来。如果你对全局变量有强烈的反对意见,请不要慌张。我也包括了一个不使用全局变量的示例。我喜欢全局变量(在之前提到的两种情况下),只是因为我发现不需要在每一个模块中重复require同样的事情。
现在你已经花时间处理日志记录了,我们不妨谈谈调试 Node 应用程序。
4.5.2. 调试 Node 应用程序
当你需要追踪一个错误时,你会想要得到所有你能得到的帮助,根据我的经验,调试的最佳方法是增加日志记录,这也是我们之前讨论它的原因之一。话虽如此,你有很多种方法可以调试 Node 应用。你可以在 Chrome 的 DevTools 中使用 node-inspector^([11)),你可以使用 WebStorm 等集成 IDE 提供的功能,然后还有古老的console.log。你还可以直接在 V8(Node 运行的 JavaScript 引擎)中使用本地的调试器^([12))。
^(11)在 GitHub 上找到 node-inspector 的开源存储库
bevacqua.io/bf/node-inspector。^(12)在
bevacqua.io/bf/node-debugger上阅读 Node.js API 调试文档。
根据你追踪的哪种类型的错误,你将选择适合的工具。例如,如果你正在追踪内存泄漏,你可能使用 memwatch 这样的包,它在可能发生内存泄漏时发出事件。对于更常见的用例,例如定位舍入错误,或找出你的 API 调用出了什么问题,可以通过添加日志语句(暂时使用 console.log,或以更永久的方式使用 logger.debug),或使用 node-inspector 包来满足。
使用 Node Inspector
node-inspector 包连接到 V8 的原生调试器,但它允许你使用 Chrome 中的完整功能调试工具进行调试,作为 Node 提供的基于终端的调试器的替代方案。要使用它,你需要做的第一件事是全局安装它:
npm install -g node-inspector
要在 Node 进程上启用调试,你可以在启动进程时向 node 传递 --debug 标志,如下所示:
node --debug app.js
作为替代方案,你可以在运行中的进程上启用它。为此,你需要找到进程 ID(PID)。以下命令 pgrep 负责处理:
pgrep node
输出将是运行中的 Node 进程的 PID。例如,它可能如下所示:
89297
向进程发送 USR1 信号将启用调试。这可以通过 kill -s 命令完成(注意我使用的是上一个命令的结果中的进程 ID):
kill -s USR1 89297
如果一切正常,Node 将通过其标准输出通知你调试器正在监听的位置:
Hit SIGUSR1 - starting debugger agent.
debugger listening on port 5858
现在,你需要执行 node-inspector 并然后打开 Chrome,将其指向检查器提供的链接:
node-inspector
如果一切顺利,你应该会看到类似于图 4.7 的内容,并在 Chrome 浏览器中准备好一个完整的调试器,该调试器(大部分情况下)的行为与客户端 JavaScript 应用程序的调试器完全一样。这个调试器将允许你监视表达式、设置断点、逐行执行代码,并检查调用栈,以及其他有用功能。
图 4.7. 使用 Node Inspector 在 Chrome 中调试 Node.js 代码

在调试之上,还有性能分析,这有助于检测代码中的潜在问题,例如内存泄漏导致内存消耗激增,可能会削弱你的服务器。
4.5.3. 添加性能分析
在性能分析方面,你有几种选择,具体取决于你的需求是具体(我们必须追踪内存泄漏!)还是通用(我们如何检测内存消耗的激增?)。让我们看看第三方服务,它可以减轻你自行进行性能分析的压力。
Nodetime 是一种服务,你可以用几秒钟的时间轻松设置,它可以跟踪诸如服务器负载、空闲内存、CPU 使用率等分析数据。您可以通过电子邮件在 bevacqua.io/bf/nodetime-register 注册,一旦注册成功,您将获得一个可以用来设置 nodetime 的 API 密钥,nodetime 需要几行 JavaScript 代码来配置:
require('nodetime').profile({
accountKey: 'your_account_key',
appName: 'your_application_name'
});
就这样,你现在将能够访问指标,以及能够对 CPU 负载进行快照,就像图 4.8 中展示的那样。
图 4.8. 通过 Nodetime 跟踪的服务器负载随时间变化

为了总结,我们将分析 Node 应用程序可用的一种有用的进程扩展技术,称为 cluster。
4.5.4. 运行时间和进程管理
当涉及到发布环境,尤其是生产环境时,你不能容忍你的进程因为任何特定的异常而崩溃和死亡。这可以通过使用名为 cluster 的原生 Node API 来缓解,该 API 允许你在多个进程中执行你的应用程序,将负载分配给它们,并在需要时创建新的进程。cluster 利用多核处理器和 Node 是单线程的事实,允许你轻松地启动一个运行相同 Web 应用程序的过程数组。这使你的应用程序具有更高的容错性;你可以启动一个新的进程!例如,在几行代码中,你可以配置 cluster 以在另一个进程死亡时启动一个工作进程,从而有效地替换它:
var cluster = require('cluster');
// triggered whenever a worker dies
cluster.on('exit', function () {
console.log('workers are expendable, bring me another vassal!');
cluster.fork(); // spawn a new worker
});
这并不意味着你应该对进程内部发生的事情掉以轻心,因为启动新的进程可能会很昂贵。分叉是有成本的,与服务器承受的负载量(请求/时间)相关,也与你进程的 启动时间(从启动到可以处理 HTTP 请求的等待时间)相关。cluster 给我们提供了一种透明地继续提供服务的方式,即使你的工作进程死亡:其他人会以他的名义加入。
在 第三章 中,我们介绍了 nodemon 作为在积极开发期间文件更改时重新加载应用程序的一种方式。这次,你将回顾 pm2,它在精神上与 nodemon 相似,但针对的是发布环境。
安排集群
配置 cluster 可能很棘手,而且目前它也是一个实验性 API,因此它可能会在未来发生变化。但 cluster 模块带来的好处是无可否认的,并且确实很有吸引力。pm2 模块允许你在应用程序中使用完全配置的 cluster 功能,而不需要编写任何代码,这使得它成为使用时的不二选择。pm2 是一个命令行工具,你需要使用 -g 标志来安装它:
npm install -g pm2
安装完成后,你现在可以通过它运行你的应用程序,pm2 将为你设置 cluster。将以下命令视为 node app 的直接替代品:
pm2 start app.js -i 2
主要区别在于你的应用程序将使用cluster模块与两个工作进程(由于-i 2选项)。工作进程将处理对应用程序的请求,如果一个工作进程崩溃,另一个将启动以确保应用程序正常运行。pm2的另一个有用功能是能够进行热代码重载,这将允许你在没有任何停机时间的情况下用新部署的应用程序替换正在运行的应用程序。你可以在附带的源代码中找到相关示例,列在 ch04/11_cluster-by-pm2 下,以及一个直接使用cluster的示例,列在 ch04/10_a-node-cluster 下。
虽然在单台计算机上进行集群具有立即的效益且成本低廉,但你也应该考虑在多台服务器上进行集群,以减轻服务器崩溃时网站可能宕机的情况。
4.6. 摘要
呼,这真是一场激烈的战斗!我们在本章中努力工作:
-
你与发布流程优化,如图像压缩和静态资源缓存,成为了更亲密的朋友。
-
你了解到在结束一天的工作前测试发布版本、增加包版本和整理变更日志的重要性。
-
然后你按照步骤部署到 Heroku,我提到了
grunt-ec2,这是许多替代部署方法之一。 -
掌握持续集成的知识是一件好事,因为你已经学会了验证你的构建过程和发布代码库质量的重要性。
-
持续部署是你能够执行的操作,但你理解这样做的影响,所以你会对此非常小心。
-
你还快速浏览了日志记录、调试、管理和监控发布环境的内容,这在解决生产应用问题时将证明是基础性的。
所有关于监控和调试的讨论都需要对架构设计、代码质量、可维护性和可测试性进行更深入的分析,这些恰好是书中第二部分的核心。第五章 Chapter 5 全部关于模块化和依赖管理,JavaScript 模块的不同方法,以及即将到来的 ES6(一个长期期待的 ECMAScript 标准更新)的部分内容。在第六章 chapter 6 中,你将发现不同的方法来正确组织 Node 应用程序的异步代码,同时在异常处理方面确保安全。第七章 Chapter 7 将帮助你有效地建模、编写和重构代码。我们还将一起分析一些小的代码示例。第八章专门介绍测试原则、自动化、技术和示例。第九章教你如何设计 REST API 接口,并解释它们如何在客户端被消费。
你将带着如何使用 JavaScript 代码设计一个连贯的应用架构的深入理解离开第二部分。将这部分内容与你已学到的第一部分中关于构建过程和工作流程的所有知识相结合,你将准备好使用“先构建”的方法来设计一个 JavaScript 应用,这是本书的最终目标。
第二部分. 管理复杂性
书的第二部分比第一部分更具互动性,因为它包含了更多的实际代码示例。你将有机会探索我们在应用程序设计中攻击复杂性的不同小角度,例如模块化、异步编程模式、测试套件、保持代码简洁以及 API 设计原则。
第五章 对 JavaScript 中的模块化进行了详细探讨。我们从基础知识开始,学习封装、闭包和语言的一些怪癖。然后我们深入研究不同的格式,这些格式允许我们构建模块化代码,例如 CommonJS、AMD 和 ES6 模块。我们还将讨论不同的包管理器,比较它们各自的特点。
第六章 教你思考异步代码。我们将通过大量的实际代码示例,遵循几种不同的风格和约定。你将了解所有关于 Promises、async 控制流库、ES6 生成器和基于事件的编程。
第七章 通过教授你 MVC(模型-视图-控制器)来扩展你的 JavaScript 视野。你将重新审视 jQuery,并学习如何编写更模块化的代码。稍后,你将利用 Backbone.js MVC 框架进一步组件化你的前端工作。Backbone.js 甚至可以用于在服务器端渲染视图,我们将利用 Node.js 平台来实现这一点。
在 第八章 中,你将学习如何立即使用 Grunt 任务自动化测试。然后你将学习如何为浏览器编写测试,以及如何使用 Chrome 或 PhantomJS 无头浏览器运行它们。你不仅将学习单元测试,还将学习视觉测试甚至性能测试。
第九章 专注于 REST API 设计原则。在本章中,你将了解在为 API 服务奠定基础时应遵循的最佳实践,以及如何设计分层架构以补充 API。最后,你将学习如何轻松消费 API,使用遵循 RESTful 设计方法的约定。
第五章. 接受模块化和依赖管理
本章涵盖
-
使用代码封装
-
理解 JavaScript 中的模块化
-
集成依赖注入
-
使用包管理
-
尝试使用 ECMAScript 6
现在我们已经完成了“构建第一”速成课程,你会注意到 Grunt 任务有所减少,尽管你肯定会继续提高你的构建能力。相比之下,你会看到更多关于如何处理你应用程序底层的 JavaScript 代码的不同方法的权衡的例子。本章重点介绍模块化设计,通过将关注点分离到不同模块中,降低应用程序的代码复杂性,这些模块是相互连接的小块代码,每块代码都擅长做一件事,并且易于测试。你将管理异步代码流、客户端 JavaScript 模式和第六章、第七章和第八章中的各种测试中的复杂性。
第二部分的核心是通过分离关注点来提高应用程序设计的质量。为了提高你分离关注点的技能,我将教你所有关于模块化、共享渲染和异步 JavaScript 开发的知识。为了提高你应用程序的弹性,你也应该测试你的 JavaScript,这是第八章的重点。虽然这是一本以 JavaScript 为重点的书,但了解 REST API 设计原则对于改善应用程序堆栈各部分之间的通信至关重要,这正是第九章的重点。
图 5.1 展示了本书后半部分这些片段是如何相互关联的。
图 5.1. 模块化、良好的架构和测试是设计可维护应用程序的基础。

应用程序通常依赖于外部库(如 jQuery、Underscore 或 AngularJS),这些库应该通过包管理器来处理和更新,而不是手动下载。同样,你的应用程序也可以分解成更小的部分,它们相互交互,这也是本章的另一个重点。
你将学习代码封装的艺术,将你的代码视为自包含的组件;设计出色的接口并精确排列它们;以及信息隐藏,只揭示消费者需要的,而隐藏其他一切。我将用很多话来解释一些难以捉摸的概念,例如作用域,它决定了变量属于哪里;你必须理解的this关键字;以及闭包,它帮助你隐藏信息。
然后,我们将探讨依赖关系解析作为手动维护脚本标签排序列表的替代方案。之后,我们将转向包管理,这是你安装和升级第三方库和框架的方式。最后,我们将查看即将推出的 ECMAScript 6 规范,它为构建模块化应用程序提供了一些新技巧。
5.1. 使用代码封装
封装意味着保持功能自包含,并隐藏特定代码片段(访问者)的实现细节。每个部分,无论是函数还是整个模块,都应该有一个明确定义的责任,隐藏实现细节,并暴露简洁的 API 以满足其消费者的需求。自包含的功能比具有许多责任的代码更容易理解和修改。
5.1.1. 理解单一职责原则
在 Node.js 社区中,受到 UNIX 哲学中保持程序简洁和自包含的启发,包因其特定的用途而闻名。这些功能协调且不过度扩展的包的高可用性在使npm包管理器变得出色方面发挥了重要作用。在很大程度上,包作者通过遵循单一职责原则(SRP)来实现这一点:构建只做一件事且做得很好的包。SRP 不仅适用于整个包;你应在模块和方法级别上遵循 SRP。SRP 通过保持代码简单和简洁,有助于使代码保持可读性和可维护性。
考虑以下用例。你需要构建一个组件,该组件接受一个字符串并返回一个带连字符的表示形式。在生成博客平台等 Web 应用程序中的语义链接时将非常有用。该组件可能接受像'Some Piece Of Text'这样的博客文章标题,并将它们转换为'some-piece-of-text'。这被称为slugging。
假设你从以下列表开始(在示例中的 ch05/01_single-responsibility-principle 中可用)。它使用两步过程,首先将所有非字母数字字符序列规范化为单个连字符,然后删除前后连字符。然后转换字符串为小写。这正是你所需要的,但没有其他东西。
列表 5.1. 使用 slugging 转换文本
function getSlug (text) {
var separator = /[^a-z0-9]+/ig;
var drop = /^-|-$/g;
return text
.replace(separator, '-')
.replace(drop, '')
.toLowerCase();
}
var slug = getSlug('Some Piece Of Text');
// <- 'some-piece-of-text'
第一个表达式/[^a-z0-9]+/ig用于查找一个或多个非字母数字字符的序列,例如空格、连字符或感叹号。这些表达式被替换为连字符。第二个表达式查找字符串两端的连字符。结合这两个表达式,你可以构建一个适合 URL 的博客文章标题版本。
理解正则表达式
虽然你不需要了解正则表达式就能理解这个例子,但我鼓励你学习基础知识。正则表达式用于在字符串中查找模式,也可以用来将这些出现替换为其他内容。这些表达式在几乎所有主要语言中都得到支持。
表达式如/[^a-z0-9]+/ig看起来可能很复杂,但它们并不难编写!如果你对这个主题感兴趣,我的博客上有一篇入门文章你可以阅读.^([a])
^a 你可以在我的博客上找到这篇文章
bevacqua.io/bf/regex。
在前面的例子中,separator变量是一个简单的正则表达式,它将匹配非字母、非数字字符的序列。例如,在'Cats, Dogs and Zebras!'字符串中,它将匹配第一个逗号和空格作为一个单独的匹配项,'and'周围的两个空格,以及末尾的'!'。第二个正则表达式匹配字符串两端的破折号,这样生成的缩略名以单词开头和结尾,特别是因为你已经在之前的一步中将任何非字母数字字符转换为破折号。结合这两个步骤就足以为你组件生成一个不错的缩略函数。
假设你有一个需要添加发布日期时间戳到缩略名的功能请求。在缩略方法中添加一个可选参数以启用此功能可能很有吸引力,但这将是错误的:你的 API 将更难以使用,更难以重构(在不破坏其他组件的情况下更改其代码,在第八章中详细讨论,当时我们讨论测试),并且更难以编写文档。更合理的方法是按照 SRP 原则使用组合模式来构建你的组件。组合仅仅意味着按顺序应用函数,而不是将它们的功能混合在一起。所以首先你会应用缩略,然后你可以添加时间戳到缩略名,如下面的代码片段所示:
function stamp (date) {
return date.valueOf();
}
var article = {
title: 'Some Piece Of Text',
date: new Date()
};
var slug = getSlug(article.title);
var time = stamp(article.date);
var url = '/' + time + '/' + slug;
// <- '/1385757733922/some-piece-of-text'
现在,想象一下,你的搜索引擎优化(SEO)专家出现了,他希望你在 URL 缩略名中排除无关的词,以便在搜索结果中获得更好的展示。你可能会被诱惑在getSlug函数中直接这样做,但这里有几个原因说明为什么在这种情况下这也是错误的:
-
单独测试缩略功能将变得更加困难,因为你会有一些与缩略无关的逻辑。
-
随着时间的推移,排除码可能会变得更加高级,但它仍然会被包含在
getSlug.中。
如果你小心谨慎,你会编写一个针对专家需求的函数,其代码片段如下:
function filter (text) {
return text.replace(keywords, '');
}
var keywords = /\bsome|the|by|for|of\b/ig; // match stopwords
var filtered = filter(article.title);
var slug = getSlug(filtered);
var time = stamp(article.date);
var url = '/' + time + '/' + slug;
// <- '/1385757733922/piece-text'
这看起来相当干净!通过为每个方法赋予清晰的责任,你在不过度复杂化问题的同时扩展了你的功能。此外,你还发现了可重用的可能性。你可能会在整个应用程序中使用 SEO 专家的过滤功能,并且从你的缩略模块中提取它将很容易,因为它不依赖于它。同样,测试这三个方法中的每一个也将很容易。目前,可以说保持代码简洁、明确,并做到函数名所暗示的正是可维护、可测试代码的基本方面之一。在第八章中,你将了解更多关于单元测试的内容。
以模块化方式分割功能很重要,但这还不够。如果你正在构建一个典型的组件,它有几个方法但不应公开其变量,你需要将此信息从公共接口中隐藏。我将在下一节讨论信息隐藏的重要性。
5.1.2. 信息隐藏和接口
当你在构建应用程序时,代码的体积和复杂性不可避免地会增加。这最终可能会使你的代码库变得难以接近,但你可以通过编写更直接的代码并使其更容易遵循代码流来帮助解决这个问题。降低复杂性增长的一种方法是通过隐藏不必要的信息,使其在接口上不可访问。这样,只有重要的信息被暴露出来;其余的被认为是与消费者无关的,这通常被称为实现细节。你不希望暴露诸如你在计算结果时使用的状态变量或随机数生成器的种子之类的元素。这必须在每个级别上完成;每个模块中的每个函数都应该尝试隐藏与其消费者无关的所有内容。通过这样做,你将帮助其他开发者和未来的你,减少在弄清楚特定方法或模块如何工作时的猜测工作。
例如,考虑以下示例代码,说明如何构建一个对象来计算简单的平均值。该示例代码(在 samples 中的 ch05/02_information-hiding 目录下找到)使用构造函数并扩展原型,使得Average对象具有add方法和calc方法。
列表 5.2. 计算平均值
function Average () {
this.sum = 0;
this.count = 0;
}
Average.prototype.add = function (value) {
this.sum += value;
this.count++;
};
Average.prototype.calc = function () {
return this.sum / this.count;
};
剩下的工作就是创建一个Average对象,向其中添加值,并计算平均值。这种方法的问题可能是你不想让人们直接访问你的私有数据,例如Average.count。你可能更愿意使用我们即将介绍的技术来隐藏这些值,供 API 消费者使用。一个更简单的方法可能是完全放弃对象,改用函数。你可以使用.reduce方法(在 ES5 中新增,位于 Array 原型上)对值数组应用累加函数来计算平均值:
function average (values) {
var sum = values.reduce(function (accumulator, value) {
return accumulator + value;
}, 0);
return sum / values.length;
}
这个函数的优点是它确实做了你想要的事情。它接受一个值数组,并返回平均值,正如其名称所示。此外,它没有像你的典型实现那样保留任何状态变量,有效地隐藏了其内部工作原理的任何信息。这被称为 纯函数:结果只能依赖于传递给它的参数,而不能依赖于状态变量、服务或不是参数体一部分的对象。纯函数还有另一个特性:它们不会产生除了提供的结果之外的任何副作用。这两个特性的结合使纯函数成为良好的接口;它们是自包含的,易于测试。因为它们没有副作用或外部依赖,只要输入和输出之间的关系不改变,你就可以重构它们的内部内容。
函数式工厂
另一种实现可能使用一个 函数式工厂。这是一个函数,当执行时,返回一个执行你想要的功能的函数。正如你将在下一节更好地理解的那样,你在工厂函数中声明的任何内容都是工厂私有的,以及存在于其中的函数。这在你阅读以下代码后更容易理解:
function averageFactory () {
var sum = 0;
var count = 0;
return function (value) {
sum += value;
count++;
return sum / count;
};
}
sum 和 count 变量仅适用于由 averageFactory 返回的函数的实例;此外,每个实例只能访问其自身的上下文,即在该实例内部声明的变量,但不能访问其他实例的上下文。想象一下一个饼干模具。averageFactory 就是饼干模具,它切割饼干(你的函数),这些饼干接受一个值并返回累积平均值(到目前为止)。作为一个例子,下面是如何使用它的样子:
var avg = averageFactory();
// <- function
avg(1);
// <- 1
avg(3);
// <- 2
就像使用你的饼干模具切割新的饼干不会影响现有的饼干一样,创建更多实例也不会对现有的实例产生影响。这种编码风格与你之前使用原型所做的类似,不同之处在于 sum 和 count 只能在实现中访问,消费者无法访问这些变量,实际上使它们成为 API 的实现细节。实现细节不仅会引入噪音;它们也可能潜在地带来安全风险:你不会希望授予外部世界修改组件内部状态的能力。
理解 变量作用域,它定义了变量的可访问性,以及提供函数调用者上下文的 this 关键字,对于构建能够正确隐藏信息的稳固结构至关重要。正确的作用域变量使你能够隐藏接口消费者不应该知道的信息。
5.1.3. 作用域和 this 关键字
在他无可争议的经典著作《JavaScript:优秀的部分》(O’Reilly Media,2008)中,Douglas Crockford 解释了语言中的许多怪癖,并鼓励我们避免“不好的部分”,例如with块、eval语句和类型强制相等运算符(==和!=)。如果你从未读过他的书,我建议你尽早阅读。Crockford 表示new和this难以理解,并建议完全避免它们。我说你需要理解它们。我将描述this代表什么,然后解释如何操作和分配它。在任何给定的 JavaScript 代码片段中,上下文由当前函数作用域和this组成。
¹ 你可以通过以下链接在亚马逊找到《JavaScript:优秀的部分》:
bevacqua.io/bf/goodparts。
如果你习惯了服务器端语言,如 Java 或 C#,那么你已经习惯了思考作用域:变量存放的袋子,它随着每个花括号的打开和关闭而开始和结束。在 JavaScript 中,作用域在函数级别发生(称为词法作用域),而不是在块级别。
图 5.2 通过比较具有块作用域的 C#(其他示例包括 Java、Perl、C 和 C++)与具有词法作用域的 JavaScript(R 是另一个示例)来区分词法作用域和块作用域。
图 5.2. 不同语言的作用域差异

在图中,两个示例都使用了message变量。在第一个示例中,message仅在if语句块内部可用,而在第二个示例中,由于词法作用域,message对整个函数都可用。正如你将要学习的,这既有优点也有缺点。
JavaScript 中的变量作用域
理解作用域的工作原理将使你能够理解模块模式,我们将在 5.2 节中讨论它,作为组件化代码库的一种方式。在 JavaScript 中,function是一个一等公民,它被当作任何其他对象来对待。嵌套函数各自带有自己的作用域,内部函数可以访问直到全局空间的上层作用域。考虑以下代码中的getCounter函数:
function getCounter () {
var counter = 0;
return function () {
return counter++;
};
}
在这个示例中,counter变量与getCounter函数相关联。返回的函数可以访问counter,因为它属于父作用域的一部分。但getCounter之外没有任何东西可以创建对counter的引用;对其的访问已被关闭,只有getCounter的特权子代可以操作它。如果你在任一作用域级别引入console.log(this)语句,你会在两种情况下看到全局Window对象实例被引用。这是真正的“不好的部分”;默认情况下,this关键字将引用全局对象,如下面的列表所示。
列表 5.3. 理解this关键字
function scoping () {
console.log(this);
return function () {
console.log(this);
};
}
scoping()();
// <- Window
// <- Window
我们有不同方式可以操作 this 关键字。将 this 上下文赋值的最常见方式是在对象上调用方法。例如,当执行 'Hello'.toLowerCase() 时,'Hello' 将用作函数调用的 this 上下文。
获取调用位置
当函数直接作为对象的属性调用时,该对象将成为 this 引用。如果该方法位于对象的原型中——例如 Object.prototype.toString——this 也将是方法被调用的对象。请注意,这是一个脆弱的行为;如果你直接引用一个方法并调用它,那么 this 就不再是 parent,而是再次成为全局对象。为了说明这一点,让我再给你展示另一个列表。
列表 5.4. this 关键字的范围

在严格模式下,this 将默认为 undefined,而不是 Window。在非严格模式下,this 总是一个对象;如果它通过对象引用调用,则是提供对象;如果它通过原始布尔值、字符串或数值值调用,则是一个封装表示;如果它通过 undefined 或 null 调用,无论是通过直接引用方法还是使用 .apply、.call 或 .bind 中的任何一个,则是指向全局对象(在严格模式下再次是 undefined)。在严格模式下传递给函数的 this 值不会被封装到对象中。我们很快就会了解到严格模式还能做什么。
除了调用函数时默认发生的情况外,你可以使用不同的方法为 this 赋值;这并不完全在你的控制之下。实际上,你可以使用 .bind 创建一个始终具有提供给它的 this 值的函数。执行方法的其他方式包括 .apply、.call 和 new 操作符。以下是一个速查表,以便你可以看到这些方法的作用:
Array.prototype.slice.call([9, 5, 7], 1, 2)
// <- [5]
String.prototype.split.apply('13.12.02', ['.'])// <- ['13', '12', '02']
var data = [1, 2];
var add = Array.prototype.push.bind(data, 3);
add(); // effectively the same as data.push(3)
add(4); // effectively the same as data.push(3, 4)
console.log(data);
// <- [1, 2, 3, 3, 4]
在 JavaScript 中,变量按照以下顺序填充作用域:
-
作用域上下文变量:this 和 arguments
-
命名函数参数:function (these, variable, names)
-
函数表达式:function something () {}
-
局部作用域变量:var foo
如果你没有在旁边使用 JavaScript 解释器进行实验,或者没有跟随代码示例(ch05/03_context-scoping),请确保查看代码示例;我已经将这些示例包含在本书提供的源代码中,如果你理解上有困难,它们包含了一些内联注释。现在让我们讨论严格模式包含的内容。
5.1.4. 严格模式
当启用时,严格模式会以修改代码工作语义的方式修改语义,减少对缺少 var 语句和类似容易出错的做法的宽容度,这在某种程度上与使用代码检查器是互补的.^([2]) 严格模式可以在单个函数或整个脚本上启用。
² 在 Mozilla 开发者网络中获取严格模式的详细解释,请访问
bevacqua.io/bf/strict。
对于客户端代码,首选函数形式。要启用严格模式,请在文件或函数顶部放置'use strict';语句(双引号也行):
function () {
'use strict';
// here lies strict mode
}
除了this默认为undefined而不是全局对象之外,严格模式对错误的容忍度更低,将它们转换为错误而不是纠正它们。限制还包括禁止with语句、八进制表示法,以及防止将关键字如eval和arguments赋值。
'use strict';
foo = 'bar' // ReferenceError foo is not defined
在严格模式下,如果尝试写入只读属性、删除不可删除的属性、使用重复属性键创建对象或声明具有重复参数名称的函数,引擎也会抛出异常。这种不容忍的态度有助于捕捉到由于编码马虎而产生的问题。
在我们讨论作用域的话题时,我想讨论的最后一个小问题是通常被称为提升的内容。理解提升对于编写合理的复杂 JavaScript 应用程序非常重要。
5.1.5. 变量提升
许多 JavaScript 面试题可以通过理解作用域、this的工作原理以及提升来回答。我们已经涵盖了前两点,但提升究竟是什么?在 JavaScript 中,提升意味着变量声明被拉到作用域的起始位置。这解释了你在某些情况下可以观察到的意外行为。
函数表达式被完全提升:函数体也被提升,而不仅仅是它们的声明。如果我从《JavaScript 高级程序设计》这本书中只能学到一点,那就是关于提升的知识;它改变了我的编码方式,以及我对它的思考方式。
提升是调用函数表达式在声明之前按预期工作的原因。将函数赋值给变量不会奏效,因为变量在你想要调用函数的时候还没有被赋值。以下代码是一个例子;你将在附带的源代码中找到更多例子,列在 ch05/04_hoisting 中:
var value = 2;
test();
function test () {
console.log(typeof value);
console.log(value);
var value = 3;
}
你可能预期方法首先打印'number',然后是2,或者可能是3。试着运行它!为什么它先打印'undefined'然后是undefined?嗯,你好,提升!如果你按照提升后代码的最终顺序重新排列代码,会更容易理解。让我们看看以下列表。
列表 5.5. 使用提升
var value;
function test () {
var value;
console.log(typeof value);
console.log(value);
value = 3;
}
value = 2;
test();
test函数末尾的value声明被提升到了作用域的顶部,这也是为什么test没有抛出TypeError异常,警告undefined不是一个函数。记住,如果你使用了声明test函数的变量形式,实际上你会得到那个错误,因为虽然var test会被提升,但赋值不会,实际上变成了以下列表中的代码。
列表 5.6. 提升 var test
var value;
var test;
value = 2;
test();
test = function () {
var value;
console.log(typeof value);
console.log(value);
value = 3;
};
代码列表 5.6 中的代码不会按预期工作,因为当你想要调用 test 时,它还没有被定义。了解哪些内容会被提升以及哪些不会是非常重要的。如果你养成了一种编写代码就像它已经被提升的习惯,将变量声明和函数拉到它们作用域的顶部,你遇到的问题会比其他情况下少得多。到现在,你应该对作用域和 this 关键字感到很舒服了。现在是时候讨论 JavaScript 中的闭包和模块模式了。
5.2. JavaScript 模块
到目前为止,你已经了解了单一职责原则、信息隐藏以及如何在 JavaScript 中应用这些原则。你对变量的作用域和提升也有了一定的了解。现在让我们继续学习闭包。闭包将帮助你创建新的作用域并防止变量泄露信息。
5.2.1. 闭包和模块模式
函数也被称为闭包,尤其是在关注函数创建新作用域的事实时。立即调用的函数表达式(IIFE)是一个立即执行的函数。IIFE 的全称是 Immediately-Invoked Function Expression。使用 IIFE 有助于创建闭包。以下代码是一个 IIFE 的示例:
(function () {
// a new scope
})();
注意函数周围的括号。这告诉解释器你不仅声明了一个匿名函数,还将其用作一个值。这些表达式也可以用于赋值操作,这在需要变量可以通过导出的返回值访问时非常有用。这通常被称为模块模式,如下面的代码所示(在示例中标记为 ch05/05_closures):
var api = (function () {
var local = 0; // private and in-place!
var publicInterface = {
counter: function () {
return ++local;
}
};
return publicInterface;
})();
api.counter();
// <- 1
之前代码的一个常见变体不依赖于闭包之外的内容,而是导入它将要使用的变量。如果它想要公开一个公共 API,那么它就导入全局对象。我倾向于更喜欢这种方法,因为所有内容都被闭包很好地封装起来,你可以指示 JSHint 在未声明的变量问题上爆炸。没有闭包和 JSHint,这些可能会意外地成为全局变量。为了说明这一点,请看以下代码:
(function (window) {
var privateThing;
function privateMethod () {
}
window.api = {
// public interface
};
})(window);
让我们考虑 原型式模块化,它通过增强原型而不是使用闭包作为 IIFE 表达式的补充替代方案。使用原型可以提供性能提升,因为许多对象可以共享同一个原型,而在原型上添加函数可以为从它继承的所有对象提供功能。
5.2.2. 原型式模块化
根据您的使用场景,原型可能正是您所需要的。将原型视为 JavaScript 声明类的方式,尽管它是一个完全不同的模型,因为原型仅仅是链接,除非您完全替换它们(并且手动进行覆盖),否则您无法覆盖属性。简而言之,不要试图将原型视为类,因为这肯定会引起可维护性问题。当您预期您的模块有多个实例时,原型最有用。例如,所有 JavaScript 字符串都共享 String 原型。原型的一个良好用途是在与 DOM 节点交互时。有时我发现自己在闭包内声明原型模块,然后在闭包外部保持私有状态,在原型之外。以下列表展示了伪代码,但请参阅作为 ch05/06_prototypal-modularity 列出的配套代码示例,以获取一个完全工作的示例,并更好地理解该模式。
列表 5.7. 使用伪代码进行原型设计
var lastId = 0;
var data = {};
function Lib () {
this.id = ++lastId;
data[this.id] = {
thing: 'secret'
};
}
Lib.prototype.getPrivateThing = function () {
return data[this.id].thing;
};
这是保持数据安全的一种方法;存在许多场景,在这些场景中,数据私有化不是必需的,允许消费者操作您的实例数据可能是一件好事。您应该将这些内容全部封装在闭包中,以便您的私有数据不会泄露出来。我相信在处理 DOM 交互时,JavaScript 中的原型最有用,正如我们将在第七章中调查的那样。那是因为在处理 DOM 对象时,您通常必须同时处理许多元素;原型通过不在每个实例上复制其方法来提高性能,从而节省资源。
现在您已经更清楚地了解了作用域、提升和闭包是如何工作的,我们可以继续探讨模块应该如何相互交互。首先,让我们看看 CommonJS 模块:这是一种保持代码井然有序并一次性处理依赖注入(DI)的方法。
5.2.3. CommonJS 模块
CommonJS(CJS)是由 Node.js 等其他一些规范采用的规范,它允许您编写模块化的 JavaScript 文件。每个模块由一个单独的文件定义,如果您将值赋给 module.exports,它就成为了该模块的公共接口。要使用模块,您需要使用从消费者到依赖项的相对路径调用 require。
让我们看看一个快速示例,在示例中标记为 ch05/07_commonjs-modules:
// file at './lib/simple.js'
module.exports = 'this is a really simple module';
// file at './app.js'
var simple = require('./lib/simple.js');
console.log(simple);
// <- 'this is a really simple module'
这些模块最有用的优点之一是变量不会泄漏到全局对象:您不需要将代码封装在闭包中。在最高作用域声明的变量(如前一个片段中的 simple 变量)仅在该模块中可用。如果您想公开某些内容,您需要通过将其添加到 module.exports 中来明确表示这种意图。
到目前为止,你可能会认为我偏离了 CJS 的路径,因为 CJS 在浏览器中不再原生支持,就像 CoffeeScript 和 TypeScript 一样。你很快就会了解到如何使用 Browserify 编译这些模块,Browserify 是一个流行的库,旨在将 CJS 模块编译成浏览器可以处理的内容。CJS 相对于浏览器行为有以下优点:
-
没有全局变量,认知负荷更少
-
揭示 API 和消费模块的简单流程
-
通过模拟依赖项来测试模块更容易
-
通过 Browserify 访问 npm 上的包
-
模块化,这转化为可测试性
-
如果使用 Node.js,客户端和服务器之间共享代码更容易
你将在第 5.4 节中了解更多关于包管理解决方案(npm、Bower 和 Component)的信息。在我们到达那里之前,我们将探讨依赖项管理,即如何处理应用程序所需的组件,以及不同的库如何帮助管理它们。
5.3. 使用依赖项管理
在这里,我们将讨论两种依赖项管理类型:内部和外部。当谈到内部依赖项时,我指的是程序编写的一部分。最常见的情况是,这些依赖项与物理文件一对一映射,但你也可能在单个文件中有多个模块。通过模块,我指的是具有单一职责的代码片段,无论它们是服务、工厂、模型、控制器还是其他什么。相比之下,外部依赖项是指代码不由你的应用程序本身管理的那些。你可能拥有或编写了该包,但代码属于完全不同的存储库。
我将解释依赖图是什么,然后我们将研究处理它们的方法,例如使用 RequireJS 模块加载器的注意事项,CommonJS 提供的简单直接性,以及 AngularJS(由 Google 构建的模型-视图-控制器框架)在保持一切模块化和可测试性的同时解决依赖项的优雅方式。
5.3.1. 依赖图
当编写依赖于其他内容的模块时,最常见的方法是让您的模块创建一个依赖于的对象的实例。为了说明这一点,请耐心地跟随我通过一小段 Java 代码;这应该很容易理解。以下列表显示了一个UserService类,它的目的是从领域逻辑层处理任何数据请求。它可以消费任何IUserRepository实现,该实现负责从 MySQL 数据库或 Redis 存储等存储库检索数据。此列表在示例中标记为 ch05/08_dependency-graphs。
列表 5.8. 使用模块创建对象
public class UserService {
private IUserRepository _userRepository;
public UserService () {
_userRepository = new UserMySqlRepository();
}
public User getUserById (int id) {
return _userRepository.getById(id);
}
}
但这还不够;如果你的服务应该使用符合接口的任何存储库,为什么你还要那样硬编码 UserMySqlRepository 呢?硬编码的依赖使得测试模块变得更加困难,因为你不会仅仅针对接口进行测试,而是针对具体的实现进行测试。一个更好的方法,碰巧也更易于测试,可能是通过构造函数传递那个依赖,如下面的列表所示。这种模式通常被称为依赖注入,它是一个听起来很聪明的替代方案,用于给对象提供实例变量。
列表 5.9. 使用依赖注入
public class UserService {
private IUserRepository _userRepository;
public UserService (IUserRepository userRepository) {
if (userRepository == null) {
throw new IllegalArgumentException();
}
_userRepository = userRepository;
}
public User getUserById (int id) {
return _userRepository.getById(id);
}
}
这样,你可以按照预期的方式构建你的服务,作为一个符合 IUserRepository 接口的任何存储库的消费者,而不需要了解实现的具体细节。创建一个 UserService 可能听起来不是什么大事,但一旦考虑到它的依赖以及依赖的依赖,事情就会变得复杂。这被称为 依赖树。以下片段显然不太吸引人:
String connectionString = "SOME_CONNECTION_STRING";
SqlConnectionString connString = new SqlConnectionString(connectionString);
SqlDbConnection conn = new SqlDbConnection(connString);
IUserRepository repo = new UserMySqlRepository(conn);
UserService service = new UserService(repo);
代码展示了 控制反转 (IoC),^([3]) 这是一个对相当简单的事情的冗长定义。IoC 意味着不是让对象负责其依赖项的实例化或获取它们的引用,而是通过构造函数或通过公共属性将依赖项提供给对象。图 5.3 考察了使用 IoC 模式的益处。
³ 在
bevacqua.io/bf/ioc阅读 Martine Fowler 关于控制反转和依赖注入的入门指南。
图 5.3. 经典依赖与使用 IoC 提高可测试性的比较

图中的 IOC 代码(位于底部)比图顶部的经典依赖管理代码更容易测试、更松散耦合,并且更容易维护。
IoC 框架用于解决依赖解析和缓解依赖地狱问题。这些框架的基本原理是放弃使用 new 关键字,并依赖于 IoC 容器。IoC 容器 是一个注册表,它了解如何实例化你的服务、存储库以及任何其他模块。学习如何配置传统的 IoC 容器(例如 Java 中的 Spring 或 C# 中的 Castle Windsor)超出了本书的范围,但了解该问题的概览对于铺平道路是必要的。
IoC 对于可测试性重要吗?
最终,避免硬编码依赖的重要性在于在单元测试时可以轻松地模拟它们,正如你将在 第八章 中看到的。
单元测试是关于断言接口是否按预期工作,而不管它们的实现方式如何。模拟是实现接口的存根,但除了符合它们的最基本要求外,不做任何事情。例如,一个模拟的用户存储库可能会始终返回相同的硬编码 User 对象。这在单元测试的上下文中很有用,您可能想单独测试 UserService 类,但不需要了解其内部工作原理,更不用说其依赖项的实现方式了!
太好了!现在就足够了解 Java 了。这一切与 JavaScript 应用程序设计有什么关系呢?如果您希望编写可测试的代码,理解测试原则是必要的。尽管您可能不同意测试驱动开发运动,但不可否认的是,没有考虑到可测试性的代码编写起来要困难得多。当谈到客户端 JavaScript 时,您还有一个额外的复杂性层:网络。除非您的代码以您在第二章中学到的方式打包在一起,否则模块不会立即可用。
接下来,我将向您介绍 RequireJS,这是一个异步模块加载器,它比传统的无管理依赖库方法更优。
5.3.2. 介绍 RequireJS
RequireJS 是一个 JavaScript 异步模块加载器 (AMD),允许您定义模块,并使它们相互依赖。以下代码(在 samples 中的 ch05/09_requirejs-usage 可以找到)是 AMD 的一种示例用法,描述了一个依赖于其他模块的模块:
require(['lib/text'], function(text) {
var result = text('foo bar');
console.log(result);
// <- 'FOO BAR'
});
按照惯例,'lib/text’ 会查找在 JavaScript 目录根目录下的 ./lib/text.js 路径上的文件。该资源将被请求、解释,一旦所有依赖项都已加载,模块的函数将被调用,并将依赖项作为模块函数的参数传递,就像我在第 5.3.1 节中提到的 Java 代码一样。以下是如何定义 'lib/text' 模块的示例:
define([], function () {
return function (input) {
return input.toUpperCase();
};
});
接下来,让我们分析 RequireJS 相比其他替代方案的优势和不足之处。
RequireJS 的优缺点
在这种情况下,定义使用了一个空数组,因为它没有依赖。返回的函数是 'lib/text' 模块提供的公共接口。使用 RequireJS 有几个好处:
-
依赖图会自动解析。不再需要担心脚本标签的顺序了!
-
包含异步模块加载。
-
在开发过程中不需要编译步骤。
-
它是单元可测试的,因此您只需加载需要测试的模块。
-
由于您的模块是在一个函数中定义的,因此强制执行闭包。
这些都是真实且令人愉悦的,但存在缺点。如果你的代码依赖的包没有被 AMD 魔法包装,你除了添加一个编译步骤来将所有内容捆绑在一起外别无选择。除非你将模块捆绑在一起,否则 RequireJS 将创建一个 HTTP 请求级联来获取每个依赖项,这在生产系统中会非常慢。AMD 的许多好处都来自于没有编译步骤,所以你只剩下了一个装满以下缺点的美化版的依赖关系图解析器:
-
如果你使用打包器,异步加载功能将不可用。
-
它要求供应商遵守 AMD 模型。
-
它会在你的代码中添加 AMD 包装器,造成混乱。
-
生产需要编译。
-
在发布环境中的代码与本地开发环境中的代码不同。
自从我们在第四章 chapter 4 中提到 Grunt 以来已经有一段时间了,你肯定不希望发布一大堆未优化的脚本!Grunt 将帮助你在构建过程中编译 AMD 模块,这样它们就不需要异步获取了。
要通过 r.js(RequireJS 优化器)使用 Grunt 编译 AMD 模块,你可以使用 grunt-contrib-requirejs 包。该包允许你将选项传递给 r.js。以下列表是相关的任务配置。你将为每个目标设置默认选项,并调整 debug 目标。这在你需要重复配置的部分时非常有用,因为这会违反 DRY 原则。
⁴ 想了解如何编译 RJS 模块,请查看随附的代码示例:
bevacqua.io/bf/requirejs。
列表 5.10. 使用 Grunt 配置模块
requirejs: {
options: {
name: 'app',
baseUrl: 'js/amd',
out: 'build/js/app.min.js'
},
debug: {
options: {
preserveLicenseComments: false,
generateSourceMaps: true,
optimize: 'none'
}
},
release: {}
}
在调试版本中,你生成一个源映射,^([5]),这有助于浏览器将它们正在执行的代码映射到编译它时使用的源代码。这在调试时非常有用,因为你将获得指向源代码的堆栈跟踪,而不是难以调试的编译结果。release 目标没有额外的配置,因为它只是使用了之前提供的默认值。如果你查看随附样本中的目录结构,这将更容易可视化配置,该结构类似于 图 5.4。
⁵ 关于源映射的更多信息,请参阅 HTML5Rocks 上的这篇入门文章:
bevacqua.io/bf/sourcemap。
图 5.4. 使用 RequireJS 在 Grunt 构建时的典型文件结构

备注
一个集成了 RequireJS 和 Grunt 的示例可以在本书的源代码中找到,位于 ch05/10_requirejs-grunt 目录下。它包含了关于配置 RequireJS 构建任务时使用的每个选项含义的详细信息。
不必按照特定顺序添加脚本标签是一个很好的特性,而且你有几种方法可以实现这一点。如果你对 AMD 解决方案并不完全满意,或者如果你对此感到好奇,请继续阅读,以了解如何将 CommonJS 模块引入浏览器,作为替代方案。
5.3.3. Browserify:浏览器中的 CJS
在 5.2.3 节中,我解释了 CJS(Node.js 包中使用的模块系统)的好处。这些模块也因 Browserify 而有了在浏览器中的位置。这个选项经常被提出作为 AMD 的替代方案,尽管意见不一。由于你遵循的是 Build First 方法,因此为浏览器编译 CJS 模块不会成为大问题;这只是你构建过程中的一步!
除了在 5.2.3 节中描述的优点之外,如没有隐式全局变量,CJS 还提供了对 AMD 的简洁替代方案,因为你不需要 AMD 定义模块所需的所有杂乱和样板代码。CJS 模块的一个持续改进的特性是能够立即访问 npm 注册表中的任何包。到 2013 年,npm 注册表增长了 10 倍,在撰写本文时,它拥有超过 10 万个注册包。
Browserify 会递归地分析你应用中的所有require()调用,以构建一个你可以通过单个<script>标签提供给浏览器的捆绑包。正如你所期望的,Grunt 有许多插件急于将你的 CJS 模块编译成 Browserify 捆绑包,其中之一就是grunt-browserify。配置它类似于你在第二章中看到的内容,其中你提供了一个文件名来声明你的 CJS 模块的入口点,以及一个输出文件名:
browserify: {
debug: {
files: { 'build/js/app.js': 'js/app.js' },
options: { debug: true }
},
release: {
files: { 'build/js/app.js': 'js/app.js' }
}
}
我认为采用这种方法的大部分心理负担不会来自 Browserify,而是学习require和 CJS 模块的模块化。幸运的是,你在配置 Grunt 任务时已经使用了 CJS 模块,这在第一部分中已经提到,这应该能给你关于 CJS 的见解,以及一些可以查看的代码示例!如何使用grunt-browserify编译 CJS 模块的完整示例可以在配套代码示例的 ch05/11_browserify-cjs 中找到。接下来,我们将探讨 AngularJS 如何处理依赖解析,作为处理依赖管理的第三(也是最后)种方法。
5.3.4. Angular 的方式
Angular 是 Google 开发的一个创新的客户端 Model-View-Controller (MVC)框架。在第七章中,你将使用另一个流行的 JavaScript MVC 框架 Backbone。但 Angular 的依赖解析器在这个部分也值得提及。6
⁶ Angular 的文档提供了一个详尽的指南,解释了在 Angular 中 DI 是如何工作的,可以在
bevacqua.io/bf/angular-di找到。
利用 Angular 中的依赖注入
Angular 已经有一个相当复杂的依赖注入解决方案,所以我们不会深入细节。幸运的是,它足够抽象,使用起来很方便。我个人使用过许多不同的 DI 框架,Angular 让它感觉自然:你甚至没有意识到你正在进行 DI,就像 Java 和 RequireJS 一样。让我们通过一个虚构的例子一起走过,这个例子可以在 samples 中的 ch05/12_angularjs-dependencies 找到。将模块声明保存在自己的文件中很方便,就像这样:
angular.module('buildfirst', []);
然后,模块的各个不同部分,如服务或控制器,都被注册为之前声明的模块的扩展。请注意,你正在传递一个空数组给 angular.module 函数,这样你的模块就不依赖于任何其他模块:
var app = angular.module('buildfirst');
app.factory('textService', [
function () {
return function (input) {
return input.toUpperCase();
};
}
]);
注册控制器的方式也类似;在下面的例子中,你将使用你创建的 textService 服务。这与 RequireJS 的工作方式类似,因为你需要使用你为服务指定的名称:
var app = angular.module('buildfirst');
app.controller('testController', [
'textService',
function (text) {
var result = text('foo bar');
console.log(result);
// <- 'FOO BAR'
}
]);
接下来,让我们简要比较一下 Angular 和 RJS。
比较 Angular 和 RequireJS
Angular 与 RequireJS 不同之处在于,它不是作为一个模块加载器来工作,而是关注依赖图。你需要为每个使用的文件添加一个 script 标签,这与 AMD 不同,AMD 会为你处理这个问题。
在 Angular 的情况下,你会看到一个有趣的行为,即脚本顺序并不那么重要。只要你有 Angular 在顶部,然后是声明你的模块的脚本,其余的脚本可以按你想要的任何顺序排列,Angular 会为你处理这些。你需要在脚本标签列表的顶部添加以下代码,这就是为什么模块声明需要自己的文件:
<script src='js/vendor/angular.js'></script>
<script src='js/app.js'></script>
其余的脚本,作为 app 模块(或你给它起的任何名字)的一部分,可以按任何顺序加载,只要它们在模块声明之后:
<!--
These could actually be in any order!
-->
<script src='js/app/testController.js'></script>
<script src='js/app/textService.js'></script>
让我们快速总结一下当前 JavaScript 模块系统的状态。
使用 Grunt 打包 Angular 组件
作为旁注,在准备构建时,你可以明确地将 Angular 和模块放在顶部,然后为拼图的其余部分进行全局搜索。以下是如何配置传递给打包任务的 files 数组的示例,例如 grunt-contrib-concat 或 grunt-contrib-uglify 包中的任务:
files: [
'src/public/js/vendor/angular.js',
'src/public/js/app.js',
'src/public/js/app/**/*.js'
]
你可能不想完全依赖功能丰富的 AngularJS 框架,你也不打算将其包含到你的项目中以利用其依赖解析能力!作为一个结束语,我想补充说,没有绝对正确的选择,这就是为什么我介绍了这三种方法:
-
RequireJS 模块,使用 AMD 定义
-
CommonJS 模块,然后使用 Browserify 编译它们
-
AngularJS,其中模块会为你解析依赖图
如果你的项目使用 Angular,那么它已经足够好,你不需要 AMD 或 CJS,因为 Angular 提供了一个足够模块化的结构。如果你不使用 Angular,那么我可能会选择 CommonJS,主要是因为你可以利用大量的 npm 包。
下一个部分将介绍其他包管理器,并像对 npm 所做的那样,教你如何在客户端项目中利用它们。
5.4. 理解包管理
使用包管理器的一个缺点是它们倾向于使用某种特定的结构来组织依赖项。例如,npm 使用 node_modules 来存储已安装的包,而 Bower 使用 bower_components。Build First 的一个巨大优势是这根本不是问题,因为你可以将这些文件的引用添加到你的构建中,就这样!包的原始位置根本无关紧要。这就是使用 Build First 方法的一个巨大原因。
我想在本节讨论两个流行的前端包管理器:Bower 和 Component。我们将考虑每个的权衡,并将它们与 npm 进行比较。
5.4.1. 介绍 Bower
虽然 npm 是一个非凡的包管理器,但它并不适合所有的包管理需求:几乎发布到它的所有包都是 CJS 模块,因为它深深植根于 Node 生态系统。尽管我选择使用 Browserify 以便能够在 CJS 格式下编写模块化的前端代码,但这可能不是你参与的所有项目的选择。
Bower 是一个由 Twitter 创建的用于网页的包管理器,它是 内容无关的,这意味着作者打包图片、样式表或 JavaScript 代码都无关紧要。到现在你应该已经习惯了 npm 使用 package.json 清单跟踪包和版本号的方式。Bower 有一个 bower.json 清单,类似于 package.json。Bower 通过 npm 安装:
npm install -g bower
使用 bower 安装包既快速又直接;你只需要指定名称或 git 远程端点。在特定项目中,你首先需要运行 bower init。Bower 会问你几个问题(你可以按 Enter 键,因为默认值就很好),然后它会为你创建一个 bower.json 清单文件,如图 5.5 所示。
图 5.5. 使用 bower init 创建 bower.json 清单文件

一旦这些准备工作完成,安装包就变得轻而易举。以下示例展示了如何安装 Lo-Dash,一个类似于 Underscore 的实用库,但维护更为积极。它将下载脚本并将它们放置在 bower_components 目录中,如图 5.6 所示。
图 5.6. 使用 bower install --save 获取依赖项并将其添加到清单中

bower install --save lodash
就这些!你应该在bower_components/lodash目录中有脚本。将它们包含在你的构建配置中是一个将文件添加到你的分发配置的问题。通常,这个示例可以在配套的源代码中找到;寻找ch05/13_bower-packages。
Bower 可以说是第二大包管理器,其注册表中拥有近 20,000 个包,仅次于拥有超过 10 万个包的 npm。Component 作为另一个包管理解决方案,其拥有的包数量约为 3,000 个,但它提供了一个更模块化的替代方案,以及更全面的客户端包管理解决方案。让我们来看看吧!
5.4.2. 大型库,小型组件
像 jQuery 这样的大型库可以做你需要的一切,以及你不需要的东西。例如,你可能不需要它附带的动画或 AJAX。从这个意义上说,试图通过自定义构建将部分内容从 jQuery 中排除出去是一场艰难的战斗;自动化这个过程并不简单,你得到的比付出的多,我想这就是“写得更少,做得更多”这个口号所指的。
Component 是一个专注于小型组件的工具,这些组件只做一件事情,但做得很好。TJ Holowaychuk,一位多产的开源作者,而不是使用一个大型的库来满足所有需求,他提倡使用多个小块来以模块化的方式构建你需要的精确内容,而不添加任何额外的冗余。
⁷ 在 Holowaychuk 的博客上阅读 Component 的介绍:
bevacqua.io/bf/component。
你需要做的第一件事,就像往常一样,是从npm安装 CLI 工具:
npm install -g component
如果你正在使用组件,你可以用一个包含最基本有效 JSON 的清单来应付。让我们也创建一个吧:
echo "{}" > component.json
安装像 Lo-Dash 这样的组件的工作方式与之前使用 Bower 的方式相似。主要区别在于,与 Bower 使用一个仅用于跟踪包的注册表不同,Component 使用 GitHub 作为其默认注册表。指定用户名和存储库,如以下命令所示,就足以获取一个组件:
component install lodash/lodash
与其他库的做法相比,Component 总是会更新清单,添加你安装的包。你还需要将入口点添加到组件清单的脚本字段中。
"scripts": ["js/app/app.js"]
在 Component 中,你还可以发现另一个不同之处,那就是它有一个额外的构建步骤,这个步骤会将你安装的所有组件打包成一个单一的build.js连接文件。鉴于组件使用 CommonJS 风格的require调用,必要的require函数也将被提供。
component build
我鼓励你查看一些配套的示例,这可能会帮助你学习如何使用 Component。第一个示例,ch05/14_adopting-component,是这里所描述内容的完整工作示例。
第二个,ch05/15_automate-component-build,解释了如何使用grunt-component-build包通过 Grunt 自动化构建步骤。这样的构建步骤如果您的代码也被视为组件,则特别有用。
总结一下,我将为您概述我们讨论的每个系统,这可能会帮助您决定使用哪个包管理器或模块系统。
5.4.3. 选择正确的模块系统
Component 背后的理念是正确的——模块化的代码片段,能够很好地完成一项任务——但它也有一些细微的缺点。例如,它在component install中有一个不必要的构建步骤。执行component install应该构建组件运行所需的所有内容,就像npm做的那样。它也相当神秘,配置起来有点困难,文档也难以找到。糟糕的命名是这方面的巨大缺点,因为您无法通过在网络上搜索 Component 而得到不相关的结果,这使得找到您想要的文档变得困难。
如果您不认同 CJS 概念,Bower 是个不错的选择,而且它当然比您自己下载代码、将其放入目录并自行处理版本升级要好。Bower 在获取包方面做得不错,但它对模块化的帮助甚微,这也是它的不足之处。
就 Browserify 而言,目前如果您愿意承认 CJS 是今天最简单的模块格式,那么它是我们目前最好的选择。Browserify 没有内置包管理器是一个好事,因为它并不重要您选择哪个源来获取您消费的模块。它们可以来自 npm、Bower、GitHub 或其他地方。
Browserify 提供了将供应商代码引入 CJS 格式以及将 CJS 格式的应用程序导出为单个文件的机制。正如我们在 5.3.3 节中讨论的那样,Browserify 可以生成源映射,有助于开发过程中的调试,并且使用它可以使你访问最初为 Node 开发编写的任何 CJS 模块。
最后,AMD 模块可能非常适合与 Bower 一起使用,因为它们不会相互干扰。这里的优点是您不必学习 CJS 方法,尽管我会争辩说这并没有太多需要学习的。
在讨论即将到来的 ECMAScript 6 中 JavaScript 语言的变化之前,我们还有一个话题需要处理。那就是循环依赖的话题,比如鸡依赖于依赖于鸡的蛋。
5.4.4. 了解循环依赖
循环依赖,如之前解释的鸡依赖于依赖于鸡的蛋,是一个难以解决的问题,并且许多模块系统根本不支持它们。在本节中,我旨在通过回答以下问题来解决您可能遇到的问题:
-
使用循环依赖有合理的理由吗?
-
您可以使用哪些模式来避免它们?
-
我们讨论的解决方案是如何处理循环依赖的?
互相依赖的组件代表了一种代码异味,这意味着你的代码中可能存在更深层次的问题。解决循环依赖的最佳方法是完全避免它们。你可以使用一些模式来避免它们。如果两个组件正在互相通信,这可能是一个迹象,表明它们需要通过它们都消费的服务进行通信,例如。这样,将更容易对受影响的组件进行推理(并为它们编写代码)。在第七章中,你将了解在客户端应用程序中使用 AngularJS 避免这些鸡生蛋、蛋生鸡类型情况的方法。
使用服务作为中间人是解决循环依赖的多种方法之一。你可能让 chicken 模块直接依赖于 egg 并与之通信,但如果 egg 想要与 chicken 通信,那么它应该使用 chicken 给它的回调。一个更简单的方法是让模块实例互相依赖。让 chicken 和 egg 互相依赖,而不是整个家族,这样就可以绕过这个问题。
你还需要考虑到不同的系统处理循环依赖的方式不同。如果你尝试在 Angular 中解决循环依赖,它将抛出一个错误。Angular 并没有提供任何机制来处理模块级别的循环依赖。你可以通过使用他们的依赖解析器来解决这个问题。一旦一个依赖于 chicken 模块的 egg 模块被解析,那么当 chicken 模块被使用时,它可以获取 egg 模块。
在 AMD 模块的情况下,如果你定义了一个循环依赖,使得 chicken 需要 egg 而 egg 需要 chicken,那么当 egg 的模块函数被调用时,它将为 chicken 获取一个 undefined 值。egg 可以在模块定义后使用 require 方法来获取 chicken。
CommonJS 允许通过在每次 require 调用暂停模块解析来解决循环依赖。如果一个 chicken 模块需要 egg 模块,那么 chicken 模块的解析将被暂停。当 egg 模块需要 chicken 时,它将获得 chicken 模块的局部表示,直到 require 调用被做出。然后 chicken 模块将完成解析。代码示例 ch05/16_circular-dependencies 说明了这一点。
重要的是,你应该像躲避瘟疫一样避免循环依赖。循环依赖将不必要的复杂性引入到你的程序中,模块系统没有标准的方式来处理它们,而且它们总是可以通过更有序地编写代码来避免。
为了总结本章内容,我们将探讨 ECMAScript 6 中即将到来的几个变化,以及它们在模块化组件设计方面的贡献。
5.5. 和谐:对 ECMAScript 6 的一瞥
如你所知,ECMAScript (ES) 是定义 JavaScript 代码行为的规范。ES6,也称为 Harmony,是该规范的(长期期待的)新版本。一旦 ES6 发布,你将受益于语言数百项大小改进,其中一部分我将在本节中介绍。在撰写本文时,Harmony 的一部分在 Chrome Canary(Google Chrome 的边缘版本)和 Firefox Nightly 构建中。在 Node 中,你可以通过调用 node 进程时使用 --harmony 标志来启用 ES6 语言功能。
请注意,ES6 功能高度实验性,并可能发生变化;规范始终处于变动之中。对本节中讨论的内容持保留态度。我将在即将发布的新语言版本中介绍概念和语法;目前作为 ES6 部分提出的功能不太可能改变,但具体的语法更有可能被调整。
Google 通过他们的 Traceur 项目在推广 ES6 学习方面做出了有趣的努力,该项目将 ES6 编译成 ES3(一个普遍可用的规范版本),允许你用 ES6 编写代码,然后执行生成的 ES3。尽管 Traceur 不支持 Harmony 中的所有功能,但它是最功能丰富的编译器之一。
5.5.1. Traceur 作为 Grunt 任务
由于一个名为 grunt-traceur 的包,Traceur 可作为 Grunt 任务使用。你可以使用以下配置来设置它。它将单独编译每个文件,并将结果放置在 build 目录中:
traceur: {
build: {
src: 'js/**/*.js',
dest: 'build/'
}
}
通过这个任务,你可以编译我沿途展示的一些 ES6 Harmony 示例。自然地,相关的代码示例包含了这个 Grunt 任务的运行示例,以及一些关于你可以使用 Harmony 做什么的片段,所以请务必查看 ch05/17_harmony-traceur 并浏览这些示例。第六章 和 第七章 也包含更多 ES6 代码片段,以更好地了解即将到来的语言功能。
现在你已经了解了几种开启 ES6 功能的方法,让我们深入了解 Harmony 的模块实现方式。
5.5.2. Harmony 中的模块
在本章中,你已导航不同的模块系统,并学习了模块化设计模式。AMD 和 CJS 的输入影响了 Harmony 模块背后的设计决策,旨在取悦任何一方的支持者。这些模块有自己的作用域;它们使用 export 关键字导出公共 API 成员,这些成员可以稍后使用 import 关键字单独导入。可选的显式 module 声明允许文件连接。
以下是如何这些机制工作的一个示例。我在写作时使用了当时可用的最新语法^([8])。这种语法来自 2013 年 3 月由负责推动语言发展的技术委员会 TC39 举行的一次会议。如果我是你,我不会过多关注具体细节,只需把握总体思路。
⁸ 在
bevacqua.io/bf/es6-modules找到 ES6 文章。
首先,你将定义一个包含几个导出方法的简单模块:
// math.js
export var pi = 3.141592;
export function circumference (radius) {
return 2 * pi * radius;
}
消费这些方法只需在import语句中引用它们,如下面的代码片段所示。这些语句可以选择导入模块中找到的一个、多个或所有导出。以下语句将circumference导出导入到本地模块中:
import { circumference } from "math";
如果你想要导入多个导出,可以用逗号分隔它们:
import { circumference, pi } from "math";
使用as语法,你可以将模块中的每个导出导入到一个对象中,而不是直接导入到本地上下文中:
import "math" as math;
如果你想要显式地定义模块,而不是让它们隐式地定义,对于你打算将脚本打包到一个文件中的发布场景,你可以用一种字面量方式来定义一个模块:
module "math" {
export // etc...
};
如果你对 ES6 中的模块系统感兴趣,你应该阅读一篇文章^([9]),这篇文章涵盖了关于 ES6 你已经学到的内容,并阐明了模块系统的可扩展性。始终记住,语法可能会变化。在前往第六章之前,我还有一个关于模块性的小 ES6 特性要提及。那就是let关键字。
⁹ 在
bevacqua.io/bf/es6-modules找到这篇文章。
5.5.3. 块作用域的出现
ES6 的let关键字是var语句的替代品。你可能记得var是函数作用域的,正如你在 5.1.3 节中分析的。使用let,你得到的是块作用域,这更类似于传统语言中的作用域规则。在变量声明方面,提升(Hoisting)起着重要作用,而let是绕过函数作用域限制的绝佳方式。
例如,考虑以下场景,这是一个典型的你想要条件性地声明变量的情况。提升(Hoisting)使得在if语句内部声明变量变得尴尬,因为你知道它会被提升到作用域的顶部,如果将来你决定在else块中使用相同的变量名,这可能会引起麻烦。
function processImage (image, generateThumbnail) {
var thumbnailService;
if (generateThumbnail) {
thumbnailService = getThumbnailService();
thumbnailService.generate(image);
}
return process(image);
}
使用let关键字,你可以在if块中声明它,不必担心它泄漏到该块之外,也不需要将变量声明与其赋值分开:
function processImage (image, generateThumbnail) {
if (generateThumbnail) {
let thumbnailService = getThumbnailService();
thumbnailService.generate(image);
}
return process(image);
}
在这种情况下,差异是微妙的,但避免在函数作用域顶部列出长列表的变量,这些变量可能只会在代码路径中的一个被使用,在当前使用var的 JavaScript 实现中是一个代码异味。这是一个可以通过使用let关键字来轻松避免的代码异味,将变量保持在它们所属的块作用域中。
5.6. 概述
终于,你完成了作用域、模块系统等内容的学习!
-
你了解到保持代码自包含且具有明确目的,以及信息隐藏,可以极大地提高你的界面设计。
-
作用域、
this和提升现在更加清晰,这将帮助你更好地设计符合 JavaScript 范式的代码,甚至没有意识到这一点。 -
使用闭包和模块模式教会了你模块系统是如何工作的。
-
你比较了 CommonJS、RequireJS 和 Angular 如何处理模块加载,以及它们如何处理循环依赖。
-
你了解到可测试性的重要性,我们将在第八章中进一步探讨,以及控制反转模式如何使你的代码更具可测试性。
-
我们讨论了如何利用 Browserify 在浏览器中利用 npm 包,使用 Bower 下载依赖项,以及使用 Component 编写模块化代码的 UNIX 哲学。
-
你看到了 ES6 即将带来的内容,例如模块系统和
let关键字,以及你学习了如何使用 Traceur 编译器玩转 ES6。
在第六章章节 6 中,你将学习关于异步 JavaScript 开发的内容。你将了解常见的陷阱,并通过示例来帮助你理解如何有效地调试这些函数。你将查看编写异步函数的各种模式,如回调、事件、Promise 和即将到来的 Harmony 生成器 API。
第六章. 理解 JavaScript 中的异步流程控制方法
本章涵盖
-
理解回调地狱以及如何避免它
-
在 JavaScript 中创建 Promise 并保持它们
-
使用异步控制流
-
学习基于事件的编程
-
使用 Harmony(ECMAScript 6)生成器函数
第五章章节 5 讲述了以模块化方式构建组件的重要性,你学习了大量的关于作用域、提升和闭包的知识,这些都是有效理解异步 JavaScript 代码所必需的。如果没有对 JavaScript 异步开发的适度理解,编写易于阅读、重构和维护的高质量代码就会变得更加困难。
对于 JavaScript 开发初学者来说,最经常遇到的问题之一是处理“回调地狱”,其中许多函数嵌套在彼此内部,使得调试或甚至理解一段代码变得困难。本章旨在揭开异步 JavaScript 的神秘面纱。
异步执行 是指代码不是立即执行,而是在未来执行;这样的代码不是同步的,因为它不是顺序执行的。尽管 JavaScript 是单线程的,但用户触发的事件,如点击、超时或 AJAX 响应,仍然可以创建新的执行路径。本章将介绍不同的方法,通过应用一致的风格来处理异步流程,以可接受且不痛苦的方式处理异步代码流。就像第五章(kindle_split_017.html#ch05)一样,本章有许多实用的代码示例供你参考!
为了开始,我们将查看一个最古老的模式之一:将回调作为参数传递,以便函数的调用者可以确定回调被调用时未来的行为。这种模式被称为延续传递风格,它是异步回调的核心。
6.1. 使用回调
使用回调的一个典型例子可以在 addEventListener API 中找到,它允许我们在 DOM(文档对象模型)节点上绑定事件监听器。当这些事件被触发时,我们的回调函数会被调用。在以下简单的示例中,当我们点击文档中的任何地方时,会在控制台打印一条日志语句:
document.body.addEventListener('click', function () {
console.log('Clicks are important.');
});
点击事件处理并不总是那么简单。有时你可能会看到以下类似的列表。
列表 6.1. 使用逻辑面条的回调汤

发生了什么?这正是我的想法。你已经经历了回调地狱,这是一个友好的名字,描述了在更多回调之上深度嵌套和缩进的回调,这使得跟踪流程和理解发生了什么变得相当困难。如果你无法理解 列表 6.1 中展示的代码,那很好。你不需要这样做。让我们更深入地探讨这个主题。
6.1.1. 避免回调地狱
即使是异步的代码,你也应该能够一眼看出其流程,如果你需要花几秒钟以上才能理解其流程,那么这段代码可能存在问题。每个嵌套的回调意味着更多的嵌套作用域,正如在第五章中观察到的,以及更深一级的缩进,这会在你的显示区域占用更多空间,使得代码更难跟踪。
回调地狱并非一夜之间发生,你可以防止它发生。使用一个示例(在示例中命名为 ch06/01_callback-hell),让我们看看它可能随着时间的推移慢慢渗透到你的代码库的裂缝中。假设你需要发起一个 AJAX 请求来获取数据,然后将其展示给人类。为了简化 AJAX-foo,我们将使用一个假想的 http 对象。假设你有一个记录变量,它持有对特定 DOM 元素的引用。
record.addEventListener('click', function () {
var id = record.dataset.id;
var endpoint = '/api/v1/records/' + id;
http.get(endpoint, function (res) {
record.innerHTML = res.data.view;
});
});
这仍然很容易理解!如果你需要在GET请求成功后更新另一个组件,该怎么办?考虑以下列表。假设有一个status变量中的 DOM 元素。
列表 6.2. 回调蔓延
function attach (node, status, done) {
node.addEventListener('click', function () {
var id = node.dataset.id;
var endpoint = '/api/v1/records/' + id;
http.get(endpoint, function (res) {
node.innerHTML = res.data.view;
reportStatus(res.status, function () {
done(res);
});
});
function reportStatus (status, then) {
status.innerHTML = 'Status: ' + status;
then();
}
});
}
attach(record, status, function (res) {
console.log(res);
});
好吧,这开始变得糟糕了!嵌套回调每次在代码中添加一个嵌套级别时都会增加复杂性,因为你现在必须跟踪现有函数的上下文以及更深回调的上下文。考虑到在真实的应用程序中,这些方法中的每一个可能都有更多的行,这使得在记忆中保持所有这些状态变得更加困难。
你如何对抗回调蔓延?通过减少回调嵌套深度,可以避免所有这些复杂性。
6.1.2. 解开回调混乱
你有方法来解开这些无辜的代码片段。以下是一个你应该考虑并修复的事项列表:
-
命名匿名函数,以提高其可读性,并给出它们所做工作的提示。命名的匿名回调提供了双重价值。它们的名称可以用来传达意图,并且在跟踪异常时也很有帮助,因为堆栈跟踪将显示函数名称,而不是显示为“匿名函数”。命名的函数将更容易识别,并在调试时节省你很多麻烦。
-
移除不必要的回调,例如在示例中报告状态之后的那个。如果一个回调只在函数的末尾执行,而不是异步执行,你可以将其移除。原本在回调中的代码可以直接放在函数调用之后。
-
小心将条件语句与流程控制代码混合。条件语句会阻碍你跟踪代码的能力,因为引入了新的可能性,你需要考虑代码可能遵循的所有可能方式。流程控制也会带来类似的问题。它使得阅读代码更困难,因为下一条指令不总是下一行。包含条件语句的匿名回调尤其难以跟踪流程,应该避免。第 6.1 节中的第一个示例很好地展示了这种混合是如何成为灾难的配方。你可以通过将条件语句与流程控制分离来减轻这个问题。提供函数的引用,而不是匿名回调,并可以保留条件语句的原样。
在对前一个列表中建议的更改进行更改后,代码最终会变成以下列表。
列表 6.3. 清理混乱

这还不是最糟糕的;还有其他什么?
-
reportStatus函数现在看起来毫无意义;你可以内联其内容,将其移动到唯一的调用位置,并减少心理负担。那些不会被重用的简单方法可以用其内容替换,从而减少认知负荷。 -
有时候,做相反的事情也是有意义的。您不必在行内声明点击处理程序,而是可以将其拉入一个命名函数,使
addEventListener行更短。这主要是一个个人偏好的问题,但当代码行超过 80 个字符时,它可能有所帮助。
下一个列表显示了应用这些更改后的结果代码。尽管代码在功能上等效,但阅读起来容易多了。与列表 6.2 进行比较,可以得到更清晰的画面。
列表 6.4. 提取函数
function attach (node, status, done) {
function handler () {
var id = node.dataset.id;
var endpoint = '/api/v1/records/' + id;
http.get(endpoint, updateView);
}
function updateView (res) {
node.innerHTML = res.data.view;
status.innerHTML = 'Status: ' + res.status;
done(res);
}
node.addEventListener('click', handler);
}
attach(record, status, function done (res) {
console.log(res);
});
您所做的是使代码的阅读流畅。技巧是尽可能保持每个函数尽可能小和专注,正如在第五章中所述。然后,就是给函数起适当、描述性的名字,清楚地说明方法的用途。学习何时内联不必要的回调,就像您在report-Status中所做的那样,是一个实践问题。
通常情况下,只要代码的可读性得到提高,代码本身变得稍微长一点并不会有什么影响。可读性是您编写的代码最重要的单一方面,因为您将花费大部分时间在阅读代码上。在继续之前,让我们再来看一个例子。
6.1.3. 请求之上的请求
在 Web 应用程序中,拥有依赖于其他 AJAX 请求的 Web 请求并不罕见;后端可能不适合在单个 AJAX 调用中提供您所需的所有数据。例如,您可能需要访问客户客户的列表,但为了做到这一点,您必须首先使用他们的电子邮件地址获取客户 ID,然后您需要获取与该客户关联的区域,最后才能获取与该区域和客户关联的客户。
让我们看看以下列表(在示例中的 ch06/02_requests-upon-requests 中找到)以了解这个 AJAX 狂欢可能看起来是什么样子。
列表 6.5. 使用 AJAX 进行回调嵌套
http.get('/userByEmail', { email: input.email }, function (err, res) {
if (err) { done(err); return; }
http.get('/regions', { regionId: res.id }, function (err, res) {
if (err) { done(err); return; }
http.get('/clients', { regions: res.regions }, function (err, res) {
done(err, res);
});
});
});
function done (err, res) {
if (err) { throw err; }
console.log(res.clients);
}
当您在第九章中分析 REST API 服务设计时,您将了解到,为了获取所需的数据而不得不跳过这么多环节通常是一个迹象,表明客户端代码是符合后端服务器提供的任何 API,而不是有一个专门为前端构建的 API。在我描述的案例中,如果服务器能够基于客户电子邮件完成所有这些工作,而不是进行那么多往返服务器,那就最好不过了。
图 6.1 显示了与服务器之间的重复往返,与前端专用 API 相比。如图所示,使用现有的 API,很可能它不会满足前端的需求,你必须在浏览器中对输入进行按摩处理,然后再将其传递给 API。在最坏的情况下,你可能甚至需要发出多个请求以获得所需的结果,这意味着额外的往返。如果你有一个专用的 API,它将能够完成你提出的任何任务,这将允许你优化并减少对服务器的请求次数,减少服务器负载并消除不必要的往返。
图 6.1. 诉诸现有 API 或使用前端专用 API 之间的权衡

如果你考虑到这段代码可能位于闭包内,也可能位于事件处理程序内,缩进变得难以忍受:很难通过所有这些嵌套级别来跟踪代码,尤其是如果方法很长的话。给回调函数命名并将它们提取出来,而不是使用匿名函数,就足够开始重构功能,使其更容易理解。
下面的列表展示了重构后的代码,作为如何分解嵌套的一个示例。
列表 6.6. 不再嵌套

你已经可以看到这更容易理解了;现在所有内容都在相同的嵌套级别,流程更加清晰。你可能已经注意到了一个模式,即每个方法都会检查错误以确保下一步不会遇到任何意外。在接下来的几节中,我们将探讨在 JavaScript 中处理异步流的不同方法:
-
使用回调库
-
承诺
-
生成器
-
事件发射器
你将学习每个解决方案如何简化错误处理。现在,你将在当前示例的基础上进行构建,找出如何去除那些错误检查。
6.1.4. 异步错误处理
你应该计划处理错误,而不是忽略它们。你永远不应该让错误被忽视。话虽如此,当使用回调地狱或命名函数方法时,进行任何形式的错误处理都是一件繁琐的事情。当然,比在每个函数中添加错误处理行更好的方法肯定存在。
在第五章中,你学习了不同的函数调用操作方法,例如使用.apply、.call和.bind。想象一下,如果你能写出一行如下代码,并且能够去除重复的错误检查语句,同时仍然进行检查,但只在一个地方进行,那岂不是很好?
flow([getUser, getRegions, getClients], done);
在前面的语句中,flow方法接受一个函数数组,并依次执行每个函数。每个函数都会接收到一个next参数,当它完成时应该调用该参数。如果传递给next的第一个参数是“真值”(JavaScript 中指任何非false、0、''、null或undefined的值),则done会被立即调用,中断流程。
第一个参数保留用于错误。如果该参数是“真值”,则你会短路并直接调用done。否则,将调用数组中的下一个函数,并将传递给next的所有参数(除了错误之外)以及一个新的next回调函数传递给它,该回调函数允许下一个方法继续链式调用。实现这一点似乎确实有些困难。
首先,你将要求flow方法的消费者在方法完成工作时调用一个next回调。这将有助于流程部分。你必须提供那个回调方法,并让它调用列表中的下一个函数,传递所有用于调用next的参数。你将附加一个新的next回调,该回调将调用下一个方法,依此类推。
图 6.2 解释了你将要实现的flow函数。
图 6.2. 理解异步流程方法

在你实现flow方法之前,让我们看看一个完整的用法示例。这就是你之前所做的事情,为特定客户寻找客户,但你不再需要在每个步骤中进行错误检查;flow方法将负责这一点。下面的列表显示了使用flow的样子。
列表 6.7. 使用流程方法

记住我们之前讨论的内容,让我们看看flow函数的实现。添加一个保护子句可以确保在给定步骤中对next的多次调用不会产生负面影响。只有第一次调用next会被考虑。flow的实现可以在下面的列表中找到。
列表 6.8. 实现异步系列flow方法

尝试自己跟随流程,如果你迷失了方向,请记住next()方法仅仅返回一个函数,该函数一旦被调用就会产生效果。如果你不想包含那个保护措施,你可以在每个步骤中重复使用同一个函数。然而,这种方法考虑到了消费者可能在实际步骤中两次调用next的编程错误。
维护如flow这样的方法以保持它们最新和没有错误可能会很繁琐,如果您只是想避免基于回调的异步流程的嵌套地狱,并希望得到相应的错误处理。幸运的是,聪明的人已经将这种以及其他许多异步流程模式实现到了一个名为async的 JavaScript 库中,并且它也被集成到流行的 Web 框架如 Express 中。在本章中,我们将讨论控制流范式,如回调、Promise、事件和生成器。接下来,您将熟悉async。
6.2. 使用 async 库
在 Node 的世界里,许多开发者发现很难不使用async控制流库。原生模块,即 Node 平台本身的一部分,遵循最后一个函数参数是接收错误作为第一个参数的回调的模式。以下代码片段使用 Node 的文件系统 API 异步读取文件,说明了这一点:
require('fs').readFile('path/to/file', function (err, data) {
// handle the error, use data
});
async库提供了许多异步控制流方法,就像在 6.1.3 节中构建flow实用方法时一样。您的flow方法与async.waterfall非常相似。区别在于async提供了数十种方法,如果正确应用,可以简化您的异步代码。
您可以从npm或Bower,或从 GitHub 获取async。[2] 当您在 GitHub 上时,您可能想查看 Caolan McMahon(async的作者)编写的优秀文档。
¹ 您可以从 GitHub 下载 async 库:
github.com/caolan/async。
在接下来的小节中,我们将详细介绍async控制流库,讨论您可能遇到的问题以及async如何为您解决这些问题,使代码更容易阅读。要开始,您将查看三种略有不同的流程控制方法:waterfall、series和parallel。
6.2.1. 水流、顺序或并行?
掌握异步 JavaScript 最重要的方面之一是了解您可用的所有不同工具,您在本章中一定会学到。其中一种工具是常见的控制流技术:
-
您想异步运行任务,使它们之间不相互依赖吗?使用
.parallel来并发运行它们。 -
您的任务是否依赖于前一个任务?您可以按顺序运行它们,一个接一个,但仍然异步执行。
-
您的任务是否紧密耦合?使用允许您将参数传递给列表中下一个任务的瀑布机制。我们之前讨论的 HTTP 级联是一个完美的瀑布机制用例。
图 6.3 更详细地比较了三种替代方案。
图 6.3. async 库中并行、顺序和瀑布的比较。

如图中所示,这三种策略之间存在细微的差异。让我们来分析一下。
并发
并发任务执行在当你有几个不同的异步任务,它们没有相互依赖,但你仍然需要在所有这些任务完成时做某事时最有帮助;例如,当获取不同的数据来渲染视图时。你可以定义一个并发级别,即有多少个任务可以忙碌,而其余的任务则在队列中等待:
-
一旦一个任务完成,就会从队列中抓取另一个任务,直到队列为空。
-
每个任务都会传递一个特殊的
next方法,当处理完成时应该调用它。 -
传递给
next的第一个参数是保留用于错误的;如果你传递了一个错误,则不会执行任何其他任务(尽管已经执行的任务将运行到完成)。 -
第二个参数是传递任务结果的地方。
-
一旦所有任务结束,就会调用
done回调。它的第一个参数将是错误(如果有),第二个参数将按任务排序的结果,无论它们完成所需的时间有多长。
系列
顺序执行可以帮助你连接相关任务,这些任务旨在依次执行,即使代码执行是异步的,在主循环之外。想象一下系列流程就像并发流程,其并发级别设置为 1。实际上,这正是它的样子!next(err, results)和done(err, results)的相同约定同样适用。
水落石出
水落石出的变体类似于顺序执行,但它允许你轻松地将一个任务的参数滚到下一个任务中,形成一个瀑布。这种类型的流程在任务只能使用其他任务的响应提供的数据来启动时最有用。在瀑布的情况下,流程不同,next接受一个错误,后跟任意数量的结果参数:next(err, result1, result2, result...n)。done回调的行为与此完全相同,为你提供了传递给最后一个next回调的所有参数。
接下来,让我们深入了解series和parallel是如何工作的。
系列中的流程控制
你已经在flow方法中看到了waterfall的作用。让我们谈谈series,它与waterfall所做的略有不同。它按顺序执行步骤,一次一个,就像waterfall一样,但它不会干涉每个步骤函数的参数。相反,每个步骤只接收一个next回调参数,期望(err, data)签名。你可能想知道,“那对我有什么帮助?”答案是有时拥有单个参数的一致性,并且该参数是一个回调,这很有用。以下列表是一个说明async.series如何工作的示例。
列表 6.9. 使用async.series
async.series([
function createUser (next) {
http.put('/users', user, next);
},
function listUsers (next) {
http.get('/users/list', next);
},
function updateView (next) {
view.update(next);
}
], done);
function done (err, results) {
// handle error
updateProfile(results[0]);
synchronizeFollowers(results[1]);
}
有时候需要对结果进行单独的操作,就像你在之前的列表中所做的那样。在这些情况下,使用一个对象来描述任务而不是数组更有意义。如果你这样做,done 回调将获得一个 results 对象,将结果映射到每个任务的属性名。这听起来很复杂,但实际上并不复杂,所以让我们修改以下列表中的代码来阐述这个观点。
列表 6.10. 使用 done 回调
async.series({
user: function createUser (next) {
http.put('/users', user, next);
},
list: function listUsers (next) {
http.get('/users/list', next);
},
view: function updateView (next) {
view.update(next);
}
}, done);
function done (err, results) {
// handle error
updateProfile(results.user);
synchronizeFollowers(results.list);
}
如果一个任务仅仅涉及调用一个接受参数和 next 回调的函数,你可以使用 async.apply 来缩短你的代码;这将使代码更容易阅读。apply 辅助函数将接受你想要调用的方法和你想要使用的参数,并返回一个接受 next 回调并将其附加到你的参数列表中的函数。以下代码片段中显示的两种方法在功能上是等效的:
function (next) {
http.put('/users', user, next);
}
async.apply(http.put, '/users', user)
// <- [Function]
以下代码是之前组合的任务流程的简化版本,使用了 async.apply:
async.series({
user: async.apply(http.put, '/users', user),
list: async.apply(http.get, '/users/list'),
view: async.apply(view.update)
}, done);
如果你使用了 waterfall,这种优化将不会成为可能。由 async.apply 创建的函数只期望一个 next 参数,没有其他。在 waterfall 流中,任务可以传递任意数量的参数。相比之下,在 series 中,任务总是接收恰好一个参数,即 next 回调。
并发流程控制
然后是 async.parallel。并发运行任务的工作方式与按顺序运行任务完全一样,只是你不会逐个处理任务,而是同时运行它们。并发流程导致执行时间更快,因此在你的工作流程没有特定要求,只需异步性时,parallel 是首选。
async 库还提供了功能方法,允许你遍历列表、将对象映射到其他内容或对它们进行排序。接下来,我们将探讨这些功能方法以及 async 内置的有趣任务队列功能。
6.2.2. 异步功能任务
假设你需要遍历一个产品标识符列表,并通过 HTTP 获取它们的对象表示。这是一个非常适合使用映射(map)的场景。映射通过一个修改输入的函数将输入转换为输出。以下列表(在示例中的 ch06/05_async-functional 文件夹中可用)展示了如何使用 async.map 来实现这一点。
列表 6.11. 使用映射将输入转换为输出
var ids = [23, 33, 118];
async.map(ids, transform, done);
function transform (id, complete) {
http.get('/products/' + id, complete);
}
function done (err, results) {
// handle the error
// results[0] is the response for ids[0],
// results[1] is the response for ids[1],
// and so on
}
当调用 done 时,它将有一个错误参数作为第一个参数,你应该处理它,或者有一个结果数组作为第二个参数,该数组将与调用 async.map 时提供的列表顺序相同。一些方法的行为与 async 中的 map 类似。它们将接受一个数组和函数,将函数应用于数组中的每个项目,然后使用结果调用 done。
例如,async.sortBy 允许你原地排序一个数组(这意味着它不会创建一个副本),而你只需要传递一个值作为函数 done 回调的排序标准。你可以像以下列表中那样使用它。
列表 6.12. 对数组进行排序
async.sortBy([1, 23, 54], sort, done);
function sort (id, complete) {
http.get('/products/' + id, function (err, product) {
complete(err, product ? product.name : null);
});
}
function done (err, result) {
// handle the error
// result contains ids sorted by name
}
map 和 sortBy 都基于 each,你可以将其视为 parallel,或者如果你使用 eachSeries 版本,则为 series。each 只是对数组进行循环并应用一个函数到每个元素;然后调用一个可选的 done 回调函数,它有一个错误参数告诉你是否出错。以下列表显示了使用 async.each 的一个示例。
列表 6.13. 使用 async.each
async.each([2, 5, 6], iterator, done);
function iterator (item, done) {
setTimeout(function () {
if (item % 2 === 0) {
done();
} else {
done(new Error('expected divisible by 2'));
}
}, 1000 * item);
}
function done (err) {
// handle the error
}
async 库中的更多方法处理函数式情况,所有这些都与异步地将数组转换为数据的不同表示形式有关。我们不会涵盖其余的内容,但我鼓励你查看 GitHub 上的详尽文档.^([2])
² 你可以在 GitHub 上找到流控制库
async,网址为github.com/caolan/async。
6.2.3. 异步任务队列
接下来是最后一种方法,async.queue,此方法将创建一个队列对象,可用于按顺序或并发运行任务。它接受两个参数:工作函数,它将接受一个任务对象和一个回调函数来表示工作已完成,以及并发级别,它决定了在任何给定时刻可以运行多少个任务。
如果并发级别是 1,你实际上是将队列变成了一个序列,在先前的任务结束后执行任务。让我们在以下列表中创建一个简单的队列(在示例中标记为 ch06/06_async-queue)。
列表 6.14. 创建一个简单的队列
var q = async.queue(worker, 1);
function worker (id, done) {
http.get('/users/' + id, function gotResponse (err, user) {
if (err) { done(err); return; }
console.log('Fetched user ' + id);
done();
});
}
你可以使用 q 对象来使队列工作。要向队列中添加新工作,请使用 q.push。你需要传递一个任务对象,这是传递给工作者的内容;在我们的例子中,任务是一个数值字面量,但它也可以是一个对象或甚至是一个函数;以及一个可选的回调函数,当这个特定的工作完成时会被调用。让我们看看如何在代码中实现它:
var id = 24;
q.push(id, function (err) {
if (err) {
console.error('Error processing user 23', err);
}
});
就这样。好处是你可以在不同时间点推送更多任务,它仍然会工作。相比之下,parallel 或 series 是一次性操作,你无法在之后添加任务到列表中。话虽如此,我们关于 async 控制流库的最后一个主题是关于组合流和动态创建任务列表——这两者都可能为你的方法带来更多的灵活性。
6.2.4. 流组成和动态流
有时,你需要构建更高级的流,其中
-
任务 b 依赖于任务 a
-
当任务 c 需要在之后执行
-
任务 d 可以与所有这些并行执行
当所有这些都完成后,你将运行最后一个任务:任务 e。
图 6.4 展示了该流可能的样子:
图 6.4. 复杂异步流程的剖析。提示:始终根据需求在脑海中分组任务。

-
任务 A(上车)和任务 B(支付车费)需要按瀑布模式执行,因为任务 B 依赖于任务 A 的结果。
-
任务 C(到达你的工作场所)需要在任务 A 和 B 解决后按顺序执行。它依赖于这两个任务,但不是直接依赖。
-
任务 D(阅读一本书)没有依赖关系,因此它可以与任务 A、B 和 C 并行执行。
-
任务 E(工作)依赖于任务 C 和任务 D,因此必须在那些任务完成后执行。
这听起来和看起来比实际要复杂。只要你使用async这样的控制流库,你只需要编写几个堆叠在一起的函数。这可以像以下示例中的伪代码那样。在这里,我使用在 6.2.1 节中引入的async.apply来缩短代码。一个完整的示例可以在samples/ch06/07_async-composition中找到:
async.parallel([
async.apply(async.series, [
async.apply(async.waterfall, [getOnBus, payFare]),
getToWork
]),
readBook
], doWork);
以这种方式组合流程在编写涉及许多async操作的应用程序时非常有用,例如查询数据库、读取文件或连接到外部 API,所有这些操作往往会导致高度复杂的异步操作树。
动态组合流程
通过向对象添加任务动态创建流程,可以使你组织任务列表变得更容易,这在没有使用控制流库的情况下会困难得多。这是对你在 JavaScript 这种动态语言中编写代码的事实的一种认可。你可以通过编写动态函数来利用这一点,所以请这样做!以下列表将一个项目列表映射到每个函数,然后使用该函数查询该项目。
列表 6.15. 映射和查询项目列表
var tasks = {};
items.forEach(function queryItem (item) {
tasks[item.name] = function (done) {
item.query(function queried (res) {
done(null, res);
});
};
});
function done (err, results) {
// results will be organized by name
}
async.series(tasks, done);
异步的轻量级替代方案
关于async在客户端使用方面,我想提一下。async最初主要是为 Node.js 社区开发的,因此它对浏览器的测试并不那么严格。
我构建了自己的版本,contra,它有一个广泛的单元测试套件,在每次发布之前都会执行。我将contra中的代码保持到最小;它比async小 10 倍,使其非常适合浏览器。它提供了可以在async中找到的方法,以及实现事件发射器的一种简单方法,这在 6.4 节中有解释。你可以在 GitHub 上找到它,^([a]),并且它可在 npm 和 Bower 上使用。
^a 在 GitHub 上获取我的流程控制库
contra,网址为github.com/bevacqua/contra。
让我们继续讨论 Promises,这是一种通过链式函数和合同处理来处理异步编程的方法。你使用过 jQuery 的 AJAX 功能吗?那么你已经使用过一种名为 Deferred 的 Promises 变体,它与官方的 ES6 Promises 实现略有不同,但本质上相似。
6.3. 创建 Promises
Promises 是一个新兴的标准,实际上它是官方 ECMAScript 6 草案规范的一部分。目前,你可以通过包含一个库,如 Q、RSVP.js 或 when 来使用 Promises。你也可以通过添加 ES6 Promises polyfill 来使用 Promises.^([3]) 一个 polyfill 是一段代码,它使你期望语言运行时能够原生提供的功能成为可能。在这种情况下,Promise 的 polyfill 将提供 Promises,就像它们在 ES6 中应该原生工作一样,使它们对 ES 标准的早期实现可用。
³ 在
bevacqua.io/bf/promises找到 ES6 Promises polyfill。
在本节中,我将描述 ES6 中的 Promises,只要你包含 polyfill,你就可以使用它。如果你使用的是除了 polyfill 以外的 Promise,语法会有所不同,但这些变化足够微妙,核心概念保持不变。
6.3.1. Promise 基础
创建一个 Promise 涉及一个回调函数,该函数将 fulfill 和 reject 函数作为其参数。调用 fulfill 将改变 Promise 的状态为 fulfilled;你将在下一分钟看到这意味着什么。调用 reject 将状态改为 rejected。以下是一段简短且自解释的 Promise 声明代码,其中你的 Promise 将有一半的时间被实现,另一半时间被拒绝:
var promise = new Promise(function logic (fulfill, reject) {
if (Math.random() < 0.5) {
fulfill('Good enough.');
} else {
reject(new Error('Dice roll failed!'));
}
});
如你所注意到的,Promises 没有任何固有的属性使它们专门用于异步操作,你也可以使用它们进行同步操作。当混合同步和异步代码时,这很有用,因为 Promises 不关心这一点。Promises 从 pending 状态开始,一旦它们失败或成功,它们就被解决,并且状态不能再改变。Promises 可以处于三种互斥状态之一:
-
Pending:尚未实现或拒绝。 -
Fulfilled:与 Promise 相关的操作已成功执行。 -
Rejected:与 Promise 相关的操作失败。
Promise 连续
一旦创建了一个 Promise 对象,你可以通过 then(success, failure) 方法向其添加回调函数。当 Promise 被解决时,这些回调函数将相应地执行。当 Promise 被实现,或者如果它已经被实现,success 回调将被调用,如果它被拒绝或者如果它已经被拒绝,failure 将被调用。
图 6.5 展示了 Promises 如何被拒绝或实现,以及 Promise 连续工作的方式。
图 6.5. Promise 连续基础

从图 6.5 中可以得出几点启示。首先,记住在创建 Promise 对象时,你会接受实现和拒绝回调,然后你可以使用它们来解决 Promise。调用 p.then(success, fail) 将在 Promise 被实现时执行成功,在 Promise 被拒绝时执行失败。请注意,这两个回调都是可选的,你也可以使用 p.catch(fail) 作为 p.then(null, fail) 的语法糖。
以下扩展列表显示了添加到我们之前示例中的 then 后续调用。你可以在代码示例中的 ch06/08_promise-basics 下找到它。
列表 6.16. 带有后续调用的 Promise

你可以随意多次调用 promise.then,当 Promise 被解决时,正确的分支(无论是成功还是拒绝)中的所有回调都将被调用,它们的调用顺序与它们被添加的顺序相同。如果代码是异步的,可能涉及 setTimeout 或 XMLHttpRequest,那么依赖于 Promise 结果的回调将不会执行,直到 Promise 被解决,如下面的列表所示。一旦 Promise 被解决,传递给 p.then(success, fail) 或 p.catch(fail) 的回调将立即执行,在适当的时候:如果 Promise 被实现,则只执行 success 回调,如果 Promise 被拒绝,则只执行 fail 回调。
列表 6.17. 执行 Promise
var promise = new Promise(function logic (fulfill, reject) {
console.log('Pending...');
setTimeout(function later () {
if (Math.random() < 0.5) {
fulfill('Good enough.');
} else {
reject(new Error('Dice roll failed!'));
}
}, 1000);
});
promise.then(function success (result) {
console.log('Succeeded', result);
}, function fail (reason) {
console.log('Rejected', reason);
});
除了在 Promise 对象上多次调用 .then 来创建不同的分支外,你还可以将这些回调链式连接起来,每次都改变结果。让我们来看看 Promise 链式调用。
Promise 转换链
这里的内容更难理解,但让我们一步一步来。当你链式调用回调时,它们将获得前一个回调返回的内容。考虑以下列表,第一个回调将解析 Promise 解决的 JSON 值到一个对象中,接下来的回调将打印该对象上的 buildfirst 是否为 true。
列表 6.18. 使用转换链

将回调链式调用以转换前一个值是有用的,但如果你需要链式调用异步回调,这对你就没有任何好处。你如何链式调用执行异步任务的 Promise?我们将在下一节中探讨这个问题。
6.3.2. 链式调用 Promise
你可以在回调中返回值,也可以返回其他 Promise。返回一个 Promise 有一个有趣的效果,即链中的下一个回调将等待返回的 Promise 完成。为了准备你的下一个示例,你将查询 GitHub API 获取用户列表,然后获取他们其中一个仓库的名称,让我们绘制一个 Promise 包装器,该包装器是原生浏览器 API 使用的 XMLHttpRequest 对象。
纯 AJAX 调用
关于 XMLHttpRequest 的工作原理的具体内容超出了本书的范围,但代码应该是自解释的。以下列表展示了如何使用最少的代码发起一个 AJAX 调用。
列表 6.19. 发起 AJAX 调用
var xhr = new XMLHttpRequest();
xhr.open('GET', endpoint);
xhr.onload = function loaded () {
if (xhr.status >= 200 && xhr.status < 300) {
// get me the response
} else {
// report error
}
};
xhr.onerror = function errored () {
// report error
};
xhr.send();
这只是一个传递一个端点,设置一个 HTTP 方法——在这个例子中是 GET,并对结果进行异步操作的问题。这是一个将 AJAX 转换为 Promise 的完美机会。
承诺 AJAX 数据
你不需要对代码进行任何修改,除了适当地将 AJAX 调用包裹在一个 Promise 中,并在必要时调用 resolve 和 reject。以下列表展示了一个可能的 get 实现,它通过使用 Promises 提供对 XHR 对象的访问。
列表 6.20. 承诺 AJAX

一旦这些问题都解决了,将导致仓库名称的调用序列组合起来看起来令人困惑地简单。注意你如何通过 Promises 混合异步调用,并通过使用 then 转换进行同步调用。以下是代码的样子,考虑到你实现的 get 方法:
get('https://api.github.com/users')
.catch(function errored () {
console.log('Too bad. That failed.');
})
.then(JSON.parse)
.then(function getRepos (res) {
var url = 'https://api.github.com/users/' + res[0].login + '/repos';
return get(url).catch(function errored () {
console.log('Oops! That one failed.');
});
})
.then(JSON.parse)
.then(function print (res) {
console.log(res[0].name);
});
你可以将 JSON.parse 方法打包到 get 方法中,但这似乎是一个展示如何使用 Promises 混合和匹配异步和同步操作的好机会。
如果你想要执行类似于在 第 6.2.1 节 中使用 async.waterfall 所做的操作,其中每个任务都从前一个任务的结果中获取数据,这将是非常棒的。那么,使用从 async 中获得的另一个流程控制机制呢?请继续阅读!
6.3.3. 控制流程
使用 Promises 进行流程控制可能和像 async 这样的库进行流程控制一样简单。如果你想在执行另一个任务之前等待一组 Promises,就像你使用 async.parallel 那样,你可以将 Promises 包裹在一个 Promise.all 调用中,如下面的列表所示。
列表 6.21. 承诺暂停

delay(Math.min.apply(Math, results)) Promise 将在所有之前的 Promises 成功解决之后运行;同时请注意 then(results) 如何传递一个包含每个 Promise 结果的结果数组。正如你可能从 .then 调用中推断出的,Promise.all(array) 返回一个 Promise,当 array 中的所有项目都得到解决时,它将被实现。
使用 Promise.all 在执行长时间运行的操作时特别有用,例如一系列 AJAX 调用,因为你不想如果可以一次性完成它们,却按顺序进行。如果你知道所有的请求端点,请并发而不是顺序地发出请求。然后,一旦这些请求完成,你就可以最终计算依赖于执行这些异步请求的任何内容了。
使用 Promises 进行函数式编程
当使用 Promises 执行功能任务,如 async.map 或 async.filter 等方法提供的功能时,你最好使用原生的 Array 方法。而不是求助于特定的 Promise 实现,你可以使用 .then 调用来将结果转换为所需的格式。考虑以下列表,使用与上面相同的 delay 函数,该函数接受超过 400 的结果然后进行排序。
列表 6.22. 使用 delay 函数对结果进行排序

如你所见,使用 Promises 混合同步和异步操作非常简单,即使涉及到函数操作或 AJAX 请求也是如此。到目前为止,你一直在查看成功的路径,其中一切正常,但当你使用 Promises 时,应该如何恰当地处理合理的错误处理呢?
6.3.4. 处理拒绝的 Promises
你可以通过将回调函数作为 .then(success, failure) 调用的第二个参数来提供拒绝处理程序,正如你在 第 6.3.1 节 中所检查的那样。同样,使用 .catch(failure) 可以更容易地传达意图,它是 .then(undefined, failure) 的别名。
到目前为止,我们一直在谈论显式拒绝,例如在将回调函数传递给 Promise 构造函数时显式调用 reject,但这并不是你的唯一选择。
让我们检查下面的示例,它包括错误抛出和处理。请注意,我在 Promise 中使用了 throw,尽管你应该使用更具语义的 reject 参数来显示你可以在原始 Promise 以及 then 调用中抛出异常。
列表 6.23. 捕获和抛出
function delay (t) {
function wait (fulfill, reject) {
if (t < 1) {
throw new Error('Delay must be greater than zero.');
}
setTimeout(function later () {
console.log('Resolving after', t);
fulfill(t);
}, t);
}
return new Promise(wait);
}
Promise
.all([delay(0), delay(400)])
.then(function resolved (result) {
throw new Error('I dislike the result!');
})
.catch(function errored (err) {
console.log(err.message);
});
如果你执行此示例,你会注意到 delay(0) Promise 抛出的错误将阻止成功分支的触发,因此永远不会显示 'I dislike the result!' 消息。但如果 delay(0) 不存在,那么成功分支将抛出另一个错误,这将阻止成功分支的进一步进展。
到目前为止,你已经了解了回调地狱以及如何避免它。你已经了解了使用 async 库进行异步流程控制,并且你也处理了使用 Promises 的流程控制,这在 ES6 中即将到来,但已经通过其他库和 polyfills 广泛可用。
接下来,我们将讨论 事件,这是一种异步 JavaScript 的形式,我相信你在处理 JavaScript 开发时肯定遇到过。稍后,你将了解 ES6 中关于异步流程的其他新特性。具体来说,你将了解 ES6 生成器,这是一种处理迭代器的创新特性,类似于在 C# 等语言的可枚举实现中可以找到的特性。
6.4. 理解事件
事件也被称为发布/订阅或事件发射器。事件发射器是一种模式,其中组件发射特定类型的事件并传递参数,任何感兴趣的方都可以订阅感兴趣的事件并对事件和提供的参数做出反应。存在许多不同的方法来实现事件发射器,其中大多数以某种方式涉及原型继承。但您也可以将必要的方法附加到现有对象上,正如您将在第 6.4.2 节中看到的那样。
事件在浏览器中也是原生实现的。原生事件可能是一个 AJAX 请求获取响应,人类与 DOM 交互,或者 WebSocket 仔细监听任何即将到来的动作。事件本质上是异步的,并且散布在浏览器各处,因此管理它们是您的工作。
6.4.1. 事件和 DOM
事件是网络中最古老的异步模式之一,您可以在连接浏览器 DOM 与您的 JavaScript 代码的绑定中找到它们。以下示例注册了一个事件监听器,每次文档主体被点击时都会触发:
document.body.addEventListener('click', function handler () {
console.log('Click responsibly. Do not click and drive!');
});
DOM 事件通常是由人类在浏览器窗口上点击、滚动、触摸或捏合触发的。如果它们没有被很好地抽象化,DOM 事件就很难测试。即使在下面显示的简单情况下,也要考虑匿名函数处理点击事件的含义:
document.body.addEventListener('click', function handler () {
console.log(this.innerHTML);
});
测试这种功能很困难,因为您无法独立访问事件处理器。为了便于测试,并避免模拟点击来测试处理器(您将在第八章中看到,这仍然需要在集成测试中完成),建议您将处理器提取到命名函数中,或者将逻辑的主体移动到可测试的命名函数中。这也促进了可重用性,因为如果两个事件可以以相同的方式处理。以下代码片段显示了如何提取点击处理器:
function elementClick handler () {
console.log(this.innerHTML);
}
var element = document.body;
var handler = elementClick.bind(element);
document.body.addEventListener('click', handler);
多亏了Function.prototype.bind,您将元素作为上下文的一部分保留下来。使用这种方式使用this既有利也有弊。您应该选择您最舒适的战略并坚持下去。要么始终将处理器绑定到相关元素,要么始终使用null上下文绑定处理器。一致性是可读(和可维护)代码最重要的特性之一。
接下来,您将实现自己的事件发射器,您将相关方法附加到对象上,而不使用原型,从而实现简单的实现。让我们看看这可能会是什么样子。
6.4.2. 创建您自己的事件发射器
事件发射器通常支持多种类型的事件,而不是单一的一种。让我们一步一步实现你自己的函数来创建事件发射器或改进现有对象作为事件发射器。在第一步中,你将返回对象不变,或者如果没有提供,则创建一个新对象:
function emitter (thing) {
if (!thing) {
thing = {};
}
return thing;
}
使用多个事件类型非常强大,而且只需一个对象来存储事件类型到事件监听器的映射。同样,你将为每个事件类型使用一个数组,这样你就可以将多个事件监听器绑定到每个事件类型。你还将添加一个简单的函数来注册事件监听器。以下列表(在 samples 中的 ch06/11_event-emitter 可以找到)显示了如何将现有对象转换为事件发射器。
列表 6.24. 提升对象到事件发射器状态

现在一旦创建了事件发射器,你就可以添加事件监听器了。这是它的工作方式。记住,当事件被触发时,监听器可以接收任意数量的参数;你将实现一个触发事件的方法:
var thing = emitter();
thing.on('change', function changed () {
console.log('thing changed!');
});
自然地,这就像一个 DOM 事件监听器。现在你需要实现一个触发事件的方法。没有它,就不会有事件发射器。你将实现一个 emit 方法,允许你为特定的事件类型触发事件监听器,并传递任意数量的参数。以下列表显示了它的样子。
列表 6.25. 触发事件监听器
thing.emit = function emit (type) {
var evt = events[type];
if (!evt) {
return;
}
var args = Array.prototype.slice.call(arguments, 1);
for (var i = 0; i < evt.length; i++) {
evt[i].apply(thing, args);
}
};
Array.prototype.slice.call(arguments, 1) 这个语句很有趣。在这里,你将对 arguments 对象应用 Array.prototype.slice,并告诉它从索引 1 开始。这做了两件事。它将参数对象转换为一个真正的数组,并给出一个包含所有传递给 emit 的参数的数组,除了不需要调用事件监听器的事件类型。
异步执行监听器
最后,你需要进行一个调整,那就是异步执行监听器,这样如果其中一个监听器崩溃,它们不会阻止主循环的执行。你也可以在这里使用 try/catch 块,但让我们不要在事件监听器中涉及异常;让消费者来处理。为了实现这一点,使用以下列表中所示的 setTimeout 调用。
列表 6.26. 事件发射
thing.emit = function emit (type) {
var evt = events[type];
if (!evt) {
return;
}
var args = Array.prototype.slice.call(arguments, 1);
for (var i = 0; i < evt.length; i++) {
debounce(evt[i]);
}
function debounce (e) {
setTimeout(function tick () {
e.apply(thing, args);
}, 0);
}
};
你现在可以创建发射器对象,或者将现有的对象转换为事件发射器。请注意,由于你将事件监听器包裹在超时中,如果回调抛出错误,其余的仍然会运行到完成。在事件发射器的同步实现中并非如此,因为错误会停止当前代码路径的执行。
作为一项有趣的实验,我在以下列表中使用了事件发射器,并充分利用了 Function.prototype.bind。你能告诉我它是如何工作的以及为什么吗?
列表 6.27. 使用事件发射器
var beats = emitter();
var handleCalm = beats.emit.bind(beats, 'ripple', 10);
beats.on('ripple', function rippling (i) {
var cb = beats.emit.bind(beats, 'ripple', --i);
var timeout = Math.random() * 150 + 50;
if (i > 0) {
setTimeout(cb, timeout);
} else {
beats.emit('calm');
}
});
beats.on('calm', setTimeout.bind(null, handleCalm, 1000));
beats.on('calm', console.log.bind(console, 'Calm...'));
beats.on('ripple', console.log.bind(console, 'Rippley!'));
beats.emit('ripple', 15);
显然,这是一个人为构造的例子,它并没有做什么,但有趣的是,其中两位听众控制着流程,而其他人控制着输出,并且一个单一的发射会引发一系列不可阻挡的事件。通常,你可以在附带的示例中找到这个片段的完整工作副本,位于 ch06/11_event-emitter 目录下。同时,确保阅读所有之前示例的样本!
事件发射器的强大之处在于其灵活性,一种可能的用法是反转其含义。想象一下,你控制着一个具有事件发射器的组件,并公开了发射功能,而不是“监听”功能。现在,你的组件可以通过任意消息传递,并处理它们,同时它也可能发射自己的事件,并让其他人处理它们,从而在组件之间实现有效的通信。
在本章中,我还有一个话题要讲:ES6 生成器。生成器是 ES6 中的一种特殊函数,可以懒加载迭代,并提供有趣的价值。让我们更仔细地检查它们。
6.5. 概观未来:ES6 生成器
受 Python 强烈启发的 JavaScript 生成器是一个即将到来的有趣新特性,它允许你表示值序列,如斐波那契数列,你可以对其迭代。尽管你已经有能力迭代数组,但生成器是懒加载的。懒加载是好的,因为它意味着可以创建一个无限序列生成器,并迭代它而不会陷入无限循环或栈溢出异常。生成器函数用星号表示,序列中的项必须使用yield关键字返回。
6.5.1. 创建你的第一个生成器
在下面的列表中,你将看到如何创建一个表示永不结束的斐波那契数列的生成器函数。根据定义,该序列中的前两个数字是 1 和 1,每个后续数字都是前两个数字的和。
列表 6.28. 使用斐波那契数列
function* fibonacci () {
var older = 0;
var old = 1;
yield 1;
while (true) {
yield old + older;
var next = older + old;
older = old;
old = next;
}
}
一旦你有了生成器,你可能想要消费它产生的值,为此,你需要调用生成器函数,这将给你一个迭代器。迭代器可以通过调用iterator.next()来从生成器中逐个获取值。对于使用前列表中的生成器的迭代器,该函数调用将产生一个如{ value: 1, done: false }的对象。当迭代器完成遍历生成器函数时,done属性将变为 true,但在本例中,由于无限while(true)循环,它永远不会结束。以下示例演示了如何使用永不结束的fibonacci生成器迭代几个值:
var iterator = fibonacci();
var i = 10;
var item;
while (i--) {
item = iterator.next();
console.log(item.value);
}
运行本节示例的最简单方法是访问 es6fiddle.net,它将为你运行 ES6 代码,包括使用生成器的任何内容。或者,你可以获取 Node v0.11.10 或更高版本,你可以轻松地从 nodejs.org/dist 获取。然后,在执行脚本时使用 node --harmony <file> 将启用 ES6 功能,包括生成器,例如 function* () 构造、yield 关键字和 for..of 构造,接下来我们将讨论这些。
使用 for..of 迭代
for..of 语法允许你简化遍历生成器的过程。通常你会调用 iterator.next(),存储或使用提供的 result.value,然后检查 iterator.done 以查看迭代器是否耗尽。for..of 语法为你处理这些,简化了你的代码。以下是一个使用 for..of 循环遍历生成器的表示。请注意,你使用的是一个有限生成器,因为使用像 fibonacci 这样的生成器将创建一个无限循环,除非你使用 break 退出循环:
function* keywords () {
yield 'buildfirst';
yield 'javascript';
yield 'design';
yield 'architecture';
}
for (keyword of keywords()) {
console.log(keyword);
}
在这一点上,你可能会想知道生成器如何帮助你处理异步流程,我们很快就会讨论这个问题。然而,首先,我们需要回到生成器函数并解释什么是暂停。
生成器中的执行暂停
让我们再次看看第一个生成器示例:
function* fibonacci () {
var older = 1;
var old = 0;
while (true) {
yield old + older;
older = old;
old += older;
}
}
这是如何工作的?为什么它不会陷入无限循环?每当执行 yield 语句时,生成器中的执行将被暂停并归还给消费者,传递给他们被产生的值。这就是 iterator.next() 获取值的方式。让我们通过一个简单的生成器更仔细地检查这种行为,该生成器具有副作用:
function* sentences () {
yield 'going places';
console.log('this can wait');
yield 'yay! done';
}
当你迭代一个生成器序列时,生成器中的执行将在每个 yield 调用后立即暂停(直到请求序列中的下一个项目)。这允许你执行副作用,例如上一个示例中的 console.log 语句,这是由于第二次调用 iterator.next() 的结果。以下代码片段显示了如何迭代之前的生成器:
var iterator = sentences();
iterator.next();
// <- 'going places'
iterator.next();
// logged: 'this can wait'
// <- 'yay! done'
拥有了关于生成器的全新知识后,接下来你将尝试弄清楚如何扭转局面,构建一个可以消费生成器的迭代器,从而使异步代码更容易编写。
6.5.2. 异步性和生成器
让我们构建一个迭代器,它可以很好地利用挂起,以无缝的方式结合同步和异步流程。你如何实现一个 flow 方法,允许你实现如下列表(ch06/13_generator-flow)中的功能?在这个列表中,你在需要异步执行的方法上使用 yield,然后调用一个 next 函数,这个 next 函数将由 flow 实现提供,一旦你获取了所有需要的食物类型。注意你仍然在使用回调约定,其中第一个参数要么是错误,要么是假值。
列表 6.29. 构建一个利用挂起的迭代器
flow(function* iterator (next) {
console.log('fetching food types...');
var types = yield get;
console.log('waiting around...');
yield setTimeout(next, 2000);
console.log(types.join(', '));
});
function get (next) {
setTimeout(function later () {
next(null, ['bacon', 'lettuce', 'crispy bacon']);
}, 1000);
}
要使前面的列表工作,你需要以允许 yield 语句暂停直到 next 被调用这种方式创建 flow 方法。flow 方法将接受一个生成器作为参数,例如前面列表中的那个,并遍历它。生成器应该传递一个 next 回调,这样你可以避免匿名函数,你也可以,作为替代,产生接受 next 回调的函数,并将 next 回调传递给它们。消费者可以通过调用 next() 来让迭代器知道是时候继续了。然后执行将取消挂起,并从上次停止的地方继续。
你可以在以下列表中找到 flow 函数可能实现的示例。它的工作方式与迄今为止看到的迭代器非常相似,除了它还具有让传递给 flow 的生成器函数进行序列化的能力。这种异步生成器模式的关键方面是通过允许生成器通过使用 yield 暂停(挂起)和通过调用 next 恢复(取消挂起)迭代流的来回移动。
列表 6.30. 生成器流程实现

使用 flow 函数,你可以轻松混合流程,并且使流程轻松地跳入(和跳出)异步模式。向前看,你将使用传统的 JavaScript 回调和 contra 库中的控制流组合,contra 是 async 的轻量级替代品。
6.6. 摘要
这需要覆盖很多内容,所以你可能想要休息一分钟,查看源代码示例,并稍微玩一下。
-
我们确定了回调地狱是什么,你学习了如何通过命名你的函数或组合你自己的流程控制方法来避免它。
-
你学习了如何使用
async来满足不同的需求,例如异步序列、异步映射或创建异步队列。你深入了解了 Promise 的世界。你理解了如何创建一个 Promise,如何组合多个 Promise,以及如何混合和匹配异步和同步流程。 -
你以实现无关的方式审视了事件,并学习了如何实现你自己的事件发射器。
-
我向你展示了 ES6 中生成器的未来,以及你如何可能使用它们来开发异步流程。
在第七章中,你将更深入地了解客户端编程实践。我们将讨论与 DOM 交互的当前状态,如何改进它,以及组件化开发方面的未来趋势。我们将详细说明使用 jQuery 的后果,它可能不是适合所有情况的库,以及你可以转向的一些替代方案。你还将通过 BackboneJS 这个 MVC 框架亲自动手实践。
第七章. 利用模型-视图-控制器
本章涵盖
-
比较纯 jQuery 与 MVC
-
学习 JavaScript 中的 MVC
-
介绍 Backbone
-
构建 Backbone 应用程序
-
查看服务器和浏览器中的共享视图渲染
到目前为止,我们讨论了围绕应用程序开发的话题,例如构建过程。我们还讨论了与代码相关的话题,例如连贯的异步流程和模块化应用程序设计。我们还没有涵盖应用程序本身的绝大部分,这正是本章要做的。我将解释为什么 jQuery,一个使与 DOM 交互变得更容易的流行库,可能在大型应用程序设计中有所不足,以及你可以使用哪些工具来补充它或完全替换它。你将学习使用模型-视图-控制器(MVC)设计模式开发应用程序,并在本章中创建一个管理待办事项列表的应用程序。
与模块化一样,MVC 通过分离关注点来提高软件质量。在 MVC 的情况下,这种分离分为三种类型的模块:模型(Models)、视图(Views)和控制器(Controllers)。这些部分相互连接,以将内部信息表示(模型,开发者理解的内容)与表示层(视图,用户看到的内容)以及连接这两种相同数据表示的逻辑(控制器,它还帮助验证用户数据并决定显示哪些视图)分开。
首先,我会告诉你为什么 jQuery 在大型规模应用程序设计中不足以满足需求,然后我会通过 Backbone.js 库教你关于 JavaScript 中的 MVC。这里的目的是让你开始进入现代 JavaScript 应用程序结构设计的奇妙世界,而不是让你成为 Backbone 的大师。
7.1. jQuery 是不够的
自从 jQuery 库问世以来,它通过做好几件事情,帮助了几乎每一位在世的网络开发者。它解决了不同浏览器版本中的已知错误,并在浏览器之间标准化了 Web API,为消费者提供了一个灵活的 API,该 API 提供一致的结果,使其易于使用。
jQuery 帮助普及了 CSS 选择器作为在 JavaScript 中查询 DOM 的首选方法。原生的querySelector DOM API 与 jQuery 查询类似,允许你使用 CSS 选择器字符串搜索 DOM 元素。然而,仅凭 jQuery 是不够的。让我们来讨论一下原因。
代码组织和 jQuery
jQuery 并不提供组织代码库的方法,这是可以接受的,因为 jQuery 并不是为此而设计的。尽管 jQuery 使访问原生 DOM API 变得更加简单,但它并没有努力执行将应用程序结构化的必要任务。仅依赖 jQuery 对于不需要结构的传统 Web 应用程序来说是可行的,但对于开发单页应用程序来说并不合适,因为单页应用程序往往拥有更大和更复杂的客户端代码库。
另一个原因,即使今天 jQuery 仍然如此受欢迎,是因为它是一个与其他库兼容性良好的库。这意味着你不必强迫自己在所有事情上都使用 jQuery。相反,你可以将其与其他库结合使用,这些库可能旨在增强 jQuery,也可能不是。你也可以单独使用 jQuery 而不依赖其他库。除非你将 jQuery 与 MVC 库或框架搭配使用,否则很难开发出不会随着时间的推移变成维护噩梦的模块化组件。
MVC 模式将应用程序的关注点分为视图、模型和控制器;这些组件相互交互和协作以服务于应用程序。你开发的大部分逻辑都变得自包含,这意味着一个复杂的视图不会转化为一个复杂的应用程序,这使得它成为开发可扩展应用程序的一个很好的选择。MVC 诞生于 20 世纪 70 年代末,但直到 2005 年 Ruby on Rails 的出现才进入 Web 应用程序领域。2010 年,Backbone 发布,将 MVC 带入了客户端 JavaScript 应用程序开发的主流。如今,JavaScript 开发 MVC Web 应用程序有数十种替代方案。
视图模板
首先,你有 HTML;我们可以称之为 视图。这是定义你的组件外观及其在用户界面上的表示方式。这也是你定义数据片段将放置在哪里的方式。如果你只使用 jQuery,那么你必须手动创建构成你的组件的 DOM 元素,包括它们相应的 HTML 属性值和内文。通常情况下,你会使用模板引擎,它接受一个模板字符串(在这种情况下是 HTML)和数据,并使用这些数据填充模板。模板中可能有部分是循环遍历数组并为数组中的每个项目创建一些 HTML 元素。这种代码在纯 JavaScript 中编写起来很繁琐,即使你使用 jQuery 也是如此。如果你使用模板库,你不必担心这一点,因为引擎会为你处理。 展示了模板作为可重用组件的工作方式。
图 7.1. 使用不同的模板数据模型重用模板

使用控制器
然后是功能,为您的视图赋予生命;我们称之为控制器。这就是您如何使静态 HTML 模板获得生命力的方式。在控制器中,您会执行诸如将 DOM 事件绑定到特定操作或当发生某些事情时更新视图等操作。这用 jQuery 来做很容易;您向 DOM 添加事件,然后就可以了,对吧?对于一次性绑定来说,这很好,但如果你想要开发一个像之前看到的视图一样,并且将事件绑定到渲染的 HTML 上的组件,怎么办呢?
对于这个场景,您需要一个方法来一致地创建 DOM 结构,将其绑定到事件,对变化做出反应,并更新它。您还需要它能够独立工作,因为这是一个可重用的组件,您希望它在应用程序的许多地方都能工作。坦白说,您最终会慢慢地编写自己的 MVC 框架。这很好,作为一个学习练习。事实上,这正是我理解并重视 JavaScript 中 MVC 的方式。我为一个宠物项目,我的博客,编写了自己的 MVC 引擎,这就是我走上学习更多关于 JavaScript 中其他 MVC 引擎的道路。另一种选择是使用现有的(并且经过验证的)MVC 框架。
本指南解释了 MVC 模式的工作原理,它如何帮助开发复杂的应用程序,以及为什么需要它。在 7.2 节中,您将了解它如何在 JavaScript 中应用。您将查看不同的库,这些库有助于编写 MVC 代码,然后您将选择 Backbone。正如您所期望的,MVC 模式规定您的应用程序应该分为
-
持有渲染视图所需信息的模型
-
负责渲染模型并允许人与之交互的视图
-
在渲染相关视图之前填充模型并管理人与组件交互的控制器
图 7.2 展示了典型 MVC 应用程序设计中不同元素之间的交互。
图 7.2。MVC 模式将关注点分为控制器、视图和模型。

模型
模型定义了视图需要传达的信息。这些信息可以从一个服务中提取,该服务反过来从数据库源获取数据,正如我们在第九章中讨论 REST API 设计和服务层时将要介绍的。模型包含原始数据,但模型中没有逻辑;它们是相关数据的静态集合。模型也不知道如何显示这些数据。这个关注点留给视图和视图本身。
视图
视图是模板和数据表示的组合,模板为模型的数据表示提供结构,而模型则包含实际数据。模型可以在不同的视图中重用,并且通常可以重用。例如,“文章”模型可以在“搜索”视图和“文章列表”视图中使用。将视图模板与视图模型结合,可以得到一个视图,然后可以将其用作对 HTTP 请求的响应。
控制器
控制器决定要渲染哪个视图,这是它们的主要目的之一。控制器将决定要渲染的视图,准备一个包含视图模板所需所有相关部分和片段的视图模型,并让视图引擎使用提供的模型和模板渲染视图。你可以使用控制器为视图添加行为,响应特定动作,或将人类用户重定向到另一个视图。
路由器
视图路由是 Web 中 MVC(模型-视图-控制器)模式的一个基本组成部分,尽管它并不包含在其名称中。视图路由是 MVC 应用程序中第一个被请求击中的组件。路由器通过遵循先前定义的规则将 URL 模式与控制器动作相匹配。规则在代码中定义,并根据条件捕获请求:“每当有对/articles/{slug}的请求时,通过Articles控制器路由该请求,调用getBySlug动作,并传递slug参数”(slug是从请求的 URL 中插入的)。然后路由器将任务委托给控制器,控制器将验证请求,决定要渲染的视图,渲染它,重定向到其他 URL,并执行类似操作。规则按顺序评估。如果请求的 URL 不匹配规则的模式,它将简单地忽略请求,并评估下一个规则。
让我们更深入地探讨 JavaScript MVC,这贯穿了本章的其余部分。
7.2. 模型-视图-控制器在 JavaScript 中
MVC 模式并不是什么新鲜事物,尽管在过去十年中,它的采用率显著增加,尤其是在客户端 Web 领域,这个领域传统上完全缺乏任何结构。在本节中,我将解释为什么我选择 Backbone 作为我的首选教学武器,以及为什么我放弃了其他考虑的选项。在第 7.3 节中,我将通过 Backbone 解释 MVC 的基础。然后在第 7.4 节中,你将深入一个案例研究,其中你将使用 Backbone 开发一个小型应用程序,以便你可以学习如何使用它来构建可扩展的应用程序。在第九章中,你将把 Backbone 提升到下一个层次,以及迄今为止你所学到的所有内容,并使用它来完善一个更大的应用程序。
7.2.1. 为什么选择 Backbone?
存在许多不同的框架和库用于执行客户端 MVC,更不用说服务器端 MVC 了,遗憾的是我无法涵盖所有这些。我为这本书必须做出的最艰难的选择之一是选择一个 MVC 框架来使用。有一段时间,我在 React、Backbone 和 Angular 之间犹豫不决。最终,我决定 Backbone 是教授我想传达给您的概念的最佳工具。做出那个选择并不容易,这主要取决于成熟度、简单性和熟悉性。Backbone 是现有的最古老的 MVC 库之一,因此也是最成熟的之一。它也是最流行的 MVC 库之一。Angular 是 Google 开发的 MVC 框架。它也很成熟——事实上,它是在 Backbone 之前发布的——但它也更复杂,学习曲线陡峭。React 是 Facebook 的解决方案;它不像 Angular 那样复杂,但它是一个更年轻的项目,最初于 2013 年发布,并且它不提供真正的 MVC 功能,因为它旨在只提供 MVC 中的视图。
Angular 引入了一些概念,一开始可能难以理解,我不想在书的剩余部分解释这些概念。我觉得 Angular 可能会妨碍教授如何编写 MVC 代码,我可能不得不教授如何编写 Angular 代码。最重要的是,我进入时的一个要求是展示如何进行共享渲染,在服务器和浏览器中重用相同的逻辑来渲染您的视图,跨越整个堆栈,而 Angular 并不是当您想要同时具有服务器端和客户端渲染时最好的解决方案,因为它并不是在这种限制下开发的。我们将在第 7.5 节中探讨共享渲染。
理解渐进增强
渐进增强是一种技术,它帮助为使用您网站的每个人提供可用的体验。这种技术建议您优先考虑内容,然后逐步添加增强功能,例如额外的功能,到内容中。因此,渐进增强的应用程序必须提供页面的全部内容,而不依赖于客户端 JavaScript 来渲染视图。一旦向用户提供这种最小化可消化的内容,就可以通过检测用户浏览器可用的功能来逐步增强体验。在提供这种初始体验之后,我们可能通过客户端 JavaScript 提供单页应用程序体验。
在这种哲学下开发应用程序有几个好处。因为您优先考虑内容,所以访问您网站的每个人都可以获得基本体验。这并不意味着禁用 JavaScript 的人可以查看您的网站,但意味着在移动网络上数据漫游的人可以更快地看到内容。此外,如果 JavaScript 资源的请求加载失败,至少他们可以访问您网站的可读版本。
你可以在我的博客上阅读更多关于渐进增强的内容,请访问 ponyfoo.com/articles/tagged/progressive-enhancement。
React 引入的复杂性比 Backbone 更高,并且它不像 Angular 和 Backbone 那样提供真正的 MVC 解决方案。React 帮助你编写视图,提供模板化功能,但如果你想将其作为 MVC 引擎独家使用,则需要你做更多的工作。
Backbone 更容易渐进式学习。你不需要使用其中的每个功能来构建一个简单的应用程序。随着你的进步,你可以在 Backbone 中添加更多组件,并包含额外的功能,如路由,但你甚至不需要知道这些功能,直到你需要它们。
7.2.2. 安装 Backbone
在第五章中,你使用 CommonJS 编写了客户端代码。稍后,你将编译这些模块,以便浏览器可以解释它们。下一节将专门介绍使用 Grunt 和 Browserify 实现自动化编译过程。现在,让我们谈谈 Backbone。你首先要做的是通过 npm 安装它,如下所示。
记住,如果你没有 package.json 文件,你应该使用 npm init. 命令创建一个。如果你在 Node.js 应用程序方面遇到困难,请查看附录 A。
npm install backbone --save
Backbone 需要一个 DOM 操作库,如 jQuery 或 Zepto,才能正常工作。在你的示例中,你将使用 jQuery,因为它更广为人知。如果你考虑将此设置用于生产级应用程序,我建议你查看 Zepto,因为它具有显著更小的体积。让我们继续安装 jQuery:
npm install jquery --save
一旦你有了 Backbone 和 jQuery,你就可以开始组装应用程序了。你将要编写的第一行代码是用来设置你的 Backbone 库。Backbone 需要在使用之前将一个类似 jQuery 的库分配给 Backbone.$,所以你需要这样做:
var Backbone = require('backbone');
Backbone.$ = require('jquery');
Backbone 将使用 jQuery 与 DOM 交互,附加和删除事件处理器,以及执行 AJAX 请求。这就是启动和运行的所有内容。
是时候看看 Browserify 的实际应用了!我将带你通过设置 Grunt 来编译浏览器代码。一旦这个问题解决,你就可以浏览下一节中的示例。
7.2.3. 使用 Grunt Browserify Backbone 模块
你已经在第五章的 5.3.3 节中接触到了如何使用 Browserify 模块。以下列表显示了当时 Browserify 的 Gruntfile 配置。
列表 7.1. Browserify 的 Gruntfile 配置
{
browserify: {
debug: {
files: { 'build/js/app.js': 'js/app.js' },
options: {
debug: true
}
}
}
}
这次,让我们对那个配置进行两个小的调整。第一个调整是因为你想要监视变化并让 Grunt 重新构建包。这使我们能够实现持续、快速的开发,正如我们在第三章中讨论的那样。要监视变化,你可以使用我们在第三章中讨论的grunt-contrib-watch,使用如下代码中的配置:
{
watch: {
app: {
files: 'app/**/*.js',
tasks: ['browserify']
}
}
tasks属性包含任何在匹配的files发生变化时应运行的任务。
另一种调整使用了一种称为转换的东西。转换允许 Browserify 更改你的模块中的源代码,在将代码运行在浏览器上时更好地调整以满足你的需求。在你的情况下,要包含的转换被称为brfs,代表“浏览器文件系统”。这个转换将fs.readFileSync调用的结果内联,使得将视图模板与 JavaScript 代码分离成为可能。考虑以下模块:
var fs = require('fs');
var template = fs.readFileSync(__dirname + '/template.html', {
encoding: 'utf8'
});
console.log(template);
那段代码无法转换以在浏览器中运行,因为浏览器无法访问你的服务器文件系统中的文件。为了解决这个问题,你可以在 Grunt 配置选项中添加brfs到grunt-browserify的转换列表中。brfs转换将读取由fs.readFile和fs.readFileSync语句引用的文件,并将它们内联到你的包中,使得它们可以在 Node 或浏览器中无缝工作:
options: {
transform: ['brfs'],
debug: true
}
你还需要在你的本地项目中安装brfs包,以下代码为安装命令:
npm install brfs --save-dev
就这些了,关于使用 Grunt Browserify 你的 CommonJS 模块!接下来,我将向你介绍 Backbone 的主要概念,它们是如何工作的,以及何时使用它们。
7.3. Backbone 简介
Backbone 中存在一些结构,你可以围绕它们构建你的应用程序。以下是一个列表:
-
视图负责渲染 UI 并处理人机交互。
-
模型可以用来跟踪、计算和验证属性。
-
集合是有序的模型集合,用于与列表交互。
-
路由器允许你控制 URL,从而能够开发单页应用程序。
你可能已经注意到列表中找不到控制器。实际上,Backbone 视图充当控制器。这种从传统 MVC 中微妙的偏离通常被称为模型-视图-视图-模型(MVVM)。图 7.3 说明了 Backbone 与传统 MVC 之间的差异,正如它们在第 7.2 节图 7.2 中所示,并解释了路由在这个结构中的位置。
图 7.3. Backbone 处理 MVC 中面向人类的部分:事件处理、验证和 UI 渲染。

自然地,关于这些结构中的每一个,都有更多东西可以学习。让我们逐一访问它们。
7.3.1. Backbone 视图
视图负责渲染 UI,而你则负责为你的视图组合渲染逻辑。如何渲染 UI 完全取决于你。两种首选的选项是使用 jQuery 或模板库。
视图总是与一个元素相关联,渲染将在这里进行。在下面的列表中,让我们看看一个基本视图是如何被渲染的。在这里,你创建了一个 Backbone 视图,添加了自定义的渲染功能,这将设置视图元素中的文本。然后你实例化了视图并渲染了视图实例。
列表 7.2. 渲染基本视图
var SampleView = Backbone.View.extend({
el: '.view',
render: function () {
this.el.innerText = 'foo';
}
});
var sampleView = new SampleView();
sampleView.render();
你看你是如何声明el属性并将其赋值为.view的吗?你可以将一个 CSS 选择器赋给这个属性,然后它会在 DOM 中查找这个元素。在这个视图中,这个元素将被分配给this.el.。使用一个 HTML 页面,例如以下页面,你可以渲染这个最小的 Backbone 视图:
<div class='view'></div>
<script src='build/bundle.js'></script>
如我之前在 7.2.3 节中解释的,捆绑脚本文件将是编译后的应用程序。一旦运行,视图元素将获得foo文本内容。你可以在配套的源代码中查看这个示例;它列在 ch07/01_backbone-views 中。
你的视图是静态的,你可能已经知道如何使用 jQuery 渲染一个视图,但这需要更多的工作,因为你必须创建每个元素,设置它们的属性,并在代码中构建一个 DOM 树。使用模板更容易维护,并且可以将你的关注点分开。让我们看看它是如何工作的。
使用 Mustache 模板
Mustache 是一个视图模板库,它接受一个模板字符串和一个视图模型,并返回生成的视图。你在模板中引用模型值的方式是通过使用特殊的{{value}}符号来声明它们,这个符号将被模型的value属性所替换。
Mustache 还使用类似的语法,允许你遍历数组,将模板的一部分包裹在{{#collection}}和{{/collection}}中。当遍历一个集合时,你可以使用{{.}}访问数组项本身,你也可以直接访问它的属性。
为了给你一个快速示例,让我们从一个 HTML 视图模板开始:
<p>Hello {{name}}, your order #{{orderId}} is now shipping. Your order includes:</p>
<ul>
{{#items}}
<li>{{.}}</li>
{{/items}}
</ul>
为了填充这个模板,你需要使用 Mustache,将其传递给一个模型。首先,你将不得不从 npm 安装 Mustache:
npm install mustache --save
渲染这个模板只是将其作为一个字符串传递给 Mustache,以及一个视图模型:
var Mustache = require('mustache');
Mustache.to_html(template, viewModel);
在 Backbone 中做这件事,你需要创建一个可重用的模块,如下面的代码片段所示,它将知道如何使用 Mustache 渲染任何视图,将视图的模板和视图的视图模型传递给它。在这里,你创建了一个可以供其他视图继承的基础视图,共享基本功能,如视图渲染,这样你就不必在每个创建的视图中复制和粘贴这个方法:
var Backbone = require('backbone');
var Mustache = require('mustache');
module.exports = Backbone.View.extend({
render: function () {
this.el.innerHTML = Mustache.to_html(this.template, this.viewModel);
}
});
在上一个示例中,你有一个静态视图,将所有应用程序放在一个模块中是完全可以的。但这次,你稍微模块化了它。有一个基本视图是很好的,为每个视图有一个单独的模块同样重要。在下面的代码片段中,你正在要求之前看到的基视图模板并扩展它。你使用 fs.readFileSync 来加载你的 Mustache 模板,因为 require 只适用于 JavaScript 和 JSON 文件。你不会在视图模块本身中包含模板,因为这总是很好地将你的关注点分开,尤其是如果这些关注点在不同的语言中。此外,视图模板可能被许多不同的视图使用。
var fs = require('fs');
var base = require('./base.js');
var template = fs.readFileSync(
__dirname + '/templates/sample.mu', 'utf8'
);
module.exports = base.extend({
el: '.view',
template: template
});
最后,你需要调整你的原始应用程序模块,使其需要视图而不是声明视图,并在渲染视图之前声明视图模型。这一次,视图将通过 Mustache 渲染,如下面的列表所示。
列表 7.3. 使用 Mustache 渲染视图
var SampleView = require('./views/sample.js');
var sampleView = new SampleView();
sampleView.viewModel = {
name: 'Marian',
orderId: '1234',
items: [
'1 Kite',
'2 Manning Books',
'7 Random Candy',
'3 Mars Bars'
]
};
sampleView.render();
你可以在附带的代码示例中查看这个示例;它在 ch07/02_backbone-view-templates 中列出。接下来是模型,Backbone 应用程序的另一个关键部分。
7.3.2. 创建 Backbone 模型
Backbone 模型(也称为数据模型)存储你的应用程序数据,这些数据通常是数据库中可以找到的数据的副本。它们可以用来观察变化,以及验证这些变化。这些不要与视图模型(例如我们在上一个示例中分配给 sampleView.viewModel 的视图模型,也称为模板数据)混淆,视图模型通常包含 Backbone 数据模型的组合,通常格式化为适合 HTML 模板的文本。例如,日期可能以 ISO 格式存储在数据模型中,但在模板数据中格式化为可读的字符串。同样,视图从 Backbone.View 扩展,模型从 Backbone.Model 扩展,它们可以大大提高你的数据交互性。模型可以进行验证,测试用户输入的坏数据;它们可以观察,帮助你响应数据模型中的变化;你还可以根据模型中的数据计算属性。
你可以用你的模型做的最有影响力的事情可能是观察模型数据的变化。这允许你的用户界面几乎不费吹灰之力地对数据的变化做出反应。记住,同一份数据可以用许多不同的方式表示。例如,你可以将同一份数据表示为列表中的一个条目、一张图片或一个描述。模型使你能够实时更新这些表示,随着数据的变化而变化!
数据建模和可塑性
让我们看看一个例子(在samples/ch07/03_backbone-models中找到),在这个例子中,你将用户输入的内容渲染为纯文本、二进制格式,如果它是 URL,则渲染为锚点链接。为了开始,你需要创建一个模型来检查其数据是否看起来像链接。get方法允许你在 Backbone 中访问模型属性的值。
module.exports = Backbone.Model.extend({
isLink: function () {
var link = /^https?:\/\/.+/i;
var raw = this.get('raw');
return link.test(raw);
}
});
假设你有一个binary.fromString方法来将模型数据转换为二进制字符串,并且你想要获取二进制流的第一个几个字符,你可以为这个添加一个模型方法,因为它也涉及到数据。一般来说,每个可以重用且仅(或主要)依赖于模型数据的方法可能都应该是一个模型方法。以下是一个获取二进制字符串的可能实现。如果二进制代码超过 20 个字符,你可以使用 Unicode 省略号字符'\u2026'或'...'进行截断:
getBinary: function () {
var raw = this.get('raw');
var bin = binary.fromString(raw);
if (bin.length > 20) {
return bin.substr(0, 20) + '\u2026';
}
return bin;
}
我提到你可以监听模型中的变化。让我们更深入地了解事件。
模型和事件
将你的视图与该模型关联,你需要创建该模型的一个实例。模型最有趣的特点之一是事件。例如,你可以监听模型中的变化,并在模型每次发生变化时更新你的视图。你可以使用视图的initialize属性来创建模型实例,将其绑定到变化监听器上,并给模型提供一个初始值,如下面的代码片段所示:
initialize: function () {
this.model = new SampleModel();
this.model.on('change', this.updateView, this);
this.model.set('raw', 'http://bevacqua.io/buildfirst');
}
而不是从外部渲染视图,视图将在模型发生变化时根据需要自行重新渲染。实际上,这很容易实现。每当模型发生变化时,updateView会被调用,你就有机会更新视图模型并使用更新后的值渲染模板。
updateView: function () {
this.viewModel = {
raw: this.model.get('raw'),
binary: this.model.getBinary(),
isLink: this.model.isLink()
};
this.render();
}
你视图剩下的工作就是允许用户输入修改模型。你可以通过在视图的events属性上添加属性来方便地绑定到 DOM 事件。这些属性应该具有{event-type} {element-selector}形式的键;例如,click .submit-button。属性值应该是视图中可用的事件处理器的名称。在以下代码片段中,我实现了一个事件处理器,每次输入更改时都会更新模型:
events: {
'change .input': 'inputChanged'
},
inputChanged: function (e) {
this.model.set('raw', e.target.value);
}
每当触发一个变化事件时,模型数据将被更新。这反过来会触发模型的变化事件监听器,它将更新视图模型并刷新 UI。请注意,如果其他任何东西改变了模型数据,例如传入的服务器数据,UI 也会相应地刷新。这就是使用模型的价值所在。随着你的数据变得更加复杂,你可以从使用模型来访问数据中受益更多,因为它们能够跟踪并响应数据的变化,这样你的代码就不会紧密耦合。
这是模型帮助塑造你的数据而不在代码中重复逻辑的一种方式,在接下来的几节中,我们将仔细检查模型的好处,如数据验证。你还需要关注数据组织的最后一个方面,那就是集合。在我们转向视图路由之前,让我们快速了解一下这些内容。
7.3.3. 使用 Backbone 集合组织模型
Backbone 中的集合允许你分组和排序一组模型。你可以监听集合中项的添加或移除,甚至可以在集合中的任何模型被修改时收到通知。同样,模型在计算其属性中的数据时很有帮助,集合除了处理类似 CRUD(创建、读取、更新、删除)的操作外,还关注于查找特定的模型。
集合需要一个模型类型,这样你就可以使用普通对象向其中添加值,这些值在内部被转换为该模型类型。例如,在下面的代码片段中创建的集合,每次你向其中添加项时都会创建SampleModel实例。集合的示例可以在 ch07/04_backbone-collections 找到。
var SampleModel = require('../models/sample.js');
module.exports = Backbone.Collection.extend({
model: SampleModel
});
与模型或视图类似,集合需要被实例化,你才能利用它们。为了使这个例子简短,你的视图将创建这个集合的实例,监听插入操作,并将模型添加到集合中。toJSON方法将你的集合转换为普通的 JavaScript 对象,可以在渲染模板时用于获取模型数据,如下面的列表所示。
列表 7.4. 获取模型数据
initialize: function () {
var collection = new SampleCollection();
collection.on('add', this.report);
collection.add({ name: 'Michael' });
collection.add({ name: 'Jason' });
collection.add({ name: 'Marian' });
collection.add({ name: 'Candy' });
this.viewModel = {
title: 'Names',
people: collection.toJSON()
};
this.render();
},
report: function (model) {
var name = model.get('name');
console.log('Someone got added to the collection:', name);
}
集合也可以在插入模型时对其进行验证,正如你将在第 7.4 节中看到的。但在到达那里之前,你的清单上还有最后一项。我指的是 Backbone 路由器。
7.3.4. 添加 Backbone 路由器
现代 Web 应用程序越来越多地成为单页应用程序,这意味着网站只加载一次,这导致服务器往返次数减少,客户端代码接管。客户端的路由可以通过更改 URL 中的 hash 后面的内容或使用路径如#/users或#/users/13来处理。在现代浏览器中,可以使用 History API 来修改,这允许你更改 URL 而不必求助于 hash hack,从而产生更干净的链接,就像网站从服务器获取页面一样。在 Backbone 中,你可以定义和实例化路由器,它们有两个作用:更改 URL 以给人类提供一个永久链接,他们可以使用它来导航到应用程序的一部分,以及当 URL 更改时采取行动。
图 7.4 显示了路由器如何跟踪应用程序的状态。
图 7.4. Backbone 中的路由和路由检查器

正如你在第 7.1 节中学到的,路由器是应用程序中人类接触的第一步。传统的路由器定义规则将请求路由到特定的控制器动作。在 Backbone 的情况下,控制器中介不存在,请求直接路由到视图,视图既扮演控制器的角色,也提供视图模板和渲染逻辑。Backbone 路由器检查location的变化并调用动作方法,向它们提供相关的 URL 参数。
路由更改
以下代码片段(作为 ch07/05_backbone-routing 提供)实例化了一个视图路由器,并使用Backbone.history.start开始监控 URL 的变化。它还会检查当前 URL 是否与已定义的某个路由匹配,如果是,则触发该路由:
var ViewRouter = require('./routers/viewRouter.js');
new ViewRouter();
$(function () {
Backbone.history.start();
});
就线路连接而言,你需要做的就这些。现在让我们编写你的ViewRouter组件。
路由模块
路由器负责将每个 URL 连接到动作。通常,你会构建你的应用程序,使得动作方法要么准备一个视图并渲染它,要么执行一些渲染视图的操作,例如导航到不同的路由。在以下代码片段中,我创建了一个具有不同路由的路由器:
var Backbone = require('backbone');
module.exports = Backbone.Router.extend({
routes: {
'': 'root',
'items': 'items',
'items/:id': 'getItemById'
}
});
当人类访问应用程序根目录时,第一条路由会触发他们重定向到默认路由,如下面的代码片段所示。在这种情况下,那就是items路由。这确保了如果用户在根级别而不是在#items或/items(如果你使用历史 API)访问页面,他们不会迷路。trigger选项告诉navigate更改 URL 并触发该路由的动作方法。接下来,我们应该将root方法添加到传递给Backbone.Router.extend的对象中:
root: function () {
this.navigate('items', { trigger: true });
}
只要所有视图都渲染到相同的视图容器中,在特定动作触发时实例化视图就足够了,如下面的代码片段所示:
items: function () {
new ItemView();
}
你需要在路由模块的顶部require视图,如下所示:
var ItemView = require('../views/item.js');
最后,你可能注意到getItemById路由有一个名为:id的命名参数。路由器将在视图中解析 URL,匹配items/:id模式,并调用你的动作方法,将id作为参数传递。然后,在渲染视图时使用该参数。
getItemById: function (id) {
new DetailView(id);
}
这就是视图路由的全部内容!在第 7.4 节中,你将扩展所有这些概念来构建一个小型应用程序。接下来,让我们调查如何使用你新学到的 Backbone 知识在浏览器中构建第一个使用 MVC 的应用程序。
7.4. 案例研究:购物清单
在你开始开发自己的应用程序之前,我想给你一个使用 Backbone 在浏览器中编写 MVC 的独立示例,将本章中到目前为止所学的一切付诸实践。
在本节中,您将逐步构建一个简单的购物清单应用程序,该应用程序允许您查看购物清单项目、从列表中删除它们、添加新的项目以及更改数量。我已经将练习分为五个阶段。在每一个阶段,您将添加功能并对到目前为止的内容进行重构,以保持代码整洁。这五个阶段是
-
创建带有购物清单项目的静态视图
-
添加删除按钮以删除项目
-
构建一个表单以添加新的购物清单项目
-
在列表中实现内联编辑以更改数量
-
添加视图路由
这听起来很有趣!请记住,您可以在附带的代码示例中的任何五个阶段访问代码。
7.4.1. 从静态购物清单开始
让我们回到基础,从头开始构建应用程序。Gruntfile 与 7.2.3 节 中的相同,在本案例研究的过程中不会改变,所以您不需要重新访问它。查看 列表 7.5 中的 HTML(作为 ch07/06_shopping-list 提供)以开始。请注意,您正在包含构建的 Browserify 包,以便在浏览器中运行 Common.js 代码。在这个例子中,<div> 将作为视图容器。这个 HTML 片段被称为 app.html,因为它是应用程序将运行的单一页面。
列表 7.5. 创建购物清单
<!doctype html>
<html>
<head>
<title>Shopping List</title>
</head>
<body>
<h1>Shopping List</h1>
<div class='view'></div>
<script src='build/bundle.js'></script>
</body>
</html>
接下来,这个示例需要渲染购物项目列表,显示每个项目的数量和名称。这里有一个可以渲染购物清单项目数组的 Mustache 片段。Mustache 模板将放入 views/templates 目录。
<ul>
{{#shopping_list}}
<li>{{quantity}}x {{name}}</li>
{{/shopping_list}}
</ul>
您的视图需要使用视图模型来渲染这些模板。这个功能应该放在基本视图中,以确保它只实现一次。
使用 Mustache 渲染视图
为了在视图中轻松渲染 Mustache 模板并避免重复,您将连接一个基本视图并将其放置在 views 目录中。您的其余视图将扩展这个视图,允许您添加跨每个视图共享的功能。如果视图需要以其他方式渲染,那也是可以的;您可以再次覆盖 render 方法。
var Backbone = require('backbone');
var Mustache = require('mustache');
module.exports = Backbone.View.extend({
render: function () {
this.el.innerHTML = Mustache.to_html(this.template, this.viewModel);
}
});
接下来,您将为您的列表视图创建项目。
购物清单视图
目前一个静态的购物清单就足够了,这就是为什么在下面的列表中,您可以设置一次视图模型对象并忘记它。注意 initialize 方法,它在视图实例化时运行,以便在创建时视图会自行渲染。这个视图使用您之前看到的模板,并针对 app.html 中的 .view 元素。
列表 7.6. 创建项目列表
var fs = require('fs');
var base = require('./base.js');
var template = fs.readFileSync(
__dirname + '/templates/list.mu', { encoding: 'utf8' }
);
module.exports = base.extend({
el: '.view',
template: template,
viewModel: {
shopping_list: [
{ name: 'Banana', quantity: 3 },
{ name: 'Strawberry', quantity: 8 },
{ name: 'Almond', quantity: 34 },
{ name: 'Chocolate Bar', quantity: 1 }
]
},
initialize: function () {
this.render();
}
});
最后,您需要初始化应用程序。这是入口点代码,在初始化 Backbone 后创建 List 视图的实例。请注意,因为视图会自行渲染,所以您只需要实例化它。
var Backbone = require('backbone');
Backbone.$ = require('jquery');
var ListView = require('./app/views/list.js');
var list = new ListView();
你已经为购物清单应用程序奠定了基础。让我们在下一阶段在此基础上构建。你将添加删除按钮,并重构以适应数据可以变化的应用程序。
7.4.2. 这次带有删除按钮
在这个阶段,你首先需要更新视图模板,使其包括从购物清单中移除项目的按钮。你将在按钮上设置一个 data-name 属性,以便你可以识别应该从列表中移除哪个项目。更新的模板可以在下面的代码片段中找到:
<ul>
{{#shopping_list}}
<li>
<span>{{quantity}}x {{name}}</span>
<button class='remove' data-name='{{name}}'>x</button>
</li>
{{/shopping_list}}
</ul>
在连接删除按钮之前,你需要设置一个合适的模型和集合。
使用模型和集合
这个集合将允许你监听列表的变化,例如当列表中的某个项目被移除时。该模型可以用于跟踪个体层面的变化,并且它允许你在接下来的几个阶段进行验证以及计算。就你的目的而言,你不需要比标准的 Backbone 模型更多,但始终将你的模型严格分离在不同的模块中,并给予良好的命名是个好主意。ShoppingItem 模型将位于 models 目录中。
var Backbone = require('backbone');
module.exports = Backbone.Model.extend({
});
集合也没有那么特别;它需要一个对模型的引用。这样,集合就知道在将新对象插入列表时应该创建什么类型的模型。为了保持整洁的目录结构,你将你的集合放在 collections 目录中。
var Backbone = require('backbone');
var ShoppingItem = require('../models/shoppingItem.js');
module.exports = Backbone.Collection.extend({
model: ShoppingItem
});
而不是设置一次视图模型就不再理会它,现在你已经有了模型和集合,你应该更改你的视图以使用集合。在你的视图中,你将做出的第一个更改是 require 集合,如下面的代码所示:
var ShoppingList = require('../collections/shoppingList.js');
而不是使用 viewModel 属性,从现在起你将动态设置它,你将使用 collection 属性来跟踪你的模型。请注意,正如我之前提到的,我不需要明确创建 ShoppingList 实例,因为我的集合已经知道它必须使用该模型类型。
collection: new ShoppingList([
{ name: 'Banana', quantity: 3 },
{ name: 'Strawberry', quantity: 8 },
{ name: 'Almond', quantity: 34 },
{ name: 'Chocolate Bar', quantity: 1 }
])
接下来,你将让视图在首次加载时更新 UI。为此,你将设置视图模型为集合中的任何内容,然后渲染视图。使用 toJSON 方法会得到一个包含模型对象的普通数组。
initialize: function () {
this.viewModel = {
shopping_list: this.collection.toJSON()
};
this.render();
}
最后,你将连接你的购物清单项目删除按钮。
在 Backbone 中连接 DOM 事件
要监听 DOM 事件,你可以在你的视图中为 events 对象分配属性。这些属性的命名应使用事件名称和 CSS 选择器,两者之间用空格分隔。以下是你将在更新后的视图中使用的代码。它会在匹配 .remove 选择器的元素上发生 click 事件时触发一个动作。请注意,这些事件在视图 el 内部查找元素,在本例中是你在上一个阶段创建的 <div>,它不会为视图外部的元素触发事件。最后,事件应设置为可在视图中找到的方法名称。
events: {
'click .remove': 'removeItem'
}
现在让我们定义 removeItem。你将使用集合过滤方法。按钮可以通过 e.target 访问,你将使用它的 data-name 属性来获取名称。然后你将使用该名称来过滤集合,以找到与该特定按钮关联的购物清单项目。
removeItem: function (e) {
var name = e.target.dataset.name;
var model = this.collection.findWhere({ name: name });
this.collection.remove(model);
}
一旦模型从集合中删除,视图应再次更新。直观的方法是在从集合中删除项目后更新视图模型并重新渲染视图。问题是项目可能在不同地方从集合中删除,尤其是在应用程序很好时。更好的方法是监听集合发出的事件。在这种情况下,你可以监听集合中的 remove 事件,并在该事件被触发时刷新视图。
以下列表在初始化视图时设置事件监听器,并包括重构,以避免代码重复,保持对 DRY 原则的忠诚。
列表 7.7. 设置事件监听器
initialize: function () {
this.collection.on('remove', this.updateView, this);
this.updateView();
},
updateView: function () {
this.viewModel = {
shopping_list: this.collection.toJSON()
};
this.render();
}
这是一大块内容!你现在可以查看配套的代码示例,浏览 ch07/07_the-one-with-delete-buttons,这是你在完成这个阶段时获得的运行示例。在本次教程的下一部分,你将创建一个人类可以用来向购物清单添加项目的表单。
7.4.3. 向购物车添加项目
在上一个阶段,你使你的购物清单有了生命,允许从列表中删除项目。这次,你将添加添加新项目的选项,这样人类就可以进行自己的购买,而不是删除他们不想要的项目。
为了保持趣味性,让我们加入另一个要求。在创建新项目时,你需要确保其名称尚未列出。如果项目已经在购物清单上,那么数量需要添加到现有项目上。这样可以避免创建重复的项目。
创建“添加到购物车”组件
你将在以下列表中添加的 HTML 代码用于将杂货添加到列表中。此示例可在 ch07/08_creating-items 中找到。你将使用几个输入和一个按钮,该按钮将项目添加到购物清单集合中。还有一个字段,如果设置了错误消息,则仅显示该字段。你将使用该字段进行输入验证。为了保持简单,现在这段 HTML 将放入你的列表模板中。在接下来的几个阶段中,你将重构并将其移动到自己的视图中。
列表 7.8. 设置添加到购物车组件
<fieldset>
<legend>Add Groceries</legend>
<label>Name</label>
<input class='name' value='{{name}}' />
<label>Quantity</label>
<input class='quantity' type='number' value='{{quantity}}' />
<button class='add'>Add</button>
{{#error}}
<p>{{error}}</p>
{{/error}}
</fieldset>
到目前为止,你的模型从未改变。你可以删除项目但不能更新它们。现在模型可以通过人类交互进行更改,是时候添加验证了。
输入验证
人类输入永远不应被信任,因为用户可以轻松地输入非数字的数量,或者他们可能忘记输入名称。也许他们输入了一个负数,这也应该被考虑在内。Backbone 允许你通过在模型上提供 validate 方法来验证信息。该方法接受一个 attrs 对象,这是一个内部模型变量,它包含所有模型属性,以便你可以直接访问它们。以下列表显示了如何实现验证函数。你正在检查模型是否有名称、一个不是 NaN(非数字)的数字数量。令人困惑的是,NaN 是 'number' 类型,而 NaN 也不等于自身,因此你需要使用原生的 JavaScript isNaN 方法来测试 NaN。最后,你将确保数量至少为 1。
列表 7.9. 实现验证函数
validate: function (attrs) {
if (!attrs.name) {
return 'Please enter the name of the item.';
}
if (typeof attrs.quantity !== 'number' || isNaN(attrs.quantity)) {
return 'The quantity must be numeric!';
}
if (attrs.quantity < 1) {
return 'You should keep your groceries to yourself.';
}
}
为了使编辑更简单,你还需要向模型添加一个辅助方法,该方法接受一个数量并更新模型,将此数量添加到当前数量。此更改应经过验证以确保负数不会使结果数量低于 1。默认情况下,更改模型值时不会验证模型,但你可以通过启用 validate 选项来强制执行。以下代码显示了该方法的外观:
addToOrder: function (quantity) {
this.set('quantity', this.get('quantity') + quantity, { validate: true });
}
当向模型添加任何数量时,将触发验证,如果验证失败,则模型不会更改,而是在模型上设置 validationError 属性。假设你有一个数量为 6 的模型;以下代码将失败并将 validationError 属性设置为适当的错误消息:
model.addToOrder(-6);
model.validationError;
// <- 'You should keep your groceries to yourself.'
现在模型可以保护自己免受不良数据的影响,你可以更新视图并给你的新表单赋予生命。
重构视图逻辑
我们将对视图进行的第一个更改是添加一个可以显示错误消息的渲染方法,同时保持人类输入的名称和数量,这样在发生错误时它们不会被清除。为了清晰起见,让我们将该方法命名为 updateViewWithValidation:
updateViewWithValidation: function (validation) {
this.viewModel = {
shopping_list: this.collection.toJSON(),
error: validation.error,
name: validation.name,
quantity: validation.quantity
};
this.render();
}
你还需要将事件监听器绑定到添加按钮的点击事件上。为此,在你的视图中的events对象中添加另一个属性。然后剩下的就是创建addItem事件处理程序:
'click .add': 'addItem'
你的addItem处理程序应该做的第一件事是获取人类输入并将数量解析为十进制整数:
var name = this.$('.name').val();
var quantity = parseInt(this.$('.quantity').val(), 10);
一旦你有了用户输入,你首先会确定集合中是否有任何项目具有相同的名称,如果是这样,你将在验证输入后使用addToOrder方法更新模型。如果项目尚未在列表中,那么你将创建一个新的ShoppingItem模型实例并对其进行验证。如果验证通过,那么你将新创建的项目添加到集合中。在代码中,这看起来像以下列表。
列表 7.10. 验证购物项
var model = this.collection.findWhere({ name: name });
if (model) {
model.addToOrder(quantity);
} else {
model = new ShoppingItem({ name: name, quantity: quantity }, { validate: true });
if (!model.validationError) {
this.collection.add(model);
}
}
由于你正在使用ShoppingItem类,你必须在模块顶部添加以下语句:
var ShoppingItem = require('../models/shoppingItem.js');
如果验证步骤失败,你需要重新渲染视图,添加验证错误消息,以便用户知道出了什么问题:
if (!model.validationError) {
return;
}
this.updateViewWithValidation({
name: name,
quantity: quantity,
error: model.validationError
});
如果验证成功,集合将获得一个新项目,或者现有项目将发生变化。这些情况应该通过监听集合上的add和change事件来处理。你需要在视图的initialize方法中添加以下几行:
this.collection.on('add', this.updateView, this);
this.collection.on('change', this.updateView, this);
这就是这一阶段的全部内容。你现在可以添加新项目到列表中,修改现有项目的数量,以及删除项目。在下一阶段,你将通过在每个列表项上添加内联编辑按钮来使编辑更加直观。
7.4.4. 使用内联编辑
在本节中,我们将介绍内联项目编辑。每个项目都将获得一个编辑按钮。点击它将允许人类更改数量,然后保存记录。这个功能本身很简单,但你将利用这个机会来清理一下。你将把不断增长的长列表分成三个部分:一个负责输入表单的添加项目视图,一个负责单个列表项的列表项视图,以及原始的列表视图,它将处理集合的删除和添加。
组件化你的视图
第一项任务是将你的列表视图模板分成两部分。你将使用两个不同的视图容器:一个用于列表,另一个用于表单。你之前使用的<div>可以替换为以下代码:
<ul class='list-view'></ul>
<fieldset class='add-view'></fieldset>
这种分工也意味着你需要将 Mustache 模板拆分。而不是让list模板做所有事情,你将用另外两个模板来替换它。正如你很快就会学到的,列表本身不需要任何模板;只有表单和单个列表项需要。以下代码是views/templates/addItem.mu的样子。表单几乎保持不变,除了fieldset标签已经提升为视图容器的状态,因此它不再在模板中。
<legend>Add Groceries</legend>
<label>Name</label>
<input class='name' value='{{name}}' />
<label>Quantity</label>
<input class='quantity' type='number' value='{{quantity}}' />
<button class='add'>Add</button>
{{#error}}
<p>{{error}}</p>
{{/error}}
列表视图不再需要自己的模板,因为唯一需要的元素是<ul>元素,通过el属性绑定到你的列表视图,你将在下面看到。每个列表项都将保留在其自己的视图中,并且你将使用视图模板来处理它们。列表项视图模型将持有跟踪项是否正在被编辑的属性。这个属性在视图模板中被检查,以决定是否需要渲染标签和操作按钮或内联编辑表单。列表项模板如下所示,并放入views/templates/listItem.mu中。
列表 7.11. 查看列表项模板
{{^editing}}
<span>{{quantity}}x {{name}}</span>
<button class='edit'>Edit</button>
<button class='remove'>x</button>
{{/editing}}
{{#editing}}
<span>{{name}}</span>
<input class='edit-quantity' value='{{quantity}}' type='number' />
<button class='cancel'>Cancel</button>
<button class='save'>Save</button>
{{/editing}}
{{#error}}
<span>{{error}}</span>
{{/error}}
你仍然会在列表视图中创建集合,但你需要将这个集合传递给addItem视图。这会使两个视图紧密耦合,因为addItem视图需要一个可以创建集合的列表视图,而这不是模块化的。这就是你现在入口点app.js的样子。你将在下一阶段解决耦合问题;这个代码片段是关于使你的组件更小:
var Backbone = require('backbone');
Backbone.$ = require('jquery');
var ListView = require('./views/list.js');
var listView = new ListView();
var AddItemView = require('./views/addItem.js');
var addItemView = new AddItemView({ collection: listView.collection });
让我们继续创建添加项目视图。
模块化的“添加到购物车”视图
添加项目视图与你在开始组件化列表视图之前所拥有的相似。首先,下面的列表显示了视图是如何初始化的,以及它是如何使用.add-view选择器来找到<fieldset>,这个<fieldset>将被用作视图容器。
列表 7.12. 初始化视图
var fs = require('fs');
var base = require('./base.js');
var template = fs.readFileSync(
__dirname + '/templates/addItem.mu', { encoding: 'utf8' }
);
var ShoppingItem = require('../models/shoppingItem.js');
module.exports = base.extend({
el: '.add-view',
template: template,
initialize: function () {
this.updateView();
},
updateView: function (vm) {
this.viewModel = vm || {};
this.render();
}
});
这个视图只关注向集合中添加模型,并且它确实如此。它将在添加按钮上有一个点击事件处理器,这个处理器几乎与你的旧addItem方法完全相同。唯一的区别是,在这个版本中,每当addItem事件处理器被触发时,你都会更新视图,如下面的列表所示。
列表 7.13. 更新视图
events: {
'click .add': 'addItem'
},
addItem: function () {
var name = this.$('.name').val();
var quantity = parseInt(this.$('.quantity').val(), 10);
var model = this.collection.findWhere({ name: name });
if (model) {
model.addToOrder(quantity);
} else {
model = new ShoppingItem(
{ name: name, quantity: quantity },
{ validate: true }
);
if (!model.validationError) {
this.collection.add(model);
}
}
if (!model.validationError) {
this.updateView();
return;
}
this.updateView({
name: name,
quantity: quantity,
error: model.validationError
});
}
添加项目视图唯一要做的就是添加项目,所以这就是全部!接下来让我们来构建列表项视图。
创建列表项组件
列表项组件将负责渲染对其模型所做的任何更改,并提供编辑或从列表中删除项的机会。让我们从头开始审视这个视图。首先,有一些常见的问题。你需要读取模板文件并扩展基本视图。tagName属性意味着这个视图将被渲染为<li>元素。以下是一个代码片段:
var fs = require('fs');
var base = require('./base.js');
var template = fs.readFileSync(
__dirname + '/templates/listItem.mu', { encoding: 'utf8' }
);
module.exports = base.extend({
tagName: 'li',
template: template
});
这个视图将接受模型和集合属性,正如你在重构列表视图时将会看到的那样。每当模型发生变化时,你都会重新渲染视图。视图在初始化时也需要被渲染。如果在使用内联编辑功能时发生验证错误,你也会通过视图模型跟踪它。以下是代码中的样子:
initialize: function () {
this.model.on('change', this.updateView, this);
this.updateView();
},
updateView: function () {
this.viewModel = this.model.toJSON();
this.viewModel.error = this.model.validationError;
this.render();
}
移除事件处理程序现在更简单,因为你只需从集合中移除模型,你仍然可以在视图的属性中找到这两个。这在代码中看起来是这样的:
events: {
'click .remove': 'removeItem'
},
removeItem: function (e) {
this.collection.remove(this.model);
}
接下来,你将连接编辑和取消方法,它们是相似的。第一个将项目置于编辑模式,而第二个将退出编辑模式。所有这些方法需要做的就是更改 editing 属性。其余的将由模型更改事件监听器处理,确保重新渲染视图。当切换编辑模式时,你还将清除 validationError 属性。下面的列表介绍了这些事件处理程序。
列表 7.14. 添加编辑和取消方法
events: {
'click .edit': 'editItem',
'click .cancel': 'cancelEdit',
'click .remove': 'removeItem'
},
removeItem: function (e) {
this.collection.remove(this.model);
}
editItem: function (e) {
this.model.validationError = null;
this.model.set('editing', true);
},
cancelEdit: function (e) {
this.model.validationError = null;
this.model.set('editing', false);
}
列表项视图的最后一个任务将是保存对记录所做的编辑。你将绑定到保存按钮的点击事件,解析输入,并更新数量。只有当验证成功时,你才会退出编辑模式。记住,我没有重复所有之前的事件处理程序,为了简洁:
events: {
'click .save': 'saveItem'
},
saveItem: function (e) {
var quantity = parseInt(this.$('.edit-quantity').val(), 10);
this.model.set('quantity', quantity, { validate: true });
this.model.set('editing', this.model.validationError);
}
});
列表项没有其他职责,但列表应该将这个部分视图添加和移除到 UI 中。当说到部分视图时,我的意思是它只代表对象的一部分,在这种情况下是列表的一部分而不是整个列表。列表视图需要持有与它拥有的列表项视图一样多的列表项视图。
重建列表视图
以前,你的列表视图会在每次添加或删除项目时重新渲染。现在,你的列表将只渲染单个项目并将它们附加到 DOM 或从 DOM 中移除现有项目。这不仅比重新渲染整个列表更快,而且也更模块化。列表只管理大图景动作,即项目添加或删除时。单个项目将各自负责维护它们自己的状态并更新它们自己的 UI 表示。
为了使这可行,列表视图将不再依赖于 view.render 方法,而是直接操作 DOM。你保留的旧列表视图的方面,例如硬编码的集合数据、从基本视图扩展以及 el 属性声明,在下面的列表中展示。请注意,视图容器已更改为与你的 <ul> 元素匹配。
列表 7.15. 旧列表视图的方面
var base = require('./base.js');
var ShoppingList = require('../collections/shoppingList.js');
module.exports = base.extend({
el: '.list-view',
collection: new ShoppingList([
{ name: 'Banana', quantity: 3 },
{ name: 'Strawberry', quantity: 8 },
{ name: 'Almond', quantity: 34 },
{ name: 'Chocolate Bar', quantity: 1 }
])
});
由于你不再希望每次项目更改时都重新绘制整个视图,你将依赖于两个新方法,addItem 和 removeItem,来进行 DOM 操作。每当集合更新时,你将运行这些方法,以保持 UI 始终是最新的。你还可以使用 addItem 方法通过在初始化视图时对集合中的每个模型运行它来渲染集合的初始表示。initialize 方法将如下代码片段所示。我将在下一节解释 partials 变量。
initialize: function () {
this.partials = {};
this.collection.on('add', this.addItem, this);
this.collection.on('remove', this.removeItem, this);
this.collection.models.forEach(this.addItem, this);
}
在你看到addItem方法之前,我要提到它需要require列表项视图。你将使用它来创建部分视图,每个集合中的模型一个。让我们将这个添加到列表视图模块的顶部:
var ListItemView = require('./listItem.js');
你现在准备好实现addItem方法。该方法将接受一个模型并创建一个ListItemView的实例。然后视图元素,一个<li>,将被添加到this.$el,即你的<ul>元素。为了干净地查找和从列表中移除项目,你将在partials变量中跟踪它们。Backbone 模型有一个独特的 ID 属性,可以通过model.cid访问,因此你可以将其用作partials对象中的键。代码如下:
addItem: function (model) {
var item = new ListItemView({
model: model,
collection: this.collection
});
this.$el.append(item.el);
this.partials[model.cid] = item;
}
移除元素现在只是查看partials对象,通过model.cid键访问部分,并移除元素。然后你应该确保它也从partials对象中移除。
removeItem: function (model) {
var item = this.partials[model.cid];
item.$el.remove();
delete this.partials[model.cid];
}
呼!那是一个密集的重构会话,但这是值得的。现在你有一些不同的视图正在处理同一个集合,而且它们现在更加自包含。添加项目视图只向集合添加项目,列表视图只关心创建新的列表项视图或从 DOM 中移除它们,而列表项视图只关注单个模型的变化。
给自己一个鼓励的拍拍背,并查看附带的代码示例,以确保你理解了在此阶段所做的所有更改以及购物清单应用程序的当前状态。你可以找到示例在 ch07/09_item-editing。
你在这个阶段实现了很好的关注点分离,但你可以做得更好。让我们在过程的最后阶段来检查这一点。
7.4.5. 服务层和视图路由
最后一个阶段引入了两个对组织结构的更改。你将添加一个薄层服务,并将视图路由引入到你的应用程序设计中。通过创建一个提供唯一购物清单集合的服务,你让你的视图能够主动向服务请求购物清单数据。这极大地解耦了你的视图,之前它们生成数据并相互共享。
注意,在这种情况下,你仍然是在硬编码一个项目数组,但你同样可以从 Ajax 请求中获取它们,并通过 Promise 提供对这些项目的访问,就像你在第六章中看到的那样。目前,以下列表将足够使用。这应该放在services目录中。
列表 7.16. 硬编码一个项目数组
var ShoppingList = require('../collections/shoppingList.js');
var items = [
{ name: 'Banana', quantity: 3 },
{ name: 'Strawberry', quantity: 8 },
{ name: 'Almond', quantity: 34 },
{ name: 'Chocolate Bar', quantity: 1 }
];
module.exports = {
collection: new ShoppingList(items)
};
一旦到位,添加项目和列表视图都应该require该服务,并将shoppingService.collection分配给它们的collection属性。这样做之后,你不再需要传递之前由列表视图初始化的集合引用。
让我们转向路由更改,总结一下你的购物清单冒险之旅。
购物清单的配置
在这个阶段,您也将实现路由。为了保持趣味性,您将把“添加项目”视图移动到不同的路由。以下列表中的代码应该放入其自己的模块中。将其放置在 routers/viewRouter.js。'root' 动作有助于在人类打开应用程序时进行重定向,并且没有设置其他哈希位置。
列表 7.17. 将“添加项目”视图移动到不同的路由
var Backbone = require('backbone');
var ListView = require('../views/list.js');
var AddItemView = require('../views/addItem.js');
module.exports = Backbone.Router.extend({
routes: {
'': 'root',
'items': 'listItems',
'items/add': 'addItem'
},
root: function () {
this.navigate('items', { trigger: true });
},
listItems: function () {
new ListView();
},
addItem: function () {
new AddItemView();
}
});
正如我在第 7.3.4 节中提到的,当我首次介绍 Backbone 路由器时,您需要回到 app.js 并将其中原有的代码替换为以下列表中的代码。这将连接您的视图路由器并激活它。而不是静态地定义第一个提供给人类的视图,它将取决于他们从哪个 URL 访问您的应用程序。
列表 7.18. 激活视图路由器
var Backbone = require('backbone');
var $ = require('jquery');
Backbone.$ = $;
var ViewRouter = require('./routers/viewRouter.js');
new ViewRouter();
$(function () {
Backbone.history.start();
});
您需要进行的最后一个更改以实现路由与视图和模板有关。首先,您将恢复到最后阶段之前使用的单个视图容器:
<div class='view'></div>
其次,您需要在“添加项目”视图和列表视图中都将 el 属性设置为 '.view'。您还必须稍微更改一下视图模板。例如,“添加项目”视图模板应该有一个取消按钮,该按钮可以返回到列表视图。它应该看起来像以下代码:
<a href='#items' class='cancel'>Cancel</a>
最后,您将为您的列表视图提供一个应得的视图模板,它将很小。它需要一个 <ul> 来保持列表和一个与“添加项目”视图路由匹配的锚点链接。以下代码片段显示了放置在 views/templates/list.mu 中的模板应该看起来像什么:
<ul class='items'></ul>
<a href='#items/add'>Add Item</a>
列表视图在初始化时应渲染此模板并查找列表元素:
this.render();
this.$list = this.$('.items');
当向列表添加项目时,而不是将它们附加到 $el(现在是一个共享的视图容器),您应该将它们附加到 $list:
this.$list.append(item.el);
这就是全部内容!请确保查看附带的代码库。最后阶段可以在 ch07/10_the-road-show 下找到,其中包含您迄今为止所做的一切。接下来,您将学习关于 Rendr 的内容,这是一种您可以在服务器端渲染客户端 Backbone 视图的技术,这对于在开发 Node.js 应用程序时提高人类感知性能非常有用。
7.5. Backbone 和 Rendr:服务器/客户端共享渲染
Rendr 通过在服务器端渲染 Backbone 应用程序来提升其感知性能。这允许你在浏览器中的 JavaScript 代码执行和 Backbone 启动之前显示渲染后的页面。当页面第一次加载时,人类将更快地看到内容。在那次首次加载之后,Backbone 将接管并在客户端处理路由。第一次加载非常重要,在人类获得任何内容之前在服务器上渲染应用程序比让他们等待 Backbone 获取你的数据、填充你的视图和渲染你的模板要好。这就是为什么服务器端渲染在 Web 应用程序开发过程中仍然至关重要。让我们快速了解一下 Rendr 的世界。
7.5.1. 深入 Rendr
Rendr 采用传统的应用程序构建方法。它期望你以某种方式命名你的模块并将它们放置在特定的目录中。Rendr 还对应该使用哪种模板以及你的应用程序如何访问其数据有自己的看法。默认情况下,这意味着 Rendr 期望你有一个 REST API 来访问应用程序数据;你将在第九章中研究 REST API 设计。第九章。
Rendr 在 Node.js 上运行,作为你的 HTTP 堆栈中的中间件。它通过拦截请求并在将预渲染的结果交给客户端之前在服务器端渲染视图来工作。在其传统方法中,它通过定义控制器来帮助分离关注点,在那里你可以获取数据、渲染视图或执行重定向。Rendr 而不是在视图中引用你的模板,使用定义良好的命名策略来抽象依赖关系,这些依赖关系主要由 Rendr 引擎管理。一旦你查看第 7.5.2 节中的代码,这将会更加清晰。
天堂中的问题
并非一切尽善尽美。在撰写本文时,Rendr(v0.5)包含了一些“独特”的设计选择,这最终使我决定在本章中不使用它,因为它会复杂化示例。例如,Rendr 使用 Browserify 将你编写的模块带到浏览器中,但它使用 Browserify 编译你的 CommonJS 模块的方式有三个不同的“黑客”技巧:
1. jQuery 需要通过
browserify-shim进行模拟。这很成问题,因为 Rendr 的服务器端版本使用它自己的 jQuery 版本,并且可能存在版本差异。如果你尝试通过npm获取的 CommonJS 版本,它将无法工作。2. 它需要别名来使其
require调用按预期工作,这是一个问题,因为它也转化为下一个缺陷。3. 你不能使用
brfs转换与 Rendr 一起使用。
决定不深入探讨 Rendr 主要是因为它应用范围较窄。如果你选择 Node.js 以外的服务器端语言,你无法将我即将教授的许多概念应用到你的设计中。除了这些问题之外,了解 Rendr 为你的 Backbone 应用程序提供的传统 MVC 功能肯定是有价值的。在服务器端语言中存在许多传统的 MVC 框架,它们提供了与 Backbone 和 Rendr 结合后类似的功能,但在讨论客户端 JavaScript 时,你很少了解到这些。执行共享渲染的能力无疑增加了其吸引力。与大多数决定技术栈的事情一样,这是一个权衡。请注意,Facebook 的 React 是一个很好的例子,它是一个能够进行服务器端和客户端渲染的库,无需任何额外的工具。
深入了解
为了展示 Rendr,我选择了一个稍微修改过的示例 Airbnb(Rendr 的公司)使用的示例,用于教授 Rendr 的工作原理。你可以在配套代码样本的 ch07/11_entourage 中找到代码。
首先,让我们谈谈模板。Rendr 鼓励你使用 Mustache 的超集,即 Handlebars。Handlebars 提供了额外的功能,主要是以辅助方法的形式,例如 if 方便方法。Rendr 期望你编译 Handlebars 模板,并将打包的结果放置在 app/templates/compiledTemplates.js 中。为此,首先安装 Handlebars 的 Grunt 插件:
npm install --save-dev grunt-contrib-handlebars
要配置 Handlebars Grunt 插件,你必须将以下列表中的代码添加到 Gruntfile 中。传递给 handlebars:compile 任务的 options 是 Rendr 所需要的,它期望模板以某种方式命名。
列表 7.19. 配置 Handlebars 插件
handlebars: {
compile: {
options: {
namespace: false,
commonjs: true,
processName: function (filename) {
return filename.replace('app/templates/', '').replace('.hbs', '');
}
},
src: 'app/templates/**/*.hbs',
dest: 'app/templates/compiledTemplates.js'
}
}
目前,Browserify 的配置也依赖于 Rendr 的期望。你需要模拟 jQuery,而不是从 npm 中安装它。你被期望提供一个别名,以便 Rendr 可以访问 rendr-handlebars,这是 Rendr 使用的 Handlebars 适配器。最后,Rendr 需要你提供一些映射,以便它可以访问你的应用程序模块。配置 Browserify 以与 Rendr 协同工作的代码可以在以下列表中找到。
列表 7.20. 配置 Browserify 以与 Rendr 一起工作
browserify: {
options: {
debug: true,
alias: ['node_modules/rendr-handlebars/index.js:rendr-handlebars'],
aliasMappings: [{
cwd: 'app/',
src: ['**/*.js'],
dest: 'app/'
}],
shim: {
jquery: {
path: 'assets/vendor/jquery-1.9.1.min.js',
exports: '$'
}
}
},
app: {
src: ['app/**/*.js'],
dest: 'public/bundle.js'
}
}
就构建配置而言,这就足够了。可能不是最理想的,但一旦配置完成,你就可以将其置之脑后。让我们进入示例应用程序代码,看看它是如何工作的。
7.5.2. 理解 Rendr 中的样板代码
在构建你的 Rendr 应用程序时,你将采取的第一步是为 Node 程序创建入口点。你将把这个文件命名为 app.js 并将其放置在你的应用程序根目录中。正如我之前提到的,Rendr 作为你的 HTTP 栈中的中间件工作,位于 Express 内部。
Rendr 的 Express 中间件
Express 是一个流行的 Node.js 框架,它封装了原生的 http 模块,提供了更多功能,并允许你执行路由和其他一些操作。在本节之后,我们将讨论的大部分内容都是 Rendr 的固有特性,而不是 Express 的部分。尽管如此,Rendr 通过增强 Express 来使其约定生效。
npm install express --save
看看以下代码片段。你正在使用 express 包在 Node 中设置一个 HTTP 服务器。调用 express() 将创建一个新的 Express 应用程序实例,并且你可以使用 app.use 向该实例添加中间件。调用 app.listen(port) 将使应用程序保持运行并响应所选端口上的传入 HTTP 请求。最佳实践规定,你的应用程序的监听端口应该可配置为环境变量,并具有合理的默认值。
var express = require('express');
var app = express();
var port = process.env.PORT || 3000;
app.use(express.static(__dirname + '/public'));
app.use(express.bodyParser());
app.listen(port, function () {
console.log('listening on port %s', port);
});
static 中间件告诉 Express 将指定目录中的所有内容作为静态资源提供服务。如果有人请求 http://localhost:3000/js/foo.js,并且 public/js/foo.js 文件存在,那么 Express 将会这样响应。bodyParser 中间件是一个工具,它将解析检测到为 JSON 或表单数据格式的请求体。
以下列表配置了 Rendr 以供你的例子使用。中间件将处理其他所有事情,正如你接下来将看到的。数据适配器配置告诉 Rendr 应该查询哪个 API。Rendr 的美在于,无论是在客户端还是服务器端,它都会在需要获取数据时查询 API。
列表 7.21. 配置 Rendr
var rendr = require('rendr');
var rendrServer = rendr.createServer({
dataAdapterConfig: {
default: {
host: 'api.github.com',
protocol: 'https'
}
}
});
app.use(rendrServer);
设置 Rendr
Rendr 提供了一系列基本对象,你需要在构建应用程序时扩展它们。从 BaseView 扩展而来的 BaseApp 对象应该被扩展并放置在 app/app.js 中以创建一个 Rendr 应用程序。在这个文件中,你可以添加在客户端和服务器上运行的初始化代码,这些代码用于维护应用程序的全局状态。以下代码片段就足够了:
var BaseApp = require('rendr/shared/app');
module.exports = BaseApp.extend({
});
你还需要创建一个路由模块,你可以用它来跟踪页面视图,每当有路由变化时,尽管现在你只是创建了一个基础路由的实例。路由模块应该放置在 app/router.js 中,它应该看起来像以下代码:
var BaseClientRouter = require('rendr/client/router');
var Router = module.exports = function Router (options) {
BaseClientRouter.call(this, options);
};
Router.prototype = Object.create(BaseClientRouter.prototype);
Router.prototype.constructor = BaseClientRouter;
让我们把注意力转向你的 Rendr 应用程序的主体应该是什么样子。
7.5.3. 一个简单的 Rendr 应用程序
你已经配置了 Grunt 和 Express 以满足 Rendr 的需求。现在,是时候开发应用程序本身了。为了使这个例子更容易理解,我将按照 Rendr 用来提供响应的逻辑顺序展示代码。为了保持你的例子既独立又有趣,你将创建三个不同的视图:
1. 首页是应用程序的欢迎屏幕。
2. 用户列表包含 GitHub 用户。
3. 用户包含特定用户的详细信息。
这些视图将与路由保持一对一的关系。主页视图将位于应用程序根目录/;用户列表将位于/users;用户详情视图将位于/users/:login,其中:login是 GitHub 上的用户登录名(在我的情况下是bevacqua)。视图由控制器渲染。
图 7.5 显示了完成后的用户列表将看起来是什么样子。
图 7.5. 使用 Rendr 构建的 GitHub 浏览器中用户列表

让我们从路由开始,然后学习控制器是如何工作的。
路由和控制器
以下代码将路由匹配到控制器操作。控制器操作应定义为控制器名称,后跟一个哈希,然后是操作名称。此模块位于app/routes.js。
module.exports = function (match) {
match('', 'home#index');
match('users' , 'users#index');
match('users/:login', 'users#show');
};
控制器获取渲染视图所需的所有数据。你必须定义路由期望的每个操作。让我们将两个控制器放在一起。按照惯例,控制器应放置在app/controllers/{{name}}_controller.js。以下代码片段,你的主页控制器,应放置在app/controllers/home_controller.js。它应该公开一个index函数,与index路由相匹配。这个函数接受一个参数对象和一个回调函数,一旦调用,将渲染视图:
module.exports = {
index: function (params, callback) {
callback();
}
};
user_controller.js模块不同。它有一个index操作,但还有一个show操作。在两种情况下,你都需要使用参数调用this.app.fetch以获取模型数据,然后在你完成时调用回调函数,如下所示。
列表 7.22. 获取模型数据
module.exports = {
index: function (params, callback) {
var spec = {
collection: {
collection: 'Users',
params: params
}
};
this.app.fetch(spec, function (err, result) {
callback(err, result);
});
},
show: function (params, callback) {
var spec = {
model: {
model: 'User',
params: params
},
repos: {
collection: 'Repos',
params: { user: params.login }
}
};
this.app.fetch(spec, function (err, result) {
callback(err, result);
});
}
};
如果没有匹配的模型和集合,将无法获取这些数据。让我们接下来详细说明这些内容。
模型和集合
模型和集合需要扩展 Rendr 提供的基类对象,因此让我们创建这些。以下代码是基模型,放置在app/models/base.js。
var RendrBase = require('rendr/shared/base/model');
module.exports = RendrBase.extend({});
基础集合同样很薄。然而,拥有自己的基类对象对于轻松在模型之间共享功能是必要的:
var RendrBase = require('rendr/shared/base/collection');
module.exports = RendrBase.extend({});
我们将使用你想要用于获取模型的端点来定义你的模型,在这种情况下是从 GitHub API。你的模型还应该导出一个唯一的标识符,这与你在用户控制器中调用app.fetch时使用的相同。以下代码显示了用户模型的样子。这应该放置在app/models/user.js。
var Base = require('./base');
module.exports = Base.extend({
url: '/users/:login',
idAttribute: 'login'
});
module.exports.id = 'User';
只要你的模型没有验证或计算数据函数,它们看起来就会很相似:一个url端点,唯一的标识符,以及用于查找单个模型实例的参数名称。当你查看第九章中 REST API 的设计时,以这种方式构造 URL 会感觉更自然。以下是如何看起来像 Repo 模型:
var Base = require('./base');
module.exports = Base.extend({
url: '/repos/:owner/:name',
idAttribute: 'name'
});
module.exports.id = 'Repo';
就像你在第 7.4 节中的案例研究一样,集合需要引用一个模型来学习它们正在处理的数据类型。集合类似于模型,并使用一个唯一标识符来告诉 Rendr 它们是哪种集合,以及你可以从中获取数据的 URL。以下代码展示了代码中的用户集合。它应该放在app/collections/users.js中:
var User = require('../models/user');
var Base = require('./base');
module.exports = Base.extend({
model: User,
url: '/users'
});
module.exports.id = 'Users';
仓库集合几乎相同,除了它使用 Repo 模型,并且它有一个不同的 URL 从 REST API 获取数据。代码如下,并且应该放在app/collections/repos.js中:
var Repo = require('../models/repo');
var Base = require('./base');
module.exports = Base.extend({
model: Repo,
url: '/users/:user/repos'
});
module.exports.id = 'Repos';
在这一点上,用户请求了一个 URL,路由器决定将他们引导到哪个控制器动作。动作方法可能从 API 获取数据,然后调用其回调。最后,让我们学习一下视图是如何渲染 HTML 的。
视图和模板
就像 Rendr 中的大多数事情一样,定义你的视图的第一步是创建你自己的基本视图,这是 Rendr 基本视图的扩展。基本视图应该放在app/views/base.js中,如下所示代码:
var RendrBase = require('rendr/shared/base/view');
module.exports = RendrBase.extend({});
你的第一个视图是主页视图。它应该放在app/views/home/index.js中,如下所示。正如你所看到的,视图也需要导出一个标识符:
var BaseView = require('../base');
module.exports = BaseView.extend({
});
module.exports.id = 'home/index';
由于你的视图主要由相互链接组成,但功能不多,它们大多是空的。用户视图几乎与主页视图相同。它放在app/views/users/index.js中,其代码如下:
var BaseView = require('../base');
module.exports = BaseView.extend({
});
module.exports.id = 'users/index';
用户详情视图放在app/views/users/show.js中。这个视图必须修改模板数据,这就是我所说的视图模型,以便将repos对象提供给模板,如下所示。
列表 7.23. 使repos对象可用于模板
var BaseView = require('../base');
module.exports = BaseView.extend({
getTemplateData: function () {
var data = BaseView.prototype.getTemplateData.call(this);
data.repos = this.options.repos;
return data;
}
});
module.exports.id = 'users/show';
你将要组合的最后一个视图是一个部分视图,用于渲染仓库列表。它应该放在app/views/user_repos_view.js中,正如你所看到的,部分视图几乎与其他视图没有区别,并且它们需要一个视图控制器,就像其他视图一样:
var BaseView = require('./base');
module.exports = BaseView.extend({
});
module.exports.id = 'user_repos_view';
最后,是视图模板。你将要查看的第一个视图模板是layout .hbs文件。这是将作为所有模板容器的 HTML。你可以在以下列表中找到代码。请注意,你正在启动应用程序数据并使用 JavaScript 初始化它。这是 Rendr 所必需的。{{{body}}}表达式将被视图动态替换,随着路由的变化。
列表 7.24. 启动应用程序数据
<!doctype html>
<html>
<head>
<title>Entourage</title>
</head>
<body>
<div>
<a href='/'>GitHub Browser</a>
</div>
<ul>
<li><a href='/'>Home</a></li>
<li><a href='/users'>Users</a></li>
</ul>
<section id='content' class='container'>
{{{body}}}
</section>
<script src='/bundle.js'></script>
<script>
(function() {
var App = window.App = new (require('app/app'))({{json appData}});
App.bootstrapData({{json bootstrappedData}});
App.start();
})();
</script>
</body>
</html>
接下来是主页视图模板。这里有一些没有访问视图模型数据的链接。这个模板放在app/templates/home/index.hbs中。请注意,Backbone 会捕获导航到任何匹配其路由的应用程序中的链接,并表现得像一个单页应用程序。而不是在点击链接时重新加载整个页面,Backbone 将加载相应的视图。
<h1>Entourage</h1>
<p>
Demo on how to use Rendr by consuming GitHub's public API.
</p>
<p>
Check out <a href='/repos'>Repos</a> or <a href='/users'>Users</a>.
</p>
现在事情变得更有趣了。在这里,你正在遍历控制器操作中获取的模型列表,并渲染用户列表及其账户详情链接。这个模板放在app/templates/users/index.hbs:
<h1>Users</h1>
<ul>
{{#each models}}
<li>
<a href='/users/{{login}}'>{{login}}</a>
</li>
{{/each}}
</ul>
接下来是用户详情模板,它位于app/templates/users/show.hbs。你可以在下面的列表中找到模板代码。考虑到你是如何告诉 Handlebars 加载user_repos_view部分的,以及这个名字如何与定义在其视图中的标识符完全匹配。
列表 7.25. 设置用户详情模板
<img src='{{avatar_url}}' width='80' height='80' /> {{login}} ({{public_repos}} public repos)
<br />
<div>
<div>
{{view 'user_repos_view' collection=repos}}
</div>
<div>
<h3>Info</h3>
<br />
<table>
<tr>
<th>Location</th>
<td>{{location}}</td>
</tr>
<tr>
<th>Blog</th>
<td>{{blog}}</td>
</tr>
</table>
</div>
</div>
用户仓库视图是你的最后一个视图模板,在这个例子中是一个部分。它必须位于app/templates/user_repos_view.hbs,它用于遍历一组仓库,显示每个仓库的有趣指标,如下面的列表所示。
列表 7.26. 设置用户仓库模板
<h3>Repos</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Watchers</th>
<th>Forks</th>
</tr>
</thead>
<tbody>
{{#each models}}
<tr>
<td>{{name}}</td>
<td>{{watchers_count}}</td>
<td>{{forks_count}}</td>
</tr>
{{/each}}
</tbody>
</table>
就这些了!呼。正如你所见,一旦你绕过围绕应用程序的大量样板代码,创建一个 Rendr 应用程序并不那么困难。我确信随着时间的推移,它们会减少围绕 Rendr 应用程序核心的样板代码量。使用 Rendr、Backbone 和 CommonJS 创建应用程序的可爱之处在于你的代码可以多么模块化。模块化是可测试代码的特征属性之一。
7.6. 概述
哇,我们在这个章节中确实取得了很大的成果:
-
你学习了为什么 jQuery 不够用,以及更结构化的方法如何帮助你进行应用程序开发过程。
-
你对模型-视图-控制器模式应该如何工作有一个概述。
-
在了解了 Backbone 的基本概念之后,你开始了 Backbone 的冒险之旅。
-
你利用 CommonJS 和 Browserify 使模块化的 Backbone 组件在浏览器中互操作。
-
你利用 Rendr 将 Backbone 应用程序带到服务器端,提高了感知性能。
让我们利用这种势头来学习更多关于可测试性和如何编写良好测试的知识。各种测试都在等待;翻到下一页吧!
第八章. 测试 JavaScript 组件
本章涵盖
-
将单元测试基础知识应用于 JavaScript 组件
-
在 Tape 中编写单元测试
-
模拟、监视和代理
-
手动测试浏览器
-
使用 Grunt 进行测试自动化
-
理解积分和视觉测试
通过编写测试,你可以提高你构建的模块和应用程序的可靠性,并确保它们按你预期的那样工作。按照典型的“先构建”风格,你将获得必要的洞察力来自动化这些测试并在云上运行它们。本章包含一些指导方针,将帮助你编写测试,你还将获得实际测试组件的经验。在某些情况下,我会带你了解你可能为给定代码块编写的测试,帮助你可视化编写深思熟虑的单元测试背后的思维过程。
虽然我不是测试驱动开发(TDD)范式的倡导者,该范式鼓励你在开发任何功能之前编写测试,但我认为测试很重要,你应该编写它们。在本章中,我们将交替进行流程设计和应用设计。你将了解如何编写测试,然后我会给你提供自动化测试的工具。
你是什么意思,你不是 TDD 的倡导者?
确实如此。我不建议你使用 TDD,让我详细说明一下。我对 TDD 本身没有意见,但编写测试已经是一项重大的承诺。如果你是初学者,将 TDD 加入你的学习过程中,可能不会对你有好处。当我刚开始接触测试时,TDD 对我肯定不起作用!TDD 可能会让人感到不知所措,也许你不知道从哪里开始,或者你可能会编写一些无意义的测试,这些测试针对的是实现本身,而不是测试底层接口及其预期行为。在尝试学习 TDD 之前,我建议你尝试为现有代码编写一些测试。这样,当你(如果)决定走 TDD 路线时,你会知道你的测试应该如何构建,哪些部分是重要的测试点,哪些部分不是。更重要的是,你会知道编写特定测试用例是否必要,甚至是否有帮助。话虽如此,如果你已经具备编写单元测试的经验,并且测试驱动开发适合你,那么我对此没有异议!
你主要在第五章中学习了模块化;在第六章中讨论了改进你的异步流程;以及通过第七章中的 MVC 模式,以更有组织的方式构建你的代码。所有这些模块化都有助于通过创建更小的组件来降低你的应用设计复杂性,这些组件更容易操作和理解。到目前为止,在第二部分中完成的工作的一个好处是,测试变得简单得多。
8.1. JavaScript 测试速成课程
测试的本质在于学习如何隔离功能,以便于轻松地进行测试。这就是为什么模块化对于获得更易于测试的代码如此重要的原因,这反过来又提高了质量,这是“先构建”的基石。模块化、松散耦合的代码更容易测试,因为你要考虑的事情更少,你的测试可以包含在小的单元中,这些单元只关注一小段代码的正确性。相比之下,单体、紧密耦合的代码更难测试,因为可能出现更多错误,其中许多可能与你试图测试的功能部分完全无关。
8.1.1. 独立的逻辑单元
考虑以下虚构的示例以供参考。你有一个查询 API 端点的方法(你将在第九章中学习关于 API 设计的内容,所以请耐心等待),然后在对数字进行计算后再返回一个值。假设你想确保数据,无论是什么,都被正确地乘以 555:
function getWorkDone () {
return get('/api/data').then(function (res) {
return res.data * 555;
});
}
在这种情况下,你不需要关心这个方法中与计算部分无关的部分,它们会妨碍你的测试。测试变得困难,因为你现在需要处理 Promise 的事情来验证数据是否被正确计算。你可能想要考虑将其重构为两个更小的方法,一个只做计算,另一个处理查询 API:
function getWorkDone () {
return get('/api/data').then(function (res) {
return compute(res.data);
});
}
function compute (data) {
return data * 555;
}
这种关注点的分离使得可重用性成为可能,因为你可以将计算运行在代码的其他可能需要它的地方。更重要的是,现在单独测试计算要容易得多。以下代码片段足以确保 compute 方法按预期工作:
if (compute(3) !== 1665) {
throw new Error('assertion failed!');
}
当你使用一个配备帮助测试需求的库时,事情会变得容易得多,我将教你如何使用 Tape 库,它遵循一个名为测试任何协议 (TAP) 的单元测试协议^([1))。其他流行的 JavaScript 测试库包括 Jasmine 和 Mocha,但我们不会涉及那些。它们涉及更复杂的设置,通常需要一个测试框架并在全局命名空间中填充全局变量。我们将使用 Tape,它不依赖于全局或测试框架,并且使得测试代码变得容易,无论它是为 Node.js 还是浏览器编写的。
¹ 访问
testanything.org了解更多关于测试任何协议的信息。
8.1.2. 使用测试任何协议 (TAP)
TAP 是在多种语言中实现的测试协议,包括 Node.js。你可以通过几种方式执行 tap 测试:
-
使用
node在你的终端中直接运行测试 -
在浏览器中,使用 Browserify 将测试编译为客户端 JavaScript
-
远程使用自动化服务,如 Travis-CI,就像你在第四章中做的那样
为了开始,你将了解如何在本地环境中使用 Tape,只需简单地启动一个浏览器。在第 8.4 节中,你将学习如何使用 Grunt 自动化此过程,以避免自己启动浏览器,我将解释如何将其包含在你的 CI 工作流程中。
开始使用需要浏览器的 JavaScript 单元测试可能会让人一开始感到困惑。你首先在 Node 中设置一个无意义的单元测试,然后在你到达单元测试原则和建议之前,在浏览器中运行它,这些原则和建议你可以在第 8.2 节中找到。
8.1.3. 编写我们的第一个单元测试
要创建你的第一个单元测试并在浏览器中运行它,从本章前面示例中的compute函数开始,将其放在一个 CommonJS 模块中。这个例子在 samples 中的 ch08/01_your-first-tape-test 可用。你可以把这个文件保存到src/compute.js:
module.exports = function (data) {
return data * 555;
};
在以下代码中,你可以找到使用tape编写的单元测试,它提供了一个执行基本断言的接口。一旦创建了一个测试,你可以给它命名,一个函数将提供一个接口来编写你的测试。你将在第 8.2 节中了解更多关于断言的内容。Tape 中的每个测试用例都可以使用描述和测试方法来定义。你将把这个文件放在test/compute.js:
var test = require('tape');
var compute = require('../src/compute.js');
test('compute() should multiply by 555', function (t) {
t.equal(1665, compute(3));
t.end();
});
注意,你必须require compute函数来测试它。Tape 不会为你加载源代码。同样,tape模块也应该被require。API 相当简单,需要你调用t.end()来表示测试已完成。Tape 主要关注关于你的假设的断言和跟踪测试结果。要运行使用tape编写的任何测试,你只需使用 Node 运行代码即可:
node test/compute.js
让我们看看在浏览器中运行这些测试需要什么。
8.1.4. 浏览器中的 Tape
在浏览器中运行 Tape 测试主要是将你的测试 browserify 化。你可以通过使用全局 Browserify 包一次性完成,或者使用 Grunt 来自动化这个过程。让我们自动化它。你需要使用grunt-browserify来完成这个任务:
npm install --save-dev grunt grunt-browserify
一旦安装了 grunt-browserify,你需要按照第一部分中的方式设置一个 Gruntfile,并配置 browserify 任务将你的 CommonJS 代码编译成浏览器可以无缝解释的内容。在单元测试的例子中,你的配置可能看起来像以下列表(你可以在 ch08/02_tape-in-the-browser 下找到这个例子)。
列表 8.1. 编译浏览器可解释的代码
module.exports = function (grunt) {
grunt.initConfig({
browserify: {
tests: {
files: {
'test/build/test-bundle.js': ['test/**/*.js']
}
}
}
});
grunt.loadNpmTasks('grunt-browserify');
};
使用browserify:tests目标,你可以编译代码以便在 HTML 文件中引用。作为最后一步,你需要组合 HTML 文件。幸运的是,一旦组合好,你就不需要再碰它了,因为 JavaScript 将由 Browserify 打包器处理,你不需要手动更改 HTML 中的脚本标签或其他任何内容,如下面的列表所示。
列表 8.2. 编译代码以便 HTML 文件引用
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Unit Testing JavaScript with Tape</title>
</head>
<body>
<script src='build/test-bundle.js'></script>
</body>
</html>
运行测试只需用浏览器打开这个 HTML 文件即可。你将在本章后面回到 Grunt,看看如何自动化测试过程。让我们谈谈测试原则以及如何在 JavaScript 测试中应用它们。
8.1.5. 安排、行动、断言
编写单元测试通常被认为是一个困难且繁琐的过程,但并不一定如此。如果您的代码是考虑到模块化和可测试性编写的,那么测试就会容易得多。单体、紧密耦合的代码确实会使测试变得复杂。这是因为测试在能够独立验证小组件时最有效,因此您不必担心依赖项。这种测试被称为单元测试。第二种最常见的测试类型是集成测试,它涉及测试组件之间的交互是否按预期工作,关注组件网络的操作方式。图 8.1 比较了这两种测试类型。
图 8.1. 单元测试和集成测试策略之间的差异。请注意,应结合使用这两种方法。单元测试和集成测试不是互斥的。纯函数在第 8.1.15 节部分中讨论。

8.1.6. 单元测试
与关注交互的集成测试相比,好的单元测试积极忽略交互,只关注单个组件在独立情况下如何工作。此外,好的单元测试不关心组件的实现细节;它们只关注组件的公共 API。这意味着好的单元测试可以被视为组件预期如何工作的示例。尽管不是理想的情况,但有时当软件包的文档不足时,单元测试是次优选择。
好的单元测试通常遵循“安排 行动 断言”(AAA)模式,在单元测试中创建依赖项的模拟版本,并监视方法以确保它们被调用。以下小节将探讨这些概念。在您到达第 8.3 节部分之前,您将经历实际的单元测试案例场景。
AAA 模式可以帮助您开发简洁且有序的单元测试。它包括在三个阶段构建单元测试:
-
安排:您创建测试所需的所有实例。
-
行动:您执行测试并跟踪其结果。
-
断言:您验证结果是否与预期输出匹配。
按照这些简单的步骤,在快速浏览单元测试时很容易找到自己的位置。断言用于验证,例如,typeof {}的结果是否匹配object。请注意,当这些步骤可以简化为单行且易于阅读时,您可能应该这样做。
8.1.7. 便利性胜过约定
一些纯粹主义者会告诉您每个单元测试只做一次断言。我建议您保持务实,并允许自己在同一测试中写几个断言,只要它们测试的是同一具体的功能。如果您这样做,也不会有什么坏处,因为测试框架(在您的案例中是 Tape)会确切地告诉您哪个断言在哪个测试中失败了。通常,每个测试使用单个断言会导致大量代码重复和令人沮丧的测试会话。
8.1.8. 案例研究:单元测试事件发射器
让我们为 emitter 方法编写测试,该方法增强对象,使它们能够发出和监听我们在 第六章 中看到的事件。这应该能给你一个关于真实单元测试可能的样子的大致概念。以下列表(在示例中可用,位于 ch08/03_arrange-act-assert)展示了该方法的全貌。这正是你在 第 6.4.2 节 中实现的事件 emitter 方法。
列表 8.3. 你的事件 emitter 实现方法
function emitter (thing) {
var events = {};
if (!thing) {
thing = {};
}
thing.on = function (type, listener) {
if (!events[type]) {
events[type] = [listener];
} else {
events[type].push(listener);
}
};
thing.emit = function (type) {
var evt = events[type];
if (!evt) {
return;
}
var args = Array.prototype.slice.call(arguments, 1);
for (var i = 0; i < evt.length; i++) {
evt[i].apply(thing, args);
}
};
return thing;
}
你如何测试所有这些?这相当大!跟我重复:测试接口。其余的并不那么重要。你想要确保,给定正确的参数,每个公共 API 方法都按照你期望的方式执行。在 emitter 函数的情况下,API 包括 emitter 函数本身、on 方法和 emit 方法。API 是消费者可以访问的任何东西,这正是你想要验证的。
你可以将编写好的单元测试视为断言正确的事情。你的测试将验证的断言应该是确定的,并且它们也应该忽略实现细节,例如事件监听器是如何存储的。私有方法通常是实现细节,你不需要担心测试它们;只有公共接口才是重要的。如果你想测试私有方法,你必须将它们公开,以便它们可以像任何其他公共接口方法一样进行单元测试。
8.1.9. 测试事件发射器
为了开始,让我们从一个测试开始,断言使用不同的参数调用 emitter 是否会导致发射器对象。这是一个基本测试,你将验证返回的对象具有预期的属性(on 和 emit)。
列表 8.4. 使用 TAP 编写的第一个测试

总是拥有断言某物预期操作基本方法的单元测试是件好事。记住,你只需要编写这些测试一次,它们将帮助你随时断言这些验证。让我们在以下列表中编写一些更多的基本断言,确保返回的对象确实是提供的同一个对象。
列表 8.5. 编写基本断言
test('emitter(thing) should reference the same object', function (t) {
var data = { a: 1 }; // Arrange
var thing = emitter(data); // Act
t.equal(data, thing); // Assert
t.end();
});
test('emitter(thing) should reference the same array', function (t) {
var data = [1, 2]; // Arrange
var thing = emitter(data); // Act
t.equal(data, thing); // Assert
t.end();
});
在“基本的 JavaScript 单元测试”领域,你有时会找到断言某物应该是一个函数的测试,实际上确实是一个函数。虽然如果 emitter 不是一个函数,其他任何测试都会失败,但冗余在单元测试中是一件好事。此外,你的测试应该在断言处失败,而不是在安排或执行时失败。如果你的测试在其他地方失败,可能意味着是时候添加更多测试来断言不会发生这种情况,或者问题可能出在你的代码上。
测试对象类型可能看起来很 trivial,但可能会带来回报。更重要的是测试返回值类型。你编写的第一个测试确保了属性存在,但没有检查它们是否是函数。让我们重新设计它,添加类型检查。这些变化可能看起来很 trivial,但你希望明确断言的目的,以便清晰。
列表 8.6. 测试中的类型检查

8.1.10. 测试 .on 方法
接下来,我们将编写.on方法的测试。这一次,如果调用.on不抛出异常,我们就满意了。稍后,我们将确保在测试emit方法时监听器能够正常工作。注意,我编写了两个几乎完全相同的测试,尽管它们有不同的目的。在测试中,找到重复的代码是相当常见的,复制粘贴是可以的,尽管不鼓励滥用。
列表 8.7. 测试 .on 函数


最后,你需要测试emit函数。为此,你将像之前一样附加一些监听器,然后发出事件。然后你将断言监听器正确触发,每次调用.on时触发一次。注意,如果你将emit改为异步,通过在事件处理器中包装setTimeout调用,这个测试就会失败。在这种情况下,你可以调整测试以适应新的功能,或者一开始就避免改变功能。
列表 8.8. 测试 .emit 函数

最后,让我们添加一个方法来确保emit以我们期望的方式将任何参数传递给事件监听器。
列表 8.9. 对 .emit 的进一步测试

就这样!你的事件发射器实现已经完全测试过了。你只编写了验证公共 API 如何工作的断言,而没有干涉实现细节。在这个阶段,你可以添加处理 API 非传统使用的测试,例如在没有参数的情况下调用emit()。然后你可以决定是否希望emit在特定情况下抛出异常。将你的测试视为正式和更严格的 API 文档。
在下一节中,你将学习如何创建模拟、监视函数调用以及代理require语句。
8.1.11. 模拟、间谍和代理
有时你想要更大的隔离性,即使应用程序的两个部分不能进一步解耦。应用程序可能需要查询真实数据库,使用服务获取数据,连接不同的模块,或者可能有其他原因不能解耦实现。你可以使用各种不同的工具,如模拟、间谍和代理,来绕过紧密耦合引入的测试问题。图 8.2 描述了问题以及这些存根提供的解决方案。
图 8.2. 使用源代码与在测试中使用模拟

接下来,你将学习如何模拟依赖项,如果你正在使用具有外部依赖项的组件,这可能会很有用。
8.1.12. 模拟
模拟 在你的系统测试(SUT)中创建了依赖项(如服务或其他对象)的假实例。在静态类型语言中,模拟通常涉及对编译器的访问,通常称为反射。JavaScript 作为一种动态类型语言的一个优点是,你可以创建一个具有几个属性的对象,然后就可以了。假设你必须测试以下代码片段:
function (http, done) {
http.get('/api/data', done);
}
在实际应用中,也许这个片段访问了网络并查询了一个端点,从应用程序的 API 获取数据。你永远不需要连接到外部服务来运行单元测试,这使得模拟成为一个理想的场景。在这种情况下,你正在发起一个 GET 请求,并调用一个带有可选错误和数据返回的 done 函数。
使用纯 JavaScript 模拟 http 对象,实际上很容易。注意你如何使用 setTimeout 来保持方法异步,就像原始代码预期的那样,以及你如何可以随意构造任何响应来适应你的测试:
{
get: function (endpoint, done) {
setTimeout(function () {
done(null, { data: 'dummy' });
}, 0);
}
}
这个测试的服务器端方面,查询真实的 HTTP 端点,应该在服务器测试中处理,这不再是客户端关心的问题了。另一个选择可能是将这些事情放在集成测试中测试,这是你将在本章后面了解的主题。接下来,我将介绍 Sinon.js。Sinon 是一个用于创建模拟、间谍和存根的库。它还允许你模拟 XHR 请求、服务器响应和计时器。让我们看看它。
8.1.13. 介绍 Sinon.js
有时候,仅仅手动模拟值是不够的,在那些更高级的场景中,使用像 Sinon.js 这样的库可能会很有帮助。Sinon 可以帮助你轻松地测试 setTimeout 延迟、日期、XHR 请求,甚至设置假 HTTP 服务器以供测试使用。使用 Sinon,创建名为间谍(spies)的函数变得非常简单。间谍 是一种准备告诉你它们是否被调用、被调用的次数以及它们使用什么参数被调用的函数。实际上,你已经在 列表 8.9 中使用了一种定制的间谍版本,其中我们有一个 listener 函数,它跟踪了被调用的次数。让我们看看使用间谍如何帮助断言函数调用。
8.1.14. 监视函数调用
间谍可以在测试的函数需要函数参数时使用,并且你可以使用它们来轻松断言它们是否被使用以及如何使用。
让我们通过一个简单的例子(在 ch08/04_spying-on-function-calls 中找到)来了解一下。这里有一对接受回调函数参数的函数:
var maxwell = {
immediate: function (cb) {
cb('foo', 'bar');
},
debounce: function (cb) {
setTimeout(cb, 0);
}
};
Sinon 使得测试这些变得容易。无需构建自定义回调,你可以确保 immediate 函数正好调用了一次你的回调:
test('maxwell.immediate invokes a callback immediately', function (t) {
var cb = sinon.spy();
maxwell.immediate(cb);
t.plan(2);
t.ok(cb.calledOnce, 'called once');
t.ok(cb.calledWith('foo', 'bar'), 'arguments match expectation');
});
注意我是如何从 t.end 切换到 t.plan 的。使用 t.plan(n) 允许你定义在测试用例执行期间你期望进行的断言数量。如果测试没有精确匹配断言的数量,则测试将失败。这对于异步测试最有用,因为你的代码可能最终没有调用回调,其中你可能还有更多的断言。使用 t.plan 可以验证确实执行了正确的断言数量。
测试延迟执行稍微有点复杂,但 Sinon 提供了一个易于使用的接口,如下面的列表所示。通过调用 sinon.useFakeTimers(),任何随后的对 setTimeout 或 setInterval 的调用都将被模拟。你还得到一个简单的 tick API,可以手动更改时钟。
列表 8.10. 测试延迟执行
test('maxwell.debounce invokes a callback after a timeout', function (t) {
var clock = sinon.useFakeTimers();
var cb = sinon.spy();
maxwell.debounce(cb);
t.plan(2);
t.ok(cb.notCalled, 'not called before tick');
clock.tick(0);
t.ok(cb.called, 'called after tick');
});
Sinon.js 有更多你可以执行的小技巧,例如创建假的 XHR 请求。关于模拟的最后一个话题,我想讨论的是当你需要为通过在给定的模块上调用 require 而提供的任何结果创建模拟时的情况。让我们看看这是如何工作的!
8.1.15. 代理调用需求
这里的问题是,有时模块需要其他模块,而这些模块又需要额外的模块,而你不希望在单元测试中看到所有这些。单元测试是关于控制环境,检测执行测试所需的绝对必要的部分,并模拟其他一切。有一个叫做 proxyquire 的很好的 npm 包可以帮助这种情况。假设你想测试以下列表中的代码(在 samples 中的 ch08/05_proxying-your-dependencies 可用),你希望从数据库中获取一个用户,然后出于安全原因返回模型的一个子集。
列表 8.11. 使用 require 方法
var User = require('../models/User.js');
module.exports = function (id, done) {
User.findOne({ id: id }, function (err, user) {
if (err || !user) {
done(err); return;
}
done(null, {
name: user.name,
email: user.email
})
});
};
让我们暂时考虑一个小重构。始终最好将“纯”功能隔离。纯函数是函数式编程中的一个概念,它描述了一个函数,其输出仅由其输入定义,而与其他任何东西无关。纯函数每次接收到相同的输入时都会返回相同的值。在上面的例子中,你的纯且可重用的功能是将用户模型映射到其“安全”子集,所以让我们将其提取到自己的函数中,并使你的代码更加美观且易于理解。
列表 8.12. 创建一个纯函数
var User = require('./models/User.js');
function subset (user) {
return {
name: user.name,
email: user.email
};
}
module.exports = function (id, done) {
User.findOne({ id: id }, function (err, user) {
done(err, user ? subset(user) : null);
});
};
然而,正如你所见,除非你单独公开 subset 函数,否则你将陷入查询数据库以获取用户的困境。你可以争论该模块应该获取一个 user 对象,而不仅仅是 id,这是正确的。然而,有时你不得不查询数据库。也许你有一个 user 参数并对其进行操作,但你还想询问数据库关于他的权限或他所属的组。在这些情况下,以及在前一种情况下,假设你没有进一步重构它,一个好的解决方案是从 require 调用中返回一个模拟结果。
好消息是,使用 proxyquire 意味着你根本不需要更改应用程序代码。以下列表演示了如何使用 proxyquire 来模拟所需模块,而无需使用数据库。注意你传递给 proxyquire 的模拟对象是 require 路径和期望得到的结果的映射(而不是你通常得到的结果)。
列表 8.13. 模拟所需模块
var proxyquire = require('proxyquire');
var user = {
id: 123,
name: 'Marian',
email: 'marian@company.com'
};
var mapperMock = {
'./models/User.js': {
findOne: function (query, done) {
setTimeout(done.bind(null, null, user));
}
}
};
var mapper = proxyquire('../src/mapper.js', mapperMock);
一旦你通过不使用数据库连接来隔离映射功能,测试就变得非常简单。你正在使用带有模拟数据库访问的 mapper 函数,并断言它是否返回具有 name 和 email 属性的对象。请注意,你正在使用 Sinon 的 cb.args 来确定 cb 间谍首次调用时的参数。
列表 8.14. 使用 Sinon 创建间谍

在接下来的部分,我将更深入地探讨客户端测试,讨论模拟 XHR(XMLHttpRequest)。在你查看其他自动化形式和非单元测试类型之前,你还将对 DOM 交互测试有所了解。
8.2. 浏览器中的测试
由于 AJAX 请求和 DOM 交互,测试客户端代码通常很麻烦。这通常伴随着模块化和代码组织的完全缺乏,对客户端 JavaScript 测试开发者来说意味着混乱。尽管如此,在第五章中,你通过选择 Browserify 解决了浏览器模块化的问题。Browserify 允许你在客户端代码中使用自包含的 CommonJS 模块,但代价是额外的构建步骤。
你还通过在客户端使用 MVC 框架来解决代码组织问题,以正确分离你的关注点。在第九章中,你将了解 REST API 设计,你将将其应用于你未来编写的 Web 应用程序,消除通常表征前端应用程序开发的端点混乱。
在下一节中,你将学习如何通过模拟 XHR 请求和隔离 DOM 交互来编写客户端代码的测试,以便你可以针对它编写测试。让我们从简单部分开始:模拟 XHR 请求和服务器响应。
8.2.1. 模拟 XHR 和服务器通信
类似于使用proxyquire创建假的require结果,你可以使用 Sinon 来模拟任何你想要的 XHR 请求,而无需修改你的源代码。使用 Sinon 来模拟服务器响应和监视请求数据。这些是你唯一需要处理 XHR 的原因。图 8.3 显示了这些模拟如何帮助你隔离和测试通常依赖于外部资源的代码。
图 8.3. 测试期间原生 XMLHttpRequest 与假 XHR 模拟的比较

要查看代码中的实现方式,这里有一个客户端 JavaScript 代码片段,它发起一个 HTTP 请求并返回响应文本(参见 sample ch08/06_fake-xhr-requests)。我使用superagent模块来发起 HTTP 请求,因为它在服务器或浏览器中都能无缝工作。非常适合用于 Browserifying 操作!
module.exports = function (done) {
require('superagent')
.get('https://api.github.com/zen')
.end(cb);
function cb (err, res) {
done(null, res.text);
}
};
在这种情况下,你不想为superagent本身编写测试。你也不想测试 API 调用。你可能想确保确实发起了一个 AJAX 调用。该方法应该通过响应文本回调给你,所以你应该测试这一点,如下所示。
列表 8.15. 创建发送响应文本的方法
var test = require('tape');
var sinon = require('sinon');
test('qotd service should make an XHR call', function (t) {
var quote = require('../src/qotdService.js');
var cb = sinon.spy();
quote(cb);
t.plan(2);
setTimeout(function () {
t.ok(cb.called);
t.ok(cb.calledWith(null, sinon.match.string));
}, 2000);
});
对于测试结果来说这很好,但你不能让测试依赖于网络条件,或者花费那么长时间等待来做出断言。测试你的方法的正确方式是模拟响应。Sinon 允许你通过创建一个假服务器来实现这一点,它提供了双重价值。它捕获你的代码发出的真实请求,并将它们转换成它控制的可测试对象。它还允许你在测试中为这些请求创建响应,模拟一个运行中的服务器。为了获得这种功能,在调用测试方法之前,使用sinon.fakeServer.create()创建假服务器。然后,一旦调用了应该创建 AJAX 请求的方法,你可以响应这个请求,设置响应的状态码、头和正文。让我们更新你的测试方法以反映这些变化。
列表 8.16. 测试“每日名言”服务
test('qotd service should make an XHR call', function (t) {
var quote = require('../src/qotdService.js');
var cb = sinon.spy();
var server = sinon.fakeServer.create();
var headers = { 'Content-Type': 'text/html' };
quote(cb);
t.plan(4);
t.equals(server.requests.length, 1);
t.ok(cb.notCalled);
server.requests[0].respond(200, headers, 'The cake is a lie.');
t.ok(cb.called);
t.ok(cb.calledWith(null, 'The cake is a lie.'));
});
如你所见,你验证了一个请求被发起,并且你确实收到了与响应文本完全相同的值。
在前往自动化部门之前,需要尝试的最后一块浏览器测试是 DOM 交互测试。DOM 测试就像测试 AJAX 调用一样复杂,因为你正在与跨隔阂的东西交互。注意隔阂。
8.2.2. 案例研究:测试 DOM 交互
客户端开发和测试就是这样有趣。你有三个层次:HTML、JavaScript 和 CSS,它们共同工作以提供复杂的位组合。然而,正如任何优秀的开发者所做的那样,你必须在这三种技术之间保持关注点的分离,尽量避免将它们过于紧密地耦合在一起。CSS 很容易保持独立。你可以在 CSS 中创建类,并通过给它们匹配的class属性将它们分配给 DOM 元素。当 CSS 对 HTML 的结构做出假设时,它就开始崩溃了。最好的 CSS 是那些不依赖于 HTML 以特定方式结构的 CSS,那些不紧密耦合到 HTML 的 CSS。
JavaScript 和 HTML 与 CSS 和 HTML 相似,你的 HTML 不应该对 JavaScript 做出任何假设。即使 JavaScript 被关闭,HTML 也应该工作得相当好;这被称为渐进增强,它有助于更快地将主要内容传递给用户,从而提供更好的整体体验。问题是你的 JavaScript 代码必须对 HTML 做出假设。获取 DOM 节点的内部文本、附加事件监听器、读取数据属性、设置属性或任何其他形式的 DOM 操作,都是以假设 DOM 节点存在为前提的。
让我们进入你的想象中的应用程序,其中事件来参加派对,十进制数字被四舍五入。
设置 HTML
在这个应用程序中,你有一个输入框,你可以在其中输入十进制数字,然后点击按钮以获取该数字的四舍五入版本。每个结果都会写入页面上的一个列表中。还有一个按钮可以清除结果列表。图 8.4 展示了应用程序应有的外观。
图 8.4. 本案例研究中将要构建的应用程序

我们将从浏览这个应用程序开始,并解释在过程中所做的选择。然后,我会向你展示在这个小型应用程序中你应该测试什么,以及如何在不担心实现细节的情况下对这些因素进行测试覆盖率。
考虑以下 HTML 片段。请注意,你不会在 DOM 中直接编写任何 JavaScript。保持关注点的分离对于可测试性至关重要:
<h1>Event Bar</h1>
<p>Enter a number and see it rounded!</p>
<input class='square' placeholder='Decimals only please.' />
<button class='barman'>Another Round!</button>
<button class='clear'>Clear Results</button>
<div class='result'>
<h4>Results come here to cool off!</h4>
</div>
接下来,你将学习如何实现 JavaScript 功能。
实现 JavaScript 功能
接下来,我们将讨论一个小的 JavaScript 应用程序,它使用 JavaScript DOM API 与前面示例中显示的 HTML 进行交互。首先,你将使用 querySelector,这是一个相对不为人知但功能强大的原生浏览器 API,它允许你以类似 jQuery 的方式使用 CSS 选择器找到 DOM 节点。querySelector 在所有主流浏览器中都受支持,甚至可以追溯到 Internet Explorer 8。该 API 存在于文档根以及任何 DOM 节点上,允许你将搜索限制在其子节点。如果你想查找多个元素,而不是第一个元素,你可以使用 querySelectorAll 代替。
var barman = document.querySelector('.barman');
var square = document.querySelector('.square');
var result = document.querySelector('.result');
var clear = document.querySelector('.clear');
注意
我从不使用 HTML 中的 id 属性。它会导致各种问题,例如 CSS 选择器优先级,导致开发者使用 !important 风格规则,并且无法重用值,因为 HTML id 属性的本意是要保持唯一性。
让我们实现负责确定你的输入如何处理的代码。如果它不是一个数字,那么那是一个错误。如果它是一个整数,那也是一个问题。否则,你将返回四舍五入的值:
function rounding (number, done) {
if (isNaN(number)) {
done(new Error('Do you even know what a number is?'));
} else if (number === Math.round(number)) {
done(new Error('You are such a unit. Integers cannot be rounded!'));
} else {
done(null, Math.round(number));
}
}
done 回调应该在你的结果列表中创建一个新的段落,并用任何错误消息(如果有)或四舍五入的值(如果有)填充它。如果你看到错误,你将设置不同的 CSS 类,以帮助设计师相应地设计页面,而无需你对 JavaScript 进行任何额外的更改,如下面的列表所示。
列表 8.17. 使用 done 回调
function report (err, value) {
var p = document.createElement('p');
if (err) {
p.className = 'error';
p.innerText = err.message;
} else {
p.className = 'rounded';
p.innerText = 'Rounded to ' + value + '. Another round?';
}
result.appendChild(p);
}
最后一个拼图是绑定点击事件,在将输入传递给你在列表 8.17 中组合的两个方法之前解析输入。以下代码片段将完成这项工作:
barman.addEventListener(click, round);
function round () {
var number = parseFloat(square.value);
rounding(number, report);
}
连接重置按钮甚至更容易。你的监听器应该移除酒吧管理员创建的每个段落;这再简单不过了!以下列表显示了你可以如何做到这一点。
列表 8.18. 连接重置按钮
clear.addEventListener(click, reset);
function reset () {
var all = result.querySelectorAll('.result p');
var i = all.length;
while (i--) {
result.removeChild(all[i]);
}
}
就这样;你的应用程序已经完全可用。你如何确保未来的重构不会破坏现有的代码?你需要识别确保你的代码按预期工作的测试,然后编写这些测试。
识别测试用例
首先,让我偏离一下话题,提到你需要完全忽略这个案例研究开头的 HTML。你不应该在测试中编写任何 HTML。如果你需要一个 DOM,你应该在测试中使用 JavaScript 来构建它。正如你将在实现测试时看到的那样,这甚至可能比编写 HTML 更容易。分离关注点是单元测试最重要的方面之一。
接下来,你应该尝试识别你的应用程序关注点并将它们与实现细节区分开来。为了进行这个实验,假设你之前写下的所有内容都是实现细节,因为你的应用程序没有提供 API,甚至没有构建任何面向公众的对象。当实现中的每一件事都是实现细节时,你仍然可以进行单元测试,但你需要测试应用程序应该做什么,而不是每个方法应该做什么。
测试案例应该断言你可以在之前呈现的应用程序定义中找到的语句,在与其实现进行核对时是正确的。
应用程序定义
在这个应用程序中,你有一个输入框,你可以输入十进制数字,然后点击按钮以获取该数字的四舍五入版本。每个结果都写入到页面上的列表中。还有一个按钮可以清除结果列表。
下面的列表中列出了几个测试案例。这些是从引用的定义和其他实施中施加的逻辑约束(你希望将其变成定义的一部分)中得出的。请记住,你可以准备任何你想要的测试案例,只要它们满足定义即可。这些是我设计的:
-
没有输入时点击 barman 应该导致错误信息。
-
使用整数点击 barman 应该导致错误信息。
-
使用数字点击 barman 应该得到一个四舍五入的数字。
-
使用两个值点击 barman 两次应该产生两个结果。
-
当列表为空时点击清除不会抛出异常。
-
点击清除将移除列表中的任何结果。
让我们开始测试。我之前提到过,你将在每个测试中通过代码创建 DOM。你将通过创建一个在每次测试之前调用的 Setup 任务和一个在每次测试之后调用的 Teardown 任务来实现这一点。Setup 将创建元素。Teardown 将删除它们。这为每个测试提供了一个干净的起点,即使另一个测试已经运行。
Setup 和 Teardown
大多数 JavaScript 测试框架出于令人费解的原因,将全局变量包含在测试程序中。例如,如果你想在 Mocha 测试框架(Buster.js 和 Jasmine 也这样做)中在每次测试之前运行一个任务,你将传递一个回调函数到beforeEach全局方法。实际上,测试案例应该用其他全局变量来描述,例如describe和it,如下所示。
列表 8.19。使用describe描述测试案例
function setup () {
// prepare something
}
describe('foo()', function () {
beforeEach(setup);
it('should not throw', function () {
assert.doesNotThrow(function () {
foo();
});
});
});
这太糟糕了!在测试中使用全局变量,甚至不应该成为常态。幸运的是,tape没有屈服于这种荒谬,仍然可以在每个测试之前轻松运行一些代码。以下列表显示了相同的代码,但使用tape。
列表 8.20。使用tape描述测试案例
var test = require('tape');
function testCase (name, cb) {
var t = test(name, cb);
t.once('prerun', setup);
}
function setup () {
// prepare something
}
testCase('foo() should not throw', function (t) {
assert.doesNotThrow(function () {
foo();
});
});
当然,这看起来更冗长,但它不会污染全局命名空间,打破了最古老的约定之一。在tape中,测试在测试运行的不同点发出事件,例如prerun。为了设置和拆除我们的测试,你需要创建并使用一个testCase方法。名称无关紧要,但我发现testCase在这个情况下适用:
function testCase (name, cb) {
var t = test(name, cb);
t.once('prerun', setup);
t.once('end', teardown);
}
现在你已经知道了如何为每个测试运行这些方法,是时候编写它们了!
准备测试工具
在setup方法中,你需要创建测试中需要的每个 DOM 元素,并设置通过 HTML 提供的任何默认值。请注意,测试 HTML 本身并不属于这些测试的一部分,这就是为什么你完全忽略它。你的关注点是,假设 HTML 是你预期的,应用程序将成功运行。测试 HTML 是集成测试的关注点。
setup方法在下面的列表中。bar模块是你的应用程序代码,封装在一个函数中,这样你就可以随时执行它。在这种情况下,你需要在每个测试之前运行应用程序。这将把事件监听器附加到新创建的 DOM 元素上。
列表 8.21. 使用setup方法
var bar = require('../src/event-bar.js');
function setup () {
function add (type, className) {
var element = document.createElement(type);
element.className = className;
document.body.appendChild(element);
}
add('input', 'square');
add('div', 'barman');
add('div', 'result');
add('div', 'clear');
bar();
}
teardown方法甚至更简单,因为你只需给它几个选择器,然后遍历它们,移除在setup期间创建的元素:
function teardown () {
var selectors = ['.barman', '.square', '.result', '.clear'];
selectors.forEach(function (selector) {
var element = document.querySelector(selector);
element.parentNode.removeChild(element);
});
}
哇哦!接下来是测试。
编写测试用例
只要你在安排(Arrange)、行动(Act)和断言(Assert)之间保持关注点的清晰分离,你就不会在编写或阅读测试时遇到任何问题。在第一个测试中,你获取barman元素,点击它,并获取任何结果。你验证只有一个结果。然后你断言该结果中的 CSS 类和文本是正确的,如下面的列表所示。
列表 8.22. 断言 CSS 类和文本是否正确
testCase('barman without input should show an error', function (t) {
// Arrange
var barman = document.querySelector('.barman');
var result;
// Act
barman.click();
result = document.querySelectorAll('.result p');
// Assert
t.plan(4);
t.ok(barman);
t.equal(result.length, 1);
t.equal(result[0].className, 'error');
t.equal(result[0].innerText, 'Do you even know what a number is?');
});
下一个测试也进行了错误检查。确保你的错误检查按预期工作,与确保预期的工作路径确实工作一样重要。在下面的列表中,你也在点击之前在输入中设置了一个值。
列表 8.23. 检查你的代码错误

到现在为止,你应该开始看到模式。看看当它们遵循 AAA 约定时,如何容易地识别每个测试做什么?接下来的一个,如下面的列表所示,验证了预期的工作路径是否按预期工作。它将输入设置为十进制值,然后点击按钮,然后检查结果是否为四舍五入的数字。
列表 8.24. 验证路径是否有效

编写与你的代码交互的测试用例,就像你期望人类与之交互一样,这无疑是好的。有时人类会做出意想不到的事情,这也应该被测试。
测试可能的输出
我们以一种特定的方式被连接起来,我们相信三种可能的结果:某物要么永远不工作,要么工作一次,要么总是工作。我经常开玩笑说,只有三个数字存在:0,1 和无限。如下所示,断言两次点击按预期工作应该是足够的。你总是可以回头添加更多测试。
列表 8.25. 确保两次点击工作

在开发代码时,你可能会发现你的代码正在抛出错误,这会降低你的生产力。以下列表中的简单测试,断言一个方法调用不会抛出错误,在这些情况下是有帮助的。下一节将讨论自动化测试,这肯定也有帮助。
列表 8.26. 断言一个方法调用不会抛出错误
testCase('clearing empty list does not throw', function (t) {
// Arrange
var clear = document.querySelector('.clear');
// Assert
t.plan(2);
t.ok(clear);
t.doesNotThrow(function () {
clear.click();
});
});
在你令人尴尬的小套件中的最后一个测试几乎是一个集成测试。它反复点击,然后断言点击清除按钮确实移除了累积的结果。
列表 8.27. 验证清除按钮工作
testCase('clicking clear removes any results in the list', function (t) {
// Arrange
var barman = document.querySelector('.barman');
var square = document.querySelector('.square');
var clear = document.querySelector('.clear');
var result;
var resultCleared;
// Act
square.value = '3.4';
barman.click();
square.value = '3';
barman.click();
square.value = '';
barman.click();
result = document.querySelectorAll('.result p');
clear.click();
resultCleared = document.querySelectorAll('.result p');
// Assert
t.plan(2);
t.equal(result.length, 3);
t.equal(resultCleared.length, 0);
});
在你的测试中,最大的价值总是在需要重构的时候出现。假设你改变了你的事件栏程序的实施。你再次运行测试。如果它们成功,一切正常,除非你手动测试时发现了一个错误,在这种情况下,你需要添加更多测试并修复问题。如果它们失败,有两种可能性。现在的测试可能已经过时了。例如,当点击清除按钮时,它可能已经被更改为“仅移除最旧的结果”。在这种情况下,你应该更新测试以反映这些变化。测试永远可以重复进行,无需额外成本,这就是它们如此有价值的原因。
你可以在附带的代码示例中查看完全工作的示例,包括我向你展示的所有代码,在 ch08/07_dom-interaction-testing 目录下。接下来,我们将回到我们在第七章中开发的案例研究,并为其添加单元测试。
8.3. 案例研究:单元测试 MVC 购物清单
在第七章中,我们在开发 MVC 购物清单应用程序方面取得了相当多的里程碑,在本节中,我们将对这个应用程序的一个迭代进行单元测试。具体来说,你将与我一起在 7.4 节的结尾进行单元测试,在我们将 Rendr 添加到解决方案的 7.5 节之前。你可以在样本中的 ch07/10_the-road-show 目录下查看该应用程序的源代码。其单元测试的对应版本可以在 ch08/07b_testability-boulevard 下找到。
路演是一个小型应用程序,但足够大,可以展示你如何可以逐步向应用程序添加测试,最终得到一个经过良好测试的应用程序。如果我们没有在第五章中投入精力模块化我们的应用程序,那么采取这种逐步的测试方法将会困难得多。我们在第七章中学习了如何这样做,并在构建应用程序时应用了这些概念。本节将指导我们编写视图路由器和模型验证的测试。然后你可以自由探索为视图控制器添加测试覆盖率。
8.3.1. 测试视图路由器
在开始任何测试之前,你总是需要先配置环境,以便测试可以运行。在这种情况下,这意味着你需要将应用程序(从 ch07/10_the-road-show)复制过来作为起点,然后在此之上添加本章构建的测试工具,用于在浏览器中运行 Tape(ch08/02_tape-in-the-browser 示例)。
一旦初始设置完成(示例中的 ch08/07b_testability-boulevard),你就可以开始使用 Tape 充实你的测试。我们将从路由器开始(在第七章列表 7.18 中展示),因为这是我们想要测试的最简单的模块。为了参考,以下列表是模块当前的样子。
列表 8.28. 测试模块
var Backbone = require('backbone');
var ListView = require('../views/list.js');
var AddItemView = require('../views/addItem.js');
module.exports = Backbone.Router.extend({
routes: {
'': 'root',
'items': 'listItems',
'items/add': 'addItem'
},
root: function () {
this.navigate('items', { trigger: true });
},
listItems: function () {
new ListView();
},
addItem: function () {
new AddItemView();
}
});
我们想在测试这个模块时断言一些事情。你想要知道的是
-
有三条路径。
-
与它们相关的路由处理程序确实存在。
-
root路由处理程序正确地重定向到listItems操作。 -
视图路由在每种情况下都会渲染正确的视图。
你可能已经迫不及待地想要考虑为视图创建模拟,或者可能使用proxyquire来完全模拟这些模块。为了开始,我们将断言确实注册了三个路由,并且它们的路由处理程序存在于路由器上。
为了实现这一点,以下列表使用proxyquireify(一种在客户端上工作的proxyquire变体)结合sinon和tape来构建routes.js测试模块。
列表 8.29. 第一个视图路由器测试


一旦测试文件准备就绪,你可以通过打开一个带有编译测试包的浏览器并检查开发者控制台中的任何错误消息来验证测试是否通过。
测试运行器 HTML 文件
首先,你需要一个测试运行器 HTML 文件,如下所示。它没有什么特别之处,只是加载了构建的测试包:
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Unit Testing JavaScript with Tape</title>
</head>
<body>
<script src='build/test-bundle.js'></script>
</body>
</html>
一旦创建了routes.js测试模块和runner.html测试运行器,你应该创建一个 Grunt 任务来构建包。
创建一个 Grunt 任务来构建包
因为你已经学会了如何编写自己的任务,并且为了巩固这一知识,你将创建自己的任务来编译 Browserify 包!为了使其工作,你应该在 Gruntfile 中包含以下所有列表。它直接使用 browserify 包,而不是通过 grunt-browserify 插件作为中介。有时直接使用包而不是通过插件可以提供更大的灵活性,让你的任务能够做更多的事情。
列表 8.30. 创建自定义 Browserify 任务


查看测试执行
当一切设置完毕后,你可以运行以下命令,并在浏览器中看到正在执行的测试:
grunt browserify_tests
open test/runner.html
应该会弹出一个浏览器窗口。如果我们打开开发者控制台,我们会看到图 8.5 中显示的输出。
图 8.5. 开发者工具显示我们提供的测试结果

还有几个路由测试要做。接下来,你将确保每个路由处理器都按预期工作,无论是重定向用户到不同的路由还是渲染特定的视图。
一些额外的测试
以下列表包含了剩余测试的代码。你可以将其添加到 routes.js 测试套件的末尾。
列表 8.31. 单独测试路由处理器


一旦所有的测试都添加到你的 routes.js 文件中,你可以再次运行 Grunt 任务并重新加载浏览器。图 8.6 包含了执行新测试套件的结果。
图 8.6. 展示了我们适度测试套件及其十个断言的结果

虽然我们对路由器的测试是最基本的,它们并不断言很多,但我们至少确保了路由的存在以及它们的路由处理器按预期工作。在应用程序中,路由通常是配置汇聚的点,测试有助于确保使用正确的模块。
8.3.2. 测试视图模型上的验证
应用程序还需要使用几个不同的输入来测试模型验证,确保在特定情况下模型无效,在满足所有验证条件时有效。为了参考,以下列表中包含了购物项模块的代码。
列表 8.32. 测试验证
var Backbone = require('backbone');
module.exports = Backbone.Model.extend({
addToOrder: function (quantity) {
this.set('quantity', this.get('quantity') + quantity, {
validate: true
});
},
validate: function (attrs) {
if (!attrs.name) {
return 'Please enter the name of the item.';
}
if (typeof attrs.quantity !== 'number' || isNaN(attrs.quantity)) {
return 'The quantity must be numeric!';
}
if (attrs.quantity < 1) {
return 'You should keep your groceries to yourself.';
}
}
});
验证在测试 JavaScript 时带来一个有趣的使用场景。鉴于我们想要为每个可能的验证场景设置一个测试,我们可以在一个数组中设置一个测试用例列表,然后为每个测试用例创建一个单独的测试。
以下列表展示了一种使用测试用例工厂和一系列测试用例来保持测试中 DRY 的可能方法。我加入了一个不属于测试用例数组的测试用例以供对比。
列表 8.33. 模型验证测试用例库


假设你必须将每个测试用例单独编写为一个测试:这将导致大量的复制粘贴,违反了 DRY 原则。
按照本章中我们讨论的实践,你也可以为视图编写测试。好的测试案例可以是
-
确保分配给视图的模板是针对该视图的预期模板
-
检查事件处理器是否在
events属性中声明 -
确保那些事件处理器执行了它们预期执行的操作
你可以使用 sinon 在调用每个待测试方法之前模拟视图中的不同属性。我将把这些测试案例留给你作为练习。
当你完成视图控制器的测试编写后,将是时候将你的注意力转向更多的自动化了。这次,你将使用 Grunt 自动化 Tape 测试,你还将学习如何在远程集成服务器上持续运行这些测试。
8.4. 自动化 Tape 测试
你在 第 8.1.4 节 中使用 Grunt 自动化了 Browserify 流程。你如何将 tape 测试添加到你的 Grunt 构建中?在 Node 上运行测试比在浏览器上执行它们要容易得多。正如你之前所学的,你可以通过向 node CLI 提供测试文件路径来在 Node 上运行它们:
node test/something.js
通过使用 grunt-tape 插件自动化之前代码中显示的过程非常简单。以下代码片段(在 samples 中的 ch08/08_grunt-tape-node 中找到)是你 Gruntfile 中运行 tape 测试所需的所有内容。请注意,你不需要运行 Browserify,因为在这种情况下,测试将在 Node 上运行:
module.exports = function (grunt) {
grunt.initConfig({
tape: {
files: ['test/something.js']
}
});
grunt.loadNpmTasks('grunt-tape');
grunt.registerTask('test', ['tape']);
};
这很快。在浏览器中怎么样?
8.4.1. 自动化浏览器中的 Tape 测试
在你的命令行上运行 tape 测试在浏览器中也很容易。你可以使用 testling 来完成。Testling(也称为 substack)是由 James Halliday 编写的工具,他是一位多产的 Node 贡献者,也是 Tape 的作者,并且是一个模块化狂热者。当时并没有现成的 grunt-testling 包,但我决定不要让你失望。我创建了 grunt-testling,这样你就可以从 Grunt 中运行 Testling。grunt-testling 包不需要任何 Grunt 配置。但你需要配置 testling 本身。Testling 通过在 package.json 中放置一个 'testling' 属性并告诉它测试文件的位置来进行配置。以下列表(在 ch08/09_grunt-tape-browser 中找到)是一个用于此目的的示例 package.json。
列表 8.34. 自动化 Tape 测试
{
"name": "buildfirst",
"version": "0.1.0",
"author": "Nicolas Bevacqua <buildfirst@bevacqua.io>",
"homepage": "https://github.com/bevacqua/buildfirst",
"repository": "git://github.com/bevacqua/buildfirst.git",
"devDependencies": {
"grunt": "⁰.4.4",
"grunt-contrib-clean": "⁰.5.0",
"grunt-testling": "¹.0.0",
"tape": "~2.10.2",
"testling": "¹.6.1"
},
"testling": {
"files": "test/*.js"
}
}
一旦你配置了 testling,安装了 grunt-testling,并将其添加到你的 Gruntfile 中,你应该就绪了!
module.exports = function (grunt) {
grunt.initConfig({});
grunt.loadNpmTasks('grunt-testling');
grunt.registerTask('test', ['testling']);
};
你现在可以通过在终端中输入以下命令在浏览器中运行测试:
grunt test
图 8.7 展示了使用 Testling 与 Grunt 结合使用的结果。
图 8.7. 使用 Testling CLI 通过 Grunt 驱动测试

接下来,让我简要重申一下你在 第三章 中首次看到的概念:适应测试的持续开发。
8.4.2. 持续测试
运行测试的一个重要方面是在每次更改时进行,确保你不会在本地开发环境中长时间处理损坏的代码。你可能还记得第三章中的一个特定的 watch 配置片段,它允许你在代码库中的某个地方检测到文件更改时运行特定任务。以下列表是那个片段的改编版本,用于在文件更改时运行测试和代码风格检查。
列表 8.35. 在文件更改时运行测试和代码风格检查
watch: {
lint: {
tasks: ['lint'],
files: ['src/**/*.less']
},
unit: {
tasks: ['test'],
files: ['src/**/*.js', 'test/**/*.js']
}
}
在 Node 和浏览器中自动化测试都很重要。监视更改并在本地再次运行这些测试也很重要。此时,你可能想再次查看第四章中、4.4 节,我在那里讨论了持续集成,这对于设置你的项目至关重要,因为每次将代码推送到版本控制系统时都会执行测试。
在隔离状态下测试组件并不是测试应用程序的唯一方式。实际上,存在无数种测试类型,我们将在下一节简要讨论其中一些有趣的类型。
8.5. 集成、视觉和性能测试
正如我之前提到几次的那样,测试有多种大小和形状。例如,集成测试允许你测试应用程序工作流程中的不同路径,确保组件交互按预期工作。组件已经在隔离状态下进行了测试,集成测试提供了冗余层,并能够捕获在执行应用程序并亲自查看之前不明显的问题。
8.5.1. 集成测试
集成测试在工具方面与单元测试并无不同。你仍然可以使用 Tape、Sinon 和 Proxyquire 来运行这些测试。区别在于应该测试什么。在集成测试中,你不再努力测试一个完全隔离的组件版本,而是尽可能多地测试可以测试的互连,并对剩余部分进行模拟。例如,你可能运行你的应用程序的 web 服务器,用真实的 HTTP 请求对其进行打击,并检查响应是否与你的断言相符。
你还可以使用 Selenium,一个浏览器自动化工具,来帮助在客户端执行这些综合测试。Selenium 通过其 API 与浏览器通信,该 API 支持多种语言。你可以通过 Selenium 服务器向浏览器发送命令。你可以为你的测试编写一系列步骤,然后 Selenium 启动一个浏览器并为你执行这些操作。一个运行中的 Web 服务器和浏览器自动化协同工作,允许你自动化你可能手动执行的测试。记住,你只需要组装一次测试!然后你可以根据需要多次运行它。你还可以随时返回并更改测试。不过,我不得不告诉你,设置 Selenium 很麻烦,通常很令人沮丧,而且文档也很差。但一旦你组装了几次测试,你将获得好处。
集成测试不仅限于使用 Selenium 等工具进行浏览器自动化。你还可以运行仅在你后端堆栈或仅在前端工作的集成测试。
8.5.2. 视觉测试
视觉测试主要是由在不同视口尺寸下对应用程序进行截图,并验证布局没有损坏。你可以通过比较截图与预期内容或通过将最新截图叠加到上一个截图上,生成所谓的“差异”来执行这些验证。这些差异让你能够通过突出显示差异和阴影未更改的截图部分,快速识别从一个版本到另一个版本的变化。许多 Grunt 插件可以为你自动获取应用程序的截图。其中一些甚至更进一步,将最新截图与上一个截图进行比较,显示差异可能存在的位置。一个这样的 Grunt 插件是grunt-photobox。配置它主要是决定你想要加载哪个 URL 以及你想要在截图时视口的分辨率。如果你的网站遵循响应式 Web 设计范式,这尤其有用,该范式使用 CSS 媒体查询根据视口尺寸和其他变量更改页面的外观。以下代码片段显示了如何配置grunt-photobox以获取不同尺寸的页面截图。让我过一遍选项:
-
urls字段是你想要从中获取图片的页面数组。 -
在
screenSizes中,你可以定义你想要获取的每个屏幕截图的宽度;高度将是页面的完整高度。请确保使用字符串。请注意,Photobox 将会按照你决定的每个分辨率对每个网站进行截图:photobox: { buildfirst: { options: { urls: ['http://bevacqua.io/bf'], screenSizes: ['320', '960', '1440'] // these must be strings } } }
一旦你在 Grunt 中配置了 Photobox,你可以运行以下命令,Photobox 将会生成一个你可以浏览的网站来比较截图:
grunt photobox:buildfirst
您可以在附带的代码示例中找到完整工作的示例,作为 ch08/10_visual-testing。最后,让我们将注意力转向性能测试。
8.5.3. 性能测试
跟踪应用程序的性能可以帮助快速识别性能问题的根本原因。您可以使用 Google PageSpeed 或 Yahoo YSlow 等工具在 Web 应用程序中监控性能。这两个工具都提供了类似的见解,并且它们都可以通过 Grunt 插件自动化。它们在其服务之间确实有一些差异。PageSpeed Grunt 工具为您提供更多关于您应该对网站进行哪些改进的见解。例如,它可能会让您知道您没有像应该的那样积极缓存静态资源。YSlow 插件提供了一个更紧凑的版本,告诉您请求了多少次,页面加载花费了多长时间,下载了多少内容,以及性能评分。
PageSpeed 插件grunt-pagespeed要求您从 Google 获取一个 API 密钥.^([2]) 然后,您可以像列表 8.36(sample ch08/11_pagespeed-insights)中所示那样配置插件。在代码中,您告诉 PageSpeed 它想要访问哪个 URL,想要生成的结果区域,要使用的策略('desktop'或'mobile'),以及认为测试成功的最低分数(满分 100 分)。请注意,您故意避免在 Gruntfile 中包含 API 密钥。相反,您将从环境变量中获取它以保持秘密安全。
² 从
code.google.com/apis/console获取 API 密钥。
列表 8.36. 配置 PageSpeed 插件
pagespeed: {
desktop: {
url: 'http://bevacqua.io/bf',
locale: 'en_US',
strategy: 'desktop',
threshold: 80
},
options: {
key: process.env.PAGESPEED_KEY
}
}
要运行示例,您必须将您从 Google 获得的密钥输入到您的终端中:
PAGESPEED_KEY=$YOUR_API_KEY grunt pagespeed:desktop
关于将秘密存储在环境变量中的原因的更多信息,请参阅第三章,第 3.2 节。
在grunt-yslow(YSlow 的 Grunt 插件)的情况下,您不需要获取任何 API 密钥,这使得事情变得相当简单。配置插件只是指定您想要访问的网站 URL,并设置页面重量、页面加载速度、性能评分(满分 100 分)和请求数量的阈值,如下所示(sample ch08/12_yahoo-yslow)。
列表 8.37. 配置 YSlow 插件
yslow: {
options: {
thresholds: {
weight: 1000,
speed: 5000,
score: 80,
requests: 30
}
},
buildfirst: {
files: [
{ src: 'http://bevacqua.io/bf' }
]
}
}
要运行这些 YSlow 测试,请在您的终端中输入以下命令:
grunt yslow:buildfirst
所有这些示例都可以在附带的源代码示例中找到,位于 ch08 目录下。请务必查看它们!
8.6. 概述
那真是太激动人心了!我们在短时间内覆盖了许多概念:
-
您接受了单元测试的速成课程,并学习了如何调整组件,使它们更适合测试。
-
我解释了 Tape 以及您如何使用它无缝地在客户端和服务器端运行测试,而无需重复代码。
-
你学习了关于模拟、间谍和代理;为什么你需要它们;以及如何在 JavaScript 代码中使用它们。
-
我展示了几个案例研究,帮助你弄清楚你应该测试哪些内容以及如何测试。
-
你使用 Grunt 在服务器和浏览器上运行 Tape 测试,而无需离开命令行来处理自动化。
-
我介绍了集成和视觉测试,并学习了如何使用 Grunt 自动化这些任务。
如果你想要了解更多关于测试的知识,我建议你阅读 Christian Johansen 所著的 Test-Driven JavaScript Development(开发者图书馆,2010 年)。
第九章. REST API 设计和分层服务架构
本章涵盖
-
设计 API 架构
-
理解 REST 约束模型
-
了解 API 分页、缓存和节流方案
-
API 文档技术
-
开发分层服务架构
-
在客户端消费 REST API
我已经描述了如何处理构建过程,你已经了解了部署以及配置应用程序将运行的不同环境。你还学习了模块化、依赖管理、JavaScript 中的异步代码流以及开发可扩展应用程序架构的 MVC 方法。为了使内容更加完整,本章重点介绍了设计 REST API 架构并在客户端消费它,允许你使用透明且干净的 API 将前端与后端持久层绑定。
9.1. 避免 API 设计陷阱
如果你曾经为大型企业的前端项目工作过,那么我相信你一定能够理解后端 API 设计的完全缺乏一致性。你需要访问产品类别列表吗?你应该执行一个 AJAX 请求 GET /categories。你有属于某个类别的产品吗?当然;使用 GET /getProductListFromCategory?category_id=id。你有属于两个类别的产品吗?使用 GET /productInCategories?values=id_1,id_2,...id_n。需要保存产品描述的更改?很遗憾,你将不得不再次通过网络发送整个产品。POST /product,在主体中附加一个大的 JSON 块。需要向特定的人发送定制的电子邮件吗?POST /email-customer,包括他们的电子邮件和电子邮件消息数据。
如果你没有发现该 API 设计有任何问题,那么很可能是你花了太多时间与类似的 API 一起工作。以下列表详细说明了设计中的问题:
-
每个新方法都有自己的命名约定:
GET动词在端点中重复,使用驼峰式、连字符分隔或下划线分隔。你命名它! -
除了命名约定外,端点没有以任何方式标记,以区分它们与渲染视图的端点。
-
参数输入偏好也极其不同,没有通过查询字符串或请求体进行明确的区分。也许 cookies 可以解决这个问题!
-
不清楚何时使用每个 HTTP 动词(
HEAD、GET、POST、PUT、PATCH、DELETE)。因此,只有GET和POST被使用。 -
API 中的不一致性。设计良好的 API 不仅具有良好的文档,而且在各个方面都表现出一致性,这允许消费者在 API 中进行黑客攻击,以及实现者通过复制其功能来轻松地构建在现有 API 之上。
这并不仅仅是某个疯子决定混合命名和参数传递约定,同时忽略 API 端点之间的任何标准化和一致性。API 今天的状况最可能的情景是维护 API 的项目员工轮换。是的,也可能是一个不知道更好的单个人,但在那种情况下,你会在 API 方法中观察到至少一些程度的一致性。一个设计良好的 API 能够让消费者在使用几个相关方法后推断出方法的文档。这是因为方法将以一致的方式进行命名,它们将接受类似的参数,这些参数也将以一致的方式进行命名和排序。当一个 API 设计不佳,或者不遵循一套一致性指南时,从简单地使用 API 中推断出如何操作会更难。只有当 API 被一致地设计为简单易懂时,才能达到这种推断状态。
在本章中,我将教你如何设计一致、一致和连贯的 API,以便在您的 Web 项目和其他地方直接消费。前端使用的 API 是我们可以做得更好的一个领域。与 JavaScript 测试一起,我认为这些都是前端开发中最常被低估的方面之一。
REST 代表表示状态转移,它是一套全面的指南,你可以用它来设计 API 架构。一旦你理解了 REST,我将带你去看看如何设计一个典型的分层服务架构,以配合该 API。在结束之前,你将获得开发客户端代码以与 REST API 交互的见解,这将允许你对 API 的响应做出反应。让我们开始吧!
9.2. 学习 REST API 设计
REST 是一组架构约束,它在你开发基于 HTTP 的 API 时为你提供帮助。想象一下,你以“什么都行”的模式开始开发一个 Web API——一张白纸。然后逐个加入 REST 约束。最终结果将是一个标准化的 API,大多数开发者都会感到舒适地进行开发和消费。请注意,关于如何设计 REST API 有不同的解释,而且我在本章中穿插了几个我的解释。这些是我认为效果很好的解释,但最终这仅仅是我的观点。
Roy Fielding 撰写了一篇博士论文,向世界介绍了 REST,[1] 自 2000 年发表以来,其采用率一直在增加。我将只涵盖与我们目的相关的约束:为你的应用程序前端构建一个专门的 REST API。在其他约束中,你将接触到如何构建组成你的 API 的端点,如何处理请求,以及你应该使用哪些类型的状态码。稍后我们将深入更高级的 HTTP 通信主题,例如分页结果、缓存响应和限制请求。
¹ Fielding, Roy Thomas. 《网络软件架构的设计与架构风格》。博士论文,加州大学欧文分校,2000 年。
bevacqua.io/bf/rest。
你将访问的第一个这样的约束是 REST 是无状态的,这意味着请求应包含所有必要的信息,以便后端理解你想要什么,服务器不应利用存储在服务器中的任何额外上下文。在实践中,你得到“纯”端点,其中输出(响应)完全由输入(请求)定义。
我们感兴趣的另一个约束是 REST 期望一个统一的接口。API 中的每个端点都期望接受参数,影响持久层,并以某种可预测的方式响应。为了进一步说明,你需要了解 REST 处理资源。
REST 资源
在 REST 中,资源是信息的抽象,任何信息。就你的目的而言,你可以假设资源和数据库模型是等效的。用户是一种资源,产品和分类也是如此。资源可以通过我描述的统一接口进行查询。
让我们更接近实际,用实际术语描述这对你结构前端 API 意味着什么。
9.2.1. 端点、HTTP 动词和版本
你是否曾经使用过 API 并且觉得它很棒?感觉你“明白了”,实际上你能猜到它们方法的名称,它们的方法以你预期的方式工作,而且没有惊喜?几个执行良好的 API 的例子浮现在我的脑海中;第一个是 Ruby 标准库中的语言 API,它有明确定义其目的的方法,参数使用上保持一致,并且有镜像方法,这些方法正好执行相反的操作。
Ruby 中的String类有一个.capitalize方法;它创建一个字符串的大写副本。
然后是.capitalize!,它将原始字符串大写而不是创建一个副本。你还有.strip,它返回一个没有前后空白字符的副本。你可能已经猜到了下一个:.strip!,它与.strip相同,但作用于原始字符串。
Facebook 有其他很好的例子。他们的 Graph REST API 易于使用,并且它在端点方面是一致的,即端点大多以相同的方式工作。你也可以截取 URL 的一部分,通过他们的网站进行“黑客”操作;例如,facebook.com/me 会带你到你的个人资料,因为他们的 API 将 me 识别为当前认证的用户。
这种一致的行为对于优秀的 API 至关重要。相比之下,糟糕的 API 设计会导致困惑,并具有缺乏命名约定、模糊或差的文档,甚至更糟糕的是:未记录的副作用。PHP 是编写糟糕 API 的臭名昭著的指南。问题在于缺乏规范和不同的作者接管 PHP 语言 API 的不同部分。结果,PHP 函数具有广泛变化的签名、名称和甚至大小写约定。你无法猜测给定函数的名称。有时这些问题可以通过将现有的 API 包装在一致的 API 中来解决,这是 jQuery 成为流行的一部分——通过在更方便和一致的 API 中抽象 DOM API。
在 API 设计中,最重要的方面是一致性,而这始于端点命名约定。
命名你的端点
首先,你需要为所有 API 端点定义一个前缀。如果你有一个要使用的子域名,例如 api.example.com,那也可以。对于前端 API 工作,使用 example.com/api 作为前缀应该可以。前缀有助于区分 API 方法和视图路由,并设定了它们产生的响应类型的预期(在现代网络应用中通常是 JSON)。
尽管前缀本身就足够了,但构建一个连贯的 API 主要依赖于在命名端点时遵循严格的指南。以下是一套指南,帮助你开始:
-
使用全部小写、带有连字符的端点,例如
/api/verification-tokens.这样可以增加 URL 的“可玩性”,即手动进入并手动修改 URL 的能力。你可以选择任何你喜欢的命名方案,只要你能保持一致性。 -
使用一个或两个名词来描述资源,例如
verification-tokens、users或products。 -
总是使用复数来描述资源:
/api/users而不是/api/user。这样可以使 API 更具语义性,正如你将在下一分钟看到的。
这些指南带我们到了一个有趣的观点。让我们以 /api/products 为例,看看你如何设计一个既 RESTful 又一致的 API。
HTTP 动词和 CRUD 一致性
首先,获取产品列表可能是你对产品 API 执行的最基本任务。/api/products 端点是这项任务的理想选择,因此你在服务器上实现了一个返回产品列表的 JSON 的路线,并且开始对自己感到相当满意。接下来,你想要返回单个产品;这将在人类访问产品详情页面时使用。在这种情况下,你可能倾向于将端点定义为 /api/product/:id,但你的一个指导原则是始终使用复数形式,所以它最终会看起来像 /api/products/:id.
这两种方法都明确定义为 GET 请求,因为它们以只读方式与服务器交互。那么移除产品怎么办呢?通常,非 REST 接口使用如 POST /removeProduct?id=:id. 这样的方法。有时会使用 GET 动词,这会导致像谷歌这样的网络爬虫通过跟随网站上的 GET 链接来清除重要的数据库信息。^([2)] REST 建议你使用与获取单个产品相同的端点上的 DELETE HTTP 动词,即 /api/products/:id。利用 HTTP 的一个构建块——它们的动词——你可以构建更具语义和一致性的 API。
² 阅读这篇文章,了解谷歌如何仅通过跟随链接就清除了网站内容的类似故事:
bevacqua.io/bf/spider。
插入给定资源类型的项目涉及类似的思想过程。在非 REST 场景中,你可能有过 POST /createProduct 和相关数据的主体,而在 REST 中,你应该使用更具语义的 PUT 动词,以及一致的 /api/products 端点。最后,编辑应使用 PATCH 动词和一个如 /api/products/:id 的端点。我们将保留 POST 动词用于不单纯涉及创建或更新数据库对象的操作,例如通过电子邮件的 /notifySubscribers。关系是最后一种可以被认为是基本存储操作(创建、读取、更新、删除,简称 CRUD)的端点类型。鉴于我迄今为止所描述的所有内容,你可能不会觉得想象 GET /api/products/:id/parts 是一个请求的良好起点有多难,该请求会响应构成特定产品的单个部件。
就 CRUD 而言,这就是全部内容。如果你想要使用除了 CRUD 之外的东西怎么办?使用你的最佳判断。通常,你可以使用 POST 动词,理想情况下将你自己限制在特定的资源类型上,这不一定需要是数据库模型引用。例如,POST /api/authentication/login 可以处理前端上的登录尝试。
作为总结,表 9.1 显示了到目前为止讨论的动词和端点,按照典型的 REST API 设计。为了简洁起见,我省略了 /api 前缀。请注意,我使用 products 作为示例资源类型,以便使示例更容易相关联,但这适用于任何资源类型。
表 9.1. 典型 REST API 中的产品端点
| 动词 | 端点 | 描述 |
|---|---|---|
| GET | /products | 获取产品列表 |
| GET | /products/:id | 通过 ID 获取单个产品 |
| GET | /products/:id/parts | 获取单个产品中的部件列表 |
| PUT | /products/:id/parts | 为特定产品插入一个新的部件 |
| 删除 | /products/:id | 通过 ID 删除单个产品 |
| PUT | /products | 插入一个新的产品 |
| HEAD | /products/:id | 通过状态码 200 或 404 返回产品是否存在 |
| PATCH | /products/:id | 通过 ID 编辑现有产品 |
| POST | /authentication/login | 大多数其他 API 方法应使用 POST 请求 |
请注意,为每种操作类型选择的 HTTP 动词并不是一成不变的。实际上,这是一个激烈争论的话题,人们争论POST应该用于插入,或任何其他非幂等操作,而使用其他动词(GET、PUT、PATCH、DELETE)的端点必须导致幂等操作——对这些端点的重复请求不应改变结果。
版本控制也是 REST API 设计的一个重要方面,但对于前端操作来说,这是否必要呢?
API 版本控制
在传统的 API 场景中,版本控制是有用的,因为它允许您在不破坏现有消费者交互的情况下对服务进行破坏性更改。在 REST API 版本控制方面存在两种主要的思想流派。
一种观点认为,API 版本应该设置在 HTTP 头中,如果没有在请求中指定版本,您应该从 API 的最新版本获得响应。这种正式的方法更接近原始 REST 论文所提出的,但有人认为如果 API 执行不当,可能会导致意外破坏性更改。
相反,他们提出将版本嵌入到 API 端点前缀中:/api/v1/.... 通过查看请求的端点,可以立即识别出应用程序想要使用哪个 API 版本。
事实上,将v1放在端点或请求头中并不会改变太多,所以这主要取决于 API 实现者的偏好。当涉及到 Web 应用程序及其伴随的 API 时,您不一定需要实现任何版本控制,这就是我倾向于采用请求头方法的原因。这样,如果您在某个时候决定确实需要版本控制,您可以轻松地定义“最新版本”作为默认版本,如果消费者仍然想要旧版本,他们可以显式地添加一个请求头以获取旧版本。话虽如此,请求特定的 API 版本总是比盲目接受最新的 API 更可取,以免意外破坏功能。
我提到您不一定需要在前端使用的 REST API 中实现版本控制,这取决于两个因素:
-
API 是否面向公众?在这种情况下,版本控制是必要的,这将在你的服务行为中增加更多的可预测性。
-
被多个应用程序使用的 API 吗?API 和前端是由不同的团队开发的吗?更改 API 端点是否有漫长的流程?如果这些情况中的任何一个适用,你可能最好对你的 API 进行版本控制。
除非你的团队和应用程序足够小,以至于两者都位于同一个存储库中,并且开发者可以模糊地触及两者,否则请选择安全的方案,并在你的 API 中使用版本。
是时候继续前进,研究请求和响应可能的样子了。
9.2.2. 请求、响应和状态码
正如我之前提到的,遵循 REST 约定的一致性是开发高度可用的 API 的关键。这也适用于请求和响应。API 应该一致地接受参数;这通常是通过端点获取 ID 的方式实现的。在按 ID 获取产品的路由/api/products/:id的情况下,当请求/api/products/bad0-bab8 URL 时,假设bad0-bab8是请求的资源标识符。
请求
当今的 Web 路由器在解析 URL 并提供指定的请求参数方面毫无困难。例如,以下代码展示了 Node.js Web 框架 Express 如何让你定义一个动态路由,该路由通过标识符捕获对产品的请求。然后它解析请求 URL 并为你提供适当的解析参数:
app.get('/api/products/:id', function (req, res, next) {
// req.params.id contains the extracted id
});
将标识符作为请求端点的一部分是非常好的,因为它允许DELETE和GET请求使用相同的端点,从而创建一个更直观的 API,就像我之前提到的 Ruby。你应该决定一个一致的数据传输策略,在制作修改服务器上资源的PUT、PATCH或POST请求时上传数据。如今,由于它的简单性、它是浏览器的本地格式以及服务器端语言中 JSON 解析库的高度可用性,JSON 几乎被普遍用作数据传输的首选格式。
响应
就像请求一样,响应应该符合一致的数据传输格式,这样在解析响应时就不会有惊喜。即使服务器端发生错误,响应仍然应该根据所选的传输方式是有效的;例如,如果我们的 API 是使用 JSON 构建的,那么我们 API 产生的所有响应都应该有效的 JSON(假设用户在 HTTP 头中接受 JSON 响应)。
你应该弄清楚你将包裹你的响应的包装器。包装器或消息包装器对于在所有 API 端点提供一致体验至关重要,它允许消费者对 API 提供的响应做出某些假设。一个有用的起点可能是一个包含单个字段的对象,该字段名为data,包含你的响应正文:
{
"data": {} // the actual response
}
错误字段可能也是一个有用的字段,仅在发生错误时出现,包含可能暴露错误消息、原因和伴随元数据的对象。假设您在GET /api/products/baeb-b00f端点上查询 API,但数据库中不存在baeb-b00f产品:
{
"error": {
"code": "bf-404",
"message": "Product not found.",
"context": {
"id": "baeb-b00f"
}
}
}
仅使用信封和适当的错误字段在您的响应中是不够的。作为一名 REST API 开发者,您还应该意识到您为 API 响应选择的状态码。
HTTP 状态码
在找不到产品的情况下,您应该响应404 未找到状态码,以及描述错误的正确格式化响应。状态码在允许 API 消费者对响应做出假设方面尤为重要。当您以 2xx 成功类状态码响应时,响应体应包含所有请求的相关数据。以下是一个示例,展示了针对可能找到的产品请求的响应,以及 HTTP 版本和状态码:
HTTP/1.1 200 OK
{
"data": {
"id": "baeb-b001",
"name": "Angry Pirate Plush Toy",
"description": "Batteries not included.",
"price": "$39.99",
"categories": ["plushies", "kids"]
}
}
然后是 4xx 客户端错误类代码,这意味着请求很可能由于客户端(例如用户未正确认证)的错误而失败。在这些情况下,您应该使用error字段来描述请求为何有误。例如,如果在尝试创建产品时表单输入验证失败,您可以使用400 请求错误状态码返回响应,如下所示。
列表 9.1. 描述错误
HTTP/1.1 400 Bad Request
{
"error": {
"code": "bf-400",
"message": "Some required fields were invalid.",
"context": {
"validation": [
"The product name must be 6-20 alphanumeric characters",
"The price can't be negative",
"At least one product category should be selected"
]
}
}
}
5xx 状态码范围内的另一种错误是意外错误,例如500 内部服务器错误。这些错误应该以与 4xx 错误相同的方式呈现给消费者。假设之前的请求导致错误;然后您应该以 500 状态码和响应体中的数据片段进行响应,类似于以下内容:
HTTP/1.1 500 Internal Server Error
{
"error": {
"code": "bf-500",
"message": "An unexpected error occurred while accessing the database."
"context": {
"id": "baeb-b001"
}
}
}
当其他所有方法都失败时,捕获这类错误通常相对容易,并返回一个500消息,传递一些关于出错原因的上下文信息。
到目前为止,我已经介绍了端点、请求体、状态码和响应体。设置适当的响应头是另一个值得提及的 REST API 设计方面,原因有很多。
9.2.3. 分页、缓存和节流
尽管在小型应用中不太重要,但分页、缓存和节流都在定义一致且高度可用的 API 中发挥着作用。特别是分页通常是必需的,因为完全缺乏分页会很容易通过允许 API 从数据库查询和传输大量数据到客户端来削弱您的应用。
响应分页
回到第一个 REST 端点示例,假设我向你的 API 发出对/api/products的查询。这个端点应该返回多少产品?所有?如果有 100 个、1000 个、10 个或 100 万个呢?你必须在哪里划线。你可以在 API 中设置默认的分页限制,并能够为每个单独的端点覆盖该默认值。在合理的范围内,消费者应该能够传递一个查询字符串参数并选择不同的限制。
假设你每个请求只接受 10 个产品。那么,你必须实现分页机制来访问你应用上可用的其他产品。要实现分页,你使用Link头。
如果你查询第一个产品页面,响应的Link头应该类似于以下代码:
Link: <http://example.com/api/products/?p=2>; rel="next",
<http://example.com/api/products/?p=54>; rel="last"
注意,端点必须是绝对路径,以便消费者可以解析Link头并直接查询它们。rel属性描述了请求页面与链接页面之间的关系。
如果你现在请求第二页,/api/products/?p=2,你应该得到一个类似的Link头,这次会告诉你“上一页”和“第一页”的相关页面也是可用的:
Link: <http://example.com/api/products/?p=1>; rel="first",
<http://example.com/api/products/?p=1>; rel="prev",
<http://example.com/api/products/?p=3>; rel="next",
<http://example.com/api/products/?p=54>; rel="last"
存在一些情况,数据流动得太快,以至于传统的分页方法无法按预期工作。例如,如果在请求第一页和第二页之间,有少量记录进入数据库,那么第二页将导致第一页上的项目重复,这些项目由于插入而被推到了第二页。这个问题有两个解决方案。第一个解决方案是使用标识符而不是页码。这允许 API 确定你离开的位置,即使有新的记录被插入,你仍然会在 API 给出的最后一个标识符范围上下文中获得下一页。第二种方法是向消费者提供令牌,允许 API 跟踪他们在最后请求之后到达的位置以及下一页应该是什么样子。
如果你处理需要分页才能高效工作的那种大型数据集,那么实施缓存和节流可能会带来很大的回报。缓存可能比节流产生更好的结果,所以让我们先讨论一下缓存。
响应缓存
通常,缓存 API 结果的责任在于最终客户端根据需要决定。然而,API 可以以不同程度的信心提出建议,说明其响应应该如何缓存。以下是对 HTTP 缓存行为和相关 HTTP 头部的快速入门课程。
将Cache-Control头设置为private可以绕过中间代理(例如像nginx这样的代理,其他像 Varnish 这样的缓存层,以及介于其间的各种硬件),并且只允许最终客户端缓存响应。同样,将其设置为public允许中间代理将其缓存中的响应副本存储起来。
Expires头告诉浏览器一个资源应该被缓存,并且直到过期日期过去之前不再请求:
Cache-Control: private
Expires: Thu, 3 Jul 2014 18:31:12 GMT
在 API 响应中定义未来的Expires头是困难的,因为如果服务器中的数据发生变化,这可能意味着客户端的缓存变得过时,但客户端直到过期日期之前都没有任何方式知道这一点。作为响应中Expires头的一个保守替代方案,可以使用一种称为“条件请求”的模式:
条件请求可以是基于时间的,在您的响应中指定一个Last-Modified头。最好在Cache-Control头中指定一个max-age,以便在一段时间后即使修改日期没有变化,浏览器也会使缓存失效:
Cache-Control: private, max-age=86400
Last-Modified: Thu, 3 Jul 2014 18:31:12 GMT
下次浏览器请求此资源时,它将仅在资源自该日期以来未更改的情况下请求资源内容,使用If-Modified-Since请求头:
If-Modified-Since: Thu, 3 Jul 2014 18:31:12 GMT
如果资源自Thu, 3 Jul 2014 18:31:12 GMT以来没有变化,服务器将返回一个空体,并带有304 Not Modified状态码:
作为Last-Modified协商的替代方案,可以使用ETag(也称为实体标签)头,它通常是一个表示资源当前状态的哈希值。这允许服务器确定缓存的资源内容是否与最新版本不同:
Cache-Control: private, max-age=86400
ETag: "d5aae96d71f99e4ed31f15f0ffffdd64"
在随后的请求中,将发送带有相同资源最后请求版本的ETag值的If-None-Match请求头:
If-None-Match: "d5aae96d71f99e4ed31f15f0ffffdd64"
如果当前版本具有相同的ETag值,则当前版本是客户端缓存的版本,并将返回一个304 Not Modified响应。一旦实施缓存,请求节流也可以减轻服务器负载。
请求节流
节流,也称为速率限制,是一种可以用来限制客户端在一定时间窗口内对您的 API 发起请求数量的技术。您有多个标准可以用来限制消费者,但最常见的方法之一是定义一个固定的速率限制,并在一段时间后重置配额。您还必须决定如何执行这种限制。可能限制是按 IP 地址执行的,您还可以为认证用户提供更宽松的限制。
假设您为未认证用户定义了每小时 2,000 个请求的限制;API 应该在响应中包含以下头,每次请求都会从剩余额度中扣除一个点。X-RateLimit-Reset头应包含一个 UNIX 时间戳,描述限制将被重置的时刻:
X-RateLimit-Limit: 2000
X-RateLimit-Remaining: 1999
X-RateLimit-Reset: 1404429213925
一旦请求配额耗尽,API 应该返回一个429 Too Many Requests响应,其中包含一个包含在常规错误封装中的有用错误消息:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 2000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1404429213925
{
"error": {
"code": "bf-429",
"message": "Request quota exceeded. Wait 3 minutes and try again.",
"context": {
"renewal": 1404429213925
}
}
}
在处理内部 API 或仅面向前端使用的 API 时,这种保护通常是不必要的,但当公开 API 时,这是一个至关重要的措施。与分页和缓存一起,这些措施有助于减轻后端服务的压力。
当消费者在使用你的 API 时发生意外情况时,详尽的文档将是您设计的非常易用的服务的最后堡垒。下一节将解释正确记录 API 的基本要素。
9.2.4. 记录 API
任何值得使用的 API 都应该有良好的文档,无论它是面向公众的还是不是。当其他一切失败时,消费者会参考 API 文档。你可以根据代码库中散布的元数据自动生成 API,通常以代码注释的形式出现,但你必须确保文档保持最新和相关性。
良好的 API 文档应该
-
解释响应信封的外观。
-
展示错误报告是如何工作的。
-
展示身份验证、分页、节流和缓存在高级别是如何工作的。
-
详细说明每个端点,解释用于查询这些端点的 HTTP 动词,并描述请求中应包含的每条数据以及可能出现在响应中的字段。
测试用例有时可以作为文档提供帮助,提供最新的工作示例,同时指示访问 API 的最佳实践。文档使 API 客户端开发者能够快速筛选他们可能遇到的任何问题,因为他们没有完全理解 API 期望他们提供的数据类型。API 文档中另一个理想的组成部分是变更日志,它简要地详细说明从一个版本到下一个版本发生的变化。有关变更日志的更多信息,请参阅第 4.2.2 节。
即使 API 和 Web 应用程序在一起,文档也可能很有用,因为它有助于减少研究 API 预期或其工作方式所需的时间。开发者不必在代码中筛选,可以直接阅读文档。当被问及有关 API 的问题时,你可以引导他们查看文档。在这方面,文档不仅对维护 REST API 有益,而且对任何类型的服务、库或框架都很有意义。以库为例——以 jQuery 为例——文档应涵盖库的每个公共 API 方法,明确详细地说明可能的参数和响应组合。在那些有助于消费者理解 API 为何如此设计的案例中,文档也可能解释底层实现。执行良好的 API 文档示例包括 Twitter、Facebook、GitHub 和 Stack-Exchange.^([3])
³ 你可以在
bevacqua.io/bf/api-twitter、bevacqua.io/bf/api-fb、bevacqua.io/bf/api-github和bevacqua.io/bf/api-stack分别找到这些示例。
在掌握了设计 REST API 所需的知识后,在下一节中,你将探讨为 API 创建一系列层的可能性。这些层将定义 API 并帮助你保持服务模块化结构并易于测试。
9.3. 实现分层服务架构
如果你的 API 足够小并且仅针对前端,那么它很可能会存在于同一个项目中。如果是这样,那么 API 与 Web 应用的控制器位于同一层是有意义的。
一种常见的做法是拥有一个所谓的服务层来处理数据处理任务的核心,同时拥有一个数据层来负责与数据库的交互。同时,API 应该设计为其他层之上的一个薄层。这种架构在图 9.1 中有所展示。
图 9.1. 三层服务架构概述

从上往下看这个图,你可以看到每个 API 层的组成部分。
9.3.1. 路由层
API 层负责处理节流、分页、缓存头、解析请求体和准备响应。然而,所有这些都应该通过使用服务层作为访问或修改数据的唯一方式来完成,以下是一些原因:
-
控制器在生成响应之前必须验证请求数据。
-
API 会向服务请求它需要的数据,以便正确地完成响应。
-
当服务任务完成时,API 控制器会以适当的状态码和相关的响应数据做出响应。
9.3.2. 服务层
服务层可以被设计为将所有数据访问推迟到第三层:数据层。这一层负责处理无法直接从数据存储中提取的任何缺失数据的计算:
-
服务层由许多小型服务组成。每个服务都处理业务的一部分。
-
服务层查询数据层,计算业务逻辑规则,并在模型级别验证请求数据。
-
CRUD 操作通常最终会传递到数据层。
-
像发送电子邮件这样的任务,其中不涉及持久性访问,可能完全由服务层的一个组件处理,而不需要求助于数据存储。
9.3.3. 数据层
数据层负责与持久化介质(如数据库、平面文件、内存等)通信。其目的是通过一个一致的接口提供对这些介质的访问。设置此类接口的目标是你可以轻松地交换持久化层(例如数据库引擎或内存中的键值存储),这也使得测试变得更加容易:
-
数据访问层为底层数据存储中的数据提供了一个接口。这使得与不同的数据源交互和更换供应商变得更加容易。
-
模型与底层数据存储无关,它们是接口的一部分。
-
底层数据模型与接口保持分离。这使得更换数据存储变得更加容易,因为数据层消费者不会受到影响。
这只是一个匆忙的概述!让我们放慢速度,更详细地逐步分析这个三层架构。请注意,这种类型的架构不仅限于 API 设计,也可能适用于典型的 Web 应用程序,甚至可能适用于其他类型的应用程序,如果它们值得额外的基础设施。
9.3.4. 路由层
控制器是这种架构中的公共接口层。在这一层,你将定义应用程序可以被访问的路径。路由层还负责解析请求 URL 和请求体中找到的任何参数。
在尝试满足请求之前,你可能需要验证客户端是否超出了他们允许的配额,如果是这样,你可以在那时终止请求,传递适当的响应和状态码429 Too Many Requests。
正如我们所知,用户输入是不可信的,这就是你需要在最严格的意义上验证和清理用户输入的地方。一旦请求被解析,确保请求提供了满足请求所需的所有信息,不多也不少。一旦你确认所有必需的字段都已提供,你应该清理它们并确保输入有效。例如,如果提供的电子邮件地址不是一个有效的电子邮件,你的 API 应该知道响应一个400 Bad Request和一个格式适当的响应体。
一旦请求被解析并且其输入得到验证,你就可以将其交给服务层,服务层会将请求提供的输入转换为所需的输出。(请稍等,我们将会更深入地探讨服务层。)一旦服务层回复你,你就可以最终确定请求是否能够得到满足,并使用相应的状态码和响应数据做出回应。那么,服务层究竟应该做什么呢?很高兴你提出了这个问题!
9.3.5. 服务层
在服务层,也称为业务逻辑层,请求被处理,数据从数据层获取,并返回该数据的表现形式。此时验证业务规则是有意义的,而这不是路由层的责任。
例如,如果用户尝试创建一个价格为“非常昂贵”或-1的新产品,那么确定这不是有效的货币输入的责任就落在路由层。如果选定的产品类别期望产品价格在$20 - $150范围内,而产品定价为$200,那么确定该请求无法满足的责任就落在服务层。
服务层也负责执行任何必要的数据聚合。尽管路由层可能只需要对服务层进行一次调用就能获取所需的数据,但服务层与数据层之间的交互并非如此。例如,服务层可能需要获取新闻网站上文章的列表,并将其交给一个对文章内容进行加工处理的服务,找出相似之处,并最终返回相关文章的列表。
在这方面,服务层是架构中的事件组织者,因为它将查询和命令其他层,以提供产生有意义响应的手段。让我们快速了解一下数据层应该如何构建的具体细节。
9.3.6. 数据层
数据层是唯一一个旨在访问持久化组件(即您的数据库)的层。数据层的目标是确保提供一致的 API,无论使用的是哪种底层数据存储。如果您在 MongoDB、MySQL 或 Redis 中持久化数据,数据层提供的 API 将通过提供不依赖于任何特定持久化模型的统一 API 来隐藏这些细节。
图 9.2 显示了可能位于数据层接口后面的潜在数据存储。请注意,这个接口不一定隐藏单一类型的数据存储:例如,您可能同时使用 Redis 和 MySQL。
图 9.2. 数据层接口和几个底层数据存储

数据层通常是较薄的,在服务和持久化层之间架起桥梁。数据层产生的结果也预期是一致的,因为改变底层持久化模型实际上不会产生影响。
虽然不推荐,但在小型项目中,如果应用程序的持久化模型没有重大变化,合并服务层和数据层是可行的。请记住,一开始分割这两个层是容易的,但一旦有数十个服务消费数十种不同的数据模型,这样做会变得越来越复杂和非平凡。这就是为什么,如果可能的话,建议从一开始就分割这两个层。
本章我们将讨论的最后一个主题是如何在客户端消费这类服务。
9.4. 消费客户端的 REST API
当在 Web 应用程序的客户端与 REST API 层进行密集交互时,通常明智的做法是创建一个薄层作为 API 和应用程序核心之间的中介。这个层依赖于创建共享基础设施来向 API 发出请求,以换取以下列出的好处:
-
对应用程序中发生的请求的高级概述
-
允许你执行缓存并避免额外的请求
-
在应用程序的一个地方管理错误,提供一致的 UI 体验
-
在单页应用程序中导航时能够取消挂起的请求
我将首先描述创建这样一个层将涉及什么,然后我们将继续讨论具体细节。
9.4.1. 请求处理层
组建这样的层可以通过两种方式完成。你可以修补浏览器中的 XHR 实现,确保你的应用程序发出的任何 AJAX 请求都必须通过修补到 XHR 中的代理进行,或者你可以围绕 XHR 创建一个包装器并在每次 AJAX 请求发生时使用它。后者通常被认为更“干净”,因为它不会像猴子补丁方法那样影响浏览器提供的原生行为,这有时会导致意外行为。这通常是优先创建围绕 XHR 调用的简单包装器并使用该包装器而不是原生 API 的足够理由。
我创建了一个名为measly的库,正是出于这个目的。它采用较少侵入性的包装器方法,因为这样它就不会影响不了解 Measly 行为的代码,并且允许你轻松地将请求与 DOM 的不同部分关联起来。它还允许进行缓存和事件处理,这两者都可以限制在特定 DOM 元素或全局的上下文中。我将向您介绍measly的几个关键特性。要开始,您需要从 npm 安装它。它也在 Bower 上以相同名称提供。
npm install --save measly
安装measly后,你将准备好进入下一节,我们将探讨如何使用它来确保请求不会导致意外的副作用。
9.4.2. 取消旧请求
单页 Web 应用程序如今非常流行。在传统的 Web 应用程序中,当用户代理导航到另一个页面时,会取消所有挂起的请求,但单页应用程序(SPA)又是如何呢?如果你正在开发 SPA,那么你很可能希望当用户导航到另一个页面时,散乱的请求不会破坏应用程序的状态。
以下代码是一个通用的示例,假设客户端 MVC 框架在进入和离开视图时广播事件。在这个例子中,你正在在视图容器元素上创建一个measly层级,并在离开视图时取消该层级的所有请求:
view.on('enter', function (container) {
measly.layer({ context: container });
});
view.on('leave', function (container) {
measly.find(container).abort();
});
每当你需要发起 AJAX 调用时,你首先需要查找层级。你也可以保留一个引用以方便使用。使用measly层级创建请求相当直接。在这种情况下,你正在发起一个对DELETE/api/products/:id的请求,通过 REST API 按 ID 删除产品:
var layer = measly.find(container);
deleteButton.addEventListener('click', function () {
layer.delete('/api/products/' + selectedItem.id);
});
每当你发起一个请求时,measly会发出一系列事件,让你可以对其做出反应。例如,如果你想知道请求何时成功,你可以使用data事件,如果你想监听错误,你可以订阅error事件。你有两个不同的地方可以监听错误:
-
在请求级别直接,你只有在特定请求导致错误时才会收到通知。
-
在层级层面,你可以了解任何导致错误的请求。
这些方法都有明显的用例。你肯定想知道请求何时成功,这样你就可以利用响应数据做些具体的事情。
你可能还需要了解任何在全局范围内起源于你应用程序的错误,这样你就可以显示相应的 UI 元素来通知人类这些错误,或者将报告发送到日志服务。
9.4.3. 一致的 AJAX 错误管理
以下列表解释了如何在 AJAX 错误发生时显示 UI 对话框,但只有当响应中的状态码等于 500 时,这意味着发生了内部服务器错误。你将在对话框中填写响应提供的错误消息,然后经过短暂的超时后再次隐藏它。
列表 9.2. 当 AJAX 错误发生时显示 UI 对话框

坦白说,这是一个非常无趣的方法,而且不是你不能完成的事情。更有用的场景是在上下文中进行验证。在这种情况下,请注意400 Bad Request响应,这是 API 应该分配给验证失败响应的状态码。Measly 将在事件处理程序中将this设置为请求对象,允许你访问请求的重要属性,例如其 DOM 上下文元素。以下代码拦截任何400 Bad Request响应,并将其转换为 DOM 上下文中的验证消息。如果你将 Measly 与创建的请求的视觉上下文绑定得足够紧密,人类将不会在查找你的验证消息列表时遇到任何麻烦:
measly.on(400, function (err, body) {
var message = document.createElement('pre');
message.classList.add('validation-messages');
message.innerText = body.validation.messages.join('\n');
this.context.appendChild(message);
});
最好的部分是,你几乎可以免费获得这个功能!因为你在使用上下文确保在切换视图时请求被终止,你可能只需要声明几个子层,这样做是有意义的,例如部分视图或 HTML 表单。我想提出的最后一个点是 Measly 中的缓存规则。
Measly 缓存规则
Measly 允许你以两种方式缓存。首先,它允许你定义一个响应被认为是新鲜的时间量,这意味着对同一资源的后续请求将导致缓存响应,只要缓存副本仍然新鲜。以下列表显示了如何请求缓存响应 60 秒,然后当按钮被点击时,Measly 要么使用缓存副本(如果自上次请求以来在 60 秒内),要么如果数据已更新,则发起新的请求。
列表 9.3. 使用 Measly 缓存文件
measly.get('/api/products', {
cache: 60000
});
queryButton.addEventListener('click', function () {
var req = measly.get('/api/products', {
cache: 60000
});
req.on('data', function (body) {
console.log(body);
});
})
你还可以通过手动阻止不合理的 HTTP 请求来避免对服务器进行查询。以下列表是一个示例,其中产品列表是通过手动缓存的。
列表 9.4. 手动阻止不合理的 HTTP 请求
var saved = []; // a list of products that you know to be fresh
var req = measly.get('/api/products');
req.on('ready', function () {
if (computable) {
req.prevent(null, saved);
}
});
req.on('data', function (body) {
console.log(body);
});
你可以在本章的配套代码示例中找到一个关于 Measly 如何工作的快速演示,该示例列在 ch09/01_a-measly-client-side-layer 中。在演示中,我展示了如何创建不同的上下文来包含对 DOM 部分的不同请求。
总的来说,measly 可能不是你想要的答案,但结合这本书的其余部分,我希望它给你带来了一些思考!
9.5. 摘要
这并不难,对吧?我们覆盖了大量的内容,并在过程中探讨了众多最佳实践:
-
负责任的 API 设计遵循 REST 约束模型,通过提供常规端点、清理输入并提供一致的输出。
-
在 REST API 中,分页、节流和缓存都是提供快速和安全 API 服务所必需的。
-
应该认真对待文档,以降低 API 引入的摩擦。
-
你应该开发一个由领域逻辑层和数据层支持的薄 API 层。
-
一个薄客户端层可以帮助你为 AJAX 请求分配上下文、验证响应,并在用户界面上渲染 HTTP 错误。
附录 A. Node.js 中的模块
本附录涵盖了你在 Grunt 构建中使用模块和 Node.js 时需要了解的内容。Node.js 是一个建立在 V8 JavaScript 引擎之上的平台,这个引擎使得 Google Chrome 中的 JavaScript 成为现实。本书中使用的构建工具 Grunt 运行在 Node 上。Node 是单线程的,就像所有的 JavaScript 一样。
Node 附带了一个小巧的命令行界面(CLI)实用工具,名为npm,用于从 node-packaged 模块注册表中检索和安装包。在整个书中,你将学习如何根据需要使用npm工具。让我们首先安装 Node.js,因为npm是捆绑在一起的!
A.1. 安装 Node.js
安装 Node 有多种选择。如果你是点击类型的人,那么你可能想去他们的网站nodejs.org,点击那个大绿色的安装按钮。一旦二进制文件下载完成,如果需要,解压它们,然后双击安装。就这样。
如果你更喜欢在终端中安装东西,可以考虑使用nvm,这是一个用户创建的 Node 版本管理器。要安装nvm,你可以在你的终端中输入以下行:
curl https://raw.github.com/creationix/nvm/master/install.sh | bash
一旦安装了nvm,重新打开你的终端窗口以获取对nvm CLI 的访问权限。如果你在安装nvm时遇到任何问题,请参考他们的公共仓库github.com/creationix/nvm。一旦你有了nvm,你就可以安装 Node 的一个版本,如下面的代码所示:
nvm install 0.10
nvm alias default stable
第一个命令安装了0.10.x分支的最新稳定版本的 Node。第二个命令使得从现在开始打开的任何终端窗口都可以访问你安装的 Node 版本。
太好了,现在你有了 Node!是时候学习更多关于它的模块系统了,该系统基于 CommonJS 模块规范。
A.2. 模块系统
Node 应用程序在执行 node 进程时指定一个入口点。例如,如果你运行node app.js,你的 Node 进程将使用app.js作为入口点。要加载其他代码片段,你必须使用require函数。这个函数接受一个路径,并加载在该位置找到的模块。传递给require的路径可以是
-
相对于你
require的脚本的路径,以'.'开头。例如,如果你执行require('./main.js'),你将加载与要求脚本相同的目录中的文件。我们也可以使用..来获取父目录中的脚本。 -
到一个目录的路径。在这些情况下,
require将在提供的目录中查找名为index.js的文件,并将其提供给你。 -
绝对路径。这个很少使用,但你可能需要提供一个绝对文件路径,如下面的代码所示:
require('/Users/nico/dev/buildfirst/main.js') -
包的名称。你可以仅通过提供包名来引入包;例如,要获取
async包,你应该使用require('async')。大多数情况下,这实际上等同于执行require('./node_modules/async')。
A.3. 导出功能
如果不能与模块交互,那么引入模块将没有用处。模块可以通过将内容赋值给 module.exports 来导出功能,实际上就是它们的 API。例如,考虑以下模块:
var mine = 'gold';
module.exports = function (pure) {
return pure + mine;
};
如果你使用 var thing = require('./thing.js') 获取了这个模块,那么 thing 将被分配 thing.js 内部 module.exports 最终成为的内容。值得注意的是,与浏览器模型不同,在浏览器模型中,window 被隐式地分配了全局变量,而 CommonJS 模块系统将你在模块中声明的变量保持为私有,除非你通过将内容赋值给 module.exports 来显式地使它们公开。Node 有一个你可以分配的 global 对象,称为 global,但使用它是不被推荐的,因为这会破坏模块化原则。
A.4. 关于包
依赖关系被保存在一个 package.json 文件中,该文件被 npm 用于确定运行应用程序所需的包。在安装包时,你可以提供一个 --save 标志,让 npm 自动将那个依赖关系持久化到 package.json 清单中,这样你就不必手动操作。每当你在没有任何参数的情况下运行 npm install 时,package.json 中的依赖关系就会被安装。
本地依赖关系被安装到 node_modules 目录中,这个目录应该在版本控制中忽略。在 Git 的情况下,你可以在名为 .gitignore 的文件中添加包含 node_modules 的行,Git 就会知道不要对这些文件进行版本控制。
关于 Node 的知识,你只需要知道这些就能在你的 Grunt 构建中有效地使用它。
附录 B. Grunt 简介
Grunt 是一个允许你编写、配置和自动化任务的工具——例如压缩 JavaScript 文件或编译 LESS 样式表——用于你的应用程序。
LESS 是一个 CSS 预处理器,在第二章中介绍。压缩基本上是通过删除空白和许多语法树优化来创建一个更小的文件。这些任务也可以与代码质量相关,例如运行单元测试(在第八章中介绍)或执行代码覆盖率工具,如 JSHint。它们当然可以与部署过程相关:可能是通过 FTP 部署应用程序,或者准备部署,生成 API 文档。
Grunt 仅仅是执行你的构建任务的工具。这些任务使用插件定义,如下一部分所述。
B.1. Grunt 插件
Grunt 只提供框架;你负责选择正确的插件来执行你需要的任务。例如,你可能使用grunt-contrib-concat来捆绑资源。你还需要配置这些插件以完成你想要的工作;例如,提供一个要捆绑的文件列表和结果捆绑文件的路径。
插件可以定义一个或多个 Grunt 任务。这些插件使用 Node 平台上的 JavaScript 编写和配置。Node 社区开发了数百个现成的 Grunt 插件,你只需要配置它们,就像你马上会看到的那样。如果你找不到适合你特定需求的插件,你也可以自己创建 Grunt 插件。
B.2. 任务和目标
任务可以被配置为符合多个目标,并且每个目标在配置任务时通过添加更多数据来定义。任务目标的一个常见用途是针对不同的发行版编译应用程序,如第三章所述。目标对于以略微不同的目的重复使用相同的任务非常有用。LESS 是一种表达式丰富的语言,它编译成 CSS。你可能会有 LESS 任务目标,用于编译应用程序的不同部分。也许你需要使用不同的目标,因为其中一个目标通过添加指向原始 LESS 代码的源映射来使调试更容易,而另一个目标可能将样式表压缩到最小。
B.3. 命令行界面
Grunt 附带了一个命令行界面(CLI),称为grunt,你可以使用它来运行你的任务。为了分析这个工具的工作方式,让我们分析以下语句:
grunt less:debug mocha
假设你已经配置了 Grunt,你将在下一部分了解它,这个语句将执行less任务的debug目标,如果该任务成功,那么为mocha任务配置的任何目标都将被执行。重要的是要注意,如果 Grunt 任务失败,Grunt 不会尝试运行更多任务。相反,它将在打印失败原因后退出。
值得注意的是,任务是以串行方式执行的:下一个任务只有在当前任务完成后才开始。它们不会并行运行。你不必每次都给 CLI 一个完整的任务列表,你可以使用任务别名:执行一系列任务的别名。如果你在创建别名时使用特殊名称 default,那么分配给该别名的任务将在没有任务参数的情况下执行 grunt CLI。
理论已经足够了!让我们通过实际操作来学习 Grunting;你将从安装 Grunt 开始,并扩展我们讨论的所有领域。要安装 Grunt,你首先需要 Node,这是 Grunt 工作的平台。要安装 Node,请访问 附录 A,然后立即回到这里。我会等待。
好吧,让我们安装 Grunt CLI。在终端中使用 npm,输入以下命令:
npm install --global grunt-cli
--global 标志告诉 npm 这不是一个项目级别的包安装,而是一个全局安装。本质上,这将最终使你能够直接从命令行使用该包。你可以通过运行以下命令来验证 CLI 是否已正确安装:
grunt --version
这应该会输出当前安装的 Grunt CLI 的版本号。太好了!你到目前为止所做的一切都是一次性的;你不需要担心再次执行这些步骤。但你是如何使用 Grunt 的呢?
B.4. 在项目中使用 Grunt
假设你有一个 PHP 网络应用程序(尽管服务器端语言并不重要),并且你想在更改 JavaScript 文件时自动运行一个代码检查器,这是一个静态分析工具,可以告诉你关于你使用的语法的错误。
你首先需要在项目根目录中有一个 package.json 文件。这个文件由 npm 使用来维护所有依赖项的清单。这个文件不需要太多;它需要是一个有效的 JSON 对象,所以 {} 就可以了。将目录切换到你的应用程序根目录,并在终端中输入以下内容:
echo "{}" > package.json
接下来,你将不得不安装一些依赖项。你需要安装 grunt,这是框架本身,不要与 grunt-cli 混淆,grunt-cli 用于查找内容并将任务执行委托给本地安装的 grunt 包。要开始,你还需要安装 grunt-contrib-jshint,这是一个易于配置的任务,可以将 JSHint(一个 JavaScript 代码检查工具)作为 Grunt 任务运行。npm install 命令允许你一次性安装多个包,所以让我们这样做:
npm install --save-dev grunt grunt-contrib-jshint
--save-dev 标志告诉 npm 将这些包包含在 package.json 清单中,并将它们标记为开发依赖项。将不应在生产服务器上执行的内容标记为开发依赖项是一种最佳实践。构建组件应始终在执行应用程序之前运行。
你已经有了框架、插件和 CLI;唯一缺少的是配置任务,这样你就可以开始使用 Grunt 了。
B.5. 配置 Grunt
要配置 Grunt,你需要创建一个Gruntfile.js文件。所有你的构建任务配置和定义都将存储在这里。以下是一个示例Gruntfile.js:
module.exports = function (grunt) {
grunt.initConfig({
jshint: {
browser: ['public/js/**/*.js']
}
});
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.registerTask('default', ['jshint']);
};
如附录 A(kindle_split_022.html#app01)中所述,我们在其中讨论了 Common.JS 模块,这里模块导出一个函数,Grunt 将调用该函数来配置你的任务。initConfig方法接受一个对象,该对象将作为所有不同任务和目标的配置。这个配置对象中的每个顶级属性代表特定任务的配置。例如,jshint包含jshint任务的配置。每个任务配置中的属性代表目标配置。
在这种情况下,你正在为jshint的browser目标配置['public/js/**/*.js']。这被称为文件匹配模式,它用于声明要针对哪些文件。你稍后将会学到所有关于文件匹配模式的知识;现在只需说它将匹配public/js或其子目录中的任何.js文件就足够了。
loadNpmTasks方法告诉 Grunt,“嘿,加载你在这个 Grunt 插件中能找到的所有任务”,所以它本质上是在加载jshint任务。你稍后会学到如何编写自己的任务。
最后,registerTask可以通过传递一个任务名称和应该执行的任务数组来定义任务别名。你将把它设置为jshint,这样它就会运行jshint:browser以及你未来可能添加的任何其他jshint目标。默认名称意味着当你不提供任务参数在命令行中执行grunt时,这个任务将会运行。让我们试试看!
grunt
恭喜你,你已经执行了你的第一个 Grunt 任务!然而,你可能对整个“文件匹配”的概念感到困惑;让我们来解决这个问题。
B.6. 文件匹配模式
使用如['public/js/**/*.js']这样的模式可以帮助快速定义要处理的文件。只要你能适当地理解如何使用它们,这些模式就很容易遵循。Glob 允许你使用纯文本来引用真实的文件系统路径。例如,你可以使用docs/api.txt而不需要任何特殊字符,这将匹配docs/api.txt中的文件。请注意,这是一个相对路径,并且它将相对于你的Gruntfile.js。
如果你混合了特殊字符,事情就变得有趣了。例如,将你的最后一个例子改为docs/*.txt可以帮助我们匹配docs目录中的所有文本文件。如果你想包括子目录,那么你需要使用**,也就是 globstar 模式:docs/**/*.txt。
B.6.1. 大括号表达式
然后是括号展开。假设你想匹配多种不同类型的图像;你可能想使用以下模式:images/*.{png,gif,jpg}。这将匹配以 .png、.gif 和 .jpg 结尾的任何图像。它不仅限于扩展名,尽管这是最常见的情况。你也可以使用括号展开来匹配不同的目录:public/{js,css}/**/*。请注意,我们排除了扩展名。这没问题;星号将匹配任何文件类型,而不仅限于特定的一种。
B.6.2. 否定表达式
最后,还有否定表达式,这些表达式有些难以正确使用。否定表达式可以定义为“从你之前匹配的内容中移除匹配的结果。”模式按顺序处理,因此包含和排除的顺序很重要。否定模式以 !. 开头。这里有一个常见的用例:['js/**/*.js', '!js/vendor/**/*.js']。这意味着,“包含 js 目录中的所有内容,但如果不包含在 js/vendor 中。”这对于检查你编写的代码很有用,同时保持第三方库不变。
我想特别指出 globbing 中的一个注意事项;我经常看到有人抱怨 ['js', '!js/vendor'] “不起作用”,现在你知道了 globbing 的工作原理,这个原因就很容易理解了。第一个 globbing 模式会匹配 js 目录本身,而 !js/vendor 不会做任何事情。稍后,js 目录将扩展到其中的每个文件,包括 js/vendor 中的文件。解决这个问题的一个快速方法是让 Globber 为你展开目录,使用 globstars:['js/**/*.js', '!js/vendor/**']。
还有两个主题需要你掌握:配置任务和创建自己的任务。让我们继续看看如何从头开始配置 Grunt 来运行任务。
B.7. 设置任务
现在,你将学习如何通过浏览互联网来设置一个随机任务...作为一个快速入门技巧,让我们回到 B.1 节 的原始示例。记得你是如何配置它来运行 JSHint 的吗?这里是你使用的代码:
module.exports = function (grunt) {
grunt.initConfig({
jshint: {
browser: ['public/js/**/*.js']
}
});
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.registerTask('default', ['jshint']);
};
假设你想压缩(在第二章中介绍)你的 CSS 样式表,并将它们合并成一个文件。你可以搜索 Google 上的 grunt 插件来做这件事,或者你可能访问 gruntjs.com/plugins 并自行查找。前往那个页面,然后输入 css。你将看到的第一项结果之一是 grunt-contrib-cssmin,它将链接到该软件包在 npm 网站上的页面。
在 npm 上,你通常会找到详细的 README 文件,以及指向 GitHub 仓库中完整源代码的链接。在这种情况下,它指导你从 npm 安装该软件包,并将 loadNpmTasks 添加到你的 Gruntfile.js 中,如下面的代码所示:
module.exports = function (grunt) {
grunt.initConfig({
jshint: {
browser: ['public/js/**/*.js']
}
});
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-cssmin');
grunt.registerTask('default', ['jshint']);
};
你还必须以与之前安装 grunt-contrib-jshint 时相同的方式从 npm 安装该软件包:
npm install --save-dev grunt-contrib-cssmin
现在你只需要进行配置。Grunt 项目通常有很好的文档,在它们的首页上提供了几个配置示例,以及所有可用选项的详细列表。名为grunt-contrib-*的包是由 Grunt 本身背后的团队开发的,所以它们应该基本上没有问题就能正常工作。在寻找适合任务的正确包时,如果某个包不起作用或者文档不完善,就继续寻找。你不必非得选择它们。流行度(npm 安装和 GitHub 星标)是衡量一个包好坏的好指标。
结果表明,第一个使用示例显示你也可以使用这个包来连接你的 CSS,因此你不需要额外的任务来做这件事。以下是一个示例,展示了如何使用grunt-contrib-cssmin在压缩的同时合并两个文件:
cssmin: {
combine: {
files: {
'path/to/output.css': ['path/to/input_one.css', 'path/to/input_two.css']
}
}
}
你可以轻松地根据你的需求进行修改和整合。你还将添加一个build任务别名。别名对于定义工作流程非常有用,正如你将在第一部分中看到的。例如,第三章使用它们来定义调试和发布工作流程:
module.exports = function (grunt) {
grunt.initConfig({
jshint: {
browser: ['public/js/**/*.js']
},
cssmin: {
all: {
files: { 'build/css/all.min.css': ['public/css/**/*.css'] }
}
}
});
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-cssmin');
grunt.registerTask('default', ['jshint']);
grunt.registerTask('build', ['cssmin']);
};
就这样!如果你在终端中运行grunt build,它将把你的 CSS 文件捆绑在一起,然后进行压缩,并将结果写入all.min.css文件。你可以在附带的源代码示例中找到这个示例,以及其他我们之前讨论过的示例,在appendix/introduction-to-grunt部分。让我们通过解释如何编写你自己的 Grunt 任务来结束这个附录。
B.8. 创建自定义任务
Grunt 有两种任务:多任务和常规任务。正如你可能猜到的,多任务允许消费者设置不同的任务目标并单独运行它们。在实践中,几乎所有的 Grunt 任务都是多任务。让我们一步一步地创建一个多任务!
你将创建一个可以统计文件列表中单词数量的任务,并且如果统计的单词数量超过了预期,任务将失败。首先,让我们快速浏览一下这段代码:
grunt.registerMultiTask('wordcount', function () {
var options = this.options({
threshold: 0
});
});
在这里,你正在为threshold选项设置一个默认值,这个值在任务配置时可以被覆盖,正如你将在下一分钟看到的。因为你使用了registerMultiTask,你可以支持多个任务目标。现在你需要遍历文件列表,读取它们,并计算其中的单词数量:
var total = 0;
this.files.forEach(function (file) {
file.src.forEach(function (src) {
if (grunt.file.isDir(src)) {
return;
}
var data = grunt.file.read(src);
var words = data.split(/[^\w]+/g).length;
total += words;
grunt.verbose.writeln(src, 'contains', words, 'words.');
});
});
Grunt 将提供一个files对象,你可以使用它来遍历文件,过滤掉目录,并从文件中读取数据。一旦你计算出了单词数量,你可以打印结果,如果threshold被超过则失败:
if (options.threshold) {
if (total > options.threshold) {
grunt.log.error('Threshold of', options.threshold, 'exceeded. Found', total, 'words.');
grunt.fail.warn('Too many words');
} else {
grunt.log.ok(total, 'words found in total.');
}
} else {
grunt.log.writeln(total, 'words found in total.');
}
最后,你只需要像之前那样配置一个任务目标:
wordcount: {
capped: {
files: {
src: ['text/**/*.txt']
},
options: {
threshold: 3000
}
}
}
如果所有这些文件的总字数超过 3,000 字,任务将失败。请注意,如果您没有提供阈值,它将使用默认值0,这是您在任务中指定的。这些信息足以理解我们在第一章中介绍的 Grunt。在第二章中,您将更深入地了解构建任务本身,它们应该如何工作,以及您如何组合任务来创建用于开发和发布及部署的构建工作流程。
附录 C. 选择你的构建工具
决定一项技术总是困难的。你不想做出无法撤销的承诺,但最终你必须做出选择。在构建技术方面的承诺在这方面没有不同:这是一个重要的选择,你应该这样对待它。
为了这本书的目的,我决定选择 Grunt 作为我的首选构建工具。我努力不偏重于 Grunt 特定的概念,而是从更广泛的角度解释构建过程,将 Grunt 作为一种辅助手段——达到目的的手段。我选择 Grunt 的原因有几个;以下列表中展示了其中的一些:
-
Grunt 在 Windows 上也有一个健康的社区。
-
它非常受欢迎;它甚至被 Node 社区之外的人使用。
-
它很容易学习;你选择插件并配置它们。不需要使用高级概念,也不需要任何先前的知识。
这些都是使用 Grunt 在书中教授构建过程的好理由,但我想明确指出,我不认为 Grunt 是唯一最佳选择;其他流行的构建工具可能比 Grunt 更适合你的需求。
我写这个附录是为了帮助你理解我在前端开发工作流程中最常用的三个构建工具之间的区别:
-
本书所使用的配置驱动型构建工具 Grunt
-
npm,一个也可以作为构建工具使用的包管理器
-
Gulp,一个介于 Grunt 和 npm 之间的代码驱动型构建工具
我还会列出一些特定工具可能比其他工具更好的情况。
在阅读这个附录之前,你应该阅读这本书的第一部分和附录 A。Grunt 在附录 A 中介绍,并在第一部分中进行了全面介绍。在这个附录中,我假设你对 Grunt 有基本了解。作为第一步,让我们讨论 Grunt 优于其他工具的地方。
C.1. Grunt:优点
Grunt 的最佳特性是其易用性。它使程序员能够几乎毫不费力地使用 JavaScript 开发构建流程。所需做的只是搜索合适的插件,阅读其文档,然后安装和配置它。这种易用性意味着大型开发团队中的成员,他们的技能水平往往不同,在调整构建流程以满足项目最新的需求时不会有任何困难。团队也不需要精通 Node;他们只需要向配置对象添加属性,并将任务名称添加到构建流程的不同数组中。
Grunt 的插件库足够大,你很少需要自己开发构建任务,这也使得你和你的团队能够快速开发构建过程。如果你采取的是先构建的方法,即使只是小步骤和逐步开发构建流程,这种快速开发也是至关重要的。
通过 Grunt 管理部署也是可行的,因为存在许多用于这些任务的包,例如grunt-git、grunt-rsync和grunt-ec2。
C.2. Grunt:缺点
Grunt 的不足之处在哪里?如果你有一个相当大的构建流程,它可能会变得过于冗长。一旦开发了一段时间,通常很难理解整个构建流程。当你的构建流程中的任务数量达到两位数时,几乎可以肯定你会发现自己需要单独运行属于同一任务的目标,以便以正确的顺序组合流程。
由于任务是声明性配置的,你也会很难弄清楚任务的执行顺序。此外,当涉及到构建时,你的团队应该致力于编写可维护的代码。在 Grunt 的情况下,你将维护每个任务的配置文件,或者至少是团队使用的每个构建流程的配置文件。
现在我们已经确定了 Grunt 的优点和缺点,以及它可能适合你项目的场景,让我们谈谈 npm:它如何作为构建工具使用以及它与 Grunt 的不同之处。
C.3. npm 作为构建工具
要将 npm 用作构建工具,你需要一个 package.json 文件和 npm 本身。为 npm 定义任务就像在你的包清单中添加scripts对象的属性一样简单。属性名将用作任务名,值将是你要执行的命令。以下代码片段代表一个典型的 package.json 文件,使用 JSHint 命令行界面通过 JavaScript 文件运行 lint 器并检查错误。使用 npm,你可以运行任何可用的 shell 命令:
Grunt 概述
Grunt 有以下优点:
-
数千个插件可以满足你的需求。
-
易于理解和调整的配置。
-
只需要基本的 JavaScript 理解。
-
支持跨平台开发。是的,甚至是 Windows!
-
对于大多数团队来说效果很好。
Grunt 有一些缺点:
-
随着配置的构建定义越来越大,它们变得越来越难以管理。
-
当涉及许多多目标任务定义时,很难跟踪构建流程。
-
Grunt 比其他构建工具慢得多。
{
"scripts": {
"test": "jshint . --exclude node_modules"
},
"devDependencies": {
"jshint": "².5.1"
}
}
一旦定义了任务,你就可以通过运行以下命令在你的命令行中执行它:
npm run test
注意,npm 为特定的任务名提供了快捷方式。在test的情况下,你可以执行npm test并省略run动词。你可以在脚本声明中通过链式npm run命令来组合构建流程。以下列表允许你在执行lint任务后立即运行unit任务,通过执行npm test命令。
列表 C.1. 将 npm run命令链起来以创建构建流程
{
"scripts": {
"lint": "jshint . --exclude node_modules",
"unit": "tape test/*",
"test": "npm run lint && npm run unit"
},
"devDependencies": {
"jshint": "².5.1",
"tape": "².10.2"
}
}
你也可以将任务作为后台作业来调度,使其异步执行。假设你有一个以下这样的包文件,其中你将在你的 JavaScript 构建流程中复制一个目录,并在你的 CSS 构建流程中编译 Stylus 样式表(Stylus 是 CSS 预处理器)。在这种情况下,异步运行任务是最理想的。你可以使用 & 作为分隔符,或者在命令之后,如你的包描述文件中的以下列表所示。之后,你可以执行 npm run build 来并发处理这两个步骤。
列表 C.2. 使用 Stylus
{
"scripts": {
"build-js": "cp -r src/js/vendor bin/js",
"build-css": "stylus src/css/all.styl -o bin/css",
"build": "npm run build-js & npm run build-css"
},
"devDependencies": {
"stylus": "⁰.45.0"
}
}
有时候一个 shell 命令是不够的,你可能需要一个 Node 包,比如 stylus 或 jshint,正如你在最后几个例子中看到的。这些依赖应该通过 npm 安装。
C.3.1. 安装 npm 任务依赖
JSHint CLI 并不一定在你的系统中可用,你有两种安装它的方法:
-
在全局范围内,当你从命令行使用它时
-
当在
npm run任务中使用时将其添加为 devDependency
如果你想要直接从命令行使用这个工具,而不是在 npm run 任务中,你应该使用以下命令中的 -g 标志全局安装它:
npm install -g jshint
如果你在一个 npm run 任务中使用这个包,那么你应该将其添加为 dev-Dependency,如下面的命令所示。这允许 npm 在任何已安装包依赖的系统上找到 JSHint 包,而不是期望环境全局安装了 JSHint。这适用于任何在操作系统中不可直接使用的 CLI 工具。
npm install --save-dev jshint
你不仅限于使用 CLI 工具。实际上,npm 可以运行任何 shell 脚本。让我们深入探讨这一点!
C.3.2. 在 npm 任务中使用 shell 脚本
以下是一个在 Node 上运行并显示随机表情字符串的脚本示例。第一行告诉环境脚本是在 Node 环境中。
#!/usr/bin/env node
var emoji = require('emoji-random');
var emo = emoji.random();
console.log(emo);
如果你将这个脚本放在项目根目录下名为 emoji 的文件中,你将不得不将 emoji-random 声明为依赖,并将命令添加到包描述文件中的 scripts 对象:
{
"scripts": {
"emoji": "./emoji"
},
"devDependencies": {
"emoji-random": "⁰.1.2"
}
}
一旦这些问题解决,运行命令就只是在你终端中调用 npm run emoji 的事情,这将执行你在包描述文件 scripts 属性中为 emoji 指定的命令。
C.3.3. npm 与 Grunt 对比:优点与缺点
使用 npm 作为构建工具相较于 Grunt 有几个优势:
-
你不受 Grunt 插件的限制,可以利用 npm 的所有功能,npm 上托管了成千上万的包。
-
你不需要除了
npm以外的任何额外的 CLI 工具或文件,你已经在使用npm来管理依赖和你的package.json描述文件,其中列出了依赖和你的构建命令。因为npm直接运行 CLI 工具和 Bash 命令,所以它的性能将远远优于 Grunt。
考虑到 Grunt 最大的缺点之一是它是 I/O 密集型的。大多数 Grunt 任务都是从磁盘读取然后写入磁盘。如果你有多个任务在处理相同的文件,那么文件可能会被多次从磁盘读取。在 Bash 中,命令可以直接将一个命令的输出管道传输到下一个命令,从而避免了 Grunt 中额外的 I/O 负载。
npm 可能最大的缺点是 Bash 在 Windows 环境中表现不佳。使用 npm run 的开源项目在人们尝试在 Windows 上修改它们时可能会遇到问题。类似地,Windows 开发者会尝试使用 npm 的替代品。这个缺点几乎排除了 npm 在需要运行在 Windows 上的项目中的应用。
Gulp,另一个构建工具,与 Grunt 和 npm 都有相似之处,你很快就会发现。
C.4. Gulp: 流式构建工具
Gulp 与 Grunt 类似,因为它依赖于插件并且是跨平台的,也支持 Windows 用户。Gulp 是一个代码驱动的构建工具,与 Grunt 的声明式任务定义方法相比,使你的任务定义更容易阅读。Gulp 也类似于 npm run,因为它使用 Node 流来读取文件并通过函数将数据转换为最终写入磁盘的输出。这意味着 Gulp 没有你在使用 Grunt 时可能观察到的磁盘密集型 I/O 问题。它也比 Grunt 快,原因相同:减少了 I/O 的时间。
使用 Gulp 的主要缺点是它严重依赖于流、管道和异步代码。请别误会;如果你喜欢 Node,那确实是一个优点。但问题是,除非你和你的团队对 Node 非常熟悉,否则如果你必须构建自己的 Gulp 任务插件,你可能会遇到处理流的难题。
Gulp
关于 Gulp 有几点是很好的:
-
高质量的插件很容易获得。
-
代码驱动意味着你的 Gulpfile 比配置驱动的 Gruntfile 更容易理解。
-
比 Grunt 快,因为它使用流管道而不是每次都读写磁盘。
-
与 Grunt 一样,支持跨平台开发。
Gulp 也有一些缺点:
-
如果你没有 Node 的经验,学习起来可能有些困难。
-
由于类似的原因,开发高质量的插件也很困难。
-
你的整个团队(现有成员和潜在成员)都应该熟悉流和异步代码。
-
任务依赖系统还有许多需要改进的地方。
当团队协作时,Gulp 并不像 npm 那样具有约束性。你们前端团队中的大多数人可能知道 JavaScript,尽管他们可能不太擅长 Bash 脚本编写,而且有些人可能在使用 Windows!这就是为什么我通常建议将 npm run 保留在个人项目中,并在团队对 Node 感到舒适的项目中使用 Gulp,在其他所有地方使用 Grunt。这是我个人的观点;找出对你和你的团队最有效的方法。此外,你不应该将自己局限于 Grunt、Gulp 或 npm run,因为那些工具对我有用。进行研究,也许你会找到一个你甚至比这三个更好的工具。
让我们通过几个示例来了解 Gulp 任务的模样。
在 Gulp 中运行测试
Gulp 在其约定上与 Grunt 类似。在 Grunt 中有一个 Gruntfile.js 文件,用于定义你的构建任务,而在 Gulp 中文件需要命名为 Gulpfile.js。其他小的区别是,在 Gulp 的情况下,CLI 包含在同一个包中,因此你必须在本地和全局范围内从 npm 安装 gulp 包:
touch Gulpfile.js
npm install -g gulp
npm install --save-dev gulp
要开始,我将创建一个 Gulp 任务来检查 JavaScript 文件,使用 JSHint 的方式,就像你已经用 Grunt 和 npm run 看到的那样。在 Gulp 的情况下,你必须安装 gulp-jshint Gulp 插件用于 JSHint:
npm install --save-dev gulp-jshint
现在你已经完全配备了全局安装的 CLI、本地的 gulp 安装和 gulp-jshint 插件,你可以组合构建任务来运行检查器。要使用 Gulp 定义构建任务,你必须以编程方式在 Gulpfile.js 文件中编写它们。
首先,使用 gulp.task,传递给它一个任务名称和一个函数。该函数包含运行该任务所需的所有代码。在这里,你应该使用 gulp.src 来创建一个读取流到你的源文件。你可以提供单个文件的路径,或者使用你学习 Grunt 时看到的 globbing 模式。相同的流应该被管道输入到 JSHint 插件中,你可以配置或使用它自带默认设置。然后你只需要将 JSHint 任务的输出通过一个报告器管道,并将其打印到你的终端。我描述的所有内容都导致了以下 Gulpfile:

我还应该提到,你正在返回流,这样 Gulp 就会明白在它认为任务完成之前,它应该等待数据停止流动。你可以使用自定义的 JSHint 报告器来使输出更简洁,更易于人类阅读。JSHint 报告器不需要是 Gulp 插件,所以你可以使用 jshint-stylish 这样的工具。让我们在本地上安装它:
npm install --save-dev jshint-stylish
更新的 Gulpfile 应该看起来像以下代码。它将加载 jshint-stylish 模块来格式化报告输出。
var gulp = require('gulp');
var jshint = require('gulp-jshint');
gulp.task('test', function () {
return gulp
.src('./sample.js')
.pipe(jshint())
.pipe(jshint.reporter('jshint-stylish'));
});
完成了!这就是你需要做的所有事情来声明一个名为 test 的 Gulp 任务。如果你全局安装了 gulp CLI,则可以使用以下命令运行它:
gulp test
那只是一个简单的例子。你可以将 JSHint 检查器的输出通过一个报告器传递,该报告器会打印出 linting 测试的结果。你还可以使用 gulp.dest 将输出写入磁盘,它创建了一个写入流。让我们逐步分析另一个构建任务。
在 Gulp 中构建库
要开始,让我们做最基本的事情——使用 gulp.src 从磁盘读取,并将源文件的内容通过 gulp.dest 写回磁盘,实际上是将文件复制到另一个目录:
var gulp = require('gulp');
gulp.task('build', function () {
return gulp
.src('./sample.js')
.pipe(gulp.dest('./build'));
});
复制文件是件好事,但它不会压缩其内容。要这样做,你必须使用一个 Gulp 插件。在这种情况下,你可以使用 gulp-uglify,这是一个流行的 UglifyJS 压缩器的插件:
var gulp = require('gulp');
var uglify = require('gulp-uglify');
gulp.task('build', function () {
return gulp
.src('./sample.js')
.pipe(uglify())
.pipe(gulp.dest('./build'));
});
如你可能已经意识到的,流允许你在只读取和写入磁盘一次的情况下添加更多插件。作为一个例子,让我们通过 gulp-size 也进行管道处理,这将计算缓冲区内容的尺寸并将其打印到终端。请注意,如果你在 Uglify 之前添加它,你会得到未压缩的尺寸,如果在之后添加,你会得到压缩后的尺寸。你也可以两者都做!
var gulp = require('gulp');
var uglify = require('gulp-uglify');
var size = require('gulp-size');
gulp.task('build', function () {
return gulp
.src('./sample.js')
.pipe(uglify())
.pipe(size())
.pipe(gulp.dest('./build'));
});
为了强调根据需要添加或删除管道的能力,让我们添加最后一个插件。这次你将使用 gulp-header 为压缩后的代码片段添加许可信息,例如名称、包版本和许可类型。要运行以下列表中显示的示例,请在命令行中输入 gulp build。
列表 C.3. 使用 gulp-header 添加许可信息
var gulp = require('gulp');
var uglify = require('gulp-uglify');
var size = require('gulp-size');
var header = require('gulp-header');
var pkg = require('./package.json');
var info = '// <%= pkg.name %>@v<%= pkg.version %>, <%= pkg.license %>\n';
gulp.task('build', function () {
return gulp
.src('./sample.js')
.pipe(uglify())
.pipe(header(info, { pkg : pkg }))
.pipe(size())
.pipe(gulp.dest('./build'));
});
与 Grunt 一样,在 Gulp 中,你可以通过传递一个任务名称数组到 gulp.task 来定义流程,而不是一个函数。在这方面,Grunt 和 Gulp 的主要区别在于 Gulp 以异步方式执行这些依赖项,而 Grunt 以同步方式执行。
gulp.task('build', ['build-js', 'build-css']);
在 Gulp 中,如果你想同步运行任务,你必须将任务声明为一个依赖项,然后定义你自己的任务。所有依赖项都在你的任务开始之前执行。
gulp.task('build', ['dep'], function () {
// here goes the task that depends on 'dep'
});
如果你从这个附录中带走任何东西,那应该是这样的:无论你使用哪种工具,只要它能让你以不让你过于费力的方式组合所需的构建流程即可。
附录 D. JavaScript 代码质量指南
本风格指南旨在为应用程序的 JavaScript 代码提供基本规则,以便它易于阅读且在不同开发者之间保持一致。重点是确保应用程序不同部分的质量和连贯性。
D.1. 模块组织
本风格指南假设你正在使用模块系统,例如 CommonJS,^([1]) AMD,^([2]) ES6 Modules,^([3]) 或任何其他类型的模块系统。要全面了解模块系统,请参阅第五章(kindle_split_017.html#ch05);我会等待。
¹ CommonJS 模块规范在
bevacqua.io/bf/commonjs上托管一个维基页面。² RequireJS 在
bevacqua.io/bf/amd上有一篇关于 AMD 目的的综合文章。³ 现在,开始使用 ES6 要容易得多!请参阅
bevacqua.io/bf/es6-intro。
模块系统提供单独的作用域,避免全局项目的泄漏,并通过自动化依赖图生成来改进代码库的组织,而不是需要手动创建数十个 <script> 标签。
模块系统还提供了依赖注入模式,这在单独测试组件时至关重要。
D.1.1. 严格模式
总是在模块顶部放置 "use strict";^([4])。严格模式允许你捕获无意义的操作,阻止不良实践,并且由于它允许编译器对你的代码做出某些假设,因此运行速度更快。
⁴ Mozilla 开发者网络(MDN)有一篇很好的文章解释了 JavaScript 中的严格模式,请参阅
bevacqua.io/bf/strict。
D.1.2. 缩进
应用程序中每个文件中的缩进必须保持一致。为此,强烈建议使用 Editor-Config。EditorConfig 通过在项目根目录中放置一个 .editorconfig^([5]) 文件来实现,然后你应该为你的首选文本编辑器安装 EditorConfig 插件。以下是我建议的用于开始 JavaScript 缩进的默认设置:
⁵ 在
bevacqua.io/bf/editorconfig上了解更多关于 EditorConfig 的信息。
# editorconfig.orgroot = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
EditorConfig 可以透明地处理缩进,并且每个人都可以通过按 Tab 键来一致地产生正确数量的制表符或空格。选择制表符或空格取决于项目的特定情况,但我建议使用两个空格进行缩进。
缩进不仅涉及制表符,还包括函数声明中参数之前、之后和之间的空格。这种类型的缩进通常很难正确设置,而且对于大多数团队来说,甚至很难达成一个能满足所有人的方案。
function () {}
function( a, b ){}
function(a, b) {}
function (a,b) {}
尽量将这些差异降到最低,但也不必过分考虑。
在可能的情况下,通过保持行宽在 80 个字符以下来提高可读性。
D.1.3. 分号
自动分号插入(ASI)不是一个特性。不要依赖它.^([6]) 它非常复杂^([7]),并且没有实际的理由让团队中的所有开发者都承担起了解 ASI 工作原理这种琐碎知识的负担。避免头疼;避免使用 ASI。始终在需要的地方添加分号。
⁶ Ben Alman 在
bevacqua.io/bf/semicolons上提供了关于为什么你应该使用分号而不是省略它们的良好建议。⁷ 自动分号插入(ASI)内部工作原理的指南可以在
bevacqua.io/bf/asi找到。
D.1.4. 代码检查
由于 JavaScript 不需要编译步骤来处理未声明的变量,因此代码检查几乎是必需的。再次强调,不要使用像jslint([8])那样对代码风格有强烈偏见的代码检查器。相反,使用更宽容的工具,如`jshint`([9])或eslint^([10])。以下是在使用 JSHint 时的一些提示:
⁸ JSLint,最初的 JavaScript 代码检查器,今天仍然可以在线使用
bevacqua.io/bf/jslint。⁹ JSHint 是一个现代替代品,在构建过程中被广泛观察。可以在
bevacqua.io/bf/jshint找到。¹⁰ ESLint 是另一个代码检查工具,旨在减少对样式检查的关注。可以在
bevacqua.io/bf/eslint找到它。
-
声明一个
.jshintignore文件,并包括node_modules、bower_components等。 -
你可以使用以下类似文件来保持你的规则在一起:
{
"curly": true,
"eqeqeq": true,
"newcap": true,
"noarg": true,
"noempty": true,
"nonew": true,
"sub": true,
"undef": true,
"unused": true,
"trailing": true,
"boss": true,
"eqnull": true,
"strict": true,
"immed": true,
"expr": true,
"latedef": "nofunc",
"quotmark": "single",
"indent": 2,
"node": true
}
这些规则绝对不是你应该坚持的规则,但找到不进行代码检查和过于关注编码风格之间的平衡是很重要的。如果放任不管,你可能会陷入常见的错误,比如遗漏分号或错误地关闭字符串引号,但如果做得太过分,你可能会发现团队花更多的时间处理代码风格而不是编写有意义的代码。
D.2. 字符串
字符串应该始终使用相同的引号进行引用。在整个代码库中一致地使用'或"。确保团队在编写 JavaScript 代码的每个部分都使用相同的引号。
差劲的字符串
var message = 'oh hai ' + name + "!";
优秀的字符串
var message = 'oh hai ' + name + '!';
通常,如果你在 Node 中创建一个参数替换方法,比如util.format,你会成为一个更快乐的 JavaScript 开发者.^([11]) 这样做会使字符串格式化变得容易得多,代码看起来也更整洁。
¹¹ Node 的 util.format 文档可以在
bevacqua.io/bf/util.format找到。
更好的字符串
var message = util.format('oh hai %s!', name);
你可以使用以下代码片段实现类似的功能:
function format () {
var args = [].slice.call(arguments);
var initial = args.shift();
function replacer (text, replacement) {
return text.replace('%s', replacement);
}
return args.reduce(replacer, initial);
}
为了声明多行字符串,尤其是当谈论 HTML 片段时,有时最好使用数组作为缓冲区,然后连接其部分。字符串连接样式可能更快,但跟踪起来也更困难:
var html = [
'<div>',
format('<span class="monster">%s</span>', name),
'</div>'
].join('');
使用数组构建器样式,您也可以推送片段的部分,然后在最后将所有内容连接在一起。这是 Jade^(12))等字符串模板引擎喜欢做的事情。
^(12) 通过访问他们的 GitHub 仓库
bevacqua.io/bf/jade了解更多关于 Jade 模板的信息。
D.2.1. 变量声明
总是以一致的方式声明变量,并在它们的范围内顶部。鼓励将变量声明保持为一行一个。逗号优先,单个var语句,多个var语句,这些都行,但要在整个项目中保持一致。确保团队中的每个人都遵循样式指南,以确保一致性。
不一致的声明
var foo = 1,
bar = 2;
var baz;
var pony;
var a
, b;
或者
var foo = 1;
if (foo > 1) {
var bar = 2;
}
注意,以下示例不仅因为其风格,还因为其陈述之间的一致性而可行。
一致的声明
var foo = 1;
var bar = 2;
var baz;
var pony;
var a;
var b;
var foo = 1;
var bar;
if (foo > 1) {
bar = 2;
}
没有立即赋值的变量声明可以共享同一行代码。
可接受的声明
var a = 'a';
var b = 2;
var i, j;
D.3. 条件语句
强制使用括号。这,加上合理的间距策略,将帮助您避免像苹果的 SSL/TLS 错误^(13))这样的错误。
^(13) 关于苹果“GOTO Fail”错误的详细报告可以在
bevacqua.io/bf/gotofail找到。
不良的条件语句
if (err) throw err;
良好的条件语句
if (err) {
throw err;
}
避免使用==和!=运算符;始终优先使用===和!==。这些运算符被称为“严格相等运算符”,而它们的对应物将尝试将操作数^(14))转换为相同的值类型。如果可能,尽量将单行条件语句也保持在多行格式中。
^(14) 相等运算符在 MDN 上有一个专门的页面
bevacqua.io/bf/equality。
不良的强制转换相等
function isEmptyString (text) {
return text == '';
}
isEmptyString(0);
// <- true
良好的严格相等
function isEmptyString (text) {
return text === '';
}
isEmptyString(0);
// <- false
D.3.1. 三元运算符
三元运算符适用于清晰的条件语句,但不适用于令人困惑的选择。一般来说,如果你不能像你的大脑一样快速地通过眼睛解析它,那么它可能过于复杂,不利于其自身。
jQuery 是代码库中充满糟糕三元运算符的一个典型例子^(15)。
^(15) 可以在
bevacqua.io/bf/jquery-ternary找到一些 jQuery 中三元运算符误用的例子。
不良的三元运算符
function calculate (a, b) {
return a && b ? 11 : a ? 10 : b ? 1 : 0;
}
良好的三元运算符
function getName (mobile) {
return mobile ? mobile.name : 'Generic Player';
}
在可能引起混淆的情况下,使用if和else语句。
D.3.2. 函数
在声明函数时,始终使用函数声明形式^([16)) 而不是函数表达式.^([17)) 如果你在将函数表达式赋值给变量之前尝试使用它们,你会得到一个错误。相比之下,函数声明会被提升^([18)) 到作用域的顶部,这意味着无论你在代码中放置它们的位置如何,它们都会正常工作。你可以在第五章 [kindle_split_017.html#ch05] 中了解关于提升的所有细节。
(16) StackOverflow 有一个涵盖函数声明的答案,链接为
bevacqua.io/bf/fn-declaration。(17) 你可以在 MDN 上找到函数表达式的简洁定义,链接为
bevacqua.io/bf/fn-expr。(18) 变量提升的解释可以在
bevacqua.io/bf/hoisting找到的代码示例中找到。
使用表达式是坏的
var sum = function (x, y) {
return x + y;
};
使用声明是好的
function sum (x, y) {
return x + y;
}
话虽如此,将函数表达式应用于另一个函数并没有什么错误.^([19))
(19) 约翰·雷西格在他的博客上解释了如何在
bevacqua.io/bf/partial-application上部分应用函数。
Currying 是好的
var plusThree = sum.bind(null, 3);
请记住,函数声明将被提升^([20)) 到作用域的顶部,所以它们的声明顺序并不重要。然而,你应该始终将它们保持在作用域的顶层,并且始终避免在条件语句中放置它们。
(20) 变量提升的解释可以在
bevacqua.io/bf/hoisting找到的代码示例中找到。
差的函数
if (Math.random() > 0.5) {
sum(1, 3);
function sum (x, y) {
return x + y;
}
}
好的函数
if (Math.random() > 0.5) {
sum(1, 3);
}
function sum (x, y) {
return x + y;
}
Or
function sum (x, y) {
return x + y;
}
if (Math.random() > 0.5) {
sum(1, 3);
}
如果你需要一个“无操作”方法,你可以使用 Function.prototype 或 function noop () {}。理想情况下,在整个应用程序中只使用一个 noop 引用。每次你必须操作 arguments 对象或其他类似数组的对象时,都要将它们转换为数组。
差的数组循环
var divs = document.querySelectorAll('div');
for (i = 0; i < divs.length; i++) {
console.log(divs[i].innerHTML);
}
好的数组循环
var divs = document.querySelectorAll('div');
[].slice.call(divs).forEach(function (div) {
console.log(div.innerHTML);
});
然而,请注意,在 V8 环境中使用这种方法在 arguments. 上会有显著的性能影响^([21))。如果性能是一个主要问题,请避免使用 slice 将 arguments 转换为数组,而改用 for 循环。
(21) 请参阅一篇关于优化函数参数操作的精彩文章,链接为
bevacqua.io/bf/arguments。
差的参数访问器
var args = [].slice.call(arguments);
更好的参数访问器
var i;
var args = new Array(arguments.length);
for (i = 0; i < args.length; i++) {
args[i] = arguments[i];
}
永远不要在循环中声明函数。
差的内联函数
var values = [1, 2, 3];
var i;
for (i = 0; i < values.length; i++) {
setTimeout(function () {
console.log(values[i]);
}, 1000 * i);
}
或者
var values = [1, 2, 3];
var i;
for (i = 0; i < values.length; i++) {
setTimeout(function (i) {
return function () {
console.log(values[i]);
};
}(i), 1000 * i);
}
更好地提取函数
var values = [1, 2, 3];
var i;
for (i = 0; i < values.length; i++) {
wait(i);
}
function wait (i) {
setTimeout(function () {
console.log(values[i]);
}, 1000 * i);
}
或者更好,使用 .forEach,它没有在 for 循环中声明函数的相同限制。
更好,使用 foreach 的函数数组
[1, 2, 3].forEach(function (value, i) {
setTimeout(function () {
console.log(value);
}, 1000 * i);
});
命名函数与匿名函数
每当一个方法不是微不足道的,都要努力使用命名的函数表达式而不是匿名函数。这使你在分析堆栈跟踪时更容易找到异常的根本原因。
差的,匿名函数
function once (fn) {
var ran = false;
return function () {
if (ran) { return };
ran = true;
fn.apply(this, arguments);
};
}
好的,有命名的函数
function once (fn) {
var ran = false;
return function run () {
if (ran) { return };
ran = true;
fn.apply(this, arguments);
};
}
通过使用保护子句而不是流动的 if 语句来避免不必要的缩进级别增加。
差
if (car) {
if (black) {
if (turbine) {
return 'batman!';
}
}
}
或者
if (condition) {
// 10+ lines of code
}
好
if (!car) {
return;
}
if (!black) {
return;
}
if (!turbine) {
return;
}
return 'batman!';
或者
if (!condition) {
return;
}
// 10+ lines of code
D.3.3. 原型
应尽量避免修改本地类型的原型;使用方法代替。如果你必须扩展本地类型的功能,尝试使用 poser^([22]) 代替。Poser 提供了上下文无关的本地类型引用,你可以安全地构建和扩展。
^(22) Poser 提供了上下文无关的本地类型引用,你可以安全地构建和扩展。更多信息,请参阅
bevacqua.io/bf/poser。
差
String.prototype.half = function () {
return this.substr(0, this.length / 2);
};
好
function half (text) {
return text.substr(0, text.length / 2);
}
除非你有充分的性能理由来证明自己,否则请避免使用典型继承模型:
-
它们比使用普通对象更冗长。
-
在创建
new对象时会引起头痛。 -
它们需要一个闭包来隐藏实例的宝贵私有状态。
-
只使用普通对象即可。
D.3.4. 对象字面量
使用埃及符号 {} 来实例化。用工厂方法代替构造函数。以下是一个供你实现对象的通用模式:
function util (options) {
// private methods and state go here
var foo;
function add () {
return foo++;
}
function reset () { // note that this method isn't publicly exposed
foo = options.start || 0;
}
reset();
return {
// public interface methods go here
uuid: add
};
}
D.3.5. 数组字面量
使用方括号符号 [] 来实例化。如果你因为性能原因必须声明一个固定维度的数组,那么使用 new Array(length) 符号也是可以的。
JavaScript 中的数组拥有丰富的 API,你应该充分利用。你可以从数组操作的基础知识^([23]) 开始,然后过渡到更高级的使用场景。例如,你可以使用 .forEach 方法遍历集合中的所有项目。
^(23) 一篇关于 JavaScript 数组的介绍性文章在我的博客上可读:
bevacqua.io/bf/arrays。
以下列表显示了你可以对数组执行的基本操作:
-
使用
.push来在集合末尾插入项目,或使用.shift来在开头插入项目。 -
使用
.pop来获取最后一个项目并从集合中移除,或者使用.unshift来对第一个项目执行相同的操作。 -
精通
.splice以通过索引删除项目,或在特定索引处插入项目,或同时执行这两项操作!
还要了解并使用函数式集合操作方法!这些方法可以节省你大量时间,否则你将不得不手动执行这些操作。以下是一些你可以做的例子:
-
使用
.filter来丢弃不感兴趣的价值。 -
使用
.map来将数组值转换成其他东西。 -
使用
.reduce来遍历数组并生成一个单一的结果。 -
使用
.some和.every来断言所有数组项目是否满足某个条件。 -
使用
.sort来排列集合中的元素。 -
使用
.reverse来反转数组中的顺序。
Mozilla 开发者网络 (MDN) 在 developer.mozilla.org/ 对所有这些方法以及更多方法进行了详尽的文档记录。
D.4. 正则表达式
将正则表达式保存在变量中;不要内联使用。这将大大提高可读性。
不良的正则表达式
if (/\d+/.test(text)) {
console.log('so many numbers!');
}
良好的正则表达式
var numeric = /\d+/;
if (numeric.test(text)) {
console.log('so many numbers!');
}
此外,学习编写正则表达式([24])以及它们的作用。然后你还可以在线可视化它们([25])。
^(24)在我的博客上有一篇关于正则表达式的入门文章,请参阅
bevacqua.io/bf/regex。^(25)Regexper 允许你可视化任何正则表达式的工作方式,请参阅
bevacqua.io/bf/regexper。
D.4.1. 调试语句
最好将你的console语句放入一个可以在生产中轻松禁用的服务中。或者,不要将任何console.log打印语句发送到生产分发。
D.4.2. 注释
注释不是用来解释代码做了什么的。好的代码应该是自我解释的。如果你正在考虑写一个注释来解释一段代码做了什么,那么你可能需要改变代码本身。这个规则的例外是解释正则表达式做了什么。好的注释应该解释代码为什么做了可能没有明确目的的事情。
不良的注释
// create the centered container
var p = $('<p/>');
p.center(div);
p.text('foo');
良好的注释
var container = $('<p/>');
var contents = 'foo';
container.center(parent);
container.text(contents);
megaphone.on('data', function (value) {
container.text(value); // the megaphone periodically emits updates for container
});
或者
var numeric = /\d+/; // one or more digits somewhere in the string
if (numeric.test(text)) {
console.log('so many numbers!');
}
完全注释掉代码块应该完全避免;这就是为什么你设置了版本控制系统的原因!
D.4.3. 变量命名
变量必须有有意义的名称,这样你就不必求助于注释来解释某个功能片段做了什么。相反,尽量做到表达清晰、简洁,并使用有意义的变量名:
不良的命名
function a (x, y, z) {
return z * y / x;
}
a(4, 2, 6);
// <- 3
良好的命名
function ruleOfThree (had, got, have) {
return have * got / had;
}
ruleOfThree(4, 2, 6);
// <- 3
D.4.4. Polyfills
Polyfill 是一段代码,它透明地使你的应用程序能够在旧浏览器中使用现代功能。尽可能使用原生浏览器实现,并为不支持该功能的浏览器包含一个提供相同行为的 polyfill^([26))。这使得代码更容易处理,并且减少了为了使事情正常工作而进行的黑客行为。
^(26)Remy Sharp 简洁地解释了什么是 polyfill,请参阅
bevacqua.io/bf/polyfill。
如果你不能用 polyfill 修复某个功能片段,那么请将所有修补代码的使用包裹在一个全局可访问的实现中,该实现可以从应用程序的任何地方访问^([27))。
^(27)我写了一篇关于开发高质量模块的文章,其中涉及实现包装的主题,请参阅
bevacqua.io/bf/hq-modules。
D.4.5. 每日技巧
创建默认值
使用||来定义默认值。如果左边的值是假值^([28)),则将使用右边的值。
^(28)在 JavaScript 中,在条件语句中,假值被视为 false。假值包括‘’,
null,undefined和0。更多信息,请参阅bevacqua.io/bf/casting。
function a (value) {
var defaultValue = 33;
var used = value || defaultValue;
}
使用 bind 部分应用函数
使用.bind来部分应用^(29)函数:
^(29) 以 jQuery 闻名的 John Resig 在
bevacqua.io/bf/partial-application上发表了一篇关于部分 JavaScript 函数的有趣文章。
function sum (a, b) {
return a + b;
}
var addSeven = sum.bind(null, 7);
addSeven(6);
// <- 13
使用Array.prototype.slice.call将类数组对象转换为数组
使用Array.prototype.slice.call将类数组对象转换为真正的数组:
var args = Array.prototype.slice.call(arguments);
所有事物的事件发射器
在所有事物上使用事件发射器^(30)!这种模式有助于您将实现与不同对象或应用层之间的消息传递解耦:
^(30) Contra 提供了一个易于使用的事件发射器实现
bevacqua.io/bf/contra.emitter。
var emitter = contra.emitter();
body.addEventListener('click', function () {
emitter.emit('click', e.target);
});
emitter.on('click', function (elem) {
console.log(elem);
});
// simulate click
emitter.emit('click', document.body);
Function.prototype作为无操作
将Function.prototype用作“无操作”:
function (cb) {
setTimeout(cb || Function.prototype, 2000);
(10) 数据库任务的代码示例可以在网上找到,链接为
bevacqua.io/bf/db-tasks↩︎[1] ↩︎






浙公网安备 33010602011771号