基于 qiankun 的微前端应用改造踩坑记

前言

随着业务发展,咱们的系统变得愈来愈庞大,给构建速度、静态资源大小以及应用性能带来了极大的挑战css

一个系统是由众多小模块组成的,大部分用户都不会拥有全部模块的权限,因此咱们的第一个优化方式就是 code split,将每一个小模块的代码分割出来,按需加载,也取得了必定的效果html

然而,当系统数量愈来愈多时,用户开始抱怨入口太多,但愿由统一的入口来完成全部的功能,这个场景有几种解决方案前端

  • 合并全部系统到一个大系统中webpack

    • 优势
      • 用户体验能够作到最好,一个单页应用的操做流畅度较高
    • 缺陷
      • 容易变成一个巨石应用,开发、构建时都会产生性能问题
      • 任何一个小模块的修改均可能致使整个大系统不可用
      • 限制了开发框架,将来难以升级
  • 作个应用框架,用 IFrame 嵌入目标系统git

    • 优势
      • 改形成本低,只须要开发应用框架
      • 能够支持同时打开多个系统并经过标签进行切换
      • 切换系统时自然地能够维持页面的状态,让用户继续以前的操做路径
      • 各应用独立部署,互不干扰
    • 缺陷
      • IFrame 中的路由变化没法体如今应用框架的 URL 上,用户一刷新就会回归到初始页面,影响体验,需独立开发一套通信机制让应用框架保存 IFrame 中系统的路由,须要对现有系统作改造
      • IFrame 加载速度慢
      • 若界面上 IFrame 较多,dom 结构会变得复杂,影响系统性能
  • 开发统一导航栏,替换各系统的导航栏,在导航栏中经过 <a> 标签实现系统切换github

    • 优势
      • 改形成本相对较低,须要开发能够快捷集成到不一样系统中去的导航栏;如果须要统一域名,则各系统须要改造,全部请求须携带特有的子路径
      • 基本不影响用户使用单个系统的体验
    • 缺陷
      • 系统间的切换本质上是打开了一个新的系统,加载性能会影响用户体验,用户只是 看上去 像在使用一个系统,如果用户切换的频率较高,则感觉更强烈
      • 系统间的通信只能依赖 localStorage/sessionStorage 等浏览器存储
      • 不支持同时打开多系统,没法自然恢复页面状态
  • 采用微前端架构,对应用进行改造web

    • 优势
      • 真正能够作到在一个入口使用全部功能
      • 不一样应用间的切换体验较好,除了第一次切换须要消耗必定时间作 js 解析,后续的切换则较为平滑
      • 主应用能够提供通用功能供子应用使用
      • 不一样应用能够由不一样团队、使用不一样的技术栈开发
    • 缺陷
      • 有必定的改造工做量
      • 主应用承载全部流量入口,无形中增大了系统压力

再来梳理一下现状redis

  • 全部系统都是基于内部的统一框架开发,拥有统同样式的顶部栏和侧边栏json

  • 全部系统都拥有本身的 Nodejs 层,用于页面渲染和 API 请求转发bootstrap

  • 全部系统都拥有不一样的域名,没有特定的域名子路径

  • 不一样的系统有本身的小团队在开发,部分使用不一样版本的 React 和 Ant Design

  • 开发广泛要求将来新功能模块的开发可使用与时俱进的技术

基于现状分析,微前端是一个能够去尝试的方向,因而便开始了踩坑之路,将现有的系统改形成为微前端的子应用

为了统一语言,现有的系统在下文称为子应用

踩坑之路

选型

咱们使用 qiankun 来做为微前端的实现库,(听说)能够快速实现改造

应用改造

增长子路径

qiankun 是基于 single-spa 封装的,其内部实现的子应用加载机制,是基于浏览器 url 来实现的,经过第一段子路径来决定要加载哪一个子应用,好比

  • ${你的域名}/appA/......:表示加载 a 应用
  • ${你的域名}/appB/......:表示加载 b 应用

因此,为每一个子应用改造使得全部的访问都增长子路径,是咱们要作的第一步

  • 为每一个路由增长前缀,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%;
  }
}
复制代码

子应用暴露生命周期函数,UMD 格式打包

此步骤参考官方文档便可

另外,若是但愿子应用也能单独访问,则能够在入口 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 校验问题

终于来到了很是关键的一个环节,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 请求的校验过程

  • 从请求的 cookie 中取出 x-auth-token(这个 key 是咱们的项目规定的,不是固定的)
  • 经过这个 token,判断是否有与之对应的有效的 session,若是有,则取出用户信息
  • 经过用户信息生成 jwt,并透传其余参数,转发到真正的后端 API

不难看出,主应用登录后生成的 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,经过对原有系统的改造,以及开发一个主应用来做为容器,实现了多应用合并的效果,在应用间切换时的用户体验获得了很大的提升;同时,也考虑了兼容的问题,支持子应用单独访问,也兼容了原有的连接,自动重定向到正确的连接

微前端不是银弹,只有真正遇到业务问题,须要提升用户体验的时候,再考虑去引入。不过,在将来任何应用开发的初期,均可以预先考虑到 共享域名、微前端改造 等的需求,保证全部请求都有惟一子路径

相关文章
相关标签/搜索