前言
關注核心實現請直接跳至 第四小節:執行流程。
本文中的命令僅適用於支援shell的系統,如Mac、烏班圖及其他linux發行版。不適用於windows,如果想在windows下執行文章中的命令請使用git命令視窗(需安裝git)或linux子系統(win10以下不支援)。
一、初始化工程
1、初始化工程目錄
cd ~ && mkdir my-single-spa && cd "$_"2、初始化npm環境
# 初始化package.json檔案npm init -y# 安裝dev依賴npm install @babel/core @babel/plugin-syntax-dynamic-import @babel/preset-env rollup rollup-plugin-babel rollup-plugin-commonjs rollup-plugin-node-resolve rollup-plugin-serve -D模組說明:
模組說明
3、配置babel和rollup
建立babel.config.js
# 建立babel.config.jstouch babel.config.js新增內容:
module.export = function (api) { // 快取babel的配置 api.cache(true); // 等同於api.cache.forever() return { presets: [ ['@babel/preset-env', {module: false}] ], plugins: ['@babel/plugin-syntax-dynamic-import'] };};建立rollup.config.js
# 建立rollup.config.jstouch rollup.config.js新增內容:
import resolve from 'rollup-plugin-node-resolve';import babel from 'rollup-plugin-babel';import commonjs from 'rollup-plugin-commonjs';import serve from 'rollup-plugin-serve';export default { input: './src/my-single-spa.js', output: { file: './lib/umd/my-single-spa.js', format: 'umd', name: 'mySingleSpa', sourcemap: true }, plugins: [ resolve(), commonjs(), babel({exclude: 'node_modules/**'}), // 見下方的package.json檔案script欄位中的serve命令 // 目的是隻有執行serve命令時才啟動這個外掛 process.env.SERVE ? serve({ open: true, contentBase: '', openPage: '/toutrial/index.html', host: 'localhost', port: '10001' }) : null ]}4、在package.json中新增script和browserslist欄位
{ "script": { "build:dev": "rollup -c", "serve": "SERVE=true rollup -c -w" }, "browserslist": [ "ie >=11", "last 4 Safari major versions", "last 10 Chrome major versions", "last 10 Firefox major versions", "last 4 Edge major versions" ]}5、新增專案資料夾
mkdir -p src/applications src/lifecycles src/navigation src/services toutrial && touch src/my-single-spa.js && touch toutrial/index.html到目前為止,整個專案的資料夾結構應該是:
.├── babel.config.js├── package-lock.json├── package.json├── rollup.config.js├── node_modules├── toutrial| └── index.html└── src ├── applications ├── lifecycles ├── my-single-spa.js ├── navigation └── services到此,專案就已經初始化完畢了,接下來開始核心的內容,微前端框架的編寫。
二、app相關概念
1、app要求
微前端的核心為app,微前端的場景主要是:將應用拆分為多個app載入,或將多個不同的應用當成app組合在一起載入。
為了更好的約束app和行為,要求每個app必須向外export完整的生命週期函式,使微前端框架可以更好地跟蹤和控制它們。
// app1export default { // app啟動 bootstrap: [() => Promise.resolve()], // app掛載 mount: [() => Promise.resolve()], // app解除安裝 unmount: [() => Promise.resolve()], // service更新,只有service才可用 update: [() => Promise.resolve()]}生命週期函式共有4個:bootstrap、mount、unmount、update。 生命週期可以傳入 返回Promise的函式也可以傳入 返回Promise函式的陣列。
2、app狀態
為了更好的管理app,特地給app增加了狀態,每個app共存在11個狀態,其中每個狀態的流轉圖如下:
狀態說明(app和service在下表統稱為app):
load、mount、unmount條件 判斷需要被載入(load)的App:
判斷需要被掛載(mount)的App:
判斷需要被解除安裝(unmount)的App:
3、app生命週期函式和超時的處理
app的生命週期函式何以傳入陣列或函式,但是它們都必須返回一個Promise,為了方便處理,所以我們會判斷:如果傳入的不是Array,就會用陣列將傳入的函式包裹起來。
export function smellLikeAPromise(promise) { if (promise instanceof Promise) { return true; } return typeof promise === 'object' && promise.then === 'function' && promise.catch === 'function';}export function flattenLifecyclesArray(lifecycles, description) { if (Array.isArray(lifecycles)) { lifecycles = [lifecycles] } if (lifecycles.length === 0) { lifecycles = [() => Promise.resolve()]; } // 處理lifecycles return props => new Promise((resolve, reject) => { waitForPromise(0); function waitForPromise(index) { let fn = lifecycles[index](props); if (!smellLikeAPromise(fn)) { reject(`${description} at index ${index} did not return a promise`); return; } fn.then(() => { if (index >= lifecycles.length - 1) { resolve(); } else { waitForPromise(++index); } }).catch(reject); } });}// 示例app.bootstrap = [ () => Promise.resolve(), () => Promise.resolve(), () => Promise.resolve()];app.bootstrap = flattenLifecyclesArray(app.bootstrap);
具體的流程如下圖所示:
思考:如果用reduce的話怎麼寫?有什麼需要注意的問題麼?
為了app的可用性,我們還講給每個app的生命週期函式增加超時的處理。
// flattenedLifecyclesPromise為經過上一步flatten處理過的生命週期函式export function reasonableTime(flattenedLifecyclesPromise, description, timeout) { return new Promise((resolve, reject) => { let finished = false; flattenedLifecyclesPromise.then((data) => { finished = true; resolve(data) }).catch(e => { finished = true; reject(e); }); setTimeout(() => { if (finished) { return; } let error = `${description} did not resolve or reject for ${timeout.milliseconds} milliseconds`; if (timeout.rejectWhenTimeout) { reject(new Error(error)); } else { console.log(`${error} but still waiting for fulfilled or unfulfilled`); } }, timeout.milliseconds); });}// 示例reasonableTime(app.bootstrap(props), 'app bootstraping', {rejectWhenTimeout: false, milliseconds: 3000}) .then(() => { console.log('app 啟動成功了'); console.log(app.status === 'NOT_MOUNTED'); // => true }) .catch(e => { console.error(e); console.log('app啟動失敗'); console.log(app.status === 'SKIP_BECAUSE_BROKEN'); // => true });三、路由攔截
微前端中app分為兩種:一種是根據Location進行變化的,稱之為app。另一種是純功能(Feature)級別的,稱之為service。
如果要實現隨Location的變化動態進行mount和unmount那些符合條件的app,我們就需要對瀏覽器的Location相關操作做統一的攔截。另外,為了在使用Vue、React等檢視框架時降低衝突,我們需要保證微前端必須是第一個處理Location的相關事件,然後才是Vue或React等框架的Router處理。
為什麼Location改變時,微前端框架一定要第一個執行相關操作哪?如何保證"第一個"?
因為微前端框架要根據Location來對app進行mount或unmount操作。然後app內部使用的Vue或React才開始真正進行後續工作,這樣可以最大程度減少app內部Vue或React的無用(冗餘)操作。
對原生的Location相關事件進行攔截(hijack),統一由微前端框架進行控制,這樣就可以保證總是第一個執行。
const HIJACK_EVENTS_NAME = /^(hashchange|popstate)$/i;const EVENTS_POOL = { hashchange: [], popstate: []};function reroute() { // invoke主要用來load、mount、unmout滿足條件的app // 具體條件請看文章上方app狀態小節中的"load、mount、unmount條件" invoke([], arguments)}window.addEventListener('hashchange', reroute);window.addEventListener('popstate', reroute);const originalAddEventListener = window.addEventListener;const originalRemoveEventListener = window.removeEventListener;window.addEventListener = function (eventName, handler) { if (eventName && HIJACK_EVENTS_NAME.test(eventName) && typeof handler === 'function') { EVENTS_POOL[eventName].indexOf(handler) === -1 && EVENTS_POOL[eventName].push(handler); } return originalAddEventListener.apply(this, arguments);};window.removeEventListener = function (eventName, handler) { if (eventName && HIJACK_EVENTS_NAME.test(eventName)) { let eventsList = EVENTS_POOL[eventName]; eventsList.indexOf(handler) > -1 && (EVENTS_POOL[eventName] = eventsList.filter(fn => fn !== handler)); } return originalRemoveEventListener.apply(this, arguments);};function mockPopStateEvent(state) { return new PopStateEvent('popstate', {state});}// 攔截history的方法,因為pushState和replaceState方法並不會觸發onpopstate事件,所以我們即便在onpopstate時執行了reroute方法,也要在這裡執行下reroute方法。const originalPushState = window.history.pushState;const originalReplaceState = window.history.replaceState;window.history.pushState = function (state, title, url) { let result = originalPushState.apply(this, arguments); reroute(mockPopStateEvent(state)); return result;};window.history.replaceState = function (state, title, url) { let result = originalReplaceState.apply(this, arguments); reroute(mockPopStateEvent(state)); return result;};// 再執行完load、mount、unmout操作後,執行此函式,就可以保證微前端的邏輯總是第一個執行。然後App中的Vue或React相關Router就可以收到Location的事件了。export function callCapturedEvents(eventArgs) { if (!eventArgs) { return; } if (!Array.isArray(eventArgs)) { eventArgs = [eventArgs]; } let name = eventArgs[0].type; if (!HIJACK_EVENTS_NAME.test(name)) { return; } EVENTS_POOL[name].forEach(handler => handler.apply(window, eventArgs));}四、執行流程(核心)
整個微前端框架的執行順序和js事件迴圈相似,大體執行流程如下:
觸發時機
整個系統的觸發時機分為兩類:
瀏覽器觸發:瀏覽器Location發生改變,攔截onhashchange和onpopstate事件,並mock瀏覽器history的pushState()和replaceState()方法。手動觸發:手動呼叫框架的registerApplication()或start()方法。修改佇列(changesQueue)
每通過觸發時機進行一次觸發操作,都會被存放到changesQueue佇列中,它就像事件迴圈的事件佇列一樣,靜靜地等待被處理。如果changesQueue為空,則停止迴圈直至下一次觸發時機到來。
和js事件迴圈佇列不同的是,changesQueue是當前迴圈內的所有修改(changes)會綁成一批(batch)同時執行,而js事件迴圈是一個一個地執行。
"事件"迴圈
在每一次迴圈的開始階段,會先判斷整個微前端的框架是否已經啟動。
未啟動: 根據規則(見上文的『判斷需要被載入(load)的App』)載入需要被載入的app,載入完成之後呼叫內部的finish方法。
已啟動: 根據規則獲取當前因為不滿足條件而需要被解除安裝(unmount)的app、需要被載入(load)的app以及需要被掛載(mount)的app,將load和mount的app先合併在一起進行去重,等unmout完成之後再統一進行mount。然後再等到mount執行完成之後就會呼叫內部的finish方法。
可以通過呼叫mySingleSpa.start()來啟動微前端框架。
通過上文我們可以發現不管是當前的微前端框架的狀態是未啟動或已啟動,最終都會呼叫內部的finish方法。其實,finish方法的內部很簡單,判斷當前的changesQueue是否為空,如果不為空則重新啟動下一次迴圈,如果為空則終止終止迴圈,退出整個流程。
function finish() { // 獲取成功mount的app let resolveValue = getMountedApps(); // pendings是上一次迴圈進行時儲存的一批changesQueue的別名 // 其實就是下方呼叫invoke方法的backup變數 if (pendings) { pendings.forEach(item => item.success(resolveValue)); } // 標記迴圈已結束 loadAppsUnderway = false; // 發現changesQueue的長度不為0 if (pendingPromises.length) { const backup = pendingPromises; pendingPromises = []; // 將『修改佇列』傳入invoke方法,並開啟下一次迴圈 return invoke(backup); } // changesQueue為空,終止迴圈,返回已mount的app return resolveValue;}location事件
另外在每次迴圈終止時都會將已攔截的location事件進行觸發,這樣就可以保證上文說的微前端框架的location觸發時機總是首先被執行,而Vue或React的Router總是在後面執行。
最後關於微前端框架倉庫地址如何獲取,首先關注我,並且私信我回復“教程”即可免費獲取!
-
1 #
-
2 #
只是一個使用框架教程吧
你哪個部門?沒聽說過你。