微前端-技術方案總結

開始寫這篇文章的起因是公司的大前端部門開始實現公司自己的微前端框架
在和大前端部門的合作中,對微前端相關的知識和技術點、難點的總結

微前端是什么

微前端的思想概念來源于微服務架構。是一種由獨立交付的多個前端應用組成整體的架構風格。
具體的,將前端應用分解成一些更小、更簡單的能夠獨立開發、測試、部署的小塊,而在用戶看來仍然是內聚的單個產品

為什么要有微前端

我們正常的一個單體應用,主要負責一個完整的業務,所以也被稱為獨石應用(一個建筑完全由一個石頭雕塑成)

但是隨著版本迭代會出現很多痛點

  • 增量更新慢
    • 項目文件越多,每次打包編譯需要的時間也越長
    • 每次上線,未修改的文件都需要重新編譯(chunkhash 和 dll 并不能從根本上解決問題)
  • 高耦合
    • 修改代碼帶來的關聯影響大
    • 項目龐大導致增加新人熟悉項目的難度和時間
  • 無法獨立部署:無關的功能模塊沒有拆分,無法各自獨立部署
  • 無法團隊自治:如果將模塊拆分給各個小團隊,無法實現團隊自我維護

從公司和用戶層面來看,不利于效率提升
一個公司的 OA、CRM、ERP、PMS 等后臺,沒有統一的入口,不方便使用,降低工作效率

從用戶層面來看,不利于用戶體驗流量管理
一個被更多賦能的產品或者應用,更容易獲得用戶的青睞,獲得流量

因此,在借鑒微服務架構的基礎上,誕生了微前端架構

微前端作為一種大型應用的解決方案,目的就是解決上面提到的痛點,做到以下幾點:

  • 技術選型獨立:每個開發團隊自行選擇技術棧(VueReactAngularJquery),不受其他團隊影響
  • 業務獨立:每個交付產物既可以獨立使用,也可以融合成一個大型應用使用
  • 樣式隔離:父子應用之間、子應用之間不會有樣式沖突、覆蓋

技術方案

當前主流的方案

  • 大倉庫拆分成獨立的模塊文件夾,通過 webpack 統一去構建。本質上沒有變化,只是在項目結構和編譯分包上的優化。
  • 大倉庫拆成小倉庫。互相之間通過 location.href 切換。比較適合后臺類型的應用
  • 大倉庫拆成小倉庫,發包到 npm 上,然后集成。較上者更進了一步,主要針對 headerfootersiderBar 等公共部分組件。
  • 大倉庫拆成小倉庫,不通過頁面跳轉,通過注入的方式集成到主應用
    • iframe(天然的微前端方案,但是弊端很多)
    • single-spa
    • web components(最適合但是兼容性最差)

從趨勢上看,最終都是向注入集成的技術方案靠攏

iframe 的優缺點

iframe 的優點

  • 瀏覽器原生的硬隔離方案,改造成本低
  • 天然支持 CSS 隔離、JS 隔離

iframe 的問題

  • URL 不同步
    • iframe 內部頁面跳轉,url 不會更新
    • 瀏覽器刷新導致 iframe url 狀態丟失、后退前進按鈕無法使用。
  • UI 不同步
    • DOM 結構不共享。iframe 里的彈窗遮罩無法在整個父應用上覆蓋
  • 全局上下文完全隔離,內存變量不共享。iframe 內外系統的通信、數據同步等需求,主應用的 cookie 要透傳到根域名都不同的子應用中實現免登效果。
  • 慢。每次子應用進入都是一次瀏覽器上下文重建、資源重新加載的過程。
  • 雙滾動條

綜合考量之下,iframe 不適合作為微前端的方案,最多只能作為過渡階段的方案來使用

技術點

Entry 方式

Entry 用于父應用引入子應用相應的資源文件(包括 JSCSS),主要分為兩種方式:

  • JS Entry
  • HTML Entry

JS Entry 方式

JS Entry 的原理是:

  1. CSS 打包進 JS,生成一個 manifest.json 配置文件
  2. manifest.json 中標識了子應用資源文件的相對路徑地址
  3. 主應用通過插入 script 標簽 src 屬性的方式加載子應用資源文件(子應用域名 + manifest.json 中的相對路徑地址)

基于這樣的原理,因此 JS Entry 有缺陷:

  • 打包時,需要額外對工程化代碼做修改,生成一份資源配置文件 manifest.json 給主應用加載
  • 打包時,需要額外對樣式打包做修改,需要把 CSS 打包進 JS 中,也增加了編譯后的包體積
  • 打包時,不能在 html 中插入行內 script 代碼。因為 manifest.json 中只能存放地址路徑。因此要禁止 webpack 把配置代碼直接打入 html
// vue-cli 3.x vue.config.js
config.optimization.runtimeChunk('single') // 不能使用
  • 父子應用域名不一致,父應用加載子應用 manifest.json 會發生跨域,需要額外處理

HTML Entry 方式

HTML Entry 是利用 import-html-entry 直接獲取子應用 html 文件,解析 html 文件中的資源加載入主應用
第一步,解析遠程 html 文件,得到一個對象

// 使用
import importHTML from 'import-html-entry'
importHTML(url, opts = {})

// 獲取到的對象
{
    template: 經過處理的腳本,link、script 標簽都被注釋掉了,
    scripts: [腳本的http地址 或者 { async: true, src: xx } 或者 代碼塊],
    styles: [樣式的http地址],
    entry: 入口腳本的地址,是標有 entry 的 script 的 src,或者是最后一個 script 標簽的 src
}

第二步,處理這個對象,向外暴露一個 Promise 對象,這個對象回傳的值就是下面這個對象

// import-html-entry 源碼中對獲取到的對象的處理
{
     // template 是 link 替換為 style 后的 template
    template: embedHTML,
    // 靜態資源地址
    assetPublicPath,
    // 獲取外部腳本,最終得到所有腳本的代碼內容
    getExternalScripts: () => getExternalScripts(scripts, fetch),
    // 獲取外部樣式文件的內容
    getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
    // 腳本執行器,讓 JS 代碼(scripts)在指定 上下文 中運行
    execScripts: (proxy, strictGlobal) => {
        if (!scripts.length) {
            return Promise.resolve();
        }
        return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
    }
}

getExternalStyleSheets 做了哪些事?
getExternalStyleSheets 會做兩件事

  1. 將子應用中的 link 標簽轉為 style 標簽
  2. 把對應的 href 遠程文件內容通過 fetch get 的方式放進 style 標簽中
    • 如果是 inline style,通過 substring 的方式獲取行內 style 代碼字符串
    • 如果是 遠程 style,通過 fetch get 方式獲取 href 地址對應的代碼字符串
// 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. 按順序獲取子應用 html 中的 script,并拼成一個 scripts 數組
  2. 使用 fetch get 的方式循環加載 scripts 數組
    • 如果是 inline script,通過 substring 的方式獲取行內 JS 代碼字符串
    • 如果是 遠程 script ,通過 fetch get 方式獲取 src 地址對應的代碼字符串

最后返回一個 scriptsText 數組,數組里每個元素都是子應用 scripts 數組中的可執行代碼的字符串
這個數組就是 execScripts 真正使用的參數

這里會遇到一些問題:

  1. 跨域
    父應用 fetch 子應用第三方庫的 cdn 文件,大部分 cdn 站點支持 CORS 跨域
    但是少部分 cdn 站點不支持,因此導致跨域 fetch 文件失敗

  2. 重復加載
    一些通用的 cdn 文件,父子應用都進行了加載,當父應用加載子應用時,會因為重復加載執行這部分 cdnJS 代碼,導致錯誤

解決方案:
直接硬編碼把需要加載的 cdn script 寫進父應用的 html
父應用直接加載父子應用需要的全部 cdn
子應用通過是否通過微前端方式加載的標識判斷是否獨立運行,自行獨立加載這部分 cdn 文件

這個方案的優點是:父應用不需要做重復加載的邏輯判斷,交給子應用自己判斷
相對應的缺點是:A子應用不需要用到的B子應用的 cdn 也在第一時間加載,徒耗性能

execScripts 做了哪些事?
execScripts 是真正執行子應用 JS 文件的函數

  1. 先調用 getExternalScripts 獲取可執行的 JS 代碼數組
  2. 最終使用 eval 在當前上下文中執行 JS 代碼。
  3. proxy 參數支持傳入一個上下文對象,從而保證了 JS沙盒 的可行性

HTML Entry 優于 JS Entry 的地方

  1. 不用生成額外的 manifest.json
  2. 不用把 css 打包進 js
  3. 全局 css 獨立打包,不會冗余
  4. 不使用生成 script 的方式插入子應用 JS 代碼,不會生成額外的 DOM 節點

JS 沙盒

JS 沙盒的目的是隔離兩個子應用,避免互相影響
JS 沙盒的實現有兩種方式

  • 代理沙盒:利用 proxy API,可以實現多應用沙箱,把不同的應用對應不同的代理
  • 快照沙盒:將不同沙盒之間的區別保存起來,只能兩個,多了會混亂

代理沙盒

  • 獲取屬性:proxyObj[key] || window[key]
  • 設置屬性:proxyObj[key] = value
    利用函數作用域的形參 window(實參 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);

快照沙盒

沙箱失活時,把記錄在 window 上的修改記錄賦值到 modifyPropsMap 上,等待下次激活
沙箱激活時,先生成一個當前 window 的快照 windowSnapshot,把記錄在沙箱上的 window 修改對象 modifyPropsMap 賦值到 window
沙箱實際使用的還是全局 window 對象

// snapshot 的 demo
class SnapshotSandbox {
    constructor() {
        this.windowSnapshot = {}; // window 狀態快照
        this.modifyPropsMap = {}; // 沙箱運行時被修改的 window 屬性
        this.active();
    }
    
    // 激活
    active() {
        // 設置快照
        this.windowSnapshot = {};
        for (const prop in window) {
            if (window.hasOwnProperty(prop)) {
                this.windowSnapshot[prop] = window[prop];
            }
        }
        // 還原這個沙箱上一次記錄的環境
        Object.keys(this.modifyPropsMap).forEach(p => {
            window[p] = this.modifyPropsMap[p]
        })
    }
    
    // 失活
    inactive() {
        // 記錄本次的修改
        // 還原 window 到激活之前的狀態
        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

目前主流方法是優先代理沙箱,如果不支持 proxy API,則使用快照沙箱

CSS 沙盒

子應用樣式

子應用通過 BEM + css module 的方式隔離
保證A子應用的樣式不會在B子應用的 DOM 上生效

子應用切換

子應用失活,樣式 style 不需要刪除,因為已經做了隔離
已加載的子應用重新激活,也不需要重新插入 style 標簽,避免重復加載

父子應用通信

父子應用通信主要分為:數據事件

數據

  • url
  • localStorage
  • sessionStorage
  • cookie
  • eventBus

事件

  • 子應用 main.js export 到父應用的 window 對象
  • 父應用 自定義事件
  • 父應用 window.eventBus
  • H5 api sharedWorker
  • H5 api BroadcastChannel

目前用的較多的方案是 eventBus自定義事件

應用監控

每個項目都有對自己的應用監控

  • 用戶行為監控
  • 錯誤監控
  • 性能監控

如果使用代理沙箱
因為 proxy API 只能代理對象的 get set,無法代理事件的監聽和移除,子應用的監控在代理對象上無法執行
所以只能直接在父應用上監聽父子應用的事件

如果使用快照沙箱
因為同時只有一個子應用被激活,只有一個子應用的JS在執行,同時又是直接操作 window 對象
可以考慮直接使用子應用自己的監控,因為都是對 window 的事件監聽,所以可以同時監聽到父子兩個應用的事件

下面列舉 single-spaqiankun 的監控方案

// single-spa 的異常捕獲
export { addErrorHandler, removeErrorHandler } from 'single-spa';

// qiankun 的異常捕獲
// 監聽了 error 和 unhandlerejection 事件
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
  window.addEventListener('error', errorHandler);
  window.addEventListener('unhandledrejection', errorHandler);
}

// 移除 error 和 unhandlerejection 事件監聽
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
  window.removeEventListener('error', errorHandler);
  window.removeEventListener('unhandledrejection', errorHandler);
}

現有框架對比

image.png

參考上圖

single-spa

比較基礎的微前端框架,也是我公司大前端部門搭建自有框架的選擇方案
需要自己定制的部分較多,包括

  • Entry 方式
  • JS 沙盒
  • CSS 沙盒
  • 父子應用通信方式
  • 應用監控事件處理

官網:https://zh-hans.single-spa.js.org/
github:https://github.com/single-spa/single-spa

icestark

icestark 是阿里的微前端框架,現在的不限制主應用所使用的框架了
針對 React 主應用不限框架的主應用 有兩種不同的接入方式

PS:通過下面的引用描述來看,目前應該不支持多個子應用共存(待確認)

一般情況下不存在多個微應用同時運行的場景

頁面運行時同時只會存在一個微應用,因此多個微應用不存在樣式相互污染的問題

在 Entry 方式上

  • 通過 fetch + 創建 script 標簽的方式注入。有一點 JS Entry 和 HTML Entry 中間過渡的意思
  • 不需要子應用生成配置文件,但是會生成 scriptDOM 節點

在 JS 沙盒上

  • 如果是不可控的子應用官方建議使用 iframe 的方案嵌入
  • 如果是可控的子應用,使用代理沙盒(還未研究過對應的源碼,但快照沙盒作為降級策略,應該也有被使用,待確認)

在 CSS 沙盒上

  • 主要方案是 BEM + CSS Modules
  • 實驗性方案是 Shadow DOM
  • 全局樣式庫,例如 normalize.cssreset.css 統一由主應用引入

在應用通信上

  • 使用了 eventBus 的方案來處理數據事件

在應用監控上

  • 統一由主應用來監控

官網:https://micro-frontends.ice.work/
github:https://github.com/ice-lab/icestark

qiankun

同樣是阿里的微前端框架,qiankun 是對 single-spa 的一層封裝
核心做了構建層面的一些約束以及沙箱能力,支持多子應用并存
但是接入的修改成本較高
總的來說算是目前比較優選的微前端框架

在 Entry 方式上

  • 已經支持 HTML Entry 的方式,在框架內部也是依賴的 import-html-entry

在 JS 沙盒上

  • 使用三種沙盒
    • legacySandBox:支持 proxy API 且只有單子應用并存
    • proxySandBox:支持 proxy API 且多子應用并存
    • snapshotSandBox:不支持 proxy API 的快照沙盒
  • legacySandBox 其實是 proxySandBoxsnapshotSandBox 的結合,既想要 proxy 的代理能力,又想在一定程度上有直接操作 window 對象的能力

在 CSS 沙盒上

  • 主要方案是 BEM
  • BEM 不需要子應用自己處理,在子應用接入 qiankun 框架時可以通過配置統一增加 prefix
  • 全局樣式庫,例如 normalize.cssreset.css 統一由主應用引入

在應用通信上

  • Actions 方案:適用于通信較少
    • 數據上:使用一個 store 來存儲數據,使用觀察者模式來監聽
    • 事件上:利用觀察者派發事件的觸發事件通信
  • Shared 方案:適用于通信較多
    • 主應用基于 redux 維護一個狀態池,通過 shared 實例暴露一些方法給子應用使用
    • 子應用需要單獨維護一份 shared 實例,保證在使用和表現上的一致性
      • 獨立運行時使用自身的 shared 實例
      • 在嵌入主應用時使用主應用的 shared 實例
    • 數據和事件都可以通過 redux 來通信

在應用監控上

  • 統一由主應用來監控

官網:https://qiankun.umijs.org/zh
github:https://github.com/umijs/qiankun

Garfish

從開發者大會上看到的方案,來自于字節跳動,有希望成為最優的方案

  • 支持多子應用并存
  • 支持 HTML EntryJS Entry
  • JS 沙盒直接使用快照沙盒
  • 通過HTML整體快照,來實現 CSS 沙盒
  • 通信
    • 數據:同樣通過一個 store 來保存數據
    • 事件:通過自定義事件
  • 監控
    • 保留 window addEventListener removeEventListener 的副本
    • 在沙盒 document 對象上監聽監控

最大的特點是,能夠快照子應用的 DOM 節點,保持 DOM 樹
加上 JS 沙盒 、 CSS 沙盒,能夠保持整個子應用的完整狀態

官網:https://garfish.dev/
github:https://github.com/bytedance/garfish

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容