Cómo cortamos el monolito. Parte 3, Administrador de marcos sin marcos

Oye. En el último artículo hablé sobre el administrador de marcos, un orquestador de aplicaciones front-end. La implementación descrita resuelve muchos problemas, pero tiene inconvenientes.



Debido al hecho de que las aplicaciones se cargan en un iframe, hay problemas con el diseño, los complementos no funcionan correctamente, los clientes aún descargan dos paquetes con Angular, incluso si las versiones de Angular en la aplicación y Frame Manager son las mismas. Y usar iframe en 2020 parece de mala educación. Pero, ¿qué pasa si abandonamos los marcos y cargamos todas las aplicaciones en una ventana?



Resultó que esto es posible, y ahora les diré cómo implementarlo.







Soluciones posibles



Single-spa : "Un enrutador javascript para microservicios front-end", como se indica en el sitio web de la biblioteca. Le permite ejecutar simultáneamente aplicaciones escritas en diferentes marcos en la misma página. La solución no funcionó para nosotros: la mayor parte de la funcionalidad no era necesaria, y el cargador System.js que se usa en ella en algunos casos crea problemas al compilar con webpack. Y usar un cargador de módulos con paquete web no parece ser la mejor solución.



Elementos angulares: este paquete le permite envolver componentes angulares en componentes web. Puede envolver toda la aplicación. Luego, tendrá que agregar un polyfill para navegadores antiguos, y crear un componente web a partir de una aplicación completa con su propio enrutamiento parece una decisión ideológicamente incorrecta.



Implementación del administrador de marcos



Veamos cómo se implementa la carga de aplicaciones sin marcos en el administrador de marcos usando un ejemplo.



La configuración inicial se ve así: tenemos una aplicación principal: main. Siempre se carga primero y debe cargar otras aplicaciones dentro de sí mismo: app-1 y app-2. Creemos tres aplicaciones usando el comando ng new <app-name> . A continuación, configuraremos el proxy para que los archivos html y js de la aplicación requerida se envíen a solicitudes como /<app-name>/*.js , /<app-name>/*.html , y las estadísticas de la aplicación principal se envíen a todas las demás solicitudes.



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;




Para las aplicaciones app-1 y app-2, especificaremos baseHref en angular.json app1 y app2, respectivamente. También cambiaremos los selectores de componentes raíz a app-1 y app-2.



Así es como se ve la aplicación principal




Primero, carguemos al menos una sub-aplicación. Para hacer esto, necesita cargar todos los archivos js especificados en index.html.



Descubra las URL de los archivos js: realice una solicitud http para index.html, analice la cadena con DOMParser y seleccione todas las etiquetas de script. Convirtamos todo en una matriz y mapeámoslo a una matriz de direcciones. Las direcciones obtenidas de esta manera contendrán location.origin, por lo que lo reemplazamos con una cadena vacía:



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


Hay direcciones, ahora necesitas cargar los 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);
  });
}


El código agrega elementos de script con el src necesario al DOM, y después de descargar los scripts, elimina estos elementos; una solución bastante estándar, la carga en webpack y system.js se implementa de manera similar.



Después de cargar los scripts, en teoría, tenemos todo para lanzar la aplicación integrada. Pero, de hecho, obtendremos una reinicialización de la aplicación principal. Parece que la aplicación cargada está de alguna manera en conflicto con la principal, lo que no sucedió cuando se cargó en el iframe.



Cargando paquetes de paquetes web



Angular usa webpack para cargar módulos. En una configuración estándar, el paquete web divide el código en los siguientes paquetes:



  • main.js: todo el código del cliente;
  • polyfills.js - polyfills;
  • styles.js - estilos;
  • vendor.js: todas las bibliotecas utilizadas en la aplicación, incluida Angular;
  • runtime.js - tiempo de ejecución del paquete web;
  • <module-name> .module.js: módulos perezosos.


Si abre alguno de estos archivos, al principio puede ver el código:



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


Y en 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]);


Funciona así: cuando se carga el paquete, crea una matriz webpackJsonp, si aún no existe, e inserta su contenido en ella. El tiempo de ejecución del paquete web anula la función de inserción de esta matriz para que luego pueda cargar nuevos paquetes y procese todo lo que ya está en la matriz.



Todo esto es necesario para que el orden en que se carguen los bultos no importe.



Por lo tanto, si carga una segunda aplicación Angular, intentará agregar sus módulos al tiempo de ejecución del paquete web ya existente, lo que en el mejor de los casos conducirá a la reinicialización de la aplicación principal.



Cambiar el nombre de webpackJsonp



Para evitar conflictos, debe cambiar el nombre de la matriz webpackJsonp. Angular CLI usa su propia configuración de paquete web, pero se puede ampliar si se desea. Para hacer esto, necesita instalar el paquete angular-builders / custom-webpack:



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



Luego, en el archivo angular.json en la configuración del proyecto, reemplace architect.build.builder con @ angular-builders / custom-webpack: browser , y agregue a architect.build.options :



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


También necesita reemplazar architect.serve.builder con @ angular-builders / custom-webpack: dev-server para que esto funcione localmente con el servidor de desarrollo.



Ahora necesita crear un archivo de configuración de paquete web, que se especifica arriba en customWebpackConfig: custom-webpack.config.js



Define configuraciones personalizadas, puede leer más en la documentación oficial .



Estamos interesados ​​en jsonpFunction .



Puede establecer una configuración de este tipo en todas las aplicaciones cargadas para evitar conflictos (si después de eso aún persisten los conflictos, lo más probable es que haya sido maldecido):



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


Ahora, si intentamos cargar todos los scripts de la forma descrita anteriormente, veremos un error:



El selector app-1 no coincide con ningún elemento



Antes de cargar la aplicación, debe agregar su elemento raíz al DOM:



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


Intentemos de nuevo - ¡hurra, la aplicación se ha cargado!







Cambiar entre aplicaciones



Eliminamos la aplicación anterior del DOM y podemos cambiar entre aplicaciones:



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


Pero hay fallas aquí: cuando vamos a app-1 → app-2 → app-1, recargamos los paquetes js para la aplicación app-1 y ejecutamos su código. Además, no destruimos aplicaciones cargadas previamente, lo que conduce a pérdidas de memoria y consumo innecesario de recursos.



Si no vuelve a descargar los paquetes de aplicaciones, el proceso de arranque no se ejecutará por sí solo y la aplicación no se cargará. Debe delegar el proceso de inicio de bootstrap a la aplicación principal.



Para hacer esto, reescribamos el archivo main.ts de las aplicaciones cargadas:



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

window[BOOTSTRAP_FN_NAME] = bootstrapFn;


El método bootstrapModule no se ejecuta inmediatamente, sino que se almacena en una función contenedora que reside en una variable global. En la aplicación principal, puede acceder a ella y ejecutarla cuando sea necesario.



Para destruir la aplicación y reparar las pérdidas de memoria, debe llamar al método de destrucción del módulo de la aplicación raíz (AppModule). El método platformBrowserDynamic (). BootstrapModule devuelve un enlace, lo que significa nuestra función contenedora:



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

this.ngModule.destroy(); //   


Después de llamar a destroy () en el módulo raíz, se llamarán los métodos ngOnDestroy () de todos los servicios y componentes de la aplicación (si están implementados).



Todo funciona. Pero si la aplicación cargada contiene módulos perezosos, no podrán cargarse:







se puede ver que falta la ruta de la aplicación en la dirección (debería haber /app2/lazy-lazy-module.js ). Para resolver este problema, debe sincronizar el href base de la aplicación principal y cargada:



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

  base.href = appBaseHref;
}


Ahora todo funciona como debería.



Salir



Veamos cuánto tiempo lleva cargar una subaplicación poniendo console.time () antes de cargar los scripts en la aplicación principal y console.timeEnd () en el constructor del componente raíz de la aplicación principal.



Cuando las aplicaciones app-1 y app-2 se cargan por primera vez, vemos algo como esto:







Bastante rápido. Pero si regresa a la aplicación descargada anteriormente, puede ver los siguientes números: La







aplicación se carga instantáneamente, ya que todos los fragmentos necesarios ya están en la memoria. Pero ahora debe tener más cuidado con las suscripciones y referencias de objetos no utilizados, ya que incluso cuando se destruye la aplicación, pueden provocar pérdidas de memoria.



Administrador de marcos sin marcos



La solución descrita anteriormente se implementa en el administrador de marcos, que admite la carga de aplicaciones con o sin iframes. Aproximadamente una cuarta parte de todas las aplicaciones en Tinkoff Business ahora se cargan sin marcos, y su número crece constantemente.



Y gracias a la solución descrita, aprendimos cómo manipular Angular y las bibliotecas comunes utilizadas en el administrador de marcos y las aplicaciones, lo que aumentó aún más la velocidad de carga y trabajo. Hablaremos de esto en el próximo artículo.



Repositorio con código de muestra



All Articles