company_banner

Упрощаем работу с Angular с помощью @taiga-ui/cdk: 5 наших лучших практик

    CDK — базовый пакет библиотеки компонентов Taiga UI. Он не имеет никакой привязки к визуальной составляющей библиотеки, а скорее служит набором полезных инструментов для упрощения создания Angular-приложений.

    Среди всех этих инструментов я выделил мою пятерку фаворитов. Я использую их во всех своих проектах и уже давно не представляю, как писать на Angular без них, потому что они ежедневно экономят мне массу времени.

    Дисклеймер о весе библиотеки

    Перед написанием статьи хотелось бы ответить на вопрос: «Для чего тащить в бандл целый мультитул, когда мне нужна пара функций?»

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

    23 КБ — результат не самый страшный, но и не очень приятный. Но все сущности наших библиотек лежат в отдельных Secondary Entry Point, что делает их полностью tree shakable. Это значит, что такой объем в бандле мы получим только в случае импорта и использования всех сущностей библиотеки в нашем приложении. Если вы импортите пару сущностей — только они и попадут к вам в бандл, добавив к нему в результате меньше 1 КБ.

    tuiPure — продвинутая мемоизация вычислений

    Это декоратор, который можно вешать на геттеры и чистые методы классов. Давайте разберем оба сценария.

    Как геттер

    Можно повесить декоратор tuiPure на геттер. В таком случае он позволяет сделать отложенные вычисления. 

    Пример 1. Скрываем и показываем сороковое число Фибоначчи, когда пользователь нажимает на кнопку

    // template
    <div *ngIf="show">fibonacci(40) = {{ fibonacci40 }}</div>
    
    // component
    @tuiPure
    get fibonacci40(): number {
      return calculateFibonacci(40);
    }

    Когда мы запросим число в первый раз, функция посчитает сороковой элемент Фибоначчи. Все последующие запросы будут сразу возвращать ранее посчитанное число.

    Пример 2. У нас есть компонент pull to refresh, который эмулирует поведение под iOS и Android. Один из его стримов вызывается только для Андроида, а для iOS — нет. Завернем его в getter с pure, и ненужный Observable не будет создан для iOS.

    <tui-mobile-ios-loader
       *ngIf="isIOS; else angroidLoader"
    ></tui-mobile-ios-loader>
    
    <ng-template #angroidLoader>
       <tui-mobile-android-loader
           [style.transform]="loaderTransform$ | async"
       ></tui-mobile-android-loader>
    </ng-template>
    @tuiPure
    get loaderTransform$(): Observable<string> {
        return this.pulling$.pipe(
            map(distance => translateY(Math.min(distance, ANDROID_MAX_DISTANCE))),
        );
    }

    Также можно обратиться к changes от ContentChild / ContentChildren: если мы вызываем такой геттер из шаблона, то уже можем быть уверены, что content готов. При соблюдении порядка также это можно провернуть и с ViewChild / ViewChildren.

    Как метод

    На метод тоже можно повесить декоратор @tuiPure. Тогда он будет работать следующим образом: при первом вызове метода посчитает значение и вернет его. Все последующие вызовы с теми же самыми аргументами будут возвращать уже посчитанный результат. Если аргумент изменится — результат пересчитается.

    Это полезно не только для сложных вычислений, но и в связке с геттером для создания непримитивов — объектов или массивов.

    get filteredItems(): readonly string[] {
       return this.computeFilteredItems(this.items);
    }
    
    @tuiPure
    private computeFilteredItems(items: readonly string[]): readonly string[] {
       return items.filter(someCondition);
    }

    В этом случае можно вызвать геттер из шаблона, один раз отфильтровать items, и он будет возвращать тот же самый массив до тех пор, пока this.itemsне изменится. Это поможет избежать лишних операций пересоздания массива на каждый чих проверки изменений, а также проблем из-за постоянных изменений ссылки на массив при передаче дальше. При этом нам не придется, например, самим синхронизировать состояние в ngOnChanges, если this.items— инпут компонента.

    Документация по tuiPure

    *tuiLet

    Это простая структурная директива для объявления локальных переменных в шаблонах.

    <ng-container *tuiLet="timer$ | async as time">
       <p>Timer value: {{time}}</p>
       <p>
           It can be used many times:
           <tui-badge [value]="time"></tui-badge>
       </p>
       <p>
           It subsribed once and async pipe unsubsribes it after component destroy
       </p>
    </ng-container>

    Вместо *tuiLetможно использовать *ngIf если вам не нужно показывать шаблон при falsy-значении (или если оно не предусмотрено). Но если вы работаете, например, с числами, то 0, скорее всего, является вполне адекватным значением. Тут и поможет *tuiLet 

    Документация по tuiLet

    Метапайпы tuiMapper и tuiFilter

    Мы создали пайп, чтобы не создавать другие пайпы, — tuiMapper.

    Он очень простой. Это pure-пайп, который принимает в себя чистую функцию для преобразования и произвольное количество аргументов к ней. В шаблоне это выглядит так:

    {{value | tuiMapper : mapper : arg1 : arg2 }}

    Также удобно и преобразовывать данные для инпутов компонентов в шаблоне или использовать через *ngIf / *tuiLet:

    <div
        *ngIf="item | tuiMapper : toMarkers : itemIsToday(item) : !!getItemRange(item) as markers"
        class="dots"
    >
        <div class="dot" [tuiBackground]="markers[0]"></div>
        <div
            *ngIf="markers.length > 1"
            class="dot"
            [tuiBackground]="markers[1]"
        ></div>
    </div>

    Добавление цветных маркеров-точек в календарях @taiga-ui/core

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

    А еще у нас есть tuiFilter для удобства фильтрации массивов. Фактически это частный случай маппера, но нам нужен довольно часто. Поскольку это чистый пайп, то никаких проблем с производительностью из-за пересоздания массивов тут нет.

    Документация на mapper / документация на filter

    destroy$

    Это Observable-based сервис, который упрощает процесс отписки в компонентах и директивах.

    @Component({
       // ...
       providers: [TuiDestroyService],
    })
    export class TuiDestroyExample {
       constructor(
         @Inject(TuiDestroyService) 
         private readonly destroy$: Observable<void>
       ) {}
    
       // …
       subscribeSomething() {
           fromEvent(this.element, 'click')
               .pipe(takeUntil(this.destroy$))
               .subscribe(() => {
                   console.log('click');
               });
       }
    }
    

    Все что нам нужно — добавить его в providers компонента и заинжектить в конструкторе. Я предпочитаю писать типы сущностей из DI, которые минимально необходимы в компоненте. Здесь это Observable<void>. Но можно писать и покороче:

    constructor(private destroy$: TuiDestroyService) {}

    Кстати, сервис в такой ситуации привязывается не к лайфсайклу компонента, а к его DI Injector’у. Это может помочь в ситуации, когда нужно подписаться в сервисе или внутри DI фабрики. Такие кейсы довольно редки, но зато TuiDestroyService в них буквально спасает — например, когда мы хотели дергать markForCheck из фабрики токена в статье о DI фокусах для проброса данных. 

    Ссылка на документацию

    Плагины ng-event-plugins

    Фактически это внешняя библиотека ng-event-plugins, которая поставляется вместе с cdk (прямая зависимость, которую не нужно устанавливать отдельно). Она добавляет свои обработчики к менеджеру плагинов Angular. В ней есть несколько очень полезных плагинов, которые добавляют ряд возможностей в шаблоны компонентов.

    Например, .stopи .preventпозволяют декларативно делать stopPropagation и preventDefault на любой прилетающий ивент.

    Было:

    <some-input (mousedown)="handle($event)">
        Choose date
    </some-input>
    export class SomeComponent {
       // …
       handle(event: MouseEvent) {
          event.preventDefault();
          event.stopPropagation();
    
          this.onMouseDown(event);
       }
    }

    Стало:

    <some-input (mousedown.prevent.stop)="onMouseDown()">
        Choose date
    </some-input>

    Или модификатор .silent который позволяет не запускать проверку изменений на событие:

    <div (mousemove.silent)="onMouseMove()">
        Callbacks to mousemove will not trigger change detection
    </div>

    Можно отслеживать ивенты в capture-фазе с помощью .capture:

    <div (click.capture.stop)="onClick()">
        <div (click)="never()">Clicks will be stopped before reaching this DIV</div>
    </div>

    Все это работает и с @HostListener’ами, и с кастомными событиями. Вы можете почитать подробнее в документации ng-event-plugins.

    Итого

    Мы посмотрели ряд сущностей пакета @taiga-ui/cdk. Надеюсь, какие-нибудь из них вам приглянулись и тоже будут помогать во всех дальнейших проектах!

    Кстати, у меня еще есть статья про саму библиотеку Taiga UI, в которой описаны остальные пакеты и общая философия библиотеки.

    TINKOFF
    IT’s Tinkoff — просто о сложном

    Комментарии 4

      0

      Не рассматривали вместо destroy service until destroy? Там поудобнее будет

        +1

        Рассматривали с давних времен, следим за развитием, но к себе не берем.

        Until-destroy завязывается на внутреннюю реализацию Ангуляра, использует приватное API и императивно патчит его сущности. Такие штуки могут ломаться от неожиданного апдейта ангуляра (там и сейчас разные реализации для Ivy и View Engine), могут взорваться при неожиданном желании собраться в SSR или Ionic. Мы к такому не готовы и стараемся выбрать Angular Way везде, где это возможно. Вот в этих двух файлах реализация until-destroy с множеством патчинга и вторжением в кухню ангуляра:

        https://github.com/ngneat/until-destroy/blob/master/src/lib/until-destroyed.ts

        https://github.com/ngneat/until-destroy/blob/master/src/lib/until-destroy.ts

        А вот так выглядит наш TuiDestroyService, использующий один лайфсайкл хук и возможности DI в Angular:

        https://github.com/TinkoffCreditSystems/taiga-ui/blob/main/projects/cdk/services/destroy.service.ts

        Кроме того, наш сервис можно использовать для различных фокусов с DI и отписываться в сервисах/DI-фабриках, если в этом есть нужда. У until-destroy такой возможности нет

        0

        Мы у себя на проекте пришли к соглашению по | async, который, как мне видится, лаконичной альтернативой *tuiLet встроенными средствами. На Верхнем уровне описывается такая структурка:

        <ng-container
          *ngIf="{
            isSomeFactor: (isSomeFactor$ | async) || altValue,
            someUsefulValue: someUsefulValue$ | async | somePipe 
          } as data"
        >
         <!--код компонента-->
        </ng-container>

        Можно использовать в таком виде даже когда используется только один источник где-то в глубине компонента. Так проще понять какие вообще Observable потребляет компонент, да и расширяется список потребляемого добра легко.

          +1
          Да, такой альтернативный подход тоже неплохая идея, но мы его не используем. В этом случае у нас создается новый JS объект контекста на каждую проверку изменений. В целом, ничего сильно страшного в этом нет, но такие пересборки объектов могут постепенно привести к проблемам с перфомансам в очень больших приложениях (например, мы везде пересоздания объектов выносим в Pure, чтобы все скейлилось как можно дольше). Чтобы в дальшейнем не выискивать довольно хитрое узкое горлышко, мы так не делаем с самого начала.

          В целом, хорошая идея для небольшой статьи — замерить такие штуки и определить, на каком масштабе юзера начнут замечать разницу, посмотреть дефолтную стратегию и OnPush

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

        Самое читаемое