模块联邦实践

83 阅读9分钟

早期 微信图片_20250922174323_104_83.jpg

早期业务,线进行开发的时候,我复杂的业务,和在一个主应用中,发布,测试部署,严重耦合,多个业务线复杂人,迭代,一旦有bug 就会立刻回滚,最终导致上线失败率很低 并且只是改变其中的子页面,模块,导致整个应用,全部都进行拆分 而且业务形态,非常集中以报表,图表为主

所有结合当时的业务形态,我们这里,主要是用模块联邦来处理,进行拆分

首先,划分主子应用边界 左侧栏,以及顶部栏,以及图表,下拉框,表格,都是划到主应用 作为子应用,主要去负责

最后结合具体实践,和坑的整理

技术选型

  1. 公司业务技术栈高度统一,全线,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 ✅ 完全隔离

  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文件里面,还可以自动去重

模块级别的复用

  1. 针对通用库,配合shared 并指定确定版本号, 2. 对于业务模块,配合exposes 指定具体的代码
  2. 强制开启单例模式,并且只共享通用库,不共享纯业务代码
  3. 库的升级,统一由主应用团队,推动,解决.

模块联邦运行时思路

有些疑惑

隔离性

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 驱动

模块联邦

  1. 作为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',
      }
    })

实践中常见坑

  1. 初期的useTheme context 上下文,共享缺失的问题
  2. 主应用,有context的访问,子应用也有类似的访问
  3. 主子应用和主应用,最好是共用一套,全局状态管理

css 隔离

非常常见的css 污染,对于模块联邦,没有内置的隔离

微前端实践1.png

一种方案为,我自己通过建立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 标记,那就直接复用,加载没有就再单独去加载