微前端
是當下的前端熱詞,稍具規模的團隊都會去作技術探索,做爲一個不甘落後的團隊,咱們也去作了。也許你看過了Single-Spa
,qiankun
這些業界成熟方案,很是強大:JS沙箱隔離、多棧支持、子應用並行、子應用嵌套,但仔細想一想它真的適合你嗎?css
對於我來講,過重了,概念太多,理解困難。先說一下背景,咱們之因此要對我司的小貸管理後臺作微前端改造,主要基於如下幾個述求:前端
因此和市面上不少前端團隊引入微前端的目的不一樣的是,咱們是拆
,而更多的團隊是合
。因此本方案適合和我目的一致的前端團隊,將本身維護的巨嬰系統
瓦解,而後經過微前端"框架"來聚合,下降項目管理難度,提高開發體驗與業務使用體驗。react
巨嬰系統技術棧: Dva + Antdwebpack
方案參考美團一篇文章:微前端在美團外賣的實踐 git
在作這個項目的按需提早加載設計時,本身去深究過webpack構建出的項目代碼運行邏輯,收穫比較多:webpack 打包的代碼怎麼在瀏覽器跑起來的?, 不瞭解的能夠看看github
基於業務角色,咱們將巨嬰系統拆成了一個基座系統和四個子系統(能夠按需擴展子系統),以下圖所示:web
基座系統
除了提供基座功能,即系統的登陸、權限獲取、子系統的加載、公共組件共享、公共庫的共享,還提供了一個基本全部業務人員都會使用的業務功能:用戶授(guan)信(li)。json
子系統
以靜態資源的方式,提供一個註冊函數,函數返回值是一個Switch包裹的組件與子系統全部的models。segmentfault
子系統以組件的形式加載到基座系統中,因此路由是入口,也是整個設計的第一步,爲了區分基座系統頁面和子系統頁面,在路由上約定了下面這種形式:數組
// 子系統路由匹配,僞代碼 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
組件去異步獲取子項目組件。
路由設計完了,而後異步加載組件就是這個方案的靈魂了,流程是這樣的:
直接上代碼吧,簡單明瞭,資源加載的邏輯後面再詳講,須要注意的是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 3.x就支持了esm,即按需引入,但因爲咱們構建工具沒有作相應升級,用了babel-plugin-import這個插件,因此致使了兩個問題,打包冗餘與沒法全量導出antd Modules。分開來說:
結論:使用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; }, {}); }
若是對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地址。