模块联邦
Module Federation
(简称 MF
) 这项技术确实很好地解决了多应用模块复用的问题,相比之前的各种解决方案,它的解决方式更加优雅和灵活
应用级模块共享之痛
1. 发布 npm 包
- 开发效率问题。每次改动都需要发版,并所有相关的应用安装新依赖,流程比较复杂。
- 项目构建问题。引入了公共库之后,公共库的代码都需要打包到项目最后的产物后,导致产物体积偏大,构建速度相对较慢。
2. Git Submodule
通过 git submodule
的方式,我们可以将代码封装成一个公共的 Git 仓库,然后复用到不同的应用中,但也需要经历如下的步骤:
- 公共库 lib1 改动,提交到 Git 远程仓库;
- 所有的应用通过
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>
- 兼容性问题。并不是所有的依赖都有 UMD 格式的产物,因此这种方案不能覆盖所有的第三方 npm 包。
- 依赖顺序问题。我们通常需要考虑间接依赖的问题,如对于 antd 组件库,它本身也依赖了 react 和 moment,那么
react
和moment
也需要external
,并且在 HTML 中引用这些包,同时也要严格保证引用的顺序,比如说moment
如果放在了antd
后面,代码可能无法运行。而第三方包背后的间接依赖数量一般很庞大,如果逐个处理,对于开发者来说简直就是噩梦。 - 产物体积问题。由于依赖包被声明
external
之后,应用在引用其 CDN 地址时,会全量引用依赖的代码,这种情况下就没有办法通过 Tree Shaking 来去除无用代码了,会导致应用的性能有所下降。
4. Monorepo
- 所有的应用代码必须放到同一个仓库。如果是旧有项目,并且每个应用使用一个 Git 仓库的情况,那么使用 Monorepo 之后项目架构调整会比较大,也就是说改造成本会相对比较高。
- Monorepo 本身也存在一些天然的局限性,如项目数量多起来之后依赖安装时间会很久、项目整体构建时间会变长等等,我们也需要去解决这些局限性所带来的的开发效率问题。而这项工作一般需要投入专业的人去解决,如果没有足够的人员投入或者基建的保证,Monorepo 可能并不是一个很好的选择。
- 项目构建问题。跟
发 npm 包
的方案一样,所有的公共代码都需要进入项目的构建流程中,产物体积还是会偏大。
MF 核心概念
模块联邦中主要有两种模块: 本地模块
和远程模块
。
- 实现任意粒度的模块共享。这里所指的模块粒度可大可小,包括第三方 npm 依赖、业务组件、工具函数,甚至可以是整个前端应用!而整个前端应用能够共享产物,代表着各个应用单独开发、测试、部署,这也是一种
微前端
的实现。 - 优化构建产物体积。远程模块可以从本地模块运行时被拉取,而不用参与本地模块的构建,可以加速构建过程,同时也能减小构建产物。
- 运行时按需加载。远程模块导入的粒度可以很小,如果你只想使用 app1 模块的
add
函数,只需要在 app1 的构建配置中导出这个函数,然后在本地模块中按照诸如import('app1/add')
的方式导入即可,这样就很好地实现了模块按需加载。 - 第三方依赖共享。通过模块联邦中的共享依赖机制,我们可以很方便地实现在模块间公用依赖代码,从而避免以往的
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>
- 远程配置,打包,部署
- 本地配置,引入
MF 实现原理
总体而言,实现模块联邦有三大主要的要素:
Host
模块: 即本地模块,用来消费远程模块。Remote
模块: 即远程模块,用来生产一些模块,并暴露运行时容器
供本地模块消费。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 }
共享依赖的实现
本地模块设置了shared: ['vue']
参数之后,当它执行远程模块代码的时候,一旦遇到了引入vue
的情况,会优先使用本地的 vue
,而不是远端模块中的vue
。
总结
- 远程模块打包时,构建 remoteEntry.js(运行时容器)(本地模块通过 export 的函数引入远程模块)
- 本地模块打包时,将普通的 import 用远程模块导出的 get 方法引入远程模块(或者 dynamicLoadingCss 引入 css)(init 方法可以在 window 上挂载本地共享模块的引入方法,供远程模块使用本地共享模块)
共享模块可以减少远程模块的构建时间,因为远程模块的共享依赖不用再次打包,在运行时直接从 window 对象上获取本地共享模块。