Wie wir den Monolithen gesägt haben. Teil 3, Frame Manager ohne Frames

Hallo. Im letzten Artikel habe ich über den Frame-Manager gesprochen, einen Orchestrator für Front-End-Anwendungen. Die beschriebene Implementierung löst viele Probleme, hat jedoch Nachteile.



Aufgrund der Tatsache, dass Anwendungen in einen Iframe geladen werden, gibt es Probleme mit dem Layout, Plugins funktionieren nicht richtig, Clients laden immer noch zwei Bundles mit Angular herunter, selbst wenn die Versionen von Angular in der Anwendung und im Frame Manager identisch sind. Und die Verwendung von iframe im Jahr 2020 scheint schlecht zu sein. Was aber, wenn wir Frames aufgeben und alle Anwendungen in einem Fenster laden?



Es stellte sich heraus, dass dies möglich ist, und jetzt werde ich Ihnen sagen, wie Sie es implementieren können.







Mögliche Lösungen



Single-Spa : "Ein Javascript-Router für Front-End-Microservices" - wie auf der Website der Bibliothek angegeben. Ermöglicht das gleichzeitige Ausführen von Anwendungen, die in verschiedenen Frameworks auf derselben Seite geschrieben wurden. Die Lösung hat bei uns nicht funktioniert: Die meisten Funktionen wurden nicht benötigt, und der darin verwendete System.js-Loader verursacht in einigen Fällen Probleme beim Erstellen mit Webpack. Die Verwendung eines Modulladers mit Webpack scheint nicht die beste Lösung zu sein.



Winkelelemente: Mit diesem Paket können Sie Winkelkomponenten in Webkomponenten einbinden. Sie können die gesamte Anwendung umbrechen. Dann müssen Sie eine Polyfüllung für alte Browser hinzufügen, und das Erstellen einer Webkomponente aus einer gesamten Anwendung mit eigenem Routing scheint eine ideologisch falsche Entscheidung zu sein.



Implementierung des Frame Managers



Lassen Sie uns anhand eines Beispiels sehen, wie das Laden von Anwendungen ohne Frames im Frame-Manager implementiert wird.



Das anfängliche Setup sieht folgendermaßen aus: Wir haben eine Hauptanwendung - main. Es wird immer zuerst geladen und muss andere Anwendungen in sich selbst laden - App-1 und App-2. Erstellen wir drei Anwendungen mit dem Befehl ng new <app-name> . Als Nächstes konfigurieren wir das Proxying so, dass HTML- und JS-Dateien der erforderlichen Anwendung an Anforderungen wie /<app-name>/*.js , /<appname>/*.html gesendet werden und die Statik der Hauptanwendung an alle anderen Anforderungen gesendet wird.



proxy.conf.js
const cfg = [
  {
    context: [
      '/app1/*.js',
      '/app1/*.html'
    ],
    target: 'http://localhost:3001/'
  },
  {
    context: [
      '/app2/*.js',
      '/app2/*.html'
    ],
    target: 'http://localhost:3002/'
  }
];

module.exports = cfg;




Für die Anwendungen App-1 und App-2 geben wir die baseHref in angular.json app1 bzw. app2 an. Wir werden auch die Stammkomponenten-Selektoren in App-1 und App-2 ändern.



So sieht die Hauptanwendung aus




Lassen Sie uns zuerst mindestens eine Unteranwendung laden. Dazu müssen Sie alle in index.html angegebenen js-Dateien laden.



Finden Sie URLs von JS-Dateien heraus: Stellen Sie eine http-Anfrage für index.html, analysieren Sie die Zeichenfolge mit DOMParser und wählen Sie alle Skript-Tags aus. Lassen Sie uns alles in ein Array konvertieren und einem Array von Adressen zuordnen. Auf diese Weise erhaltene Adressen enthalten location.origin, daher ersetzen wir sie durch eine leere Zeichenfolge:



private getAppHTML(): Observable<string> {
  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, ''));
}


Es gibt Adressen, jetzt müssen Sie die Skripte laden:

private importJs(url: string): Observable<void> {
  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);
  });
}


Der Code fügt dem DOM Skriptelemente mit dem erforderlichen src hinzu und löscht diese Elemente nach dem Herunterladen der Skripte - eine ziemlich standardmäßige Lösung, die in webpack und system.js geladen wird, ist ebenfalls implementiert.



Nach dem Laden der Skripte haben wir theoretisch alles, um die eingebettete Anwendung zu starten. Tatsächlich erhalten wir jedoch eine Neuinitialisierung der Hauptanwendung. Es sieht so aus, als ob die geladene App irgendwie mit der Haupt-App in Konflikt steht, was beim Laden in den Iframe nicht passiert ist.



Laden von Webpack-Bundles



Angular verwendet Webpack zum Laden von Modulen. In einer Standardkonfiguration teilt das Webpack den Code in die folgenden Bundles auf:



  • main.js - der gesamte Clientcode;
  • polyfills.js - Polyfills;
  • styles.js - styles;
  • vendor.js - alle in der Anwendung verwendeten Bibliotheken, einschließlich Angular;
  • runtime.js - Webpack-Laufzeit;
  • <Modulname> .module.js - faule Module.


Wenn Sie eine dieser Dateien öffnen, sehen Sie ganz am Anfang den Code:



(window["webpackJsonp"] = window["webpackJsonp"] || []).push([/.../])


Und in 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]);


Dies funktioniert folgendermaßen: Wenn das Bundle geladen wird, erstellt es das webpackJsonp-Array, sofern es noch nicht vorhanden ist, und schiebt seinen Inhalt hinein. Die Webpack-Laufzeit überschreibt die Push-Funktion dieses Arrays, sodass Sie später neue Bundles laden und alles verarbeiten können, was sich bereits im Array befindet.



All dies ist notwendig, damit die Reihenfolge, in der die Bundles geladen werden, keine Rolle spielt.



Wenn Sie also eine zweite Angular-Anwendung laden, wird versucht, ihre Module zur vorhandenen Webpack-Laufzeit hinzuzufügen, was bestenfalls zu einer Neuinitialisierung der Hauptanwendung führt.



Ändern Sie den Namen von webpackJsonp



Um Konflikte zu vermeiden, müssen Sie den Namen des webpackJsonp-Arrays ändern. Angular CLI verwendet eine eigene Webpack-Konfiguration, kann jedoch bei Bedarf erweitert werden. Dazu müssen Sie das Paket angle-builders / custom-webpack installieren:



npm i -D @ angle-builders / custom-webpack.



Ersetzen Sie dann in der Datei angle.json in der Projektkonfiguration architektur.build.builder durch @ angle-builders / custom-webpack: browser und fügen Sie sie zu architektur.build.options hinzu :



"customWebpackConfig": {
  "path": "./custom-webpack.config.js"
}


Sie müssen außerdem architektur.serve.builder durch @ angle-builders / custom-webpack: dev-server ersetzen, damit dies lokal mit dem dev-Server funktioniert.



Jetzt müssen Sie eine Webpack-Konfigurationsdatei erstellen, die oben in customWebpackConfig angegeben ist: custom-webpack.config.js



Sie definiert benutzerdefinierte Einstellungen. Weitere Informationen finden Sie in der offiziellen Dokumentation .



Wir sind an jsonpFunction interessiert .



Sie können diese Konfiguration in allen geladenen Anwendungen festlegen, um Konflikte zu vermeiden (wenn danach immer noch Konflikte bestehen, wurden Sie höchstwahrscheinlich verflucht):



module.exports = {
 output: {
   jsonpFunction: Math.random().toString()
 },
};


Wenn wir nun versuchen, alle Skripte auf die oben beschriebene Weise zu laden, wird ein Fehler angezeigt:



Die Selektor-App-1 stimmte mit keinen Elementen überein



Bevor Sie die Anwendung laden, müssen Sie ihr Stammelement zum DOM hinzufügen:



private addAppRootElement(appName: string) {  
  const rootElementSelector = APP_CFG[appName].rootElement;
  this.appRootElement = this.document.createElement(rootElementSelector);
  this.appContainer.nativeElement.appendChild(this.appRootElement);
}


Versuchen wir es noch einmal - Hurra, die Anwendung wurde geladen!







Zwischen Anwendungen wechseln



Wir entfernen die vorherige Anwendung aus dem DOM und können zwischen Anwendungen wechseln:



destroyApp () {
  if (!this.currentApp) return;
  this.appContainer.nativeElement.removeChild(this.appRootElement);
}


Aber hier gibt es Fehler: Wenn wir zu App-1 → App-2 → App-1 gehen, laden wir die js-Bundles für die App-1-Anwendung neu und führen ihren Code aus. Darüber hinaus zerstören wir zuvor geladene Anwendungen nicht, was zu Speicherlecks und unnötigem Ressourcenverbrauch führt.



Wenn Sie die Anwendungspakete nicht erneut herunterladen, wird der Bootstrap-Prozess nicht von selbst ausgeführt und die Anwendung wird nicht geladen. Sie müssen den Bootstrap-Startprozess an die Hauptanwendung delegieren.



Dazu schreiben wir die Datei main.ts der geladenen Anwendungen neu:



const BOOTSTRAP_FN_NAME = 'ngBootstrap';
const bootstrapFn = (opts?) => platformBrowserDynamic().bootstrapModule(AppModule, opts);

window[BOOTSTRAP_FN_NAME] = bootstrapFn;


Die bootstrapModule- Methode wird nicht sofort ausgeführt, sondern in einer Wrapper-Funktion gespeichert, die sich in einer globalen Variablen befindet. In der Hauptanwendung können Sie darauf zugreifen und es bei Bedarf ausführen.



Um die Anwendung zu zerstören und Speicherlecks zu beheben, müssen Sie die Zerstörungsmethode des Root-Anwendungsmoduls (AppModule) aufrufen. Die BootstrapModule-Methode platformBrowserDynamic (). Gibt einen Link dazu zurück. Dies bedeutet, dass unsere Wrapper-Funktion:



this.getBootstrapFn$().subscribe((bootstrapFn: BootstrapFn) => {
  this.zone.runOutsideAngular(() => {
    bootstrapFn().then(m => {
      this.ngModule = m;  //    
    });
  });
});

this.ngModule.destroy(); //   


Nach dem Aufruf von destroy () im Root-Modul werden die ngOnDestroy () -Methoden aller Dienste und Anwendungskomponenten (sofern implementiert) aufgerufen.



Alles arbeitet. Wenn die geladene Anwendung jedoch Lazy-Module enthält, können diese nicht geladen werden: Sie







können sehen, dass der Anwendungspfad in der Adresse fehlt (es sollte /app2/lazy-lazy-module.js geben ). Um dieses Problem zu lösen, müssen Sie die Basis-HREF der Haupt- und der geladenen Anwendung synchronisieren:



private syncBaseHref(appBaseHref: string) {
  const base = this.document.querySelector('base');

  base.href = appBaseHref;
}


Jetzt funktioniert alles wie es sollte.



Ergebnis



Lassen Sie uns sehen, wie lange das Laden einer Unteranwendung dauert, indem Sie console.time () einfügen, bevor Sie Skripte in die Hauptanwendung und console.timeEnd () in den Konstruktor der Stammkomponente der Hauptanwendung laden.



Wenn die Anwendungen App-1 und App-2 zum ersten Mal geladen werden, sehen wir ungefähr Folgendes:







Ziemlich schnell. Wenn Sie jedoch zur zuvor heruntergeladenen Anwendung zurückkehren, werden die folgenden Nummern angezeigt: Die







Anwendung wird sofort geladen, da sich alle erforderlichen Blöcke bereits im Speicher befinden. Jetzt müssen Sie jedoch bei nicht verwendeten Objektreferenzen und Abonnements vorsichtiger sein, da diese selbst bei Zerstörung der Anwendung zu Speicherverlusten führen können.



Frame Manager ohne Frames



Die oben beschriebene Lösung ist im Frame-Manager implementiert, der das Laden von Anwendungen mit oder ohne Iframes unterstützt. Etwa ein Viertel aller Anwendungen in Tinkoff Business werden jetzt ohne Frames geladen, und ihre Anzahl wächst ständig.



Und dank der beschriebenen Lösung haben wir gelernt, wie man Angular und die im Frame-Manager und in den Anwendungen verwendeten allgemeinen Bibliotheken fummelt, was das Laden und Arbeiten weiter beschleunigt. Wir werden im nächsten Artikel darüber sprechen.



Repository mit Beispielcode



All Articles