文章來(lái)源于公眾號(hào):程序員成長(zhǎng)指北 ,作者mcuking
前幾天看到了 微前端在美團(tuán)外賣(mài)的實(shí)踐,感覺(jué)和筆者所在團(tuán)隊(duì)實(shí)踐了一年多的微前端方案非常類(lèi)似,只不過(guò)我們是基于 Vue 技術(shù)棧的,所以也想總結(jié)一篇文章分享給大家。因?yàn)楣P者文筆不算太好,其中借用了一些美團(tuán)文章的一些總結(jié)性的文字,還請(qǐng)見(jiàn)諒哦~
背景介紹
對(duì)于大型前端項(xiàng)目,比如公司內(nèi)部管理系統(tǒng)(一般包括 OA、HR、CRM、會(huì)議預(yù)約等系統(tǒng)),如果將所有業(yè)務(wù)放在一個(gè)前端項(xiàng)目里,隨著業(yè)務(wù)功能不斷增加,就會(huì)導(dǎo)致如下這些問(wèn)題:
- 代碼規(guī)模龐大,導(dǎo)致編譯時(shí)間過(guò)長(zhǎng),開(kāi)發(fā)、打包速度越來(lái)越慢
- 項(xiàng)目文件越來(lái)越多,導(dǎo)致查找相關(guān)文件變得越來(lái)越困難
- 某一個(gè)業(yè)務(wù)的小改動(dòng),導(dǎo)致整個(gè)項(xiàng)目的打包和部署
方案介紹
preload-routes
和 async-routes
是目前筆者所在團(tuán)隊(duì)使用的微前端方案,最終會(huì)將整個(gè)前端項(xiàng)目拆解成一個(gè)主項(xiàng)目和多個(gè)子項(xiàng)目,其中兩者作用如下:
- 主項(xiàng)目:用于管理子項(xiàng)目的路由切換、注冊(cè)子項(xiàng)目的路由和全局 Store 層、提供全局庫(kù)和方法
- 子項(xiàng)目:用于開(kāi)發(fā)子業(yè)務(wù)線業(yè)務(wù)代碼,一個(gè)子項(xiàng)目對(duì)應(yīng)一個(gè)子業(yè)務(wù)線,并且包含兩端(PC + Mobile)代碼和復(fù)用層代碼(項(xiàng)目分層中的非視圖層)
結(jié)合筆者之前的采用分層架構(gòu)實(shí)現(xiàn)復(fù)用非視圖代碼的方式(感興趣的話請(qǐng)參考筆者之前的文章 前端分層架構(gòu)實(shí)踐心得),完整的方案如下:
如圖所示,將整個(gè)前端項(xiàng)目按照業(yè)務(wù)線拆分出多個(gè)子項(xiàng)目,每個(gè)子項(xiàng)目都是獨(dú)立的倉(cāng)庫(kù),只包含了單個(gè)業(yè)務(wù)線的代碼,可以進(jìn)行獨(dú)立開(kāi)發(fā)和部署,降低了項(xiàng)目維護(hù)的復(fù)雜度。
采用這套方案,使得我們的前端項(xiàng)目不僅保有了橫向上(多個(gè)子項(xiàng)目)的擴(kuò)展性,又擁有了縱向上(單個(gè)子項(xiàng)目)的復(fù)用性。那么這套方案具體是怎么實(shí)現(xiàn)的呢?下面就詳細(xì)說(shuō)明方案的實(shí)現(xiàn)機(jī)制。
在講解之前,首先明確下這套方案有兩種實(shí)現(xiàn)方式,一種是預(yù)加載路由,另一種是懶加載路由,可以根據(jù)實(shí)際需求選擇其中一個(gè)即可。接下來(lái)就分別介紹這兩種方式的實(shí)現(xiàn)機(jī)制。
實(shí)現(xiàn)機(jī)制
預(yù)加載路由方式
preload-routes
1.子項(xiàng)目按照 vue-cli 3 的 library 模式進(jìn)行打包,以便后續(xù)主項(xiàng)目引用
注:在 library
模式中, Vue 是外置的。這意味著包中不會(huì)有 Vue ,即便你在代碼中導(dǎo)入了 Vue 。如果這個(gè)庫(kù)會(huì)通過(guò)一個(gè)打包器使用,它將嘗試通過(guò)打包器以依賴(lài)的方式加載 Vue ;否則就會(huì)回退到一個(gè)全局的 Vue 變量。
2.在編譯主項(xiàng)目的時(shí)候,通過(guò) InsertScriptPlugin 插件將子項(xiàng)目的入口文件 main.js 以 script 標(biāo)簽形式插入到主項(xiàng)目的 html 中
注:務(wù)必將子項(xiàng)目的入口文件 main.js
對(duì)應(yīng)的 script
標(biāo)簽放在主項(xiàng)目入口文件 app.js
的 script
標(biāo)簽之上,這是為了確保子項(xiàng)目的入口文件先于主項(xiàng)目的入口文件代碼執(zhí)行,接下來(lái)的步驟就會(huì)明白為什么這么做。
再注:本地開(kāi)發(fā)環(huán)境下項(xiàng)目的入口文件編譯后的 main.js
是保存在內(nèi)存中的,所以磁盤(pán)上看不見(jiàn),但是可以訪問(wèn)。
InsertScriptPlugin 核心代碼如下:
compiler.hooks.compilation.tap('InsertScriptWebpackPlugin', compilation => {
compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing.tap(
'InsertScriptWebpackPlugin',
htmlPluginData => {
const {
assets: { js }
} = htmlPluginData;
// 將傳入的 js 以 script 標(biāo)簽形式插入到 html 中
// 注意:需要將子項(xiàng)目的入口文件 main.js 放在主項(xiàng)目入口文件 app.js 之前,因?yàn)樾枰禹?xiàng)目提前將自己的 route list 注冊(cè)到全局上
js.unshift(...self.files);
}
);
});
3.主項(xiàng)目的 html 要訪問(wèn)子項(xiàng)目里的編譯后的 js / css
等資源,需要進(jìn)行代理轉(zhuǎn)發(fā)
- 如果是本地開(kāi)發(fā)時(shí),可以通過(guò) webpack 提供的 proxy,例如:
const PROXY = {
'/app-a/': {
target: 'http://localhost:10241/'
}
};
- 如果是線上部署時(shí),可以通過(guò) nginx 轉(zhuǎn)發(fā)或者將打包后的主項(xiàng)目和子項(xiàng)目放在一個(gè)文件夾中按照相對(duì)路徑引用。
4.當(dāng)瀏覽器解析 html 時(shí),解析并執(zhí)行到子項(xiàng)目的入口文件 main.js
,將子項(xiàng)目的 route list 注冊(cè)到 Vue.share.routes 上,以便后續(xù)主項(xiàng)目將其合并到總的路由中。
子項(xiàng)目 main.js
代碼如下:(為了盡量減少首次主項(xiàng)目頁(yè)面渲染時(shí)加載的資源,子項(xiàng)目的入口文件建議只做路由掛載)
import Vue from 'vue';
import routes from './routes';
const share = (Vue.__share__ = Vue.__share__ || {});
const routesPool = (share.routes = share.routes || {});
// 將子項(xiàng)目的 route list 掛載到 Vue.__share__.routes 上,以便后續(xù)主項(xiàng)目將其合并到總的路由中
routesPool[process.env.VUE_APP_NAME] = routes;
5.繼續(xù)向下解析 html,解析并執(zhí)行到主項(xiàng)目 main.js
時(shí),從 Vue.share.routes 獲取所有子項(xiàng)目的 route list,合并到總的路由表中,然后初始化一個(gè) vue-router
實(shí)例,并傳入到 new Vue 內(nèi)
相關(guān)關(guān)鍵代碼如下
// 從 Vue.__share__.routes 獲取所有子項(xiàng)目的 route list,合并到總的路由表中
const routes = Vue.__share__.routes;
export default new Router({
routes: Object.values(routes).reduce((acc, prev) => acc.concat(prev), [
{
path: '/',
redirect: '/app-a'
}
])
});
到此就實(shí)現(xiàn)了單頁(yè)面應(yīng)用按照業(yè)務(wù)拆分成多個(gè)子項(xiàng)目,直白來(lái)說(shuō)子項(xiàng)目的入口文件 main.js
就是將主項(xiàng)目和子項(xiàng)目聯(lián)系起來(lái)的橋梁。
另外如果需要使用 vuex
,則和 vue-router
的順序恰好相反(先主項(xiàng)目后子項(xiàng)目):
1.首先在主項(xiàng)目的入口文件中初始化一個(gè) store
實(shí)例 new Vuex.Store
,然后掛在到 Vue.__share__.store
上
2.然后在子項(xiàng)目的 App.vue
中獲取到 Vue.__share__.store
并調(diào)用 store.registerModule(‘a(chǎn)pp-x', store)
,將子項(xiàng)目的 store
作為子模塊注冊(cè)到 store
上
懶加載路由方式
async-routes
懶加載路由,顧名思義,就是說(shuō)等到用戶點(diǎn)擊要進(jìn)入子項(xiàng)目模塊,通過(guò)解析即將跳轉(zhuǎn)的路由確定是哪一個(gè)子項(xiàng)目,然后再異步去加載該子項(xiàng)目的入口文件 main.js
(可以通過(guò) systemjs 或者自己寫(xiě)一個(gè)動(dòng)態(tài)創(chuàng)建 script
標(biāo)簽并插入 body
的方法)。加載成功后就可以將子項(xiàng)目的路由動(dòng)態(tài)添加到主項(xiàng)目總的路由里了。
1.主項(xiàng)目 router.js
文件中定義了在 vue-router 的 beforeEach 鉤子去攔截路由,并根據(jù)即將跳轉(zhuǎn)的路由分析出需要哪個(gè)子項(xiàng)目,然后去異步加載對(duì)應(yīng)子項(xiàng)目入口文件,下面是核心代碼:
const cachedModules = new Set();
router.beforeEach(async (to, from, next) => {
const [, module] = to.path.split('/');
if (Reflect.has(modules, module)) {
// 如果已經(jīng)加載過(guò)對(duì)應(yīng)子項(xiàng)目,則無(wú)需重復(fù)加載,直接跳轉(zhuǎn)即可
if (!cachedModules.has(module)) {
const { default: application } = await window.System.import(
modules[module]
);
if (application && application.routes) {
// 動(dòng)態(tài)添加子項(xiàng)目的 route-list
router.addRoutes(application.routes);
}
cachedModules.add(module);
next(to.path);
} else {
next();
}
return;
}
});
2.子項(xiàng)目的入口文件 main.js
僅需要將子項(xiàng)目的 routes 暴露給主項(xiàng)目即可,代碼如下:
import routes from './routes';
export default {
name: 'javascript',
routes,
beforeEach(from, to, next) {
console.log('javascript:', from.path, to.path);
next();
}
};
注意:這里除了暴露 routes
方法外,另外又暴露了 beforeEach
方法,其實(shí)就是為了支持通過(guò)路由守衛(wèi)對(duì)子項(xiàng)目進(jìn)行頁(yè)面權(quán)限限制,主項(xiàng)目拿到這個(gè)子項(xiàng)目的 beforeEach
,可以在 vue-router
的 beforeEach
鉤子執(zhí)行,具體代碼請(qǐng)參考 async-routes
。
除了主項(xiàng)目和子項(xiàng)目的交互方式不同,代理轉(zhuǎn)發(fā)子項(xiàng)目資源、vuex store
注冊(cè)等和上面的預(yù)加載路由完全一致。
優(yōu)缺點(diǎn)
下面談下這套方案的優(yōu)缺點(diǎn):
優(yōu)點(diǎn)
- 子項(xiàng)目可單獨(dú)打包、單獨(dú)部署上線,提升了開(kāi)發(fā)和打包的速度
- 子項(xiàng)目之間開(kāi)發(fā)互相獨(dú)立,互不影響,可在不同倉(cāng)庫(kù)進(jìn)行維護(hù),減少的單個(gè)項(xiàng)目的規(guī)模
- 保持單頁(yè)應(yīng)用的體驗(yàn),子項(xiàng)目之間切換不刷新
- 改造成本低,對(duì)現(xiàn)有項(xiàng)目侵入度較低,業(yè)務(wù)線遷移成本也較低
- 保證整體項(xiàng)目統(tǒng)一一個(gè)技術(shù)棧
缺點(diǎn):
- 主項(xiàng)目和子項(xiàng)目需要共用一個(gè) Vue 實(shí)例,所以無(wú)法做到某個(gè)子項(xiàng)目單獨(dú)使用最新版 Vue (例如 Vue3)或者 React
部分問(wèn)題解答
1.如果子項(xiàng)目代碼更新后,除了打包部署子項(xiàng)目之外,還需要打包部署主項(xiàng)目嗎?
不需要更新部署主項(xiàng)目。這里有個(gè) trick
上文忘記提及,就是子項(xiàng)目打包后的入口文件并沒(méi)有加上 chunkhash
,直接就是 main.js
(子項(xiàng)目其他的 js 都有 chunkhash)。也就是說(shuō)主項(xiàng)目只需要記住子項(xiàng)目的名字,就可以通過(guò) subapp-name/main.js
找到子項(xiàng)目的入口文件,所以子項(xiàng)目打包部署后,主項(xiàng)目并不需要更新任何東西。
2.針對(duì)第二個(gè)問(wèn)題中子項(xiàng)目入口文件 main.js 不使用 chunkhash 的話,如何防止該文件始終被緩存呢?
可以在靜態(tài)資源服務(wù)器端針對(duì)子項(xiàng)目入口文件設(shè)置強(qiáng)制緩存為不緩存,下面是服務(wù)器為 nginx
情況的相關(guān)配置:
location / {
set $expires_time 7d;
...
if ($request_uri ~* \/(contract|meeting|crm)-app\/main.js(\?.*)?$) {
# 針對(duì)入口文件設(shè)置 expires_time -1,即expire是服務(wù)器時(shí)間的 -1s,始終過(guò)期
set $expires_time -1;
}
expires $expires_time;
...
}
待完善
- 可以通過(guò)寫(xiě)一個(gè)腳手架來(lái)自動(dòng)生成子項(xiàng)目以及相關(guān)的配置
結(jié)尾
如果沒(méi)有在一個(gè)大型前端項(xiàng)目中使用多個(gè)技術(shù)棧的需求,還是很推薦筆者目前團(tuán)隊(duì)實(shí)踐的這個(gè)方案的。另外如果是 React 技術(shù)棧,也是可以按照這種思想去實(shí)現(xiàn)類(lèi)似的方案的。
以上就是W3Cschool編程獅
關(guān)于基于 Vue 技術(shù)棧的微前端方案實(shí)踐的相關(guān)介紹了,希望對(duì)大家有所幫助。