關注核心實現請直接跳至 第四小節:執行流程。javascript
本文中的命令僅適用於支持shell的系統,如Mac、烏班圖及其餘linux發行版。不適用於windows,若是想在windows下執行文章中的命令請使用git命令窗口(需安裝git)或linux子系統(win10如下不支持)。html
一、初始化工程目錄前端
cd ~ && mkdir my-single-spa && cd "$_"
複製代碼
二、初始化npm環境java
# 初始化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
複製代碼
模塊說明:node
模塊名稱 | 說明 |
---|---|
@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和rolluplinux
建立babel.config.jswebpack
# 建立babel.config.js
touch babel.config.js
複製代碼
添加內容:git
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.jsgithub
# 建立rollup.config.js
touch rollup.config.js
複製代碼
添加內容:web
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字段
{
"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"
]
}
複製代碼
四、添加項目文件夾
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老是在後面執行。
微前端框架倉庫地址:github.com/YataoZhang/…