外观
微前端落地复盘:qiankun 沙箱隔离、样式冲突与公共依赖治理的踩坑全记录
我们团队决定上微前端的动因很现实:一个运行五年的中后台主应用,技术栈从 Vue 2 逐页迁到 Vue 3,同时新业务线要独立发版频率,历史代码不敢动又必须共存。微前端方案里我们对比过 qiankun(single-spa 封装,社区资料最多)、micro-app(Web Component 方案)与 wujie(iframe 改良),最终选择 qiankun,主要因为团队 Vue 技术栈与它的大量现成实践。一年多运行下来,架构目标基本达成,但过程中踩的坑远比官方文档展示的多,这篇逐条复盘。
先说结论性的架构事实:微前端不是「多应用拼一起」这么简单,它的三大难点——JS 运行时隔离、样式隔离、依赖共享——每一个都对应着一套机制选择,选错了后面全是补丁。
沙箱隔离:Proxy 沙箱不等于绝对安全
qiankun 的 JS 沙箱分三档:单实例场景用 Proxy 沙箱(代理 window 的读写,卸载时还原),兼容模式用快照沙箱(激活时拍 window 快照、卸载时 diff 还原),低版本浏览器还有 legacy 分支。启用方式在 loadMicroApp 或 registerMicroApps 的配置里:
js
// 主应用:注册子应用与沙箱配置
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{
name: 'order-app',
entry: '//localhost:7101',
container: '#subapp-viewport',
activeRule: '/order',
props: { token: localStorage.getItem('token') } // 通过 props 下发登录态
}
])
start({
sandbox: { experimentalStyleIsolation: true }, // 样式隔离:运行时给规则加属性前缀
prefetch: 'all'
})第一课就来自沙箱的「漏点」:Proxy 沙箱只拦截子应用对 window 的直接读写,拦不住定时器、事件监听这类「注册后留存」的全局副作用。我们的子应用 A 在路由守卫里 window.addEventListener('resize', ...) 却从不清除,切换五次后主应用里挂着五个回调,拖拽侧边栏时页面明显卡顿。修复方式是约定:子应用在 unmount 生命周期里必须清除定时器、解绑 window/document 级监听、销毁独立创建的 Map/Set 容器。沙箱管不了的事,必须写进子应用的接入规范。
样式冲突:两层隔离机制各管一段
样式是微前端最密集的冲突源。qiankun 提供两档隔离:strictStyleIsolation(Shadow DOM,隔离最彻底但弹窗组件挂 body 后样式全丢、事件冒泡也要适配)与 experimentalStyleIsolation(运行时把子应用样式规则改写为 div[data-qiankun="order-app"] .xxx 前缀)。我们选了后者,代价是子应用必须保证样式有统一的前缀入口,于是给每个子应用加一层根 class,并约定组件样式全部 scoped。
但隔离住「子应用之间」与「主子之间」,隔离不住「挂到 body 上的弹窗」。Element Plus 的弹窗默认 teleport 到 body,脱离了属性前缀的作用域,主应用的同名类瞬间互相污染。我们的修复分两步:
ts
// 子应用:弹窗类组件统一挂载到自己的容器内,不逃逸到 body
// main.ts(子应用入口)
import { createApp } from 'vue'
let app: ReturnType<typeof createApp> | null = null
export async function mount(props: Record<string, any>) {
app = createApp(App)
app.provide('qiankunContainer', props.container) // 注入容器引用
app.mount(props.container.querySelector('#order-root') as HTMLElement)
}
export async function unmount() {
app?.unmount()
app = null
}ts
// 弹窗组件:teleport 目标改为子应用容器
<template>
<Teleport :to="containerEl" :disabled="!containerEl">
<el-dialog v-model="visible" title="编辑订单">…</el-dialog>
</Teleport>
</template>
<script setup lang="ts">
// 中文注释:从注入里拿容器;独立运行(非微前端环境)时回退到 body
import { inject, ref, onMounted } from 'vue'
const containerEl = ref<HTMLElement | null>(null)
onMounted(() => { containerEl.value = inject('qiankunContainer', document.body) as HTMLElement })
</script>公共依赖治理:externals 解耦了一半,还有一半靠纪律
四个子应用都依赖 Vue、element-plus、axios,不治理的话首屏要下载四份框架代码。我们采用「主应用 externals + 全局变量」方案:主应用用 CDN 引入 Vue 与 element-plus 并挂到 window,子应用构建时声明 externals 不再打包,运行时自动取全局变量。qiankun 还提供 initGlobalState 做主子通信,登录态与主题切换走这条通道,避免 localStorage 的多写竞态。
这个方案的软肋是版本漂移:子应用 B 想用 element-plus 的新 API,而全局变量还是旧版本——构建期不报错,运行时才炸。治理手段有三条,全部落在工程规范上:主应用的 CDN 依赖用固定版本 tag(不用 latest);各子应用的 peerDependencies 声明与主应用版本做 CI 校验,不一致即失败;公共依赖升级由主应用仓库统一提版,子应用跟随发版验证。
通信机制:宁走窄而标准的通道,不造宽而共享的捷径
通信层的细节同样值得单独复盘。主子应用之间我们最终只保留两条通道:initGlobalState 承载登录态、主题、全局配置这类「一主多从」的广播数据,生命周期由框架托管,主应用卸载子应用时自动清理;业务级联动(比如主应用跳转子应用的指定详情页)一律走路由参数,天然可回溯、可分享链接。项目早期曾尝试把子应用的 Pinia store 挂到 window 上互相调用,结果出现了「子应用 B 卸载后主应用还持有它的 store 引用」的悬挂问题,引发一轮极难排查的空指针——悬挂引用在微前端环境里比普通 SPA 更隐蔽,因为卸载重建随时可能发生。教训沉淀成一句话:跨应用的数据流转走窄而标准的通道,宁可多写一层参数转换,也不做全局共享的捷径。
避坑总结
- 快照沙箱不支持多实例并发。同屏挂载多个子应用时快照会互相覆盖,必须走 Proxy 沙箱或改造成单实例模式;规划页面布局时先确认是否存在「同屏多子应用」的真实需求,没有就把架构收敛到单实例。
- 弹窗、面包屑、全局 loading 是样式逃逸三巨头。凡 teleport 到 body 的组件都会绕过样式隔离,统一约定「teleport 目标改为子应用容器」,并把这个约定写进子应用模板工程的公共组件里,靠规范而不是靠人记。
- activeRule 与子应用路由 basename 必须对齐。主应用路由基座是
/order时,子应用 router 的 base 必须同步设置为/order,否则刷新后匹配错乱。历史上我们出过「列表页正常、详情页 404」的事故,根因就是两边基座配置各自演化。 - 公共包的样式资源别忘了 externals。只 external JS 不处理 CSS,element-plus 的样式会被打进每个子应用,体积优化白做;样式由主应用统一 CDN 引入,子应用构建里显式排除。
- 子应用生命周期里做全量清理。unmount 中清除定时器、解绑全局监听、关闭 WebSocket、还原被改写的原型方法(如有)。沙箱兜底有限,「谁注册谁销毁」是微前端世界的内存铁律,建议在接入清单里做成 checklist 逐项验收。
写在最后
复盘下来,微前端落地 70% 的工作量不在「把应用挂上去」,而在隔离机制的边界认知与跨团队工程纪律:沙箱的漏点靠生命周期规范堵,样式逃逸靠 teleport 约定堵,版本漂移靠 CI 门禁堵。qiankun 解决的是机制问题,机制之上的治理问题只能靠团队自己的规范体系。如果你的场景是「两三个团队、数年内共存的遗留系统」,这些投入是值得的;若只是想复用一个页面,直接抽组件库或 npm 包可能是更便宜的答案——微前端永远该是权衡后的选择,而不是技术尝鲜。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。