规模化的-JavaScript-全-

规模化的 JavaScript(全)

原文:zh.annas-archive.org/md5/310075695FB63536AA5B7DE9945E79F9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

有些应用程序就是做得很好。这些是例外,而不是规则。许多 JavaScript 应用程序对一件事或两件事做对了,而对其他事情做得很糟糕。我们做错的事情是我们从未考虑过的规模影响因素的副作用。这本书是关于扩展我们的前端架构以满足我们所要求的质量要求。扩展 JavaScript 应用程序是一个有趣且有趣的问题。有这么多移动的部件——用户、开发者、部署环境、浏览器环境以及将这些因素结合起来形成有意义的用户体验的任务。我们在扩展什么,为什么?这本书的目的是帮助我们回答这些问题。

本书涵盖内容

第一章,从 JavaScript 的角度看待规模,介绍了可扩展的 JavaScript 应用程序的概念以及它们与其它扩展应用程序的不同之处。

第二章,规模影响因素,帮助我们理解需要扩展可以帮助我们设计更好的架构。

第三章,组件组合,解释了形成我们架构核心的模式如何作为组装组件的蓝图。

第四章,组件通信和职责,解释了相互通信的组件是扩展约束。它告诉我们特性是由组件通信模式的结果。

第五章,可寻址性和导航,详细讨论了具有指向资源的 URI 的大型 web 应用程序,以及如何扩展的设计可以处理越来越多的 URI。

第六章,用户偏好和默认值,告诉我们为什么用户需要控制我们软件的某些方面。它还解释了可扩展的应用程序组件是可配置的。

第七章,加载时间和响应性,解释了更多的移动部件意味着整个应用程序的性能会下降。这包括在进行新功能添加的同时,保持我们的 UI 具有响应性所做的权衡。

第八章,可移植性和测试,讨论了如何编写不受单一环境紧密耦合的 JavaScript 代码。这包括创建可移植的模拟数据和可移植的测试。

第九章,缩减规模,解释了如果想要在其他领域进行扩展,从应用程序中删除未使用或有缺陷的组件是必要的。

第十章,应对失败,解释了大规模 JavaScript 架构不会因为一个组件的错误而崩溃。这包括如何考虑失败的设计是实现广泛场景下的规模的关键。

本书所需材料

  • 节点 JS

  • 代码编辑器/集成开发环境

  • 现代网络浏览器

本书面向读者

本书适合于对前端架构问题感兴趣的高级 JavaScript 开发者。无需先掌握任何框架知识,然而,书中介绍的多数概念是 Backbone、Angular 或 Ember 等框架中组件的适应。需要强大的 JavaScript 语言技能,并且所有代码示例都采用 ECMAScript 6 语法呈现。

约定

在本书中,您会找到一些区分不同信息种类的设计样式。以下是这些样式的一些示例及其含义的解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理显示如下:"例如,users/31729。在这里,路由需要找到与这个字符串匹配的模式,并且该模式也将指定如何提取31729变量。"

代码块如下所示设置:

// Renders the sections of the view. Each section
    // either has a renderer, or it doesn't. Either way,
    // content is returned.
    render() {

注意

警告或重要说明以这样的盒子形式出现。

提示

技巧和小窍门像这样出现。

读者反馈

来自我们读者的反馈总是受欢迎的。告诉我们您对这本书的看法——您喜欢或不喜欢的部分。读者反馈对我们来说很重要,因为它帮助我们开发出您能真正从中受益的标题。

要发送给我们一般性反馈,只需电子邮件<feedback@packtpub.com>,并在消息主题中提到本书的标题。

如果您在某个主题上有专业知识,并且对撰写或贡献书籍感兴趣,请查看我们的作者指南:www.packtpub.com/authors

客户支持

既然您是 Packt 书籍的自豪拥有者,我们有很多东西可以帮助您充分利用您的购买。

下载示例代码

您可以从www.packtpub.com下载本书的示例代码文件,该网站为您购买的所有 Packt Publishing 书籍提供下载。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support注册,以便将文件直接通过电子邮件发送给您。

勘误

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

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

盗版

互联网上侵犯版权材料是一个持续存在的问题,涵盖所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上以任何形式发现我们作品的非法副本,请立即提供给我们位置地址或网站名称,以便我们可以寻求解决方案。

如果您怀疑有侵权材料,请通过<copyright@packtpub.com>联系我们并提供链接。

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

问题

如果您对本书任何方面有问题,您可以联系<questions@packtpub.com>,我们将尽力解决问题。

第一章.从 JavaScript 视角看规模化

JavaScript 应用程序正在变得更大。这是因为我们可以用这门语言做更多的事情——比大多数人想象的还要多。毕竟,JavaScript 被设想为激活其他静态网页的手段。一种填充 HTML 空白的手段。年复一年,越来越多的网站开始开发 JavaScript 代码以提高其页面的功能性。

尽管某些语言特色让人感到挫败,但 JavaScript 的流行度已经达到了临界质量——今天它成为了 GitHub 上最受欢迎的编程语言(githut.info/). 从那时起,网站开始看起来更像是在用户桌面上安装的应用程序。库和框架如雨后春笋般涌现。为什么?因为前端 JavaScript 应用程序很大且复杂。

在当今的前端开发职业中,我们有大量的工具可供选择。JavaScript 语言已经发展到了可以独立使用的程度;它越来越不依赖于库来执行最基本和最基础的编程任务。这尤其适用于 ECMAScript 规范的下一版,其中添加到语言中的构造部分解决了困扰开发者多年的问题。当然,这并不否定应用程序框架的必要性。前端开发环境和其支持的网络标准离完美还远,但它们正在改善。

长期以来在前端开发领域缺失的一环是架构。由于实施内容的复杂性,前端架构近年来变得普遍。复杂的工具允许前端开发者设计一种能够与我们要解决的问题一起扩展的架构。这本书的核心就是可扩展的 JavaScript 架构。但扩展到什么程度呢?这并不是传统计算领域中的扩展问题,在那里你需要在一个分布式服务器环境中处理更多的负载。前端扩展提出了它自己独特的挑战和约束。本章将定义 JavaScript 架构面临的一些扩展问题。

影响者规模化

我们并不是仅仅因为能够扩展就扩展我们的软件系统。虽然可扩展性常常被吹嘘,但这些主张需要付诸实践。为了这样做,可扩展软件必须有它的理由。如果没有扩展的必要,那么简单地构建一个不可扩展的系统既简单又经济。把为处理各种扩展问题而构建的东西放入一个不需要扩展的上下文中,这只会让人感到笨拙。特别是对于最终用户来说。

因此,作为 JavaScript 开发者和架构师,我们需要承认并理解那些需要可扩展性的影响因素。虽然并非所有 JavaScript 应用程序都需要扩展,但这并不总是绝对的。例如,很难说我们知道这个系统不会以任何有意义的方式需要扩展,因此我们不要投入时间和精力使其具有可扩展性。除非我们正在开发一个一次性的系统,否则总会对增长和成功有所期待。

在光谱的另一端,JavaScript 应用程序并非天生就是成熟的可扩展系统。它们随着时间成长,在此过程中积累可扩展的属性。影响因子是那些从事 JavaScript 项目工作的有效工具。我们不希望从构思阶段就开始过度工程化,也不希望构建的东西过早地被早期决策所束缚,限制其扩展能力。

扩展的需求

扩展软件是一个反应性事件。考虑影响因子有助于我们主动为这些扩展事件做准备。在其他系统中,比如 Web 应用程序后端,这些扩展事件可能是短暂的峰值,并且通常会自动处理。例如,由于更多用户发出更多请求而增加了负载。负载均衡器介入,平均地将负载分配到后端服务器上。在极端情况下,当需要时系统可能会自动提供新的后端资源,并在不再需要时销毁它们。

前端发生的扩展事件并非如此。实际上,通常发生的扩展事件发生的时间更长,且更复杂。JavaScript 应用程序的独特之处在于,它们可用的唯一硬件资源就是运行它们的浏览器中的资源。它们从后端获取数据,这可能扩展得很好,但这不是我们关心的。随着我们的软件的增长,成功执行某事的必要副作用是我们需要注意可扩展性的影响因子。

扩展的需求

前面的图表向我们展示了从上至下的影响因子图表,从用户开始,他们要求我们的软件实现功能。根据功能的各种方面,比如它们的大小以及它们与其他功能的关系,这影响了负责功能开发的开发团队。随着影响因子的扩大,这种影响也在增长。

增长的用户基础

我们不仅仅为一位用户构建应用。如果是这样,那就没有扩大努力的必要。虽然我们所构建的东西可能是基于一位用户代表的 requirements,但我们的软件服务于许多用户。我们需要预见我们应用发展过程中的不断增长的客户基础。尽管我们可能根据应用的性质设定活跃用户数量的目标,例如,通过使用像www.alexa.com/这样的工具来参考类似应用。例如,如果我们的应用面向公众互联网,我们希望有很多注册用户。另一方面,我们可能会针对私有安装,在那里,系统中的用户加入速度可能会慢一些。但在后一种情况下,我们仍然希望部署数量增加,从而使使用我们软件的总人数增加。

与我们的前端互动的用户数量是影响扩展性的最大因素。每增加一个用户,以及随着各种架构视角的加入,增长是呈指数级的。如果你从自上而下的角度来看,用户掌握着主动权。归根结底,我们的应用是为了服务他们。我们越能有效地扩展 JavaScript 代码,我们就越能取悦更多用户。

构建新特性

成功的软件,尤其是拥有庞大用户基础的软件,最明显的副作用可能是为了保持用户满意度而必须添加的新特性。随着系统用户的增长,功能集合也在不断增加。尽管新特性显而易见,但这个方面常常被项目忽视。我们知道新特性正在路上,然而,却很少思考无休止的新特性如何妨碍我们扩大努力的规模。

当软件还处于起步阶段时,这种情况尤其棘手。开发软件的组织会不遗余力地吸引新用户。在初期这样做似乎没有太大后果,因为副作用有限。没有很多成熟的功能,没有庞大的开发团队,也不太可能因为破坏了用户依赖的某项功能而惹恼现有用户。当这些因素不存在时,我们更容易灵活地推出新特性,让现有和潜在用户眼花缭乱。但我们如何迫使自己关注这些早期设计决策?我们如何确保自己不会不必要地限制我们扩展软件支持更多特性的能力?

正如我们将在本书中看到的那样,新功能的开发以及现有功能的增强是可扩展的 JavaScript 架构持续面临的问题。我们不应该只关注我们软件市场营销文献中列出的功能数量。我们还需要考虑特定功能的复杂性,各个功能之间的通用性,以及每个功能有多少活动部分。从顶层视角来看,用户是第一层,每个功能是下一层,从那里开始,它会扩展到巨大的复杂性。

让一个功能变得复杂不仅仅是单个用户的问题。相反,是一群都需要同一个功能才能有效使用我们的软件的用户。从那里开始,我们必须开始考虑人物角色,或者职责,以及哪些职责对哪些角色可用。这种组织结构的需求在游戏进行到很晚之后才会变得明显;在我们做出决定使引入基于角色的功能交付变得困难之后。而且,根据我们的软件是如何部署的,我们可能需要支持各种独特的用例。例如,如果我们有多个大型组织作为客户,每个组织都有自己的部署,他们很可能对用户结构有自己的独特限制。这是具有挑战性的,我们的架构需要支持许多组织的不同需求,如果我们想要扩展。

雇佣更多的开发者

将这些功能变为现实需要精通 JavaScript 的开发人员,他们知道自己在做什么,如果我们有幸,我们能够雇佣他们组成一个团队。团队的建设不是自动发生的。在团队成员开始积极依赖彼此输出一些精彩的代码之前,需要建立一定程度的信任和尊重。一旦开始发生这种情况,我们就处于良好的状态。再次从我们扩展的影响者的顶层视角来看,我们交付的功能可以直接影响我们团队的士气。维持一种平衡基本上是不可能的,但至少我们可以接近它。功能太多,开发者太少,会导致团队成员之间产生持续的不安全感。当没有机会交付预期中的东西时,尝试就没有多大意义了。另一方面,如果你有太多的开发者,由于功能有限,导致沟通成本过高,很难定义责任。当没有共享的责任理解时,事情开始崩溃。

实际上,处理想要开发的功能却缺乏足够的开发者要比处理开发者过多要容易。当 feature 开发负担很重时,退一步思考—"如果我们有更多开发者,我们会怎么做 differently?" 这个问题通常会被忽略。我们去招聘更多的开发者,他们到来之后,让大家都惊讶的是,features 的吞吐量并没有立即得到改善。这就是为什么拥有一个开放的开发生态文化很重要,在那里,没有人会问愚蠢的问题,责任定义明确。

没有一种正确的团队结构或开发方法论。开发团队需要致力于解决我们试图交付的软件所面临的问题。最大的挑战无疑是功能的数量、大小和复杂性。因此,在组建团队时,我们需要考虑这个问题,以及在团队扩充时也是如此。尤其是后者,因为软件还处于初期阶段时我们所使用的团队结构,可能不适合功能扩展时我们所面临的挑战。

架构视角

前一部分内容是对影响 JavaScript 应用程序扩展性的因素的采样。从顶部开始,每一个影响因素都会影响它下面的影响因素。我们的用户数量和性质是我们首先要考虑的影响因素,这对我们开发的功能数量和性质有直接影响。进一步地,开发团队的规模和结构也受到这些功能的影响。我们的任务是将这些扩展性的影响因素,转化为从架构视角考虑的因素:

架构视角

扩展性影响了我们的架构视角。我们的架构反过来又决定了对扩展性影响因素的响应。这个过程是迭代和无休止的,贯穿我们软件的整个生命周期。

浏览器是一个独特的环境

在传统意义上的扩展性在浏览器环境中实际上并不真正有效。当后端服务因需求过载而无法应对时,常见的做法是“堆砌更多硬件”来解决问题。当然,说起来容易做起来难,但与 20 年前相比,如今扩展我们的数据服务要容易得多。当今的软件系统都是设计为可扩展的。如果后端服务总是可用且总是有响应,这对我们的前端应用程序是有帮助的,但这只是我们面临的问题中的一部分。

我们不能给运行我们代码的网络浏览器增加更多硬件;鉴于这一点,我们算法的时间和空间复杂性很重要。桌面应用程序通常有一组运行软件的系统要求,比如操作系统版本、最小内存、最小 CPU 等。如果我们在我们 JavaScript 应用程序中宣传这些要求,我们的用户基础会大幅减少,可能会引发一些仇恨邮件。

期望基于浏览器的网络应用程序简洁且快速,这是一种新兴现象。也许,这在一定程度上是由于我们面临的竞争。有很多膨胀的应用程序 out there,无论它们是在浏览器中使用还是在本地下载,用户都知道膨胀的感觉是什么,通常会避开:

浏览器是一个独特的环境

JavaScript 应用程序需要许多资源,所有这些资源都有不同的类型;这些资源都由浏览器代表应用程序获取。

增加我们麻烦的一个事实是,我们正在使用一个设计用来下载和显示超文本、点击链接并重复的平台。现在我们做的是同样的事情,只不过是用完整的应用程序。多页面应用程序正逐渐被单页面应用程序所取代。说到这里,应用程序仍然被当作一个网页来处理。尽管如此,我们正处在巨大的变革之中。浏览器是一个完全可行的网络平台,JavaScript 语言正在成熟,还有许多 W3C 规范正在制定中;它们帮助我们的 JavaScript 更像一个应用程序,而不是一个文档。请看下面的图表:

浏览器是一个独特的环境

网络平台中发现的技术的样本

我们使用架构视角来评估我们提出的任何架构设计。这是一种强大的技术,通过不同的镜头检查我们的设计。JavaScript 架构也不例外,尤其是对于那些可扩展的架构。JavaScript 架构与其他环境架构的区别在于我们有独特的视角。浏览器环境要求我们以不同的方式思考设计、构建和部署应用程序。在浏览器中运行的任何东西本质上都是短暂的,这改变了我们多年来认为理所当然的软件设计实践。此外,我们花在编码架构上的时间比画图更多。等到我们画出任何东西时,它已经被另一个规范或工具所取代。

组件设计

在架构层面,组件是我们工作的主要构建块。这些可能是具有多级抽象的高级组件。或者,它们可能是我们正在使用的框架暴露的内容,因为许多这些工具都提供它们自己的“组件”概念。在本书中,组件位于中间位置——不是太抽象,也不是太具体实现。这里的想法是我们需要对我们的应用程序组成进行深思熟虑,而无需过于担心具体实现。

当我们首次着手构建一个考虑可扩展性的 JavaScript 应用程序时,组件的组成开始成形。组件如何组合是我们扩展的关键限制因素,因为它们设定了标准。组件实现模式以保持一致性,正确地获得这些模式非常重要:

组件设计

组件具有内部结构。这种组合的复杂性取决于考虑中的组件类型。

正如我们将看到的,我们各种组件的设计与其他视角中我们做出的权衡紧密相关。这是件好事,因为它意味着如果我们关注所需的扩展特性,我们可以回顾并调整组件的设计以满足这些特性。

组件通信

组件不会在浏览器中单独存在。组件一直在相互通信。我们有多种通信技术可供选择。组件通信可能简单到方法调用,也可能复杂到异步发布-订阅事件系统。我们采取的架构方法取决于我们更具体的目标。组件的挑战在于,我们通常在开始实现应用程序之后才知道理想的通信机制是什么。我们必须确保我们可以调整所选的通信路径:

组件通信

组件通信机制使组件解耦,实现可扩展的结构。

我们很少为组件实现自己的通信机制。既然有许多工具可以为我们解决至少部分问题,何必如此呢。很可能,我们最终会得到一种混合了现有通信工具和我们自己实现特定内容的混合物。重要的是,组件通信机制是其自身的视角,可以独立于组件本身进行设计。

加载时间

JavaScript 应用程序总是在加载一些东西。最大的挑战是应用程序本身,在用户可以执行任何操作之前,它需要加载所有必要的静态资源。然后还有应用程序数据。这需要在某个时刻加载,通常按需加载,并导致用户体验到的整体延迟。加载时间是一个重要的视角,因为它极大地影响到我们产品整体质量的感知。

加载时间

初始加载是用户的第一次印象,大多数组件都在这里初始化;要让初始加载速度快,而不牺牲其他方面的性能,是非常困难的。

在这里,我们可以做很多事情来抵消用户等待事物加载的负面体验。这包括利用 Web 规范,使我们能够将应用程序及其使用的服务作为可在 Web 浏览器平台上安装的组件来处理。当然,这些想法都还处于初级阶段,但随着它们和我们的应用程序一起成熟,值得考虑。

响应性

我们架构性能视角的第二部分关注的是响应性。也就是说,在一切加载完成后,我们响应用户输入需要多长时间?虽然这个问题与从后端加载资源的问题不同,但它们仍然密切相关。通常,用户操作会触发 API 请求,我们用来处理这些工作流程的技术影响用户感知的响应性。

响应性

用户感知到的响应性受到我们组件对 DOM 事件响应所需时间的影响;在 DOM 事件初始发生和我们最终通过更新 DOM 来通知用户之间,很多事情都有可能发生。

由于必要的 API 交互,用户感知的响应性很重要。虽然我们无法使 API 更快,但我们可以采取措施确保用户总是从 UI 获得反馈,并且反馈是即时的。然后,还有简单地在 UI 中导航的响应性,例如使用已经加载的缓存数据。除了其他架构视角外,所有视角都与我们的 JavaScript 代码性能紧密相关,最终,也与用户感知的响应性相关。这个视角对我们组件设计和它们选择的通信路径的合理性进行检查。

可寻址性

仅仅因为我们正在构建单页应用程序,并不意味着我们不再关心可寻址的 URI。这或许是 Web 的巅峰之作——指向我们想要资源的唯一标识符。我们将它们粘贴到浏览器地址栏中,然后见证奇迹发生。我们的应用程序肯定有可寻址的资源,我们只是以不同的方式指向它们。不是后端 Web 服务器解析的 URI,在那里页面被构建并发送回浏览器,而是我们的本地 JavaScript 代码理解 URI:

地址可访问性

组件监听路由器的路由事件并相应地响应。变化的浏览器 URI 触发这些事件。

通常,这些 URI 将映射到 API 资源。当用户在我们的应用程序中点击这些 URI 时,我们会将 URI 翻译成另一个用于请求后端数据的 URI。我们用来管理这些应用程序 URI 的组件称为路由器,有许多框架和库带有基本的路由器实现。我们可能会使用其中的一个。

地址可访问性视角在我们的架构中扮演着重要角色,因为确保我们应用的各个方面都有可访问的统一资源标识符(URI)会复杂化我们的设计。然而,如果我们聪明地处理,它也可以让事情变得更容易。我们的组件可以使用 URI,就像用户使用链接一样。

可配置性

软件很少能直接按照你的需求来工作。高度可配置的软件系统被认为是好的软件系统。前端配置是一个挑战,因为配置有多个维度,更不用说存储这些配置选项的问题了。可配置组件的默认值也是一个问题——它们从哪里来?例如,是否有设置默认语言,直到用户更改它?像往常一样,我们前端的不同部署需要这些设置的不同默认值:

可配置性

组件配置值可以来自后端服务器,或者来自网页浏览器。默认值必须存在于某个地方。

我们软件的每个可配置方面都会复杂其设计。更不用说性能开销和潜在的错误。因此,可配置性是一个大问题,花时间讨论不同利益相关者认为的可配置性价值是值得的。根据我们部署的性质,用户可能重视配置的可移植性。这意味着他们的值需要存储在后台,在他们的账户设置中。显然,这样的决定对后台设计有影响,有时最好采用不需要修改后台服务的做法。

做出架构性权衡

如果我们想构建可扩展的东西,我们必须从我们架构的各种角度考虑很多问题。我们不可能同时从每个角度获得我们需要的所有东西。这就是我们为什么要做出架构性权衡——我们用一个设计方面换取另一个更受欢迎的方面。

定义你的常量

在我们开始做出权衡之前,明确指出哪些是不能交易的非常重要。我们的设计中有哪些方面对于实现扩展至关重要,以至于它们必须保持不变?例如,一个常数可能是特定页面渲染的实体数量,或者是函数调用间接性的最大级别。这些架构常数不应该有很多,但它们确实存在。最好是我们保持它们范围狭窄且数量有限。如果我们有太多不能违反或更改以适应我们需求的严格设计原则,我们将无法轻松适应规模变化的驱动因素。

在考虑到扩展影响因素的不确定性时,是否有意义坚持永远不变的设计原则呢?是有意义的,但仅当这些原则出现并变得明显时。所以这可能不是一个一开始就需要遵循的原则,尽管我们通常至少会有一两个一开始就需要遵循的原则。这些原则的发现可能源于代码的早期重构,或我们软件的后期成功。无论如何,我们今后使用的常数必须是明确并得到所有相关人员的一致同意。

性能对于开发便捷性的影响

性能瓶颈需要被修复,或者在可能的情况下避免。一些性能瓶颈很明显,并对用户体验产生可观察的影响。这些需要立即修复,因为这意味着我们的代码由于某些原因没有实现扩展,甚至可能指向一个更大的设计问题。

其他性能问题相对较小。通常开发者会运行针对代码的基准测试,尽一切可能改善性能。这种方法扩展性不佳,因为这些对最终用户不可见的较小性能瓶颈修复起来耗时较长。如果我们的应用程序规模合理,有多个开发者参与开发,如果每个人都修复小的性能问题,我们将无法跟上功能开发的速度。

这些微优化将特定的解决方案引入我们的代码中,对其他开发者来说并不是很容易阅读。另一方面,如果我们对这些微小的低效之处视而不见,我们就能保持代码的清洁,从而使其更易于处理。在可能的情况下,用更好的代码质量换取优化的性能。这从多个方面提高了我们扩展的能力。

性能的可配置性

拥有几乎每个方面都可配置的通用组件是件好事。然而,这种组件设计方法是以性能为代价的。在最开始,组件还很少时,这种代价可能不明显,但随着我们软件在功能上的扩展,组件的数量增加,配置选项的数量也随之增加。根据每个组件的大小(其复杂性、配置选项的数量等)性能退化的潜力呈指数增长。看看下面的图表:

Configurability for performance

左边的组件的配置选项是右边组件的两倍。它也更难使用和维护。

只要没有性能问题影响到我们的用户,我们可以保留我们的配置选项。只需记住,为了消除性能瓶颈,我们可能不得不移除某些选项。配置的可变性不太可能成为我们性能问题的主要来源。随着我们的扩展和添加新特性,我们很容易过分追求。我们会在事后的回顾中意识到,在设计时我们创造了我们认为会有帮助的配置选项,但最终成了负担。如果没有实际的配置选项好处,就把可配置性换成性能。

替代性的性能

与配置的可变性相关的问题就是替代性。我们的用户界面表现良好,但随着用户基础的增长和更多特性的添加,我们会发现某些组件不能轻易地被其他组件替代。这可能是发展问题,我们希望设计一个新的组件来替换预先存在的某个组件。或者也许我们需要在运行时替换组件。

我们替换组件的能力主要取决于组件通信模型。如果新的组件能够像现有的组件一样发送/接收消息/事件,那么它就是一个相对直接的替代。然而,我们软件的许多方面并不是可替代的。为了性能,可能甚至没有可替换的组件。

随着我们的扩展,我们可能需要将更大的组件重构为更小、可替换的组件。这样做,我们引入了新的间接级别,以及性能损失。权衡小的性能损失,以获得有助于我们架构扩展的其他方面的可替代性。

地址可寻性的开发便利性

在我们的应用程序中为资源分配可寻址的 URI 确实使实现功能变得更加困难。我们实际上需要为应用程序暴露的每个资源都分配 URI 吗?可能不是。然而,为了保持一致性,几乎为每个资源分配 URI 是有意义的。如果我们没有一个一致且易于遵循的路由和 URI 生成方案,我们更有可能跳过为某些资源实现 URI。

几乎总是比省略 URI 更好,为应用中的每个资源分配 URI,更糟糕的是,根本不支持可寻址资源。URI 使我们的应用表现得像网络上的其他应用;所有用户的大本营。例如,也许 URI 生成和路由是我们应用中任何事物的常数——一个不可能发生的权衡。在几乎所有情况下,权衡开发便捷性与可寻址性。关于 URI 的开发便捷性问题可以在软件成熟时更深入地解决。

维护性对性能的影响

软件中功能开发的便捷性归根结底是开发团队及其扩展影响因素。例如,我们可能面临因预算原因招聘初级开发人员的压力。这种方法扩展的好坏取决于我们的代码。当我们关注性能时,我们可能会引入各种令人望而生畏的代码,相对缺乏经验的开发者将难以接受。显然,这阻碍了新功能开发的便捷性,如果困难,耗时更长。这显然不符合客户需求。

开发者不必总是为理解我们为解决代码特定区域的性能瓶颈所采取的非正统方法而挣扎。我们当然可以通过编写可理解的优质代码来帮助解决这个问题。也许甚至是文档。但我们不会不劳而获;如果我们想要支持团队整体在扩展过程中的发展,我们需要在短期内为培训和指导付出生产力代价。

在关键的、经常使用且不经常修改的代码路径上,权衡开发便捷性与性能。我们无法总是逃避性能所需的可憎之处,但如果隐藏得当,我们将会因为更常见的代码易于理解和自解释而受益。例如,低级 JavaScript 库表现良好,具有易于使用的连贯 API。但你如果看看一些底层代码,它们并不美观。那是我们收获——让其他人维护因性能原因而丑陋的代码。

维护性对性能

左侧的我们的组件遵循一致且易读的编码风格;它们都使用右侧的高性能库,从而在隔离难以阅读和理解的优化代码的同时,为我们的应用提供性能。

为了维护性而减少功能

当其他方法都失败时,我们需要退一步,全面审视我们应用的功能集。我们的架构能支持它们全部吗?有更好的替代方案吗?放弃我们投入了无数小时的架构几乎是没有意义的——但这种情况确实会发生。然而,大多数时候,我们会被要求引入一组具有挑战性的特性,这些特性违反了我们的一项或多项架构常数。

当这种情况发生时,我们正在破坏已经存在的稳定特性,或者我们在应用程序中引入了质量较差的东西。这两种情况都不好,而且与利益相关者合作找出必须去掉的内容是值得的,即使这会花费时间、让人头痛和咒骂。

如果我们花时间通过做出取舍来确定我们的架构,我们应该有一个站得住脚的理由,说明为什么我们的软件不能支持数百个特性。

为了可维护性减少功能

当一个架构达到极限时,我们无法继续扩展。关键是要理解那个临界点在哪里,这样我们才能更好地理解和与利益相关者沟通它。

利用框架

框架的存在是为了帮助我们使用一套连贯的模式来实现我们的架构。市面上有许多不同的框架,选择哪个框架取决于个人喜好和我们的设计需求。例如,某个 JavaScript 应用框架提供了大量的开箱即用功能,而另一个框架虽然功能更多,但我们可能并不需要其中大部分。

JavaScript 应用框架在大小和复杂性上各不相同。一些框架附带了完整的工具,而一些更倾向于机制而非政策。这些框架没有一个是为我们特定的应用而设计的。任何框架声称的能力都需要打折扣。框架宣传的功能适用于一般情况,而且非常简单。将其应用于我们架构的上下文是完全不同的。

话说回来,我们当然可以使用我们喜欢的某个框架作为设计过程的输入。如果我们真的很喜欢这个工具,而且我们的团队有使用它的经验,我们可以让它影响我们的设计决策。只要我们明白框架不会自动响应扩展影响因素——这部分取决于我们。

小贴士

花时间研究我们项目要使用的框架是值得的,因为选择错误的框架是一个代价高昂的错误。我们通常在实现了许多功能之后才意识到我们应该选择其他方案。最终结果是大量的重写、重规划、重培训和重文档化。更不用说第一次实现时浪费的时间。明智地选择你的框架,并警惕框架耦合。

框架与库的比较

既然有一个拥有我们所需一切的单体框架,为什么还要使用小型库的混合呢?库是我们的工具,如果它们满足我们架构的需求,那么当然可以使用它们。一些开发者因为低级工具带来的依赖性混乱而避开低级工具。实际上,即使我们利用的是涵盖一切的框架,这种情况也会发生。

归根结底,框架和库之间的区别对我们来说并不重要。创建一个第三方依赖噩梦不会很好地扩展。同样,独家使用一个工具并维护大量我们自己编写的代码也不会扩展得好。关键在于找到在依赖其他项目和自己重新发明轮子之间合适的平衡点。

一致地实现模式

我们用来帮助实现架构的工具,通过暴露出 JavaScript 应用程序中常见的模式来实现这一点。并且它们是一致地这样做。由于不断增加的功能集,我们的应用程序规模也在增长,我们可以一次又一次地使用相同的框架组件。框架还促进了我们自己实现的一致性模式。如果我们查看任何框架的内部实现,我们都会看到它有自己的通用组件;这些组件被扩展来为我们提供可用的组件。

性能是内置的

开源框架拥有最多的开发者查看代码,以及最多的项目在生产中使用该框架。它们从用户社区获得大量反馈,包括性能改进。第三方工具有正确的关注点,因为它们很可能是给定应用程序中使用最多的代码。将所有的性能结果都留给浏览器供应商和 JavaScript 库是不明智的。利用我们经常使用的组件背后的性能是明智的。

利用社区智慧

成功的 JavaScript 框架周围都有强大的社区支持。这比拥有健壮的文档更有效,因为我们可以随时提出问题。很可能,在我们自己的项目中,有人正在尝试做类似的事情,并且使用的是与我们相同的框架。开源项目就像一个知识引擎;即使我们需要的确切答案还没有出来,我们通常可以通过社区的智慧找到足够的信息来自行解决问题。

框架无法开箱即扩展

说一个框架比另一个框架扩展得更好是没有根据的。将TODO应用程序作为衡量框架扩展能力的一个基准几乎没有用处。我们编写 TODO 应用程序是为了熟悉框架,以及它与其他框架的比较。如果我们不确定哪个框架符合我们的风格,TODO 应用程序是一个不错的开始。

我们的目标是实现能够响应影响因素而良好扩展的东西。这些因素是独特且事先未知的。我们所能做的是预测未来可能遭遇的缩放影响因素。基于这些可能的影响因素以及我们正在构建的应用程序的性质,有些框架比其他框架更适合。框架帮助我们扩展,但它们不会为我们扩展。

总结

扩展 JavaScript 应用程序并不像扩展其他类型的应用程序那样。尽管我们可以使用 JavaScript 创建大规模的后端服务,但我们的关注点是在浏览器中与用户交互的应用程序的扩展。在产生一个可扩展架构的决策过程中,有一些指导我们决策过程的影响因素。

我们回顾了其中一些影响因素,以及它们自上而下流动的方式,为前端 JavaScript 开发创造了独特的挑战。我们研究了用户更多、功能更多、开发者更多所带来的影响;我们可以看到有很多需要考虑的东西。虽然浏览器正在成为一个强大的平台,我们将我们的应用程序交付给它,但它仍然具有其他平台不具备的限制。

设计和实现一个可扩展的 JavaScript 应用程序需要有一个架构。软件最终必须完成的事情只是设计的一个输入。缩放影响因素也很关键。从那里开始,我们解决考虑中的架构的不同视角。诸如组件组合和响应性等事情在我们的讨论中涉及到扩展时就会发挥作用。这些都是我们架构受到缩放影响因素影响的可观察方面。

随着这些缩放因子的随时间变化,我们使用架构视角作为工具来修改我们的设计,或产品以适应缩放挑战。下一章的重点将放在更详细地研究这些缩放影响因素。理解它们并制定出一个检查清单,将使我们能够实施一个能够响应这些事件的 JavaScript。

第二章:规模影响者

规模影响的发起者从我们软件的用户开始。他们是影响力度最大的发起者,因为他们是我们构建应用的原因。正如我们在前一章所看到的,用户影响最终影响我们编写的代码和实施它的开发人员。当我们停下来思考这些规模影响者时,我们认识到能够应对它们的健壮的 JavaScript 架构是一个审慎的原因。然后我们可以把我们找到的信息从不同的架构角度审视我们的代码。我们将在本书中深入探讨这些观点,从下一章开始。

但在我们这样做之前,让我们更深入地了解这些规模影响者。我们希望密切关注这些,因为关于我们的设计,我们做出的每一个决定实际上如何扩展在很大程度上取决于我们预见的影響。也许更重要的是,我们需要以这样的方式设计我们的架构,以便它能够让我们处理我们没有预见的扩展场景。

我们将从更仔细地观察我们软件的用户开始。他们为什么使用它?我们的软件是如何让他们快乐的?对我们有什么好处?这些问题,信不信由你,与我们编写 JavaScript 的方式密切相关。从用户出发,我们然后再深入到功能,我们应用的外向个性。有些功能不适合我们的应用,但有时候那并不重要——我们说了不算。如果我们想要扩大规模,取悦我们的用户,有时我们必须充分利用这些功能。

负责实施这些功能的发展资源是一个可以成就或破坏产品的规模影响者。我们将查看开发团队面临的挑战,以及他们如何受到功能影响。我们将在本章结束时为每个这些影响者提供一个通用的检查表;以帮助确保我们已经考虑了我们能够扩展的最紧迫的问题。

扩大用户规模

最重要的用户是我们——开发组织。虽然我们的任务是通过提供可扩展的软件来保持我们的用户快乐,但我们也需要让自己快乐。而这需要一个可行的商业模式。我们关心这个原因是因为不同的模型意味着获取新用户和管理现有用户的不同方法。从那里开始,扩大我们的用户基础的复杂性会更深。我们需要考虑我们的用户是如何组织的,他们如何使用我们的软件相互沟通,如何提供支持,收集反馈和收集用户指标。

对于 JavaScript 应用程序可行的业务模式包括提供广告支持的免费服务,到我们收取许可费的私有、本地软件部署。决定哪种方法适合组织可能不在我们手中。然而,我们的责任是理解选定的业务模式,并将其与当前和未来使用我们软件的用户联系起来。

业务模式可能会变得相当复杂。例如,组织通常会从一种清晰明了、能让用户满意,同时满足商业期望的方法开始。然而,随着组织的成长和成熟,曾经连贯的业务模式变得模糊不清,对于我们的架构产生了不可预测的结果。让我们来看看这些业务模式以及它们如何影响我们用户基础的可扩展性。

许可费用

软件许可是一个复杂的话题,在这里我们不会深入探讨。重要的是我们是否依赖许可软件作为我们的业务模式。如果是,那么我们很可能有其他组织在本地部署我们的 JavaScript 应用程序。个人购买许可证的可能性不大——这取决于软件的性质。销售许可证的情况下,我们的软件更有可能被多个组织私有化部署。

这种业务模式有两个有趣的扩展属性需要考虑。首先,对于给定组织内的用户数量存在一个基本限制。虽然组织可以很大,我们可以向多个大型组织销售产品,但常见的案例是拥有较少用户,并采用授权模型。其次,每个组织在定制方面都有不同的需求。这包括配置性、用户组织等。采用授权模型时,我们更有可能遇到这些类型的更改或增强请求。

所以,虽然支持的用户不多,但由于使用我们软件的组织的结构性质,支持他们的性质更加复杂,因此难以扩展。在这些环境中,依赖管理也可能具有挑战性,因为限制决定了我们的软件如何能够扩展。在其他环境中,这些限制较为宽松。

订阅费用

订阅服务是我们为使用我们的软件而收取的定期费用。这种方法通常对我们的用户来说成本更低。此外,这种业务模式也更加灵活,因为它可以轻松地应用于本地部署的软件,以及公开部署的软件。

由于组织部署基于订阅的软件比基于许可的软件成本更低,我们更有可能接触到更多的组织。请注意,这些组织是按部门划分的,每个部门都有自己的预算限制。

然而,在扩展方面,订阅模式的挑战与许可模式的挑战相似,即复杂的定制化请求。如果订阅可能会让我们获得更多的企业内部部署,可能会带来更复杂的功能请求。采用订阅方式所面临的另一个扩展问题就是客户保留。如果不能持续提供价值,用户是不会继续支付订阅费用的。

所以,如果我们选择订阅模式,我们需要加大力度提供新功能,这些功能可以证明用户的持续订阅费用是合理的。

消费费用

软件的另一种商业模式是消费模式,或者说,按需付费。这对用户来说是一个有吸引力的模式,因为他们为他们不使用的资源付费。当然,这并不适合每一个应用程序。如果用户没有有意义的东西可以消耗呢?如果我们在运行应用程序的方式上,资源消耗对我们来说不是问题呢?

在其他情况下,资源使用情况是显而易见的。也许用户执行了一些计算密集型的任务,或者在一段时间内存储了大量数据。在这些情况下,消耗模型对我们和用户来说都是完全合理的。消耗较少的用户,支付较少费用。用户行为可能会有波动,但与他们在使用我们应用程序的其他时间相比,这些事件是短暂的。

我们这个业务模型所面临的扩展挑战是我们除了应用的核心方面外,还需要好的工具。首先,我们需要一个测量和记录消耗的工具。其次,我们需要准确描绘这些消耗指标的工具,通常是以视觉化的方式。根据用户在消耗什么,以及我们期望达到什么程度的集成,可能还需要考虑第三方组件。

广告支持

另一个选择是将我们的应用部署到公共互联网上,并使用显示广告来赚钱。这些是免费的应用程序,因此更有可能被使用。另一方面,广告会让很多人感到厌烦,这抵消了“免费”的吸引力。

使用这种方法的目标,或许不是广告收入,而是产生大规模使用。实际上,用户越多,广告收入也会越多。然而,一个在线 JavaScript 应用程序的大规模采用可能会吸引投资者。所以,用户账户的数量本身就有价值。

这类应用程序与其他业务模型不同的地方在于它们的扩展方式。在互联网上获得广泛流行的应用程序为不同的用户角色解决不同的问题。遵循这一模式意味着我们需要有覆盖面,而扩大覆盖面意味着降低入门门槛。在使用这种业务模型时,我们的重点是易用性和社会有效性。

开源

我们需要考虑的最后一种商业模式是开源。别笑;开源软件对网络的功能至关重要。我们的 JavaScript 应用程序很可能使用了一些开源组件,更有可能的是,我们只使用了开源组件。但为什么人们会花宝贵的时间开发供所有人使用,甚至包括他们的竞争对手的工具呢?

这里的一个误解是,人们只是闲坐着,失业,为其他人构建开源软件。事实是,我们大多数将使用的工具都是由使用与我们相同技术的大型公司的有强大地位的人构建的。他们甚至可能启动开源项目来为公司解决问题——为他们的开发过程提供一个缺失的工具。

第二个误解是我们通过启动或贡献开源项目在帮助我们的竞争对手。我们不可能仅通过开源软件就让自己处于比竞争对手更糟的位置。通过其他标准,是的,通过伤害自己来帮助我们的竞争对手是完全可能的。

另一方面,开源项目可能对一个组织是有益的。这些项目必须是有效的;即有用且通用的。如果它发展壮大,我们就在创造我们依赖的新技术利益相关者,这是件好事。围绕开源项目的社区是无价的。虽然开源本身不能支持一个组织,但不可否认的是,它是任何 JavaScript 应用程序商业模式的一个重要组成部分。

分组与角色分组使我们能够对我们的用户进行分类。想想角色是一种用户类型。这是一个强大的抽象概念,因为它允许我们通过角色类型泛化特征的方面。例如,我们不是基于用户属性检查条件,而是基于角色属性检查条件。将用户从一个角色移动到另一个角色比修改我们的逻辑容易得多。

确定用户角色以及它们如何转化为小组实施是一个棘手的问题。我们可以确定的是,我们必须调整我们用户的组织结构。因此,使分组机制尽可能通用是我们的第一个目标。这也有一定的权衡——任何完全通用的东西都会有负面的性能影响。

有些分组决策一开始是显而易见的。比如用户是否意识到系统中还有其他用户。如果他们意识到了,我们可以开始深入探讨用户如何使用我们的应用程序相互沟通的具体问题。再次,这可能基于我们应用程序的功能类型是显而易见的。我们正在遵循的业务模式也影响我们的用户管理设计。如果我们出售软件许可证,并且很可能被部署在本地,那么我们可以预期会有很多不同的用户角色需求,以及随后的分组实现。如果我们公开部署在互联网上,分组就不是那么重要了——我们可以选择一种简单的性能方法,例如。

随着我们软件的复杂性增加,随着我们增加更多功能和吸引更多客户,我们将开始看到需要将应用程序的某些部分隔离开来的需求。也就是说,我们需要根据访问控制权限将某些功能绑定下来。与其设立不同的用户角色,安装不同的软件系统;不如让他们拥有一个带有用户、组和访问控制的单一系统更容易。

这对我们作为 JavaScript 架构师有深远影响,因为一旦我们走上了访问控制的道路,就无法回头。从那时起,我们必须保持一致性——每个功能都需要检查适当的权限。进一步 complicating 事情的是,如果我们这样分组用户,我们可能在某个时候以类似的方式对我们的系统中的其他实体进行分组。这是很合理的,特别是对最终用户来说——这一组事物是由那一组用户访问和使用的。

Communicating users

关于用户以及他们之间的关系的另一个方面是,这些用户可用的沟通渠道。他们是否明确地选择其他用户进行沟通?还是沟通更隐性?后者的一个例子可能是我们同一个组的用户,正在查看一个图表。这个图表是基于系统中由小组其他成员输入的数据生成的。除了明确的沟通渠道外,思考这些隐性的沟通渠道是否值得?

我们应用程序的性质决定了用户可以打开哪些沟通渠道。它可能还取决于用户本身。有些应用程序的用户需要深入其中,熟练地完成一项任务——与 other users 沟通是不必要的。另一方面,我们可能会发现自己正在开发一些更加注重社交的应用程序。事实上,我们甚至可能依赖外部社交网络的服务。

如果我们打算依赖第三方用户管理,无论是社交网络还是其他方式,我们必须注意我们与这些服务耦合的紧密程度。在规模上,使用第三方认证机制可能具有我们想要的社会性增值功能——特别是考虑到大多数用户会喜欢他们不需要再创建另一个账户就能使用我们的应用程序。一旦我们开始实现新功能,第三方集成变得复杂,此时将这种用户管理方法扩展到其他方面将成为一个问题。例如,一个照片编辑应用程序可能会通过使用 Facebook 登录来扩展得更好,因为大多数用户的照片都来源于此。

如果我们的应用程序有用或有趣,用户会找到彼此沟通的方式。我们可以抵制它,或者我们可以利用用户沟通作为帮助我们扩展的工具。也就是说,扩展用户可以透明地指向对他们有用的东西的能力,否则他们需要去到处寻找。

支持机制

能够使我们的 JavaScript 应用程序顺利运行是非常好的。即使一切都在按计划进行,我们已经部署完毕且没有 bug,我们仍需要支持那些用户不知道如何使用某功能的情况。或者他们执行了一些他们可能不应该执行的操作。或者有其他十万里挑一的用户体验问题需要迅速解决。

我们的支持机制不扩展会让我们的事业陷入停滞。因此,除了我们的软件需要扩展得很好外,我们还需要考虑用户支持系统如何与之一同扩展。支持可以紧密集成,或者外包给第三方软件和人员。

用户最好不需要支持就能使用我们的软件。这就是为什么我们在设计时考虑易用性。我们走过各种用户体验,通常是与专家和/或实际用户一起,并将为他们整合设计到我们的软件中。这是我们支持用户时可以解决的最明显的问题。因为如果我们能通过易用性设计做到这一点,那么我们就可以消除我们扩展过程中可能遇到的大部分潜在支持问题。

无论如何,我们仍然必须假设我们没有考虑到部署后必然会出现的支持案例。用户是好奇的。即使一切都在顺利进行,他们可能仍然会有问题。因此,我们实在不能说:“我们为您设计了一个优秀的用户体验,一切都在运行,所以您走吧。”我们需要对用户的疑问和担忧做出回应。因为一旦我们对询问表现出轻视,我们就未能扩大我们的应用程序。

我们的 JavaScript 组件可以帮助支持用户吗?如果我们希望这样,绝对可以!实际上,上下文帮助可能是最有效的。如果用户对某个组件有疑问,并且他们看到在该问题组件中的帮助按钮,那么他们可以利用它来提交他们的问题。在支持问题的接收端,混淆更少。我们确切地知道用户想要做什么,而花时间创建问题周围的上下文不再必要。

这确实说起来容易做起来难,对我们还有其他的扩展影响。这些上下文帮助系统并非不劳而获。如果我们决定走那条路,我们必须考虑在实施每个功能时都提供上下文帮助。这个方法能与我们在做的其他事情一起扩展吗?

我们可能想要考虑的另一种方法是一个知识库,其中包含来自创建软件的组织以及使用它的那些人的信息。为特定目的使用它的人很可能比我们更有答案,这些答案极具价值。不仅对寻找答案的用户有价值,对我们也是如此。

反馈机制

是否真的需要将反馈与支持区分开来?支持无疑是反馈。如果我们关注随着时间的推移遇到的各种支持问题,我们可以将其转化为反馈,并利用这些信息作为反馈。然而,区分这两种形式仍然是有价值的,因为用户的心态是不同的。在体验支持问题时,从轻微到强烈的挫折感都有。现在的用户并不关心改进产品——他们需要完成自己的工作。

另一方面,使用我们软件一段时间的用户会高度意识到他们工作流程的低效。收集这类反馈至关重要。我们如何获得它?一种方法是在应用程序中提供一个反馈按钮,就像我们为上下文支持按钮所做的。另一种方法是让第三方处理反馈收集。对于理解用户在谈论什么,自动化上下文总是对我们更有利,这样我们就不用花太多时间在上面。

与反馈相关的一个重要方面是保持客户的参与度。并非所有使用我们软件的人都会与我们分享他们的想法。但无疑有些人会的——即使他们只是在发泄不满。我们必须回应这些反馈,以建立对话。提供这类反馈的用户希望我们回应他们。而这些用户的持续对话是产品改进的来源,而不是用户最初提交的那些辉煌想法。

随着我们的用户基础增长,我们能否保持响应并积极地响应用户反馈?显然,这是一个挑战,鉴于我们桌上还有其他一切事情,处理应用程序的增长。创建围绕给定用户数据的对话是一回事,但采取行动又是另一回事。假设我们已经为我们的软件集成了伟大的反馈机制。我们最终必须将其转化为可执行的工作。因此,我们需要考虑我们的基于用户反馈生成需求的过程如何扩展。如果它不能,并且用户反馈从未被执行,他们将会放弃,我们就未能实现扩展。

通知用户

JavaScript 应用程序需要向其用户显示通知。这些实现起来可能相当直接,尤其是如果我们主要关心响应用户行为的话。例如,当用户做某事时,它会导致向后端发送 API 请求。我们想要向用户显示一个通知,指示该操作是否成功或失败。这些通知在应用程序中看起来都一样——我们可以为大多数甚至所有通知使用相同的工具。

在设计可扩展的 JavaScript 架构时,通知很容易被忘记。这是一个大话题——有上下文通知、一般通知以及用户离线时发生的通知。后者通常意味着已经向用户发送了电子邮件,提示他们登录并采取必要的行动。

上下文通知可能是最重要的,因为它们向用户提供了关于他们当前正在做的事情的反馈。确保这些通知在用户界面上保持一致,对于所有类型的实体来说是一个挑战。更一般的通知是作为后台发生某事的结果而发生的。

属于用户的某些资源可能已经改变了状态,要么是预期之中,要么是出乎意料。无论如何,用户可能希望知道这些事件。理想情况下,如果他们登录并使用系统,那么一个通用的通知会自动显示。然而,我们可能还希望将这些通知通过电子邮件发送给用户。

任何通知系统的挑战都是数量问题。如果有很多用户,而且他们相对活跃,将需要生成和传递大量的通知。这无疑会干扰我们代码中其他组件的性能。我们还面临着通知带来的可配置性问题。我们永远不可能为所有用户正确设置通知,因此我们需要一定的通知调整程度。找到使应用程序可扩展的正确通知级别取决于我们 JavaScript 架构师和开发者。

用户指标

了解用户如何与我们的软件互动的最佳方式是通过数据。有些数据点是无法猜测或手动收集的。这就是我们需要依赖能够自动收集用户指标的工具的地方,这些工具在用户与我们的软件互动时发挥作用。有了原始数据,我们就能够很好地进行分析,并做出决策。

虽然自动化这个任务是有意义的,但这个任务可能根本就不必要。如果我们真的不确定一个特定功能的未来方向,或者当我们想要更深入地了解应该优先处理什么工作时,收集用户指标可能是有价值的。大多数时候,我们可以不费吹灰之力地得到这些答案,当然也不需要分析工具。如果我们部署在本地,我们可能甚至不被允许收集这样的数据。

市面上有很多好的第三方指标收集工具。这些工具特别有帮助,因为它们附带了我们需要的很多报告。还有我们不需要的很多报告。还有一个问题是我们希望第三方组件多么紧密地集成。总是有可能我们需要关闭这样的功能。或者,至少改变数据存储的位置。

这些数据除了作为产品方向决策的输入之外,还有许多其他用途。我们的代码可以利用用户指标数据反思性地改善体验。这可能仅仅是基于过去事件提出下一步建议。或者,我们可以根据这些数据进行效率优化。这一切都取决于我们的用户想要什么。确定用户想要什么是一个本身具有扩展性的问题,因为随着我们的成长,我们会吸引更多想要不同东西的用户。用户指标可能最终成为解决这个问题的有力工具。

scaling users example(规模用户示例)

我们的软件公司正在开发一个在线贷款应用。这个应用相当直接;前端没有太多的移动部件。申请人首先创建一个账户,然后可以申请新贷款并管理现有贷款。这个应用的商业模型是基于消费的。我们通过贷款的利息来赚取收入,所以贷款消费得越多,我们赚的钱就越多。

显然,影响规模扩展的因素包括用户数量和易用性。我们价值主张的一部分是小型贷款的低利率。当用户申请新贷款时,应该几乎没有 overhead;所需输入最少,贷款申请成功或失败的等待时间也最少。这是我们提供价值的高度聚焦的愿景,也是我们将面临的一些更明显的规模扩展影响因素。

让我们思考一下我们应用在规模方面的更微妙的含义。鉴于这类应用的性质,我们不太可能看到对社交功能的请求。在大多数情况下,用户可以被视为一个黑箱;当使用我们的应用时,他们处于自己的小宇宙中。由于易用性对我们来说非常重要,而且我们的应用没有太多复杂的部分,因此在规模方面,支持和反馈不太可能是关键因素。我们无法消除支持和反馈,但在这些方面的关注可以最小化。

另一方面,我们需要推广我们的服务,我们真的不知道我们的客户为什么要贷款,最受欢迎的还款计划是什么,等等。为此,我们可能能够提供更有效的市场信息,以及改善我们的整体用户体验。这里的含义是,收集我们应用的元数据是一件大事。由于我们追求大量用户,这意味着我们将需要存储大量的元数据。我们还需要以这样的方式设计每个功能,以便我们可以收集指标并稍后使用,这使得设计变得复杂。

扩展功能

现在我们将关注如何扩展我们软件中实施的功能。用户是最终的决策者,现在我们已经有了关于在规模方面需要什么的大致想法,我们可以将这些知识应用于功能开发。当我们考虑扩展用户时,我们是在思考为什么。我们为什么选择这个商业模式而不是那个商业模式?为什么我们需要为其中一个用户角色启用事物,而为其他角色禁用它们?一旦我们开始用 JavaScript 设计和实现功能,我们开始思考如何。我们不仅关心正确性,也关心扩展性。与用户一样,影响者是决定可扩展功能的关键。

应用价值

我们认为我们在实施的功能方面做得很好,并且每次我们引入新功能时,我们都在为用户提供价值。值得我们思考这一点,因为本质上,这就是我们试图做的事情——将我们软件的价值扩展到更广泛的受众。在这方面没有扩展的一个例子是,当现有用户依赖现有功能而被忽视,并对我们软件因为我们关注了新的领域而感到失望。

当我们忘记了我们最初为软件解决的问题时,这种情况就会出现。这听起来可能是个荒谬的观念,但根据许多因素,我们很容易走向完全不同的方向。在某些罕见的情况下,这种改变方向导致了世界上一些最成功的软件。在更常见的情况下,它导致软件失败,确实是一个扩展问题。我们的软件应始终提供一组核心价值主张——这是我们软件的精髓,绝不能动摇。我们经常面临其他扩展影响因素,如新客户希望从我们的软件提供的核心价值中得到不同的事物。无法处理这意味着我们无法扩展应用程序的主要价值主张。

当我们扩大价值时走向错误方向的一个指标是与当前价值和理想价值混淆。也就是说,我们的软件目前所做与将来我们可能希望它做的事情之间的区别。我们必须向前看,这是毫无疑问的。但是,未来计划需要不断与可能实现的事情进行理智的检查。这通常意味着回溯我们最初创建软件的原因。

如果我们的应用程序真的很吸引人,我们希望它是这样,那么我们必须对抗其他有影响力的扩张因素,以保持这种方式。也许这意味着我们评估新功能的过程的一部分涉及确保该功能以某种方式贡献于我们软件的核心价值主张功能。并非所有考虑中的功能都能做到这一点,这些功能应受到最严格的审查。改变方向真的值得吗,会危及我们扩展能力吗?

杀手级功能与功能致死

我们希望我们的应用程序能够脱颖而出。如果有一个足够细分的市场,我们几乎没有任何竞争,那会很不错。那样我们就可以轻松实现稳定且无需花哨功能的软件,大家都会很满意。鉴于这并非现实,我们必须进行区分——实现杀手级功能就是其中之一,这是我们的软件独有的方面,也是用户非常关心的。

挑战在于,杀手级功能很少是计划好的。相反,它是我们在交付应用程序时其他事情做得好的副作用。随着我们不断成熟应用程序,精炼和调整功能,我们会偶然发现那个演变成杀手级功能的“小”变化。杀手级功能往往就是这样产生的,这并不令人惊讶。通过倾听客户的需求和满足扩展要求,我们能够发展我们的功能。我们增加新功能,减少一些功能,修改现有功能。如果我们成功地这样做足够长的时间,杀手级功能就会显现出来。

有时在规划某个功能时很清楚地意识到它试图成为一个杀手级功能,仅仅是为了成为一个杀手级功能。这不是最优的。这对用户也没有价值。他们选择我们的软件不是因为我们产品路线图中“有很多杀手级功能”。他们选择我们是因为我们能为他们做到他们需要的事情。可能比其他替代方案更有效率。当我们开始思考为了杀手级功能而思考时,我们开始偏离应用程序的核心价值观。

这个问题最好的解决方案是一个开放的环境,它欢迎在功能构思阶段所有团队成员的输入。我们越早能够杀死一个糟糕的想法,我们就越能节省时间,不用在它上面工作。不幸的是,情况并不总是这么清晰,我们必须在功能上做一些开发,才能发现其中一个或多个方面扩展得不好。这可能是由于任何 number of reasons,但这不是完全的损失。如果我们仍然愿意在开发已经开始后取消一个功能,那么我们可以学到一个宝贵的教训。

当事情无法扩展并且我们决定终止功能时,这对我们的软件来说是一种帮助。我们没有通过向其强制推行不适用的事物来妥协我们的架构。在开发任何功能的过程中,我们将达到一个需要问自己的点;“我们是否重视这个功能胜过我们现有的架构,如果是这样,我们愿意改变架构来适应它吗?”大多数时候,我们的架构比功能更有价值。因此,停止开发不适应的功能可以作为一个宝贵的教训。在未来,我们将根据这个被取消的功能更好地了解哪些功能可以扩展,哪些不能。

数据驱动的功能

拥有具有大量不同用户基础的应用程序是一回事。另一回事是我们能够通过收集数据来利用他们与我们的软件互动的方式。用户指标是收集与软件决策和未来发展方向相关的信息的强大工具。我们将这些称为数据驱动的功能。

在最初阶段,当我们没有用户或者很少有用户时,我们显然无法收集用户指标。我们将不得不依赖其他信息,比如我们团队的集体智慧。我们都可能在过去参与过 JavaScript 项目,因此我们有足够的信息来让产品起飞。一旦产品上线,我们需要工具来更好地支持我们的功能决策。特别是,我们需要了解哪些功能是我们需要的,哪些是不需要的?随着我们软件的成熟,我们收集到更多的用户指标,我们可以进一步完善我们的功能,以满足用户的实际需求。

拥有使特性数据驱动所需的必要数据是一个难以扩展的挑战,因为我们首先需要收集和精炼数据的机制。这需要我们可能根本不存在的开发努力。此外,我们实际上必须根据这些数据做出关于特性的决定——数据本身不会自己变成我们的需求。

我们还想知道我们被要求实现的特性的可行性。如果没有数据支持我们的假设,这项任务是非常困难的。例如,我们对我们的应用程序将要运行的环境有数据吗?简单的数据点可能足以确定某个特性不值得实现。

数据驱动特性需要从两个角度进行工作,那就是我们自动收集的数据和我们提供数据。这两者都难以扩展,但两者对于扩展都是必要的。唯一的真正解决方案是确保我们实现的特性数量足够少,这样我们就可以处理某个特性生成的数据量。

与其他产品竞争

除非我们在一个非常利基的市场中运营,否则很可能存在竞争产品。即使我们在某种程度上处于利基市场,与其他应用程序仍然会有一些重叠。有很多软件开发公司——所以我们很可能面临直接竞争。我们通过创建更优越的特性与类似的产品竞争。这意味着我们不仅要不断提供顶级软件,还要注意竞争对手在做什么,以及他们的软件用户怎么想。这是限制我们扩展能力的一个因素,因为我们必须花时间了解这些竞争技术是如何工作的。

如果我们有一个销售团队在销售我们的产品,他们往往是关于竞争对手在做什么的好信息来源。他们经常会被告知潜在客户我们的软件是否能做到这样那样,因为其他应用程序能做到。或许最有说服力的销售点是我们能够提供那个特性,而且我们能做得更好。

我们必须小心这里,因为这又是限制我们赢得客户能力的另一个扩展因素。我们必须扩展我们对现有和潜在客户的承诺。承诺过多,我们将无法实现特性,导致用户失望。承诺过少,或者根本不承诺,我们一开始就无法赢得客户。克服这种扩展限制的最佳方式是确保那些销售我们产品的人与我们的软件现实保持良好联系。它能做到什么,不能做到什么,哪些是未来的可能性,哪些是不切实际的选项。

为了销售我们的产品,必须在承诺一些事情而不了解实现这些承诺的全部影响上留有回旋余地。否则,我们将无法获得我们想要的目标客户,因为我们没有围绕我们的产品产生任何兴奋感。如果我们要将这种销售方法扩展到新的客户,我们需要一种经过验证的方法,将承诺提炼成可实现的东西。一方面,我们不能妥协架构。另一方面,我们需要在中间找到某种平衡,以满足用户的需求。

修改现有功能

在我们成功部署了我们的 JavaScript 应用程序之后,我们仍然在不断优化我们的代码和整体架构的设计。唯一不变的是变化,或者类似的东西。需要大量的纪律性回到软件的现有功能上进行修改,以改善用户的体验。原因是我们有更多的压力来自利益相关者要求添加新功能。这对我们的应用程序来说是一个长期的可扩展性问题,因为我们不能永远添加新功能,而从不改进已经存在的内容。

不太可能的情况是,我们不需要更改任何东西;我们所有的现有用户都很满意,他们不想让我们碰任何东西。一些用户害怕变化,这意味着他们喜欢我们软件的某些方面,因为我们在实施方面做得很好。显然,我们想要更多这样好的功能,通过这种方式,用户通常很满意,并且看不到改进的需要。

那么我们如何达到这个阶段呢?我们必须倾听用户的反馈,并根据这些反馈制定修改功能的路线图。为了与我们的用户及其需求一起扩展,我们必须在实施新功能和修改现有功能之间找到平衡。检查我们是否在正确的方向上改进功能的一种方法是将拟议的更改广播给我们的用户基础。然后我们可以衡量我们收到的任何反馈。实际上,这可能会促使我们那些通常安静的用户给出一些具体的建议。这是一种将球抛给用户的方法——“这是我们正在考虑的,你们觉得呢?”

在确定要改进哪些功能以及何时相对于实施新功能来改进它们之后,还存在架构风险。我们的代码耦合度有多紧密?我们能将一个功能隔离到什么程度,以至于我们不会破坏其他功能?我们永远不可能完全消除这种风险——我们只能减少耦合。在这里起作用的规模问题是我们花在修改给定功能上的时间,由于重构、修复回归等等原因?当我们的组件松耦合时,我们会花更少的时间在这些活动上,因此,我们可以扩展我们的功能改进。从管理的角度来看,我们总是有因为我们的更改而阻碍组织中其他人的风险。

支持用户组和角色

根据我们遵循的商业模式和我们的用户基础大小,用户管理对我们来说成为一个扩展问题,因为它触及我们实施的每一个功能。这种问题进一步复杂化,因为用户管理很可能与功能需求一样频繁地更改。随着我们的应用程序的增长,我们可能会处理角色、组和访问控制。

用户管理复杂时会有很多副作用。我们刚刚实施的新功能可能最初运行得非常好,但在我们的生产客户可能面临的大量其他场景中失败。这意味着我们需要花更多的时间来测试功能,并且质量保证团队可能已经不堪重负。更不用说由于每个功能中用户管理的复杂性而产生的额外的安全和隐私问题。

我们实际上并不能做太多关于复杂的用户管理架构的事情,因为它们往往是使用应用程序的组织及其结构的症状。我们在本地部署时更有可能面临这类复杂性。

引入新服务

有时候,现有的后端服务不再足以支持新功能。当前端开发工作的依赖性非常小的时候,我们可以更好地扩展我们的前端开发工作。如果这听起来违反直觉,不用担心。确实,我们需要后端服务来执行用户的请求。因此,依赖关系总是存在的。我们想要避免的是不必要的更改 API。

如果能够使用现有 API 实现功能,我们就这样做。这样后端团队可以专注于通过修复漏洞来提高稳定性和性能。如果 API 必须不断更改以支持我们的功能,他们就无法做到这一点。

有时不可避免地需要添加新的后端服务。为了扩展我们的开发过程,我们需要知道何时需要新的服务,以及如何实施它们。

首先是要评估新服务的必要性。有时候这很简单——无法实现所需的 API。我们将不得不将就使用现有的东西。第二个问题是新服务的可行性。由于我们需要新的 API,我们很可能形成新 API 的形状。然后我们需要听听后端团队的意见。如果我们是一个拥有全栈开发人员的团队,开销会比较小,因为我们很可能都在同一个团队中,并且彼此之间的沟通更为密切。

既然我们已经决定推进新的 API,我们必须同步前端和后端特性的实现。这里没有我们可以遵循的一刀切的解决方案,因为服务可能容易或难以实现。我们的特性可能需要几个新的服务。关键是在 API 上达成一致,并建立一个模拟机制。一旦真正的服务可用,禁用模拟就是时间问题。

然而,在扩展我们整个应用程序方面,这只是前端功能与后端服务之间的一个集成点。引入新特性对系统的影响是未知的。我们只能通过测试和先验知识猜测这么多。直到生产环境,我们才会看到我们新特性扩展效果的全面影响。使用完全相同服务的不同特性对请求负载、错误率等有不同的影响。

消费实时数据

在 JavaScript 应用程序中,为了保持用户会话与现实同步,通常会有面向后端数据的有状态连接。这简化了我们代码的某些方面,同时使其他方面变得复杂。扩展的影响是巨大的。通过 WebSocket 连接发送实时数据,这被称为“推送数据”。在 WebSocket 连接之前,主流的技术是长轮询 HTTP 请求。这意味着,数据不是在改变时交付给客户端,而是客户端负责检查数据是否已更改。

围绕实时数据的扩展问题今天仍然存在。有了 WebSocket 技术,一些负担已经从我们的前端代码转移到了后端。应用程序服务需要在相关消息发生时推送 WebSocket 消息。然而,我们需要从多个角度来考虑这个问题。例如,我们的整体架构是否依赖于实时数据的交付,还是我们只考虑将实时数据用于单一功能?

如果我们考虑首次引入 WebSocket 连接,以更好地支持一个新功能,我们必须问自己是否这是我们要融入我们未来架构中的东西。实时数据只影响一个或两个功能时的挑战在于缺乏清晰性。开发者看到一个实时数据输入的功能与另一个没有实时数据输入的功能相比,在开发我们软件的过程中解决一致性问题会更加困难。

通常来说,将实时数据适当地集成到前端架构的代码中,在多个方面都有更好的扩展性。这基本上意味着任何给定组件都应该能够像其他任何组件一样访问实时数据。然而,当我们自上而下地流动,从用户及其组织那里面临的可扩展性问题,最终决定了我们实施的功能类型。这反过来又影响了实时数据发布的速度。根据我们应用程序的结构以及用户数据是如何连接的,实时数据每次浏览器会话交付的频率可能会大幅波动。对于我们所实施的每一个功能,都必须考虑这些问题。

缩放功能示例

我们的视频会议软件在大组织中很受欢迎。这主要归功于它的稳定性、性能,以及它基于浏览器,无需插件。我们的一个客户请求我们实现聊天工具。他们非常喜欢我们的软件,以至于他们希望用它来进行所有的实时通信,而不仅仅是视频会议。

在 JavaScript 层面实现聊天工具并不会太难。我们最终会重用一些使我们的网页视频会议功能成为可能的组件。稍微重构一下,我们就能得到所需的聊天组件。但文本聊天和视频聊天之间在缩放上有一些微妙的区别。

关键的区别在于文本聊天与视频聊天的持续时间,后者通常是一时的。这意味着我们需要找出持久化聊天的方法。我们的视频聊天不需要用户账户加入,以防人们想邀请组织外的人。这与文本聊天不同,因为我们不能确切地邀请匿名参与者,然后在他们离开后取消聊天。我们很可能还需要在我们的用户管理组件中进行其他更改。例如,聊天组现在是否对应于视频组?

由于这只是其中一个客户提出了这个要求,我们可能希望有一种方法来关闭它。这个新功能不仅有可能削弱我们的核心价值——视频会议,还可能在对其他客户部署时造成问题。有了新的后端服务、增加的界面复杂性以及所需的其他培训和支持,可以理解并非所有组织都希望启用这个功能。所以,如果我们还没有在我们的架构中实现这一点,即组件的开关功能,那么这也是影响我们扩展能力的一个因素。

缩放开发

在扩展影响因素方面,我们需要克服的最后障碍实际上是软件开发本身。任何足够复杂的 JavaScript 应用程序都不可能由一个开发者独立编写。即使是在开源环境中,也涉及到一个团队,即使它只是非正式的和自我组织的。在其他机构中,团队及其角色定义更为具体。不管团队是如何组建的,扩大团队的规模是我们如何应对本章中讨论的其他扩展影响因素的直接结果。

我们将要解决的首要问题是我们在新兴软件项目中最早遇到的问题——寻找开发资源。团队不是一个静态的事物;随着软件在代码大小和解决方案范围上的增长,我们将不得不添加新资源。不管我们喜欢与否,最好的资源最有可能是那些离开的资源,因为它们最受欢迎。理想情况下,我们可以留住一支有才华的团队,但无论如何,我们将不得不扩大获取新资源的过程。我们如何以及何时招聘 JavaScript 程序员受到我们要实现的功能和我们要构建的架构的影响,以服务于这些功能的运行。

从日常角度来看,每个团队成员应该负责实现我们应用程序的特定部分。这是一个复杂的问题,扩展影响因素应该受到责备。我们必须小心地为团队定义角色;不要使它们过于 restrictive。当事情因影响因素而变化时,我们需要调整并交付。僵化的角色定义在这里对我们帮助不大。另一方面,我们需要至少尝试建立界限,如果我们的组件开发中有任何自主性的话。

最后,我们将尝试找出是否有健全的方法来确定我们可能拥有过多的开发资源。大声说出来几乎听起来像是一件坏事。我们拥有所有这些才华,还有这么多工作要做——这两件事似乎是相辅相成的,不是吗?不,并不总是这样。

寻找开发资源

诱惑确实很大,尤其是对于产品经理来说,倾向于招聘开发资源不是为了我们现在正在做的工作,而是为了我们计划在将来进行的工作。但是,出于许多原因,这种方法扩展性不佳。新员工在这种情境下首先可能面临的问题是在实际功能上无法通过工作来学习代码。要记住,他们是被招聘来完成我们尚未开始的路标上的某项工作。所以,他们最终试图有所帮助,但现在还没有真正的义务。几周后,他们要努力避免挡住那些试图完成工作的人的路。

通常更好的做法是考虑我们现在正在做的工作。下一次软件发布中预期会有哪些功能是我们目前能力中缺失的清晰缺口吗?如果没有明确定义的缺口,新程序员就无事可做,这将导致不必要的沟通开销。这种做法的缺点是,一旦我们明确了在开发所需功能方面的能力缺口,我们可能就找不到所需的资源。这种压力可能导致招聘错误的人,这些人可能因为各种原因与团队格格不入。

一种更好的扩展我们开发资源增长的方法是等待缺口出现。缺口并不意味着世界末日,你的公司要倒闭。它只是意味着我们开发方面可以做得更好。如果我们能避免的话,我们不应该一次尝试招聘超过一个开发者。如果我们花时间找到合适的资源,那么他们很可能会用我们的流程和其他方法填补我们识别出的任何缺口。

小贴士

在软件开发生命周期中关于沟通开销的经典资源是弗雷德·布鲁克斯的《人月神话》。

开发职责

网络浏览器平台是一个复杂的领域,有许多技术和许多活动部分。网络平台的某些组件比其他组件更具前瞻性,但对于我们理解来说仍然很重要。这些新兴技术是网络的未来。那么在我们团队中谁来负责学习这些新技术并在整个组织中推广呢?网络平台的挑战在于,要掌握比一个人在同时交付产品功能时合理管理的内容还要多。这就是为什么我们需要至少有一定级别的开发角色。

这些角色的边界严格程度取决于组织和其中的文化。正在开发的应用程序的性质可能会影响要设置的开发角色类型。没有固定的食谱,严格性应该在可能的情况下避免。原因是我们需要适应扩展性影响者带来的变化。严格的角色实际上阻碍了其他有能力的开发者扑灭火灾。当截止日期临近时,我们通常没有时间角色的边界争议。

前端架构师最有可能看到实施给定应用程序架构的合理角色。这些角色很可能是短暂的,由建筑师指导,但由成员本身有机形成。这在开源项目中尤为明显,人们做他们擅长的事情,因此也做他们喜欢做的事情。虽然我们不能总是完全采用这种模式,但我们确实可以从中获得启示——根据我们的功能需求,塑造人们擅长做的事情的角色。这样做将帮助开发者在需要指导的地方获得指导。对 JavaScript 开发的某些方面感兴趣,并不意味着他们在需要的水平上精通。资深人员指导他们,做他们喜欢做的事情,对产品长期的收益巨大。

资源过多

我们部分解决了这样一个观念:轻易招聘过多开发资源——甚至颇具诱惑。当产品管理为我们定义了一个清晰的路线图时,我们想要安心地知道我们确实拥有足够的开发资源来完成我们的路线图。过快招聘人员不可避免地导致开发资源过多。我们现在可能已经面临这种情况,那么接下来要考虑的就是如何应对。

如果我们对我们的团队成员不满意,并且很清楚我们有比所需更多的资源,答案是显而易见的。然而,如果我们有太多优秀的资源不想失去,还有另一种看待事物的方法。我们需要调整产品路线图,以适应我们招聘的开发人才。这通常意味着找到一个渠道,使我们能够将产品想法从开发传递给产品管理。这更是一门艺术,而不是一门科学。

担任前端架构师是一项具有挑战性的工作,需要确定谁将构建什么。扩展我们的开发资源的最佳方式是向当前正在实施它的人提供一个我们架构的准确地图。如果有差异,找出正确的前进路径。例如,可能存在缺口,我们需要更多的 JavaScript 程序员,或者可能资源过多,产品中需要有所调整。

扩展开发示例

我们的应用程序已经存在一段时间,取得了一些成功,并在各种环境中得到部署。我们的一个核心开发人员 Ryan,触及了代码的许多领域。他帮助许多其他开发者改进他们的代码,提供建议等。我们的应用程序已经达到了一个足够大的规模,以至于我们开始注意到所有功能上的性能下降。

我们需要 Ryan 来实现一些性能优化,这将涉及重构代码的某些部分,基本上会占用他所有的时间。如果我们打算扩大规模以满足客户需求,我们仍然还有功能要交付。另一方面,我们看到了在性能方面扩展能力的红旗。

我们意识到我们需要招聘一名新开发者来帮助开发新功能。这名开发者不需要像 Ryan 那样的技能。他们需要掌握我们所使用技术的基础知识。如果我们运气好,我们会找到一个可以承担更多责任的人。但目前,我们需要填补的由 Ryan 留下的空缺相当狭窄。而且,为了扩大规模,我们不需要立即找到另一个 Ryan。

影响者清单

我们将用几个清单来结束这一章。这些问题很简单,没有唯一正确的答案。有些答案将贯穿我们软件的整个生命周期。例如,我们的商业模式希望“不会经常改变”。其他答案取决于当前的情况,这就是这些清单的目的。我们可以随时回来再次查看,无论何时发生变化。这可能是需求、用户、新的部署或开发环境的变化。这些问题不过是影响可扩展 JavaScript 应用程序的因素的微妙提醒。如果阅读它们导致的问题比答案多,那么它们就发挥了作用。

用户清单

用户是我们最初构建软件的原因。这个清单涵盖了我们需要扩展应用程序的最基本方面。这些问题将在软件的整个生命周期中相关。不仅仅是在用户管理方面有问题的时候。特征开发的变化应该触发对这份清单的查看。

我们软件的商业模式是什么?

  • 它是基于许可的吗?

  • 它是基于订阅的吗?

  • 它是基于消费的吗?

  • 它是基于广告的吗?

  • 它是开源的吗?

我们的应用程序有不同的用户角色吗?

  • 一个角色是否有特征对另一个角色隐藏,而对其他角色可见?

  • 我们应用程序中的每个功能都必须是角色意识的吗?

  • 角色是如何定义和管理的?

  • 我们的商业模式如何影响应用程序中角色的使用?

我们的用户是否使用我们的软件相互沟通?

  • 用户是否相互合作以有效使用我们的应用程序?

  • 用户沟通是否是我们数据模型的副作用?

  • 我们应用程序中的用户角色如何影响用户沟通?

我们如何支持我们的应用程序?

  • 支持是内置在应用程序中,还是外部处理的?

  • 用户能否通过一个中央知识库互相支持?

  • 我们商业模式和应用程序用户角色如何影响我们需要提供的支持类型?

我们如何从用户那里收集反馈?

  • 反馈收集是内置在应用程序中,还是外部处理的?

  • 我们如何激励用户提供反馈?

  • 我们提供的支持类型如何影响我们想要收集的反馈类型?

我们如何向用户通知相关信息?

  • 我们的应用程序是否有通用的、与上下文无关的通知机制?

  • 我们如何确保在任意给定时间只发生相关的通知?

  • 用户可以审计他们的通知吗?

我们应该收集哪种类型的用户指标?

  • 我们是否使用指标来改善产品的未来版本?

  • 我们的特性是否可以在运行时使用指标来改善用户体验?

  • 商业模式如何影响我们收集指标的需求?

特性清单

遵循来自我们软件用户的规模影响者,我们的软件特性是什么。这个列表涵盖了我们应该问自己关于任何新特性或实现现有特性的变化的问题。它们将帮助我们在每个特性基础上解决与可扩展性相关的常见问题。

我们软件的核心价值主张是什么?

  • 我们正在实施或增强的特性是否有助于我们产品整体的价值主张?

  • 我们当前的价值主张是否过于宽泛?

  • 用户数量和他们的角色如何影响我们专注于与应用程序价值相关的特性的能力?

我们如何确定一个特性的可行性?

  • 我们是否试图实现杀手级特性,而不是让它们自然地出现?

  • 我们是否花时间确定一个提议的特性是否可行,而不是做得差劲?

  • 我们软件的价值主张以及用户的特性请求如何影响我们最终实现的特性可行性?

我们能否对特性做出明智的决策?

  • 我们是否有任何用户指标数据,我们可以基于此做出决策?

  • 过去我们实施过的类似特性有任何历史数据吗?

  • 我们的商业模式如何影响我们可以收集和用于应用程序特性决策的数据?

我们的竞争对手是谁?

  • 我们是否提供了与竞品类似,但做得更好的东西?

  • 我们是否处于利基市场?

  • 我们可以从竞品中学习到什么?

  • 我们的商业模式如何影响我们面临的竞争程度以及我们需要实现的特性类型?

我们如何让现有的东西变得更好?

  • 考虑到我们添加特性的速度,我们是否有足够的时间来维护现有的特性?

  • 从架构上讲,修改一个特性而不破坏其他特性是否安全?

  • 用户如何影响我们对现有特性的改进?

  • 我们的商业模式如何影响我们部署产品增强的能力?

我们如何将用户管理整合到特性中?

  • 访问控制机制是否已经通用到不再特性发展为日常担忧的程度?

  • 我们能否将特性组织成小组?

  • 用户能否开启或关闭特性?

  • 我们正在构建的应用程序类型,以及我们的用户和他们的角色,如何影响我们特性的复杂性?

我们的特性是否与后端服务紧密耦合?

  • 现有的服务是否足够通用,能够处理我们正在实施的新特性?

  • 我们能够在浏览器中完全模拟后端服务吗?

  • 我们的特性如何影响后端服务的设计和功能?

前端如何与后端数据保持同步?

  • 我们能否利用 WebSocket 连接来实现推送通知?

  • 高用户活动是否会导致更多消息被发送给其他用户?

  • 实时数据消费如何影响我们特性的复杂性?

开发者清单

在我们软件开发过程中,我们需要回顾的最终清单是关于开发资源的。这个清单不会像用户或者特性清单那样经常使用。尽管如此,确保我们在开发资源方面解决出现的问题是很重要的。

我们如何找到合适的发展资源?

  • 我们能否用目前现有的开发资源应付过去?

  • 我们需要重新审视正在开发的特性,以适应我们所拥有的资源吗?

  • 我们是否有为正在构建的产品配备正确的开发资源?

我们如何分配开发责任?

  • 责任区域之间应该有多少重叠?

  • 我们当前的责任区域是否反映了我们在构建什么?

  • 团队成员的各种技能如何影响他们的职责?

我们能否避免雇佣过多的资源?

  • 我们是否过早地雇佣了人员?

  • 由于资源过多,我们是否经历了沟通上的开销?

  • 同时开发多个特性是否会影响这样一种观念:更多的开发者意味着能完成更多的工作?

总结

当涉及到在 JavaScript 应用程序中扩展影响者时,有三个主要关注领域。每个领域都直接影响其下方的领域,直到我们最终到达底层,即开发发生的地方。

首先,也是最重要的是,我们软件的用户。有许多与用户相关的因素会影响我们软件的扩展需求。例如,我们组织选择的企业模型会在不知不觉中影响我们后来关于架构的决策。基于许可证的部署可能会在某处进行本地部署,因此更有可能需要进行定制。复杂性的组合无穷无尽,它们都源于我们软件的用户。

我们接下来主要关注的是功能本身。我们必须把我们从思考我们的用户以及他们对扩展性的影响中获得的大部分洞察力,作为输入提供给我们的功能设计。例如,一旦人们开始使用我们的软件,很短的时间内可能会发生很多事情。这会如何分散我们应用程序的核心价值呢?信不信由你,专注也是需要扩展的。

最后,还有开发活动。需要建设团队,而且找到合适的人并不容易。即使我们有了一个由优秀开发者组成的团队,也需要考虑到责任以及它们是如何受到功能和使用它们的人的影响。同样地,随着我们应用程序的开发进展,我们还需要确保正确的资源得到配置。

既然我们已经在前端奠定了扩展性的基础,现在就准备深入具体内容吧。本书的剩余部分将把前两章的概念放入 JavaScript 的语境中。我们知道什么是影响扩展性的因素,现在我们开始做出架构上的取舍。这是有趣的部分,因为我们可以开始写代码了。

第三章: 组件组合

大规模的 JavaScript 应用程序可以看作是一系列相互通信的组件。本章的重点在于这些组件的组合,而下一章我们将探讨这些组件是如何彼此通信的。组合是一个很大的主题,也是与可扩展的 JavaScript 代码相关的。当我们开始考虑我们组件的组合时,我们会开始注意到我们设计中的一些缺陷;限制了我们根据影响者进行扩展的局限性。

组件的组合不是随机的——有一些在 JavaScript 组件中普遍存在的模式。我们将从本章开始探讨一些这些通用的组件类型,它们封装了在每个网络应用程序中都能找到的常见模式。理解组件实现模式对于以可扩展的方式扩展这些通用组件至关重要。

从纯粹的技术角度来看,正确地组合我们的组件是一回事,轻松地将这些组件映射到功能上是另一回事。对我们已经实现的组件来说,同样的挑战也成立。我们编写代码的方式需要提供一定程度的透明度,这样在运行时和设计时分解我们的组件并理解它们在做什么是可行的。

最后,我们将探讨将业务逻辑与我们的组件解耦的想法。这并不是什么新想法——关注分离已经存在很长时间了。JavaScript 应用程序的挑战在于它涉及很多东西——很难清楚地将与业务逻辑相关的其他实现关注区分开来。我们组织源代码的方式(相对于使用它们的组件)可以对我们的扩展能力产生巨大的影响。

通用组件类型

在当今这个时代,没有人会不借助库、框架或两者就着手构建大规模的 JavaScript 应用程序,这是极不可能的。让我们将这些统称为工具,因为我们更关心使用帮助我们扩展的工具,而不是工具之间的优劣。归根结底,开发团队需要决定哪种工具最适合我们正在构建的应用程序,个人喜好暂且不论。

选择我们使用的工具的指导因素是它们提供的组件类型以及它们的能力。例如,一个较大的网络框架可能拥有我们需要的所有通用组件。另一方面,一个函数式编程实用库可能提供我们需要的很多底层功能。如何将这些事物组合成一个可扩展的、连贯的功能,由我们来决定。

想法是找到暴露我们需要的组件的通用实现的工具。通常,我们会扩展这些组件,构建我们应用程序特有的特定功能。本节将介绍在一个大规模 JavaScript 应用程序中我们最需要的典型组件。

模块

几乎每种编程语言都以一种形式或另一种形式存在模块。除了 JavaScript。不过这几乎是不正确的——在撰写本文时,ECMAScript 6 处于最终草案状态,引入了模块的概念。然而,如今市场上已经有了一些工具,可以让我们在不依赖script标签的情况下模块化代码。大规模的 JavaScript 代码仍然是一件相对较新的事情。像script标签这样的东西并不是为模块代码和依赖管理这类问题而设计的。

RequireJS 可能是最受欢迎的模块加载器和依赖解析器。我们需要一个库只是为了将模块加载到我们的前端应用程序中,这反映了涉及的复杂性。例如,当考虑到网络延迟和竞争条件时,模块依赖关系并不是一件简单的事情。

另一个选择是使用像Browserify这样的转换器。这种方法越来越受欢迎,因为它允许我们使用 CommonJS 格式声明我们的模块。这种格式被 NodeJS 使用,即将到来的 ECMAScript 模块规范与 CommonJS 比与 AMD 更接近。优点是我们今天编写的代码与后端 JavaScript 代码的兼容性更好,也适应未来。

一些框架,如 Angular 或 Marionette,有自己的关于模块的想法——尽管是更抽象的想法。

这些模块更多的是关于组织我们的代码,而不是巧妙地将代码从服务器传输到浏览器。这类模块甚至可能更好地映射到框架的其他功能。例如,如果有一个中心化的应用程序实例用来管理我们的模块,框架可能提供一种从应用程序管理模块的手段。请看下面的图表:

模块

使用模块作为构建块的全局应用程序组件。模块可以很小,只包含一个功能,也可以很大,包含几个功能

这让我们能在模块级别执行更高级的任务(例如禁用模块或使用参数配置它们)。本质上,模块代表特性。它们是一种允许我们将关于给定特性的某些东西封装起来的包装机制。模块帮助我们对应用程序进行模块化处理,通过为我们的特性添加高级操作,将特性视为构建模块。没有模块,我们就找不到这种有意义的处理方式。

模块的组成根据声明模块的机制不同而有所不同。一个模块可能是简单的,提供一个命名空间,从中可以导出对象。如果我们使用特定的框架模块风味,它可能会有更多内容。例如自动事件生命周期,或者执行** boilerplate** 设置任务的方法。

无论如何划分,可扩展 JavaScript 中的模块是创建更大块状结构的方法,也是处理复杂依赖关系的方法:

// main.js
// Imports a log() function from the util.js model.
import log from 'util.js';
log('Initializing...');

// util.js
// Exports a basic console.log() wrapper function.
'use strict';

export default function log(message) {
    if (console) {
        console.log(message);
    }
}

虽然使用模块大小的构建块来构建大型应用程序更容易,但是将模块从应用程序中抽离并独立工作也更简单。如果我们的应用程序是单块的,或者我们的模块太多且过于细粒度,我们很难从代码中切除问题区域,或者测试进行中的工作。我们的组件可能独立运行得很好。然而,它可能在系统的其他地方产生负面影响。如果我们能够一次抽离一个拼图块,而不需要太多的努力,我们可以扩展故障排除过程。

路由器

任何大型 JavaScript 应用程序都有大量的可能的 URI。URI 是用户正在查看的页面的地址。用户可以通过点击链接导航到这个资源,或者他们可能会被我们的代码自动带到一个新的 URI,也许是对某些用户操作的响应。网络一直依赖于 URI,在大规模 JavaScript 应用程序出现之前就已经如此。URI 指向资源,而资源可以是几乎任何东西。应用程序越大,资源越多,潜在的 URI 也越多。

路由器组件是我们在前端使用的工具,用于监听 URI 变化事件并相应地响应。我们不再依赖后端 web 服务器解析 URI 并返回新内容。大多数网站仍然这样做,但在构建应用程序时,这种方法有几个缺点:

路由器

浏览器在 URI 发生变化时触发事件,路由器组件响应这些变化。URI 变化可以由历史 API 触发,或者由location.hash触发。

主要问题是我们希望 UI 是可移动的,也就是说,我们希望能够将其部署在任何后端,并且一切都能正常工作。由于我们不在后端组装 URI 的标记,所以在后端解析 URI 也没有意义。

我们声明性地在路由器组件中指定所有的 URI 模式。我们通常将这些称为路由。把路由想象成一张蓝图,而 URI 则是该蓝图的一个实例。这意味着当路由器接收到一个 URI 时,它可以将其与一个路由相关联。这就是路由器组件的责任。这在小型应用中很简单,但当我们谈论规模时,对路由器设计进行进一步的思考是必要的。

作为起点,我们必须考虑我们想要使用的 URI 机制。这两个选择基本上是监听哈希变化事件,或者利用历史 API。使用哈希-感叹号 URI 可能是最简单的方法。另一方面,现代浏览器都支持的history API 允许我们格式化不带哈希-感叹号的 URI——它们看起来像真正的 URI。我们正在使用的框架中的路由器组件可能只支持其中之一,从而简化了决策。一些支持这两种 URI 方法,在这种情况下,我们需要决定哪一种最适合我们的应用程序。

关于我们架构中路由的下一个考虑因素是如何响应路由变化。通常有两种方法。第一种是声明性地将路由绑定到回调函数。当路由器没有很多路由时,这是理想的。第二种方法是在路由被激活时触发事件。这意味着没有直接绑定到路由器上。相反,其他组件监听此类事件。当有大量路由时,这种方法有益,因为路由器不知道组件,只知道路由。

下面是一个显示路由器组件监听路由事件的示例:

// router.js

import Events from 'events.js'

// A router is a type of event broker, it
// can trigger routes, and listen to route
// changes.
export default class Router extends Events {

    // If a route configuration object is passed,
    // then we iterate over it, calling listen()
    // on each route name. This is translating from
    // route specs to event listeners.
    constructor(routes) {
        super();

        if (routes != null) {
            for (let key of Object.keys(routes)) {
                this.listen(key, routes[key]);
            }
        }
    }

    // This is called when the caller is ready to start
    // responding to route events. We listen to the
    // "onhashchange" window event. We manually call
    // our handler here to process the current route.
    start() {
        window.addEventListener('hashchange',
            this.onHashChange.bind(this));

        this.onHashChange();
    }

    // When there's a route change, we translate this into
    // a triggered event. Remember, this router is also an
    // event broker. The event name is the current URI.
    onHashChange() {
        this.trigger(location.hash, location.hash);
    }

};

// Creates a router instance, and uses two different
// approaches to listening to routes.
//
// The first is by passing configuration to the Router.
// The key is the actual route, and the value is the
// callback function.
//
// The second uses the listen() method of the router,
// where the event name is the actual route, and the
// callback function is called when the route is activated.
//
// Nothing is triggered until the start() method is called,
// which gives us an opportunity to set everything up. For
// example, the callback functions that respond to routes
// might require something to be configured before they can
// run.

import Router from 'router.js'

function logRoute(route) {
    console.log('${route} activated');
}

var router = new Router({
    '#route1': logRoute
});

router.listen('#route2', logRoute);

router.start();

注意

为了运行这些示例,有些必要的代码被省略了。例如,events.js模块包含在本书的代码包中,它与示例不是那么相关。

为了节省空间,代码示例避免了使用特定的框架和库。实际上,我们不会自己编写路由器或事件 API——我们的框架已经做到了。我们 instead 使用纯 ES6 JavaScript,以说明与扩展我们的应用程序相关的要点。

当我们谈论路由时,我们还将考虑是否想要全局的、单块的路由器、每个模块的路由器,或其他组件。拥有单块路由器的缺点是,当它变得足够大时,它变得难以扩展,因为我们在不断添加功能和路由。优点是所有路由都在一个地方声明。单块路由器仍然可以触发所有组件可以监听的事件。

每个模块的路由方法涉及多个路由实例。例如,如果我们的应用程序有五个组件,每个都有自己的路由器。这种方法的优势是模块完全自包含。任何与这个模块合作的人都不需要查看其他地方来弄清楚它响应哪些路由。采用这种方法,我们还可以使路由定义与响应它们的函数之间的耦合更紧密,这可能意味着代码更简单。这种方法的缺点是我们失去了将所有路由声明在中央位置的集中性。请看下面的图表:

路由器

左边的路由器是全局的——所有模块都使用相同的实例来响应 URI 事件。右边的模块有自己的路由器。这些实例包含特定于模块的配置,而不是整个应用程序的配置。

根据我们所使用的框架的功能,路由器组件可能支持也可能不支持多个路由器实例。可能只有一个回调函数每条路由来实现。我们对路由器事件可能还有些不清楚的细微差别。

模型/集合

应用程序与之交互的 API 暴露实体。一旦这些实体被传输到浏览器,我们将存储这些实体的模型。集合是一组相关实体,通常是相同类型的。

我们使用的工具可能提供通用模型和/或集合组件,也可能有类似但名称不同的东西。建模 API 数据的目标是对 API 实体的大致模拟。这可能像将模型存储为普通的 JavaScript 对象,将集合存储为数组一样简单。

将 API 实体简单地存储在数组中的对象中的挑战在于,然后另一个组件负责与 API 通信,在数据变化时触发事件,并执行数据转换。我们希望在需要时能够使其他组件能够转换集合和模型,以履行他们的职责。但我们不想有重复的代码,最好是我们能够封装像转换,API 调用和事件生命周期这样的常见事物。看看下一个图表:

模型/集合

模型封装与 API 的交互,解析数据,以及在数据变化时触发事件。这使得模型外的代码更简单。

隐藏 API 数据如何加载到浏览器中,或者我们如何发出命令的细节,有助于我们在成长过程中扩展我们的应用程序。随着向 API 添加更多实体,我们代码的复杂性也在增长。我们可以通过将 API 交互限制在我们的模型和集合组件中来限制这种复杂性。

提示

下载示例代码

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

我们在模型和集合上面临的另一个可扩展性问题就是它们在大局中的位置。也就是说,我们的应用程序实际上只是一个由较小组件组成的大型组件。我们的模型和集合很好地映射到我们的 API,但不一定映射到功能。API 实体比特定功能更通用,通常被几个功能使用。这让我们提出了一个问题——我们的模型和集合应该放入哪个组件中?

下面是一个具体视图扩展通用视图的例子。相同的模型可以传递给两者:

// A super simple model class.
class Model {
    constructor(first, last, age) {
        this.first = first;
        this.last = last;
        this.age = age;
    }
}

// The base view, with a name method that
// generates some output.
class BaseView {
    name() {
        return '${this.model.first} ${this.model.last}';
    }
}

// Extends BaseView with a constructor that accepts
// a model and stores a reference to it.
class GenericModelView extends BaseView {
    constructor(model) {
        super();
        this.model = model;
    }
}

// Extends GenericModelView with specific constructor
// arguments.
class SpecificModelView extends BaseView {
    constructor(first, last, age) {
        super();
        this.model = new Model(...arguments);
    }
}

var properties = [ 'Terri', 'Hodges', 41 ];

// Make sure the data is the same in both views.
// The name() method should return the same result...
console.log('generic view',
    new GenericModelView(new Model(...properties)).name());
console.log('specific view',
    new SpecificModelView(...properties).name());

一方面,组件可以完全与它们所使用的模型和集合相通用。另一方面,一些组件对于它们的要求是具体的——它们可以直接实例化它们的集合。在运行时配置通用组件与特定模型和集合只会对我们有利,当组件真正通用,并且在多个地方使用时。否则,我们不妨将模型封装在使用它们的组件内部。选择正确的方法有助于我们实现规模扩展。因为,我们并非所有的组件都完全通用或完全具体。

控制器/视图

根据我们使用的框架和团队遵循的设计模式,控制器和视图可以表示不同的事物。MV模式和风格变化实在太多了,无法在规模上提供有意义的区分。微小的差异相对于类似但不同的 MV方法有相应的取舍。对于我们讨论大规模 JavaScript 代码的目的,我们将它们视为同一类型的组件。如果我们决定在我们的实现中分离这两个概念,本节中的想法将适用于这两种类型。

让我们暂时使用“视图”这个术语,知道我们从概念上涵盖了视图和控制器。这些组件与其他几种组件类型交互,包括路由器、模型或集合以及模板,这些将在下一节中讨论。当发生某些事情时,用户需要被告知。视图的工作是更新 DOM。

这可能只是改变 DOM 元素的一个属性,或者涉及到渲染一个新的模板:

控制器/视图

一个视图组件在路由和模型事件响应中更新 DOM

一个视图可以在多种事件发生时更新 DOM。路由可能已经改变。模型可能已经被更新。或者更直接一点,比如视图组件上的方法调用。更新 DOM 并不像人们想象的那么简单。我们需要考虑性能问题——当我们的视图被事件淹没时会发生什么?我们需要考虑延迟问题——这个 JavaScript 调用堆栈会运行多久,在停止并实际允许 DOM 渲染之前?

我们的视图的另一个职责是响应 DOM 事件。这些通常是由用户与我们的 UI 交互触发的。交互可能从我们的视图开始和结束。例如,根据用户输入或我们的某个模型的状态,我们可能会用一条消息更新 DOM。或者如果事件处理程序被去抖(debounced),我们可能会什么都不做。

防抖函数将多个调用合并成一个。例如,在 10 毫秒内调用foo() 20 次可能只会导致foo()的实现被调用一次。要了解更详细的解释,请查看:drupalmotion.com/article/debounce-and-throttle-visual-explanation。大多数情况下,DOM 事件被转换成其他东西,要么是一个方法调用,要么是另一个事件。例如,我们可能会调用模型的一个方法,或者转换一个集合。大多数情况下的最终结果是我们通过更新 DOM 来提供反馈。

这可以直接完成,也可以间接完成。在直接更新 DOM 的情况下,扩展起来很简单。而在间接更新,或者通过副作用更新的情况下,扩展变得更具挑战性。这是因为随着应用程序拥有更多的活动部件,形成原因和效果的心理地图变得越来越困难。

以下是一个示例,显示了一个视图监听 DOM 事件和模型事件。

import Events from 'events.js';

// A basic model. It extending "Events" so it
// can listen to events triggered by other components.
class Model extends Events {
    constructor(enabled) {
        super();
        this.enabled = !!enabled;
    }

    // Setters and getters for the "enabled" property.
    // Setting it also triggers an event. So other components
    // can listen to the "enabled" event.
    set enabled(enabled) {
        this._enabled = enabled;
        this.trigger('enabled', enabled);
    }

    get enabled() {
        return this._enabled;
    }
}

// A view component that takes a model and a DOM element
// as arguments.
class View {
    constructor(element, model) {

        // When the model triggers the "enabled" event,
        // we adjust the DOM.
        model.listen('enabled', (enabled) => {
            element.setAttribute('disabled', !enabled);
        });

        // Set the state of the model when the element is
        // clicked. This will trigger the listener above.
        element.addEventListener('click', () => {
            model.enabled = false;
        });
    }
}

new View(document.getElementById('set'), new Model());

所有这些复杂性的好处是我们实际上得到了一些可重用的代码。视图对于它监听的模型或路由器是如何更新的是不关心的。它在意的只是特定组件上的特定事件。这实际上对我们有帮助,因为它减少了我们需要实现的特殊情况处理量。

在运行时生成的 DOM 结构,由于渲染所有我们的视图而产生,也需要考虑。例如,如果我们查看一些顶级 DOM 节点,它们内部有嵌套结构。正是这些顶级节点构成了我们布局的骨架。也许这是由主应用程序视图渲染的,而我们的每个视图都有与其的子关系。或者层次结构可能比这更深。我们正在使用的工具很可能有处理这些父子关系的机制。然而,请注意,庞大的视图层次结构难以扩展。

模板

模板引擎曾经主要存在于后端框架中。现在这种情况越来越少见,这要归功于前端可用的复杂模板渲染库。在大型 JavaScript 应用程序中,我们很少与后端服务讨论 UI 特定的事情。我们不会说,“这是一个 URL,为我渲染 HTML”。趋势是赋予我们的 JavaScript 组件一定程度的自主权——让他们渲染自己的标记。

组件标记与渲染它们的组件耦合是一件好事。这意味着我们可以轻松地判断 DOM 中的标记是如何生成的。然后我们可以诊断问题,调整大型应用程序的设计。

模板有助于为我们每个组件建立关注点的分离。在浏览器中渲染的标记主要来自模板。这使得标记特定的代码不会出现在我们的 JavaScript 中。前端模板引擎不仅仅是字符串替换的工具;它们通常还有其他工具来帮助我们减少要编写的样板 JavaScript 代码量。例如,我们可以在标记中嵌入条件语句和 for-each 循环,这取决于它们是否适合。

特定于应用程序的组件

我们迄今为止讨论的组件类型对于实现可扩展的 JavaScript 代码非常有用,但它们也非常通用。在实现过程中,我们不可避免地会遇到障碍——我们遵循的组件组合模式将不适用于某些功能。这时,我们应该退后一步,考虑可能需要向我们的架构中添加一种新类型的组件。

例如,考虑小部件的概念。这些都是主要关注呈现和用户交互的通用组件。假设我们的许多视图都在使用完全相同的 DOM 元素和完全相同的事件处理程序。在应用程序中的每个视图中重复它们是没有意义的。如果我们将其提取为公共组件,是不是会更好?一个视图可能过于复杂,所以也许我们需要一种新类型的小部件组件?

有时我们会为了组件化而创建组件。例如,我们可能会有一个组件,它将路由器、视图、模型/集合和模板组件粘合在一起,形成一个协调一致的单元。模块部分解决了这个问题,但它们并不总是足够。有时我们缺少一点编导,以便我们的组件进行通信。我们在下一章讨论通信组件。

扩展通用组件

我们经常在开发过程的后期发现,我们依赖的组件缺少我们需要的东西。如果我们使用的基组件设计得很好,那么我们可以扩展它,插入我们需要的新的属性或功能。在本节中,我们将通过一些场景,了解在应用程序中使用的一些常见的通用组件。

如果我们想要扩展我们的代码,我们需要尽可能利用这些基本组件。我们可能也希望在某个时候开始扩展我们自己的基本组件。有些工具比其他工具更好地促进通过实现这种特殊行为来扩展机制。

识别共同的数据和功能

在考虑扩展特定类型的组件之前,考虑所有组件类型中常见的属性和功能是有价值的。其中一些东西一开始就会很明显,而其他的则不那么明显。我们能否扩展在很大程度上取决于我们能否识别出组件之间的共性。

如果我们有一个全局应用程序实例,这在大型 JavaScript 应用程序中很常见,全局值和功能可以放在那里。然而,随着更多共同事物的发现,这可能会随着时间的推移变得不受控制。另一种方法可能是拥有几个全局模块,而不仅仅是一个单一的应用程序实例。或者两者都有。但从可理解性的角度来看,这种方法并不适用:

识别常见数据和功能

理想的组件层次结构不应超过三级。最高级别通常位于我们应用程序依赖的框架中

作为一个经验法则,我们应该避免在任何给定组件上扩展超过三级。例如,从我们正在使用的工具中扩展出通用视图组件的通用版本。这包括我们应用程序中每个视图实例都需要的属性和功能。这只是一个两级的层次结构,易于管理。这意味着如果任何给定组件需要扩展我们的通用视图,它可以在不复杂化事物的情况下做到这一点。三级应该是任何给定类型的最大扩展层次结构深度。这足以避免不必要的全局数据,超出这个范围会因为层次结构不易理解而出现扩展问题。

扩展路由组件

我们的应用程序可能只需要一个单一的路由器实例。即使在这种情况下,我们可能仍然需要重写通用路由器的某些扩展点。在有多个路由器实例的情况下,肯定会有我们不想重复的共同属性和功能。例如,如果我们应用程序中的每个路由都遵循相同的模式,只有细微的差别,我们可以在基础路由器中实现工具以避免重复代码。

除了声明路由外,当给定路由被激活时,还会发生事件。根据我们应用程序的架构,需要发生不同的事情。也许有些事情总是需要发生,无论哪个路由被激活。这就是扩展路由以提供我们自己的功能变得方便的地方。例如,我们必须验证给定路由的权限。对于我们来说,通过个别组件来处理这个问题并没有多大意义,因为这样在复杂的访问控制规则和大量路由的情况下,无法很好地扩展。

扩展模型/集合

我们的模型和集合,无论它们具体的实现方式如何,都将彼此共享一些共同属性-尤其是如果它们针对同一个 API,这通常是常见情况。给定模型或集合的具体内容围绕 API 端点、返回的数据和可采取的可能行动展开。我们可能会为所有实体 targeting 相同的基 API 路径,并且所有实体都有一些共享属性。与其在每一个模型或集合实例中重复自己,不如抽象出共同的属性。

除了在我们模型和集合之间共享属性,我们还可以共享通用行为。例如,某个给定的模型可能没有足够的数据来实现某个特性。也许这些数据可以通过转换模型得到。这类转换可能是通用的,并且可以抽象到基础模型或集合中。这真的取决于我们正在实现的特性的类型以及它们之间的相互一致性。如果我们发展迅速,并且有很多关于"非传统"特性的请求,那么我们更有可能在需要这些一次性更改的模型或集合的视图中实现数据转换。

大多数框架都处理了执行 XHR 请求以获取我们的数据或执行操作的细微差别。不幸的是,这还不是整个故事,因为我们的特性很少与单个 API 实体一对一映射。更有可能的是,我们将有一个需要多个相关集合和一个转换集合的特性。这种操作可以迅速变得复杂,因为我们必须处理多个 XHR 请求。

我们可能会使用承诺(promises)来同步这些请求的取回,然后在获得所有必要的来源后执行数据转换。

以下是一个示例,显示一个特定模型扩展通用模型,以提供新的取回行为:

// The base fetch() implementation of a model, sets
// some property values, and resolves the promise.
class BaseModel {
    fetch() {
        return new Promise((resolve, reject) => {
            this.id = 1;
            this.name = 'foo';
            resolve(this);
        });
    }
}

// Extends BaseModel with a specific implementation
// of fetch().
class SpecificModel extends BaseModel {

    // Overrides the base fetch() method. Returns
    // a promise with combines the original
    // implementation and result of calling fetchSettings().
    fetch() {
        return Promise.all([
            super.fetch(),
            this.fetchSettings()
        ]);
    }

    // Returns a new Promise instance. Also sets a new
    // model property.
    fetchSettings() {
        return new Promise((resolve, reject) => {
            this.enabled = true;
            resolve(this);
        });
    }
}

// Make sure the properties are all in place, as expected,
// after the fetch() call completes.
new SpecificModel().fetch().then((result) => {
    var [ model ] = result;
    console.assert(model.id === 1, 'id');
    console.assert(model.name === 'foo');
    console.assert(model.enabled, 'enabled');
    console.log('fetched');
});

扩展控制器/视图

当我们在一个基础模型或基础集合中,常常会发现我们的控制器或视图之间有共享的属性。这是因为控制器和视图的职责就是渲染模型或集合数据。例如,如果同一个视图反复渲染相同的模型属性,我们可能可以把这部分内容移到一个基础视图中,然后从这个基础上扩展。那些重复的部分可能就在模板本身。这意味着我们可能需要考虑在基础视图中加入一个基础模板,如图所示。扩展这个基础视图的视图会继承这个基础模板。

根据我们可用的库或框架,以这种方式扩展模板可能并不可行。或者我们特性的性质可能使得这种实现变得困难。例如,可能没有通用的基础模板,但可能有很多更小的视图和模板可以插入到更大的组件中:

扩展控制器/视图

扩展基础视图的视图可以填充基础视图的模板,同时继承其他基础视图的功能

我们的视图还需要响应用户交互。它们可能会直接响应,或者将事件传递给组件层次结构的上层。无论哪种情况,如果我们的特性在某种程度上是一致的,我们都会希望把一些通用的 DOM 事件处理抽象到一个通用的基础视图中。这对于扩展我们的应用程序非常有帮助,因为当我们添加更多特性时,DOM 事件处理代码的增加量被最小化。

将特性映射到组件

现在我们已经了解了最常见的 JavaScript 组件以及我们希望在应用程序中使用它们时的扩展方式,是时候考虑如何将这些组件粘合在一起了。一个单独的路由器没什么用。一个独立的模型、模板或控制器也是如此。相反,我们想要这些东西一起工作,形成一个实现我们应用程序中特性的连贯单位。

为此,我们必须将我们的特性映射到组件上。我们也不能随意地进行这种映射——我们需要思考我们的特性中哪些是通用的,以及它们有哪些独特之处。这些特性属性将指导我们在生产可扩展性产品时的设计决策。

通用特性

组件组合最重要的方面可能是一致性和可重用性。在考虑我们应用程序面临的可扩展性影响时,我们会列出一个所有组件必须具备的特性的清单:比如用户管理、访问控制以及其他我们应用程序特有的特性。这还包括其他架构视角(在本书的剩余部分会有更深入的探讨),它们构成了我们通用特性的核心:

通用特性

由我们框架中的其他通用组件组成的通用组件

我们应用程序中每个特性的通用方面都相当于一份蓝图。它们指导我们在构建更大的模块时如何组合。这些通用特性考虑到了帮助我们扩展的建筑因素。如果我们能将这些因素编码为聚合组件的一部分,我们将在扩展应用程序时更加得心应手。

使这项设计任务具有挑战性的是,我们必须从可扩展性架构的角度以及特性完整性的角度来考虑这些通用组件。如果每个特性都表现得一样,那就没问题了。如果每个特性都遵循一个相同的模式,那么在扩展时,天空就是极限。

但是 100%一致的特性功能是一种错觉,这一点对于 JavaScript 程序员来说比对于用户更加明显。这种模式之所以会崩溃,是出于必要的考虑。重要的是以一种可扩展的方式来应对这种崩溃。这就是为什么成功的 JavaScript 应用程序会不断地重新审视我们特性的通用方面,以确保它们反映现实。

特定特性

当需要实现某种不符合模式的功能时,我们面临的是一个可扩展性挑战。我们必须进行调整,并考虑向我们的架构引入此类功能所带来的后果。当模式被打破时,我们的架构需要改变。这不是一件坏事——这是必要的。我们扩展以适应这些新功能的能力的限制,在于我们现有特性的通用方面。这意味着我们不能对通用特性组件过于僵化。如果我们过于苛求,我们就是在为自己设置失败的局面。

在做出任何由于奇特功能而导致的草率建筑决策之前,想想具体的扩展后果。例如,新功能是否真的重要,它使用不同的布局,并需要与所有其他功能组件不同的模板?JavaScript 扩展艺术的状态是围绕找到我们组件组合要遵循的几种基本蓝图。其他一切都取决于在如何进行上的讨论。

分解组件

组件组合是一种创建秩序的活动;把小的部分组合成大的行为。在开发过程中,我们经常需要朝着相反的方向努力。即使开发完成后,我们也可以通过分解代码,观察它在不同上下文中运行来了解组件如何工作。组件分解意味着我们能够把系统拆开,以一种结构化的方式检查各个部分。

维护和调试组件

在应用程序开发的过程中,我们的组件积累了越来越多的抽象。我们这样做是为了更好地支持一个功能的需求,同时支持某些有助于我们扩展的建筑属性。问题在于,随着抽象的积累,我们失去了对组件运行情况的透明度。这不仅对于诊断和修复问题至关重要,而且也关系到代码学习的难易程度。

例如,如果有很多间接调用,程序员就需要花更长的时间来追踪原因到效果。在追踪代码上浪费的时间,降低了我们从开发角度扩展的能力。我们面临着两个相反的问题。首先,我们需要抽象来解决现实世界的功能需求和建筑约束。其次,由于缺乏透明度,我们无法掌握自己的代码。

下面是一个示例,展示了渲染组件和特性组件。特性使用的渲染器很容易被替代:

// A Renderer instance takes a renderer function
// as an argument. The render() method returns the
// result of calling the function.
class Renderer {
    constructor(renderer) {
        this.renderer = renderer;
    }

    render() {
        return this.renderer ? this.renderer(this) : '';
    }
}

// A feature defines an output pattern. It accepts
// header, content, and footer arguments. These are
// Renderer instances.
class Feature {
    constructor(header, content, footer) {
        this.header = header;
        this.content = content;
        this.footer = footer;
    }

    // Renders the sections of the view. Each section
    // either has a renderer, or it doesn't. Either way,
    // content is returned.
    render() {
        var header = this.header ?
                '${this.header.render()}\n' : '',
            content = this.content ?
                '${this.content.render()}\n' : '',
            footer = this.footer ?
                this.footer.render() : '';

        return '${header}${content}${footer}';
    }
}

// Constructs a new feature with renderers for three sections.
var feature = new Feature(
    new Renderer(() => { return 'Header'; }),
    new Renderer(() => { return 'Content'; }),
    new Renderer(() => { return 'Footer'; })
);

console.log(feature.render());

// Remove the header section completely, replace the footer
// section with a new renderer, and check the result.
delete feature.header;
feature.footer = new Renderer(() => { return 'Test Footer'; });

console.log(feature.render());

一个可以帮助我们应对这两种相反的扩展影响因素的策略是可替代性。特别是我们组件或子组件可以多么容易地被其他东西替代。这应该是非常容易实现的。所以在我们引入层层抽象之前,我们需要考虑一下复杂组件能否很容易地被简单组件替代。这可以帮助程序员学习代码,也有助于调试。

例如,如果我们能够将一个复杂的组件从系统中取出来,用一个虚拟组件来替代,我们就可以简化调试过程。组件替换后错误消失,我们找到了有问题的组件。否则,我们可以排除一个组件,继续在其他地方寻找。

重构复杂组件

当然,说到比做到容易,尤其是面对截止日期时,实现组件的可替代性。一旦无法轻松用其他组件替换组件,是时候考虑重构我们的代码了。至少是那些使得可替代性变得不可行的部分。找到正确的封装级别和正确的透明度级别,这是一个平衡的行为。

在更细粒度的层次上,替代也有帮助。例如,假设一个视图方法又长又复杂。如果在执行该方法的过程中有几个阶段,我们想运行一些自定义内容,我们做不到。把一个单一的方法重构成几个方法会更好,每个都可以被覆盖。

可插拔的业务逻辑

并非我们所有的业务逻辑都需要在我们的组件内部,与外部世界隔离。相反,如果我们能将业务逻辑写成一组函数,那就更好了。从理论上讲,这为我们提供了关注点分离。组件在那里处理帮助我们扩展的具体架构问题,而业务逻辑可以插入到任何组件中。实际上,将业务逻辑从组件中分离出来并不简单。

扩展与配置

当我们构建组件时,可以采取两种方法。作为一个起点,我们有库和框架提供的工具。从那里,我们可以继续扩展这些工具,随着我们深入到特性的更深层次,变得更加具体。或者,我们可以为组件实例提供配置值。这些指导组件如何行为。

扩展那些本来需要配置的东西的优势在于,调用者不需要担心它们。如果我们能通过使用这种方法来解决问题,那就更好了,因为它会导致更简单的代码-尤其是使用组件的代码。另一方面,我们可能会有通用的功能组件,如果它们支持这个配置或那个配置选项,可以用于特定目的。这种方法的优势在于使用更简单的组件层次结构,以及更少的总体组件。

有时保持组件尽可能通用,在其可理解范围内,会更好。这样,当我们需要为特定功能使用通用组件时,我们就可以使用它,而无需重新定义我们的层次结构。当然,这会让调用该组件的复杂性增加,因为他们需要为其提供配置值。

这一切都是我们,即我们应用程序的 JavaScript 架构师,需要权衡的。我们是希望封装一切,配置一切,还是希望在这两者之间找到平衡?

无状态的业务逻辑

在函数式编程中,函数没有副作用。在某些语言中,这一特性被强制执行,在 JavaScript 中则不是。然而,我们仍然可以在 JavaScript 中实现无副作用的函数。如果一个函数接受参数,并且总是根据这些参数返回相同的输出,那么可以说这个函数是无状态的。它不依赖于组件的状态,也不会改变组件的状态。它只是计算一个值。

如果我们能建立一个以这种方式实现的业务逻辑库,我们就能设计出非常灵活的组件。我们不是直接在组件中实现这些逻辑,而是将行为传递给组件。这样,不同的组件就可以利用相同的无状态业务逻辑函数。

找到可以以这种方式实现的正确函数是一个棘手的问题,因为一开始就实现这些并不是一个好主意。相反,随着我们应用程序开发的迭代进行,我们可以使用这种策略来重构代码,将其转化为任何可以使用它们的组件共享的通用无状态函数。这导致以集中方式实现业务逻辑,并且组件小、通用,在各种上下文中可重用。

组织组件代码

除了以帮助我们的应用程序扩展的方式组合我们的组件外,我们还需要考虑我们源代码模块的结构。当我们刚开始一个项目时,我们的源代码文件往往很好地映射到客户浏览器中运行的内容。随着时间的推移,当我们积累更多功能和组件时,早期关于如何组织我们的源代码树的决定可能会稀释这种强烈的映射。

当我们追踪运行时行为到源代码时,涉及的心理努力越少越好。我们可以通过这种方式扩展到更稳定的功能,因为我们的精力更多地集中在当天的设计问题上——那些直接提供客户价值的事情:

组织组件代码

图显示了将组件部分映射到其实现工件的图

在我们的架构背景下,代码组织的另一个方面是我们的隔离特定代码的能力。我们应该将我们的代码看作是我们的运行时组件,它们是自给自足的单元,我们可以开启或关闭它们。也就是说,我们应该能够找到给定组件所需的全部源代码文件,而无需四处寻找。如果一个组件需要,比如说,10 个源代码文件——JavaScript、HTML 和 CSS——那么理想情况下,这些都应该在同一个目录中找到。

当然,例外是所有组件都共享的通用基础功能。这些功能应该尽可能地靠近表面,这样很容易追踪我们的组件依赖关系;它们都指向层次结构的顶部。当我们的组件依赖关系到处都是时,扩展依赖图是一个挑战。

总结

本章向我们介绍了组件组合的概念。组件是可扩展的 JavaScript 应用程序的构建块。我们可能会遇到的常见组件包括模块、模型/集合、控制器/视图和模板等。尽管这些模式帮助我们实现了一定程度的一致性,但它们本身并不足以使我们的代码在各种缩放影响因素下运行良好。这就是为什么我们需要扩展这些组件,提供我们自己的通用实现,以便应用程序的具体功能可以进一步扩展和使用。

根据我们应用程序遇到的各种缩放因素,获取组件中通用功能的方法可能会有所不同。一种方法是不断扩展组件层次结构,并保持一切被封装和隐藏在外界之外。另一种方法是在创建组件时将逻辑和属性插入其中。后者的代价是使用这些组件的代码复杂度增加。

我们以查看如何组织源代码来结束本章,以便其结构更好地反映我们逻辑组件设计的情况。这有助于我们扩展开发工作,并将一个组件的代码与其他组件的代码隔离。在下一章中,我们将更详细地研究组件之间的空间。拥有独立站立的精心制作的组件是一回事,实现可扩展的组件通信是另一回事。

第四章:组件通信和职责

上一章重点讨论了组件的是什么——它们由什么组成以及为什么。这一章则专注于我们 JavaScript 组件之间的粘合剂——如何。如果我们设计的组件有特定的目的,那么它们需要与其他组件通信来实现更大的行为。例如,一个路由组件不太可能更新 DOM 或与 API 通信。我们有擅长这些任务的组件,所以其他组件可以通过与它们通信来请求它们执行这些任务。

我们将从探讨前端开发中常见的通信模型开始这一章。我们不太可能为组件间通信开发自己的框架,因为已经有许多健壮的库已经实现了这一点。从 JavaScript 扩展的角度来看,我们更感兴趣的是我们应用程序中选择的通信模型如何阻止我们扩展,以及可以采取什么措施。

给定组件的责任影响它与我们的组件以及我们无法控制的服务的通信,比如后端 API 和 DOM API。一旦我们开始实现我们应用程序的组件,层次开始显现出来,如果明确指出,这些对于可视化通信流程很有用。这允许我们预见到未来组件通信扩展问题。

通信模型

有多种通信模型我们可以用来实现组件间的通信。最简单的就是方法调用,或者函数调用。这种方法最直接,实现起来也最容易。然而,一个直接调用另一个方法组件之间也有很强的耦合关系。这种耦合关系无法扩展到几个组件以上。

相反,我们需要在组件之间建立一个间接层;一种从一个组件到另一个组件调解通信的东西。这有助于我们扩展组件间的通信,因为我们不再直接与其他组件通信。相反,我们依赖我们的通信机制来完成消息传递。这种通信机制的两种流行模型是消息传递和事件触发。让我们比较一下这两种方法。

消息传递模型

消息传递通信模型在 JavaScript 应用程序中非常普遍。例如,消息可以从一台机器上的一个进程传递到另一个进程;它们可以从一台主机传递到另一台主机,或者在同一个进程中传递。尽管消息传递有些抽象,但它仍然是一个相当低层次的概念——有很大的解释空间。它是在两个通信组件之间提供高级抽象的机制。

例如,发布-订阅是消息传递通信模型的一个更具体类型。实现这些消息的机制通常称为经纪人。一个组件将订阅特定主题的消息,而其他组件将在该主题上发布消息。关键的设计特点是组件之间彼此不知晓。这促进了组件之间的松耦合,当组件很多时,有助于我们进行扩展。

消息传递模型

这展示了一个使用经纪人将发布消息传递给订阅者的发布-订阅模型。

另一种消息传递抽象是命令-响应。在这里,一个组件向另一个组件发出命令并获取一个响应。在这个场景中,耦合度稍微紧了一些,因为调用者是针对一个特定的组件来执行命令。

然而,这仍然比直接命令调用更受欢迎,因为我们仍然可以轻松地替代调用者和接收者。

事件模型

我们经常听说用户界面代码是事件驱动的,也就是说,某个事件发生,导致 UI 重新渲染一个部分。或者,用户在 UI 上执行某些操作,触发一个事件,我们的代码必须解释并对其采取行动。从通信的角度来看,UI 只是一堆声明性的视觉元素;被触发的事件以及响应这些事件的回调函数。

这就是为什么发布-订阅模型非常适合 UI 开发。我们开发的大多数组件将触发一种或多种事件类型,而其他组件将订阅这种事件类型并在其触发时运行代码。在较高层次上,大多数组件之间的通信方式就是这样——通过事件,这实际上就是发布-订阅。

从事件和触发机制的角度来说,而不是消息和发布-订阅机制,是有道理的,因为这更符合 JavaScript 开发者的熟悉术语。例如,那里有 DOM 及其整个事件系统。它们是与 Ajax 调用和Promise对象相关联的异步事件,然后还有我们应用程序利用的框架自定义的事件系统。

事件模型

事件是由一个组件触发的,而另一个监听该事件的组件执行回调;这个过程是由事件经纪人机制组织的。

毋庸置疑,所有通过我们的应用程序组件触发事件的独立事件系统使得难以心理上把握给定动作实际发生了什么。这确实是一个扩展问题,本章的各种部分将深入探讨使我们能够扩展组件通信的解决方案。

通信数据架构

事件数据并非是不可透明的—它包含着我们的回调函数用来做出决策的数据。有时,这些数据是不必要的,可以被回调函数安全地忽略。然而,我们不想一开始就决定后来添加的某些回调函数不需要这些数据。这是我们帮助通信机制扩展的东西—在正确的地方提供正确的数据。

数据不仅需要准备好供每个回调函数消费,而且需要有一个可预测的结构。我们将探讨建立事件名称本身以及传递给处理程序函数的数据的命名约定的方法。通过确保所需的事件数据存在且不太可能被误解,我们可以使组件间的通信更加透明,从而更具扩展性。

命名约定

提出有意义的名称是困难的,尤其是当有很多东西需要命名,就像事件一样。一方面,我们希望事件名称具有含义。这有助于我们扩展,因为仅仅通过查看事件名称和其他什么也不做,就能找到意义。另一方面,如果我们试图给事件名称加载过多的意义,那么快速解读事件名称的好处就会丧失。

拥有良好、简短且有意义的事件名称的主要关注点是那些处理这些事件的开发者。想法是,当他们的代码在反应事件时,他们可以快速地构建出一个事件流程的心理地图。请注意,这只是有助于整体可扩展事件架构的众多小实践之一,但无论如何它都是重要的。

例如,我们可能有一个基本事件类型,以及该事件的更具体版本。我们可以有这些基本事件类型的几个,还有几个更具体的实例来覆盖更直接的场景。如果我们的事件名称和类型过于具体,这意味着我们实际上无法重用它们。这也意味着开发者需要处理更多的事件。

数据格式

除了事件名称本身,还有事件载荷。这应该总是包含有关触发的事件的数据,以及可能有关触发它们的组件的数据。关于事件数据最重要的记忆点是,它应该总是包含与订阅这些类型事件的处理程序相关的数据。通常,回调函数可能会根据事件数据中的某个属性的状态决定什么都不做,忽略该事件。

例如,如果在每个回调函数中我们都要对组件进行查找,只是为了获取做出决策或执行进一步操作所需的数据,那么这实际上并不是可扩展的。当然,猜测需要什么数据并不容易。如果我们知道这些数据,我们就可以直接调用函数,省去一开始就需要事件触发机制的麻烦。这个想法是为了松耦合,但同时也要提供可预测的数据。

以下是事件数据可能的样子的一个简化示例:

var eventData = {
    type: 'app.click',
    timestamp: new Date(),
    target: 'button.next'
};

在尝试确定触发事件时与给定事件相关的数据时,一个有用的练习是思考在处理程序内部可以导出什么,以及处理程序几乎永远不需要什么。例如,不建议计算事件数据,然后到处传递。如果处理程序可以计算它,它应该承担这个责任。如果我们开始看到重复的代码,那么这就是另一个故事,是时候开始考虑常见的事件数据了。

常见数据

事件数据将始终包含触发事件的组件的数据—可能是对组件本身的引用。这是一个不错的选择,因为我们今天所知道的一切就是事件被触发了—我们不知道随后的回调函数会想要对这一事件做什么。所以,只要不造成混淆或误导,给我们的回调函数传递很多数据是好的。

所以,如果我们知道同一类型的组件将始终触发相同类型的事件,我们可以相应地设计我们的回调,期望同样的数据总是存在。我们可以使事件数据更加通用,并向回调函数提供有关事件本身的数据。例如,有像时间戳、事件状态等东西—这些与组件无关,而与事件有关。

以下是一个示例,展示了定义所有扩展它的事件的常见数据的基本事件:

// click-event.js
// All instances will have "type" and "timestamp"
// properties, plus any passed-in properties. What's
// important is that anything using "ClickEvent"
// knows that "type" and "timestamp" will always be
// there.
export default class ClickEvent {

    constructor(properties) {
        this.type = 'app.click';
        this.timestamp = new Date();
        Object.assign(this, properties);
    }

};

// main.js
import ClickEvent from 'click-event.js';

// Create a new "ClickEvent" and pass it some properties.
// We can override some of the standard properties,
// and pass it new ones.
var clickEvent = new ClickEvent({
    type: 'app.button.click',
    target: 'button.next',
    moduleState: 'enabled'
});

console.log(clickEvent);

再次,不要一开始就试图在数据重用上表现得很聪明。让重复发生,然后处理它。更好的方法是创建一个基本事件结构,这样一旦找到重复的属性,就很容易将它们移到公共结构中。

可追踪的组件通信

大型 JavaScript 应用程序最大的挑战之一是保持一个关于事件开始和结束的心理模型,换句话说,就是追踪事件在我们组件中的流动。不可追踪的代码使我们的软件的可扩展性面临风险,因为我们无法预测给定事件发生后会发生什么。

在开发过程中,我们可以使用多种策略来减轻确定事件流程的痛苦,甚至可能修改设计来简化事情。简洁性是可以扩展的,我们无法简化我们不理解的事物。

订阅事件

发布-订阅消息模型的一个好处是我们可以介入并添加一个新的订阅。这意味着如果我们不确定某事如何工作,我们可以从各个角度向问题抛出事件回调函数,直到我们更好地了解实际发生的情况。这是一个黑客工具,支持黑客攻击我们软件的工具帮助我们扩展,因为我们在赋予开发者自行解决问题的权力。如果某件事不清晰,当代码容易受到攻击时,他们更有可能自己找出答案。

订阅事件

在特定点或按特定顺序订阅事件可以改变事件的生命周期。拥有这种能力很重要,但如果过度使用,会导致不必要的复杂性。

在极端情况下,我们甚至可能需要使用这种订阅方法来修复生产系统中的某个故障。例如,假设一个回调函数能够停止一个事件的执行,取消任何进一步的处理程序的运行。在我们的代码中触发的事件具有这些类型的入口点是件好事。

全局日志事件

响应触发事件的回调函数可以在内部记录消息。然而,有时我们需要从事件机制本身的角度进行日志记录。例如,如果我们正在处理一些复杂的代码,我们需要知道我们的回调函数相对于其他回调函数何时被调用。事件触发机制应该有一个选项来处理生命周期日志。

这意味着对于任何触发的事件,我们可以看到关于该事件的日志信息,与响应事件的代码无关。我们将这些称为元事件——关于事件的事件。例如,回调运行之前的触发时间、回调运行之后的触发时间以及没有更多回调时的触发时间。这为我们在回调中实现的日志记录提供了一些急需的上下文,以追踪我们的代码。

下面是一个启用了日志的事件代理的示例:

// events.js
// A simple event broker.
export default class Events {

    // Accepts a "log()" function when created,
    // used when triggering events.
    constructor(log) {
        this.log = log;
        this.listeners = {};
    }

    // Calls all functions listening to event "name", passing
    // "data" to each. If the "log()" function was provided to
    // the broker when created, then it logs BEFORE each callback
    // is called, and AFTER.
    trigger(name, data) {
        if (name in this.listeners) {
            var log = this.log;
            return this.listeners[name].map(function(callback) {
                log && console.log('BEFORE', name);

                var result = callback(Object.assign({
                    name: name
                }, data));

                log && console.log('AFTER', name);

                return result;
            });
        }
    }
};

// main.js
import Events from 'events.js';

// Two event callback functions that log
// data. The second one is async because it
// uses "setTimeout()".
function callbackFirst(data) {
    console.log('CALLBACK', data.name);
}

function callbackLast(data) {
    setTimeout(function() {
        console.log('CALLBACK', data.name);
    }, 500);
}

var broker = new Events(true);

broker.listen('first', callbackFirst);
broker.listen('last', callbackLast);

broker.trigger('first');
broker.trigger('last');

//
// BEFORE first
// CALLBACK first
// AFTER first
// BEFORE last
// AFTER last
// CALLBACK last
//
// Notice how we can trace the event broker
// invocation? Also note that "CALLBACK last"
// is obviously async because it's not in between
// "BEFORE last" and "AFTER last".

事件生命周期

不同的事件触发机制具有不同的事件生命周期,理解每个机制如何工作以及如何控制它们是值得的。我们从查看 DOM 事件开始。我们 UI 中的 DOM 节点形成了一棵树结构,任何一个节点都可以触发一个 DOM 事件。如果这个事件有直接附着在触发节点上的处理函数,它们将被执行。然后,事件将向上传播,重复寻找处理函数的过程,然后继续向上直到达到文档节点。

我们的处理函数实际上可以改变 DOM 事件的默认传播行为。

例如,如果我们不想让 DOM 树中更高层次的处理程序运行,较低层次的处理程序可以阻止事件的传播。

事件生命周期

对比不同框架中的组件事件系统的事件处理方法,以及由浏览器处理的字符串事件(DOM 事件)。

我们需要关注的另一个重要的事件触发机制是我们正在使用的框架。JavaScript 作为一种语言,没有通用的事件触发系统,只有针对 DOM 树、Ajax 调用和 Promise 对象的专用系统。内部这些都是在使用相同的任务队列;它们只是以使它们看起来是独立系统的方式暴露出来。这就是我们正在使用的框架介入并提供必要抽象的地方。这类事件分发器相当简单;给定事件的订阅者按 FIFO 顺序执行。其中一些事件系统支持更高级的生命周期选项,在本节中讨论,如全局事件日志和早期事件终止。

通信开销

直接在组件上调用方法的一个优点是,涉及的开销非常小。当所有组件间的通信都通过事件触发机制来中介时,至少会有一点点开销。实际上,这种间接开销几乎注意不到;是其他开销因素可能导致可扩展性问题。

在这一节中,我们将探讨事件触发频率、回调执行以及回调复杂度。这三个因素都可能使得软件性能下降到无法使用的地步。

事件频率

当我们的软件只有少数几个组件时,事件频率有一个基本限制。事件频率可能迅速变成问题的是当有很多组件,其中一些对事件做出响应。这意味着,如果用户在快速而高效地做某事,或者有多个 Ajax 响应同时到达,我们需要一种防止这些事件阻塞 DOM 的方法。

JavaScript 的一个挑战是它是单线程的。有 web workers,但那些远远超出了本书的范围,因为它们引入了一个全新的架构问题类别。假设用户在一秒内点击了四次某物。在正常情况下,这对我们的事件系统来说不是什么大问题。但是,假设在他们这样做的同时有一个昂贵的 Ajax 响应处理程序正在运行。最终,UI 将变得无响应。

为了避免 UI 变得无响应,我们可以对事件进行节流。这意味着对回调执行的频率加以限制。所以,不是一完成一个就进行下一个,而是完成一个后,休息几毫秒再进行下一个。这样节流的好处是,它给了待处理的 DOM 更新或者待处理的 DOM 事件回调函数运行的机会。缺点是,长运行的更新或其他代码可能会对我们的事件生命周期产生负面影响。

下面是一个示例,展示了事件代理对触发的事件进行节流到特定时间频率的例子:

// events.js
// The event broker. Sets sets the threshold
// for event triggering frequency to 100
// milliseconds.
export default class Events {

    constructor() {
        this.last = null;
        this.threshold = 100;
        this.size = 0;
        this.listeners = {};
    }

    // Triggers the event, but only if the it meets the
    // frequency threshold.
    trigger(name, data) {
        var now = +new Date();

        // If we're passed the wait threshold, or we've never
        // triggered an event, we can call "_trigger()", where
        // the event callback functions are processed.
        if (this.last === null || now - this.last > this.threshold) {
            this._trigger(name, data);
            this.last = now;
        // Otherwise, we've triggered something recently, and we
        // need to set a timeout. The "size" multiplier is
        // for spreading out the lineup of triggers.
        } else {
            this.size ++;
            setTimeout(() => {
                this._trigger(name, data);
                this.size --;
            }, this.threshold * this.size || 1);
        }
    }

    // This is the actual triggering mechanism, called by
    // "trigger()" after it checks the frequency threshold.
    _trigger(name, data) {
        if (name in this.listeners) {
            return this.listeners[name].map(function(callback) {
                return callback(Object.assign({
                    name: name
                }, data));
                return result;
            });
        }
    }

};

//main.js
import Events from 'events.js';

function callback(data) {
    console.log('CALLBACK', new Date().getTime());
}

var broker = new Events(true);

broker.listen('throttled', callback);

var counter = 5;

// Trigger events in a tight loop. This will
// cause the broker to throttle the callback
// processing.
while (counter--) {
    broker.trigger('throttled');
}
//
// CALLBACK 1427840290681
// CALLBACK 1427840290786
// CALLBACK 1427840290886
// CALLBACK 1427840290987
// CALLBACK 1427840291086
//
// Notice how the logged timestamps in each
// callback are spread out?

回调执行时间

虽然事件触发机制在一定程度上可以控制回调函数何时执行,但我们并不一定能控制回调会花费多少时间完成。从事件系统的角度来看,每个回调函数都是一个运行到完成的单线程小黑盒——这是 JavaScript 的单线程特性所决定的。如果一个具有破坏性的回调函数被抛给事件机制,我们如何知道哪个回调出了问题,以便于我们可以诊断和修复它?

解决这个问题有两种技术可以采用。如章节前面所提到的,事件触发机制应该有一个简单的方法来开启全局事件日志。从那里,我们可以推算出任何给定回调运行的时间,假设我们有开始和完成的时间戳。但这并不是强制回调时间的最有效方法。

另一种技术是在给定回调函数开始运行时设置一个超时函数。当超时函数运行,比如说一秒后,它会检查相同的回调是否仍在运行。如果是,它可以明确地抛出一个异常。这样,当回调执行时间过长时,系统会明确地失败。

这种方法还有一个问题——如果回调卡在一个紧密循环中怎么办?我们的监控回调将永远没有机会运行。

回调执行时间

比较执行时间短的回调和执行时间长的回调,后者更新 DOM 或处理排队 DOM 事件的灵活性不大

回调复杂性

当所有其他方法都失败时,我们作为大型 JavaScript 应用程序的架构师,需要确保事件处理器的复杂性处于适当水平。过多的复杂性意味着潜在的性能瓶颈和 UI 冻结——这是不好的用户体验。如果回调函数太细粒度,或者事件本身也是,我们仍然会面临性能问题,因为事件触发机制本身增加了开销——需要处理更多的回调意味着更多的开销。

大多数支持组件间通信的 JavaScript 框架中找到的事件系统的灵活性是一件好事。框架默认会触发它认为重要的事件。这些可以被忽略,而对我们没有可观测的性能损失。然而,它们也允许我们根据需要触发我们自己的事件。所以如果我们发现过了一段时间,我们过度细化了我们的事件,我们可以稍微回退一些。

一旦我们掌握了应用程序中合适的事件粒度,我们可以调整我们的回调函数以反映这一点。我们甚至可以开始以这样的方式编写我们的小回调函数,使它们可以用来组合提供更粗粒度功能的高级函数。

以下是一个显示触发其他事件的事件回调函数以及监听这些事件的更专注的函数的示例:

import Events from 'events.js';

// These callbacks trigger "logic" events. This
// small indirection keeps our logic decoupled
// from event handlers that might have to perform
// other boilerplate activities.
function callbackFirst(data) {
    data.broker.trigger('logic', {
        value: 'from first callback'
    });
}

function callbackSecond(data) {
    data.broker.trigger('logic', {
        value: 'from second callback'
    });
}

var broker = new Events();

broker.listen('click', callbackFirst);
broker.listen('click', callbackSecond);

// The "logic" callback is small, and focused. It
// doesn't have to worry about things like DOM
// access or fetching network resources.
broker.listen('logic', (data) => {
    console.log(data.name, data.value);
});

broker.trigger('click');
//
// logic from first callback
// logic from second callback

通信责任区域

当我们思考 JavaScript 组件通信时,查看外部世界以及我们的应用程序与之接触的边缘是有帮助的。到目前为止,我们主要关注的是组件间的通信——我们的组件是如何与同一 JavaScript 应用程序中的其他组件进行交流的?组件间的通信并不会自发产生,也不会就此结束。可扩展的 JavaScript 代码需要考虑流入和流出应用程序的事件。

后端 API

明显的起点是后端 API,因为它定义了我们应用程序的领域。前端实际上只是 API 最终真相的伪装。当然,它不仅仅是那样,但 API 数据最终确实限制了我们应用程序可以和不可以做的事情。

在组件和责任方面,思考哪些组件负责与后端直接通信是有帮助的。当应用程序需要数据时,这些组件将启动 API 对话,获取数据,并在到达时让我知道,这样我就可以将其转交给另一个组件。实际上,与直接与 API 通信的组件相关的组件间通信还是相当多的。

例如,假设我们有一个集合组件,为了填充它,我们必须调用一个方法。集合知道它需要填充自己,或者为自己创建吗?更有可能是其他组件启动了集合的创建,然后要求它从 API 中获取一些数据。虽然我们知道这个发起组件不会直接与 API 交谈,但我们还知道它在通信中扮演着重要的角色。

当扩展到许多组件时,考虑这一点很重要,因为它们都应该遵循一个可预测的模式。

后端 API

前端的事件经纪人,直接或间接地将 API 响应及其数据转换为组件可以订阅的事件

WebSocket 更新

WebSocket 连接在 Web 应用程序中消除了长轮询的需要。现在它们被更频繁地使用,因为浏览器对这项技术有强烈的支持。为后端服务器支持 WebSocket 连接也有很多库。具有挑战性的部分是账本记录,它允许我们检测到变化并通过发送消息通知相关会话。

抛开后端复杂性,WebSocket 确实在前端解决了很多软实时更新问题。WebSocket 是与后端的双向通信通道,但它们真正闪耀的地方在于接收更新,即某个模型改变了状态。

这允许我们的任何组件在数据来自此模型时重新渲染自己。

挑战的部分是,在任何给定的前端会话中,我们只允许有一个 WebSocket 连接。这意味着我们的处理程序函数需要弄清楚如何处理这些消息。您可能还记得,在章节开头,当我们讨论事件数据,以及事件名称的意义和它们数据结构的软实时更新时。WebSocket 消息事件是为什么这很重要的一个好例子。我们需要弄清楚如何处理它,我们收到的 WebSocket 消息类型会有很多变化。

注意

由于 WebSocket 连接是状态的,它们可能会断开。这意味着我们将不得不面对实现重新连接断开 Socket 连接的额外挑战。

让一个回调函数处理所有这些 WebSocket 消息的处理,甚至到 DOM,这是一个糟糕的主意。一种方法可能是拥有多个处理程序,每个处理程序针对每种特定的 WebSocket 更新类型。这将很快变得无法控制,因为会有很多回调函数需要运行,从责任上讲,很多组件将不得不与 WebSocket 连接紧密耦合。

如果组件不在乎更新数据是否来自 WebSocket 连接呢?它关心的只是数据发生了变化。也许我们需要为关心数据变化的组件引入一种新类型的事件。然后,我们的 WebSocket 处理程序只需要将消息转换为这些类型的事件。这是一种可扩展的 WebSocket 通信方法,因为我们可以完全移除 WebSocket,而实际上不会影响系统的很多部分。

WebSocket 更新

事件将一种 WebSocket 消息转换为实体特定的事件,因此只有感兴趣的组件需要订阅

更新 DOM

我们的组件需要与 DOM 交互。这是不言而喻的——它是在浏览器中运行的 Web 应用程序。认真考虑一下与 DOM 交互的组件和那些不交互的组件是值得的。这些通常是视图组件,因为它们将我们应用程序的数据转换为用户可以在他们的浏览器窗口中查看的内容。

这些类型的组件实际上更难以扩展,主要是因为它们事件流的双向性质。增加这一挑战的是,当对一些新代码应该放在哪里有疑问时,通常会放在视图中。然后,当我们的视图过载时,我们开始在控制器或工具中放置代码,谁知道还会放在哪里。必须有更好的方法。

让我们花一分钟考虑视图事件通信。首先,有传入事件。这些事件告诉视图我们的数据发生了什么,它应该更新 DOM。顺从地,它就这样做了。这种方法实际上非常可靠,当视图监听一个组件的事件时,它工作得很好。随着我们的应用程序扩展以适应更多功能和改进,我们的视图必须开始自己找出答案。当视图更愚蠢时,它们会工作得更好。

例如,最初负责在数据事件响应中渲染一个元素的视图现在必须做更多的事情。在完成这件事之后,它需要计算一些派生值,并更新另一个元素。这样使视图“更智能”的过程逐渐失控,直到我们无法再扩展。

从通信的角度来看,我们希望将视图视为数据与 DOM 的简单一对一绑定。如果这个原则从未被违反,那么当数据发生变化时,我们更容易预测会发生什么,因为我们知道哪些视图会监听这些数据,以及它们绑定的 DOM 元素。

现在让我们来看一下另一个方向上的绑定——监听 DOM 的变化。在这里,挑战再次出现,我们倾向于使我们的视图变得智能。当输入数据出现问题时,我们会在 DOM 事件触发的视图事件处理程序中加载本应在其他地方完成的责任。当视图更愚蠢时,它们会工作得更好。它们应该将 DOM 事件转换为任何其他组件都可以监听的应用特定事件,就像我们对待 WebSocket 消息事件一样。那些实际启动某些业务流程的“更智能”的组件并不关心动作的原因是否来自 DOM。这有助于我们通过创建更少的通用组件来扩展,这些组件实际上并不做太多的事情。

松耦合通信

当组件间的通信耦合度较低时,在遇到需要扩展的影响因素时,我们可以更容易地进行适应。首先,一个良好的事件驱动的组件间通信设计使我们能够移动组件。我们可以移除一个有故障或表现不佳的组件,并将其替换为另一个。不能这样替换组件意味着我们将不得不在原地修复组件;这对于软件交付来说风险更大,从开发角度来看,这也是一个扩展瓶颈。

松耦合的组件间通信的另一个好处是,当出错时,我们可以隔离有问题的组件。我们可以防止一个组件中发生的异常影响到其他组件的状态,当用户尝试做其他事情时,这会导致更多的问题。像这样隔离问题有助于我们扩展响应以修复有问题的组件。

替换组件

根据给定组件触发和响应的事件,我们应该能够轻松地将组件替换为不同版本。我们仍然需要弄清楚组件的内部工作原理,因为很可能我们并不想完全改变它。但这是更容易的部分——实现组件的难点在于将它们相互连接。可扩展的组件实现意味着使这种连接尽可能易于接近和一致。

那么,组件可替代性为什么如此重要呢?我们会认为,由几个相互连接的组件组成的稳定代码不需要频繁更改,如果需要更改的话。从这种观点来看,当然可替代性被降低了——如果你不使用它,为什么还要担心呢?这种思维方式的问题在于,如果我们认真对待扩展 JavaScript 代码的规模,我们不能对一些组件应用原则而忽视其他组件。

实际上,对稳定代码重构的抵触并不一定是一件好事。例如,如果我们有一些新想法,这些想法可能需要我们对稳定组件进行重构,那么这种抵触实际上可能会阻碍我们。我们所有组件之间的可替代性为我们带来的好处是在实施新想法时具有可扩展性。如果通过替换稳定组件并引入新实现来实验很容易,那么我们更有可能将改进的设计理念融入到产品中。

替换组件不仅仅是设计时的活动。我们可以引入可变性,其中将有许多可能填充空白组件的可能性,然后在运行时选择正确的组件。这种灵活性意味着我们可以轻松扩展功能,以考虑规模影响因素,例如新的用户角色。

一些角色获得一个组件,其他角色获得一个不同但兼容的组件,或者根本不获得组件。关键是要支持这种灵活性。

替换组件

只要组件遵循相同的通信协议,通常是通过事件触发和处理,开发实验性技术就会更容易。

处理意外事件

松耦合的组件有助于我们扩展处理有缺陷组件的能力,主要因为当我们能够将问题根源隔离到单个组件时,我们可以快速定位问题并修复它。此外,在有缺陷的组件在生产环境中运行的情况下,在我们实施并交付修复方案时,我们可以限制负面影响的范围。

缺陷是会发生的——我们需要接受这一点并为此设计。当缺陷发生时,我们希望从中学习,以便将来不再重复。由于我们的时间表很紧,需要尽早和频繁地发布,因此缺陷可能会遗漏。这些都是我们未测试过的边缘情况,或者是单元测试中遗漏的独特编程错误。无论如何,我们需要设计我们的组件故障模式以考虑这些情况。

隔离有缺陷的组件的一种方法可能是将任何事件回调函数包裹在 try/catch 中。如果发生任何意外异常,我们的回调只需通知事件系统有关组件处于错误状态。这给了其他处理程序一个恢复它们状态的机会。如果在事件回调管道中有故障组件,我们可以安全地向用户显示一个关于特定操作无法工作的错误。由于其他组件都处于良好的状态,得益于不良组件的通知,用户可以安全地使用其他功能。

下面是一个显示能够捕获回调函数错误的事件经纪人的示例:

// events.js
export default class Events {

    constructor() {
        this.listeners = {};
    }

    // Triggers an event...
    trigger(name, data) {
        if (!(name in this.listeners)) {
            return;
        }

        // We need this to keep track of the error state.
        var error = false,
            mapped;

        mapped = this.listeners[name].map((callback) => {
            // If the previous callback caused an error,
            // we don't run any more callbacks. The values
            // in the mapped output will be "undefined".
            if (error) {
                return;
            }

            var result;

            // Catch any exceptions thrown by the callback function,
            // and the result object sets "error" to true.
            try {
                result = callback(Object.assign({
                    name: name,
                    broker: this
                }, data));
            } catch (err) {
                result = { error: true };
            }

            // The callbacks can throw an exception, or just return
            // an object with the "error" property set to true. The
            // outcome is the same - we stop processing callbacks.
            if (result && result.error) {
                error = true;
            }

            return result;
        });

        // Something went wrong, so we let other components know
        // by triggering an error variant of the event.
        if (error) {
            this.trigger('${name}:error');
        }
    }

}

// main.js
import Events from 'events.js';

// Callback fails by returning an error object.
function callbackError(data) {
    console.log('callback:', 'going to return an error');
    return { error: true };
}

// Callback fails by throwing an exception.
function callbackException(data) {
    console.log('callback:', 'going to raise an exception');
    throw Error;
}

var broker = new Events();

// Listens to both the regular events (the happy path),
// and the error variants.
broker.listen('shouldFail', callbackError);
broker.listen('shouldFail:error', () => {
    console.error('error returned from callback');
});

broker.listen('shouldThrow', callbackException);
broker.listen('shouldThrow:error', () => {
    console.error('exception thrown from callback');
});

broker.trigger('shouldFail');
broker.trigger('shouldThrow');
// callback: going to return an error
// error returned from callback
// callback: going to raise an exception
// exception thrown from callback

组件层

在任何足够大的 JavaScript 应用程序中,都存在一个门槛,即通信组件的数量呈现出扩展问题。主要瓶颈是我们创造的复杂性以及我们理解复杂性的能力。为了对抗这种复杂性,我们可以引入层次。这些是帮助我们视觉上理解运行时发生情况的抽象分类概念。

事件流方向

当我们用层次设计时,首先揭示我们代码的是关于事件流方向组件间通信的复杂性。例如,假设我们的应用程序有三个层次。顶层关注路由和其他进入 UI 的入口点。中间层有数据和业务逻辑分散其中。底层是我们的视图所在。这些层中有多少组件并不重要;虽然这是一个因素,但它是次要的。从这种观点来看重要的是穿越其他层的箭头类型。

例如,考虑到上述的三层架构,我们可能会注意到最直接的层连接是在路由器和数据/业务逻辑层之间。这是因为事件流主要是单向的:从上到下,从路由器到其下方的层。从那里开始,模型和控制器组件之间可能有一些通信,但最终事件流仍然向下移动。

在数据/逻辑层和视图层之间,通信箭头开始看起来双向且令人困惑。这是因为代码中的事件流也是双向且令人困惑的。这不是可扩展的,因为我们不能轻易地掌握我们触发的事件的效应。使用分层设计方法的好处是找出一种消除双向事件流的方法。这可能意味着引入一个间接层,负责在源和目标之间调解事件。

如果我们巧妙地这样做,额外的移动部件会给我们的层次图带来清晰而不是杂乱,性能影响可以忽略不计。

事件流方向

组件层之间可识别的事件流向对可扩展性有巨大的影响

映射到开发者职责

层次结构是一种辅助工具,而不是正式架构规范的产物。这意味着我们可以将它们用于可能有助于我们的任何事情。不同的人群可能会有他们自己的层次结构,用于理解复杂性的目的。然而,如果整个开发团队遵循相同的层次结构,并且它们被保持得非常简单,那么这将更有用。超过四个或五个层次就失去了使用它们的初衷。

开发者可以使用层次结构作为自我组织的手段。他们理解架构,并且有即将到来的迭代的任务。比如说我们有两个开发者正在处理同一个功能。他们可以使用我们组件架构的层次结构来规划他们的实现,并避免相互干扰。当有一个更大的参考点,比如一个层次结构时,事情就会无缝地汇集在一起。

在心中绘制代码地图

即使没有图表,只要知道我们正在查看的组件代码属于特定的层次,就能帮助我们心中绘制出它在做什么以及它对系统其他部分的影响。知道我们正在工作的层次在我们编码时会给我们一个潜意识上下文——我们知道哪些组件是我们的邻居,以及当我们的事件跨越层次边界时会发生什么。

在层次结构的背景下,新组件与现有组件相比,在设计问题上会有明显的不足,以及它们层与层之间的通信模式。这些层次的存在,以及它们被所有开发者频繁作为非正式辅助工具的事实,可能足以在早期阶段消除设计问题。或者也许根本就没有问题,但是层次结构足以促进对设计进行讨论。团队中的一些人可能会学到一些东西,而另一些人可能会确信设计是坚固的。

总结

我们 JavaScript 应用程序的构建块是组件。将它们粘合在一起的是所使用的通信模型。在底层,组件间的通信包括一个组件通过某种中介机制向另一个组件传递消息。这通常被抽象和简化为事件系统。

我们审视了实际从一个组件传递到下一个组件的事件数据的形式。这些数据需要保持一致性、可预测性和意义性。我们还探讨了可追踪事件。也就是说,我们能否从事件触发机制中全局记录事件?

在我们的 JavaScript 代码边界,是通信的端点。我们审视了具有与外部系统通信职责的各种组件,比如 DOM、Ajax 调用或本地存储。我们需要将我们智能组件与系统边缘隔离开来。

可替代性和层次结构是扩展的关键概念。通过快速开发新代码并降低风险来帮助我们扩展,层次结构在许多方面都有所帮助,通过保持更广阔的视野来触手可及。在层次结构中,错误的设计假设更早地被揭露。

现在是我们思考如何扩大应用的可达性的时候了,我们将会看到前两章的教训在那里是否有任何价值。

第五章:可寻址性与导航

运行在网络上的应用程序依赖于可寻址资源。URI 是至关重要的互联网技术,它消除了一类复杂性,因为我们可以把关于资源的信息编码到 URI 字符串中。这是策略部分。机制部分则由浏览器或我们的 JavaScript 代码来完成——查找请求的资源并显示它。

过去,处理 URI 是在后端进行的。当用户传递一个 URI 给浏览器时,浏览器的责任是将这个请求发送到后端并显示响应。随着大规模 JavaScript 应用程序的出现,这一责任主要转移到了前端。我们有了在浏览器中实现复杂路由的工具,有了这些工具,对后端技术的依赖就减少了。

然而,前端路由的好处确实是有代价的,一旦我们的软件增加了功能。本章深入探讨了在我们应用程序架构成长和成熟过程中可能遇到的路由场景。大多数路由器组件的底层实现细节并不重要。我们更关心的是我们的路由器组件如何适应规模影响因素。

路由方法

在 JavaScript 中有两种路由方法。第一种是使用基于哈希的 URI。这些 URI 以#字符开头,这是更受欢迎的方法。另一种不太受欢迎的方法是使用浏览器的 history API 生成更传统的 URI,网民们已经习惯了这种 URI。这种技术更加复杂,直到最近才获得足够的浏览器支持,使其变得可行。

哈希 URI

URI 中哈希部分最初的意图是指向文档的特定位置。所以浏览器会查看#字符左侧的所有信息,并将这些信息发送到后端,请求一些页面内容。只有在页面到达并渲染后,#字符右侧才变得相关。这时,浏览器使用 URI 的哈希部分在页面上找到本地相关位置。

如今,URI 中的哈希部分被用于不同的场景。它依旧用于 URI 变更时避免向后端传递无关数据。主要区别在于,现今我们处理的是应用程序和功能,而非网站和静态内容。由于在地址变更时大部分应用程序已经加载到浏览器中,向后端发送不必要的请求是没有意义的。我们只想要对新 URI 必要的数据,这通常通过后台的 API 请求来实现。

当我们谈论在 JavaScript 应用程序中使用哈希方法来改变 URI 时,通常只是哈希部分发生变化。这意味着相关浏览器事件将会触发,通知我们的代码 URI 已经改变。但它不会自动向后端发出请求以获取新页面内容,这是关键。我们实际上可以通过这种方式的前端路由获得很多性能和效率上的提升,这也是我们使用这种方法的原因之一。

它不仅效果良好,而且实施起来也很简单。实现一个哈希变更事件监听器,以执行逻辑来获取相关数据,然后用相关内容更新页面,并没有很多复杂的部件。此外,浏览器历史的变更我们自己也会自动处理。

传统的 URI

对于某些用户和开发者来说,哈希方法看起来就像是黑客技术。更不用说在公共互联网环境中呈现的 SEO 挑战了。他们更喜欢更传统的用斜杠分隔的资源名格式的外观和感觉。现在在所有现代浏览器中,由于对历史 API 的增强,这通常是可能实现的。本质上,路由机制可以监听历史堆栈上推入的状态,当发生这种情况时,它防止请求发送到后端,而是本地处理它。

显然,这种方法需要更多的代码才能工作,也需要考虑更多的边缘情况。例如,后端需要支持前端路由器所支持的所有 URI,因为用户可以将任何有效的 URI 输入到应用程序中。处理这种情况的一种技术是在服务器上使用重写规则,将 404 错误重定向回应用程序的索引页面,我们的真实路由处理就位于那里。

话说回来,大多数 JavaScript 应用框架中找到的路由组件抽象了方法上的差异,并提供了一种无缝地朝一个方向或另一个方向过渡的手段。是使用哪一个更重要,是为了增强功能还是提高可扩展性?实际上并不重要。但在可扩展性方面,重要的是要认识到实际上有两种主要方法,我们不想完全承诺于其中之一。

路由器是如何工作的

现在是我们深入研究路由器的时候了。我们想了解路由器的职责以及当 URI 发生变化时它的生命周期是什么样的。本质上,这相当于路由器取新的 URI 并判断它是否是路由器感兴趣的东西。如果是,那么它就会用解析后的 URI 数据作为参数触发适当的路线事件。

理解路由器在底层的角色对于扩展我们的应用程序很重要,因为我们有越多的 URI 和响应这些路由事件的组件,就有越多的扩展问题潜力。当我们知道路由器生命周期正在发生什么时,我们可以针对扩展影响因素做出适当的扩展权衡。

路由器职责

路由器的简化观点只是一个映射—有路由,字符串或正则表达式模式定义,它们映射到回调函数。重要的是这个过程快速、可预测且稳定。尤其是在我们应用程序中的 URI 数量增长时,正确实现这一过程具有挑战性。以下是任何路由组件需要处理的任何路由的大致概述:

  • 存储路由模式与其相应事件名称的映射

  • 监听 URI 变化事件—哈希变化弹出状态

  • 执行路由模式查找,将新的 URI 与每个映射的模式进行比较

  • 当找到匹配项时,根据模式解析新的 URI

  • 触发映射的路由事件,传递任何解析的数据

    注意

    路由查找过程涉及在路由映射中进行线性搜索以找到匹配项。当定义了大量的路由时,这意味着性能显著下降。当路由映射是一个对象数组时,它也可能导致路由性能不一致。例如,如果一个路由位于数组的末尾,这意味着它最后被检查并且执行缓慢。如果它位于数组的开头,执行效果会更好。

    为了避免频繁访问的 URI 的性能下降,我们可以扩展路由器,使其根据优先级属性对路由映射数组进行排序。另一种方法涉及使用字典结构,以避免线性查找。当然,只有当路由器性能如此差,以至于可以测量出性能下降时,才考虑这样的优化。

当 URI 发生变化时,路由器要做很多事情,这就是理解给定路由的生命周期很重要的原因,从地址栏中 URI 发生变化开始,到完成所有的事件处理函数。从性能角度来看,大量的路由可能会对我们的应用程序产生负面影响。从组成角度来看,跟踪哪些组件创建和响应哪些路由是具有挑战性的。当我们知道任何给定路由的生命周期看起来是什么样的时,处理起来会稍微容易一些。

路由器事件

一旦路由器为改变后的 URI 找到了匹配项,并且一旦根据其匹配模式解析了 URI,它的最后工作就是触发路由事件。触发的事件是映射的一部分。URI 可能编码了变量,这些变量被解析并通过每个路由事件处理程序传递数据。

路由器事件

路由事件提供了一个抽象层,这意味着非路由器组件可以触发路由事件

大多数框架附带可以直接在路由变化时调用函数的路由组件,而不是触发一个路由事件。实际上,这更简单,是一种更直接的方法,适合小型应用程序。通过路由器触发事件机制间接获得的间接性是我们组件与路由器之间松耦合的原因。

这是有益的,因为不同组件之间如果没有相互了解,它们可以监听同一个路由事件。随着我们扩大规模,之前已经设立的同一路由将需要承担新的责任,而添加新处理程序比不断构建相同的函数代码要容易。还有抽象的好处——监听路由事件的组件不知道事件实际上是由路由实例触发的。当需要组件触发类似路由的行为,而不实际依赖路由时,这个特性很有用。

uri 部分和模式

在大型 JavaScript 应用程序中,路由组件需要经过深思熟虑。我们还需要对 URI 本身进行深思熟虑。它们由什么组成?在整个应用程序中它们是否一致?什么是一个糟糕的 URI?在这些考虑上走向错误的方向会使我们难以扩展应用程序的可寻址性。

编码信息

URI 的作用在于,客户端可以将它传递给我们的应用程序,并且它包含了足够的信息,可以据此进行有用的操作。最简单的 URI 只是指向一种资源类型,或者是一个应用内的静态位置——/users/home 是这类 URI 的 respective 例子。利用这些信息,我们的路由器可以触发一个路由事件,并触发一个回调函数。这些回调甚至不需要任何参数——它们知道要做什么,因为不存在变异性。

另一方面,路由回调函数可能需要一些上下文。这时在 URI 中编码信息就变得很重要。最常见的用途是当客户端要求某个资源的具体实例时,使用唯一标识符。例如,users/31729。在这里,路由需要找到与这个字符串匹配的模式,并且该模式还将指定如何提取 31729 变量。然后将其传递给回调函数,现在回调函数有足够的信息来执行其任务。

URI 可能会变得很大且复杂,如果我们试图在它们中编码很多信息。这个例子就是编码显示资源网格的页面的查询参数。尝试在路由模式中指定所有可能性是困难且容易出错的。肯定会有变化,以及关于变量组合使用的不预期的边缘情况。其中一些可能是可选的。

当一个给定的 URI 有如此多的潜在复杂性时,最好将编码选项保持在传递给路由器的 URI 模式之外。相反,让回调函数查看 URI 并进一步解析以确定上下文。这样可以保持路由规格整洁,将奇异的复杂处理程序与其他一切隔离。

对于常见的查询,我们可能希望为用户提供一个简单的 URI,尤其是如果它以链接的形式呈现。例如,最近的帖子链接到/posts/recent。这个 URI 的处理程序需要确定一些事情,否则这些事情需要编码在 URI 中——比如排序和要获取的资源数量。有时这些事情不需要包含在 URI 中,而这些决策对用户体验和代码的可扩展性都有好处。

设计 URI

资源名是我们创建 URI 的好灵感。如果 URI 链接到一个显示事件的页面,它可能应该以events开始。然而,有时后端暴露的资源名并不直观。或者,作为一个组织或行业,我们喜欢缩写某些术语。这些也应该避免,除非应用程序的上下文提供了意义。

反过来说也是正确的——URI 中包含太多意义实际上可能会导致混淆,如果它太冗长。这可以从这个单词的角度看过于冗长,或者从 URI 组件的数量的角度看过于冗长。为了帮助传达结构,并使人类眼睛更容易解析,通常会将 URI 分解为部分。例如,事物类型,后面是事物标识符。实际上,对于用户来说,将分类或其他辅助信息编码在 URI 中并不真正有帮助——尽管它当然可以在 UI 中显示。

我们能够做到的地方,我们应该保持一致。如果我们限制资源名的字符数,它们都应该遵循相同的限制。如果我们使用斜杠来分隔 URI 部分,到处都应该这样做。这个想法的整个出发点是,当有很多 URI 时,它能够很好地扩展,因为用户最终可以猜测出某个东西的 URI,而不必点击链接。

在保持一致性的同时,我们有时希望某些类型的 URI 能够突出显示。例如,当我们访问一个将资源置于不同状态的页面,或需要用户输入的页面时,我们应该用不同的符号前缀动作。假设我们正在编辑一个任务——URI 可能是/tasks/131:edit。在我们应用程序的各个地方保持一致性,用斜杠分隔 URI 组件。所以我们本可以做成类似/tasks/131/edit。然而,这会让它看起来像一个不同的资源,而实际上,它和tasks/131是同一个资源。只是现在,UI 控件处于不同的状态。

下面是一个显示用于测试路由的正则表达式的例子:

// Wildcards are used to match against parameters in URIs...
console.log('second', (/^user\/(.*)/i).exec('user/123'));
//    [ 'user/123', '123' ]

// Matches against the same URI, only more restrictively...
console.log('third', (/^user\/(\d+)/i).exec('user/123'));
//    [ 'user/123', '123' ]

// Doesn't match, looking for characters and we got numbers...
console.log('fourth', (/^user\/([a-z])/i).test('user/123'));
//    false

// Matches, we got a range of characters...
console.log('fifth', (/^user\/([a-z]+)/i).exec('user/abc'));
//    [ 'user/abc', 'abc' ]

将资源映射到 URI

是时候看看 URI 在实际应用中的样子了。我们最常发现 URI 的形式,就是在我们应用程序中的链接。至少,这是理念所在——拥有一个良好互联的应用程序。虽然路由器理解如何处理 URI,但我们还需要查看所有这些链接需要生成并插入 DOM 中的地方。

生成链接有两种方法。第一种是一种相对手动的过程,需要模板引擎和实用函数的帮助。第二种尝试自动化,以扩展许多 URI 的可管理性。

手动构建 URI

如果一个组件在 DOM 中渲染内容,它可能会构建 URI 字符串并将它们添加到链接元素中。当只有少数页面和 URI 时,这样做是足够简单的。这里的扩展问题在于,JavaScript 应用程序中的页面计数和 URI 计数是互补的——大量的 URI 意味着大量的页面,反之亦然。

我们可以使用路由模式映射配置,该结构指定 URI 的外观以及它们被激活时会发生什么,作为实现视图时的参考。借助大多数框架以一种形式或另一种形式使用的模板引擎,我们可以使用模板特性来动态渲染所需的链接。或者,如果缺乏模板复杂性,我们需要一个独立的实用程序来为我们生成这些 URI 字符串。

当有很多 URI 需要链接,有很多模板时,这变得具有挑战性。模板语法为我们提供了一些帮助,使得构建这些链接稍微不那么痛苦。但这仍然耗时且容易出错。此外,我们将开始看到模板内容的重复,感谢我们在模板中构建链接的静态性质。我们至少需要硬编码,在模板中链接到的资源类型。

自动化资源 URI

我们链接的大部分资源都是来自 API 的实际资源,并在我们的代码中由模型或集合表示。既然如此,如果我们不是利用模板工具为这些资源构建 URI,而是可以在每个模型或集合上使用相同的函数来构建 URI,那会很好。这样,因为我们只关心抽象的uri()函数,所以与构建 URI 相关的模板中的任何重复都会消失。

这种方法虽然简化了模板,但引入了与路由器同步模型的挑战。例如,模型生成的 URI 字符串需要与路由器期望看到的模式匹配。所以要么,实现者需要足够自律,以保持模型生成的 URI 与路由器同步,要么模型需要基于模式来生成 URI 字符串。

如果路由器使用某种简化的正则表达式语法来构建 URI 模式,那么可以通过路由定义自动同步模型中实现的uri()函数。那里的挑战是模型需要了解路由器——这可能会导致依赖性规模问题——我们有时希望使用模型,而不一定是路由器。如果我们的模型存储了与路由器注册的 URI 模式呢?然后它可以使用这个模式来生成 URI 字符串,而且它仍然只在一个地方更改。另一个组件然后将模式注册到路由器,所以没有与模型的紧密耦合。

以下是一个示例,展示了如何将 URI 字符串封装在模型中,远离其他组件:

// router.js
import events from 'events.js';

// The router is also an event broker...
export default class Router {

    constructor() {
        this.routes = [];
    }

    // Adds a given "pattern" and triggers event "name"
    // when activated.
    add(pattern, name) {
        this.routes.push({
            pattern: new RegExp('^' +
                pattern.replace(/:\w+/g, '(.*)')),
            name: name
        });
    }

    // Adds any configured routes, and starts listening
    // for navigation events.
    start() {
        var onHashChange = () => {
            for (let route of this.routes) {
                let result = route.pattern.exec(
                    location.hash.substr(1));
                if (result) {
                    events.trigger('route:' + route.name, {
                        values: result.splice(1)
                    });
                    break;
                }
            }
        };

        window.addEventListener('hashchange', onHashChange);
        onHashChange();
    }

}

// model.js
export default class Model {

    constructor(pattern, id) {
        this.pattern = pattern;
        this.id = id;
    }

    // Generates the URI string for this model. The pattern is
    // passed in as a constructor argument. This means that code
    // that needs to generate URI strings, like DOM manipulation
    // code, can just ask the model for the URI.
    get uri() {
        return '#' + this.pattern.replace(/:\w+/, this.id);
    }

}

// user.js
import Model from 'model.js';

export default class User extends Model {

    // The URI pattern for instances of this model is
    // encapsulated in this static method.
    static pattern() {
        return 'user/:id';
    }

    constructor(id) {
        super(User.pattern(), id);
    }

}

// group.js
import Model from 'model.js';

export default class Group extends Model {

    // The "pattern()" method is static because
    // all instances of "Group" models will use the
    // same route pattern.
    static pattern() {
        return 'group/:id';
    }

    constructor(id) {
        super(Group.pattern(), id);
    }

}

// main.js
import Router from 'router.js';
import events from 'events.js';
import User from 'user.js';
import Group from 'group.js';

var router = new Router()

// Add routes using the "pattern()" static method. There's
// no need to hard-code any routes here.
router.add(User.pattern(), 'user');
router.add(Group.pattern(), 'group');

// Setup functions that respond to routes...
events.listen('route:user', (data) => {
    console.log(`User ${data.values[0]} activated`);
});

events.listen('route:group', (data) => {
    console.log(`Group ${data.values[0]} activated`);
});

// Construct new models, and user their "uri" property
// in the DOM. Again, nothing related to routing patterns
// need to be hard-coded here.
var user = new User(1);
document.querySelector('.user').href = user.uri;

var group = new Group(1);
document.querySelector('.group').href = group.uri;

router.start();

触发路由

最常见的路由触发形式是用户在我们的应用程序中点击一个链接。如前一部分所述,我们需要让我们的链接生成机制能够处理许多页面和许多 URI。这种规模影响因素的另一个维度是实际的触发动作本身。例如,对于较小的应用程序,显然链接会较少。这也意味着用户点击事件较少——更多的导航选择意味着更高的事件触发频率。

考虑较少为人所知的导航参与者也很重要。这些包括在某些后端任务完成后重定向用户,或者只是一个直接的绕道,从点 A 到点 B。

用户操作

当用户在我们的应用程序中点击一个链接时,浏览器会捕捉到这一动作并更改 URI。这包括进入我们应用程序的入口点——可能来自另一个网站或书签。正是这种灵活性使得链接和 URI 能够来自任何地方并指向任何事物。在我们能够利用链接的地方是有意义的,因为这意味着我们的应用程序连接良好,而处理 URI 更改是路由器擅长并且能够轻松处理的事情。

但是还有其他触发 URI 更改和随后路由工作流程的方法。例如,假设我们正在一个create事件表单上。我们提交表单,响应回来后成功——我们想让用户留在create事件页面吗?还是想带他们到显示事件列表的页面,这样他们就可以看到他们刚刚添加的事件?在后一种情况下,手动更改 URI 是有意义的,而且实现起来非常简单。

用户操作

我们的应用程序可以改变地址栏的不同方式

重定向用户

在 API 响应成功后重定向用户到一个新的路由是手动触发路由的一个好例子。还有其他几个场景,我们希望能够将用户从他们当前的位置重定向到一个与他们正在执行的活动相符的新页面,或者确保他们只是在观察正确的信息。

并非所有的重处理都需要在后端进行——我们可能会面临一个本地的 JavaScript 组件运行一个进程,完成后,我们想将用户带到我们应用中的另一个页面。

这里的关键思想是效果比原因更重要——我们并不太关心是什么原因导致了 URI 的变化。真正重要的是能够以意想不到的方式使用路由器。随着我们的应用程序扩展,我们通常会面临通过快速简单的路由器黑客手段来解决问题的场景。能够完全控制我们应用程序的导航,让我们对应用程序的扩展方式有了更多的控制权。

Router 配置

我们的路由与它们的事件映射通常比路由实现本身要大。这是因为随着我们的应用程序增长并拥有更多的路由模式,可能性列表会变得更大。很多时候,这是应用程序满足其扩展需求不可避免的后果。关键是不要让大量的路由声明因自身重量而崩溃,而这可以通过多种方式发生。

配置给定路由器实例响应的路线有不止一种方法。根据我们使用的框架,路由器组件在配置上可能比其他组件有更多的灵活性。一般来说,有静态路由方法,或者事件注册方法。我们还想要考虑路由器随时禁用给定路线的能力。

静态路由声明

简单的应用程序通常使用静态声明配置它们的路由器。这意味着在路由创建时,将路由模式映射到回调函数。这种方法的好处是所有路由模式的相对局部性。一眼就能看出我们的路由配置情况,我们不需要去寻找特定的路由。然而,当有大量路由时,这种方法行不通,因为我们必须去搜索它们。此外,这种方法没有关注点的分离,这不利于开发者独立于彼此尝试做他们的事情。

注册事件

当有大量路由需要定义时,应该关注封装的路由——哪些组件需要这些路由,它们是如何告诉路由器的?嗯,大多数路由器会允许我们调用一个让我们添加新路由配置的方法。然后我们只需要包含路由器并从组件中添加路由。

这绝对是正确的方向;它允许我们将路由声明保留在需要它们的组件中,而不是将整个应用程序的路由配置组合成一个对象。然而,我们可以进一步扩展这种可扩展性。

与其让我们的组件直接依赖路由实例,不如触发一个添加路由事件?这将被任何监听该事件的 router 所接收。也许我们的应用程序正在使用多个 router 实例,每个实例都有自己的专业化功能——比如日志记录——它们都可以基于特定条件监听添加的路由。关键是,我们的组件不应该关心路由实例,只需要知道当某个模式与 URI 变化匹配时,会触发路由事件。

注册事件

如何通过使用事件使组件与路由器隔离

禁用路由

在我们配置好一个给定的路由之后,我们是否假设它在会话期间始终是一个有效的路由?或者,路由器是否应该有一种方法来禁用一个给定的路由?这取决于我们从责任角度如何看待具体案例。

例如,如果发生了某些事情,且某个路径不再可访问——尝试它只会得到一个用户友好的错误——路由处理函数可以检查该路径是否可访问。然而,这增加了回调函数本身的复杂性,这种复杂性将散布在应用程序的回调中,而不是集中在某一个地方。

另一种方法可能是有一个检查组件,当组件进入需要这样做的状态时,该组件会禁用路由。当状态变为路由可以处理的内容时,该组件也会启用路由。

第三种方法是在路由首次注册时添加一个守卫函数作为选项。当路由匹配时,它会运行这个函数,如果守卫通过,则正常激活,否则失败。这种方法最适合扩展,因为检查的状态与相关路由紧密耦合,无需为路由启用/禁用状态。将守卫函数视为路由匹配条件的一部分。

下面是一个示例,展示了接受守卫条件函数的路由器。如果存在这个守卫函数并且返回false,则不会触发路由事件:

// router.js
import events from 'events.js';

// The router triggers events in response to
// route changes.
export default class Router {

    constructor() {
        this.routes = [];
    }

    // Adds a new route, with an optional
    // guard function.
    add(pattern, name, guard) {
        this.routes.push({
            pattern: new RegExp('^' +
                pattern.replace(/:\w+/g, '(.*)')),
            name: name,
            guard: guard
        });
    }

    start() {
        var onHashChange = () => {
            for (let route of this.routes) {
                let guard = route.guard;
                let result = route.pattern.exec(
                    location.hash.substr(1));

                // If a match is found, and there's a guard
                // condition, evaluate it. The event is only
                // triggered if this passes.
                if (result) {
                    if (typeof guard === 'function' && guard()) {
                        events.trigger('route:' + route.name, {
                            values: result.splice(1)
                        });
                    }
                    break;
                }
            }
        };

        window.addEventListener('hashchange', onHashChange);
        onHashChange();
    }

}

// main.js
import Router from 'router.js';
import events from 'events.js';

var router = new Router()

// Function that can be used as a guard condition
// with any route we declare. It's returning a random
// value to demonstrate the various outcomes, but this
// could be anything that we want applied to all our routes.
function isAuthorized() {
    return !!Math.round(Math.random());
}

// The first route doesn't have a guard condition,
// and will always trigger a route event. The second
// route will only trigger a route event if the given
// callback function returns true.
router.add('open', 'open');
router.add('guarded', 'guarded', isAuthorized);

events.listen('route:open', () => {
    console.log('open route is always accessible');
});

events.listen('route:guarded', (data) => {
    console.log('made it past the guard function!');
});

router.start();

调试路由器

一旦我们的路由器增长到足够大的规模,我们将不得不解决复杂的情况。如果我们事先知道可能出现的问题,我们将更好地应对它们。我们还可以将故障排除工具集成到我们的路由器实例中,以帮助这个过程。扩展我们架构的可寻址性意味着能够快速、可预测地响应问题。

冲突的路由

冲突路由可能引起巨大的头痛,因为它们可能非常难以追踪。冲突模式是后来添加到路由器中的更具体模式的通用或类似版本。更通用的模式发生冲突,因为它与最具体的 URI 相匹配,这些 URI 应该已经被更具体模式匹配。然而,它们永远不会被测试,因为通用路由是首先执行的。

当这种情况发生时,可能根本看不出来路由存在问题,因为错误的路由处理程序运行得非常好,在 UI 上,一切看起来都很正常——除非有一点不对劲。如果按 FIFO 顺序处理路由,特定性很重要。也就是说,如果首先添加更通用的路由模式,那么当它们被激活时,它们总是与更具体的 URI 字符串匹配。

当有大量 URI 需要排序时,按照这种方式排序的挑战是,这是一项耗时的工作。我们必须比较新添加的路由与现有路由的模式。如果它们都被添加到同一个地方,开发人员之间的承诺也可能存在冲突。这是将路由按组件分离的另一个优点。这样做使得可能发生冲突的路由更容易被发现和处理,因为组件很可能具有少量类似的 URI 模式。

下面是一个显示具有两个冲突路由的路由组件的示例:

// Finds the first matching route in "routes" - tested
// against "uri".
function match() {
    for (let route of routes) {
        if (route.route.test(uri)) {
            console.log('match', route.name);
            break;
        }
    }
}

var uri = 'users/abc';

var routes = [
    { route: /^users/, name: 'users' },
    { route: /^users\/(\w+)/, name: 'user' }
];

match();
//    match users
// Note that this probably isn't expected behavior
// if we look closely at the "uri". This illustrates
// the importance of order, when testing against a
// collection of URIs specs.

routes.reverse();

match();
//    match user

记录初始配置

路由器应该在配置了所有相关路由之后才开始监听 URI 变化事件。例如,如果个别组件用对该组件必要的路由配置路由器,我们不希望路由器在组件有机会配置其路由之前就开始监听 URI 变化事件。

初始化其下级组件的主要应用组件可能会引导这个过程,并在完成后告诉路由器开始工作。当个别组件有自己的路由封装在内时,在开发过程中,理解路由器的整体配置可能很困难。为此,我们需要在我们的路由器中有一个选项,用于记录其整个配置——模式及其触发的事件。这有助于我们进行扩展,因为我们不必牺牲模块化路由就能了解整体情况。

记录路由事件

除了记录初始路由配置之外,如果路由器能够在触发 URI 变化事件的生命周期中进行日志记录也是很有帮助的。这与我们在前一章中讨论的事件机制日志不同——这些事件将在路由触发路由事件之后记录。

如果我们正在构建一个大规模的 JavaScript 架构,拥有许多路由,我们就想了解关于我们的路由器的一切,以及它在运行时是如何行为的。路由器对于我们应用的可扩展性是如此的基础,以至于我们在这里要投入对细节的关注。

例如,了解路由器在遍历可用路由、寻找匹配项时的行为可能很有用。了解路由器从 URI 字符串解析出来的结果也很有用,这样我们就可以将其与下游的路由事件处理程序所看到的内容进行比较。并非所有的路由组件都支持这种级别的日志记录。如果我们发现需要它,一些框架将提供足够的入口点进入它们的组件,并附有优秀的扩展机制。

处理无效资源状态

有时,我们忘记路由是无状态的;它接受一个 URI 字符串作为输入,并根据模式匹配条件触发事件。与可寻址性相关的一个可扩展性问题并不在于路由器状态,而在于监听路由的组件状态。

例如,想象我们从一项资源导航到另一项资源。在我们访问这个新资源时,第一项资源会发生很多变化。很容易出现这样的情况:它以这样一种方式改变,使得这个特定用户无法访问,同时它还保存在用户的历史记录中,他们只需要点击后退按钮。

路由器和可寻址性可能会将这类边缘情况引入我们的应用程序。然而,处理这些边缘情况并不是路由器的责任。这些问题是由许多 URI、许多组件以及将它们全部联系在一起的复杂业务规则共同造成的。路由器只是一个帮助我们应对大规模政策的机制,而不是实施政策的场所。

摘要

本章详细介绍了如何随着应用程序的扩展实现可寻址性这一架构特性。

我们从路由和可寻址性的讨论开始,首先查看了不同的路由方法——hash 变化事件和利用现代浏览器中可用的历史 API。大多数框架为我们抽象了这些差异。接下来,我们探讨了路由器的职责,以及它们应该如何通过触发事件与其它组件解耦。

URI 本身的设计也在我们软件的可扩展性中扮演了角色,因为它们需要保持一致和可预测。用户甚至可以利用这种可预测性来帮助他们扩展对我们软件的使用。URI 编码信息,然后传递给响应路由的事件处理程序;这也需要考虑。

接着,我们查看了路由被触发的各种方式。这里的标准方法是点击一个链接。如果我们的应用程序连接良好,它将到处都是链接。为了帮助我们管理众多链接,我们需要一种自动生成 URI 字符串的方法。接下来,我们将查看组件运行所需要的中间数据。这些包括用户偏好和我们组件的默认值。

第六章:用户偏好和默认值

任何足够大的 JavaScript 应用程序都需要配置其组件。我们组件配置的范围和性质根据应用程序的不同而有所变化。在配置组件时,需要考虑许多扩展因素,我们将在整章中讨论这些因素。

我们将首先确定我们必须处理的偏好类型,然后本章的其余部分将讨论这些偏好相关的具体扩展问题以及如何解决它们。

偏好类型

当我们设计大型 JavaScript 架构时,关心的三种主要偏好类型是:地区、行为和外观。在本节中,我们将为每个偏好类别提供定义。

地区

当今的应用程序不能只支持一个单一的地区,如果它们要在全球范围内取得成功的话。由于全球化以及互联网,来自世界其他地区的应用程序需求已成为新的常态。因此,我们必须以一种能够无缝容纳许多地区的方式设计我们的 JavaScript 架构。一个地区的用户应该能够像其他任何地区的用户一样轻松、自信地使用我们的应用程序。

注意

使组件能够使用任何地区的过程称为国际化。然后,为我们的应用程序创建特定地区的数据的过程称为本地化

国际化/本地化之所以困难,是因为它触及了用户界面的每一个视觉方面。尽管有很多组件不关心地区(如控制器或集合),但这仍然可能相当多。例如,任何原本在模板某处硬编码的字符串标签,现在需要通过一个地区感知翻译机制。

语言翻译本身就已经够困难了。但是地区数据包括与我们软件中使用的任何一种文化相关的任何和一切内容。例如,用于日期/时间或货币值的形式。这些只是最常见和最直接的元素。事物可以一直变化到如何度量数量,或者一直到整个页面的布局。

行为

我们组件的大部分行为都存在于代码中,并且是不变的。由于不同偏好而发生的行为变化是微妙而又重要的。当有多个互动组件时,必然会有一种不兼容的组合会引起问题。

例如,在我们组件的实现中可能有一个函数,它从一个配置值中获取一个值,用这个值来计算某物。这可能是一个用户偏好,或者可能是我们为了可维护性而设置的东西。

注意

在本书的剩余部分,我们将把个别配置值称为偏好。我们将把给定组件内所有偏好的聚合效果称为配置。

行为偏好可能会对用户看到的内容产生不同的影响。一个简单的例子就是关闭组件,或者禁用它。这个偏好会导致组件在 UI 中不再渲染。另一个偏好将决定显示多少元素。一个常见的例子是用户告诉应用程序他们希望在每页看到多少搜索结果。

这些类型的偏好并不总是直接映射到最终用户。也就是说,组件可能有一些不是直接暴露给用户的偏好。这可能是为了提高开发人员的灵活性,减少我们编写的代码量。可配置的组件有多种形式,从这种角度来看,我们需要确保相应地处理它们,以帮助我们的软件实现扩展。

我们需要的不仅仅是前端组件,因为给定偏好可能会改变后端行为。这可能简单到一个查询参数偏好,或者另一个偏好导致使用不同的 API 端点。所有这些看似无害的偏好加起来会产生深远的影响,跨越整个应用程序,可能会影响系统中的其他用户。

外观

如果一个现代 JavaScript 应用程序要跨越受众人口统计数据进行扩展,它的外观需要是可配置的。这一要求可以从可配置的标志,到具有潜在能力彻底改变 UI 外观和感觉的可互换主题不等。

一般来说,外观变化主要围绕 CSS 属性,如字体、颜色、宽度、边框半径等。虽然确实大多数 CSS 实现并未被大多数 JavaScript 开发者触及,但我们仍然需要关注主题边界。

例如,如果我们对外观以及如何配置它持灵活态度,我们可能会让用户在运行时选择自己的主题。因此,我们需要实现一个用户可以与之交互的主题切换机制。此外,主题化的用户界面意味着偏好需要被存储和加载。

那么这就是粗粒度主题——那细粒度外观配置又是怎样的呢?然而,粗粒度更为常见,对特定组件风格的配置并非不可能。外观粒度级别与其他扩展影响因素一致,比如我们的软件部署在哪里,以及我们配置 API 的能力如何。

支持本地化

拥有对我们所有组件的国际化支持是一个好主意。实际上,有很多 JavaScript 工具可以帮助我们完成这项任务。有些工具比较独立,而有些则更针对特定的框架。使用这些工具很简单,但还有很多其他与本地化相关的工作需要考虑,特别是在扩展的情况下。

决定支持哪些区域

一旦我们拥有带有国际化支持并在生产环境中使用的软件,下一步就是决定支持哪些区域。当我们确保所有组件都进行国际化时,我们只支持一个区域——默认区域。一开始这样做是可以的,可能需要数年才能出现对第二个辅助区域支持的需求。

这通常是新软件项目的情况。我们知道国际化应该是我们优先级列表上的重要事项,但在其他所有事情中很容易分心。不花费精力支持区域的主要论点是它目前是不需要的。反对这种心态的观点是,随着组件的增长,事后实施国际化是非常困难的。所以这又是需要考虑的与扩展相关的权衡。我们希望我们的应用程序能够跨文化扩展,还是认为立即上市更重要?

除了特殊情况外,我们假设国际化是必不可少的——我们需要确定我们首先要支持哪些区域,以及哪些可以等待。例如,在实际需要之前就寻求大规模区域支持是一个糟糕的主意。区域占用的物理空间,需要有人来维护这些区域。所以如果没有客户来承担这种增加的扩展复杂性的成本,这是不值得的。

相反,所选的本地化区域应完全基于客户需求。如果我们有一个地方有数百人寻求支持,而只有不到十几个人询问另一个地方,优先级就很明显了。如果我们像优先支持功能一样优先支持区域,这将对我们有帮助。

维护区域

首先,如果我们支持某个区域,我们将需要翻译在 UI 中显示的所有字符消息。其中一些是在模板文件中静态编码的,而其他字符串在我们的 JavaScript 模块中找到。如果只是找到这些字符串,并一次性翻译它们那该多好。但字符串很少会永远保持不变——经常会有微小的调整。此外,随着我们的软件增长和更多组件的添加,需要翻译的字符串也会增加。

仅字符串翻译的缩放因素就是我们支持的区域设置数量——这就是为什么我们需要谨慎地只支持有限数量的区域设置,只要我们能够做到。复杂性并没有就此结束。例如,一些消息字符串可以从源语言映射到目标语言。像语法屈折这样的东西——单词如何根据修改承担不同的意义——并不是那么直接。实际上,这些用例有时需要国际化的专用库。

其他可本地化的数据,如日期/时间格式,不需要太多维护。对于给定区域设置,应用程序中通常使用一两个格式。对于这些格式,客户可能会对他们文化中使用的标准格式感到满意。幸运的是,我们可以在我们的项目中使用通用区域数据仓库CLDR)数据——一个可下载的通用区域数据仓库。这是一个良好的起点,因为大多数时候这些数据都是足够的,并且根据请求容易覆盖。

设置区域设置

一旦我们建立了国际化库,并且有几个区域设置,我们就可以开始测试我们的应用程序在不同文化角度下的行为。对于这种行为,有许多需要考虑的项目。例如,我们需要为用户启用区域设置,并且我们需要跟踪这个选择。

选择区域设置

在 JavaScript 应用程序中选择区域设置有两种常见方法。第一种方法是使用 accept-language 请求头。第二种方法是在用户设置页面上一个选择器小部件。

accept-language 方法的优点在于无需涉及用户输入。我们的应用程序会根据用户的浏览器语言偏好发送,从而我们可以设置区域设置。这种方法的挑战在于,从可用性的角度来看,它可能过于限制性,从实现角度来看也是如此。例如,用户可能无法控制他们的浏览器语言偏好,或者浏览器可能没有支持我们应用程序的区域设置偏好。

注意

使用 accept-language 请求头方法遇到的另一个技术挑战是,没有简单的方法将请求头从浏览器传递到 JavaScript 代码——这有点疯狂,因为它们都在浏览器中!例如,如果我们的 JavaScript 代码需要知道区域设置偏好,以便它可以加载适当的区域设置数据,它将需要访问 accept-language 头部。为此,我们需要后端技巧。

更加灵活的方法是向用户展示一个区域设置选择器小部件,然后从中明确用户想要激活哪个区域设置。然而,我们需要找到一种存储这个区域设置选择的方法,这样用户就不必重复选择他们的区域设置。

存储区域设置偏好

一旦用户选择地区偏好,可以作为 cookie 值存储。下次应用程序在浏览器中加载时,我们将准备好地区偏好。然后我们可以标记选择器为适当的选择,以及加载相关地区数据。

将地区偏好存储在 cookie 中的问题是,如果用户转到另一个浏览器,将需要重复相同的选择过程。这对于当今用户比以往任何时候都更加移动是一个真正的问题——在一个设备上所做的更改应该在任何应用程序被使用的地方反映出来。而这是通过 cookie 办不到的。

如果我们使用后端 API 存储地区偏好,它将无处不在对用户可用。下一个挑战是加载相关地区数据,使其可供我们其他组件使用。通常,我们希望在此开始渲染数据之前准备好这些数据,因此这是我们向后端发出的第一个请求之一。有时,所有地区都作为单一资源一起提供。如果我们支持很多地区,这可能成为问题,因为加载它需要的前期成本很高。

另一方面,一旦我们加载地区偏好,我们只能加载立即需要的地区。这将提高初始加载时间,但权衡是切换到新地区较慢。这可能不会经常发生,所以最好不要加载从未使用过的地区数据。

存储地区偏好

JavaScript 应用程序首先加载地区偏好,然后使用该偏好加载本地数据

在 URI 中使用地区

除了在后台存储本地偏好或作为 cookie 值外,地区还可以编码为 URI 的一部分。通常,它们作为两个字符代码表示,例如enfr,并位于 URI 的开头。使用这种方法的优点是不需要存储偏好。我们仍然可能需要一个选择器让用户选择他们偏好的地区,但这将导致新的 URI,而不是将偏好值存储在某个地方。

像这样在 URI 中编码首选地区的方法与基于 cookie 的方法有相同的缺点。虽然我们可以收藏一个 URI,或将一个 URI 传递给其他人——他们会看到我们相同的地区——问题是这并不是一个永久的偏好。请注意,我们总是可以存储偏好并在应用程序加载时更新 URI。但由于路由和 URI 生成的额外复杂性,这种方法扩展性不佳。

通用组件配置

正如我们在上一节关于地区偏好的内容中看到的那样,我们需要加载一个偏好值,然后我们的每个组件都可以使用这个值。或者在地区的情况下,可能只有一个组件,但这个偏好值间接影响了所有组件。除了地区之外,我们还有许多其他想要在组件中进行配置的事物。本节从通用角度来探讨这个问题。首先我们需要决定给定组件的哪些方面是可配置的,然后是如何在运行时将这些偏好值传递给组件的机制。

决定配置值

组件配置的第一步是决定偏好——组件哪些方面需要配置,哪些方面可以保持静态?这远非一门精确的科学,因为往往后来我们会意识到某些静态内容应该是可配置的。试错是找到可配置偏好的最佳过程,尤其是当我们的软件刚刚起步时。过多的初始可配置性考虑是可扩展性的瓶颈。

当某事物不可配置时,它具有简单性的优势。它更加结构化,且不是活动的部件。这消除了潜在的边缘案例和性能问题。为使值可配置而进行的前期论证并不经常发生。但随着我们的软件成熟,我们将有一个更好的视角,因为我们已经设定了一些偏好,并且我们将更好地了解预期会发生什么。

例如,我们将开始在我们的多个组件中看到重复。它们将基本相同,只有微妙的差异。如果我们继续添加彼此之间只有细微差别的新的组件类型,我们将面临可扩展性问题。我们的代码库将增长到一个无法管理的规模,并且我们会让开发者困惑,因为给定组件的责任将变得模糊。

这就是我们利用可配置性来实现规模的地方。这是通过引入对新组件类型的偏好来实现的。例如,假设我们需要一个新的视图,它除了处理 DOM 事件的方式与另一个已经在多个地方使用的视图相同外,其他方面都相同。我们不是实现一个新的视图类型,而是增强现有的视图,使其能够接受一个新的函数值,用于覆盖这个事件的默认值。

另一方面,我们不能随意引入组件偏好。当我们这样做时,我们用新的瓶颈取代了旧的瓶颈。我们需要考虑性能,因为每增加一个新的可配置偏好都会受到影响。还有代码复杂性——使用偏好并不像使用静态值那么简单。还有可能引入与其他开发者在同一开发周期内引入的其他偏好不一致的偏好。最后,还需要跟踪和文档化给定组件可用的各种偏好。

存储和硬编码的默认值

就组件而言,偏好应尽可能像普通的 JavaScript 变量一样处理。这使得我们的代码具有灵活性——用静态值替换偏好不应该产生很大的影响。普通变量通常声明有一个初始值,偏好也应该声明有一个默认值。这样,如果由于某种原因我们无法获取存储在后台的偏好值,软件将继续使用合理的默认值运行。

对于任何偏好,都应该有一个回退默认值,并且这些值应该在某个地方进行文档化。理想情况下,使用的默认值服务于常见情况,因此不需要为了使用软件而调整每个偏好。如果我们由于某种原因无法从后端访问存储的配置值,硬编码的默认值会让软件继续运行,尽管是使用不那么理想的配置。

提示

有时,无法访问配置值是一个不可逾越的障碍,软件应该快速失败,而不是使用硬编码的默认值。虽然软件完全可以通过默认值正常运行,但根据我们的客户和他们部署的情况,这种模式可能比软件不可用更糟糕。这在部署大规模 JavaScript 应用程序时需要考虑。

默认偏好值的安全性使得在后台删除修改过的偏好值变得可能。把它看作是一个恢复出厂设置的动作。换句话说,如果我们通过调整偏好值引入了软件问题,我们只需删除我们存储的值即可。如果后台不需要存储默认值,那么就没有覆盖默认值的风险。

存储和硬编码的默认值

默认值总是存在,但很容易被后端偏好值覆盖

后端影响

如果我们将在后台存储偏好值以提供用户的便携性,那么我们需要某种机制,使我们能够在配置存储中放入新的值偏好,以及检索我们的偏好。理想情况下,这是一个允许我们定义任意键值偏好,并且让我们用一个请求检索所有配置的 API。

这种方法对前端开发如此宝贵,是因为我们可以在开发组件的同时为其定义新偏好,而不会打扰到后端团队。对于后端 API 来说,前端配置是任意的——无论是否有 UI,API 都能正常工作。

有时,这实际上可能比想象的更有麻烦。如果变化非常小——整个应用程序中只需要少量的配置值呢?如果是这样,我们可能会考虑维护一个静态的 JSON 文件,作为我们的前端配置。它足够任意,我们可以随时定义偏好,对于获取偏好值来说,它与 API 一样好用。

当用户定义的偏好设置不适用时,例如,用户的首选语言。我们的应用程序可能有一个默认语言,直到用户更改它。他们是在为自己更改偏好,而不是系统中的每个用户。这时我们就需要前面提到的配置 API。它存储这些值的方式,很可能是数据库,需要对用户敏感。但并非所有偏好值都需要这样;有些是由部署操作员设置的,用户无法更改这些。

Backend implications

当前用户会话可以用来加载特定于该用户的偏好设置;这些与系统设置不同,不会因用户而异。

Loading configuration values

加载前端所需配置有两种方法。第一种方法是加载所有配置,因为 UI 中会渲染任何内容。这意味着在路由开始处理任何内容之前,我们会等待配置可用。这通常意味着等待一个加载配置数据的承诺。这里的明显缺点是初始加载时间变长。优点是我们拥有了后续所需的一切——不再需要配置请求。

我们可以在浏览器中使用本地存储来缓存偏好值。它们很少变化,这种策略有可能提高初始加载性能。另一方面,它增加了复杂性——所以只有在配置值很多且加载它们的时间明显时才考虑这种方法。

instead of loading all our configuration up-front, preference values can be loaded on demand. That is, when a component is about to be instantiated, a request is made for its configuration. This has the appeal of being efficient, but again, how much configuration could there possibly be to warrant such complexity? Strive toward loading all application configuration up-front where possible.

Loading configuration values

一个与后端通信的配置组件为获取或设置偏好值的任何组件提供了抽象。

配置行为

如果我们实现得当,我们组件的行为很大程度上是自包含的。它们向外部世界暴露的是对它们行为进行微调的偏好。这可能是一些内部关注的内容——比如使用的模型类型,或首选算法。它可能是一些面向用户的内容,比如启用组件,或设置显示模式。正是这些偏好帮助我们使组件能够在各种上下文中工作。

启用和禁用组件

一旦我们的软件达到一定的临界质量,不是所有功能对所有用户都相关。简单地能够在启用/禁用状态之间切换组件是一个强大的工具。对我们作为软件供应商,以及我们的客户来说都是如此。例如,我们知道某些功能是我们软件中某些用户角色所必需的,但它们并不是常见情况。为了更好地优化常见用户,我们可能会选择禁用某些不经常使用的高级功能。这可以清理布局,提高性能等。

另一方面,我们可能会默认开启所有功能,但如果组件有能力被关闭,那么这就让用户决定了哪些对他们来说是相关的。如果他们能够根据自己的喜好安排用户界面,移除对他们没有特别用处的元素,那么这将提升用户体验。

在任何情况下,这对整体布局都有影响。如果我们不花时间设计可扩展的布局,那么切换组件实际上并没有任何价值。在设计我们的布局时,我们需要考虑用户可能会使用,或者我们自己可能会使用的各种配置场景。

启用和禁用组件

在页面上禁用组件有可能更新布局;我们的样式需要能够处理这种情况

更改数量

在 UI 中显示的数量在某一方面只是一个在设计时做出的猜测。我们希望列表中显示的项数量是最优的,用户不需要为此类型的偏好更改而烦恼。问题是数量是非常主观的。它更多的是关于使用我们的应用程序执行任务的个人,以及他们习惯于什么,他们使用我们的软件时正在做什么,还有许多其他因素,数量偏好默认可能不是最优的。

一个常见的数量问题是我想在屏幕上显示多少个实体?这些实体可以是整个应用程序中常用的网格小部件,一个搜索结果页面,或者任何其他渲染事物集合的东西。我们可以选择显示较少数量的效率默认设置,同时允许更多数量以满足用户的需求。

小贴士

始终检查用户提供的偏好是一个好主意。一个保护措施是在放置允许的值的选择,而不是接受任意的用户输入。例如,我们不应该允许网格中渲染 1,000 个实体。尽管如此,返回这些数据的 API 也应该进行检查并限制数量参数。

另一个数量考虑的是我们需要显示哪些实体属性?在网格的情况下,我们可能希望显示某些列而隐藏其他列。这样的偏好应该是持久的,因为如果我们设置了想要看到的数据,我们就不想重复那项工作。

当我们改变数量偏好时,会有后端影响。在决定渲染多少实体的情况下,我们可能希望在获取数据时将这个约束传递给 API——获取我们不打算显示的东西是没有意义的。这也可能涉及到模型或集合的改变。在确定在特定 UI 区域显示哪些数据的情况下,我们可能要求模型或集合只提供他们拥有的一部分数据。

更改顺序

在 UI 中渲染集合的顺序是另一个常见的 behavioral 偏好,我们很可能会支持它。这里最大的影响是配置某物的默认顺序。例如,按修改日期对每个集合进行排序,这样最近的实体首先出现,这是一个好的默认设置。

许多网格组件允许用户在给定列的升序和降序之间切换排序。这些都是操作,不一定偏好。然而,如果默认顺序从来不是我们想要的,它们可能会变得烦人。因此,我们可能需要为用户提供一种方式,为任何给定网格提供默认排序偏好,同时保留点击列标题进行临时排序的能力。

可能的更复杂的排序偏好是,点击列标题并不总是有帮助。例如,如果我们想按不在 UI 中渲染的东西排序,比如相关性或最佳销售?这里可能有一个控制可以用来实现这一点,但这又是另一个可能的偏好——因为它可能有助于提供更好的体验。

// users.js
export default class Users {

    // Accepts a "collection" array, and an "order"
    // string.
    constructor(collection, order) {
        this.collection = collection;
        this.order = order;

        // Creates an iterator so we can iterate over
        // the "collection" array without having to
        // directly access it.
        this[Symbol.iterator] = function*() {
            for (let user of this.collection) {
                yield user;
            }
        };
    }

    set order(order) {

        // When the order break it down into it's parts,
        // the "key" and the "direction".
        var [ key, direction ] = order.split(' ');

        // Sorts the collection. If the property value can be
        // converted to lower case, they it's converted to avoid
        // case inconsistencies.
        this.collection.sort((a, b) => {
            var aValue = typeof a[key].toLowerCase === 'function' ?
                a[key].toLowerCase() : a[key];

            var bValue = typeof b[key].toLowerCase === 'function' ?
                b[key].toLowerCase() : b[key];

            if (aValue < bValue) {
                return -1;
            } else if (aValue > bValue) {
                return 1;
            } else {
                return 0;
            }
        });

        // If the direction is "desc", we need to reverse the sort.
        if (direction === 'desc') {
            this.collection.reverse();
        }
    }

}

// main.js
import Users from 'users.js';

var users = new Users([
    { name: 'Albert' },
    { name: 'Craig' },
    { name: 'Beth' }
], 'name');

console.log('Ascending order...');
for (let user of users) {
    console.log(user.name);
}
//
// Albert
// Beth
// Craig

users.order = 'name desc';

console.log('Descending order...');
for (let user of users) {
    console.log(user.name);
}
//
// Craig
// Beth
// Albert

配置通知

当用户在我们的应用程序中执行某些操作,比如打开或关闭某些功能时,我们需要提供关于该操作状态的反馈。它成功了吗?失败了吗?正在运行吗?这通常通过通知完成,以屏幕角落的短暂弹出窗口或某个面板的形式呈现。

用户可能希望控制他们通知方式的某些方面——没有比收到我们不关心的信息垃圾更让人恼火了。因此,与通知相关的的一个偏好可能就是通知主题的选择。例如,我们可能希望选择不接收不相关实体类型的通知。

另一个可能的偏好可能是给定通知在屏幕上保持活动状态的持续时间。例如,它应该在我们确认它之前一直停留原地,还是应该在三秒后消失?在极端情况下,如果没有什么其他办法能让它们不那么烦人,用户可能想要完全关闭通知。如果需要,以后可以随时方便地浏览操作日志。

内联选项

那么我们如何收集用户偏好输入呢?对于不太活跃的全球应用程序偏好,一个按类别划分的设置页面可能是合适的。然而,必须在设置页面上为个别小部件配置特定事项有点儿烦人。有时,拥有内联选项会更好。

内联意味着用户可以使用与相关 UI 部分相关的元素来设置他们的偏好。例如,在网格中选择特定的列显示。把这样的偏好埋在某个设置页面上并没有多大意义。当偏好控制与它们控制的元素相对位置时,通常需要更少的解释。当控制具有上下文意义时,用户通常更容易理解其含义。

注意

上下文偏好控制的一个缺点是,它们有可能导致界面混乱。如果页面上有许多组件,每个组件上都有偏好控制,那么我们很可能会制造混乱而不是提供便利。

更改外观和感觉

如今,应用程序的外观和感觉很少是静态的、不变的方面。相反,它们通常会附带几套用户可以选择的主题。或者,软件中内置了易于创建主题的支持。这允许我们的客户决定他们的软件应为他们的用户呈现何种外观。除了更新我们应用程序外观和感觉的预设主题外,还可以设置个别样式偏好。

主题工具

如果我们想要我们的应用程序能够根据请求更换主题,我们必须在 CSS 及其使用的标记上投入大量的设计和架构工作。虽然这个话题远远超出了本书的范围,但研究一下有助于生成主题的工具还是值得的。

在我们这一领域中可用的第一个工具是一个 CSS 框架。与 JavaScript 框架类似,CSS 框架定义了一致的模式和约定。接下来,就要我们这些组件作者来弄清楚如何将这些 CSS 模式应用到我们的组件以及它们生成的标记上。可以把一个主题看作是一系列样式偏好。当配置更改时,由于新的偏好值,外观也会发生变化。使一个 CSS 模块成为主题的原因是,它定义了与应用程序中所有其他主题相同的属性——只有这些属性的值会发生变化。

我们可以使用后端构建过程的一部分工具—CSS 编译器。这些工具接收使用 CSS 方言的文件,并预处理它们。关于这些预处理器语言的好处是,我们能够更精确地控制样式偏好的指定方式。例如,CSS 中没有变量这样的东西,但预处理器中有,这是非常方便的可配置性功能。

选择主题

一旦我们有了可定制的用户界面,我们需要一种加载特定主题实例的方法。即使我们不允许用户选择他们喜欢的主题,能够通过更改偏好值来改变设计也是很好的。当我们决定实现新设计时,这当然可以使部署到生产环境变得更加简单。

将来,我们可能会决定让用户选择自己的主题。例如,我们可能已经拥有大量用户,现在有这种需求。我们可以像系统中使用的任何其他偏好值一样创建主题选择器。我们需要有一种主题选择小部件,用户所做的选择可以映射到路径,因为这是交换一个主题到另一个主题所需的一切。

另一种可能性是根据用户角色设置不同的主题作为默认主题。例如,如果管理员登录,具有不同的视觉提示实际上您以特定类型的用户登录是有帮助的。在截图等场景中,这类事情可以帮助。

个人样式偏好

应用程序的外观和感觉可以逐个元素级别进行更改。也就是说,如果我们想改变某物的宽度,我们可以在屏幕上进行更改。也许我们不喜欢正在使用的字体样式,我们也想更改,但其他方面保持不变。

应避免此类细粒度的样式偏好,因为它们扩展性不佳。我们的组件必须了解特定的样式考虑,这通常会在大多数情况下降低组件的真正目的。在某些情况下,为屏幕选择不同的布局不会有害,因为这通常意味着将一个 CSS 类交换为另一个。

另一种可能性是使用拖放交互来设置某物的尺寸。但是,最好是将这些保留为短暂交互,而不是持久偏好。我们希望为常见的配置值优化,而针对个人口味调整元素大小并没有什么共同之处。

性能影响

我们将以概述由各种配置区域引入的性能影响来结束本章。如果我们确实需要在某一区域获取配置值,因为它们增加了价值,它们可能会影响整体性能—因此我们需要以某种方式抵消这种成本。

可配置的区域性能

说到语言环境,最显著的性能瓶颈就是初始加载。这是因为我们必须在实际为用户渲染任何内容之前加载所有语言环境数据。这包括字符串消息翻译,以及所有进行本地化的其他必要数据。当一次性加载多个语言环境时,初始化过程中的性能受到进一步限制。

提高加载性能的最佳方法是只加载用户实际想要的语言环境。一旦他们设置了这个偏好,他们不太可能频繁更改,所以附近有其他语言环境数据并准备好并没有真正的好处。

渲染视图时不可避免地会有减速,因为大量数据需要通过我们使用的本地化机制。单凭这一点不太可能引起性能问题,因为大多数操作都是小而高效的——简单的查找和字符串格式化。尽管如此,额外的开销是存在的,需要予以考虑。

可配置行为性能

改变组件行为的配置对性能影响最小。实际上,可配置行为的性能特性与可配置语言环境的特性相似。最大的挑战是初始配置加载。在那之后,只需执行查找,这是快速的。

需要注意的是,当我们需要配置大量组件时。虽然单个查找很快,但当查找量很大时,性能会受到影响。达到这个点需要一段时间,但风险依然存在。

以下是一个示例,展示了我们可以配置集合何时排序,从而影响具有依赖顺序并且被频繁调用的其他操作的性能:

// users.js
export default class Users {

    // The users collection excepts data, and an
    // "order" property name.
    constructor(collection, order) {
        this.collection = collection;
        this.order = order;
        this.ordered = !!order;
    }

    // Whenever the "order" property is set, we need
    // to sort the internal "collection" array.
    set order(key) {
        this.collection.sort((a, b) => {
            if (a[key] < b[key]) {
                return -1;
            } else if (a[key] > b[key]) {
                return 1;
            } else {
                return 0;
            }
        });
    }

    // Finds the smallest item of the collection. If the
    // collection is ordered, then we can just return the
    // first collection item. Otherwise, we need to iterate
    // over the collection to find the smallest item.
    min(key) {
        if (this.ordered) {
            return this.collection[0];
        } else {
            var result = {};
            result[key] = Number.POSITIVE_INFINITY;

            for (let item of this.collection) {
                if (item[key] < result[key]) {
                    result = item;
                }
            }

            return result;
        }
    }

    // The inverse of the "min()" function, returns the
    // last collection item if ordered. Otherwise, it looks
    // for the largest item.
    max(key) {
        if (this.ordered) {
            return this.collection[this.collection.length - 1];
        } else {
            var result = {};
            result[key] = Number.NEGATIVE_INFINITY;

            for (let item of this.collection) {
                if (item[key] > result[key]) {
                    result = item;
                }
            }

            return result;
        }
    }

}

// main.js
import Users from 'users.js';

var users;

// Creates an "ordered" users collection.
users = new Users([
    { age: 23 },
    { age: 19 },
    { age: 51 },
    { age: 39 }
], 'age');

// Calling "min()" and "max()" doesn't result in
// two iterations over the collection because they're
// already ordered.
console.log('ordered min', users.min());
console.log('ordered max', users.max());
//
// ordered min {age: 19}
// ordered max {age: 51}

// Creates an "unordered" users collection.
users = new Users([
    { age: 23 },
    { age: 19 },
    { age: 51 },
    { age: 39 }
]);

// Every time "min()" or "max()" is called, we
// have to iterate over the collection to find
// the smallest or largest item.
console.log('unordered min', users.min('age'));
console.log('unordered max', users.max('age'));
//
// unordered min {age: 19}
// unordered max {age: 51}

行为偏好可能用于完全交换一个函数与另一个函数。它们可能有相同的接口,但实现不同。在运行时决定使用哪个函数并不昂贵,但还需要考虑内存消耗。例如,如果我们应用程序中有许多支持不同函数的偏好,我们将不得不存储默认实现,以及作为偏好值存储的函数。

可配置主题性能

我们唯一可以预期的可配置主题的延迟就是确定使用哪个主题的初始成本。然后是下载它以及将样式应用到标记的过程——这与只有一个静态样式集的应用程序没有区别。如果我们允许用户切换主题,那么还需要等待新的 CSS 和相关静态资源下载和渲染的额外延迟。

摘要

本章介绍了大规模 JavaScript 应用程序中可配置性的概念。主要的配置类别包括地区、行为和外观。地区是当今网络应用程序的一个重要部分,因为没有什么能阻止世界上任何地方的人使用我们的应用程序。然而,国际化带来了可扩展性的挑战。它增加了我们开发周期的复杂性,以及维护地区的成本。

偏好需要存储在某个地方。将它们存储在浏览器中是可行的,但这种方法缺乏可移植性。将偏好存储在后端并在应用程序初始化时加载它们要更合适得多。扩展许多偏好面临许多挑战,包括区分用户定义和系统偏好。我们是否包含了合理的硬编码默认值并不重要。

我们应用程序的风格是另一个可配置的维度。有框架和构建工具可以帮助我们构建外观和感觉的主题。可配置组件有一些小的性能考虑——下一章将探讨随着我们扩展软件而出现的性能挑战。

第七章: 加载时间和响应性

JavaScript 的可伸缩性包括应用程序的加载时间以及用户与应用程序交互时的响应性。共同地,我们将这两个架构品质称为性能。在用户眼中,性能是质量的主要指标——正确地做到这一点很重要。

随着我们的应用程序获得新功能和用户基础的增长,我们必须找到避免相关性能下降的方法。初始加载受到诸如 JavaScript 工件负载大小等因素的影响。我们 UI 的响应性更多地与代码的运行特性有关。

在本章中,我们将探讨这两个性能维度的各种权衡,以及它们将如何影响系统其他区域。

组件工件

在书的早期部分,我们强调过,大型 JavaScript 应用程序只是组件的集合。这些组件以复杂和精细的方式相互通信——这些通信实现了我们系统的行为。在组件可以通信之前,它们必须被交付到浏览器。了解这些组件是由什么组成的,以及它们实际上是如何被交付到浏览器的,有助于我们推理出应用程序的初始加载时间。

组件依赖

组件是我们应用程序的基石;这意味着我们需要将它们交付给浏览器,并以某种连贯的方式执行它们。组件本身可以从单体的 JavaScript 文件,到分布在几个模块中的东西。所有拼图碎片都是通过依赖图拼在一起的。我们从一个应用程序组件开始,因为这是进入我们应用程序的入口点。它通过要求它们来找到所有它需要的组件。例如,可能只有少数几个顶级组件,它们映射到我们软件的关键功能。这是依赖树的第一层,除非我们的所有功能组件都是单体构成的,否则可能还会有进一步的模块依赖需要解决。

模块加载机制遍历树结构,直到获取所需的所有内容。模块及其依赖关系细化到合理粒度的好处是,很多复杂性都被隐藏了起来。我们不需要在脑海中持有整个依赖图,这对于中等规模的应用来说是一个不切实际的目标。

这种模块化结构和用于加载和处理依赖的机制带来了性能影响。具体来说,初始加载时间会受到影响,因为模块加载器需要遍历依赖图,并为每个资源向后端请求。虽然请求是异步的,但网络开销依然存在——这在初始加载时对我们影响最大。

然而,仅仅因为我们想要一个模块化结构,并不意味着我们必须承担网络开销的后果。尤其是当我们开始扩展大量功能和大量用户时。每个客户端会话需要交付更多内容,随着更多用户请求相同的事物,后端资源争用也会增加。模块依赖关系是可以追踪的,这为我们的构建工具提供了许多选项。

组件依赖关系

如何加载 JavaScript 应用程序模块;依赖项会自动加载。

构建组件

当我们的组件达到一定复杂程度时,它们可能需要不仅仅是几个模块来实现所有功能。随着组件数量的增加,我们给自己制造了网络请求开销问题。即使模块携带的数据量很小,仍然需要考虑网络开销。

我们应该实际上追求更小的模块,因为它们更容易被其他开发者消费——如果它们小,那么它们很可能有更少的运动部件。如前所见,模块及其之间的依赖关系使我们能够实现分而治之。这是因为模块加载器追踪依赖关系图,并在需要时拉入模块。

如果我们想避免向后端发送这么多请求,我们可以将更大的组件工件作为构建工具链的一部分来构建。有许多工具可以直接利用模块加载器来追踪依赖关系,并构建相应的组件,如 RequireJS 和 Browserify。这很重要,因为它意味着我们可以选择适合我们应用程序的模块粒度,同时仍然能够构建更大的组件工件。或者我们也可以切换回实时将较小模块加载到浏览器中。

在网络请求开销方面的扩展含义造成了很大的影响。组件越多,这些组件越大,构建过程就越重要。特别是自从进行了代码压缩以来,这个压缩文件大小的过程经常是构建过程的一部分。能够关闭这些构建步骤,另一方面,对开发团队的可扩展性也有影响。如果我们能够在这之间切换浏览器接收的组件工件类型,那么开发过程可以推进得更快。

构建组件

构建组件会导致请求的工件更少,网络请求也较少。

加载组件

在本节中,我们将探讨负责将我们的源模块和构建组件实际加载到浏览器中的机制。目前有许多第三方工具用于结构化我们的模块并声明它们的依赖关系,但趋势是转向使用这些任务的新浏览器标准。我们还将探讨延迟加载模块以及加载延迟对用户体验的影响。

加载模块

目前在生产中使用的大型应用程序许多都采用了如 RequireJS 和 Browserify 这样的技术。RequireJS 是一个纯粹的 JavaScript 模块加载器,它有可以构建较大组件的工具。Browserify 的目标是使用为 Node.js 编写的代码来构建在浏览器中运行的组件。尽管这两种技术都解决了本章讨论的许多问题,但新的 ECMAScript 6 模块方法才是未来的发展方向。

支持使用基于浏览器的模块加载和依赖管理方法的主要论点是,不再需要另一个第三方工具。如果语言有一个解决扩展问题的特性,走那条路总是更好的,因为我们的工作量会少一些。这肯定不是万能的,但它确实具备我们所需的大部分功能。

例如,我们不再需要依赖发送 Ajax 请求,并在请求到达时评估 JavaScript 代码——这一切都交给浏览器处理。该语法实际上与在其他编程语言中找到的标准import export关键词更加一致。另一方面,原生的 JavaScript 模块仍然是新宠,而仅仅因为这一点就抛弃使用不同模块加载器的代码还不够充分。对于新项目,研究允许我们一开始就使用这些新模块结构的 ES6 转换器技术是值得的。

注意

我们应用程序经历的网络开销的一部分,以及用户最终支付的一部分,与 HTTP 规范有关。该规范最新草稿版 2.0 解决了许多开销和性能问题。这对模块加载意味着什么?如果我们能够以最小的开销获得合理的网络性能,我们可能能够简化我们的构件。编译较大组件的需求可能会被推迟,以便专注于坚实的模块化架构。

懒加载模块

单块编译组件丧失的一个优势是,我们可以推迟到实际需要时才加载某些模块。对于编译组件来说,要么全部加载,要么全部不加载——这在我们整个前端被编译成一个单独的 JavaScript 文件时尤其正确。好处是,当需要时一切都已准备就绪。如果用户决定在初次加载后五分钟与一个功能互动,代码早已在浏览器中,随时待命。

另一方面,懒加载是默认模式。这意味着模块直到另一个组件明确请求它时才被加载到浏览器中。这可能意味着一个require()调用或一个import声明。在这些调用被作出之前,它们不会从后端获取。好处是,初始页面加载应该要快得多,它只拉取初始显示给用户的特性所需的模块。

另一方面,当用户在初始加载五分钟后尝试使用某个功能时,我们的应用程序将首次需要或导入一些模块。这意味着在初始加载后会有一些延迟。请注意,随后的会话中按需加载的模块数量应该很少。因为必然有一些共享模块在用户最初看到的页面中被加载。

我们必须仔细考虑我们系统中的依赖关系。尽管我们可能认为我们推迟了某些模块的加载,但可能存在一些间接依赖,它们会在不需要的时候无意中加载主页面的模块。开发工具中的网络面板为此提供了理想的功能,因为通常很明显我们正在加载我们实际上不需要的东西。如果我们的应用有很多功能,懒加载尤其有帮助。初始加载时间的节省是巨大的,而且很可能有些用户从未使用过这些功能,因此也无需加载。

接下来是一个示例,展示了在实际需要时才加载模块的概念:

// stuff.js
// Export something we can call from another module...
export default function doStuff() {
    console.log('doing stuff');
}

// main.js
// Don't import "doStuff()" till the link
// is clicked.
document.getElementById('do-link')
    .addEventListener('click', function(e) {
        e.preventDefault();

        // In ES6, it's just "System.import()" - which isn't easy
        // to do across environments yet.
        var loader = new traceur.runtime.BrowserTraceurLoader();
        loader.import('stuff.js').then(function(stuff) {
            stuff.default();
        });
    });

模块加载延迟

模块是对事件的响应而加载的,而这些事件几乎总是用户事件。应用被启动。选择了一个标签页。这类事件如果它们尚未被加载,有可能会加载新的模块。挑战在于在这些代码模块还在传输中或被评估时,我们能为此类用户做些什么?因为正是我们在等待的代码,所以我们不能执行那些能提供更好加载体验的代码。

例如,在我们有一个模块被加载,以及它所有的依赖都被加载之前,我们无法执行那些对用户感知到的 UI 响应性至关重要的操作。这些操作包括发起 API 调用,以及操纵 DOM 以提供用户反馈。没有来自 API 的数据,我们只能告诉用户,耐心点,东西正在加载! 如果用户因为我们的模块需要一些时间而且加载指示器没有消失而感到沮丧,他们将会开始随机点击看起来可以点击的元素。如果我们没有为这些元素设置任何事件处理程序,那么 UI 将感觉不响应。

以下是示例,展示了导入运行昂贵代码的模块如何阻塞导入模块中代码的运行:

// delay.js

var i = 10000000;

// Eat some CPU cycles, causing a delay in any
// modules that import this one.
console.log('delay', 'active');
while (i--) {
    for (let c = 0; c < 1000; c++) {

    }
}
console.log('delay', 'complete');

// main.js

// Importing this module will block, because
// it runs some expensive code.
import 'delay.js';

// The link is displayed, and it looks clickable,
// but nothing happens. Because there's no event
// handler setup yet.
document.getElementById('do-link')
    .addEventListener('click', function(e) {
        e.preventDefault();
        console.log('clicked');
    });

网络是不可预测的,我们应用后台所面对的规模化的影响者也是如此。用户众多意味着在加载我们的模块时可能会产生高延迟。如果我们想要扩展,就必须考虑这些情况。这涉及到使用策略。在主应用之后我们需要加载的第一个模块,是能够通知用户的功能。

例如,我们的 UI 有一个默认的加载器元素,但是当我们的第一个模块加载时,它会继续渲染关于正在加载的内容以及可能需要多长时间的更详细信息,或者,它可能只需要传达网络或后端存在问题的坏消息。随着我们的扩展,这类不愉快的事件会发生。如果我们想要继续扩展,我们必须尽早考虑这些问题,并使 UI 始终感觉响应灵敏,即使它实际上不是。

通信瓶颈

当我们的应用程序拥有更多的运动部件时,它会增加更多的通信开销。这是因为我们的组件需要彼此通信以实现特性的更大行为。如果我们愿意,我们可以将组件间的通信开销减少到几乎为零,但那样我们将面临单体和重复代码的问题。如果我们想要模块化的组件,通信是必须的,但这需要付出代价。

本节将探讨在我们扩展软件时可能会遇到的一些与通信瓶颈有关的问题。我们需要寻找在不牺牲模块化的情况下提高通信性能的折衷方案。其中最有效的方法之一是使用我们网络浏览器中可用的分析工具。它们可以揭示用户在与我们的 UI 交互时所经历的同等响应问题。

减少间接性

主要的抽象概念是通过事件经纪人实现的,我们的组件通过他彼此间进行通信。经纪人的职责是维护任何给定事件类型的订阅者列表。我们的 JavaScript 应用程序在两个方面可扩展——给定事件类型的订阅者数量和事件类型数量。在性能瓶颈方面,这可能会迅速变得无法控制。

我们首先想要密切关注的是我们特性的构成。实现一个特性时,我们将遵循现有特性的相同模式。这意味着我们将使用相同的组件类型,相同的事件等。虽然有细微的差异,但跨特性的总体模式是相同的。这是一个好习惯:遵循特性间的相同模式。所使用的模式是了解如何减少开销的良好起点。

例如,说我们应用程序中使用的模式需要 8-10 个组件来实现给定特性。这是开销太大。这些组件中的任何一个都要与几个其他组件通信,其中一些抽象概念并没有那么有价值。它们在我们的脑海中和设计纸上看起来很好,因为这是我们设计起源于架构的模式。现在我们已经实现了这个模式,最初的价值已经有点稀释,现在变成了一个性能问题。

接下来是一个示例,它展示了仅仅添加新组件就足以使通信开销成本呈指数级增加:

// component.js
import events from 'events.js';

// A generic component...
export default class Component {

    // When created, this component triggers an
    // event. It also adds a listener for that
    // same event, and does some expensive work.
    constructor() {
        events.trigger('CreateComponent');
        events.listen('CreateComponent', () => {
            var i = 100000;
            while (--i) {
                for (let c = 0; c < 100; c++) {}
            }
        });
    }

};

// main.js
import Component from 'component.js';

// A place to hold our created components...
var components = [];

// Any time the add button is clicked, a new
// component is created. As more and more components
// are added, we can see a noticeable impact on
// the overall latency of the system.
// Click this button for long enough, and the browser
// tab crashes.
document.getElementById('add')
    .addEventListener('click', function() {
        console.clear();
        console.time('event overhead');
        components.push(new Component());
        console.timeEnd('event overhead');
        console.log('components', components.length);
    });

松耦合的组件是一件好事,因为它们分离了关注点,并且给了我们更少的实现风险和更多的实现自由。我们组件之间的耦合方式建立了一个可重复的模式。在初始实现之后的某个时刻,随着我们软件的成熟,我们会意识到曾经很好地服务于我们的模式现在过于复杂。我们组件的关注点已经被很好地理解,我们不再需要我们曾经认为可能需要的实现自由。解决这个问题的是改变模式。模式是被遵循的,所以它是未来我们代码的样子 ultimate indicator。它是解决通信瓶颈的最佳位置,通过移除不必要的组件。

代码分析

我们只需查看我们的代码,就能直观地感觉到有很多不必要的复杂性。正如我们在上一节所看到的,应用程序中使用的组件间通信模式非常明显。我们可以在逻辑设计层面看到过量的组件,但在运行时物理层面呢?

在我们开始重构代码、改变模式、移除组件等之前,我们需要对代码进行基准测试。这将给我们一个关于我们代码的运行时性能特性的想法,而不仅仅是它看起来如何。配置文件为我们提供了我们需要的信息,以对优化做出有用的决策。最重要的是,通过配置我们的代码,我们可以避免对最终用户体验影响很小或没有影响的微优化。至少,我们可以优先解决我们需要处理的性能问题。我们组件之间的通信开销很可能会优先考虑,因为它对用户的影响最直观,并且是一个巨大的扩展障碍。

我们可以使用的第一个工具是浏览器的内置分析工具。我们可以手动使用开发者工具 UI 来分析整个应用程序,同时与之交互。这对于诊断 UI 的具体响应性问题很有用。我们还可以编写使用相同浏览器内分析机制的代码,针对更小的代码片段,如单个函数,并获得相同的输出。结果配置文件实际上是一个调用堆栈,详细介绍了 CPU 时间是如何花费的。这指向了正确的方向,因此我们可以将精力集中在优化昂贵的代码上。

注意

我们只是触及了分析 JavaScript 应用程序性能的表面。这是一个巨大的主题,你可以在 Google 上搜索“分析 JavaScript 代码”-那里有很多好的资源。这是一个让你入门的好资源:developer.chrome.com/devtools/docs/cpu-profiling

下面是一个示例,展示了如何使用浏览器开发者工具创建一个比较几个函数的配置文件:

// Eat some CPU cycles, and call other functions
// to establish a profilable call stack...
function whileLoop() {
    var i = 100000;

    while (--i) {
        forLoop1(i);
        forLoop2(i);
    }
}

// Eat some CPU cycles...
function forLoop1(max) {
    for (var i = 0; i < max; i++) {
        i * i;
    }
}

// Eat less CPU cycles...
function forLoop2(max) {
    max /= 2;
    for (var i = 0; i < max; i ++) {
        i * i;
    }
}

// Creates the profile in the "profile" tab
// of dev tools.
console.profile('main');
whileLoop();
console.profileEnd('main');
// 1177.9ms 1.73% forLoop1
// 1343.2ms 1.98% forLoop2

存在一些可以在浏览器外部分析 JavaScript 代码的工具。我们使用它们各有不同的目的。例如,benchmark.js 以及与之类似的工具,用于测量我们代码的原始性能。输出结果告诉我们每秒我们的代码可以运行多少次操作。这种方法真正有用的地方在于比较两个或更多函数的实现。分析可以为我们提供哪个函数最快,以及优势有多大的详细 breakdown。归根结底,这是我们最需要的重要分析信息。

组件优化

现在我们已经解决了组件通信性能瓶颈的问题,是时候看看我们组件内部了,具体是在实现细节和它们可能带来的性能问题上。例如,维护状态是 JavaScript 组件的一个常见要求,然而,从性能角度来看,这并不容易扩展,因为需要所有的记账代码。我们还需要注意那些修改其他组件使用的数据的函数引入的副作用。最后,DOM 本身以及我们的代码与它交互的方式,有很多可能导致不响应。

维护状态的组件

我们代码中的大多数组件需要维护状态,这在很大程度上是不可避免的。例如,如果我们的组件由一个模型和一个视图组成,视图需要根据模型的状态来决定何时重新渲染自己。视图还持有一个 DOM 元素的引用——直接或通过选择器字符串——而任何给定的元素在任何时候都具有状态。

所以状态是我们组件中的一个事实——这有什么大不了的?实际上,真的没有什么大不了的。事实上,我们可以写出一些真正的事件驱动的代码,这些代码对状态的变化做出反应,从而改变用户所看到的内容。当然,问题出现在我们进行扩展的时候;我们的组件单独来看,需要维护更多的状态,后端提供的数据模型变得更加复杂,DOM 元素也是如此。所有这些具有状态的东西都相互依赖。随着这些系统的增长,会带来大量的复杂性,并且真的可能会损害性能。

幸运的是,我们使用的框架为我们处理了很多这种复杂性。不仅如此——它们还针对这些类型的状态变更操作进行了大量优化,因为这对于使用它们的应用程序来说是如此基础。不同的框架采取不同的方法来处理组件状态的变化。例如,一些采取了更自动化的方法,这需要更多的开销来监控状态的变化。其他的更明确,状态是显式改变的,因此直接结果是事件被触发。后者的方法要求程序员更加自律,但也需要更少的开销。

为了避免随着组件数量及其复杂性的增加可能出现的性能问题,我们可以采取两件事。首先,我们要确保只维护那些重要事物的状态。例如,如果我们为永远不会发生状态变化设置处理程序,这是浪费的。同样地,如果我们有组件状态发生变化并触发永远不会导致 UI 更新的事件,这也是浪费的。虽然难以发现,但如果能避免这些隐藏的宝藏,我们也将避免与响应性相关的未来扩展问题。

维护状态的组件

视图可以对任何模型属性变化做出相同反应;或者,它们可以对特定属性变化有特殊反应。虚拟 DOM 试图为我们自动化这个过程。

处理副作用

在前一部分,我们探讨了组件维护的状态以及如果我们不小心,它们如何影响性能。那么这些状态变化是如何发生的呢?它们不是自发发生的——必须有什么明确地改变变量的值。这称为副作用,是另外一种可能影响性能且不可避免的东西。副作用是我们在前一部分讨论的状态变化的原因,如果不对它们小心处理,它们也会影响性能。

具有副作用的函数相反的是纯函数。这些函数接收输入并返回输出。中间没有状态变化。这类函数具有所谓的引用透明性——这意味着对于给定的输入,无论我们调用函数多少次,我们都保证有相同的输出。这个属性对于优化和并发性等事情很重要。例如,如果对于给定的输入我们总是得到相同的结果,函数调用的时间地点实际上并不重要。

想想我们应用程序中共享的通用组件和特定功能的组件。这些组件不太可能维护状态——状态更有可能存在于更接近 DOM 的组件中。这些顶级组件中的函数是没有副作用实现的好的候选者。甚至我们的功能组件也可能实现没有副作用的函数。作为一个经验法则,我们应该将我们的状态和副作用推送到尽可能接近 DOM 的地方。

正如我们在第四章组件通信与职责所看到的,在大致的发布/订阅事件系统中,要心理追溯正在发生的事情是困难的。有了事件,我们实际上并不需要追踪这些路径,但有了函数,情况就不同了。挑战在于,如果我们的函数改变了某物的状态,并且这导致了系统其他地方的故障,要追踪这类问题是非常困难的。此外,我们使用越多的无副作用函数,就越不需要进行理智检查的代码。我们经常遇到一些检查某物状态的代码片段,看似无原因。原因就在于——这是它工作的方式。这种方法在开发努力的扩展上只能走那么远。

下面是一个展示有副作用函数与副作用函数的例子:

// This function mutates the object that's
// passed in as an argument.
function withSideEffects(model) {
    if (model.state === 'running') {
        model.state = 'off';
    }

    return model;
}

// This function, on the other hand, does not
// introduce side-effects because instead of
// mutating the "model", it returns a new
// instance.
function withoutSideEffects(model) {
    return Object.assign({}, model, model.state === 'off' ?
        { state: 'running' } : {});
}

var first = { state: 'running' },
    second = { state: 'off' },
    result;

// We can see that "withSideEffects()" causes
// some unexpected side-effects because it
// changes the state of something that's used
// elsewhere.
result = withSideEffects(first);
console.log('with side effects...');
console.log('original', first.state);
console.log('result', result.state);

// By creating a new object, "withoutSideEffects()",
// doesn't change the state of anything. It can't
// possibly introduce side-effects somewhere else in
// our code.
result = withoutSideEffects(second);
console.log('without side effects...');
console.log('original', second.state);
console.log('result', result.state);

DOM 渲染技术

更新 DOM 是昂贵的。优化 DOM 更新的最佳方法是不更新它们。换句话说,尽可能少地更新。我们应用扩展的挑战在于 DOM 操作变得更为频繁,出于必要。需要监视的状态更多,需要通知用户的事情也更多。即便如此,除了我们选择的框架所采用的技术外,我们还可以通过编写代码来减轻 DOM 更新的负担。

那么,为什么 DOM 更新相对于在页面中运行的简单 JavaScript 来说如此昂贵呢?确定显示应该看起来怎样的计算过程,消耗了大量的 CPU 周期。我们可以采取措施减轻浏览器渲染引擎的负载,使用在我们的视图组件中需要的更少工作的技术,从而提高 UI 的响应性。

例如,重排是导致一系列需要进行的计算的渲染事件。本质上,重排发生在我们元素的某些方面发生变化时,这可能导致其他附近元素布局的改变。整个过程在整个 DOM 中级联,所以一个看似廉价的 DOM 操作可能造成相当多的开销。现代浏览器中的渲染引擎很快。我们可以在 DOM 代码中有点粗心,UI 将表现完美。但随着新移动部件的增加,DOM 渲染技术的可扩展性就发挥作用了。

因此,首先要考虑的是,哪些视图更新可能导致重排?例如,改变元素的内容不是什么大问题,很可能永远不会导致性能问题。将新元素插入页面中,或者响应用户交互更改现有元素的样式——这些都可能带来响应性问题。

注意

目前流行的一个 DOM 渲染技术是使用虚拟 DOM。ReactJS 和其他类似库利用了这个概念。想法是,我们的代码可以直接将内容渲染到 DOM 中,就像它是在第一次渲染整个组件一样。虚拟 DOM 拦截这些渲染调用,并找出已经渲染的内容和发生变化的内容之间的差异。虚拟 DOM 的名字来源于事实,即 DOM 的表示形式存储在 JavaScript 内存中,并用于进行比较。这样,只有在绝对必要时才会触摸真实的 DOM。这种抽象允许进行一些有趣的优化,同时保持视图代码的简洁性。

不断地向 DOM 发送更新也不是理想的选择。因为 DOM 会接收到需要执行的更改列表,并按顺序应用它们。对于可能引发多次重排的复杂 DOM 更新,最好先卸载 DOM 元素,进行更新,然后重新挂载。当元素重新挂载时,昂贵的重排计算一次性完成,而不是连续几次执行。

然而,有时问题并不在 DOM 本身——而是在 JavaScript 的单线程特性。当我们的组件 JavaScript 正在运行时,DOM 没有机会渲染任何待处理的更新。如果在某些情况下我们的 UI 无响应,最好设置一个超时,让 DOM 更新。这也给了任何待处理的 DOM 事件一个被处理的机会,这对于用户在 JavaScript 代码运行时尝试做某事来说很重要。

接下来是一个示例,展示了如何在 CPU 密集型计算期间延迟运行 JavaScript 代码,给 DOM 一个更新机会:

// This calls the passed-in "func" after setting a
// timeout. This "defers" the call till the next
// available opportunity.
function defer(func, ...args) {
    setTimeout(function() {
        func(...args[0]);
    }, 1);
}

// Perform some expensive work...
function work() {
    var i = 100000;
    while (--i) {
        for (let c = 0; c < 100; c++) {
            i * c;
        }
    }
}

function iterate(coll=[], pos=0) {
    // Eat some CPU cycles...
    work();

    // Update the progress in the DOM...
    document.getElementById('progress').textContent =
        Math.round(pos / coll.length * 100) + '%';

    // Defer the next call to "iterate()", giving the
    // DOM a chance to display the updated percentage.
    if (++pos < coll.length) {
        defer(iterate, [ coll, pos ]);
    }
}

iterate(new Array(1000).fill(true));

Web Workers 是另一种处理长时间运行的 JavaScript 代码的可能性。因为它们不能接触 DOM,所以它们不会影响 DOM 的响应性。然而,这项技术超出了本书的范围。

API 数据

随着我们继续扩展,性能问题的最后一个主要障碍将是应用程序数据本身。这是我们必须特别注意的一个领域,因为有这么多影响扩展的因素在起作用。更多功能并不一定意味着更多数据,但通常确实如此。这意味着更多类型的数据和更多的数据量。后者主要受我们软件不断增长的用户基础的影响。我们作为 JavaScript 架构师的工作是要找出我们如何扩展应用程序,以应对加载时间增加和数据到达浏览器时的数据量增加。

加载延迟

或许对我们应用程序性能扩展的最大威胁就是数据本身。我们应用程序数据随时间变化和演进的方式 somewhat of a phenomenon。我们前端添加的功能确实影响了我们数据的形状,但我们的 JavaScript 代码不控制用户数量或他们与我们的软件互动的方式。后两者可能导致数据爆炸,如果我们的前端没有准备,它将停止运行。

我们作为前端工程师面临的挑战是,当我们等待数据时,用户没有什么可显示的。我们所能做的就是采取必要的步骤,提供一个可接受的加载用户体验。这引出了一个问题——当我们等待数据加载时,我们是否应该用加载信息遮挡整个屏幕,还是为等待数据的元素逐一显示加载信息?第一种方法,用户很少有风险做不允许的事情,因为我们阻止了他们与 UI 交互。第二种方法,我们需要担心在网络请求未完成时用户与 UI 交互。

这两种方法都不理想,因为数据加载的任何时刻,我们应用程序的响应性都会受到根本性的限制。我们不想完全阻止用户与 UI 交互。所以,也许我们需要对数据加载强制执行严格的超时。好处是,我们保证了响应性,即使响应是告知用户后端正在花费太长时间。缺点是,有时等待是必要的,就用户而言,如果需要完成某事。有时,糟糕的用户体验是可取的——而不是无意中创造出更糟糕的体验。

为了帮助后端数据扩展,前端需要做两件事。首先,尽可能地缓存响应。这减轻了后端的负载,而且使用了缓存数据的客户端响应性也更强,因为它无需再次请求。显然,我们需要一种失效机制,因为我们不想缓存过时的数据。WebSocket 在这里是一个很好的解决方案候选——即使它们只通知前端会话某个特定实体类型已更改,以便清除缓存。第二种帮助处理增长数据集的技术是减少任何给定请求加载的数据量。例如,大多数 API 调用都有选项,让我们限制结果的数量。这需要保持在一个合理的范围内。有助于思考用户首先需要查看什么,并围绕这一点进行设计。

处理大数据集的工作

在前一节中,我们讨论了前端开发中与应用程序数据相关的扩展问题。随着我们应用程序的增长,数据也在增长,这带来了一个加载挑战。一旦我们设法将数据加载到浏览器中,我们仍然有很多数据需要处理,这可能导致用户交互不响应。例如,如果我们有一个 1000 项的集合,并且一个事件将这个结构传递给几个组件进行处理,用户体验就会受到影响。我们需要的是帮助我们将大数据集和难以扩展的数据集转换为仅包含必需品的工具。

这就是低级实用库大显身手的地方——对大数据集进行复杂转换。更大的框架可能暴露出类似的工具——它们很可能在幕后使用低级实用工具。我们将要在数据上执行的转换是映射-减少(map-reduce)类型的。无论如何,这是抽象的模式,函数式编程库如 Underscore/lodash 提供了这个模式的许多变体。这如何帮助我们处理大数据集的扩展呢?我们可以编写干净、可复用的映射和减少功能,同时将许多优化工作推迟到这些库中。

注意

理想情况下,我们的应用程序只加载当前页面渲染所需的数据。很多时候这根本不可能——API 不能为我们的功能所需的每个可能的查询场景都做好准备。所以,我们用 API 进行广泛过滤,然后当数据到达时,我们的组件使用更具体的条件对数据进行过滤。

在这里,扩展问题在于后端过滤的内容和浏览器中过滤的内容之间的混淆。如果一个组件更多地依赖 API,而其他组件则在本地进行大部分过滤,这会导致开发者之间的混淆,以及非直观的代码。如果 API 发生微妙变化,甚至可能导致不可预测的错误,因为我们的组件以不同的方式使用它。

映射或减少的时间越少,UI 对用户的响应性越强。这就是为什么我们要尽早获取用户看到的数据非常重要的原因。例如,我们不想在数据一到达就立即在事件中传递 API 数据。我们需要以这样的方式构建组件通信,即在可能的情况下尽快进行计算密集型的集合过滤。这减轻了所有组件的工作负担,因为它们现在正在处理一个较小的集合。因此,扩展到更多组件并不是什么大问题,因为它们将处理更少的数据。

运行时优化组件

我们的代码应该针对常见情况进行优化。这是一个不错的扩展策略,因为随着更多功能和用户的加入,增长的是常见情况,而不是边缘情况。然而,总是有可能我们会处理两个同样常见的案例。想想将我们的软件部署到多个客户环境中的情况。随着功能的发展以满足客户的需求,对于任何给定的功能,可能会有两到三个常见情况。

如果我们有两个处理常见情况的函数,那么我们需要在运行时确定使用哪个函数。这些常见情况非常粗粒度。例如,一个常见情况可能是“集合很大”或“集合很小”。检查这些条件并不昂贵。因此,如果我们能够适应不断变化的常见情况,那么我们的软件将比如果我们不能适应变化条件时的响应性更强。例如,如果集合很大,函数可以采取不同的过滤方法。

在运行时优化组件

组件可以在运行时根据大型或小型集合等粗分类改变其行为。

总结

从用户的角度来看,响应性是质量的一个强烈指标。不响应的用户界面令人沮丧,并且不太可能需要我们在扩展方面做出任何进一步的努力。应用程序的初始加载是用户对我们应用程序的第一个印象,也是最难快速实现的部分。我们研究了将所有资源加载到浏览器中的挑战。这是模块、依赖项和构建工具的组合。

在 JavaScript 应用程序中,响应性的下一个主要障碍是组件间通信的瓶颈。这通常是由于过多的间接性,以及实现特定功能所需的事件设计。组件本身也可能成为响应性的瓶颈,因为 JavaScript 是单线程的。我们讨论了这一领域的几个潜在问题,包括维护状态的成本,以及处理副作用的成本。

API 数据是用户关心的内容,直到我们拥有这些数据,用户体验才会下降。我们研究了扩展 API 及其内部数据带来的扩展问题。一旦我们拥有了数据,我们的组件需要能够快速地映射和减少它,同时数据集在我们扩展时继续增长。现在我们已经有了如何使我们的架构表现良好的更好想法,是时候考虑如何使其在各种环境中具有可测试性和功能性了。

第八章:可移植性与测试

网络应用已经走了很长的路,仅仅几年前还只是简单地在网页中嵌入 JavaScript 代码。如今,我们在构建 JavaScript 应用程序,如果你在读这本书,那么是在构建可扩展的应用程序。这意味着我们的架构需要考虑到可移植性;后端服务于我们的应用程序并为其提供数据,是可替换的。

与可移植性相伴的是测试性的想法。当我们开发大规模的 JavaScript 代码时,我们不能对后端做出假设,这意味着有能力在没有后端的情况下运行。本章将探讨这两个密切相关的话题以及它们在面对不断变化的扩展影响时对我们意味着什么。

解耦后端

如果我们还需要更多的动机来证明 JavaScript 不再只用于可脚本的网页,那就看看 Node.js 吧。它不需要完整的浏览器环境,只需要 V8 JavaScript 引擎。Node 主要是作为后端服务器环境创建的,但它仍然很好地展示了 JavaScript 语言已经取得了多大的进步。同样,我们希望我们的代码是可移植的,能够与任何我们可以投入的后端基础设施一起运行。

在本节中,我们将探讨为什么我们要松耦合我们前端 JavaScript 代码与其后端 API 之间的联系。然后,我们将介绍模拟 API 的第一步,完全不需要后端。

模拟后端 API

如果我们正在开发一个大规模的 JavaScript 应用程序,我们将有一个后端基础设施的初步构建。那么,为什么我们还要考虑将我们的代码与这个后端分离,使其不再依赖于它呢?在追求可扩展性时,支持松耦合的组件总是好的,这对于 Web 应用程序中前端和后端环境之间的耦合也是正确的。即使后端 API 永远不会改变,我们也不能假设构建 API 所使用的技术和框架永远不会改变。松耦合这种依赖关系还有其他好处——比如能够独立于系统其他部分更新 UI。但模拟后端 API 的主要扩展好处来自于开发和测试的角度。能够快速搭建新的 API 端点并对其进行请求测试是没有替代品的。模拟 API 是我们 JavaScript 代码的碰撞测试假人。

不管喜欢与否,有时我们感觉自己好像在创建演示软件——在开发冲刺中间,我们必须向感兴趣的利益相关者展示我们所拥有的东西。与其让这导致绝望,我们应该从我们的模拟数据中获得信心。演示不再是大问题,而且有了我们模拟数据的信心,我们将开始将这些事件视为对自己的小挑战。当然,我们总是要维护一个英雄程序员的外表——为了管理人员的利益!

考虑到模拟数据有多棒,那么它的缺点是什么呢?就像我们产品中的任何东西一样,它是一种需要维护的软件——这总是伴随着风险。例如,如果模拟 API 与实际 API 不同步,或者它 creates confusion between what's functional in the UI versus what's mocked,那么它的价值就会降低。为了应对这些风险,我们必须制定围绕我们设计和实现功能的过程,我们稍后会讨论这些。

模拟后端 API

模拟 API 位于任何与实际 API 通信的组件之外;当移除模拟时,组件并不会知道更好

前端入口点

前端和后端接口的边界在哪里?这是我们希望进行切换的地方,在模拟数据和 API 正常返回的数据之间。这个边界实际上可能位于 web 服务器后面——在这种情况下,我们仍然在进行真实的 HTTP 请求,只是没有与真实应用程序交互。在其他情况下,我们完全在浏览器中进行模拟,HTTP 请求在离开浏览器之前被模拟库处理程序拦截。

在两种模拟方式中,我们前端应用程序之间都有一个概念上的边界——这是我们试图建立的。一旦我们找到它,这是关键,因为它代表了我们与后端的独立性。在生产中紧密耦合后端并没有什么问题——那就是它的目的。在其他情况下,例如在开发过程中,能够编排我们的组件发送 API 请求时发生的事情,是一种关键的扩展策略。

有可能直接使用模型和集合创建模拟数据模块。例如,如果我们正在运行在模拟模式,我们会导入这个模块,我们就会有模拟数据可以工作。这种方法的问题是,我们的应用程序知道它实际上并没有与后端真正工作。我们不希望这样。因为我们希望我们的代码运行得好像它在生产环境中运行一样。否则,我们将会经历手动实例化模拟的一些副作用——它需要尽可能地远离我们实际的代码。

无论我们决定采用哪种模拟机制,它都需要是模块化的。换句话说,我们需要有能力将其关闭并完全从构建中移除。在生产环境中,不应该有模拟。实际上,我们的模拟代码甚至不应该出现在生产构建中。如果我们通过 web 服务器提供模拟数据,这一点要容易实现一些。如果我们的模拟处理程序存在于浏览器中,我们需要以某种方式将它们移除,这需要某种构建选项。关于构建工具,我们稍后在第章节中会有更多介绍。

前端入口点

在浏览器中模拟 API 请求,拦截 XHR 级别的调用。如果有模拟代码,它会寻找模拟 API。当模拟被移除时,原生 HTTP 请求功能如常。

模拟工具

如前一部分所述,模拟后端 API 主要有两种方法。第一种方法是引入像 Mockjax 这样的库到我们的应用程序中以拦截 XHR 请求。第二种方法是在那里放置一个真实的 HTTP 服务器,但这个服务器实际上并没有接触到真正的应用程序——它像 Mockjax 方法一样提供模拟数据。

Mockjax 的工作方式简单而巧妙。它基于这样的假设:应用程序正在使用 jQuery ajax()调用来进行 HTTP 请求,这是一个相对安全的假设,因为大多数框架都在幕后使用这个。当调用 Mockjax 时,它用自己的功能覆盖了一些核心 jQuery XHR 功能。这是在每次进行 XHR 请求时运行的。它检查是否有与请求 URI 匹配的路由规范,如果找到,就会运行处理程序。否则,它将直接传递并尝试向后端发起请求——如果我们想将真实 API 请求与模拟请求结合起来,这还是挺有用的。我们稍后会深入研究这种结合。

任何给定的处理程序都可以返回 JSON 数据,或者任何其他格式,就像我们真实的 API 一样。关键在于我们的核心代码——我们的模型和集合初始化请求——对 Mockjax 一无所知,因为所有这些都是在更低的层次上发生的。同样的模型和集合代码在没有对生产后端进行修改的情况下运行。我们只需要在部署到真实 API 时,拔掉调用 Mockjax 的模块即可。

我们可以使用模拟 Web 服务器技术实现相同的属性——运行未修改的代码。这实际上是劫持 XHR 请求的完全相同的想法,只是在一个不同的层面上进行。主要优点是我们不需要在部署过程中采取任何特殊步骤。要么是模拟服务器,要么是真实服务器,在生产环境中,不太可能运行模拟服务器。缺点是我们确实需要一个正在运行的服务器,这对我们要求不高——但这确实是一个额外的步骤。而且我们确实失去了一些可移植性。例如,我们可以打包一个模拟构建发送给某人。如果它不需要 Web 服务器,整个应用程序可以在浏览器中演示。

模拟工具

从浏览器或后台 Web 服务器模拟 API;两种方法达到相同的结果——我们的代码不知道它正在与模拟通信。

生成模拟数据集

既然我们已经知道声明模拟 API 端点的选项,那么我们需要数据。假设我们的 API 返回 JSON 数据,我们可以将模拟数据存储在 JSON 文件中。例如,模拟模块可以将这些 JSON 模块作为依赖项引入,模拟处理程序可以将其作为数据源。但是这些数据从哪里来呢?

当我们开始构建模拟数据时,很可能存在一个 API,它正在某个地方运行。使用我们的浏览器,我们可以查看各种 API 端点返回的数据,并手动策划我们的模拟数据。如果 API 有文档,这个过程会简单得多,因为那样我们就会有线索,了解任何给定实体中任何给定字段允许的值。有时我们实际上没有创建模拟数据的起点——我们将在功能设计过程部分讨论这个问题。

手动创建我们的模拟数据集的优点是,我们可以确保它是准确的。也就是说,我们不希望创建与我们要模拟的数据不反映的东西,因为这将是整个目的的失败。更不用说跟上 API 变化的速度瓶颈了。理想的情况是使用一个工具来自动化生成模拟数据集的任务。它只需要知道给定实体的模式,然后就可以处理剩余的工作,接受几个参数并在其中加入一些随机性。

另一个有用的模拟数据生成工具可能是从给定部署中提取真实 API 数据的功能,并将其作为 JSON 文件存储。例如,假设有一个预演环境,我们的代码表现出问题。我们可以针对该环境运行我们的数据提取工具以获取所需的数据。由于我们希望尽量保持预演环境不变,这种方法是安全的,因为我们在诊断过程中对模拟数据造成的任何损害,都在内存中,可以轻松清除。

执行操作

实施模拟 API 的一个挑战性方面是执行操作。这些是除了 GET 以外的请求,通常需要改变某个资源的状态。例如,改变资源属性的值,或者彻底删除资源。我们需要一些通用的代码,我们的处理程序可以利用它来执行这些操作,因为我们的 API 端点在执行它们上的动作时应该遵循相同的模式。

实际上能否容易地实施取决于我们 API 动作工作流的复杂度。一个容易实施的动作可能就是修改资源的一个属性值然后返回200表示成功。然而,我们的应用程序很可能有更复杂的工作流,比如长时间运行的动作。例如,这类动作可能会返回一个新创建的动作资源的 ID,从那里,我们需要监控该动作的状态。我们的前端代码已经做到了这一点,因为那正是它需要与真实 API 一起工作的地方——我们需要在模拟中实现这些应用程序的细微差别。

操作可能会很快变得非常复杂。尤其是如果应用程序很大,有很多实体类型和很多操作。这里的想法是努力实现模拟这些操作的最小可行性成功路径。不要试图详细地模拟应用程序所做的每一件事——这不会扩展。

功能设计流程

我们不是为了好玩而创建模拟 API,我们是为了帮助开发功能。考虑到我们可能有一个相当大的 API,因此有很多要模拟的内容,我们需要一个过程来规范我们做事情的顺序。例如,我们需要等待 API 实施后再开始实现一个功能吗?如果我们能够模拟 API,那么我们就不必等待,但是 API 本身仍然需要设计,而且 API 有许多利益相关者。

在本节中,我们将回顾一些确保我们正确使用模拟,并以与我们的功能开发同步的方式进行操作的必要步骤。

设计 API

一些 API 端点足够通用,可以支持多个功能。这些是我们应用程序中的核心实体。通常,有一小部分实体扮演着至关重要的角色,大多数功能都会使用它们。另一方面,我们开发的大多数新功能将需要扩展我们的 API。这意味着一个新的 API 端点,或者几个。这取决于我们的后端资源是如何组合的,这涉及到一定程度的设计工作。

试图扩展我们的功能开发的问题在于,实现一个新的 API 可能需要花费很长时间。所以如果我们需要在开始开发前端功能之前就有 API,我们最终会推迟功能,这并不是理想的。我们希望在新鲜的时候开始做某件事。如果某件事在待办事项列表中积压,它经常永远留在那里。为拟议的功能实现一个模拟 API 让我们可以不拖延地开始滚动,这对于扩展开发是至关重要的。

当我们实现一个新 API 端点的模拟时,我们进入了绿色地带设计领域。这意味着我们必须考虑到那些可能不一定会进行前端开发的人的考虑。而且我们可能触及也可能不触及真实 API 的实际实现——这完全取决于我们的团队结构。话说回来,无论主题专家是谁,他们都需要透明地访问我们拟议的 API 的设计。他们可以提供建议、进行更改等等。继续走不可能的道路是没有意义的。另一种方法可能是让后端程序员草拟一个可能的 API 规范。这是纯粹的大局观;只包含最基本的端点,带有最小的属性和操作。其他的都是可以在我们模拟和实际代码之后轻易更改的细节。

在接触后端代码之前,使用模拟 API 实现功能可以帮助防止犯下昂贵的错误。例如,假设我们使用模拟 API 在前端实现了一些功能,直到它具有可演示性。这给了具有特定后端领域知识的工程师一个机会来指出功能的不可行性,从而让我们避免在未来犯下昂贵的错误。

设计 API

设计模拟 API 的循环,以及针对它实现功能

实现模拟

现在我们已经接到实现一个功能的任务,第一步是实现一个模拟 API 来支持我们前端代码的开发。正如我们在上一节所看到的,我们应该与最终将实现真实 API 的人紧密合作。第一步是要确定高层次的 API 看起来是什么样的。其余的我们可以随着我们接近实现真实 API 而进行微调。

然而,在开发我们的模拟数据时,我们并不总是必须依赖 API 团队成员的手把手指导。我们可能有一些 API 端点,它们可能已经被我们的一些前端组件使用。话说回来,可能有一个可识别的模式我们可以遵循,尤其是如果模拟只是一个我们碰巧缺失的平凡实体类型的话。如果我们遵循一个好的模式,那么这就是一个好的起点,因为以后进行激进更改的机会更小。

当我们知道我们的模拟 API 看起来是什么样子,以及我们可以对其做些什么时,我们需要用模拟数据来填充它。如果我们已经有一些为其他模拟生成数据的工具,我们需要找出如何扩展这些工具。或者,我们只需要手动创建一些测试实体来开始。我们不想在前面花费太多时间输入数据。我们只需要最少的有效实体数量来证明我们的方法是可行的。

提示

我们可能并不总是想在创建数据之前就启动实际的模拟端点。相反,我们可能更愿意从数据出发,向上设计——设计正确的实体,而不是担心 API 本身的技术细节。这是因为,数据最终需要在某个地方进行存储,这是一个重要的活动。专注于数据让我们以不同的思维方式工作。选择最适合手头任务的处理方法。

我们所创建的模拟并不总是创造全新的东西。也就是说,我们模拟的 API 可能已经存在,或者其实现正在进行中。这实际上使得模拟的实现变得容易得多,因为我们可以向 API 作者请求示例数据,或者寻求帮助,以构建我们的模拟。记住,如果我们想要实现可移植性,我们必须能够将前端从后端中分离出来,这意味着我们需要模拟整个 API。

实施功能

现在我们已经有了我们的模拟 API,是时候受益了。事情并没有结束——模拟 API 经常进行微调。但这足以让我们开始编写真实的前端代码。立即,我们会发现一些问题。这些问题可能是拟议的 API 的问题,或者是与 API 通信的组件的问题。我们不能让这些问题沮丧,因为这正是我们所寻找的——早期发现问题。没有模拟 API 是无法获得这些的。

如果 API 普遍可行,而且我们的组件代码工作正常,我们可能会发现我们设计中的性能瓶颈。如果我们有生成模拟数据的工具,这尤其容易发现,因为生成 100,000 个实体轻而易举,看看我们的前端代码会发生什么。有时这需要快速重构,有时则需要完全改变方法。关键是我们要尽早而不是稍后找到这些问题。

我们可以通过模拟来做一件其他难以实现的事情,那就是经常进行演示。当我们严重依赖具有大量开销的大型后端环境时,这并不容易。如果少于几分钟就能让一个功能运行起来进行演示,我们可以自信地展示我们所做的。也许它是错误的,也许利益相关者会想到一些他们错过的事情,当他们看到他们的想法变为现实时。这就是模拟如何帮助我们通过早期和持续的反馈来扩展特征开发生命周期。

实施功能

正在开发中的组件的内部,与模拟 API 端点通信

将模拟数据与 API 数据协调一致

此时,功能已经实现,我们如何协调为功能创建的模拟数据取决于实际 API 的状态。例如,如果我们只是模拟 API 中已经存在一段时间的东西,那么只要我们模拟和真实数据之间有高保真度,就可以安全地假设什么也不需要发生。然而,如果我们模拟的是一个全新的 API,有很大几率会发生一些变化,哪怕是微小的变化。重要的是我们要捕捉这些变化,确保我们的现有模拟数据在后续版本中保持相关性。

这是模拟过程中难以扩展且通常令人不愉快的一部分。我们的模拟数据有如此多的不同方式与实际 API 中的数据不同步,以至于很难尝试去跟上。如果我们有生成模拟数据的工具,那就容易多了。我们甚至可能能够根据 API 团队创建的规范生成整个 API。但这也存在问题,因为虽然模拟生成可以自动化,但规范本身需要在某个地方、以某种方式创建。因此,最好实现一个可以生成模拟数据的工具,但让我们的代码处理请求。只要我们不要重复自己太多,并且 API 有一个合理的模式,我们应该能够跟上我们的模拟数据。

另一种做法是在关闭某些模拟 API 端点的同时保留其他端点。可以把它看作是一种穿透——在这里,可以指定模拟端点的粒度,而不是只能切换整个模拟 API。例如,这种能力如果在调试应用程序中的特定问题时会非常有用,我们需要引导某些 API 端点返回特定的响应以复制问题。我们可以在 Mockjax 等库中实现这一点,因为不匹配请求路径规的请求只是被转发给本地的 XHR 机制。

将模拟数据与 API 数据协调一致

一个组件使用模拟 API,而另一个使用实际 API

单元测试工具

是时候将注意力转向测试了,我们在学习了大规模模拟 API 端点的基础知识之后。我们模拟 API 的能力对于测试代码非常有用,因为我们可以使用同样的模拟数据或至少是同样的数据来进行测试。这意味着,如果我们的测试失败,我们可以开始与 UI 交互(如果需要的话),使用测试失败的相同数据,试图找出发生了什么。

我们将探讨使用随 JavaScript 框架提供的单元测试工具,并找出它们的价值观所在。我们还将研究使用更通用的独立测试框架,这些框架可以与任何代码一起运行。在本节结束时,我们将看看我们的测试如何自动化,以及这种自动化如何融入我们的开发工作流程。

框架内置工具

如果我们使用的是较大型的、全面的 JavaScript 应用程序框架,那么有很大概率它会自带一些单元测试工具。这些工具并不是要取代框架无关的现有单元测试工具。相反,它们是为了补充这些工具——为编写符合框架口味的测试提供特定支持。

对我们来说,这意味着我们需要编写更少的单元测试代码。如果我们遵循框架的模式,那么已经有很多单元测试工具了解我们的代码。例如,如果它已经知道我们将使用哪些组件来实现我们的功能,那么它可以为我们生成测试。这极大地帮助我们避免重复,并最终使我们的代码获得更全面的测试覆盖。

除了为我们生成测试骨架之外,框架测试设施还可以为我们提供测试中可用的实用函数。这意味着我们无需维护那么多单元测试代码,这之所以可能,是因为框架知道我们将在测试中想要做什么,并以实用函数的形式为我们抽象出这些操作。

依赖框架特定的测试工具的挑战在于,我们将把我们的产品与特定的框架耦合在一起。这对我们来说可能不是一个问题,因为一旦选定了一个框架,我们就会坚持使用它,对吧?嗯,不一定。在今天动荡的 JavaScript 生态系统中更是如此。在可移植性方面的一部分要求我们的架构具有一定程度的灵活性,意味着我们必须适应变化。这或许也是为什么如今越来越多的项目依赖于大型框架,而更多依赖于库的组合。

框架内建的工具

单元测试与框架的组件和单元测试工具有紧密的耦合关系

注意

在大型 JavaScript 应用程序中有很多异步代码,我们的单元测试不应忽略这些异步代码。例如,我们需要确保我们的模型单元能够获取数据并执行操作。这类函数返回承诺,我们需要确保它们如预期般正确解决或失败。

使用模拟 API 可以大大简化这一过程。无论是采用浏览器内方法还是 Web 服务器方法都可以,因为我们的代码仍然将它们视为真正的异步操作。我们可能还需要考虑模拟的是 WebSocket 连接。在浏览器中这样做稍微有些棘手,因为我们必须覆盖内置的 WebSocket 类。如果我们的模拟位于 Web 服务器后面,我们可以使用真实的 WebSocket 连接进行测试。

无论如何,模拟 WebSocket 都是困难的,因为我们必须模拟在某些其他事情发生时触发 WebSocket 消息的逻辑,例如 API 操作。然而,在获得更基本的测试覆盖后,我们仍然可能想要考虑模拟 WebSocket,因为如果我们的应用程序依赖于它们,自动化测试它们是很重要的。

独立的单元测试工具

单元测试工具的另一种方法是使用独立的框架。也就是说,一个不关心我们使用哪个 JavaScript 应用程序框架或库的单元测试工具。Jasmine 是这一目的的标准,因为它为我们提供了一种清晰简洁的方式来声明测试规格。开箱即用,它有一个在浏览器中工作的测试运行器,为我们提供了格式化的测试通过和测试失败的输出。

大多数其他独立的单元测试设施都使用 Jasmine 作为基础,并扩展它以提供额外的功能。例如,有 Jest 项目,它本质上是对 Jasmine 进行了扩展,增加了模块加载和模拟等功能。同样,这种类型的工具是框架无关的;它纯粹关注测试。使用这些独立的工具进行单元测试是一个很好的可移植性策略,因为这意味着,如果我们决定将代码转移到不同的技术,我们的测试仍然有效,并且实际上可以帮助使过渡顺利进行。

Jasmine 并不是市面上唯一的游戏,它只是最通用,给了我们在结构测试方面很大的自由。例如,Qunit 已经存在很长时间了。它适用于任何框架,但最初是为 jQuery 项目设计的测试工具。如果我们觉得现有的测试工具太重,或者不给我们项目所需的灵活性或输出,我们甚至可能想要自己开发测试工具。我们可能不想编写自己的测试运行器。我们的单元测试不是随意运行的, whenever we feel like it。它们通常是我们要自动化的大量任务链的一部分。

注意

有些代码比其他代码更容易测试。这意味着,根据我们的组件是如何组织的,可能很容易将它们分解为可测试的单元,或者可能很难。例如,具有许多活动部件和许多副作用的代码意味着,如果我们想要在组件上获得良好的测试覆盖率,我们必须为这个组件编写相对较大的测试套件。如果我们代码的耦合度较低,副作用较少,那么编写测试就会容易得多。

虽然我们希望编写可测试的代码,以使编写单元测试的过程更容易,但并不总是可能的。所以如果这意味着牺牲覆盖率,有时候这是更好的选择。我们不想为了编写更多的测试而重写代码,或者更糟糕的是,改变我们满意的架构。只有当我们认为我们的组件足够大,值得有更全面的测试覆盖时,我们才应该这样做。如果到了这个地步,我们可能需要重新思考我们的设计。好的代码自然容易测试。

工具链和自动化

随着我们的应用程序变得更大更复杂,有很多事情需要在“离线”状态下进行,作为持续开发过程的一部分。运行单元测试是我们希望自动化的任务之一。例如,在我们甚至运行测试之前,我们可能需要使用一个工具来检查我们的代码,以确保我们没有提交太草率的代码。测试通过后,我们可能需要构建我们的组件工件,以便它们可以被我们应用程序的运行实例使用。如果我们正在生成模拟数据,这可能也是同一过程的一部分。

总的来说,我们有一个工具链可以自动化所有这些任务。这些任务通常是一个更大、更粗粒度任务中的较小步骤,比如构建生产构建开发。更复杂的任务只是我们定义的较小任务的组合。这是一种灵活的方法,因为工具链可以处理任务的顺序,按照它们需要发生的顺序,或者,我们可以单独运行任务。例如,我们可能只想运行测试。

最流行的工具链是一个任务运行器,名为 Grunt。其他类似的工具,如 Gulp,也越来越受欢迎。这些工具的好处在于它们有一个充满插件的活跃生态系统,这些插件能完成我们大部分需要做的事情——我们只需要配置使用这些插件的个别任务以及我们想要组合的更复杂的任务。对于我们来说,设置一个可以自动化我们开发过程大部分步骤的工具链,所需的努力非常小——基本上除了编写代码本身,其他事情都可以自动化。如果没有工具链,要使我们的开发工作扩展到多于几个贡献者,会非常困难甚至是不可能的。

使用工具链进行自动化任务的另一个好处是我们可以随时更改正在构建的工件类型。例如,当我们正中间开发一个功能时,我们不一定希望每次更改都构建生产工件。实际上这样做会大大减慢我们的速度。如果我们的工具可以仅仅部署原始源模块,那也会使调试变得容易很多。然后当我们接近完成时,我们开始构建生产版本,并针对那些版本进行测试。我们的单元测试可以同时针对原始源代码和生成的工件构建运行——因为我们永远不知道编译后可能会引入什么。

测试模拟场景

我们的应用程序规模越大,它需要处理的场景就越多。这是因为更多用户使用更多功能,我们的代码需要处理的复杂性也随之增加。拥有模拟数据和单元测试确实可以帮助我们测试这些场景。在本节中,我们将介绍一些可用于创建这些模拟场景并对其进行测试的选项,包括我们的单元测试以及像用户一样与系统交互。

模拟 API 和测试数据

模拟数据对我们很有价值,其中之一就是单元测试。如果我们模拟 API,我们可以运行我们的单元测试,好像我们的代码正在击中真实的 API。我们对模拟数据中的个别数据点有精细的控制,并且可以随意更改——它是沙盒中的数据,对外部世界没有负面影响。即使我们使用工具生成模拟数据,我们也可以进去调整。

一些单元测试工具接受测试数据,这些数据仅用于运行测试。这和我们在像 Mockjax 这样的 API 模拟工具中使用的数据并没有太大区别。主要区别是,测试数据在我们使用的单元测试框架之外并没有多大用处。

那么,如果我们既能用于测试又能用于模拟呢?例如,假设我们想利用单元测试框架的测试数据功能。它有一些自动化特性,如果我们不提供测试数据,我们就无法使用。另一方面,我们还想为开发目的模拟 API,以便与功能交互,与后端分离等。没有任何东西阻止我们将测试数据既用于单元测试,又用于 API 模拟。这样,我们就可以使用我们创建的任何模拟数据生成器来生成测试和浏览器中用户交互共享的场景。

模拟 API 和测试数据

单元测试可以通过请求击中模拟 API,或者直接使用测试数据;如果模拟 API 提供相同的数据,那么更容易找出失败测试中的问题。

场景生成工具

随着时间的推移,我们将积累新的功能和更多客户使用这些功能的场景。因此,我们工具链中有一个生成模拟数据的工具将非常有帮助。更进一步,这个工具可以接受生成模拟数据的参数。这些参数可能只是粗粒度的,但我们通常只需要将随机生成的模拟数据转换为我们需要的精心策划的场景。

我们将生成的单个模拟场景彼此之间不会有太大差异。这是有点意思的地方——我们需要一个作为基线的东西,这样如果我们确实对我们的场景做出了有趣的发现,我们可以问——这个数据有什么不同? 如果我们开始生成很多场景,因为我们有一个可以让我们这样做工具,我们需要确保我们确实有一个“黄金”模拟数据集——这是我们确信其按预期工作的东西。

我们需要对黄金模拟数据进行的更改类型包括更改集合中实体的数量等。例如,假设我们想看看某事物在给定页面上的表现如何。那么我们创建一百万个模拟实体,看看会发生什么。页面完全崩溃——进一步调查发现了一个reduce()函数,该函数试图对一个大于最大安全整数的数字进行求和。这种情况可以揭示有趣的错误。即使我们使用的场景牵强附会,不太可能在生产中发生,我们仍然应该修复错误,因为其他不那么极端的场景肯定会导致它触发。

场景生成工具

更改场景可能会导致我们的测试失败;通常我们会创建扩展场景,看看我们的代码在哪里崩溃

我们可以模拟大量的可能性。例如,我们可以通过删除实体的属性来扭曲一些数据,确保我们的前端组件对它期望的东西有合理的默认值,或者它以优雅的方式失败。后者实际上非常重要。随着我们扩展 JavaScript 代码,我们有越来越多无法修复的场景,我们只需要确保我们的失败模式是可以接受的。

端到端测试和持续集成

最后一步是将端到端测试组合到我们的功能中,并将其连接到我们的持续集成过程中。单元测试是一回事,它们让我们确信我们的组件是坚固的——当它们通过时。用户不在乎单元测试,端到端测试作为与我们的 UI 交互的用户的代表。例如,可能在我们实施的任何给定功能的规格说明中嵌入了一组用例。端到端测试应该围绕这些用例设计。

像 Selenium 这样的工具使自动化端到端测试成为可能。它们将测试记录为作为用户执行的一系列步骤。同样的步骤可以在我们告诉它时重复。例如,一个端到端测试可能涉及资源的创建、修改和删除。该工具知道在 UI 中寻找成功路径的什么。当这种情况不发生时,我们知道测试失败了,我们需要去修复它。自动化这类测试对于扩展是至关重要的,因为随着我们添加功能,用户与我们的应用程序互动的方式越来越多。

我们再次可以向我们的工具链寻求帮助,因为既然它已经在自动化我们所有的其他任务,它可能也应该自动化我们的端到端测试。工具链对于我们的持续集成过程也是至关重要的。我们可能会共享一个 CI 服务器来构建我们系统的其他方面,只是它们是以不同的方式完成的。工具链使我们容易与 CI 过程集成,因为我们只需要脚本适当的工具链命令即可。

在系统中设置模拟数据可以帮助我们进行端到端的测试,因为如果工具要像用户那样操作,那么它就必须发出后端 API 请求。这样做可以确保我们的一致性,并帮助我们排除测试本身作为问题来源的可能。借助模拟 API,我们可以开发单元测试,并对同一来源进行端到端的测试。

端到端测试和持续集成

工具链、模拟数据以及我们的测试,所有这些都运行在 CI 环境中;我们所开发的代码是输入。

概要

本章介绍了前端 JavaScript 应用程序的可移植性概念。在此上下文中,可移植性意味着与后端不是紧密耦合的。具有可移植性的主要优势在于,我们可以将 UI 视为其独立的应用程序,它不需要任何特定的后端技术。

为了帮助我们的前端实现独立,我们可以模拟它依赖的后端 API。模拟也让我们可以严格专注于 UI 开发——消除了后端问题阻碍我们开发的的可能性。

模拟数据可以帮助我们测试代码。有许多单元测试库,每个库都有自己的方法,我们可以利用它们。如果我们使用相同的模拟数据来运行测试,那么我们可以在浏览器中看到的不一致性中排除不一致性。我们的测试需要自动化,还有其他几个发生在我们开发过程中的任务也需要自动化。

我们所实现的工具链与持续集成服务器完美契合——这是实现规模扩张的关键工具。端到端的测试也是在这里自动完成的,这使我们能更好地了解用户在使用我们的软件时可能会遇到的问题。现在,是时候转换思路,认真审视应用扩展的局限性了。我们不能无限扩展,下一章节将探讨在我们达到一定规模后如何避免撞墙。

第九章:缩减扩展

我们倾向于认为扩展是一个单向问题——我们只能从当前的位置向上扩展。不幸的是,这并不完全有效。我们只能在一条线上扩展这么久,然后基础就会在我们脚下崩溃。关键在于识别扩展限制,并围绕它们进行设计。

在本章中,我们将探讨几乎所有浏览器环境中 JavaScript 架构师面临的根本性扩展限制。我们还将探讨客户作为扩展影响因素,以及新特性与现有特性之间的冲突。从过度设计中缩减也是一项基本活动。

我们整个应用程序的组成决定了通过关闭特性来缩减扩展的难易程度。这一切都取决于耦合,如果我们仔细观察,我们经常会发现我们需要重构我们的组件,以便它们可以稍后轻松移除。

扩展限制

我们的应用程序受到它们运行的环境的限制。这意味着客户机上运行的硬件和浏览器本身。有趣的是,网络应用程序还需要考虑代码本身的传输。例如,如果我们正在编写后端代码,我们可以向任何问题投入更多的代码,这不是问题,因为代码不会移动——它在一个地方运行。

对于 JavaScript 来说,大小很重要。这一点是无法回避的。作为推论,网络带宽也很重要——既包括我们的 JavaScript 工件的交付,也包括从 API 获取我们的应用程序数据。

在本节中,我们将解决浏览器计算环境中对我们施加的硬性扩展限制。随着我们的应用程序的增长,我们感受到这些限制的压力越来越多。在为我们的应用程序规划新特性时,需要考虑这些方面。

JavaScript 工件大小

我们的 JavaScript 工件的累计大小只能增长到一定程度。最终,我们的应用程序的加载时间将会受到严重影响,以至于没有人愿意使用我们的应用程序。巨大的 JavaScript 工件通常意味着其他区域的过度膨胀。例如,如果我们向浏览器交付巨大的文件,我们可能有过多的东西。也许我们不需要那些没有人使用的特性,或者也许我们的组件中有重复的代码。

无论原因是什么,效果都不好。越小越好。我们如何知道我们的 JavaScript 文件大小是否足够小呢?这取决于——没有普遍的“理想”大小。我们的应用程序部署在哪里,是在公共互联网上?还是企业用户背后的 VPN?这些系统的用户可能有不同的接受标准。总的来说,公共互联网用户对我们加载时间的性能和功能膨胀的容忍度较低。另一方面,企业用户通常更欣赏更多功能,并对加载时间的不佳更加宽容。

不断添加到我们产品中的新功能是 JavaScript 文件大小增长的最大贡献者。这些导致了新组件的增加,从而增加了重量。任何给定功能都至少有最小文件集,每个文件都是遵循我们现有功能模式的组件。如果我们的一半模式还可以,那么我们应该能够保持我们组件的大小合理。然而,当截止日期涉及时,重复的代码总是找到进入应用程序的方式。即使我们的代码尽可能精简,当被要求实现功能时,我们仍然必须实现它们。

编译过的文件可以帮助我们解决文件大小的问题。我们可以合并和压缩文件,减少网络请求次数,节省总体带宽。但是,任何特定功能都会使这些编译过的文件持续增长。我们可以在遇到任何问题之前持续增长一段时间。如前所述,问题都是相对的,取决于环境和我们软件的用户。在所有情况下,我们的 JavaScript 文件的大小不能无限增长。

JavaScript 文件大小

JavaScript 文件的大小是组成组件的所有模块的聚合结果。

网络带宽

我们的 JavaScript 文件的大小贡献了我们的应用程序整体网络带宽消耗。尤其是随着更多用户的采用——用户是我们所有架构问题的乘数。与我们的 JavaScript 代码相结合的是我们的应用程序数据。这些 API 调用也贡献了整体网络带宽消耗和用户感知的延迟。

小贴士

随着我们的应用程序跨越地理边界,我们会注意到各种连接问题。在世界上许多地方,高速网络根本不是一个选项。如果我们想要进入这些市场,而且我们应该这么做,那么我们的架构需要能够应对缓慢的互联网连接。使用 CDN 传递我们的应用程序所使用的库可以帮助解决这个问题,因为它们考虑了请求的地理位置。

挑战在于,任何新功能都会增加新的网络带宽消耗。这包括代码的大小,以及新组件引入的新 API 调用。请注意,这些效果并不会立即显现。例如,新组件在页面加载时不会进行 API 调用,只有在用户导航到特定的 URI 时才会进行 API 调用。

尽管如此,新的 API 端点意味着随着时间的推移网络带宽使用会增加。此外,当用户导航到功能页面时,并不是只做一个 API 调用那么简单。有时需要三个或更多的 API 调用,以便构建要呈现的数据。当我们认为一个新的 API 调用不是什么大问题时,我们需要记住,通常这会变成多个调用,这意味着更多的带宽消耗。

是否存在根本的网络带宽限制?从理论上讲,并不存在,就像我们的 JavaScript 资源大小一样——如果我们愿意,可以把它们扩展到每个 10MB。我们可以肯定的是,这不会改善用户体验,而且副作用可能会导致更糟糕的体验。网络带宽消耗也是如此。

网络带宽

组件通过请求 JavaScript 模块和 API 数据来消耗网络带宽

下面是一个例子,展示了我们的应用程序随着更多请求的发出而遭受聚合延迟的痛苦:

// model.js
// A model with a fake "fetch()" method that doesn't
// actually set any data.
export default class Model {

    fetch() {

        // Returns a promise so the caller can work
        // with this asynchronous method. It resolves
        // after 1 second, meant to simulate a real
        // network request.
        var promise = new Promise((resolve, reject) => {
            setTimeout(() => resolve(), 1000);
        });

        return promise;
    }

};

// main.js
import Model from 'model.js';

function onRequestsInput(e) {
    var size = +e.target.value,
        cnt = 0,
        models = [];

    // Create some models, based on the "requests"
    // number.
    while (cnt++ < size) {
        models.push(new Model());
    }

    // Setup a timer, so we can see how long it
    // takes to fetch all these models.
    console.clear();
    console.time(`fetched ${models.length} models`);

    // Use "Promise.all()" to synchronize the fetches
    // of each model. When they're all done, we can stop
    // the timer.
    Promise.all(models.map(item => item.fetch())).then(() => {
        console.timeEnd(`fetched ${models.length} models`);
    });
}

// Setup our DOM listener, so we know how many
// models to create and fetch based on the "requests"
// input.
var requests = document.getElementById('requests');

requests.addEventListener('input', onRequestsInput);
requests.dispatchEvent(new Event('input'));

内存消耗

随着我们实现每一个功能,浏览器消耗的内存也在增长。这听起来像是一个显而易见的陈述,但它很重要。内存问题不仅伤害应用程序的性能,它们可能会导致整个浏览器标签页崩溃。因此,我们需要密切关注我们代码的内存分配特性。浏览器内置的性能分析工具可以记录对象在内存中的分配情况随时间变化。这对于诊断问题,或者观察我们的代码行为非常有用。

注意

频繁创建和销毁对象会导致性能滞后。这是因为不再引用的对象会被垃圾回收。当垃圾回收器运行时,我们的 JavaScript 代码不会运行。因此,我们有一个冲突的需求——我们希望代码运行得快,同时不想浪费内存。

想法是不必要地触发垃圾回收器的运行。例如,有时我们可以把变量提升到更高的作用域。这意味着在整个应用程序的生命周期中,引用并没有被多次创建和销毁。

另一种场景是在短时间内频繁分配,例如在循环中。虽然 JavaScript 引擎在处理这类场景时很聪明,但仍然值得我们关注。最佳资源是考虑垃圾回收器的低级库的源代码,避免不必要的分配。

API 返回的响应也消耗内存,具体取决于返回的数据,可能消耗大量的内存。我们希望确保给定 API 端点可以响应的数据量有限制。许多后端 API 会自动这样做,一次不超过 1000 个实体。如果我们需要遍历集合,那么我们需要提供一个偏移量参数。然而,我们可能还需要进一步限制 API 响应的大小,因为集合中单个实体的大小在浏览器中作为模型占用大量内存。

尽管这些集合通常会在用户从一个页面移动到另一个页面时进行垃圾回收,但我们实施的每个新功能都可能带来微妙的内存泄漏错误。这些微妙的错误难以处理,因为泄漏发生缓慢,并且在不同的环境中表现不同。当内存泄漏很大且明显时,它更容易复现,因此也更容易定位和修复。

接下来是一个例子,展示了内存消耗如何迅速失控:

// model.js
var counter = 0;

// A model that consumes more and more memory,
// with each successive instance.
export default class Model {

    constructor() {
        this.data = new Array(++counter).fill(true);
    }

};

// app.js
// A simple application component that
// pushes items onto an array.
export default class App {

    constructor() {
        this.listening = [];
    }

    listen(object) {
        this.listening.push(object);
    }

};

// main.js
import Model from 'model.js';

function onRequestsInput(e) {
    var size = +e.target.value,
        cnt = 0,
        models = [];

    // Create some models, based on the "requests"
    // number.
    while (cnt++ < size) {
        models.push(new Model());
    }

    // Setup a timer, so we can see how long it
    // takes to fetch all these models.
    console.clear();
    console.time(`fetched ${models.length} models`);

    // Use "Promise.all()" to synchronize the fetches
    // of each model. When they're all done, we can stop
    // the timer.
    Promise.all(models.map(item => item.fetch())).then(() => {
        console.timeEnd(`fetched ${models.length} models`);
    });
}

// Setup our DOM listener, so we know how many
// models to create and fetch based on the "requests"
// input.
var requests = document.getElementById('requests');

requests.addEventListener('input', onRequestsInput);
requests.dispatchEvent(new Event('input'));

CPU 消耗

影响我们用户界面响应性的一个重要因素是客户端的 CPU。如果它能够在有代码需要运行时,例如在点击时运行我们的代码,那么 UI 将感觉是响应性的。如果 CPU 正在处理其他事情,我们的代码将不得不等待。用户也只能等待。显然,在给定的操作系统环境中,有很多软件要求 CPU 的注意力——其中大部分完全超出我们的控制。我们无法减少浏览器之外其他应用程序的使用,但我们可以从我们的 JavaScript 应用程序中减少 CPU 的使用。但首先,我们需要了解这些 JavaScript CPU 周期来自哪里。

在架构层面,我们不考虑使单个组件的小部分更高效的微优化。我们关心的是缩小规模,这在应用运行时对 CPU 消耗有明显的影响。我们在第七章,加载时间和响应性,看到了如何分析我们的代码。这告诉我们 CPU 在我们的代码中花费的时间。用配置文件作为我们的测量标准,我们可以进行更改。

影响 CPU 使用的两个因素是活动的特性数量和这些特性使用的大小。例如,当我们向系统中添加更多组件时,CPU 消耗自然会更多,因为当事情在 UI 中发生时,该特性的组件代码需要以某种方式响应。但这一点本身不太可能产生很大的影响。是实现新特性时伴随的 API 数据使得 CPU 成本变得昂贵。

CPU 消耗

合并消耗 CPU 周期的力量——处理更多数据的更多组件

例如,如果我们不断实施新功能,而数据集保持不变,我们开始感受到 CPU 成本。这是因为有更多的间接性,意味着对于任何给定事件需要运行更多的代码。然而,这种减慢会以冰川般的速度发生——我们可以在不费吹灰之力的情况下,不断增加数百个功能。是变化的数据使这成为一种扩展不可能性。因为如果你将功能数量乘以不断增长的数据集,CPU 成本将呈指数级增长。

好吧,也许并非我们所有的功能都在消耗所有的数据。也许我们的设计中间接性非常少。这仍然是在缩减规模时需要考虑的最大因素。所以如果我们需要降低 CPU 成本,我们就需要移除功能及其处理的数据——这是唯一能产生可测量影响的方法。

下面是一个示例,展示了组件数量和数据项数量的组合如何逐渐消耗更多的 CPU 时间:

// component.js
// A generic component used in an application...
export default class Component {

    // The constructor accepts a collection, and performs
    // a "reduce()" on it, for no other reason than to eat
    // some CPU cycles.
    constructor(collection) {
        collection.reduce((x, y) => x + y, 0);
    }

}
// main.js
import Component from 'component.js';

function onInput() {
    // Creates a new collection, the size
    // is based on the "data" input.
    var collection = new Array(+data.value).fill(1000),
        size = +components.value,
        cnt = 0;

    console.clear();

    // Sets up a timer so we can see how long it
    // takes for x components to process y collection items.
    console.time(`${size} components, ${collection.length} items`);

    // Create the number of components in the "components"
    // input.
    while (cnt++ < size) {
        new Component(collection);
    }

    // We're done processing the components, so stop the timer.
    console.timeEnd(`${size} components, ${collection.length} items`);
}

// Setup out DOM event listeners...
var components = document.getElementById('components'),
    data = document.getElementById('data');

components.addEventListener('input', onInput);
data.addEventListener('input', onInput);

components.dispatchEvent(new Event('input'));

后端能力

我们将要解决的最后一个扩展限制是提供我们静态资源和 API 数据的后端。这是一个限制因素,因为我们的代码不能在到达浏览器之前运行,我们也不能在原始数据到达之前向用户显示信息。这两件事都取决于后端来实现,但在进行前端开发时,关于后端有几点需要注意的。

第一个关注点是我们应用程序的使用情况。正如运行我们 JavaScript 代码的浏览器不能无限扩展一样,我们的后端 API 也不能无限扩展。虽然它们有一些特性使它们能够扩展浏览器不具备的,但它们仍然受到更多请求量的影响。第二个关注点是我们代码与 API 的交互方式。我们必须观察一个用户如何使用我们的应用程序,以及这些交互产生的 API 请求。如果我们能够优化一个用户的请求,增加更多用户对后端的冲击会更小。

例如,我们不想发起我们不需要的请求。这意味着,在实际需要之前不要加载数据。并且,不要反复加载相同的数据。如果一个用户在会话开始五分钟后才开始与一个功能互动,那么在这段时间内后端就可以处理其他请求。有时我们的组件会使用相同的 API 端点。如果它们同时被创建,并且先后发送相同的 API 请求会怎样呢?后端不得不服务这两个请求,这是不必要的,因为它们将具有相同的内容。

我们需要构建组件通信结构,以考虑规模影响因素,如后端产生的负载。在这个特定实例中,第二个组件可以在挂起请求映射中查找并返回那个承诺,而不是生成一个新的请求。

后端能力

新组件应致力于减少带宽消耗;一种方法是使用更少的 API 请求来实现相同的功能。

冲突的功能

随着我们软件的增长,我们功能之间的界限变得模糊。至少会有一些重叠,这是件好事。如果没有至少一点重叠,用户在从我们 UI 的一个区域过渡到另一个区域时会有困难。当达到一个特性阈值时,问题就出现了,因为此时有多个重叠的层次不断叠加。这是一个自我传播的问题,每增加一个新特性就会变得更糟,直到解决这个问题。

这个问题的两个潜在原因包括我们应用程序中随时间变得无关的部分,而它们并没有被退役,而是闲置妨碍。客户需求在这种规模影响中起了很大的作用,因为它决定了产品的未来方向。这也应该让我们了解到现在所拥有的,要么为了满足需求而需要改变,要么在不久的将来需要消失。

重叠功能

在我们应用程序的生命周期中,将会有与现有功能重叠的新功能。这就是软件开发的本质——建立在你已经拥有的基础上,而不是从完全无关的领域开始。当这种重叠不显眼时,作为从现有功能到新功能和增强的桥梁,这是很好的。

当重叠与现有功能冲突时,这种重叠就不那么好了。这就像想在树林里建房子,而不先移走任何树。如果重叠要无缝且可扩展,需要发生两件事之一。要么我们需要调整已经存在的内容以适应即将到来的内容,要么我们需要重新思考新功能,使其更好地适应可用空间。这很有趣,因为考虑到我们所拥有的,有时我们甚至在实现功能之前就必须缩减功能——这通常比实现后更容易。

不合理的功能重叠的最终结果是用户觉得功能繁琐、难以使用,因此我们预计将来会收到一些投诉。这是我们稍后可能需要修复或删除的另一件事。我们实际上经常这样说服自己——这并不是一个很大的增加,但足够满足截止日期。但这种足够好的代价是什么?除了预期的用户烦恼外,还有代码需要担心。我们很少说这样的话——好吧,用户可能不喜欢它,但代码非常棒。通常糟糕的用户体验是功能规划不当和实施不佳的结果。

解决方案其实相当简单,正如我们已经看到的。这是为变化腾出空间,或者修改新功能的问题。我们经常忽视的一点是记录潜在问题。例如,如果我们看到一个计划中的功能与我们的现有代码不匹配,我们需要提出来并概述哪些不匹配以及为什么。拥有这些信息归档并可搜索总比忽视要好。这是我们通过与团队包容来扩展我们的架构理念的方式。

功能重叠

旧功能与新功能之间的重叠是缩减不必要的代码的一个很好的起点

不相关功能

随着时间的推移,一些功能证明了自己的价值。我们的用户非常喜欢它们,并且经常使用。更重要的是——我们几乎不需要维护它们。它们就是能正常工作。另一方面,我们实现的其他一些功能开始生锈的速度比我们预期的要快。可能有无数的迹象表明这种情况正在发生。也许有一小部分用户喜欢这个功能,但它存在 bug 并且难以维护。也许我们的大部分用户喜欢这个功能,但它阻碍了项目中的多项举措的实施。但最常见的情况是,根本没有人使用它。

无论出于何种原因,功能确实变得不相关。我们作为行业的问题是我们喜欢积累代码。有时我们出于必要保留不相关的功能——我们可能会破坏太多东西,或者在需要的地方引入向后不兼容。在其他时候,这实际上更多的是前端问题,我们保留功能是因为我们没有明确的命令来摆脱它。好吧,如果我们想要扩展应用程序,我恐怕这种情况需要发生。

这是一个主动而不是被动的问题。正如我们所知,每个组件都对我们扩展约束有所贡献——无论是网络、内存、CPU 还是其他方面。谁知道呢,也许我们的产品里存在的功能完全可以应付。最好把它解决掉,因为这样它实际上限制我们扩展能力的可能性更小。我们可能认为它是一段无害的代码,但彻底排除不是更好吗?此外,这种态度 Simply put, it's better to scale down the things we don't need, and then think about where to go from there. If we set the precedent with all our stakeholders that we're ready and willing to trim the fat, we're more likely to convince them to ship a leaner product.

无关功能

我们的应用程序可扩展的空间有限;删除无关功能可以释放扩展空间

客户需求

取决于我们正在构建的产品类型,以及它服务的用户类型,客户需求将转化为有序规划实施,或者冲动反应。我们都想让客户开心——这就是我们为什么要开发软件。但正是这些快速决定实施人们尖叫着要的东西,损害了我们的架构。就好像我们把功能当成错误来实施。对于错误,我们尽快实施快速修复,因为我们需要把它们赶出门。

新功能不是错误。尽管用户和管理层怎么说——没有他们所要求的功能他们也能活下去。我们需要找到一种方法,为我们争取必要的时间,将客户想要的新功能纳入我们的架构中。这并不是说我们可以一直推迟——我们必须及时地这样做。也许移除用户不太关心的现有功能是前进最快的途径。

客户需求

确定哪些功能会包含在下一个版本中;这些功能要么是我们已经拥有的,要么是客户希望的新功能

设计失败

缩小规模一方面是通过修复我们现有的代码。例如,通过移除功能,或者通过修改现有组件以适应新计划的功能。但这也只能让我们在未来走得更远。两年前看起来是个好主意的设计想法,是为了我们当时考虑的功能,其中一些可能今天已经不再存在。

要想对我们的架构产生持久的影响,我们必须修复破碎的模式。它们仍然在我们的产品中起作用,因为我们在让它们起作用,尽管它们可能不是完成工作的最佳工具。找出正确的设计不是一个一次性的事件,它发生在我们的软件变化中,以及我们的扩展影响命令中。

在本节中,我们将探讨几种可能解决我们设计中一些缺陷的方法。也许有很多我们不需要的活动部件。也许由于我们组件通信模型的复杂性,我们正在效率低下地处理我们的 API 数据。或者,我们 DOM 元素的结构导致了晦涩的选择器字符串,从而减慢了开发速度。这只是可能性的一小部分——缺陷模式因项目而异。

不必要的组件

当我们最初开始设计我们的架构和构建我们的软件时,我们将利用当时有意义的模式。我们设计组件使其彼此之间松耦合。为了实现这种松耦合,我们通常会做出权衡——增加活动部件。例如,为了保持每个组件职责的专注,我们必须将较大的组件拆分为较小的组件。这些模式决定了我们特征组件的构成。如果我们遵循这种模式,并且它有不必要的部分,我们所开发的新东西也将包含不必要的部分。

很难做到模式正确,因为当我们需要决定使用哪些模式时,我们没有足够的信息。例如,框架中有很多通用的模式,因为它们服务的受众比我们的应用程序广泛得多。因此,虽然我们想要利用框架暴露的相同模式,但我们需要将它们适应到我们的特定功能中。这些是随着客户需求逐渐改变我们产品的性质而逐渐变化的模式。我们可以接受这种自然现象,并投入时间修复我们的模式。或者,我们可以随着问题的出现而解决,保持我们原始的模式不变。对我们曾经认为的基础进行调整,是我们扩展架构的最佳方式。

最常见的模式缺陷是多余的间接性。也就是说,组件是抽象的,并没有真正的价值。虽然它们将组件与另一样东西解耦,但它们所做的也就仅此而已。我们会注意到,随着时间的推移,我们的代码积累了这些相对较小的模块,而且倾向于看起来都一样。它们之所以小,是因为它们不做太多的事情,它们看起来一样,是因为它们是我们承诺在整个代码中一致使用的模式的一部分。在模式被构思的时候,这个组件是完全有意义的。实现几个组件后,它变得不那么有意义了。失去这个组件并不会损害设计,事实上,整个项目现在感觉有点更轻了。有趣的是,模式在纸上的样子与在实际应用中的样子之间的脱节。

下一个例子展示了一个使用控制器的组件,以及另一个不需要控制器且少了一个活动部件的组件版本:

// view.js
// An ultra-simplistic view that updates
// the text of an element that's already in
// the DOM.
export default class View {

    constructor(element, text) {
        element.textContent = text;
    }

};

// controller.js
import events from 'events.js';
import View from 'view.js';

// A controller component that accepts and configures
// a router instance.
export default class Controller {

    constructor(router) {
        // Adds the route, and creates a new "View" instance
        // when the route is activated, to update content.
        router.add('controller', 'controller');
        events.listen('route:controller', () => {
            new View(document.getElementById('content'), 'Controller');
        });
    }

};

// component-controller.js
import Controller from 'controller.js';

// An application that doesn't actually do
// anything accept create a controller. Is the
// controller really needed here?
export default class ComponentController {

    constructor(router) {
        this.controller = new Controller(router);
    }

};

// component-nocontroller.js
import events from 'events.js';
import View from 'view.js';

// An application component that doesn't
// require a component. It performs the work
// a controller would have done.
export default class ComponentNoController {

    constructor(router) {
        // Configures the router, and creates a new
        // view instance to update the DOM content.
        router.add('nocontroller', 'nocontroller');
        events.listen('route:nocontroller', () => {
            new View(document.getElementById('content'), 'No Controller');
        });
    }

};

// main.js
import Router from 'router.js';
import ComponentController from 'component-controller.js';
import ComponentNoController from 'component-nocontroller.js';

// The global router instance is shared by components...
var router = new Router();

// Create our two component type instances,
// and start the router.
new ComponentController(router);
new ComponentNoController(router);

router.start();

低效的数据处理

微优化在效率上并没有给我们带来太多好处。另一方面,重复处理可能会导致巨大的扩展问题。挑战在于,我们可能甚至不会注意到正在进行重复处理,除非我们寻找它。这通常发生在数据从一个组件传递到另一个组件时。第一个组件对 API 数据进行转换。然后,原始数据被传递给第二个组件,它随后执行了完全相同的转换。随着更多组件的添加,这些低效问题开始累积。

我们很少发现这类问题的原因是我们被美丽的设计模式所迷惑。有时候,那些损害用户体验的低效问题被我们的代码所掩盖,因为我们是一致地做事情。也就是说,我们保持组件之间的关联关系是松耦合的,正因为如此,我们的架构在多个方面都能扩展。

大多数时候,一点重复的数据处理是完全可以接受的权衡。这取决于它在处理其他扩展影响方面的灵活性给我们带来了什么。例如,如果我们能够轻松地处理多种不同的配置,并在需要时启用/禁用功能,这是因为我们有数量众多的不统一部署,那么这种权衡可能是有意义的。然而,在一个方面扩展往往意味着在另一个方面不扩展。例如,数据量很可能会增加,这意味着组件之间传递的数据会增加。所以之前没有问题的那些偷偷摸摸的数据转换,现在变成了大问题。当这种情况发生时,我们必须减少数据处理。

再次强调,这并不意味着我们需要开始引入微优化——这意味着我们必须开始寻找大的效率胜利。起点应该始终是网络调用本身,因为前端最大的效率胜利就是一开始就没有获取到数据。要查看的第二件事是组件之间传递的数据。我们需要确保一个组件没有在前一个组件链中做完全相同的事情。

下面是一个示例,展示了每次调用fetch()时都会获取模型数据的组件。它还展示了一个替代实现,当已经有挂起的请求时,不会获取模型:

// model.js
// A dummy model with a dummy "fetch()" method.
export default class Model {

    fetch() {
        return new Promise((resolve) => {
            setTimeout(() => {

                // We want to log from within the model
                // so that we know a fetch has actually
                // been performed.
                console.log('processing model');

                // Sets some dummy data and resolves the
                // promise with the model instance.
                this.first = 'First';
                this.last = 'Last';

                resolve(this);
            }, 1000);
        });
    }

};

// component-duplicates.js
import Model from 'model.js';

// Your standard application component
// with a model.
export default class ComponentDuplicates {

    constructor() {
        this.model = new Model();
    }

    // A naive proxy to "model.fetch()". It's
    // naive because it shouldn't fetch the model
    // while there's outstanding fetch requests.
    fetch() {
        return this.model.fetch();
    }

};

// component-noduplicates.js
import Model from 'model.js';

// Your standard application component with a
// model instance.
export default class ComponentNoDuplicates {

    constructor() {
        this.promise = null;
        this.model = new Model();
    }

    // "Smartly" proxies to "model.fetch()". It avoids
    // duplicate API fetches by storing promises until
    // they resolve.
    fetch() {

        // There's a promise, so there's nothing to do -
        // we can exit early by returning the promise.
        if (this.promise) {
            return this.promise;
        }

        // Stores the promise by calling "model.fetch()".
        this.promise = this.model.fetch();

        // Remove the promise once it's resolved.
        this.promise.then(() => {
            this.promise = null;
        });

        return this.promise;
    }

};

// main.js
import ComponentDuplicates from 'component-duplicates.js';
import ComponentNoDuplicates from 'component-noduplicates.js';

// Create instances of the two component types.
var duplicates = new ComponentDuplicates(),
    noDuplicates = new ComponentNoDuplicates();

// Perform two "fetch()" calls. You can see that
// the fetches are both carried out by the model,
// even though there's no need to.
duplicates.fetch();
duplicates.fetch().then((model) => {
    console.log('duplicates', model);
});

// Here we do the exact same double "fetch() call,
// only this component knows not to carry out
// the second call.
noDuplicates.fetch();
noDuplicates.fetch().then((model) => {
    console.log('no duplicates', model);
});

提示

当我们的组件之间相互独立时,避免进行重复的 API 调用是很困难的。比如说,一个功能创建了一个新的模型并获取它。另一个在同一页上的功能需要相同的模型,但它对第一个组件一无所知——它也创建了一个模型并获取数据。

这些会导致完全相同的 API 调用,这显然是不必要的。不仅对于前端来说效率低下,因为它对于完全相同的数据有两个不同的回调,而且它还损害了整个系统。当我们发起不需要的请求时,我们会在后端堵塞请求队列,影响其他用户。我们必须留心这些重复调用,并相应地调整我们的架构。

过度创新的标记

用于渲染我们 UI 组件的标记可能会变得有些失控。因为我们追求的是特定的外观和感觉,所以我们必须对标记进行一些篡改才能达到目的。然后我们再继续篡改,因为在这个浏览器或那个浏览器上看起来不太对。结果是元素被深深地嵌套在其他元素中,以至于它们失去了任何语义意义。我们应该努力实现标签的语义化使用——测试放入p元素中,可点击的按钮是button元素,页面部分用section元素分割等等。

这里的挑战在于,我们追求的设计通常表现为线框图,我们需要以一种方式实现它,使其可以被切成我们的框架和组件可以使用的部分。因此,随着试图保持事物语义化,简单性就丧失了,同时将事物划分为独立的视图也不总是可行的。

尽管如此,我们还是需要在可能的地方简化 DOM 结构,因为它直接影响到我们 JavaScript 代码的简单性和性能。例如,我们的组件经常需要找到页面上的元素,要么改变它们的状态,要么从它们那里读取值。我们可以编写选择器字符串,查询 DOM 并返回我们需要的元素。这些字符串贯穿于我们的视图代码中,它们反映了我们标记的复杂性。

当我们在代码中遇到复杂的选择器字符串时,即使是我们自己写的,我们也无法弄清楚它实际上在查询什么——因为 DOM 结构和所使用的标签并无帮助。所以结果证明,使用语义化标记实际上对我们的 JavaScript 代码有很大的帮助。还有复杂 DOM 结构的性能影响——如果我们经常遍历深层 DOM 结构,我们就会付出性能上的代价。

过度创新的标记

过度深入的元素嵌套通常可以简化,减少元素的使用。

应用程序组合

我们将以一个关于应用程序组成的章节结束。这是我们对应用程序的 10,000 英尺高空视角,在这里我们可以看到各个功能是如何组合的。第三章中,组件组合我们研究了组件组合,同样的原则在这里也适用。这个想法是我们正在操作一个稍微高层次的东西。

在第六章,用户偏好和默认设置中,我们探讨了可配置性,这也与应用程序组成的想法相关。例如,关闭功能,或者打开默认禁用的功能。我们整个应用程序的组成对缩减某些方面有很大的影响。

功能启用

缩减的便捷方法是关闭功能。困难的部分是让利益相关者同意这是一个好主意。然后我们可以直接删除功能,一切就绪了,对吧?不一定。我们可能需要花些时间来移除功能。例如,如果它涉及到系统的几个入口点,而且没有配置可以关闭这些入口点呢?这不是什么大问题,只是意味着我们需要花更多时间编写移除这些功能的代码。

唯一的问题是测试从系统中移除功能的效果。对于没有配置能完成工作的场景,我们不得不花时间编写代码来做这件事,然后我们才能进行测试。例如,我们可以花五分钟关闭配置值,然后我们就会得到立即的结果。也许我们最早就能了解到,在我们可以安全地将功能从系统中移除之前,有很多工作要做。

除了在删除功能后测试我们应用程序的运行行为外,我们可能还需要一些构建时的选项。如果我们的生产代码被编译成几个 JavaScript 工件,那么我们需要一种完全从构建中移除这些功能的方法。通过配置禁用组件是一回事,这意味着当我们的代码运行时,某些东西不会加载等等。如果我们把功能从我们的源代码仓库中移除,那么这显然不那么令人担忧——我们的工具无法构建不存在的代码。然而,如果我们有数百个可能包含在我们构建工件中的潜在组件,我们需要一种排除它们的方法。

新功能影响

对我们应用程序的下一个重大影响是新功能的添加。是的,这个讨论是关于缩减的,但我们不能忽视新功能对我们应用程序的添加。毕竟,这就是我们最初为什么要缩减的原因。不是为了构建一个做更少的较小应用程序。这是为了给客户想要的功能腾出空间,并随着时间的推移提高我们产品的整体质量。

添加功能和删除功能的过程通常是并行的。例如,在一个开发冲刺期间,一个团队负责实现一个新功能,而另一个团队负责移除一个正在引起问题的问题功能。由于这两项活动都以重大方式影响应用程序,我们必须小心行事,并尽量减少这些影响。

本质上,这意味着确保旧功能的移除不会对新添加的功能造成太大的干扰。例如,如果新功能依赖于旧功能中的某个部分。如果我们的设计是合理的,那么就不会有直接的依赖关系。然而,人类对复杂性理解不足——尤其是通过间接作用的原因和效果。因此,扩大这项操作可能意味着我们根本不能在所有活动中并行执行。

新功能影响

根据我们组件间通信模型的不同,向系统中添加新组件的效果应该是相当温和的。

核心库

影响我们应用组合的最后因素就是我们所使用的框架和库。不言而喻,我们只想要用我们所需要的——所谓“用进废退”。这主要是在我们引入较小库作为依赖时的问题。相比之下,框架在大部分情况下是包含一切的。这意味着你可能需要的所有东西框架里都已经有了。虽然这并不一定正确,但它仍然帮助我们减少了第三方库的依赖。

即便框架如今也是模块化的,这意味着我们可以挑选我们想要的好东西,而把其他的留给别人。即便如此,引入组件(无论是来自框架还是其他地方)仍然很容易,而这些我们可能根本不会使用。这在网站开发中相当常见。我们需要这样的一个功能模块,我们不想亲自编写,因为那边的库已经能实现了。然后它就会迷失在页面混合中。我们应该吸取网站没有学到的教训——我们的应用需要一组专注的依赖关系,这对于完成工作至关重要。

总结

本章介绍了这样一个观念:我们应用中的并非一切都是无限可扩展的。实际上,我们应用中的任何方面都不是无限可扩展的,因为每个方面都受到不同因素的限制。这些因素以独特的方式融合在一起,我们必须要做出必要的权衡。如果我们想要持续扩展,我们必须在其他领域进行缩减。

新功能源于客户需求,它们通常与我们已经实现的其他功能重叠。这可能是因为我们没有对新功能定义得很清楚,或者是因为系统现有的入口点定义得不明确。无论如何,这都可能使得一个具有挑战性的练习;用新功能替换现有功能。我们经常需要去除重叠区域,因为它们在代码层面和可用性层面都会造成混淆。

缩小规模不仅仅是逐个处理的活动——还需要考虑设计模式。移除一个功能后,我们需要审视我们正在使用的模式,并问自己,我们是否希望未来一直重复这样做? 向前发展的更好、更可扩展的方法,是修复这个模式。即使在我们缩减规模之后,仍然存在出错的可能性。在下一章中,我们将更详细地探讨失败的组件以及如何处理它们。

第十章 应对失败

在本书的这一点上,我们希望认为我们的架构是健全的。我们考虑过可扩展性,并做出了所有适当的选择,例如牺牲性能以提高配置性。我们在可扩展的 JavaScript 架构中尚未深入探讨的一个方面是人类因素。尽管我们很聪明,但我们是最薄弱的环节,因为我们设计和编写应用程序代码——我们很擅长犯微妙的错误。

除非我们完全被排除在软件开发之外,否则我们必须考虑设计组件时的失败。这包括考虑失败模式——我们是快速失败还是尝试从错误中恢复?它包括考虑错误的质量——有些错误比其他错误更容易处理。但它也关于了解我们的局限性;我们不可能实际检测和恢复每一个可想象的错误。

当我们扩展应用程序时,我们处理失败的方法也需要扩展。这是我们需要在众多其他扩展因素之间做出的另一个权衡。让我们先来看看快速失败的故障模式。

快速失败

快速失败的系统或组件在失败时停止运行。这听起来可能不是一种理想的设计特性,但考虑一下替代方案:一个失败后仍然继续运行的系统或组件。这些组件可能正在运行错误的状态下,而如果系统或组件停止,这种情况是不可能的。

有时我们想要恢复一个失败的组件,我们将在本章后面讨论这个话题。在本节中,我们将回顾一些用于确定 JavaScript 组件是否应快速失败的准则,以及这对用户有什么后果。有时,即使我们的快速失败机制也会失败,我们也需要考虑这一点。

使用质量约束

当我们的组件快速失败时,通常是由于一个已知的错误状态。另一方面,可能发生一些完全出乎意料的事情。无论如何,它都可能导致我们的组件处于不良状态,我们不希望应用程序像一切都很正常一样继续运行。让我们关注当质量约束不满足时的快速失败。这些是关于我们的应用程序如何行为的断言。例如,我们不应该尝试发送超过三次 API 请求;我们不应该等待超过 30 秒的响应——模型的这个属性应该总是有一个非空字符串,等等。

当这些断言被证明是错误的时,就是停止执行的时候了——无论是单个组件还是整个系统。我们这样做并不是为了惹恼用户。和任何失败一样,我们希望它们尽可能少发生。将快速失败想象成汽车事故中安全气囊的部署——当这种情况发生时,我们的车就无法再开了。

在某些条件下使组件或整个系统快速失败的决策不应被轻视。例如,如果我们因为一个特性团队以这种方式实现而在一个地方快速失败,对其他团队来说是未知的,整个应用程序开始失败。同时,结果表明这是设计的一部分,是预期行为。这种失败模式需要有严格的理由。讨论快速失败场景时,真正有帮助的是如果应用程序继续不受干扰地运行可能发生的灾难性结果。

使用质量约束

当违反约束时,会导致组件快速失败,可能使整个应用程序快速失败

提供有意义的反馈

我们不希望给用户或我们开发团队的其他成员错误地传达为什么在某些场景下我们的软件不能运行的原因。这意味着我们必须区分快速失败和完全不受控制的失败。后者是打破我们应用程序的东西,可能会导致浏览器标签页崩溃。或者更糟糕的是,它还在地上爬来爬去,给用户留下它仍然有点工作的印象,同时一直在造成伤害。

这意味着当我们快速失败时,我们必须让用户清楚地知道出了什么问题,他们不应该继续使用它。无论是一个失败的组件还是整个应用程序,我们必须使信息清晰简洁。用户不需要总是知道出了什么问题;他们只需要知道当前组件或应用程序是损坏的,他们所做的任何事情都不会起作用。

这实际上是我们将快速失败引入架构的一个重要后果——我们在某些条件下获得了响应性。我们永远不会让用户猜测。当然,面前有损坏的软件是很烦人的,但比这更糟糕的是等待、尝试,然后再次等待,以发现它是损坏的。通过清楚地传达应用程序或其部分正在运行,或者通过在元素上抛出一个div遮罩或者关闭 DOM 事件处理程序,我们可能希望阻止用户与它交互。

接下来的例子展示了两个错误处理程序。第一个程序通过禁用按钮隐式地处理错误。另一个回调做了同样的事情,但还明确显示了一个错误信息:

// The DOM elements...
var error = document.getElementById('error'),
    fail1 = document.getElementById('fail1'),
    fail2 = document.getElementById('fail2');

// The first event merely disables the button.
function onFail1(e) {
    e.target.disabled = true;
}

// The second event disables the button, but
// also explicitly informs the user about what
// went wrong.
function onFail2(e) {
    e.target.disabled = true;
    error.style.display = 'block';
}

// Setup event handlers...
fail1.addEventListener('click', onFail1);
fail2.addEventListener('click', onFail2);

当我们不能快速失败时...

我们可以将快速失败机制设计到我们的组件中。但我们不能保证这些机制本身不会失败。也就是说,我们为了保护自己而编写的代码是由我们编写的。等等。我们可以在错误处理代码层上继续编写快速失败和优雅失败的代码,当底层出现故障时,它会在底层出现故障。但这是为了什么目的?

认识到我们并不能总是可预测地失败是我们面临的可扩展性挑战的一部分。因为,在某个时刻,我们必须关注我们实际上试图提供的特性,而不是支撑它的框架。额外的失败处理代码并不会让我们的产品变得更好,它只是以代码的形式增加了体积。如果我们试图专注于我们正在构建的特性,那些我们想要快速失败的明显案例自然会显现出来。

失败检测代码的问题在于,它需要与其他应用程序一起扩展,外部扩展因素指导其发展。例如,更多用户意味着后端有更多的需求。这意味着我们的失败检测代码可能永远都不会到达——我们如何考虑这种情况?我们不这样做。因为试图解决这些问题,并不会扩展。尝试防止它们发生是更有成果的努力。

容错性

具有容错性的系统具有在组件出现故障时生存下来的能力。这是通过更正组件中的错误,或者用新实例替换有缺陷的组件来实现的。将容错性想象为一架飞机,它只用一个引擎就能降落——乘客是我们的用户。

通常,我们在大规模服务器环境中听到关于容错性的讨论。在具有足够复杂性的前端开发中,这也是一个可行的概念。在本节中,我们将首先考虑如何将组件分类为关键组件和非关键组件。然后,我们将转向错误检测,以及如何处理错误,以便应用程序能够继续运行。

分类关键行为

就像不能被另一个线程中断的关键代码段一样,在我们的应用程序中,有一些组件不能优雅地失败。有些组件无论发生什么都必须正常工作,如果它们不能,那么它们需要快速失败以避免造成进一步的损害。这就是我们需要对组件进行分类的原因。虽然对于给定组件必须按预期工作这一点可能显而易见,但以某种方式一致地分类它们是有意义的。在整个组织中传播这样的想法是个好主意。

当我们知道哪些组件是关键的,我们就知道它们必须正常工作,而且无法想象它们需要恢复的情况。如果这些组件失败了,那就有一个需要修复的错误。我们还可以更加集中地进行单元测试。

给组件设置不同的重要性层级不是一个好主意。例如,对于绝对关键的组件有一个等级,再下一个等级的组件虽然不关键但也不足以认为是普通的,依此类推——这样就违背了初衷。我们要么可以没有这个组件生存,要么就不能。这种简单性让我们把组件分为两个类别,给它们贴标签比让它们疲劳要简单得多。任何非关键组件都有可能容忍失败,因此我们可以开始考虑这些组件的故障检测和恢复设计。

Classifying critical behavior

关键组件与其他可以容忍错误的组件

检测和隔离异常行为

我们的组件之间应当是解耦的,如果我们设计一个可扩展性好的架构的话。这种解耦的一部分就是错误处理。一个组件的错误不应该导致另一个组件的失败。如果我们能够采用这样的信条,其他一切都会变得更简单。因为如果一个组件失败了,我们可以自信地说,这次失败不是由另一个组件引起的。从那时起,找出原因并解决问题就会容易得多。

如果我们有一个事件代理之类的组件,那么将一个组件的错误与另一个组件解耦就会容易得多。如果所有的组件间通信都是通过代理进行的,那么那里就是实现检测错误并防止它们传播到其他组件的一个好机制。例如,如果一个组件接收到一个事件并运行一个回调函数失败,它可能会对整个应用程序产生副作用,甚至可能导致整个应用程序完全失败。

相反,事件代理会检测到这个错误,例如抛出的异常,或者是回调函数返回的错误状态代码。在异常的情况下,它不会在调用栈中找到上升的路径,因为它被捕获了。事件队列中的下一个处理程序 then 可以接收到关于失败处理程序的信息——这样它们就可以决定做什么,也许什么都不做。重要的是错误被隔离,并且它的发生被传达给其他组件。

下面是一个示例,展示了能够检测错误并将它们转发给事件下一个回调的事件代理:

// events.js
// The event broker...
class Events {

    // Trigger an event...
    trigger(name, data) {
        if (name in this.listeners) {
            // We need to know the outcome of the previous handler,
            // so each result is stored here.
            var previous = null;

            return this.listeners[name].map(function(callback) {
                var result;

                // Get the result of running the callback. Notice
                // that it's wrapped in an exception handler. Also
                // notice that callbacks are passed the result
                // of the "previous" callback.
                try {
                    result = previous = callback(Object.assign({
                        name: name
                    }, data), previous);
                } catch(e) {
                    // If the callback raises an exception, the
                    // exception is returned, and also passed to
                    // the next callback. This is how the callbacks
                    // know if their predecessor failed or not.
                    result = previous = e;
                }

                return result;
            });
        }
    }

}

var events = new Events();

export default events;

// main.js
import events from 'events.js';

// Utility for getting the error message from
// the object. If it's an exception, we can return
// the "message" property. If it has an "error"
// property, we can return that value. Otherwise,
// it's not an error and we return "undefined".
function getError(obj) {
    if (obj instanceof Error) {
        return obj.message;
    } else if (obj && obj.hasOwnProperty('error')) {
        return obj.error;
    }
}

// This callback will be executed first, since it's
// the first to subscribe to the event. It'll randomly
// throw errors.
events.listen('action', (data, previous) => {
    if (Math.round(Math.random())) {
        throw new Error('First callback failed randomly');
    } else {
        console.log('First callback succeeded');
    }
});

// This callback is second in line. It checks if the
// "previous" result is an error. If so, it will exit
// early by returning the error. Otherwise, it'll randomly
// throw its own error or succeed.
events.listen('action', (data, previous) => {
    var error = getError(previous);
    if (error) {
        console.error(`Second callback failed: ${error}`);
        return previous;
    } else if (Math.round(Math.random())) {
        throw new Error('Second callback failed randomly');
    } else {
        console.log('Second callback succeeded');
    }
});

// The final callback function will check for errors in
// the "previous" result. What's key here is that only
// one of the preceding callbacks will have failed. Because
// the second callback doesn't do anything if the first
// callback fails.
events.listen('action', (data, previous) => {
    var error = getError(previous);
    if (error) {
        console.error(`Third callback failed: ${error}`);
        return previous;
    } else {
        console.log('Third callback succeeded');
    }
});

events.trigger('action');

Detecting and containing errant behavior

容错意味着一个组件产生的错误不能影响其他组件

禁用有缺陷的组件

当我们对整个应用程序进行快速失败时,是因为我们试图避免更糟糕的问题出现。但是,如果系统中的一个组件与其它组件完全解耦,并且出现了问题呢?我们可以尝试从失败中恢复,但并非总是可行——如果存在 bug,唯一的恢复选项就是修补代码。与此同时,如果无法恢复,我们可以禁用该组件。

这样做有两个目的。首先,errant component 传播其问题的机会减少。其次,禁用组件或完全隐藏它,防止任何用户交互。这意味着用户重复尝试最终导致其他 bug 的事情的机会减少。这不应该发生,因为组件是隔离的,但仍然——我们不知道我们的设计在哪里有缺陷。

在问题组件被排除在外之后,我们可以在用户完全绝望之前得到一些安慰。只是系统中有一个方面用户无法与之交互。这给我们一点时间来诊断问题并修复有问题的组件。

设计问题是谁负责禁用组件——是组件本身,还是负责检测问题的核心组件的责任?一方面,组件自行关闭是个好主意,因为关闭可能涉及多个步骤,以保持其他组件运行顺畅。另一方面,当遇到问题时,像事件经纪人这样的东西关闭问题组件,将错误处理放在一个地方。我们采取的方法真的取决于最简单的解决方案。如果事件经纪人可以安全地做到这一点,那么那可能是最好的选择。

禁用故障组件

禁用的组件不与系统中的其他部分交互,这降低了问题组件引起问题的可能性

优雅地降级功能

在检测到错误时禁用组件是一件事,另一件事是以优雅的方式处理失败的组件并将其从 UI 中移除。尽管我们努力使组件之间保持松耦合,但在 DOM 方面,这是一个完全不同的问题。例如,我们实际上能不干扰周围元素的情况下移除失败组件的 DOM 元素吗?还是说我们最好保持元素的位置不变,但视觉上禁用它们并关闭任何 JavaScript 事件处理程序?

我们采取的方法取决于我们正在构建什么,即我们应用程序的性质。一些应用程序由于组件的组合和 UI 的一般布局而容易添加和删除功能,但视觉设计不仅仅是可从应用程序其余部分分离的皮肤。从理论上讲,它应该与系统的其余部分解耦,但在实践中这种观念行不通。如果我们想要扩展,页面上的元素布局与失败组件以及我们能够在其他地方产生副作用的情况下禁用或删除它们的能力有关。

我们应该将处理失败组件的想法视为关闭它们,因为通常需要发生一些行动——以便我们优雅地降低用户体验。很少整个功能会失败——是一个像路由器这样的组件导致一个功能无法正常工作。所以,如果我们关闭给定组件的路由处理程序,我们将需要关闭其他组件以从 UI 中移除功能,并向用户显示错误消息等。这些关闭语义需要考虑并对我们构建的任何给定功能进行测试。我们不是试图保护功能本身;实际上,我们是在保护系统其余部分免受功能可能造成的危害。

功能优雅降级

一个集合组件失败,导致整个功能服务中断;但整个应用程序仍然可以正常工作

故障恢复

在上一节中,我们开始思考我们前端代码中的容错性。也就是说,我们的应用程序需要在不成功的组件丢失的情况下至少在短期内生存下来。但如果有一些我们可以从中恢复的错误类型呢?所以,在检测到错误后,我们不会关闭组件,而是采取一些其他行动;这些行动仍然能满足用户的需求。

在这一节中,我们将看看我们的组件可以以各种方式从失败的操作中恢复。例如,我们可以重新尝试一个操作,或者我们可以通过重新启动它来清除组件的错误状态。有时,在恢复过程中获取用户关于他们希望如何进行的输入是有意义的。

重试失败的操作

如果我们的组件执行一个失败的操作,它可以重新尝试这个操作。这个操作甚至不必是组件的一个基本部分。但是由于组件依赖于这个操作,如果它失败了,那么组件也会失败。例如,一个后端 API 调用可能会失败,使得发起调用的我们的组件处于一个不确定的状态。API 调用在失败时重新尝试是一个很好的选择。

无论是我们正在重试的 API 调用,还是涉及其他组件的操作,我们都必须确保它是幂等的。这意味着在初始操作调用之后,随后的调用没有任何副作用。换句话说,连续多次调用该操作不会对系统中的其他部分产生负面影响。数据获取请求——请求不改变后端资源状态而向 API 请求数据的请求——是重试的好候选。例如,如果我们的数据获取请求失败是因为后端响应时间过长,可能是由于其他用户的需求竞争造成的,我们可以再次尝试请求并获得即时结果。我们可能不想继续等待,但如果我们决定重试,这是安全的。下面是一个示例,展示了将重试失败的数据获取尝试的模型:

// api.js
// Simulate an API call by returning a promise.
function fetch() {
    return new Promise((resolve, reject) => {

        // After one second, randomly resolve or
        // reject the promise.
        setTimeout(() => {
            if (Math.round(Math.random())) {
                resolve();
            } else {
                reject();
            }
        }, 1000);

    });
}

export default fetch;

// model.js
import fetch from 'api.js';

// An entity model that's fetched from the API.
export default class Model {

    // Initialized with a "retries" count and an
    // "attempts" counter, used when the requests fail.
    constructor(retries=3) {
        this.attempts = 0;
        this.retries = retries;
    }

    // Returns a new promise where "fetchExecutor()"
    // attempts, and possibly re-attempts to call the API.
    fetch() {
        return new Promise(this.fetchExecutor.bind(this));
    }

    fetchExecutor(resolve, reject) {
        // Call the API and resolve the promise. Also reset the
        // "attempts" counter.
        fetch().then(() => {
            this.attempts = 0;
            resolve();
        }).catch(() => {
            // Make another API request attempt, unless
            // we've already made too many, in which case
            // we can reject the promise.
            if (this.attempts++ < this.retries) {
                console.log('retrying', this.attempts);
                this.fetchExecutor(resolve, reject);
            } else {
                this.attempts = 0;
                reject(`Max fetch attempts 
                    ${this.retries} exceeded`);
            }
        });
    }

};

// main.js
import Model from 'model.js';

var model = new Model();

// Fetch the model, and look at the logging
// output to see how many attempts were made.
model.fetch()
    .then(() => {
        console.log('succeeded');
    })
    .catch((e) => {
        console.error(e);
    });

注意

我们必须注意我们执行的操作类型以及我们收到的失败类型。例如,创建新资源的表单提交可以以多种方式失败。如果我们尝试此操作,并且它返回了一个 503 错误,我们知道可以安全重试——因为后端实际上并没有接触到任何资源。另一方面,我们可能会得到一个 500——这意味着我们不知道后端发生了什么。

在使用数据获取请求时,我们不必太担心失败的类型,因为我们没有改变任何状态。这意味着在重试操作之前,我们需要考虑操作类型,如果它修改资源,还需要考虑错误响应的类型。

重启组件

组件通常具有生命周期——启动、关闭以及根据组件类型在中间存在几个阶段。通常,这个生命周期需要由创建组件的事物来启动。随着组件在其生命周期中移动,它会改变其内部状态。这种状态可能是后来在组件中看到的失败的来源。

例如,如果一个组件处于忙碌状态,并且不处理来自外部组件的任何外部请求,那么我们很可能会在系统中的其他地方看到问题。也许组件确实很忙,或者也许有些东西错误地将它置于该状态。如果是这种情况,那么重新启动生命周期可能足以解决任何问题,并使组件处于运行状态,能够再次处理外部请求。

本质上,重新启动组件是恢复错误的一种绝望的努力。这意味着我们不知道组件出了什么问题,只知道有些东西不工作,它正在整个应用程序中制造混乱。在出现问题时重新启动组件的主要复杂性在于,一旦我们排除了糟糕的内部状态,组件仍然需要继续它之前的工作。例如,如果我们有一个从后端获取集合的组件,并且我们重新启动它,由于组件状态的问题,那么它需要再次获取那个集合。

所以在我们将重新启动功能设计到我们的组件之前,我们需要考虑几件事情。首先,我们如何知道何时重新启动一个组件?这通常是一个特定于应用程序的决定,但它们大多数集中在组件失败的边缘案例上。如果有一个错误,那么重新启动它可能无助于解决问题,但尝试一下也无妨。另一个方面是数据源的恢复——不是内部状态,而是这个应用程序使用的数据源。这两者是分开的——内部状态是组件计算出来的东西,而数据是一个外部来源,作为输入提供。

我们不希望实现组件重新启动功能作为掩盖我们代码中其他问题的机制。这只是设计我们组件的一种好方法。它迫使我们思考组件可能在环境中被抛来抛去的各种方式。即使只是问这个问题也是值得的——如果我重新启动这个组件,或者在运行时用新的实例替换它,会发生什么?我们可能永远不会实际做这些事情,即使我们想这么做也不一定可行。然而,经历这个练习意味着我们将开始设计在这些问题上更有韧性的组件。

重新启动组件

组件状态周期的一个非常高层次的视图

手动用户干预

如果导致问题的组件能够自行重新启动,以摆脱错误状态,那么我们可能希望给用户一些控制何时发生这种情况的权力。例如,如果一个组件生成错误,那么我们可以禁用该功能,告诉用户该功能出了问题,并询问他们是否希望重新加载该功能。

同样的方法也可以用于重试失败的操作——询问用户是否想再试一次。当然,我们必须自由处理用户更平凡的重新尝试/重启操作。当很明显用户希望这个操作成功,而且他们等待的时间不长时,我们不应该因为重新尝试操作而打扰他们。这会适得其反——我们的目的是在软件遇到不允许它执行任务的场景时,通过将控制权交还给用户,做出响应。

我们可能需要设定一个阈值,在尝试重启/重试之前,必须满足这个阈值才能向用户寻求输入。例如,我们试图获取的 API 数据已经超时了两次,用户可能已经变得不耐烦。所以我们在这里停下来,告诉用户发生了什么——我们没有从后端收到响应。我们应该继续尝试,还是停下来?因为当我们的组件遇到这种非确定性情况时,将控制权交给人类可能更好,因为他们的洞察力可能比我们的代码更强。

我们的组件会愉快地重新启动和重试事情,但这必须得到用户的同意。但是当用户放弃,他们已经经历了足够的折磨,想要采取积极行动,而不是让车轮空转时,会发生什么?那么我们可能需要向用户提供一些指导。除了让他们的应用程序一次又一次地尝试相同的事情之外,他们还能做些什么?我们的组件是否了解错误,可以转达给用户?例如,如果一个特定错误的解决方法是更改用户偏好,那么在这里显示一个友好、富有指导性的信息,告诉他们如何解决问题是有意义的。

小贴士

最好将故障排除建议表述为可能的解决方案——而不是确定无疑的方案。这只是为了避免收到糟糕的客服请求。

当我们无法从失败中恢复时...

如果我们已经到了失败的这一步,而用户仍然无法从我们的软件中获得他们需要的,那我们也没有办法了。正如章节标题所暗示的,并非一切都能恢复。后端 API 并不总是可以访问的。我们的组件在生产环境中可能会有 bug,有时甚至在它们被发现之前已经存在了好几年。

像这样的史诗级失败,就像是我们的应用程序在人群面前脸朝地摔了一跤。重试操作只是返回同样的结果。重启组件没有效果。向用户请求输入也不会有帮助,因为可能无法重试正在失败的特定操作,或者我们只是在这里没有实现任何用户输入。

在两种情况下,解决方案都是恢复到快速失败的失败模式——关闭组件,或在特殊情况下关闭整个应用程序。如果我们只是禁用失败的组件,我们必须确保我们的应用程序可以在没有它的情况下运行。这就回到了飞机单引擎着陆的比喻——能做得到吗?如果不能,那么我们必须停止整个应用程序。

所有这些可能在第一眼看起来有点过于激烈。然而,这样做消除了支持团队不必担心的一整类其他缺陷。由于故障组件的副作用,新缺陷进入实时系统的可能性更小。

我们在可扩展的错误处理中玩的是概率游戏,而当我们不过于聪明地处理恢复活动时,概率就在我们这边。

当我们无法从失败中恢复...

失败组件的两个失败模式选项;这个选择可以在运行时做出,而不一定是预先的设计决策

性能和复杂性

有了健壮的失败检测和恢复机制,是时候关注它们引入的性能和复杂性影响了。在任何大型 JavaScript 应用程序中,没有什么是不需要付出代价的——每取得一点进步,都会有一个新的扩展挑战。失败处理只是那些进步之一。

与失败处理紧密相关的两个扩展因素是性能和复杂性。我们的软件以有趣的方式失败,而且没有优雅的处理方式,结果是复杂的实现。复杂的代码通常对性能不是很好。所以我们将首先查看什么使我们的异常处理代码变慢。

异常处理

当我们用 JavaScript 处理异常时,我们通常会捕获所有被抛出的错误。无论是我们可以预见到会被抛出的东西,还是突如其来的东西,然后由异常处理程序决定如何处理错误。例如,它会关闭组件,还是重试操作?try/catch语句的好处是我们可以确保在给定代码段中不会有未捕获的错误。因为那就是我们开始看到其他组件间的副作用的时候。

实现这一点的一种方式,作为一个全面的异常处理机制,不让错误通过,就是在事件代理中。在这里,我们会将任何事件回调调用的调用包裹在一个try/catch块中。这样,无论调用事件回调函数的结果如何,异常处理代码都可以检查异常并决定如何处理。

然而,这里有一个问题——在异常处理器内运行的代码要付出性能代价。JavaScript 引擎非常擅长优化我们的代码。有些事情阻止这些优化发生,而异常处理器就是其中之一。当有多个异常处理级别,一直延伸到调用堆栈底部时,问题会被放大。

这种影响在用户可感知的延迟方面有多明显?这取决于我们应用程序的规模——更多的组件意味着可能有更多没有得到优化的代码在运行。但总的来说,这不会是我们应用程序是否运行缓慢的决定因素。然而,与其他决定因素一起,它可能很重要。在事件经纪人层面实现精简的异常处理是一个合理的权衡。我们的所有代码都通过这里的 try 块运行,但我们得到的回报很多——我们只有适当地处理失败,才能运行得快。

嵌套异常处理在我们每个组件内部进行时,可能会引起更多的性能和复杂性问题。例如,如果我们的事件回调函数捕获了错误,并且处理得不好,那么我们可能做得弊大于利。通常最好让异常在同一个地方被捕获。正如前面所提到的,还有性能方面的影响。我们可以在较高层次上承受打击,但我们不想在每个组件上进一步遭受打击,尤其是因为这些组件的数量会不断增加。

状态检查

除了异常处理,我们还有逻辑在执行操作前检查我们组件的状态。如果当前状态不适合该操作,那么它就不会被执行,因为这样做可能会引起问题。这是一种主动的异常处理,我们在尝试做任何事情之前处理任何潜在的错误,而异常处理则更加乐观。

组件状态本身可能很简单,但当我们的代码必须检查边缘情况时,通常涉及到检查组件所在位置的状态,还要检查其他组件的状态。不一定直接——因为我们的组件是解耦的——但间接地,比如通过向主应用程序发出查询。这可能会变得相当复杂。当我们添加更多组件时,那里将需要进行更多的状态检查,同时我们现有的状态检查代码可能会变得更加复杂。

如果用if语句或其他类似方式编写简单的状态检查,那还好。但通常发生的情况是,随着测试失败,这些边缘情况越来越多,更多的边缘情况处理被添加到混乱中。如果我们从整个应用程序的状态来考虑,我们会发现它只是我们所有组件状态的汇总。考虑到有很多组件,每个组件都有自己的独特状态和在特定情况下可以执行的操作的限制,难怪我们不能预测应用程序将以何种方式失败。当我们开始这条道路时,很容易在系统中引入更多问题。这是复杂性的代价——在别处添加了一些错误处理后,原本没有问题的地方现在却成了问题。

提示

为了减少组件状态检查的复杂性以便处理错误,有一种方法是将我们的操作声明性地绑定到必须满足的条件上。例如,我们可以有一种映射,其中包含操作的名称和要检查的所有条件集合。然后,一个通用机制可以查看这个映射,并确定我们是否可以执行该操作。在组件间一致地使用这样的方法将减少问题if语句的数量。

通知其他组件

作为 JavaScript 架构师,我们所面临的另一个挑战是在一个松耦合组件系统中处理失败。我们希望组件之间松耦合,因为这意味着它们可以互换,并且系统更容易构建和扩展。在错误处理的情境下,这种分离作为失败组件与系统其余部分之间的安全网。这固然是好消息,但我们还需要在保留现有松耦合的同时,通知组件失败,以及发生在快乐路径上的所有其他事件。我们该如何做呢?

让我们先来思考一下事件代理——所有组件间通信的仲裁者。如果它能够传递我们所有的组件事件,那么它当然也能够传递错误通知,对吧?假设代理执行了一个函数回调,并抛出了一个异常。这个异常被代理捕获,并将关于错误的详细信息作为参数传递给事件的下一个回调函数。

在正常情况下,回调会接收到一个错误参数,因此需要检查这个参数——这是一个小障碍,有小额的开销。如果函数不在乎它之前发生了什么,那么这个参数可以安全地忽略。或者,如果传递了一个错误,回调可以查看错误并决定接下来做什么。如果是这种错误——检查这个状态,否则做那个,等等——它可能会选择什么都不做。重要的是错误要被传达,因为我们不希望一个组件中的错误产生副作用,有时需要在其他组件中采取纠正措施,但需要知道错误发生了。

记录和调试

在大型 JavaScript 应用程序中应对失败的一部分是产生正确的信息。最明显的地方是从错误控制台开始,其中记录了未捕获的异常,或者使用console.error()生成的简单错误信息。一些错误信息会导致快速的修复,而其他的会让程序员去寻找根本不存在的解决方案。

除了在错误发生时记录错误,我们可能还希望记录一些即将发生错误的状况。这些是警告信息,它们在前端应用程序中的使用不如预期的那么频繁。警告在诊断我们代码中更隐蔽的问题时特别有用,因为它们在失败后留下了一连串的线索。

用户如果不打开他们的开发者工具窗口,不一定能看到这些日志,普通用户可能根本看不到。相反,我们只向他们展示与他们在应用程序中正在做的事情相关的错误。因此,我们不能只发表声明,我们必须接着提供下一步的行动。

有意义的错误日志

有意义的错误信息非常有用。这确实是一个可扩展性的问题,考虑到错误信息的有效性直接影响到开发者解决问题的能力。考虑一下不包含有用信息的错误信息。当我们调查这些失败时,花在拼凑出发生了什么上的时间要更多。我们可以使用浏览器中的开发者工具追踪错误的来源,但这只能告诉我们位置。我们需要更好的指导来了解出了什么问题。

有时这些模糊的错误信息并不是什么大问题,因为当我们追溯到它们在代码中的来源时,立即就很清楚出了什么问题。通常这只是我们忽略的一个边缘情况,用几行代码就能修复。其他时候,问题比这更深。例如,如果错误实际上是由另一个组件的副作用引起的呢?这会建议我们可能想要修复设计问题,因为我们一直认为我们没有副作用。

考虑以下错误信息:Uncaught TypeError: component.action is not a function。要解读这个信息需要做很多工作,除非我们因为每天与代码交互而对代码非常熟悉。问题是我们随着应用规模的扩大而对我们的代码变得越来越不熟悉,因为增加了更多的组件。这意味着我们与它们相处的时间变少了,当它们出问题时,快速修复它们变得很困难。除非我们能从错误本身得到帮助。如果上面的错误改为:ActionError: The "query" component does not support the "save" action会怎样?

坦白说,在生成的错误消息中包含这种具体细节确实增加了我们代码的复杂性。然而,如果我们能在提供具体检查和让代码自然失败之间找到平衡,那么这些好处将被证明是有用的。例如,为永远不会发生的事情编写错误检查和详细消息是完全没有意义的。只关注那些收益大的场景。也就是说,如果存在强烈的错误发生可能性,那么该消息可以指向一个快速的解决方案。

当我们快速失败时,我们应该抛出自定义异常。这使得错误在控制台明确显示,我们可以提供有意义的帮助开发者诊断问题。抛出异常是快速失败的简单方法,因为一旦抛出,当前的执行栈将停止运行。

关于潜在失败的警告

错误信息和警告信息之间的区别是,后者意味着系统仍在正常运行,尽管不是最优的。例如,如果我们对给定集合中的物品数量施加一些约束,当接近这个限制时,我们可以发出警告。这个功能带来了与增强错误消息相同的关注点——涉及更多的代码和复杂性。

那么,如果我们已经建立了强大的错误处理机制,还有什么意义呢?警告很好,因为它们在开发者工具的控制台中以视觉方式区分开来。错误有功能障碍的含义,但是警告要表达的不是这个意思。我们试图表明的是,可能会发生不好的事情。例如,如果我们把汽车引擎转速提高到很高,我们会注意到转速表指针进入了红色区域。这是一个警告,意味着如果这种行为继续下去,可能会发生“不好的事情”。

警告背后的模糊性实际上是有帮助的,但对于错误,我们追求的是具体性。我们希望警告是通用的,这样它们就可以对我们应用程序的状态做出广泛的断言。这意味着我们的日志不会被开始重复的小警告信息填满。到这个时候,它们就失去了所有的意义。如果它们是通用的,它们可以在我们诊断错误时帮助错误病理。它们大多数时候作为线索,告诉我们几秒钟后发生了什么错误。如果我们和一个更有经验的用户一起调试,他们可能会把这些警告传递给我们。对于不太涉及的 users,我们需要一种更友好的方法来解决问题。

通知和指导用户

我们在此节中讨论的错误和警告通常最终都会显示在开发者工具的控制台中。这说明我们并不太关心用户是否能看见这些错误或警告。对于我们希望用户看到的消息,它们需要成为界面的一部分——我们不能依赖开发者工具是否打开或存在。一些相同的错误信息原则也适用于我们明确显示给用户的消息。例如,我们想要通知用户出了问题。我们如何具体地表达这个信息由我们决定。在这里我们也要考虑受众——告诉他们一个组件状态必须在调用某个方法之前是某种特定的状态,是没有帮助的。

然而,如果我们能够将错误的名称翻译成用户可以看到并直接与之交互的功能,那么它们会立即理解。现在他们拥有了不工作的东西。他们可能不在乎为什么它不行——他们能得到这些信息又能做什么呢?更好的做法是跟随指令。这个坏了,所以你需要这么做。这个努力是值得实施的,因为从规模上讲,软件正在处理许多我们需要人工干预的问题,这是无法规模化的。它还使用户继续使用我们的软件——这本身就是一种很大的规模影响力。

有时并没有好的指令。也就是说,用户需要的功能就是不起作用,而且他们对此无能为力。然而,我们仍然可以努力让消息告诉他们这个功能已经停止工作。开发者工具控制台中的错误信息可能包含更多关于出错原因的相关信息。然而,我们希望在没有在 UI 上做些用户友好的事情的情况下不要提出异常。这样我们就是在为两个受众服务——开发者和用户。

改进架构

如果我们想要架构能够扩展,就需要有健壮的方法来处理失败的组件。但这种方法只能让我们走得更远——因为反复处理同样的失败是不具备扩展性的。尽可能消除失败的可能性才是具备扩展性的。添加新组件会引入新的失败模式,我们需要为此做好准备,并通过消除旧的失败模式来抵消这些影响。

这是通过设计来实现的;特别是经过修订的设计。变化可能是一个小的调整,也可能是方向上的根本转变。这真的取决于频率、严重性以及增长速度。综合考虑所有这些因素,我们会得出一些设计上的权衡,使我们能够向前推进。

有几种技术可以帮助我们实现这一点。例如,当我们遇到新的失败场景时,我们需要有一种一致的方式来记录它们,我们需要更好地将我们的组件分类为关键组件和非关键组件。正如往常一样,我们需要保持事情的简单性。

记录失败场景

端到端测试是记录场景的好方法。特别是那些导致我们的软件失败的场景。我们可以设计一些这些场景,在我们设计和实现功能时即兴创作。但端到端测试的亮点在于能够重现在生产环境中实际发生的失败。这些测试不仅对于重现错误以便我们知道它已经被修复是必不可少的,而且对于历史保存也是必要的。

随着时间的推移,我们会积累起端到端测试,这些测试模拟真实生活中的场景;其中一个客户实际做过的事情,导致了失败。这使得我们的软件在实现层面上变得更强大。在一定程度上,我们的软件由于需要考虑每个端到端测试而设计上存在缺陷。想法是改进架构,以至于某些失败根本不可能发生。

假设我们有一些端到端测试在获取给定集合时失败。结果发现,我们发送参数的方式,每次请求都发送,实际上并不需要。此外,我们解析响应的方式也可以得到改进——某些部分是静态的。这些都是架构上的改进,因为它们具有通用性,适用于我们的数据模型,并且因为生成了失败的代码已经不再存在,从而消除了某些失败。

改进组件分类

关键组件不能失败,它们是我们核心应用的一个组成部分——如果它们失败了,那么整个应用也会失败。这就是为什么我们只有很少的关键组件;可能只有几个与每个组件都有关的组件,绝对需要按预期运行。另一方面,非关键组件即使失败了,也不会导致整个应用失败。或者,它们可以尝试从失败中恢复,以保持用户体验的流畅。

尽管我们对关键组件的分类相对是静态的,但这并不总是如此。例如,我们可能有一个功能组件,我们认为它不是关键的,应用可以在没有它的情况下生存。这在过去可能是正确的,但现在我们的应用已经增长,而这个组件以不明显的方式触及其他每个组件——所以它不能失败是关键。

关键组件是否会失去其关键性?它们更可能被完全移除出设计,而不是被降级为非关键组件。然而,我们需要确保我们始终对我们关键组件有一个清晰的理解。这是我们架构的一个重要特性——拥有不可能失败的组件。如果它们确实失败了,那么这就被认为是整个应用的失败。在我们扩展应用时,我们必须保持这种架构特性不变,这通常意味着我们要确保我们识别出随着引入的新关键组件。

复杂性促进失败

复杂组件有很多内部部件,并且它们以多种方式与它们的环境相连。随着复杂性,我们有隐含的状态,这些通常在组件失败后才被发现。我们只是在心理上无法把握复杂设计。当设计师们自己都无法把握设计时,他们怎么可能把握所有的失败模式呢?

复杂性伤害我们有两种方式。第一种是在触发失败本身。由于所有活动部件,我们错过了在更简单的组件中明显的边缘情况。我们必须引入大量的错误处理代码来考虑复杂性,使得组件更加复杂,引发更多的失败。循环重复自己。

复杂性伤害我们的第二种方式是在它们发生时处理失败。例如,具有少量活动部件的简单组件以明显的方式失败。即使我们错过了它们并且后来必须去修复,修复它们也不需要时间。这是因为我们要在心理上处理的实在太少。简洁促进安全。

总结

本章向我们介绍了大规模 JavaScript 应用的各种失败模式。快速失败模式意味着一旦我们检测到问题,就会立即停止一切,以努力防止进一步的损害。当我们的应用中的关键组件出现问题时,这通常是可取的。

容错性是一种架构特性,意味着系统能够检测错误,并防止它们干扰正常运行。在 JavaScript 环境中,这通常意味着捕获异常,并防止它们干扰其他组件。组件可以从错误中恢复有几种方式,包括重试操作,或者重启自身,以清除不良状态。

错误处理增加了我们代码的复杂度,如果处理不当,还会对性能产生影响。为了避免这些问题,我们必须追求简单的不操纵状态的组件,并避免过度的异常处理。错误信息可以帮助程序员和用户获取他们需要的信息,更好地应对失败。最终目标是将失败转化为改进的设计,彻底消除有问题的代码。

大规模应用 JavaScript 确实是可行的,尽管有时它看起来像是一个无法逾越的障碍。为了得到正确的答案,我们首先需要提出正确的问题。我希望这本书已经为您提供了形成关于如何扩展您的 JavaScript 应用程序问题的必要知识。在正确的情境中,关注正确的扩展影响因素,并在正确的时间,将为您提供答案。

posted @ 2024-05-23 14:41  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报