Pull to refresh

Директор директив. Расширяем функционал angular-компонентов красиво. Директива-контекст

Level of difficultyMedium
Reading time8 min
Views3.2K

Игнорируете кастомные директивы в Angular? Зря-зря, многое упускаете.

Позвольте мне показать в нескольких статьях, как с помощью директив можно расширить функционал ваших компонентов, да так, что никакой DX не пострадает (а только улучшится) (по моему мнению).

Представляю вам набор паттернов, которыми пользуюсь я. Их список и названия я выдумал только что за десять минут. Возможно, у них уже есть какие-то научные названия или они ассоциируются с какими-нибудь умными аббревиатурами, но я ничего такого не находил, поэтому будем пользоваться моими названиями. Тем более, они классные.

Итак, о каких выдуманных мной паттернах пойдёт речь:

  • котнекст — разберём в этой статье;

  • имплементация;

  • портал;

  • контент-контекст;

  • шпион;

  • кукловод.

Ссылки буду обновлять по мере написания статей.

Вообще, работа директив тесно связана с DI, поэтому, чтобы до конца понять то что будет написано дальше, хорошо бы разбираться, что такое DI-контекст, откуда он берётся, и как в него что-то добавлять. Впрочем, можно и наоборот с помощью этих примеров закрепить знания о DI.

Директива-контекст

Контекст позволяет немного изменить поведение элементов внутри себя. Но не совсем любых: директива-контекст и элементы, на которые она влияет, должны работать в синергии.

Давайте попробуем сделать такую директиву-контекст, которая будет задавать в какой таймзоне будет выводиться та или иная дата. Хотим, чтобы было вот так:

Снимок экрана 2024-03-07 в 21.21.09.png

Приходим к проблеме

Представим, что есть компонент DateComponent, который просто выводит переданные через инпут дату и время в текущей пользовательской таймзоне с помощью Intl.DateTimeFormat. Это специальное API, позволяющее очень гибко вывести дату и время в человеко-читаемом формате в разных локалях и таймзонах.

Воспользуемся нашим компонентом вот так:

<section>
  <h3>Europe/Moscow time zone:</h3>
  <p>Now: <app-date [date]="now"></app-date>
</section>

Мы попросили наш компонент вывести дату и время из переменной now. Получим вот такой результат:

Снимок экрана 2024-03-06 в 22.37.44.png

Спорить не о чем: на момент написания в Москве действительно было 22:36.

Если внутри нашего section образуется ещё один компонент, на этот раз уже со вчерашней датой, то он, опять же, отобразит её по московскому времени.

Но это всё до тех пор, пока пользователь (в данном случае я) действительно находится в Москве. Стоит ему оказаться, например, в Красноярске, так сразу будет бесстыдное компьютерное враньё.

Что ж, раз мы хотим, чтобы дата отображалась всегда именно по московскому времени, так давайте передадим в инпут компонента app-date таймзону (благо, наш компонент такое понимает):

<section>
  <h3>Europe/Moscow time zone:</h3>
  <p>Now: <app-date [date]="now" timeZone="Europe/Moscow"></app-date></p>
</section>

Здорово, теперь где бы пользователь ни находился, ему всегда будет выводиться текущее московское время. Очень полезно.

А теперь представим, что у нас в section целая орава разных дат: текущая дата, вчерашняя дата, завтрашняя дата, день рождения пользователя (откуда-то вдруг), всех его родственников до третьего колена, и всё это с точностью до минуты. Не забываем, что чтобы вывести даты рождения нам придётся построить генеалогическое древо компонентов прямо в ангуляре.

Короче говоря, в одном файле всё не уместится, придётся разбивать по компонентам, и всех их будет объединять одно — они будут выводить даты с помощью старого доброго app-date. Получится что-то вроде такого:

<section>
  <h3>Europe/Moscow time zone:</h3>
 
  <p>Now: <app-date [date]="now" timeZone="Europe/Moscow"></app-date></p>

  <p>User's birthday <app-date [date]="user.birthday" timeZone="Europe/Moscow"></app-date></p>

  <app-family-tree [startingLeaf]="user"></app-family-tree>
  <!-- app-family-tree тоже использует компонент app-date -->
</section>

Снова мысленно переместимся в Красноярск и заметим, что текущее время и день рождения пользователя действительно выводятся по московскому времени. А вот генеалогическое древо вывелось опять по красноярскому времени, хотя заголовок нашего приложения обещает иное.

Логично, ведь мы никак не проинструктировали компонент app-family-tree о том, в какой таймзоне нужно выводить дату и время. Проинструктируем, нам не сложно:

<section>
  <h3>Europe/Moscow time zone:</h3>
 
  <p>Now: <app-date [date]="now" timeZone="Europe/Moscow"></app-date>

  <p>User's birthday <app-date [date]="user.birthday" timeZone="Europe/Moscow"></app-date>

  <app-family-tree [tree]="user" timeZone="Europe/Moscow"></app-family-tree>
</section>

Как думаете, что с этой информацией сделает компонент app-family-tree? Да, просто передаст её дальше, ничего с ней не делая. Ему она не сильно-то и нужна, она предназначена для его дочерних компонентов. В React это называется Prop Drilling. Дело неблагодарное. Да и вообще уже от инпута timeZone как-то в глазах рябит, уж больно его много.

Как нам сделать так, чтобы во всей этой каше дата и время выводились именно в той таймзоне, в которой мы хотим, а передать её бы пришлось только один раз? Можно ли сделать некую зону вокруг элемента, внутри которой компонент app-date выводил бы дату только по московскому времени? Что-то такое:

...
<!-- отсюда -->
<section>
  <h3>Europe/Moscow time zone:</h3>
  <p>Now: <app-date [date]="now"></app-date>
  <p>User's birthday <app-date [date]="user.birthday"></app-date>
  <app-family-tree [tree]="user"></app-family-tree>
</section>
<!-- и досюда выводим даты только в зоне Europe/Moscow -->
...

Можно! Сделаем директиву-контекст. Выглядеть будет вот так:

<section appDateTimeZone="Europe/Moscow">
  <h3>Europe/Moscow time zone:</h3>
  <p>Now: <app-date [date]="now"></app-date>
  <p>User's birthday <app-date [date]="user.birthday"></app-date>
  <app-family-tree [tree]="user"></app-family-tree>
</section>

Всё что внутри элемента, на который мы посадили директиву (и он сам тоже) теперь находится в контексте. Все элементы (компоненты, директивы, пайпы), которые знают о том, что могут находиться в нашем контексте, теперь начинают учитывать этот контекст в своей работе. В нашем случае о контексте узнает тот самый компонент app-date.

Простыми словами: все даты, которые выводит app-date, будут отображаться по московскому времени, и никаких инпутов ему для этого не потребовалось.

Реализуем

Первым делом создадим директиву appDateTimeZone. Она будет максимально простой:

import { Directive, Input } from '@angular/core';

@Directive({
  selector: '[appDateTimeZone]'
})
export class DateTimeZoneDirective {
  @Input('appDateTimeZone') timeZone?: string;
}

У неё есть один единственный строковый инпут — таймзона. Обратим внимание, что мы дали ему алиас, равный названию нашей директивы. Таким образом, когда мы будем пользоваться нашей директивой, мы сможем объеденить объявление её и её инпута в одном слове:

<!-- если бы инпут и директива назывались по-разному: -->
<section appDateTimeZone timeZone="Europe/Moscow">...</section>

<!-- но мы их назвали одинаково, и теперь можем обойтись одним объявлением: -->
<section appDateTimeZone="Europe/Moscow">...</section>

С директивой закончили. Всё, что она делает — это хранит информацию и сидит себе спокойно в DI-контексте. Теперь займёмся компонентом app-date.

Шаблон у него был всегда максимально простым:

{{ getFormattedDate() }}

А вот класс:

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

@Component({
  selector: 'app-date',
  templateUrl: './date.component.html',
  styleUrls: ['./date.component.css'],
})
export class DateComponent {
  @Input() date: Date;
  @Input() timeZone?: string;

  getFormattedDate(): string {
    const formatter = Intl.DateTimeFormat(undefined, {
      timeZone: this.timeZone,
      year: 'numeric',
      month: 'numeric',
      day: 'numeric',
      hour: 'numeric',
      minute: 'numeric',
    });
    return formatter.format(this.date);
  }
}

Тоже ничего особенного: два инпута, и в зависимости от них функция getFormattedDate вычисляет выводимый текст. Используется то самое API Intl.DateTimeFormat для форматирования дат.

Каким образом компонент app-date должен узнать, что он находится в каком-то там контексте, и должен на него реагировать? Очень просто: он просто достаёт директиву из DI-контекста! Заинжектим директиву.

import { Component, inject, Input } from '@angular/core';
import { DateTimeZoneDirective } from '../date-time-zone.directive';

@Component({
  selector: 'app-date',
  templateUrl: './date.component.html',
  styleUrls: ['./date.component.css'],
})
export class DateComponent {
  @Input() date: Date;
  @Input() timeZone?: string;

  timeZoneContext = inject(DateTimeZoneDirective, { optional: true });

  getFormattedDate() {
    // ...
  }
}

Да, инжектор просто пройдёт по дереву компонентов вверх, и будет искать в нём нашу директиву. Не забываем поставить параметр optional — если вдруг никакой директивы в дереве компонентов не окажется, это спасёт нас от рантайм-ошибки, а вместо директивы просто придёт null.

Поменяем поведение функции getFormattedDate: учтём данные, которые передали в директиву.

// ...
export class DateComponent {
  @Input() date: Date;
  @Input() timeZone?: string;

  timeZoneContext = inject(DateTimeZoneDirective, { optional: true });

  getFormattedDate(): string {
    const formatter = Intl.DateTimeFormat(undefined, {
      // самое интересное тут 👇
      timeZone: this.timeZone ?? this.timeZoneContext?.timeZone,
      year: 'numeric',
      month: 'numeric',
      day: 'numeric',
      hour: 'numeric',
      minute: 'numeric',
    });
    return formatter.format(this.date);
  }
}

В момент, когда нужно указать таймзону, мы сначала заглядываем в инпут, и если там ничего нет, то берём значение таймзоны из директивы-контекста. Если, конечно, есть она. Но если нет и её, то в свойство timeZone просто попадёт undefined — это значит, что дата выведется по текущей пользовательской таймзоне.

Проверяем

Идём в корневой компонент и расписываем в шаблоне вот такие вещи:

<section appDateTimeZone="Europe/Moscow">
  <h3>Europe/Moscow time zone:</h3>
  <p>Now: <app-date [date]="now"></app-date>
</section>

Обращаем внимание на то, что инпутом у app-date мы не пользуемся. Что получили:

Снимок экрана 2024-03-07 в 20.21.09.png

Вроде работает, но пока не впечатляет. Что, если сделать ещё одну section, в которой выводить текущее время, но в таймзоне Красноярска? Добавляем:

<section appDateTimeZone="Europe/Moscow">
  <h3>Europe/Moscow time zone:</h3>
  <p>Now: <app-date [date]="now"></app-date></p>
</section>

<section appDateTimeZone="Asia/Krasnoyarsk">
  <h3>Asia/Krasnoyarsk time zone:</h3>
  <p>Now: <app-date [date]="now"></app-date></p>
</section>

Получаем такое:

Можем пойти дальше: расписать секции через *ngFor и накидать ещё разных таймзон и дат:

<section
  *ngFor="let timeZone of ['Europe/Berlin',
						   'Europe/Moscow',
						   'Asia/Krasnoyarsk',
						   'Asia/Tokyo']"
  [appDateTimeZone]="timeZone"
>
  <h3>{{ timeZone }} time zone:</h3>
  
  <p>Now: <app-date [date]="now"></app-date></p>
  <p>User's birthday: <app-date [date]="user.birthday"></app-date></p>
</section>

Результат:

Снимок экрана 2024-03-07 в 20.29.35.png

И да, напоминаю, если компонент app-date будет не в одном файле с директивой-контекстом, а где-то далеко под семью вложенными компонентами, о своём контексте он не забудет и всё равно его учтёт.

Убедимся: создадим компонент user-info:

<p>Name: {{ user.name }}</p>
<p>Birthday: <app-date [date]="user.birthday"></app-date></p>

Воспользуемся:

<section
  *ngFor="let timeZone of ['Europe/Moscow', 'Asia/Krasnoyarsk']"
  [appDateTimeZone]="timeZone"
>
  <h3>{{ timeZone }} time zone:</h3>
  <p>Now: <app-date [date]="now"></app-date></p>

  <app-user-info [user]="user"></app-user-info>
</section>

Таймзоны по-прежнему работают, хотя в новый компонент мы их не передавали:

Снимок экрана 2024-03-07 в 20.37.45.png


На этом всё. Вот пример на stackblitz — можно потрогать. Следующей будет директива-имплементация.

Ещё я в телеграм-канал иногда пишу.

Спасибо за внимание.

Tags:
Hubs:
Total votes 10: ↑10 and ↓0+10
Comments3

Articles