Vite - 模块联邦
现有的模块复用解决方案
发布 npm 包
将一些公共代码封装到一起,发布成一个 npm 包进行模块共享。
npm 包的发布更新流程大概如下:
缺点:
- 开发效率问题:每次改动都需要发版,相关应用需要安装新依赖,流程比较复杂
- 项目构建问题。引入了公共库之后,公共库的代码都需要打包到项目最后的产物中,导致产物体积偏大,构建速度相对较慢。当然,
tree shaking
能解决一些体积的问题
Git Submodule
Submodule
翻译过来就是“子模块”的意思,Git Submodule 就是在一个仓库中嵌套另一个仓库。
特点:
- 相互独立:主仓库和子仓库都有各自的git记录,克隆主仓库时不会克隆子仓库的文件,只是克隆一个快链(gitlink)
- 便于维护:不需要再把子仓库的文件拷贝过来
通过 Git Submodule 的方式来封装公共代码库,然后进行复用,大概的流程如下:
- 公共库 lib1 改动,提交到 Git 远程仓库
- 主应用通过
git
命令更新子仓库内容,然后进行联调
整体流程跟npm
包没有太大的区别。
依赖外部化(external)+ CDN 引入
在Vite
中,先使用external
排除要打包的依赖:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
external: ['react', 'react-dom'],
},
},
});
然后在index.html
中通过<script>
引入 CDN 中的第三方依赖,从而达到模块复用的效果。
<!-- index.html -->
<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>
缺点:
- 兼容性问题。并不是所有的依赖都有
UMD
格式的产物,因此这种方案不能覆盖所有的第三方npm
包 - 依赖顺序问题。我们通常需要考虑间接依赖的问题,如对于
antd
组件库,它本身也依赖了react
和moment
,那么react
和moment
也要进行外部依赖处理,并且在HTML
中引用这些包,同时也要严格保证引用的顺序,比如说moment
如果放在了antd
后面,代码可能无法运行。而第三方包背后的间接依赖数量一般很庞大,如果逐个处理,对于开发者来说简直就是噩梦 - 产物体积问题。因为是全量引入模块代码,就没有办法通过
Tree Shaking
来去除无用代码了,会导致应用的性能有所下降
Monorepo
Monorepo 是一种项目管理方式,将多个项目放在同一个仓库中进行管理,互相依赖的子项目通过软链的方式进行调试,代码复用显得非常方便,如果有依赖的代码变动,那么用到这个依赖的项目当中会立马感知到。
缺点:
- 都在同一个仓库。如果是旧有项目,并且每个应用使用一个 Git 仓库的情况,那么使用 Monorepo 之后项目架构调整会比较大,也就是说改造成本会相对比较高
- 项目构建问题。跟发
npm
包的方案一样,所有的公共代码都需要进入项目的构建流程中,产物体积还是会偏大 - 开发效率问题。Monorepo 本身也存在一些天然的局限性,如项目数量多起来之后依赖安装时间会很久、项目整体构建时间会变长等等,我们也需要去解决这些局限性所带来的的开发效率问题。而这项工作一般需要投入专业的人去解决,如果没有足够的人员投入或者基建的保证,Monorepo 可能并不是一个很好的选择
模块联邦
概述
模块联邦(Module Federation)由Webpack在 2020 年上半年提出。
MF主要有两种模块:本地模块和远程模块。
本地模块就是当前构建流程中的一部分,而远程模块是在本地模块运行时进行导入,本地模块和远程模块之间又可以共享公共的依赖。
在模块联邦中,每个模块既可以是本地模块,也可以是远程模块。
模块联邦的优势如下:
- 任意粒度的模块共享。这里所指的模块粒度可大可小,包括第三方 npm 依赖、业务组件、工具函数,甚至可以是整个前端应用!而整个前端应用能够共享产物,代表着各个应用单独开发、测试、部署,这也是一种微前端的实现
- 优化构建产物体积。远程模块可以在本地模块运行时被拉取,而不用参与本地模块的构建,可以加速构建过程,同时也能减小构建产物
- 运行时按需加载。远程模块导入的粒度可以很小,如果你只想使用
app1
模块的add
函数,只需要在app1
的构建配置中导出这个函数,然后在本地模块中按照诸如import('app1/add')
的方式导入即可,这样就很好地实现了模块按需加载 - 第三方依赖共享。通过模块联邦中的共享依赖机制,我们可以很方便地实现在模块间公用依赖代码,从而避免以往的
external + CDN
引入方案的各种问题
在 Vite 中应用
源码仓库:module-federation
Vite 社区已经提供了一个成熟的解决方案,就是插件 vite-plugin-federation,通过使用该插件来实现模块联邦。
该插件不只是在 Vite 中使用,在Rollup
中也可以使用。与 Webpack Module Federation 兼容,也就是说,不同构建工具实现的模块联邦是可以兼容的,当然也有一些限制,详细的可以看下插件的文档说明。
在需要实现模块联邦的项目中安装插件:
npm install @originjs/vite-plugin-federation --save-dev
使用Vite
创建一个本地模块项目host
,进行如下配置:
// vite.config.ts
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',
},
});
再创建一个远程模块项目remote
,配置如下:
// vite.config.ts
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
vue(),
federation({
name: 'remote-app',
// 模块入口文件
filename: 'remoteEntry.js',
// 导出模块声明
exposes: {
'./Button': './src/components/Button.js',
'./App': './src/App.vue',
'./utils': './src/util.ts',
},
// 共享依赖声明
shared: ['vue'],
}),
],
build: {
target: 'esnext',
},
});
对远程模块所属项目remote
进行模拟部署:
# 打包
npm run build
# 预览导包后的产物,模拟部署
npx vite preview --port=3001 --strictPort
回到本地模块项目host
中,在App.vue
中使用远程模块:
<!-- App.vue -->
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Hello Vue 3 + TypeScript + Vite" />
<RemoteApp />
<RemoteButton />
<p>
<b>使用远程模块的 add 函数计算 1 + 1 = {{ add(1, 1) }}</b>
</p>
</template>
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue';
import { defineAsyncComponent } from 'vue';
// 下面是导入远程模块
import RemoteApp from 'remote-app/App';
import { add } from 'remote-app/utils';
const RemoteButton = defineAsyncComponent(() => import('remote-app/Button'));
</script>
运行host
项目看效果:
模块联邦使用的整体流程如下:
- 远程模块通过
exposes
注册导出的模块,本地模块通过remotes
注册远程模块地址 - 远程模块进行编译打包,部署到服务器
- 本地模块通过
import
导入模块使用,例如:import { add } from 'remote-app/utils'
实现原理
在 Vite 中,模块联邦的功能由插件 vite-plugin-federation 来实现的,下面来看看它的实现原理。
总体而言,实现模块联邦有三大主要要素:
- 本地模块:用来消费远程模块
- 远程模块:用来生产一些模块,并暴露运行时容器供本地模块消费
- 共享依赖:用来在本地模块和远程模块中实现第三方依赖的共享
本地模块和远程模块编译后产生了这些文件:
本地模块项目:
远程模块项目:
本地模块
在本地模块中,导入了远程模块的 App 组件:
// host/src/App.vue
import RemoteApp from 'remote-app/App';
该语句编译后:
// host/dist/assets/index.b72ad8c4.js
// 简化了部分函数名称
// 远程模块表
const remotesMap = {
'remote_app':{url:'http://localhost:3001/assets/remoteEntry.js',format:'esm',from:'vite'},
};
// 该方法会在 ensure 函数中进行调用,进行初始化操作
const wrapShareModule = remoteFrom => {
// remoteFrom 可以是 vite 也可以是 webpack,get 方法获取模块时会进行判断处理
return {
'vue':{'3.2.37':{get:()=>get('./__federation_shared_vue.js', remoteFrom), loaded:1}}
}
};
// 初始化操作,加载远程模块
async function ensure(remoteId) {
const remote = remoteMap[remoteId];
if (!remote.inited) {
// 进行初始化操作
if (scriptTypes.includes(remote.format)) {
// 通过 <script> 加载模块
return new Promise(resolve => {
const callback = () => {
if (!remote.inited) {
// 初始化逻辑,lib 就是远程模块入口
remote.lib = window[remoteId];
// 共享依赖(Vue)
remote.lib.init(wrapShareModule(remote.from));
remote.inited = true;
}
resolve(remote.lib);
};
return loadJS(remote.url, callback);
});
} else if (importTypes.includes(remote.format)) {
// 通过 import 加载模块
return new Promise(resolve => {
const getUrl = typeof remote.url === 'function' ? remote.url : () => Promise.resolve(remote.url);
getUrl().then(url => {
__vitePreload(() => import(/* @vite-ignore */ url),true?[]:void 0).then(lib => {
if (!remote.inited) {
// 初始化逻辑,lib 就是远程模块入口
const shareScope = wrapShareModule(remote.from);
lib.init(shareScope);
remote.lib = lib;
// 共享依赖(Vue)
remote.lib.init(shareScope);
remote.inited = true;
}
resolve(remote.lib);
});
});
})
}
} else {
// 直接返回远程模块
return remote.lib;
}
}
// 获取指定的模块
function getRemote(remoteName, componentName){
return ensure(remoteName)
.then(
remote => remote.get(componentName).then(factory => factory())
);
}
// import 语句被编译后:
const App = await getRemote("remote-app" , "./App");
针对import
语句进行了处理,然后通过一些工具函数来从远程模块中获取需要的模块数据。
远程模块
而远程模块中的代码编译后如下:
// remote/dist/assets/remoteEntry.js
// remoteEntry.js 是在配置文件中声明的模块文件入口
// 模块信息
let moduleMap = {
'./App': () => {
dynamicLoadingCss(['__federation_expose_App.fb379786.css']);
return __federation_import('./__federation_expose_App.93722fe1.js').then(
(module) =>
Object.keys(module).every((item) => exportSet.has(item))
? () => module.default
: () => module
);
},
// Button 和 utils 的处理逻辑跟 App 一样,只是加载文件不同
'./Button': () => { /* ... */ },
'./utils': () => { /* ... */ },
};
// 使用 <link> 加载 css
const dynamicLoadingCss = (cssFilePaths) => { /* ... */ }
// 关键方法,暴露模块
const get = (module) => {
return moduleMap[module]();
};
// 该方法在上面的本地模块中进行初始化时会进行调用
const init = (shareScope) => {
// 初始化逻辑,用于共享模块,例如配置文件中的共享依赖 Vue
}
export { dynamicLoadingCss, get, init }
远程模块会记录好模块信息,提供共享依赖、模块获取等工具函数。
在App.vue
模块中,导入vue
的语句被编译成了这样:
// remote/dist/assets/__federation_expose_App.93722fe1.js
import {importShared} from './__federation_fn_import.js'
const { /* ... */ } = await importShared('vue')
上面代码中,共享依赖vue
通过函数importShared
进行获取了:
// remote/dist/assets/__federation_fn_import.js
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) {
let module = null;
if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
// 获取本地模块共享的依赖,例如 vue
}
if (module) {
moduleCache[name] = module;
return module;
} else {
// 本地模块中没有共享依赖,那就用自己的
return getConsumerSharedModule(name);
}
}
// 共享依赖信息
const moduleMap = {
vue: {
get: () => () => __federation_import('./__federation_shared_vue.js'),
import: true,
},
};
// 当本地模块没有提供对应的共享依赖时,远程模块就使用自己的共享依赖,例如 vue
async function getConsumerSharedModule(name, shareScope) {
if (moduleMap[name]?.import) {
const module = (await moduleMap[name].get())();
moduleCache[name] = module;
return module;
} // ...
}
总结
转载自:https://juejin.cn/post/7147331959650254862