Webpack Module Federation

Module Federation + Styled Components 解決方案

2024-02-03

前言

在用 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 共享依賴

ModuleFederationPluginshared 設定中,把 reactreact-domstyled-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 Bootstrapeager: true + dynamic import
styled-components 衝突同為 Singletonsingleton: true
CSS class 碰撞Namespace 隔離namespace: "appX"
全域樣式洩漏Shadow DOMStyleSheetManager target

小結

Module Federation 本身非常強大,但把多個 App 結合在一起時,依賴管理和樣式隔離是兩個最容易踩到的坑。singleton: true 解決了依賴衝突,namespace 或 Shadow DOM 解決了樣式衝突。實際上大多數情況只需要前兩招就夠了,Shadow DOM 方案在實作上稍微複雜,適合全域樣式特別混亂、沒辦法從架構層面整理的情境。