☰
qiankun微前端路由跳转实战:主应用与子应用间跳转方案与坑点
2026/10/1 9:24:32 网站建设 项目流程

做微前端改造这一年多,我遇到最多的问题不是子应用加载不出来,也不是样式隔离失效,而是看起来最不起眼的页面跳转。明明单应用里一行router.push就搞定的事,到了qiankun微前端架构下却要纠结半天:到底应该用哪个路由实例?子应用里能不能直接改window.location?从A子应用跳B子应用,参数怎么带才优雅?

这篇文章就把qiankun里主应用与子应用、子应用与子应用之间互相跳转的几种主流方式,从原理到实践完整过一遍。我会用自己的真实项目代码来讲,包括每个方案的适用场景、潜在坑点,以及我踩过之后总结的排查思路。不管你是刚接触微前端,还是已经在做qiankun迁移,这篇应该都能帮你省不少时间。

1. qiankun里"跳转"为何让人头大:场景与设计思路

1.1 先拆解:微前端里的跳转到底有哪几种

很多人一开始接触qiankun时会有一个错觉:微前端就是把几个应用拼在一起,每个应用内部的路由各自为战。但实际上,只要涉及页面流转,跳转问题就会立刻浮出水面。

我在项目中把qiankun的跳转场景拆成了四类,这四类基本覆盖了日常开发的全部需求:

  • 主应用 → 子应用:用户在主应用首页点击入口,进入某个子应用的某个页面。
  • 子应用 → 主应用:用户在子应用内完成操作后,返回主应用的个人中心或首页。
  • 子应用 A → 子应用 B:比如用户在某子应用里点了一个链接,需要跳转到另一个子应用的对应页面。
  • 子应用内部跳转:这个其实和单应用没有区别,用子应用自己的路由实例即可,不需要额外处理。

前三种才是微前端架构下的"特殊场景",也是最容易写错的。第四种基本不涉及架构层面的问题,我在实际开发中通常不做特殊处理。

这里有一个很重要的认知:qiankun本身并不提供路由能力,它只是一个应用加载器。所有跨应用的跳转,本质上都是在"如何让URL发生变化,并且让对应的子应用被正确激活"这件事上做文章。理解了这一点,后续的代码就好理解了。

1.2 路由模式选型:hash还是history,先把这个定下来

在聊跳转之前,必须先把路由模式定下来。因为这直接决定了你跳转时写的是/app1/detail还是#/app1/detail,更重要的是,它会影响qiankun的activeRule匹配规则。

我自己的经验是:主应用建议用history模式,子应用跟着主应用的模式走。原因有几点:

  • history模式URL更干净,便于后续做埋点、分享和seo(虽然微前端场景下seo一般不是重点)。
  • qiankun官方文档的示例大多基于history模式,遇到问题更容易找到参考。
  • hash模式虽然部署简单,但在qiankun里,子应用之间的跳转往往要借助主应用路由,而主应用的路由如果是history,子应用却用hash,跳转时很容易出现URL重复堆叠#/#/的情况,维护成本更高。

举一个具体例子:如果主应用是history模式,子应用用的也是history模式,那么从主应用跳转到子应用的URL是https://example.com/app1/detail。如果子应用用的是hash模式,那同样的跳转,URL会变成https://example.com/app1/detail#/detail——子应用内部的路由被塞到了hash里。这在单一子应用时问题不大,可一旦有多个子应用,主应用的路由配置、qiankun的匹配规则、子应用的base配置都会变得混乱。

所以,我在新项目里一律统一使用history模式。如果因为历史原因子应用已经用了hash模式,那至少在qiankun的activeRule上要单独做兼容,后文会提到。

1.3 设计原则:谁负责路由,谁负责状态

在写跳转代码之前,我建议你先在团队里定一条规矩:路由跳转统一由主应用的路由实例发起,子应用不直接修改URL。

为什么?因为qiankun的架构决定了,子应用是挂载在主应用页面里的,子应用认为的"根路径",其实是主应用URL的一部分。如果子应用内部直接用window.location.href去改URL,等于绕过了框架的路由机制,会导致主应用的路由状态不同步,严重时直接白屏。

我在项目里遵循的原则是:

  • 所有跨应用的跳转,由主应用路由实例来驱动。子应用通过props拿到主应用传入的跳转方法,调用该方法实现跳转。
  • 子应用内部跳转,使用子应用自己的路由实例。这部分不影响全局URL,也不涉及其他应用。
  • 需要在跳转时传递的数据,优先通过URL query参数传递,少量敏感数据通过全局状态或缓存处理,避免把所有东西都塞进URL。

后面所有方案,都是围绕这个原则展开的。

2. 动手前的前置准备:通信基础设施搭建

2.1 主应用注册子应用时,把"跳转能力"通过props下发

qiankun的子应用加载,核心就是主应用里调用registerMicroApps注册子应用列表。很多同学在这里只传了name、entry、container、activeRule,忽略了props这个关键配置。实际上,props是把主应用能力传递给子应用的唯一官方通道。

我在主应用里的注册代码大致长这样:

// 主应用 main.js import { registerMicroApps, initGlobalState, start } from 'qiankun'; import router from './router'; const apps = [ { name: 'app-order', entry: '//localhost:8081', container: '#subapp-viewport', activeRule: '/order' }, { name: 'app-goods', entry: '//localhost:8082', container: '#subapp-viewport', activeRule: '/goods' } ]; registerMicroApps( apps.map((app) => ({ ...app, props: { mainRouter: router, token: localStorage.getItem('token'), userInfo: store.state.userInfo } })) ); start();

注意这里的mainRouter,我把主应用的Vue Router实例直接传给了子应用。这样,子应用内任何地方,只要通过props.mainRouter.push(...)就能发起一次主应用级的路由跳转。后文所有跨应用的跳转方案,都是建立在这个props传递基础上的。

有一个细节:props在子应用每次重新挂载时都会被重新注入。也就是说,如果主应用里token变了、用户信息变了,下一次进入子应用时props里的值会跟着变。但如果在子应用已经挂载的过程中主应用token变了,props并不会自动更新。要处理这种实时同步的场景,就需要配合后面要说的initGlobalState来做了。

2.2 全局状态globalState的正确用法

initGlobalState是qiankun官方提供的全局状态管理器,它解决的问题是"跨应用的数据同步",本质上是一个轻量级的状态订阅发布机制。

它和路由跳转是什么关系呢?两种场景会用到:

  • 需要跳转,但目标地址依赖主应用实时数据。比如从子应用A跳转到子应用B,B页面需要知道当前用户权限才能决定渲染哪些内容,这时候跳转前先把权限数据同步到globalState,再触发跳转。
  • 跳转后需要通知目标子应用执行某些操作。比如从A跳转到B时,B需要自动加载某个详情数据,但数据源却在子应用A手里,这时可以通过globalState把参数带过去,B在挂载后监听状态变化即可。

我在主应用里初始化globalState时,会默认放一个schema版本号和redirect跳转指令位:

// 主应用 const actions = initGlobalState({ schemaVersion: '1.0.0', redirect: null // 用于子应用之间跳转的状态位 }); // 主应用监听全局状态变化 actions.onGlobalStateChange((state, prev) => { console.log('全局状态变更:', state, prev); });

这里把redirect设计成一个"约定字段",专门用于子应用间跳转。后面到子应用跳转子应用的章节,我会详细展开这个用法。

2.3 子应用入口的生命周期改造

qiankun要求子应用导出bootstrap、mount、unmount三个生命周期钩子。在跳转方案里,关键是mount阶段把props保存下来。

以Vue 3子应用为例:

// 子应用 main.js import { createApp } from 'vue'; import { createRouter, createWebHistory } from 'vue-router'; import App from './App.vue'; import routes from './routes'; let app = null; let router = null; let mainRouter = null; // 主应用路由实例 // 独立运行时直接启动 if (!window.__POWERED_BY_QIANKUN__) { router = createRouter({ history: createWebHistory(), routes }); app = createApp(App); app.use(router); app.mount('#app'); } // 微前端环境下导出生命周期 export async function bootstrap() { console.log('子应用 bootstrap'); } export async function mount(props) { mainRouter = props.mainRouter; router = createRouter({ history: createWebHistory( window.__POWERED_BY_QIANKUN__ ? '/order' : '/' ), routes }); app = createApp(App); app.use(router); app.mount('#app'); } export async function unmount() { app.unmount(); app = null; router = null; }

注意这段代码里createWebHistory的base参数:window.__POWERED_BY_QIANKUN__ ? '/order' : '/'。这行代码很关键,它让子应用在qiankun环境下,把/order当作基础路径,从而匹配到主应用URL中/order之后的部分。如果这里不配,子应用内部路由就匹配不上,跳转后很容易白屏。

mainRouter保存在mount级别的变量里,子应用任意页面组件中都可以通过一个工具函数获取,比如:

// 子应用 utils/navigation.js let mainRouter = null; export function setMainRouter(router) { mainRouter = router; } export function getMainRouter() { return mainRouter; }

然后在mount里调用setMainRouter(props.mainRouter),页面组件里直接getMainRouter().push('/goods/detail')即可。这样避免了在每个组件里都从props取一遍跳转方法的麻烦。

3. 主应用与子应用之间的双向跳转实战

3.1 主应用跳转子应用:最简单也最容易被忽略的细节

主应用跳转子应用,本质就是一次普通的路由跳转。因为qiankun的activeRule会自动根据URL变化来加载或卸载子应用。

比如我要从主应用的Dashboard页面跳转到订单子应用的列表页,直接写:

// 主应用 任意组件 this.$router.push('/order/list');

子应用的activeRule是/order,URL变为/order/list后,qiankun会自动激活app-order,并加载对应的子应用入口,子应用内部路由匹配到/list,渲染列表页。

这个过程看似简单,但我实际开发中遇到过几个坑:

坑一:activeRule的匹配粒度。如果主应用有多个模块,activeRule写成/order,那么/order123、/order/detail都能匹配。前者可能是你不想让子应用接管的路由,这就要把activeRule写得更精确,比如加一个activeRule: (location) => location.pathname.startsWith('/order'),并配合exact匹配逻辑。

坑二:qiankun的prefetch和singular配置。如果主应用里有多个子应用在跑,跳转时可能遇到子应用加载慢导致的短暂白屏。prefetch虽然能提前加载资源,但冷启动首次跳转总归有等待时间。我通常会在跳转前加一个全局loading,而不是等子应用挂载完成。这个优化和跳转本身关系不大,但直接影响用户体验。

坑三:主应用里嵌套路由。如果主应用的路由是嵌套结构,跳转时$router.push的路径要写完整路径,不能只写相对路径。比如子应用挂载点是在/dashboard布局下的,要跳转/order/list,就不要写成../order/list这种相对写法,Vue Router在嵌套路由里会解析混乱,直接导致找不到匹配项。

3.2 子应用跳回主应用:props下发的router是首选

子应用跳回主应用,正确的做法是用主应用传下来的mainRouter。

比如子应用订单页有个"返回首页"按钮:

// 子应用 组件内 import { getMainRouter } from '@/utils/navigation'; function goHome() { getMainRouter().push('/'); }

这里会有一个疑问:直接window.location.href = '/';不也可以吗?

从结果上看,URL确实变了,主应用也重新加载了。但问题是,这种方式等于整个页面刷新,子应用的状态全部丢失,主应用的状态也全部丢失,而且多了一次完整的浏览器导航,性能差很多。如果项目里用了keep-alive或者全局状态管理,整页刷新带来的损失更大。所以,能走框架路由就绝不用window.location。

还有一种情况:子应用跳主应用时,需要携带业务数据。比如订单子应用完成后要回到主应用工作台,并展示"订单处理成功"的提示。这时候我一般会在query里带上状态参数:

getMainRouter().push({ path: '/dashboard', query: { message: 'order_done', id: orderId } });

主应用页面在created或mounted里读取$route.query,弹出提示即可。注意这类业务性提示是一次性的,主应用页面再次刷新时可能会重复读到query。为了避免这种问题,我通常会在路由跳转后,用router.replace把query清理掉,或者记录一个"已经消费"标识。

3.3 带参数跳转:query、params和全局状态怎么选

跨应用跳转时带参数,我给的选型建议是:

  • 简单标识类参数(id、type、pageNo等):优先放URL query里。好处是刷新页面参数还在,支持浏览器前进后退,也可以直接分享链接。
  • 敏感或体积大的数据(token、复杂对象):不要放URL里,URL会有一堆编码,又长又丑,还有长度限制(不同浏览器上限不同,一般2KB-8KB)。这种情况放到globalState或sessionStorage里。
  • 需要实时更新的数据:放globalState里,配合onGlobalStateChange监听。

举个例子,从订单子应用跳转到商品子应用的详情页,同时需要告诉商品页当前用户想从哪个渠道进来:

// 子应用A 订单子应用内 getMainRouter().push({ path: '/goods/detail', query: { goodsId: 'G10086', from: 'order' } });

商品子应用的详情页组件读取:

// 子应用B 商品子应用内 // 注意:这里的route是子应用的内部路由实例,不是主应用的 const goodsId = route.query.goodsId; const from = route.query.from;

这里有个容易混淆的地方:子应用内部看到的路由实例,只能解析主应用URL中自己base之后的部分。主应用URL是/goods/detail?goodsId=G10086&from=order,子应用base是/goods,那么子应用内部匹配到的路径就是/detail?goodsId=G10086&from=order。query是全局共用的,子应用能读到;但如果是params(如/goods/detail/:id),id会被解析到route.params里,这个是和base有关系的,稍不注意就会读成undefined。

在实际项目中,我在跨应用跳转时统一使用query传参,避免params在不同应用里的解析差异。这个经验在团队协作时特别有用,新来的同事不用去猜"这个参数到底该放哪"。

4. 子应用之间的跳转:三种主流方案与取舍

4.1 方案A:主应用中转(最推荐,也最干净)

子应用之间跳转,我的第一选择永远是"借助主应用路由中转",本质上就是子应用A拿到mainRouter,直接push到子应用B的路径。因为qiankun的activeRule会自动根据URL切换子应用,所以在A里执行mainRouter.push('/goods/detail'),qiankun会卸载A、加载B,URL也同步变化。这一套流程下来,完全不需要额外的状态同步。

我在真实项目里最常用的就是这个方案。比如订单子应用里点击"查看商品详情":

// 子应用A 订单子应用组件 function viewGoods(goodsId) { getMainRouter().push({ path: '/goods/detail', query: { goodsId } }); }

这个方案的好处:实现简单、URL可刷新、可分享、可回退,跳转过程完全是框架层面的行为,不会出现状态不同步的问题。

它唯一的"缺点"是子应用A需要知道B的完整路径。如果你把所有子应用的路由表都收口到主应用里(我建议这样做),这个路径就是从主应用视角看的完整路径,基本不会出错。

4.2 方案B:全局事件总线(适合"响应式"跳转场景)

事件总线方案适合的场景是:跳转动作不是由用户点击触发的,而是由某个业务状态变化触发的。比如某个子应用里有WebSocket推送,收到"订单超时"事件后,需要自动跳到另一个子应用去提示用户,或者某个子应用里登录状态失效,需要跳回主应用的登录页。

这种"被动跳转",用主应用中转的写法在子应用业务代码里到处判断太分散。我通常会在主应用里维护一个简单的事件总线,并通过props传递给所有子应用:

// 主应用 创建 event-bus.js class EventBus { constructor() { this.events = {}; } on(event, callback) { if (!this.events[event]) { this.events[event] = []; } this.events[event].push(callback); return () => this.off(event, callback); } off(event, callback) { if (!this.events[event]) return; this.events[event] = this.events[event].filter((cb) => cb !== callback); } emit(event, payload) { if (!this.events[event]) return; this.events[event].forEach((cb) => { try { cb(payload); } catch (err) { console.error(`[event-bus] ${event} 回调执行出错`, err); } }); } } const eventBus = new EventBus(); export default eventBus;

主应用在registerMicroApps时,把eventBus也放进props:

props: { mainRouter: router, eventBus }

子应用里注册监听:

// 子应用B 挂载时注册 let offOverTime = null; export async function mount(props) { offOverTime = props.eventBus.on('order-overtime', (payload) => { getMainRouter().push({ path: '/goods/list', query: { overtime: payload.orderId } }); }); // ... } export async function unmount() { if (offOverTime) offOverTime(); }

这里有一个团队协作上的经验:事件名要统一管理,不建议散落在各子应用的业务代码里。我习惯在主应用维护一个event-names.js,以常量形式导出所有跨应用事件名,避免拼写问题导致事件对不上。

事件总线的缺点也很明显:事件是"一次性"、无状态的。如果子应用B还没挂载完成,事件emit时就丢失了。所以事件总线适合"触发一次就够"的场景,不适合需要持久化状态的跳转。

4.3 方案C:基于globalState的状态驱动跳转(适合复杂场景)

如果子应用A跳转B时,需要同时传递一个较大的业务快照,并且希望B在渲染前就能拿到这份数据,用globalState是最合适的。

实现思路是:globalState里维护一个redirect字段。子应用A要跳转时,先把目标路径和数据写入redirect,然后调用mainRouter.push修改URL。子应用B挂载后,从globalState里读取redirect数据并消费。

主应用初始化:

// 主应用 const actions = initGlobalState({ redirect: { path: '', payload: null, timestamp: 0 } }); // 提供getGlobalState方法方便子应用读取 registerMicroApps(apps.map((app) => ({ ...app, props: { mainRouter: router, globalState: actions, setGlobalState: actions.setGlobalState, onGlobalStateChange: actions.onGlobalStateChange } })));

子应用A跳转并携带数据:

// 子应用A function goToGoodsWithPayload(goodsItem) { props.setGlobalState({ redirect: { path: '/goods/detail', payload: goodsItem, timestamp: Date.now() } }); getMainRouter().push(`/goods/detail?goodsId=${goodsItem.id}`); }

子应用B在mount或者页面组件里消费:

// 子应用B let consumedRedirect = { path: '', timestamp: 0 }; props.onGlobalStateChange((state) => { const redirect = state.redirect; if (redirect && redirect.timestamp > consumedRedirect.timestamp) { if (redirect.path === '/goods/detail') { // 渲染详情,使用redirect.payload store.commit('setGoodsItem', redirect.payload); } consumedRedirect = redirect; } });

这个方案的核心在于timestamp,我用它来区分"新跳转"和"旧状态"。如果不加这个时间戳,子应用B每次全局状态变化都会重复消费同一条跳转指令。加了时间戳后,只有当跳转指令比上一次消费的更新时,才执行真正的逻辑。

从实际效果看,方案C在"跨应用表单草稿"这类场景下特别好用:用户在订单子应用填写了一半,跳转到商品子应用选关联商品,再跳回来,草稿数据通过globalState一直保留,URL保持简洁,刷新也不会导致数据丢失(因为刷新后globalState会重置,但可以从其他持久化方案里恢复)。

4.4 三种跳转方案对比:到底该用哪个

我把这三种方案放在一张表里,方便你按场景选型:

对比维度方案A:主应用中转方案B:事件总线方案C:globalState
实现复杂度最低中等中等偏高
是否可刷新恢复是,URL携带参数否,事件一次性是,依赖主应用持久化
大数据量传递不推荐,放URL有长度限制可以,但事件不能追溯推荐,内存中共享
耦合度子应用A需知道B路径事件名统一管理即可全局状态结构需约定
典型场景按钮点击、菜单跳转推送通知、状态失效跳转表单草稿、复杂业务快照传递

我的建议是:默认用方案A,特殊场景用B或C,别把三种混在一起用。我见过一个项目,子应用间跳转三种方案都用,结果一条跳转链路里既有mainRouter.push,又有事件总线通知,还有globalState同步,排查问题时根本分不清是哪个环节出了问题。

5. 实测经验:我踩过的跳转坑与排查思路

5.1 坑一:history模式下子应用白屏,base没配对

这是新手最容易踩的坑。现象是主应用URL变成/order/list后,子应用确实被加载了,但页面空白,控制台报路由匹配不到任何组件。

根本原因是子应用创建路由时,base没设置成自己的activeRule前缀。

// 错误写法 router = createRouter({ history: createWebHistory(), // 少了一个 base routes }); // 正确写法 const base = window.__POWERED_BY_QIANKUN__ ? '/order' : '/'; router = createRouter({ history: createWebHistory(base), routes });

排查思路:先看子应用入口的mount函数里,创建路由时有没有配置base;再看主应用URL是不是/order/xxx,子应用内部路由路径是不是/xxx。这两者不匹配,必然白屏。

5.2 坑二:刷新页面404,服务器没做通配回退

这个坑在微前端里相当经典。开发环境下用webpack-dev-server跑子应用,history模式通常已经配了historyApiFallback,所以本地跳转没问题。但一旦部署到测试环境,用nginx服务器托管时,用户直接在浏览器地址栏输入https://example.com/order/list,nginx找不到这个物理路径,就会返回404。

解决方法是让所有前端路由都回退到对应的入口HTML:

# nginx 配置 location / { try_files $uri $uri/ /index.html; } # 如果子应用单独部署,还需要单独配置 location /order/ { try_files $uri $uri/ /order/index.html; }

在开发环境,webpack-dev-server也要确认是否开启了historyApiFallback,否则本地调试时会一直404,很容易让人误以为是qiankun跳转代码写错了。

5.3 坑三:跳转后子应用重复挂载,内存泄漏

症状是跳转到子应用B,再跳回主应用,再跳入子应用B,每次进入都会多一次事件监听、多一个定时器,页面越来越卡。

这通常是因为unmount生命周期里没有清理干净。

我在子应用的unmount里一定会做这几件事:

export async function unmount() { // 1. 卸载应用实例 app.unmount(); // 2. 清理路由实例 router = null; // 3. 清理全局状态监听 if (offGlobalStateChange) offGlobalStateChange(); // 4. 清理事件总线监听 if (offEvent) offEvent(); // 5. 清理定时器 timers.forEach(clearInterval); }

特别注意事件总线和globalState的监听,这两个如果不在unmount里取消,子应用虽然DOM被移除了,但主应用里订阅回调还在,一旦emit事件,会操作一个已经不存在的组件,轻则报错,重则内存泄漏。

5.4 坑四:全局状态和URL数据不同步,导致页面内容错乱

方案C里redirect的payload存在globalState,但URL里也带了一个goodsId。刷新后globalState失效,只剩URL里的goodsId,子应用B却还试图从globalState里取payload,结果取不到,页面内容错乱。

这个问题的本质是:一份数据,两个数据源,没有主次关系。

我的解决思路是:所有刷新后可恢复的数据,必须能从URL参数推导出来;globalState里的数据只是URL参数的缓存加速器。子应用B在挂载时,先读URL query里的goodsId拉取详情;如果globalState里有对应的payload,直接渲染,省一次请求;两者都不冲突。这样刷新后虽然globalState丢了,但URL里的goodsId依然能驱动页面正常渲染。

5.5 常见问题速查表

我在项目文档里整理过一张速查表,遇到问题排查会快很多,也分享出来:

现象可能原因检查要点
跳转后白屏,控制台无报错子应用base未配置或配置错误检查子应用创建路由时的base
刷新页面404服务器未配置history模式回退检查nginx或webpack-dev-server配置
子应用重复挂载、监听重复unmount清理不完整检查事件监听、globalState监听、定时器清理
跳到子应用B但页面仍显示AactiveRule匹配顺序错误检查主应用registerMicroApps的顺序和匹配规则
点击跳转无响应mainRouter在子应用里为undefined检查props是否正确传递、mount时机
传的参数在子应用里取不到query和params混用统一使用query传参,确认子应用base
history模式部署后子应用资源404webpack publicPath未配置子应用设置publicPath: '/order/'
子应用间事件触发但没收到事件名不一致或监听时机太晚检查事件名常量是否统一、连接是否完成

写在最后:一条跳转经验法则

qiankun的跳转问题,说到底就一句话:路由这件事,最终由主应用路由来裁决。子应用内部怎么跳都行,一旦跨出子应用边界,就交给主应用的路由实例。数据传递则按"通用参数放URL、临时状态放globalState、一次性通知放事件总线"来分流。把这个原则贯彻落实,微前端的跳转就从一个玄学问题变成了一个工程问题,好排查也好维护。

实际项目里,我还会在mainRouter外面包一层统一的跳转工具,比如go(path, query, payload),内部决定数据到底走URL还是走globalState,这样业务组件里的代码非常干净,后期要调整传输方案也只改一处。这个做法在团队协作时尤其值回票价,新同事不需要理解底层细节,照着工具函数调用就行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询