Недавно я прочитал интересное руководство от Уэса Граймса о динамической загрузке модулей и компонентов в Angular (ссылка). Этот подход дает очень гибкий способ избежать сложных операторов *nfIf и *ngSwitch в шаблонах. Проблема возникает, когда у нас есть десятки компонентов на выбор, которые имеют сотни внедренных сервисов с большими килобайтами зависимостей npm. Все эти файлы должны быть упакованы в один пакет и мгновенно загружены в браузер. Было бы неплохо отложить их загрузку до тех пор, пока они нам действительно не понадобятся.

Angular предлагает ленивую загрузку из коробки для маршрутизируемых модулей со следующим синтаксисом для определений маршрутов.

const routes: Routes = [
  {
    path: 'lazy-loaded-path',
    loadChildren: './path/to/module/some.module#SomeModule'
  }
];

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

Срезать ветку

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

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

Ленивая загрузка компонентов

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

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

LoaderService с отложенной загрузкой и логикой создания динамических компонентов.

Компонент формы с выходом и вызовом LoaderService.

Компилятор не знает, что причудливая строка в LoaderService - это реальный путь к какому-то существующему модулю, поэтому нам нужно добавить ее также в angular.json.

{
  "projects": {
    ...
      "architect": {
        ...
        "build": {
          ...
          "options": {
            "lazyModules": ["src/app/form/form-inputs/form-inputs.module"]

Я не буду вдаваться в подробности этого решения, потому что оно хорошо объяснено в статье, которую я упомянул в начале. Короче говоря, он лениво загружает FormInputsModule по пути к файлу и динамически помещает TextInputComponent в выход в FormComponent.

Одна вещь должна привлечь наше внимание. Мы импортируем ссылку TextInputComponent на LoaderService и FormComponent. Мы должны это сделать, потому что componentResolver.resolveComponentFactory() нужно знать, какой компонент он собирается построить. Но это ссылка, которую мы хотим отложить, поэтому нам нужно сломать дерево импорта здесь.

Оставайтесь в безопасности, с типовой безопасностью

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

Здесь мы можем использовать другой способ сломать дерево зависимостей. Шаблоны компонентов.

Этот компонент является простой передачей в TextInputInternalComponent. Чтобы быть еще более безопасным, мы могли бы использовать интерфейс со всеми соответствующими полями и реализовать его как TextInputs.

При загрузке по шаблону дерево зависимостей прерывается. Теперь мы можем реализовать реальный ввод и импортировать moment.js.

Наконец, ленивый загруженный FormInputsModule.

Эффект

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

Как это работает

Давайте объясним шаг за шагом, что здесь происходит.

  1. Ссылка на TextInputComponent добавляется в основной пакет приложения, потому что это зависимость от LoaderService, и мы ничего не можем с этим поделать. Мы просто стараемся сделать это максимально простым, без логики, без инжекторов, без стилей. Пока мы не запустим loadTextInput(), он ни для чего не используется, поэтому ничего не происходит.
  2. LoaderService загружает FormInputsModule по строковой ссылке . Это выполняется ленивым способом, весь модуль извлекается из API и выполняется. Уловка здесь в том, что FormInputsModule импортирует TextInputComponent, а не наоборот. Основной модуль импортирует только очень простой компонент, который вообще ничего не импортирует, поэтому дерево зависимостей на этом заканчивается.
  3. FormInputsModule объявляет, компилирует и экспортирует TextInputComponent. После этого шага он станет доступен для Injector, мы сможем resolveComponentFactory()на нем.
  4. Экземпляр TextInputComponent создан. Внутри он показывает TextInputInternalComponent с внешними зависимостями и желаемой логикой.

Это решение не на 100% безопасно для TypeScript. Часть пути зависимостей проходит через входные данные компонентов, которые не проверяют ограниченные типы. Однако не случайно я назвал реальный компонент ввода постфиксом Internal. TextInputInternalComponent не экспортируется из FormInputsModule и должен оставаться закрытым внутри этого модуля. Только общедоступный API - это TextInputComponent, поэтому тяжелое дерево зависимостей не просачивается наружу.

А как насчет услуг?

Если нам не нужны какие-либо компоненты и нужно просто лениво загружать какие-то сервисы, есть другое решение.

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

У нас будет сервис, который возвращает текущую дату.

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

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

А вот и разыграйте жетон инъекции.

Наконец, мы можем загрузить все в CalendarLoaderService.

Код загрузки очень похож на случай компонентов. Снова у нас есть moduleReference, но на этот раз мы вызываем moduleReference.injector<GetDate>(GET_DATE). Injection Token - это своего рода волшебный ключ, который относится к чему-то, но за то, что вернется, отвечает модуль. Это определяет специальный провайдер.

{
    provide: GET_DATE,
    useClass: CalendarService
}

Также с точки зрения TypeScript у нас есть интерфейс, который гарантирует, что все типы ввода и возврата верны. Это стоит нам ноль килобайт дополнительного кода в основном пакете, потому что интерфейсы удаляются из скомпилированного кода. Это просто определение типа, никакой логики JavaScript!