從微服務(wù)到微前端:淺談微前端的設(shè)計思想
記得作為實(shí)習(xí)生剛到公司的第一天就被什么monorepo、微前端等名詞搞得一頭霧水,經(jīng)過一段時間的學(xué)習(xí)終于摸清了一點(diǎn)門道,那么今天就來和大家聊一下我對微前端設(shè)計的看法和理解。
1、引入:什么是微服務(wù)?
微服務(wù)是近幾年在互聯(lián)網(wǎng)業(yè)界內(nèi)非常?? 的一個詞,在俺們大學(xué)沸點(diǎn)工作室Java組也已經(jīng)有Spring Cloud微服務(wù)的實(shí)踐先例,那我們作為前端的角度,該怎么理解微服務(wù)呢?
可以看看下面這個?? :
一個系統(tǒng)有PC Web端、手機(jī)H5、和后臺管理系統(tǒng),那么整個系統(tǒng)的結(jié)構(gòu)大概就像這樣:

這樣會造成什么問題嘞?
單體服務(wù)端項目過大,不利于快速上手和打包編譯; 不同系統(tǒng)會有相同的功能點(diǎn),導(dǎo)致產(chǎn)生大量重復(fù)的無意義的接口; 數(shù)據(jù)庫設(shè)計復(fù)雜。
那么微服務(wù)又是怎么解決的嘞?
核心就是就是將系統(tǒng)拆分成不同的服務(wù),通過網(wǎng)關(guān)和controller來進(jìn)行簡單的控制和調(diào)用,各服務(wù)分而治之、互不影響。
我們現(xiàn)在再看一哈新的項目結(jié)構(gòu):

通過服務(wù)的拆分后,我們的系統(tǒng)是不是更加清晰了?? ,那么問題來了?
這和我們本次的主題,微前端有什么關(guān)系嗎
前端的微前端思想其實(shí)同樣來自于此:通過拆分服務(wù),實(shí)現(xiàn)邏輯的解耦。
2、前端微服務(wù)設(shè)計
2.1 為什么前端需要微服務(wù)?
當(dāng)我們create一個新項目后,想必各位都有以下體會:
寫項目的第一天:打包 20s
寫項目的一周:打包 1min
寫項目的一個月:打包 5min
之前體驗過公司老項目,代碼量非常大,可讀性不高,打包需要10+分鐘。
隨著項目體量的增加,一個巨大的單體應(yīng)用是難以維護(hù)的,從而導(dǎo)致:開發(fā)效率低、上線困難等一系列問題。
2.2 微前端的應(yīng)用場景
對于一個管理系統(tǒng),它的頁面通常是長這個樣子的:

側(cè)邊欄的每一個tab,下面可能還有若干的二級節(jié)點(diǎn)甚至是三級節(jié)點(diǎn),久而久之,這樣的一個管理系統(tǒng),終究也會像前面提到的服務(wù)端一樣,難以維護(hù)。
如果我們用微前端該如何設(shè)計呢?
每一個tab就是一個子應(yīng)用,有自己的狀態(tài);自己的作用域;并且單獨(dú)打包發(fā)布。在全局層面只需要用一個主應(yīng)用(master)就可以實(shí)現(xiàn)管理和控制。
一句話來講就是:應(yīng)用分發(fā)路由->路由分發(fā)應(yīng)用。
2.3 早期微前端思路——iFrame
Why not iframe ?
對于路由分發(fā)應(yīng)用這件事:我們只需要通過iFrame就可以實(shí)現(xiàn)了,當(dāng)點(diǎn)擊不同的tab時,view區(qū)域展示的是iFrame組件,根據(jù)路由動態(tài)的改變iframe的src屬性,那不是so easy?
它的好處有哪些?
自帶樣式 沙盒機(jī)制(環(huán)境隔離) 前端之間可以相互獨(dú)立運(yùn)行
那我們?yōu)槭裁礇]有使用iFrame做微前端呢?
CSS問題(視窗大小不同步) 子應(yīng)用通信(使用postMessage并不友好) 組件不能共享 使用創(chuàng)建 iframe,可能會對性能或者內(nèi)存造成影響
微前端的設(shè)計構(gòu)思:不僅能繼承iframe的優(yōu)點(diǎn),又可以解決它的不足。
3、微前端核心邏輯
3.1 子應(yīng)用加載(Loader)
先來看看微前端的流程:

我們可以達(dá)成的共識是:需要先加載基座(master),再把選擇權(quán)交給主應(yīng)用,由主應(yīng)用根據(jù)注冊過的子應(yīng)用來抉擇加載誰,當(dāng)子應(yīng)用加載成功后,再由vue-router或react-router來根據(jù)路由渲染組件。
3.1.1 注冊
如果精簡代碼邏輯,在基座中實(shí)際上只需要做三件事:
// 假設(shè)我們的微前端框架叫hailuo
import Hailuo from './lib/index';
// 1. 聲明子應(yīng)用
const routers = [
{
path: 'http://localhost:8081',
activeWhen: '/subapp1'
},
{
path: 'http://localhost:8082',
activeWhen: '/subapp2'
}
];
// 2. 注冊子應(yīng)用
Hailuo.registerApps(routers);
// 3. 運(yùn)行微前端
Hailuo.run();
注冊非常好理解,用一個數(shù)組維護(hù)所有已經(jīng)注冊了的子應(yīng)用:
registerApps(routers: Router[]) {
(routers || []).forEach((r) => {
this.Apps.push({
entry: r.path,
activeRule: (location) => (location.href.indexOf(r.activeWhen) !== -1)
});
});
}
3.1.2 攔截
我們需要通過攔截注冊路由事件以保證主/子應(yīng)用的邏輯處理時機(jī)。
import Hailuo from ".";
// 需要攔截的實(shí)踐
const EVENTS_NAME = ['hashchange', 'popstate'];
// 實(shí)踐收集
const EVENTS_STACKS = {
hashchange: [],
popstate: []
};
// 基座切換路由后的邏輯
const handleUrlRoute = (...args) => {
// 加載對應(yīng)的子應(yīng)用
Hailuo.loadApp();
// 執(zhí)行子應(yīng)用路由的方法
callAllEventListeners(...args);
};
export const patch = () => {
// 1. 先保證基座的事件監(jiān)聽路由的變化
window.addEventListener('hashchange', handleUrlRoute);
window.addEventListener('popstate', handleUrlRoute);
// 2. 重寫addEventListener和removeEventListener
// 當(dāng)遇到路由事件后:收集到stack中
// 如果是其他事件:執(zhí)行original事件監(jiān)聽方法
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = (name, handler) => {
if(name && EVENTS_NAME.includes(name) && typeof handler === "function") {
EVENTS_STACKS[name].indexOf(handler) === -1 && EVENTS_STACKS[name].push(handler);
return;
}
return originalAddEventListener.call(this, name, handler);
};
window.removeEventListener = (name, handler) => {
if(name && EVENTS_NAME.includes(name) && typeof handler === "function") {
EVENTS_STACKS[name].indexOf(handler) === -1 &&
(EVENTS_STACKS[name] = EVENTS_STACKS[name].filter((fn) => (fn !== handler)));
return;
}
return originalRemoveEventListener.call(this, name, handler);
};
// 手動給pushState和replaceState添加上監(jiān)聽路由變化的能力
// 有點(diǎn)像vue2中數(shù)組的變異方法
const createPopStateEvent = (state: any, name: string) => {
const evt = new PopStateEvent("popstate", { state });
evt['trigger'] = name;
return evt;
};
const patchUpdateState = (updateState: (data: any, title: string, url?: string)=>void, name: string) => {
return function() {
const before = window.location.href;
updateState.apply(this, arguments);
const after = window.location.href;
if(before !== after) {
handleUrlRoute(createPopStateEvent(window.history.state, name));
}
};
}
window.history.pushState = patchUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchUpdateState(
window.history.replaceState,
"replaceState"
);
}
3.1.3 加載
通過路由可以匹配到符合的子應(yīng)用后,那么該如何將它加載到頁面呢?
我們知道SPA的html文件只是一個空模板,實(shí)質(zhì)是通過js驅(qū)動的頁面渲染,那么我們把某一個頁面的js文件,全都剪切到另一個html的<script>標(biāo)簽中執(zhí)行,就實(shí)現(xiàn)了A頁面加載B的頁面。
async loadApp() {
// 加載對應(yīng)的子應(yīng)用
const shouldMountApp = this.Apps.filter(this.isActive);
const app = shouldMountApp[0];
const subapp = document.getElementById('submodule');
await fetchUrl(app.entry)
// 將html渲染到主應(yīng)用里
.then((text) => {
subapp.innerHTML = text;
});
// 執(zhí)行 fetch到的js
const res = await fetchScripts(subapp, app.entry);
if(res.length) {
execScript(res.reduce((t, c) => (t+c), ''));
}
}
Better實(shí)踐 —— 【html-entry】
它是一個加載并處理html、js、css的庫。
它不是去加載一個個的js、css資源,而是去加載微應(yīng)用的入口html。
第一步 :發(fā)送請求,獲取子應(yīng)用入口HTML。 第二步 :處理該html文檔,去掉html、head標(biāo)簽,處理靜態(tài)資源。 第三步 :處理sourceMap;處理js沙箱;找到入口js。 第四步 :獲取子應(yīng)用provider內(nèi)容
同時,約束了子應(yīng)用提供加載和銷毀函數(shù)(這個結(jié)構(gòu)是不是很眼熟):
export function provider({ dom, basename, globalData }) {
return {
render() {
ReactDOM.render(
<App basename={basename} globalData={globalData} />,
dom ? dom.querySelector('#root') : document.querySelector('#root')
);
},
destroy({ dom }) {
if (dom) {
ReactDOM.unmountComponentAtNode(dom);
}
},
};
}
3.2 沙箱(Sandbox)
沙箱是什么:你可以理解為對作用域的一種比喻,在一個沙箱內(nèi),我的任何操作不會對外界產(chǎn)生影響。
Why we need sandbox?
當(dāng)我們集成了很多子應(yīng)用到一起后,勢必會出現(xiàn)沖突,如全局變量沖突、樣式?jīng)_突,這些沖突可能會導(dǎo)致應(yīng)用樣式異常,甚至功能不可用。所以想讓微前端達(dá)到生產(chǎn)可用的程度,讓每個子應(yīng)用之間達(dá)到一定程度隔離的沙箱機(jī)制是必不可少的。
實(shí)現(xiàn)沙箱,最重要的是:控制沙箱的開啟和關(guān)閉。
3.2.1 快照沙箱
原理就是運(yùn)行在某一環(huán)境A時,打一個快照,當(dāng)從別的環(huán)境B切換回來的時候,我們通過這個快照就可以立即恢復(fù)之前環(huán)境A時的情況,比如:
// 切換到環(huán)境A
window.a = 2;
// 切換到環(huán)境B
window.a = 3;
// 切換到環(huán)境A
console.log(a); // 2
實(shí)現(xiàn)思路,我們假設(shè)有Sandbox這個類:
class Sandbox {
private original;
private mutated;
sandBoxActive: () => void;
sandBoxDeactivate: () => void;
}
const sandbox = new Sandbox();
const code = "...";
sandbox.activate();
execScript(code);
sandbox.sandBoxDeactivate();
來理一下這個邏輯:
在sandBoxActive的時候,把變量存到original里; 在sandBoxDeactivate的時候,把當(dāng)前變量和original對比,不同的存到mutated(保存了快照),然后把變量的狀態(tài)恢復(fù)到original; 當(dāng)該沙箱再次觸發(fā)sandBoxActive,就可以把mutated的變量恢復(fù)到window上,實(shí)現(xiàn)沙箱的切換。
3.2.2 VM沙箱
類似于node中的vm模塊(可在 V8 虛擬機(jī)上下文中編譯和運(yùn)行代碼):http://nodejs.cn/api/vm.html#vm_vm_executing_javascript
快照沙箱的缺點(diǎn)是無法同時支持多個實(shí)例。 但是vm沙箱利用proxy就可以解決這個問題。
class SandBox {
execScript(code: string) {
const varBox = {};
const fakeWindow = new Proxy(window, {
get(target, key) {
return varBox[key] || window[key];
},
set(target, key, value) {
varBox[key] = value;
return true;
}
})
const fn = new Function('window', code);
fn(fakeWindow);
}
}
export default SandBox;
// 實(shí)現(xiàn)了隔離
const sandbox = new Sandbox();
sandbox.execScript(code);
const sandbox2 = new Sandbox();
sandbox2.execScript(code2);
// map
varBox = {
'aWindow': '...',
'bWindow': '...'
}
我們把各個子應(yīng)用的window放到map中,通過proxy代理,當(dāng)訪問時,直接就是訪問到的各個子應(yīng)用的window對象;如果沒有,比如使用window.addEventListener,就會去真正的window中尋找。
3.2.3 CSS沙箱
前提:webpack在構(gòu)建的時候,最終是通過appendChild去添加style標(biāo)簽到html里的
解決方案:劫持appendChild,增加namespace。
