早期
早期业务,线进行开发的时候,我复杂的业务,和在一个主应用中,发布,测试部署,严重耦合,多个业务线复杂人,迭代,一旦有bug 就会立刻回滚,最终导致上线失败率很低 并且只是改变其中的子页面,模块,导致整个应用,全部都进行拆分 而且业务形态,非常集中以报表,图表为主
所有结合当时的业务形态,我们这里,主要是用模块联邦来处理,进行拆分
首先,划分主子应用边界 左侧栏,以及顶部栏,以及图表,下拉框,表格,都是划到主应用 作为子应用,主要去负责
最后结合具体实践,和坑的整理
技术选型
- 公司业务技术栈高度统一,全线,react webpack ,多个模块级别,高度复用,交互样式,逻辑,高度统一,复用需求高
js 隔离
qiankun js 是通过 代理.隔离, iframe 是完全独立的浏览器环境, 模块联邦,原生不提供 css 隔离,iframe 独立的文档,样式, qiankun 是通过scoped 作用域, 模块联邦,我们是通过css module 构建层去解决 全局变量: ifame 是完全独立的window 对象,qiandkun 代理window ,模块联邦阿是共享的window ,我们通过eslint 校验,以及事件中心去解决问题
js 隔离方案
iframe
// 主应用
window.foo = 1
// iframe 子应用
window.foo = 2
// 主应用
console.log(window.foo) // 1 ✅ 完全隔离
- qiankun
// 主应用
window.foo = 1
// 子应用(在沙箱中)
proxyWindow.foo = 2 // 写入 fakeWindow
// 主应用
console.log(window.foo) // 1 ✅ 代理隔离
// 但是:
// 子应用如果直接操作原型
Array.prototype.hack = function() {}
// 主应用会受影响 ❌
模块联邦,完全共享,无隔离
css 隔离
iframe 不同浏览器环境,完全隔离 qiankun 通过scoped 来进行处理 ,但是全局选择器,会受到影响
/* 主应用 */
.button { color: red; }
/* 子应用(经过 scoped 处理)*/
[data-qiankun="app-a"] .button { color: blue; }
/* 结果:大部分情况不冲突 */
/* 但全局选择器(html, body, *)可能冲突 ⚠️ */
模块联邦无隔离,需要用css Modules 进行隔离
性能
qiankun 应用级别,单独进行加载,库重复加载,性能中等,运行时 沙箱,创建,内存占用中等 模块联邦: 组件级别加载,库自动去重,无运行时消耗 ,内存占用小
模块联邦,解决的问题
主要解决的是 模块级别复用的问题,可以做到应用之间的模块,组件代码复用
具体什么叫做模块级别的复用
作为模块联邦真正复用的是代码模块,传统微前端,复用的是整个子应用,无法跨应用import 模块,组件 比如金融报表业务,真正想复用一个通用表格模块,或者顶部组件,只能通过模块联邦去处理
依赖去重
作为模块联邦多个 react 同版本,子应用都依赖,如果想避免冲突,可以配置
shared: {
react: { singleton: true, requiredVersion: false, eager: true },
'react-dom': { singleton: true, requiredVersion: false, eager: true },
antd: { singleton: true, eager: true },
axios: { singleton: true },
'react-router-dom': { singleton: true },
'rea
隔离性
模块联邦,不具备隔离特性,只能通过约束,eslint 去规避 js 污染
host remote
host 作为业务模块的使用者 remote 作为模块的提供者 两种关系不固定,看具体谁是提供模块的
remoteEntry.js
是什么,一个运行时的模块,注册中心,具体包含了,引用的主/子应用对应,所共用的模块,注册表,对应的路径,以及动态导入语句,
// remoteEntry.js 的简化结构
;(function() {
// 1. 在 window 上挂载一个全局变量
window.app_order = {
// 2. 提供 get 方法来获取模块
get(moduleName) {
// 根据模块名返回对应的代码
if (moduleName === './OrderList') {
return import('./src_components_OrderList.js')
}
if (moduleName === './OrderDetail') {
return import('./src_components_OrderDetail.js')
}
},
// 3. 声明依赖(shared)
init(sharedScope) {
// 初始化共享依赖
}
}
})()
之后会以script 标签来进行引用xxx.remoteEntry.js 文件
初始化的时候,会根据模块路径,动态import 模块 当具体引用的时候,会进行替换,导入模块,然后默认导出引用
entry 为何叫 entry 呢
是remote应用唯一对外入口, 相当于主应用访问子应用模块的入口文件
import
模块联邦的import 是运行时跨应用动态导入加载,文件不不会被打包到host 应用里的bundle.js文件里面,还可以自动去重
模块级别的复用
- 针对通用库,配合shared 并指定确定版本号, 2. 对于业务模块,配合exposes 指定具体的代码
- 强制开启单例模式,并且只共享通用库,不共享纯业务代码
- 库的升级,统一由主应用团队,推动,解决.
模块联邦运行时思路
有些疑惑
隔离性
qiankun 强隔离沙箱
// 子应用 A
window.foo = 1 // 写入沙箱
// 子应用 B
console.log(window.foo) // undefined(隔离)
// Remote A
window.foo = 1 // 写入真实 window
// Remote B
console.log(window.foo) // 1(共享 window)
技术选型
技术栈是否统一: 完全不统一, 还是以qiankun代表 细粒度组件共享需求: MF 强隔离,性能: 强调强隔离用qiankun 强调性能,以MF
隔离性: qiankun 有沙箱,mF 无隔离 共享: qiankun 无法共享依赖,MF 可以自动去重 粒度: qiankun 应用级别, mF 属于模块级别复用 驱动: qiankun 路由驱动, mF import 驱动
模块联邦
- 作为webpack 承担的功能,解决的是单体复杂应用,如难以单独部署,协作问题,以及
拆分一个巨型应用
先去配置一个最基本的模块联邦微应用: 以下以一些
// 主应用
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
new ModuleFederationPlugin({
name: 'hostApp',
filename: 'remoteEntry.js',
exposes: {
'./RouteGuard': './src/components/RouteGuard',
'./GlobalConfigContext': './src/context/GlobalConfigContext',
'./ThemeContext': './src/context/ThemeContext',
'./ActionToolbar': './src/components/ActionToolbar',
'./SmartTable': './src/components/SmartTable/SmartTable',
'./BaseChart': './src/components/BaseChart/BaseChart',
'./DateProcessor': './src/components/DateProcessor',
'./useBusinessData': './src/hooks/useBusinessData',
'./biz-utils': './src/utils/biz-utils',
...
},
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: false, eager: true },
'react-dom': { singleton: true, requiredVersion: false, eager: true },
antd: { singleton: true, eager: true },
axios: { singleton: true },
'react-router-dom': { singleton: true },
'react-redux': { singleton: true },
},
}),
//
//子应用
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
new ModuleFederationPlugin({
name: 'remoteApp',
// 这里的也必须用这个remoteEntr.js吗
filename: 'remoteEntry.js',
// 这里暴露的是业务模块,不是共用的组件
exposes: {
'./ReportModule': './src/modules/ReportModule/ReportModule',
'./OptionChainModule': './src/modules/OptionChainModule/OptionChainModule'
},
remotes: {
hostApp: 'hostApp@http://localhost:3000/remoteEntry.js',
}
})
实践中常见坑
- 初期的useTheme context 上下文,共享缺失的问题
- 主应用,有context的访问,子应用也有类似的访问
- 主子应用和主应用,最好是共用一套,全局状态管理
css 隔离
非常常见的css 污染,对于模块联邦,没有内置的隔离
一种方案为,我自己通过建立xxx.module.css 文件,来进行模块化处理
{
// 1. 处理 .module.less (开启 CSS Modules)
test: /\.module\.less$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]___[hash:base64:5]', // 生成唯一的类名
},
},
},
'less-loader',
],
},
<div className="report-module-container>
<div className={styles.reportModuleContainer}
由于本公司技术栈,以及金融报表业务,不允许,有太多的自定义样式, 因此没有采用更灵活的 css-in-js模式.
const Button = styled.button`
background: blue;
color: white;
`;
通过这种模式来进行处理,那么其他模块,主应用自然样式是不会干扰的.
模块联邦为何不自己提供呢?
js隔离
模块联邦本身不提供任何有关js 隔离方案 常见问题 组件全局配置污染 原型链劫持 这些问题,主应用,和其他子应用人员,不一定会及时沟通,所以造成各种线上问题
import 'moment/locale/zh-cn';
moment.locale('zh-cn');
// 恶意修改全局对象
window.fetch = () => Promise.reject('🔥 Remote App 劫持了 fetch!');
const { Option } = Select;
message.config({
top: 600, // 极其夸张的位置
duration: 5,
});
实际开发对于全局对象的修改现象,很难排查 更多的可以去利用eslint 配置
// eslint.config.js (ESLint 9+)
const reactPlugin = require('eslint-plugin-react');
const js = require('@eslint/js');
const globals = require('globals');
const windowRule = require('./no-window-mutate'); // 引入文件
module.exports = [
js.configs.recommended,
{
files: ['src/**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2021,
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2021,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
react: reactPlugin,
local: { // 定义一个叫 "local" 的插件
rules: {
'no-window-mutate': windowRule // 注册规则
}
},
},
rules: {
// --- 零容忍的红线规则 (Error) ---
// 禁止修改原生对象原型链 (如 Array.prototype.map)
// 'local/no-window-mutate': 'error', // 引入上面的规则
'no-extend-native': 'error',
// 禁止对全局只读对象(如 window, document)进行重新赋值
"no-global-assign": ["error", { "exceptions": [] }],
'no-restricted-syntax': [
'error',
{
selector: "AssignmentExpression[left.object.name='window']",
message: "禁止修改 window 对象的属性!这是全局污染。"
}
],
"no-restricted-globals": ["error", {
"name": "window",
"message": "请不要直接使用 window 对象,如需共享请使用发布订阅总线"
}]
,
// 禁止给 window/global 赋值
'no-global-assign': 'error',
// --- 宽松的兼容规则 (Off) ---
// 对于遗留代码,暂时关闭所有非致命规则,避免满屏黄字
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'no-unused-vars': 'off', // 彻底关闭未使用变量检查
'no-undef': 'off', // 彻底关闭未定义变量检查
'no-console': 'off', // 允许 console.log
},
},
];
全局总线
实际场景下,我们设定统一的 EventBus 来进行处理
异常兜底
为了解决子应用,加载异常或超时,通过动态远程加载,remoterEntry.js文件,异常加载,重试 本质是利用 remoteentry 为 script 脚本加载,=
const remoteAppStr = `promise new Promise(resolve => {
// 1. 定义远程地址
const url = 'http://localhost:3001/remoteEntry.js';
// 2. 封装加载函数 (支持重试)
const loadScript = (retries = 0) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
// 加载成功,按照 MF 协议初始化
const container = window.remoteApp;
// 这里的 __webpack_init_sharing__ 和 __webpack_share_scopes__ 是 Webpack 运行时全局变量
// typeof 判断防止报错
if (typeof __webpack_init_sharing__ !== 'undefined') {
__webpack_init_sharing__('default').then(() => {
container.init(__webpack_share_scopes__.default);
resolve(container);
});
} else {
// 如果环境不支持(极少见),直接 resolve
resolve(container);
}
};
script.onerror = () => {
if (retries < 3) {
console.warn('Loading remote failed, retrying...', retries + 1);
// 延迟 1 秒重试
setTimeout(() => loadScript(retries + 1), 1000);
} else {
console.error('Remote offline after 3 retries.');
// 兜底:返回一个伪造的模块,防止应用崩溃
// 这里的 proxy 会让 import('remote/xxx') 返回一个永远 resolve 的 Promise,但内容为空
resolve({
get: () => Promise.resolve(() => () => "Remote Offline"), // 渲染一个简单的文本组件
init: () => { }
});
}
};
document.head.appendChild(script);
};
loadScript();
})`
module.exports = remoteAppStr
依赖共享
依赖冲突
对于react 这类库,要求必须同版本号,否则会引发严重兼容性问题 所以子应用统一使用,strictVersion: true ,作为工程规范上的第一层拦截
shared: {
react: { singleton: true, requiredVersion: false ,requiredVersion: '^16.0.0',strictVersion: true},
'react-dom': { singleton: true, requiredVersion: false },
wind-ui: { singleton: true },
axios: { singleton: true },
'react-router-dom': { singleton: true },
'react-redux': { singleton: true },
},
如果遇到这种版本问题
Unsatisfied version 18.3.1 from hostApp of shared singleton module react (required ^16.0.0)
如果遇到,确实有些子应用,因断裂式升级,无法复用,那么就不使用singleton ,重复加载
shared 承担,去配置需要共享的库 可以设置是否要单例,以及,严格版本匹配 懒加载: eager
如果不去单独配置,会导致,每个子应用都会产生重复包加载,性能,内存手影响
那么singleton 机制是什么呢 主应用,先去加载 react 子应用后续再去加载 判断sharedScoped 是否存在兼容版本,并且标记singelton 标记,那就直接复用,加载没有就再单独去加载