规模化的-JavaScript-一-

39 阅读1小时+

规模化的 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 调用或本地存储。我们需要将我们智能组件与系统边缘隔离开来。

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

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