С помощью Angular можно сделать всё что угодно. Или почти всё. Но иногда это коварное «почти» приводит к тому, что разработчик губит время, создавая обходные решения, или пытаясь понять, почему что-то происходит, или почему что-то не работает так, как ожидается.
Автор статьи, перевод которой мы сегодня публикуем, говорит, что хочет поделиться советами, которые помогут Angular-разработчикам сэкономить немного времени. Он собирается рассказать о подводных камнях Angular, с которыми ему (и не только ему) довелось встретиться.
№1. Пользовательская директива, которую вы применили, не работает
Итак, вы обнаружили симпатичную директиву Angular сторонней разработки и решили использовать её со стандартными элементами в шаблоне Angular. Замечательно! Попробуем это сделать:
<span awesomeTooltip="'Tooltip text'"> </span>
Вы запускаете приложение… И ничего не происходит. Вы, как любой нормальный опытный программист, заглядываете в консоль инструментов разработчика Chrome. И ничего там не видите. Директива не работает и Angular хранит молчание.
Потом какая-нибудь светлая голова из вашей команды решает поместить директиву в квадратные скобки.
<span [awesomeTooltip]="'Tooltip text'"> </span>
После этого, потеряв немного времени, мы видим в консоли следующее.
Вот в чём дело: мы просто забыли импортировать модуль с директивой
Сейчас причина проблемы совершенно очевидна: мы просто забыли импортировать модуль директивы в модуль приложения Angular.
Отсюда выводим важное правило: никогда не используйте директивы без квадратных скобок.
Поэкспериментировать с директивами можно здесь.
№2. ViewChild возвращает undefined
Предположим, вы создали ссылку на элемент для ввода текста, описанный в шаблоне Angular.
<input type="text" name="fname" #inputTag>
Вы собираетесь, с помощью функции RxJS fromEvent, создать поток, в который будет попадать то, что вводится в поле. Для этого вам понадобится ссылка на поле ввода, которую можно получить с помощью декоратора Angular ViewChild
:
class SomeComponent implements AfterViewInit {
@ViewChild('inputTag') inputTag: ElementRef;
ngAfterViewInit(){
const input$ = fromEvent(this.inputTag.nativeElement, 'keyUp')
}
...
}
Здесь мы, с помощью функции RxJS fromEvent
, создаём поток, в который будут попадать данные, вводимые в поле.
Испытаем этот код.
Ошибка
Что случилось?
Собственно говоря, здесь применимо следующее правило: если ViewChild
возвращает undefined
— поищите в шаблоне *ngIf
.
<div *ngIf="someCondition">
<input type="text" name="fname" #inputTag>
</div>
Вот он — виновник проблемы.
Кроме того, проверьте шаблон на наличие в нём других структурных директив или ng-template
выше проблемного элемента.
Рассмотрим возможные варианты решения этой проблемы.
▍Вариант решения проблемы №1
Можно просто скрыть элемент шаблона в том случае, если он вам не нужен. В этом случае элемент всегда будет продолжать существовать и ViewChild
сможет вернуть ссылку на него в хуке ngAfterViewInit
.
<div [hidden]="!someCondition">
<input type="text" name="fname" #inputTag>
</div>
▍Вариант решения проблемы №2
Ещё один способ решения этой проблемы заключается в использовании сеттеров.
class SomeComponent {
@ViewChild('inputTag') set inputTag(input: ElementRef|null) {
if(!input) return;
this.doSomething(input);
}
doSomething(input) {
const input$ = keysfromEvent(input.nativeElement, 'keyup');
...
}
}
Тут, как только Angular назначает свойству inputTag
определённое значение, мы создаём поток из данных, введённых в поле ввода.
Вот пара полезных ресурсов, имеющих отношение к этой проблеме:
- Здесь можно почитать о том, что результаты работы
ViewChild
в Angular 8 могут быть статическими и динамическими. - Если вы испытываете сложности при работе с RxJs — взгляните на этот видеокурс.
№3. Выполнение кода при обновлении списка, сгенерированного с помощью *ngFor (после того, как элементы появились в DOM)
Предположим, у вас имеется какая-нибудь интересная пользовательская директива для организации прокручиваемых списков. Вы собираетесь применить её к списку, который создан с помощью директивы Angular *ngFor
.
<div *ngFor="let item of itemsList; let i = index;"
[customScroll]
>
<p *ngFor="let item of items" class="list-item">{{item}}</p>
</div>
Обычно в подобных случаях при обновлении списка нужно вызвать нечто вроде scrollDirective.update
для настройки поведения скроллинга с учётом изменений, произошедших в списке.
Может показаться, что это можно сделать с помощью хука ngOnChanges
:
class SomeComponent implements OnChanges {
@Input() itemsList = [];
@ViewChild(CustomScrollDirective) scroll: CustomScrollDirective;
ngOnChanges(changes) {
if (changes.itemsList) {
this.scroll.update();
}
}
...
}
Правда, тут мы встречаемся с проблемой. Хук вызывается до вывода обновлённого списка браузером. В результате пересчёт параметров директивы для организации прокрутки списка выполняется неправильно.
Как выполнить вызов сразу после того, как *ngFor
завершит работу?
Сделать это можно, выполнив следующие 3 простых шага:
▍Шаг №1
Поместим ссылки на элементы туда, где применяется *ngFor
(#listItems
).
<div [customScroll]>
<p *ngFor="let item of items" #listItems>{{item}}</p>
</div>
▍Шаг №2
Получим список этих элементов с помощью декоратора Angular ViewChildren
. Он возвращает сущность типа QueryList
.
▍Шаг №3
Класс QueryList
имеет свойство changes, предназначенное только для чтения, которое выдаёт события каждый раз, когда меняется список.
class SomeComponent implements AfterViewInit {
@Input() itemsList = [];
@ViewChild(CustomScrollDirective) scroll: CustomScrollDirective;
@ViewChildren('listItems') listItems: QueryList<any>;
private sub: Subscription;
ngAfterViewInit() {
this.sub = this.listItems.changes.subscribe(() => this.scroll.update())
}
...
}
Теперь проблема решена. Здесь можно поэкспериментировать с соответствующим примером.
№4. Проблемы с ActivatedRoute.queryParam, возникающие в том случае, когда запросы можно выполнять без параметров
Понять суть этой проблемы нам поможет следующий код.
// app-routing.module.ts
const routes: Routes = [
{path: '', redirectTo: '/home', pathMatch: 'full'},
{path: 'home', component: HomeComponent},
];
@NgModule({
imports: [RouterModule.forRoot(routes)], // Фрагмент #1
exports: [RouterModule]
})
export class AppRoutingModule { }
//app.module.ts
@NgModule({
...
bootstrap: [AppComponent] // Фрагмент #2
})
export class AppModule { }
// app.component.html
<router-outlet></router-outlet> // Фрагмент #3
// app.component.ts
export class AppComponent implements OnInit {
title = 'QueryTest';
constructor(private route: ActivatedRoute) { }
ngOnInit() {
this.route.queryParams
.subscribe(params => {
console.log('saveToken', params); // Фрагмент #4
});
}
}
К некоторым фрагментам этого кода сделаны комментарии вида Фрагмент #x
. Рассмотрим их:
- В главном модуле приложения мы определили маршруты и добавили туда
RouterModule
. Маршруты настроены так, что если в URL не предоставлен маршрут, мы перенаправляем пользователя на страницу/home
. - В качестве компонента для загрузки мы указываем в главном модуле
AppComponent
. AppComponent
использует<router-outlet>
для вывода соответствующих компонентов маршрута.- Теперь — самое важное. Нам нужно получить
queryParams
для маршрута из URL
Предположим, что нам достался такой URL:
https://localhost:4400/home?accessToken=someTokenSequence
В таком случае queryParams
будет выглядеть так:
{accessToken: ‘someTokenSequence’}
Посмотрим на работу всего этого в браузере.
Тестирование приложения, в котором реализована система маршрутизации
Тут у вас может появиться вопрос о сути проблемы. Параметры мы получили, всё работает как ожидается…
Присмотритесь к приведённой выше копии экрана браузера, и к тому, что выводится в консоль. Тут можно заметить, что объект queryParams
выдаётся дважды. Первый объект оказывается пустым, он выдаётся в ходе процесса инициализации маршрутизатора Angular. Только после этого мы получаем объект, в котором содержатся параметры запроса (в нашем случае — {accessToken: ‘someTokenSequence’}
).
Проблема заключается в том, что если в URL не будет никаких параметров запроса, то маршрутизатор не выдаст ничего. То есть — после выдачи первого пустого объекта второй объект, тоже пустой, который мог бы указывать на отсутствие параметров, выдан не будет.
Второй объект при выполнении запроса без параметров не выдаётся
В результате оказывается, что если код ожидает второго объекта, из которого он может получить данные запроса, то он не будет запущен в том случае, если в URL не было параметров запроса.
Как решить эту проблему? Здесь нам может помочь RxJs. Мы создадим на основе ActivatedRoute.queryParams
два наблюдаемых объекта. Как обычно — рассмотрим пошаговое решение проблемы.
▍Шаг №1
Первый наблюдаемый объект, paramsInUrl$
, будет выдавать данные в том случае, если значение queryParams
не является пустым:
export class AppComponent implements OnInit {
constructor(private route: ActivatedRoute,
private locationService: Location) {
}
ngOnInit() {
// Отфильтровываем первое пустое значение
// И повторно выдаём значение с параметрами
const paramsInUrl$ = this.route.queryParams.pipe(
filter(params => Object.keys(params).length > 0)
);
...
}
}
▍Шаг №2
Второй наблюдаемый объект, noParamsInUrl$
, будет выдавать пустое значение только в том случае, если в URL не было обнаружено параметров запроса:
export class AppComponent implements OnInit {
title = 'QueryTest';
constructor(private route: ActivatedRoute,
private locationService: Location) {
}
ngOnInit() {
...
// Выдаёт пустой объект, но только в том случае, если в URL
// не было обнаружено параметров запроса
const noParamsInUrl$ = this.route.queryParams.pipe(
filter(() => !this.locationService.path().includes('?')),
map(() => ({}))
);
...
}
}
▍Шаг №3
Теперь скомбинируем наблюдаемые объекты с помощью функции RxJS merge:
export class AppComponent implements OnInit {
title = 'QueryTest';
constructor(private route: ActivatedRoute,
private locationService: Location) {
}
ngOnInit() {
// Отфильтровываем первое пустое значение
// И повторно выдаём значение с параметрами
const paramsInUrl$ = this.route.queryParams.pipe(
filter(params => Object.keys(params).length > 0)
);
// Выдаёт пустой объект, но только в том случае, если в URL
// не было обнаружено параметров запроса
const noParamsInUrl$ = this.route.queryParams.pipe(
filter(() => !this.locationService.path().includes('?')),
map(() => ({}))
);
const params$ = merge(paramsInUrl$, noParamsInUrl$);
params$.subscribe(params => {
console.log('saveToken', params);
});
}
}
Теперь наблюдаемый объект param$
выдаёт значение лишь один раз — независимо от того, содержится ли что-нибудь в queryParams
(выдаётся объект с параметрами запроса) или нет (выдаётся пустой объект).
Поэкспериментировать с этим кодом можно здесь.
№5. Медленная работа страниц
Предположим, у вас имеется компонент, который выводит некие отформатированные данные:
// home.component.html
<div class="wrapper" (mousemove)="mouseCoordinates = {x: $event.x, y: $event.y}">
<div *ngFor="let item of items">
<span>{{formatItem(item)}}</span>
</div>
</div>
{{mouseCoordinates | json}}
// home.component.ts
export class HomeComponent {
items = [1, 2, 3, 4, 5, 6];
mouseCoordinates = {};
formatItem(item) {
// Имитация тяжёлых вычислений
const t = Array.apply(null, Array(5)).map(() => 1);
console.log('formatItem');
return item + '%';
}
}
Этот компонент решает две задачи:
- Он выводит массив элементов (предполагается, что эта операция выполняется однократно). Кроме того, он форматирует то, что выводится на экран, вызывая метод
formatItem
. - Он выводит координаты мыши (это значение, очевидно, будет обновляться очень часто).
Вы не ожидаете того, что у этого компонента будут какие-то проблемы с производительностью. Поэтому запускаете тест производительности только для того, чтобы соблюсти все формальности. Однако в ходе этого теста проявляются некие странности.
Много вызовов formatItem и довольно большая нагрузка на процессор
В чём же дело? А дело в том, что когда Angular перерисовывает шаблон, он вызывает и все функции из шаблона (в нашем случае — функцию formatItem
). В результате, если в функциях шаблона выполняются какие-нибудь тяжёлые вычисления, это создаёт нагрузку на процессор и влияет на то, как пользователи будут воспринимать соответствующую страницу.
Как это исправить? Достаточно выполнить вычисления, выполняемые в formatItem
, заранее, и вывести на страницу уже готовые данные.
// home.component.html
<div class="wrapper" (mousemove)="mouseCoordinates = {x: $event.x, y: $event.y}">
<div *ngFor="let item of displayedItems">
<span>{{item}}</span>
</div>
</div>
{{mouseCoordinates | json}}
// home.component.ts
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.sass']
})
export class HomeComponent implements OnInit {
items = [1, 2, 3, 4, 5, 6];
displayedItems = [];
mouseCoordinates = {};
ngOnInit() {
this.displayedItems = this.items.map((item) => this.formatItem(item));
}
formatItem(item) {
console.log('formatItem');
const t = Array.apply(null, Array(5)).map(() => 1);
return item + '%';
}
}
Теперь тест производительности выглядит гораздо приличнее.
Всего 6 вызовов formatItem и низкая нагрузка на процессор
Теперь приложение работает гораздо лучше. Но у применённого здесь решения есть некоторые особенности, не всегда приятные:
- Так как мы выводим координаты мыши в шаблоне — возникновение события
mousemove
всё ещё приводит к запуску проверки изменений. Но, так как нам нужны координаты мыши, избавиться от этого мы не можем. - Если же в обработчике события
mousemove
должны лишь выполняться некие вычисления (которые не влияют на то, что выводится на странице), тогда, чтобы ускорить приложение, можно поступить следующим образом:- Можно, внутри функции-обработчика события, использовать
NgZone.runOutsideOfAngular
. Это позволяет предотвратить запуск проверки изменений при возникновении событияmousemove
(это повлияет исключительно на данный обработчик). - Можно предотвратить zone.js-патч для некоторых событий, использовав следующую строку кода в polyfills.ts. Это подействует на всё Angular-приложение.
- Можно, внутри функции-обработчика события, использовать
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // отключить патч для указанных событий
Если вас интересуют вопросы повышения производительности Angular-приложений — вот, вот, вот и вот — полезные материалы об этом.
Итоги
Сейчас, когда вы прочли эту статью, в вашем арсенале Angular-разработчика должно появиться 5 новых инструментов, с помощью которых вы сможете решать некоторые распространённые проблемы. Надеемся, советы, которые вы здесь нашли, помогут вам сэкономить немного времени.
Уважаемые читатели! Знаете ли вы о чём-то таком, что, при разработке Angular-приложений, помогает экономить время?
Автор: ru_vds