文章首發於 mcuking 的部落格:https://github.com/mcuking/blog/issues/81
文章來自小夥伴 mucking 的投稿
專案地址:
preload-routes
https://github.com/micro-frontends-vue/preload-routes
async-routes
https://github.com/micro-frontends-vue/async-routes
mobile-web-best-practice
https://github.com/mcuking/mobile-web-best-practice
前幾天看到了 微前端在美團外賣的實踐,感覺和筆者所在團隊實踐了一年多的微前端方案非常類似,只不過我們是基於 Vue 技術棧的,所以也想總結一篇文章分享給大家。因為筆者文筆不算太好,其中借用了一些美團文章的一些總結性的文字,還請見諒哦~
背景介紹對於大型前端專案,比如公司內部管理系統(一般包括 OA、HR、CRM、會議預約等系統),如果將所有業務放在一個前端專案裡,隨著業務功能不斷增加,就會導致如下這些問題:
程式碼規模龐大,導致編譯時間過長,開發、打包速度越來越慢專案檔案越來越多,導致查詢相關檔案變得越來越困難某一個業務的小改動,導致整個專案的打包和部署方案介紹preload-routes 和 async-routes 是目前筆者所在團隊使用的微前端方案,最終會將整個前端專案拆解成一個主專案和多個子專案,其中兩者作用如下:
主專案:用於管理子專案的路由切換、註冊子專案的路由和全域性 Store 層、提供全域性庫和方法子專案:用於開發子業務線業務程式碼,一個子專案對應一個子業務線,並且包含兩端(PC + Mobile)程式碼和複用層程式碼(專案分層中的非檢視層)結合筆者之前的採用分層架構實現複用非檢視程式碼的方式(感興趣的話請參考筆者之前的文章 前端分層架構實踐心得),完整的方案如下:
如圖所示,將整個前端專案按照業務線拆分出多個子專案,每個子專案都是獨立的倉庫,只包含了單個業務線的程式碼,可以進行獨立開發和部署,降低了專案維護的複雜度。
採用這套方案,使得我們的前端專案不僅保有了橫向上(多個子專案)的擴充套件性,又擁有了縱向上(單個子專案)的複用性。那麼這套方案具體是怎麼實現的呢?下面就詳細說明方案的實現機制。
在講解之前,首先明確下這套方案有兩種實現方式,一種是預載入路由,另一種是懶載入路由,可以根據實際需求選擇其中一個即可。接下來就分別介紹這兩種方式的實現機制。
實現機制預載入路由方式preload-routes
1.子專案按照 vue-cli 3 的 library 模式進行打包,以便後續主專案引用
注:在 library 模式中,Vue 是外接的。這意味著包中不會有 Vue,即便你在程式碼中匯入了 Vue。如果這個庫會通過一個打包器使用,它將嘗試通過打包器以依賴的方式載入 Vue;否則就會回退到一個全域性的 Vue 變數。
2.在編譯主專案的時候,通過 InsertScriptPlugin 外掛將子專案的入口檔案 main.js 以 script 標籤形式插入到主專案的 html 中
注:務必將子專案的入口檔案 main.js 對應的 script 標籤放在主專案入口檔案 app.js 的 script 標籤之上,這是為了確保子專案的入口檔案先於主專案的入口檔案程式碼執行,接下來的步驟就會明白為什麼這麼做。
再注:本地開發環境下專案的入口檔案編譯後的 main.js 是儲存在記憶體中的,所以磁碟上看不見,但是可以訪問。
InsertScriptPlugin 核心程式碼如下:
compiler.hooks.compilation.tap('InsertScriptWebpackPlugin', compilation => { compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing.tap( 'InsertScriptWebpackPlugin', htmlPluginData => { const { assets: { js } } = htmlPluginData; // 將傳入的 js 以 script 標籤形式插入到 html 中 // 注意:需要將子專案的入口檔案 main.js 放在主專案入口檔案 app.js 之前,因為需要子專案提前將自己的 route list 註冊到全域性上 js.unshift(...self.files); } );});
3.主專案的 html 要訪問子專案裡的編譯後的 js / css 等資源,需要進行代理轉發
如果是本地開發時,可以通過 webpack 提供的 proxy,例如:const PROXY = { '/app-a/': { target: 'http://localhost:10241/' }};
如果是線上部署時,可以通過 nginx 轉發或者將打包後的主專案和子專案放在一個資料夾中按照相對路徑引用。4.當瀏覽器解析 html 時,解析並執行到子專案的入口檔案 main.js,將子專案的 route list 註冊到 Vue.__share__.routes 上,以便後續主專案將其合併到總的路由中。
子專案 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 上,以便後續主專案將其合併到總的路由中routesPool[process.env.VUE_APP_NAME] = routes;
5.繼續向下解析 html,解析並執行到主專案 main.js 時,從 Vue.__share__.routes 獲取所有子專案的 route list,合併到總的路由表中,然後初始化一個 vue-router 例項,並傳入到 new Vue 內
相關關鍵程式碼如下
// 從 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' } ])});
到此就實現了單頁面應用按照業務拆分成多個子專案,直白來說子專案的入口檔案 main.js 就是將主專案和子專案聯絡起來的橋樑。
另外如果需要使用 vuex,則和 vue-router 的順序恰好相反(先主專案後子專案):
1.首先在主專案的入口檔案中初始化一個 store 例項 new Vuex.Store,然後掛在到 Vue.__share__.store 上
2.然後在子專案的 App.vue 中獲取到 Vue.__share__.store 並呼叫 store.registerModule(‘app-x', store),將子專案的 store 作為子模組註冊到 store 上
懶載入路由方式async-routes
懶載入路由,顧名思義,就是說等到使用者點選要進入子專案模組,通過解析即將跳轉的路由確定是哪一個子專案,然後再非同步去載入該子專案的入口檔案 main.js(可以通過 systemjs 或者自己寫一個動態建立 script 標籤並插入 body 的方法)。載入成功後就可以將子專案的路由動態新增到主專案總的路由裡了。
1.主專案 router.js 檔案中定義了在 vue-router 的 beforeEach 鉤子去攔截路由,並根據即將跳轉的路由分析出需要哪個子專案,然後去非同步載入對應子專案入口檔案,下面是核心程式碼:
const cachedModules = new Set();router.beforeEach(async (to, from, next) => { const [, module] = to.path.split('/'); if (Reflect.has(modules, module)) { // 如果已經載入過對應子專案,則無需重複載入,直接跳轉即可 if (!cachedModules.has(module)) { const { default: application } = await window.System.import( modules[module] ); if (application && application.routes) { // 動態新增子專案的 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 方法,其實就是為了支援通過路由守衛對子專案進行頁面許可權限制,主專案拿到這個子專案的 beforeEach,可以在 vue-router 的 beforeEach 鉤子執行,具體程式碼請參考 async-routes。
優缺點下面談下這套方案的優缺點:
優點
子專案可單獨打包、單獨部署上線,提升了開發和打包的速度子專案之間開發互相獨立,互不影響,可在不同倉庫進行維護,減少的單個專案的規模保持單頁應用的體驗,子專案之間切換不重新整理改造成本低,對現有專案侵入度較低,業務線遷移成本也較低保證整體專案統一一個技術棧缺點:
主專案和子專案需要共用一個 Vue 例項,所以無法做到某個子專案單獨使用最新版 Vue(例如 Vue3)或者 React部分問題解答1.如果子專案程式碼更新後,除了打包部署子專案之外,還需要打包部署主專案嗎?
不需要更新部署主專案。這裡有個 trick 上文忘記提及,就是子專案打包後的入口檔案並沒有加上 chunkhash,直接就是 main.js(子專案其他的 js 都有 chunkhash)。也就是說主專案只需要記住子專案的名字,就可以通過 subapp-name/main.js 找到子專案的入口檔案,所以子專案打包部署後,主專案並不需要更新任何東西。
2.針對第二個問題中子專案入口檔案 main.js 不使用 chunkhash 的話,如何防止該檔案始終被快取呢?
可以在靜態資源伺服器端針對子專案入口檔案設定強制快取為不快取,下面是伺服器為 nginx 情況的相關配置:
location / { set $expires_time 7d; ... if ($request_uri ~* \\/(contract|meeting|crm)-app\\/main.js(\\?.*)?$) { # 針對入口檔案設定 expires_time -1,即expire是伺服器時間的 -1s,始終過期 set $expires_time -1; } expires $expires_time; ...}
待完善可以通過寫一個腳手架來自動生成子專案以及相關的配置結尾
如果沒有在一個大型前端專案中使用多個技術棧的需求,還是很推薦筆者目前團隊實踐的這個方案的。另外如果是 React 技術棧,也是可以按照這種思想去實現類似的方案的。這麼好的實踐文章快去點個在看讓更多小夥伴看到吧!
分享前端好文,點亮 在看