Module Federation 共享库版本不一致时,别只靠沟通,试试在构建阶段就把冲突拦下来
在实际落地 Module Federation 的多团队架构里,共享库版本漂移最终都会变成线上事故,区别只是早晚。靠人肉沟通、靠文档约定、靠 code review 提醒,都挡不住依赖树悄悄变化。真正有效的做法是把一致性校验嵌进构建流程,在 CI 阶段就让版本冲突直接失败。
为什么沟通管不住共享依赖版本
Module Federation 的共享机制本身就带有容错性。shared 配置里可以声明 singleton、requiredVersion、strictVersion,但这些参数只是运行时协商的依据,不是构建期的硬约束。两个 remote 各自打包时,如果都声明了 react 为 shared,但一个用 18.2.0,另一个用 18.3.1,只要 requiredVersion 写得宽松(比如 ^18.0.0),运行时 Webpack 会静默加载其中一个版本,另一个 remote 拿到的可能是同一份,也可能是两份不同的副本——取决于版本范围是否匹配、是否 singleton、加载顺序等多个因素。
这就带来一类极难排查的问题:本地开发环境一切正常,因为每个开发者的 node_modules 状态、构建缓存、启动顺序都不同;到了线上,某个 remote 加载了不兼容的补丁版本,出现 hooks 状态错乱、上下文丢失或者更隐蔽的渲染异常。而这类问题的根因,在代码层面根本看不出来,得去翻构建产物里的版本声明。
沟通之所以失效,核心原因有三个。第一,依赖版本信息分散在每个仓库的 package.json、锁文件、以及构建时的实际解析结果里,单靠人眼比对不可行。第二,版本漂移往往是间接依赖引入的——你锁了 react@18.2.0,但某个第三方库升级后要求 react@>=18.3.0,包管理器自动帮你升了,没人注意到。第三,多团队发布节奏不同,A 团队这周升级了,B 团队下个月才升,中间这段时间线上就是混合版本在跑。
构建期拦截的核心思路:让版本事实暴露在产物里
要解决这个问题,第一步不是写校验脚本,而是让每个构建产物显式声明自己实际使用的共享依赖版本。这不是靠约定,而是靠工具链强制输出。
Webpack 5 的 Module Federation 在构建时会在产物中生成 remoteEntry.js,其中包含了 shared scope 的初始化逻辑。但这个文件是压缩过的,而且运行时协商逻辑复杂,不适合直接解析。更可靠的方案是在构建阶段注入自定义插件,把实际解析到的共享依赖版本写入一个独立的 manifest 文件。
具体做法是写一个 Webpack 插件,在 compilation 的 afterProcessAssets 钩子里,遍历 compilation.modules,找到所有属于 webpack/sharing/consume 或 webpack/sharing/provide 类型的模块,提取它们的 version 字段和 name 字段。这些模块是 Webpack 内部为 shared 依赖生成的包装模块,version 字段就是该构建实际使用的版本号。
// shared-version-plugin.js
class SharedVersionManifestPlugin {
constructor(options = {}) {
this.filename = options.filename || 'shared-versions.json';
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('SharedVersionManifestPlugin', (compilation) => {
compilation.hooks.afterProcessAssets.tap(
{ name: 'SharedVersionManifestPlugin', stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONS },
() => {
const sharedModules = {};
for (const module of compilation.modules) {
const moduleType = module.constructor?.name || '';
if (moduleType === 'ConsumeSharedModule' || moduleType === 'ProvideSharedModule') {
const name = module._name || module.name;
const version = module._version || module.version;
if (name && version) {
sharedModules[name] = version;
}
}
}
const manifest = JSON.stringify(sharedModules, null, 2);
compilation.emitAsset(this.filename, new compiler.webpack.sources.RawSource(manifest));
}
);
});
}
}
module.exports = SharedVersionManifestPlugin;
在每个 remote 的 Webpack 配置中挂上这个插件,构建后就会在 dist 目录下生成 shared-versions.json,内容类似:
{
"react": "18.2.0",
"react-dom": "18.2.0",
"lodash": "4.17.21"
}
这个文件就是构建期的事实来源。它记录的不是 package.json 里声明的范围,也不是锁文件里的版本,而是本次构建实际打包进去的版本。间接依赖导致的漂移、锁文件未同步导致的差异,都会在这个文件里暴露出来。
把比对逻辑放进 CI,而不是靠人去看
有了 manifest 文件,下一步是自动化比对。核心问题是:谁来定义“正确”的版本?
在一个多团队架构中,通常有一个 host 应用或者一个共享配置仓库作为基准。最直接的做法是让 host 应用在构建时也生成同样的 manifest,然后所有 remote 在 CI 中拉取 host 的最新 manifest 进行比对。
比对逻辑不复杂,但需要明确策略。对于 singleton 类型的共享依赖(如 react、react-dom),版本必须完全一致,连补丁版本都不能有差异。原因是 singleton 意味着运行时只会加载一个实例,如果两个 remote 声明了不同版本,实际加载哪个版本取决于加载顺序,这本身就是不确定性。
对于非 singleton 的共享依赖(如 lodash),可以允许版本范围兼容,但必须显式声明 requiredVersion 并在 CI 中校验实际版本是否落在范围内。如果落在范围外,构建失败。
比对脚本可以写成独立的 Node.js 脚本,放在共享配置仓库中,通过 npm 包或 git submodule 的方式分发到各个 remote 的 CI 流程里:
// check-shared-versions.js
const fs = require('fs');
const path = require('path');
const semver = require('semver');
function checkSharedVersions(localManifestPath, baselineManifestPath, policy) {
const local = JSON.parse(fs.readFileSync(localManifestPath, 'utf8'));
const baseline = JSON.parse(fs.readFileSync(baselineManifestPath, 'utf8'));
const errors = [];
for (const [pkg, localVersion] of Object.entries(local)) {
const baselineVersion = baseline[pkg];
if (!baselineVersion) {
// 本地声明了 shared,但 baseline 里没有——可能是新增的共享依赖
// 这种情况需要人工确认,不应该静默通过
errors.push(`${pkg}: local has ${localVersion}, but baseline does not declare it`);
continue;
}
if (policy.singleton.includes(pkg)) {
if (localVersion !== baselineVersion) {
errors.push(
`${pkg}: singleton version mismatch — local=${localVersion}, baseline=${baselineVersion}`
);
}
continue;
}
if (policy.ranges[pkg]) {
if (!semver.satisfies(localVersion, policy.ranges[pkg])) {
errors.push(
`${pkg}: ${localVersion} does not satisfy required range ${policy.ranges[pkg]}`
);
}
}
}
// 反向检查:baseline 里声明了 singleton,但 local 没有
for (const pkg of policy.singleton) {
if (baseline[pkg] && !local[pkg]) {
errors.push(`${pkg}: baseline declares ${baseline[pkg]} as singleton, but local build does not include it`);
}
}
if (errors.length > 0) {
console.error('Shared dependency version conflicts detected:');
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}
console.log('Shared dependency versions are consistent.');
}
module.exports = { checkSharedVersions };
策略文件 policy.json 明确列出哪些包是 singleton、哪些包有版本范围要求。这个文件应该由架构组维护,放在共享配置仓库中,变更需要走专门的评审流程。
更进一步:把 manifest 发布为可消费的制品
上面的方案解决了“构建时发现冲突”的问题,但还有一个操作层面的痛点:每个 remote 的 CI 都要去拉取 host 的最新构建产物或者访问共享配置仓库,这在网络隔离、权限管理复杂的场景下并不总是顺畅。
更彻底的方案是把 host 的 shared-versions manifest 发布为一个 npm 包(或者内部制品库的独立制品),版本号跟随 host 发布节奏。remote 的 CI 只需要安装这个包,就能拿到基准版本信息。这样比对逻辑就变成了纯本地操作,不依赖网络访问其他仓库。
// shared-version-baseline/package.json
{
"name": "@org/shared-version-baseline",
"version": "2024.11.0",
"main": "shared-versions.json",
"files": ["shared-versions.json", "policy.json"]
}
remote 的 CI 流程变成:
npm install @org/shared-version-baseline@latest
node ./scripts/check-shared-versions.js \
--local ./dist/shared-versions.json \
--baseline ./node_modules/@org/shared-version-baseline/shared-versions.json \
--policy ./node_modules/@org/shared-version-baseline/policy.json
这个方案的额外好处是版本有了明确的审计轨迹。哪次发布引入了共享依赖变更、哪个 remote 在哪个时间点没有跟上,全部可以通过包版本号和构建日志追溯。
一个真实场景的教训
我们团队在两年前经历过一次因为共享依赖版本不一致导致的线上事故。当时的架构是一个 host 加四个 remote,react 和 react-dom 声明为 singleton。某次一个 remote 团队升级了一个图表库,该图表库的新版本依赖 react@^18.3.0,而当时其他团队都还在 18.2.0。包管理器自动把那个 remote 的 react 升到了 18.3.1,但 shared 配置里的 requiredVersion 写的是 ^18.0.0,所以构建没有报任何错误。
线上表现为:某个页面在操作后出现 React 状态完全错乱,组件树正常但数据流断了。排查了整整两天,最后发现是两个 React 实例同时存在——host 加载了 18.2.0,那个 remote 因为版本不匹配,Webpack 回退加载了它自己打包的 18.3.1。两个实例之间的 context 不互通,导致状态更新丢失。
事后复盘时,我们意识到 requiredVersion: '^18.0.0' 这个看似合理的宽松声明,实际上是在告诉 Webpack“这两个版本可以共存”,而 singleton 参数又告诉它“只能有一个实例”。这两个配置放在一起本身就是矛盾的。但当时没有人意识到这个矛盾,因为构建过程没有给出任何信号。
引入 manifest 比对机制后,类似的问题在 CI 阶段就会直接失败。那个 remote 的构建会生成 {"react": "18.3.1"},而 baseline 是 {"react": "18.2.0"},singleton 策略要求完全一致,构建立刻报错,开发者被迫停下来处理版本问题,而不是把问题推到线上。
这个方案不做什么
需要明确一点:构建期的版本比对解决的是“版本一致性的可见性和强制力”问题,它不解决“应该升到哪个版本”的决策问题。版本升级策略、兼容性评估、灰度发布这些仍然需要人来判断。这个方案的价值在于把决策的时间点从“线上出问题之后”提前到“代码合入之前”,并且把判断标准从“我觉得应该没问题”变成“构建明确告诉你有问题”。
另外,这个方案针对的是 Module Federation 的 shared 机制。如果你的架构里没有使用 shared,而是每个 remote 各自打包所有依赖,那版本冲突的问题不存在,但包体积和运行时隔离的问题会更突出,那是另一个话题。
常见问题
为什么不用 strictVersion: true 直接让 Webpack 在构建时报错?
strictVersion 确实可以在构建时报错,但它只校验本地的 requiredVersion 声明和实际解析版本是否一致,不解决跨应用之间的版本对齐问题。每个 remote 的 requiredVersion 可以不同,strictVersion 只会检查“我自己声明的要求是否被满足”,而不会检查“我的版本和 host 的版本是否一致”。对于 singleton 依赖,即使所有 remote 都开启了 strictVersion,只要各自的 requiredVersion 范围有交集但版本不同,构建仍然会通过,运行时仍然可能出现多实例问题。
manifest 里记录的是直接依赖还是包括间接依赖?
取决于 Webpack 的 shared 配置。如果你在 shared 里显式声明了某个包,Webpack 会为它生成 ProvideSharedModule 或 ConsumeSharedModule,这些模块会出现在 compilation.modules 里,插件就能捕获到。如果某个包没有被声明为 shared,而是被直接打包进产物,它就不会出现在 manifest 里。所以这个方案的覆盖范围取决于你的 shared 声明策略。建议把运行时强耦合的包(如 React、React Router、状态管理库)都显式声明为 shared,这样它们就能被纳入比对范围。
多版本并行发布时,baseline 包怎么管理?
这是实际操作中最容易出问题的地方。如果 host 和 remote 的发布节奏不同,baseline 包版本会频繁变更。建议的做法是:baseline 包只反映“当前生产环境实际运行的版本”,而不是“即将发布的版本”。也就是说,只有当 host 的新版本真正部署到生产环境后,才发布对应的 baseline 包。这样 remote 在开发新功能时,如果依赖了尚未上线的 host 版本,CI 会提示版本不一致,开发者就需要等待 host 发布或者调整依赖——这虽然会带来一些流程上的约束,但避免了“开发环境用新版本、生产环境用旧版本”的错位。