微前端应用隔离时,两个子应用的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的全局上下文导致的吗?怎么才能让它们彻底隔离呢?
为什么会这样?因为你在构建子应用的时候,如果没把 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。