如何拆解React巨石应用?qiankun | 🏆 技术专题第四期征文

avatar
SugarTurboS Club @SugarTurboS

背景

📢 博客首发 : SugarTurboS Blog

团队的项目 A 经历两年需求的洗礼,一些问题也随之暴露出来:

  • 项目引用的npm包很多,业务代码也很多,有着向巨石应用发展的趋势。巨石应用的一些典型问题如下:构建效率低下dev-server 占用内存大甚至内存泄露维护成本急剧增加
  • 项目主框架升级成本高,要兼容旧代码
  • 项目里的某些业务几乎不再迭代,但每个版本依然会被打包构建,每次构建的npm包版本可能不同,导致一些隐藏未知错误
  • 该项目之前是由两个不同的项目合并而来,代码风格上存在两种方式,解决类似问题时引入的技术方案也是不一样,导致后期维护成本高,同样对于新人来说阅读性差

解决之路

为什么用微前端

对于微前端跟 iframe 的方案区别,为什么用微前端这个问题,这里不再累赘,qiankun里面有一篇文章已经说得非常不错,有兴趣可以去看看。 why not iframe

为什么我们选择qiankun

  • qiankun的接入对项目改动小,成本低。
  • 社区活跃,作者对于 issue 回复快。
  • star 数相对较多
  • 阿里自家项目有在使用,稳定性 ok,即生产可用(这一点其实是官方上说的,实际我们也不肯定,就我们使用感觉,稳定性还是有一定保障的)。

重构之路

这个的坑不是指qiankun的坑。当然,qiankun这个框架还是有一些坑在的。这里主要的指在项目重构的时候,遇到的一些坑及我们的解决方案,以供大家参考。

两个 React 的坑

项目之前的结构,所有的包都安装在根目录的node_modules,项目里所有内容都指向一个React。而用qiankun重构后,我们定义每个子项目为一个相对独立的项目(有独立的package.json文件,独立包管理),但子项目之前又会有一些公共的组件,我们把它放在以子项目同级的 common 文件夹,如下图。

这时候就遇到一个问题:子项目引用自己package.json目录下的node_modules里面的React,而 common 引用根目录的package.json目录下node_modules里面的React,当子项目引用 common 封装的React 组件,子项目跑的时候会报同时引入了两个React,导致报错。

一开始,我们想到的方案是这样的,把全部包安装在根目录的node_modules。但是,这个方案最大问题是所有子项目必须用同一版本React、后期React想升级,必须所有子项目做兼容,但是有些子项目被划分出来就是为了不再跟随升级迭代,这就矛盾了。

后来,我们换了一个方案,我们在打包的时候,预先把 common 目录 copy 一份到子项目,这样就能保住都是引用一个React。在开发的时候,额外启动一个监听服务 watch common 目录,监听到文件变化的时候自动 copy 文件到子项目,子项目的 common 目录进行权限控制,只能进行读写操作,无其他操作执行权限。所有引用 common 通过@common 映射。这样给到开发时,common 内容的更改只需要在根目录 common 修改,子项目通过@common 引用不需要关注真实的 common 与子项目的目录结构关系。

babel 配置读取不到的坑

重构前,我们们只有一个 babel 配置。重构后,我们们的目录结构是典型的 monorepo 结构。我们们只有子项目有 .babelrc.json 文件,导致 common 往上查找找不到配置报错 (ps:我们们项目是使用babel7构建)

一开始,是沿用babel6时候的方式,使用.babelrc.json文件。根目录及子项目分别有一个.babelrc.json文件,这样的最大缺点是两个.babelrc.json文件配置几乎相同,后期维护改配置需要修改两个文件。

然后改用子项目 .babelrc.json 通过 extends 配置复用根目录的.babelrc.json配置。

后面发现,由于 babel 配置有一些是需要配置路径,而json只能配置相对路径,于是改用js格式配置。

我们们项目引用的一些 npm 包没有转es6,我们们只能用 webpack 对这些包额外 babel 转化一下。但是发现项目的 babel 配置对 npm 包并不生效。后来发现是因为 babel7 之后,.babelrc 不会对 node_modules 包起作用,必须改用babel.config.js代替。

以上就是我们最终关于babel的配置。

【记得 babel-loader 时要配置参数 rootModeupward,表示允许 babel 往上查找babel.config.js文件】,同样子项目要配置 extends 参数指向最外层的 babel 文件路径。

关于babel6.xbabel7的区别,babel对于monorepo项目的配置,官网上面这篇文章写的是最详细的。

通信

qiankun 只给我们们提供了一个 initGlobalState(初始化一个全局state)、onGlobalStateChange(监听变化)、setGlobalState(更新state)的全局状态管理,并不跟React的状态管理器做关联。我们们要做的是把全局state与子应用redux做一个双向绑定。

// 这里面state与globalState要进行深比较,如果是浅比较,会导致程序陷入死循环。
const [state, setState] = useState({}) // 这里用state代替redux,做一个简单演示。
let globalState = null
// 监听globalState值变化,如果有变化则更新state
actions.onGlobalStateChange((newGlobalState) => {
  globalState = newGlobalState
  const diffState = getDiffState(globalState, state)
  if (diffState) setState(diffState)
})

// 监听state值变化,如果有变化则更新globalState
useEffect(() => {
  const diffState = getDiffState(state, globalState)
  if (diffState) actions.setGlobalState(diffState)
}, [state])

异步加载

由于我们们项目之前是使用 webpackimport 实异步加载。在使用qiankun重构后,发现以下问题:

当前处于子应用 A,切换子应用 B,在异步 js 还在加载过程中,快速切换回应用 A。待子应用 B 的异步 js 加载完毕后,我们们切换回子应用 B,发现子应用那个异步 js 加载的内容为空。

导致该问题原因是 A->B->A 过程后,子应用 B 的沙箱被移除了,异步 js 缺少执行环境,导致异步 js 执行的(window.webpackJsonp...)已经找不到。

目前没有找到有效的解决办法,这可能是框架的一个隐藏坑,已提issue,期望大佬们能协助解决。我们现在想到的可行方案是改用loadMicroApp手动加载子应用。

浏览器的 fetch 差异

在项目送测过程中,测试发现在某些浏览器(目前知道的是搜狗浏览器某个版本)会有兼容性问题。后来追查发现,有些浏览器的 fetch 默认 credentials 不是 same-origin,导致一些 cookieheader 信息没被带上,后台权限认证一直不过。

解决方法就是调用 qiankunstart 是重写fetch,设置 credentials=same-origin,保证浏览器的兼容性。

start({
  fetch(...args) {
    const config = {
      credentials: 'same-origin',
    }
    if (!args[1]) args[1] = {}
    args[1] = {
      ...args[1],
      ...config,
    }
    return fetch(...args)
  },
})

总结

其实整体来说,接入qiankun成本还是比较低的。遇到的问题大多不是qiankun直接导致,而是用qiankun重构后,项目结构发生变化带来的一些问题。

优化开发体验篇

项目重构后,因为整体结构的变化,出现的一些性能及开发体验的问题。这里主要说影响比较大的两点。

内存占用严重,子应用无法热更新

1、有同事发现,项目用qiankun重构后,在本地开发过程中,如果chrome tool长期打开,随着页面刷新次数越多,chrome的内存占用会越来越严重。理论上来说,就算程序有内存泄露啥的,刷新页面也会释放掉才对,为啥内存却是越来越大呢?后来发现,只要不打开chrome tool,内存是正常的,刷新内存就会降下来的。而且,我们们使用未重构的分支验证,也是不会内存越来越大的。如图:

2、子应用内容变更,是无法热更新的。一开始以为是webpack的配置没有配对导致的。后来发现并不是webpack,而是qiankun使用的single-spa框架的问题。详细可见issue。里面作者也提供了一个方案,就是允许你重新加载子应用。但是这样就违背了热更新的更新局部的思想。而且,加载子应用跟刷新并没有太大差别了,开发体验太差。

我们们讨论发现,没有什么方案可以解决这个问题。只有一个规避的方案,就是我们们平时开发的时候,使用子应用路由进行开发,这样就可以规避这两个略为蛋疼的问题。当然,在一些场景下,如果主应用做一些权限的东西,单独跑子应用必须重写一套权限。我们目前做法是把这种模块挂公共目录里。后面还要继续探索有没有更好的方案。大家如果有更好的解决方案欢迎留言。

monorepo 项目的开发命令管理

项目组的小伙伴吐槽说,我们们之前开发只需要npm run dev一个命令行就可以搞定。qiankun重构后,每个子应用启动一个服务,qiankun还要一个服务。如果要全套跑起来,我们需要打开多个命令行窗口分别运行。这样太麻烦了。针对这个问题,自然是引入npm-run-all解决这个问题。一开始我们也是这样做的,但是后面发现,实际开发过程中,有的时候小 A 只要开发子应用 A,小 B 只需要开发子应用 B,每个人都全部启动,既浪费内存资源,也不优雅。那么,要怎么样随心所欲一行命令开启你想要的服务呢? 我们们最后是使用npm-run-all的 Node API。自主处理命令行,然后使用它提供的 API 动态启动想要的服务。如npm start main A,会启动 qiankun 所在的服务及 A 微服务。

// package.json
"scripts": {
  'start': 'node start.js',
  "start:main": "cd client/main && npm run dev",
  "start:A": "cd client/A && npm run dev",
  "start:B": "cd client/B && npm run dev",
  "start:C": "cd client/C && npm run dev",
}
// start.js
const runAll = require('npm-run-all')

function getApps() {
  // 查找命令行所带的参数,如果没有带参数,然后启动Main及A服务
  let apps = process.argv.filter((arg) =>
    ['Main', 'A', 'B', 'C'].some((name) => name === arg)
  )
  if (apps.length <= 0) apps = ['Main', 'A']
  return apps
}

function getTasks() {
  let apps = getApps()
  let tasks = apps.map((app) => `start:${app}`)
  return tasks
}

runAll(getTasks(), {
  parallel: true,
  // stdout: writable,
  // stderr: errWritable,
  // printLabel: true,
})
  .then((results) => {
    console.log('done!', results)
  })
  .catch((err) => {
    console.log('failed!', err)
  })

公共包

重构后,我们发现有些包子应用都有使用,比如Reactantd...,如果每个子应用都安装依赖一个antd,那就很浪费资源加载,也会影响用户首屏等待时间。qiankun没有提供这方面的方案。我们最后是使用dll方式,先把这些公共包提前打包后,在dll到各个子项目。dll的方式也是有一些不足的,因为dll无法按需加载,只能引用整个包,同样。dll需要提前加载,如果dll打包的东西不是使用很频繁或首屏使用的,会特别浪费。所以我们一般只有满足以下条件才会考虑dll:

  • 1、多个项目使用
  • 2、项目使用的频率高
  • 3、这个npm包几乎大部分功能都需要使用

结尾

最后说说我们的想法。项目是否需要引入 qiankun,我们觉得关键还是要清楚引入 qiankun 后的收益及成本。拿我们们这个项目来说,因为可预见后面业务越来越大的时候,它肯定会变成巨石应用。qiankun 的接入在当前来看可能成本高于收益,但从长远来说,收益是绝对高于成本的,所以我们们把它引到项目中去了。

最后,由于篇幅有限,很多细节的东西没有在这里展现。如果有兴趣的,欢迎私下交流。

未经授权,禁止转载~

更多文章