随着业务发展,我们的系统变得越来越庞大,给构建速度、静态资源大小以及应用性能带来了极大的挑战
一个系统是由众多小模块组成的,大部分用户都不会拥有所有模块的权限,所以我们的第一个优化方式就是 code split
,将每个小模块的代码分割出来,按需加载,也取得了一定的效果
然而,当系统数量越来越多时,用户开始抱怨入口太多,希望由统一的入口来完成所有的功能,这个场景有几种解决方案
合并所有系统到一个大系统中
做个应用框架,用 IFrame 嵌入目标系统
开发统一导航栏,替换各系统的导航栏,在导航栏中通过 <a>
标签实现系统切换
看上去
像在使用一个系统,若是用户切换的频率较高,则感受更强烈localStorage/sessionStorage
等浏览器存储采用微前端架构,对应用进行改造
再来梳理一下现状
所有系统都是基于内部的统一框架开发,拥有统一样式的顶部栏和侧边栏
所有系统都拥有自己的 Nodejs 层,用于页面渲染和 API 请求转发
所有系统都拥有不同的域名,没有特定的域名子路径
不同的系统有自己的小团队在开发,部分使用不同版本的 React 和 Ant Design
开发普遍要求未来新功能模块的开发可以使用与时俱进的技术
基于现状分析,微前端是一个可以去尝试的方向,于是便开始了踩坑之路,将现有的系统改造成为微前端的子应用
为了统一语言,现有的系统在下文称为子应用
我们使用 qiankun 来作为微前端的实现库,(据说)可以快速实现改造
qiankun 是基于 single-spa 封装的,其内部实现的子应用加载机制,是基于浏览器 url 来实现的,通过第一段子路径来决定要加载哪个子应用,比如
所以,为每个子应用改造使得所有的访问都增加子路径,是我们要做的第一步
为每个路由增加前缀,koa 的代码示例如下
// 直接访问根路径,转发增加路由前缀 router.get('/', controller.redirect); // 渲染页面,这里的 authMiddleware 是校验中间件,实现登陆校验逻辑 router.get('/appA/*', authMiddleware, controller.index); // 这里使用 ${子应用名} + '_apis' 来表示特定应用的 api 请求, // 方便在主应用中做区分进行转发,同时也方便 Nginx 配置转发(共享域名) router.use('/appA_apis/*', authMiddleware, controller.transfer); // 剩下的路由忽略 ... 复制代码
修改每个在页面上的 api 请求,使之匹配 ${子应用名} + '_apis'
这一步相对比较麻烦,现有的子应用在页面代码中都写了 /apis
的前缀,如果不是在统一的地方处理的,改动起来会非常麻烦。基于现状,我们用了一个取巧的方式:拦截所有 Ajax 请求,并根据需要修改其前缀。具体代码如下
(() => { if (!XMLHttpRequest.prototype['nativeOpen']) { XMLHttpRequest.prototype['nativeOpen'] = XMLHttpRequest.prototype.open; const customizeOpen = function(method, url, ...params) { if ( // 不需要修改前缀的请求,如果情况比较多,可以单独抽取出来 url.indexOf('hot-update.json') < 0 ) { // 将 /apis 前缀转化为 /appA_apis 前缀,这里是在框架里 // 处理成 routerPrefix 注入到 window 对象的 url = `${window['routerPrefix']}_${url.slice(1)}`; } this.nativeOpen(method, url, ...params); }; XMLHttpRequest.prototype.open = customizeOpen; } })(); 复制代码
修改静态文件的路径,修改原先的 /statics 路径,使之匹配 ${子应用名} + '_statics'
这一步大致就是 webpack 的配置了,主要是修改 output 和 publicPath 相关的配置,根据项目实际去操作即可,此处不再赘述
经过以上步骤,子应用已经可以支持子路径的访问了,但这里还少了一步比较关键的,它不影响你的改造,但是会影响你改造之后用户的正常访问。比如,用户在收藏夹中保存了你的系统某个页面的地址,例如 xxx.site.com/pages/user
,此时如果你进行了部署,则会导致用户的访问出现 404,所以还需要在路由文件进行兼容
// 直接通过 URL 访问旧路由时,重定向到新的匹配路由,redirectToNewPrefix 的实现很简单,取出 ctx.url 并且替换掉原先的路由前缀即可 router.get('/pages/*', controller.redirectToNewPrefix); 复制代码
至此,我们算是完成了 为子应用增加路由前缀
的工作。
参考官网,搭建一个最简单的主应用,只需要有一个用于挂载子应用的节点
<div id="subViewport"></div> 复制代码
然后调用 registerMicroApps
方法注册一下子应用即可
registerMicroApps( [ { name: 'appA', entry: appAEntryMap[process.env.NODE_ENV], // 根据运行环境,加载应用对应的入口,如 'http://localhost:3000/appA' container: '#subViewport', activeRule: '/appA' }, { name: 'appB', // app name registered entry: appBEntryMap[process.env.NODE_ENV], container: '#subViewport', activeRule: '/appB' } ] ); setDefaultMountApp('/appA'); // 设置默认加载的应用,当路由匹配不到时会触发 start(); 复制代码
这里可能会出现 #subViewport
挂载的子应用没有占满容器的现象,查阅官方 issue,给出一个可解决的方案是通过 css 去控制,让该节点下渲染的子 div 占满容器(该 div 会注入 hash,故无法根据 id 或 class 去处理)
#subViewport { width: 100%; height: 100%; > div { width: 100%; height: 100%; } } 复制代码
此步骤参考官方文档即可
另外,如果希望子应用也能单独访问,则可以在入口 js 处增加代码
// 不是在 qiankun 框架中装载的时候,直接渲染 if (!window['__POWERED_BY_QIANKUN__']) { bootstrap().then(mount); } 复制代码
启动主应用,访问页面,发现一片空白,查看控制台,出现了跨域问题
Access to fetch at 'http://localhost:3000/appA' from origin 'http://localhost:4001' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled. 复制代码
qiankun 是使用 fetch 来获取子应用的 html 文件的,所以出现了跨域问题。处理起来也比较简单,由于本身是由 Nodejs 渲染出来的,只需要增加 koa2-cors
中间件即可解决问题
这里注意,如果是在开发模式下,需要 webpack-dev-server
也支持跨域,可参考 这篇文章
终于来到了非常关键的一个环节,API 请求的处理,这也是官网和 Demo 没有提及的环节,但却是最重要的,决定了你的微前端改造是否成功
如果主应用、子应用以及后端 API 都是同一个域名,则天然地不用解决这个问题
以下方案都基于一个大前提:主应用、子应用都有各自的 Node 端处理页面渲染、登陆校验和 API 转发工作
首先要清楚,qiankun 子应用在浏览器端发 api 请求时,实际上是请求了主应用的 Node 端,url 为 /appA_apis/xxx
/appB_apis/xxx
这样的格式,而主应用的 Node 端是没有处理这些路由的逻辑的,故需要添加转发逻辑,把这些请求都转发到子应用的 Node 端去
先在主应用的配置文件添加子应用配置
subApps: [ { name: 'appA', prefix: '/appA_apis', // 子应用的 host,例如 http://localhost:3000 host: process.env['subApps.appA.host'] }, { name: 'appB', prefix: '/appB_apis', host: process.env['subApps.appB.host'] } ] 复制代码
然后在主应用的路由配置处,增加转发
subApps.forEach(subApp => { router.all(`${subApp.prefix}/*`, (ctx, next) => { // 转发请求到 `${subApp.host}/${ctx.url}`,注意参数要透传,content-type 也要保持一致,此处实现方式多种,不在此赘述 ... }) }) 复制代码
转发后会发现,API 请求在子应用的 Node 端无法通过校验,我们先来看下 API 请求的校验过程
x-auth-token
(这个 key 是我们的项目规定的,不是固定的)不难看出,主应用登陆后生成的 x-auth-token
并没有办法被子应用的 Node 端识别为有效的 session id
这里有两种做法
主应用和所有子应用共享同一个 session 存储,我们项目用的是 redis,所以就是让所有应用共用同一个 redis
优点:简单粗暴,工作量较小
缺陷:共用存储可能会产生一些冲突,某一子应用的开发不注意时可能错误地覆盖掉其他子应用的关键数据;各子应用无法拥有特殊的用户信息(比如在 subA 的用户信息里面有一个主应用和其他子应用都没有的特别的字段)
子应用提供一个特殊的 SSO 接口,主应用在登陆后,调用所有子应用的 SSO 接口并传输这个 x-auth-token
和加密后的用户账号,让各子应用生成各自的 session
优点:存储分离;各子应用可以根据需要维护特殊的用户信息
缺陷:需要开发新接口;子应用数量较多时,登陆动作的响应时间变长(需要确保每个子应用的 SSO 接口都成功)
基于现状,我们选择的是第二个方案,对用户账号采用 RC4 对称加密,每个子应用维护单独的 salt,主应用维护所有的 salt,子应用配置变成了
subApps: [ { name: 'appA', prefix: '/appA_apis', salt: 'appA', // 子应用的 host,例如 http://localhost:3000 host: process.env['subApps.appA.host'] } ] 复制代码
然后,在主应用登陆完成后,调用子应用提供的 SSO 接口
for (const subApp of subApps) { // 阻塞调用接口,确保每个请求都正确 await ... } 复制代码
经过以上步骤,我们的页面请求问题就基本上解决了
最后,是子应用间的切换。一开始使用 React Router 的 Link 标签,发现无法从一个子应用切换到另一个子应用,因为每个子应用都拥有自己的路由,而每一个路由的 history 都是调用 createBrowserHistory()
方法创建的
再次查看 qiankun 的文档,发现一句话
当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。
关键就在于触发这个浏览器 url 的变化。这里使用 window.history.pushState
方法,达成目的
history.pushState(null, linkPath, linkPath); 复制代码
完成了子应用的切换,又发现了另一个现象:当子应用 A 切换到某一个路由时,切换到子应用 B 并进行操作;然后再次切换回子应用 A,url 并不是子应用 A 刚刚卸载时的路径,但子应用 A 重新装载后会回到刚刚的页面。这对用户操作体验是好的,但是产生了 url 地址和真实呈现的界面不一致的现象
解决思路就是切换到子应用时,跳转至之前的路由,所以需要存储当前路由。由于只能影响当前打开的界面,故选择将该值存储到 sessionStorage
中
首先,需要切换子应用之前,记录当前的路由
sessionStorage.setItem('appA-currentRoute', window.location.href); 复制代码
然后,在子应用装载后,获取当前路由并跳转,然后删除记录的路由
const currentRoute = sessionStorage.getItem('appA-currentRoute'); if (currentRoute) { history.pushState(null, currentRoute, currentRoute); sessionStorage.setItem('appA-currentRoute', ''); } 复制代码
通过以上方案,实现了子应用切换的应用状态维护和 url 的匹配
至此,我们完成了微前端的初步实践,基于微前端框架 qiankun,通过对原有系统的改造,以及开发一个主应用来作为容器,实现了多应用合并的效果,在应用间切换时的用户体验得到了很大的提高;同时,也考虑了兼容的问题,支持子应用单独访问,也兼容了原有的链接,自动重定向到正确的链接
微前端不是银弹,只有真正遇到业务问题,需要提高用户体验的时候,再考虑去引入。不过,在未来任何应用开发的初期,都可以预先考虑到 共享域名、微前端改造
等的需求,保证所有请求都有唯一子路径