Revolutionizing Micro Frontends with Webpack 5, Module Federation and Bit

Nathan Sebhastian

The upcoming Webpack 5 will bring lots of goodies to improve both developer experience and build time, but none of them is as ground-breaking as the new Module Federation.

Up until today, the implementation of micro front-end strategy seems to only bring increased complexity and inconsistent performance where the bad outweighs the good. The Module Federation is here to try and change that.

Note: This article assumes you have an understanding of what a micro front-end is and how Webpack and React works.

I will also not get into the details about publishing and managing components on Bit (Github). You can to read more about it here:

Module Federation is a JavaScript architecture invented by Zack Jackson, who then proposes to create a Webpack plugin for it. The Webpack team agrees, and they collaborated to bring the plugin into Webpack 5, which is currently in beta.

In short, Module Federation allows JavaScript application to dynamically import code from another application at runtime. The module will build a unique JavaScript entry file which can be downloaded by other applications by setting up the Webpack configuration to do so.

It also tackles the problem of code dependency and increased bundle size by enabling dependency sharing. For example, if you’re downloading a React component, your application won’t import React code twice. The module will smartly use the React source you already have and only import the component code.

Finally, we can use React.lazy and React.suspense to provide a fallback should the imported code fail for some reason, making sure the user experience won’t be disrupted because of the failing build.

To see how module federation actually works, you will need to download the Webpack 5, which is still in beta. You can clone this sample repo that I have created for this article. It includes the following package.json file inside app1 directory:

{
"name": "@bit-module-federation/app1",
"version": "0.0.0",
"private": true,
"devDependencies": {
"@babel/core": "7.10.3",
"@babel/preset-react": "7.10.1",
"babel-loader": "8.1.0",
"bundle-loader": "0.5.6",
"html-webpack-plugin": "git://github.com/ScriptedAlchemy/html-webpack-plugin#master",
"serve": "11.3.2",
"webpack": "5.0.0-beta.18",
"webpack-cli": "3.3.11",
"webpack-dev-server": "3.11.0"
},
"scripts": {
"start": "webpack-dev-server",
"build": "webpack --mode production",
"serve": "serve dist -p 3001",
"clean": "rm -rf dist"
},
"dependencies": {
"react": "^16.13.0",
"react-dom": "^16.13.0"
}
}

In this project, we’re using Webpack 5 beta 18 as our bundler. Next, here is the webpack.config.js file:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = {
entry: "./src/index",
mode: "development",
devServer: {
contentBase: path.join(__dirname, "dist"),
port: 3001,
},
output: {
publicPath: "http://localhost:3001/",
},
module: {
rules: [
{
test: /.jsx?$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: ["@babel/preset-react"]
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
],
};

Nothing new yet, we only use the standard webpack configuration to set the necessary settings like entry point, output and module rules.

This application will also include a single component inside ./src/components directory called Header, which will be rendered by the app:

import React from "react";const Header = ({children}) => <h1 style={{color:'#0384E2'}}>{children}</h1>;export default Header;

If you run npm install and then npm start, you’ll see the component gets rendered in localhost:3001:

Nothing fancy yet

It’s time to test Module Federation by exposing Header component. You need to import the ModuleFederationPlugin and add it into your webpack config file:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");
module.exports = {
entry: "./src/index",
mode: "development",
devServer: {
contentBase: path.join(__dirname, "dist"),
port: 3001,
},
output: {
publicPath: "http://localhost:3001/",
},
module: {
rules: [
{
test: /.jsx?$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: ["@babel/preset-react"]
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: "app1",
library: { type: "var", name: "app1" },
filename: "remoteEntry.js",
exposes: {
// expose each component
"./Header": "./src/components/Header",
},
shared: ["react", "react-dom"],
}),

new HtmlWebpackPlugin({
template: "./public/index.html",
}),
],
};

The Module Federation includes the following options:

  • name: Will be used as the entry file name if filename is not set.
  • library: Will assign the output of the build into the variable app1 .
  • filename: The name of the specialized entry file.
  • exposes: Expose the component for consumption by other apps.
  • shared: This library will be imported if the consumer app doesn’t have it.

The plugin will generate an entry file which allows consuming application to build the exposed components. In this example, we’re naming it remoteEntry.js . Unlike the main Webpack entry file, this specialized remote entry only includes the code needed by a remote app to import the exposed components.

Now you’re set to expose Header component for reuse inside other apps. But before that, let’s run npm start once again. You’ll notice an error message on the browser console:

Uncaught Error: Shared module is not available for eager consumption

This is because Header component is now a shared module, which is loaded asynchronously and not ready yet on initial render. To fix this issue, you need to move the content of index.js into a new file named boostrap.js:

// bootstrap.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

Then import it back inside index.js :

import("./bootstrap");

This way, the initial application chunk from Webpack will load bootstrap.js file asynchronously, which in turn will wait for App component to be ready for render. Here is the repo branch for the code up to this point.

Let’s import the Header component into another application. This repo branch will include a new React application named app2 . This application has the same package.json file as app1, but with a slightly modified webpack config file:

// webpack config file of app2
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const path = require("path");
module.exports = {
entry: "./src/index",
mode: "development",
devServer: {
contentBase: path.join(__dirname, "dist"),
port: 3002,
},
output: {
publicPath: "
http://localhost:3002/",
},

module: {
rules: [
{
test: /.jsx?$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: ["@babel/preset-react"],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: "app2",
library: { type: "var", name: "app2" },
remotes: {
app1: "app1",
},

shared: ["react", "react-dom"],
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
],
};

We’re changing the output host to localhost:3002 and instead of setting exposes configuration, we’re setting the remotes configuration. This way, app2 will have access to app1’s exposed components.

We also modify the HTML to include the remoteEntry.js file from app1:

// app2’s index.html
<html>
<head>
<script src="http://localhost:3001/remoteEntry.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

With that change, we’re ready to import the exposed component into ./app2/src/App.js file:

import React from 'react';const Header = React.lazy(() => import('app1/Header'));export default () => (
<div style={{margin: '20px'}}>
<React.Suspense fallback='Loading header'>
<Header>Hello this is App 2</Header>
</React.Suspense>

</div>
);

You need to use React.lazy because importing exposed component returns a promise, while React.Suspense will provide a fallback should the component fail to render for some reason.

Open localhost:3002 and you will see the Header component being rendered:

app2 rendering Header from app1

We usually share reusable components between micro frontends by publishing them as npm packages. This is done both to keep a consistent UI between MFs and to make our app easily maintainable.

Here, we’ll be using Bit to publish and manage our components. We’ll create a repo for our design system and publish its components as independent pieces (not as a library). This will make sure each micro frontend is dependent only on the components it actually needs (so, it never receive meaningless updates).

It will also make it easy for autonomous teams that are building independent MFs, to collaborate on shared components (Bit supports ‘bit import’ — a sort-of ‘cloning’ of a component into a repo to modify and update it).

Example: components published on Bit

This combination of consuming MFs at runtime and more elementary components at build-time, makes for a great design.

Having said that, Bit is often used not only for design system but also as a way to implement micro frontends — only at build-time (micro frontends are published to Bit from independent repos, and consumed by the “container app” or “master app”)

Here is a diagram explaining our use case for module federation:

A module federation use case

I’ve created a repo where you can download the code and see the implementation of the diagram. Inside this repo, we’re using a sample design system hosted on Bit. As mentioned earlier, I’ve used Bit to keep a consistent UI between MFs, and make sure my project is easily maintainable.

A design system published on Bit

You can also view the components put together in this Bit collection (these are, in fact, the MFs).

Apps render example on Bit

As illustrated in the diagram, app1 will render a card of hotels that users can book:

import React from 'react';
import Card from '@bit/nsebhastian.design-system.card';
const App = props => {
const buttonClick = () => {
const onClick = props.onClick;
if (onClick) {
onClick();
} else {
console.log('button is clicked');
}
};
return (
<div style={{padding: '50px 12px', display: 'flex'}}>
<Card
image='https://firebasestorage.googleapis.com/v0/b/react-firebase-basic.appspot.com/o/hotel1.jpg?alt=media&token=e1ffa47a-268a-42a1-8da4-8b954b9ffaa9'
title='Hotel 1'
buttonTitle='Book'
buttonClick={() => buttonClick()}
/>
<Card
image='https://firebasestorage.googleapis.com/v0/b/react-firebase-basic.appspot.com/o/hotel2.jpg?alt=media&token=45ec4e54-d2fe-442f-8b02-a841200c7f54'
title='Hotel 2'
buttonTitle='Book'
buttonClick={() => buttonClick()}
/>
<Card
image='https://firebasestorage.googleapis.com/v0/b/react-firebase-basic.appspot.com/o/hotel3.jpg?alt=media&token=6d10b7d1-e2b8-4c0a-bd7b-c4212530bc43'
title='Hotel 3'
buttonTitle='Book'
buttonClick={() => buttonClick()}
/>
</div>
);
};
export default App;

Using the same Module Federation plugin, app1 exposes its App component:

// app1’s module federation configsnew ModuleFederationPlugin({
name: "app1",
library: { type: "var", name: "app1" },
filename: "remoteEntry.js",
exposes: {
"./App": "./src/App",
},

shared: ["react", "react-dom"],
}),

app2 will render a simple form where users can book the hotel room:

import React from 'react';
import DatePicker from 'react-datepicker';
import Button from '@bit/nsebhastian.design-system.button';
import 'react-datepicker/dist/react-datepicker.css';
import './App.css';
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = {startDate: '', endDate: ''};
}
render() {
const {startDate, endDate} = this.state;
return (
<div className='container'>
<div className='column'>
<div className='column-header'>
<h2>Book the room</h2>
</div>
<div className='column-content'>
<form>
<div className='form-group'>
<label className='form-label'>Check-in date: </label>
<DatePicker
selected={startDate}
onChange={date => this.setState({startDate: date})}
/>
</div>
<div className='form-group'>
<label className='form-label'>Check-out date: </label>
<DatePicker
selected={endDate}
onChange={date => this.setState({endDate: date})}
/>
</div>
</form>
</div>
<Button
title='Book now'
onClick={() => alert('Book request received. Thank you!')}
/>
</div>
</div>
);
}
}

Then app3 will import components from app1 and app2 to build the master application:

import React from 'react';
import Navbar from '@bit/nsebhastian.design-system.navbar'
const ExploreHotel = React.lazy(() => import('app1/App'));
const BookRoom = React.lazy(() => import('app2/App'));
export default class App extends React.Component {
constructor(props){
super(props);
this.state = { view:1 }
this.bookTheRoom = this.bookTheRoom.bind(this);
}
bookTheRoom(){
this.setState({view: 2})
}

render(){
const {view} = this.state
let component = (
<React.Suspense fallback='Loading app1'>
<ExploreHotel onClick={this.bookTheRoom}/>
</React.Suspense>

)
if(view === 2){
component = (
<React.Suspense fallback='Loading app2'>
<BookRoom />
</React.Suspense>

)
}
return(
<>
<Navbar links={['home', 'about']} />
{component}
</>
)
}
}

Just like the previous example, we simply wrap the remote import with React.lazy and render it with fallback using React.Suspense. There is nothing new with the code except we’re importing components from two applications. You can view the apps demo on the following links:

The Webpack Module Federation plugin is still in beta, so there might be more changes in how the configuration works. Still, this plugin is set to bring a scalable solution to sharing code between independent applications that’s very convenient for developers without sacrificing the bundle size.

When Webpack 5 is official, we can expect to see more use cases for Module Federation that’s going to help make micro front-end strategy affordable to more and more developers.

Author: admin

Leave a Reply

Your email address will not be published.