Хабрахабр

[Из песочницы] Подходы к управлению модулями в Angular (и не только)

Понимание организации сущностей, с которыми работаешь — не то, что сразу получается у разработчика, пишущего свои первые проекты на Angular.

И вроде всё здорово, всё работает. И одна из проблем, к которой можно прийти — неэффективное использование Angular модулей, в частности — излишне перегруженный app модуль: создали новую компоненту, забросили в него, сервис — тоже туда. Однако со временем такой проект станет тяжело поддерживать и оптимизировать.

Благо, Angular предоставляет разработчикам возможность создавать свои модули, и ещё называет их feature модулями.

Domain Feature Модули

Поэтому первое, что стоит сделать — выделить в приложении крупные куски и вынести их в отдельные модули. Перегруженный app модуль нужно дробить.

Они призваны разделить интерфейс по признаку ключевой задачи (domain), которую выполняет каждая его часть. Популярный подход — разделение приложения на domain feature модули. Проще говоря, всё, что могло бы оказаться под пунктом меню. Примерами domain feature модулей могут быть страница редактирования профиля, страница с товарами и т.д.

image

Все объявления в синих рамках, а также контент других пунктов меню, заслуживают своих собственных domain feature модулей.

Импортируются Domain feature модули, как правило, в один, больший модуль. Domain feature модули могут использовать неограниченное количество declarables (компоненты, директивы, пайпы), однако экспортируют только ту компоненту, что представляет UI данного модуля.

Однако если и объявляют, то жизнь этих сервисов должна ограничиваться жизнью модуля. Domain Feature модули обычно не объявляют внутри себя сервисы. Эти методы будут разобраны дальше в статье. Достичь этого можно при помощи lazy loading’а или объявления сервисов во внешней компоненте модуля.

Ленивая Загрузка

Так, вы можете убрать из первоначального бандла то, что не нужно юзеру при первом открытии приложения: профиль пользователя, страничка товаров, страничка с фотографиями и т.д. Разделение приложения на Domain Feature модули позволит использовать lazy loading. Всё это можно подгрузить по требованию.

Сервисы и Инжекторы

Вопрос: где следует объявлять глобальные сервисы? Приложение разделено на крупные куски — модули, и некоторые из этих модулей загружаются по требованию. И что делать, если мы хотели бы ограничить область видимости сервиса?

Инжекторы лениво загруженных модулей

В отличие от declarables, о существовании которых необходимо объявлять в каждом модуле, где они используются, объявленные один раз в каком-либо из модулей синглтоны сервисов становятся доступными во всём приложении.

Не совсем так. Получается, что сервисы можно объявлять в любом модуле и не беспокоиться?

Лениво загруженные модули имеют свой собственный инжектор (компоненты тоже, но об этом дальше). Вышесказанное — правда, если в приложении используется только глобальный инжектор, однако зачастую всё несколько интереснее. Причина кроется в том, как работает dependency injection в Angular. Почему вообще лениво загруженные модули создают свой собственный инжектор?

Как только инжектор создаёт первый сервис, он закрывается для добавления новых провайдеров. Инжектор может пополняться новыми провайдерами до тех пор, пока он не начинает использоваться.

Это просходит ещё до создание первых компонент и до предоставления им зависимостей. Когда приложение запускается, Angular в первую очередь настраивает корневой инжектор, фиксируя в нём те провайдеры, которые были объявлены в App модуле и в импортированных в него модулях.

Подгруженному модулю не остаётся ничего, кроме как создать собственный инжектор. В ситуации, когда лениво подгружается модуль, глобальный инжектор уже много времени как был настроен и вступил в работу. Это приводит к поведению, знакомому javascript разработчикам по цепочке прототипов: если сервис не был найден в инжекторе лениво загруженного модуля, DI фрэймворк пойдёт искать его в родительском инжекторе и т.д. Этот инжектор будет дочерним по отношению к инжектору, использовавшимся в модуле, который инициализировал загрузку.

Провайдеры также можно переопределять, опять же, прямо как в javascript прототипах. Таким образом, лениво загруженные модули позволяют объявлять сервисы, которые будут доступны только в рамках данного модуля.

Возвращаясь к domain feature модулям, описанное поведение — один из способов ограничить жизнь объявленных в них провайдеров.

Core Модуль

Простой ответ — в App модуле. Так всё-таки, где следует объявлять глобальные сервисы, такие как сервисы авторизации, API сервисы, Юзер сервисы и т.д.? Результат будет тот же, как если бы сервисы были объявлены напрямую в App модуле. Однако в целях наведения порядка в App модуле (этим то мы и занимаемся), следует объявлять глобальные сервисы в отдельном модуле, получившем название Core модуль, и импортировать его ТОЛЬКО в App модуль.

Всё, что нужно сделать — добавить в Injectable опцию providedIn, и указать в ней значение ‘root’. Начиная с версии 6, в ангуляре появилась возможность объявлять глобальные сервисы, никуда их не импортируя. Сервисы, объявленные таким образом, становятся доступными всему приложению, а потому отпадает необходимость объявлять их в модуле.

Помимо того, что данный подход смотрит в светлейшее будущее ангуляра без модулей, он также помогает оттришэйкать ненужный код.

Проверка на Синглтон

Можно ли от этого защититься? Но что, если кто-то в проекте захочет импортировать Core модуль ещё куда-нибудь? Можно.

Если инжектор положит в переменную зависимость, значит кто то пытается повторно объявить Core модуль. Добавьте в Core модуль конструктор, который просит заинжектить в него Core модуль (всё верно, самого себя), и пометьте это объявление декораторами Optional и SkipSelf.

Использование подхода в BrowserModule

Использование описанного подхода в BrowserModule.

Этот подход может использоваться как с модулями, так и с сервисами.

Объявление Сервиса в Компоненте

Мы уже рассмотрели способ ограничения области видимости провайдеров, используя lazy loading, но вот ещё один.

А ещё — дополнительное свойство viewProviders. Каждый инстанс компоненты имеет свой собственный инжектор, и для его настройки, прямо как декоратор NgModule, декоратор Component имеет свойство providers. Они оба служат для настройки инжектора компоненты, однако провайдеры, объявленные каждым из способов, имеют разную область видимости.

Для понимания разницы, нужна коротенькая предыстория.

Компонента состоит из view и контента.

Я вью компоненты

Вью компоненты

Я контент компоненты

Контент компоненты

Всё, что находится в html файле компоненты — это её view, тогда как то, что передаётся между открывающим и закрывающим тэгами компоненты, является её контентом.

Полученный результат:

Полученный результат

Полученный результат

Тогда как viewProviders, как и заложено в названии, делает сервисы видимыми только для вью и закрывает их для контента. Так вот, провайдеры, добавленные в providers, доступны как во view компоненты, в которой они объявлены, так и для контента, который передан компоненте.

Несмотря на то, что лучшая практика — объявлять сервисы в root инжекторе, существуют сценарии, когда использование инжектора компоненты приходится на руку:

Например сервис, который хранит специфичные для каждого нового экземпляра компоненты данные. Первый — это когда каждый новый экземпляр компоненты должен иметь свой собственный экземпляр сервиса.

В таком случае, мы объявим провайдер в самой внешней компоненте, той самой, которая экспортируется из модуля. Для другого сценария нам нужно вспомнить, что, хоть Domain feature модули и могут объявлять какие-то нужные только им провайдеры, желательно чтобы эти провайдеры умирали вместе с этими модулями.

Нужный только этой части приложения сервис мы объявим в providers самой внешней компоненты, UserProfileComponent. Например, domain feature module, отвечающий за профиль пользователя. Теперь все declarables, которые объявлены в разметке этой компоненты, а также переданы ей в контенте, получат один и тот же экземпляр сервиса.

Переиспользуемые Компоненты

На этот вопрос также нет однозначного ответа, но есть наработанные подходы. Что делать с компонентами, которые мы хотим переиспользовать?

Shared Модуль

Все переиспользуемые в проекте компоненты можно хранить в одном модуле, экспортируя их из него и импортируя его в те модули проекта, где эти компоненты могут понадобиться.

В такой модуль можно поместить компоненты кнопки, выпадающего списка, какого нибудь стилизованного блока текста и т.д, а также кастомные директивы и пайпы.

Такой модуль обычно имеет название SharedModule.

Или объявлять, используя forRoot подход. При этом важно заметить, что SharedModule не должен объявлять сервисов. О нём поговорим чуть позже.

Несмотря на то, что подход c SharedModules работает, к нему есть пара замечаний:

  1. Мы не сделали структуру приложения чище, мы просто переложили беспорядок из одного места в другое;
  2. Этот подход не смотрит в светлое будущее Angular, в котором не будет модулей.

Подход, который лишён этих недостатков, есть и предполагает создание модуля для каждой компоненты.

Module Per Component или SCAM (single component angular module)

В этот же же модуль необходимо импортировать зависимости компоненты. Создавая какую-либо новую компоненту, следует помещать её в свой собственный модуль.

image

Каждый раз, когда определенная компонента нужна в каком-либо месте приложения, всё, что нужно сделать — это импортировать модуль данной компоненты.

Хотя в названии есть слово component, этот подход распространяется также на пайпы и директивы (SPAM, SDAM). На английском такой подход называется module per component или SCAM — single component angular module.

Так как модуль, создаваемый для компоненты, экспортирует её, а также уже содержит все нужные ей зависимости, для настройки TestBed достаточно положить этот модуль в imports. Наверное самое значительное преимуществ данного подхода — облегчение тестирования компонент.

Немного заглянуть в будущее можно через эту статью. Такой подход способствует порядку и структуре в коде проекта, а также готовит нас к будущему без модулей, где для использования одной компоненты в разметке другой нужно будет лишь объявить зависимости в директиве Component.

Интерфейс ModuleWithProviders

У Angular есть на этот случай набор правил, который может не соответствовать тому, что ожидает разработчик. Если в проекте завёлся модуль, содержащий в себе объявление сервисов XYZ, и так получилось, что со временем этот модуль начал использоваться повсеместно, каждый импорт этого модуля будет пытаться добавить сервисы XYZ в соответствующий инжектор, что неизбежно приведёт к коллизиям. Особенно это касается инжектора лениво загруженного модуля.

И это именно то, что нужно в описанном выше случае. Для избежания проблем с коллизией, Angular предоставляет интерфейс ModuleWithProviders, который позволяет прикрепить провайдеры к модулю, оставив при этом providers самого модуля нетронутым.

Стратегии forRoot(), forChild()

Со стороны импортируемого модуля нужно лишь создать статический метод, возвращающий ModuleWithProviders, который исторически получил название forRoot. Для того, чтобы сервисы точно были зафиксированы в глобальном инжекторе, модуль с провайдерами импортируется только в AppModule.

image

forRoot — это скорее удобная условность, чем требование. Методов, возвращающих ModuleWithProviders, может быть сколько угодно, и названы они могут быть как угодно.

Например, RouterModule имеет статический метод forChild, который используется для настройки роутинга в лениво загруженных модулях.

Заключение:

  1. Разделяйте пользовательский интерфейс по ключевым задачам и создавайте для каждой выделенной части свой модуль: кроме более удобной для понимания структуры кода проекта, получите возможность лениво загружать части интерфейса
  2. Используйте инжекторы лениво загруженных модулей и компонент, если того требует архитектура приложения
  3. Выносите объявления глобальных сервисов в отдельный модуль, Core модуль, и импортируйте его только в app модуль. Это поможет в очистке app модуля
  4. А лучше используйте опцию providedIn со значанием 'root' декоратора Injectable
  5. Используйте хак с декораторами Optional и SkipSelf, чтобы предотвратить повторный импорт модулей и сервисов
  6. Храните переиспользуемые компоненты, директивы и пайпы в Shared модуле
  7. Однако лучший подход, который ещё и в будущее смотрит, и облегчает тестирование — создание модуля для каждой компоненты (директивы и пайпы тоже)
  8. Используйте интерфейс ModuleWithProviders, если хотите избежать коллизии провайдеров. Популярный подход — реализация метода forRoot для добавления провайдеров в корневом модуле
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть