三、显示数据

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

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

在呈现任何数据之前,必须首先获取该数据。大多数情况下,单页 web 应用依赖于某种 web 服务。因此,我们将了解 FetchAPI 是什么,如何使用 Aurelia 的 Fetch 客户端,以及如何配置它。

最后,在结束本章之前,我们将通过添加视图来显示联系人列表和联系人详细信息,从而在联系人管理应用上实践我们的新知识。

模板基础

模板是一个 HTML 文件,其根元素是template元素。它必须是有效的 HTML,因为模板引擎依赖浏览器解析文件并从中构建 DOM 树,引擎将遍历、分析并通过行为丰富 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元素。

ShadowDOM 是一个 API,允许我们在 DOM 中创建独立的子树。这样的子树可以单独加载自己的样式表和 JavaScript,而不会与周围的文档发生冲突。这项技术是无痛 web 组件开发的核心,但在撰写本文时,浏览器仍不广泛支持这项技术。

style元素上的scoped属性告诉浏览器将样式表的范围限制为包含元素及其子元素。这可以防止样式干扰文档的其余部分,而不必使用 ShadowDOM 根。它是 ShadowDOM 的有用替代品,但浏览器仍然不广泛支持它。

数据绑定

数据绑定是使用表达式将模板元素链接到数据模型(JS 对象)的操作。此数据模型称为绑定上下文。Aurelia 使用此上下文向其模板公开组件视图模型的属性和方法。此外,以下部分中描述的一些行为将信息添加到它们的绑定上下文中。

绑定方式

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

  • 单向:初始计算表达式,并在视图中应用和呈现指令。观察表达式时,只要其值发生变化,就可以对其重新求值,并且指令可以更新视图。它仅以一种方式更改流,即从模型到视图。
  • Two-way: Similar to one-way, but the updates flow both ways: if the template element, such as an input, changes from user interaction, the model is updated. It changes flow both ways, from the model to the view, and from the view to the model.

    当然,双向绑定限制了可以绑定到的表达式类型。只有可分配表达式(通常,可以在 JavaScript 分配指令中的 equal(=)运算符左侧使用的表达式)可以用于双向绑定。例如,不能双向绑定到条件三元表达式或方法调用。

  • 一次性:初始计算表达式并应用指令,但未观察到表达式,因此初始渲染后对模型的任何更改都不会反映在视图上。渲染视图时,绑定仅从模型流向视图一次。

字符串插值

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

<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> 

在本例中,仅当模型的isFirstTime属性为 truthy 时,我们使用三元表达式将emphasisCSS 类有条件地分配给h1元素。

默认情况下,字符串插值指令是单向绑定的。这意味着,只要表达式的值发生更改,就会在文档中重新计算和更新表达式。

数据绑定命令

分析模板中的元素时,模板引擎使用数据绑定命令查找属性。数据绑定命令的后缀为属性,并用点分隔。它指示引擎对此属性执行某种类型的数据绑定。其形式如下:attribute.command="expression"

让我们浏览一下 Aurelia 提供的各种绑定命令。

绑定

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

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

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

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

单向

bind类似,该命令执行数据绑定,但不适应其上下文;无论目标的类型如何,绑定都是单向的。

双向

bind类似,该命令执行数据绑定,但不适应其上下文,无论目标类型如何,绑定都是双向的。当然,将此命令应用于无法自行更新的属性是无用的。

一次

bind类似,此命令执行数据绑定,但强制一次性绑定,这意味着初始渲染后发生的对模型的任何更改都不会反映在视图上。

您可能已经推断出,单向和双向绑定提供的一次性绑定比实时绑定轻得多。事实上,由于实时绑定需要观察,因此会消耗更多的 CPU 和内存。在具有数百条数据绑定指令的大型应用中,尽可能使用一次性绑定可以在性能级别上产生巨大的差异。这就是为什么尽可能坚持一次性绑定并仅在必要时使用实时绑定被认为是一种好的做法。

触发器

trigger命令将事件绑定到表达式,该表达式将在每次触发事件时进行计算。Event对象作为$event变量可用于表达式:

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

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

请注意,事件名称的拼写没有任何on前缀:属性名为click,而不是onclick

代表

trigger命令直接在目标元素上附加事件处理程序时,delegate通过将单个处理程序附加到文档或最近的 ShadowDOM 根来利用事件委派。此处理程序将事件分派到其合法目标,以便对绑定表达式进行计算。

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

与直接连接到目标元素的事件处理程序相比,事件委派消耗的内存要少得多。就像一次性绑定与实时绑定一样,在较小的应用中,使用委托通常是不明显的,但随着应用的大小的增长,它会对内存占用造成影响。另一方面,在某些场景中,需要将事件处理程序直接附加到元素,特别是在使用禁用的冒泡触发自定义事件时。

电话

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命令可用于将 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"速记)将创建对 HTML 元素的引用,作为绑定上下文中名为someProperty的属性
  • 当放置在具有some-attribute自定义属性的元素上时,some-attribute.ref="someProperty"将创建对此自定义属性的视图模型的引用,作为绑定上下文中名为someProperty的属性
  • 当放置在自定义元素上时,view-model.ref="someProperty"将创建对自定义元素视图模型的引用,作为绑定上下文中名为someProperty的属性
  • 当放置在自定义元素上时,view.ref="someProperty"将创建对自定义元素的view实例的引用,作为绑定上下文中名为someProperty的属性
  • 当放置在自定义元素上时,controller.ref="someProperty"将创建对自定义元素的Controller实例的引用,作为绑定上下文中名为someProperty的属性

绑定文字

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

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

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

当然,当数据绑定到文本值时,最好使用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 代码,该代码作为绑定到的innerHTML属性的数据,将显示在div中。

但是,此 HTML 不被视为模板,因此模板引擎不会对其进行解释。例如,如果它包含绑定表达式或需要指令,则不会对其求值。

显示用户生成的 HTML 是众所周知的安全风险,因为它可能包含恶意脚本。强烈建议在向任何用户显示此类 HTML 之前对其进行清理。

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

文本内容

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属性。它可以绑定到字符串或对象:

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属性。默认情况下,该属性是双向绑定的,可用于更改元素的水平滚动位置,或将其位置指定给上下文中的属性,以便使用。

向左滚动

scrollleft属性可用于绑定元素的scrollLeft属性。默认情况下,此属性是双向绑定的,可用于更改元素的垂直滚动位置,或将其位置指定给上下文中的属性,以便使用。

使用内置行为

核心库aurelia-templating-resources提供了一套标准行为,建立在aurelia-templating之上,可以在任何 Aurelia 模板中使用。

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

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

在本例中,p元素只有在模型的hasError属性为 truthy 时才可见。

该属性的工作原理是在文档头或最近的 ShadowDOM 根中注入一个 CSS 类,并在应该隐藏的元素上添加这个 CSS 类。这个 CSS 类只是将display属性设置为none

隐藏

这与show类似,但条件相反:

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

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

除了反向条件之外,该属性的工作方式与show完全相同,并且使用相同的 CSS 类。

如果

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 中删除

实际上,当条件为 false 时,它所在的元素不会从 DOM 中删除,它自己的行为及其子元素的行为将被解除绑定。这是一个非常重要的区别,因为它对性能有重大影响。

对于下面的示例,让我们假设some-component非常庞大,显示大量数据,具有许多绑定,并且非常消耗内存和 CPU。

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

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

另一方面,这意味着,当条件变得真实时,必须重新绑定元素及其子元素。在条件经常开关的情况下,最好使用showhide。在ifshow/hide之间进行选择主要是为了平衡性能和用户体验之间的优先级,应该有真正的性能测试作为支持。

模板控制器是将其所在元素转换为模板的属性。然后,它可以控制该模板的呈现方式。标准属性ifrepeat是模板控制器。

重复

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

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

在本例中,li元素将被重复,并将数据绑定到items数组中的每个项。

Set对象也可以是数据绑定对象,而不是数组。

作为模板控制器,repeat实际上将其所在的元素转换为模板。然后为有界序列中的每个项呈现此模板。对于每个项,都会创建一个子绑定上下文,使用绑定表达式中of关键字左侧的名称,可以在该子绑定上下文上使用项本身。这意味着两件事:可以根据需要命名 item 变量,也可以在 item 本身的上下文中使用它:

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

在本例中,li元素将插入people数组中每个项目的ul元素中。对于每个li元素,将创建一个子上下文,将当前项作为person属性公开,如果personisImportant属性对应,则在li上设置一个importantCSS 类。每个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。这就是selectedPersonli元素如何拥有activeCSS 类。

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元素将重复等于pageCount的次数,i0pageCount - 1包括在内。

重复模板制作

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

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

这里,呈现的 DOM 将是一个包含交替的ip元素的div元素。

上下文变量

除了当前项本身之外,repeat还向子绑定上下文添加了其他变量:

  • $index:数组中项目的索引
  • $firsttrue如果该项是数组中的第一项;false否则
  • $lasttrue如果该项是数组中的最后一项;false否则
  • $eventrue如果项目索引为偶数;false否则
  • $oddtrue如果项目的索引为奇数;false否则

带属性的

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> 

在前面的示例中,如果hasFocustrue,则input将聚焦于渲染。当hasFocus更改为false值时,input将丢失focus。另外,如果用户将focus给予input,则hasFocus将被设置为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目录中的组件。当然,这意味着 compose 将在currentWidgetType更改时交换组件(除非使用一次性绑定)。

此外,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> 

在本例中,我们在innerhtml属性的绑定中使用内置的sanitizeHTML值转换器。此值转换器将在绑定过程中通过管道传输,并将从绑定值中清除任何潜在的危险元素。

值转换器实际上不会更改它们所操作的绑定上下文值。它们只是充当拦截器,并为用于呈现的绑定提供替换值。

传递参数

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

让我们想象一个名为truncate的值转换器,它作用于一个字符串值,另外还需要一个length参数。在求值期间,它将提供的值截断为提供的长度(如果更长),并返回结果。下面是如何使用此转换器:

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

此处,title如果更长,将被截断为 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行为使绑定只能单向进行。它可用于字符串插值表达式:

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

在这里,不会观察到视图模型的quote属性,因此,如果文本发生更改,则不会更新文本。

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

油门

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

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

这里,我们看到了这两个场景的一个示例。第一个throttle应用于字符串插值表达式,默认情况下是单向的,当视图模型的title属性更改时,将限制视图中文本的更新。第二个应用于inputvalue属性绑定,默认为双向,当value更改为input时,会限制视图模型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事件的处理程序将Event对象分配给视图模型的position属性。但是,此处理程序将被限制,position将仅每 800 毫秒更新一次。

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

去盎司

debounce绑定行为也是一种速率限制行为。它确保在给定延迟未发生任何更改之前,不会发送任何更新。

一个常见的用例是自动触发对搜索 API 调用的搜索输入。在每次击键后调用这样一个 API 将效率低下,充其量也会消耗资源。最好在用户停止键入后等待给定的时间间隔,然后再调用搜索 API。这可以使用debounce完成:

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

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

这意味着,当应用于双向绑定时,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事件的处理程序将Event对象分配给视图模型的position属性。但是,此处理程序将被取消公告,因此只有当鼠标在div上停止移动 800 毫秒时,position才会被更新。

在前面的示例中,您可能注意到,throttledebounce可以将延迟(以毫秒表示)作为参数。省略时,延迟也默认为 200 毫秒。

更新记录器

updateTrigger绑定行为用于更改触发视图模型更新的事件。隐式地说,这意味着它只能用于双向绑定,并且只能用于支持双向绑定的元素的属性,例如inputvalueselectvalue或者divtextcontent属性和contenteditable="true"

使用时,它需要事件名称作为参数,并且至少需要一个:

<template> 
  <input value.bind="title & updateTrigger:'change':'input' "> 
</template> 

在这里,视图模型的title属性将在input每次触发changeinput事件时更新。

实际上,changeinput事件是 Aurelia 中的默认触发器。除了这两个事件外,blurkeyuppaste事件也可以用作触发器。

信号

信号绑定行为允许以编程方式触发绑定更新。当绑定值不可见或必须在特定时间间隔刷新时,这一点特别有用。

让我们想象一个名为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 秒。当然,为了防止内存泄漏,interval 句柄存储在一个实例变量中,并在组件停用时使用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的 getter 很快就可以进行评估,所以脏检查是绝对可以的。

然而,某些计算属性可能会完成繁重的工作:例如,从大型数组中搜索或聚合数据。在这种情况下,依赖脏检查意味着每秒将对属性进行多次评估,这可能会使浏览器负担过重。

计算自

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装饰器在非常特殊的情况下非常有用。它不应取代值转换器,因为它们是转换数据的首选方式。

从端点获取数据

获取 API

fetchapi 是为获取资源而设计的,包括通过网络获取资源。在撰写本文时,其规范虽然确实很有希望,但尚未获得批准。然而,许多现代浏览器,如 Chrome、Edge 和 Firefox 已经支持它。对于其他材料,需要使用 polyfill。

fetchapi 依赖于请求和响应的概念。这允许拦截管道在发送请求之前修改请求,在接收请求时修改响应。它使身份验证和 CORS 等工作变得更加容易。

在以下部分中,术语RequestResponse指的是 FetchAPI 的类。Mozilla 开发者网络有大量关于此 API 的文档:https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API

使用取数客户端

Aurelia 的 Fetch client 是本机或多填充 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凭证设置(有关此设置的更多信息,请参阅官方获取 API 文档)和拒绝错误响应(请参阅前面的rejectErrorResponses方法)。

除了回调配置函数外,configure方法还可以直接传递RequestInit对象。在这种情况下,此RequestInit对象将用作所有请求的默认属性。

这意味着,如果我们将一个RequestInit对象存储在defaultProperties变量中,那么下面两行将执行完全相同的操作:

client.configure(defaultProperties); 
client.configure(config => { config.withDefaults(defaultProperties); }); 

RequestInit对象对应于 Fetch API 的Request构造函数所期望的第二个参数。用于指定Request的各种属性。最常用的是:

  • method:HTTP 方法,例如 GET、POST
  • headers:包含请求的 HTTP 头的对象
  • body: The body of the request, for example a Blob, BufferSource, FormData, URLSearchParams, or USVString instance

    我将让您查看官方文档,了解更多有关可用Request房产的详细信息。

如您所见,RequestInit对象可用于指定 HTTP 方法和请求主体,因此我们将能够执行 POST 和 PUT 请求来创建和更新person对象。我们将在下一章开始构建表单时看到这方面的示例。

常见的陷阱

正如我们在第 2 章布局、菜单和熟悉中看到的,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')); 
  } 
} 

另一个解决方案是在应用的主configure方法中将HttpClient类注册为瞬态:

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 端点、显示联系人列表以及允许导航到给定联系人的详细信息。

为了让我们的应用更性感,我们将利用 FontAwesome,一个提供可伸缩矢量图标的 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 调用。然而,这将模糊责任之间的界限。视图模型将负责进行调用、解析请求、处理错误并最终缓存响应,所有这些都是除了数据显示之外的,数据显示是其主要任务。

相反,我们将创建一个 contact gateway 类,该类将负责从端点获取数据,可重用,并能够自行发展:

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 进行。

我们的联系人网关公开了两种方法:一种是获取所有联系人,另一种是通过 ID 获取单个联系人。它们通过调用客户端的fetch方法来工作,默认情况下,该方法会向提供的 URL 发送 get 请求。这里,由于 URL 是相对路径,因此它们将相对于构造函数中配置的基本 URL。

HTTP 请求完成后,解析fetch返回的Promise,并对解析的Response对象调用json方法,将响应体反序列化为 JSON。json方法还返回一个Promise,因此当第二个Promise解析时,我们将转换Contact类实例中的非类型化数据传输对象,我们将在后面编写。

这意味着,基于端点返回的内容,getAll返回一个Contact对象数组的Promise和一个Contact对象的getByIdPromise

先决条件

为了让所有这些都起作用,我们需要做两件事。首先,我们将通过在移动到应用目录后在控制台中运行以下命令来安装 Fetch 客户端:

npm install aurelia-fetch-client --save

为本书编写的所有代码都已在谷歌浏览器上运行。如果使用其他浏览器,则可能需要为各种 API(如 Fetch)安装 polyfills。

此外,您需要让 Aurelia bundler 了解此库。在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库与其他供应商库捆绑在一起以便我们的应用可以使用它所必需的。

最后,environment配置对象上默认不存在contactsUrl属性。我们需要补充一点:

aurelia_project/environments/dev.js

export default { 
  debug: true, 
  testing: true, 
  contactsUrl: 'http://127.0.0.1:8000/', 
}; 

这里,我们将端点默认运行的 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-list模板中更改contact of contacts | orderBy:'fullName'repeat.for指令对其进行测试。

创建 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绑定中将filterBy应用于contacts,传递filter作为搜索值,然后是将要搜索的firstNamelastNamecompany属性的名称。

这里要注意的一件有趣的事情是,我们甚至没有在视图模型上声明filter属性。它仅在视图中使用。由于它绑定到输入元素的 value 属性,因此默认情况下绑定是双向的,并且绑定只会将其值分配给视图模型。视图模型不需要知道此属性。

联系人详细视图

如果单击联系人,您应该会在浏览器控制台中看到错误。原因很简单:应该显示联系人详细信息的路由引用了一个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属性。此外,routeconfig对象用于将文档标题动态分配给联系人的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标记之前,并封装在一个div元素中,该元素具有form-horizontal类:

<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> 

由于加载的联系人在组件的生命周期内从未更改,因此通过一次性绑定,可以大大改进此模板。这意味着用one-time命令替换所有bind命令,并用oneTime绑定行为装饰所有字符串插值。我将把这个作为练习留给读者。

总结

如您所见,Aurelia 的数据绑定语言清晰简洁。这是非常不言自明的,使模板易于理解,即使对于不熟悉 Aurelia 的开发人员也是如此。此外,它是自适应的,使得编写性能良好的应用尽可能容易。

除了 Fetch 客户端的便利性之外,这些特性加上值转换器和绑定行为系统的灵活性和可重用性,使得编写数据显示组件变得轻而易举。

构建表单以创建和编辑数据并不复杂。我们将在下一章中看到这一点以及表单验证。