乾坤

乾坤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主要解析原理

  1. 通过fetch获取entry资源
  2. processTpl通过正则解析html模版并抽取script、style并删除注释
  3. getExternalStyleSheets将link样式转换为inlineStyle
  4. execScripts执行抽取的script脚本

无界

无界代码隔离实现原理

  1. 实现自定义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);
    }
    
  2. 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对象,通过统一对象实现应用间通讯

框架对比

对比qiankunwujiemicroApp
体积94kb78kb30kb
数据通讯机制基于props属性传递发布订阅 + CustomEvent发布订阅 + CustomEvent
接入成本
多框架兼容
js沙箱稳定
window侵入x
样式隔离x
元素隔离x
预加载
保活模式x

目前看来,乾坤的接入成本及js沙箱稳定系较差,但生态较强。无界代码隔离较好,但window挂载数据量较大比较适合中小型的微前端集成。microapp与无界较为类似,但window挂载数据量较小,沙箱隔离度较好,但接入适配仍需调研。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐