company_banner

5 советов для прокачки своих навыков в Angular

    Этим летом мы с Ромой запустили серию твитов с полезными советами и приемами по Angular. Сообщество тепло встретило эту инициативу, и я решил написать обобщающую статью. Вот мои 5 рекомендаций, которыми хочется поделиться с разработчиками. Эти советы будут подкреплены конкретными примерами из моего твиттера. Они помогут вам поднять свои навыки или как минимум дадут пару практических приемов.

    1. Разберитесь в работе механизма проверки изменений

    В интернете множество отличных углубленных статей про проверку изменений в Angular. Например, вот эта. Так что давайте просто освежим основы и перейдем к советам.

    Основы

    В Angular два режима проверки изменений: Default и OnPush. Первый запускает проверку на каждый tick внутри приложения. Этим управляет Zone.js, которая патчит все асинхронные операции вроде подписок на события и промисов. Второй режим помечает view для проверки, только если в нем случилось слушаемое событие или изменились входные данные.

    Default vs OnPush

    По правде говоря, вряд ли есть причины использовать режим Default. Просто пишите код так, как задумано принципами фреймворка, и вы не попадете в неприятности с OnPush. Это означает никогда не мутировать данные. Если ваши входные объекты или массивы немутабельные, OnPush подцепит изменения и обновит вью.

    Подписки на события через @HostListener Angular заметит и в OnPush. Но что делать, если вы используете RxJS? Всегда можно заинжектить ChangeDetectorRef и вызвать markForCheck(), когда потребуется. Декларативным решением тут будет async пайп, если стрим в итоге доходит до шаблона. Он сам запустит проверку и возьмет на себя отписку. 

    Вам наверняка попадался такой паттерн:

    <div *ngIf="stream$ | async as result">
        …
    </div>

    Но что делать, если вам важны также falsy-результаты? Можно выкинуть всю логику на условие из ngIf и сделать свою простую структурную директиву. Она будет использоваться, только чтобы объявить контекст для вложенного вью:

    Код
    @Directive({
      selector: "[ngLet]"
    })
    export class LetDirective<T> {
      @Input()
      ngLet: T;
    
      constructor(
        @Inject(ViewContainerRef) container: ViewContainerRef,
        @Inject(TemplateRef) templateRef: TemplateRef<LetContext<T>>
      ) {
        container.createEmbeddedView(templateRef, new LetContext<T>(this));
      }
    }

    NgZone

    Если у вас нет возможности полностью перейти на OnPush, можно провести оптимизации. Заинжектите NgZone и выполняйте нагруженные операции в .runOutsideAngular(). Таким образом не будет возникать лишних тиков в механизме проверки изменений. Даже компоненты в режиме Default не будут реагировать на эти операции. Это уместно делать для частых событий, таких как mousemove или scroll. Это можно сделать декларативно в RxJS-стримах с помощью двух операторов: один — для выхода из зоны, другой — для возврата в нее, чтобы запустить проверку изменений:

    Код
    class ZonefreeOperator<T> implements Operator<T, T> {
      constructor(private readonly zone: NgZone) {}
    
      call(observer: Observer<T>, source: Observable<T>): TeardownLogic {
        return this.zone.runOutsideAngular(
          () => source.subscribe(observer)
        );
      }
    }
    
    export function zonefull<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
      return map(value => zone.run(() => value));
    }
    
    export function zonefree<T>(zone: NgZone): MonoTypeOperatorFunction<T> {
      return source => source.lift(new ZonefreeOperator(zone));
    }

    Еще один вариант, работающий с @HostListener, — создать свой EventManagerPlugin. Мы выпустили open-source-библиотеку под названием ng-event-plugins. Она позволяет отсеивать лишние проверки изменений. Подробнее об этом читайте в этой статье.

    2. Хорошенько разберитесь в RxJS

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

    — до сложных комбинаций операторов. Например, чтобы понять, настоящий у вас скролл или инерционный:

    Только посмотрите, как просто создать шапку, исчезающую при прокрутке сайта вниз. Совсем немного CSS и базовый RxJS:

    Код
    @Directive({
      selector: "[sticky]",
      providers: [DestroyService]
    })
    export class StickyDirective {
      constructor(
        @Inject(DestroyService) destroy$: Observable<void>,
        @Inject(WINDOW) windowRef: Window,
        renderer: Renderer2,
        { nativeElement }: ElementRef<HTMLElement>
      ) {
        fromEvent(windowRef, "scroll")
          .pipe(
            map(() => windowRef.scrollY),
            pairwise(),
            map(([prev, next]) => next < THRESHOLD || prev > next),
            distinctUntilChanged(),
            startWith(true),
            takeUntil(destroy$)
          )
          .subscribe(stuck => {
            renderer.setAttribute(
              nativeElement, 
              "data-stuck", 
              String(stuck)
            );
          });
      }
    }

    Сложно переоценить знание RxJS для Angular-разработчика в долгосрочной перспективе. Простой на первый взгляд RxJS требует некоего смещения парадигмы, чтобы начать мыслить потоками. Но, когда вы это сделаете, ваш код станет аккуратнее и легче в поддержке.

    Тут особо нечего советовать, кроме как больше практиковаться . Если видите ситуацию, которую можно решить через RxJS, — попытайтесь сделать это. Избегайте сайд-эффектов и вложенных подписок. Держите свои стримы в порядке и следите за утечками памяти (подробнее об этом дальше).

    3. Выжимайте максимум из TypeScript

    Мы все пишем Angular-приложения на TypeScript. Но, чтобы получить максимальную пользу, нужно задействовать его целиком. Я редко вижу проекты с включенным strict: true. Вам определенно следует сделать это. Оно спасет вас от множества cannot read property of null и undefined is not a function.

    Дженерики

    В TypeScript существуют дженерики — для случаев, когда тип, с которым мы работаем, неизвестен. Комбинация дженериков, перегрузок и сужения типов позволит вам сделать крутой API. Почти никогда не придется делать тайпкаст. Посмотрите на этот пример типизированного RxJS-метода fromEvent:

    Код
    // Тип события с конкретным currentTarget
    export type EventWith<
      E extends Event,
      T extends FromEventTarget<E>
    > = E & {
      readonly currentTarget: T;
    };
    
    // Типизированный вариант fromEvent
    export function typedFromEvent<
      E extends keyof GlobalEventHandlersEventMap,
      T extends FromEventTarget<EventWith<GlobalEventHandlersEventMap[E], T>>
    >(
      target: T,
      event: E,
      options: AddEventListenerOptions = {},
    ): Observable<EventWith<GlobalEventHandlersEventMap[E], T>> {
      return fromEvent(target, event, options);
    }

    С ним вы будете уверены, что событие имеет конкретный тип, а currentTarget — это элемент, на котором вы его слушаете.

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

    Есть немало статей о продвинутой типизации и всевозможных трюках с TypeScript. Я очень советую расширять свои знания в этой области, это поможет вам писать надежный код. Мой последний совет: не используйте any. Если вы не можете применить дженерик, более безопасным выбором будет unknown.

    Декораторы

    Не забывайте про другие инструменты TypeScript, такие как декораторы. При умелом использовании они могут значительно улучшить ваш код. К примеру, бывают ситуации, когда тип переменной корректный, но значение не подходит: отрицательное или дробное число не подходит для обозначения количества, хотя тип и там, и там — number. TypeScript такое не отловит, но мы можем защитить компоненты в runtime с помощью декоратора:

    Код
    export function assert<T, K extends keyof T>(
      assertion: (input: T[K]) => boolean,
      messsage: string
    ): PropertyDecorator {
      return (target, key) => {
        Object.defineProperty(target, key, {
          set(this: T, initialValue: T[K]) {
            let currentValue = initialValue;
    
            Object.defineProperty(this, key, {
              get(): T[K] {
                return currentValue;
              },
              set(this: T, value: T[K]) {
                console.assert(assertion(value), messsage);
                currentValue = value;
              }
            });
          }
        });
      };
    }

    Вы знали, что декорированный абстрактный класс не нуждается в пробросе аргументов в super()? Angular сам сделает это за вас, если не использовать конструктор в дочернем классе:

    Еще один хороший пример для декораторов — переиспользуемая логика обработки. Посмотрите кусок кода из нашей библиотеки для Web Audio API в Angular, где мы превращаем декларативный байндинг в императивные нативные команды с помощью строго типизированного декоратора. Подробнее про саму библиотеку можно почитать тут.

    4. Dependency Injection. Используйте его почаще

    DI — одна из причин, почему Angular такой мощный фреймворк. Возможно, это даже главная причина. Но слишком часто его не используют по полной.

    Могу порекомендовать нашу статью, посвященную DI, чтобы освоиться с этим инструментом.

    RxJS

    Что касается практических советов, выше я уже говорил быть внимательными к утечкам памяти в RxJS. Главным образом это означает, что, если вы подписались на стрим руками, вам же и придется от него отписаться. Идиоматическим решение в Angular будет инкапсуляция подобной логики в сервис:

    Код
    @Injectable()
    export class DestroyService extends Subject<void> implements OnDestroy {
        ngOnDestroy() {
            this.next();
            this.complete();
        }
    }

    Вы также можете создавать общие стримы и добавлять их в DI. Нет необходимости создавать отдельные потоки на базе requestAnimationFrame в вашем приложении. Создайте токен и переиспользуйте его. Вы даже можете заложить в него операторы для выхода из зоны, описанные выше:

    Токены

    DI — хороший инструмент для повышения абстрактности вашего кода. Если вы не зависите от глобальных объектов вроде window или navigator — ваше приложение готово к использование в Angular Universal в серверном окружении. Такой код просто тестируется, так как все его зависимости легко подменить на заглушки. Глобальные объекты без труда превращаются в токены. Внутри фабрики при объявлении токена нам доступен глобальный инжектор. Нам потребуется всего пара строк для создания WINDOW токена на базе встроенного DOCUMENT:

    Код
    export const WINDOW = new InjectionToken<Window>(
      'An abstraction over global window object',
      {
        factory: () => {
          const {defaultView} = inject(DOCUMENT);
    
          if (!defaultView) {
            throw new Error('Window is not available');
          }
    
          return defaultView;
        },
      },
    );

    Чтобы не тратить на это время, используйте нашу open-source-библиотеку, где мы уже реализовали многие такие токены. А для Angular Universal есть ее библиотека-сестра с качественными заглушками. Не стесняйтесь, пишите нам, если вам нужен еще какой-то токен.

    Токены и фабрики дают массу возможностей. В сочетании с иерархической структурой DI они позволяют сделать приложение очень модульным. Можете почитать больше про использование провайдеров в этой статье.

    5. Бросайте императора в бездну, как Вейдер

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

    Геттеры

    Что это означает — «писать декларативный код»? В первую очередь постарайтесь использовать ngOnChanges как можно реже. Это плохо типизированный сайд-эффект, который нужен, по сути, только когда надо выполнить какое-то действие при изменении нескольких входных значений. 

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

    Производительность и геттеры — тема для отдельной статьи, до которой я надеюсь вскоре добраться. А пока возьмите за правило не создавать новых объектов или массивов внутри геттеров. Это существенно более затратная операция, чем арифметика или сложение строк. Если вам требуется пересоздать объект, воспользуйтесь техникой мемоизации — к примеру, чистым пайпом.

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

    Template reference variables

    Вместо ручного запрашивания элементов из шаблона в Angular предусмотрен декоратор @ViewChild. Однако зачастую он не нужен, если можно завернуть передачу элемента в шаблоне:

    <input #input>
    <button (click)="onClick(input)">Focus</button>

    Здесь мы передаем template reference variable непосредственно в метод, где она нужна. Так код нашего компонента остается чище. Думайте об этом как о подобии замыкания в шаблоне. 

    Но что, если мы хотим получить DOM-элемент, который является компонентом? Мы могли бы написать @ViewChild(MyComponent, {read: ElementRef}), но мы обойдемся без поля класса, если создадим директиву с exportAs:

    Код
    @Directive({
        selector: '[element]',
        exportAs: 'elementRef',
    })
    export class ElementDirective<T extends Element> extends ElementRef<T> {
        constructor(@Inject(ElementRef) {nativeElement}: ElementRef<T>) {
            super(nativeElement);
        }
    }

    Динамический контент

    Люди часто используют ComponentFactoryResolver для императивного создания динамических компонентов. Зачем, если есть директива ngComponentOutlet? Потому что так мы получим доступ к экземпляру компонента и сможем передать в него данные. Хороший способ решения подобной задачи — опять же, Dependency Injection. ngComponentOutlet позволяет передать Injector, который мы создадим и подложим в него данные через токен.

    По сути, для создания динамического контента у нас есть интерполяция, шаблоны и компоненты. И принципиальной разницы между ними нет. У нас есть контекст и есть его представление.

    Мы уже давно используем этот подход, не зависящий от типа переданного контента. Мы вынесли его в крошечную open-source-библиотеку под названием ng-polymorpheus. Она не делает ничего, кроме передачи контента в соответствующий встроенный инструмент, ngTemplateOutlet, ngContentOutlet или же простую интерполяцию с вызовом функции. Когда привыкаешь к такому, обратной дороги уже нет! Подробнее читайте в этой статье.

    На этом все. Надеюсь, мои советы будут вам полезны. Приятного кодинга!

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

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

      0
      В чем смысл директивы elementRef? Можно же просто обращаться к якорю напрямую:
      <img #element>
      <button (click)="element.focus()"></button>
      
        0
        Если у тебя нативный элемент то да, но если у тебя там компонент, то template reference variable будет ссылаться на его инстанс, а не на его DOM элемент.
          0
          Прошу прощения, не внимательно проскринил статью, приём корректный.
        0
        Если у вас нет возможности полностью перейти на OnPush, можно провести оптимизации.

        А есть какой-то глобальный метод для полного перехода на OnPush? Например отказ от zonejs?
          0

          OnPush всё равно работает с зоной, от неё нет нужды отказываться. Нужно для начала избавиться от всех мутаций данных. А там посмотреть, если где-то не подцепляются изменения — нужно капнуть почему. Чаще всего там что-то изменяется в асинхронных коллбэках. Можно пройтись по всем subscribe, setTimeout, setInterval и тому подобному и глянуть, можно ли там перейти на async пайп в шаблоне. Если нет, то надо заинжектить ChangeDetectorRef и сделать на нём markForCheck().

            0

            OnPush под капотом юзает NgZone, убрав зоны ничего детектиться не будет. Без зон жить можно (и это актуально для Angular Elements), но вся ответственность на CD будет лежать на вас, придется инжектить CDR и вручную вызывать его методы, но можно использовать такие штуки вроде пайпа ngrxPush, аля AsyncPipe работающий без зон, еще есть ngrxLet, аналог ngFor. @ngrx/component снимает с вас значительную часть нагрузки по управлению ручным манипулированием CD (беззональное приложение), но пакет еще находится в стадии тестирования.

            0
            Просто пишите код так, как задумано принципами фреймворка, и вы не попадете в неприятности с OnPush

            Ровно до тех пор не заходите уведомить другие дочерние компоненты родительского компонента о том что "надо бы перерендерится". Проблема в том что при onpush компонент обновиться только если изменятся @Input() (см ниже) или если он сам вызовет markForCheck(), способа сказать angular-у "перерендерь меня полностью со всеми подкомпонентами" до сих пор не существует :( Самый простой и очевидный способ решить проблему через @Input(), но, как указано в статье, надо помнить о том что нельзя просто взять изменить свойство, надо создавать новый объект, в некоторых случаях это неудобно.


            Кроме этого в статье упущен еще один важный момент: если вы изменяете @Input() программно, вы обязаны вызвать markForCheck()иначе angular не поймет что надо обновить компонент. На практике это значит что если вы хотите чтобы ваш компонент работал во всех случаях, то в каждый сеттер каждого @Input() надо пихать проверку действительно ли значение изменилось и вызывать markForCheck() когда это произошло (ну или проверять в ngDoCheck()).

              0

              Именно об этом я и говорю. По принципам фреймворка данные движутся сверху вниз, события снизу вверх. Если вам нужно перерендерить дочерние компоненты, а при этом их входные данные не поменялись — вы делаете что-то не по канонам Angular. У самого был подобный кейс. Поведением внутреннего компонента управляла расположенная выше по дереву директива, чтобы избежать пробрасывания инпутов. Вложенный компонент брал её из DI. Решил я эту задачу добавлением стрима:
              readonly refresh$: Observable<void> = new Subject();
              Директива делала next в него в ngOnChanges, а нижестоящий компонент запускал по этому стриму проверку изменений. Возможно вам такой паттерн тоже подойдёт.


              Ну а программное изменение инпутов компонента — это проделки Императора, смотрите совет №5 :)

                0
                Если вам нужно перерендерить дочерние компоненты, а при этом их входные данные не поменялись — вы делаете что-то не по канонам Angular

                Это обычный очень распространенный кейс:


                <страница которая с сервера объект с дочерними элементам>
                    <заголовок, который используется на многих других страницах и вычисляет статус по дочерними элементам />
                
                    <дочерний элемент №1>
                        <редактировать />
                        <удалить />
                    </дочерний элемент №1>
                
                    <дочерний элемент №2>
                        <редактировать />
                        <удалить />
                    </дочерний элемент №2>
                </страница которая с сервера объект с дочерними элементам>

                Когда дочерний элемент удаляет/изменят сам себя надо
                1) уведомить страницу
                2) перерисовать всё компоненты


                Когда объект действительно сложный гораздо проще было бы сказать "перерендерь меня полностью", но вместо этого приходится создавать глубокую копию*, ну или пробрасывать refresh$, но это как раз (очередной) костыль для решения проблемы, которую должен решать фреймфорк. Более того, refresh$, в отличии от клона, может не работать когда где-то внутри есть сторонние компоненты, который о нем не знают.


                * — еще можно реализовать deep check (обычно оверкил), или фейковый @Input (ничем не лучше refresh$).


                Ну а программное изменение инпутов компонента — это проделки Императора, смотрите совет №5 :)

                Далеко не все можно написать декларативно. Ну а если вы не учитываете что ваш компонент может быть использовать программно, то вы сами роете себе яму чтобы попасть в неё в будущем.


                См: https://github.com/angular/angular/issues/22567

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

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