为了更清晰地呈现Vite模块联邦的实践过程,本文花费了大量篇幅进行详细阐述,希望各位读者能耐心阅读,相信一定会有所收获。
同时,准备了相应的体验环境以做参考。
引子
这里提及低代码只是为了交代为什么会搞模块联邦,如果只是想了解如何实现Vite的模块联邦可以跳过【引子】
最近公司的低代码项目遇到了一些头疼的问题,表现出来的现象是编辑器加载缓慢和应用打包慢、体积大等问题。
现象一:编辑器加载缓慢
首先说一下编辑器中的页面是通过iframe的方式加载的一个与运行时一样的页面,编辑器的工作区域看上去像是蒙在画布上的一个透明蒙层。
编辑器的工作区域归低代码平台管理,这部分的加载并不慢。主要慢的是通过iframe加载的应用页面,这个页面完全和应用独立运行时一样。
打开开发者工具就可以看到,主要原因是加载的包太多了,请求疯狂等待中。
话不多说,诸君看图。
上面这些密密麻麻的加载文件都是应用注册的组件和其他依赖包,通过上图可以看到光是依赖加载就用了24s。这还是经过vite打包优化后的结果,简直不可接受啊。
现象二:应用打包慢、体积大
当前低代码平台导出的应用主要包含DSL数据、静态资源和渲染器。其中DSL数据和静态资源对打包速度和体积的影响微乎其微,主要的影响因素在于渲染器部分。事实上,这个渲染器就是一个打包后的 Vue 项目。
话不多说,诸君看图。
为什么渲染器的打包速度如此慢呢?根本原因在于应用对许多组件的被动依赖。
所谓被动依赖,是因为不论应用的内容是什么,即使是一个空白的页面,应用依然会加载所有已注册的组件。
问题分析
现在的低代码平台应用结构大致是这样的
从图中可以看出根本原因在于组件总是全量注册和全量加载。随着组件数量的增加,出现了上述问题。
现在不是流行“减负”嘛,那我们可以给Script
模块减减负。
解决这个问题的思路很明确:将组件包从应用中拆离出去,减少导出应用的体积。同时,实现组件的按需加载,即应用页面需要什么组件就加载什么组件,以提高打包速度和编辑器加载速度。
解决这个问题的方案是组件的按需远程加载。这不仅包括远程加载组件,还要确保只加载应用页面所需的组件,以实现更高效的应用打包和编辑器加载。😁
模块联邦
拆解远程组件的方案经过筛选后,确定了模块联邦的方案。
为什么是模块联邦?
模块联邦是一个允许开发人员跨多个 JavaScript
应用程序或微前端共享代码和资源的概念。在传统的 Web 应用程序中,单个页面的所有代码通常包含在单个代码库中。这可能会导致难以维护和扩展的单体应用程序。
通过模块联邦,代码可以被分割成更小的、可独立部署的模块,这些模块可以在需要时按需加载。这使得微前端可以独立开发和部署,从而减少团队之间的协调并缩短开发周期。
模块联邦的核心是基于远程加载 JavaScript
模块的思想。这意味着,不是一次加载单个应用程序的所有代码,而是可以将代码分割成更小的、可独立部署的模块,这些模块可以在需要时按需加载。
模块联邦的远程加载和按需加载,完美的匹配了我们的需求。
vite-plugin-federation
模块联邦的思路看起来挺不错的,但是我们的低代码技术栈采用的是 vue3
和 vite
,而问题在于 vite
并不原生支持模块联邦。好在,社区中提供了一个基于 vite
实现模块联邦的插件——@originjs/vite-plugin-federation。
在使用 @originjs/vite-plugin-federation
时,有几个核心概念需要明确:
- Vite 构建:通过 Vite 对独立项目进行打包,构建资源包。
- Remote:是一个通过 Vite 构建的项目,它会将一些模块或代码暴露给其他使用 Vite 构建的项目消费。
- Host:是一个使用 Vite 构建的项目,它会消费其他项目(Remote)暴露出来的模块或代码。
示例项目我用了Monorepo的形式搭建,项目结构如下:
Remote
这里我们首先配置 Remote 项目,即示例工程中的 remote-ui
项目。在初始化工程后,我们需要先配置一下 vite-plugin-federation
插件。
- 安装插件
pnpm --filter remote-ui add -rD @originjs/vite-plugin-federation
# 如果是非npm安装请运行下面的命令
npm install @originjs/vite-plugin-federation --save-dev
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-ui',
// 作为远程模块的入口文件,非必填,默认为`remoteEntry.js`
filename: 'remoteEntry.js',
// 这里我们暴露出两个vue组件,当然也可以是其他js/ts模块
exposes: {
'./hello-world': './src/components/HelloWorld.vue',
'./i-button': './src/components/IButton.vue',
},
// 本地模块和远程模块共享的依赖。可根据需要调整。
// 本地模块需配置所有使用到的远端模块的依赖;远端模块需要配置对外提供的组件的依赖。
shared: ['vue'],
}),
],
});
到这里Remote端的模块联邦配置就完成了,接下来是 Remote 项目的vite打包配置。
vite.config.ts
打包配置
在vite-plugin-federation
的官方文档中并没有对vite打包的目标进行说明,如果只按照官方文档,你在打包时可能会遇到下面的提示。
这个错误是因为vite-plugin-federation中使用了顶层的
await
,默认的目标环境是['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14']
,然而这些目标环境并不支持在模块的顶层使用await
关键字。目前浏览器对顶层
await
的支持还是可以满足生产的,只要不是兼容特别老的浏览器
这里需要配置一下vite
的打包配置,主要是配置build.target
为esnext
来解决上述报错。
...
// https://vitejs.dev/config/
export default defineConfig({
...
build: {
// 假设有原生动态导入支持,并且将会转译得尽可能小
target: 'esnext',
// 启用混淆,减少模块体积
minify: true,
// 小于4096KB得引用资源将转为Base64,减少额外得HTTP请求
assetsInlineLimit: 4096,
},
// 用于调试时提供服务给 Host 端
preview: {
host: '0.0.0.0',
port: 5001,
},
});
- 打包&预览
pnpm --filter remote-ui build
打包产物如下
这里我们关心的文件有 remoteEntry.js
入口文件,__federation_shared_vue-UTNxSTI4.js
共享依赖文件,以及 __federation_expose_Hello-world-pEgJDYxf.js
暴露模块文件。
接下来启动预览,对外提供导出模块的服务,服务端口为5001
。
pnpm --filter remote-ui preview
到此Remote端的配置就完成了。
Host
这里我们首先配置 Host 项目,即示例工程中的 vue3-host
项目。跟 Remote 项目一项,工程初始化后先安装vite-plugin-federation
插件。
vite.config.ts
配置插件
这里的插件配置和 Remote 项目有所区别,我们的vue3-host
作为一个纯消费项目,所以配置和 Remote 项目有所不同。
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: 'vue3-host',
// 作为本地模块,引用的远端模块入口文件
remotes: {
// 这里的remote-ui会作为Remote项目的入口文件的代理
// 详细配置可查看 https://github.com/originjs/vite-plugin-federation/blob/main/README-zh.md
'remote-ui': 'http://localhost:5001/assets/remoteEntry.js',
},
shared: ['vue'],
}),
],
});
- 使用远程模块
在vue3-host
项目的App.vue
文件中使用remote-ui
暴露的两个组件HelloWorld
和IButton
。
<script setup lang="ts">
import HelloWorld from 'remote-ui/hello-world';
import IButton from 'remote-ui/i-button';
</script>
<template>
<div>
<h1>Vue3-host</h1>
<IButton text="remote button"></IButton>
<HelloWorld></HelloWorld>
</div>
</template>
启动vue3-host
项目后,可以看到引用的 Remote 组件已经被完整的渲染在页面。
动态加载
上面的配置,我们解决了组件的远程加载问题,但是别忘了我们还需要解决组件的按需加载的问题。
为了确保 <component>
在 DSL 中能够成功加载到组件,我们之前的做法是在 vue
中全局注册所有组件。这样一来,<component is="xxx">
总是能够成功渲染。
在main.ts
中全局注册逻辑像下面这样:
import { createApp } from 'vue';
import HelloWorld from 'remote-ui/hello-world';
import IButton from 'remote-ui/i-button';
import App from './App.vue';
const app = createApp(App);
app.component('hello-world', HelloWorld);
app.component('i-button', IButton);
app.mount('#app');
在<IComopnent>
组件中,通过 DSL 的type
字段来确定当前要渲染的组件,<IComponent>
代码如下:
<script setup lang="ts">
defineProps<{
dsl: Record<string, any>;
}>();
</script>
<template>
<component :is="dsl.type"></component>
</template>
我们在页面中使用<IComonent>
效果如下:
<script setup lang="ts">
import IComponent from './components/IComponent.vue';
</script>
<template>
<div>
<h1>Vue3-host</h1>
<IComponent :dsl="{ type: 'i-button' }" />
</div>
</template>
从截图可以看出我们只需要i-button
组件,但是请求的组件除了i-button
还有hello-world
。
OK,既然这条路走的下去,那么按需加载无非就是去掉全局的组件注册,在<IComponent>
组件中,通过type
来确定要加载的远程组件,动态的加载组件。
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
const props = defineProps<{
dsl: Record<string, any>;
}>();
const com = defineAsyncComponent(() => import(`remote-ui/${props.dsl.type}`));
</script>
<template>
<component :is="com"></component>
</template>
然而这时候并没有渲染出i-button
组件。
vite的坑
改造后的 <IComponent>
看起来逻辑一切正常,但事实上页面无法加载远程的 i-button
组件。回头查看控制台就会发现,控制台抛出了异常。
这表明 Vite 不支持 'remote-ui/${props.dsl.type}'
这种写法。
具体原因查看文档
此时,内心简直一万匹艸鲵🐎跑过,简直头大。
查看文档后,尝试了rollup
的插件@rollup/plugin-dynamic-import-vars
,然而并没有什么用。
解决方案
在vite-plugin-federation
的issue
中深入研究了一段时间后,终于发现了其隐藏的用法。我们可以摆脱对import
的依赖,实现对远程模块的动态加载。
vite-plugin-federation
提供了__federation_method_setRemote
、__federation_method_getRemote
和__federation_method_unwrapDefault
方法。
__federation_method_setRemote
:设置远程模块的入口地址。__federation_method_getRemote
:获取远程模块。__federation_method_unwrapDefault
:解析模块抛出内容。
接着,我们对main.ts
和<IComponent>
组件进行了相应的改造。
main.ts
import { createApp } from 'vue';
// 'virtual:__federation__'并不是一个真实导入的模块,这个模块是`vite-plugin-federation`动态导出的
// 所以ts检查不到'virtual:__federation__'会抛出错误,这里我们忽略ts检查即可
// @ts-ignore
import { __federation_method_setRemote } from 'virtual:__federation__';
import App from './App.vue';
__federation_method_setRemote('remote-ui', {
url: () => Promise.resolve('http://localhost:5001/assets/remoteEntry.js'),
format: 'esm',
from: 'vite',
});
const app = createApp(App);
app.mount('#app');
IComponent.vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import {
__federation_method_getRemote,
__federation_method_unwrapDefault,
// @ts-ignore
} from 'virtual:__federation__';
const props = defineProps<{
dsl: Record<string, any>;
}>();
const com = defineAsyncComponent(async () => {
const module = await __federation_method_getRemote(
'remote-ui',
`./${props.dsl.type}`
);
return __federation_method_unwrapDefault(module);
});
</script>
<template>
<component :is="com"></component>
</template>
同时,确保在 vite.config.ts
中保留 federation
插件的配置,因为 virtual:__federation__
是插件动态抛出的一个模块,我们需要维持 vite
中的 federation
插件配置。不过,可以将 remote
选项留空,就像下面这样配置。
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
federation({
name: 'vue3-host-dynamic',
remotes: {},
shared: ['vue'],
}),
],
});
最后启动项目就可以看到我们按需加载的远程组件啦!
最后
🎉🎉🎉 恭喜,你成功地搭建了一个远程加载和按需加载的vite模块联邦。希望这篇文章为你的项目带来更多的可能性和便利。
如果你觉得这篇文章对你在开发中有所帮助,麻烦多点赞评论收藏😊
如果这篇文章对你实现某些业务有所启发,麻烦多点赞评论收藏😊
如果...,麻烦多点赞评论收藏😊
如果大家有其他模块联邦方案,欢迎留言交流哦!