5 вещей, которые я бы хотел знать, когда начинал использовать Angular

от автора

Современный Angular — это мощный фреймворк с множеством возможностей, вместе с которыми приходят и сложные, на первый взгляд, концепции и механизмы. Особенно это заметно тем, кто только начал работу как во фронтэнде в принципе, так и с Angular в частности.

С этой же проблемой столкнулся и я, когда примерно два года назад пришел в Тинькофф на позицию Junior Frontend Developer и погрузился в мир Angular. Поэтому предлагаю вам короткий рассказ о пяти вещах, понимание которых очень облегчило бы мою работу на первых порах.

Dependency Injection (DI)

Первое время я заходил в компонент и видел, что в конструкторе класса есть какие-то аргументы. Я провел небольшой анализ работы методов класса, и стало понятно, что это какие-то внешние зависимости. Но как они попали в класс? В каком месте вызвали конструктор?

Предлагаю сразу разобраться на примере, а для этого нам понадобится класс. Если в «обычном» JavaScript ООП присутствует с определенными «хаками», то вместе с ES6 появился и «настоящий» синтаксис. В Angular прямо из коробки используется TypeScript, в котором синтаксис примерно такой же. Поэтому далее предлагаю использовать его.

Представим, что в нашем приложении существует класс JokerService, который управляет шутками. Метод getJokes() возвращает список шуток. Допустим, мы его используем в трех местах. Как получить шутки в трех разных местах в коде? Есть несколько способов:

  1. Создавать экземпляр класса в каждом месте. Но зачем нам засорять память и создавать столько одинаковых сервисов? А если мест будет 100?
  2. Сделать метод статическим и получать данные с помощью JokerService.getJokes().
  3. Реализовать один из паттернов проектирования. Если нам нужно, чтобы сервис был один на все приложение, то это будет Singleton. Но для этого нужно написать новую логику в классе.

Итак, у нас есть три вполне рабочих варианта. Первый нам не подойдет — в данном случае он неэффективен. Мы не хотим создавать лишние экземпляры, так как они будут полностью идентичны. Остается два варианта.

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

В случае со статическим методом придется передавать настройки с каждым вызовом, так как класс общий для всех мест. То есть в каждый вызов getJokes() мы будем передавать все уникальные для данного места параметры. Конечно, лучше передать их при создании экземпляра и потом просто вызывать метод getJokes().

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

Если вы подумали, что можно просто создать объект и по ключу брать нужный сервис, то могу вас поздравить: вы только что поняли, как в целом работает Dependency Injection. Но давайте немного углубимся.

Чтобы убедиться в необходимости механизма, который поможет нам получать нужные экземпляры, представьте, что JokerService нужны еще два других сервиса, один из которых не обязательный, а второй должен в определенном месте отдавать специальный результат. Это несложно.

Dependency Injection в Angular

Как гласит документация, DI — важный паттерн дизайна приложения. Angular имеет собственный фреймворк для зависимостей, который используется в самом Angular для повышения эффективности и модульности.

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

Пусть синтаксис и файлы с расширением html вас не путают. Каждый компонент в Angular — это обычный объект JavaScript, экземпляр класса. Если говорить общими словами: когда вы вставляете компонент в шаблон — создается экземпляр класса компонента. Соответственно, в этот момент можно передать в конструктор нужные зависимости. А теперь рассмотрим пример:

@Component({     selector: 'jokes',     template: './jokes.template.html', }) export class JokesComponent {     private jokes: Observable<IJoke[]>;      constructor(private jokerService: JokerService) {         this.jokes = this.jokerService.getJokes();     } }

В конструкторе компонента мы просто указываем, что нам нужен JokerService. Мы не создаем его сами. Если будет еще пять компонентов, которые его используют, то все они будут обращаться к одному и тому же экземпляру. Все это позволяет нам экономить время, исключить бойлерплейт и писать очень производительные приложения.

Providers

А теперь предлагаю разобраться с тем случаем, когда нужно получать разные экземпляры сервиса. Для начала взглянем на сам сервис:

@Injectable({     providedIn: 'root', // Здесь мы указываем, куда будет «подставлен» сервис }) export class JokerService {     getJokes(): Observable<IJoke[]> {         // Наша логика получения шуток     } }

Когда сервис один на все приложение, будет достаточно этого варианта. Но что делать, если у нас есть, допустим, две реализации JokerService? Или просто по какой-то причине в определенном компоненте нужен свой экземпляр сервиса? Ответ прост: provider.

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

  • Во все приложение — указываем provideIn: ‘root’ в самом декораторе сервиса.
  • В модуль — указываем провайдер в декораторе сервиса как provideIn: JokesModule или в декораторе модуля @NgModule как providers: [JokerService].
  • В компонент — указываем провайдер в декораторе компонента, как в модуле.

Место выбирается в зависимости от ваших потребностей. С местом разобрались, перейдем к самому механизму. Если мы просто указали provideIn: root в сервисе, это будет эквивалентно следующей записи в модуле:

@NgModule({     // ... здесь другие свойства модуля     providers: [{provide: JokerService, useClass: JokerService}], }) export class JokesModule {}

Это можно прочитать примерно так: «Если запрашивается JokerService, то отдай экземпляр класса JokerService». Отсюда можно получить определенный экземпляр различными способами:

  • По токену — нужно указать InjectionToken и получать сервис по нему. Обратите внимание, что в примерах ниже в provide можно передать этот же токен:

    const JOKER_SERVICE_TOKEN = new InjectionToken<string>('JokerService');  // ... Здесь модуль или компонент  [{provide: JOKER_SERVICE_TOKEN, useClass: JokerService}];

  • По классу — можно подменить класс. Например, мы будем просить JokerService, а отдавать — JokerHappyService:

    [{provide: JokerService, useClass: JokerHappyService}];

  • По значению — можно сразу вернуть нужный экземпляр:

    [{provide: JokerService, useValue: jokerService}];

  • По фабрике — можно подменить класс фабрикой, которая будет создавать нужный экземпляр при обращении:

    [{provide: JokerService, useFactory: jokerServiceFactory}];

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

Кстати, DI работает не только для сервисов, а вообще для любой сущности, которую вы получаете в конструкторе компонента. Это очень мощный механизм, который следует использовать на полную.

Небольшой итог

Для полного понимания предлагаю рассмотреть упрощенный механизм Dependency Injection в Angular по шагам на примере сервиса:

  1. При инициализации приложения у сервиса есть токен. Если мы не указали его специально в провайдере, то это JokerService.
  2. При запросе сервиса в компоненте механизм DI проверяет, существует ли переданный токен.
  3. Если токена не существует, то DI кинет ошибку. В нашем случае токен существует и по нему находится JokerService.
  4. В момент создания компонента в конструктор в качестве аргумента передается экземпляр JokerService.

Change Detection

Мы часто слышим в качестве аргумента для использования фреймворков что-то вроде «Фреймворк все сделает за вас — быстрее и эффективнее. Вам не нужно ни о чем думать. Просто управляйте данными». Возможно, это действительно так в отношении очень простого приложения. Но если приходится работать с пользовательским вводом и постоянно оперировать данными, то знать, как работает процесс обнаружения изменений и рендеринга, просто необходимо.

В Angular за проверку изменений отвечает механизм Change Detection. В результате различных операций — изменение значения свойства класса, завершение асинхронной операции, ответ на HTTP-запрос и так далее — запускается процесс проверки по всему дереву компонентов.

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

Zone.js

Понять, как Angular следит за свойствами класса и синхронными операциями, довольно просто. Но как он отслеживает асинхронные? За это отвечает библиотека Zone.js, созданная одним из разработчиков Angular.

Вот что это такое. Зона сама по себе — это «контекст выполнения», если выражаться грубо — место и состояние, в котором выполняется код. После выполнения асинхронной операции функция обратного вызова (callback) выполняется в той же зоне, где была зарегистрирована. Так Angular узнает, в каком месте произошло изменение и что следует проверить.

Zone.js заменяет своими реализациями практически все нативные асинхронные функции и методы. Поэтому она может отследить момент, когда будет вызван callback асинхронной функции. То есть Zone сообщает Angular, когда и где нужно запустить процесс проверки изменений.

Стратегии обнаружения изменений

Мы разобрались, каким образом Angular следит за компонентом и запускает проверку изменений. А теперь представьте, что у вас огромное приложение с десятками компонентов. И на каждый клик, каждую асинхронную операцию, каждый удачно выполненный запрос запускается проверка по всему дереву компонентов. Скорее всего, у такого приложения будут серьезные проблемы с производительностью.

Разработчики Angular подумали об этом и дали нам возможность установить стратегию обнаружения изменений, корректный выбор которой может довольно сильно увеличить производительность.

Всего на выбор два варианта:

  • Default — как можно догадаться из названия, это стратегия по умолчанию, когда на каждое действие запускается CD.
  • OnPush — стратегия, при которой CD запускается лишь в нескольких случаях:
    • если изменилось значение @Input();
    • если произошло событие внутри компонента или его потомков;
    • если проверка была запущена вручную.

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

  • Четкое понимание, как работает процесс CD.
  • Аккуратная работа с @Input() свойствами.
  • Прирост производительности.

Работа с @Input()

Как и в других популярных фреймворках, в Angular используется нисходящий поток данных. Компонент принимает входные параметры, которые помечаются декоратором @Input(). Рассмотрим на примере:

interface IJoke {     author: string;     text: string; }  @Component({     selector: 'joke',     template: './joke.template.html', }) export class JokeComponent {     @Input() joke: IJoke; }

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

setAuthorNameOnly() {     const name = this.joke.author.split(' ')[0];      this.joke.author = name; }

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

@Component({     selector: 'joke',     template: './joke.template.html',     changeDetection: ChangeDetectionStrategy.OnPush, }) export class JokeComponent {     @Input() readonly joke: IJoke;     @Output() updateName = new EventEmitter<string>();      setAuthorNameOnly() {         const name = this.joke.author.split(' ')[0];          this.updateName.emit(name);     } }

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

RxJS

Конечно, я могу ошибаться, но складывается ощущение, что ReactiveX и реактивное программирование в целом — это новый тренд. Angular поддался этому тренду (а может, и создал его) и использует RxJS по умолчанию. Базовая логика всего фреймворка работает на данной библиотеке, поэтому очень важно понимать принципы реактивного программирования.

Но что такое RxJS? Он объединяет три идеи, которые я раскрою довольно простым языком с некоторыми упущениями:

  • Паттерн «Наблюдатель» — сущность, которая производит события, и есть слушатель, который получает информацию об этих событиях.
  • Паттерн «Итератор» — позволяет получить последовательный доступ к элементам объекта, не раскрывая его внутренней структуры.
  • Функциональное программирование с коллекциями — паттерн, при котором логика бьется на маленькие и очень простые составные части, каждая из которых решает только одну задачу.

Объединение этих паттернов позволяет нам очень просто описывать сложные с первого взгляда алгоритмы, например:

private loadUnreadJokes() {     this.showLoader(); // Ставим лоадер      fromEvent(document, 'load')         .pipe(             switchMap(                 () =>                     this.http                         .get('/api/v1/jokes') // Запрашиваем шутки                         .pipe(map((jokes: any[]) => jokes.filter(joke => joke.unread))), // Фильтруем непрочитанные             ),         )         .subscribe(             (jokes: any[]) => (this.jokes = jokes), // Ставим шутки             error => {                 /* Обработка ошибки */             },             () => this.hideLoader(), // Скрываем лоадер вне зависимости от результата         ); }

Всего 18 строк со всеми красивыми отступами. А теперь попробуйте переписать этот пример на Vanilla или хотя бы на jQuery. Почти 100% у вас это займет как минимум в два раза больше места и будет не так выразительно. Здесь же вы можете просто идти глазами по строке и читать код как книгу.

Observable

Понимание, что любые данные можно представить в виде потока, приходит не сразу. Поэтому предлагаю перейти к простой аналогии. Представьте, что поток — это массив данных, отсортированных по времени. Например, в таком варианте:

const observable = []; let counter = 0;  const intervalId = setInterval(() => {     observable.push(counter++); }, 1000);  setTimeout(() => {     clearInterval(intervalId); }, 6000);

Мы будем считать актуальным последнее значение в массиве. Каждую секунду в массив будет добавляться число. Как мы можем узнать в другом месте приложения, что в массив был добавлен элемент? В обычной ситуации мы вызывали бы какой-нибудь callback и по нему обновляли значение массива, а затем просто брали бы последний элемент.

Благодаря реактивному программированию отпадает необходимость не только писать множество новой логики, но и вообще думать о том, чтобы обновить информацию. Это можно сравнить с простым слушателем:

document.addEventListener('click', event => {});

Вы можете поставить много EventListener‘ов во всем приложении, и они все будут отрабатывать, если вы, конечно, не позаботились об обратном специально.

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

Теперь давайте посмотрим на реальный пример:

export class JokesListComponent implements OnInit {     jokes$: Observable<IJoke>;     authors$ = new Subject<string[]>();     unread$ = new Subject<number>();      constructor(private jokerService: JokerService) {}      ngOnInit() {         // Обратите внимание, я не использую subscribe() в этом месте         this.jokes$ = this.jokerService.getJokes();          this.jokes$.subscribe(jokes => {             this.authors$.next(jokes.map(joke => joke.author));             this.unread$.next(jokes.filter(joke => joke.unread).length);         });     } }

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

TestBed

Рано или поздно разработчик понимает, что если проект — не MVP, то нужно писать тесты. И чем больше тестов будет написано, чем понятнее и подробнее у них описание, тем проще, быстрее и надежнее вносить изменения и реализовывать новый функционал.

Вероятно, в Angular это предвидели и дали нам мощный инструмент для тестирования. Многие разработчики сначала пытаются осилить какую-то технологию «с разбега», не вдаваясь в документацию. Так же поступал и я, отчего довольно поздно осознал все возможности тестирования, доступные «из коробки».

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

Как мы уже выяснили, благодаря DI зависимости берутся вне компонента. С одной стороны, это немного усложняет всю систему, с другой — дает нам большие возможности для настройки тестов и проверки множества кейсов. Предлагаю разобраться на примере компонента:

@Component({     selector: 'app-joker',     template: '<some-dependency></some-dependency>',     styleUrls: ['./joker.component.less'], }) export class JokerComponent {     constructor(         private jokesService: JokesService,         @Inject(PARTY_TOKEN) private partyService: PartyService,         @Optional() private sleepService: SleepService,     ) {}      makeNewFriend(): IFriend {         if (this.sleepService && this.sleepService.isSleeping) {             this.sleepService.wakeUp();         }          const joke = this.jokesService.generateNewJoke();          this.partyService.goToParty('Pacha');         this.partyService.toSay(joke.text);          const laughingPeople = this.partyService.getPeopleByReaction('laughing');         const girl = laughingPeople.find(human => human.sex === 'female');         const friend = this.partyService.makeFriend(girl);          return friend;     } }

Итак, в текущем примере есть три сервиса. Один импортится обычным способом, один — по токену и еще один сервис опционален. Как мы сконфигурируем тестовый модуль? Я покажу сразу готовый вид:

beforeEach(async(() => {     TestBed.configureTestingModule({         imports: [SomeDependencyModule],         declarations: [JokerComponent], // Самое главное, что необходимо указать         providers: [{provide: PARTY_TOKEN, useClass: PartyService}],     }).compileComponents();      fixture = TestBed.createComponent(JokerComponent);     component = fixture.componentInstance;     fixture.detectChanges(); // Необходимо только в случае, если вы проверяете верстку }));

TestBed позволяет нам сделать полную имитацию требуемого модуля. В него можно запровайдить любые сервисы, подменить модули, получить экземпляры классов из компонента и многое другое. Теперь, когда у нас есть уже сконфигурированный модуль, перейдем к возможностям.

Можно избегать лишних зависимостей

Приложение на Angular состоит из модулей, которые могут включать в себя другие модули, сервисы, директивы и прочее. В тесте нам необходимо, по сути, воссоздать работу модуля. Если в нашем примере мы используем в шаблоне <some-dependency></some-dependency>, это значит, что мы должны импортировать SomeDependencyModule и в тест. А если там есть свои зависимости? Значит, и их тоже нужно импортировать.
Если приложение сложное, таких зависимостей будет масса. Импорт всех зависимостей приведет к тому, что в каждом тесте будет находиться вообще все приложение и будут вызываться все методы. Наверное, нам такое не подходит.

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

TestBed.configureTestingModule({     declarations: [JokerComponent],     providers: [{provide: PARTY_TOKEN, useClass: PartyService}], })     .overrideTemplate(JokerComponent, '') // Шаблон теперь пустой, без зависимостей     .compileComponents();

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

Можно мокировать все зависимости из конструктора

Мы уже ознакомились с Injection Token, поэтому предлагаю сразу перейти к делу. В примере выше я уже запровайдил в тест сервис по токену. Если вы пишете не интеграционный тест, то смысла вызывать методы реального сервиса нет, просто сделайте мок.

Для этого можно использовать специальные библиотеки вроде ts-mockito, которые существенно облегчат вам жизнь, но это не обязательно. Angular предоставляет массу возможностей «из коробки».

// Создаем моковый сервис export class MockPartyService extends PartyService {     meetFriend(): IFriend {         return {} as IFriend;     }      goToParty() {}      toSay(some: string) {         console.log(some);     } }  // ...  TestBed.configureTestingModule({     declarations: [JokerComponent, MockComponent],     providers: [{provide: PARTY_TOKEN, useClass: MockPartyService}], // Просто провайдим его }).compileComponents();

Вот и все. Так же можно поступить с любой зависимостью из конструктора.

Множество кейсов

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

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

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

Итог

Мы ознакомились с некоторыми базовыми механизмами Angular, знаний о которых мне не хватало в самом начале моего пути. Чтобы подробно раскрыть каждый механизм, нужна отдельная статья, которые есть в том числе и на «Хабре».

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


ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/480782/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *