開始寫這篇文章的起因是公司的大前端部門開始實現公司自己的微前端框架
在和大前端部門的合作中,對微前端相關的知識和技術點、難點的總結
微前端是什么
微前端的思想概念來源于微服務架構。是一種由獨立交付的多個前端應用組成整體的架構風格。
具體的,將前端應用分解成一些更小、更簡單的能夠獨立開發、測試、部署的小塊,而在用戶看來仍然是內聚的單個產品
為什么要有微前端
我們正常的一個單體應用,主要負責一個完整的業務,所以也被稱為獨石應用(一個建筑完全由一個石頭雕塑成)
但是隨著版本迭代會出現很多痛點:
- 增量更新慢
- 項目文件越多,每次打包編譯需要的時間也越長
- 每次上線,未修改的文件都需要重新編譯(chunkhash 和 dll 并不能從根本上解決問題)
- 高耦合
- 修改代碼帶來的關聯影響大
- 項目龐大導致增加新人熟悉項目的難度和時間
- 無法獨立部署:無關的功能模塊沒有拆分,無法各自獨立部署
- 無法團隊自治:如果將模塊拆分給各個小團隊,無法實現團隊自我維護
從公司和用戶層面來看,不利于效率提升
一個公司的 OA、CRM、ERP、PMS 等后臺,沒有統一的入口,不方便使用,降低工作效率
從用戶層面來看,不利于用戶體驗和流量管理
一個被更多賦能的產品或者應用,更容易獲得用戶的青睞,獲得流量
因此,在借鑒微服務架構的基礎上,誕生了微前端架構
微前端作為一種大型應用的解決方案,目的就是解決上面提到的痛點,做到以下幾點:
- 技術選型獨立:每個開發團隊自行選擇技術棧(
Vue
、React
、Angular
、Jquery
),不受其他團隊影響 - 業務獨立:每個交付產物既可以獨立使用,也可以融合成一個大型應用使用
- 樣式隔離:父子應用之間、子應用之間不會有樣式沖突、覆蓋
技術方案
當前主流的方案
- 大倉庫拆分成獨立的模塊文件夾,通過
webpack
統一去構建。本質上沒有變化,只是在項目結構和編譯分包上的優化。 - 大倉庫拆成小倉庫。互相之間通過
location.href
切換。比較適合后臺類型的應用 - 大倉庫拆成小倉庫,發包到
npm
上,然后集成。較上者更進了一步,主要針對header
、footer
、siderBar
等公共部分組件。 - 大倉庫拆成小倉庫,不通過頁面跳轉,通過注入的方式集成到主應用
- iframe(天然的微前端方案,但是弊端很多)
- single-spa
- web components(最適合但是兼容性最差)
從趨勢上看,最終都是向注入集成的技術方案靠攏
iframe 的優缺點
iframe
的優點
- 瀏覽器原生的硬隔離方案,改造成本低
- 天然支持
CSS
隔離、JS
隔離
iframe
的問題
- URL 不同步
-
iframe
內部頁面跳轉,url
不會更新 - 瀏覽器刷新導致
iframe url
狀態丟失、后退前進按鈕無法使用。
-
- UI 不同步
-
DOM
結構不共享。iframe
里的彈窗遮罩無法在整個父應用上覆蓋
-
- 全局上下文完全隔離,內存變量不共享。
iframe
內外系統的通信、數據同步等需求,主應用的cookie
要透傳到根域名都不同的子應用中實現免登效果。 - 慢。每次子應用進入都是一次瀏覽器上下文重建、資源重新加載的過程。
- 雙滾動條
綜合考量之下,iframe
不適合作為微前端的方案,最多只能作為過渡階段的方案來使用
技術點
Entry 方式
Entry 用于父應用引入子應用相應的資源文件(包括 JS
、CSS
),主要分為兩種方式:
- JS Entry
- HTML Entry
JS Entry 方式
JS Entry 的原理是:
- 把
CSS
打包進JS
,生成一個manifest.json
配置文件 -
manifest.json
中標識了子應用資源文件的相對路徑地址 - 主應用通過插入
script
標簽src
屬性的方式加載子應用資源文件(子應用域名 +manifest.json
中的相對路徑地址)
基于這樣的原理,因此 JS Entry 有缺陷:
- 打包時,需要額外對工程化代碼做修改,生成一份資源配置文件
manifest.json
給主應用加載 - 打包時,需要額外對樣式打包做修改,需要把
CSS
打包進JS
中,也增加了編譯后的包體積 - 打包時,不能在
html
中插入行內script
代碼。因為manifest.json
中只能存放地址路徑。因此要禁止webpack
把配置代碼直接打入html
中
// vue-cli 3.x vue.config.js
config.optimization.runtimeChunk('single') // 不能使用
- 父子應用域名不一致,父應用加載子應用
manifest.json
會發生跨域,需要額外處理
HTML Entry 方式
HTML Entry 是利用 import-html-entry
直接獲取子應用 html
文件,解析 html
文件中的資源加載入主應用
第一步,解析遠程 html 文件,得到一個對象
// 使用
import importHTML from 'import-html-entry'
importHTML(url, opts = {})
// 獲取到的對象
{
template: 經過處理的腳本,link、script 標簽都被注釋掉了,
scripts: [腳本的http地址 或者 { async: true, src: xx } 或者 代碼塊],
styles: [樣式的http地址],
entry: 入口腳本的地址,是標有 entry 的 script 的 src,或者是最后一個 script 標簽的 src
}
第二步,處理這個對象,向外暴露一個 Promise 對象,這個對象回傳的值就是下面這個對象
// import-html-entry 源碼中對獲取到的對象的處理
{
// template 是 link 替換為 style 后的 template
template: embedHTML,
// 靜態資源地址
assetPublicPath,
// 獲取外部腳本,最終得到所有腳本的代碼內容
getExternalScripts: () => getExternalScripts(scripts, fetch),
// 獲取外部樣式文件的內容
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
// 腳本執行器,讓 JS 代碼(scripts)在指定 上下文 中運行
execScripts: (proxy, strictGlobal) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
}
}
getExternalStyleSheets 做了哪些事?
getExternalStyleSheets
會做兩件事
- 將子應用中的
link
標簽轉為style
標簽 - 把對應的
href
遠程文件內容通過fetch get
的方式放進style
標簽中- 如果是
inline style
,通過substring
的方式獲取行內style
代碼字符串 - 如果是
遠程 style
,通過fetch get
方式獲取href
地址對應的代碼字符串
- 如果是
// import-html-entry getExternalStyleSheets 源碼
export function getExternalStyleSheets(styles, fetch = defaultFetch) {
return Promise.all(styles.map(styleLink => {
if (isInlineCode(styleLink)) {
// if it is inline style
return getInlineCode(styleLink);
} else {
// external styles
return styleCache[styleLink] || (styleCache[styleLink] = fetch(styleLink).then(response => response.text()));
}
}))
}
getExternalScripts 做了哪些事?
getExternalScripts
同樣做了兩件事
- 按順序獲取子應用
html
中的script
,并拼成一個scripts
數組 - 使用
fetch get
的方式循環加載scripts
數組- 如果是
inline script
,通過substring
的方式獲取行內JS
代碼字符串 - 如果是
遠程 script
,通過fetch get
方式獲取src
地址對應的代碼字符串
- 如果是
最后返回一個 scriptsText
數組,數組里每個元素都是子應用 scripts
數組中的可執行代碼的字符串
這個數組就是 execScripts
真正使用的參數
這里會遇到一些問題:
跨域
父應用fetch
子應用第三方庫的cdn
文件,大部分cdn
站點支持CORS
跨域
但是少部分cdn
站點不支持,因此導致跨域fetch
文件失敗重復加載
一些通用的cdn
文件,父子應用都進行了加載,當父應用加載子應用時,會因為重復加載執行這部分cdn
的JS
代碼,導致錯誤
解決方案:
直接硬編碼把需要加載的 cdn script
寫進父應用的 html
中
父應用直接加載父子應用需要的全部 cdn
子應用通過是否通過微前端方式加載的標識判斷是否獨立運行,自行獨立加載這部分 cdn
文件
這個方案的優點是:父應用不需要做重復加載的邏輯判斷,交給子應用自己判斷
相對應的缺點是:A子應用不需要用到的B子應用的 cdn
也在第一時間加載,徒耗性能
execScripts 做了哪些事?
execScripts
是真正執行子應用 JS
文件的函數
- 先調用
getExternalScripts
獲取可執行的JS
代碼數組 - 最終使用
eval
在當前上下文中執行JS
代碼。 -
proxy
參數支持傳入一個上下文對象,從而保證了 JS沙盒 的可行性
HTML Entry 優于 JS Entry 的地方
- 不用生成額外的
manifest.json
- 不用把
css
打包進js
中 - 全局
css
獨立打包,不會冗余 - 不使用生成
script
的方式插入子應用JS
代碼,不會生成額外的DOM
節點
JS 沙盒
JS 沙盒的目的是隔離兩個子應用,避免互相影響
JS 沙盒的實現有兩種方式
- 代理沙盒:利用
proxy API
,可以實現多應用沙箱,把不同的應用對應不同的代理 - 快照沙盒:將不同沙盒之間的區別保存起來,只能兩個,多了會混亂
代理沙盒
- 獲取屬性:
proxyObj[key] || window[key]
- 設置屬性:
proxyObj[key] = value
利用函數作用域的形參 window(實參 proxyObj),來代替全局對象 window
// proxy 的 demo
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
})
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
window.a = 'hello';
console.log(window.a)
})(sandbox1.proxy);
((window) => {
window.a = 'world';
console.log(window.a)
})(sandbox2.proxy);
快照沙盒
沙箱失活時,把記錄在 window
上的修改記錄賦值到 modifyPropsMap
上,等待下次激活
沙箱激活時,先生成一個當前 window
的快照 windowSnapshot
,把記錄在沙箱上的 window
修改對象 modifyPropsMap
賦值到 window
沙箱實際使用的還是全局 window
對象
// snapshot 的 demo
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {}; // window 狀態快照
this.modifyPropsMap = {}; // 沙箱運行時被修改的 window 屬性
this.active();
}
// 激活
active() {
// 設置快照
this.windowSnapshot = {};
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop];
}
}
// 還原這個沙箱上一次記錄的環境
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p]
})
}
// 失活
inactive() {
// 記錄本次的修改
// 還原 window 到激活之前的狀態
this.modifyPropsMap = {};
for (const prop in window) {
if (window.hasOwnProperty(prop) && this.windowSnapshot[prop] !== window[prop]) {
this.modifyPropsMap[prop] = window[prop]; // 保存變化
window[prop] = this.windowSnapshot[prop] // 變回原來
}
}
}
}
let sandbox = new SnapshotSandbox();
((window) => {
window.a = 1
window.b = 2
console.log(window.a) //1
sandbox.inactive() //失活
console.log(window.a) //undefined
sandbox.active() //激活
console.log(window.a) //1
})(sandbox.proxy);
//sandbox.proxy就是window
目前主流方法是優先代理沙箱,如果不支持 proxy API
,則使用快照沙箱
CSS 沙盒
子應用樣式
子應用通過 BEM
+ css module
的方式隔離
保證A子應用的樣式不會在B子應用的 DOM
上生效
子應用切換
子應用失活,樣式 style
不需要刪除,因為已經做了隔離
已加載的子應用重新激活,也不需要重新插入 style
標簽,避免重復加載
父子應用通信
父子應用通信主要分為:數據和事件
數據
- url
- localStorage
- sessionStorage
- cookie
- eventBus
事件
- 子應用 main.js export 到父應用的 window 對象
- 父應用 自定義事件
- 父應用 window.eventBus
- H5 api sharedWorker
- H5 api BroadcastChannel
目前用的較多的方案是 eventBus 和 自定義事件
應用監控
每個項目都有對自己的應用監控
- 用戶行為監控
- 錯誤監控
- 性能監控
如果使用代理沙箱
因為 proxy API
只能代理對象的 get
set
,無法代理事件的監聽和移除,子應用的監控在代理對象上無法執行
所以只能直接在父應用上監聽父子應用的事件
如果使用快照沙箱
因為同時只有一個子應用被激活,只有一個子應用的JS在執行,同時又是直接操作 window 對象
可以考慮直接使用子應用自己的監控,因為都是對 window 的事件監聽,所以可以同時監聽到父子兩個應用的事件
下面列舉 single-spa
和 qiankun
的監控方案
// single-spa 的異常捕獲
export { addErrorHandler, removeErrorHandler } from 'single-spa';
// qiankun 的異常捕獲
// 監聽了 error 和 unhandlerejection 事件
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler);
}
// 移除 error 和 unhandlerejection 事件監聽
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
window.removeEventListener('error', errorHandler);
window.removeEventListener('unhandledrejection', errorHandler);
}
現有框架對比
參考上圖
single-spa
比較基礎的微前端框架,也是我公司大前端部門搭建自有框架的選擇方案
需要自己定制的部分較多,包括
- Entry 方式
- JS 沙盒
- CSS 沙盒
- 父子應用通信方式
- 應用監控事件處理
官網:https://zh-hans.single-spa.js.org/
github:https://github.com/single-spa/single-spa
icestark
icestark 是阿里的微前端框架,現在的不限制主應用所使用的框架了
針對 React 主應用 和 不限框架的主應用 有兩種不同的接入方式
PS:通過下面的引用描述來看,目前應該不支持多個子應用共存(待確認)
一般情況下不存在多個微應用同時運行的場景
頁面運行時同時只會存在一個微應用,因此多個微應用不存在樣式相互污染的問題
在 Entry 方式上
- 通過
fetch
+創建 script 標簽
的方式注入。有一點 JS Entry 和 HTML Entry 中間過渡的意思 - 不需要子應用生成配置文件,但是會生成
script
的DOM
節點
在 JS 沙盒上
- 如果是不可控的子應用,官方建議使用 iframe 的方案嵌入
- 如果是可控的子應用,使用代理沙盒(還未研究過對應的源碼,但快照沙盒作為降級策略,應該也有被使用,待確認)
在 CSS 沙盒上
- 主要方案是
BEM
+CSS Modules
- 實驗性方案是
Shadow DOM
- 全局樣式庫,例如
normalize.css
、reset.css
統一由主應用引入
在應用通信上
- 使用了
eventBus
的方案來處理數據和事件
在應用監控上
- 統一由主應用來監控
官網:https://micro-frontends.ice.work/
github:https://github.com/ice-lab/icestark
qiankun
同樣是阿里的微前端框架,qiankun 是對 single-spa
的一層封裝
核心做了構建層面的一些約束以及沙箱能力,支持多子應用并存
但是接入的修改成本較高
總的來說算是目前比較優選的微前端框架
在 Entry 方式上
- 已經支持
HTML Entry
的方式,在框架內部也是依賴的import-html-entry
在 JS 沙盒上
- 使用三種沙盒
-
legacySandBox:支持
proxy API
且只有單子應用并存 -
proxySandBox:支持
proxy API
且多子應用并存 -
snapshotSandBox:不支持
proxy API
的快照沙盒
-
legacySandBox:支持
-
legacySandBox 其實是 proxySandBox 與 snapshotSandBox 的結合,既想要
proxy
的代理能力,又想在一定程度上有直接操作window
對象的能力
在 CSS 沙盒上
- 主要方案是
BEM
-
BEM
不需要子應用自己處理,在子應用接入qiankun
框架時可以通過配置統一增加prefix
- 全局樣式庫,例如
normalize.css
、reset.css
統一由主應用引入
在應用通信上
-
Actions
方案:適用于通信較少- 數據上:使用一個 store 來存儲數據,使用觀察者模式來監聽
- 事件上:利用觀察者派發事件的觸發事件通信
-
Shared
方案:適用于通信較多- 主應用基于
redux
維護一個狀態池,通過shared
實例暴露一些方法給子應用使用 - 子應用需要單獨維護一份
shared
實例,保證在使用和表現上的一致性- 獨立運行時使用自身的
shared
實例 - 在嵌入主應用時使用主應用的
shared
實例
- 獨立運行時使用自身的
- 數據和事件都可以通過
redux
來通信
- 主應用基于
在應用監控上
- 統一由主應用來監控
官網:https://qiankun.umijs.org/zh
github:https://github.com/umijs/qiankun
Garfish
從開發者大會上看到的方案,來自于字節跳動,有希望成為最優的方案
- 支持多子應用并存
- 支持
HTML Entry
、JS Entry
-
JS
沙盒直接使用快照沙盒 - 通過HTML整體快照,來實現
CSS
沙盒 - 通信
- 數據:同樣通過一個
store
來保存數據 - 事件:通過自定義事件
- 數據:同樣通過一個
- 監控
- 保留
window
addEventListener
removeEventListener
的副本 - 在沙盒
document
對象上監聽監控
- 保留
最大的特點是,能夠快照子應用的 DOM 節點,保持 DOM 樹
加上 JS 沙盒 、 CSS 沙盒,能夠保持整個子應用的完整狀態
官網:https://garfish.dev/
github:https://github.com/bytedance/garfish