乾坤
乾坤js隔离机制及发展历程
qiankun有三种js隔离机制,分别是SnapshotSandbox、LegacySandbox、ProxySandbox。这三种沙箱模式的中文解释分别为快照沙箱、支持单应用的代理沙箱和支持多应用的代理沙箱。
一开始乾坤也只有一种沙箱叫“快照沙箱”,也就是由SnapshotSandbox类来实现的沙箱。这个沙箱有个缺点,就是需要遍历window上的所有属性,性能较差。随着ES6的普及,利用Proxy可以比较良好的解决这个问题,这就诞生了LegacySandbox,可以实现和快照沙箱一样的功能,但是却性能更好,和SnapshotSandbox一样,由于会污染全局的window,LegacySandbox也仅仅允许页面同时运行一个微应用,所以我们也称LegacySandbox为支持单应用的代理沙箱。从LegacySandbox这个类名可以看出,一开始肯定是不叫LegacySandbox,是因为有了更好的机制,才将这个名字强加给它了。那这个更好的机制是什么呢,就是ProxySandbox,它可以支持一个页面运行多个微应用,因此我们称ProxySandbox为支持多应用的代理沙箱。事实上,LegacySandbox在未来应该会消失,因为LegacySandbox可以做的事情,ProxySandbox都可以做,而SanpsshotSandbox因为向下兼容的原因反而会和ProxySandbox长期并存。
ProxySandbox的实现原理
先看下极简版的代码
class ProxySandBox{
proxyWindow;
isRunning = false;
active(){
this.isRunning = true;
}
inactive(){
this.isRunning = false;
}
constructor(){
const fakeWindow = Object.create(null);
this.proxyWindow = new Proxy(fakeWindow,{
set:(target, prop, value, receiver)=>{
if(this.isRunning){
target[prop] = value;
}
},
get:(target, prop, receiver)=>{
return prop in target ? target[prop] : window[prop];
}
});
}
}
// 验证:
let proxySandBox1 = new ProxySandBox();
let proxySandBox2 = new ProxySandBox();
从上面的代码可以发现,ProxySandbox,完全不存在状态恢复的逻辑,同时也不需要记录属性值的变化,因为所有的变化都是沙箱内部的变化,和window没有关系,window上的属性至始至终都没有受到过影响。
通过代理的window的proxy,子应用的js执行的时候是如何把它当作window使用?
执行子应用js代码的时候会将this.proxyWindow作为参数传入,这样子应用原本应该直接操作window的地方,都是操作这个proxyWindow对象,实现了代理功能。具体代码体现如下:
window.proxy = proxy; // 这里的proxy就是我们通过参数传入的proxyWindow对象
return `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`; // 这里与实际代码相比做了一定简化
import-html-entry
import-html-entry 是 qiankun 中一个举足轻重的依赖,用于获取子应用的 HTML 和 JS,同时对 HTML 和 JS 进行了各自的处理,以便于子应用在父应用中加载
import-html-entry的工作流程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FEQ7jZnB-1690978348321)(media/16885204415196/%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A120230705-095948@2x.png)]
import-html-entry主要解析原理
- 通过fetch获取entry资源
- processTpl通过正则解析html模版并抽取script、style并删除注释
- getExternalStyleSheets将link样式转换为inlineStyle
- execScripts执行抽取的script脚本
无界
无界代码隔离实现原理
-
实现自定义webComponent,通过shadowRoot实现原生html及style隔离。在polyfill(浏览器不兼容webCompnent)情况下,会降级为iframe
/* 降级处理 */ // 如果浏览器不兼容webComponent if (this.degrade) { const iframeBody = rawDocumentQuerySelector.call(iframeWindow.document, "body") as HTMLElement; const { iframe, container } = initRenderIframeAndContainer(this.id, el ?? iframeBody, this.degradeAttrs); this.el = container; // 销毁js运行iframe容器内部dom if (el) clearChild(iframeBody); // 修复vue的event.timeStamp问题 patchEventTimeStamp(iframe.contentWindow, iframeWindow); // 当销毁iframe时主动unmount子应用 iframe.contentWindow.onunload = () => { this.unmount(); }; if (this.document) { if (this.alive) { iframe.contentDocument.replaceChild(this.document.documentElement, iframe.contentDocument.documentElement); // 保活场景需要事件全部恢复 recoverEventListeners(iframe.contentDocument.documentElement, iframeWindow); } else { await renderTemplateToIframe(iframe.contentDocument, this.iframe.contentWindow, this.template); // 非保活场景需要恢复根节点的事件,防止react16监听事件丢失 recoverDocumentListeners(this.document.documentElement, iframe.contentDocument.documentElement, iframeWindow); } } else { await renderTemplateToIframe(iframe.contentDocument, this.iframe.contentWindow, this.template); } this.document = iframe.contentDocument; return; } // 浏览器兼容webComponent 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); }
-
script通过iframe加载隔离,不会侵入宿主脚本
webComponent中没有script,怎么实现页面交互行为?怎么加载的iframe中的script?
大概的解析过程如下图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-16TwhMeT-1690978348322)(media/16885204415196/%E4%BC%81%E4%B8%9A%E5%BE%AE%E4%BF%A120230705-144933@2x.png)]
代理实现代码如下:
export function proxyGenerator(
iframe: HTMLIFrameElement,
urlElement: HTMLAnchorElement,
mainHostPath: string,
appHostPath: string
): {
proxyWindow: Window;
proxyDocument: Object;
proxyLocation: Object;
} {
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];
}
// 省略代码...
},
set: (target: Window, p: PropertyKey, value: any) => {
checkProxyFunction(value);
target[p] = value;
return true;
},
has: (target: Window, p: PropertyKey) => p in target,
});
// proxy document
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") {
// 。。。。
}
if (propKey === "documentURI" || propKey === "URL") {
return (proxyLocation as Location).href;
}
// from shadowRoot
if (
propKey === "getElementsByTagName" ||
propKey === "getElementsByClassName" ||
propKey === "getElementsByName"
) {
// 。。。
}
if (propKey === "getElementById") {
// 。。。
}
if (propKey === "querySelector" || propKey === "querySelectorAll") {
const rawPropMap = {
querySelector: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__",
querySelectorAll: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__",
};
//。。。
}
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);
}
},
}
);
// proxy 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] };
},
}
);
return { proxyWindow, proxyDocument, proxyLocation };
}
应用间通信实现原理
wujie应用间通讯、资源共享等通过主应用window挂载实现。
模版解析原理
与import-html-entry相同
micro-app
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V9Kc1NIf-1690978348322)(media/16885204415196/v2-ded24e5f4f92bb505dd5baa09797fc03_r.jpeg)]
js隔离
沙箱模式,类似乾坤的ProxySandbox。
css隔离
micro-app的css隔离类似CSS MODULE,通过添加唯一name前缀实现的class类名隔离。
元素隔离
micro-app实现了类似shadowDom功能,元素不会逃脱边界。
/**
* define element
* @param tagName element name
*/
export function defineElement (tagName: string): void {
class MicroAppElement extends HTMLElement implements MicroAppElementType {
static get observedAttributes (): string[] {
return ['name', 'url']
}
private isWaiting = false
private cacheData: Record<PropertyKey, unknown> | null = null
private connectedCount = 0
private connectStateMap: Map<number, boolean> = new Map()
public appName = '' // app name
public appUrl = '' // app url
public ssrUrl = '' // html path in ssr mode
public version = version
//...someHanlder
// create app instance
private handleCreateApp (): void {
const createAppInstance = () => new CreateApp({
name: this.appName,
url: this.appUrl,
container: this.shadowRoot ?? this,
scopecss: this.useScopecss(),
useSandbox: this.useSandbox(),
inline: this.getDisposeResult('inline'),
iframe: this.getDisposeResult('iframe'),
ssrUrl: this.ssrUrl,
})
/**
* Actions for destroy old app
* If oldApp exist, it must be 3 scenes:
* 1. oldApp is unmounted app (url is is different)
* 2. oldApp is prefetch, not prerender (url, scopecss, useSandbox, iframe is different)
* 3. oldApp is prerender (url, scopecss, useSandbox, iframe is different)
*/
const oldApp = appInstanceMap.get(this.appName)
if (oldApp) {
if (oldApp.isPrerender) {
this.unmount(true, createAppInstance)
} else {
oldApp.actionsForCompletelyDestroy()
createAppInstance()
}
} else {
createAppInstance()
}
}
/**
* Data from the base application
*/
set data (value: Record<PropertyKey, unknown> | null) {
if (this.appName) {
microApp.setData(this.appName, value as Record<PropertyKey, unknown>)
} else {
this.cacheData = value
}
}
/**
* get data only used in jsx-custom-event once
*/
get data (): Record<PropertyKey, unknown> | null {
if (this.appName) {
return microApp.getData(this.appName, true)
} else if (this.cacheData) {
return this.cacheData
}
return null
}
}
globalEnv.rawWindow.customElements.define(tagName, MicroAppElement)
}
通讯
主应用会向所有子应用注入microApp对象,通过统一对象实现应用间通讯
框架对比
对比 | qiankun | wujie | microApp |
---|---|---|---|
体积 | 94kb | 78kb | 30kb |
数据通讯机制 | 基于props属性传递 | 发布订阅 + CustomEvent | 发布订阅 + CustomEvent |
接入成本 | 中 | 低 | 低 |
多框架兼容 | √ | √ | √ |
js沙箱稳定 | √ | √ | √ |
window侵入 | x | √ | √ |
样式隔离 | x | √ | √ |
元素隔离 | x | √ | √ |
预加载 | √ | √ | √ |
保活模式 | x | √ | √ |
目前看来,乾坤的接入成本及js沙箱稳定系较差,但生态较强。无界代码隔离较好,但window挂载数据量较大比较适合中小型的微前端集成。microapp与无界较为类似,但window挂载数据量较小,沙箱隔离度较好,但接入适配仍需调研。
附上自己搭建的基于vite、wujie搭建的微前端框架:
主工程:https://github.com/SplitterChan/micro-v3-vite-template
子工程:https://github.com/SplitterChan/micro-v3-vite-sub-template