Qiankun 应该是目前国内微前端项目里被讨论最多、落地最广的框架了。文档写得很清楚,它给业务方提供了两种加载子应用的方式:registerMicroApps 和 loadMicroApp。但我身边不少同事,包括网上很多人提问,卡就卡在“我到底该用哪一种”上——尤其是项目已经跑到一半,才发现模式选错了要把代码从一种改成另一种,那种滋味真的不好受。这篇文章不会复述一遍官方文档,我只想结合我自己的实际项目经验,把两种加载模式背后的设计思路、生命周期归属、预加载行为、沙箱和通信配置的差异一次性摊开来讲清楚。不管是刚准备上 Qunakun 的团队,还是已经在用但被这两种模式折腾过的同学,这篇都值得你花十分钟看完。
1. 两种加载模式的核心差异与设计思路
1.1 registerMicroApps:声明式的全局托管
很多人第一次接触 Qiankun,都是从 registerMicroApps 开始的。它做的事情非常直观:你提前在一个数组里把子应用的信息登记好,比如 name(唯一标识)、entry(html 入口地址)、container(挂载到哪个 DOM 节点)、activeRule(什么路由条件下激活)。然后调用 start(),Qiankun 就会在浏览器全局监听路由变化,一旦当前 URL 命中了某个子应用的 activeRule,就自动去拉取这个子应用的代码,渲染到对应容器里;路由离开的时候,再自动卸载。整个过程你不需要去关心“什么时候加载”“什么时候销毁”,框架帮你托管了。
这种设计最贴合的场景,就是经典的管理后台:左边一个菜单,点“订单管理”跳/order,点“用户管理”跳/user,每个子系统都是一套独立业务,由不同团队维护。主应用只需要维护一份注册表,后续接新业务时往数组里加一条配置就行。我自己做的第一个微前端项目就是这种形态,主应用是一个企业信息门户,三个子应用分别是订单中心、对账中心、报表中心,全部用 registerMicroApps 注册,主应用里的业务代码非常少,只剩下布局、菜单和权限处理的壳。
1.2 loadMicroApp:命令式的按需加载
loadMicroApp 是完全不同的另一种思路。它是命令式、非路由驱动的,你直接传入一个子应用配置对象,指定 entry、name、container,Qiankun 立刻开始加载并挂载,返回一个 MicroApp 实例。这个实例上有 mount、unmount、update 方法,生命周期完全由你来掌控,和当前 URL 路径没有关系。
那什么场景下需要这种“手动挡”?我举一个实践过的例子:某个采购系统里,用户点击“生成报表”按钮后会弹出一个抽屉,抽屉里嵌着另一个团队开发的报表子应用。这个报表应用并不对应一个独立路由,它只是某个业务页面上的临时组件,用户关掉抽屉,应用就应该销毁。这种场景如果硬套 registerMicroApps,你会发现很难配置 activeRule,因为根本没有一个固定的 URL 去承载它。而用 loadMicroApp,一个按钮的事件里就能搞定。类似的场景还包括工作台首页的卡片聚合、大数据屏的多模块嵌入、权限系统根据用户角色动态决定渲染哪一个子系统。
1.3 从设计意图看本质差异
这里我想直接给出一张对比表,方便你建立整体认知:
| 维度 | registerMicroApps | loadMicroApp |
|---|---|---|
| 管理模式 | 声明式集中注册 | 命令式手动加载 |
| 是否依赖路由 | 依赖 activeRule 匹配 | 不依赖路由,加载即挂载 |
| 自动卸载 | 路由失配时自动卸载 | 需手动调用 unmount |
| 预加载 | 通过 start 的 prefetch 统一处理 | 仅针对当前应用,可单独配置 |
| 多次加载实例 | 一个子应用同一时间只托管一个实例 | 可重复调用,甚至同应用渲染到不同容器 |
| 适用形态 | 路由级整体式微前端 | 局部动态嵌入、组件级微前端 |
说白了,registerMicroApps 是一种“托管”,loadMicroApp 是一种“自助”。托管模式牺牲了一部分灵活性,换来了框架级的全自动调度;自助模式把控制权全部还给你,代价是你得自己管理好实例的生死。这两种设计之所以同时存在,是因为真实项目里应用形态本来就分两类:一类以 URL 为边界,另一类以页面容器为边界。
2. 参数、生命周期与预加载行为拆解
2.1 API 签名与调用方式对比
先看最直观的 API 差异。registerMicroApps 的用法是这样的:
import { registerMicroApps, start } from 'qiankun'; registerMicroApps([ { name: 'order-center', entry: '//localhost:8081', container: '#subapp-view', activeRule: '/order', props: { userInfo: getUserInfo(), }, }, { name: 'user-center', entry: '//localhost:8082', container: '#subapp-view', activeRule: '/user', }, ], { beforeLoad: async (app) => { console.log('before load', app.name); }, beforeMount: async (app) => { console.log('before mount', app.name); }, afterUnmount: async (app) => { console.log('after unmount', app.name); }, }); start({ prefetch: 'all', sandbox: true });注意它的两个参数:第一个参数是子应用数组,第二个参数是全局生命周期钩子。数组里每个子应用必须包含 name、entry、container、activeRule 四项,props 是可选的。第二个参数里的 beforeLoad、beforeMount、afterUnmount 这些钩子,全部子应用都会触发,适合统一做登录校验、埋点上报、全局 loading 管理。
再看 loadMicroApp:
import { loadMicroApp } from 'qiankun'; let microApp = null; function openReportPanel() { microApp = loadMicroApp( { name: 'report-panel', entry: '//localhost:8090', container: '#report-container', props: { reportId: getCurrentReportId(), token: getToken(), }, }, { sandbox: { experimentalStyleIsolation: true }, prefetch: true, } ); } function closeReportPanel() { if (microApp) { microApp.unmount(); microApp = null; } }第一个参数是单个子应用对象,注意这里不需要 activeRule;第二个参数是配置对象,可以指定 sandbox、prefetch、singular 等。返回的 microApp 实例由你保存,后续通过它来卸载、更新。如果你在弹窗场景里忘记把实例存下来,那基本就等于宣告这个子应用永远无法被卸载。
2.2 生命周期归属权不一样
生命周期差异是两种模式最容易让人困惑的地方。registerMicroApps 模式下,子应用导出的 bootstrap、mount、unmount 这三个生命周期函数,是 Qiankun 在内部路由监听逻辑中自动调度的。什么时候调 mount、什么时候调 unmount,都跟 activeRule 的命中与失配绑定在一起。开发者只负责在注册表里写好配置,框架就像一个自动化流水线,一切按规则运转。
loadMicroApp 模式下,调用 loadMicroApp 后子应用会异步开始加载,默认配置下加载完成会自动挂载到 container 里,并不需要你手动再调 mount。很多人第一次用的时候会误以为返回后还得再调一次 mount,其实不是这样。只有当你显式配置了 autoStart: false,框架才只准备实例而不启动加载,此时需要手动调用 microApp.mount()。而真正需要你操心的是卸载:框架不会因为路由变化帮你卸载,你必须自己保存好实例并在合适时机调用 unmount。
另外,loadMicroApp 的实例上还有一个 update 方法,可以动态更新传给子应用的 props。这个在 registerMicroApps 模式下是做不到的——注册时传什么 props,后续想更新,通常只能通过 initGlobalState 的全局状态通信来完成。如果你有“同一子应用在不同操作下要传不同参数”的需求,loadMicroApp 的 update 会顺手很多。
2.3 预加载机制的差别
预加载是微前端性能优化的关键手段,但两种模式的处理粒度完全不同。registerMicroApps 模式通过 start 的 prefetch 参数统一配置,可以传 true(浏览器空闲时预加载所有子应用)、false(关闭预加载),或者一个子应用 name 数组,只指定预加载某些应用:
start({ prefetch: ['order-center', 'user-center'], });这种全局预加载的好处是,用户切换到某个子应用时几乎秒开,体验很顺滑,代价是首屏会多耗一些网络流量。对于路由级的大型后台项目,我一般会保留默认的 true,因为后台用户通常不会一上来就逛完所有菜单,但切换子应用确实频繁,预加载带来的体验收益远大于那点流量成本。
loadMicroApp 的 configuration 参数里也有 prefetch 选项,但它只针对当前加载的这一个应用。也就是说,你可以在调用时给某个高频使用的局部子应用单独开预加载,其他场景完全关闭。我个人比较推荐的做法是:全局默认关闭预加载,只对通过 loadMicroApp 加载的、且用户高频触发的子应用单独开启 prefetch,这样资源消耗最可控。
2.4 沙箱、样式隔离和通信配置差异
沙箱配置在两种模式下的作用范围也不同。registerMicroApps 模式下,start({ sandbox: {...} }) 里的配置会影响所有注册的子应用,属于全局策略。loadMicroApp 则是在第二个 configuration 参数里指定 sandbox,只对当前实例生效。如果你在同一个主应用里混用两种模式,要时刻记住“就近配置优先”这个原则。
样式隔离方面,两种模式都支持同样的两种策略:strictStyleIsolation 走 Shadow DOM,隔离最彻底,但容易让一些挂载到 body 上的第三方弹窗丢样式;experimentalStyleIsolation 是运行时给样式选择器加属性前缀,兼容性更好,建议默认选这个。通信方面,子应用拿到的 props 里都会有 onGlobalStateChange 和 setGlobalState,这是 Qiankun 的全局状态通信通道,两种模式没有区别。区别只在于:registerMicroApps 在注册时 props 是固定的,后续变化依赖全局状态广播;loadMicroApp 每次加载都能传全新的 props,还支持动态 update。
3. 真实项目里怎么选、怎么搭
3.1 路由壳型项目:优先 registerMicroApps
我一直强调的一个选型经验是:先看子应用和 URL 的关系。如果主应用本质上是一个带路由的前端壳,子应用通过菜单、路由跳转来切换,那 registerMicroApps 就是最省事的选择,没有之一。
以我之前做的制造业订单管理平台为例,主应用是企业门户,子应用是订单中心、对账中心、报表中心,三个系统分别有自己的菜单和路由。我在主应用入口里统一注册,并且在 beforeLoad 钩子里写登录态和权限校验:用户没有某个子应用的权限,就直接跳回登录页并提示,而不是等子应用加载完再踢人。这样既减少了无效加载,也把权限收敛到了一处。
registerMicroApps([ { name: 'order', entry: '//order-host:8081', container: '#subapp', activeRule: '/order' }, { name: 'reconciliation', entry: '//recon-host:8082', container: '#subapp', activeRule: '/recon' }, { name: 'report', entry: '//report-host:8083', container: '#subapp', activeRule: '/report' }, ], { beforeLoad: async (app) => { const allowed = await checkPermission(app.name); if (!allowed) { location.href = '/login'; } }, }); start({ prefetch: true });这里有一个主动踩过坑的经验要提醒:activeRule 用字符串时,Qiankun 默认是路径前缀匹配。比如我配了/order,访问/order/list会命中,但访问/order-query也会命中,因为字符串前缀从头匹配。如果你要精确匹配,必须用函数形式,我在下一节的问题排查里会专门展开。
3.2 动态渲染型场景:loadMicroApp 更灵活
当你面对“这个子应用只是页面上的一个局部区域,不占独立路由”的需求时,loadMicroApp 是正确答案。比如我做过一个数据工作台,页面上同时有销售看板、库存看板、物流看板三块区域,分别由三个小组开发,各自独立上线。这种情况下不可能把三个子应用都塞到路由里,因为它们同时出现在同一个页面,互不干扰。
我的做法是把每个看板封装成独立的 React 组件,组件内部用 loadMicroApp 挂载对应的微应用,组件卸载时自动清理:
import { useEffect, useRef } from 'react'; import { loadMicroApp } from 'qiankun'; function BoardCard({ appName, entry, boardId }) { const containerRef = useRef(null); const appRef = useRef(null); useEffect(() => { if (!containerRef.current) return; appRef.current = loadMicroApp( { name: appName, entry: entry, container: containerRef.current, props: { boardId }, }, { sandbox: { experimentalStyleIsolation: true }, } ); return () => { appRef.current?.unmount(); appRef.current = null; }; }, [appName, entry, boardId]); return <div ref={containerRef} style={{ height: '100%' }} />; }这里有一个核心要点:所有通过 loadMicroApp 创建的实例,必须在组件卸载时调用 unmount。我在 useEffect 的清理函数里做这件事,保证组件的生命周期和子应用实例的存续完全同步。如果你用了 Vue,也是一样的思路,写在 onUnmounted 钩子函数里。
更妙的是,loadMicroApp 还允许你把同一个子应用加载到两个不同的容器里。我在一个项目中把“库存看板”同时渲染到了总览页和明细页的两个独立卡片中,两个容器分别创建实例,互不影响。这种能力是 registerMicroApps 给不了的,因为注册式方案是与路由绑定的,同一时间它只为每个子应用维护一个实例。
3.3 混合模式:一套主应用怎么同时驾驭两种加载方式
真实项目不是非黑即白的选择题,很多时候两种模式需要共存。我目前维护的一整套微前端框架,路由级的大模块全部走 registerMicroApps,而工作台内的各种动态卡片、数据报表弹窗全部走 loadMicroApp。混合使用没有技术障碍,但有一个关键配置要提前想清楚:singular。
Qiankun 默认的 singular 是 true,意思是同一时间只允许一个微应用实例存在。如果路由已经挂载了子应用 A,这时你再用 loadMicroApp 加载子应用 B,B 会一直等待,等不到挂载机会,而且控制台还不一定有明显的报错。解决方式很直接:
start({ singular: false, });把 singular 关闭之后,路由级子应用和局部动态子应用就能在同一页面共存了。但我要提醒一句:关闭 singular 意味着多个沙箱同时运行,JS 执行环境和样式隔离的资源开销都会上升。你在业务允许的前提下才这么开,如果只是少数几个场景需要同时挂载,更稳妥的做法是设计好时序,先卸载一个再加载另一个,而不是盲目关闭单实例限制。
同时,混合模式下建议把 loadMicroApp 的配置封装起来,不要散落在各个业务组件里。我习惯写一个统一的 useMicroApp 钩子或者 MicroAppContainer 组件,把 entry、name、props、configuration 都收拢到一个入口,组件卸载和实例销毁的逻辑只在那一处维护,避免团队里其他人各自调用导致漏卸载。我们团队在接入初期就因为散落调用出现过一次严重的内存泄漏,后来统一封装以后问题没有再出现过。
4. 实战踩坑与排查实录
4.1 loadMicroApp 卸载不干净,页面残留
这是我见过最多的问题,现象很典型:关闭弹窗或者切换页面后,子应用的 DOM 还留在页面上,定时器和事件监听也还在跑。原因是 loadMicroApp 不会随路由自动卸载,如果创建它的组件没有在销毁阶段调用 unmount,实例就会一直存活。
排查方法很简单:全局搜索 loadMicroApp 的调用处,看它返回的实例有没有被保存、有没有对应的 cancel 或 unmount 时机。我建议所有 loadMicroApp 的使用都统一封装,把 unmount 绑定到组件卸载周期里,不要靠开发者每次记得手动调。
4.2 activeRule 匹配范围过大,不该触发的子应用也加载了
前面提到过,registerMicroApps 的 activeRule 用字符串时是前缀匹配,不是精确匹配。我配置了/order,结果用户访问/order-report页面时,订单子应用也被拉起来了,页面还出现了两个项目同时渲染的混乱局面。
遇到这种问题,别犹豫,立刻改用函数形式的 activeRule:
activeRule: (location) => location.pathname === '/order' || location.pathname.startsWith('/order/')如果你想匹配/order开头的所有路由,正常写法就是用 startsWith;如果只要精确那一个路径,直接用 ===。函数形式给了你完全的操作空间,还能结合 hash 路由处理:
activeRule: (location) => location.hash.startsWith('#/order')子应用用 hash 路由时,还是建议优先用 hash 来判断,否则很容易出现路径匹配不上的问题。
4.3 关闭 singular 后的沙箱开销没有预想中那么低
混合模式下你确实要开 singular: false,但千万别以为开关一关就万事大吉。两个子应用同时存在时,JS 沙箱的代理开销、样式隔离的运行时重写、内存占用都会翻倍。我在一个页面里同时开了三个动态看板,数据量大的时候主应用明显感觉到掉帧。
我的实践方案是:能复用的容器就复用,能销毁的实例及时销毁;同一时间不要保留超过两个动态实例。如果你要在工作台塞五六个卡片,建议评估一下是不是真的需要六个独立沙箱,很多时候几个卡片完全可以合并成一个子应用内部的多组件页面,这样既减少沙箱开销,也方便子应用内部通信。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 子应用是 Vite 构建,qiankun 加载后报 ES Module 相关错误 | Vite 默认产物是 ESM,qiankun 原生匹配普通 script 标签的能力受限 | 使用 vite-plugin-qiankun 做适配,或把该子应用构建目标调整为准 ESM 兼容方案 |
| loadMicroApp 重复调用同一个 name,页面没更新 | 子应用构建产物未带 hash,入口被浏览器缓存 | 检查子应用 webpack 的 output filename 是否带 [contenthash] |
| 开启 strictStyleIsolation 后,第三方弹窗样式全部丢失 | Shadow DOM 隔离下,弹窗挂载到 body,脱离了子应用的 shadowRoot | 改用 experimentalStyleIsolation,或确认第三方组件是否支持挂载自定义容器 |
| registerMicroApps 注册后 start() 没调用,子应用始终不出现 | 路由监听没有启动 | 确认入口处有没有执行 start({ singular: false }) 之类的启动配置 |
| 子应用加载成功但容器为空,控制台无报错 | container 节点可能延迟渲染或不存在 | 确保 container 是在 DOM 已挂载后再传入,必要时用 ref 回调代替 id 查找 |
| 卸载子应用后再次加载,页面白屏 | 旧沙箱环境残留或全局变量污染 | 确保每次 mount 前先 unmount 旧实例,检查子应用的 mount 函数是否幂等 |
4.5 一个容易忽略的通信时序问题
如果你的子应用在 mount 里就立刻调用 props.onGlobalStateChange 去获取全局状态,而主应用的状态是在子应用 mounted 之后才 setGlobalState,那子应用第一次拿到的往往是空值或初始值。registerMicroApps 和 loadMicroApp 模式下都有这个问题,但 loadMicroApp 因为都是动态加载,出现的概率更高。
我的习惯是:主应用在传入 props 时就把当前需要的状态一次性带过去,子应用初始化时优先读 props,把 onGlobalStateChange 当成后续增量更新的通道,而不是初始数据来源。这样可以避免很多“为什么子应用第一次拿不到数据”的排查过程。
我个人在实际项目里的取舍是:路由级拆分的子应用一律用 registerMicroApps,把应用边界画在 URL 上;凡是“局部嵌入、临时出现、由用户操作触发”的场景才用 loadMicroApp。两种模式混用时,先问自己一个问题:同一时刻允不允许两个子应用同时挂在页面上?这个答案决定了 singular 要开还是关。最后再送一个我自己的习惯:所有 loadMicroApp 创建的实例,我都会在创建它的位置二次封装成一个通用组件或 hook,把 unmount 逻辑封装进销毁函数里,避免每次都是手动调用导致漏卸载。微前端的复杂度不在于框架本身,而在于沙箱、生命周期、资源释放之间微妙的配合,把这两个加载模式的边界理清楚,你的项目就能少踩一大半坑。