在这篇文章(原文链接)中,我们将深入探讨Angular Core的一些高级功能!

您可能已经遇到过ng-template这个Angular核心指令,例如在使用ngIf/elsengSwitch时。

ng-template指令和相关的ngTemplateOutlet指令是非常强大的Angular功能,支持各种高级用例。

这些指令经常与ng-container一起使用,因为这些指令被设计为一起使用,所以如果我们一次性学习它们将会有所帮助,因为我们将围绕每个指令有更多的上下文。

然后让我们看看这些指令启用的一些更高级的用例。 注意:本文的所有代码都可以在此Github repository中找到。

ng-template指令简介

与名称一样,ng-template指令表示Angular模板:这意味着此标记的内容将包含模板的一部分,然后可以与其他模板一起组合以形成最终的组件模板。

Angular已经在我们一直使用的许多结构指令中使用了ng-templatengIfngForngSwitch

让我们开始学习ng-template并举例说明。 这里我们定义一个选项卡组件的两个选项卡按钮(稍后会详细介绍):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component({
  selector: 'app-root',
  template: `      
      <ng-template>
          <button class="tab-button" 
                  (click)="login()">{{loginText}}</button>
          <button class="tab-button" 
                  (click)="signUp()">{{signUpText}}</button>
      </ng-template>
  `})
export class AppComponent {
    loginText = 'Login';
    signUpText = 'Sign Up'; 
    lessons = ['Lesson 1', 'Lessons 2'];

    login() {
        console.log('Login');
    }

    signUp() {
        console.log('Sign Up');
    }
}

你会注意到关于ng-template的第一件事

如果您尝试上面的示例,您可能会惊讶地发现此示例不会向屏幕呈现任何内容

这是正常的,这是预期的行为。 这是因为使用ng-template标签我们只是定义一个模板,但我们还没有使用它。

然后让我们找一个示例,我们可以使用一些最常用的Angular指令来渲染输出。

ng-template指令和ngIf

您可能在实现if/else方案时第一次遇到ng-template,例如这个:

1
2
3
4
5
6
7
<div class="lessons-list" *ngIf="lessons else loading">
  ... 
</div>

<ng-template #loading>
    <div>Loading...</div>
</ng-template>

这是ngIf/else功能的一种非常常见的用法:我们在等待数据从后端到达时显示备用loading模板。

我们可以看到,else子句指向一个名称为loading的模板。 使用#loading语法通过模板引用为其分配了名称。

但除了那个模板之外,使用ngIf还会创建第二个隐式ng模板! 让我们来看看幕后发生的事情:

1
2
3
4
5
6
7
8
9
<ng-template [ngIf]="lessons" [ngIfElse]="loading">
   <div class="lessons-list">
     ... 
   </div>
</ng-template>

<ng-template #loading>
    <div>Loading...</div>
</ng-template>

这就是内部发生的事情,因为Angular解析了简洁的*ngIf结构指令语法糖。 让我们分解一下语法糖解析期间发生了什么:

  • 应用结构指令ngIf的元素已移至ng-template
  • 使用[ngIf][ngIfElse]模板输入变量语法将*ngIf的表达式拆分并应用于两个单独的指令

这只是ngIf特定情况的一个例子。 但是使用ngForngSwitch也会发生类似的过程。

这些指令都是非常常用的,因此这意味着这些模板在Angular中无处不在,无论是隐式还是显式。

但基于这个例子,可能会想到一个问题:

如果有多个结构指令应用于同一个元素,这是如何工作的?

多种结构指令

让我们看看如果我们尝试在同一元素中使用ngIfngFor会发生什么:

1
2
3
4
5
6
<div class="lesson" *ngIf="lessons" 
       *ngFor="let lesson of lessons">
    <div class="lesson-detail">
        {{lesson | json}}
    </div>
</div>  

这行不通! 相反,我们会收到以下错误消息:

1
2
3
Uncaught Error: Template parse errors:
Can't have multiple template bindings on one element. Use only one attribute 
named 'template' or prefixed with *

这意味着它不可能将两个结构指令应用于同一个元素。 为了做到这一点,我们必须做类似的事情:

1
2
3
4
5
6
7
<div *ngIf="lessons">
    <div class="lesson" *ngFor="let lesson of lessons">
        <div class="lesson-detail">
            {{lesson | json}}
        </div>
    </div>
</div>

在这个例子中,我们已经将ngIf指令移动到外部包装div,但为了使其工作,我们必须创建额外的div元素。

这个解决方案已经可以工作了,但有没有办法将结构指令应用到页面的一部分而不必创建额外的元素?

这正是ng-container结构指令允许我们做的!

ng-container指令

为了避免创建额外的div,我们可以改为使用ng-container指令:

1
2
3
4
5
6
7
<ng-container *ngIf="lessons">
    <div class="lesson" *ngFor="let lesson of lessons">
        <div class="lesson-detail">
            {{lesson | json}}
        </div>
    </div>
</ng-container>

正如我们所看到的,ng-container指令为我们提供了一个元素,我们可以将结构指令附加到页面的一部分,而不必为此创建额外的元素。

ng-container指令还有另一个主要用途:它还可以提供一个占位符,用于动态地将模板注入页面。

使用ngTemplateOutlet指令创建动态模板

能够创建模板引用并将它们指向其他指令(例如ngIf)只是一个开始。

我们也可以使用模板本身并使用ngTemplateOutlet指令在页面的任何位置实例化它:

1
<ng-container *ngTemplateOutlet="loading"></ng-container>

我们可以在这里看到ng-container如何帮助这个例子:我们使用它来在页面上实例化我们在上面定义的加载模板。

我们通过其模板引用#loading引用加载模板,我们使用ngTemplateOutlet结构指令来实例化模板。

我们可以根据需要向页面添加尽可能多的ngTemplateOutlet标记,并实例化许多不同的模板。 传递给该指令的值可以是任何计算模板引用的表达式,稍后将详细介绍。

现在我们知道了如何实例化模板,让我们来谈谈模板可以访问的内容。

模板上下文

关于模板的一个关键问题是,它们内部可见什么?

模板是否有自己独立的变量范围,模板可以看到哪些变量?

ng-template标签主体内部,我们可以访问外部模板中可见的相同上下文变量,例如lessons变量。

这是因为所有ng-template实例也可以访问嵌入它们的相同上下文。

但是每个模板也可以定义自己的输入变量集! 实际上,每个模板都关联一个包含所有模板特定输入变量的上下文对象。

我们来看一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Component({
  selector: 'app-root',
  template: `      
<ng-template #estimateTemplate let-lessonsCounter="estimate">
    <div> Approximately {{lessonsCounter}} lessons ...</div>
</ng-template>
<ng-container 
   *ngTemplateOutlet="estimateTemplate;context:ctx">
</ng-container>
`})
export class AppComponent {

    totalEstimate = 10;
    ctx = {estimate: this.totalEstimate};
  
}

以下是此示例的解释:

  • 这个模板,与以前的模板不同,还有一个输入变量(也可能有几个)
  • 输入变量名为lessonsCounter,它是通过ng-template属性使用前缀let-定义的
  • 变量lessonsCounterng-template主体内部可见,但外部不可见
  • 此变量的内容由其分配给属性let-lessonsCounter的表达式确定
  • 该表达式针对上下文对象进行评估,与模板一起传递给ngTemplateOutlet以进行实例化
  • 然后,对于要在模板内显示的任何值,此上下文对象必须具有名为estimate的属性
  • context对象通过context属性传递给ngTemplateOutlet,该属性可以接收任何求值为对象的表达式

鉴于上面的示例,这将呈现给屏幕:

1
Approximately 10 lessons ...

这为我们提供了如何定义和实例化我们自己的模板的良好概述。

我们还可以做的另一件事是在组件本身的层次上以编程方式与模板交互:让我们看看我们如何做到这一点。

Template References

与我们使用模板引用引用加载模板的方式相同,我们也可以使用ViewChild装饰器将模板直接注入到我们的组件中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component({
  selector: 'app-root',
  template: `      
      <ng-template #defaultTabButtons>
          <button class="tab-button" (click)="login()">
            {{loginText}}
          </button>
          <button class="tab-button" (click)="signUp()">
            {{signUpText}}
          </button>
      </ng-template>
`})
export class AppComponent implements OnInit {

    @ViewChild('defaultTabButtons')
    private defaultTabButtonsTpl: TemplateRef<any>;

    ngOnInit() {
        console.log(this.defaultTabButtonsTpl);
    }

}

正如我们所看到的,通过向ViewChild装饰器提供模板引用名称defaultTabButtons,可以像任何其他DOM元素或组件一样注入模板。

这意味着模板也可以在组件类的级别访问,我们可以做一些事情,例如将它们传递给子组件!

我们想要这样做的一个例子是创建一个更可定制的组件,其中不仅可以传递配置参数或配置对象:我们还可以将模板作为输入参数传递

具有模板部分@Inputs的可配置组件

让我们以tab容器为例,我们希望为组件的用户提供配置tab按钮外观的可能性。

下面是这样的,我们首先要为父组件中的按钮定义自定义模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Component({
  selector: 'app-root',
  template: `      
<ng-template #customTabButtons>
    <div class="custom-class">
        <button class="tab-button" (click)="login()">
          {{loginText}}
        </button>
        <button class="tab-button" (click)="signUp()">
          {{signUpText}}
        </button>
    </div>
</ng-template>
<tab-container [headerTemplate]="customTabButtons"></tab-container>      
`})
export class AppComponent implements OnInit {

}

然后在tab容器组件上,我们可以定义一个input属性,它也是一个名为headerTemplate的模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Component({
    selector: 'tab-container',
    template: `
    
<ng-template #defaultTabButtons>
    
    <div class="default-tab-buttons">
        ...
    </div>
    
</ng-template>
<ng-container 
  *ngTemplateOutlet="headerTemplate ? headerTemplate: defaultTabButtons">
    
</ng-container>
... rest of tab container component ...
`})
export class TabContainerComponent {
    @Input()
    headerTemplate: TemplateRef<any>;
}

在最后的组合示例中,这里有几件事情正在发生。 让我们逐一来看:

  • 为选项卡按钮定义了一个默认模板,名为defaultTabButtons
  • 仅当input属性headerTemplate未定义时,才会使用此模板
  • 如果定义了属性,则通过headerTemplate传递的自定义输入模板将用于显示按钮
  • headerTemplate使用ngTemplateOutlet属性在ng-container占位符中实例化
  • 决定使用哪个模板(默认或自定义)是使用三元表达式,但如果该逻辑很复杂,我们也可以将其委托给控制器方法

此设计的最终结果是,如果未提供自定义模板,则tab容器将显示tab按钮的默认外观,但如果自定义模板可用,它将使用自定义模板。

总结和结论

核心指令ng-containerng-templatengTemplateOutlet结合在一起,使我们能够创建高度动态和可定制的组件。

我们甚至可以根据输入模板完全改变组件的外观和感觉,我们可以定义模板并在应用程序的多个位置实例化。

这只是这些功能可以组合的一种可能方式!