闲庭信步聊前端 - 见微知著微前端
筆者初次接觸微前端在2020年7月,是從同事的口中聽說的。雖然不算是一個早期接觸者,但是也確實的推動和跟進了內部某大型項目的開發和落地。也希望能把一些走過的坑和一些思考分享給大家。文內所指應用均為PC網頁應用。此文不進行移動端M頁討論。
當時網上信息也不是很多,百度搜索微前端的結果也寥寥無幾,乾坤,飛冰占據了大部分篇幅,還有的就是美團的內部技術沙龍。誰想半年后微前端一詞越炒越烈,各種解決方案和技術實現如雨后春筍,也逐漸形成了百家爭鳴的態勢。
什么是微前端
網上介紹微前端的文章不勝枚舉,大多源引自 Micro Frontends這篇文章。文內詳細的介紹了微前端的思想。也推廣了一種微前端的實現方案( Web Components ),隨著時間的推移和筆者對微前端的實踐,對微前端這一概念也有了一些自己的認知,希望可以幫助大家更好的理解微前端。
何為微前端,分而治之然同臺而坐也。微者,散也。詳細來說,即微前端是一種架構,是將整個巨石應用拆分成多個可獨立開發、部署、上線,運行的小型應用(子應用),對外暴露一個控制臺應用(父應用)來統一管理各個子應用的運行狀態,多個子應用間在用戶無感情況下往復切換。
微前端一詞第一次出現是在工作思維(ThoughtWorks)2016年的技術雷達( Technology Radar )中。但是筆者認為微前端的思想在更早就已經被頻繁的應用于巨石應用上了。回望前后端還未分離的時代,還在用jsp混寫java和HTML時,當時一個頁面就是一個html文檔,以index.html文件為入口,通過錨點標簽(<a/>)將頁面連接起來,頁面以功能為指標,分別隸屬于不同的項目中,項目間互不影響,可分布獨立上線。筆者認為這就是最早期的微前端。隨著SPA應用的興起,這一早期的微前端思想也慢慢的被埋沒。
為什么要用微前端
現在,SPA應用已經無法滿足現代網頁應用的邏輯復雜度和功能的多樣性了。試想一個幾M的入口文件,在首開時請求無數張圖片和css文件,這期間的網絡耗時和瀏覽器渲染時間都早已遠遠達不到用戶預期了。
雖然也出現了諸如懶加載,精靈圖,文件壓縮,CDN等數不勝數的解決方案,但本質還是隸屬于同一個巨石應用,這也導致對應用的任何改動都要重新打包整個項目,即使開啟了happypack多線程打包,其工程效率也是不敢恭維。相信有過大型項目維護經驗的讀者也能體會到那種心在飛而身不動的那種無力感。
另一方面,今年前端技術棧爆炸式增長,早已不是手握jQuery,走遍天下都不怕的時代了。vue已經到了3.0,react的版本更是到了17這個恐怖數字。angular國內雖然用的不多,但是也是三大框架之一,其版本也是早早到了11。即使在各個版本向下兼容的情況下,試問有幾個人敢保證項目在基礎框架不被時代拋棄的前提下。長期穩定運行。這些人中又有幾個人經歷過angular2的重寫升級和react的hooks重構(雖然react不建議這樣)。
所以長遠來看,將一個巨石應用拆分成多個小型應用。以達到項目可穩定升級迭代還是很具有可行性的。基本原理的流程如下:
現代應用痛點:
- 項目中的組件和功能模塊會越來越多,導致整個項目的打包速度變慢; 
- 因為文件夾的數量會隨著功能模塊的增多而增多,查找代碼會變得越來越慢; 
- 如果只改動其中一個模塊的情況,需要把整個項目重新打包上線; 
- 目錄層級和模塊層級過深而且文件又多,定位文件會越來越慢; 
- 所有的項目都只能使用同一技術框架如:react、vue等; 
微前端優勢:
- 技術棧無關:主框架不限制接入應用的技術棧,微應用完全具備自主權; 
- 獨立開發、獨立部署:微應用的 git 倉庫獨立,微應用部署完成后父應用打開的頁面同步更新; 
- 增量升級:在面對各種復雜的場景時,我們通常很難對一個已存在的系統做全量的技術棧升級或者重構,而微前端可以讓我們可以做到很好的漸進式重構; 
- 獨立運行:每個項目都可以作為一個完整的單獨項目去運行,它可能只是一個大后臺項目中的某個功能模塊,然后所有微應用組合到一起就是 PM 想要的完整功能; 
微前端技術方案
路由分發式微前端
顧名思義就是通過路由(如 nginx 配置)將不同的業務分發到不同的應用上,這也是采用較多而且做簡單的“微前端”方法,但是這種方式其實更像是將多個應用聚合起來了,將不同的前端應用拼湊到一起讓他們看起來像是一個整體。
iFrame
iframe 是瀏覽器提供的一個 html 標簽,iframe 元素會創建包含另一個頁面的內聯框架(即行內框架)。這個標簽可以有效的把完成“微前端”,但是如果使用就要考慮兩件事情:
應用的加載問題:父應用何時去加載和卸載微應用,在頁面切換時使用怎么的效果或者動畫讓整個頁面看起來更加自然更容易接受;
應用的通訊問題:通過 HTMLIFrameElement.contentWindow 去獲取 iFrame 元素的 window 對象是一種更簡化的方法,但是這就需要定義一套通訊規范:事件的key采用什么形式怎么起名,從什么時候開始監聽時間等等問題;
Web Components
Web Components 是一套不同的技術,允許您創建可重用的定制元素(它們的功能封裝在您的代碼之外)并且在您的web應用中使用它們。它主要有三項主要技術組成,它們可以一起使用來創建封裝功能的定制元素,可以在你喜歡的任何地方重用,不必擔心代碼沖突。
- Custom elements:一組JavaScript API,允許您定義custom elements及其行為,然后可以在您的用戶界面中按照需要使用它們。 
- Shadow DOM:一組JavaScript API,用于將封裝的“影子”DOM樹附加到元素(與主文檔DOM分開呈現)并控制其關聯的功能。通過這種方式,您可以保持元素的功能私有,這樣它們就可以被腳本化和樣式化,而不用擔心與文檔的其他部分發生沖突。 
- HTML templates:<template> 和 <slot> 元素使您可以編寫不在呈現頁面中顯示的標記模板。然后它們可以作為自定義元素結構的基礎被多次重用。 
微前端框架選擇
Mooa
Mooa 是一個為 Angular 服務的微前端框架,它是一個基于 single-spa 的微前端解決方案,但是他的項目服務于 Angular,就直接pass這種框架了,有興趣的同學可以自行去研究一下。
飛冰
飛冰是一個面向大型系統的微前端解決方案,它可以保證一個系統的操作體驗基礎上,實現各個微應用的獨立開發和發版,通過 icestark 管理微應用的注冊和渲染,正整個系統徹底解耦。(這是我們的候選之一)
飛冰的官網地址:https://ice.work/docs/icestark/about
qiankun
qiankun 是一個 single-spa 的微前端實現庫,qiankun 的設計理念一個是簡單,另一個就是解耦/技術棧無關:
- 簡單:由于主應用微應用都能做到技術棧無關,qiankun 對于用戶而言只是一個類似 jQuery 的庫,你需要調用幾個 qiankun 的 API 即可完成應用的微前端改造。同時由于 qiankun 的 HTML entry 及沙箱的設計,使得微應用的接入像使用 iframe 一樣簡單; 
- 解耦/技術棧無關:微前端的核心目標是將巨石應用拆解成若干可以自治的松耦合微應用,而 qiankun 的諸多設計均是秉持這一原則,如 HTML entry、沙箱、應用間通信等。這樣才能確保微應用真正具備 獨立開發、獨立運行 的能力; 
因為他的介紹和理念就很突出兩個字,簡單所以他就成為了我們的主角,但是呢還是要經過一番對比的。
qiankun的官網地址:https://qiankun.umijs.org/zh
qiankun與飛冰的對比
下面就列出以下對他們的大概對比,截止至2020-12-03
飛冰
- git:星星數 943; 
- js 隔離:沙箱; 
- 樣式隔離:使用 CSS Modules 方案管理樣式;(正常嘗試使用 Shadow Dom 的方案) 
- 打包相關:強制依賴 ice.js,及應用只能使用 react,打包也要使用ice.js 框架打包; 
- 更新周期:1周 ~ 一個月; 
qiankun
- git:星星數 7.8k; 
- js 隔離:沙箱; 
- 樣式隔離:Shadow Dom; 
- 打包相關:依賴于 qiankun,官方推薦使用 parcel 打包,但是可以使用 webpack; 
- 更新周期:1天 ~ 一周; 
經過上述的整理和查看后,我們內部決定使用qiankun,原因有很多,因為我們的項目對打包結果有要求,使用 parcel 滿足不了我們的要求,所以要用 webpack 來打包自己想要的結果,樣式隔離相比較 飛冰 更成熟,星星數高等等。
qiankun實際使用的介紹和問題記錄
框架選擇完畢后我們就開始搭建項目,搭建了兩種不同的父應用(也可稱之為基應用),一種是 react 的一種 vue 的。之后用了 demo 提供的微應用做了嘗試,然后打算統一下代碼最后決定后臺項目都是用 react 來寫,所以最后切換為 react 項目。
在研究之中得到一個消息,咱們的上線打包結果有限制的,必須要以xxx什么形式的才可以上線,然后還有一堆配置,因為時間緊迫所以只能被迫開始使用公司的搭建的 zz-react-cli 來生成 react 項目,所以最后父應用和微應用都是使用的 zz-react-cli 生成的。(這就是我們內部問題了,就不多介紹了),下面開始給大家介紹使用和遇到的問題以及怎么解決的。
基本項目結構是這樣的,包含主子應用,如下圖:
qiankun基本使用
項目框架
父應用和微應用都是用 react 官方腳手架生成,然后下面再講對其修改和配置。
父應用的改造
父應用要安裝兩個依賴包,一個 history 一個 qiankun,然后就是對 index.js 的修改:
import?{?registerMicroApps,?start?}?from?'qiankun';ReactDOM.render(<React.StrictMode><App?/></React.StrictMode>,document.getElementById('root') );registerMicroApps([{name:?'app1',entry:??'//localhost:3002/',container:?'#container',activeRule:?'/app1',props:?{name:?'kuitos',}}],{beforeLoad:?app?=>?console.log('before?load',?app.name),beforeMount:?[app?=>?console.log('before?mount',?app.name),],}, );start();reportWebVitals();然后就是對 app.jsx 的修改:
import?{?createBrowserHistory?}?from?'history'; const?history?=?createBrowserHistory(); function?App()?{//?子項的點擊const?onMenuClick?=?(path)?=>?{history.push(path);};return?(<div?className="layout"><header?className="layout-header"><img?src={logo}?className="layout-logo"?alt="logo"?/><div?className="layout-link"?onClick={()?=>?{?onMenuClick('/app1');?}}>點擊加載app1微應用頁面</div></header><div?className="layout-main"?id="container">app1微應用展示區域</div></div>); }微應用的改造
微應用什么額外的 npm 包都不需要安裝,下面是 index.js 的修改:
const?initAPP?=?container?=>?{ReactDOM.render(<React.StrictMode><App?/></React.StrictMode>,container?container.querySelector('#root'):document.querySelector('#root')) }//全局變量來判斷環境,獨立運行時if(!window.__POWERED_BY_QIANKUN__){initAPP()} /*** bootstrap 只會在微應用初始化的時候調用一次,下次微應用重新進入時會直接調用 mount 鉤子,不會再重復觸發 bootstrap。*?通常我們可以在這里做一些全局變量的初始化,比如不會在 unmount 階段被銷毀的應用級別的緩存等。*/ export?async?function?bootstrap()?{console.log('react?app?bootstraped'); } /***?應用每次進入都會調用?mount?方法,通常我們在這里觸發應用的渲染方法*/ export?async?function?mount(props)?{const?{container}=propsinitAPP(container) } /***?應用每次?切出/卸載?會調用的方法,通常在這里我們會卸載微應用的應用實例*/ export?async?function?unmount(props)?{ReactDOM.unmountComponentAtNode(props.container???props.container.querySelector('#root')?:?document.getElementById('root')); } /***?可選生命周期鉤子,僅使用?loadMicroApp?方式加載微應用時生效*/ export?async?function?update(props)?{console.log('update?props',?props); } reportWebVitals();這樣就是基本配置,但是這樣配置并沒有完全ok,中間你會碰到各種各樣的問題,然后碰到問題請不要著急啦,請看下面的問題記錄找到相應的解決辦法。
到了這里,也許你會問,網上類似資料不是有很多么,隨便搭一個demo應該很容易跑出來。不過,下面說的就是基于這套架構,我們遇到的問題了,真正的干貨,快仔細看!
qiankun 問題記錄
無法識別生命周期鉤子問題
qiankun 拋出一個 Application died in status LOADING_SOURCE_CODE: You need to export the functional lifecycles in xxx entry 的錯誤,這個問題是自己的項目或者官方項目引入 qiankun 是會拋出的錯誤,微應用的 webpack 打包部分就要加上以下代碼:
const?packageName?=?require('../package.json').name;output:?{//?這里改成跟主應用中注冊的一致library:?'brokenSubApp',libraryTarget:?'umd',jsonpFunction:?`webpackJsonp_${packageName}`, },具體原因是 qiankun 拋出這個錯誤是因為無法從微應用的 entry js 中識別出其導出的生命周期鉤子。(官方有介紹)
請求資源跨問題
項目開始啟動的初始階段因為沒有一個固定域名,但是開發還要允許跨域,這時候要怎么解決呢:
devServer:?{headers:?{'Access-Control-Allow-Origin':?'*',} }在微應用的 webpack 的 devServer 中加上 'Access-Control-Allow-Origin': '*' 即可。父應用不需要加,因為是父應用去加載微應用的資源。
還有一種方法就是如果你有代理工具,比如說我們使用的 whistle。
127.0.0.1:8085?a.zhuanzhuan.com/manager? 127.0.0.1:8084??a.zhuanzhuan.com把父應用和微應用的資源代理到同一個域名下也可以。
微應用刷新404問題
將父應用與微應用的路由模式切換為同樣的路由模式,微應用以 browserHistory 為例:
<!--?config?設置?--> import?{?createBrowserHistory?}?from?'history'; const?history?=?createBrowserHistory();<Provider?store={store}><ConnectedRouter?history={history}><div?className="rooter-wrap"><Route?routeList={routeList}/></div></ConnectedRouter> </Provider><!--?另一個頁面的配置?--> <BrowserRouterbasename?=?{window.__POWERED_BY_QIANKUN__???'/qc_xxx'?:?'/hunter_qc_xxx'}forceRefresh?=?{!window.__POWERED_BY_QIANKUN__} >每個人或者公司的項目都不一樣,這個具體要看各自的項目代碼去更新一些東西。(如果有使用的話自己要研究下這里,否則就會造成刷新之后 404 的問題)
父應用和微應用的菜單問題
因為微應用可以單獨運行,所以微應用有一套自己的菜單導航邏輯,然后再父應用時要把所有的菜單做整合。那么這就要延伸出一個問題了,怎么樣去對所有的微應用的菜單做整合呢,我們想了三種思路:
在配置中心配置一套菜單導航,然后父應用請求配置中心拿到導航再渲染。微應用內部自己維護路由:
- 優點:本地開發方便; 
- 缺點:維護兩套路由,配置中心一套和本地一套; 
父應用和微應用都在配置中心,這樣子應用單獨開發時也依賴于外部資源,可能會影響開發效率:
- 優點:維護一套路由,有現在的配置中心接口,不需要單獨開發; 
- 缺點:微應用單獨開發時不方便,還要維護不同環境的不同路由。微應用和父應用都要拉取整套路由配置,然后子應用要過濾并篩選出自己的路由; 
微應用打包時,把導航打包出一份單獨的 .json 文件,然后通過 nginx 配置到某個地址,通過地址去訪問這個文件內容,父應用請求該文件然后做整合,如下圖:
- 優點:本地開發方便,不需要過多的配置; 
- 缺點:需要單獨開發這個功能;父應用要拉去多個子應用但是會影響首屏渲染速度; 
結合上面三種方案,我們內部討論后,決定用第三方方式,經過開發和實際上線運行發現其完全可行
下面介紹下怎么打包:
/***?需要一個 npm 包:generate-json-webpack-plugin*?webpack中寫配置*/const?GenerateJsonPlugin?=?require('generate-json-webpack-plugin'); const?menuList?=?require('../src/router/menuData.js');?//?路由表 //?需要滿足封裝請求的格式 const?menuConfig?=?{code:?0,msg:?'',data:?menuList };/***?省略中間代碼...*//***?在 webpack 的 build 中增加 plugins:*?生成指定的json文件*/plugins:?[//?生成指定的json文件new?GenerateJsonPlugin('webserver/config/menu.json',?menuConfig),],//?之后在通過?nginx?配置一個單獨的配置去指向這個文件這樣就解決了父應用和微應用的菜單問題。
子應用間同步數據
在真正的開發場景中,總會遇到復雜且超出預期的需求。子應用間同步數據就是其中之一。理論上講,如果子應用之間足夠獨立,業務邏輯足夠分離。是不會存在子應用間共用數據的情況的。所有數據由父應用分發即可,這也是qiankun的理念。詳見issue412。但是在實踐過程中,確實的存在這類需求。但是同時筆者也建議盡量減少這類需求,因為可能會導致數據流的混亂造成難以解決的bug。下面介紹詳細的解決方案
父應用使用proxy創建全局store,完整代碼如下。
const?GlobalStore?=?(function?()?{if?(window.__hunterMfeBase__)?{return?window.__hunterMfeBase__;}//?初始化storeconst?store?=?Object.assign(Object.create(null),?{/***?賦值變量*?@param?prop?屬性名*?@param?value?屬性值*?@param?options*?{*???readOnly:?Boolean?只讀*?}*/setValue(prop,?value,?options?=?{})?{const?{?readOnly?}?=?options;//?非只讀屬性-直接賦值if?(!(readOnly?===?true))?{this[prop]?=?value;return;}//?只讀屬性-將值代理到__value__上this[prop]?=?{value,readOnly};},/***?初始化全局變量?(用于在父應用上初始化全局變量)*?@param?global*/init(global?=?{})?{Object.keys(global).forEach(item?=>?(this[item]?=?global[item]));}});//?初始化攔截器const?handler?=?{//?攔截取值操作get(target,?prop)?{//?const?{?activeApp?=?'base'?}?=?store;//?console.log(`[${activeApp}]?get`,?{?activeApp,?target,?prop?});const?primitive?=?target[prop];?//?獲取原始值//?只讀屬性-返回代理的?__value__if?({}.toString.call(primitive)?===?'[object?Object]'?&&?primitive.readOnly?===?true)?{return?primitive.__value__;}//?非只讀屬性-直接返回原始值return?primitive;},//?攔截賦值操作set(target,?prop,?value)?{//?const?{?activeApp?=?'base'?}?=?store;//?console.log(`[${activeApp}]?set`,?{?activeApp,?target,?prop,?value?});const?oldVal?=?target[prop];?//?讀取原始值//?存在舊值且為只讀屬性-不可進行更改值操作if?({}.toString.call(oldVal)?===?'[object?Object]'?&&?oldVal.readOnly?===?true)?{console.error(`GlobalStore.${prop}是只讀屬性,無法重復賦值`);return?true;}//?新值存在只讀屬性-數據代理到__value__if?({}.toString.call(value)?===?'[object?Object]'?&&?value.readOnly?===?true)?{delete?value.readOnly;const?proxyVal?=?{__value__:?value.value,readOnly:?true,};target[prop]?=?proxyVal;return?true;}//?非只讀屬性-直接賦值target[prop]?=?value;return?true;}};window.__hunterMfeBase__?=?new?Proxy(store,?handler);return?window.__hunterMfeBase__; }());export?default?GlobalStore;可在父應用初始化時為全局store賦值
import?GlobalStore?from?'@store';GlobalStore.init({//?常規屬性userInfo:{},//?只讀屬性permissionInfo:{value:?{},readOnly:?true}, });//?手動添加屬性 GlobalStore.other?=?''; //?手動添加只讀屬性 GlobalStore.setValue({value:?'',readOnly:?true })子應用中引用全局store
const?GlobalStore?=?(function?()?{return?window.__hunterMfeBase__?||?{}; }());export?default?GlobalStore;子應用讀取全局store中數據
import?GlobalStore?from?'@/store/globalStore'; console.log(GlobalStore.userInfo);子應用中設置全局store中的數據
import?GlobalStore?from?'@/store/globalStore';//...res為接口請求結果 const?{?userInfo?=?{},?permissionInfo?=?{}?}?=?res; GlobalStore.userInfo?=?userInfo;此時所有子應用中獲取的userInfo都是更新過的。
至此本期微前端的所有內容已經分享完了,大家有什么問題可以再文下留言哦。我們會酌情考慮繼續分享的。
總結
以上是生活随笔為你收集整理的闲庭信步聊前端 - 见微知著微前端的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: PDF转word之后的结果事图片格式,如
- 下一篇: 使用vbs播放语音
