在 Html 文件中輸入 html:5 按下回車鍵,可快速生成 HTML5 頁面模板:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body></body>
</html>
html:5
在 html 文件中輸入 div#id>ul.list>li.item*5 按下回車鍵,可快速生成父子關系的結構:
<div id="id">
<ul class="list">
<li class="item"></li>
<li class="item"></li>
<li class="item"></li>
<li class="item"></li>
<li class="item"></li>
</ul>
</div>
父子關系構建
重復元素: 使用 * 加上數字來創建多個相同的標簽。例如,p*3 后按 Tab 會產生三個 <p> 段落標簽。
<p></p>
<p></p>
<p></p>
<div class="container"></div>
<div id="main"></div>
<a href="https://example.com"></a>
<nav>
<ul>
<li class="item1"><a href="#">item 1</a></li>
<li class="item2"><a href="#">item 2</a></li>
<li class="item3"><a href="#">item 3</a></li>
<li class="item4"><a href="#">item 4</a></li>
</ul>
</nav>
SS 框架包含多個可供開發人員和網頁設計人員使用的現成 CSS 庫。樣式表是為網頁設計中的日常任務而準備的,例如導航欄、字體、顏色和布局設置。它們簡化了前端開發人員的工作,為他們提供了創建UI界面的工具,而不是從頭開始每個項目。樣式表受到 JavaScript 等其他腳本技術的支持和擴展。
使用 CSS 框架時,用戶必須使用正確的類、結構和 ID 編寫 HTML 代碼才能創建網頁,因為 CSS 樣式表是完整的。前端開發人員使用 CSS 框架快速實現網站和應用程序上的關鍵用戶界面元素,例如按鈕、樣式表單和自適應網格。
人們為什么使用框架,最直接的答案是效率。框架開箱即用,包含大量元素和樣式,否則您在開發網站時需要從頭開始構建。許多開發人員和網頁設計師也會在構建自定義設計系統之前使用框架快速構建新網站或應用程序的原型。
以下是 2024 年最流行的 CSS 框架列表:
根據2023 年 CSS 現狀研究,“Tailwind CSS 再次成為開發者樂于繼續使用的主要 UI 框架”。
Tailwind 是一個“實用程序優先的CSS 框架”,它提供的類使用戶能夠直接在用戶的標記中創建自定義用戶界面。實現內聯樣式有助于快速創建引人注目的 UI,而無需編寫任何 CSS。
Tailwind CSS 是最流行的實用 CSS 庫之一,并為網頁設計提供了其他顯著優勢。在實踐中,雖然 Tailwind 使讀取類屬性變得更加困難,但您可以通過它如何簡化樣式的實際維護來恢復所有這些。Tailwind 還消除了使用中間類名來掛鉤樣式的需要,這很有幫助,特別是當錯誤或代碼漂移使類名產生誤導時。
Tailwind 類和內聯樣式之間有一個顯著區別:特異性!無論源代碼組織如何,內聯樣式都會覆蓋基于 CSS 類的樣式,當元素需要上下文相關樣式時,會導致極其令人沮喪的情況。對于 Tailwind,所有都是類,就像大多數手寫 CSS 一樣,這使得混合自定義 CSS 和框架樣式是可預測的。
Bootstrap由 Twitter 的 Mark Otto 和 Jacob Thornton 創建,是一個開源框架,它使用 CSS 和基于 JavaScript 的界面組件模板來鼓勵內部工具之間的一致性。它倡導了現在無處不在的移動優先概念,并為其無縫實施提供了必要的工具。Bootstrap 通過合并網格系統,將屏幕離散地劃分為最終用戶看不到的列,從而促進了流行的移動優先方法的直接采用。
得益于 Bootstrap,開發人員不再被迫啟動單獨的項目來僅僅為了調整網站以適應較小的屏幕尺寸。當合并必要的 Bootstrap 類時,設計會自動調整。
由于它是一個廣泛使用和測試的庫,擁有大量的貢獻者和審閱者,因此如果您花時間閱讀和理解實際代碼(甚至是導致問題的問題),您可以從中學到很多實用的架構/設計選擇。給他們)。此外,它還提供了一些非常全面且相對簡單的文檔。它的可擴展性也很強,同時也是細粒度的。
Materialise 是由 Google 精心設計和概念化的 CSS 框架,建立在 Material Design 原則之上,Material Design 是一種無縫融合創造力和技術的創新設計語言。谷歌的目標是創建一個設計框架,為任何平臺上的所有產品提供統一的用戶體驗。
該框架提供集成自定義組件、精致動畫和過渡的默認樣式,確保為用戶提供無縫體驗。Materialise 作為一個以用戶體驗為中心的框架脫穎而出,它包含旨在為用戶提供增強反饋的組件和動畫。提供了詳細的文檔以及具體的代碼示例,以幫助新用戶有效地導航該框架。
Foundation被描述為“世界上最先進的響應式前端框架”,提供了一個全面的工具包,包括網格系統、HTML、SASS 和 CSS 用戶界面元素、模板以及包含導航、按鈕、排版、表格等等。此外,它還通過 JavaScript 擴展提供可選功能。該框架非常強調移動設備,并且事實證明對于開發需要強大設計基礎的大量 Web 應用程序非常有益。
其龐大、靈活的工具包對于廣大前端開發人員來說是寶貴的資源,可以幫助他們找到有效的解決方案。Foundation 提供了針對電子郵件和網頁量身定制的獨特框架組件,可隨時部署在各自的域中。此外,它還具有命令行界面(CLI),這對于從事涉及 Webpack 等模塊捆綁器的項目的開發人員來說特別有利。
該框架旨在讓前端開發人員完全控制其用戶界面。它不要求他們使用特定的語言或風格,這使其成為大多數人的首選工具。
Bulma基于 Flexbox,是一個開源、響應式 CSS 框架,以其卓越的內置功能而聞名,減少了大量手動 CSS 編碼的需要,并確保快速響應時間。它使用圖塊來構建 Metro 風格的網格,從而產生時尚且組織良好的頁面布局。用戶可以通過僅導入他們想要使用的特定元素來進一步簡化流程。
由于其模塊化設計方法和高水平的定制化,Bulma 成為開發人員和設計師的最愛。其響應式模板顯著減少了設計工作量,提供了下拉菜單、表格、面板和導航欄等各種組件。Bulma 還提供交互式教程和入門模板。此外,該框架擁有龐大的 Stack Overflow 社區,對于獲得各種問題的解決方案具有無價的價值。
Skeleton在其主頁上被描述為“極其簡單、響應式的樣板”,這個輕量級工具只有 400 行源代碼,旨在生成可在移動設備和更大屏幕上無縫運行的 CDD 元素。Skeleton 采用 12 列網格系統,最大寬度為 960px,可容納小型、中型和大型顯示器,只需一行 CSS 代碼即可輕松修改。它包含了響應式設計的所有基本元素,具有用戶友好的語法,有助于快速實施,使響應式設計的創建變得非常簡單。
該工具非常適合設計師入門。如果您正在著手一個較小的項目,或者只是覺得您不需要大型框架的所有實用功能,那么您應該使用 Skeleton。
Open Props是一個完全基于 CSS 變量構建的低級框架,這意味著它幾乎完全可定制,并且可以進行調整以適應任何設計系統。它使用即時編譯來保證只生成項目所需的 CSS 變量,這可能有助于提高應用程序的性能。
在 CSS 中使用原始樣式表可能會很快失去控制,尤其是在與團隊合作時。使用 Tailwind 或 Open Props 等框架使您能夠為每個站點創建自定義主題,從而促進高性能內聯樣式的實現。此外,這些框架具有壓縮語法,可以更快地設計樣式。如果這些框架提供了您想要的所有樣式,為什么還要重新發明輪子呢?使用框架使工作更智能、更輕松,而不是更困難、更繁重。
近看了幾個微前端框架的源碼(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、子應用的觸發規則來判斷是否要加載、卸載子應用
一個 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.加載所有符合條件的子應用
為了支持不同框架的子應用,所以規定了子應用必須向外暴露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。
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 版本的源碼就可以了。
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 下。
下面再詳細描述一下這四步是怎么做的。
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。
這需要使用一個 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 標簽添加到 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
}
}
為了保證子應用正常執行,需要將這部分的內容保存起來。然后每次在子應用 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 版本主要添加以下兩個功能:
1.隔離子應用 window 作用域2.隔離子應用元素作用域
在 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 代理對象會存有上一次加載的數據。剛才創建 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 運行起來跑一跑,這樣能幫助你更好的理解代碼。
如果你覺得我的文章寫得不錯,也可以看看我的其他一些技術文章或項目:
?帶你入門前端工程[16]?可視化拖拽組件庫一些技術要點原理分析[17]?前端性能優化 24 條建議(2020)[18]?前端監控 SDK 的一些技術要點原理分析[19]?手把手教你寫一個腳手架 [20]?計算機系統要素-從零開始構建現代計算機[21]
[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
*請認真填寫需求信息,我們會在24小時內與您取得聯系。