Обход подводных камней Angular и экономия времени

в 9:30, , рубрики: angular, javascript, Блог компании RUVDS.com, разработка, Разработка веб-сайтов

С помощью Angular можно сделать всё что угодно. Или почти всё. Но иногда это коварное «почти» приводит к тому, что разработчик губит время, создавая обходные решения, или пытаясь понять, почему что-то происходит, или почему что-то не работает так, как ожидается.

Обход подводных камней Angular и экономия времени - 1

Автор статьи, перевод которой мы сегодня публикуем, говорит, что хочет поделиться советами, которые помогут Angular-разработчикам сэкономить немного времени. Он собирается рассказать о подводных камнях Angular, с которыми ему (и не только ему) довелось встретиться.

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

Итак, вы обнаружили симпатичную директиву Angular сторонней разработки и решили использовать её со стандартными элементами в шаблоне Angular. Замечательно! Попробуем это сделать:

<span awesomeTooltip="'Tooltip text'"> </span>

Вы запускаете приложение… И ничего не происходит. Вы, как любой нормальный опытный программист, заглядываете в консоль инструментов разработчика Chrome. И ничего там не видите. Директива не работает и Angular хранит молчание.

Потом какая-нибудь светлая голова из вашей команды решает поместить директиву в квадратные скобки.

<span [awesomeTooltip]="'Tooltip text'"> </span>

После этого, потеряв немного времени, мы видим в консоли следующее.

Обход подводных камней Angular и экономия времени - 2

Вот в чём дело: мы просто забыли импортировать модуль с директивой

Сейчас причина проблемы совершенно очевидна: мы просто забыли импортировать модуль директивы в модуль приложения 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, создаём поток, в который будут попадать данные, вводимые в поле.
Испытаем этот код.

Обход подводных камней Angular и экономия времени - 3

Ошибка

Что случилось?

Собственно говоря, здесь применимо следующее правило: если 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. Рассмотрим их:

  1. В главном модуле приложения мы определили маршруты и добавили туда RouterModule. Маршруты настроены так, что если в URL не предоставлен маршрут, мы перенаправляем пользователя на страницу /home.
  2. В качестве компонента для загрузки мы указываем в главном модуле AppComponent.
  3. AppComponent использует <router-outlet> для вывода соответствующих компонентов маршрута.
  4. Теперь — самое важное. Нам нужно получить queryParams для маршрута из URL

Предположим, что нам достался такой URL:

https://localhost:4400/home?accessToken=someTokenSequence

В таком случае queryParams будет выглядеть так:

{accessToken: ‘someTokenSequence’}

Посмотрим на работу всего этого в браузере.

Обход подводных камней Angular и экономия времени - 4

Тестирование приложения, в котором реализована система маршрутизации

Тут у вас может появиться вопрос о сути проблемы. Параметры мы получили, всё работает как ожидается…

Присмотритесь к приведённой выше копии экрана браузера, и к тому, что выводится в консоль. Тут можно заметить, что объект queryParams выдаётся дважды. Первый объект оказывается пустым, он выдаётся в ходе процесса инициализации маршрутизатора Angular. Только после этого мы получаем объект, в котором содержатся параметры запроса (в нашем случае — {accessToken: ‘someTokenSequence’}).

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

Обход подводных камней Angular и экономия времени - 5

Второй объект при выполнении запроса без параметров не выдаётся

В результате оказывается, что если код ожидает второго объекта, из которого он может получить данные запроса, то он не будет запущен в том случае, если в 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 + '%';
    }

}

Этот компонент решает две задачи:

  1. Он выводит массив элементов (предполагается, что эта операция выполняется однократно). Кроме того, он форматирует то, что выводится на экран, вызывая метод formatItem.
  2. Он выводит координаты мыши (это значение, очевидно, будет обновляться очень часто).

Вы не ожидаете того, что у этого компонента будут какие-то проблемы с производительностью. Поэтому запускаете тест производительности только для того, чтобы соблюсти все формальности. Однако в ходе этого теста проявляются некие странности.

Обход подводных камней Angular и экономия времени - 6

Много вызовов 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 + '%';
    }
}

Теперь тест производительности выглядит гораздо приличнее.

Обход подводных камней Angular и экономия времени - 7

Всего 6 вызовов formatItem и низкая нагрузка на процессор

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

  • Так как мы выводим координаты мыши в шаблоне — возникновение события mousemove всё ещё приводит к запуску проверки изменений. Но, так как нам нужны координаты мыши, избавиться от этого мы не можем.
  • Если же в обработчике события mousemove должны лишь выполняться некие вычисления (которые не влияют на то, что выводится на странице), тогда, чтобы ускорить приложение, можно поступить следующим образом:
    1. Можно, внутри функции-обработчика события, использовать NgZone.runOutsideOfAngular. Это позволяет предотвратить запуск проверки изменений при возникновении события mousemove (это повлияет исключительно на данный обработчик).
    2. Можно предотвратить zone.js-патч для некоторых событий, использовав следующую строку кода в polyfills.ts. Это подействует на всё Angular-приложение.

* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // отключить патч для указанных событий

Если вас интересуют вопросы повышения производительности Angular-приложений — вот, вот, вот и вот — полезные материалы об этом.

Итоги

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

Уважаемые читатели! Знаете ли вы о чём-то таком, что, при разработке Angular-приложений, помогает экономить время?

Обход подводных камней Angular и экономия времени - 8

Автор: ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js