聊一款簡單且精妙的微前端框架 ice stark(上)

語言: CN / TW / HK

highlight: arduino-light theme: nico


這個系列計劃分上下兩篇文章,上篇主要交代文章背景和最關鍵的對 ice stark 整個框架的原理和運行流程進行分析。工欲善其事,必先利其器。對項目使用的技術知根知底是基本素養,瞭解原理才能完美應對各種 bug 場景、各種業務需求。

下篇會先從開發規範進行講解,因為微前端目前的一些痛點都可以通過規範去約束,所以開發規範尤其地重要。然後是本地開發的相關配置和使用、生產環境的配置和使用、在開發過程中會遇到的各種問題,以及對一些擴展能力都會提供對應的分析過程和解決結果。內容比較多,在寫作上耗時會較長所以放後面了。

image-20220909180556354.png

業務背景

公司要做一個可觀測平台的產品,需要將監控領域的五大產品融合成一個端到端的全鏈路平台。重點是要保證五個產品能夠獨立進行迭代、出售的同時,在交互體驗、甚至功能上融為一體。

技術選型

提到對大型系統的解耦和多個團隊的協作模式,第一想法就是當今大行其道的微前端了。

微前端體系能夠很好的解決了這種大型應用的分工,同時沒有 iframe 那種”完全隔離“的割裂感,無論是給開發團隊還是給用户都能帶來不錯的體驗。市面上有很多的微前端框架,為什麼選擇 ice stark呢?主要基於以下幾點

1、使用簡單,子應用改造成本小。ice stark 對子應用的侵入幾乎可以忽略不計。

2、同類的微前端框架(single-spa、qiankun)實現基本差不多,ice stark 的文檔寫的很不錯

3、源碼質量高,整體代碼思路也特別清晰,這個時候我產生了將它 fork 下來可以由自己團隊內部根據業務進行改造的心思

4、支持 vite,對於 Vue 生態的企業來説是個很棒的消息,雖然當前業務場景用不到,但後續的擴展還是方便的

5、支持微模塊,對於一個大型融合應用來説,可以很方便地通過微模塊來複用和共享功能組件

前端架構

整個體系的結構比較純粹,在五個子應用能夠獨立迭代開發的基礎上,增加一個主應用作為基座應用,連接不同子應用的功能的同時,給用户提供統一的交互感。當然,主應用給用户保持統一的交互體驗感應該是這個體系中相對重要的部分。

image-20221007230711949.png

ice stark 簡介

説起微前端,大家可能對螞蟻的 qiankun 耳熟能詳。其實在阿里內部早在微前端這個概念出現之前,就有對應的解決方案誕生了,那就是 ice stark。Ice stark 是由 ice 團隊開源的一款面向大型系統的微前端解決方案,已經服務於阿里巴巴內外部300+應用

image-20220916155651191.png

同時具備微前端解決方案傳統意義上的所有功能

image-20220916155824589.png

當然,最令人欣喜的是它的微模塊部分:提供了很好的跨子應用共享模塊的能力

image-20221007234425292.png

基本使用

ice stark 使用的是主應用 + 子應用的模式,子應用用於提供自身所負責的業務能力;主應用用來控制系統整體佈局和配置、註冊所有的微應用,連接所有子應用的同時負責給用户帶來統一的視覺與交互,所以微前端體系前端有個很重要的工作就是維護統一 & 模糊主子應用之間的邊界感,這個主要放在下篇講解。

主應用的接入十分簡單,只有三步:

  1. 提供一個 DOM 節點作為子應用渲染的節點

    <div id="ice-container"></div>

  1. 註冊子應用,主要是提供子應用的服務地址、路由、應用名稱等信息

    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'], } ])

  1. 運行

    ts start()

子應用的接入只要向外提供mountunmount兩個生命週期函數就可以了

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工作流程的拆解,官方給出的工作流程圖如下:

O1CN01TLS76R1hwE2F8KPCe_!!6000000004341-2-tps-3576-2664.png

總結一下就是:通過劫持當前路由去匹配到對應微應用後,通過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狀態標記等轉換成內部使用的標準化格式,方便後續的處理。這一步對應的流程圖如下:

image-20220927172303553.png

二、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 https://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是給主應用的stylelink, script標籤添加上icestark="static"的屬性(icestark="dynamic"的除外),如下圖所示

image-20220928211827202.png

因為主應用的資源是穩定的,加載完成後基本就不會變化了;相對來説,子應用的資源就屬於”動態的“了

但其實根據 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 列表,再通過fetchScriptsfetchStyles加載子應用的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 中的pushStatereplaceState,以及監聽路由變化的popstatehashchange兩個方法

```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); }; ```

pushStatereplaceState是 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中的事件

監聽hashchangepopstate事件執行的方法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 丟失,實測場景也是如此,這塊邏輯應該可以再優化一下

接下來使用當前 urllastUrl 對比,不相同才觸發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數組將它們統統卸載掉,並且對狀態為MOUNTEDLOADING_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); } } ```

獲取子應用資源可以説是加載子應用的核心邏輯了,官方給出的流程圖其實也表明的很清楚了:

子應用資源的獲取可以通過三種方式:

image-20220930225049216.png

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, {  cacheCss = false,  fetch = defaultFetch, }: {  cacheCss?: boolean;  fetch?: Fetch; }) {  const cssRoot: HTMLElement = document.getElementsByTagName('head')[0];

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

這些不同的處理方式最終的目的就是獲取到子應用生命週期即導出的mountunmount方法:

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源碼中體現的淋漓盡致

我繪製了張流程圖作為最後的總結和提煉:

icestark源碼流程.drawio.png

最後,創作不易,你的贊就是給作者最大的鼓勵~

作者水平有限,如有錯誤或者不嚴謹之處,懇請批評指正~