最近看了幾個微前端框架的原始碼(single-spa、qiankun、micro-app),感覺收穫良多。所以打算造一個迷你版的輪子,來加深自己對所學知識的瞭解。
這個輪子將分為五個版本,逐步的實現一個最小可用的微前端框架:
- 支援不同框架的子應用(v1 分支)
- 支援子應用 HTML 入口(v2 分支)
- 支援沙箱功能,子應用 window 作用域隔離、元素隔離(v3 分支)
- 支援子應用樣式隔離(v4 分支)
- 支援各應用之間的資料通訊(main 分支)
每一個版本的程式碼都是在上一個版本的基礎上修改的,所以 V5 版本的程式碼是最終程式碼。
Github 專案地址:https://github.com/woai3c/mini-single-spa
V1 版本
V1 版本打算實現一個最簡單的微前端框架,只要它能夠正常載入、解除安裝子應用就行。如果將 V1 版本細分一下的話,它主要由以下兩個功能組成:
- 監聽頁面 URL 變化,切換子應用
- 根據當前 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 和監聽兩個事件來完成:
其中 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)
}
這段程式碼的邏輯也比較簡單:
- 解除安裝所有已失活的子應用
- 初始化所有剛註冊的子應用
載入所有符合條件的子應用
根據當前 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 改變後,如果子應用滿足以下兩個條件,則需要載入該子應用:
activeRule()
的返回值為true
,例如 URL 從/
變為/vue
,這時子應用 vue 為啟用狀態(假設它的啟用規則為/vue
)。- 子應用狀態必須為
bootstrap
或unmount
,這樣才能向mount
狀態轉換。如果已經處於mount
狀態並且activeRule()
返回值為true
,則不作任何處理。
如果頁面的 URL 改變後,子應用滿足以下兩個條件,則需要解除安裝該子應用:
activeRule()
的返回值為false
,例如 URL 從/vue
變為/
,這時子應用 vue 為失活狀態(假設它的啟用規則為/vue
)。- 子應用狀態必須為
mount
,也就是當前子應用必須處於載入狀態(如果是其他狀態,則不作任何處理)。然後 URL 改變導致失活了,所以需要解除安裝它,狀態也從mount
變為unmount
。
API 介紹
V1 版本主要向外暴露了兩個 API:
registerApplication()
,註冊子應用。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))
})
}
上面程式碼的邏輯:
- 利用 ajax 請求子應用入口 URL 的內容,得到子應用的 HTML
- 提取 HTML 中
script
style
的內容或 URL,如果是 URL,則再次使用 ajax 拉取內容。最後得到入口頁面所有的script
style
的內容 - 將所有 style 新增到
document.head
下,script
程式碼直接執行 - 將剩下的 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
}
這樣微前端也能拿到每個子應用暴露的方法,從而實現載入、解除安裝子應用的功能。
另外,子應用還得做兩件事:
- 配置 cors,防止出現跨域問題(由於主應用和子應用的域名不同,會出現跨域問題)
- 配置資源釋出路徑
如果子應用是基於 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 版本主要新增以下兩個功能:
- 隔離子應用 window 作用域
- 隔離子應用元素作用域
隔離子應用 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 物件:
- 當子應用裡的程式碼訪問
window.xxx
屬性時,就會被這個代理物件攔截。它會先看看子應用的代理 window 物件有沒有這個屬性,如果找不到,就會從父應用裡找,也就是在真正的 window 物件裡找。 - 當子應用裡的程式碼修改 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 規則,然後替換裡面的 body
、html
字串:
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 版本主要新增了一個全域性資料通訊的功能,設計思路如下:
- 所有應用共享一個全域性物件
window.spaGlobalState
,所有應用都可以對這個全域性物件進行監聽,每當有應用對它進行修改時,會觸發change
事件。 - 可以使用這個全域性物件進行事件訂閱/釋出,各應用之間可以自由的收發事件。
下面是實現了第一點要求的部分關鍵程式碼:
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 執行起來跑一跑,這樣能幫助你更好的理解程式碼。
如果你覺得我的文章寫得不錯,也可以看看我的其他一些技術文章或專案: