背景
在使用 qiankun 微前端框架时,主子应用通信和路由跳转是两个核心问题。本文档总结了我们在实践中遇到的坑以及最终形成的完善方案。
一、主子应用通信方案
1.1 为什么不用 qiankun 内置的 initGlobalState?
qiankun 提供了 initGlobalState API 用于主子应用通信,但存在以下限制:
- 属性限制:子应用只能修改主应用初始化时定义的属性,新增属性会被拦截
- 与 Pinia 集成困难:无法与 Vue 的状态管理库深度集成
- 灵活性不足:不适合作为企业级项目的基础架构
1.2 自定义通信方案
我们选择基于 Pinia 实现自定义通信方案,核心思路:
- 主应用维护全局状态(Pinia Store)
- 通过 props 向子应用注入通信方法
- 子应用通过这些方法与主应用通信
1.3 踩坑:qiankun 会覆盖同名方法
问题 :qiankun 会自动向子应用 props 注入 setGlobalState 和 onGlobalStateChange 方法,覆盖我们自定义的同名方法。
解决方案:使用独特的命名,避免与 qiankun 内置方法冲突:
| 原名称 | 新名称 |
|---|---|
getGlobalState |
getMainState |
setGlobalState |
setMainState |
onGlobalStateChange |
onMainStateChange |
offGlobalStateChange |
offMainStateChange |
1.4 运行时属性校验
虽然 TypeScript 提供了编译时检查,但为了防止 // @ts-ignore 等绕过方式,我们添加了运行时校验:
typescript
function setGlobalState(partialState: Partial<GlobalState>): void {
// 运行时校验:检查是否有未定义的属性
const validKeys = Object.keys(DEFAULT_STATE);
Object.keys(partialState).forEach((key) => {
if (!validKeys.includes(key)) {
console.warn(
`[GlobalStore] 警告:属性 "${key}" 未在 GlobalState 中定义,` +
`建议先在 types/global.ts 中声明`
);
}
});
// ... 继续执行状态更新
}
1.5 两种数据同步模式
根据业务需求,我们实现了两种同步模式:
实时同步模式(sub-app-1):
- 通过
onMainStateChange注册回调 - 主应用状态变化时自动同步到子应用 Pinia Store
- 适合需要实时响应的场景
按需获取模式(sub-app-2):
- 不注册状态监听
- 需要时调用
getMainState()获取最新数据 - 适合数据更新频率低的场景
二、路由跳转方案
2.1 核心问题:主子应用路由冲突
现象:从子应用跳转到主应用路由后,点击浏览器返回按钮,行为异常。
原因 :子应用和主应用都使用 Vue Router 的 history 模式,两个 router 都会监听 popstate 事件,导致冲突。
2.2 最终方案:子应用使用 memoryHistory + 主应用统一导航
核心思路:
- 子应用使用
createMemoryHistory,不监听浏览器 popstate 事件 - 跨应用导航统一由主应用 router 处理
- 子应用内部路由变化通过
syncRoute同步到浏览器 URL
2.2.1 主应用导航方法
typescript
/**
* 导航到指定路径(由主应用统一处理)
*/
function navigateTo(options: NavigateOptions): void {
if (!mainRouter) {
console.error("[GlobalStore] 主应用路由未初始化");
return;
}
const { path, appName, replace = false } = options;
let targetPath = path;
// 如果指定了子应用名称,拼接完整路径
if (appName) {
const routeConfig = subAppRoutes.get(appName);
if (routeConfig) {
const subPath = path.startsWith("/") ? path : `/${path}`;
targetPath = `${routeConfig.basePath}${subPath}`;
}
}
// 使用主应用 router 进行跳转
if (replace) {
mainRouter.replace(targetPath);
} else {
mainRouter.push(targetPath);
}
}
2.2.2 子应用路由同步
typescript
/**
* 同步子应用内部路由到浏览器 URL
* 仅更新地址栏显示,不触发路由跳转
*/
function syncSubAppRoute(appName: string, subPath: string): void {
const routeConfig = subAppRoutes.get(appName);
if (!routeConfig) return;
const normalizedSubPath = subPath.startsWith("/") ? subPath : `/${subPath}`;
const fullPath =
normalizedSubPath === "/"
? routeConfig.basePath
: `${routeConfig.basePath}${normalizedSubPath}`;
// 使用 replaceState 更新 URL,不产生新的历史记录
window.history.replaceState(null, "", fullPath);
}
2.3 子应用路由映射表
主应用维护子应用路由映射表,子应用只需传递内部路径和应用名称:
typescript
const subAppRoutes = new Map<string, SubAppRouteConfig>([
["sub-app-1", { basePath: "/sub-app-1" }],
["sub-app-2", { basePath: "/sub-app-2" }],
["sub-app-3", { basePath: "/sub-app-3" }],
]);
2.4 使用方式
javascript
// 跳转到主应用路由
globalStore.navigateTo({ path: "/about" });
// 跳转到其他子应用
globalStore.navigateTo({ path: "/", appName: "sub-app-2" });
// 跳转到子应用内部页面
globalStore.navigateTo({ path: "/detail/123", appName: "sub-app-1" });
// 替换历史记录(不产生新的历史条目)
globalStore.navigateTo({ path: "/about", replace: true });
2.5 直接访问子应用深层路由
问题 :直接访问 http://localhost:5173/sub-app-1/about 时,子应用默认从 / 开始。
解决方案 :主应用传递 initialPath,子应用挂载前先跳转到对应路由。
主应用:
javascript
// 从路由参数提取子路径
const subpath = route.params.subpath;
const initialPath = subpath
? "/" + (Array.isArray(subpath) ? subpath.join("/") : subpath)
: "/";
loadMicroApp({
// ...
props: {
initialPath,
// 其他 props...
},
});
子应用:
javascript
function render(props) {
const { initialPath } = props;
router = createRouter({
history: window.__POWERED_BY_QIANKUN__
? createMemoryHistory()
: createWebHistory("/"),
routes,
});
// ... 创建应用实例
// 微前端环境下:注册路由同步(跳过初始路由)
if (window.__POWERED_BY_QIANKUN__) {
let isInitialNavigation = true;
router.afterEach((to) => {
if (isInitialNavigation) {
isInitialNavigation = false;
return;
}
globalStore.syncRoute(to.path);
});
}
// 如果有初始路径,先跳转再挂载
if (window.__POWERED_BY_QIANKUN__ && initialPath && initialPath !== "/") {
router.replace(initialPath).then(() => {
instance.mount(container ? container.querySelector("#app") : "#app");
});
} else {
instance.mount(container ? container.querySelector("#app") : "#app");
}
}
2.6 isInitialNavigation 标志位的位置选择
在实现「跳过初始路由同步」时,isInitialNavigation 标志位的放置位置有三种选择:
| 方案 | 位置 | 特点 |
|---|---|---|
| 模块顶层 | let isInitialNavigation = true 在文件顶部 |
只在子应用首次加载时为 true,切换后再回来不会重置 |
| bootstrap 生命周期 | 在 bootstrap() 中设置 |
与模块顶层行为一致,bootstrap 只执行一次 |
| render 函数内部 | 在 render() 函数内定义 |
每次 mount 都会重新初始化为 true ✅ |
我们选择在 render 函数内部定义标志位,原因:
- 语义正确:「跳过初始路由」的语义是「每次挂载时,跳过第一次路由同步」,而不是「整个应用生命周期只跳过一次」
- 场景覆盖:用户从 sub-app-1 切换到 sub-app-2,再切回 sub-app-1 时,子应用会重新 mount,此时应该再次跳过初始路由
- 逻辑内聚 :
initialPath本身就是通过 props 在 mount 时传入的,标志位放在 render 内部与之呼应
javascript
// ✅ 正确:标志位在 render 函数内部,每次 mount 都会重置
function render(props) {
// ...
if (window.__POWERED_BY_QIANKUN__) {
let isInitialNavigation = true; // 每次 render 都重新初始化
router.afterEach((to) => {
if (isInitialNavigation) {
isInitialNavigation = false;
return;
}
globalStore.syncRoute(to.path);
});
}
}
// ❌ 错误:标志位在模块顶层,子应用切换后再回来不会重置
let isInitialNavigation = true; // 只在模块加载时初始化一次
function render(props) {
// ...
router.afterEach((to) => {
if (isInitialNavigation) {
isInitialNavigation = false;
return;
}
globalStore.syncRoute(to.path);
});
}
三、仪表盘模式(多子应用并行)
3.1 场景说明
仪表盘页面需要同时加载多个子应用,这是 loadMicroApp 相比 registerMicroApps + start 的核心优势。
3.2 dashboardMode 标识
在仪表盘模式下,子应用需要禁用某些功能:
javascript
// 主应用加载子应用时传递 dashboardMode
loadMicroApp({
name: "sub-app-1",
container: "#dashboard-app-1",
props: {
dashboardMode: true, // 关键标识
// 其他通信方法...
},
});
3.3 子应用行为差异
| 功能 | 单实例模式 | 仪表盘模式 |
|---|---|---|
| URL 同步 | ✅ 启用 | ❌ 禁用(避免多子应用互相覆盖) |
| 跨应用导航 | ✅ 启用 | ❌ 禁用(避免离开仪表盘页面) |
| 内部路由 | ✅ 启用 | ✅ 启用 |
| 状态通信 | ✅ 启用 | ✅ 启用 |
3.4 子应用适配代码
javascript
// 子应用根据 dashboardMode 控制行为
router.afterEach((to) => {
// 仪表盘模式下不同步 URL
if (globalStore.dashboardMode) return;
globalStore.syncRoute(to.path);
});
vue
<!-- 仪表盘模式下隐藏跨应用导航按钮 -->
<template>
<div v-if="!globalStore.dashboardMode" class="cross-app-nav">
<button @click="navigateToOtherApp">跳转到其他应用</button>
</div>
</template>
四、完整架构图
scss
┌─────────────────────────────────────────────────────────────┐
│ 主应用 (main-app) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ GlobalStore (Pinia) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ state │ │ subscribers │ │ mainRouter │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ Methods: │ │
│ │ - getMainState() - setMainState() │ │
│ │ - onMainStateChange() - offMainStateChange() │ │
│ │ - navigateTo() - syncRoute() │ │
│ │ - createSubAppMethods() │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ props 注入通信方法 │
│ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ sub-app-1 │ │ sub-app-2 │ │ sub-app-3 │ │
│ │ (实时同步模式) │ │ (按需获取模式) │ │ (实时同步模式) │ │
│ │ memoryHistory │ │ memoryHistory │ │ memoryHistory │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 仪表盘页面 (/dashboard) │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ sub-app-1 │ │ sub-app-3 │ │ │
│ │ │ dashboardMode │ │ dashboardMode │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
五、关键文件清单
主应用
src/types/global.ts- 类型定义src/stores/globalStore.ts- 全局状态管理src/config/microApps.ts- 子应用配置src/main.ts- 应用入口src/views/subApp1/index.vue- 子应用 1 加载组件src/views/subApp2/index.vue- 子应用 2 加载组件src/views/subApp3/index.vue- 子应用 3 加载组件src/views/dashboard/index.vue- 仪表盘页面(多子应用并行)
子应用
src/stores/global.js- 子应用状态管理src/main.js- 应用入口,生命周期钩子
六、注意事项
- 命名规范 :所有传递给子应用的方法都使用
Main前缀,避免 qiankun 覆盖 - 跨应用跳转 :子应用跳转到主应用或其他子应用时,必须使用
navigateTo方法 - 子应用内部跳转:子应用内部页面跳转使用自己的 router,会自动同步 URL
- 状态类型 :新增状态属性需要先在
types/global.ts中声明 - 卸载清理:子应用 unmount 时需要取消状态监听,重置 store
- 返回按钮 :子应用内的"返回"按钮在微前端环境下应使用
router.push("/")而非router.back() - 仪表盘模式 :多子应用并行时必须传递
dashboardMode: true,禁用 URL 同步和跨应用导航
七、总结
通过自定义通信方案和 memoryHistory + 主应用统一导航的路由方案,我们解决了 qiankun 微前端中的核心问题:
- 通信问题:基于 Pinia 的自定义通信,支持实时同步和按需获取两种模式
- 路由问题:子应用使用 memoryHistory 避免 popstate 冲突,跨应用导航由主应用统一处理
- URL 同步:子应用内部路由变化通过 syncRoute 同步到浏览器地址栏
- 深层路由:通过 initialPath 支持直接访问子应用深层路由
- 多子应用并行:通过 dashboardMode 支持仪表盘等多子应用同时加载场景
这套方案已在实践中验证可行,可作为企业级微前端项目的基础架构。