The Build Series: E3 — Micro-Frontends are Awesome!
- Published on
What's a Micro-Frontend?
The micro-frontend concept is vaguely inspired by, and named after, micro-services. It is essentially to split frontend monoliths into smaller, simpler code bases that can be developed, tested and deployed independently, while still appearing to end users as a single application
Here's a simple illustration which explains this concept:
The main application Wrapper
(For lack of a better name 🤷🏽♂️), renders applications One
, Two
and Three
which are independent web applications. They could be hosted on the same domain where the Wrapper
is, or they could be hosted somewhere else. The key takeaway here is that, no matter where and how your application is hosted, or even what framework your application uses, we can render it in our Wrapper
.
Why to use Micro-Frontends?
The main benefits of splitting your code base into micro-frontends are
- Independent Updates: We can update and deploy
N
applications independent of other applications. - Simpler code base: Compared to a monolithic application, our *Micro-Frontend *code base will be much simpler and easier to maintain.
- Independent Teams: Team A working on Micro-Frontend A will have complete ownership over their code base, from inception to deployment. So now instead of having a "HTML" team and a "CSS style" team, we have independent teams working on applications
- Shared Component Library/Styles: All of the Micro-Frontends can use shared components, utility functions and base styles. One can build a CSS Framework, and use it between all the Micro-Frontends.
- Library/Framework Agnostic: The best part, one can technically have an Angular and a React app running in a plain JavaScript shell 🕺🏽
Now that we understand what a *Micro-frontend *is and how it benefits us, let's look at different design patterns for building one ourselves.
Exploring the Micro-Frontend Design Patterns
Note that the design patterns we will explore here is based on Client side rendering, i.e all the logic involved in rendering the applications are on the client. A wrapper application will be responsible for loading our individual *Micro-frontend. *The Wrapper application will handle user authentication, routing to respective *Micro-Frontend, *un-mounting the applications, etc.
- Micro-Frontends using an iFrame
- Micro-Frontends as dependencies
- Micro-Frontends exposing a render function
iFrame Wrapper
This is one of the easier approaches to build a Micro-Frontend app. I know, this doesn't seem "innovative". Developers generally tend to hate iFrames due to inconsistency with navigation history, scrolling issues, responsive styling, inconsistent screen reader behaviours etc. But most, if not all, of these caveats can be addressed if the implementation is well planned.
<html>
<head>
<title>Micro-Frontends are Awesome!</title>
</head>
<body>
<h3>Application One</h3>
<iframe id="mf-container-one" src="/application-one"></iframe>
<h3>Application One</h3>
<iframe id="mf-container-two" src="/application-two"></iframe>
<h3>Application One</h3>
<iframe id="mf-container-three" src="/application-three"></iframe>
</body>
</html>
Using this approach, we can get started with the Micro-Frontend pattern really quickly. None of the Teams have to make any changes to their codebase. Styles are isolated and this pattern works well when we have multiple applications on the same page like widgets.
The drawbacks are mostly around the issues described above around iFrames. Deep-linking, routing and managing history requires extra engineering effort. Data and event sharing between applications becomes challenging, but it is do-able if the event/data sharing architecture is well planned. Another issue I've generally faced is that if the applications are hosted on different domains, then we need to enable the X-Frame-Options
header, which makes your application vulnerable.
Installing application as a dependency
In this pattern, each Micro-Frontend application is published as an independent package, and is installed by the wrapper as dependencies.
{
"name": "@micro-frontend/container",
"version": "1.0.0",
"description": "A Micro-Frontend Container",
"dependencies": {
"@micro-frontend/one": "1.0.0",
"@micro-frontend/two": "1.0.0",
"@micro-frontend/three": "1.0.0"
}
}
We can see some obvious advantages here. Each Micro-Frontend app can be still independently developed by different teams. Depending on how the Micro-Frontend application is structured, styling and component isolations also works well in this pattern.
However, one drawback is that, if any of the Micro-Frontend app is updated, the main container also needs to be built and deployed. And if one of the Micro-Frontend team messed up, then the rollback must be done for the container again. The container is tightly coupled to the Micro-Frontend apps, which we ideally do not want.
Exposing a Render Function
In this pattern, we expose a render method from each of the Micro-Frontends, and the Wrapper
loads these applications on-demand.
We keep a map of all the bootstrap methods exposed by individual applications. These apps don't render as soon as their main.js
script is downloaded, but rather wait till the render${app}
methods are called.
<html>
<head>
<title>MicroFrontends are awesome!</title>
</head>
<body>
<h1>Application {applicationName}</h1>
<div id="mf-container"></div>
</body>
<!-- These scripts don't render anything on load -->
<!-- Instead they attach entry-point functions to `window` object -->
<script src="https://one.micro.dev/main.js"></script>
<script src="https://two.micro.dev/main.js"></script>
<script src="https://three.micro.dev/main.js"></script>
<script type="text/javascript">
// These render functions are attached to window by the above scripts
const microFrontendsMap = {
'application-one': window.renderOne,
'application-two': window.renderTwo,
'application-three': window.renderThree,
}
const renderApplication = loadMF(microFrontendsMap)
// loadMF() decides which application needs to be rendered
// and we call render function attached to the window object
renderApplication('mf-container')
</script>
</html>
With this approach, we can work with deep-linking, managing history and routing much easier as compared to the iFrame approach. In cases where your *Micro-Frontend *is composed of a singular framework like React/Angular, we can have a shared vendor bundle for the Micro-Frontends, all applications use same version of React, so we bundle it and serve for all the applications. We are also able to make the application responsive, and work with screen readers.
One issue which one may encounter is, when using multiple versions of a framework on the same page. Say Application One
and Two
use different versions of React, when using hooks, one might see the infamous "hooks can only be called inside the body of a function component
" error. This can be mitigated by using a single vendor file for the framework. And as long as your team does not update the framework packages every alternate day, you should be good. Of course, this depends on how your team is structured and what versions of framework each team uses, whether or not you want the application to run in a stand alone mode and so on.
Show me the code!
This example is using React. I'll get around to building something similar using Angular sometime in the future.
First we start with the MicroFrontend
component. This component will be responsible for
- Loading the scripts required to run the independent applications,
- Loading the styles required by the independent applications,
- Mounting and Un-Mounting the applications based on application logic
The entire concept behind our MicroFrontend
component lies in the fact that the individual applications expose the render lifecycle method and the scripts in order of execution. In React, this is done by generating an asset-manifest.json
file during build.
A typical asset-manifest.json
will look something like this:
{
"files": {
"main.css": "/static/css/main.chunk.css",
"main.js": "/static/js/main.chunk.js",
"runtime-main.js": "/static/js/runtime-main.js"
},
"entrypoints": ["static/js/runtime-main.js", "static/css/main.css", "static/js/main.js"]
}
files
: are the files which are present in yourpublic
ordist
folder. (Depends on where your build files are)entrypoints
: this lists the order in which the files should be loaded. In the above example, we must load theruntime-main.js
before loading themain.js
as it contains React related stuff which is required by the application.
Assuming that each Micro-Frontend app is hosted somewhere, MicroFrontend
component should be able to get the asset-manifest.json
for each of the applications, and download the required scripts and styles.
// Fetches the asset-manifest.json for the given `host` and
// injects the scripts and styles to the wrapper
function loadScripts() {
const { match, host, containerId } = this.props
this.abortController = new AbortController()
fetch(`${host}/asset-manifest.json`, {
signal: this.abortController.signal,
})
.then((res) => res.json())
.then((manifest) => {
const entryPoints = manifest['entrypoints'] || []
// Identify and split JS and CSS files
const { styles, scripts } = this.identifyFiles(entryPoints)
// Inject the styles to the document.
styles.forEach((style) => {
this.loadStyles(style)
})
// Load the scripts asynchronously, and call the renderMethod
this.scriptCache.load(scripts).then((event) => {
if (this.props.host === host) this.renderMicroFrontend()
})
})
}
In the loadScripts()
method we fetch the asset-manifest.json
for a specified host
, iterate through the entrypoints
and identify *.js
and *.css
files. Then we inject these files to our document.
const renderMicroFrontend = () => {
const { name, window, containerId } = this.props
if (window[`render${name}`]) {
window[`render${name}`](containerId)
}
}
function render() {
const { containerId } = this.props
return <main id={containerId} />
}
The render method is fairly simple. We have a <main>
element with an id, which is sent as props when the component is called. This is the node where we want to render our application.
When the containerId
is updated, the previous application gets unmounted as the node gets removed from the DOM. This way we are able to control mounting and un-mounting applications from the wrapper.
The renderMicroFrontend
method calls the render
method of the application which needs to be displayed.
import React from 'react'
import ReactDOM from 'react-dom'
import * as serviceWorker from './serviceWorker'
import App from './App'
import './index.scss'
window.renderOne = (containerId) => {
ReactDOM.render(<App />, document.getElementById(containerId))
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()
}
window.unmountOneUser = (containerId) => {
ReactDOM.unmountComponentAtNode(document.getElementById(containerId))
}
As shown above, we expose a render method which the wrapper application hosting this Micro-Frontend will call. This means that the wrapper application would need to know the name of the render method for each of the Micro-Frontends ,either at build time, or via some API calls. This is upto you on how you'd want to expose and call the render methods.
Putting everything together, this is the MicroFrontend
component.
class MicroFrontend extends React.Component {
scriptCache
abortController
constructor(props) {
super(props)
this.state = {
containerId: '',
}
this.scriptCache = new ScriptCache()
}
componentDidMount() {
this.loadScripts() // Load Scripts on Mount
}
/**
* Fetches the asset-manifest.json for the given `host` and
* injects the scripts and styles to the wrapper
*/
loadScripts() {
const { match, host, containerId } = this.props
this.abortController = new AbortController()
fetch(`${host}/asset-manifest.json`, {
signal: this.abortController.signal,
})
.then((res) => res.json())
.then((manifest) => {
const entryPoints = manifest['entrypoints'] || []
// Identify and split JS and CSS files
const { styles, scripts } = this.identifyFiles(entryPoints)
// Inject the styles to the document.
styles.forEach((style) => {
this.loadStyles(style)
})
// Load the scripts asynchronously, and call the renderMethod
this.scriptCache.load(scripts).then((event) => {
if (this.props.host === host) this.renderMicroFrontend()
})
})
}
/**
* Identify and split JS and CSS files.
*/
identifyFiles() {
let styles = []
let scripts = []
entryPoints.forEach((path) => {
path.includes('.css') ? styles.push(`${host}/${path}`) : scripts.push(`${host}/${path}`)
})
return { scripts, styles }
}
loadStyles = (style, containerId, index) => {
const styleId = `style-${containerId}${index}`
if (!document.getElementById(styleId)) {
const styleRef = document.createElement('link')
styleRef.setAttribute('rel', 'stylesheet')
styleRef.setAttribute('type', 'text/css')
styleRef.setAttribute('href', style)
document.head.appendChild(styleRef)
}
}
/**
* If an update happens, and the containerId is different,
* we unmount the previous app before mounting the new app
*/
componentDidUpdate(prevProps, prevState, snapshot) {
if (prevProps.containerId !== this.props.containerId) {
// we are trying to unmount previous app
const { name } = prevProps
const { window, containerId } = this.props
this.unmountMicroFrontend(name, window, containerId)
this.loadScripts()
}
}
componentWillUnmount() {
const { containerId, window, name } = this.props
this.unmountMicroFrontend(name, window, containerId)
}
unmountMicroFrontend = (name, window, containerId) => {
if (this.abortController) {
this.abortController.abort()
}
if (window[`unmount${name}`]) {
window[`unmount${name}`](containerId)
}
}
renderMicroFrontend = () => {
const { name, window, containerId } = this.props
if (window[`render${name}`]) {
window[`render${name}`](containerId)
}
}
render() {
const { containerId } = this.props
return <main id={containerId} />
}
}
MicroFrontend.propTypes = {
host: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
containerId: PropTypes.string.isRequired,
history: PropTypes.object,
match: PropTypes.object,
}
export default MicroFrontend
MicroFrontend
components takes a couple of props,
const Wrapper = () => {
return (
<MicroFrontend
host={url}
name={spaName}
containerId={applicationId}
history={history}
match={match}
/>
)
}
host
: The hostname where the Micro-Frontend app is hosted.name
: Name of the Micro-Frontend app which also doubles as the name of the render method exposed by the Micro-Frontend. The convention here isrender${name}
containerId
: ID of the<main>
element where we want to inject our applicationmatch
,history
: Optional props fromreact-router
to manage deep-linking and stuff
As for the individual Micro-Frontends, instead of React rendering the applications automatically, we wrap it in a method and expose it via the window
object. This way, we have the render function available on the window
object for the main wrapper
to call.
import React from 'react'
import ReactDOM from 'react-dom'
import * as serviceWorker from './serviceWorker'
import App from './App'
import './index.scss'
window.renderOne = (containerId) => {
ReactDOM.render(<App />, document.getElementById(containerId))
}
window.unmountOne = (containerId) => {
ReactDOM.unmountComponentAtNode(document.getElementById(containerId))
}
When the main.js
script gets loaded, it attaches the render
method to the window
object which can then be used for mounting and un-mounting the application dynamically.
Of course, this is just one way to do it. Another way is to broadcast an event from the Micro-Frotend application, which lets the wrapper know that the Micro-Frontend app is has loaded all the scripts and can be rendered
Closing notes
We built a Micro-Frontend application by using a runtime render function and also satisfying the requirements for a Micro-Frontend application.
This article turned out to be much longer than I anticipated. I'll hopefully be able to provide examples for different frameworks in a future post.
That's it for this episode. Feel free to reach out in case of any issues/help.
Until next time! ✌🏽
On this page