知海

模块联邦概念

webpackjsorg-main核心概念

模块联邦概念

动机

多个独立的构建应当能够组合成一个应用程序。这些独立的构建彼此充当容器,可以暴露和消费彼此之间的代码,从而形成一个统一的应用。

这通常被称为微前端(Micro-Frontends),但其应用场景不仅限于此。

底层概念

我们区分本地模块(local modules)和远程模块(remote modules)。本地模块是当前构建中的常规模块。远程模块则不属于当前构建,而是在运行时从远程容器(remote container)加载的模块。

加载远程模块被视为异步操作。当使用远程模块时,这些异步操作将被置于远程模块与入口点之间的下一个块加载操作(chunk loading operation)中。没有块加载操作,就无法使用远程模块。

块加载操作通常是 import() 调用,但旧式结构如 require.ensurerequire([...]) 也同样支持。

容器通过容器入口(container entry)创建,它提供对特定模块的异步访问。这种暴露的访问分为两个步骤:

  1. 加载模块(异步)
  2. 执行模块(同步)

步骤 1 将在块加载期间完成。步骤 2 将在模块执行期间与其他(本地和远程)模块交错完成。这样,将模块从本地转换为远程或反向转换,都不会影响执行顺序。

容器可以嵌套。容器可以使用其他容器的模块。容器之间的循环依赖也是可能的。

高层概念

每个构建既充当容器,也消费其他构建作为容器。这样,每个构建都能通过从其容器加载,访问任何其他暴露的模块。

共享模块(Shared modules)既是可覆盖的,也会作为覆盖项提供给嵌套容器。它们通常指向每个构建中的同一个模块,例如,同一个库。

packageName 选项允许设置一个包名,用于查找 requiredVersion。默认情况下,它会根据模块请求自动推断;当自动推断应被禁用时,请将 requiredVersion 设置为 false

构建块

ContainerPlugin(底层)

该插件会创建一个额外的容器入口,其中包含指定的暴露模块。

ContainerReferencePlugin(底层)

该插件将特定容器的引用添加为外部模块(externals),并允许从这些容器导入远程模块。它还会调用这些容器的 override API,以向其提供覆盖项。本地覆盖项(通过 __webpack_override__ 或当构建本身也是容器时的 override API)以及指定的覆盖项都会提供给所有被引用的容器。

ModuleFederationPlugin(高层)

ModuleFederationPlugin 结合了 ContainerPluginContainerReferencePlugin

概念目标

  • 应支持暴露和消费 webpack 支持的任何模块类型。
  • 块加载应并行加载所需的一切(Web 端:对服务器的单次往返)。
  • 从消费者到容器的控制
    • 覆盖模块是单向操作。
    • 同级容器不能覆盖彼此的模块。
  • 概念应与环境无关。
    • 可用于 Web、Node.js 等环境。
  • 共享中的相对请求和绝对请求:
    • 即使未被使用也始终会被提供。
    • 相对于 config.context 解析。
    • 默认不强制要求 requiredVersion
  • 共享中的模块请求:
    • 仅在它们被使用时才提供。
    • 将匹配构建中所有使用的等价模块请求。
    • 将提供所有匹配的模块。
    • 会从图中该位置的 package.json 中提取 requiredVersion
    • 当你有嵌套的 node_modules 时,可以提供和消费多个不同的版本。
  • 共享中包含尾部 / 的模块请求将匹配所有具有此前缀的模块请求。

使用场景

每个页面独立构建

单页应用(SPA)的每个页面都在容器构建中以独立构建的形式暴露。应用外壳(application shell)也是一个独立的构建,将所有页面作为远程模块引用。这样,每个页面都可以独立部署。当路由更新或添加新路由时,应用外壳会被部署。应用外壳将常用库定义为共享模块,以避免在页面构建中重复这些库。

组件库作为容器

许多应用共享一个公共的组件库,可以将其构建为一个容器,并暴露每个组件。每个应用都从组件库容器中消费组件。对组件库的更改可以独立部署,而无需重新部署所有应用。这些应用会自动使用最新版本的组件库。

动态远程容器

容器接口支持 getinit 方法。
init 是一个兼容 async 的方法,调用时接收一个参数:共享作用域(shared scope)对象。此对象在远程容器中用作共享作用域,并填充来自宿主(host)的提供的模块。
可以利用它在运行时动态地将远程容器连接到宿主容器。

init.js

js 复制代码
(async () => {
  // 初始化共享作用域。使用来自此构建和所有远程模块的已知提供模块填充它。
  await __webpack_init_sharing__("default");
  const container = globalThis.someContainer; // 或者从其他地方获取容器
  // 初始化容器,它可能提供共享模块
  await container.init(__webpack_share_scopes__.default);
  const module = await container.get("./module");
})();

T> 容器 是联邦构建暴露的远程容器入口对象,通常通过该远程的 remoteEntry.js 暴露。它提供了此处显示的 getinit 方法。在诸如 window[scope]globalThis.someContainer 的示例中,只有在远程容器脚本已经加载后,该容器才被认为存在。

容器会尝试提供共享模块,但如果共享模块已经被使用,则会发出警告,并且所提供的共享模块将被忽略。容器可能仍会将其用作回退方案。

这样,你就可以动态加载一个 A/B 测试,该测试提供不同版本的共享模块。

T> 在尝试动态连接远程容器之前,请确保你已经加载了该容器。

示例:

init.js

js 复制代码
function loadComponent(scope, module) {
  return async () => {
    // 初始化共享作用域。使用来自此构建和所有远程模块的已知提供模块填充它。
    await __webpack_init_sharing__("default");
    const container = window[scope]; // 由已加载的 remoteEntry.js 脚本暴露的远程容器
    // 初始化容器,它可能提供共享模块
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

loadComponent("abtests", "test123");

查看完整实现

基于 Promise 的动态远程模块

通常,远程模块使用 URL 进行配置,如下例所示:

js 复制代码
export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        app1: "app1@http://localhost:3001/remoteEntry.js",
      },
    }),
  ],
};

但你也可以向此远程模块传递一个 Promise,它将在运行时被解析。你应使用任何符合上述 get/init 接口的模块来解析此 Promise。例如,如果你想通过查询参数来传递应使用的联邦模块版本,可以执行以下操作:

js 复制代码
export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // 这部分取决于你计划如何托管和版本化你的联邦模块
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
        // 注入的脚本已加载并可在 window 上使用
        // 我们现在可以解析这个 Promise
        const proxy = {
          get: (request) => window.app1.get(request),
          init: (...arg) => {
            try {
              return window.app1.init(...arg)
            } catch(e) {
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // 注入此脚本,src 设置为带版本的 remoteEntry.js
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
  ],
});

请注意,使用此 API 时,你_必须_解析一个包含 get/init API 的对象。

动态公共路径(publicPath)

提供宿主 API 来设置 publicPath

可以允许宿主导航运行时设置远程模块的 publicPath,方法是从该远程模块暴露一个方法。

当你将独立部署的子应用挂载到宿主域名的子路径下时,这种方法特别有用。

场景:

你有一个托管在 https://my-host.com/app/* 的宿主应用,以及一个托管在 https://foo-app.com 的子应用。子应用也会被挂载到宿主域名下,因此,期望通过 https://my-host.com/app/foo-app 访问 https://foo-app.com,并且 https://my-host.com/app/foo-app/* 的请求通过代理重定向到 https://foo-app.com/*

示例:

webpack.config.js(远程)

js 复制代码
export default {
  entry: {
    remote: "./public-path",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "remote", // 此名称需要与入口名称匹配
      exposes: ["./public-path"],
      // ...
    }),
  ],
};

public-path.js (远程)

js 复制代码
export function set(value) {
  __webpack_public_path__ = value;
}

src/index.js (宿主)

ts 复制代码
const publicPath = await import("remote/public-path");
publicPath.set("/your-public-path");

// 启动应用 例如 import('./bootstrap.js')

从脚本推断 publicPath

可以通过 document.currentScript.src 从脚本标签推断 publicPath,并在运行时使用 __webpack_public_path__ 模块变量进行设置。

示例:

webpack.config.js(远程)

js 复制代码
export default {
  entry: {
    remote: "./setup-public-path",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "remote", // 此名称需要与入口名称匹配
      // ...
    }),
  ],
};

setup-public-path.js (远程)

js 复制代码
// 使用你自己的逻辑推导出 publicPath,并使用 __webpack_public_path__ API 进行设置
__webpack_public_path__ = `${document.currentScript.src}/../`;

T> output.publicPath 还有一个 'auto' 值可用,它会自动为你确定 publicPath。

故障排查

Uncaught Error: Shared module is not available for eager consumption

应用程序正在急切执行一个充当全向宿主导航的应用程序。有以下几种选择:

你可以在模块联邦的高级 API 中将依赖项设置为 eager,这不会将模块放入异步块中,而是同步提供它们。这允许我们在初始块中使用这些共享模块。但请注意,所有提供的和回退的模块将始终被下载。建议仅在应用程序的某一点(例如外壳)提供它。

我们强烈建议使用异步边界。它会拆分出较大块的初始化代码,以避免任何额外的往返请求,并总体上提高性能。

例如,你的入口文件如下所示:

index.js

jsx 复制代码
import { createRoot } from "react-dom/client";
import App from "./App";

const root = createRoot(document.getElementById("root"));
root.render(<App />);

让我们创建一个 bootstrap.js 文件,将入口文件的内容移动进去,并在入口文件中导入 bootstrap:

index.js

diff 复制代码
+ import('./bootstrap');
- import { createRoot } from 'react-dom/client';
- import App from './App';

- const root = createRoot(document.getElementById('root'));
- root.render(<App />);

bootstrap.js

diff 复制代码
+ import { createRoot } from 'react-dom/client';
+ import App from './App';
+ const root = createRoot(document.getElementById('root'));
+ root.render(<App />);

这种方法有效,但可能存在限制或缺点。

通过 ModuleFederationPlugin 为依赖项设置 eager: true

webpack.config.js

js 复制代码
// ...
new ModuleFederationPlugin({
  shared: {
    ...deps,
    react: {
      eager: true,
    },
  },
});

Uncaught Error: Module "./Button" does not exist in container.

错误信息中可能不是 "./Button",但看起来会类似。此问题通常出现在从 webpack beta.16 升级到 beta.17 时。

在 ModuleFederationPlugin 中,将 exposes 从:

diff 复制代码
new ModuleFederationPlugin({
  exposes: {
-   'Button': './src/Button'
+   './Button':'./src/Button'
  }
});

Uncaught TypeError: fn is not a function

你可能缺少远程容器,请确保已添加它。
如果你已加载了要消费的远程容器,但仍然看到此错误,也请将宿主容器的远程容器文件添加到 HTML 中。

设置 output.uniqueName

在模块联邦设置中,宿主和每个远程模块都必须具有全局唯一的 output.uniqueName。Webpack 默认从 package.json 中的 name 字段推导此值。这意味着,共享相同 package.json name 的两个构建(这是从现有项目中拆出远程模块时的常见模式)可能无法在运行时静默冲突。

一种解决方案是为每个配置使用具有不同名称的单独 package.json

或者,你可以在每个 webpack 配置中显式设置 output.uniqueName

webpack.config.js(宿主)

js 复制代码
export default {
  output: {
    uniqueName: "my-host-app",
  },
  plugins: [
    new ModuleFederationPlugin({
      // ...
    }),
  ],
};

webpack.config.js(远程)

js 复制代码
export default {
  output: {
    uniqueName: "my-remote-app", // 必须与宿主及所有其他远程模块不同
  },
  plugins: [
    new ModuleFederationPlugin({
      // ...
    }),
  ],
};

该值可以是任何字符串,只要它在给定页面上加载的每个联邦构建中是唯一的即可。

帮助我们改进文档

发现翻译问题或内容错误?请告诉我们。