微前端
是当下的前端热词,稍具规模的团队都会去作技术探索,做为一个不甘落后的团队,咱们也去作了。也许你看过了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地址。