微前端在得物客服域的技術實踐/ 那麼多微前端框架,為啥我們選Qiankun + MF

得物技術發表於2022-06-08

一、業務背景

當前客服一站式工作臺包含線上服務、電話、工單和工具類四大功能,頁面的基本結構如下:

每個業務模組相對獨立,各有獨立的業務體系,單個模組體積較大,專案整體採用SPA + iframe的架構模式,其中的工單系統就是通過iframe巢狀的。在客服業務不斷迭代的過程中,SPA + iframe的架構模式暴露出了很多問題,主要問題如下:

  • 問題一:SPA架構模式下,由於各個模組集中於一個架構下,導致首屏載入資源過多,首屏載入速度較慢;SPA只有入口檔案,所以需要對各個模組做業務模式相容,導致入口檔案程式碼條件語句較多,程式碼紊亂,出現線上問題的時候,排查較為困難,如果有新的同學參與開發,梳理業務也較為困難,甚至有的時候難以理解。
  • 問題二:專案中巢狀大量的iframe,iframe也會拖累頁面的載入速度,iframe使用postMessage通訊時也會帶來資料延遲,資料丟失等各種問題,客服使用時間較長的時候,當切換iframe中的頁面時,前一個頁面中的無法被完全釋放,導致瀏覽器所佔的記憶體不停的飆升,最終導致瀏覽器崩潰。

基於上面兩個問題,我們用微前端技術對一站式工作臺做了業務上的拆分,本文主要闡述在拆分過程中遇到的問題和挑戰。

二、技術方案調研

通過對微前端技術方案的調研,可以知道:微前端是一種類似於微服務的架構,它將微服務的理念應用於瀏覽器端,即將單頁面前端應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用,具備以下幾個核心價值:

  • 技術棧無關: 主框架不限制接入應用的技術棧,微應用具備完全自主權
  • 獨立 開發 、獨立部署: 微應用倉庫獨立,前後端可獨立開發,部署完成後主框架自動完成同步更新
  • 增量升級: 在面對各種複雜場景時,我們通常很難對一個已經存在的系統做全量的技術棧升級或重構,而微前端是一種非常好的實施漸進式重構的手段和策略
  • 獨立執行時: 每個微應用之間狀態隔離,執行時狀態不共享

通過對開源社群相關微前端技術的調研,現今主流的微前端解決方案主要包括以下這些:

  • 技術框架: iframe、single-spa、qiankun、icestark、Garfish、microApp、ESM、EMP
  • 技術亮點: js Entry、html Entry、沙箱隔離、樣式隔離、web Component、ESM、ModuleFederation
##### 解決方案##### 來源##### 特點##### 缺點
iframe-天生隔離樣式與指令碼、多頁視窗大小不好控制,隔離性無法被突破,導致應用間上下文無法被共享,隨之帶來開發體驗、產品體驗等問題無法做到單頁導致許多功能無法正常在主應用中展示
single-spa國外Js Entry, 主應用重寫 window.addEventListener攔截監聽路由的時間,執行內部的reroute邏輯,載入子應用基於reroute,對於需要快取,載入多應用的場景不適合
qiankun螞蟻金服基於 single-spa,增加了 html-entry,sandbox, globalSate, 資源預載入等核心功能需要編譯為umd方式,對於AMD,systemJs支援不友好,且官方沒有公開支援vite構建
icestark阿里把大部分配置通過 cache 寫進window['icestark']全域性變數只對React支援,跨框架支援不友好
Garfish位元組對現有 MFE 框架的增強版,VM 沙箱-
microApp京東基於web Component的實現存在相容性問題,微前端方面的探索不夠成熟
ESM-微模組,通過構建工具編譯為js,遠端載入模組,無技術棧限制,跟頁面路由無關,可以隨處掛載無法相容所有瀏覽器(但可以通過編譯工具解決),需手動隔離樣式(可通過css module解決),應用通訊不友好
EMP歡聚時代基於Module Federation、去中心化、跨應用狀態共享、跨框架元件呼叫、遠端拉取ts宣告檔案、動態更新微應用、第三方依賴的共享等能力目前無法涵蓋所有框架

經過調研以及結合我們的業務現狀,採用了 qiankun + Module Federation 作為我們微前端的技術框架,按照功能拆分,將應用拆分為4個獨立的系統,可以獨立 開發 ,獨立部署,可根據許可權配置接入基座;專案中涉及到依賴其他模組的地方採用遠端元件的方式載入依賴元件,例如:IM,電話中會依賴工單中的工單建立,賠付,工單詳情,訂單詳情等元件,工具箱目前會依賴IM中的會話記錄元件,所以IM,工單可以作為remote端,IM、電話,工具箱可以作為host端,提供更友好的元件複用方法,取消了以前的iframe載入方式,也不需要利用qiankun載入多個微應用的方式去實現,避免大量資源的重複載入,提高頁面的響應速度。

1、一站式工作臺微前端架構圖

2、MF遠端元件規劃圖

三、方案具體實現

前面我們已經通過調研和結合專案實際,採用qiankun作為業務應用拆分的微前端框架,模組聯邦作為不同應用之間共享遠端元件的框架,形成了初步的框架體系,在此框架體系下,我們面臨很多的技術挑戰,如下:

  • 微應用需要具備快取(keep-alive)能力,應用切換狀態不能丟失
  • 需要具備同一時刻載入多個微應用
  • 沙箱隔離和引入第三方資源
  • 基座-微應用,微應用-微應用之間如何進行通訊
  • 如何接入遠端元件
  • 樣式隔離

基座-微應用連線示意圖

1、微應用快取能力的實現

qiankun為我們提供了兩個註冊方法:registerMicroAppsloadMicroApp

  • registerMicroApps(apps, lifeCycles?) :適用於 route-based 場景,路由改變會幫我們自動註冊微應用和銷燬上一個微應用,對於不需要做快取的應用來說,推薦使用這個方法,簡單易用,只需要給微應用設定一個獨立的路由匹配規則即可。

下面是qiankun官網的一段demo示例:

import { registerMicroApps } from 'qiankun';

registerMicroApps(
  [
    {
      name: 'app1',
      entry: '//localhost:8080',
      container: '#container',
      activeRule: '/react',
      props: {
        name: 'kuitos',
      },
    },
    {
      name: 'app2',
      entry: '//localhost:8081',
      container: '#container',
      activeRule: '/vue',
      props: {
        name: 'Tom',
      },
    },
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)],
  },
);
  • loadMicroApp(app, configuration?) :適用於需要手動 載入/解除安裝 一個微應用的場景。對於我們來說,需要實現快取和同時載入多個微應用,這個方法更適用。

結論:

qiankun2.0之後官方為我們提供 loadMicroApp API 給我們帶來手動控制應用載入/解除安裝的能力,且不是基於routeBase載入資源,所以我們不用擔心在切換選單的時候,導致前一個微應用被主動解除安裝。

基於loadMicroApp手動控制載入微應用的特性,想要實現keep-alive能力,可以 基座和微應用設定合適keep-alive快取策略, 然後 通過“display: none”的方式去控制切換的顯示和隱藏(DOM重新渲染會導致歷史狀態丟失),在基座中為每個微應用設定掛載點,應用切換的時候就不會導致前一個微應用DOM被解除安裝。

在基座中的邏輯

當我們檢測到路由變化的時候,手動的去呼叫 loadMicroAppFn 去載入對應的微應用,對於需要同時載入多個的場景,可以迴圈去呼叫載入(vite構建下載入多個微應用可能會失敗,建議採用webpack構建)。

具體原因可參考issue:

// 手動載入微應用方法封裝
const loadMicroAppFn = (microApp) => {
  const app = loadMicroApp(
    {
      ...microApp,
      props: {
        ...microApp.props,
        // 下發給微應用的資料
        microFn: (status) => setMicroStatus(status)
      },
    },
    {
      sandbox: true,
      singular: false
    }
  );
  
  return app;
}
// 為每個微應用提供一個掛載的容器節點:
<template>
  <div class="tabs-view">
    <div class="tabs-view-content tabs-view-container">
      <template v-if="microApps && microApps.length">
        <div
          v-for="micro in microApps" :key="micro.name"
          :id="micro.id"
          v-show="currentPath && currentPath.startsWith(`${micro.key}`)"
        ></div>
      </template>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, computed, toRefs, watch, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'

export default defineComponent({
  name: 'Micro-content',
  components: {
  },
  props: {
    currentMenu: {
      type: String,
      default: ''
    }
  },
  setup(props) {
    const route = useRoute()
    const store = useStore()
    // 微應用登錄檔
    const microApps = computed(() => store.getters.microAppsList).value
    const currentPath = ref(route.path)

    watch(
      () => route.path,
      (to, _) => {
        currentPath.value = route.path
      },
      { immediate: true }
    )

    return {
      route,
      spin,
      microApps,
      currentPath
    }
  }
})
</script>
<template>
  <div class="app-content">
    <a-config-provider :locale="zhCN" prefixCls="basic">
      <router-view v-if="isShowViews" v-slot="{ Component }">
        <keep-alive v-if="isKeppAlive">
          <component :is="Component" />
        </keep-alive>
        <component :is="Component" v-else />
      </router-view>
    </a-config-provider>
  </div>
</template>

在子應用中的邏輯: 需要呼叫qiankun生命週期,入口檔案設定合適的keep-alive快取策略

import './public-path'
import { createApp } from 'vue'
import App from './App.vue'
import router, { setupRouter, destroyRoute } from '@/router'
import { setupStore } from '@/store'
import { isChildApp } from '@/utils/env'

let app: any = null
function render(props) {
  app = createApp(App)
  // 掛載vuex狀態管理
  setupStore(app, props)
  // 掛載路由
  setupRouter(app)
  // 路由準備就緒後掛載APP例項
  router.isReady().then(() => {
    app.mount(document.getElementById('miro-app'))
  })
}

// 獨立執行時
if (!isChildApp()) {
  render({})
}

// 暴露主應用生命週期鉤子
export async function mount(props: any) {
  render(props)
}

export async function bootstrap() {
  console.log('vue app bootstraped')
}

// 銷燬生命週期
export async function unmount(props: any) {
  app.unmount()
  app._container.innerHTML = ''
  destroyRoute()
  app = null
}
<template>
  <a-config-provider :locale="zhCN">
    <router-view v-slot="{ Component }">
      <keep-alive v-if="isKeepAlive">
        <component :is="Component" />
      </keep-alive>
      <component :is="Component" v-else />
    </router-view>
  </a-config-provider>
</template>

微應用載入前後performance效能對比圖:

  • 第一次啟用各個微應用效能消耗:

  • 載入成功之後切換微應用效能消耗:

\

通過微應用啟用前後的效能對比可知:

  • 微應用初始化載入的時候,需要經歷一次資源請求,頁面渲染,會有一次大的效能開銷;
  • 微應用載入成功之後,在此切換回來,採用“display: none”+keep-alive方式處理+ 路由 過濾,雖然需要經歷一次重流重繪,但也不會帶來太大的效能開銷

2、沙箱隔離和引入第三方資源資源

qiankun 內部的沙箱主要是通過是否支援 window.Proxy 分為 LegacySandbox 和 SnapshotSandbox 兩種。對於通過script標籤去載入的第三方資源,需要注意的是:要顯示的申明一個全域性變數並掛載到window上,這樣才能在使用的時候獲取到。

擴充套件閱讀:多例項還有一種 ProxySandbox 沙箱,這種沙箱模式目前看來是最優方案。由於其表現與舊版本略有不同,所以暫時只用於多例項模式。ProxySandbox 沙箱穩定之後可能會作為單例項沙箱使用。原文連結:https://segmentfault.com/a/11...

// 例如下面這個例子
// global.js中定義一個全域性變數
var globalMicroApp = 'micro-name'
// index.html引入這個global.js
<script src="global.js"></script>

// global.js中定義一個全域性變數
var globalMicroApp = 'micro-name'
window. globalMicroApp = globalMicroApp
// index.html引入這個global.js
<script src="global.js"></script>

案例1由於沙箱隔離,在使用的時候無法獲取到該全域性變數,案例2才是正確的方式,如果有使用jQuery,最好放在基座中載入,例如當使用ajax jsonp去跨域載入資源的時候,放在微應用中沙箱隔離的原因會導致無法獲取到callbackName(沒有顯示的掛載到window上),對於jsonp跨域的請求,也需要特殊處理,否則qiankun會劫持該jsonp請求,將其轉為fetch請求導致跨域失敗。

const loadMicroAppFn = (microApp) => {
  const app = loadMicroApp(
    {
      ...microApp,
      props: {
        ...microApp.props
      },
    },
    {
      sandbox: true,
      singular: false,
      // 指定部分特殊的動態載入的微應用資源(css/js) 不被 qiankun 劫持處理
      excludeAssetFilter: (url) => {
        return !!(url.indexOf("https://xxx.com/xxx") !== -1);
      },
    }
  );

  return app;
};

3、應用之間的通訊

通訊方式可以採用:URL攜參,window,postMessage, qiankun提供的props, initGlobalState等方式;在此只介紹props, initGlobalState這兩種方式。

  • props方式傳遞引數:

基座通過qiankun loadMicroApp方法下發一個state引數,這個state可以為普通型別,也可以為一個callback,或者vuex action方法,微應用啟用之後可以通過 qiankun 生命週期函式 mount 拿到props傳遞下來的state,如果需要微應用更新資料到基座,可以下發一個action或者callback,微應用在接受方法後儲存到自己的vuex store中,需要更新資料的之後,直接呼叫快取的action或者callback。

props通訊示意圖

  • initGlobalState方式傳遞引數:

action訂閱-釋出模式示意圖

基座:

import { initGlobalState, MicroAppStateActions } from 'qiankun';

// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);

actions.onGlobalStateChange((state, prev) => {
  // state: 變更後的狀態; prev 變更前的狀態
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();

微應用:

// 從生命週期 mount 中獲取通訊方法,使用方式和 master 一致
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 變更後的狀態; prev 變更前的狀態
    console.log(state, prev);
  });

  props.setGlobalState(state);
}

4、如何接入遠端元件

遠端元件採用webpack5模組聯邦去實現,在微前端實踐中需要注意的事項:

// mian.ts中只能匯出qiankun生命週期
const { bootstrap, mount, unmount } = await import('./bootstrap')
export { bootstrap, mount, unmount }

需要將入口檔案(mian.ts)轉移到新的檔案(bootstrap.ts),並在入口檔案中匯出qiankun生命週期,避免打包出兩個入口檔案,導致qiankun載入生命週期函式失敗。

詳細的接入方法可以參考這篇文章:Module Federation 在得物客服工單業務中的最佳實踐

5、樣式隔離

qiankun官方API給我們提供了很完善的API,如下所示:

sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }

  • 預設場景sandbox: true, 只能保證單例項下的樣式隔離,無法保證多個微應用共存,基座-微應用之間的樣式隔離;
  • 設定為strictStyleIsolation: true ;表示開啟嚴格的樣式隔離模式。這種模式下 qiankun 會為每個微應用的容器包裹上一個 shadow dom 節點,從而確保微應用的樣式不會對全域性造成影響;
  • qiankun 還提供了一個實驗性的樣式隔離特性,當 experimentalStyleIsolation 被設定為 true 時,qiankun 會改寫子應用所新增的樣式為所有樣式規則增加一個特殊的選擇器規則來限定其影響範圍,因此改寫後的程式碼會表達類似為如下結構:
.app-main {
  font-size: 14px;
}

div[data-qiankun-react16] .app-main {
  font-size: 14px;
}

這種試驗特性(experimentalStyleIsolation)也可以通過postcss外掛去實現,社群提供了一個外掛postcss-plugin-namespace,使用起來也比較簡單,配置如下:

postcss:{
    plugins:[require('postcss-plugin-namespace')('.basic-project',{ ignore: [ '*'] })]
}
.app-main {
  font-size: 14px;
}

.basic-project .app-main {
  font-size: 14px;
}

雖然官方提供了很完善的API,但對於很多場景來說都不能很完美的解決樣式衝突的問題,例如基座的全域性樣式會汙染微應用的全域性樣式,如果你使用的是antd/ant-design-vue,可以採用如下的方式去更改UI庫字首,也是一個很好的解決方案:在入口檔案app.vue中:ant-design-vue提供了一個prefixCls可以幫助我們修改class字首:

在vue.config.js中可以在less/sass loader中覆蓋ant-design-vue的類名全域性變數:

修改完之後的效果:

// 修改前
.ant-menu-item {
  text-align: center;
  padding: 10px;
}

// 修改後
.basic-menu-item {
  text-align: center;
  padding: 10px;
}

四、帶來的成效

通過微前端技術對一站式工作臺的改造,我們對改造前和改造後做了對比:

專案名稱CR效率開發效率班車釋出制度遠端元件
改造前較慢專案較重,程式碼耦合性較高,開發難度大應用較重,班車釋出需要考慮的問題較多不支援
改造後各子應用拆分,完全解耦,可節省1/3時間獨立應用開發,業務邏輯解耦,開發效率更高獨立開發,獨立釋出,更輕便,班車釋出,需要測試迴歸的內容較少,能更快的交付業務需求支援

五、思考與總結

經歷專案立項到完成整個過程,選定qiankun作為我們的微前端框架,在整個開發過程中可謂是艱難曲折,第一個難關就是微應用快取能力的實現,社群中只有簡短的demo,距離真正落地到專案差的還很遠;其次我們的專案還需要考慮重新整理頁面,在當前微應用過載其他微應用的場景;有些微應用需要依賴第三方的外掛,這個外掛可能會是一個jQuery外掛,可能還會遇到jsonp跨域的場景;還需要考慮微應用之間通用元件的複用問題;原始專案採用vite構建,面對qiankun對vite支援不友好的情況下,最終不得不選擇webpack5。

在遭遇這一系列問題後,然後再到解決這些問題,對我們來說,收益還是很大,也積累了很多社群方案中短板的內容。經過這次專案之後我的思考是:任何技術框架都有其適用場景,對於特定的業務場景,可能原來的技術架構顯得臃腫,但他可能是最合適的,微前端不是神話,正確的場景使用正確的技術才是最優選。

六、參考文件

文/CHENLONG

關注得物技術,做最潮技術人!

相關文章