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

Этим летом мы с Ромой запустили серию твитов с полезными советами и приемами по Angular. Сообщество тепло встретило эту инициативу, и я решил написать обобщающую статью.

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

249480208609a7a1f7db2d01affcbcbc.png

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

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

Основы

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

Default vs OnPush

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

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

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

    …

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

Код

@Directive({
  selector: "[ngLet]"
})
export class LetDirective {
  @Input()
  ngLet: T;

  constructor(
    @Inject(ViewContainerRef) container: ViewContainerRef,
    @Inject(TemplateRef) templateRef: TemplateRef>
  ) {
    container.createEmbeddedView(templateRef, new LetContext(this));
  }
}

NgZone

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

Код

class ZonefreeOperator implements Operator {
  constructor(private readonly zone: NgZone) {}

  call(observer: Observer, source: Observable): TeardownLogic {
    return this.zone.runOutsideAngular(
      () => source.subscribe(observer)
    );
  }
}

export function zonefull(zone: NgZone): MonoTypeOperatorFunction {
  return map(value => zone.run(() => value));
}

export function zonefree(zone: NgZone): MonoTypeOperatorFunction {
  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,
    @Inject(WINDOW) windowRef: Window,
    renderer: Renderer2,
    { nativeElement }: ElementRef
  ) {
    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 & {
  readonly currentTarget: T;
};

// Типизированный вариант fromEvent
export function typedFromEvent<
  E extends keyof GlobalEventHandlersEventMap,
  T extends FromEventTarget>
>(
  target: T,
  event: E,
  options: AddEventListenerOptions = {},
): Observable> {
  return fromEvent(target, event, options);
}

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

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

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

Декораторы

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

Код

export function assert(
  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 implements OnDestroy {
    ngOnDestroy() {
        this.next();
        this.complete();
    }
}

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

Токены

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

Код

export const WINDOW = new InjectionToken(
  '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 со своим синтаксисом шаблонов и декораторами подталкивает нас к декларативному подходу. Я уже упоминал его. Не будем сейчас вдаваться в подробности о его преимуществах по сравнению с императивным кодом. Я просто дам вам четкий совет: пишите декларативный код. Когда вы к нему привыкнете, то заметите, насколько все стало лучше.

ee5f84ca4fc196adb2d4c0606643a18e.gif

Геттеры

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

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

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

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

Template reference variables

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


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

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

Код

@Directive({
    selector: '[element]',
    exportAs: 'elementRef',
})
export class ElementDirective extends ElementRef {
    constructor(@Inject(ElementRef) {nativeElement}: ElementRef) {
        super(nativeElement);
    }
}

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

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

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

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

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

© Habrahabr.ru