从落地到"半停用":qiankun 微前端在一个 Vue 3 + Vite 项目中的完整实践与反思

从落地到"半停用":qiankun 微前端在一个 Vue 3 + Vite 项目中的完整实践与反思

本文基于一个真实企业项目------某工业监测管理平台(已对业务信息做脱敏处理)的微前端改造实践整理而成。系统由管理后台(admin)与大屏展示端(client)两个独立 Vue 3 应用组成,曾用 qiankun 集成,后转为独立部署。这篇文章既讲"怎么接的",也讲"为什么后来不用了",希望能给正在选型微前端的团队一些参考。

一、项目背景

系统包含两个独立前端应用:

应用 定位 技术栈
admin 管理后台主应用,基于 JeecgBoot Vue3 二次开发 Vue 3.4 + TypeScript + Vite 5 + Ant Design Vue 4 + Pinia
client 大屏展示应用 Vue 3.2 + JavaScript + Vite 4 + Three.js / 地图 SDK / ECharts

两个应用由不同时期、不同风格的团队开发:admin 是标准的中后台技术栈,client 则是重渲染、重 WebSocket、全屏运行的大屏应用。

最初的诉求很典型:

  1. 用户在 admin 里点一个菜单,希望能无刷新进入大屏,而不是跳到一个新站点;
  2. 登录态(token)要共享,不能让用户再登一次;
  3. 两个应用保持独立仓库目录、独立构建部署节奏。

于是自然想到了 qiankun------国内最主流的微前端框架,基于 single-spa,通过 HTML Entry + JS 沙箱实现子应用接入,对技术栈几乎无侵入。

二、主应用侧的实现

2.1 环境变量驱动的微应用注册清单

qiankun 的第一步是 registerMicroApps。我们没有把子应用列表硬编码,而是约定了一个环境变量前缀 VITE_APP_SUB_,启动时动态扫描生成注册清单:

ts 复制代码
// admin/src/qiankun/apps.ts
const _apps: object[] = [];
for (const key in import.meta.env) {
  if (key.includes('VITE_APP_SUB_')) {
    const name = key.split('VITE_APP_SUB_')[1];
    const obj = {
      name,                                  // 微应用名称,全局唯一
      entry: import.meta.env[key],           // 微应用入口地址
      container: '#content',                 // 挂载节点
      activeRule: name,                      // 激活路由前缀
    };
    _apps.push(obj);
  }
}
export const apps = _apps;

对应的 .env.development

ini 复制代码
# 命名必须以 VITE_APP_SUB_ 开头,client 为子应用项目名称,也是路由父路径
VITE_APP_SUB_client = '//localhost:3010'

这样做的好处:

  • 新增子应用零代码改动------加一行环境变量即可;
  • 环境隔离天然成立 ------开发指向 //localhost:3010,生产指向部署域名;
  • name 同时充当 activeRule,约定"子应用名即路由前缀",即访问 /client/** 时激活 client 子应用。

2.2 注册与启动

ts 复制代码
// admin/src/qiankun/index.ts(节选)
import { registerMicroApps, start, runAfterFirstMounted, addGlobalUncaughtErrorHandler } from 'qiankun';
import { apps } from './apps';
import { getProps, initGlState } from './state';

function genActiveRule(routerPrefix) {
  return (location) => location.pathname.startsWith(routerPrefix);
}

function filterApps() {
  apps.forEach((item) => {
    item.props = getProps();                 // 主应用下发给子应用的数据
    item.activeRule = genActiveRule('/' + item.activeRule);
  });
  return apps;
}

function registerApps() {
  const _apps = filterApps();
  registerMicroApps(_apps, {
    beforeLoad: [(loadApp) => console.log('before load', loadApp)],
    beforeMount: [(mountApp) => console.log('before mount', mountApp)],
    afterMount: [(mountApp) => console.log('after mount', mountApp)],
    afterUnmount: [(unloadApp) => console.log('after unload', unloadApp)],
  });
  runAfterFirstMounted(() => console.log('开启监控'));
  addGlobalUncaughtErrorHandler((event) => console.log(event));
  initGlState();
  start({});
}

export default registerApps;

几个细节值得展开:

activeRule 用函数而非字符串。 genActiveRule 返回一个 location => boolean 的函数,比字符串匹配灵活------后续如果要支持 /client/client/xxx 之外的复杂规则(比如 hash 模式、多前缀),改函数即可。

生命周期钩子是埋点/监控的天然切面。 beforeLoad(资源加载前)、beforeMount/afterMount(挂载前后)、afterUnmount(卸载后)可以用来做加载耗时上报、子应用切换埋点。runAfterFirstMounted 则专门用于首个子应用挂载后开启监控脚本------避免监控脚本在子应用加载前就跑起来,统计到一堆空白时间。

addGlobalUncaughtErrorHandler 兜底。 微前端场景下,子应用的未捕获异常会冒泡到主应用,统一在这里上报,避免大屏里 Three.js 的渲染异常把整个后台搞崩而毫无感知。

2.3 数据通信:props 下发 + 全局状态两条通道

qiankun 的主子通信我们用了两条通道,分别解决不同的问题。

通道一:props 直传------解决"登录态与上下文共享"

ts 复制代码
// admin/src/qiankun/state.ts(节选)
import { initGlobalState } from 'qiankun';
import { store } from '/@/store';
import { router } from '/@/router';
import { getToken } from '/@/utils/auth';

export function getProps() {
  return {
    data: {
      publicPath: '/',
      token: getToken(),   // 登录态
      store,               // 主应用 Pinia 实例
      router,              // 主应用路由实例
    },
  };
}

子应用在 mount(props) 生命周期里直接拿到 token,无需再走一遍 SSO/登录流程;拿到 store 和 router 实例,则可以做一些深度联动(比如大屏里点击设备跳回后台对应详情页)。

注意:直接传 store/router 实例是"强耦合"方案,要求子应用与主应用的 Pinia / Vue Router 版本兼容。这在"同一团队维护的两个应用"里可接受,但如果你追求子应用完全技术栈无关,应该只传纯数据。

通道二:initGlobalState------解决"双向响应式通信"

ts 复制代码
export function initGlState(info = { userName: 'admin' }) {
  const actions = initGlobalState(info);
  actions.setGlobalState(info);
  actions.onGlobalStateChange((newState, prev) => {
    console.info('newState', newState);
    console.info('prev', prev);
  });
  return actions;
}

qiankun 内置的 GlobalState 是一个观察者模式的全局状态池:主应用 setGlobalState,子应用通过 props 里的 onGlobalStateChange 监听;反过来子应用也可以 setGlobalState 通知主应用。适合做主题切换、用户信息变更这类低频、双向的信号同步。

2.4 挂载容器:藏在布局组件里的 #content

子应用挂载点 #content 放在主应用布局的内容区:

vue 复制代码
<!-- admin/src/layouts/default/content/index.vue -->
<template>
  <div :class="[prefixCls, getLayoutContentMode]" v-loading="getOpenPageLoading && getPageLoading">
    <PageLayout />
    <!-- qiankun 挂载子应用盒子 -->
    <!-- <div id="content" class="app-view-box" v-if="openQianKun == 'true'"></div> -->
  </div>
</template>

同时配合 JeecgBoot 的动态路由机制,在后端菜单里把某个菜单项的 component 配置为 LayoutsContent

ts 复制代码
// admin/src/router/helper/routeHelper.ts
LayoutMap.set('LAYOUT', LAYOUT);
LayoutMap.set('IFRAME', IFRAME);
// 微前端 qiankun
LayoutMap.set('LayoutsContent', LayoutContent);

这样"进入大屏"就变成了一个正常的菜单路由,点击菜单 → URL 变为 /client/... → qiankun 的 activeRule 命中 → 子应用挂载到 #content。整个体验是"后台里的一个页面",而不是"跳去了另一个网站"。

2.5 防重复启动:window.qiankunStarted

启动代码放在布局组件的 onMounted 里,而布局组件可能因路由/权限刷新被多次挂载,start() 重复调用会报错。用一个全局标志位防重:

ts 复制代码
onMounted(() => {
  if (openQianKun == 'true') {
    if (!window.qiankunStarted) {
      window.qiankunStarted = true;
      registerApps();
    }
  }
});

这是 qiankun + Vue 项目的一个经典坑:注册和启动必须且只能执行一次 。更优雅的做法是放到 main.ts 的初始化阶段,但放在布局 onMounted 里可以确保挂载容器已存在,各有取舍。

三、子应用侧:Vite 是最大的坑

3.1 为什么 Vite 项目接 qiankun 特别麻烦

qiankun(底层 import-html-entry)的工作方式是:抓取子应用 entry 的 HTML → 解析出内联/外链的 script 和 style → 用 eval(在沙箱上下文中)执行脚本,从而拿到子应用导出的 bootstrap / mount / unmount 生命周期。

问题来了:Vite 开发模式的产物是原生 ESM<script type="module">),原生 import 语句由浏览器直接加载,不经过任何可被沙箱劫持的环节------qiankun 的 JS 沙箱根本拦不住它。所以 Vite 项目接 qiankun 必须借助社区方案 vite-plugin-qiankun

  • 它把开发模式的产物改造成 UMD 风格输出,并暴露生命周期钩子;
  • 它提供 renderWithQiankun / qiankunWindow 等工具,让子应用感知自己运行在 qiankun 环境中。

我们的 client 应用装好了依赖:

json 复制代码
// client/package.json
"qiankun": "^2.10.16",
"vite-plugin-qiankun": "^1.0.15"

3.2 子应用入口的标准写法

一个 Vite + Vue 3 子应用的入口大致长这样(renderWithQiankunvite-plugin-qiankun 提供):

js 复制代码
// client/src/main.js(示意:启用 qiankun 时的写法)
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/es/helper';

let app;

function render(props = {}) {
  const { container } = props;
  app = createApp(App)
    .use(router)
    .use(createPinia())
    .mount(container ? container.querySelector('#app') : '#app');
}

renderWithQiankun({
  bootstrap() {},
  mount(props) {
    render(props);                    // 挂载到 qiankun 传入的容器
    // props.data.token ------ 主应用下发的登录态
    // props.onGlobalStateChange ------ 全局状态监听
  },
  unmount() {
    app && app.unmount();
    app = null;
  },
  update() {},
});

// 独立运行时(直接访问 localhost:3010)
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render();
}

三个关键点:

  1. 双模式渲染__POWERED_BY_QIANKUN__ 判断是否运行在 qiankun 中,独立开发调试时照常 mount('#app')
  2. 挂载点用 container.querySelector :qiankun 会把子应用包在 wrapper div 里传进来,直接 mount('#app') 在沙箱里可能查到主应用的同名节点;
  3. 路由 base 动态设置 :qiankun 下 createWebHistory('/client/'),独立运行时用默认 base。

3.3 base 与部署路径

js 复制代码
// client/vite.config.js
export default defineConfig({
  // 这里的改造是为了兼容 qiankun
  base: '/client/',  // 动态改变 base 值
  server: {
    port: '3010',
    cors: true,      // 开发期允许主应用跨域抓取 entry HTML
    // ...
  },
});

base: '/client/' 一举两得:独立部署时静态资源路径正确;作为子应用时与主应用的 activeRule/client)对齐。cors: true 则是开发联调的必需品------主应用(3100 端口)要能 fetch 到子应用(3010 端口)的 entry。

3.4 历史遗留:Vuex 时代的全局状态桥接

client 里还保留了一个 registerGlobalModule.js,是 Vuex 时代的通信桥接方案:

js 复制代码
// client/src/utils/registerGlobalModule.js(节选)
export default function registerGlobalModule(store, props = {}) {
  const initState = (props.getGlobalState && props.getGlobalState()) || { user: {} };
  if (!store.hasModule('global')) {
    const globalModule = {
      namespaced: true,
      state: initState,
      mutations: {
        setGlobalState(state, payload) {
          state = Object.assign(state, payload);
          if (props.setGlobalState) {
            props.setGlobalState(state);   // 通知父应用
          }
        },
      },
      // ...
    };
    store.registerModule('global', globalModule);
  } else {
    store.dispatch('global/initGlobalState', initState);  // 每次 mount 同步一次父应用数据
  }
}

思路是把主应用下发的全局状态注册为子应用 Vuex 的一个 global 模块,子应用改状态时反向 props.setGlobalState 通知主应用。后来 client 迁移到了 Pinia,这段代码就成了化石------但它记录了一个真实的演进过程:微前端的通信方案要跟着状态管理库的升级而重写,这也是维护成本的一部分。

四、样式与布局的暗坑

大屏应用是全屏运行的,但 qiankun 把子应用挂在了主应用布局的内容区里------顶栏、侧边栏还在,大屏就"小"了。解决方案是一个针对 qiankun wrapper 的样式覆盖:

less 复制代码
// admin/src/App.vue
// 客户端子应用
#__qiankun_microapp_wrapper_for_client__ {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 9999;
}

__qiankun_microapp_wrapper_for_client__ 是 qiankun 为 client 子应用生成的容器 wrapper id。直接在主应用里把它 fixed 全屏 + 最高层级,大屏就盖住了整个后台布局。

这个 hack 简单有效,但也暴露了问题:子应用的渲染形态(全屏大屏)与主应用的布局模型(中后台框架)本质上是冲突的。微前端最擅长的"子应用嵌在后台内容区里"的场景,对这个项目恰恰不成立。

五、现状:为什么最终"半停用"了

如今这套代码的状态是:

  • 主应用侧:admin/src/qiankun/ 注册代码完整保留,但布局组件里的调用处和 #content 容器全部被注释.envVITE_GLOB_APP_OPEN_QIANKUN=false
  • 子应用侧:qiankunvite-plugin-qiankun 依赖还装着,但 vite.config.js 未启用插件、main.js 未导出生命周期------纯独立应用
  • 两个应用独立部署(/admin/client),主应用通过一个配置子应用地址的环境变量直接 URL 跳转/新窗口打开大屏。

回头看,放弃集成的原因是务实的:

  1. 场景错配。大屏是全屏、长时间运行、面向监控中心的展示端,用户不会在"后台表单"和"3D 大屏"之间频繁切换。微前端最大的价值------"多个子应用在同一个壳里无缝切换"------在这个业务里几乎用不到。为了一个"无刷新跳转"的体验,维护整套沙箱机制,性价比不高。

  2. Vite + qiankun 的持续成本vite-plugin-qiankun 本质是对 Vite 产物形态的"逆改造",Vite 大版本升级时经常要等社区适配;生产构建还要处理 __POWERED_BY_QIANKUN__ 注入、publicPath 运行时修改等问题。

  3. 重渲染应用的沙箱风险。Three.js、WebSocket、地图 SDK 这些重资源、长连接的东西跑在 JS 沙箱里,卸载时的内存回收、事件解绑、定时器清理都要小心翼翼;一旦泄漏,主应用跟着遭殃。

  4. 独立部署的运维优势。大屏应用更新频率和后台完全不同步,独立部署、独立回滚、独立扩容(大屏可以单独扔到大屏机的内网环境)都更简单。

而保留下来的 qiankun/ 目录和被注释的调用代码,则是一种低成本的"可回退"策略------业务哪天真的需要"后台内嵌大屏"了,放开注释、启用插件就能快速恢复。

六、总结与反思

这次实践给我的几点启发:

1. 微前端是组织架构问题的技术解,先确认你有这个问题。 多团队、多技术栈、需要统一门户频繁切换------这才是微前端的主场。如果只是"两个页面想共享登录态",SSO + Cookie + URL 跳转可能才是正解。

2. qiankun 的接入成本主要在子应用的构建体系,而非 API。 registerMicroApps 十分钟就能跑通,但 Vite 子应用的产物改造、publicPath、路由 base、样式隔离、卸载清理,每一个都是需要踩坑的细节。Webpack 项目接 qiankun 的成本显著低于 Vite 项目。

3. 通信设计要匹配耦合度。 我们同时用了 props 直传(强耦合、传实例)和 GlobalState(松耦合、传数据),前者开发效率高但绑定版本,后者通用但啰嗦。没有银弹,按需选择。

4. "半停用"不是失败,是架构演进的中间态。 保留完整可恢复的集成代码 + 独立部署的运行形态,用环境变量(VITE_GLOB_APP_OPEN_QIANKUN)做开关------这本身就是一种务实的架构决策:用最低的成本保留未来的可能性

如果你正在做类似选型,我的建议是:先把"是否真的需要子应用在主应用内渲染"这个问题回答清楚。答案是"是",qiankun 依然是成熟可靠的选择(新项目也可以关注基于 ESM 的 wujie / micro-app / Module Federation 方案);答案是"否",那么 SSO + 独立部署 + URL 跳转,可能就是最好的"微前端"。


本文代码均来自真实项目(已做适当节选与脱敏处理),环境:qiankun 2.10 / Vue 3 / Vite 4-5 / JeecgBoot Vue3。