Comment nous avons scié le monolithe. Partie 3, Frame Manager sans cadres

Hey. Dans le dernier article, j'ai parlé du gestionnaire de trames - un orchestrateur d'applications frontales. La mise en œuvre décrite résout de nombreux problèmes, mais elle présente des inconvénients.



En raison du fait que les applications sont chargées dans une iframe, des problèmes de mise en page apparaissent, les plugins ne fonctionnent pas correctement, les clients téléchargent toujours deux bundles avec Angular, même si les versions d'Angular dans l'application et Frame Manager sont les mêmes. Et utiliser iframe en 2020 semble être de mauvaises manières. Mais que se passe-t-il si nous abandonnons les cadres et chargeons toutes les applications dans une seule fenêtre?



Il s'est avéré que cela est possible et je vais maintenant vous dire comment le mettre en œuvre.







Solutions possibles



Single-spa : "Un routeur javascript pour les microservices frontaux" - comme indiqué sur le site Web de la bibliothèque. Vous permet d'exécuter simultanément des applications écrites dans différents frameworks sur la même page. La solution n'a pas fonctionné pour nous: la plupart des fonctionnalités n'étaient pas nécessaires, et le chargeur System.js utilisé dans certains cas crée des problèmes lors de la construction avec webpack. Et utiliser un chargeur de module avec webpack ne semble pas être la meilleure solution.



Éléments angulaires: ce package vous permet d'encapsuler des composants angulaires dans des composants Web. Vous pouvez envelopper l'ensemble de l'application. Ensuite, vous devrez ajouter un polyfill pour les anciens navigateurs, et créer un composant Web à partir d'une application entière avec son propre routage ressemble à une mauvaise décision idéologiquement.



Implémentation du gestionnaire de trames



Voyons comment le chargement d'applications sans cadres dans le gestionnaire de cadres est implémenté à l'aide d'un exemple.



La configuration initiale ressemble à ceci: nous avons une application principale - main. Il se charge toujours en premier et doit charger d'autres applications en lui-même - app-1 et app-2. Créons trois applications en utilisant la commande ng new <app-name> . Ensuite, nous allons configurer le proxy pour que les fichiers html et js de l'application requise soient envoyés à des requêtes telles que /<nom-app>/*.js , /<nom-app>/*.html , et les statiques de l'application principale sont envoyées à toutes les autres requêtes.



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;




Pour les applications app-1 et app-2, nous spécifierons respectivement baseHref dans angular.json app1 et app2. Nous allons également changer les sélecteurs de composants racine en app-1 et app-2.



Voici à quoi ressemble l'application principale




Commençons par charger au moins une sous-application. Pour ce faire, vous devez charger tous les fichiers js spécifiés dans index.html.



Découvrez les URL des fichiers js: faites une requête http pour index.html, analysez la chaîne en utilisant DOMParser et sélectionnez toutes les balises de script. Convertissons tout en un tableau et mappons-le à un tableau d'adresses. Les adresses obtenues de cette manière contiendront location.origin, nous le remplaçons donc par une chaîne vide:



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, ''));
}


Il y a des adresses, maintenant vous devez charger les scripts:

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);
  });
}


Le code ajoute des éléments de script avec le src nécessaire au DOM, et après avoir téléchargé les scripts, il supprime ces éléments - une solution assez standard, le chargement dans webpack et system.js est implémentée de la même manière.



Après avoir chargé les scripts - en théorie - nous avons tout pour lancer l'application embarquée. Mais en fait, nous obtiendrons une réinitialisation de l'application principale. Il semble que l'application en cours de chargement soit en conflit avec l'application principale, ce qui ne s'est pas produit lors du chargement dans l'iframe.



Chargement des bundles Webpack



Angular utilise webpack pour charger des modules. Dans une configuration standard, le webpack divise le code dans les bundles suivants:



  • main.js - tout le code client;
  • polyfills.js - polyfills;
  • styles.js - styles;
  • vendor.js - toutes les bibliothèques utilisées dans l'application, y compris Angular;
  • runtime.js - runtime webpack;
  • <module-name> .module.js - modules paresseux.


Si vous ouvrez l'un de ces fichiers, au tout début, vous pouvez voir le code:



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


Et dans 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]);


Cela fonctionne comme ceci: lorsque le bundle est chargé, il crée un tableau webpackJsonp, s'il n'existe pas déjà, et y pousse son contenu. Le runtime webpack remplace la fonction push de ce tableau afin que vous puissiez ultérieurement télécharger de nouveaux bundles et traite tout ce qui se trouve déjà dans le tableau.



Tout cela est nécessaire pour que l'ordre dans lequel les bundles sont chargés n'a pas d'importance.



Ainsi, si vous chargez une deuxième application Angular, elle essaiera d'ajouter ses modules au runtime webpack déjà existant, ce qui conduira au mieux à la réinitialisation de l'application principale.



Changer le nom de webpackJsonp



Pour éviter les conflits, vous devez changer le nom du tableau webpackJsonp. Angular CLI utilise sa propre configuration Webpack, mais elle peut être étendue si vous le souhaitez. Pour ce faire, vous devez installer le package angular-builders / custom-webpack:



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



Ensuite, dans le fichier angular.json de la configuration du projet, remplacez architect.build.builder par @ angular-builders / custom-webpack: browser , et ajoutez à architect.build.options :



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


Il faut également remplacer architect.serve.builder par @ angular-builders / custom-webpack: dev-server pour que cela fonctionne localement avec le serveur de développement.



Vous devez maintenant créer un fichier de configuration webpack, qui est spécifié ci-dessus dans customWebpackConfig: custom-webpack.config.js



Il définit les paramètres personnalisés, vous pouvez en savoir plus dans la documentation officielle .



Nous sommes intéressés par jsonpFunction .



Vous pouvez définir une telle configuration dans toutes les applications chargées pour éviter les conflits (si après cela, des conflits persistent, vous avez probablement été maudit):



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


Maintenant, si nous essayons de charger tous les scripts de la manière décrite ci-dessus, nous verrons une erreur:



Le sélecteur app-1 ne correspond à aucun élément



Avant de charger l'application, vous devez ajouter son élément racine au DOM:



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


Essayons à nouveau - hourra, l'application est chargée!







Basculer entre les applications



Nous supprimons l'application précédente du DOM et nous pouvons basculer entre les applications:



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


Mais il y a des défauts ici: quand nous allons app-1 → app-2 → app-1, nous rechargeons les bundles js pour l'application app-1 et exécutons leur code. De plus, nous ne détruisons pas les applications précédemment chargées, ce qui entraîne des fuites de mémoire et une consommation de ressources inutile.



Si vous ne retéléchargez pas les bundles d'applications, le processus d'amorçage ne s'exécutera pas de lui-même et l'application ne se chargera pas. Vous devez déléguer le processus de démarrage de bootstrap à l'application principale.



Pour ce faire, réécrivons le fichier main.ts des applications chargées:



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

window[BOOTSTRAP_FN_NAME] = bootstrapFn;


La méthode bootstrapModule n'est pas exécutée immédiatement, mais est stockée dans une fonction wrapper qui réside dans une variable globale. Dans l'application principale, vous pouvez y accéder et l'exécuter si nécessaire.



Pour détruire l'application et corriger les fuites de mémoire, vous devez appeler la méthode destroy du module d'application racine (AppModule). La méthode platformBrowserDynamic (). BootstrapModule renvoie un lien vers elle, ce qui signifie notre fonction wrapper:



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

this.ngModule.destroy(); //   


Après avoir appelé destroy () sur le module racine, les méthodes ngOnDestroy () de tous les services et composants d'application (s'ils sont implémentés) seront appelées.



Tout fonctionne. Mais si l'application chargée contient des modules paresseux, ils ne pourront pas se charger:







on peut voir que le chemin de l'application est manquant dans l'adresse (il devrait y avoir /app2/lazy-lazy-module.js ). Pour résoudre ce problème, vous devez synchroniser le href de base de l'application principale et de l'application chargée:



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

  base.href = appBaseHref;
}


Maintenant, tout fonctionne comme il se doit.



Résultat



Voyons combien de temps il faut pour charger une sous-application en mettant console.time () avant de charger les scripts dans l'application principale et console.timeEnd () dans le constructeur du composant racine de l'application principale.



Lorsque les applications app-1 et app-2 sont chargées pour la première fois, nous voyons quelque chose comme ceci:







Assez rapide. Mais si vous revenez à l'application téléchargée précédemment, vous pouvez voir les chiffres suivants: L'







application est chargée instantanément, puisque tous les morceaux nécessaires sont déjà en mémoire. Mais maintenant, vous devez faire plus attention aux références d'objets et aux abonnements inutilisés, car même lorsque l'application est détruite, ils peuvent entraîner des fuites de mémoire.



Gestionnaire de cadres sans cadres



La solution décrite ci-dessus est implémentée dans le gestionnaire de cadres, qui prend en charge le chargement d'applications avec ou sans iframes. Environ un quart de toutes les applications de Tinkoff Business sont désormais chargées sans cadres et leur nombre ne cesse de croître.



Et grâce à la solution décrite, nous avons appris à fouiller Angular et les bibliothèques courantes utilisées dans le gestionnaire de cadres et les applications, ce qui a encore augmenté la vitesse de chargement et de travail. Nous en parlerons dans le prochain article.



Référentiel avec exemple de code



All Articles