也许这才是你想要的微前端方案

前言

微前端是当下的前端热词,稍具规模的团队都会去做技术探索,作为一个不甘落后的团队,我们也去做了。也许你看过了Single-Spa,qiankun这些业界成熟方案,非常强大:JS沙箱隔离、多栈支持、子应用并行、子应用嵌套,但仔细想想它真的适合你吗?

对于我来说,太重了,概念太多,理解困难。先说一下背景,我们之所以要对我司的小贷管理后台做微前端改造,主要基于以下几个述求:

  •  系统从接手时差不多30个页面,一年多时间,发展到目前150多个页面,并还在持续增长;
  •  项目体积变大,带来开发体验很差,打包构建速度很慢(初次构建,1分钟以上);
  •  小贷系统开发量占整个web组50%的人力,每个迭代都有两三个需求在这一个系统上开发,代码合并冲突,上线时间交叉。带来的是开发流程管理复杂;
  •  业务人员是分类的,没有谁会用到所有的功能,每个业务人员只拥有其中30%甚至更少的功能。但不得不加载所有业务代码,才能看到自己想要的页面;

所以和市面上很多前端团队引入微前端的目的不同的是,我们是拆,而更多的团队是合。所以本方案适合和我目的一致的前端团队,将自己维护的巨婴系统瓦解,然后通过微前端"框架"来聚合,降低项目管理难度,提升开发体验与业务使用体验。

巨婴系统技术栈: Dva + Antd

方案参考美团一篇文章:微前端在美团外卖的实践

在做这个项目的按需提前加载设计时,自己去深究过webpack构建出的项目代码运行逻辑,收获比较多:webpack 打包的代码怎么在浏览器跑起来的?, 不了解的可以看看

方案设计

基于业务角色,我们将巨婴系统拆成了一个基座系统和四个子系统(可以按需扩展子系统),如下图所示:

基座系统除了提供基座功能,即系统的登录、权限获取、子系统的加载、公共组件共享、公共库的共享,还提供了一个基本所有业务人员都会使用的业务功能:用户授(guan)信(li)。

子系统以静态资源的方式,提供一个注册函数,函数返回值是一个Switch包裹的组件与子系统所有的models。

路由设计

子系统以组件的形式加载到基座系统中,所以路由是入口,也是整个设计的第一步,为了区分基座系统页面和子系统页面,在路由上约定了下面这种形式:

 
 
 
 
  1. // 子系统路由匹配,伪代码  
  2. function Layout(layoutProps) {  
  3.   useEffect(() => {  
  4.       const apps = getIncludeSubAppMap();  
  5.       // 按需加载子项目;  
  6.       apps.forEach(subKey => startAsyncSubapp(subKey));  
  7.   }, []); 
  8.   return (  
  9.       
  10.         
  11.           {/* 企业用户管理 */}  
  12.             
  13.           {/* ...省略一百行 */}  
  14.             
  15.         
  16.       

即只要以subPage路径开头,就默认这个路由对应的组件为子项目,从而通过AsyncComponent组件去异步获取子项目组件。

异步加载组件设计

路由设计完了,然后异步加载组件就是这个方案的灵魂了,流程是这样的:

  •  通过路由,匹配到要访问的具体是那个子项目;
  •  通过子项目id,获取对应的manifest.json文件;
  •  通过获取manifest.json,识别到对应的静态资源(js,css)
  •  加载静态资源,加载完,子项目执行注册
  •  动态加载model,更新子项目组件

直接上代码吧,简单明了,资源加载的逻辑后面再详讲,需要注意的是model和component的加载顺序:

 
 
 
 
  1. export default function AsyncComponent({ location }) {  
  2.   // 子工程资源是否加载完成  
  3.   const [ayncLoading, setAyncLoaded] = useState(true);  
  4.   // 子工程组件加载存取  
  5.   const [ayncComponent, setAyncComponent] = useState(null);  
  6.   const { pathname } = location;  
  7.   // 取路径中标识子工程前缀的部分, 例如 '/subPage/xxx/home' 其中xxx即子系统路由标识  
  8.   const id = pathname.split('/')[2];  
  9.   useEffect(() => {  
  10.     if (!subAppMapInfo[id]) {  
  11.       // 不存在这个子系统,直接重定向到首页去  
  12.       goBackToIndex();  
  13.     }  
  14.     const status = subAppRegisterStatus[id];  
  15.     if (status !== 'finish') {  
  16.       // 加载子项目  
  17.       loadAsyncSubapp(id).then(({ routes, models }) => {  
  18.         loadModule(id, models);  
  19.         setAyncComponent(routes);  
  20.         setAyncLoaded(false);  
  21.         // 已经加载过的,做个标记  
  22.         subAppRegisterStatus[id] = 'finish';  
  23.       }).catch((error = {}) => {  
  24.         // 如果加载失败,显示错误信息  
  25.         setAyncLoaded(false);  
  26.         setAyncComponent(  
  27.           
  28.             margin: '100px auto',  
  29.             textAlign: 'center',  
  30.             color: 'red',  
  31.             fontSize: '20px'  
  32.           }}  
  33.           >  
  34.             {error.message || '加载失败'}  
  35.           
);  
  •       });  
  •     } else {  
  •       const models = subappModels[id];  
  •       loadModule(id, models);  
  •       // 如果能匹配上前缀则加载相应子工程模块  
  •       setAyncLoaded(false);  
  •       setAyncComponent(subappRoutes[id]);  
  •     }  
  •   }, [id]);  
  •   return (  
  •       
  •       {ayncComponent}  
  •       
  •   );  
  • 子项目设计

    子项目以静态资源的形式在基座项目中加载,需要暴露出子系统自己的全部页面组件和数据model;然后在打包构建上和以前也稍许不同,需要多生成一个manifest.json来搜集子项目的静态资源信息。

    子项目暴露出自己自愿的代码长这样:

     
     
     
     
    1. // 子项目资源输出代码  
    2. import routes from './layouts';  
    3. const models = {};  
    4. function importAll(r) {  
    5.   r.keys().forEach(key => models[key] = r(key).default);  
    6. }  
    7. // 搜集所有页面的model  
    8. importAll(require.context('./pages', true, /model\.js$/));  
    9. function registerApp(dep) {  
    10.   return {  
    11.     routes, // 子工程路由组件  
    12.     models, // 子工程数据模型集合  
    13.   };  
    14. }  
    15. // 数组第一个参数为子项目id,第二个参数为子项目模块获取函数  
    16. (window["registerApp"] = window["registerApp"] || []).push(['collection', registerApp]); 

    子项目页面组件搜集:

     
     
     
     
    1. import menus from 'configs/menus';  
    2. import { Switch, Redirect, Route } from 'react-router-dom';  
    3. import pages from 'pages';  
    4. function flattenMenu(menus) {  
    5.   const result = [];  
    6.   menus.forEach((menu) => {  
    7.     if (menu.children) {  
    8.       result.push(...flattenMenu(menu.children));  
    9.     } else {  
    10.       menu.Component = pages[menu.component];  
    11.       result.push(menu);  
    12.     }  
    13.   });  
    14.   return result;  
    15. }  
    16. // 子项目自己路径分别 + /subpage/xxx   
    17. const prefixRoutes = flattenMenu(menus);  
    18. export default (  
    19.     
    20.     {prefixRoutes.map(child =>  
    21.       
    22.         exact  
    23.         key={child.key}  
    24.         path={child.path}  
    25.         component={child.Component}  
    26.         breadcrumbName={child.title}  
    27.       />  
    28.     )}  
    29.       
    30.   ); 

    静态资源加载逻辑设计

    开始做方案时,只是设计出按需加载的交互体验:即当业务切换到子项目路径时,开始加载子项目的资源,然后渲染页面。但后面感觉这种改动影响了业务体验,他们以前只需要加载数据时loading,现在还需要承受子项目加载loading。所以为了让业务尽量小的感知系统的重构,将按需加载换成了按需提前加载。简单点说,就是当业务登录时,我们会去遍历他的所有权限菜单,获取他拥有那些子项目的访问权限,然后提前加载这些资源。

    遍历菜单,提前加载子项目资源:

     
     
     
     
    1. // 本地开发环境不提前按需加载  
    2. if (getDeployEnv() !== 'local') {  
    3.   const apps = getIncludeAppMap();  
    4.   // 按需提前加载子项目资源;  
    5.   apps.forEach(subKey => startAsyncSubapp(subKey));  

    然后就是show代码的时候了,思路参考webpackJsonp,就是通过拦截一个全局数组的push操作,得知子项目已加载完成:

     
     
     
     
    1. import { subAppMapInfo } from './menus';  
    2. // 子项目静态资源映射表存放:  
    3. /**  
    4.  * 状态定义:  
    5.  * '': 还未加载  
    6.  * ‘start’:静态资源映射表已存在;  
    7.  * ‘map’:静态资源映射表已存在;  
    8.  * 'init': 静态资源已加载;  
    9.  * 'wait': 资源加载已完成, 待注入;  
    10.  * 'finish': 模块已注入;  
    11. */  
    12. export const subAppRegisterStatus = {};  
    13. export const subappSourceInfo = {};  
    14. // 项目加载待处理的Promise hash 表  
    15. const defferPromiseMap = {};  
    16. // 项目加载待处理的错误 hash 表  
    17. const errorInfoMap = {};  
    18. // 加载css,js 资源  
    19. function loadSingleSource(url) {  
    20.   // 此处省略了一写代码  
    21.   return new Promise((resolove, reject) => {  
    22.     link.onload = () => {  
    23.       resolove(true);  
    24.     };  
    25.     link.onerror = () => {  
    26.       reject(false);  
    27.     };  
    28.   });  
    29. }  
    30. // 加载json中包含的所有静态资源  
    31. async function loadSource(json) {  
    32.   const keys = Object.keys(json);  
    33.   const isOk = await Promise.all(keys.map(key => loadSingleSource(json[key])));  
    34.   if (!isOk || isOk.filter(res => res === true) < keys.length) {  
    35.     return false;  
    36.   }  
    37.   return true;  
    38. }  
    39. // 获取子项目的json 资源信息  
    40. async function getManifestJson(subKey) {  
    41.   const url = subAppMapInfo[subKey];  
    42.   if (subappSourceInfo[subKey]) {  
    43.     return subappSourceInfo[subKey];  
    44.   }  
    45.   const json = await fetch(url).then(response => response.json())  
    46.     .catch(() => false);  
    47.   subAppRegisterStatus[subKey] = 'map';  
    48.   return json;  
    49. }  
    50. // 子项目提前按需加载入口  
    51. export async function startAsyncSubapp(moduleName) {  
    52.   subAppRegisterStatus[moduleName] = 'start'; // 开始加载  
    53.   const json = await getManifestJson(moduleName);  
    54.   const [, reject] = defferPromiseMap[moduleName] || [];  
    55.   if (json === false) {  
    56.     subAppRegisterStatus[moduleName] = 'error';  
    57.     errorInfoMap[moduleName] = new Error(`模块:${moduleName}, manifest.json 加载错误`);  
    58.     reject && reject(errorInfoMap[moduleName]);  
    59.     return;  
    60.   }  
    61.   subAppRegisterStatus[moduleName] = 'map'; // json加载完毕  
    62.   const isOk = await loadSource(json);  
    63.   if (isOk) {  
    64.     subAppRegisterStatus[moduleName] = 'init';  
    65.     return;  
    66.   }  
    67.   errorInfoMap[moduleName] = new Error(`模块:${moduleName}, 静态资源加载错误`);  
    68.   reject && reject(errorInfoMap[moduleName]);  
    69.   subAppRegisterStatus[moduleName] = 'error';  
    70. }  
    71. // 回调处理  
    72. function checkDeps(moduleName) {  
    73.   if (!defferPromiseMap[moduleName]) {  
    74.     return;  
    75.   }  
    76.   // 存在待处理的,开始处理;  
    77.   const [resolove, reject] = defferPromiseMap[moduleName];  
    78.   const registerApp = subappSourceInfo[moduleName];  
    79.   try {  
    80.     const moduleExport = registerApp();  
    81.     resolove(moduleExport);  
    82.   } catch (e) {  
    83.     reject(e);  
    84.   } finally {  
    85.     // 从待处理中清理掉  
    86.     defferPromiseMap[moduleName] = null;  
    87.     subAppRegisterStatus[moduleName] = 'finish';  
    88.   }  
    89. }  
    90. // window.registerApp.push(['collection', registerApp])  
    91. // 这是子项目注册的核心,灵感来源于webpack,即对window.registerApp的push操作进行拦截  
    92. export function initSubAppLoader() {  
    93.   window.registerApp = [];  
    94.   const originPush = window.registerApp.push.bind(window.registerApp);  
    95.   // eslint-disable-next-line no-use-before-define  
    96.   window.registerApp.push = registerPushCallback;  
    97.   function registerPushCallback(module = []) {  
    98.     const [moduleName, register] = module;  
    99.     subappSourceInfo[moduleName] = register;  
    100.     originPush(module);  
    101.     checkDeps(moduleName);  
    102.   }  
    103. }  
    104. // 按需提前加载入口  
    105. export function loadAsyncSubapp(moduleName) {  
    106.   const subAppInfo = subAppRegisterStatus[moduleName];  
    107.   // 错误处理优先  
    108.   if (subAppInfo === 'error') {  
    109.     const error = errorInfoMap[moduleName] || new Error(`模块:${moduleName}, 资源加载错误`);  
    110.     return Promise.reject(error);  
    111.   }  
    112.   // 已经提前加载,等待注入  
    113.   if (typeof subappSourceInfo[moduleName] === 'function') {  
    114.     return Promise.resolve(subappSourceInfo[moduleName]());  
    115.   }  
    116.   // 还未加载的,就开始加载,已经开始加载的,直接返回  
    117.   if (!subAppInfo) {  
    118.     startAsyncSubapp(moduleName);  
    119.   }  
    120.   return new Promise((resolve, reject = (error) => { throw error; }) => {  
    121.     // 加入待处理map中;  
    122.     defferPromiseMap[moduleName] = [resolve, reject];  
    123.   });  

    这里需要强调一下子项目有两种加载场景:

    至此,框架的大致逻辑就交代清楚了,剩下的就是优化了。

    其他难点

    其实不难,只是怪我太菜,但这些点确实值得记录,分享出来共勉。

    公共依赖共享

    我们由于基座项目与子项目技术栈一致,另外又是拆分系统,所以共享公共库依赖,优化打包是一个特别重要的点,以为就是webpack配个external就完事,但其实要复杂的多。

    antd 构建

    antd 3.x就支持了esm,即按需引入,但由于我们构建工具没有做相应升级,用了babel-plugin-import这个插件,所以导致了两个问题,打包冗余与无法全量导出antd Modules。分开来讲:

    结论:使用babel-plugin-import这个插件打包commonJs代码已经过时, 其存在的唯一价值就是还可以帮我们按需引入css 代码;

    项目公共组件共享

    项目中公共组件的共享,我们开始尝试将常用的组件加入公司组件库来解决,但发现这个方案并不是最理想的,第一:很多组件和业务场景强相关,加入公共组件库,会造成组件库臃肿;第二:没有必要。所以我们最后还是采用了基座项目收集组件,并统一暴露:

     
     
     
     
    1. function combineCommonComponent() {  
    2.  const contexts = require.context('./components/common', true, /\.js$/);  
    3.  return contexts.keys().reduce((next, key) => {  
    4.    // 合并components/common下的组件  
    5.    const compName = key.match(/\w+(?=\/index\.js)/)[0];  
    6.    next[compName] = contexts(key).default;  
    7.    return next;  
    8.  }, {});  

    webpackJsonp 全局变量污染

    如果对webpack构建后的代码不熟悉,可以先看看开篇提到的那篇文章。

    webpack构建时,在开发环境modules是一个对象,采用文件path作为module的key; 而正式环境,modules是一个数组,会采用index作为module的key。

    由于我基座项目和子项目没有做沙箱隔离,即window被公用,所以存在webpackJsonp全局变量污染的情况,在开发环境,这个污染没有被暴露,因为文件Key是唯一的,但在打正式包时,发现qa 环境子项目无法加载,最后一分析,发现了window.webpackJsonp 环境变量污染的bug。

    最后解决的方案就是子项目打包都拥有自己独立的webpackJsonp变量,即将webpackJsonp重命名,写了一个简单的webpack插件搞定:

     
     
     
     
    1. // 将webpackJsonp 重命名为 webpackJsonpCollect  
    2. config.plugins.push(new RenameWebpack({ replace: 'webpackJsonpCollect' })); 

    子项目开发热加载

    基座项目为什么会成为基座,就因为他迭代少且稳定的特殊性。但开发时,由于子项目无法独立运行,所以需要依赖基座项目联调。但做一个需求,要打开两个vscode,同时运行两个项目,对于那个开发,这都是一个不好的开发体验,所以我们希望将dev环境作为基座,来支持本地的开发联调,这才是最好的体验。

    将dev环境的构建参数改成开发环境后,发现子项目能在线上基座项目运行,但webSocket通信一直失败,最后找到原因是webpack-dev-sever有个host check逻辑,称为主机检查,是一个安全选项,我们这里是可以确认的,所以直接注释就行。

    网页名称:也许这才是你想要的微前端方案
    分享网址:http://www.shufengxianlan.com/qtweb/news26/421976.html

    网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等

    广告

    声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联

    猜你还喜欢下面的内容

    品牌网站设计知识

    各行业网站