Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Понимание организации сущностей, с которыми работаешь — не то, что сразу получается у разработчика, пишущего свои первые проекты на Angular.
И одна из проблем, к которой можно прийти — неэффективное использование Angular модулей, в частности — излишне перегруженный app модуль: создали новую компоненту, забросили в него, сервис — тоже туда. И вроде всё здорово, всё работает. Однако со временем такой проект станет тяжело поддерживать и оптимизировать.
Благо, Angular предоставляет разработчикам возможность создавать свои модули, и ещё называет их feature модулями.
Domain Feature Модули
Перегруженный app модуль нужно дробить. Поэтому первое, что стоит сделать — выделить в приложении крупные куски и вынести их в отдельные модули.
Популярный подход — разделение приложения на domain feature модули. Они призваны разделить интерфейс по признаку ключевой задачи (domain), которую выполняет каждая его часть. Примерами domain feature модулей могут быть страница редактирования профиля, страница с товарами и т.д. Проще говоря, всё, что могло бы оказаться под пунктом меню.
Все объявления в синих рамках, а также контент других пунктов меню, заслуживают своих собственных domain feature модулей.
Domain feature модули могут использовать неограниченное количество declarables (компоненты, директивы, пайпы), однако экспортируют только ту компоненту, что представляет UI данного модуля. Импортируются Domain feature модули, как правило, в один, больший модуль.
Domain Feature модули обычно не объявляют внутри себя сервисы. Однако если и объявляют, то жизнь этих сервисов должна ограничиваться жизнью модуля. Достичь этого можно при помощи lazy loading’а или объявления сервисов во внешней компоненте модуля. Эти методы будут разобраны дальше в статье.
Ленивая Загрузка
Разделение приложения на Domain Feature модули позволит использовать lazy loading. Так, вы можете убрать из первоначального бандла то, что не нужно юзеру при первом открытии приложения: профиль пользователя, страничка товаров, страничка с фотографиями и т.д. Всё это можно подгрузить по требованию.
Сервисы и Инжекторы
Приложение разделено на крупные куски — модули, и некоторые из этих модулей загружаются по требованию. Вопрос: где следует объявлять глобальные сервисы? И что делать, если мы хотели бы ограничить область видимости сервиса?
Инжекторы лениво загруженных модулей
В отличие от declarables, о существовании которых необходимо объявлять в каждом модуле, где они используются, объявленные один раз в каком-либо из модулей синглтоны сервисов становятся доступными во всём приложении.
Получается, что сервисы можно объявлять в любом модуле и не беспокоиться? Не совсем так.
Вышесказанное — правда, если в приложении используется только глобальный инжектор, однако зачастую всё несколько интереснее. Лениво загруженные модули имеют свой собственный инжектор (компоненты тоже, но об этом дальше). Почему вообще лениво загруженные модули создают свой собственный инжектор? Причина кроется в том, как работает dependency injection в Angular.
Инжектор может пополняться новыми провайдерами до тех пор, пока он не начинает использоваться. Как только инжектор создаёт первый сервис, он закрывается для добавления новых провайдеров.
Когда приложение запускается, Angular в первую очередь настраивает корневой инжектор, фиксируя в нём те провайдеры, которые были объявлены в App модуле и в импортированных в него модулях. Это просходит ещё до создание первых компонент и до предоставления им зависимостей.
В ситуации, когда лениво подгружается модуль, глобальный инжектор уже много времени как был настроен и вступил в работу. Подгруженному модулю не остаётся ничего, кроме как создать собственный инжектор. Этот инжектор будет дочерним по отношению к инжектору, использовавшимся в модуле, который инициализировал загрузку. Это приводит к поведению, знакомому javascript разработчикам по цепочке прототипов: если сервис не был найден в инжекторе лениво загруженного модуля, DI фрэймворк пойдёт искать его в родительском инжекторе и т.д.
Таким образом, лениво загруженные модули позволяют объявлять сервисы, которые будут доступны только в рамках данного модуля. Провайдеры также можно переопределять, опять же, прямо как в javascript прототипах.
Возвращаясь к domain feature модулям, описанное поведение — один из способов ограничить жизнь объявленных в них провайдеров.
Core Модуль
Так всё-таки, где следует объявлять глобальные сервисы, такие как сервисы авторизации, API сервисы, Юзер сервисы и т.д.? Простой ответ — в App модуле. Однако в целях наведения порядка в App модуле (этим то мы и занимаемся), следует объявлять глобальные сервисы в отдельном модуле, получившем название Core модуль, и импортировать его ТОЛЬКО в App модуль. Результат будет тот же, как если бы сервисы были объявлены напрямую в App модуле.
Начиная с версии 6, в ангуляре появилась возможность объявлять глобальные сервисы, никуда их не импортируя. Всё, что нужно сделать — добавить в Injectable опцию providedIn, и указать в ней значение ‘root’. Сервисы, объявленные таким образом, становятся доступными всему приложению, а потому отпадает необходимость объявлять их в модуле.
Помимо того, что данный подход смотрит в светлейшее будущее ангуляра без модулей, он также помогает оттришэйкать ненужный код.
Проверка на Синглтон
Но что, если кто-то в проекте захочет импортировать Core модуль ещё куда-нибудь? Можно ли от этого защититься? Можно.
Добавьте в Core модуль конструктор, который просит заинжектить в него Core модуль (всё верно, самого себя), и пометьте это объявление декораторами Optional и SkipSelf. Если инжектор положит в переменную зависимость, значит кто то пытается повторно объявить Core модуль.
Использование описанного подхода в BrowserModule.
Этот подход может использоваться как с модулями, так и с сервисами.
Объявление Сервиса в Компоненте
Мы уже рассмотрели способ ограничения области видимости провайдеров, используя lazy loading, но вот ещё один.
Каждый инстанс компоненты имеет свой собственный инжектор, и для его настройки, прямо как декоратор NgModule, декоратор Component имеет свойство providers. А ещё — дополнительное свойство viewProviders. Они оба служат для настройки инжектора компоненты, однако провайдеры, объявленные каждым из способов, имеют разную область видимости.
Для понимания разницы, нужна коротенькая предыстория.
Компонента состоит из view и контента.
Вью компоненты
Контент компоненты
Всё, что находится в html файле компоненты — это её view, тогда как то, что передаётся между открывающим и закрывающим тэгами компоненты, является её контентом.
Полученный результат:
Полученный результат
Так вот, провайдеры, добавленные в providers, доступны как во view компоненты, в которой они объявлены, так и для контента, который передан компоненте. Тогда как viewProviders, как и заложено в названии, делает сервисы видимыми только для вью и закрывает их для контента.
Несмотря на то, что лучшая практика — объявлять сервисы в root инжекторе, существуют сценарии, когда использование инжектора компоненты приходится на руку:
Первый — это когда каждый новый экземпляр компоненты должен иметь свой собственный экземпляр сервиса. Например сервис, который хранит специфичные для каждого нового экземпляра компоненты данные.
Для другого сценария нам нужно вспомнить, что, хоть Domain feature модули и могут объявлять какие-то нужные только им провайдеры, желательно чтобы эти провайдеры умирали вместе с этими модулями. В таком случае, мы объявим провайдер в самой внешней компоненте, той самой, которая экспортируется из модуля.
Например, domain feature module, отвечающий за профиль пользователя. Нужный только этой части приложения сервис мы объявим в providers самой внешней компоненты, UserProfileComponent. Теперь все declarables, которые объявлены в разметке этой компоненты, а также переданы ей в контенте, получат один и тот же экземпляр сервиса.
Переиспользуемые Компоненты
Что делать с компонентами, которые мы хотим переиспользовать? На этот вопрос также нет однозначного ответа, но есть наработанные подходы.
Shared Модуль
Все переиспользуемые в проекте компоненты можно хранить в одном модуле, экспортируя их из него и импортируя его в те модули проекта, где эти компоненты могут понадобиться.
В такой модуль можно поместить компоненты кнопки, выпадающего списка, какого нибудь стилизованного блока текста и т.д, а также кастомные директивы и пайпы.
Такой модуль обычно имеет название SharedModule.
При этом важно заметить, что SharedModule не должен объявлять сервисов. Или объявлять, используя forRoot подход. О нём поговорим чуть позже.
Несмотря на то, что подход c SharedModules работает, к нему есть пара замечаний:
- Мы не сделали структуру приложения чище, мы просто переложили беспорядок из одного места в другое;
- Этот подход не смотрит в светлое будущее Angular, в котором не будет модулей.
Подход, который лишён этих недостатков, есть и предполагает создание модуля для каждой компоненты.
Module Per Component или SCAM (single component angular module)
Создавая какую-либо новую компоненту, следует помещать её в свой собственный модуль. В этот же же модуль необходимо импортировать зависимости компоненты.
Каждый раз, когда определенная компонента нужна в каком-либо месте приложения, всё, что нужно сделать — это импортировать модуль данной компоненты.
На английском такой подход называется module per component или SCAM — single component angular module. Хотя в названии есть слово component, этот подход распространяется также на пайпы и директивы (SPAM, SDAM).
Наверное самое значительное преимуществ данного подхода — облегчение тестирования компонент. Так как модуль, создаваемый для компоненты, экспортирует её, а также уже содержит все нужные ей зависимости, для настройки TestBed достаточно положить этот модуль в imports.
Такой подход способствует порядку и структуре в коде проекта, а также готовит нас к будущему без модулей, где для использования одной компоненты в разметке другой нужно будет лишь объявить зависимости в директиве Component. Немного заглянуть в будущее можно через эту статью.
Интерфейс ModuleWithProviders
Если в проекте завёлся модуль, содержащий в себе объявление сервисов XYZ, и так получилось, что со временем этот модуль начал использоваться повсеместно, каждый импорт этого модуля будет пытаться добавить сервисы XYZ в соответствующий инжектор, что неизбежно приведёт к коллизиям. У Angular есть на этот случай набор правил, который может не соответствовать тому, что ожидает разработчик. Особенно это касается инжектора лениво загруженного модуля.
Для избежания проблем с коллизией, Angular предоставляет интерфейс ModuleWithProviders, который позволяет прикрепить провайдеры к модулю, оставив при этом providers самого модуля нетронутым. И это именно то, что нужно в описанном выше случае.
Стратегии forRoot(), forChild()
Для того, чтобы сервисы точно были зафиксированы в глобальном инжекторе, модуль с провайдерами импортируется только в AppModule. Со стороны импортируемого модуля нужно лишь создать статический метод, возвращающий ModuleWithProviders, который исторически получил название forRoot.
Методов, возвращающих ModuleWithProviders, может быть сколько угодно, и названы они могут быть как угодно. forRoot — это скорее удобная условность, чем требование.
Например, RouterModule имеет статический метод forChild, который используется для настройки роутинга в лениво загруженных модулях.
Заключение:
- Разделяйте пользовательский интерфейс по ключевым задачам и создавайте для каждой выделенной части свой модуль: кроме более удобной для понимания структуры кода проекта, получите возможность лениво загружать части интерфейса
- Используйте инжекторы лениво загруженных модулей и компонент, если того требует архитектура приложения
- Выносите объявления глобальных сервисов в отдельный модуль, Core модуль, и импортируйте его только в app модуль. Это поможет в очистке app модуля
- А лучше используйте опцию providedIn со значанием 'root' декоратора Injectable
- Используйте хак с декораторами Optional и SkipSelf, чтобы предотвратить повторный импорт модулей и сервисов
- Храните переиспользуемые компоненты, директивы и пайпы в Shared модуле
- Однако лучший подход, который ещё и в будущее смотрит, и облегчает тестирование — создание модуля для каждой компоненты (директивы и пайпы тоже)
- Используйте интерфейс ModuleWithProviders, если хотите избежать коллизии провайдеров. Популярный подход — реализация метода forRoot для добавления провайдеров в корневом модуле