We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
微前端是当下的前端热词,稍具规模的团队都会去做技术探索,作为一个不甘落后的团队,我们也去做了。也许你看过了Single-Spa,qiankun这些业界成熟方案,非常强大:JS沙箱隔离、多栈支持、子应用并行、子应用嵌套,但仔细想想它真的适合你吗?
微前端
Single-Spa
qiankun
对于我来说,太重了,概念太多,理解困难。先说一下背景,我们之所以要对我司的小贷管理后台做微前端改造,主要基于以下几个述求:
所以和市面上很多前端团队引入微前端的目的不同的是,我们是拆,而更多的团队是合。所以本方案适合和我目的一致的前端团队,将自己维护的巨婴系统瓦解,然后通过微前端"框架"来聚合,降低项目管理难度,提升开发体验与业务使用体验。
拆
合
巨婴系统
巨婴系统技术栈: 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组件去异步获取子项目组件。
AsyncComponent
路由设计完了,然后异步加载组件就是这个方案的灵魂了,流程是这样的:
直接上代码吧,简单明了,资源加载的逻辑后面再详讲,需要注意的是model和component的加载顺序:
需要注意的是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操作,得知子项目已加载完成:
webpackJsonp
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]; }); }
这里需要强调一下子项目有两种加载场景:
至此,框架的大致逻辑就交代清楚了,剩下的就是优化了。
其实不难,只是怪我太菜,但这些点确实值得记录,分享出来共勉。
我们由于基座项目与子项目技术栈一致,另外又是拆分系统,所以共享公共库依赖,优化打包是一个特别重要的点,以为就是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逻辑,称为主机检查,是一个安全选项,我们这里是可以确认的,所以直接注释就行。
这篇文章,本身就是个总结。如果有什么疑惑,欢迎一起讨论。
The text was updated successfully, but these errors were encountered:
子项目如何打包可否分享下?
Sorry, something went wrong.
子项目其实和一般的项目,打包没什么异样,主要三点变动:
其实上面都已经提到了,只是由于项目是公司的,自己没额外写demo,所以没给demo项目;
No branches or pull requests
前言
微前端
是当下的前端热词,稍具规模的团队都会去做技术探索,作为一个不甘落后的团队,我们也去做了。也许你看过了Single-Spa
,qiankun
这些业界成熟方案,非常强大:JS沙箱隔离、多栈支持、子应用并行、子应用嵌套,但仔细想想它真的适合你吗?对于我来说,太重了,概念太多,理解困难。先说一下背景,我们之所以要对我司的小贷管理后台做微前端改造,主要基于以下几个述求:
所以和市面上很多前端团队引入微前端的目的不同的是,我们是
拆
,而更多的团队是合
。所以本方案适合和我目的一致的前端团队,将自己维护的巨婴系统
瓦解,然后通过微前端"框架"来聚合,降低项目管理难度,提升开发体验与业务使用体验。巨婴系统技术栈: Dva + Antd
方案参考美团一篇文章:微前端在美团外卖的实践
在做这个项目的按需提前加载设计时,自己去深究过webpack构建出的项目代码运行逻辑,收获比较多:webpack 打包的代码怎么在浏览器跑起来的?, 不了解的可以看看
方案设计
基于业务角色,我们将巨婴系统拆成了一个基座系统和四个子系统(可以按需扩展子系统),如下图所示:
基座系统
除了提供基座功能,即系统的登录、权限获取、子系统的加载、公共组件共享、公共库的共享,还提供了一个基本所有业务人员都会使用的业务功能:用户授(guan)信(li)。子系统
以静态资源的方式,提供一个注册函数,函数返回值是一个Switch包裹的组件与子系统所有的models。路由设计
子系统以组件的形式加载到基座系统中,所以路由是入口,也是整个设计的第一步,为了区分基座系统页面和子系统页面,在路由上约定了下面这种形式:
即只要以subPage路径开头,就默认这个路由对应的组件为子项目,从而通过
AsyncComponent
组件去异步获取子项目组件。异步加载组件设计
路由设计完了,然后异步加载组件就是这个方案的灵魂了,流程是这样的:
直接上代码吧,简单明了,资源加载的逻辑后面再详讲,
需要注意的是model和component的加载顺序
:子项目设计
子项目以静态资源的形式在基座项目中加载,需要暴露出子系统自己的全部页面组件和数据model;然后在打包构建上和以前也稍许不同,需要多生成一个manifest.json来搜集子项目的静态资源信息。
子项目暴露出自己自愿的代码长这样:
子项目页面组件搜集:
静态资源加载逻辑设计
开始做方案时,只是设计出按需加载的交互体验:即当业务切换到子项目路径时,开始加载子项目的资源,然后渲染页面。但后面感觉这种改动影响了业务体验,他们以前只需要加载数据时loading,现在还需要承受子项目加载loading。所以为了让业务尽量小的感知系统的重构,将
按需加载
换成了按需提前加载
。简单点说,就是当业务登录时,我们会去遍历他的所有权限菜单,获取他拥有那些子项目的访问权限,然后提前加载这些资源。遍历菜单,提前加载子项目资源:
然后就是show代码的时候了,思路参考
webpackJsonp
,就是通过拦截一个全局数组的push操作,得知子项目已加载完成:这里需要强调一下子项目有两种加载场景:
按需提前加载
的场景, 那么startAsyncSubapp先执行,提前缓存资源;按需加载
的场景,就存在loadAsyncSubapp先执行,利用Promise完成发布订阅。至于为什么startAsyncSubapp在前但后执行,是因为useEffect是组件挂载完成才执行;至此,框架的大致逻辑就交代清楚了,剩下的就是优化了。
其他难点
其实不难,只是怪我太菜,但这些点确实值得记录,分享出来共勉。
公共依赖共享
我们由于基座项目与子项目技术栈一致,另外又是拆分系统,所以共享公共库依赖,优化打包是一个特别重要的点,以为就是webpack配个external就完事,但其实要复杂的多。
antd 构建
antd 3.x就支持了esm,即按需引入,但由于我们构建工具没有做相应升级,用了babel-plugin-import这个插件,所以导致了两个问题,打包冗余与无法全量导出antd Modules。分开来讲:
结论:使用babel-plugin-import这个插件打包commonJs代码已经过时, 其存在的唯一价值就是还可以帮我们按需引入css 代码;
项目公共组件共享
项目中公共组件的共享,我们开始尝试将常用的组件加入公司组件库来解决,但发现这个方案并不是最理想的,第一:很多组件和业务场景强相关,加入公共组件库,会造成组件库臃肿;第二:没有必要。所以我们最后还是采用了基座项目收集组件,并统一暴露:
webpackJsonp 全局变量污染
如果对webpack构建后的代码不熟悉,可以先看看开篇提到的那篇文章。
webpack构建时,在开发环境modules是一个对象,采用文件path作为module的key; 而正式环境,modules是一个数组,会采用index作为module的key。
由于我基座项目和子项目没有做沙箱隔离,即window被公用,所以存在webpackJsonp全局变量污染的情况,在开发环境,这个污染没有被暴露,因为文件Key是唯一的,但在打正式包时,发现qa 环境子项目无法加载,最后一分析,发现了window.webpackJsonp 环境变量污染的bug。
最后解决的方案就是子项目打包都拥有自己独立的
webpackJsonp
变量,即将webpackJsonp重命名,写了一个简单的webpack插件搞定:子项目开发热加载
基座项目为什么会成为基座,就因为他迭代少且稳定的特殊性。但开发时,由于子项目无法独立运行,所以需要依赖基座项目联调。但做一个需求,要打开两个vscode,同时运行两个项目,对于那个开发,这都是一个不好的开发体验,所以我们希望将dev环境作为基座,来支持本地的开发联调,这才是最好的体验。
将dev环境的构建参数改成开发环境后,发现子项目能在线上基座项目运行,但webSocket通信一直失败,最后找到原因是webpack-dev-sever有个host check逻辑,称为主机检查,是一个安全选项,我们这里是可以确认的,所以直接注释就行。
总结
这篇文章,本身就是个总结。如果有什么疑惑,欢迎一起讨论。
The text was updated successfully, but these errors were encountered: