開始寫這篇文章的起因是公司的大前端部門開始實(shí)現(xiàn)公司自己的微前端框架
在和大前端部門的合作中,對微前端相關(guān)的知識和技術(shù)點(diǎn)、難點(diǎn)的總結(jié)
微前端是什么
微前端的思想概念來源于微服務(wù)架構(gòu)。是一種由獨(dú)立交付的多個(gè)前端應(yīng)用組成整體的架構(gòu)風(fēng)格。
具體的,將前端應(yīng)用分解成一些更小、更簡單的能夠獨(dú)立開發(fā)、測試、部署的小塊,而在用戶看來仍然是內(nèi)聚的單個(gè)產(chǎn)品
為什么要有微前端
我們正常的一個(gè)單體應(yīng)用,主要負(fù)責(zé)一個(gè)完整的業(yè)務(wù),所以也被稱為獨(dú)石應(yīng)用(一個(gè)建筑完全由一個(gè)石頭雕塑成)
但是隨著版本迭代會出現(xiàn)很多痛點(diǎn):
- 增量更新慢
- 項(xiàng)目文件越多,每次打包編譯需要的時(shí)間也越長
- 每次上線,未修改的文件都需要重新編譯(chunkhash 和 dll 并不能從根本上解決問題)
- 高耦合
- 修改代碼帶來的關(guān)聯(lián)影響大
- 項(xiàng)目龐大導(dǎo)致增加新人熟悉項(xiàng)目的難度和時(shí)間
- 無法獨(dú)立部署:無關(guān)的功能模塊沒有拆分,無法各自獨(dú)立部署
- 無法團(tuán)隊(duì)自治:如果將模塊拆分給各個(gè)小團(tuán)隊(duì),無法實(shí)現(xiàn)團(tuán)隊(duì)自我維護(hù)
從公司和用戶層面來看,不利于效率提升
一個(gè)公司的 OA、CRM、ERP、PMS 等后臺,沒有統(tǒng)一的入口,不方便使用,降低工作效率
從用戶層面來看,不利于用戶體驗(yàn)和流量管理
一個(gè)被更多賦能的產(chǎn)品或者應(yīng)用,更容易獲得用戶的青睞,獲得流量
因此,在借鑒微服務(wù)架構(gòu)的基礎(chǔ)上,誕生了微前端架構(gòu)
微前端作為一種大型應(yīng)用的解決方案,目的就是解決上面提到的痛點(diǎn),做到以下幾點(diǎn):
- 技術(shù)選型獨(dú)立:每個(gè)開發(fā)團(tuán)隊(duì)自行選擇技術(shù)棧(
Vue、React、Angular、Jquery),不受其他團(tuán)隊(duì)影響 - 業(yè)務(wù)獨(dú)立:每個(gè)交付產(chǎn)物既可以獨(dú)立使用,也可以融合成一個(gè)大型應(yīng)用使用
- 樣式隔離:父子應(yīng)用之間、子應(yīng)用之間不會有樣式?jīng)_突、覆蓋
技術(shù)方案
當(dāng)前主流的方案
- 大倉庫拆分成獨(dú)立的模塊文件夾,通過
webpack統(tǒng)一去構(gòu)建。本質(zhì)上沒有變化,只是在項(xiàng)目結(jié)構(gòu)和編譯分包上的優(yōu)化。 - 大倉庫拆成小倉庫?;ハ嘀g通過
location.href切換。比較適合后臺類型的應(yīng)用 - 大倉庫拆成小倉庫,發(fā)包到
npm上,然后集成。較上者更進(jìn)了一步,主要針對header、footer、siderBar等公共部分組件。 - 大倉庫拆成小倉庫,不通過頁面跳轉(zhuǎn),通過注入的方式集成到主應(yīng)用
- iframe(天然的微前端方案,但是弊端很多)
- single-spa
- web components(最適合但是兼容性最差)
從趨勢上看,最終都是向注入集成的技術(shù)方案靠攏
iframe 的優(yōu)缺點(diǎn)
iframe 的優(yōu)點(diǎn)
- 瀏覽器原生的硬隔離方案,改造成本低
- 天然支持
CSS隔離、JS隔離
iframe 的問題
- URL 不同步
-
iframe內(nèi)部頁面跳轉(zhuǎn),url不會更新 - 瀏覽器刷新導(dǎo)致
iframe url狀態(tài)丟失、后退前進(jìn)按鈕無法使用。
-
- UI 不同步
-
DOM結(jié)構(gòu)不共享。iframe里的彈窗遮罩無法在整個(gè)父應(yīng)用上覆蓋
-
- 全局上下文完全隔離,內(nèi)存變量不共享。
iframe內(nèi)外系統(tǒng)的通信、數(shù)據(jù)同步等需求,主應(yīng)用的cookie要透傳到根域名都不同的子應(yīng)用中實(shí)現(xiàn)免登效果。 - 慢。每次子應(yīng)用進(jìn)入都是一次瀏覽器上下文重建、資源重新加載的過程。
- 雙滾動(dòng)條
綜合考量之下,iframe 不適合作為微前端的方案,最多只能作為過渡階段的方案來使用
技術(shù)點(diǎn)
Entry 方式
Entry 用于父應(yīng)用引入子應(yīng)用相應(yīng)的資源文件(包括 JS、CSS),主要分為兩種方式:
- JS Entry
- HTML Entry
JS Entry 方式
JS Entry 的原理是:
- 把
CSS打包進(jìn)JS,生成一個(gè)manifest.json配置文件 -
manifest.json中標(biāo)識了子應(yīng)用資源文件的相對路徑地址 - 主應(yīng)用通過插入
script標(biāo)簽src屬性的方式加載子應(yīng)用資源文件(子應(yīng)用域名 +manifest.json中的相對路徑地址)
基于這樣的原理,因此 JS Entry 有缺陷:
- 打包時(shí),需要額外對工程化代碼做修改,生成一份資源配置文件
manifest.json給主應(yīng)用加載 - 打包時(shí),需要額外對樣式打包做修改,需要把
CSS打包進(jìn)JS中,也增加了編譯后的包體積 - 打包時(shí),不能在
html中插入行內(nèi)script代碼。因?yàn)?manifest.json中只能存放地址路徑。因此要禁止webpack把配置代碼直接打入html中
// vue-cli 3.x vue.config.js
config.optimization.runtimeChunk('single') // 不能使用
- 父子應(yīng)用域名不一致,父應(yīng)用加載子應(yīng)用
manifest.json會發(fā)生跨域,需要額外處理
HTML Entry 方式
HTML Entry 是利用 import-html-entry 直接獲取子應(yīng)用 html 文件,解析 html 文件中的資源加載入主應(yīng)用
第一步,解析遠(yuǎn)程 html 文件,得到一個(gè)對象
// 使用
import importHTML from 'import-html-entry'
importHTML(url, opts = {})
// 獲取到的對象
{
template: 經(jīng)過處理的腳本,link、script 標(biāo)簽都被注釋掉了,
scripts: [腳本的http地址 或者 { async: true, src: xx } 或者 代碼塊],
styles: [樣式的http地址],
entry: 入口腳本的地址,是標(biāo)有 entry 的 script 的 src,或者是最后一個(gè) script 標(biāo)簽的 src
}
第二步,處理這個(gè)對象,向外暴露一個(gè) Promise 對象,這個(gè)對象回傳的值就是下面這個(gè)對象
// import-html-entry 源碼中對獲取到的對象的處理
{
// template 是 link 替換為 style 后的 template
template: embedHTML,
// 靜態(tài)資源地址
assetPublicPath,
// 獲取外部腳本,最終得到所有腳本的代碼內(nèi)容
getExternalScripts: () => getExternalScripts(scripts, fetch),
// 獲取外部樣式文件的內(nèi)容
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
// 腳本執(zhí)行器,讓 JS 代碼(scripts)在指定 上下文 中運(yùn)行
execScripts: (proxy, strictGlobal) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
}
}
getExternalStyleSheets 做了哪些事?
getExternalStyleSheets 會做兩件事
- 將子應(yīng)用中的
link標(biāo)簽轉(zhuǎn)為style標(biāo)簽 - 把對應(yīng)的
href遠(yuǎn)程文件內(nèi)容通過fetch get的方式放進(jìn)style標(biāo)簽中- 如果是
inline style,通過substring的方式獲取行內(nèi)style代碼字符串 - 如果是
遠(yuǎn)程 style,通過fetch get方式獲取href地址對應(yīng)的代碼字符串
- 如果是
// import-html-entry getExternalStyleSheets 源碼
export function getExternalStyleSheets(styles, fetch = defaultFetch) {
return Promise.all(styles.map(styleLink => {
if (isInlineCode(styleLink)) {
// if it is inline style
return getInlineCode(styleLink);
} else {
// external styles
return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(response => response.text()));
}
}))
}
getExternalScripts 做了哪些事?
getExternalScripts 同樣做了兩件事
- 按順序獲取子應(yīng)用
html中的script,并拼成一個(gè)scripts數(shù)組 - 使用
fetch get的方式循環(huán)加載scripts數(shù)組- 如果是
inline script,通過substring的方式獲取行內(nèi)JS代碼字符串 - 如果是
遠(yuǎn)程 script,通過fetch get方式獲取src地址對應(yīng)的代碼字符串
- 如果是
最后返回一個(gè) scriptsText 數(shù)組,數(shù)組里每個(gè)元素都是子應(yīng)用 scripts 數(shù)組中的可執(zhí)行代碼的字符串
這個(gè)數(shù)組就是 execScripts 真正使用的參數(shù)
這里會遇到一些問題:
跨域
父應(yīng)用fetch子應(yīng)用第三方庫的cdn文件,大部分cdn站點(diǎn)支持CORS跨域
但是少部分cdn站點(diǎn)不支持,因此導(dǎo)致跨域fetch文件失敗重復(fù)加載
一些通用的cdn文件,父子應(yīng)用都進(jìn)行了加載,當(dāng)父應(yīng)用加載子應(yīng)用時(shí),會因?yàn)橹貜?fù)加載執(zhí)行這部分cdn的JS代碼,導(dǎo)致錯(cuò)誤
解決方案:
直接硬編碼把需要加載的 cdn script 寫進(jìn)父應(yīng)用的 html 中
父應(yīng)用直接加載父子應(yīng)用需要的全部 cdn
子應(yīng)用通過是否通過微前端方式加載的標(biāo)識判斷是否獨(dú)立運(yùn)行,自行獨(dú)立加載這部分 cdn 文件
這個(gè)方案的優(yōu)點(diǎn)是:父應(yīng)用不需要做重復(fù)加載的邏輯判斷,交給子應(yīng)用自己判斷
相對應(yīng)的缺點(diǎn)是:A子應(yīng)用不需要用到的B子應(yīng)用的 cdn 也在第一時(shí)間加載,徒耗性能
execScripts 做了哪些事?
execScripts 是真正執(zhí)行子應(yīng)用 JS 文件的函數(shù)
- 先調(diào)用
getExternalScripts獲取可執(zhí)行的JS代碼數(shù)組 - 最終使用
eval在當(dāng)前上下文中執(zhí)行JS代碼。 -
proxy參數(shù)支持傳入一個(gè)上下文對象,從而保證了 JS沙盒 的可行性
HTML Entry 優(yōu)于 JS Entry 的地方
- 不用生成額外的
manifest.json - 不用把
css打包進(jìn)js中 - 全局
css獨(dú)立打包,不會冗余 - 不使用生成
script的方式插入子應(yīng)用JS代碼,不會生成額外的DOM節(jié)點(diǎn)
JS 沙盒
JS 沙盒的目的是隔離兩個(gè)子應(yīng)用,避免互相影響
JS 沙盒的實(shí)現(xiàn)有兩種方式
- 代理沙盒:利用
proxy API,可以實(shí)現(xiàn)多應(yīng)用沙箱,把不同的應(yīng)用對應(yīng)不同的代理 - 快照沙盒:將不同沙盒之間的區(qū)別保存起來,只能兩個(gè),多了會混亂
代理沙盒
- 獲取屬性:
proxyObj[key] || window[key] - 設(shè)置屬性:
proxyObj[key] = value
利用函數(shù)作用域的形參 window(實(shí)參 proxyObj),來代替全局對象 window
// proxy 的 demo
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
})
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
window.a = 'hello';
console.log(window.a)
})(sandbox1.proxy);
((window) => {
window.a = 'world';
console.log(window.a)
})(sandbox2.proxy);
快照沙盒
沙箱失活時(shí),把記錄在 window 上的修改記錄賦值到 modifyPropsMap 上,等待下次激活
沙箱激活時(shí),先生成一個(gè)當(dāng)前 window 的快照 windowSnapshot,把記錄在沙箱上的 window 修改對象 modifyPropsMap 賦值到 window
沙箱實(shí)際使用的還是全局 window 對象
// snapshot 的 demo
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {}; // window 狀態(tài)快照
this.modifyPropsMap = {}; // 沙箱運(yùn)行時(shí)被修改的 window 屬性
this.active();
}
// 激活
active() {
// 設(shè)置快照
this.windowSnapshot = {};
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop];
}
}
// 還原這個(gè)沙箱上一次記錄的環(huán)境
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p]
})
}
// 失活
inactive() {
// 記錄本次的修改
// 還原 window 到激活之前的狀態(tài)
this.modifyPropsMap = {};
for (const prop in window) {
if (window.hasOwnProperty(prop) && this.windowSnapshot[prop] !== window[prop]) {
this.modifyPropsMap[prop] = window[prop]; // 保存變化
window[prop] = this.windowSnapshot[prop] // 變回原來
}
}
}
}
let sandbox = new SnapshotSandbox();
((window) => {
window.a = 1
window.b = 2
console.log(window.a) //1
sandbox.inactive() //失活
console.log(window.a) //undefined
sandbox.active() //激活
console.log(window.a) //1
})(sandbox.proxy);
//sandbox.proxy就是window
目前主流方法是優(yōu)先代理沙箱,如果不支持 proxy API,則使用快照沙箱
CSS 沙盒
子應(yīng)用樣式
子應(yīng)用通過 BEM + css module 的方式隔離
保證A子應(yīng)用的樣式不會在B子應(yīng)用的 DOM 上生效
子應(yīng)用切換
子應(yīng)用失活,樣式 style 不需要?jiǎng)h除,因?yàn)橐呀?jīng)做了隔離
已加載的子應(yīng)用重新激活,也不需要重新插入 style 標(biāo)簽,避免重復(fù)加載
父子應(yīng)用通信
父子應(yīng)用通信主要分為:數(shù)據(jù)和事件
數(shù)據(jù)
- url
- localStorage
- sessionStorage
- cookie
- eventBus
事件
- 子應(yīng)用 main.js export 到父應(yīng)用的 window 對象
- 父應(yīng)用 自定義事件
- 父應(yīng)用 window.eventBus
- H5 api sharedWorker
- H5 api BroadcastChannel
目前用的較多的方案是 eventBus 和 自定義事件
應(yīng)用監(jiān)控
每個(gè)項(xiàng)目都有對自己的應(yīng)用監(jiān)控
- 用戶行為監(jiān)控
- 錯(cuò)誤監(jiān)控
- 性能監(jiān)控
如果使用代理沙箱
因?yàn)?proxy API 只能代理對象的 get set,無法代理事件的監(jiān)聽和移除,子應(yīng)用的監(jiān)控在代理對象上無法執(zhí)行
所以只能直接在父應(yīng)用上監(jiān)聽父子應(yīng)用的事件
如果使用快照沙箱
因?yàn)橥瑫r(shí)只有一個(gè)子應(yīng)用被激活,只有一個(gè)子應(yīng)用的JS在執(zhí)行,同時(shí)又是直接操作 window 對象
可以考慮直接使用子應(yīng)用自己的監(jiān)控,因?yàn)槎际菍?window 的事件監(jiān)聽,所以可以同時(shí)監(jiān)聽到父子兩個(gè)應(yīng)用的事件
下面列舉 single-spa 和 qiankun 的監(jiān)控方案
// single-spa 的異常捕獲
export { addErrorHandler, removeErrorHandler } from 'single-spa';
// qiankun 的異常捕獲
// 監(jiān)聽了 error 和 unhandlerejection 事件
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler);
}
// 移除 error 和 unhandlerejection 事件監(jiān)聽
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
window.removeEventListener('error', errorHandler);
window.removeEventListener('unhandledrejection', errorHandler);
}
現(xiàn)有框架對比
參考上圖
single-spa
比較基礎(chǔ)的微前端框架,也是我公司大前端部門搭建自有框架的選擇方案
需要自己定制的部分較多,包括
- Entry 方式
- JS 沙盒
- CSS 沙盒
- 父子應(yīng)用通信方式
- 應(yīng)用監(jiān)控事件處理
官網(wǎng):https://zh-hans.single-spa.js.org/
github:https://github.com/single-spa/single-spa
icestark
icestark 是阿里的微前端框架,現(xiàn)在的不限制主應(yīng)用所使用的框架了
針對 React 主應(yīng)用 和 不限框架的主應(yīng)用 有兩種不同的接入方式
PS:通過下面的引用描述來看,目前應(yīng)該不支持多個(gè)子應(yīng)用共存(待確認(rèn))
一般情況下不存在多個(gè)微應(yīng)用同時(shí)運(yùn)行的場景
頁面運(yùn)行時(shí)同時(shí)只會存在一個(gè)微應(yīng)用,因此多個(gè)微應(yīng)用不存在樣式相互污染的問題
在 Entry 方式上
- 通過
fetch+創(chuàng)建 script 標(biāo)簽的方式注入。有一點(diǎn) JS Entry 和 HTML Entry 中間過渡的意思 - 不需要子應(yīng)用生成配置文件,但是會生成
script的DOM節(jié)點(diǎn)
在 JS 沙盒上
- 如果是不可控的子應(yīng)用,官方建議使用 iframe 的方案嵌入
- 如果是可控的子應(yīng)用,使用代理沙盒(還未研究過對應(yīng)的源碼,但快照沙盒作為降級策略,應(yīng)該也有被使用,待確認(rèn))
在 CSS 沙盒上
- 主要方案是
BEM+CSS Modules - 實(shí)驗(yàn)性方案是
Shadow DOM - 全局樣式庫,例如
normalize.css、reset.css統(tǒng)一由主應(yīng)用引入
在應(yīng)用通信上
- 使用了
eventBus的方案來處理數(shù)據(jù)和事件
在應(yīng)用監(jiān)控上
- 統(tǒng)一由主應(yīng)用來監(jiān)控
官網(wǎng):https://micro-frontends.ice.work/
github:https://github.com/ice-lab/icestark
qiankun
同樣是阿里的微前端框架,qiankun 是對 single-spa 的一層封裝
核心做了構(gòu)建層面的一些約束以及沙箱能力,支持多子應(yīng)用并存
但是接入的修改成本較高
總的來說算是目前比較優(yōu)選的微前端框架
在 Entry 方式上
- 已經(jīng)支持
HTML Entry的方式,在框架內(nèi)部也是依賴的import-html-entry
在 JS 沙盒上
- 使用三種沙盒
-
legacySandBox:支持
proxy API且只有單子應(yīng)用并存 -
proxySandBox:支持
proxy API且多子應(yīng)用并存 -
snapshotSandBox:不支持
proxy API的快照沙盒
-
legacySandBox:支持
-
legacySandBox 其實(shí)是 proxySandBox 與 snapshotSandBox 的結(jié)合,既想要
proxy的代理能力,又想在一定程度上有直接操作window對象的能力
在 CSS 沙盒上
- 主要方案是
BEM -
BEM不需要子應(yīng)用自己處理,在子應(yīng)用接入qiankun框架時(shí)可以通過配置統(tǒng)一增加prefix - 全局樣式庫,例如
normalize.css、reset.css統(tǒng)一由主應(yīng)用引入
在應(yīng)用通信上
-
Actions方案:適用于通信較少- 數(shù)據(jù)上:使用一個(gè) store 來存儲數(shù)據(jù),使用觀察者模式來監(jiān)聽
- 事件上:利用觀察者派發(fā)事件的觸發(fā)事件通信
-
Shared方案:適用于通信較多- 主應(yīng)用基于
redux維護(hù)一個(gè)狀態(tài)池,通過shared實(shí)例暴露一些方法給子應(yīng)用使用 - 子應(yīng)用需要單獨(dú)維護(hù)一份
shared實(shí)例,保證在使用和表現(xiàn)上的一致性- 獨(dú)立運(yùn)行時(shí)使用自身的
shared實(shí)例 - 在嵌入主應(yīng)用時(shí)使用主應(yīng)用的
shared實(shí)例
- 獨(dú)立運(yùn)行時(shí)使用自身的
- 數(shù)據(jù)和事件都可以通過
redux來通信
- 主應(yīng)用基于
在應(yīng)用監(jiān)控上
- 統(tǒng)一由主應(yīng)用來監(jiān)控
官網(wǎng):https://qiankun.umijs.org/zh
github:https://github.com/umijs/qiankun
Garfish
從開發(fā)者大會上看到的方案,來自于字節(jié)跳動(dòng),有希望成為最優(yōu)的方案
- 支持多子應(yīng)用并存
- 支持
HTML Entry、JS Entry -
JS沙盒直接使用快照沙盒 - 通過HTML整體快照,來實(shí)現(xiàn)
CSS沙盒 - 通信
- 數(shù)據(jù):同樣通過一個(gè)
store來保存數(shù)據(jù) - 事件:通過自定義事件
- 數(shù)據(jù):同樣通過一個(gè)
- 監(jiān)控
- 保留
windowaddEventListenerremoveEventListener的副本 - 在沙盒
document對象上監(jiān)聽監(jiān)控
- 保留
最大的特點(diǎn)是,能夠快照子應(yīng)用的 DOM 節(jié)點(diǎn),保持 DOM 樹
加上 JS 沙盒 、 CSS 沙盒,能夠保持整個(gè)子應(yīng)用的完整狀態(tài)
官網(wǎng):https://garfish.dev/
github:https://github.com/bytedance/garfish