ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Module Federation 依赖隔离实战:共享代码却不共享状态(isolate-shared-dependencies 深入解析)

Module Federation 依赖隔离实战:共享代码却不共享状态(isolate-shared-dependencies 深入解析)
  • 示例工程

【免费下载链接】module-federation-examples

Implementation examples of module federation , by the creators of module federation

项目地址:https://gitcode.com/gh_mirrors/mo/module-federation-examples
点击查看免费下载

导读

本指南围绕 module-federation-examples 仓库中的 isolate-shared-dependencies 示例 展开,讲解如何在 Module Federation 场景下做到"共享依赖的代码、但隔离依赖的状态"——通过一个可组合的 runtime plugin,在多个容器按父子层级加载且几乎不互相交互时,既节省网络流量与加载耗时,又规避 Vue 2.x 这类单例模式库因全局状态导致的冲突。读完本文,你将掌握该 runtime plugin 的配置写法、隔离原理(resolveShare 钩子 + webpack require 缓存技巧)以及三个应用演示的完整运行方式,并了解如何用 Playwright 端到端验证隔离行为是否符合预期。

问题背景:共享依赖意味着共享状态

Module Federation 的shared机制在多个容器间复用依赖实例时,默认共享的是"代码 + 实例状态"。绝大多数场景下这是理想行为——各应用共用同一个 React 或 Vue 运行时,避免重复下载。但共享状态在某些库上会带来麻烦:

  • 依赖单例模式的库:例如 Vue 2.x 会暴露一个全局实例,当多个容器都在该实例上注册全局组件时,会产生命名冲突或状态互相污染;
  • 父子层级加载场景:多个容器按父 → 子关系依次加载,彼此之间几乎没有交互,此时共享状态不仅无用,反而有害,而共享代码却非常有价值——可以显著减少网络占用、降低加载耗时。

isolate-shared-dependencies 示例正是对这一矛盾的 PoC(概念验证)实现:通过一个 runtime plugin,让每个容器可以精确指定哪些依赖需要隔离,被隔离的依赖仍然共享同一份代码产物(chunk),但每个容器拿到的是各自独立的实例。

核心特性

该示例在 README.md 中明确了三点特性:

  1. 共享代码但不共享状态:减少网络用量与加载耗时;
  2. 可精确指定要隔离的依赖:通过插件参数声明隔离名单;
  3. 所有容器都必须启用该插件:即使某个容器不隔离任何依赖(保持默认共享行为),也必须注册插件,否则会抛出 warning,且依赖无法被正确隔离。

第三条是易被忽略的硬性约束:插件的隔离机制依赖每个容器的__webpack_require__缓存快照,缺了任何一个容器的配合,隔离都会静默失效。

示例架构与角色分工

示例由 5 个部分组成,通过 pnpm workspace 组织在 runtime-plugins/isolate-shared-dependencies 目录下:

组件角色行为
app1父应用,第一个加载共享 vue + shared-lib + shared-lib-2,不隔离任何依赖,使用共享实例;加载 app2
app2子应用,第二个加载隔离 shared-lib(不使用共享实例);加载 app3
app3子应用,最后加载同 app1,不隔离依赖,应与 app1 共享实例
shared-lib共享库实例化时生成随机 ID 并保留,用于直观判断每个应用跑的是哪个实例
shared-lib-2共享库被 shared-lib 传递使用、也被所有应用直接使用,同样生成随机 ID 标识实例

应用之间的加载链路是 app1 → app2 → app3,形成一个三级父子嵌套。三个 Vue 应用分别在 3001、3002、3003 端口提供服务,页面上一秒刷新一次实例 ID,方便肉眼观察实例归属。

shared-lib 与 shared-lib-2:如何"暴露"实例

两个共享库的实现非常精简,核心是模块加载时生成一次随机 ID,之后一直复用:

  • shared-lib/index.js:模块顶层const instanceId = getRandomInt(100000)生成 ID,导出getLib1InstanceId();同时内部 import 了 shared-lib-2 并透传getLib2InstanceIdThroughLib1(),用于演示传递依赖场景;
  • shared-lib-2/index.js:同样在模块顶层生成 ID,导出getLib2InstanceId()。

关键点在于:ID 是在模块工厂执行(实例化)时生成的。如果多个应用共享同一个模块实例,它们拿到的 ID 必然相同;如果某个应用被隔离,它的模块工厂会独立再执行一次,于是拿到一个不同的 ID。这正是演示与测试判断"是否共享实例"的依据。

插件核心:plugin/isolatePluginFactory.ts 逐行拆解

插件的完整实现在 plugin/isolatePluginFactory.ts,依赖@module-federation/runtime(本仓库锁定 2.5.1,见 plugin/package.json)。它返回一个标准FederationRuntimePlugin对象,只实现了两个钩子:beforeInit与resolveShare,与 runtime-plugins/README.md 中 Hook Coverage 表格的描述一致。

工厂函数与参数解析

export default function IsolateSharedDependenciesPluginFactory( options: string[] | IsolateSharedDependenciesPluginOptions = {}, ): FederationRuntimePlugin { const dependencies = Array.isArray(options) ? options : options.dependencies ?? []; if (!Array.isArray(dependencies)) { throw new Error( 'IsolateSharedDependenciesPlugin needs to receive a list of dependencies to isolate', ); } ... }
  • 工厂函数支持两种传参形式:直接传字符串数组,或传{ dependencies: [...] }对象;
  • 若传入的不是数组,会立即抛错,防止配置失误被静默吞掉;
  • 内部用dependenciesToIsolateSet = new Set(dependencies)保存隔离名单,并维护patchedInstances缓存已隔离依赖的实例。

beforeInit:保存容器标识与 webpack require

beforeInit: args => { originId = args.origin.name; (args.origin as ExtendedFederationHost).__webpack_require__ = __webpack_require__; return args; },

每个容器初始化时,插件把当前容器的名称记录为originId,并把该容器编译期注入的__webpack_require__挂到 FederationHost 实例上。这一步是后续"清空模块缓存、重新实例化"的前提——它拿到了该容器 webpack 运行时模块缓存的访问句柄。

resolveShare:隔离逻辑的核心

resolveShare: args => { if (!dependenciesToIsolateSet.has(args.pkgName)) { return args; } const resolvedDependency = args.resolver(); if (!resolvedDependency) { return args; } args.resolver = () => ({ ...resolvedDependency, scope: [originId], lib: undefined, loaded: false, loading: new Promise(async resolve => { const originalFactory = await resolvedDependency.get(); resolvedDependency.lib = originalFactory; resolvedDependency.loaded = true; resolve(() => { if (!patchedInstances[args.pkgName]) { const originInstance = args.GlobalFederation.__INSTANCES__.find( instance => instance.name === resolvedDependency.from, ) as ExtendedFederationHost; ... } return patchedInstances[args.pkgName]; }); }), }); return args; },

resolveShare是 Module Federation runtime 的 share 策略钩子,负责为每个依赖产出"resolver"(解析器)。插件的处理逻辑可分四步理解:

  1. 命中隔离名单才处理:args.pkgName不在dependenciesToIsolateSet中时原样返回,依赖走默认共享路径;
  2. 改造 resolver:命中时调用原始args.resolver()拿到共享描述对象,然后返回一个副本:scope限定为[originId](防止与其他容器合并共享作用域)、lib置空、loaded置 false,并重写loadingPromise;
  3. 等待原始模块工厂:await resolvedDependency.get()先取到其他容器已经执行过的模块工厂(保证共享的代码 chunk 只下载一次);
  4. 包装工厂执行:真正require时,若该依赖尚未隔离过,则执行一段"缓存隔离"技巧(见下节),并把产物存入patchedInstances复用;若其他容器没有注册本插件(缺少__webpack_require__),则打印 warning 并回退为返回原始工厂——这正是 README 第三条特性的代码落点。

隔离的底层技巧:清空 webpack require 缓存再实例化

const originCache = originInstance.__webpack_require__.c; const savedOriginCache = { ...originCache }; Object.keys(originCache).forEach(key => { delete originCache[key]; }); patchedInstances[args.pkgName] = originalFactory(); Object.keys(originCache).forEach(key => { delete originCache[key]; }); Object.keys(savedOriginCache).forEach(key => { originCache[key] = savedOriginCache[key]; });

这段代码是插件的"魔术"所在,思路非常直接:

  • 找到第一个容器(即 resolvedDependency 的提供方from)的__webpack_require__.c模块缓存表;
  • 把缓存快照保存下来,然后临时清空全部缓存;
  • 调用originalFactory()重新执行模块工厂——由于缓存已被清空,模块会重新初始化,生成全新的实例 ID,也就是"隔离出的独立实例";
  • 紧接着再次清空缓存(防止重执行产生的中间产物残留),最后把快照完整还原,确保其他模块的缓存不受影响。

整个操作发生在同一 tick 内同步完成,因此对运行时其他模块是透明的:代码 chunk 只加载一次,但被隔离依赖的实例被"复制"出了一份独立的。

三份 webpack 配置:如何声明隔离名单

三个应用都基于@module-federation/enhanced的 ModuleFederationPlugin 构建,差异仅在name、端口与runtimePlugins参数上。

app1/webpack.config.js(父应用,不隔离):

new ModuleFederationPlugin({ experiments: { asyncStartup: true }, name: 'app1', shareStrategy: 'loaded-first', filename: 'remoteEntry.js', manifest: false, shared: ['vue', 'shared-lib', 'shared-lib-2'], exposes: { '.': './src/index.ts' }, runtimePlugins: [[require.resolve('../plugin/isolatePluginFactory.ts'), { dependencies: [] }]], })

app2/webpack.config.js(子应用,隔离 shared-lib):

runtimePlugins: [[require.resolve('../plugin/isolatePluginFactory.ts'), { dependencies: ['shared-lib'] }]],

app3/webpack.config.js(子应用,不隔离):

runtimePlugins: [[require.resolve('../plugin/isolatePluginFactory.ts'), { dependencies: [] }]],

需要留意的配置要点:

  • runtime plugin 元组形式:runtimePlugins数组的每一项是[插件模块路径, 插件选项]元组,插件选项会作为工厂函数参数传入。这是 runtime plugin 的标准传参方式,也是 runtime-plugins/README.md 中所说的 "tuple runtime plugin parameters";
  • 三个应用必须都注册插件:app1、app3 虽传{ dependencies: [] },但依然注册了插件,否则 warning 出现且隔离失效;
  • shareStrategy: 'loaded-first':保证共享解析时优先复用先加载容器的实例,是隔离逻辑得以成立的基础策略;
  • exposes: { '.': './src/index.ts' }:每个应用对外暴露自己的挂载函数,由父应用通过loadRemote动态加载后调用,见 app1/src/index.ts 导出的mount({ parentContainer })。

页面如何呈现实例 ID:App.vue 的观察窗口

三个应用的 App.vue 结构一致,模板渲染三段信息:

<span>Lib 1 instance ID: {{ sharedLib1Id }} | </span> <span>Lib 2 instance ID through lib 1: {{ sharedLib2IdThroughLib1 }} |</span> <span>Lib 2 instance ID: {{ sharedLib2Id }}</span>

data()初始化时分别调用getLib1InstanceId()、getLib2InstanceIdThroughLib1()、getLib2InstanceId()取值,并设置setInterval每 1000ms 刷新一次(见 app1/src/App.vue),这样运行时实例归属一目了然:

  • app1 与 app3 不隔离,二者显示相同的 Lib1 / Lib2 ID(共享实例);
  • app2 隔离了 shared-lib,其 Lib1 ID 与 app1/app3 不同,但 Lib2(shared-lib-2 未被隔离)仍与 app1/app3 一致;
  • 每个应用内 "Lib 2 instance ID through lib 1" 与 "Lib 2 instance ID" 始终相等,说明传递依赖与直接依赖解析到同一个 shared-lib-2 实例。

父子加载通过 runtime 的init+loadRemote实现:app1 在 mounted 中init注册 remote app2 并loadRemote('app2')后调用其mount(app1/src/App.vue),app2 同样加载 app3(app2/src/App.vue),app3 则不再加载子应用。

运行演示与验证

启动三个应用

在 runtime-plugins/isolate-shared-dependencies 目录下执行:

pnpm run start

该命令(见 package.json 的pnpm --filter isolate-shared-dependencies_app* --parallel start)会并行构建并启动 app1、app2、app3,分别监听:

  • http://localhost:3001/ (app1,父应用)
  • http://localhost:3002/ (app2,子应用)
  • http://localhost:3003/ (app3,子应用)

打开 3001 端口页面即可看到三个应用嵌套渲染并实时显示各自的实例 ID。

端到端验证:Playwright 测试

仓库自带完整的 Playwright 测试 e2e/checkIsolatedDependencies.spec.ts,测试配置见 playwright.config.ts(基于 runtime-plugins/playwright.base.ts 的createRuntimePluginPlaywrightConfig生成,默认 baseURL 指向 3001)。运行方式:

pnpm run test:e2e

测试对"隔离是否真的生效"做了精确断言:

  • 三个应用都成功加载(App 1/2/3 loaded可见);
  • app2 的 Lib1 ID 与 app1 不同,证明 shared-lib 被隔离出独立实例;
  • app3 的 Lib1/Lib2 ID 与 app1 相同,证明不隔离的应用仍共享实例;
  • 每个应用内部 Lib2 直接与透传 ID 一致,证明 shared-lib-2 的传递依赖解析正确。

其中extractInstanceId通过正则/\d+/从页面文本中抽取数值并比较,是验证共享/隔离行为的核心断言逻辑(见 checkIsolatedDependencies.spec.ts)。

实现要点总结与适用边界

  • 适用场景:多个容器以父子关系加载、彼此几乎不交互,但希望复用依赖的代码产物以降低网络与加载成本;或使用了 Vue 2.x 这类依赖全局单例的库,需要规避全局状态冲突;
  • 实现路径:beforeInit保存容器标识与__webpack_require__,resolveShare命中隔离名单后改写 resolver,通过临时清空并还原首个容器的模块缓存,强制模块工厂重新执行以得到独立实例;
  • 硬性前提:所有参与容器都必须注册本插件,且依赖的提供方必须先行加载(loaded-first策略),否则插件会打印[IsolateSharedDependenciesPlugin] ... can't isolate ...形式的 warning 并回退为共享实例;
  • 限制说明:该实现是官方仓库中的 PoC(概念验证)示例,仓库 README 也标注了部分功能 "Not implemented yet",生产环境接入前应结合自身依赖图评估其适用性。

若想进一步探索 runtime plugin 体系,可继续阅读 runtime-plugins 目录,其中展示了beforeRequest、errorLoadRemote、createScript、beforePreloadRemote等更多钩子的用法,以及 control-sharing、remote-router 等其他策略型插件示例。

  • 示例工程

【免费下载链接】module-federation-examples

Implementation examples of module federation , by the creators of module federation

项目地址:https://gitcode.com/gh_mirrors/mo/module-federation-examples
点击查看免费下载

相关推荐

上一篇:uni-app x 鸿蒙(HarmonyOS)断点调试完全指南:uts / uvue / ets 调试与联编调试实战
下一篇:基于 QMK 固件打造 Cluecard RGB 灯光效果测试键位:原理、配置与 HID 调试实战

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进