In a perfect world, you’d be able to create a greenfield Angular SPA from scratch. In the real world, that’s usually not the case. That legacy web application has way too much baggage to realistically convert it to an SPA in a single shot. This is particularly true if you’re currently using server-side rendering with (e.g.) JSP or Rails technology.

The only real solution is to incrementally move/upgrade pieces of UI logic and data access patterns (i.e. converting to REST interfaces). If you are planning a move to Angular*, a good starting point is to first embed small pieces of Angular-implemented logic into your existing application. This approach also allows the new Angular components to share CSS styles for seamless visual integration.

NgInterop is a simple TypeScript class that allows a legacy Web application to have two-way communications (via pub/sub) with embedded Angular components. The underlying MessagingSerivce class is an implementation of Message Bus pattern in Angular 2 and TypeScript.

Source code for the demo project is here: embedded-angular

NgInterop.js import {Injectable, NgZone} from '@angular/core'; import {AngularEvent, EventCallbackFunction, HtmlEvent, LogEvent} from './event.types'; import {MessagingService} from './messaging.service'; @Injectable({ providedIn: 'root' }) export class NgInterop { public static readonly ANGULAR_EVENT: string = 'AngularEvent'; public static readonly HTML_EVENT: string = 'HtmlEvent'; public static readonly LOG_EVENT: string = 'LogEvent'; private typeClassMap: any = {}; private readonly initCallback: any; constructor(private ngZone: NgZone, private messagingService: MessagingService) { this.typeClassMap[NgInterop.ANGULAR_EVENT] = AngularEvent; this.typeClassMap[NgInterop.HTML_EVENT] = HtmlEvent; this.typeClassMap[NgInterop.LOG_EVENT] = LogEvent; this.initCallback = window['NgInteropInitCallback']; window['ngInterop'] = this; this.init(); } private init() { if (!this.initCallback) { console.warn('NgInterop.init: No NgInteropInitCallback found!'); return; } this.initCallback(); } public subscribeToClass(className: string, callBack: EventCallbackFunction): any { const self = this; this.ngZone.run(() => { self.messagingService.of(self.typeClassMap[className]).subscribe(callBack); }); } public publishToClass(className: string, source: string, value: string): any { const self = this; this.ngZone.run(() => { self.messagingService.publish(new self.typeClassMap[className](source, value)); }); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 import { Injectable , NgZone } from '@angular/core' ; import { AngularEvent , EventCallbackFunction , HtmlEvent , LogEvent } from './event.types' ; import { MessagingService } from './messaging.service' ; @ Injectable ( { providedIn : 'root' } ) export class NgInterop { public static readonly ANGULAR_EVENT : string = 'AngularEvent' ; public static readonly HTML_EVENT : string = 'HtmlEvent' ; public static readonly LOG_EVENT : string = 'LogEvent' ; private typeClassMap: any = { } ; private readonly initCallback : any ; constructor ( private ngZone: NgZone , private messagingService: MessagingService ) { this . typeClassMap [ NgInterop . ANGULAR_EVENT ] = AngularEvent ; this . typeClassMap [ NgInterop . HTML_EVENT ] = HtmlEvent ; this . typeClassMap [ NgInterop . LOG_EVENT ] = LogEvent ; this . initCallback = window [ 'NgInteropInitCallback' ] ; window [ 'ngInterop' ] = this ; this . init ( ) ; } private init ( ) { if ( ! this . initCallback ) { console . warn ( 'NgInterop.init: No NgInteropInitCallback found!' ) ; return ; } this . initCallback ( ) ; } public subscribeToClass ( className : string , callBack : EventCallbackFunction ) : any { const self = this ; this . ngZone . run ( ( ) = > { self . messagingService . of ( self . typeClassMap [ className ] ) . subscribe ( callBack ) ; } ) ; } public publishToClass ( className : string , source : string , value : string ) : any { const self = this ; this . ngZone . run ( ( ) = > { self . messagingService . publish ( new self . typeClassMap [ className ] ( source , value ) ) ; } ) ; } }

Highlights:

6 : Side note on the new Angular 6 providedIn syntax. This saves from having to add every service to the app.module.ts @NgModule providers list. Very handy!

: Side note on the new Angular 6 syntax. This saves from having to add every service to the app.module.ts @NgModule providers list. Very handy! 19 : This saves the native JavaScript initialization callback function (see index.html below). This example only has one callback function, but it would be easy to extend this functionality to support multiple initialization callbacks.

: This saves the native JavaScript initialization callback function (see index.html below). This example only has one callback function, but it would be easy to extend this functionality to support multiple initialization callbacks. 20 : Add the NgInterop instance into the window object so that external JavaScript can simply call methods on window.ngInterop (again, see index.html below).

: Add the NgInterop instance into the object so that external JavaScript can simply call methods on (again, see index.html below). 32 and 38: Wrap the MessagingService subscribe/publish in a NgZone.run() call. This allows the external JavaScript to execute these functions in the Angular zone.

Other notes:

The typeClassMap object maps a BaseEvent class name (string) to a real class. The public static *_EVENT names provide safer access to the NgInterop functions from the Angular code.

object maps a BaseEvent class name (string) to a real class. The public static *_EVENT names provide safer access to the NgInterop functions from the Angular code. There’s no way to do type or parameter checking on the native JavaScript side, but it is still good practice to strongly type the BaseEvent derived classes. This provides good documentation and catches problems early on the TypeScript side.

Here is the stripped down index.html that shows how the external JavaScript code interacts with NgInterop.

index.html (snippet) <script> function subscribeToEvents() { ... window.ngInterop.subscribeToClass('AngularEvent', function (event) { ... }); } ... function clickMe() { window.ngInterop.publishToClass('HtmlEvent', 'clickMe', getRandomString()); } ... window['NgInteropInitCallback'] = subscribeToEvents; </script> 1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script> function subscribeToEvents ( ) { . . . window . ngInterop . subscribeToClass ( 'AngularEvent' , function ( event ) { . . . } ) ; } . . . function clickMe ( ) { window . ngInterop . publishToClass ( 'HtmlEvent' , 'clickMe' , getRandomString ( ) ) ; } . . . window [ 'NgInteropInitCallback' ] = subscribeToEvents ; </script>

Highlights:

4 : After subscribeToEvents() is called by the NgInterop constructor, this function subscribes to AngularEvent messages. AngularEvent messages are published when the Angular ‘Toggle Remove Button’ is clicked in the AppComponent class.

: After is called by the NgInterop constructor, this function subscribes to AngularEvent messages. AngularEvent messages are published when the Angular ‘Toggle Remove Button’ is clicked in the class. 10 : On an HTML click event an HtmlEvent message is published. The subscriber to the HtmlEvent messages is also in the AppComponent class.

: On an HTML click event an HtmlEvent message is published. The subscriber to the HtmlEvent messages is also in the AppComponent class. 13 : The callback function is added to the window object. This is executed prior to Angular being started up.

: The callback function is added to the object. This is executed prior to Angular being started up. All logging is done by publishing LogEvent messages. These are displayed by the LogComponent class.

The example app has two Angular components that interact with the native JavaScript as well as with each other with NgInterop. The rest of the code should be self-explanatory.

Screenshot of the example app:



This project uses the following:

Angular CLI — Of course.

RxJS — Used by the MessagingService.

Bootstrap 4 — For the pretty buttons and “card” layout.

Moment.js — To more easily format the log timestamp.

Protractor — For running the Angular e2e tests.

Enjoy!

*There are probably similar integration approaches for React and Vue. I just don’t know what they are.

UPDATE (7/27/18):

Here’s a React approach: Creating & Managing components outside React.