iframe 接班人-微前端框架 qiankun 在中后臺(tái)系統(tǒng)實(shí)踐
背景
在轉(zhuǎn)轉(zhuǎn)的中臺(tái)業(yè)務(wù)中,交易流轉(zhuǎn)、業(yè)務(wù)運(yùn)營和商戶賦能等功能,主要集中在兩個(gè)系統(tǒng)中(暫且命名為 inner/outer )。兩個(gè)系統(tǒng)基座(功能框架)類似,以 inner 系統(tǒng)為例,如圖:

業(yè)務(wù)現(xiàn)狀問題
維護(hù)迭代,隨時(shí)間延續(xù)是不可避免的
至今,inner/outer 均有以下特點(diǎn):
頁面結(jié)構(gòu)繁雜 分類較多,菜單頁面多;布局五花八門,不統(tǒng)一 技術(shù)棧不統(tǒng)一 歷史原因,存在 jquery、靜態(tài)模板、react等技術(shù)棧權(quán)限不統(tǒng)一 不同用戶,權(quán)限不一樣,使用的功能模塊不同 項(xiàng)目管理不統(tǒng)一 部分功能模塊是由業(yè)務(wù)方維護(hù);同一功能模塊面向不同用戶角色,也需要在不同系統(tǒng)中使用
初次接觸上述問題時(shí),閃現(xiàn)在腦海里的是:用 iframe 呀。確實(shí),剛開始也是這樣做的。
問題暴露,在維護(hù)迭代中是個(gè)契機(jī)
系統(tǒng)在一個(gè)長時(shí)間跨度的運(yùn)行下,隨著維護(hù)人員的變遷、使用人群的增多,更多的問題也接踵而至:
樣式不統(tǒng)一
由于沒有統(tǒng)一規(guī)范,每個(gè)功能模塊在不同的開發(fā)者鍵盤下設(shè)想的結(jié)構(gòu)不同,輸出的風(fēng)格也不統(tǒng)一,使整個(gè)系統(tǒng)看起來略顯雜亂。
瀏覽器前進(jìn)/后退
首先,iframe 頁面沒有自己的歷史記錄,使用的是基座(父頁面)的瀏覽歷史。所以,當(dāng)iframe 頁在內(nèi)部進(jìn)行跳轉(zhuǎn)時(shí),瀏覽器地址欄無變化,基座中加載的 src 資源也無變化,當(dāng)瀏覽器刷新時(shí),無法停留在iframe內(nèi)部跳轉(zhuǎn)后的頁面上,需要用戶重新走一遍操作,體驗(yàn)上會(huì)大打折扣。
彈窗遮罩層覆蓋可視范圍
iframe 頁產(chǎn)生的彈窗,一般只能遮罩 iframe 區(qū)域。
頁面間消息傳遞
與基座非同源下,iframe 無法直接獲取基座 url 的參數(shù),消息傳遞需要周轉(zhuǎn)一下,如使用postmessage來實(shí)現(xiàn);而動(dòng)態(tài)創(chuàng)建的 iframe 頁,或許還需要借助本地存儲(chǔ)等。
頁面緩存
iframe 資源變更上線后,打開系統(tǒng)會(huì)發(fā)現(xiàn) iframe 頁依舊是老資源。需要用時(shí)間戳方案或強(qiáng)制刷新。
加載異常處理
與基座非同源下,onerror 事件無法使用。使用 try catch 解決此問題,嘗試獲取 contentDocument 時(shí)將拋出異常
以上問題,從業(yè)務(wù)價(jià)值看,對(duì)用戶的使用體驗(yàn)會(huì)有損失;從工程價(jià)值看,希望能通過技術(shù)提升業(yè)務(wù)體驗(yàn)的同時(shí),也提高系統(tǒng)的維護(hù)性。
改進(jìn)實(shí)踐 - 微前端
實(shí)踐新技術(shù),在問題暴露時(shí)是方向
大多數(shù)工程師,包括我,一邊兒嘴里說著:學(xué)不動(dòng)啦!一邊兒想嘗試一些新方式來優(yōu)化系統(tǒng)。
結(jié)合問題分類,有思考一些嘗試方向,如:
中后臺(tái) UI 規(guī)范:歷經(jīng)迭代,百花齊放,然而更需要的是找到合適我司的風(fēng)格,保持一致性。
此部分這次不再細(xì)說,可以 關(guān)注我們公眾號(hào) - 大轉(zhuǎn)轉(zhuǎn) FE,后續(xù)我們會(huì)有專門的文章講這部分。
另外,大互聯(lián)網(wǎng)時(shí)代,從工程角度看,社區(qū)對(duì)類似系統(tǒng)的探索有很多,除了 iframe 外,也有不少相對(duì)成熟的替代方案:
1. single-spa
2. qiankun
提起這兩個(gè),就要提一下微前端理念,目前社區(qū)有很多關(guān)于微前端架構(gòu)的介紹,這里簡單提一下:
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. — Micro Frontends
大致是說,微前端有以下特點(diǎn):
技術(shù)棧無關(guān):基座不限制子應(yīng)用的技術(shù)棧 完全獨(dú)立:子應(yīng)用獨(dú)立部署維護(hù),接入時(shí)基座同步更新;又可獨(dú)立運(yùn)行
基于此,不難想到:iframe 也是符合微前端理念的。那其他方案又是如何做的呢?
single-spa
社區(qū)里 single-spa 介紹也不少。根據(jù) demo 比葫蘆畫瓢,可以知道它的架構(gòu)分布:

啟動(dòng)服務(wù)的配置主要是在single-spa-config 文件中,包含項(xiàng)目名稱、
項(xiàng)目地址、路由配置等:
//?single-spa-config.js
import?{registerApplication,?start?}?from?'single-spa';
//?子應(yīng)用唯一ID
const?microAppName?=?'react';
//?子應(yīng)用入口
const?loadingFunction?=?()?=>?import('./react/app.js');
//?url前綴校驗(yàn)
const?activityFunction?=?location?=>?location.pathname.startsWith('/react');
//?注冊(cè)
registerApplication(
??microAppName,
??loadingFunction,
??activityFunction
);
//singleSpa?啟動(dòng)
start();
single-spa 讓基座和子應(yīng)用共用一個(gè) document,那就需要對(duì)子應(yīng)用進(jìn)行改造:把子項(xiàng)目的容器和生成的 js 插入到基座項(xiàng)目中。
不需要 HTML入口文件js入口文件導(dǎo)出的模塊,必須包括bootstrap、mount和unmount三個(gè)方法
<div?id='micro-react'>div>
<script?src=/js/chunk-vendors.js>?script>
<script?src=/js/app.js>?script>
不過這種方式需要對(duì)現(xiàn)有項(xiàng)目的打包方式和配置項(xiàng)進(jìn)行改造,成本很大。所以,對(duì)于已有的工程項(xiàng)目,我選擇了放棄使用。
qiankun
qiankun 也是社區(qū)提到比較多的一個(gè)開源框架,是基于single-spa 實(shí)現(xiàn)了開箱即用。可以采用html entry 方式接入子應(yīng)用,且子應(yīng)用只需暴露一些生命周期,改動(dòng)較少。【少】這個(gè)點(diǎn),真是讓我躍躍欲試。
目前我司業(yè)務(wù)場(chǎng)景是單實(shí)例模式(一個(gè)運(yùn)行時(shí)只有一個(gè)子應(yīng)用被激活),我們可以根據(jù)一張圖來看看單實(shí)例下以html entry方式 qiankun 實(shí)現(xiàn)流程:

如上圖所示,一個(gè)子應(yīng)用的全過程有:
初始化配置,匹配出子應(yīng)用 初始化子應(yīng)用,加載對(duì)應(yīng)的 html 資源,以及創(chuàng)建 JS 沙箱環(huán)境 掛載子應(yīng)用,執(zhí)行生命周期鉤子函數(shù) 卸載子應(yīng)用,當(dāng)切換路由時(shí),執(zhí)行各卸載鉤子函數(shù),以及卸載 JS 沙箱環(huán)境,清除容器節(jié)點(diǎn)
具體實(shí)現(xiàn)細(xì)節(jié),大家可以參考qiankun源碼。
實(shí)踐
基座
從規(guī)范化開發(fā)角度,我司的中后臺(tái)系統(tǒng)是基于 umi 開發(fā)(詳細(xì)可參考我們之前的文章 umi 中后臺(tái)項(xiàng)目實(shí)踐)。在構(gòu)建主應(yīng)用使用了配套的 qiankun 插件:@umijs/plugin-qiankun。
1. 初始化配置項(xiàng),注冊(cè)子應(yīng)用
插件安裝之后,我們可以在入口文件里配置:
此處主要以運(yùn)行時(shí)為例
//?app.js
export?const?qiankun?=?Promise.resolve().then(()?=>?({
??//?運(yùn)行時(shí)注冊(cè)子應(yīng)用信息
??apps:?[
????{
??????//?結(jié)算單管理
??????name:?'settlement',?//?唯一id,與子應(yīng)用的library?保持一致
??????entry:?'//xxx',?//?html?entry
??????history:?'hash',?//?子應(yīng)用的?history?配置,默認(rèn)為當(dāng)前主應(yīng)用?history?配置
??????container:?'#root-content',?//?子應(yīng)用存放節(jié)點(diǎn)
??????mountElementId:?'root-content'?//?子應(yīng)用存放節(jié)點(diǎn)
????},?{
??????//?公告消息
??????name:?'news',?//?唯一id,與子應(yīng)用的library?保持一致
??????entry:?'//xxx',?//?html?entry
??????history:?'hash',?//?子應(yīng)用的?history?配置,默認(rèn)為當(dāng)前主應(yīng)用?history?配置
??????container:?'#root-content',?//?子應(yīng)用存放節(jié)點(diǎn)
??????mountElementId:?'root-content'?//?子應(yīng)用存放節(jié)點(diǎn)
????}
??],
??jsSandbox:?{?strictStyleIsolation:?true?},?//?是否啟用?js?沙箱,默認(rèn)為?false
??prefetch:?true,?//?是否啟用?prefetch?特性,默認(rèn)為?true
??lifeCycles:?{
????//?see?https://github.com/umijs/qiankun#registermicroapps
????beforeLoad:?(props)?=>?{
??????return?Promise.resolve(props).then(()?=>?loading())
????},
????afterMount:?(props)?=>?{
??????console.log('afterMount',?props)
????},
????afterUnmount:?(props)?=>?{
??????console.log('afterUnmount',?props)
????}
??}
}))
2. 裝載子應(yīng)用,在路由配置中使用microApp來獲取相應(yīng)的子應(yīng)用名稱:
//?router.config.js
export?default?[
??{
????path:?'/',
????component:?'../layouts/BasicLayout',
????routes:?[
??????...
??????{
????????path:?'/settlement/list',
????????name:?'結(jié)算單管理',
????????icon:?'RedEnvelopeOutlined',
????????microApp:?'settlement',??//?子應(yīng)用唯一id
??????},
??????{
????????path:?'/settlement/detail/:id',
????????name:?'結(jié)算單管理',
????????icon:?'RedEnvelopeOutlined',
????????microApp:?'settlement',?//?子應(yīng)用唯一id
????????hideInMenu:?true,
??????},
??????...
??????...
??????{
????????component:?'./404',
??????},
????],
??},
??{
????component:?'./404',
??},
]
以上就是基座的改動(dòng)點(diǎn),看起來代碼侵入性很少。
子應(yīng)用
在子應(yīng)用中,需要做如下的配置
1. 入口文件設(shè)置 baseName,及暴露鉤子函數(shù)
//設(shè)置主應(yīng)用下的子應(yīng)用路由命名空間
const?BASE_NAME?=?window.__POWERED_BY_QIANKUN__???"/settlement"?:?"";
//?獨(dú)立運(yùn)行時(shí),直接掛載應(yīng)用
if?(!window.__POWERED_BY_QIANKUN__)?{
??effectRender();
}
//?在子應(yīng)用初始化的時(shí)候調(diào)用一次
export?async?function?bootstrap()?{
??console.log("ReactMicroApp?bootstraped");
}
export?async?function?mount(props)?{
??console.log("ReactMicroApp?mount",?props);
??effectRender(props);
}
//卸載子應(yīng)用的應(yīng)用實(shí)例
export?async?function?unmount(props)?{
??const?{?container?}?=?props?||?{};
??ReactDOM.unmountComponentAtNode(document.getElementById('root-content')
??);
}
2. webpack 配置中,需要設(shè)置輸出為 umd 格式:
//?設(shè)置別名
merge:?{
??plugins:?[new?webpack.ProvidePlugin({
????React:?'react',
????PropTypes:?'prop-types'
??})],
??output:?{
????library:?`[name]`,?//?子應(yīng)用的包名,這里與主應(yīng)用中注冊(cè)子應(yīng)用名稱一致
????libraryTarget:?"umd",?//?所有的模塊定義下都可運(yùn)行的方式
????jsonpFunction:?`webpackJsonp_ReactMicroApp`,?//?按需加載
??}
}?//自定義webpack配置
OK,配置完成!
理論上,啟動(dòng)項(xiàng)目,部署等都應(yīng)該沒有問題了。咦,打開地址,頁面一直在 loading,控制臺(tái)一堆報(bào)錯(cuò),看起來要踩一踩坑了。
踩坑
1. 版本一致性
如果主應(yīng)用和子應(yīng)用都是基于 umi 框架,在使用 @umijs/umi-plugin-qiankun 插件時(shí),要使用同一個(gè)版本,否則子應(yīng)用報(bào)錯(cuò)。
2. 跨域
qiankun 是通過 fetch 去獲取子應(yīng)用資源的,所以必須支持跨域
const?mountDOM?=?appWrapperGetter();
const?{?fetch?}?=?frameworkConfiguration;
const?referenceNode?=?mountDOM.contains(refChild)???refChild?:?null;
if?(src)?{
??execScripts(null,?[src],?proxy,?{
????fetch,
????strictGlobal:?!singular,
????beforeExec:?()?=>?{
??????Object.defineProperty(document,?'currentScript',?{
????????get():?any?{
??????????return?element;
????????},
????????configurable:?true,
??????})
????};
??})
}
比如:基座地址為 b.zhuanzhuan.com, 子應(yīng)用為 d.zhuanzhuan.com 。當(dāng)基座去加載子應(yīng)用時(shí),會(huì)出現(xiàn)跨域錯(cuò)誤。
曾經(jīng)有采用通過 Node 服務(wù)做一層中轉(zhuǎn),跳過跨域問題:
??....
??maxDays:?3,?//?保留最大天數(shù)日志文件
}
//?代理
config.httpProxy?=?{
??'/cors':?{
????target:?'https://d.zhuanzhuan.com',
????pathRewrite:?{'^/cors'?:?''}
??}
};
return?config
但考慮應(yīng)用的訪問量,以及線上線下環(huán)境維護(hù)成本,覺得必要性不是很大,最終選擇通過 nginx 解決跨域。
3. 子應(yīng)用內(nèi)部跳轉(zhuǎn)
子應(yīng)用內(nèi)部跳轉(zhuǎn),需要在基座路由上提前注冊(cè)好,否則在跳轉(zhuǎn)后,頁面識(shí)別不到。
{
??path:?'/settlement/detail/:id',
??name:?'結(jié)算單管理',
??icon:?'RedEnvelopeOutlined',
??microApp:?'settlement',
??hideInMenu:?true,
},
4. css 污染
qiankun 只能解決子應(yīng)用之間的樣式相互污染,不能解決子應(yīng)用樣式污染基座的樣式。比如:當(dāng)切換到某個(gè)子應(yīng)用時(shí),左側(cè)菜單欄突然往右移了。

查看控制臺(tái),不難發(fā)現(xiàn),子應(yīng)用的相同模塊覆蓋了基座:

這個(gè)問題,可以通過改變基座的前綴來解決,搞一個(gè)postcss 插件給不同的組件添加不同的前綴。
這里補(bǔ)充一個(gè) css 隔離常用的方式如:css前綴、CSS Module、動(dòng)態(tài)加載/卸載樣式表。
qiankun 中 css沙箱機(jī)制 采用的是 動(dòng)態(tài)加載/卸載樣式表。
重寫 HTMLHeadElement.prototype.appendChild事件
//?Just?overwrite?it?while?it?have?not?been?overwrite
if?(
??HTMLHeadElement.prototype.appendChild?===?rawHeadAppendChild?&&
??HTMLBodyElement.prototype.appendChild?===?rawBodyAppendChild?&&
??HTMLHeadElement.prototype.insertBefore?===?rawHeadInsertBefore
)?{
??HTMLHeadElement.prototype.appendChild?=?getOverwrittenAppendChildOrInsertBefore({
????rawDOMAppendOrInsertBefore:?rawHeadAppendChild,
????appName,
????appWrapperGetter,
????proxy,
????singular,
????dynamicStyleSheetElements,
????scopedCSS,
????excludeAssetFilter,
??})?as?typeof?rawHeadAppendChild;
....
當(dāng)子應(yīng)用加載時(shí),在 head插入style/link; 當(dāng)卸載時(shí),直接移除。
//?Just?overwrite?it?while?it?have?not?been?overwrite
if?(
??HTMLHeadElement.prototype.removeChild?===?rawHeadRemoveChild?&&
??HTMLBodyElement.prototype.removeChild?===?rawBodyRemoveChild
)?{
??HTMLHeadElement.prototype.removeChild?=?getNewRemoveChild({
????appWrapperGetter,
????headOrBodyRemoveChild:?rawHeadRemoveChild,
??});
??HTMLBodyElement.prototype.removeChild?=?getNewRemoveChild({
????appWrapperGetter,
????headOrBodyRemoveChild:?rawBodyRemoveChild,
??});
}
看起來很完美,但有時(shí)候會(huì)出現(xiàn),基座樣式丟失的問題。這個(gè)跟子應(yīng)用卸載的時(shí)機(jī)有關(guān)系:當(dāng)切換子應(yīng)用時(shí),當(dāng)前子應(yīng)用沙箱環(huán)境還未被卸載,但基座 css 已被插入,當(dāng)卸載時(shí)會(huì)連帶基座 css 一起被清除。
5. 錯(cuò)誤捕獲,降級(jí)處理
若子應(yīng)用加載失敗,需要給相應(yīng)的提示或動(dòng)態(tài)插入iframe頁:
//?iframe.js
export?default?({?sourceUrl?})?=>
??<iframe
????src={sourceUrl}
????title="xxxx"
????width="100%"
????height="100%"
????border="0"
????frameBorder="0"
??/>
import?{?render?}?from?'react-dom';
//?全局未捕獲異常處理器
addGlobalUncaughtErrorHandler((event)?=>?{
??console.error(event);
??const?{?message,?location:?{?hash?}?}?=?event;
??//?加載失敗時(shí)提示
??if?(message?&&?message.includes("died?in?status?LOADING_SOURCE_CODE"))?{
????Modal.Confirm({
????content:?"子應(yīng)用加載失敗,請(qǐng)檢查應(yīng)用是否可運(yùn)行"
????onOk:?()?=>?import('./Inframe.js')
????});
??}
});
6. 路由懶加載樣式丟失
子應(yīng)用中存在按需加載的路由,在加載時(shí)頁面樣式丟失,這是官方庫產(chǎn)生的問題,issue 里已有大佬提 PR 啦,可參考 https://github.com/umijs/qiankun/issues/857
以上,就是我們的不完全踩坑。
應(yīng)用間的通信,在我司的業(yè)務(wù)場(chǎng)景中復(fù)雜度不高,使用官方提供的方案就可以解決,此處沒有詳說。
后續(xù)
持續(xù)性思考會(huì)帶來的技術(shù)紅利
此次接入 qiankun,也只是處于表面應(yīng)用。后續(xù)我們更要思考接入它之后更深的工程價(jià)值,如:
- 自動(dòng)接入 qiankun
結(jié)合我司已有的腳手架和 umi 模板,額外添加一個(gè)命令,自動(dòng)注冊(cè)子應(yīng)用,做到自動(dòng)化。
- 子應(yīng)用間組件共享
基座和子應(yīng)用大概率都用到了 react/dva 等,是否可以在基座加載完之后,子應(yīng)用直接復(fù)用?當(dāng)然,淺顯思考應(yīng)該少不了 webpack 的 externals。
?

