# 几种微前端方案探究
# 前言
随着技术的发展,前端应用承载的内容也日益复杂,基于此而产生的各种问题也应运而生,从MPA(Multi-Page Application,多页应用)到SPA(Single-Page Application,单页应用),虽然解决了切换体验的延迟问题,但也带来了首次加载时间长,以及工程爆炸增长后带来的巨石应用(Monolithic)问题;对于MPA来说,其部署简单,各应用之间天然硬隔离,并且具备技术栈无关、独立开发、独立部署等特点。要是能够将这两方的特点结合起来,会不会给用户和开发带来更好的用户体验?至此,在借鉴了微服务理念下,微前端便应运而生。
An architectural style where independently deliverable frontend applications are composed into a greater whole. [Micro Frontends from martinfowler.com] (https://martinfowler.com/articles/micro-frontends.html)
根据martinfowler对微前端的定义可以看出:微前端是一种由独立交付的多个前端应用组成整体的架构风格,即微前端和微服务一样是一种架构风格,因而其并不是一种框架或者库,而是一种风格或者说是一种思想,所以为了实现微前端的方案就有很多种,最常见的方案有以下几种:
- 路由分发
iframe
应用微服务
- 微件化
- 微应用化
- Web Components
相关对比:
方案 | 开发成本 | 维护成本 | 可行性 | 同一框架要求 | 实现难度 | 潜在风险 | 落地实践 |
---|---|---|---|---|---|---|---|
路由分发 | 低 | 低 | 高 | 否 | easy | 无 | http服务器反向代理,如nginx配置location |
iframe | 低 | 低 | 高 | 否 | easy | seo不友好、cookie管理、通信机制、弹窗问题、刷新后退、安全问题 | 前后端不分离项目常用 |
应用微服务 | 高 | 低 | 中 | 否 | hard | 共享及隔离粒度不统一 | qiankun、icestark、mooa及类single-spa应用 |
微件化 | 高 | 中 | 低 | 是 | hard | 实现微件管理机制 | 无 |
微应用化 | 中 | 中 | 高 | 是 | normal | 多个项目组合,需要考虑各个部署升级情况 | emp |
Web Components | 高 | 低 | 高 | 否 | normal | 新api,浏览器兼容性 | 无 |
对于微前端方案的选择应该从现有资源及历史积淀中去选择上述一种或几种方案的组合,从不同维度(比如:共享能力、隔离机制、数据方案、路由鉴权等)去考虑,实现工程的平滑迁移,从而实现架构的迭代升级逐步重构,切忌为了架构而架构,不要无谓的炫技,任何技术都是合适的才是最好的,大巧不工,重剑无锋!
# 方案对比
这里重点分析以应用微服务及微应用化的几种落地方案,对其实现思路做一个简单的探究
- single-spa
- qiankun
- icestark
- emp
- piral
# 源码解析
# single-spa源码
single-spa的整体思路是通过生命周期的钩子函数来对劫持的路由进行应用的加载,核心在于apps及reroute这两个文件
# apps.js
export function getAppChanges() {
const appsToUnload = [],
appsToUnmount = [],
appsToLoad = [],
appsToMount = [];
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:
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;
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
export function getMountedApps() {
return apps.filter(isActive).map(toName);
}
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
if (getAppNames().indexOf(registration.name) !== -1)
throw Error(
formatErrorMessage(
21,
__DEV__ &&
`There is already an app registered with name ${registration.name}`,
registration.name
)
);
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
export function unregisterApplication(appName) {
if (apps.filter((app) => toName(app) === appName).length === 0) {
throw Error(
formatErrorMessage(
25,
__DEV__ &&
`Cannot unregister application '${appName}' because no such application has been registered`,
appName
)
);
}
return unloadApplication(appName).then(() => {
const appIndex = apps.map(toName).indexOf(appName);
apps.splice(appIndex, 1);
});
}
export function unloadApplication(appName, opts = { waitForUnmount: false }) {
if (typeof appName !== "string") {
throw Error(
formatErrorMessage(
26,
__DEV__ && `unloadApplication requires a string 'appName'`
)
);
}
const app = find(apps, (App) => toName(App) === appName);
if (!app) {
throw Error(
formatErrorMessage(
27,
__DEV__ &&
`Could not unload application '${appName}' because no such application has been registered`,
appName
)
);
}
const appUnloadInfo = getAppUnloadInfo(toName(app));
if (opts && opts.waitForUnmount) {
if (appUnloadInfo) {
return appUnloadInfo.promise;
} else {
const promise = new Promise((resolve, reject) => {
addAppToUnload(app, () => promise, resolve, reject);
});
return promise;
}
} else {
let resultPromise;
if (appUnloadInfo) {
resultPromise = appUnloadInfo.promise;
immediatelyUnloadApp(app, appUnloadInfo.resolve, appUnloadInfo.reject);
} else {
resultPromise = new Promise((resolve, reject) => {
addAppToUnload(app, () => resultPromise, resolve, reject);
immediatelyUnloadApp(app, resolve, reject);
});
}
return resultPromise;
}
}
# reroute.js
export function reroute(pendingPromises = [], eventArguments) {
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({
resolve,
reject,
eventArguments,
});
});
}
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
let appsThatChanged,
navigationIsCanceled = false,
oldUrl = currentUrl,
newUrl = (currentUrl = window.location.href);
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
function cancelNavigation() {
navigationIsCanceled = true;
}
function loadApps() {
return Promise.resolve().then(() => {
const loadPromises = appsToLoad.map(toLoadPromise);
return (
Promise.all(loadPromises)
.then(callAllEventListeners)
.then(() => [])
.catch((err) => {
callAllEventListeners();
throw err;
})
);
});
}
function performAppChanges() {
return Promise.resolve().then(() => {
window.dispatchEvent(
new CustomEvent(
appsThatChanged.length === 0
? "single-spa:before-no-app-change"
: "single-spa:before-app-change",
getCustomEventDetail(true)
)
);
window.dispatchEvent(
new CustomEvent(
"single-spa:before-routing-event",
getCustomEventDetail(true, { cancelNavigation })
)
);
if (navigationIsCanceled) {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
finishUpAndReturn();
navigateToUrl(oldUrl);
return;
}
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);
unmountAllPromise.then(() => {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
});
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);
});
return unmountAllPromise
.catch((err) => {
callAllEventListeners();
throw err;
})
.then(() => {
callAllEventListeners();
return Promise.all(loadThenMountPromises.concat(mountPromises))
.catch((err) => {
pendingPromises.forEach((promise) => promise.reject(err));
throw err;
})
.then(finishUpAndReturn);
});
});
}
function finishUpAndReturn() {
const returnValue = getMountedApps();
pendingPromises.forEach((promise) => promise.resolve(returnValue));
try {
const 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())
);
} catch (err) {
setTimeout(() => {
throw err;
});
}
appChangeUnderway = false;
if (peopleWaitingOnAppChange.length > 0) {
const nextPendingPromises = peopleWaitingOnAppChange;
peopleWaitingOnAppChange = [];
reroute(nextPendingPromises);
}
return returnValue;
}
function callAllEventListeners() {
pendingPromises.forEach((pendingPromise) => {
callCapturedEventListeners(pendingPromise.eventArguments);
});
callCapturedEventListeners(eventArguments);
}
function getCustomEventDetail(isBeforeChanges = false, extraProperties) {
const newAppStatuses = {};
const appsByNewStatus = {
[MOUNTED]: [],
[NOT_MOUNTED]: [],
[NOT_LOADED]: [],
[SKIP_BECAUSE_BROKEN]: [],
};
if (isBeforeChanges) {
appsToLoad.concat(appsToMount).forEach((app, index) => {
addApp(app, MOUNTED);
});
appsToUnload.forEach((app) => {
addApp(app, NOT_LOADED);
});
appsToUnmount.forEach((app) => {
addApp(app, NOT_MOUNTED);
});
} else {
appsThatChanged.forEach((app) => {
addApp(app);
});
}
const result = {
detail: {
newAppStatuses,
appsByNewStatus,
totalAppChanges: appsThatChanged.length,
originalEvent: eventArguments?.[0],
oldUrl,
newUrl,
navigationIsCanceled,
},
};
if (extraProperties) {
assign(result.detail, extraProperties);
}
return result;
function addApp(app, status) {
const appName = toName(app);
status = status || getAppStatus(appName);
newAppStatuses[appName] = status;
const statusArr = (appsByNewStatus[status] =
appsByNewStatus[status] || []);
statusArr.push(appName);
}
}
}
# qiankun源码
qiankun是基于single-spa而封装了隔离及共享机制的框架,其简化了single-spa的相关生命周期,并且提供了沙箱隔离机制及共享机制
# sandbox
export type SandBox = {
/** 沙箱的名字 */
name: string;
/** 沙箱的类型 */
type: SandBoxType;
/** 沙箱导出的代理实体 */
proxy: WindowProxy;
/** 沙箱是否在运行中 */
sandboxRunning: boolean;
/** latest set property */
latestSetProp?: PropertyKey | null;
/** 启动沙箱 */
active: () => void;
/** 关闭沙箱 */
inactive: () => void;
};
// Proxy沙箱
export default class ProxySandbox implements SandBox {
/** window 值变更记录 */
private updatedValueSet = new Set<PropertyKey>();
name: string;
type: SandBoxType;
proxy: WindowProxy;
sandboxRunning = true;
latestSetProp: PropertyKey | null = null;
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
...this.updatedValueSet.keys(),
]);
}
if (--activeSandboxCount === 0) {
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
// @ts-ignore
delete window[p];
}
});
}
this.sandboxRunning = false;
}
constructor(name: string) {
this.name = name;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
const rawWindow = window;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);
const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
// We must kept its description while the property existed in rawWindow before
if (!target.hasOwnProperty(p) && rawWindow.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
const { writable, configurable, enumerable } = descriptor!;
if (writable) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
// @ts-ignore
target[p] = value;
}
if (variableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
rawWindow[p] = value;
}
updatedValueSet.add(p);
this.latestSetProp = p;
return true;
}
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get(target: FakeWindow, p: PropertyKey): any {
if (p === Symbol.unscopables) return unscopables;
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'window' || p === 'self') {
return proxy;
}
if (
p === 'top' ||
p === 'parent' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
// if your master app in an iframe context, allow these props escape the sandbox
if (rawWindow === rawWindow.parent) {
return proxy;
}
return (rawWindow as any)[p];
}
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as rawWindow.hasOwnProperty
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}
// mark the symbol to document while accessing as document.createElement could know is invoked by which sandbox for dynamic append patcher
if (p === 'document' || p === 'eval') {
setCurrentRunningSandboxProxy(proxy);
// FIXME if you have any other good ideas
// remove the mark in next tick, thus we can identify whether it in micro app or not
// this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case
nextTick(() => setCurrentRunningSandboxProxy(null));
switch (p) {
case 'document':
return document;
case 'eval':
// eslint-disable-next-line no-eval
return eval;
// no default
}
}
// eslint-disable-next-line no-nested-ternary
const value = propertiesWithGetter.has(p)
? (rawWindow as any)[p]
: p in target
? (target as any)[p]
: (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has(target: FakeWindow, p: string | number | symbol): boolean {
return p in unscopables || p in target || p in rawWindow;
},
getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
/*
as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
*/
if (target.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(target, p);
descriptorTargetMap.set(p, 'target');
return descriptor;
}
if (rawWindow.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
descriptorTargetMap.set(p, 'rawWindow');
// A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
}
return undefined;
},
// trap to support iterator with sandbox
ownKeys(target: FakeWindow): PropertyKey[] {
const keys = uniq(Reflect.ownKeys(rawWindow).concat(Reflect.ownKeys(target)));
return keys;
},
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
const from = descriptorTargetMap.get(p);
/*
Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
otherwise it would cause a TypeError with illegal invocation.
*/
switch (from) {
case 'rawWindow':
return Reflect.defineProperty(rawWindow, p, attributes);
default:
return Reflect.defineProperty(target, p, attributes);
}
},
deleteProperty(target: FakeWindow, p: string | number | symbol): boolean {
if (target.hasOwnProperty(p)) {
// @ts-ignore
delete target[p];
updatedValueSet.delete(p);
return true;
}
return true;
},
});
this.proxy = proxy;
activeSandboxCount++;
}
}
// 快照snapshot沙箱
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
}
this.sandboxRunning = false;
}
}
# globalState.js
// 触发全局监听
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
Object.keys(deps).forEach((id: string) => {
if (deps[id] instanceof Function) {
deps[id](cloneDeep(state), cloneDeep(prevState));
}
});
}
export function initGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
} else {
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(state);
emitGlobal(globalState, prevGlobalState);
}
return getMicroAppStateActions(`global-${+new Date()}`, true);
}
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
return {
/**
* onGlobalStateChange 全局依赖监听
*
* 收集 setState 时所需要触发的依赖
*
* 限制条件:每个子应用只有一个激活状态的全局监听,新监听覆盖旧监听,若只是监听部分属性,请使用 onGlobalStateChange
*
* 这么设计是为了减少全局监听滥用导致的内存爆炸
*
* 依赖数据结构为:
* {
* {id}: callback
* }
*
* @param callback
* @param fireImmediately
*/
onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
if (!(callback instanceof Function)) {
console.error('[qiankun] callback must be function!');
return;
}
if (deps[id]) {
console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
}
deps[id] = callback;
const cloneState = cloneDeep(globalState);
if (fireImmediately) {
callback(cloneState, cloneState);
}
},
/**
* setGlobalState 更新 store 数据
*
* 1. 对输入 state 的第一层属性做校验,只有初始化时声明过的第一层(bucket)属性才会被更改
* 2. 修改 store 并触发全局监听
*
* @param state
*/
setGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
return false;
}
const changeKeys: string[] = [];
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(
Object.keys(state).reduce((_globalState, changeKey) => {
if (isMaster || _globalState.hasOwnProperty(changeKey)) {
changeKeys.push(changeKey);
return Object.assign(_globalState, { [changeKey]: state[changeKey] });
}
console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
return _globalState;
}, globalState),
);
if (changeKeys.length === 0) {
console.warn('[qiankun] state has not changed!');
return false;
}
emitGlobal(globalState, prevGlobalState);
return true;
},
// 注销该应用下的依赖
offGlobalStateChange() {
delete deps[id];
return true;
},
};
}
# icestark源码
ice是淘系团队的一个全流程前端框架,其中包含了脚手架、组件库、vscode插件、lowcode生成等相关生态,其中icestark是其中包含的相关微前端的应用,这里不展开ice相关的架构了,简单来说其本质是以微内核形态配合各种插件市场来构成的强大生态系统,本文重点在微前端,因而只讨论微前端相关内容
# apps.ts
export function registerMicroApp(appConfig: AppConfig, appLifecyle?: AppLifecylceOptions) {
// check appConfig.name
if (getAppNames().includes(appConfig.name)) {
throw Error(`name ${appConfig.name} already been regsitered`);
}
// set activeRules
const { activePath, hashType = false, exact = false, sensitive = false, strict = false } = appConfig;
const activeRules: (ActiveFn | string | MatchOptions)[] = Array.isArray(activePath) ? activePath : [activePath];
const checkActive = activePath
? (url: string) => activeRules.map((activeRule: ActiveFn | string | MatchOptions) => {
if (typeof activeRule === 'function' ) {
return activeRule;
} else {
const pathOptions: MatchOptions = { hashType, exact, sensitive, strict };
const pathInfo = Object.prototype.toString.call(activeRule) === '[object Object]'
? { ...pathOptions, ...(activeRule as MatchOptions) }
: { path: activeRule as string, ...pathOptions };
return (checkUrl: string) => matchActivePath(checkUrl, pathInfo);
}
}).some((activeRule: ActiveFn) => activeRule(url))
// active app when activePath is not specified
: () => true;
const microApp = {
status: NOT_LOADED,
...appConfig,
appLifecycle: appLifecyle,
checkActive,
};
microApps.push(microApp);
}
export function registerMicroApps(appConfigs: AppConfig[], appLifecyle?: AppLifecylceOptions) {
appConfigs.forEach(appConfig => {
registerMicroApp(appConfig, appLifecyle);
});
}
// 可以加载module粒度的应用
export async function loadAppModule(appConfig: AppConfig) {
const { onLoadingApp, onFinishLoading, fetch } = getAppConfig(appConfig.name)?.configuration || globalConfiguration;
let lifecycle: ModuleLifeCycle = {};
onLoadingApp(appConfig);
const appSandbox = createSandbox(appConfig.sandbox);
const { url, container, entry, entryContent, name } = appConfig;
const appAssets = url ? getUrlAssets(url) : await getEntryAssets({
root: container,
entry,
href: location.href,
entryContent,
assetsCacheKey: name,
fetch,
});
updateAppConfig(appConfig.name, { appAssets, appSandbox });
cacheLoadMode(appConfig);
if (appConfig.umd) {
await loadAndAppendCssAssets(appAssets);
lifecycle = await loadUmdModule(appAssets.jsList, appSandbox);
} else {
await appendAssets(appAssets, appSandbox, fetch);
lifecycle = {
mount: getCache(AppLifeCycleEnum.AppEnter),
unmount: getCache(AppLifeCycleEnum.AppLeave),
};
setCache(AppLifeCycleEnum.AppEnter, null);
setCache(AppLifeCycleEnum.AppLeave, null);
}
onFinishLoading(appConfig);
return combineLifecyle(lifecycle, appConfig);
}
function capitalize(str: string) {
if (typeof str !== 'string') return '';
return `${str.charAt(0).toUpperCase()}${str.slice(1)}`;
}
async function callAppLifecycle(primaryKey: string, lifecycleKey: string, appConfig: AppConfig) {
if (appConfig.appLifecycle && appConfig.appLifecycle[`${primaryKey}${capitalize(lifecycleKey)}`]) {
await appConfig.appLifecycle[`${primaryKey}${capitalize(lifecycleKey)}`](appConfig);
}
}
function combineLifecyle(lifecycle: ModuleLifeCycle, appConfig: AppConfig) {
const combinedLifecyle = { ...lifecycle };
['mount', 'unmount', 'update'].forEach((lifecycleKey) => {
if (lifecycle[lifecycleKey]) {
combinedLifecyle[lifecycleKey] = async (props) => {
await callAppLifecycle('before', lifecycleKey, appConfig);
await lifecycle[lifecycleKey](props);
await callAppLifecycle('after', lifecycleKey, appConfig);
};
}
});
return combinedLifecyle;
}
export async function mountMicroApp(appName: string) {
const appConfig = getAppConfig(appName);
// check current url before mount
if (appConfig && appConfig.checkActive(window.location.href) && appConfig.status !== MOUNTED) {
updateAppConfig(appName, { status: MOUNTED });
if (appConfig.mount) {
await appConfig.mount({ container: appConfig.container, customProps: appConfig.props });
}
}
}
export async function unmountMicroApp(appName: string) {
const appConfig = getAppConfig(appName);
if (appConfig && (appConfig.status === MOUNTED || appConfig.status === LOADING_ASSETS || appConfig.status === NOT_MOUNTED)) {
// remove assets if app is not cached
const { shouldAssetsRemove } = getAppConfig(appName)?.configuration || globalConfiguration;
emptyAssets(shouldAssetsRemove, !appConfig.cached && appConfig.name);
updateAppConfig(appName, { status: UNMOUNTED });
if (!appConfig.cached && appConfig.appSandbox) {
appConfig.appSandbox.clear();
appConfig.appSandbox = null;
}
if (appConfig.unmount) {
await appConfig.unmount({ container: appConfig.container, customProps: appConfig.props });
}
}
}
// unload micro app, load app bundles when create micro app
export async function unloadMicroApp(appName: string) {
const appConfig = getAppConfig(appName);
if (appConfig) {
unmountMicroApp(appName);
delete appConfig.mount;
delete appConfig.unmount;
delete appConfig.appAssets;
updateAppConfig(appName, { status: NOT_LOADED });
} else {
console.log(`[icestark] can not find app ${appName} when call unloadMicroApp`);
}
}
// remove app config from cache
export function removeMicroApp(appName: string) {
const appIndex = getAppNames().indexOf(appName);
if (appIndex > -1) {
// unload micro app in case of app is mounted
unloadMicroApp(appName);
microApps.splice(appIndex, 1);
} else {
console.log(`[icestark] can not find app ${appName} when call removeMicroApp`);
}
}
export function removeMicroApps(appNames: string[]) {
appNames.forEach((appName) => {
removeMicroApp(appName);
});
}
// clear all micro app configs
export function clearMicroApps () {
getAppNames().forEach(name => {
unloadMicroApp(name);
});
microApps = [];
}
# start.ts
export function reroute (url: string, type: RouteType | 'init' | 'popstate'| 'hashchange' ) {
const { pathname, query, hash } = urlParse(url, true);
// trigger onRouteChange when url is changed
if (lastUrl !== url) {
globalConfiguration.onRouteChange(url, pathname, query, hash, type);
const unmountApps = [];
const activeApps = [];
getMicroApps().forEach((microApp: AppConfig) => {
const shouldBeActive = microApp.checkActive(url);
if (shouldBeActive) {
activeApps.push(microApp);
} else {
unmountApps.push(microApp);
}
});
// trigger onActiveApps when url is changed
globalConfiguration.onActiveApps(activeApps);
// call captured event after app mounted
Promise.all(
// call unmount apps
unmountApps.map(async (unmountApp) => {
if (unmountApp.status === MOUNTED || unmountApp.status === LOADING_ASSETS) {
globalConfiguration.onAppLeave(unmountApp);
}
await unmountMicroApp(unmountApp.name);
}).concat(activeApps.map(async (activeApp) => {
if (activeApp.status !== MOUNTED) {
globalConfiguration.onAppEnter(activeApp);
}
await createMicroApp(activeApp);
}))
).then(() => {
callCapturedEventListeners();
});
}
lastUrl = url;
};
function start(options?: StartConfiguration) {
if (started) {
console.log('icestark has been already started');
return;
}
started = true;
recordAssets();
// update globalConfiguration
Object.keys(options || {}).forEach((configKey) => {
globalConfiguration[configKey] = options[configKey];
});
hijackHistory();
hijackEventListener();
// trigger init router
globalConfiguration.reroute(location.href, 'init');
}
function unload() {
unHijackEventListener();
unHijackHistory();
started = false;
// remove all assets added by micro apps
emptyAssets(globalConfiguration.shouldAssetsRemove, true);
clearMicroApps();
}
# emp源码
emp实现方式完全不同于类single-spa的方案,其是利用的webpack5的模块联邦机制,实现模块与模块之间的共享调用,YY的大佬们基于ts的xxx.d.ts的共享传递,实现了类似微服务的service mesh的功能,emp提供了完整的脚手架功能
# emp-cli
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = (env, config, {analyze, empEnv, ts, progress, createName, createPath, hot}) => {
const isDev = env === 'development'
const conf = {
plugin: {
mf: {
plugin: ModuleFederationPlugin,
args: [{}],
},
},
}
if (ts) {
createName = createName || 'index.d.ts'
createPath = createPath ? resolveApp(createPath) : resolveApp('dist')
conf.plugin.tunedts = {
plugin: TuneDtsPlugin,
args: [
{
output: path.join(createPath, createName),
path: createPath,
name: createName,
isDefault: true,
},
],
}
}
config.merge(conf)
}
# emp-tune-dts-plugin
function tuneType(createPath, createName, isDefault, operation = emptyFunc) {
// 获取 d.ts 文件
const filePath = `${createPath}/${createName}`
const fileData = fs.readFileSync(filePath, {encoding: 'utf-8'})
let newFileData = ''
newFileData = fileData
isDefault && (newFileData = defaultRepalce(fileData))
// }
newFileData && (newFileData = operation(newFileData) ? operation(newFileData) : newFileData)
// 覆盖原有 index.d.ts
fs.writeFileSync(filePath, newFileData, {encoding: 'utf-8'})
}
class TuneDtsPlugin {
constructor(options) {
this.options = options || {}
}
apply(compiler) {
const _options = this.options
console.log('------------TuneDtsPlugin Working----------')
if (compiler.options.output.path) {
_options.path = compiler.options.output.path
_options.output = `${compiler.options.output.path}/${_options.name}`
}
compiler.hooks.afterEmit.tap(plugin, function () {
setTimeout(function () {
generateType(_options)
})
})
}
}
# piral源码
piral是一个基于react的微前端框架,其定义了两个概念:一个是Piral,这是给一个应用的壳子(application shell),其承载着各种应用,当然也包括由pilets构建共享的组件,定义这些应用何时被加载以及何时被集成;另一个是Pilet,这是一个特殊的模块(feature modules),其承载着不同的一应用,包含着独立的资源,其决定了组件的加载时机。Piral通过对加了一层pilets而进行资源的隔离,对没有加这一层的则可进行数据的共享
# piral
// render.tsx
export function renderInstance(options?: PiralRenderOptions) {
return runInstance((app, selector) => render(app, getContainer(selector)), options);
}
// run.tsx
export function runInstance(runner: PiralRunner, options: PiralRenderOptions = {}) {
const { selector = '#app', settings, layout, errors, middleware = noChange, ...config } = options;
const { app, instance } = getAppInstance(middleware(config), { settings, layout, errors });
runner(app, selector);
return instance;
}
# piral-base
// dependency.ts
export function compileDependency(
name: string,
content: string,
link = '',
dependencies?: AvailableDependencies,
): Promise<PiletApp> {
const app = evalDependency(name, content, link, dependencies);
return checkPiletAppAsync(name, app);
}
// fetch.ts
export function defaultFetchDependency(url: string) {
return fetch(url, {
method: 'GET',
cache: 'force-cache',
}).then((m) => m.text());
}
# piral-core
// actions
export function initialize(ctx: GlobalStateContext, loading: boolean, error: Error | undefined, modules: Array<Pilet>) {
ctx.dispatch((state) => ({
...state,
app: {
...state.app,
error,
loading,
},
modules,
}));
}
export function injectPilet(ctx: GlobalStateContext, pilet: Pilet) {
ctx.dispatch((state) => ({
...state,
modules: replaceOrAddItem(state.modules, pilet, (m) => m.name === pilet.name),
registry: removeNested<RegistryState, BaseRegistration>(state.registry, (m) => m.pilet === pilet.name),
}));
ctx.emit('unload-pilet', {
name: pilet.name,
});
}
export function setComponent<TKey extends keyof ComponentsState>(
ctx: GlobalStateContext,
name: TKey,
component: ComponentsState[TKey],
) {
ctx.dispatch((state) => ({
...state,
components: withKey(state.components, name, component),
}));
}
export function setErrorComponent<TKey extends keyof ErrorComponentsState>(
ctx: GlobalStateContext,
type: TKey,
component: ErrorComponentsState[TKey],
) {
ctx.dispatch((state) => ({
...state,
errorComponents: withKey(state.errorComponents, type, component),
}));
}
export function setRoute<T = {}>(
ctx: GlobalStateContext,
path: string,
component: ComponentType<RouteComponentProps<T>>,
) {
ctx.dispatch((state) => ({
...state,
routes: withKey(state.routes, path, component),
}));
}
export function includeProvider(ctx: GlobalStateContext, provider: JSX.Element) {
const wrapper: React.FC = (props) => cloneElement(provider, props);
ctx.dispatch((state) => ({
...state,
provider: !state.provider ? wrapper : (props) => createElement(state.provider, undefined, wrapper(props)),
}));
}
// createGlobalState.ts
export function createGlobalState(customState: NestedPartial<GlobalState> = {}) {
const defaultState: GlobalState = {
app: {
error: undefined,
loading: typeof window !== 'undefined',
layout: 'desktop',
},
components: {
ErrorInfo: DefaultErrorInfo,
LoadingIndicator: DefaultLoadingIndicator,
Router: BrowserRouter,
Layout: DefaultLayout,
},
errorComponents: {},
registry: {
extensions: {},
pages: {},
wrappers: {},
},
routes: {},
data: {},
portals: {},
modules: [],
};
return Atom.of(extend(defaultState, customState));
}
# 总结
微前端落地实践方案很多,想了解更多框架的同学,可以看这篇文章2020 非常火的 11 个微前端框架。微前端的本质在于资源的隔离与共享,这里的颗粒度既可以是应用,也可以是模块,或者是自己定义的抽象层,这些都是为了更好的“高内聚,低耦合”。正如“软件工程中没有银弹”所说的那样,不存在一种通式通解能够一下解决所有问题,只有结合具体业务,选择合适的技术方案,才能最大限度的发挥架构的作用,切勿为了微而微!
# 参考
- 【第1917期】微前端在小米 CRM 系统的实践
- 基于 qiankun 的微前端最佳实践(万字长文) - 从 0 到 1 篇
- 【PPT】@张克军:微前端架构体系
- 【第1728期】每日优鲜供应链前端团队微前端改造
- 实施前端微服务化的六七种方式
- 微前端自检清单
- 【第2154期】EMP微前端解决方案
- 基于 qiankun 的微前端最佳实践(图文并茂) - 应用间通信篇
- 【第1789期】使用 Angular 打造微前端架构的 ToB 企业级应用
- 【第1709期】可能是你见过最完善的微前端解决方案
- 微前端在美团外卖的实践
- Bifrost微前端框架及其在美团闪购中的实践
- 爱奇艺号微前端架构实践
- 前端微服务在字节跳动的打磨与应用
- 用微前端的方式搭建类单页应用
- 基于 React 的微前端:Piral 简析