前期准备
- 一个使用 CRA 创建的新项目或者旧项目
- 了解
publicPath
是什么 - 了解 single-spa 中关于微前端应用的概念(及 qiankun 中 html-entry 的概念)
解决如何覆盖 CRA 配置的问题
通常情况下,覆盖 CRA 配置的解决方案有两种:
- 直接
npm run eject
- 使用
react-app-rewired
或者rescripts
等第三方工具
这里使用第二种方式,原因也比较简单,因为魔改 webpack
是需要很大的勇气的,且 npm run eject
不可逆(虽然可以通过其他方式恢复,但太麻烦了),并且对于需要覆盖的配置,我们也是有针对性的,所以使用第三方工具会更好一些。
我这里使用 rescripts
这个库,它与 create-react-rewired
大同小异。
覆盖 webpack 的打包模式
首先,对于 single-spa
中要加载的微前端应用,我们需要提供诸如 bootstrap
、 mount
以及 unmount
等若干生命周期钩子,但 CRA
中 webpack
的默认打包方式不会将这些方法暴露出来,所以声明配置如下:
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
让我们来挨个分析下每行配置的作用及意义:
-
library
和libraryTarget
是共同生效的,默认情况下,libraryTarget
的值是var
,即在entry file
在执行后,会返回一个变量,我们这里使用umd
的原因是因为,在主应用中,我们仍然会使用module system
,无论是webpack
还是Systemjs
,因此umd
是一个最佳选择,因为它适配所有的module system
。 -
jsonpFunction
是用来按需加载chunk
的工具函数,由于微前端应用中,一个页面中,会同时存在多个webpack
运行时环境,所以可能会存在命名冲突,导致加载chunk
时出现意想不到的后果,手动设置一个唯一的命名可以解决这个冲突 -
globalObject
本身属性的默认值即是window
,但由于libraryTarget
我们设置成了umd
,对于 nodejs 环境,全局对象时global
而非window
,这里显示地声明它是window
证明微前端应用只是针对 browser 而言的
移除 CRA 中内置的 HMR 功能
HMR 功能一般是针对开发环境而言的,对于为什么微前端应用在开发环境要关闭 HMR,我还没有深入研究,但关闭它是官方代码库示例中提供的最佳实践。
在 CRA 中,HMR 功能是分两部分存在的,一个是 webpack.config.devServer
提供的,另一个是 CRA 自己实现的 webpackHotDevClient
,我们需依次移除或者关闭它们。
首先关闭 webpack.devServer
的 HMR 功能,很简单,添加如下配置:
config.hot = false;
config.watchContentBase = false;
config.liveReload = false;
再来移除 webpackHotDevClient
,这个会稍微麻烦一些,因为它是直接声明在 webpack.config.entry
中的,所以使用下面的代码移除它:
config.entry = config.entry.filter(
(e) => !e.includes('webpackHotDevClient')
);
同时还有 HotModuleReplacementPlugin
插件,它提供 css 的 HMR 功能,利用相同的代码移除它:
config.plugins = config.plugins.filter(
(p) => !(p instanceof webpack.HotModuleReplacementPlugin)
);
这样就完全从 CRA 中移除了 HMR 的功能。
对 devServer 添加 CORS 配置以支持跨域访问
以 html-entry
为前提实现的微前端框架,构建前提既是微前端应用要支持跨域访问,对于部署阶段,我们可以在 web server
或代理层完成该步骤,对于开发阶段,我们则需要对 devServer
进行一些调整,因为它默认是不支持跨域访问的。
解决跨域问题除了配置反向代理之外,还可以使用 CORS
来解决,在 devServer
中,显示使用后者更加快捷,添加如下代码即可:
config.headers = {
'Access-Control-Allow-Origin': '*',
};
这样既实现了最简单的 CORS
配置,但满足开发环境中对于跨域访问的支持,足够了。
对于使用 history 作为路由模式的应用做适配
很简单,声明如下配置即可:
config.historyApiFallback = true;
推荐使用 history
作为微前端子应用的路由模式,因为在全局路由解析中,针对 hash
的匹配并不像 url
那样灵活,同时也存在一些微妙的 bug。
使 devServer 监听微前端子应用相对应的端口
也十分简单,使用如下代码:
config.port = 7101;
这里的 7101
,是微前端子应用监听的接口,建议不论在开发阶段,还是在部署阶段,都使用相同的接口以减少分辨接口的心智负担。
为微前端应用指定单独的 publicPath
需要单独指定 publicPath
的原因是因为,当前我们的微前端架构依赖于 html entry
,每个路径所对应的 entry
所加载的微前端应用,必然会有一些从 publicPath
加载资源的代码。
但在项目中,除非将所有子应用项目中的静态资源目录集合到一起,托管在主应用中,或者使用 CDN
,不然在项目启动时,会遇到很多 404
的错误,其根本原因是因为,之前请求的静态资源,并不是托管在主应用的服务器上,而是子应用的,因此如何在主应用或者代理层中映射这些静态资源的加载请求,是必须要解决的事情。
默认情况下,CRA 的 publicPath
是 /
,即相对于当前服务器的域名,子应用在主应用中加载时,所相对的是主应用服务器的域名,所以这里需要对每个子应用声明不同的 publicPath
。
在 CRA 中声明 publicPath
有两种,
- 通过在
package.json
中添加homepage
字段 - 通过
PUBLIC_PATH
环境变量
当前我使用的方式是第一种,因为第二种在当前的 CRA 版本中不生效(感觉像是一个 bug),如下:
{
"name": "vcapp-login",
"homepage": "/login",
...
}
除了针对静态资源设置单独的 publicPath
之外,还需要在应用中,针对动态使用 publicPath
的地方做出修改,这个在 qiankun
中已经有响应的解决方案,如下:
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
简单原理就是动态的注入了 __INJECTED_PUBLIC_PATH_BY_QIANKUN__
这个全局变量,它是通过 html entry
导入 entry
时,动态解析出来的。
适配 CRA 支持多 entry 启动模式
众所周知,CRA 启动的 entry
文件是 src/index.jsx
,如果一个项目需要适配为微前端应用,势必需要在 index.jsx
中实现各种微前端模块的生命周期函数。
在微前端应用的架构中,很重要的一点既是解耦,如果我们在开发子应用时,且没有用到任何和主应用或者其他子应用相关的模块或者状态时,仍然需要其他它们显示不是一种理想的开发模式。
我们理想的模式应该是,我们仍然可以按照传统 SPA 的启动方式,开发子应用,当需要与主应用集成,或者与其他子应用调试时,又可以以微前端模块的方式启动它。这其实是在说,我们当前的子应用要支持多 entry
启动模式。
在 CRA 中,虽然可以通过覆盖 webpack
的方式来解决这个问题,但是我认为有更简便的方法。考虑到无论是传统启动方式,还是微前端模块的启动方式,这两种启动方式在同一时间,我们只会使用一种,那我们移花接木式的变更 index.jsx
的内容,在 CRA 加载 entry
之前欺骗它岂不是更好?这里我们可以利用以下两点来实现类似的效果:
- 使用
npm scripts
中的 hook 前缀来截止启动指令 - 在 hook 中执行一些脚本,动态修改
index.jsx
的内容
由于涉及到的代码较多,这里就简单贴一个 npm scripts
的截图好了,如下:
可以发现,对于 start
、 build
,均支持两种模式的指令,从而适配不同开发模式下的构建需求。
最后说一点,对于 index.jsx
内容的更改,最简单的方式即时通过软链接的方式来实现,提前提供两份被链接的目标文件,比如:
micro.tsx
和 standalone.tsx
均对应不同的 entry
入口,使用 CRA 启动应用前,动态地创建软链接将它们和 index.tsx
文件链接起来即可(由于项目中使用了 ts,后缀为 .tsx
,js 项目同理)。
引入主应用
之后我们就可以愉快地在主应用中引入我们的子应用了,主要配置有两个,一是注册子应用,如下:
registerMicroApps(
[
// 其他子应用
...,
{
name: "vcapp-login",
entry: "//localhost:7101/login",
container: "#subapp-container",
activeRule: "/trade-login/",
},
],
)
二是增加对于子应用的 publicPath
的配置,这儿会分为两部分,一个是部署环境下的,一个是开发环境下的,这里分享开发环境下的。我主应用项目使用的打包器是 parcel
,因此可以直接对它内部的 web server
增加中间件来完成这部分工作,如下:
app.use(
createProxyMiddleware("/login", {
target: "http://localhost:7101",
})
);
注意这里的 7101
,与上文中的 7101
对应,如果它们不一致,会造成子应用加载失败。
其他的坑
- CRA 对于
svg
格式的图片,没有写在url-loader
的匹配规则中,如果子应用使用了svg
图片,需要覆盖url-loader
配置已适配publicPath
变更造成的影响
最后
由于仓库代码在公司内网,不太方面直接拷贝出来,日后有时间会单另在 github
创建一个示例项目。
同时由于该微前端应用的架构基于 single-spa
和 qiankun
,对于 CRA 项目向微前端项目的迁移所做的一些工作并不具有通用性。
对于微前端这种架构,我更多地将它作为一种能够渐进式地重构项目的手段在使用,对于大型复杂项目,并没有太多的经验,一是因为没机会做类似的复杂度极高的中台项目,二是因为很多巨石应用,大多是旧项目,所以将它用作重构项目的一种手段也许更能发挥它的用处。
如有错误,还望指出。