也許這才是你想要的微前端方案
前言
微前端是當(dāng)下的前端熱詞,稍具規(guī)模的團隊都會去做技術(shù)探索,作為一個不甘落后的團隊,我們也去做了。也許你看過了Single-Spa,qiankun這些業(yè)界成熟方案,非常強大:JS沙箱隔離、多棧支持、子應(yīng)用并行、子應(yīng)用嵌套,但仔細想想它真的適合你嗎?
對于我來說,太重了,概念太多,理解困難。先說一下背景,我們之所以要對我司的小貸管理后臺做微前端改造,主要基于以下幾個述求:
- 系統(tǒng)從接手時差不多30個頁面,一年多時間,發(fā)展到目前150多個頁面,并還在持續(xù)增長;
- 項目體積變大,帶來開發(fā)體驗很差,打包構(gòu)建速度很慢(初次構(gòu)建,1分鐘以上);
- 小貸系統(tǒng)開發(fā)量占整個web組50%的人力,每個迭代都有兩三個需求在這一個系統(tǒng)上開發(fā),代碼合并沖突,上線時間交叉。帶來的是開發(fā)流程管理復(fù)雜;
- 業(yè)務(wù)人員是分類的,沒有誰會用到所有的功能,每個業(yè)務(wù)人員只擁有其中30%甚至更少的功能。但不得不加載所有業(yè)務(wù)代碼,才能看到自己想要的頁面;
所以和市面上很多前端團隊引入微前端的目的不同的是,我們是拆,而更多的團隊是合。所以本方案適合和我目的一致的前端團隊,將自己維護的巨嬰系統(tǒng)瓦解,然后通過微前端"框架"來聚合,降低項目管理難度,提升開發(fā)體驗與業(yè)務(wù)使用體驗。
巨嬰系統(tǒng)技術(shù)棧: Dva + Antd
方案參考美團一篇文章:微前端在美團外賣的實踐
在做這個項目的按需提前加載設(shè)計時,自己去深究過webpack構(gòu)建出的項目代碼運行邏輯,收獲比較多:webpack 打包的代碼怎么在瀏覽器跑起來的?, 不了解的可以看看
方案設(shè)計
基于業(yè)務(wù)角色,我們將巨嬰系統(tǒng)拆成了一個基座系統(tǒng)和四個子系統(tǒng)(可以按需擴展子系統(tǒng)),如下圖所示:
基座系統(tǒng)除了提供基座功能,即系統(tǒng)的登錄、權(quán)限獲取、子系統(tǒng)的加載、公共組件共享、公共庫的共享,還提供了一個基本所有業(yè)務(wù)人員都會使用的業(yè)務(wù)功能:用戶授(guan)信(li)。
子系統(tǒng)以靜態(tài)資源的方式,提供一個注冊函數(shù),函數(shù)返回值是一個Switch包裹的組件與子系統(tǒng)所有的models。
路由設(shè)計
子系統(tǒng)以組件的形式加載到基座系統(tǒng)中,所以路由是入口,也是整個設(shè)計的第一步,為了區(qū)分基座系統(tǒng)頁面和子系統(tǒng)頁面,在路由上約定了下面這種形式:
- // 子系統(tǒng)路由匹配,偽代碼
- function Layout(layoutProps) {
- useEffect(() => {
- const apps = getIncludeSubAppMap();
- // 按需加載子項目;
- apps.forEach(subKey => startAsyncSubapp(subKey));
- }, []);
- return (
- <HLayout {...props}>
- <Switch>
- {/* 企業(yè)用戶管理 */}
- <Route exact path={Paths.PRODUCT_WHITEBAR} component={pages.ProductManage} breadcrumbName="企業(yè)用戶管理" />
- {/* ...省略一百行 */}
- <Route path="/subPage/" component={pages.AsyncComponent} />
- </Switch>
- </HLayout>
- }
即只要以subPage路徑開頭,就默認(rèn)這個路由對應(yīng)的組件為子項目,從而通過AsyncComponent組件去異步獲取子項目組件。
異步加載組件設(shè)計
路由設(shè)計完了,然后異步加載組件就是這個方案的靈魂了,流程是這樣的:
- 通過路由,匹配到要訪問的具體是那個子項目;
- 通過子項目id,獲取對應(yīng)的manifest.json文件;
- 通過獲取manifest.json,識別到對應(yīng)的靜態(tài)資源(js,css)
- 加載靜態(tài)資源,加載完,子項目執(zhí)行注冊
- 動態(tài)加載model,更新子項目組件
直接上代碼吧,簡單明了,資源加載的邏輯后面再詳講,需要注意的是model和component的加載順序:
- export default function AsyncComponent({ location }) {
- // 子工程資源是否加載完成
- const [ayncLoading, setAyncLoaded] = useState(true);
- // 子工程組件加載存取
- const [ayncComponent, setAyncComponent] = useState(null);
- const { pathname } = location;
- // 取路徑中標(biāo)識子工程前綴的部分, 例如 '/subPage/xxx/home' 其中xxx即子系統(tǒng)路由標(biāo)識
- const id = pathname.split('/')[2];
- useEffect(() => {
- if (!subAppMapInfo[id]) {
- // 不存在這個子系統(tǒng),直接重定向到首頁去
- goBackToIndex();
- }
- const status = subAppRegisterStatus[id];
- if (status !== 'finish') {
- // 加載子項目
- loadAsyncSubapp(id).then(({ routes, models }) => {
- loadModule(id, models);
- setAyncComponent(routes);
- setAyncLoaded(false);
- // 已經(jīng)加載過的,做個標(biāo)記
- 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);
- // 如果能匹配上前綴則加載相應(yīng)子工程模塊
- setAyncLoaded(false);
- setAyncComponent(subappRoutes[id]);
- }
- }, [id]);
- return (
- <Spin spinning={ayncLoading} style={{ width: '100%', minHeight: '100%' }}>
- {ayncComponent}
- </Spin>
- );
- }
子項目設(shè)計
子項目以靜態(tài)資源的形式在基座項目中加載,需要暴露出子系統(tǒng)自己的全部頁面組件和數(shù)據(jù)model;然后在打包構(gòu)建上和以前也稍許不同,需要多生成一個manifest.json來搜集子項目的靜態(tài)資源信息。
子項目暴露出自己自愿的代碼長這樣:
- // 子項目資源輸出代碼
- 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, // 子工程數(shù)據(jù)模型集合
- };
- }
- // 數(shù)組第一個參數(shù)為子項目id,第二個參數(shù)為子項目模塊獲取函數(shù)
- (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>);
靜態(tài)資源加載邏輯設(shè)計
開始做方案時,只是設(shè)計出按需加載的交互體驗:即當(dāng)業(yè)務(wù)切換到子項目路徑時,開始加載子項目的資源,然后渲染頁面。但后面感覺這種改動影響了業(yè)務(wù)體驗,他們以前只需要加載數(shù)據(jù)時loading,現(xiàn)在還需要承受子項目加載loading。所以為了讓業(yè)務(wù)盡量小的感知系統(tǒng)的重構(gòu),將按需加載換成了按需提前加載。簡單點說,就是當(dāng)業(yè)務(wù)登錄時,我們會去遍歷他的所有權(quán)限菜單,獲取他擁有那些子項目的訪問權(quán)限,然后提前加載這些資源。
遍歷菜單,提前加載子項目資源:
- // 本地開發(fā)環(huán)境不提前按需加載
- if (getDeployEnv() !== 'local') {
- const apps = getIncludeAppMap();
- // 按需提前加載子項目資源;
- apps.forEach(subKey => startAsyncSubapp(subKey));
- }
然后就是show代碼的時候了,思路參考webpackJsonp,就是通過攔截一個全局?jǐn)?shù)組的push操作,得知子項目已加載完成:
- import { subAppMapInfo } from './menus';
- // 子項目靜態(tài)資源映射表存放:
- /**
- * 狀態(tài)定義:
- * '': 還未加載
- * ‘start’:靜態(tài)資源映射表已存在;
- * ‘map’:靜態(tài)資源映射表已存在;
- * 'init': 靜態(tài)資源已加載;
- * '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中包含的所有靜態(tài)資源
- 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}, 靜態(tài)資源加載錯誤`);
- reject && reject(errorInfoMap[moduleName]);
- subAppRegisterStatus[moduleName] = 'error';
- }
- // 回調(diào)處理
- 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];
- // 錯誤處理優(yōu)先
- if (subAppInfo === 'error') {
- const error = errorInfoMap[moduleName] || new Error(`模塊:${moduleName}, 資源加載錯誤`);
- return Promise.reject(error);
- }
- // 已經(jīng)提前加載,等待注入
- if (typeof subappSourceInfo[moduleName] === 'function') {
- return Promise.resolve(subappSourceInfo[moduleName]());
- }
- // 還未加載的,就開始加載,已經(jīng)開始加載的,直接返回
- if (!subAppInfo) {
- startAsyncSubapp(moduleName);
- }
- return new Promise((resolve, reject = (error) => { throw error; }) => {
- // 加入待處理map中;
- defferPromiseMap[moduleName] = [resolve, reject];
- });
- }
這里需要強調(diào)一下子項目有兩種加載場景:
- 從基座頁面路徑進入系統(tǒng), 那么就是按需提前加載的場景, 那么startAsyncSubapp先執(zhí)行,提前緩存資源;
- 從子項目頁面路徑進入系統(tǒng), 那就是按需加載的場景,就存在loadAsyncSubapp先執(zhí)行,利用Promise完成發(fā)布訂閱。至于為什么startAsyncSubapp在前但后執(zhí)行,是因為useEffect是組件掛載完成才執(zhí)行;
至此,框架的大致邏輯就交代清楚了,剩下的就是優(yōu)化了。
其他難點
其實不難,只是怪我太菜,但這些點確實值得記錄,分享出來共勉。
公共依賴共享
我們由于基座項目與子項目技術(shù)棧一致,另外又是拆分系統(tǒng),所以共享公共庫依賴,優(yōu)化打包是一個特別重要的點,以為就是webpack配個external就完事,但其實要復(fù)雜的多。
antd 構(gòu)建
antd 3.x就支持了esm,即按需引入,但由于我們構(gòu)建工具沒有做相應(yīng)升級,用了babel-plugin-import這個插件,所以導(dǎo)致了兩個問題,打包冗余與無法全量導(dǎo)出antd Modules。分開來講:
- 打包冗余,就是通過BundleAnalyzer插件發(fā)現(xiàn),一個模塊即打了commonJs代碼,也打了Esm代碼;
- 無法全量導(dǎo)出,因為基座項目不知道子項目會具體用哪個模塊,所以只能暴力的導(dǎo)出Antd所有模塊,但babel-plugin-import這個插件有個優(yōu)化,會分析引入,然后刪除沒用的依賴,但我們的需求和它的目的是沖突的;
結(jié)論:使用babel-plugin-import這個插件打包commonJs代碼已經(jīng)過時, 其存在的唯一價值就是還可以幫我們按需引入css 代碼;
項目公共組件共享
項目中公共組件的共享,我們開始嘗試將常用的組件加入公司組件庫來解決,但發(fā)現(xiàn)這個方案并不是最理想的,第一:很多組件和業(yè)務(wù)場景強相關(guān),加入公共組件庫,會造成組件庫臃腫;第二:沒有必要。所以我們最后還是采用了基座項目收集組件,并統(tǒng)一暴露:
- 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構(gòu)建后的代碼不熟悉,可以先看看開篇提到的那篇文章。
webpack構(gòu)建時,在開發(fā)環(huán)境modules是一個對象,采用文件path作為module的key; 而正式環(huán)境,modules是一個數(shù)組,會采用index作為module的key。
由于我基座項目和子項目沒有做沙箱隔離,即window被公用,所以存在webpackJsonp全局變量污染的情況,在開發(fā)環(huán)境,這個污染沒有被暴露,因為文件Key是唯一的,但在打正式包時,發(fā)現(xiàn)qa 環(huán)境子項目無法加載,最后一分析,發(fā)現(xiàn)了window.webpackJsonp 環(huán)境變量污染的bug。
最后解決的方案就是子項目打包都擁有自己獨立的webpackJsonp變量,即將webpackJsonp重命名,寫了一個簡單的webpack插件搞定:
- // 將webpackJsonp 重命名為 webpackJsonpCollect
- config.plugins.push(new RenameWebpack({ replace: 'webpackJsonpCollect' }));
子項目開發(fā)熱加載
基座項目為什么會成為基座,就因為他迭代少且穩(wěn)定的特殊性。但開發(fā)時,由于子項目無法獨立運行,所以需要依賴基座項目聯(lián)調(diào)。但做一個需求,要打開兩個vscode,同時運行兩個項目,對于那個開發(fā),這都是一個不好的開發(fā)體驗,所以我們希望將dev環(huán)境作為基座,來支持本地的開發(fā)聯(lián)調(diào),這才是最好的體驗。
將dev環(huán)境的構(gòu)建參數(shù)改成開發(fā)環(huán)境后,發(fā)現(xiàn)子項目能在線上基座項目運行,但webSocket通信一直失敗,最后找到原因是webpack-dev-sever有個host check邏輯,稱為主機檢查,是一個安全選項,我們這里是可以確認(rèn)的,所以直接注釋就行。