[JavaScript, Angular, TypeScript] Создание приложений на Angular с использованием продвинутых возможностей DI
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Меня зовут Андрей, и я занимаюсь разработкой фронтенда на Angular для внутренних продуктов компании. Фреймворк обладает обширными возможностями, одни и те же задачи можно решить огромным количеством способов. Чтобы облегчить свою работу и повысить продуктивность, я задался целью найти универсальный и не сложный подход, который бы упростил проектирование и позволил уменьшить объем кода при сохранении его читаемости. Перепробовав множество различных вариантов и учтя допущенные ошибки, я пришел к архитектуре, которой хочу поделиться в этой статье.Слоеный пирог приложения
Как известно, приложение на Angular представляет собой дерево компонентов, внедряющих зависимости измодульных или элементных инжекторов. При этом его задачей как клиента является получение от сервера информации, которая преобразуется к нужному виду и отображается в браузере. А действия пользователя на странице вызывают изменение информации и визуального представления. Таким образом, приложение разбивается на три абстрактных уровня или слоя, взаимодействующих друг с другом:
- Хранение данных и осуществление операций с данными (слой данных).
- Преобразование информации к виду, требуемому для отображения, обработка действий пользователя (слой управления или контроллер).
- Визуализация данных и делегация событий (слой представления).
В контексте фреймворка они будут обладать следующими характерными особенностями:
- элементы слоя представления – компоненты;
- зависимости слоя управления находятся в элементных инжекторах, а слоя данных – в модульных;
- связь между слоями осуществляется средствами системы DI;
- элементы каждого уровня могут иметь дополнительные зависимости, которые непосредственно к слою не относятся;
- слои связаны в строгом порядке: сервисы одного уровня не могут зависеть друг от друга, компоненты слоя представления могут внедрять только контроллеры, а контроллеры – только сервисы слоя данных.
Последнее требование может быть не самым очевидным. Однако, по моему опыту, код, где сервисы из одного уровня общаются напрямую, становится слишком сложным для понимания. Такой код спустя время проще переписать заново, чем разобраться в связях между слоями. При выполнении же требования – связи остаются максимально прозрачными, читать и поддерживать такой код значительно проще.Вообще говоря, под данными, передаваемыми между слоями, имеются в виду произвольные объекты. Однако, в большинстве случаев ими будут Observable, которые идеально подходят к описываемому подходу. Как правило, слой данных отдает Observable с частью состояния приложения. Затем в слое управления с помощью операторов rxjs данные преобразовываются к нужному формату, и в шаблоне компонента осуществляется подписка через async pipe. События на странице связываются с обработчиком в контроллере. Он может иметь сложную логику управления запросами к слою данных и подписывается на Observable, которые возвращают асинхронные команды. Подписка позволяет гибко реагировать на результат выполнения отдельных команд и обрабатывать ошибки, например, открывая всплывающие сообщения. Элементы слоя управления я буду дальше называть контроллерами, хотя они отличаются от таковых в MVC паттерне. Слой данныхСервисы слоя данных хранят состояние приложения (бизнес-данные, состояние интерфейса) в удобном для работы с ним виде. В качестве дополнительных зависимостей используются сервисы для работы с данными (например: http клиент и менеджеры состояния). Для непосредственного хранения данных удобно использовать BehaviourSubject в простых случаях, и такие библиотеки как akita, Rxjs или ngxs – для более сложных. Однако, на мой взгляд, последние две избыточны при данном подходе. Лучше всего для предлагаемой архитектуры подходит akita. Ее преимуществами являются отсутствие бойлерплейта и возможность переиспользовать стейты обычным наследованием. При этом обновлять стейт можно непосредственно в операторах rxjs запросов, что гораздо удобнее, чем создание экшенов.
@Injectable({providedIn: 'root'})
export class HeroState {
private hero = new BehaviorSubject(null);
constructor(private heroService: HeroService) {}
load(id: string) {
return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));
}
save(hero: Hero) {
return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));
}
get hero$(): Observable<Hero> {
return this.hero.asObservable();
}
}
Слой управленияТак как каждый сервис слоя относится к конкретному компоненту с его поддеревом, логично назвать сервис контроллером компонента. Благодаря тому, что контроллер компонента находится в элементном инжекторе, в нем можно использовать OnDestroy hook и внедрять те же зависимости, что и в компоненте, например ActivatedRoute. Безусловно, можно не создавать отдельный сервис для контроллера в тех случаях, где это равноценно вынесению кода из компонента.Помимо зависимостей из слоя данных, в контроллере могут быть внедрены зависимости управляющие визуализацией (например: открытие диалогов, роутер) и помогающие с преобразованием данных (например: FormBuilder).
@Injectable()
export class HeroController implements OnDestroy {
private heroSubscription: Subscription;
heroForm = this.fb.group({
id: [],
name: ['', Validators.required],
power: ['', Validators.required]
});
constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }
save() {
this.heroState.save(this.heroForm.value).subscribe();
}
initialize() {
this.route.paramMap.pipe(
map(params => params.get('id')),
switchMap(id => this.heroState.load(id)),
).subscribe();
this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));
}
ngOnDestroy() {
this.heroSubscription.unsubscribe();
}
}
Слой представленияФункцией слоя представления является визуализация и связывание событий с их обработчиками. И то, и другое происходит в шаблоне компонента. При этом класс будет содержать только код, внедряющий зависимости уровня управления. Простые компоненты (в том числе из внешних библиотек), не использующие внедрение, будут относиться к дополнительным зависимостям. Они получают данные через Input поля и делегируют события через Output.
@Component({
selector: 'hero',
template: `
<hero-form [form]="heroController.heroForm"></hero-form>
<button (click)="heroController.save()">Save</button>
`,
providers: [HeroController]
})
export class HeroComponent {
constructor(public heroController: HeroController) {
this.heroController.initialize();
}
}
Повторное использование кодаЧасто в процессе разработки приложения часть разметки, поведения и бизнес-логики начинает дублироваться. Обычно эта проблема решается использованием наследования и написанием переиспользуемых компонентов. Разделение на слои описанным выше способом способствует более гибкому выделению абстракций при меньшем количестве кода. Основная идея заключается в том, чтобы внедрять зависимости слоя, который мы собираемся переиспользовать, указывая не конкретные, а абстрактные классы. Тем самым можно выделить две базовые техники: подмена слоя данных и подмена контроллера. В первом случае заранее неизвестно, с какими данными будет работать контроллер. Во втором - что отображается и какой будет реакция на события.В демо-приложении я постарался уместить различные методы их использования. Возможно, здесь они немного избыточны, но будут полезны в реальных задачах.Пример демонстрирует реализацию пользовательского интерфейса, позволяющего загружать список сущностей и отображать его в разных вкладках с возможностью редактирования и сохранения каждого элемента.
Для начала опишем абстрактный класс для слоя данных, который будет использован в сервисах слоя управления. Его конкретная реализация будет указываться через useExisting провайдер.
export abstract class EntityState<T> {
abstract get entities$(): Observable<T[]>; // список сущностей
abstract get selectedId$(): Observable<string>; // id выбранного элемента
abstract get selected$(): Observable<T>; // выбранный элемент
abstract select(id: string); // выбрать элемент с указанным id
abstract load(): Observable<T[]> // загрузить список
abstract save(entity: T): Observable<T>; // сохранить сущность
}
Теперь создадим компонент для карточки с формой. Так как форма здесь может быть произвольной, будем отображать ее, используя проекцию содержимого. Контроллер компонента внедряет EntityState и использует метод для сохранения данных.
@Injectable()
export class EntityCardController {
isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));
constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {
}
save(form: FormGroup) {
this.entityState.save(form.value).subscribe({
next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),
error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })
})
}
}
В самом компоненте используем еще один способ внедрения зависимости – через директиву @ContentChild.
@Component({
selector: 'entity-card',
template: `
<mat-card>
<ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">
<mat-card-title>
<ng-content select=".header"></ng-content>
</mat-card-title>
<mat-card-content>
<ng-content></ng-content>
</mat-card-content>
<mat-card-actions>
<button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>
</mat-card-actions>
</ng-container>
<ng-template #notSelected>Select Item</ng-template>
</mat-card>
`,
providers: [EntityCardController]
})
export class EntityCardComponent {
@ContentChild(EntityFormController) entityFormController: EntityFormController<any>;
constructor(public entityCardController: EntityCardController) {
this.entityCardController.initialize();
}
}
Для того чтобы это было возможно, необходимо в провайдерах компонента, который проецируется в entity-card, указать реализацию EntityFormController:
providers: [{ provide: EntityFormController, useClass: HeroFormController }]
Шаблон компонента, использующего эту карточку, будет выглядеть следующим образом:
<entity-card>
<hero-form></hero-form>
</entity-card>
Осталось разобраться со списком: сущности содержат разные поля, так что преобразование данных отличается. Клик на элемент списка вызывает одну и ту же команду из слоя данных. Опишем базовый класс контроллера, содержащий общий код.
export interface Entity {
value: string;
label: string;
}
@Injectable()
export abstract class EntityListController<T> {
constructor(protected entityState: EntityState<T>) {}
select(value: string) {
this.entityState.select(value);
}
selected$ = this.entityState.selectedId$;
abstract get entityList$(): Observable<Entity[]>;
}
Для уточнения преобразования конкретной модели данных к отображаемому виду теперь достаточно объявить наследника и переопределить абстрактное свойство.
@Injectable()
export class FilmsListController extends EntityListController<Film> {
entityList$ = this.entityState.entities$.pipe(
map(films => films.map(f => ({ value: f.id, label: f.title })))
)
}
Компонент списка использует этот сервис, однако его реализация будет предоставлена внешним компонентом.
@Component({
selector: 'entity-list',
template: `
<mat-selection-list [multiple]="false"
(selectionChange)="entityListController.select($event.options[0].value)">
<mat-list-option *ngFor="let item of entityListController.entityList$ | async"
[selected]="item.value === (entityListController.selected$ | async)"
[value]="item.value">
{{ item.label }}
</mat-list-option>
</mat-selection-list>
`
})
export class EntityListComponent {
constructor(public entityListController: EntityListController<any>) {}
}
Компонент, являющийся абстракцией всей вкладки, включает список сущностей и проецирует содержимое с формой.
@Component({
selector: 'entity-page',
template: `
<mat-sidenav-container>
<mat-sidenav opened mode="side">
<entity-list></entity-list>
</mat-sidenav>
<ng-content></ng-content>
</mat-sidenav-container>
`,
})
export class EntityPageComponent {}
Использование компонента entity-page:
@Component({
selector: 'film-page',
template: `
<entity-page>
<entity-card>
<span class="header">Film</span>
<film-form></film-form>
</entity-card>
</entity-page>
`,
providers: [
{ provide: EntityState, useExisting: FilmsState },
{ provide: EntityListController, useClass: FilmsListController }
]
})
export class FilmPageComponent {}
Компонент entity-card передается через проекцию содержимого для возможности использования ContentChild.Извините, данный ресурс не поддреживается. :( ПослесловиеОписанный подход позволил мне значительно упростить процесс проектирования и ускорить разработку без ущерба качеству и читаемости кода. Он отлично масштабируется к реальным задачам. В примерах были продемонстрированы лишь базовые техники переиспользования. Их комбинация с такими фичами как multi-провайдеры и модификаторы доступа (Optional, Self, SkipSelf, Host) позволяет гибко выделять абстракции в сложных случаях, используя меньше кода, чем обычное переиспользование компонентов.
===========
Источник:
habr.com
===========
Похожие новости:
- [Управление разработкой, Управление продуктом] Бесшовная миграция монолитного фронтенда для критически важного бизнес-продукта
- [Angular] Getting To Know Angular Components
- [JavaScript] Конец вечного противостояния snake_keys VS camelKeys: наводим порядок в стилях написания переменных
- [Программирование, Angular] Простая архитектура приложений на фреймворке Angular (перевод)
- [Программирование] Как я организую свои скрипты NPM (перевод)
- [JavaScript, Видеоконференцсвязь] Видеоконференции — как бороться с высокой загрузкой ЦПУ?
- [Веб-дизайн, JavaScript, jQuery, HTML] EasyUI: действительно easy?
- [JavaScript, Расширения для браузеров, Медийная реклама] AdBlock: особенности работы и продвинутые методы блокировки
- [JavaScript, Программирование, Алгоритмы, Функциональное программирование] Решаем вопрос сортировки в JavaScript раз и навсегда
- [Разработка веб-сайтов, TypeScript, Лайфхаки для гиков] Карманная книга по TypeScript. Часть 2. Типы на каждый день (перевод)
Теги для поиска: #_javascript, #_angular, #_typescript, #_angular, #_typescript, #_rxjs, #_dependency_injection, #_blog_kompanii_mir_plat.form_(nspk) (
Блог компании Мир Plat.Form (НСПК)
), #_javascript, #_angular, #_typescript
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 06:27
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Меня зовут Андрей, и я занимаюсь разработкой фронтенда на Angular для внутренних продуктов компании. Фреймворк обладает обширными возможностями, одни и те же задачи можно решить огромным количеством способов. Чтобы облегчить свою работу и повысить продуктивность, я задался целью найти универсальный и не сложный подход, который бы упростил проектирование и позволил уменьшить объем кода при сохранении его читаемости. Перепробовав множество различных вариантов и учтя допущенные ошибки, я пришел к архитектуре, которой хочу поделиться в этой статье.Слоеный пирог приложения Как известно, приложение на Angular представляет собой дерево компонентов, внедряющих зависимости измодульных или элементных инжекторов. При этом его задачей как клиента является получение от сервера информации, которая преобразуется к нужному виду и отображается в браузере. А действия пользователя на странице вызывают изменение информации и визуального представления. Таким образом, приложение разбивается на три абстрактных уровня или слоя, взаимодействующих друг с другом:
@Injectable({providedIn: 'root'})
export class HeroState { private hero = new BehaviorSubject(null); constructor(private heroService: HeroService) {} load(id: string) { return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero))); } save(hero: Hero) { return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero))); } get hero$(): Observable<Hero> { return this.hero.asObservable(); } } @Injectable()
export class HeroController implements OnDestroy { private heroSubscription: Subscription; heroForm = this.fb.group({ id: [], name: ['', Validators.required], power: ['', Validators.required] }); constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { } save() { this.heroState.save(this.heroForm.value).subscribe(); } initialize() { this.route.paramMap.pipe( map(params => params.get('id')), switchMap(id => this.heroState.load(id)), ).subscribe(); this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero)); } ngOnDestroy() { this.heroSubscription.unsubscribe(); } } @Component({
selector: 'hero', template: ` <hero-form [form]="heroController.heroForm"></hero-form> <button (click)="heroController.save()">Save</button> `, providers: [HeroController] }) export class HeroComponent { constructor(public heroController: HeroController) { this.heroController.initialize(); } } Для начала опишем абстрактный класс для слоя данных, который будет использован в сервисах слоя управления. Его конкретная реализация будет указываться через useExisting провайдер. export abstract class EntityState<T> {
abstract get entities$(): Observable<T[]>; // список сущностей abstract get selectedId$(): Observable<string>; // id выбранного элемента abstract get selected$(): Observable<T>; // выбранный элемент abstract select(id: string); // выбрать элемент с указанным id abstract load(): Observable<T[]> // загрузить список abstract save(entity: T): Observable<T>; // сохранить сущность } @Injectable()
export class EntityCardController { isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null)); constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) { } save(form: FormGroup) { this.entityState.save(form.value).subscribe({ next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }), error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 }) }) } } @Component({
selector: 'entity-card', template: ` <mat-card> <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected"> <mat-card-title> <ng-content select=".header"></ng-content> </mat-card-title> <mat-card-content> <ng-content></ng-content> </mat-card-content> <mat-card-actions> <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button> </mat-card-actions> </ng-container> <ng-template #notSelected>Select Item</ng-template> </mat-card> `, providers: [EntityCardController] }) export class EntityCardComponent { @ContentChild(EntityFormController) entityFormController: EntityFormController<any>; constructor(public entityCardController: EntityCardController) { this.entityCardController.initialize(); } } providers: [{ provide: EntityFormController, useClass: HeroFormController }]
<entity-card>
<hero-form></hero-form> </entity-card> export interface Entity {
value: string; label: string; } @Injectable() export abstract class EntityListController<T> { constructor(protected entityState: EntityState<T>) {} select(value: string) { this.entityState.select(value); } selected$ = this.entityState.selectedId$; abstract get entityList$(): Observable<Entity[]>; } @Injectable()
export class FilmsListController extends EntityListController<Film> { entityList$ = this.entityState.entities$.pipe( map(films => films.map(f => ({ value: f.id, label: f.title }))) ) } @Component({
selector: 'entity-list', template: ` <mat-selection-list [multiple]="false" (selectionChange)="entityListController.select($event.options[0].value)"> <mat-list-option *ngFor="let item of entityListController.entityList$ | async" [selected]="item.value === (entityListController.selected$ | async)" [value]="item.value"> {{ item.label }} </mat-list-option> </mat-selection-list> ` }) export class EntityListComponent { constructor(public entityListController: EntityListController<any>) {} } @Component({
selector: 'entity-page', template: ` <mat-sidenav-container> <mat-sidenav opened mode="side"> <entity-list></entity-list> </mat-sidenav> <ng-content></ng-content> </mat-sidenav-container> `, }) export class EntityPageComponent {} @Component({
selector: 'film-page', template: ` <entity-page> <entity-card> <span class="header">Film</span> <film-form></film-form> </entity-card> </entity-page> `, providers: [ { provide: EntityState, useExisting: FilmsState }, { provide: EntityListController, useClass: FilmsListController } ] }) export class FilmPageComponent {} =========== Источник: habr.com =========== Похожие новости:
Блог компании Мир Plat.Form (НСПК) ), #_javascript, #_angular, #_typescript |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 06:27
Часовой пояс: UTC + 5