微前端作为一种前端架构模式,因其模块化、灵活性和可维护性而日益受到前端开发者的关注。本文是微前端演进系列的第一篇,我们将从微前端的基本实现原理出发,讲解single-spa
的使用方法,并通过手写一个简化版的single-spa
来加深理解
接下来,我们将分析single-spa
在实际应用中的局限性,并引出现代微前端解决方案——qiankun
关于qiankun
的源码分析以及在实际应用中可能遇到的挑战和解决方案,我们将保留至系列的第二篇文章中进行详细讨论
微前端关键思路
微前端的核心目标是将巨石应用拆解为多个可以独立运行、松耦合的子应用,它的关键在于如何实现这些子应用之间的有效分工与合作
应用之间的分工
主应用(基座应用) :作为微前端架构的骨架,主应用负责全局路由管理、子应用的注册与加载,以及协调应用之间的交互
子应用:每个子应用是一个独立的单页应用(SPA),通过暴露一系列生命周期钩子(如bootstrap
初始化、mount
挂载、unmount
卸载)来与主应用进行集成,这些钩子定义了子应用在微前端环境中的生命周期行为
应用之间的合作
- 注册子应用:在主应用中注册子应用,包括子应用的入口URL、挂载点(DOM元素)、对应的生命周期钩子
- 路由监听与匹配:
-
路由匹配:当用户访问的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>
);
到这步为止,子应用内容已经能够出现在父应用项目中了;运行父应用,点击路由进行跳转,页面展示出对应子应用内容,如下图所示:
联调与优化
-
处理子应用路由跳转问题
虽然子应用的内容已经展示在了页面上,但是点击子应用的路由时,跳转会出错(本来应该展示子应用中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;
-
支持子应用的独立运行
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的原理用一句话概括就是:通过路由拦截实现子应用间的切换
监听 hashchange
和 popstate
当页面路由发生变化时,hashchange
或 popstate
这两个原生事件会被触发。single-spa 通过监听这两个事件来感知路由的变化。一旦检测到变化,single-spa
会根据当前的 URL
判断每个子应用应该处于的状态。例如:
-
对于需要挂载的子应用,
single-spa
会调用该子应用暴露的mount
函数,将其内容加载到页面上 -
对于需要卸载的子应用,
single-spa
会调用该子应用暴露的unmount
函数,以从页面中移除其内容
对应代码位置:
监听路由变化的逻辑 :
src/navigation/navigation-events.js
判断子应用状态的逻辑:
src/applications/apps.js
调用子应用生命周期钩子的逻辑:
src/lifecycles
劫持pushState
和 replaceState
single-spa
通过劫持 pushState
和 replaceState
方法,增加了一个判断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
确实发生了变化,否则调用 pushState
或 replaceState
不会导致应用重载。
总结来说,single-spa
的整体思路是通过 ① 劫持路由和 ② 用户提供的生命周期函数 进行应用的状态管理,本质是一个状态机
以下是我绘制的整体流程,说明了single-spa
是如何 加载子应用 和 维护子应用状态 的
流程图并不难理解,接下来我们就要亲自动手实现一个简易版的single-spa
,进一步加深我们的理解
single-spa 手写实现
实现核心思路
-
管理子应用的状态和生命周期
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;
});
});
};
-
存储所有子应用信息
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();
};
-
监听路由、更改应用状态、调用生命周期函数(核心)
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;
};
}
-
启动应用
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
到底能否按预期运行
- 首次点击子应用路由,子应用进行初始化和挂载
- 点击其他子应用,原子应用卸载、新子应用挂载(如果还未初始化,则先进行初始化)
- 跳转到已经初始化过的子应用,子应用不再进行初始化
- 跳转到其他路由
因为子应用是通过 activeWhen
和 container
来确定挂载时机和挂载节点的,所以我们也完全可以在同一个页面里挂载多个子应用
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
的介绍和源码分析,以及在实际应用中可能遇到的挑战和解决方案。
参考文章