博客首发 : SugarTurboS Blog
团队的项目 A 经历两年需求的洗礼,一些问题也随之暴露出来:
npm
包很多,业务代码也很多,有着向巨石应用发展的趋势。巨石应用的一些典型问题如下:构建效率低下、dev-server 占用内存大甚至内存泄露、维护成本急剧增加。npm
包版本可能不同,导致一些隐藏未知错误。对于微前端跟 iframe 的方案区别,为什么用微前端这个问题,这里不再累赘,qiankun
里面有一篇文章已经说得非常不错,有兴趣可以去看看。
why not iframe
qiankun
qiankun
的接入对项目改动小,成本低。这个的坑不是指qiankun
的坑。当然,qiankun
这个框架还是有一些坑在的。这里主要的指在项目重构的时候,遇到的一些坑及我们的解决方案,以供大家参考。
项目之前的结构,所有的包都安装在根目录的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
配置。重构后,我们们的目录结构是典型的 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
时要配置参数rootMode
为upward
,表示允许babel
往上查找babel.config.js
文件】,同样子项目要配置extends
参数指向最外层的 babel 文件路径。
关于babel6.x
与babel7
的区别,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])
由于我们们项目之前是使用 webpack
的 import
实异步加载。在使用qiankun
重构后,发现以下问题:
当前处于子应用 A,切换子应用 B,在异步 js 还在加载过程中,快速切换回应用 A。待子应用 B 的异步 js 加载完毕后,我们们切换回子应用 B,发现子应用那个异步 js 加载的内容为空。
导致该问题原因是 A->B->A 过程后,子应用 B 的沙箱被移除了,异步 js 缺少执行环境,导致异步 js 执行的(window.webpackJsonp...
)已经找不到。
目前没有找到有效的解决办法,这可能是框架的一个隐藏坑,已提issue
,期望大佬们能协助解决。我们现在想到的可行方案是改用loadMicroApp
手动加载子应用。
在项目送测过程中,测试发现在某些浏览器(目前知道的是搜狗浏览器某个版本)会有兼容性问题。后来追查发现,有些浏览器的 fetch
默认 credentials
不是 same-origin
,导致一些 cookie
的 header
信息没被带上,后台权限认证一直不过。
解决方法就是调用 qiankun
的 start
是重写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。里面作者也提供了一个方案,就是允许你重新加载子应用。但是这样就违背了热更新的更新局部的思想。而且,加载子应用跟刷新并没有太大差别了,开发体验太差。
我们们讨论发现,没有什么方案可以解决这个问题。只有一个规避的方案,就是我们们平时开发的时候,使用子应用路由进行开发,这样就可以规避这两个略为蛋疼的问题。当然,在一些场景下,如果主应用做一些权限的东西,单独跑子应用必须重写一套权限。我们目前做法是把这种模块挂公共目录里。后面还要继续探索有没有更好的方案。大家如果有更好的解决方案欢迎留言。
项目组的小伙伴吐槽说,我们们之前开发只需要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)
})
重构后,我们发现有些包子应用都有使用,比如React
、antd
…,如果每个子应用都安装依赖一个antd
,那就很浪费资源加载,也会影响用户首屏等待时间。qiankun
没有提供这方面的方案。我们最后是使用dll
方式,先把这些公共包提前打包后,在dll
到各个子项目。dll
的方式也是有一些不足的,因为dll
无法按需加载,只能引用整个包,同样。dll
需要提前加载,如果dll
打包的东西不是使用很频繁或首屏使用的,会特别浪费。所以我们一般只有满足以下条件才会考虑dll
:
npm
包几乎大部分功能都需要使用最后说说我们的想法。项目是否需要引入 qiankun,我们觉得关键还是要清楚引入 qiankun 后的收益及成本。拿我们们这个项目来说,因为可预见后面业务越来越大的时候,它肯定会变成巨石应用。qiankun 的接入在当前来看可能成本高于收益,但从长远来说,收益是绝对高于成本的,所以我们们把它引到项目中去了。
最后,由于篇幅有限,很多细节的东西没有在这里展现。如果有兴趣的,欢迎私下交流。
未经授权,禁止转载~