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

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

image.png


前言

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

微前端核心原理

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

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

路由劫持

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

监听 hash 路由 和 history 路由

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

  • 当通过类似 window.location.href = xxx<a href="#xxx"></a> 的方式修改 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<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // 每个子应用自会被注册一次
  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<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  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) : "<style>/* ".concat(styleSrc, " */").concat(styleSheets[i], "</style>"));
      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);
}


目录
相关文章
|
25天前
|
Web App开发 前端开发 JavaScript
前端应用实现 image lazy loading 的原理介绍
前端应用实现 image lazy loading 的原理介绍
29 0
|
2月前
|
编解码 前端开发 开发者
现代前端开发中的响应式设计原理与实践
传统的网页设计通过固定的布局方式难以适应不同设备的屏幕尺寸,而响应式设计则能够使网页在各种终端上都能良好呈现。本文将深入探讨现代前端开发中响应式设计的原理和实践,帮助开发者更好地理解和应用响应式设计技术。
|
4月前
|
存储 JavaScript 前端开发
探索主流前端框架的响应式原理!
探索主流前端框架的响应式原理!
|
22天前
|
前端开发 数据可视化 JavaScript
探索前端可视化开发:低代码平台原理与实践
【4月更文挑战第7天】本文探讨了低代码平台在前端开发中的应用,介绍了其模型驱动、组件化和自动化部署的原理,强调了提升效率、降低技术门槛、灵活适应变更和保证一致性等优势。建议开发者明确适用场景,选择合适平台,并培养团队低代码技能,同时规划与现有技术栈的融合,实施持续优化治理。低代码平台正改变开发格局,为业务创新和数字化转型提供新途径。
46 0
|
3天前
|
JavaScript 前端开发 数据安全/隐私保护
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(二)
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(二)
|
3天前
|
JavaScript 前端开发 Python
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(一)
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(一)
|
6天前
|
移动开发 前端开发 应用服务中间件
前端——html拖拽原理
前端——html拖拽原理
13 0
|
7天前
|
前端开发 JavaScript
前端 富文本编辑器原理——从javascript、html、css开始入门(二)
前端 富文本编辑器原理——从javascript、html、css开始入门
19 0
前端 富文本编辑器原理——从javascript、html、css开始入门(二)
|
7天前
|
前端开发 JavaScript 索引
前端 富文本编辑器原理——从javascript、html、css开始入门(一)
前端 富文本编辑器原理——从javascript、html、css开始入门
19 0
|
3月前
|
前端开发 JavaScript API