Сервіси

Тур Героїв розвивається, найближчим часом ми збираємось додати більше компонентів.

Декільком компонентам потрібен доступ до інформації про героїв і ми не хочемо копіювати та вставляти один і той самий код знову і знову. Натомість, ми створимо єдиний спільний сервіс та навчимось вставляти його в компоненти, де він потрібен.

Рефакторинг доступу даних для відокремлення сервіса зробить компонент тонким та дозволятиме сфокусуватись на підтримці візуального подання. Це також спростить створення модульних тестів компонента з макетом сервіса.

Оскільки сервіси даних завжди асинхронні, ми завершуватимемо цю главу з Promise-орієнтованою версією сервісів даних.

Запустіть живий приклад для цієї частини.

На чому ми зупинились

Перед тим як продовжити наш Тур Героїв, давайте перевіримо, чи маємо ми наступну структуру. Якщо ні, потрібно повернутись та пройти кроки з попередньої частини.

angular-tour-of-heroes
app
app.component.ts
app.module.ts
hero.ts
hero-detail.component.ts
main.ts
node_modules ...
index.html
package.json
styles.css
systemjs.config.js
tsconfig.json

Забезпечте запуск та транспіляцію застосунка

Відкрийте вікно терміналу/консолі. Ми хочемо запустити компілятор TypeScript, запустити веб-сервер та мати нагляд за зміною файлів. Все це забезпечить наступна команда:

npm start

Таким чином застосунок працюватиме, коли ми продовжимо будувати Тур Героїв.

Створення сервісу Героя

Наші замовники хочуть поділитись своїм баченням версії для застосунку. Вони кажуть, що хочуть показати героїв в декількох варіантах на різних сторінках. Ми вже зараз можемо вибрати героя зі списку. Скоро ми додамо панель керування з найефективнішими героями, та створимо окреме візуальне подання для редагування даних героя. Усі три візуальні подання потребують інформації про героя.

В даний момент у AppComponent визначається макет героїв для показу. У нас є принаймні два недоліки. По-перше, визначення героїв не повинно бути роботою компонента. По-друге, ми не можемо легко зробити спільним список героїв з іншими компонентами та візуальними поданнями.

Ми можемо зробити рефакторинг даних героя відділивши бізнес-логіку в окремий сервіс, який видаватиме дані, та зробити спільним цей сервіс для усіх компонентів, які потребують героїв.

Створимо HeroService

Створіть файл у теці app з назвою hero.service.ts.

Ми прийняли угоду, згідно з якою даємо назви файлам сервісів з літерами у нижньому регістрі перед .service. Коли назва сервісу матиме декілька слів, кожне з них ми напишемо через дефіз. Тобто файл для SpecialSuperHeroService буде називатись special-super-hero.service.ts.

Назвемо клас HeroService та експортуємо його, щоб інші могли його імпортувати.

app/hero.service.ts (starting point)

import { Injectable } from '@angular/core'; @Injectable() export class HeroService { }

Придатність сервісу для вставки

Зверніть увагу, що ми імпортували функцію Angular Injectable та застосували її у якості декоратора @Injectable().

Не забувайте за дужки! Ігнорування їх призводить до помилок, які важко діагностувати.

Коли TypeScript бачить декоратор @Injectable(), він видає метадані про сервіс — ті метадані, які Angular може потребува́ти для впровадження залежностей в інших сервісах.

HeroService не має жодних залежностей на даний момент. Додамо декоратор все одно. Це є "кращою практикою", коли застосовують декоратор @Injectable()з самого початку​ для узгодженості та майбутніх вигод.

Отримання Героїв

Додамо заготовку метода getHeroes.

app/hero.service.ts (заготовка getHeroes)

@Injectable() export class HeroService { getHeroes(): void {} // stub }

Покищо ми не записуватимемо код в цей клас, щоб відзначити важливий момент.

Користувачі нашого сервіса не знають як сервіси отримують дані. Наш HeroService може отримувати дані для Hero звідусіль. Він може отримувати дані з веб-сервіса або локального сховища, або з макету даних.

Це правильне видалення доступу даних з компонента. Ми можемо змінювати нашу думку про впровадження на стільки часто, на скільки нам це треба, з будь-якої причини, не чіпаючи будь-якого компонента, де потрібні герої.

Макет Героїв

Ми вже маємо визначений макет даних Hero у AppComponent. Але там йому не місце. Йому не місце навіть тут. Ми перенесемо макет даних в окремий файл.

Виріжте масив HEROES із app.component.ts та вставте його у новий файл у теці app з назвою mock-heroes.ts. Ми копіюємо також вираз import {Hero} ..., оскільки масив героїв використовує клас Hero.

app/mock-heroes.ts

import { Hero } from './hero'; export const HEROES: Hero[] = [ {id: 11, name: 'Mr. Nice'}, {id: 12, name: 'Narco'}, {id: 13, name: 'Bombasto'}, {id: 14, name: 'Celeritas'}, {id: 15, name: 'Magneta'}, {id: 16, name: 'RubberMan'}, {id: 17, name: 'Dynama'}, {id: 18, name: 'Dr IQ'}, {id: 19, name: 'Magma'}, {id: 20, name: 'Tornado'} ];

Ми експортуємо константу HEROES, отже ми можемо імпортувати її будь-куди — хоч і в наш HeroService.

А зараз, повернемось у app.component.ts, звідки ми вирізали масив HEROES, ми залишили без уваги властивість heroes:

app/app.component.ts (властивість heroes)

heroes: Hero[];

Видача макету Героїв

Тепер у HeroService імпортуємо макет HEROES і повернемо його з методу getHeroes. Наш HeroService має наступний вигляд:

app/hero.service.ts

import { Injectable } from '@angular/core'; import { Hero } from './hero'; import { HEROES } from './mock-heroes'; @Injectable() export class HeroService { getHeroes(): Hero[] { return HEROES; } }

Використаємо сервіс Героя

Ми вже готові використовувати HeroService в інших компонентах, починаючи від нашого AppComponent.

Ми починаємо, як правило, з імпортування речей, які хочемо використовувати — імпортуємо HeroService.

import { HeroService } from './hero.service';

Імпортування сервісу дозволяє нам посилатись на нього у коді. Як повинен діяти AppComponent щоб отримати конкретний примірник HeroService?

Чи повинні ми отримати HeroService через ключове слово new? Ні, у жодному разі!

Ми можемо створити новий примірник HeroService за допомогою new, наприклад так:

heroService = new HeroService(); // don't do this

Це погана ідея з кількох причин, включаючи:

А якщо ... а якщо ... Гей, ми можемо це зробити!

Ми зробимо це. Справді зробимо. Але на стільки просто уникнути цих помилок, що нам не буде виправдання, якщо зробимо тут щось неправильно.

Впровадження HeroService

Два рядки замінять один рядок, де ми створювали примірник через new:

  1. Ми додамо конструктор, який визначатиме приватну властивість.
  2. Ми додамо до метаданих провайдерів компонента.

Ось конструктор:

app/app.component.ts (конструктор)

constructor(private heroService: HeroService) { }

Сам-по-собі конструктор нічого не робить, але його параметр одночасно оголошує приватну змінну heroService та визначає її тип як HeroService.

Тепер Angular знатиме куди треба вставляти примірних HeroService під час створення AppComponent.

Дізнайтесь більше про це у розділі Впровадження Залежностей.

Інжектор покищо не знає як створювати HeroService. Якщо ви запустите наш код зараз, Angular кине помилку:

EXCEPTION: No provider for HeroService! (AppComponent -> HeroService)

Ми повинні навчити інжектор створювати HeroService за допомогою реєстрації провайдера для HeroService. Зробимо це через додавання масиву у властивість providers внизу метаданих компонента при виклику @Component.

providers: [HeroService]

Масив providers говорить Angular, що треба створювати свіжий примірник HeroService, коли створюється новий AppComponent. AppComponent може використовувати цей сервіс для отримання героїв, так само як і кожен дочірній компонент у його дереві компонентів.

метод getHeroes у AppComponent

Ми маємо сервіс у приватній змінній heroService. Давайте використаємо її.

Зараз трохи помислимо. Ми можемо викликати цей сервіс та отримати дані в одному рядку.

this.heroes = this.heroService.getHeroes();

Насправді нам не треба виділяти окремий метод для охоплення одного рядка. Але ми зробимо це все одно:

getHeroes(): void { this.heroes = this.heroService.getHeroes(); }

Хук ngOnInit життєвого циклу

AppComponent повинен отримувати та відображати героїв без метушні. Де ми повинні викликати метод getHeroes? В конструкторі? Ні, не там!

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

У конструкторі краще робити прості ініціалізації, такі як перенесення значень із параметрів конструктора до властивостей. Він не призначений для важкої роботи. Ми повинні мати можливість створити компонент в тесті та не турбуватись, чи він справді може робити реальну роботу — таку як виклик сервера! — перед тим як ми скажемо йому зробити це.

Якщо не конструктор, щось повинно викликати getHeroes.

Angular буде викликати цей метод, якщо ми впровадимо хук життєвого циклу Angular з назвою ngOnInit. Angular надає декілька інтерфейсів щоб можна було виконувати певні дії під час протікання життєвих циклів компонентів: під час створення, після кожної зміни, та під час його остаточно знищення.

Кожен інтерфейс має по-одному методу. Коли компонент впроваджує цей метод, Angular викликає його у відповідний час.

Дізнайтесь більше про хуки у розділі Хуки Життєвих Циклів.

Ось так по-суті впроваджується інтерфейс OnInit:

app/app.component.ts (заготовка ngOnInit)

import { OnInit } from '@angular/core'; export class AppComponent implements OnInit { ngOnInit(): void { } }

Ми написали метод ngOnInit з нашою логікою ініціалізації всередині та залишили його для Angular щоб він викликав його у правильний час. У нашому випадку, при ініціалізації ми викликаємо getHeroes.

ngOnInit(): void { this.getHeroes(); }

Наш застосунок повинен працювати як очікується, показувати список героїв та детальну інформацію про героя, коли ми клацнемо на його імені.

Ми вже близько. Але щось є не зовсім вірним.

Асинхронні Сервіси та Promises

Наш HeroService повертає список макетів героїв миттєво. Сигнатура getHeroes є синхронною:

this.heroes = this.heroService.getHeroes();

Спитайте про героїв, і вони зразу ж повертаються.

Одного дня ми захочемо отримувати героїв з віддаленого сервера. Покищо ми не викликаємо http, але ми зробимо це у наступних розділах.

Коли ми це зробимо, нам потрібно буде почекати, допоки сервер відповість, але тим часом ми не зможемо блокувати UI, навіть якщо ми схочемо (чого не повинно бути), оскільки браузер не зробить цього.

Ми повинні використовувати деяку асинхронну техніку, тим самим змінивши сигнатуру нашого методу getHeroes.

Ми будемо використовувати Promises.

Сервіс Героя створює Promise

Promise це обіцянка зробити зворотній виклик після того, як результат буде готовий. Ми просимо асинхронний сервіс зробити якусь роботу та передаємо йому функцію зворотнього виклику (колбек-функцію). Він виконує роботу (десь) та зрештою викликає нашу колбек-функцію передаючи їй свій результат або помилку.

Це ми спрощуємо. Дізнайтесь більше про ES2015 проміси, наприклад тут.

Оновіть HeroService цією версією Promise-орієнтованого методу getHeroes:

app/hero.service.ts (уривок)

getHeroes(): Promise<Hero[]> { return Promise.resolve(HEROES); }

Наші дані все ще у макеті. Ми симулюємо поведінку надзвичайно швидкого сервера з нульовим часом очікування, повертаючи миттєво вирішений Promise з нашим макетом героїв в якості результату.

Робота з Promise

Повернувшись до AppComponent з його методом getHeroes, ми бачимо, що він все ще залишається таким:

app/app.component.ts (стара версія getHeroes)

getHeroes(): void { this.heroes = this.heroService.getHeroes(); }

Відповідно до наших змін HeroService, тепер ми встановлюємо Promise для this.heroes замість масиву героїв.

Ми повинні змінити нашу логіку щоб працювати з Promise коли він вирішиться. Коли Promise вирішиться успішно, ми матимемо героїв для відображення.

Ми передаємо нашу функцію зворотнього виклику як аргумент до методу then, що є у Promise:

app/app.component.ts (нова версія getHeroes)

getHeroes(): void { this.heroService.getHeroes().then(heroes => this.heroes = heroes); }

Стрілочна функція ES2015 у функції зворотнього виклику є коротшою, ніж її еквівалент з ключовим слово function, вона елегантно обробляє this.

Наш колбек встановлює властивість компонента heroes в масив героїв, повернений сервісом. Ось і все!

Наш застосунок повинен все ще працювати, повинен показувати список героїв, та повинен реагувати на вибір імені, показуючи детальну інформацію героя.

Ознайомтесь з додатком "Зробіть його повільним" щоб побачити як застосунок працюватиме при повільному з'єднанні.

Огляд структури застосунка

Давайте пересвідчимось, що ми маємо наступну структуру після усіх наших рефакторингів у цьому розділі:

angular-tour-of-heroes
app
app.component.ts
app.module.ts
hero.ts
hero-detail.component.ts
hero.service.ts
main.ts
mock-heroes.ts
node_modules ...
index.html
package.json
styles.css
systemjs.config.js
tsconfig.json

Тут зібрано усі файли з кодом, який ми обговорювали в цьому розділі.

import { Injectable } from '@angular/core'; import { Hero } from './hero'; import { HEROES } from './mock-heroes'; @Injectable() export class HeroService { getHeroes(): Promise<Hero[]> { return Promise.resolve(HEROES); } } import { Component, OnInit } from '@angular/core'; import { Hero } from './hero'; import { HeroService } from './hero.service'; @Component({ selector: 'my-app', template: ` <h1>{{title}}</h1> <h2>My Heroes</h2> <ul class="heroes"> <li *ngFor="let hero of heroes" [class.selected]="hero === selectedHero" (click)="onSelect(hero)"> <span class="badge">{{hero.id}}</span> {{hero.name}} </li> </ul> <my-hero-detail [hero]="selectedHero"></my-hero-detail> `, styles: [` .selected { background-color: #CFD8DC !important; color: white; } .heroes { margin: 0 0 2em 0; list-style-type: none; padding: 0; width: 15em; } .heroes li { cursor: pointer; position: relative; left: 0; background-color: #EEE; margin: .5em; padding: .3em 0; height: 1.6em; border-radius: 4px; } .heroes li.selected:hover { background-color: #BBD8DC !important; color: white; } .heroes li:hover { color: #607D8B; background-color: #DDD; left: .1em; } .heroes .text { position: relative; top: -3px; } .heroes .badge { display: inline-block; font-size: small; color: white; padding: 0.8em 0.7em 0 0.7em; background-color: #607D8B; line-height: 1em; position: relative; left: -1px; top: -4px; height: 1.8em; margin-right: .8em; border-radius: 4px 0 0 4px; } `], providers: [HeroService] }) export class AppComponent implements OnInit { title = 'Tour of Heroes'; heroes: Hero[]; selectedHero: Hero; constructor(private heroService: HeroService) { } getHeroes(): void { this.heroService.getHeroes().then(heroes => this.heroes = heroes); } ngOnInit(): void { this.getHeroes(); } onSelect(hero: Hero): void { this.selectedHero = hero; } } import { Hero } from './hero'; export const HEROES: Hero[] = [ {id: 11, name: 'Mr. Nice'}, {id: 12, name: 'Narco'}, {id: 13, name: 'Bombasto'}, {id: 14, name: 'Celeritas'}, {id: 15, name: 'Magneta'}, {id: 16, name: 'RubberMan'}, {id: 17, name: 'Dynama'}, {id: 18, name: 'Dr IQ'}, {id: 19, name: 'Magma'}, {id: 20, name: 'Tornado'} ];

Що ми пройшли

Давайте підіб'ємо підсумок того, що ми збудували.

Запустіть живий приклад для цієї частини.

Йдемо далі

Наш Тур Героїв став більш універсальним з використанням спільних компонентів та сервісів. Ми хочемо створити панель керування та меню з лінками для навігації між візуальними поданнями, і форматувати дані у шаблоні. У процесі розвитку нашого застосунка, ми навчимось проектувати їх, щоб надалі можна було легко рости та легко супроводжувати написане.

Ми вивчемо Компонент Маршрутизатора Angular та навігацію поміж візуальних подань у наступному розділі підручника.

Додаткова інформація: Зробіть його повільним

Ми можемо симулювати повільне з'єднання.

Імпортуйте Hero та додайте наступний метод getHeroesSlowly до HeroService

app/hero.service.ts (getHeroesSlowly)

getHeroesSlowly(): Promise<Hero[]> { return new Promise(resolve => { // Simulate server latency with 2 second delay setTimeout(() => resolve(this.getHeroes()), 2000); }); }

Як і getHeroes, він також повертає Promise, але цей очікує 2 секунди перед вирішенням Promise та поверненням макету героїв.

Поверністься у AppComponent, замініть heroService.getHeroes на heroService.getHeroesSlowly та дивіться на поведінку застосунка.

Наступний крок

Маршрутизація