Как мы распилили монолит. Часть 3, Frame Manager без фреймов
Привет. В прошлой статье я рассказал про Frame manager — оркестратор фронтовых приложений. Описанная реализация решает многие проблемы, но в ней есть недостатки.
Из-за того, что приложения грузятся в iframe, появляются проблемы с версткой, некорректно работают плагины, клиенты по-прежнему скачивают два бандла с Ангуляром, даже если версии Ангуляра в приложении и Frame Manager«е одинаковые. Да и использовать iframe в 2020 году кажется моветоном. А что, если отказаться от фреймов и загружать все приложения в один window?
Оказалось, это возможно, и сейчас я расскажу, как это реализовать.
Возможные решения
Single-spa: «А javascript router for front-end microservices» — как указано на сайте библиотеки. Позволяет одновременно запускать на одной странице приложения, написанные на разных фреймворках. Нам решение не подошло: большая часть функциональности оказалась не нужна, а используемый в нем лоадер System.js в некоторых случаях создает проблемы при сборке с webpack. Да и использовать загрузчик модулей вместе с webpack кажется не лучшим решением.
Angular elements: пакет позволяет оборачивать Angular-компоненты в web components. Можно обернуть приложение целиком. Тогда придется добавлять полифил для старых браузеров, да и создание веб-компонента из целого приложения со своим роутингом выглядит идеологически неверным решением.
Реализация в Frame manager
Посмотрим, как реализована загрузка приложений без фреймов в Frame manager на примере.
Начальный сетап выглядит следующим образом: у нас есть главное приложение — main. Оно всегда загружается первым и внутри себя должно загружать другие приложения — app-1 и app-2. Создадим три приложения с помощью команды ng new
const cfg = [
{
context: [
'/app1/*.js',
'/app1/*.html'
],
target: 'http://localhost:3001/'
},
{
context: [
'/app2/*.js',
'/app2/*.html'
],
target: 'http://localhost:3002/'
}
];
module.exports = cfg;
У приложений app-1 и app-2 укажем baseHref в angular.json app1 и app2 соответственно. Также поменяем селекторы корневых компонентов на app-1 и app-2.
Сначала добьемся загрузки хотя бы одного подприложения. Для этого нужно загрузить все js-файлы, указанные в index.html.
Узнаем url-ы js-файлов: делаем http-запрос за index.html, парсим строку с помощью DOMParser и выбираем все script-тэги. Преобразуем все в массив и смапим его в массив адресов. Адреса, полученные таким образом, будут содержать location.origin, поэтому заменим его на пустую строку:
private getAppHTML(): Observable {
return this.http.get(`/${this.currentApp}/index.html`, {responseType: 'text'});
}
private getScriptUrls(html: string): string[] {
const appDocument: Document = new DOMParser().parseFromString(html, 'text/html');
const scriptElements = appDocument.querySelectorAll('script');
return Array.from(scriptElements)
.map(({src}) => src.replace(this.document.location.origin, ''));
}
Адреса есть, теперь надо загрузить скрипты:
private importJs(url: string): Observable {
return new Observable(sub => {
const script = this.document.createElement('script');
script.src = url;
script.onload = () => {
this.document.head.removeChild(script);
sub.next();
sub.complete();
};
script.onerror = e => {
sub.error(e);
};
this.document.head.appendChild(script);
});
}
Код добавляет в DOM script-элементы с нужными src, а после скачивания скриптов удаляет эти элементы — довольно стандартное решение, примерно так же реализована загрузка в webpack и system.js.
После загрузки скриптов — в теории — у нас есть все для запуска встраиваемого приложения. Но на деле мы получим переинициализацию главного приложения. Похоже, загружаемое приложение как-то конфликтует с основным, чего не происходило при загрузке в iframe.
Загрузка webpack-бандлов
Angular для загрузки модулей использует webpack. В стандартной конфигурации вебпак разбивает код на следующие бандлы:
- main.js — весь клиентский код;
- polyfills.js — полифилы;
- styles.js — стили;
- vendor.js — все библиотеки, используемые в приложении, включая Angular;
- runtime.js — рантайм вебпака;
.module.js — lazy-модули.
Если открыть любой из этих файлов, в самом начале можно увидеть код:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([/.../])
А в runtime.js:
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
Работает это так: при загрузке бандла он создает массив webpackJsonp, если его еще нет, и пушит в него свое содержимое. Рантайм вебпака переопределяет функцию push данного массива, чтобы впоследствии можно было загружать новые бандлы, и обрабатывает все, что уже лежит в массиве.
Все это нужно для того, чтобы порядок загрузки бандлов не имел значения.
Таким образом, если загрузить второе Angular-приложение, оно попытается добавить свои модули в уже существующий рантайм вебпака, что в лучшем случае приведет к переинициализации основного приложения.
Меняем название webpackJsonp
Чтобы избежать конфликтов, нужно изменить название массива webpackJsonp. Angular CLI использует свой конфиг вебпака, но при желании его можно расширить. Для этого нужно установить пакет angular-builders/custom-webpack:
npm i -D @angular-builders/custom-webpack.
Затем в файле angular.json в конфигурации проекта заменить architect.build.builder на @angular-builders/custom-webpack: browser, а в architect.build.options добавить:
"customWebpackConfig": {
"path": "./custom-webpack.config.js"
}
Также нужно заменить architect.serve.builder на @angular-builders/custom-webpack: dev-server, чтобы это работало локально с dev-сервером.
Теперь нужно создать файл с конфигурацией вебпака, который указан выше в customWebpackConfig: custom-webpack.config.js
В нем определяются пользовательские настройки, подробнее можно почитать в официальной документации.
Нас интересует jsonpFunction.
Можно задать такой конфиг во всех загружаемых приложениях, чтобы избежать конфликтов (если после этого конфликты все же останутся, скорее всего, вас прокляли):
module.exports = {
output: {
jsonpFunction: Math.random().toString()
},
};
Теперь, если попробовать загрузить все скрипты описанным выше способом, увидим ошибку:
Перед загрузкой приложения нужно добавить его корневой элемент в DOM:
private addAppRootElement(appName: string) {
const rootElementSelector = APP_CFG[appName].rootElement;
this.appRootElement = this.document.createElement(rootElementSelector);
this.appContainer.nativeElement.appendChild(this.appRootElement);
}
Пробуем еще раз — ура, приложение загрузилось!
Переключение между приложениями
Удаляем предыдущее приложение из DOM и можем переключаться между приложениями:
destroyApp () {
if (!this.currentApp) return;
this.appContainer.nativeElement.removeChild(this.appRootElement);
}
Но тут есть недоработки: при переходе app-1 → app-2 → app-1 мы повторно загружаем js-бандлы для приложения app-1 и выполняем их код. Кроме того, мы не уничтожаем загруженные ранее приложения, что ведет к утечке памяти и ненужному потреблению ресурсов.
Если повторно не загружать бандлы приложения, то процесс бутстрапа сам по себе не выполнится и приложение не загрузится. Нужно делегировать процесс запуска бутстрапа главному приложению.
Для этого перепишем файл main.ts загружаемых приложений:
const BOOTSTRAP_FN_NAME = 'ngBootstrap';
const bootstrapFn = (opts?) => platformBrowserDynamic().bootstrapModule(AppModule, opts);
window[BOOTSTRAP_FN_NAME] = bootstrapFn;
Метод bootstrapModule не выполняется сразу, а сохраняется в функции-обертке, которая находится в глобальной переменной. В главном приложении можно получить к ней доступ и выполнить, когда необходимо.
Чтобы уничтожить приложение и устранить утечки памяти, нужно вызвать метод destroy корневого модуля приложения (AppModule). Ссылку на него возвращает метод platformBrowserDynamic ().bootstrapModule, а значит, и наша функция-обертка:
this.getBootstrapFn$().subscribe((bootstrapFn: BootstrapFn) => {
this.zone.runOutsideAngular(() => {
bootstrapFn().then(m => {
this.ngModule = m; // сохраняем ссылку на модуль
});
});
});
this.ngModule.destroy(); // вызываем при уничтожении
После вызова destroy () у корневого модуля последует вызов методов ngOnDestroy () всех сервисов и компонентов приложения (если они реализованы).
Все работает. Но если в загружаемом приложении есть lazy-модули, они не смогут загрузиться:
Видно, что в адресе пропущен путь приложения (должно быть /app2/lazy-lazy-module.js). Чтобы решить эту проблему, нужно синхронизировать base href основного и загружаемого приложений:
private syncBaseHref(appBaseHref: string) {
const base = this.document.querySelector('base');
base.href = appBaseHref;
}
Теперь все работает как надо.
Итоги
Посмотрим, сколько занимает загрузка подприложения, поставив console.time () перед загрузкой скриптов в главном приложении и console.timeEnd () в конструктор корневого компонента главного приложения.
При первой загрузке приложений app-1 и app-2 видим примерно такой результат:
Довольно быстро. Но если вернуться в загруженное ранее приложение, можно увидеть такие цифры:
Приложение загружается моментально, так как все нужные чанки уже есть в памяти. Но теперь необходимо более внимательно относиться к неиспользуемым ссылкам на объекты и подпискам, так как даже при уничтожении приложения они могут приводить к утечкам памяти.
Frame manager без фреймов
Описанное выше решение реализовано во Frame manager, который поддерживает загрузку приложений как с iframe, так и без. Без фреймов сейчас загружается около четверти всех приложений в Тинькофф Бизнесе, и их число постоянно растет.
А еще благодаря описанному решению мы научились шарить Angular и общие библиотеки, используемые в Frame manager и приложениях, что еще больше повысило скорость загрузки и работы. Об этом расскажем в следующей статье.
Репозиторий с примером кода