How to elegantly implement code sharing across applications

In the first half of 2020, Webpack released a very exciting feature: Module Federation (translated as module federation). This feature has attracted widespread attention from the industry as soon as it was launched, and it is even called the Game Changer in the field of front-end construction. In fact, this technology really solves the problem of multiple application module reuse, and its solution is more elegant and flexible than previous solutions. But from another perspective, Module Federation represents a general solution and is not limited to a specific build tool. Therefore, we can also implement this feature in Vite, and the community has already matured s solution.

1. The pain of module sharing

For an Internet product, there are generally different subdivided applications. For example, Tencent documents can be divided into word, excel, ppt and other categories, and Douyin PC sites can be divided into sub-sites such as short video sites, live broadcast sites, and search sites. , and each sub-station is independent of each other, and may be independently developed and maintained by different development teams. It seems that there is no problem, but in fact, it often encounters some problems of module sharing, which means that there will always be problems in different applications. Some shared code, such as public components, public utility functions, public third-party dependencies, etc. For these shared codes, besides simple copy and paste, is there a better way to reuse them?

Here are some common code reuse methods:

1.1 Release npm package

Publishing npm packages is a common way to reuse modules. We can package some common codes into an npm package, and then refer to this npm package in other projects. The specific release update process is as follows:

  1. Public library lib1 changes, published to npm;
  2. All applications install new dependencies and perform joint debugging.

image.png

Encapsulating npm packages can solve the problem of module reuse, but it introduces new problems:

  • development efficiency issues. Every change needs to be released, and all related applications need to install new dependencies. The process is more complicated.
  • Project build issue. After the introduction of the public library, the code of the public library needs to be packaged into the final product of the project, resulting in a large product size and a relatively slow build speed.

Therefore, this solution cannot be used as the final solution, but is only a helpless move to solve the problem temporarily.

1.2 Git Submodule

Through git submodule, we can encapsulate the code into a public Git repository, and then reuse it in different applications, but we also need to go through the following steps:

  1. Submit changes to the public library lib1 to the Git remote warehouse;
  2. All applications update the sub-warehouse code through the git submodule command and perform joint debugging.

It can be seen that the overall process is almost the same as that of sending npm packages, and there are still various problems in the npm package solution.

1.3 Rely on externalization + CDN introduction

The so-called dependency externalization (external) means that we do not need to let some third-party dependencies participate in the construction, but use a certain public code. According to this idea, we can declare external for some dependencies in the build engine, and then add the dependent CDN address in the HTML, for example:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="root"></div>
    <!-- 从 CDN 上引入第三方依赖的代码 -->
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/index.min.js"><script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/index.min.js"><script>
  </body>
</html>

As shown in the above example, we can use CDN to import react and react-dom, generally using UMD format products, so that different projects can use the same dependent code through window.React, so as to achieve module The effect of reuse. However, this approach also has certain limitations:

  • Compatibility issues . Not all dependencies have products in UMD format, so this solution cannot cover all third-party npm packages.
  • Dependency order problem . We usually need to consider the issue of indirect dependencies. For example, for the antd component library, it also depends on react and moment, so react and moment also need external, and these packages are referenced in HTML, and the order of references must be strictly guaranteed, such as It is said that if moment is placed behind antd, the code may not run. The number of indirect dependencies behind third-party packages is generally huge, and if they are dealt with one by one, it will be a nightmare for developers.
  • Product volume issues . After the dependent package is declared external, when the application references its CDN address, it will fully reference the dependent code. In this case, there is no way to remove useless code through Tree Shaking, which will cause the performance of the application to decline.

1.4 Monorepo

As a new project management method, Monorepo can also solve the problem of module reuse very well. Under the Monorepo architecture, multiple projects can be placed in the same Git warehouse, and each interdependent sub-project is debugged through a soft chain. Code reuse is very convenient. If there is a dependent code change, then use this dependency It will be immediately felt in the project.

image.png

I have to admit that Monorepo is a very good solution to the problem of module reuse between applications, but at the same time, it also has some limitations in use.

  • All application code must be placed in the same repository . If it is an old project and each application uses a Git repository, the project structure adjustment will be relatively large after using Monorepo, which means that the transformation cost will be relatively high.
  • Monorepo itself also has some natural limitations. For example, when the number of projects increases, it will take a long time to install dependencies, and the overall construction time of the project will become longer, etc. We also need to solve the development efficiency problems caused by these limitations. And this work generally requires professional personnel to solve it. If there is not enough personnel investment or infrastructure guarantee, Monorepo may not be a good choice.
  • Project build issue . Like the solution of sending npm packages, all public codes need to enter the project's construction process, and the product size will still be too large.

Second, the core concept of Module Federation

Next, let's formally introduce Module Federation, that is, the module federation solution, and see how it solves the problem of module reuse. There are mainly two types of modules in a module federation: local modules and remote modules.

The local module is a common module, which is part of the current build process, while the remote module is not part of the current build process and is imported when the local module is running. At the same time, the local module and the remote module can share some dependent code, as shown in the following figure Show:

image.png

It is worth emphasizing that in a module federation, each module can be a local module and import other remote modules, or it can be a remote module and be imported by other modules. As shown in the following example:

image.png

The above is the main design principle of module federation. Now let’s analyze the advantages of this design:

  • Realize module sharing at any granularity . The module granularity referred to here can be large or small, including third-party npm dependencies, business components, tool functions, and even the entire front-end application! The entire front-end application can share products, which means that each application is independently developed, tested, and deployed, which is also a realization of a micro-front end.
  • Optimize build product volume . The remote module can be pulled from the runtime of the local module without participating in the construction of the local module, which can speed up the construction process and reduce the construction artifacts.
  • Loaded on demand at runtime . The granularity of remote module import can be very small. If you only want to use the add function of the app1 module, you only need to export this function in the build configuration of app1, and then import it in the local module as import('app1/add') That's it, this makes it possible to load modules on demand.
  • Third-party dependencies are shared . Through the shared dependency mechanism in the module federation, we can easily realize the common dependency code between modules, thus avoiding various problems of the previous external + CDN introduction scheme.

From the above analysis, you can see that module federation almost perfectly solves the problem of module sharing in the past, and can even achieve application-level sharing, thereby achieving the effect of micro-frontends. Next, let's use specific examples to learn how to use the module federation capability in Vite to solve code reuse.

3. Application of Module Federation

The community has provided a relatively mature Vite module federation solution: vite-plugin-federation, which implements a complete module federation capability based on Vite (or Rollup). Next, we implement the module federation application based on it. First, initialize the two Vue scaffolding projects host and remote, and then install the vite-plugin-federation plug-in respectively. The commands are as follows:

npm install @originjs/vite-plugin-federation -D

Then add the following configuration code in the configuration file vite.config.ts:

// 远程模块配置
// remote/vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import federation from "@originjs/vite-plugin-federation";


// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    // 模块联邦配置
    federation({
      name: "remote_app",
      filename: "remoteEntry.js",
      // 导出模块声明
      exposes: {
        "./Button": "./src/components/Button.js",
        "./App": "./src/App.vue",
        "./utils": "./src/utils.ts",
      },
      // 共享依赖声明
      shared: ["vue"],
    }),
  ],
  // 打包配置
  build: {
    target: "esnext",
  },
});


// 本地模块配置
// host/vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import federation from "@originjs/vite-plugin-federation";


export default defineConfig({
  plugins: [
    vue(),
    federation({
      // 远程模块声明
      remotes: {
        remote_app: "http://localhost:3001/assets/remoteEntry.js",
      },
      // 共享依赖声明
      shared: ["vue"],
    }),
  ],
  build: {
    target: "esnext",
  },
});

In the above configuration, we have completed the module export of the remote module and the registration of the remote module in the local module. For the specific implementation of the remote module, you can refer to the code in the Github warehouse . Next, let's focus on how to use the remote module.

First, we need to package the remote module and rely on the execution command under the remote path:

// 打包产物
pnpm run build
// 模拟部署效果,一般会在生产环境将产物上传到 CDN 
npx vite preview --port=3001 --strictPort

Then, we use the remote module in the host project, the sample code is as follows.

<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue";
import { defineAsyncComponent } from "vue";
// 导入远程模块
// 1. 组件
import RemoteApp from "remote_app/App";
// 2. 工具函数
import { add } from "remote_app/utils";
// 3. 异步组件
const AysncRemoteButton = defineAsyncComponent(
  () => import("remote_app/Button")
);
const data: number = add(1, 2);
</script>


<template>
  <div>
    <img alt="Vue logo" src="./assets/logo.png" />
    <HelloWorld />
    <RemoteApp />
    <AysncRemoteButton />
    <p>应用 2 工具函数计算结果: 1 + 2 = {
   
   { data }}</p>
  </div>
</template>


<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Then, after starting the project with npm run dev, you can see the following results.

image.png

The components and utility function logic of application 2 have already taken effect in application 1, that is to say, we have completed the runtime import of the remote module into the local module. Let's sort out the overall usage process:

  1. The remote module registers the exported module through exposes, and the local module registers the remote module address through remotes.
  2. Remote modules are built and deployed to the cloud.
  3. The remote module is introduced locally by importing 'remote module name/xxx' to realize runtime loading.

4. Implementation Principle of Module Federation

From the above examples, you can see that Module Federation is relatively simple to use, and the transformation cost for existing projects is not large. So, how is such a powerful and easy-to-use feature realized in Vite? Next, let's delve into the implementation principle behind MF, and analyze what the vite-plugin-federation plugin does behind it.

Overall, there are three main elements to realize module federation:

  • Host module : It is a local module, used to consume remote modules.
  • Remote module : It is a remote module, which is used to produce some modules and expose the runtime container for consumption by local modules.
  • Shared dependency : Shared dependency is used to share third-party dependencies between local modules and remote modules.

First, let's take a look at how the local module consumes the remote module. Earlier, we wrote import statements like this in local modules.

import RemoteApp from "remote_app/App";

Let's see what Vite compiles this code into.

// 为了方便阅读,以下部分方法的函数名进行了简化
// 远程模块表
const remotesMap = {
  'remote_app':{url:'http://localhost:3001/assets/remoteEntry.js',format:'esm',from:'vite'},
  'shared':{url:'vue',format:'esm',from:'vite'}
};


async function ensure() {
  const remote = remoteMap[remoteId];
  // 做一些初始化逻辑,暂时忽略
  // 返回的是运行时容器
}


async function getRemote(remoteName, componentName) {
  return ensure(remoteName)
    // 从运行时容器里面获取远程模块
    .then(remote => remote.get(componentName))
    .then(factory => factory());
}


// import 语句被编译成了这样
// tip: es2020 产物语法已经支持顶层 await
const __remote_appApp = await getRemote("remote_app" , "./App");

It can be seen that in addition to the import statement being compiled, remoteMap and some utility functions are added to the code. Their purpose is very simple, that is, to pull the module with the corresponding name by accessing the remote runtime container. The runtime container actually refers to the exported object of the remote module packaging product remoteEntry.js. Let's take a look at its logic:

// remoteEntry.js
const moduleMap = {
  "./Button": () => {
    return import('./__federation_expose_Button.js').then(module => () => module)
  },
  "./App": () => {
    dynamicLoadingCss('./__federation_expose_App.css');
    return import('./__federation_expose_App.js').then(module => () => module);
  },
  './utils': () => {
    return import('./__federation_expose_Utils.js').then(module => () => module);
  }
};


// 加载 css
const dynamicLoadingCss = (cssFilePath) => {
  const metaUrl = import.meta.url;
  if (typeof metaUrl == 'undefined') {
    console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
    return
  }
  const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
  const element = document.head.appendChild(document.createElement('link'));
  element.href = curUrl + cssFilePath;
  element.rel = 'stylesheet';
};


// 关键方法,暴露模块
const get =(module) => {
  return moduleMap[module]();
};


const init = () => {
  // 初始化逻辑,用于共享模块,暂时省略
}


export { dynamicLoadingCss, get, init }

From the code of the runtime container we can draw some key information:

  • moduleMap is used to record the information of the exported module, all modules declared in the exposes parameter will be packaged into a separate file, and then imported through dynamic import.
  • The container exports a very critical get method, so that the local module can access the remote module by calling this method.

So far, we have sorted out the interaction process between the runtime container of the remote module and the local module, as shown in the figure below.

image.png

Next, we move on to analyzing the implementation of shared dependencies. Taking the previous sample project as an example, after the local module sets the shared: ['vue'] parameter, when it executes the remote module code, once it encounters the situation of introducing vue, it will give priority to using the local vue instead of the remote vue in the module.

image.png

Let's focus on the logic of container initialization, and return to the logic of the ensure function after the local module is compiled.

// host


// 下面是共享依赖表。每个共享依赖都会单独打包
const shareScope = {
  'vue':{'3.2.31':{get:()=>get('./__federation_shared_vue.js'), loaded:1}}
};
async function ensure(remoteId) {
  const remote = remotesMap[remoteId];
  if (remote.inited) {
    return new Promise(resolve => {
        if (!remote.inited) {
          remote.lib = window[remoteId];
          remote.lib.init(shareScope);
          remote.inited = true;
        }
        resolve(remote.lib);
    });
  }
}

It can be found that the main logic of the ensure function is to pass the shared dependency information to the runtime container of the remote module and initialize the container. Next we enter the logical init of container initialization.

const init =(shareScope) => {
  globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
  // 下面的逻辑大家不用深究,作用很简单,就是将本地模块的`共享模块表`绑定到远程模块的全局 window 对象上
  Object.entries(shareScope).forEach(([key, value]) => {
    const versionKey = Object.keys(value)[0];
    const versionValue = Object.values(value)[0];
    const scope = versionValue.scope || 'default';
    globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
    const shared= globalThis.__federation_shared__[scope];
    (shared[key] = shared[key]||{})[versionKey] = versionValue;
  });
};

When the shared dependency table of the local module can be accessed in the remote module, the dependencies of the local module (such as vue) can also be used in the remote module. Now let's take a look at how the import code of import { h } from 'vue' in the remote module is converted, as shown below.

// __federation_expose_Button.js
import {importShared} from './__federation_fn_import.js'
const { h } = await importShared('vue')

It is not difficult to see that the processing logic of third-party dependent modules is concentrated in the importShared function, let us find out.

// __federation_fn_import.js
const moduleMap= {
  'vue': {
     get:()=>()=>__federation_import('./__federation_shared_vue.js'),
     import:true
   }
};
// 第三方模块缓存
const moduleCache = Object.create(null);
async function importShared(name,shareScope = 'default') {
  return moduleCache[name] ? 
    new Promise((r) => r(moduleCache[name])) : 
    getProviderSharedModule(name, shareScope);
}


async function getProviderSharedModule(name, shareScope) {
  // 从 window 对象中寻找第三方包的包名,如果发现有挂载,则获取本地模块的依赖
  if (xxx) {
    return await getHostDep();
  } else {
    return getConsumerSharedModule(name); 
  }
}


async function getConsumerSharedModule(name , shareScope) {
  if (moduleMap[name]?.import) {
    const module = (await moduleMap[name].get())();
    moduleCache[name] = module;
    return module;
  } else {
    console.error(`consumer config import=false,so cant use callback shared module`);
  }
}

Since the shared dependency information has been mounted when the container is initialized when the remote module is running, the remote module can easily perceive whether the current dependency is a shared dependency. If it is a shared dependency, use the dependent code of the local module, otherwise use the remote module. Its own dependent product code, the schematic diagram is as follows.

image.png

V. Summary

First, I introduced to you the historical solutions to the problem of module reuse, mainly including publishing npm packages, Git Submodule, relying on externalization + CDN import, and Monorepo architecture. I also analyzed their respective advantages and limitations, and then introduced Module The concept of Federation (MF), and analyzed why it can solve the module sharing problem almost perfectly, the main reasons include the realization of arbitrary granularity of module sharing, reducing the size of build products, loading on demand at runtime, and sharing third-party dependencies. aspect.

Next, I will use a specific project example to tell you how to use the feature of module federation in Vite, that is, to complete the construction of MF through the vite-plugin-federation plugin. Finally, I also gave you a detailed introduction to the underlying implementation principles of MF, and analyzed the implementation mechanism and core compilation logic of MF from three perspectives: local modules, remote modules, and shared dependencies.

Guess you like

Origin blog.csdn.net/xiangzhihong8/article/details/131449848