How we sawed the monolith. Part 3, Frame Manager without frames

Hey. In the last article, I talked about the Frame manager, an orchestrator of front-end applications. The described implementation solves many problems, but it has drawbacks.



Due to the fact that applications are loaded in an iframe, there are problems with the layout, plugins do not work correctly, clients still download two bundles with Angular, even if the versions of Angular in the application and Frame Manager are the same. And using iframe in 2020 seems like bad manners. But what if we abandon frames and load all applications in one window?



It turned out that this is possible, and now I will tell you how to implement it.







Possible solutions



Single-spa : "A javascript router for front-end microservices" - as indicated on the library website. Allows you to simultaneously run applications written in different frameworks on the same page. The solution did not work for us: most of the functionality was not needed, and the System.js loader used in it in some cases creates problems when building with webpack. And using a module loader with webpack doesn't seem to be the best solution.



Angular elements: This package allows you to wrap Angular components in web components. You can wrap the entire application. Then you will have to add a polyfill for old browsers, and creating a web component from an entire application with its own routing looks like an ideologically wrong decision.



Frame manager implementation



Let's see how loading applications without frames in the Frame manager is implemented using an example.



The initial setup looks like this: we have a main application - main. It always loads first and must load other applications within itself - app-1 and app-2. Let's create three applications using the ng new <app-name> command . Next, set up proxying so that html and js files of the required application are sent to requests of the form /<app-name>/*.js , /<app-name>/*.html , and the statics of the main application are sent to all other requests.



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;




For applications app-1 and app-2, we will specify the baseHref in angular.json app1 and app2, respectively. We'll also change the root component selectors to app-1 and app-2.



This is what the main application looks like




First, let's get at least one sub-application loaded. To do this, you need to load all the js files specified in index.html.



Find out urls of js files: make an http request for index.html, parse the string using DOMParser and select all script tags. Let's convert everything to an array and map it to an array of addresses. Addresses obtained this way will contain location.origin, so we replace it with an empty string:



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


There are addresses, now you need to load the 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);
  });
}


The code adds script elements with the necessary src to the DOM, and after downloading the scripts, it removes these elements - a fairly standard solution, loading into webpack and system.js is similarly implemented.



After loading the scripts - in theory - we have everything to launch the embedded application. But in fact, we will get the main application reinitialized. It looks like the app being loaded is somehow conflicting with the main one, which didn't happen when loaded into the iframe.



Loading webpack bundles



Angular uses webpack to load modules. In a standard configuration, the webpack splits the code into the following bundles:



  • main.js - all client code;
  • polyfills.js - polyfills;
  • styles.js - styles;
  • vendor.js - all libraries used in the application, including Angular;
  • runtime.js - webpack runtime;
  • <module-name> .module.js - lazy modules.


If you open any of these files, at the very beginning you can see the code:



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


And 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]);


It works like this: when the bundle is loaded, it creates an array webpackJsonp, if it does not already exist, and pushes its contents into it. The webpack runtime overrides the push function of this array so that you can later load new bundles, and processes everything that is already in the array.



All this is necessary so that the order in which the bundles are loaded does not matter.



Thus, if you load a second Angular application, it will try to add its modules to the already existing webpack runtime, which at best will lead to reinitialization of the main application.



Change the name of webpackJsonp



To avoid conflicts, you need to change the name of the webpackJsonp array. Angular CLI uses its own webpack config, but it can be extended if desired. To do this, you need to install the angular-builders / custom-webpack package:



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



Then, in the angular.json file in the project configuration, replace architect.build.builder with @ angular-builders / custom-webpack: browser , and add to architect.build.options :



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


You also need to replace architect.serve.builder with @ angular-builders / custom-webpack: dev-server for this to work locally with the dev server.



Now you need to create a webpack configuration file, which is specified above in customWebpackConfig: custom-webpack.config.js



It defines custom settings, you can read more in the official documentation .



We are interested in jsonpFunction .



You can set such a config in all loaded applications to avoid conflicts (if after that conflicts still remain, most likely you were cursed):



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


Now, if we try to load all scripts in the way described above, we will see an error:



The selector app-1 did not match any elements



Before loading the application, you need to add its root element to the DOM:



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


Let's try again - hurray, the application has loaded!







Switch between applications



We remove the previous application from the DOM and we can switch between applications:



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


But there are flaws here: when we go app-1 โ†’ app-2 โ†’ app-1, we reload the js bundles for the app-1 application and execute their code. In addition, we do not destroy previously loaded applications, which leads to memory leaks and unnecessary resource consumption.



If you do not re-download the application bundles, the bootstrap process will not execute by itself and the application will not load. You need to delegate the bootstrap startup process to the main application.



To do this, let's rewrite the main.ts file of the loaded applications:



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

window[BOOTSTRAP_FN_NAME] = bootstrapFn;


The bootstrapModule method is not executed immediately, but is stored in a wrapper function that resides in a global variable. In the main application, you can access it and execute when needed.



To destroy the application and fix memory leaks, you need to call the destroy method of the root application module (AppModule). The platformBrowserDynamic (). BootstrapModule method returns a link to it, which means our wrapper function:



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

this.ngModule.destroy(); //   


After calling destroy () on the root module, the ngOnDestroy () methods of all services and application components (if they are implemented) will be called.



Everything works. But if the loaded application contains lazy modules, they will not be able to load:







It can be seen that the application path is missing in the address (there should be /app2/lazy-lazy-module.js ). To solve this problem, you need to synchronize the base href of the main and the loaded application:



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

  base.href = appBaseHref;
}


Now everything works as it should.



Outcome



Let's see how long it takes to load a subapplication by putting console.time () before loading scripts in the main application and console.timeEnd () in the constructor of the root component of the main application.



When the app-1 and app-2 applications are loaded for the first time, we see something like this:







Pretty fast. But if you return to the previously downloaded application, you can see the following numbers: The







application is loaded instantly, since all the necessary chunks are already in memory. But now you need to be more careful about unused object references and subscriptions, since even when the application is destroyed, they can lead to memory leaks.



Frame manager without frames



The solution described above is implemented in the Frame manager, which supports loading applications with or without iframes. About a quarter of all applications in Tinkoff Business are now loaded without frames, and their number is constantly growing.



And thanks to the described solution, we learned how to fumble Angular and the common libraries used in the Frame manager and applications, which further increased the speed of loading and working. We will talk about this in the next article.



Repository with sample code



All Articles