五、制作可复用的组件

Aurelia was built with reusability and composability in mind. As such, its templating engine supports not only the composition of components, but also custom HTTP elements and attributes, called HTML behaviors in Aurelia's terminology. In fact, the resources we use in templates, such as if, repeat, show, focus, compose, and router-view, are not special constructs baked into the framework, but are actual HTML behaviors written using the same API we will use to write our own custom HTML behaviors.

In this chapter, we will see how composition differs from custom elements, what the pros and cons are for each technique, and in which scenarios one is better suited than the other. We will next look at how to create custom attributes and custom elements, and what we can do with them. Lastly, we will see how we can customize Aurelia's view location convention.

Composition

Composition is the simplest way to assemble components in an Aurelia application. It is also the most limited way to do so. Its purpose is mainly to reuse existing components and templates in other contexts. Composition suits only simple reusing scenarios, where the situation does not differ too much from the intended usage. The flexibility of composition is very limited when compared to HTML behaviors.

In the following sections, we will see the various possibilities and limitations of composition by refactoring our contact management application. We will extract the contact creation behavior from our contact-edition component into a new contact-creation component. By doing this, we strive for a cleaner design, so our new components will have more focused responsibilities. However, since the contact form by itself is the same in both contexts, we will see various ways to extract this common template and behavior and to reuse them within those two components.

Splitting the contact edition component

Let's first start by removing all references to contact creation from the contact-edition component:

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 })); 
    }); 
  } 
} 

Here, we simply remove the isNew property, the if statements that used it in the activate and save methods, and the related code branches.

The same thing goes for the template:

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> 

Here, we simply remove the static title and the Cancel button displayed when creating a new component, so basically all template parts that were displayed when isNew was true.

Next, let's create our new contact-creation component:

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')); 
    }); 
  } 
} 

In the view-model of this new component, we simply initialize a contact property with a new Contact instance. Additionally, we define a save method which, if there are no validation errors, delegates to the create method of ContactGateway and, when the returned Promise resolves, navigates back to the contact list.

For the template, we'll start with the frame around the form fields themselves:

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> 

Except for the form fields, which we have omitted for now, this template is almost identical to the template of contact-edition. The main differences are highlighted, the title is a static New contact string, and the Cancel button navigates back to the contact list instead of the contact's details.

Reusing templates

One of the possibilities composition offers is reusing a template in multiple contexts. We will illustrate this by extracting the form fields from the contact-edition.html template into its own template, so we can use it in both contact-edition.html and contact-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> 

Here, we extracted the set of fields and list editors from contact-edition.html into its own template. We can now use composition to render this template where the fields were before:

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> 

Additionally, the contact-form.html template must be composed in the contact-creation.html template. I'll let you replace the comment in the template with the same compose instruction as in the previous snippet.

Note

Do not forget to update the contact-creation route in src/app.js, by changing its moduleId property to 'contact-creation'.

Once this is done, you can run the application and test that everything still works unchanged.

When using composition to render a template, this template will inherit the surrounding binding context. This means that, in order to compose the contact-form.html, the template rendering it must have a contact object stored as a contact property on its context. That's because the contact-form.html template expects the presence of a context property named contact.

The whole point about composability is that a component should be independent from its surrounding context. This example breaks this rule. We need a way to inject the contact object into the component.

If our component is just a template and has no view-model, we can inject the contact object in an untyped view-model:

<compose view="contact-form.html" view-model.bind="{ contact: contact }"></compose> 

Here, the templating engine will create an object and assign its contact property the contact object from the surrounding binding context, then the composition engine will data-bind this dynamic view-model with the contact-form.html template.

Reusing components

如果我们的组件有行为,这意味着它有一个视图模型类。因此,前面的技术无法工作,因为它将使用匿名对象覆盖组件的视图模型,组件将丢失其行为。

尽管它目前没有任何行为,但让我们为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属性。我们需要将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 类组成。此外,自定义元素可以将模板声明为其视图。当然,属性不能声明视图,因为它只是为了增强或更改元素的行为。

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: The name of the attribute used to expose the property to the outside world. If omitted, the name of the property, transformed to dash-case, will be used. For example, the defaultText property would be exposed as the default-text attribute.

    破折号大小写是一种大小写模式,其中所有单词都是小写,并用连字符分隔。尽管社区对这个名字没有明确的共识(也被称为烤肉串案例,但我会在书中坚持使用一致的词汇。

例如,假设我们希望上一个示例中的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的 options 对象应该有一个name属性,正如您可能猜到的那样,该属性将用作可绑定属性的名称。在这种情况下,还可以使用 options 对象上的附加defaultValue属性指定属性的默认值。

为了说明这一点,让我们重构前面的示例,通过在类本身上放置bindable修饰符来声明属性:

import {bindable, bindingMode} from 'aurelia-framework'; 

@bindable({ 
  name: 'text', 
  defaultValue: 'Some default text', 
  defaultBindingMode: bindingMode.twoWay, 
  changeHandler: 'onTextChanged' 
}) 
export class TextBlockCustomElement { 
} 

在这里,我们可以清楚地看到,text属性已经从TextBlockCustomElement类中完全消失。它的整个声明由类上的bindable装饰器处理。

变更处理方式

HTML 行为可以对其任何可绑定属性使用更改处理程序方法。当属性值更改时,模板引擎将自动调用更改处理程序方法。

除非使用bindablechangeHandler选项指定显式方法名称,否则给定属性的更改处理程序方法的名称为该属性的名称,后跟Changed。例如,名为firstName的属性的更改处理程序方法的默认名称为firstNameChanged

使用两个参数调用更改处理程序方法,第一个参数是属性的新值,第二个参数是以前的值。当然,由于处理程序是在其属性更改后调用的,因此可以在 change handler 方法中使用属性本身,而不是第一个参数:

export class TextBlockCustomElement { 
  @bindable text; 

  textChanged(newValue, oldValue) { 
    //Here, newValue is equal to this.text 
  } 
} 

生命周期

所有 HTML 行为都遵循相同的生命周期。行为的视图模型可以实现以下任何回调方法,模板引擎将在行为生命周期的特定时刻调用这些方法:

  • created(owningView: View, view?: View):在行为创建之后立即调用。作为声明行为的View实例,owningView作为第一个参数传递。此外,如果行为是具有视图的自定义元素,则行为的View实例将作为第二个参数传递。
  • bind(bindingContext: Object, overrideContext: Object): This is called right after the view and the view-model have been bound together. The surrounding binding context will be passed as the first parameter. An override context, which exposes the ancestor contexts and can be used to add contextual properties by the view-model, is passed as the second parameter.

    如果该行为未声明bind回调方法,则在此阶段将调用视图模型的可绑定属性的更改处理程序,以允许视图模型根据实例的绑定指令初始化其状态。但是,如果实现了bind,则绑定期间模板引擎不会自动调用更改处理程序,bind方法被认为负责初始化这种情况下的行为状态。

  • attached():在绑定视图连接到 DOM 之后立即调用。

  • detached():这是在绑定视图与 DOM 分离后立即调用的。这在开始处理行为的过程时发生。
  • unbind():视图模型与其视图解除绑定后立即调用。这标志着行为生命的终结。通常情况下,如果unbind正确执行其工作,并且没有忽略释放任何引用和资源,则在该方法返回后,可以对视图模型实例进行垃圾回收。

除了这些生命周期回调方法外,还可以实现任何可绑定属性的已更改处理程序方法。每次可绑定属性的值在行为实例的生命周期内发生更改时,模板引擎都将调用相应的已更改处理程序方法(如果已实现)。

实际上,这些生命周期回调方法并不局限于 HTML 行为。它们可以添加到任何 Aurelia 组件,例如路由器组件或组合组件。

自定义属性

自定义属性是可以通过向任何 HTML 元素添加相应的 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-dialog插件原本是一个更大的 Aurelia 接口项目的一部分,现在被重新定义并重新定义为 Aurelia UX,它使用了ai-前缀。我们已经在第 4 章表格中看到了这一点,以及如何使用ai-dialogai-dialog-body等元素验证它们

具有单个值的属性

默认情况下,自定义属性有一个隐式的value属性,该属性的值将在这里赋值。当然,可以实现名为valueChanged的变更处理程序方法,以便对value属性的变更做出反应。

显然,属性可以在没有任何值的情况下使用:

<div my-attribute></div> 

在这种情况下,value属性将被分配一个空字符串。

最后,当声明一个单值属性时,customAttributedecorator 可以接受第二个参数,这是属性的默认绑定模式。默认情况下,自定义属性是单向绑定的。但是,使用 decorator 可以覆盖此约定。

例如,假设一个file-drop-target属性在默认情况下是双向绑定的:

import {customAttribute, bindingMode} from 'aurelia-framework'; 

@customAttribute('file-drop-target', bindingMode.twoWay) 
export class FileDropTarget { 
  //Omitted snippet...  
} 

添加图像预览

为了说明单值自定义属性是如何工作的,让我们创建一个。

在我们的联系人应用中,我们将在联系人照片上载组件中添加所选图像的预览。为此,我们将利用浏览器的URL.createObjectURL函数,该函数将Blob对象作为参数,并返回指向此资源的特殊 URL。我们的自定义属性(基本上用于img元素)将绑定到Blob对象,并从中生成一个对象 URL,并将此 URL 分配给img元素的src属性。

大多数主流浏览器都支持URL.createObjectURL函数,但它仍然是文件 API 的实验性功能。Mozilla 开发者网络有一个很好的文档,可以在上找到 https://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(); 
  } 
} 

在这里,我们依赖命名约定将类标识为自定义属性,并将属性所在的 HTML 元素注入构造函数中。

我们为PLATFORM常量的global值检索URL对象和Blob类。在浏览器中运行并使用aurelia-pal-browser实现时,此global属性将引用window对象。它允许我们在调用方法之前检查这些值的可用性。这样,如果应用在服务器端执行以呈现其 HTML,并且服务器上使用的 PAL 实现不提供这些 API,则此自定义属性不会引发任何错误,只会保持src属性不变。

我们还使用valueChanged释放以前的对象 URL(如果有的话),然后创建一个新的 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元素,只有当preview可用时才会显示该元素。在这个div中,我们添加了一个img元素,上面有blob-src自定义属性,绑定到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 元素的依赖,并通过构造函数检索它。

当我们的属性被attached添加到文档中时,我们在其元素上添加适当的事件监听器。当drop事件发生时,我们将丢弃的files分配给属性的value属性。

最后,当我们的属性从文档中获取detached时,我们移除其元素上的事件侦听器。

具有多个属性的属性

自定义属性可以声明可绑定属性。在这种情况下,属性不再具有单个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 元素具有以下属性:

  • 它可以具有可绑定的属性
  • 它可以有自己的模板来控制渲染方式
  • 它可以支持内容投影,因此用户可以向其中注入绑定的视图片段或自定义模板
  • 它可以定义自己的行为
  • 它可以与本机 domapi 接口

此外,定制元素可以通过多种不同的方式进行定制,主要使用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并在元素中设置label的样式。label将使用for属性链接到隐藏的file input,因此点击它将打开输入的文件选择对话框,即使input不可见。这使得我们可以在浏览器的file input中显示更性感的 UI。

接下来,让我们创建 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属性的外部范围的表达式同步。

该元素可以实现一些 ID 生成算法,而不是将inputId 属性默认为空。这将使其他开发人员使用该元素更加简单。

接下来,我们添加一个label,其for属性也绑定到inputId属性。这将把labelinput链接在一起,因此点击label将打开input的文件选择对话框。此外,我们将file-drop-target属性添加到此label并将其绑定到files属性,因此在此label上拖放的文件将被分配到files属性。

最后,我们在label中添加了一个slotslot是影子 DOM 规范的一部分,允许内容投影的机制。我们将在后面的部分更详细地介绍内容投影;现在需要记住的一点是,这个slot元素将被替换为file-picker元素实例的内容。

使用自定义元素

我们新的file-picker元素现在可以使用了。当然,它需要全局加载到resources功能的configure函数中,或者在我们使用它的模板中加载required:

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-id指定为photo,它将我们file-picker中封装的file inputlabel与上面两行的另一个label链接起来。

我们还使用accept属性指定file-picker的选择对话框应仅显示图像文件,并将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 input.click 方法

完成此操作后,验证控制器分配的validateTrigger可以从contact-photo组件中删除,因此它将恢复为默认的blur触发器。

因为除了一致性和更好的重用性之外,它并没有增加太多内容,所以我将把它作为一个练习留给读者来应用这些更改。本章填写的申请样本可作为参考。

代孕行为

代理行为允许自定义元素声明自身的属性、事件处理程序和绑定。这是通过将这些代理行为添加到自定义元素的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 中。这种机制被描述为 ShadowDOM1.0 规范的一部分,并且是大型 web 组件不断增长的标准的一部分。

默认插槽

自定义图元中的投影点称为插槽。使用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属性,槽的名称作为其值。这里,我们在icon插槽上投射一个i元素,在label插槽上投射一个包含按钮标签的span

此外,如果多个内容元素对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插槽中。

数据绑定预计内容

模板引擎将在投影内容之前首先处理内容,因此在投影元素上或投影元素内部使用字符串插值或绑定命令是完全合法的。前面的示例说明了这一点,在跨度投影到label插槽之前,使用字符串插值来渲染contactfullName

在投影发生之前,内容是数据绑定的。这意味着使用元素实例周围的上下文绑定内容。它无权访问自定义元素的内部上下文。

在前面的示例中,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> 

在这里,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

槽位异常

那么一个声明插槽的自定义元素呢,它被投影到另一个声明自己插槽的自定义元素中?这是完全可能的。让我们想象一个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属性的限制可以通过在slot周围的另一个元素上添加show属性来解决。但是,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方法的对象的默认结果。

您可以通过添加到返回fullName属性的Contact类 atoString方法中,并注释掉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属性。实际上,单个元素不能承载多个模板控制器。

我们需要用另一个托管with属性的template元素包围可替换模板,并将其绑定到当前的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> 

此模板的全局布局与我们在第 4 章表单中创建的所有列表编辑器使用相同的框架,以及如何验证它们。首先,我们为每个item重复一个块,并使用with在当前item上的该块中确定上下文范围。

在 repeateditem 块中,我们首先声明一个可替换的item部分,它封装了单个项的整个模板。作为此部分的默认内容,我们使用与联系人表单其余部分相同的列设置。第一列包含一个名为label的空可替换部件。第二列包含一个名为value的可替换部分,如果当前项未被替换,则将其呈现为字符串。第三列包含一个名为remove-btn的可替换部分,默认情况下,该部分包含一个移除按钮,当单击该按钮时,该按钮会将项目从阵列中剪接出来。

这里我们可以看到,就像插槽可以在其默认内容中定义子插槽一样,可替换部件可以将其他可替换部件定义为其默认内容。在list-editor中,它允许我们替换整个项目模板,或者只替换其中的一部分。这是一个非常强大的功能。

我们甚至可以在同一个自定义元素中使用可更换部件和插槽。这就是我们在这里要做的,重复项之外的最后一个块包含一个添加按钮,单击该按钮时调用addItem函数。此按钮包含名为add-button-content的第一个插槽。其默认内容是一个图标,以及另一个名为add-button-label的插槽,其默认内容是文本Add。这使我们能够投射内容来定制添加按钮的全部内容,或者只定制其标签。

最后,我们show包含添加按钮的整个块,前提是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方法call绑定。

接下来,我们用绑定到项目的typeselect元素替换label模板部分。当然,这个绑定是用validate装饰的,所以项目的type是正确验证的。

我们还将value模板部分替换为绑定到项目numbertel input。同样,该绑定用validate装饰,因此该项的number被验证。

最后,我们将文本Add a phone number投射到add-button-label槽上。

此时,如果运行应用,电话号码编辑器的外观和行为应该与以前相同。我将把它作为一个练习留给读者,让他们使用list-editor重构电子邮件地址、地址和社交档案的编辑器。本章完整的应用示例可作为参考。

使用定制装饰器

aurelia-templating库提供了许多装饰器,可用于自定义自定义元素的行为以及模板引擎如何处理它们。

以下大部分代码片段都是从chapter-5/samples/element-decorators示例中摘录的。

资源

viewResources装饰符可用于声明视图依赖项。它的行为与require元素类似,但来自组件的视图模型,而不是其模板。

例如,我们可以从联系人管理应用中重构contact-edition组件,方法是从其模板中删除require语句:

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

如果多个元素共享同一个视图,这将非常有用。此外,当元素的模板要在可重用库或插件中分发时,显式指定该元素的模板被认为是一种良好的做法。事实上,正如我们将在本章末尾看到的,使用元素的开发人员可以更改视图位置的约定。在这种情况下,依赖标准公约的要素将被打破。

内联视图

inlineViewdecorator 允许我们用 JS 文件中声明的内联模板完全替换组件的模板文件:

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装饰器告诉模板引擎给定的自定义元素没有模板。在这种情况下,模板引擎将简单地绑定元素本身,然后处理其内容(如果有),也就是说,除非也使用了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装饰器告诉模板引擎使用给定的ViewStrategy实例加载组件的视图。实际上,useViewinlineViewnoView装饰师在幕后使用它。

它只是将提供的视图策略作为元数据附加到类上。在视图定位过程中,该元数据随后由视图定位器检查,并用于定位组件的视图。

它主要用于定制ViewStrategy实现,这是本书范围之外的一个高级主题。然而,知道它的存在是件好事,以防你需要去那里。

流程属性

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属性
  • instructionBehaviorInstruction实例,包含模板引擎用于处理、数据绑定和显示自定义元素的所有信息

处理内容

processContent装饰器可用于控制模板引擎如何以及是否处理自定义元素的内容。这完全取决于传递给装饰器的参数。

如果 decorator 通过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 元素
  • instructionBehaviorInstruction实例,包含模板引擎用于处理、数据绑定和显示自定义元素的所有信息

例如,可以使用此装饰器创建一个自定义元素,作为 Aurelia 应用中的集成点,以便封装必须使用不同模板引擎的应用部分。

无集装箱

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自定义元素上使用代理行为,因为应该投影代理行为的元素没有呈现。

例如,在使用 SVG 元素时,当必须遵守特定的 DOM 结构时,此装饰器非常有用。

使用阴影域

useShadowDOM装饰器将使自定义元素在阴影 DOM 中呈现其视图。这有助于将自定义元素的 DOM 子树与文档的其余部分隔离开来,以防止元素的 DOM 子树与外部世界之间 CSS 或 DOM 查询的不必要交互。

为了说明这一点,让我们考虑一下联系管理应用中的自定义 T0.自定义元素。此元素有一个 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装饰符用于自定义元素的属性。它选择与提供的查询选择器匹配的所有直接子项,并将它们作为数组分配给修饰属性。

为了说明这一点,让我们设想以下自定义元素:

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组件中,如果点击保存按钮一次,然后在底层获取调用完成且路由器离开表单之前再次点击,则将并行执行对后端的多个调用。有时候,这并不重要。但是,在许多情况下,这也可能是一个问题。

创建提交任务属性

为了解决这个问题,我们将创建一个名为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完成时isSubmitTaskExecuting被设置回false,无论成功与否。

最后,当文档中的属性为detached时,我们只需删除submit事件侦听器。

使用提交任务属性

现在我们可以使用form元素进入各个组件,并用新的submit-task属性替换submit事件处理程序,使用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 type。我们还使用ref属性将此按钮的引用分配给绑定上下文中的button属性,并将按钮的disabled属性绑定到disabled可绑定属性。

在按钮内部,我们添加了一个span,当按钮的form元素的isSubmitTaskExecuting属性为true时,该span将被隐藏。在这个span中,我们定义了一个icon插槽,其默认内容是一个复选图标。

我们还在按钮内部添加了一个微调器图标,只有当按钮的form元素的isSubmitTaskExecuting属性为true时才会显示该图标。

最后,我们定义了一个默认槽,其中包含Submit文本作为其默认内容。

当没有提交进行时,这个自定义元素只会显示一个检查图标,并在任何提交任务期间用微调器替换这个检查图标。当提交任务完成时,它将切换回检查图标。

此外,icon插槽将允许实例覆盖默认检查图标,未命名插槽将允许实例覆盖Submit标签。

使用提交按钮

现在我们可以使用form元素进入各个组件,并用新的submit-button元素替换保存按钮:

src/contact-creation.html

<template> 
  <!-- Omitted snippet... --> 
  <submit-button>Save</submit-button> 
  <!-- Omitted snippet... --> 
</template> 

在这里,我们只需定义一个submit-button元素,并将Save文本投影到默认插槽上,这将覆盖其默认标签。

我将把它作为练习留给读者,让他们也将这些更改应用于contact-editioncontact-photo组件。

此时,如果您运行应用,当提交任务正在进行时,您应该会看到各种保存按钮的复选图标被微调器替换。

定制视图定位策略

视图定位是定位给定组件的模板或视图的过程。按照惯例,模板应该是一个与视图模型位于同一目录中的文件,并且除了扩展名(应该是.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方法替换了 decorator 的用法,返回模板文件的路径。

总结

HTML 行为是非常强大和通用的。它们为创建复杂而灵活的组件、专用于单个应用的组件、可重用的组件、完全可定制的组件以及作为第三方插件或框架分发的组件开辟了一个全新的天地。

它们还提供了将第三方库集成到 Aurelia 中的好方法。我们将在第 11 章中看到如何与其他库集成。随着 Aurelia 的模板 API 的开放性和易用性,我们将能够定制和插入这些集成组件的呈现过程,以完成一些令人惊奇的事情。

但我们还没有做到。在下一章中,我们将后退一步,好好看看我们的联系人管理应用。我们将反思我们做出的设计选择和我们没有做出的设计选择,并看看我们如何才能让事情变得更好。我们还将讨论组织 Aurelia 应用的不同方法,以使其更加模块化、可测试和易于维护。