關注核心實現請直接跳至 第四小節:執行流程。javascript
本文中的命令僅適用於支持shell的系統,如Mac、烏班圖及其餘linux發行版。不適用於windows,若是想在windows下執行文章中的命令請使用git命令窗口(需安裝git)或linux子系統(win10如下不支持)。
一、初始化工程目錄html
cd ~ && mkdir my-single-spa && cd "$_"
二、初始化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
模塊名稱 | 說明 |
---|---|
@babel/core | babel編譯器的核心庫,負責全部babel預設和插件的加載及執行 |
@babel/plugin-syntax-dynamic-import | 支持使用import() 進行動態導入,當前在Stage 4: finished 的階段 |
@babel/preset-env | 預設:爲方便開發提供的經常使用的插件集合 |
rollup | javascript打包工具,在打包方面比webpack更加的純粹 |
rollup-plugin-babel | 讓rollup支持babel,開發者可使用高級js語法 |
rollup-plugin-commonjs | 將commonjs模塊轉換爲ES6 |
rollup-plugin-node-resolve | 讓rollup支持nodejs的模塊解析機制 |
rollup-plugin-serve | 支持dev serve,方便調試和開發 |
三、配置babel和rollupjava
建立babel.config.jsnode
# 建立babel.config.js touch babel.config.js
添加內容:linux
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.jswebpack
# 建立rollup.config.js touch rollup.config.js
添加內容:git
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 ] }
四、在package.json中添加script和browserslist字段github
{ "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" ] }
四、添加項目文件夾web
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,微前端的場景主要是:將應用拆分爲多個app加載,或將多個不一樣的應用當成app組合在一塊兒加載。
爲了更好的約束app和行爲,要求每一個app必須向外export完整的生命週期函數,使微前端框架能夠更好地跟蹤和控制它們。
// app1 export 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函數的數組。
爲了更好的管理app,特意給app增長了狀態,每一個app共存在11個狀態,其中每一個狀態的流轉圖以下:
狀態說明(app和service在下表統稱爲app):
狀態 | 說明 | 下一個狀態 |
---|---|---|
NOT_LOADED | app還未加載,默認狀態 | LOAD_SOURCE_CODE |
LOAD_SOURCE_CODE | 加載app模塊中 | NOT_BOOTSTRAPPED、SKIP_BECAUSE_BROKEN、LOAD_ERROR |
NOT_BOOTSTRAPPED | app模塊加載完成,可是還未啓動(未執行app的bootstrap 生命週期函數) |
BOOTSTRAPPING |
BOOTSTRAPPING | 執行app的bootstrap 生命週期函數中(只執行一次) |
SKIP_BECAUSE_BROKEN |
NOT_MOUNTED | app的bootstrap 或unmount 生命週期函數執行成功,等待執行mount 生命週期函數(可屢次執行) |
MOUNTING |
MOUNTING | 執行app的mount 生命週期函數中 |
SKIP_BECAUSE_BROKEN |
MOUNTED | app的mount 或update(service獨有) 生命週期函數執行成功,意味着此app已掛載成功,可執行Vue的$mount()或ReactDOM的render() |
UNMOUNTING、UPDATEING |
UNMOUNTING | app的unmount 生命週期函數執行中,意味着此app正在卸載中,可執行Vue的$destory()或ReactDOM的unmountComponentAtNode() |
SKIP_BECAUSE_BROKEN、NOT_MOUNTED |
UPDATEING | service更新中,只有service纔會有此狀態,app則沒有 |
SKIP_BECAUSE_BROKEN、MOUNTED |
SKIP_BECAUSE_BROKEN | app變動狀態時碰見錯誤,若是app的狀態變爲了SKIP_BECAUSE_BROKEN ,那麼app就會blocking ,不會往下個狀態變動 |
無 |
LOAD_ERROR | 加載錯誤,意味着app將沒法被使用 | 無 |
load、mount、unmount條件
判斷須要被加載(load)的App:
判斷須要被掛載(mount)的App:
判斷須要被卸載(unmount)的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事件循環類似,大致執行流程以下:
整個系統的觸發時機分爲兩類:
registerApplication()
或start()
方法。每經過觸發時機進行一次觸發操做,都會被存放到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觸發時機老是首先被執行,而Vue或React的Router老是在後面執行。
微前端框架倉庫地址:https://github.com/YataoZhang...