三、微前端 - single-spa 技术原理深度解析 🔬

259 阅读9分钟

微前端技术原理深度解析 🔬

3.1 微前端架构概述

微前端架构的核心在于如何将多个独立的前端应用组合成一个统一的应用。这涉及到路由分发、应用隔离、生命周期管理、通信机制等多个方面。

3.2 Single-SPA 框架实践

3.2.1 主应用(基座)搭建

使用 Single-SPA CLI 工具初始化项目

直接使用 single-spa 的 cli 工具来初始化一个项目

  • 创建基座应用

root-config.png

2.init.png 直接运行后看到 welcome 页面,此时基座应用就初始化好了

welcome.png 打开项目看看都做了什么

ejs.png

这里有很多 type="systemjs-importmap" 的 script 标签,是因为 single-spa 是基于 systemjs 的,所以需要先加载 systemjs 的资源,然后再加载子应用的资源。 如果想进一步了解 systemjs 的规范,可以看我之前的文章 前端模块化之 SystemJS 规范使用和原理解析

主应用核心功能

主应用主要做了以下几件事情:

  • 引入 single-spa 的资源
  • 引入 systemjs 的资源
  • 注册子应用 welcome 的资源(single-spa 自带应用)
  • 启动应用
<script type="systemjs-importmap">
  {
    "imports": {
      "@bean/root-config": "//localhost:9000/bean-root-config.js"
    }
  }
</script>

System.import("@bean/root-config");

@bean/root-config 是主应用的配置文件,对应的是我们的 src/bean-root-config.js 文件

start.png

核心 API 说明

  • registerApplication() 注册子应用:检查路径是否匹配,如果匹配则加载对应的应用
  • start() 启动应用:开启路径监控,路径切换时调用对应的应用

3.2.2 子应用接入

React 子应用接入

jw-react.png

Vue 子应用接入

Vue 子应用和 React 子应用的初始化方式一样

子应用接入协议

接入 single-spa 的子应用需要暴露 bootstrap、mount、unmount 三个钩子函数

bean-react.png

子应用注册配置

主应用使用 registerApplication 注册子应用:

registerApplication({
  name: "@bean/react", // 不重名即可
  app: () => System.import("@bean/react"),
  activeWhen: (location) => location.pathname.startsWith("/react"),
});

Import Maps 配置

直接运行 react 子应用会有下面这样的提示:

importmaps-react.png

需要把这个链接配置到主应用的 importmaps 的配置文件中:

<script type="systemjs-importmap">
   {
     "imports": {
       "@bean/root-config": "//localhost:9000/bean-root-config.js",
       "@bean/react": "//localhost:3000/bean-react.js"
     }
   }
 </script>

当路由匹配到 /react 的时候,会加载 @bean/react 的资源,然后会调用 bean-react.js 文件中的 bootstrap、mount、unmount 三个钩子函数。

Vue 项目特殊配置

Vue 项目的初始化和 React 项目一样,直接使用 cli 工具就可以了,但是需要注意的是需要额外修改一下 vue.config.js 文件:

module.exports = {
  publicPath: "http://localhost:4000/", // 不配置路由切换的时候,不能加载正确的 js 文件
  chainWebpack: (config) => {
    if (config.plugins.has("SystemJSPublicPathWebpackPlugin")) {
      config.plugins.delete("SystemJSPublicPathWebpackPlugin"); // 删除这个插件,否则会报错 https://github.com/single-spa/single-spa/issues/1010
    }
  },
};

3.3 Single-SPA 核心原理实现

3.3.1 基础 Demo 演示

下面写个简单 demo 实现一下 single-spa 的主要功能:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <a href="#/a">a应用</a>
    <a href="#/b">b应用</a>
    <script src="./single-spa.min.js"></script>
    <script type="module">
      let { registerApplication, start } = singleSpa;

      // 接入应用,应用需要实现接入协议,然后通过 registerApplication 注册应用
      const app1 = {
        bootstrap: async (props) => console.log("app1 bootstrap", props),
        mount: async (props) => {
          return new Promise((resolve) => {
            setTimeout(() => {
              console.log("app1 mount resolve");
              resolve();
            }, 1000);
          });
        },
        unmount: async () => console.log("app1 unmount"),
      };
      const app2 = {
        bootstrap: async (props) => console.log("app2 bootstrap", props),
        mount: async (props) => console.log("app2 mount", props),
        unmount: async () => console.log("app2 unmount"),
      };

      // 所谓的注册应用 就是看一下路径是否匹配,如果匹配则‘加载’对应的应用
      registerApplication(
        "a",
        async () => app1,
        (location) => location.hash.startsWith("#/a"),
        { a: 1 }
      );

      registerApplication(
        "b",
        async () => app2,
        (location) => location.hash.startsWith("#/b"),
        { a: 2 }
      );

      // 开启路径的监控,路径切换的时候,可以调用对应的应用
      start();
    </script>
  </body>
</html>

以打印的方式模拟一下 single-spa 各生命周期的执行情况,当然你也可以在 mount 和 unmount 方法中挂载 DOM 和移除 DOM。

3.3.2 应用生命周期状态机

主要实现 single-spa 的三个 API:registerApplicationstartreroute。通过上面的 demo,我们已经知道 single-spa 是一个状态机,维护着应用的状态,它的主要工作是路由劫持和应用加载,主要分为四个过程:加载、激活启用、挂载、卸载销毁。

lifecycle.svg

应用注册模块

application/app.js

import { NOT_LOADED } from "./app.helpers.js";
import { reroute } from "../navigation/reroute.js";

export const apps = [];
export function registerApplication(appName, loadApp, activeWhen, customProps) {
  const registration = {
    name: appName,
    loadApp,
    activeWhen,
    customProps,
    status: NOT_LOADED,
  };

  apps.push(registration);
  // 我们需要给每个应用添加对应的状态变化
  // 未加载 -> 加载中 -> 未启动 -> 启动中 -> 未挂载 -> 挂载中 -> 挂载完成
  // 需要检查哪些应用要被加载,还有哪些应用要被卸载,还有哪些应用要被挂载
  reroute(); // 重写路由
}

路由重写模块

navigation/reroute.js

export function reroute(...args) {
  // 获取 app 对应的状态,进行分类
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();

  const loadApps = () => {
    // 加载应用
    Promise.all(appsToLoad.map(toLoadPromise));
  };
  loadApps();
}

应用状态管理模块

application/app.helpers.js

// 看一下这个应用是否正在被激活
export function isActive(app) {
  return app.status === MOUNTED; // 此应用正在被激活
}

// 看一下这个应用是否应该被激活
export function shouldBeActive(app) {
  // 根据路由判断应该加载哪个应用
  return app.activeWhen(window.location);
}

export function getAppChanges() {
  const appsToLoad = [];
  const appsToMount = [];
  const appsToUnmount = [];

  apps.forEach((app) => {
    const appShouldBeActive = shouldBeActive(app);
    switch (app.status) {
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        //1. 标记当前路径下,哪些应用要被加载
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;

      case NOT_BOOTSTRAPPED:
      case BOOTSTRAPPING:
      case NOT_MOUNTED:
        // 2. 标记当前路径下,哪些应用要被挂载
        if (appShouldBeActive) {
          appsToMount.push(app);
        }
        break;

      case MOUNTED:
        // 3. 标记当前路径下,哪些应用要被卸载
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;

      default:
        break;
    }
  });

  return {
    appsToLoad,
    appsToMount,
    appsToUnmount,
  };
}

3.3.3 应用生命周期管理

应用加载阶段

已分类的应用,针对应加载的应用进行加载:

lifecycles/load.js

import {
  LOADING_SOURCE_CODE,
  NOT_BOOTSTRAPPED,
  NOT_LOADED,
} from "../application/app.helpers.js";

const flattenArrayToPromise = (promiseArray) => {
  promiseArray = Array.isArray(promiseArray) ? promiseArray : [promiseArray];

  return function (props) {
    return promiseArray.reduce((prevPromise, nextPromise) => {
      return prevPromise.then(() => nextPromise(props));
    }, Promise.resolve());
  };
};

export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    // 1. 已经加载的应用直接返回,只加载还未加载的
    if (app.status !== NOT_LOADED) {
      return app;
    }
    // 2. 标记为加载中
    app.status = LOADING_SOURCE_CODE;
    return app.loadApp(app.customProps).then((registerApp) => {
      const { bootstrap, mount, unmount } = registerApp;
      // 3. 标记为未启动
      app.status = NOT_BOOTSTRAPPED;
      app.bootstrap = flattenArrayToPromise(bootstrap);
      app.mount = flattenArrayToPromise(mount);
      app.unmount = flattenArrayToPromise(unmount);
      // 5. 返回应用
      return app;
    });
  });
}

toload-promise.png

重要说明:每次注册都会加载应用,但是不会挂载应用。

应用启动和挂载

当执行 start 方法时,就会开启路径的监控,路径切换的时候,可以加载对应的应用:

const tryBootstrapAndMountPromise = (app, unmountAllPromises) => {
  if (shouldBeActive(app)) {
    // 保证卸载完毕后再挂载
    return toBootstrapPromise(app).then((app) =>
      unmountAllPromises.then(() => toMountPromise(app))
    );
  }
};

const performAppChange = () => {
  // 1. 将不需要的应用卸载
  const unmountAppPromises = Promise.all(appsToUnmount.map(toUnmountPromise));
  // 加载需要的应用 -> 启动对应的应用 -> 挂载对应的应用
  // 2. 加载需要的应用(可能这个应用在注册的时候已经被加载了)
  // 默认情况注册的时候 路径是 /a, 但是当我们 start 的时候应用是 /b

  // 还有未加载的应用
  const loadMountPromises = Promise.all(
    appsToLoad.map((app) =>
      toLoadPromise(app).then((app) => {
        // 当应用加载完毕后,我们需要启动和挂载应用, 但是要保证挂载前,先卸载掉之前的应用
        return tryBootstrapAndMountPromise(app, unmountAppPromises);
      })
    )
  );

  // 没有未加载的应用了
  const mountPromises = Promise.all(
    appsToMount.map((app) =>
      tryBootstrapAndMountPromise(app, unmountAppPromises)
    )
  );

  return Promise.all([loadMountPromises, mountPromises]).then(() => {
    console.log("加载和挂载应用完成");
  });
};

if (started) {
  // 开启的时候设置成 true
  appChangeUnderWay = true;
  // 用户调用了 start 方法, 我们需要处理当前应用要挂载或卸载
  performAppChange();
  return;
}

应用挂载和卸载

挂载和卸载应用的方法里除了改变应用的状态就是调用应用暴露的 mount 和 unmount 方法:

lifecycles/mount.js

import { NOT_MOUNTED, MOUNTED } from "../application/app.helpers.js";

export function toMountPromise(app) {
  return Promise.resolve().then(() => {
    // 1. 已经挂载的应用直接返回,只挂载还未挂载的
    if (app.status !== NOT_MOUNTED) {
      return app;
    }
    return app.mount(app.customProps).then(() => {
      // 2. 标记为已挂载
      app.status = MOUNTED;
      return app;
    });
  });
}

lifecycles/unmount.js

import {
  MOUNTED,
  NOT_MOUNTED,
  UNMOUNTING,
} from "../application/app.helpers.js";

export function toUnmountPromise(app) {
  return Promise.resolve().then(() => {
    // 1. 只卸载已经挂载的应用
    if (app.status !== MOUNTED) {
      return app;
    }
    // 2. 标记为卸载中
    app.status = UNMOUNTING;
    // 4. unmount, mount, bootstrap 可能是一个方法,也可能是一个数组, 已经提前统一处理成一个方法了
    return app.unmount(app.customProps).then(() => {
      // 3. 标记为未挂载
      app.status = NOT_MOUNTED;
      return app;
    });
  });
}

demo.gif

3.3.4 路由事件劫持机制

问题背景

另外还有一个问题,就是子应用中如果监听了 hashChange 或 popState 事件,当路径切换的时候,这个事件会在子应用挂载前触发,导致子应用无法接收到事件或直接就报错了。所以 single-spa 劫持了原生的路由系统,保证当应用挂载完成后,再触发应用里的 addEventListener。

事件劫持实现

navigation/navigation-event.js

import { reroute } from "./reroute.js";

// 对用户的路径切换进行劫持,劫持后重新调用 reroute, 进行计算应用的加载
const urlRoute = (...args) => {
  reroute(...args);
};

window.addEventListener("hashchange", urlRoute);

window.addEventListener("popstate", urlRoute); // 浏览器历史切换的时候会执行此方法

// 当路由切换的时候, 我们触发 single-spa 的 addEventListener, 应用中可能也包含了 addEventListener。 需要确保应用挂载完成后,再触发应用里的 addEventListener

// 需要劫持原生的路由系统, 保证当我们加载完成后再切换路由, 这里收集的事件需要在应用挂载完成后执行
const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};

const listeningTo = ["hashchange", "popstate"];
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;

window.addEventListener = (eventName, callback) => {
  // 有要监听的事件, 函数不能重复
  console.log("eventName", eventName);
  if (
    listeningTo.includes(eventName) &&
    !capturedEventListeners[eventName].includes(callback)
  ) {
    capturedEventListeners[eventName].push(callback);
    return;
  }
  originalAddEventListener(eventName, callback);
};

window.removeEventListener = (eventName, callback) => {
  if (
    listeningTo.includes(eventName) &&
    capturedEventListeners[eventName].includes(callback)
  ) {
    capturedEventListeners[eventName] = capturedEventListeners[
      eventName
    ].filter((listener) => listener !== callback);
    return;
  }
  originalRemoveEventListener(eventName, callback);
};

事件执行机制

收集的事件需要在应用挂载完成后执行:

navigation/navigation-event.js

export const callCapturedEventListeners = (...args) => {
  if (args?.[0]) {
    const { type } = args[0];
    console.log("type", type);
    if (listeningTo.includes(type)) {
      capturedEventListeners[type].forEach((listener) => {
        listener.apply(this, args);
      });
    }
  }
};

History API 劫持

针对 history 的 pushState 和 replaceState 方法,我们也需要劫持,保证当应用挂载完成后,再触发应用里的 addEventListener:

const patchedUpdateState = (updateState, methodName) => {
  return function (...args) {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, args);
    const urlAfter = window.location.href;
    if (urlBefore !== urlAfter) {
      // 即使浏览器在调用 replaceState 时默认不生成 popstate 事件,我们也需要一个 popstate 事件,以便所有应用程序能够重新路由
      window.dispatchEvent(
        new PopStateEvent("popstate", { state: window.history.state })
      );
    }
    return result;
  };
};

window.history.pushState = patchedUpdateState(
  window.history.pushState,
  "pushState"
);

window.history.replaceState = patchedUpdateState(
  window.history.replaceState,
  "replaceState"
);

3.3.5 并发控制优化

问题分析

到目前为止,我们基本实现了 single-spa 的主要功能,有个小瑕疵需要额外处理一下,就是 hashChange 和 popState 同时挂载的话,reroute 事件可能会多次执行。这个需要额外处理一下。

并发控制实现

navigation/reroute.js

let appChangeUnderWay = false;
let peopleWaitingOnAppChange = [];
// 后续路径变化,也需要走这里,重新计算哪些应用被加载或卸载
export function reroute(...args) {
  // 如果多次触发 reroute 方法,我们可以创造一个队列来屏蔽这个问题
  if (appChangeUnderWay) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({ resolve, reject, eventArguments: args });
    });
  }

  // 然后在挂载完成后,执行所有的队列。把之前直接执行 callCapturedEventListeners 方法的地方换成执行 callEventListener 方法

  const callEventListener = () => {
    appChangeUnderWay = false;

    callCapturedEventListeners(...args);
    peopleWaitingOnAppChange.forEach(({ resolve, reject, eventArguments }) => {
      callCapturedEventListeners(...eventArguments);
    });
    peopleWaitingOnAppChange = [];
  };
}

简版 single-spa 全部代码 github 地址

3.4 本章小结

本章深入探讨了微前端的技术原理,包括路由分发、应用隔离、生命周期管理、通信机制等核心概念。并通过实际代码演示了 Single-SPA 关键功能的实现,从基础使用到核心原理的完整实现,如果对你深入理解微前端架构的工作机制有帮助,可以一键三连支持一下。