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