电商网站前后台模板小说风云榜
乾坤
乾坤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容器内部domif (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的connectconst 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;}// 不要绑定thisif (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 documentconst 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 fixif (propKey === "createElement" || propKey === "createTextNode") {// 。。。。}if (propKey === "documentURI" || propKey === "URL") {return (proxyLocation as Location).href;}// from shadowRootif (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.documentif (documentProperties.includes(propKey.toString())) {return document[propKey];}if (documentMethods.includes(propKey.toString())) {return getTargetValue(document, propKey);}},});// proxy locationconst 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) {// 如果是跳转链接的话重开一个iframeif (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 = falseprivate cacheData: Record<PropertyKey, unknown> | null = nullprivate connectedCount = 0private connectStateMap: Map<number, boolean> = new Map()public appName = '' // app namepublic appUrl = '' // app urlpublic ssrUrl = '' // html path in ssr modepublic version = version//...someHanlder// create app instanceprivate 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