跳到主内容

从首屏3s到200ms:Vite模块联邦微前端共享实战 ​

问题背景:多Vue3微前端架构的性能痛点 ​

在中后台团队的实际业务场景中,我们通常会把多个独立迭代的Vue3业务线拆分为独立子应用,通过微前端架构聚合到统一主应用中。在接入模块联邦之前,我们团队的3个Vue3子应用全部独立打包,每个应用都内置了vue、vue-router、pinia以及公共UI组件库,统计发现公共资源重复度超过62%,整体打包后的vendor体积达到1.2MB,首屏加载耗时稳定在3s以上,用户反馈页面加载卡顿。 之前我们尝试过通过externals抽离公共依赖、全局挂载CDN资源的方案,但存在两个核心问题:一是不同子应用依赖的依赖版本不一致时,全局单例的依赖会引发隐式兼容报错;二是公共资源升级后所有子应用都需要重新发版同步,维护成本极高,最终我们选定Vite生态的Module Federation(模块联邦)方案来解决这一痛点。

微前端共享方案选型对比 ​

我们针对市面上主流的微前端资源共享方案做了实测对比,结果如下:

  1. qiankun+externals方案:需要子应用将公共依赖全局挂载,隔离性差,不同版本依赖容易冲突,无法实现按需动态加载,最终首屏优化幅度仅能达到20%左右。
  2. 统一CDN公共包方案:版本同步难度高,公共依赖升级后所有子应用必须同步发版,缓存策略难以统一,出现问题排查链路极长。
  3. 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条可复用的避坑经验:

  1. 不要强制所有依赖开启单例模式:很多同学配置时会直接把shared下所有依赖的singleton设为true,遇到lodash、dayjs这类不同子应用依赖不同大版本的工具库时,会出现方法不存在的隐式报错。修正方法是仅把vue、vue-router这类核心框架依赖开启单例,工具类依赖单独配置singleton为false,允许子应用自行加载对应版本。
  2. 开发环境不要直接引用localhost远程地址:开发环境下跨域访问remoteEntry.js会触发浏览器CORS拦截,很多同学会直接放开devServer的所有跨域规则,容易引发安全隐患。正确做法是配置federation插件的devRemoteUrlReplace属性,把远程地址替换为开发环境的相对路径,通过Vite代理转发请求,从根源避免跨域问题。
  3. 不要将开发依赖放入共享列表:很多新手会把vite、@vitejs/plugin-vue这类构建阶段的开发依赖也加入shared配置列表,导致生产构建生成大量无效chunk,运行时直接报模块找不到的错误。修正方法是配置shared列表时只保留生产环境运行时需要的业务依赖,所有devDependencies中的包全部排除出共享范围。 我们项目上线后统计数据显示,整体公共资源体积从1.2MB降到280KB,首屏加载耗时从3s优化到200ms左右,子应用的构建速度也提升了60%,完全达到了预期的优化目标。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。