深入微前端(一):single-spa入门、实战、手写实现与分析

322 阅读14分钟

微前端作为一种前端架构模式,因其模块化、灵活性和可维护性而日益受到前端开发者的关注。本文是微前端演进系列的第一篇,我们将从微前端的基本实现原理出发,讲解single-spa的使用方法,并通过手写一个简化版的single-spa来加深理解

接下来,我们将分析single-spa在实际应用中的局限性,并引出现代微前端解决方案——qiankun

关于qiankun的源码分析以及在实际应用中可能遇到的挑战和解决方案,我们将保留至系列的第二篇文章中进行详细讨论

微前端关键思路

微前端的核心目标是将巨石应用拆解为多个可以独立运行、松耦合的子应用,它的关键在于如何实现这些子应用之间的有效分工与合作

应用之间的分工

主应用(基座应用) :作为微前端架构的骨架,主应用负责全局路由管理、子应用的注册与加载,以及协调应用之间的交互

子应用:每个子应用是一个独立的单页应用(SPA),通过暴露一系列生命周期钩子(如bootstrap初始化、mount挂载、unmount卸载)来与主应用进行集成,这些钩子定义了子应用在微前端环境中的生命周期行为

应用之间的合作

  1. 注册子应用:在主应用中注册子应用,包括子应用的入口URL、挂载点(DOM元素)、对应的生命周期钩子
  2. 路由监听与匹配
  • 路由匹配:当用户访问的URL与子应用的入口URL相匹配时,主应用将加载子应用

    • 初始化:首次加载时,主应用会调用子应用的bootstrap方法进行初始化
    • 挂载:随后,调用mount方法将子应用的DOM元素挂载到主应用中
  • 路由不匹配:当URL不再与子应用的入口URL匹配时,主应用将调用子应用提供的unmount方法,卸载子应用,从而释放资源

single-spa 实战应用

在简单了解了微前端的实现思路后,我们先使用 single-spa 来搭建一个微前端应用,然后我们再进一步深入了解其原理

初始化子应用

1. 新建项目

使用 create-react-app 构建我们的子应用

npx create-react-app child-app1
cd child-app1
npm start

2. 提供生命周期方法

import ReactDOMClient from "react-dom/client";
import App from "./App";
import React from "react";

let root: ReactDOMClient.Root | null = null;

function render(props?: any) {
  const { container } = props || {};
  root = container
    ? ReactDOMClient.createRoot(container)
    : ReactDOMClient.createRoot(document.getElementById("root") as HTMLElement);
  root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
}

/* @ts-ignore */
if (!window.singleSpaNavigate) {
  render({});
}

export async function bootstrap() {
  console.log("[react16] react app bootstraped");
  // 这里可以做一些初始化工作,比如配置全局变量等
  return Promise.resolve();
}
export async function mount(props: any) {
  console.log("调式输出:app mount", props);
  render(props);
}
export async function unmount() {
  console.log("调式输出:app unmount");
  root?.unmount();
}

另一种可选方案:我们也可以使用 single-spa-react 插件,该插件是一个用于将 React 应用集成到微前端架构中的库,它为我们提供了动态挂载和卸载React应用的能力

import ReactDOMClient from "react-dom/client";
import App from "./App";
import React from "react";
import singleSpaReact from "single-spa-react";

const reactLifecycles = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: () => (
    <React.StrictMode>
      <App />
    </React.StrictMode>
  ),
  // 可以指定要挂载的元素,如果没有指定,则会默认在 body 中新建一个div元素
  // domElementGetter: () => {
  //   // 返回要挂载的DOM元素
  //   const element = document.getElementById("sub_container");
  //   if (!element) {
  //     throw new Error(
  //       "Could not find the app container element with id=sub_container"
  //     );
  //   }
  //   return element;
  // },
  errorBoundary(err, info, props) {
    return <div>This renders when a catastrophic error occurs</div>;
  },
});

export async function bootstrap(props: any) {
  console.log("app1 bootstrap");
  return reactLifecycles.bootstrap(props);
}

export async function mount(props: any) {
  console.log("app1 mount");
  return reactLifecycles.mount(props);
}

export async function unmount(props: any) {
  console.log("app1 unmount");
  return reactLifecycles.unmount(props);
}

3. 打包成 JS 文件

为了让父应用能够加载子应用,需要将子应用打包成 js 文件供父应用使用

在 webpack 的配置文件中加上打包配置:在根目录新增config-overrides.js文件,调整webpack的打包策略(create-react-app支持通过在根目录中使用config-overrides.js来覆盖webpack配置)

const { merge } = require("webpack-merge");

// 为了让 single-spa 的主应用能正确识别子应用暴露出来的一些信息,需要以下配置信息
const customConfig = {
  output: {
    // 打包的 lib 名称 
    library: "singleReact",
    // 指定模块类型  umd 会把打包后那三个属性挂在 window 上
    // 比如 window.bootstrap / window.mount / window.unmount
    libraryTarget: "umd",
  },
};

module.exports = function override(config, env) {
  return merge(config, customConfig);
};

配置后,运行项目就可以看到 bundle.js 中已经将暴露出的生命周期函数挂在了 window.singleReact 下了

初始化父应用

1. 新建项目

使用 create-react-app 构建我们的父应用

npx create-react-app mainApp
cd mainApp
npm start

2. 安装 single-spa

npm i single-spa

3. 添加路由配置

设置其中一个路由作为子应用入口

import { BrowserRouter, Link, Route, Routes } from "react-router-dom";

function App() {
  return (
    <div>
      <BrowserRouter>
        <Link to="/">Home</Link> | <Link to="/about">react子应用</Link>
        
        <Routes>
          <Route path="/" element={<h3>React Home Page</h3>} />
        </Routes>
      </BrowserRouter>
      {/* 可用于指定子应用挂载的节点 */}
       <div id="sub_container" />
    </div>
  );
}

export default App;

4. 注册子应用信息

使用single-spa注册和开始子应用

import ReactDOM from "react-dom/client";
import App from "./App";
import React from "react";
// 固定导出两个方法  注册应用 / 开始应用
import { registerApplication, start } from "single-spa";

// 加载子应用的 js 脚本
async function loadScript(url: string) {
  // js加载是异步的 所以要用promise
  return new Promise((resole, reject) => {
    let script = document.createElement("script");
    script.src = url;
    script.onload = resole; // 加载成功
    script.onerror = reject; //加载失败
    document.head.appendChild(script); //把script放在html的head标签里
  });
}

// 注册应用  
registerApplication(
  // 参数1 注册一个名字  
  "myReactApp",
  // 参数2 一定要是个promise函数,用于获取子应用暴露的生命周期函数
  async () => {
    // 动态创建script标签 把这个模块引入进来
    await loadScript("http://localhost:8091/static/js/bundle.js");
    // 这样就可以导出window上的lib包了  'singleReact'就是上面子应用配置的包名
    return (window as any).singleReact; //bootstrap mount unmount
  },
  // 参数3 路径匹配: 用户切换到/about路径下 需要加载刚刚定义的子应用
  (location) => location.pathname.startsWith("/about")
);

// 开启应用
start();

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

到这步为止,子应用内容已经能够出现在父应用项目中了;运行父应用,点击路由进行跳转,页面展示出对应子应用内容,如下图所示:

联调与优化

  1. 处理子应用路由跳转问题

虽然子应用的内容已经展示在了页面上,但是点击子应用的路由时,跳转会出错(本来应该展示子应用中menu1下的内容,但什么都没有展示)

我们的预期是从 /about => /about/menu1,但当前路由跳转是从 /about -> /menu1,这可以通过给子应用配置基础路径来实现 —— 在 BrowserRouter 中配置 basename/about

import { BrowserRouter, Link, Route, Routes } from "react-router-dom";

function App() {
  return (
    <div>
      <BrowserRouter basename={"/about"}>
        <Link to="/menu1">menu1</Link> | <Link to="/menu2">menu2</Link>
        <Routes>
          <Route path="/" element={<h3>子应用1</h3>} />
          <Route path="/menu1" element={<h3>React 子菜单1 Page</h3>} />
          <Route path="/menu2" element={<h3>React 子菜单2 Page</h3>} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;
  1. 支持子应用的独立运行

single-spa 提供了一个全局函数 singleSpaNavigate,可以使用这个全局变量来区分当前是否运行在 single-spa 的主应用的上下文中;我们可以在 index.tsx 中增加对当前子应用环境的判断,如果是独立运行,就直接进行渲染

/* @ts-ignore */
if (!window.singleSpaNavigate) {
  // 子应用独立运行时
  root = ReactDOMClient.createRoot(document.getElementById("root") as HTMLElement);
  root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
}

子应用路由的基础路径也要做下对应调整

  <BrowserRouter
-   basename={"/about"}
+   basename={(window as any).singleSpaNavigate ? "/about" : "/"}
      >

到目前为止我们已经成功实现了一个微前端项目,不过这还只是个开始,接下来我们要吃透 single-spa 的原理,探究它究竟是如何实现不同应用之间的切换与加载的

single-spa 原理解析

single-spa的原理用一句话概括就是:通过路由拦截实现子应用间的切换

监听 hashchangepopstate

当页面路由发生变化时,hashchangepopstate 这两个原生事件会被触发。single-spa 通过监听这两个事件来感知路由的变化。一旦检测到变化,single-spa 会根据当前的 URL 判断每个子应用应该处于的状态。例如:

  • 对于需要挂载的子应用,single-spa 会调用该子应用暴露的 mount 函数,将其内容加载到页面上

  • 对于需要卸载的子应用,single-spa 会调用该子应用暴露的 unmount 函数,以从页面中移除其内容

对应代码位置:

监听路由变化的逻辑 :src/navigation/navigation-events.js

判断子应用状态的逻辑:src/applications/apps.js

调用子应用生命周期钩子的逻辑: src/lifecycles

劫持pushStatereplaceState

single-spa 通过劫持 pushStatereplaceState 方法,增加了一个判断URL是否实际变化的逻辑。这样做是为了处理一些第三方插件(如scroll-restorer)在页面滚动时调用这些方法,将滚动位置记录在路由的state中,而页面的URL并未发生改变。在这种情况下,single-spa 不应该触发应用的重载,但因为这种操作会触发 hashchange 事件,根据之前的逻辑,可能会引起意外的重载

为了解决这个问题,single-spa 引入了 urlRerouteOnly 参数,允许开发者指定是否仅在URL值发生变化时才监听并重载应用。

对应的代码逻辑如下:

  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    originalReplaceState,
    "replaceState"
  );
  
  /**
 * 通过装饰器模式,增强 pushstate 和 replacestate 方法,
 * 除了执行原生操作历史记录的行为外,还增加了对 URL 变化的判断
 * @param {*} updateState window.history.pushstate/replacestate
 * @param {*} methodName 'pushstate' or 'replacestate'
 */
function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    // 执行原生操作历史记录的行为
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;

    // 增加了对 URL 变化的判断    
    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      // fire an artificial popstate event so that
      // single-spa applications know about routing that
      // occurs in a different application
      window.dispatchEvent(
        createPopStateEvent(window.history.state, methodName)
      );
    }

    return result;
  };
}

通过这种方式,除非URL确实发生了变化,否则调用 pushStatereplaceState 不会导致应用重载。

总结来说,single-spa的整体思路是通过 ① 劫持路由和 ② 用户提供的生命周期函数 进行应用的状态管理,本质是一个状态机

以下是我绘制的整体流程,说明了single-spa是如何 加载子应用 和 维护子应用状态 的

image.png

流程图并不难理解,接下来我们就要亲自动手实现一个简易版的single-spa,进一步加深我们的理解

single-spa 手写实现

实现核心思路

  1. 管理子应用的状态和生命周期

lifeCycles.ts

/**
 * 子应用状态和生命周期管理
 */

// App status
export enum STATUS {
  NOT_LOADED = "NOT_LOADED",
  LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE",
  NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED",
  BOOTSTRAPPING = "BOOTSTRAPPING",
  NOT_MOUNTED = "NOT_MOUNTED",
  MOUNTING = "MOUNTING",
  MOUNTED = "MOUNTED",
  UPDATING = "UPDATING",
  UNMOUNTING = "UNMOUNTING",
  UNLOADING = "UNLOADING",
  LOAD_ERROR = "LOAD_ERROR",
  SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN",
}

type LifeCycleFn = (config: any) => Promise<any>;

// 子应用导出的生命周期函数
export type LifeCycles = {
  bootstrap: LifeCycleFn;
  mount: LifeCycleFn;
  unmount: LifeCycleFn;
  update?: LifeCycleFn;
};

type ActivityFn = (location: Location) => boolean;

interface customProps {
  [str: string]: any;
  [num: number]: any;
}

// 定义了子应用可传递的数据类型
export type IChildApp = {
  name: string; // 子应用名称
  app: LifeCycles; // 子应用导出的生命周期函数
  status?: STATUS; // 当前状态
  activeWhen: ActivityFn; // 激活路由
  customProps?: customProps; // 自定义属性
  bootstrap?: LifeCycleFn;
  mount?: LifeCycleFn;
  unmount?: LifeCycleFn;
  update?: LifeCycleFn;
};

// 注册子应用
export const toLoadPromise = (app: IChildApp) => {
  return Promise.resolve()
    .then(() => {
      if (app.status !== STATUS.NOT_LOADED) {
        return;
      }
      // 修改应用状态
      app.status = STATUS.LOADING_SOURCE_CODE;
      // 校验参数
      // 略
      app.status = STATUS.NOT_BOOTSTRAPPED;
      // 子应用传递的生命周期函数
      const res = app.app;
      // 应用初始化,给应用赋值
      app.bootstrap = res.bootstrap;
      app.mount = res.mount;
      app.unmount = res.unmount;
      return app;
    })
    .catch((err) => {
      console.log(err);
      app.status = STATUS.SKIP_BECAUSE_BROKEN;
      return app;
    });
};

// 初始化子应用
export const toBootstrapPromise = (app: IChildApp) => {
  return Promise.resolve().then(() => {
    if (app.status !== STATUS.NOT_BOOTSTRAPPED) {
      return app;
    }
    app.status = STATUS.BOOTSTRAPPING;
    return app.bootstrap!(app.customProps)
      .then(() => {
        app.status = STATUS.NOT_MOUNTED;
        return app;
      })
      .catch((err) => {
        console.log(err);
        app.status = STATUS.SKIP_BECAUSE_BROKEN;
        return app;
      });
  });
};

// 挂载子应用
export const toMountPromise = (app: IChildApp) => {
  return Promise.resolve().then(() => {
    if (app.status !== STATUS.NOT_MOUNTED) {
      return;
    }
    app.status = STATUS.MOUNTING;
    return app.mount!(app.customProps)
      .then(() => {
        app.status = STATUS.MOUNTED;
        return app;
      })
      .catch((err) => {
        console.log(err);
        app.status = STATUS.SKIP_BECAUSE_BROKEN;
        return app;
      });
  });
};

// 卸载子应用
export const toUnmountPromise = (app: IChildApp) => {
  return Promise.resolve().then(() => {
    if (app.status !== STATUS.MOUNTED) {
      return;
    }
    app.status = STATUS.UNMOUNTING;
    return app.unmount!(app.customProps)
      .then(() => {
        app.status = STATUS.NOT_MOUNTED;
        return app;
      })
      .catch((err) => {
        console.log(err);
        app.status = STATUS.SKIP_BECAUSE_BROKEN;
        return app;
      });
  });
};
  1. 存储所有子应用信息

applications.ts

/**
 * apps 管理
 */

import { IChildApp, STATUS } from "./lifeCycles";
import { reroute, shouldBeActive } from "./sample-single-spa";

// 全局变量 apps,存储 app
const apps: IChildApp[] = [];

// 判断应用的下一步状态
export const getAppChanges = () => {
  const // appsToUnload:IChildApp[] = [],
    appsToUnmount: IChildApp[] = [],
    appsToLoad: IChildApp[] = [],
    appsToMount: IChildApp[] = [];
  apps.forEach((app) => {
    const appShouldBeActive = shouldBeActive(app);
    switch (app.status) {
      case STATUS.NOT_LOADED:
      case STATUS.LOADING_SOURCE_CODE:
        appsToLoad.push(app);
        break;
      case STATUS.NOT_BOOTSTRAPPED:
      case STATUS.NOT_MOUNTED:
        if (appShouldBeActive) {
          appsToMount.push(app);
        }
        // else{
        //     appsToUnload.push(app);
        // }
        break;
      case STATUS.MOUNTED:
        !appShouldBeActive && appsToUnmount.push(app);
    }
  });
  return { appsToUnmount, appsToLoad, appsToMount };
};

// 注册子应用
export const registerApplication = (childAppList: IChildApp[]) => {
  apps.push(
    ...childAppList.map((app) => {
      return { ...app, status: STATUS.NOT_LOADED };
    })
  );
  reroute();
};
  1. 监听路由、更改应用状态、调用生命周期函数(核心)

sample-single-spa.ts

/**
 * 简易 single-spa 框架
 *
 */

import { getAppChanges } from "./applications";
import {
  IChildApp,
  toBootstrapPromise,
  toLoadPromise,
  toMountPromise,
  toUnmountPromise,
} from "./lifeCycles";
import { isStarted } from "./start";

// 更改应用状态、调用生命周期函数
export const reroute = () => {
  const { appsToUnmount, appsToLoad, appsToMount } = getAppChanges();
  if (!isStarted()) {
    // 未启动时只加载应用
    loadApps(appsToLoad);
  } else {
    performAppChanges({ appsToUnmount, appsToLoad, appsToMount });
  }
};

// 加载应用
const loadApps = (appsToLoad: IChildApp[]) => {
  appsToLoad.map(toLoadPromise);
};

// 触发自定义事件,并更改应用状态
const performAppChanges = ({
  appsToUnmount,
  appsToLoad,
  appsToMount,
}: {
  appsToUnmount: IChildApp[];
  appsToLoad: IChildApp[];
  appsToMount: IChildApp[];
}) => {
  // unmount
  appsToUnmount.map(toUnmountPromise);
  // load And Mount
  appsToLoad.map(tryToBootstrapAndMount);

  // Mount
  // 在上一步已挂载的不用再挂载了
  appsToMount
    .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
    .map(tryToBootstrapAndMount);
};

// 判断当前应用是否应该挂载
export const shouldBeActive = (app: IChildApp) => {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    console.error("shouldBeActive function error", err);
    return false;
  }
};

// 尝试初始化和挂载--这里会进行两次判断
export const tryToBootstrapAndMount = (app: IChildApp) => {
  if (shouldBeActive(app)) {
    // 初始化
    toBootstrapPromise(app).then((app) =>
      // 挂载
      shouldBeActive(app) ? toMountPromise(app) : app
    );
  } else {
    toUnmountPromise(app);
  }
};

// 增加全局函数
(window as any).singleSpaNavigate = true;

// 添加路由监听事件
window.addEventListener("hashchange", reroute);
window.history.pushState = patchedUpdateState(window.history.pushState);
window.history.replaceState = patchedUpdateState(window.history.replaceState);

/**
 * 装饰器,增强 pushState 和 replaceState 方法
 * @param {*} updateState
 */
function patchedUpdateState(updateState: any) {
  return function () {
    // 当前url
    const urlBefore = window.location.href;
    // pushState or replaceState 的执行结果
    const result = updateState.apply(window.history, arguments);
    // 执行updateState之后的url
    const urlAfter = window.location.href;
    if (urlBefore !== urlAfter) {
      reroute();
    }
    return result;
  };
}
  1. 启动应用

start.ts

/**
 * 启动
 */

import { reroute } from "./sample-single-spa";

let started = false;

export const start = () => {
  started = true;
  reroute();
};

export const isStarted = () => {
  return started;
};

验证实现效果

使用我们自己写的 single-spa 来配置下父应用

import ReactDOM from "react-dom/client";
import App from "./App";
import React from "react";
// 以下引入的是自己实现的 single-spa
import { registerApplication } from "./config/applications";
import { start } from "./config/start";
import { IChildApp } from "./config/lifeCycles";

// 远程加载子应用;
async function loadScript(url: string) {
  // js加载是异步的 所以要用 promise
  return new Promise((resole, reject) => {
    let script = document.createElement("script");
    script.src = url;
    script.onload = resole; // 加载成功
    script.onerror = reject; //加载失败
    document.head.appendChild(script); //把script放在html的head标签里
  });
}

// 记载函数,返回一个 promise
function loadApp(url: string, globalVar: string, jsPath?: string) {
  // 支持远程加载子应用
  return async () => {
    await loadScript(url + (jsPath || "/static/js/bundle.js"));
    // await createScript(url + "/js/app.js");
    // 这里的return很重要,需要从这个全局对象中拿到子应用暴露出来的生命周期函数
    return (window as any)[globalVar];
  };
}

const loadChild = async () => {
  const result = await loadApp("http://localhost:8091", "singleReact")();
  const result2 = await loadApp("http://localhost:8092", "singleReact2")();

  const leftMenuList: IChildApp[] = [
    {
      name: "myReactApp",
      activeWhen: (location) => location.pathname.startsWith("/about"),
      app: result,
      customProps: {
      // 要挂载的容器节点
        container: document.getElementById("sub_container") as HTMLElement,
      },
    },
    {
      name: "myReactApp2",
      activeWhen: (location) => location.pathname.startsWith("/child2"),
      app: result2,
      customProps: {
       // 要挂载的容器节点
        container: document.getElementById("sub_container") as HTMLElement,
      },
    },
  ];

  registerApplication(leftMenuList);
  // 启动子应用
  start();
};

loadChild();

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

现在可以开始测试下我们的single-spa到底能否按预期运行

  • 首次点击子应用路由,子应用进行初始化和挂载

  • 点击其他子应用,原子应用卸载、新子应用挂载(如果还未初始化,则先进行初始化)

  • 跳转到已经初始化过的子应用,子应用不再进行初始化

  • 跳转到其他路由

因为子应用是通过 activeWhencontainer 来确定挂载时机和挂载节点的,所以我们也完全可以在同一个页面里挂载多个子应用

  const loadChild = async () => {
      const result = await loadApp("http://localhost:8091", "singleReact")();
      const result2 = await loadApp("http://localhost:8092", "singleReact2")();

      const leftMenuList: IChildApp[] = [
        {
          name: "myReactApp",
+          activeWhen: (location) => location.pathname.startsWith("/about"),
          app: result,
          customProps: {
+            container: document.getElementById("sub_container") as HTMLElement,
          },
        },
        {
          name: "myReactApp2",
+          activeWhen: (location) => location.pathname.startsWith("/about"),
          app: result2,
          customProps: {
+            container: document.getElementById("sub_container2") as HTMLElement,
          },
        },
      ];

      registerApplication(leftMenuList);
      // 启动子应用
      start();
    };

到此,我们已经成功实现了一个简易版的single-spa

理解了singl-spa的核心思路,也就理解了微前端的核心,single-spa为我们提供了子应用加载和切换机制,然而,它在应用隔离和通信方面的解决方案并不完善,同时它自身的一些实现方式也导致了它的局限

single-spa 局限说明

对子应用的侵入性太强

single-spa 采用 JS Entry 的方式接入子应用,这就要求整个子应用要打包成一个 JS 文件,常见的打包优化基本上都没了,比如:按需加载、首屏资源加载优化、css 独立打包等优化措施。

样式隔离问题和 JS 隔离

全局样式和全局对象(window)的污染

资源预加载 和 应用间通信

子应用已经被整个打包成 js 文件,所以做不到让浏览器在后台悄悄的加载其它子应用的静态资源

正是因为有以上这些问题,所以实际开发中我们一般不会直接使用 single-spa,而是采用更成熟完善的微前端解决方案:qiankun(乾坤);在下一篇,我们会详细展开对 qiankun 的介绍和源码分析,以及在实际应用中可能遇到的挑战和解决方案。

参考文章

  1. SingleSpa微前端基本使用以及原理 - CSDN
  2. 微前端框架 之 single-spa 从入门到精通 - 稀土掘金
  3. SingleSpa及qiankun入门、源码分析及案例 - CSDN