Понимание динамической области видимости и TempleteRef в Angular

Jun 9, 2019

Этот пост будет посвящен шаблонным ссылкам в Angular и динамической области видимости. Хотя основной фокус в статье на TemplateRef мы также коснемся семантики языков программирования (область видимости и привязка имен).

Исходники mgechev/puppy-list

Введение в шаблонные ссылки (Template References)

Для понимания TempleteRef давайте взглянем на директиву NgForOf. Предположим у нас есть лист щенков (puppies) и нам нужно итерировать его в PuppyListComponent. Puppy интерфейс:

interface Puppy {
  name: string;
  photo: string;
  age: number;
  breed: string;
}

Простая реализация PuppyListComponent:

@Component({
    selector: 'puppy-list',
    template: `
        <md-list>
            <md-list-item *ngFor="let puppy of puppies">
                <img mdListAvatar [src]="puppy.photo" alt="...">
                <h3 mdLine> {{puppy.name}} </h3>
                <p mdLine>
                    <span>Age: {{puppy.age}} </span>
                    <span>Breed: {{puppy.breed}} </span>
                </p>
            </md-list-item>
        </md-list>
    `
})
export class PuppyListComponent {
    @Input() puppies: Puppy[];
}

В примере выше мы определили компонент со входящим свойством puppies. Внутри компонента шаблон, в котором перебираем щенков в цикле и отражаем на экране. Для этих целей мы используем директиву *ngFor.

Позднеее мы используем компонент следующим путем:

@Component({
  selector: 'puppies-cmp',
  template: `
    <puppy-list [puppies]="puppies"></puppy-list>
  `
})
class PuppiesComponent {
  puppies = [
    { name: 'Dino', age: 1, photo: '...', breed: 'Rottweiler' },
    { name: 'Max', age: 2, photo: '...', breed: 'Beagle' },
    { name: 'Lucy', age: 1, photo: '...', breed: 'Golden Retriever' }
  ];
}

В PuppiesComponent мы объявляем массив puppies и передаем его как входящее свойство puppy-list.

Шаблонные директивы

Давайте сделаем шаг назад и взглянем на PuppyListComponent снова:

@Component({
    // ...
    template: `
    <md-list>
        <md-list-item *ngFor="let puppy of puppies">
            ...
        </md-list-item>
    </md-list>
    `
})
export class PuppyListComponent { ... }

Важно отметить, этот шаблон эквивалентен:

@Component({
// ...
template: `
<md-list>
    <ng-template ngFor let-puppy [ngForOf]="puppies">
        <md-list-item>
            ...
        </md-list-item>
    </ng-template>
</md-list>
`
})
export class PuppyListComponent {...}

Отличие от предыдущего примера в том, что вместо использования директивы *ngFor на md-list-item компоненте мы создали шаблон-элемент с атрибутом ngFor, let-puppy и [ngForOf]="puppies". Поясним значения атрибутов на ng-template:

  • ngFor - намек Angular, что здесь мы используем NgForOf директиву
  • let-puppy - создаем новую локальную переменную для шаблона, для которой мы можем создавать привязки. Этим путем мы можем ссылаться на каждого puppies посрдством переменной puppy. Мы объясним атрибут let- далее в статье.
  • [ngForOf] - указывам коллекцию для итерации.

Фактически каждый раз когда Angular видит префикс * он понимает, что это синтаксический сахар для ng-template. Вот почему мы зовем это шаблонной директивой. По умолчанию Angular будет использовать разметку между тегами ng-template в качестве шаблона для самой директивы. Есть несколько шаблонных директив:

  • NgForOf - позволяют итерировать коллекцию.
  • NgIf - условие включения шаблона на основе выражения.
  • NgSwitch - добавляет / убавляет DOM элементы, когда соответствующее гнездо соответствует switch выражению.
  • etc.

Все они используют TemplateRefs внутри.

Давайте предположим что нам нужно создать другую страницу в нашем приложении, которая также перебирает в цикле шенков, но на этот раз визуализация отличается. Очевидная реализация стратегии для такого компонента это скопировать PuppyListComponent и затем обновить его шаблон в соответствии с требованиями макета. Angular, однако, позволяет нам более элегантно подойти к даннной проблеме.

Передаем кастомный TemplateRef

Теперь вместо размещения директивы *ngFor прямо над md-list-item компонентом давайте используем элемент ng-template и внедрим шаблон, который будет использовать NgForOf, снаружи (то есть через родительский компонент).

<ng-template ngFor
             let-puppy
             [ngForOf]="puppies"
             [ngForTemplate]="puppyTemplate">
</ng-template>

Отличие от предыдущего фрагмента в том, что мы явно передаем шаблон, который Angular будет использовать, чтобы отобразить каждого щенка. Для этой цели мы используем ngForTemplate свойство и директиву NgForOf. Важно отметить, что мы можем указать TemplateRef как входящее свойство.

Angular получит шаблон для NgForOf от значения свойства puppyTemplate объявленного в контроллере PuppyListComponent. Поправим компонент PuppyListComponent на (не забывайте, что PuppyListComponent дочерний компонент для PuppiesComponent):

import { Puppy } from './../puppy/puppy';
import { Component, Input, TemplateRef, ContentChild } from '@angular/core';
import { NgForOfContext } from '@angular/common';

@Component({
    selector: 'puppy-list',
    template: `
    <md-list>
        <ng-template ngFor let-puppy [ngForOf]="puppies" [ngForTemplate]="puppyTemplate">
        </ng-template>
    </md-list>
    `
})
export class PuppyListComponent {
    @Input() puppies: Puppy[];

    @ContentChild(TemplateRef) puppyTemplate: TemplateRef<NgForOfContext<Puppy>>;
}

Теперь все становится более интересным. Обратите внимание, что в определении класс мы для свойства puppyTemplate применили декоратор @ContentChild и передали TemplateRef как его аргумент. Этот аргумент является селектором, который Angular будет сопоставлять с содержимым данного компонента. Типом свойства puppyTemplate является TemplateRef<NgForOfContext<Puppy>> (будет объяснено в финальной части статьи).

Короче говоря, Angular получит ссылку на шаблон, который будет использовать ngForOf в качестве контента переданного PuppyListComponent от родителя.

Давайте изменим шаблон родительского компонента в зависимости от вышеприведенных манипуляций:

@Component({
    selector: 'app-root',
    template: `
    <puppy-list [puppies]="puppies">
        <ng-template let-puppy>
            <md-list-item>
                <img mdListAvatar [src]="puppy.photo" alt="...">
                <h3 mdLine> {{puppy.name}} </h3>
                <p mdLine>
                    <span>Age: {{puppy.age}} </span>
                    <span>Breed: {{puppy.breed}} </span>
                </p>
            </md-list-item>
        </ng-template>
    </puppy-list>
    `
})
export class PuppiesComponent {
    // ...
}

Отметьте как в качестве контента элемента puppy-list мы передаем ng-template, который станет значением puppyTemplate объявленным внутри PuppyListComponent.

Теперь, если в другом компоненте, скажем PuppyAvatarsComponent мы захотим показать только аватары, мы можем сделать это используя PuppyListComponent.

import { Component } from '@angular/core';

@Component({
    selector: 'puppy-avatars',
    template: `
    <puppy-list [puppies]="puppies">
        <ng-template let-puppy>
            <img mdListAvatar [src]="puppy.photo" alt="...">
        </ng-template>
    </puppy-list>
    `,
    // ...
})
export class PuppyAvatarsComponent {
    // ...
}

Таким образом мы просто передаем ссылку на другой шаблон. В этом шаблоне вместо детальной визуализации о каждом щенке мы просто визуализируем их аватары.

Введение в динамическую область видимости

В языках программирования есть две основные области видимости привязки имен:

  • Лексическая область видимости (область видимости определяется в момент определения функции)
  • Динамическая область видимости (область видимости определяется в момент вызова функции)

Возможно вы в курсе что с var в JavaScript мы можем объявлять переменные с функциональной лексической областью видимости, а для let в свою очередь переменные объявляются с блочной лексической областью видимости.

Это просто означает, что в следующем примере переменная foo будет видна внутри всей функции (функциональня лексическя область видимости)

function baz() {
  if (bar) {
    var foo = 42;
  }
  // 42
  console.log(foo);
}

в отличие от нижеприведенного фрагмента (блочная лексическая область видимости), где переменная будет видна только внутри кострукции if:

function baz() {
  if (bar) {
    let foo = 42;
  }
  // undefined
  console.log(foo);
}

Как лексическая так и динамическая область видимости обращаются к 'scope' области объявления переменной и обе ссылаются на не как на 'область программы, где данная переменная видна', однако, мы можем ссылаться на 'место программы' двумя способами - местоположение в исходном коде или местоположение во время выполнения.

Давайте рассмотри пример:

let bar = 42;

function foo() {
  console.log(bar);
}

function baz(cb) {
  let bar = 1.618;
  cb();
}
baz(foo)

Так как у JavaScript лексическая область видимости, то однажды вызвав baz(foo) на экране будут выведено 42. Однако, если бы JavaScript имел динамическую область видимости, то на экране вывелось бы 1.618.

В отличие от JavaScript, шаблонный ссылки (template references) Angular используют динамическую область видимости. Давайте взглянем почему?

Динамическая область видимости в Angular

Здесь PuppyAvatarComponent:

import { Component } from '@angular/core';

@Component({
    selector: 'puppy-avatars',
    template: `
    <puppy-list [puppies]="puppies">
        <ng-template let-puppy>
            <img mdListAvatar [src]="puppy.photo" alt="...">
        </ng-template>
    </puppy-list>
    `,
    // ...
})
export class PuppyAvatarsComponent {
    // ...
}

Как вы можете знать, директива NgForOf предоставляет еще несколько свойств, например:

  • index - индекс текущего элемента.
  • odd - нечетный.
  • even - четный.
  • first - если первый элемент коллекци, то имеет значение true.
  • last - если последний элемент коллекци, то имеет значение true.

С let-odd="odd" мы установим значение переменной odd в шаблоне равным значению переменной odd из директивы NgForOf и с let-f="first" мы установим значение переменной f в шаблоне равным значению переменной first из директивы NgForOf.

Что насчет let-puppy? Что за магия и почему мы не указываем какой переменной директивы NgForOf мы хотим получить ее значение? Фактически, директивы NgForOf имеет еще одно свойство - $implicit. Это свойство присваивается всем переменным шаблона, которые мы объявляем атрибутом без значения. Итак, если мы имеем:

<ng-template let-puppy let-dog let-odd="odd" let-f="first">
</ng-template>
  • Оба и puppy и dog указывают на текущий элемент в итерации (то есть $implicit)
  • odd будет указывать на свойство odd директивы NgForOf.
  • f будет указывать на свойство first директивы NgForOf.

Теперь, что быть более точным odd, even, first, last и $implicit не являются свойствами NgForOf, а его контекста.

Реализация NgForOfContext может быть найдена здесь

Вернемся обратно к PuppyListComponent:

@Component(...)
export class PuppyListComponent {
    // ...
    @ContentChild(TemplateRef) puppyTemplate: TemplateRef<NgForOfContext<Puppy>>;
}

Отметьте тип свойства puppyTemplate - TemplateRef<NgForOfContext<Puppy>>;. Это имеет следующую семантику:

TemplateRef элемент, который следует использовать в контексте типа NgForOfContext, где
$implicit имееь тип Puppy.

Так же другие шаблонные директивы имеют свой собственный контекст, так как они используют TemplateRef, который должен интерпретировать в данном контексте. Например, контекст NgIf объявляется классом:

export class NgIfContext {
  public $implicit: any = null;
  public ngIf: any = null;
}

Как вы можете видеть, в отличие от NgForOfContext NgIfContext не имеет параметр типа, поскольку нет ничего к чему мы могли бы обратиться с помощью свойства $implicit.

Как именно у Angular реализована динамическая область видимости для TemplateRef?

Давайте вернемся немного назад и посмотрим на PuppyAvatarsComponent.

@Component({
    selector: 'puppy-avatars',
    template: `
    <puppy-list [puppies]="puppies">
        <ng-template let-puppy="$implicit">
            <img mdListAvatar [src]="puppy.photo" alt="...">
        </ng-template>
    </puppy-list>
    `,
})
export class PuppyAvatarsComponent {
    puppies = Puppies;

    $implicit = {
        name: 'Danny',
        age: 12,
        breed: 'Poodle'
    }
}

Отметьте, что в ng-template мы привязали свойство $implicit и в теле компонента PuppyAvatarsComponent мы объявили свойство с именем $implicit. Хотя свойство $implicit существует в текущей лексической области видимости шаблона, его значение будет проигнорировано и поскольку puppy имеет динамическую область видимости и будет привязано к значению $implicit где мы его используем, то есть у компонета с селектором puppy-list.

Заключение

TemplateRef позволяет нам не только выполнять расширенную проекцию контента, но также повторно использовать шаблоны в нашем приложении.

В отличие от лексической области видимости, которая используется в большинстве языках программирования, Angular использует динамическую область видимости для своих шаблонных ссылок, поскольку нам интересен контекст в котором используется данный TemplateRef.

Добавить комментарий