Skip to content

依赖共享三路线

基于微前端 2026 生态 · 核于 2026-07

速查

  • 问题面(Fowler 原文):「如果每个微前端都带一份自己的 React,就是在强迫用户下载 n 次 React」;但共享的对价是构建耦合回潮——「重新引入了构建时耦合……可能落得一场大规模协同升级」
  • 三路线按版本协商发生的时机分:externals + import maps(部署时集中裁定)、Module Federation shared(运行时现场协商)、不共享(各自打包,接受重复)
  • single-spa 立场:大库共享、小库各自带——React/Vue/Angular/moment/rxjs 这类大库「只加载一次至关重要」;react-router 级别的小库「小到可以重复」;共享越多,升级越要全体同步
  • import maps 已 Baseline Widely available(2023-03 起):JSON 控制裸说明符(bare specifier)→ URL 的解析;必须在任何模块加载之前声明;type="importmap" 的 script 不能用 src 外链
  • import maps 三键:imports(全局映射,尾斜杠做前缀匹配)、scopes按引用者路径给不同映射 → 同页多版本共存,最具体 scope 优先、逐级回退)、integrity(模块级 SRI 校验)
  • SystemJS 是历史兼容层:import maps 未原生化年代的 polyfill(System.register 格式);2026 年新项目用原生 ESM + 原生 import maps 即可
  • MF shared 双端声明:共享是「注册 + 消费」双向协议——生产者和消费者都要shared 里声明同一依赖(shareKey 一致);只有一端声明时,另一端照旧打包/回退自己的副本
  • singleton(默认 false):开启后共享作用域(share scope)内只留一个版本——版本不一致时加载较高版本,低版本一方收控制台警告;React 这类全局内部状态库必须开
  • singleton 的反直觉后果:你实际运行的 React 版本由页面里版本最高的参与者决定,本地 lockfile 说了不算——「可能加载出乎意料的版本」不是 bug 是协商结果
  • requiredVersion(默认取自 package.json):声明可接受的版本范围,不满足时警告并使用共享域中可用版本;strictVersion 把警告升级为拒绝——有本地 fallback 就用 fallback,配了 import: false 没有 fallback 则运行时抛错
  • eager(默认 false):共享模块打进入口同步可用(不必等异步协商),代价是入口体积膨胀且提供的模块总会被下载;shareStrategyversion-first(默认,初始化即拉全部 remote 保证最高版本,远端挂了启动就炸)vs loaded-first(按需、容错好)
  • 子路径坑shared: ['react-dom'] 只拦精确的 import 'react-dom'——react-dom/client打进本地副本,第二份 React DOM 直接破坏单例(hydration 报错);要写 'react-dom/' 尾斜杠前缀匹配
  • 混用禁忌(single-spa 原文立场):同一依赖不要同时走 import maps 与 MF shared——两套解析各自为政,双份加载、版本漂移;一个页面选一条路线当权威源

一、问题面:n 份 React 与共享的对价

五个子应用各自打包,用户就下载五份 React——Fowler 把这笔账算得直白:「重复依赖增加了用户必须下载的字节数」。但他同一段里就把另一头的代价也标了价:一旦为了去重把公共依赖抽出来,「我们就重新引入了构建时耦合」,升级 React 变成全体子应用的协同升级战役——这恰恰背叛了微前端「自治」的初衷。

所以依赖共享不是「要不要去重」的技术题,而是拿部署独立性换字节的架构交易。三条路线本质是三种成交方式:

路线版本协商时机裁决者
externals + import maps部署时import map 的维护者(集中裁定)
Module Federation shared运行时share scope 里的 semver 协商(现场裁定)
不共享各应用自己(不交易)

「不共享」是严肃选项而非摆烂——Fowler 指出独立编译天然自带按页 code-split,性能账未必输(详见预加载与性能代价)。下面展开前两条。

二、externals + import maps:集中裁定路线

这是 single-spa 官方立场的做法,机制分两半。构建侧:webpack externals(或等价配置)把 React 这类大库从每个应用的产物里剔除,产物里只留 import "react" 这个裸说明符。运行时侧:浏览器原生 import map 告诉模块加载器这个裸说明符去哪取:

html
<!-- 必须出现在任何模块加载之前;type="importmap" 的 script 不能用 src 外链 -->
<script type="importmap">
  {
    "imports": {
      "react": "https://cdn.example.com/react@18.3.1/index.js",
      "react-dom": "https://cdn.example.com/react-dom@18.3.1/index.js",
      "@org/api": "https://cdn.example.com/org-api/v42/api.js",
      "lodash/": "https://cdn.example.com/lodash@4.17.21/"
    },
    "scopes": {
      "/legacy-app/": {
        "react": "https://cdn.example.com/react@17.0.2/index.js"
      }
    },
    "integrity": {
      "https://cdn.example.com/react@18.3.1/index.js": "sha384-…"
    }
  }
</script>

这张 JSON 就是全页面依赖的权威裁定书,三个键各管一件事(MDN 一手语义):

  • imports:裸说明符 → URL 的全局映射。键以 / 结尾即前缀匹配"lodash/" 覆盖 lodash/debounce 等全部子路径),多键可匹配时最具体的键优先;值必须是绝对 URL 或 /./../ 开头的路径。
  • scopes按引用者(referrer)URL 路径给出不同映射——上例中 /legacy-app/ 目录下加载的模块拿到 React 17,其余拿 18。这是同页多版本共存的原生方案,渐进升级的直接工具;匹配规则是最具体 scope 优先、没命中逐级回退到 imports
  • integrity:给模块 URL 挂 SRI 哈希,锁死 CDN 内容(与 <script integrity> 同格式)。

工程约束同样来自一手文档:import map 必须在首个模块加载前声明并处理完(晚了不生效);type="importmap" 的 script 禁止 src/async/defer 等属性——不能外链,得内联或由服务端渲染进 HTML;多张 map 会合并进全局表,但已解析/已映射的说明符先者优先(后来的映射被丢弃);映射只作用于 import 语句与 import(),不影响 <script src> 与 worker。

构建侧的配对动作只有一行——把共享库声明为外部依赖,产物里保留裸说明符:

js
// 每个微前端的 webpack 配置:React 不进产物,运行时交给 import map 解析
module.exports = {
  externals: ["react", "react-dom"], // import "react" 原样保留在产物里
  output: { libraryTarget: "module" }, // 产 ESM,与浏览器模块图对接
  experiments: { outputModule: true },
};

single-spa 在这条路线上的配套立场值得整体吸收:

  • 什么该共享:「大型库只加载一次对 Web 应用至关重要」——React/Vue/Angular/moment/rxjs 被点名;
  • 什么不该:react-router 这类「小到可以重复」的库各自带,保留独立升级权;
  • 为什么不全共享:共享的依赖必须全体微前端同步升级——共享名单越长,被锁死的自由越多。

运维上,import map 本身成了一等部署产物:CI 经 import-map-deployer 并发安全地更新映射(防多条流水线的竞态)、本地用 import-map-overrides 把某个模块指回 localhost——「改一行 JSON 就是一次发布」。至于教程里常见的 SystemJS:那是 import maps 未原生化年代的 polyfill(把代码编译成 System.register 格式模拟模块语义)。import maps 自 2023-03 起已 Baseline Widely available,新项目直接原生 ESM + 原生 import map,SystemJS 只在需要兼容化石浏览器时才请回来。

三、Module Federation shared:运行时协商路线

MF 的 shared 机制不设集中裁定者:每个应用照常打包自己的依赖副本,运行时把副本注册进共享作用域(share scope,默认名 'default'),用的时候按 semver 现场协商「页面里已有的副本能不能用、用谁的」。所有关键行为都从这个模型推得出来。

双端声明。共享是「注册 + 消费」的双向协议:生产者在 shared 里声明,副本才进 scope;消费者也在 shared 里声明(同一 shareKey),构建器才会把 import 'react' 改写成「先查 scope」的运行时查找。四种组合的结局值得列成真值表:

生产者声明消费者声明结局
共享成立:scope 协商出一个副本,双方共用
消费者照旧打包并使用自己的副本——共享静默失效,页面双份依赖
scope 无人提供 → 消费者回退到自己的 fallback 副本(import 所指)
✓ 且 import: false无 fallback 可回——运行时报错,模块加载失败

第二行是实践中最阴险的一种:没有报错、没有警告,只是 bundle 分析里多出一份 React——共享失效的排查要靠产物体积与 MF DevTools,而不是控制台。

singleton:最高版本获胜。默认 false(版本不一致时各用各的副本)。开启后 scope 里同一 shareKey 只允许一个实例——MF 文档的裁决规则:版本不一致时加载较高版本,低版本一方在控制台收到警告。React、Vue 这类依赖全局内部状态的库必须开 singleton(两份 React 实例 = hooks 报错、context 断裂)。由此推出一个反直觉但必然的后果:你实际运行的版本由页面里最高版本的参与者决定——你 lockfile 锁的 18.2.0,遇上某个 remote 带着 18.3.1 进场,全页面跑的就是 18.3.1。「singleton 可能加载出乎意料的版本」不是 bug,是协商机制的定义行为——治理手段是让所有参与方的版本范围收敛(配合 requiredVersion),而不是指望 lockfile。

requiredVersion 与 strictVersion:从警告到拒绝requiredVersion(默认从自家 package.json 推断)声明「我能接受的版本范围」;scope 里的版本不满足时,默认行为是控制台警告然后照用可用版本——宽容但危险。strictVersion 把宽容关掉:版本不满足即拒绝该共享模块——有本地 fallback 就静默用 fallback;配了 import: false(无 fallback)则运行时直接抛错。webpack 一手文档还给了默认值规则:有本地 fallback 且非 singleton 时默认 true,其余默认 false。三档语义排成一条治理光谱:不设 requiredVersion(全凭 semver)→ requiredVersion(不合就警告)→ strictVersion(不合就拒绝)

eager 与 shareStrategy:时机的代价。共享模块默认异步加载(要等 scope 初始化完成,这也是 MF 应用入口普遍要 import('./bootstrap') 动态引导的原因)。eager: true 把共享模块直接打进入口、同步可用——代价是入口体积膨胀,且提供的模块总会被下载(不管最后用没用上)。shareStrategy 决定协商的积极程度:默认 version-first——初始化阶段就把所有 remote 的入口文件拉一遍,登记各家版本以保证「最高版本」裁决准确;代价写在 MF 文档的 warning 里——任何一个 remote 离线,启动期就触发 beforeLoadShare 阶段的加载失败,没配错误兜底就是白屏。loaded-first 反过来:优先复用已加载的副本、remote 按需再拉——版本裁决可能「非全局最优」,换来启动性能与离线容错。生产系统若 remote 可用性没有保证,loaded-first + 错误兜底是更稳的组合。

子路径坑:尾斜杠前缀匹配。现代库大量走子路径导出(react-dom/clientlodash/debounce),而 shared 的键默认精确匹配

ts
// ❌ 只拦截 import 'react-dom' —— react-dom/client 打进本地副本:
//    页面出现第二份 React DOM,singleton 破裂,hydration 报错
shared: { 'react-dom': { singleton: true } }

// ✅ 尾斜杠 = 前缀匹配,拦下该命名空间全部子路径(MF 文档原方案)
shared: {
  'react-dom': { singleton: true },
  'react-dom/': { singleton: true }, // 覆盖 react-dom/client、react-dom/server……
}

MF 文档把这条列为 Common mistake:漏了尾斜杠时「import { createRoot } from 'react-dom/client' 会加载第二份 React DOM,破坏 React 的单例要求并引发 hydration 错误」。以上是机制层全貌;exposes/remotes 等插件配置细节见 webpack 深入,MF 的架构层与 2.0 运行时生态见 Module Federation 叶

四、对比与混用禁忌

维度externals + import mapsMF shared不共享
协商时机部署时(改 map 即发布)运行时(scope 内 semver 协商)
裁决者map 维护者(集中、可审计)最高版本参与者(分散、动态)各应用
多版本共存scopes 按路径显式指派非 singleton 时各用各的天然多版本
失败模式map 指错 URL → 全页面同错(好排查)版本漂移、意外版本获胜(难排查)重复下载、体积上浮
构建工具要求产 ESM + externals 即可(工具无关)构建器需 MF 支持(webpack/Rspack/官方 Vite 插件)
对 ESM 的态度原生模块图,天生 ESM自有运行时容器,ESM/非 ESM 皆可无所谓
代表立场single-spa 官方Module Federation 本命Fowler 的性价比提醒

最后是混用禁忌。single-spa 文档在共享依赖一节明确建议不要对同一依赖混用两套机制:import maps 与 MF shared 是两套互不知晓的解析体系——前者活在浏览器模块图里,后者活在 MF 运行时容器里。同一个 React 两边各挂一份,结果就是双份下载、两个模块实例(singleton 名存实亡)、以及「哪边的版本在跑」无从推理。原则一句话:一个页面里,一个依赖只能有一个权威解析源。工程混用(如 single-spa 编排 + MF 共享)是成熟模式,但依赖名单必须二选一划界。

小结

依赖共享是拿部署独立性换字节的交易,三条路线对应三种成交方式。import maps 路线把裁决权交给一张集中维护的 JSON——imports 定全局映射、scopes 供同页多版本、integrity 锁内容,Baseline Widely available(2023-03 起)意味着 SystemJS 已退居历史兼容层;配套的 single-spa 立场是「大库共享、小库各自带」。MF shared 路线把裁决搬到运行时——双端声明才成交、singleton 让最高版本获胜(你的 lockfile 说了不算)、requiredVersion/strictVersion 决定不合规时是警告还是拒绝、version-first 用启动期全量拉取换版本最优、子路径必须尾斜杠前缀匹配。两条路线不要在同一依赖上混用。共享名单本身则永远从「不共享」起步——每加一个共享项,就多一分 Fowler 警告的构建耦合。下一页算总账:预加载与性能代价