Module Federation + Styled Components 解決方案
前言
在用 Webpack Module Federation 把多個獨立 App 整合在一起的過程中,如果各個 App 都同時使用 styled-components,很快就會碰到兩個問題:一是多個 React 實體共存導致 hooks 報錯,二是各 App 之間的 CSS class 可能互相干擾。這篇筆記記錄了這兩個問題的成因與對應解法。
問題一:多個 React 實體衝突
Module Federation 允許多個 App 各自攜帶自己的依賴,但如果 Host App 和 Remote App 分別載入了自己的 react 實體,styled-components 依賴的 React context 就會對不上,導致 hooks 報錯(Invalid hook call)。
解法:Singleton 共享依賴
在 ModuleFederationPlugin 的 shared 設定中,把 react、react-dom、styled-components 都標記為 singleton: true,強制整個應用只使用一份實體。
Host App (App A) — webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
remoteB: 'remoteB@http://localhost:3001/remoteEntry.js',
},
shared: {
react: {
singleton: true, // 強制只用一個實體
requiredVersion: '^18.0.0',
eager: true, // Host 先載入,不等待 chunk
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
eager: true,
},
'styled-components': {
singleton: true, // styled-components 也必須 singleton
requiredVersion: '^6.0.0',
eager: true,
},
},
}),
],
};
Remote App (App B) — webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'remoteB',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
'./App': './src/App',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
eager: false, // Remote 不設 eager,由 Host 決定
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
eager: false,
},
'styled-components': {
singleton: true,
requiredVersion: '^6.0.0',
eager: false,
},
},
}),
],
};
eager: true 的陷阱處理
Host 設定了 eager: true 之後,entry file 不能直接 import App,否則會報 Shared module is not available for eager consumption 的錯誤。解法是把 entry 改成動態 import:
// src/index.js (Host) — 不能直接 import App
import('./bootstrap'); // 改成動態 import
// src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
這樣 Webpack 才能在初始化共享模組之後,再去載入 App 的程式碼。
問題二:CSS 樣式隔離
即使靠 singleton 解決了 React 實體的問題,各 App 之間的樣式還是可能彼此影響。以下三種方案依隔離程度遞增排列:
方案一:styled-components 天然隔離(推薦)
styled-components 會產生唯一的 hash class name(例如 .sc-bdXxxt),不同 App 的樣式天然不衝突。但如果擔心 hash 碰撞,可以透過 babel-plugin-styled-components 加上 namespace 前綴:
// .babelrc (App A)
{
"plugins": [
["babel-plugin-styled-components", {
"namespace": "appA",
"displayName": true,
"generateId": true
}]
]
}
// .babelrc (App B)
{
"plugins": [
["babel-plugin-styled-components", {
"namespace": "appB",
"displayName": true,
"generateId": true
}]
]
}
這樣產生的 class 就會是 .appA-sc-xxx vs .appB-sc-xxx,完全不重疊。
方案二:CSS Modules 編譯時隔離
不用 styled-components 的話,CSS Modules 搭配自訂 localIdentName 也能達到相同效果:
// webpack.config.js
{
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
// 加入 app 名稱確保唯一性
localIdentName: '[name]__[local]--[hash:base64:5]',
localIdentContext: path.resolve(__dirname, 'src'),
},
},
},
],
}
方案三:Shadow DOM 完全隔離
如果 Remote App 有全域 CSS 絕對不能外洩,可以用 Shadow DOM 包起來,並搭配 StyleSheetManager 讓 styled-components 把樣式注入到 Shadow DOM 內部,而不是注入到主文件的 <head>:
// remote B — ShadowWrapper.jsx
import { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { StyleSheetManager } from 'styled-components';
const ShadowWrapper = ({ children }) => {
const hostRef = useRef(null);
const rootRef = useRef(null);
useEffect(() => {
if (!hostRef.current || rootRef.current) return;
const shadow = hostRef.current.attachShadow({ mode: 'open' });
const mountPoint = document.createElement('div');
shadow.appendChild(mountPoint);
rootRef.current = createRoot(mountPoint);
rootRef.current.render(
<StyleSheetManager target={shadow}>
{children}
</StyleSheetManager>
);
return () => rootRef.current?.unmount();
}, []);
return <div ref={hostRef} />;
};
export default ShadowWrapper;
在 Host App 使用時,只需要把 Remote 的組件包在 ShadowWrapper 裡:
// host A
import RemoteButton from 'remoteB/Button';
const App = () => (
<div>
<h1>Host App A</h1>
<ShadowWrapper>
<RemoteButton /> {/* 完全隔離在 Shadow DOM 內 */}
</ShadowWrapper>
</div>
);
架構總結
┌─────────────────────────────────────────────┐
│ Host App A (eager: true) │
│ ┌─────────────────────────────────────┐ │
│ │ Shared Singleton Layer │ │
│ │ • react@18 │ │
│ │ • react-dom@18 │ │
│ │ • styled-components@6 │ │
│ └────────────────┬────────────────────┘ │
│ │ 共享同一實體 │
│ ┌────────────────▼────────────────────┐ │
│ │ Remote App B (eager: false) │ │
│ │ namespace: "appB" │ │
│ │ → class: .appB-sc-Button-xxxx │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
| 問題 | 解法 | 關鍵設定 |
|---|---|---|
| 多 React 實體 | Singleton 共享 | singleton: true |
| Host 載入順序 | Async Bootstrap | eager: true + dynamic import |
| styled-components 衝突 | 同為 Singleton | singleton: true |
| CSS class 碰撞 | Namespace 隔離 | namespace: "appX" |
| 全域樣式洩漏 | Shadow DOM | StyleSheetManager target |
小結
Module Federation 本身非常強大,但把多個 App 結合在一起時,依賴管理和樣式隔離是兩個最容易踩到的坑。singleton: true 解決了依賴衝突,namespace 或 Shadow DOM 解決了樣式衝突。實際上大多數情況只需要前兩招就夠了,Shadow DOM 方案在實作上稍微複雜,適合全域樣式特別混亂、沒辦法從架構層面整理的情境。