前言
微前端
是當下的前端熱詞,稍具規模的團隊都會去做技術探索,作為一個不甘落後的團隊,我們也去做了。也許你看過了Single-Spa
,qiankun
這些業界成熟方案,非常強大:JS沙箱隔離、多棧支援、子應用並行、子應用巢狀,但仔細想想它真的適合你嗎?
對於我來說,太重了,概念太多,理解困難。先說一下背景,我們之所以要對我司的小貸管理後臺做微前端改造,主要基於以下幾個述求:
- 系統從接手時差不多30個頁面,一年多時間,發展到目前150多個頁面,並還在持續增長;
- 專案體積變大,帶來開發體驗很差,打包構建速度很慢(初次構建,1分鐘以上);
- 小貸系統開發量佔整個web組50%的人力,每個迭代都有兩三個需求在這一個系統上開發,程式碼合併衝突,上線時間交叉。帶來的是開發流程管理複雜;
- 業務人員是分類的,沒有誰會用到所有的功能,每個業務人員只擁有其中30%甚至更少的功能。但不得不載入所有業務程式碼,才能看到自己想要的頁面;
所以和市面上很多前端團隊引入微前端的目的不同的是,我們是拆
,而更多的團隊是合
。所以本方案適合和我目的一致的前端團隊,將自己維護的巨嬰系統
瓦解,然後通過微前端"框架"來聚合,降低專案管理難度,提升開發體驗與業務使用體驗。
巨嬰系統技術棧: Dva + Antd
方案參考美團一篇文章:微前端在美團外賣的實踐
在做這個專案的按需提前載入設計時,自己去深究過webpack構建出的專案程式碼執行邏輯,收穫比較多:webpack 打包的程式碼怎麼在瀏覽器跑起來的?, 不瞭解的可以看看
方案設計
基於業務角色,我們將巨嬰系統拆成了一個基座系統和四個子系統(可以按需擴充套件子系統),如下圖所示:
基座系統
除了提供基座功能,即系統的登入、許可權獲取、子系統的載入、公共元件共享、公共庫的共享,還提供了一個基本所有業務人員都會使用的業務功能:使用者授(guan)信(li)。
子系統
以靜態資源的方式,提供一個註冊函式,函式返回值是一個Switch包裹的元件與子系統所有的models。
路由設計
子系統以元件的形式載入到基座系統中,所以路由是入口,也是整個設計的第一步,為了區分基座系統頁面和子系統頁面,在路由上約定了下面這種形式:
// 子系統路由匹配,虛擬碼
function Layout(layoutProps) {
useEffect(() => {
const apps = getIncludeSubAppMap();
// 按需載入子專案;
apps.forEach(subKey => startAsyncSubapp(subKey));
}, []);
return (
<HLayout {...props}>
<Switch>
{/* 企業使用者管理 */}
<Route exact path={Paths.PRODUCT_WHITEBAR} component={pages.ProductManage} breadcrumbName="企業使用者管理" />
{/* ...省略一百行 */}
<Route path="/subPage/" component={pages.AsyncComponent} />
</Switch>
</HLayout>
}
即只要以subPage路徑開頭,就預設這個路由對應的元件為子專案,從而通過AsyncComponent
元件去非同步獲取子專案元件。
非同步載入元件設計
路由設計完了,然後非同步載入元件就是這個方案的靈魂了,流程是這樣的:
- 通過路由,匹配到要訪問的具體是那個子專案;
- 通過子專案id,獲取對應的manifest.json檔案;
- 通過獲取manifest.json,識別到對應的靜態資源(js,css)
- 載入靜態資源,載入完,子專案執行註冊
- 動態載入model,更新子專案元件
直接上程式碼吧,簡單明瞭,資源載入的邏輯後面再詳講,需要注意的是model和component的載入順序
:
export default function AsyncComponent({ location }) {
// 子工程資源是否載入完成
const [ayncLoading, setAyncLoaded] = useState(true);
// 子工程元件載入存取
const [ayncComponent, setAyncComponent] = useState(null);
const { pathname } = location;
// 取路徑中標識子工程字首的部分, 例如 '/subPage/xxx/home' 其中xxx即子系統路由標識
const id = pathname.split('/')[2];
useEffect(() => {
if (!subAppMapInfo[id]) {
// 不存在這個子系統,直接重定向到首頁去
goBackToIndex();
}
const status = subAppRegisterStatus[id];
if (status !== 'finish') {
// 載入子專案
loadAsyncSubapp(id).then(({ routes, models }) => {
loadModule(id, models);
setAyncComponent(routes);
setAyncLoaded(false);
// 已經載入過的,做個標記
subAppRegisterStatus[id] = 'finish';
}).catch((error = {}) => {
// 如果載入失敗,顯示錯誤資訊
setAyncLoaded(false);
setAyncComponent(
<div style={{
margin: '100px auto',
textAlign: 'center',
color: 'red',
fontSize: '20px'
}}
>
{error.message || '載入失敗'}
</div>);
});
} else {
const models = subappModels[id];
loadModule(id, models);
// 如果能匹配上字首則載入相應子工程模組
setAyncLoaded(false);
setAyncComponent(subappRoutes[id]);
}
}, [id]);
return (
<Spin spinning={ayncLoading} style={{ width: '100%', minHeight: '100%' }}>
{ayncComponent}
</Spin>
);
}
子專案設計
子專案以靜態資源的形式在基座專案中載入,需要暴露出子系統自己的全部頁面元件和資料model;然後在打包構建上和以前也稍許不同,需要多生成一個manifest.json來蒐集子專案的靜態資源資訊。
子專案暴露出自己自願的程式碼長這樣:
// 子專案資源輸出程式碼
import routes from './layouts';
const models = {};
function importAll(r) {
r.keys().forEach(key => models[key] = r(key).default);
}
// 蒐集所有頁面的model
importAll(require.context('./pages', true, /model\.js$/));
function registerApp(dep) {
return {
routes, // 子工程路由元件
models, // 子工程資料模型集合
};
}
// 陣列第一個引數為子專案id,第二個引數為子專案模組獲取函式
(window["registerApp"] = window["registerApp"] || []).push(['collection', registerApp]);
子專案頁面元件蒐集:
import menus from 'configs/menus';
import { Switch, Redirect, Route } from 'react-router-dom';
import pages from 'pages';
function flattenMenu(menus) {
const result = [];
menus.forEach((menu) => {
if (menu.children) {
result.push(...flattenMenu(menu.children));
} else {
menu.Component = pages[menu.component];
result.push(menu);
}
});
return result;
}
// 子專案自己路徑分別 + /subpage/xxx
const prefixRoutes = flattenMenu(menus);
export default (
<Switch>
{prefixRoutes.map(child =>
<Route
exact
key={child.key}
path={child.path}
component={child.Component}
breadcrumbName={child.title}
/>
)}
<Redirect to="/home" />
</Switch>);
靜態資源載入邏輯設計
開始做方案時,只是設計出按需載入的互動體驗:即當業務切換到子專案路徑時,開始載入子專案的資源,然後渲染頁面。但後面感覺這種改動影響了業務體驗,他們以前只需要載入資料時loading,現在還需要承受子專案載入loading。所以為了讓業務儘量小的感知系統的重構,將按需載入
換成了按需提前載入
。簡單點說,就是當業務登入時,我們會去遍歷他的所有許可權選單,獲取他擁有那些子專案的訪問許可權,然後提前載入這些資源。
遍歷選單,提前載入子專案資源:
// 本地開發環境不提前按需載入
if (getDeployEnv() !== 'local') {
const apps = getIncludeAppMap();
// 按需提前載入子專案資源;
apps.forEach(subKey => startAsyncSubapp(subKey));
}
然後就是show程式碼的時候了,思路參考webpackJsonp
,就是通過攔截一個全域性陣列的push操作,得知子專案已載入完成:
import { subAppMapInfo } from './menus';
// 子專案靜態資源對映表存放:
/**
* 狀態定義:
* '': 還未載入
* ‘start’:靜態資源對映表已存在;
* ‘map’:靜態資源對映表已存在;
* 'init': 靜態資源已載入;
* 'wait': 資源載入已完成, 待注入;
* 'finish': 模組已注入;
*/
export const subAppRegisterStatus = {};
export const subappSourceInfo = {};
// 專案載入待處理的Promise hash 表
const defferPromiseMap = {};
// 專案載入待處理的錯誤 hash 表
const errorInfoMap = {};
// 載入css,js 資源
function loadSingleSource(url) {
// 此處省略了一寫程式碼
return new Promise((resolove, reject) => {
link.onload = () => {
resolove(true);
};
link.onerror = () => {
reject(false);
};
});
}
// 載入json中包含的所有靜態資源
async function loadSource(json) {
const keys = Object.keys(json);
const isOk = await Promise.all(keys.map(key => loadSingleSource(json[key])));
if (!isOk || isOk.filter(res => res === true) < keys.length) {
return false;
}
return true;
}
// 獲取子專案的json 資源資訊
async function getManifestJson(subKey) {
const url = subAppMapInfo[subKey];
if (subappSourceInfo[subKey]) {
return subappSourceInfo[subKey];
}
const json = await fetch(url).then(response => response.json())
.catch(() => false);
subAppRegisterStatus[subKey] = 'map';
return json;
}
// 子專案提前按需載入入口
export async function startAsyncSubapp(moduleName) {
subAppRegisterStatus[moduleName] = 'start'; // 開始載入
const json = await getManifestJson(moduleName);
const [, reject] = defferPromiseMap[moduleName] || [];
if (json === false) {
subAppRegisterStatus[moduleName] = 'error';
errorInfoMap[moduleName] = new Error(`模組:${moduleName}, manifest.json 載入錯誤`);
reject && reject(errorInfoMap[moduleName]);
return;
}
subAppRegisterStatus[moduleName] = 'map'; // json載入完畢
const isOk = await loadSource(json);
if (isOk) {
subAppRegisterStatus[moduleName] = 'init';
return;
}
errorInfoMap[moduleName] = new Error(`模組:${moduleName}, 靜態資源載入錯誤`);
reject && reject(errorInfoMap[moduleName]);
subAppRegisterStatus[moduleName] = 'error';
}
// 回撥處理
function checkDeps(moduleName) {
if (!defferPromiseMap[moduleName]) {
return;
}
// 存在待處理的,開始處理;
const [resolove, reject] = defferPromiseMap[moduleName];
const registerApp = subappSourceInfo[moduleName];
try {
const moduleExport = registerApp();
resolove(moduleExport);
} catch (e) {
reject(e);
} finally {
// 從待處理中清理掉
defferPromiseMap[moduleName] = null;
subAppRegisterStatus[moduleName] = 'finish';
}
}
// window.registerApp.push(['collection', registerApp])
// 這是子專案註冊的核心,靈感來源於webpack,即對window.registerApp的push操作進行攔截
export function initSubAppLoader() {
window.registerApp = [];
const originPush = window.registerApp.push.bind(window.registerApp);
// eslint-disable-next-line no-use-before-define
window.registerApp.push = registerPushCallback;
function registerPushCallback(module = []) {
const [moduleName, register] = module;
subappSourceInfo[moduleName] = register;
originPush(module);
checkDeps(moduleName);
}
}
// 按需提前載入入口
export function loadAsyncSubapp(moduleName) {
const subAppInfo = subAppRegisterStatus[moduleName];
// 錯誤處理優先
if (subAppInfo === 'error') {
const error = errorInfoMap[moduleName] || new Error(`模組:${moduleName}, 資源載入錯誤`);
return Promise.reject(error);
}
// 已經提前載入,等待注入
if (typeof subappSourceInfo[moduleName] === 'function') {
return Promise.resolve(subappSourceInfo[moduleName]());
}
// 還未載入的,就開始載入,已經開始載入的,直接返回
if (!subAppInfo) {
startAsyncSubapp(moduleName);
}
return new Promise((resolve, reject = (error) => { throw error; }) => {
// 加入待處理map中;
defferPromiseMap[moduleName] = [resolve, reject];
});
}
這裡需要強調一下子專案有兩種載入場景:
- 從基座頁面路徑進入系統, 那麼就是
按需提前載入
的場景, 那麼startAsyncSubapp先執行,提前快取資源; - 從子專案頁面路徑進入系統, 那就是
按需載入
的場景,就存在loadAsyncSubapp先執行,利用Promise完成釋出訂閱。至於為什麼startAsyncSubapp在前但後執行,是因為useEffect是元件掛載完成才執行;
至此,框架的大致邏輯就交代清楚了,剩下的就是優化了。
其他難點
其實不難,只是怪我太菜,但這些點確實值得記錄,分享出來共勉。
公共依賴共享
我們由於基座專案與子專案技術棧一致,另外又是拆分系統,所以共享公共庫依賴,優化打包是一個特別重要的點,以為就是webpack配個external就完事,但其實要複雜的多。
antd 構建
antd 3.x就支援了esm,即按需引入,但由於我們構建工具沒有做相應升級,用了babel-plugin-import這個外掛,所以導致了兩個問題,打包冗餘與無法全量匯出antd Modules。分開來講:
- 打包冗餘,就是通過BundleAnalyzer外掛發現,一個模組即打了commonJs程式碼,也打了Esm程式碼;
- 無法全量匯出,因為基座專案不知道子專案會具體用哪個模組,所以只能暴力的匯出Antd所有模組,但babel-plugin-import這個外掛有個優化,會分析引入,然後刪除沒用的依賴,但我們的需求和它的目的是衝突的;
結論:使用babel-plugin-import這個外掛打包commonJs程式碼已經過時, 其存在的唯一價值就是還可以幫我們按需引入css 程式碼;
專案公共元件共享
專案中公共元件的共享,我們開始嘗試將常用的元件加入公司元件庫來解決,但發現這個方案並不是最理想的,第一:很多元件和業務場景強相關,加入公共元件庫,會造成元件庫臃腫;第二:沒有必要。所以我們最後還是採用了基座專案收集元件,並統一暴露:
function combineCommonComponent() {
const contexts = require.context('./components/common', true, /\.js$/);
return contexts.keys().reduce((next, key) => {
// 合併components/common下的元件
const compName = key.match(/\w+(?=\/index\.js)/)[0];
next[compName] = contexts(key).default;
return next;
}, {});
}
webpackJsonp 全域性變數汙染
如果對webpack構建後的程式碼不熟悉,可以先看看開篇提到的那篇文章。
webpack構建時,在開發環境modules是一個物件,採用檔案path作為module的key; 而正式環境,modules是一個陣列,會採用index作為module的key。
由於我基座專案和子專案沒有做沙箱隔離,即window被公用,所以存在webpackJsonp全域性變數汙染的情況,在開發環境,這個汙染沒有被暴露,因為檔案Key是唯一的,但在打正式包時,發現qa 環境子專案無法載入,最後一分析,發現了window.webpackJsonp 環境變數汙染的bug。
最後解決的方案就是子專案打包都擁有自己獨立的webpackJsonp
變數,即將webpackJsonp重新命名,寫了一個簡單的webpack外掛搞定:
// 將webpackJsonp 重新命名為 webpackJsonpCollect
config.plugins.push(new RenameWebpack({ replace: 'webpackJsonpCollect' }));
子專案開發熱載入
基座專案為什麼會成為基座,就因為他迭代少且穩定的特殊性。但開發時,由於子專案無法獨立執行,所以需要依賴基座專案聯調。但做一個需求,要開啟兩個vscode,同時執行兩個專案,對於那個開發,這都是一個不好的開發體驗,所以我們希望將dev環境作為基座,來支援本地的開發聯調,這才是最好的體驗。
將dev環境的構建引數改成開發環境後,發現子專案能線上上基座專案執行,但webSocket通訊一直失敗,最後找到原因是webpack-dev-sever有個host check邏輯,稱為主機檢查,是一個安全選項,我們這裡是可以確認的,所以直接註釋就行。
總結
這篇文章,本身就是個總結。如果有什麼疑惑或更好的建議,歡迎一起討論,issues地址。