single-spa v5.9.3
通过轻量级路由劫持和状态机设计,实现微前端的动态加载与隔离,主要实现
- 路由管理:hashchange、popstate、history.pushState、history.replaceState进行劫持,路由变化时,触发 reroute()
- 子应用状态管理:不同执行逻辑转化不同的状态,比如
- 加载流程:toLoadPromise→toBootstrapPromise→toMountPromise
- 卸载流程:toUnmountPromise→toUnloadPromise
- 子应用生命周期触发:
- app.bootstrap():初始化时仅执行一次
- app.mount():应用激活时触发
- app.unmount():应用从激活变为非激活状态时触发
- app.unload():最终卸载时触发一次
single-spa 采用 JS Entry 的方式接入微前端
我们需要在基座中注册子应用,比如下面代码中,我们注册了对应的映射路径 path 以及对应的加载的方法
registerApplication({
name: "app1",
app: loadApp(url),
activeWhen: activeWhen("/app1"),
customProps: {},
});
整体流程图
1. registerApplication()
在基座初始化时,会调用 registerApplication() 进行子应用的注册
从下面的源码我可以知道,主要执行:
- 格式化用户传递的子应用配置参数:sanitizeArguments()
- 将子应用加入到 apps 中
- 如果是浏览器,则触发
- ensureJQuerySupport():增加 JQuery的支持
- reroute():统一处理路由的方法
function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
var registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
reroute()
- 状态计算:通过 getAppChanges() 根据当前的 URL 筛选出需要 加载/卸载 的应用,主要分为 4 种类型
- 根据是否已经触发 start(),从而决定要触发
- loadApps(): 加载应用资源,没有其他逻辑
- performAppChanges():卸载非 active 状态的应用(调用 umount 生命周期) + 加载并挂载 active 子应用
function reroute() {
if (appChangeUnderway) {
return new Promise(function (resolve, reject) {
peopleWaitingOnAppChange.push({
resolve: resolve,
reject: reject,
eventArguments: eventArguments,
});
});
}
var { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
getAppChanges();
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
}
1.1 getAppChanges()
根据目前 app.status 的状态进行不同数组数据的组装
- appsToLoad
- appsToUnload
- appsToMount
- appsToUnmount
apps.forEach(function (app) {
var appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});
1.2 loadApps()
在 loadApps() 中,就是遍历 appsToLoad 数组 => toLoadPromise(app),本质就是触发 app.loadApp()进行子应用的加载
function loadApps() {
return Promise.resolve().then(function () {
var loadPromises = appsToLoad.map(toLoadPromise);
return (
Promise.all(loadPromises)
.then(callAllEventListeners)
// there are no mounted apps, before start() is called, so we always return []
.then(function () {
return [];
})
.catch(function (err) {
callAllEventListeners();
throw err;
})
);
});
}
1.2.1 toLoadPromise()
触发 app.loadApp()进行子应用的加载
需要子应用提供一个 loadApp()并且返回 Promise
状态改为 NOT_BOOTSTRAPPED
function toLoadPromise(app) {
return Promise.resolve().then(function () {
if (app.loadPromise) {
return app.loadPromise;
}
if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
return app;
}
app.status = LOADING_SOURCE_CODE;
var appOpts, isUserErr;
return (app.loadPromise = Promise.resolve()
.then(function () {
var loadPromise = app.loadApp(getProps(app));
return loadPromise.then(function (val) {
app.loadErrorTime = null;
appOpts = val;
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
delete app.loadPromise;
return app;
});
})
.catch(function (err) {
//...
}));
});
}
2. 监听路由变化
single-spa 源码中有自动执行的一系列代码:
- 监听 hashchange 和 popstate 变化,触发urlReroute()->reroute()
- 劫持 window.addEventListener 和 window.removeEventListener,将外部应用通过注册的 ["hashchange", "popstate"] 的监听方法 放入到 capturedEventListeners 中,在下面的 unmountAllPromise.then() 之后才会调用 capturedEventListeners 存储的方法执行
- 重写 history.pushState() 和 history.replaceState() 方法,在原来的基础上增加 window.dispatchEvent(createPopStateEvent(window.history.state, methodName)) ,从而触发第一步的 popstate 监听,从而触发 urlReroute()->reroute() 进行子应用路由的状态同步
总结:
- 路由变化触发微前端子应用加载
- pushState 和 replaceState 改变路由触发微前端子应用加载
- 阻止外部的hashchange、popstate对应的监听方法直接执行,而是等待微前端执行后才触发这些方法
var routingEventsListeningTo = ["hashchange", "popstate"];
if (isInBrowser) {
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
var originalAddEventListener = window.addEventListener;
var originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
if (typeof fn === "function") {
if (
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], function (listener) {
return listener === fn;
})
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, listenerFn) {
//...
};
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
if (window.singleSpaNavigate) {
//...
} else {
window.singleSpaNavigate = navigateToUrl;
}
}
function urlReroute() {
reroute([], arguments);
}
function callAllEventListeners() {
pendingPromises.forEach(function (pendingPromise) {
callCapturedEventListeners(pendingPromise.eventArguments);
});
callCapturedEventListeners(eventArguments);
}
3. start()启动开始状态
当基座主动触发 single-spa 的 start() 方法时
function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
此时已经在监听路由变化,然后进行 active 子应用的挂载 performAppChanges()
function reroute() {
if (appChangeUnderway) {
return new Promise(function (resolve, reject) {
peopleWaitingOnAppChange.push({
resolve: resolve,
reject: reject,
eventArguments: eventArguments,
});
});
}
var { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
getAppChanges();
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
}
3.1 getAppChanges()
根据目前 app.status 的状态进行不同数组数据的组装
- appsToLoad
- appsToUnload
- appsToMount
- appsToUnmount
apps.forEach(function (app) {
var appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});
3.2 performAppChanges()
而当 start() 方法触发后,started 设置为 true, 标志着应用从 初始化注册应用(加载应用)的模式进入到 运行阶段(监听路由变化)
此时触发 reroute(),则进入 performAppChanges()
urlRerouteOnly控制路由触发规则:
- urlRerouteOnly=true:用户点击或者使用 API 才会触发 reroute()
- urlRerouteOnly=false:任何 history.pushState() 的调用都会触发 reroute()
function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
在 performAppChanges() 中,先组装出需要卸载的子应用
var unloadPromises = appsToUnload.map(toUnloadPromise);
var unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map(function (unmountPromise) {
return unmountPromise.then(toUnloadPromise);
});
var allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
var unmountAllPromise = Promise.all(allUnmountPromises);
再组装出需要加载的应用
/* We load and bootstrap apps while other apps are unmounting, but we
* wait to mount the app until all apps are finishing unmounting
*/
var loadThenMountPromises = appsToLoad.map(function (app) {
return toLoadPromise(app).then(function (app) {
return tryToBootstrapAndMount(app, unmountAllPromise);
});
});
/* These are the apps that are already bootstrapped and just need
* to be mounted. They each wait for all unmounting apps to finish up
* before they mount.
*/
var mountPromises = appsToMount
.filter(function (appToMount) {
return appsToLoad.indexOf(appToMount) < 0;
})
.map(function (appToMount) {
return tryToBootstrapAndMount(appToMount, unmountAllPromise);
});
先触发 unmountAllPromise ,然后再触发 loadThenMountPromises.concat(mountPromises),最终全部完成后触发finishUpAndReturn
return unmountAllPromise
.catch(function (err) {
callAllEventListeners();
throw err;
})
.then(function () {
/* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
* events (like hashchange or popstate) should have been cleaned up. So it's safe
* to let the remaining captured event listeners to handle about the DOM event.
*/
callAllEventListeners();
return Promise.all(loadThenMountPromises.concat(mountPromises))
.catch(function (err) {
pendingPromises.forEach(function (promise) {
return promise.reject(err);
});
throw err;
})
.then(finishUpAndReturn);
});
在上面的方法中,我们看到了很多封装的方法,比如toLoadPromise()、tryToBootstrapAndMount()、toUnloadPromise()、finishUpAndReturn(),接下来我们将展开分析
3.2.1 tryToBootstrapAndMount()
当用户目前的路由是 /app1,导航到 /app2时:
- 调用 app.activeWhen() 进行子应用状态的检测(需要子应用提供实现方法),shouldBeActive(app2) 返回 true
- 触发 toBootstrapPromise(app2) 更改状态为 BOOTSTRAPPING,并且触发子应用提供的 app2.bootstrap() 生命周期方法 => 更改状态为 NOT_MOUNTED
- 触发传入的unmountAllPromise,进行 /app1 卸载,然后再触发 toMountPromise(app2) 执行子应用提供的 app2.mount() 生命周期方法,然后更改状态为 MOUNTED
如果卸载完成 /app1 后,我们再次检测 shouldBeActive(app2) 的时候发现路由改变,不是 /app2,那么 app2 停止挂载,直接返回 app2,状态仍然保留在 toBootstrapPromise(app2) 时的 NOT_MOUNTED
function tryToBootstrapAndMount(app, unmountAllPromise) {
if (shouldBeActive(app)) {
return toBootstrapPromise(app).then(function (app) {
return unmountAllPromise.then(function () {
return shouldBeActive(app) ? toMountPromise(app) : app;
});
});
} else {
return unmountAllPromise.then(function () {
return app;
});
}
}
function shouldBeActive(app) {
return app.activeWhen(window.location);
}
function toBootstrapPromise(appOrParcel, hardFail) {
return Promise.resolve().then(function () {
if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
return appOrParcel;
}
appOrParcel.status = BOOTSTRAPPING;
if (!appOrParcel.bootstrap) {
// Default implementation of bootstrap
return Promise.resolve().then(successfulBootstrap);
}
return reasonableTime(appOrParcel, "bootstrap")
.then(successfulBootstrap)
.catch(function (err) {
//...
});
});
function successfulBootstrap() {
appOrParcel.status = NOT_MOUNTED;
return appOrParcel;
}
}
function toMountPromise(appOrParcel, hardFail) {
return Promise.resolve().then(function () {
return reasonableTime(appOrParcel, "mount")
.then(function () {
appOrParcel.status = MOUNTED;
//...
return appOrParcel;
})
.catch(function (err) {
//...
});
});
}
3.2.2 toUnloadPromise()
逻辑也非常简单,就是触发子应用提供的 app2.unload() 生命周期方法,将状态改为 UNLOADING => 将状态改为 NOT_LOADED
var appsToUnload = {};
function toUnloadPromise(app) {
return Promise.resolve().then(function () {
var unloadInfo = appsToUnload[toName(app)];
if (app.status === NOT_LOADED) {
finishUnloadingApp(app, unloadInfo);
return app;
}
if (app.status === UNLOADING) {
return unloadInfo.promise.then(function () {
return app;
});
}
if (app.status !== NOT_MOUNTED && app.status !== LOAD_ERROR) {
return app;
}
var unloadPromise =
app.status === LOAD_ERROR
? Promise.resolve()
: reasonableTime(app, "unload");
app.status = UNLOADING;
return unloadPromise
.then(function () {
finishUnloadingApp(app, unloadInfo);
return app;
})
.catch(function (err) {
errorUnloadingApp(app, unloadInfo, err);
return app;
});
});
}
function finishUnloadingApp(app, unloadInfo) {
delete appsToUnload[toName(app)];
delete app.bootstrap;
delete app.mount;
delete app.unmount;
delete app.unload;
app.status = NOT_LOADED;
unloadInfo.resolve();
}
3.3.3 finishUpAndReturn()
- 返回已经挂载应用的列表
- 处理等待中的 pendingPromises
- 触发全局事件通知
- 重置全局状态 appChangeUnderway = false
- 检测是否有未处理的后续请求,如果有,则重新触发 reroute() 处理
function finishUpAndReturn() {
var returnValue = getMountedApps();
pendingPromises.forEach(function (promise) {
return promise.resolve(returnValue);
});
var appChangeEventName =
appsThatChanged.length === 0
? "single-spa:no-app-change"
: "single-spa:app-change";
window.dispatchEvent(
new customEvent(appChangeEventName, getCustomEventDetail())
);
window.dispatchEvent(
new customEvent("single-spa:routing-event", getCustomEventDetail())
);
appChangeUnderway = false;
if (peopleWaitingOnAppChange.length > 0) {
var nextPendingPromises = peopleWaitingOnAppChange;
peopleWaitingOnAppChange = [];
reroute(nextPendingPromises);
}
return returnValue;
}
import-html-entry v1.17.0
假设我们要转化的 index.html 为:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Hello Micro Frontend</h1>
<script src="app.js" entry></script>
<script src="async.js" async></script>
<script>console.log('Inline script');</script>
</body>
</html>
style.css 的具体内容为:
body { background-color: lightblue; }
app.js 内容为:
// 子应用导出的生命周期钩子
export function bootstrap() {
console.log("Sub app bootstrap");
}
export function mount() {
console.log("Sub app mounted");
}
bootstrap();
async.js 内容为:
console.log("Async script loaded");
我们在外部使用这个库一般直接使用 importEntry() 获取子应用的数据
在我们这个示例中,会传入一个 entry = "index.html",因此会直接走 importHTML()
function importEntry(entry, opts = {}) {
const {
fetch = defaultFetch,
getTemplate = defaultGetTemplate,
postProcessTemplate,
} = opts;
const getPublicPath =
opts.getPublicPath || opts.getDomain || defaultGetPublicPath;
// html entry
if (typeof entry === "string") {
return importHTML(entry, {
fetch,
getPublicPath,
getTemplate,
postProcessTemplate,
});
}
// config entry
if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {
//...
} else {
throw new SyntaxError("entry scripts or styles should be array!");
}
}
从下面代码可以知道,主要分为几个步骤:
- 获取 HTML 内容:通过fetch直接请求对应的 https 得到对应的 HTML 字符串(也就是我们上面示例的 index.html 内容)
- 解析 HTML:调用 processTpl() 解析 HTML 得到
- scripts = ["/app.js ", ""]
- entry = "/app.js "
- styles = ["/style.css "]
- 将 CSS 样式进行内联:在 getEmbedHTML() 下载 style.css 的内容,替换 template 模板中 为 的內联样式
function getEmbedHTML(template, styles, opts = {}) { const { fetch = defaultFetch } = opts; let embedHTML = template; return getExternalStyleSheets(styles, fetch).then((styleSheets) => { embedHTML = styleSheets.reduce((html, styleSheet) => { const styleSrc = styleSheet.src; const styleSheetContent = styleSheet.value; html = html.replace( genLinkReplaceSymbol(styleSrc), isInlineCode(styleSrc) ? `${styleSrc}` : `<style>/* ${styleSrc} */${styleSheetContent}</style>` ); return html; }, embedHTML); return embedHTML; }); }
3. 返回值对象解析
3.1 template
替换 template 模板中 为 內联样式替换到 template 中,然后返回一个对象数据,包括
- template:替换了所有 styles 的模板数据
- assetPublicPath:微应用的路径
- getExternalScripts():提供外部调用可以下载 scripts = ["/app.js ", ""] 的方法
- getExternalStyleSheets():提供外部调用可以下载 styles = ["/style.css "] 的方法
- execScripts():执行 getExternalScripts() 下载 scripts,然后调用 geval() 生成沙箱代码并执行,确保 JS 在代理的上下文中运行,避免全局污染
function importHTML(url, opts = {}) { //... return ( embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url) .then((response) => readResAsString(response, autoDecodeResponse)) .then((html) => { const assetPublicPath = getPublicPath(url); const { template, scripts, entry, styles } = processTpl( getTemplate(html), assetPublicPath, postProcessTemplate ); return getEmbedHTML(template, styles, { fetch }).then((embedHTML) => ({ template: embedHTML, assetPublicPath, getExternalScripts: () => getExternalScripts(scripts, fetch), getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch), execScripts: (proxy, strictGlobal, opts = {}) => { if (!scripts.length) { return Promise.resolve(); } return execScripts(entry, scripts, proxy, { fetch, strictGlobal, ...opts, }); }, })); })) ); }