Webpack 5 的模块联邦能力让多个独立构建的应用可以像使用本地模块一样共享代码,但共享一旦变多,依赖版本冲突、全局状态串扰、副作用重复执行等问题就会逐渐暴露。这些痛点本质上都指向同一个缺陷:模块来自不同构建产物,却在运行时被塞进了同一个可见作用域,失去了应有的边界。Border Universe 边界宇宙就是社区针对这一场景提出的隔离思路,它把每一次远程容器加载都视作一个独立的运行宇宙,模块查找、依赖解析和副作用执行都在该宇宙内部完成,不同宇宙之间只有通过明确的共享契约才能发生交互。

边界宇宙要解决的模块隔离困境
在传统 Webpack 打包模式下,所有代码经过编译后会被包裹进一个统一的运行时闭包,模块 ID 和依赖关系在同一个作用域链中解析。只要一个页面上加载了两个由不同团队维护的微前端应用,它们各自的 React、Vue 或公共工具库就可能被重复实例化。即使两个应用都使用了 React,一旦版本不同,就会导致 Hook 规则断裂、上下文丢失,甚至出现一份页面上存在两个 React 副本的诡异现象。
模块联邦虽然提供了 shared 配置来声明共享依赖,但共享依赖的协商过程仍然发生在同一个全局容器上下文中。如果远程应用没有正确声明 requiredVersion,或者某个依赖被多个远程入口以不同版本重复提供,协商结果就变得不可预测。边界宇宙的核心思路是让每个远程容器拥有独立的依赖解析优先级:先在自己的容器作用域内查找模块,找不到再向宿主或其他宇宙请求。这样即便多个远程容器携带了不同版本的同一个库,也不会在运行时互相覆盖。
这种隔离并不是简单地把代码放进 iframe 或沙箱,而是通过 Webpack 运行时提供的模块工厂函数和容器注册表来实现逻辑层面的作用域隔离。每个边界宇宙内部维护一份模块缓存,模块的初始化顺序、循环依赖的处理以及 tree shaking 后的导出映射都只在当前宇宙内生效。
// 伪代码:边界宇宙内部的模块查找顺序
function resolveModule(moduleId, currentUniverse) {
if (currentUniverse.modules[moduleId]) {
return currentUniverse.modules[moduleId];
}
const sharedCandidate = currentUniverse.sharedScope[moduleId];
if (sharedCandidate) {
return sharedCandidate;
}
throw new Error(`Module ${moduleId} not found in universe ${currentUniverse.name}`);
}
边界宇宙的运行时实现机制
Webpack 5 在产物中会注入一个全局的容器运行时,每个通过模块联邦暴露的远程入口都会注册为一个容器。容器内部存储了模块的工厂函数、导出映射以及共享作用域对象。边界宇宙的运行时隔离正是在这个容器对象上叠加了一层作用域包装,使得模块解析函数不再直接访问全局 module 缓存,而是先访问当前宇宙的缓存。
举例来说,当宿主应用加载一个远程按钮组件时,远程按钮组件内部可能 import 了 lodash。在没有边界隔离的情况下,远程按钮会优先使用宿主页面全局已经存在的 lodash 实例,或者反过来把自己的 lodash 注入全局,造成宿主其他模块的预期行为被改变。引入边界宇宙后,远程按钮会在自己的容器作用域中查找 lodash,只有在该容器明确将 lodash 声明为共享依赖时,才会进入宿主的共享作用域进行版本协商。
共享依赖的协商机制是边界宇宙能够保持边界的同时又允许高效复用的关键。Webpack 的 sharedScope 对象可以理解为一个跨宇宙的公共契约区,每个宇宙在创建时可以声明自己愿意共享的依赖名称、版本范围以及是否允许单例。协商过程会按照优先级选择满足版本范围的实例,并将选择结果写回所有参与宇宙的共享作用域,从而避免后续加载时重复计算。
// webpack.config.js 中配置边界宇宙相关的共享依赖
module.exports = {
output: {
publicPath: 'auto',
uniqueName: 'host-universe'
},
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js'
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: false
},
lodash: {
singleton: false,
requiredVersion: '^4.17.0'
}
}
})
]
};
上面的配置中,singleton 为 true 时表示整个页面只允许存在一个该依赖的实例,所有宇宙共享同一份模块;singleton 为 false 时则允许不同宇宙保留自己的副本,只是在版本兼容时优先复用。这种灵活的协商粒度让边界宇宙既能隔离不同应用的内部实现,又不会因为过度隔离导致公共库体积成倍增长。
用边界宇宙隔离远程模块依赖的实战配置
假设我们有一个宿主应用和一个远程组件库,宿主使用 React 18,远程组件库内部依赖了某个只兼容 React 17 的图表库。如果直接加载远程组件,图表库会错误地使用宿主提供的 React 18,导致运行时崩溃。借助边界宇宙的思路,我们可以让远程容器内部保留一份 React 17 的副本,并且只在远程组件的宇宙内生效,对外部宿主完全透明。
远程容器的配置需要将 React 声明为非共享依赖,同时在容器内部通过别名或 resolve 配置把 React 指向远程构建自己的版本。宿主侧则完全不需要知道远程组件内部用了什么版本的 React,只需正常加载远程组件即可。这样就形成了一个清晰的边界:远程组件宇宙内的所有依赖解析都限定在远程构建产物的范围内,不会泄漏到宿主宇宙。
// 远程组件库 webpack.config.js
module.exports = {
output: {
publicPath: 'auto',
uniqueName: 'remote-chart-universe'
},
resolve: {
alias: {
react: path.resolve(__dirname, 'node_modules/react-17')
}
},
plugins: [
new ModuleFederationPlugin({
name: 'remoteChart',
filename: 'remoteEntry.js',
exposes: {
'./Chart': './src/components/Chart'
},
shared: {
// react 不进入 shared,由远程宇宙内部自行解析
}
})
]
};
宿主应用加载远程组件时,可以指定远程容器的作用域名称,通过容器引用而不是全局变量来访问远程模块。Webpack 会在运行时根据容器名称创建对应宇宙,并把远程模块的依赖解析上下文切换到该宇宙。这样即使远程图表库内部使用了过时的 React 生命周期,也不会影响宿主应用中的 React 18 组件树。
在调试过程中,可以通过浏览器开发工具观察 Webpack 的全局容器注册表,看到每个容器就是一个独立的宇宙实例,各自的 module 缓存和 sharedScope 对象互不干扰。这种可视化让开发者能够更直观地理解模块边界到底隔离到了什么程度。
边界宇宙的应用场景与注意事项
边界宇宙特别适合微前端架构中多个团队共享基础设施但又不希望相互制约的场景。例如,一个大型后台系统中不同子应用可能由不同团队维护,有的团队希望升级 React 到 18,有的团队还在使用 17。通过边界宇宙隔离,每个子应用可以在自己的容器内保留所需版本的依赖,同时通过 shared 机制共享那些确定可以统一的公共库,既保证了升级自由度,又避免了重复打包严重的基础库。
另一个常见应用是组件库的多版本共存。同一个页面可能需要同时渲染基于 antd 3 和 antd 4 开发的业务组件,如果全部放在同一个作用域,样式和全局配置会互相覆盖。边界宇宙让不同版本的组件库在各自容器中实例化,样式作用域和全局 ConfigProvider 上下文不会跨边界传播,从而实现在同一页面安全地混用新旧组件。
不过边界宇宙并非万能。它主要解决的是 JavaScript 模块依赖和运行时作用域的问题,对于 DOM 结构和 CSS 样式,仍然需要配合其他隔离方案,比如 CSS Modules、Shadow DOM 或样式命名空间。此外,过多的边界宇宙会增加运行时容器的初始化开销,模块工厂函数的包装和共享作用域的维护都会消耗少量内存和计算资源,因此在设计架构时需要权衡边界粒度和性能之间的关系。
实际落地时,建议先梳理清楚哪些依赖必须跨团队共享,哪些依赖应该封闭在各自宇宙内。共享依赖应当保持稳定且向后兼容,边界内的依赖则可以按需升级。通过合理的 shared 配置和容器命名,可以让边界宇宙的隔离能力发挥最大价值,同时避免运行时复杂度失控。