Skip to content

Qiankun 微前端通信与路由方案总结

背景

在使用 qiankun 微前端框架时,主子应用通信和路由跳转是两个核心问题。本文档总结了我们在实践中遇到的坑以及最终形成的完善方案。

一、主子应用通信方案

1.1 为什么不用 qiankun 内置的 initGlobalState

qiankun 提供了 initGlobalState API 用于主子应用通信,但存在以下限制:

  1. 属性限制:子应用只能修改主应用初始化时定义的属性,新增属性会被拦截
  2. 与 Pinia 集成困难:无法与 Vue 的状态管理库深度集成
  3. 灵活性不足:不适合作为企业级项目的基础架构

1.2 自定义通信方案

我们选择基于 Pinia 实现自定义通信方案,核心思路:

  • 主应用维护全局状态(Pinia Store)
  • 通过 props 向子应用注入通信方法
  • 子应用通过这些方法与主应用通信

1.3 踩坑:qiankun 会覆盖同名方法

问题:qiankun 会自动向子应用 props 注入 setGlobalStateonGlobalStateChange 方法,覆盖我们自定义的同名方法。

解决方案:使用独特的命名,避免与 qiankun 内置方法冲突:

原名称新名称
getGlobalStategetMainState
setGlobalStatesetMainState
onGlobalStateChangeonMainStateChange
offGlobalStateChangeoffMainStateChange

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.1.1 深入分析:Vue Router 3 vs 4 的 history.state 处理差异

这个问题的根本原因在于 Vue Router 4 没有对 history.state 做唯一性标记

Vue Router 3.x(有唯一标识):

javascript
// 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(无唯一标识):

typescript
// 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 + 主应用统一导航

核心思路:

  1. 子应用使用 createMemoryHistory,不监听浏览器 popstate 事件
  2. 跨应用导航统一由主应用 router 处理
  3. 子应用内部路由变化通过 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 函数内部定义标志位,原因:

  1. 语义正确:「跳过初始路由」的语义是「每次挂载时,跳过第一次路由同步」,而不是「整个应用生命周期只跳过一次」
  2. 场景覆盖:用户从 sub-app-1 切换到 sub-app-2,再切回 sub-app-1 时,子应用会重新 mount,此时应该再次跳过初始路由
  3. 逻辑内聚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>

四、完整架构图

┌─────────────────────────────────────────────────────────────┐
│                        主应用 (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 页面处理需要考虑两种场景:

  1. 主应用路由未匹配:用户访问不存在的主应用路由
  2. 子应用路由未匹配:用户访问子应用中不存在的路由

5.2 设计原则

  • 微前端环境:子应用检测到未匹配路由时,通知主应用统一处理,跳转到主应用的 404 页面
  • 独立运行:子应用使用自己的 404 页面
  • 用户体验:404 页面显示原始访问路径,支持返回首页和重试功能

5.3 主应用 404 处理

typescript
// 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 检测

子应用通过路由守卫检测未匹配路由,并通知主应用:

javascript
// 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 子应用路由配置

javascript
// 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 路径,在 afterEachsetTimeout 回调中处理。

六、关键文件清单

主应用

  • 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 - 应用入口,生命周期钩子

七、注意事项

  1. 命名规范:所有传递给子应用的方法都使用 Main 前缀,避免 qiankun 覆盖
  2. 跨应用跳转:子应用跳转到主应用或其他子应用时,必须使用 navigateTo 方法
  3. 子应用内部跳转:子应用内部页面跳转使用自己的 router,会自动同步 URL
  4. 状态类型:新增状态属性需要先在 types/global.ts 中声明
  5. 卸载清理:子应用 unmount 时需要取消状态监听,重置 store
  6. 返回按钮:子应用内的"返回"按钮在微前端环境下应使用 router.push("/") 而非 router.back()
  7. 仪表盘模式:多子应用并行时必须传递 dashboardMode: true,禁用 URL 同步和跨应用导航
  8. 404 处理:微前端环境下子应用不注册 404 路由,通过 onRouteNotFound 回调通知主应用统一处理

八、总结

通过自定义通信方案和 memoryHistory + 主应用统一导航的路由方案,我们解决了 qiankun 微前端中的核心问题:

  1. 通信问题:基于 Pinia 的自定义通信,支持实时同步和按需获取两种模式
  2. 路由问题:子应用使用 memoryHistory 避免 popstate 冲突,跨应用导航由主应用统一处理
  3. URL 同步:子应用内部路由变化通过 syncRoute 同步到浏览器地址栏
  4. 深层路由:通过 initialPath 支持直接访问子应用深层路由
  5. 多子应用并行:通过 dashboardMode 支持仪表盘等多子应用同时加载场景
  6. 404 统一处理:微前端环境下由主应用统一处理 404,子应用独立运行时使用自己的 404 页面

这套方案已在实践中验证可行,可作为企业级微前端项目的基础架构。

基于 VitePress 构建