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

2020/7/28 11:04:09

本文主要是介绍基于 qiankun 的微前端应用改造踩坑记,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言

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

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

然而,当系统数量越来越多时,用户开始抱怨入口太多,希望由统一的入口来完成所有的功能,这个场景有几种解决方案

  • 合并所有系统到一个大系统中

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

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

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

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

再来梳理一下现状

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

  • 所有系统都拥有自己的 Nodejs 层,用于页面渲染和 API 请求转发

  • 所有系统都拥有不同的域名,没有特定的域名子路径

  • 不同的系统有自己的小团队在开发,部分使用不同版本的 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,通过对原有系统的改造,以及开发一个主应用来作为容器,实现了多应用合并的效果,在应用间切换时的用户体验得到了很大的提高;同时,也考虑了兼容的问题,支持子应用单独访问,也兼容了原有的链接,自动重定向到正确的链接

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



这篇关于基于 qiankun 的微前端应用改造踩坑记的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程