Webpack 5 introduced Module Federation that allows importing remote modules to your application. Practical use case for this is to have multiple separate Micro Frontends (buzzword alert) that are loaded from one main application.
This repository consists of three (3) applications; one main application (main
) and two microfrontends (app1
and app2
) which are stored in their respective directories.
To run the demo, first install dependencies by running npm install
in each of the application's directory.
Then to run the applications run npm start
again in each of the application's directory.
Applications are then accesible from following URLs:
Application | URL |
---|---|
main | http://localhost:3000 |
app1 | http://localhost:3001 |
app2 | http://localhost:3002 |
The main
application imports both of the microfrontends (app1
and app2
) and as such simulates a container/wrapper application.
Create React App (CRA from here on) doesn't yet support Webpack 5 or Module Federation so getting all of this (seemingly) working requires few steps. This repo, besides providing a runnable demo application, aims to document that journey
This is mostly based on the modified react-scripts by blackarctic so kudos to him.
Let's bootstrap our main application:
npx create-react-app main --template webpack-5-typescript --scripts-version webpack-5-react-scripts
This template also takes care of setting up the required bootstrap file for Module Fedration to work.
Let's remove main/node_modules
and check latest versions.
$ rm -rf node_modules
$ npm outdated
Package Current Wanted Latest Location
@testing-library/jest-dom MISSING 5.11.6 5.11.6 main
@testing-library/react MISSING 11.2.2 11.2.2 main
@testing-library/user-event MISSING 12.2.2 12.2.2 main
@types/jest MISSING 26.0.15 26.0.15 main
@types/node MISSING 12.19.7 14.14.10 main
@types/react MISSING 16.14.2 17.0.0 main
@types/react-dom MISSING 16.9.10 17.0.0 main
react MISSING 17.0.1 17.0.1 main
react-dom MISSING 17.0.1 17.0.1 main
typescript MISSING 4.1.2 4.1.2 main
web-vitals MISSING 0.2.4 1.0.1 main
webpack-5-react-scripts MISSING 0.8.0 0.8.0 main
Bleeding edge isn't bleeding edge without latest and greatest, so
main/package.json
{
...,
"dependencies": {
- "@types/node": "^12.0.0",
+ "@types/node": "^14.14.10",
- "@types/react": "^16.9.53",
+ "@types/react": "^17.0.0",
- "@types/react-dom": "^16.9.8",
+ "@types/react-dom": "^17.0.0",
- "web-vitals": "^0.2.4",
+ "web-vitals": "^1.0.1",
- "typescript": "^4.0.3",
+ "typescript": "^4.1.2",
},
...
}
npm install
I'm using VSCode and at the time of writing it's using Typescript 4.0.2 for IntelliSense which causes some ugly ESLint errors when opening .tsx
files. To fix this open any .tsx
file and start typing >select typescript version
in Command Palette
and from dropdown menu choose Use Workspace Version
. Note, in order to get this working you need to add folder (main
in this case) to your workspace using Add Folder to Workspace...
, so don't just open the project root folder in VSCode.
Next we can try running the app
npm start
which should be then accessible from http://localhost:3000
, great!
To get started with Module Federation we need to modify Webpack config. This could be done by ejecting CRA but then we would lose upgradeability among other things. Luckily there are number of tools to tackle this problem. I decided to go with rescripts.
Let's install it
npm install @rescripts/cli --save-dev
and create configuration file for it
main/.rescriptsrc.js
const { appendWebpackPlugin } = require("@rescripts/utilities");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = (config) =>
appendWebpackPlugin(
new ModuleFederationPlugin({
name: "main",
library: { type: "var", name: "main" },
filename: "remoteEntry.js",
remotes: {
app1: "app1",
app2: "app2",
},
shared: ["react", "react-dom", "react/jsx-dev-runtime"],
}),
config
);
I'm not going into details what's going on here as there are numerous blog posts that do better explaining it. Other good resources are Webpack's documentation and this repo with bunch of practical examples.
and replace react-scripts
calls with rescripts
calls in package.json
{
...,
"scripts": {
- "start": "react-scripts start",
+ "start": "rescripts start",
- "build": "react-scripts build",
+ "build": "rescripts build",
- "test": "react-scripts test",
+ "test": "rescripts test",
- "eject": "react-scripts eject"
+ "eject": "rescripts eject"
},
...
}
$ npm start
> [email protected] start .../main
> rescripts start
internal/modules/cjs/loader.js:883
throw err;
^
Error: Cannot find module 'react-scripts/config/paths'
but wait, that doesn't work? That's because rescripts
expects to find react-scripts
package but we're using modified webpack-5-react-scripts
for Webpack 5 support. To work around this we can utilize npm package aliases.
main/package.json
{
...,
"dependencies": {
...,
- "webpack-5-react-scripts": "0.8.0"
+ "react-scripts": "npm:webpack-5-react-scripts@^0.8.0",
},
...
}
And then run npm install
again.
We could have also installed webpack-5-react-script
using the alias to begin with: npm install react-scripts@npm:[email protected]
Let's style our app a little.
main/src/App.tsx
import React from "react";
import "./App.css";
import Header from "./components/Header";
function App() {
return (
<div className="app">
<Header />
</div>
);
}
export default App;
main/src/App.css
.app {
display: flex;
flex-flow: column;
height: 100vh;
}
main/src/components/Header.tsx
import React from "react";
import "./Header.css";
const Header: React.FC = () => (
<div className="block">
<header className="header">Main app</header>
</div>
);
export default Header;
main/src/components/Header.css
.block {
align-items: center;
background-color: #282c34;
display: flex;
flex: 1;
justify-content: center;
}
.header {
color: white;
font-size: calc(10px + 2vmin);
}
Now we have a nice base for our app that we can utilize. Let's create our first child app by copying contents of main
.
rsync -ar main/ app1 --exclude node_modules --exclude package-lock.json
Let's modify name
of the app in package.json
{
- "name": "main",
+ "name": "app1",
...
}
and run npm install
.
We want to run these apps in different ports and the easiest way to do this by controlling it from .env
file. While we're at it let's also add one other useful parameter for later use.
app1/.env
HOST=localhost
PORT=3001
Next let's make few modifications to distinct app1
from main
.
app1/components/Header.tsx
const Header: React.FC = () => (
<div className="block">
- <header className="header">Main app</header>
+ <header className="header">App 1</header>
</div>
);
app1/components/Header.css
.block {
align-items: center;
- background-color: #282c34;
+ background-color: #2a9d8f;
display: flex;
flex: 1;
justify-content: center;
}
We can now run app1
individually with npm start
but we also want to be able to include it from our main
app. To do this let's modify app1/.rescriptsrc.js
const { appendWebpackPlugin } = require("@rescripts/utilities");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = (config) => {
config.output.publicPath = `//${process.env.HOST}:${process.env.PORT}/`;
return appendWebpackPlugin(
new ModuleFederationPlugin({
name: packageJson.name,
library: { type: "var", name: packageJson.name },
filename: "remoteEntry.js",
exposes: {
Header: "./src/components/Header",
},
shared: ["react", "react-dom", "react/jsx-dev-runtime"],
}),
config
);
return config;
};
Header
component of app1
is now exposed via http://localhost:3001/remoteEntry.js
To use app1/Header
in main
app we need to do few modifications:
Include remoteEntry.js
script in index.html
main/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
...
+ <script src="http://localhost:3001/remoteEntry.js"></script>
<title>React App</title>
</head>
...
</html>
To dynamically import Header
React provides us couple of useful tools, lazy and Suspense.
main/src/App.tsx
import React from "react";
import "./App.css";
import Header from "./components/Header";
+const Header1 = React.lazy(() => import("app1/Header"));
function App() {
return (
<div className="app">
<Header />
+ <React.Suspense fallback="Loading Header1">
+ <Header1 />
+ </React.Suspense>
</div>
);
}
export default App;
To fix missing type declarations error of app1/Header
we'll create a new file app1.d.ts
.
main/src/app1.d.ts
/// <reference types="react" />
declare module "app1/Header" {
const Header: React.ComponentType;
export default Header;
}
Now when we run npm start
in main
folder and we can see header components of both main
and app1
!
Ok, so we can see both headers but they look the same?
This is because we have clashes in CSS class names, specifically app1
overriding .block
and .header
style declarations of main
. There are couple of options to fix this.
We could scope our CSS classes with application specific classname (like .app1
) but that would require creating a wrapper component or adding extra class name to each element with existing class name definitions. Or we could import components to Shadow Root using something like ReactShadow but that would require some extra work in order to apply the styles too. But wait a minute, CSS Modules should solve this and they're supported by CRA out-of-the-box, great!
Just for the sake of it, let's also enable Sass Stylesheets so we'll get some nice things for future developing.
$ npm install node-sass --save
To utilize Sass the only thing we then need to do is to change our stylesheet extentions (and imports) from .css
to .scss
. For CSS Modules (with CRA) we would add .module
suffix before .scss
and slightly change how we import and apply our styles.
app1/src/components/Header.tsx
import React from "react";
-import "./Header.css";
+import styles from "./Header.module.scss";
const Header: React.FC = () => (
- <div className="block">
+ <div className={styles.block}>
- <header className="header">App 1</header>
+ <header className={styles.header}>App 1</header>
</div>
);
export default Header;
But then on npm start
we're greeted with Failed to compile
error, wtf? This is because react-scripts
, or in our case webpack-5-react-scripts
, uses old version of sass-loader
which expects node-sass@^4.0.0
. To work around this let's install the latest version before 5.0 instead:
npm install [email protected]
Next we have a problem with missing type declarations for our Header.module.scss
. This is because styles are now imported as plain JavaScript objects and TypeScript is unable to perform typechecking. We could manually create type definitions for all of our CSS/SCSS modules as follows:
Header.module.scss.d.ts
export const identifierName: string;
but that would get tedious after a while. Luckily there's a plugin to automate this!
$ npm install typescript-plugin-css-modules --save-dev
After installing we need to create a new file to store the style declarions.
app1/src/styles.d.ts
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.module.scss' {
const classes: { [key: string]: string };
export default classes;
}
Finally we're able to run the app1
again! Now let's make these same changes to main
to enable CSS Modules and SASS there too.
After the changes both of the applications load but main
still displays same background color for both of the headers. When we open dev tools and inspect the elements we can see the culprit. CSS Modules are in use as we can see from the hash in the class name but for some reason they are same for both of the headers. How is that possible? Shouldn't hash be always unqiue?
A bit of googling reveals that CRA uses relative filepath for classname generation and this is exactly the case with our Header
components. It wasn't so straightforward after all, was it?
The issue lies deep in getCSSModuleLocalIdent and there's no easy way to fix it. However as we're already fiddling with CRA's Webpack configuration we might go all in and try to do that anyway.
What we want to achieve is to add some kind of project specific uniqueness to the hash to avoid collisions between components from different projects. Package name in package.json
is a good candidate for this.
getLocalIdent
is called from two module rules in Webpack's config here and here. Luckily rescripts
provides a nice way to reference certain path in the config which should make it somewhat future proof. Easiest way to patch the method is to just completely replace it.
To do that we would use the edit method of rescripts
edit(transformCssModule, getPaths(cssModuleMatcher, config), config)
We want to target both CSS and Sass modules so the matcher (cssModuleMatcher
) would look like this:
const cssModuleRegex = /\.module\.css$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
const cssModuleMatcher = (inQuestion) =>
inQuestion &&
inQuestion.test &&
(inQuestion.test.toString() === sassModuleRegex.toString() ||
inQuestion.test.toString() === cssModuleRegex.toString());
and the transform (transformCssModule
) method:
const transformCssModule = (match) => {
return {
...match,
use: match.use.map((it) => {
if (
it.options &&
it.options.modules &&
it.options.modules.getLocalIdent
) {
it.options.modules.getLocalIdent = getLocalIdent;
}
return it;
}),
};
};
finally the getLocalIdent
would remain the same with the exception of adding name from package.json
to the hash
const loaderUtils = require("loader-utils");
const path = require("path");
+const packageJson = require("./package.json");
function getLocalIdent(context, localIdentName, localName, options) {
const fileNameOrFolder = context.resourcePath.match(
/index\.module\.(css|scss|sass)$/
)
? "[folder]"
: "[name]";
+ const appName = packageJson.name;
const hash = loaderUtils.getHashDigest(
+ appName +
path.posix.relative(context.rootContext, context.resourcePath) +
localName,
"md5",
"base64",
5
);
const className = loaderUtils.interpolateName(
context,
fileNameOrFolder + "_" + localName + "__" + hash,
options
);
return className.replace(".module_", "_").replace(/\./g, "_");
}
Putting all these together should yield something like this.
Finally when we restart the applications we should see distinct header background colors!
Let's copy the codebase of app1
and use it as a base for our app2
rsync -ar app1/ app2 --exclude node_modules --exclude package-lock.json
Make the usual tweaks
package.json
{
- "name": "app1",
+ "name": "app2",
...
}
.env
HOST=localhost
-PORT=3001
+PORT=3002
src/components/Header.tsx
import React from "react";
import styles from "./Header.module.scss";
const Header: React.FC = () => (
<div className={styles.block}>
- <header className={styles.header}>App 1</header>
+ <header className={styles.header}>App 2</header>
</div>
);
export default Header;
app2/src/components/Header.module.scss
.block {
align-items: center;
- background-color: #2a9d8f;
+ background-color: #f4a261;
display: flex;
flex: 1;
justify-content: center;
}
.header {
color: white;
font-size: calc(10px + 2vmin);
}
and install and run it
npm install && npm start
And not to forget include it in our main
app
main/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
...
<script src="http://localhost:3001/remoteEntry.js"></script>
+ <script src="http://localhost:3002/remoteEntry.js"></script>
<title>React App</title>
</head>
...
</html>
main/src/app2.d.ts
/// <reference types="react" />
declare module "app2/Header" {
const Header: React.ComponentType;
export default Header;
}
main/src/App.tsx
import React from "react";
import "./App.scss";
import Header from "./components/Header";
const Header1 = React.lazy(() => import("app1/Header"));
+const Header2 = React.lazy(() => import("app2/Header"));
function App() {
return (
<div className="app">
<Header />
<React.Suspense fallback="Loading Header1">
<Header1 />
</React.Suspense>
+ <React.Suspense fallback="Loading Header2">
+ <Header2 />
+ </React.Suspense>
</div>
);
}
export default App;
and ta-da! We now have two micro frontends being loaded from main app!