适配 CRA 项目为微前端应用

前期准备

  • 一个使用 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 等若干生命周期钩子,但 CRAwebpack 的默认打包方式不会将这些方法暴露出来,所以声明配置如下:

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 的截图好了,如下:

image.png

可以发现,对于 start 、 build ,均支持两种模式的指令,从而适配不同开发模式下的构建需求。

最后说一点,对于 index.jsx 内容的更改,最简单的方式即时通过软链接的方式来实现,提前提供两份被链接的目标文件,比如:

image.png

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-spaqiankun,对于 CRA 项目向微前端项目的迁移所做的一些工作并不具有通用性。

对于微前端这种架构,我更多地将它作为一种能够渐进式地重构项目的手段在使用,对于大型复杂项目,并没有太多的经验,一是因为没机会做类似的复杂度极高的中台项目,二是因为很多巨石应用,大多是旧项目,所以将它用作重构项目的一种手段也许更能发挥它的用处。

如有错误,还望指出。

阅读 355

推荐阅读
狮乐园
用户专栏

日就月将,学有缉熙于光明

856 人关注
47 篇文章
专栏主页