核心思想
Be Technology Agnostic; Isolate Team Code; Establish Team Prefixes; Favor Native Browser Features over Custom APIs; Build a Resilient Site.
- 主框架不限制子应用的技术栈。
- 子应用代码隔离,可以独立开发、独立部署。
- 运行时,子应用状态隔离,不会相互影响。
实现方式
- Nginx路由转发:通过Nginx配置反向代理来实现不同路径映射到不同应用,但是在切换应用时会触发浏览器刷新,影响体验。
- iframe嵌套:父应用单独是一个页面,每个微应用嵌套一个iframe,可以采用postMessage或者contentWindow方式通信,但是样式展示、兼容性等性能都有局限性。
- Web Components集成:对于历史系统改造成本高,微应用通信较为复杂。
- 通用技术栈基座式:微应用独立构建和部署,有基座应用来进行路由管理、应用加载、启动卸载、通信。基座框架主要需要解决路由切换、微应用隔离、主应用与微应用之间的通信问题。
微前端框架(通用技术栈基座式)
single-spa
官方功能:
- Use multiple frameworks on the same page without page refreshing (React, AngularJS, Angular, Ember, or whatever you're using)
- Deploy your microfrontends independently
- Write code using a new framework, without rewriting your existing app
- Lazy load code for improved initial load time
流程
- Register:注册微应用并监听微应用url变化,通过url匹配来决定何时进入该微应用的生命周期;
- Load:加载微应用;
- 微应用中必须存在bootstrap、mount、unmount生命周期,并在mount开始时渲染微应用;
- bootstrap:只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
注册机制—registerApplication
export function registerApplication(
appNameOrConfig, //微应用名称
appOrLoadApp, // 微应用激活后的回调
activeWhen, // 微应用激活规则
customProps // 主应用传递给微应用的参数
) {
// 1. 处理参数,使参数规范化,保证每个子应用注册的参数合法;
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
...
// 2. 将微应用保存到数组apps(全局数组)中,single-spa的核心工作就是对apps中保存的微应用进行管理和控制。
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
// 3. 对JQuery的某些监听事件进行拦截
ensureJQuerySupport();
// 4. 调用reroute函数,加载微应用
reroute();
}
}
路由管理
We capture navigation event listeners so that we can make sure that application navigation listeners are not called until single-spa has ensured that the correct applications are unmounted and mounted.
// 1. 劫持hashchange,popstate (urlReroute函数调用了reroute方法)
// We will trigger an app change for any routing events.
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
// 2. 拦截设置hashchange、popstate监听事件的监听函数
// 这里将hashchange、popstate事件存入capturedEventListeners中,在加载子应用(loadApps)和卸载(unmount)时执行所有捕获事件的回调。
window.addEventListener = function (eventName, fn) {
if (typeof fn === "function") {
if (
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) => listener === fn)
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, listenerFn) {
if (typeof listenerFn === "function") {
if (routingEventsListeningTo.indexOf(eventName) >= 0) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((fn) => fn !== listenerFn);
return;
}
}
return originalRemoveEventListener.apply(this, arguments);
};
// 3. 拦截可能改变路由状态的api方法
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
微应用状态管理
// App statuses
export const NOT_LOADED = "NOT_LOADED";
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";
export const BOOTSTRAPPING = "BOOTSTRAPPING";
export const NOT_MOUNTED = "NOT_MOUNTED";
export const MOUNTING = "MOUNTING";
export const MOUNTED = "MOUNTED";
export const UPDATING = "UPDATING";
export const UNMOUNTING = "UNMOUNTING";
export const UNLOADING = "UNLOADING";
export const LOAD_ERROR = "LOAD_ERROR";
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";
微应用的状态对应微应用的生命周期(Load, Bootstrap, Mount、Unmount),在状态变化时修改微应用的status。
Reroute
在执行start、registerApplication、路由发生变化都会执行reroute,那么它是干什么的呢?
export function reroute(pendingPromises = [], eventArguments) {
// ...
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
//...
//是否执行过start函数,如果是,则进一步执行每一个微应用的生命周期;如果否,则加载微应用
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
// 1.执行卸载逻辑;2.执行相关挂载逻辑;3.执行在不同阶段派发自定义事件。
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
// 在loadApps中调用了toLoadPromise方法,作用为:1.调用子应用传入的加载微应用的方法;2. 保存微应用的各种状态。
return loadApps();
}
//...
}
Reroute->控制微应用状态的流转和事件分发。
qiankun
single-spa只是劫持了单页应用的路由变换,没有考虑到样式和JS的隔离;相较于single-spa,qiankun做了两件重要的事情,其一是加载资源,第二是进行资源隔离。而资源隔离又分为JS资源隔离和CSS资源隔离。
JS资源隔离-沙箱
沙箱分3种:
- SnapshotSandbox:在沙箱挂载和卸载时记录快照,在应用切换时恢复快照环境,用于不支持 Proxy 的低版本浏览器。
- LegacySandBox:支持单应用的代理沙箱;
- proxySandbox:支持多应用的代理沙箱;
沙箱环境
SnapshotSandbox
SnapshotSandbox 的沙箱环境主要是通过激活时记录 window 状态快照,在关闭时通过快照还原 window 对象来实现的。
LegacySandbox
LegacySandbox通过一个proxy对象来记录沙箱运行时被修改的全局变量,对window做一个代理,记录微应用在window上挂载、删除、修改的操作,待恢复全局环境时,反向执行。
const proxy = new Proxy(fakeWindow, {
set: (_: Window, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
const originalValue = (rawWindow as any)[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
currentUpdatedPropsValueMap.set(p, value);
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
// eslint-disable-next-line no-param-reassign
(rawWindow as any)[p] = value;
this.latestSetProp = p;
return true;
}
//...
},
get(_: Window, p: PropertyKey): any {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// or use window.top to check if an iframe context
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
//...
});
在微应用脚本执行过程中,会将该proxy对象作为window参数传入,微应用的全局对象就是该微应用沙箱的proxy 对象:
eval(
(function(window) {
/* 子应用脚本文件内容 */
})(proxy)
);
LegacySandbox 的沙箱隔离是通过激活沙箱时还原微应用状态,卸载时还原主应用状态实现的。
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {
if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
...this.addedPropsMapInSandbox.keys(),
...this.modifiedPropsOriginalValueMapInSandbox.keys(),
]);
}
挂载沙箱
挂载时激活微应用的沙箱,在沙箱启动时开始劫持各类全局监听(patchAtMounting内部调用了patchInterval(计时器劫持)、patchWindowListener(window事件监听)、patchHistoryListener(history事件监听)、patchHTMLDynamicAppendPrototypeFunctions(动态添加样式表和脚本文件劫持)),并触发微应用的mount生命周期。
async mount() {
// 1. 启动/恢复 沙箱
sandbox.active();
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(0, bootstrappingFreers.length);
const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(bootstrappingFreers.length);
// must rebuild the side effects which added at bootstrapping firstly to recovery to nature state
if (sideEffectsRebuildersAtBootstrapping.length) {
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
}
// 2. 开启全局变量补丁
// render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter);
// 3. 重置一些初始化时的副作用
// 存在 rebuilder 则表明有些副作用需要重建
if (sideEffectsRebuildersAtMounting.length) {
sideEffectsRebuildersAtMounting.forEach((rebuild) => rebuild());
}
// clean up rebuilders
sideEffectsRebuilders = [];
}
卸载沙箱
关闭沙箱,释放劫持状态,并触发微应用的unmount生命周期。
/**
* 恢复 global 状态,使其能回到应用加载之前的状态
*/
async unmount() {
// record the rebuilders of window side effects (event listeners or timers)
// note that the frees of mounting phase are one-off as it will be re-init at next mounting
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map((free) => free());
sandbox.inactive();
},
css隔离
应用之间样式隔离: Dynamic Stylesheet 动态样式表,当应用切换时移除老应用样式,添加新应用样式
主应用和子应用之间的样式隔离:
- BEM (Block Element Modifier) 约定项目前缀
- CSS-Modules 打包时生成不冲突的选择器名
- Shadow DOM严格意义上的隔离:在渲染时向Dom结构中插入一个Shadow Dom元素子树,但是特殊的是,但shadow-dom并不在主 DOM 树中。
Start Api
用于初始化qiankun的配置:
export function start(opts: FrameworkConfiguration = {}) {
// 设置配置参数默认值
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const {
prefetch,
sandbox,
singular,
urlRerouteOnly = defaultUrlRerouteOnly,
...importEntryOpts
} = frameworkConfiguration;
//判断是否预加载,如果需要预加载,则添加全局事件 single-spa:first-mount 监听,在第一个微应用挂载后预加载其他微应用资源,优化后续其他微应用的加载速度。
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 是否启用沙箱
if (sandbox) {
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
frameworkConfiguration.sandbox = typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true };
if (!singular) {
console.warn(
'[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
);
}
}
}
// 调用single-spa中的startSingleSpa方法
startSingleSpa({ urlRerouteOnly });
started = true;
frameworkStartedDefer.resolve();
}
registerMicroApps Api
复用single-spa的注册机制
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// Each app only needs to be registered once
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
microApps = [...microApps, ...unregisteredApps];
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
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,
});
});
}
loadMicroApp API
加载微应用
初始化微应用资源
本阶段只会触发1次。
获取微应用资源(import-html-entry):single-spa通过SystemJS加载模块,qiankun直接将微应用打包的 HTML 作为入口,基座应用可以通过 fetch html 的方式获取微应用的静态资源,更方便。
// get the entry html content and script executor
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// template: 经过处理的脚本(处理了link与script标签);
// execScripts:执行js脚本,返回的对象一般包含一些微应用的生命周期钩子函数,主应用可以通过在特定阶段调用这些生命周期钩子函数,进行挂载和销毁微应用的操作。
// assetPublicPath:静态资源地址
execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, {
fetch,
strictGlobal,
beforeExec: execScriptsHooks.beforeExec,
afterExec: execScriptsHooks.afterExec,
});
},
挂载微应用HTML
// as single-spa load and bootstrap new app parallel with other apps unmounting
// (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
// 生成一个`<div id="__qiankun_microapp_wrapper_for_${snakeCase(id)}__" data-name="${name}">${tpl}</div>`标签包裹微应用
const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appName,
);
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
const render = getRender(appName, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
注册生命周期: 在加载过程中,将注册时微应用的生命周期与qiankun内置的生命周期合并:
const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith(
{},
getAddOns(global, assetPublicPath),
lifeCycles,
(v1, v2) => concat(v1 ?? [], v2 ?? []),
);
在qiankun内置的生命周期中,beforeLoad时会注入一个环境变量,并在beforeUnmount时删除环境变量:
export default function getAddOn(global: Window, publicPath = '/'): FrameworkLifeCycles<any> {
let hasMountedOnce = false;
return {
async beforeLoad() {
// eslint-disable-next-line no-param-reassign
global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath;
},
async beforeMount() {
if (hasMountedOnce) {
// eslint-disable-next-line no-param-reassign
global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath;
}
},
async beforeUnmount() {
if (rawPublicPath === undefined) {
// eslint-disable-next-line no-param-reassign
delete global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
} else {
// eslint-disable-next-line no-param-reassign
global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = rawPublicPath;
}
hasMountedOnce = true;
},
};
}
可以在微应用中获取该环境变量,将其设置为__webpack_public_path__的值,作为公共路径。
挂载微应用
本阶段可能会触发多次
卸载微应用
本阶段可能会触发多次