簡單聊聊微前端

入坑的H發表於2024-11-12

什麼是微前端?

微前端是一種前端架構模式,它將一個龐大的前端應用拆分為多個獨立、小型的應用,這些小型應用可以獨立開發、獨立執行、獨立部署,但對使用者而言,它們仍然是一個統一的整體。這種架構模式主要是為了解決傳統單體應用在大型專案中遇到的問題,如程式碼冗餘、開發效率低下、部署風險高等。

為什麼要用微前端?

  1. 模組化與解耦

    微前端強調模組化,每個微應用都是一個獨立的模組,這使得程式碼更加清晰、易於維護。
    透過將前端應用拆分為多個獨立的子應用,可以實現業務邏輯的解耦,降低系統的複雜性。
  1. 提高開發效率

    微前端架構允許不同團隊並行開發各自的微應用,從而縮短了開發週期。
    由於微應用可以獨立部署,因此無需等待其他團隊的開發進度,即可快速上線新功能。
  1. 降低部署風險

    在傳統的單體應用中,每次部署都涉及整個應用的更新,風險較高。而微前端架構下,每次只需部署更新的微應用,降低了部署的風險和影響範圍。
  1. 技術棧靈活性

    微前端架構不限制接入的微應用的技術棧,這意味著團隊可以根據自身需求和技術儲備選擇合適的技術棧進行開發。
    這種靈活性有助於團隊嘗試新技術、保持技術棧的更新和多樣性。
  1. 漸進式重構與升級

    對於遺留系統或大型專案,微前端提供了一種漸進式重構和升級的策略。透過逐個替換或升級微應用,可以逐步實現整個系統的現代化改造。
  1. 更好的使用者體驗

    微前端架構有助於最佳化前端效能,如減少首次載入時間、提高頁面響應速度等,從而提升使用者體驗。
    透過動態載入和解除安裝微應用,可以實現更細粒度的資源管理和最佳化。

行業解決方案?

  1. 基於路由分發的微前端方案

    這種方案透過配置路由來分發請求到不同的微應用。每個微應用可以獨立開發、測試和部署,而在使用者看來仍然是內聚的單個產品。此方案的優點包括簡單、快速和易配置,但可能在切換應用時觸發瀏覽器重新整理,影響體驗。
  1. 基於iframe的微前端方案

    iframe作為一種古老的技術,可以輕鬆地從獨立的子頁面構建頁面,提供天然的隔離性。這種方案的優點是實現簡單、技術不限制,但缺點是可能存在Bundle大小各異、SEO不友好、URL狀態不同步、DOM結構不共享以及全域性上下文完全隔離等問題。
  1. 基於Web Components的微前端方案

    Web Components是瀏覽器的原生元件,允許建立可重用的使用者介面小部件。這種方案的優點包括技術棧無關、獨立開發和應用間隔離。然而,由於Web Components的瀏覽器和框架支援不夠廣泛,可能需要更多的polyfills,且重寫現有的前端應用和系統架構可能較為複雜。

    MicroApp

    特點:由京東出品,基於WebComponent的思想實現的微前端框架。它輕量、高效,且提供了js沙箱、樣式隔離、元素隔離、預載入等一系列完善的功能。
    優勢:使用起來成本較低,不需要修改子應用的渲染邏輯或webpack配置,接入微前端成本較低。此外,它無任何依賴,體積小巧,擴充套件性高。
    適用場景:適合需要快速整合不同技術棧子應用的專案。
  1. 基於Module Federation的微前端方案

    Module Federation是webpack5新增的功能,可以幫助將多個獨立的構建組成一個應用程式。這種方案的優點包括開箱即用、獨立開發與部署、去中心化和元件共享。但缺點是可能無法提供沙箱隔離、技術單一(僅限使用webpack5以上版本)、程式碼封閉性高以及拆分粒度需要權衡。

    EMP(Esm Module Federation):

    特點:基於Webpack 5 Module Federation特性進行二次封裝,特別最佳化了對ESM(ECMAScript Modules)的支援。它允許多個應用共享模組,子應用可以在不重新構建的情況下被主應用載入和使用。
    優勢:完全支援ESM模組系統,減少模組解析開銷,提高載入效率。相比原生的Module Federation,EMP配置更簡便。
    不足:學習曲線存在,雖然配置簡化,但依然需要掌握Module Federation的核心概念。此外,技術棧有限制,需要使用Webpack 5,且社群支援相對較少。
  1. 中心基座方案(如qiankun等)

    中心基座方案是目前主流的微前端採用的技術方案之一。

    qiankun:

    基於single-spa進行二次開發,提供了更加開箱即用的API、樣式隔離、JS沙箱和資源預載入等功能。這種方案的優點是技術棧無關、易於整合和管理微應用,但可能需要注意沙箱隔離的完善性和效能最佳化。

    Single-spa

    特點:Single-spa是最早的微前端框架,它允許多個前端框架應用(如Vue、React、Angular)同時工作在同一個頁面上。每個子應用可以使用不同的框架,技術棧靈活。
    優勢:提供了依賴共享機制,避免多個應用載入相同的依賴包,且生態完善,有豐富的社群外掛和工具支援。
    不足:學習曲線較陡峭,配置較為複雜,需要專門學習,且在同時載入多個子應用時效能可能受影響。

    Garfish

    特點:位元組跳動推出的微前端框架,專注於輕量級和高效能的解決方案。它無需複雜的配置即可使用,適合快速開發,且支援多種前端框架。
    優勢:效能優越,適合對速度有要求的專案。同時提供了技術棧無關的支援,靈活性高。
    不足:相對於其他成熟的微前端方案,Garfish的社群支援和文件相對較少,且在某些複雜場景下可能需要額外的開發工作。
  1. 自由框架組合模式

qiankun

一、概念

qiankun,意為“乾坤”,是阿里巴巴開源的一個微前端框架。它透過HTML Entry的方式接入微應用,使得接入過程像使用iframe一樣簡單。在qiankun中,主應用負責載入和管理子應用,而子應用則是獨立的前端應用,可以獨立開發、部署和執行。

二、原理

  1. 路由劫持與應用載入:qiankun基於single-spa實現了路由劫持和應用載入。當瀏覽器的URL發生變化時,qiankun會匹配到相應的子應用並進行載入。
  2. 樣式隔離:qiankun實現了兩種樣式隔離方式。一種是嚴格的樣式隔離模式,透過為每個微應用的容器包裹上一個shadow dom節點來實現。另一種是透過動態改寫css選擇器來實現,類似於css scoped的方式。
  3. JS沙箱:qiankun的JS沙箱分為兩種實現方式。在主流瀏覽器中(支援Proxy),使用基於Proxy的多例項沙箱實現。在不支援Proxy的瀏覽器中,則使用基於diff的沙箱實現。這些沙箱確保了子應用的JS執行環境相互隔離,防止了衝突和汙染。
  4. 資源預載入:qiankun實現了資源的預載入策略,即在瀏覽器空閒時間預載入未開啟的微應用資源,從而加速微應用的開啟速度。
  5. 應用間通訊:qiankun透過釋出訂閱模式來實現應用間通訊。每個應用在初始化時會生成一套通訊方法,用於更改全域性狀態和註冊回撥函式。當全域性狀態發生改變時,會觸發各個應用註冊的回撥函式執行。

三、優缺點

優點
  1. 技術棧無關:qiankun允許任意技術棧的應用接入,無論是React、Vue、Angular還是其他框架,都可以輕鬆整合。
  2. 簡單易用:qiankun提供了開箱即用的API和豐富的生命週期函式,使得微前端的開發和管理變得簡單高效。
  3. 效能最佳化:透過資源預載入和應用間通訊機制,qiankun最佳化了微應用的載入速度和效能表現。
  4. 社群支援:作為阿里巴巴開源的專案,qiankun擁有強大的社群支援和廣泛的實踐案例。
缺點
  1. 樣式隔離的侷限性:雖然qiankun實現了樣式隔離,但在某些複雜場景下,仍可能出現樣式衝突或覆蓋的情況。這需要開發者在使用時注意樣式的管理和規劃。
  2. 學習成本:雖然qiankun簡化了微前端的開發過程,但對於初次接觸微前端的開發者來說,仍需要一定的學習成本來理解和掌握其原理和使用方法。
  3. 框架依賴:qiankun是基於single-spa進行封裝的,因此在使用qiankun時,也需要對single-spa有一定的瞭解和認識。這可能會增加一些額外的學習負擔。

實踐 - 虛擬碼

基座
做一套基座的容器 - vue為例
MicroPage 元件頁面內容變化的核心區域
 1 <!-- content 微前端 頁面內容變化的核心區域 -->
 2 <div class="unusual-container" v-show="loadErrorInfo.isError">
 3     <!-- 錯誤兜底頁面 -->
 4     <ErrorPage :subTitle="loadErrorInfo.errorMessage" :status="loadErrorInfo.errorStatus" />
 5 </div>
 6 <!-- 無報錯 資源正常載入展示 MICRO_APP_CONTAINER_ID 微前端容器id-->
 7 <div class="micro-view-container" :id="MICRO_APP_CONTAINER_ID" v-show="!loadErrorInfo.isError">
 8     <template v-for="item in microList" :key="`${item.entryKey}`">
 9       <div :id="item.container" v-show="showContainer(item.container)" class="microContainer"></div>
10     </template>
11     <Skeleton active v-show="showContainer('empty')" />
12 </div>
13 

平臺主題容器

1 <!-- layouts/content 通用的主題header 選單等 -->
2 <div :class="[prefixCls, getLayoutContentMode]">
3     <div v-show="isShowMicroPage">
4       <MicroPage />
5     </div>
6     <div v-show="!isShowMicroPage">
7       <NormalPage />
8     </div>
9 </div>
基座初始化配置
  1 import { loadMicroApp, MicroApp } from 'qiankun'
  2 
  3 
  4 // 載入並渲染微應用
  5 const mountMicroApp = async () => {
  6   // store中存了錯誤資訊
  7   if (isError) {
  8     return
  9   }
 10   // 沒有找到entry
 11   if (!entry) {
 12     setMicroAppLoadErrorInfo('set錯誤資訊')
 13     return
 14   }
 15 
 16   // 載入前預檢一級路由是否有許可權
 17   if (!'許可權' && !'報錯' && !'校驗一級路路由是否有許可權') {
 18     setMicroAppLoadErrorInfo('set錯誤資訊')
 19     return
 20   }
 21 
 22   // 清空錯誤資訊
 23   setMicroAppLoadErrorInfo('清空錯誤資訊')
 24  
 25  
 26   if (!microItem) {
 27     console.log(`沒有找到[${entry}]的微應用資訊`)
 28     return
 29   }
 30  
 31 
 32   const microAppInstance = loadMicroApp(
 33     {
 34       name,
 35       entry: `${entry}/?__v=${new Date().getTime()}`,
 36       container: microContainer,
 37       props: {
 38         // 跳轉到指定路徑對應的微應用 - 先存後跳,
 39         token
 40         permissionMap,
 41         userInfo,
 42         permissionEnum,
 43         stationOrgList,
 44         defaultStation,
 45         homePath,
 46         // microLogout: 處理登入邏輯的函式,
 47         microEmitter, // 子工程用來通訊的emitter
 48         logSentryMsg,
 49         logSentryError,
 50         logBreadCrumb,
 51         microDefHttp: defHttp,
 52         parentWindow: window,
 53       } as unknown as PassToMicroAppProps,
 54     },
 55     {
 56       excludeAssetFilter: (url) => url.indexOf('.baidu.com') !== -1,
 57 
 58     },
 59   )
 60 
 61   microAppInstanceMap[entry] = microAppInstance
 62 
 63   await microAppInstance.mountPromise
 64     .then(() => {
 65       // 許可權校驗
 66     })
 67     .catch((err) => {
 68       // ...
 69     })
 70 }
 71 
 72 // 切換顯示子應用 容器
 73 const changeContainer = async (entry, immediate) => {
 74   if (!microItem) {
 75     console.log(`沒有找到[${entry}]的微應用資訊`)
 76     return
 77   }
 78 }
 79 
 80 
 81 // 解除安裝微應用
 82 const unmountMicroApp = async (entry) => {
 83   if (!'如果store中和變數中都沒有微應用例項,則不需要解除安裝') {
 84     return
 85   }
 86 
 87   if (!'上個子工程載入異常不需要解除安裝') {
 88     return
 89   }
 90   
 91   // 只有mount成功的app,才執行解除安裝
 92   let mounted = true
 93   await needUnmountApp!.mountPromise.catch(() => (mounted = false))
 94   if (!mounted) {
 95     return
 96   }
 97 
 98   // 解除安裝失敗時列印日誌並繼續
 99   
100 }
101 
102 // 許可權校驗
103 const checkMicroAppPermission = (routePath: string) => {
104   if ('如果除錯模式開啟了,則直接跳過不校驗') {
105     return
106   }
107   // 子工程回傳的路由列表
108   // microLogger.info('校驗許可權路由例項:', findRouteInstance)
109 
110   if ('如果子工程中回傳的路由例項有ignoreAuth則不需要檢驗許可權') {
111     return
112   }
113   if (!'路由許可權校驗') {
114     setMicroAppLoadErrorInfo(MicroAppErrorType.PAGE_NOT_ACCESS)
115   }
116 }
117 
118 // 校驗當前路有是否是存在註冊列表中的
119 const checkIsExist = (routePath: string): boolean => {
120   if ('如果除錯模式開啟了,則直接跳過不校驗') {
121     return true
122   }
123 
124   if (!'當前路由從已註冊的子工程列表中查詢不存在') {
125     setMicroAppLoadErrorInfo('錯誤收集')
126     return false
127   }
128   return true
129 }
130 
131 watch(
132   () => loadErrorInfo.value.isError,
133   (isError) => {
134     if (isError) {
136       // 重置是否是同一個entry ...
138       return
139     }
140     isLocalError = false
141   },
142 )
143 
144 // 監聽路由變更
145 watch(
146   () => currentRoute.value.path,
147   async (path: string) => {},
148 )
149 
150 // 監聽當前微應用的entry變更
151 watch(
152   () => entry,
153   async (entry: string) => {},
154   { immediate: true },
155 )
156 
157 // 監聽訊息盒子同一路由點選是否需要重新載入微應用
158 watch(
159   () => xxx,
160   async (xxx) => {},
161 )
162 
163 onMounted(() => {
164   console.log('初始化微應用容器', '++++++++++++++++')
165   microEmitter.on('xxx', (tab) => {})
166 })

基座內維護的子應用資訊集合

  1 // 下面例子 以 lol、cf、dnf 三款遊戲作為三個子應用 舉例
  2 export const microBaseConfig = {
  3   lol: {
  4     entryKey: 'lol',
  5     container: 'lolDom',
  6     entry: {
  7       dev: '',
  8       testing: '',
  9       staging: '',
 10       prod: '',
 11     },
 12   },
 13   cf: {
 14     entryKey: 'cf',
 15     container: 'cfDom',
 16     entry: {
 17       dev: '',
 18       testing: '',
 19       staging: '',
 20       prod: '',
 21     },
 22   },
 23   dnf: {
 24     entryKey: 'dnf',
 25     container: 'dnfDom',
 26     entry: {
 27       dev: '',
 28       testing: '',
 29       staging: '',
 30       prod: '',
 31     },
 32   },
 33 }
 34 
 35 const microConfigMap: Record<string, MicroConfig> = {}
 36 // 遍歷物件的鍵
 37 Object.keys(microBaseConfig).forEach((key) => {
 38   const originalItem = microBaseConfig[key]
 39   // 複製原始物件,避免直接修改
 40   const newItem: MicroConfig = {
 41     ...originalItem,
 42     entryUrl: originalItem.entry[import.meta.env.MODE],
 43   }
 44   microConfigMap[key] = newItem
 45 })
 46 
 47 export const microConfig = microConfigMap
 48 export const microList: MicroConfig[] = Object.values(microConfigMap)
 49 export const lolEntry = microConfigMap.lol.entryUrl
 50 export const cfEntry = microConfigMap.cf.entryUrl
 51 export const dnfEntry = microConfigMap.dnf.entryUrl
 52 
 53 
 54 // 在基座註冊的子應用路由
 55 /**
 56  * @description: 遊戲首頁皮膚
 57  */
 58 const gameRoute: MicroAppInfoConfig = {
 59   name: 'game',
 60   path: '/game/index',
 61   entry: lolEntry,
 62   isMicroApp: true,
 63   meta: {
 64     icon: 'home|svg',
 65     title: '首頁',
 66     groupTitle: '分組標題',
 67   },
 68 }
 69 
 70 /**
 71  * @description: lol遊戲中心
 72  */
 73 const lolRoute: MicroAppInfoConfig = {
 74   name: 'lol',
 75   path: '/lol',
 76   entry: lolEntry,
 77   isMicroApp: true,
 78   meta: {
 79     icon: 'lolIcon|svg',
 80     title: 'lol遊戲中心',
 81     groupTitle: '分組標題',
 82   },
 83   children: [
 84     {
 85       name: 'lol-list',
 86       path: '/lol/list',
 87       entry: lolEntry,
 88       isMicroApp: true,
 89       meta: {
 90         title: '英雄列表',
 91       },
 92     },
 93     {
 94       path: '/lol/add',
 95       name: 'lol-add',
 96       entry: lolEntry,
 97       isMicroApp: true,
 98       meta: {
 99         title: '建立英雄',
100         hideMenu: true,
101         currentActiveMenu: '/lol/list',
102       },
103     },
104   ],
105 }
106 
107 /**
108  * @description: cf遊戲中心
109  */
110 const linkRoute: MicroAppInfoConfig = {
111   path: '/cf',
112   name: 'cf',
113   entry: cfEntry,
114   isMicroApp: true,
115   meta: {
116     icon: 'cf|svg',
117     title: 'cf管理',
118     groupTitle: '分組標題',
119   },
120   children: [
121     {
122       path: '/cf/list',
123       name: 'cfList',
124       entry: cfEntry,
125       isMicroApp: true,
126       meta: {
127         title: '武器總覽庫',
128       },
129     },
130     {
131       path: '/cf/detail',
132       name: 'cfDetail',
133       entry: cfEntry,
134       isMicroApp: true,
135       meta: {
136         title: '武器詳情',
137       },
138     },
139   ],
140 }
141 
142 /**
143  * @description: dnf遊戲中心
144  */
145 const dnfRoute: MicroAppInfoConfig = {
146   path: '/dnf/list',
147   name: 'dnf',
148   entry: dnfEntry,
149   isMicroApp: true,
150   meta: {
151     hideChildrenInMenu: true,
152     icon: 'dnf|svg',
153     title: 'dnf發展歷史',
154     groupTitle: '分組標題',
155   },
156   children: [
157     {
158       path: '/dnf/list',
159       name: 'dnfList',
160       entry: dnfEntry,
161       isMicroApp: true,
162       meta: {
163         title: '發展歷史',
164         currentActiveMenu: '/history',
165       },
166     },
167     {
168       path: '/dnf/detail',
169       name: 'dnf-detail',
170       entry: dnfEntry,
171       isMicroApp: true,
172       meta: {
173         title: '年況詳情',
174         currentActiveMenu: '/history',
175         hideMenu: true,
176       },
177     },
178   ],
179 }

子應用程式碼庫 main.js

 1 import { qiankunWindow, renderWithQiankun } from 'vite-plugin-qiankun/dist/helper'
 2 
 3 export const isQianKun = (() => {
 4   const bool = qiankunWindow.__POWERED_BY_QIANKUN__ || !!qiankunWindow.name
 5   console.log('isQianKun', bool)
 6   return bool
 7 })()
 8 
 9 // 放到專案頂部
10 if (isQianKun) {
11   window['__webpack_public_path__'] = qiankunWindow.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
12 }
13 
14 // 正常邏輯
15 async function render() {}
16 
17 // 初始化qiankun
18 const initQianKun = () => {
19   console.log('initQianKun', '子應用初始化')
20   renderWithQiankun({
21     // @ts-ignore
22     bootstrap(props) {
23       console.log('微應用 vehicle:bootstrap', props)
24     },
25     mount(props: PassToMicroAppProps) {
26       console.log('微應用 vehicle:mount', props)
27       window.parentWindow = props?.parentWindow
28       // 可以透過props讀取基座傳過來的資料
29       // ...
30       render(props)
31       props.onGlobalStateChange((state: QiankunStore, prev: QiankunStore) => {
32         console.log('task子應用onGlobalStateChange改變的state: ', state)
33         console.log('task子應用onGlobalStateChange改變的prev: ', prev)
34       })
35       props.setGlobalState({
36         currentMicroAppRoutes: dynamicRoutes,
37       })
38     },
39     unmount(props) {
40       console.log('微應用 vehicle:unmount', props)
41       app?.unmount()
42     },
43     update(props) {
44       console.log('微應用 vehicle:update', props)
45     },
46   })
47 }
48 isQianKun ? initQianKun() : render()

相關文章