微前端-技術(shù)方案總結(jié)

開始寫這篇文章的起因是公司的大前端部門開始實(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、ReactAngular、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)了一步,主要針對 headerfooter、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 的原理是:

  1. CSS 打包進(jìn) JS,生成一個(gè) manifest.json 配置文件
  2. manifest.json 中標(biāo)識了子應(yīng)用資源文件的相對路徑地址
  3. 主應(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 會做兩件事

  1. 將子應(yīng)用中的 link 標(biāo)簽轉(zhuǎn)為 style 標(biāo)簽
  2. 把對應(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 同樣做了兩件事

  1. 按順序獲取子應(yīng)用 html 中的 script,并拼成一個(gè) scripts 數(shù)組
  2. 使用 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ù)

這里會遇到一些問題:

  1. 跨域
    父應(yīng)用 fetch 子應(yīng)用第三方庫的 cdn 文件,大部分 cdn 站點(diǎn)支持 CORS 跨域
    但是少部分 cdn 站點(diǎn)不支持,因此導(dǎo)致跨域 fetch 文件失敗

  2. 重復(fù)加載
    一些通用的 cdn 文件,父子應(yīng)用都進(jìn)行了加載,當(dāng)父應(yīng)用加載子應(yīng)用時(shí),會因?yàn)橹貜?fù)加載執(zhí)行這部分 cdnJS 代碼,導(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ù)

  1. 先調(diào)用 getExternalScripts 獲取可執(zhí)行的 JS 代碼數(shù)組
  2. 最終使用 eval 在當(dāng)前上下文中執(zhí)行 JS 代碼。
  3. proxy 參數(shù)支持傳入一個(gè)上下文對象,從而保證了 JS沙盒 的可行性

HTML Entry 優(yōu)于 JS Entry 的地方

  1. 不用生成額外的 manifest.json
  2. 不用把 css 打包進(jìn) js
  3. 全局 css 獨(dú)立打包,不會冗余
  4. 不使用生成 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-spaqiankun 的監(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)有框架對比

image.png

參考上圖

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)用生成配置文件,但是會生成 scriptDOM 節(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 其實(shí)是 proxySandBoxsnapshotSandBox 的結(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í)例
    • 數(shù)據(jù)和事件都可以通過 redux 來通信

在應(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ù)
    • 事件:通過自定義事件
  • 監(jiān)控
    • 保留 window addEventListener removeEventListener 的副本
    • 在沙盒 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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容