我们如何看到巨石。第3部分,不带框架的框架管理器

嘿。上一篇文章中,我谈到了框架管理器,它是前端应用程序的协调器。所描述的实现解决了许多问题,但是具有缺点。



由于应用程序已加载到iframe中,因此存在布局问题,插件无法正常工作,即使应用程序和Frame Manager中Angular的版本相同,客户端也仍然下载了Angular的两个捆绑包。在2020年使用iframe似乎是一种不好的举止。但是,如果我们放弃框架并将所有应用程序加载到一个窗口中怎么办?



事实证明这是可能的,现在我将告诉您如何实现它。







可能的解决方案



Single-spa:“用于前端微服务的javascript路由器”-如图书馆网站上所示。允许您在同一页面上同时运行以不同框架编写的应用程序。该解决方案对我们不起作用:不需要大多数功能,并且在使用webpack进行构建时,在某些情况下使用的System.js加载器会产生问题。而且,将模块加载程序与webpack一起使用似乎不是最佳解决方案。



Angular元素:此软件包允许您将Angular组件包装在Web组件中。您可以包装整个应用程序。然后,您将不得不为旧的浏览器添加一个polyfill,并且从整个应用程序创建具有自己的路由的Web组件似乎在思想上是错误的决定。



框架管理器实施



让我们看看如何使用示例来实现在框架管理器中加载不带框架的应用程序。



初始设置如下所示:我们有一个主应用程序-main。它始终首先加载,并且必须加载其内部的其他应用程序-app-1和app-2。让我们使用ng new <app-name>命令创建三个应用程序接下来,设置进行代理,这样所需的应用程序的HTML和JS文件被发送到表单的请求/<app-name>/*.js/<app-name>/*.html,主要应用的静态发送到所有其他请求。



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;




对于应用程序app-1和app-2,我们将分别在angular.json app1和app2中指定baseHref。我们还将根组件选择器更改为app-1和app-2。



这是主应用程序的外观




首先,让我们至少加载一个子应用程序。为此,您需要加载index.html中指定的所有js文件。



找出js文件的网址:对index.html发出http请求,使用DOMParser解析字符串并选择所有脚本标签。让我们将所有内容转换为数组并将其映射到地址数组。通过这种方式获得的地址将包含location.origin,因此我们将其替换为空字符串:



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


有地址,现在您需要加载脚本:

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


该代码将具有必要src的脚本元素添加到DOM,并在下载脚本后删除了这些元素-一种相当标准的解决方案,类似地实现了将其加载到webpack和system.js中。



从理论上讲,加载脚本后,我们将拥有启动嵌入式应用程序的所有功能。但是实际上,我们将对主应用程序进行重新初始化。看起来,已加载的应用程序与主应用程序之间存在某种冲突,这在加载到iframe中时并没有发生。



加载webpack捆绑包



Angular使用webpack加载模块。在标准配置中,Webpack将代码分为以下捆绑包:



  • main.js-所有客户端代码;
  • polyfills.js-polyfills;
  • styles.js-样式;
  • vendor.js-应用程序中使用的所有库,包括Angular;
  • runtime.js-Webpack运行时;
  • <module-name> .module.js-惰性模块。


如果打开这些文件中的任何一个,一开始您就可以看到以下代码:



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


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


它的工作方式如下:加载捆绑软件时,它将创建webpackJsonp数组(如果尚不存在),并将其内容压入其中。webpack运行时将覆盖此数组的push函数,以便您以后可以加载新的包,并处理数组中已存在的所有内容。



所有这些都是必要的,因此,装入捆绑包的顺序无关紧要。



因此,如果您加载第二个Angular应用程序,它将尝试将其模块添加到已经存在的webpack运行时中,这充其量只能导致主应用程序的重新初始化。



更改webpackJsonp的名称



为了避免冲突,您需要更改webpackJsonp数组的名称。 Angular CLI使用自己的webpack配置,但可以根据需要进行扩展。为此,您需要安装angular-builders / custom-webpack软件包:



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



然后,在项目配置的angular.json文件,替换architect.build.builder@角建设者/自定义的WebPack:浏览器,并添加architect.build.options



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


您还需要用@ angular-builders / custom-webpack:dev-server替换architect.serve.builder以便在开发服务器上本地工作。 现在,您需要创建一个webpack配置文件,该文件在上面的customWebpackConfig中指定:custom-webpack.config.js 它定义了自定义设置,您可以在官方文档中阅读更多内容 我们对jsonpFunction感兴趣 您可以在所有已加载的应用程序中设置这样的配置以避免冲突(如果在此之后冲突仍然存在,则很可能您被诅咒了):



















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


现在,如果我们尝试以上述方式加载所有脚本,我们将看到一个错误:



选择器app-1与任何元素都不匹配



在加载应用程序之前,您需要将其根元素添加到DOM中:



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


让我们再试一次-欢呼,该应用程序已加载!







在应用程序之间切换



我们从DOM中删除了先前的应用程序,我们可以在应用程序之间进行切换:



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


但是有一些缺点:当我们进入app-1→app-2→app-1时,我们会为app-1应用程序重新加载js捆绑包并执行其代码。此外,我们不会破坏先前加载的应用程序,这会导致内存泄漏和不必要的资源消耗。



如果不重新下载应用程序捆绑包,则引导过程将不会自行执行,并且该应用程序也不会加载。您需要将引导启动过程委托给主应用程序。



为此,让我们重写已加载应用程序的main.ts文件:



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

window[BOOTSTRAP_FN_NAME] = bootstrapFn;


bootstrapModule 方法不会立即执行,而是存储在驻留在全局变量中的包装函数中。在主应用程序中,您可以访问它并在需要时执行它。



要销毁应用程序并修复内存泄漏,您需要调用根应用程序模块(AppModule)的destroy方法。 platformBrowserDynamic()。BootstrapModule方法返回指向它的链接,这意味着我们的包装器函数:



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

this.ngModule.destroy(); //   


在根模块上调用destroy()之后,将调用所有服务和应用程序组件(如果已实现)的ngOnDestroy()方法。



一切正常。但是,如果加载的应用程序包含惰性模块,则它们将无法加载:







可以看出地址中缺少应用程序路径(应该为/app2/lazy-lazy-module.js)。要解决此问题,您需要同步主程序和已加载应用程序的基本href:



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

  base.href = appBaseHref;
}


现在一切正常。



结果



让我们看看通过在主应用程序中加载脚本之前将console.time()和主应用程序根组件的构造函数中的console.timeEnd()放置之前,加载子应用程序需要多长时间。



第一次加载app-1和app-2应用程序时,我们看到的是这样的:非常







快。但是,如果您返回以前下载的应用程序,则会看到以下数字:







由于所有必需的块已经在内存中,因此该应用程序将立即加载。但是,现在您需要更加注意未使用的对象引用和订阅,因为即使应用程序被破坏,它们也可能导致内存泄漏。



没有框架的框架管理器



上述解决方案是在框架管理器中实现的,该管理器支持加载带有或不带有iframe的应用程序。现在,Tinkoff Business中大约有四分之一的应用程序没有框架加载,并且其数量还在不断增长。



借助所描述的解决方案,我们学习了如何使Angular以及用于框架管理器和应用程序的通用库失效,从而进一步提高了加载和工作速度。我们将在下一篇文章中讨论。



带有示例代码的存储库



All Articles