微前端沙箱隔离:qiankun 和 wujie 到底在争什么
上个月接手一个老项目,四个团队各写各的,技术栈从 Vue2 到 React18 都有。领导一句"用微前端整合一下",我就开始了长达两周的沙箱隔离踩坑之旅。
问题的起点很简单:子应用 A 往 window 上挂了个 globalConfig,子应用 B 也挂了一个,然后就打架了。更离谱的是,子应用 C 的 CSS 里写了个 body { font-size: 14px !important },直接把主应用的样式干碎了。
这两个问题,一个是 JS 沙箱的事,一个是 CSS 隔离的事。qiankun 和 wujie 给出了完全不同的解法,背后的设计哲学也截然不同。
JS 沙箱:快照、代理、还是直接换个 window?
最朴素的思路:快照沙箱
qiankun 最早的沙箱方案简单粗暴------进子应用之前,把 window 上所有属性拍个快照存起来;子应用卸载时,把 window 恢复回去。
ts
class SnapshotSandbox {
private snapshot: Record<string, any> = {}
private modifications: Record<string, any> = {}
activate() {
// 进场前:把当前 window 拍个照
for (const key in window) {
this.snapshot[key] = (window as any)[key]
}
}
deactivate() {
// 离场时:记录子应用改了啥,然后恢复 window
for (const key in window) {
if ((window as any)[key] !== this.snapshot[key]) {
this.modifications[key] = (window as any)[key] // 存下改动
;(window as any)[key] = this.snapshot[key] // 还原
}
}
}
}
能跑。但问题也明显------同一时间只能激活一个子应用。因为大家共用一个 window,你在上面改,我也在上面改,没法并行。
这就是 qiankun 早期单实例模式的限制来源。
Proxy 代理沙箱:qiankun 的主力方案
为了支持多个子应用同时运行,qiankun 搞了 ProxySandbox。思路是给每个子应用造一个"假 window":
ts
class ProxySandbox {
private fakeWindow: Record<string, any> = {}
proxy: WindowProxy
constructor() {
const fakeWindow = this.fakeWindow
this.proxy = new Proxy(fakeWindow, {
get(target, key) {
// 先从 fakeWindow 找,找不到再去真 window
return key in target ? target[key] : (window as any)[key]
},
set(target, key, value) {
target[key] = value // 写操作全部拦截到 fakeWindow
return true
},
has(target, key) {
return key in target || key in window
}
})
}
}
子应用里写 window.xxx = 123,实际写到了 fakeWindow 上,不会污染真正的 window。读的时候先找 fakeWindow,找不到再降级到真 window。
这个方案解决了多实例问题,但有个绕不开的麻烦:子应用的代码怎么让它用 proxy 而不是真 window?
qiankun 的做法是拿到子应用的 JS 代码文本,用 (function(window, self, globalThis) { ... }).call(proxy, proxy, proxy, proxy) 包一层执行。等于在运行时把子应用代码的 window 引用偷梁换柱了。
听着挺巧妙,但实际用起来坑不少。
我在项目里踩过的 Proxy 沙箱的坑
有一次子应用里用了个第三方地图 SDK,它内部用 window.addEventListener 绑了一堆事件。问题是这个 SDK 的代码不是通过 qiankun 的 entry 加载的,而是在 HTML 里用 <script> 标签直接引的 CDN。
结果这部分代码跑在真 window 上,而子应用自己的代码跑在 proxy 上。两边的 window 不是同一个对象,事件监听和业务逻辑之间怎么通信就成了问题。排查了大半天,最后的解法是把 SDK 改成动态 import 的方式加载,让它也走 qiankun 的沙箱。
还有个经典问题:window.location 是不能被 Proxy 完整代理的(涉及到浏览器安全策略),qiankun 对这块做了特殊处理,但偶尔还是会有奇怪的表现。
wujie 的思路:直接用 iframe 的 window
wujie 选了一条完全不同的路------用 iframe 来跑 JS。
不是把子应用渲染在 iframe 里(那就回到原始时代了),而是创建一个隐藏的 iframe,让子应用的 JS 在 iframe 的 window 环境下执行,但 DOM 操作代理到主应用的文档上。
ts
// wujie 的核心思路(简化版)
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
// JS 跑在 iframe 里 → 天然隔离,每个 iframe 有自己的 window
const sandboxWindow = iframe.contentWindow
// 但 DOM 操作要代理出去:
// sandboxWindow.document → 指向主应用中子应用的挂载容器
Object.defineProperty(sandboxWindow, 'document', {
get: () => proxyDocument // 指向主应用中的 shadow DOM 容器
})
这个设计很聪明。iframe 的 window 是浏览器原生隔离的,不需要自己实现沙箱逻辑。setTimeout、setInterval、事件监听、location 这些全都是天然独立的。
qiankun 用 Proxy 模拟了一个不完美的 window,wujie 直接拿了一个真的。
CSS 隔离:这块的差距更大
JS 沙箱好歹都有方案,CSS 隔离才是真正让人头疼的地方。
qiankun 的 CSS 隔离:三种模式
动态样式表切换(默认):子应用激活时插入样式,卸载时移除。能防止子应用之间互相影响,但子应用的样式可能影响主应用。
Scoped CSS(experimentalStyleIsolation):运行时给子应用的所有 CSS 选择器加前缀。
css
/* 原始样式 */
.header { color: red; }
body { font-size: 14px; }
/* 加前缀后 */
div[data-qiankun="app1"] .header { color: red; }
div[data-qiankun="app1"] body { font-size: 14px; } /* body 选择器加前缀后其实没啥用 */
这个方案的问题:运行时解析和改写 CSS 有性能开销,而且有些选择器处理不了------比如 body、html、@keyframes 名字冲突。我之前项目里子应用用了 Ant Design,那个全局样式改写出来的效果,一言难尽。
Shadow DOM(strictStyleIsolation):用 Shadow DOM 包裹子应用。理论上完美隔离,但实际上问题更多:
ts
// qiankun 的 Shadow DOM 模式
const container = document.getElementById('app-container')
const shadow = container.attachShadow({ mode: 'open' })
// 子应用渲染到 shadow 内部
// 问题来了:
// 1. 子应用里的弹窗(Modal)通常 append 到 document.body
// → 跑到 Shadow DOM 外面了 → 样式丢失
// 2. 子应用里用 document.querySelector → 查不到 shadow 内的元素
// 3. React 17 之前的事件委托挂在 document 上 → shadow 内事件冒泡有问题
所以 qiankun 官方文档对 Shadow DOM 模式的态度是"谨慎使用"。很多团队实际上在用的是动态样式表 + BEM 命名约定这种半自动的隔离。
wujie 的 CSS 隔离:Web Component + Shadow DOM
wujie 用的也是 Shadow DOM,但配合它的 iframe JS 执行方案,体验好很多。
子应用的 DOM 渲染在一个 Web Component 的 Shadow DOM 里,JS 跑在 iframe 里。iframe 里的 document 被代理到 Shadow DOM 容器上,所以子应用调 document.querySelector 查到的是 Shadow DOM 内的元素,弹窗也能 append 到正确的位置。
ts
// wujie 的 Web Component(简化)
class WujieApp extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' })
// 子应用的 HTML/CSS 都渲染在这个 shadow 里
}
}
customElements.define('wujie-app', WujieApp)
// iframe 里的 document 操作被劫持:
// document.body.appendChild(modal)
// → 实际 append 到 shadow DOM 内的 body 容器
// → 样式不会丢失
弹窗问题解决了吗?大部分场景是的。但也不是完全没坑------有些组件库会往 window.document.body(注意是 window 上取的 document)上挂东西,如果恰好绕过了 proxy,还是会逃逸。
设计哲学对比
两个框架的取舍逻辑,核心就一句话:
qiankun 选择在同一个页面上下文里做隔离,wujie 选择用原生隔离能力再做桥接。
qiankun 的路线:共享 window → Proxy 拦截 → 运行时改写 CSS → 在一个上下文里模拟多个沙箱。好处是子应用和主应用天然在同一个 DOM 树里,通信方便、路由同步简单。代价是隔离不彻底,边界情况多,要处理的 hack 也多。
wujie 的路线:iframe 跑 JS → Shadow DOM 渲染 UI → 通过 proxy 桥接两侧。好处是隔离干净,很多 qiankun 的历史坑天然不存在。代价是架构复杂度高,iframe 和主应用之间的 DOM 代理逻辑如果出 bug,排查成本不低。
画个表可能更清楚:
| 维度 | qiankun | wujie |
|---|---|---|
| JS 隔离 | Proxy 模拟 fakeWindow | iframe 原生 window |
| CSS 隔离 | 动态样式 / Scoped / Shadow DOM | Web Component Shadow DOM |
| 多实例 | ProxySandbox 支持 | 天然支持(每个 iframe 独立) |
| 弹窗逃逸 | Shadow DOM 模式下有问题 | 基本解决(document 被代理) |
| 子应用改造成本 | 需要导出生命周期钩子 | 相对较低 |
| 通信复杂度 | 低(同上下文) | 中(跨 iframe) |
| 社区生态 | 成熟,用的人多 | 较新,踩坑资料少 |
选型的时候怎么想
我个人的判断标准比较简单粗暴:
子应用技术栈比较统一(比如都是 React 或都是 Vue),团队对微前端有经验,选 qiankun。它的坑多但资料也多,大部分问题都有现成的解法。
子应用技术栈混乱(jQuery、Vue2、React18 啥都有),或者子应用是那种不太可能配合改造的老系统,wujie 的隔离能力会省很多事。iframe 原生隔离把很多脏活揽过去了。
如果是新项目,说实话我会先考虑要不要用微前端。Module Federation 或者简单的 iframe 嵌入能不能解决问题?微前端引入的复杂度不低,别为了用而用。
还有一点------沙箱隔离只是微前端的一个维度。路由同步、应用通信、公共依赖管理、构建部署流程,这些加在一起才是完整的工程决策。只看沙箱就选型,容易后面翻车。
回过头看
沙箱隔离这个问题,本质上是在问:多个独立应用塞到一个页面里,怎么让它们互不干扰?
qiankun 的答案是"我在 JS 层面给你隔开",wujie 的答案是"我让浏览器帮你隔开"。两个思路没有绝对的高下,只有场景的匹配度。
不过有一点是确定的:不管用哪个框架,上线前一定要跑一轮子应用并行加载的测试,重点关注全局变量污染和样式冲突。这两个问题不在开发阶段暴露,就一定会在生产环境暴露。到时候定位起来,比一开始就解决要痛苦十倍。