Como serramos o monólito. Parte 3, Gerenciador de quadros sem quadros

Ei. No último artigo, falei sobre o gerenciador de quadros - um orquestrador de aplicativos front-end. A implementação descrita resolve muitos problemas, mas tem desvantagens.



Devido ao fato dos aplicativos serem carregados em um iframe, há problemas com o layout, os plug-ins não funcionam corretamente, os clientes ainda baixam dois bundles com Angular, mesmo que as versões do Angular no aplicativo e do Frame Manager sejam iguais. E usar iframe em 2020 parece falta de educação. Mas e se abandonarmos os frames e carregarmos todos os aplicativos em uma janela?



Descobriu-se que isso é possível, e agora vou lhe dizer como implementá-lo.







Soluções possíveis



Spa único : "Um roteador javascript para microsserviços front-end" - conforme indicado no site da biblioteca. Permite que você execute simultaneamente aplicativos escritos em diferentes estruturas na mesma página. A solução não funcionou para nós: a maior parte da funcionalidade não era necessária e o carregador System.js usado nele em alguns casos cria problemas ao construir com webpack. E usar um carregador de módulo com webpack não parece ser a melhor solução.



Elementos angulares: este pacote permite envolver componentes angulares em componentes da web. Você pode agrupar todo o aplicativo. Em seguida, você terá que adicionar um polyfill para navegadores antigos, e criar um componente da web a partir de um aplicativo inteiro com seu próprio roteamento parece uma decisão ideologicamente errada.



Implementação do gerenciador de quadros



Vamos ver como o carregamento de aplicativos sem quadros no gerenciador de quadros é implementado usando um exemplo.



A configuração inicial é assim: temos um aplicativo principal - principal. Ele sempre carrega primeiro e deve carregar outros aplicativos dentro dele mesmo - app-1 e app-2. Vamos criar três aplicativos usando o comando ng new <app-name> . A seguir, configuraremos o proxy para que os arquivos html e js do aplicativo requerido sejam enviados para solicitações como /<app-name>/*.js , /<app-name>/*.html , e as estatísticas do aplicativo principal sejam enviadas para todas as outras solicitações.



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 os aplicativos app-1 e app-2, especificaremos o baseHref no angular.json app1 e app2, respectivamente. Também alteraremos os seletores do componente raiz para app-1 e app-2.



Esta é a aparência do aplicativo principal




Primeiro, vamos carregar pelo menos um subaplicativo. Para fazer isso, você precisa carregar todos os arquivos js especificados em index.html.



Descubra urls de arquivos js: faça uma solicitação http para index.html, analise a string usando DOMParser e selecione todas as tags de script. Vamos converter tudo em um array e mapeá-lo para um array de endereços. Os endereços obtidos dessa forma conterão location.origin, portanto, o substituímos por uma string vazia:



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


Existem endereços, agora você precisa carregar os 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);
  });
}


O código adiciona elementos de script com o src necessário ao DOM e, depois de baixar os scripts, ele exclui esses elementos - uma solução bastante padrão, o carregamento em webpack e system.js é implementado de forma semelhante.



Depois de carregar os scripts - em teoria - temos tudo para iniciar o aplicativo embutido. Mas, na verdade, teremos uma reinicialização do aplicativo principal. Parece que o aplicativo que está sendo carregado está de alguma forma em conflito com o principal, o que não aconteceu quando carregado no iframe.



Carregando pacotes do webpack



Angular usa webpack para carregar módulos. Em uma configuração padrão, o webpack divide o código nos seguintes pacotes:



  • main.js - todo o código do cliente;
  • polyfills.js - polyfills;
  • styles.js - estilos;
  • vendor.js - todas as bibliotecas usadas no aplicativo, incluindo Angular;
  • runtime.js - tempo de execução do webpack;
  • <module-name> .module.js - módulos lazy.


Se você abrir qualquer um desses arquivos, logo no início poderá ver o código:



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


E em 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 assim: quando o pacote é carregado, ele cria o array webpackJsonp, se ainda não existir, e empurra seu conteúdo para ele. O tempo de execução do webpack substitui a função push dessa matriz para que você possa carregar novos pacotes posteriormente e processa tudo o que já está na matriz.



Tudo isso é necessário para que a ordem de carregamento dos maços não importe.



Portanto, se você carregar um segundo aplicativo Angular, ele tentará adicionar seus módulos ao tempo de execução do webpack já existente, o que, na melhor das hipóteses, levará à reinicialização do aplicativo principal.



Altere o nome de webpackJsonp



Para evitar conflitos, você precisa alterar o nome do array webpackJsonp. O Angular CLI usa sua própria configuração do webpack, mas pode ser estendido, se desejado. Para fazer isso, você precisa instalar o pacote angular-builders / custom-webpack:



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



Em seguida, no arquivo angular.json na configuração do projeto, substitua architect.build.builder por @ angular-builders / custom-webpack: browser e adicione a architect.build.options :



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


Você também precisa substituir architect.serve.builder por @ angular-builders / custom-webpack: dev-server para que isso funcione localmente com o servidor dev.



Agora você precisa criar um arquivo de configuração do webpack, que é especificado acima em customWebpackConfig: custom-webpack.config.js



Ele define as configurações personalizadas, você pode ler mais na documentação oficial .



Estamos interessados ​​em jsonpFunction .



Você pode definir essa configuração em todos os aplicativos carregados para evitar conflitos (se depois disso os conflitos ainda persistirem, provavelmente você foi amaldiçoado):



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


Agora, se tentarmos carregar todos os scripts da maneira descrita acima, veremos um erro:



O seletor app-1 não correspondeu a nenhum elemento



Antes de carregar o aplicativo, você precisa adicionar seu elemento raiz ao DOM:



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


Vamos tentar novamente - uau, o aplicativo foi carregado!







Alternar entre aplicativos



Removemos o aplicativo anterior do DOM e podemos alternar entre os aplicativos:



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


Mas há falhas aqui: quando vamos app-1 → app-2 → app-1, recarregamos os pacotes js para o aplicativo app-1 e executamos seu código. Além disso, não destruímos os aplicativos carregados anteriormente, o que leva a vazamentos de memória e consumo desnecessário de recursos.



Se você não baixar novamente os pacotes do aplicativo, o processo de bootstrap não será executado sozinho e o aplicativo não será carregado. Você precisa delegar o processo de inicialização do bootstrap ao aplicativo principal.



Para fazer isso, vamos reescrever o arquivo main.ts dos aplicativos carregados:



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

window[BOOTSTRAP_FN_NAME] = bootstrapFn;


O método bootstrapModule não é executado imediatamente, mas é armazenado em uma função de wrapper que reside em uma variável global. No aplicativo principal, você pode acessá-lo e executá-lo quando necessário.



Para destruir o aplicativo e corrigir vazamentos de memória, você precisa chamar o método destroy do módulo do aplicativo raiz (AppModule). O método platformBrowserDynamic (). BootstrapModule retorna um link para ele, o que significa que nossa função de wrapper:



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

this.ngModule.destroy(); //   


Depois de chamar destroy () no módulo raiz, os métodos ngOnDestroy () de todos os serviços e componentes do aplicativo (se forem implementados) serão chamados.



Tudo funciona. Mas se o aplicativo carregado contiver módulos lazy, eles não serão capazes de carregar:







pode ser visto que o caminho do aplicativo está faltando no endereço (deve haver /app2/lazy-lazy-module.js ). Para resolver este problema, você precisa sincronizar o href de base do aplicativo principal e o carregado:



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

  base.href = appBaseHref;
}


Agora tudo funciona como deveria.



Resultado



Vamos ver quanto tempo leva para carregar um subaplicativo, colocando console.time () antes de carregar scripts no aplicativo principal e console.timeEnd () no construtor do componente raiz do aplicativo principal.



Quando os aplicativos app-1 e app-2 são carregados pela primeira vez, vemos algo assim:







Muito rápido. Mas se você retornar ao aplicativo baixado anteriormente, poderá ver os seguintes números: O







aplicativo é carregado instantaneamente, pois todos os pedaços necessários já estão na memória. Mas agora você precisa ter mais cuidado com as referências e assinaturas de objetos não utilizados, porque mesmo quando o aplicativo é destruído, eles podem causar vazamentos de memória.



Gerenciador de quadros sem quadros



A solução descrita acima é implementada no gerenciador de quadros, que suporta o carregamento de aplicativos com ou sem iframes. Cerca de um quarto de todos os aplicativos do Tinkoff Business agora são carregados sem quadros, e seu número está crescendo constantemente.



E, graças à solução descrita, aprendemos como mexer no Angular e nas bibliotecas comuns usadas no gerenciador de quadros e nos aplicativos, o que aumentou ainda mais a velocidade de carregamento e trabalho. Falaremos sobre isso no próximo artigo.



Repositório com código de amostra



All Articles