Qiankun 微前端通信与路由方案总结
背景
在使用 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 等绕过方式,我们添加了运行时校验:
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.1.1 深入分析:Vue Router 3 vs 4 的 history.state 处理差异
这个问题的根本原因在于 Vue Router 4 没有对 history.state 做唯一性标记。
Vue Router 3.x(有唯一标识):
// vue-router 3.x - src/util/push-state.js
const Time = window.performance || Date;
function genKey() {
return Time.now().toFixed(3); // ✅ 生成唯一 key
}
function pushState(url, replace) {
const state = { key: genKey() }; // ✅ 每次导航都有唯一标识
history.pushState(state, "", url);
}
// popstate 监听中校验 key
window.addEventListener("popstate", (e) => {
if (e.state && e.state.key) {
setStateKey(e.state.key); // ✅ 通过 key 判断是否是自己的路由
}
});Vue Router 4.x(无唯一标识):
// vue-router 4.x - src/history/html5.ts
function buildState(...): StateEntry {
return {
back,
current,
forward,
replaced,
position: window.history.length - 1,
scroll: computeScroll ? computeScrollPosition() : null,
// ❌ 没有唯一标识!
};
}
// popstate 监听中直接读取 state
window.addEventListener("popstate", ({ state }) => {
const fromState: StateEntry = historyState.value; // ❌ 不判断来源
// 两个 router 都会执行,互相干扰
});冲突原理:
1. 主应用 router 写入 state: { current: '/home' }
2. 子应用 router 写入 state: { current: '/list' }
3. 用户点击返回按钮 → 触发 popstate
4. 主应用 router 读取 state → 可能读到子应用的数据
5. 子应用 router 读取 state → 可能读到主应用的数据
6. 路由状态混乱!2.2 最终方案:子应用使用 memoryHistory + 主应用统一导航
核心思路:
- 子应用使用
createMemoryHistory,不监听浏览器 popstate 事件 - 跨应用导航统一由主应用 router 处理
- 子应用内部路由变化通过
syncRoute同步到浏览器 URL
2.2.1 主应用导航方法
/**
* 导航到指定路径(由主应用统一处理)
*/
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 子应用路由同步
/**
* 同步子应用内部路由到浏览器 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 子应用路由映射表
主应用维护子应用路由映射表,子应用只需传递内部路径和应用名称:
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 使用方式
// 跳转到主应用路由
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,子应用挂载前先跳转到对应路由。
主应用:
// 从路由参数提取子路径
const subpath = route.params.subpath;
const initialPath = subpath
? "/" + (Array.isArray(subpath) ? subpath.join("/") : subpath)
: "/";
loadMicroApp({
// ...
props: {
initialPath,
// 其他 props...
},
});子应用:
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 内部与之呼应
// ✅ 正确:标志位在 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 标识
在仪表盘模式下,子应用需要禁用某些功能:
// 主应用加载子应用时传递 dashboardMode
loadMicroApp({
name: "sub-app-1",
container: "#dashboard-app-1",
props: {
dashboardMode: true, // 关键标识
// 其他通信方法...
},
});3.3 子应用行为差异
| 功能 | 单实例模式 | 仪表盘模式 |
|---|---|---|
| URL 同步 | ✅ 启用 | ❌ 禁用(避免多子应用互相覆盖) |
| 跨应用导航 | ✅ 启用 | ❌ 禁用(避免离开仪表盘页面) |
| 内部路由 | ✅ 启用 | ✅ 启用 |
| 状态通信 | ✅ 启用 | ✅ 启用 |
3.4 子应用适配代码
// 子应用根据 dashboardMode 控制行为
router.afterEach((to) => {
// 仪表盘模式下不同步 URL
if (globalStore.dashboardMode) return;
globalStore.syncRoute(to.path);
});<!-- 仪表盘模式下隐藏跨应用导航按钮 -->
<template>
<div v-if="!globalStore.dashboardMode" class="cross-app-nav">
<button @click="navigateToOtherApp">跳转到其他应用</button>
</div>
</template>四、完整架构图
┌─────────────────────────────────────────────────────────────┐
│ 主应用 (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 │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘五、404 页面统一处理
5.1 场景说明
在微前端架构中,404 页面处理需要考虑两种场景:
- 主应用路由未匹配:用户访问不存在的主应用路由
- 子应用路由未匹配:用户访问子应用中不存在的路由
5.2 设计原则
- 微前端环境:子应用检测到未匹配路由时,通知主应用统一处理,跳转到主应用的 404 页面
- 独立运行:子应用使用自己的 404 页面
- 用户体验:404 页面显示原始访问路径,支持返回首页和重试功能
5.3 主应用 404 处理
// globalStore.ts - 添加 onRouteNotFound 回调
function createSubAppMethods(appName: string): SubAppProps {
return {
// ... 其他方法
onRouteNotFound: (path: string) => {
// 获取子应用的基础路径
const routeConfig = subAppRoutes.get(appName);
const basePath = routeConfig?.basePath || "";
// 拼接完整的原始路径
const fullPath = path.startsWith("/")
? `${basePath}${path}`
: `${basePath}/${path}`;
// 跳转到主应用 404 页面,携带原始路径信息
navigateTo({
path: "/404",
query: { from: fullPath },
});
},
};
}5.4 子应用 404 检测
子应用通过路由守卫检测未匹配路由,并通知主应用:
// router/index.js
export function setupRouterGuards(router, options = {}) {
const { onRouteNotFound, onRouteChange } = options;
let isMounted = false;
let pendingNotFoundPath = null;
router.beforeEach((to, from, next) => {
if (window.__POWERED_BY_QIANKUN__ && onRouteNotFound) {
if (to.matched.length === 0) {
if (isMounted) {
// 已挂载,直接通知主应用处理 404
onRouteNotFound(to.fullPath);
return;
} else {
// 未挂载,记录待处理的 404 路径
pendingNotFoundPath = to.fullPath;
}
}
}
next();
});
router.afterEach((to) => {
if (!isMounted) {
setTimeout(() => {
isMounted = true;
if (
window.__POWERED_BY_QIANKUN__ &&
onRouteNotFound &&
pendingNotFoundPath
) {
onRouteNotFound(pendingNotFoundPath);
pendingNotFoundPath = null;
}
}, 0);
return;
}
if (onRouteChange && to.matched.length > 0) {
onRouteChange(to.path);
}
});
}5.5 子应用路由配置
// main.js
function render(props = {}) {
const { onRouteNotFound } = props;
// 微前端环境:不添加 404 路由,由主应用处理
// 独立运行:添加 404 路由,由子应用自己处理
const finalRoutes = window.__POWERED_BY_QIANKUN__
? routes
: [...routes, notFoundRoute];
router = createRouter({
history: window.__POWERED_BY_QIANKUN__
? createMemoryHistory()
: createWebHistory("/"),
routes: finalRoutes,
});
// 设置路由守卫
setupRouterGuards(router, {
onRouteNotFound,
onRouteChange: window.__POWERED_BY_QIANKUN__
? (path) => globalStore.syncRoute(path)
: undefined,
});
}5.6 pendingNotFoundPath 的作用
问题:子应用初始加载时,路由可能先跳转到 /,再跳转到目标路径。如果目标路径是 404,直接在 beforeEach 中处理会导致闭包捕获错误的路径。
解决方案:使用 pendingNotFoundPath 变量保存待处理的 404 路径,在 afterEach 的 setTimeout 回调中处理。
六、关键文件清单
主应用
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/views/notFound/index.vue- 统一 404 页面
子应用
src/stores/global.js- 子应用状态管理src/router/index.js- 路由配置,包含 404 检测守卫src/views/notFound/index.vue- 独立运行时的 404 页面src/main.js- 应用入口,生命周期钩子
七、注意事项
- 命名规范:所有传递给子应用的方法都使用
Main前缀,避免 qiankun 覆盖 - 跨应用跳转:子应用跳转到主应用或其他子应用时,必须使用
navigateTo方法 - 子应用内部跳转:子应用内部页面跳转使用自己的 router,会自动同步 URL
- 状态类型:新增状态属性需要先在
types/global.ts中声明 - 卸载清理:子应用 unmount 时需要取消状态监听,重置 store
- 返回按钮:子应用内的"返回"按钮在微前端环境下应使用
router.push("/")而非router.back() - 仪表盘模式:多子应用并行时必须传递
dashboardMode: true,禁用 URL 同步和跨应用导航 - 404 处理:微前端环境下子应用不注册 404 路由,通过
onRouteNotFound回调通知主应用统一处理
八、总结
通过自定义通信方案和 memoryHistory + 主应用统一导航的路由方案,我们解决了 qiankun 微前端中的核心问题:
- 通信问题:基于 Pinia 的自定义通信,支持实时同步和按需获取两种模式
- 路由问题:子应用使用 memoryHistory 避免 popstate 冲突,跨应用导航由主应用统一处理
- URL 同步:子应用内部路由变化通过 syncRoute 同步到浏览器地址栏
- 深层路由:通过 initialPath 支持直接访问子应用深层路由
- 多子应用并行:通过 dashboardMode 支持仪表盘等多子应用同时加载场景
- 404 统一处理:微前端环境下由主应用统一处理 404,子应用独立运行时使用自己的 404 页面
这套方案已在实践中验证可行,可作为企业级微前端项目的基础架构。