Концепция плагинов всегда была популярной и продуктивной в области разработки программного обеспечения. Масштабируемость и возможность коллективной разработки необходимы для приложений уровня предприятия, когда каждая команда разработчиков может представлять своё направление бизнеса и разрабатывать свой собственный плагин для веб-портала. Angular, являясь одним из наиболее подходящих фреймворков для построения больших браузерных информационных систем, предлагает прекрасные возможности разделения приложения на части прямо «из коробки» при помощи технологии ленивой загрузки. Результатом этого должно быть приемлемое время загрузки даже больших приложений при использовании должного подхода к их дизайну. При этом стандартная реализация ленивой загрузки подразумевает жёсткое задание маршрутов в центральной части приложения, что может приводить к трудностям с разработкой несколькими командами и невозможности добавления новых модулей без изменений в коде. В этой статье я собираюсь объяснить и продемонстрировать как можно создать приложение Angular, поддерживающее плагины, описанные исключительно в конфигурационном файле, когда центральная часть приложения и подключаемые модули полностью независимы друг от друга и даже могут быть расположены в разных местах.

Чтобы сделать хорошую иллюстрацию использованного подхода, я написал минимально возможный пример приложения, который расположен здесь. Он был создан и протестирован на Angular 11.

Подготовительные действия

Начнём с создания приложения Angular.

ng new ng-plugin-demo

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

ng g library plugin1
ng g component main-screen --project=plugin1
ng g component child-screen --project=plugin1
ng g library plugin2
ng g component main-screen --project=plugin2

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

<p>Plugin1 works!</p>
<p>Module base path is {{ moduleBasePath }}</p>
<a [routerLink]="['child']">Go to the child</a>
<br />
<a [routerLink]="'..'">Go back</a>
<br />
<img [src]="moduleBasePath + '/assets/smile.png'" />

Второй компонент может быть прост, насколько это возможно.

<p>child-screen works!</p>
<br />
<a [routerLink]="'..'">Go back</a>

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

const routes: Routes = [
  {
    path: '',
    component: MainScreenComponent
  },
  {
    path: 'child',
    component: ChildScreenComponent
  }
];

Эти маршруты добавлены в импорт модуля маршрутизации стандартным образом.

  imports: [RouterModule.forChild(routes)],

А модуль маршрутизации, в свою очередь, добавлен в импорт главного модуля библиотеки.

  imports: [
    Plugin1RoutingModule
  ],

Поскольку плагин содержит картинку, нам нужно создать специальную папку assets и изменить соответствующий файл ng-package.json для того, чтобы сказать компилятору как ему следует вести себя с данной папкой.

  "assets": ["./assets"],

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

Общая библиотека

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

ng g library common

Интерфейс, описывающий плагин, должен быть объявлен как показано ниже.

export interface DemoPlugin {
  path: string;
  baseUrl: string;
  pluginFile: string;
  moduleName: string;
}

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

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

ng g service plugin-config --project=common

Цель сервиса – хранение данных конфигурации плагинов, которые должны быть единожды загружены сразу после старта приложения.

export class PluginConfigService {
  public value: DemoPlugin[] = [];
  public loaded = false;

  public constructor() { }

  public load(uri: string): Promise<DemoPlugin[]> {
    return fetch(uri).then(result => result.json()).then(json => {
      this.value = json;
      this.loaded = true;
      return json;
    });
  }
}

Наконец, нам надо задать injection token базового пути модуля чтобы облегчить загрузку объектов, таких как картинки, в плагинах.

export const MODULE_BASE_PATH = new InjectionToken<string>('MODULE_BASE_PATH');

Центральная часть приложения

Загрузчик плагинов

Немного теории. Когда мы создаём библиотек Angular, у ней всегда есть зависимости. Эти зависимости должны быть заданы в файле приложения package.json в одноимённом разделе. Если вы активно используете компоненты сторонних производителей, список зависимостей может быть довольно внушительным. По умолчанию Angular не включает собственный код зависимости в откомпилированную библиотеку. Это может выглядеть не вполне понятным, но это сделано для того, чтобы уменьшить общий размер приложения и рассчитать оптимальный вариант ленивой загрузки. Есть опция, позволяющее обойти этот принцип, которая называется ‘budledDependencies’, но далеко не всегда будет хорошей идеей её использовать, потому что вам может понадобиться обращаться к одним и тем же библиотекам из нескольких плагинов. В этом случае приложению может понадобиться загружать один и тот же код каждый раз с загрузкой нового плагина.

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

ng g module plugin-loader --routing

Теперь мы можем перейти к наиболее интересной части и запрограммировать модуль загрузки плагинов. Модуль должен содержать файл конфигурации зависимостей. Ниже представлен пример такого файла.

import * as AngularAnimations from '@angular/animations';
import * as AngularCommon from '@angular/common';
import * as AngularCommonHttp from '@angular/common/http';
import * as AngularCompiler from '@angular/compiler';
import * as AngularCore from '@angular/core';
import * as AngularForms from '@angular/forms';
import * as AngularPlatformBrowser from '@angular/platform-browser';
import * as AngularPlatformBrowserDynamic from '@angular/platform-browser-dynamic';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import * as AngularRouter from '@angular/router';
import * as rxjs from 'rxjs';
import * as RxjsOperators from 'rxjs/operators';
import * as Common from 'common';

export const dependencyMap: { [propName: string]: any } = {
  '@angular/animations': AngularAnimations,
  '@angular/common': AngularCommon,
  '@angular/common/http': AngularCommonHttp,
  '@angular/compiler': AngularCompiler,
  '@angular/core': AngularCore,
  '@angular/forms': AngularForms,
  '@angular/platform-browser': AngularPlatformBrowser,
  '@angular/platform-browser-dynamic': AngularPlatformBrowserDynamic,
  '@angular/platform-browser/animations': BrowserAnimationsModule,
  '@angular/router': AngularRouter,
  rxjs,
  'rxjs/operators': RxjsOperators,
  'common': Common
};

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

Самая главная часть модуля загрузчика плагинов – это его конфигурация маршрутов, расположенная в файле plugin-loader-routing.module.ts. Начнём с функции загрузки плагина.

const loadModule = (url: string): Promise<any> => {
  try {
    return fetch(url)
      .then((response) => response.text())
      .then((source) => {
        const exports = {}; // This will hold module exports
        // Shim 'require'
        const require = (module) => {
          if (!dependencyMap[module]) {
            throw new Error(`No '${module}' module defined in the provided dependency map for the '${url}' module`);
          }
          return dependencyMap[module];
        };
        eval(source); // Interpret the plugin source
        return exports;
      });
  } catch (error) {
    const message =
      `Cannot load a module at '${url}'. ` +
      (error instanceof Error ? `${error.name} ${error.message}` : JSON.stringify(error));
    window.alert(message);
    return Promise.reject(message);
  }
};

Функция попросту загружает файл плагина, создаёт объект экспорта, который будет содержать экспорт загруженного модуля, переопределяет ключевое слово ‘require’ и вызывает функцию eval JavaScript. Если плагину необходима зависимость, не заданная в конфигурационном файле, будет отображена ошибка в консоли браузера, которая позволит вам понять, как решить проблему.

Сам модуль маршрутизации содержит конфигурацию роутера, которая определяет, какой плагин должен быть загружен и выполняет загрузку с помощью вызова функции ‘loadModule’.

@NgModule({
  imports: [RouterModule.forChild([])],
  exports: [RouterModule],
  providers: [
    {
      provide: ROUTES,
      useFactory: (pluginConfigService: PluginConfigService) => pluginConfigService.value.map(plugin => ({
        matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null => group.segments[0].path === plugin.path ? { consumed: [] } : null,
        loadChildren: () => loadModule(`${plugin.baseUrl}/${plugin.pluginFile}?v=${new Date().getTime()}`).then(m => m[plugin.moduleName])
      })),
      deps: [PluginConfigService],
      multi: true,
    },
  ],
})
export class PluginLoaderRoutingModule { }

Как вы можете видеть, мы вынуждены прибегнуть к injection token ROUTES, потому что нам нужен доступ к сервису конфигурации плагинов, который содержит информацию о конфигурации плагинов, загруженную при запуске приложения. Это будет разъяснено немного позже. Также нам надо задать пути для маршрутов, потому что они должны быть заданы в корневой конфигурации роутера главного модуля, если мы хотим использовать простые маршруты, такие как ‘http://localhost:4260/plugin1’. Неплохой способ определения, какой плагин должен быть загружен, состоит в использовании параметров функции URL matcher.

Конечно же, модуль маршрутизации загрузчика плагинов должен быть импортирован модулем загрузчика плагинов.

  imports: [
    CommonModule,
    PluginLoaderRoutingModule
  ]

Главный модуль (APP MODULE)

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

  providers: [
    PluginConfigService,
    {
      provide: APP_INITIALIZER,
      useFactory: (pluginConfigService: PluginConfigService) =>
        () => pluginConfigService.load(`${environment.pluginConfigUri}?v=${new Date().getTime()}`),
      deps: [PluginConfigService],
      multi: true
    }
  ],

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

export const environment: Environment = {
  production: false,
  pluginConfigUri: './assets/plugin-config.json'
};

Можно расположить файл конфигурации плагинов в папке assets приложения.

[
  {
    "path": "plugin1",
    "baseUrl": "http://localhost:4261/plugin1",
    "pluginFile": "bundles/plugin1.umd.js",
    "moduleName": "Plugin1Module"
  },
  {
    "path": "plugin2",
    "baseUrl": "http://localhost:4261/plugin2",
    "pluginFile": "bundles/plugin2.umd.js",
    "moduleName": "Plugin2Module"
  }
]

К сожалению, APP_INITIALIZER не предотвращает запуск конфигурации роутера до окончания инициализации, так что мы вынуждены опять использовать injection token ROUTES.

const staticRoutes: Route[] = [];

@NgModule({
  imports: [RouterModule.forRoot([])],
  exports: [RouterModule],
  providers: [
    {
      provide: ROUTES,
      useFactory: (pluginConfigService: PluginConfigService) => {
        const pluginRoute = {
          // This function is called when APP_INITIALIZER is not yet completed, so matcher is the only option
          matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null =>
            group.segments.length && pluginConfigService.value.some(plugin => plugin.path === group.segments[0].path) ? { consumed: [group.segments[0]] } : null,
          // Lazy load the plugin loader module because it may contain many 'heavy' dependencies
          loadChildren: () => import('./plugin-loader/plugin-loader.module').then((m) => m.PluginLoaderModule)
        };
        return [...staticRoutes, pluginRoute];
      },
      deps: [PluginConfigService],
      multi: true,
    },
  ]
})
export class AppRoutingModule { }

В этом примере у нас нет корневых маршрутов помимо плагинов, так что массив staticRoutes остаётся пустым. Обычно он включает ваши статические (т. е. жёстко заданные) маршруты.

App component, очевидно, должен быть изменён. Нам надо внедрить туда сервис конфигурации плагинов как зависимость.

export class AppComponent {
  public constructor(public readonly pluginConfigService: PluginConfigService) {}
}

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

<ng-container *ngFor="let plugin of pluginConfigService.value">
  <a [routerLink]="'/' + plugin.path">{{ plugin.moduleName }}</a>
  <br />
</ng-container>
<router-outlet></router-outlet>

Базовый путь (URL) модуля

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

Насколько вы помните, мы уже создали injection token под названием MODULE_BASE_PATH. Добавим его по модуль плагина №1 для иллюстрации того, каким путём это можно сделать. В первую очередь нам надо создать функцию-фабрику.

const moduleBasePathFactory = (pluginConfigService: PluginConfigService): string =>
  pluginConfigService.value.find(plugin => plugin.path === 'plugin1').baseUrl;

Потом добавим нового провайдера в декоратор модуля.

  providers: [
    {
      provide: MODULE_BASE_PATH,
      useFactory: moduleBasePathFactory,
      deps: [PluginConfigService],
    }
  ]

Injection token добавлен. Он может быть внедрён в компоненты плагина при помощи добавления его в параметры конструктора класса, как это показано ниже.

  public constructor(@Inject(MODULE_BASE_PATH) public readonly moduleBasePath: string) {}

Чтобы загрузить картинку вам надо просто использовать внедрённое значение В HTML-шаблоне компонента.

<img [src]="moduleBasePath + '/assets/smile.png'" />

Последние приготовления

Для того, чтобы запустить приложение, мы должны настроить конфигурационные файлы. Вы можете поменять порт по умолчанию, используемый с командой ng serve, в файле angular.json. Путь настройки – projects.ng-plugin-demo.architect.serve.options.port.

Немного больше изменений нужно сделать с файлом package.json. Для того, чтобы запущенное локально приложение могло загружать плагины, надо установить HTTP-сервер. Чтобы это сделать, выполните следующую команду.

npm install --save-dev http-server

После установки он будет автоматически добавлен к секции devDependencies. Довольно удобно добавить новую команду в раздел scripts.

    "serve:plugins": "http-server ./dist/ --port=4261 --cors",

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

    "build:all": "ng build common && ng build plugin1 && ng build plugin2 && ng build"

Теперь, для того, чтобы запустить демо, вам нужно открыть 2 окна терминала и запустить команды ‘npm run serve:plugins’ и ‘ng serve --hmr’ соответственно.

Что если мы поменяем настройки компилятора?

По умолчанию Angular CLI создаёт новое приложение с определёнными предустановленными настройками компилятора. Попробуем поменять две из них, а именно ‘enableIvy’ и ‘aot’ (AOT означает Ahead Of Time compilation). Эти настройки могут быть найдены в файлах angular.json и tsconfig.json.

  1. AOT включен, Ivy включён – работает без проблем.

  2. AOT выключен, Ivy выключен.

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

В файле plugin1.module.ts поменяйте стрелочные функции на обычные экспортируемые функции и уберите из них стрелочный синтаксис, как это показано ниже.

export function find(plugin: DemoPlugin): boolean {
  return plugin.path === 'plugin1';
}

export function moduleBasePathFactory(pluginConfigService: PluginConfigService): string {
  return pluginConfigService.value.find(find).baseUrl;
}

В файлах plugin1-routing.module.ts и plugin1-routing.module.ts уберите вызов функции из секции импорта декоратора. Результат должен выглядеть как показано ниже.

export const routerModule = RouterModule.forChild(routes);

  imports: [routerModule],

В файлах app-routing.module.ts и plugin-loader-routing.module.ts вам надо добавить ‘useValue’ к провайдеру ROUTES.

  providers: [
    {
      provide: ROUTES,
      useFactory: (pluginConfigService: PluginConfigService) => {
        const pluginRoute = {
          // This function is called when APP_INITIALIZER is not yet completed, so matcher is the only option
          matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null =>
            group.segments.length && pluginConfigService.value.some(plugin => plugin.path === group.segments[0].path) ? { consumed: [group.segments[0]] } : null,
          // Lazy load the plugin loader module because it may contain many 'heavy' dependencies
          loadChildren: () => import('./plugin-loader/plugin-loader.module').then((m) => m.PluginLoaderModule)
        };
        return [...staticRoutes, pluginRoute];
      },
      // The member below must exist if Ivy is off
      useValue: [],
      deps: [PluginConfigService],
      multi: true,
    },
  ]

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

3.      AOT выключен, Ivy включён.

Если вы запустите приложение в этой конфигурации без каких-либо изменений, то получите ошибку ‘ERROR Error: Uncaught (in promise): Error: Cannot match any routes. URL Segment: 'plugin1'’. Чтобы от неё избавиться, вам надо убрать useValue: [] из провайдера ROUTES. После этого приложение будет работать как задумано.

4.      AOT включён, Ivy выключен.

Пожалуйста, снова добавьте useValue: [] в файл plugin-loader-routing.module.ts.

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

Добавьте новую функцию как рекомендовано ниже в файл plugin-loader.module.ts.

export function createCompiler(compilerFactory: CompilerFactory) {
  return compilerFactory.createCompiler();
}

Также добавьте 3 провайдера COMPILER_OPTIONS, CompilerFactory и Compiler в тот же модуль.

  providers: [
    { provide: COMPILER_OPTIONS,
      useValue: {},
      multi: true
    },
    {
      provide: CompilerFactory,
      useClass: JitCompilerFactory,
      deps: [COMPILER_OPTIONS],
    },
    {
      provide: Compiler,
      useFactory: createCompiler,
      deps: [CompilerFactory],
    },
  ]

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

  providers: [
    {
      provide: ROUTES,
      useFactory: (pluginConfigService: PluginConfigService, compiler: Compiler) => pluginConfigService.value.map(plugin => ({
        matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null => group.segments[0].path === plugin.path ? { consumed: [] } : null,
        loadChildren: () =>
          loadModule(`${plugin.baseUrl}/${plugin.pluginFile}?v=${new Date().getTime()}`)
            .then(m => m[plugin.moduleName])
            .then(result => result instanceof NgModuleFactory ? Promise.resolve(result) : compiler.compileModuleAsync(result))
      })),
      // The member below must exist if Ivy is off
      useValue: [],
      deps: [PluginConfigService, Compiler],
      multi: true,
    },
  ]

Кажется, мы больше не можем использовать injection token ROUTES в главном модуле, потому что в этом случае Angular не понимает, что модуль загрузки плагинов должен быть подвержен ленивой загрузке и это выразится в ошибке ‘Runtime compiler is not loaded’. Выходит так, что мы должны прямо прописать маршруты для того, чтобы избежать данной ошибки. Таким образом, чтобы обойти проблему, закомментируйте массив провайдеров в декораторе модуля и добавьте переменную для обращения к сервису конфигурации плагинов.

let service: PluginConfigService;

Также понадобится функция для сопоставления маршрутов плагинов.

export function pluginMatcher(_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null {
  return group.segments.length && service.value.some(plugin => plugin.path === group.segments[0].path) ? { consumed: [group.segments[0]] } : null;
}

Измените массив импорта в декораторе модуля.

  imports: [RouterModule.forRoot([
    ...staticRoutes,
    {
      matcher: pluginMatcher,
      loadChildren: () => import('./plugin-loader/plugin-loader.module').then((m) => m.PluginLoaderModule)
    }
  ])],

И, наконец, добавьте конструктор в класс модуля, потому что нам надо присвоить значение переменной сервиса.

  public constructor(private readonly pluginConfigService: PluginConfigService) {
    service = this.pluginConfigService;
  }

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

Заключение

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