背景
在中臺業務中,交易流轉、業務運營和商戶賦能等功能,主要集中在兩個系統中(暫且命名為 inner/outer )。兩個系統基座(功能框架)類似,以 inner 系統為例,如圖:
inner系統基座
業務現狀問題維護迭代,隨時間延續是不可避免的
至今,inner/outer 均有以下特點:
頁面結構繁雜 分類較多,選單頁面多;佈局五花八門,不統一技術棧不統一 歷史原因,存在 jquery、靜態模板、react 等技術棧許可權不統一 不同使用者,許可權不一樣,使用的功能模組不同專案管理不統一 部分功能模組是由業務方維護;同一功能模組面向不同使用者角色,也需要在不同系統中使用初次接觸上述問題時,閃現在腦海裡的是:用 iframe 呀。確實,剛開始也是這樣做的。
問題暴露,在維護迭代中是個契機
系統在一個長時間跨度的執行下,隨著維護人員的變遷、使用人群的增多,更多的問題也接踵而至:
樣式不統一由於沒有統一規範,每個功能模組在不同的開發者鍵盤下設想的結構不同,輸出的風格也不統一,使整個系統看起來略顯雜亂。
瀏覽器前進/後退首先,iframe 頁面沒有自己的歷史記錄,使用的是基座(父頁面)的瀏覽歷史。所以,當iframe 頁在內部進行跳轉時,瀏覽器位址列無變化,基座中載入的 src 資源也無變化,當瀏覽器重新整理時,無法停留在iframe內部跳轉後的頁面上,需要使用者重新走一遍操作,體驗上會大打折扣。
彈窗遮罩層覆蓋可視範圍iframe 頁產生的彈窗,一般只能遮罩 iframe 區域。
頁面間訊息傳遞與基座非同源下,iframe 無法直接獲取基座 url 的引數,訊息傳遞需要週轉一下,如使用postmessage來實現;而動態建立的 iframe 頁,或許還需要藉助本地儲存等。
頁面快取iframe 資源變更上線後,開啟系統會發現 iframe 頁依舊是老資源。需要用時間戳方案或強制重新整理。
載入異常處理與基座非同源下,onerror 事件無法使用。使用 try catch 解決此問題,嘗試獲取 contentDocument 時將丟擲異常
改進實踐 - 微前端實踐新技術,在問題暴露時是方向
大多數工程師,包括我,一邊兒嘴裡說著:學不動啦!一邊兒想嘗試一些新方式來優化系統。
結合問題分類,有思考一些嘗試方向,如:
中後臺 UI 規範:歷經迭代,百花齊放,然而更需要的是找到合適我司的風格,保持一致性。另外,大網際網路時代,從工程角度看,社群對類似系統的探索有很多,除了 iframe 外,也有不少相對成熟的替代方案:
1. single-spa
2. qiankun
提起這兩個,就要提一下微前端理念,目前社群有很多關於微前端架構的介紹,這裡簡單提一下:
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. — Micro Frontends
大致是說,微前端有以下特點:
技術棧無關:基座不限制子應用的技術棧完全獨立:子應用獨立部署維護,接入時基座同步更新;又可獨立執行基於此,不難想到:iframe 也是符合微前端理念的。那其他方案又是如何做的呢?
single-spa社群裡 single-spa 介紹也不少。根據 demo 比葫蘆畫瓢,可以知道它的架構分佈:
single-spa架構
啟動服務的配置主要是在single-spa-config 檔案中,包含專案名稱、 專案地址、路由配置等:
// single-spa-config.jsimport {registerApplication, start } from 'single-spa';// 子應用唯一IDconst microAppName = 'react';// 子應用入口const loadingFunction = () => import('./react/app.js');// url字首校驗const activityFunction = location => location.pathname.startsWith('/react');// 註冊registerApplication( microAppName, loadingFunction, activityFunction);//singleSpa 啟動start();
single-spa 讓基座和子應用共用一個 document,那就需要對子應用進行改造:把子專案的容器和生成的 js 插入到基座專案中。
不需要 HTML 入口檔案js 入口檔案匯出的模組,必須包括 bootstrap、mount 和 unmount 三個方法<div id='micro-react'></div><script src=/js/chunk-vendors.js> </script><script src=/js/app.js> </script>
不過這種方式需要對現有專案的打包方式和配置項進行改造,成本很大。所以,對於已有的工程專案,我選擇了放棄使用。
qiankunqiankun 也是社群提到比較多的一個開源框架,是基於single-spa 實現了開箱即用。可以採用html entry 方式接入子應用,且子應用只需暴露一些生命週期,改動較少。【少】這個點,真是讓我躍躍欲試。
目前我司業務場景是單例項模式(一個執行時只有一個子應用被啟用),我們可以根據一張圖來看看單例項下以html entry方式 qiankun 實現流程:
qiankun原理
如上圖所示,一個子應用的全過程有:
初始化配置,匹配出子應用初始化子應用,載入對應的 html 資源,以及建立 JS 沙箱環境掛載子應用,執行生命週期鉤子函式解除安裝子應用,當切換路由時,執行各解除安裝鉤子函式,以及解除安裝 JS 沙箱環境,清除容器節點具體實現細節,大家可以參考qiankun原始碼。
實踐基座從規範化開發角度,我司的中後臺系統是基於 umi 開發(詳細可參考我們之前的文章 umi 中後臺專案實踐)。在構建主應用使用了配套的 qiankun 外掛:@umijs/plugin-qiankun。
1. 初始化配置項,註冊子應用
外掛安裝之後,我們可以在入口檔案裡配置:
此處主要以執行時為例
// app.jsexport const qiankun = Promise.resolve().then(() => ({ // 執行時註冊子應用資訊 apps: [ { // 結算單管理 name: 'settlement', // 唯一id,與子應用的library 保持一致 entry: '//xxx', // html entry history: 'hash', // 子應用的 history 配置,預設為當前主應用 history 配置 container: '#root-content', // 子應用存放節點 mountElementId: 'root-content' // 子應用存放節點 }, { // 公告訊息 name: 'news', // 唯一id,與子應用的library 保持一致 entry: '//xxx', // html entry history: 'hash', // 子應用的 history 配置,預設為當前主應用 history 配置 container: '#root-content', // 子應用存放節點 mountElementId: 'root-content' // 子應用存放節點 } ], jsSandbox: { strictStyleIsolation: true }, // 是否啟用 js 沙箱,預設為 false prefetch: true, // 是否啟用 prefetch 特性,預設為 true lifeCycles: { // see /file/2020/09/25/20200925140958_14.jpg 裝載子應用,在路由配置中使用microApp來獲取相應的子應用名稱:// router.config.jsexport default [ { path: '/', component: '../layouts/BasicLayout', routes: [ ... { path: '/settlement/list', name: '結算單管理', icon: 'RedEnvelopeOutlined', microApp: 'settlement', // 子應用唯一id }, { path: '/settlement/detail/:id', name: '結算單管理', icon: 'RedEnvelopeOutlined', microApp: 'settlement', // 子應用唯一id hideInMenu: true, }, ... ... { component: './404', }, ], }, { component: './404', },]
以上就是基座的改動點,看起來程式碼侵入性很少。
子應用
在子應用中,需要做如下的配置
1. 入口檔案設定 baseName,及暴露鉤子函式
//設定主應用下的子應用路由名稱空間const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/settlement" : "";// 獨立執行時,直接掛載應用if (!window.__POWERED_BY_QIANKUN__) { effectRender();}// 在子應用初始化的時候呼叫一次export async function bootstrap() { console.log("ReactMicroApp bootstraped");}export async function mount(props) { console.log("ReactMicroApp mount", props); effectRender(props);}//解除安裝子應用的應用例項export async function unmount(props) { const { container } = props || {}; ReactDOM.unmountComponentAtNode(document.getElementById('root-content') );}
2. webpack 配置中,需要設定輸出為 umd 格式:
// 設定別名merge: { plugins: [new webpack.ProvidePlugin({ React: 'react', PropTypes: 'prop-types' })], output: { library: `[name]`, // 子應用的包名,這裡與主應用中註冊子應用名稱一致 libraryTarget: "umd", // 所有的模組定義下都可執行的方式 jsonpFunction: `webpackJsonp_ReactMicroApp`, // 按需載入 }} //自定義webpack配置
OK,配置完成!
理論上,啟動專案,部署等都應該沒有問題了。咦,開啟地址,頁面一直在 loading,控制檯一堆報錯,看起來要踩一踩坑了。
踩坑
1. 版本一致性
如果主應用和子應用都是基於 umi 框架,在使用 @umijs/umi-plugin-qiankun 外掛時,要使用同一個版本,否則子應用報錯。
2. 跨域
qiankun 是通過 fetch 去獲取子應用資源的,所以必須支援跨域
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, 子應用為 d.zhuanzhuan.com 。當基座去載入子應用時,會出現跨域錯誤。
曾經有采用通過 Node 服務做一層中轉,跳過跨域問題:
.... maxDays: 3, // 保留最大天數日誌檔案}// 代理config.httpProxy = { '/cors': { target: 'https://d.zhuanzhuan.com', pathRewrite: {'^/cors' : ''} }};return config
但考慮應用的訪問量,以及線上線下環境維護成本,覺得必要性不是很大,最終選擇通過 nginx 解決跨域。
3. 子應用內部跳轉
子應用內部跳轉,需要在基座路由上提前註冊好,否則在跳轉後,頁面識別不到。
{ path: '/settlement/detail/:id', name: '結算單管理', icon: 'RedEnvelopeOutlined', microApp: 'settlement', hideInMenu: true,},
4. css 汙染
qiankun 只能解決子應用之間的樣式相互汙染,不能解決子應用樣式汙染基座的樣式。比如:當切換到某個子應用時,左側選單欄突然往右移了。
系統右移
檢視控制檯,不難發現,子應用的相同模組覆蓋了基座:
樣式覆蓋
這個問題,可以通過改變基座的字首來解決,搞一個postcss 外掛給不同的元件新增不同的字首。
這裡補充一個 css 隔離常用的方式如:css字首、CSS Module、動態載入/解除安裝樣式表。
qiankun 中 css沙箱機制 採用的是 動態載入/解除安裝樣式表。
重寫 HTMLHeadElement.prototype.appendChild 事件
// Just overwrite it while it have not been overwriteif ( 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;....
當子應用載入時,在 head 插入 style/link ; 當解除安裝時,直接移除。// Just overwrite it while it have not been overwriteif ( HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild && HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild) { HTMLHeadElement.prototype.removeChild = getNewRemoveChild({ appWrapperGetter, headOrBodyRemoveChild: rawHeadRemoveChild, }); HTMLBodyElement.prototype.removeChild = getNewRemoveChild({ appWrapperGetter, headOrBodyRemoveChild: rawBodyRemoveChild, });}
看起來很完美,但有時候會出現,基座樣式丟失的問題。這個跟子應用解除安裝的時機有關係:當切換子應用時,當前子應用沙箱環境還未被解除安裝,但基座 css 已被插入,當解除安裝時會連帶基座 css 一起被清除。
5. 錯誤捕獲,降級處理
若子應用載入失敗,需要給相應的提示或動態插入iframe頁:
// iframe.jsexport 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; // 載入失敗時提示 if (message && message.includes("died in status LOADING_SOURCE_CODE")) { Modal.Confirm({ content: "子應用載入失敗,請檢查應用是否可執行" onOk: () => import('./Inframe.js') }); }});
6. 路由懶載入樣式丟失
子應用中存在按需載入的路由,在載入時頁面樣式丟失,這是官方庫產生的問題,issue 裡已有大佬提 PR 啦,可參考 https://github.com/umijs/qiankun/issues/857
以上,就是不完全踩坑。
後續
持續性思考會帶來的技術紅利
此次接入 qiankun,也只是處於表面應用。後續我們更要思考接入它之後更深的工程價值,如:
- 自動接入 qiankun
結合我司已有的腳手架和 umi 模板,額外新增一個命令,自動註冊子應用,做到自動化。
- 子應用間元件共享
基座和子應用大概率都用到了 react/dva 等,是否可以在基座載入完之後,子應用直接複用?當然,淺顯思考應該少不了 webpack 的 externals。