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 内置方法冲突:

原名称 新名称
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 + 主应用统一导航

核心思路:

  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>

四、完整架构图

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

六、注意事项

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

七、总结

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

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

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

相关推荐
TEC_INO2 小时前
STM32_11:DMA
java·前端·stm32
韩曙亮2 小时前
【Web APIs】浏览器本地存储 ② ( window.sessionStorage 本地存储常用 API 简介 | 代码示例 )
开发语言·前端·javascript·localstorage·sessionstorage·web apis·浏览器本地存储
郑州光合科技余经理2 小时前
私有化B2B订货系统实战:核心模块设计与代码实现
java·大数据·开发语言·后端·架构·前端框架·php
time_rg2 小时前
深入理解react——2. Concurrent Mode
前端·react.js
0_12 小时前
封装了一个vue版本 Pag组件
前端·javascript·vue.js
Stirner2 小时前
A2UI : 以动态 UI 代替 LLM 文本输出的方案
前端·llm·agent
Code知行合壹2 小时前
Vue.js进阶
前端·javascript·vue.js
大猫和小黄2 小时前
若依从零到部署:前后端分离和微服务版
java·微服务·云原生·架构·前后端分离·若依
我叫唧唧波2 小时前
【微前端】qiankun基础
前端·前端框架