React App Framework - 11. Webpack 5 Module Federation

背景

久违的React-App-Framework终于有了新内容了, 从一年前就开始关注的Create-React-App对于Webpack5的支持, 在上个月终于有了可以稳定使用的版本, 可以通过@next版本来使用webpack5开发, 也可以使用期待很久的文件缓存加速和Module Federation了, 而且对于分子应用来说, Module Federation提供的各自打包发布能力格外重要. 经过一段时间的试用和踩坑, 终于完成了子应用分离打包部署, 这里记录下部分重点内容.

配置

config-overrides

之前在webpack4开发时, 是通过npm run eject后实现修改webpack.config.js的, 这样的好处是自由, 但是问题是不方便维护. 所以这次决定切换成使用react-app-rewired来实现修改webpack.config

sass/less

sass/lesstypescript主题变量注入的使用方法基本没有变化, 在config中找到对应的loaders加入相关修改即可, web-worker的支持也是同理, 不过其中有部分动态生成的配置需要从react-scripts/config/webpack.config.js中复制出来使用, 因为它没有暴露公开接口

调试方法

react-app-rewiredbuildstart都是可以使用node debug的, 可以在运行时断点查看相应配置是否正确

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "start debug building",
"program": "${workspaceFolder}/node_modules/react-app-rewired/scripts/start.js",
"args": [""],
"outFiles": ["${workspaceFolder}/config/*.js"],
"env": {}
}
]
}

调整完成所有配置启动之后, 发现了第一个问题: 切换主题变量之后不会触发重新编译, 导致页面主题不变

cachetheme

最开始怀疑是之前开发的babelLoader.options.customize缓存注入组件由于webpack配置变更导致没有生效, 但是实际测试后发现是有效的. 同时删除缓存文件之后主题也是可以改变的, 再联想到webpack5对于缓存有了比较大的优化, 所以基本能确定问题出现在webpack5的最新的缓存机制上了

webpack5支持了基于文件系统的持久化缓存, 官方文档指明它会在依赖项和配置内容不变的时候, 直接读取缓存内容, 不会进行任何编译. 所以可以很明确的知道问题出现在这里了, 同时官方也提供了解决方案: version参数, 设置version参数后, webpack就会针对不同的版本启用不同的缓存路径, 对于我们指定主题来说, 非常方便, 而且也不会造成切换主题时缓存失效了

1
config.cache.version = `${config.cache.version}-${buildConfig.options.appTheme}`;

Module Federation

修复缓存问题后, webpack5就可以正式替换webpack4版本了(虽然还是使用的"react-scripts": "^5.0.0-next.47"版本, 但是实测页面没有任何问题). 下一步就是使用最吸引人的Module Federation来实现子应用的模块化开发了, 官方文档介绍了Module Federation的基本原理, 能实现的效果是远程加载一个js应用, 可以理解为动态import的外部链接版本, 同时还支持基础库共享, 不会造成多份react/antd库的问题

架构设计

由于原本已经将子应用隔离划分得比较好, 支持Module Federation的成本其实很低, 只需要配置相应的子应用打包模块配置, 并实现动态导入即可

sequenceDiagram participant U as User participant F as Base Framework participant M as App Module alt 用户访问 U ->> F: 1. 加载基础框架 F ->> M: 2. 加载子应用模块代码 M -->> U: 3 返回子应用 end

webpack.config

按照官方文档指导, 配置基础框架shared-module, 再将每个子应用抽离一个ModuleFederationPlugin, 并配置文件名和exposes即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
config.plugins = [
new ModuleFederationPlugin({
shared: Object.assign(
_.mapValues(pkg.dependencies, () => ({
singleton: true,
requiredVersion: false,
})),
{
'./src/other-file': {
singleton: true,
requiredVersion: false,
},
},
),

remotes: {
// xxx
},
name: 'app_base',
}),
..._.map(
buildConfig.includesAppConfig,
(config, app) =>
new ModuleFederationPlugin({
shared: Object.assign(
_.mapValues(pkg.dependencies, () => ({
singleton: true,
requiredVersion: false,
})),
{
'./src/other-file': {
singleton: true,
requiredVersion: false,
},
},
),
name: `app_${app}`,
filename: `remote-app-${app}.js`,
exposes: {
[`./index`]: `./src/containers/${app}/index.tsx`,
},
}),
),
];

配置完成后, npm start就能直接输出对应appremote.js文件了, 在代码中使用也非常简单: const App = React.lazy(() => import("app_xxx/index"));, 同时可以在react-app-env.d.ts中添加相关模块的定义, 这样就可以避免ts模块编译报错

1
2
3
4
5
6
7
8
9
10
declare module 'app_*/index' {
const Component: React.FunctionComponent<any>;
export default Component;
}

// 下面动态加载组件需要使用
declare global {
const __webpack_init_sharing__: (type: string) => Promise<any>;
const __webpack_share_scopes__: { default: (type: string) => Promise<any> };
}

动态加载

这样配置完后, 通过network就可以看到页面会自动加载remote_app.js了, 但是这里存在一个问题, 查看编译后生成的index.html可以发现, remote_app.js是通过<script src="xxxxx.js">加载的, 如果子系统太多, 一次性加载的脚本太多也会导致首屏时间变长, 所以需要解决这个问题

官方文档和demo也提供了解决方案: 使用动态注入script实现即可, 这里直接参考官方demo实现useDynamicScriptComponent, 同时额外加上全局缓存, 可以避免在react-router变化的时候导致重新加载React.lazy组件, 导致页面状态丢失

开发完动态加载后, 需要将ModuleFederationPlugin中的remotes参数去掉, 就不会在index.html中注入加载脚本了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import React, { useEffect, useState } from 'react';
import debug from '../utils/debug';

const cache: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {};

const loadComponent = (scope: string, module: string) => {
if (!cache[scope]) {
cache[scope] = React.lazy(async () => {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
await __webpack_init_sharing__('default');
const container = (window as any)[scope]; // or get the container somewhere else
// Initialize the container, it may provide shared modules
await container.init(__webpack_share_scopes__.default);
const factory = await (window as any)[scope].get(module);
const Module = factory();
return Module;
});
}
return cache[scope];
};

const useDynamicScriptComponent = ({
url,
scope,
module,
}: {
url: string;
scope: string;
module: string;
}): React.LazyExoticComponent<React.ComponentType<any>> => {
const [ready, setReady] = useState(() => !!cache[scope]);
const [failed, setFailed] = useState(false);

useEffect(() => {
if (!url) {
return;
}

if (cache[scope]) {
setReady(true);
setFailed(false);
return;
}

const element = document.createElement('script');

element.src = url;
element.type = 'text/javascript';
element.async = true;

setReady(false);
setFailed(false);

element.onload = () => {
debug.log(`Dynamic Script Loaded: ${url}`);
setReady(true);
};

element.onerror = () => {
debug.error(`Dynamic Script Error: ${url}`);
setReady(false);
setFailed(true);
};

document.head.appendChild(element);

return () => {
debug.log(`Dynamic Script Removed: ${url}`);
document.head.removeChild(element);
};
}, [url, scope]);

if (!ready || failed) {
return null;
}

return loadComponent(scope, module);
};

export default useDynamicScriptComponent;

模块配置管理

实现完动态模块加载后, 就可以将模块路径实现动态注入了, 最开始考虑开发一个独立的服务端接口用于管理, 但后来发现可以直接使用k8s提供的ConfigMaps来实现动态管理模块

首先在public/config/module.js下定义好模块配置

1
2
3
4
5
6
7
window.__appModule = {
app_xxx: {
url: host + 'app-modules/app-xxx/remote-app-xxx.js',
scope: 'app_xxx',
module: './index',
},
};

然后在ConfigMaps中添加对public/config目录的覆盖, 将配置挂载到module.js中, 在需要使用的时候直接使用window.__appModule读取全局变量, 就可以实现热更新模块功能了

同时修改nginx配置, 将模块管理部分独立一个容器, 可以实现所有测试环境共用同一个模块管理后端, 也能减少模块管理的复杂度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
location ^~ /app-modules/ {
proxy_buffering off;
proxy_pass http://app-module-server;
proxy_redirect default;
}

location ~ \.html$ {
add_header Cache-Control "private, no-cache, no-store, must-revalidate";
try_files $uri /index.html;
}

location ~ ^/config/module\.js$ {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
}

app-modules-nginx-server内就使用已有的服务端代码发布流程, 将打包后的代码发布到此容器内即可, 不同应用不同分支都有独立的文件夹, 可以通过外层configmaps随时切换需要的分支

子应用代理动态配置

完成子应用的动态加载后, 如果需要切换子应用的nginx-proxy还是需要重启整个容器, 同样考虑动态配置实现, 最初考虑过使用nginxproxymanager, 但是维护起来比较复杂, 就决定直接使用ConfigMaps来实现了, 配置如下, 这里的conf.d/app-proxy/也是通过ConfigMaps来注入的文件

1
2
3
server {
include conf.d/app-proxy/*.conf;
}

然后开发一个简单的文件监听重启nginx的服务, 可以充分利用nginx的配置检测和热更新功能, 保证了线上环境的正常运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const fs = require('fs');
const path = require('path');
const child_process = require('child_process');

fs.watch(path.resolve('/etc/nginx/conf.d/app-proxy'), async function (event, filename) {
console.log(`event is: ${event}`);
if (filename) {
console.log(`filename provided: ${filename}`);
} else {
console.log(`filename not provided`);
}
child_process.spawn('/usr/sbin/nginx', ['-t'], { stdio: 'inherit' });
child_process.spawn('/usr/sbin/nginx', ['-s', 'reload'], { stdio: 'inherit' });
});

console.log(`nginx conf watching`);

小结

完成以上内容后, 就成功的将Module Federation使用到项目中了. 对比之前的架构, 能实现基础框架动态更新子应用代理/代码模块, 子模块发布代码统一管理, 容器基本不用重启, 基本告别运维压力了. 而且webpack5带来的文件缓存也极大的提高了开发效率, 独立子应用打包速度也有了飞速提升.

页面架构

最终的页面架构如下

sequenceDiagram participant Br as Browser participant BN as Base Nginx participant BA as Base App Code participant SS as Subapp Module Server participant SN as Subapp Module Nginx participant SC as Subapp Module Code alt 页面加载 Br ->> BN: 1. 访问系统 BN ->> BA: 2. 读取基础框架代码 BA -->> Br: 3.1 返回基础框架代码 BA -->> Br: 3.2 返回module.js Br ->> BN: 4. 访问子模块代码 BN ->> SN: 5. 访问子模块代码 SN ->> SC: 6. 读取本地子模块代码 SC -->> Br: 7. 返回子模块代码 Br ->> BN: 8. 发起子模块api请求 BN ->> SS: 9. 转发到子模块server SS -->> Br: 10. 返回请求结果 BN -->> BN: 监听configmaps触发nginx配置更新重启 BN -->> BN: 更新configmaps的module.js文件 SN -->> SN: 更新子模块代码 end

其他

webpack5对比起webpack4确实基本能做到无痛升级, 但Module Federation里是对代码质量有些要求的, 这里记录一下遇到的一些问题和解决方案

codemirror/require

代码中有个地方使用了codemirror, 然后在测试时发现, 如果切换子应用(不同的app_module), 后加载的子应用的codemirror没有样式. 研究后发现是因为codemirror代码中使用的require是相对路径, 在共享模块的时候不会生效, 需要修改为共享模块引用, 于是通过webpack-loader实现打包时动态替换解决了这个问题

1
2
3
4
5
6
7
8
9
10
11
config.module.rules.unshift({
test: /\.js$/,
include: path.resolve('node_modules/codemirror'),
enforce: 'pre',
loader: 'pattern-replace-loader',
options: {
verbose: buildConfig.options.verbose,
search: /"\.\.\/\.\.\/lib\/codemirror"/g,
replace: '"codemirror"',
},
});

d3.js打包失败

在引用d3的时候, npm start不会有问题, 但是在build的时候会有如下报错

1
2
Module not found: Error: Can't resolve '/node_modules/@babel/runtime/helpers/esm/createForOfIteratorHelper' in '/node_modules/d3-array/src'
Did you mean 'createForOfIteratorHelper.js'?

webpack中添加resolve的处理即可

1
2
3
4
5
6
config.module.rules.unshift({
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
});

循环引用

在没有使用Module Federation的时候, 代码里的循环引用不会出问题, 因为webpack的模块加载可以解决循环引用, 但是Module Federation里的循环引用会导致remote.js出现报错, 于是只能加上"import/no-cycle": "error""plugin:import/recommended", "plugin:import/typescript"EsLint插件检测循环引用, 然后调整相关问题代码

React App Framework - 11. Webpack 5 Module Federation

https://mosby-zhou.github.io/2021/10-13-react-app-webpack-5-module-federation/

作者

Mosby

发布于

2021-10-13

许可协议

评论