Skip to content
On this page

前言

前阵子看到新的微前端方案 无界,核心是用 iframe+WebComponent的方式实现。感觉很有意思,用到 iframe 的话就需要解决下面几个问题

  1. 路由状态丢失,刷新一下,iframe 的 url 状态就丢失了
  2. dom 割裂严重,弹窗只能在 iframe 内部展示,无法覆盖全局
  3. web 应用之间通信非常困难
  4. 每次打开白屏时间太长,对于 SPA 应用来说无法接受

这几个问题都是各大微前端框架不用 iframe 的原因之一,虽然有天然的 js css 隔离。

当前版本为 wujie@1.0.18

无界是如何渲染子应用

  1. 注册子应用信息,这个很好理解
  2. 创建子应用JS沙箱,通过主要依赖 iframe 实现
  3. 创建 WebComponent 自定义元素 wujie装载 Dom
  4. 运行 JS 脚本,渲染Dom

创建 JS 沙箱

JSiframe 中运行,那肯定要先创建一个 iframe 容器。

ts
class Wujie {
  constructor() {
    // 创建目标地址的解析,保证子应用与父应用是同域应用。来解决父子应用通信问题
    const { urlElement, appHostPath, appRoutePath } = appRouteParse(url);

    // 创建iframe
    this.iframe = iframeGenerator(
      this,
      attrs,
      mainHostPath,
      appHostPath,
      appRoutePath
    );
  }
}

function iframeGenerator(
  sandbox: WuJie,
  attrs: { [key: string]: any },
  mainHostPath: string,
  appHostPath: string,
  appRoutePath: string
): HTMLIFrameElement {
  const iframe = window.document.createElement("iframe");
  const attrsMerge = {
    src: mainHostPath,
    style: "display: none",
    ...attrs,
    name: sandbox.id,
    [WUJIE_DATA_FLAG]: "",
  };
  setAttrsToElement(iframe, attrsMerge);
  window.document.body.appendChild(iframe);
  // 停止加载 iframe 得到一个纯净的iframe
  sandbox.iframeReady = stopIframeLoading(iframeWindow).then(() => {});
  return iframe;
}

通过设置 子应用 url 与父应用一致来解决通信问题

Details

iframe 之间的通信

  • 同源时可直接使用 window.parent|window.top 获取父应用信息,通过 window.top === window.self 来判断自身是否为iframe
  • 非同源时通过possmessage 方法,以及 window.addEventListener('message',()=>{})

但是这样也带来的另外一个问题,我本身是想要获取子应用的内容。现在src设置成了父应用的,那岂不是加载了父应用内容? 这也很没必要,所以无界这边使用一个魔法来终止掉iframe加载.

ts
/**
 * 防止运行主应用的js代码,给子应用带来很多副作用
 */
// TODO 更加准确抓取停止时机
function stopIframeLoading(iframeWindow: Window) {
  const oldDoc = iframeWindow.document;
  // 在一开始 oldDoc 是blank
  // 加载html后就会变成具体的document对象
  return new Promise<void>((resolve) => {
    function loop() {
      setTimeout(() => {
        let newDoc;
        try {
          newDoc = iframeWindow.document;
        } catch (err) {
          newDoc = null;
        }
        // wait for document ready
        if (!newDoc || newDoc == oldDoc) {
          loop();
        } else {
          iframeWindow.stop
            ? iframeWindow.stop()
            : iframeWindow.document.execCommand("Stop");
          resolve();
        }
      }, 1);
    }
    loop();
  });
}

window.stop() 方法的效果相当于点击了浏览器的停止按钮。由于脚本的加载顺序,该方法不能阻止已经包含在加载中的文档,但是它能够阻止图片、新窗口、和一些会延迟加载的对象的加载。

解析入口

Wujie 也是以 html 为入口,这个跟qiankun 或者说跟主流微前端框架是一致的,毕竟现在都是经过前端打包器打包出来的产物。

加载入口的方式有两个 startApp 运行无界 app 和 preloadApp 预加载无界 app 这两个api

ts
export async function startApp(
  startOptions: startOptions
): Promise<Function | void> {
  // 实例化js沙箱
  const newSandbox = new WuJie({
    name,
    url,
    attrs,
    degradeAttrs,
    fiber,
    degrade,
    plugins,
    lifecycles,
  });

  // 根据url请求对应的html文件
  const { template, getExternalScripts, getExternalStyleSheets } =
    await importHTML({
      url,
      html,
      opts: {
        fetch: fetch || window.fetch,
        plugins: newSandbox.plugins,
        loadError: newSandbox.lifecycles.loadError,
        fiber,
      },
    });
}

importHTML 中会去分离 html中的 css脚本以及 script 脚本,得到纯 html 的文本。 就比如说有这样一段 html

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>New Tab</title>
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet" href="css/style.min.css" />
    <style>
      .bg {
        background: red;
      }
    </style>
  </head>
  <body class="hide-overlay">
    <div class="overlay loading-overlay"></div>
    <div id="main-view" style="visibility: hidden">
      <div class="backgrounds"></div>
      <div class="dashboard"></div>
      <div class="overlay drop-overlay">
        <p>
          Drop to upload backgrounds <span class="badge badge-plus">PLUS</span>
        </p>
      </div>
      <div class="full-screen-portals"></div>
    </div>
    <script src="app/globals.js"></script>
    <script src="js/lib.min.js" async></script>
    <script src="app/app.min.js" defer></script>
  </body>
</html>

经过 importHTML 处理后会得到 template, getExternalScripts, getExternalStyleSheets

其中 template 是去除 style,css,script 等标签后的纯html 内容

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>New Tab</title>
    <meta name="viewport" content="width=device-width" />
  </head>
  <body class="hide-overlay">
    <div class="overlay loading-overlay"></div>
    <div id="main-view" style="visibility: hidden">
      <div class="backgrounds"></div>
      <div class="dashboard"></div>
      <div class="overlay drop-overlay">
        <p>
          Drop to upload backgrounds <span class="badge badge-plus">PLUS</span>
        </p>
      </div>
      <div class="full-screen-portals"></div>
    </div>
  </body>
</html>

getExternalScripts 获取文档中所有的内联script具体格式如下

ts
function getExternalScripts(): ScriptResultList {}
interface ScriptBaseObject {
  /** 脚本地址,内联为空 */
  src?: string;
  /** 脚本是否为async执行 */
  async?: boolean;
  /** 脚本是否为defer执行 */
  defer?: boolean;
  /** 脚本是否为module模块 */
  module?: boolean;
  /** 脚本是否设置crossorigin */
  crossorigin?: boolean;
  /** 脚本crossorigin的类型 */
  crossoriginType?: "anonymous" | "use-credentials" | "";
  /** 脚本正则匹配属性 */
  attrs?: ScriptAttributes;
}

type ScriptObject = ScriptBaseObject & {
  /** 内联script的代码 */
  content?: string;
  /** 忽略,子应用自行请求 */
  ignore?: boolean;
  /** 子应用加载完毕事件 */
  onload?: Function;
};

type ScriptResultList = (ScriptBaseObject & {
  // 获取 script code 的Promise
  contentPromise: Promise<string>;
})[];

getExternalStyleSheets 获取文档中所有样式(不包括内联样式)

ts
function getExternalStyleSheets(): StyleResultList {}

interface StyleObject {
  /** 样式地址, 内联为空 */
  src?: string;
  /** 样式代码 */
  content?: string;
  /** 忽略,子应用自行请求 */
  ignore?: boolean;
}
type StyleResultList = {
  src: string;
  // 获取 样式内容的promise
  contentPromise: Promise<string>;
  ignore?: boolean;
}[];

为什么 wujie 要花这么大力气分离出 template,script 以及 style

因为 wujie 需要将 script 放入 iframe 中执行,将 template,style 放入 webComponent 自定义元素中,所以将他们分离出来。

wujie 的解析与 qiankun 的有何不同

qiankun 自己实现了 import-html-entry 来解析 html 内容,而 wujie 则是实现在 importHtml 中。其实两者思路上都大体一致利用正则匹配出各类标签,然后再去解析这类标签的 attrs.总体来说 wujie 的解析颗粒度更加细。

挂载 DOM 以及 style

上一步我们以及拿到模板中分离出来的 template 和 获取样式表具体内容的方法 getExternalStyleSheets

至于为什么要先挂载 Dom 有两点考虑

  1. 大部分的 UI 框架都需要有一个根节点才能挂载
  2. 后续沙箱中会劫持 dom

wujie中选择将 子应用dom 塞入自定义元素 wujie-app中,这样可以天然利用 webComponent 带来的 样式隔离 ,这也是与 qiankun 最大的区别。

ts
export function defineWujieWebComponent() {
  const customElements = window.customElements;
  if (customElements && !customElements?.get("wujie-app")) {
    class WujieApp extends HTMLElement {
      connectedCallback(): void {
        if (this.shadowRoot) return;
        const shadowRoot = this.attachShadow({ mode: "open" });
        const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));
        patchElementEffect(shadowRoot, sandbox.iframe.contentWindow);
        sandbox.shadowRoot = shadowRoot;
      }

      disconnectedCallback(): void {
        const sandbox = getWujieById(this.getAttribute(WUJIE_APP_ID));
        sandbox?.unmount();
      }
    }
    customElements?.define("wujie-app", WujieApp);
  }
}

html 模板和 样式内容进行合并,插入 自定义元素中

这段逻辑在 Wujie.active

ts
// packages\wujie-core\src\sandbox.ts

class Wujie {
  async active() {
    // 预执行无容器,暂时插入iframe内部触发Web Component的connect
    const iframeBody = rawDocumentQuerySelector.call(
      iframeWindow.document,
      "body"
    ) as HTMLElement;
    this.el = renderElementToContainer(
      createWujieWebComponent(this.id),
      el ?? iframeBody
    );

    // 注意这个 iframeWindow 还是那个纯净的沙箱
    await renderTemplateToShadowRoot(
      this.shadowRoot,
      iframeWindow,
      this.template
    );
  }
}

这样子就把解析出来的 样式和模板插入到自定义元素wujie-app中,当然这里面还有其他一些优化逻辑这里就先不分析。

执行沙箱脚本

上一小节以及挂载Dom了,接下来就可以执行 js

https://zhuanlan.zhihu.com/p/442815952

https://juejin.cn/post/7215967453913317434