概要
1. Single-spa
2. Qiankun
Single-spa
single-spa的理想化实现是使用es6 modules,以js entry的方式接入,每一个微应用都是基座app importmap的一个键值
接入Single-spa时需要先对microapp改造:
- 微应用路由改造(大多数情况下依赖于路由来激活微应用时)来避免各个应用间的冲突,比如添加一个特定的前缀
- 提供给single-spa使用的钩子,改造微应用入口,挂载点变更和生命周期函数导出
- 打包工具配置更改 – 要打包成一个js文件(当然也可以使用webpack的manifest,但是根本问题是无法进行分包加载等优化)
js entry的方式由于没有上下文路由/一次全量加载所有js,没法实现按需加载,首屏渲染优化,CSS独立打包,更新版本还要考虑缓存策略的问题
Single-spa 调用流程
registerApps => 处理/验证入参 => 格式化入参生成registerations => reroute() => load()
single-spa.start() => reroute() => load() if not loaded => 调用bootsraped钩子 => 变为bootstraped
路由变更 => reroute() => 判断是否active => active则mount(即启动微应用,比如new vue(),ReactDOM.render())
若inactive => unmount (销毁微应用实例)
single-spa核心方法,区分出各个状态的micro app,返回后处理每种状态,并对应状态需要调用的钩子(各微应用传入),实现状态变更
export function getAppChanges() {
const appsToUnload = [],
appsToUnmount = [],
appsToLoad = [],
appsToMount = [];
// We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
const currentTime = new Date().getTime();
apps.forEach((app) => {
const 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:
...
将一个微应用传入的mount钩子全部转化为promises
export function toMountPromise(appOrParcel, hardFail) {
return Promise.resolve().then(() => {
if (appOrParcel.status !== NOT_MOUNTED) {
return appOrParcel;
}
if (!beforeFirstMountFired) {
window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
beforeFirstMountFired = true;
}
return reasonableTime(appOrParcel, "mount")
.then(() => {
appOrParcel.status = MOUNTED;
....
activeWhen传入方法时,可以实现懒加载,满足条件后才load微应用
Single-spa五个缺少的功能
- 需要改造微应用,上文提到的微应用改造
- 样式隔离,多个微应用同时挂载,样式冲突
- JS 隔离,多个微应用同时挂载,window冲突
- 资源预加载,微应用只在符合条件时开始加载
- 无法实现应用间的通信
Single-spa缺少这些功能也情有可原,它只专注微应用的宏观管理,不包含某些具体的功能需求
总结来说single-spa – 只做了 管理(初始化、挂载、卸载)微应用,并调用对应方法,粗暴一点讲,single-spa只是微应用的状态管理机
Qiankun
qiankun 基于Single spa封装,see more on https://github.com/umijs/qiankun
qiankun依赖import html entry包 以html entry的方式接入
基于qiankun,只需要提供微应用 hotst的url就可以接入,简单到类似于iframe
注册微应用方法,registerApplication的入参(包含钩子,配置,微应用meta)进入single-spa中被使用/调用
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
...
registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise;
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
...
}
- html entry 保留微应用内部优化,减少对微应用入侵
qiankun提供了封装的load方法(最终给single-spa使用) – 通过html entry解析微应用的template, execScripts, assetPublicPath, getExternalScripts资源并加载
execScripts 即微应用的业务代码,包含export出来的三个钩子,qiankun拿到后传入single-spa
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
...
// get the entry html content and script executor
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// trigger external scripts loading to make sure all assets are ready before execScripts calling
await getExternalScripts();
...
- shadow root / scroped CSS 两种方式实现内置的样式隔离
创建微应用的根节点为shadow root,给元素加attribute实现scopedCSS
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
const appElement = containerElement.firstChild as HTMLElement;
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
return appElement;
}
- built-in sandbox window.Proxy 代理window实现微应用运行时沙箱
基于 Proxy 实现的沙箱,实现window的property get/set,全局对象比如document/eval等
export default class ProxySandbox implements SandBox {
...
constructor(name: string, globalContext = window) {
...
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
...
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable, set } = descriptor!;
// only writable property can be overwritten
// here we ignored accessor descriptor of globalContext as it makes no sense to trigger its logic(which might make sandbox escaping instead)
// we force to set value by data descriptor
if (writable || set) {
Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
}
} else {
target[p] = value;
}
...
},
get: (target: FakeWindow, p: PropertyKey): any => {
...
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}
if (p === 'document') {
return this.document;
}
if (p === 'eval') {
return eval;
}
...
}
- built-in prefetch strategy 内置预加载逻辑,传入配置对象即可使用
根据传入的prefetch configure判断需要prefetch的时间点,监听事件,符合要求调用prefetch()
即importEntry – html entry
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
- build-in GloabState 内置全局状态,实现微应用间通信
内部实现状态机,将状态更新,订阅更新等方法通过mount钩子传给微应用
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
...
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
...
],
...
}