single-spa 流程解析及简单实现
什么是 single-spa
官方描述:
Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。
实际上:
设计了生命周期的概念,并负责调度子应用的生命周期,生命周期钩子里如何实现(如 dom 挂载等)由开发者决定。
挟持 url 在 url 变化时(或直接手动挂载 parcel),匹配对应子应用,并执行生命周期流程。
虽然 single-spa 说自己是微前端框架,但是一个微前端的特性都没有实现,本质是子应用加载器 + 状态机。都是需要开发者在加载自己子 App 的时候实现的。框架里面维护了各个子应用的状态,以及在适当的时候负责更改子应用的状态、执行相应的生命周期函数。
一些概念
Root Config
指主应用的 index.html + main.js
HTML 负责声明资源路径,JS 负责注册子应用和启动主应用 Application:要暴露 bootstrap, mount, umount 三个生命周期,一般在 mount 开始渲染子 SPA 应用
Parcel:也要暴露 bootstrap, mount, unmount 三个生命周期,额外再暴露 update。Parcel 可大到一个 Application,也可以小到一个功能组件。与 Application 不同的是 Parcel 需要开发都手动调用生命周期。
SystemJS
可以在浏览器使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。与 webpack 的模块化功能重叠。
single-spa-layout
在 index.html 指定在哪里渲染哪个子应用。
single-spa-react single-spa-vue
给子应用快速生成 bootstrap, mount, unmount 的生命周期函数的工具库。
single-spa-css
隔离前后两个子应用的 CSS 样式。
single-spa-leaked-globals
在子应用 mount 时给 window 对象恢复/添加一些全局变量,如 jQuery 的 $ 或者 lodash 的 _
,在 unmount 时把 window 对象的变量删掉。
demo
index.html
<div class="navbar">
<ul>
<a onclick="singleSpaNavigate('/app1')">
<li>App 1</li>
</a>
<a onclick="singleSpaNavigate('/app2')">
<li>App 2</li>
</a>
</ul>
</div>
root-application.js
import * as singleSpa from 'single-spa';
const pathPrefix = (prefix) => (location) => location.pathname.startsWith(prefix);
singleSpa.registerApplication('app-1', (v) => import('../app1/app1.js'), pathPrefix('/app1'), {
content: 'app1111111',
});
singleSpa.registerApplication('app-2', () => import('../app2/app2.js'), pathPrefix('/app2'), { content: 'app2222222' });
console.log('start');
singleSpa.start();
app1/app1.js
import React from 'react';
import ReactDOMClient from 'react-dom/client';
import singleSpaReact from 'single-spa-react';
import Root from './root.component.tsx';
console.log('app1');
const reactLifecycles = singleSpaReact({
React,
ReactDOMClient,
rootComponent: Root,
domElementGetter,
});
export function bootstrap(props) {
console.log('app1bootstrap', props);
return reactLifecycles.bootstrap(props);
}
export function mount(props) {
console.log('app1mount', props);
return reactLifecycles.mount(props);
}
export function unmount(props) {
console.log('app1unmount', props);
return reactLifecycles.unmount(props);
}
function domElementGetter() {
// Make sure there is a div for us to render into
let el = document.getElementById('app1');
if (!el) {
el = document.createElement('div');
el.id = 'app1';
document.body.appendChild(el);
}
return el;
}
app1/root.component.tsx
import * as React from 'react';
export default class Root extends React.Component<any, any> {
render() {
const message: string = 'This was rendered by app 1 which was written in React';
return <div style={{ marginTop: '100px' }}>{message}</div>;
}
}
app2 与上述配置代码一致。
主流程大致分析
打开 http://localhost:8080/
应用注册流程 registerApplication
通过 sanitizeArguments 格式化用户传递的子应用配置参数,然后 push 到 apps 里,执行 reroute
reroute 更改 app.status 和执行生命周期函数
reroute 里执行 getAppChanges,该方法会遍历 app 应用数组判断生命周期,根据 shouldBeActive 方法 location 匹配的 app 激活规则判断子应用是已激活,返回不同状态的应用:将 apps 应用根据 app.status 分为 4 大类appsToUnload、appsToUnmount、appsToLoad、appsToMount
。此时路由没有匹配上,所以不会将 app 放到 appsToLoad 里,即 appsToLoad 为空;
判断是否 isStarted,执行 loadApps,loadApps 返回一个立即 resolved 的 promise,内部执行 appsToLoad.map(toLoadPromise),通过 Promise.all 保证所有的子应用都加载完成; 因为 appsToLoad 为空,至此结束。
start 流程
执行 singleSpa.start(),将 started 置为 true,然后执行 patchHistoryApi
patchHistoryApi
该方法里完成两件事:
1.监听 hashchange、popstate
function urlReroute() {
reroute([], arguments);
}
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);
此时浏览器导航操作的 hasChange 或 popState 事件回调函数将收集到 capturedEventListeners 对象中待 reroute 后遍历执行.
2.重写 window.history.pushState、window.history.replaceState
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(originalReplaceState, 'replaceState');
接着执行 reroute,内部判断是否 isStarted,此时执行 performAppChanges()。因 apps 里没有任何应用,故结束 start 流程,暂不展开 performAppChanges 分析。
点击切换流程
点击按钮 app1 执行 singleSpaNavigate->navigateToUrl-> window.history.pushState(null, null, url)->urlReroute->reroute,getAppChanges 里 appsToLoad 既有对应路由的 app,再次执行 performAppChanges
performAppChanges 执行状态改变及钩子
立即返回 Promise.resolve(),回调里执行 apps 里各状态对应的钩子列表
const unloadPromises = appsToUnload.map(toUnloadPromise);
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);
//...
const loadThenMountPromises = appsToLoad.map((app) => {
return toLoadPromise(app).then((app) => tryToBootstrapAndMount(app, unmountAllPromise));
});
const mountPromises = appsToMount
.filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
.map((appToMount) => {
return tryToBootstrapAndMount(appToMount, unmountAllPromise);
});
toUnloadPromise、toUnmountPromise、包括 tryToBootstrapAndMount 内部的 toBootstrapPromise、toMountPromise 都是执行对应子应用暴露出来的钩子方法,通过异步保证执行顺序。其中 toLoadPromise
toLoadPromise 通过微任务加载子应用
立即返回 Promise.resolve(),回调里执行 loadApp 即注册应用时配置的 import('../app1/app1.js'),在回调中对返回进行了生命周期函数的合法性检查,获取子应用的各个生命周期函数赋到 app,并通过 flattenFnArray 可串联执行钩子方法。
最后执行收尾工作 callAllEventListeners 即执行之前收集的 capturedEventListeners。
performAppChanges 里会触发钩子:
- single-spa:before-no-app-change or single-spa:before-no-app-change
- single-spa:before-routing-event
- single-spa:before-mount-routing-event(依赖上一个钩子是否调用 cancelNavigation 或 unmountAllPromise 回调)
- single-spa:no-app-change or single-spa:app-change
- single-spa:routing-event
- single-spa:before-first-mount
- single-spa:first-mount
single-spa 的不足
- 复杂的子应用加载逻辑
- 应用之间的 js、css 隔离
- 子应用切换遗留的副作用
- 子应用状态恢复
- 应用之间的通信
- 子应用预加载
简单实现
const NOT_LOADED = 'NOT_LOADED';
const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE';
const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED';
const BOOTSTRAPPING = 'BOOTSTRAPPING';
const NOT_MOUNTED = 'NOT_MOUNTED';
const MOUNTING = 'MOUNTING';
const MOUNTED = 'MOUNTED';
const UNMOUNTING = 'UNMOUNTING';
// 存放所有的子应用
const apps = [];
let isStarted = false;
export const registerApplication = (appConfig) => {
apps.push({ ...appConfig, status: NOT_LOADED });
reroute();
};
export const start = () => {
isStarted = true;
reroute();
};
const reroute = async () => {
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
if (isStarted) {
await Promise.all(appsToUnmount.map(toUnmount));
await Promise.all(appsToLoad.map(toLoad));
await Promise.all(appsToMount.map(tryToBoostrapAndMount));
} else {
await Promise.all(appsToLoad.map(toLoad));
}
};
const toLoad = async (app) => {
if (app.status !== NOT_LOADED) return;
app.status = LOADING_SOURCE_CODE;
const res = await app.app();
Object.assign(app, { ...res, status: NOT_BOOTSTRAPPED });
};
const toUnmount = async (app) => {
if (app.status !== MOUNTED) return;
app.status = UNMOUNTING;
await app.unmount(app.customProps);
app.status = NOT_MOUNTED;
};
const tryToBoostrapAndMount = async (app) => {
if (!shouldBeActive(app)) return;
if (app.status !== NOT_BOOTSTRAPPED && app.status !== NOT_MOUNTED) return;
app.status = BOOTSTRAPPING;
await app.bootstrap(app.customProps);
app.status = NOT_MOUNTED;
if (shouldBeActive(app)) {
app.status = MOUNTING;
await app.mount(app.customProps);
app.status = MOUNTED;
}
};
const getAppChanges = () =>
apps.reduce(
(acc, app) => {
const { appsToLoad, appsToMount, appsToUnmount } = acc;
const isActive = shouldBeActive(app);
if (app.status === NOT_LOADED || (app.status === NOT_BOOTSTRAPPED && isActive)) {
appsToLoad.push(app);
} else if ((app.status === NOT_MOUNTED || app.status === BOOTSTRAPPING) && isActive) {
appsToMount.push(app);
} else if (app.status === MOUNTED && !isActive) {
appsToUnmount.push(app);
}
return acc;
},
{ appsToLoad: [], appsToMount: [], appsToUnmount: [] },
);
const shouldBeActive = (app) => {
try {
return app.activeWhen(window.location);
} catch (err) {
console.error('activeWhen error', err);
return false;
}
};
const patchHistoryUpdate =
(updateState) =>
(...args) => {
const urlBefore = window.location.href;
const result = Reflect.apply(updateState, window.history, args);
const urlAfter = window.location.href;
if (urlBefore !== urlAfter) reroute();
return result;
};
window.addEventListener('hashchange', reroute);
window.history.pushState = patchHistoryUpdate(window.history.pushState);
window.history.replaceState = patchHistoryUpdate(window.history.replaceState);