微前端原理解析

概念

微前端

微前端是一种架构风格,它将一个大型的前端应用拆分成多个独立的微应用,这些微应用可以独立开发、部署和维护。微前端的目标是将一个复杂的前端应用拆分成多个小的、可独立开发和部署的应用,从而提高开发效率和应用的可维护性。

在实际场景中,我们常常需要再当前主应用,嵌入某个子应用,就不必自己开发,子应用可能有别的业务部门开发,技术栈亦不一致。

几种集成微前端的方式

npm 包

  1. 将子应用以 lib 模式构建,提供一个mount应用挂载方法,提供一个打成一个 npm 包。
  2. 主应用在项目中以依赖的方式安装子应用。
js
// micoapp.js
export function mount(app: HTMLElement) {
  micoapp.render(app);
}

问题:

  • 子应用需要单独维护一个 npm 包,一个 lib 的构建模式
  • 构建子应用时,会需要打包对依赖进行控制。我们通常构建库模式不会希望打包所有依赖,依赖由主应用安装,但是子应用的依赖可会和主应用冲突,比如:主应用使用的是 vue@2.x,库使用的是 vue@3.x
    1. 在构建工具层面会可能报错(与构建工具策略相关),比如查找构建 vue3的 api ref,watch 时,会报错
    2. 运行时报错,在运行时,vue@2.xvue@3.x 的全局命名空间都是 Vue会冲突,出现兼容性问题
    3. 所以需要知道冲突的依赖,进行 internal 内联构建处理
  • 需要给子应用增加样式作用域,否则会出现样式污染;比如:tailwindcss 需要加唯一 prefix
  • 子应用的路由需要单独管理,主应用需要根据子应用的路由进行跳转
  • 加载性能问题

打包成 umd 格式

将子应用打包成 umd 格式,将依赖全量构建。上传 cdn,可以解决每次更新子应用,都需要主应用升级依赖发布的问题。

js
const umd = {
  format: "umd",
  name: "SportShare",
  entryFileNames: format,
  // 输出目录
  dir: "dist/umd",
};

使用 iframe 嵌入

直接利用原生 iframe 嵌入子应用的站点,可以有效的解决样式隔离和脚本隔离的问题,但是存在一些问题:

  • 子路由跳转路由后,刷新页面,无法保持状态,即主应用和子应用的路由通信问题
  • 子应用的 modal 始终是基于 iframe 定位的,会和主应用割裂
  • iframe 的每次创建,会并发加载大量资源,造成性能损耗

微前端框架可以有效解决以上问题。

wujie 微前端框架原理解析

微前端

解析 html:

js
// 简化源码
const { template, getExternalScripts, getExternalStyleSheets } =
  await importHTML({ url, html, opts: {} });
//fetch html text
function importHTML(params: {
  url: string,
  html?: string,
  opts: ImportEntryOpts,
}): Promise<htmlParseResult> {
  const getHtmlParseResult = (url, html, htmlLoader) =>
    fetch(url)
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const assetPublicPath = getPublicPath(url);
        // 解析 html 中的 script 和 link 标签 template
        const { template, scripts, styles } = processTpl(
          htmlLoader(html),
          assetPublicPath
        );
        return {
          template: template,
          getExternalScripts: () => getExternalScripts(scripts),
          getExternalStyleSheets: () => getExternalStyleSheets(styles),
        };
      });
  return getHtmlParseResult(url, html, htmlLoader);
}

样式隔离

style 转换成 inline style,替换原来的 template 中的 link,再将 template 插入到 shadow dom 中。

js
/**
 * convert external css link to inline style for performance optimization
 * @return embedHTML
 */
async function getEmbedHTML(
  template,
  styleResultList: StyleResultList
): Promise<string> {
  let embedHTML = template;

  return Promise.all(
    styleResultList.map((styleResult, index) =>
      styleResult.contentPromise.then((content) => {
        if (styleResult.src) {
          embedHTML = embedHTML.replace(
            genLinkReplaceSymbol(styleResult.src),
            styleResult.ignore
              ? `<link href="${styleResult.src}" rel="stylesheet" type="text/css">`
              : `<style>/* ${styleResult.src} */${content}</style>`
          );
        } else if (content) {
          embedHTML = embedHTML.replace(
            getInlineStyleReplaceSymbol(index),
            `<style>/* inline-style-${index} */${content}</style>`
          );
        }
      })
    )
  ).then(() => embedHTML);
}
/**
 * 将template渲染到shadowRoot
 */
export async function renderTemplateToShadowRoot(
  shadowRoot: ShadowRoot,
  iframeWindow: Window,
  template: string
): Promise<void> {
  const html = renderTemplateToHtml(iframeWindow, template);
  // 处理 css-before-loader 和 css-after-loader
  const processedHtml = await processCssLoaderForTemplate(
    iframeWindow.__WUJIE,
    html
  );
  shadowRoot.appendChild(processedHtml);
}

脚本隔离 SandBox

js
// 创建一个iframe
class wujie{
  constructor(attrs){
   this.iframe = iframeGenerator(this, attrs, mainHostPath, appHostPath, appRoutePath);
  }
  start(){
     const scriptResultList = await getExternalScripts();
         // 同步代码
    const syncScriptResultList: ScriptResultList = [];
    // async代码无需保证顺序,所以不用放入执行队列
    const asyncScriptResultList: ScriptResultList = [];
    // defer代码需要保证顺序并且DOMContentLoaded前完成,这里统一放置同步脚本后执行
    const deferScriptResultList: ScriptResultList = [];

    scriptResultList.forEach((scriptResult) => {
      if (scriptResult.defer) deferScriptResultList.push(scriptResult);
      else if (scriptResult.async) asyncScriptResultList.push(scriptResult);
      else syncScriptResultList.push(scriptResult);
    });

    // 同步代码
    // fiber ? 则在浏览器空闲时间插入iframe
    syncScriptResultList.concat(deferScriptResultList).forEach((scriptResult) => {
      this.execQueue.push(() =>
        scriptResult.contentPromise.then((content) =>
          this.fiber
            ? this.requestIdleCallback(() => insertScriptToIframe({ ...scriptResult, content }, iframeWindow))
            : insertScriptToIframe({ ...scriptResult, content }, iframeWindow)
        )
      );
    });
  }

  // asyncScriptResultList 同上
}

proxy代理

js
/**
 * 非降级情况下window、document、location代理
 */
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];
      }
      // https://262.ecma-international.org/8.0/#sec-proxy-object-internal-methods-and-internal-slots-get-p-receiver
      const descriptor = Object.getOwnPropertyDescriptor(target, p);
      if (descriptor?.configurable === false && descriptor?.writable === false) {
        return target[p];
      }
      // 修正this指针指向
      return getTargetValue(target, p);
    },

    set: (target: Window, p: PropertyKey, value: any) => {
      checkProxyFunction(target, 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") {
          return new Proxy(document[propKey], {
            apply(_createElement, _ctx, args) {
              const rawCreateMethod = propKey === "createElement" ? rawCreateElement : rawCreateTextNode;
              const element = rawCreateMethod.apply(iframe.contentDocument, args);
              patchElementEffect(element, iframe.contentWindow);
              return element;
            },
          });
        }
        if (propKey === "documentURI" || propKey === "URL") {
          return (proxyLocation as Location).href;
        }

        // from shadowRoot
        if (
          propKey === "getElementsByTagName" ||
          propKey === "getElementsByClassName" ||
          propKey === "getElementsByName"
        ) {
          return new Proxy(shadowRoot.querySelectorAll, {
            apply(querySelectorAll, _ctx, args) {
              let arg = args[0];
              if (_ctx !== iframe.contentDocument) {
                return _ctx[propKey].apply(_ctx, args);
              }

              if (propKey === "getElementsByTagName" && arg === "script") {
                return iframe.contentDocument.scripts;
              }
              if (propKey === "getElementsByClassName") arg = "." + arg;
              if (propKey === "getElementsByName") arg = `[name="${arg}"]`;

              // FIXME: This string must be a valid CSS selector string; if it's not, a SyntaxError exception is thrown;
              // so we should ensure that the program can execute normally in case of exceptions.
              // reference: https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll

              let res: NodeList[] | [];
              try {
                res = querySelectorAll.call(shadowRoot, arg);
              } catch (error) {
                res = [];
              }

              return res;
            },
          });
        }
        if (propKey === "getElementById") {
          return new Proxy(shadowRoot.querySelector, {
            // case document.querySelector.call
            apply(target, ctx, args) {
              if (ctx !== iframe.contentDocument) {
                return ctx[propKey]?.apply(ctx, args);
              }
              try {
                return (
                  target.call(shadowRoot, `[id="${args[0]}"]`) ||
                  iframe.contentWindow.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__.call(
                    iframe.contentWindow.document,
                    `#${args[0]}`
                  )
                );
              } catch (error) {
                warn(WUJIE_TIPS_GET_ELEMENT_BY_ID);
                return null;
              }
            },
          });
        }
        if (propKey === "querySelector" || propKey === "querySelectorAll") {
          const rawPropMap = {
            querySelector: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__",
            querySelectorAll: "__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__",
          };
          return new Proxy(shadowRoot[propKey], {
            apply(target, ctx, args) {
              if (ctx !== iframe.contentDocument) {
                return ctx[propKey]?.apply(ctx, args);
              }
              // 二选一,优先shadowDom,除非采用array合并,排除base,防止对router造成影响
              return (
                target.apply(shadowRoot, args) ||
                (args[0] === "base"
                  ? null
                  : iframe.contentWindow[rawPropMap[propKey]].call(iframe.contentWindow.document, args[0]))
              );
            },
          });
        }
        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 };
}

预加载

其实就是先试用fetch获取资源,后续资源就可以走缓存 如果开启了 fiber ,则在浏览器空闲时间插入资源,可以提高性能,避免占用主线程

js
// for prefetch
export function getExternalStyleSheets(
  styles: StyleObject[],
  fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> = defaultFetch,
  loadError: loadErrorHandler
): StyleResultList {
  return styles.map(({ src, content, ignore }) => {
    // 内联
    if (content) {
      return { src: "", contentPromise: Promise.resolve(content) };
    } else if (isInlineCode(src)) {
      // if it is inline style
      return { src: "", contentPromise: Promise.resolve(getInlineCode(src)) };
    } else {
      // external styles
      return {
        src,
        ignore,
        contentPromise: ignore ? Promise.resolve("") : fetchAssets(src, styleCache, fetch, true, loadError),
      };
    }
  });
}

// for prefetch
export function getExternalScripts(
  scripts: ScriptObject[],
  fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> = defaultFetch,
  loadError: loadErrorHandler,
  fiber: boolean
): ScriptResultList {
  // module should be requested in iframe
  return scripts.map((script) => {
    const { src, async, defer, module, ignore } = script;
    let contentPromise = null;
    // async
    if ((async || defer) && src && !module) {
      contentPromise = new Promise((resolve, reject) =>
        fiber
          ? requestIdleCallback(() => fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject))
          : fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject)
      );
      // module || ignore
    } else if ((module && src) || ignore) {
      contentPromise = Promise.resolve("");
      // inline
    } else if (!src) {
      contentPromise = Promise.resolve(script.content);
      // outline
    } else {
      contentPromise = fetchAssets(src, scriptCache, fetch, false, loadError);
    }
    // refer https://html.spec.whatwg.org/multipage/scripting.html#attr-script-defer
    if (module && !async) script.defer = true;
    return { ...script, contentPromise };
  });
}

主子应用路由同步

js
/**
 * 同步子应用路由到主应用路由
 */
export function syncUrlToWindow(iframeWindow: Window): void {
  const { sync, id, prefix } = iframeWindow.__WUJIE;
  let winUrlElement = anchorElementGenerator(window.location.href);
  const queryMap = getAnchorElementQueryMap(winUrlElement);
  // 非同步且url上没有当前id的查询参数,否则就要同步参数或者清理参数
  if (!sync && !queryMap[id]) return (winUrlElement = null);
  const curUrl = iframeWindow.location.pathname + iframeWindow.location.search + iframeWindow.location.hash;
  let validShortPath = "";
  // 同步
  if (sync) {
    queryMap[id] = window.encodeURIComponent(
      validShortPath ? curUrl.replace(prefix[validShortPath], `{${validShortPath}}`) : curUrl
    );
    // 清理
  } else {
    delete queryMap[id];
  }
  const newQuery =
    "?" +
    Object.keys(queryMap)
      .map((key) => key + "=" + queryMap[key])
      .join("&");
  winUrlElement.search = newQuery;
  if (winUrlElement.href !== window.location.href) {
    window.history.replaceState(null, "", winUrlElement.href);
  }
  winUrlElement = null;
}

/**
 * 同步主应用路由到子应用
 */
export function syncUrlToIframe(iframeWindow: Window): void {
  // 获取当前路由路径
  const { pathname, search, hash } = iframeWindow.location;
  const { id, url, sync, execFlag, prefix, inject } = iframeWindow.__WUJIE;

  // 只在浏览器刷新或者第一次渲染时同步
  const idUrl = sync && !execFlag ? getSyncUrl(id, prefix) : url;
  // 排除href跳转情况
  const syncUrl = (/^http/.test(idUrl) ? null : idUrl) || url;
  const { appRoutePath } = appRouteParse(syncUrl);

  const preAppRoutePath = pathname + search + hash;
  if (preAppRoutePath !== appRoutePath) {
    iframeWindow.history.replaceState(null, "", inject.mainHostPath + appRoutePath);
  }
}
pnpm&monorepo