Master Micro Frontends with Lit-Element – Part 2

Micro Frontends Architecture

In the first part of this two part series, we saw a general overview of the architecture. In this part we will dive deeper into the code behind this Micro Frontend architecture.

For the sake of simplicity in the upcoming example, we will be working with two sample web components:

  • Address Manager
  • Payment Summary

Both of these web components will be implemented using lit-element, and their respective JavaScript bundles will be hosted on a CDN. Additionally, we will showcase the communication between these Micro Frontends (MFEs).

Developing your Lit-Element Web Components

The following code demonstrates the process of crafting a fundamental lit-element web component:

address-manager.ts
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('address-manager')
export class AddressManager extends LitElement {
    render() {
        return html`
          <h1>Address Manager</h1>        
     `;
    }  
}
payment-summary.ts
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('payment-summary')
export class PaymentSummary extends LitElement {
    render() {
        return html`        
        <h1>Payment Summary</h1>
     `;
    }
}

Building your Web components

Ensure that you import your web component in the following manner:

index.ts
import './address-manager';

The following Rollup configuration illustrates the process of generating two bundles. One is constructed using modern module JavaScript, while the other is crafted using ECMAScript 5 to produce a legacy bundle that can be interpreted by older web browsers.

Rollup.config.js
import resolve from '@rollup/plugin-node-resolve';    
import terser from '@rollup/plugin-terser';           
import typescript from 'rollup-plugin-typescript2';   
import commonjs from '@rollup/plugin-commonjs';       

export default [
{
    input: `./index.ts`,
    output: {
        file: `dist/address-manager.js`,
        format: 'esm',
    },
    plugins: [
        resolve(),
        typescript({ tsconfig: './tsconfig.json' }),
        terser({ 
            module: true, 
            warnings: true, 
            output: { comments: false } 
        }),            
    ],
},
{
    input: `./index.ts`,
    output: {
        file: `dist/address-manager.legacy.js`,
        format: 'iife',
    },
    plugins: [
        resolve(),
        commonjs({ include: 'node_modules/**' }),
        typescript({ tsconfig: './tsconfig.es5.json' }),
        terser({ 
            module: false, 
            warnings: true, 
            output: { comments: false } 
        }),          
    ],
}];

Notable differences in the above configuration include:

  • Changing the output.format from “esm” to “iife”
  • Modifying the output.file extension from “.js” to “.legacy.js”
  • Adjusting the “terser” plugin’s “module” property
  • Using the “commonjs” plugin exclusively in the legacy build

Please keep in mind that each web component should reside in its own repository and possess its own rollup.config.js. The provided example includes the configuration for “address-manager” for the sake of brevity.

Differential Loading

Differential loading involves the loading of distinct bundles tailored to specific web browsers. In the Rollup configuration mentioned earlier, we generated two bundles: one as a modern ESM module and the other as an IIFE, which is specifically optimized for ES5 JavaScript according to the settings in the tsconfig.json file. This approach allows you to segregate your code for older browsers like Internet Explorer 11 from your ESM module, resulting in the ability to deliver more compact and efficient JavaScript to modern browsers, which constitute the majority of your user base. Moreover, this separation simplifies the process of discontinuing support for Internet Explorer when the time is right.

In modern web browsers that support module scripts, the script element bearing the “nomodule” attribute will be disregarded, while the script element with a type of “module” will be requested and processed as a module script.

Conversely, older web browsers will ignore the script element with a type of “module” because it is an unfamiliar script type for them. However, they will successfully fetch and process the other script element as a traditional script since they do not implement the “nomodule” attribute.

In the following example, the first script will be loaded in Internet Explorer 11 and the second will be in modern browsers.

<script src='https://my-cdn/v1.0.0/address-manager.legacy.js' nomodule></script>
<script src='https://my-cdn/v1.0.0/address-manager.js' type='module'></script>        

Shell Application

Now that we have developed and built our lit-element web components, we can integrate them into our shell or container application.

Index.html
<!DOCTYPE html>
<html>
<body>
    <script src="www.myCDN.com/v1.0.0/app_bundle.js"></script>
    <script src="www.myCDN.com/v1.0.0/address-manager.js"></script>
    <script src="www.myCDN.com/v1.0.0/payment-summary.js"></script>
    
    <address-manager></address-manager>
    <payment-summary></payment-summary>
</body>
</html

To create the app_bundle.js file mentioned above, we configure our shell application’s Rollup as follows: set the input property to “app.js” and set the output.file property to “app_bundle.js,” much like the way we configured it for bundling the web components earlier.

Setting Service

Ideally, a settings service should be established to fetch the versions of each Micro Frontend. Subsequently, the statically hardcoded version in the HTML code mentioned above could be replaced with the dynamically obtained version. This approach would eliminate the need for the Shell application to be updated every time a Micro Frontend undergoes a change.

Communication

Communication within the shell application should be accomplished using native events. Here’s an example of an EventBus that leverages native events:

eventbus.js
export class EventBus {
    _bus: EventTarget;

    constructor(description = '') {
        this._bus = document.appendChild(document.createComment(description));
    }

    subscribe(eventName: string, callback: EventListener): void {
        this._bus.addEventListener(eventName, callback);
    }

    unsubscribe(eventName: string, callback: EventListener): void {
        this._bus.removeEventListener(eventName, callback);
    }

    publish(eventName: string, detail: CustomEventInit = {}): void {
        this._bus.dispatchEvent(new CustomEvent(eventName, detail));
    }
}

This EventBus allows the shell application to subscribe to and publish native events, making it possible to communicate between components using standard event mechanisms.

Using the EventBus:

First we need to initialize the EventBus so our components can use it.

app.ts
import { EventBus } from './eventbus';
window.EventBus = new EventBus("event bus");

The following code demonstrates the usage of the EventBus for intercommunication between our pair of web components. The address manager component features a dropdown select control for selecting countries (line 18), and when a change occurs in this control, it “publishes” a “country-changed” event (line 26).

address-manager.js
import { html, LitElement } from 'lit';
import { customElement, query } from 'lit/decorators.js';

@customElement('address-manager')
export class AddressManager extends LitElement {

    @query('select')
    select: HTMLSelectElement;

    constructor() {
        super();
    }

    render() {
        return html`
          <h1>Address Manager</h1>

          <select @change="${this._onChange}">
            <option>USA</option>
            <option>EUR</option>
          </select>
     `;
    }

    private _onChange() {
        window.EventBus.publish('country-changed', { 
            detail: { 
                country: this.select.options[this.select.selectedIndex].text 
            } 
        });
    }
}

Subsequently, the payment-summary component is configured to subscribe (listen) to the “country-changed” event (line 18), and it accordingly updates the payment amount whenever a change in the selected country is detected.

payment-summary.js
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('payment-summary')
export class PaymentSummary extends LitElement {
    @property()
    paymentAmount = '100.00';

    render() {
        return html`        
        <h1>Payment Summary</h1>
        <div>Price: ${this.paymentAmount}</div>
     `;
    }

    connectedCallback(): void {
        super.connectedCallback();
        window.EventBus.subscribe('country-changed', this._onCountryChanged);
    }

    private _onCountryChanged: EventListener = (e: Event) => {
        const customEvent = e as CustomEvent;
        
        //Setting this.paymentAmount will automatically call the render method with new value
        this.paymentAmount = customEvent.detail.country === 'USA' ? '$100.00' : '€200.00';
    }
}

Conclusion

This series of articles has demonstrated the major concepts required to build a lightweight Micro Frontend application. To download all the source code click here.