Компоненти Angular: погані практики використання

502

Від автора: Angular – дивовижний інструмент. Прямо з коробки він надає величезний функціонал (роутинг, анімації, HTTP-модуль, форми/валідації і т. д.), прискорює процес розробки, а в освоєнні він не такий вже і складний (особливо з таким потужним інструментом як Angular CLI).

В необережних руках хороший інструмент перетворюється на зброю знищення. Сьогодні я розповім вам про компоненти Angular і практики, які НЕ ВАРТО використовувати. Почнемо.

Попередження: в цій статті я буду показувати приклади компонентів, а також використовувати инлайновые шаблони. Врахуйте, що в більшості випадків це вважається ПОГАНОЮ практикою. Однак читачам так легше і зручніше засвоювати матеріал. Також для стислості я пропущу імпорти та деякі шаблони.

Компоненти Angular ЗА ФАКТОМ не використовуються

Компоненти – основні будівельні цеглинки в екосистемі Angular, це міст, що сполучає логіку програми з поданням. Але іноді розробники наполегливо ігнорують переваги компонентів. Розберемо приклад:

@Component({
selector: ‘app-some-component-with-form’,
template: `

First Name
Last Name
Age

`
})
export class SomeComponentWithForm {
public form: FormGroup;
constructor(private formBuilder: FormBuilder){
this.form = formBuilder.group({
firstName: [“, Validators.required],
lastName: [“, Validators.required],
age: [“, Validators.max(120)],
})
}
}

Як бачите, у нас є маленька форма з трьома полями і шаблоном, де зберігаються справжні инпуты. Кожен input зі своїм label поміщений всередину тега div. Всього таких контейнерів 3. По суті, вони однакові. Так чому б не виділити їх в компонент? А тепер погляньте сюди:

@Component({
selector: ‘app-single-control-component’,
template: `

{{ label }}

`
})
export class SingleControlComponent{
@Input() control: AbstractControl
@Input() label: string;
}

Ми виділили одне поле в свій власний компонент і визначили 3 input’а, які приймають дані від батьківського компонента. У нашому випадку це сутність form control і label, прив’язаний до input. Давайте виправимо наш перший шаблон компонента:

Так набагато акуратніше. Це був дуже простий приклад, але він може сильно ускладнитися, якщо неправильно використовувати компоненти. Скажімо, у вас є сторінка з стрічкою новин – блок з нескінченним скрол, поділений за темами, всередині якого є менші блоки, які представляють окремі новини/статті (як Medium. По суті, ми тільки що описали стрічку новин з сайту Medium).

Компоненти Angular: погані практики використання

Ідеальний приклад використання компонентів Angular. Тепер якщо ми захочемо створити щось подібне, ми можемо задатися питанням, як полегшити собі життя за допомогою компонентів. Ось так:

Компоненти Angular: погані практики використання

Великі ділянки будуть компонентами (позначено червоним). Вони будуть містити список статей, follow/unfollow функціонал, заголовок теми. Маленькі ділянки – це теж компоненти (зелені). Вони будуть зберігати об’єкт з інформацією про одній статті, функціонал bookmark story/report story і посилання на повну статтю. Бачите, як це допомагає розділити велику частину логіки (розділяй і володарюй!) у повторно використовувані шматки коду, з якими потім буде набагато зручніше працювати, якщо знадобиться вносити правки.

Ви можете подумати «ну, поділ компонентів – проста концепція Angular, навіщо приділяти їй стільки уваги, ніби це щось дуже важливе, все це і так знають». Однак проблема в тому, що багато розробники обманюються роутинг-модулем Angular: він з’єднує рауса з компонентом, тому люди (в основному, новачки, але, буває, на цьому спотикаються і більш досвідчені розробники думають про компонентах, як про окремих сторінках. Компоненти в Angular – це НЕ сторінки, це шматки подання. Кілька компонентів складають уявлення. Ще одна неприємна ситуація – коли у вас є компонент, в якому майже немає логіки, але по мірі додавання вимог він розростається все більше і більше. В один прекрасний момент потрібно подумати про його поділ, інакше ви виростите неконтрольованого монстра.

Використання .toPromise()

У Angular є вбудований HTTP-модуль, щоб наше додаток спілкувалося з віддаленим сервером. Як ви вже знаєте (якщо немає, то чому ви це читаєте?), Angular замість Promise’ів використовує Rx.js для підтримки HTTP-запитів. Не всі знають Rx.js але якщо ви зібралися дуже довго працювати з Angular, краще вивчіть його. Новачки в Angular, як правило, трансформують Observables, які повертаються з викликів API в HTTP-модулі, в Promise’и з допомогою .toPromise(), так як вони знайомі з цим методом. Це, мабуть, найгірше, що можна зробити зі своїм додатком з-за власної ліні:

Ви додаєте непотрібну логіку в додаток. Вами не потрібно трансформувати в Promise, можна працювати безпосередньо з потоком даних.

Ви втрачаєте багато крутих фішок Rx.js: можна кешувати відповідь, можна маніпулювати дані до підписки, в отриманих даних можна шукати логічні помилки (наприклад, якщо ваше API завжди повертає 200 ОК з булевими властивістю success для визначення успішності запиту) і перекидати їх, ловлячи в самому додатку за допомогою однієї-двох рядків коду… але ви воліли використовувати .toPromise().

Не використовуйте Rx.js занадто часто

Це більш загальний рада. Rx.js – дивовижний інструмент, вивчіть його, щоб вміти маніпулювати даними, подіями і загальним станом вашого додатка.

Забуті директиви

Старий рада. Angular не використовує директиви так, як це було в Angular.js (частині ми зустрічаємо щось типу ng-click, ng-src, велика частина яких нині замінена на Inputs і Outputs), але в ньому все ще залишилися ngIf, ngForOf. Практичне правило для Angular.js було: «Не проводите маніпуляції з DOM в контролері»

Практичне правило для Angular буде: «Не проводите маніпуляції з DOM в компоненті»

Це все, що потрібно знати. Не забувайте про директиви.

Для даних не оголошені інтерфейси

Швидше за все, ви думали, що тип даних, одержуваних від сервера/API дорівнює any. Насправді, це не так. Необхідно задавати тип для всіх даних, одержуваних з backend’а. Саме тому Angular використовується в основному на TypeScript.

Маніпуляції з даними в компоненті

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

interface Movie {
id: number;
title: string;
}
@Component({
selector: ‘app-some-component-with-form’,
template: `…` // our form is here
})
export class SomeComponentWithForm {
public form: FormGroup;
public movies: Array
constructor(private formBuilder: FormBuilder){
this.form = formBuilder.group({
firstName: [“, Validators.required],
lastName: [“, Validators.required],
age: [“, Validators.max(120)],
favoriteMovies: [[]], /*
we’ll have a multiselect dropdown
in our template to select favorite movies
*/
});
}
public onSubmit(values){
/*
‘values’ is actually a form value, which represents a user
but imagine our API does not expect as to send a list of movie
objects, just a list of id-s, so we have the values to map
*/
values.favouriteMovies = values.favouriteMovies.map((movie: Movie) => movie.id);
// then we will send the user data to the server using some service
}
}

Зараз це не схоже на катастрофу, просто парочка маніпуляцій з даними перед відправкою значень на сервер. Але уявіть, що у вас багато зовнішніх ключів, полів Many-To-Many, багато обробників даних, і все це залежить від певних сценаріїв, змінних, стану програми. Ваш метод onSubmit швидко перетвориться на смітник. А тепер погляньте на цей код:

interface Movie {
id: number;
title: string;
}
User interface {
firstName: string;
lastName: string;
age: number;
favoriteMovies: Array;
/*
notice how we supposed that this property
may be either an Array of Movie objects
or of numerical identificators
*/
}
class UserModel implements User {
firstName: string;
lastName: string;
age: number;
favoriteMovies: Array;
constructor(source: User){
this.firstName = source.firstName;
this.lastName = source.lastName;
this.age = source.age;
this.favoriteMovies = source.favoriteMovies.map((movie: Movie) => movie.id);
/*
we moved the data manipulation to this separate class,
which is also a valid representation of a User model,
so no unnecessary clutter here
*/
}
}

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

@Component({
selector: ‘app-some-component-with-form’,
template: `…` // our form is here
})
export class SomeComponentWithForm {
public form: FormGroup;
public movies: Array
constructor(private formBuilder: FormBuilder){
this.form = formBuilder.group({
firstName: [“, Validators.required],
lastName: [“, Validators.required],
age: [“, Validators.max(120)],
favoriteMovies: [[]], /*
we’ll have a multiselect dropdown
in our template to select favorite movies
*/
});
}
public onSubmit(values: User){
/*
now we will just create a new User instance from our form,
with all the data manipulations done inside the constructor
*/
let user: UserModel = new UserModel(values);
// then we will send the user model data to the server using some service
}
}

І всі подальші маніпуляції з даними будуть проходити всередині конструктора моделі, не забруднюючи код компонента. Ще одне практичне правило – перевірте перед кожною відправкою даних на сервер, чи є там ключове слово new.

Не використання/зловживання пайпа

Тут я відразу перейду до прикладу. Наприклад, у вас є 2 випадного списку, з допомогою яких можна вибрати одиниці виміру ваги. Перший список – просто міра ваги, другий – одиниця на ціну/кількість (це важливо). Перший список повинен бути звичайним, проте перед лейблом другого списку повинен бути символ «/», щоб це виглядало приблизно так: «1 долар / кг» або «7 доларів / унція». Розглянемо наступний код:

@Component({
selector: ‘some-component’,
template: `

`
})
export class SomeComponent {
public weghtUnits = [{value: 1, label: ‘kg’}, {value: 2, label: ‘oz’}];
}

Обидва компоненти списку використовують один масив варіантів, тобто вони будуть однакові. Нам їх потрібно розділити. Дурний спосіб:

@Component({
selector: ‘some-component’,
template: `

`
})
export class SomeComponent {
public weightUnits = [{value: 1, label: ‘kg’}, {value: 2, label: ‘oz’}];
public slashedWeightUnits = [{value: 1, label: ‘/kg’}, {value: 2, label: ‘/oz’}];
// we just add a new property
}

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

@Component({
selector: ‘some-component’,
template: `

`
})
export class SomeComponent {
public weightUnits = [{value: 1, label: ‘kg’}, {value: 2, label: ‘oz’}];
public get slashedWeightUnits() {
return this.weightUnits.map(weightUnit => {
return {
label: ‘/’ + weightUnit.label,
value: weightUnit.value
};
})
}
// so now we map existing weight units to a new array
}

На перший погляд рішення хороше. Але, насправді, це ще гірше. Випадаючий список буде отрисовываться добре, поки ви не захочете на нього натиснути. Навіть трохи раніше. Якщо придивитися, то він блимає (так, блимає!). Чому? Щоб це зрозуміти, потрібно трохи глибше зануритися в механізм визначення змін і те, як він працює з введенням/виводом.

У компоненті випадаючого списку є инпут options, і випадаючий список буде перерисовываться кожен раз, коли змінюється значення цього инпута. Значення поля визначається після виклику функції, тому механізм визначення змін не може зрозуміти, чи змінилося воно чи ні. Тому він змушений постійно викликати функцію на кожній ітерації визначення змін, з-за чого випадаючий список постійно оновлюється. Тобто проблема вирішена… створенням ще більшої проблеми. Хороший спосіб:

@Pipe({
name: ‘slashed’
})
export class Slashed implements PipeTransform {
transform(value){
return value.map(item => {
return {
label: ‘/’ + item.label,
value: item.value
};
})
}
}
@Component({
selector: ‘some-component’,
template: `

`
})
export class SomeComponent {
public weightUnits = [{value: 1, label: ‘kg’}, {value: 2, label: ‘oz’}];
// we will the delegate data transformation to a pipe
}

Ви, звичайно, знайомі з пайпа. Це навіть не рада (сама документація говорить використовувати пайпи в таких випадках), тут суть не в самому пайпі. Мені цей спосіб теж не подобається. Якщо у мене багато простих, але різних маніпуляцій з даними в додатку, чи потрібно мені писати Pipe клас для кожної маніпуляції? Якщо більша їх частина настільки специфічна, що використовується тільки в одному місці компонента? Так я засорю свій код. Більш просунутий спосіб:

@Pipe({
name: ‘map’
})
export class Mapping implements PipeTransform {
/*
this will be a universal pipe for array mappings. You may add more
type checkings and runtime checkings to make sure it works correctly everywhere
*/
transform(value, mappingFunction: Function){
return mappingFunction(value)
}
}
@Component({
selector: ‘some-component’,
template: `

`
})
export class SomeComponent {
public weightUnits = [{value: 1, label: ‘kg’}, {value: 2, label: ‘oz’}];
public slashed(units){
return units.map(unit => {
return {
label: ‘/’ + unit.label,
value: unit.value
};
});
}
// we will delegate a custom mapping function to a more generic pipe, which will just call it on value change
}

В чому різниця? Пайп викликає свій метод transform тоді і тільки тоді, коли дані змінюються. Поки weightUnits не зміняться, пайп буде виконаний лише один раз, а не на кожній ітерації визначення змін.

Я не кажу, що у вас має бути суворо 1 або 2 маппінг пайпа. Потрібно створювати кастомні пайпи на більш складні речі (робота з датою/часом тощо). Там, де повторне використання життєво необхідно, а також для маніпуляцій, заточених під певні компоненти, необхідно створювати універсальний пайп.

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

Загальні зауваження по повторному використанню

Всякий раз, коли пишіть компонент, який може бути використаний повторно іншими розробниками, використовуйте однакові перевірки всього, що потрібно в компоненті. Якщо у вікні є инпут типу Т, який обов’язковий для коректної роботи компонента, перевірте, що його значення реально оголошено в конструкторі. Инпут може і бути типу Т, але він може бути не визначений на момент роботи програми (у TypeScript є тільки перевірки типів під час компіляції). Кидайте виключення, щоб представляти помилки в більш відповідному контексті, а також зі своїм повідомленням, щоб не читати контекст Zone.js (як часто буває з помилками Angular).

Будьте постійні і точні. У своєму додатку можна знайти багато непотрібного.