iframes redefined - Angular & UI5 web apps as web components
Motivation
As described in previous blog posts, we achieved to integrate different web applications implemented in different frameworks - namely Angular and UI5 - in a single application. Although this approach works smooth and seamless, it would be desirable to have a framework independent "hosting" component. This component then could be easily integrated independently in every web application. Luckily, the W3C already defines a standard for defining custom HTML elements which form the basis following approach. Elements defined based on this standard can therefore easily integrated in any existing web application.
To support the maximal possible number of browsers and platforms we decided to utilize a framework to create the web components. This way, the bundled package will contain all necessary dependencies and required polyfills to be applicable in most scenarios. StencilJS meets these requirements and offers an Angular-React inspired syntax to define web components.
The UI5 web component
The UI5 web component basically relies on the implementation of the Angular-UI5-hybrid described in previous blog posts. The communication scheme from and to the web component was adapted and is now mapped by custom events defined by the W3C standard. Under these conditions, the implementation of the UI5 web component reduces to a few lines of code:
import {Component, Event, EventEmitter, h, Listen, Prop} from '@stencil/core'; declare var sap: any; @Component({ tag: 'ui5-component' }) export class Ui5Component { @Prop() componentName: string; @Prop() componentUrl: string; @Event() ui5Message: EventEmitter; @Event() loadingError: EventEmitter; private container: any; private ui5EventBus: any; initUi5Script() { const ui5Script = document.createElement('script'); ui5Script.setAttribute('id','sap-ui-bootstrap'); ui5Script.setAttribute('src', 'https://openui5.hana.ondemand.com/resources/sap-ui-core.js'); ui5Script.setAttribute('data-sap-ui-libs', ''); ui5Script.setAttribute('data-sap-ui-libs', 'sap.m'); ui5Script.setAttribute('data-sap-ui-async', 'true'); ui5Script.setAttribute('data-sap-ui-theme', 'sap_fiori_3'); ui5Script.onload = this.initUi5ComponentContainer.bind(this); document.body.append(ui5Script); } private loadComponent() { try { const component = sap.ui.getCore().createComponent(this.componentName, this.componentUrl); this.container.setComponent(component); } catch (error) { this.loadingError.emit(error); } } @Listen('messageToUi5') messageReceived(data: any) { if (this.ui5EventBus) { this.ui5EventBus.publish('UI5Component', 'hostToUi5', data); } } private initUi5ComponentContainer() { const oCore = sap.ui.getCore(); this.ui5EventBus = oCore.getEventBus(); oCore.attachInit(() => { this.container = new sap.ui.core.ComponentContainer({ width: '100%', height: '100%' }); this.container.placeAt(document.getElementById('ui5content')); this.ui5EventBus.subscribe('UI5Component', 'ui5ToHost', (_channel, _eventId, data) => { this.ui5Message.emit(data); }); this.loadComponent(); }); } componentDidLoad() { this.initUi5Script(); } componentDidUpdate() { this.loadComponent(); } render() { return ( <div id="ui5content"></div> ); } }
The approach is similar to the Angular-UI5-hybrid application. After loading the UI5 runtime environment via dynamically adding script tag, we use a ComponentContainer to host any UI5 component. We register a handler for messages on the UI5 event bus which then emits events to the host of the web component.
After bundling this component in a package, this UI5 web component can easily be integrated in any web application or in any HTML page:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>UI5 web component PoC</title> <script type="application/javascript"> window.onload = () => { let component = document.getElementById('myUi5Component'); component.addEventListener('ui5Message', (data) => { console.log(JSON.stringify(data)); }); component.addEventListener('loadingError', (error) => { console.error(JSON.stringify(error)); }); }; </script> <script type="module" src="ui5-webcomponent.esm.js"></script> <script nomodule src="ui5-webcomponent.js"></script> </head> <body> <ui5-component id="myUi5Component" component-name="de.exxcellent.school.ui5" component-url="http://localhost/ui5app"> </ui5-component> </body> </html>
Any changes on the component attributes are reflected immediately by an internal reload of the application (see lifecycle hook methode "componentDidUpdate").
For further details have a look at the repository under https://github.com/schpg/ui5-webcomponent.git
The Angular web component
The Angular web component is implemented in a similar fashion. It expects a bundled Angular web application at a given URL. It loads the index.html of the application, extracts the contained script references and attaches corresponding script elements to the DOM. As the bundled javascript files already contain the bootstrapping mechanism of the Angular web application, no further steps are required to start the hosted application.
import {Component, EventEmitter, Event, h, Prop, Listen} from '@stencil/core'; @Component({ tag: 'ng-component' }) export class NgComponent { @Prop() url: string; @Event() ngMessage: EventEmitter; @Event() loadingError: EventEmitter; ... private loadScriptsByIndex() : Promise<HTMLCollectionOf<HTMLScriptElement>> { return new Promise((resolve, reject) => { if (!this.url) { reject('invalid url'); return; } let address = !this.url.endsWith('/index.html') ? this.url + '/index.html' : this.url; fetch(address).then((response) => response.text()).then((html) => { let index = document.createElement('html'); index.innerHTML = html; resolve(index.getElementsByTagName('script')); }) }); } private attachAngularBootstrapScripts() { return this.loadScriptsByIndex().then((scriptElements) => { let scriptArray = Array.from(scriptElements).map((element) => { return new Promise((resolve) => { let scriptElement = document.createElement('script'); scriptElement.src = this.url + NgComponent.getFilename(element.src); scriptElement.onload = resolve; scriptElement.noModule = element.noModule; document.head.appendChild(scriptElement); }) }); return Promise.all(scriptArray); }); } private static attachAngularAnker() { let angularDiv = document.getElementById('angularDiv'); angularDiv.innerHTML = '<app-root></app-root>'; } private attachZoneScript() { return new Promise((resolve) => { let scriptElement = document.createElement('script'); scriptElement.src = 'https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.10.3/zone.min.js'; scriptElement.onload = resolve; document.head.appendChild(scriptElement); }); } private initAngularApp() { NgComponent.attachAngularAnker(); this.attachZoneScript().then(this.attachAngularBootstrapScripts.bind(this)).catch((error) => { this.loadingError.emit(error); }); } componentDidLoad() { this.initAngularApp(); } componentDidUpdate() { this.initAngularApp(); } @Listen('messageToNg') messageToNg(data) { if (!window['ngHostMessageHandler']) { return; } window['ngHostMessageHandler'](data); } render() { return ( <div id="angularDiv" /> ); } }
Events are handled similarly to the UI5 web component. We listen or emit events which are - due to the lack of a global event bus - dipatched by an optional function. This function is passed via the global window object. The bundled Angular web component can therefore simply integrable in any web application by referencing the new HTML tag:
<!DOCTYPE html> <html dir="ltr" lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0"> <title>Ng web component PoC</title> <script type="module" src="ng-webcomponent.esm.js"></script> <script nomodule src="ng-webcomponent.js"></script> </head> <body> <ng-component url="http://localhost/ngapp"> </ng-component> </body> </html>
For further details have a look at the repository under https://github.com/schpg/ng-webcomponent.git
Limitations
Basically, this approach has the same limitations as described in the blog post referring to the routing in hybrid applications. Therefore, to prevent interfering router interactions and unpredictable behaviour, it is highly recommended using a non hash based routing scheme for the hosted and / or hosting web application.
In contrast to the UI5 standard library which already provides means to host other components (i.e. the ComponentContainer widget) with a complete lifecycle of loading and unloading of the application, the Angular web application is loaded "by hand" using standard mechanisms. These mechanisms consist of creating script tags for all referenced files. Unfortunately, this mechanism does not allow for unloading files, which once have been loaded. As a consequence, there are potential naming conflicts and therefore unpredictable behaviour when loading multiple Angular web applications with this web component.
Conclusion
The presented encapsulation of the hosting functionality makes Angular and especially UI5 web applications portable and seamlessly integratable in basically every web application. The use of W3C standards provides a sustainable and universal approach to incorporate web applications of different flavours with a bidirectional communication feature.
Image sources
The cover image used in this post was created by Markus Spiske under the following license. All other images on this page were created by eXXcellent solutions under the terms of the Creative Commons Attribution 4.0 International License