微前端x重構(gòu)實(shí)踐落地總結(jié)
前言
大家好,我是海怪。最近換到了新部門,在做智能平臺(tái)相關(guān)的內(nèi)容。我接到的第一個(gè)任務(wù)就是把以前前端的項(xiàng)目重構(gòu)一次。
說(shuō)是重構(gòu),不如說(shuō)是重寫一遍。因?yàn)樵瓉?lái)的項(xiàng)目是 ant-design-vue + vue 全家桶,要切換成 ant-design + ant-design-pro + react 全家桶。
更讓人頭疼的是,產(chǎn)品經(jīng)理并不會(huì)讓我們有大把大把時(shí)間專門搞重構(gòu),我們要邊重構(gòu)邊做需求。在這樣的挑戰(zhàn)下,我想到了微前端解決方案,下面就跟大家分享這次 微前端在重構(gòu)上的落地實(shí)踐吧。
這次實(shí)踐我簡(jiǎn)化了一下,放在 Github 上,大家可以自行 clone 來(lái)玩玩。
技術(shù)棧
首先,來(lái)講講技術(shù)棧,老項(xiàng)目主要用了下面的技術(shù):
框架 Vue vuex vue-router 樣式 scss UI ant-design-vue ant-design-pro for vue 腳手架 vue-cli

新項(xiàng)目需要用到的技術(shù)有:
框架 React redux + redux-toolkit react-router 新式 less UI react-design-react react-design-pro for react 腳手架 團(tuán)隊(duì)內(nèi)部自創(chuàng)腳手架

可以看到兩個(gè)項(xiàng)目除了業(yè)務(wù)之外,幾乎沒(méi)什么交集了。
微前端策略

老項(xiàng)目作為主應(yīng)用,通過(guò) qiankun 去加載新項(xiàng)目(子應(yīng)用)里的頁(yè)面。
當(dāng)沒(méi)有需求時(shí),在新項(xiàng)目(子應(yīng)用)重寫頁(yè)面,重寫完了之后,在老項(xiàng)目(主應(yīng)用)中加載新項(xiàng)目的頁(yè)面,下掉老項(xiàng)目的頁(yè)面 當(dāng)有需求時(shí),也是在新項(xiàng)目(子應(yīng)用)重寫面面再做對(duì)應(yīng)需求(向產(chǎn)品要多點(diǎn)時(shí)間),重寫完了之后,在老項(xiàng)目(主應(yīng)用)中加載新項(xiàng)目的頁(yè)面
這樣一來(lái)就可以避免 “我要一整個(gè)月都做重構(gòu)” 的局面,而是可以做到一個(gè)頁(yè)面一個(gè)頁(yè)地慢慢遷移。最終等所有頁(yè)面都在新項(xiàng)目寫好之后,直接把老項(xiàng)目下掉,新項(xiàng)目就可以從幕后站出來(lái)了。相當(dāng)于從重寫的第一天開始,老項(xiàng)目就成替身了。

如果只看上面畫的架構(gòu)圖,會(huì)覺得:啊,不就引入一個(gè) qiankun 就完事了么?實(shí)際上還有很細(xì)節(jié)和問(wèn)題需要注意的。
升級(jí)版架構(gòu)
上圖的架構(gòu)有一個(gè)問(wèn)題就是,當(dāng)每次點(diǎn)擊側(cè)邊欄的 MenuItem 時(shí),都會(huì)加載一次微應(yīng)用的子頁(yè)面,也即:

微應(yīng)用子頁(yè)面之間的切換,其實(shí)就是在微應(yīng)用里路由切換嘛,大可不需要通過(guò)重新加載一次微應(yīng)用來(lái)做微應(yīng)用子頁(yè)面的切換。
所以,我想了一個(gè)辦法:我在 旁邊放了一個(gè)組件 Container。進(jìn)入主應(yīng)用后,這個(gè)組件先直接把微應(yīng)用整個(gè)都加載了。
<a-layout>
??
??<a-layout-content>
????
????<micro-app-container>micro-app-container>
????
????<router-view/>
??a-layout-content>
a-layout>
當(dāng)展示老頁(yè)面時(shí),把這個(gè) Container 高度設(shè)為 0,要展示新頁(yè)面時(shí),再把 Container 高度自動(dòng)撐開。
//?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>
這樣一來(lái),當(dāng)進(jìn)入老項(xiàng)目時(shí),這個(gè) Container 自動(dòng)被 mounted 后就會(huì)地去加載子應(yīng)用了。當(dāng)在切換新頁(yè)面時(shí),本質(zhì)上是在子應(yīng)用里做路由切換,而不是從 A 應(yīng)用切換到 B 應(yīng)用了。

子應(yīng)用的布局
由于新的項(xiàng)目(子應(yīng)用)里的頁(yè)面要供給老項(xiàng)目(主應(yīng)用)來(lái)使用的,所以子應(yīng)用也應(yīng)該有兩套布局:
第一套標(biāo)準(zhǔn)的管理后臺(tái)布局,有 Sider,Header 還有 Content,另一套側(cè)作為子應(yīng)用時(shí),只展示 Content 部分的布局。
//?單獨(dú)運(yùn)行時(shí)的布局
export?const?StandaloneLayout:?FC?=?()?=>?{
??return?(
????<AntLayout?className={styles.layout}>
??????<Sider/>
??????<AntLayout>
????????<Header?/>
????????<Content?/>
??????AntLayout>
????AntLayout>
??)
}
//?作為子應(yīng)用時(shí)的布局
export?const?MicroAppLayout?=?()?=>?{
??return?(
????<Content?/>
??)
}


最后通過(guò) window.__POWERED_BY_QIANKUN__ 就可以切換不同的布局了。
import?{?StandaloneLayout,?MicroAppLayout?}?from?"./components/Layout";
const?Layout?=?window.__POWERED_BY_QIANKUN__???MicroAppLayout?:?StandaloneLayout;
function?App()?{
??return?(
????<Layout/>
??);
}
樣式?jīng)_突
qiankun 是默認(rèn)開啟 JS 隔離(沙箱),關(guān)閉 CSS 樣式隔離的。為什么這么做呢?因?yàn)?CSS 的隔離是不能無(wú)腦做去做的,下面來(lái)講講這方面的問(wèn)題。
qiankun 一共提供了兩種 CSS 隔離方法(沙箱):嚴(yán)格沙箱 以及 實(shí)驗(yàn)性沙箱。
嚴(yán)格沙箱
開啟代碼:
start({
??sandbox:?{
????strictStyleIsolation:?true,
??}
})
嚴(yán)格沙箱主要通過(guò) ShadowDOM 來(lái)實(shí)現(xiàn) CSS 樣式隔離,效果是當(dāng)子應(yīng)用被掛在到 ShadowDOM 上,主子應(yīng)用的樣式 完完全全 地被隔離,無(wú)法互相影響。你說(shuō):這不是很好么?No No No。
這種沙箱的優(yōu)點(diǎn)也成為了它自己的缺點(diǎn):除了樣式的硬隔離,DOM 元素也直接硬隔離了,導(dǎo)致子應(yīng)用的一些 Modal、Popover、Drawer 組件會(huì)因?yàn)檎也坏街鲬?yīng)用的 body 而丟失,甚至跑到整個(gè)屏幕之外。
還記得我剛說(shuō)主應(yīng)用和子應(yīng)用都用了 ant-design 么?ant-design 的 Modal、PopoverDrawer 的實(shí)現(xiàn)方式就是要掛在到 document.body 上的,這么一隔離,它們一掛在整個(gè)元素起飛了。
實(shí)驗(yàn)性沙箱
開啟代碼:
start({
??sandbox:?{
????experimentalStyleIsolation:?true,
??}
})
這種沙箱實(shí)現(xiàn)方式就是給子應(yīng)用的樣式加后綴標(biāo)簽,有點(diǎn)像 Vue 里的 scoped,通過(guò)名字來(lái)做樣式 “軟隔離”,比如像這樣:

其實(shí)這種方式已經(jīng)很好地做了樣式隔離,但是主應(yīng)用里經(jīng)常有人喜歡寫 !important 來(lái)覆蓋 ant-design 的組件原樣式:
.ant-xxx?{
???color:?white:?!important;
}
而 !importnant 的優(yōu)先級(jí)是最高的,如果微應(yīng)用也用了這個(gè) .ant-xxx 類,就很容易被主應(yīng)用的樣式影響了。所以在加載微應(yīng)用時(shí),還需要處理 ant-design 之間的樣式?jīng)_突問(wèn)題。
ant-design 樣式?jīng)_突
ant-design 提供了一個(gè)非常好的類名前綴功能:用 prefixCls 來(lái)做樣式隔離,我自然也用上了:
//?自定義前綴
const?prefixCls?=?'cmsAnt';
//?設(shè)置?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; // 引入來(lái)改變?nèi)肿兞恐?br>但是不知道為什么,在 less 文件中改了 ant-prefix 變量后,ant-design-pro 的樣式還是老樣子,有的組件樣式改變了,有的沒(méi)變化。
最后,我是通過(guò) less-loader 的 modifyVars 在打包時(shí)來(lái)更新全局的 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 不生效。
主子應(yīng)用狀態(tài)管理
老項(xiàng)目(主應(yīng)用)用到了 vuex 全局狀態(tài)管理,所以新項(xiàng)目頁(yè)面(子應(yīng)用)里有時(shí)需要更改主應(yīng)用里的狀態(tài),這里我用了 qiankun 的 globalState 來(lái)處理。

首先在 Container 里創(chuàng)建了 globalActions,再監(jiān)聽 vuex 狀態(tài)變更,每次變更都通知子應(yīng)用,同時(shí)把 vuex 的 commit 和 dispatch 函數(shù)傳給子應(yīng)用:
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',
????????//?初始化時(shí)就傳入主應(yīng)用的狀態(tài)和?commit,?dispatch
????????props:?{
??????????state,
??????????dispatch,
??????????commit,
????????}
??????},
????])
????
????start()
????
????//?vuex?的?store?變更后再次傳入主應(yīng)用的狀態(tài)和?commit,?dispatch
????this.$store.watch((state)?=>?{
??????console.log('state',?state);
??????globalActions.setGlobalState({
????????state,
????????commit,
????????dispatch
??????});
????})
??},
}
子應(yīng)用里接收主應(yīng)用傳來(lái)的 state,commit 以及 dispatch 函數(shù),同時(shí)新起一個(gè) Context,把這些東西都放到 MicroAppContext 里。(Redux 因?yàn)椴恢С执娣藕瘮?shù)這種 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?時(shí)監(jiān)聽?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?狀態(tài)更新')
????render(state);
??})
??render(props);
}
這樣一來(lái),子應(yīng)用也可以通過(guò) commit,和 dispatch 來(lái)更改主應(yīng)用的值了。
const?OrderList:?FC?=?()?=>?{
??const?{?state,?commit?}?=?useContext(MicroAppContext);
??return?(
????<div>
??????<h1?className="title">【微應(yīng)用】訂單列表h1>
??????<div>
????????<p>主應(yīng)用的?Counter:?{state.counter}p>
????????<Button?type="primary"?onClick={()?=>?commit('increment')}>【微應(yīng)用】+1Button>
????????<Button?danger?onClick={()?=>?commit('decrement')}>【微應(yīng)用】-1Button>
??????div>
????div>
??)
}

當(dāng)然了,這樣的實(shí)踐也是我自己 “發(fā)明” 的,不知道這是不是一個(gè)好的實(shí)踐,我只能說(shuō)這樣能 Work。
全局變量報(bào)錯(cuò)
另一個(gè)問(wèn)題就是當(dāng)子應(yīng)用隱式使用全局變量時(shí),import-html-entry 執(zhí)行 JS 時(shí)會(huì)直接爆炸。比如微應(yīng)用有如下 的代碼:
var?x?=?{};?//?報(bào)錯(cuò),要改成?window.x?=?{};
x.a?=?1?//?報(bào)錯(cuò),要改成?window.x.a?=?1;
function?a()?{}?//?要改成?window.a?=?()?=>?{}
a()?//?報(bào)錯(cuò),要改成?window.a()
在主應(yīng)用加載微應(yīng)用后,上面的 x 和 a 全都會(huì)報(bào) xxx is undefined,這是因?yàn)?qiankun 在加載微應(yīng)用時(shí),會(huì)執(zhí)行這部分 JS 代碼,而此時(shí) var 聲明的變量不再是全局變量,其他的文件無(wú)法獲取到。
解決方法就是使用 window.xxx 來(lái)顯式定義/使用全局變量。具體可見 Issue: 子應(yīng)用全局變量 undefined
主應(yīng)用切換路由時(shí)不更新子應(yīng)用路由
只要主子應(yīng)用都用上了 Hash 路由,那么很大概率會(huì)遇到這個(gè)問(wèn)題。
比如你主應(yīng)用有 /micro-app/home 和 /micro-app/user 兩個(gè)路由,actvieRule 為 /#/micro-app,子應(yīng)用也有對(duì)應(yīng)的 /micro-app/home 和 /micro-app/user 兩個(gè)路由。
那么如果 在主應(yīng)用里 從 /micro-app/home 切換到 /micro-app/user,會(huì)發(fā)現(xiàn)子應(yīng)用的路由并沒(méi)有改變。但如果你 在主應(yīng)用的子應(yīng)用里 去切換,那么就能切換成功。
這是因?yàn)樵谥鲬?yīng)用切換路由時(shí)不是通過(guò) location.url 這種可以觸發(fā) hash change 事件的方式來(lái)變更路由,而 react-router 只監(jiān)聽了 hash change 事件,所以當(dāng)主應(yīng)用切換路由時(shí),沒(méi)有觸發(fā) hash change 事件,導(dǎo)致子應(yīng)用的監(jiān)聽不到路由變化,也就不會(huì)做頁(yè)面切換了。
具體可見:Issue: 加載子應(yīng)用正常,但主應(yīng)用切換路由,子應(yīng)用不跳轉(zhuǎn),瀏覽器返回前進(jìn)可觸發(fā)子應(yīng)用跳轉(zhuǎn)。
解決方法很簡(jiǎn)單,下面三選一:
將 vue 主應(yīng)用中的 Link 超鏈方式替換成原生的 a 標(biāo)簽,從而觸發(fā)瀏覽器的 hash change 事件 主應(yīng)用手動(dòng)監(jiān)聽路由變更,同時(shí)手動(dòng)觸發(fā) hash change 事件 主應(yīng)用跟子應(yīng)用都改用 browser history 模式
加載狀態(tài)
主應(yīng)用在加載子應(yīng)用時(shí)還是需要不少時(shí)間的,所以最好要展示一個(gè)加載中的狀態(tài),qiankun 正好提供了一個(gè) loader 回調(diào)來(lái)讓我們控制子應(yīng)用的加載狀態(tài):
<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?//?控制加載狀態(tài)
????}
??},
])
start()
總結(jié)
總的來(lái)說(shuō),微前端在解構(gòu)巨石應(yīng)用的幫助真的很大。像我們這種要重構(gòu)整個(gè)應(yīng)用的情況,部門肯定不會(huì)先暫停業(yè)務(wù),給開發(fā)一整個(gè)月來(lái)專門重構(gòu)的,只能在評(píng)新需求的時(shí)候多給你一兩天時(shí)間而已。
微前端就可以解決重構(gòu)的過(guò)程中邊做新需求邊重構(gòu)的問(wèn)題,使得新老頁(yè)面都能共存,不會(huì)一下子整個(gè)業(yè)務(wù)都停掉來(lái)做重構(gòu)工作
