文章首發於個人博客 https://github.com/mcuking/bl...
項目地址:javascript
async-routeshtml
對於大型前端項目,好比公司內部管理系統(通常包括 OA、HR、CRM、會議預定等系統),若是將全部業務放在一個前端項目裏,隨着業務功能不斷增長,就會致使以下這些問題:前端
preload-routes 和 async-routes 是目前筆者所在團隊使用的微前端方案,最終會將整個前端項目拆解成一個主項目和多個子項目,其中二者做用以下:vue
結合以前的分層架構實現複用非視圖代碼的方式,完整的方案以下:java
如圖所示,將整個前端項目按照業務線拆分出多個子項目,每一個子項目都是獨立的倉庫,只包含了單個業務線的代碼,能夠進行獨立開發和部署,下降了項目維護的複雜度。webpack
採用這套方案,使得咱們的前端項目不只保有了橫向上(多個子項目)的擴展性,又擁有了縱向上(單個子項目)的複用性。那麼這套方案具體是怎麼實現的呢?下面就詳細說明方案的實現機制。nginx
在講解以前,首先明確下這套方案有兩種實現方式,一種是預加載路由,另外一種是懶加載路由,接下來就分別介紹這兩種方式的實現機制。git
preload-routesgithub
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 等資源,須要進行代理轉發
const PROXY = { '/app-a/': { target: 'http://localhost:10241/' } };
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 上
懶加載路由,顧名思義,就是說等到用戶點擊要進入子項目模塊,經過解析即將跳轉的路由肯定是哪個子項目,而後再異步去加載該子項目的入口文件 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。
除了主項目和子項目的交互方式不一樣,代理轉發子項目資源、vuex store 註冊等和上面的預加載路由徹底一致。
下面談下這套方案的優缺點:
優勢
缺點:
不須要更新部署主項目。這裏有個 trick 上文忘記說起,就是子項目打包後的入口文件並無加上 chunkhash,直接就是 main.js(子項目其餘的 js 都有 chunkhash)。也就是說主項目只須要記住子項目的名字,就能夠經過 subapp-name/main.js 找到子項目的入口文件,因此子項目打包部署後,主項目並不須要更新任何東西。
能夠在靜態資源服務器端針對子項目入口文件設置強制緩存爲不緩存,下面是服務器爲 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 技術棧,也是能夠按照這種思想去實現相似的方案的。