Привет! Меня зовут Самат, я frontend-специалист компании SimbirSoft.
Моё первое знакомство с Angular началось с погружения в документацию, которая помогла мне лучше понять, как устроен этот фреймворк. Наверняка, этого было бы достаточно, чтобы подключиться к проекту или заняться его разработкой с нуля.
Но если после подключения разработчик заходит в уже выстроенную систему, и его задачи редко связаны с архитектурой, то в разработке с нуля ситуация обстоит иначе, даже если на первый взгляд кажется, что вопросов не возникнет. Они появятся позже, когда окажется, что нельзя легко и просто внести важную фичу или безопасно изменить часть приложения, так как она тесно связана с другой частью. А компоненты приложения, которые мы сами же и писали, покажутся незнакомыми и сложными.
Цель этой статьи – познакомиться с темой построения архитектуры Angular-приложений. Я расскажу о том, как разработать приложения таким образом, чтобы специалист любого уровня при наличии знаний Angular мог легко разобраться в коде и структуре приложения. И как избежать проблем масштабирования.
На мой взгляд, можно выделить три основных показателя качества для любого приложения:
Масштабируемость.
Скорость отладки проекта.
Скорость погружения разработчика в проект.
Все три пункта взаимосвязаны, и разработчики Angular постарались создать все условия по выстраиванию архитектуры.
Для этого в Angular из коробки есть сущности, которые позволяют писать код структурированно. Многие разработчики отказываются от них из-за того, что не знают, как именно их применять, или используют неправильно. Это приводит к большим затратам сил и времени. На примере ниже я постараюсь разобрать эти ситуации.
Модули
Самой главной сущностью в Angular являются модули. Цель модуля – объединить между собой компоненты, сервисы и прочие сущности, а также связать их семантически.
В Angular 14 завезли standalone-компоненты, но пока они идут с флагом developer preview. Уже анонсирован Angular 15, где будут стабилизировать фичу и выводить ее в массы. На мой взгляд, фича будет очень полезной, поскольку позволит уменьшить общее количество модулей. По факту standalone-компонент можно считать модуль-компонентом – ярким примером может стать страница с набором компонентов на ней. Чтобы понять, почему я его так назвал, можно обратиться к этой странице.
Несмотря на то, что сейчас есть возможность построить всё приложение на standalone-компонентах, я бы все же применял эту фичу к shared-сущностям, а действительно важные и большие части приложения оставлял в модулях. Ведь наверняка у многих возникал вопрос: зачем нужна отдельная обертка NgModule, почему мы не можем импортировать сущность напрямую?
Итак, предположим, что у нас большое приложение, тогда можно прикинуть общую структуру следующим образом:
├── App
├── Pages/
│ ├── page1
│ ├── page2
│ ├── …..
│ └── pageN
└── Shared/
├── Modules
├── Components
├── Pipes
├── Interceptors
├── Guards
├── Services
├── Directives
└── Providers
Стоит обратить внимание: есть папка с модулями и есть папка с компонентами. В чем разница? Разница в том, что могут быть сложные shared-компоненты, содержащие в себе полный набор сущностей, очевидно, что логичнее будет назвать его модулем. Примером может стать MatDialogModule из material-angular, который включает в себя несколько сущностей.
Так как провайдерами могут быть не только сервисы, то хорошо бы отделять их друг от друга – для этого существует Providers. Речь о них пойдет ниже.
App – это основной модуль нашего приложения:
└── App/
├── app.module.ts
├── app.routing-routing.module.ts
├── app.component.ts
└── …..
Pages – это страницы приложения, значит доступ к ним будет воспроизводиться в app.routing-routing.module.ts.
В основном такие роуты выгоднее прописывать через loadChildren, что будет выглядеть примерно так:
{
...
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
children: [
{
path: '',
redirectTo: 'contracts',
pathMatch: 'full',
},
{
path: 'contracts',
loadChildren: () =>
import('../Pages/contracts-page/contracts-page.module').then(
(m) => m.ContractsPageModule
),
},
{
path: 'control',
loadChildren: () =>
import('../Pages/control-page/control-page.module').then(
(m) => m.ControlPageModule
),
},
],
},
Чтобы везде вручную не прописывать canActive, можно строить роуты такой структурой, то есть помещать их в children. Конечно, не исключая вариант частных случаев.
Далее предположим, что большинство страниц являются модулями. Поскольку сам модуль равносилен app-модулю, то у него могут быть свои сущности. И в нашем случае придется прописать роуты, иначе Angular не поймет, что ему нужно делать.
Реализовать это очень просто: если в app.module.ts RouterModule импортируется через .forRoot(appRoutes), то в модуле страницы (childPage.module.ts) это будет происходить через .forChild(childPageRoutes), где childPageRoutes – роуты из childPage-routing.module.ts. Я назову это просто childPage.routes.ts, так визуально воспринимается гораздо проще.
У childPageModule могут быть собственные вложенные роуты, компоненты, сервисы и любые Angular-сущности. Важно понимать и разделять их. Не нужно использовать сущность из соседнего модуля, если она лежит на уровне другого модуля! Это сильно сбивает с толку и даже 100%-ное покрытие тестами не всегда позволяет избежать последствий.
Например, пришла задача, где нужно внести изменение в поведении какой-то части приложения, а эта часть является дочерним модулем page1 модуля и лежит на его уровне.
Разработчик внес изменения в child-module, являющийся дочерним модулем page1. Тесты прошли, тестировщик тоже утвердил, ведь в задаче идет речь о page1 и его дочернем модуле. Но child-module был импортирован еще в page2, page3, page, что привело к неожиданному поведению приложения. Проблема чаще обнаруживается уже на продакшене. Сомневаюсь, что кто-то будет доволен таким результатом. И все потому, что подобные компоненты должны лежать на уровне Shared, а разработчик не должен был импортировать сущности из соседнего модуля. Тогда такой проблемы скорее всего не возникло. Описанная ситуация значительно упрощена, ведь на практике все протекает гораздо сложнее и это явление не такое редкое, как может показаться.
Резюме:
Модуль не должен импортировать сущности из дочернего/соседнего модуля (за исключением shared-модулей), а если возникла такая потребность, то необходимо вынести сущность на shared-уровень.
Модуль должен стремиться быть независимым от других частей приложения.
Модуль можно считать как отдельное мини-приложение.
Такой подход позволяет дробить все приложение на логические части. Назвать все независимыми сущностями сложно, но желательно стремиться к этому, так как вполне реально.
Shared – здесь название говорит само за себя. Такая иерархия папок лишь пример – назвать можно и по-другому. Суть в том, что здесь располагаются сущности общего применения, в которых находятся компоненты, пайпы, сервисы, модули и прочее. То есть, shared-сущности мы можем использовать где угодно, будучи уверенными в их поведении.
Наверняка каждому middle-разработчику известно о вышеупомянутых сущностях. Но хотелось бы отметить важные моменты:
Директивы, пожалуй, самая недооцененная сущность в Angular-проектах. В ходе работы над некоторыми проектами я замечал, что поведение компонента и манипуляции с DOM производится на одном уровне, хотя можно было бы вынести это в директиву, тем самым сократив объем кода компонента. Приятным бонусом стала бы возможность переиспользования такой логики буквально в одну строчку, что значительно бы снизило визуальную нагрузку разработчика.
Уровни абстракций. Самым часто встречающимся примером являются сервисы. Не нужно хранить их все в одном месте, иначе наступит день, когда папка Services будет содержать в себе десятки файлов, в которых сложно будет ориентироваться. Пусть будут глобальные и локальные сервисы. Преимущества:
глобальный сервис доступен везде;
локальный может быть доступен в рамках целого модуля или одного компонента. Они должны располагаться на соответствующем уровне, то есть сервис модуля должен находится рядом с модулем, сервис компонента – рядом с компонентом. Это обеспечит, на мой взгляд, лучшую семантику и взаимодействие между ними.
Компоненты
С компонентами, казалось бы, все просто, но и тут встречаются проблемы. В статье выше я упоминал директивы, которые позволяют упростить логику компонента. Но это не всё.
Я бы также разделил модульные компоненты на уровни ответственности помимо того, что есть shared-компоненты, которые доступны во всем приложении. Часто компоненты делят на «умные» и «глупые». В общем случае у нас будет один «умный» компонент, который станет поставщиком данных в «глупые» компоненты.
Он будет включать в себя:
Работу с сервисами, которые поставляют и отправляют данные.
Общение с приложением.
Обработку маршрутизации.
Передачу данных в дочерние компоненты.
Обработку событий из дочернего компонента.
«Глупые» компоненты – это те, которые в общих случаях будут лишь частями отображения и обработки данных «умного» компонента.
Для этого существуют декораторы – @Input() и @Output().
@Input() – принимает в себя данные.
@Output() – отдает данные или события для базового компонента.
Таким образом, количество кода в «умном» компоненте сильно сократится.
Не стоит экономить время на процедуре деления «умного» компонента на части – на этом этапе чаще всего и приходит понимание того, что компонент можно вынести на уровень shared-компонента.
Хотелось бы отдельное внимание уделить абстракциям, которые нам доступны из коробки, но почему-то так мало применяемых на практике, где это необходимо.
Предположим, у нас есть N модулей, которые работают схожим образом. У каждого из них есть состояние и свой собственный цикл жизни.
Пусть это будет простая браузерная игра, где есть состояния, которые могут выглядеть так:
enum GameStates {
STOP = 'stop',
PLAY = 'play',
PAUSE = 'pause',
RESULT = 'result'
}
Тогда состояние игр можно описать абстрактным классом:
export abstract class GameStateController {
constructor() {}
$gameState: BehaviorSubject<GameState> = new BehaviorSubject(GameState.STOP);
setState(state: GameState) {
this.$gameState.next(state);
this.onChangeState(state);
}
abstract onChangeState(state: GameState): void;
}
И его можно крайне просто использовать в любом компоненте:
@Component({
selector: 'app-some-game-component',
template: '
<ng-container [ngSwitch]="$gameState | async">
<ng-container *ngSwitchCase="'STOP'">
…
</ng-container>
<ng-container *ngSwitchCase="'PLAY'">
…
</ng-container>
</ng-container>
',
})export class SomeGameComponent extends GameStateController {
constructor(private someGameService: SomeGameService) {
super()
}
ngOnInit(): void {
this.setState(GameState.PLAY);
}
onChangeState(state: GameState): void {
switch (state) {
case GameState.PAUSE: {
…
}
case GameState.PLAY: {
…
}
…
}
}
}
Так наследование позволило сократить логику N компонентов для игр. Теперь нам не нужно описывать каждый раз состояние игры в каждом компоненте, а достаточно будет просто наследоваться от абстрактного класса.
И если потребуется масштабирование, то ничего не помешает расширять класс. Также акцентирую внимание на том, что для описания состояния использовался RxJS, позволяющий описывать конструкции намного очевиднее, нежели прописывать переменные по типу isAnySate: boolean. А в шаблоне разместился пайп async, который позволяет удобно работать с подобными переменными.
Сервисы и провайдинг
Я подключался к проектам, где в корневой директории есть папка Services, в которой лежат абсолютно все сервисы приложения. Наверное, это уместно лишь в самых маленьких приложениях. К тому же, один и тот же компонент может использовать несколько сущностей, а сервис может содержать в себе другой сервис – образуется чересчур сложная паутина связей. В ней тяжело разобраться как логически, так и визуально, потому что увидев одну папку с кучей сервисов, нам кажется, что они равносильны друг другу.
Если разложить архитектуру компонента и сервисов на слои, то получится:
Упрощенный пример структуры:
├── Core/
│ ├── StateService
│ └── ItemsApiService
└── pageN/
├── ItemsComponent
└── ItemsService
@Component({
selector: 'app-items-component',
templateUrl: './page1.component.html',
styleUrls: ['./page1.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ItemsComponent implements OnInit {
items$: Observable<Item[]>;
constructor(private itemsService: ItemsService) {
this.items$ = itemsService.items$;
}
ngOnInit(): void {
this.itemsService.LoadItems();
}
addItem(item: Item) {
this.itemsService.addItem(item);
}
deteleItem(item: Item) {
this.itemsService.deleteItem(item);
}
updateItem(item: Item) {
this.itemsService.updateItem(item);
}
...................
}
@Injectable()
export class ItemsService {
items$: BehaviorSubject<Item[]> = new BehaviorSubject<Item[]>([]);
constructor(private itemsApi: ItemsApi) { }
LoadItems() {
this.itemsApi.getItems()
.subscribe((items: Item[]) => this.setItems(items));
}
addItem(item: Item) {
this.itemsApi.addItem(item)
.subscribe((item: Item) => this.setItems([...this.items$.getValue(), item]));
}
updateItem(item: Item) {
this.itemsApi.updateItem(item)
.subscribe((items: Item[]) => this.setItems(items);
}
private setItems(items: Item[]) {
this.items$.next(items);
}
.....................
}
На кратком примере выше представлен пример структуры, где компонент не принимает прямого участия в управлении данными, а делает это через сервис ItemsServic, но при этом в компоненте нет подписок. Если взглянуть на сервис, то видно, что он обращается еще на уровень выше, а именно – к ItemsApiService, который общается с бэкэндом. Более того, ItemsService позволяет легко оперировать потоками, если это необходимо, не перегружая логику компонента.
Стоит помнить, что сервисы зарегистрированные с компонентом и дестроятся вместе с ним. У них даже есть хук ngOnDestroy, в котором можно произвести, например, отписки и не бояться утечек памяти.
Что касается провайдинга, тут еще интереснее. Если нужен уникальный инстанс сервиса внутри компонента, то можно запровайдить сервис внутри компонента:
@Component({
...
providers: [AnyService]
})
Angular DI бежит вверх по цепочке и ищет ближайший инжектор – это означает, что в таком компоненте по дефолту будет принят компонентный провайдер.
Для инжекта существуют декораторы:
@Self() – ищет зависимость в провайдерах компонента.
@SkipSelf() – ищет зависимость по цепочке исключая провайдеров компонента.
@Host() – будет искать зависимость в инжекторе любого компонента, пока не достигнет хоста.
@Optional() – используется наиболее часто в совокупности с декораторами выше, в случае если DI не найдет зависимость, то вернет null.
Получается, что если в провайдерах модуля и компонента есть один и тот же сервис, то для того, чтобы иметь доступ к двум инстансам, необходимо инжектировать второй сервис с декоратором @SkipSelf().
Либо можно воспользоваться InjectionToken и создать уникальный инстанс сервиса:
export const moduleInjectToken = new InjectionToken('Module injection token');
@NgModule({
declarations: [
...
],
imports: [
...
],
providers: [
...,
ClicksService,
{
provide: moduleInjectToken, useClass: ClicksService
}
]
})
Для получения доступа к такому сервису нужно использовать декоратор @Inject(token: any):
constructor(
@Inject(moduleInjectToken) public clicksService: ClicksService
)
Полезно будет в случае с вложенными компонентами.
Кстати, провайдить можно что угодно. Например, если имеем кейс с данными, которые не придется как-либо менять, можно сделать частный провайдер:
export const ITEMS_TOKEN = new InjectionToken('Items token');
export const itemsFactory = (itemsApi: ItemsApi): Observable<Item[]> =>
itemsApi.getItems().pipe(shareReplay());
providers: [
{
provide: ITEMS_TOKEN,
useFactory: itemsFactory,
deps: [ItemsApi]
}
]
Получаем доступ к провайдеру из компонента, и можем использовать его прямо в разметке с помощью async-пайпа:
@Inject(ITEMS_TOKEN) public items$: Observable<Item[]>
Observable идет вместе с пайпом ShareReplay() для того, чтобы кэшировать данные.
Больше информации о частных провайдерах можно прочесть тут.
Интересный материал про DI & IoC – тут.
Директивы
Из документации Angular следует, что существуют три основных вида директив: структурные, атрибутивные и сами компоненты. Здесь стоит обратить внимание, что компонент – это тоже директива, но у него есть шаблон, и это единственная принципиальная разница между директивой и компонентом. Нас же интересуют кастомные директивы, которые можно создавать самим.
Возникает вопрос: зачем нам создавать директиву, если все можно сделать в самом компоненте?
Мы сможем легко переиспользовать ее. Достаточно будет обернуть директиву в модуль (директива может быть объявлена только в одном модуле) и экспортировать ее.
Вынести часть логики из компонента и разгрузить его.
Наверняка, если каждый, кто читает эту статью, откроет рабочий проект, то обязательно найдет хотя бы один компонент, в котором есть взаимодействие с DOM. Если посмотреть документацию директив, то там приведены примеры с событиями mouseenter/mouseleave. Скорее всего, читающие подумают — зачем так делать, если есть :hover, а весь пример можно поместить внутри компонента? И пойдут дальше. Но не всегда можно что-то реализовать посредством CSS и это не единственные возможности директив.
У директив тоже могут быть свои провайдеры. На основе примера с провайдингом выше мы можем провернуть с директивой подобное:
@Directive({
selector: '[someDirective]',
providers: [
{
provide: SOME_TOKEN,
useFactory: someFactory,
deps: [...]
}
]
})
export class SomeDirective {
@Output() some: Observable<Any>;
constructor(@Inject(SOME_TOKEN) public some$: Observable<Any>) {
this.items = items$;
}
}
В шаблоне:
<div someDirective (some)="onSomeEvent($event)">
...
</div>
Пример сильно упрощенный, но если вместо some подставить, например, resize, то так мы опишем логику отслеживания изменений размеров элемента/окна. Тогда появится директива, которую можно будет использовать где угодно. Таких примеров можно привести целую массу, возможно, даже в вашем проекте есть несколько компонентов, использующих отслеживание изменений размеров элемента, описанных внутри себя. Попробуйте вынести такую логику в директиву.
Решение задач на проекте
На мой взгляд, решение задач должно выстраиваться в следующем порядке:
Ознакомление с задачей.
Техническая проработка.
Решение задачи.
С 1-м и 3-м пунктом, полагаю, все понятно. А техническая проработка – это теоретическая модель решения задачи, которую я бы рекомендовал ревьюить.
Как она связана с архитектурой?
Оформляя техническую проработку, мы сначала документируем то, что делаем по факту – а это часть архитектуры. И на данном этапе хорошо выявляются потенциальные архитектурные недочеты, не говоря об ошибках, нарушениях паттернов и др.
Почему недочеты?
Потому что модель может быть решением. Но наша работа как игра в шахматы – нужно думать на несколько шагов вперед, и даже если сегодня заказчик говорит «нам не пригодится», то завтра это может стать следующей задачей. Нужно стараться учесть все заранее, чтобы не пришлось сносить целые модули. И речь не об оверинжиниринге, а том, что не стоит минимизировать потенциальную масштабируемость с мыслями, что это точно не понадобится.
Это база, которую нужно освоить с самого начала на техническом уровне. Очевидно, есть частные случаи, но здесь же описаны общие основы, которые помогут в будущем. Это сильно сократит время погружения разработчика в проект, поскольку структура будет очевидна с первого взгляда. Кроме того, документирование станет проще, потому что каждая сущность будет иметь единственный уровень ответственности. Что касается частных случаев, то для них необходимо и частное решение.
Скорость отладки и масштабируемость качественно возрастут, потому что снизится количество потенциальных багов. А отладить сущность с единственной ответственностью куда проще, чем нечто, мутировавшее в «монстра» из тысяч строк кода. Масштабируемость пройдет проще, а в некоторых задачах будет занимать меньше времени, так как разработчик начнет использовать то, что уже написано. При таком раскладе и тесты пишутся легче.
Вывод
Мы рассмотрели одну из возможных моделей построения Angular-приложения. Приведенные выше примеры – лишь рекомендации, описанные простым языком. Они не являются строгими правилами разработки, но на них стоит обратить внимание при построении архитектуры.
Спасибо за внимание! Полезные материалы для разработчиков мы также публикуем в наших соцсетях – ВК и Telegram.