微前端簡介
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends
前端是一種多個團隊通過獨立發佈功能的方式來共同構建現代化 web 應用的技術手段及方法策略。
在當前紛繁複雜的微前端框架中, qiankun
是使用量最多的框架,今天就來淺淺分析下他的運行原理
single-spa
首先我們需要知道的是 qiankun
是基於 single-spa
的封裝框架,所以在這之前可以先了解下他是做什麼的
single-spa
僅僅是一個子應用生命週期的調度者。single-spa
爲應用定義了 boostrap
, load
, mount
, unmount
四個生命週期回調:
single-spa 相關可查看我的另一篇文章: Single-spa 源碼淺析
例子
我們先從 qiankun
的整體 API
啓動來入手:
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'react app', // app name registered
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
},
]);
start();
這便是 qiankun
在主應用最簡單的啓動方案
- 註冊子應用
- 啓動
註冊子應用
我們先看 registerMicroApps
做了什麼
import { registerApplication } from 'single-spa';
// 一個空的 promise
const frameworkStartedDefer = new Deferred<void>();
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// 過濾,保證 name 的唯一
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
// microApps 是緩存的數據, 默認[]
microApps = [...microApps, ...unregisteredApps];
// 針對每一個微應用,進行循環註冊, registerApplication 是 single-spa 的方法
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
// 註冊微應用
registerApplication({
name,
app: async () => {
// 這是 single-spa 用來加載微應用的函數,這裏 qiankun 對他做了下包裝
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,
});
});
}
這裏說下 qiankun
新增的 API
loader
- (loading: boolean) => void
- 可選,loading
狀態發生變化時會調用的方法。
其他的都是 single-spa
自身的 API
,不再贅述。
在加載微應用時, 最核心的函數 loadApp
:
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)()
此函數主要分爲三個部分:
async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {}, // 看成 qiankun 的一個全局配置
lifeCycles?: FrameworkLifeCycles<T>, // 註冊時的生命週期傳遞
): Promise<ParcelConfigObjectGetter>
1. 基礎配置
const {entry, name: appName} = app; // app 中的參數獲取
// 存儲在 __app_instance_name_map__ 上的 全局唯一名稱 根據次數生成名字
const appInstanceId = genAppInstanceIdByName(appName);
const markName = `[qiankun] App ${appInstanceId} Loading`;
const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration; // 從全局配置中取值, 附加默認值
// import-html-entry 庫提供的方法
// 獲取入口的 html 內容和 script
const {template, execScripts, assetPublicPath, getExternalScripts} = await importEntry(entry, importEntryOpts);
// 觸發外部腳本加載以確保所有資源準備好
await getExternalScripts();
// singular 是否爲單實例場景,單實例指的是同一時間只會渲染一個微應用。默認爲 true。
// boolean | ((app: RegistrableApp<any>) => Promise<boolean>);
// 相當於加了一個 flag, 保證 start 的執行
// https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
// 在 html 中添加 qiankun 特有的 html 標籤屬性, 返回模板 html
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
// 樣式是否嚴格隔離
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox);
// 根據模板和對應配置創建 html 元素
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 生成渲染函數
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加載設置應用可見區域 dom 結構
// 確保每次應用加載前容器 dom 結構已經設置完畢
render({element: initialAppWrapperElement, loading: true, container: initialContainer}, 'loading');
// 簡單來說看做一個 document.getElementById 即可
const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement,
);
// 默認的一些參數值
let global = globalContext;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
// 沙箱的快速模式,默認開啓,官網文檔還沒更新介紹
const speedySandbox = typeof sandbox === 'object' ? sandbox.speedy !== false : true;
let sandboxContainer;
// 是否開啓了沙箱
if (sandbox) {
sandboxContainer = createSandboxContainer(
appInstanceId,
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox,
);
// 用沙箱的代理對象作爲接下來使用的全局對象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
// 合併參數
const {
beforeUnmount = [],
afterUnmount = [],
afterMount = [],
beforeMount = [],
beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));
2. hook 以及代碼執行
// 執行 beforeLoad 生命週期
await execHooksChain(toArray(beforeLoad), app, global);
// 正式執行代碼,獲取生命週期
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
// 從 script 導出中執行生命週期
const {bootstrap, mount, unmount, update} = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
// qiankun 提供的一個全局狀態,方便 loading 等狀態的控制
const {onGlobalStateChange, setGlobalState, offGlobalStateChange}: Record<string, CallableFunction> =
getMicroAppStateActions(appInstanceId);
3,返回函數的邏輯
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {}, // 看成 qiankun 的一個全局配置
lifeCycles?: FrameworkLifeCycles<T>, // 註冊時的生命週期傳遞
): Promise<ParcelConfigObjectGetter> {
// ...
// 返回值: 獲取配置的一個函數
const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;
// 一個配置對象, 在各個週期中手動添加需要執行的邏輯
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
async () => {
if (process.env.NODE_ENV === 'development') {
// dev 場景下的判斷,忽略
}
},
async () => {
// 同上, 保證所有微應用執行了
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
return undefined;
},
// 在子應用 mount 之前初始化包裝元素
async () => {
appWrapperElement = initialAppWrapperElement;
appWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => appWrapperElement,
);
},
// 添加 mount hook, 確保每次應用加載前容器 dom 結構已經設置完畢
async () => {
const useNewContainer = remountContainer !== initialContainer;
if (useNewContainer || !appWrapperElement) {
// 元素在卸載後將被銷燬,如果它不存在,需要重新創建它
appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId); // 看做一個容器 div 即可
syncAppWrapperElement2Sandbox(appWrapperElement);
}
// 渲染
render({element: appWrapperElement, loading: true, container: remountContainer}, 'mounting');
},
mountSandbox,
// 執行 beforeMount 聲明週期
async () => execHooksChain(toArray(beforeMount), app, global),
// 子應用的 mount export
async (props) => mount({...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange}),
// 切換 loading 在 mounted
async () => render({element: appWrapperElement, loading: false, container: remountContainer}, 'mounted'),
async () => execHooksChain(toArray(afterMount), app, global),
// 在應用程序安裝後初始化 添加 prevAppUnmountedDeferred 的 promise 事件
async () => {
if (await validateSingularMode(singular, app)) {
prevAppUnmountedDeferred = new Deferred<void>();
}
},
async () => {
if (process.env.NODE_ENV === 'development') {
// dev 場景下的判斷,忽略
}
},
],
unmount: [
// unmount 的一些事件執行,和 mount 類似
// ...
],
};
if (typeof update === 'function') {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
}
到這裏 single-spa
的 registerApplication
註冊階段已完成
那麼註冊完就是啓動: start
啓動
function start(opts: FrameworkConfiguration = {}) {
// 參數合併,取值
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const { prefetch, urlRerouteOnly = defaultUrlRerouteOnly, ...importEntryOpts } = frameworkConfiguration
// 如果有預加載策略
// prefetch - boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] }) - 可選,是否開啓預加載,默認爲 true。
// 配置爲 true 則會在第一個微應用 mount 完成後開始預加載其他微應用的靜態資源
// 配置爲 'all' 則主應用 start 後即開始預加載所有微應用靜態資源
// 配置爲 string[] 則會在第一個微應用 mounted 後開始加載數組內的微應用資源
// 配置爲 function 則可完全自定義應用的資源加載時機 (首屏應用及次屏應用)
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 判斷是否支持 Proxy 或者一些其他配置 ,不支持則自動降級, 設置 { loose: true } 或者 { speedy: false }
// frameworkConfiguration 是全局變量
frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);
// 來自 single-spa 的 API
startSingleSpa({ urlRerouteOnly });
started = true; // flag 設置
// 空 promise 正式添加狀態,原本都是掛載,處於 await 狀態
frameworkStartedDefer.resolve();
}
關於沙箱
在創建沙箱時會根據 start
時的判斷切換沙箱模式:
if (window.Proxy) {
sandbox = useLooseSandbox
? new LegacySandbox(appName, globalContext)
: new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox });
} else {
sandbox = new SnapshotSandbox(appName);
}
他們之間的具體區別,已經在沙箱一文中講述了:淺析微前端沙箱
小結
在 qiankun
中,總共做了以下幾件事情:
- 基於
single-spa
封裝,提供了更加開箱即用的API
。 - 技術棧無關,任意技術棧的應用均可 使用/接入。
HTML Entry
,保持技術站的。- 樣式隔離。
JS
沙箱,確保微應用之間 全局變量/事件 不衝突。- 資源預加載。
以組件的方式使用微應用
import { loadMicroApp } from 'qiankun';
// do something
const container = document.createElement('div');
const microApp = loadMicroApp({ name: 'app', container, entry: '//micro-app.alipay.com' });
// do something and then unmount app
microApp.unmout();
// do something and then remount app
microApp.mount();
通過這個 API
我們可以自己去控制一個微應用加載/卸載
我們先來看看 loadMicroApp
他做了什麼:
export function loadMicroApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration?: FrameworkConfiguration & { autoStart?: boolean },
lifeCycles?: FrameworkLifeCycles<T>,
): MicroApp {
const { props, name } = app;
const container = 'container' in app ? app.container : undefined;
// 通過 document.querySelector 獲取到 DOM
const containerXPath = getContainerXPath(container);
const appContainerXPathKey = `${name}-${containerXPath}`;
let microApp: MicroApp;
// 同一個容器上安裝了多個微應用,但我們必須等到前面的實例全部卸載,否則會導致一些併發問題
const wrapParcelConfigForRemount = (config: ParcelConfigObject): ParcelConfigObject => {
//...
};
/**
* 使用名稱+容器xpath作爲微應用實例ID,這意味着如果將微應用渲染到之前已經渲染過的dom,則微應用將不會再次加載和評估其生命週期
*/
const memorizedLoadingFn = async (): Promise<ParcelConfigObject> => {
//...
};
if (!started && configuration?.autoStart !== false) {
// 我們需要調用 single-spa 的 start 方法,因爲當主應用程序自動調用 pushStatereplaceState 時應該調度 popstate 事件,但在 single-spa 中,它會在調度 popstate 之前檢查啓動狀態
startSingleSpa({ urlRerouteOnly: frameworkConfiguration.urlRerouteOnly ?? defaultUrlRerouteOnly });
}
// single-spa 的 API 之間在 dom 上渲染
microApp = mountRootParcel(memorizedLoadingFn, { domElement: document.createElement('div'), ...props });
if (container) {
if (containerXPath) {
// 緩存
const microAppsRef = containerMicroAppsMap.get(appContainerXPathKey) || [];
microAppsRef.push(microApp);
containerMicroAppsMap.set(appContainerXPathKey, microAppsRef);
const cleanup = () => {
const index = microAppsRef.indexOf(microApp);
microAppsRef.splice(index, 1);
microApp = null;
};
// 添加 unMount 的清理函數
microApp.unmountPromise.then(cleanup).catch(cleanup);
}
}
return microApp;
}
這種使用方式就是 Widget
級別的使用,可以完美替代組件形式, 這裏提供下官方的比較:
npm 包分發業務組件背後的工程問題
在以前,我們經常通過發佈 npm
包的方式複用/共享我們的業務組件,但這種方式存在幾個明顯的問題:
- npm 包的更新下發需要依賴產品重新部署纔會生效
- 時間一長就容易出現依賴產品版本割裂導致的體驗不一致
- 無法灰度
- 技術棧耦合
說白了就是 npm
包這種靜態的共享方式,喪失了動態下發代碼的能力,導致了其過慢的工程響應速度,這在現在雲服務流行的時代就會顯得格外扎眼。
而微前端這種純動態的服務依賴方式,恰好能方便的解決上面的問題:被依賴的微應用更新後的產物,在產品刷新後即可動態的獲取到,如果我們在微應用加載器中再輔以灰度邏輯,那麼動態更新帶來的變更風險也能得到有效的控制。
新的 UI 共享模式
在以前,如果我們希望複用一個站點的局部 UI
,幾乎只有這樣一條路徑:從業務系統中抽取出一個組件 -> 發佈 npm 包 -> 調用方使用 npm 包。
且不說前面提到的 npm
自身的問題,單單是從一個已有的業務系統中抽取出一個 UI
組件這個事情,都可能夠我們喝一壺的了。我們不僅要在物理層面,將這部分代碼抽取成若干個單獨的文件,同時還要考慮如何跟已有的系統做上下文解耦,這類工作出現在一個越是年代久遠的項目上,實施起來就越是困難,做過這類事情的同學應該都會深有體會。
總結
qiankun
作爲一個微前端的框架來說,還是較爲輕量級的,能把大部分技術棧連接到一起,當然目前也存在着一些需要使用者自己去 cover 的場景,比如 localStorage
、cookie
的隔離等等, 在微前端的框架選擇上,他是一個不錯的嘗試