Skip to main content

微前端沙箱隔离技术

在微前端架构中,多个子应用共享同一运行时环境,容易因全局变量污染和样式冲突引发不可预见的错误。例如:

  • 样式污染:某子应用定义的 .ant-btn 样式意外覆盖主应用按钮样式
  • 变量冲突:子应用 A 通过 window.token = 'A' 篡改主应用认证令牌
  • 原型污染:子应用重写 Array.prototype.includes 引发其他应用异常

而沙箱隔离技术通过为每个子应用创建独立的环境,解决上述问题。下面以 qiankun 为例,探讨其沙箱隔离机制的技术实现。

一、样式隔离

1. 动态样式隔离(默认)

qiankun 默认开启,可以确保单实例场景子应用间的样式隔离,但无法确保主应用跟子应用、或多实例场景的子应用样式隔离,其原理是通过监听子应用的生命周期,动态注入或移除其样式表。当子应用挂载时,样式被插入页面;卸载时,样式自动移除。

适用场景:单页面切换子应用的简单场景

技术实现

// 样式注入核心逻辑
const mountStyles = (app, styles) => {
const styleStore = new Map();

app.mount(() => {
styles.forEach(style => {
const styleElement = document.createElement('style');
styleElement.textContent = style;
document.head.appendChild(styleElement);
styleStore.set(style, styleElement);
});
});

app.unmount(() => {
styleStore.forEach(element => element.remove());
});
};

深度剖析

  • 运行时开销:每次挂载/卸载需操作 DOM,存在重排风险(可通过 requestAnimationFrame 优化)
  • 典型缺陷案例:主应用使用 !important 的全局样式仍可能穿透子应用样式

2. 影子 DOM 沙箱(Shadow DOM)

手动开启后,qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。

开启方式

start({ sandbox: { strictStyleIsolation: true } });

高级配置方案

start({
sandbox: {
strictStyleIsolation: true,
// 允许主应用样式穿透
styleSheetTransform: (cssText, appName) =>
`:host { ${cssText} }` // 将全局样式限定在 Shadow DOM 内部
}
});

实现原理

  • 为子应用容器创建 Shadow Root。
  • 子应用的 DOM 结构和样式仅作用于 Shadow Tree 内部。
  • 示例:若主应用和子应用均有 .App 类名,Shadow DOM 可确保两者互不干扰。

局限性

  • 第三方 UI 库(如 Element UI)的全局样式可能失效。
  • 子应用无法直接操作主文档 DOM(如全局弹窗)。

3. 作用域沙箱(Scoped CSS)

手动开启后,qiankun 会改写子应用所添加的样式,为子应用的样式规则添加唯一前缀选择器(如 div[data-qiankun="appName"]),限制其作用范围。例如,.app-main 会被重写为 div[data-qiankun="appName"] .app-main

开启方式

start({
sandbox: { experimentalStyleIsolation: true }
});

实现原理

  1. 解析子应用的 <style><link> 样式资源。
  2. 为所有选择器添加父级属性选择器前缀。
  3. 将外部样式表转换为内联样式以支持重写。

优缺点

  • 优点:兼容性强,无需修改子应用代码。
  • 缺点:解析和重写样式规则会增加运行时开销,且无法处理动态插入的样式。

二、JS 隔离

1. SnapshotSandbox 快照沙箱

基于 diff 实现的沙箱,用于兼容不支持 Proxy 的低版本浏览器。

实现原理

把主应用的 window 对象做浅拷贝,将 window 的键值对存成一个 Hash Map

之后无论子应用对 window 做任何改动,在恢复环境时,把这个 Hash Map 又应用回 window 即可。

激活时:保存当前 window 对象的快照,并恢复子应用上次卸载前的状态。

卸载时:对比当前 window 与快照的差异,记录修改并还原初始状态。

class SnapshotSandbox {
active() {
this.windowSnapshot = {...window};
Object.keys(this.modifyPropsMap).forEach(p => window[p] = this.modifyPropsMap[p]);
}
inactive() {
Object.keys(window).forEach(p => {
if (window[p] !== this.windowSnapshot[p]) {
this.modifyPropsMap[p] = window[p];
window[p] = this.windowSnapshot[p];
}
});
}
}

2. LegacySandbox 单例代理沙箱

上面的 SnapshotSandbox 有一个问题:每次子应用 unmount 时都要对每个属性值做一次 Diff,类似这样:

for (const prop in window) {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录子应用的变更
this.modifyPropsMap[prop] = window[prop];
// 恢复主应用的环境
window[prop] = this.windowSnapshot[prop];
}
}

如果有 1000 个属性那就得对比 1000 次。

LegacySandbox 的想法就是通过监听对 window 的修改来直接记录 Diff 内容,因为只要对 window 属性进行设置,那么就会有两种情况:

  • 如果是新增属性,则存到 addedMap
  • 如果是更新属性,则把原来的键值存到 prevMap,把新的键值存到 newMap

通过 addedMap, prevMapnewMap 这三个变量就能反推出子应用以及原来环境的变化,qiankun 也能以此作为恢复环境的依据。

3. ProxySandbox 多例代理沙箱

前面两种沙箱都是 单例模式 下使用的沙箱。即一个页面中只能同时展示一个子应用,无论是 set 还是 get 都是直接操作 window 对象。

在这种单例模式下,子应用修改全局变量时会在原来的 window 上做修改,如果在同个路由页面下展示多个子应用,则会有环境变量污染的问题。

为了避免真实的 window 被污染,qiankun 实现了 ProxySandbox。它的想法是:

  • 把当前 window 的一些原生属性(documentlocation 等)拷贝出来,单独放在一个对象上,这个对象也称为 fakeWindow
  • 之后对每个子应用分配一个 fakeWindow
  • 当子应用修改全局变量时:
    • 如果是原生属性,则修改全局的 window
    • 如果不是原生属性,则修改 fakeWindow 里的内容
  • 子应用获取全局变量时:
    • 如果是原生属性,则从 window 里拿
    • 如果不是原生属性,则优先从 fakeWindow 里获取

这样一来连恢复环境都不需要了,因为每个子应用都有自己一个环境,当在 active 时就给这个子应用分配一个 fakeWindow,当 inactive 时就把这个 fakeWindow 存起来,以便之后再利用。

类似这样:

class ProxySandbox {
constructor() {
const fakeWindow = {};
this.proxy = new Proxy(fakeWindow, {
get: (target, p) => target[p] || window[p],
set: (target, p, value) => (target[p] = value)
});
}
}

优势

  • 完全隔离子应用的全局变量,支持多实例并行。
  • 高性能,无需遍历 window 对象。