Skip to content
On this page

模块联邦

Module Federation(简称 MF) 这项技术确实很好地解决了多应用模块复用的问题,相比之前的各种解决方案,它的解决方式更加优雅和灵活

应用级模块共享之痛

1. 发布 npm 包

图片.png

  • 开发效率问题。每次改动都需要发版,并所有相关的应用安装新依赖,流程比较复杂。
  • 项目构建问题。引入了公共库之后,公共库的代码都需要打包到项目最后的产物后,导致产物体积偏大,构建速度相对较慢。

2. Git Submodule

通过 git submodule 的方式,我们可以将代码封装成一个公共的 Git 仓库,然后复用到不同的应用中,但也需要经历如下的步骤:

  1. 公共库 lib1 改动,提交到 Git 远程仓库;
  2. 所有的应用通过git submodule命令更新子仓库代码,并进行联调。

你可以看到,整体的流程其实跟发 npm 包相差无几,仍然存在 npm 包方案所存在的各种问题。

3. 依赖外部化(external)+ CDN 引入

<!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/react@17.0.2/index.min.js"><script>
    <script src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/index.min.js"><script>
  </body>
</html>
  1. 兼容性问题。并不是所有的依赖都有 UMD 格式的产物,因此这种方案不能覆盖所有的第三方 npm 包。
  2. 依赖顺序问题。我们通常需要考虑间接依赖的问题,如对于 antd 组件库,它本身也依赖了 react 和 moment,那么reactmoment 也需要 external,并且在 HTML 中引用这些包,同时也要严格保证引用的顺序,比如说moment如果放在了antd后面,代码可能无法运行。而第三方包背后的间接依赖数量一般很庞大,如果逐个处理,对于开发者来说简直就是噩梦。
  3. 产物体积问题。由于依赖包被声明external之后,应用在引用其 CDN 地址时,会全量引用依赖的代码,这种情况下就没有办法通过 Tree Shaking 来去除无用代码了,会导致应用的性能有所下降。

4. Monorepo

图片.png

  1. 所有的应用代码必须放到同一个仓库。如果是旧有项目,并且每个应用使用一个 Git 仓库的情况,那么使用 Monorepo 之后项目架构调整会比较大,也就是说改造成本会相对比较高。
  2. Monorepo 本身也存在一些天然的局限性,如项目数量多起来之后依赖安装时间会很久、项目整体构建时间会变长等等,我们也需要去解决这些局限性所带来的的开发效率问题。而这项工作一般需要投入专业的人去解决,如果没有足够的人员投入或者基建的保证,Monorepo 可能并不是一个很好的选择。
  3. 项目构建问题。跟 发 npm 包的方案一样,所有的公共代码都需要进入项目的构建流程中,产物体积还是会偏大。

MF 核心概念

模块联邦中主要有两种模块: 本地模块远程模块

图片.png

图片.png

  1. 实现任意粒度的模块共享。这里所指的模块粒度可大可小,包括第三方 npm 依赖、业务组件、工具函数,甚至可以是整个前端应用!而整个前端应用能够共享产物,代表着各个应用单独开发、测试、部署,这也是一种微前端的实现。
  2. 优化构建产物体积。远程模块可以从本地模块运行时被拉取,而不用参与本地模块的构建,可以加速构建过程,同时也能减小构建产物。
  3. 运行时按需加载。远程模块导入的粒度可以很小,如果你只想使用 app1 模块的add函数,只需要在 app1 的构建配置中导出这个函数,然后在本地模块中按照诸如import('app1/add')的方式导入即可,这样就很好地实现了模块按需加载。
  4. 第三方依赖共享。通过模块联邦中的共享依赖机制,我们可以很方便地实现在模块间公用依赖代码,从而避免以往的external + CDN 引入方案的各种问题。

MF 应用实战

用插件实现

pnpm install @originjs/vite-plugin-federation -D
  • 远程模块配置
// 远程模块配置
// 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",
  },
});
  • 远程模块打包
// 打包远程模块
// 打包产物
pnpm run build
// 模拟部署效果,一般会在生产环境将产物上传到 CDN
npx vite preview --port=3001 --strictPort
  • 使用
<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>
  1. 远程配置,打包,部署
  2. 本地配置,引入

MF 实现原理

总体而言,实现模块联邦有三大主要的要素:

  1. Host模块: 即本地模块,用来消费远程模块。
  2. Remote模块: 即远程模块,用来生产一些模块,并暴露运行时容器供本地模块消费。
  3. Shared依赖: 即共享依赖,用来在本地模块和远程模块中实现第三方依赖的共享。

模块引入

import RemoteApp from "remote_app/App";

插件会把这段引入编译成

// 为了方便阅读,以下部分方法的函数名进行了简化
// 远程模块表
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");

除了 import 语句被编译之外,在代码中还添加了remoteMap和一些工具函数,它们的目的很简单,就是通过访问远端的运行时容器来拉取对应名称的模块。

而运行时容器其实就是指远程模块打包产物remoteEntry.js的导出对象,我们来看看它的逻辑是怎样的:

// 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 }

图片.png

共享依赖的实现

本地模块设置了shared: ['vue']参数之后,当它执行远程模块代码的时候,一旦遇到了引入vue的情况,会优先使用本地的 vue,而不是远端模块中的vue图片.png

总结

  1. 远程模块打包时,构建 remoteEntry.js(运行时容器)(本地模块通过 export 的函数引入远程模块)

图片.png

  1. 本地模块打包时,将普通的 import 用远程模块导出的 get 方法引入远程模块(或者 dynamicLoadingCss 引入 css)(init 方法可以在 window 上挂载本地共享模块的引入方法,供远程模块使用本地共享模块)

图片.png

图片.png

共享模块可以减少远程模块的构建时间,因为远程模块的共享依赖不用再次打包,在运行时直接从 window 对象上获取本地共享模块。