近看了幾個微前端框架的源碼(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 版本細分一下的話,它主要由以下兩個功能組成:
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() 判斷每個子應用是否激活,然后觸發加載、卸載子應用的操作。
何時加載、卸載子應用
首先我們將子應用的狀態分為三種:
現在我們來看看什么時候會加載一個子應用,當頁面 URL 改變后,如果子應用滿足以下兩個條件,則需要加載該子應用:
如果頁面的 URL 改變后,子應用滿足以下兩個條件,則需要卸載該子應用:
API 介紹
V1 版本主要向外暴露了兩個 API:
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 版本的源碼就可以了。
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 版本主要添加以下兩個功能:
隔離子應用 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 時候,讓它們訪問的是子應用的代理 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}`)
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 版本主要添加了一個全局數據通信的功能,設計思路如下:
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 運行起來跑一跑,這樣能幫助你更好的理解代碼。
如果你覺得我的文章寫得不錯,也可以看看我的其他一些技術文章或項目:
[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
在 javascript 語言中, 有一個奇奇怪怪的 "關鍵字" 叫做 this
● 為什么說它是 奇奇怪怪 呢, 是因為你寫出 100 個 this, 可能有 100 個解釋, 完全不挨邊
● 但是, 在你的學習過程中, 搞清楚了 this 這個玩意, 那么會對你的開發生涯有很大幫助的
● 接下來咱們就開始一點一點的認識一下 this
● 看到 this, 先給他翻譯過來 "這個"
● 到底啥意思呢 ?
○ 飯桌上, 你媽和你說, 你多吃點的這個
○ 商店里, 你媳婦和你說, 這個包 這個包 這個包 我都要
○ 宴會上, 你爸和人介紹說, 這個傻小子是我兒子
● 你看, 每一句話上都有 "這個", 但是每個 "這個" 都是一個意思嗎 ? 并不
● 就像我們 js 內的 this 一樣, 每一個 this 的意思都不一樣
● 但是我們會發現
○ 在說話的過程中, "這個" 是和我們說話的手勢有關系
● 在 js 內一個道理
○ this 的意思是和代碼的 "手勢" 有關系
● 例子 :
○ 當你媳婦手指著一個 LV 包的時候, 說的 "這個" 指代的就是 LV包`
○ 當你媽指著魚香肉絲的時候說 "這個" 指代的就是 魚香肉絲
○ 所以在 javascript 內的 this 是要看 "說這句話的代碼手指向哪里了"
● 看看下面一段代碼
var box=document.querySelector('#box')
box.onclick=function () {
console.log(this)
}
● 當你點擊 box 這個元素的時候, 會觸發后面的函數
● 然后函數一執行, 就會在控制臺打印一下 this
● 這里的 this 就是 box 這個元素
● 這就是一個非常簡單的 this 指向的例子了
● 接下來我們就開始詳細學習一下 this
● this , 是一個指針形變量, 它動態的指向當前函數的運行環境
● "什么鬼東西, 我聽不懂啊"
● 給一個私人的解釋 : "根據 this 所在的函數是如何被調用的來決定 this 是什么"
● 舉個栗子來看一下
function fn() {
console.log(this)
}
fn()
// this 就是 window
● 因為 this 是在 fn 函數內, 所以 fn 函數的調用方式就決定了這個 this 是什么
function a() {
function b() {
console.log(this)
}
b()
}
a()
// this 就是 window
● 因為 this 是在 b 函數內, 所以 b 函數的調用方式決定了 this 是什么, 和 a 函數沒關系
● 就是這個意思
● 最后, 根據這些年的經驗總結給出一個私人的概念, 要牢記
○ 函數的 this
○ 和函數定義在哪沒關系
○ 和函數怎么定義沒關系
○ 只看這個函數的調用方式
○ 箭頭函數除外
● 對象調用, 就是利用一個對象作為宿主來調用函數
● 最簡單的方式就是把函數寫在一個對象內, 利用對象來調用
// 對象內寫一個函數
const obj={
fn: function () { console.log(this) }
}
// 調用這個函數
obj.fn()
● 這時候, 我們調用了和這個對象內的 fn 函數
● 調用方式就是利用對象調用的函數, 所以在這個函數內的 this 就是 obj 這個對象
● 換句話說, 只要在這個函數內, 只要出現 this 就是這個對象
● 顧名思義, 全局調用就是直接調用一個全局函數
function fn() {
console.log(this)
}
fn()
● 此時這個函數內的 this 就是 window
● 可能有的小伙伴覺得瘋了
● 但是我們仔細思考一下, 你會發現
● 其實 fn 因為是在全局上的, 那么其實調用的完整寫法可以寫成 window.fn()
● 此時就回到了之前對象調用那條路上, 這樣就通順了
● 這個時候, 有的小伙伴可能會想到一個問題, 如果這個函數不放在全局呢 ?
const obj={
fn: function () {
function fun() {
console.log(this)
}
fun()
}
}
obj.fn()
● 此時的 this 應該是什么呢 ?
● 按照之前的思路思考
○ obj.fn() 確實調用了函數, 但是 this 不是在 obj.fn 函數內, 是在 fun 函數內
○ fun() 確實也調用了函數, 但是我沒有辦法寫成 window.fun()
○ 那么 this 到底是不是 window 呢, 還是應該是 obj 內
● 答案確實是 window, 這又是為什么呢 ?
● 說道這里, 我們會發現
● this 真的是好奇怪哦 o(* ̄︶ ̄*)o 搞不定了
● 要是按照這個方式, 我來回來去的得記多少種, 誰會記得下來呢
● 首先, this 在各種不同的情況下會不一樣
● 那么從現在開始我把我總結的內容毫無保留的傳授給你
● 在 js 的非嚴格模式下適用
● 在非箭頭函數中適用
● 不管函數定義在哪, 不管函數怎么定義, 只看函數的調用方式
○ 只要我想知道 this 是誰
○ 就看這個 this 是寫在哪個函數里面
○ 這個函數是怎么被調用的
function fn() {
console.log(this)
}
// this 在函數 fn 內, 就看 fn 函數是怎么被調用的就能知道 this 是誰
const obj={
fn: function () {
console.log(this)
}
}
// this 在 obj.fn 函數內, 就看這個函數怎么被調用的就能知道 this 是誰
const obj={
fn: function () {
function fun() {
console.log(this)
}
}
}
// 這個 this 是在 fun 函數內
// 如果你想知道這個 this 是誰
// 和 obj.fn 函數沒有關系, 只要知道 fun 函數是怎么被調用的就可以了
● 一定要注意 : 你想知道的 this 在哪個函數內, 就去觀察哪個函數的調用方式就好了
● 調用方式 : 函數名()
● this 是 window
● 只要你書寫 "函數名()" 調用了一個函數, 那么這個函數內的 this 就是 window
function fn() {
console.log(this)
}
fn()
// 這里就是 fn() 調用了一個函數, 那么 fn 內的 this 就是 window
const obj={
fn: function () {
function fun() {
console.log(this)
}
fun()
}
}
obj.fn()
// 這里的 this 因為是在 fun 函數內
// fun() 就調用了這個 fun 函數
// 所以不用管 fun 函數寫在了哪里
// 這個 fun 函數內的 this 就是 window
● 調用方式:
○ 對象.函數名()
○ 對象['函數名']()
● this 就是這個對象, 對象叫啥, 函數內的 this 就叫啥
const obj={
fn: function () {
console.log(this)
}
}
obj.fn()
// 因為 obj.fn() 調用了這個函數, 所以 obj.fn 函數內的 this 就是 obj
const xhl={
fn: function () {
console.log(this)
}
}
xhl.fn()
// 因為 obj.fn() 調用了這個函數, 所以 xhl.fn 函數內的 this 就是 xhl
function fn() {
const xhl={
fn: function () {
console.log(this)
}
}
xhl.fn()
}
fn()
// 因為我們要觀察的 this 是在 xhl.fn 這個函數內
// 所以只需要關注這個函數是如何被調用的即可
// 因為是 xhl.fn 調用了和這個函數, 所以函數內的 this 就是 xhl
● 調用方式
○ setTimeout(function () {}, 1000)
○ setInterval(function () {}, 1000)
● this 就是 window
● 一個函數不管是怎么定義的, 只要被當做定時器處理函數使用, this 就是 widnow
setTimeout(function () {
console.log(this)
}, 1000)
// 這里的 this 就是 window
setInterval(function () {
console.log(this)
}, 1000)
// 這里的 this 就是 window
const xhl={
fn: function () {
console.log(this)
}
}
setTimeout(xhl.fn, 1000)
// 這里的 xhl.fn 函數不是直接書寫 xhl.fn() 調用的
// 而是給到了 setTimeout 定時器處理函數
// 所以這里的 this 就是 window
● 調用方式
○ 事件源.on事件類型=事件處理函數
○ 事件源.addEventListener(事件類型, 事件處理函數)
● this 就是 事件源
● 只要是作為事件處理函數使用, 那么該函數內的 this 就是 事件源
奧,對了,事件就是:在事件中,當前操作的那個元素就是事件源
box.onclick=function () {
console.log(this)
}
// 這里的 this 就是 box
box.addEventListener('click', function () {
console.log(this)
})
// 這里的 this 就是 box
const xhl={
fn: function () {
console.log(this)
}
}
box.addEventListener('click', xhl.fn)
// 這里的 xhl.fn 函數不是直接書寫 xhl.fn() 調用的
// 而是給到了 事件, 被當做了事件處理函數使用
// 所以這里的 this 就是 事件源box
const xhl={
fn: function () {
console.log(this)
}
}
box.onclick=xhl.fn
// 這里的 xhl.fn 函數不是直接書寫 xhl.fn() 調用的
// 而是給到了 事件, 被當做了事件處理函數使用
// 所以這里的 this 就是 事件源box
● 調用方式
○ new 函數名()
● this 就是該構造函數的當前實例
● 只要和 new 關鍵字調用了, this 就是實例對象
function fn() {
console.log(this)
}
const f=new fn()
// 這里的因為 fn 函數和 new 關鍵字在一起了
// 所以這里的 this 就是 fn 函數的實例對象
// 也就是 f
const xhl={
fn: function () {
console.log(this)
}
}
const x=new xhl.fn()
// 這里的 xhl.fn 也是因為和 new 關鍵字在一起了
// 所以這里的 this 就是 xhl.fn 函數的實例對象
// 也就是 x
記清楚原則 :
不管函數在哪定義
不管函數怎么定義
只看函數的調用方式
● 在嚴格模式下適用
● 其實只有一個
○ 全局函數沒有 this, 是 undefined
○ 其他的照搬經驗一就可以了
// 非嚴格模式
function fn() {
console.log(this)
}
fn()
// 因為是在非嚴格模式下, 這里的 this 就是 window
// 嚴格模式
'use strict'
function fn() {
console.log(this)
}
fn()
// 因為是在嚴格模式下, 這里的 this 就是 undefined
記清楚原則 :
嚴格模式下
全局函數沒有 this
是個 undefiend
● 專門來說一下箭頭函數
● 其實也只有一條
○ 推翻之前的所有內容
○ 箭頭函數內沒有自己的 this
○ 箭頭函數內的 this 就是外部作用域的 this
● 換句話說, 當你需要判斷箭頭函數內的 this 的時候
○ 和函數怎么調用沒有關系了
○ 要看函數定義在什么位置
// 非箭頭函數
const xhl={
fn: function () {
console.log(this)
}
}
xhl.fn()
// 因為是 非箭頭函數, 所以這里的 this 就是 xhl
//==========================================================// 箭頭函數
const xhl={
fn: ()=> {
console.log(this)
}
}
xhl.fn()
// 因為是 箭頭函數, 之前的經驗不適用了
// 這個函數外部其實就是全局了, 所以這里的 this 就是 window
// 非箭頭函數
box.onclick=function () {
console.log(this)
}
// 因為是 非箭頭函數, 這里的 this 就是 box
//==========================================================// 箭頭函數
box.onclick=()=> {
console.log(this)
}
// 因為是 箭頭函數
// 這個函數外部就是全局了, 所以這里的 this 就是 window
// 非箭頭函數
const obj={
fn: function () {
function fun() {
console.log(this)
}
fun()
}
}
obj.fn()
// 因為是 非箭頭函數, 所以 fun 函數內的 this 就是 window
//==========================================================// 箭頭函數
const obj={
fn: function () {
const fun=()=> {
console.log(this)
}
fun()
}
}
obj.fn()
// 因為是 箭頭函數
// 那么這個 fun 外面其實就是 obj.fn 函數
// 所以只要知道了 obj.fn 函數內的 this 是誰, 那么 fun 函數內的 this 就出來了
// 又因為 obj.fn 函數內的 this 是 obj
// 所以 fun 函數內的 this 就是 obj
記清楚原則 :
只要是箭頭函數
不管函數怎么調用
就看這個函數定義在了哪里
● 好了
● 按照以上三個經驗, 記清楚原則
● 那么在看到 this 就不慌了
家好,很高興又見面了,我是姜茶的編程筆記,我們一起學習前端相關領域技術,共同進步,也歡迎大家關注、點贊、收藏、轉發,您的支持是我不斷創作的動力
我們來聊聊箭頭函數(就是下面這個東西)!箭頭函數的語法比傳統的函數表達式更簡潔,而且它們沒有自己的 this、arguments、super 或 new.target。它們非常適合用在需要匿名函數的地方,同時不能作為構造函數使用。
// 當只有一個參數時,圓括號不是必須的
(singleParam)=> { statements }
singleParam=> { statements }
// 沒有參數的函數必須加圓括號
()=> { statements }
箭頭函數有兩個主要優點:
1?? 語法更簡潔
2?? 不會綁定 this
沒有自己的 this
箭頭函數不會創建自己的 this,它只會繼承外層作用域的 this。
function Person() {
this.age=0;
setInterval(()=> {
// this 正確地指向 p 實例
console.log(this===p); // true
this.age++;
}, 1000);
}
var p=new Person();
由于 this 是詞法綁定的,嚴格模式中與 this 相關的規則將被忽略。
var f=()=> { 'use strict'; return this; };
f()===window; // 或 global
因為箭頭函數沒有自己的 this,使用這些方法調用時只能傳遞參數,它們的第一個參數 this 會被忽略。
let adder={
base: 1,
add: function (a) {
console.log(this===adder); // true
let f=(v)=> v + this.base;
return f(a);
},
addThruCall: function (a) {
let f=(v)=> {
console.log(this===adder); // true
console.log(`v 的值是 ${v},this.base 的值是 ${this.base}`); // 'v 的值是 1,this.base 的值是 1'
return v + this.base;
};
let b={ base: 3 };
// call() 方法不能綁定 this 為 b 對象,第一個參數 b 被忽略了
return f.call(b, a);
}
};
console.log(adder.add(1)); // 輸出 2
console.log(adder.addThruCall(1)); // 輸出 2
箭頭函數沒有 this 綁定。
"use strict";
var obj={
i: 10,
b: ()=> console.log(this.i, this), // undefined, Window{...}
c: function () {
console.log(this.i, this); // 10, Object {...}
}
};
obj.b();
obj.c();
箭頭函數不能用作構造函數,用 new 調用會拋出錯誤。
var Foo=()=> {};
var foo=new Foo(); // TypeError: Foo is not a constructor
ES6 的箭頭函數表達式是匿名函數的一種簡寫方式:
// 匿名函數
let show=function () {
console.log("匿名函數")
};
show(); // "匿名函數"
let show1=()=> console.log("匿名函數");
show1(); // "匿名函數"
不過,箭頭函數和傳統匿名函數在實際操作中還是有一些區別的。
如果你有任何問題或建議,歡迎在評論區留言交流!祝你編程愉快!
*請認真填寫需求信息,我們會在24小時內與您取得聯系。