How to convert an existing project to MicroFrontend with Module Federation

Nowadays, I joined a frontend team in a new company in Singapore, the project is based on monorepo (you can find more about monorepo here) and the tech lead maintains the project very well, keeping updating the main libraries to the latest version and keeping an eye on the new technology to improve the solution and codes.

When I invested the project, I found the project was not just a monorepo, but also a monolith frontend project which involves other project which is kidn of a historical problem. It works currently but have some issues for other team, let's call it Team D. Team D is working on one tab in the web page, it uses the same framework (React) and similar technologies, but it does not have any intersection with main project in business actually. It has its own development libraries and release plan. But since its codes are still in the same src folder with main project, which causes team D still stays inside of the main project's release lifecycles. Therefore, we need to separate it from Microfrontend technology.

What is MicroFrontend

Microfrontend is a popular architechtural solution that helps large, complex monolith applications to be built as a collection of independently-developed and deployed microservices. This can improve the scalability, maintainability and flexibility of the application, making it easier to collaborate with a team of developers.

What is Module federation

One way to implement microfrontend in the application is module federation, a plugin in the webpack 5. It connects different applications during build time and share modules with each other during runtime. This allows each module to define and manage its own components, while still being able to use common libraries by other modules.

Actually there are some other popular frameworks doing the microfrontend, like single-spa, bit, qiankun. The reason we choose module federation is:

  1. All the other frameworks are doing two things

    1. organize the microfrontend services
    2. connect these services together through importing.

    Since we already have our own application well-organized in a monorepo way, each module is dynamically imported by react new feature React.lazy. So what we need here actually is just the way of importing modules dynamically.

  2. Module federation is just a plugin of webpack 5, we do not need to install another library to enable microfrontend. It also saves us time to maintain the extra library.

Migration with Module federation with Nx

In our project, we installed Nx to manage the monorepo, it's good to use module federation with Nx as well to include the new project in the management of Nx. You can learn more about monorepo and the comparasion with polyrepo Check here. Nx creates its own module federation module to simplify the procedure, if the whole project has not been created, it's good to use it.

1const withModuleFederation = require('@nrwl/react/module-federation');
2const moduleFederationConfig = require('./module-federation.config');
3
4module.exports = withModuleFederation({
5  ...moduleFederationConfig,
6});

Configure host with original configuration

But normally when we want to apply the module federation to the project, it means the project's webpack has already been well configured, it comes some conflict if we use the methods from Nx. So instead of that, let's use the original module federation configuration.

 1const { ModuleFederationPlugin } = require('webpack').container
 2const deps = require('../../package.json').dependencies
 3
 4...
 5
 6module.exports = {
 7  entry: './src/bootstrap.tsx', // used to be entry: './src/index.tsx',
 8  //...
 9  plugins: [
10    new ModuleFederationPlugin({
11      name: 'host',
12      shared: {
13        react: {
14          singleton: true,
15          requiredVersion: deps['react'],
16        },
17        'react-dom': {
18          singleton: true,
19          requiredVersion: deps['react-dom'],
20        },
21      },
22    }),
23    ...
24   ]};

Here we only define the name in module federation plugin because we will use dynamic importing later. And we update the entry from ./src/index.tsx to './src/bootstrap.tsx'. It's because module federation needs an entrance to import the modules.

Then let's create a new file bootstrap.tsx under folder src and fill the content with:

1import('./index')

Configure remote

So the host part is finished, let's see how to create a remote project with Nx. In the root folder, run the following command. Here I named the project as delivery-test, feel free to change it if you want other name.

1nx generate @nrwl/react:remote delivery-test

It would create the remote project under the project with its own configuration. For the webpack, we would configure like below:

 1require('dotenv').config()
 2const { join, resolve } = require('path')
 3const webpack = require('webpack')
 4const packageConfig = require('../../package.json')
 5const { version } = packageConfig
 6const isDev = process.env.NODE_ENV === 'development'
 7const isProductionBuild = process.env.NODE_ENV === 'production'
 8const isDevDockerEnv = isDev && process.env.DEVELOPMENT_ENV === 'docker'
 9
10const { ModuleFederationPlugin } = require('webpack').container
11const deps = packageConfig.dependencies
12
13module.exports = {
14  mode: isDev ? 'development' : 'production',
15  entry: './src/main.ts',
16  output: {
17    ...(isProductionBuild && {
18      path: join(process.cwd(), 'dist/apps/delivery-test'),
19      filename: 'static/js/[name].[contenthash].js',
20      chunkFilename: 'static/js/[name].[contenthash].chunk.js',
21    }),
22    publicPath: 'auto',
23  },
24  context: __dirname,
25  devtool: isDev ? 'eval-cheap-module-source-map' : 'nosources-source-map',
26  devServer: isDev
27    ? {
28        historyApiFallback: true,
29        host: '0.0.0.0',
30        port: 9002,
31        hot: true,
32        liveReload: false,
33        static: __dirname,
34        devMiddleware: {
35          publicPath: '/',
36        },
37        client: { overlay: false },
38      }
39    : undefined,
40  resolve: {
41    extensions: ['.js', '.jsx', '.ts', '.tsx'],
42  },
43  // ...
44  plugins: [
45    new ModuleFederationPlugin({
46      name: 'delivery-test',
47      filename: 'remoteEntry.js',
48      library: { type: 'global', name: 'delivery_test' }, // NOTE: use underscore here, minus is not allowed
49      exposes: {
50        './Module': './src/remote-entry.ts',
51      },
52      shared: {
53        react: {
54          singleton: true,
55          requiredVersion: deps['react'],
56        },
57        'react-dom': {
58          singleton: true,
59          requiredVersion: deps['react-dom'],
60        }
61      },
62    }),
63  ],
64}

In the module federation configuration, we define a global variable delivery_test to pass the parameters through dynamical importing. Be carefull, the variable's name does not accept - symbol.

For the Nx commands, we can also update the commands in project.json:

 1"build": {
 2  "executor": "nx:run-commands",
 3  "options": {
 4    "command": "pnpm cross-env NODE_OPTIONS=--max-old-space-size=6144 NODE_ENV=production STAGING=true webpack --config ./apps/delivery-test/webpack.config.js"
 5  }
 6},
 7"build-serve": {
 8  "executor": "nx:run-commands",
 9  "options": {
10    "command": "pnpm cross-env NODE_OPTIONS=--max-old-space-size=6144 NODE_ENV=production STAGING=true webpack serve --config ./apps/delivery-test/webpack.config.js --port 9002"
11  }
12},
13"serve": {
14  "executor": "nx:run-commands",
15  "options": {
16    "command": "pnpm cross-env NODE_OPTIONS=--max-old-space-size=6144 NODE_ENV=development webpack serve --config ./apps/delivery-test/webpack.config.js",
17  }
18},

Module federation dynamic library

So the setup and configuration are completed. So how can we use the component from remote project in the host? In the module federation configuration of host, we do not have any text about the remote project delivery-test, it's because we gonna import the remote dynamically, which means the remote project can be imported during runtime without specifying at build time. For the principle, you can check this one and also 4 ways to use dynamic remotes.

 1function loadComponent(scope: string, module: string) {
 2  return async () => {
 3    const libName = scope.replace(/\//g, '_').replace(/-/g, '_')
 4    // Initializes the share scope. This fills it with known provided modules from this build and all remotes
 5    await __webpack_init_sharing__('default')
 6    const container = window[libName] // or get the container somewhere else
 7    // Initialize the container, it may provide shared modules
 8    await container.init(__webpack_share_scopes__.default)
 9    const factory = await window[libName].get(module)
10    const Module = factory()
11    return Module
12  }
13}
14
15const urlCache = new Set()
16const useDynamicScript = (url: string) => {
17  const [ready, setReady] = React.useState(false)
18  const [errorLoading, setErrorLoading] = React.useState(false)
19  useEffect(() => {
20    if (!url) return
21    if (urlCache.has(url)) {
22      setReady(true)
23      setErrorLoading(false)
24      return
25    }
26    setReady(false)
27    setErrorLoading(false)
28    const element = document.createElement('script')
29    element.src = url
30    element.type = 'text/javascript'
31    element.async = true
32    element.onload = () => {
33      urlCache.add(url)
34      setReady(true)
35    }
36    element.onerror = () => {
37      setReady(false)
38      setErrorLoading(true)
39    }
40    document.head.appendChild(element)
41    return () => {
42      urlCache.delete(url)
43      document.head.removeChild(element)
44    }
45  }, [url])
46  return {
47    errorLoading,
48    ready,
49  }
50}
51
52const componentCache = new Map()
53const useFederatedComponent = (remoteUrl: string, scope: string, module: string) => {
54  const key = `${remoteUrl}-${scope}-${module}`
55  const [Component, setComponent] = React.useState(null)
56  const { ready, errorLoading } = useDynamicScript(remoteUrl)
57  React.useEffect(() => {
58    if (Component) setComponent(null)
59    // Only recalculate when key changes
60  }, [key])
61  React.useEffect(() => {
62    if (ready && !Component) {
63      const Comp = React.lazy(loadComponent(scope, module))
64      componentCache.set(key, Comp)
65      setComponent(Comp)
66    }
67    // key includes all dependencies (scope/module)
68  }, [Component, ready, key, module, scope])
69  return { errorLoading, Component }
70}
71// NOTE: to make dynamica import work, we need to pass the container to a global variable which should be defined in the remote app's webpack config
72// Find the file withModuleFederationPlugin.js in the remote app's webpack config
73// 1. remove the code of setting "outputModule: true"
74// 2. update the library in modulefederation from value 'module' to {
75//   type: 'global',
76//   name: options.name.replace('-', '_'), // the name does not accept dash and backslash
77// }
78const App = () => {
79  const { Component: FederatedComponent, errorLoading } = useFederatedComponent(
80    'http://localhost:9002/remoteEntry.js',
81    'delivery-test',
82    './Module',
83  )
84  return (
85    <React.Suspense fallback="Loading System">
86      {errorLoading
87        ? `Error loading module "${module}"`
88        : FederatedComponent && <FederatedComponent />}
89    </React.Suspense>
90  )
91}

Use the above codes in the host project, and call the App directly with <App />.

comments powered by Disqus