从落地到"半停用":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、全屏运行的大屏应用。
最初的诉求很典型:
- 用户在 admin 里点一个菜单,希望能无刷新进入大屏,而不是跳到一个新站点;
- 登录态(token)要共享,不能让用户再登一次;
- 两个应用保持独立仓库目录、独立构建部署节奏。
于是自然想到了 qiankun——国内最主流的微前端框架,基于 single-spa,通过 HTML Entry + JS 沙箱实现子应用接入,对技术栈几乎无侵入。
二、主应用侧的实现
2.1 环境变量驱动的微应用注册清单
qiankun 的第一步是 registerMicroApps。我们没有把子应用列表硬编码,而是约定了一个环境变量前缀 VITE_APP_SUB_,启动时动态扫描生成注册清单:
// 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:
# 命名必须以 VITE_APP_SUB_ 开头,client 为子应用项目名称,也是路由父路径
VITE_APP_SUB_client = '//localhost:3010'
这样做的好处:
- 新增子应用零代码改动——加一行环境变量即可;
- 环境隔离天然成立——开发指向
//localhost:3010,生产指向部署域名; name同时充当activeRule,约定"子应用名即路由前缀",即访问/client/**时激活 client 子应用。
2.2 注册与启动
// 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 直传——解决"登录态与上下文共享"
// 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——解决"双向响应式通信"
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 放在主应用布局的内容区:
<!-- 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:
// 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() 重复调用会报错。用一个全局标志位防重:
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 应用装好了依赖:
// client/package.json
"qiankun": "^2.10.16",
"vite-plugin-qiankun": "^1.0.15"
3.2 子应用入口的标准写法
一个 Vite + Vue 3 子应用的入口大致长这样(renderWithQiankun 由 vite-plugin-qiankun 提供):
// 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();
}
三个关键点:
- 双模式渲染:
__POWERED_BY_QIANKUN__判断是否运行在 qiankun 中,独立开发调试时照常mount('#app'); - 挂载点用
container.querySelector:qiankun 会把子应用包在 wrapper div 里传进来,直接mount('#app')在沙箱里可能查到主应用的同名节点; - 路由 base 动态设置:qiankun 下
createWebHistory('/client/'),独立运行时用默认 base。
3.3 base 与部署路径
// 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 时代的通信桥接方案:
// 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 的样式覆盖:
// 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容器全部被注释;.env里VITE_GLOB_APP_OPEN_QIANKUN=false; - 子应用侧:
qiankun和vite-plugin-qiankun依赖还装着,但vite.config.js未启用插件、main.js未导出生命周期——纯独立应用; - 两个应用独立部署(
/admin与/client),主应用通过一个配置子应用地址的环境变量直接 URL 跳转/新窗口打开大屏。
回头看,放弃集成的原因是务实的:
-
场景错配。大屏是全屏、长时间运行、面向监控中心的展示端,用户不会在"后台表单"和"3D 大屏"之间频繁切换。微前端最大的价值——"多个子应用在同一个壳里无缝切换"——在这个业务里几乎用不到。为了一个"无刷新跳转"的体验,维护整套沙箱机制,性价比不高。
-
Vite + qiankun 的持续成本。
vite-plugin-qiankun本质是对 Vite 产物形态的"逆改造",Vite 大版本升级时经常要等社区适配;生产构建还要处理__POWERED_BY_QIANKUN__注入、publicPath 运行时修改等问题。 -
重渲染应用的沙箱风险。Three.js、WebSocket、地图 SDK 这些重资源、长连接的东西跑在 JS 沙箱里,卸载时的内存回收、事件解绑、定时器清理都要小心翼翼;一旦泄漏,主应用跟着遭殃。
-
独立部署的运维优势。大屏应用更新频率和后台完全不同步,独立部署、独立回滚、独立扩容(大屏可以单独扔到大屏机的内网环境)都更简单。
而保留下来的 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。
浙公网安备 33010602011771号