Guide For Micro-Frontends
Module Federation
Shared
The shared
configuration is used to share common dependencies between consumers and producers, reducing the runtime download volume and thus improving performance. shared
allows you to configure rules for reusing dependency versions.
- Type:
PluginSharedOptions
- Required: No
- Default:
undefined
The PluginSharedOptions
type is as follows:
type PluginSharedOptions = string[] | SharedObject;
interface SharedObject {
[sharedName: string]: SharedConfig;
}
interface SharedConfig {
singleton?: boolean;
requiredVersion?: string;
eager?: boolean;
shareScope?: string;
}
- Example
new ModuleFederationPlugin({
name: '@demo/host',
shared: {
react: {
singleton: true,
},
'react-dom': {
singleton: true,
},
},
//...
});
Singleton
- Type:
boolean
- Required: No
- Default:
false
Whether to allow only one version of the shared module within the shared scope (singleton mode).
- If singleton mode is enabled, the shared dependencies between the remote application components and the host application will only be loaded once, and a higher version will be loaded if the versions are inconsistent. A warning will be given for the party with the lower version.
- If singleton mode is not enabled, and the shared dependencies between the remote application and the host application have different versions, each will load their own dependencies.
RequiredVersion
- Type:
string
- Required: No
- Default:
require('project/package.json')[devDeps | dep]['depName']
The required version, which can be a version range. The default value is the current application's dependency version.
- When using shared dependencies, it will check whether the dependency version listed in the
package.json
is greater than or equal torequiredVersion
. - If it is, it will be used normally. If it is less than
requiredVersion
, a warning will be given in the console, and the smallest version available in the shared dependencies will be used. - When one party sets
requiredVersion
and the other sets singleton, the dependency withrequiredVersion
will be loaded, and the singleton party will directly use the dependency withrequiredVersion
, regardless of the version.
Implementation with Vite
Vite Module Federation using @module-federation/vite (modern way)
Install @module-federation/vite
:
pnpm add @module-federation/vite
In your host, import it and use like so:
import { defineConfig } from 'vite';
import { federation } from '@module-federation/vite';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [
react(),
federation({
name: 'host',
filename: 'remoteEntry.js',
remotes: {
// Note about the key for the object (i.e. '@mf-books'), it can be whatever you want. with this you'll do the import. i.e. '@mf-books/SomeComponent'
'@mf-books': {
name: '@mf-books', // <--- this needs to match the EXACT name of the remote MF.
type: 'module', // <--- IMPORTANT!!! without this you'll get an error. Your remote vite apps are bundled as esm.
entry: 'http://localhost:3001/remoteEntry.js',
},
},
shared: ['react', 'react-dom'],
}),
],
build: {
modulePreload: false,
target: 'esnext', // <--- or 'chrome89' , just as long as you have top-level-await in the runtime environment it's fine.
minify: false,
cssCodeSplit: false,
sourcemap: true,
},
});
In your remote, import it and use it like so:
import { defineConfig } from 'vite';
import { federation } from '@module-federation/vite';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [
react(),
federation({
name: '@mf-books',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/exposes/ExposedBooksMF',
},
shared: ['react', 'react-dom'],
}),
],
server: {
port: 3001,
},
build: {
modulePreload: false,
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
});
Now you can import the component like so:
import { Suspense, lazy } from 'react';
import MicroFrontendErrorBoundary from '@src/components/ErrorBoundaries/MicroFrontendErrorBoundary';
const RemoteApp = lazy(() => import('@mf-books/A1pp'));
export default function BooksMF() {
return (
<MicroFrontendErrorBoundary>
<Suspense fallback={<div>loading...</div>}>
<RemoteApp />
</Suspense>
</MicroFrontendErrorBoundary>
);
}
Dynamic Import of Remote Modules
For that you'll need to install:
...continue this some day...
B. Vite Module Federation using @originjs/vite-plugin-federation
- 1. Vite Dev mode
As Vite is built on esbuild in dev development mode, we provide separate support for dev mode to take advantage of Vite's high performance development server in the case of remote module deployment.
Only the Host side supports dev mode, the Remote side requires the RemoteEntry.js package to be generated using vite build
. This is because Vite Dev mode is Bundleless and you can use vite build --watch
to achieve a hot update effect.
- 2. Static import
Static import and dynamic import of components are supported, the following shows the difference between the two methods, you can see examples of dynamic import and static import in the project in examples, here is a simple example.
- React
// static import
import myButton from 'remote/myButton';
// dynamic import
const myButton = React.lazy(() => import('remote/myButton'));
- Static imports may rely on the browser's
Top-level await
feature, so you will need to setbuild.target
in the configuration file to 'next' or use the pluginvite-plugin-top-level-await
. You can see the browser compatibility of top-level await here compatibility)
- 3. Configuration
Here is a list of props you can pass to the federation plugin, and their descriptions:
prop: name
Required as the module name of the remote module.
prop: filename
As the entry file of the remote module, not required, default is remoteEntry.js
.
prop: exposes
As the remote module, the list of components exposed to the public, required for the remote module.
exposes: {
// 'externally exposed component name': 'externally exposed component address'
'./remote-simple-button': './src/components/Button.vue',
'./remote-simple-section': './src/components/Section.vue'
},
- If you need a more complex configuration
exposes: {
'./remote-simple-button': {
import: './src/components/Button.vue',
name: 'customChunkName',
dontAppendStylesToHead: true
},
},
The import
property is the address of the module. If you need to specify a custom chunk name for the module use the name
property.
The dontAppendStylesToHead
property is used if you don't want the plugin to automatically append all styles of the exposed component to the <head>
element, which is the default behavior. It's useful if your component uses a ShadowDOM and the global styles wouldn't affect it anyway. The plugin will then expose the addresses of the CSS files in the global window object, so that your exposed component can append the styles inside the ShadowDOM itself. The key under the window object used for styles will be css__{name_of_the_app}__{key_of_the_exposed_component}
. In the above example it would be css__App__./remote-simple-button
, assuming that the global name
option (not the one under exposed component configuration) is App
. The value under this key is an array of strings, which contains the addresses of CSS files. In your exposed component you can iterate over this array and manually create <link>
elements with href
attribute set to the elements of the array like this:
const styleContainer = document.createElement('div');
const hrefs = window['css__App__./remote-simple-button'];
hrefs.forEach((href: string) => {
const link = document.createElement('link');
link.href = href;
link.rel = 'stylesheet';
styleContainer.appendChild(link);
});
prop: remotes
The remote module entry file referenced as a local module
- remote module address, e.g.
https://localhost:5011/remoteEntry.js
- You can simply configure it as follows
remotes: {
// 'remote module name': 'remote module entry file address'
'remote-simple': 'http://localhost:5011/remoteEntry.js',
} - Or do a slightly more complex configuration, if you need to use other fields
remotes: {
'remote-simple': {
external: 'http://localhost:5011/remoteEntry.js',
format: 'var',
}
}
format:'esm'|'systemjs'|'var'
default: 'esm'
Specify the format of the remote component, this is more effective when the host and the remote use different packaging formats, for example the host uses vite + esm and the remote uses webpack + var, in which case you need to specify type : 'var'
.
from : 'vite'|'webpack'
default: 'vite'
Specify the source of the remote component, from vite-plugin-federation
select vite
, from webpack
select webpack
.
prop: shared
Dependencies shared by local and remote modules. Local modules need to configure the dependencies of all used remote modules; remote modules need to configure the dependencies of externally provided components.
import: boolean
default: true
The import
property relates to the way shared modules are handled on the "remote" side in a module federation setup. Here's the full explanation:
When import
is true
, the remote
(the module being consumed) will attempt to fetch shared modules from the host
(the application that loads the remote). If a shared module isn't available on the host side, it will throw an error because the remote expects this dependency to be available and won't package it itself. It will report an error directly, because there is no fallback module available
If you set import
to false
, the remote
will stop relying on the host
for that shared module. Instead, it will package the module independently, leading to potentially larger bundles but reducing dependency on the host.
Why set import
to false then?
At runtime, host
& remote
can have different versions of package A. The remote's A serves as a backup. This means that that the host
's version wins, if it exists. BUT! There are 2 flags to help fine-tune the exact behavior in such case, and those flags are version
& requiredVersion
.
version: string
The version
prop is set on the host
side (Only works on the host
).
There's no real reason/use-case for you to use this property.
By default, the version is set as the version listed under the host
's package.json
.
You can provide a string value here if you need to configure it manually only if you can't get version
.
requiredVersion: string
The version
prop is set on the remote
side (Only works on the remote
).
The remote
can specify the required version it expects to find on the host
's shared
.
When the version of the host side does not meet the requiredVersion
requirement, it will use its own shared
module (provided that it is configured with import=true
, which is enabled by default).
generate: boolean
default: true
Should the remote generate a shared chunk file or not.
If you're sure that the host side always has a shared chunk that can be used, then you can set generate:false
to NOT generate a shared file on the remote side to reduce the size of the remote's chunk file, which is only effective on the remote side, the host side will generate a shared chunk no matter what.
1. Pros & Cons
• Monolith Pros
- Easier to develop
- Easier to deploy
- Easy to scale
• Monolith Cons
- Codebase size is huge
- Deployment of whole application for a small change
- Commitment to a single tech-stack
• Micro-Frontends Pros
- Incremental upgrades
- Simple decoupled codebase
- Independent deployment
- Autonomous teams
• Micro-Frontends Cons
- Payload size if the builds of the application are not handled properly, it can significantly increase the payload size
- Operational complexity
- Increased cost of multiple configurations (setups)
- Multiple servers
2. Micro-Frontends Concepts
communication
should be kept to a minimum, and if used, then only for simple stuff.
Shared
Common dependencies should be shared. Even if two teams are building separate applications, they might be using similar dependencies. Those same dependencies, should not be loaded to our browser twice.
Zero Coupling
Try to go for zero coupling among projects. Even if it might take a bit more effort, aim to achieve this.
State
You should never use any shared store (like redux) in any of your micro-frontend implementation.
Design
CSS from one application should not affect another application.
3. Micro-Frontends Challenges
The main challenges are:
- Communication between micro-frontends
- Sharing css & design issues
- Sharing dependencies
- 1. Communication between micro-frontends
Communication should happen via callbacks oe events. Like we stressed out earlier, avoid communication as much as you can. Make sure that you really need to communicate between them.
- 2. Sharing css & design issues
Use css-in-js library. Always try to manually namespace the css.
- 3. Sharing dependencies
Let's say all of your micro-frontend use react
, and even more so, use the same version of react. If each team used its own react, when this two applications will get loaded in the browser, the browser will be loading 2 copies of react, which would make the payload size pretty big.
4. Integration Approaches
The integration approaches are:
- Server-side template composition
- Build time integration
- Run time integration
- A. Server-side template composition
The first and very simple integration approach we know called server-side template composition. What happens in this one is that all the micro-frontends would be integrated on the server side, before it is ever presented to the client. This is not the solution we want, because it comes with a huge overhead on our application server.
- B. Build time integration
The next one is Build time integration. This is a very simple one. In any application you have built you've probably used build time integration, although you may not have realized it.
During build time, all separate micro-applications are downloaded and integrate into a container application during its build process, and it was then deployed as a whole to each and every environment.
Let's look at a timeline you should be familiar with:
- Development: Engineering team A develops a LIST library.
- Deployment: Team A deploys the package to npm.
- Publishing: Team A publishes the package to npm.
- Team B: Develops the container frontend consuming the package.
- Build: Build the app with LIST dependency.
- Release: Release the application bundle.
As you can understand from the step above, when team A makes a package of v1 team B needs to list it as its dependency, and import it in order to use it. But what if team A release a v2 of that package? In such a scenario, team B would have to list v2 now as its dependency, replacing v1, and so another build process is required, as well as a full CI/CD & deployment. This is where runtime build integration comes into the picture.
- C. Run time integration
The next one is Run time integration. This can take place:
- via IFrames
- via JavaScript
- via Web Components
Under optimal circumstances, and as for best practices sake, the runtime integration should always happen via JavaScript. The timeline of making runtime integration using javascript is as follows:
- Development of application A
- Building the application
- Deploy it onto a specific url (i.e. https://all-app.com/app-a.js)
- Navigate to Container app
- Fetch app-a.js and execute it in that container application
So how the runtime integration works here is anytime that application A has an update, it will be redeployed to that same url, that the container already has a reference to. So whenever the container gets loaded, it will be loaded with the updated app-a.js, and the container will always show the updated version of application A, and it would be highly decoupled, and that would be an advantage for both of the teams. So this was the timeline of runtime integration via JavaScript.