聊一款簡單且精妙的微前端框架 ice stark(上)
highlight: arduino-light theme: nico
這個系列計劃分上下兩篇文章,上篇主要交代文章背景和最關鍵的對 ice stark 整個框架的原理和執行流程進行分析。工欲善其事,必先利其器。對專案使用的技術知根知底是基本素養,瞭解原理才能完美應對各種 bug 場景、各種業務需求。
下篇會先從開發規範進行講解,因為微前端目前的一些痛點都可以通過規範去約束,所以開發規範尤其地重要。然後是本地開發的相關配置和使用、生產環境的配置和使用、在開發過程中會遇到的各種問題,以及對一些擴充套件能力都會提供對應的分析過程和解決結果。內容比較多,在寫作上耗時會較長所以放後面了。
業務背景
公司要做一個可觀測平臺的產品,需要將監控領域的五大產品融合成一個端到端的全鏈路平臺。重點是要保證五個產品能夠獨立進行迭代、出售的同時,在互動體驗、甚至功能上融為一體。
技術選型
提到對大型系統的解耦和多個團隊的協作模式,第一想法就是當今大行其道的微前端了。
微前端體系能夠很好的解決了這種大型應用的分工,同時沒有 iframe 那種”完全隔離“的割裂感,無論是給開發團隊還是給使用者都能帶來不錯的體驗。市面上有很多的微前端框架,為什麼選擇 ice stark呢?主要基於以下幾點
1、使用簡單,子應用改造成本小。ice stark 對子應用的侵入幾乎可以忽略不計。
2、同類的微前端框架(single-spa、qiankun)實現基本差不多,ice stark 的文件寫的很不錯
3、原始碼質量高,整體程式碼思路也特別清晰,這個時候我產生了將它 fork 下來可以由自己團隊內部根據業務進行改造的心思
4、支援 vite,對於 Vue 生態的企業來說是個很棒的訊息,雖然當前業務場景用不到,但後續的擴充套件還是方便的
5、支援微模組,對於一個大型融合應用來說,可以很方便地通過微模組來複用和共享功能元件
前端架構
整個體系的結構比較純粹,在五個子應用能夠獨立迭代開發的基礎上,增加一個主應用作為基座應用,連線不同子應用的功能的同時,給使用者提供統一的互動感。當然,主應用給使用者保持統一的互動體驗感應該是這個體系中相對重要的部分。
ice stark 簡介
說起微前端,大家可能對螞蟻的 qiankun 耳熟能詳。其實在阿里內部早在微前端這個概念出現之前,就有對應的解決方案誕生了,那就是 ice stark。Ice stark 是由 ice 團隊開源的一款面向大型系統的微前端解決方案,已經服務於阿里巴巴內外部300+應用
同時具備微前端解決方案傳統意義上的所有功能
當然,最令人欣喜的是它的微模組部分:提供了很好的跨子應用共享模組的能力
基本使用
ice stark 使用的是主應用 + 子應用的模式,子應用用於提供自身所負責的業務能力;主應用用來控制系統整體佈局和配置、註冊所有的微應用,連線所有子應用的同時負責給使用者帶來統一的視覺與互動,所以微前端體系前端有個很重要的工作就是維護統一 & 模糊主子應用之間的邊界感,這個主要放在下篇講解。
主應用的接入十分簡單,只有三步:
-
提供一個 DOM 節點作為子應用渲染的節點
<div id="ice-container"></div>
-
註冊子應用,主要是提供子應用的服務地址、路由、應用名稱等資訊
ts import { registerMicroApps, start } from '@ice/stark' const appContainer = document.getElementById('ice-container') registerMicroApps([ { name: 'app1', activePath: ['/', '/message', '/about'], exact: true, title: '通用頁面', container: appContainer, url: ['//unpkg.com/icestark-child-common/build/js/index.js'], } ])
-
執行
ts start()
子應用的接入只要向外提供mount
和unmount
兩個生命週期函式就可以了
ts
export function mount(props) {
const { container } = props
vue = new Vue({
router,
store,
components: {App},
template: '<App/>'
}).$mount()
container.innerHTML = ''
container.appendChild(vue.$el)
}
export function unmount() {
vue && vue.$destroy()
}
當然這兩個鉤子函式是在生產環境起作用的,如果想在本地開發環境執行,可以增加以下配置:
ts
import { isInIcestark, getMountNode, registerAppEnter, registerAppLeave, setLibraryName } from '@ice/stark-app'
// isInIcestark 用於區分當前應用的執行環境
if (!isInIcestark()) {
new Vue({
el: '#app',
router,
store,
components: {App},
template: '<App/>'
})
} else {
// 獲取主應用暴露出的 DOM 節點
const mountNode = getMountNode()
registerAppEnter(() => {
vue = new Vue({
router,
store,
components: {App},
template: '<App/>'
}).$mount()
// 掛載前先清空下,避免被上一個子應用汙染檢視
mountNode.innerHTML = ''
mountNode.appendChild(vue.$el)
})
registerAppLeave(() => {
vue && vue.$destroy()
})
}
可以看到,ice stark 的核心實現就是直接將子應用的 vue 例項渲染到主應用暴露出的 DOM 節點上面去。
從全域性視角分析,整體流程可以大致拆分成註冊子應用 => 劫持路由 => 獲取子應用資源 => 執行掛載四大部分
原始碼 & 執行流程詳解
這部分主要是對ice stark工作流程的拆解,官方給出的工作流程圖如下:
總結一下就是:通過劫持當前路由去匹配到對應微應用後,通過fetch/script/import其中一種方式獲取微應用的靜態資源(js、css等),然後將微應用渲染到主應用的指定區域當中。
接下來,我們從原始碼層面對其中原理進行探討:
一、註冊子應用
上文有提到過,主應用的配置只有裡簡單兩步:註冊子應用然後呼叫start()
方法就能運行了。首先我們就來看看註冊子應用的方法registerMicroApps
:
ts
export function registerMicroApps(appConfigs: AppConfig[], appLifecyle?: AppLifecylceOptions) {
appConfigs.forEach((appConfig) => {
registerMicroApp(appConfig, appLifecyle);
});
}
遍歷子應用的配置項,然後呼叫registerMicroApp
對每一個子應用的配置進行處理
``ts
export function registerMicroApp(appConfig: AppConfig, appLifecyle?: AppLifecylceOptions) {
// 通過 name 屬性校驗子應用是否已經被註冊,已註冊的話丟擲異常
if (getAppNames().includes(appConfig.name)) {
throw Error(
name ${appConfig.name} already been regsitered`);
}
const { activePath, hashType = false, exact = false, sensitive = false, strict = false } = appConfig;
// 格式化子應用需要啟用的路由列表
const activePathArray = formatPath(activePath, {
hashType,
exact,
sensitive,
strict,
});
const { basename: frameworkBasename } = globalConfiguration;
// 標記當前的路由是否在該子應用路由列表中
const findActivePath = findActivePathCurry(mergeFrameworkBaseToPath(activePathArray, frameworkBasename));
const microApp = {
// 新增子應用狀態引數
status: NOT_LOADED,
...appConfig,
appLifecycle: appLifecyle,
findActivePath,
};
// 將標準化後的子應用物件推入microApp陣列 microApps.push(microApp); } ```
註冊子應用的目的就是根據使用者輸入的子應用的配置,通過格式化路由、新增status狀態標記等轉換成內部使用的標準化格式,方便後續的處理。這一步對應的流程圖如下:
二、start(options)
start方法可以傳入一些配置引數,包含一些常見的 Hooks 以及自定義配置:
ts
onAppEnter?: (appConfig: AppConfig) => void; // 微應用渲染前的回撥(選填)
onAppLeave?: (appConfig: AppConfig) => void; // 微應用解除安裝前的回撥(選填)
onLoadingApp?: (appConfig: AppConfig) => void; // 微應用開始載入的回撥(選填)
onFinishLoading?: (appConfig: AppConfig) => void; // 微應用結束載入的回撥(選填)
onError?: (err: Error) => void; // 微應用載入過程發生錯誤的回撥(選填)
onActiveApps?: (appConfigs: AppConfig[]) => void; // 微應用開始被啟用的回撥(選填)
fetch?: Fetch; // 自定義 fetch(選填)
shouldAssetsRemove?: (
assetUrl?: string,
element?: HTMLElement | HTMLLinkElement | HTMLStyleElement | HTMLScriptElement,
) => boolean; // 判斷頁面資源是否持久化保留(選填)
onRouteChange?: (
url: string,
pathname: string,
query: object,
hash?: string,
type?: RouteType | 'init' | 'popstate' | 'hashchange',
) => void; // 頁面路由變化會觸發的鉤子
prefetch?: Prefetch; // 預載入微應用資源(選填)
basename?: string; // 微應用路由匹配統一新增 basename,選填
整個start()
方法很簡潔,也就二十多行,按程式碼邏輯分,小小的start方法一共做了七件事:
樣式快取的配置儲存到全域性、避免重複呼叫、標記主應用資源、更新全域性配置項、預載入子應用、路由劫持及初始化子應用
```ts function start(options?: StartConfiguration) { // 1、樣式快取的配置儲存到全域性 if (options?.shouldAssetsRemove && !temporaryState.shouldAssetsRemoveConfigured) { temporaryState.shouldAssetsRemoveConfigured = true; }
// 2、避免重複呼叫 if (started) { console.log('icestark has been already started'); return; } started = true;
// 3、標記主應用資源 recordAssets();
// 4、更新全域性配置項 globalConfiguration.reroute = reroute; Object.keys(options || {}).forEach((configKey) => { globalConfiguration[configKey] = options[configKey]; });
// 5、預載入子應用 const { prefetch, fetch } = globalConfiguration; if (prefetch) { doPrefetch(getMicroApps(), prefetch, fetch); }
// 6、路由劫持 hijackHistory(); hijackEventListener();
// 7、初始化子應用 globalConfiguration.reroute(location.href, 'init'); } ```
1、樣式快取的配置儲存到全域性
首先會判斷傳參中是否配置了shouldAssetsRemove
,配置了就將它儲存到臨時的全域性變數temporaryState
中
ts
// See http://github.com/ice-lab/icestark/issues/373#issuecomment-971366188
// todos: remove it from 3.x
if (options?.shouldAssetsRemove && !temporaryState.shouldAssetsRemoveConfigured) {
temporaryState.shouldAssetsRemoveConfigured = true;
}
從功能上說,shouldAssetsRemove
是為了提供快取樣式的能力,但實際體驗上會有樣式閃爍甚至樣式錯亂的問題,不建議開啟。註釋說會在 3.x版本移除這個配置項,不過3.x版本什麼時候釋出咱也不敢問。
2、避免重複呼叫
接著會根據started
的值判斷是否啟動過,啟動過則直接 return,避免重複呼叫
ts
if (started) {
console.log('icestark has been already started');
return;
}
started = true;
不過我覺得這裡用console.warn
好一點,因為出現重複呼叫start()
方法的唯一場景應該就是程式碼寫出問題了,警告一下比較好。
3、標記主應用資源
```ts recordAssets();
export function recordAssets(): void { // getElementsByTagName is faster than querySelectorAll const assetsList = getAssetsNode(); assetsList.forEach((assetsNode) => { setStaticAttribute(assetsNode); }); } ```
Tips
這裡我們可以從原始碼的註釋裡學習到程式碼的優化技巧:
// getElementsByTagName is faster than querySelectorAll
”getElementsByTagName 比 querySelectorAll 快“,但是為什麼呢?
因為使用
getElementsByTagName
方法我們得到的結果就像是一個物件的索引,而通過querySelectorAll
方法我們得到的是一個物件的克隆,當物件資料量越大,克隆帶來的消耗就會越大。具體可以檢視這篇文章Why is getElementsByTagName() faster than querySelectorAll()?
我們來拆解下recordAssets
ts
export const PREFIX = 'icestark';
export const DYNAMIC = 'dynamic';
export const STATIC = 'static';
export function getAssetsNode(): Array<HTMLStyleElement|HTMLScriptElement> {
let nodeList = [];
['style', 'link', 'script'].forEach((tagName) => {
nodeList = [...nodeList, ...Array.from(document.getElementsByTagName(tagName))];
});
return nodeList;
}
export function setStaticAttribute(tag: HTMLStyleElement | HTMLScriptElement): void {
if (tag.getAttribute(PREFIX) !== DYNAMIC) {
tag.setAttribute(PREFIX, STATIC);
}
tag = null;
}
此時,並沒有載入子應用相關資源,所以recordAssets
是給主應用的style
,link
, script
標籤新增上icestark="static"
的屬性(icestark="dynamic"
的除外),如下圖所示
因為主應用的資源是穩定的,載入完成後基本就不會變化了;相對來說,子應用的資源就屬於”動態的“了
但其實根據 start 的流程執行到這一步是還沒有icestark="dynamic"
的元素存在的,哈哈。不過加上if (tag.getAttribute(PREFIX) !== DYNAMIC)
判斷應該是保證職業的嚴謹,後面的手動釋放變數tag = null
也值得我們學習。
4、更新全域性配置項
ts
// 將路由劫持方法放到全域性配置物件 globalConfiguration 當中
globalConfiguration.reroute = reroute;
// 將 start 中的配置項放到全域性配置物件 globalConfiguration 當中
Object.keys(options || {}).forEach((configKey) => {
globalConfiguration[configKey] = options[configKey];
});
個人覺得這裡的寫法可以優化下更好一點:options 存在才遍歷,而不是為了避免遍歷出錯而宣告個空物件
options && Object.keys(options).forEach(...);
5、預載入子應用
當配置項中開啟prefetch
引數時,會執行預載入
ts
const { prefetch, fetch } = globalConfiguration;
if (prefetch) {
doPrefetch(getMicroApps(), prefetch, fetch);
}
老規矩,拆解一下這個方法 getMicroApps
+ doPrefetch
ts
export function getMicroApps() {
return microApps;
}
getMicroApps
就是去拿第一步格式化好的子應用陣列,然後將子應用陣列塞進doPrefetch
中
```ts export function doPrefetch( apps: MicroApp[], prefetchStrategy: Prefetch, fetch: Fetch, ) { const executeAllPrefetchTasks = (strategy: (app: MicroApp) => boolean) => { getPrefetchingApps(apps)(strategy) .forEach(prefetchIdleTask(fetch)); };
if (Array.isArray(prefetchStrategy)) { executeAllPrefetchTasks(names2PrefetchingApps(prefetchStrategy)); return; } if (typeof prefetchStrategy === 'function') { executeAllPrefetchTasks(prefetchStrategy); return; } if (prefetchStrategy) { executeAllPrefetchTasks((app) => app.status === NOT_LOADED || !app.status); } } ```
配置 prefetch
時有三種類型選擇:Boolean | string[] | Function(app)
,所以doPrefetch
中的判斷分支邏輯是針對不同型別做的處理,這裡就不分開講解了,我們只需要關注核心的executeAllPrefetchTasks
方法:
```ts const executeAllPrefetchTasks = (strategy: (app: MicroApp) => boolean) => { getPrefetchingApps(apps)(strategy) .forEach(prefetchIdleTask(fetch)); };
function prefetchIdleTask(fetch = window.fetch) { return (app: MicroApp) => { window.requestIdleCallback(async () => { const { url, entry, entryContent, name } = app; const { jsList, cssList } = url ? getUrlAssets(url) : await getEntryAssets({ entry, entryContent, assetsCacheKey: name, fetch, }); window.requestIdleCallback(() => fetchScripts(jsList, fetch)); window.requestIdleCallback(() => fetchStyles(cssList, fetch)); }); }; } ```
window.requestIdleCallback
會在瀏覽器空閒時間執行其中的回撥函式,作為一個”預載入“功能,這無疑是一個很好的實現方式。原始碼中也很貼心地提供了該方法的 polyfill
window.requestIdleCallback = window.requestIdleCallback || function (cb) { const start = Date.now(); return setTimeout(() => { cb({ didTimeout: false, timeRemaining() { return Math.max(0, 50 - (Date.now() - start)); }, }); }, 1); };
window.requestIdleCallback
的回撥函式中,會通過我們配置的子應用中的 url 或者 entry 去獲取子應用的 js 和 css 列表,再通過fetchScripts
和fetchStyles
載入子應用的js、css資源
``ts
export function fetchScripts(jsList: Asset[], fetch: Fetch = defaultFetch) {
return Promise.all(jsList.map((asset) => {
const { type, content } = asset;
if (type === AssetTypeEnum.INLINE) {
return content;
} else {
return cachedScriptsContent[content]
|| (cachedScriptsContent[content] = fetch(content)
.then((res) => res.text())
.then((res) =>
${res} \n //# sourceURL=${content}`)
);
}
}));
}
export function fetchStyles(cssList: Asset[], fetch: Fetch = defaultFetch) { return Promise.all( cssList.map((asset) => { const { type, content } = asset; if (type === AssetTypeEnum.INLINE) { return content; } return cachedStyleContent[content] || (cachedStyleContent[content] = fetch(content).then((res) => res.text())); }), ); } ```
這兩個方法實現的很巧妙,先使用 fetch API 請求對應的資源,然後儲存在變數cachedStyleContent
中,遇到下一次呼叫這個方法獲取資源時,就可以直接從cachedStyleContent
獲取了。
看完預載入的完整實現邏輯,你應該就瞭解了:這裡的預載入只能提升非首屏首次載入的子應用渲染速度
6、路由劫持
ice stark 的核心就是劫持路由去匹配對應的子應用,然後載入子應用的資源並渲染到主應用當中去。
讓我們對路由劫持的實現一探究竟:
```ts // hajack history & eventListener hijackHistory(); hijackEventListener();
globalConfiguration.reroute(location.href, 'init'); ```
發現個小錯誤:註釋中的 hijack 拼錯了 (°ー°〃)
這裡一共做了三步處理:劫持 history,劫持事件偵聽器和初始化路由。初始化路由部分程式碼較多就拆到下一部分說了。
hijackHistory
hijackHistory
用來劫持 window.history
中的pushState
和replaceState
,以及監聽路由變化的popstate
和hashchange
兩個方法
```ts const originalPush: OriginalStateFunction = window.history.pushState; const originalReplace: OriginalStateFunction = window.history.replaceState;
const hijackHistory = (): void => { window.history.pushState = (state: any, title: string, url?: string, ...rest) => { originalPush.apply(window.history, [state, title, url, ...rest]); const eventName = 'pushState'; handleStateChange(createPopStateEvent(state, eventName), url, eventName); };
window.history.replaceState = (state: any, title: string, url?: string, ...rest) => { originalReplace.apply(window.history, [state, title, url, ...rest]); const eventName = 'replaceState'; handleStateChange(createPopStateEvent(state, eventName), url, eventName); };
window.addEventListener('popstate', urlChange, false); window.addEventListener('hashchange', urlChange, false); }; ```
pushState
和replaceState
是 HTML5 中history
提供的 API。他們用於操作瀏覽器歷史棧,能夠在不載入頁面的情況下改變瀏覽器的URL。hash 模式的路由變化會觸發
hashchange
事件,history 模式的路由變化會觸發popstate
事件。
所以無論路由怎樣玩著花地變化,都能在這裡得到照顧。
我麼可以再深入點看看細節:
看看重寫的window.history.pushState
和`window.history.replaceState
做了什麼額外操作
ts
handleStateChange(createPopStateEvent(state, eventName), url, eventName);
```ts export function createPopStateEvent(state, originalMethodName) { let evt; try { evt = new PopStateEvent('popstate', { state }); } catch (err) { evt = document.createEvent('PopStateEvent'); evt.initPopStateEvent('popstate', false, false, state); } evt.icestark = true; evt.icestarkTrigger = originalMethodName; return evt; }
const handleStateChange = (event: PopStateEvent, url: string, method: RouteType) => { setHistoryEvent(event); globalConfiguration.reroute(url, method); };
let historyEvent = null; export function setHistoryEvent(evt: PopStateEvent | HashChangeEvent) { historyEvent = evt; } ```
這裡其實做了三件事情:
- 建立一個原生的
popstate
事件 - 使用
historyEvent
變數來記錄這個事件 - 呼叫
reroute
啟用並載入對應子應用,掛載完子應用後會執行儲存在上一步historyEvent
中的事件
監聽hashchange
和popstate
事件執行的方法urlChange
實現的邏輯與上面的也基本一致
ts
const urlChange = (event: PopStateEvent | HashChangeEvent): void => {
setHistoryEvent(event);
globalConfiguration.reroute(location.href, event.type as RouteType);
};
三、reroute(url, type)
start()
方法最後一步就是呼叫 reroute
方法對當前路由對應的子應用進行初始化
```ts globalConfiguration.reroute(location.href, 'init');
let lastUrl = null; 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.findActivePath(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; } ```
拆解一下,
首先宣告一個let lastUrl = null;
用於儲存上一次的 url 路徑
使用urlParse
對當前 url 進行了解析,並獲取pathname
, query
, hash
三個屬性
ts
const { pathname, query, hash } = urlParse(url, true);
Warning
注意:如果使用 params 傳參,比如
this.$router.push({ name: 'xxx', params: { a: 1 } })
就會導致 params 丟失,實測場景也是如此,這塊邏輯應該可以再優化一下
接下來使用當前 url
和 lastUrl
對比,不相同才觸發reroute
的核心邏輯,避免重複呼叫;並在函式末尾更新lastUrl
的值
ts
if (lastUrl !== url) {
// 核心邏輯
...
}
// reoute每被呼叫一次就更新一次lastUrl
lastUrl = url;
核心邏輯中,路由變化意味著需要觸發全域性的onRouteChange
鉤子
ts
globalConfiguration.onRouteChange(url, pathname, query, hash, type);
接著對全部的子應用進行分類
ts
const unmountApps = [];
const activeApps = [];
getMicroApps().forEach((microApp: AppConfig) => {
const shouldBeActive = !!microApp.findActivePath(url);
if (shouldBeActive) {
activeApps.push(microApp);
} else {
unmountApps.push(microApp);
}
});
和當前路由匹配的子應用放activeApps
裡準備啟用,否則放unmountApps
裡準備解除安裝
分完類後就可以對被啟用的子應用呼叫全域性的onActiveApps
鉤子了
ts
globalConfiguration.onActiveApps(activeApps);
最後遍歷unmountApps
陣列將它們統統解除安裝掉,並且對狀態為MOUNTED
和LOADING_ASSETS
的子應用呼叫onAppLeave
鉤子
ts
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();
});
這裡有一定單例模式的思想:每次只啟用和當前路由匹配的一個子應用。可以很好地避免子應用之間的耦合與相互汙染。
解除安裝子應用的邏輯就不贅述了,我們來仔細看看是如何建立並渲染子應用的
ts
await createMicroApp(activeApp);
```ts export async function createMicroApp( app: string | AppConfig, appLifecyle?: AppLifecylceOptions, configuration?: StartConfiguration, ) { // 程式碼太多,省略一些校驗的邏輯,我們關注核心邏輯就好了 // ... const { container, basename, activePath, configuration: userConfiguration, findActivePath } = appConfig;
if (container) { setCache('root', container); }
const { fetch } = userConfiguration; // 部分程式碼省略 // ... switch (appConfig.status) { case NOT_LOADED: case LOAD_ERROR: await loadApp(appConfig); break; case UNMOUNTED: if (!appConfig.cached) { const appendAssets = [ ...(appConfig?.appAssets?.cssList || []), ...(appConfig?.loadScriptMode === 'import' ? filterRemovedAssets(importCachedAssets[appConfig.name] ?? [], ['LINK', 'STYLE']) : []), ];
await loadAndAppendCssAssets(appendAssets, { cacheCss: shouldCacheCss(appConfig.loadScriptMode), fetch, }); } await mountMicroApp(appConfig.name); break; case NOT_MOUNTED: await mountMicroApp(appConfig.name); break; default: break; } return getAppConfig(appName); } ```
1、主子應用共享 DOM
建立子應用時有個最重要的前提:
```ts const { container, basename, activePath, configuration: userConfiguration, findActivePath } = appConfig;
if (container) { setCache('root', container); }
export const setCache = (key: string, value: any): void => { if (!(window as any)[namespace]) { (window as any)[namespace] = {}; } (window as any)[namespace][key] = value; }; ```
這一步就是將主應用中我們丟擲來的那個 DOM 節點給掛到window['root']
上去作為全域性的共享節點,後續就可以呼叫子應用暴露的 mount
方法直接掛載到這個共享節點上面。
我們可以從這裡發現一絲端倪:主子應用之間的資訊共享可以通過window
實現。
2、根據子應用狀態作不同處理
繼續往下,我們先來快速過一遍子應用各個狀態(status
)值的含義:
在註冊子應用的時候,會給所有初始的子應用物件標記狀態為NOT_LOADED
;獲取子應用資源之前,會先將子應用的狀態標記為LOADING_ASSETS
,當資源獲取成功後,會將狀態變更為NOT_MOUNTED
,獲取失敗則標記為LOAD_ERROR
;已經掛載過的子應用被解除安裝狀態會變更為UNMOUNTED
;
所以你應該明白了上面程式碼的大致邏輯
- 沒載入過的子應用會呼叫
loadApp(appConfig)
- 載入過的且被解除安裝了的子應用會先根據
appConfig.cached
快取配置判斷是否重新獲取資源或者直接掛載 - 資源獲取完畢的子應用直接呼叫
mountMicroApp
執行掛載
3、loadApp(appConfig)
loadApp
做了兩件事情:
- 獲取子應用資源
- 呼叫
mountMicroApp
掛載子應用
```ts async function loadApp(app: MicroApp) { const { title, name, configuration } = app;
if (title) { document.title = title; }
updateAppConfig(name, { status: LOADING_ASSETS });
let lifeCycle: ModuleLifeCycle = {}; try { lifeCycle = await loadAppModule(app); // in case of app status modified by unload event if (getAppStatus(name) === LOADING_ASSETS) { updateAppConfig(name, { ...lifeCycle, status: NOT_MOUNTED }); } } catch (err) { configuration.onError(err); log.error(err); updateAppConfig(name, { status: LOAD_ERROR }); } if (lifeCycle.mount) { await mountMicroApp(name); } } ```
獲取子應用資源可以說是載入子應用的核心邏輯了,官方給出的流程圖其實也表明的很清楚了:
子應用資源的獲取可以通過三種方式:
ts
switch (loadScriptMode) {
case 'import':
await loadAndAppendCssAssets([
...appAssets.cssList,
...filterRemovedAssets(importCachedAssets[name] || [], ['LINK', 'STYLE']),
], {
cacheCss,
fetch,
});
lifecycle = await loadScriptByImport(appAssets.jsList);
// Not to handle script element temporarily.
break;
case 'fetch':
await loadAndAppendCssAssets(appAssets.cssList, {
cacheCss,
fetch,
});
lifecycle = await loadScriptByFetch(appAssets.jsList, appSandbox, fetch);
break;
default:
await Promise.all([
loadAndAppendCssAssets(appAssets.cssList, {
cacheCss,
fetch,
}),
loadAndAppendJsAssets(appAssets, { scriptAttributes }),
]);
lifecycle =
getLifecyleByLibrary() ||
getLifecyleByRegister() ||
{};
}
這三種方式調 css 資源的處理方式基本是一致的,使用loadAndAppendCssAssets
:
```ts
export async function loadAndAppendCssAssets(cssList: Array
if (cacheCss) { // ... // 省略部分邏輯 }
// load css content
return await Promise.all(
cssList.map((asset, index) => appendCSS(cssRoot, asset, ${PREFIX}-css-${index}
)),
);
}
```
該方法目的是將子應用的css列表遍歷新增字首屬性icestark-css-${index}
後拼到主應用的head當中去。
js 資源處理方式對應如下:
- import
loadScriptByImport
- fetch
loadScriptByFetch
- script
getLifecyleByLibrary
||getLifecyleByRegister
這些不同的處理方式最終的目的就是獲取到子應用生命週期即匯出的mount
和unmount
方法:
ts
lifecycle = {
mount,
unmount,
};
再將它合併到子應用的配置當中去:
ts
return combineLifecyle(lifecycle, appConfig);
ts
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;
}
4、mountMicroApp(name)
獲取完子應用的資源和生命週期之後,這一步將執行子應用的掛載,為整個流程畫上句號
```ts export async function mountMicroApp(appName: string) { const appConfig = getAppConfig(appName); // check current url before mount const shouldMount = appConfig?.mount && appConfig?.findActivePath(window.location.href);
if (shouldMount) { if (appConfig?.mount) { await appConfig.mount({ container: appConfig.container, customProps: appConfig.props }); } updateAppConfig(appName, { status: MOUNTED }); } } ```
-
先對子應用是否有匯出
mount
方法以及當前啟用路由是否屬於該子應用的進行一個判斷ts const shouldMount = appConfig?.mount && appConfig?.findActivePath(window.location.href);
-
判斷無誤後則直接呼叫子應用的
mount
方法,將子應用渲染到其配置的container
中,並更改子應用狀態為MOUNTED
ts if (appConfig?.mount) { await appConfig.mount({ container: appConfig.container, customProps: appConfig.props }); } updateAppConfig(appName, { status: MOUNTED });
整個微前端流程的實現思想和原理在 Ice stark原始碼中體現的淋漓盡致
我繪製了張流程圖作為最後的總結和提煉:
最後,創作不易,你的贊就是給作者最大的鼓勵~
作者水平有限,如有錯誤或者不嚴謹之處,懇請批評指正~