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


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


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




Domain Feature Модули


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


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


image

Все объявления в синих рамках, а также контент других пунктов меню, заслуживают своих собственных 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

Использование описанного подхода в 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 работает, к нему есть пара замечаний:


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

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


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


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



image

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


На английском такой подход называется 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.


image


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


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


Заключение:


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

Комментарии (16)


  1. kolkov
    16.09.2019 11:01

    Спасибо! Статья будет очень полезна многим, и не только начинающим. Один момент только не освещен на мой взгляд, а где лучше хранить модели для данных из полученных из api с бэкенда? И где хранить сервисы api? Особенно это бкдет актуально для ленивых модулей. Спасибо!


    1. psFitz
      16.09.2019 12:08

      Я пришел к такому выводу:
      Если сервис используется только в 1 модуле — храним его в этом модуле
      Если сервис используется больше чем в 1 модуле — храним его в shared модуле
      Так же и с интерфейсами


      1. dopusteam
        16.09.2019 14:52

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

        Буду рад, если приведёте примеры интерфейсов, которые не меняются и могут быть вынесены в shared.

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


        1. psFitz
          16.09.2019 19:17

          Буду рад, если приведёте примеры интерфейсов, которые не меняются и могут быть вынесены в shared.

          Например у меня есть 3 страницы (3 разных модуля)


          1. Создание поста
          2. Список постов
          3. Список постов в админке

          каждый модуль использует интерфейс post.interface.ts
          в какой из них мне ложить этот интерфейс, если не в shared или core?


          1. dopusteam
            16.09.2019 21:19

            А нужен ли тут один интерфейс?

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

            Понятно, что к примерам можно придраться, но смысл понятен.

            В общем это явно должны быть разные интерфейсы. У них может быть общий родительский, но, как по мне, это тоже лишнее.


            1. psFitz
              17.09.2019 09:07

              Если везде городить разные интерфейсы — какой в них смысл кроме автокомплита?


              1. dopusteam
                17.09.2019 09:18

                Помимо комплита (по мне, так этого уже достаточно) ещё навскидку:

                1. Уверенность, что я не пытаюсь получить свойство, которое в данном конкретном случае не используется \ не нужно \ отсутствует (компилятор во многих случаях поругается, в том числе в шаблонах)
                2. Поддержка кода, когда я вижу, что реально есть в интерфейсе в данном конкретном случае
                3. При создании объекта нет необходимости заполнять лишние поля. Если конечно вы не делаете все их опциональными
                4. Буква I из SOLID, в конце концов

                Теперь подскажите, в чём преимущества одного интерфейса?


                1. psFitz
                  17.09.2019 12:27

                  тем, что если я добавлю или поменяю поле на бекенде — мне надо будет обновить один инерфейс, а не 10. У меня есть интерфейс для post и я во всем приложении могу быть уверен, что использую нужный мне post и мне не надо думать какой интерфейс использовать, из edit-post page или из view post page, а посты у меня могут быть много где.
                  Понятно, что на создание поста у меня будет другой интерфейс, потому-что при создании как вы написали выше — может быть капча или что-то другое, но функция создания поста вернет мне Observable< PostInterface > уже с рейтингом, пустым полем комментариев и всем остальным


                  1. dopusteam
                    17.09.2019 12:32

                    тем, что если я добавлю или поменяю поле на бекенде — мне надо будет обновить один инерфейс, а не 10

                    Где нужно будет — там и поменяете, а не везде. Ваш один аргумент перевешивает мои 5 или нет?

                    У меня есть интерфейс для post и я во всем приложении могу быть уверен, что использую нужный мне post и мне не надо думать какой интерфейс использовать

                    Если на бэке добавится поле, то оно всё равно автоматически не выведется в шаблон нигде. Так или иначе менять придётся фронт. А вдруг новое поле нужно только в одном модуле?

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


                    1. psFitz
                      17.09.2019 12:35

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


                      1. dopusteam
                        17.09.2019 12:42

                        если для списка нужен только заголовок — то и бек будет возвращать только заголовок и для этого будет отдельный интерфейс

                        Ну я уж утрировал пример. Для списка нужны ещё поля, конечно.

                        Вы очень выборочно на вопросы отвечаете.

                        Повторюсь и дополню
                        1. Что если новое поле нужно только в одном модуле, а не везде?
                        2. Фронт автоматически не обновится сам при добавлении поля, всё равно фронт менять надо, что по этому поводу думаете?
                        3. Сколько полей будет в этом интерфейсе? Все возможные? Наступит ли момент, когда придётся бить интерфейс на несколько?
                        4. Что по поводу SOLID, про interface segregation?
                        5. Являются ли поля объекта опциональными или их все ининциализировать каждый раз нужно?

                        Понятно, что на создание поста у меня будет другой интерфейс

                        Но в любом случае будет общий интерфейс, потому-что есть страницы редактирования, просмотра и создания


                        Противоречие


                        1. psFitz
                          17.09.2019 13:15

                          Противоречие

                          Нет противоречия, я писал выше, на создание будет отдельный интерфейс, но функция создания будет возвращать общий интерфейс, потому-что бекенд вернет post уже с комментариями, id итд, полями которые не учавствуют в создании.


                          . Что если новое поле нужно только в одном модуле, а не везде?

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


                          1. Фронт автоматически не обновится сам при добавлении поля, всё равно фронт менять надо, что по этому поводу думаете?

                          А что по этому поводу думать? Я уже ответил выше, что мне не надо будет менять 20 интерфейсов, только 1, там где надо будет фронт менять — поменяю, на крайний случай есть refactoring в ide


                          1. Сколько полей будет в этом интерфейсе? Все возможные? Наступит ли момент, когда придётся бить интерфейс на несколько?

                          Будут все поля, которые нужны для этого интерфейса, не больше, не меньше, даже если делать разные интерфейсы — будет один большой где надо отобразить все данные модели.


                          1. Что по поводу SOLID, про interface segregation?

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


                          1. Являются ли поля объекта опциональными или их все ининциализировать каждый раз нужно?

                          Не нужно ничего инициировать. Я уже писал, что на создание post будет отдельный интерфейс.


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


                          1. dopusteam
                            17.09.2019 13:28

                            Ок, спасибо за ответы, но я как раз против того, чтоб гонять данные лишние, и иметь моноинтерфейс.
                            Один интерфейс приводит к тому, что либо часть данных в некоторых случаях отсутствует и непонятно из сигнатуры метода когда и какая.
                            Либо данные всегда есть, даже ненужные, и, даже если их не выводить, они приводят к увеличению объёма передаваемых данных и к потенциальным утечкам данных


                            Вашу точку зрения по поводу думать головой разделяю, на этом можно и закончить


      1. Xuxicheta
        16.09.2019 17:12

        Все сервисы работы с api делаются providedIn: 'root', ангуляр сам распределяет их по модулям в зависимости от того, как они будут использоваться.
        Если любите модели в отдельном файле, то делать на каждый апи-сервис отдельную папку, где лежит сам сервис и его модели. Я предпочитаю описывать модель в самом сервисе. Ну и один ендпойнт — один сервис.


        1. psFitz
          16.09.2019 19:23

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


  1. 8gen
    16.09.2019 15:28

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