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);
}


目录
相关文章
|
22天前
|
人工智能 前端开发 JavaScript
前端架构思考 :专注于多框架的并存可能并不是唯一的方向 — 探讨大模型时代前端的分层式微前端架构
随着前端技术的发展,微前端架构成为应对复杂大型应用的流行方案,允许多个团队使用不同技术栈并将其模块化集成。然而,这种设计在高交互性需求的应用中存在局限,如音视频处理、AI集成等。本文探讨了传统微前端架构的不足,并提出了一种新的分层式微前端架构,通过展示层与业务层的分离及基于功能的横向拆分,以更好地适应现代前端需求。
|
16天前
|
前端开发 API UED
深入理解微前端架构:构建灵活、高效的前端应用
【10月更文挑战第23天】微前端架构是一种将前端应用分解为多个小型、独立、可复用的服务的方法。每个服务独立开发和部署,但共同提供一致的用户体验。本文探讨了微前端架构的核心概念、优势及实施方法,包括定义服务边界、建立通信机制、共享UI组件库和版本控制等。通过实际案例和职业心得,帮助读者更好地理解和应用微前端架构。
|
22天前
|
前端开发 API UED
拥抱微前端架构:构建灵活、高效的前端应用
【10月更文挑战第17天】微前端架构是一种将前端应用拆分为多个小型、独立、可复用的服务的方法,每个服务可以独立开发、部署和维护。本文介绍了微前端架构的核心概念、优势及实施步骤,并分享了业界应用案例和职业心得,帮助读者理解和应用这一新兴架构模式。
|
27天前
|
存储 监控 前端开发
掌握微前端架构:构建未来前端应用的基石
【10月更文挑战第12天】随着前端技术的发展,传统的单体应用架构已无法满足现代应用的需求。微前端架构通过将大型应用拆分为独立的小模块,提供了更高的灵活性、可维护性和快速迭代能力。本文介绍了微前端架构的概念、核心优势及实施步骤,并探讨了其在复杂应用中的应用及实战技巧。
|
1月前
|
存储 监控 前端开发
掌握微前端架构:构建可扩展的前端应用
【10月更文挑战第6天】随着前端应用复杂性的增加,传统单体架构已难以满足需求。微前端架构通过将应用拆分为独立模块,提升了灵活性与可维护性。本文介绍微前端的概念、优势及实施步骤,包括定义边界、创建共享UI库、设置通信机制等,并探讨其在SPA扩展、大型项目模块化及遗留系统现代化中的应用。通过实战技巧如版本控制、配置管理和监控日志,帮助团队高效协作,保持应用灵活性。微前端架构为构建大型前端应用提供有效解决方案,适合希望提升项目可扩展性的开发者参考。
|
1月前
|
编解码 前端开发 JavaScript
前端:Rem 及其转换原理
Rem是一种用于前端开发的相对字体大小单位,它基于根元素的字体尺寸来定义文本大小,有助于实现响应式布局和可维护性。Rem的转换原理是通过相对于HTML根元素的字体大小来设置子元素的字体大小,从而实现统一的比例调整,提高页面的适应性和灵活性。此方法简化了跨浏览器和设备的布局调整,增强了用户体验。
|
27天前
|
缓存 JavaScript 前端开发
拿下奇怪的前端报错(三):npm install卡住了一个钟- 从原理搞定安装的全链路问题
本文详细分析了 `npm install` 过程中可能出现的卡顿问题及解决方法,包括网络问题、Node.js 版本不兼容、缓存问题、权限问题、包冲突、过时的 npm 版本、系统资源不足和脚本问题等,并提供了相应的解决策略。同时,还介绍了开启全部日志、使用替代工具和使用 Docker 提供 Node 环境等其他处理方法。
346 0
|
2月前
|
前端开发 JavaScript 架构师
了解微前端,深入前端架构的前世今生
该文章深入探讨了微前端架构的起源、发展及其解决的问题,并详细讲解了微前端在现代Web应用中的实现方式与优势,帮助读者理解微前端的设计理念和技术细节。
|
28天前
|
存储 安全 前端开发
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
124 0
|
2月前
|
移动开发 前端开发 JavaScript
浅谈前端路由原理hash和history
该文章详细解析了前端路由的两种模式——Hash模式与History模式的工作原理及其实现方式,并通过实例代码展示了如何在实际项目中运用这两种路由模式。