2022 你还不会微前端吗 (下) — 揭秘微前端核心原理

前言

在上篇 2022 你还不会微前端吗 (上) — 从巨石应用到微应用 中已经了解了微前端的由来和基本使用,也提到了一些相关的原理,本篇文章为下篇主要从原理层面进行解析,然后再自己实现一个包含核心部分的微前端框架。

00065C4B.gif

微前端核心原理

当然在正式开始自己实现之前,有且非常有必要先了解一下已有的微前端框架是如何实现其核心功能的,这里我们以 qiankun 来作为目标来了解一下其中的核心点:

  • 路由劫持
  • 加载子应用
  • 独立运行时,即沙箱
  • 应用通信

路由劫持

qiankun 中路由劫持是通过 single-spa 实现的,而它本身则提供了另外两种核心功能,即 子应用的加载沙箱隔离

监听 hash 路由 和 history 路由

我们知道路由会分为 hash 路由 和 history 路由,因此要监听路由变化就得注册 hashchangepopstate 事件:

  • 当通过类似 window.location.href = xxx 的方式修改 hash 值时会直接 hashchange 事件
  • 当使用原生的 pushStatereplaceState 改变当前 history 路由时,是并不会触发 popstate 事件,因此需要对原生的 pushStatereplaceState重写/增强,这样在重写/增强后的方法中,就可以通过手动派发 popstate 的方式实现当调用 pushStatereplaceState 方法时能够触发 replaceState 事件

源码位置:single-spa\src\navigation\navigation-events.js

function createPopStateEvent(state, originalMethodName) {
    // 省略代码
    if (isInBrowser) {
      // 分别为 hash 路由和 history 路由注册监听事件
      window.addEventListener("hashchange", urlReroute);
      window.addEventListener("popstate", urlReroute);

      // 省略代码

      // 重写/增强原有的 window.history.pushState 和 window.history.replaceState 方法
      window.history.pushState = patchedUpdateState(
        window.history.pushState,
        "pushState"
      );
      window.history.replaceState = patchedUpdateState(
        window.history.replaceState,
        "replaceState"
      );
      
    // 省略代码
  }
}


function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;

    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      if (isStarted()) {
        // 子应用启动后,需要手动触发 popstate 事件,这样子应用就可以知道路由发生变化后需要如何匹配自身的路由
        window.dispatchEvent(
          createPopStateEvent(window.history.state, methodName)
        );
      } else {
        // 子应用启动之前不需要手动触发 popstate 事件,因为其他应用不需要了解在知识呢定义的路由之外的路由事件
        reroute([]);
      }
    }

    return result;
  };
}

拦截额外的导航事件

除了在微前端框架中需要监听对应的导航事件外,在微前端框架外部我们也可以通过 addEventListener 的方式来注册 hashchangepopstate 事件,那么这样一来导航事件就会有多个,为了在实现对导航事件的控制,达到路由变化时对应的子应用能够正确的 卸载挂载,需要对 addEventListener 注册的 hashchangepopstate 进行拦截,并将对应的事件给存储起来,便于后续在特定的时候能够实现手动触发。

源码位置:single-spa\src\navigation\navigation-events.js

// 捕获导航事件侦听器,以便确保对应的子应用正确的卸载和安装
const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};

export const routingEventsListeningTo = ["hashchange", "popstate"];

function createPopStateEvent(state, originalMethodName) {
  // 保存原始方法
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;
  
  // 重写/增强 addEventListener
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function") {
      // 拦截 hashchange 和 popstate 类型的事件 
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], (listener) => listener === fn)
      ) {
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }

    return originalAddEventListener.apply(this, arguments);
  };
  
  // 重写/增强 removeEventListener
  window.removeEventListener = function (eventName, listenerFn) {
    if (typeof listenerFn === "function") {
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        capturedEventListeners[eventName] = capturedEventListeners[
          eventName
        ].filter((fn) => fn !== listenerFn);
        return;
      }
    }

    return originalRemoveEventListener.apply(this, arguments);
  };
}

加载子应用

上篇文章 中其实不难发现,如果直接使用 single-spa 实现微前端那么在基座应用中注册子应用时,必须要指定每个子应用对应的 url,以及如何加载子应用依赖的 js 文件等,每个子应用信息大致如下:

{
        name: 'singleVue3', // 子应用注册时的 name
        async activeWhen() { // 当匹配到对应的 url 且子应用加载完毕时
            await loadScript('http://localhost:5000/js/chunk-vendors.js');
            await loadScript('http://localhost:5000/js/app.js');
            return window.singleVue3
        },
        app(location: Location) {
            return location.pathname.startsWith('/vue3-micro-app')
        },
        customProps: {
            container: '#micro-content'
        }
 }

相反,再看看 qiankun 注册子应用时,每个子应用的信息大致如下:

{
    name: 'singleVue3',
    entry: 'http://localhost:5000',
    container: '#micro-content',
    activeRule: '/vue3-micro-app',
}

会发现更加简洁,并且也不用在手动指定子应用依赖的 js 文件,那么 qiankun 是怎么知道当前子应用需要依赖什么 js 文件呢?

通过 import-html-entry 加载并解析子应用的 HTML

在基座应用中通过调用 registerMicroApps(...) 函数注册子应用时,其内部实际上是通过 single-spa 中的 registerApplication(...) 函数来实现的,其内容如下:

// qiankun\src\apis.ts

import { mountRootParcel, registerApplication, start as startSingleSpa } from 'single-spa';
import { loadApp } from './loader';

export function registerMicroApps(
  apps: Array>,
  lifeCycles?: FrameworkLifeCycles,
) {
  // 每个子应用自会被注册一次
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));

  microApps = [...microApps, ...unregisteredApps];

  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    // 真正注册子应用的地方,通过 loadApp 加载并解析子应用对应的 html 模板
    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

其中比较核心的就是 loadApp(...) 函数:

  • 会通过 import-html-entry 中的 importEntry(...) 函数获取入口的 HTML 内容和 script 的执行器

    • 通过 fetch() 请求到子应用的 html 字符串
    • 通过 processTpl() 函数将对应的 html 字符串进行处理,即通过正则去匹配获其中的 jscssentry js 等等内容
    • processTpl() 函数会返回如下结果

      • templatehtml 模板内容
      • scriptsjs 脚本包含内联和外联
      • stylescss 样式表,包含内联和外联
      • entry:子应用入口 js 脚本,若没有则默认为 scripts[scripts.length - 1]
// qiankun\src\loader.ts
import { importEntry } from 'import-html-entry';

export async function loadApp(
  app: LoadableApp,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles,
): Promise {
  const { entry, name: appName } = app;

 // 省略代码

  const {
    singular = false,
    sandbox = true,
    excludeAssetFilter,
    globalContext = window,
    ...importEntryOpts
  } = configuration;

  // 获取入口的 HTML 内容 和 script 的执行器
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
  
  省略代码 
}

处理模板内的 CSS

上述已经获取到了 css 样式表相关的数据 styles,而样式又会区分 内联外联 样式:

  • 内联样式 通过查找 <> 的索引位置,最后使用 substring 方法来截取具体内容
  • 外链样式 则通过 fetch 请求对应的资源

    // import-html-entry\src\index.js
    
    // 获取内嵌的 HTML 内容
    function getEmbedHTML(template, styles) {
    var opts = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
    var _opts$fetch = opts.fetch,
        fetch = _opts$fetch === void 0 ? defaultFetch : _opts$fetch;
    var embedHTML = template;
    return _getExternalStyleSheets(styles, fetch).then(function (styleSheets) {
      embedHTML = styles.reduce(function (html, styleSrc, i) {
        html = html.replace((0, _processTpl2.genLinkReplaceSymbol)(styleSrc), isInlineCode(styleSrc) ? "".concat(styleSrc) : ""));
        return html;
      }, embedHTML);
      return embedHTML;
    });
    }
    
    // 获取 css 资源
    function _getExternalStyleSheets(styles) {
    var fetch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : defaultFetch;
    return Promise.all(styles.map(function (styleLink) {
      if (isInlineCode(styleLink)) {
        // 内联样式
        return (0, _utils.getInlineCode)(styleLink);
      } else {
        // 外链样式
        return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(function (response) {
          return response.text();
        }));
      }
    }));
    } // for prefetch
    
    // import-html-entry\lib\utils.js
    
    function getInlineCode(match) {
    var start = match.indexOf('>') + 1;
    var end = match.lastIndexOf('<');
    return match.substring(start, end);
    }

    处理模板中的 JavaScript

    处理 js 脚本的方式和 css 样式表的方式大致相同,仍然是需要区分内联和外链两种:

  • 内联 script 通过查找 <> 的索引位置,最后使用 substring 方法来截取具体内容
  • 外链 script 则通过 fetch 请求对应的资源
  • 通过 eval() 来执行 script 脚本的内容

    // import-html-entry\src\utils.js
    
    export function execScripts(entry, scripts, proxy = window, opts = {}) {
      ...
      return getExternalScripts(scripts, fetch, error)
          .then(scriptsText => {
    
              const geval = (scriptSrc, inlineScript) => {
                  const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
                                  // 获取可执行的 code
                  const code = getExecutableScript(scriptSrc, rawCode, { proxy, strictGlobal, scopedGlobalVariables });
                                  
                                  // 执行代码
                  evalCode(scriptSrc, code);
    
                  afterExec(inlineScript, scriptSrc);
              };
    
              function exec(scriptSrc, inlineScript, resolve) {
    
                  ...
    
                  if (scriptSrc === entry) {
                      noteGlobalProps(strictGlobal ? proxy : window);
    
                      try {
                          // bind window.proxy to change `this` reference in script
                          geval(scriptSrc, inlineScript);
                          const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
                          resolve(exports);
                      } catch (e) {
                          // entry error must be thrown to make the promise settled
                          console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`);
                          throw e;
                      }
                  } else {
                      if (typeof inlineScript === 'string') {
                          try {
                              // bind window.proxy to change `this` reference in script
                              geval(scriptSrc, inlineScript);
                          } catch (e) {
                              // consistent with browser behavior, any independent script evaluation error should not block the others
                              throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`);
                          }
                      } else {
                          // external script marked with async
                          inlineScript.async && inlineScript?.content
                              .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText))
                              .catch(e => {
                                  throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`);
                              });
                      }
                  }
                                  ...
              }
    
              function schedule(i, resolvePromise) {
    
                  if (i < scripts.length) {
                      const scriptSrc = scripts[i];
                      const inlineScript = scriptsText[i];
    
                      exec(scriptSrc, inlineScript, resolvePromise);
                      // resolve the promise while the last script executed and entry not provided
                      if (!entry && i === scripts.length - 1) {
                          resolvePromise();
                      } else {
                          schedule(i + 1, resolvePromise);
                      }
                  }
              }
    
              return new Promise(resolve => schedule(0, success || resolve));
          });
    }
    
    // 通过 eval() 执行脚本内容
    export function evalCode(scriptSrc, code) {
      const key = scriptSrc;
      if (!evalCache[key]) {
          const functionWrappedCode = `(function(){${code}})`;
                  // eval 函数
          evalCache[key] = (0, eval)(functionWrappedCode);
      }
      const evalFunc = evalCache[key];
      evalFunc.call(window);
    }
    
    // import-html-entry\src\index.js
    
    export function getExternalScripts(scripts, fetch = defaultFetch, errorCallback = () => {
    }) {
    
      const fetchScript = scriptUrl => scriptCache[scriptUrl] ||
          (scriptCache[scriptUrl] = fetch(scriptUrl).then(response => {
              // 通常浏览器将脚本加载的 4xx 和 5xx 响应视为错误并会触发脚本错误事件
              // https://stackoverflow.com/questions/5625420/what-http-headers-responses-trigger-the-onerror-handler-on-a-script-tag/5625603
              if (response.status >= 400) {
                  errorCallback();
                  throw new Error(`${scriptUrl} load failed with status ${response.status}`);
              }
                          
              return response.text();
          }).catch(e => {
              errorCallback();
              throw e;
          }));
    
      return Promise.all(scripts.map(script => {
    
              if (typeof script === 'string') {
                  if (isInlineCode(script)) {
                      // 内联 script
                      return getInlineCode(script);
                  } else {
                      // 外链 script
                      return fetchScript(script);
                  }
              } else {
                  // 使用空闲时间加载 async script
                  const { src, async } = script;
                  if (async) {
                      return {
                          src,
                          async: true,
                          content: new Promise((resolve, reject) => requestIdleCallback(() => fetchScript(src).then(resolve, reject))),
                      };
                  }
    
                  return fetchScript(src);
              }
          },
      ));
    }
    
    // import-html-entry\lib\utils.js
    
    function getInlineCode(match) {
    var start = match.indexOf('>') + 1;
    var end = match.lastIndexOf('<');
    return match.substring(start, end);
    }

    独立运行时 —— 沙箱

    沙箱 的目的是 为了隔离子应用间 脚本样式 的影响,即需要针对子应用的