Motivation

Multiple independent builds can make up an application, and there should be no dependencies between these independent builds, so they can be developed and deployed separately. This is often called a micro front end, but it’s not limited to that.

This is the official motivation for the Module Fedration (MF), which many people refer to as the “game-changer” of the JavaScript architecture, and I think it’s true. Before MF, Module sharing in JavaScript was more about NPM installation dependencies, downloading packages into the project’s local environment and importing them through ES Modules or other modular means. Alternatively, you can directly create modules in the local directory and import and export modules. Both util and Component dependency sharing are based on this pattern.

The emergence of MF has completely broken the way of JS module sharing. In my opinion, the emergence of MF has opened a new era of front-end micro-service architecture.

Modules share evolution history

Let’s review some of the pre-MF solutions.

NPM

When we need to share the same module in two projects, our first thought is to develop the module separately, publish it to NPM, and then introduce it separately in both projects. For example, the current advertising projects smart-ADS and Smart-AdS-Admin share an Employee module between them, which we managed with Veggie (a Monorepo that encapsulates the shared components of advertising projects) :

The downside of this scenario, however, is that if we upgrade the Employee package, each project needs to be updated individually. Although Monorepo can solve the complexity of repeated installation and version maintenance, it still needs to compile locally and go through the process of re-issuing the version online. In addition, local development and debugging also have certain costs.

UMD

At present, the front-end can only realize module sharing at Runtime based on UMD. That is, the modules to be shared are packaged into UMD format by packaging tool and then uploaded to CDN. Other projects are introduced by script tag. At present, a common scenario is that all data reporting SDKS support the introduction of CDN. Take our company’s HAidu SDK as an example:

The problem with this approach is that compile-time optimizations are not possible, such as the inability to share the same dependencies between packages and projects, and the possibility of conflicting versions of dependencies.

DllPlugin and Externals

Before MF, Webpack also had some attempts of DllPlugin and Externals, but DllPlugin also cannot rely on sharing, and can only be a mode of module sharing based on local build environment. Another problem with introducing some dependencies through the CDN via the externals option is the inability to use multiple versions of a dependency.

Micro front-end

This is a popular front-end architecture solution in recent years. Micro front-end is to solve the problem of multi-project coexistence, and the biggest problem of multi-project coexistence is module sharing, and there should be no conflict. From a build point of view, the micro front end has two solutions:

  • Each sub-application is packaged separately, which can improve the speed of packaging, but cannot extract common dependencies;
  • Packaging the whole application together solves the problem of common dependencies, but the packaging speed is slower and slower as the application expands.

Looking back at the evolution of the module sharing approach over the years, each solution has more or less some obvious disadvantages, so when choosing a solution, we have to choose a compromise solution according to the business scenario. Let’s look at the emergence of MF and how to solve these problems.

Gamer-Changer–MF

Let’s first look at what MF can solve.

1. Remote module sharing

Previously, we used to download modules in the local project through NPM, or package some modules in the project itself and reuse them between pages, which is a way of sharing local modules. However, the emergence of MF makes sharing of remote modules become normal in future JS modular development. For example, smart-ADS and smart-ADS-admin mentioned above, if Employee module is shared in MF mode, the dependency diagram is as follows:

2. Common dependency reuse

As mentioned earlier, the problem with remotely loading modules through UMD is that dependency sharing is not possible, resulting in a project loading the same dependencies repeatedly. MF can specify the dependencies needed by remote modules through configuration items during construction, thus solving the dependency sharing problem. And dependencies that need to be shared are requested only when they are needed, eliminating the problem of projects reloading dependencies.

3. Perfect micro-front-end solution

We know that among the micro front-end architecture schemes, the most common one is the architecture scheme based on base applications, such as Qiankun of Alibaba Open Source. In a microfront-end application, there is always a pedestal application that manages all the child applications. Of course, the base-based architecture approach has its own advantages, such as flexible management of communication between children and children, sandbox mechanism, loading and unloading children when switching applications, and so on. However, there has not been a good solution to the problem of common dependency loading and dependency conflict caused by inconsistent versions between father and son.

Using the shared capability of MF and the remotes capability of MF, i.e. the common dependency loading problem, can also solve the issue of version conflicts by creating an application-specific library for other applications to consume.

4. Applicable to browser and Node environments

Applied to the browser environment, this is the basic capability. MF also works for target: “node”, which uses file paths that point to other micro-front-end applications instead of URLs. This way you can use the same code with a different Webpack configuration to implement ssr.MF in Node.js and keep the same features in Node.js, such as build independently, deploy independently.

This section summarizes several important functions of MF in my opinion. The following will list some specific application scenarios of MF in React based on these functions.

MF practice

Here are a few React scenarios.

React with TypeScript

In this example, we have two apps APP1 and APP2, each with the following directory structure:

APP1 consumes the Button component exported from APP2, app.tsx code is as follows:

import * as React from "react";

const RemoteButton = React.lazy(() = > import("app2/Button"));

const App = () = > (
  <div>
  	<h1>App 1</h1>
    <h2>React in Typescript</h2>{/* Use Suspense to add fallback */}<React.Suspense fallback="Loading Button">
      <RemoteButton />
    </React.Suspense>
  </div>
);

export default App;
Copy the code

APP1 webpack.config.js configuration:

module.exports = {
	// Omit other configurations
  plugins: [
    new ModuleFederationPlugin({
      name: "app1".// Specify the address of the remote module service to be loaded
      remotes: {
        app2: "app2@http://localhost:3002/remoteEntry.js",},// Specify the dependencies to be shared
      shared: ["react"."react-dom"],}),],}Copy the code

APP2 button.tsx:

import * as React from "react";

const Button = () = > <button>App 2 Button</button>;

export default Button;

Copy the code

APP2 can also consume Button components locally, APP2 app.tsx:

import * as React from "react";

import LocalButton from "./Button";

const App = () = > (
  <div>
    <h1>Typescript</h1>
    <h2>App 2</h2>
    <LocalButton />
  </div>
);

export default App;

Copy the code

The App2 webpack. Config. Js:

module.exports = {
	// Omit other configurations
  plugins: [
    new ModuleFederationPlugin({
      name: "app2".filename: "remoteEntry.js".// Share the exported modules with other applications
      exposes: {
        "./Button": "./src/Button",},// Specify the dependencies to be shared
      shared: ["react"."react-dom"],}),],}Copy the code

Final effect:In this case, we need to add the TypeScript type declaration for the remote module “app2/Button” to APP1, otherwise TS will prompt us

The app2/Button module or its corresponding type declaration could not be found

So we need to add a type definition to app.d.ts:

declare module "app2/Button" {
  const Button: React.ComponentType;

  export default Button;
}
Copy the code

This is currently a sore point with MF, used in TS projects, and TS cannot be used from remote loading declaration files.

Nextjs SSR

We know that Nextjs is a framework based on React that supports various rendering schemes such as CSR, SSR, SSG, etc. At present, the landing page project of our advertisement uses Nextjs + Preact as the technology stack.

Getting back to the subject, let’s take a look at MF in Nextjs.

In this example, we create a new host application and a remoteLib application with the following directory structure:First we look at remoteLib, which packages a SmartButton component and then exports it through the MF plug-in to expose it to other applications. SmartButton.jsx

import React from "react";

const SmartButton = () = > {
    return <button style={{ height: "45px",fontSize: "18px", lineHeight: "45px",
    backgroundColor: "#0070f3", color: "#fff}} ">
        Hey, I'm a smart button from remoteLib
    </button>
};

export default SmartButton;
Copy the code

Look at its webpack.config.js:

module.exports = {
	// Omit other configurations
  plugins: [
    new ModuleFederationPlugin({
      name: "remoteLib".filename: "remoteEntry.js".exposes: {
        "./SmartButton": "./src/SmartButton.jsx",},shared: {
        react: {
          singleton: true.requiredVersion: packageJson.dependencies["react"],}, ["react-dom"] : {singleton: true.requiredVersion: packageJson.dependencies["react-dom"],},},}),]}Copy the code

The configuration of the export module is the same as the previous example, but the singleton and requiredVersion configurations have been added to the shared dependency configuration. Here is a brief introduction to the functions of these two configurations:

  • Singleton: This configuration is in the shared scope and only allows a unique version of the shared module, which is turned off by default. React and react-dom use a global internal state, so we need to ensure that react and react-dom are unique without isolating the runtime.
  • RequiredVersion, which is easy to understand, along with the Singleton, specifies the version of the shared dependency, usually obtained directly from package.json.

Moving on to the host application code, we first need to look at the next.config.js file for ease of understanding. Those of you who have used Nextjs should know that all of its configuration is in that file, including the extension to the Webpck configuration. Let’s focus on the Webpack section:

const { NodeModuleFederation } = require("@telenko/node-mf");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  future: { webpack5: true },
	webpack: (config, options) = > {
    const { buildId, dev, isServer, defaultLoaders, webpack } = options;
    const mfConf = {
      remotes: {
        remoteLib: isServer
          ? "remoteLib@http://localhost:3002/node/remoteEntry.js"
          :
            {
              external: `external new Promise((r, j) => {
              window['remoteLib'].init({
                react: {
                  "${packageJsonDeps.react}": { get: () => Promise.resolve().then(() => () => globalThis.React), } } }); r({ get: (request) => window['remoteLib'].get(request), init: (args) => {} }); }) `,}},shared: {
        react: {
          eager: true.requiredVersion: packageJsonDeps["react"].singleton: true,},"react-dom": {
          eager: true.requiredVersion: packageJsonDeps["react-dom"].singleton: true,}}};return {
      ...config,
      plugins: [
        ...config.plugins,
        new (isServer ? NodeModuleFederation : ModuleFederationPlugin)(mfConf),
      ],
      experiments: { topLevelAwait: true}}; }},Copy the code

Unlike the previous configurations, we need to distinguish between the server configuration and the browser configuration when we import remote. On the server side, we load the module via remoteLib’s server address, and when we actually get to the client side, We attach the remote module’s Container to the Window object. Those of you who have read the Webpack MF documentation should know that MF supports dynamic Remote based on promises.

Another difference is that when using the MF plugin, we will need to load the customized MF plugin for Node from @telenko/ node-MF. This package encapsulates a collection of Webpack plugins specifically for MF on the Node side.

Let’s take a look at the index.js code:

import Head from "next/head";
import Image from "next/image";
import styles from ".. /styles/Home.module.css";
import dynamic from "next/dynamic";

// Load the Button component dynamically
const RemoteButton = dynamic(
  () = > {
    return window.remoteLib? .get("./SmartButton").then((factory) = > factory());
  },
  { ssr: false});export default function Home() {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        
        <h1 className={styles.title}>
          Welcome to <a href="https://nextjs.org">Next.js!</a>
        </h1>

        { <RemoteButton /> }

      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{" "}
          <span className={styles.logo}>
            <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
          </span>
        </a>
      </footer>
    </div>
  );
}

Copy the code

Take a look at the final image:

In this way, we realize MF remote module reuse based on Nextjs. However, there are still some things that are not very good about this model:

  • As before, the type of TS is not recognized. The remote module type can only be declared in the type file of the host application.
  • Local development and debugging experience is poor. Every time you change remoteLib, you have to repackage and build the remoteLib service, which is inefficient and poor development experience.

Of course, whether there is a more appropriate development mode, but also need to slowly explore.

Mining MF usage scenarios from advertising business projects

In the process of developing Smart-ADS V6.0.0, we mentioned earlier in the introduction of the evolution of module sharing that smart-ADS and SmartD-ADS-Admin share several UI modules, taking the Employee module as an example. We maintained these generic modules by creating Monorepo–Veggie, developed them, released them to NPM, and then introduced them separately for each project.

During development, we encountered two pain points:

  • The Antd Design versions for Smart-Ads and SmartD-ADS-Admin are inconsistent, and Veggie is also a newly created repository, so the Antd version is relatively new. Smartd-ads-admin is relatively old, so a component developed in Veggie has the same ANTD component with incompatible API, which makes it unuseful in SmartD-ADS-Admin. This requires an upgrade to the antD version, which may incur some upgrade costs. Although we solved this problem by upgrading later, it triggered my thinking: not only the SmartD-ADS-Admin case, but also other projects may have this problem, and the components we maintain may only be used in part of the project. Therefore, how to ensure the consistency of the versions of the core dependencies of each project, such as React, React – DOM, ANTD, etc., is a problem to be considered.
  • Smart-ads has gone through six major versions from V1.0.0 to V6.0.0. With the increasingly complex business functions and the expansion of front-end modules, the speed of our local development startup and online construction is also getting slower and slower. At present, it takes more than one minute for smart-ADS to start locally, and it may take more than ten minutes for CI to install dependencies and build due to network fluctuations, which seriously affects development efficiency, experience efficiency and online efficiency.

As for the first problem, we may be able to pay close attention to the update of each library at any time by formulating specifications, so as to make timely updates. However, such disadvantages are also obvious, as more and more projects, manual update of each project is inefficient.

For problem two, although some optimization can be made by perfecting the Webpack configuration, due to the complexity of Webpack, its build performance is unlikely to improve as fast as the expansion of our business. While the community now has a new build tool, Vite, to address the development experience, it remains to be seen whether it can meet all the requirements in a large project build scenario.

Through MF scheme, we can solve the above two pain points to a certain extent.

Let’s start with a graph:The ability to rely on MF allows us to maintain some core dependencies, such as React, React-DOM, and ANTD, using a remote service, and then each project references the library exported by this service separately. We only need to maintain the version dependent on the remote service, so as to ensure that the version of core dependencies of each project is consistent. Moreover, each project does not need to upgrade itself, which greatly improves efficiency. Perfect solution to the pain point I mentioned above.

The separation of these common dependencies also reduces the number of dependencies built between each project, thus optimizing the construction time to a certain extent, thus partially solving the second pain point. To go even further, we could customize our Remote services for each project, pulling out most of the dependencies that don’t need to change much and uploading them to the CDN through other Remote Library builds, so that each project would have far fewer dependencies to build at development time. Thus, the pain point two is basically solved.

Let’s implement a simple react demo along these lines.

Let’s look at the directory structure for each application:

Component-app mainly exports components that are shared between each project, replacing Veggie in our previous project. Llib-app manages the core dependencies between projects and does nothing but maintain versions of the core dependencies. Main-app can be thought of as one of our projects, consuming component-app and lib-app export dependencies.

For a quick look at the component-app configuration, it’s pretty much the same as in our previous example:

module.exports = {
  // Omit some configurations
  plugins: [
    new ModuleFederationPlugin({
      name: "component_app".filename: "remoteEntry.js".exposes: {
        "./Button": "./src/Button.jsx"."./Dialog": "./src/Dialog.jsx"."./Logo": "./src/Logo.jsx"."./ToolTip": "./src/ToolTip.jsx",},remotes: {
        "lib-app": "lib_app@http://localhost:3000/remoteEntry.js",},}),],}Copy the code

Export a bunch of components, and add a module configuration using lib-app to ensure that the core dependencies are consistent with the project when developing components.

import React from 'lib-app/react'
import { Button } from 'lib-app/antd'
import 'lib-app/antd/dist/antd.css'

export default function CustomButton(props) {
  const type = props.type || 'primary'
  return <Button type={type}>{type} Button</Button>
}    
Copy the code

Next, let’s look at the lib-app configuration:

module.exports = {
	// Omit some configurations
  plugins: [
    new ModuleFederationPlugin({
      name: 'lib_app'.filename: 'remoteEntry.js'.exposes: {
        './react': 'react'.'./react-dom': 'react-dom'.'./antd': 'antd'.'./antd/dist/antd.css': 'antd/dist/antd.css'}}})]Copy the code

The lib-app service does only maintenance of core dependencies, without any other code.

For a final look at the main-app implementation, look at webpack.config.js:

module.exports = {
	// Omit some configurations
  plugins: [
     new ModuleFederationPlugin({
      name: "main_app".remotes: {
        "lib-app": "lib_app@http://localhost:3000/remoteEntry.js"."component-app": "component_app@http://localhost:3001/remoteEntry.js",},}),],}Copy the code

Take a look at the module that uses remote in index.js:

import React from 'lib-app/react';
import Button from 'component-app/Button'
import Dialog from 'component-app/Dialog'
import ToolTip from "component-app/ToolTip"

function App () {
  const [visible, setVisible] = useState(false)
  
	return (
      <div style={{ padding: '20px' }}>
        <h1>Open Dev Tool And Focus On Network,checkout resources details</h1>
        <p>React, React-DOM, ANTD JS Files Hosted on<strong>lib-app</strong>
        </p>
        <p>
          components hosted on <strong>component-app</strong>
        </p>
        <h4>Buttons:</h4>
        <Button type="primary" />
        <Button type="warning" />
        <h4>Dialog:</h4>
        <Button onClick={()= > setVisible()}>click me to open Dialog</Button>
        <Dialog
          switchVisible={(visible)= > setVisible(visible)}
          visible={visible}
        />
        <h4>hover me please!</h4>
        <ToolTip content="hover me please" message="Hello,world!" />
      </div>)}export default App
Copy the code

End result:

This completes the demo, and it’s not too difficult to implement.

summary

In the previous section, we used three examples to describe the application scenario of MF based on React. First of all, we can see from the example that MF brings great advantages to remote module sharing, enabling shared modules to evolve from localized to remote servitization. Moreover, it also solves the dependency reuse problem caused by module sharing. This allows us to have unlimited imagination when designing front-end application architectures, as seen in the previous examples.

Of course, THERE are still some problems with MF, and the use of MF also puts some pressure on the front-end application maintenance code:

  • First, TypeScript doesn’t support loading declaration files remotely, so we need to maintain the type declarations of remote modules when we import them.
  • Secondly, from the perspective of services, as illustrated in the third example, when all our projects share the same Lib-App, the availability and robustness of lib-App are very important. If the service is unavailable due to misoperation or other reasons, a large number of projects may fail or some functions may become unavailable. In addition, when using this architecture, fallback scheme needs to be fully considered, which has not been carefully studied for the time being.
  • Also, there are maintenance costs if remote services are broken down too thinly. In addition to the need for a certain amount of manpower for business development, it is also necessary to ensure the timely updating of remote services. And the more services are split, the local development debugging is also a problem if several services are involved in a single development.

At the end

In general, the emergence of MF opens a new era of JS module reuse, so MF is mentioned as a game-changer at the beginning of this article. Although up to now, a large number of usage scenarios have been explored, such as micro-front-end, module reuse of multi-project direct remote, Dynamic Route sharing, separation of Core Dependencies, and so on. I still feel that there are usage scenarios that we can dig into, and that we need to dig into in complex project development.

Reference

1. Intensive reading of Webpack5 new features – module federation

2.module-federation

3.Webpack 5 Module Federation: A game-changer in JavaScript architecture

4.module-federation-examples