首頁>技術>

前言

本文內容是自己對微前端的一些淺見以及對最近寫的一個微前端框架技術實現的總結。作者水平有限,歡迎大家多多指錯,多提意見~ 原始碼地址:

https://github.com/chuifengji/microcosmos

微前端是什麼

我第一次聽說微前端這個概念是在一年前左右偶然看到了美團的一篇技術部落格:用微前端的方式搭建單頁應用。然而那時候我連單頁面應用是什麼都還不知道,自然是看的一頭霧水了。目前大家普遍認為微前端的概念由ThoughtWorks在2016年提出。四年的時間,飛速發展,目前我們已經能看到很多優秀的開源作品,如single-spa、qiankun、icestark、Micro Frontends etc.

那麼微前端到底是什麼呢?其實這個問題會更好的幫助我們認識:為什麼需要微前端?

你可能不知道微前端,但你應該知道微服務。

微服務是一種軟體開發技術- 面向服務的體系結構(SOA)架構樣式的一種變體,將應用程式構造為一組鬆散耦合的服務。在微服務體系結構中,服務是細粒度的,協議是輕量級的微服務是一種以業務功能為主的服務設計概念,每一個服務都具有自主執行的業務功能,對外開放不受語言限制的 API (最常用的是 HTTP),應用程式則是由一個或多個微服務組成。

微服務的出現主要是為了解決應用的複雜化帶來的一系列問題。微前端亦然。當大家發現傳統的SPA在不斷的迭代中慢慢進化成了巨石應用,使得應用的開發、部署、維護都變得異常困難。我們就迫切的需要一種方式將前端應用進行拆分,以此來分解複雜度。

又或者單純的分久必合合久必分罷了?

與元件化的區別?在以前,我們提出元件化開發的概念,但它在我們現在的期望面前不夠用了。誠然元件化的主要目的是追求更好的可複用和維護性,這點和微前端類似。但它對應用拆分的力度是元件,我們無法將元件分開部署。微前端則是將前端應用分解成能夠獨立開發、測試、部署的子應用,而在使用者看來仍然是內聚的單個產品,粒度是app,並且,因為獨立開發,我們期望技術棧無關,這是非常重要的。我還沒有工作經驗,在這方面難談太多,qiankun開發者的這篇文章很好的回答了為什麼技術棧無關在微前端中如此重要。微前端的核心價值

理想的微前端是什麼樣呢?和聲的觀點我蠻贊同,那就是子工程是不知道自己是作為子工程在工作的。不過應用間通訊的場景還是有的,不然大家也不會總是強調父子通訊了。

為了實現我們的願景,我們需要將多個獨立的前端應用整合到一起,實現的方式當然有很多。比如制定一套嚴格的開發規範和完善的工程化方案,又或者iframe?我想你一定想到了iframe,沒錯iframe就是實現微前端的一種方式之一。但是iframe所帶來的種種問題使我們實在沒法優先考慮它。

在這裡,我們要談的是實現一種容器(容器這個詞似乎比框架更貼切一些),容器承載著主應用,通過在主應用中註冊子應用的方式來實現微前端,也就是一種runtime的方案。

下面是我用microcosmos寫的一個微前端demo,主應用中包含了一個vue app和react app。

我想你應該已經知道微前端是什麼了。接下來,讓我們看看microcosmos的技術實現。

Microcosmos實現整體架構

咕咕咕,下面這張圖就是microcosmos的架構了,整體的架構很簡單,你從對應的表情能看出來我對各個部分實現的滿意程度。下面分別介紹。

相關API

引入

npm i microcosmos

import { start, register,initCosmosStore } from 'microcosmos';

註冊的應用

register([  {    name: 'sub-react',    entry: "http://localhost:3001",    container: "sub-react",    matchRouter: "/sub-react"  },  {    name: 'sub-vue',    entry: "http://localhost:3002",    container: "sub-vue",    matchRouter: "/sub-vue"  }])

開始

start()

主應用路由方式

function App() { function goto(title, href) {  window.history.pushState(href, title, href); } return (    <div>   <nav>    <ol>     <li onClick={(e) => goto('sub-vue', '/sub-vue')}>子應用一</li>     <li onClick={(e) => goto('sub-react', '/sub-react')}>子應用二</li>    </ol>   </nav>      <div id="sub-vue"></div>      <div id="sub-react"></div>  </div> )}

子應用必須匯出生命週期鉤子函式

bootstrap、mount、unmount

export async function bootstrap() {  console.log('react bootstrap')}export async function mount() {  console.log('react mount')  ReactDOM.render(<App />, document.getElementById('app-react'))}export async function unmount() {  console.log('react unmout')  let root = document.getElementById('sub-react');  root.innerHTML = ''}

全域性狀態通訊/儲存

應用之間通訊的場景是有,但絕大多數情況下資料量少,頻度低,所以全域性Store設計的也很簡單。

在主應用中:

initCosmosStore:初始化storesubscribeStore:監聽store變化changeStore:給store派發新值getStore:獲取store當前快照
let store = initCosmosStore({ name: 'chuifengji' })store.subscribeStore((newValue, oldValue) => {  console.log(newValue, oldValue);})store.changeStore({ name: 'wzx' })store.getStore();

在子應用中:

export async function mount(rootStore) {  rootStore.subscribeStore((newValue, oldValue) => {    console.log(newValue, oldValue);  }    rootStore.changeStore({ name: 'xxx' }).then(res => console.log(res))    rootStore.getStore();    instance = new Vue({    router,    store,    render: h => h(App)  }).$mount('#app-vue')}
html-loader

html-loader是通過獲取頁面的html,來獲取app的資訊,相對的一種方法是JS-loader,Js-loader和子應用的耦合性要高一點,子應用得和主應用約定好承載容器不是。

那html-loader是如何工作的呢?其實很簡單,就是通過應用的入口地址,如:http://localhost:3001, 再呼叫fetch函式。獲取到html的text格式資訊後,我們從中取出我們需要的部分掛載到子應用承載點上。下面這張圖是上面那個微前端demo的element結構。你可以看到子應用被掛載id為sub-react的標籤下。

如何來做呢?

我想你的第一反應可能是正則,我一開始也是用正則來處理的,但是我後來發現,正則太難完備了(原諒我這個正則盲)我總能寫出示例讓我自己的正則匯出錯誤的結果。並且用正則來寫,程式碼看著確實挺亂的,後期維護也不太方便。既然是html字串,為什麼我們不用dom api來處理呢?又是iframe...直接新建一個iframe,利用src屬性載入iframe。問題來了,我怎麼知道iframe什麼時候載入好了?onload嗎,顯然不行,我們只是為了取出資料而已。DOMContentLoaded?像下面這樣,寫一個ready函式,還是不行,DOMContentLoaded會等待js執行完才回調。對SPA來說,這時間可能有點長了。

function iframeReady(iframe: HTMLIFrameElement, iframeName: string): Promise<Document> {    return new Promise(function (resolve, reject) {        window.frames[iframeName].addEventListener('DOMContentLoaded', () => {            let html = iframe.contentDocument || (iframe.contentWindow as Window).document;            resolve(html);        });    });}

沒辦法,只好想別的辦法,寫定時函式來判斷dom中是否存在body節點,通過適當調整定時函式的執行週期,好像可以,但我們無法知道子應用的結構,依賴於body還是不行的,太不可靠了。

function iframeReady(iframe: HTMLIFrameElement): Promise<Document> {    return new Promise(function (resolve, reject) {        (function isiframeReady() {            if (iframe.contentDocument.body || (iframe.contentWindow as Window).document.body) {                resolve(iframe.contentDocument || (iframe.contentWindow as Window).document)            } else {                setInterval(isiframeReady, 10)            }        })()    })}

而且要獲取到iframe的contentWindow的話你需要將iframe掛在到dom上,確實,可以設定為display:none,但太不優雅了。怎麼看怎麼不舒服。

srcdoc?是個不錯的選擇,可惜IE不支援這個新屬性。

那就將正則和DOM API結合吧。我們通過正則獲取head和body節點下的內容,這兩個正則還是挺容易完備的,再將它們innerHtml到createElement出的一個div節點中,通過DOM API來遍歷。DOM的結構是穩定的,我們可以輕鬆可靠的獲取我們想要的內容,即html結構資訊和js。

life-cycle

生命週期迴圈實際上是個大便歷。每次路由改變的時候我們需要觸發lifeCycle,對已經註冊的app進行遍歷,該解除安裝的解除安裝,該注入的注入。

lifeCycle會遍歷子應用列表,依次執行它們的生命週期函式,這裡有個小問題,子應用的生命週期函式是如何被主應用獲取到的,如果你和我一樣不熟悉webpack,或許會陷入這樣的困惑,事實上,webpack以umd格式進行打包的話,require函式會將export出的函式合成一個module掛到window上。這樣我們就可以獲取啦。

本身倒沒有什麼問題,只是我寫的lifeCycle,對於應用狀態依賴有點弱。。比如說,第一次進入某個子應用需要fetch,後面就不應需要了。我的做法是通過函式快取來實現,但是整個生命週期的執行沒有絲毫變化,這樣似乎不太好,不夠優雅。

這裡倒是有個要補充的點,我們希望使用者的點選觸發window.history.pushState事件,以此來顯式的改變位址列url,但是我們還需要對pushSate進行監聽來觸發函式切換應用。pushState又是沒法被直接監聽的,我們需要對window.history.pushState事件進行包裝,通過監聽自定義事件來監聽history變化,下面是實現函式。

export function patchEventListener(event: any, ListerName: string) {    return function (this: any) {        const e = new Event(ListerName);        event.apply(this, arguments)        window.dispatchEvent(e);    };}window.history.pushState = patchEventListener(window.history.pushState, "cosmos_pushState");window.addEventListener("cosmos_pushState", routerChange);
js隔離

微前端中既然存在多個獨立開發的應用,自然需要隔離js,採取的方式是構建沙箱。在瀏覽器當中,沙箱隔離了作業系統和瀏覽器渲染引擎,限制程序對作業系統資源的訪問和修改。實際上,如果我們需要的app需要執行一些信任度不高的外部js的時候你也是需要沙箱的。一般情況下,我們說的沙箱強調的是兩層,隔離和安全。js沙箱本身是個蠻大的坑,好在大部分情況下程式碼安全都不是微前端要考慮的問題,主應用對接入的子應用不能信任這樣的情況還是比較少。微前端中的沙箱要考慮的是第一層,完全的隔離反而會帶來問題。

如果不考慮全域性物件,不考慮DOM和BOM,我們要做的事情其實非常簡單。使用new Function,這樣子應用之間的變數都執行在函式作用域中,自然不會衝突了,但是我們還是得考慮全域性變數,考慮DOM和BOM。特別是那些個框架大多都改了原生物件。那我們如何實現window的隔離呢?

主要的思路有三種:

快照沙箱:

快照沙箱實際上就是在應用mount時啟用生成快照,在unmount時啟用恢復原有環境。比如app A掛載時修改了一個全域性變數window.appName = 'vue',那我就可以記錄下當前的快照(修改前的屬性值)。當app A解除安裝時,我就可以把當前的快照和當前環境進行比對,獲知原有環境從而恢復執行環境。

class SnapshotSandbox {    constructor() {        this.proxy = window;         this.modifyPropsMap = {}; // 修改了哪些屬性        this.active();    }    active() {        this.windowSnapshot = {}; // window物件的快照        for (const prop in window) {            if (window.hasOwnProperty(prop)) {                // 將window上的屬性進行拍照                this.windowSnapshot[prop] = window[prop];            }        }        Object.keys(this.modifyPropsMap).forEach(p => {            window[p] = this.modifyPropsMap[p];        });    }    inactive() {        for (const prop in window) { // diff 差異            if (window.hasOwnProperty(prop)) {                // 將上次拍照的結果和本次window屬性做對比                if (window[prop] !== this.windowSnapshot[prop]) {                    // 儲存修改後的結果                    this.modifyPropsMap[prop] = window[prop];                     // 還原window                    window[prop] = this.windowSnapshot[prop];                 }            }        }    }}let sandbox = new SnapshotSandbox();((window) => {    window.a = 1;    window.b = 2;    window.c = 3    console.log(a,b,c)    sandbox.inactive();    console.log(a,b,c)})(sandbox.proxy);

快照沙箱的思路很簡單,也很容易做到子應用的狀態保持,但是顯然快照沙箱只能支援單例項的場景,對於多例項共存的場景,它就無能為力了。

借用iframe:

啊這個,也太沒逼格了。開玩笑,其實iframe也不好做,雖然我們通過它可以拿到完全隔離的 window、document 等上下文。但還是不能直接加以使用的,對一些原生物件,我們還是得進行“魔改”。然後你還得通過postMessage,建立iframe和主應用之間的通訊。不然路由啥的還玩個錘子。

proxy代理:

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 = {a:'ldl'};    console.log(window.a)})(sandbox1.proxy);a:'ldl'((window) => {    window.a = 'world';    console.log(window.a)})(sandbox2.proxy);

上面這個proxy是很簡單了,讀時優先獲取"拷貝值",沒有就代理到原值,寫時代理到“拷貝值”。但它存在著諸多問題,且不說各種惡意程式碼,如果全域性物件使用self、this、globalThis,代理就無效了,只代理get和set也是不夠的。最重要的,只是在一定程度上隔離了全域性變數而已,window的原生物件和方法,全部失效。

所以我們需要真的拷貝下來,一次性拷貝開銷又太大,那就Copy-on-write。

function getOwnPropertyDescriptors(target: any) {    const res: any = {}    Reflect.ownKeys(target).forEach(key => {        res[key] = Object.getOwnPropertyDescriptor(target, key)    })    return res}export function copyProp(target: any, source: any) {    if (Array.isArray(target)) {        for (let i = 0; i < source.length; i++) {            if (!(i in target)) {                target[i] = source[i];            }        }    }    else {        const descriptors = getOwnPropertyDescriptors(source)        //delete descriptors[DRAFT_STATE as any]        let keys = Reflect.ownKeys(descriptors)        for (let i = 0; i < keys.length; i++) {            const key: any = keys[i]            const desc = descriptors[key]            if (desc.writable === false) {                desc.writable = true                desc.configurable = true            }            if (desc.get || desc.set)                descriptors[key] = {                    configurable: true,                    writable: true,                     enumerable: desc.enumerable,                    value: source[key]                }        }        target = Object.create(Object.getPrototypeOf(source), descriptors)        console.log(target)    }}export function copyOnWrite(draftState: {    originalValue: {        [key: string]: any;    };    draftValue: any;    onWrite: any;    mutated: boolean;}) {    const { originalValue, draftValue, mutated, onWrite } = draftState;    if (!mutated) {        draftState.mutated = true;        if (onWrite) {            onWrite(draftValue);        }        copyProp(draftValue, originalValue);    }}

我選擇也是這種辦法,具體的這裡就不多說了。我對這個部分的實現很不滿意, 程式碼參考自immer,這個庫實在有太多可以借鑑的東西。

感興趣的可以自己研究下:immer

css隔離

css隔離我其實沒有刻意去考慮,其實我個人覺得這不是微前端容器要考慮的內容,因為這個問題和微前端無關,幾乎是在有css起,我們就在遭遇這樣的問題,SPA時代更是已經成了必須要考慮的問題。所以在microcosmos中我沒有去解決css隔離的問題。你依然要像開發SPA一樣,採取 BEM(Block Element Modifier) 約定專案字首,css module,css-in-js等方案。

而像qiankun所說的 Dynamic Stylesheet其實蠻無聊的(我自己也加了hh),子應用的裝卸自然包含著css的裝卸,但是這不能保證子應用與主應用之間沒有衝突,更不用說還可能存在多個子應用並行的情況。(當然了,他們現在也提出了其他方案,值得期待!)

那你可能會說,Why not shadow dom?

shadow dom確實天生隔離樣式,我們很多的開源元件庫都使用了shadow dom。但是要把整個應用掛在shadow dom風險還是太大了。會出現各種各樣的問題。

比如React17之前,為了減少 DOM 上的事件物件來節省記憶體,優化頁面效能,同時也為了實現事件排程機制,所有的事件都代理到document元素上。 而shadow dom 裡觸發的事件,在外層拿到 event.target 的時候,只會拿到 host(宿主元素),所以導致了 react 的事件排程出現問題。

如果你不了解react,我解釋一下。

在React 的「合成事件機制」中「事件」並不會直接繫結到具體的 DOM 元素上,而是通過在 document 上繫結的 ReactEventListener 來管理, 當時元素被單擊或觸發其他事件時,事件被 dispatch 到 document 時將由 React 進行處理並觸發相應合成事件的執行。

對於shadow dom,因為主文件內部的指令碼並不了解 shadow dom 內部,尤其是當元件來自於第三方庫,所以,為了保持細節簡單,瀏覽器會重新定位(retarget)事件。當事件在元件外部捕獲時,shadow DOM 中發生的事件將會以 host 元素作為目標。

這將讓 React 在處理合成事件時,不認為 ShadowDOM 中元素基於 JSX 語法繫結的事件被觸發了。

當然了,除此之外,shadow DOM還會有許多其他問題,(我聽說的,shadow dom我用的很少。

應用通訊

理想的微前端可能都不需要這個設計,因為我們說了,子應用是不知道自己是作為子應用在執行的,但是畢竟只是理想。我們還是會有一些父子通訊的需求。不過既然需求較弱,我的實現也很弱了。

microcosmos 裡我只是運用了簡單的釋出訂閱來實現。靠,你這也太簡單了吧。(別罵了別罵了,能用就行

export function initCosmosStore(initData) {    return window.MICROCOSMOS_ROOT_STORE = (function () {        let store = initData;        let observers: Array<Function> = [];        function getStore() {            return store;        }        function changeStore(newValue) {            return new Promise((resolve, reject) => {                if (newValue !== store) {                    let oldValue = store;                    store = newValue;                    resolve(store);                    observers.forEach(fn => fn(newValue, oldValue));                }            })        }        function subscribeStore(fn) {            observers.push(fn);        }        return { getStore, changeStore, subscribeStore }    })()}
預載入

預載入是個好東西,對於一些通過微前端實現的工作臺,主應用上可能註冊了十幾個甚至更多的子應用,我們往往不會在短時間內都執行它們,那通過預載入,就能夠提前抓取子應用的資料資訊,以便讓之後的應用切換更加流暢,顯著降低白屏時間,讓微前端的優勢發揮到極致。然鵝這個部分我的實現也非常簡單,寫著玩的嘛~

至此,microcosmos的技術實現就講完啦,當然還是有些小細節,沒法全部來講。

總結

微前端的架構雖然看起來簡單,但如果真的要做一個高可用的版本,還有很多的路要走,相信未來我們會有更完善的一整套微前端工程化方案,而不是侷限於容器。

PS:即將釋出的webpack5的特性之一module federation使得JavaScript 應用得以從另一個 JavaScript 應用中動態的載入程式碼 —— 同時共享依賴。如果某應用所消費的 federated module 沒有 federated code 中所需的依賴,Webpack 將會從 federated 構建源中下載缺少的依賴項。讓我們一起看看這最終會給微前端帶來什麼。

引用微服務概念Micro Frontendsnew 一個 immer by ayqyModule Federation

作者:Ethanlv_吹風機

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • Docker服務