手把手教你寫一個簡易的微前端框架

譚光志發表於2022-02-28

最近看了幾個微前端框架的原始碼(single-spaqiankunmicro-app),感覺收穫良多。所以打算造一個迷你版的輪子,來加深自己對所學知識的瞭解。

這個輪子將分為五個版本,逐步的實現一個最小可用的微前端框架:

  1. 支援不同框架的子應用(v1 分支)
  2. 支援子應用 HTML 入口(v2 分支)
  3. 支援沙箱功能,子應用 window 作用域隔離、元素隔離(v3 分支)
  4. 支援子應用樣式隔離(v4 分支)
  5. 支援各應用之間的資料通訊(main 分支)

每一個版本的程式碼都是在上一個版本的基礎上修改的,所以 V5 版本的程式碼是最終程式碼。

Github 專案地址:https://github.com/woai3c/mini-single-spa

V1 版本

V1 版本打算實現一個最簡單的微前端框架,只要它能夠正常載入、解除安裝子應用就行。如果將 V1 版本細分一下的話,它主要由以下兩個功能組成:

  1. 監聽頁面 URL 變化,切換子應用
  2. 根據當前 URL、子應用的觸發規則來判斷是否要載入、解除安裝子應用

監聽頁面 URL 變化,切換子應用

一個 SPA 應用必不可少的功能就是監聽頁面 URL 的變化,然後根據不同的路由規則來渲染不同的路由元件。因此,微前端框架也可以根據頁面 URL 的變化,來切換到不同的子應用:

// 當 location.pathname 以 /vue 為字首時切換到 vue 子應用
https://www.example.com/vue/xxx
// 當 location.pathname 以 /react 為字首時切換到 react 子應用
https://www.example.com/react/xxx

這可以通過重寫兩個 API 和監聽兩個事件來完成:

  1. 重寫 window.history.pushState()
  2. 重寫 window.history.replaceState()
  3. 監聽 popstate 事件
  4. 監聽 hashchange 事件

其中 pushState()replaceState() 方法可以修改瀏覽器的歷史記錄棧,所以我們可以重寫這兩個 API。當這兩個 API 被 SPA 應用呼叫時,說明 URL 發生了變化,這時就可以根據當前已改變的 URL 判斷是否要載入、解除安裝子應用。

// 執行下面程式碼後,瀏覽器的 URL 將從 https://www.xxx.com 變為 https://www.xxx.com/vue
window.history.pushState(null, '', '/vue')

當使用者手動點選瀏覽器上的前進後退按鈕時,會觸發 popstate 事件,所以需要對這個事件進行監聽。同理,也需要監聽 hashchange 事件。

這一段邏輯的程式碼如下所示:

import { loadApps } from '../application/apps'

const originalPushState = window.history.pushState
const originalReplaceState = window.history.replaceState

export default function overwriteEventsAndHistory() {
    window.history.pushState = function (state: any, title: string, url: string) {
        const result = originalPushState.call(this, state, title, url)
        // 根據當前 url 載入或解除安裝 app
        loadApps()
        return result
    }
    
    window.history.replaceState = function (state: any, title: string, url: string) {
        const result = originalReplaceState.call(this, state, title, url)
        loadApps()
        return result
    }
    
    window.addEventListener('popstate', () => {
        loadApps()
    }, true)
    
    window.addEventListener('hashchange', () => {
        loadApps()
    }, true)
}

從上面的程式碼可以看出來,每次 URL 改變時,都會呼叫 loadApps() 方法,這個方法的作用就是根據當前的 URL、子應用的觸發規則去切換子應用的狀態:

export async function loadApps() {
    // 先解除安裝所有失活的子應用
    const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED)
    await Promise.all(toUnMountApp.map(unMountApp))
    
    // 初始化所有剛註冊的子應用
    const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP)
    await Promise.all(toLoadApp.map(bootstrapApp))

    const toMountApp = [
        ...getAppsWithStatus(AppStatus.BOOTSTRAPPED),
        ...getAppsWithStatus(AppStatus.UNMOUNTED),
    ]
    // 載入所有符合條件的子應用
    await toMountApp.map(mountApp)
}

這段程式碼的邏輯也比較簡單:

  1. 解除安裝所有已失活的子應用
  2. 初始化所有剛註冊的子應用
  3. 載入所有符合條件的子應用

    根據當前 URL、子應用的觸發規則來判斷是否要載入、解除安裝子應用

    為了支援不同框架的子應用,所以規定了子應用必須向外暴露 bootstrap() mount() unmount() 這三個方法。bootstrap() 方法在第一次載入子應用時觸發,並且只會觸發一次,另外兩個方法在每次載入、解除安裝子應用時都會觸發。

不管註冊的是什麼子應用,在 URL 符合載入條件時就呼叫子應用的 mount() 方法,能不能正常渲染交給子應用負責。在符合解除安裝條件時則呼叫子應用的 unmount() 方法。

registerApplication({
    name: 'vue',
    // 初始化子應用時執行該方法
    loadApp() { 
        return {
            mount() {                
                // 這裡進行掛載子應用的操作
                app.mount('#app')
            },
            unmount() {
                // 這裡進行解除安裝子應用的操作 
                app.unmount()
            },
        }
    },
    // 如果傳入一個字串會被轉為一個引數為 location 的函式
    // activeRule: '/vue' 會被轉為 (location) => location.pathname === '/vue'
    activeRule: (location) => location.hash === '#/vue'
})

上面是一個簡單的子應用註冊示例,其中 activeRule() 方法用來判斷該子應用是否啟用(返回 true 表示啟用)。每當頁面 URL 發生變化,微前端框架就會呼叫 loadApps() 判斷每個子應用是否啟用,然後觸發載入、解除安裝子應用的操作。

何時載入、解除安裝子應用

首先我們將子應用的狀態分為三種:

  • bootstrap,呼叫 registerApplication() 註冊一個子應用後,它的狀態預設為 bootstrap,下一個轉換狀態為 mount
  • mount,子應用掛載成功後的狀態,它的下一個轉換狀態為 unmount
  • unmount,子應用解除安裝成功後的狀態,它的下一個轉換狀態為 mount,即解除安裝後的應用可再次載入。

在這裡插入圖片描述

現在我們來看看什麼時候會載入一個子應用,當頁面 URL 改變後,如果子應用滿足以下兩個條件,則需要載入該子應用:

  1. activeRule() 的返回值為 true,例如 URL 從 / 變為 /vue,這時子應用 vue 為啟用狀態(假設它的啟用規則為 /vue)。
  2. 子應用狀態必須為 bootstrapunmount,這樣才能向 mount 狀態轉換。如果已經處於 mount 狀態並且 activeRule() 返回值為 true,則不作任何處理。

如果頁面的 URL 改變後,子應用滿足以下兩個條件,則需要解除安裝該子應用:

  1. activeRule() 的返回值為 false,例如 URL 從 /vue 變為 /,這時子應用 vue 為失活狀態(假設它的啟用規則為 /vue)。
  2. 子應用狀態必須為 mount,也就是當前子應用必須處於載入狀態(如果是其他狀態,則不作任何處理)。然後 URL 改變導致失活了,所以需要解除安裝它,狀態也從 mount 變為 unmount

API 介紹

V1 版本主要向外暴露了兩個 API:

  1. registerApplication(),註冊子應用。
  2. start(),註冊完所有的子應用後呼叫,在它的內部會執行 loadApps() 去載入子應用。

registerApplication(Application) 接收的引數如下:

interface Application {
    // 子應用名稱
    name: string

    /**
     * 啟用規則,例如傳入 /vue,當 url 的路徑變為 /vue 時,啟用當前子應用。
     * 如果 activeRule 為函式,則會傳入 location 作為引數,activeRule(location) 返回 true 時,啟用當前子應用。
     */
    activeRule: Function | string

    // 傳給子應用的自定義引數
    props: AnyObject

    /**
     * loadApp() 必須返回一個 Promise,resolve() 後得到一個物件:
     * {
     *   bootstrap: () => Promise<any>
     *   mount: (props: AnyObject) => Promise<any>
     *   unmount: (props: AnyObject) => Promise<any>
     * }
     */
    loadApp: () => Promise<any>
}

一個完整的示例

現在我們來看一個比較完整的示例(程式碼在 V1 分支的 examples 目錄):

let vueApp
registerApplication({
    name: 'vue',
    loadApp() {
        return Promise.resolve({
            bootstrap() {
                console.log('vue bootstrap')
            },
            mount() {
                console.log('vue mount')
                vueApp = Vue.createApp({
                    data() {
                        return {
                            text: 'Vue App'
                        }
                    },
                    render() {
                        return Vue.h(
                            'div',     // 標籤名稱
                            this.text  // 標籤內容
                        )
                    },
                })
                
                vueApp.mount('#app')
            },
            unmount() {
                console.log('vue unmount')
                vueApp.unmount()
            },
        })
    },
    activeRule:(location) => location.hash === '#/vue',
})

registerApplication({
    name: 'react',
    loadApp() { 
        return Promise.resolve({
            bootstrap() {
                console.log('react bootstrap')
            },
            mount() {
                console.log('react mount')
                ReactDOM.render(
                    React.createElement(LikeButton),
                    $('#app')
                );
            },
            unmount() {
                console.log('react unmount')
                ReactDOM.unmountComponentAtNode($('#app'));
            },
        })
    },
    activeRule: (location) => location.hash === '#/react'
})

start()

演示效果如下:
請新增圖片描述

小結

V1 版本的程式碼打包後才 100 多行,如果只是想了解微前端的最核心原理,只看 V1 版本的原始碼就可以了。

V2 版本

V1 版本的實現還是非常簡陋的,能夠適用的業務場景有限。從 V1 版本的示例可以看出,它要求子應用提前把資源都載入好(或者把整個子應用打包成一個 NPM 包,直接引入),這樣才能在執行子應用的 mount() 方法時,能夠正常渲染。

舉個例子,假設我們在開發環境啟動了一個 vue 應用。那麼如何在主應用引入這個 vue 子應用的資源呢?首先排除掉 NPM 包的形式,因為每次修改程式碼都得打包,不現實。第二種方式就是手動在主應用引入子應用的資源。例如 vue 子應用的入口資源為:

在這裡插入圖片描述
那麼我們可以在註冊子應用時這樣引入:

registerApplication({
    name: 'vue',
    loadApp() { 
        return Promise.resolve({
            bootstrap() {
                import('http://localhost:8001/js/chunk-vendors.js')
                import('http://localhost:8001/js/app.js')
            },
            mount() {
                // ...            
            },
            unmount() {
                // ...            
            },
        })
    },
    activeRule: (location) => location.hash === '#/vue'
})

這種方式也不靠譜,每次子應用的入口資原始檔變了,主應用的程式碼也得跟著變。還好,我們有第三種方式,那就是在註冊子應用的時候,把子應用的入口 URL 寫上,由微前端來負責載入資原始檔。

registerApplication({
    // 子應用入口 URL
    pageEntry: 'http://localhost:8081'
    // ...
})

“自動”載入資原始檔

現在我們來看一下如何自動載入子應用的入口檔案(只在第一次載入子應用時執行):

export default function parseHTMLandLoadSources(app: Application) {
    return new Promise<void>(async (resolve, reject) => {
        const pageEntry = app.pageEntry    
        // load html        
        const html = await loadSourceText(pageEntry)
        const domparser = new DOMParser()
        const doc = domparser.parseFromString(html, 'text/html')
        const { scripts, styles } = extractScriptsAndStyles(doc as unknown as Element, app)
        
        // 提取了 script style 後剩下的 body 部分的 html 內容
        app.pageBody = doc.body.innerHTML

        let isStylesDone = false, isScriptsDone = false
        // 載入 style script 的內容
        Promise.all(loadStyles(styles))
        .then(data => {
            isStylesDone = true
            // 將 style 樣式新增到 document.head 標籤
            addStyles(data as string[])
            if (isScriptsDone && isStylesDone) resolve()
        })
        .catch(err => reject(err))

        Promise.all(loadScripts(scripts))
        .then(data => {
            isScriptsDone = true
            // 執行 script 內容
            executeScripts(data as string[])
            if (isScriptsDone && isStylesDone) resolve()
        })
        .catch(err => reject(err))
    })
}

上面程式碼的邏輯:

  1. 利用 ajax 請求子應用入口 URL 的內容,得到子應用的 HTML
  2. 提取 HTML 中 script style 的內容或 URL,如果是 URL,則再次使用 ajax 拉取內容。最後得到入口頁面所有的 script style 的內容
  3. 將所有 style 新增到 document.head 下,script 程式碼直接執行
  4. 將剩下的 body 部分的 HTML 內容賦值給子應用要掛載的 DOM 下。

下面再詳細描述一下這四步是怎麼做的。

一、拉取 HTML 內容

export function loadSourceText(url: string) {
    return new Promise<string>((resolve, reject) => {
        const xhr = new XMLHttpRequest()
        xhr.onload = (res: any) => {
            resolve(res.target.response)
        }

        xhr.onerror = reject
        xhr.onabort = reject
        xhr.open('get', url)
        xhr.send()
    })
}

程式碼邏輯很簡單,使用 ajax 發起一個請求,得到 HTML 內容。
在這裡插入圖片描述
上圖就是一個 vue 子應用的 HTML 內容,箭頭所指的是要提取的資源,方框標記的內容要賦值給子應用所掛載的 DOM。

二、解析 HTML 並提取 style script 標籤內容

這需要使用一個 API DOMParser,它可以直接解析一個 HTML 字串,並且不需要掛到 document 物件上。

const domparser = new DOMParser()
const doc = domparser.parseFromString(html, 'text/html')

提取標籤的函式 extractScriptsAndStyles(node: Element, app: Application) 程式碼比較多,這裡就不貼程式碼了。這個函式主要的功能就是遞迴遍歷上面生成的 DOM 樹,提取裡面所有的 style script 標籤。

三、新增 style 標籤,執行 script 指令碼內容

這一步比較簡單,將所有提取的 style 標籤新增到 document.head 下:

export function addStyles(styles: string[] | HTMLStyleElement[]) {
    styles.forEach(item => {
        if (typeof item === 'string') {
            const node = createElement('style', {
                type: 'text/css',
                textContent: item,
            })

            head.appendChild(node)
        } else {
            head.appendChild(item)
        }
    })
}

js 指令碼程式碼則直接包在一個匿名函式內執行:

export function executeScripts(scripts: string[]) {
    try {
        scripts.forEach(code => {
            new Function('window', code).call(window, window)
        })
    } catch (error) {
        throw error
    }
}

四、將剩下的 body 部分的 HTML 內容賦值給子應用要掛載的 DOM 下

為了保證子應用正常執行,需要將這部分的內容儲存起來。然後每次在子應用 mount() 前,賦值到所掛載的 DOM 下。

// 儲存 HTML 程式碼
app.pageBody = doc.body.innerHTML

// 載入子應用前賦值給掛載的 DOM
app.container.innerHTML = app.pageBody
app.mount()

現在我們已經可以非常方便的載入子應用了,但是子應用還有一些東西需要修改一下。

子應用需要做的事情

在 V1 版本里,註冊子應用的時候有一個 loadApp() 方法。微前端框架在第一次載入子應用時會執行這個方法,從而拿到子應用暴露的三個方法。現在實現了 pageEntry 功能,我們就不用把這個方法寫在主應用裡了,因為不再需要在主應用裡引入子應用。

但是又得讓微前端框架拿到子應用暴露出來的方法,所以我們可以換一種方式暴露子應用的方法:

// 每個子應用都需要這樣暴露三個 API,該屬性格式為 `mini-single-spa-${appName}`
window['mini-single-spa-vue'] = {
    bootstrap,
    mount,
    unmount
}

這樣微前端也能拿到每個子應用暴露的方法,從而實現載入、解除安裝子應用的功能。

另外,子應用還得做兩件事:

  1. 配置 cors,防止出現跨域問題(由於主應用和子應用的域名不同,會出現跨域問題)
  2. 配置資源釋出路徑

如果子應用是基於 webpack 進行開發的,可以這樣配置:

module.exports = {
    devServer: {
        port: 8001, // 子應用訪問埠
        headers: {
            'Access-Control-Allow-Origin': '*'
        }
    },
    publicPath: "//localhost:8001/",
}

一個完整的示例

示例程式碼在 examples 目錄。

registerApplication({
    name: 'vue',
    pageEntry: 'http://localhost:8001',
    activeRule: pathPrefix('/vue'),
    container: $('#subapp-viewport')
})

registerApplication({
    name: 'react',
    pageEntry: 'http://localhost:8002',
    activeRule:pathPrefix('/react'),
    container: $('#subapp-viewport')
})

start()

請新增圖片描述

V3 版本

V3 版本主要新增以下兩個功能:

  1. 隔離子應用 window 作用域
  2. 隔離子應用元素作用域

隔離子應用 window 作用域

在 V2 版本下,主應用及所有的子應用都共用一個 window 物件,這就導致了互相覆蓋資料的問題:

// 先載入 a 子應用
window.name = 'a'
// 後載入 b 子應用
window.name = 'b'
// 這時再切換回 a 子應用,讀取 window.name 得到的值卻是 b
console.log(window.name) // b

為了避免這種情況發生,我們可以使用 Proxy 來代理對子應用 window 物件的訪問:

app.window = new Proxy({}, {
    get(target, key) {
        if (Reflect.has(target, key)) {
            return Reflect.get(target, key)
        }
        
        const result = originalWindow[key]
        // window 原生方法的 this 指向必須綁在 window 上執行,否則會報錯 "TypeError: Illegal invocation"
        // e.g: const obj = {}; obj.alert = alert;  obj.alert();
        return (isFunction(result) && needToBindOriginalWindow(result)) ? result.bind(window) : result
    },

    set: (target, key, value) => {
        this.injectKeySet.add(key)
        return Reflect.set(target, key, value)
    }
})

從上述程式碼可以看出,用 Proxy 對一個空物件做了代理,然後把這個代理物件作為子應用的 window 物件:

  1. 當子應用裡的程式碼訪問 window.xxx 屬性時,就會被這個代理物件攔截。它會先看看子應用的代理 window 物件有沒有這個屬性,如果找不到,就會從父應用裡找,也就是在真正的 window 物件裡找。
  2. 當子應用裡的程式碼修改 window 屬性時,會直接在子應用的代理 window 物件上修改。

那麼問題來了,怎麼讓子應用裡的程式碼讀取/修改 window 時候,讓它們訪問的是子應用的代理 window 物件?

剛才 V2 版本介紹過,微前端框架會代替子應用拉取 js 資源,然後直接執行。我們可以在執行程式碼的時候使用 with 語句將程式碼包一下,讓子應用的 window 指向代理物件:

export function executeScripts(scripts: string[], app: Application) {
    try {
        scripts.forEach(code => {            
            // ts 使用 with 會報錯,所以需要這樣包一下
            // 將子應用的 js 程式碼全域性 window 環境指向代理環境 proxyWindow
            const warpCode = `
                ;(function(proxyWindow){
                    with (proxyWindow) {
                        (function(window){${code}\n}).call(proxyWindow, proxyWindow)
                    }
                })(this);
            `

            new Function(warpCode).call(app.sandbox.proxyWindow)
        })
    } catch (error) {
        throw error
    }
}

解除安裝時清除子應用 window 作用域

當子應用解除安裝時,需要對它的 window 代理物件進行清除。否則下一次子應用重新載入時,它的 window 代理物件會存有上一次載入的資料。剛才建立 Proxy 的程式碼中有一行程式碼 this.injectKeySet.add(key),這個 injectKeySet 是一個 Set 物件,存著每一個 window 代理物件的新增屬性。所以在解除安裝時只需要遍歷這個 Set,將 window 代理物件上對應的 key 刪除即可:

for (const key of injectKeySet) {
    Reflect.deleteProperty(microAppWindow, key as (string | symbol))
}

記錄繫結的全域性事件、定時器,解除安裝時清除

通常情況下,一個子應用除了會修改 window 上的屬性,還會在 window 上繫結一些全域性事件。所以我們要把這些事件記錄起來,在解除安裝子應用時清除這些事件。同理,各種定時器也一樣,解除安裝時需要清除未執行的定時器。

下面的程式碼是記錄事件、定時器的部分關鍵程式碼:

// 部分關鍵程式碼
microAppWindow.setTimeout = function setTimeout(callback: Function, timeout?: number | undefined, ...args: any[]): number {
    const timer = originalWindow.setTimeout(callback, timeout, ...args)
    timeoutSet.add(timer)
    return timer
}

microAppWindow.clearTimeout = function clearTimeout(timer?: number): void {
    if (timer === undefined) return
    originalWindow.clearTimeout(timer)
    timeoutSet.delete(timer)
}
microAppWindow.addEventListener = function addEventListener(
    type: string, 
    listener: EventListenerOrEventListenerObject, 
    options?: boolean | AddEventListenerOptions | undefined,
) {
    if (!windowEventMap.get(type)) {
        windowEventMap.set(type, [])
    }

    windowEventMap.get(type)?.push({ listener, options })
    return originalWindowAddEventListener.call(originalWindow, type, listener, options)
}

microAppWindow.removeEventListener = function removeEventListener(
    type: string, 
    listener: EventListenerOrEventListenerObject, 
    options?: boolean | AddEventListenerOptions | undefined,
) {
    const arr = windowEventMap.get(type) || []
    for (let i = 0, len = arr.length; i < len; i++) {
        if (arr[i].listener === listener) {
            arr.splice(i, 1)
            break
        }
    }

    return originalWindowRemoveEventListener.call(originalWindow, type, listener, options)
}

下面這段是清除事件、定時器的關鍵程式碼:

for (const timer of timeoutSet) {
    originalWindow.clearTimeout(timer)
}

for (const [type, arr] of windowEventMap) {
    for (const item of arr) {
        originalWindowRemoveEventListener.call(originalWindow, type as string, item.listener, item.options)
    }
}

快取子應用快照

之前提到過子應用每次載入的時候會都執行 mount() 方法,由於每個 js 檔案只會執行一次,所以在執行 mount() 方法之前的程式碼在下一次重新載入時不會再次執行。

舉個例子:

window.name = 'test'

function bootstrap() { // ... }
function mount() { // ... }
function unmount() { // ... }

上面是子應用入口檔案的程式碼,在第一次執行 js 程式碼時,子應用可以讀取 window.name 這個屬性的值。但是子應用解除安裝時會把 name 這個屬性清除掉。所以子應用下一次載入的時候,就讀取不到這個屬性了。

為了解決這個問題,我們可以在子應用初始化時(拉取了所有入口 js 檔案並執行後)將當前的子應用 window 代理物件的屬性、事件快取起來,生成快照。下一次子應用重新載入時,將快照恢復回子應用上。

生成快照的部分程式碼:

const { windowSnapshot, microAppWindow } = this
const recordAttrs = windowSnapshot.get('attrs')!
const recordWindowEvents = windowSnapshot.get('windowEvents')!

// 快取 window 屬性
this.injectKeySet.forEach(key => {
    recordAttrs.set(key, deepCopy(microAppWindow[key]))
})

// 快取 window 事件
this.windowEventMap.forEach((arr, type) => {
    recordWindowEvents.set(type, deepCopy(arr))
})

恢復快照的部分程式碼:

const { 
    windowSnapshot, 
    injectKeySet, 
    microAppWindow, 
    windowEventMap, 
    onWindowEventMap,
} = this
const recordAttrs = windowSnapshot.get('attrs')!
const recordWindowEvents = windowSnapshot.get('windowEvents')!

recordAttrs.forEach((value, key) => {
    injectKeySet.add(key)
    microAppWindow[key] = deepCopy(value)
})

recordWindowEvents.forEach((arr, type) => {
    windowEventMap.set(type, deepCopy(arr))
    for (const item of arr) {
        originalWindowAddEventListener.call(originalWindow, type as string, item.listener, item.options)
    }
})

隔離子應用元素作用域

我們在使用 document.querySelector() 或者其他查詢 DOM 的 API 時,都會在整個頁面的 document 物件上查詢。如果在子應用上也這樣查詢,很有可能會查詢到子應用範圍外的 DOM 元素。為了解決這個問題,我們需要重寫一下查詢類的 DOM API:

// 將所有查詢 dom 的範圍限制在子應用掛載的 dom 容器上
Document.prototype.querySelector = function querySelector(this: Document, selector: string) {
    const app = getCurrentApp()
    if (!app || !selector || isUniqueElement(selector)) {
        return originalQuerySelector.call(this, selector)
    }
    // 將查詢範圍限定在子應用掛載容器的 DOM 下
    return app.container.querySelector(selector)
}

Document.prototype.getElementById = function getElementById(id: string) {
    // ...
}

將查詢範圍限定在子應用掛載容器的 DOM 下。另外,子應用解除安裝時也需要恢復重寫的 API:

Document.prototype.querySelector = originalQuerySelector
Document.prototype.querySelectorAll = originalQuerySelectorAll
// ...

除了查詢 DOM 要限制子應用的範圍,樣式也要限制範圍。假設在 vue 應用上有這樣一個樣式:

body {
    color: red;
}

當它作為一個子應用被載入時,這個樣式需要被修改為:

/* body 被替換為子應用掛載 DOM 的 id 選擇符 */
#app {
    color: red;
}

實現程式碼也比較簡單,需要遍歷每一條 css 規則,然後替換裡面的 bodyhtml 字串:

const re = /^(\s|,)?(body|html)\b/g
// 將 body html 標籤替換為子應用掛載容器的 id
cssText.replace(re, `#${app.container.id}`)

V4 版本

V3 版本實現了 window 作用域隔離、元素隔離,在 V4 版本上我們將實現子應用樣式隔離。

第一版

我們都知道建立 DOM 元素時使用的是 document.createElement() API,所以我們可以在建立 DOM 元素時,把當前子應用的名稱當成屬性寫到 DOM 上:

Document.prototype.createElement = function createElement(
    tagName: string,
    options?: ElementCreationOptions,
): HTMLElement {
    const appName = getCurrentAppName()
    const element = originalCreateElement.call(this, tagName, options)
    appName && element.setAttribute('single-spa-name', appName)
    return element
}

這樣所有的 style 標籤在建立時都會有當前子應用的名稱屬性。我們可以在子應用解除安裝時將當前子應用所有的 style 標籤進行移除,再次掛載時將這些標籤重新新增到 document.head 下。這樣就實現了不同子應用之間的樣式隔離。

移除子應用所有 style 標籤的程式碼:

export function removeStyles(name: string) {
    const styles = document.querySelectorAll(`style[single-spa-name=${name}]`)
    styles.forEach(style => {
        removeNode(style)
    })

    return styles as unknown as HTMLStyleElement[]
}

第一版的樣式作用域隔離完成後,它只能對每次只載入一個子應用的場景有效。例如先載入 a 子應用,解除安裝後再載入 b 子應用這種場景。在解除安裝 a 子應用時會把它的樣式也解除安裝。如果同時載入多個子應用,第一版的樣式隔離就不起作用了。

第二版

由於每個子應用下的 DOM 元素都有以自己名稱作為值的 single-spa-name 屬性(如果不知道這個名稱是哪來的,請往上翻一下第一版的描述)。

在這裡插入圖片描述
所以我們可以給子應用的每個樣式加上子應用名稱,也就是將這樣的樣式:

div {
    color: red;
}

改成:

div[single-spa-name=vue] {
    color: red;
}

這樣一來,就把樣式作用域範圍限制在對應的子應用所掛載的 DOM 下。

給樣式新增作用域範圍

現在我們來看看具體要怎麼新增作用域:

/**
 * 給每一條 css 選擇符新增對應的子應用作用域
 * 1. a {} -> a[single-spa-name=${app.name}] {}
 * 2. a b c {} -> a[single-spa-name=${app.name}] b c {}
 * 3. a, b {} -> a[single-spa-name=${app.name}], b[single-spa-name=${app.name}] {}
 * 4. body {} -> #${子應用掛載容器的 id}[single-spa-name=${app.name}] {}
 * 5. @media @supports 特殊處理,其他規則直接返回 cssText
 */

主要有以上五種情況。

通常情況下,每一條 css 選擇符都是一個 css 規則,這可以通過 style.sheet.cssRules 獲取:

在這裡插入圖片描述
拿到了每一條 css 規則之後,我們就可以對它們進行重寫,然後再把它們重寫掛載到 document.head 下:

function handleCSSRules(cssRules: CSSRuleList, app: Application) {
    let result = ''
    Array.from(cssRules).forEach(cssRule => {
        const cssText = cssRule.cssText
        const selectorText = (cssRule as CSSStyleRule).selectorText
        result += cssRule.cssText.replace(
            selectorText, 
            getNewSelectorText(selectorText, app),
        )
    })

    return result
}

let count = 0
const re = /^(\s|,)?(body|html)\b/g
function getNewSelectorText(selectorText: string, app: Application) {
    const arr = selectorText.split(',').map(text => {
        const items = text.trim().split(' ')
        items[0] = `${items[0]}[single-spa-name=${app.name}]`
        return items.join(' ')
    })

    // 如果子應用掛載的容器沒有 id,則隨機生成一個 id
    let id = app.container.id
    if (!id) {
        id = 'single-spa-id-' + count++
        app.container.id = id
    }

    // 將 body html 標籤替換為子應用掛載容器的 id
    return arr.join(',').replace(re, `#${id}`)
}

核心程式碼在 getNewSelectorText() 上,這個函式給每一個 css 規則都加上了 [single-spa-name=${app.name}]。這樣就把樣式作用域限制在了對應的子應用內了。

效果演示

大家可以對比一下下面的兩張圖,這個示例同時載入了 vue、react 兩個子應用。第一張圖裡的 vue 子應用部分字型被 react 子應用的樣式影響了。第二張圖是新增了樣式作用域隔離的效果圖,可以看到 vue 子應用的樣式是正常的,沒有被影響。

在這裡插入圖片描述

在這裡插入圖片描述

V5 版本

V5 版本主要新增了一個全域性資料通訊的功能,設計思路如下:

  1. 所有應用共享一個全域性物件 window.spaGlobalState,所有應用都可以對這個全域性物件進行監聽,每當有應用對它進行修改時,會觸發 change 事件。
  2. 可以使用這個全域性物件進行事件訂閱/釋出,各應用之間可以自由的收發事件。

下面是實現了第一點要求的部分關鍵程式碼:

export default class GlobalState extends EventBus {
    private state: AnyObject = {}
    private stateChangeCallbacksMap: Map<string, Array<Callback>> = new Map()

    set(key: string, value: any) {
        this.state[key] = value
        this.emitChange('set', key)
    }

    get(key: string) {
        return this.state[key]
    }

    onChange(callback: Callback) {
        const appName = getCurrentAppName()
        if (!appName) return

        const { stateChangeCallbacksMap } = this
        if (!stateChangeCallbacksMap.get(appName)) {
            stateChangeCallbacksMap.set(appName, [])
        }

        stateChangeCallbacksMap.get(appName)?.push(callback)
    }

    emitChange(operator: string, key?: string) {
        this.stateChangeCallbacksMap.forEach((callbacks, appName) => {
            /**
             * 如果是點選其他子應用或父應用觸發全域性資料變更,則當前開啟的子應用獲取到的 app 為 null
             * 所以需要改成用 activeRule 來判斷當前子應用是否執行
             */
            const app = getApp(appName) as Application
            if (!(isActive(app) && app.status === AppStatus.MOUNTED)) return
            callbacks.forEach(callback => callback(this.state, operator, key))
        })
    }
}

下面是實現了第二點要求的部分關鍵程式碼:

export default class EventBus {
    private eventsMap: Map<string, Record<string, Array<Callback>>> = new Map()

    on(event: string, callback: Callback) {
        if (!isFunction(callback)) {
            throw Error(`The second param ${typeof callback} is not a function`)
        }

        const appName = getCurrentAppName() || 'parent'

        const { eventsMap } = this
        if (!eventsMap.get(appName)) {
            eventsMap.set(appName, {})
        }

        const events = eventsMap.get(appName)!
        if (!events[event]) {
            events[event] = [] 
        }

        events[event].push(callback)
    }

    emit(event: string, ...args: any) {
        this.eventsMap.forEach((events, appName) => {
            /**
             * 如果是點選其他子應用或父應用觸發全域性資料變更,則當前開啟的子應用獲取到的 app 為 null
             * 所以需要改成用 activeRule 來判斷當前子應用是否執行
             */
            const app = getApp(appName) as Application
            if (appName === 'parent' || (isActive(app) && app.status === AppStatus.MOUNTED)) {
                if (events[event]?.length) {
                    for (const callback of events[event]) {
                        callback.call(this, ...args)
                    }
                }
            }
        })
    }
}

以上兩段程式碼都有一個相同的地方,就是在儲存監聽回撥函式的時候需要和對應的子應用關聯起來。當某個子應用解除安裝時,需要把它關聯的回撥函式也清除掉。

全域性資料修改示例程式碼

// 父應用
window.spaGlobalState.set('msg', '父應用在 spa 全域性狀態上新增了一個 msg 屬性')
// 子應用
window.spaGlobalState.onChange((state, operator, key) => {
    alert(`vue 子應用監聽到 spa 全域性狀態發生了變化: ${JSON.stringify(state)},操作: ${operator},變化的屬性: ${key}`)
})

在這裡插入圖片描述

全域性事件示例程式碼

// 父應用
window.spaGlobalState.emit('testEvent', '父應用傳送了一個全域性事件: testEvent')
// 子應用
window.spaGlobalState.on('testEvent', () => alert('vue 子應用監聽到父應用傳送了一個全域性事件: testEvent'))

在這裡插入圖片描述

總結

至此,一個簡易微前端框架的技術要點已經講解完畢。強烈建議大家在看文件的同時,把 demo 執行起來跑一跑,這樣能幫助你更好的理解程式碼。

如果你覺得我的文章寫得不錯,也可以看看我的其他一些技術文章或專案:

相關文章