六、设计关注点——组织和解耦
组织大型应用可能很复杂。根据应用的结构以及各部分之间的相互依赖程度,决定如何组织代码并不总是显而易见的。在使用您不熟悉的框架时,情况更为真实。
有许多方法可以组织 Aurelia 应用。就像任何与设计和架构相关的东西一样,选择一个组织模型是一个需要考虑很多标准的问题。显然,选择一种模式而不是另一种模式意味着从其优势中获益,但要处理其缺点和局限性。
在本章中,我们将首先看到组织应用的不同方式,以及可以帮助我们做到这一点的框架的各种特性。当然,我们将对联系人管理应用进行重构,使其具有更大的可扩展性。在我们确定一个稳定的结构之前,我们将玩弄不同的想法。
其次,如果构成应用的组件紧密耦合,那么基于组件的框架是没有意义的。在本章的后半部分,我们将看到使用数据绑定、共享服务或 Aurelia 的事件聚合器来解耦组件的不同方法。
重新组织我们的申请
在开始探索应用的结构可能性之前,我们首先需要确定我们的目标。如果我们不知道我们在一个组织模型中追求什么样的属性,我们就无法做出明智的决定。
当然,这些属性在这里是绝对任意的。在一个真正的项目中,有一个真正的客户、真正的利益相关者和真正的用户,我们至少会有一些关于这些属性可能是什么的线索。在我们的联系人管理应用中,我们将坚持使用典型的中大型项目中最常用的属性。
首先,我们假设我们的应用注定会增长。目前,它只管理联系人,但我们可以想象,我们的产品所有者对该应用有着宏伟的计划,我们最终将添加一些完全无关的功能。
当前的结构,或者没有,适合一个小的应用。对于更大的应用,具有更独特的功能,项目的结构必须确保开发人员不会在代码中迷失方向。在我们的应用上下文中,我们需要选择一种结构,这种结构可以最大限度地减少一段时间后需要重新组织的可能性,因为它的结构不可伸缩。
第二,我们将努力实现一种架构,使功能尽可能地解耦和独立。目标是使包含和排除应用的功能尽可能容易。这一要求对于大多数应用来说并不典型,但在本例中,它将允许我们了解 Aurelia 在需要时如何帮助实现这一点。
重构结构
目前,我们的应用基本上没有任何结构,除了全局资源和验证设置,它们作为功能分组在自己的目录中。所有与联系人管理功能相关的文件都位于src
目录的根目录下,组件与 API 网关和模型混合。让我们在那里整理一下。
注
在chapter-6/samples/app-reorganized
中找到的示例说明了按照下一节所述进行重组后的应用。可作为参考。
让我们首先将所有与联系人管理相关的代码分组到一个contacts
目录中。这会将每个功能隔离在其自己的目录中。此外,为了减少冗余,让我们重命名以contact-
开头的文件并删除前缀。
项目结构应如下所示:
这已经更好了。但是,我们可以通过创建子目录来根据文件的职责类型对其进行分组,从而增强内聚性。这里,我们首先有组件creation
、details
、edition
、list
和photo
。我们还有一项服务:gateway
。最后,我们有一些models
,它们都分组在同一个文件中。
模型分解
让我们首先将模型分解到一个新的models
目录中,然后分解models.js
文件,将每个模型类移动到这个新目录中自己的文件中。应该是这样的:
现在,只要看一眼models
目录,开发人员就可以看到我们有多个模型以及它们的名称。
当然,这意味着我们必须对这些类执行一些更改。首先,我们必须在address.js
、email-address.js
、phone-number.js
、social-profile.js
的顶部添加import
语句进行验证:
import {ValidationRules} from 'aurelia-validation';
接下来,必须在contact.js
的顶部添加其他模型类的import
语句:
import {PhoneNumber} from './phone-number';
import {EmailAddress} from './email-address';
import {Address} from './address';
import {SocialProfile} from './social-profile';
隔离网关
gateway
与其他文件不同,它是一种服务。通常,服务是为应用的其他部分提供一些功能的单例。在这里,我们只有这一个服务,但它仍然值得放在它自己的目录中,因此更容易找到。
让我们创建一个services
目录并将gateway
移动到那里:
要使gateway
像以前一样工作,首先需要更改的是通过删除./
前缀使environment import
语句的路径成为绝对路径:
import environment from 'environment';
我们还需要更改导入Contact
类的路径:
import {Contact} from '../models/contact';
组件分组
最后,我们可以将可视组件分组到它们自己的目录中。让我们创建一个components
目录,并移动其中的其余文件:
此时,应用被破坏。我们需要做两件事:修复组件中模型类和网关的import
和require
语句,以及修复app
组件中的路由声明。
首先,在creation.js
、details.js
、edition.js
、list.js
和photo.js
中,网关的import
语句必须固定:
import {ContactGateway} from '../services/gateway';
此外,Contact
模型的import
语句也必须固定在creation.js
中:
import {Contact} from '../models/contact';
最后,我们需要修改creation.html
和edition.html
中的require
语句,修复路径并添加别名,这样form.html
模板仍然作为contact-form
自定义元素加载:
<require from="./form.html" as="contact-form"></require>
此时,我们的contacts/components
已准备就绪。我们只需要修复app
组件中所有路由声明的组件路径:
config.map([
{ route: '', redirect: 'contacts' },
{ route: 'contacts', name: 'contacts',
moduleId: 'contacts/components/list', nav: true, title: 'Contacts' },
{ route: 'contacts/new', name: 'contact-creation',
moduleId: 'contacts/components/creation', title: 'New contact' },
{ route: 'contacts/:id', name: 'contact-details',
moduleId: 'contacts/components/details' },
{ route: 'contacts/:id/edit', name: 'contact-edition',
moduleId: 'contacts/components/edition' },
{ route: 'contacts/:id/photo', name: 'contact-photo',
moduleId: 'contacts/components/photo' },
]);
文件结构现在更干净了。如果现在运行应用,一切都应该像以前一样工作。
没有银弹
我们刚刚重构的结构不是一个普遍的事实。品味和观点总是在这样的决定中起作用,对于这类问题没有正确或错误的答案。
然而,这种结构背后的原理很简单,可以归结为几个原则:
- 通用或应用范围的资源位于
resources
功能中。像order-by
值转换器或file-picker
自定义元素之类的东西就属于这里。 - 类似地,不属于特定功能,但在应用范围内使用的服务和模型应该位于它们自己的目录中,位于
src
目录的根目录中;例如,在src/services
和src/models
中。我们的应用中没有这些。 - 每个域功能都位于自己的目录中,例如
contacts
目录。 - 技术特性也可以存在,例如
validation
特性。这些特性的目的是提供一些通用行为或扩展其他特性。 - 在功能目录中,文件按职责类型分组。组件,无论是路由组件,如
creation
、details
、edition
、list
和photo
,还是专用小部件或自定义元素,如form.html
模板,都被分组在components
子目录中。服务和模型也在它们自己的目录中。如果给定功能存在专门的值转换器或绑定行为,那么它们也应该位于功能目录中自己的目录中。
这些是我在构建 Aurelia 应用时使用的准则。当然,经常有需要反思的案例,要么因为它们没有直接进入现有的位置,要么因为盲目应用这些规则会造成混乱。
例如,如果我们有很多路由组件和专门的小部件,那么最好将components
目录一分为二,命名为screens
和widgets
。通过这种方式,将更容易识别哪些组件是路由组件,哪些是特定于功能的自定义元素或可组合的小部件。
此外,有时最好向结构中添加另一级别的分类,无论是按子域或类别对特征进行分组,还是按更具体的目的对服务、模型或组件进行分组。这里真正的指导方针是尽量使结构传达意图和隐含的知识,并尽可能容易地理解每个部分的位置。
我尝试遵循的另一个准则是使域功能目录镜像导航菜单结构。当然,当菜单结构太复杂时,这是不可行的,尽管这可能是需要重新思考的迹象。在可能的情况下,它显然使开发人员更容易和直观地浏览代码和应用。
利用子路由器
此时,所有与联系人管理相关的代码都位于contacts
目录中。但真的是这样吗?实际上,没有。路由定义仍然位于app
组件中。我们怎样才能将它们移动到contact
目录中?
第一种可能是利用子路由器。这样,我们可以在contacts
中声明一个main
组件,该组件将负责声明到各种联系人管理组件的路由,例如list
、creation
和edition
。然后,app
组件将需要一条通向联系人main
组件的单一路径,而不需要知道更专业的contacts
路径。
注
在下面的部分中,我们将尝试不同的方法。为了使代码恢复到每次尝试之前的状态更容易,我建议您在此时以某种方式备份应用,或者简单地复制和粘贴项目目录,或者如果从 GitHub 克隆代码,则在源代码管理上创建分支。此外,在chapter-6/samples/app-using-child-router
处找到的示例说明了如以下部分所述修改的应用。可作为参考。
更改根路径
让我们从更改根路由器配置开始:
src/app.js
export class App {
configureRouter(config, router) {
this.router = router;
config.title = 'Learning Aurelia';
config.map([
{ route: '', redirect: 'contacts' },
{ route: 'contacts', name: 'contacts', moduleId: 'contacts/main',
nav: true, title: 'Contacts' },
]);
config.mapUnknownRoutes('not-found');
}
}
在这里,我们删除所有通向各种联系人管理组件的路由,并将它们替换为映射到contacts
URL 前缀的单个路由。该路线通向contacts
的main
部分。当然,我们保留默认路由重定向到此contacts
路由。
配置联系人子路由器
接下来,我们需要创建contacts
的main
组件:
src/contacts/main.js
import {inlineView} from 'aurelia-framework';
@inlineView('<template><router-view></router-view></template>')
export class Contacts {
configureRouter(config) {
config.map([
{ route: '', name: 'contacts',
moduleId: './components/list', title: 'Contacts' },
{ route: 'new', name: 'contact-creation',
moduleId: './components/creation', title: 'New contact' },
{ route: ':id', name: 'contact-details',
moduleId: './components/details' },
{ route: ':id/edit', name: 'contact-edition',
moduleId: './components/edition' },
{ route: ':id/photo', name: 'contact-photo',
moduleId: './components/photo' },
]);
}
}
在这里,我们首先使用inlineView
装饰器声明一个模板,该模板仅使用router-view
元素呈现子路由器的活动组件。此子路由器使用configureRouter
方法进行配置,该方法声明之前app
组件中的contacts
路由。
当然,路由声明需要稍微更改。首先,必须从每个路由的route
属性中删除contacts/
前缀,因为它现在由父路由器处理。因此,通向list
组件的路由现在是子路由器的默认路由,因为它的模式匹配一个空字符串。此外,moduleId
属性可以设置为相对属性,而不是绝对属性,就像以前一样。如果我们重命名或移动contacts
目录,这将减少所做的更改量。最后,由于该子路由器的导航模型不用于呈现任何菜单,因此我们可以从通向列表的路由中删除nav
属性。
含义
如果您运行应用并使用它,您可能会注意到,当我们现在浏览creation
、details
、edition
和photo
组件时,联系人顶部菜单项保持高亮显示,而之前只有在list
组件处于活动状态时才高亮显示。
这是因为此菜单项是使用指向contacts
的main
组件的路由呈现的,当我们在任何子路由上时,该组件保持激活状态。这是一个有趣的副作用,它增加了对用户的反馈,并使顶部菜单的行为更加一致。
此外,使用子路由器将承担在模块内部声明模块路由的责任。如果需要更改模块的路由,这些更改将在模块的边界内进行,并且不会影响应用的其余部分。
然而,子路由器有一些限制。通常,在编写本文时,路由器在生成 URL 时只能访问自己的路由。这意味着您不能对其他路由器中定义的路由使用route-href
属性,也不能使用Router
类的generate
或navigateToRoute
方法,无论它们是父路由器、子路由器还是同级路由器。当模块之间需要有直接链接时,这可能会出现问题。必须手动生成路由,这意味着可以在多个位置定义路由模式,如果路由模式发生更改,并且开发人员仅更新部分模式实例,则会增加引入错误的风险。
在功能中声明根路由
另一个可能有用的工具是 Aurelia 的feature
系统。我们可以利用configure
功能直接在根路由器上注册联系人管理路由。
让我们回顾一下插入子路由器之前的情况,看看结果如何。
注
在chapter-6/samples/app-using-feature
处找到的示例说明了如以下部分所述修改的应用。可作为参考。
创建特征
我们首先需要为我们的新特性configure
创建index.js
文件:
src/contacts/index.js
import {Router} from 'aurelia-router';
const routes = [
{ route: 'contacts', name: 'contacts',
moduleId: 'contacts/components/list', nav: true, title: 'Contacts' },
{ route: 'contacts/new', name: 'contact-creation',
moduleId: 'contacts/components/creation', title: 'New contact' },
{ route: 'contacts/:id', name: 'contact-details',
moduleId: 'contacts/components/details' },
{ route: 'contacts/:id/edit', name: 'contact-edition',
moduleId: 'contacts/components/edition' },
{ route: 'contacts/:id/photo', name: 'contact-photo',
moduleId: 'contacts/components/photo' },
];
export function configure(config) {
const router = config.container.get(Router);
routes.forEach(r => router.addRoute(r));
}
这里,configure
函数只是从 DI 容器中检索根路由器,然后使用Router
类的addRoute
方法注册路由。因为这里没有子路由器,所以路由使用其完整 URL(包括contacts/
前缀)进行映射,并且它们使用绝对路径来引用它们的组件,因为它们与声明根configureRouter
方法的组件(这里是app
)相关。
当然,这意味着我们需要将此功能加载到应用的主configure
功能中:
src/main.js
//Omitted snippet...
export function configure(aurelia) {
aurelia.use
.standardConfiguration()
.feature('validation')
.feature('resources')
.feature('contacts');
//Omitted snippet...
}
更改根路径
最后,我们需要从app
组件中删除联系人管理路由:
src/app.js
export class App {
configureRouter(config, router) {
this.router = router;
config.title = 'Learning Aurelia';
config.map([
{ route: '', redirect: 'contacts' },
]);
config.mapUnknownRoutes('not-found');
}
}
在这里,我们只需删除通向各种联系人管理组件的所有路由,除了默认路由重定向到显示list
组件的contacts
路由。
功能上的异径联轴节
应用仍然以两种方式依赖于contacts
功能:将其加载到主configure
功能中,默认路由重定向到app
组件中的一条路由。如果我们想删除此功能,我们现在有两个地方需要更新。我们如何从app
组件中删除依赖关系?
第一种可能是简单地添加一个home
组件,或某种欢迎仪表板,并将其用作默认路由。这样,访问应用根目录的用户将始终在同一位置受到欢迎,即使应用具有更改功能。除了在主configure
函数中,我们也不会引用contacts
功能。
或者,我们可以动态选择默认路由重定向到的路由。由于app
组件的configureRouter
方法是在组件的激活生命周期中调用的,因此该功能当时已经配置好,其路由已经添加到根路由器。我们可以简单地获取路由器的第一个导航模型条目,并将默认路由重定向到它:
src/app.js
function findDefaultRoute(router) {
return router.navigation[0].relativeHref;
}
export class App {
configureRouter(config, router) {
this.router = router;
config.title = 'Learning Aurelia';
config.map([
{ route: '', redirect: findDefaultRoute(router) },
]);
config.mapUnknownRoutes('not-found');
}
}
此解决方案的优点是,默认路由将始终重定向到顶部菜单中显示的第一条路由,这在大多数没有明显主屏幕的应用中是合理的行为。
但是,如果从应用中删除所有功能,导航模型将为空,此代码将中断。在这种情况下,拥有一个独特的主页可以节省时间,尽管在大多数情况下,没有任何功能但只有一个简单主页的应用是毫无意义的。
含义
通过功能或app
组件在根路由器上定义所有应用路由的主要优点之一是,根路由器知道所有路由,这意味着它可以为应用中的任何路由生成 URL。
当组件和特性之间存在大量链接时,这种区别是不可忽略的。在这种情况下,使用子路由器而不能依靠路由器生成大部分 URL 是痛苦的。
为什么不能两者兼而有之?
我们刚才探讨的两种解决方案都有其优缺点。使用子路由器感觉是正确的做法,主要是因为它修复了顶部菜单中不一致的行为,这可能比它应得的更困扰我,但使跨功能的链接变得复杂。此外,它需要在app
组件中声明通向联系人main
组件的路线。
另一方面,使用功能也感觉不错。特性是专门为此类用例设计的。
让我们尝试合并这两种策略:在main
组件中声明一个子路由器来处理联系人的路由,并使用一个特性在根路由器上添加通向此main
组件的路由。
注
以下代码片段摘自本章完整的示例应用,可在chapter-6/app
中找到。
如果我们保留在上一节中引入contacts
功能时所做的修改,这意味着我们需要添加main
组件,就像我们玩子路由器时所做的一样:
src/contacts/main.js
import {inlineView} from 'aurelia-framework';
@inlineView('<template><router-view></router-view></template>')
export class Contacts {
configureRouter(config) {
config.map([
{ route: '', name: 'contacts',
moduleId: './components/list', title: 'Contacts' },
{ route: 'new', name: 'contact-creation',
moduleId: './components/creation', title: 'New contact' },
{ route: ':id', name: 'contact-details',
moduleId: './components/details' },
{ route: ':id/edit', name: 'contact-edition',
moduleId: './components/edition' },
{ route: ':id/photo', name: 'contact-photo',
moduleId: './components/photo' },
]);
}
}
接下来,该功能的configure
功能必须更改,以便添加通向contacts
的main
组件的路径:
src/contacts/index.js
import {Router} from 'aurelia-router';
export function configure(config) {
const router = config.container.get(Router);
router.addRoute({ route: 'contacts', name: 'contacts',
moduleId: 'contacts/main', nav: true, title: 'Contacts' });
}
使用此模式,可以轻松添加新功能,而无需更改任何内容,只需将其加载到主configure
函数中即可。当您还需要更改app
组件时,唯一的情况是在不使用动态方法的情况下更改默认路由重定向到的功能时。
注
我并不主张在每个 Aurelia 应用中使用此模式。它增加了复杂性,因此,只有在真正需要时才应该使用它。这里的主要目标是展示框架提供的可能性。
解耦元件
决定程序的组件如何相互依赖以及如何相互通信是设计的全部内容。设计 Aurelia 应用也不例外。然而,为了做出明智的设计选择,您需要知道框架提供了哪些技术。
在 Aurelia 应用中,让组件进行通信通常有四种方法:使用数据绑定、使用远程服务、使用共享服务和使用事件。
到目前为止,我们的应用主要依赖于数据绑定和远程服务,即我们的后端。路由组件不直接相互通信,而是通过后端进行通信。每次激活时,每个路由组件都会从后端检索所需的数据,然后将用户执行的任何操作委托回后端。此外,路由组件由其他可重用组件组成,并使用数据绑定与它们通信。
在以下部分中,我们将从快速总结已经使用的技术开始,然后讨论其他技术:事件和共享服务。在这样做的过程中,我们还将对联系人管理应用进行重大重构,以便我们可以尝试基于这些技术的完全不同的体系结构。
作为一个实验,我们将首先重构我们的应用,这样当事情发生时,我们可以监听并在本地分派后端发送的事件。这样,任何需要对此类事件做出反应的组件都可以简单地订阅本地事件。
完成后,我们将使用这些本地事件进一步重构我们的应用,这次是向实时、多用户同步方向。我们将创建一个服务,该服务将加载联系人列表,然后侦听更改事件以保持其联系人同步。我们将重构所有路由组件,以便它们从本地联系人列表中检索数据,而不是在每次激活时从后端获取数据。
流程与此类似:
当用户执行操作时,例如创建新联系人或更新现有联系人,将向后端发送命令。这不会改变。但是,应用不会在每次显示联系人列表组件时从后端重新加载整个数据集,而是简单地显示其数据的本地副本,因为它将通过侦听更改事件来保持数据的最新状态,更改事件在每次发送命令时由后端发出。
这种新的设计借鉴了CQRS/ES模式的一些概念。这种模式的一个优点是,每当任何用户对数据进行更改时,应用都会立即收到通知,因此应用会不断与服务器的状态同步。
注
CQR 代表命令和查询责任分离,ES 代表事件源。如果您对这些模式的定义超出了本书的范围,您可以查看 Martin Fowler 对它们的看法,如果您感到好奇的话:http://martinfowler.com/bliki/CQRS.html 和http://martinfowler.com/eaaDev/EventSourcing.html 。
当然,整个同步机制需要在生产就绪的应用中进行某种形式的冲突管理。实际上,当用户编辑联系人时,如果另一个用户对同一联系人进行更改,第一个用户将看到表单正在动态更新,新值将覆盖他自己的更改。那太糟糕了。然而,我们不会走这么远。让我们把这看作是一个概念证明和一个使组件通信的方法的实验。
使用数据绑定
使组件通信的最常见和最简单的方法是通过数据绑定。我们已经看到了很多这样的例子;当我们将edit
组件的contact
属性与form
组件的contact
可绑定属性绑定时,我们让它们通信。
数据绑定允许模板内组件的松散耦合。当然,它有一些固有的限制:绑定由父组件声明,通信仅限于应用树中的一层组件。使通信达到多个级别需要树中的每个组件都将数据绑定到其子级。我们可以在photo
组件中看到这一点,它的files
属性绑定到file-picker
的files
属性,而file-picker
的files
属性又绑定到file-drop-target
属性,支持跨多个组件层的通信。
它也是使组件通信的更灵活的方式,因为它非常容易更改,而且依赖关系位于模板中,组件本身在模板中声明和组合。
使用远程服务
使组件通信的另一种方法是通过远程服务。在我们的应用中,我们也经常使用这种技术。应用存储的状态很少;后端是状态的实际存储库。
为了显示要修改的联系人,edition
组件向后端查询联系人的数据。当用户保存联系人的修改时,将向后端发送更新命令,该命令将更改应用于其内部状态。然后,当应用将用户带回联系人的详细信息时,组件将查询联系人数据的新副本。导航到联系人列表时也会发生同样的情况:每次都会查询后端,每次都会获取整个联系人列表。
这种技术非常普遍。在这种情况下,应用将其后端视为真相的唯一来源,并依赖它来完成一切。这样的应用可以简单得多,因为业务规则和命令的复杂副作用等都可以完全由后端处理。应用只是一个位于后端之上的丰富用户界面。
然而,这种技术的缺点是,如果通信线路中断,应用将毫无用处。在网络故障的情况下,或者后端由于某种原因不负责任时,应用将不再工作。
使用事件
一种广泛用于减少耦合的设计技术是发布/订阅模式。应用此模式时,组件可以订阅消息总线,以便在发送特定类型的消息时得到通知。然后,其他组件可以使用相同的消息总线发送消息,而不知道哪些组件将处理它们。
使用此模式,各个组件之间没有任何依赖关系。相反,它们都依赖于消息总线,消息总线就像它们之间的一种抽象层。此外,此模式大大提高了设计的灵活性和可扩展性,因为新组件可以非常轻松地订阅现有消息类型,而无需更改其他组件。
Aurelia 通过其aurelia-event-aggregator
库提供EventAggregator
类,该类可以充当这样的消息总线。我们将在下一节中看到如何从这门课中获益。
事件聚合器
aurelia-event-aggregator
库是默认配置的一部分,因此,默认情况下,我们不需要安装或加载任何东西来使用它。
此库导出EventAggregator
类,该类公开了三种方法:
publish(name: string, payload?: any): void
:发布命名事件以及可选负载。subscribe(name: string, callback: function): Subscription
:订阅命名事件。每次使用订阅的name
发布事件时,都会调用callback
函数。传递给publish
方法的payload
将作为其第一个参数传递给callback
函数。subscribeOnce(name: string, callback: function): Subscription
:订阅命名事件,但仅订阅一次。首次发布事件时,将自动释放订阅。订阅将被返回,因此甚至可以在事件发布之前手动处理。
subscribe
和subscribeOnce
方法返回的Subscription
对象有一个方法,名为dispose
。此方法只是从已注册的处理程序中删除callback
函数,以便在发布事件时不再调用它。
例如,某些组件可以使用以下代码发布名为something-happened
的事件:
import {inject} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
@inject(EventAggregator)
export class SomeComponent {
constructor(eventAggregator) {
this.eventAggregator = eventAggregator;
}
doSomething(args) {
this.eventAggregator.publish('something-happened', { args });
}
}
在这里,组件的构造函数将被注入一个EventAggregator
实例,然后将其存储在组件上。然后,当调用doSomething
方法时,名为something-happened
的事件将发布在事件聚合器上。事件的有效负载是一个具有args
属性的对象,该属性包含传递给doSomething
方法的args
参数。
为了对此事件做出反应,另一个组件可以订阅该事件:
import {inject} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
@inject(EventAggregator)
export class AnotherComponent {
constructor(eventAggregator) {
this.eventAggregator = eventAggregator;
}
activate() {
this.subscription = this.eventAggregator.subscribe('something-happened', e => {
console.log('Something happened.', e.args);
});
}
deactivate() {
this.subscription.dispose();
}
}
在这里,另一个组件的构造函数也被注入了事件聚合器,它存储在组件上。激活后,组件开始侦听something-happened
事件,因此每次发布一个日志时,它都可以将日志写入浏览器控制台。它还保留对订阅的引用,以便在停用时可以dispose
它并停止侦听事件。
在组件中使用事件聚合器时,这种模式非常常见。使用它可以确保组件仅在事件处于活动状态时侦听事件。它还可以防止内存泄漏;事实上,如果事件聚合器仍然持有对组件的引用,则无法对组件进行垃圾收集。
使用事件扩展对象
除了EventAggregator
类之外,aurelia-event-aggregator
库还导出一个名为includeEventsIn
的函数。它需要一个对象作为它的单个参数。
此函数可用于使用事件聚合器的功能扩展对象。它将在内部创建一个EventAggregator
实例,并向对象添加一个publish
、一个subscribe
和一个subscribeOnce
方法,所有这些都将委托给这个新EventAggregator
实例的对应方法。
例如,通过在类构造函数中调用此函数,可以使该类的所有实例都具有自己的本地事件。让我们想象一下下面的课程:
import {includeEventsIn} from 'aurelia-event-aggregator';
export class SomeModel {
constructor() {
includeEventsIn(this);
}
doSomething() {
this.publish('something-happened');
}
}
something-happened
事件可以直接在SomeModel
实例上订阅:
const model = new SomeModel();
model.subscribe('something-happened', () => {
console.log('Something happened!');
});
由于每个实例都有自己的私有EventAggregator
实例,因此事件不会在整个应用中共享,甚至不会在多个实例中共享。相反,事件的作用域将分别限定到每个实例。
使用事件类
publish
、subscribe
和subscribeOnce
方法可以用于命名事件,但它们也支持类型化事件。因此,以下签名同样有效:
publish(event: object): void
:发布事件对象。使用对象的原型作为键来选择要调用的回调函数。subscribe(type: function, callback: function): Subscription
:订阅一类事件。每次发布作为订阅的type
实例的事件时,都会调用callback
函数。发布的事件对象本身将作为其单个参数传递给callback
函数。subscribeOnce(type: function, callback: function): Subscription
:订阅某类事件,但只订阅一次。
作为示例,让我们设想以下事件类:
export class ContactCreated {
constructor(contact) {
this.contact = contact;
}
}
发布此类事件的方式如下:
eventAggregator.publish(new ContactCreated(newContact));
在这里,我们可以想象,eventAggregator
变量包含一个EventAggregator
类的实例,newContact
变量包含一些表示新创建联系人的对象。
订阅此活动的方式如下:
eventAggregator.subscribe(ContactCreated, e => {
console.log(e.contact.fullName);
});
这里,每次发布ContactCreated
事件时都会调用回调,其e
参数将是发布的ContactCreated
实例。
此外,EventAggregator
在处理事件类时支持继承。这意味着您可以订阅事件基类,并且每次发布从该基类继承的任何事件类时都将调用回调函数。
让我们回到前面的示例,添加一些事件类:
export class ContactEvent {
constructor(contact) {
this.contact = contact;
}
}
export class ContactCreated extends ContactEvent {
constructor(contact) {
super(contact);
}
}
在这里,我们定义一个名为ContactEvent
的类,ContactCreated
类从中继承。
现在让我们设想以下两种订阅:
eventAggregator.subscribe(ContactCreated, e => {
console.log('A contact was created');
});
eventAggregator.subscribe(ContactEvent, e => {
console.log('Something happened to a contact');
});
执行此代码后,如果发布了ContactEvent
的实例,则会将文本Something happened to a contact
记录到控制台。
但是,如果发布了一个ContactCreated
实例,那么文本A contact was created
和Something happened to a contact
都将被记录到控制台,因为事件聚合器将进入原型链,并尝试查找所有祖先的订阅。在处理复杂的事件层次结构时,此功能非常强大。
基于类的事件为消息传递添加了一些结构,因为它们强制事件负载遵守预定义的约定。根据您的编程风格,您可能更喜欢使用强类型事件,而不是使用具有非类型有效负载的命名事件。它特别适合类型化 JS 超集,如 TypeScript。
创建交互连接
下面是某种实验,或者概念的证明,我建议您在此时以某种方式备份应用,或者简单地复制和粘贴项目目录,或者在源代码控制上创建分支,如果您从 GitHub 克隆代码。这样,当您继续下一章时,您将能够从当前点开始。
注
此外,在chapter-6/samples/app- using-server-events
处找到的示例说明了按照以下部分所述修改的应用。可作为参考。
我们使用的后端接受交互式连接,以便向客户端应用发送事件。使用这种交互式连接,它可以在每次创建、更新或删除联系人时通知连接的客户端。为了调度这些事件,后端依赖于WebSocket协议。
注
WebSocket 协议允许在客户端和服务器之间建立长期的双向连接。因此,它允许服务器向连接的客户端发送基于事件的消息。
在本节中,我们将创建一个名为ContactEventDispatcher
的服务。此服务将创建一个与后端的 WebSocket 连接,并将侦听来自服务器的更改事件,以便通过应用的事件聚合器在本地分派它们。
为了创建与服务器的交互连接,我们将使用socket.io库。
注
io 库为交互连接提供了客户端实现和 node.js 服务器,两者都支持 WebSocket,并且在不支持 WebSocket 时提供回退实现。后端已使用此库处理来自应用的交互式连接。可在找到 http://socket.io/ 。
我们先安装socket.io
客户端。在项目目录中打开控制台并运行以下命令:
> npm install socket.io-client --save
当然,新的依赖项必须添加到应用的捆绑包中。在aurelia_project/aurelia.json
中,在build
下,然后在bundles
下,在名为vendor-bundle.js
的捆绑包的dependencies
部分,添加以下条目:
{
"name": "socket.io-client",
"path": "../node_modules/socket.io-client/dist",
"main": "socket.io.min"
},
我们现在可以创建ContactEventDispatcher
类。这个类是一个服务,我们将在contacts
功能的services
目录中创建它:
src/contacts/services/event-dispatcher.js
import {inject} from 'aurelia-framework';
import io from 'socket.io-client';
import environment from 'environment';
import {EventAggregator} from 'aurelia-event-aggregator';
import {Contact} from '../models/contact';
@inject(EventAggregator)
export class ContactEventDispatcher {
constructor(eventAggregator) {
this.eventAggregator = eventAggregator;
}
activate() {
if (!this.connection) {
this.connection = io(environment.contactsUrl);
this.connecting = new Promise(resolve => {
this.connection.on('contacts.loaded', e => {
this.eventAggregator.publish('contacts.loaded', {
contacts: e.contacts.map(Contact.fromObject)
});
resolve();
});
});
}
return this.connecting;
}
deactivate() {
this.connection.close();
this.connection = null;
this.connecting = null;
}
}
此类需要将EventAggregator
实例传递给其构造函数,并声明activate
方法,该方法使用socket.io
客户端库导入的io
函数与使用environment
的contactUrl
的服务器创建connection
。然后创建一个新的Promise
,分配给connecting
属性并由activate
方法返回。这个Promise
允许监控到后端的连接进程的状态,因此调用方可以在连接建立时挂接到后端以作出反应。此外,该方法还确保在任何给定时间只打开后端的一个connection
。如果多次调用activate
,则返回connecting``Promise
。
当后端接收到新连接时,它将当前联系人列表作为名为contacts.loaded
的事件发送。同样地,activate
方法初始化连接后,它会侦听此事件以在事件聚合器上重新发布它。在这样做的过程中,它还将从服务器接收的对象的初始列表转换为一个由Contact
对象组成的数组。最后解析connecting``Promise
通知呼叫者activate
操作完成。
该类还公开了一个deactivate
方法,该方法关闭并清除连接。
此时,dispatcher 在启动时发布一个包含当前联系人列表的contacts.loaded
事件。但是,后端最多可以发送三种类型的事件:
contact.created
,创建新联系人时contact.updated
,联系人更新时contact.deleted
,删除联系人时
每个事件的有效负载都有一个contact
属性,其中包含执行命令的联系人。
基于此信息,我们可以修改 dispatcher,使其侦听这些事件并在本地重新发布它们:
src/contacts/services/event-dispatcher.js
//Omitted snippet...
export class ContactEventDispatcher {
//Omitted snippet...
activate() {
if (!this.connection) {
this.connection = io(environment.contactsUrl);
this.connecting = new Promise(resolve => {
this.connection.on('contacts.loaded', e => {
this.eventAggregator.publish('contacts.loaded', {
contacts: e.contacts.map(Contact.fromObject)
});
resolve();
});
});
this.connection.on('contact.created', e => {
this.eventAggregator.publish('contact.created', {
contact: Contact.fromObject(e.contact)
});
});
this.connection.on('contact.updated', e => {
this.eventAggregator.publish('contact.updated', {
contact: Contact.fromObject(e.contact)
});
});
this.connection.on('contact.deleted', e => {
this.eventAggregator.publish('contact.deleted', {
contact: Contact.fromObject(e.contact)
});
});
}
return this.connecting;
}
//Omitted snippet...
}
在这里,我们添加了事件处理程序,这样,当后端发送contact.created
事件、contact.updated
事件或contact.deleted
事件时,受影响的联系人将转换为Contact
对象,并在应用的事件聚合器上重新发布该事件。
一旦准备好了,我们需要activate
事件侦听器。我们将在contacts
功能的configure
功能中执行此操作。但是,当启动连接时,dispatcher 使用Contact
类将从后端接收的对象列表转换为Contact
实例。由于Contact
类依赖于要加载的aurelia-validation
插件,并且由于我们不能确定调用configure
函数时插件是否确实加载,所以我们不能在这里使用Contact
,否则在初始化Contact
的验证规则时可能会抛出错误。那我们怎么做呢?
Aurelia 框架配置过程支持配置后任务。这些任务只是加载所有插件和功能后调用的函数,可以使用框架配置对象的postTask
方法添加,并传递给configure
函数:
src/contacts/index.js
import {Router} from 'aurelia-router';
import {ContactEventDispatcher} from './services/event-dispatcher';
export function configure(config) {
const router = config.container.get(Router);
router.addRoute({ route: 'contacts', name: 'contacts', moduleId: 'contacts/main', nav: true, title: 'Contacts' });
config.postTask(() => {
const dispatcher = config.container.get(ContactEventDispatcher);
return dispatcher.activate();
});
}
在这里,我们添加了一个配置后任务,一旦加载了所有插件和特性,它就会激活 dispatcher。另外,由于后期配置任务支持Promise
s,我们可以返回activate
返回的Promise
,因此我们可以确定与后端的交互连接已经完成,并且在框架的引导过程完成时,初始联系人已经加载。
添加通知
此时,contacts
的main
组件侦听服务器事件,并在本地分发它们。然而,我们仍然没有对这些事件采取任何行动。让我们添加一些通知,告诉用户服务器上发生了什么。
我们将添加一个通知系统,该系统将在每次后端发送更改事件时让用户知道。因此,我们将使用一个名为humane.js
的库,可在找到该库 http://wavded.github.io/humane-js/ 。您可以通过在项目目录中打开控制台窗口并运行以下命令来安装它:
> npm install humane-js --save
完成后,还必须让捆绑程序知道此库。在aurelia_project/aurelia.json
中,在build
下,然后在bundles
下,在名为vendor-bundle.js
的捆绑包的dependencies
部分,添加以下片段:
{
"name": "humane-js",
"path": "../node_modules/humane-js",
"main": "humane.min"
},
为了隔离此库的使用,我们将在其周围创建一个自定义元素:
src/contacts/components/notifications.js
import {inject, noView} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
import Humane from 'humane-js';
@noView
@inject(EventAggregator, Humane)
export class ContactNotifications {
constructor(events, humane) {
this.events = events;
this.humane = humane;
}
attached() {
this.subscriptions = [
this.events.subscribe('contact.created', e => {
this.humane.log(`Contact '${e.contact.fullName}' was created.`);
}),
this.events.subscribe('contact.updated', e => {
this.humane.log(`Contact '${e.contact.fullName}' was updated.`);
}),
this.events.subscribe('contact.deleted', e => {
this.humane.log(`Contact '${e.contact.fullName}' was deleted.`);
})
];
}
detached() {
this.subscriptions.forEach(s => s.dispose());
this.subscriptions = null;
}
}
此自定义元素首先需要将一个EventAggregator
实例和一个Humane
对象注入到其构造函数中。当它是 DOM 的attached
时,它订阅contact.created
、contact.updated
和contact.deleted
事件,以便在发布时显示适当的通知。它还将EventAggregator
调用的subscribe
方法返回的订阅存储在一个数组中,因此当它从 DOMdetached
返回时,它能够dispose
这些订阅。
为了使用这个定制元素,我们需要通过添加一个require
语句和这个元素的一个实例来修改特性的main
组件的模板。
但是,main
模板越来越大,所以让我们从视图模型类中删除inlineView
装饰器,并将模板移动到它自己的文件中:
src/contacts/main.html
<template>
<require from="./components/notifications"></require>
<contact-notifications></contact-notifications>
<router-view></router-view>
</template>
最后,我们需要为humane.js
的一个主题添加样式表,这样通知的样式就正确了:
index.html
<!DOCTYPE html>
<html>
<head>
<!-- Omitted snippet... -->
<link href="node_modules/humane-js/themes/flatty.css" rel="stylesheet">
</head>
<body>
<!-- Omitted snippet... -->
</body>
</html>
如果此时运行应用并修改联系人,您将看到通知不会显示。我们错过了什么?
走出陷阱
在将库与 Aurelia 集成时,这是一个棘手的问题,我已经经历过好几次了。这是由body
元素上的aurelia-app
属性引起的。
事实上,一些库在加载元素时会向body
添加元素。这就是humane.js
所做的。加载时,它会创建一个 DOM 子树,将其用作显示通知的容器,并将其附加到body
中。
但是,当 Aurelia 的引导过程结束并呈现应用时,承载aurelia-app
属性的元素的内容将替换为app
组件的呈现视图。这意味着 DOM 元素的humane.js
将尝试用于显示不再在 DOM 上的通知。哎呀。
解决这个问题相当简单。我们需要将aurelia-app
属性移动到另一个元素,这样在呈现应用时body
元素的内容不会被删除:
index.html
<!DOCTYPE html>
<html>
<head>
<!-- Omitted snippet... -->
</head>
<body>
<div aurelia-app="main">
<!-- Omitted snippet... -->
</div>
</body>
</html>
现在,如果刷新浏览器,然后执行某些操作,例如更新联系人,您应该会看到在视口顶部显示几秒钟的通知。
注
根据经验,我从未将aurelia-app
属性直接放在body
中。我通过多次花费太多时间试图弄明白为什么我集成到项目中的外部库不起作用而学到了这一课。
模拟多用户场景
此时,我们的应用能够在服务器上发生更改时通知用户,即使这是由其他用户完成的。让我们测试一个多用户场景。为此,应用必须使用 Aurelia 的 CLI 以外的其他工具运行,因为在撰写本文时,浏览器同步功能会干扰我们的同步机制。
最简单的解决方案是通过运行以下命令安装http-server
节点模块(如果尚未安装):
> npm install -g http-server
然后您可以构建我们的应用:
> au build
完成此命令后,可以启动普通 HTTP 服务器:
> http-server -o -c-1
然后,您可以在两个浏览器窗口中打开应用,并将它们并排放置。在一种情况下,执行创建新联系人或更新现有联系人等操作。您应该会在两个窗口中看到弹出的通知。
使用共享服务
目前,我们的应用大多是无状态的,因为每个路由组件都从服务器加载其数据。不存在依赖于全局状态的路由组件(在其自身范围之外)。
但是,有时应用需要存储全局状态。这种状态通常由某种服务管理,可以使用数据绑定通过组件传播,也可以使用依赖项注入系统注入组件,在这种情况下,依赖项是在 JS 代码中声明和控制的,而不是在模板中。
在很多情况下,本地存储状态是有益的,甚至是必需的。它可以节省带宽并减少对后端的调用次数。如果你想让你的应用离线可用,你可能需要在某个时候在本地存储一个状态。
在本节中,我们将通过创建一个服务来重构应用,该服务将在所有路由组件之间共享,并允许它们访问相同的本地数据。此服务将充当本地数据存储,并依赖于我们在上一节中创建的 dispatcher 发布的事件来初始化其状态并与服务器状态保持同步。
创建内存存储
我们将通过创建一个新服务开始重构,我们称之为ContactStore
:
src/contacts/services/store.js
import {inject} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
import {Contact} from '../models/contact';
@inject(EventAggregator)
export class ContactStore {
contacts = [];
constructor(eventAggregator) {
this.eventAggregator = eventAggregator;
}
activate() {
this.subscriptions = [];
}
detached() {
this.subscriptions.forEach(s => s.dispose());
this.subscriptions = null;
}
getById(id) {
const index = this.contacts.findIndex(c => c.id == id);
if (index < 0) {
return Promise.reject();
}
return Promise.resolve(Contact.fromObject(this.contacts[index]));
}
}
此存储首先声明一个contacts
属性,该属性被分配一个空数组。此数组将包含联系人的本地列表。接下来,该类期望将一个EventAggregator
实例注入其构造函数,然后将其存储在eventAggregator
属性中。
然后,该类定义了一个activate
方法,该方法将订阅聚合器上的一些事件,以及一个deactivate
方法,该方法将处理订阅。这与我们在前面编写通知组件时实现的模式相同。
ContactStore
还公开了一个getById
方法,该方法需要一个联系人id
作为其参数,如果没有找到联系人,则返回一个拒绝的Promise
,如果找到联系人,则返回一个使用联系人副本解析的Promise
。一些路由组件将使用此方法来代替网关的getById
方法,因此它模拟其签名,以最小化我们必须进行的更改量。
现在activate
方法需要添加一些事件订阅,以便能够对它们做出反应:
src/contacts/services/store.js
// Omitted snippet...
export class ContactStore {
// Omitted snippet...
activate() {
this.subscriptions = [
eventAggregator.subscribe('contacts.loaded', e => {
this.contacts.splice(0);
this.contacts.push.apply(this.contacts, e.contacts);
}),
eventAggregator.subscribe('contact.created', e => {
const index = this.contacts.findIndex(c => c.id == e.contact.id);
if (index < 0) {
this.contacts.push(e.contact);
}
}),
eventAggregator.subscribe('contact.updated', e => {
const index = this.contacts.findIndex(c => c.id == e.contact.id);
if (index >= 0) {
Object.assign(this.contacts[index], e.contact);
}
}),
eventAggregator.subscribe('contact.deleted', e => {
const index = this.contacts.findIndex(c => c.id == e.contact.id);
if (index >= 0) {
this.contacts.splice(index, 1);
}
}),
];
}
// Omitted snippet...
}
在这里,activate
方法订阅 dispatcher 发布的各种事件,以便保持其联系人列表最新:
- 当它接收到一个
contacts.loaded
事件时,它使用事件有效负载中包含的新联系人列表重置contacts
阵列 - 当它接收到一个
contact.created
事件时,它首先使用它的id
确保该联系人在数组中不存在,如果不存在,则添加它 - 当它收到一个
contact.updated
事件时,它仍然使用其id
检索更新联系人的本地副本,并更新其所有属性 - 当它接收到一个
contact.deleted
事件时,它会在数组中找到联系人的索引,始终使用其id
,并将其拼接出来
此存储现在可以从服务器检索联系人列表的本地副本,然后保持自身的最新状态。
使用商店
现在,我们可以修改执行读取操作的所有路由组件,以便它们使用此存储而不是网关。让我们仔细看看。
首先,creation
组件不需要更改。
接下来,必须修改details
、edition
和photo
组件。对于其中每一项,我们需要:
- 导入
ContactStore
类 - 将
ContactStore
类添加到inject
装饰器中,以便将其注入构造函数中 - 向构造函数添加一个
store
参数 - 在构造函数中,将
store
参数指定给store
属性 - 在
activate
方法中,将对gateway
的getById
方法的调用替换为对store
的调用
以下是details
组件在这些更改后的外观:
src/contacts/components/details.js
import {inject} from 'aurelia-framework';
import {Router} from 'aurelia-router';
import {ContactStore} from '../services/store';
import {ContactGateway} from '../services/gateway';
@inject(ContactStore, ContactGateway, Router)
export class ContactDetails {
constructor(store, gateway, router) {
this.store = store;
this.gateway = gateway;
this.router = router;
}
activate(params, config) {
return this.store.getById(params.id).then(contact => {
this.contact = contact;
config.navModel.setTitle(this.contact.fullName);
});
}
tryDelete() {
if (confirm('Do you want to delete this contact?')) {
this.gateway.delete(this.contact.id)
.then(() => { this.router.navigateToRoute('contacts'); });
}
}
}
注意在gateway
上仍然调用delete
操作。实际上,所有写操作仍然使用ContactGateway
类执行。但是,所有读取操作现在都将使用ContactStore
服务执行,因为它保留服务器状态的同步本地副本。
因此,最后,还必须修改list
组件。我们需要:
- 将
ContactGateway
导入替换为ContactStore
导入 - 将对
ContactGateway
类的依赖替换为对inject
装饰器上ContactStore
类的依赖 - 删除
contacts
属性声明和初始化 - 将构造函数的
gateway
参数替换为store
参数 - 在构造函数中,通过将
store
参数的contacts
属性赋值给this.contacts
来移除gateway
属性的赋值 - 删除
activate
回调方法
新的list
组件现在被剥离至其最小值:
src/contacts/components/list.js
import {inject, computedFrom} from 'aurelia-framework';
import {ContactStore} from '../services/store';
@inject(ContactStore)
export class ContactList {
constructor(store) {
this.contacts = store.contacts;
}
}
我们可以在这里看到国家共享的核心。store
的contacts
属性包含一个实际状态持有者数组。正是这个阵列通过ContactStore
实例在组件之间共享,允许从不同的屏幕访问相同的数据。因此,这个数组永远不应该被覆盖,只应该被变异,这样 Aurelia 的绑定系统就可以无缝地使用它。
但是,我们仍然需要在某个地方activate
这个ContactStore
实例,这样它就可以开始监听变更事件。在激活事件调度器之前,让我们在功能的configure
函数中执行此操作:
src/contacts/index.js
import {Router} from 'aurelia-router';
import {ContactStore} from './services/store';
import {ContactEventDispatcher} from './services/event-dispatcher';
export function configure(config) {
const router = config.container.get(Router);
router.addRoute({ route: 'contacts', name: 'contacts', moduleId: 'contacts/main', nav: true, title: 'Contacts' });
config.postTask(() => {
const store = config.container.get(ContactStore);
store.activate();
const dispatcher = config.container.get(ContactEventDispatcher);
return dispatcher.activate();
});
}
这里,我们通过检索强制 DI 容器初始化单个ContactStore
实例,然后简单地activate
它。
最后,我们可以从ContactGateway
类中删除getAll
和getById
方法,因为它们不再使用。
此时,如果您运行应用,一切仍应像以前一样工作。
总结
设计一个有价值的应用几乎从来都不简单。始终需要权衡许多因素,决定哪些优点是有益的,哪些缺点是可以接受的:
- 子路由器使顶部菜单的活动项表现更好,而根路由则不然。
- 子路由器使跨功能的链接变得困难,而根路由使其变得容易。
- 特性有助于在 Aurelia 应用中隔离和集成域或技术特性。
- 数据绑定是将组件连接在一起的最简单方法。然而,它也有局限性。
- 使用移除服务来通信数据是使组件通信的另一种非常简单的方法。但是,它可能会占用大量带宽,会给远程服务带来一些负载,并使远程服务器成为单点故障,如果用户没有网络连接或远程服务关闭,则会导致应用无法使用。
- 在组件之间共享服务以使其通信是多功能的,但会增加复杂性。
- 使用事件使组件通信增加了可扩展性和解耦性,但也增加了复杂性。为了使事件在大型应用中易于发现,需要遵守规则。
其中的一些优点和缺点可能看起来微不足道,我倾向于同意,在大多数情况下,没有一直突出显示的菜单项没有什么大不了的,但在一些项目中,它可能是不可接受的。我所能做的就是给你工具,让你自己做出明智的决定。
版权属于:月萌API www.moonapi.com,转载请注明出处