广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Webpack 5 中的 Carrier Universe 载体宇宙是什么?如何用它实现跨应用模块共享?

Webpack 5 的 Module Federation 让跨应用模块共享变成了现实,但当一个团队同时维护多个微前端时,remoteEntry 的数量会跟着应用数量线性增长,而且每个 remote 都可能打包进一份略有差异的 react、react-dom 或 antd。Carrier Universe 载体宇宙正是为了把这种“应用级联邦”细化成“依赖级联邦”而出现的一套实践模式。它把需要共享的基础库放进一个或多个轻量的 carrier 里,并通过 universe 清单统一描述这些 carrier 的版本、入口地址和依赖关系,从而在运行时以更细的粒度完成模块加载与去重。

Webpack 5 中的 Carrier Universe 载体宇宙是什么?如何用它实现跨应用模块共享?

与原生 Module Federation 的容器相比,carrier 不承载业务页面,只承载公共依赖。这样做有两个直接好处:一是业务应用的 remoteEntry 不再包含 react 这类巨型依赖,体积更小;二是当 react 需要升级时,只需发布新的 carrier 版本,并更新 universe 清单,所有接入方不需要重新构建就能在下一轮会话中拿到新版本。下面从核心组成、配置方式、运行时行为和边界限制四个层面展开。

Carrier Universe 的核心组成与配置模型

Carrier Universe 并不是一个单独的 Webpack 官方插件,它更像是一套基于 ContainerPlugin 和 ContainerReferencePlugin 的组合约定。理解它需要先拆分三个角色:载体(carrier)、宇宙清单(universe)和运行时加载器(loader)。载体是实际承载公共依赖的远程包,它可以使用 Webpack 的容器能力构建,也可以借助社区工具生成。与普通业务 remote 不同,carrier 的 exposes 字段不会暴露页面组件,而是暴露 react、react-dom、axios 等第三方库,或者团队内部的工具函数集。

宇宙清单则是一个 JSON 文件,它记录了当前环境中所有可用 carrier 的版本、入口地址、依赖约束和加载顺序。这个清单可以放在 CDN、对象存储或者配置中心里,由每个接入方在启动时拉取。通过把版本信息从构建配置中剥离出来,宇宙清单让依赖升级变成了一项纯数据变更:发布方更新清单里的 carrier 版本号,接入方在下一次会话中就能自动使用新版本。

运行时加载器负责读取清单、按需加载远程入口、处理模块解析和版本冲突。它通常以 npm 包的形式提供,暴露 loadCarrier、resolveModule 等 API。加载器内部会维护一个 carrier 缓存,避免同一个远程包被重复下载。当检测到两个 carrier 都声明了同一个依赖但版本不同时,加载器可以依照 singleton 策略只保留一份实例,或者按照语义化版本规则选择满足大多数约束的版本。

用一个最小示例理解载体宇宙

下面通过一个基础配置来展示 carrier 的组织方式。假设我们有三个微前端应用,它们都需要使用 react 和 react-dom,但各自锁定的版本不完全一致。我们先把这两个依赖打包成一个名为 shared-react 的 carrier,再由各业务应用通过 universe 清单引用它。

构建 carrier 的 Webpack 配置大致如下:

const { Configuration } = require('webpack');
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  plugins: [
    new ModuleFederationPlugin({
      name: 'shared_react_carrier',
      filename: 'remoteEntry.js',
      exposes: {
        './react': 'react',
        './react-dom': 'react-dom',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

这个配置把 react 和 react-dom 作为远程模块暴露出去,并且声明为单例依赖。业务应用不需要在自己的构建里重复打包它们,只需要在运行时从 carrier 获取。为了让所有应用发现这个 carrier,我们需要在 CDN 上放置一个 universe 清单文件:

{
  "version": "1.0.0",
  "carriers": [
    {
      "name": "shared-react",
      "version": "1.0.0",
      "url": "https://cdn.ipipp.com/carriers/shared-react/1.0.0/remoteEntry.js",
      "dependencies": {
        "react": "^18.2.0",
        "react-dom": "^18.2.0"
      }
    }
  ]
}

业务应用侧则通过一个自定义的运行时加载器来消费这个清单。加载器会先请求 universe.json,再根据应用需要加载对应的 carrier。这样做的好处是,业务应用的 Webpack 配置里不再出现 react 的版本锁定,依赖版本完全由 universe 清单和 carrier 自身决定。

运行时如何加载与解析载体

加载器的工作流程可以分为四步:拉取宇宙清单、匹配应用声明的依赖、加载匹配到的 carrier 远程入口、从已加载的 carrier 中解析模块。一个简化的加载代码如下:

async function loadCarrier(carrierName) {
  const universe = await fetchUniverse();
  const meta = universe.carriers.find(c => c.name === carrierName);
  if (!meta) throw new Error('carrier not found: ' + carrierName);
  if (window.__CARRIER_CACHE__[carrierName]) {
    return window.__CARRIER_CACHE__[carrierName];
  }
  await new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = meta.url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
  const container = window[meta.name];
  await container.init({ react: { singleton: true } });
  window.__CARRIER_CACHE__[carrierName] = container;
  return container;
}
const reactModule = await loadCarrier('shared-react').then(c => c.get('./react'));

这里使用动态创建 `<script>` 标签的方式加载远程入口,是因为 remoteEntry.js 本身是一个 UMD 产物,它会在全局挂载一个容器对象。对于需要严格控制加载时机的场景,也可以使用 import() 直接加载远程入口,但需要提前配好 __webpack_require__.l 的 URL 加载逻辑。

缓存是 carrier universe 运行时的一个重要优化点。除了将已加载的容器对象保存在内存中,加载器还可以把 universe 清单和 remoteEntry 文件缓存在 localStorage 或 Service Worker 里。这样在页面刷新时,如果清单的版本号没有变化,就可以跳过网络请求,直接复用本地缓存。不过需要注意的是,缓存策略必须和发布流程配合好:如果 remoteEntry 文件更新了但清单版本号没有变化,就会导致旧模块被继续使用。建议在构建流程中为每个 carrier 生成带内容哈希的文件名,并在发布后更新清单中的 URL。

版本冲突的处理通常依赖 singleton 机制。当 react-dom 被多个 carrier 依赖且版本不一致时,加载器会尝试找到一个能同时满足所有版本约束的实例。如果找不到,它可以给出明确的冲突错误,或者配置成降级使用最高版本。这种策略比原生 Module Federation 的 shared 更灵活,因为 shared 的冲突解决发生在构建阶段,而 carrier universe 的冲突解决可以延迟到运行时,并且可以根据线上清单动态调整。

与原生 Module Federation 的差异和选型建议

原生 Module Federation 的核心思路是构建时声明 shared,运行时协商实际使用的依赖版本。它的优点是配置简单,一个 remotes 字段就能让应用加载另一个应用的模块。但当远程应用数量增加后,shared 依赖的协商结果会变得越来越不可控:不同 remote 之间的版本约束可能互相冲突,最终每个应用还是可能加载到不同的 react 实例,导致 hooks 错误、context 断裂等问题。

Carrier Universe 则把“共享什么”从应用中抽离出来,变成由宇宙清单统一治理。应用不再直接声明共享依赖,而是声明需要哪些 carrier。这样,依赖版本的唯一事实来源就是清单,业务应用的构建产物不会因为依赖升级而改变。对比起来,原生联邦更适合两三个应用之间的点对点共享,而载体宇宙更适合规模更大、依赖关系更复杂的微前端集群。

当然,引入 carrier universe 也需要付出额外成本。首先要维护一个独立的宇宙清单和对应的发布流程,这要求团队有基础平台或工程效能支持。其次,运行时多了一次清单请求,虽然可以通过缓存和预加载缓解,但在弱网环境下仍然会带来首屏延迟。最后,调试链路变长,问题可能出现在 carrier 构建、清单配置、加载器解析或缓存失效中任何一个环节。如果团队规模较小、应用数量不多,使用原生 Module Federation 的 shared 配合 singleton 已经足够,不必过度设计。

广告位 · 文章底部(后台-模块管理替换此区域)