開篇
時值當下,作為一名合格的前端開發(fā)人員,我相信你一定會有一個很明顯的感覺:Web 業(yè)務日益復雜化和多元化,前端的職責越來越重要,戰(zhàn)場越來越多樣,應用也越來越復雜,前端開發(fā)已經由WebPage 模式為主轉變?yōu)橐?WebApp 模式為主了——我們已經迎來了一個“大前端”的時代。
隨之也會帶來許多工程化的問題。例如:在一個相對長的時間跨度下,隨著時間的推移,越來越復雜的前端項目,會變的越來越龐大。如何保證項目的可維護性,開發(fā)質量,開發(fā)體驗......
介紹微前端
微前端是在2016年底的 ThoughtWorks Technology Radar 被提出 ,將微服務這個被廣泛應用于服務端的技術范式擴展到前端領域。

簡單通俗的來講就是:微前端可以使多個團隊之間使用不同技術棧,然后在客戶端(瀏覽器)運行時動態(tài)組成一個完整的 SPA 應用。(當然也有在構建時組合的方案,但并不符合微前端的核心思想,也不是主流微前端實現方式。)
總之,微前端是將微服務概念做了一個很好的延伸和實現。都是希望將某個單一的應用,轉化為多個可以獨立運行、獨立開發(fā)、獨立部署、獨立維護的服務或者應用,從而滿足業(yè)務快速變化以及多團隊可并行開發(fā)的需求。
微前端的應用場景
- 大型單頁應用。這類應用的特點是系統(tǒng)體量較大,而且隨著業(yè)務上的功能升級,項目體積還會不斷增大。
阿里云就是這個場景下微前端很好的實踐成果,采用微前端架構方式可無限擴展,其復雜度不會很明顯的增長。(微前端最先提出來的主要原因,就是如何將巨石應用解構)
- 系統(tǒng)重復模塊的復用。在多個獨立系統(tǒng)內部可能會開發(fā)一些重復度很高的功能,比如用戶管理,權限管理這些重復的功能。
微前端可以減少這些重復的開發(fā)成本。(理想情況下,可以將產品原子化,然后根據業(yè)務場景的需求,實現不同應用之間頁面級別的自由組合,且每個功能模塊都能單獨迭代)
- 遺留系統(tǒng)的兼容和擴展。例:一個已經存在了3,5年的項目,依賴版本落后,里面還存在著一些祖?zhèn)鞔a,因為種種原因項目不能及時升級。
微前端提供了一種增量升級的能力,在不重寫原有系統(tǒng)的基礎之上,實施漸進式重構。對于新功能的開發(fā)可以使用新的技術,避免了繼續(xù)使用過時的技術。極大的降低長期項目迭代維護的難度。
微前端的核心思想
技術無關:微應用之間可以選擇不同的技術棧。
環(huán)境獨立:為了達到高度解耦的目的,每個微應用不應當共享運行時環(huán)境,即使所有微應用都使用了相同的框架,它們之間也應該盡量避免依賴共享狀態(tài)或全局變量。(也就是應用之間的 css 和 js 隔離)
原生優(yōu)先:優(yōu)先使用瀏覽器原生事件進行通信。(如果確實必須跨應用進行通信,盡量讓通信內容和方式變得簡單,這樣能有效地減少微應用之間的公共依賴)
獨立開發(fā)、部署:微應用可獨立開發(fā),部署完成后主框架自動完成同步更新
為什么不用 iframe
說到微前端的方案,iframe 可以說是最簡單的微前端基石方案了,提供了瀏覽器原生的硬隔離方案,不論是 css 還是 js 隔離,然后正好也滿足了獨立運行,開發(fā),部署,維護?!珵槭裁此械默F代化微前端方案都不用iframe呢?
因為iframe最大的特性同時也是它最大的問題所在,它的隔離性無法被突破,導致應用間上下文無法被共享,隨之帶來的開發(fā)體驗、產品體驗的問題。
url 不同步。因為不是單頁面應用,瀏覽器刷新會導致 iframe url 狀態(tài)丟失、后退前進按鈕無法使用。
UI 不同步,DOM 結構不共享。iframe 內的彈窗無法應用到整個大應用中,只能在對應的窗口內展示。(iframe還不會自動調節(jié)寬高)
全局上下文完全隔離,內存變量不共享。就需要設計 iframe 應用之間的通信、數據同步等需求。(iframe 可以通過 postMessage通信)
慢。每次子應用進入都是一次瀏覽器上下文重建、資源重新加載的過程,占用大量資源的同時也在極大地消耗資源。
第1個問題可以解決,第4個問題不是不能忽略。第2和3很難解決。
(騰訊公開了一個基于 iframe 的微前端方案,但是要解決的問題很多,目前還沒開源)
現代微前端方案的選擇
微前端是一個技術應用架構體系,微前端架構解決方案大概分為兩類場景(技術實現角度):
單實例:即同一時刻,只有一個子應用被展示,子應用具備一個完整的應用生命周期。通?;?url 的變化來做子應用的切換。——基座模式的 qiankun(qiankun2.0 時支持了多應用并行)
多實例:同一時刻可展示多個子應用,子應用更像是一個業(yè)務組件而不是應用,可以說是微應用粒度的前端組件化?!ブ行哪J降?emp
emp
介紹:emp是YY業(yè)務中臺Web團隊的微前端解決方案。emp 基于 Webpack 5 的 Module Federation(模塊聯(lián)邦)實現,提供了在當前應用中遠程加載其他服務器上應用的能力(每個應用之間都可以彼此分享資源),將多個獨立構建應用組成一個應用程序。
優(yōu)點:第三方依賴可共享,減少重復的代碼加載。
缺點:無法涵蓋所有的框架,而且極度依賴于Webpack5。
因為emp主要解決的是業(yè)務的拆分,不是跨框架調用。算是對跨技術棧沒有高要求的微前端方案吧。
qiankun
介紹:qiankun 是基于 single-spa 封裝實現的(single-spa 做了完善的應用加載邏輯,qiankun 的路由系統(tǒng)就是基于此實現的),與框架無關的微前端內核。qiankun 算得上真正意義上的微前端,是螞蟻沉淀了自己的微前端方案并開源的成果。
優(yōu)點:真正做到了與技術棧無關。
缺點:Css隔離方案并不完美。
我們最后會選擇 qiankun 作為我們的微前端解決方案。
我們的項目都基于umi,而 umi 提供了配套的 qiankun 插件 @umijs/plugin-qiankun,方便 umi 應用通過修改配置的方式切換成微前端架構系統(tǒng),幾乎零成本的接入(沒有使用umi的應用,直接使用qiankun代碼其實也不需要改造太多,qiankun 工程侵入性很?。⑶也辉傩枰リP注各種過程中的技術細節(jié)。
至此,已經介紹完了微前端的一些基本內容。
但是想要進階更高級的工程師,必然不能只停留于調用API,更要了解其底層設計思想和實現原理。
下面就簡單深入下 qiankun。
qiankun在技術細節(jié)上的決策和基本實現原理

路由系統(tǒng)
基于Single-SPA實現。把所有應用都注冊在基座上,通過基座應用來監(jiān)聽路由,按照路由規(guī)則來加載不同的應用,來實現應用間的解耦。
應用加載
子應用提供什么形式的資源作為渲染入口?

Js Entry的問題在于:
需將子應用的所有資源(包括 css、圖片等資源)打成一個Entry Script,Code Splitting 也無法應用,資源加載速度變慢。
而且每一次子應用發(fā)布,主應用都需要重新配置打包,因為js/css地址的hash會變。
主應用為子應用預留的容器 id 還需與子應用容器保持一致。
相比之下HTML Entry,子應用地址只需配一次,子應用的信息可以得到完整的保留。
缺點:將子應用資源解析的消耗留到了運行時。
qiankun 采用 HTML Entry 方案替代 single-spa 的 JS Entry 加載子應用的方案進行優(yōu)化。(以達到像接入一個 iframe 一樣簡單的目的)
通過 html 作為應用入口,然后通過解析html從中提取 js 和 css 依賴下載,同時將 HTML Document 作為子節(jié)點塞到主應用的容器中。(本質上 HTML 充當的是應用靜態(tài)資源表的角色)。

應用隔離(微前端方案中最關鍵的問題)
Css隔離
CSS Module 簡單高效,也更加智能化。問題在于:雖然可以保證自己的子應用做到隔離,但是無法保證依賴的第三方庫的全局樣式可以做到應用之間的隔離。
下面介紹下qiankun的方案選擇:
-
Dynamic Stylesheet(動態(tài)樣式表):
動態(tài)的加載和卸載樣式表。在應用切出/卸載后,同時卸載掉其樣式表。
原理:瀏覽器會對所有的樣式表的插入、移除做整個 CSSOM 的重構,從而保證了在一個時間點里,只有一個應用的樣式表是生效的。(上面提到的 HTML Entry 方案則天生具備樣式隔離的特性,因為應用卸載后會直接移除去 HTML 結構,從而自動移除了其樣式表)
問題:可以確保子應用之間的樣式沖突,但子應用和主應用之間的沖突是無法避免,只有通過手動的方式確保,比如給主應用所有樣式添加一個前綴。(但在螞蟻在實踐中,大多數主應用可能只提供一個頭部,側邊欄的組件)
-
Shadow DOM(qiankun 2.0 版本支持,在開啟 strictStylesolution時,將采用 shadow DOM的方式進行樣式隔離):
將微應用插入到 qiankun 創(chuàng)建好的 shadow Tree 中,微應用的樣式(包括動態(tài)插入的樣式)都會被掛載到這個 shadow Host 節(jié)點下,最終整個應用的所有 DOM 都會被繪制成一顆shadow tree。
原理:Shadow DOM內部所有節(jié)點的樣式對樹外面的節(jié)點是無效的,因此微應用的樣式只會作用在 Shadow Tree 內部,自然就實現了樣式隔離。

問題:一旦子應用中出現運行時越界跑到外面構建 DOM 的場景,必定會導致構建出來的 DOM 無法應用子應用樣式的情況。(例:像 antd modal 組件是動態(tài)掛載到 document.body)
所以,qiankun 的 css 隔離方案不是特別完美。(還有一個實驗性的樣式隔離特性 experimentalStyleIsolation ,會改寫子應用所添加的樣式為所有樣式規(guī)則增加一個特殊的選擇器規(guī)則來限定其影響范圍。但目前 @keyframes, @font-face, @import, @page 等規(guī)則不會支持)
Js隔離
- proxySandbox 沙箱:
解析 script 標簽,用 with 語句包裹起來,然后把 Proxy 包裝的 fakeWindow (window 上不可修改的屬性) 作為第一個參數傳進去。
(with做的是擴展語句的作用域鏈,也就是將 Proxy(fakeWindow) 添加到作用域鏈的頂部)
這里稍微再深入一下,看下簡化后的代碼,具體是如何實現的(源碼鏈接):
// zone.js將覆蓋Object.defineProperty
const rawObjectDefineProperty = Object.defineProperty;
function createFakeWindow(globalContext: Window) {
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
//篩選不可修改的屬性描述
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
//對窗口對象的處理,使top/self/window屬性可配置和可寫,否則當get trap(捕獲器)返回時會導致TypeError。
if (p === 'top' || p === 'parent' || p === 'self' || p === 'window') {
descriptor.configurable = true;
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
// 凍結描述符以避免被zone.js修改
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
可以到 fakeWindow 就是 createFakeWindow 返回出來的,主要是從 window 上篩選出不可修改的屬性,偽造一個 window 。
const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
['fetch', true],
['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'],
]);
const nativeGlobal = new Function('return this')();
export default class ProxySandbox implements SandBox {
name: string;
type: SandBoxType;
proxy: WindowProxy;
globalContext: typeof window;
sandboxRunning = true;
//......
/** 啟動沙箱 */
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
/** 關閉沙箱 */
inactive() {
if (--activeSandboxCount === 0) {
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
delete this.globalContext[p];
}
});
}
this.sandboxRunning = false;
}
constructor(name: string, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.Proxy;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);
const hasOwnProperty = (key: PropertyKey) =>
fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
// 當屬性在globalContext中存在時,必須保持它的描述一致
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable } = descriptor!;
if (writable) {
//將修改對象屬性代理到 fakeWindow
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
target[p] = value;
}
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 會拋出 TypeError,在沙箱卸載的情況下應該忽略錯誤
return true;
},
get: (target: FakeWindow, p: PropertyKey): any => {
if (p === Symbol.unscopables) return unscopables;
if (p === 'window' || p === 'self') {
//防止使用 window.window 或 window.self 去逃離沙箱環(huán)境獲取到真正 window
return proxy;
}
if (p === 'globalThis') {
// 劫持 globalWindow 訪問與 globalThis 關鍵字
return proxy;
}
if (p === 'top' || p === 'parent') {
// 如果你的主應用程序在 iframe 上下文中, 允許些 props 離開沙箱
if (globalContext === globalContext.parent) {
//如果一個窗口沒有父窗口,則它的 parent 屬性為自身的引用.
return proxy;
}
return (globalContext as any)[p];
}
if (p === 'hasOwnProperty') {
//先查找 fakeWindow,后查找 globalContext 對象自身屬性中是否具有指定的屬性
return hasOwnProperty;
}
if (p === 'document') {
//將返回的 子應用的 document 更正為主應用的 document
return document;
}
if (p === 'eval') {
return eval;
}
const value = propertiesWithGetter.has(p)
? (globalContext as any)[p]
: p in target
? (target as any)[p]
: (globalContext as any)[p];
// 一些dom api必須綁定到本機窗口,否則會導致異常報錯:'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
//getTargetValue 主要對 window.console、window.atob 這類的檢測處理,不然微應用中調用時會拋出 Illegal invocation 異常
return getTargetValue(boundTarget, value);
},
//......
});
this.proxy = proxy;
}
}
主要看下 Proxy 攔截操作中的 get 和 set 做了什么 :
setter:這里就是把對 window 屬性的修改,全局屬性的操作代理到 fakeWindow 上。
getter:就是對于屬性值的獲取做了一些限制,防止逃離沙箱環(huán)境獲取到真正的 window。
這樣在執(zhí)行代碼時,所有全局變量就會被掛載到了 fakeWindow 上,而不是真正的全局 window 上,當應用被卸載時,對應的 Proxy 會被清除,所以不會導致全局污染。
- SnapshotSandbox(qiankun 2.0 版本支持):在不支持 proxy 特性的瀏覽器(IE11)上,使用快照模式來保證兼容性。
也簡單看下代碼的實現:
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
for (const prop in obj) {
if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
callbackFn(prop);
}
}
}
/**
* 基于 diff 方式實現的沙箱,用于不支持 Proxy 的低版本瀏覽器
*/
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
/** 啟動沙箱 */
active() {
// 記錄當前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢復之前的變更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
/** 關閉沙箱 */
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 記錄變更,恢復環(huán)境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
this.sandboxRunning = false;
}
}
大致思路:在加載應用前,把 window 上所有的屬性保存起來(拍攝快照)。應用被卸載時,再恢復 window 上的所有屬性,所以也可以防止全局污染。
但是當頁面同時存在多個頁面實例時,就無法把它們隔離開來了。所以快照策略并不支持多實例模式。
內部通信
- 基于 props 以單向數據流的方式傳遞給子應用。(主要解決父子應用的強耦合時的通信)

- initGlobalState(state) 定義全局狀態(tài),并返回通信方法

基座會創(chuàng)建一個內部包含通信的變量和兩個用來修改和監(jiān)聽變量值的方法。
下面看下簡化后的源碼,去除了console.warn和console.error:
//cloneDeep深度拷貝
import { cloneDeep } from 'lodash';
import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces';
let globalState: Record<string, any> = {};
const deps: Record<string, OnGlobalStateChangeCallback> = {};
// 觸發(fā)全局監(jiān)聽
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
Object.keys(deps).forEach((id: string) => {
if (deps[id] instanceof Function) {
deps[id](cloneDeep(state), cloneDeep(prevState));
}
});
}
export function initGlobalState(state: Record<string, any> = {}) {
if (state === globalState) { } else {
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(state);
emitGlobal(globalState, prevGlobalState);
}
return getMicroAppStateActions(`global-${+new Date()}`, true);
}
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
return {
onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
//訂閱
deps[id] = callback;
const cloneState = cloneDeep(globalState);
//是否立即觸發(fā)
if (fireImmediately) {
callback(cloneState, cloneState);
}
},
setGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
return false;
}
const changeKeys: string[] = [];
//記錄之前的 globalState
const prevGlobalState = cloneDeep(globalState);
//生成新的 globalState
globalState = cloneDeep(
Object.keys(state).reduce((_globalState, changeKey) => {
if (isMaster || _globalState.hasOwnProperty(changeKey)) {
changeKeys.push(changeKey);
return Object.assign(_globalState, { [changeKey]: state[changeKey] });
}
return _globalState;
}, globalState),
);
if (changeKeys.length === 0) {
return false;
}
//發(fā)布
emitGlobal(globalState, prevGlobalState);
return true;
},
// 注銷該應用下的依賴
offGlobalStateChange() {
delete deps[id];
return true;
},
};
}
簡單來講就是標準的訂閱-發(fā)布模式,就是通過訂閱全局變量的修改狀態(tài)來實現通信。(具體源碼可以看這里)
資源加載
在微前端方案中存在一個典型的問題:
如果子應用比較多,就會存在之間重復依賴的場景。解決方案是在主應用中主動的依賴基礎框架,然后子應用保守的將基礎的依賴處理掉,但是,這個機制里存在一個問題,如果子應用中既有react15又有react16,這時主應用該如何做?

螞蟻的方案是在主應用中維護一個語義化版本的映射表,在運行時分析當前的子應用,最后可以決定真實運行時真正的消費到哪一個基礎框架的版本,可以實現真正運行時的依賴系統(tǒng),也能解決子應用多版本共存時依賴去從的問題,能確保最大程度的依賴復用。
結尾
講了這么多,最后簡單提一下之后可能會遇到的微前端問題。
當一個單體應用被拆成若干個,其維護成本也相應增加。
如何管理多個版本,如何復用公共組件等,導致管理版本變得復雜,依賴關系也極其復雜。
還有,如果應用拆分的粒度過小,開發(fā)體驗也會不太友好。
應用如果是不同的人開發(fā)的話,如果需求跨多個業(yè)務,此時需要與多個開發(fā)應用者合作,溝通成本大大增加。
最后
趕在春節(jié)放假前,進行微前端的技術分享,這篇可以看作是演講稿。內容少點的可以看 PPT。
參考學習鏈接:
https://martinfowler.com/articles/micro-frontends.html
https://swearer23.github.io/micro-frontends/
https://developer.aliyun.com/article/742576?spm=a2c6h.14164896.0.0.6e633edbLg3STt
https://zhuanlan.zhihu.com/p/78362028
https://zhuanlan.zhihu.com/p/131022025
https://zhuanlan.zhihu.com/p/355419817
https://www.yuque.com/kuitos/gky7yw/nwgk5a
https://www.yuque.com/zhuanjia/oeisq4/vt6kto
https://zhuanlan.zhihu.com/p/97226980
https://zhuanlan.zhihu.com/p/356225293
https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM