这个难度要比CSS隔离难一些了。要考虑的东西也非常多。
方案:
使用 WebAssembly 进行隔离,WebAssembly 会被限制运行在一个安全的沙箱执行环境中,但运行时不能直接调用 Web API
使用 Web Worker 进行隔离,每个 Worker 有自己独立的 Isolate 实例。只能使用部分 Web API。
iframe 隔离: 空白页(
src="about:blank"
) iframe 隔离和服务端同源的 iframe 隔离方案设计。不仅可以利用不同的浏览上下文实现彻底的微应用隔离,与普通 iframe 方案而言,还可以解决白屏体验问题,是微前端框架实现隔离的重要手段;但是无法调用history
API,URL 状态无法同步。iframe + Proxy 隔离: 解决空白页 iframe 隔离无法调用
history
API 的问题,并可用于解决 iframe 方案中无法处理的 URL 状态同步问题;快照隔离: 浏览器无法兼容 Proxy 时(ES6以下),可以通过简单的快照实现
window
变量的隔离,但是这种隔离方案限制较多,例如无法实现主子应用的隔离,无法实现多个微应用并存的隔离。当然大多数场景是一个时刻运行一个微应用,但是是一种兼容性良好的隔离方案(2024年了让我看看谁还在用IE6、7、8)。
讲方案之前,先给大家简单聊一下V8隔离方案
V8隔离
如果我们在最外层同时声明两个名字一样的值,就会导致变量名冲突(2024年非必要不要用var了,即便用了var后者也会覆盖前者,一样有bug)。
隔离的最初需求就是为了解决全局变量产生冲突,而单页面应用全局变量冲突的可能性更高。如果子应用处于 MPA 模式,那么 JS 可以做到天然隔离,这都得益于V8引擎对 JS 的执行上下文做了隔离处理。
V8核心概念:
Isolate:Isolate 是隔离的 V8 运行时实例,在 V8 中使用 Isolate 来实现 Web 页面、Web Workder 以及 Chrome 插件中的 JavaScript 运行时环境隔离。(物理隔离,同一个标签页中如果存在多个相同站点的页面,那么页面会共享 Isolate)
Handle:
Handle
(指向 JavaScript 对象在堆中存储的地址,如果 JavaScript 对象需要被释放,则首先会从 HandleScope 对应的栈中推出相应的 Handle,然后会被垃圾回收器标注,方便后续可以快速通过释放的 Handle 寻找需要被释放的 JavaScript 对象所在的内存地址。);HandleScope
(主要用于管理 JavaScript 对象的生命周期的范围,在 C++ 中会开辟栈空间来存储 Handle,当栈中的 Handle 释放后,会从栈中推出该 Handle。如果释放 HandleScope,则栈中所有的 Handle 都会被释放,因此 Handle Scope 便于管理内部所有 Handle 的自动释放。)Context:JavaScript 中的
window
对象隔离则是通过 Context 来实现。所以一个页面有一个Isolate,一个Isolate有多个Context;全局执行上下文栈:在 Isolate 中执行 JavaScript,可以通过切换 Context 来实现不同 JavaScript 代码的运行,可以简单理解为用于切换 JavaScript 中的window
变量;执行上下文栈:在当前的 Context 中运行时,会有执行上下文栈的概念,即为:全局上下文、函数上下文以及eval
上下文。JavaScript 的执行通过上下文栈进行控制,当函数被执行时,当前函数对应的上下文会被推入一个上下文栈,当函数执行完毕后,上下文栈会弹出该函数的上下文,并将控制权返回给之前的上下文
V8效果:
如果两个 JS 文件在相同的全局执行上下文,声明的全局属性会产生覆盖
如果两个 JS 文件在不同的全局执行上下文,声明的全局属性互不干扰
以后还能单开一篇深层V8原理给大家(疯狂画饼骗关注,嘿嘿嘿)
iframe隔离与iframe+ Proxy 隔离
iframe隔离在 V8 的隔离中我们了解到可以通过创建不同的 Isolate 或者 Context 对 JS 代码进行上下文隔离处理,但是这种能力没有直接开放给浏览器,因此我们无法直接利用 Web API 实现微应用的 JS 隔离。但是在浏览器中创建 iframe 会创建相应的全局执行上下文栈,用于切换主应用和iframe 应用的全局执行上下文环境,因此可以通过在应用框架中创建空白的 iframe 来隔离微应用的 JS 运行环境。
思路:
通过请求获取后端的微应用列表数据(并进行预渲染),动态创建主导航
根据导航切换微应用,切换时会跨域请求微应用 JS 的文本内容并进行缓存处理
切换微应用的同时创建一个同域的 iframe 应用,请求主应用下空白的 HTML 进行渲染
DOM 渲染完成后,微应用的 JS 会在 iframe 环境中通过 Script 标签进行隔离执行
但是存在以下问题未解决
src = about:blank
iframe 的history
无法正常工作,框架的路由功能丢失虽然 DOM 环境天然隔离,却无法使得 iframe 中的 Modal 相对于主应用居中
主应用和微应用的 URL 状态没有同步
iframe+ Proxy 隔离:
我们将 src = about:blank
iframe 中的 history
使用主应用的 history
代替运行
不同微应用可以拥有各自 iframe 对应的全局上下文执行环境,可以实现 JS 的彻底隔离
使用主应用的
history
,iframe 可以设置成src = about:blank
,不会产生运行时错误使用主应用的
history
,未来可以处理主应用和微应用的历史会话同步问题
我们就可以修改上面的思路4为:
DOM 渲染完成后,微应用的 JS 会在 iframe 环境中通过iframe + Proxy + With代理隔离执行JS。
!!!只是提供简单的解决思路,真正要实现会话同步还需要考虑主子应用之间的路由冲突问题等等
Proxy
可以对需要访问的对象进行拦截,并可以通过拦截函数对返回值进行修改,这种特性可以使我们在微应用中访问 window
对象的属性时,返回定制化的属性值
代码demo:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JS隔离</title>
</head>
<body>
<!-- 主应用导航 -->
<div id="nav"></div>
<!-- 主应用内容区 -->
<div id="container"></div>
<script type="text/javascript">
// 隔离类
class IframeSandbox {
// 沙箱配置信息
options = null;
// iframe 实例
iframe = null;
// 被代理的 iframe 的 Window 实例
iframeWindow = null;
// 是否执行过 JS
execScriptFlag = false;
constructor(options) {
this.options = options;
// 创建 iframe 时浏览器会创建新的全局执行上下文,用于隔离主应用的全局执行上下文
this.iframe = this.createIframe();
// 获取 iframe 的 Window 实例
this.iframeWindow = this.iframe.contentWindow;
// 代理 iframe 的 Window 实例
this.proxyIframeWindow();
}
// 创建 iframe
createIframe() {
// 获取 iframe 基础配置
const { rootElm, id, url } = this.options;
const iframe = window.document.createElement("iframe");
const attrs = {
src: "about:blank", // 默认 src:about:blank不可变,仅作为隔离使用
"app-id": id, // id
"app-src": url, // url
style: "border:none;width:100%;height:100%;", // 默认CSS
};
Object.keys(attrs).forEach((name) => {
iframe.setAttribute(name, attrs[name]);
});
rootElm?.appendChild(iframe);
return iframe;
}
// 被绑定的函数本身没有 prototype
// 识别出被绑定的函数,构造函数
isBoundedFunction(fn) {
return (
fn.name.indexOf("bound ") === 0 && !fn.hasOwnProperty("prototype")
);
}
// 可以识别 Object、Array 等原生构造函数,也可以识别用户自己创建的构造函数
isConstructable(fn) {
return (
fn.prototype &&
// 通常情况下构造函数和类的 prototype.constructor 指向本身
fn.prototype.constructor === fn &&
// 通常情况下构造函数和类都会存在 prototype.constructor,因此长度至少大于 1
// 需要注意普通函数中也会存在 prototype.constructor,
// 因此如果 prototype 有自定义属性或者方法,那么可以判定为类或者构造函数,因此这里的判断是大于 1
// 注意不要使用 Object.keys 进行判断,Object.keys 无法获取 Object.defineProperty 定义的属性
Object.getOwnPropertyNames(fn.prototype).length > 1
);
}
// 因此这里需要重新将这些原生 native api 的 this 修正为 iframe 的 window
getTargetValue(target, prop) {
const value = target[prop];
// 过滤出 window.alert、window.addEventListener 等 API
// 修正this 指向
if (
typeof value === "function" &&
!this.isBoundedFunction(value) &&
!this.isConstructable(value)
) {
// 修正 value 的 this 指向为 target
const boundValue = Function.prototype.bind.call(value, target);
// 重新恢复 value 在 bound 之前的属性和原型(bind 之后会丢失)
for (const key in value) {
boundValue[key] = value[key];
}
// 如果原来的函数存在 prototype 属性,而 bound 之后丢失了,那么重新设置回来
if (
value.hasOwnProperty("prototype") &&
!boundValue.hasOwnProperty("prototype")
) {
boundValue.prototype = value.prototye;
}
return boundValue;
}
return value;
}
// 代理 iframe 的 Window 实例
proxyIframeWindow() {
this.iframeWindow.proxy = new Proxy(this.iframeWindow, {
get: (target, prop) => {
// 只解决 src:about:blank 下的 history 同域问题并没有真正设计主子应用的路由冲突问题
if (prop === "history" || prop === "location") {
// 获取主应用的 history 实例或者location 实例
return window[prop];
}
if (prop === "window" || prop === "self") {
// 获取iframe应用的 window 实例
return this.iframeWindow.proxy;
}
// 获取主应用的 window 实例
return this.getTargetValue(target, prop);
},
set: (target, prop, value) => {
target[prop] = value;
return true;
},
has: (target, prop) => true,
});
}
// 执行 JS
execScript() {
const scriptElement =
this.iframeWindow.document.createElement("script");
scriptElement.textContent = `
(function(window) {
with(window) {
${this.options.scriptText}
}
}).bind(window.proxy)(window.proxy);
`;
this.iframeWindow.document.head.appendChild(scriptElement);
}
// 激活
async active() {
this.iframe.style.display = "block";
// 如果已经通过 Script 加载并执行过 JS,则无需重新加载处理
if (this.execScriptFlag) return; // 跳过
this.execScript();
this.execScriptFlag = true;
}
// 预渲染
prerender() {
this.iframe.style.display = "none";
// 如果已经通过 Script 加载并执行过 JS,则无需重新加载处理
if (this.execScriptFlag) return; // 跳过
this.execScript();
this.execScriptFlag = true;
}
// 失活
// INFO: JS 加载以后无法通过移除 Script 标签去除执行状态
// INFO: 因此这里不是指代失活 JS,如果是真正想要失活 JS,需要销毁 iframe 后重新加载 Script
inactive() {
this.iframe.style.display = "none";
}
// 销毁沙箱
destroy() {
this.options = null;
this.execScriptFlag = false;
if (this.iframe) {
this.iframe.parentNode?.removeChild(this.iframe);
}
this.iframe = null;
}
}
// 微应用管理
class MicroAppManager {
scriptText = ""; // 缓存微应用的脚本文本,目前仅支持一个微应用
// 隔离实例
sandbox = null;
// 微应用挂载的根节点
rootElm = null;
constructor(rootElm, app) {
this.rootElm = rootElm;
this.app = app;
}
// 获取 JS 文本(微应用服务需要支持跨域请求)
async fetchScript() {
try {
const res = await window.fetch(this.app.script);
return await res.text();
} catch (err) {
console.error(err);
}
}
// 预渲染
rerender() {
// 当前主线程中存在多个并行执行的 requestIdleCallback 时,浏览器会根据空闲时间来决定要在当前 Frame 还是下一个 Frame 执行
requestIdleCallback(async () => {
// 预请求资源
this.scriptText = await this.fetchScript();
// 预渲染处理
this.idlePrerender();
});
}
// 预渲染
idlePrerender() {
// 预渲染
requestIdleCallback((dealline) => {
// 打印 空闲时
console.log("deadline: ", dealline.timeRemaining());
// 这里只有在浏览器非常空闲时才可以进行操作
if (dealline.timeRemaining() > 40) {
// TODO: active 中还可以根据 Performance 性能面板进行再分析,如果内部的某些操作比较耗时,可能会影响下一帧的渲染,则可以放入新的 requestIdleCallback 中进行处理
// 除此之外,例如在子应用中可以先生成虚拟 DOM 树,预渲染不做 DOM 更改处理,真正切换应用的时候进行 DOM 挂载
// 也可以在挂载应用的时候放入 raF 中进行处理
this.active(true);
} else {
this.idlePrerender();
}
});
}
// 激活
async active(isPrerender) {
// 缓存资源处理
if (!this.scriptText) {
this.scriptText = await this.fetchScript();
}
// 如果没有创建沙箱,则实时创建
// 需要注意只给激活的微应用创建 iframe 沙箱,因为创建 iframe 会产生内存损耗
if (!this.sandbox) {
this.sandbox = new IframeSandbox({
rootElm: this.rootElm,
scriptText: this.scriptText,
url: this.app.script,
id: this.app.id,
});
}
isPrerender ? this.sandbox.prerender() : this.sandbox.active();
}
// 失活
inactive() {
this.sandbox?.inactive();
}
}
// 微前端管理
class MicroManager {
// 微应用实例映射表
appsMap = new Map(); // 微应用实例映射表
rootElm = null; // 微应用挂载的根节点信息
constructor(rootElm, apps) {
this.rootElm = rootElm;
// this.setAppMaps(apps);
this.initApps(apps);
}
// 初始化微应用
initApps(apps) {
apps.forEach((app) => {
const appManager = new MicroAppManager(this.rootElm, app);
this.appsMap.set(app.id, appManager);
if (app.prerender) {
appManager.rerender();
}
});
}
// 激活微应用
activeApp(id) {
const current = this.appsMap.get(id);
current && current.active();
}
// 失活微应用
inactiveApp(id) {
const current = this.appsMap.get(id);
current && current.inactive();
}
}
// 主应用管理
class MainApp {
microApps = []; // 微应用列表
microManager = null; // 微前端管理实例
constructor() {
this.init();
}
async init() {
this.microApps = await this.fetchMicroApps(); // 获取微应用列表
this.createNav(); // 创建导航
this.navClickListener(); // 监听导航点击事件
this.hashChangeListener(); // 监听 hash 变化
// 创建微前端管理实例
this.microManager = new MicroManager(
document.getElementById("container"),
this.microApps
);
}
// 从主应用服务器获请求微应用列表信息
async fetchMicroApps() {
/**
* name: 微应用名称
* id: 微应用 ID
* script: 微应用 JS 文件地址
* style: 微应用 CSS 文件地址
* mount: 挂载到 window 上的启动函数 window.micro1_mount
* unmount: 挂载到 window 上的启动函数 window.micro1_unmount
* prerender: 预渲染函数(用于预加载,进行性能优化)
*/
try {
const res = await window.fetch("/microapps", {
method: "post",
});
return await res.json();
} catch (err) {
console.error(err);
}
}
// 根据微应用列表创建主导航
createNav(microApps) {
const fragment = new DocumentFragment(); // 创建文档片段
this.microApps?.forEach((microApp) => {
// TODO: APP 数据规范检测 (例如是否有 script)
const button = document.createElement("button");
button.textContent = microApp.name; // 导航按钮显示微应用名称
button.id = microApp.id; // 导航按钮 id
fragment.appendChild(button); // 添加到文档片段
});
nav.appendChild(fragment); // 添加到导航
}
// 导航点击的监听事件
navClickListener() {
const nav = document.getElementById("nav");
nav.addEventListener("click", (e) => {
// 并不是只有 button 可以触发导航变更,例如 a 标签也可以,因此这里不直接处理微应用切换,只是改变 Hash 地址
// 不会触发刷新,类似于框架的 Hash 路由
window.location.hash = event?.target?.id;
});
}
// hash 路由变化的监听事件
hashChangeListener() {
// 监听 Hash 路由的变化,切换微应用(这里设定一个时刻只能切换一个微应用)
window.addEventListener("hashchange", () => {
this.microApps?.forEach(async ({ id }) => {
id === window.location.hash.replace("#", "")
? this.microManager.activeApp(id)
: this.microManager.inactiveApp(id);
});
});
}
}
new MainApp();
</script>
</body>
</html>
快照隔离
Window 快照会完全复用主应用的 Context,本质上没有形成隔离,仅仅是在主应用 Context 的基础上记录运行时需要的差异属性,每一个微应用内部都需要维护一个和主应用 window
对象存在差异的对象。不管是调用 Web API 还是设置 window
属性值,本质上仍然是在主应用的 window
对象上进行操作,只是会在微应用切换的瞬间恢复主应用的 window
对象,此方案无法做到真正的 Context 隔离,并且在一个时刻只能运行一个微应用,无法实现多个微应用同时运行。
如果微应用在运行时仅仅需要隔离 window
对象的属性冲突,那么快照隔离是一个非常不错的隔离方案
实现思路:
通过请求获取后端的微应用列表数据,动态创建主导航
根据导航切换微应用,切换时会跨域请求微应用 JS 的文本内容并进行缓存处理
切换微应用时需要先失活已经激活的微应用,确保一个时刻只有一个微应用运行
运行微应用前需要将微应用之前运行记录的 DIFF 对象和主应用的
window
快照进行合并,从而恢复微应用之前运行的 window 对象失活微应用前需要先通过当前运行时的
window
对象和主应用window
快照进行对比,计算出本次运行时的 DIFF 差异对象,为下一次恢复微应用的window
对象做准备,同时通过快照恢复主应用的window
对象
我们只需要修改MicroAppSandbox、MicroApp、MainApp即可实现
class MicroAppSandbox {
// 配置信息
options = null;
// 是否执行过 JS
exec = false;
// 微应用 JS 运行之前的主应用 window 快照
mainWindow = {};
// 微应用 JS 运行之后的 window 对象(用于理解)
microWindow = {};
// 微应用失活后和主应用的 window 快照存在差异的属性集合
diffPropsMap = {};
constructor(options) {
this.options = options;
// 重新包装需要执行的微应用 JS 脚本
this.wrapScript = this.createWrapScript();
}
createWrapScript() {
// 微应用的代码运行在立即执行的匿名函数中,隔离作用域
return `;(function(window){
${this.options.scriptText}
})(window)`;
}
execWrapScript() {
// 在全局作用域内执行微应用代码
(0, eval)(this.wrapScript);
}
// 微应用 JS 运行之前需要记录主应用的 window 快照(用于微应用失活后的属性差异对比)
recordMainWindow() {
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.mainWindow[prop] = window[prop];
}
}
}
// 微应用 JS 运行之前需要恢复上一次微应用执行后的 window 对象
recoverMicroWindow() {
// 如果微应用和主应用的 window 对象存在属性差异
// 上一次微应用 window = 主应用 window + 差异属性(在微应用失活前会记录运行过程中涉及到更改的 window 属性值,再次运行之前需要恢复修改的属性值)
Object.keys(this.diffPropsMap).forEach((p) => {
// 更改 JS 运行之前的微应用 window 对象,注意微应用本质上共享了主应用的 window 对象,因此一个时刻只能运行一个微应用
window[p] = this.diffPropsMap[p];
});
// 用于课程理解
this.microWindow = window;
}
recordDiffPropsMap() {
// 这里的 microWindow 是微应用失活之前的 window(在微应用执行期间修改过 window 属性的 window)
for (const prop in this.microWindow) {
// 如果微应用运行期间存在和主应用快照不一样的属性值
if (
window.hasOwnProperty(prop) &&
this.microWindow[prop] !== this.mainWindow[prop]
) {
// 记录微应用运行期间修改或者新增的差异属性(下一次运行微应用之前可用于恢复微应用这一次运行的 window 属性)
this.diffPropsMap[prop] = this.microWindow[prop];
// 恢复主应用的 window 属性值
window[prop] = this.mainWindow[prop];
}
}
}
active() {
// 记录微应用 JS 运行之前的主应用 window 快照
this.recordMainWindow();
// 恢复微应用需要的 window 对象
this.recoverMicroWindow();
if (this.exec) {
return;
}
this.exec = true;
// 执行微应用(注意微应用的 JS 代码只需要被执行一次)
this.execWrapScript();
}
inactive() {
// 清空上一次记录的属性差异
this.diffPropsMap = {};
// 记录微应用运行后和主应用 Window 快照存在的差异属性
this.recordDiffPropsMap();
console.log(
`${this.options.appId} diffPropsMap: `,
this.diffPropsMap
);
}
}
class MicroApp {
scriptText = "";
sandbox = null;
rootElm = null;
constructor(rootElm, app) {
this.rootElm = rootElm;
this.app = app;
}
async fetchScript(src) {
try {
const res = await window.fetch(src);
return await res.text();
} catch (err) {
console.error(err);
}
}
async active() {
if (!this.scriptText) {
this.scriptText = await this.fetchScript(this.app.script);
}
if (!this.sandbox) {
this.sandbox = new MicroAppSandbox({
scriptText: this.scriptText,
appId: this.app.id,
});
}
this.sandbox.active();
// 获取元素并进行展示,这里先临时约定微应用往 body 下新增 id 为 `${this.app.id}-dom` 的元素
const microElm = document.getElementById(`${this.app.id}-dom`);
if (microElm) {
microElm.style = "display: block";
}
}
inactive() {
// 获取元素并进行隐藏,这里先临时约定微应用往 body 下新增 id 为 `${this.app.id}-dom` 的元素
const microElm = document.getElementById(`${this.app.id}-dom`);
if (microElm) {
microElm.style = "display: none";
}
this.sandbox?.inactive();
}
}
class MicroApps {
appsMap = new Map();
rootElm = null;
constructor(rootElm, apps) {
this.rootElm = rootElm;
this.setAppMaps(apps);
}
setAppMaps(apps) {
apps.forEach((app) => {
this.appsMap.set(app.id, new MicroApp(this.rootElm, app));
});
}
prefetchApps() {}
activeApp(id) {
const app = this.appsMap.get(id);
app?.active();
}
inactiveApp(id) {
const app = this.appsMap.get(id);
app?.inactive();
}
}
class MainApp {
microApps = [];
microAppsManager = null;
constructor() {
this.init();
}
async init() {
this.microApps = await this.fetchMicroApps();
this.createNav();
this.navClickListener();
this.hashChangeListener();
this.microAppsManager = new MicroApps(
document.getElementById("container"),
this.microApps
);
}
async fetchMicroApps() {
try {
const res = await window.fetch("/microapps", {
method: "post",
});
return await res.json();
} catch (err) {
console.error(err);
}
}
createNav(microApps) {
const fragment = new DocumentFragment();
this.microApps?.forEach((microApp) => {
const button = document.createElement("button");
button.textContent = microApp.name;
button.id = microApp.id;
fragment.appendChild(button);
});
nav.appendChild(fragment);
}
navClickListener() {
const nav = document.getElementById("nav");
nav.addEventListener("click", (e) => {
// 此时有一个微应用已经被激活运行
console.log("主应用 window.a: ", window.a);
window.location.hash = event?.target?.id;
});
}
hashChangeListener() {
window.addEventListener("hashchange", () => {
// 需要失活应用,为了确保一个时刻只能激活一个应用(这里可以设计微应用的运行状态,根据状态进行处理)
this.microApps?.forEach(async ({ id }) => {
if (id !== window.location.hash.replace("#", "")) {
this.microAppsManager.inactiveApp(id);
}
});
// 没有微应用被激活时,主应用的 window 对象会被恢复
console.log("恢复主应用的 window.a: ", window.a);
// 激活应用
this.microApps?.forEach(async ({ id }) => {
if (id === window.location.hash.replace("#", "")) {
this.microAppsManager.activeApp(id);
}
});
});
}
}
new MainApp();
可以解决
let
或者const
声明变量的隔离问题可以解决微应用之间的全局属性隔离问题,包括使用未限定标识符的变量、
this
无法实现主应用和微应用同时运行时的全局属性隔离问题