Angular2 : Isolating app

Hey everyone, here is my research about encapsulating Angular2 for many purposes :

Let’s do it !

Multiple selector in the same page

So, first of all, we must ensure that Angular2 can load over two different components, with different selector names, and one files bundle each.
So we will have :
– selector 1

<my-app-1></my-app-1>

– bundle 1

<script src="bundle1.min.js"></script>

– selector 2

<my-app-2></my-app-2>

– bundle 2

<script src="bundle2.min.js"></script>

If we do this, we ran into error about Zone.js (yeah, Angular2 comes with Zone.js). The plugin is not wrapped into a namespace, we need to be sure that we don’t run it multiple times otherwise we will get this error :

Uncaught Error: Zone already loaded

To avoid this we need to load Zone.js in the main.ts file, and ensure that the plugin is not already loaded (but we need also to be sure Zone.js is not currently loading) :

var loadInterval = function () {
    var tmp = setInterval(function () {
        if (window['Zone'] !== undefined) {
            load();
            clearInterval(tmp);
        }
    }, 200);
};
if (window['ZoneLoading']) {
    loadInterval();
} else {
    if (window['Zone'] === undefined) {
        window['ZoneLoading'] = true;
        var zone_js_script = document.createElement('script');
        zone_js_script.setAttribute('src', 'https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.7.4/zone.min.js');
        document.head.appendChild(zone_js_script);
        loadInterval();
    }
}

main.ts file

load()

is the remainder of the angular2 initialization.

Okay, but why if we want the same component instead of two different components ?

Multiple selector of the same app on the same page

The most complicated thing of Angular2 is to have the same app multiple times on the page. In Angular.JS we also had this problem : two ng-app were not allowed on the same page (or two angular.boostrap())

Let’s suppose you have this in your .html file :

<my-app-1></my-app-1>
<my-app-1></my-app-1>
<script src="bundle.min.js"></script>

To make it work we need to tweak a little bit the main.ts file :

let components: Object[] = [];
let foundedComponents = document.querySelectorAll('my-app-1');
for (let i = 0; i < foundedComponents.length; ++i) {
    if (foundedComponents[i].id === '') {
        foundedComponents[i].id = "my-app-app-" + i;
        components.push(
        { type: AppComponent, selector: "my-app-1#my-app-1-app-" + i }
        )
    }
}
let platform = platformBrowserDynamic([
    { provide: BOOTSTRAP_COMPONENTS_TOKEN, useValue: components }
]);
platform.bootstrapModule(AppModule);

We ensure Angular2 has now two components to bootstrap (with the same @Component class)

Multiple JS files bundles for the multiple apps with the same selector on the same page

But what if we consider that our Angular2 app is a widget ? Maybe our client will put it at two places in the website as a snippet so now we can have :

<my-app-1></my-app-1>
<script src="bundle.min.js"></script>
<my-app-1></my-app-1>
<script src="bundle.min.js"></script>

Simply, it must be clear in your head that the second bundle can’t be loaded at all. We’ll make the first one to take care of both apps in the page.

To achieve this, I just put in the very first beginning of the files bundle this :

if(window['sdk_my_app']){throw 'My App SDK is already loaded.';}window['sdk_my_app'] = !0;

I personally use browserify for my file bundle so the command is now :

browserify -s main tools/isolation.js dist/app/main.js > dist/bundle.js

Then when the second file will load, there will be an error

Uncaught My App SDK is already loaded.

It will prevent the second file to load and erase the first one, which would end in conflict.

As we see in the second part, the first file take care about every selector found in the page, so it will work =)

Asynchronous ?

In this part, let’s assume that someone else is trying to add the selector after that the files bundle is initialized.
No, Angular2 Quickstart doesn’t support that : the selector must be here before the script is fully loaded.

So to make it work, we need to tweak again the second part code :

We had :

let components: Object[] = [];
let foundedComponents = document.querySelectorAll('my-app-1');
for (let i = 0; i < foundedComponents.length; ++i) {
    if (foundedComponents[i].id === '') {
        foundedComponents[i].id = "my-app-app-" + i;
        components.push(
        { type: AppComponent, selector: "my-app-1#my-app-1-app-" + i }
        )
    }
}
let platform = platformBrowserDynamic([
    { provide: BOOTSTRAP_COMPONENTS_TOKEN, useValue: components }
]);
platform.bootstrapModule(AppModule);

Let’s wrap it in a function :

var temp = function () {
    let components: Object[] = [];
    let foundedComponents = document.querySelectorAll('my-app-1');
    for (let i = 0; i < foundedComponents.length; ++i) {
        if (foundedComponents[i].id === '') {
            foundedComponents[i].id = "my-app-app-" + i;
            components.push(
            { type: AppComponent, selector: "my-app-1#my-app-1-app-" + i }
            )
        }
    }
    let platform = platformBrowserDynamic([
        { provide: BOOTSTRAP_COMPONENTS_TOKEN, useValue: components }
    ]);
    platform.bootstrapModule(AppModule);
}
temp();

Using the new MutationObserver we could re-init all this like that :

        var target = document.querySelector('body');
        var observer = new MutationObserver(function (mutations) {
            mutations.forEach(function (mutation) {
                if(mutation.addedNodes[0] && mutation.addedNodes[0]['tagName'] === 'MY-APP-1'){
                    temp();
                }
            });
        });
        var config = { attributes: true, childList: true, characterData: true , subtree: true};
        observer.observe(target, config);

You’ll see a quick graphics glitch as each widget is fully reloaded, not only the new one, but if someone remove asynchronouly the app component selector, it will find the new one.

Conclusion

With a little bit of tweaks, we can now answer to all questions mentionned in the introduction 😉

Here is the final main.ts file :

import 'reflect-metadata';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { BOOTSTRAP_COMPONENTS_TOKEN } from './app.module';
import { enableProdMode } from "@angular/core";

var loadInterval = function () {
    var tmp = setInterval(function () {
        if (window['Zone'] !== undefined) {
            load();
            clearInterval(tmp);
        }
    }, 200);
};

enableProdMode();

var load = function () {
        var temp = function () {
            let components: Object[] = [];
            var foundedComponents = document.querySelectorAll('my-app-1');
            for (let i = 0; i < foundedComponents.length; ++i) {
                if (foundedComponents[i].id === '') {
                    foundedComponents[i].id = "my-app-1-app-" + i;
                    components.push(
                        { type: AppComponent, selector: "my-app-1#my-app-1-app-" + i }
                    )
                }
            }
            let platform = platformBrowserDynamic([
                { provide: BOOTSTRAP_COMPONENTS_TOKEN, useValue: components }
            ]);
            platform.bootstrapModule(AppModule);
        };
        temp();
        var target = document.querySelector('body');
        var observer = new MutationObserver(function (mutations) {
            mutations.forEach(function (mutation) {
                if(mutation.addedNodes[0] && mutation.addedNodes[0]['tagName'] === 'MY-APP-1'){
                    temp();
                }
            });
        });
        var config = { attributes: true, childList: true, characterData: true , subtree: true};
        observer.observe(target, config);
}
if (window['ZoneLoading']) {
    loadInterval();
} else {
    if (window['Zone'] === undefined) {
        window['ZoneLoading'] = true;
        var my_awesome_script = document.createElement('script');
        my_awesome_script.setAttribute('src', 'https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.7.4/zone.min.js');
        document.head.appendChild(my_awesome_script);
        loadInterval();
    }
}

And the isolation.js file we need to avoid multiple SDK :

if(window['sdk_my_app']){throw 'My App SDK is already loaded.';}window['sdk_my_app'] = true;

As an usage example : I use it in a widget builder, with different params like this :

<my-app-1 settings="{type: 'small'}"></my-app-1>
<script src="bundle.min.js"></script>
<my-app-1 settings="{type: 'big'}"></my-app-1>
<script src="bundle.min.js"></script>

Please feel free to get me in touch via the comments if you have some questions 🙂

Contact me !

Don't hesitate to contact me about a new exciting project or a job proposition !

.