- 示例工程
【免费下载链接】module-federation-examples
Implementation examples of module federation , by the creators of module federation
本指南以仓库 css-isolation 示例为蓝本,讲解如何在 Module Federation 架构下,利用 Shadow DOM 包装器实现 host 与 remote 应用之间 CSS 的完全隔离。你将掌握一套三步式隔离技术(Shadow DOM 容器渲染、all: initial阻断继承、自定义 style-loader 插入逻辑),理解其底层源码实现,并学会运行演示与 Playwright 端到端测试来验证隔离效果。
背景:为什么微前端需要“物理级”的 CSS 隔离
在微前端实践中,CSS 泄漏是最常见的痛点之一。通常的解法是“风格约束”:
- 避免使用全局样式;
- 手动或借助 CSS-in-JS 库对样式做作用域(scoping)处理;
- 使用 CSS Modules 让类名自动局部化。
但对于存量单体应用拆分为微应用的场景,上述方案往往成本极高——历史代码里充满全局 CSS;而像 Bootstrap 这类自带全局 reboot 规则的第三方库,更是无法通过改名或加前缀解决。此时需要一种更简单、更彻底的手段:把 remote 应用的 CSS 从物理结构上“关进笼子”。
本示例采用的思路与 Web Components 一脉相承:让 remote 应用运行时把自己“装进”一个 Shadow DOM 容器中,利用 Shadow DOM 天然隔离样式的作用域,彻底切断 CSS 在 host 与 remote 之间的相互泄漏。该示例建立在仓库中 different-react-versions-isolated 示例的基础之上,进一步解决了样式隔离问题。
架构总览:两个应用、两种 React 版本
示例包含两个应用:
- app1(host):宿主应用,使用 React 16.14 与 ReactDOM 16.14(见 app1/package.json);
- app2(remote):远程应用,使用 React 17.0.2 与 ReactDOM 17.0.2(见 app2/package.json)。
app2 通过 Module Federation 暴露一个 injector 函数,宿主应用 app1 导入该函数后,将 app2 注入到页面中一个指定的 div 内。app1 在 webpack.config.js 中声明远程依赖:
new ModuleFederationPlugin({ experiments: { asyncStartup: true }, name: 'app1', shareStrategy: 'loaded-first', remotes: { app2: `app2@${getRemoteEntryUrl(3002)}`, }, })app2 则在 webpack.config.js 中暴露./appInjector模块:
new ModuleFederationPlugin({ experiments: { asyncStartup: true }, name: 'app2', shareStrategy: 'loaded-first', library: { type: 'var', name: 'app2' }, filename: 'remoteEntry.js', exposes: { './appInjector': './src/appInjector', }, })从依赖声明可见,两个应用刻意使用不同的 React 版本且未做共享(share),这正是为了配合 Shadow DOM 场景下的隔离验证——两个 React 实例互不干扰地共存于同一页面。
三步隔离技术(Isolation Technique)
整套隔离方案由三个环节组成,任何一个缺失都会导致 CSS 泄漏。
第 1 步:在 Shadow DOM 容器中渲染 remote 应用
Web Components 之所以能自带隔离样式,是因为其内部渲染在一个独立的 DOM 子树(Shadow DOM)中,与外部文档的 CSS 规则天然隔绝。本示例复用同一机制:app2 在加载后,动态地把自己“塞进”一个 Shadow DOM 容器里,从而阻止自己的 CSS 泄漏到 host 应用。
第 2 步:在 Shadow DOM 容器外层施加all: initial
Shadow DOM 的样式隔离并非绝对——默认情况下,Shadow DOM 内部的样式会继承来自 Light DOM(外部文档)的样式。因此示例在容器元素上显式设置style.all = 'initial',强制将所有可继承属性重置为初始值,从源头阻断 host 的 CSS 向 remote 渗透。
第 3 步:改造 remote 应用的 style-loader 注入目标
这是最关键、也最容易遗漏的一环。Webpack 的 style-loader 默认会把 CSS 以<style>标签的形式注入到文档的<head>中。这意味着:即使 remote 应用被渲染进了 Shadow DOM,它的样式依然会全局生效、影响所有应用。
示例的解决办法是:在 app2 的 Webpack 配置中,为 style-loader 传入一个自定义插入函数(insert选项):
{ test: /\.css$/, use: [ { loader: 'style-loader', options: { insert: require.resolve('./styleLoader.js'), }, }, 'css-loader', ], }这个自定义函数具备双模式能力:
- 嵌入模式(app2 被 host 加载):将 CSS 注入 Shadow DOM 容器,而非
<head>; - 独立运行模式(app2 单独访问):行为与默认 style-loader 完全一致,样式照常注入
<head>。
此外,考虑到部分 CSS 可能在更晚的时机被懒加载(lazy load),自定义逻辑还维护了一个“当前应用已创建的所有 Shadow DOM 容器”的注册表:一旦有 CSS 脚本被懒加载,会自动将其追加到所有已存在的 Shadow DOM 容器中,确保懒加载样式不会漏出作用域。
源码拆解:styleLoader.js 的核心实现
所有 Shadow DOM 包装与 style-loader 相关的配置都集中在 remote 应用 app2 的 styleLoader.js 中。host 应用 app1 只需在需要时注入 app2、不需要时销毁它即可,完全不需要感知隔离细节。
模块级状态
const styles = []; const containers = {}; let isStandalone = false;styles:记录当前应用注入过的所有样式节点;containers:按父元素 id 维护的 Shadow DOM 容器注册表;isStandalone:标记当前是否处于独立运行模式。
createShadowContainer:创建隔离容器
export const createShadowContainer = parentElementId => { const shadowContainer = document.getElementById(parentElementId); // Block all styles coming from the light DOM shadowContainer.style.all = 'initial'; shadowContainer.attachShadow({ mode: 'open', delegatesFocus: true }); shadowContainer.shadowRoot.append(...styles.map(style => style.cloneNode(true))); // Create a body element so that reboot CSS rules work in the shadow DOM const body = document.createElement('body'); // Create a placeholder for the React app const appPlaceholder = document.createElement('div'); appPlaceholder.id = 'app-placeholder'; body.appendChild(appPlaceholder); shadowContainer.shadowRoot.appendChild(body); containers[parentElementId] = shadowContainer; return appPlaceholder; };该函数对应三步技术中的前两步:
- 找到宿主传入的父元素(
#parent),设置style.all = 'initial'阻断 Light DOM 样式继承; - 以
open模式附加 Shadow DOM(并启用delegatesFocus,让焦点可以穿透容器); - 将已注册的全部样式克隆进 shadowRoot——这样容器创建瞬间就已具备完整的全局样式;
- 额外插入一个
<body>元素,再在其中放入 React 应用的挂载占位 div(#app-placeholder)。这个细节的意义在第 5 节详述。
insertStyle:双模式样式插入
const insertStyle = style => { // Update the style list for newly created shadow containers styles.push(style); if (isStandalone) { document.head.appendChild(style); } else { // Update the style list for already existing shadow containers. // This will provide them with any lazy loaded styles. Promise.resolve().then(() => { Object.values(containers).forEach(container => { container.shadowRoot.appendChild(style.cloneNode(true)); }); }); } };这是第 3 步的核心实现:
- 无论哪种模式,先把样式记入
styles注册表; - 独立运行模式下,直接注入
document.head,行为等价于默认 style-loader; - 嵌入模式下,将样式克隆并追加到每一个已存在的容器的 shadowRoot 中。之所以用
Promise.resolve().then()异步执行,是为了配合 React 的渲染时序——让先于 Shadow DOM 容器创建完成而插入的样式(以及后续懒加载的样式)最终都能落入正确的作用域。
runStandalone:切换独立模式
export const runStandalone = () => { isStandalone = true; styles.forEach(style => document.head.appendChild(style)); };当 app2 以独立页面访问时(见 app2/src/index.js,它以<App isStandalone />渲染),会调用runStandalone():置位标志位,并把此前积累的所有样式一次性补进<head>,保证独立运行下样式不缺失。
deleteShadowContainer:清理注册表
export const deleteShadowContainer = id => { delete containers[id]; };宿主卸载 remote 时调用,从注册表中移除对应容器,防止内存泄漏与重复注入。
应用间的协作:注入与清理
remote 应用通过 app2/src/appInjector.js 对外暴露inject与cleanup两个函数:
import App from './App'; import React from 'react'; import ReactDOM from 'react-dom'; import { createShadowContainer, deleteShadowContainer } from '../styleLoader'; export const inject = parentElementId => { const appPlaceholder = createShadowContainer(parentElementId); ReactDOM.render(<App />, appPlaceholder); }; export const cleanup = parentElementId => { deleteShadowContainer(parentElementId); ReactDOM.unmountComponentAtNode(document.getElementById(parentElementId)); };inject(parentElementId):在宿主指定的元素上创建 Shadow DOM 容器,并把 React 应用渲染进容器内的占位节点;cleanup(parentElementId):注销容器并从占位节点卸载 React 应用。
宿主应用 app1 的 App.js 则通过useEffect完成生命周期管理:
import { inject, cleanup } from 'app2/appInjector'; const parentElementId = 'parent'; const App = () => { useEffect(() => { inject(parentElementId); return () => cleanup(parentElementId); }, []); // ... return ( <div> <h1>Host Application - React Version {React.version}</h1> <h2>App 1</h2> <div id={parentElementId}></div> </div> ); };挂载时注入、卸载时清理,宿主完全不感知 Shadow DOM 的存在。
关键注意事项(Important Notes)
1. Shadow DOM 内的应用必须使用 React >= 17
这是硬性约束:被装入 Shadow DOM 的应用必须使用 React 17 或更高版本,因为 React 16 及更早版本使用的合成事件(synthetic events)不适用于 Shadow DOM 中的事件冒泡机制,应用将无法正常重渲染。而宿主应用可以使用任意版本的 React。
本示例恰好是这一规则的活教材:app2 使用 React 17.0.2(进入 Shadow DOM),app1 使用 React 16.14.0(宿主,不受限制)。如果对调版本,remote 应用将无法正常工作。
2. 隔离配置全部集中在 remote 端
Shadow DOM 包装与 style-loader 的全部配置都位于 app2 的styleLoader.js中。宿主 app1 不需要任何样式相关改动,只负责“注入”和“销毁”,这让隔离能力可以作为一个可复用的“远程应用模板”下发给各个团队。
3. 有意插入的<body>标签
每个 Shadow DOM 容器中都会有意额外插入一个<body>元素。原因在于:全局 reboot 型 CSS 规则(如 Bootstrap 的 reset)通常以body及其子内容为选择器目标。在 Shadow DOM 中补一个body元素,就能让 remote 应用内部的此类规则正常命中目标,从而支持“宿主一套 Bootstrap、remote 另一套 Bootstrap”这类极端场景。
4. 测试工具的 Shadow DOM 适配
使用 Playwright 等工具测试此类结构时,需要额外的设置与调整:Shadow DOM 内部的元素无法通过常规的 DOM 查询直接命中,需要借助测试框架的 shadow piercing 能力或通过容器根节点(如#parent的shadowRoot)进行定位。具体做法请参阅所用测试工具的 Shadow DOM 交互文档。
效果验证:端到端测试如何证明隔离有效
仓库提供了完整的 Playwright 测试用例 e2e/checkApplications.spec.ts,其断言设计直接对应 README 描述的隔离效果,是理解本方案正确性的最佳佐证:
宿主侧验证:
mounts the remote inside a shadow DOM:断言#parent元素真实存在shadowRoot(Boolean(element.shadowRoot) === true),并且 Shadow DOM 内部能定位到 remote 应用的一级标题与按钮;同时断言宿主一级标题的font-style为italic(这是 app1 的 styles.css 定义的样式);retains host styles after interacting with the remote:点击 remote 的 “Make Everything Yellow” 按钮(触发懒加载 app2 的 moreStyles.css,其中* { background-color: yellow; })之后,宿主标题的font-style依然为italic——证明 remote 的全局样式没有泄漏到 host。
remote 独立运行验证:
applies yellow styles after clicking the button:在独立页面(端口 3002)点击按钮后,#root与按钮的background-color变为rgb(255, 255, 0)(黄色)——证明独立模式下样式正常生效。
这两组断言从正反两面共同验证了隔离的完整性:嵌入时 remote 样式被关进 Shadow DOM(不影响宿主),独立运行时样式又恢复全局注入(功能不受影响)。
运行演示(Running Demo)
在css-isolation工作区执行:
pnpm run start该命令会并行构建并启动 app1 与 app2 的开发服务器:
- localhost:3001 —— HOST(宿主,React 16)
- localhost:3002 —— STANDALONE REMOTE(独立运行的 remote,React 17)
对应的 npm scripts 定义在 css-isolation/package.json 中:start通过pnpm --filter css-isolation_app* --parallel start并行拉起两个应用的 dev server(默认使用 Rspack,见各应用的 rspack.config.js),另有legacy:start可切换为传统 Webpack 构建(webpack-cli serve)。
运行 Playwright 端到端测试
在css-isolation工作区执行:
pnpm test:e2e该命令基于 playwright.config.ts 运行完整端到端套件。配置文件会自动以pnpm start启动 3002 与 3001 两个 dev server 作为测试前置(webServer配置),并通过LEGACY_START环境变量切换传统 Webpack 构建。
本地调试可用的辅助命令:
pnpm test:e2e:ui—— 以 Playwright UI 模式交互式探索测试;pnpm test:e2e:debug—— 启用 Playwright 调试工具运行套件。
CI 环境中:
pnpm e2e:ci—— 安装所需浏览器并以 headless 模式执行测试;pnpm legacy:e2e:ci(或设置环境变量LEGACY_START=true)—— 针对传统 Webpack 构建运行同样的校验。
小结:把隔离能力沉淀为一个可复用模板
本示例的核心价值在于三点:
- 思路可移植:三步隔离技术不依赖任何框架私有能力,纯用 Web 标准(Shadow DOM +
all: initial+ 自定义 style-loader insert)实现,可以复用到任意微前端组合中; - 改动面最小:全部隔离逻辑收敛在 remote 端单个
styleLoader.js文件中,宿主零感知,老应用拆分为微应用时无需回改历史 CSS; - 可验证、可回归:配套的 Playwright 测试从宿主样式保留、remote 独立运行两个维度守护隔离边界,并支持 Webpack/Rspack 双构建链路的 CI 回归。
若要进一步了解与“不同 React 版本隔离”场景的组合用法,可继续阅读仓库中的 different-react-versions-isolated 示例。
- 示例工程
【免费下载链接】module-federation-examples
Implementation examples of module federation , by the creators of module federation
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考