前言
本文介绍了微前端的概念、核心点及其技术方案。文章还对比了主流的微前端技术方案,如原生 iframe 和基于 web components 的无界框架,分析了它们的优缺点。通过案例源码和详细的技术解析,帮助读者理解微前端的实现原理和应用场景。
案例的源码在:🥳源码
文章太长:剩余部分在万字理解微前端内容!!!🔥(二)qiankun、Micro App、Module Federation
一、微前端简介
1. 什么是微前端?
微前端是一种将多个前端应用组合成一个整体的架构风格,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品。
微前端概念的误区:
- 微前端不是一门具体的技术,而是整合了技术、策略和方法,可能会以脚手架、配套工具和规范约束等等成体系的形式综合呈现,是一种宏观上的架构。这种架构目前有多种方案,各有利弊,但只要适用业务场景的就是好方案。
- 微前端本身并没有技术栈的约束。每一套微前端方案的设计,都是基于实际需求出发。如果是多团队统一使用了 React 技术栈,可能对微前端方案的跨技术栈使用并没有要求;如果是多团队同时使用了 React 和 Vue 技术栈,可能就对微前端的跨技术栈要求比较高。
- 微前端要求各个应用能独立开发、测试、部署,但并不要求各个应用能独立运行。也就是说,微前端的粒度不一定是应用级的,也有可能是页面级,甚至组件级。
摘自掘金·字节架构前端
2. 微前端要解决的问题?
- 技术:应用随着项目迭代越来越庞大,耦合度升高,以致缺乏灵活性,难以维护
- 团队协作:解决组织和团队间协作带来的工程问题
- 业务:用户喜欢聚合、一体化的应用,可以在既不重写原有系统的基础之下,又可以抽出人力来开发新的业务
3. 微前端的评价标准?
判断一个微前端是否优秀(符合需求)和全面,可以从以下维度进行评价分析:
二、主流技术方案对比
1. iframe
1.1 原生 iframe
通过 iframe 嵌入来实现微前端,(可能)是从微前端概念产生以来最为经典、能跑就行(又不是不能用)的解决方案。
| 优点 | 缺点 |
|---|---|
| iframe 使用简单,即来即用。 | 视窗大小不同步:例如我们在 iframe 内的弹窗想要居中展示。 |
| iframe 可以创建一个全新、独立的宿主环境,子应用独立运行,隔离完美。 | 子应用间通信问题:只能通过 postMessage 传递序列化的消息。 |
| iframe 支持在一个页面放上多个应用,组合灵活。 | 额外的性能开销:加载速度、构建 iframe 环境导致白屏时间太长。 |
| 路由状态丢失:刷新一下,iframe 的 url 状态就丢失了。 |
iframe 视窗大小不同步问题:
- iframe 有自己独立的视窗环境,它内部的页面获取的是 iframe 视窗的尺寸信息,而不是整个浏览器的视窗尺寸。当浏览器的窗口大小发生变化时,iframe 内的页面并不能及时感知到外部浏览器视窗的变化,导致计算出来的弹窗居中位置可能不准确。
1.2 无界
无界微前端是一款基于 基于 WebComponent 容器(ShadowRoot) + iframe 沙箱的微前端框架。
1.2.1 补充:ShadowDom
影子 DOM(Shadow DOM)允许你将一个 DOM 树附加到一个元素上,并且使该树的内部对于在页面中运行的 JavaScript 和 CSS 是隐藏的,在其之下你可以用与普通 DOM 相同的方式附加任何元素。
有一些影子 DOM 术语需要注意:
- 影子宿主(Shadow host): 影子 DOM 附加到的常规 DOM 节点。
- 影子树(Shadow tree): 影子 DOM 内部的 DOM 树。
- 影子边界(Shadow boundary): 影子 DOM 终止,常规 DOM 开始的地方。
- 影子根(Shadow root): 影子树的根节点。
影子 DOM 在平时开发中使用到的,例如常见的 video 标签,它有一个 shadowRoot 属性,这个属性就是影子 DOM 的根节点。
如何查看影子 DOM?
默认是不显示的,需要在控制台的设置中进行开启
从下图中可以看到,video 元素的影子 dom 结构,可以知道,video 本身是没有样式的,那些控件样式,是通过影子 dom 结构来实现的,保证了外部样式不会影响到默认的样式。
如何创建一个影子 dom?
<div id="host"></div>
<script>
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
// 填充内容
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
</script>
Element.shadowRoot 和“mode”选项
通过 mode 属性,可以让页面中的 JavaScript 可以通过影子宿主的 shadowRoot 属性访问影子 DOM 的内部。
{mode: "open"}:可以通过页面内的 JavaScript 方法来获取 Shadow DOM{mode: "closed"}:不可以从外部获取 Shadow DOM,此时shadowRoot返回null。
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
// const shadow = host.attachShadow({ mode: 'closed' })
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
document.querySelector("#btn").onclick = () => {
const spans = host.shadowRoot.querySelectorAll("span");
// mode为open,可以获取到影子dom内的元素,mode为closed,会报错
spans.forEach((span) => {
console.log(span);
});
};
在影子 dom 内应用样式
一般有两种方法:
- 编程式,通过构建一个
CSSStyleSheet对象并将其附加到影子根。 - 声明式,通过在一个
<template>元素的声明中添加一个<style>元素。
📦 编程式:
- 创建一个空的
CSSStyleSheet对象 - 使用
CSSStyleSheet.replace()或CSSStyleSheet.replaceSync()设置其内容 - 通过将其赋给
ShadowRoot.adoptedStyleSheets来添加到影子根
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const sheet = new CSSStyleSheet();
sheet.replaceSync("span { color: red; border: 2px dotted black;}");
shadow.adoptedStyleSheets = [sheet];
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
也可以通过创建 style 标签来实现:
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
// 填充内容
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
// 填充样式 外部的style不会作用在里面
const style = document.createElement("style");
style.textContent = "span { color: blue; }";
shadow.appendChild(style);
📦 声明式:
构建 CSSStyleSheet 对象的一个替代方法是将一个 <style> 元素包含在用于定义 web 组件的 <template> 元素中。
<div id="host"></div>
<template id="my-shadow-dom">
<style>
span {
color: blue;
}
</style>
<span>I'm in the shadow DOM</span>
</template>
<script>
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const template = document.getElementById("my-shadow-dom");
shadow.appendChild(template.content);
</script>
Shadow DOM 在各种微前端方案中有广泛运用,比如无界、Garfish 等,主要的作用是:把每个子应用包裹到一个 Shadow DOM 中,从而保证其运行时的样式的绝对隔离。
1.2.2 无界方案
无界微前端框架通过继承 iframe 的优点,解决 iframe 的缺点,下面来看看无界是如何去解决的?
例如:假设有 A 应用,想要加载 B 应用:
在应用 A 中构造一个 shadow和 iframe,然后将应用 B 的 html 写入 shadow 中,js 运行在 iframe 中,注意 iframe 的 url,iframe 保持和主应用同域但是保留子应用的路径信息,这样子应用的 js 可以运行在 iframe 的 location 和 history 中保持路由正确。
在 iframe 中拦截 document 对象,统一将 dom 指向 shadowRoot,此时比如新建元素、弹窗或者冒泡组件就可以正常约束在 shadowRoot 内部。
为什么要改写嵌入的 iframe 的域名(从 hostB 转换为 hostA)?
答:为了利用同源 iframe 可以便捷通信的特性。
在 iframe 中操作路由,会导致主、子应用路由不统一的问题怎么解决?
答:监听 iframe 的路由变化并同步到主应用,浏览器的 url 也会同步到 iframe。
接下来的三步分别解决 iframe 的三个缺点:
- ✅ dom 割裂严重的问题(例如:弹窗只能在 iframe 内部展示,无法覆盖全局),主应用提供一个容器给到
shadowRoot插拔,shadowRoot内部的弹窗也就可以覆盖到整个应用 A - ✅ 路由状态丢失的问题,浏览器的前进后退可以天然的作用到
iframe上,此时监听iframe的路由变化并同步到主应用,如果刷新浏览器,就可以从 url 读回保存的路由 - ✅ 通信非常困难的问题,
iframe和主应用是同域的,天然的共享内存通信,而且无界提供了一个去中心化的事件机制
将这套机制封装进 wujie 框架,可以发现:
- ✅ 首次白屏的问题,wujie 实例可以提前实例化,包括
shadowRoot、iframe的创建、js 的执行,这样极大的加快子应用第一次打开的时间 - ✅ 切换白屏的问题,一旦 wujie 实例可以缓存下来,子应用的切换成本变的极低,如果采用保活模式,那么相当于
shadowRoot的插拔
由于子应用完全独立的运行在 iframe 内,路由依赖 iframe 的 location 和 history,我们还可以在一张页面上同时激活多个子应用,由于 iframe 和主应用处于同一个浏览器的主窗口,因此浏览器前进、后退都可以作用到到子应用。
优缺点:
| 优点 | 缺点 |
|---|---|
| 多应用同时激活在线,框架具备同时激活多应用,并保持这些应用路由同步的能力。 | 内存占用较高,为了降低子应用的白屏时间,将未激活子应用的 shadowRoot 和 iframe 常驻内存并且保活模式下每张页面都需要独占一个 wujie 实例,内存开销较大。 |
| 组件式的使用方式。 | 兼容性一般,目前用到了浏览器的 shadowRoot 和 proxy 能力 |
| 无需注册,更无需路由适配,在组件内使用,跟随组件装载、卸载。 | iframe 劫持 document 到 shadowRoot 时,某些第三方库可能无法兼容导致穿透。 |
| 应用级别的 keep-alive。 | |
| 纯净无污染。 |
应用加载机制和 js 沙箱机制
将子应用的 js 注入主应用同域的 iframe 中运行,iframe 是一个原生的 window 沙箱,内部有完整的 history 和 location 接口,子应用实例 instance 运行在 iframe 中,路由也彻底和主应用解耦,可以直接在业务组件里面启动应用。
iframe 连接机制和 css 沙箱机制
无界采用 webcomponent 来实现页面的样式隔离,无界会创建一个wujie自定义元素,然后将子应用的完整结构渲染在内部
子应用的实例 instance 在 iframe 内运行,dom 在主应用容器下的 webcomponent 内,通过代理 iframe 的 document 到 webcomponent ,可以实现两者的互联。
html、css 不放在 iframe 中,是因为 iframe 导致 dom 割裂(例如弹窗不能覆盖到主应用),而 web component 可以实现。
例如:
// 模拟 iframe 的 document 对象
const iframeDocument = {
getElementsByClassName: function (className) {
console.log(`Original iframe document: Getting elements by class name: ${className}`);
},
getElementById: function (id) {
console.log(`Original iframe document: Getting element by id: ${id}`);
},
};
// 模拟 webcomponent
const webcomponent = {
getElementsByClassName: function (className) {
console.log(`Webcomponent: Getting elements by class name: ${className}`);
},
getElementById: function (id) {
console.log(`Webcomponent: Getting element by id: ${id}`);
},
};
// 使用 Proxy 进行代理
const proxiedIframeDocument = new Proxy(iframeDocument, {
get(target, prop) {
if (prop in webcomponent) {
return webcomponent[prop];
}
return target[prop];
},
});
// 测试代理
proxiedIframeDocument.getElementById("test-id");
proxiedIframeDocument.getElementsByClassName("test-class");
将 document 的查询类接口:getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body 全部代理到 webcomponent ,这样 instance 和 webcomponent 就精准的链接起来。
在源码中可以看到,使用 proxy 进行了代理(wujie-core/src/proxy.ts)
当子应用发生切换,iframe 保留下来,子应用的容器可能销毁,但 webcomponent 依然可以选择保留,这样等应用切换回来将 webcomponent 再挂载回容器上,子应用可以获得类似 vue 的 keep-alive 的能力.
路由同步机制
在 iframe 内部进行 history.pushState ,浏览器会自动的在 joint session history 中添加 iframe 的 session-history,浏览器的前进、后退在不做任何处理的情况就可以直接作用于子应用
joint session history:即联合会话历史记录,它将主页面和 iframe 等子文档的会话历史记录合并在一起。
session history:指的是浏览器会话历史记录,也就是在当前浏览器会话期间用户访问过的页面序列。每个页面或者页面状态在会话历史里都有对应的一个条目。
劫持 iframe 的 history.pushState 和 history.replaceState,就可以将子应用的 url 同步到主应用的 query 参数上,当刷新浏览器初始化 iframe 时,读回子应用的 url 并使用 iframe 的 history.replaceState 进行同步。
通信机制
承载子应用的 iframe 和主应用是同域的,所以主、子应用天然就可以很好的进行通信,在无界我们提供三种通信方式
- props 注入机制
子应用通过 $wujie.props 可以轻松拿到主应用注入的数据
- window.parent 通信机制
子应用 iframe 沙箱和主应用同源,子应用可以直接通过 window.parent 和主应用通信
- 去中心化的通信机制
无界提供了 EventBus 实例,注入到主应用和子应用,所有的应用可以去中心化的进行通信
1.2.3 无界使用
主应用
// 主应用:src/App.jsx
import React from "react";
import { WujieReact } from "wujie-react";
function App() {
return (
<div>
{/* 导航菜单 */}
<nav>
<Link to="/vue-app">Vue 子应用</Link>
<Link to="/react-app">React 子应用</Link>
</nav>
{/* Vue 子应用容器 */}
<WujieReact name="vueApp" url="http://localhost:3001" sync={true} props={{ user: { name: "Alice" } }} />
{/* React 子应用容器 */}
<WujieReact name="reactApp" url="http://localhost:3002" sync={true} />
</div>
);
}
export default App;
startApp:启动子应用,在页面挂载时调用destroyApp:销毁子应用,在页面卸载时调用
wujie-vue和wujie-react也是根据下面这种写法封装的。
import { useRef, useEffect } from "react";
import { startApp, destroyApp } from "wujie";
export default function SubApp() {
const myRef = useRef(null);
let destroy = null;
const startAppFunc = async () => {
destroy = await startApp({
name: "subapp",
url: "http://localhost:3001/",
el: myRef.current,
});
};
useEffect(() => {
startAppFunc();
return () => {
if (destroy) {
destroyApp(destroy);
}
};
});
return <div ref={myRef} />;
}
子应用
// Vue 子应用:src/main.js
import { createApp } from "vue";
import App from "./App.vue";
if (window.__POWERED_BY_WUJIE__) {
// 无界环境:挂载到无界提供的容器
window.__WUJIE_MOUNT = () => {
const app = createApp(App);
// 接收主应用传递的 props
app.config.globalProperties.$wujieProps = window.$wujie.props;
app.mount("#app");
};
// 子应用卸载时清理
window.__WUJIE_UNMOUNT = () => {
app.unmount();
};
} else {
// 独立运行模式
createApp(App).mount("#app");
}
/**********************************************************/
// React 子应用:src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
if (window.__POWERED_BY_WUJIE__) {
// 无界环境挂载
window.__WUJIE_MOUNT = () => {
ReactDOM.render(<App />, document.getElementById("root"));
};
window.__WUJIE_UNMOUNT = () => {
ReactDOM.unmountComponentAtNode(document.getElementById("root"));
};
} else {
// 独立运行
ReactDOM.render(<App />, document.getElementById("root"));
}
1.2.4 无界源码分析
我们先了解核心的几个方法在做什么,最后再将流程串联起来,就明白无界的运行原理了。
在 @wujie/core/src/index.ts 的入口文件中,
使用的函数为:processAppForHrefJump() 和 defineWujieWebComponent(),
还包括关键的 Wujie 类,
导出的内容为 setupApp()、startApp()、preloadApp() 和 destroyApp(),
下面对这个几个方法进行源码分析。
processAppForHrefJump
该函数用于监听浏览器的 popstate 事件,当用户点击前进/后退按钮或调用历史导航方法时,根据当前 URL 中的参数判断是否需要跳转子应用。若需跳转,则通过 iframe 替换内容实现子应用渲染;若为后退操作且之前有跳转记录,则恢复子应用原始内容。
export function processAppForHrefJump(): void {
// 监听浏览器历史记录,发生变化时触发的一个事件。
// 当用户点击浏览器的 “前进” 或 “后退” 按钮,
// 或者通过 JavaScript 调用 history.back()、history.forward() 及 history.go() 方法时,就会触发 popstate 事件。
window.addEventListener("popstate", () => {
// 根据url生成a标签
let winUrlElement = anchorElementGenerator(window.location.href);
// 返回a标签连接解析的参数 http://www.example.com?a=1&b=2 => {a:1,b:2}
const queryMap = getAnchorElementQueryMap(winUrlElement);
winUrlElement = null;
Object.keys(queryMap)
.map((id) => getWujieById(id))
.filter((sandbox) => sandbox)
.forEach((sandbox) => {
// sandox 是 wujie实例
/**
* 通过 wujie实例绑定的id获取到子应用url
* wujie实例中:this.id = name; id为子应用传递的name
*/
const url = queryMap[sandbox.id];
/**
* 在 Wujie 的 constructor 中创建iframe
* this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);
* iframeGenerator方法:创建了iframe,也就是创建 js沙箱【具体内容在 src/iframe.ts】
*/
// rawDocumentQuerySelector 判断是否存在wujie,来实用 querySelector
const iframeBody = rawDocumentQuerySelector.call(sandbox.iframe.contentDocument, "body");
// 前进href
if (/http/.test(url)) {
if (sandbox.degrade) {
renderElementToContainer(sandbox.document.documentElement, iframeBody);
renderIframeReplaceApp(
window.decodeURIComponent(url),
getDegradeIframe(sandbox.id).parentElement,
sandbox.degradeAttrs
);
} else
renderIframeReplaceApp(
window.decodeURIComponent(url),
sandbox.shadowRoot.host.parentElement,
sandbox.degradeAttrs
);
sandbox.hrefFlag = true;
// href后退
} else if (sandbox.hrefFlag) {
if (sandbox.degrade) {
// 走全套流程,但是事件恢复不需要
const { iframe } = initRenderIframeAndContainer(sandbox.id, sandbox.el, sandbox.degradeAttrs);
patchEventTimeStamp(iframe.contentWindow, sandbox.iframe.contentWindow);
iframe.contentWindow.onunload = () => {
sandbox.unmount();
};
iframe.contentDocument.appendChild(iframeBody.firstElementChild);
sandbox.document = iframe.contentDocument;
} else renderElementToContainer(sandbox.shadowRoot.host, sandbox.el);
sandbox.hrefFlag = false;
}
});
});
}
defineWujieWebComponent
这里需要补充一点
web component的生命周期:
constructor(): 构造函数,在组件实例化时调用。connectedCallback(): 当组件被添加到 DOM 中时调用。disconnectedCallback(): 当组件从 DOM 中移除时调用。attributeChangedCallback(name, oldValue, newValue): 当组件的属性发生变化时调用。adoptedCallback(): 当组件被移动到新的文档中时调用。
定义 web component 容器,将 shadow 包裹并获得 dom 装载和卸载的生命周期
// src/shadow.ts
export function defineWujieWebComponent() {
const customElements = window.customElements;
if (customElements && !customElements?.get("wujie-app")) {
class WujieApp extends HTMLElement {
// 创建shadowRoot
connectedCallback(): void {
if (this.shadowRoot) return;
const shadowRoot = this.attachShadow({ mode: "open" });
const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));
// 对传入的 shadowRoot 元素进行属性劫持,使其某些属性指向代理的 iframe 环境。若元素已打过标记则跳过。
patchElementEffect(shadowRoot, sandbox.iframe.contentWindow);
// 挂载沙箱上
sandbox.shadowRoot = shadowRoot;
}
// 卸载shadowRoot
disconnectedCallback(): void {
const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));
sandbox?.unmount();
}
}
customElements?.define("wujie-app", WujieApp);
}
}
Wujie 类
Wujie 类是无界沙箱的核心,沙箱实例就是 WuJie 类的实力,里面存储着 shadowroot、模板以及各种子应用的配置属性。在 startApp 和 preloadApp 中会 new 实例。
在这个构造函数中,主要做的:
- 初始化操作
- 创建同域的 iframe
- 将 iframe 的
window,document,location进行代理,并挂载到沙箱实例上 - 将沙箱进行缓存
// src/sandbox.ts
class Wujie {
constructor(options: {
name: string; // 子应用的id,唯一标识
url: string; // 子应用的url,可以包含protocol、host、path、query、hash
attrs: { [key: string]: any }; // 子应用标签的属性,比如id、name、data-name、data-url
degradeAttrs: { [key: string]: any }; // 降级属性,比如data-src、data-src-type
fiber: boolean; // 是否使用fiber,默认为false,优化手段
degrade; // 是否降级,默认为false,即不降级
plugins: Array<plugin>;
lifecycles: lifecycles; // 生命周期钩子 beforeLoad等...
}) {
// 传递inject给嵌套子应用
if (window.__POWERED_BY_WUJIE__) this.inject = window.__WUJIE.inject;
else {
this.inject = {
idToSandboxMap: idToSandboxCacheMap,
appEventObjMap,
mainHostPath: window.location.protocol + "//" + window.location.host,
};
}
const { name, url, attrs, fiber, degradeAttrs, degrade, lifecycles, plugins } = options;
this.id = name;
this.fiber = fiber;
this.degrade = degrade || !wujieSupport;
this.bus = new EventBus(this.id);
this.url = url;
this.degradeAttrs = degradeAttrs;
this.provide = { bus: this.bus };
this.styleSheetElements = [];
this.execQueue = [];
this.lifecycles = lifecycles;
this.plugins = getPlugins(plugins);
// 创建目标地址的解析
const { urlElement, appHostPath, appRoutePath } = appRouteParse(url);
const { mainHostPath } = this.inject;
// 创建iframe iframeGenerator代码在后面有说
this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);
if (this.degrade) {
// 降级处理 使用 Object.defineProperties 进行代理【降级使用 localGenerator】
const { proxyDocument, proxyLocation } = localGenerator(this.iframe, urlElement, mainHostPath, appHostPath);
this.proxyDocument = proxyDocument;
this.proxyLocation = proxyLocation;
} else {
// 正常处理 使用 Proxy 进行代理,代码在后面【无降级使用 proxyGenerator】
const { proxyWindow, proxyDocument, proxyLocation } = proxyGenerator(
this.iframe,
urlElement,
mainHostPath,
appHostPath
);
this.proxy = proxyWindow;
this.proxyDocument = proxyDocument;
this.proxyLocation = proxyLocation;
}
this.provide.location = this.proxyLocation;
addSandboxCacheWithWujie(this.id, this);
}
}
创建 iframe iframeGenerator 的核心代码:
创建和主应用同源的 iframe,路径携带了子路由的路由信息,并且 iframe 必须禁止加载 html,防止进入主应用的路由逻辑
⚠️ 注意:子应用的资源和接口的请求都在主域名发起,所以会有跨域问题,子应用必须要支持跨域。
export function iframeGenerator(
sandbox: WuJie,
// 属性
attrs: { [key: string]: any },
// 主应用地址
mainHostPath: string,
// 子应用地址
appHostPath: string,
// 子路由
appRoutePath: string
): HTMLIFrameElement {
let src = attrs && attrs.src;
// 判断是否需要使用这种 Object URL 来作为 iframe 的 src 属性值(URL.createObjectURL() 方法生成的特殊 URL)
let useObjectURL = false;
// 如果没有src,则使用objectURL,否则使用src
if (!src) {
// getSandboxEmptyPageURL() 方法返回一个空的 HTML 页面的 URL,用于创建同源的 iframe 沙箱环境。下面有代码。
src = getSandboxEmptyPageURL();
// true
useObjectURL = !!src; // !! 表示转为布尔值
// 如果没有src,则使用主应用地址
if (!src) src = mainHostPath; // fallback to mainHostPath
}
const iframe = window.document.createElement("iframe");
const attrsMerge = {
// 将iframe隐藏
style: "display: none",
...attrs,
// 设置主应用的src
src,
// 设置name
name: sandbox.id,
// 添加标记
[WUJIE_DATA_FLAG]: "",
};
// 该方法通过遍历,将属性设置到元素上
setAttrsToElement(iframe, attrsMerge);
// 插入iframe
window.document.body.appendChild(iframe);
/**
* iframe.contentWindow:iframe 的 window属性
* 如果与 iframe 父级同源,那么父级页面可以访问 iframe 的文档以及内部 DOM,
*/
const iframeWindow = iframe.contentWindow;
// 变量需要提前注入,在入口函数通过变量防止死循环,下面有代码
// sandbox => wujie实例
patchIframeVariable(iframeWindow, sandbox, appHostPath);
// 但是一旦设置`src`后,`iframe`由于同域,会加载主应用的`html`、`js`,所以必须在`iframe`实例化完成并且还没有加载完`html`时中断加载,防止污染子应用
// 在 Wujie.active 会等待 iframeReady 执行完成
// frame沙箱的src设置了主应用的host,初始化iframe的时候需要等待iframe的location.orign从'about:blank'初始化为主应用的host,内部采用 循环 + setTimeout 来实现的(1s后 url 没变化,结束循环)
sandbox.iframeReady = stopIframeLoading(iframe, useObjectURL && { mainHostPath }).then(() => {
// 变量中没有,重新添加
if (!iframeWindow.__WUJIE) {
patchIframeVariable(iframeWindow, sandbox, appHostPath);
}
/**
* 初始化iframe的dom结构 => 主要是对iframe新增变量,标签,做大量重写操作(例如window、document、node、history、event这些)
* 在 iframe 的 window 上添加变量,有 iframeDocument.head、querySelector、querySelectorAll、createElement 等方法
* patchIframeHistory:重写iframe的history的pushState和replaceState方法 将从location劫持后的数据修改回来,防止跨域错误 同步路由到主应用,下面有说
* patchIframeEvents:修改window对象的事件监听,只有路由事件采用iframe的事件
*/
initIframeDom(iframeWindow, sandbox, mainHostPath, appHostPath);
/**
* 如果有同步优先同步,非同步从url读取
*/
if (!isMatchSyncQueryById(iframeWindow.__WUJIE.id)) {
iframeWindow.history.replaceState(null, "", mainHostPath + appRoutePath);
}
});
return iframe;
}
stopIframeLoading是为了停止加载这个 iframe,因为创建这个 ifame 的是主应用,所以是主应用的 src 或about:blank,这样会导致 iframe 加载主应用的资源,但是 iframe 是子应用的,不需要加载主应用资源,所以需要停止掉,后续再去初始化子应用 iframe。initIframeDom:主要是对 iframe 新增变量,标签,做大量重写操作(例如 window、document、node、history、event 这些)
// getSandboxEmptyPageURL:生成一个空的 HTML 页面的 URL,用于创建同源的 iframe 沙箱环境。
const getSandboxEmptyPageURL = () => {
if (disabled) return "";
if (prevURL) return prevURL;
// 使用空页面 URL 可以防止 iframe 加载主应用的 HTML 内容,从而避免进入主应用的路由逻辑或其他副作用。
const blob = new Blob(["<!DOCTYPE html><html><head></head><body></body></html>"], { type: "text/html" });
prevURL = URL.createObjectURL(blob);
return prevURL;
};
// patchIframeVariable:为一个 iframe 设置多个全局变量
function patchIframeVariable(iframeWindow: Window, wujie: WuJie, appHostPath: string): void {
// wujie实例
iframeWindow.__WUJIE = wujie;
// 子应用公共加载路径
iframeWindow.__WUJIE_PUBLIC_PATH__ = appHostPath + "/";
/**
* $wujie对象,提供给子应用的接口
public provide: {
bus: EventBus;
shadowRoot?: ShadowRoot;
props?: { [key: string]: any };
location?: Object;
};
*/
iframeWindow.$wujie = wujie.provide;
// 原生的window对象
iframeWindow.__WUJIE_RAW_WINDOW__ = iframeWindow;
}
// patchIframeHistory:重写iframe的history的pushState和replaceState方法(在 iframeGenerator 方法的 initIframeDom 方法中) 实现子应用路由和基座路由的联动
function patchIframeHistory(iframeWindow: Window, appHostPath: string, mainHostPath: string): void {
const history = iframeWindow.history;
const rawHistoryPushState = history.pushState;
const rawHistoryReplaceState = history.replaceState;
// 重写 pushState
history.pushState = function (data: any, title: string, url?: string): void {
const baseUrl =
mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;
const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);
const ignoreFlag = url === undefined;
rawHistoryPushState.call(history, data, title, ignoreFlag ? undefined : mainUrl);
if (ignoreFlag) return;
updateBase(iframeWindow, appHostPath, mainHostPath);
syncUrlToWindow(iframeWindow);
};
// 重写 replaceState
history.replaceState = function (data: any, title: string, url?: string): void {
const baseUrl =
mainHostPath + iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;
const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);
const ignoreFlag = url === undefined;
rawHistoryReplaceState.call(history, data, title, ignoreFlag ? undefined : mainUrl);
if (ignoreFlag) return;
updateBase(iframeWindow, appHostPath, mainHostPath);
syncUrlToWindow(iframeWindow);
};
}
其中有个 patchIframeHistory,通过重写子应用的 pushState 实现子应用路由和基座路由的联动
无降级使用 proxyGenerator
非降级情况下对子应用的 window、document、location 进行代理
从图中代码可以看到使用 Proxy 分别对 window、document、location 进行代理
proxyWindow
getTargetValue() 用来修正 this 指针指向,因为子应用执行 window 的函数时,期望函数的 this 指向子应用的 window,但是由于代理 window 是在基座生成的,所以 this 指向的基座的 window,所以需要修正 this 指向。
const proxyWindow = new Proxy(iframe.contentWindow, {
get: (target: Window, p: PropertyKey): any => {
// location进行劫持
if (p === "location") {
return target.__WUJIE.proxyLocation;
}
// 判断自身
if (p === "self" || (p === "window" && Object.getOwnPropertyDescriptor(window, "window").get)) {
return target.__WUJIE.proxy;
}
// 不要绑定this
if (p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__" || p === "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__") {
return target[p];
}
const descriptor = Object.getOwnPropertyDescriptor(target, p);
if (descriptor?.configurable === false && descriptor?.writable === false) {
return target[p];
}
// 修正this指针指向
return getTargetValue(target, p);
},
set: (target: Window, p: PropertyKey, value: any) => {
checkProxyFunction(target, value);
target[p] = value;
return true;
},
has: (target: Window, p: PropertyKey) => p in target,
});
proxyDocument(🔗 核心:子应用 js 和 Webcomponents 通信的关键)
分析出 document 的所有属性以及方法,有些属性需要代理到全局的 document,有些需要代理到沙箱的 shadow root 节点上。
通信关键:将 iframe 的 dom 代理到 shadowbox 上
const proxyDocument = new Proxy(
{},
{
get: function (_fakeDocument, propKey) {
const document = window.document;
const { shadowRoot, proxyLocation } = iframe.contentWindow.__WUJIE;
// iframe初始化完成后,webcomponent还未挂在上去,此时运行了主应用代码,必须中止
if (!shadowRoot) stopMainAppRun();
const rawCreateElement = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_ELEMENT__;
const rawCreateTextNode = iframe.contentWindow.__WUJIE_RAW_DOCUMENT_CREATE_TEXT_NODE__;
// need fix
if (propKey === "createElement" || propKey === "createTextNode") {
return new Proxy(document[propKey], {
apply(_createElement, _ctx, args) {
const rawCreateMethod = propKey === "createElement" ? rawCreateElement : rawCreateTextNode;
const element = rawCreateMethod.apply(iframe.contentDocument, args);
patchElementEffect(element, iframe.contentWindow);
return element;
},
});
}
if (propKey === "documentURI" || propKey === "URL") {
return (proxyLocation as Location).href;
}
// from shadowRoot
if (
propKey === "getElementsByTagName" ||
propKey === "getElementsByClassName" ||
propKey === "getElementsByName"
) {
return new Proxy(shadowRoot.querySelectorAll, {
apply(querySelectorAll, _ctx, args) {
let arg = args[0];
if (_ctx !== iframe.contentDocument) {
return _ctx[propKey].apply(_ctx, args);
}
if (propKey === "getElementsByTagName" && arg === "script") {
return iframe.contentDocument.scripts;
}
if (propKey === "getElementsByClassName") arg = "." + arg;
if (propKey === "getElementsByName") arg = `[name="${arg}"]`;
let res: NodeList[] | [];
try {
res = querySelectorAll.call(shadowRoot, arg);
} catch (error) {
res = [];
}
return res;
},
});
}
if (propKey === "getElementById") {
return new Proxy(shadowRoot.querySelector, {
// case document.querySelector.call
apply(target, ctx, args) {
if (ctx !== iframe.contentDocument) {
return ctx[propKey]?.apply(ctx, args);
}
try {
return (
target.call(shadowRoot, `[id="${args[0]}"]`) ||
iframe.contentWindow.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__.call(
iframe.contentWindow.document,
`#${args[0]}`
)
);
} catch (error) {
warn(WUJIE_TIPS_GET_ELEMENT_BY_ID);
return null;
}
},
});
}
if (propKey === "querySelector" || propKey === "querySelectorAll") {
const rawPropMap = {
querySelector: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__",
querySelectorAll: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__",
};
return new Proxy(shadowRoot[propKey], {
apply(target, ctx, args) {
if (ctx !== iframe.contentDocument) {
return ctx[propKey]?.apply(ctx, args);
}
// 二选一,优先shadowDom,除非采用array合并,排除base,防止对router造成影响
return (
target.apply(shadowRoot, args) ||
(args[0] === "base"
? null
: iframe.contentWindow[rawPropMap[propKey]].call(iframe.contentWindow.document, args[0]))
);
},
});
}
if (propKey === "documentElement" || propKey === "scrollingElement") return shadowRoot.firstElementChild;
if (propKey === "forms") return shadowRoot.querySelectorAll("form");
if (propKey === "images") return shadowRoot.querySelectorAll("img");
if (propKey === "links") return shadowRoot.querySelectorAll("a");
const { ownerProperties, shadowProperties, shadowMethods, documentProperties, documentMethods } =
documentProxyProperties;
if (ownerProperties.concat(shadowProperties).includes(propKey.toString())) {
if (propKey === "activeElement" && shadowRoot.activeElement === null) return shadowRoot.body;
return shadowRoot[propKey];
}
if (shadowMethods.includes(propKey.toString())) {
return getTargetValue(shadowRoot, propKey) ?? getTargetValue(document, propKey);
}
// from window.document
if (documentProperties.includes(propKey.toString())) {
return document[propKey];
}
if (documentMethods.includes(propKey.toString())) {
return getTargetValue(document, propKey);
}
},
}
);
proxyLocation
代理 location
const proxyLocation = new Proxy(
{},
{
get: function (_fakeLocation, propKey) {
const location = iframe.contentWindow.location;
if (
propKey === "host" ||
propKey === "hostname" ||
propKey === "protocol" ||
propKey === "port" ||
propKey === "origin"
) {
return urlElement[propKey];
}
if (propKey === "href") {
return location[propKey].replace(mainHostPath, appHostPath);
}
if (propKey === "reload") {
warn(WUJIE_TIPS_RELOAD_DISABLED);
return () => null;
}
if (propKey === "replace") {
return new Proxy(location[propKey], {
apply(replace, _ctx, args) {
return replace.call(location, args[0]?.replace(appHostPath, mainHostPath));
},
});
}
return getTargetValue(location, propKey);
},
set: function (_fakeLocation, propKey, value) {
// 如果是跳转链接的话重开一个iframe
if (propKey === "href") {
return locationHrefSet(iframe, value, appHostPath);
}
iframe.contentWindow.location[propKey] = value;
return true;
},
ownKeys: function () {
return Object.keys(iframe.contentWindow.location).filter((key) => key !== "reload");
},
getOwnPropertyDescriptor: function (_target, key) {
return { enumerable: true, configurable: true, value: this[key] };
},
}
);
降级使用 localGenerator
在不支持 Proxy 的浏览器下,使用 Object.defineProperties 对降级情况下 document、location 代理处理。
importHtml
用来解析子应用模板的脚本、样式、模板(在 startApp 中有使用)
在主应用中,通过 fetch 来获取到子应用的内容,得到一个 html 字符串,然后通过正则表达式匹配到内部样式表、外部样式表、脚本;源码通过 /<(link)\s+.*?>/gis 匹配外部样式,通过 /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi 匹配脚本;通过 /<style[^>]*>[\s\S]*?<\/style>/gi 匹配内部样式;
通过 fetch url 加载子应用资源,这里也是需要子应用支持跨域设置的原因
手写一个 importHTML 函数,用来解析子应用模板的脚本、样式、模板:
const STYLE_REG = /<style>(.*)<\/style>/gi;
const SCRIPT_REG = /<script>(.*)<\/script>/gi;
const LINK_REG = /<(link)\s+.*?>/gi;
async function imoprtHTML() {
let html = await fetch("https://baidu.com");
html = await html.text();
const ans = html
.replace(STYLE_REG, (match) => {
// ... 很多逻辑
return match;
})
.replace(SCRIPT_REG, (match) => {
// ... 很多逻辑
return match;
})
.replace(LINK_REG, (match) => {
// ... 很多逻辑
debugger;
return match;
});
}
源码:
const newSandbox = new WuJie({ name, url, attrs, degradeAttrs, fiber, degrade, plugins, lifecycles });
// ...
const { template, getExternalScripts, getExternalStyleSheets } = await importHTML({
url,
html,
opts: {
fetch: fetch || window.fetch,
plugins: newSandbox.plugins,
loadError: newSandbox.lifecycles.loadError,
fiber,
},
});
processCssLoader
处理 css-loader
- processCssLoader
- css-loader:执行 css 插件
- getEmbedHTML:将外部 css 转为内联优化性能
返回一个带有 css 样式的 html 模版,后续插入到 shadowroot 中。
问:子应用为什么采用了内联样式而不是 link 标签引入?
答:因为需要对样式经常处理,所以需要将样式请求回来进行处理再放回去,还有一个就是子应用切换后样式需要恢复必须把样式收集起来,内联样式更好收集处理。
export async function processCssLoader(
sandbox: Wujie,
template: string,
getExternalStyleSheets: () => StyleResultList
): Promise<string> {
const curUrl = getCurUrl(sandbox.proxyLocation);
/** css-loader */
const composeCssLoader = compose(sandbox.plugins.map((plugin) => plugin.cssLoader));
const processedCssList: StyleResultList = getExternalStyleSheets().map(({ src, ignore, contentPromise }) => ({
src,
ignore,
contentPromise: contentPromise.then((content) => composeCssLoader(content, src, curUrl)),
}));
const embedHTML = await getEmbedHTML(template, processedCssList);
return sandbox.replace ? sandbox.replace(embedHTML) : embedHTML;
}
// getEmbedHTML(src/entry.ts)
async function getEmbedHTML(template, styleResultList: StyleResultList): Promise<string> {
let embedHTML = template;
return Promise.all(
styleResultList.map((styleResult, index) =>
styleResult.contentPromise.then((content) => {
if (styleResult.src) {
embedHTML = embedHTML.replace(
genLinkReplaceSymbol(styleResult.src),
styleResult.ignore
? `<link href="${styleResult.src}" rel="stylesheet" type="text/css">`
: `<style>/* ${styleResult.src} */${content}</style>`
);
} else if (content) {
embedHTML = embedHTML.replace(
getInlineStyleReplaceSymbol(index),
`<style>/* inline-style-${index} */${content}</style>`
);
}
})
)
).then(() => embedHTML);
}
sandbox.active()
在 startApp 中,有: await newSandbox.active() 方法,作用是子应用激活,同步路由,动态修改 iframe 的 fetch(对 fetch 进行重写), 准备 shadow, 准备子应用注入(active 方法在 Wujie 类上 src/sandbox.ts)
class Wujie {
public async active(options: {
url: string;
sync?: boolean;
prefix?: { [key: string]: string };
template?: string;
el?: string | HTMLElement;
props?: { [key: string]: any };
alive?: boolean;
fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
replace?: (code: string) => string;
}): Promise<void> {
const { sync, url, el, template, props, alive, prefix, fetch, replace } = options;
// ...初始化配置
// 等待 iframe 初始完成(在 src/iframe.ts 中 iframeGenerator进行赋值 => stopIframeLoading 方法)
await this.iframeReady;
// 处理子应用自定义fetch(重写子应用的fetch方法)
const iframeWindow = this.iframe.contentWindow;
const iframeFetch = fetch
? (input: RequestInfo, init?: RequestInit) =>
fetch(typeof input === "string" ? getAbsolutePath(input, (this.proxyLocation as Location).href) : input, init)
: this.fetch;
if (iframeFetch) {
iframeWindow.fetch = iframeFetch;
this.fetch = iframeFetch;
}
// 处理子应用路由同步
if (this.execFlag && this.alive) {
// 当保活模式下子应用重新激活时,只需要将子应用路径同步回主应用
syncUrlToWindow(iframeWindow);
} else {
// 先将url同步回iframe,然后再同步回浏览器url
syncUrlToIframe(iframeWindow);
syncUrlToWindow(iframeWindow);
}
// inject template
this.template = template ?? this.template;
/* 降级处理 */
if (this.degrade) {
// ...省略
return;
}
if (this.shadowRoot) {
// 预执行有容器,直接插入容器内
this.el = renderElementToContainer(this.shadowRoot.host, el);
if (this.alive) return;
} else {
// 预执行无容器,暂时插入iframe内部触发 Web Component 的 connect
const iframeBody = rawDocumentQuerySelector.call(iframeWindow.document, "body") as HTMLElement;
this.el = renderElementToContainer(createWujieWebComponent(this.id), el ?? iframeBody);
}
// 将template渲染到shadowRoot
await renderTemplateToShadowRoot(this.shadowRoot, iframeWindow, this.template);
// 子应用样式打补丁 1、兼容:root选择器样式到:host选择器上 2、将@font-face定义到shadowRoot外部
this.patchCssRules();
// inject shadowRoot to app
this.provide.shadowRoot = this.shadowRoot;
}
}
// 创建 `wujie-app` 组件。
export function createWujieWebComponent(id: string): HTMLElement {
const contentElement = window.document.createElement("wujie-app");
contentElement.setAttribute(WUJIE_APP_ID, id);
contentElement.classList.add(WUJIE_IFRAME_CLASS);
return contentElement;
}
// 将准备好的内容插入容器
export function renderElementToContainer(
element: Element | ChildNode,
// 插入的容器
selectorOrElement: string | HTMLElement
): HTMLElement {
const container = getContainer(selectorOrElement);
if (container && !container.contains(element)) {
// 有 loading 无需清理,已经清理过了
if (!container.querySelector(`div[${LOADING_DATA_FLAG}]`)) {
// 清除内容
clearChild(container);
}
// 插入元素
if (element) {
rawElementAppendChild.call(container, element);
}
}
return container;
}
sandbox.start()
start 启动子应用:1、运行 js 2、处理兼容样式
startApp 的代码中,有: await newSandbox.start() 方法,作用是将解析的脚本插入到子应用 iframe 内,这里如果开启了 fiber 并且浏览器支持,可以在预加载时优化一些性能,在空闲时间去执行该操作
使用的是
requestIdleCallback,执行insertScriptToIframe将 脚本插入到 iframe 中。
insertScriptToIframe 在插入脚本时,需要改变脚本的 window, self, global, location 的指向。
if (!iframeWindow.__WUJIE.degrade && !module && attrs?.type !== "importmap") {
code = `(function(window, self, global, location) {
${code}
}).bind(window.__WUJIE.proxy)(
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxyLocation,
);`;
}
class Wujie {
public async start(getExternalScripts: () => ScriptResultList): Promise<void> {
this.execFlag = true;
// 执行脚本
const scriptResultList = await getExternalScripts();
// 假如已经被销毁了
if (!this.iframe) return;
const iframeWindow = this.iframe.contentWindow;
// 标志位,执行代码前设置
iframeWindow.__POWERED_BY_WUJIE__ = true;
// 用户自定义代码前
const beforeScriptResultList: ScriptObjectLoader[] = getPresetLoaders("jsBeforeLoaders", this.plugins);
// 用户自定义代码后
const afterScriptResultList: ScriptObjectLoader[] = getPresetLoaders("jsAfterLoaders", this.plugins);
// 同步代码
const syncScriptResultList: ScriptResultList = [];
// async代码无需保证顺序,所以不用放入执行队列
const asyncScriptResultList: ScriptResultList = [];
// defer代码需要保证顺序并且DOMContentLoaded前完成,这里统一放置同步脚本后执行
const deferScriptResultList: ScriptResultList = [];
scriptResultList.forEach((scriptResult) => {
if (scriptResult.defer) deferScriptResultList.push(scriptResult);
else if (scriptResult.async) asyncScriptResultList.push(scriptResult);
else syncScriptResultList.push(scriptResult);
});
// 插入代码前
beforeScriptResultList.forEach((beforeScriptResult) => {
this.execQueue.push(() =>
this.fiber
? this.requestIdleCallback(() => insertScriptToIframe(beforeScriptResult, iframeWindow))
: insertScriptToIframe(beforeScriptResult, iframeWindow)
);
});
// 同步代码
syncScriptResultList.concat(deferScriptResultList).forEach((scriptResult) => {
this.execQueue.push(() =>
scriptResult.contentPromise.then((content) =>
this.fiber
? this.requestIdleCallback(() => insertScriptToIframe({ ...scriptResult, content }, iframeWindow))
: insertScriptToIframe({ ...scriptResult, content }, iframeWindow)
)
);
});
// 异步代码
asyncScriptResultList.forEach((scriptResult) => {
scriptResult.contentPromise.then((content) => {
this.fiber
? this.requestIdleCallback(() => insertScriptToIframe({ ...scriptResult, content }, iframeWindow))
: insertScriptToIframe({ ...scriptResult, content }, iframeWindow);
});
});
//框架主动调用mount方法
this.execQueue.push(this.fiber ? () => this.requestIdleCallback(() => this.mount()) : () => this.mount());
//触发 DOMContentLoaded 事件
const domContentLoadedTrigger = () => {
eventTrigger(iframeWindow.document, "DOMContentLoaded");
eventTrigger(iframeWindow, "DOMContentLoaded");
this.execQueue.shift()?.();
};
this.execQueue.push(this.fiber ? () => this.requestIdleCallback(domContentLoadedTrigger) : domContentLoadedTrigger);
// 插入代码后
afterScriptResultList.forEach((afterScriptResult) => {
this.execQueue.push(() =>
this.fiber
? this.requestIdleCallback(() => insertScriptToIframe(afterScriptResult, iframeWindow))
: insertScriptToIframe(afterScriptResult, iframeWindow)
);
});
//触发 loaded 事件
const domLoadedTrigger = () => {
eventTrigger(iframeWindow.document, "readystatechange");
eventTrigger(iframeWindow, "load");
this.execQueue.shift()?.();
};
this.execQueue.push(this.fiber ? () => this.requestIdleCallback(domLoadedTrigger) : domLoadedTrigger);
// 由于没有办法准确定位是哪个代码做了mount,保活、重建模式提前关闭loading
if (this.alive || !isFunction(this.iframe.contentWindow.__WUJIE_UNMOUNT)) removeLoading(this.el);
this.execQueue.shift()();
// 所有的execQueue队列执行完毕,start才算结束,保证串行的执行子应用
return new Promise((resolve) => {
this.execQueue.push(() => {
resolve();
this.execQueue.shift()?.();
});
});
}
}
上述流程:
sandbox.destroy()
子应用销毁
class Wujie {
/** 销毁子应用 */
public destroy() {
this.bus.$clear();
// thi.xxx = null;
// 清除 dom
if (this.el) {
clearChild(this.el);
this.el = null;
}
// 清除 iframe 沙箱
if (this.iframe) {
this.iframe.parentNode?.removeChild(this.iframe);
}
// 删除缓存
deleteWujieById(this.id);
}
}
1.2.5 无界运行流程
1.2.6 实现一个简单的无界
核心:
- 创建 web component
- 创建沙箱(沙箱包含 iframe、shadowRoot)
- 创建 shadowDOM
- 通过请求获取到子应用内容(html、css、js)
- 将 html、css 合并为 template,放入 shadowDOM 中
- 将 js 放入沙箱中执行(需要对 iframe 中的属性进行代理)
主应用(index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div>主应用内容(不能被子应用样式污染)</div>
<!-- 子应用容器 -->
<div id="sub-container"></div>
<script>
console.log("主应用", window.a); // 主应用undefined
</script>
<script>
function createWujieAppElement() {
class WujieApp extends HTMLElement {
async connectedCallback() {
// 1. 创建沙箱
const sandbox = createSandbox();
// 2. 创建shadowDOM
sandbox.shadowRoot = this.attachShadow({ mode: "open" });
// 3. 通过请求获取到子应用内容(html、css、js)
const { template, css, js } = await importHtml("./sub.html");
// 4. 将html、css放入shadowDOM中
const templateAndCSS = mergeTemplateAndCSS(template, css);
injectTemplate(sandbox, templateAndCSS);
// 5. 将js放入沙箱中执行
runScriptInSandbox(sandbox, js);
}
}
window.customElements.define("wujie-app", WujieApp);
const subContainer = document.getElementById("sub-container");
subContainer.appendChild(document.createElement("wujie-app"));
}
// 类似startApp
createWujieAppElement();
// 创建沙箱
function createSandbox() {
const sandbox = {
iframe: createIframe(),
shadowRoot: null,
};
return sandbox;
}
// 创建iframe
function createIframe() {
const iframe = document.createElement("iframe");
iframe.src = "about:blank"; // 默认空连接
iframe.style.display = "none";
document.body.appendChild(iframe);
return iframe;
}
// 通过请求解析html、css、js
async function importHtml(url) {
const html = await Promise.resolve(fetch(url).then((res) => res.text()));
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// 提取 HTML(去除 script 和 style 标签)
const template = extractHTML(doc);
// 提取 CSS 内容
const css = extractCSS(doc);
// 提取 JS 内容
const js = extractJS(doc);
return {
template,
css,
js,
};
}
// 提取 HTML 内容(去除 script 和 style 标签)
function extractHTML(doc) {
const cloneDoc = doc.documentElement.cloneNode(true);
const scripts = cloneDoc.querySelectorAll("script");
const styles = cloneDoc.querySelectorAll("style");
scripts.forEach((script) => script.remove());
styles.forEach((style) => style.remove());
return new XMLSerializer().serializeToString(cloneDoc);
}
// 提取 CSS 内容
function extractCSS(doc) {
const styleElements = doc.querySelectorAll("style");
let cssText = "";
styleElements.forEach((style) => {
cssText += style.innerHTML;
});
return cssText;
}
// 提取 JS 内容
function extractJS(doc) {
const scriptElements = doc.querySelectorAll("script");
let jsText = "";
scriptElements.forEach((script) => {
if (script.src) {
// 如果是外链脚本,可能需要异步加载
console.warn("外链脚本暂不支持直接提取", script.src);
} else {
jsText += script.innerHTML + "\n";
}
});
return jsText;
}
// 合并模板和样式,将 CSS 插入到 HTML 的 <head> 中
function mergeTemplateAndCSS(template, css) {
const parser = new DOMParser();
const doc = parser.parseFromString(template, "text/html");
// 创建 style 元素
const styleElement = doc.createElement("style");
styleElement.textContent = css;
// 将 style 插入到 head 中
const head = doc.querySelector("head");
if (head) {
head.appendChild(styleElement);
} else {
// 如果没有 head,则创建一个并添加到 html 元素中
const htmlElement = doc.querySelector("html");
const headElement = doc.createElement("head");
headElement.appendChild(styleElement);
htmlElement.insertBefore(headElement, htmlElement.firstChild);
}
return new XMLSerializer().serializeToString(doc);
}
// 将 template 插入到 shadowRoot 中
function injectTemplate(sandbox, template) {
const warpper = document.createElement("div");
warpper.innerHTML = template;
sandbox.shadowRoot.appendChild(warpper);
}
// 运行js(将js放入iframe的head标签中就行)
function runScriptInSandbox(sandbox, script) {
const iframeWindow = sandbox.iframe.contentWindow;
const scriptElement = iframeWindow.document.createElement("script");
// 获取head将script插入
const headElement = iframeWindow.document.querySelector("head");
/**
* 子应用中,如果打印window,获取到的是父应用的,
* 我们希望在脚本执行之前,有些方法是父应用的,有些方法是子应用的
* 例如1:document.querySelector('#sub-text'),就不是iframe的,而是获取shadowRoot的
* 例如2:添加弹窗,document.createElement('div').appendChild(div),不能在iframe中,需要代理到主应用
* 例如3:路由也需要同步到主应用
*/
Object.defineProperty(iframeWindow.Document.prototype, "querySelector", {
get() {
// 如果加载的脚本内部调用了 querySelector,则代理到 shadowRoot 中
/**
* 问:为什么sandbox.shadowRoot这个影子dom有querySelector属性?
* ✅ shadowRoot 是一个 ShadowRoot 对象,它继承自 DocumentFragment 接口,
* 在 DOM 标准中,ShadowRoot 同样拥有完整的 DOM 操作能力,包括:
querySelector
querySelectorAll
getElementById
appendChild
*/
// 当子应用iframe调用document.querySelector,相当于调用 sandbox.shadowRoot['querySelector']
return new Proxy(sandbox.shadowRoot["querySelector"], {
apply(target, thisArg, args) {
/**
* target:原始方法
* thisArg:this指向(document)
* args:参数
*/
// thisArg 是 iframe 的 document,去指向 shadowRoot
return thisArg.querySelector.apply(sandbox.shadowRoot, args);
},
});
},
});
scriptElement.textContent = script;
headElement.appendChild(scriptElement);
}
</script>
</body>
</html>
子应用(sub.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
div {
color: #fff;
background-color: blue;
}
</style>
</head>
<body>
<div id="sub-text">子应用内容</div>
<script>
window.a = 100; // 子应用不会影响到主应用
console.log(window.a, "sub.html");
const subEle = document.querySelector("#sub-text");
console.log(subEle);
</script>
</body>
</html>
😭😭这里内容太多了,发布不了,只能开第二章了!