微前端重構實踐落地總結
前言
大家好。最近換到了新部門,在做智慧平臺相關的內容。我接到的第一個任務就是把以前前端的專案重構一次。
說是重構,不如說是重寫一遍。因為原來的專案是 ant-design-vue + vue 全家桶
,要切換成 ant-design + ant-design-pro + react 全家桶
。
更讓人頭疼的是,產品經理並不會讓我們有大把大把時間專門搞重構,我們要邊重構邊做需求。在這樣的挑戰下,我想到了微前端解決方案,下面就跟大家分享這次 微前端在重構上的落地實踐吧 。
這次實踐我簡化了一下,放在 Github 上,大家可以自行 clone 來玩玩。
技術棧
首先,來講講技術棧,老專案主要用了下面的技術:
-
框架
-
Vue
-
vuex
-
vue-router
-
樣式
-
scss
-
UI
-
ant-design-vue
-
ant-design-pro for vue
-
腳手架
-
vue-cli
新專案需要用到的技術有:
-
框架
-
React
-
redux + redux-toolkit
-
react-router
-
新式
-
less
-
UI
-
react-design-react
-
react-design-pro for react
-
腳手架
-
團隊內部自創腳手架
可以看到兩個專案除了業務之外,幾乎沒什麼交集了。
微前端策略
老專案作為主應用,通過 qiankun 去載入新專案(子應用)裡的頁面。
-
當沒有需求時,在新專案(子應用)重寫頁面,重寫完了之後,在老專案(主應用)中載入新專案的頁面,下掉老專案的頁面
-
當有需求時,也是在新專案(子應用)重寫面面再做對應需求(向產品要多點時間),重寫完了之後,在老專案(主應用)中載入新專案的頁面
這樣一來就可以避免 “我要一整個月都做重構” 的局面,而是可以做到一個頁面一個頁地慢慢遷移。最終等所有頁面都在新專案寫好之後,直接把老專案下掉,新專案就可以從幕後站出來了。相當於從重寫的第一天開始,老專案就成替身了。
如果只看上面畫的架構圖,會覺得:啊,不就引入一個 qiankun 就完事了麼?實際上還有很細節和問題需要注意的。
升級版架構
上圖的架構有一個問題就是,當每次點選側邊欄的 MenuItem
時,都會載入一次微應用的子頁面,也即:
微應用子頁面之間的切換,其實就是在微應用里路由切換嘛,大可不需要通過重新載入一次微應用來做微應用子頁面的切換。
所以,我想了一個辦法:我在 <router-view>
旁邊放了一個元件 Container
。進入主應用後,這個元件先直接把微應用整個都載入了。
<a-layout> <!-- 頁面 --> <a-layout-content> <!-- 子應用容器 --> <micro-app-container></micro-app-container> <!-- 主應用路由 --> <router-view/> </a-layout-content> </a-layout>
當展示老頁面時,把這個 Container
高度設為 0
,要展示新頁面時,再把 Container
高度自動撐開。
// micro-app-container <template> <div class="container" :style="{ height: visible ? '100%' : 0 }"> <div id="micro-app-container"></div> </div> </template> <script> import { registerMicroApps, start } from 'qiankun' export default { name: "Container", props: { visible: { type: Boolean, defaultValue: false, } }, mounted() { registerMicroApps([ { name: 'microReactApp', entry: '//localhost:3000', container: '#micro-app-container', activeRule: '/#/micro-react-app', }, ]) start() }, } </script>
這樣一來,當進入老專案時,這個 Container
自動被 mounted 後就會地去載入子應用了。當在切換新頁面時,本質上是在子應用裡做路由切換,而不是從 A 應用切換到 B 應用了。
子應用的佈局
由於新的專案(子應用)裡的頁面要供給老專案(主應用)來使用的,所以子應用也應該有兩套佈局:
第一套標準的管理後臺佈局,有 Sider
, Header
還有 Content
,另一套側作為子應用時,只展示 Content
部分的佈局。
// 單獨執行時的佈局 export const StandaloneLayout: FC = () => { return ( <AntLayout className={styles.layout}> <Sider/> <AntLayout> <Header /> <Content /> </AntLayout> </AntLayout> ) } // 作為子應用時的佈局 export const MicroAppLayout = () => { return ( <Content /> ) }
最後通過 window.__POWERED_BY_QIANKUN__
就可以切換不同的佈局了。
import { StandaloneLayout, MicroAppLayout } from "./components/Layout"; const Layout = window.__POWERED_BY_QIANKUN__ ? MicroAppLayout : StandaloneLayout; function App() { return ( <Layout/> ); }
樣式衝突
qiankun 是預設開啟 JS 隔離(沙箱),關閉 CSS 樣式隔離的。為什麼這麼做呢?因為 CSS 的隔離是不能無腦做去做的,下面來講講這方面的問題。
qiankun 一共提供了兩種 CSS 隔離方法(沙箱): 嚴格沙箱 以及 實驗性沙箱 。
嚴格沙箱
開啟程式碼:
start({ sandbox: { strictStyleIsolation: true, } })
嚴格沙箱主要通過 ShadowDOM
來實現 CSS 樣式隔離,效果是當子應用被掛在到 ShadowDOM
上,主子應用的樣式 完完全全 地被隔離,無法互相影響。你說:這不是很好麼?No No No。
這種沙箱的優點也成為了它自己的缺點:除了樣式的硬隔離,DOM 元素也直接硬隔離了,導致子應用的一些 Modal
、 Popover
、 Drawer
元件會因為找不到主應用的 body
而丟失,甚至跑到整個螢幕之外。
還記得我剛說主應用和子應用都用了 ant-design 麼?ant-design 的 Modal
、 Popover
Drawer
的實現方式就是要掛在到 document.body
上的,這麼一隔離,它們一掛在整個元素起飛了。
實驗性沙箱
開啟程式碼:
start({ sandbox: { experimentalStyleIsolation: true, } })
這種沙箱實現方式就是給子應用的樣式加字尾標籤,有點像 Vue 裡的 scoped
,通過名字來做樣式 “軟隔離”,比如像這樣:
其實這種方式已經很好地做了樣式隔離,但是主應用裡經常有人喜歡寫 !important
來覆蓋 ant-design 的元件原樣式:
.ant-xxx { color: white: !important; }
而 !importnant
的優先順序是最高的,如果微應用也用了這個 .ant-xxx
類,就很容易被主應用的樣式影響了。所以在載入微應用時,還需要處理 ant-design 之間的樣式衝突問題。
ant-design 樣式衝突
ant-design 提供了一個非常好的類名字首功能:用 prefixCls
來做樣式隔離,我自然也用上了:
// 自定義字首 const prefixCls = 'cmsAnt'; // 設定 Modal、Message、Notification rootPrefixCls ConfigProvider.config({ prefixCls, }) // 渲染 function render(props: any) { const { container, state, commit, dispatch } = props; const value = { state, commit, dispatch }; const root = ( <ConfigProvider prefixCls={prefixCls}> <HashRouter basename={basename}> <MicroAppContext.Provider value={value}> <App /> </MicroAppContext.Provider> </HashRouter> </ConfigProvider> ); ReactDOM.render(root, container ? container.querySelector('#root') : document.querySelector('#root')); }
@ant-prefix: cmsAnt; // 引入來改變全域性變數值
但是不知道為什麼,在 less 檔案中改了 ant-prefix
變數後,ant-design-pro 的樣式還是老樣子,有的元件樣式改變了,有的沒變化。
最後,我是通過 less-loader
的 modifyVars
在打包時來更新全域性的 ant-prefix
less 變數才搞定的:
var webpackConfig = { test: /.(less)$/, use: [ ... { loader: 'less-loader', options: { lessOptions: { modifyVars: { 'ant-prefix': 'cmsAnt' }, sourceMap: true, javascriptEnabled: true, } } } ] }
具體 Issue 看 Issue: ant-design 改了 prefixCls 後 ant-design-pro 不生效。
主子應用狀態管理
老專案(主應用)用到了 vuex 全域性狀態管理,所以新專案頁面(子應用)裡有時需要更改主應用裡的狀態,這裡我用了 qiankun 的 globalState 來處理。
首先在 Container 裡建立了 globalActions
,再監聽 vuex 狀態變更,每次變更都通知子應用,同時把 vuex 的 commit
和 dispatch
函式傳給子應用:
import {initGlobalState, registerMicroApps, start} from 'qiankun' const globalActions = initGlobalState({ state: {}, commit: null, dispatch: null, }); export default { name: "Container", props: { visible: { type: Boolean, defaultValue: false, } }, mounted() { const { dispatch, commit, state } = this.$store; registerMicroApps([ { name: 'microReactApp', entry: '//localhost:3000', container: '#micro-app-container', activeRule: '/#/micro-react-app', // 初始化時就傳入主應用的狀態和 commit, dispatch props: { state, dispatch, commit, } }, ]) start() // vuex 的 store 變更後再次傳入主應用的狀態和 commit, dispatch this.$store.watch((state) => { console.log('state', state); globalActions.setGlobalState({ state, commit, dispatch }); }) }, }
子應用裡接收主應用傳來的 state
, commit
以及 dispatch
函式,同時新起一個 Context,把這些東西都放到 MicroAppContext
裡。(Redux 因為不支援存放函式這種 nonserializable 的值,所以只能先存到 Context 裡)
// 渲染 function render(props: any) { const { container, state, commit, dispatch } = props; const value = { state, commit, dispatch }; const root = ( <HashRouter basename={basename}> <MicroAppContext.Provider value={value}> <App /> </MicroAppContext.Provider> </HashRouter> ); ReactDOM.render(root, container ? container.querySelector('#root') : document.querySelector('#root')); } // mount 時監聽 globalState,只要一改再次渲染 App export async function mount(props: any) { console.log('[micro-react-app] mount', props); props.onGlobalStateChange((state: any) => { console.log('[micro-react-app] vuex 狀態更新') render(state); }) render(props); }
這樣一來,子應用也可以通過 commit
,和 dispatch
來更改主應用的值了。
const OrderList: FC = () => { const { state, commit } = useContext(MicroAppContext); return ( <div> <h1 className="title">【微應用】訂單列表</h1> <div> <p>主應用的 Counter: {state.counter}</p> <Button type="primary" onClick={() => commit('increment')}>【微應用】+1</Button> <Button danger onClick={() => commit('decrement')}>【微應用】-1</Button> </div> </div> ) }
當然了,這樣的實踐也是我自己 “發明” 的,不知道這是不是一個好的實踐,我只能說這樣能 Work。
全域性變數報錯
另一個問題就是當子應用隱式使用全域性變數時, import-html-entry
執行 JS 時會直接爆炸。比如微應用有如下 <script>
的程式碼:
var x = {}; // 報錯,要改成 window.x = {}; x.a = 1 // 報錯,要改成 window.x.a = 1; function a() {} // 要改成 window.a = () => {} a() // 報錯,要改成 window.a()
在主應用載入微應用後,上面的 x
和 a
全都會報 xxx is undefined
,這是因為 qiankun 在載入微應用時,會執行這部分 JS 程式碼,而此時 var 宣告的變數不再是全域性變數,其他的檔案無法獲取到。
解決方法就是使用 window.xxx
來顯式定義/使用全域性變數。具體可見 Issue: 子應用全域性變數 undefined
主應用切換路由時不更新子應用路由
只要主子應用都用上了 Hash 路由,那麼很大概率會遇到這個問題。
比如你主應用有 /micro-app/home
和 /micro-app/user
兩個路由, actvieRule
為 /#/micro-app
,子應用也有對應的 /micro-app/home
和 /micro-app/user
兩個路由。
那麼如果 在主應用裡 從 /micro-app/home
切換到 /micro-app/user
,會發現子應用的路由並沒有改變。但如果你 在主應用的子應用裡 去切換,那麼就能切換成功。
這是因為在主應用切換路由時不是通過 location.url
這種可以觸發 hash change 事件的方式來變更路由,而 react-router 只監聽了 hash change 事件,所以當主應用切換路由時,沒有觸發 hash change 事件,導致子應用的監聽不到路由變化,也就不會做頁面切換了。
具體可見:Issue: 載入子應用正常,但主應用切換路由,子應用不跳轉,瀏覽器返回前進可觸發子應用跳轉。
解決方法很簡單,下面三選一:
-
將 vue 主應用中的 Link 超鏈方式替換成原生的 a 標籤,從而觸發瀏覽器的 hash change 事件
-
主應用手動監聽路由變更,同時手動觸發 hash change 事件
-
主應用跟子應用都改用 browser history 模式
載入狀態
主應用在載入子應用時還是需要不少時間的,所以最好要展示一個載入中的狀態,qiankun 正好提供了一個 loader
回撥來讓我們控制子應用的載入狀態:
<div class="container" :style="{ height: visible ? '100%' : 0 }"> <a-spin v-if="loading"></a-spin> <div id="micro-app-container"></div> </div>
registerMicroApps([ { name: 'microReactApp', entry: '//localhost:3000', container: '#micro-app-container', activeRule: '/#/micro-react-app', props: { state, dispatch, commit, }, loader: (loading) => { this.loading = loading // 控制載入狀態 } }, ]) start()
總結
總的來說,微前端在解構巨石應用的幫助真的很大。像我們這種要重構整個應用的情況,部門肯定不會先暫停業務,給開發一整個月來專門重構的,只能在評新需求的時候多給你一兩天時間而已。
微前端就可以解決重構的過程中邊做新需求邊重構的問題,使得新老頁面都能共存,不會一下子整個業務都停掉來做重構工作。
如果你也喜歡我的文章,可以給個在看、點贊、關注哦~
- 如何讓瀏覽器不快取檔案
- 專案穩定性治理思考:防禦性CSS技能
- 手摸手服務端渲染-react
- Web頁面全鏈路效能優化指南
- 玻璃擬態是什麼?前端該如何實現
- 【趕緊收藏】45個前端與設計必備線上開發工具
- 前端截圖身份溯源
- 徹底搞懂垃圾回收機制底層原理
- 都2022 年了,你總不能還只會 npm i 吧?
- 前端架構破局 - NodeJS 落地 WebSocket
- Vite 約定式路由的最佳實踐
- 讓前端程式碼自動學會畫畫
- 在 CSS 中隱藏元素的 10 種方法
- 我用Nodejs一鍵下載了10000張妹子圖片
- 前端該如何優雅地Mock資料
- 一個經常被忽略的 single-spa 微前端實踐
- 深度:JS的7種資料型別以及它們的底層資料結構
- 深度思考:JS 不可變資料之高效能場景
- 微前端重構實踐落地總結
- Web3.0是什麼,為什麼MetaVerse這麼火?