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

最近看了幾個微前端框架的源碼(single-spa[1]、qiankun[2]、micro-app[3]),感覺收穫良多。所以打算造一個迷你版的輪子,來加深自己對所學知識的瞭解。

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

  1. 支持不同框架的子應用(v1[4] 分支)2. 支持子應用 HTML 入口(v2[5] 分支)3. 支持沙箱功能,子應用 window 作用域隔離、元素隔離(v3[6] 分支)4. 支持子應用樣式隔離(v4[7] 分支)5. 支持各應用之間的數據通信(main[8] 分支)

每一個版本的代碼都是在上一個版本的基礎上修改的,所以 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()[9]2. 重寫 window.history.replaceState()[10]3. 監聽 popstate[11] 事件 4. 監聽 hashchange[12] 事件

其中 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. 子應用狀態必須爲 bootstrap 或 unmount,這樣才能向 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 的內容,得到子應用的 HTML2. 提取 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[13],它可以直接解析一個 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[14] 來代理對子應用 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[15] 語句將代碼包一下,讓子應用的 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 運行起來跑一跑,這樣能幫助你更好的理解代碼。

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

• 帶你入門前端工程 [16]• 可視化拖拽組件庫一些技術要點原理分析 [17]• 前端性能優化 24 條建議(2020)[18]• 前端監控 SDK 的一些技術要點原理分析 [19]• 手把手教你寫一個腳手架 [20]• 計算機系統要素 - 從零開始構建現代計算機 [21]

References

[1] single-spa: https://github.com/single-spa/single-spa
[2] qiankun: https://github.com/umijs/qiankun
[3] micro-app: https://github.com/micro-zoe/micro-app
[4] v1: https://github.com/woai3c/mini-single-spa/tree/v1
[5] v2: https://github.com/woai3c/mini-single-spa/tree/v2
[6] v3: https://github.com/woai3c/mini-single-spa/tree/v3
[7] v4: https://github.com/woai3c/mini-single-spa/tree/v4
[8] main: https://github.com/woai3c/mini-single-spa
[9] window.history.pushState(): https://developer.mozilla.org/zh-CN/docs/Web/API/History/pushState
[10] window.history.replaceState(): https://developer.mozilla.org/zh-CN/docs/Web/API/History/replaceState
[11] popstate: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/popstate_event
[12] hashchange: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/hashchange_event
[13] DOMParser: https://developer.mozilla.org/zh-CN/docs/Web/API/DOMParser
[14] Proxy: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
[15] with: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/with
[16] 帶你入門前端工程: https://woai3c.gitee.io/introduction-to-front-end-engineering/
[17] 可視化拖拽組件庫一些技術要點原理分析: https://github.com/woai3c/Front-end-articles/issues/19
[18] 前端性能優化 24 條建議(2020): https://github.com/woai3c/Front-end-articles/blob/master/performance.md
[19] 前端監控 SDK 的一些技術要點原理分析: https://github.com/woai3c/Front-end-articles/issues/26
[20] 手把手教你寫一個腳手架 : https://github.com/woai3c/Front-end-articles/issues/22
[21] 計算機系統要素 - 從零開始構建現代計算機: https://github.com/woai3c/nand2tetris

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/yguZZac17yW2koiQADvROg