基于iframe的微前端框架实现方案

950 阅读9分钟

使用iframe来构建微前端方案,在几年前kuitos大神在Why Not Iframe中提到了不使用此种方案的决定性原因。本文主要针对第一点url不同步和第二点dom结构不共享的痛点着手解决。下面先展示框架的设计思路,再针对两个痛点的解决方案做详细的描述。

general-design.png

整体思路:注册一个web componentxblade-edge,在该标签内部,通过sandbox实现对shadow dom管理,最终会创建shadowRoot和iframe, style, div(其中style和div是由dom-duplicate-plugin生成)。sandbox还提供注册插件的功能,在不同阶段调用plugin提供的方法。

更详细的代码点击xblade查看。

实现细节

dom与js

dom这一部分比较简单,通过import-html-entry中的parseHtml方法,获取到目标网页的html文本和scripts标签,然后将html赋值给iframe.srcdoc属性以及再经过一些处理后将scripts标签插入到iframe的header中。

dom.png

css隔离

iframe css不会污染全局即完成css隔离。

插件

url不同步和dom结构不共享的问题分别通过两个插件解决。

url-routing

该插件针对url不同步的问题进行解决。先看一下解决思路

url.png

解决方案分为两步,第一步是通过proxy拦截iframe上的history对象将子应用的url同步到主应用的url query参数,也需要拦截location对象以便子应用可以从query参数里获取到当前url信息, 第二步是将浏览器的popState事件同步给iframe。

在看具体实现之前,我们先解释一下为什么要这么做:

我们知道react的react-router-dom和vue的vue-router,都是以history库为基础进行编写的。在该库中,对pushState/replaceState进行了封装,封装成了push/replace方法,以及基于window.locationf封装了一个新的location对象,这里以react-router-dom为例

// react-router-dom/index.tsx
import { createBrowserHistory, createHashHistory } from "history";

export function BrowserRouter({
  basename,
  children,
  window,
}) {
  let historyRef = React.useRef();
  if (historyRef.current == null) {
    historyRef.current = createBrowserHistory({ window });
  }

  let history = historyRef.current;
  
  let [state, setState] = React.useState({
    action: history.action,
    location: history.location,
  });
 
  // ...

  return (
    <Router
      // ...
      location={state.location}
      navigationType={state.action}
      navigator={history}
    />
  );
}

// react-router-dom/index.tsx
export const Link = React.forwardRef(
  function LinkWithRef(
    { onClick, reloadDocument, replace = false, state, target, to, ...rest },
    ref
  ) {

    let internalOnClick = useLinkClickHandler(to, { replace, state, target });
    function handleClick(
      event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
    ) {
      // ...
        internalOnClick(event);
      
    }

    return (
      <a
        // ...
        onClick={handleClick}
      />
    );
  }
);

// react-router-dom/index.tsx 
export function useLinkClickHandle(
  to,
  {
    target,
    replace: replaceProp,
    state,
  } = {}
) {
  let navigate = useNavigate();

  return React.useCallback(
    (event) => {
      // ...
      navigate(to, { replace, state });
      
    },
    [/* deps */]
  );
}

// react-router/lib/hooks.tsx
export function useNavigate(): NavigateFunction {
  let {  navigator } = React.useContext(NavigationContext);

  let navigate: NavigateFunction = React.useCallback(
    (to, options = {}) => {
      // some code...
      if (typeof to === "number") {
        navigator.go(to);
        return;
      }

      // some code...

      (!!options.replace ? navigator.replace : navigator.push)(
        path,
        options.state
      );
    },
    [/* deps */]
  );

  return navigate;
}

可以看到这里使用的navigator就是一开始初始化的history对象,即通过调用history上的方法和获取history对象上的location属性来维护react-router-dom,下面我们再看一看history库相关的具体实现:

export function createBrowserHistory(
  options = {}
) {
  let { window = document.defaultView! } = options;
  let globalHistory = window.history;
  let [index, location] = getIndexAndLocation();

  function getIndexAndLocation() {
    let { pathname, search, hash } = window.location;
    let state = globalHistory.state || {};
    return [
      state.idx,
      {
        pathname,
        search,
        hash,
        state: state.usr || null,
        key: state.key || "default",
      },
    ];
  }
  
  function getNextLocation(to , state = null) {
    return {
      pathname: location.pathname,
      hash: "",
      search: "",
      ...(typeof to === "string" ? parsePath(to) : to),
      state,
      key: createKey(),
    };
  }

  function push(to , state) {
    let nextAction = Action.Push;
    let nextLocation = getNextLocation(to, state);

      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
        globalHistory.pushState(historyState, "", url);

  }

  function replace(to, state) {
    let nextAction = Action.Replace;
    let nextLocation = getNextLocation(to, state);
   
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index);

      globalHistory.replaceState(historyState, "", url);

    }
  }

  function go(delta) {
    globalHistory.go(delta);
  }

  let history = {
    get action() {
      return action;
    },
    get location() {
      return location;
    },
    push,
    replace,
    go,
  };

  return history;
}

在上面的代码中,我们可以看到,在react-router中调用的push/replace方法,最终都是调用了浏览器history对象中的pushState/replaceState方法来改变浏览器url;react-router中使用的location对象也是window上的location对象,至此,我们知道了所需要拦截的方法和属性,下面看具体的实现:

首先要在子应用中iframe调用replaceState/pushState时,屏蔽原有的操作,直接使用主应用的history对象上的两个方法将子应用的url同步到主应用的url上:

// proxy history
function createReplaceOrPushStateFunc(
  src: string,
  key: 'replaceState' | 'pushState'
) {
  return function (data, unused, url) {
    let pathname;
    const xbladeUrl = getQueryParam(location.href, XBLADE_APP_URL) || src;

    try {
      pathname = new URL(url).pathname;
    } catch {
      pathname = url;
    }
    if (pathname === new URL(xbladeUrl).pathname) {
      return;
    }

    if (typeof url === 'string') {
      const newBladeAppUrl = setPath(src, pathname as string);
      const newUrl = setQueryParam(
        location.href,
        XBLADE_APP_URL,
        newBladeAppUrl
      );

      history[key](data, unused, newUrl);
    }
  };
}

function proxyIframeHistory(iframeWindow, src) {
  iframeWindow[XBLADE_PROPERTY.History] = new Proxy({}, {
    get(_, key) {
      if (key === 'replaceState' || key === 'pushState') {
        return createReplaceOrPushStateFunc(src, key);
      }
      if (typeof history[key] === 'function')
        return history[key].bind(history);

      return history[key];
    },
  });
}

// proxy location
function getHrefProp(url, propName) {
  const bladeAppUrl = getQueryParam(location.href, XBLADE_APP_URL) || url;
  const bladeAppUrlObj = new URL(bladeAppUrl);
  return bladeAppUrlObj[propName as keyof URL];
}

function proxyIframeLocation(iframeWindow, src) {
  iframeWindow[XBLADE_PROPERTY.Location] = new Proxy({}, {
    get(_, key) {
      if (key === 'assign') {
        return location[key].bind(location);
      }
      if (typeof key === 'string') {
        return getHrefProp(src, key);
      }
      return location[key];
    },
    set(target, key, value) {
      return Reflect.set(target, key, value);
    },
  });
}

怎么才能在子应用中使用proxy过后的history和location呢?这就要用上在dom那一节获取的scripts标签了,我们获取到scripts标签的内容后,包裹一层IIFE,将处理后的window,location,history传进去。

function overrideIframeScriptGlobalVars(
  iframe,
  scripts
) {
  getExternalScripts(scripts, fetch, () => {}).then((scriptContents) => {
    scriptContents.map((s) => {
      const scriptElement = iframe.contentDocument.createElement('script');
      if (typeof s === 'string') {
        const code = `(function(window, self, location, history){
          try{
            ${s}
          } catch(e){
            console.log(e)
          }
          \n
        }).bind(window.__xblade__window)(
          window.__xblade__window,
          window.__xblade__window,
          window.__xblade__location,
          window.__xblade__history
        );`;
        scriptElement.text = code;
      }
      iframe.contentDocument.head.appendChild(scriptElement);
    });
  });
}

这样第一步就完成了。第二步要做的事就很简单了,我们直接监听主应用window上的popState事件,然后将popState事件在子应用的window上dispatch。

function popStateEventHandler(e) {
  iframeWindow.dispatchEvent(new PopStateEvent('popstate', e.state)); 
}

window.addEventListener('popstate', popStateEventHandler);

dom-duplicate-plugin

该插件针对dom结构不共享进行解决。看一下解决思路:

modal-design.png

我们获取到xblade-edge的selectors属性接收到的值,在xblade内部创建一个mutationObserver,观测iframe中body内部的element,当触发回调的element中存在可以被上述的selectors选中时,根据mutation type类型,使用不同的方案处理:

  • childList: 将element增加或者删除到shadowRoot下的overlayElement中
  • attributes:修改element的属性和iframe中的element保持一致

当然,我们还需要处理clone的element中的事件,比如点击clone后的弹窗关闭按钮,要将事件同步给iframe中相同的element,触发原有的事件。

再者,我们还需要考虑到弹窗样式的问题,需要将iframe中的所有style全部提取出来并放到与overlayElement项同的层级。

下面先看css部分的处理:

// shadowRoot中:root需要替换成:host才能生效
function setStyleForTargetElement(
  iframe,
  shadowRoot
) {
  iframe.contentDocument
    .querySelectorAll('style')
    .forEach((originStyleElement) => {
      const styleSheetElement = iframe.contentDocument.createElement('style');
      styleSheetElement.innerHTML = originStyleElement.innerHTML.replaceAll(
        ':root',
        ':host'
      );

      shadowRoot.appendChild(styleSheetElement);
    });
}

function insertOriginNodeStyle(iframe) {
  const styleSheetElement = iframe.contentDocument.createElement('style');
  iframe.contentDocument.head.appendChild(styleSheetElement);
  styleSheetElement.sheet.insertRule(
    '[data-xblade-visibility=true] { opacity: 0 !important; visibility: hidden !important;  }'
  );
}

function cloneFakeNodeConstruct(node, cloneNode) {
  let originNode = node;
  let currNode = cloneNode;

  while ((originNode.parentNode as HTMLElement).tagName !== 'BODY') {
    const cloneNodeParent = originNode.parentNode.cloneNode();
    originNode.parentNode.alternation = cloneNodeParent;
    cloneNodeParent.appendChild(currNode);
    originNode = originNode.parentNode;
    currNode = currNode.parentNode;
  }

  return currNode;
}

insertOriginNodeStyle这个方法是在iframe的head中插入一条css,目的是为了隐藏iframe中目标元素(总不能展示两个相同的元素吧)。

cloneFakeNodeConstruct会克隆目标element的上层元素直到body,目的是为维持克隆目标element的dom结构,使得css规则可以生效,让弹窗在iframe之外style也可以保持一致。这里有一个问题,为什么不直接将原element的style全部提取出来然后作用到克隆的element上呢?主要原因在于需要克隆的element多半是弹窗、通知类似的组件,一般这种类型的组件会有动画,且动画效果多半都是通过切换element的class名实现的,而且在attributes变化的回调参数中,接收到也是属性变化,我们只需要同步属性即可,而不需要去区分当前更改是什么属性,减轻处理的负担。

接着,我们处理事件。

function overrideIframeAddEventListener(iframe) {
  const originAddEventListener = iframe.contentWindow.EventTarget.prototype.addEventListener;

  const addEventListener = function (
    type,
    listener,
    options
  ) {
    if (this.listeners) {
      this.listeners.push({
        type,
        listener,
        options,
      });
    } else {
      this.listeners = [
        {
          type,
          listener,
          options,
        },
      ];
    }

    originAddEventListener.call(this, type, listener, options);
  };

  iframe.contentWindow.EventTarget.prototype.addEventListener =
    addEventListener;
}

首先我们要改写iframe上的addEventListener函数,将所用通过该函数添加的事件监听回调函数及其相关属性放到对应的dom节点的listeners属性上,方便后续获取。下面看一下在克隆element时如何处理事件:

export function cloneEvents(node, cloneNode) {
  if (!node && !cloneNode) {
    return;
  }
  node.alternation = cloneNode;
  const listenerAndNodePairs = [
    {
      node,
      listeners: [
        ...getReactDomListeners(node),
        ...(node.listeners || []),
      ],
    },
  ];

  if (listenerAndNodePairs) {
    for (const pair of listenerAndNodePairs) {
      const { node, listeners } = pair;
      for (const l of listeners) {
        cloneNode.addEventListener(
          l.type,
          (e: Event) => {
            // react根节点监听事件捕获阶段,调用stopPropagation会导致事件无法传下去,所以此处特殊处理
            if (typeof l.options !== 'boolean') {
              e.stopPropagation();
            }
            node.dispatchEvent(new (e.constructor)(e.type, e));
          },
          l.options
        );
      }
    }
  }

  for (let i = 0; i < node.childNodes.length; i++) {
    cloneEvents(node.childNodes[i], cloneNode.childNodes[i]);
  }
}

function getReactDomListeners(node) {
  const reactFiberPropsKey = Object.keys(node).find((key) =>
    key.includes(REACT_DOM_PROP)
  );
  const listeners: any[] = [];

  if (!reactFiberPropsKey) {
    return listeners;
  }

  const props = node[reactFiberPropsKey];

  for (const key in props) {
    if (key.slice(0, 2) === 'on') {
      const eventName = key.slice(2).toLocaleLowerCase();
      const eventCallback = props[key];
      listeners.push({
        type: eventName,
        listener: eventCallback,
      });
    }
  }

  return listeners;
}

特别注意,react17事件已经不在dom注册事件回调的节点上监听了,此处在getReactDomListeners中进行处理,进一步的解释可以看我的这篇文章。这里做的事非常简单,就是将每个节点收集到listener在对应的clone节点上监听,然而,我们并不执行原有的事件回调,而是在原节点上dispatch事件,这样就把事件同步到了iframe中。

接着,我们看一下如何处理observer

function createTargetElementObserver(
  iframe,
  selectors,
  overlayElement&
) {
  const observer = new MutationObserver((mutationList) => {
    mutationList.forEach((mutation) => {
      switch (mutation.type) {
      case 'attributes':
        if (hasTargetSelector(mutation.target, selectors)) {
          attributesHandler(mutation);
        }
        break;
      case 'childList':
        addChildListHandler(mutation, selectors, overlayElement);
        removeChildListHandler(mutation);
        break;
      default:
        break;
      }
    });
  });

  observer.observe(iframe.contentDocument.body, {
    attributes: true,
    childList: true,
    subtree: true,
  });

  return observer;
}

这里比较清晰,直接创建了一个observer,然后根据不同的type使用不同的handler来处理。

首先看attributesHandler:

function attributesHandler(mutation) {
  const targetElement = mutation.target.alternation as HTMLElement;
  if (targetElement) {
    const attrName = mutation.attributeName;
    const attrValue = (mutation.target as HTMLElement).getAttribute(attrName);
    targetElement.setAttribute(attrName, attrValue);
  }
}

直接修改对应的属性即可。

addChildListHandler,我们首先判断当前node的子元素和父元素上有没有目标selector,如果有,我们就clone这个节点和这个节点上的事件。

function addChildListHandler(
  mutation,
  selectors,
  overlayElement
) {
  const addedNodes = mutation.addedNodes;
  for (const node of addedNodes) {
    if (hasTargetSelector(node, selectors)) {
      const cloneNode = cloneNodeAndEvents(node);
      hideOriginElement(node as HTMLElement);

      if (node.parentNode.alternation) {
        updateTargetElement(node, cloneNode);
      } else {
        mountTargetElement(node, cloneNode, overlayElement);
      }
    }
  }
}

function updateTargetElement(node, cloneNode) {
  const alternativeChildren = node.parentNode.alternation.childNodes;
  const children = node.parentNode.childNodes;

  const nodeIndex = Array.from(children).findIndex((child) => child === node);

  if (nodeIndex === children.length - 1) {
    node.parentNode.alternation.appendChild(cloneNode);
  } else {
    node.parentNode.alternation.insertBefore(
      cloneNode,
      alternativeChildren[nodeIndex]
    );
  }
}

function mountTargetElement(
  node,
  cloneNode,
  overlayEl
) {
  const fakeRoot = cloneFakeNodeConstruct(node, cloneNode);
  try {
    overlayEl.appendChild(fakeRoot);
  } catch (error) {
    console.log('fake error: ', error);
  }
}

mountTargetElement函数中,我们通过cloneFakeNodeConstruct方法,将从当前节点开始一直往上到body节点的克隆所有节点,目的就是为了保证dom结构和iframe中的结构一致,这样才能使得style生效。并且,在clone的过程中,我们还会将iframe中node新增一个属性alternation并指向clone后的节点。这样我们通过判断node.parentNode.alternation是否存在就知道当前节点是第一次挂载还是属于overlayElement中新增的子节点。如果是第一次出现,那么就执行cloneFakeNodeConstruct方法,并将其插入到overlayElement中;如果该节点的父节点已经存在于overlayElement中,我们就需要将其插入到对应的位置上。

最后我们再来看一下removeChildListHandler做了什么,

function removeChildListHandler(mutation) {
  const removedNodes = mutation.removedNodes;
  for (const node of removedNodes) {
    if (node.alternation) {
      node.alternation.parentNode.removeChild(node.alternation);
      node.alternation = null;
    }
  }
}

就是将删除节点在iframe中对应位置的节点删除。

至此,我们就分析完了所有的技术点,更多的细节可以移步这个仓库查看。现在还只是在初期阶段,还有很多问题以及特性待解决,例如:

  1. 基于现在的子应用url同步方案,如何做到嵌套处理
  2. 性能问题,页面同时加载多个iframe卡顿感还是很明显的
  3. 子应用动态插入style还未处理
  4. 应用间的通信
  5. 插件机制,是否有必要存在或者说有更好的设计
    ...

上述说的问题基本都有解决方案雏形,后续有机会再更新解决方案~

至此,关于基于iframe的微前端实现方案已经阐述完毕,文中如有描述有误,还请各位大佬不吝赐教,不清楚的地方,也可以讨论一下~


使用的画图工具:excalidraw.com/
代码仓库:github.com/shnning/xbl…