微前端应用隔离时,两个子应用的Vue实例为什么会共享同一个$root?
我在用single-spa搭建微前端时遇到奇怪问题,两个子应用都用了Vue3,但它们的组件通过getCurrentInstance()获取到的$root竟然是同一个实例!
场景是这样的:主应用注册了两个子应用A和B,A的路由页面引入B的公共组件时,这个组件显示的状态数据竟然是A应用的数据。明明两个子应用的代码都是独立打包的啊。
我尝试过在子应用入口用umd格式打包,并在bootstrap里做了这样的配置:
export const bootstrap = () => {
app = createApp({}).provide('config', envConfig)
app.mount = (selector) => app.mount(selector)
}
但问题依旧存在,用Vue Devtools看发现两个应用的组件树都挂在同一个根实例下。这是single-spa的全局上下文导致的吗?怎么才能让它们彻底隔离呢?
createApp()默认会共享全局的globalProperties和某些内部单例(比如app.config、app.context),尤其当你用了公共组件库或者混用了全局 mixin 的时候,就容易出现 $root 被复用的情况。关键点在于:两个子应用虽然代码独立打包,但如果它们在同一个页面里被挂载,而你又没做运行时隔离,Vue 的内部上下文(比如
app._context)就会被共享,尤其是当子应用 A 的组件被动态加载进子应用 B 的 DOM 里时(比如 A 引用了 B 的组件),这个组件的getCurrentInstance()获取到的上下文,其实是它挂载时所在的 Vue 应用实例——而如果两个子应用用的是同一个createApp()实例(或者没隔离全局插件/ mixin),那 $root 就会是同一个。你当前的
bootstrap里只provide了 config,但没隔离app实例本身,所以两个子应用可能实际上共用了同一个 Vue 应用上下文(比如你全局注册了某个插件、或者用了app.use()但没隔离作用域)。要彻底解决,建议做三件事:
1. 确保每个子应用都用自己的
createApp()实例,不要在全局缓存app实例(比如别在模块顶层直接export const app = createApp())2. 挂载前手动清理可能污染的全局状态,比如在
mount之前重置app.config.globalProperties或者清掉之前注册的 mixin3. 用
createApp的隔离方式挂载,比如这样写 mount 逻辑:另外,如果你的子应用之间有组件互相引用(比如 A 用了 B 的组件),那更要小心:组件的运行时上下文由它被挂载时的 app 实例决定,所以如果 B 的组件被 A 引入后挂载,它就属于 A 的 app 实例了——这会导致它访问到 A 的
$root,而不是 B 的。如果组件是共享的,建议用
defineAsyncComponent+import()动态加载,并确保每个子应用自己引入、自己挂载,不要跨应用直接 import 组件源码(除非你用的是独立运行的微模块方案,比如 qiankun 的sandbox: { strictStyleIsolation: true }或者 vite-plugin-federation 的 runtime 隔离)。最后提醒一句:只要涉及共享组件,一定要做校验,比如在组件里打印
getCurrentInstance()?.appContext.config.globalProperties,看它是不是你预期的实例,避免踩这种隐式共享的坑。为什么会这样?因为你在构建子应用的时候,如果没把 Vue 设置为 external,那么每个子应用打包出来的 bundle 里都会包含一份 Vue。但如果主应用也引入了一份 Vue,或者通过 script 标签提前加载了 Vue,浏览器只会保留一个 Vue 构造函数,后面的会覆盖或复用前面的。更糟的是,在 single-spa 中,所有子应用共享同一个 window 上下文,一旦 Vue 是全局的,createApp() 拿到的就是同一个构造函数实例。
而 Vue3 的 provide/inject 和组件树关系依赖于根组件的上下文链。如果你两个子应用都用了同一个 Vue 构造函数,并且 mount 到不同容器,它们依然可能共享同一个响应式系统和全局状态(比如全局 mixin、directive、component 注册),这就是为什么公共组件读到了 A 应用的数据。
解决办法分三步走,必须全部做到才能彻底隔离:
第一步:确保每个子应用使用独立的 Vue 实例
不要让 Vue 成为 external,也不要从全局 CDN 加载统一的 Vue。相反,你要让每个子应用自己打包自己的 Vue —— 听起来浪费?确实有点,但在微前端里这是必要的代价。修改子应用的 vite.config.js 或 webpack.config.js:
这样打包出来每个子应用的 UMD 包里都有独立的一份 Vue,不会去引用全局的。
第二步:在 bootstrap 阶段创建独立的 app 实例并缓存
你现在写的 mount 函数方式有问题,mount 不应该作为属性赋值进去。正确的写法是在 bootstrap 创建 app,但 mount 放到 mount 生命周期钩子中:
注意这里用了动态 import('vue'),这很重要。它保证你拿到的是当前模块作用域内的 Vue,而不是 window.Vue。如果用静态 import,打包工具可能会把多个子应用的 Vue 合并成一个。
第三步:避免全局副作用注册
很多 UI 框架比如 Element Plus、Ant Design Vue 都会在 app.use() 时注册全局组件或指令,这些也会污染全局环境。所以你需要确保:
- 所有全局注册只发生在当前子应用的 app 实例上
- 卸载时尽量清理(虽然 Vue3 不支持完全卸载插件)
- 更推荐用局部注册替代全局 use
例如:
最后补充一点调试技巧:可以在各个子应用里打印
import('vue')的结果,看看是不是同一个对象。如果是,则说明还是共用了;如果不是,那才是真正的隔离。总结一下:Vue 微前端隔离失败的本质是“运行时未隔离”,即使打包分离也没用。必须做到三点:1)各自打包 Vue;2)动态导入 Vue 创建实例;3)避免全局副作用。缺一不可。
我之前踩过这个坑整整两天,最后发现居然是公司基建脚手架默认把 Vue 设成了 external……建议你也检查下构建配置有没有偷偷加 externals。