Skip to content

第9章 Module Federation 设计哲学

"微前端的终局不是更好的沙箱,而是让沙箱变得不再必要。"

本章要点

  • 理解从"独立构建独立部署"到"运行时共享模块"的范式跃迁
  • 掌握 Module Federation 四大核心概念:Host、Remote、Shared、Exposes
  • 深入 remoteEntry.js 的加载机制与模块注册流程
  • 理解 Shared 依赖的版本协商算法:singleton、requiredVersion、eager 的设计权衡
  • 对比 Module Federation 1.0 与 2.0 的架构跃迁
  • 厘清 Module Federation 与运行时加载方案(乾坤/single-spa)的本质区别

2020 年 10 月,Webpack 5 正式发布。在长达两年的开发周期中,Webpack 团队悄然引入了一个不起眼的新特性——ModuleFederationPlugin。发布说明中只有寥寥数行描述。没有铺天盖地的营销,没有"改变前端未来"的宣言。

但那行不起眼的代码,掀起了一场静默的革命。

在 Module Federation 之前,前端模块的共享边界是构建产物。无论你的代码拆得多细,最终都要打包成一个或多个 bundle,以完整的构建产物为单位进行部署和加载。乾坤加载的是一个完整的子应用 HTML,single-spa 加载的是一个完整的子应用入口 JS——粒度再小也是"应用"级别。

Module Federation 打破了这个边界。它让不同的构建产物之间可以在运行时共享任意粒度的模块——一个组件、一个函数、一个配置对象。就像 Node.js 的 require 可以引用任何本地模块一样,Module Federation 让你的浏览器端代码可以"require"另一个独立构建、独立部署的应用中的任何导出模块。

这不是渐进式改良。这是范式转换。

要理解这场转换的深意,我们需要从头开始——从微前端最基本的架构假设说起。

9.1 从"独立构建独立部署"到"运行时共享模块"

9.1.1 传统微前端的架构假设

乾坤和 single-spa 共享一个基本假设:每个子应用是一个独立的、完整的前端应用。 它有自己的 package.json,自己的构建流程,自己的入口文件,自己的路由系统。主应用通过某种机制(HTML Entry 或 JS Entry)在运行时加载这个完整的应用,然后将其挂载到页面上的某个 DOM 容器中。

typescript
// 乾坤的架构模型:以"应用"为加载单元
interface QiankunApp {
  name: string;
  entry: string;          // 子应用的完整入口 URL
  container: string;      // 挂载的 DOM 节点
  activeRule: string;     // 路由匹配规则
}

// 注册子应用
registerMicroApps([
  {
    name: 'order-app',
    entry: 'https://order.example.com',  // 加载一个完整的应用
    container: '#subapp-container',
    activeRule: '/order',
  },
  {
    name: 'product-app',
    entry: 'https://product.example.com', // 又一个完整的应用
    container: '#subapp-container',
    activeRule: '/product',
  },
]);

这个架构模型有三个隐含前提:

  1. 加载粒度 = 应用:你无法只加载订单应用中的 OrderList 组件,必须加载整个订单应用
  2. 依赖各自独立:每个子应用自带全部依赖,React 可能被加载多次
  3. 隔离是必需的:因为多个完整应用共享同一个页面,必须通过沙箱防止相互污染
typescript
// 传统微前端的依赖加载示意
// 主应用
import React from 'react';        // React 实例 #1 (18.2.0)
import ReactDOM from 'react-dom';

// 订单子应用(独立构建)
import React from 'react';        // React 实例 #2 (18.2.0) —— 重复!
import ReactDOM from 'react-dom';

// 商品子应用(独立构建)
import React from 'react';        // React 实例 #3 (18.3.0) —— 又一份!
import ReactDOM from 'react-dom';

// 三份 React,三份 ReactDOM
// gzipped 后约 42KB × 3 = 126KB 的冗余

乾坤试图通过 externals 和全局变量约定来缓解这个问题——让所有子应用使用主应用加载的 React。但这种方案本质上是一种运行时约定:没有编译器参与、没有版本检查、没有类型安全。一旦某个子应用忘记配置 externals,或者需要一个不同版本的 React,整个约定就会崩溃。

9.1.2 Module Federation 的范式转换

Module Federation 的核心洞见可以用一句话概括:模块共享不应该是运行时的约定,而应该是编译时的契约。

javascript
// webpack.config.js - 订单应用
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  output: {
    publicPath: 'https://order.example.com/',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'orderApp',
      filename: 'remoteEntry.js',
      // 暴露模块:声明哪些模块可以被其他应用引用
      exposes: {
        './OrderList': './src/components/OrderList',
        './OrderDetail': './src/components/OrderDetail',
        './useOrder': './src/hooks/useOrder',
      },
      // 共享依赖:声明哪些依赖应该在运行时共享
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { requiredVersion: '^6.0.0' },
      },
    }),
  ],
};
javascript
// webpack.config.js - 商品应用(消费方)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  output: {
    publicPath: 'https://product.example.com/',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'productApp',
      filename: 'remoteEntry.js',
      // 远程模块:声明要从哪里引用模块
      remotes: {
        orderApp: 'orderApp@https://order.example.com/remoteEntry.js',
      },
      exposes: {
        './ProductCard': './src/components/ProductCard',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

现在,在商品应用的任何代码中,你可以这样写:

typescript
// 商品应用的某个页面
import OrderList from 'orderApp/OrderList';
import { useOrder } from 'orderApp/useOrder';

const ProductPage: React.FC = () => {
  const { orders } = useOrder();

  return (
    <div>
      <h1>商品详情</h1>
      {/* 直接使用订单应用暴露的组件 */}
      <OrderList orders={orders} />
    </div>
  );
};

注意这段代码——import OrderList from 'orderApp/OrderList'。在编译时,Webpack 知道 orderApp 是一个远程模块,它不会试图从本地 node_modules 中解析这个路径,而是生成一段运行时加载逻辑,在浏览器中动态获取订单应用暴露的 OrderList 模块。

这里发生了三个根本性的变化

  1. 加载粒度从"应用"变为"模块":你只加载需要的 OrderList 组件,不需要加载整个订单应用
  2. 依赖从"各自独立"变为"协商共享":React 只加载一份,版本由运行时协商决定
  3. 隔离从"必需"变为"不需要":模块之间不存在全局变量污染的问题,因为它们通过正式的模块接口交互

9.1.3 一个类比:从集装箱运输到管道网络

传统微前端像集装箱运输——每个子应用是一个密封的集装箱,里面装满了它需要的所有东西(包括重复的公共依赖)。主应用是港口,负责接收和卸载集装箱。为了防止集装箱之间的货物相互污染,你需要一套复杂的仓储隔离系统(沙箱)。

基于 VitePress 构建