Этим летом мы с Ромой запустили серию твитов с полезными советами и приемами по 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
или же простую интерполяцию с вызовом функции. Когда привыкаешь к такому, обратной дороги уже нет! Подробнее читайте в этой статье.
На этом все. Надеюсь, мои советы будут вам полезны. Приятного кодинга!