外观
从首屏3s到200ms:Vite模块联邦微前端共享实战
问题背景:多Vue3微前端架构的性能痛点
在中后台团队的实际业务场景中,我们通常会把多个独立迭代的Vue3业务线拆分为独立子应用,通过微前端架构聚合到统一主应用中。在接入模块联邦之前,我们团队的3个Vue3子应用全部独立打包,每个应用都内置了vue、vue-router、pinia以及公共UI组件库,统计发现公共资源重复度超过62%,整体打包后的vendor体积达到1.2MB,首屏加载耗时稳定在3s以上,用户反馈页面加载卡顿。 之前我们尝试过通过externals抽离公共依赖、全局挂载CDN资源的方案,但存在两个核心问题:一是不同子应用依赖的依赖版本不一致时,全局单例的依赖会引发隐式兼容报错;二是公共资源升级后所有子应用都需要重新发版同步,维护成本极高,最终我们选定Vite生态的Module Federation(模块联邦)方案来解决这一痛点。
微前端共享方案选型对比
我们针对市面上主流的微前端资源共享方案做了实测对比,结果如下:
- qiankun+externals方案:需要子应用将公共依赖全局挂载,隔离性差,不同版本依赖容易冲突,无法实现按需动态加载,最终首屏优化幅度仅能达到20%左右。
- 统一CDN公共包方案:版本同步难度高,公共依赖升级后所有子应用必须同步发版,缓存策略难以统一,出现问题排查链路极长。
- Vite Module Federation方案:基于Vite原生的ESM构建能力,支持运行时动态协商依赖版本,既可以实现公共依赖的全局复用,也允许子应用保留独立版本的特殊依赖,天然支持构建时远程模块引用,不需要侵入式修改子应用的业务代码。 最终实测模块联邦方案可以把公共资源复用率提升到90%以上,首屏加载性能优化幅度超过90%,完全符合我们的预期。
实操步骤与核心代码实现
我们基于pnpm Monorepo架构搭建整个项目,使用社区成熟的@originjs/vite-plugin-federation插件实现模块联邦能力,所有代码可以直接在生产环境运行。
第一步:初始化Monorepo工程环境
根目录下创建pnpm-workspace.yaml声明工作区,统一管理主应用和子应用的依赖:
yaml
packages:
- 'packages/*'目录下新建三个业务包:host(主应用)、sub-order(订单子应用)、sub-user(用户管理子应用),统一安装vite、vue、@originjs/vite-plugin-federation等基础依赖。
第二步:主应用Host的模块联邦配置
主应用作为依赖提供者,统一对外暴露所有公共共享依赖,子应用直接引用即可,主应用的vite.config.ts配置代码如下:
typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
// 声明当前应用为联邦提供者
name: 'host',
// 对外暴露的远程模块入口文件名
filename: 'remoteEntry.js',
// 对外共享的公共依赖,所有子应用都可以复用
exposes: {},
shared: {
// 核心框架依赖强制单例,避免多实例冲突
vue: {
singleton: true,
requiredVersion: '^3.3.4'
},
'vue-router': {
singleton: true,
requiredVersion: '^4.2.4'
},
pinia: {
singleton: true,
requiredVersion: '^2.1.6'
},
// 公共UI组件库也统一共享
'element-plus': {
singleton: true
}
}
})
],
build: {
target: 'esnext'
}
})主应用的入口main.ts需要修改为异步加载模式,等待联邦模块初始化完成后再挂载根实例:
typescript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
// 异步初始化联邦模块
import('./bootstrap').then(() => {
const app = createApp(App)
app.use(router).use(createPinia())
app.mount('#app')
})第三步:子应用的模块联邦配置
子应用不需要重复打包公共依赖,直接声明远程引用主应用的共享资源,同时对外暴露自身的业务页面供主应用调用:
typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'sub-order',
// 声明远程模块地址,指向主应用暴露的联邦入口
remotes: {
host: 'http://localhost:5173/assets/remoteEntry.js'
},
// 对外暴露订单列表业务页面
exposes: {
'./OrderList': './src/views/OrderList.vue'
},
// 直接继承主应用的共享依赖,不需要本地打包
shared: ['vue', 'vue-router', 'pinia', 'element-plus']
})
},
build: {
target: 'esnext'
}
})第四步:运行时加载隔离实现
为了避免不同子应用的样式冲突,我们在全局配置中开启CSS命名空间隔离,每个子应用的根元素都挂载独立的唯一类名,配合Vite的CSS Modules能力实现样式隔离,不需要额外引入沙箱方案就可以实现加载隔离效果。
实战避坑总结
我们在上线落地过程中遇到了多个高频问题,总结出3条可复用的避坑经验:
- 不要强制所有依赖开启单例模式:很多同学配置时会直接把shared下所有依赖的singleton设为true,遇到lodash、dayjs这类不同子应用依赖不同大版本的工具库时,会出现方法不存在的隐式报错。修正方法是仅把vue、vue-router这类核心框架依赖开启单例,工具类依赖单独配置singleton为false,允许子应用自行加载对应版本。
- 开发环境不要直接引用localhost远程地址:开发环境下跨域访问remoteEntry.js会触发浏览器CORS拦截,很多同学会直接放开devServer的所有跨域规则,容易引发安全隐患。正确做法是配置federation插件的
devRemoteUrlReplace属性,把远程地址替换为开发环境的相对路径,通过Vite代理转发请求,从根源避免跨域问题。 - 不要将开发依赖放入共享列表:很多新手会把vite、@vitejs/plugin-vue这类构建阶段的开发依赖也加入shared配置列表,导致生产构建生成大量无效chunk,运行时直接报模块找不到的错误。修正方法是配置shared列表时只保留生产环境运行时需要的业务依赖,所有devDependencies中的包全部排除出共享范围。 我们项目上线后统计数据显示,整体公共资源体积从1.2MB降到280KB,首屏加载耗时从3s优化到200ms左右,子应用的构建速度也提升了60%,完全达到了预期的优化目标。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。