Aurelia-学习手册-全-

Aurelia 学习手册(全)

原文:zh.annas-archive.org/md5/31FCE017BF58226A6BEEA3734CAADF0F

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

网络发展非常快。技术不断更迭,每几年就会出现新的想法,广泛流行,然后被其他东西取代。

如果你从事网页开发已经很多年,很可能你已经见证了这个周期的发展。像 Prototype,还有 jQuery,在 2000 年代中期广泛流行,现在许多项目仍在使用。

然后,随着浏览器和 JavaScript 引擎性能的不断提高,过去十年左右,出现了许多基于 JavaScript 的全功能前端框架,如 Angular 和 Durandal。最近,基于不同概念或范式的现代框架,如 React 和 Polymer,已经获得了大量流行。

Aurelia 就是一个现代框架。它是 Rob Eisenberg 的杰作,Durandal 的创始人,基于前沿的 Web 标准,建立在现代软件架构概念和思想之上,提供强大的工具集和惊人的开发者体验。

本书内容覆盖

第一章,入门,带你了解 Aurelia 的基本概念,解释如何设置你的环境并开始一个项目。

第二章,布局、菜单及熟悉,深入探讨了 Aurelia 核心概念,如依赖注入、日志记录和插件系统。它还解释了如何创建多页面应用程序的主布局和导航菜单。

第三章,显示数据,指导你了解模板和数据绑定系统,这样你就可以构建复杂的视图。

第四章,表单及其验证方式,在前一章的基础上,展示了如何构建丰富的表单以及如何使用 Aurelia 的灵活且强大的验证机制。它还探讨了不同的编辑模型,例如内联编辑或基于对话框的编辑。

第五章,创建可复用的组件,向你展示如何构建可复用的 Aurelia 组件,如自定义 HTML 元素和属性。它还解释了如何利用 Aurelia 支持的某些前沿 Web 标准,如 Shadow DOM 和内容投射。

第六章,设计关注点——组织和解耦,带你了解组织和管理 Aurelia 应用程序的不同方式。它还讨论了管理解耦组件之间通信的各种技术。

第七章,测试一切,教你如何为 Aurelia 应用程序编写和运行自动化测试,包括单元测试和端到端测试。

第八章:国际化,国际化,向你展示了如何对文本和各种数据类型的格式进行国际化,例如日期和数字。

第九章:动画,动画,教你如何使用 CSS 动画化视图转换,并介绍通用动画 API,这样你就可以使用更丰富的动画插件。

第十章:生产环境打包,生产环境打包,向你展示了如何通过将应用程序打包成一个或多个捆绑包来优化生产。

第十一章:与其他库集成,与其他库集成,给出了如何在你的应用程序中集成各种 UI 库的示例,例如 Bootstrap 小部件、jQuery UI、D3 和 Polymer 组件。

附录 A:使用 JSPM,使用 JSPM,向你展示了如何使用 SystemJS 和 JSPM 开发、构建和捆绑一个 Aurelia 应用程序。

附录 B:使用 Webpack,使用 Webpack,向你展示了如何使用 Webpack 开发、构建和捆绑一个 Aurelia 应用程序。

你需要这本书的原因

为了获得最佳体验,你需要一台运行 Windows、Linux 或 Mac OS X 的 PC/笔记本电脑,一个互联网连接,以及一个现代浏览器。所有代码示例都是使用 Google Chrome 开发和测试的;因此,它是我们推荐的浏览器。

本书中提到的所有软件都是免费的,可以从互联网上下载。

这本书面向谁

这本书面向所有开发者,无论是想学习使用 Aurelia 构建单页应用程序,还是只是对框架感到好奇。了解 JavaScript 的基础知识 ideal 跟进这本书;然而,如果你是 JS 的新手,你会在路上学会大部分基础知识。

约定

在这本书中,你会发现有许多文本样式用来区分不同类型的信息。以下是一些这些样式的示例及其含义解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、假网址、用户输入和 Twitter 处理方式如下所示:"因此,在 aurelia_project/aurelia.json 文件中,在 build 部分,在 bundles 下,让我们向名为 vendor-bundle.js 的捆绑包的 dependencies 中添加以下条目:"

代码块如下所示:

{ 
  "name": "aurelia-i18n", 
  "path": "../node_modules/aurelia-i18n/dist/amd", 
  "main": "aurelia-i18n" 
}, 
{ 
  "name": "i18next", 
  "path": "../node_modules/i18next/dist/umd", 
  "main": "i18next" 
}, 
{ 
  "name": "i18next-xhr-backend", 
  "path": "../node_modules/i18next-xhr-backend/dist/umd", 
  "main": "i18nextXHRBackend" 
},

当我们希望吸引您的注意力到代码块的某个特定部分时,相关的行或项目被设置为粗体:

<template> 
  <h1 t="404.title"></h1> 
  <p t="404.explanation"></p> 
</template>

任何命令行输入或输出如下所示:

> npm install aurelia-i18n i18next --save

新术语和重要词汇以粗体显示。例如,在菜单或对话框中出现的屏幕上的词汇,在文本中如下所示:"在此阶段,如果您运行应用程序,点击 新建 按钮,然后例如在 生日 文本框中输入胡言乱语,然后尝试保存。"

注意

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

提示

技巧和小窍门如下所示。

读者反馈

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

要发送给我们一般性反馈,只需将反馈发送至 feedback@packtpub.com,并在消息主题中提到书籍的标题。

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

客户支持

既然您已经成为 Packt 书籍的自豪拥有者,我们有很多事情可以帮助您充分利用您的购买。

下载示例代码

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

您可以按照以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从您购买本书的下拉菜单中选择。

  7. 点击代码下载

您还可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍的名称来访问此页面。请注意,您需要登录到您的 Packt 账户。

下载文件后,请确保使用最新版本解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书的代码包也托管在 GitHub 上,地址为:github.com/PacktPublishing/Learning-Aurelia。我们还有其他来自我们丰富目录的书籍和视频的代码包,可以在github.com/PacktPublishing/找到。去看看吧!

勘误表

尽管我们已经竭尽全力确保内容的准确性,但错误仍可能发生。如果您在我们的图书中发现任何错误——可能是文本或代码中的错误——我们将非常感激您能向我们报告。这样做不仅能让其他读者避免沮丧,还能帮助我们改进本书的后续版本。如果您发现任何错误,请访问 www.packtpub.com/submit-errata,选择您的书籍,点击“错误提交表单”链接,并输入错误的详细信息。一旦您的错误得到验证,您的提交将被接受,并且错误将被上传到我们的网站,或添加到该标题的错误部分已有的错误列表中。

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

版权侵犯

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

如果您发现任何可疑的版权侵犯材料,请联系我们 copyright@packtpub.com。

我们非常感谢您在保护我们的作者权益和为我们提供有价值内容方面所给予的帮助。

问题咨询

如果您在阅读本书的过程中遇到任何问题,欢迎您通过 questions@packtpub.com 与我们联系,我们将竭诚为您解决问题。

第一章:入门

Aurelia 开发者体验是其关键优势。该框架的作者对开发过程中的每一个环节都给予了深思熟虑的关注,因此使用该框架的过程无缝而流畅,从而使得学习曲线尽可能平滑。

这本书谦虚地遵循了同样的哲学。它将教你如何从 A 到 Z 使用 Aurelia 构建真实世界的应用程序。实际上,在阅读本书并跟随代码示例时,你确实会做这件事。你将从设置你的开发环境和创建项目开始,然后我会引导你了解诸如路由、模板、数据绑定、自动化测试、国际化以及打包等概念。我们将讨论应用程序设计、组件之间的通信以及第三方集成。我们将涵盖所有现代、真实世界的单页应用程序所需的主题。

在第一章中,我们将首先定义一些将在整本书中使用的术语。我们将快速介绍 Aurelia 的核心概念。然后,我们将查看核心 Aurelia 库,并了解它们如何相互交互以形成一个完整、功能丰富的框架。我们还将了解开发 Aurelia 应用程序所需的工具以及如何安装它们。最后,我们将开始创建我们的应用程序并探索其全局结构。

术语

由于这本书是关于一个 JavaScript 框架的,因此 JavaScript 在其中扮演着中心角色。如果你对最近几年变化很大的术语不是完全了解,让我来澄清一些事情。

JavaScript(或 JS)是 ECMAScriptES)标准的方言或实现。它不是唯一的实现,但绝对是其中最受欢迎的。在这本书中,我将使用 JS 缩写来讨论实际的 JavaScript 代码或代码文件,而在谈论实际的 ECMAScript 标准版本时,我将使用 ES 缩写。

就像计算机编程中的所有事物一样,ECMAScript 标准随时间不断发展。在撰写本书时,最新版本是 ES2016,于 2016 年 6 月发布。它最初被称为 ES7,但制定规范的 TC39 委员会决定改变他们的批准和命名模型,因此有了新名字。

之前的版本,在命名模型改变之前称为 ES2015ES6)的版本,于 2015 年 6 月发布,与之前的版本相比是一个很大的进步。这个较早的版本,称为 ES5,于 2009 年发布,是六年来最新的版本,因此现在所有现代浏览器都广泛支持。如果你在过去五年中一直在编写 JavaScript,你应该熟悉 ES5。

当他们决定改变 ES 命名模型时,TC39 委员会还选择改变规格的批准模型。这个决定是为了更快地发布语言的新版本。因此,新的特性正在社区中起草和讨论,必须通过一个批准过程。每年,将发布一个新的规格版本,包括当年批准的特性和概念。

这些即将推出的功能通常被称为ESNext。这个术语包括已经批准或至少相当接近批准但尚未发布的语言特性。可以合理地期待其中大多数或至少一些特性将在下一个语言版本中发布。

由于 ES2015 和 ES2016 仍然是较新的版本,它们并没有得到大多数浏览器的完全支持。此外,ESNext 特性通常根本没有浏览器支持。

这些多个名称可能会让人感到相当困惑。为了简化事情,我将坚持使用官方名称 ES5 代表之前版本,ES2016 代表当前版本,ESNext 代表下一个版本。但这只是我的偏好;在接下来的章节中,我们可能会遇到一些仍然使用原始命名法的工具或库。

在深入之前,你应该熟悉 ES2016 引入的功能以及 ESNext 装饰器(如果你还不熟悉的话)。我们将在整本书中使用这些功能。

注意

如果你不知道从 ES2015 和 ES2016 开始,你可以在 Babel 网站上找到新特性的概述:

babeljs.io/docs/learn-es2015/

至于 ESNext 装饰器,谷歌工程师 Addy Osmani 解释得相当好:

medium.com/google-developers/exploring-es7-decorators-76ecb65fb841

为进一步阅读,你可以查看未来 ES 版本的特性提案(如装饰器、类属性声明、异步函数等):

github.com/tc39/proposals

核心概念

在我们开始实践之前,有几个核心概念需要解释。

约定

首先,Aurelia 非常依赖约定。其中大多数约定是可配置的,如果它们不符合你的需求,可以进行更改。每当我们在书中遇到一个约定时,我们都会看看是否有可能改变它。

组件

组件是 Aurelia 的一等公民。Aurelia 组件是什么?它由一个 HTML 模板组成,称为视图,和一个 JavaScript 类组成,称为视图模型。视图负责显示组件,而视图模型控制其数据和行为。通常,视图位于一个.html文件中,视图模型在.js文件中。按照约定,这两个文件通过命名规则绑定,它们必须位于同一目录中,并且具有相同的名称(当然,除了它们的扩展名)。

以下是一个没有数据、没有行为和静态模板的空组件的示例:

component.js

export class MyComponent {} 

component.html

<template> 
  <p>My component</p> 
</template> 

组件必须遵守两个约束,视图的根 HTML 元素必须是template元素,视图模型类必须从.js文件中导出。作为一个经验法则,组件的 JS 文件应该只导出一个视图模型类。如果导出了多个类或函数,Aurelia 将在文件的导出函数和类上迭代,并使用找到的第一个作为视图模型。然而,由于 ES 规范中对象的键的枚举顺序不是确定的,没有任何保证导出会按照它们声明的顺序进行迭代,所以 Aurelia 可能会将错误的类作为组件的视图模型。

那个规则的唯一例外是一些视图资源,我们将在第三章,显示数据,和第五章,创建可复用的组件中看到它们。除了它的视图模型类,一个组件的 JS 文件可以导出像值转换器、绑定行为和自定义属性等东西,基本上任何不能有视图的视图资源,这排除了自定义元素。

组件是 Aurelia 应用的主要构建块。组件可以使用其他组件;它们可以组合成更大的或更复杂的组件。得益于插槽机制,你可以设计一个组件的模板,使其部分可以被替换或自定义。我们将在接下来的章节中看到所有这些。

架构

Aurelia 不是您通常意义上的单页应用的单体框架。它是一组松散耦合的库,具有明确定义的抽象。它的每个核心库都解决了一个特定且明确定义的问题,这是单页应用中常见的。Aurelia 利用依赖注入和插件架构,因此您可以丢弃框架的部分内容,用第三方甚至您自己的实现来替换它们。或者,您也可以丢弃不需要的功能,使您的应用程序更轻便,加载速度更快。我们将在第二章,布局、菜单和熟悉中更深入地了解这个插件机制。

核心 Aurelia 库可以分为多个类别。让我们快速浏览一下。

核心功能

以下库大多相互独立,如果需要,可以单独使用。它们各自提供一组专注的功能,是 Aurelia 的核心:

  • aurelia-dependency-injection:一个轻量级但强大的依赖注入容器。它支持多种生命周期管理策略和子容器。

  • aurelia-logging:一个简单的日志记录器,支持日志级别和可插拔的消费者。

  • aurelia-event-aggregator:一个轻量级的消息总线,用于解耦通信。

  • aurelia-router:一个客户端路由器,支持静态、参数化或通配符路由,以及子路由。

  • aurelia-binding:一个适应性强且可插拔的数据绑定库。

  • aurelia-templating:一个可扩展的 HTML 模板引擎。

抽象层

以下库主要定义接口和抽象,以解耦关注点并启用可扩展性和可插拔行为。这并不意味着上一节中的某些库没有除了它们的功能之外的自己的抽象。其中一些确实有。但当前节中描述的库几乎除了定义抽象之外没有其他目的:

  • aurelia-loader:一个定义了加载 JS 模块、视图和其他资源的接口的抽象。

  • aurelia-history:一个定义了历史管理接口的抽象,被路由使用。

  • aurelia-pal:一个用于平台特定能力的抽象。它用于抽象代码运行的平台,如浏览器或 Node.js。实际上,这意味着一些 Aurelia 库可以在服务器端使用。

默认实现

以下库是前两节库暴露的抽象的默认实现:

  • aurelia-loader-defaultaurelia-loader抽象的 SystemJS 和require基础加载器的实现。

  • aurelia-history-browser:基于标准浏览器哈希变化和推态机制的aurelia-history抽象的实现。

  • aurelia-pal-browseraurelia-pal抽象的浏览器实现。

  • aurelia-logging-consoleaurelia-logging抽象的浏览器控制台实现。

集成层

以下库的目的是将一些核心库集成在一起。它们提供接口实现和适配器,以及默认配置或行为:

  • aurelia-templating-routeraurelia-routeraurelia-templating库之间的集成层。

  • aurelia-templating-bindingaurelia-templatingaurelia-binding库之间的集成层。

  • aurelia-framework:一个将所有核心 Aurelia 库集成到一个功能齐全的框架的集成层。

  • aurelia-bootstrapper:一个将aurelia-framework的默认配置带入并处理应用程序启动的集成层。

附加工具和插件

如果你查看 Aurelia 在 GitHub 上的组织页面github.com/aurelia,你会看到更多仓库。前面部分列出的库只是 Aurelia 的核心——如果我可以这么说的话,这只是冰山一角。在 GitHub 上还有许多其他库,它们提供了额外的功能或集成了第三方库,其中一些是由 Aurelia 团队开发和维护的,许多其他是由社区开发的。我们将在后续章节中介绍一些这些额外的库,但我强烈建议你在阅读完这本书后自己探索 Aurelia 生态系统,因为它是快速发展的,Aurelia 社区正在做一些非常令人兴奋的事情。

工具

在接下来的部分,我们将介绍开发 Aurelia 应用程序所需的工具。

Node.js 和 NPM

由于 Aurelia 是一个 JavaScript 框架,因此其开发工具自然也是用 JavaScript 编写的。这意味着当你开始学习 Aurelia 时,你需要做的第一件事就是在你的开发环境中安装 Node.js 和 NPM。

注意

Node.js 是基于 Google 的 V8 JavaScript 引擎的服务器端运行环境。它可以用来构建完整的网站或网络 API,但它也被许多前端项目用于开发和构建任务,如转换、校验和压缩。

NPM 是 Node.js 的默认包管理器。它使用www.npmjs.com作为其主要仓库,所有可用的包都存储在这里。它与 Node.js 捆绑在一起,因此如果你在电脑上安装了 Node.js,NPM 也会被安装。

要在你的开发环境中安装 Node.js 和 NPM,你只需要访问nodejs.org/并下载适合你环境的正确安装程序。

如果 Node.js 和 NPM 已经安装,我强烈建议你确保使用至少版本 3 的 NPM,因为旧版本可能与我们将要使用的其他一些工具存在兼容性问题。如果你不确定你有哪些版本,你可以在控制台中运行以下命令来检查:

> npm -v

如果 Node.js 和 NPM 已经安装但你需要升级 NPM,你可以通过运行以下命令来实现:

> npm install npm -g

Aurelia 命令行界面(CLI)

尽管可以使用任何包管理器、构建系统或打包器来构建 Aurelia 应用程序,但管理 Aurelia 项目的最佳工具是命令行界面,也称为 CLI。

截至撰写本文时,CLI 只支持 NPM 作为其包管理器以及requirejs作为其模块加载器和打包器,这可能是因为它们都是最成熟和最稳定的。它还在幕后使用 Gulp 4 作为其构建系统。

基于 CLI 的应用在运行时总是会被打包,即使在开发环境中也是这样。这意味着在开发过程中应用的性能将与生产环境中的性能非常接近。这也意味着打包是一个持续关注的问题,因为新的外部库必须添加到某些打包中,以便在运行时可以使用。我们将在第十章详细看到这一点,生产环境下的打包

在本书中,我们将坚持使用首选方案并使用 CLI。然而,书末有两个附录介绍了替代方案,第一个是针对 Webpack 的,第二个是针对 SystemJS 和 JSPM 的。

安装 CLI

CLI 是一个命令行工具,应该通过打开控制台并执行以下命令来全局安装:

> npm install -g aurelia-cli

根据你的环境,你可能需要以管理员权限运行这个命令。

如果你已经安装了它,请确保你有最新版本,通过运行以下命令:

> au -v

然后你可以将这个命令输出的版本与 GitHub 上标记的最新版本号进行比较,地址是:github.com/aurelia/cli/releases/latest

如果你没有最新版本,你可以通过运行以下命令简单地更新它:

> npm install -g aurelia-cli

如果出于某种原因更新 CLI 的命令失败了,只需卸载然后重新安装即可:

> npm uninstall aurelia-cli -g
> npm install aurelia-cli -g

这应该会重新安装最新版本。

项目骨架

作为 CLI 的替代方案,项目骨架可在 github.com/aurelia/skeleton-navigation 找到。这个仓库包含多个样本项目,基于不同的技术,如 SystemJS 和 JSPM、Webpack、ASP .Net Core 或 TypeScript。

准备骨架非常简单。你只需要从 GitHub 下载并解压存档,或者在本地克隆仓库。每个目录都包含一个不同的骨架。根据你的选择,你可能需要安装不同的工具并运行设置命令。通常,骨架中的 README.md 文件中的说明是非常清晰的。

这些骨架是使用不同技术开始新应用的其他良好起点。本书的最后两章附录展示了如何使用其中一些骨架,使用 SystemJS 和 JSPM 或 Webpack 构建应用程序。

除了附录,本书其余部分将继续使用 CLI。

我们的应用

使用 CLI 创建 Aurelia 应用非常简单。你只需要在你想创建项目的目录中打开一个控制台,并运行以下命令:

> au new

CLI 的项目创建过程将开始,你应该看到类似这样的内容:

我们的应用

命令行界面(CLI)首先会询问您想要为您项目命名什么。这个名称将用于创建项目所在的目录以及设置一些值,例如它将创建的package.json文件中的name属性。让我们给我们的应用命名为learning-aurelia

我们的应用

接下来,CLI 会询问我们想要使用哪些技术来开发应用。在这里,您可以选择一个自定义转换器,如 TypeScript,以及一个 CSS 预处理器,如 LESS 或 SASS。

注意

转换器,编译器的小表亲,将一种编程语言翻译成另一种。在我们的案例中,它将用于将 ESNext 代码转换为 ES5,后者被所有现代浏览器理解。

默认选择是使用 ESNext 和普通 CSS,这是我们将会选择的:

我们的应用

接下来的步骤简单回顾了我们所做的选择,并请求确认创建项目,然后询问我们是否想要安装项目的依赖,默认情况下它会这样做。在此阶段,命令行界面将创建项目并在幕后运行npm install。一旦完成,我们的应用就准备好了:

我们的应用

在此阶段,您运行au new的目录将包含一个名为learning-aurelia的新目录。这个子目录将包含 Aurelia 项目。我们将在下一节中稍作探讨。

注意

命令行界面(CLI)可能会发生变化,在将来提供更多选项,因为计划支持更多工具和技术。如果您运行它,不要惊讶看到不同或新的选项。

我们创建项目的路径使用了 Visual Studio Code 作为默认代码编辑器。如果你想使用其他编辑器,比如AtomSublimeWebStorm,这些是在撰写本文时支持的其他选项,你只需要在创建过程开始时选择选项#3 自定义转换器、CSS 预处理器等,然后为每个问题选择默认答案,直到被要求选择您的默认代码编辑器。创建过程的其余部分应该基本保持不变。请注意,如果您选择不同的代码编辑器,您的体验可能与本书中找到的示例和屏幕截图不同,因为撰写本书时使用的是 Visual Studio Code。

如果您是 TypeScript 开发者,您可能想创建一个 TypeScript 项目。然而,我建议您坚持使用简单的 ESNext,因为本书中的每个示例和代码示例都是用 JS 编写的。尝试跟随 TypeScript 可能会证明很繁琐,尽管如果您喜欢挑战,可以尝试。

基于 CLI 的项目的结构

如果您在代码编辑器中打开新创建的项目,您应该看到以下文件结构:

  • node_modules:包含项目依赖的标准 NPM 目录

  • src:包含应用源代码的目录

  • test:包含应用自动化测试套件的目录,我们将在第七章中探索,测试所有事物

  • .babelrc:Babel 的配置文件,CLI 使用它将我们的应用的 ESNext 代码转换成 ES5,这样大多数浏览器都可以运行它。

  • index.html:加载并启动应用的 HTML 页面

  • karma.conf.jsKarma的配置文件,CLI 使用它来运行单元测试;

  • package.json:标准的 Node.js 项目文件

这个目录还包括其他文件,如.editorconfig.eslintrc.json.gitignore,它们对学习 Aurelia 来说兴趣不大,所以我们不覆盖它们。

除了所有这些,你应该看到一个名为aurelia_project的目录。这个目录包含与使用 CLI 构建和打包应用相关的事物。让我们看看它由什么组成。

aurelia.json文件

这个目录中最重要的文件是一个名为aurelia.json的文件。这个文件包含了 CLI 用于测试、构建和打包应用的配置。这个文件根据你在项目创建过程中的选择可能会有很大的变化。

注意

这种情况非常少见,需要手动修改这个文件。向应用中添加一个外部库就是这种情况,我们在接下来的章节中会面临多次。除了这种情况,这个文件基本上不应该手动更新。

这个文件中第一个有趣的部分是platform

"platform": { 
  "id": "web", 
  "displayName": "Web", 
  "output": "scripts", 
  "index": "index.html" 
}, 

这一部分告诉 CLI,输出目录的名称是scripts,它还告诉 CLI,将加载并启动应用的 HTML 主页是index.html文件。

下一个有趣的部分是transpiler部分:

"transpiler": { 
  "id": "babel", 
  "displayName": "Babel", 
  "fileExtension": ".js", 
  "options": { 
    "plugins": [ 
      "transform-es2015-modules-amd" 
    ] 
  }, 
  "source": "src/**/*.js" 
}, 

这一部分告诉 CLI 使用 Babel 转换应用的源代码。它还定义了额外的插件,因为有些插件已经在.babelrc中配置好,在转换源代码时使用。在这种情况下,它添加了一个插件,将以 AMD 兼容模块的形式输出转换后的文件,以兼容requirejs

这个文件中有许多其他部分,其中一些我们将在后续章节中覆盖,还有一些我留给你们自己探索。

任务

aurelia_project目录包含一个名为tasks的子目录。这个子目录包含各种 Gulp 任务,用于构建、运行和测试应用。这些任务可以使用 CLI 执行。

你可以首先尝试不带任何参数运行au

> au

这将列出所有可用的命令以及它们的可用参数。这个列表包括内置命令,比如我们已经在用的new,或者在下一节中会看到的generate,还有在tasks目录中声明的 Gulp 任务。

要运行这些任务中的一个,只需执行au,后面跟上任务的名称作为它的第一个参数:

> au build

此命令将运行定义在aurelia_project/tasks/build.js中的build任务。这个任务使用 Babel 转换应用程序代码,如果有的话,执行 CSS 和标记预处理器,并在scripts目录中打包代码。

运行后,你应在scripts目录下看到两个新文件:app-bundle.jsvendor-bundle.js。这两个文件是在应用程序启动时由index.html加载的实际文件。前者包含所有应用程序代码,包括 JS 文件和模板,而后者包含应用程序使用的所有外部库,包括 Aurelia 库。我们将在第十章中学习如何自定义打包——生产环境下的打包

你可能会注意到列表中有一个名为run的命令。这个任务定义在aurelia_project/tasks/run.js中,在启动本地 HTTP 服务器以提供应用程序之前内部执行build任务:

> au run

默认情况下,HTTP 服务器将在端口 9000 上监听请求,因此你可以打开你喜欢的浏览器,访问 http://localhost:9000/ 来查看默认的演示应用程序。

注意

如果你需要更改开发 HTTP 服务器运行的端口号,你只需要打开aurelia_project/tasks/run.js,找到对browserSync函数的调用。传递给这个函数的对象包含一个名为port的属性。你可以相应地更改它的值。

run任务可以接受一个--watch开关:

> au run --watch

如果存在此开关,任务将继续监控源代码,并在任何代码文件更改时重新构建应用程序并自动刷新浏览器。这在开发过程中非常有用。

生成器

命令行界面(CLI)还提供了一种生成代码的方法,使用位于aurelia_project/generators目录中的类。在撰写本文时,有创建自定义属性、自定义元素、绑定行为、值转换器和甚至任务和生成器的生成器。是的,有一个生成器用于生成生成器。

注意

如果你对 Aurelia 一无所知,那些概念(值转换器、绑定行为以及自定义属性和元素)可能对你来说毫无意义。不用担心,我们将在接下来的章节中介绍这些主题。

可以使用内置的generate命令执行生成器:

> au generate attribute

此命令将运行自定义属性生成器。它会询问要生成的属性的名称,然后在其src/resources/attributes目录中创建它。

如果你看一下这个生成器,它可以在aurelia_project/generators/attribute.js中找到,你会发现文件导出一个名为AttributeGenerator的单一类。这个类使用@inject装饰器(我们将在第二章中更详细地看到,布局、菜单和熟悉)来声明aurelia-cli库中的各种类作为依赖项,并在其构造函数中注入它们的实例。它还定义了一个execute方法,当生成器运行时由 CLI 调用。这个方法利用aurelia-cli提供的服务与用户交互并生成代码文件。

注意

默认可用的生成器名称有attributeelementbinding-behaviorvalue-convertertaskgenerator

环境

基于 CLI 的应用程序支持环境特定的配置值。默认情况下,CLI 支持三个环境-开发、暂存和生产。这些环境的每个配置对象都可以在aurelia_project/environments目录中的不同文件dev.jsstage.jsprod.js中找到。

一个典型的环境文件看起来像这样:

aurelia_project/environments/dev.js

export default { 
  debug: true, 
  testing: true 
}; 

默认情况下,环境文件用于根据环境启用 Aurelia 框架的调试日志和仅限测试的模板功能。我们将在下一节看到这一点。然而,环境对象可以增强任何所需的属性。通常,它可用于根据环境配置后端的不同 URL。

添加新环境仅仅是 在aurelia_project/environments目录中为其添加一个文件的问题。例如,您可以通过在目录中创建一个local.js文件来添加一个local环境。

许多任务,基本上是build和所有使用它的任务,如runtest,都期望使用env参数指定环境:

> au build --env prod

在这里,应用程序将使用prod.js环境文件进行构建。

如果没有提供env参数,默认使用dev

注意

当执行build任务时,它只是在运行转译器和打包输出之前将适当的环境文件复制到src/environment.js。这意味着src/environment.js绝不应该手动修改,因为它将被build任务自动覆盖。

Aurelia 应用程序的结构

上一节描述了特定于基于 CLI 的项目的一些文件和文件夹。然而,项目中的某些部分无论构建系统和包管理器如何都是相同的。这些是在本节中将要看到的更全局的主题。

托管页面

Aurelia 应用程序的第一个入口点是 HTML 页面的加载和托管。默认情况下,这个页面名为index.html,位于项目的根目录中。

默认的托管页面看起来像这样:

index.html

<!DOCTYPE html> 
<html> 
  <head> 
    <meta charset="utf-8"> 
    <title>Aurelia</title> 
  </head> 

  <body aurelia-app="main"> 
    <script src="img/vendor-bundle.js"  
            data-main="aurelia-bootstrapper"></script> 
  </body> 
</html> 

当页面加载时,body元素内的script元素加载了scripts/vendor-bundle.js文件,该文件包含了requirejs本身以及所有外部库的定义和对app-bundle.js的引用。加载时,requirejs检查data-main属性并将其值作为入口点模块使用。在这里,aurelia-bootstrapper开始工作。

启动器首先在 DOM 中查找具有aurelia-app属性的元素。我们可以在默认的index.html文件中的body元素中找到这样的属性。这个属性识别作为应用程序视图口的元素。启动器使用属性的值作为应用程序的主模块名称,定位模块,加载它,并在元素内渲染结果 DOM,覆盖任何先前的内容。应用程序现在正在运行。

注意

尽管默认的应用程序没有说明这种情况,但一个 HTML 文件托管多个 Aurelia 应用程序是可能的。它只需要包含多个带有aurelia-app属性的元素,每个元素都引用自己的主模块。

主模块

按惯例,由aurelia-app属性引用的主模块命名为main,因此位于src/main.js中。此文件预计将导出一个configure函数,该函数将由 Aurelia 启动过程调用,并将传递一个用于配置和启动框架的配置对象。

默认情况下,主要的configure函数看起来像这样:

src/main.js

import environment from './environment'; 

export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .feature('resources'); 

  if (environment.debug) { 
    aurelia.use.developmentLogging(); 
  } 

  if (environment.testing) { 
    aurelia.use.plugin('aurelia-testing'); 
  } 

  aurelia.start().then(() => aurelia.setRoot()); 
} 

configure函数首先告诉 Aurelia 使用其默认配置,并加载resources特性,我们将在第二章,布局、菜单和熟悉中看到特性是如何工作的。它还根据环境的debug属性有条件地加载开发日志插件,并根据环境的testing属性有条件地加载测试插件。这意味着,默认情况下,两个插件将在开发中加载,而在生产中不会加载任何一个。

最后,该函数启动了框架,然后将根组件附加到 DOM。

注意

start方法返回一个Promise,其解析触发了对setRoot的调用。如果你不熟悉 JavaScript 中的Promise,我强烈建议你在继续之前查阅相关资料,因为它们是 Aurelia 中的核心概念。

根组件

任何 Aurelia 应用程序的根部都有一个单一的组件,包含应用程序内的所有内容。按惯例,这个根组件名为app。它由两个文件组成:app.html,其中包含渲染组件的模板,以及app.js,其中包含其视图模型类。

在默认的应用程序中,模板非常简单:

src/app.html

<template> 
  <h1>${message}</h1> 
</template> 

这个模板由一个单一的 h1 元素组成,它将包含视图模型的 message 属性的值作为文本,感谢字符串插值,我们将在 第三章,显示数据 中更详细地探讨。

app 视图模型看起来像这样:

src/app.js

export class App { 
  constructor() { 
    this.message = 'Hello World!'; 
  } 
} 

这个文件简单地导出一个类,该类有一个 message 属性,包含字符串 Hello World!

应用程序启动时,此组件将被渲染。如果你运行应用程序并使用你最喜欢的浏览器导航到应用程序,你会看到一个包含 Hello World!h1 元素。

你可能会注意到,这个组件的代码中没有提到 Aurelia。实际上,视图模型只是普通的 ESNext,Aurelia 可以原样使用它。当然,我们稍后会在很多视图模型中利用许多 Aurelia 特性,所以大多数视图模型实际上将依赖于 Aurelia 库,但这里的重点是,如果你不想在视图模型中使用任何 Aurelia 库,你就不必使用,因为 Aurelia 设计得尽可能不具侵入性。

传统引导方式

可以在宿主页面中将 aurelia-app 属性留空:

<body aurelia-app> 

在这种情况下,引导过程要简单得多。而不是加载一个包含 configure 函数的主模块,引导器将简单地使用框架的默认配置并作为应用程序根加载 app 组件。

对于一个非常简单的应用程序来说,这可能是一个更简单的开始方式;因为它消除了 src/main.js 文件的必要性,你可以直接删除它。然而,这意味着你被默认框架配置所束缚。你不能加载功能或插件。对于大多数实际应用,你需要保留主模块,这意味着指定为 aurelia-app 属性值的 aurelia-app

自定义 Aurelia 配置

主模块的 configure 函数接收一个配置对象,用于配置框架:

src/main.js

//Omitted snippet... 
aurelia.use 
  .standardConfiguration() 
  .feature('resources'); 

if (environment.debug) { 
  aurelia.use.developmentLogging(); 
} 

if (environment.testing) { 
  aurelia.use.plugin('aurelia-testing'); 
} 
//Omitted snippet... 

这里,standardConfiguration() 方法是一个简单的助手,它封装了以下内容:

aurelia.use 
  .defaultBindingLanguage() 
  .defaultResources() 
  .history() 
  .router() 
  .eventAggregator(); 

这是 Aurelia 的默认配置。它加载了默认的绑定语言、默认的模板资源、浏览器历史插件、路由插件和事件聚合器。这是典型 Aurelia 应用程序使用的默认一组功能。本书的各个章节都会涉及到这些插件。除了绑定语言之外的所有这些插件都是可选的,绑定语言是模板引擎所必需的。如果你不需要其中一个,那就不要加载它。

除了标准配置之外,根据环境设置还会加载一些插件。当环境的debug属性为true时,会使用developmentLogging()方法加载 Aurelia 的控制台日志记录器,因此可以在浏览器控制台中看到跟踪和错误信息。当环境的testing属性为true时,会使用plugin方法加载aurelia-testing插件。这个插件注册了一些在调试组件时非常有用的资源。

configure函数中的最后一行启动了应用程序并显示其根组件,根据约定,这个组件的名称是app。然而,如果你违反了约定并为根组件指定了其他名称,你可以通过将根组件的名称作为setRoot函数的第一个参数来绕过这个约定:

aurelia.start().then(() => aurelia.setRoot('root')); 

在这里,预期根组件位于src/root.htmlsrc/root.js文件中。

总结

得益于 Aurelia 的命令行界面(CLI),入门非常简单。安装工具并创建一个空项目仅仅是运行几个命令的问题,通常等待初始 NPM 安装完成的时间比实际设置的时间还要长。

在下一章中,我们将介绍依赖注入和日志记录,并开始通过向应用程序中添加组件和配置路由来导航它们来构建我们的应用程序。

第二章:布局、菜单和熟悉

至此,你应该已经对如何创建 Aurelia 应用程序有了很好的了解。大局可能仍然模糊,但随着我们贯穿本章,细节将不断出现。我们首先将了解依赖注入和 Aurelia 的插件系统是如何工作的,然后我们将了解如何使用、配置和自定义 Aurelia 日志记录器,以便我们可以追踪和监控我们代码中的情况。最后,我们将探讨 Aurelia 路由器和导航模型。顺便说一下,在我们开始构建真实应用程序时,我们将继续研究模板,通过创建全局布局模板及其导航菜单来构建真实应用程序。

在本书中,我们将逐步构建一个应用程序。在每一章中,我们将添加功能性和技术性特征。它从这一章开始。所以在深入技术之前,请允许我首先描述我们的应用程序将做什么。

我们将要构建一个联系人管理应用程序。这个应用程序将允许用户浏览联系人、执行搜索、创建和编辑条目。当然,它将依赖于一个 HTTP API 来管理数据。这个后端可以在 github.com/PacktPublishing/Learning-Aurelia找到;这是一个简单的基于 Node.js 的服务。只需下载它,在一个目录中解压,在该目录中打开控制台并运行 npm install 以恢复所需包,然后运行 npm start 来启动网络服务器。

接下来,你应该去使用 Aurelia CLI 创建一个空项目,最好使用默认选项。本书中的所有示例和代码样本都是使用默认 CLI 设置构建的;如果你定制项目创建或使用骨架,一些代码片段可能无法工作。因此,为了使学习过程尽可能顺利,我强烈建议你从默认设置开始。

依赖注入

SOLID 原则最早是由罗伯特·C·马丁(Robert C. Martin)在 2000 年代初提出的,他也被大家亲切地称为“Uncle Bob”。这个记忆助手的缩写后来由迈克尔·费瑟斯(Michael Feathers)提出,为这些原则的普及做出了贡献。它们描述了良好面向对象设计的核心五个关注点。尽管SOLID原则本身超出了本书的范围,但我们将详细讨论其中一个原则:依赖倒置。

依赖倒置原则表明类和模块应该依赖于抽象。当一个类依赖于抽象时,它无法负责创建这些依赖,它们必须被注入到对象中。这就是我们所说的依赖注入DI)。它极大地增加了解耦和组合性,并强制执行一种在应用程序顶层或接近应用程序入口点组合对象图的编码风格。这样,应用程序的行为可以通过改变根部对象组合的方式而无需修改大量代码来改变。

然而,手动创建整个对象图,或者像 Mark Seemann 所说的“穷人版 DI”,很快就会变得单调。这就是依赖注入容器发挥作用的地方。一个 DI 容器,利用约定和配置,能够理解如何创建对象图。

在 Aurelia 中,几乎所有的对象都是由一个 DI 容器提供的。这个容器有两个责任:创建和组装对象,之后管理它们的生存周期。它可以通过使用附加到它必须实例化的类的元数据来做到这一点。

inject装饰器

让我们想象一个显示人员列表的PersonListView组件。视图模型需要一个PersonService实例,用于检索一个Person对象列表:

src/person-list-view.js

import {PersonService} from 'app-services'; 
import {inject} from 'aurelia-framework'; 

@inject(PersonService) 
export class PersonListView { 

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

  getPeople() { 
    return this.personService.getAll(); 
  } 
} 

在这里,我们有一个简单的视图模型,其构造函数期望一个personService参数。这个参数然后存储在一个实例变量中,以便稍后使用。视图模型还有一个getPeople方法,该方法调用personServicegetAll方法来检索人员列表。如果你熟悉面向对象设计和依赖倒置,这里没有什么新东西。

这段代码片段中有趣的是PersonListView类上的inject装饰器。这个装饰器是从 Aurelia 导入的,指示 DI 容器在创建PersonListView的新实例时,解析一个PersonService实例,并将其作为构造函数的第一个参数注入。这里重要的是,传递给inject装饰器的依赖项列表与构造函数期望的参数列表一致。如果类有多个依赖项,你必须将它们全部按正确顺序传递给inject

src/person-list-view.js

import {PersonService, AnotherService} from 'app-services'; 
import {inject} from 'aurelia-framework'; 

@inject(PersonService, AnotherService) 
export class PersonListView { 

  constructor(personService, anotherService) { 
    this.personService = personService; 
    this.anotherService = anotherService; 
  } 

  getPeople() { 
    return this.personService.getAll(); 
  } 
} 

注意

装饰器是 ESNext 的一个特性;目前没有任何浏览器支持它们。此外,Babel 默认也不支持它们,所以如果你想在你的代码中使用它们,你需要添加babel-plugin-transform-decorators-legacy插件。使用 CLI 创建的项目已经包含了这个设置。

TypeScript 和 autoinject

如果你使用 TypeScript,在构造函数声明中指定了每个依赖项的类型时,使用inject装饰器是相当冗余的。为了简化事情,Aurelia 提供了一个autoinject装饰器,它利用了 TypeScript 转译器添加到转译后的 JS 类中的类型元数据。

为了使用autoinject,你首先需要在你的tsconfig.json文件中将experimentalDecorators设置为true以启用装饰器和元数据发射,然后在同一文件的compilerOptions部分将emitDecoratorMetadata设置为true。由 CLI 创建的 TypeScript 项目已经包含了这些设置。

下面是使用 TypeScript 的相同PersonListView的示例:

src/person-list-view.js

import {PersonService} from 'app-services'; 
import {Person} from 'models'; 
import {autoinject} from 'aurelia-framework'; 

@autoinject 
export class PersonListView { 

  constructor(private personService: PersonService) { 
  } 

  getPeople(){ 
    return this.personService.getAll(); 
  } 
} 

在这里,DI 容器知道,为了创建一个PersonListView实例,它首先需要解析一个PersonService实例并在PersonListView的构造函数中注入它,这要归功于autoinject装饰器。

静态 inject 方法或属性

如果你不使用 ESNext 装饰器也不是 TypeScript,或者不想在给定类内部有 Aurelia 的依赖,你可以使用返回这些依赖的静态inject方法声明类的依赖:

src/person-list-view.js

import {PersonService} from 'app-services'; 

export class PersonListView { 
  static inject() { return [PersonService]; } 

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

  getPeople() { 
    return this.personService.getAll(); 
  } 
} 

静态的inject方法应该返回包含类依赖的数组。

或者,也可以支持包含依赖项数组的静态inject属性。实际上,当你使用injectautoinject装饰器时,背后发生的就是这件事,它们只是将依赖项分配给类的静态inject属性。它们只是语法糖。

根容器和子容器

在 Aurelia 中,一个容器可以创建子容器,这些子容器又可以创建自己的子容器,从而形成从应用程序的根容器开始的容器树。每个子容器继承其父容器的服务,但可以注册自己的服务以覆盖父容器的服务。

如我们在第一章中看到的,*入门 *,一个应用程序从根组件开始。它也从根容器开始。当评估一个视图时,模板引擎会在每次遇到视图内的子组件时创建一个子容器,无论是自定义元素、具有自定义属性的元素还是通过路由或组合创建的视图模型。子组件的视图模型类将在子容器中注册为单例,然后用于解析子组件实例。随着这个组件的视图被加载和分析,这个过程会递归进行。随着组件被组合成树状结构,容器也是如此。

由于子容器通常是由模板引擎创建的,所以你很可能永远不需要手动创建一个子容器。不过,这里有一个例子展示了它是如何完成的:

let childContainer = container.createChild(); 

解析实例

实例的解析涉及到解析器。我们稍后会回到这部分,详细解释它们是如何工作的以及如何使用,但与此同时,可以先将它们视为负责解析 DI 容器请求的类实例的策略。

解析实例时,根容器首先检查它是否已经有了一个针对该类的Resolver。如果有,这个Resolver就被用来获取一个实例。如果没有找到Resolver,根容器将自动注册一个单例Resolver对该类进行实例获取。

使用子容器解析实例时,情况有点不同。子容器仍然检查它是否有该类的Resolver,如果有,则使用它来获取实例。然而,如果没有找到Resolver,子容器将委托其父容器进行解析。父容器会重复这个过程,直到实例被解析或解析请求上升到根容器。当它这样做时,根容器按照前述方式解析实例。

这意味着当首次解析时动态注册的类的实例是应用单例,因为它们是在根容器中注册的,所以每个子容器最终都会解析为这个单一实例。

视图模型由模板引擎使用容器解析,所以你大多数时候永远不需要手动解析一个实例。然而,有一些场景你可能希望在一个对象中注入一个容器并手动解析服务。以下是这样做的方法:

let personService = container.get(PersonService); 

在这里,get 方法是用 PersonService 类调用,并返回这个类的实例。

生命周期

由容器创建的任何对象都有生命周期。有三种典型的生命周期:

  • 容器单例:当容器首次请求类时实例化该类,然后保留对该实例的引用。每当从容器中请求该类的实例时,这个相同的实例会被返回。这意味着实例的生命周期与容器的生命周期绑定。它不会被垃圾回收,直到容器被丢弃,且没有其他对象持有对实例的引用。

  • 应用单例:作为应用单例注册的类,其实质是在应用的根容器中注册的一个容器单例,因此整个应用中都会重用同一个实例。

  • 瞬态:当一个类被注册为瞬态时,容器每次请求实例时都会创建一个新的实例。它不会保留对任何这些实例的引用。容器仅仅作为一个工厂。

注册

为了解析一个类的实例,容器首先必须了解它。这个学习过程被称为注册。大多数时候,它是由容器在接收到解析请求时自动且即时执行的。它也可以通过使用容器的注册 API 手动执行。

容器注册 API

Container 类提供了多种方法用于手动注册一个类。

container.registerSingleton(key: any, fn?: Function): void 

此方法将类注册为容器单例。key 将在查找时使用,fn 预期是要实例化的类。如果只提供 key,则预期它是一个类,因为它将用于查找和实例化。

例如,container.registerSingleton(HttpClient)HttpClient 类注册为单例。第一次解析 HttpClient 时,将创建一个实例并返回。对于后续每次解析 HttpClient 的请求,都将返回这个单一实例。

另外,container.registerSingleton(PersonService, CachingPersonService) 使用 PersonService 作为键来注册 CachingPersonService 类。这意味着当解析 PersonService 类时,将返回 CachingPersonService 的单一实例。这种映射在处理抽象时非常重要。

当然,类是容器单例还是应用单例,仅仅取决于调用它的容器是否是应用的根容器。

container.registerTransient(key: any, fn?: Function): void 

此方法将类注册为瞬态,意味着每次请求key时,都会创建fn的新实例。与registerSingleton类似,fn可以省略,在这种情况下,key将用于查找和实例创建。

container.registerInstance(key: any, instance?: any): void 

此方法将现有实例注册为单例。如果你已经有一个实例并希望将其注册到容器中,这很有用。与registerSingleton的区别在于,传递的是实际的单例实例,而不是类。如果只提供key,它将用于查找和作为实例,但我真的看不到这种情况会有什么用,因为你需要已经拥有值才能查找它。

例如,container.registerInstance(HttpClient, myClient)HttpClient 类注册 myClient 实例。每次从容器中请求 HttpClient 实例时,将返回 myClient 实例:

container.registerHandler(key: any, 
  (container?: Container, key?: any, resolver?: Resolver) => any): void 

此方法注册一个自定义处理程序,这是一个每次容器根据key请求时将被调用的函数。这个处理函数将传递容器、key 和内部存储处理器的 Resolver。这支持了超出标准单例和瞬态生命周期的多种场景。

例如,container.registerHandler(PersonService, () => new PersonService(myConfig)) 注册了一个工厂函数。每次从容器中请求一个PersonService实例时,该处理函数将被调用,并使用捕获的myConfig值创建一个新的PersonService实例:

container.registerResolver(key: any, resolver: Resolver): void 

此方法注册一个自定义 Resolver 实例。在幕后,我们之前看到的所有容器方法都使用这个方法带有内置解析器。然而,创建我们自己的 Resolver 实现也是可能的。

注意

虽然大多数时候键是类,但它们可以是任何东西,包括字符串、数字、符号或对象。

自动注册

类的自动注册由以下类方法处理:

container.autoRegister(key: any, fn?: Function): Resolver 

这个方法可以带有单一参数,即要注册的类,或者带有两个参数,第一个参数是要注册的类的键,第二个参数是要注册的类。当只有一个参数传递时,类本身被用作键。

容器在尝试解析一个找不到任何解析器的类的实例时,会自动调用autoRegister。它很少被应用程序直接使用。

注册策略

给定类的自动注册过程可以通过将Registration策略附加到类的元数据来定制。这可以通过使用注册装饰器之一来完成:

import {transient} from 'aurelia-framework'; 

@transient() 
export class MyModel {} 

在这个例子中,transient装饰器将告诉autoRegister方法,MyModel类必须作为暂态注册,所以每次容器必须解析MyModel实例时,它将创建一个新的实例。

另外,你可以使用singleton(registerInChild: boolean = false)装饰器。当registerInChild参数为false时,默认就是这样,这个装饰器告诉autoRegister方法,这个类应该在根容器上注册为单例。这使得这个类成为应用程序的单例,而这本来就是容器的默认行为,所以将singletonregisterInChild设置为false或让其保持默认值是有点没用的。

然而,singletonregisterInChild设置为true表示,该类应该作为单例注册,不是在根容器上,而是在实际调用autoRegister方法的容器上。这允许我们装饰一个类,使得每个容器都有自己的实例:

import {singleton} from 'aurelia-framework'; 

@singleton(true) 
export class MyModel {} 

在这个例子中,MyModel将被注册为容器单例。每个容器都将有自己的实例。

这两个装饰器背后依赖于registration(registration: Registration)。这个第三个装饰器用于将一个Registration策略与一个类关联。如果你创建了自己的自定义Registration策略,可以使用它。它被transientsingleton背后使用,将内置的Registration策略之一附加到它们装饰的类上。

创建自定义注册策略

注册策略必须实现以下方法:

registerResolver(container: Container, key: any, fn: Function): Resolver 

默认情况下,autoRegister方法将传递给它的类注册为应用程序单例。然而,当被调用时,拥有附加到其元数据的Registration策略的类,autoRegister将委托该类的注册到RegistrationregisterResolver方法,该方法预期为该类创建一个Resolver,将其注册到容器中,并返回它。

通常,registerResolver方法实现将使用作为参数传递的Container实例的注册 API 来注册类。例如,内置的TransientRegistrationregisterResolver方法,它被transient装饰器在幕后使用,看起来像这样:

registerResolver(container, key, fn) { 
  return container.registerTransient(key, fn); 
} 

在这里,该方法调用容器的registerTransient方法,该方法创建一个瞬态Resolver,并返回它。

解析器

我们之前定义了Resolver作为负责解析实例的策略。当容器简化为最基本的形式时,它仅仅管理一个将key与相应的Resolver相关联的Map,这些Resolver是通过Registration策略或容器注册方法创建的。

除了在注册服务时使用解析器之外,解析器还可以在声明依赖时使用:inject装饰器,因此顺便说一下,inject静态方法或属性,可以作为Resolver而不是key传递。正如我们之前所见,在解析key依赖时,容器或其一个祖先将找到该keyResolver,或者根容器将自动注册一个单例Resolver,这个Resolver将用于解析一个实例。但是,当解析一个Resolver依赖时,容器将直接使用这个Resolver来解析一个实例。这允许我们在特定注入的上下文中覆盖给定类注册的解析策略。

通常在注入时有用的大约有六个解析器。

懒惰

Lazy解析器注入一个函数,当评估时,延迟解析依赖项:

import {Lazy, inject} from 'aurelia-dependency-injection'; 
import {PersonService} from 'person-service'; 

@inject(Lazy.of(PersonService)) 
Export class PersonListView { 
  constructor(personServiceAccessor) { 
    this.personServiceAccessor = personServiceAccessor; 
  } 

  getPeople() { 
    return this.personServiceAccessor().getAll(); 
  } 
} 

这意味着在实例创建时不会解析PersonService,而是在调用personServiceAccessor函数时解析。如果解析需要在创建对象之后而不是创建对象时进行委托,或者在对象生命周期内必须重新评估多次解析,这可能很有用。

全部

默认情况下,Container解析为与请求键匹配的第一个实例。All解析器允许我们注入一个包含给定键注册的所有服务的数组:

import {All, inject} from 'aurelia-dependency-injection'; 
import {PersonValidator} from 'person-validator'; 

@inject(All.of(PersonValidator)) 
Export class PersonForm { 
  constructor(validators) { 
    this.validators = validators; 
  } 

  validate() { 
    for (let i = 0; i < this.validators.length; ++i) { 
      this.validators[i].validate(); 
    } 
  } 
} 

在这里,我们可以想象多个对象或类已经使用PersonValidator键进行了注册,并且它们都被作为数组注入到PersonForm视图模型中。

可选

Optional解析器只有在给定键已经注册时才注入实例。如果没有,它不会自动注册,而是注入null。第二个参数省略或设置为true时,使查找解析器上升到容器层次结构。如果设置为false,则只检查当前容器。

import {Optional, inject} from 'aurelia-dependency-injection'; 
import {PersonService} from 'person-service'; 

@inject(Optional.of(PersonService, false)) 
Export class PersonListView { 
  constructor(personService) { 
    this.personService = personService; 
  } 

  getPeople() { 
    return this.personService ? this.personService.getAll() : []; 
  } 
} 

在这里,只有在当前容器中已经注册了PersonService实例时,才在PersonListView构造函数中注入PersonService的一个实例。如果没有,则注入null

父级

Parent解析器跳过当前容器,从父容器开始解析。如果当前容器是根容器,则注入null

import {Parent, inject} from 'aurelia-dependency-injection'; 
import {PersonService} from 'person-service'; 

@inject(Parent.of(PersonService)) 
Export class PersonListView { 
  constructor(personService) { 
    this.personService = personService; 
  } 
} 

工厂

Factory解析器注入一个工厂函数。每次执行工厂函数时,它将请求容器中的新实例。此外,传递给这个工厂函数的任何参数都将由容器传递给类构造函数。如果类有依赖项,使用任何inject策略声明,额外的参数将在解析的依赖项传递到构造函数时附加:

import {Factory, inject} from 'aurelia-dependency-injection'; 
import {AddressService} from 'address-service'; 

@inject(AddressService) 
class Person { 
  constructor(addressService, address) { 
    this.addressService = addressService; 
    this.address = address; 
  } 
} 

@inject(Factory.of(Person)) 
export class PersonListView { 
  constructor(personFactory) { 
    this.personFactory = personFactory; 
  } 

  createPerson(address) { 
    return this.personFactory(address); 
  } 
} 

在这个例子中,我们首先看到一个Person类被inject装饰器修饰,这暗示容器其构造函数需要一个AddressService实例作为第一个参数。我们也可以看到,构造函数实际上期望一个名为address的第二个参数,容器对此一无所知。接下来,我们有一个PersonListView类,以一种Person工厂在其构造函数中被注入的方式被装饰。其createPerson方法,传入一个address,用这个地址调用Person工厂函数。

当被调用时,为了创建一个Person实例,容器将首先解析一个AddressService实例来满足Person的依赖关系,然后用解析的AddressService实例和传递给工厂函数的address调用Person构造函数。

新实例

NewInstance解析器让容器在每次注入时创建类的全新实例,完全忽略类任何现有的注册。

import {NewInstance, inject} from 'aurelia-dependency-injection'; 
import {PersonService} from 'person-service'; 

@inject(NewInstance.of(PersonService)) 
Export class PersonListView { 
  constructor(personService) { 
    this.personService = personService; 
  } 
} 

插件系统

既然我们已经对 Aurelia 中依赖注入的工作原理有了很好的理解,我们就可以开始使用它了。除了用于使用injectResolver创建和组合组件外,依赖注入还是 Aurelia 插件系统的核心。

插件

几乎 Aurelia 的每一个部分都是以插件的形式出现的。事实上,aurelia-framework库只是一个插件系统和配置机制,Aurelia 的其他核心库都是以这种方式 plugged into this mechanism。

一个 Aurelia 插件从index.js文件开始,这个文件必须导出一个configure函数。这个函数将在 Aurelia 启动时被调用,并接收一个 Aurelia 配置对象作为其第一个参数和一个可选的配置回调函数。

一个示例

让我们想象一个名为our-plugin的插件。这个插件首先需要在我们的main.js文件中的configure函数中启用:

src/main.js

export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .developmentLogging() 
    .plugin('our-plugin', config => { config.debug = true; }); 
  aurelia.start().then(() => aurelia.setRoot()); 
} 

在这里,除了标准的应用程序配置外,我们还告诉 Aurelia 加载our-plugin。我们还告诉 Aurelia 使用作为plugin函数第二个参数提供的回调来配置our-plugin。这个回调接收到由our-plugin定义的配置对象,我们将其debug属性设置为true

现在让我们想象一下我们插件的index.js文件:

export function configure(aurelia, callback) { 
  let config = { debug: false }; 
  if (typeof callback === 'function') { 
    callback(config); 
  } 
  aurelia.container.registerInstance(OurPluginConfig, config); 
} 

在这里,我们首先可以为我们的插件创建一个默认配置对象,如果提供了配置回调,我们将用我们的配置调用它,给插件的使用者机会更改它。然后我们可以将我们的配置对象注册为OurPluginConfig类的唯一实例。然后我们可以想象,由our-plugin暴露的服务会有这个OurPluginConfig的依赖,所以当它们由容器实例化时,它们会注入配置对象。

注册全局资源

使用这个configure函数,任何插件都可以注册自己的服务,甚至更改或覆盖其他插件声明的服务。它还可以为模板引擎注册资源:

export function configure(aurelia) { 
  aurelia.globalResources('./my-component'); 
} 

在这里,一个插件注册了一个名为my-component的资源。这个资源可能有很多不同的事物;我们将在下一章节中覆盖模板资源。

特性

插件是组织和解除代码耦合的好方法。但是插件作为项目依赖存在于外部库中。例如,在使用 CLI 时,插件位于node_modules目录中。在典型项目中,那里的代码不受版本控制。这部分代码不应作为项目的一部分进行修改。实际上它不属于项目;它由其他人管理,或者至少在一个不同的项目工作流中。

但是,如果我们想要像这样结构自己的应用程序怎么办呢?使用插件机制会使这变得相当复杂,因为我们需要将不同的插件视为不同的项目,并单独打包它们,然后在应用程序中安装它们。每次需要更改插件中的任何一个时,都需要单独进行更改,然后发布并更新应用程序中的依赖关系。尽管有时共享在多个项目中使用的通用组件或行为很有用,但这种工作流程在非必要时增加了开发过程的复杂性。

幸运的是,Aurelia 有一个解决方案,即特性。特性与插件完全一样工作,但它位于应用程序内部。让我们看一个例子:

src/my-feature/index.js

export function configure(aurelia) { 
  // register some services or resources used by this feature 
} 

src/main.js

export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .developmentLogging() 
    .feature('my-feature'); 
  aurelia.start().then(() => aurelia.setRoot()); 
} 

特性工作方式和插件完全一样,不同之处在于我们使用feature方法而不是plugin方法来加载它们,并且它们位于src目录内。像插件一样,特性预期在其根目录下有一个index.js文件,该文件应导出一个configure函数。像插件一样,它可以传递一个配置回调作为feature方法的第二个参数,这个回调将传递给特性的configure函数。

feature方法期望相对路径到包含index.js文件的目录。例如,如果我的特性位于src/some/path/index.js,加载它的调用将是feature('some/path')

特性是组织代码的好方法。它们使你更容易将可能是一个巨大、单块的应用程序分解成一系列设计良好的模块。当然,这都取决于开发团队的设计技能。在第六章,设计关注 - 组织和解耦,我们将介绍一些模式、策略和组织代码的方法,以构建更好的 Aurelia 应用程序。

日志记录

Aurelia 带有一个简单而强大的日志系统。它支持日志级别和可插拔的附加器。

配置

为了配置日志,至少必须添加一个日志附加器:

**src/main.js**

import * as LogManager from 'aurelia-logging'; 
import {ConsoleAppender} from 'aurelia-logging-console'; 

export function configure(aurelia) { 
  aurelia.use.standardConfiguration(); 

  LogManager.addAppender(new ConsoleAppender()); 
  LogManager.setLevel(LogManager.logLevel.info); 

  aurelia.start().then(() => aurelia.setRoot()); 
}; 

在这里,首先向日志模块添加了ConsoleAppender实例,该实例从aurelia-logging-console库导入。这个附加器简单地将日志输出到浏览器的控制台。

为了使日志工作,至少必须添加一个附加器。如果没有添加附加器,日志将被简单丢弃。

接下来,日志级别被设置为info。这意味着所有较低级别的日志不会被分发到附加器。Aurelia 支持四个日志级别,从最低到最高:debuginfowarnerror。例如,将最小日志级别设置为warn意味着debuginfo日志将被忽略。此外,还有一个none日志级别可用。当设置时,它简单地执行没有任何过滤,并将所有日志分发到附加器。

默认配置

上一个示例旨在展示一个完全自定义的设置。相反,你可以在配置应用程序时使用developmentLogging方法:

**src/main.js**

export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .developmentLogging(); 

  aurelia.start().then(() => aurelia.setRoot()); 
}; 

这个默认配置安装了ConsoleAppender,并将日志级别设置为none

附加器

附加器必须实现一个简单的接口,每个日志级别有一个方法。例如,以下是 Aurelia 的ConsoleAppender实现:

export class ConsoleAppender { 
  debug(logger, ...rest) { 
    console.debug(`DEBUG [${logger.id}]`, ...rest); 
  } 

  info(logger, ...rest) { 
    console.info(`INFO [${logger.id}]`, ...rest); 
  } 

  warn(logger, ...rest) { 
    console.warn(`WARN [${logger.id}]`, ...rest); 
  } 

  error(logger, ...rest) { 
    console.error(`ERROR [${logger.id}]`, ...rest); 
  } 
} 

正如你所看到的,每个方法首先接收初始化日志的日志器,然后是传递给日志器的日志方法的参数。

写日志

为了写日志,你首先需要获取一个日志器:

import {LogManager} from 'aurelia-framework'; 
const logger = LogManager.getLogger('my-logger'); 

getLogger方法期望日志器的名称,并返回日志器实例。如果为提供的名称不存在日志器,则会创建一个新的。日志器是单例,所以对于给定的名称始终返回相同的实例。

一旦你有一个日志器实例,你可以调用它的四个日志方法之一:debug()info()warn()error()。每个这些方法都将调用所有附加器的相应级别方法,假设方法日志级别等于或高于配置的最小日志级别。否则,附加器不会被调用,日志将被丢弃。

日志器方法可以传递任意数量的参数,这些参数将被分发到附加器。例如,当在日志器上调用error('A message', 12)时,调用将被委派给附加器的appender.error(logger, 'A message', 12)

默认情况下,所有日志记录器都使用全局日志级别进行配置。然而,日志记录器还具有一个setLevel方法,允许为单个日志记录器设置不同的日志级别:

logger.setLevel(LogManager.logLevel.warn); 

路由

除了非常简单的情况外,一个典型的单页应用程序通常由多个视图组成。大多数时候,这样的应用程序有一个固定的全局布局,包括一个显示当前视图的可变区域和一个允许用户从一个视图导航到另一个视图的菜单。在 Aurelia 中,这些功能由路由器插件支持。

配置路由器

为了启用路由,请确保您的应用程序依赖于aurelia-routeraurelia-templating-router库,就像基于 CLI 的项目那样默认依赖。然后在你main.js文件的configure函数中加载路由插件, either by loading the whole standardConfiguration(), which includes the router, or by loading the router()individually. 有关如何在应用程序configure函数中加载插件的更多信息,请参阅第一章,入门

声明路由

我们将从向我们的根组件添加一个configureRouter方法开始。当 Aurelia 检测到组件上的这个回调方法时,它会在组件初始化周期中调用它。这个方法接收两个参数:一个路由配置对象和路由本身:

src/app.js

export class App { 
  configureRouter(config, router) { 
    this.router = router; 
    config.title = 'Learning Aurelia'; 
    config.map([ 
      { route: ['', 'contacts'], name: 'contacts', moduleId: 'contact-list', nav: true, title: 'Contacts' }, 
      { route: 'contacts/:id', name: 'contact-details', moduleId: 'contact-details' }, 
    ]); 
  } 
} 

configureRouter方法中,我们首先将路由器分配给一个实例变量。这很重要,因为我们的根组件的视图需要访问路由器以渲染菜单和活动路由组件。

一旦完成,我们设置全局标题。这个值将显示在浏览器标题栏中。

接下来,我们使用map方法配置两个路由。路由配置基本上是将一个 URL 路径模式与一个组件的映射,当路径匹配时激活路由,并在路由激活时显示组件。它还包含其他属性。让我们分解一个路由配置:

  • route属性是 URL 路径模式。重要的是要注意,这些模式省略了路径的前斜杠。有三种类型的模式:

    • 静态路由:该模式完全匹配路径。我们第一个路由的第一个模式是这种模式的例子:它匹配根路径(/),由于省略了前斜杠,它匹配空字符串。这使得它成为默认路由。

    • 参数化路由:该模式完全匹配路径,并且与占位符匹配的路径部分(前缀为冒号:)被解析为路由参数。这些参数的值在屏幕激活生命周期中作为路由组件的一部分提供。我们第二个路由的模式是这种模式的例子:它匹配以/contacts/开头的路径,后跟第二个部分,被解释为联系人的id

      注意

      此外,可以通过在参数后添加一个问号使其成为可选参数。 例如,contacts/:id?/details 模式将匹配 /contacts/12/details/contacts/details 两者。 当在路径中省略参数时,传递给路由组件的相应参数将是 undefined

    • 通配符路由:该模式匹配路径的开始部分,路径的其余部分被视为一个单一参数,其值在屏幕激活生命周期中作为路由组件的一部分提供。 例如,my-route*param 模式将匹配任何以 /my-route 开头的路径,param 将是 一个参数,其值是匹配到的路径的其余部分。

  • name 属性唯一标识路由。 我们稍后可以看到如何使用它来生成路由的 URL。

  • moduleId 属性是路由组件的路径。

  • nav 属性,当设置为 true 值时,告诉路由器将此路由包含在其导航模型中,该模型用于自动构建应用程序的导航菜单。另外,如果 nav 是一个数字,则路由器将使用它来对导航菜单中的项目进行排序。

  • title 属性在路由活动时将显示在浏览器标题栏中,除非组件覆盖它。 如果 navtrue,它也用作路由的菜单项文本。

  • settings 属性是可选的,可以包含激活组件或管道步骤可以使用任意数据,我们将在本章后面看到。

重定向路由

代替 moduleId,路由可以声明一个 redirect 属性。 当这样的路由被激活时,路由器将执行内部重定向到代表该属性值的路径。 这允许用多个模式技术声明默认路由的替代方法,正如我们第一个路由所展示的那样。 相反,我们可以声明以下路由:

config.map([ 
  { route: '', redirect: 'contacts' }, 
  { route: 'contacts', name: 'contacts', moduleId: 'contact-list', nav: true, title: 'Contacts' }, 
  { route: 'contacts/:id', name: 'contact-details', moduleId: 'contact-details' }, 
]); 

与这个配置的主要区别是,当访问 / 时,浏览器地址栏中的 URL 将更改为 /contacts,因为路由器将执行重定向。

使用此模式时,nav 属性应该只在目标路由上设置为 true。 如果它在重定向路由上设置而不是目标路由,那么路由器将无法突出显示相应的菜单项,因为该路由在目标路由激活之前仅短暂激活片刻。 最后,在重定向路由及其目标路由上都设置为 true 会导致两者都在菜单中渲染,这是没有意义的,因为它们都通向同一个地方。

如果 nav 属性是 false,那么设置 title 也是没有意义的,因为该路由从未激活足够长的时间以至于标题可见。

然而,为重定向路由设置name可能是有用的。当重定向预期在未来会改变时,可以使用重定向路由的name来生成链接,而不是目标路由的。这样,路由的redirect属性是唯一需要改变的东西,依赖于这个路由的每一个链接都会随之改变。

导航策略

除了moduleIdredirect属性之外,路由还可以有一个navigationStrategy属性。其值必须是一个函数,该函数将由路由器调用,并传递一个NavigationInstruction实例。然后可以动态地配置这个对象。例如,我们的最后一个路由可以配置成这样:

{ 
  route: 'contacts/:id', name: 'contact-details',  
  navigationStrategy: instruction => { 
    instruction.config.moduleId = 'contact-details'; 
  } 
} 

最后,这个路由做的和之前一样。但对于需要比moduleIdredirect更灵活的场景,这个替代方案可以变得很有用,因为NavigationInstruction实例包含以下属性:

  • config:正在导航到的路由的配置对象

  • fragment:触发导航的 URL 路径

  • params:包含从路由模式中提取的每个参数的对象

  • parentInstruction:如果这个路由是一个子路由,则是指令父路由的指令

  • plan:由路由器内部构建并使用以执行导航的导航计划

  • previousInstruction:当前指令将在路由器中替换的导航指令

  • queryParams:包含从查询字符串解析出的值的对象

  • queryString:原始查询字符串

  • viewPortInstructions:视口指令,由路由器内部构建并使用以执行导航

布局我们的应用程序

基于其路由配置,路由器生成一个导航模型,可以用来自动生成导航菜单。因此,当添加新路由时,我们不需要改变路由的配置和菜单视图。

由于我们根组件的视图模型负责声明路由,它的视图应该是全局布局并渲染导航菜单。让我们使用这个导航模型来创建根组件的视图:

src/app.html

<template> 
  <require from="app.css"></require> 
  <nav class="navbar navbar-default navbar-fixed-top" role="navigation"> 
    <div class="navbar-header"> 
      <button type="button" class="navbar-toggle" data-toggle="collapse" 
              data-target="#skeleton-navigation-navbar-collapse"> 
        <span class="sr-only">Toggle Navigation</span> 
      </button> 
      <a class="navbar-brand" href="#"> 
        <i class="fa fa-home"></i> 
        <span>${router.title}</span> 
      </a> 
    </div> 

    <div class="collapse navbar-collapse" id="skeleton-navigation-navbar-collapse"> 
      <ul class="nav navbar-nav"> 
        <li repeat.for="row of router.navigation" class="${row.isActive ? 'active' : ''}"> 
          <a data-toggle="collapse" data-target="#skeleton-navigation-navbar-collapse.in" href.bind="row.href"> 
            ${row.title} 
          </a> 
        </li> 
      </ul> 

      <ul class="nav navbar-nav navbar-right"> 
        <li class="loader" if.bind="router.isNavigating"> 
          <i class="fa fa-spinner fa-spin fa-2x"></i> 
        </li> 
      </ul> 
    </div> 
  </nav> 
  <div class="page-host"> 
    <router-view></router-view> 
  </div> 
</template> 

这个模板中突出显示的部分是最有趣的部分。让我们来看一下。

首先要注意的是,我们需要一个名为app.css的文件,我们将在一会儿写它。这个文件将样式化我们的应用程序组件。

接下来,视图使用了router属性,该属性定义在我们根组件的视图模型的configureRouter方法中。我们首先在带有nav-brand类的a标签中看到它,其中字符串插值指令渲染文档标题。

然后,我们在li标签上发现了一个repeat.for="row of router.navigation"属性。这个绑定指令为router.navigation数组中的每个项目重复li标签。这个navigation属性包含了路由器的导航模型,该模型是用路由的 truthy nav属性构建的。在渲染每个li标签时,模板引擎的绑定上下文中都有一个包含当前导航模型项的row变量。

li标签还有一个class="${row.isActive ? 'active' : ''}"属性。这个字符串插值指令使用当前导航模型项的isActive属性。如果isActive评估为true值,它就会给li标签分配一个active CSS 类。这个属性由路由器管理,仅当导航模型项属于活动路由时才是true。在这个模板中,它用来突出显示活动菜单项。

li标签内的锚点有一个href.bind="row.href"属性。这个指令将标签的href属性绑定到当前导航模型项的href属性。这个href属性是由路由器使用路由的路径模式构建的。此外,在锚点内部,还渲染了路由的title

在菜单的末尾,我们可以看到一个带有loader CSS 类的li标签。这个元素包含一个旋转图标。它有一个if.bind="router.isNavigating"属性,它将这个元素在 DOM 中的存在与路由器的isNavigating属性的值绑定在一起。这意味着当路由器执行导航时,顶部的右角将显示一个旋转图标。当没有导航发生时,这个图标不仅不可见,实际上甚至根本不在 DOM 中,感谢if属性。

最后,router-view元素作为路由视图 port,显示活动路由组件。这是整个模板中唯一必需的部分。当一个组件配置路由器时,其视图必须包含一个router-view元素,否则将抛出错误。利用导航模型是可选的,菜单可以是静态的,或者通过任何你能想象到的其他方式构建。显示标题也是可选的。利用isNavigating指示器绝对是完全不必要的。然而,如果一个组件配置了路由器,而它的视图却不能显示活动路由组件,那么这个组件配置路由器就是毫无意义的。

这个视图使用了一种结构,如果你曾经使用过 Bootstrap,你可能就会熟悉。Bootstrap 是由 Twitter 开发的 CSS 框架,我们将在我们的应用程序中使用它。让我们来安装它:

> npm install bootstrap --save

我们还需要在我们的应用程序中加载它:

index.html

<!DOCTYPE html> 
<html> 
  <head> 
    <title>Learning Aurelia</title> 
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet"> 
  </head> 
  <!-- Omitted snippet... --> 
</html> 

我们的app组件在能正常工作之前还缺最后一块拼图,那就是app.css文件。文件内容如下:

src/app.css

.page-host { 
  position: absolute; 
  left: 0; 
  right: 0; 
  top: 50px; 
  bottom: 0; 
  overflow-x: hidden; 
  overflow-y: auto; 
} 

尝试一下

至此,如果你运行我们的应用程序,你应在浏览器的控制台看到一个路由错误。那是因为默认路由试图加载contact-list组件,而这个组件还不存在。

让我们创建一个空的文件:

src/contact-list.html

<template> 
<h1>Contacts</h1> 
</template> 

src/contact-list.js

export class ContactList {} 

现在如果你再次尝试运行应用程序,你应该看到应用程序正确加载,显示顶部菜单和空的 contact-list 组件。

屏幕激活生命周期

当路由器检测到 URL 路径发生变化时,它会经历以下生命周期:

  1. 确定目标路由。如果没有任何路由与新路径匹配,将抛出一个错误,并且在这里停止处理过程。

  2. 给活动路由组件一个拒绝停用的机会,在这种情况下,路由器恢复之前的 URL 并在这里停止处理过程。

  3. 给目标路由组件一个拒绝激活的机会,在这种情况下,路由器恢复之前的 URL 并在这里停止处理过程。

  4. 停用活动路由组件。

  5. 激活目标路由组件。

  6. 视图被交换。

为了加入这个生命周期,组件可以实现以下任意一个回调方法:

  • canActivate(params, routeConfig, navigationInstruction):在步骤 #2 时调用,以知道组件是否可以被激活。可以返回一个 boolean 值、一个 Promise 类型的 boolean 值、一个导航命令,或者一个 Promise 类型的导航命令。

  • activate(params, routeConfig, navigationInstruction):在步骤 #5 时调用,当组件被激活时。可以返回一个可选的 Promise

  • canDeactivate():在步骤 #3 时调用,以知道组件是否可以被停用。可以返回一个 boolean 值、一个 Promise 类型的 boolean 值、一个导航命令,或者一个 Promise 类型的导航命令。

  • deactivate():在步骤 #4 时调用,当组件被停用时。可以返回一个可选的 Promise

Promise 在整个生命周期中都是被支持的。这意味着当回调方法中的任何一个返回一个 Promise 时,路由器会在继续处理之前等待其解决。

此外,canActivateactivate 都接收与导航上下文相关的参数:

  • params 对象将有一个属性,用于每个解析的路由模式中的参数,以及每个查询字符串值的属性。例如,我们的 contact-details 组件将接收一个具有 id 属性的 params 对象。在匹配路径中没有值的可选参数将被设置为 undefined

  • routeConfig 将是原始的路由配置对象,具有一个额外的 navModel 属性。这个 navModel 对象有一个 setTitle(title: string) 方法,该方法可以被组件用来将文档标题更改为动态值,如激活期间加载的数据。我们将在第三章中看到更多内容,显示数据

  • navigationInstruction 是路由器用来执行导航的 NavigationInstruction 实例。

最后,canDeactivatecanActivate都可以如果它们返回false、一个解析为falsePromise、一个导航命令或一个解析为导航命令的Promise来取消导航。

导航命令

导航命令是一个具有navigate(router: Router)方法的对象。当从canDeactivatecanActivate返回导航命令时,路由器取消当前导航并将控制权委托给命令。Aurelia 自带一个导航命令:Redirect。这是一个使用它的示例:

src/contact-details.js

import {inject} from 'aurelia-framework'; 
import {Redirect} from 'aurelia-router'; 
import {ContactService} from 'app-services'; 

@inject(ContactService) 
export class ContactDetails { 
  constructor(contactService) { 
    this.contactService = contactService; 
  } 

  canActivate(params) { 
    return this.contactService.getById(params.id) 
      .then(contact => { this.contact = contact; }) 
      .catch(e => new Redirect('error')); 
  } 
} 

在这里,在canActivate回调方法中,ContactDetails视图模型尝试通过其id加载联系人。如果由getById返回的Promise被拒绝,用户将被重定向到error路由。

处理未知路由

当路由器无法将 URL 路径与任何路由匹配时,它会抛出一个错误。但在提出这个错误之前,它首先将导航指令委托给一个未知的路由处理程序,如果有的话。此处理程序可以通过使用mapUnknownRoutes方法进行配置,该方法可以接受以下值之一作为参数:

  • 组件显示的路径,而不是抛出错误。

  • 路由配置对象,包含moduleIdredirectnavigationStrategy属性之一。路由器将委托导航到此路由,而不是抛出错误。

  • 一个接收NavigationInstruction实例并返回要显示的组件路径而不是抛出错误的函数。

让我们实现一个not-found组件,当链接断裂时,我们的应用程序将显示它:

src/not-found.html

<template> 
  <h1>Something is broken...</h1> 
  <p>The page cannot be found.</p> 
</template> 

src/not-found.js

export class NotFound {} 

在我们的根组件中,我们只需要添加突出显示的行:

src/app.js

export class App { 
  configureRouter(config, router) { 
    this.router = router; 
    config.title = 'Learning Aurelia';  
    config.map([ /* omitted for brevity */ ]); 
    config.mapUnknownRoutes('not-found'); 
  } 
} 

任何时候路由器无法将 URL 路径与现有路由匹配,我们的not-found组件都将显示。

约定路由

mapUnknownRoutes提供的另一个选项是使用路由约定而不是一组静态定义的路由。如果你的所有路由都遵循路径和moduleId之间的相同命名模式,我们可以想象这样的事情:

src/app.js

export class App { 
  configureRouter(config, router) { 
    this.router = router; 
    config.title = 'Learning Aurelia'; 
    config.mapUnknownRoutes(instruction => getComponentForRoute(instruction.fragment)); 
  } 
} 

在这里,路由依赖于一个由getComponentForRoute函数实现的约定,该函数接收触发导航的 URL 路径,并返回必须显示的组件路径。

激活策略

当多个静态路由导致相同的组件,并在这些路由之间发生导航时,路由器只是保持相同的组件实例。由于这个原因,激活生命周期不会执行。这种行为由激活策略决定。activationStrategy枚举有两个值:

  • replace:用新路由替换当前路由,保持相同的组件实例,不经过激活生命周期。这是默认行为。

  • invokeLifecycle:即使活动组件没有变化,也要经历激活生命周期。

改变这种行为有两种方法:

  • 在路由的配置对象中,你可以添加一个activationStrategy属性,指定激活此路由时应使用哪种策略。

  • 在路由组件的视图模型中,你可以添加一个determineActivationStrategy方法,该方法必须返回所有显示此组件的路由所使用的策略。

子路由 (Child routers)

就像 DI 一样,容器可以有子容器,形成一个容器树;就像组件可以包含子组件,形成一个组件树,路由器也可以有子路由器。这意味着一个路由组件的视图模型可以有自己的configureRouter方法,其视图有一个router-view元素。当遇到这样的组件时,路由器将为这个子组件创建一个子路由器。这个子路由器的路由模式相对于父路由的模式是相对的。

这使得应用程序可以有一个具有多级层次的导航树。在讨论如何组织大型应用程序时,我们将会看到如何利用这一特性,请参阅第六章,设计关注点 - 组织和解耦

管道 (Pipelines)

可能很有必要将路由器与一些在每次发出导航请求时都会被调用的逻辑连接起来。例如,具有认证机制的应用程序可能需要将某些路由限制为仅限认证用户。Aurelia 路由器的管道正是为这类场景而设计的。

路由器支持四个管道:authorizepreActivatepreRenderpostRender。这些管道在导航过程中的不同阶段被调用。让我们看看它们各自发生在哪里:

  1. 如果存在的话,当前路由组件的canDeactivate方法会被调用。

  2. 执行authorize管道。

  3. 如果存在的话,目标路由组件的canActivate方法会被调用。

  4. 执行preActivate管道。

  5. 如果存在的话,当前路由组件的deactivate方法会被调用。

  6. 如果存在的话,目标路由组件的activate方法会被调用。

  7. 执行preRender管道。

  8. 在路由视口中交换视图。

  9. 执行postRender管道。

管道由步骤组成,这些步骤按顺序调用。管道步骤是一个具有run(instruction, next)方法类的实例,其中instruction是一个NavigationInstruction实例,next是一个Next对象。

Next对象是一个具有方法的对象。

当调用next()时,它告诉路由器管道继续执行下一个步骤。next.cancel()方法取消了导航过程,并期望传递一个导航命令或Error对象作为参数。

两者都返回Promise

让我们看一个例子:

src/app.js

import {AuthenticatedStep} from 'authenticated-step'; 

export class App { 
  configureRouter(config, router) { 
    config.title = 'Aurelia'; 
    config.addPipelineStep('authorize', AuthenticatedStep); 
    config.map([ 
      { route: 'login', name: 'login', moduleId: 'login', title: 'Login' }, 
      { route: 'management', name: 'management', moduleId: 'management',  
        settings: { secured: true } }, 
    ]); 
    this.router = router; 
  } 
} 

这里需要注意的是,AuthenticatedStep类被添加到了authorize管道中。管道步骤作为类添加,而不是实例。这是因为路由使用其 DI 容器来解析步骤的实例。这允许步骤有依赖关系,这些依赖关系在执行前被解析和注入。

第二个要注意的是,management路由有一个settings对象,其secured属性被设置为true。它将由以下片段中的管道步骤使用,以识别需要对已认证用户限制的路由。

src/authenticated-step.js

import {inject} from 'aurelia-framework'; 
import {Redirect} from 'aurelia-router'; 
import {User} from 'user'; 

@inject(User) 
export class AuthenticatedStep { 
  constructor(user) { 
    this.user = user; 
  } 

  run(instruction, next) { 
    let isRouteSecured = instruction.getAllInstructons().some(i => i.config.settings.secured); 
      if (isRouteSecured && !this.user.isAuthenticated) { 
      return next.cancel(new Redirect('login')); 
    } 
    return next(); 
  } 
} 

这是实际的管道步骤。在这个例子中,我们可以想象我们的应用程序包含一个User类,它暴露了当前用户的信息。我们的管道依赖于这个类的实例,以知道当前用户是否已认证。

run方法首先检查指令中的任何路由是否被配置为安全。这是通过检查所有导航指令,包括潜在父路由的指令,并检查其配置的settings中的真值secured属性来实现的。

例如,当导航到前一个代码片段中定义的management路由时,isRouteSecured的值将被设置为true。如果management组件声明了子路由,并且导航是对其中之一进行的,那么情况也会如此。在这种情况下,即使子路由没有被配置为securedisRouteSecured仍将是true,因为其中一个父路由将是secured

当目标路由或其之一被设置为安全时,如果用户未认证,导航将被取消,用户将被重定向到login路由。否则,调用next,让路由器知道它可以继续导航过程。

事件

Aurelia 路由还提供了另一个扩展点。除了屏幕激活生命周期和管道之外,路由还通过事件聚合器发布事件,这是 Aurelia 的核心库之一。

可以在samples/chapter-2/router-events中找到路由事件的演示。让我们看看这些事件:

  • router:navigation:processing:每次路由开始处理导航指令时,都会触发此事件。

  • router:navigation:error:当导航指令触发错误时,会触发此事件。

  • router:navigation:canceled:当导航指令被取消时,会触发此事件,取消可以是当前或目标路由组件的屏幕激活生命周期回调方法之一,或者是管道步骤。

  • router:navigation:success:当导航指令成功时,会触发此事件。

  • router:navigation:complete:一旦导航指令的处理完成,无论它失败、被取消还是成功,都会触发此事件。

所有这些事件的负载都包含一个NavigationInstruction实例,作为instruction属性存储。此外,除了router:navigation:processing之外,其他事件的所有负载都有一个PipelineResult作为result属性。例如,在处理error事件时,可以使用result属性的output属性来访问被抛出的Error对象。

我们将在第六章中看到事件聚合器是如何工作的,设计关注 - 组织和解耦

多个视口

在所有之前的示例中,router-view元素从未有过任何属性。它实际上可以有一个name属性。当省略这个属性时,由元素声明在路由器上的视口被称为default。您看到这里的含义了吗?

如果你回答路由支持多个视口,那你猜对了。当然,这也意味着每个声明在视图中的视口都必须为每个视口配置路由。让我们看看这是如何工作的:

注意

以下代码片段摘自samples/chapter-2/router-multiple-viewports

src/app.html

<template> 
  <require from="nav-bar.html"></require> 
  <require from="bootstrap/css/bootstrap.css"></require> 

  <nav-bar router.bind="router"></nav-bar> 

  <div class="page-host"> 
    <router-view name="header"></router-view> 
    <router-view name="content"></router-view> 
  </div> 
</template> 

在组件的视图中,有趣的是注意到有两个router-view元素,有不同的name属性。这个组件的路由最终会有两个视口:一个名为header,另一个名为content

src/app.js

export class App { 
  configureRouter(config, router) { 
    config.title = 'Learning Aurelia'; 
    config.map([ 
      { 
        route: ['', 'page-1'], name: 'page-1', nav: true, title: 'Page 1',  
        viewPorts: {  
          header: { moduleId: 'header' },  
          content: { moduleId: 'page-1' } 
        } 
      }, 
      { 
        route: 'page-2', name: 'page-2', nav: true, title: 'Page 2',  
        viewPorts: {  
          header: { moduleId: 'header' },  
          content: { moduleId: 'page-2' } 
        } 
      }, 
    ]); 

    this.router = router; 
  } 
} 

在视图模型的configureRouter回调方法中,两个路由都使用特定的moduleId进行了配置,既适用于header,也适用于contentviewPorts

如果在路由激活时没有为每个路由的视口配置路由,则路由器将抛出错误。无论是否静态地使用viewPorts属性为每个视口定义了moduleId,还是viewPorts属性通过navigationStrategy动态配置,都不重要。在前一个示例中,page-2路由可以被替换为:

{ 
  route: 'page-2', name: 'page-2', nav: true, title: 'Page 2',  
  navigationStrategy: instruction => { 
    instruction.config.viewPorts = { 
      header: { moduleId: 'header' },  
      content: { moduleId: 'page-2' } 
    }; 
  } 
} 

此路由与前一个示例中的效果相同。这里唯一的区别是,每次路由激活时都会动态地配置视口。

当然,重定向路线不会受到视口的影响,因为它们不会渲染任何内容。

状态推送与哈希变化

路由器通过响应 URL 的变化来工作。在旧浏览器中,只有 URL 中的#符号后面的部分,即哈希部分,可以改变而不触发页面重载。因此,在这些浏览器上运行的路由器只能更改哈希部分,并监听哈希部分的更改。

随着 HTML5 的推出,一个新的历史 API 被引入,以实现对浏览器历史的操作。这使得运行在现代浏览器上的 JavaScript 路由器可以直接操作其当前 URL 和浏览历史,并监控当前 URL 的变化。这个 API 使得路由器能够使用完整的 URL,并允许诸如同构应用之类的技术,具有服务器渲染和渐进增强。这些技术可以使应用程序的内容能够被更广泛的客户端访问,同时也将提高应用程序的 SEO,因为谷歌已经弃用了基于哈希的带有 AJAX 内容加载的应用(参见googlewebmastercentral.blogspot.com/2015/10/deprecating-our-ajax-crawling-scheme.html)。

注意

当一个应用程序可以在客户端和服务器上执行时,它被称为同构应用程序。通常,同构应用程序在服务器端执行,以渲染基于文本的 HTML 表示,然后可以返回给客户端;例如,搜索引擎爬虫。当在客户端执行时,它通常通过运行时事件处理程序、数据绑定和实际行为进行增强,以便用户可以与应用程序互动。

奥雷利亚(Aurelia)的路由插件可以与这两种策略中的任何一种工作。默认情况下,它被配置为使用基于哈希的策略,因为状态推送需要服务器相应地配置。此外,基于哈希的策略支持不完全兼容 HTML5 的旧浏览器。

然而,如果不需要支持旧浏览器,或者需要服务器端渲染,并且应用程序可能会向同构方向发展,路由器可以配置为使用历史 API。

注意

下面的代码片段是samples/chapter-2/router-push-state的摘录。

首先,在index.html文件中,在头部部分,必须添加一个<base href="/">标签。这个元素指示浏览器/是页面中所有相对 URL 的基础。

接下来,在根组件的视图模型中,路由必须配置不同:

src/app.js

export class App { 
  configureRouter(config, router) { 
    this.router = router; 
    config.title = 'Aurelia'; 
    config.options.pushState = true; 
    config.options.hashChange = false; 
    config.map([ /* omitted for brevity */ ]); 
  } 
} 

此外,为了在用户使用除根 URL 以外的 URL 访问应用程序时显示正确的路由,服务器需要输出index.html页面,而不是对未知路径的 404 响应。这样,当用户访问应用程序的路由时,服务器将响应 index 页面,然后应用程序启动,路由器将处理路由并显示正确的视图。这意味着应用程序中的路由和服务器端资源(如 CSS、图片、字体、JS、HTML 或必须由 index 页面或应用程序从服务器加载的任何文件)之间必须没有命名冲突。

生成 URL

路由器能够对 URL 的变化做出反应,并相应地更新其视口,这是一件事。但是关于允许它导航的链接呢?如果我们硬编码 URL,任何路由路径模式的更改都需要更改路由配置,还要检查用于导航的每个地方,无论是 JS 代码还是视图,并修改它。

幸运的是,路由器也能够在生成 URL。要生成一个路由路径,有两个要求:

  • 路由配置必须有一个唯一的name属性

  • 如果路由具有参数化或通配符模式,生成 URL 时必须提供包含每个参数值的参数对象。

在代码中

要在 JS 代码中生成 URL 路径,你首先必须有一个路由器的实例,通常是通过在需要它的类中注入它来获得的。然后,你可以调用以下方法:

router.generate(name: string, params?: any, options?: any): string 

必须使用路由名称、路由有时的参数对象以及可选的选项对象调用此方法,并将返回生成的 URL。目前唯一支持的选择是absolute,当设置为true时,强制路由器返回绝对 URL 而不是相对 URL。

例如,对于路径模式为contacts/:id的名为contact-details的路由,为 id 为 12 的联系人生成 URL 的调用将是:

let url = router.generate('contact-details', { id: 12 }); 

而对于绝对 URL:

let url = router.generate('contact-details', { id: 12 }, { absolute: true }); 

在视图中

如果我们需要在视图中渲染一个指向我们路由的链接怎么办?我猜你可以看到如何将路由器注入视图模型中,调用generate方法,并将锚点的href属性与结果数据绑定。在最坏的情况下,这会很快变得繁琐。

aurelia-templating-router库带有一个route-href属性,这使得这变得容易得多。例如,要为名为contact-details的路由渲染一个到 id 为 12 的联系人链接的模板片段将是:

<a route-href="route: contact-details; params.bind: { id: 12 }"> 
  Contact #12</a> 

机会很大,ID 不会被硬编码,而是存储在一个对象中:

<a route-href="route: contact-details; params.bind: { id: contact.id }"> 
  ${contact.name}</a> 

默认情况下,route-href属性会将生成的 URL 分配给它所在元素的href属性,但它支持一个attribute属性,可以用来指定必须设置 URL 的属性名称:

<q route-href="route: quote; attribute: cite">...</q> 

在这里,quote路由的 URL 将被分配给q元素的cite属性。

导航

路由器提供了方便的方法,可以从 JS 代码执行导航:

  • navigate(fragment: string, options?: any): boolean:导航到新的位置,其路径为fragment。如果导航成功,则返回true,否则返回false。目前支持两个options

    • replace: boolean:如果设置为true,新 URL 将替换历史记录中的当前位置,而不是添加到历史记录中。

    • trigger: boolean:如果设置为false,Aurelia 的路由器将不会被触发。这意味着如果 URL 是相对的,它会在浏览器的地址栏中更改,但实际上不会发生导航。

  • navigateToRoute(name: string, params?: any, options?: any): boolean:方便地包装了对generate的调用,然后是navigate

  • navigateBack(): void:返回历史记录中的上一个位置。

摘要

依赖注入是 Aurelia 的核心,因此理解其工作方式很重要。如果你在本章之前对这个概念不熟悉,一下子可能接受不了这么多;但请放心,由于我们将在书的剩余部分大量使用这些功能,这将帮助你更加熟悉它。

插件、功能和路由也是如此。我们将在书的后面继续深入研究这些主题,特别是在第六章,设计关注 - 组织和解耦,当我们讨论各种应用程序结构的实现方式时。

在到达那里之前,我们还有很多内容需要学习。在下一章,我们将讨论数据绑定和模板的基础知识,并将组件添加到我们的联系人管理应用程序中以获取和显示数据。

第三章:显示数据

为了渲染视图,Aurelia 依赖于两个核心库:aurelia-templating,它提供了一个丰富且可扩展的模板引擎,以及aurelia-binding,它是一个现代且适应性强的数据绑定库。由于模板引擎依赖于数据绑定的抽象,这意味着可以使用 Aurelia 之外的数据绑定库,aurelia-templating-binding库充当了两者之间的桥梁。此外,aurelia-templating-resources建立在模板引擎之上,定义了一组标准行为和组件。

在本章中,我们将介绍数据绑定和模板的基础知识。我们将了解 Aurelia 提供的标准行为以及如何在视图中使用它们。

在渲染任何数据之前,首先必须获取它。大多数时候,单页网络应用程序依赖于某种类型的网络服务。因此,我们将了解 Fetch API 是什么,如何使用 Aurelia 的 Fetch 客户端,以及如何配置它。

最后,在关闭本章之前,我们将把我们新学到的知识应用到我们的联系人管理应用程序中,通过添加视图来显示联系人列表和联系人的详细信息。

模板基础

模板是一个根元素为template元素的 HTML 文件。它必须是有效的 HTML,因为模板引擎依赖于浏览器解析该文件并从中构建一个 DOM 树,然后引擎将遍历、分析和丰富行为。

这意味着适用于 HTML 文件的限制也适用于任何 Aurelia 模板。例如,table元素只能作为子元素包含某些类型的元素,如theadtbodytr。因此,以下模板在大多数浏览器中是非法的:

<template> 
  <table> 
    <compose view="table-head.html"></compose>  
  </table> 
</template> 

在这里,我们想要使用在后面小节中介绍的compose元素,以插入包含表头的视图。由于compose不是table的有效子元素,大多数浏览器在解析 HTML 文件时会忽略它,因此模板引擎无法看到它。

为了克服这些限制,Aurelia 寻找一个特殊的as-element属性。这个属性作为元素名称的别名供模板引擎使用:

<template> 
  <table> 
    <thead as-element="compose " view="table-head.html"></thead> 
  </table> 
</template> 

在这里,将元素名称从compose更改为thead使其成为一个合法的 HTML 片段,并添加as-element="compose"属性告诉 Aurelia 的模板引擎将这个thead元素视为一个compose元素。

视图资源

视图资源是可供模板引擎使用的工件,因此它们可以被模板使用。例如,自定义元素或值转换器是资源。

正如我们在前一章中看到的那样,资源可以全局加载,例如通过应用程序的configure方法、通过插件或通过特性。这样的资源对应用程序中的每个模板都可用。

本地加载资源

除了全局资源外,每个模板都有自己的资源集。一个需要使用一个在全球范围内不可用的资源的模板必须首先加载它。这通过使用require元素来实现:

src/some-module/some-template.html

<template> 
  <require from="some-resource"></require> 
  <!-- at this point, some-resource is available to the template --> 
</template> 

from属性必须是要加载的资源的路径。在前一个示例中,路径是相对于代码根目录的,通常是指向src目录。这意味着some-resource预期直接位于src中。然而,路径也可以通过使用.前缀来使其相对于当前模板文件所在的目录:

src/some-module/some-template.html

<template> 
  <require from="./some-resource"></require> 
</template> 

在这个例子中,some-resource预期位于src/some-module目录中。

此外,可以指定as属性。它用于更改资源的本地名称,以解决与其他资源的名字冲突,例如:

<template> 
  <require from="some-resource" as="another-resource"></require> 
</template> 

在这个例子中,some-resource作为another-resource在模板中可用。

资源类型

默认情况下,预期资源是一个 JS 文件,在这种情况下,路径应该排除.js扩展名。例如,要加载从sort.js文件导出的值转换器,模板只需要求sort。无论资源类型是什么,值转换器、绑定行为、自定义元素等等,除了用作自定义元素的模板之外,都是正确的。

稍后我们将看到如何创建自定义元素。我们还将看到在没有视图模型的情况下如何创建仅包含模板的组件,当一个组件没有行为时。在这种情况下,作为资源加载时,仅包含模板的组件必须使用其完整文件名(包括其扩展名)来引用。例如,要加载一个名为menu.html的仅包含模板的组件,我们需要要求menu.html,而不仅仅是menu。否则,模板引擎将不知道它在寻找一个 HTML 文件而不是一个 JS 文件,并尝试加载menu.js。当我们开始将应用程序拆分为组件时,我们将看到这个的真实示例。

加载 CSS

除了本地加载模板资源外,require元素还可以用来加载样式表:

src/my-component.html

<template> 
  <require from="./my-component.css"></require> 
</template> 

在这个例子中,my-component.css样式表将被加载并添加到文档的头部。

此外,可以使用as="scoped"属性将样式表的作用域限定在组件内:

src/my-component.html

<template> 
  <require from="./my-component.css" as="scoped"></require> 
</template> 

在这个第二个例子中,如果my-component使用 ShadowDOM,并且浏览器支持它,样式表将被注入到 ShadowDOM 根部。否则,它将被注入到组件的视图中,并将scoped属性设置到style元素。

注意

影子 DOM 是一个 API,它允许我们在 DOM 中创建孤立的子树。这样的子树可以加载它们自己的样式表和 JavaScript,并与周围文档的冲突风险无关。这项技术对于无痛开发 Web 组件至关重要,但在撰写本文时,它仍然没有得到广泛浏览器的支持。

style元素上的scoped属性告诉浏览器将样式表的作用域限制在包含元素及其后代元素上。这防止样式与其他文档部分发生冲突,而无需使用 ShadowDOM 根。它是 ShadowDOM 的有用替代品,但仍然没有得到广泛浏览器的支持。

数据绑定

数据绑定是将模板元素使用表达式与数据模型链接起来的动作,数据模型是一个 JS 对象。这个数据模型称为绑定上下文。这个上下文由 Aurelia 用于例如,暴露组件视图模型的属性和方法给其模板。此外,以下部分描述的一些行为会在其绑定上下文中添加信息。

绑定模式

数据绑定支持三种不同的模式:

  • 单向:该表达式最初被评估,并且应用了说明并在视图中渲染。该表达式被观察,因此,无论其值如何变化,都可以重新评估,说明可以更新视图。它的变化只流向一个方向,从模型流向视图。

  • 双向:与单向类似,但更新既可以从模型流向视图,也可以从视图流向模型:如果模板元素(如input)通过用户交互发生变化,模型就会被更新。它的变化是双向的,从模型流向视图,以及从视图流向模型。

    注意

    当然,双向绑定限制了可以绑定的表达式的种类。只有可赋值表达式(通常是可以在 JavaScript 赋值指令的等号(=)操作符左侧使用的表达式)可以用于双向绑定。例如,你不能双向绑定到一个条件三元表达式或一个方法调用。

  • 一次性:该表达式最初被评估,并且应用了说明,但该表达式不会被观察,因此任何在初始渲染后发生的模型变化都不会在视图中反映出来。绑定只会在视图渲染时从模型流向视图,只有一次。

字符串插值

构建模板时最基本的需求是显示文本。这可以通过使用字符串插值来实现:

<template> 
  <h1>Welcome ${user.name}!</h1> 
</template> 

与 ES2015 的字符串插值类似,Aurelia 模板中的此类说明在${}之间评估表达式,并将结果作为文本插入到 DOM 中。

字符串插值可以与更复杂的表达式一起使用:

<template> 
  <h1>Welcome ${user ? user.name : 'anonymous user'}!</h1> 
</template> 

这里,我们使用三元表达式在绑定上下文中定义用户时显示用户的名字,否则显示通用信息。

它也可以用在属性内部:

<template> 
  <h1 class="${isFirstTime ? ' emphasis' : ''}">Welcome!</h1> 
</template> 

在这个例子中,我们使用三元表达式在modelisFirstTime属性为真时,有条件地将emphasis CSS 类分配给h1元素。

默认情况下,字符串插值指令被绑定单向。这意味着,无论表达式的值如何变化,它都将被重新评估并在文档中更新。

数据绑定命令

当分析模板中的一个元素时,模板引擎会寻找带有数据绑定命令的属性。数据绑定命令是附加在属性后面,由点分隔的。它指导引擎对这个属性执行某种数据绑定。它有以下形式:attribute.command="expression"

让我们来看看 Aurelia 提供的各种绑定命令。

绑定(bind)

bind命令将属性的值解释为表达式,并将这个表达式绑定到属性本身:

<template> 
  <a href.bind="url">Go</a> 
</template> 

在这个例子中,绑定上下文中url属性的值将被绑定到a元素的href属性上。

bind命令是自适应的。它根据其目标元素和属性选择其绑定模式。默认情况下,它使用单向绑定,除非目标属性可以通过用户交互更改:例如inputvalue。在这种情况下,bind执行双向绑定,因此用户引起的变化会在模型上得到反映。

单向(One-way)

类似于bind,这个命令执行数据绑定,但不适应其上下文;绑定被强制为单向,无论目标类型如何。

双向(Two-way)

类似于bind,这个命令执行数据绑定,但不适应其上下文,绑定被强制为双向,无论目标类型如何。当然,将这个命令应用于自身无法更新的属性是毫无意义的。

一次性(One-time)

类似于bind,这个命令执行数据绑定,但强制进行一次性绑定,意味着在初始渲染之后模型中的任何变化都不会在视图中反映出来。

注意(Note)

你可能已经推断出一次性绑定比提供的实时绑定(单向和双向绑定)要轻量得多。确实,因为实时绑定需要观察,所以它更消耗 CPU 和内存。在一个大型应用程序中,如果有很多数据绑定指令,尽可能使用一次性绑定会在性能上产生巨大的不同。这就是为什么尽可能坚持使用一次性绑定,并在必要时才使用实时绑定被认为是一个好习惯。

触发器(trigger)

trigger命令将事件绑定到表达式,每次事件被触发时该表达式将被评估。Event对象作为$event变量可供表达式使用:

<template> 
  <button click.trigger="open($event)">Open</button> 
</template> 

在这个例子中,buttonclick 事件将触发对绑定上下文的 open 方法的调用,并将 Event 对象传递给它。当然,使用 $event 是完全可选的;在这里,点击处理器可以是 open(),在这种情况下,Event 对象将被简单忽略。

请注意,事件名称不带 on 前缀:属性名称为 click,而不是 onclick

delegate

与直接在目标元素上附加事件处理器的 trigger 命令不同,delegate 利用事件委托,通过将一个处理程序附加到文档或最近的 ShadowDOM 根元素上来实现。这个处理程序会将事件分派到它们正确的目标,以便评估绑定的表达式。

trigger 一样,Event 对象作为 $event 变量 available 给表达式,属性名中必须省略 on 前缀。

注意

与直接附加到目标元素的事件处理程序相比,事件委托消耗的内存要少得多。就像一次性绑定与实时绑定一样,在小型应用程序中使用委托几乎不会注意到任何差异,但随着应用程序的大小增长,它可能会对内存足迹产生影响。另一方面,直接将事件处理程序附加到元素上是某些场景所必需的,尤其是当禁用冒泡时要触发自定义事件。

call

call 命令用于将一个包含表达式的函数绑定到自定义属性或自定义元素的结构。当发生特定事件或满足给定条件时,这些自定义行为可以调用该函数来评估包装的表达式。

此外,自定义行为可以传递一个参数对象,此对象上的每个属性都将在此表达式的上下文中作为变量可用:

<template> 
  <person-form save.call="createPerson(person)"></person-form> 
</template> 

在这里,我们可以想象有一个带有 save 属性的 person-form 自定义元素。在这个模板中,我们将 person-formsave 属性绑定到一个包含对模型 createPerson 方法的调用的函数,并向其传递表达式作用域上 person 变量的值。

然后 person-form 视图模型会在某个时刻调用这个函数。传递给这个函数的参数对象将在此表达式的上下文中可用:

this.save({ person: this.somePersonData }); 

在这里,person-form 视图模型调用绑定在 save 属性上的函数,并向其传递一个 person 参数。

显然,这个命令在与原生 HTML 元素一起使用时是没有用的。

当我们覆盖自定义元素的制作时,我们会看到这个命令更具体的例子。

ref

ref 命令可用于将 HTML 元素或组件部分的引用分配给绑定上下文。如果模板或视图模型需要访问模板中使用的 HTML 元素或组件的某部分,这可能很有用。

在以下示例中,我们首先使用 ref 将模型上的 input 元素分配为 nameInput,然后使用字符串插值实时显示这个 inputvalue

<template> 
  <input type="text" ref="nameInput"> 
  <p>Is your name really ${nameInput.value}?</p> 
</template> 

ref命令必须用于一组特定的属性:

  • element.ref="someProperty"(或ref="someProperty"的简写)将在绑定上下文中创建一个名为someProperty的属性,引用一个 HTML 元素

  • 当放在具有some-attribute自定义属性的元素上时,some-attribute.ref="someProperty"将在绑定上下文中创建一个属性,名为someProperty,引用这个自定义属性的视图模型

  • 当放在自定义元素上时,view-model.ref="someProperty"将在绑定上下文中创建一个属性,名为someProperty,引用自定义元素的视图模型

  • 当放在自定义元素上时,view.ref="someProperty"将在绑定上下文中创建一个属性,名为someProperty,引用自定义元素的view实例

  • 当放在自定义元素上时,controller.ref="someProperty"将在绑定上下文中创建一个属性,名为someProperty,引用自定义元素的两个Controller实例

绑定字面量

模板引擎将所有没有命令的属性的值解释为字符串。例如,一个value="12"属性将被解释为一个'12'字符串。

一些组件可能具有需要特定值类型的属性,例如布尔值、数字,甚至是数组或对象。在这种情况下,您应该使用数据绑定强制模板引擎将表达式解释为适当的类型,即使该表达式是一个字面值,且永远不会改变。例如,一个value.bind="12"属性将被解释为数字12

类似地,一个options="{ value: 12 }"属性将被解释为一个'{ value: 12 }'字符串,而options.bind="{ value: 12 }"属性将被解释为一个包含value属性的数字12的对象。

当然,当数据绑定到字面值时,最好使用one-time而不是bind,以减少应用程序的内存占用。

使用内置绑定上下文属性

每个绑定上下文都公开了两个可能在某些场景中有用的属性:

  • $this: 一个自引用的属性。它包含对上下文本身的引用。它可能很有用,例如,将整个上下文传递给一个方法,或者在组合时将其注入到组件中。

  • $parent: 一个引用父级绑定上下文的属性。它可能很有用,例如,在repeat.for属性的作用域内访问被子上下文覆盖的父上下文的一个属性。它可以通过链式调用向上追溯到绑定上下文树更高层。例如,调用$parent.$parent.$parent.name将尝试访问曾祖上下文的name属性。

绑定到 DOM 属性

一些标准 DOM 属性通过 Aurelia 暴露为属性,因此它们可以进行数据绑定。

innerhtml

innerhtml属性可用于数据绑定到元素的innerHTML属性:

<template> 
  <div innerhtml.bind="htmlContent"></div> 
</template> 

在这个例子中,我们可以想象模型的htmlContent属性将包含 HTML 代码,这些代码与divinnerHTML属性数据绑定,将在div内部显示。

然而,这 HTML 不被认为是模板,所以它不会被模板引擎解释。如果它包含绑定表达式或需要指令,例如,它们不会被评估。

显示用户生成的 HTML 是一个众所周知的安全风险,因为它可能包含恶意脚本。强烈建议在向任何用户显示之前对这种 HTML 进行消毒。

aurelia-templating-resources附带一个简单的值转换器(我们将在本章后面看到值转换器是什么),名为sanitizeHTML,它用于这个目的。然而,强烈建议使用更完整的消毒器,如sanitize-html,可以在www.npmjs.com/package/sanitize-html找到。

textcontent

textcontent属性可用于数据绑定到元素的textContent属性:

<template> 
  <div textcontent.bind="text"></div> 
</template> 

在这个例子中,我们可以想象模型的text属性将包含一些文本,这些文本与divtextContent属性数据绑定,将在div内部显示。

innerhtml类似,绑定到textcontent的文本不被认为是模板,所以它不会被模板引擎解释。

如前所述,bind命令试图检测它应该使用哪种绑定模式。因此,如果元素的contenteditable属性设置为true,则textcontent上的bind命令将使用双向绑定:

<template> 
  <div textcontent.bind="text" contenteditable="true"></div> 
</template> 

在这个例子中,模型的text属性将被绑定到divtextContent属性并在div内部显示。另外,由于div的内容是可编辑的,用户对这部分内容所做的任何更改都将反映在模型的text属性上。

style

style属性可用于数据绑定到元素的style属性。它可以绑定到一个字符串或一个对象:

some-component.js 
export class ViewModel { 
  styleAsString = 'font-weight: bold; font-size: 20em;'; 
  styleAsObject = { 
    'font-weight': 'bold', 
    'font-size': '20em' 
  }; 
} 
some-component.html 
<template> 
  <div style.bind="styleAsString"></div> 
  <div style.bind="styleAsObject"></div> 
</template> 

另外,style属性可以使用字符串插值。然而,由于一些技术限制,它不支持 Internet Explorer。为了解决这个问题,并确保应用程序与 IE 兼容,在使用字符串插值时应使用css别名:

<template> 
  <div css="color: ${color}; background-color: ${bgColor};"></div> 
</template> 

在这里,div将把其colorbackground-color样式与模型的colorbgColor属性数据绑定。

scrolltop

scrolltop属性可用于绑定到元素的scrollTop属性。默认支持双向绑定,该属性可用于更改元素的的水平滚动位置,或者将其位置分配给上下文中的属性以便使用。

scrollleft

scrollleft属性可以用来绑定到元素的scrollLeft属性。默认双向绑定,这个属性可以用来更改元素的垂直滚动位置,或者将其位置分配给上下文中的一个属性以便使用。

使用内置行为

核心库aurelia-templating-resources提供了一组标准行为,基于aurelia-templating构建,可以在 Aurelia 模板中使用。

show

show属性根据它所绑定的表达式的值来控制元素的可见性:

<template> 
  <p show.bind="hasError">An error occurred.</p> 
</template> 

在这个例子中,只有当模型的hasError属性为 truthy 时,p元素才会可见。

这个属性通过在文档头部或最近的 ShadowDOM 根中注入 CSS 类,并在元素应该隐藏时添加这个 CSS 类来工作。这个 CSS 类简单地将display属性设置为none

hide

这与show类似,但条件是倒置的:

<template> 
  <p hide.bind="isValid">Form is invalid.</p> 
</template> 

在这个例子中,当模型的isValid属性为 truthy 时,p元素将隐藏。

除了倒置条件之外,这个属性的工作方式与show完全一样,并使用相同的 CSS 类。

if

if属性与show非常相似。主要区别在于,当绑定的表达式评估为false值时,它不是简单地隐藏元素,而是完全将元素从 DOM 中移除。

<template> 
  <p if.bind="hasError">An error occurred.</p> 
</template> 

由于if属性是一个模板控制器,因此可以直接放在嵌套的template元素上,以控制多个元素的可见性:

<template> 
  <h1>Some title</h1> 
  <template if.bind="hasError"> 
    <i class="fa fa-exclamation-triangle"></i> 
    An error occurred. 
  </template> 
</template> 

在这个例子中,当hasErrorfalse时,i元素及其后面的文本将从 DOM 中移除。

实际上,当条件为 falsey 时,它所附加的元素不仅会被从 DOM 中移除,它自己的行为和其子元素的行为也会被解绑。这是一个非常重要的区别,因为它有重大的性能影响。

以下示例中,假设some-component非常大,显示大量数据,有许多绑定,并且非常耗内存和 CPU。

<template> 
  <some-component if.bind="isVisible"></some-component> 
</template> 

如果我们在这里用show替换if,整个组件层次结构的数据绑定仍然存在,即使它不可见也会消耗内存和 CPU。当使用if时,当isVisible变为false,组件将解绑,减少应用程序中的活动绑定数量。

另一方面,这意味着当条件变为 truthy 时,元素及其后代必须重新绑定。在条件经常开关的场景中,使用showhide可能更好。选择ifshow/hide之间的主要问题是平衡性能和用户体验的优先级,并且应该有真实的性能测试支持。

注意

模板控制器是一个属性,它将所作用的元素转换成模板,并控制这个模板的渲染方式。标准的属性ifrepeat是模板控制器。

repeat.for

repeat属性与特殊的for绑定命令一起使用时,可以用来为一系列值重复一个元素:

<template> 
  <ul> 
    <li repeat.for="item of items">${item.title}</li> 
  </ul> 
</template> 

在这个例子中,li元素将被重复并绑定到items数组中的每个项目:

instead of an array, a Set object can also be data-bound too.

作为一个模板控制器,repeat实际上将所作用的元素转换成一个模板。然后为绑定序列中的每个项目渲染这个模板。对于每个项目,将创建一个子绑定上下文,在该上下文中,通过绑定表达式中of关键字左边的名称来使用项目本身。这意味着两件事:您可以随意命名项目变量,而且您可以在项目的上下文中使用它:

<template> 
  <ul> 
    <li repeat.for="person of people"  
        class="${person.isImportant ? 'important' : ''}"> 
      ${person.fullName} 
    </li> 
  </ul> 
</template> 

在这个例子中,li元素将被插入到ul元素中,为people数组中的每个项目。对于每个li元素,将创建一个子上下文,将当前项目作为person属性暴露出来,如果对应的personisImportant属性,那么li上会设置一个important CSS 类。每个li元素将包含其personfullName,作为文本。

此外,由repeat创建的子上下文从周围上下文继承,所以li元素外的任何可用属性在内部都是可用的:

<template> 
  <ul> 
    <li repeat.for="person of people"  
        class="${person === selectedPerson ? 'active' : ''}"> 
      ${person.fullName} 
    </li> 
  </ul> 
</template> 

在这里,根绑定上下文暴露了两个属性:一个people数组和selectedPerson。当每个li元素被渲染时,每个子上下文都可以访问当前的person以及父上下文。这就是li元素对于selectedPerson将具有active CSS 类的原因。

repeat属性默认使用单向绑定,这意味着绑定数组将被观察,对其进行的任何更改都将反映在视图中:

如果向数组中添加一个项目,模板将被渲染成一个额外的视图,并插入到 DOM 中的适当位置。

如果从数组中删除一个项目,相应的视图元素将被从 DOM 中删除。

绑定到地图

repeat属性能够与map对象一起使用,使用稍微不同的语法:

<template> 
  <ul> 
    <li repeat.for="[key, value] of map">${key}: ${value}</li> 
  </ul> 
</template> 

在这里,repeat属性将为map中的每个条目创建一个分别具有keyvalue属性的子上下文,分别与map条目的keyvalue匹配。

重要的是要记住,这种语法只适用于map对象。在前一个示例中,如果map不是Map实例,那么在子绑定上下文中就不会定义keyvalue属性。

重复 n 次

repeat属性还可以在绑定到数值时使用标准语法重复一个模板给定次数:

<template> 
  <ul class="pager"> 
    <li repeat.for="i of pageCount">${i + 1}</li> 
  </ul> 
</template> 

在这个例子中,假设 pageCount 是一个数字,li 元素将被重复多次,次数等于 pageCounti0pageCount - 1 包括在内。

重复模板

如果需要重复的元素由多个没有每个项目单一容器的元素组成,可以在 template 元素上使用 repeat

<template> 
  <div> 
    <template repeat.for="item of items"> 
      <i class="icon"></i> 
      <p>${item}</p> 
    </template> 
  </div> 
</template> 

在这里,渲染后的 DOM 是一个包含交替 ip 元素的 div 元素。

上下文变量

除了当前项目本身,repeat 还在子绑定上下文中添加了其他变量:

  • $index:项目在数组中的索引

  • $first:如果项目是数组的第一个,则为 true;否则为 false

  • $last:如果项目是数组的最后一个,则为 true;否则为 false

  • $even:如果项目的索引是偶数,则为 true;否则为 false

  • $odd:如果项目的索引是奇数,则为 true;否则为 false

with 属性

with 属性通过它绑定的表达式创建一个子绑定上下文。它可以用来重新作用域模板的一部分,以防止访问路径过长。

例如,以下模板没有使用 with,在访问其属性时 person 被多次遍历:

<template> 
  <div> 
    <h1>${person.firstName} ${person.lastName}</h1> 
    <h3>${person.company}</h3> 
  </div> 
</template> 

通过将顶层的 div 元素重新作用域为 person,可以简化对其属性的访问:

<template> 
  <div with.bind="person"> 
    <h1>${firstName} ${lastName}</h1> 
    <h3>${company}</h3> 
  </div> 
</template> 

前面的例子很短,但你可以想象一个更大的模板如何从中受益。

此外,由于 with 创建了一个子上下文,外层作用域中所有可用的变量都将可在内层作用域中访问。

焦点属性

focus 属性可用于将元素的所有权与文档的焦点绑定到表达式。它默认使用双向绑定,这意味着当元素获得或失去 focus 时,它所绑定的变量将被更新。

以下代码片段摘自 samples/chapter-3/binding-focus

<template> 
  <input type="text" focus.bind="hasFocus"> 
</template> 

在 previous example,如果 hasFocustrue,则在渲染时 input 将会获得焦点。当 hasFocus 变为 false 值时,input 将会失去 focus。此外,如果用户将 focus 给予 inputhasFocus 将被设置为 true。类似地,如果用户将焦点从 input 移开,hasFocus 将被设置为 false

组合元素

组合是将组件实例化并插入视图中的动作。aurelia-templating-resources 库导出一个 compose 元素,允许我们在视图中动态组合组件。

注意

以下各节中的代码片段摘自 samples/chapter-3/composition。在阅读本节时,你可以并行运行示例应用程序,这样你就可以查看组合的实时示例。

渲染视图模型

组件可以通过引用其视图模型的 JS 文件路径来组合:

<template> 
  <compose view-model="some-component"></compose> 
</template> 

在这里,当渲染时,compose 元素将加载 some-component 的视图模型,实例化它,定位其模板,渲染视图,并将其插入到 DOM 中。

当然,view-model属性可以绑定或使用字符串插值:

<template> 
  <compose view-model="widgets/${currentWidgetType}"></compose> 
</template> 

在这个例子中,compose元素将根据当前绑定上下文中的currentWidgetType属性的值,显示位于widgets目录中的组件。当然,这意味着当currentWidgetType发生变化时,compose 将交换组件(除非使用了一次性绑定)。

此外,view-model属性可以绑定到视图模型的实例:

src/some-component.js

import {AnotherComponent} from 'another-component'; 

export class SomeComponent { 
  constructor() { 
    this.anotherComponent = new AnotherComponent(); 
  } 
} 

在这里,一个组件导入并实例化了另一个组件的视图模型。在其模板中,compose元素可以直接绑定到AnotherComponent的实例:

src/some-component.html

<template> 
  <compose view-model.bind="anotherComponent"></compose> 
</template> 

当然,这意味着,如果anotherComponent被分配了一个新值,compose元素将相应地反应,并用新的一个替换掉之前的组件视图。

传递激活数据

当渲染组件时,组合引擎将尝试调用组件上存在的activate回调方法。与路由器的屏幕激活生命周期方法类似,这个方法可以被组件实现,以便在它们被渲染时可以行动。它还可以用来将激活数据注入组件。

compose元素也支持model属性。如果有的话,这个属性的值将被传递给组件的activate回调方法。

让我们想象一下以下的组件:

src/some-component.js

export class SomeComponent { 
  activate(data) { 
    this.activationData = data || 'none'; 
  } 
} 
src/some-component.html 
<template> 
  <p>Activation data: ${activationData}</p> 
</template> 

当没有任何model属性时,这个组件将显示<p>Activation data: none</p>。然而,当像这样组成时,它会显示<p>Activation data: Some parameter</p>

<template> 
  <compose view-model="some-component" model="Some parameter"></compose> 
</template> 

当然,model可以使用字符串插值,也可以进行数据绑定,因此可以将复杂对象传递给组件的activate方法。

当与未实现activate方法的组件一起使用时,model属性的值将被直接忽略。

渲染模板

compose元素还可以简单地渲染一个模板,使用当前的绑定上下文:

<template> 
  <compose view="some-template.html"></compose> 
</template> 

在这里,some-template.html将使用周围的绑定上下文渲染成一个视图。这意味着compose元素周围的任何变量也将对some-template.html可用。

当与view-model属性一起使用时,view属性将覆盖组件的默认模板。它可以在使用不同模板时复用视图模型的行为。

值转换器

在数据绑定的世界里,经常需要将数据在视图模型和视图之间进行转换,或者在双向绑定更新模型时将用户输入转换回来。

实现这种方法的一种方式是在视图模型中使用计算属性,以执行另一个属性值的来回转换。这种解决方案的缺点是,它不能跨视图模型复用。

在 Aurelia 中,值转换器解决了这个需求。值转换器是一个可以插入绑定表达式的对象。每次绑定需要评估表达式以渲染其结果,或者在双向绑定情况下更新模型时,转换器都作为拦截器,可以转换值。

使用值转换器

值转换器是视图资源。和 Aurelia 中的所有视图资源一样,为了在模板中使用,它必须被加载,要么通过一个configure函数全局加载,要么通过一个require元素局部加载。

注意

如果你不记得如何加载资源,请参阅模板基础部分。

在模板中,可以使用管道(|)操作符将值转换器包裹在一个数据绑定表达式周围:

<template> 
  <div innerhtml.bind="htmlContent | sanitizeHTML"></div> 
</template> 

在这个示例中,我们使用了内置的sanitizeHTML值转换器来绑定innerhtml属性。这个值转换器会在绑定过程中被管道使用,并将会清除绑定值中的任何潜在危险元素。

值转换器实际上并不改变它们操作的绑定上下文值。它们仅仅作为拦截器,为绑定提供了一个用于渲染的替代值。

传递一个参数

值转换器可以接受参数,在这种情况下,它们必须在绑定表达式中使用冒号(:)分隔符指定。

让我们想象一个名为truncate的值转换器,它对字符串值起作用,同时期望一个length参数。在评估期间,它将提供的值截断到提供的长度(如果更长),并返回结果。这个转换器将如何使用:

<template> 
  <h1>${title | truncate:20}</h1> 
</template> 

在这里,如果title超过 20 个字符,它将被截断到 20 个字符。否则,它将保持不变。

传递多个参数

可以向值转换器传递多个参数。只需继续使用冒号(:)分隔符。例如,如果truncate可以接受第二个参数,即在截断后的字符串后添加省略号,它将像这样传递:

${title | truncate:20:'...'} 

传递上下文变量作为参数

绑定上下文中的变量也可以作为参数使用,在这种情况下,当这些变量中的任何一个发生变化时,绑定表达式将会重新评估。例如:

some-component.js

export class ViewModel { 
  title = 'Some title'; 
  maxTitleLength = 2; 
} 
some-component.html 
<template> 
  <h1>${title | truncate:maxTitleLength}</h1> 
</template> 

在这里,字符串插值的价值取决于视图模型的titlemaxTitleLength属性。每当它们中的一个发生变化时,表达式将会重新评估,truncate转换器将会重新执行,视图将会更新。

串联

值转换器可以被串联。在这种情况下,值通过转换器链进行管道,当评估表达式值时从左到右,当更新模型时从右到左:

<template> 
  <h1>${title | truncate:20:'...' | capitalize}</h1> 
</template> 

在这个示例中,title首先会被截断,然后首字母大写后渲染。

实现一个值转换器

值转换器是一个必须实现至少以下方法之一的类:

  • toView(value: any [, ...args]): any:在评估绑定表达式后、渲染结果之前调用。value参数是绑定表达式的值。该方法必须返回转换后的值,它将传递给下一个转换器或渲染到视图中。

  • fromView(value: any [, ...args]): any:当更新模型的绑定目标值时调用,在将值分配给模型之前。value参数是绑定目标的值。该方法必须返回转换后的值,它将传递给下一个转换器或分配给模型。

如果值转换器使用参数,它们将以附加参数的形式传递给方法。例如,让我们想象一下值转换器的以下使用方式:

${text | truncate:20:'...'} 

在这种情况下,truncate值转换器的toView方法预计会像这样:

export TruncateValueConverter { 
  toView(value, length, ellipsis = '...') { 
    value = value || ''; 
    return value.length > length ? value.substring(0, length) + ellipsis : value; 
  } 
} 

在这里,truncate值转换器的toView方法除了它应用的value之外,还期望有一个length参数。它还接受一个名为ellipsis的第三个参数,有一个默认值。如果提供的value比提供的length长,该方法将截断它,附上ellipsis,然后返回这个新值。如果value不太长,它简单地返回它不变。

默认情况下,Aurelia 认为任何以ValueConverter结尾的作为资源加载的类都是一个值转换器。值转换器的名称将是类名,不包含ValueConverter后缀,驼峰命名。例如,一个名为OrderByValueConverter的类将作为orderBy值转换器提供给模板。

然而,当创建一个将包含在可重用插件或库中的转换器时,你不应依赖这个约定。在这种情况下,类应该用valueConverter装饰器装饰:

import {valueConverter} from 'aurelia-framework'; 

@valueConverter('truncate') 
export Truncate { 
  // Omitted snippet... 
} 

这样,即使你的插件用户改变了默认的命名约定,你的类仍然会被 Aurelia 识别为值转换器。

绑定行为

绑定行为是视图资源,与值转换器相似,它们应用于表达式。然而,它们拦截绑定操作本身并访问整个绑定说明,因此可以修改它。这开辟了许多可能性。

使用绑定行为

要为一个绑定表达式添加绑定行为,它必须紧跟在表达式的末尾,使用&分隔符:

${title & oneTime} 

当然,就像值转换器一样,绑定行为可以链接,在这种情况下,它们将从左到右执行:

${title & oneWay & throttle} 

如果表达式还使用值转换器,绑定行为必须放在它们之后:

${title | toLower | capitalize & oneWay & throttle} 

传递参数

就像值转换器一样,绑定行为也可以传递参数,使用相同的语法:

${title & throttle:500} 

行为及其参数必须用冒号(:)分隔,参数之间也必须以同样的方式分隔:

${title & someBehavior:p1:p2} 

内置绑定行为

aurelia-templating-resources库附带了许多绑定行为。让我们去发现它们。

注意

以下部分中的代码片段摘自samples/chapter-3/binding-behaviors

oneTime

oneTime行为使绑定变为单向 only。它可以用在字符串插值表达式上:

<template> 
  <em>${quote & oneTime}</em> 
</template> 

在这里,视图模型的quote属性不会被观察,所以如果它发生变化,文本不会被更新。

此外,Aurelia 还附带了其他绑定模式的绑定行为:oneWaytwoWay。它们可以像oneTime一样使用。

节流

throttle绑定行为可用于限制视图模型更新的速率对于双向绑定或视图更新的速率对于单向绑定。换句话说,一个被 500 毫秒节流的绑定将至少在两个更新通知之间等待 500 毫秒。

<template> 
  ${title & throttle} 
  <input value.bind="value & throttle"> 
</template> 

在这里,我们看到了这两个场景的例子。第一个throttle应用于字符串插值表达式,默认是单向的,当视图模型的title属性发生变化时,将节流视图中的文本更新。第二个应用于input元素的value属性的绑定,默认是双向的,当inputvalue发生变化时,将节流视图模型的value属性的更新。

throttle行为可以接受一个参数,表示更新之间的时差,以毫秒表示。然而,这个参数可以省略,默认使用 200 毫秒。

<template> 
  ${title & throttle:800} 
  <input value.bind="value & throttle:800"> 
</template> 

在这里,我们有一个与之前相同的示例,但是绑定将被 800 毫秒节流。

事件也可以被节流。无论它是在trigger还是delegate绑定命令中使用,将事件分发到视图模型的节流将相应地节流:

<template> 
  <div mousemove.delegate="position = $event & throttle:800"> 
    The mouse was last moved to (${position.clientX}, ${position.clientY}). 
  </div> 
</template> 

在这里,div元素的mousemove事件的处理程序将事件对象分配给视图模型的position属性。然而,这个处理程序将被节流,所以position将每 800 毫秒更新一次。

您可以在samples/chapter-3/binding-behaviors中看到throttle行为的一些示例。

debounce

debounce绑定行为也是一种速率限制行为。它确保在给定延迟过去且没有更改的情况下不发送任何更新。

一个常见的用例是一个搜索输入,它会自动触发搜索 API 的调用。在用户每次输入后调用这样的 API 将是效率低下且消耗资源的。最好在用户停止输入后等待一段时间再调用搜索 API。这可以通过使用debounce来实现:

<template> 
  <input value.bind="searchTerms & debounce"> 
</template> 

在这个例子中,视图模型将观察searchTerms属性,并在每次更改时触发搜索。debounce行为将确保在用户停止输入 200 毫秒后searchTerms才得到更新。

这意味着,当应用于双向绑定时,debounce限制了视图模型的更新速率。然而,当应用于单向绑定时,它限制了视图的更新速率:

<template> 
  <input value.bind="text"> 
  ${text & debounce:500} 
</template> 

在这里,debounce应用于字符串插值表达式,所以只有当用户在输入中停止打字 500 毫秒后,显示的文本才会更新。这里的区别很重要。text属性仍然会实时更新。只有字符串插值绑定会被延迟。

就像throttle一样,debounce也可以应用于事件,使用触发器或委托绑定命令:

<template> 
  <div mousemove.delegate="position = $event & debounce:800"> 
    The mouse was last moved to (${position.clientX}, ${position.clientY}). 
  </div> 
</template> 

在这里,div元素的mousemove事件的处理程序将事件对象分配给视图模型的position属性。然而,这个处理程序将被防抖,所以只有在鼠标在div上停止移动 800 毫秒后,position才会更新。

你可能会注意到,在之前的例子中,throttledebounce都可以接受延迟,以毫秒表示,作为参数。省略时,延迟也默认为 200 毫秒。

updateTrigger

updateTrigger绑定行为用于改变触发视图模型更新的事件。这意味着它只能与双向绑定一起使用,只能用于支持双向绑定的元素的属性,如inputvalueselectvalue或具有contenteditable="true"divtextcontent属性。

使用时,它期望事件名称作为参数,至少需要一个:

<template> 
  <input value.bind="title & updateTrigger:'change':'input' "> 
</template> 

在这里,视图模型的title属性将在每次input触发changeinput事件时更新。

实际上,changeinput事件在 Aurelia 中是默认的触发器。除了这两个,blurkeyuppaste事件也可以用作触发器。

signal

信号绑定行为允许程序化地触发绑定更新。这对于不可观察的绑定值或在特定时间间隔内必须刷新时特别有用。

让我们想象一个名为timeInterval的值转换器,它接收一个Date对象,计算输入日期和当前日期之间的时间间隔,并将这个时间间隔输出为用户友好的字符串,如a minute agoin 2 hours3 years ago

由于结果取决于当前日期和时间,如果不定期刷新,它将很快过时。可以使用signal行为来实现这一点:

src/some-component.html

<template> 
  Last updated ${lastUpdatedAt | timeInterval & signal:'now'} 
</template> 

在这个模板中,lastUpdatedAt使用timeInterval值转换器显示,其绑定被一个名为nowsignal装饰。

src/some-component.js

import {inject} from 'aurelia-framework'; 
import {BindingSignaler} from 'aurelia-templating-resources'; 

@inject(BindingSignaler) 
export class SomeComponent { 
  constructor(signaler) { 
    this.signaler = signaler; 
  } 

  activate() { 
    this.handle = setInterval(() => this.signaler.signal('now'), 5000); 
  } 

  deactivate() { 
    clearInterval(this.handle); 
  } 
} 

在视图模型中,在注入一个BindingSignaler实例并将其存储在实例变量中后,activate方法创建一个间隔循环,每 5 秒触发一个名为now的信号。每次触发信号时,模板中的字符串插值绑定都将更新,使得显示的时间间隔最多比当前时间晚 5 秒。当然,为了防止内存泄漏,间隔处理程序存储在实例变量中,并在组件停用时使用clearInterval函数销毁。

可以将多个信号名称作为参数传递给signal。在这种情况下,每次触发任何一个信号时,绑定都会刷新:

<template> 
  <a href.bind="url & signal:'signal-1':'signal-2' ">Go</a> 
</template> 

此外,它只能用于字符串插值和属性绑定;信号一个triggercallref表达式是没有意义的。

计算属性

高效的数据绑定是一个复杂的问题。Aurelia 的数据绑定库是适应性强的,并使用多种技术尽可能高效地观察视图模型和 DOM 元素。它在可能的情况下利用 DOM 事件和 Reflect API,在没有其他策略适用时才回退到脏检查。

注意

脏检查是一种使用超时循环反复评估表达式的观察机制,检查其值自上次评估以来是否发生变化,如果发生变化,则更新相关绑定。

计算属性是脏检查经常使用的一种场景。看这个例子:

export class ViewModel { 
  get fullName() { 
    return `${this.firstName} ${this.lastName}`;
  } 
} 

当对fullName应用绑定时,Aurelia 无法知道其值是如何计算的,必须依赖脏检查来检测变化。在这个例子中,fullName的获取器评估速度很快,所以使用脏检查是绝对可以的。

然而,一些计算属性可能会最终执行重工作:例如从一个大型数组中搜索或聚合数据。在这种情况下,依赖脏检查意味着属性将每秒评估几次,这可能会使浏览器过载。

计算来自

aurelia-binding库导出一个computedFrom装饰器,可以用来解决这个问题。在装饰一个计算属性时,它通知绑定系统属性依赖于哪些依赖项来计算其结果。

import {computedFrom} from 'aurelia-binding'; 

const items = [/* a static, huge list of items */]; 
export class ViewModel { 
  @computedFrom('searchTerm') 
  get matchCount() { 
    return items.filter(i => i.value.includes(this.searchTerm)).size; 
  } 
} 

在这里,为了观察matchCount,绑定系统会观察searchTerm。只有当它发生变化时,它才会重新评估matchCount。这比每秒多次评估属性的结果以检查其是否已更改要高效得多。

computedFrom装饰器接受访问路径作为依赖项,这些路径相对于它所在的类的实例是相对的:

import {computedFrom} from 'aurelia-binding'; 

const items = [/* a static, huge list of items */]; 
export class ViewModel { 
  model = { 
    searchTerm: '...' 
  }; 

  @computedFrom('model.searchTerm') 
  get matchCount() { 
    return items.filter(i => i.value.includes(this.searchTerm)).size; 
  } 
} 

在这里,我们可以看到matchCount依赖于作为视图模型model属性存储的对象的searchTerm属性。

当然,它期望至少有一个依赖项作为参数传递。

computedFrom装饰器观察属性或路径。它无法观察数组的内容。这意味着以下示例将无法工作:

import {computedFrom} from 'aurelia-binding'; 

export class ViewModel { 
  items = [/* a huge list of items, that can change during the lifetime of the component */]; 
  searchTerms = '...'; 

  @computedFrom('items', 'searchTerms') 
  get matchCount() { 
    return this.items.filter(i => i.value.includes(this.searchTerm)).size; 
  } 
} 

在这里,如果items得到一个项目的添加或删除,computedFrom不会检测到它,也不会重新评估matchCount。它能检测到的唯一情况是一个全新的数组是否被分配给items属性。

computedFrom装饰器在非常特定的情况下很有用。它不应该替代值转换器,因为那些是转换数据的首选方式。

从端点获取数据

Fetch API

Fetch API 已被设计用于获取资源,包括通过网络。在撰写本文时,尽管其规范非常有前途,但仍未获得批准。然而,许多现代浏览器如 Chrome、Edge 和 Firefox 已经支持它。对于其他浏览器,需要一个 polyfill。

Fetch API 依赖于请求和响应的概念。这允许拦截管道在发送之前修改请求和接收时修改响应。它使得处理诸如认证和 CORS 之类的事情变得更容易。

在以下章节中,术语RequestResponse指的是 Fetch API 的类。Mozilla 开发者网络有关于这个 API 的详尽文档:developer.mozilla.org/en-US/docs/Web/API/Fetch_API

使用 Fetch 客户端

Aurelia 的 Fetch 客户端是一个围绕原生或 polyfilled Fetch API 的包装器。它支持默认请求配置,以及可插拔的拦截机制。它由一个名为HttpClient的类组成。这个类暴露了通过 HTTP 获取资源的方法。

配置

HttpClient类有一个configure方法。它期望的参数是一个回调函数,该函数接收一个配置对象,该对象暴露了可以用来配置客户端的方法:

  • withBaseUrl(baseUrl: string):这为客户端设置了基础 URL。所有相对 URL 的请求都将相对于这个 URL 进行。

  • withDefaults(defaults: RequestInit):这设置了传递给Request构造函数的默认属性。

  • withInterceptor(interceptor: Interceptor):这为拦截管道添加了一个Interceptor对象。

  • rejectErrorReponses()fetch方法返回一个Response对象的Promise。这个Promise只在发生网络错误或类似情况阻止请求完成时被拒绝。否则,无论服务器可能回答什么 HTTP 状态,Promise都会成功解决为Response。这个方法添加了一个拦截器,当响应的状态不是成功代码时拒绝Promises。HTTP 成功代码在200299之间。

  • useStandardConfiguration():标准配置包括same-origin凭据设置(有关此设置的更多信息,请参见官方 Fetch API 文档)和拒绝错误响应(请参阅前面的rejectErrorResponses方法)。

除了一个回调配置函数外,configure方法还可以直接传递一个RequestInit对象。在这种情况下,这个RequestInit对象将被用作所有请求的默认属性。

这意味着,如果我们有一个存储在defaultProperties变量中的RequestInit对象,下面的两行将执行完全相同的事情:

client.configure(defaultProperties); 
client.configure(config => { config.withDefaults(defaultProperties); }); 

RequestInit对象对应于 Fetch API 的Request构造函数期望的第二个参数。它用于指定Request的各种属性。最常用的属性有:

  • method:HTTP 方法,例如 GET、POST

  • headers:包含请求的 HTTP 头的对象

  • body:请求体,例如一个BlobBufferSourceFormDataURLSearchParamsUSVString实例

    注意

    我将让你查看官方文档以获取关于可用Request属性的更多详细信息。

正如你所看到的,一个RequestInit对象可以用来指定一个 HTTP 方法和请求体,因此我们将能够执行 POST 和 PUT 请求来创建和更新person对象。我们将在下一章看到这个例子,那时我们开始构建表单。

一个常见的陷阱

正如我们在第二章中看到的,布局、菜单和熟悉,DI 容器默认自动将所有类作为应用程序单例注册。这意味着,如果您的应用程序包含多个服务,它们依赖于应该是独立的HttpClient实例,并且各自配置其相应的HttpClient不同,您会遇到奇怪的问题。

让我们想象一下以下两个服务:

import {inject} from 'aurelia-framework'; 
import {HttpClient} from 'aurelia-fetch-client'; 

@inject(HttpClient) 
export class ContactService { 
  constructor(http) { 
    this.http = http.configure(c => c.withBaseUrl('api/contacts')); 
  } 
} 

@inject(HttpClient) 
export class AddressService { 
  constructor(http) { 
    this.http = http.configure(c => c.withBaseUrl('api/addresses')); 
  } 
} 

在这里,我们有两个服务,分别名为ContactServiceAddressService。它们在其构造函数中都作为HttpClient实例注入,并使用不同的基础 URL 配置自己的实例。

默认情况下,相同的HttpClient实例将被注入到两个服务中,因为 DI 容器默认认为它是应用程序的单例。您看到问题了吗?第二个服务创建后,将覆盖第一个服务的基础 URL,所以第一个服务最终将尝试对错误的 URL 执行 HTTP 调用。

这种场景有很多可能的解决方案。您可以使用NewInstance解析器在每个服务中强制注入一个新的实例:

import {inject, NewInstance} from 'aurelia-framework'; 
import {HttpClient} from 'aurelia-fetch-client'; 

@inject(NewInstance.of(HttpClient)) 
export class ContactService { 
  constructor(http) { 
    this.http = http.configure(c => c.withBaseUrl('api/contacts')); 
  } 
} 

@inject(NewInstance.of(HttpClient)) 
export class AddressService { 
  constructor(http) { 
    this.http = http.configure(c => c.withBaseUrl('api/addresses')); 
  } 
} 

另一个解决方案可能是将HttpClient类作为您的应用程序主要configure方法中的瞬态注册:

import {HttpClient} from 'aurelia-fetch-client'; 

export function configure(config) { 
  config.container.registerTransient(HttpClient); 
  //Omitted snippet... 
} 

拦截器

拦截器是在 HTTP 调用过程中的不同时间截取请求和响应的对象。一个Interceptor对象可以实现以下任意回调方法:

  • request(request: Request): Request|Response|Promise<Request|Response>:在请求被发送之前调用。它可以修改请求,或者返回一个新的请求。它还可以返回一个响应来短路剩余的过程。在这种情况下,下一个拦截器的request方法将被跳过,并且将使用响应,好像请求已经被发送一样。支持Promise

  • requestError(error: any): Request|Response|Promise<Request|Response>:当一个拦截器的request方法抛出错误时调用。它可能重新抛出错误以传播它,或者返回一个新的请求或响应以从失败中恢复。支持Promise

  • response(response: Response, request?: Request): Response|Promise<Response>:在响应被接收之后调用。它可以修改响应,或者返回一个新的响应。支持Promise

  • responseError(error: any, request?: Request): Response|Promise<Response>:当一个拦截器的response方法抛出错误时调用。它可能重新抛出错误以传播它,或者返回一个新的响应以从失败中恢复。支持Promise

例如,我们可以定义以下的拦截器类:

export class BearerAuthorizationInterceptor { 
  constructor(token) { 
    this.token = token; 
  } 

  request(request) { 
    request.headers.set('Authorization', `Bearer ${this.token}`); 
  } 
} 

这个拦截器期望一个Bearer认证令牌在它的构造函数中被传递。当添加到一个 Fetch 客户端时,它会在每个请求中添加一个Authorization头,允许一个已经认证的用户访问一个受保护的端点。

我们的应用程序

至此,我们已经涵盖了我们将需要的应用程序的下一步:查询我们的 HTTP 端点、显示联系人列表以及允许导航到给定联系人的详细信息。

为了使我们的应用程序更具吸引力,我们将利用 Font Awesome,一个提供可缩放矢量图标的 CSS 库。让我们首先安装它:

> npm install font-awesome --save

接下来,我们需要将其包含在我们的应用程序中:

index.html

<head>  
  <!-- Omitted snippet --> 
  <link href="node_modules/font-awesome/css/font-awesome.min.css" rel="stylesheet"> 
</head> 

我们的联系人网关

我们本可以在我们的视图模型中直接进行 HTTP 调用。然而,这样做会模糊责任之间的界限。视图模型除了要负责数据展示(其主要任务)之外,还要负责调用、解析请求、处理错误以及最终缓存响应。

相反,我们将创建一个联系人网关类,它将负责从端点获取数据,将是可重用的,并且能够独立发展:

src/contact-gateway.js

import {inject} from 'aurelia-framework'; 
import {HttpClient} from 'aurelia-fetch-client'; 
import {Contact} from './models'; 
import environment from './environment'; 

@inject(HttpClient) 
export class ContactGateway { 

  constructor(httpClient) { 
    this.httpClient = httpClient.configure(config => { 
      config 
        .useStandardConfiguration() 
        .withBaseUrl(environment.contactsUrl); 
    }); 
  } 

  getAll() {    
    return this.httpClient.fetch('contacts') 
      .then(response => response.json()) 
      .then(dto => dto.map(Contact.fromObject)); 
  } 

  getById(id) { 
    return this.httpClient.fetch(`contacts/${id}`) 
      .then(response => response.json()) 
      .then(Contact.fromObject); 
  } 
} 

在这里,我们首先声明一个构造函数期望一个HttpClient实例的类,这是 Aurelia 的 Fetch 客户端。在这个构造函数中,我们配置客户端,使其使用标准配置,我们在配置部分看到了这个配置,并使用environment对象的contactsUrl属性作为其基本 URL。这意味着所有带有相对 URL 的请求都将相对于这个 URL 进行。

我们的 contact gateway 暴露了两个方法:一个获取所有联系人,第二个通过其 ID 获取单个联系人。它们通过调用客户端的fetch方法来工作,该方法默认向提供的 URL 发送 GET 请求。在这里,由于 URL 是相对路径,它们将相对于在构造函数中配置的基 URL 进行转换。

当 HTTP 请求完成后,fetch返回的Promise被解决,然后在解决后的Response对象上调用json方法来反序列化响应体为 JSON。json方法也返回一个Promise,所以当这个第二个Promise解决时,我们将未类型的数据传输对象转换为稍后我们将编写的Contact类的实例。

这意味着,基于端点返回的内容,getAll返回一个Contact对象的数组Promise,而getById返回一个单个Contact对象的Promise

先决条件

为了让这一切正常工作,我们需要做两件事。首先,我们将安装 Fetch 客户端,通过在移动到应用程序目录后,在控制台中运行以下命令:

npm install aurelia-fetch-client --save

注意

本书中编写的所有代码都在 Google Chrome 上运行过。如果你使用其他浏览器,你可能需要为各种 API(如 Fetch)安装 polyfill。

此外,你还需要让 Aurelia 打包器知道这个库。在aurelia_project/aurelia.json中,在build下的bundles中,在名为vendor-bundle.js的包定义中,将aurelia-fetch-client添加到dependencies数组中:

aurelia_project/aurelia.json

{ 
  //Omitted snippet... 
  "build": { 
    //Omitted snippet ... 
    "bundles": { 
      //Omitted snippet ... 
      { 
        "name": "vendor-bundle.js", 
        //Omitted snippet ... 
        "dependencies": [ 
          "aurelia-fetch-client", 
          //Omitted snippet ... 
        ] 
      } 
    } 
  } 
} 

这是为了让aurelia-fetch-client库与其他库一起被捆绑,以便我们的应用程序可以使用它。

最后,contactsUrl属性在environment配置对象中默认不存在。我们需要添加它:

aurelia_project/environments/dev.js

export default { 
  debug: true, 
  testing: true, 
  contactsUrl: 'http://127.0.0.1:8000/', 
}; 

在这里,我们将默认在哪个 URL 上运行我们的端点的 URL 分配给contactsUrl属性。在现实世界中,我们还会将其设置在stage.jsprod.js中,因此我们的端点为所有环境配置。我将留下这个作为读者的练习。

显示联系人

现在让我们在我们的空contact-list组件中添加一些代码。我们将利用我们新的ContactGateway类来获取联系人列表并显示它。

src/contact-list.js

import {inject} from 'aurelia-framework'; 
import {ContactGateway} from './contact-gateway'; 

@inject(ContactGateway) 
export class ContactList { 

  contacts = []; 

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

  activate() { 
    return this.contactGateway.getAll() 
      .then(contacts => { 
        this.contacts.splice(0); 
        this.contacts.push.apply(this.contacts, contacts); 
      }); 
  } 
} 

在这里,我们首先在contact-list组件的视图模型中注入了一个ContactGateway实例。在activate方法中,我们使用getAll获取联系人,一旦Promise解决,我们确保清除联系人数组;然后我们将加载的联系人添加到其中,以便它们可供模板使用。

在这种情况下,更改数组被视为比覆盖整个contacts属性更好的做法,因为视图中的repeat.for绑定观察数组实例的更改,但不观察属性本身,所以如果contacts在视图渲染后覆盖,视图不会刷新。

您可能注意到了getAll返回的Promise是如何在activate中返回的。这使得对 HTTP 端点的调用作为屏幕激活生命周期的一部分运行。如果没有这

我们还需要定义Contact类。它在列表和详细视图中会有用的计算属性:

src/models.js

export class Contact { 
  static fromObject(src) { 
    return Object.assign(new Contact(), src); 
  } 

  get isPerson() { 
    return this.firstName || this.lastName; 
  } 

  get fullName() { 
    const fullName = this.isPerson  
      ? `${this.firstName} ${this.lastName}`  
      : this.company; 
    return fullName || ''; 
  } 
} 

这个类有一个名为fromObject的静态方法,它作为一个工厂方法。它期望一个源对象作为其参数,创建一个Contact的新实例,并将源对象的所有属性分配给它。此外,它定义了一个isPerson属性,如果联系人至少有一个名字或姓氏,则返回true,并在模板中用来区分人和公司。它还定义了一个fullName属性,如果联系人代表一个人,它将返回名字和姓氏,如果联系人是公司,它将返回公司名称。

现在,唯一缺少的是contact-list模板:

src/contact-list.html

<template> 
  <section class="container"> 
    <h1>Contacts</h1> 
    <ul> 
      <li repeat.for="contact of contacts">${contact.fullName}</li> 
    </ul> 
  </section> 
</template> 

这里我们简单地将联系人渲染为无序列表。

你现在可以测试它:

> au run --watch

注意

不要忘记通过在api目录中运行npm start来启动 HTTP 端点。当然,如果你之前没有运行过,你首先需要运行npm install来安装其依赖项。

如果你没有省略任何步骤,当你导航到 http://localhost:9000/时,你应该看到联系人列表出现。

对联系人进行分组和排序

目前,联系人列表很无聊。联系人以子弹列表的形式显示,甚至没有排序。我们可以通过按联系人名字的第一个字母分组并按字母顺序对这些组进行排序,大大提高这个屏幕的可使用性。这将使浏览列表和查找联系人变得更容易。

要实现这一点,我们有两个选择:我们可以在视图模型中先分组然后排序联系人,或者我们可以将此逻辑隔离在值转换器中,以便我们以后可以重新使用它们。我们将选择后者,因为它符合单一责任原则,并使我们的代码更加简洁。

创建 orderBy 值转换器

我们的orderBy值转换器将应用于一个数组,并期望其第一个参数为用于排序项目的属性名称。

我们的值转换器还可以接受一个可选的第二个参数,这将是一个排序方向,作为一个'asc''desc'字符串。省略时,排序顺序将升序。

src/resources/value-converters/order-by.js

export class OrderByValueConverter { 
  toView(array, property, direction = 'asc') { 
    array = array.slice(0); 
    const directionFactor = direction == 'desc' ? -1 : 1;  
    array.sort((item1, item2) => { 
      const value1 = item1[property]; 
      const value2 = item2[property]; 
      if (value1 > value2) { 
        return directionFactor; 
      } else if (value1 < value2) { 
        return -directionFactor; 
      } else { 
        return 0; 
      } 
    }); 
    return array; 
  } 
} 

注意

一个重要的部分是在调用sort之前调用slice。它确保对数组进行复制,因为sort方法会修改它调用的数组。如果没有slice调用,原始数组将被修改。这是不好的;值转换器绝不应该修改其源值。这不是预期行为,因此这样的转换器会让使用它的开发者感到非常惊讶。

在设计值转换器时,你确实应该密切关注以避免此类副作用。

为了使这个新的转换器对模板可用,而不是每次需要时都手动require它,让我们在resources特性中加载它:

src/resources/index.js

export function configure(config) { 
  config.globalResources([ 
    './value-converters/order-by', 
  ]); 
} 

你可以通过将contact of contacts | orderBy:'fullName'repeat.for指令更改为contact-list模板中的新firstLetter属性来测试它。

创建 groupBy 值转换器

接下来,我们的groupBy值转换器将以几乎相同的方式工作;它将应用于数组,并期望一个参数,这个参数将是用于分组项目的属性的名称。它将返回一个对象数组,每个对象都包含两个属性:用作key的分组值和作为items数组的分组项目:

src/resources/value-converters/group-by.js

export class GroupByValueConverter { 
  toView(array, property) { 
    const groups = new Map(); 
    for (let item of array) { 
      let key = item[property]; 
      let group = groups.get(key); 
      if (!group) { 
        group = { key, items: [] }; 
        groups.set(key, group); 
      } 
      group.items.push(item); 
    } 
    return Array.from(groups.values()); 
  } 
} 

这个值转换器还需要在resources特性的configure函数中加载。这个你自己来吧。

更新联系人列表

为了利用我们的值转换器,我们首先需要在Contact类中添加一个新属性:

src/models.js

//Omitted snippet... 
export class Contact { 
  //Omitted snippet... 
  get firstLetter() { 
    const name = this.lastName || this.firstName || this.company; 
    return name ? name[0].toUpperCase() : '?'; 
  } 
} 

这个新的firstLetter属性取联系人的姓氏、名字或公司名字的第一个字母。它将用于将联系人分组在一起。

接下来,让我们丢弃我们之前的联系人列表模板,重新开始:

src/contact-list.html

<template> 
  <section class="container"> 
    <h1>Contacts</h1> 
    <div repeat.for="group of contacts|groupBy:'firstLetter'|orderBy:'key'" 
         class="panel panel-default"> 
      <div class="panel-heading">${group.key}</div> 
      <ul class="list-group"> 
        <li repeat.for="contact of group.items|orderBy:'fullName'"    
            class="list-group-item"> 
          <a route-href="route: contact-details;  
                         params.bind: { id: contact.id }"> 
            <span if.bind="contact.isPerson"> 
              ${contact.firstName} <strong>${contact.lastName}</strong> 
            </span> 
            <span if.bind="!contact.isPerson"> 
              <strong>${contact.company}</strong> 
            </span> 
          </a> 
        </li> 
      </ul> 
    </div> 
  </section> 
</template> 

在这里,我们首先按照它们的firstLetter属性的值将联系人分组。groupBy转换器返回一个组对象的数组,然后根据它们的key属性进行排序并重复到面板上。对于每个组,以该组分的字母显示在面板标题中,然后按fullName属性对组中的联系人进行排序并显示在列表组中。对于每个联系人,都会渲染一个到其详细视图的链接,显示其人员或公司名称。

筛选联系人

即使联系人被分组和排序,找到特定的联系人可能仍然很麻烦,特别是如果用户不知道联系人的全名。我们添加一个搜索框,用于实时过滤联系人列表。

我们首先需要创建另一个值转换器来过滤联系人数组:

src/resources/value-converters/filter-by.js

export class FilterByValueConverter { 
  toView(array, value, ...properties) { 
    value = (value || '').trim().toLowerCase(); 
    if (!value) { 
      return array; 
    } 
    return array.filter(item =>  
      properties.some(property =>  
        (item[property] || '').toLowerCase().includes(value))); 
  } 
} 

我们的filterBy值转换器期望一个第一个参数,这是要搜索的值。此外,它考虑以下参数是要搜索的属性。任何不在指定属性中包含搜索值的联系人将被过滤出结果。

注意

不要忘记在resources特性的configure函数中加载filterBy值转换器。

接下来,我们需要在contact-list模板中添加搜索框并应用我们的值转换器:

src/contact-list.html

<template> 
  <section class="container"> 
    <h1>Contacts</h1> 

    <div class="row"> 
      <div class="col-sm-2"> 
        <div class="input-group"> 
          <input type="text" class="form-control" placeholder="Filter"  
                 value.bind="filter & debounce"> 
          <span class="input-group-btn" if.bind="filter"> 
            <button class="btn btn-default" type="button"  
                    click.delegate="filter = ''"> 
              <i class="fa fa-times"></i> 
              <span class="sr-only">Clear</span> 
            </button> 
          </span> 
        </div> 
      </div> 
    </div> 

    <div repeat.for="group of contacts 
                     | filterBy:filter:'firstName':'lastName':'company' 
                     | groupBy:'firstLetter'  
                     | orderBy:'key'" 
         class="panel panel-default"> 
      <!-- Omitted snippet... --> 
    </div> 
  </section> 
</template> 

在这里,我们首先添加一个搜索框,形式为一个input元素,其value绑定到filter属性。这个绑定是去抖的,所以属性将在用户停止输入 200 毫秒后才会更新。

另外,当filter不为空时,input旁边会显示一个按钮。点击这个按钮,简单地将filter分配为一个空字符串。

最后,我们在repeat.for绑定中将对contacts应用filterBy,传递filter作为搜索值,随后是firstNamelastNamecompany属性的名称,这些属性将被搜索。

注意

这里有趣的一点是,我们甚至没有在视图模型中声明filter属性。它只在视图中使用。由于它绑定到输入元素的值属性,默认情况下绑定是双向的,绑定只会将其值分配给视图模型。视图模型不需要知道这个属性。

联系人详细视图

如果你点击一个联系人,你应该在浏览器控制台看到一个错误。原因很简单:应该显示联系人详情的路由指的是一个contact-details组件,而这个组件还不存在。让我们来纠正这个问题。

视图模型

视图模型将利用我们之前编写的某些类:

src/contact-details.js

import {inject} from 'aurelia-framework'; 
import {ContactGateway} from './contact-gateway'; 

@inject(ContactGateway) 
export class ContactDetails { 
  constructor(contactGateway) { 
    this.contactGateway = contactGateway; 
  } 

  activate(params, config) { 
    return this.contactGateway.getById(params.id) 
      .then(contact => { 
        this.contact = contact; 
        config.navModel.setTitle(contact.fullName); 
      }); 
  } 
} 

这段代码相当直接。视图模型期望在其构造函数中注入ContactGateway的一个实例,并实现activate生命周期回调方法。这个方法使用id路由参数并向网关请求适当的联系人对象。它返回网关的Promise,所以导航只有在联系人加载完成后才会完成。当这个Promise解决时,联系人对象被分配给视图模型的contact属性。此外,路由config对象用于动态将文档标题分配给联系人的fullName

模板

联系人详情的模板很大,所以让我们将其分解为部分。你可以按照这一节逐步构建模板。

首先,让我们添加一个头,将显示联系人的图片和姓名:

<template> 
  <section class="container"> 
    <div class="row"> 
      <div class="col-sm-2"> 
        <img src.bind="contact.photoUrl" class="img-responsive" alt="Picture"> 
      </div> 
      <template if.bind="contact.isPerson"> 
        <h1 class="col-sm-10">${contact.fullName}</h1> 
        <h2 class="col-sm-10">${contact.company}</h2> 
      </template>  
      <template if.bind="!contact.isPerson"> 
        <h1 class="col-sm-10">${contact.company}</h1> 
      </template> 
    </div> 
  </section> 
</template> 

模板的其余部分,应该放在关闭section标签之前,被一个带有form-horizontal类的div元素包含:

<div class="form-horizontal"> 
  <!-- the rest of the template goes here. --> 
</div> 

在这个元素内部,我们首先显示联系人在创建和最后修改时的日期和时间:

<div class="form-group"> 
  <label class="col-sm-2 control-label">Created on</label> 
  <div class="col-sm-10"> 
    <p class="form-control-static">${contact.createdAt}</p> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-2 control-label">Modified on</label> 
  <div class="col-sm-10"> 
    <p class="form-control-static">${contact.modifiedAt}</p> 
  </div> 
</div> 

接下来,如果联系人有生日,我们将显示联系人的生日:

<div class="form-group" if.bind="contact.birthday"> 
  <label class="col-sm-2 control-label">Birthday</label> 
  <div class="col-sm-10"> 
    <p class="form-control-static">${contact.birthday}</p> 
  </div> 
</div> 

之后,我们将显示联系人的电话号码:

<template if.bind="contact.phoneNumbers.length > 0"> 
  <hr> 
  <div class="form-group"> 
    <h4 class="col-sm-2 control-label">Phone numbers</h4> 
  </div> 
  <div class="form-group" repeat.for="phoneNumber of contact.phoneNumbers"> 
    <label class="col-sm-2 control-label">${phoneNumber.type}</label> 
    <div class="col-sm-10"> 
      <p class="form-control-static"> 
        <a href="tel:${phoneNumber.number}">${phoneNumber.number}</a> 
      </p> 
    </div> 
  </div> 
</template> 

在这里,块被包含在一个模板中,该模板仅当联系人至少有一个电话号码时才渲染。每个电话号码都显示其类型:家庭、办公室或移动电话等。

接下来的部分都会遵循与电话号码相同的模式。它们将显示联系人的电子邮件地址、地理位置和社交媒体资料:

<template if.bind="contact.emailAddresses.length > 0"> 
  <hr> 
  <div class="form-group"> 
    <h4 class="col-sm-2 control-label">Email addresses</h4> 
  </div> 
  <div class="form-group"  
       repeat.for="emailAddress of contact.emailAddresses"> 
    <label class="col-sm-2 control-label">${emailAddress.type}</label> 
    <div class="col-sm-10"> 
      <p class="form-control-static"> 
        <a href="mailto:${emailAddress.address}"  
           target="_blank">${emailAddress.address}</a> 
      </p> 
    </div> 
  </div> 
</template> 

<template if.bind="contact.addresses.length > 0"> 
  <hr> 
  <div class="form-group"> 
    <h4 class="col-sm-2 control-label">Addresses</h4> 
  </div> 
  <div class="form-group" repeat.for="address of contact.addresses"> 
    <label class="col-sm-2 control-label">${address.type}</label> 
    <div class="col-sm-10"> 
      <p class="form-control-static">${address.number} ${address.street}</p> 
      <p class="form-control-static">${address.postalCode} ${address.city}</p> 
      <p class="form-control-static">${address.state} ${address.country}</p> 
    </div> 
  </div> 
</template> 

<template if.bind="contact.socialProfiles.length > 0"> 
  <hr> 
  <div class="form-group"> 
    <h4 class="col-sm-2 control-label">Social Profiles</h4> 
  </div> 
  <div class="form-group" repeat.for="profile of contact.socialProfiles"> 
    <label class="col-sm-2 control-label">${profile.type}</label> 
    <div class="col-sm-10"> 
      <p class="form-control-static"> 
        <a if.bind="profile.type === 'GitHub'"  
           href="https://github.com/${profile.username}"  
           target="_blank">${profile.username}</a> 
        <a if.bind="profile.type === 'Twitter'"  
           href="https://twitter.com/${profile.username}"  
           target="_blank">${profile.username}</a> 
      </p> 
    </div> 
  </div> 
</template> 

最后,如果有的话,我们将显示联系人的备注:

<template if.bind="contact.note"> 
  <hr> 
  <div class="form-group"> 
    <label class="col-sm-2 control-label">Note</label> 
    <div class="col-sm-10"> 
      <p class="form-control-static">${contact.note}</p> 
    </div> 
  </div> 
</template> 

由于在组件的生命周期中加载的联系人永远不会改变,可以通过将所有bind命令替换为one-time命令,并将所有字符串插值装饰为oneTime绑定行为来大大改进此模板。我将把这个作为读者的练习留给读者。

概要

正如你所见,Aurelia 的数据绑定语言清晰简洁。它相当容易理解,即使对于不熟悉 Aurelia 的开发人员来说,模板也很容易理解。此外,它是适应性强的,使得编写高性能应用程序尽可能简单。

除了 Fetch 客户端的便利性,这些特质结合了值转换器和绑定行为系统的灵活性与可重用性,使得编写数据展示组件变得非常简单。

构建用于创建和编辑数据的形式并不比这更复杂。我们将在下一章中看到这一点,其中包括表单验证。

第四章:表单及其验证方式

在本章中,我们将了解数据绑定如何适用于用户输入元素,如inputselecttextarea。我们还将了解当处理比简单 GET 请求更复杂的场景时,Fetch 客户端如何工作,例如带有 JSON 主体的 POST 或 PUT 请求,或者向服务器上传文件的需求。

此外,我们还将了解如何使用aurelia-validation插件验证表单。

最后,我们将讨论使用aurelia-dialog插件创建复杂表单的各种策略,从内联列表编辑到使用模态窗口编辑。

绑定表单输入

Aurelia 支持所有官方 HTML5 用户输入元素的双向绑定。其中一些相当简单易用,比如text input,我们已经在前面的章节中用许多示例探索过。其他的,如单选按钮或复选框,则不太直接。让我们逐一了解它们。

以下部分中的代码片段摘自chapter-4/samples/binding-forms

选择元素

对于select元素,我们通常绑定到它的value属性,并且经常使用repeat.for来渲染它的option元素:

<template> 
  <select value.bind="selectedCountry"> 
    <option>Select your country</option> 
    <option repeat.for="country of countries"  
            value.bind="country">${country}</option> 
  </select> 
</template> 

当然,select元素的value属性默认绑定双向,所以选中的option元素的value将分配给绑定到selectvalue属性的表达式。在此示例中,selectedCountry属性将被分配选中的country值。

option元素的value属性只期望字符串值。在前一个示例中,countries属性是一个字符串数组,因此每个optionvalue绑定到一个字符串。要渲染绑定到任何其他类型值的option——例如一个对象——必须使用特殊的model属性:

<template> 
  <select value.bind="selectedCulture"> 
    <option>Select your culture</option> 
    <option repeat.for="culture of cultures"  
            model.bind="culture">${culture.name}</option> 
  </select> 
</template> 

在此,selectedCulture属性将被赋值为选中的culture对象,因为cultures属性是一个对象数组。

或者,如果你需要选择一个键属性,比如一个 ID,而不是整个数组项,你仍然可以使用option元素的value属性,前提是键属性是一个字符串值:

<template> 
  <select value.bind="selectedCultureIsoCode"> 
    <option>Select your culture</option> 
    <option repeat.for="culture of cultures"  
            value.bind="culture.isoCode">${culture.name}</option> 
  </select> 
</template> 

在此示例中,选中的optionvalue绑定到相应项的isoCode属性,这是一个字符串,因此选中项的此属性将被分配给selectedCultureIsoCode

当然,在渲染过程中,绑定到select属性的value表达式的值将被求值,如果任何option具有匹配的valuemodel属性,这个option将被渲染为选中状态。

多选

select元素具有multiple属性时,绑定到其value属性的表达式预期是一个数组:

<template> 
  <select value.bind="selectedCountries" multiple> 
    <option repeat.for="country of countries"  
            value.bind="country">${country}</option> 
  </select> 
</template> 

在此,选中的选项的值将被添加到selectedCountries数组中。

当用户选择一个项目时,选中的值总是添加到选择数组的末尾。

当然,当将非字符串值的数组渲染到多选列表时,也适用于相同的规则;数组的每个项目应绑定到其optionmodel属性上:

<template> 
  <select value.bind="selectedCultures" multiple> 
    <option repeat.for="culture of cultures"  
            model.bind="culture">${culture.name}</option> 
  </select> 
</template> 

在这里,所选的culture对象将被添加到selectedCultures数组中。

使用键字符串属性的替代方案,在多选中同样适用:

<template> 
  <select value.bind="selectedCulturesIsoCodes" multiple> 
    <option repeat.for="culture of cultures"  
            value.bind="culture.isoCode">${culture.name}</option> 
  </select> 
</template> 

在这个示例中,所选culture对象的isoCode属性将被添加到selectedCulturesIsoCodes数组中,这是一个字符串数组。

匹配器

当使用model属性时,可能会发生这种情况:分配给selectvalue属性的对象具有相同的身份,但与分配给optionmodel属性的对象不是同一个实例。在这种情况下,Aurelia 将无法渲染正确的option作为选中项。

matcher属性正是为这种场景设计的:

<template> 
  <select value.bind="selectedCulture" matcher.bind="matchCulture"> 
    <option>Select your culture</option> 
    <option repeat.for="culture of cultures"  
            model.bind="culture">${culture.name}</option> 
  </select> 
</template> 

在这里,当尝试找出哪个option应该被选中时,select元素会将等价比较委托给matchCulture函数,该函数应大致如下所示:

export class ViewModel { 
  matchCulture = (culture1, culture2) => culture1.isoCode === culture2.isoCode; 
} 

在这里,这个函数期望有两个文化对象,它们可能具有相同的身份,代表相同的文化。如果这两个对象具有相同的身份,将返回true,否则返回false

输入元素

绑定到input元素在大多数情况下是很简单的,但实际上取决于type属性。例如,对于text输入,value属性默认是双向绑定的,可以用来获取用户的输入:

<template> 
  <input type="text" value.bind="title"> 
</template> 

在这里,title属性的初始值将在input中显示,用户对input值的任何更改也将应用到title属性上。类似地,对title属性的任何更改也将应用到inputvalue上。

对于大多数其他类型的input,使用方式相同:colordateemailnumberpasswordtelurl等。然而,也有一些特殊情况,如下所述。

文件选择器

input元素的type属性为file时,它暴露其files属性作为一个属性。它默认使用双向绑定:

<template> 
  <input type="file" accepts="image/*" files.bind="images"> 
</template> 

在这个示例中,input元素的files属性被绑定到视图模型的images属性上。当用户选择一个文件时,images被赋予一个包含所选文件的FileList对象。如果input元素具有multiple属性,用户可以选择多个文件,结果的FileList对象将包含用户选择的多个File对象。

FileListFile类是 HTML5 文件 API 的一部分,可以与 Fetch API 一起使用,将用户选择的文件发送到服务器。在本书稍后的章节中,我们将看到在构建联系人应用程序的照片编辑组件时的一个示例。

Mozilla 开发者网络有关于文件 API 的详尽文档。关于FileList类的详细信息可以在developer.mozilla.org/en-US/docs/Web/API/FileList找到。

单选按钮

select元素的option类似,单选按钮可以使用valuemodel属性来指定按钮选中时的值。value属性只期望字符串值,所以对于任何其他类型的值,必须使用model属性。

此外,单选按钮可以绑定它们的checked属性,该属性默认是双向的,到一个表达式,当选中时将被分配按钮的valuemodel

<template> 
  <label repeat.for="country of countries"> 
    <input type="radio" name="countries" value.bind="country"  
           checked.bind="selectedCountry"> 
    ${country} 
  </label> 
</template> 

在这里,一组单选按钮使用名为countries的字符串数组进行渲染。选中的单选按钮的country,绑定到value属性,将被分配给selectedCountry属性。

option元素一样,当绑定到不是字符串的值时,应使用model属性而不是value

<template> 
  <label repeat.for="culture of cultures"> 
    <input type="radio" name="cultures" model.bind="culture"  
              checked.bind="selectedCulture"> 
    ${culture.name} 
  </label> 
</template> 

在这里,一组单选按钮使用一个culture对象的数组进行渲染。选中的单选按钮的culture,绑定到model属性,将被分配给selectedCulture属性。

select元素类似,使用model属性的单选按钮也可以使用matcher属性来自定义等价比较逻辑。

所有之前的示例都使用了repeat.for绑定到数组来渲染动态的单选按钮列表。如果你需要渲染一个静态的单选按钮列表,并且期望的输出是一个布尔值,例如呢?在这种情况下,不需要在数组上迭代:

<template> 
  <h4>Do you speak more than one language?</h4> 
  <label> 
    <input type="radio" name="isMultilingual" model.bind="null"  
           checked.bind="isMultilingual">  
    That's none of your business 
  </label> 
  <label> 
    <input type="radio" name="isMultilingual" model.bind="true"  
           checked.bind="isMultilingual"> 
    Yes 
  </label> 
  <label> 
    <input type="radio" name="isMultilingual" model.bind="false"  
           checked.bind="isMultilingual"> 
    No 
  </label> 
</template> 

在这个例子中,渲染了一个静态的单选按钮列表,每个按钮都使用它们的model属性绑定到不同的标量值。它们的checked属性绑定到isMultilingual属性,这将根据选择哪个按钮而被分配为nulltruefalse

当然,在渲染过程中,如果绑定到按钮组checked属性的表达式有一个值与按钮的valuemodel属性匹配,这个按钮将被渲染为选中状态。

复选框

复选框列表在其典型用法上与带multiple属性的select元素相似。每个input元素都有valuemodel属性。此外,预期checked属性将被绑定到数组,到这个数组中将会添加所有选中的inputvaluemodel

<template> 
  <label repeat.for="country of countries"> 
    <input type="checkbox" value.bind="country"  
           checked.bind="selectedCountries"> 
    ${country} 
  </label> 
</template> 

在这里,一组复选框使用名为countries的字符串数组进行渲染。选中的复选框的country,绑定到value属性,将被添加到selectedCountries数组。

option元素或单选按钮一样,value属性只期望字符串值。当绑定到任何其他类型的值时,应使用model属性:

<template> 
  <label> 
    <input type="checkbox" model.bind="culture"  
           checked.bind="selectedCultures"> 
    ${culture.name} 
  </label> 
</template> 

在此,一组复选框使用culture对象的数组进行渲染。选中的复选框的culture,通过model属性绑定,将被添加到selectedCultures数组中。

select元素和单选按钮类似,使用model属性的复选框也可以使用matcher属性来自定义等价比较逻辑。

当然,如果渲染对象数组时,选中的值是某种字符串 ID,仍然可以使用value属性:

<template> 
  <label> 
    <input type="checkbox" value.bind="culture.isoCode"  
           checked.bind="selectedCulturesIsoCodes"> 
    ${culture.name} 
  </label> 
</template> 

在此,一组复选框使用culture对象的数组进行渲染。选中的复选框的cultureisoCode属性,绑定到value属性,将被添加到selectedCulturesIsoCodes字符串数组中。

当然,在渲染过程中,如果绑定到checked属性的数组包含绑定到valuemodel属性的值,此复选框将被渲染为选中状态。

Alternatively,复选框可以绑定到不同的布尔表达式,而不是一个单一的数组。这可以通过省略任何valuemodel属性来实现:

<template> 
  <label> 
    <input type="checkbox" checked.bind="speaksFrench">French 
  </label> 
  <label> 
    <input type="checkbox" checked.bind="speaksEnglish">English 
  </label> 
  <label> 
    <input type="checkbox" checked.bind="speaksGerman">German 
  </label> 
</template> 

在此示例中,每个checkbox绑定到不同的属性,这将根据复选框是否被选中分配truefalse

textarea

绑定到textarea元素与绑定到text``input元素相同:

<template> 
  <textarea value.bind="text"></textarea> 
</template> 

在此,text属性的初始值将在textarea内显示,由于textareavalue属性的绑定是默认的双向的,用户对textarea内容的所有修改都将反映在text属性上。

禁用元素

禁用inputselecttextareabutton元素只需绑定到其disabled属性即可:

<template> 
  <input type="text" disabled.bind="isSending"> 
  <button disabled.bind="isSending">Send</button> 
</template> 

isSendingtrue时,inputbutton元素都将被禁用。

设置元素只读

同样,使inputtextarea元素只读只需将其readonly属性绑定即可:

<template> 
  <input type="text" readonly.bind="!canEdit"> 
</template> 

在此,当canEditfalse时,input将变为只读。

向我们的应用程序添加表单

既然我们知道如何处理用户输入元素,我们可以在我们的联系人管理应用程序中添加表单以创建和编辑联系人。

添加新路由

我们需要添加三个新路由:一个用于创建新联系人,另一个用于编辑现有联系人,最后一个用于上传联系人的照片。让我们在根组件中添加它们:

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:        'contact-list',  
        nav: true, title: 'Contacts' }, 
      { route: 'contacts/new', name: 'contact-creation',  
        moduleId: 'contact-edition', title: 'New contact' }, 
      { route: 'contacts/:id', name: 'contact-details',  
        moduleId: 'contact-details' }, 
      { route: 'contacts/:id/edit', name: 'contact-edition',  
        moduleId: 'contact-edition' }, 
      { route: 'contacts/:id/photo', name: 'contact-photo', 
        moduleId: 'contact-photo' }, 
    ]); 
    config.mapUnknownRoutes('not-found'); 
  } 
} 

在前面的代码片段中,三个新路由被突出显示。

在这里,定位很重要。contact-creation路径在contact-details路径之前,这是因为它们的route属性。在尝试查找 URL 更改时的匹配路径时,路由器会按照它们被定义的顺序深入路由定义。由于contact-details的模式匹配任何以contacts/开头,后跟第二个部分作为参数的解释,因此路径contacts/new符合此模式,所以如果contact-creation路径定义在后面,它将无法到达,而contact-details路径将使用等于newid参数到达。

依赖于路由顺序的更好替代方法是将模式更改,以避免可能的冲突。例如,我们可以将contact-details的模式更改为类似于contacts/:id/details。在这种情况下,路由的顺序将不再重要。

您可能已经注意到两个新路径具有相同的moduleId。这是因为我们将为创建新联系人和编辑现有联系人使用相同的组件。

添加新路径的链接

下一步将是添加刚刚添加的路由的链接。我们首先在contact-list组件中添加一个到contact-creation路径的链接:

src/contact-list.html

 <template> 
  <section class="container"> 
    <h1>Contacts</h1> 

    <div class="row"> 
      <div class="col-sm-1"> 
        <a route-href="route: contact-creation" class= "btn btn-primary"> 
          <i class="fa fa-plus-square-o"></i> New 
        </a> 
      </div> 
      <div class="col-sm-2"> 
        <!-- Search box omitted for brevity --> 
      </div> 
    </div> 
    <!--  Contact list omitted for brevity --> 
  </section> 
</template> 

在这里,我们添加了一个a元素,并利用route-href属性渲染contact-creation路径的 URL。

我们还需要添加到contact-photocontact-edition路径的链接。我们将在contact-details组件中完成这个任务:

src/contact-details.html

 <template> 
  <section class="container"> 
    <div class="row"> 
      <div class="col-sm-2"> 
        <a route-href="route: contact-photo; params.bind:
          { id: contact.id }"  
           > 
          <img src.bind="contact.photoUrl" class= "img-responsive" alt="Photo"> 
        </a> 
      </div> 
      <div class="col-sm-10"> 
        <template if.bind="contact.isPerson"> 
          <h1>${contact.fullName}</h1> 
          <h2>${contact.company}</h2> 
        </template> 
        <template if.bind="!contact.isPerson"> 
          <h1>${contact.company}</h1> 
        </template> 
        <a class="btn btn-default" route-href="route:
          contact-edition;  
          params.bind: { id: contact.id }"> 
          <i class="fa fa-pencil-square-o"></i> Modify 
        </a> 
      </div> 
    </div> 
    <!-- Rest of template omitted for brevity --> 
  </section> 
</template> 

在这里,我们首先重构显示fullNamecompany(如果联系人是人)的模板,通过添加一个外部的div并将col-sm-10CSS 类从标题移动到这个div

接下来,我们将显示联系人性照的img元素包裹在一个导航到contact-photo路径的锚点中,使用联系人的id作为参数。

最后,我们添加另一个指向contact-edition路径的锚点,使用联系人的id作为参数。

更新模型

为了重用代码,我们将坚持使用Contact类,并在我们的表单组件中使用它。我们还将为电话号码、电子邮件地址、地址和社会资料创建类,这样我们的contact-edition组件就无需知道创建这些对象空实例的详细信息。

我们需要添加创建我们模型空实例的能力,并将其所有属性初始化为适当的默认值。因此,我们将为我们的模型类添加所有属性的默认值。

最后,我们需要更新ContactfromObject工厂方法,以便所有列表项都正确映射到我们的模型类实例。

src/models.js

export class PhoneNumber { 
  static fromObject(src) { 
    return Object.assign(new PhoneNumber(), src); 
  } 

  type = 'Home'; 
  number = ''; 
} 

export class EmailAddress { 
  static fromObject(src) { 
    return Object.assign(new EmailAddress(), src); 
  } 

  type = 'Home'; 
  address = ''; 
} 

export class Address { 
  static fromObject(src) { 
    return Object.assign(new Address(), src); 
  } 

  type = 'Home'; 
  number = ''; 
  street = ''; 
  postalCode = ''; 
  city = ''; 
  state = ''; 
  country = ''; 
} 

export class SocialProfile { 
  static fromObject(src) { 
    return Object.assign(new SocialProfile(), src); 
  } 

  type = 'GitHub'; 
  username = ''; 
} 

export class Contact { 
  static fromObject(src) { 
    const contact = Object.assign(new Contact(), src); 
    contact.phoneNumbers = contact.phoneNumbers 
      .map(PhoneNumber.fromObject); 
    contact.emailAddresses = contact.emailAddresses 
      .map(EmailAddress.fromObject); 
    contact.addresses = contact.addresses 
      .map(Address.fromObject); 
    contact.socialProfiles = contact.socialProfiles 
      .map(SocialProfile.fromObject); 
    return contact; 
  } 

  firstName = ''; 
  lastName = ''; 
  company = ''; 
  birthday = ''; 
  phoneNumbers = []; 
  emailAddresses = []; 
  addresses = []; 
  socialProfiles = []; 
  note = ''; 

  // Omitted snippet... 
} 

在这里,我们首先添加了PhoneNumberEmailAddressAddressSocialProfile类的类。每个类都有一个静态的fromObject工厂方法,其属性都使用默认值正确初始化。

接下来,我们添加了一个Contact对象属性,其初始值为默认值,并更改了其fromObject工厂方法,以便列表项能够正确映射到它们相应的类中。

创建表单组件

现在我们可以创建我们新的contact-edition组件了。如早前所提,这个组件将用于创建和编辑。它能够通过检查在其activate回调方法中是否接收了一个id参数来检测它是用于创建新的联系人还是编辑现有的联系人。确实,contact-creation路由的模式定义了无参数,所以当我们的表单组件通过这个路由被激活时,它不会接收任何id参数。另一方面,由于contact-edition路由的模式确实定义了一个id参数,所以当我们的表单组件通过这个路由被激活时,它会接收到这个参数。

我们可以这样做,因为在我们联系管理应用程序的范围内,创建和编辑过程几乎是一致的。然而,在许多情况下,最好是为创建和编辑分别设计单独的组件。

激活视图模型

让我们首先从视图模型和activate回调方法开始:

src/contact-edition.js

import {inject} from 'aurelia-framework'; 
import {ContactGateway} from './contact-gateway'; 
import {Contact} from './models'; 

@inject(ContactGateway) 
export class ContactEdition { 
  constructor(contactGateway) { 
    this.contactGateway = contactGateway; 
  } 

  activate(params, config) { 
    this.isNew = params.id === undefined; 
    if (this.isNew) { 
      this.contact = new Contact(); 
    } 
    else { 
      return this.contactGateway.getById(params.id).then(contact => { 
        this.contact = contact; 
        config.navModel.setTitle(contact.fullName); 
      }); 
    } 
  } 
} 

在这里,我们首先向我们的视图模型注入ContactGateway类的实例。然后,在activate回调方法中,我们首先定义了一个isNew属性,该属性基于是否存在id参数。这个属性将用于我们的组件,使其知道它是被用来创建一个新的联系人还是编辑一个现有的联系人。

接下来,基于这个isNew属性,我们初始化组件。如果我们正在创建一个新的联系人,那么我们只需创建一个contact属性并将其分配给一个新的、空的Contact实例;否则,我们使用ContactGateway根据id参数检索适当的联系人,当Promise解决时,将Contact实例分配给contact属性,并将文档标题设置为联系人的fullName属性。

一旦激活周期完成,视图模型将有一个适当初始化为Contact对象的contact属性和一个指示联系人是新的还是现有的isNew属性。

构建表单布局

接下来,让我们构建一个用于显示表单的模板。由于这个模板相当大,我将把它分成几部分,这样你就可以逐步构建它并在每个步骤进行测试(如果需要的话)。

模板由一个头部组成,后面是一个form元素,它将包含模板的其余部分:

src/contact-edition.html

 <template> 
  <section class="container"> 
    <h1 if.bind="isNew">New contact</h1> 
    <h1 if.bind="!isNew">Contact #${contact.id}</h1> 

    <form class="form-horizontal"> 
      <!-- The rest of the template goes in here --> 
    </form> 
  </section> 
</template> 

在头部,我们使用isNew属性来显示是告诉用户他正在创建一个新的联系人还是显示正在编辑的联系人id的动态标题。

编辑标量属性

接下来,我们将向form元素中添加块,其中包含输入元素,以编辑联系人的firstNamelastNamecompanybirthdaynote,如前一个代码片段中定义的那样:

<div class="form-group"> 
  <label class="col-sm-3 control-label">First name</label> 
  <div class="col-sm-9"> 
    <input type="text" class="form-control" value.bind="contact.firstName"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Last name</label> 
  <div class="col-sm-9"> 
    <input type="text" class="form-control" value.bind="contact.lastName"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Company</label> 
  <div class="col-sm-9"> 
    <input type="text" class="form-control" value.bind="contact.company"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Birthday</label> 
  <div class="col-sm-9"> 
    <input type="date" class="form-control" value.bind="contact.birthday"> 
  </div> 
</div> 

<div class="form-group"> 
  <label class="col-sm-3 control-label">Note</label> 
  <div class="col-sm-9"> 
    <textarea class="form-control" value.bind="contact.note"></textarea> 
  </div> 
</div> 

在这里,我们仅为每个属性定义一个form-group以进行编辑。前三个属性各自绑定到一个text input元素。此外,birthday属性绑定到一个date输入,使其更容易编辑日期——当然,仅限支持它的浏览器,而note属性则绑定到一个textarea元素。

编辑电话号码

在此之后,我们需要为列表添加编辑器。由于每个列表中包含的数据并不复杂,我们将使用内联编辑器,这样用户就可以在最少的点击次数内直接编辑任何项目的任何字段。

我们将在本章后面讨论更复杂的编辑模型,使用对话框。

让我们从电话号码开始:

<hr> 
<div class="form-group" repeat.for="phoneNumber of contact.phoneNumbers"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="phoneNumber.type" class="form-control"> 
      <option value="Home">Home</option> 
      <option value="Office">Office</option> 
      <option value="Mobile">Mobile</option> 
      <option value="Other">Other</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <input type="tel" class="form-control" placeholder="Phone number"  
           value.bind="phoneNumber.number"> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.phoneNumbers.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-default" click.delegate="contact.addPhoneNumber()"> 
      <i class="fa fa-plus-square-o"></i> Add a phone number 
    </button> 
  </div> 
</div> 

这个电话号码列表编辑器可以分解为几个部分,其中最重要的是突出显示的。首先,为联系的phoneNumbers数组中的每个phoneNumber重复一个form-group

对于每个phoneNumber,我们定义一个select元素,其value绑定到phoneNumbertype属性,以及一个tel输入,其value绑定到phoneNumbernumber属性。此外,我们定义了一个button,当点击时,使用当前的$index(正如您可能记得的前一章中提到的,这是通过repeat属性添加到绑定上下文的),从contactphoneNumbers数组中拼接出电话号码。

最后,在电话号码列表之后,我们定义了一个button,其click事件调用contact中的addPhoneNumber方法。

添加缺失的方法

我们在上一个模板中添加的按钮之一调用了一个尚未定义的方法。让我们把这个方法添加到Contact类中:

src/models.js

//Snippet... 
export class Contact { 
  //Snippet... 
  addPhoneNumber() { 
    this.phoneNumbers.push(new PhoneNumber()); 
  } 
} 

此代码片段中的第一个方法用于向列表中添加一个空电话号码,简单地在phoneNumbers数组中推入一个新的PhoneNumber实例。

编辑其他列表

其他列表的模板,如电子邮件地址、地址和社会资料,都非常相似。只有正在编辑的字段会改变,但主要概念——重复的表单组、每个条目都有一个删除按钮和一个在列表末尾的添加按钮——是相同的。

让我们从emailAddresses开始:

<hr> 
<div class="form-group" repeat.for="emailAddress of contact.emailAddresses"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="emailAddress.type" class="form-control"> 
      <option value="Home">Home</option> 
      <option value="Office">Office</option> 
      <option value="Other">Other</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <input type="email" class="form-control" placeholder="Email address"  
           value.bind="emailAddress.address"> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.emailAddresses.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-primary"  
            click.delegate="contact.addEmailAddress()"> 
      <i class="fa fa-plus-square-o"></i> Add an email address 
    </button> 
  </div> 
</div> 

这个模板与电话号码的模板非常相似。主要区别在于可用的类型并不完全相同,而且inputtypeemail

如您所想象,地址的编辑器会更大一些:

<hr> 
<div class="form-group" repeat.for="address of contact.addresses"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="address.type" class="form-control"> 
      <option value="Home">Home</option> 
      <option value="Office">Office</option> 
      <option value="Other">Other</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <div class="row"> 
      <div class="col-sm-4"> 
        <input type="text" class="form-control" placeholder="Number"  
               value.bind="address.number"> 
      </div> 
      <div class="col-sm-8"> 
        <input type="text" class="form-control" placeholder="Street"  
               value.bind="address.street"> 
      </div> 
    </div> 
    <div class="row"> 
      <div class="col-sm-4"> 
        <input type="text" class="form-control" placeholder="Postal code"  
               value.bind="address.postalCode"> 
      </div> 
      <div class="col-sm-8"> 
        <input type="text" class="form-control" placeholder="City"  
               value.bind="address.city"> 
      </div> 
    </div> 
    <div class="row"> 
      <div class="col-sm-4"> 
        <input type="text" class="form-control" placeholder="State"  
               value.bind="address.state"> 
      </div> 
      <div class="col-sm-8"> 
        <input type="text" class="form-control" placeholder="Country"  
               value.bind="address.country"> 
      </div> 
    </div> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.addresses.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-primary"  
            click.delegate="contact.addAddress()"> 
      <i class="fa fa-plus-square-o"></i> Add an address 
    </button> 
  </div> 
</div> 

在这里,左侧包含六个不同的输入,允许我们编辑地址的各种文本属性。

至此,您可能已经对社交资料的模板有一个大致的了解:

<hr> 
<div class="form-group" repeat.for="profile of contact.socialProfiles"> 
  <div class="col-sm-2 col-sm-offset-1"> 
    <select value.bind="profile.type" class="form-control"> 
      <option value="GitHub">GitHub</option> 
      <option value="Twitter">Twitter</option> 
    </select> 
  </div> 
  <div class="col-sm-8"> 
    <input type="text" class="form-control" placeholder="Username"  
           value.bind="profile.username"> 
  </div> 
  <div class="col-sm-1"> 
    <button type="button" class="btn btn-danger"  
            click.delegate="contact.socialProfiles.splice($index, 1)"> 
      <i class="fa fa-times"></i>  
    </button> 
  </div> 
</div> 
<div class="form-group"> 
  <div class="col-sm-9 col-sm-offset-3"> 
    <button type="button" class="btn btn-primary"  
            click.delegate="contact.addSocialProfile()"> 
      <i class="fa fa-plus-square-o"></i> Add a social profile 
    </button> 
  </div> 
</div> 

当然,每个列表添加项目的方法都需要添加到Contact类中:

src/models.js

//Omitted snippet... 
export class Contact { 
  //Omitted snippet... 
  addEmailAddress() { 
    this.emailAddresses.push(new EmailAddress()); 
  } 

  addAddress() { 
    this. addresses.push(new Address()); 
  } 

  addSocialProfile() { 
    this.socialProfiles.push(new SocialProfile()); 
  } 
} 

正如你所看到的,这些方法与我们之前为电话号码编写的那些几乎完全相同。此外,每个列表的模板片段也基本上彼此相同。所有这种冗余都呼吁进行重构。我们将在第五章,制作可复用的组件中看到,如何将常见行为和模板片段提取到一个组件中,我们将重新使用它来管理每个列表。

保存和取消

我们表单(至少在视觉上)完整的最后一件缺失的事情是在包含form元素的末尾添加一个保存和取消按钮:

//Omitted snippet... 
<form class="form-horizontal" submit.delegate="save()"> 
  //Omitted snippet... 
  <div class="form-group"> 
      <div class="col-sm-9 col-sm-offset-3"> 
        <button type="submit" class="btn btn-success">Save</button> 
        <a if.bind="isNew" class="btn btn-danger"  
           route-href="route: contacts">Cancel</a> 
        <a if.bind="!isNew" class="btn btn-danger"  
           route-href="route: contact-details;  
           params.bind: { id: contact.id }">Cancel</a> 
      </div> 
    </div> 
</form> 

首先,我们将一个对save方法的调用绑定到form元素的submit事件,然后我们添加了一个包含一个名为Savesubmit按钮的最后一个form-group

接下来,我们添加了两个Cancel链接:一个在创建新联系人时显示,用于导航回到联系人列表;另一个在编辑现有联系人时显示,用于导航回到联系人的详细信息。

我们还需要将save方法添加到视图模型中。这个方法最终将委派给ContactGateway,但为了测试我们到目前为止所做的一切是否工作,让我们只写一个方法版本:

save() { 
  alert(JSON.stringify(this.contact)); 
} 

至此,你应该能够运行应用程序并尝试创建或编辑一个联系人。点击保存按钮时,你应该会看到一个显示联系人的警报,该联系人作为 JSON 序列化格式。

使用 fetch 发送数据

我们现在可以向ContactGateway类添加创建和更新联系人的方法:

src/contact-gateway.js

//Omitted snippet... 
import {HttpClient, json} from 'aurelia-fetch-client'; 
//Omitted snippet... 
export class ContactGateway { 
  //Omitted snippet... 
  create(contact) { 
    return this.httpClient.fetch('contacts',  
      { method: 'POST', body: json(contact) }); 
  } 

  update(id, contact) { 
    return this.httpClient.fetch(`contacts/${id}`,  
      { method: 'PUT', body: json(contact) }); 
  } 
} 

首先要做的第一件事是importfetch-clientjson函数。这个函数接受任何 JS 值作为参数,并返回一个包含接收参数序列化为 JSON 的Blob对象。

接下来,我们添加了一个create方法,它接受一个contact作为参数,并调用 HTTP 客户端的fetch方法,传递要调用的相对 URL,然后是一个配置对象。这个对象包含将分配给底层Request对象的属性。在这里,我们指定一个method属性,告诉客户端执行一个POST请求,我们指示请求的body将是序列化为 JSON 的contact。最后,fetch方法返回一个Promise,这是我们新create方法返回的,所以调用者可以在请求完成后做出反应。

update方法非常相似。第一个区别是参数:首先期望联系人的id,然后是contact对象本身。其次,fetch调用略有不同;它发送一个到不同 URL 的请求,使用PUT方法,但其主体相同。

一个 FetchRequestbody预期是一个Blob、一个BufferSource、一个FormData、一个URLSearchParams或一个USVString对象。关于这方面的文档可以在 Mozilla 开发者网络上找到,网址为developer.mozilla.org/en-US/docs/Web/API/Request/Request

为了测试我们新方法是否有效,让我们将contact-edition组件的视图模型中的模拟save方法替换为真实的方法:

//Omitted snippet... 
import {Router} from 'aurelia-router'; 

@inject(ContactGateway, Router) 
export class ContactEdition { 
  constructor(contactGateway, router) { 
    this.contactGateway = contactGateway; 
    this.router = router; 
  } 

  // Omitted snippet... 
  save() { 
    if (this.isNew) { 
      this.contactGateway.create(this.contact)  
        .then(() => this.router.navigateToRoute('contacts')); 
    } 
    else { 
      this.contactGateway.update(this.contact.id, this.contact)  
        .then(() => this.router.navigateToRoute('contact-details',  
                    { id: this.contact.id })); 
    } 
  } 
} 

在这里,我们首先导入Router,并在视图模型中注入它的一个实例。接下来,我们改变save方法的主体:如果组件正在创建一个新的联系人,我们首先调用ContactGatewaycreate方法,将contact对象传递给它,然后在Promise解决时返回至contacts路由;否则,当组件正在编辑一个现有的联系人时,我们首先调用ContactGatewayupdate方法,将联系人的idcontact对象传递给它,然后在Promise解决时返回至该联系人的详情路由。

此时,你应该能够创建或更新一个联系人。然而,一些创建或更新的请求可能会返回状态码为 400 的响应,表示“坏的请求”。不必惊慌;因为 HTTP 端点会执行一些验证,而我们的表单目前不会,所以这种情况是预料之中的,例如,如果你留下了一些字段是空的。我们将在本章后面为我们的表单添加验证,这将防止这类错误的发生。

上传联系人的照片

既然我们能够创建和编辑联系人,现在让我们添加一个组件来上传其照片。这个组件将被命名为contact-photo,并通过我们已经在本章早些时候添加的具有相同名称的路由来激活。

这个组件将使用一个file input元素让用户从他的文件系统中选择一个图片文件,并将利用 HTML5 文件 API 以及 Fetch 客户端将选定的图片文件发送到我们的 HTTP 端点。

构建模板

这个组件的模板简单地重用了我们已经在前面讲解过的几个概念:

src/contact-photo.html

 <template> 
  <section class="container"> 
    <h1>${contact.fullName}</h1> 

    <form class="form-horizontal" submit.delegate="save()"> 
      <div class="form-group"> 
        <label class="col-sm-3 control-label" for="photo">Photo</label> 
        <div class="col-sm-9"> 
          <input type="file" id="photo" accept="image/*"  
                 files.bind="photo"> 
        </div> 
      </div> 

      <div class="form-group"> 
        <div class="col-sm-9 col-sm-offset-3"> 
          <button type="submit" class="btn btn-success">Save</button> 
          <a class="btn btn-danger" route-href="route: contact-details;  
             params.bind: { id: contact.id }">Cancel</a> 
        </div> 
      </div> 
    </form> 
  </section> 
</template> 

在这里,我们首先将联系人的fullName作为页面标题显示出来。然后,在一个form元素中,其submit事件会触发一个save方法,我们添加了一个file input和两个按钮,用于保存取消上传照片。file input有一个accept属性,迫使浏览器的文件选择对话框只显示图片文件,并且它的files属性被绑定到photo属性。

创建视图模型

视图模型与contact-edition视图模型非常相似,至少在比较导入、构造函数和activate方法时是这样的:

src/contact-photo.js

import {inject} from 'aurelia-framework'; 
import {Router} from 'aurelia-router'; 
import {ContactGateway} from './contact-gateway'; 

@inject(ContactGateway, Router) 
export class ContactPhoto { 

  constructor(contactGateway, router) { 
    this.contactGateway = contactGateway; 
    this.router = router; 
  } 

  activate(params, config) { 
    return this.contactGateway.getById(params.id).then(contact => { 
      this.contact = contact; 
      config.navModel.setTitle(this.contact.fullName); 
    }); 
  } 
  save() { 
    if (this.photo && this.photo.length > 0) { 
      this.contactGateway.updatePhoto( 
        this.contact.id,  
        this.photo.item(0) 
      ).then(() => { 
        this.router.navigateToRoute( 
          'contact-details',  
          { id: this.contact.id }); 
      }); 
    } 
  } 
} 

这个视图模型期望在其构造函数中注入ContactGateway的一个实例和Router的一个实例。在其activate方法中,它然后使用其id参数加载一个Contact实例,并使用contactfullName初始化文档标题。这与contact-edition视图模型非常相似。

save方法有一点不同。它首先检查是否已经选择了文件;如果没有,现在什么也不做。否则,它调用ContactGatewayupdatePhoto方法,将联系人的id和选定的文件传递给它,并在Promise解决时返回到联系人的详细信息。

使用 fetch 上传文件

使我们的照片上传功能正常工作的最后一步是在ContactGateway类中的uploadPhoto方法:

src/contact-gateway.js

//Omitted snippet... 
export class ContactGateway { 
  //Omitted snippet... 
  updatePhoto(id, file) { 
    return this.httpClient.fetch(`contacts/${id}/photo`, {  
      method: 'PUT', 
      headers: { 'Content-Type': file.type }, 
      body: file 
    }); 
  } 
} 

我们 HTTP 后端的contacts/{id}/photo端点期望一个 PUT 请求,带有正确的Content-Type头和图像二进制作为其主体。这正是这里fetch调用的作用:它使用file参数,这被期望是一个File类的实例,并使用它的type属性设置Content-Type头,然后将file本身作为请求体发送。

如早先所述,File类是 HTML5 文件 API 的一部分。Mozilla 开发者网络提供了关于这个 API 的详尽文档。关于File类的细节可以在developer.mozilla.org/en-US/docs/Web/API/File找到。

像往常一样,updatePhoto方法返回由 HTTP 请求解决的Promise,所以调用者可以在操作完成时采取行动。

至此,你应该能够运行应用程序并通过上传新图像文件来更新联系人的照片。

删除联系人

至此,我们的应用程序允许我们创建、读取和更新联系人。显然,创建、读取、更新、删除CRUD)这四个字母中有一个缺失了:我们还不能删除一个联系人。让我们快速实现这个功能。

首先,让我们在联系人的details组件中添加一个删除按钮:

src/contact-details.html

 <template> 
  <section class="container"> 
    <div class="row"> 
      <div class="col-sm-2"> 
        <!-- Omitted snippet... --> 
      </div> 
      <div class="col-sm-10"> 
        <!-- Omitted snippet... --> 
        <a class="btn btn-default" route-href="route: contact-edition;  
          params.bind: { id: contact.id }"> 
          <i class="fa fa-pencil-square-o"></i> Modify 
        </a> 
        <button class="btn btn-danger" click.delegate="tryDelete()"> 
          <i class="fa fa-trash-o"></i> Delete 
        </button> 
      </div> 
    </div> 
    <!-- Rest of template omitted for brevity --> 
  </section> 
</template> 

新的删除按钮将在点击时调用tryDelete方法:

src/contact-details.js

//Omitted snippet... 
export class ContactDetails { 
  //Omitted snippet... 
  tryDelete() { 
    if (confirm('Do you want to delete this contact?')) { 
      this.contactGateway.delete(this.contact.id) 
        .then(() => { this.router.navigateToRoute('contacts'); }); 
    } 
  } 
} 

tryDelete方法首先要求用户进行确认删除,然后使用联系人的id调用网关的delete方法。当返回的Promise解决时,它返回到联系人列表。

最后,ContactGateway类的delete方法只是执行一个到后端适当路径的 Fetch 调用,使用DELETEHTTP 方法:

src/contact-gateway.js

//Omitted snippet... 
export class ContactGateway { 
  //Omitted snippet... 
  delete(id) { 
    return this.httpClient.fetch(`contacts/${id}`, { method: 'DELETE' }); 
  } 
} 

至此,如果你点击一个联系人的删除按钮并批准确认对话框,你应该会被重定向到联系人列表,并且联系人应该消失了。

验证

如果你尝试保存一个生日无效、电话号码为空、地址、电子邮件或社交资料用户名为空的联系人,而你的浏览器的调试控制台是打开的,你会看到 HTTP 端点用 400 Bad Request 响应拒绝这个请求。这是因为后端在对创建或更新的联系人执行一些验证。

拥有一个执行某种验证的远程服务是很常见的;相反的,实际上被认为是糟糕的架构,因为远程服务不应该信任其客户端的有效数据。然而,为了提供更佳的用户体验,通常也会看到客户端应用程序也执行验证。

Aurelia 提供了aurelia-validation库,该库为验证提供者定义了一个接口,以及将验证插入组件的各种机制。它还提供了这个接口的默认实现,提供了一个简单而强大的验证机制。

让我们看看我们如何使用这些库来验证我们的联系人表单。

这一节只是对aurelia-validation提供的最常见特性的概述。实际上,这个库比这里描述的要灵活得多,功能也更强大,所以我在阅读这本书后邀请你进一步挖掘它。

安装库

要安装这个库,你只需要在项目的目录下运行以下命令:

> npm install aurelia-validation --save

接下来,我们需要使这个库在应用程序的包中可用。在aurelia_project/aurelia.json中,在build下的bundles中,在名为vendor-bundle.js的包的dependencies数组中,添加以下条目:

{ 
  "name": "aurelia-validation", 
  "path": "../node_modules/aurelia-validation/dist/amd", 
  "main": "aurelia-validation" 
}, 

这个配置项将告诉 Aurelia 的打包器将新安装的库包含在供应商包中。

配置

aurelia-validation库在使用前需要一些配置。此外,作为一个 Aurelia 插件,它需要在我们的应用程序启动时加载。

我们可以在我们主要的configure函数里完成这一切。然而,这种情况真的是一个很好的 Aurelia 特性的候选。如果你记得的话,特性类似于插件,只不过它们是在应用程序本身内定义的。通过引入一个validation特性,我们可以隔离验证的配置,这会给我们一个可以放置额外服务和自定义验证规则的中央位置。

让我们先创建我们的validation特性:

src/validation/index.js

export function configure(config) { 
  config 
    .plugin('aurelia-validation'); 
} 

我们新特性的configure函数只是加载了aurelia-validation插件。

接下来,我们需要在我们主要的configure函数中加载这个特性:

src/main.js 
//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .feature('validation') 
    .feature('resources'); 
  //Omitted snippet... 
} 

在这里,我们只是链接了引导式 API 的feature方法的额外调用,以加载我们的validation特性。

验证联系人表单

既然一切配置都正确,那就让我们在我们的contact-edition表单中添加验证吧。

设置模板

为了告诉验证机制需要验证什么,所有用于获取待验证用户输入的双向绑定都必须用validate绑定行为装饰,这由aurelia-validation提供:

src/contact-edition.html

 <template> 
  <!-- Omitted snippet... -->   
  <input type="text" class="form-control"  
         value.bind="contact.firstName & validate"> 
  <!-- Omitted snippet... --> 
  <input type="text" class="form-control"  
         value.bind="contact.birthday & validate"> 
  <!-- Omitted snippet... --> 
  <textarea class="form-control"  
            value.bind="contact.note & validate"></textarea> 
  <!-- Omitted snippet... --> 
  <select value.bind="phoneNumber.type & validate" class="form-control"> 
  <!-- Omitted snippet... --> 
  <input type="tel" class="form-control" placeholder="Phone number"  
         value.bind="phoneNumber.number & validate"> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们在每个双向绑定中添加了validate绑定行为。代码片段没有展示contact-edition表单的所有绑定;我留给读者一个练习,即在模板中所有inputtextareaselect元素的value属性上添加validate。本书的示例应用程序可以作为参考。

validate绑定行为有两个任务。首先,它将绑定指令注册到ValidationController,该控制器为给定组件组织验证,所以验证机制知道指令绑定的属性,并在需要时对其进行验证。其次,它可以连接到绑定指令,所以绑定到元素的属性可以在元素的目标值变化时立即验证。

使用 ValidationController

ValidationController在验证过程中扮演着指挥者的角色。它跟踪一组需要验证的绑定,提供方法手动触发验证,并记录当前的验证错误。

为了利用ValidationController,我们首先必须在组件中注入一个实例:

src/contact-edition.js

import {inject, NewInstance} from 'aurelia-framework'; 
import {ValidationController} from 'aurelia-validation'; @inject(ContactGateway, NewInstance.of(ValidationController), Router) 
export class ContactEdition { 

  constructor(contactGateway, validationController, router) { 
    this.contactGateway = contactGateway; 
    this.validationController = validationController; 
    this.router = router; 
  } 
  //Omitted snippet... 
} 

在这里,我们向视图模型中注入了一个全新的ValidationController实例。使用NewInstance解析器很重要,因为默认情况下,DI 容器认为所有服务都是应用程序单例,而我们确实希望每个组件都有一个独特的实例,以便在验证时它们可以被孤立考虑。

接下来,我们只需要确保在保存任何联系人之前表单是有效的:

src/contact-edition.js

//Omitted snippet... 
export class ContactEdition { 
  //Omitted snippet... 
  save() { 
    this.validationController.validate().then(errors => { 
 if (errors.length > 0) { 
 return; 
 } 
      //Omitted call to create or update... 
    } 
  } 
} 

在这里,我们将调用网关的createupdate方法的代码封装起来,以便在验证(完成且没有错误时)执行:

validate方法返回一个Promise,该Promise用验证错误数组解决。这意味着验证规则可以是异步的。例如,自定义规则可以执行 HTTP 调用到后端以检查数据唯一性或执行进一步的数据验证,validate方法的返回Promise将在 HTTP 调用完成时解决。

如果异步规则的Promise被拒绝,例如 HTTP 调用失败,validate返回的Promise也将被拒绝,所以当使用此类异步、远程验证规则时,确保在这个层次上处理拒绝,这样用户就知道发生了什么。

添加验证规则

此时,验证已经准备就绪,但不会做任何事情,因为我们还没有在模型上定义任何验证规则。让我们从Contact类开始:

src/models.js

import {ValidationRules} from 'aurelia-validation'; 
// Omitted snippet... 

export class Contact { 
  // Omitted snippet... 

  constructor() { 
 ValidationRules 
 .ensure('firstName').maxLength(100) 
 .ensure('lastName').maxLength(100) 
 .ensure('company').maxLength(100) 
 .ensure('birthday') 
 .satisfies((value, obj) => value === null || value === undefined 
 || value === '' || !isNaN(Date.parse(value))) 
 .withMessage('${$displayName} must be a valid date.') 
 .ensure('note').maxLength(2000) 
 .on(this); 
 } 

  //Omitted snippet... 
} 

在这里,我们使用aurelia-validation的流式 API,为Contact的某些属性添加验证规则:firstNamelastNamecompany属性的长度不能超过 100 个字符,note属性的长度不能超过 2000 个字符。

此外,我们使用satisfies方法为birthday属性定义内联的自定义规则。这个规则确保birthday只有在它是空值或可以解析为有效Date对象的字符串时才是有效的。我们还使用withMessage方法指定当我们的自定义规则被违反时应显示的错误消息模板。

消息模板使用与 Aurelia 的模板引擎相同的字符串插值语法,并且可以使用一个名为$displayName的上下文变量,它包含正在验证的属性的显示名称。

注意

自定义验证规则应始终接受空值。这是为了保持关注点的分离;required规则已经负责拒绝空值,所以你的自定义规则应只关注其自己的特定验证逻辑。这样,开发者可以根据他们想做什么,选择性地使用你的自定义规则,或不与required一起使用。

最后,on方法将刚刚构建的规则集附加到Contact实例的元数据中。这样,当验证Contact对象的属性时,验证过程可以检索应适用的验证规则。

我们还需要为Contact中代表列表项的所有类添加验证规则:

src/models.js

//Omitted snippet... 

export class PhoneNumber { 
  //Omitted snippet... 

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('number').required().maxLength(25) 
 .on(this); 
 } 

  //Omitted snippet... 
} 

export class EmailAddress { 
  //Omitted snippet...   

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('address').required().maxLength(250).email() 
 .on(this); 
 } 

  //Omitted snippet...   
} 

export class Address { 
  //Omitted snippet... 

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('number').required()maxLength(100) 
 .ensure('street').required().maxLength(100) 
 .ensure('postalCode').required().maxLength(25) 
 .ensure('city').required().maxLength(100) 
 .ensure('state').maxLength(100) 
 .ensure('country').required().maxLength(100) 
 .on(this); 
 } 

  //Omitted snippet... 
} 

export class SocialProfile { 
  //Omitted snippet...   

  constructor() { 
 ValidationRules 
 .ensure('type').required().maxLength(25) 
 .ensure('username').required().maxLength(100) 
 .on(this); 
 } 

  //Omitted snippet...   
} 

在这里,我们将每个属性设置为required,并为它们指定最大长度。此外,我们确保EmailAddress类的address属性是一个有效的电子邮件地址。

渲染验证错误

此时,如果我们的表单无效,save方法不会向后端发送任何 HTTP 请求,这是正确的行为。然而,它仍然不显示任何错误消息。让我们看看如何向用户显示验证错误。

错误属性

控制器有一个errors属性,其中包含当前的验证错误。这个属性可以用来,例如,渲染一个验证摘要:

src/contact-edition.html

<template>   
  <!-- Omitted snippet... --> 
  <form class="form-horizontal" submit.delegate="save()"> 
    <ul class="col-sm-9 col-sm-offset-3 list-group text-danger" 
        if.bind="validationController.errors"> 
      <li repeat.for="error of validationController.errors"  
          class="list-group-item"> 
        ${error.message} 
      </li> 
    </ul> 
    <!-- Omitted snippet... --> 
  </form> 
</template> 

在这个例子中,我们添加了一个无序列表,它将在验证控制器有错误时渲染。在这个列表内部,我们为每个error重复一个列表项。在每个列表项中,我们渲染错误的message

验证错误属性

使用validation-errors自定义属性,也可以检索到不是所有的验证错误,而是只检索来自更窄范围的错误。

当添加到给定元素时,此属性会收集其宿主元素下所有验证过的绑定指令的验证错误,并使用双向绑定将这些错误分配给它所绑定的属性。

例如,让我们从上一个示例中移除验证摘要,并使用validation-errors属性为表单中的特定字段渲染错误:

src/contact-edition.html

<template> 
  <!-- Omitted snippet... --> 
  <div validation-errors.bind="birthdayErrors"  
       class="form-group ${birthdayErrors.length ? 'has-error' : ''}"> 
    <label class="col-sm-3 control-label">Birthday</label> 
    <div class="col-sm-9"> 
      <input type="text" class="form-control"  
             value.bind="contact.birthday & validate"> 
      <span class="help-block" repeat.for="errorInfo of birthdayErrors"> 
 ${errorInfo.error.message} 
 <span> 
    </div> 
  </div> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们在包含birthday属性的form-group div中添加了validation-errors属性,我们将其绑定到新的birthdayErrors属性。如果birthday有任何错误,我们还向form-group div添加了has-error CSS 类。最后,我们添加了一个help-block span,它针对birthdayErrors数组中的每个错误重复出现,并显示错误的message

创建自定义 ValidationRenderer

validation-errors属性允许我们在模板中显示特定区域的错误。然而,如果我们必须为表单中的每个属性添加此代码,这将很快变得繁琐且无效。幸运的是,aurelia-validation提供了一个机制,可以在一个名为验证渲染器的专用服务中提取此逻辑。

验证渲染器是一个实现render方法的类。这个方法以其第一个参数接收到一个验证渲染指令对象。这个指令对象包含了关于应显示哪些错误和哪些应移除的信息。它基本上是前一次和当前验证状态之间的差异,因此渲染器知道它必须对 DOM 中显示的错误消息应用哪些更改。

在撰写本文时,Aurelia 中还没有可用的验证渲染器。很可能一些社区插件很快就会提供针对主要 CSS 框架的渲染器。与此同时,让我们自己实现这个功能:

src/validation/bootstrap-form-validation-renderer.js

export class BootstrapFormValidationRenderer { 

  render(instruction) { 
    for (let { error, elements } of instruction.unrender) { 
      for (let element of elements) { 
        this.remove(element, error); 
      } 
    } 

    for (let { error, elements } of instruction.render) { 
      for (let element of elements) { 
        this.add(element, error); 
      } 
    } 
  } 
} 

在这里,我们导出一个名为BootstrapFormValidationRenderer的类,其中包含一个render方法。这个方法简单地遍历instruction的错误来进行unrender,然后遍历每个错误elements,并调用一个remove方法(我们马上就会写)。接下来,它遍历instruction的错误来进行render,然后遍历每个错误elements,并调用一个add方法。

接下来,我们需要告诉我们的类如何显示验证错误,通过编写我们的验证渲染器类中的add方法:

add(element, error) { 
  const formGroup = element.closest('.form-group'); 
  if (!formGroup) { 
    return; 
  } 

  formGroup.classList.add('has-error'); 

  const message = document.createElement('span'); 
  message.className = 'help-block validation-message'; 
  message.textContent = error.message; 
  message.id = `bs-validation-message-${error.id}`; 
  element.parentNode.insertBefore(message, element.nextSibling); 
} 

在这里,我们检索到离承载绑定指令触发错误的元素的form-group CSS 类最近的元素,并向其添加has-error CSS 类。接下来,我们创建一个help-block span,它将包含错误的message。我们还设置其id属性使用错误的id,这样在需要删除时可以轻松找到它。最后,我们将这个消息元素插入 DOM,紧随触发错误的元素之后。

为了完成我们的渲染器,让我们编写一个将删除先前渲染的验证错误的方法:

remove(element, error) { 
  const formGroup = element.closest('.form-group'); 
  if (!formGroup) { 
    return; 
  } 

  const message = formGroup.querySelector( 
    `#bs-validation-message-${error.id}`); 
  if (message) { 
    element.parentNode.removeChild(message); 
    if (formGroup.querySelectorAll('.help-block.validation-message').length  
        === 0) {     
      formGroup.classList.remove('has-error'); 
    } 
  } 
} 

在这里,我们首先获取到离触发错误的绑定说明的宿主元素最近的具有form-group类的元素。然后我们使用错误的id获取消息元素,并将其从 DOM 中移除。最后,如果form-group不再包含任何错误消息,我们将其has-error类移除。

我们的验证渲染器现在必须通过依赖注入容器向应用程序提供。逻辑上,我们会在我们validation特性的configure函数中进行此操作:

src/validation/index.js

//Omitted snippet... 
import {BootstrapFormValidationRenderer} 
 from './bootstrap-form-validation-renderer'; 

export function configure(config) { 
  config.plugin('aurelia-validation'); 
  config.container.registerHandler( 
 'bootstrap-form', 
 container => container.get(BootstrapFormValidationRenderer)); 
} 

在这里,我们以bootstrap-form的名称注册我们的验证渲染器。我们可以在我们的contact-edition表单中使用这个名称,告诉验证控制器应该使用这个渲染器来显示form的验证错误:

src/contact-edition.html

<template> 
  <!-- Omitted snippet... --> 
  <form class="form-horizontal" submit.delegate="save()" 
        validation-renderer="bootstrap-form"> 
    <!-- Omitted snippet... --> 
  </form> 
  <!-- Omitted snippet... --> 
</template> 

validation-renderer属性将根据提供的值解析我们的BootstrapFormValidationRenderer实例,并将其注册到当前的验证控制器。然后控制器会在验证状态发生更改时通知渲染器,以便可以渲染新的错误并移除已解决的错误。

注意

使用字符串键注册渲染器使得可以注册多个不同名称的验证渲染器,因此不同的渲染器可以在不同的表单中使用。

更改验证触发器

默认情况下,当元素失去焦点时验证属性,但是可以通过设置控制器的validateTrigger属性来更改这种行为:

src/contact-edition.js

import {ValidationController, validateTrigger} from 'aurelia-validation'; 
// Omitted snippet... 
export class ContactEdition { 
  constructor(contactGateway, validationController, router) { 
    validationController.validateTrigger = validateTrigger.change; 
    // Omitted snippet... 
  } 
} 

在这里,我们首先导入validateTrigger枚举,并告诉ValidationController当它们绑定的元素的值发生变化时应该重新验证属性。

除了changevalidateTrigger枚举还有另外三个值:

  • blur:当绑定说明的宿主元素失去焦点时验证属性。这是默认值。

  • changeOrBlur:当绑定说明发生变化时或当宿主元素失去焦点时验证属性。它基本上结合了changeblur两种行为。

  • manual:完全禁用自动验证。在这种情况下,只有调用控制器的validate方法,如我们在save方法中所做的那样,才能触发验证,并且它一次性对所有注册的绑定进行验证。

当然,即使validateTriggerblurchangeblurOrChange,显式调用validate方法总是执行验证。

创建自定义 ValidationRules

aurelia-validation库可以轻松添加自定义验证规则。为了说明这一点,我们首先将应用于Contactbirthday属性的规则移动到一个可重用的date验证规则中。然后,我们还将向我们的联系人照片上传组件添加验证,这需要一些自定义规则来验证文件。

验证日期

让我们先创建一个文件,该文件将声明并注册我们各种自定义规则:

src/validation/rules.js

import {ValidationRules} from 'aurelia-validation'; 

ValidationRules.customRule( 
  'date',  
  (value, obj) => value === null || value === undefined || value === ''  
                  || !isNaN(Date.parse(value)),  
  '${$displayName} must be a valid date.' 
); 

这个文件没有导出任何内容。它只是导入了ValidationRules类,并使用其customRule静态方法注册了一个新的date规则,该规则重用了我们在Contact类中之前定义的准则和消息。

接下来,我们需要在某个地方导入这个文件,以便注册规则并将其提供给应用程序。最好在validation功能的configure函数中执行此操作:

src/validation/index.js

import './rules'; 
//Omitted snippet... 

通过导入rules文件,date自定义规则被注册,因此一旦通过 Aurelia 导入validation功能,它就可以使用。

最后,我们现在可以更改Contactbirthday属性的ValidationRules,使其使用这个规则:

src/models.js

//Omitted snippet... 
export class Contact { 
  //Omitted snippet... 

  constructor() { 
    ValidationRules 
      .ensure('firstName').maxLength(100) 
      .ensure('lastName').maxLength(100) 
      .ensure('company').maxLength(100) 
 .ensure('birthday').satisfiesRule('date') 
      .ensure('note').maxLength(2000) 
      .on(this); 
  } 

  //Omitted snippet... 
} 
//Omitted snippet... 

在这里,我们简单地移除了对birthday属性的satisfies调用,并将其替换为对satisfiesRule的调用,该调用期望规则名称作为其第一个参数。

验证文件是否被选择

在这一点上,如果未选择任何文件,联系人照片上传组件在用户点击保存按钮时不会做任何事情。我们在验证方面可以做的第一件事是确保已选择文件。因此,我们将创建一个名为notEmpty的新规则,以确保验证的值有一个length属性大于零:

src/validation/rules.js

//Omitted snippet... 
ValidationRules.customRule( 
  'notEmpty', 
  (value, obj) => value && value.length && value.length > 0, 
  '${$displayName} must contain at least one item.' 
); 

在这里,我们使用ValidationRules类的customRule静态方法全局注册我们的验证规则。此方法期望以下参数:

  • 规则的名称。它必须是唯一的。

  • 条件函数,它接收值和(如果有)父对象。如果规则得到满足,它预期返回true,如果规则被违反,则返回false。它还可以返回一个Promise,其解析结果为boolean

  • 错误消息模板。

这个规则能够与具有length属性的任何值一起工作。例如,它可以用于数组或FileList实例。

验证文件大小

接下来,我们将创建一个验证规则,以确保FileList实例中的所有文件重量小于最大尺寸:

src/validation/rules.js

//Omitted snippet... 
ValidationRules.customRule( 
  'maxFileSize', 
  (value, obj, maximum) => !(value instanceof FileList) 
    || value.length === 0 
    || Array.from(value).every(file => file.size <= maximum), 
  '${$displayName} must be smaller than ${$config.maximum} bytes.', 
  maximum => ({ maximum }) 
); 

在这里,我们首先定义一个新的maxFileSize验证规则,确保FileList中的每个文件的大小不超过给定的maximum。该规则仅在值为FileList实例且FileList不为空时适用。

此规则期望一个maximum参数。使用此类规则时,传递给satisfiesRule流畅方法的任何参数都将传递给底层的条件函数,以便它使用它来评估条件。然而,为了对消息模板可用,规则参数必须在单个对象中聚合。因此,customRule可以传递第四个参数,预期是一个函数,它将规则参数聚合到单个对象中。此对象随后作为$config对消息模板可用。

这就是我们在maxFileSize规则中所看到的;它期望以一个名为maximum的参数被调用,这是以字节为单位的最大文件大小。当向属性添加规则时,此参数预期传递给satisfiesRule方法:

ValidationRules.ensure('photo').satisfiesRule('maxFileSize', 1024); 

此参数随后传递给条件函数,以便可以验证FileList实例中所有文件的大小。它还传递给聚合函数,该函数返回一个包含maximum属性的对象。此对象随后作为$config对消息模板可用,因此模板可以在错误消息中显示maximum

在这里,我们的自定义规则只有一个参数,但一个规则可以有尽可能多的参数。它们都将以相同的顺序传递给条件函数和聚合函数,顺序与传递给satisfiesRule的顺序相同。

验证文件扩展名

最后,让我们创建一个规则,以确保FileList实例中的所有文件扩展名都在特定的一组值中:

src/validation/rules.js

//Omitted snippet... 
function hasOneOfExtensions(file, extensions) { 
  const fileName = file.name.toLowerCase(); 
  return extensions.some(ext => fileName.endsWith(ext)); 
} 

function allHaveOneOfExtensions(files, extensions) { 
  extensions = extensions.map(ext => ext.toLowerCase()); 
  return Array.from(files) 
    .every(file => hasOneOfExtensions(file, extensions)); 
} 

ValidationRules.customRule( 
  'fileExtension', 
  (value, obj, extensions) => !(value instanceof FileList) 
    || value.length === 0 
    || allHaveOneOfExtensions(value, extensions), 
  '${$displayName} must have one of the following extensions: '  
    + '${$config.extensions.join(', ')}.', 
  extensions => ({ extensions }) 
); 

此规则名为fileExtension,期望一个文件扩展名数组作为参数,并确保FileList中的所有文件名以扩展名之一结尾。与maxFileSize一样,仅当验证的值是一个不为空的FileList实例时,它才适用。

验证联系照片选择器

既然我们已经定义了验证联系照片组件所需的所有规则,让我们像对contact-edition组件一样设置视图模型:

  1. ContactPhoto视图模型中注入ValidationControllerNewInstance

  2. save中显式调用validate方法,如果有任何验证错误,则省略调用updatePhoto

  3. contact-photo.html模板中的form元素添加validation-renderer="bootstrap-form"属性

  4. validate绑定行为添加到file inputfiles属性的绑定

这些任务与我们对contact-edition组件已经完成的任务相同,我将留给读者作为练习。

接下来,我们需要向视图模型的photo属性添加验证规则:

src/contact-photo.js

import {ValidationController, ValidationRules} from 'aurelia-validation'; 
//Omitted snippet... 
export class ContactPhoto { 
  //Omitted snippet... 

  constructor(contactGateway, router, validationController) { 
    //Omitted snippet... 
    ValidationRules 
      .ensure('photo') 
        .satisfiesRule('notEmpty') 
          .withMessage('${$displayName} must contain 1 file.') 
        .satisfiesRule('maxFileSize', 1024 * 1024 * 2) 
        .satisfiesRule('fileExtension', ['.jpg', '.png']) 
      .on(this); 
  } 

  //Omitted snippet... 
} 

在这里,我们告诉验证控制器photo必须至少包含一个文件,这个文件必须是 JPEG 或 PNG,并且最大不超过 2 MB。我们还使用withMessage方法定制当没有选择文件时显示的消息。

如果您测试这个,它应该能正常工作。然而,验证在file input失去焦点时触发,使得可用性有些奇怪。为了在用户关闭浏览器的文件选择对话框时立即验证表单,从而立即显示可能的错误消息,让我们将验证控制器的validateTrigger更改为change

src/contact-photo.js

import {ValidationController, ValidationRules, validateTrigger}  
  from 'aurelia-validation'; 
//Omitted snippet... 
export class ContactPhoto { 
  //Omitted snippet... 

  constructor(contactGateway, router, validationController) { 
    validationController.validateTrigger = validateTrigger.change; 
    //Omitted snippet... 
  } 

  //Omitted snippet... 
} 

如果您在做出此更改后进行测试,您应该发现可用性得到了很大改善,因为文件在用户关闭文件选择对话框时就会进行验证。

编辑复杂结构

在前几节中,我们创建了一个表单,用于编辑项目列表(如电话号码、电子邮件地址、地址和社会资料等),这种策略称为内联编辑。表单包括每个列表项的输入元素。这种策略将用户编辑或添加新列表项所需的点击次数降到最低,因为用户可以直接在表单中编辑所有列表项的所有字段。

然而,当表单需要管理更复杂项目的列表时,一个解决方案是只显示列表中最具相关性的信息作为只读,并使用模态对话框创建或编辑项目。对话框为单个项目显示复杂表单提供了更多的空间。

aurelia-dialog插件暴露了一个对话框功能,我们可以利用它来创建模态编辑器。为了说明这一点,我们将克隆我们的联系人管理应用程序,并更改contact-edition组件,使其使用对话框编辑而不是列表项的内联编辑。

以下代码片段是chapter-4/samples/list-edition-models的摘录。

安装对话框插件

要安装aurelia-dialog插件,只需在项目目录中打开一个控制台,并运行以下命令:

> npm install aurelia-dialog --save 

安装完成后,我们还需要将插件添加到供应商包配置中。为此,请打开aurelia_project/aurelia.json,然后在build下的bundles中,在名为vendor-bundle.js的包的dependencies数组中添加以下条目:

{ 
  "name": "aurelia-dialog", 
  "path": "../node_modules/aurelia-dialog/dist/amd", 
  "main": "aurelia-dialog" 
}, 

最后,我们需要在我们的主configure函数中加载插件:

src/main.js

//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .plugin('aurelia-dialog') 
    .feature('validation') 
    .feature('resources'); 
  //Omitted snippet... 
} 

此时,aurelia-dialog暴露的服务和组件已准备好在我们的应用程序中使用。

创建编辑对话框

对话框插件使用组合来将组件作为对话框显示。这意味着下一步是创建将用于编辑新或现有项目的组件。

由于无论编辑的项目类型如何,对话框编辑的背后的行为都将相同,我们将创建一个单一的视图模型,我们将在电话号码、电子邮件地址、地址和社会资料等项目中重复使用:

src/dialogs/edition-dialog.js

import {inject, NewInstance} from 'aurelia-framework'; 
import {DialogController} from 'aurelia-dialog'; 
import {ValidationController} from 'aurelia-validation'; 

@inject(DialogController, NewInstance.of(ValidationController)) 
export class EditionDialog { 

  constructor(dialogController, validationController) { 
    this.dialogController = dialogController; 
    this.validationController = validationController; 
  } 

  activate(model) { 
    this.model = model; 
  } 

  ok() { 
    this.validationController.validate().then(errors => { 
      if (errors.length === 0) { 
        this.dialogController.ok(this.model) 
      } 
    }); 
  } 

  cancel() { 
    this.dialogController.cancel(); 
  } 
} 

在这里,我们创建了一个组件,在该组件中注入了DialogControllerValidationController类的NewInstance。接下来,我们定义了一个接收modelactivate方法,该model将是需要编辑的项目 - 电话号码、电子邮件地址、地址或社会资料。我们还定义了一个ok方法,该方法验证表单,如果没有错误,则使用更新后的model作为对话框的输出调用DialogControllerok方法。最后,我们定义了一个cancel方法,它简单地将调用委托给DialogControllercancel方法。

DialogController被注入到一个作为对话框显示的组件中时,它被用来控制显示组件的对话框。它的okcancel方法可以用来关闭对话框,并向调用者返回一个响应。这个响应随后可以被调用者用来确定对话框是否被取消以及检索其输出(如果有)。

尽管我们将为所有项目类型重用相同的视图模型类,但每个项目类型的模板必须是不同的。让我们从电话号码的对话框编辑开始:

src/dialogs/phone-number-dialog.html

<template> 
  <ai-dialog> 
    <form class="form-horizontal" validation-renderer="bootstrap-form"  
          submit.delegate="ok()"> 
      <ai-dialog-body> 
        <h2>Phone number</h2> 
        <div class="form-group"> 
          <div class="col-sm-2"> 
            <label for="type">Type</label> 
          </div> 
          <div class="col-sm-10"> 
            <select id="type" value.bind="model.type & validate"  
                    attach-focus="true" class="form-control"> 
              <option value="Home">Home</option> 
              <option value="Office">Office</option> 
              <option value="Mobile">Mobile</option> 
              <option value="Other">Other</option> 
            </select> 
          </div> 
        </div> 
        <div class="form-group"> 
          <div class="col-sm-2"> 
            <label for="number">Number</label> 
          </div> 
          <div class="col-sm-10"> 
            <input id="number" type="tel" class="form-control"  
                   placeholder="Phone number"  
                   value.bind="model.number & validate"> 
          </div> 
        </div> 
      </ai-dialog-body>
<ai-dialog-footer> 
        <button type="submit" class="btn btn-primary">Ok</button> 
        <button class="btn btn-danger"  
                click.trigger="cancel()">Cancel</button> 
      </ai-dialog-footer> 
    </form> 
  </ai-dialog> 
</template> 

这里值得注意的是ai-dialogai-dialog-bodyai-dialog-footer元素,它们是 Aurelia 对话框的容器。此外,select元素上的attach-focus="true"属性确保当对话框显示时这个元素获得焦点。最后,formsubmit事件委托给ok方法,而点击取消按钮则委托给cancel方法。

模板的其余部分应该很熟悉。用户输入元素绑定到model的属性,这些绑定被validate绑定行为装饰,以便属性得到适当验证。

我们还需要为其他项目类型创建模板:src/dialogs/email-address-dialog.htmlsrc/dialogs/address-dialog.htmlsrc/dialogs/social-profile-dialog.html。此时,这些模板应该很容易创建。我将留给读者一个练习来编写它们;list-edition-models示例可以作为参考。

使用编辑对话框

利用我们新的视图模型和模板的最后一步是改变contact-edition组件的行为:

src/contact-edition.js

import {DialogService} from 'aurelia-dialog'; 
import {Contact, PhoneNumber, EmailAddress, Address, SocialProfile}  
  from './models'; 
//Omitted snippet... 
@inject(ContactGateway, NewInstance.of(ValidationController), Router,  
        DialogService) 
export class ContactEdition { 
  constructor(contactGateway, validationController, router, dialogService) { 
    this.contactGateway = contactGateway; 
    this.validationController = validationController; 
    this.router = router; 
    this.dialogService = dialogService; 
  } 
   //Omitted snippet... 
  _openEditDialog(view, model) { 
    return new Promise((resolve, reject) => { 
      this.dialogService.open({  
        viewModel: 'dialogs/edition-dialog', 
        view: `dialogs/${view}-dialog.html`,  
        model: model 
      }).then(response => { 
        if (response.wasCancelled) { 
          reject(); 
        } else { 
          resolve(response.output); 
        } 
      }); 
    }); 
  } 

  editPhoneNumber(phoneNumber) { 
    this._openEditDialog('phone-number',  
                         PhoneNumber.fromObject(phoneNumber)) 
      .then(result => { Object.assign(phoneNumber, result); }); 
  } 

  addPhoneNumber() { 
    this._openEditDialog('phone-number', new PhoneNumber()) 
      .then(result => { this.contact.phoneNumbers.push(result); }); 
  } 

  //Omitted snippet... 
} 

在这里,我们通过在构造函数中注入DialogService来向我们的ContactEdition视图模型添加一个新的依赖。接下来,我们定义了一个_openEditDialog方法,它定义了打开编辑对话框的通用行为。

此方法调用DialogServiceopen方法来打开一个对话框,使用edition-dialog视图模型和给定项目类型的模板,组合成一个单一组件。还传递了一个model,它将在edition-dialogactivate方法中注入。如果你阅读了第三章显示数据中的组合部分,这应该会很熟悉。

此外,该方法返回一个 Promise,当用户点击 确定 时解析,但当用户点击 取消 时拒绝。这样,在使用这个方法时,只有当用户通过点击 确定 来确认其修改时,结果的 Promise 才会被解析,否则会被拒绝。

editPhoneNumber 方法使用 _openEditDialog 方法来显示电话号码编辑对话框。要编辑的 phoneNumber 的副本作为 model 传递,因为如果我们传递原始 phoneNumber 对象,即使用户取消其修改,它也会被修改。当用户确认其修改时,Promise 解析,这时更新后的 model 属性会被回赋给原始的 phoneNumber

同样地,addPhoneNumber 方法使用了 _openEditDialog 方法,但传递了一个新的 PhoneNumber 实例作为模型。另外,当 Promise 解析时,新的电话号码会被添加到 contactphoneNumbers 数组中。

最后,模板必须更改,以便电话号码列表以只读方式显示,并为每个电话号码添加一个新的 编辑 按钮:

src/contact-edition.html

<template> 
  <!-- Omitted snippet... --> 
  <hr> 
  <div class="form-group" repeat.for="phoneNumber of contact.phoneNumbers"> 
    <div class="col-sm-2 col-sm-offset-1">${phoneNumber.type}</div> 
    <div class="col-sm-7">${phoneNumber.number}</div> 
    <div class="col-sm-1"> 
      <button type="button" class="btn btn-danger"  
              click.delegate="editPhoneNumber(phoneNumber)"> 
        <i class="fa fa-pencil"></i> Edit 
      </button> 
    </div> 
    <div class="col-sm-1"> 
      <button type="button" class="btn btn-danger"  
              click.delegate="contact.phoneNumbers.splice($index, 1)"> 
        <i class="fa fa-times"></i>  
      </button> 
    </div> 
  </div> 
  <div class="form-group"> 
    <div class="col-sm-9 col-sm-offset-3"> 
      <button type="button" class="btn btn-primary"  
              click.delegate="addPhoneNumber()"> 
        <i class="fa fa-plus-square-o"></i> Add a phone number 
      </button> 
    </div> 
  </div> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们移除了 selectinput 元素,并用字符串插值指令来显示 phoneNumbertypenumber 属性。我们还添加了一个 编辑 按钮,当点击时,调用新的 editPhoneNumber 方法。最后,我们更改了 添加 按钮,使其调用新的 addPhoneNumber 方法。

当然,对于 contact-edition 组件的视图模型和模板,以及其他项目类型的更改也必须应用相同的更改。然而,对于电子邮件地址、地址和社会资料的内联编辑策略的更改,现在对您来说应该是很直接的;我将把这个留给读者作为一个练习。

总结

使用 Aurelia 创建表单很简单,主要是利用双向绑定。验证表单也很容易,得益于验证插件。此外,验证插件的抽象层允许我们使用我们想要的验证库,尽管插件提供的默认实现已经相当强大。

在下一章中,Aurelia 的力量将真正开始变得清晰。通过利用我们迄今为止看到的内容,并添加自定义属性、自定义元素和内容投射到混合中,我们将能够创建极其强大、可重用和可扩展的组件,将它们组合成模块化和可测试的应用程序。当然,在覆盖这些主题的同时,我们将对我们的联系人管理应用程序进行大量重构,以提取组件和可重用行为,同时添加在没有自定义元素和属性时无法实现的特性。

第五章:制作可重用组件

Aurelia 是为了可重用性和组合性而构建的。因此,其模板引擎不仅支持组件的组合,还支持自定义 HTTP 元素和属性,在 Aurelia 的术语中称为 HTML 行为。实际上,我们在模板中使用的资源,如ifrepeatshowfocuscomposerouter-view,并不是框架中内置的特殊构建,而是使用与我们编写自己的自定义 HTML 行为相同的 API 编写的实际 HTML 行为。

在本章中,我们将了解组合与自定义元素的区别,以及每种技术的优缺点,并探讨在哪种场景下一方比另一方更适合。接下来,我们将查看如何创建自定义属性和自定义元素,以及我们可以它们做什么。最后,我们将了解如何自定义 Aurelia 的视图位置约定。

组合

组合是 Aurelia 应用程序中组装组件的最简单方法,也是最有限的方法。其主要目的是在其他上下文中重用现有的组件和模板。组合只适用于简单的重用场景,其中情况与预期使用相差不大。与 HTML 行为相比,组合的灵活性非常有限。

在以下部分中,我们将通过重构我们的联系人管理应用程序来了解组合的各种可能性和局限性。我们将从我们的contact-edition组件中提取联系人创建行为,放入一个新的contact-creation组件中。这样做是为了实现更清晰的设计,使我们的新组件具有更专注的责任。然而,由于联系人表单本身在两种上下文中都是相同的,我们将看到各种提取这个公共模板和行为并在这两个组件中重用它们的方法。

拆分联系人编辑组件

首先,我们从contact-edition组件中移除所有与联系人创建相关的引用:

src/contact-edition.js

//Omitted snippet... 
export class ContactEdition { 
  //Omitted snippet... 

  activate(params, config) { 
    return this.contactGateway.getById(params.id).then(contact => { 
      this.contact = contact; 
      config.navModel.setTitle(this.contact.fullName); 
    }); 
  } 

  save() { 
    return this.validationController.validate().then(errors => { 
      if (errors.length > 0) { 
        return; 
      } 

      return this.contactGateway.update(this.contact.id, this.contact) 
        .then(() => this.router.navigateToRoute('contact-details', { id: this.contact.id })); 
    }); 
  } 
} 

在这里,我们简单地移除了isNew属性,以及在activatesave方法中使用它的if语句和相关代码分支。

同样的事情也适用于模板:

src/contact-edition.html

<template> 
  <section class="container"> 
    <h1>Contact #${contact.id}</h1> 
    <!-- Omitted snippet --> 
    <div class="form-group"> 
        <div class="col-sm-9 col-sm-offset-3"> 
          <button type="submit" class="btn btn-success">Save</button> 
          <a class="btn btn-danger" route-href="route: contact-details;
params.bind: { id: contact.id }">Cancel</a> 
        </div> 
      </div> 
    </form> 
  </section> 
</template> 

在这里,我们简单地移除了创建新组件时显示的静态标题和取消按钮,基本上就是当isNewtrue时显示的所有模板部分。

接下来,让我们创建一个新的contact-creation组件:

src/contact-creation.js

import {inject, NewInstance} from 'aurelia-framework'; 
import {ValidationController} from 'aurelia-validation'; 
import {Router} from 'aurelia-router'; 
import {ContactGateway} from './contact-gateway'; 
import {Contact} from './models'; 

@inject(ContactGateway, NewInstance.of(ValidationController), Router) 
export class ContactCreation { 

  contact = new Contact(); 

  constructor(contactGateway, validationController, router) { 
    this.contactGateway = contactGateway; 
    this.validationController = validationController; 
    this.router = router; 
  } 

  save() { 
    return this.validationController.validate().then(errors => { 
      if (errors.length > 0) { 
        return; 
      } 

      return this.contactGateway.create(this.contact) 
        .then(() => this.router.navigateToRoute('contacts')); 
    }); 
  } 
} 

在这个新组件的视图模型中,我们简单地将一个contact属性初始化为一个新的Contact实例。此外,我们定义了一个save方法,如果没有验证错误,它将委派给ContactGatewaycreate方法,当返回的Promise解决时,导航回到联系人列表。

对于模板,我们将从表单字段本身的框架开始:

src/contact-creation.html

<template> 
  <section class="container"> 
    <h1>New contact</h1> 

    <form class="form-horizontal" validation-renderer="bootstrap-form"  
          submit.delegate="save()"> 
      <!-- The form will go here --> 

      <div class="form-group"> 
        <div class="col-sm-9 col-sm-offset-3"> 
          <button type="submit" class="btn btn-success">Save</button> 
          <a class="btn btn-danger" route-href="route: contacts">Cancel</a> 
        </div> 
      </div> 
    </form> 
  </section> 
</template> 

除了我们目前省略的表单字段外,这个模板与contact-edition模板几乎完全相同。主要区别是高亮显示的,标题是一个静态的New contact字符串,而取消按钮导航回到联系人列表而不是联系人的详细信息。

复用模板

组合性提供的一个可能性就是在一个多个上下文中复用模板。我们将通过从contact-edition.html模板中提取表单字段到其单独的模板,来说明这一点,这样我们就可以在contact-edition.htmlcontact-creation.html中都使用它:

src/contact-form.html

<template> 
  <div class="form-group"> 
    <label class="col-sm-3 control-label">First name</label> 
    <div class="col-sm-9"> 
      <input type="text" class="form-control" value.bind="contact.firstName & validate"> 
    </div> 
  </div> 

  <div class="form-group"> 
    <label class="col-sm-3 control-label">Last name</label> 
    <div class="col-sm-9"> 
      <input type="text" class="form-control" value.bind="contact.lastName & validate"> 
    </div> 
  </div> 

  <!-- Omitted company, birthday and note fields --> 

  <hr> 
  <div class="form-group" repeat.for="phoneNumber of contact.phoneNumbers"> 
    <div class="col-sm-2 col-sm-offset-1"> 
      <select value.bind="phoneNumber.type & validate" class="form-control"> 
        <option value="Home">Home</option> 
        <option value="Office">Office</option> 
        <option value="Mobile">Mobile</option> 
        <option value="Other">Other</option> 
      </select> 
    </div> 
    <div class="col-sm-8"> 
      <input type="tel" class="form-control" placeholder="Phone number"  
             value.bind="phoneNumber.number & validate"> 
    </div> 
    <div class="col-sm-1"> 
      <button type="button" class="btn btn-danger"  
              click.delegate="contact.phoneNumbers.splice($index, 1)"> 
        <i class="fa fa-times"></i> Remove 
      </button> 
    </div> 
  </div> 
  <div class="form-group"> 
    <div class="col-sm-9 col-sm-offset-3"> 
      <button type="button" class="btn btn-primary"  
              click.delegate="contact.addPhoneNumber()"> 
        <i class="fa fa-plus-square-o"></i> Add a phone number 
      </button> 
    </div> 
  </div> 

  <!-- Omitted emailAddresses, addresses and socialProfiles list editors --> 
</template> 

在此,我们从contact-edition.html中提取了字段集和列表编辑器,放入了单独的模板中。现在我们可以使用组合方法在这段模板中渲染之前的位置:

src/contact-edition.html

<template> 
  <section class="container"> 
    <h1>Contact #${contact.id}</h1> 

    <form class="form-horizontal" validation-renderer="bootstrap-form" submit.delegate="save()"> 
      <compose view="contact-form.html"></compose> 

      <!-- Omitted buttons snippet... --> 
    </form> 
  </section> 
</template> 

此外,contact-form.html模板必须在contact-creation.html模板中进行组合。我会让你将模板中的注释替换为与前一个片段相同的compose指令。

注意

不要忘记更新src/app.js中的contact-creation路由,通过将它的moduleId属性更改为'contact-creation'

一旦完成,你可以运行应用程序并测试一切是否仍然如常工作。

当使用组合方法渲染模板时,这个模板将继承周围的绑定上下文。这意味着,为了组合contact-form.html,渲染它的模板必须在其上下文中存储一个名为contactcontact对象。这是因为contact-form.html模板期望存在一个名为contact的上下文属性。

关于组合性的整个要点是,一个组件应该与其周围上下文独立。这个例子违反了这条规则。我们需要一种方法将contact对象注入到组件中。

如果我们的组件只是一个模板并且没有视图模型,我们可以在一个未命名的视图模型中注入contact对象:

<compose view="contact-form.html" view-model.bind="{ contact: contact }"></compose> 

在这里,模板引擎将创建一个对象,并将其contact属性分配为周围绑定上下文中的contact对象,然后组合引擎将这个动态视图模型与contact-form.html模板进行数据绑定。

复用组件

如果我们的组件有行为,这意味着它有一个视图模型类。因此,前一个技术不能工作,因为它将用一个匿名对象覆盖组件的视图模型,并且组件将失去其行为。

尽管现在它没有任何行为,让我们为我们的contact-form组件创建一个空的视图模型,以便我们可以说明这一点:

src/contact-form.js

export class ContactForm { 
} 

这将允许我们在contact-creation.htmlcontact-edition.html中更改compose指令,以便使用contact-form组件而不是单独的模板。为此,我们将使用compose元素的view-model属性而不是它的view属性:

<compose view-model="contact-form"></compose> 

注意compose元素现在有一个view-model属性,而不是view属性,路径中的.html文件扩展名已经被移除,所以现在它指的是整个组件,而不仅仅是模板。

然而,像这样组合后,我们的组件又回到了依赖周围上下文的contact属性的状态。我们需要将其注入。

组合引擎支持向组合组件传递模型。因此,compose元素支持model属性:

<compose view-model="contact-form" model.bind="contact"></compose> 

为了让视图模型接收这个模型,它必须实现一个名为activate的回调方法,该方法将由组合引擎调用,并传递绑定到model属性的值:

src/contact-form.js

export class ContactForm { 
  activate(contact) { 
    this.contact = contact; 
  } 
} 

此时,contact-form.html模板使用ContactForm视图模型的contact属性进行数据绑定,覆盖了周围上下文的contact。这使得更加灵活。例如,你可以注入一个与周围上下文中的contact名称不同的对象,或者在不破坏任何内容的情况下更改contact-form组件中的属性名称。这类似于在同一个函数中传递一个参数与使用来自外层作用域的变量的区别。

当然,在这种情况下,由于model属性绑定到周围上下文的contact属性,如果这个contact属性被分配了一个新值,组件将被重新组合。这意味着组件的activate方法将被用新的contact值重新调用。

使用模板作为自定义元素

由于我们的contact-form.html模板只有一个参数,即一个联系对象,因此组合是绝对足够的。然而,如果我们的组件需要有多个可以分别绑定的参数,就不能使用组合,除非我们将所有参数聚合在一个单一的参数对象中,这可能会很快变得难看。另一方面,自定义元素正是为这种情况设计的。

为了举例,让我们将我们的contact-form组件转换为自定义 HTML 元素。由于 Aurelia 的模板引擎支持仅包含模板的自定义元素,因此我们可以删除contact-form.js视图模型,因为当前的contact-form除了渲染模板外没有其他行为。

接下来,我们只需要告诉模板哪些参数应该作为属性暴露在元素上:

src/contact-form.html

<template bindable="contact"> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们在template元素上使用bindable属性,告诉 Aurelia 的模板引擎,当这个模板作为自定义元素使用时,暴露一个contact属性,模板可以使用这个自定义元素进行绑定。

要定义多个绑定属性,只需用逗号将它们分开。例如,bindable="title, contact"将定义两个名为titlecontact的绑定属性。

然后,在contact-creation.htmlcontact-edition.html两个文件中,我们首先以资源的形式加载模板:

<template> 
  <require from="contact-form.html"></require> 
  <!-- Omitted snippet... --> 
</template> 

require语句告诉模板引擎contact-form元素仅使用contact-form.html模板渲染,不带任何视图模型。

接下来,我们可以用我们新的contact-form元素替换compose指令:

<contact-form contact.bind="contact"></contact-form> 

在这里,我们将contact-creationcontact-edition组件的contact属性绑定到我们contact-form自定义元素的contact属性上。这将把contact注入到模板中。另外,属性也将被绑定,这意味着如果周围上下文的contact被分配为新的值,contact-form.html上下文中的contact属性将被同步,所有依赖它的绑定也将被更新。

这允许我们在元素没有行为时,仅使用模板来创建自定义元素。在这种情况下,我们需要编写的代码量严格限制在最小值。

理解 HTML 行为

HTML 行为使我们能够用自定义元素和属性丰富标准 HTML。与组合相比,它们不仅提供了更多可能性,而且更加灵活,而且它们还具有比模板内的compose指令更具有语义意义的特点。

一个 HTML 行为至少由一个视图模型 JS 类组成。另外,自定义元素可以声明一个模板为其视图。当然,属性不能声明一个视图,因为它的意图只是简单地增强或改变元素的 behavior。

无论是 HTML 行为中的元素还是属性,它们都使用相同的基本概念并遵循相同的通用规则。

注入 DOM 元素

HTML 行为通常需要使用对其 DOM 元素的引用,特别是自定义属性。模板引擎了解这一点。当在模板中评估 HTML 元素时,它会在当前 DI 容器中暴露这个元素。

由于这一点,如果元素是一个 Aurelia 自定义元素,它的视图模型可以在构造函数中声明对Element类的依赖,并在其中看到 DOM 元素被注入。

同样,声明对Element类依赖的自定义属性在其构造函数中被实例化时,会看到它所声明的 DOM 元素被注入。

注意

应避免依赖浏览器全局变量。因此,Element类应从aurelia-pal库暴露的DOM接口中获取。这样,如果您的应用程序需要被使 为同质,它可以通过使用 PAL 的不同实现来在服务器上运行。

声明绑定属性

一个 HTML 行为可以声明绑定属性。这样的属性通过模板引擎暴露给外部世界,因此自定义元素或属性的实例可以绑定到这些属性。bindable装饰器允许我们将属性标识为如此。

例如,让我们想象一个名为text-block的自定义元素,它将暴露一个名为text的可绑定属性,并像这样使用:

<text-block text.bind="someText "></text-block> 

为了将text属性作为属性暴露出来,元素的视图模型需要用bindable装饰它:

import {bindable} from 'aurelia-framework'; 

export class TextBlockCustomElement { 
  @bindable text = 'Some default text'; 
} 

bindable装饰器可以接受一个选项对象,该对象可以有以下属性:

  • defaultBindingMode:当用在属性上时.bind命令所选择的绑定模式。应使用bindingMode枚举来设置此值。如果省略,则默认使用单向绑定。

  • changeHandler:更改处理方法的名称。如果省略,将使用属性名称后跟Changed的默认名称。例如,名为title的属性的更改处理方法名称为titleChanged

  • attribute: 用于向外部暴露属性的属性名称。如果省略,将使用属性名称转换为连字符-格式的名称。例如,defaultText属性将作为default-text属性暴露出来。

    注意

    Dash-case 是一种所有单词均为小写且由连字符分隔的命名模式。尽管社区对此名称没有明确的共识(它也被称为kebab-case),但为了使用一致的词汇,我将在整本书中坚持使用这种命名方式。

例如,让我们假设我们想要上一个示例中text-block自定义元素的text属性默认使用双向绑定,并且更改处理方法的名称为onTextChanged而不是默认的textChanged

import {bindable, bindingMode} from 'aurelia-framework'; 

export class TextBlockCustomElement { 
  @bindable({  
    defaultBindingMode: bindingMode.twoWay, 
    changeHandler: 'onTextChanged' 
  }) text = 'Some default text'; 
} 

如果你出于某种原因不能或不想在类内部声明绑定属性,可以直接在类上使用bindable装饰器。在这种情况下,传递给bindable的选项对象应该有一个name属性,这个属性将作为可绑定属性的名称,正如你所猜测的那样。在这种情况下,你还可以通过在选项对象上使用额外的defaultValue属性来指定属性的默认值。

为了说明这一点,让我们将之前的示例重构为通过在类本身上放置bindable装饰器来声明属性:

import {bindable, bindingMode} from 'aurelia-framework'; 

@bindable({ 
  name: 'text', 
  defaultValue: 'Some default text', 
  defaultBindingMode: bindingMode.twoWay, 
  changeHandler: 'onTextChanged' 
}) 
export class TextBlockCustomElement { 
} 

在这里,我们可以清楚地看到TextBlockCustomElement类中text属性已经完全消失。其整个声明都由类上的bindable装饰器处理。

更改处理方法

一个 HTML 行为可以为它的任何可绑定属性提供一个更改处理方法。当属性的值发生变化时,更改处理方法会自动被模板引擎调用。

除非使用bindablechangeHandler选项显式指定方法名,否则给定属性的更改处理方法的名称为属性名称后跟Changed。例如,名为firstName的属性的默认更改处理方法名称为firstNameChanged

更改处理方法带有两个参数,第一个是属性的新值,第二个是之前的值。当然,由于处理程序在其属性发生变化后调用,更改处理程序内部可以直接使用属性而不是第一个参数:

export class TextBlockCustomElement { 
  @bindable text; 

  textChanged(newValue, oldValue) { 
    //Here, newValue is equal to this.text 
  } 
} 

生命周期

所有 HTML 行为都遵循相同的生命周期。行为的视图模型可以实现以下任意回调方法,这些方法将由模板引擎在行为生命周期的特定时刻调用:

  • created(owningView: View, view?: View):在行为创建后立即调用。owningView,即行为声明其中的View实例,作为第一个参数传递。另外,如果行为是一个具有视图的自定义元素,行为的View实例作为第二个参数传递。

  • bind(bindingContext: Object, overrideContext: Object):在视图和视图模型绑定在一起后立即调用。周围的绑定上下文将作为第一个参数传递。一个暴露祖先上下文并且可以用来由视图模型添加上下文属性的覆盖上下文,作为第二个参数传递。

    注意

    如果行为没有声明bind回调方法,那么在此阶段将调用视图模型的所有可绑定属性的更改处理程序,以便允许视图模型根据实例的绑定说明初始化其状态。然而,如果实现了bind,模板引擎在绑定过程中不会自动调用更改处理程序,在这种情况下,bind方法被认为负责初始化行为的状态。

  • attached():在绑定视图已附加到 DOM 后立即调用。

  • detached():在绑定视图从 DOM 中解除后立即调用。这发生在开始处理行为释放过程时。

  • unbind():在视图模型从其视图中解绑后立即调用此方法。这标志着行为生命的结束。通常,如果unbind正确地完成其工作,并且没有遗漏任何引用和资源,那么在返回此方法后,视图模型实例可以被垃圾回收。

除了那些生命周期回调方法,任何可绑定属性的更改处理方法都可以实现。在行为实例的生命周期内,每当可绑定属性的值发生变化时,模板引擎都会调用相应的更改处理方法,如果已实现的话。

实际上,那些生命周期回调方法并不仅限于 HTML 行为。它们可以添加到任何 Aurelia 组件中,例如路由组件或组合组件。

自定义属性

自定义属性是可以通过向任何 HTML 元素(无论是原生元素还是自定义元素)添加相应的 HTML 属性来附加到 HTML 行为上的。Aurelia 的标准模板资源包括许多我们已经介绍过的自定义属性,如focusshowhide

自定义属性完全是行为性的,意味着它们没有视图。

通常有四种类型的自定义属性:

  • 具有单值属性的属性

  • 具有多个属性的属性

  • 具有动态属性的属性

我们将在接下来的章节中详细介绍这些类型的属性。

声明一个自定义属性

标识一个类为自定义属性有两种方法。第一种是遵守命名约定,使自定义属性的类名以CustomAttribute结尾。

在这种情况下,类名的其余部分将被转换为破折号形式,并在模板中作为属性的名称。例如,一个名为MySuperAttributeCustomAttribute的类将在模板中作为my-super-attribute属性提供。

作为命名规则的替代方案,customAttribute装饰器可以应用于一个类,使其被模板引擎识别为自定义属性。在这种情况下,必须将属性在模板中提供的名称作为装饰器的第一个参数传递。例如,下面的属性将在模板中作为file-drop-target属性提供:

import {customAttribute} from 'aurelia-framework'; 

@customAttribute('file-drop-target') 
export class WhateverNameYouWant { 
  //Omitted snippet...  
} 

当使用customAttribute装饰器并传递一个显式的属性名称时,社区中公认的好做法是坚持使用破折号模式,并将所有 HTML 行为的名称前缀为一个在应用程序、插件、框架或公司中通用的两位字母标识符。

例如,原本打算作为更大 Aurelia 界面项目一部分的aurelia-dialog插件,现在重新定义并范围缩减为 Aurelia UX,使用ai-前缀。我们在第四章,表单,以及如何验证它们中已经看到过,元素如ai-dialogai-dialog-body

具有单值属性的属性

默认情况下,自定义属性有一个隐式的value属性,该属性是分配属性的值的地方。当然,可以实现一个名为valueChanged的变更处理方法,以便响应value属性的变化。

显然,一个属性可以没有任何值使用:

<div my-attribute></div> 

在这种情况下,value属性将被分配一个空字符串。

最后,在声明一个单值属性时,customAttribute装饰器可以接受第二个参数,即属性的默认绑定模式。默认情况下,自定义属性是单向绑定的。然而,使用装饰器,可以覆盖这个约定。

例如,设想一个file-drop-target属性,它默认会进行双向绑定:

import {customAttribute, bindingMode} from 'aurelia-framework'; 

@customAttribute('file-drop-target', bindingMode.twoWay) 
export class FileDropTarget { 
  //Omitted snippet...  
} 

添加图片预览

为了说明单值自定义属性是如何工作的,让我们创建一个。

在我们联系应用程序中,我们将在联系照片上传组件中添加所选图像的预览。为此,我们将利用浏览器提供的URL.createObjectURL函数,该函数接受一个Blob对象作为参数,并返回一个特殊的 URL,该 URL 指向这个资源。我们的自定义属性将主要用于img元素,将其与Blob对象绑定,从它生成一个对象 URL,并将这个 URL 分配给img元素的src属性。

注意

URL.createObjectURL函数被大多数主流浏览器支持,但仍然是对 File API 的实验性特性。Mozilla 开发者网络有一个很好的文档关于它,可以在developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL找到。

你可以说值转换器会更适合这种特性,我完全同意。值转换器可以接受一个Blob对象作为输入,并返回该对象的 URL。然后它可以在img元素的src属性和包含Blob对象的属性之间使用。

然而,在这种情况下,每个对象 URL 在使用后都必须释放,以防止内存泄漏,而值转换器没有任何机制在值不再使用时通知。相反,HTML 行为提供了更丰富的流程和更广泛的扩展点。这就是我们创建自定义属性的原因:

src/resources/attributes/blob-src.js

import {inject, PLATFORM, DOM} from 'aurelia-framework'; 

const URL = PLATFORM.global.URL; 
const Blob = PLATFORM.global.Blob; 

@inject(DOM.Element) 
export class BlobSrcCustomAttribute { 

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

  disposeObjectUrl() { 
    if (this.objectUrl && URL) { 
      this.element.src = ''; 
      URL.revokeObjectURL(this.objectUrl); 
      this.objectUrl = null; 
    } 
  } 

  valueChanged(value) { 
    this.disposeObjectUrl(); 

    if (Blob && URL && value instanceof Blob) { 
      this.objectUrl = URL.createObjectURL(value); 
      this.element.src = this.objectUrl; 
    } 
  } 

  unbind() { 
    this.disposeObjectUrl(); 
  } 
} 

在这里,我们依靠命名约定来识别我们的类作为自定义属性,并在构造函数中注入所属性。

我们为PLATFORM常量的global值检索URL对象和Blob类。当在浏览器中运行并使用aurelia-pal-browser实现时,这个global属性将引用window对象。它允许我们在调用其方法之前检查这些值是否可用。这样,如果应用程序在服务器端执行以渲染其 HTML,并且服务器上使用的 PAL 实现不提供这些 API,则自定义属性不会引发任何错误,并将src属性保持不变。

我们还使用valueChanged来释放任何先前的对象 URL,然后创建一个新的并分配给自定义属性所在的元素的src属性。

最后,unbind方法将在模板引擎解绑我们的自定义属性时调用,它只是释放当前的对象 URL,如果有的话。

注意

不要忘记在resources特性的configure函数中加载这个新属性,或者在下一个模板中添加一个require语句,在使用它之前加载属性。

接下来,让我们在我们的联系照片上传组件中使用这个自定义属性。首先,我们希望在选择有效的图像文件时只显示预览。这将防止显示损坏的图片。为此,我们将使用aurelia-validation库的validation-errors属性将当前验证错误分配给新的errors属性:

src/contact-photo.html

<template> 
  <!-- Omitted snippet... --> 
  <input type="file" id="photo" accept="image/*" files.bind="photo & validate"  
    validation-errors.bind="photoErrors"> 
  <!-- Omitted snippet... --> 
</template> 

接下来,我们在视图模型上添加计算属性以获取预览的File对象:

src/contact-photo.js

import {inject, NewInstance} from 'aurelia-framework'; 

//Omitted snippet... 
export class ContactPhoto { 
  //Omitted snippet... 

  get areFilesValid() { 
    return !this.errors || this.errors.length === 0; 
  }
get preview() { 
    return this.photo && this.photo.length > 0 && this.areFilesValid 
      ? this.photo.item(0) : null; 
  } 
  //Omitted snippet... 
} 

我们首先创建了一个areFilesValid属性,该属性使用了新的photoErrors属性,并确保photo属性没有验证错误。接下来,我们添加了一个preview属性,只有当photo包含至少一个项目并且有效时,它才会返回photo中的第一个文件。否则,它返回null

现在,利用新的preview属性和我们的blog-src属性,我们可以显示所选图像文件的前缀:

src/contact-photo.html

<template> 
  <!-- Omitted snippet... --> 
  <div class="col-sm-9"> 
    <input type="file" id="photo" accept="image/*"  
           files.bind="photo & validate"> 
    <div class="thumbnail" show.bind="preview"> 
      <img blob-src.bind="preview" alt="Preview"> 
    </div> 
  </div> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们简单地添加了一个div元素,当有预览时它才会显示。在这个div内部,我们添加了一个带有我们的blob-src自定义属性的img元素,该属性绑定到preview属性。

如果您在这个时候进行测试,您应该能够在选择有效的图像文件后看到预览。此外,当没有选择图像或选择无效时,预览应该自动隐藏。

添加文件拖放目标

在接下来的部分中,我们将添加一个自定义元素,它将作为一个文件选择器,支持使用对话框选择文件和拖放图像文件。为了为此做准备,让我们创建一个第二个自定义属性,它将监听其元素上的拖放事件,并将任何被拖放的文件分配给它的值:

src/resources/attributes/file-drop-target.js

import {customAttribute, bindingMode, inject, DOM} from 'aurelia-framework'; 

@customAttribute('file-drop-target', bindingMode.twoWay) 
@inject(DOM.Element) 
export class FileDropTarget { 
  constructor(element) { 
    this.element = element; 
    this._onDragOver = this.onDragOver.bind(this); 
    this._onDrop = this.onDrop.bind(this); 
    this._onDragEnd = this.onDragEnd.bind(this); 
  } 

  attached() { 
    this.element.addEventListener('dragover', this._onDragOver); 
    this.element.addEventListener('drop', this._onDrop); 
    this.element.addEventListener('dragend', this._onDragEnd); 
  } 

  onDragOver(e) { 
    e.preventDefault(); 
  } 

  onDrop(e) { 
    e.preventDefault(); 
    this.value = e.dataTransfer.files; 
  } 

  onDragEnd(e) { 
    e.dataTransfer.clearData(); 
  } 

  detached() { 
    this.element.removeEventListener('dragend', this._onDragEnd); 
    this.element.removeEventListener('drop', this._onDrop); 
    this.element.removeEventListener('dragover', this._onDragOver); 
  } 
} 

在这里,我们首先声明了我们自定义的属性,使其默认使用双向绑定。这样,当用户将文件拖放到带有我们属性的元素上时,分配给值的字段列表也将分配给与之绑定的表达式。

注意

说这个属性使用双向绑定有点夸张。实际上,这个属性从未真正读取它所绑定的值;它只是写入它。但由于 Aurelia 不支持这种仅向外绑定的模式,我们必须使用双向绑定。您可能已经注意到aurelia-validation插件的validation-errors属性也是以同样的方式工作的。

我们还需要声明一个对其 DOM 元素的依赖,并通过构造函数来获取它。

当我们的属性被附加到文档时,我们在其元素上添加适当的事件监听器。当发生drop事件时,我们将被拖动的files分配给属性的value属性。

最后,当我们的属性从文档中分离时,我们将其元素上的事件监听器移除。

具有多个属性的属性

自定义属性可以声明可绑定的属性。在这种情况下,属性不再有一个单一的value属性,而是有任意数量明确命名的属性。

当然,这样的属性可以定义值变化处理方法,当它们各自的属性值发生变化时,模板引擎会调用这些方法。

例如,aurelia-router库导出的route-href属性可能定义如下:

import {bindable} from 'aurelia-framework'; 

export class RouteHrefCustomAttribute { 
  @bindable route; 
  @bindable params; 
} 

使用带有多个属性的自定义属性

当使用带有多个属性的自定义属性时,语法与style属性类似,属性名后面跟着冒号和它的值,属性之间用分号分隔:

<a route-href="route: my-route; params.bind: { id: 1 }">Link</a> 

在这里,属性实例的route属性将被赋值为'my-route'字符串,其params属性将被绑定到一个具有id属性等于 1 的对象上。

显然,在这种属性上,绑定并不应用于属性本身,而是应用于其属性。我们可以在前一个示例中看到这一点,其中params属性被绑定到一个对象上。

具有动态属性的属性

当属性需要具有动态属性,其名称在静态情况下不可知时,属性类应该用dynamicOptions进行装饰。

动态属性的值变化不使用标准的值变化处理方法进行通知。相反,属性必须实现一个propertyChanged方法,每当动态属性之一的值发生变化时,此方法将被调用,并将传递三个参数:属性的名称、其新值和旧值:

import {dynamicOptions} from 'aurelia-framework'; 

@dynamicOptions 
export class BookCustomAttribute { 
  propertyChanged(name, newValue, oldValue) { 
    //React to the property change 
  } 
} 

使用带有动态属性的自定义属性

使用带有动态属性的自定义属性与使用具有多个静态属性的属性相同:

<div book="title: Learning Aurelia; last-updated.bind: now"></div> 

在这里,Book属性实例将有一个title属性,其将被赋值为'Learning Aurelia'字符串,以及一个lastUpdated属性,其将被绑定到外部上下文的now属性。

自定义元素

自定义元素比自定义属性要复杂得多。一个自定义 HTML 元素具有以下属性:

  • 它可以有可绑定的属性

  • 它可以有自己的模板来控制它的渲染方式

  • 它可以支持内容投射,因此用户可以将绑定的视图片段或自定义模板注入其中

  • 它可以定义自己的行为

  • 它可以与原生 DOM API 进行接口

此外,自定义元素可以通过多种方式进行自定义,主要是使用aurelia-templating提供的各种装饰器。我们将在接下来的章节中介绍这些可能性和扩展点。

需要理解的一个重要点是,自定义元素并不是通过模板技巧来替换它们的渲染模板来处理的。一个自定义元素是一个真实的 DOM 元素,这意味着它继承了所有 DOM 元素的属性和行为,并且可以用任何针对 DOM 元素的 API 来使用。

声明一个自定义元素

自定义元素的声明与自定义属性非常相似。按照约定,以CustomElement结尾的类被认为是自定义元素,其余的名称将被转换为短横线命名法,并用作模板中的元素名称。

例如,名为TextBlockCustomElement的类将在模板中作为text-block元素提供。

与自定义属性类似,customElement装饰器可以应用于一个类,作为命名规则的替代。在这种情况下,元素将在模板中以装饰器的第一个参数的形式提供名称。

例如,下面的元素将在模板中作为text-block元素可用:

import {customElement} from 'aurelia-framework'; 

@customElement('text-block') 
export class WhateverNameYouWant { 
  //Omitted snippet...  
} 

注意

尽管 Aurelia 支持自定义元素使用单字名称,但建议坚持使用短横线命名法,并在自定义元素名称中使用至少两个单词。这是因为 Web 组件规范为所有单字名称保留了原生浏览器元素,所以将来不可能将这样的 Aurelia 自定义元素作为标准 Web 组件导出。此外,社区公认的良好实践是在你所有的 HTML 行为名称前加上一个两个字母的标识符,这个标识符在你的应用程序、插件、框架或公司中是共同的。

然而,这些都是只是约定,因为任何没有装饰器标识其类型的资源,如valueConverterbindingBehaviorcustomAttribute,并且不匹配任何资源命名规则,如类名以ValueConverterBindingBehaviorCustomAttribute结尾,将被模板引擎视为自定义元素。在这种情况下,类的全名将被转换为短横线命名法,并用作模板中的元素名称。

例如,作为一个资源的类被命名为TextBlock,它将作为text-block元素提供给模板。然而,遵循命名规则或使用装饰器被认为是最佳实践。

创建文件选择器

让我们通过在我们的联系人管理应用程序中创建名为file-picker的第一个自定义元素来直接进入。这个元素将封装一个file input,并将使用我们在上一节中创建的file-drop-target自定义属性,以便用户可以打开文件选择对话框或将文件拖放到元素上。

声明自定义元素

我们将从为我们的自定义元素添加一些 CSS 开始:

src/resources/elements/file-picker.css

file-picker > label { 
  width: 100%; 
  height: 100%; 
  cursor: pointer; 
} 

file-picker > input[type=file] { 
  visibility: hidden; 
  width: 0; 
  height: 0; 
}  

在这里,我们简单地隐藏了file input并样式化了我们元素内的labellabel将通过for属性链接到隐藏的file input,所以点击它将会打开输入的文件选择对话框,即使input不可见。这允许我们用更时尚的 UI 替换浏览器的file input

接下来,让我们创建 JS 类:

src/resources/elements/file-picker.js

import {bindable, bindingMode} from 'aurelia-framework'; 

export class FilePickerCustomElement { 

  @bindable inputId = ''; 
  @bindable accept = ''; 
  @bindable multiple = false; 
  @bindable({ defaultBindingMode: bindingMode.twoWay }) files; 
} 

这个类简单地定义了一些可绑定的属性,这些属性将在模板中使用。另外,由于files属性用于收集用户输入,它将默认双向绑定。这就是这个类存在的主要原因。实际上,如果没有需要默认使files使用双向绑定的需求,这可以是一个仅包含模板的定制元素,没有 JS 类,类似于我们在这个章节开始时所做的那样,使用我们的contact-form

最后,我们需要构建模板:

src/resources/elements/file-picker.html

<template> 
  <require from="./file-picker.css"></require> 

  <input type="file" id="${inputId}" accept="${accept}" multiple.bind="multiple" files.bind="files"> 
  <label for="${inputId}" file-drop-target.bind="files"> 
    <slot></slot> 
  </label> 
</template> 

在这里,我们首先require元素的 CSS 文件。接下来,我们添加一个file input,它将把一些属性绑定到视图模型的属性。这允许我们元素的使用者指定inputid以及它的acceptmultiple属性。最重要的是,由于files属性绑定到inputfiles属性,用户选择的文件将与绑定到files属性的外层作用域的表达式同步。

注意

instead of leaving the inputId property empty by default, this element could implement some ID generation algorithm. This would make using the element a little simpler for other developers.

接下来,我们在label中添加一个for属性,也绑定到inputId属性。这将把labelinput链接在一起,所以点击label会打开input的文件选择对话框。此外,我们把file-drop-target属性添加到这个label中,并绑定到files属性,所以拖放到这个label上的文件将被分配给files属性。

最后,我们在label内部添加一个slotslot是影子 DOM 规范的一部分,它允许内容投射。我们将在后面的章节中详细介绍内容投射;现在需要记住的是,这个slot元素将被替换为file-picker元素实例的内容。

使用自定义元素

我们新的file-picker元素现在准备使用。当然,它需要在resources特性的configure函数中全局加载,或者在模板中使用时require

src/contact-photo.html

<template> 
  <!-- Omitted snippet... --> 
  <div class="form-group"> 
    <label class="col-sm-3 control-label" for="photo">Photo</label> 
    <div class="col-sm-6"> 
      <file-picker input-id="photo" accept="image/*" files.bind="photo & validate" class="thumbnail"> 
        <strong hide.bind="preview"> 
          Click to select a file or drag and drop one here 
        </strong> 
        <img show.bind="preview" blob-src.bind="preview" alt="Preview"> 
      </file-picker> 
    </div> 
  </div> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们用新的file-picker替换了原来的file input。我们指定input-idphoto,这使得我们的file-picker中的file input和上面的两行中的label链接在一起。

我们还指定file-picker的文件选择对话框应只显示图片文件,使用accept属性,并将files属性绑定到photo属性上。此外,这个绑定说明被validate绑定行为装饰,所以选中或拖放的文件将会得到适当的验证。

最后,我们利用内容投射,在file-picker内部注入一条strong文本,当没有preview可用时显示,以及一个img元素,当有preview可用时可见,并使用我们的blob-src自定义属性显示预览。这部分内容将在file-picker的 DOM 树中的slot位置投射。

如果你在这个时候运行应用程序,你应该能够点击file-picker来使用选择对话框选择文件,或者将图片文件拖放到元素上,如果文件有效,选中或拖放的文件应该会显示在预览区域。

验证自定义元素

由于aurelia-validation库验证任何双向绑定,validate绑定行为可以不用任何问题地用在自定义元素绑定上。实际上,我们在上一节中就是用它来验证我们的file-picker的,如果你没有注意到的话。然而,file-picker的验证正确工作的原因是因为我们的contact-photo组件使用了change作为validateTrigger

为了使自定义元素与blur``validateTrigger无缝工作,自定义元素必须发布blur事件。另外,为了尊重所有原生表单相关元素实现的 API,如果您的元素的用户输入目的是表单相关元素,实现一个focus方法被认为是一个好习惯,它将委派焦点到包含的任何表单相关元素。

为了说明这一点,让我们想象一个my-widget自定义元素,它包含一个input元素,像这样:

<template> 
  <input value.bind="value"> 
</template> 

我们也想象一个非常基础的视图模型:

import {bindable} from 'aurelia-framework'; 

export class MyWidgetCustomElement { 
  @bindable value; 
} 

为了符合aurelia-validation的要求,这个模板必须进行修改:

<template> 
  <input value.bind="value" ref="input" blur.delegate="blur()"> 
</template> 

在这里,我们在视图模型上创建了一个名为input的新属性,它将包含input元素的引用。接下来,我们为blur事件添加了一个委派事件处理程序,当触发时会调用blur方法。

接下来,让我们修改视图模型以实现新要求:

import {inject, DOM, bindable} from 'aurelia-framework'; 

@inject(DOM.Element) 
export class MyWidgetCustomElement { 
  @bindable value; 

  constructor(element) { 
    this.element = element; 
    element.focus = () => this.input.focus(); 
  }
blur() { 
    this.element.dispatchEvent(DOM.createCustomEvent('blur')); 
  } 
} 

在这里,我们首先声明了对 DOM 元素本身的依赖,并在构造函数中注入。另外,我们在my-widget元素上定义了一个focus方法,当调用时它会调用inputfocus方法。最后,我们创建了一个blur方法,当调用时会在my-widget元素上创建并分发一个blur事件。

现在my-widget元素可以使用默认的blur``validateTrigger

至于我们的file-picker,为了使其与blur``validateTrigger一起工作,应该修改它,使其在选择文件或拖放文件时发布一个blur事件。尽管该元素没有可聚焦的内容,因为file input是隐藏的,但每次其值发生变化时发布此类事件将基本上强制它使用change validateTrigger进行验证,即使验证控制器的触发器是blur。通过实现一个filesChanged变更处理方法,分派一个blur事件,可以轻松实现这一点。

实现一个focus方法不那么直接。因为它不包含任何可聚焦的元素,它应该做什么呢?一种可能性是在file-picker获得焦点时打开文件选择对话框,尽管这从用户的角度来看可能有点侵扰性。这样做只是调用file inputclick方法。

一旦完成这个操作,可以将验证控制器分配的validateTriggercontact-photo组件中移除,使其恢复到默认的blur触发器。

由于除了保持一致性和提高file-picker的重用性外,没有增加太多内容,我将留给读者作为练习来应用这些更改。本书完成的应用程序示例可以作为参考。

代理行为

代理行为允许自定义元素在其自身上声明属性、事件处理程序和绑定。这是通过将这些代理行为添加到自定义元素的template元素来实现的,模板引擎将把这些行为投射到元素本身。它特别有用,可以在元素上定义aria属性,以添加可访问性。

注意

以下代码片段摘自chapter-5/samples/surrogate-behaviors示例。

例如,设想一个名为tree-view的自定义元素,它渲染一个树形结构。在其模板中,我们可以定义一个代理role属性,例如:

<template role="tree"> 
  <!-- Omitted snippet... --> 
</template> 

当在模板中使用tree-view元素时,此role="tree"属性将添加到元素的每个实例:

<tree-view></tree-view> 

如前例所示使用时,该元素在 DOM 中渲染后看起来像以下样子:

<tree-view role="tree"></tree-view> 

代理行为也可以是事件处理程序。例如,tree-view可以声明一个这样的代理click处理程序:

<template role="tree" click.delegate="click()"> 
  <!-- Omitted snippet... --> 
</template> 

在这种情况下,当点击tree-view元素本身时,会调用click处理函数。

正如这个例子所暗示的,代理属性也可以使用数据绑定:

<template role="${role}" click.delegate="click()"> 
  <!-- Omitted snippet... --> 
</template> 

在这里,投影在tree-view元素上的role属性将绑定到自定义元素绑定上下文上的role属性。

内容投射

内容投影是将内容注入自定义元素的动作。通过定义投影点,自定义元素允许实例将外部的 DOM 子树注入到它自己的 DOM 中。这一机制是 Shadow DOM 1.0 规范的一部分,也是大型 web 组件 growing standard 的一部分。

默认插槽

自定义元素中的一个投影点被称为插槽。插槽使用slot元素定义。我们已经用了一个,当我们之前构建file-picker元素在我们的联系人管理应用程序中时:

src/resources/elements/file-picker.html

<template> 
  <require from="./file-picker.css"></require> 

  <input type="file" id="${inputId}" accept="${accept}" multiple.bind="multiple" files.bind="files"> 
  <label for="${inputId}" file-drop-target.bind="files"> 
    <slot></slot> 
  </label> 
</template> 

自定义元素可以定义一个单一的、未命名的插槽,作为默认插槽。使用这个元素时,元素的内容将被投影到这个默认插槽上。

我们在contact-photo组件中像这样使用了file-picker

<file-picker input-id="photo" accept="image/*" files.bind="photo & validate" class="thumbnail"> 
  <strong hide.bind="preview"> 
    Click to select a file or drag and drop one here 
  </strong> 
  <img show.bind="preview" blob-src.bind="preview" alt="Preview"> 
</file-picker> 

投影后的 DOM 结果看起来像这样:

<file-picker input-id="photo" accept="image/*" files.bind="photo & validate" class="thumbnail"> 
  <input type="file" id="${inputId}" accept="${accept}" multiple.bind="multiple" files.bind="files"> 
  <label for="${inputId}" file-drop-target.bind="files"> 
    <strong hide.bind="preview"> 
      Click to select a file or drag and drop one here 
    </strong> 
    <img show.bind="preview" blob-src.bind="preview" alt="Preview"> 
  </label> 
</file-picker> 

在这里,我们可以清楚地看到,file-picker实例的内容,strongimg元素已经注入到元素的 DOM 中,并替换了slot元素。

命名插槽

自定义元素可以声明多个投影点,通过定义多个不同名称的slot元素。

例如,让我们想象一下,我们想要创建一个submit-button自定义元素,其模板看起来像这样:

<template> 
  <button type="submit" class="btn btn-primary"> 
    <slot name="icon"></slot> 
    <slot name="label"></slot> 
  </button> 
</template> 

使用这个元素时,我们现在将有两个插槽,分别命名为iconlabel,我们可以将内容投影到它们中:

<submit-button> 
  <i slot="icon" class="fa fa-floppy-o" aria-hidden="true"></i> 
  <span slot="label">Update ${contact.fullName}</span> 
</submit-button> 

要将内容投影到命名插槽中,你只需要给想要投影的元素添加一个slot属性,其值为插槽的名称。在这里,我们将一个i元素投影到icon插槽上,并将一个包含按钮标签的span元素投影到label插槽上。

此外,如果多个内容元素使用slot属性的相同值,它们都将被投影到这个插槽中,按照它们在自定义元素实例中声明的顺序:

<submit-button> 
  <i slot="icon" class="fa fa-floppy-o" aria-hidden="true"></i> 
  <span slot="label">Update</span> 
  <span slot="label">${contact.fullName}</span> 
</submit-button> 

在这里,这两个span元素都将被投影到label插槽中,顺序相同。

数据绑定投影内容

模板引擎将首先处理投影前的内容,所以使用字符串插值或绑定命令在或投影元素内部是完全合法的。前一个示例说明了这一点,通过使用字符串插值来渲染contactfullName,在label插槽上的span之前。

在投影发生之前,内容是数据绑定的。这意味着内容是使用元素实例周围的上下文进行绑定的。它无法访问自定义元素的内层上下文。

在前一个示例中,submit-button的视图模型不知道任何contact属性。这个属性只存在于外层上下文中,在该上下文中声明了submit-button实例。

默认内容

当定义一个插槽时,自定义元素可以为其提供默认内容。这样,如果没有内容被投影到插槽上,它就不会留空。

为了说明这一点,让我们来改变前一个部分中的submit-button自定义元素:

<template> 
  <button type="submit" class="btn btn-primary"> 
    <slot name="icon"> 
      <i class="fa fa-check-circle-o" aria-hidden="true"></i> 
    </slot> 
    <slot name="label">Submit</slot> 
  </button> 
</template> 

在这里,我们只是在icon插槽上添加了一个检查图标,在label插槽上添加了Submit文本。这样,如果submit-button实例没有在任何插槽上投影内容,按钮将显示默认图标和标签。

只有在没有内容投影到插槽时,默认插槽的内容才会显示。这意味着,为了覆盖默认内容并强制一个空插槽,你只需在插槽上投影一个空元素:

<submit-button> 
  <span slot="icon"></span> 
</submit-button> 

在这里,一个空span将被投影到icon插槽上,这将覆盖默认图标。

插槽中套插槽

一个有趣的可能性是在另一个插槽的默认内容中定义插槽。在这种情况下,可以选择完全覆盖第一个插槽的内容,或者仅覆盖第二个插槽的内容并保留第一个插槽的其余默认内容。

让我们通过修改前一个示例中的submit-button元素来说明这一点:

<template> 
  <button type="submit" class="btn btn-primary"> 
    <slot name="content"> 
      <slot name="icon"> 
        <i class="fa fa-check-circle-o" aria-hidden="true"></i> 
      </slot> 
      <slot name="label">Submit</slot> 
    </slot> 
  </button> 
</template> 

在这里,我们将之前定义的插槽包裹在一个名为content的新插槽中。所有之前的使用示例仍然有效;然而,现在可以通过content插槽完全覆盖submit-button的内容:

<submit-button> 
  <span slot="content">Save</span> 
</submit-button> 

在这里,我们简单地将整个内容替换为一个包含文本Savespan元素。

命名插槽与默认插槽的混合

在一个给定的自定义元素内部,也可以定义命名插槽和默认未命名插槽。在这种情况下,所有在命名插槽之外的内容都将被投影到默认插槽中。

让我们通过让label插槽成为submit-button的默认未命名插槽来说明这一点:

<template> 
  <button type="submit" class="btn btn-primary"> 
    <slot name="content"> 
      <slot name="icon"> 
        <i class="fa fa-check-circle-o" aria-hidden="true"></i> 
      </slot> 
      <slot>Submit</slot> 
    </slot> 
  </button> 
</template> 

经过这个更改,我们仍然可以像以前一样覆盖contenticon插槽。然而,要覆盖label,现在只需在元素实例中投影内容,而不需要任何插槽名称:

<submit-button>Save</submit-button> 

这个submit-button实例覆盖了按钮的标签,该标签由默认的未命名插槽定义。

可以在命名插槽和默认插槽上混合投影:

<submit-button> 
  <i slot="icon" class="fa fa-check-square-o" aria-hidden="true"></i> 
  Save 
</submit-button> 

在这里,我们在icon插槽上投影了一个带有不同图标的I元素,并在默认插槽上投影了文本Save

插槽 ception

那么一个声明插槽的自定义元素,它在另一个声明自己插槽的自定义元素中被投影呢?这是完全可能的。让我们想象一个form-button-bar组件,它将封装一个submit-button按钮,还有一个取消按钮:

<template bindable="cancelUrl"> 
  <div class="form-group"> 
    <div class="col-sm-9 col-sm-offset-3"> 
      <submit-button> 
        <slot name="submit-label" slot="label">Save</slot> 
      </submit-button> 
      <a class="btn btn-danger" href.bind="cancelUrl"> 
        <slot name="cancel-label">Cancel</slot> 
      </a> 
    </div> 
  </div> 
</template> 

在这里,form-button-bar元素声明了两个插槽,分别命名为submit-labelcancel-label,它们的默认内容分别是SaveCancel。此外,submit-label插槽又被投影到了submit-buttonlabel插槽上。当使用时,如果form-button-bar实例没有在submit-label插槽上投影任何内容,它的默认内容将被投影到submit-buttonlabel插槽上。

这意味着submit-buttonlabel插槽的默认内容将总是被覆盖,要么是被form-button-barsubmit-label插槽的默认内容覆盖,要么是被其投射的内容覆盖。

这也意味着,在使用form-button-bar元素时,没有办法在submit-buttonicon插槽中投射内容,因为它没有被form-button-bar的插槽暴露出来。

限制

插槽机制的实现有几个重要的限制。插槽声明上的name属性不能绑定,元素实例上的slot属性也不能绑定。这包括字符串插值。这些属性的值必须是静态的。

此外,slot定义不能被模板控制器修改,例如ifrepeat属性。if属性的限制可以通过在另一个元素上放置一个show属性来某种程度上绕过,这个元素包围着slot。然而,repeat属性就是不能工作,因为,由于插槽名称不可绑定且必须是静态的,重复插槽意味着有多个具有相同名称的插槽,这是不支持的。

奥雷利亚团队宣布,他们打算在未来至少解除一些这些限制,但在撰写本文时,这些限制仍然存在。

模板注入

还有一种方法可以扩展自定义元素的渲染。除了内容投射之外,自定义元素还可以在其模板中声明可替换的模板部分。这些可替换的部分随后可以被实例覆盖。

这种技术与其他插槽的主要区别在于绑定方式。虽然插槽上注入的内容在投射之前绑定,因此使用外部上下文进行绑定,但注入的模板在注入后绑定。这意味着注入的模板使用自定义元素的内部上下文进行绑定。因此,注入的模板可以无需任何问题地重复。

创建分组列表

让我们通过从联系人列表中提取可重用组件来说明这一点。我们将创建一个group-list自定义元素,它将对其绑定的项目进行分组和排序,以渲染项目组。它将定义一个可替换的部分,该部分将用于渲染组内的单个项目:

src/resources/elements/group-list.html

<template bindable="items, groupBy, orderBy"> 
  <div repeat.for="group of items | groupBy:groupBy | orderBy:'key'" class="panel panel-default"> 
    <div class="panel-heading">${group.key}</div> 
    <ul class="list-group"> 
      <li repeat.for="item of group.items | orderBy:orderBy" class="list-group-item"> 
        <template replaceable part="item"></template> 
      </li> 
    </ul> 
  </div> 
</template> 

在这里,我们首先在template元素上定义可绑定的属性。这意味着group-list元素将只由这个模板组成;它将没有任何视图模型。

可绑定的属性如下:

  • items:要渲染的项目

  • groupBy:用于分组项目的属性名称

  • orderBy:用于对分组项目进行排序的属性名称

接下来,我们简单地重用来自contact-list组件的相同模板来渲染项目组。主要区别是,我们没有硬编码传递给groupByorderBy值转换器的属性,而是使用适当的可绑定属性。

最后,在模板中渲染联系人的位置,我们放置一个名为item的可替换模板部分。当使用这个自定义元素时,我们将能够注入一个模板,以替换这个部分。这个注入的模板将有权访问周围上下文,这意味着它将能够使用当前的item

使用分组列表

让我们看看如何通过重构contact-list组件以使用这个新的group-list元素来使用具有可替换部分的自定义元素:

src/contact-list.html

<template> 
  <!-- Omitted snippet... --> 
  <group-list items.bind="contacts | filterBy:filter:'firstName':'lastName':'company'" 
              group-by="firstLetter" order-by="fullName"> 
    <template replace-part="item"> 
      <a route-href="route: contact-details; params.bind: { id: item.id }"> 
        <span if.bind="item.isPerson"> 
          ${item.firstName} <strong>${item.lastName}</strong> 
        </span> 
        <span if.bind="!item.isPerson"> 
          <strong>${item.company}</strong> 
        </span> 
      </a> 
    </template> 
  </group-list> 
</template> 

在这里,我们首先使用group-list自定义元素。不要忘记将其作为资源加载并将其items属性与根据用户搜索过滤的contacts数组绑定。我们还需要使用group-byorder-by属性指定用于分组的属性和排序属性。

接下来,我们定义一个模板来替换名为item的部分。在这个模板中,我们保留了用于渲染单个联系人项的视图。正如你所看到的,模板部分能够使用来自自定义元素自身模板中的repeat.for属性的item属性。

默认模板部分

在这个阶段,group-list的使用者需要替换item部分,否则项目根本不会被渲染。在声明可替换的模板部分时,可以定义其默认内容,当该部分没有被替换时将使用该默认内容。

让我们将group-list模板更改为默认将每个项目渲染为字符串:

src/resources/elements/group-list.html

<template bindable="items, groupBy, orderBy"> 
  <!-- Omitted snippet... --> 
  <template replaceable part="item">${item}</template> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们定义了可替换的item部分的默认内容,它将简单地使用其toString方法渲染当前的item。这样,如果没有注入item部分,用户至少会看到一些东西,即使它只是[object Object],这也是一个没有覆盖toString方法的对象的默认结果。

你可以尝试通过在Contact类中添加一个返回fullName属性的toString方法,并在contact-list组件中的group-list元素内注释掉注入的item模板部分来实现。分组列表现在应该简单地渲染每个联系人的fullName,而不带任何链接。

重新作用域绑定上下文

如目前所示,我们group-list自定义元素的使用者需要知道当前项在绑定上下文中名为item。使事情变得更容易的一个可能性是使用with属性并在repeat.for内重新作用域绑定上下文到当前的item

src/resources/elements/group-list.html

<template bindable="items, groupBy, orderBy"> 
  <!-- Omitted snippet... --> 
  <li repeat.for="item of group.items | orderBy:orderBy" class="list-group-item"> 
    <template with.bind="item"> 
      <template replaceable part="item">${$this}</template> 
    </template> 
  </li> 
  <!-- Omitted snippet... --> 
</template> 

我们不能在li上放置with属性,因为它已经包含了一个repeat属性。确实,单个元素不能包含多个模板控制器。

我们需要用另一个template元素包围可替换的模板,该元素包含with属性,我们将其绑定到当前item。在可替换的item模板内,我们将字符串插值中的item引用替换为对$this的引用。$this关键字指的是当前上下文本身,得益于with,这是当前item。这部分是可选的,因为当前上下文仍然从父上下文继承,这意味着通过上下文继承item仍然可用。实际上,$thisitem都指的是当前项目。除非当前项目有其自己的item属性。在这种情况下,$this将指的是当前项目,而item将指的是当前项目的item属性。

由于item仍然在绑定上下文中可用,而Contact对象没有item属性,所以我们不需要更改contact-list模板中的任何内容。它仍然有效。然而,在重复的li上使用with意味着我们现在可以在contact-list中删除所有对item的引用:

src/contact-list.html

<template> 
  <!-- Omitted snippet... --> 
  <template replace-part="item"> 
    <a route-href="route: contact-details; params.bind: { id: id }"> 
      <span if.bind="isPerson"> 
        ${firstName} <strong>${lastName}</strong> 
      </span> 
      <span if.bind="!isPerson"> 
        <strong>${company}</strong> 
      </span> 
    </a> 
  </template> 
  <!-- Omitted snippet... --> 
</template> 

现在,我们的group-list自定义元素更易于使用。使用它的开发者不需要知道上下文有一个item属性。他们可以简单地假设可替换模板部分的绑定上下文就是当前项目。

当然,这主要是关于品味的问题,但也涉及到一致性。如果你在一个应用程序或插件中开始在这种场景下使用with,你应该保持一致,并在所有其他类似情况下继续使用它。

创建列表编辑器

让我们看看另一个例子。我们将创建一个可重用的列表编辑器,我们可以在contact-form组件中使用它来编辑电话号码、电子邮件地址、地址和社会资料:

src/resources/elements/list-editor.js

import {bindable} from 'aurelia-framework'; 

export class ListEditorCustomElement { 

  @bindable items = []; 
  @bindable addItem; 
} 

在这里,我们首先创建一个视图模型,在该模型上定义两个可绑定的属性:

  • items:要编辑的项目数组

  • addItem:用于将新项目添加到数组中的函数

注意

在重构contact-form之后,我们将能够从Contact类中删除removePhoneNumberremoveEmailAddressremoveAddressremoveSocialProfile方法,因为它们将被list-editor中的removeItem方法替换。

list-editor的模板如下所示:

src/resources/elements/list-editor.html

<template> 
  <div class="form-group" repeat.for="item of items" > 
    <template with.bind="item"> 
      <template replaceable part="item"> 
        <div class="col-sm-2 col-sm-offset-1"> 
          <template replaceable part="label"></template> 
        </div> 
        <div class="col-sm-8"> 
          <template replaceable part="value">${$this}</template> 
        </div> 
        <div class="col-sm-1"> 
          <template replaceable part="remove-btn"> 
            <button type="button" class="btn btn-danger"  click.delegate="items.splice($index, 1)"> 
              <i class="fa fa-times"></i> 
            </button> 
          </template> 
        </div> 
      </template> 
    </template> 
  </div> 
  <div class="form-group" show.bind="addItem"> 
    <div class="col-sm-9 col-sm-offset-3"> 
      <button type="button" class="btn btn-primary" click.delegate="addItem()"> 
        <slot name="add-button-content"> 
          <i class="fa fa-plus-square-o"></i> 
          <slot name="add-button-label">Add</slot> 
        </slot> 
      </button> 
    </div> 
  </div> 
</template> 

此模板的全球布局与我们在第四章表单及其验证方式中创建的所有列表编辑器使用了相同的框架。我们先为每个item重复一个块,并使用with将上下文范围限制在此块内的当前item上。

在重复项目块内,我们首先声明一个可替换的item部分,它封装了单个项目的整个模板。作为这个部分的默认内容,我们使用了与联系表单其余部分相同的列设置。第一列包含一个空的 replaceable 部分,名为label。第二列包含一个名为value的可替换部分,如果它没有被替换,则将当前项目渲染为字符串。第三列包含一个名为remove-btn的可替换部分,其默认内容是一个移除按钮,当点击时从数组中删除项目。

在这里我们可以看到,就像插槽可以定义子插槽一样,可替换部分也可以定义其他可替换部分作为它们的默认内容。在list-editor中,它允许我们替换整个项目模板,或者只替换其中的一部分。这是一个非常强大的功能。

我们甚至可以在同一个自定义元素中使用可替换部分和插槽。这就是我们在这里所做的,最后一个块,在重复的项目之外,包含一个添加按钮,当点击时调用addItem函数。这个按钮包含一个名为add-button-content的第一个插槽。其默认内容是一个图标,还有一个名为add-button-label的插槽,其默认内容是文本Add。这允许我们投影内容以自定义整个添加按钮的内容,或仅其标签。

最后,只有当addItem属性绑定到某个东西时(我们预期是一个函数),我们才显示包含添加按钮的整个块。这意味着,如果list-editor实例没有绑定add-item属性,添加按钮将不可见。

使用列表编辑器

我们现在可以在我们的contact-form组件中使用这个list-editor元素:

src/contact-form.html

<template> 
  <!-- Omitted snippet... --> 
  <hr> 
  <list-editor items.bind="contact.phoneNumbers" add-item.call="contact.addPhoneNumber()"> 
    <template replace-part="label"> 
      <select value.bind="type & validate" class="form-control"> 
        <option value="Home">Home</option> 
        <option value="Office">Office</option> 
        <option value="Mobile">Mobile</option> 
        <option value="Other">Other</option> 
      </select> 
    </template> 
    <template replace-part="value"> 
      <input type="tel" class="form-control" placeholder="Phone number" value.bind="number & validate"> 
    </template> 
    <span slot="add-button-label">Add a phone number</span> 
  </list-editor> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们首先将电话号码编辑器重构为使用我们新的list-editor元素。我们将它的items属性绑定到contactphoneNumbers属性。我们还绑定add-item属性以调用contactaddPhoneNumber方法。

接下来,我们将label模板部分替换为绑定到项目typeselect元素。当然,这个绑定被validate装饰器装饰,所以项目的type将被正确验证。

我们还将value模板部分替换为绑定到项目numbertel input。同样,这个绑定被validate装饰器装饰,所以项目的number将被验证。

最后,我们在add-button-label插槽上投影文本Add a phone number

此时,如果您运行应用程序,电话号码编辑器应该和之前一样的外观和行为。我将留给读者一个练习,即使用list-editor重构电子邮件地址、地址和社会资料的编辑器。本章的完整应用程序示例可以作为参考。

使用自定义装饰器

aurelia-templating库提供了许多装饰器,可以用来定制自定义元素的行为以及它们被模板引擎处理的方式。

注意

以下的大部分代码片段都来自chapter-5/samples/element-decorators示例。

viewResources

viewResources装饰器可以用来声明视图依赖项。它就像require元素一样,但作用于组件的视图模型而不是其模板。

例如,我们可以通过从其模板中移除require语句来重构我们联系管理应用程序中的contact-edition组件:

src/contact-edition.html

<template> 
  <!-- Comment out the require statement --> 
  <!-- <require from="contact-form.html"></require> --> 
  <!-- Omitted snippet... --> 
</template> 

然后,我们还需要用viewResources来装饰ContactEdition类:

src/contact-edition.js

import {inject, NewInstance, viewResources} from 'aurelia-framework'; 

//Omitted snippet... 
@viewResources(['contact-form.html']) 
export class ContactEdition { 
  //Omitted snippet... 
} 

contact-edition组件仍然会像以前一样工作。

viewResource装饰器期望一个依赖项数组。每个依赖项可以是以下之一:

  • 一个字符串,它必须是加载资源的路径

  • 一个对象,有一个src属性,它必须包含要加载的资源的路径,和一个可选的as属性,如果存在,它将在模板中作为资源的别名,就像require元素的as属性一样

  • 一个函数,它必须是加载资源的类

我实在想不出这个装饰器除了想要把所有依赖项放在视图模型中而不是视图里之外还有哪个好的用例。然而,由于加载依赖项是与模板相关的事务,对我来说,在视图中使用require语句来做这件事感觉更加自然。

useView

useView装饰器可以用来明确指定自定义元素模板的路径。

例如,让我们更新我们联系管理应用程序中的file-picker元素,使其使用这个装饰器:

src/resources/elements/file-picker.js

import {bindable, bindingMode, inject, DOM, useView} from 'aurelia-framework'; 

@inject(DOM.Element) 
@useView('./file-picker.html') 
export class FilePickerCustomElement { 
  //Omitted snippet... 
} 

如果多个元素共享同一个视图,这将会非常有用。另外,当一个元素打算在一个可重用的库或插件中分发时,明确指定元素的模板被认为是一个好的实践。确实,正如我们将在本章末尾看到的,使用你元素的开发者可以改变视图位置的约定。在这种情况下,依赖标准约定的元素将会被破坏。

inlineView

inlineView装饰器允许我们完全用内联模板替换组件的模板文件:

import {inlineView} from 'aurelia-framework'; 

@inlineView('<template><button type="submit">Submit</button></template>') 
export class SubmitButtonCustomElement { 
} 

这个自定义元素将没有.html文件,因为它的模板是声明内联的,位于 JS 类旁边。

这对于只是作为容器存在的自定义元素来说非常有用,它们主要依赖内容投射,因为它消除了需要一个包含很少几行的单独模板文件。

例如,这是来自aurelia-dialog库的ai-dialog元素的代码:

import {customElement, inlineView} from 'aurelia-templating'; 

@customElement('ai-dialog') 
@inlineView('<template><slot></slot></template>') 
export class AiDialog { 
} 

这个元素的唯一目的是作为ai-headerai-bodyai-footer元素的容器,因此当模板与视图模型相邻时,它会更简单。

noView

noView装饰器告诉模板引擎给定的自定义元素没有模板。在这种情况下,模板引擎将简单地绑定元素本身,然后处理其内容(如果有),除非还使用了processContent装饰器并禁用了内容处理。

没有视图的自定义元素有用的情况相当罕见。对于我所能想到的绝大多数用例,例如封装 UI 库中 JS 小部件的行为,自定义属性更为合适。然而,可能存在一些场景,你希望将某些行为封装在一个完整的元素中,而不是在另一个元素的属性中。

为了举例说明,让我们想象一个作为 UI 库中 JS 小部件适配器的自定义元素。这个小部件是通过调用一个函数并将其 DOM 元素传递给它作为小部件视觉根来创建的:

import {noView, inject, DOM} from 'aurelia-framework'; 

@noView 
@inject(DOM.Element) 
export class MyWidget { 
  constructor(element) { 
    this.element = element; 
  } 

  attached() { 
    SomeWidgetApi.create(this.element); 
  } 
} 

这样的元素不需要任何模板,因为元素的视图由外部库渲染。

viewResource装饰器类似,noView装饰器可以作为其第一个参数传递一个依赖项数组。这些依赖项将与组件一起加载。

此外,第二个参数可以指定依赖项相对于的路径。在这种情况下,将使用此路径而不是视图模型的路径来定位依赖项。

useViewStrategy

useViewStrategy装饰器告诉模板引擎使用给定的ViewStrategy实例来加载组件的视图。它实际上是由useViewinlineViewnoView装饰器在幕后使用的。

它只是将提供的视图策略作为元数据附加到类上。在视图定位过程中,这个元数据然后由视图定位器检查并用于定位组件的视图。

它主要与自定义ViewStrategy实现一起使用,这是本书范围之外的高级主题。然而,了解一下它的存在是好的,以防你将来需要去那里。

processAttributes

processAttribute装饰器可以用来提供一个函数,可以在模板引擎处理元素属性之前处理元素的属性。处理函数必须作为装饰器的参数传递:

import {processAttributes} from 'aurelia-framework'; 

@processAttributes((compiler, resources, node, attributes, instruction) => { 
  //Omitted snippet... 
}) 
export class MyCustomElementCustomElement { 
  //Omitted snippet... 
} 

处理函数将被传递一堆参数:

  • compiler: 用于编译当前模板的ViewCompiler实例。

  • resources: 包含元素模板可用的资源集的ViewResources实例。

  • node: 自定义元素的 DOM 元素本身。

  • attributes: 一个NamedNodeMap实例,它是node参数的attributes属性。

  • instruction:包含模板引擎用来处理、数据绑定并显示自定义元素的所有信息的BehaviorInstruction实例。

processContent

processContent装饰器可以用来控制模板引擎将如何以及是否处理自定义元素的內容。这取决于传递给装饰器的参数是什么。

如果装饰器被传递false,模板引擎将不会处理元素的內容。在这种情况下,元素负责处理自己的内容:

import {noView, processContent} from 'aurelia-framework'; 

@noView 
@processContent(false) 
export class ProcessNoContentSampleCustomElement { 
  //Omitted snippet... 
} 

这样的元素不会看到其内容被模板引擎处理:

<template> 
  <process-no-content-sample>${someProperty}</process-no-content-sample> 
</template> 

当渲染时,之前的模板将会精确地显示出来。字符串插值指令不会被解释,因为process-no-content-sample的内容不是由模板引擎处理的。${someProperty}文本将会保持不变。

另一个可能性是将一个处理函数传递给装饰器。在这种情况下,处理函数可以处理元素的內容,并且被期望返回truefalse,告诉模板引擎在处理函数返回后是否应该处理内容:

import {noView, processContent} from 'aurelia-framework'; 

@noView 
@processContent((compiler, resources, node, instruction) => { 
  //Omitted snippet... 
}) 
export class ProcessContentSampleCustomElement { 
  //Omitted snippet... 
} 

处理函数将被传递一堆参数:

  • compiler:用于编译当前模板的ViewCompiler实例。

  • resources:包含元素模板可用的资源集合的ViewResources实例。

  • node:自定义元素自身的 DOM 元素。

  • instruction:包含模板引擎用来处理、数据绑定并显示自定义元素的所有信息的BehaviorInstruction实例。

这个装饰器可以被用来,例如,创建一个作为 Aurelia 应用中集成点的自定义元素,以封装必须使用不同模板引擎的应用程序的部分。

containerless

containerless装饰器向模板引擎指示,自定义元素的视图必须被注入到元素本身的位置,而不是在其内部:

import {containerless} from 'aurelia-framework'; 

@containerless 
export class ContainerlessSample { 
  //Omitted snippet... 
} 

让我们假设这个containerless-sample元素有以下模板:

<template> 
  <p>This is a containerless element example.</p> 
</template> 

这个元素会被这样使用:

<div class="example"> 
  <containerless-sample></containerless-sample> 
</div> 

没有containerless装饰器,它会被渲染到 DOM 中,如下所示:

<div class="example"> 
  <containerless-sample> 
    <p>This is a containerless element example.</p> 
  </containerless-sample> 
</div> 

然而,因为它被装饰了containerless,周围的containerless-sample元素不会被渲染:

<div class="example"> 
  <p>This is a containerless element example.</p> 
</div> 

即使元素本身没有被渲染,自定义元素仍然可以声明可绑定的属性,并通过属性进行绑定。即使元素和它的属性没有被渲染到 DOM 上,这也会起作用。

当然,这意味着代理行为不能用于containerless自定义元素,因为代理行为应该被投影到的元素没有渲染。

这个装饰器在必须尊重特定 DOM 结构时非常有用,例如使用 SVG 元素时。

useShadowDOM

useShadowDOM 装饰器将使自定义元素在其影子 DOM 中渲染其视图。这对于将自定义元素的 DOM 子树与文档的其余部分隔离很有用,以防止 CSS 或 DOM 查询在元素的 DOM 子树与外部世界之间产生不希望的交互。

为了说明这一点,让我们考虑我们联系管理应用程序中的file-picker自定义元素。这个元素有一个 CSS 文件,通过它的模板加载。没有影子 DOM,CSS 文件将被附加到文档的head中,这意味着 CSS 将全局应用于整个文档。可能发生冲突。

为了防止这一点,让我们让我们的file-picker元素在其影子 DOM 中渲染其视图。这样,其 CSS 文件将在其自己的影子根中加载,并且只在此有限的范围内应用:

src/resources/elements/file-picker.js

import {bindable, bindingMode, inject, DOM, useView, useShadowDOM} from 'aurelia-framework'; 

@inject(DOM.Element) 
@useView('./file-picker.html') 
@useShadowDOM 
export class FilePickerCustomElement { 
  //Omitted snippet... 
} 

通过向我们的元素的类中添加shadowDOM装饰器,我们告诉模板引擎这个元素的内容应该在其自己的影子根内渲染。

为了让 CSS 文件在元素的影子根中渲染,我们需要将require语句标记为scoped

src/resources/elements/file-picker.html

<template> 
  <require from="./file-picker.css" as="scoped"></require> 
  <!-- Omitted snippet... --> 
</template> 

最后,由于 CSS 文件将在元素的影子根内加载,该影子根将在file-picker元素内但围绕其视图,我们需要从 CSS 选择器中删除file-picker元素,以便它们保持匹配:

src/resources/elements/file-picker.css

label { 
  width: 100%; 
  height: 100%; 
  cursor: pointer; 
} 

input[type=file] { 
  visibility: hidden; 
  width: 0; 
  height: 0; 
}  

注意file-picker > label选择器如何被label选择器替换。其他 CSS 规则的选择器也同理。

现在,如果您运行应用程序并检查围绕此元素的 DOM,您应该看到一个影子根,它封装了元素本身和一个包含 CSS 的style元素。您可能会注意到,在这里,file-picker内的strongimg元素投影到的内容位于影子根的外部。这很重要。这意味着元素的视图只受 CSS 文件的影响。其投影内容不受影响。如果您向投影内容添加了label,它将不会与file-picker.css中定义的规则匹配,因为它不会在相同的影子根上。

children

children装饰器旨在用于自定义元素的属性上。它选择所有匹配提供的查询选择器的直接子元素并将它们作为数组分配给作为数组分配给装饰属性的数组。

为了说明这一点,让我们想象以下自定义元素:

import {inlineView, children} from 'aurelia-framework'; 

@inlineView('<template><slot></slot></template>') 
export class ChildChildrenSampleCustomElement { 
  @children('item') items; 
} 

假设元素像这样使用:

<child-children-sample> 
  <item repeat.for="value of values">${value}</item> 
</child-children-sample> 

在这里,child-children-sample实例将看到重复的item元素分配为其items属性数组。

此外,items的值将与匹配的元素集同步。这意味着,如果插入一个新的item元素或移除一个现有的元素,由于repeat.for绑定,items属性将被同步。

与可绑定属性类似,一个children属性也可以实现一个使用相同命名规则的变化处理方法,以响应变化。在这个例子中,如果存在itemsChanged方法,那么在渲染时,在属性初始化期间,以及每次items数组发生变化时,该方法将被调用。

孩子

child装饰器与children非常相似,只不过它针对的是一个单一的元素。

为了说明这个问题,让我们调整一下之前的例子:

import {inlineView, child} from 'aurelia-framework'; 

@inlineView('<template><slot></slot></template>') 
export class ChildChildrenSampleCustomElement { 
  @child('header') header; 
} 

假设元素像这样使用:

<child-children-sample> 
  <header>Some title</header> 
</child-children-sample> 

在这里,child-children-sample实例会将header元素分配给它的header属性。另外,如果存在headerChanged方法,那么在渲染时,在属性初始化期间,以及每次元素被移除、添加或替换时,该方法将被调用。

奖励 - 防止多次提交

目前,我们的联系人管理应用程序处理表单提交并不好。实际上,在contact-creationcontact-editioncontact-photo组件中,如果保存按钮点击一次,然后再次点击,在底层Fetch调用完成之前,路由导航离开表单之前,将同时执行后端的多项调用。有时候,这并不重要。然而,在许多场景下,这也可能是一个问题。

创建提交任务属性

为了解决这个问题,我们将创建一个名为submit-task的自定义属性,它将替代form元素的submit处理程序。它将使用call命令绑定到一个方法,该方法预期返回一个Promise。当form提交时,属性将切换一个标志,当返回的Promise完成时,它将再次切换回来。这个标志将指示表单是否正在等待一个提交任务完成:

src/resources/attributes/submit-task.js

import {inject, DOM} from 'aurelia-framework'; 

@inject(DOM.Element) 
export class SubmitTaskCustomAttribute { 

  constructor(element) { 
    this.element = element; 
    this.onSubmit = this.trySubmit.bind(this); 
  } 

  attached() { 
    this.element.addEventListener('submit', this.onSubmit); 
    this.element.isSubmitTaskExecuting = false; 
  } 

  trySubmit(e) { 
    e.preventDefault(); 
    if (this.task) { 
      return; 
    } 

    this.element.isSubmitTaskExecuting = true; 
    this.task = Promise.resolve(this.value()).then( 
      () => this.completeTask(), 
      () => this.completeTask()); 
  } 

  completeTask() { 
    this.task = null; 
    this.element.isSubmitTaskExecuting = false; 
  } 

  detached() { 
    this.element.removeEventListener('submit', this.onSubmit); 
  } 
} 

在这里,我们首先使用命名约定来识别这个类作为一个自定义属性。我们还声明了对属性所在的 DOM 元素的依赖,我们在构造函数中注入这个元素。

在这里,当我们的自定义属性被attached到文档时,我们在元素的submit事件上添加一个监听器,当被触发时将调用trySubmit方法。另外,在元素上创建了一个新的isSubmitTaskExecuting属性,并初始化为false

当元素发布一个submit事件时,我们首先要确保当前没有正在运行的提交task。如果已经有了,我们就直接返回。如果没有,元素的isSubmitTaskExecuting属性被设置为true,然后调用与自定义属性value绑定的函数。保证得到的结果是一个Promise,并且在这个Promise上附上一个回调,无论Promise成功还是失败,当Promise完成时,isSubmitTaskExecuting都会被设置回false

最后,当属性从文档中detached时,我们简单地移除了submit事件监听器。

使用提交任务属性

现在我们可以进入带有form元素的各个组件,并将submit事件处理程序替换为新的submit-task属性,使用call命令将其绑定到save方法:

src/contact-creation.html

<template> 
  <!-- Omitted snippet... --> 
  <form class="form-horizontal" validation-renderer="bootstrap-form" submit-task.call="save()"> 
    <!-- Omitted snippet... --> 
  </form> 
  <!-- Omitted snippet... --> 
</template> 

当然,为了让这正常工作,我们需要修改save方法,使其返回跟踪 Fetch 调用的Promise

src/contact-creation.js

//Omitted snippet... 
save() { 
  //Omitted snippet... 

  return this.contactGateway.create(this.contact) 
    .then(() => this.router.navigateToRoute('contacts')); 
} 
//Omitted snippet... 

我将留给读者作为练习,也将这些更改应用于contact-editioncontact-photo组件。

此时,如果你运行应用程序,当你已经进行一个提交任务时,你不应该能够触发多个提交。

创建提交按钮

另一个很好的功能是向用户显示一个视觉指示器,表明一个提交任务正在进行。现在我们已经有了一个自定义属性,用于创建和管理适当的标志,让我们创建一个submit-button自定义元素,当其表单正在提交时,显示一个旋转的动画图标:

src/resources/elements/submit-button.html

<template bindable="disabled"> 
  <button type="submit" ref="button" disabled.bind="disabled" class="btn btn-success"> 
    <span hide.bind="button.form.isSubmitTaskExecuting"> 
      <slot name="icon"> 
        <i class="fa fa-check-circle-o" aria-hidden="true"></i> 
      </slot> 
    </span> 
    <i class="fa fa-spinner fa-spin" aria-hidden="true" show.bind="button.form.isSubmitTaskExecuting"></i> 
    <slot>Submit</slot> 
  </button> 
</template> 

在这里,我们首先在模板元素上声明了一个disabled可绑定属性。这意味着这个元素将由这个模板组成;它将没有视图模型。

接下来,我们声明了一个button元素,具有submit类型。我们还使用ref属性将这个按钮的引用分配给绑定上下文的button属性,并将按钮的disabled属性绑定到disabled可绑定属性。

在按钮内部,我们添加一个span,当按钮的form元素的isSubmitTaskExecuting属性为true时,将其隐藏。在这个span内部,我们定义一个icon插槽,其默认内容是一个复选标记。

我们还在按钮内部添加一个旋转图标,只有在按钮的form元素的isSubmitTaskExecuting属性为true时,才会显示。

最后,我们定义一个默认插槽,其中包含Submit文本作为其默认内容。

这个自定义元素将在没有提交任务进行时简单地显示一个复选标记,并在任何提交任务期间替换这个复选标记为一个旋转图标。然后在提交任务完成后,切换回复选标记。

此外,icon插槽将允许实例覆盖默认的复选标记,而未命名插槽将允许实例覆盖Submit标签。

使用提交按钮

现在我们可以进入带有form元素的各个组件,并将Save按钮替换为新的submit-button元素:

src/contact-creation.html

<template> 
  <!-- Omitted snippet... --> 
  <submit-button>Save</submit-button> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们简单地定义了一个submit-button元素,并在默认插槽上投影了Save文本,这覆盖了其默认标签。

我将留给读者作为练习,也将这些更改应用于contact-editioncontact-photo组件。

此时,如果你运行应用程序,你应该看到各种Save按钮的复选标记被旋转图标替换,当一个提交任务进行时。

自定义视图位置策略

视图位置是定位给定组件的模板或视图的过程。按照约定,模板应该是一个位于视图模型相同的目录中的文件,除了扩展名应该是.html

我们已经看到了一种自定义自定义元素视图位置过程的方法,使用诸如useViewinlineViewnoView的装饰器。需要注意的是,使用这些装饰器并不仅限于自定义元素。它们可以用于任何 Aurelia 组件,如路由组件,或使用compose指令显示的组件。

然而,还有两种其他方式可以自定义视图位置策略。让我们逐一了解它们。

改变约定本身

整个应用程序的常规视图位置策略可以通过重写ViewLocatorconvertOriginToViewUrl方法来改变。这意味着,默认情况下,应用程序中所有组件和自定义元素的观点将使用这个新策略来定位。

让我们想象我们想要改变这个约定。这应该在main模块的configure函数中完成:

src/main.js

import {ViewLocator} from 'aurelia-framework'; 
//Omitted snippet... 

export function configure(aurelia) { 
  //Omitted snippet... 
  ViewLocator.prototype.convertOriginToViewUrl = origin => { 
    let moduleId = origin.moduleId; 
    let id = (moduleId.endsWith('.js') || moduleId.endsWith('.ts')) 
      ? moduleId.substring(0, moduleId.length - 3) 
      : moduleId; 
    return id + '.html'; 
  }; 
  //Omitted snippet... 
} 

在此,我们精确地重新实现了aurelia-templating中的convertOriginToViewUrl方法。这里的约定不会改变。然而,这给你一个很好的启示,了解你如何可以实现自己的视图位置逻辑。

注意

convertOriginToViewUrl方法接收一个Origin实例作为其参数。Origin类有一个moduleId属性,它包含导出组件视图模型类的 JS 文件的路径,还有一个moduleMember属性,它包含视图模型类从其 JS 文件导出的名称。

改变单个组件的策略

改变约定的替代方法是在组件或自定义元素级别指定视图位置策略。这可以通过我们之前看到的视图位置装饰器来实现,如useViewinlineViewnoView

然而,如果你不想依赖 Aurelia 导入给定组件或自定义元素,或者如果你不能使用装饰器,你也可以在视图模型上实现getViewStrategy方法。这个方法预期返回模板文件路径的字符串,或者一个ViewStrategy实例。

aurelia-templating库自带了几种视图策略实现,所有这些都在其对应的视图位置装饰器的背后使用:

  • RelativeViewStrategy:由useView装饰器使用。其构造函数期望与useView相同的参数。

  • InlineViewStrategy:由inlineView装饰器使用。其构造函数期望与inlineView相同的参数。

  • NoViewStrategy:由noView装饰器使用。其构造函数期望与noView相同的参数。

例如,我们可以在我们的联系人管理应用程序的file-picker自定义元素中移除useView装饰器,并使用getViewStrategy方法代替:

src/resources/elements/file-picker.js

import {bindable, bindingMode, inject, DOM, useShadowDOM} from 'aurelia-framework'; 

@inject(DOM.Element) 
@useShadowDOM 
export class FilePickerCustomElement { 
  //Omitted snippet... 

  getViewStrategy() { 
    return './file-picker.html'; 
  } 
} 

在这里,我们成功地将useView从导入语句中移除。此外,我们用getViewStrategy方法替换了装饰器的使用,返回模板文件的路径。

总结

HTML 行为非常强大且灵活。它们为创建复杂且灵活的组件、专门针对单一应用程序的专用组件或可重用、完全可自定义的组件开辟了广阔的可能性,旨在作为第三方插件或框架分发。

它们还提供了一种很好的方法将第三方库集成到 Aurelia 中。我们将在第十一章,与其他库集成中看到如何做到这一点。由于 Aurelia 的模板 API 是开放的且易于使用,我们将能够定制和插入这些集成组件的渲染过程,以完成一些令人惊叹的事情。

但我们还没有达到这个阶段。在下一章中,我们将退后一步,好好看看我们的联系人管理应用程序。我们将思考我们所做的设计选择以及我们没有做出的选择,并看看我们如何可以使事情变得更好。我们还将讨论不同的方法来组织 Aurelia 应用程序,使其更加模块化、可测试和易于维护。

第六章:设计关注 - 组织和解耦

组织大型应用程序可能会变得复杂。取决于应用程序的结构以及其部分之间必须如何相互依赖,决定如何组织代码并不总是显而易见的。当你不熟悉框架时,这一点更是如此。

组织 Aurelia 应用程序有很多方法。像设计和架构相关的任何事物一样,选择一个组织模型是一个权衡许多标准的问题。显然,选择一个模型而不是另一个意味着从中受益,但也需要处理其缺点和限制。

在本章中,我们首先将了解组织应用程序的不同方法,以及框架可以帮助我们做到这一点的各种特性。当然,我们将对我们的联系管理应用程序进行重构,使其具有更可扩展的结构。我们会尝试不同的想法,直到找到一个稳定的结构。

其次,如果构成我们应用程序的组件紧密耦合,基于组件的框架就是徒劳的。在本章的第二部分,我们将看到不同的方法来解耦组件,使用数据绑定、共享服务或 Aurelia 的事件聚合器。

重新组织我们的应用程序

在探索应用程序结构的可能性之前,我们首先需要决定我们的目标是什么。如果我们不知道我们正在努力争取的组织模型的属性,我们就无法做出明智的决定。

当然,这里的属性将是绝对任意的。在真实项目中,有真实的客户、真实的利益相关者和真实的用户,我们至少会有一些线索来了解这些属性可能是什么。在我们联系管理应用程序的案例中,我们将坚持最常见的中型到大型项目中需要的属性。

首先,我们将假设我们的应用程序注定要增长。现在,它只管理联系人,但我们可以想象我们的产品所有者对应用程序有宏伟的计划,最终我们会添加一些完全不相关的功能。

当前的结构,或者它的缺失,适合一个小应用程序。对于一个具有更多独特功能的大型应用程序,项目必须以这样的方式组织,以使开发人员不会在代码中迷失。在我们应用程序的背景下,我们需要选择一个结构,以最小化将来需要重新组织的机会,因为它的结构无法扩展。

第二,我们将努力实现一种允许功能尽可能解耦和独立的架构。目标是使包括和排除应用程序的功能尽可能容易。这个要求对大多数应用程序来说并不典型,但在这个情况下,它将允许我们了解当需要时 Aurelia 如何帮助我们做到这一点。

重构结构

目前,我们的应用程序基本上没有结构,除了全局资源和验证设置,它们作为特性在自己的目录中分组。所有与联系人管理特性相关的文件都位于src目录的根目录中,组件与 API 网关和模型混合在一起。让我们在那里面整理一下。

注意

chapter-6/samples/app-reorganized中找到的示例展示了经过以下章节描述的结构调整后的应用程序。它可以作为参考。

首先,让我们将所有与联系人管理相关的代码放在一个contacts目录中。这使得每个功能都向隔离在自己的目录中迈出了一步。此外,为了减少冗余,让我们将以contact-开头的文件重命名为不带前缀的名称。

项目结构应该像这样之后:

重构结构

这已经更好了。然而,我们可以通过创建子目录来增强聚合性,按其责任类型对文件进行分组。在这里,我们首先有组件creationdetailseditionlistphoto。我们还有一个服务:网关。最后,我们有一些models,它们都被放在同一个文件里。

分解模型

让我们先将模型分解成一个新的models目录,并通过爆炸models.js文件,将每个模型类移动到这个新目录内部的各自文件中。它应该看起来像这样:

分解模型

现在,通过简单的查看models目录,开发者可以看到我们有多个模型以及它们的名称。

当然,这意味着我们必须对这些类进行一些更改。首先,我们必须在address.jsemail-address.jsphone-number.jssocial-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目录,并将剩下的文件移动到里面:

组件分组

此时,应用程序已损坏。我们需要做两件事:修复组件中的模型类和网关的importrequire语句,以及修复app组件的路由声明。

首先,在creation.jsdetails.jsedition.jslist.jsphoto.js中,必须修复网关的import语句:

import {ContactGateway} from '../services/gateway'; 

此外,Contact模型在creation.js中的import语句也必须修复:

import {Contact} from '../models/contact'; 

最后,我们需要通过修复路径并添加别名来更改creation.htmledition.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/servicessrc/models中。在我们的应用程序中没有这些。

  • 每个领域特性都位于自己的目录中,例如contacts目录。

  • 也可以存在技术特性,例如validation特性。这些特性的目的是提供一些通用行为或扩展其他特性。

  • 在特性目录内,文件按责任类型分组。组件,无论是像creationdetailseditionlistphoto这样的路由组件,还是像form.html模板这样的专用小部件或自定义元素,都位于components子目录内。服务和模型也有各自的目录。如果给定特性存在特殊的值转换器或绑定行为,它们也应该位于特性目录内的各自目录中。

这些都是我在构建 Aurelia 应用程序时遵循的指导原则。当然,通常还有需要深思熟虑的情况,要么是因为它们不适合现有的槽位,要么是因为盲目应用这些规则会搞得一团糟。

例如,如果我们的路由组件和专用小部件很多,将components目录分成两个,比如命名为screenswidgets可能是个好主意。这样,更容易识别哪些是路由组件,哪些是特定功能的定制元素或组合小部件。

此外,有时在结构中添加另一层分类会更好,无论是按子域或类别分组功能,还是按更具体的目的分组服务、模型或组件。这里的真正指南是尽量使结构传达意图和隐性知识,以及尽可能容易地理解每个部分的位置。

我尝试遵循的另一条指南是使域功能目录反映出导航菜单结构。当然,当菜单结构过于复杂时,这是不可能的,尽管这可能是一个需要重新思考的信号。当可能时,这显然可以使开发人员更容易、更直观地导航代码和应用程序。

利用子路由

此时,所有与联系人管理相关的代码都位于contacts目录中。但这真的正确吗?实际上,并不正确。路由定义仍然位于app组件中。我们如何将这些移动到contact目录内?

第一个可能性是利用子路由。这样,我们可以在contacts内部声明一个main组件,负责声明到各种联系人管理组件的路由,如listcreationedition。然后,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 前缀的单一路由替换它们。此路由通往contactsmain组件。当然,我们保留了默认路由,它重定向到这个contacts路由。

配置联系人子路由

接下来,我们需要创建contactsmain组件:

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属性。

含义

如果你运行应用程序并对其进行测试,你可能会注意到,现在在通过creationdetailseditionphoto组件导航时,联系人顶菜单项保持高亮状态,而之前只有在list组件活动时才高亮。

这是因为这个菜单项是使用指向contacts组件的main路由生成的,当我们在任何子路由上时,它保持激活状态。这是一个有趣的副作用,增加了用户的反馈,使顶级菜单的行为更加一致。

此外,使用子路由器将声明模块路由的责任移到了模块本身内部。如果需要更改模块的路由,更改将在模块的范围内进行,对应用程序的其余部分没有影响。

然而,子路由器有一些限制。通常,在编写本文时,路由器在生成 URL 时只访问自己的路由。这意味着你不能使用route-href属性,也不能使用Router类的generatenavigateToRoute方法为其他路由器中定义的路由生成 URL,无论这些路由器是父路由器、子路由器还是兄弟路由器。当模块需要彼此之间有直接链接时,这可能是个问题。必须手动生成路由,这意味着路由模式可能在不止一个地方定义,这增加了如果路由模式更改并且开发者只更新了一些模式实例时引入错误的风险。

在功能中声明根路由

这里另一个可能会有帮助的工具是 Aurelia 的feature系统。我们可以利用一个configure函数直接在根路由器上注册联系人管理路由。

让我们恢复到在插入子路由器之前的状态,看看这可能会导致什么结果。

注意

chapter-6/samples/app-using-feature找到的示例展示了根据以下部分修改后的应用程序。它可以作为参考。

创建特性

我们首先需要创建一个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功能的引用。

Alternatively, we could dynamically select the route to which the default route redirects. Since the app component's configureRouter method is called during the component's activation lifecycle, the feature has already been configured at that time and its routes have already been added to the root router. We could simply take the router's first navigation model entry and have the default route redirect to it:

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函数,使其添加到contactsmain组件的路由:

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模式的一些概念。这种模式的一个优点是,每当任何用户对数据进行更改时,应用程序都会立即收到通知,因此应用程序不断地与服务器的状态保持同步。

注意

CQRS 代表命令和查询责任分离,ES 代表事件源。由于定义这些模式超出了本书的范围,如果你对此感到好奇,可以去查看马丁·福勒(Martin Fowler)关于它们的说法:martinfowler.com/bliki/CQRS.htmlmartinfowler.com/eaaDev/EventSourcing.html

当然,在生产就绪的应用程序中,整个同步机制将需要某种形式的冲突管理。实际上,当一个用户正在编辑一个联系人时,如果另一个用户对同一个联系人进行更改,第一个用户将看到表单实时更新,新值覆盖他自己的更改。那是糟糕的。然而,我们不会深入探讨这个问题。让我们将这视为一个概念验证和一个关于使组件通信的实验。

使用数据绑定

使组件通信最常见且简单的方式是通过数据绑定。我们已经看到了很多这样的例子;当我们将edit组件的contact属性与form组件的contact可绑定属性绑定在一起时,我们就使它们进行了通信。

数据绑定允许在模板内松散地耦合组件。当然,它有一些内在的限制:绑定是由父组件声明的,通信限于应用程序树中的一层组件。要使通信超过一个层次,需要树中的每个组件都与其子组件数据绑定。我们可以看到这在photo组件中,其files属性与file-pickerfiles属性绑定,后者又与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: 订阅一个命名的事件,但只有一次。当事件第一次发布时,订阅会自动被销毁。返回的订阅可以手动在事件发布之前就销毁。

subscribesubscribeOnce方法返回的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实例,事件不会在整个应用程序之间或多个实例之间共享。相反,事件将单独每个实例范围内。

使用事件类

publishsubscribesubscribeOnce方法可以使用命名事件,但它们也支持类型化事件。因此,以下签名也是有效的:

  • publish(event: object): void:发布一个事件对象。使用对象的 prototype 作为键来选择要调用的回调函数。

  • 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 createdSomething happened to a contact,因为事件聚合器将遍历原型链并尝试找到所有祖先的订阅。当处理复杂的事件层次结构时,这个功能可能非常强大。

基于类的事件为消息添加了一些结构,因为它们强制事件有效负载遵守一个预定义的合同。根据你的编程风格,你可能会更喜欢使用强类型事件而不是带有未类型载荷的命名事件。它特别适合于像 TypeScript 这样的强类型 JS 超集。

创建一个互动连接

以下内容某种程度上是一种实验,或者是一个概念证明,我建议你在这一点上以某种方式备份你的应用程序,无论是简单地复制和粘贴项目目录,还是如果你从 GitHub 克隆了代码,就在你的源代码控制中创建一个分支。这样,当你继续下一章节时,你就能从当前点开始。

注意

另外,在chapter-6/samples/app-using-server-events找到的示例展示了应用程序按照以下章节修改后的样子。它可以作为参考。

我们使用的后端接受互动连接,以便将事件分发给客户端应用程序。使用这种互动连接,它可以在每次创建、更新或删除联系时通知连接的客户端。为了分发这些事件,后端依赖于WebSocket协议。

注意

WebSocket 协议允许客户端与服务器之间建立长生命周期的、双向的连接。因此,它允许服务器向连接的客户端发送基于事件的消息。

在本节中,我们将创建一个名为ContactEventDispatcher的服务。这个服务将与后端创建一个 WebSocket 连接,并监听服务器发送的更改事件,以便通过应用程序的事件聚合器在本地分派它们。

为了与服务器创建一个互动连接,我们将使用socket.io库。

注意

socket.io库为交互式连接提供了客户端实现和 node.js 服务器,两者都支持 WebSocket,并在 WebSocket 不受支持时提供回退实现。后端已经使用这个库来处理应用程序的交互式连接。它可以在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函数,使用environmentcontactUrl与服务器创建一个connection。然后创建一个新的Promise,将其分配给connecting属性,并通过activate方法返回。这个Promise允许监控连接到后端的过程状态,因此调用者可以连接到它以在连接建立时做出反应。此外,该方法还确保在任何给定时间只打开一个到后端的connection。如果多次调用activate,则返回connecting Promise

当后端接收到一个新的连接时,它会发送当前联系人列表作为一个名为contacts.loaded的事件。因此,一旦activate方法初始化连接,它就会监听这个事件,并在事件聚合器上重新发布它。这样做时,它还将从服务器接收的初始对象列表转换为Contact对象的数组。最后,它解决connecting Promise以通知调用者activate操作已完成。

该类还暴露了一个deactivate方法,该方法关闭并清除连接。

在这个阶段,当它开始时,分发器发布一个包含当前联系人列表的contacts.loaded事件。然而,后端还可以发送多达三种类型的事件:

  • contact.created,当创建新的联系人时。

  • contact.updated,当更新联系人时。

  • contact.deleted,当一个联系人被删除时。

这些事件的每个负载都有一个contact属性,其中包含执行命令的联系人。

根据这些信息,我们可以修改分发器,使其监听这些事件并在本地重新发布它们:

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对象,并将事件重新发布到应用程序的事件聚合器上。

一旦准备好,我们需要在contacts特性的configure函数中activate事件监听器。然而,分发器在初始化连接时使用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();
 }); 
} 

在这里,我们添加了一个后配置任务,当所有插件和功能都加载完成后激活分发器。此外,由于后配置任务支持Promises,我们可以返回由activate返回的Promise,因此我们确信当框架的引导过程完成后,与后端的交互式连接已完成,并且初始联系人已加载。

添加通知

至此,我们的main组件的contacts列表监听服务器事件,并在本地分发它们。然而,我们仍然对那些事件不做任何事情。让我们添加一些通知,当服务器上发生某些事情时告诉用户。

我们将添加一个通知系统,每当后端发送一个变更事件时,都会让用户知道。因此,我们将使用一个名为humane.js的库,该库可以在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对象被注入到其构造函数中。当它被attached到 DOM 时,它会订阅contact.createdcontact.updatedcontact.deleted事件,在发布时显示适当的通知。它还存储由EventAggregatorsubscribe方法返回的订阅在一个数组中,这样它就可以在从 DOM 中detached时释放这些订阅。

为了使用这个自定义元素,我们需要通过添加一个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 集成时遇到的一个棘手的问题。这是由于aurelia-app属性在body元素上引起的。

确实,有些库在加载时会向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> 

现在,如果您刷新浏览器然后执行某些操作,例如更新一个联系人,您应该会在视图区域的顶部看到一个通知显示几秒钟。

注意

作为一个经验法则,我从不直接在body中放置aurelia-app属性。我通过多次花费太多时间试图弄清楚为什么我项目中集成的外部库不起作用而学到了这个教训。

模拟多用户场景

至此,我们的应用程序能够在服务器上发生更改时通知用户,即使这是由另一个用户完成的。让我们测试一个多用户场景。为此,应用程序必须使用除了 Aurelia 的 CLI 之外的东西运行,因为截至撰写本文时,浏览器同步功能会与我们的一致性机制发生冲突。

最简单的解决方案是安装http-server节点模块,如果你还没有安装,可以通过运行以下命令来安装:

> npm install -g http-server

然后,你可以构建我们的应用程序:

> au build

一旦这个命令完成,你可以启动一个简单的 HTTP 服务器:

> http-server -o -c-1

然后,你可以在两个浏览器窗口中打开应用程序,并将它们并排放置。在一个窗口中执行创建新联系人或更新现有联系人的操作。你应该会在两个窗口中都看到通知弹出。

使用共享服务

目前,我们的应用程序大部分是无状态的,因为每个路由组件都从服务器加载其数据。没有路由组件依赖于其范围之外的全局状态。

然而,有时应用程序需要存储一个全局状态。这个状态通常由某种服务管理,可以通过数据绑定将状态传播给组件,或者通过依赖注入系统将它们注入,在这种情况下,依赖关系在 JS 代码中声明和控制,而不是在模板中。

有很多场景在本地存储状态是有利的,甚至是必需的。它可以让节省带宽,减少对后端的调用。如果你想让你的应用离线可用,你可能需要在某个时候本地存储一个状态。

在本节中,我们将通过创建一个服务来重构我们的应用程序,这个服务将被所有路由组件共享,并允许它们访问相同的本地数据。这个服务将作为本地数据存储,并依赖于我们在上一节中创建的分发器发布的事件来初始化其状态并与服务器的状态保持同步。

创建内存中的存储

我们将通过创建一个名为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方法订阅了分发器发布的各种事件,以便它可以保持其联系人列表的最新:

  • 当他接收到contacts.loaded事件时,它使用事件负载中包含的新联系人列表重置contacts数组

  • 当他接收到contact.created事件时,它首先确保联系人不已经在数组中使用其id,如果不在,则添加它

  • 当他接收到contact.updated事件时,它使用其id检索更新后的联系人的本地副本并更新其所有属性

  • 当他接收到contact.deleted事件时,他在数组中找到联系人的索引,总是使用它的id,并把它拿出来

这个存储现在能够从服务器检索联系人的本地列表,并保持自己最新。

使用存储

我们现在可以修改所有执行读操作的路由组件,使它们使用这个存储而不是网关。让我们逐一进行。

首先,creation组件不需要更改。

接下来,必须修改detailseditionphoto组件。对于它们中的每一个,我们需要做的是:

  1. 导入ContactStore

  2. inject装饰器中添加ContactStore类,以便在构造函数中注入

  3. 在构造函数中添加一个store参数

  4. 在构造函数中,将store参数分配给store属性

  5. activate方法中,用对store的调用替换对gatewaygetById方法的调用

以下是更改后的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'); }); 
    } 
  } 
} 

注意gatewaydelete操作仍然被调用。实际上,所有的写操作仍然使用ContactGateway类执行。然而,所有的读操作现在将使用ContactStore服务执行,因为它保持了与服务器状态同步的本地副本。

因此,最后,list组件也必须进行修改。我们需要做的是:

  1. ContactGateway导入更改为ContactStore导入

  2. inject装饰器中将ContactGateway类的依赖更改为ContactStore

  3. 删除contacts属性声明和初始化

  4. 将构造函数的gateway参数替换为store参数

  5. 在构造函数中,通过将store参数的contacts属性分配给this.contacts来删除gateway属性的分配

  6. 删除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; 
  } 
} 

我们可以在这里看到状态共享的核心。storecontacts属性包含一个数组,它是实际的状态持有者。正是这个数组,通过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类中的getAllgetById方法,因为它们已经不再使用了。

在此阶段,如果你运行应用程序,一切应该仍然和以前一样工作。

总结

设计有价值的应用程序几乎从来不是简单的。它总是关于权衡许多因素,决定哪些利弊是有益的,哪些是可以接受的:

  • 子路由使得顶部菜单的活动项目表现更好,而根路由则不然。

  • 子路由使得跨特性拥有链接变得困难,而根路由则使之变得容易。

  • 特性有助于在 Aurelia 应用程序中隔离和集成领域或技术特性。

  • 数据绑定是连接组件的最简单方法。然而,它有局限性。

  • 使用一个删除服务来通信数据是让组件通信的另一种非常简单的方法。然而,它可能会占用带宽,可能会对远程服务造成一些负载,并且使远程服务器成为单点故障,如果用户没有网络连接或远程服务宕机,应用程序将无法使用。

  • 将服务共享给组件以实现通信是多功能的,但增加了复杂性。

  • 使用事件来让组件进行通信增加了可扩展性和解耦,但也增加了复杂性。在大型应用程序中,需要有纪律性,以便事件容易被发现。

有些利弊可能看起来很微小,我倾向于同意,在大多数情况下,一个菜单项不是一直高亮显示并不是什么大问题,但在一些项目中这可能无法接受。我所能做的就是给你提供工具,让你自己做出明智的决策。

第七章:测试所有事物

自动化测试已经成为大多数现代软件开发过程的重要组成部分。敏捷方法论和软件工艺等方法强调自动化测试的重要性,并经常提倡进行全面测试驱动开发(TDD)的实践。

一套良好的自动化测试可以为项目增加巨大价值,因为它确保了任何破坏现有特性的代码更改都不会被忽视。因此,测试建立了信心。多亏了它们,开发者才不怕更改事物,玩转想法,重构,让代码变得更好。他们控制着自己的代码库。

无论你是否实践 TDD,你可能都希望对你的 Aurelia 应用进行一定程度的自动测试。这就是这一章要讲的内容。

为了使测试 Aurelia 项目更容易,Aurelia 团队选择了一组通常用于测试 JavaScript 项目的库JasmineKarmaProtractor,并将它们包括在项目骨架和 CLI 项目生成器中,以及它们相应的配置和项目中的测试运行任务。

  • Jasmine 是一个流行的 JS 测试框架,我们将用它来进行单元测试和端到端测试。它的位置在 jasmine.github.io/

  • Karma 是一个测试运行器,被测试任务在幕后使用。它的位置在 karma-runner.github.io/

  • Protractor 是一个端到端测试框架,提供了一个丰富的 API 来与浏览器交互。它的位置在 www.protractortest.org/

单元测试

在下一节中,我们将探讨如何对 Aurelia 应用进行单元测试,主要是通过在我们的联系人管理应用中添加单元测试。

注意

如果你不熟悉 Jasmine,你应该将其文档放在手边,因为阅读这一章时你可能需要查阅: jasmine.github.io/2.0/introduction.html

运行单元测试

使用 CLI 创建的项目包括一个运行单元测试的任务。这个任务定义在aurelia_project/tasks/test.js文件中,它只是使用位于项目根目录的配置文件karma.conf.js来启动 Karma。

这个任务可以通过在项目目录中打开控制台并运行以下命令来执行:

> au test

这个命令将启动单个测试运行,并在控制台输出结果。

run 任务类似,test 任务可以通过添加 watch 开关来修改,使其监视测试文件,并在检测到任何更改时重新运行:

> au test --watch

这个命令将启动一个测试运行,并监视测试文件,在每次更改后重新运行测试。

配置验证

如果你查看了aurelia-validation的代码,你可能会注意到这个插件需要在ValidationRules类使用之前加载。这是因为ValidationRules暴露的方法需要类的静态初始化,用一个ValidationParser实例,以便在错误消息中解析字符串插值等。

由于我们的模型类,如ContactPhoneNumberAddress等,在其构造函数中依赖于ValidationRules类,如果我们不首先初始化它,我们将无法在任何一个测试中使用这些模型类。另外,我们的自定义验证规则在使用之前也必须加载。

因此,让我们添加一个设置文件,它将在每次测试运行开始时初始化验证:

test/unit/setup-validation.js

import {Container} from 'aurelia-dependency-injection'; 
import {BindingLanguage} from 'aurelia-templating'; 
import {TemplatingBindingLanguage}  
  from 'aurelia-templating-binding'; 
import {ValidationParser, ValidationRules}  
  from 'aurelia-validation'; 
import '../../src/validation/rules'; 

const container = new Container(); 
container.registerSingleton( 
  BindingLanguage, TemplatingBindingLanguage); 
const parser = container.invoke(ValidationParser); 
ValidationRules.initialize(parser); 

在这里,我们首先导入rules文件,以便我们的自定义验证规则被正确注册。

接下来,我们将创建一个 DI 容器并初始化解析器所需的绑定语言实现,然后使用它来创建一个ValidationParser实例,我们用它来初始化ValidationRules类。

最后,让我们将此文件添加到单元测试设置中:

test/aurelia-karma.js

//Omitted snippet... 
function requireTests() { 
  var TEST_REGEXP = /(spec)\.js$/i; 
  var allTestFiles = [ 
    '/base/test/unit/setup.js', 
    '/base/test/unit/setup-validation.js' 
  ]; 

  Object.keys(window.__karma__.files).forEach(function(file) { 
    if (TEST_REGEXP.test(file)) { 
      allTestFiles.push(file); 
    } 
  }); 

  require(allTestFiles, window.__karma__.start); 
} 
//Omitted snippet... 

在这里,我们只需将setup-validation.js文件添加到 Karma 在开始测试运行时使用require加载的文件列表中。

配置 Bluebird 警告

让我们也配置 Bluebird Promise 库的警告,以便我们的控制台不会充斥着警告:

test/unit/setup.js

import 'aurelia-polyfills'; 
import {initialize} from 'aurelia-pal-browser'; 
initialize(); 

Promise.config({ 
  warnings: { 
    wForgottenReturn: false 
  } 
}); 

在这里,我们只需复制并粘贴src/main.js顶部的Promise配置。

在这个阶段,我们可以开始舒适地编写单元测试。

注意

test/unit/app.spec.js文件包含了由 CLI 在初始化项目时创建的app组件的示例测试。因为自我们开始以来这个组件已经完全改变了,所以这些测试不再相关并且会失败,所以你应该删除这个文件。

按照约定,包含单元测试的文件具有.spec.js扩展名。Aurelia 项目中的默认 Karma 配置期望测试位于遵循此命名约定的文件中,因此在我们联系管理应用程序中我们将遵循这一约定。

模型单元测试

我们将首先测试模型类。它们包含一些关键功能,我们想确保它们能正常工作。

然而,让我们首先确保我们的包是最新的,通过打开一个控制台并运行一个构建:

> au build

然后,为了让编写测试的过程更加流畅,让我们首先启动一个控制台并开始持续测试过程:

> au test -watch

任务应该开始运行,并且应该显示类似这样的内容:

Chrome 53.0.2785 (Windows 10 0.0.0): Executed 0 of 0 ERROR (0.015 secs / 0 secs)

测试运行返回一个错误,因为它找不到要运行的任何测试。让我们改变这个。

测试静态工厂方法

我们要写的第一个测试将确保用一个空对象调用fromObject方法创建一个空的PhoneNumber对象:

test/unit/contacts/models/phone-number.spec.js

import {PhoneNumber} from '../../../../src/contacts/models/phone-number'; 

describe('the PhoneNumber class', () => { 
  it('should create empty PhoneNumber when creating from empty object',  
  () => { 
    const result = PhoneNumber.fromObject({}); 
    expect(result).toEqual(new PhoneNumber()); 
  }); 
}); 

在这里,我们定义了一个测试用例,使用一个空对象调用fromObject静态方法,然后确保结果等于一个空PhoneNumber对象。

如果你保存文件并查看控制台,你应该会看到类似这样的消息:

Chrome 53.0.2785 (Windows 10 0.0.0): Executed 1 of 1 SUCCESS (0.016 secs / 0.008 secs)

让我们再写一个测试,测试fromObject方法的另一个角度。它会确保标量属性被正确地复制到新的PhoneNumber对象中:

test/unit/contacts/models/phone-number.spec.js

import {PhoneNumber} from '../../../../src/contacts/models/phone-number'; 

describe('the PhoneNumber class', () => { 
  //Omitted snippet... 

  it('should map all properties when creating from object', () => { 
    const src = { 
      type: 'Mobile', 
      number: '1234567890' 
    }; 
    const result = PhoneNumber.fromObject(src);
for (let property in src) { 
      expect(result[property]).toEqual(src[property]); 
    } 
  }); 
}); 

在这里,我们的新测试使用一个具有预期标量属性的对象调用fromObject静态方法:typenumber。然后,我们确保每个属性都被正确地复制到结果的PhoneNumber对象中。

这样的测试也应添加到EmailAddressAddressSocialProfile类中,每个类在自己的文件中:email-address.spec.jsaddress.spec.jssocial-profile.spec.js,遵循相同的模式。我将留下这个作为读者的练习。本章节的示例应用程序可以作为参考。

既然已经测试了列表项类,让我们为Contact类写测试。我们从之前写的相同类型的测试开始:

test/unit/contacts/models/contact.spec.js

import {Contact} from '../../../../src/contacts/models/contact'; 

describe('the Contact class', () => { 

  it('should create empty Contact when creating from empty object', () => { 
    const result = Contact.fromObject({}); 
    expect(result).toEqual(new Contact()); 
  }); 

  it('should map all properties when creating from object', () => { 
    const src = { 
      firstName: 'Never gonna give you up', 
      lastName: 'Never gonna let you down', 
      company: 'Never gonna run around and desert you', 
      birthDay: '1987-11-16', 
      note: 'Looks like you've been rickrolled' 
    }; 
    const result = Contact.fromObject(src); 

    for (let property in src) { 
      expect(result[property]).toEqual(src[property]); 
    } 
  }); 
}); 

然而,Contact类的fromObject方法不仅仅是复制属性,它还将列表项映射到相应的模型类。让我们添加一些测试来确保这能正常工作:

test/unit/contacts/models/contact.spec.js

import {Contact} from '../../../../src/contacts/models/contact'; 
import {Address} from '../../../../src/contacts/models/address'; 
import {EmailAddress} from '../../../../src/contacts/models/email-address'; 
import {PhoneNumber} from '../../../../src/contacts/models/phone-number'; 
import {SocialProfile} from '../../../../src/contacts/models/social-profile'; 

describe('the Contact class', () => { 
  //Omitted snippet... 

  it ('should map phone numbers when creating from object', () => { 
    const result = Contact.fromObject({ phoneNumbers: [{}, {}] }); 
    const expected = [new PhoneNumber(), new PhoneNumber()]; 

    expect(result.phoneNumbers).toEqual(expected); 
  }); 

  it ('should map email addresses when creating from object', () => { 
    const result = Contact.fromObject({ emailAddresses: [{}, {}] }); 
    const expected = [new EmailAddress(), new EmailAddress()]; 

    expect(result.emailAddresses).toEqual(expected); 
  }); 

  it ('should map addresses when creating from object', () => { 
    const result = Contact.fromObject({ addresses: [{}, {}] }); 
    const expected = [new Address(), new Address()]; 

    expect(result.addresses).toEqual(expected); 
  });
it ('should map social profiles when creating from object', () => { 
    const result = Contact.fromObject({ socialProfiles: [{}, {}] }); 
    const expected = [new SocialProfile(), new SocialProfile()];
expect(result.socialProfiles).toEqual(expected); 
  }); 
}); 

在这里,我们添加了列表项类的import语句。然后我们添加了四个测试用例,每个测试用例对应一个列表项类,确保每个情况下对象数组被正确地映射到相应的类中。

测试计算属性

当涉及到单元测试时,计算属性与函数没有什么不同。让我们写一些测试来覆盖Contact类的isPerson属性:

test/unit/contacts/models/contact.spec.js

//Omitted snippet... 
it('should be a person if it has a firstName and no lastName', () => { 
  const sut = Contact.fromObject({ firstName: 'A first name' }); 
  expect(sut.isPerson).toBeTruthy(); 
}); 

it('should be a person if it has a lastName and no firstName', () => { 
  const sut = Contact.fromObject({ lastName: 'A last name' }); 
  expect(sut.isPerson).toBeTruthy(); 
}); 

it('should be a person if it has a firstName and a lastName', () => { 
  const sut = Contact.fromObject({  
    firstName: 'A first name', 
    lastName: 'A last name' 
  }); 
  expect(sut.isPerson).toBeTruthy(); 
}); 

it('should not be a person if it has no firstName and no lastName', () => { 
  const sut = Contact.fromObject({ company: 'A company' }); 
  expect(sut.isPerson).toBeFalsy(); 
}); 
//Omitted snippet... 

在这里,我们添加了四个测试用例,以确保isPerson属性正确地行为。

注意

存储测试将应用的实例的变量名为sut,代表被测试的系统。许多自动化测试的作者认为这是一个标准术语。我喜欢使用这个缩写,因为它能清楚地标识测试的对象。

我将留给读者作为练习来编写fullNamefirstLetter属性的测试用例。本章节的示例应用程序可以作为参考。

单元测试服务

测试服务也是非常直接的。在我们的联系人管理应用程序中,我们有一个服务:ContactGateway。然而目前它并不是非常便于测试,主要问题是它的构造函数,它配置了HttpClient实例。

从网关构造函数中移除配置

让我们重构我们的网关,使其更容易测试。我们将把HttpClient的配置移动到功能的configure函数中,这样ContactGateway的构造函数就不包含任何配置逻辑:

src/contacts/index.js

import {Router} from 'aurelia-router'; 
import {HttpClient} from 'aurelia-fetch-client'; 
import {ContactGateway} from './services/gateway'; 
import environment from 'environment'; 

export function configure(config) { 
  const router = config.container.get(Router); 
  router.addRoute({ route: 'contacts', name: 'contacts',  
    moduleId: 'contacts/main', nav: true, title: 'Contacts' }); 

  const httpClient = config.container.invoke(HttpClient) 
    .configure(config => { config 
      .useStandardConfiguration() 
      .withBaseUrl(environment.contactsUrl); 
    }); 
  config.container.registerInstance(ContactGateway,  
    new ContactGateway(httpClient)); 
} 

在这里,我们使用 DI 容器创建一个HttpClient实例并对其进行配置,然后创建一个ContactGateway实例,我们在 DI 容器中注册它。您可能会注意到我们没有在容器中注册HttpClient本身。在大多数应用程序中,这样做是完全没问题的。然而,由于我们希望功能尽可能独立,其他功能可能会使用不同的HttpClient实例来调用不同的后端,所以我们不注册这个,因为它可能会与其他功能发生冲突。

接下来,我们可以从ContactGateway的构造函数中删除配置代码:

src/contacts/services/gateway.js

import {inject} from 'aurelia-framework'; 
import {HttpClient, json} from 'aurelia-fetch-client'; 
import {Contact} from '../models/contact'; 

@inject(HttpClient) 
export class ContactGateway { 

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

  //Omitted snippet... 
} 

ContactGateway的构造函数现在没有任何配置逻辑。

自从我们在应用程序中更改了代码后,在添加测试之前我们需要重新构建它:

> au build

测试读方法

让我们先为ContactGateway的两个读方法编写一些测试:

test/unit/contacts/services/gateway.spec.js

import {ContactGateway}  
  from '../../../../src/contacts/services/gateway';  
import {Contact} from '../../../../src/contacts/models/contact'; 

describe('the ContactGateway class', () => { 

  let httpClient, sut; 

  beforeEach(() => { 
    httpClient = jasmine.createSpyObj('HttpClient', ['fetch']); 
    sut = new ContactGateway(httpClient); 
  }); 

  function createContact() { 
    return Contact.fromObject({ id: 1, company: 'Blue Spire' }); 
  } 

  function createJsonResponseMock(content) { 
    return { json: () => Promise.resolve(content) }; 
  } 

  it('should fetch all contacts', done => { 
    const contacts = [createContact()]; 
    httpClient.fetch.and.returnValue(Promise.resolve( 
      createJsonResponseMock(contacts))); 

    sut.getAll() 
      .then(result => expect(result).toEqual(contacts)) 
      .then(() => expect(httpClient.fetch) 
        .toHaveBeenCalledWith('contacts')) 
      .then(done); 
  }); 

  it('should fetch a contact by its id', done => { 
    const contact = createContact(); 
    httpClient.fetch.and.returnValue(Promise.resolve( 
      createJsonResponseMock(contact))); 

    sut.getById(contact.id) 
      .then(result => expect(result).toEqual(contact)) 
      .then(() => expect(httpClient.fetch) 
        .toHaveBeenCalledWith(`contacts/${contact.id}`)) 
      .then(done); 
  }); 
}); 

在这里,我们首先使用 Jasmine 的beforeEach函数定义一个测试设置。这个测试设置将在每个测试用例之前执行。在这个设置中,我们首先为HttpClient创建一个模拟对象,然后我们创建一个ContactGateway实例,我们的测试将对其进行操作。

接下来,我们定义了两个帮助函数:第一个用于创建一个Contact对象,第二个用于创建一个具有 JSON 正文的响应对象的模拟。这两个函数将被我们的测试用例使用。

最后,我们编写测试用例以验证getAllgetById方法是否正常工作。这两个测试用例都是异步测试,所以它们需要一个done函数作为参数,当测试完成后它们将调用这个函数。它们都遵循相同的模式:

  1. 创建应该由测试方法返回的Contact对象。

  2. 配置模拟的HttpClientfetch方法,使其返回一个Promise,该Promise解析为一个模拟的响应对象,它暴露出作为 JSON 正文返回的数据。

  3. 调用测试方法,当它解析时:

  • 检查返回的Promise解析为预期的数据

  • 检查HttpClientfetch方法是否用适当的参数调用

测试写方法

测试写方法相当相似。然而,它需要做一些额外的工作,因为目前 HTML5 File API 没有提供一种简单的方法来比较Blob对象。所以为了测试我们网关发送的请求的正文,我们需要编写一些帮助函数:

test/unit/contacts/services/gateway.spec.js

//Omitted snippet... 

function readBlob(blob) { 
  return new Promise(resolve => { 
    let reader = new FileReader(); 
    reader.addEventListener("loadend", () => {  
      resolve(reader.result); 
    }); 
    reader.readAsText(blob); 
  }); 
} 

function expectBlobsToBeEqual(result, expected) { 
  expect(result.type).toEqual(expected.type); 
  expect(result.size).toEqual(expected.size); 

  return Promise 
    .all([ readBlob(result), readBlob(expected) ]) 
    .then(([c1, c2]) => expect(c1).toEqual(c2)); 
} 

function expectFetchToHaveBeenCalled(expectedPath,  
                                     expectedProperties) { 
  let expectedBody; 
  if (expectedProperties.body) { 
    expectedBody = expectedProperties.body; 
    delete expectedProperties.body; 
  } 

  expect(httpClient.fetch).toHaveBeenCalledWith(expectedPath,    
    jasmine.objectContaining(expectedProperties)); 
  if (expectedBody) { 
    return expectBlobsToBeEqual( 
      httpClient.fetch.calls.mostRecent().args[1].body,  
      expectedBody); 
  } 
} 
//Omitted snippet... 

第一个助手函数,名为readBlob,简单地接受一个Blob对象作为其参数,并返回一个Promise,该Promise解析为Blob内容作为一个字符串。由于读取Blob内容的过程是异步的,它只是用一个Promise包装这个过程。

第二个助手函数,名为expectBlobsToBeEqual,期望两个Blob对象作为其参数。它首先比较它们的typesize属性以确保它们相等,然后使用readBlob来检索两个Blob对象的内容并比较结果以确保它们也相等,返回结果Promise

最后一个助手函数,名为expectFetchToHaveBeenCalled,接收预期的路径和预期的请求属性。它首先从预期的请求属性中提取预期的主体,如果有,从对象中删除它。然后,它确保HttpClient的模拟fetch方法已经用预期的路径和减去主体的预期请求属性被调用,因为比较Blob对象是一个必须单独执行的异步过程。最后,如果提供了预期的主体,它使用传递给最后一个fetch调用的主体和预期的主体调用expectBlobsToBeEqual函数,并返回结果Promise

这个最后的助手函数将帮助我们编写关于我们的网关如何调用其HttpClientfetch方法的断言。让我们从一个create方法的测试开始:

test/unit/contacts/services/gateway.spec.js

import {json} from 'aurelia-fetch-client'; 
//Omitted snippet... 

it('should create a contact', done => { 
  const contact = createContact(); 
  httpClient.fetch.and.returnValue(Promise.resolve()); 

  sut.create(contact) 
    .then(() => expectFetchToHaveBeenCalled( 
      'contacts',  
      { method: 'POST', body: json(contact) })) 
    .then(done); 
}); 
//Omitted snippet... 

在这里,我们首先从 Fetch 客户端导入json函数。我们将使用它将预期的请求负载转换为 JSON 编码的Blob对象。

这个测试本身相当直接,为接下来的测试设定了路径,这些测试将遵循相同的模式:

  1. 创建一个Contact对象,将被传递给被测试的方法。

  2. 配置HttpClient的模拟fetch方法,使其返回一个解决的Promise

  3. 调用被测试的方法,当它解决时,检查HttpClientfetch方法是否用正确的参数被调用。

updateupdatePhoto方法的压力测试非常相似:

test/unit/contacts/services/gateway.spec.js

//Omitted snippet... 
it('should update a contact', done => { 
  const contact = createContact(); 
  httpClient.fetch.and.returnValue(Promise.resolve()); 

  sut.update(contact.id, contact) 
    .then(() => expectFetchToHaveBeenCalled( 
      `contacts/${contact.id}`,  
      { method: 'PUT', body: json(contact) })) 
    .then(done); 
}); 

it("should update a contact's photo", done => { 
  const id = 9; 
  const contentType = 'image/png'; 
  const file = new File(['some binary content'], 'img.png', { 
    type: contentType 
  }); 
  httpClient.fetch.and.returnValue(Promise.resolve()); 

  const expectedRequestProperties = { 
    method: 'PUT', 
    headers: { 'Content-Type': contentType }, 
    body: file 
  }; 
  sut.updatePhoto(id, file) 
    .then(() => expectFetchToHaveBeenCalled( 
      `contacts/${id}/photo`,  
      expectedRequestProperties)) 
    .then(done); 
}); 
//Omitted snippet... 

这两个测试遵循与之前一个相同的模式。

对值转换器进行单元测试

测试值转换器与测试服务并没有太大区别。当然,这取决于你需要测试的转换器的复杂性。在我们的联系人管理应用程序中,值转换器相当简单。

让我们为我们的orderBy值转换器写一个或两个测试来了解一下它:

test/unit/resources/value-converters/order-by.spec.js

import {OrderByValueConverter}  
  from '../../../../src/resources/value-converters/order-by'; 

describe('the orderBy value converter', () => { 
  let sut; 

  beforeEach(() => { 
    sut = new OrderByValueConverter(); 
  }); 

  it('should sort values using property', () => { 
    const array = [ { v: 3 }, { v: 2 }, { v: 4 }, { v: 1 }, ]; 
    const expectedResult = [ { v: 1 }, { v: 2 },  
      { v: 3 }, { v: 4 }, ]; 

    const result = sut.toView(array, 'v'); 

    expect(result).toEqual(expectedResult); 
  }); 

  it('should sort values in reverse order when direction is "desc"', () => { 
    const array = [ { v: 3 }, { v: 2 }, { v: 4 }, { v: 1 }, ]; 
    const expectedResult = [ { v: 4 }, { v: 3 },  
      { v: 2 }, { v: 1 }, ]; 

    const result = sut.toView(array, 'v', 'desc'); 

    expect(result).toEqual(expectedResult); 
  }); 
}); 

在这里,我们首先定义一个简单的测试设置,创建测试主题,然后我们添加两个测试用例。第一个验证传递给toView方法的数组是否正确地使用指定的属性进行排序。第二个验证当"desc"作为第三个参数传递时,传递给toView方法的数组是否按降序排序。

当然,如果测试支持的值转换器支持双向绑定并且有一个fromView方法,应该添加额外的测试用例来涵盖这个第二个方法。

我将留给读者一个练习,为groupByfilterBy值转换器编写测试。本章的示例应用程序可以作为参考。

单元测试自定义元素和属性

到目前为止我们所写的所有测试都与 Aurelia 关系不大。我们测试的代码可以在一个完全不同的 UI 框架中使用,而且很可能不需要做任何改变。这是因为我们还没有测试任何视觉方面。

当测试自定义元素和属性时,我们可能会满足于我们之前编写的测试类型,并且只测试它们的视图模型。这些测试将只涵盖组件的行为方面。然而,能够涵盖组件整体的测试,包括它们的视图对应部分,将会更加强大。

组件测试器

幸运的是,Aurelia 提供了aurelia-testing库,可以用来全面测试组件。因此,它导出两个重要的类:StageComponentComponentTester

StageComponent类有一个单一的静态方法:

withResources(resources: string | string[]): ComponentTester 

这个方法简单地在幕后创建一个ComponentTester类的实例,调用它自己的withResources方法,然后返回它。StageComponent基本上只是对组件测试器的 API 糖。以下两行可以互换而不产生任何效果:

var tester = StageComponent.withResources('some/resources') 
var tester = new ComponentTester().withResources('some/resources') 

ComponentTester类提供了一个 API 来配置一个短暂存在的、沙盒化的 Aurelia 应用程序,在该应用程序中,被测试的组件将在测试期间运行:

  • withResources(resources: string | string[]): ComponentTester: 将提供的资源作为全局资源加载到沙盒应用程序中。

  • inView(html: string): ComponentTester: 使用提供的 HTML 作为沙盒应用程序的根视图。

  • boundTo(bindingContext: any): ComponentTester: 使用提供的值作为沙盒应用程序的根视图的绑定上下文。

  • manuallyHandleLifecycle(): ComponentTester: 告诉组件测试器应用程序的生命周期应该由测试用例手动处理。

  • bootstrap(configure: (aurelia: Aurelia) => void): void: 使用提供的函数配置沙盒 Aurelia 应用程序。默认情况下,应用程序使用aurelia.use.standardConfiguration()进行配置。这个方法可以用来加载组件所需的额外插件或功能。

  • create(bootstrap: (aurelia: Aurelia) => Promise<void>): Promise<void>:使用提供的引导函数创建沙盒应用程序。通常,这里会使用aurelia-bootstrapper库的bootstrap函数。返回的Promise在应用程序加载并启动后解决。

  • bind(): Promise<void>:绑定沙盒应用程序。它只能在手动处理应用程序生命周期时使用。

  • attached(): Promise<void>:将沙盒应用程序附加到 DOM。它只能在手动处理应用程序生命周期时使用。

  • detached(): Promise<void>:将沙盒应用程序从 DOM 中分离。它只能在手动处理应用程序生命周期时使用。

  • unbind(): Promise<void>:解绑沙盒应用程序。它只能在手动处理应用程序生命周期时使用。

  • dispose():清理沙盒应用程序的所有资源并完全将其从 DOM 中移除。

在撰写本文时,aurelia-testing库仍处于测试阶段,因此在发布之前可能会向其添加一些新功能。

测试 file-drop-target 属性

让我们通过编写一个针对我们在第五章,创建可复用组件中编写的file-drop-target自定义属性的测试套件,看看如何使用组件测试器:

test/unit/resources/attributes/file-drop-target.spec.js

import {StageComponent} from 'aurelia-testing'; 
import {bootstrap} from 'aurelia-bootstrapper'; 

describe('the file-drop-target custom attribute', () => { 

  let viewModel, component, element; 

  beforeEach(() => { 
    viewModel = { files: null }; 
    component = StageComponent 
      .withResources('resources/attributes/file-drop-target') 
      .inView('<div file-drop-target.bind="files"></div>') 
      .boundTo(viewModel); 
  }); 

  function create() { 
    return component.create(bootstrap).then(() => { 
      element = document 
        .querySelector('[file-drop-target\\.bind]'); 
    }); 
  } 

  afterEach(() => { 
    component.dispose(); 
  }); 
}); 

在这里,我们首先创建一个空的测试套件,它包含使用beforeEach函数的测试设置和使用afterEach函数的测试清理。在测试设置中,我们首先创建一个具有files属性的viewModel对象,该属性将绑定到我们的file-drop-target属性。其次,我们使用StageComponent类创建一个沙盒 Aurelia 应用程序,在该应用程序中,我们的自定义属性将在每次测试中运行。

这个沙盒应用程序将file-drop-target属性作为全局资源加载。其根视图将是一个带有file-drop-target属性的div元素,绑定到根绑定上下文的files属性,这将是viewModel对象。

我们还定义了一个create辅助函数,该函数将创建和引导沙盒应用程序,并在应用程序渲染后检索托管我们的file-drop-target属性的element

最后,在测试清理过程中,我们只需dispose沙盒。

为了测试file-drop-target自定义属性,我们将需要在我们正在测试的属性托管的element上触发拖放事件。因此,让我们先编写一个工厂函数来创建此类事件:

test/unit/resources/attributes/file-drop-target.spec.js

import {DOM} from 'aurelia-pal'; 
//Omitted snippet...  
function createDragEvent(type, dataTransfer) { 
  const e = DOM.createCustomEvent(type, { bubbles: true }); 
  e.dataTransfer = dataTransfer; 
  return e; 
} 
//Omitted snippet... 

这个函数相当直接。它只是使用作为参数传递的事件的type创建一个Event对象。它还告诉事件在触发时应该在 DOM 上冒泡。最后,它在返回之前将提供的dataTransfer对象分配给事件。

我们将在许多其他函数中使用这个函数,这些函数将用于触发拖放过程的各种步骤:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
function dragOver() { 
  element.dispatchEvent(createDragEvent('dragover')); 
  return new Promise(setTimeout); 
} 

function drop(dataTransfer) { 
  element.dispatchEvent(createDragEvent('drop', dataTransfer)); 
  return new Promise(setTimeout); 
} 

function dragEnd(dataTransfer) { 
  element.dispatchEvent(createDragEvent('dragend', dataTransfer)); 
  return new Promise(setTimeout); 
} 
//Omitted snippet... 

这三个函数各自创建并派发一个特定的拖放事件。它们还返回一个Promise,其解决将在浏览器的事件队列被清空时发生。

更新绑定通常是一个异步过程,取决于绑定类型。Aurelia 的绑定引擎严重依赖于浏览器的事件循环,以使更新绑定的过程尽可能平滑。

因此,返回一个Promise,其resolve函数被推送到浏览器事件队列的末尾,使用setTimeout是一种在测试中使用的技术,以确保需要对属性进行更新或事件派发时,有足够的时间更新绑定。

最后,我们需要创建File对象以在我们的测试中使用:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
function createFile() { 
  return new File( 
    ['some binary content'],  
    'test.txt',  
    { type: 'text/plain' }); 
} 
//Omitted snippet... 

现在我们有了编写第一个测试用例所需的所有工具:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
it('should assign dropped files to bounded instruction', done => { 
  const files = [createFile()]; 

  create() 
    .then(() => dragOver()) 
    .then(() => drop({ files })) 
    .then(() => expect(viewModel.files).toEqual(files)) 
    .then(done); 
}); 
//Omitted snippet... 

这个测试确保,当拖动然后将一个文件列表拖放到承载我们自定义属性的元素上时,事件中的文件被分配给绑定属性的属性。

这个测试首先创建一个files列表并派发一个dragover事件,本身没有用,但只是为了遵循拖放操作的标准过程。接下来,它使用之前创建的files派发一个drop事件。最后,它确保files被正确分配给viewModelfiles属性。

最后,让我们添加另一个测试用例,以确保事件数据被正确清除:

test/unit/resources/attributes/file-drop-target.spec.js

//Omitted snippet... 
it('should clear data when drag ends', done => { 
  const files = [createFile()]; 
  const clearData = jasmine.createSpy('clearData'); 

  create() 
    .then(() => dragOver()) 
    .then(() => drop({ files })) 
    .then(() => dragEnd({ clearData })) 
    .then(() => expect(clearData).toHaveBeenCalled()) 
    .then(done); 
  }); 
//Omitted snippet... 

如果你现在运行测试,它们都应该通过。

测试 list-editor 元素

对自定义元素进行单元测试非常相似。让我们通过测试我们之前编写的list-editor自定义元素来看看它是如何工作的:

test/unit/resources/elements/list-editor.spec.js

import {StageComponent} from 'aurelia-testing'; 
import {bootstrap} from 'aurelia-bootstrapper'; 

describe('the list-editor custom element', () => { 

  let items, createItem, component, element; 

  beforeEach(() => { 
    items = []; 
    createItem = jasmine.createSpy('createItem'); 
    component = StageComponent 
      .withResources('resources/elements/list-editor') 
      .inView(`<list-editor items.bind="items"  
          add-item.call="createItem()"></list-editor>`) 
      .boundTo({ items, createItem }); 
  }); 

  function create() { 
    return component.create(bootstrap).then(() => { 
      element = document.querySelector('list-editor'); 
    }); 
  } 

  afterEach(() => { 
    component.dispose(); 
  }); 
}); 

在这里,我们首先创建一个测试套件,它有一个创建一个空items数组的测试设置,并模拟一个用于创建新项目的函数。它还创建了一个组件测试器,将list-editor作为全局资源加载,在其根视图中使用list-editor元素,并将包含items数组和模拟的createItem函数的对象定义为根绑定上下文,该函数将绑定到list-editor实例。

我们还定义了一个create函数,它将创建并引导沙盒应用程序,在该应用程序中,测试元素将在每次测试期间运行。它在应用程序启动后还会检索list-editor DOM 元素。

最后,我们定义了一个测试清理函数,它将简单地dispose组件测试器。

当然,我们需要用项目作为对象。让我们创建一个简单的类,我们可以在测试用例中使用:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
class Item { 
  constructor(text) { 
    this.text = text; 
  } 

  toString() { 
    return this.text; 
  } 
} 

这个简单的Item类在构造函数中期望有一个text值,当转换为字符串时返回这个text

在我们的测试中,我们需要检索由list-editor渲染的各种元素,以检查某些事情是否正确渲染,或者触发操作。因此,让我们在list-editor的视图中添加一些 CSS 类。这些类将帮助我们选择特定的元素,而不依赖于 HTML 结构本身,这会使测试变得脆弱,因为任何对 HTML 结构的更改都可能破坏它们。

src/resources/elements/list-editor.html

<template> 
  <div class="form-group le-item" repeat.for="item of items"> 
    <template with.bind="item"> 
      <template replaceable part="item"> 
        <div class="col-sm-2 col-sm-offset-1"> 
          <template replaceable part="label"></template> 
        </div> 
        <div class="col-sm-8"> 
          <template replaceable part="value">${$this}</template> 
        </div> 
        <div class="col-sm-1"> 
          <template replaceable part="remove-btn"> 
            <button type="button"  
                    class="btn btn-danger le-remove-btn"  
                    click.delegate="items.splice($index, 1)"> 
              <i class="fa fa-times"></i> 
            </button> 
          </template> 
        </div> 
      </template> 
    </template> 
  </div> 
  <div class="form-group" show.bind="addItem"> 
    <div class="col-sm-9 col-sm-offset-3"> 
      <button type="button" class="btn btn-primary le-add-btn"  
              click.delegate="addItem()"> 
        <slot name="add-button-content"> 
          <i class="fa fa-plus-square-o"></i> 
          <slot name="add-button-label">Add</slot> 
        </slot> 
      </button> 
    </div> 
  </div> 
</template> 

在这里,我们简单地在每个作为每个项目根的元素上添加了一个le-item CSS 类。我们还在每个允许我们从列表中删除项目的按钮上添加了一个le-remove-btn CSS 类。最后,我们在允许向列表中添加项目的按钮上添加了一个le-add-btn CSS 类。

注意

le前缀代表列表编辑器。这不是尝试写法语卡通。

就像我们之前做的那样,我们必须重新构建应用程序,以便包是更新的,并且包括在list-editor模板中的新 CSS 类:

> au build

让我们添加一些助手函数,以便在我们的测试元素内检索元素、执行操作或断言渲染 DOM 的结果:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
describe('the list-editor custom element', () => { 
  //Omitted snippet... 

  function getItemsViews() { 
    return Array.from(element.querySelectorAll('.le-item'));   
  }
function clickRemoveButtonAt(index) { 
    const removeBtn = element 
      .querySelectorAll('.le-remove-btn')[index]; 
    removeBtn.click(); 
    return new Promise(setTimeout); 
  }
function clickAddButton() { 
    const addBtn = element.querySelector('.le-add-btn'); 
    addBtn.click(); 
    return new Promise(setTimeout); 
  }
function isItemRendered(item, itemsViews) { 
    return (itemsViews || getItemsViews()) 
      .some(iv => iv.textContent.includes(item.text)); 
  }
function areAllItemsRendered() { 
    const itemsViews = getItemsViews(); 
    return items.every(i => isItemRendered(i, itemsViews)); 
  } 
}); 

在这里,我们定义了以下函数:

  • getItemsViews:检索元素(每个items的根)。

  • clickRemoveButtonAt:检索给定索引处的项目的删除按钮,并在其上触发一个click事件。它返回一个Promise,当浏览器的事件队列清空时,它将解决,以确保所有绑定都是最新的。

  • clickAddButton:检索添加按钮,并在其上触发一个click事件。它返回一个Promise,当浏览器的事件队列清空时,它将解决,以确保所有绑定都是最新的。

  • isItemRendered:如果提供的项目已经在list-editor的 DOM 中渲染,则返回true,否则返回false

  • areAllItemsRendered:如果所有项目已经在list-editor的 DOM 中渲染,则返回true,否则返回false

此时,我们已经有了编写测试所需的一切。

首先验证所有项目是否正确渲染:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
it('should render one form-group per item', done => { 
  items.push(new Item('test item 1')); 
  items.push(new Item('test item 2')); 

  create() 
    .then(() => expect(areAllItemsRendered()).toBe(true)) 
    .then(done); 
}); 
//Omitted snippet... 

接下来,让我们添加一些测试,以确保当点击项目的删除按钮时,该项目会被删除:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
it('should remove the item when the remove button is clicked', done => { 
  items.push(new Item('test item 1')); 
  items.push(new Item('test item 2')); 
  items.push(new Item('test item 3')); 

  const indexToRemove = 1; 
  const itemToRemove = items[indexToRemove]; 

  create() 
    .then(() => clickRemoveButtonAt(indexToRemove))  
    .then(() => expect(items.indexOf(itemToRemove)).toBe(-1)) 
    .then(() => expect(isItemRendered(itemToRemove)).toBe(false)) 
    .then(done); 
}); 
//Omitted snippet... 

最后,让我们添加一个测试用例,以确保点击添加按钮将创建一个新项目,并将其添加到列表中:

test/unit/resources/elements/list-editor.spec.js

//Omitted snippet... 
it('should add new item when the add item button is clicked', done => { 
  items.push(new Item('test item 1')); 
  items.push(new Item('test item 2')); 

  const indexOfItemToAdd = items.length; 
  const itemToAdd = new Item('test item 3'); 
  createItem.and.callFake(() => { items.push(itemToAdd); }); 

  create() 
    .then(() => clickAddButton()) 
    .then(() => expect(items.indexOf(itemToAdd)) 
      .toBe(indexOfItemToAdd)) 
    .then(() => expect(isItemRendered(itemToAdd)).toBe(true)) 
    .then(done); 
}); 
//Omitted snippet... 

此时,所有测试都应该通过。

单元测试路由组件

在撰写本文时,没有一种方法可以利用ComponentTester测试路由组件。我们只能在单元测试中测试视图模型的行为,并依赖端到端测试来验证视图。然而,Aurelia 团队计划添加这个功能;你应该查看一下,以防在你阅读这本书时它已经被发布了。

对这类组件的视图模型进行单元测试与我们已经编写的大多数测试并没有太大区别,但让我们通过编写一个联系人创建组件的测试套件来举一个快速的例子:

test/unit/contacts/components/creation.spec.js

 import {ValidationError}
  from 'aurelia-validation';
import {ContactCreation}
  from '../../../../src/contacts/components/creation';
import {Contact} from '../../../../src/contacts/models/contact';

describe('the contact creation component', () => {
  let gateway, validationController, router, sut;
  beforeEach(() => {
    gateway = jasmine.createSpyObj('ContactGateway', ['create']);
    validationController = jasmine.createSpyObj(
       'ValidationController', ['validate']);
    router = jasmine.createSpyObj('Router', ['navigateToRoute']);
    sut = new ContactCreation(gateway, validationController,
    router);
   });
});

在此,我们首先创建一个测试套件,该套件包含一个测试设置,用于创建一组模拟对象,然后使用这些模拟对象创建被测试系统(SUT)。

我们还需要添加一个帮助函数来创建验证错误:

test/unit/contacts/components/creation.spec.js

//Omitted snippet... 
function createValidationError() { 
  return new ValidationError({}, 'Invalid', sut.contact,  
    'firstName'); 
} 
//Omitted snippet... 

最后,让我们添加一个测试用例,以确保在尝试保存无效联系人时什么也不会发生,再添加一个测试用例,以确保保存有效联系人时能做正确的事情:

test/unit/contacts/components/creation.spec.js

//Omitted snippet... 
it('should do nothing when contact is invalid', done => { 
  const errors = [createValidationError()]; 
  validationController.validate.and 
    .returnValue(Promise.resolve(errors)); 

  sut.save() 
    .then(() => expect(gateway.create).not.toHaveBeenCalled()) 
    .then(() => expect(router.navigateToRoute) 
      .not.toHaveBeenCalled()) 
    .then(done); 
}); 

it('should create and navigate when contact is valid', done => { 
  validationController.validate.and 
    .returnValue(Promise.resolve([])); 
  gateway.create.and.returnValue(Promise.resolve()); 

  sut.save() 
    .then(() => expect(gateway.create) 
      .toHaveBeenCalledWith(sut.contact)) 
    .then(() => expect(router.navigateToRoute) 
      .toHaveBeenCalledWith('contacts')) 
    .then(done); 
}); 
//Omitted snippet... 

这给出了一个很好的测试路由组件视图模型的想法。我将留给读者作为练习,为contacts特性中的其他路由组件添加测试。本章节的示例应用程序可以作为参考。

端到端测试

单元测试的目的是验证代码单元的隔离,而端到端(E2E)测试的目的是验证整个应用程序。这些测试可以有不同的深度。它们的范围可能限于客户端应用程序本身。在这种情况下,应用程序所使用的任何远程服务都需要以某种方式被模拟。

它们也可以涵盖整个系统。大多数时候,这意味着支持应用程序的服务必须部署到一个测试位置,并用受控的测试数据进行初始化。

无论你的端到端测试策略是什么,技术上基本保持不变。在本节中,我们将了解如何利用 Protractor 为我们的联系人管理应用程序编写功能测试场景。

设置环境

在撰写本文时,CLI 不包括 Protractor 的设置。由于我们是用 CLI 开始项目的,让我们看看如何向我们的应用程序添加端到端测试的支持。

我们首先需要安装 Gulp 的protractor插件以及del库。在项目的目录中打开一个控制台,并运行以下命令:

> npm install gulp-protractor del --save-dev

接下来,我们需要存储一些关于端到端测试过程的配置值。让我们把这些添加到aurelia.json文件中:

aurelia_project/aurelia.json

{ 
  //Omitted snippet... 
  "unitTestRunner": { 
    "id": "karma", 
    "displayName": "Karma", 
    "source": "test\\unit\\**\\*.js" 
  }, 
 "e2eTestRunner": { 
    "id": "protractor", 
    "displayName": "Protractor", 
    "source": "test/e2e/src/**/*.js", 
    "output": "test/e2e/dist/", 
    "transpiler": { 
      "id": "babel", 
      "displayName": "Babel", 
      "options": { 
        "plugins": [ 
          "transform-es2015-modules-commonjs" 
        ] 
      } 
    } 
  }, 
  //Omitted snippet... 
} 

这个新部分包含路径和转换器选项,这些将被我们的端到端任务使用。

这个任务相当直接:它使用 Babel 转换测试套件,因此可以在 Node 上运行,然后启动 Protractor。让我们首先编写任务描述符:

aurelia_project/tasks/e2e.json

{ 
  "name": "e2e", 
  "description":  
    "Runs all end-to-end tests and reports the results.", 
  "flags": [] 
} 

接下来,让我们编写任务本身:

aurelia_project/tasks/e2e.js

import gulp from 'gulp'; 
import del from 'del'; 
import {webdriver_update, protractor} from 'gulp-protractor'; 
import plumber from 'gulp-plumber'; 
import notify from 'gulp-notify'; 
import changedInPlace from 'gulp-changed-in-place'; 
import sourcemaps from 'gulp-sourcemaps'; 
import babel from 'gulp-babel'; 
import project from '../aurelia.json'; 
import {CLIOptions} from 'aurelia-cli'; 

function clean() { 
  return del(project.e2eTestRunner.output + '*'); 
} 

function build() { 
  return gulp.src(project.e2eTestRunner.source) 
    .pipe(plumber({ 
      errorHandler: notify.onError('Error: <%= error.message %>') 
    })) 
    .pipe(changedInPlace({firstPass:true})) 
    .pipe(sourcemaps.init()) 
    .pipe(babel(project.e2eTestRunner.transpiler.options)) 
    .pipe(gulp.dest(project.e2eTestRunner.output)); 
} 

function run() { 
  return gulp.src(project.e2eTestRunner.output + '**/*.js') 
    .pipe(protractor({ 
      configFile: 'protractor.conf.js', 
      args: ['--baseUrl', 'http://127.0.0.1:9000'] 
    })) 
    .on('end', () => { process.exit(); }) 
    .on('error', e => { throw e; }); 
} 

export default gulp.series( 
  webdriver_update, 
  clean, 
  build, 
  run 
); 

如果你不熟悉 Gulp,让我快速解释一下这个任务做什么:

  • 如有需要,它将更新 WebDriver。

  • 它清理输出目录,那里存放着编译后的测试套件。

  • 它将测试套件编译到输出目录中。

  • 它启动了 Protractor。

    注意

    Protractor 主要是一个 API,它建立在 Selenium 之上,Selenium 是允许我们在浏览器中播放场景的实际引擎。WebDriver 是 Node 绑定,允许我们与 Selenium 通信。

你可能注意到了一个配置文件路径被传递给了 Protractor。让我们编写这个配置:

protractor.conf.js

exports.config = { 
  directConnect: true, 

  capabilities: { 
    'browserName': 'chrome' 
  }, 

  specs: ['test/e2e/dist/**/*.js'], 

  plugins: [{ 
    package: 'aurelia-tools/plugins/protractor' 
  }], 

  jasmineNodeOpts: { 
    showColors: true, 
    defaultTimeoutInterval: 30000 
  } 
}; 

深入探索 Protractor 超出了本书的范围。然而,从这个配置中,你可能可以理解到它将使用 Google Chrome 来运行测试,它期望测试文件位于test/e2e/dist目录中,这是我们配置任务以编译我们的测试套件的地方,并且从aurelia-tools包中加载了一个插件。aurelia-tools库已经包含在基于 CLI 的项目中,所以不需要安装。

这一部分相当重要,因为这个插件向 Protractor API 添加了一些 Aurelia 特定的方法。我们将在下一节中看到这些方法。

模拟后端

我们的联系人管理应用程序并不是独立存在的。它建立在一个基于 HTTP 的 API 之上,该 API 允许应用程序访问数据和执行操作。因此,我们需要一个受控的 API 版本,实际上是一个模拟,它将包含一组预定义的数据,并且我们可以在每次测试之前将其重置为原始状态。

你可以从本书的工件中获取这个模拟的 API。只需将samples中的chapter-7\app\test\e2e\api-mock目录复制到您自己项目的test\e2e目录中。您可能需要先创建e2e目录。

一旦完成这个步骤,请确保通过在api-mock目录中打开控制台并运行以下命令来恢复 API 模拟器所需的所有依赖项:

> npm install

API 模拟器现在准备运行。

为了在每次测试之前重置数据集,我们将需要一个帮助函数:

test/e2e/src/contacts/api-mock.js

import http from 'http'; 

export function resetApi() { 
  const deferred = protractor.promise.defer(); 

  const request = http.request({ 
    protocol: 'http:', 
    host: '127.0.0.1', 
    port: 8000, 
    path: '/reset', 
    method: 'POST' 
  }, response => { 
    if (response.statusCode < 200 || response.statusCode >= 300) { 
      deferred.reject(response); 
    } else { 
      deferred.fulfill(); 
    } 
  }); 
  request.end(); 

  return deferred.promise; 
} 

如果你不知道,Protractor 是在 Node 上运行的,而不是在浏览器中。因此,我们首先导入 Node 的http模块。接下来,我们定义并导出一个resetApi函数,该函数简单地向我们 HTTP API 的/reset端点发送一个POST请求。它还返回一个Promise,当 HTTP 请求完成时解析。

这个函数告诉后端将它的数据集重置为其原始状态。我们将在每个测试之前调用它,所以每个测试都可以确信它是在相同的数据集上工作,即使之前的测试创建了一个新的联系人或更新了一个现有的联系人。

页面对象模式

一个典型的端到端测试将加载一个给定的 URL,从文档中检索一个或多个 DOM 元素,对这个或这些元素执行一个动作或分发一个事件,然后验证是否达到了预期的结果。

因此,选择元素并在它们上执行操作可以迅速使测试代码膨胀。另外,通常需要在多个测试用例中选择一组给定的元素。在很多地方重复选择代码使得代码变得僵硬且难以更改。测试变得比解放更具有限制性。

为了使我们的测试更具描述性且更容易更改,我们将使用页面对象模式。这个模式描述了我们如何创建一个类来表示给定页面或组件的 UI,以封装选择特定元素并在它们上执行操作的逻辑。

让我们通过为联系人列表组件创建这样的类来说明这一点:

test/e2e/src/contacts/list.po.js

export class ContactsListPO { 

  getTitle() { 
    return element(by.tagName('h1')).getText(); 
  } 

  getAllContacts() { 
    return element.all(by.css('.cl-details-link')) 
      .map(link => link.getText()); 
  } 

  clickContactLink(index) { 
    const result = {}; 
    const link = element.all( 
      by.css(`.cl-details-link`)).get(index); 
    link.getText().then(fullName => { 
      result.fullName = fullName; 
    }); 
    link.click(); 
    return browser.waitForRouterComplete().then(() => result); 
  } 

  clickNewButton() { 
    element(by.css('.cl-create-btn')).click(); 
    return browser.waitForRouterComplete(); 
  } 

  setFilter(value) { 
    element(by.valueBind('filter & debounce')) 
      .clear().sendKeys(value); 
    return browser.sleep(200); 
  } 

  clickClearFilter() { 
    element(by.css('.cl-clear-filter-btn')).click(); 
    return browser.sleep(200); 
  } 
} 

这个类以一个getAllContacts方法开始。这个方法使用 Protractor API 选择所有具有cl-details-link CSS 类的元素,然后将它们映射到它们的文本内容。这个方法允许我们获取一个包含所有显示联系人的全名的数组。

接下来,它暴露了一个clickContactLink方法,该方法检索具有cl-details-link CSS 类的那些元素中的第index个元素,然后获取其文本内容,将其分配给result对象上的fullName属性,在执行元素上的点击操作之前。然后,它使用 Aurelia 的 Protractor 插件提供的扩展方法之一来等待路由完成其导航周期,这将是通过点击链接触发的,并返回结果Promise,其结果被改变为result对象。

注意

如前所述,深入探索 Protractor 超出了本书的范围。然而,如果你不熟悉它,了解所有 Protractor API 中的方法返回Promise是很重要的,但通常没有必要使用then来链接它们,因为 Protractor 内部会为所有异步操作排队。

我强烈建议你在尝试编写广泛的端到端测试套件之前,先熟悉 Protractor 这一方面。

clickNewButton方法相当简单;它选择具有cl-create-btn CSS 类的元素并对其执行点击操作,然后等待路由完成其导航周期。

setFilter方法使用 Protractor 的 Aurelia 插件提供的另一个扩展方法来选择与filter属性绑定且具有debounce绑定行为的元素。它然后清除其值并向其发送给定的一系列键盘输入,然后让浏览器休眠 200 毫秒。

最后,clickClearFilter方法选择具有cl-clear-filter-btn CSS 类的元素并执行点击操作。然后让浏览器休眠 200 毫秒。

注意

在撰写本文时,在操作后使用sleep指令是必要的,以确保所有可能需要对操作做出反应的绑定都已更新。

页面对象的目的是封装并抽象掉与视图的交互。由于所有与组件 HTML 相关的代码都集中在一个单一的类中,因此修改组件视图的影响将限于这个类。另外,正如我们将在下一节中看到的,测试用例本身只需要处理与视图的高级 API,而不需要处理 HTML 结构本身的复杂性。大多数对 Protractor API 的调用都将隐藏在我们的页面对象内部。

您可能注意到,前面代码片段中的大多数选择器都使用新的 CSS 类来选择元素。让我们将这些添加到联系人列表模板中:

src/contacts/components/list.html

<template> 
  <section class="container"> 
    <h1>Contacts</h1> 

    <div class="row"> 
      <div class="col-sm-1"> 
        <a route-href="route: contact-creation"  
           class="btn btn-primary cl-create-btn"> 
          <i class="fa fa-plus-square-o"></i> New 
        </a> 
      </div> 
      <div class="col-sm-2"> 
        <div class="input-group"> 
          <input type="text" class="form-control"  
                 placeholder="Filter"  
                 value.bind="filter & debounce"> 
          <span class="input-group-btn" if.bind="filter"> 
            <button class="btn btn-default cl-clear-filter-btn"  
                    type="button"  
                    click.delegate="filter = ''"> 
              <i class="fa fa-times"></i> 
              <span class="sr-only">Clear</span> 
            </button> 
          </span> 
        </div> 
      </div> 
    </div> 

    <group-list items.bind="contacts  
                  | filterBy:filter:'firstName':'lastName': 
                    'company'" 
                group-by="firstLetter" order-by="fullName"> 
      <template replace-part="item"> 
        <a route-href="route: contact-details;  
                       params.bind: { id: id }"  
           class="cl-details-link"> 
          <span if.bind="isPerson"> 
            ${firstName} <strong>${lastName}</strong> 
          </span> 
          <span if.bind="!isPerson"> 
            <strong>${company}</strong> 
          </span> 
        </a> 
      </template> 
    </group-list> 
  </section> 
</template> 

最后,在我们进入第一个测试用例之前,让我们快速添加两个我们将在测试中需要的其他页面对象:

test/e2e/src/contacts/creation.po.js

export class ContactCreationPO { 

  getTitle() { 
    return element(by.tagName('h1')).getText(); 
  } 
} 

test/e2e/src/contacts/details.po.js

export class ContactDetailsPO { 

  getFullName() { 
    return element(by.tagName('h1')).getText(); 
  } 
} 

第一个页面对象封装了联系人创建组件。它简单地暴露了一个getTitle方法,该方法选择h1元素并返回其文本内容。

第二个页面对象是用于联系详情组件的。它有一个getFullName方法,该方法允许我们通过选择h1元素并返回其文本内容来检索联系人的显示全名。

编写第一个测试用例

现在所有我们需要的工具都已经准备好了,让我们为联系人列表组件编写第一个测试用例:

test/e2e/src/contacts/list.spec.js

import {resetApi} from './api-mock.js'; 
import {ContactsListPO} from './list.po.js'; 

describe('the contacts list page', () => { 

  let listPo; 

  beforeEach(done => { 
    listPo = new ContactsListPO(); 

    resetApi().then(() => { 
      browser 
        .loadAndWaitForAureliaPage('http://127.0.0.1:9000/') 
        .then(done); 
    }); 
  }); 

  it('should display the list of contacts', () => { 
    expect(listPo.getTitle()).toEqual('Contacts'); 
    listPo.getAllContacts().then(names => { 
      expect(names.length).toBeGreaterThan(0); 
    }); 
  }); 
}); 

在这里,我们从测试设置开始,该设置创建了一个联系人列表页面对象的实例,重置了 API,然后使用了 Aurelia 的 Protractor 插件提供的另一个扩展方法来加载给定 URL,然后等待 Aurelia 应用程序完成启动。

接下来,我们定义了一个第一个测试用例,该测试用例使用页面对象的方法来确保某些联系人被显示。

注意

尽管使用 Protractor 运行的测试是异步的,但大多数情况下,没有必要使用 Jasmine 的done函数来让框架知道测试用例何时完成,因为 Protractor 修改了 Jasmine 的函数,使其自身使用自己的内部任务队列来处理异步性。

这个规则的例外是在执行 Protractor 未处理的异步操作时,比如在beforeEach函数中,我们使用异步 HTTP 请求重置 API。

运行测试

在此阶段,我们已经准备就绪并运行了我们的 E2E 测试。为此,我们首先需要运行 API 模拟,通过在我们的项目中的test/e2e/api-mock目录中打开一个控制台并执行以下命令:

> npm start

一旦 API 运行,我们还需要启动应用程序本身,通过在项目的目录中打开一个控制台并运行以下命令来实现:

> au run

这两个命令是必要的,因为端到端测试需要在我们应用程序中加载浏览器来执行,并且需要在每次测试前调用 API 来重置其数据。当然,应用程序本身也需要 API 来请求数据和执行操作。

一旦 API 模拟和应用程序都在运行,我们就可以通过在项目目录中打开第三个控制台并运行以下命令来启动端到端测试:

> au e2e

你将看到任务开始,在过程中会出现一个 Chrome 实例。你会看到应用程序加载并且测试案例场景在你眼前播放,然后 Chrome 关闭并且任务完成。完整的输出应该类似于这样:

运行测试

注意

e2e任务在 WebDriver 需要首先更新自己时,偶尔可能需要一些时间才能启动。

测试联系人列表

既然我们知道一切工作正常,让我们为联系人列表组件添加一些测试:

test/e2e/src/contacts/list.spec.js

import {resetApi} from './api-mock.js'; 
import {ContactsListPO} from './list.po.js'; 
import {ContactDetailsPO} from './details.po.js'; 
import {ContactCreationPO} from './creation.po.js'; 

describe('the contacts list page', () => { 

  let listPo, detailsPo, creationPo; 

  beforeEach(done => { 
    listPo = new ContactsListPO(); 
    detailsPo = new ContactDetailsPO(); 
    creationPo = new ContactCreationPO(); 

    resetApi().then(() => { 
      browser 
        .loadAndWaitForAureliaPage('http://127.0.0.1:9000/') 
        .then(done); 
    }); 
  }); 

  it('should load the list of contacts', () => { 
    expect(listPo.getTitle()).toEqual('Contacts'); 
    listPo.getAllContacts().then(names => { 
      expect(names.length).toBeGreaterThan(0); 
    }); 
  }); 

  it('should display details when clicking a contact link', () => { 
    listPo.clickContactLink(0).then(clickedContact => { 
      expect(detailsPo.getFullName()) 
        .toEqual(clickedContact.fullName); 
    }); 
  }); 

  it('should display the creation form when clicking New', () => { 
    listPo.clickNewButton(); 

    expect(creationPo.getTitle()).toEqual('New contact'); 
  }); 

  it('should filter the list', () => { 
    const searched = 'Google'; 

    listPo.setFilter(searched); 

    listPo.getAllContacts().then(names => { 
      expect(names.every(n => n.includes(searched))).toBe(true); 
    }); 
  }); 

  it('should reset unfiltered list when clicking clear filter', () =>  
  { 
    let unfilteredNames; 
    listPo.getAllContacts().then(names => { 
      unfilteredNames = names; 
    }); 
    listPo.setFilter('Google'); 

    listPo.clickClearFilter(); 

    listPo.getAllContacts().then(names => { 
      expect(names).toEqual(unfilteredNames); 
    }); 
  }); 
}); 

  • 这些新测试案例中的第一个确保点击列表中的一个联系人条目时,应用程序导航到联系人的详细信息组件

  • 第二个测试确保点击新建按钮时,应用程序导航到联系人创建组件

  • 第三个确保当在筛选文本框中输入搜索词时,列表使用这个搜索词进行筛选。

  • 最后,第四个测试确保在搜索后清除筛选文本框,列表将恢复未筛选状态

这个测试套件现在覆盖了联系人列表组件的所有功能。如果你在这个时候运行端到端测试,你应该看到五个测试案例通过。

测试联系人创建

让我们尝试通过为联系人创建组件添加一个测试套件来使事情变得复杂一些,该组件包括一个带有验证规则的复杂表单。

首先,我们将编写一个可重用的类,遵循页面对象模式,该类将封装联系人表单视图。这样,我们就能使用这个类来测试联系人创建,也能最终测试联系人的编辑。

我们将从为列表编辑器编写基本页面对象开始。这个类将封装如何访问并在联系表单组件的list-editor元素上执行操作的细节。

test/e2e/src/contacts/form.po.js

class ListEditorPO { 

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

  _getContainer() { 
    return element(by.css( 
      `list-editor[items\\.bind=contact\\.${this.property}]`)); 
  } 

  _getItem(index) { 
    return this._getContainer() 
      .all(by.css(`.le-item`)) 
      .get(index); 
  }  

  _selectOption(index, name, value) { 
    this._getItem(index) 
      .element(by.valueBind(`${name} & validate`)) 
      .element(by.css(`option[value=${value}]`)) 
      .click(); 
    return browser.sleep(200); 
  } 

  _setText(index, name, value) { 
    this._getItem(index) 
      .element(by.valueBind(`${name} & validate`)) 
      .clear() 
      .sendKeys(value); 
    return browser.sleep(200); 
  } 

  clickRemove(index) { 
    this._getItem(index) 
      .element(by.css(`.le-remove-btn`)) 
      .click(); 
    return browser.sleep(200); 
  } 

  clickAdd() { 
    this._getContainer() 
      .element(by.css(`.le-add-btn`)) 
      .click(); 
    return browser.sleep(200); 
  } 
} 

在这里,我们首先定义一个名为ListEditorPO的基本类。这个类封装与联系表单中的单个list-editor元素的交互,并知道如何:

  1. 在绑定给定属性的列表中给定索引的select中选择给定的option

  2. 向绑定给给定属性的列表中给定索引的字段发送给定的一系列键。

  3. 点击列表中给定索引的删除按钮。

  4. 点击添加按钮。

接下来,我们将通过编写四个特殊化的页面对象来扩展这个类,每个对象对应联系人可以有的每种类型的项目:

test/e2e/src/contacts/form.po.js

//Omitted snippet... 

class PhoneNumberListEditorPO extends ListEditorPO { 

  constructor() { 
    super('phoneNumbers'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setNumber(index, value) { 
    return this._setText(index, 'number', value); 
  } 
} 

class EmailAddressListEditorPO extends ListEditorPO { 

  constructor() { 
    super('emailAddresses'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setAddress(index, value) { 
    return this._setText(index, 'address', value); 
  } 
} 

class AddressListEditorPO extends ListEditorPO { 

  constructor() { 
    super('addresses'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setNumber(index, value) { 
    return this._setText(index, 'number', value); 
  } 

  setStreet(index, value) { 
    return this._setText(index, 'street', value); 
  } 

  setPostalCode(index, value) { 
    return this._setText(index, 'postalCode', value); 
  } 

  setState(index, value) { 
    return this._setText(index, 'state', value); 
  } 

  setCountry(index, value) { 
    return this._setText(index, 'country', value); 
  } 
} 

class SocialProfileListEditorPO extends ListEditorPO { 

  constructor() { 
    super('socialProfiles'); 
  } 

  setType(index, value) { 
    return this._selectOption(index, 'type', value); 
  } 

  setUsername(index, value) { 
    return this._setText(index, 'username', value); 
  } 
} 

在这里,我们定义了一些扩展基本ListEditorPO类的类:PhoneNumberListEditorPOEmailAddressListEditorPOAddressListEditorPOSocialProfileListEditorPO。它们都:

  • 指定底层list-editor元素绑定的属性

  • 添加专用方法来设置底层list-editor中每个项目的字段值,例如用于电话号码的setTypesetNumber,或用于地址的setStreetsetCity

最后,我们将为联系表单本身编写一个页面对象:

test/e2e/src/contacts/form.po.js

//Omitted snippet... 

export class ContactFormPO { 

  constructor() { 
    this.phoneNumbers = new PhoneNumberListEditorPO(); 
    this.emailAddresses = new EmailAddressListEditorPO(); 
    this.addresses = new AddressListEditorPO(); 
    this.socialProfiles = new SocialProfileListEditorPO(); 
  } 

  _setText(name, value) { 
    element(by.valueBind(`contact.${name} & validate`)) 
      .clear() 
      .sendKeys(value); 
    return browser.sleep(200); 
  } 

  setFirstName(value) { 
    return this._setText('firstName', value); 
  } 

  setLastName(value) { 
    return this._setText('lastName', value); 
  } 

  setCompany(value) { 
    return this._setText('company', value); 
  } 

  setBirthday(value) { 
    return this._setText('birthday', value); 
  } 

  setNote(value) { 
    return this._setText('note', value); 
  } 

  getValidationErrors() { 
    return element.all(by.css('.validation-message')) 
      .map(x => x.getText()); 
  } 
} 

在这里,我们导出一个名为ContactFormPO的类,它封装了与联系表单视图的交互。它有每个扩展ListEditorPO类的实例,因此测试可以与电话号码、电子邮件地址、地址和社会资料的各个list-editor元素交互。它还有允许我们设置名字、姓氏、公司、生日和备注值的方法。最后,它有一个允许我们检索表单上所有验证错误消息的方法。

在能够编写我们的新测试之前,我们需要将此表单页面对象与联系创建组件的页面对象连接。我们还将向其中添加几个方法:

test/e2e/src/contacts/creation.po.js

import {ContactFormPO} from './form.po.js'; 

export class ContactCreationPO extends ContactFormPO { 

  getTitle() { 
    return element(by.tagName('h1')).getText(); 
  } 

  clickSave() { 
    element(by.buttonText('Save')).click(); 
    return browser.sleep(200); 
  } 

  clickCancel() { 
    element(by.linkText('Cancel')).click(); 
    return browser.sleep(200);
 } 
} 

在这里,我们首先使ContactCreationPO类继承ContactFormPO类,然后添加一个方法来点击保存按钮,另一个方法来点击取消链接。

有了这个准备,编写联系创建组件的测试套件就相当直接了:

test/e2e/src/contacts/creation.spec.js

import {resetApi} from './api-mock.js'; 
import {ContactsListPO} from './list.po.js'; 
import {ContactCreationPO} from './creation.po.js'; 

describe('the contact creation page', () => { 

  let listPo, creationPo; 

  beforeEach(done => { 
    listPo = new ContactsListPO(); 
    creationPo = new ContactCreationPO(); 

    resetApi().then(() => { 
      browser.loadAndWaitForAureliaPage('http://127.0.0.1:9000/'); 
      listPo.clickNewButton().then(done); 
    }); 
     });   
}); 

在这个测试套件的设置中,我们首先创建列表和创建组件的页面对象。我们重置 API 的数据,然后加载应用程序,点击新建按钮导航到联系创建组件。

我们现在可以丰富这个测试套件,添加一些验证联系创建组件行为的测试用例:

it('should display errors when clicking save and form is invalid', () => { 
  creationPo.setBirthDay('this is absolutely not a date'); 
  creationPo.phoneNumbers.clickAdd(); 
  creationPo.emailAddresses.clickAdd(); 
  creationPo.addresses.clickAdd(); 
  creationPo.socialProfiles.clickAdd(); 

  creationPo.clickSave(); 

  expect(creationPo.getTitle()).toEqual('New contact'); 
  expect(creationPo.getValidationErrors()).toEqual([ 
    'Birthday must be a valid date.',  
    'Address is required.',      
    'Number is required.',  
    'Street is required.',  
    'Postal Code is required.',  
    'City is required.',  
    'Country is required.',  
    'Username is required.' 
  ]); 
}); 

it('should create contact when clicking save and form is valid', () => { 
  creationPo.setFirstName('Chuck'); 
  creationPo.setLastName('Norris'); 
  creationPo.setBirthDay('1940-03-10'); 

  creationPo.emailAddresses.clickAdd(); 
  creationPo.emailAddresses.setType(0, 'Office'); 
  creationPo.emailAddresses.setAddress(0,  
    'himself@chucknorris.com'); 

  creationPo.clickSave(); 

  expect(listPo.getTitle()).toEqual('Contacts'); 
  expect(listPo.getAllContacts()).toContain('Chuck Norris'); 
}); 

it('should not create contact when clicking cancel', () => { 
  creationPo.setFirstName('Steven'); 
  creationPo.setLastName('Seagal'); 

  creationPo.clickCancel(); 

  expect(listPo.getTitle()).toEqual('Contacts'); 
  expect(listPo.getAllContacts()).not.toContain('Steven Seagal'); 
}); 

在这里,我们定义了三个测试用例。第一个确保当表单处于无效状态并且点击保存按钮时,不会发生导航并且显示适当的验证消息。第二个确保当表单处于有效状态并且点击保存按钮时,应用程序导航回到联系人列表组件。它还确保新联系人在列表中显示。第三个测试用例确保点击取消使应用程序导航回到联系人列表组件。它还确保列表中没有显示新联系人。

进一步测试

这一章节本可以更长,通过添加我们应用程序中其他功能的测试来扩展,但编写额外的测试对 Aurelia 本身的学习体验增加的价值不大。使用 Protractor 对 Aurelia 应用程序进行端到端测试是一个值得单独成书的话题。然而,当前节点的目标只是让你稍稍了解一下并开始入门。希望,它做到了。

总结

能够既使用单元测试在微观层面测试,又使用端到端测试在宏观层面测试,对于一个框架来说是非常有价值的品质。得益于其模块化架构和面向组件的特性,Aurelia 使得编写这类测试相对容易。

事实上,自动化测试是一个广泛的主题。有专门关于这个话题的书籍,因此试图在单个章节中深入探讨它是徒劳的。然而,此时你应该已经拥有开始为你的 Aurelia 应用程序编写自动化测试的最基本知识了。

在这本书的这个阶段,构建使用 Aurelia 的单页应用程序所需的大部分主要工具应该已经掌握在你手中了。你可能还没有完全掌握它们,但你知道它们是什么以及它们的用途是什么。

然而,还有一些主题尚未涉及,其中之一就是国际化。这是我们将在下一章讨论的内容。

第八章:国际化

当涉及到 JavaScript 国际化时,i18next是最知名、最广泛使用的库之一。它提供了一系列功能,如可插拔的翻译加载器、缓存、用户语言检测和复数形式。也许这就是 Aurelia 团队在它之上构建aurelia-i18n库的原因。

本章的目的并不是要详细解释i18next,而是更多地探索aurelia-i18n层本身。至于i18next的详细信息,官方网站有广泛的文档,如果你不熟悉它,我强烈建议你查阅: i18next.com/

设置事情

aurelia-i18n库和底层i18next库在使用之前都需要安装和配置。让我们看看这个过程如何进行。

安装库

首先,需要通过在项目目录中打开控制台并运行以下命令来安装aurelia-i18ni18next

> npm install aurelia-i18n i18next --save

i18next库使用一个抽象层来加载翻译数据。在i18next术语中,这被称为后端。这个抽象层允许不同的翻译加载策略。

存储和检索翻译数据的最常见方法是在应用程序文件的某个地方使用 JSON 文件。因此,我们将安装i18next-xhr-backend实现,它使用XMLHttpRequest从服务器获取包含翻译的 JSON 文件:

> npm install i18next-xhr-backend --save

当然,打包器需要知道这些新库。因此,在aurelia_project/aurelia.json文件中,在build部分,在bundles下的vendor-bundle.jsdependencies中,让我们添加以下条目:

{ 
  "name": "aurelia-i18n", 
  "path": "../node_modules/aurelia-i18n/dist/amd", 
  "main": "aurelia-i18n" 
}, 
{ 
  "name": "i18next", 
  "path": "../node_modules/i18next/dist/umd", 
  "main": "i18next" 
}, 
{ 
  "name": "i18next-xhr-backend", 
  "path": "../node_modules/i18next-xhr-backend/dist/umd", 
  "main": "i18nextXHRBackend" 
}, 

配置插件

我们还需要在我们的主configure函数中加载和配置插件:

src/main.js

import Backend from 'i18next-xhr-backend'; 
//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .feature('validation') 
    .feature('resources') 
    .feature('contacts') 
 .plugin('aurelia-i18n', (i18n) => { 
      i18n.i18next.use(Backend); 

      return i18n.setup({ 
        backend: { 
          loadPath: './locales/{{lng}}/{{ns}}.json',  
        }, 
        lng : 'en', 
        fallbackLng : 'en', 
        debug : environment.debug 
      }); 
    }); 
  //Omitted snippet... 
}); 

在此,我们首先从i18next-xhr-backend库中导入Backend类。然后,我们调用plugin函数来加载aurelia-i18n并对其进行配置。

配置函数接收aurelia-i18n类的单个实例I18N,作为外观,分组和标准化 API。它首先告诉i18next使用i18next-xhr-backendBackend类,该类负责从服务器获取 JSON 翻译文件。然后,它调用I18N类的setup方法,带有一组选项。这些选项将用于配置插件,但也将用于后台配置i18next。这意味着您通常会传递给i18nextinit方法的任何选项,都可以传递给这个setup方法。

以下是最重要的选项:

  • backend.loadPath:用于加载翻译文件的路径。{{lng}}占位符将被替换为必须加载翻译的语言,{{ns}}占位符将被替换为必须加载翻译的命名空间。

  • lng:默认语言。

  • fallbackLng:如果在当前语言中找不到给定键,则回退到该语言。

  • debug:设置为true时,浏览器控制台中的日志将更加详细。

创建翻译文件

i18next库允许我们将翻译按命名空间隔离,这些命名空间是逻辑翻译组。其默认命名空间名为translation。如果我们看看backend.loadPath选项,我们可以很容易地看出我们的翻译文件应该放在哪里:

locales/en/translation.json

{} 

在这里,我们简单地创建一个包含空对象的 JSON 文件。我们稍后向其中添加翻译。

填充 Intl API

aurelia-i18n插件使用i18next进行翻译,但依赖原生 Intl API 进行一些其他任务,如数字和日期格式化。然而,一些浏览器(主要是移动浏览器)还不支持这个 API。因此,如果您想要支持这些浏览器,可能需要添加一个填充物。 github.com/andyearnshaw/Intl.js/ 是官方文档中推荐的一个。

获取和设置当前区域设置

除了各种视图资源,我们将在本章后面看到,aurelia-i18n还导出一个I18N类,它作为各种 API(如i18next和原生 Intl API)的门面。

让我们看看我们如何使用这个 API 来获取和设置当前区域设置,通过创建一个locale-picker自定义元素,用户可以更改当前区域设置:

src/resources/elements/locale-picker.html

<template> 
  <select class="navbar-btn form-control"  
          value.bind="selectedLocale"  
          disabled.bind="isChangingLocale"> 
    <option repeat.for="locale of locales" value.bind="locale"> 
      ${locale} 
    </option> 
  </select> 
</template> 

在此模板中,我们首先添加一个select元素,其值将绑定到selectedLocale属性,当isChangingLocale属性为true时,该元素将被禁用。在select元素中,我们为locales数组中的每个值渲染一个option。每个optionvalue绑定到其locale值,每个选项的文本将是本身使用字符串插值表达式渲染的locale

接下来,我们需要添加视图模型,这将使这个模板与I18N API 相连接:

src/resources/elements/locale-picker.js

import {inject, bindable} from 'aurelia-framework'; 
import {I18N} from 'aurelia-i18n'; 

@inject(I18N) 
export class LocalePickerCustomElement { 

  @bindable selectedLocale; 
  @bindable locales = ['en', 'fr']; 

  constructor(i18n) { 
    this.i18n = i18n; 

    this.selectedLocale = this.i18n.getLocale(); 
    this.isChangingLocale = false; 
  } 

  selectedLocaleChanged() { 
    this.isChangingLocale = true; 
    this.i18n.setLocale(this.selectedLocale).then(() => { 
      this.isChangingLocale = false; 
    }); 
  } 
} 

首先,这个类的构造函数从接收I18N实例开始,然后使用其getLocale方法检索当前区域设置并初始化selectedLocale属性。由于这个属性是可绑定的,所以声明实例的模板可以对其默认值进行数据绑定。

接下来,属性更改处理程序selectedLocaleChanged将在selectedLocale属性发生变化时由模板引擎调用,将isChangingLocale设置为true,以便禁用select元素,然后调用I18NsetLocale方法。由于它可能需要从服务器加载新的翻译文件,所以这个方法是异步的,返回一个Promise,我们监听其完成以将isChangingLocale恢复为false,以便重新启用select元素。

由于我们的本地化选择器默认支持英语和法语,因此我们需要为法语添加另一个翻译文件,其中包含一个空对象:

locales/fr/translation.json

{} 

我们现在可以使用这个自定义元素在app组件中:

src/app.html

<!-- Omitted snippet...--> 
<form class="navbar-search pull-right"> 
  <locale-picker></locale-picker> 
</form> 
<ul class="nav navbar-nav navbar-right"> 
  <!-- Omitted snippet...--> 
</ul> 
<!-- Omitted snippet...--> 

当然,如果你在这个时候运行应用程序,当你改变当前的本地化设置时,什么也不会被翻译;必须首先向模板中添加文本翻译。

翻译

aurelia-i18n库提供了许多不同的翻译文本的方法。在本节中,我们将了解我们的选择有哪些。

使用属性

在模板中翻译文本的最简单方法是使用名为t的翻译属性。让我们通过翻译我们的未找到页面来说明这一点。

我们将从将文本移动到翻译文件开始:

locales/en/translation.js

{ 
  "404": { 
    "explanation": "The page cannot be found.", 
    "title": "Something is broken..." 
  } 
} 

locales/fr/translation.js

{ 
  "404": { 
    "explanation": "La page est introuvable.", 
    "title": "Quelque-chose ne fonctionne pas..." 
  } 
} 

正如你所见,由于翻译是 JSON 结构,我们完全可以没有任何问题地使用嵌套键。

要在元素内静态显示翻译后的文本,你只需要向元素添加t属性,并将其值设置为翻译键的路径:

src/not-found.html

<template> 
  <h1 t="404.title"></h1> 
  <p t="404.explanation"></p> 
</template> 

渲染后,属性将在当前本地化的翻译文件中查找键,并将翻译值分配给元素的文本内容。如果当前本地化是英语,渲染后的 DOM 将看起来像这样:

<h1 t="404.title">The page cannot be found.</h1> 
<p t="404.explanation">Something is broken...</p> 

也可以使用t来翻译属性的值:

<input type="text" value.bind="contact.firstName"  
       t="[placeholder]contacts.firstName"> 

通过在方括号内加上属性的名称来前缀键,t属性将为这个属性分配翻译值,而不是元素的文本内容。在这里,翻译键contacts.firstName的值将被分配给inputplaceholder属性。

此外,可以在单个元素上翻译多个目标,通过用分号分隔指令来实现:

<label t="[title] help; text"> 

在这里,help键的值将被分配给title属性,text的值将被分配给元素的文本内容。当然,使用相同的技术翻译多个属性也是可能的。

最后,t属性监控当前的本地化设置。当它改变时,输出会自动使用新的本地化设置进行更新。

传递参数

由于i18next支持向翻译传递参数,你可以将对象绑定到t-params属性以传递翻译的参数。

让我们想象一下以下的翻译:

{ "message": "Hi {{name}}, welcome back!" } 

使用属性将name参数传递给这个翻译看起来像这样:

<p t="message" t-params.bind="{ name: 'Chuck' }"></p> 

渲染后,p元素将包含文本Hi Chuck, welcome back!

使用值转换器

t属性的一种替代方案是t值转换器。它可以在任何绑定表达式中使用,包括字符串插值,所以在某些情况下它比属性更方便:

<p>${'explanation' | t}</p> 

在这里,t值转换器将在翻译文件中查找explanation翻译键并输出其值。

它的使用不仅限于字符串插值。它还适用于其他绑定表达式:

<p title.bind=" 'explanation' | t "></p> 

在这里,title属性将包含explanation键的翻译。

传递参数

值转换器接受一个包含翻译参数的对象作为其第一个参数。

让我们假设以下的翻译:

{ "message": "Hi {{name}}, welcome back!" } 

使用这个翻译与值转换器的效果是这样的:

<p>${'message' | t: { name: 'Chuck' } }</p> 

渲染后,p元素将包含文本Hi Chuck, welcome back!

使用绑定行为

然而,如果你的应用程序允许你在其生命周期内更改语言,那么值转换器根本就没有用。由于值转换器的工作方式,t值转换器不知道它必须重新评估其值,因为它不能在当前区域更改时得到通知。

这就是t绑定行为发挥作用的地方。当应用t绑定行为时,它简单地将t值转换器装饰在其绑定指示上。那么,为什么不用值转换器呢?

记得我们在第三章中看到的signal绑定行为吗?显示数据?好吧,I18NsetLocale方法实际上触发了aurelia-translation-signal绑定信号,而t绑定行为监听它。当当前区域更改时,所有活动的t绑定行为强制其绑定表达式重新评估,所以每个绑定表达式的底层值转换器可以使用新的区域。

传递参数

传递给绑定行为的任何参数对象都将传递给底层的值转换器,所以值转换器示例也适用于绑定行为:

<p ></p> 

使用代码

当然,翻译一个键的所有这些不同方式都依赖于同一个I18N方法:

tr(key: string, parameters?: object): string 

例如,假设i18nI18N的一个实例,在 JS 代码中翻译同一个message键就像这样:

let message = i18n.tr('message', { name: 'Chuck' }); 

选择一种技术胜过另一种

我们刚刚看到了四种不同的做事方式。一开始可能很难决定在哪种情况下一种技术最适合胜过其他技术。

t属性是来自i18next的一个遗留问题。当独立使用,在 Aurelia 之外时,i18next使用这个属性在 DOM 树内翻译文本。aurelia-i18n库可能支持它,只是为了让有i18next经验的人可以像往常一样使用它。然而,在一个 Aurelia 应用内部,它并不能在每种情况下使用;例如,它在与自定义元素一起使用时表现不佳,因为它会覆盖元素的内容。

作为一个经验法则,在模板内翻译时,我总是选择绑定行为技术。由于t属性和t值转换器有如此重要的限制,这种技术是最灵活的,我可以通过在整个应用程序中使用相同的技术来保持一致性。

如果应用程序只有一种语言,或者如果用户在应用程序启动后不能更改当前语言,那么可以使用值转换器技术。然而,我看不出真正的益处。尽管它的内存占用可能比绑定行为略小一些,但收益不会很大,而且如果上下文发生变化,应用程序突然需要支持区域设置变化,每个值转换器实例都不得不被绑定行为替换,到处都是。因此,在大多数情况下,使用值转换器可能是一种相当鲁莽的赌博。

最后,当需要在 JS 代码中翻译文本时,我会直接使用 API,在这种情况下,I18N实例可以很容易地被注入到需要它的类中。

这些指南适用于翻译,也适用于以下各节中描述的格式化特性。

格式化数字

如前所述,aurelia-i18n也依赖于本地 Intl API 提供数字格式化功能。

注意

由于库使用了 Intl API,如果你不熟悉它,我强烈建议你查阅相关资料。Mozilla 开发者网络提供了关于该主题的详尽文档:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl

使用值转换器

格式化数字的最简单方法是使用nf值转换器:

${1234 | nf} 

它只是使用当前区域设置创建一个Intl.NumberFormat实例,并调用其format方法,将1234值传递给它。

它也可以直接传递一个Intl.NumberFormat实例:

${1234 | nf: myNumberFormat} 

在这种情况下,直接使用传递的Intl.NumberFormat实例来format值。

最后,它可以传递一个选项对象,可选地传递一个区域设置或区域设置数组:

${1234 | nf: { currency: 'EUR' }} 
${1234 | nf: { currency: 'EUR' }: 'fr'} 

在这种情况下,将创建一个Intl.NumberFormat实例,使用选项和区域设置来format值。如果没有传递区域设置,将使用当前区域设置。

使用绑定行为

nf值转换器有一个与t值转换器相同的问题:如果当前区域设置发生变化,它没有办法得到通知。因此,如果应用程序在其生命周期内允许您更改语言,应使用nf绑定行为:

${1234 & nf} 

它的运作方式与t绑定行为完全相同,监听aurelia-translation-signal绑定信号,并在信号发出时强制重新评估其绑定表达式。

它也是通过在幕后用nf值转换器装饰其绑定指令,并将所有参数传递给它,因此它支持与值转换器相同的参数。

使用代码

在幕后,值转换器依赖于I18Nnf方法:

nf(options?: object, locales?: string | string[]): Intl.NumberFormat 

这个方法简单地使用提供的选项和区域设置创建一个Intl.NumberFormat实例,并返回它。如果没有传递区域设置,将使用当前区域设置:

let value = i18n.nf({ currency: 'EUR' }).format(1234); 

在这里,我们调用nf方法使用提供的选项和当前区域设置创建一个Intl.NumberFormat实例,然后我们调用结果Intl.NumberFormat对象的format方法。

格式化日期

国际化的 Intl API 还包括日期格式化功能。因此,aurelia-i18n封装了这些功能,使其更简单地与当前区域设置一起工作。

使用值转换器

df值转换器的工作方式与nf值转换器几乎相同:

${contact.birthday | df} 

它应用的值预期要么是一个Date对象,要么是一个string,该string将使用Date(string)构造函数转换为Date对象。

df值转换器在幕后的工作方式与nf基本相同,不同之处在于它使用Intl.DateTimeFormat类。这意味着它可以接受一个Intl.DateTimeFormat实例作为参数:

${contact.birthday | df: myDateTimeFormat} 

在这种情况下,format方法将直接在提供的Intl.DateTimeFormat实例上调用。

它还可以接受一个选项对象,以及可选的区域设置或区域设置数组:

${contact.birthday | df: { timeZone: 'UTC' }} 
${contact.birthday | df: { timeZone: 'UTC' }: 'fr'} 

在这种情况下,将使用选项和区域设置创建一个Intl.DateTimeFormat实例来format值。如果没有传递区域设置,将使用当前区域设置。

使用绑定行为

df值转换器与tnf值转换器有同样的问题:它无法知道当前区域设置何时发生变化,因此无法重新评估其输出。因此,当应用程序生命周期中区域设置可以发生变化时,应使用df绑定行为:

${contact.birthday & df} 

它的工作方式与tnf绑定行为相同,它用df值转换器装饰其绑定表达式,并在aurelia-translation-signal发出时强制它重新评估其值:

此外,它将其参数传递给其底层值转换器,因此它支持与df值转换器相同的签名。

使用代码

值转换器依赖于I18N类的df方法来格式化日期:

df(options?: object, locales?: string | string[]): Intl.DateTimeFormat 

nf方法类似,它简单地使用提供的选项和区域设置创建一个Intl.DateTimeFormat实例,并返回它。如果没有提供区域设置,将使用当前区域设置:

let value = i18n.df({ timeZone: 'UTC' }).format(new Date()); 

在这里,我们调用df方法使用提供的选项和当前区域设置创建一个Intl.DateTimeFormat实例,然后我们调用结果Intl.DateTimeFormat对象的format方法。

格式化相对时间

aurelia-i18n库还提供了一个服务,用于将时间相对于当前系统时间格式化。它允许你输出类似于now5 seconds ago2 days ago等人友好的时间差。

使用值转换器

显示人类友好时间差的最简单方法是使用rt值转换器:

src/contacts/components/details.html

//Omitted snippet... 
${contact.modifiedAt | rt} 
//Omitted snippet... 

在这里,输出可能是类似于5 days ago,这取决于contact.modifiedAt的值和当前系统时间。

转换器应用的值预期要么是一个Date对象,要么是一个string,它将使用Date(string)构造函数转换为Date对象。

周期性地刷新值

之前的例子有一个小问题:rt的输出相对于当前时间,但从不更新。如果永远显示5 秒钟前,用户可能会觉得有些奇怪。

通常,rt值转换器将与signal绑定行为一起使用:

src/contacts/components/details.html

//Omitted snippet... 
${contact.modifiedAt | rt & signal: 'rt-update'} 
//Omitted snippet... 

当然,这意味着我们需要在某个地方发出rt-update信号,可能是在视图模型中:

src/contacts/components/details.js

import {inject} from 'aurelia-framework';  
import {Router} from 'aurelia-router'; 
import {BindingSignaler} from 'aurelia-templating-resources';   
import {ContactGateway} from '../services/gateway'; 
import {Contact} from '../models/contact'; 

@inject(ContactGateway, Router, BindingSignaler) 
export class ContactDetails { 

  constructor(contactGateway, router, signaler) { 
    this.contactGateway = contactGateway; 
    this.router = router; 
    this.signaler = signaler; 
  } 

  activate(params, config) { 
    return this.contactGateway.getById(params.id) 
      .then(contact => { 
        this.contact = Contact.fromObject(contact); 
        config.navModel.setTitle(this.contact.fullName); 
        this.rtUpdater = setInterval( 
          () => this.signaler.signal('rt-update'), 1000); 
      }); 
  } 

  //Omitted snippet... 

  deactivate() { 
    if (this.rtUpdater) { 
      clearInterval(this.rtUpdater); 
      this.rtUpdater = null; 
    } 
  } 
} 

在这里,我们首先在视图模型中注入一个BindingSignaler实例。然后,一旦联系人加载完成,我们使用setInterval函数每秒发出一个rt-update信号。每次发出信号时,视图中的signal绑定行为将刷新绑定并重新应用rt值转换器到contact.modifiedAt

我们通过使用clearInterval函数在组件停用时停止信号的发出,从而防止内存泄漏。

这段代码仍然有一个问题:如果当前区域更改,绑定将会有延迟地刷新。这个问题很容易解决:

src/contacts/components/details.html

//Omitted snippet... 
${contact.modifiedAt | rt  
  & signal:'rt-update':'aurelia-translation-signal'} 
//Omitted snippet... 

我们只需要监听aurelia-translation-signal信号,以及rt-update信号。前者是由I18N在当前区域每次更改时发出的信号。

现在contact.modifiedAt显示的时间差将每秒刷新,并且在当前区域更改时也会更新。

使用代码

值转换器依赖于一个独特的类,名为RelativeTime,该类由aurelia-i18n导出,并提供以下方法:

getRelativeTime(time: Date): string 

这个方法简单地计算提供的time和当前系统时间之间的差异,并使用内置的翻译集合,返回当前区域的人友好的文本。

如果你需要从一些 JS 代码中转换日期为人友好的相对时间,你可以在你的类中轻松注入RelativeTime的一个实例并使用其getRelativeTime方法。

翻译我们的联系人管理应用程序

在此阶段,您已经拥有完全国际化我们的联系人管理应用程序所需的所有工具,除了验证消息和文档标题,它们需要与aurelia-validationaurelia-router集成,这部分内容将在接下来的章节中详细介绍。

展示如何国际化应用程序中的每个模板会花费太长时间并且相当繁琐,所以我会留给读者作为一个练习。像往常一样,本章的示例应用程序可以作为参考。

下面的章节假设您已经国际化了您工作副本中应用程序中可以国际化的所有内容。如果您跳过手动执行此操作,我强烈建议您从书籍资源中的chapter-8/samples/app-translated目录获取最新的代码副本。

与验证集成

如果您向使用aurelia-validation的应用程序添加国际化,您将希望翻译错误消息。本节解释了如何将这两个库结合起来实现这一点。

覆盖 ValidationMessageProvider

验证库使用一个ValidationMessageProvider类来获取错误消息。让我们扩展这个类,并使用I18N从翻译文件中获取消息:

src/validation/i18n-validation-message-provider.js

import {inject} from 'aurelia-framework'; 
import {I18N} from 'aurelia-i18n'; 
import {ValidationParser, ValidationMessageProvider}  
  from 'aurelia-validation'; 

@inject(ValidationParser, I18N) 
export class I18nValidationMessageProvider  
  extends ValidationMessageProvider { 

  options = { 
    messageKeyPrefix: 'validation.messages.', 
    propertyNameKeyPrefix: 'validation.properties.' 
  }; 

  constructor(parser, i18n) { 
    super(parser); 
    this.i18n = i18n; 
  } 

  getMessage(key) { 
    let translationKey = key.includes('.') || key.includes(':')  
      ? key  
      : `${this.options.messageKeyPrefix}${key}`; 
    let translation = this.i18n.tr(translationKey); 
    if (translation !== translationKey) { 
      return this.parser.parseMessage(translation); 
    } 
    return super.getMessage(key); 
  } 

  getDisplayName(propertyName) { 
    let translationKey =  
      `${this.options.propertyNameKeyPrefix}${propertyName}`; 
    let translation = this.i18n.tr(translationKey); 
    if (translation !== translationKey) { 
      return translation; 
    } 
    return super.getDisplayName(propertyName); 
  } 
} 

在这里,我们首先创建一个ValidationParser实例,这是ValidationMessageProvider基类所需的,并在构造函数中注入一个I18N实例。我们还定义了options,在执行翻译前用于构建键的前缀。

接下来,我们覆盖了getMessage方法,在其中我们构建了一个翻译键,然后请求I18N实例对其进行翻译。由于tr方法最终如果没有找到对应的翻译,就会返回键,所以我们只有在找到翻译时才使用翻译,否则我们退回到getMessage的基础实现。

构建翻译键时,如果键不包含任何点或冒号,我们会在其前面加上options的默认前缀,因为我们认为这个键将是验证规则的名称,这是默认行为。然而,我们的getMessage实现允许验证规则定义一个自定义消息键,这可以是一个自定义的翻译路径,从翻译文件中的另一个区域或命名空间获取消息文本。

getDisplayName方法遵循一个类似的过程:我们在键前面加上options的默认前缀,翻译它,然后使用翻译(如果找到了的话),或者如果没有找到,就退回到基础实现。

默认情况下,我们会认为所有的验证翻译都会存放在一个共同的validation对象下,该对象将在一个messages对象下包含所有错误消息,在properties对象下包含所有属性显示名称。这些路径前缀是存储在options对象中的默认值。

这个options对象如果应用程序的某个部分需要在其翻译文件的不同部分查找验证键时可能很有用;在这种情况下,应用程序的这部分可以定义自己的、定制的I18nValidationMessageProvider实例,使用不同的options值。

下一步是告诉验证系统使用这个类而不是默认的ValidationMessageProvider。在validation特性的configure函数中执行这个操作最合适:

src/validation/index.js

import {ValidationMessageProvider} from 'aurelia-validation'; 
import './rules'; 
import {BootstrapFormValidationRenderer}  
  from './bootstrap-form-validation-renderer'; 
import {I18nValidationMessageProvider}  
  from './i18n-validation-message-provider'; 

export function configure(config) { 
  config.plugin('aurelia-validation'); 
  config.container.registerHandler('bootstrap-form',  
    container => container.get(BootstrapFormValidationRenderer)); 

 config.container.registerSingleton( 
    ValidationMessageProvider, I18nValidationMessageProvider); 
} 

在这里,我们只需告诉 DI 容器使用I18nValidationMessageProvider实例代替ValidationMessageProvider

添加翻译

现在验证系统已经知道去哪里获取翻译后的错误消息和属性显示名称,接下来让我们添加正确的翻译:

locales/en/translation.json

{ 
  //Omitted snippet... 
  "validation": { 
    "default": "${$displayName} is invalid.", 
    "required": "${$displayName} is required.", 
    "matches": "${$displayName} is not correctly formatted.", 
    "email": "${$displayName} is not a valid email.", 
    "minLength": "${$displayName} must be at least ${$config.length} character${$config.length === 1 ? '' : 's'}.", 
    "maxLength": "${$displayName} cannot be longer than ${$config.length} character${$config.length === 1 ? '' : 's'}.", 
    "minItems": "${$displayName} must contain at least ${$config.count} item${$config.count === 1 ? '' : 's'}.", 
    "maxItems": "${$displayName} cannot contain more than ${$config.count} item${$config.count === 1 ? '' : 's'}.", 
    "equals": "${$displayName} must be ${$config.expectedValue}.", 
    "date": "${$displayName} must be a valid date.", 
    "notEmpty": "${$displayName} must contain at least one item.", 
    "maxFileSize": "${$displayName} must be smaller than ${$config.maxSize} bytes.", 
    "fileExtension": "${$displayName} must have one of the following extensions: ${$config.extensions.join(', ')}." 
  },  
  "properties": { 
    "address": "Address", 
    "birthday": "Birthday", 
    "city": "City", 
    "company": "Company", 
    "country": "Country", 
    "firstName": "First name", 
    "lastName": "Last name", 
    "note": "Note", 
    "number": "Number", 
    "postalCode": "Postal code",  
    "state": "State", 
    "street": "Street", 
    "username": "Username" 
  }, 
  //Omitted snippet... 
} 

messages下的键是aurelia-validation在撰写本文时支持的标准规则,以及我们在validation特性中定义的自定义规则的消息。那些在properties下的键是应用程序中使用的每个属性的显示名称。至于法语翻译,您可以从本章的示例应用程序中获得。

在此阶段,如果您运行应用程序,点击新建按钮,例如在生日文本框中输入胡言乱语然后尝试保存,您应该会看到一条翻译后的错误消息出现。然而,如果您使用视图区域右上角的地区选择器更改当前语言环境,验证错误将不会随新语言环境刷新。

为了实现这一点,ValidationController实例需要被告知在当前语言环境发生变化时重新验证。

刷新验证错误

为了刷新验证错误,联系人创建视图模型必须订阅一个名为i18n:locale:changed的事件,当当前语言环境发生变化时,通过应用程序的事件聚合器由I18N实例发布。

事件聚合器是 Aurelia 默认配置的一部分,已经安装并加载,因此在我们的应用程序中使用它时,我们不需要做任何事情。我们可以直接更新我们的creation视图模型:

src/contacts/components/creation.js

import {EventAggregator} from 'aurelia-event-aggregator'; 
//Omitted snippet... 
@inject(ContactGateway, NewInstance.of(ValidationController),  
  Router, EventAggregator) 
export class ContactCreation { 

  contact = new Contact(); 

  constructor(contactGateway, validationController,  
              router, events) { 
    this.contactGateway = contactGateway; 
    this.validationController = validationController; 
    this.router = router; 
    this.events = events; 
  } 

  activate() { 
    this.i18nChangedSubscription = this.events.subscribe( 
      'i18n:locale:changed',  
      () => { this.validationController.validate(); }); 
  }
 deactivate() { 
    if (this.i18nChangedSubscription) { 
      this.i18nChangedSubscription.dispose(); 
      this.i18nChangedSubscription = null; 
    } 
  } 
  //Omitted snippet... 
} 

在这里,我们只需订阅正确的事件,在当前语言环境发生变化时触发验证。当然,当组件被停用时,我们也需要处理订阅,以防止内存泄漏。

如果您再次尝试保存带有无效数据的新联系人,然后在显示验证错误时更改语言环境,您应该会看到错误消息随着新语言环境实时刷新。

整合路由器

您可能注意到我们完全忽略了文档标题的翻译,即在浏览器顶部栏显示的标题。由于这个标题由aurelia-router库控制,我们需要找到一种将路由器与I18N服务集成的方法。

实际上,这样做相当简单。Router类提供了一个专门为此类场景设计的集成点:

src/main.js

import {Router} from 'aurelia-router';  
import {EventAggregator} from 'aurelia-event-aggregator'; 
//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .feature('validation') 
    .feature('resources') 
    .feature('contacts') 
    .plugin('aurelia-i18n', (i18n) => { 
      i18n.i18next.use(Backend); 

      return i18n.setup({ 
        backend: { 
          loadPath: './locales/{{lng}}/{{ns}}.json',  
        }, 
        lng : 'en', 
        fallbackLng : 'en', 
        debug : environment.debug 
      }).then(() => { 
        const router = aurelia.container.get(Router);  
        const events = aurelia.container.get(EventAggregator); 
        router.transformTitle = title => i18n.tr(title);  
        events.subscribe('i18n:locale:changed', () => { 
          router.updateTitle(); 
        }); 
      }); 
    }); 
  //Omitted snippet... 
}); 

这里,我们首先从aurelia-router库导入Router类和从aurelia-event-aggregator库导入EventAggregator类。接下来,当I18Nsetup方法返回的Promise解决时,我们检索应用程序的根路由器实例,并将其transformTitle属性设置为一个函数,该函数将接收一个路由的标题并使用I18Ntr方法对其进行翻译。我们还检索事件聚合器并订阅i18n:locale:changed事件。当这个事件发布时,我们调用路由器的updateTitle方法。

当然,我们需要将所有标题替换为翻译键,并将这些添加到翻译文件中。我将留这作为读者的练习;不过,这里有一个快速列表,列出了那些标题必须更改的地方:

  • 应用程序的主标题,在src/app.js中的app组件的configureRouter方法中设置。

  • contacts功能的主路由的标题,在src/contacts/index.js中的联系人的configure函数中添加到路由器。

  • src/contacts/main.js中定义的第一个两个路由的标题。

本章完成的示例可以作为参考。

如果您继续测试这个,文档标题应该被正确翻译。当更改当前区域设置时,它也应该相应地更新。

按功能分割翻译

从书的开头,我们的一个目标就是尽可能保持应用程序中的特性解耦。本章中我们国际化的方式完全违反了这条规则。

有方法通过使用命名空间来按照功能分割翻译文件,这是i18next的一个特性。然而,这为我们的应用程序增加了另一层复杂性。这应该让我们重新评估我们的架构选择。我们从拥有解耦特性的好处是否值得它们不断增加的复杂性?这个问题非常值得提出。

如果您对这个问题答案仍然是肯定的,并且您对如何做到这一点感到好奇,您可以查看本书资源中的chapter-8/samples/app-translations-by-feature下的示例应用程序,它实现了这种分割。

摘要

国际化和被认为是简单话题常常被忽视,但正如本章所看到,在应用程序中它在很多层面都有影响。如果在一个项目后期添加翻译,它可能会迫使一个团队重新思考一些架构决策。

然而,一个设计良好且功能强大的国际化库可以极大地帮助这些任务。建立在著名的翻译库i18next和新的网络标准 Intl API 之上,aurelia-i18n是这样的一个库。

第九章:动画

应用程序中的动画现在是常见的。动画视觉转换通常会给人一种流畅感,而且很好地使用动画可以是向用户传达某事最好的方式,比图标、图片或又是另一段文字。

Aurelia 的模板引擎已被设计来支持动画。它使用一个抽象层,允许可插拔的动画库,而 Aurelia 生态系统已经提供了多个实现。

在本章中,我们将首先了解动画师 API,并看看模板引擎是如何与其交互的。然后,我们将向我们的联系管理应用程序添加一些简单的基于 CSS 的动画,以了解它是如何工作的。

动画师 API

aurelia-templating库中,TemplatingEngine类需要与动画服务一起工作以执行视图转换。默认情况下,它使用一个名为Animator的类,该类作为空对象,顺便说一下,描述了Animator期望的接口。

注意

空对象设计模式描述了一个作为接口的空实现的对象或类。这个对象可以用作 null 引用,并消除了在引用之前检查 null 的需要。您可以在sourcemaking.com/design_patterns/null_object上获取有关此模式的更多信息。

以下是从动画师 API 中最常用的方法:

  • enter(element: HTMLElement): Promise<boolean>: 在 DOM 中添加元素的动画效果

  • leave(element: HTMLElement): Promise<boolean>: 将元素从 DOM 中移除的动画效果

  • addClass(element: HTMLElement, className: string): Promise<boolean>: 为元素添加 CSS 类,这可以根据实现方式触发动画

  • removeClass(element: HTMLElement, className: string): Promise<boolean>: 从元素中移除 CSS 类,这可以根据实现方式触发动画

  • animate(element: HTMLElement|HTMLElement[], className: string): Promise<boolean>: 在一个元素或元素数组上执行单个动画。className要么是触发动画的 CSS 类,要么是应用的效果名称,要么是动画的属性,这取决于动画师实现方式

  • runSequence(animations: CssAnimation[]): Promise<boolean>: 按顺序运行一系列动画。CssAnimation是一个由具有element: HTMLElementclassName: string属性的对象实现的接口。对于每个动画,className要么是触发动画的 CSS 类,要么是应用的效果名称,要么是动画的属性,这取决于动画师实现方式

所有这些方法都返回一个Promise,其解决值为一个boolean值。这个值通常是true,当确实执行了动画时,以及false,当没有执行动画时。最后一个场景可能会发生,例如,尝试使用不定义任何动画的 CSS 类来动画化一个元素。

在撰写本文时,模板引擎对动画师的调用仅限于在将元素添加到 DOM 时调用其enter方法,然后在移除它时调用其leave方法。其他方法不被框架使用,但将由我们自己的代码使用。

最后,对元素的动画转换是可选的。模板引擎在渲染元素时调用enter方法,在从 DOM 中移除它时调用leave方法,但仅当元素具有au-animateCSS 类。这是出于性能原因;如果没有这个可选机制,每次渲染和卸载任何元素时都会执行大量无用的代码,而通常,只有少数选定的元素具有动画转换。

CSS 动画师

aurelia-animator-css库是基于 CSS 的动画师实现。我们将安装它,并使用它为我们的联系人管理应用程序添加简单的基于 CSS 的动画。

安装插件

首先,在项目目录中打开一个控制台,并运行以下命令:

> npm install aurelia-animator-css --save

像往常一样,它需要添加到供应商包中。在aurelia_project/aurelia.json文件中,在build下的bundles部分,添加到名为vendor-bundle.js的包的dependencies下列:

"aurelia-animator-css", 

最后,我们需要加载插件,以便模板引擎使用它而不是默认的Animator

src/main.js

//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .plugin('aurelia-animator-css') 
    .feature('validation') 
  //Omitted snippet... 
} 

至此,一切就绪,可以处理 CSS 动画。

动画视图转换

然而,在动手之前,让我们快速了解一下高级算法,并看看基于 CSS 的动画师的enterleave方法是如何工作的。

当模板引擎将渲染的元素添加到 DOM 时,以下过程会发生:

  1. 模板引擎将元素添加到 DOM。

  2. 模板引擎检查元素是否具有au-animate类。如果有,它调用动画师的enter方法。如果没有,动画师完全被绕过,过程到这里结束。

  3. 动画师为元素添加au-enter类。这个类可以在描述元素在整个动画过程中将保持不变的样式的 CSS 规则中使用。

  4. 动画师为元素添加au-enter-active类。这个类应该在触发动画的 CSS 规则中使用。

  5. 动画师检查元素的计算样式是否包含动画。如果不包含,它会从其中移除au-enterau-enter-active类,并使用false解决产生的Promise。到这里过程结束。如果包含,它开始监听浏览器上的animationend事件。

  6. 当收到animationend事件时,动画师将元素的au-enterau-enter-active类移除,并将产生的Promise解决为true

从 DOM 中删除元素的流程非常相似,但顺序相反:

  1. 模板引擎检查元素是否具有au-animate类。如果有,它调用动画师的leave方法。如果没有,动画师完全被绕过,过程直接跳到第 6 步。

  2. 动画师给元素添加了au-leave类。这个类可以在描述元素在整个动画期间将保持不变的样式的 CSS 规则中使用。

  3. 动画师给元素添加了au-leave-active类。这个类应该用在触发动画的 CSS 规则中。

  4. 动画师检查元素的计算样式是否包含动画。如果有,它开始监听浏览器的一个animationend事件。如果没有,它将au-leaveau-leave-active类从其中移除,并将产生的Promise解决为false。过程直接跳到第 6 步。

  5. 当收到animationend事件时,动画师将元素的au-leaveau-leave-active类移除,并将产生的Promise解决为true

  6. 模板引擎将元素从 DOM 中移除。

既然我们已经理解了基于 CSS 的动画师是如何处理事情的,让我们先来动画化list-editor组件。

列表编辑器动画

我们在第五章制作可复用的组件中编写的list-editor组件具有允许用户添加和删除项目的特性。让添加的项目出现,比如窗帘被拉下,和删除的项目消失,比如窗帘被拉上,应该不会很难。

为了这样做,我们首先需要为组件定义 CSS 动画:

src/resources/elements/list-editor.css

list-editor .le-item.au-enter-active { 
  animation: blindDown 0.2s; 
  overflow: hidden; 
} 

@keyframes blindDown { 
  0% { max-height: 0px; } 
  100% { max-height: 80px; } 
} 

list-editor .le-item.au-leave-active { 
  animation: blindUp 0.2s; 
  overflow: hidden; 
} 

@keyframes blindUp { 
  0% { max-height: 80px; } 
  100% { max-height: 0px; } 
} 

在这里,我们首先定义了用于添加项目的 CSS 规则;项目的max-height将在 0.2 秒内从 0 动画到 80 像素,在此期间,其溢出内容将被隐藏。

然后,我们定义了用于删除项目的 CSS 规则。这与添加项目非常相似,但顺序相反;它的max-height在 0.2 秒内从 80 像素动画到 0。在这个动画期间,它的溢出内容也将被隐藏。

当然,我们需要用组件加载这个新的 CSS 文件:

src/resources/elements/list-editor.html

<template> 
  <require from="./list-editor.css"></require> 
  <!-- Omitted snippet... --> 
</template> 

我们还需要提示模板引擎项目应该被动画化:

src/resources/elements/list-editor.html

<!-- Omitted snippet... --> 
<div class="form-group le-item ${animated ? 'au-animate' : ''}"  
     repeat.for="item of items"> 
  <!-- Omitted snippet... --> 
</div> 
<!-- Omitted snippet... --> 

在这里,我们在class属性中添加了一个字符串插值表达式,只有在animated属性为真时,才会给项目的div元素添加au-animate类。

在视图模型中,animated属性将初始设置为false,因此在组件渲染时项目不会被动画化。只有在组件完全附加到 DOM 时,该属性才会被设置为true,因此添加新项目或移除现有项目的操作才能正确动画化:

src/resources/elements/list-editor.js

// Omitted snippet... 
export class ListEditorCustomElement { 
  // Omitted snippet... 

  animated = false; 

  attached() { 
    setTimeout(() => { this.animated = true; }); 
  } 
} 

为什么我们不在attached回调方法中直接将animated设置为true?为什么要用setTimeout类?嗯,如果你记得前一部分中描述的动画过程,首先元素被附加到 DOM,这意味着attached回调在同一时间被调用,然后动画师检查au-animateCSS 类。如果在attached回调中同步地将animated设置为true,当动画师检查是否需要对元素进行动画化时,au-animateCSS 类将出现在元素上,在初始渲染期间项目将被动画化,这是我们想要防止的。相反,我们将animated设置为true推送到浏览器的事件队列中,这样当au-animateCSS 类添加到项目的div元素时,组件的渲染已完成。

至此,如果你运行应用程序,导航到联系人creationedition组件,并尝试编辑列表编辑器;你应该看到动画播放。

手动触发动画

除了动画过渡效果,动画师还支持手动触发的动画。与动画过渡不同,手动动画没有au-enterau-leave这样的 CSS 类。相反,动画是通过使用用户自定义的 CSS 类来手动触发的。

用于手动触发动画的基本方法是 addClass 和 removeClass。这些方法允许你向元素添加或移除 CSS 类,并在两个状态之间实现动画过渡。

例如,假设我们有一个名为A的 CSS 类。如果我们调用animator.addClass('A'),以下过程会发生:

  1. 动画师将A-add类添加到元素上。

  2. 动画师检查元素的计算样式是否包含动画。如果不包含,它将A类添加到元素上,然后将其上的A-add类移除,并以false解析结果Promise。在此处结束该过程。如果包含动画,它开始监听浏览器上的animationend事件。

  3. 当接收到animationend事件时,动画师将A类添加到元素上,然后将其上的A-add类移除。

正如你所看到的,这个过程允许你向元素添加一个 CSS 类,并在没有该类的元素和具有该类的元素之间实现动画状态过渡,该过程应由带有-add后缀的中间类触发。

此外,当在同一元素上调用animator.removeClass('A')时,以下过程会发生:

  1. 动画师将A类从元素中移除。

  2. 动画师将A-remove类添加到元素上。

  3. 动画师检查元素的计算样式是否包含动画。如果不包含,它会从其中移除A-remove类,并用false解析产生的Promise。流程在这里结束。如果包含,它开始监听浏览器上的animationend事件。

  4. 当收到animationend事件时,动画师从元素中移除A-remove类,并用true解析产生的Promise

这个过程允许您从一个元素中移除 CSS 类,在带有类和不带类的元素之间进行带有动画的状态转换,该状态转换应由带有-remove后缀的中间类触发。

最后,animate方法允许按顺序触发addClassremoveClass。在这种情况下,动画可以由-add类、-remove类或两者同时触发。

强调验证错误

让我们在我们的联系人管理应用程序中尝试这个功能,通过添加一个动画,当用户尝试保存一个联系人并且表单无效时,验证错误会闪烁几次。

首先,我们需要创建一个 CSS 动画:

src/contacts/components/form.css

.blink-add { 
  animation: blink 0.5s; 
} 

@keyframes blink { 
  0% { opacity: 1; } 
  25% { opacity: 0; } 
  50% { opacity: 1; } 
  75% { opacity: 0; } 
  100% { opacity: 1; } 
} 

这里,我们简单地定义了一个 CSS 规则,使匹配的元素在半秒内闪烁两次。触发动画的类名为blink-add,所以我们可以通过调用addClass来触发它。然而,由于使错误信息闪烁不是一个状态转换,并且我们不想让我们的错误信息带有blink类,我们将通过调用animate来触发它,这样我们就可以确保blink在动画结束时被移除。

为了促进代码重用,让我们将当前仅作为模板的联系人form组件转换为完整的组件。为此,我们需要为表单创建一个视图模型。在这个视图模型中,我们将添加一个通过使它们闪烁来强调错误的方法:

src/contacts/components/form.js

import {inject, bindable, DOM} from 'aurelia-framework'; 
import {Animator} from 'aurelia-templating'; 

@inject(DOM.Element, Animator) 
export class ContactFormCustomElement { 

  @bindable contact; 

  constructor(element, animator) { 
    this.element = element; 
    this.animator = animator; 
  } 

  emphasizeErrors() { 
    const errors = this.element 
      .querySelectorAll('.validation-message'); 
    return this.animator.animate(Array.from(errors), 'blink'); 
  } 
} 

首先,我们定义视图模型,在其中移动bindable contact属性的声明;然后我们注入组件的 DOM 元素和animator实例。接下来,我们定义一个emphasizeErrors方法,该方法检索元素内的所有验证错误并使用blink效果调用它们。

当调用animate时,animator将遍历向元素添加blink-add的过程,这将触发动画。动画完成后,它将移除blink,添加blink-remove,并且由于blink-remove不触发任何动画,它将立即移除它,使元素回到过程开始时的状态。

接下来,我们需要从模板中移除bindable属性,因为contact现在是由视图模型定义的,并且加载包含新动画的 CSS 文件:

src/contacts/components/form.html

<template> 
  <require from="./form.css"></require> 
  <!-- Omitted snippet... --> 
</template> 

最后,让我们更新一下creation组件。我们首先需要更改formrequire语句,去掉.html后缀,这样模板引擎就知道该组件不仅仅是一个模板,还包含一个视图模型。我们还需要在creation组件的模板中获取form视图模型的引用:

src/contacts/components/creation.html

<template> 
  <require from="./form"></require> 
  <!-- Omitted snippet... --> 
  <contact-form contact.bind="contact"  
    view-model.ref="form"></contact-form> 
  <!-- Omitted snippet... --> 
</template> 

通过在contact-form自定义元素上添加view-model.ref="form"属性,将form视图模型的引用分配给creation视图模型作为一个新的form属性。

我们现在可以使用这个form属性在验证失败时调用emphasizeErrors方法:

src/contacts/components/creation.js

//Omitted snippet... 
save() { 
  return this.validationController.validate().then(errors => { 
    if (errors.length > 0) { 
      this.form.emphasizeErrors(); 
      return; 
    } 
    //Omitted snippet... 
  } 
} 
//Omitted snippet... 

至此,如果您运行应用程序,点击New按钮,在Birthday字段中输入胡言乱语,然后点击Save,验证错误信息应该出现并闪烁两次。每次点击Save按钮时,它应该再次闪烁。

当然,edition组件也应该以同样的方式进行修改。我将留给读者作为练习。本章节的示例应用程序可以作为参考。

动画路由转换

另一个可能从动画转换中受益的区域是路由器。让我们给路由转换添加一个简单的淡入/淡出动画:

src/app.css

/* Omitted snippet... */ 

section.au-enter-active { 
  animation: fadeIn 0.2s; 
} 

section.au-leave-active { 
  animation: fadeOut 0.2s; 
} 

@keyframes fadeIn { 
  0% { opacity: 0; } 
  100% { opacity: 1; } 
} 

@keyframes fadeOut { 
  0% { opacity: 1; } 
  100% { opacity: 0; } 
} 

在这里,我们创建 CSS 规则,使section元素在进入时淡入,在离开时淡出。

接下来,我们只需向每个路由组件的section元素添加au-animate类。

如果您在此时运行应用程序,路由更改应该使用新动画平滑过渡。

交换顺序

当执行路由转换时,router-view元素用新视图替换旧视图。默认情况下,这个交换过程首先动画化旧视图的移除,然后是新视图的插入。如果没有视图动画,过程是立即的。如果两个视图都有动画,动画一个接一个地运行。

router-view处理视图交换的方式称为交换策略,可以是以下之一:

  • before:首先添加新视图,然后移除旧视图。如果新视图有动画,则等待其enter动画完成后再动画化旧视图的移除。

  • with:新视图添加,旧视图移除同时进行。两个动画并行运行。

  • after:默认的交换策略。先移除旧视图,然后添加新视图。如果旧视图有动画,新视图的插入仅在旧视图的移除动画完成后进行一次动画化。

我们的淡入/淡出转换之所以正常工作,是因为它遵循了默认的交换策略:首先将旧视图动画化移出,然后将新视图动画化进入。然而,某些动画可能需要不同的交换策略。

例如,如果你在从一个路由跳转到另一个路由时希望看到新视图从右侧滑入,而旧视图向左滑出,那么你需要旧视图的移除动画和新视图的添加动画同时运行,因此你需要使用with交换策略。

因此,router-view元素的交换策略可以通过将其swap-order属性设置为适当策略的名称来更改:

<router-view swap-order="with"></router-view> 

摘要

为 Aurelia 应用程序添加动画相当简单。基于 CSS 的实现允许您轻松快速地为现有应用程序添加动画。

当需要更复杂的动画时,如果它不存在,可以很容易地编写您最喜欢的动画库的适配器插件。在撰写本文时,官方 Aurelia 库包括aurelia-velocity,它是流行的velocity.js库的适配器插件。我确信社区最终会提出其他动画解决方案的适配器,所以我强烈建议您密切关注它。

第十章 生产环境的打包

将 JavaScript 应用程序部署到生产环境时,打包是一个重要的性能实践。通过将资源(主要是 JavaScript 代码、HTML 模板和 CSS 表单)合并成单个文件,我们可以大大减少浏览器为服务应用程序而必须进行的 HTTP 调用次数。

CLI 总是打包它运行的应用程序,即使在开发环境中也是如此。这使得将应用程序部署到服务器变得相当简单;只需构建它,然后复制一堆文件即可。

然而随后版本控制问题出现了。当部署我们应用程序的新版本时,如果打包文件保持相同的名称,缓存的打包文件可能不会刷新,导致用户运行我们应用程序的过时版本。我们如何处理这个问题?

在本章中,我们将了解如何自定义联系人管理应用程序的打包。我们还将了解如何利用 CLI 的修订功能对打包文件进行版本控制,以便我们可以充分利用 HTTP 缓存。最后,我们将向项目中添加一个新的构建任务,以方便部署。

配置打包

默认情况下,使用 CLI 创建的项目包含两个打包文件:第一个名为vendor-bundle.js,其中包含应用程序使用的所有外部库;第二个名为app-bundle.js,其中包含应用程序本身。

打包配置在aurelia_project/aurelia.json文件中的构建部分。以下是在典型应用程序中的样子:

"bundles": [ 
  { 
    "name": "app-bundle.js", 
    "source": [ 
      "[**/*.js]", 
      "**/*.{css,html}" 
    ] 
  }, 
  { 
    "name": "vendor-bundle.js", 
    "prepend": [ 
      "node_modules/bluebird/js/browser/bluebird.core.js", 
      "scripts/require.js" 
    ], 
    "dependencies": [ 
      "aurelia-binding", 
      "aurelia-bootstrapper", 
      "aurelia-dependency-injection", 
      "aurelia-framework", 
      //Omitted snippet... 
    ] 
  } 
] 

每个打包文件都有一个唯一的名称,必须定义其内容,这些内容可以来自应用程序和外部依赖项。通常,app-bundle包括应用程序源中的所有 JS、HTML 和 CSS,而vendor-bundle包括外部依赖项。

这通常是小到中等应用程序的最佳配置。外部依赖项通常不会经常更改,它们被组合在它们自己的打包文件中,因此用户在新版本的应用程序发布时不需要下载这些依赖项。在大多数情况下,他们只需要下载新的app-bundle

将应用程序合并到单一打包中

然而,如果您出于某种原因希望将应用程序及其依赖项打包成一个单一的包,这样做是相当简单的。您只需要定义一个包含应用程序源代码和外部依赖项的单一打包文件:

注意

以下部分代码片段摘自本书资源中的chapter-10/samples/app-single-bundle示例。

aurelia_project/aurelia.json

"bundles": [ 
  { 
    "name": "app-bundle.js", 
    "prepend": [ 
      "node_modules/bluebird/js/browser/bluebird.core.js", 
      "scripts/require.js" 
    ], 
    "source": [ 
      "[**/*.js]", 
      "**/*.{css,html}" 
    ], 
    "dependencies": [ 
      "aurelia-binding", 
      "aurelia-bootstrapper", 
      //Omitted snippet... 
    ] 
  } 
] 

由于 Aurelia 应用程序的入口点是aurelia-bootstrapper库,入口点打包文件必须包含bootstrapper。默认情况下,这是vendor-bundle。如果您在此处更改入口点打包文件,它将成为app-bundle;您需要更改几件事。

首先,仍然在aurelia_project/aurelia.jsonbuild下,加载器的configTarget属性必须改为新的入口点捆绑文件:

aurelia_project/aurelia.json

"loader": { 
  "type": "require", 
  "configTarget": "app-bundle.js", 
  // Omitted snippet... 
}, 

此外,index.html的主要script标签也必须引用新的入口点捆绑文件:

index.html

<!-- Omitted snippet... --> 
<body aurelia-app="main"> 
  <script src="img/app-bundle.js" 
          data-main="aurelia-bootstrapper"></script> 
</body> 
<!-- Omitted snippet... --> 

如果你在此时运行应用程序,你会看到生成了一个单一的捆绑文件,浏览器在启动应用程序时只加载这个文件。

将应用程序拆分为多个捆绑文件

在某些情况下,将整个应用程序源代码放在一个app-bundle中是不理想的。我们很容易想象一个基于高度分隔的用户故事构建的应用程序。用户,根据他们的角色,只使用这个应用程序的特定部分。

这样的应用程序可以被拆分为多个较小的捆绑文件,每个文件对应一个与角色相关的部分。这样,用户就不会下载他们从未使用的应用程序部分的捆绑文件。

以下部分中的示例是从书籍资源中的chapter-10/samples/ app-with-home sample中摘录的。

让我们尝试通过将我们应用程序中的contacts特性移动到其自己的捆绑文件中来尝试这个方法。为此,我们首先需要从app-bundle中排除contacts目录中的所有内容:

aurelia_project/aurelia.json

{ 
  "name": "app-bundle.js", 
  "source": { 
    "include": [ 
      "[**/*.js]", 
      "**/*.{css,html}" 
    ], 
    "exclude": [ 
      "**/contacts/**/*" 
    ] 
  } 
} 

source属性支持数组形式的通配符模式,或者一个对象,该对象具有include和可选的exclude属性,都预期包含一个通配符模式的数组。

在这里,我们只是将source的先前值移动到include属性中,并添加一个匹配contacts目录中所有内容的exclude属性。

接下来,我们需要定义新的捆绑文件:

aurelia_project/aurelia.json

{ 
  "name": "app-bundle.js", 
  //Omitted snippet... 
}, 
{ 
  "name": "contacts-bundle.js", 
  "source": [ 
    "[**/contacts/**/*.js]", 
    "**/contacts/**/*.{css,html}" 
  ] 
},

这个名为contacts-bundle.js的新捆绑文件将包括contacts目录中的所有 JS、HTML 和 CSS 文件。

如果你在此时运行应用程序,你应该首先看到scripts目录现在包含了三个捆绑文件:app-bundle.jscontacts-bundle.jsvendor-bundle.js。如果你在浏览器中打开应用程序并检查调试控制台,你应该看到在加载应用程序时,浏览器首先加载vendor-bundle,然后是app-bundle,最后是contacts-bundle

当主configure函数在应用程序启动过程中加载contacts特性时,会加载contact-bundle。Aurelia 的特性有一个局限性:很难将一个特性隔离在一个单独的捆绑文件中。实际上,一个特性的index文件以及它所有的依赖项应该被包含在app-bundle中。将其单独打包是没有用的,因为另一个捆绑文件在启动时会被加载。然而,特性中的其他所有内容都可以单独打包。

在我们应用程序中,即使你做了这个改动,当应用程序启动时contacts-bundle仍然会被加载,因为app组件会自动将用户重定向到联系人的默认路由,即联系人列表。

如果你在应用程序中添加一个主页组件作为默认路由,并确保这个主页组件包含在app-bundle中,你应该可以看到只有在导航到它时才会加载contacts-bundle

版本化捆绑包

默认情况下,捆绑包是使用静态名称生成的。这意味着一个已经缓存了捆绑包的浏览器无法知道其缓存是否最新。如果应用程序发布了新版本怎么办?

为了解决这个问题,一个(糟糕)的解决方案是设置缓存持续时间到一个很短的时间段,这会强制所有用户频繁地下载所有捆绑包,或者接受一些用户可能运行我们应用程序的过时版本,这意味着相应地管理后端、网络服务等的兼容性。这似乎是一个导致噩梦的好配方。

一个更好的解决方案是在每个捆绑包的名称中添加某种修订号,并将缓存时间设置为让index.html的缓存时间非常短,甚至完全禁用其缓存。由于index.html与捆绑包相比非常小,这是一个有趣的选择,因为每次给定用户访问应用程序时,他会下载index.html的最新副本,该副本又会引用最新版本的捆绑包。这意味着捆绑包可以永久缓存,因为给定捆绑包名称的内容永远不会改变。用户永远不会下载某个捆绑包版本超过一次。

Aurelia CLI 通过在文件名后添加后缀来支持捆绑包版本化。这个后缀是文件内容计算出的哈希值。默认情况下,版本化是禁用的。要启用它,请打开aurelia_project/aurelia.json文件,在build部分的options设置rev属性:

aurelia_project/aurelia.json

"options": { 
  "minify": "stage & prod", 
  "sourcemaps": "dev & stage", 
  "rev": "stage & prod" 
}, 

修订机制是按环境单独启用的。通常,它会在 staging 和 production 环境中启用。然而,它不应该在开发环境中使用,因为它与浏览器重新加载以及使用watch开关时的捆绑重建机制不太友好。此外,由于大多数开发人员系统地在与缓存禁用的浏览器中进行测试,它将没有多大价值。

你还需要始终确保在aurelia_project/aurelia.json文件中,在build部分下targets的第一个条目有一个index属性,其值为index.html

aurelia_project/aurelia.json

"targets": [ 
  { 
    "id": "web", 
    "displayName": "Web", 
    "output": "scripts", 
    "index": "index.html" 
  } 
], 

这使得捆绑器知道加载应用程序的 HTML 文件的名称,因此它可以更新加载入口点捆绑的script标签。

现在,你可以通过在项目目录中打开控制台并运行以下命令来测试这个:

> au build --env stage

一旦命令完成,你应该在 scripts 目录下看到现在包含在其名称中的哈希的包。你应该看到类似于 app-bundle-ea03d27d90.jsvendor-bundle-efd8bd9cd8.js 的文件,哈希可能不同。

此外,在 index.html 中,script 标签内的 src 属性现在应该指的是带有哈希的 vendor-bundle 文件名称。

部署应用程序

此时,部署我们的应用程序相当简单。我们需要将以下文件复制到托管它的服务器上:

  • index.html

  • favicon.ico

  • locales/

  • styles/

  • scripts/

  • node_modules/bootstrap/

  • node_modules/font-awesome/

现在,大多数项目都会使用某种软件工厂来构建和部署应用程序。当然,我们可以在工厂的构建任务中轻松地放置这些文件列表。然而,这意味着每次我们向该列表添加一个文件或目录时,都需要更改构建任务。

当我在一个 Aurelia 项目中工作时,我喜欢做的一件事是在 aurelia_project/aurelia.json 文件中创建一个新的 deploy 部分,将其设置为匹配部署包中要包含的文件的 glob 模式列表:

aurelia_project/aurelia.json

{ 
  //Omitted snippet... 
  "build": { 
    //Omitted snippet... 
  }, 
  "deploy": { 
    "sources": [ 
      "index.html", 
      "favicon.ico", 
      "locales/**/*", 
      "scripts/*-bundle*.{js,map}", 
      "node_modules/bootstrap/dist/**/*", 
      "node_modules/font-awesome/{css,fonts}/**/*" 
    ] 
  } 
} 

除此之外,我通常还在项目中创建一个 deploy 任务。这个任务只是构建应用程序,然后将文件复制到要部署的目标目录,该目标目录作为任务的一个参数传递。

让我们首先创建任务定义:

aurelia_project/tasks/deploy.json

{ 
  "name": "deploy", 
  "description": "Builds, processes and deploy all application assets.", 
  "flags": [ 
    { 
      "name": "out", 
      "description": "Sets the output directory (required)", 
      "type": "string" 
    }, 
    { 
      "name": "env", 
      "description": "Sets the build environment (uses debug by default).", 
      "type": "string" 
    } 
  ] 
} 

接下来,让我们创建一个 copy 任务,该任务将由 deploy 任务使用:

aurelia_project/tasks/copy.js

import gulp from 'gulp'; 
import {CLIOptions} from 'aurelia-cli'; 
import project from '../aurelia.json'; 

export default function copy() { 
  const output = CLIOptions.getFlagValue('out', 'o'); 
  if (!output) { 
    throw new Error('--out argument is required'); 
  } 

  return gulp.src(project.deploy.sources, { base: './' }) 
    .pipe(gulp.dest(output)); 
} 

这个任务首先检索作为 out 参数传递的目标目录,如果省略则失败,然后使用来自 aurelia_project/aurelia.json 中新 deploy 部分的 glob 模式列表,并将每个匹配的文件复制到提供的目标目录中。

最后,我们可以创建部署任务本身:

aurelia_project/tasks/deploy.js

import gulp from 'gulp'; 
import build from './build'; 
import copy from './copy'; 

export default gulp.series( 
  build, 
  copy 
); 

这个任务只是依次执行 buildcopy。我们甚至可以在 buildcopy 之间运行单元测试任务。

这个 gulp 任务极大地简化了软件工厂中的构建任务。典型的软件工厂构建过程首先从版本控制中检出代码,然后运行以下命令:

> npm install
> au deploy --env $(env) --out $(build-artifacts)

最后,它会将 $(build-artifacts) 下的所有内容复制到 Web 服务器上。

在这个场景中,$(env)$(build-artifacts) 是一些环境或系统变量。第一个包含了构建所针对的环境,比如 stageprod,而第二个包含了一些临时文件夹,从中复制要部署到 Web 服务器的工件。例如,它可能仅仅是工作目录中的一个 dist 文件夹。

这种解决方案的一个优点是,现在与构建和部署我们的应用程序相关的大多数细节都在项目本身之内。软件工厂不再依赖于应用程序源代码的文件结构和文件名,而是仅依赖于gulp任务。

总结

由于命令行界面(CLI)始终以捆绑模式运行应用程序,所以最初看起来部署 Aurelia 应用程序相当简单。然后你开始考虑 HTTP 缓存过期的问题,事情就变得有点复杂了。

幸运的是,CLI 已经提供了解决这些问题的工具。再加上一些良好实践,使将应用程序准备部署到现实世界变得足够简单。

第十一章.与其他库集成

UI 框架永远不会独自存在,尤其是 Web 框架。由于 Web 是一个丰富的平台,并且由一个充满活力的社区推动,因此有数千个库、小部件和组件可以在这个平台上无数的场景中 leverage,这大大节省了开发人员的时间。

在本章中,我们将了解如何将各种库集成到我们的联系人管理应用程序中。我们将添加来自 Bootstrap 和 jQuery UI 的 UI 小部件,使用sortable.js提供一些拖放支持,以及使用 D3 的图表。我们还将了解如何利用 SASS 而不是 CSS。最后,我们甚至将了解如何集成 Polymer 组件。

使用 Bootstrap 小部件

从这本书的开头到现在,我们一直依赖于 Bootstrap 来为我们的应用程序样式和布局。然而,我们还没有使用库的 JS 小部件。让我们看看我们如何可以将此类小部件集成到我们的应用程序中。

加载库

由于 Bootstrap 的 JS 小部件使用 jQuery,所以我们首先需要安装它:

> npm install jquery --save

接下来,我们需要将 jQuery 和 Bootstrap JS 资源添加到供应商包中:

aurelia_project/aurelia.json

{ 
  //Omitted snippet... 
  { 
    "name": "vendor-bundle.js", 
    "prepend": [ 
      "node_modules/bluebird/js/browser/bluebird.core.js", 
      "scripts/require.js" 
    ], 
    "dependencies": [ 
      //Omitted snippet... 
      "jquery", 
      { 
        "name": "bootstrap", 
        "path": "../node_modules/bootstrap/dist", 
        "main": "js/bootstrap.min", 
        "deps": ["jquery"], 
        "exports": "$", 
        "resources": [ 
          "css/bootstrap.min.css" 
        ] 
      }, 
      //Omitted snippet... 
    ] 
    //Omitted snippet... 
  } 
  //Omitted snippet... 
} 

在这里,我们在包的依赖项中添加了 jQuery,然后更新了 Bootstrap 的条目,以便在 jQuery 之后加载 JS 小部件。

应用程序中的bootstrap模块也配置为导出全局jQuery对象。这意味着我们可以在 JS 代码中从bootstrap导入jQuery对象,并确保 Bootstrap 小部件已经注册到 jQuery 上。

创建一个 bs-tooltip 属性

让我们通过一个简单的例子来看看如何使用 Bootstrap JS 小部件与 Aurelia 配合。我们将创建一个自定义属性,它将封装 Bootstrap 的tooltip小部件:

src/resources/attributes/bs-tooltip.js

import {inject, DOM, dynamicOptions} from 'aurelia-framework'; 
import $ from 'bootstrap'; 

const properties = [ 
  'animation', 'container', 'delay', 'html',  
  'placement', 'title', 'trigger', 'viewport' 
]; 

@dynamicOptions 
@inject(DOM.Element) 
export class BsTooltipCustomAttribute { 

  isAttached = false; 

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

  attached() { 
    const init = {}; 
    for (let property of properties) { 
      init[property] = this[property]; 
    } 
    $(this.element).tooltip(init); 
    this.isAttached = true; 
  } 

  detached() { 
    this.isAttached = false; 
    $(this.element).tooltip('destroy'); 
  } 
} 

在这里,我们首先从 Bootstrap 中导入 jQuery 全局对象。这将确保 Bootstrap JS 库已正确加载并注册到 jQuery 命名空间中。我们还声明了tooltip小部件支持的属性列表,因此属性可以使用动态选项,并忽略不支持的选项。

我们将使用动态选项而不是显式选项,只是为了少写一些代码。我们接下来会写一些更改处理方法,如果我们使用一个显式的属性列表,在BsTooltipCustomAttribute类中全部声明为可绑定的,我们将为每个属性编写一个不同的更改处理器。所有这些更改处理器都会做几乎相同的事情:更新 Bootstrap 小部件的相应选项。相反,由于我们使用动态选项,我们可以为所有选项编写一个单一的更改处理器。

现在我们可以创建一个名为bs-tooltip的自定义属性。它作为构造函数参数接收放置它的 DOM 元素。当附加到 DOM 时,它将传递给属性的每个支持属性的值分配给一个init对象。然后这个对象被传递到tooltip初始化方法,该方法在属性托管的元素上调用。最后一行将创建tooltip小部件。

最后,当从 DOM 中分离时,它只是调用tooltip小部件的destroy方法。

bs-tooltip属性的这个第一个版本不支持更新属性。这可以通过使用propertyChanged回调方法来更新tooltip小部件来实现:

src/resources/attributes/bs-tooltip.js

//Omitted snippet... 
export class BsTooltipCustomAttribute { 
  //Omitted snippet... 

  propertyChanged(name) { 
    if (this.isAttached && properties.indexOf(name) >= 0) { 
      $(this.element).data('bs.tooltip').options[name] = this[name]; 
    } 
  } 
} 

在这里,当属性值发生变化且属性当前附加到 DOM 时,我们首先确保属性被小部件支持,然后我们简单地更新小部件的属性。

使用属性

现在我们可以向任何元素添加 Bootstraptooltip。让我们在list-editor组件中将移除按钮的title属性替换为 Bootstraptooltip

src/resources/elements/list-editor.html

<!-- Omitted snippet... --> 
<button type="button" class="btn btn-danger le-remove-btn"  
        click.delegate="removeItem($index)"  
        bs-tooltip="title.bind: 'resources.actions.remove' & t;  
                    placement: right"> 
    <i class="fa fa-times"></i> 
  </button> 
  <!-- Omitted snippet... --> 

在这里,我们只是将移除按钮的t="[title]..."属性删除,并用bs-tooltip属性替换它。在这个属性中,我们定义了一个title选项,将其绑定到前面相同的翻译结果。我们使用.bind命令和t绑定行为,当当前区域发生变化时,将更新工具提示的title。我们还指定tooltip应该放置在托管元素的right侧,使用placement选项。

不要忘记加载bs-tooltip属性,可以作为resources特性中的configure函数的全球资源,或者在list-editor模板中使用require语句来加载。

如果你在这个时候运行应用程序,并用鼠标悬停在一个list-editor实例中的移除按钮上,应该会出现一个 Bootstraptooltip小部件。

创建 bs-datepicker 元素

我们联系人管理应用程序可以极大地受益于的一个小部件是一个日期选择器。这会让大多数用户输入生日变得更加方便。

Bootstrap 本身并不包括日期选择器,但有些作为插件提供。在本节中,我们将安装bootstrap-datepicker插件,加载它,并创建一个新的自定义元素,该元素将封装一个包含日期选择器的input元素。

安装 bootstrap-datepicker 插件

我们首先安装 Bootstrap 插件:

> npm install bootstrap-datepicker --save

接下来,我们需要将其添加到供应商包中:

aurelia_project/aurelia.json

{ 
  //Omitted snippet... 
  { 
    "name": "vendor-bundle.js", 
    "prepend": [ 
      "node_modules/bluebird/js/browser/bluebird.core.js", 
      "scripts/require.js" 
    ], 
    "dependencies": [ 
      //Omitted snippet... 
      { 
        "name": "bootstrap-datepicker", 
        "path": "../node_modules/bootstrap-datepicker/dist", 
        "main": "js/bootstrap-datepicker.min", 
        "deps": ["jquery"], 
        "resources": [ 
          "css/bootstrap-datepicker3.standalone.css" 
        ] 
      }, 
      //Omitted snippet... 
    ] 
  } 
  //Omitted snippet... 
} 

在这里,我们将bootstrap-datepicker库添加到供应商包中。与标准的 Bootstrap 小部件一样,这个插件在 jQuery 对象上添加了新的函数,所以它需要有一个对 jQuery 的依赖,这样它才能注册自己。它还作为额外的资源加载自己的样式表。

创建自定义元素

现在插件已经准备好使用,我们可以开始构建自定义元素了。我们的bs-datepicker元素将暴露一个双向绑定的date属性,它将分配选定的日期作为Date对象。它还将暴露一个可绑定的options属性,我们将用它来提供传递给底层bootstrap-datepicker小部件实例的选项。

首先,让我们编写它的模板:

src/resources/elements/bs-datepicker.html

<template> 
  <require from="bootstrap-datepicker/css/ 
                 bootstrap-datepicker3.standalone.css"></require> 
  <input ref="input" class="form-control" /> 
</template> 

这个模板只需要样式表bootstrap-datepicker,然后声明一个input元素。这个input的引用将被分配给绑定上下文的input属性,以便视图模型可以使用它来托管日期选择器。

接下来,让我们编写视图模型类:

src/resources/elements/bs-datepicker.js

import {bindable, bindingMode} from 'aurelia-framework'; 
import $ from 'bootstrap'; 
import 'bootstrap-datepicker'; 

export class BsDatepickerCustomElement { 

  static defaultOptions = { autoclose: true, zIndexOffset: 1050 }; 

  @bindable({ defaultBindingMode: bindingMode.twoWay }) date; 
  @bindable options; 

  isAttached = false; 
  isUpdating = false; 

  createDatepicker() { 
    const options = Object.assign({},  
      BsDatepickerCustomElement.defaultOptions,  
      this.options); 
    $(this.input).datepicker(options) 
      .on('clearDate', this.updateDate) 
      .on('changeDate', this.updateDate); 
    if (this.date) { 
      this.updateDatepickerDate(); 
    } 
  } 

  destroyDatepicker() { 
    $(this.input) 
      .datepicker() 
      .off('clearDate', this.updateDate) 
      .off('changeDate', this.updateDate) 
      .datepicker('destroy'); 
  } 

  updateDate = function() { 
    if (!this.isUpdating) { 
      this.date = $(this.input).datepicker('getUTCDate'); 
    } 
  }.bind(this); 

  updateDatepickerDate() { 
    $(this.input).datepicker('setUTCDate', this.date); 
  } 

  optionsChanged() { 
    if (this.isAttached) { 
      this.destroyDatepicker(); 
      this.createDatepicker(); 
    } 
  } 

  dateChanged() { 
    if (this.isAttached) { 
      this.isUpdating = true; 
      this.updateDatepickerDate(); 
      this.isUpdating = false; 
    } 
  } 

  attached() { 
    this.createDatepicker(); 
    this.isAttached = true; 
  } 

  detached() { 
    this.isAttached = false; 
    this.destroyDatepicker(); 
  } 
} 

我们首先需要从 Bootstrap 中导入全局 jQuery 对象;记住,我们在将 Bootstrap 库添加到 vendor bundle 中时,它导出了 jQuery 对象,以便我们编写bs-tooltip属性。

接下来,我们加载bootstrap-datepicker插件,使其正确注册到 jQuery 中,然后创建自定义元素的类。

它首先声明一个静态的defaultOptions属性,用于在创建小部件时设置选项的默认值。

当元素附加到 DOM 时,它在input上创建一个datepicker小部件实例。它还订阅了小部件的clearDatechangeDate事件,这样当小部件的选定日期发生变化时,它可以更新自己的date属性;然后初始化小部件的选定日期。

您可能想知道我们为什么添加这些事件监听器,为什么不直接绑定到input的值。那是因为小部件已经处理了input值的验证及其作为Date对象的解析,所以我们的自定义元素只需依赖于日历的选定日期即可。基本上,我们的自定义元素只是将其date可绑定属性与日历的选定日期桥接起来。当小部件的选定日期发生变化时,其中一个事件监听器会被触发,并将小部件的新值分配给元素的date属性。同样,由于元素的date属性默认使用双向绑定,当date属性发生变化时,通常是在模板中使用元素时进行初始化,绑定系统将调用dateChanged方法,并更新小部件的选定日期。此外,我们使用一个isUpdating属性来防止元素和小部件之间发生无限循环更新。

当元素从 DOM 中分离时,它首先取消订阅小部件的clearDatechangeDate事件,然后调用其destroy方法。

最后,当元素的options属性发生变化时,小部件会被销毁然后重新创建。这是因为,在撰写本文时,bootstrap-datepicker插件没有提供任何 API 来更新小部件的选项。

注意

正如你所看到的,这个元素手动处理了 Aurelia 与 Bootstrap 小部件之间的数据绑定。这里看到的模式,在小部件上注册事件处理程序,以及前后同步数据,都是在 Aurelia 中整合外部 UI 库时相当常见的。

Aurelia 社区中的一群人在这个领域做一些非常有趣的工作。他们开发了一种他们称之为桥梁的东西,允许我们在 Aurelia 应用程序中使用各种 UI 框架。他们已经发布了一个针对 Kendo UI 的桥梁,正在为 Bootstrap 和 Materialize 等开发桥梁。如果你对这个问题感兴趣,我建议你看看他们的工作:github.com/aurelia-ui-toolkits

使用元素

现在我们可以轻松地将form组件中绑定到联系人生日的input替换为我们新的bs-datepicker元素:

src/contacts/components/form.html

<!-- Omitted snippet... --> 
<div class="form-group"> 
  <label class="col-sm-3 control-label"  
         t="contacts.birthday"></label> 
  <div class="col-sm-9"> 
    <bs-datepicker date.bind="contact.birthday & validate"> 
    </bs-datepicker> 
  </div> 
</div> 
<!-- Omitted snippet... --> 

在这里,我们简单地将之前的input元素替换为bs-datepicker元素。我们将元素的date属性绑定到contactbirthday属性上,用validate绑定行为装饰这个绑定,以便属性仍然受到验证。

由于我们新元素的这个date属性期待的是一个Date对象,而不是一个字符串值,我们需要改变Contact模型类,使其在从 JS 对象创建时解析它的birthday属性为一个Date实例。另外,我们需要将birthday的默认值从空字符串改为null

src/contacts/models/contact.js

//Omitted snippet... 
export class Contact { 

  static fromObject(src) { 
    const contact = Object.assign(new Contact(), src); 
    if (contact.birthday) { 
      contact.birthday = new Date(contact.birthday); 
    } 
    //Omitted snippet... 
  } 

  //Omitted snippet... 
  birthday = null; 
  //Omitted snippet... 
} 

现在,Contact实例的birthday属性将是null值或Date对象。

此时,如果你运行应用程序,导航到创建或编辑组件,并将焦点给予生日的input,日历选择器应该会出现。你应该能够导航日历并选择一个日期。

不要忘记加载bs-datepicker元素,无论是作为resources特性中的configure函数中的全局资源,还是在form模板中使用require语句。

国际化 bs-datepicker 元素

至此,我们的bs-datepicker元素还不支持国际化。在典型的实际应用中,输入中显示的日期的格式,以及日历中的文本和属性,如一周的第一天,应该是本地化的。

幸运的是,bootstrap-datepicker包含作为额外 JS 模块的本地化数据。我们只需要在捆绑包中包含我们需要本地化的模块。

重新配置 jQuery 和 Bootstrap 的捆绑

然而,在撰写本文时,本地化的模块不支持模块加载机制,而完全依赖于 jQuery 对象处于全局作用域中。因此,我们需要改变使用 jQuery 及 Bootstrap 小部件的方式,不是作为 AMD 模块加载,而是作为全局库加载,利用供应商捆绑包的prepend属性:

aurelia_project/aurelia.json

//Omitted snippet... 
{ 
  "name": "vendor-bundle.js", 
  "prepend": [ 
    "node_modules/bluebird/js/browser/bluebird.core.js", 
    "node_modules/jquery/dist/jquery.min.js", 
    "node_modules/bootstrap/dist/js/bootstrap.min.js", 
    "node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js", 
    "node_modules/bootstrap-datepicker/dist/locales/ 
       bootstrap-datepicker.fr.min.js", 
    "scripts/require.js" 
  ], 
  "dependencies": [ 
    //Omitted snippet... 
  ] 
} 
//Omitted snippet... 

在这里,我们向捆绑包的预加载库中添加了 jQuery、Bootstrap 小部件、bootstrap-datepicker插件及其法语本地化模块(英语本地化数据已内置在插件本身中,因此我们不需要包含它)。这意味着那些库将简单地合并到捆绑包的开头,而不是作为 AMD 模块加载,而是使用全局window作用域。当然,这意味着必须从dependencies数组中删除 jQuery、Bootstrap 和日期选择器插件的条目。

由于预加载的库只能是 JS 文件,这也意味着我们必须改变加载 Bootstrap 样式表的方式:

index.html

<!-- Omitted snippet... --> 
<head> 
    <title>Learning Aurelia</title> 
    <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"  
          rel="stylesheet"> 
    <link href="node_modules/bootstrap-datepicker/dist/css/ 
                bootstrap-datepicker3.standalone.css"  
          rel="stylesheet"> 
  <!-- Omitted snippet... --> 
<head> 
<!-- Omitted snippet... --> 

当然,必须分别从src/app.htmlsrc/resources/elements/bs-datepicker.html模板中删除对bootstrap.cssbootstrap-datepicker3.standalone.cssrequire声明。

最后,必须从bs-tooltip.jsbs-datepicker.js文件中删除对bootstrapbootstrap-datepickerimport声明,因为 jQuery、Bootstrap 和日期选择器插件将从全局作用域访问。

更新元素

要本地化日期选择器小部件,我们只需设置language选项:

src/contacts/components/form.html

<!-- Omitted snippet... --> 
<bs-datepicker date.bind="contact.birthday & validate" 
               options.bind="{ language: locale }"> 
</bs-datepicker> 
<!-- Omitted snippet... --> 

这意味着我们需要将这个locale属性添加到form的视图模型中。我们还需要订阅适当的事件,这样我们可以在当前语言环境发生变化时更新属性:

src/contacts/components/form.js

//Omitted snippet... 
import {I18N} from 'aurelia-i18n'; 
import {EventAggregator} from 'aurelia-event-aggregator'; 

@inject(DOM.Element, Animator, I18N, EventAggregator) 
export class ContactForm { 

@bindable contact; 

constructor(element, animator, i18n, eventAggregator) { 
    this.element = element; 
    this.animator = animator; 
    this.i18n = i18n; 
    this.eventAggregator = eventAggregator; 
  } 

  bind() { 
    this.locale = this.i18n.getLocale(); 
    this._localeChangedSubscription = this.eventAggregator 
      .subscribe('i18n:locale:changed', () => { 
        this.locale = this.i18n.getLocale(); 
      }); 
  } 

  unbind() { 
    this._localeChangedSubscription.dispose(); 
    this._localeChangedSubscription = null; 
  } 

  //Omitted snippet... 
} 

在这里,我们首先从aurelia-i18n库导入I18N类和从aurelia-event-aggregator库导入EventAggregator类。然后我们向 DIC 暗示它们应该都被注入到视图模型的构造函数中。

当组件进行数据绑定时,我们使用I18N实例的getLocale方法初始化locale属性,并订阅i18n:locale:changed事件,这样我们就可以保持locale属性的最新。

最后,当组件解绑时,我们取消事件订阅。

在此阶段,如果您运行应用程序并在切换当前语言环境(在法语和英语之间)的同时尝试生日日期选择器,input中显示的日期格式以及日历的文本和设置应该相应地更新。

使用 jQuery UI 小部件

jQuery UI 小部件库仍然相当受欢迎。将那些小部件集成到 Aurelia 应用程序中与刚刚与 Bootstrap 小部件进行的操作相当相似,尽管不如 Bootstrap 小部件那样无痛,正如我们将在下一节中看到的那样。

让我们使用 jQuery UI 创建一个tooltip属性,以便我们可以与 Bootstrap 的属性进行比较。

注意

以下代码段是从书籍资源中的chapter-11/samples/using-jqueryui示例中摘录的。

安装库

我们首先需要通过在项目目录中打开控制台并运行以下命令来安装 jQuery 和 jQuery UI:

> npm install jquery --save
> npm install github:components/jqueryui#1.12.1 --save

接下来,我们需要将这些库添加到供应商包中。最简单的方法是将它们放入prepend部分:

aurelia_project/aurelia.json

//Omitted snippet... 
{ 
  "name": "vendor-bundle.js", 
  "prepend": [ 
    "node_modules/bluebird/js/browser/bluebird.core.js", 
    "node_modules/jquery/dist/jquery.min.js", 
    "node_modules/components-jqueryui/jquery-ui.min.js", 
    "scripts/require.js" 
  ], 
  "dependencies": [ 
    //Omitted snippet... 
  ] 
} 
//Omitted snippet... 

由于 CSS 文件不能全局加载到prepend部分,所以让我们将它们加载到index.html文件中:

index.html

<!-- Omitted snippet... --> 
<head> 
<title>Aurelia</title> 
  <link href="node_modules/bootstrap/dist/css/bootstrap.min.css"  
        rel="stylesheet"> 
  <link href="node_modules/components-jqueryui/themes/base/all.css"  
        rel="stylesheet"> 
  <!-- Omitted snippet... --> 
</head> 
<!-- Omitted snippet... --> 

此时,我们现在可以创建我们的属性。

创建一个 jq-tooltip 属性

一开始,我们的新属性将与使用 Bootstrap 的那个非常相似:

src/resources/attributes/jq-tooltip.js

import {inject, DOM, dynamicOptions} from 'aurelia-framework'; 

const properties = [ 
  'classes', 'content', 'disabled', 'hide', 'position', 
  'show', 'track',  
]; 

@dynamicOptions 
@inject(DOM.Element) 
export class JqTooltipCustomAttribute { 

  isAttached = false; 

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

  attached() { 
    const options = {}; 
    for (let property of properties) { 
      options[property] = this[property]; 
    } 
    $(this.element).tooltip(options); 
    this.isAttached = true; 
  }   

  detached() { 
    this.isAttached = false; 
    $(this.element).tooltip('destroy'); 
  } 
} 

我们首先定义了jq-tooltip组件支持的options,这样属性就可以使用动态选项并忽略那些在此不支持的属性;jq-tooltip属性表现得与我们在上一节创建的bs-tooltip属性一模一样。接下来,我们提示 DI 容器,应该将包含属性的 DOM 元素注入到构造函数中。

当属性附加到 DOM 时,它检索绑定到属性实例的每个支持属性的值,以构建一个options对象。然后将这个对象传递给tooltip初始化方法,该方法应用于包含属性的元素。

当属性从 DOM 中移除时,在包含属性的元素上调用了小部件的destroy方法。

此时,属性不支持属性更改。由于 jQuery 的tooltip小部件提供了一个 API 来更新选项,这个实现不需要销毁并重新创建小部件来更新属性,就像bs-tooltip属性一样:

src/resources/attributes/jq-tooltip.js

//Omitted snippet... 
propertyChanged(name) { 
  if (this.isAttached && properties.indexOf(name) >= 0) { 
    $(this.element).tooltip('option', name, this[name]); 
  } 
} 
//Omitted snippet... 

在这里,我们简单地添加了propertyChanged回调方法,如果属性附加到 DOM 并且更新后的属性被小部件支持,它将更新小部件实例。

现在我们的属性已经准备好了,让我们在list-editor组件中将移除按钮的title属性替换为jq-tooltip自定义属性:

src/resources/elements/list-editor.html

<!-- Omitted snippet.. --> 
<button type="button" class="btn btn-danger le-remove-btn"  
        click.delegate="removeItem($index)" 
        jq-tooltip="content.bind: 'resources.actions.remove' & t"> 
  <i class="fa fa-times"></i> 
</button> 
<!-- Omitted snippet.. --> 

在这里,我们只是在正确的button元素上放置了一个jq-tooltip属性。我们将它的content属性绑定到正确的翻译,这被t绑定行为修饰。

不要忘记加载jq-tooltip属性,要么作为resources特性中的configure函数中的全局资源,要么在list-editor模板中使用require语句加载。

然而,如果你运行应用程序,并将鼠标悬停在list-editor元素的移除按钮上,你会发现tooltip没有显示。

这是由一个众所周知的长久限制造成的;社区中的一些人会说这是一个 bug(我会同意)在tooltip小部件中,它强制宿主元素具有一个title属性,即使它没有被使用。

因此,让我们更新属性并添加一个方法,如果宿主元素上不存在title属性,则创建一个空的title属性:

src/resources/attributes/jq-tooltip.js

//Omitted snippet... 
attached() { 
  if (!this.element.hasAttribute('title')) { 
    this.element.setAttribute('title', ''); 
  } 
  //Omitted snippet... 
} 
//Omitted snippet... 

现在你可以运行应用程序,tooltip应该正确显示。

使用 SASS 而不是 CSS

SASS,代表 Syntactically Awesome Stylesheets,根据他们的网站,是世界上最为成熟、稳定、强大的专业级 CSS 扩展语言。无论这一说法是否真实,它都是非常受欢迎的,至少我可以肯定地说我使用得很多。

在 Aurelia 应用中使用 SASS 而不是 CSS 相当简单,至少对于基于 CLI 的项目来说是这样。CLI 已经提供了许多 CSS 处理器的支持,比如 SASS、LESS 和 Stylus。

让我们使用 CLI 重新创建我们的联系人管理应用,并在创建过程中启用 SASS 处理器:

使用 SASS 代替 CSS

你可以为所有其他问题选择默认值。

一旦项目创建完成并且已经获取了依赖项,我们就可以把我们应用的工作副本中的以下目录和文件移动到新创建的项目中:

  • aurelia_project/environments

  • locales

  • src

  • index.html

我们还需要从package.json文件中复制dependencies,并运行另一个npm install以获取所有的应用依赖。最后,我们需要复制aurelia_project/aurelia.json文件中的 vendor-bundle 配置。

你可以参考书籍资源中的chapter-11/samples/using-sass示例。

用 SASS 替换 CSS

让我们通过将.css扩展名替换为.scss扩展名,将应用中的 CSS 文件转换为 SASS 文件:

src/resources/elements/list-editor.scss

list-editor .animated .le-item { 
  &.au-enter-active { 
    animation: blindDown 0.2s; 
    overflow: hidden; 
  } 

  &.au-leave-active { 
    animation: blindUp 0.2s; 
    overflow: hidden; 
  } 
} 

@keyframes blindDown { 
  0% { max-height: 0px; } 
  100% { max-height: 80px; } 
} 

@keyframes blindUp { 
  0% { max-height: 80px; } 
  100% { max-height: 0px; } 
} 

由于 CLI 创建的构建任务现在包括一个 SASS 处理器,src目录中的每个.scss文件都将被转换成具有相同路径的.css文件,并且会包含在app-bundle中该路径下。

例如,resources/elements/list-editor.scss文件将被转换成 CSS,结果将被打包成app-bundle中的resources/elements/list-editor.css

这意味着require语句必须保持使用.css扩展名引用样式表:

src/resources/elements/list-editor.html

<template> 
  <require from="./list-editor.css"></require> 
  <!-- Omitted snippet... --> 
</template> 

如果你在这个时候运行应用程序,一切应该都会像以前一样进行样式设计。

拖放与可排序

可排序(Sortable)(github.com/RubaXa/Sortable)是一个知名的拖放库。其简单而强大的 API 使得集成变得非常容易。

我们可以在我们的联系人管理应用中使用它,允许用户使用拖放来重新排序list-editor元素的项。

安装库

首先,我们需要通过在项目目录中打开控制台并运行以下命令来安装库:

> npm install sortablejs --save

接下来,我们需要将其添加到 vendor bundle 中:

aurelia_project/aurelia.json

//Omitted snippet... 
{ 
  "name": "vendor-bundle.js", 
  "prepend": [ 
    //Omitted snippet... 
  ], 
  "dependencies": [ 
    "sortablejs", 
    //Omitted snippet... 
  ] 
}, 
//Omitted snippet... 

此时,我们可以在我们的应用中使用这个库。

给 list-editor 添加拖放

让我们首先给列表项添加一个处理程序。这个处理程序将是用户能够拖动项目上下列表的区域。此外,我们还需要添加一个div元素,它将作为可排序项目的容器:

src/resources/elements/list-editor.html

<!-- Omitted snippet... --> 
<div ref="container"> 
  <div class="form-group le-item ${animated ? 'au-animate' : ''}"  
       repeat.for="item of items"> 
    <template with.bind="item"> 
      <div class="col-sm-1"> 
        <i class="fa fa-bars fa-2x sort-handle pull-right"></i> 
      </div> 
      <template replaceable part="item"> 
        <div class="col-sm-2"> 
          <template replaceable part="label"></template> 
        </div> 
        <!-- Omitted snippet... --> 
      </template> 
      <!-- Omitted snippet... --> 
    </template> 
  </div> 
</div> 
<!-- Omitted snippet... --> 

这里,我们首先在包含列表项的div元素上为视图模型的container属性分配一个引用。这个container将由sortable API 用来启用其子元素的拖放。接下来,我们移除了标签列上的col-sm-offset-1 CSS 类,并添加了一个大小为 1 的列,使用 Bootstrap 的col-sm-1 CSS 类包含一个bars Font Awesome 图标并作为sort-handle,使用相同名称的 CSS 类。

让我们也添加一个 CSS 规则来改变拖动处理器的鼠标光标:

src/resources/elements/list-editor.css

/* Omitted snippet... */ 
list-editor .sort-handle { 
 cursor: move; 
} 

我们现在可以使用sortable来添加拖放支持:

src/resources/elements/list-editor.js

//Omitted snippet... 
import sortable from 'sortablejs'; 

export class ListEditor { 
  //Omitted snippet... 
 moveItem(oldIndex, newIndex) { 
    const item = this.items[oldIndex]; 
    this.items.splice(oldIndex, 1); 
    this.items.splice(newIndex, 0, item); 
  } 

 attached() { 
    this.sortable = sortable.create(this.container, { 
      sort: true, 
      draggable: '.le-item', 
      handle: '.sort-handle',  
      animation: 150, 
      onUpdate: (e) => { 
        if (e.newIndex != e.oldIndex) { 
          this.animated = false; 
          this.moveItem(e.oldIndex, e.newIndex);  
          setTimeout(() => { this.animated = true; }); 
        } 
      } 
    }); 
    setTimeout(() => { this.animated = true; }); 
  } 

 detached() { 
    this.sortable.destroy(); 
    this.sortable = null; 
  } 
  //Omitted snippet... 
} 

这里,我们首先导入了sortable API。然后,当元素附着到 DOM 上时,我们在具有le-item CSS 类的container元素上创建一个sortable实例。我们指定sortable应该使用具有sort-handle CSS 类的项目的子元素作为拖动处理程序。最后,当一个项目在列表的不同位置被放下时,触发onUpdate回调,在其中我们从items数组中删除被放下项目的前一个位置,然后将其放回新的位置。

我们需要使用splice来删除然后添加移动的项目,因为 Aurelia 无法观察数组的索引设置器。它只能通过覆盖Array.prototype的方法来反应数组的变化,比如splice

此外,在移动项目之前,我们还需要删除项目上的animated CSS 类,这样就不会触发动画的 CSS 规则。我们然后使用setTimeout将其加回来,这样只有在模板引擎完成移除旧视图并添加新视图后,它才会被添加。这样,在拖动和放下项目时,不会播放添加或删除项目的动画,这看起来会很奇怪。

最后,当list-editor从 DOM 中分离时,我们在sortable实例上调用destroy方法,以防止内存泄漏。

到此为止,您可以运行应用程序,为联系人列表属性中的一个项目重新排序,并保存表单。在详细视图中,项目应该按照您放置的顺序出现。

使用 D3 绘制图表

以图形的形式呈现数据是现代应用程序中另一个常见的需要。当涉及到 Web 时,D3.js是一个众所周知的光库,它提供了一个非常强大的 API,用于在 DOM 中显示数据。

在下一节中,我们将向我们的联系人管理应用程序添加一个树视图,该视图将按地址部分显示联系人分组。取所有联系人的所有地址,节点的第一个层次将是国家,然后每个国家将有自己的州作为子节点,然后是每个城市,依此类推。

注意

本节我们将要构建的树视图只是用 D3 能够实现功能的一个简单、拙劣的示例。访问d3js.org/,浏览数百个示例,亲自体验这个库的强大功能。

安装库

首先,通过在项目目录中打开控制台并运行以下命令来安装库:

> npm install d3 --save

像往常一样,我们需要将其添加到供应商包中:

aurelia_project/aurelia.json

//Omitted snippet... 
{ 
  "name": "vendor-bundle.js", 
  "prepend": [ 
    //Omitted snippet... 
  ], 
  "dependencies": [ 
    { 
      "name": "d3", 
      "path": "../node_modules/d3/build", 
      "main": "d3.min" 
    }, 
    //Omitted snippet... 
  ] 
} 
//Omitted snippet... 

至此,D3 已准备好使用。

准备应用程序

在创建树本身之前,让我们先为它周围的应用程序做好准备。我们将添加一个route组件,使用网关加载联系人,在其中显示树。我们还将为这个组件在联系人main中添加一个route,然后添加允许在列表和树之间导航的链接。

我们先从route开始:

src/contacts/main.js

//Omitted snippet... 
config.map([ 
  { route: '', name: 'contacts', moduleId: './components/list',  
    title: 'contacts.contacts' }, 
  { route: 'by-address', name: 'contacts-by-address',  
    moduleId: './components/by-address',  
    title: 'contacts.byAddress' }, 
  { route: 'new', name: 'contact-creation',  
    moduleId: './components/creation',  
    title: 'contacts.newContact' }, 
  { 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' }, 
]); 
//Omitted snippet... 

这里,我们简单地添加了一个名为contacts-by-addressroute,匹配by-address路径,并指向我们将在一分钟内创建的by-address组件。

接下来,让我们在列表组件中添加一个链接,该链接指向尚不存在的树组件:

src/contacts/components/list.html

<template> 
  <section class="container au-animate"> 
    <h1 t="contacts.contacts"></h1> 
    <p> 
      <a route-href="route: contacts-by-address"  
         t="contacts.viewByAddress"></a> 
    </p> 
    <!-- Omitted snippet... --> 
  </section> 
</template> 

注意

你可能注意到新routetitle属性和新链接的文本都使用了新的翻译,增加的内容留给读者作为练习。像往常一样,本章节的示例应用程序可以作为参考。

最后,我们将创建by-address组件。为了使事情尽可能解耦,我们将 D3 相关代码隔离在一个名为contact-address-tree的自定义元素中。by-address组件的唯一责任将是将这个自定义元素与应用程序的其他部分连接起来。

让我们先从视图模型开始:

src/contacts/components/by-address.js

import {inject} from 'aurelia-framework'; 
import {Router} from 'aurelia-router'; 
import {ContactGateway} from '../services/gateway'; 

@inject(ContactGateway, Router) 
export class ContactsByAddress { 

  contacts = []; 

  constructor(contactGateway, router) { 
    this.contactGateway = contactGateway; 
    this.router = router; 
  } 

  activate() { 
    return this.contactGateway.getAll().then(contacts => { 
      this.contacts.splice(0); 
      this.contacts.push.apply(this.contacts, contacts);  
    }); 
  } 

  navigateToDetails(contact) { 
    this.router 
      .navigateToRoute('contact-details', { id: contact.id }); 
  } 
} 

这个视图模型相当直接。当激活时,它使用注入的网关检索联系人的完整列表。它还暴露了一个触发导航到给定联系人的详细信息组件的方法。当在树中点击一个联系节点时,将调用这个方法。

模板相当简单,正如您所想象的:

src/contacts/components/by-address.html

<template>  
  <require from="./by-address.css"></require> 
  <require from="../elements/address-tree"></require> 

  <section class="container au-animate"> 
    <h1 t="contacts.byAddress"></h1> 

    <p> 
      <a route-href="route: contacts" t="contacts.viewByName"></a> 
    </p> 

    <contact-address-tree contacts.bind="contacts"  
                          click.call="navigateToDetails(contact)"> 
    </contact-address-tree> 
  </section> 
</template> 

这个模板简单地声明了一个contact-address-tree元素,绑定加载的contacts,并在点击联系节点时调用navigateToDetails

CSS 文件简单地设置了contact-address-tree元素的大小:

src/contacts/components/by-address.css

contact-address-tree { 
  display: block; 
  width: 100%; 
  min-height: 400px; 
} 

创建contact-address-tree自定义元素

一切准备就绪,我们可以使用我们新的元素了,现在让我们创建它。

注意

由于我们正在添加更多专门针对联系人的自定义元素,我建议我们在contacts特性中创建一个新的elements目录,将联系人form移动到那里,并在其中创建这些新元素。本章完成的应用程序示例可以作为参考。

我们首先通过一些 CSS 规则来布局,这些规则将样式化树的各个部分,如分支节点、叶节点和链接:

src/contacts/elements/address-tree.css

contact-address-tree .node circle { 
  fill: #d9edf7; 
  stroke: #337ab7; 
  stroke-width: 1.5px; 
} 

contact-address-tree .node text { 
  font: 15px; 
} 

contact-address-tree .node text { 
  text-shadow: 0 1px 0 #fff, 0 -1px 0 #fff, 1px 0 0 #fff, -1px 0 0 #fff; 
} 

contact-address-tree .leaf { 
  cursor: pointer; 
} 

contact-address-tree .leaf circle { 
  fill: #337ab7; 
} 

contact-address-tree .leaf text { 
  font-weight: bold; 
} 

contact-address-tree .link { 
  fill: none; 
  stroke: #777; 
  stroke-width: 1.5px; 
} 

由于树视图的渲染将由 D3 API 处理,自定义元素不需要模板。因此,它将被声明为带有noView装饰器,传递 CSS 文件的路径给它,以便作为资源加载:

src/contacts/elements/address-tree.js

import {inject, DOM, noView, bindable} from 'aurelia-framework'; 
import * as d3 from 'd3'; 

@inject(DOM.Element) 
@noView(['./address-tree.css']) 
export class ContactAddressTreeCustomElement {      

  @bindable contacts; 
  @bindable click; 

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

此外,视图模型的构造函数将被注入到 DOM 元素本身,因此 D3 API 可以用它作为视口来渲染树。它还暴露了一个contacts和一个click可绑定属性。

这是 Aurelia 部分的内容。现在,我们添加一个attached方法,它将在元素内部渲染树。这个方法里面的代码将完全不知道 Aurelia,只是简单地与d3 API 和 DOM element本身一起工作:

src/contacts/elements/address-tree.js

//Omitted snippet... 
export class ContactAddressTreeCustomElement { 
  //Omitted snippet... 

 attached() { 
    // Calculate the size of the viewport 
    const margin = { top: 20, right: 200, bottom: 20, left: 12 }; 
    const height = this.element.clientHeight  
      - margin.top - margin.bottom; 
    const width = this.element.clientWidth  
      - margin.right - margin.left; 

    // Create the host elements and the tree factory 
    const tree = d3.tree().size([height, width]); 
    const svg = d3.select(this.element).append('svg') 
        .attr('width', width + margin.right + margin.left) 
        .attr('height', height + margin.top + margin.bottom); 
    const g = svg.append('g') 
        .attr('transform',  
              `translate(${margin.left}, ${margin.top})`); 

    // Create the hierarchy, then initialize the tree from it 
    const rootNode = this.createAddressTree(this.contacts); 
    const hierarchy = d3.hierarchy(rootNode); 
    tree(hierarchy); 

    // Render the nodes and links 
    const link = g.selectAll('.link') 
      .data(hierarchy.descendants().slice(1)) 
      .enter().append('path') 
      .attr('class', 'link') 
      .attr('d', d => `M${d.y},${d.x}C${(d.y + d.parent.y) / 2}, 
                       ${d.x} ${(d.y + d.parent.y) / 2}, 
                       ${d.parent.x} ${d.parent.y}, 
                       ${d.parent.x}`); 

    const node = g.selectAll('.node') 
      .data(hierarchy.descendants()) 
      .enter().append('g') 
      .attr('class', d => 'node ' + (d.children ? 'branch' : 'leaf')) 
      .attr('transform', d => `translate(${d.y}, ${d.x})`) 
      .on('click', e => { this.onNodeClicked(e); }); 

    node.append('title') 
      .text(d => d.data.name); 

    node.append('circle') 
      .attr('r', 10); 

    node.append('text') 
      .attr('dy', 5) 
      .attr('x', d => d.children ? -15 : 15) 
      .style('text-anchor', d => d.children ? 'end' : 'start') 
      .text(d => d.data.name); 
  } 
} 

注意

这段代码是 Mike Bostock 示例的简化改编,可以在bl.ocks.org/mbostock/4339083找到。

详细解释d3 API 如何工作超出了本书的范围。然而,前一个代码片段中的内联注释可以让你对它如何工作有一个大致的了解。

你可能注意到了一些缺失的部分:createAddressTreeonNodeClicked方法还没有存在。

后者相当简单:

src/contacts/elements/address-tree.js

//Omitted snippet... 
export class ContactAddressTreeCustomElement { 
  //Omitted snippet... 

 onNodeClicked(node) { 
    if (node.data.contact && this.click) { 
      this.click({ contact: node.data.contact }); 
    } 
  } 
} 

这个方法只是确保被点击的节点是联系人节点,并且click属性已经被正确绑定,然后用被点击的contact对象调用它。这将执行用.call命令绑定到click属性的表达式,把它作为contact参数传递给属性。

前者要稍微复杂一点。它的任务是将联系人列表转换为树数据结构,这将作为d3 API 的数据源:

src/contacts/elements/address-tree.js

//Omitted snippet... 
export class ContactAddressTreeCustomElement { 
  //Omitted snippet... 

 createAddressTree(contacts) { 
    const rootNode = { name: '', children: [] }; 
    for (let contact of contacts) { 
      for (let address of contact.addresses) { 
        const path = this.getOrCreateAddressPath( 
          rootNode, address); 
        const pathTail = path[path.length - 1]; 
        pathTail.children.push({ 
          name: contact.fullName,  
          contact 
        }); 
      } 
    } 
    return rootNode; 
  } 

  getOrCreateAddressPath(rootNode, address) { 
    const countryNode = this.getOrCreateNode( 
      rootNode, address.country); 
    const stateNode = this.getOrCreateNode( 
      countryNode, address.state); 
    const cityNode = this.getOrCreateNode( 
      stateNode, address.city); 
    const streetNode = this.getOrCreateNode( 
      cityNode, address.street); 
    const numberNode = this.getOrCreateNode( 
      streetNode, address.number); 
    return [countryNode, stateNode, cityNode,  
      streetNode, numberNode]; 
  } 

  getOrCreateNode(parentNode, name) { 
    name = name || '?'; 

    const normalizedName = this.normalizeNodeName(name); 
    let node = parentNode.children 
      .find(n => n.normalizedName === normalizedName); 
    if (!node) { 
      node = { name, normalizedName, children: [] }; 
      parentNode.children.push(node); 
    } 
    return node; 
  } 

  normalizeNodeName(name) { 
    return name.toLowerCase().trim().replace(/\s+/, ' '); 
  } 
} 

在这里,createAddressTree方法首先创建一个带有空children列表的根节点。然后,它遍历每个联系人的addresses,为每个地址创建一个节点路径,从国家开始,一直深入到街道号码。整个路径或其中一部分如果已经存在,就不会再次创建节点,而是简单地检索。最后,一个代表联系人的叶节点被附加到路径中的最后一个节点,即街道号码节点。

在此阶段,如果你运行应用程序并前往地址树视图,你应该能看到联系人显示出来,以树状布局。

使用 Polymer 组件

Polymer是一个流行的库,严重倾向于 web 组件。它的社区提供了各种各样的组件,其中包括一个google-map元素,它封装了 Google Maps API,以便在 HTML 中声明性地显示地图。

Aurelia 提供了一个名为aurelia-polymer的集成库,它允许在 Aurelia 应用程序中使用 Polymer 组件。在下一节中,我们将将其集成到我们的联系人管理应用程序中。在详细信息组件中,我们将显示一个显示联系人地址的小地图。

安装库

Polymer 及其库通常使用Bower进行安装。Bower 和 NPM 可以毫无问题地并行使用,因此让我们首先安装它,如果你还没有在开发环境中安装它,那么通过打开一个控制台并运行以下命令:

> npm install -g bower

Bower 是另一个用于网络库的包管理器,可以在bower.io/找到。

完成这些之后,让我们创建 Bower 的项目文件:

bower.json

{ 
  "name": "learning-aurelia", 
  "private": true, 
  "dependencies": { 
    "polymer": "Polymer/polymer#¹.2.0", 
    "google-map": "GoogleWebComponents/google-map#¹.1.13", 
    "webcomponentsjs": "webcomponents/webcomponentsjs#⁰.7.20" 
  } 
} 

这个文件与package.json非常相似。它描述了由 Bower 管理的项目的依赖关系。在这里,我们包括了 Polymer 和 Google Maps 组件。

我们还包含了webcomponentjs,这是各种 web 组件 API 的 polyfill,例如自定义元素 API 和 HTML Imports API。由于这两个 API 是 Polymer 所必需的,如果目标浏览器不支持这些 API,则需要这个 polyfill。

注意

你可以在这里检查你最喜欢的浏览器是否支持所需的 API:caniuse.com/#feat=custom-elementsv1caniuse.com/#feat=imports

就像 NPM 一样,项目文件中列出的包必须被安装。因此,在项目目录中打开一个控制台并运行以下命令:

> bower install

完成这些之后,我们需要安装的最后一样东西是 Polymer 和 Aurelia 之间的桥梁,通过在项目目录中打开一个控制台并运行以下命令来完成:

> npm install aurelia-polymer --save

配置应用程序

现在一切都安装好了,我们需要配置我们的应用程序,使其可以加载 Polymer 组件。

首先,我们需要将aurelia-polymer库添加到供应商捆绑包中:

aurelia_project/aurelia.json

//Omitted snippet... 
{ 
  "name": "vendor-bundle.js", 
  "prepend": [ 
    //Omitted snippet... 
  ], 
  "dependencies": [ 
    { 
      "name": "aurelia-polymer", 
      "path": "../node_modules/aurelia-polymer/dist/amd", 
      "main": "index" 
    }, 
    //Omitted snippet... 
  ] 
} 
//Omitted snippet... 

当然,由于这个库是一个 Aurelia 插件,我们需要将其加载到我们应用程序的主要configure函数中:

src/main.js

//Omitted snippet... 
export function configure(aurelia) { 
  aurelia.use 
    .standardConfiguration() 
    .plugin('aurelia-polymer')  
    .plugin('aurelia-animator-css') 
  //Omitted snippet... 
} 

如前所述,Polymer 依赖于 HTML Imports。在撰写本文时,基于 CLI 的 Aurelia 应用程序不支持使用 HTML Imports 加载视图。因此,我们将无法在需要它们的模板中加载组件。我们别无选择,只能将它们加载到index.html文件中:

index.html

<!-- Omitted snippet... --> 
<head> 
  <!-- Omitted snippet... --> 
  <script src="bower_components/webcomponentsjs/ 
               webcomponents-lite.js"></script> 
  <link rel="import" href="bower_components/polymer/polymer.html"> 
  <link rel="import"  
        href="bower_components/google-map/google-map.html"> 
</head> 
<!-- Omitted snippet... --> 

在这里,我们首先加载 Web Components API polyfill。如果不需要 polyfill,可以删除这一行。接下来,我们导入 Polymer 和google-map组件。

在一个准备生产的应用程序中,分别导入 Polymer 和每个组件是不理想的。强烈建议将组件进行融合,生成一个单一的包,在index.html文件中加载: github.com/Polymer/vulcanize

至此,与 Polymer 的集成已经运行起来。google-map元素已经可以使用。

显示 Google 地图

让我们先确保一切都能正常工作,通过创建一个自定义元素来显示一个带有单个地址标记的地图:

src/contacts/elements/address-map.html

<template> 
  <button class="btn btn-default"  
          click.delegate="isMapVisible = !isMapVisible"> 
    ${isMapVisible ? 'contacts.hideMap' : 'contacts.showMap' & t} 
  </button> 
  <google-map if.bind="isMapVisible"  
              style="display: block; height: 400px;"  
              api-key="your_key"> 
  </google-map> 
</template> 

注意

google-map Polymer 组件在幕后加载了 Google Maps API。为了使其正确加载,你需要一个 Google Maps API 密钥。你可以通过遵循在 developers.google.com/maps/documentation/javascript/get-api-key#key 找到的说明来创建一个。

在这里,我们首先添加一个切换isMapVisible属性的按钮。接下来,我们添加一个google-map Polymer 元素。其api-key属性应该设置为你的 Google Maps API 密钥。

至于视图模型,现在几乎为空:

src/contacts/elements/address-map.js

export class AddressMapCustomElement {  
  isMapVisible = false; 
} 

最后,我们需要将这个address-map元素添加到联系人的details组件中:

src/contacts/components/details.html

<!-- Omitted snippet... --> 
<div class="form-group" repeat.for="address of contact.addresses"> 
  <label class="col-sm-2 control-label"> 
    ${'contacts.types.' + address.type & t} 
  </label> 
  <div class="col-sm-10"> 
    <p class="form-control-static"> 
      ${address.number} ${address.street}</p> 
    <p class="form-control-static"> 
      ${address.postalCode} ${address.city}</p> 
    <p class="form-control-static"> 
      ${address.state} ${address.country}</p> 
    <address-map address.bind="address"></address-map> 
  </div> 
</div> 
<!-- Omitted snippet... --> 

在这个阶段,如果你运行应用程序并导航到一个联系人的详情,你应该看到每个地址下都有一个按钮。如果你点击它,应该会弹出一个地图。

地址编码

为了在地图上显示地址作为标记,我们需要获取该地址的地理坐标。因此,我们将创建一个名为Geocoder的新服务,它将使用基于OpenStreetMap数据的搜索服务Nominatimwww.openstreetmap.org/),以找到给定地址的纬度和经度:

src/contacts/services/geocoder.js

import {HttpClient} from 'aurelia-fetch-client'; 

export class Geocoder { 

  http = new HttpClient().configure(config => { 
    config 
      .useStandardConfiguration() 
      .withBaseUrl('http://nominatim.openstreetmap.org/'); 
  }); 

  search(address) { 
    const query = { 
      format: 'json', 
      street: `${address.number} ${address.street}`, 
      city: address.city, 
      state: address.state, 
      country: address.country, 
      postalcode: address.postalCode, 
      limit: 1, 
    }; 
    return this.http.fetch(`search?${toQueryString(query)}`) 
      .then(response => response.json()) 
      .then(dto => dto.length === 0 ? null : dtoToResult(dto[0])); 
  } 
} 

function toQueryString(query) { 
  return Object.getOwnPropertyNames(query) 
    .map(name => { 
      const key = encodeURIComponent(name); 
      const value = encodeURIComponent(query[name]); 
      return `${key}=${value}`; 
    }) 
    .join('&'); 
} 

function dtoToResult(dto) { 
  return { 
    latitude: parseFloat(dto.lat), 
    longitude: parseFloat(dto.lon) 
  }; 
} 

这个类首先创建一个HttpClient实例,使用 Nominatim 的 URL 和标准配置。然后暴露一个search方法,该方法期望一个Address对象作为参数,向 Nominatim 端点发送请求并返回结果Promise。这个Promise如果找不到地址就解决为null,或者包含匹配位置的latitudelongitude的对象。

显示标记

既然我们现在可以进行地址编码,那就让我们更新一下address-map元素,显示一个标记:

src/contacts/elements/address-map.js

import {inject, bindable} from 'aurelia-framework'; 
import {Geocoder} from '../services/geocoder'; 

@inject(Geocoder) 
export class AddressMapCustomElement { 

  @bindable address; 

  isAttached = false; 
  isMapVisible = false; 
  isGeocoded = false; 
  latitude = null; 
  longitude = null; 

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

  addressChanged() { 
    if (this.isAttached) { 
      this.geocode(); 
    } 
  } 

  attached() { 
    this.isAttached = true; 
    this.geocode(); 
  } 

  detached() { 
    this.isAttached = false; 
  } 

  geocode() { 
    if (this.address) { 
      this.geocoder.search(this.address).then(position => { 
        if (position) { 
          this.latitude = position.latitude; 
          this.longitude = position.longitude; 
          this.isGeocoded = true; 
        } else { 
          this.isMapVisible = false; 
          this.isGeocoded = false;  
          this.latitude = null; 
          this.longitude = null; 
        } 
      }); 
    } 
  } 
} 

在这里,我们首先将一个Geocoder实例注入到视图模型中。我们还添加了一个可绑定的address属性。当元素附加到 DOM 时,我们进行地理编码,如果找到其坐标,我们设置latitudelongitude属性的值。我们还设置isGeocodedtrue。这个标志最初设置为false,如果地址无法定位,我们将用来禁用切换按钮。如果找不到地址,我们隐藏地图,禁用切换按钮,并将latitudelongitude重置为null

在元素附加到 DOM 之后,每次address发生变化时,我们还进行地理编码,以保持latitudelongitude属性的最新。

至于模板,我们不需要做太多更改:

src/contacts/elements/address-map.html

<template> 
  <button class="btn btn-default"  
          click.delegate="isMapVisible = !isMapVisible"  
          disabled.bind="!isGeocoded"> 
    ${isMapVisible ? 'contacts.hideMap' : 'contacts.showMap' & t} 
  </button> 
  <google-map if.bind="isMapVisible"  
              latitude.bind="latitude"  
              longitude.bind="longitude"  
              zoom="15"  
              style="display: block; height: 400px;" 
             api-key="your_key"> 
    <google-map-marker latitude.bind="latitude"  
                       longitude.bind="longitude"  
                       open="true"> 
      ${address.number} ${address.street}  
      ${address.postalCode} ${address.city}  
      ${address.state} ${address.country} 
    </google-map-marker> 
  </google-map> 
</template> 

在这里,我们首先在isGeocodedfalse时禁用切换按钮。接下来,我们将google-map元素的latitudelongitude进行绑定,并将它的zoom设置为15,以便它显示在地址位置的中心。

最后,我们在google-map元素内部添加一个google-map-marker元素。我们还绑定这个标记的latitudelongitude,并将其open属性设置为true,以便在渲染时打开其信息窗口。在标记内部,我们显示完整的地址作为文本,它将在信息窗口内渲染。

你可能会好奇这个google-map-marker元素是从哪里来的。实际上,HTML Imports 机制允许从单个文件中加载多个组件。当我们 在index.html中导入bower_components/google-map/google-map.html文件时,许多组件被注册到 Polymer 中,其中就包括地图和标记。

如果你在这个时候运行应用程序,导航到联系人的详细信息,然后点击地址的查看地图按钮,应该会出现一个带有标记在正确位置的地图,并且一个信息窗口会显示完整的地址。

总结

将一个 UI 库集成到 Aurelia 应用程序中几乎总是遵循相同的流程:你围绕它创建一个自定义元素或属性。利用 Aurelia 的双向数据绑定,大多数时候并不太复杂。

这对于遵循良好实践和社区标准库来说尤其如此,比如支持常见模块加载器、暴露数据变更事件,并在其公共 API 中有一个析构器。那些较老,或者不遵循这些标准的库,集成就更痛苦。Aurelia 在这方面尽其所能简化。

附录 A:使用 JSPM

JSPM (jspm.io/) 是基于未来网络标准的、github.com/systemjs/systemjs 通用模块加载器的包管理器,这可能是目前最前瞻性的模块加载器。

在撰写本文的时刻,创建基于 JSPM 的 Aurelia 项目的最简单方法是使用合适的骨架。然而,Aurelia 团队计划在未来的 CLI 中添加创建基于 JSPM 的项目功能。

在这个附录中,我们将看到在撰写本文时使用requirejs的 CLI 基础项目和基于 JSPM 的项目骨架之间的区别。

注意

附录的目的是不详细介绍 JSPM 和 SystemJS。因此,我强烈建议如果你打算在你的项目中使用它们,你要更多地熟悉它们。

提示

使用基于 JSPM 的骨架重建的我们的联系人管理应用程序,可以在书籍资源中的appendix-a\using-jspm找到,并可在整个附录中作为参考。

入门

创建基于 JSPM 的应用程序的第一步是从github.com/aurelia/skeleton-navigation/releases/latest下载最新的骨架版本,并解压文件。在根目录中,你会发现每个可用的骨架都有一个独特的目录。我们将要查看的是名为skeleton-esnext的一个。

JSPM 骨架使用 Gulp 作为其构建系统。因此,如果你还没有安装它,首先打开一个控制台并运行以下命令来全局安装它:

> npm install -g gulp

另外,我们还需要安装 JSPM 本身:

> npm install -g jspm

一旦我们需要的工具安装完毕,让我们通过在项目目录中打开一个控制台并运行以下命令来恢复项目的构建系统的依赖项:

> npm install

此命令将恢复运行和构建我们应用程序所需的所有依赖项,基本上就是package.json文件中的devDependencies部分的所有内容。

接下来,我们需要通过运行以下命令来恢复我们应用程序本身使用的库:

> jspm install -y

此命令将使用 JSPM 恢复package.json文件中jspm部分的所有的dependencies

到此为止,一切准备就绪。

运行任务

JSPM 骨架附带一套相当完整的 Gulp 任务。这些任务可以在build/tasks目录中找到。

你最可能想做的第一件事是运行来自骨架的示例应用程序。这可以通过在项目目录中打开一个控制台并运行以下命令来完成:

> gulp watch

此命令将启动一个带监视器的开发 Web 服务器,每当源文件更改时都会刷新浏览器。

如果你想在不用监听文件和自动刷新浏览器的情况下运行应用程序,可以通过运行serve任务来实现:

> gulp serve

运行单元测试

默认情况下,基于 JSPM 的骨架的单元测试可以在test/unit目录中找到。它通常还包含三个与单元测试相关的不同 Gulp 任务:

  • test:运行单元测试一次

  • tdd:运行单元测试一次,然后监视文件并当代码变化时重新运行测试

  • cover:使用 Istanbul(github.com/gotwarlost/istanbul)启用代码覆盖率运行单元测试一次。

例如,如果你想进行测试驱动开发并且让测试在编码过程中持续运行,你可以运行以下命令:

> gulp tdd

由于骨架依赖于 Karma 来运行测试,所以在运行上述任何任务之前,你需要在你的环境中安装 Karma CLI:

> npm install -g karma-cli

运行端到端测试

基于 JSPM 的骨架还包含一个e2e任务,它将启动在test/e2e/src目录中找到的端到端测试。

然而,由于端到端测试依赖于 Protractor,你首先需要通过运行正确的任务来更新 Selenium 驱动程序:

> gulp webdriver-update

然后,由于端到端测试需要与应用程序本身交互,你需要启动应用程序:

> gulp serve

最后,你可以打开第二个控制台并启动端到端测试:

> gulp e2e

添加库

使用 JSPM 添加库只需运行正确的命令:

> jspm install aurelia-validation

此命令将为项目安装aurelia-validation库。由于 JSPM 被设计为与 SystemJS 一起工作,它还将添加适当的条目到 SystemJS 映射配置中,该配置在config.js文件中,并由 SystemJS 用来将模块名称映射到 URL 或本地路径。

一旦这个命令完成,SystemJS 模块加载器将能够定位到aurelia-validation及其任何依赖项,所以你可以立即在你的应用程序中使用它。

在基于 JSPM 的应用程序中使用库类似于基于 CLI 的项目。如果你需要使用库的一些 JS 导出,只需在 JS 文件中导入它们:

import {ValidationController} from 'aurelia-validation'; 

如果你想要导入其他资源,比如 CSS 文件,只需在适当的模板中require它:

<require from="bootstrap/css/bootstrap.css"></require> 

打包

与 CLI 或基于 Webpack 的骨架相反,基于 JSPM 的骨架在开发环境中运行时不会自动打包应用程序。但它包含了一个专门的 Gulp 任务用于打包:

> gulp bundle

此任务将根据打包配置创建一些捆绑包。它还将更新config.js文件中的 SystemJS 映射,所以加载器知道从每个正确的捆绑包中加载每个模块。

这意味着,如果你手动从开发环境部署应用程序,而不是使用自动构建系统,那么在部署后你需要解包你的应用程序:

> gulp unbundle

此命令将重置config.js文件中的 SystemJS 映射到其原始的未捆绑状态。然而,当运行watch任务时,它会自动调用,所以你不需要手动经常运行它。

配置捆绑包

bundling 配置可以在build/bundles.js文件中找到。它看起来像这样:

build/bundles.js

module.exports = { 
  "bundles": { 
    "dist/app-bundle": { 
      "includes": [ 
        "[**/*.js]", 
        "**/*.html!text", 
        "**/*.css!text" 
      ], 
      "options": { 
        "inject": true, 
        "minify": true, 
        "depCache": true, 
        "rev": true 
      } 
    }, 
    "dist/aurelia": { 
      "includes": [ 
        "aurelia-framework", 
        "aurelia-bootstrapper", 
        // Omitted snippet... 
      ], 
      "options": { 
        "inject": true, 
        "minify": true, 
        "depCache": false, 
        "rev": true 
      } 
    } 
  } 
}; 

默认情况下,此配置描述了两个包:

  • app-build:包含从src目录中所有的 JS 模块、模板和 CSS 文件。

  • aurelia:包含 Aurelia 库、Bootstrap、fetch polyfill 和 jQuery。

app-build包的 JS glob 模式[**/*.js]周围的括号,告诉打包器忽略依赖关系。如果没有这些括号,打包器将递归地遍历每个 JS 文件的每个import语句,并将所有依赖关系包含在包中。由于默认的打包配置将应用程序的资源放在第一个包中,所有外部依赖放在第二个包中,所以我们不想在app-build包中包含任何依赖关系,因此使用了括号。

当向您的应用程序添加外部库时,您需要将其添加到包的includes中,通常它会在aurelia包中,我通常将其重命名为vendor-bundle。如果您不这样做,SystemJS 的映射将引用未打包的库,并尝试从jspm_packages目录中加载它,这在生产环境中不是我们想要的结果。

除了其内容外,包的配置还有options。这些选项中最有用的大概是rev,当设置为true时,启用包版本控制。因此,每个包的名称将附上一个基于内容的哈希,SystemJS 映射也将用这些版本化的包名称更新。

总结

在 Aurelia 的大部分开发过程中,JSPM 一直是事实上的包管理器,SystemJS 是首选的模块加载器;也就是说,直到 CLI 发布为止。然而,JSPM 和 SystemJS 在 Aurelia 生态系统中仍然非常重要,大多数在 CLI 发布之前启动的项目都运行在这项技术上。

附录 B. 使用 Webpack

Webpack (webpack.github.io/) 又是另一个流行的 Web 模块打包器。Aurelia 已经提供了使用 Webpack 的 ES next 和 Typescript 应用程序骨架。

此外,还有计划将 CLI 对 Webpack-based 项目的支持。然而,目前,骨架是基于 Webpack 创建 Aurelia 项目的最佳起点。

在本附录中,我们将看到在撰写本文档时使用requirejs的基于 CLI 的项目和从骨架开始的基于 Webpack 的项目之间的差异。

注意

本附录的目的并不是要覆盖 Webpack 本身。因此,我强烈建议如果你还不熟悉 Webpack,请在继续阅读之前先熟悉一下 Webpack。

提示

我们的联系人管理应用程序,使用 Webpack 骨架重建,可以在书籍的资源中的appendix-b\using-webpack找到,并可作为本附录的参考。

入门

为了创建一个基于 Webpack 的应用程序,第一步是下载github.com/aurelia/skeleton-navigation/releases/latest的骨架并解压文件。根目录包含每个可用骨架的独立目录。我们在这里要保留的是名为skeleton-esnext-webpack

Webpack 骨架使用 NPM 作为其包管理器。因此,我们需要通过在项目目录中打开控制台并运行以下命令来安装项目的依赖项:

> npm install

完成此操作后,示例应用程序即可运行。

运行任务

Webpack 骨架不使用 Gulp 作为其构建系统,而是简单地依赖于 NPM 任务。如果你查看package.json文件中的scripts部分,你会看到项目的任务列表及其相应的命令。以下是最常见的:

  • start:启动开发 Web 服务器。当第一次访问index.html时,应用程序被捆绑并提供,然后该过程监视源文件,以便在检测到更改时重新创建捆绑并刷新浏览器。start命令是server的别名,而server又是server:dev的别名。

  • test:运行单元测试。使用 Istanbul(github.com/gotwarlost/istanbul)启用代码覆盖。

  • e2e:运行端到端测试。此任务将启动应用程序,该应用程序将在端口 19876 上运行,以及 E2E 测试套件。

  • build:prod:为生产环境打包应用程序。捆绑包和index.html文件将被优化以适应生产环境,并将在dist文件夹中生成。此外,生产构建将在每个捆绑包的名称中添加基于内容的全局哈希,以便对它们进行版本控制。这与在 CLI-based 项目中在aurelia_project/aurelia.json中设置rev选项以启用捆绑修订的效果相同。

  • server:prod:启动一个 Web 服务器以提供生产捆绑包。它必须在build:prod之后运行。

添加库

外部库是通过 NPM 添加的,与基于 CLI 的项目一样。然而,为了使文件被包含在捆绑包中,外部库必须在 JS 文件中引用,因为 Webpack 通过分析应用程序中每个 JS 模块的import声明来确定必须包含在捆绑包中的内容。

你可以通过查看骨架的main模块来查看这个示例:

src/main.js

// we want font-awesome to load as soon as possible to show the fa-spinner 
import '../styles/styles.css'; 
import 'font-awesome/css/font-awesome.css'; 
import 'bootstrap/dist/css/bootstrap.css'; 
import 'bootstrap'; 
//Omitted snippet... 

在骨架的示例应用程序中,所有全局资源,如应用程序的样式表、Font Awesome、Bootstrap 的样式表以及 Bootstrap 的 JS 文件都在main.js文件中被导入。这些导入将告诉 Webpack 将这些资源包含在应用程序捆绑包中。此外,Webpack 足够智能,可以分析 CSS 文件以找出它们的依赖关系。这意味着它知道如何处理导入的 CSS 文件、图片和字体文件。

捆绑

捆绑包本身是在webpack.config.js文件中配置的。默认情况下,骨架定义了三个入口捆绑包:

  • aurelia-bootstrap:包含 Aurelia 的启动器、默认的 polyfill、Aurelia 的浏览器平台抽象以及 Bluebird Promise 库。

  • aurelia:包含所有 Aurelia 的默认库

  • app:包含所有应用程序模块

除了直接列为其内容的模块外,一个捆绑包还将包含所有未包含在其他捆绑包中的其内容的依赖项。例如,在骨架的示例中,Bootstrap 的 JS 文件被包含在app捆绑包中,因为它们没有被包含在任何其他捆绑包中,并且包含在app捆绑包中的模块会导入它们。

如果你想要,例如,让aurelia捆绑包包含所有外部库,你应该将其添加到它的模块列表中:

webpack.config.js

//Omitted snippet... 
const coreBundles = { 
  bootstrap: [ 
    //Omitted snippet... 
  ], 
  aurelia: [ 
    //Omitted snippet... 
    'aurelia-templating-binding', 
    'aurelia-templating-router', 
    'aurelia-templating-resources', 
    'bootstrap' 
  ] 
} 
//Omitted snippet... 

如果你在此时运行示例应用程序,Bootstrap 的 JS 文件应该现在会被包含在aurelia捆绑包中,而不是app捆绑包。

懒加载捆绑包

骨架的示例应用程序中定义的所有捆绑包都是入口捆绑包,这意味着这些捆绑包直接由index.html文件加载。所有这些代码都在应用程序启动之前一次性加载。

正如在第十章,生产环境下的打包中讨论的,根据应用程序的使用模式和结构,从性能角度来看,将应用程序的不同部分分别打包可能更好,并且只有在需要时才加载其中一些捆绑包。

懒加载包的配置是在package.json文件中完成的:

{ 
  //Omitted snippet... 
  "aurelia": { 
 "build": { 
 "resources": [ 
 { 
 "bundle": "contacts", 
 "path": [ 
 "contacts/components/creation", 
 "contacts/components/details", 
 "contacts/components/edition", 
 "contacts/components/form", 
 "contacts/components/list", 
 "contacts/components/photo", 
 "contacts/models/address", 
 "contacts/models/contact", 
 "contacts/models/email-address", 
 "contacts/models/phone-number", 
 "contacts/models/social-profile", 
 "contacts/main" 
 ], 
 "lazy": true 
 } 
 ] 
 } 
 } 
  //Omitted snippet... 
} 

在这个例子中,我们将我们联系管理应用程序的contacts特性的所有组件和模型打包在一个独特的、懒加载的捆绑包中。有了这个配置,contacts捆绑包只有在用户导航到某个联系人路由组件时才会从服务器加载。

至于依赖项包含,懒加载的捆绑包将表现得就像一个入口捆绑包。除了在其配置中列出的模块外,懒加载的捆绑包还将包含所有没有包含在任何入口捆绑包中的依赖项。这意味着如果你只在一个模块中从外部库import东西(并且在这个应用程序中的其他地方没有用到),并且你没有将这个外部库包含在某个入口捆绑包中,这个库将被包含在你的懒加载捆绑包中。这是优化应用程序打包时需要考虑的一个重要因素。

基于环境的配置

Webpack 骨架使用一个名为NODE_ENV的环境变量来根据上下文定制打包过程。这个环境变量通过package.json中描述的任务设置为developmenttestproduction

如果你查看webpack.config.js文件,你会看到一个switch语句,它根据环境生成一个 Webpack 配置对象。这就是你可以根据环境定制打包的地方。

例如,如果你使用aurelia-i18n插件,你可能希望在构建应用程序时将locales目录复制到dist目录。最简单的方法是在生产和开发配置中都添加以下行:

webpack.config.js

//Omitted snippet... 
config = generateConfig( 
  baseConfig, 
  //Omitted snippet... 
  require('@easy-webpack/config-copy-files') 
    ({patterns: [{ from: 'favicon.ico', to: 'favicon.ico' }]}),
  require('@easy-webpack/config-copy-files') 
 ({patterns: [{ from: 'locales', to: 'locales' }]}), 
  //Omitted snippet... 
); 
//Omitted snippet... 

另外,如果你想使用aurelia-testing插件,无论是用于单元测试中的组件测试器,还是用于调试目的的view-spycompile-spy属性,你应该使用 NPM 安装它,并将其添加到aurelia捆绑包中,适用于testdevelopment环境:

webpack.config.js

//Omitted snippet... 
coreBundles.aurelia.push('aurelia-testing'); 
config = generateConfig( 
  baseConfig, 
  //Omitted snippet... 
); 
//Omitted snippet... 

配置 Webpack 可能一开始会感到复杂和令人畏惧。Webpack 骨架使用easy-webpackgithub.com/easy-webpack/core)来简化这个配置过程。使用easy-webpack的另一个巨大优势是,它强制执行社区标准,并且使得复用复杂的配置片段变得相当容易。

因此,你可以使用位于github.com/easy-webpack或其他地方提供的众多配置模块之一,甚至是你自己的模块,来进一步定制 Webpack 的配置。

总结

尽管 Webpack 并非 Aurelia 的首选打包工具,但它已经得到了很好的支持。此外,无论使用 Webpack 还是 CLI 进行打包,Aurelia 应用程序本身的变化并不大,主要是围绕它的基础代码发生了变化。这使得从一个打包工具迁移到另一个打包工具变得更为简单。

posted @ 2024-05-23 14:39  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报