手把手教你寫一個簡易的微前端框架
最近看了幾個微前端框架的源碼(single-spa[1]、qiankun[2]、micro-app[3]),感覺收穫良多。所以打算造一個迷你版的輪子,來加深自己對所學知識的瞭解。
這個輪子將分爲五個版本,逐步的實現一個最小可用的微前端框架:
- 支持不同框架的子應用(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 版本細分一下的話,它主要由以下兩個功能組成:
- 監聽頁面 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 和監聽兩個事件來完成:
- 重寫 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)
}
這段代碼的邏輯也比較簡單:
- 卸載所有已失活的子應用 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))
})
}
上面代碼的邏輯:
- 利用 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 內容。
二、解析 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
}
這樣微前端也能拿到每個子應用暴露的方法,從而實現加載、卸載子應用的功能。
另外,子應用還得做兩件事:
- 配置 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 版本主要添加以下兩個功能:
- 隔離子應用 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 對象:
- 當子應用裏的代碼訪問
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 規則,然後替換裏面的 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
獲取:
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
事件。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