效率前端微應(yīng)用推進(jìn)之微前端研發(fā)提效
一、背景
業(yè)務(wù)背景
得物效率前端所在的效率工程為提升企業(yè)協(xié)作效率而生,面臨大量的 PC 側(cè)的中后臺應(yīng)用場景。
在之前的微信公眾號《得物效率前端微應(yīng)用推進(jìn)過程與思考》中詳細(xì)介紹了效率前端推進(jìn)微應(yīng)用落地的思路和部分效果。
這篇文章將著重介紹得物效率前端微應(yīng)用推進(jìn)中,微前端的研發(fā)效率遇到的挑戰(zhàn)和解決方案。
名詞解釋
微應(yīng)用
「微應(yīng)用」是得物效率前端內(nèi)部稱謂,是一個基于“monorepo & 微前端 & 基座與業(yè)務(wù)分離”的、包括“文檔 & 工具”的一套體系化降低研發(fā)成本和提升用戶體驗(yàn)的技術(shù)產(chǎn)品。
微前端
「微前端」是得物效率前端微應(yīng)用推進(jìn)的重要一環(huán),尤其是父子應(yīng)用技術(shù)棧不同時,利用 iframe / qiankun / wujie / micro-app 等工具進(jìn)行微前端化改造,能顯著增強(qiáng)業(yè)務(wù)擴(kuò)展性。
圖片
基座應(yīng)用/父應(yīng)用
微前端中,基座應(yīng)用(父應(yīng)用)、子應(yīng)用是常見概念,本文描述中,“基座應(yīng)用”又名“父應(yīng)用”,為簡化文案,“基座應(yīng)用與子應(yīng)用”也被稱為“父子應(yīng)用”。
瀏覽器插件/擴(kuò)展
chrome://extensions/ 頁面中對第三方工具稱為“擴(kuò)展”,在本文語境下,又稱為“插件”。
二、研發(fā)費(fèi)力度痛點(diǎn)
就研發(fā)效率而言,微前端在團(tuán)隊(duì)多個業(yè)務(wù)落地后,面臨研發(fā)過程費(fèi)力度高的問題。
費(fèi)力點(diǎn)1
本地開發(fā)時,從啟動 1 個本地服務(wù),變?yōu)樾鑶?2 個本地服務(wù):
大大多數(shù)情況下,項(xiàng)目需要啟動 2 個本地服務(wù)(基座應(yīng)用和子應(yīng)用)才能進(jìn)行日常開發(fā),因?yàn)樽討?yīng)用通常依賴基座應(yīng)用透傳一些依賴數(shù)據(jù)。
而非微前端場景下,啟動 1 個應(yīng)用就可以了,這反而引入了降低了研發(fā)效率。雖然微前端方案更注重業(yè)務(wù)效率,但研發(fā)效率也是必須要考慮的。
如果電腦性能一般的話,卡頓問題就隨之而來了。
費(fèi)力點(diǎn)2
基座應(yīng)用本地代碼需要做適配性改造:
如父應(yīng)用需要區(qū)分本地環(huán)境和生產(chǎn)環(huán)境類似這樣的代碼,雖然代碼量不大,但還是需要關(guān)注的:
// 基座代碼
if (isLocal) {
return <microapppage src="localhost://8000/a/b/c" />
} else if (isProd) {
return <microapppage src="https://www.abc.com/a/b/c">
}
費(fèi)力點(diǎn)3
頻繁無規(guī)律刷新:
在 Qiankun 微前端框架且本地開發(fā)環(huán)境下,基座應(yīng)用與子應(yīng)用頁面均需要 WebSocket 與其自身本地服務(wù)進(jìn)行通信。
在同時啟動基座與子應(yīng)用的本地服務(wù)后,修改子應(yīng)用代碼以及偶發(fā)的,頁面會觸發(fā) Reload,而不是局部更新,開發(fā)體驗(yàn)很差。針對這個問題我們做了一些分析。
HMR 的熱更新邏輯
本地開發(fā)過程會啟動 Webpack-Dev-Server 服務(wù),其會監(jiān)聽業(yè)務(wù)文件變化,瀏覽器通過 WebSocket 與 Webpack-Dev-Server 進(jìn)行通信。
當(dāng)發(fā)現(xiàn)文件內(nèi)容改變時 Webpack-Dev-Server 會根據(jù)更新的文件內(nèi)容生成 Hash 信息傳遞給瀏覽器,如圖:
圖片
當(dāng)瀏覽器收到信息時,會根據(jù)收到的信息和配置進(jìn)行判斷是刷新操作還是熱更新操作。熱更新時,Webpack-Dev-Serer 通過 Jsonp 拉取最新的 JS 模塊代碼,并進(jìn)行模塊替換,如圖:
圖片
若此過程異常,則會降級為頁面刷新。
無規(guī)律刷新原因分析
基座應(yīng)用(端口 8010)熱更新時返回的文件 Json 和 JS 文件(分別是 **:8010/update.json 和 **:8010/update.js)內(nèi)容如下:
圖片
圖片
但在嵌套在基座應(yīng)用中的本地子應(yīng)用(端口 8020)熱更新時,兩個同類文件并沒有返回內(nèi)容(**:8020/update.json 和 **:8020/update.js)
圖片
圖片
若瀏覽器單獨(dú)打開兩個文件的地址(**:8020/update.json 和 **:8020/update.js),有內(nèi)容返回。
圖片
但是在基座與子應(yīng)用嵌套的情況下,子應(yīng)用的任何請求(包括 update.json / update.js )都會被基座應(yīng)用代理,開發(fā)環(huán)境下很容易出現(xiàn)子應(yīng)用的更新探針請求被基座代理后,出現(xiàn)內(nèi)容丟失的情況。
子應(yīng)用 HMR 邏輯檢測到更新探針請求內(nèi)容異常,局部更新失效,降級為頁面刷新。
即使該問題解決,微前端應(yīng)用開發(fā)者依然面臨同時啟動 2 個應(yīng)用才能啟動開發(fā)的問題,所以我們不過度投入精力關(guān)注這個問題。
三、技術(shù)調(diào)研
解決「默認(rèn)情況下,父子應(yīng)用需要分別獨(dú)立啟動,并指定關(guān)聯(lián)關(guān)系」的問題,最好的方式是回歸到非微前端場景下的常規(guī)開發(fā)方式,即只啟動 1 個應(yīng)用進(jìn)行本地開發(fā)。
通常情況下,我們開發(fā)的是子應(yīng)用(也就是業(yè)務(wù)頁面),那先實(shí)現(xiàn)子應(yīng)用單獨(dú)啟動即可開啟項(xiàng)目開發(fā)吧,以下是面向該需求的技術(shù)調(diào)研。
Shared 通信
Shared 通信方案的原理是,主應(yīng)用維護(hù)一個狀態(tài)池,通過 Shared 實(shí)例暴露一些方法給子應(yīng)用使用。
同時,子應(yīng)用需要單獨(dú)維護(hù)一份 Shared 實(shí)例,在獨(dú)立運(yùn)行時使用自身的 Shared 實(shí)例,在嵌入主應(yīng)用時使用主應(yīng)用的 Shared 實(shí)例,這樣就可以保證在使用和表現(xiàn)上的一致性。
Shared 通信方案要求父子應(yīng)用都各自維護(hù)一份屬于自己的 Shared 實(shí)例,同樣會增加項(xiàng)目的復(fù)雜度。同時,在子應(yīng)用獨(dú)立運(yùn)行時,Shared 只能獲取本地緩存數(shù)據(jù),無法真正做到完全獨(dú)立于子應(yīng)用運(yùn)行。
圖片
Mock 父應(yīng)用環(huán)境
也就是在子應(yīng)用中模擬父應(yīng)用嵌套環(huán)境,提供一個獨(dú)立的模擬父應(yīng)用的組件,封裝了 Layout 布局、權(quán)限、用戶信息等,并且具備必要的父子通信能力,子應(yīng)用調(diào)用該 Mock 組件,獨(dú)立啟動以后進(jìn)行日常開發(fā)。
這個方案和 Shared 通信有類似之處。
用戶體驗(yàn)
// 這是子應(yīng)用代碼
import { MicroLayout } from '@abc/components';
// 組件內(nèi)使用
<MicroLayout title="Layout 內(nèi)容">
{children}
</MicroLayout>
圖片
流程設(shè)計
圖片
其他
對業(yè)務(wù)開發(fā)者而言,基座應(yīng)用 Layout 的改動均需要使用者進(jìn)行 Mock Component 的升級,比較麻煩。
對 Mock Component 開發(fā)者而言,需要額外開發(fā)父子通信方案 Microservice,用于 Mock Component 與子應(yīng)用的通信。
這套方案在應(yīng)對比較簡單的甚至沒有數(shù)據(jù)傳輸?shù)奈?yīng)用上是可以的,但是對于自定義化程度較高,復(fù)雜程度較高的微應(yīng)用項(xiàng)目來說就不是很方便了。
四、Chrome 代理插件
也就是通過 Chrome 插件,將線上子應(yīng)用 URL 代理到本地代碼。
利用瀏覽器提供的插件特性,劫持 HTTP 請求,將已經(jīng)部署在測試/預(yù)發(fā)/線上環(huán)境的項(xiàng)目的微應(yīng)用部分代理到開發(fā)者本地啟動的項(xiàng)目,這樣可以不用啟動基座應(yīng)用,直接打開目標(biāo)環(huán)境的主應(yīng)用,卻可以訪問本地子應(yīng)用項(xiàng)目進(jìn)行開發(fā)工作。
用戶體驗(yàn)
圖片
其他
子應(yīng)用需要的數(shù)據(jù)通過目標(biāo)環(huán)境的父應(yīng)用獲得,和第一套方案相比,他既可以滿足復(fù)雜場景下的子應(yīng)用獨(dú)立啟動,也不需要關(guān)注每個接入的子應(yīng)用他的數(shù)據(jù)依賴關(guān)系,研發(fā)成本也較低。
圖片
四、Chrome 代理插件
產(chǎn)品設(shè)計
“Chrome 插件代理子應(yīng)用到本地代碼”以提升微前端研發(fā)效率,該瀏覽器插件需要具備以下核心能力:
圖片
規(guī)則靈活配置。插件實(shí)現(xiàn)了 from 和 to 的地址映射規(guī)則配置表單。
如下圖,所有含 https://t1-xxxxxxxx.net/microapp/ 路徑的請求都將被代理到 localhost:8020/microapp/。
圖片
- 緩存能力。用戶關(guān)閉瀏覽器/電腦后再次打開,仍然能夠使用之前保存的代理規(guī)則。
- 快捷操作。為常用產(chǎn)品配置內(nèi)置規(guī)則,一鍵即可啟動,非常方便。
- 實(shí)時顯示。需要實(shí)時顯示代理規(guī)則的生效情況,方便用戶確認(rèn)哪些規(guī)則正在生效。
技術(shù)設(shè)計
Proxy 和 Popup
Chrome 插件分為 2 個模塊:Proxy 代理劫持模塊和 Popup 用戶交互模塊。
圖片
功能流轉(zhuǎn)
圖片
無感更新(Seamless Update)
圖片
popup.html 作為用戶界面入口,動態(tài)引入 popup.js 處理用戶交互,popup.js 動態(tài)引入 proxy.js 執(zhí)行 url 攔截規(guī)則。
這么做需要在 chrome 插件的 csp 安全策略配置中加入 cdn 域名白名單,允許插件訪問外部 cdn 資源。
manifest.json 配置如下:
值得注意的是,無感更新方案只在 Chrome V2 中實(shí)現(xiàn)了,V3 版本的 Chrome 插件執(zhí)行了更為保守的安全策略,限制第三方資源的加載。
{
...
"permissions": [
"webRequest", // 允許瀏覽器開放http請求劫持的功能
"storage", // 允許使用瀏覽器緩存
"activeTab",
"background",
"webRequestBlocking",
"<all_urls>"
],
// 允許特定域名可以訪問的安全策略
"content_security_policy":"script-src 'self' https://cdn.xxxxx.com 'sha256-G7YAg/PQDo8GYc/fSYvWtXP98kXS7iqT7K4QZgyhUIE='; object-src 'self'",
"content_scripts": [
{
"matches": ["*://*.xxxxxxx.net/*"],
"js": ["contentScript.bundle.js"]
}
]
}
// ==========attentinotallow===========
//v2配置,v2版本中可以配置script等通過外部引入,這個content_security_policy配置參數(shù)不加或者加上之后相應(yīng)的值填none
"content_security_policy": "script-src 'none'; object-src 'none'",
//v3配置,v3版本中安全政策配置script引入等信息,都必須填寫self,即只允許script標(biāo)簽引用當(dāng)前插件內(nèi)部文件,不允許引用外部鏈接,如果不填寫self的話,插件添加到擴(kuò)展程序時會報錯
"content_security_policy": {
//原文:此政策涵蓋您的擴(kuò)展程序中的頁面,包括 html 文件和服務(wù)人員;
"extension_pages": "script-src 'self'; object-src 'self'",
//原文:此政策涵蓋您的擴(kuò)展程序使用的任何[沙盒擴(kuò)展程序頁面](https://developer.chrome.com/docs/extensions/mv3/manifest/sandbox/)。;
"sandbox": "sandbox allow-scripts; script-src 'self'; object-src 'self'"
},
在 popup.html 中動態(tài)引入 popup.js 示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Popup</title>
<script>
var scriptEl = document.createElement('script');
scriptEl.defer = "defer";
scriptEl.src = "https://cdn.xxxxxxxxxxx/popup.js?timestamp=" + Date.now();
document.getElementsByTagName('head')[0].appendChild(scriptEl);
</script>
</head>
<body>
<div id="app-container"></div>
</body>
</html>
各模塊 JS 在構(gòu)建后上傳至 CDN,再在 popup.html / background.js / ... 中動態(tài)引入以實(shí)現(xiàn)無感更新。
圖片
Proxy
- 攔截頁面請求。
- 提供緩存配置的功能,用戶在關(guān)閉瀏覽器后,配置的映射關(guān)系不會自動消失,以便用戶下次打開的時候正常提供服務(wù)。
- 通信功能,需要將用戶在 Popup 頁面交互時提交的數(shù)據(jù)地址數(shù)據(jù)提供給攔截方法,從而實(shí)現(xiàn)對應(yīng)的攔截效果。
- 提供配置信息狀態(tài)的緩存數(shù)據(jù),方便觀測攔截效果。
以下是攔截邏輯的相關(guān)代碼:
chrome.webRequest.onBeforeRequest.addListener(
worker.getRequest.bind(worker),
{urls: worker.getFilterUrls(worker.replaceRules)},
['blocking']
);
// Class background
class Background {
constructor() {
this.replaceRules = []
}
getRequest(details) {
if (!details) {
// chrome未返回任何request
return false
}
const {url} = details
const returnObj = {}
if (this.replaceRules.length) {
// 存在替換規(guī)則
this.replaceRules.map((item) => {
if (url.includes(item.from)) {
// 更新 icon 狀態(tài)
this.setBadgeInfo(true)
// 代理替換
returnObj.redirectUrl = url.replace(item.from, item.to)
// 緩存更新當(dāng)前代理域名
chrome.storage.sync.get(null, function (data) {
if (data.messageProxyingData) {
// 存在被代理的數(shù)據(jù),合并數(shù)據(jù)
const isExistMessageProxyingData = data.messageProxyingData
chrome.storage.sync.set({messageProxyingData: Object.assign(isExistMessageProxyingData, {[item.key]: true})})
} else {
// 未存在緩存,使用新數(shù)據(jù)
chrome.storage.sync.set({messageProxyingData: {[item.key]: true}})
}
})
}
})
}
return returnObj
}
}
可以看到,chrome.webRequest.onBeforeRequest/ getRequest 配合攔截獲取請求地址,然后攔截替換目標(biāo)路徑,當(dāng)然這是比較簡單的邏輯,復(fù)雜的可以參考 glob 寫法代理鏈接。
Popup
Popup 用戶交互模塊,支持一鍵開啟和一鍵關(guān)閉、支持自定義配置映射地址。
圖片
const sendMessage = (data: DataType[]) => {
// 保留數(shù)據(jù)中已開啟的數(shù)據(jù),進(jìn)行數(shù)據(jù)傳輸
const finalData = data.filter.((item) => item.is_open);
chrome.runtime.sendMessage({ data: finalData, type: 'rule' });
};
const handleSaveData = (
data: DataType[],
isNeedSendMsg: boolean,
key?: number[]
) => {
setDataSource([...data]);
// 緩存配置
chrome.storage.sync.set({ popupData: data });
if (isNeedSendMsg) {
sendMessage(data);
if (key?.length) {
chrome.storage.sync.get(null, function (data) {
const messageProxyingData = data.messageProxyingData || {};
if (messageProxyingData) {
key.forEach.((item) => {
delete messageProxyingData[item];
});
}
chrome.storage.sync.set({ messageProxyingData });
});
}
}
};
ContentScript
原本無需在 ContentScript 中植入代理相關(guān)的任何邏輯,但在 Qiankun 微前端場景下有一個問題,子應(yīng)用的熱更新會失效(可能導(dǎo)致 HMR 無效進(jìn)而只能 Reload 頁面查看最新頁面效果),為此我們正好可以借助 Chrome 插件可注入 ContentScript 的能力,為用戶自動規(guī)避一些問題。
下面這段注入頁面的 ContentScript 可以輔助解決 Qiankun 子應(yīng)用代碼更新后 HMR 失效的問題(方案來自社區(qū))。
const scrpit = document.createElement('script')
scrpit.textContent = `
window.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__ =
window.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__ || {
iframeReady: function () {
var overlay = document.querySelector(
'iframe[style*="z-index: 2147483647;"]',
);
if (overlay) {
overlay.style.display = 'none';
}
},
};
`
document.body.appendChild(scrpit)
五、推進(jìn)情況
項(xiàng)目覆蓋率
研發(fā)效率
- 避免了父子應(yīng)用均啟動時,子應(yīng)用代碼更新后,父應(yīng)用被動觸發(fā) Reload 的問題。
- 子應(yīng)用 HMR 熱更新延時與非微前端項(xiàng)目沒有差距。
用戶反饋
同時,Chrome 插件方案實(shí)現(xiàn)簡單、使用方便,是此類場景下,在研發(fā)成本和用戶體驗(yàn)上的表現(xiàn)比較均衡的解決方案。
六、思考
對于此產(chǎn)品,還有可以持續(xù)完善的地方,比如:可以增加本地服務(wù)的?;钐结?,替代手動開啟代理規(guī)則,更智能化;或者利用其動態(tài)更新能力擴(kuò)充功能,從很具體的小事情入手,不斷解決更多問題。
對于團(tuán)隊(duì),發(fā)現(xiàn)工作中的痛點(diǎn),用工具化的方式沉淀解決方案,解決實(shí)際問題,也是前端同學(xué)提升自身價值的一個可行路徑。