若是說,2021年了你還不瞭解「微前端」,請自覺搬好板凳前排聽講,小編特意邀請了咱們 LigaAI 團隊的前端負責人先軍老師,帶你輕鬆玩轉微前端。css
什麼是微前端?
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. – Micro Frontendshtml
微前端是一種多個團隊經過獨立發佈功能的方式,來共同構建現代化 web 應用的技術手段及方法策略。前端
不一樣於單純的前端框架/工具,微前端是一套架構體系,這個概念最先在2016年末由 ThoughtWorks 提出。 微前端是一種相似於微服務的架構,它將微服務的理念應用於瀏覽器端,將 Web 應用從整個的「單體應用」轉變爲多個小型前端應用的「聚合體」。vue
各個前端應用「原子化」,能夠獨立運行、開發、部署,從而知足業務的快速變化,以及分佈式、多團隊並行開發的需求。react
核心價值(爲何要使用微前端?)
- 不限技術棧webpack
主應用不限制接入的子應用的技術棧,子應用擁有徹底自主權。所接入的子應用之間也相互獨立,沒有任何直接或間接的技術棧、依賴、以及實現上的耦合。git
- 可獨立開發、部署web
微應用倉庫獨立,先後端都可獨立開發,部署完成後主框架自動完成同步更新。獨立部署的能力在微前端體系中相當重要,可以縮小單次開發的變動範圍,進而下降相關風險。 各個微前端都應該有本身的持續交付管道,這些管道能夠將微前端構建、測試並部署到生產環境中。vue-router
- 增量升級vuex
在面對各類複雜場景時,咱們一般很難對一個已經存在的系統作全量的技術棧升級或重構。 所以,微前端是一種很是好的實施漸進式重構的手段和策略,它能夠逐漸升級咱們的架構、依賴關係和用戶體驗。當主框架發生重大變化時,微前端的每一個模塊能夠獨立按需升級,不須要總體下線或一次性升級全部內容。若是咱們想要嘗試新的技術或互動模式,也能在隔離度更好的環境下作試驗。
- 簡單、解耦、易維護
微前端架構下的代碼庫傾向於更小/簡單、更容易開發,避免無關組件之間沒必要要的耦合,讓代碼更簡潔。經過界定清晰的應用邊界來下降意外耦合的可能性,更好地避免這類無心間形成的耦合問題。
在什麼場景下使用?
微前端架構旨在解決單體應用在一個相對長的時間跨度下,因爲參與人員、團隊的增多、變遷,從一個普通應用演變成一個巨石應用 (Frontend Monolith) 後應用不可維護的問題。這類問題在企業級 Web 應用中尤其常見。
- 兼容遺留系統
現今技術不斷更迭,團隊想要保技術棧不落後,就須要在兼容原有系統的前提下,使用新框架去開發新功能。而遺留系統的功能早已完善,且運行穩定,團隊沒有必要也沒有精力去將遺留系統重構一遍。此時團隊若是須要使用新框架、新技術去開發新的應用,使用微前端是很好的解決方案。
- 應用聚合
大型的互聯網公司,或商業Saas平臺,都會爲用戶/客戶提供不少應用和服務。如何爲用戶呈現具備統一用戶體驗和一站式的應用聚合成爲必須解決的問題。前端聚合已成爲一個技術趨勢,目前比較理想的解決方案就是微前端。
- 不一樣團隊間開發同一個應用,所用技術棧不一樣
團隊須要把第三方的SaaS應用進行集成或者把第三方私服應用進行集成(好比在公司內部部署的 gitlab等),以及在已有多個應用的狀況下,須要將它們聚合爲一個單應用。
圖源:https://micro-frontends.org/
什麼是qiankun?
qiankun 是一個基於 single-spa 的微前端實現庫,旨在幫助你們能更簡單、無痛地構建一個生產可用微前端架構系統。
qiankun 孵化自螞蟻金融科技基於微前端架構的雲產品統一接入平臺。在通過一批線上應用的充分檢驗及打磨後,該團隊將其微前端內核抽取出來並開源,但願能同時幫助有相似需求的產品更方便地構建本身的微前端系統,同時也但願經過社區的幫助將 qiankun 打磨得更加成熟完善。
目前 qiankun 已在螞蟻內部服務了超過 200+ 線上應用,在易用性及完備性上,絕對是值得信賴的。
📦 基於 single-spa 封裝,提供了更加開箱即用的 API。
📱 不限技術棧,任意技術棧的應用都可 使用/接入,不管是 React/Vue/Angular/JQuery 仍是其餘等框架。
💪 HTML Entry 接入方式,讓你接入微應用像使用 iframe 同樣簡單。
🛡 樣式隔離,確保微應用之間樣式互相不干擾。
🧳 JS 沙箱,確保微應用之間 全局變量/事件 不衝突。
⚡️ 資源預加載,在瀏覽器空閒時間預加載未打開的微應用資源,加速微應用打開速度。
🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 應用一鍵切換成微前端架構系統。
遇到的問題及解決建議
子應用靜態資源404
1.全部圖片等靜態資源上傳至 cdn,css 中直接引用 cdn 地址(推薦) 2.將字體文件和圖片打包成base64(適用於字體文件和圖片體積小的項目)(但老是有一些不符合要求的資源,請使用第三種)
// webpack config loader, 添加如下rule到rules中 { test: /\.(png|jpe?g|gif|webp|woff2?|eot|ttf|otf)$/i, use: [{ loader: 'url-loader', options: {}, }] } // chainWebpack config.module.rule('fonts').use('url-loader').loader('url-loader').options({}).end(); config.module.rule('images').use('url-loader').loader('url-loader').options({}).end();
3.在打包時給其注入完整路徑(適用於字體文件和圖片體積比較大的項目)
const elementFromPoint = document.elementFromPoint; document.elementFromPoint = function (x, y) { const result = Reflect.apply(elementFromPoint, this, [x, y]); // 若是座標元素爲shadow則用該shadow再次獲取 if (result && result.shadowRoot) { return result.shadowRoot.elementFromPoint(x, y); } return result; };
css樣式隔離
默認狀況下,qiankun會自動開啓沙箱模式,但這個模式沒法隔離主應用與子應用,也沒法適應同時加載多子應用的場景。 qiankun還給出了shadow dom的方案,須要配置sandbox: { strictStyleIsolation: true }
基於 ShadowDOM 的嚴格樣式隔離並非一個能夠無腦使用的方案,大部分狀況下都須要接入應用作一些適配後才能正常在 ShadowDOM 中運行起來。好比 react 場景下須要解決這些問題 ,使用者須要清楚開啓了 strictStyleIsolation 意味着什麼。下面會列出我解決ShadowDom的一些案例。
fix shadow dom
getComputedStyle
當獲取shadow dom的計算樣式的時候傳入的element是DocumentFragment,會報錯。
const getComputedStyle = window.getComputedStyle; window.getComputedStyle = (el, ...args) => { // 若是爲shadow dom則直接返回 if (el instanceof DocumentFragment) { return {}; } return Reflect.apply(getComputedStyle, window, [el, ...args]); };
elementFromPoint
根據座標(x, y)當獲取一個子應用的元素的時候,會返回shadow root,並不會返回真正的元素。
const elementFromPoint = document.elementFromPoint; document.elementFromPoint = function (x, y) { const result = Reflect.apply(elementFromPoint, this, [x, y]); // 若是座標元素爲shadow則用該shadow再次獲取 if (result && result.shadowRoot) { return result.shadowRoot.elementFromPoint(x, y); } return result; };
document 事件 target 爲 shadow
當咱們在document添加click、mousedown、mouseup等事件的時候,回調函數中的event.target不是真正的目標元素,而是shadow root元素。
// fix: 點擊事件target爲shadow元素的問題 const {addEventListener: oldAddEventListener, removeEventListener: oldRemoveEventListener} = document; const fixEvents = ['click', 'mousedown', 'mouseup']; const overrideEventFnMap = {}; const setOverrideEvent = (eventName, fn, overrideFn) => { if (fn === overrideFn) { return; } if (!overrideEventFnMap[eventName]) { overrideEventFnMap[eventName] = new Map(); } overrideEventFnMap[eventName].set(fn, overrideFn); }; const resetOverrideEvent = (eventName, fn) => { const eventFn = overrideEventFnMap[eventName]?.get(fn); if (eventFn) { overrideEventFnMap[eventName].delete(fn); } return eventFn || fn; }; document.addEventListener = (event, fn, options) => { const callback = (e) => { // 當前事件對象爲qiankun盒子,而且當前對象有shadowRoot元素,則fix事件對象爲真實元素 if (e.target.id?.startsWith('__qiankun_microapp_wrapper') && e.target?.shadowRoot) { fn({...e, target: e.path[0]}); return; } fn(e); }; const eventFn = fixEvents.includes(event) ? callback : fn; setOverrideEvent(event, fn, eventFn); Reflect.apply(oldAddEventListener, document, [event, eventFn, options]); }; document.removeEventListener = (event, fn, options) => { const eventFn = resetOverrideEvent(event, fn); Reflect.apply(oldRemoveEventListener, document, [event, eventFn, options]); };
js 沙箱
主要是隔離掛載在window上的變量,而qiankun內部已經幫你處理好了。在子應用運行時訪問的window實際上是一個Proxy代理對象。 全部子應用的全局變量變動都是在閉包中產生的,不會真正回寫到 window 上,這樣就能避免多實例之間的污染了。
圖源:前端優選
複用公共依賴
好比:企業中的util、core、request、ui等公共依賴,在微前端中,咱們不須要每一個子應用都加載一次,這樣既浪費資源而且還會致使原本單例的對象,變成了多例。 在webpack中配置externals。把須要複用的排除打包,而後在index.html中加載排除的lib外鏈(子應用須要在script或者style標籤加上ignore屬性,有了這個屬性,qiankun 便不會再去加載這個 js/css,而子項目獨立運行,這些 js/css 仍能被加載)
<link ignore rel="stylesheet" href="//element-ui.css"> <script ignore src="//element-ui.js"></script>
externals: { 'element-ui': { commonjs: 'element-ui', commonjs2: 'element-ui', amd: 'element-ui', root: 'ElementUI' // 外鏈cdn加載掛載到window上的變量名 } }
父子共享(以國際化爲例)
應用註冊時或加載時,將依賴傳遞給子項目
// 註冊 registerMicroApps([ { name: 'micro-1', entry: 'http://localhost:9001/micro-1', container: '#micro-1', activeRule: '/micro-1', props: { i18n: this.$i18n } }, ]); // 手動加載 loadMicroApp({ name, entry, container: `#${this.boxId}`, props: { i18n: this.$i18n } });
子應用啓動時獲取props參數初始化
let { i18n } = props; if (!i18n) { // 當獨立運行時或主應用未共享時,動態加載本地國際化 const module = await import('@/common-module/lang'); i18n = module.default; } new Vue({ i18n, router, render });
主應用在註冊子應用或者手動加載子應用時把共享的變量經過props傳遞給子應用,子應用在bootstrap或者mount鉤子函數中獲取,若是沒有從props中獲取到該變量,子應用則動態加載本地變量。
keep-alive(Vue)
其實並不建議作keepAlive,可是我仍是作了,我能說什麼…
網上有其餘方案,我沒有采納,我在這裏說下個人方案吧(綜合了網上的方案),使用loadMicroApp手動加載和卸載子應用。這裏有幾個難點:
// microApp.js (能夠走CI/CD運維配置,也能夠經過接口從服務器獲取) const apps = [{ name: 'micro-1', activeRule: '/micro-1' }, { name: 'micro-2', activeRule: '/micro-2', prefetch: true }, { name: 'micro-3', activeRule: '/micro-3', prefetch: false, // 預加載資源 preload: false, // 預渲染 keepalive: true // 緩存子應用 }]; export default apps.map(app => ({ ...app, entry: getEntryUrl(app.name) }));
<template> <div v-show="isActive" :id="boxId" :class="b()" /> </template> <script> import { loadMicroApp } from 'qiankun'; export default { name: 'MicroApp', props: { app: { type: Object, required: true } }, inject: ['appLayout'], computed: { boxId() { return `micro-app_${this.app.name}`; }, activeRule() { return this.app.activeRule; }, currentPath() { return this.$route.fullPath; }, // 判斷當前子應用是否爲激活狀態 isActive() { const {activeRule, currentPath} = this; const rules = Array.isArray(activeRule) ? [ ...activeRule ] : [activeRule]; return rules.some(rule => { if (typeof rule === 'function') { return rule(currentPath); } return currentPath.startsWith(`${rule}`); }); }, isKeepalive() { return this.app.keepalive; } }, watch: { isActive: { handler() { this.onActiveChange(); } } }, created () { // 須要等spa start後再加載應用,纔會有shadow節點 this.$once('started', () => { this.init(); }); // 把當前實例加入到layout中 this.appLayout.apps.set(this.app.name, this); }, methods: { init() { // 預掛載 if (this.app.preload) { this.load(); } // 若是路由直接進入當前應用則會在這裏掛載 this.onActiveChange(); }, /** * 加載微應用 * @returns {Promise<void>} */ async load() { if (!this.appInstance) { const { name, entry, preload } = this.app; this.appInstance = loadMicroApp({ name, entry, container: `#${this.boxId}`, props: { ..., appName: name, preload, active: this.isActive } }); await this.appInstance.mountPromise; } }, /** * 狀態變動 * @returns {Promise<void>} */ async onActiveChange() { // 觸發全局事件 this.eventBus.$emit(`${this.isActive ? 'activated' : 'deactivated'}:${this.app.name}`); // 若是當前爲激活則加載 if (this.isActive) { await this.load(); } // 若是當前爲失效而且當前應用已加載而且配置爲不緩存則卸載當前應用 if (!this.isActive && this.appInstance && !this.isKeepalive) { await this.appInstance.unmount(); this.appInstance = null; } // 通知佈局當前狀態變動 this.$emit('active', this.isActive); } } }; </script>
// App.vue (layout) <template> <template v-if="!isMicroApp"> <keep-alive> <router-view v-if="keepAlive" /> </keep-alive> <router-view v-if="!keepAlive" /> </template> <micro-app v-for="app of microApps" :key="app.name" :app="app" @active="onMicroActive" /> </template> <script> computed: { isMicroApp() { return !!this.currentMicroApp; } }, mounted () { // 啓動qiankun主應用,開啓多例與嚴格樣式隔離沙箱(shadow dom) start({ singular: false, sandbox: { strictStyleIsolation: true } }); // 過濾出須要預加載的子應用進行資源預加載 const prefetchAppList = this.microApps.filter(item => item.prefetch); if (prefetchAppList.length) { // 延遲執行,放置影響當前訪問的應用資源加載 (window.requestIdleCallback || setTimeout)(() => prefetchApps(prefetchAppList)); } // 觸發微應用的初始化事件,表明spa已經started了 this.appValues.forEach(app => app.$emit('started')); }, methods: { onMicroActive() { this.currentMicroApp = this.appValues.find(item => item.isActive); } } </script>
路由的響應,若是咱們不卸載keepAlive的子應用,則子應用依然會響應路由的變化,從而致使子應用的當前路由已經不是離開時的路由了。
/** * 讓vue-router支持keepalive,當主路由變動時若是當前子應用沒有該路由則不作處理 * 由於經過瀏覽器前進後退會先觸發主路由的監聽,致使沒有及時通知到子應用deactivated,則子應用路由沒有及時中止監聽,則會處理本次主路由變動 * @param router */ const supportKeepAlive = (router) => { const old = router.history.transitionTo; router.history.transitionTo = (location, cb) => { const matched = router.getMatchedComponents(location); if (!matched || !matched.length) { return; } Reflect.apply(old, router.history, [location, cb]); }; }; // 重寫監聽路由變動事件 supportKeepAlive(instance.$router); // 若是爲預掛載而且當前不爲激活狀態則中止監聽路由,並設置_startLocation爲空,爲了在激活的時候能夠響應 if (preload && !active) { // 若是當前子應用不是預加載(我這裏作了多個子應用並存且能夠預加載),而且訪問的不是當前子應用則把路由中止 instance.$router.history.teardown(); instance.$router.history._startLocation = ''; }
頁面的activated與deactivated觸發。
// 在子應用建立的時候監聽激活與失效事件 if (eventBus) { eventBus.$on(`activated:${appName}`, activated); eventBus.$on(`deactivated:${appName}`, deactivated); } /** * 獲取當前路由的組件 * @returns {*} */ const getCurrentRouteInstance = () => { const {matched} = instance?.$route || {}; if (matched?.length) { const { instances } = matched[matched.length - 1]; if (instances) { return instances.default || instances; } } }; /** * 觸發當前路由組件hook * @param hook */ const fireCurrentRouterInstanceHook = (hook) => { const com = getCurrentRouteInstance(); const fns = com?.$options?.[hook]; if (fns) { fns.forEach(fn => Reflect.apply(fn, com, [{ micro: true }])); } }; /** * 激活當前子應用回調 */ const activated = () => { instance?.$router.history.setupListeners(); console.log('setupListeners'); fireCurrentRouterInstanceHook('activated'); }; /** * 被 keep-alive 緩存的組件停用時調用。 */ const deactivated = () => { instance?.$router.history.teardown(); console.log('teardown'); fireCurrentRouterInstanceHook('deactivated'); };
vuex 全局狀態共享
(慎用!破壞了vuex的理念, 不適用於大量的數據)
子應用使用本身的vuex,並非真正的使用主應用的vuex。須要共享的vuex模塊主應用與子應用理論來講是引用的相同的文件,咱們在這個vuex模塊標記它是否須要共享,並watch主應用與子應用的該模塊。
當子應用中的state發生了改變則更新主應用的state,相反主應用的state變動後也一樣修改子應用的state。
/** * 獲取命名空間狀態數據 * @param state 狀態數據 * @param namespace 命名空間 * @returns {*} */ const getNamespaceState = (state, namespace) => namespace === 'root' ? state : get(state, namespace); /** * 更新狀態數據 * @param store 狀態存儲 * @param namespace 命名空間 * @param value 新的值 * @returns {*} */ const updateStoreState = (store, namespace, value) => store._withCommit(() => setVo(getNamespaceState(store.state, namespace), value)); /** * 監聽狀態存儲 * @param store 狀態存儲 * @param fn 變動事件函數 * @param namespace 命名空間 * @returns {*} * @private */ const _watch = (store, fn, namespace) => store.watch(state => getNamespaceState(state, namespace), fn, { deep: true }); const updateSubStoreState = (stores, ns, value) => stores.filter(s => s.__shareNamespaces.has(ns)).forEach(s => updateStoreState(s, ns, value)); export default (store, mainStore) => { // 若是有主應用存儲則開啓共享 if (mainStore) { // 多個子應用與主應用共享時判斷主應用存儲是否已經標記爲已共享 if (mainStore.__isShare !== true) { // 全部子應用狀態 mainStore.__subStores = new Set(); // 已監聽的命名空間 mainStore.__subWatchs = new Map(); mainStore.__isShare = true; } // 把當前子應用存儲放入主應用裏面 mainStore.__subStores.add(store); const shareNames = new Set(); const { _modulesNamespaceMap: moduleMap } = store; // 監聽當前store,更新主應用store,並統計該子應用須要共享的全部命名空間 Object.keys(moduleMap).forEach(key => { const names = key.split('/').filter(k => !!k); // 若是該命名空間的上級命名空間已經共享則下級不須要再共享 const has = names.some(name => shareNames.has(name)); if (has) { return; } const { _rawModule: { share } } = moduleMap[key]; if (share === true) { const namespace = names.join('.'); // 監聽當前子應用存儲的命名空間,發生變化後更新主應用與之同名的命名空間數據 _watch(store, value => updateStoreState(mainStore, namespace, value), namespace); shareNames.add(namespace); } }); // 存儲當前子應用須要共享的命名空間 store.__shareNamespaces = shareNames; shareNames.forEach(ns => { // 從主應用同步數據 updateStoreState(store, ns, getNamespaceState(mainStore.state, ns)); if (mainStore.__subWatchs.has(ns)) { return; } // 監聽主應用的狀態,更新子應用存儲 const w = mainStore.watch(state => getNamespaceState(state, ns), value => updateSubStoreState([...mainStore.__subStores], ns, value), { deep: true }); console.log(`主應用store監聽模塊【${ns}】數據`); mainStore.__subWatchs.set(ns, w); }); } return store; };
看到這裏,你必定也驚歎於微前端的精妙吧!紙上得來終覺淺,期待各位的實踐行動,若是遇到任何問題,歡迎關注咱們 [LigaAI@OSChina](https://my.oschina.net/u/5057806) ,一塊兒交流,共同進步~更多詳情,請點擊咱們的官方網站 LigaAI-新一代智能研發管理平臺
本文部份內容參考: Micro Frontends、Micro Frontends from martinfowler.com、微前端的核心價值、qiankun介紹
本文做者: Alone zhou