От автора: Angular – удивительный инструмент. Прямо из коробки он предоставляет огромнейший функционал (роутинг, анимации, HTTP-модуль, формы/валидации и т.д.), ускоряет процесс разработки, а в освоении он не такой уж и сложный (особенно с таким мощным инструментом как Angular CLI).
В неосторожных руках хороший инструмент превращается в оружие уничтожения. Сегодня я расскажу вам про компоненты Angular и практики, которые НЕ СТОИТ использовать. Начнем.
Предупреждение: в этой статье я буду показывать примеры компонентов, а также использовать инлайновые шаблоны. Учтите, что в большинстве случаев это считается ПЛОХОЙ практикой. Однако читателям так легче и удобнее усваивать материал. Также для краткости я пропущу импорты и некоторые шаблоны.
Компоненты Angular ПО ФАКТУ не используются
Компоненты – основные строительные кирпичики в экосистеме Angular, это мост, соединяющий логику приложения с представлением. Но иногда разработчики настойчиво игнорируют преимущества компонентов. Разберем пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
@Component({ selector: 'app-some-component-with-form', template: ` <div [formGroup]="form"> <div class="form-control"> <label>First Name</label> <input type="text" formControlName="firstName" /> </div> <div class="form-control"> <label>Last Name</label> <input type="text" formControlName="lastName" /> </div> <div class="form-control"> <label>Age</label> <input type="text" formControlName="age" /> </div> </div> ` }) 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. По сути, они одинаковые. Так почему бы не выделить их в компонент? А теперь взгляните сюда:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Component({ selector: 'app-single-control-component', template: ` <div class="form-control"> <label>{{ label }}</label> <input type="text" [formControl]="control" /> </div> ` }) export class SingleControlComponent{ @Input() control: AbstractControl @Input() label: string; } |
Мы выделили одно поле в свой собственный компонент и определили 3 input’а, которые принимают данные от родительского компонента. В нашем случае это сущность form control и label, привязанный к input. Давайте поправим наш первый шаблон компонента:
1 2 3 4 5 6 7 8 9 10 |
<div> <app-single-control-component [control]="form.controls['firstName']" [label]="'First Name'"> </app-single-control-component> <app-single-control-component [control]="form.controls['lastName']" [label]="'Last Name'"> </app-single-control-component> <app-single-control-component [control]="form.controls['age']" [label]="'Age'"> </app-single-control-component> </div> |
Так намного аккуратнее. Это был очень простой пример, но он может сильно усложниться, если неправильно использовать компоненты. Скажем, у вас есть страница с лентой новостей – блок с бесконечным скролом, разделенный по темам, внутри которого есть меньшие блоки, представляющие отдельные новости/статьи (как Medium. По сути, мы только что описали ленту новостей с сайта Medium).
Идеальный пример использования компонентов 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(), так как они знакомы с этим методом. Это, пожалуй, худшее, что можно сделать со своим приложением из-за собственной лени:
Вы добавляете ненужную логику в приложение. Observable не нужно трансформировать в 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, передачи данных между компонентами и другими утилитами. Манипуляции с данными должны быть в отдельных классах модели. Взгляните:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
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<Movie> 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 to map the values */ 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 быстро превратится в помойку. А теперь взгляните на этот код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
interface Movie { id: number; title: string; } interface User { firstName: string; lastName: string; age: number; favoriteMovies: Array<Movie | number>; /* 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<Movie | number>; 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 */ } } |
Как видите, теперь у нас есть класс, представляющий пользователя, и все манипуляции находятся в конструкторе. Теперь компонент будет выглядеть так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
@Component({ selector: 'app-some-component-with-form', template: `...` // our form is here }) export class SomeComponentWithForm { public form: FormGroup; public movies: Array<Movie> 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 долларов / унция». Рассмотрим следующий код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Component({ selector: 'some-component', template: ` <div> <dropdown-component [options]="weightUnits"></dropdown-component> <-- This will render a dropdown based in the options --> <input type="text" placeholder="Price"> <dropdown-component [options]="weightUnits"></dropdown-component> <-- We need to make this one's labels to be preceded with a slash --> </div> ` }) export class SomeComponent { public weghtUnits = [{value: 1, label: 'kg'}, {value: 2, label: 'oz'}]; } |
Оба компонента списка используют один массив вариантов, т.е. они будут одинаковые. Нам их нужно как-то разделить. Глупый способ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Component({ selector: 'some-component', template: ` <div> <dropdown-component [options]="weightUnits"></dropdown-component> <input type="text" placeholder="Price"> <dropdown-component [options]="slashedWeightUnits"></dropdown-component> <-- Now this one's labels will be preceded with a slash --> </div> ` }) 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 } |
Это, конечно, решит проблему, но что будет, если значения не константы, а вытягиваются с сервера? Также создание нового свойства для каждой манипуляции с данными довольно быстро засорит код. Опасный способ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@Component({ selector: 'some-component', template: ` <div> <dropdown-component [options]="weightUnits"></dropdown-component> <input type="text" placeholder="Price"> <dropdown-component [options]="slashedWeightUnits"></dropdown-component> <-- Now this one's labels will be preceded with a slash --> </div> ` }) 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, и выпадающий список будет перерисовываться каждый раз, когда изменяется значение этого инпута. Значение поля определяется после вызова функции, поэтому механизм определения изменений не может понять, изменилось оно или нет. Поэтому он вынужден постоянно вызывать функцию на каждой итерации определения изменений, из-за чего выпадающий список постоянно перерисовывается. Т.е. проблема решена… созданием еще большей проблемы. Хороший способ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
@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: ` <div> <dropdown-component [options]="weightUnits"></dropdown-component> <input type="text" placeholder="Price"> <dropdown-component [options]="(weightUnits | slashed)"></dropdown-component> <-- This will do the job --> </div> ` }) export class SomeComponent { public weightUnits = [{value: 1, label: 'kg'}, {value: 2, label: 'oz'}]; // we will delegate the data transformation to a pipe } |
Вы, конечно, знакомы с пайпами. Это даже не совет (сама документация говорит использовать пайпы в таких случаях), тут суть не в самом пайпе. Мне этот способ тоже не нравится. Если у меня много простых, но разных манипуляций с данными в приложении, нужно ли мне писать Pipe класс для каждой манипуляции? Что если большая их часть настолько специфична, что используется только в одном месте компонента? Так я засорю свой код. Более продвинутый способ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
@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: ` <div> <dropdown-component [options]="weightUnits"></dropdown-component> <input type="text" placeholder="Price"> <dropdown-component [options]="(weightUnits | map : slashed)"></dropdown-component> <-- This will do the job --> </div> ` }) 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).
Будьте постоянны и точны. В своем приложении можно найти много ненужного.
Автор: Armen Vardanyan
Источник: //codeburst.io/
Редакция: Команда webformyself.