微前端之如何拆解React巨石应用 qiankun
背景
团队的项目 A 经历两年需求的洗礼,一些问题也随之暴露出来:
项目引用的包很多,业务代码也很多,有着向巨石应用发展的趋势。巨石应用的一些典型问题如下:构建效率低下、*
dev-server 占用内存大甚至内存泄露 *、维护成本急剧增加。项目主框架升级成本高,要兼容旧代码。
项目里的某些业务几乎不再迭代,但每个版本依然会被打包构建,每次构建的包版本可能不同,导致一些隐藏未知错误。
该项目之前是由两个不同的项目合并而来,代码风格上存在两种方式,解决类似问题时引入的技术方案也是不一样,导致后期维护成本高,同样对于新人来说阅读性差。
解决之路
为什么用微前端
对于微前端跟 iframe 的方案区别,为什么用微前端这个问题,这里不再累赘,里面有一篇文章已经说得非常不错,有兴趣可以去看看。
为什么我们选择`qiankun`
的接入对项目改动小,成本低。
社区活跃,作者对于 issue 回复快。
star 数相对较多
阿里自家项目有在使用,稳定性 ok,即生产可用(这一点其实是官方上说的,实际我们也不肯定,就我们使用感觉,稳定性还是有一定保障的)。
重构之路
这个的坑不是指的坑。当然,这个框架还是有一些坑在的。这里主要的指在项目重构的时候,遇到的一些坑及我们的解决方案,以供大家参考。
两个 React 的坑
项目之前的结构,所有的包都安装在根目录的,项目里所有内容都指向一个。而用重构后,我们定义每个子项目为一个相对独立的项目(有独立的文件,独立包管理),但子项目之前又会有一些公共的组件,我们把它放在以子项目同级的 common 文件夹,如下图。

这时候就遇到一个问题:子项目引用自己目录下的里面的,而 common 引用根目录的目录下里面的,当子项目引用 common 封装的React 组件,子项目跑的时候会报同时引入了两个,导致报错。
一开始,我们想到的方案是这样的,把全部包安装在根目录的。但是,这个方案最大问题是所有子项目必须用同一版本、后期想升级,必须所有子项目做兼容,但是有些子项目被划分出来就是为了不再跟随升级迭代,这就矛盾了。
后来,我们换了一个方案,我们在打包的时候,预先把 common 目录 copy 一份到子项目,这样就能保住都是引用一个。在开发的时候,额外启动一个监听服务 watch common 目录,监听到文件变化的时候自动 copy 文件到子项目,子项目的 common 目录进行权限控制,只能进行读写操作,无其他操作执行权限。所有引用 common 通过@common 映射。这样给到开发时,common 内容的更改只需要在根目录 common 修改,子项目通过@common 引用不需要关注真实的 common 与子项目的目录结构关系。
babel 配置读取不到的坑
重构前,我们们只有一个 配置。重构后,我们们的目录结构是典型的 结构。我们们只有子项目有 文件,导致 common 往上查找找不到配置报错 (ps:我们们项目是使用构建)
一开始,是沿用时候的方式,使用文件。根目录及子项目分别有一个文件,这样的最大缺点是两个文件配置几乎相同,后期维护改配置需要修改两个文件。

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

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

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

以上就是我们最终关于的配置。
【记得 时要配置参数 为 ,表示允许 往上查找文件】,同样子项目要配置 参数指向最外层的 babel 文件路径。
通信
只给我们们提供了一个 (初始化一个全局)、(监听变化)、(更新)的全局状态管理,并不跟的状态管理器做关联。我们们要做的是把全局与子应用做一个双向绑定。
// 这里面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])
异步加载
由于我们们项目之前是使用 的 实异步加载。在使用重构后,发现以下问题:
当前处于子应用 A,切换子应用 B,在异步 js 还在加载过程中,快速切换回应用 A。待子应用 B 的异步 js 加载完毕后,我们们切换回子应用 B,发现子应用那个异步 js 加载的内容为空。
导致该问题原因是 A->B->A 过程后,子应用 B 的沙箱被移除了,异步 js 缺少执行环境,导致异步 js 执行的()已经找不到。
目前没有找到有效的解决办法,这可能是框架的一个隐藏坑,已提,期望大佬们能协助解决。我们现在想到的可行方案是改用手动加载子应用。
浏览器的 fetch 差异
在项目送测过程中,测试发现在某些浏览器(目前知道的是搜狗浏览器某个版本)会有兼容性问题。后来追查发现,有些浏览器的 默认 不是 ,导致一些 的 信息没被带上,后台权限认证一直不过。
解决方法就是调用 的 是重写,设置 ,保证浏览器的兼容性。
start({
fetch(...args) {
const config = {
credentials: 'same-origin',
}
if (!args[1]) args[1] = {}
args[1] = {
...args[1],
...config,
}
return fetch(...args)
},
})
总结
其实整体来说,接入成本还是比较低的。遇到的问题大多不是直接导致,而是用重构后,项目结构发生变化带来的一些问题。
优化开发体验篇
项目重构后,因为整体结构的变化,出现的一些性能及开发体验的问题。这里主要说影响比较大的两点。
内存占用严重,子应用无法热更新
1、有同事发现,项目用重构后,在本地开发过程中,如果长期打开,随着页面刷新次数越多,的内存占用会越来越严重。理论上来说,就算程序有内存泄露啥的,刷新页面也会释放掉才对,为啥内存却是越来越大呢?后来发现,只要不打开,内存是正常的,刷新内存就会降下来的。而且,我们们使用未重构的分支验证,也是不会内存越来越大的。如图:


2、子应用内容变更,是无法热更新的。一开始以为是的配置没有配对导致的。后来发现并不是,而是使用的框架的问题。详细可见。里面作者也提供了一个方案,就是允许你重新加载子应用。但是这样就违背了热更新的更新局部的思想。而且,加载子应用跟刷新并没有太大差别了,开发体验太差。
我们们讨论发现,没有什么方案可以解决这个问题。只有一个规避的方案,就是我们们平时开发的时候,使用子应用路由进行开发,这样就可以规避这两个略为蛋疼的问题。当然,在一些场景下,如果主应用做一些权限的东西,单独跑子应用必须重写一套权限。我们目前做法是把这种模块挂公共目录里。后面还要继续探索有没有更好的方案。大家如果有更好的解决方案欢迎留言。
monorepo 项目的开发命令管理
项目组的小伙伴吐槽说,我们们之前开发只需要一个命令行就可以搞定。重构后,每个子应用启动一个服务,还要一个服务。如果要全套跑起来,我们需要打开多个命令行窗口分别运行。这样太麻烦了。针对这个问题,自然是引入解决这个问题。一开始我们也是这样做的,但是后面发现,实际开发过程中,有的时候小 A 只要开发子应用 A,小 B 只需要开发子应用 B,每个人都全部启动,既浪费内存资源,也不优雅。那么,要怎么样随心所欲一行命令开启你想要的服务呢?
我们们最后是使用的 Node API。自主处理命令行,然后使用它提供的 API 动态启动想要的服务。如,会启动 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)
})
公共包
重构后,我们发现有些包子应用都有使用,比如、...,如果每个子应用都安装依赖一个,那就很浪费资源加载,也会影响用户首屏等待时间。没有提供这方面的方案。我们最后是使用方式,先把这些公共包提前打包后,在到各个子项目。的方式也是有一些不足的,因为无法按需加载,只能引用整个包,同样。需要提前加载,如果打包的东西不是使用很频繁或首屏使用的,会特别浪费。所以我们一般只有满足以下条件才会考虑:
1、多个项目使用
2、项目使用的频率高
3、这个包几乎大部分功能都需要使用
结尾
最后说说我们的想法。项目是否需要引入 qiankun,我们觉得关键还是要清楚引入 qiankun 后的收益及成本。拿我们们这个项目来说,因为可预见后面业务越来越大的时候,它肯定会变成巨石应用。qiankun 的接入在当前来看可能成本高于收益,但从长远来说,收益是绝对高于成本的,所以我们们把它引到项目中去了。
最后,由于篇幅有限,很多细节的东西没有在这里展现。如果有兴趣的,欢迎私下交流。
未经授权,禁止转载~