Command Palette

Search for a command to run...

Command Palette

Search for a command to run...

Tech
PreviousNext

Web Components vs Module Federation: Micro Frontends Guide

A deep comparison of Web Components and Module Federation for micro frontends architecture

當前端應用規模逐漸擴大,如何讓多個團隊協作開發、獨立部署成為關鍵議題。微前端(Micro Frontends)正是為了解決這個問題而生的架構模式,而 Web Components 與 Module Federation 則是目前最主流的兩種實作方案。

為什麼需要微前端?

在深入比較之前,先來了解微前端要解決的核心問題:

  • 團隊自治:不同團隊可以獨立開發、測試、部署各自負責的功能模組
  • 技術異構:允許不同模組使用不同的框架或技術棧
  • 漸進式遷移:讓 legacy 系統可以逐步現代化,而非一次性重寫
  • 獨立部署:單一模組的更新不需要整個應用重新部署

概念比較總覽

特性Web ComponentsModule Federation
標準化W3C 瀏覽器原生標準Webpack 5+ / Rspack 功能
框架依賴框架無關需要 bundler 支援
樣式隔離Shadow DOM 原生支援需額外配置 CSS Modules
共享依賴需手動處理內建 shared 機制
運行時載入透過 Custom Elements透過 remoteEntry.js
版本管理較複雜內建版本協商
學習曲線中等較陡峭
瀏覽器支援現代瀏覽器原生支援需要 bundler 編譯

Web Components 深入解析

核心技術棧

Web Components 建立在三個瀏覽器原生 API 之上:

  • Custom Elements:定義全新的 HTML 標籤,賦予其自訂行為
  • Shadow DOM:建立獨立的 DOM 子樹,實現樣式與結構的完全封裝
  • HTML Templates:定義可複用的 HTML 片段,搭配 <slot> 實現內容投射

實作範例:使用者卡片元件

以下範例展示如何建立一個具備響應式屬性的自定義元素:

// user-card.ts
class UserCard extends HTMLElement {
  private shadow: ShadowRoot;
 
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
  }
 
  // 宣告需要監聽變化的屬性
  static get observedAttributes() {
    return ["name", "avatar", "role"];
  }
 
  // 元素被加入 DOM 時觸發
  connectedCallback() {
    this.render();
  }
 
  // 監聽的屬性發生變化時觸發
  attributeChangedCallback(name: string, oldValue: string, newValue: string) {
    if (oldValue !== newValue) {
      this.render();
    }
  }
 
  private render() {
    const name = this.getAttribute("name") || "Unknown";
    const avatar = this.getAttribute("avatar") || "/default-avatar.png";
    const role = this.getAttribute("role") || "User";
 
    this.shadow.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: system-ui, sans-serif;
        }
        .card {
          display: flex;
          align-items: center;
          gap: 1rem;
          padding: 1rem;
          border-radius: 8px;
          background: var(--card-bg, #f5f5f5);
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .avatar {
          width: 60px;
          height: 60px;
          border-radius: 50%;
          object-fit: cover;
        }
        .info h3 {
          margin: 0 0 0.25rem;
          color: var(--text-primary, #333);
        }
        .info span {
          color: var(--text-secondary, #666);
          font-size: 0.875rem;
        }
      </style>
      <div class="card">
        <img class="avatar" src="${avatar}" alt="${name}" />
        <div class="info">
          <h3>${name}</h3>
          <span>${role}</span>
        </div>
      </div>
    `;
  }
}
 
customElements.define("user-card", UserCard);

註冊完成後,即可像使用原生 HTML 標籤一樣使用:

<user-card name="Michael Lo" avatar="/images/michael.jpg" role="Frontend Engineer"> </user-card>

跨元件通訊:Custom Events

Web Components 之間的通訊仰賴瀏覽器原生的事件機制。使用 CustomEvent 時有兩個重要選項:

  • bubbles: true:事件會向上冒泡至父元素
  • composed: true:事件能穿透 Shadow DOM 邊界,讓外部也能監聽
// notification-bell.ts
class NotificationBell extends HTMLElement {
  private shadow: ShadowRoot;
  private count = 0;
 
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
  }
 
  connectedCallback() {
    this.shadow.innerHTML = `<button>🔔 <span>0</span></button>`;
    this.shadow.querySelector("button")?.addEventListener("click", () => {
      // composed: true 讓事件能穿透 Shadow DOM
      this.dispatchEvent(
        new CustomEvent("notification-click", {
          bubbles: true,
          composed: true,
          detail: { count: this.count },
        }),
      );
    });
  }
 
  setCount(count: number) {
    this.count = count;
    const span = this.shadow.querySelector("span");
    if (span) span.textContent = String(count);
  }
}
 
customElements.define("notification-bell", NotificationBell);
 
// 在應用層監聽事件(即使元件使用 Shadow DOM,事件仍可被捕獲)
document.addEventListener("notification-click", (e: CustomEvent) => {
  console.log("Notifications:", e.detail.count);
});

優缺點分析

  • 零依賴:瀏覽器原生支援,無需額外 runtime
    • 真正隔離:Shadow DOM 提供完整的樣式封裝
    • 框架無關:可在 React、Vue、Angular 甚至純 HTML 中使用
    • 面向未來:W3C 標準,長期維護有保障

Module Federation 深入解析

核心概念

Module Federation 是 Webpack 5 引入的革命性功能,讓多個獨立建構的應用程式能在運行時動態共享程式碼:

  • Host(宿主):載入並消費遠端模組的應用
  • Remote(遠端):暴露模組供其他應用使用
  • Shared(共享):多個應用共用的依賴,避免重複載入
  • Exposes(暴露):Remote 應用對外公開的模組清單

實作範例:Remote 應用配置

假設我們要將一個獨立的 React 應用作為 Remote,暴露其元件供其他應用使用:

// apps/remote-app/webpack.config.ts
import { container } from "webpack";
const { ModuleFederationPlugin } = container;
 
export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "remoteApp",
      filename: "remoteEntry.js",
      // 對外暴露的模組
      exposes: {
        "./Button": "./src/components/Button",
        "./UserProfile": "./src/components/UserProfile",
        "./utils": "./src/utils/helpers",
      },
      // 共享依賴配置
      shared: {
        react: {
          singleton: true, // 確保只有一個 React 實例
          requiredVersion: "^18.0.0",
          eager: false, // 延遲載入
        },
        "react-dom": {
          singleton: true,
          requiredVersion: "^18.0.0",
        },
        "@tanstack/react-query": {
          singleton: true,
          requiredVersion: "^5.0.0",
        },
      },
    }),
  ],
};

實作範例:Host 應用配置

Host 應用需要宣告要載入哪些 Remote,並配置相同的共享依賴:

// apps/host-app/webpack.config.ts
import { container } from "webpack";
const { ModuleFederationPlugin } = container;
 
export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "hostApp",
      remotes: {
        // 靜態配置
        remoteApp: "remoteApp@http://localhost:3001/remoteEntry.js",
        // 動態配置:支援運行時決定 Remote URL
        dynamicRemote: `promise new Promise(resolve => {
          const remoteUrl = window.REMOTE_URL || 'http://localhost:3002/remoteEntry.js';
          const script = document.createElement('script');
          script.src = remoteUrl;
          script.onload = () => resolve(window.dynamicRemote);
          document.head.appendChild(script);
        })`,
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    }),
  ],
};

實作範例:動態載入遠端元件

在 Host 應用中,使用 React.lazy 搭配 SuspenseErrorBoundary 優雅地載入遠端元件:

// apps/host-app/src/App.tsx
import React, { Suspense, lazy } from "react";
import { ErrorBoundary } from "react-error-boundary";
 
// 靜態導入:路徑在 build time 確定
const RemoteButton = lazy(() => import("remoteApp/Button"));
 
// 動態導入工具函式
async function loadRemoteModule(scope: string, module: string) {
  // @ts-ignore - Webpack runtime API
  await __webpack_init_sharing__("default");
  const container = window[scope];
  // @ts-ignore
  await container.init(__webpack_share_scopes__.default);
  const factory = await container.get(module);
  return factory();
}
 
// 注意:lazy component 應在模組層級建立,避免重複渲染
const DynamicUserProfile = lazy(() => loadRemoteModule("remoteApp", "./UserProfile"));
 
function App() {
  return (
    <div>
      <h1>Host Application</h1>
 
      <ErrorBoundary fallback={<div>Remote component failed to load</div>}>
        <Suspense fallback={<div>Loading remote button...</div>}>
          <RemoteButton onClick={() => alert("Clicked!")}>Remote Button</RemoteButton>
        </Suspense>
      </ErrorBoundary>
 
      <ErrorBoundary fallback={<div>Profile unavailable</div>}>
        <Suspense fallback={<div>Loading profile...</div>}>
          <DynamicUserProfile userId="123" />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}
 
export default App;

TypeScript 類型支援

為了獲得完整的類型提示,需要為遠端模組建立型別宣告:

// apps/host-app/src/types/federated.d.ts
declare module "remoteApp/Button" {
  import { ComponentType, ButtonHTMLAttributes } from "react";
 
  interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
    variant?: "primary" | "secondary" | "ghost";
    size?: "sm" | "md" | "lg";
  }
 
  const Button: ComponentType<ButtonProps>;
  export default Button;
}
 
declare module "remoteApp/UserProfile" {
  import { ComponentType } from "react";
 
  interface UserProfileProps {
    userId: string;
    showDetails?: boolean;
  }
 
  const UserProfile: ComponentType<UserProfileProps>;
  export default UserProfile;
}

優缺點分析

  • 運行時整合:真正的動態載入,無需重新部署 Host
    • 智慧共享:內建依賴共享與版本協商機制
    • 無縫整合:與 React/Vue 等現代框架完美配合
    • 獨立部署:各 Remote 可獨立發布,互不影響
    • 開發友好:支援 HMR,TypeScript 類型提示完整

場景選擇指南

場景推薦方案原因
多框架混用Web Components真正的框架無關,完全隔離
純 React/Vue 微前端Module Federation更好的 DX 與依賴共享
跨專案 Design SystemWeb Components一次建構,所有框架皆可使用
大型企業級應用Module Federation完善的依賴管理與部署策略
需要 SSRModule FederationWeb Components SSR 支援不成熟
Legacy 系統遷移Web Components無需修改現有建構工具

進階:混合架構策略

在某些場景下,結合兩種方案能發揮最大效益。以下範例展示如何用 Web Component 包裝 Module Federation 元件,讓 federated 模組能在任何環境中使用:

// federated-wrapper.ts
import React from "react";
import ReactDOM from "react-dom/client";
 
class FederatedWrapper extends HTMLElement {
  private root: ReactDOM.Root | null = null;
  private Component: React.ComponentType | null = null;
 
  static get observedAttributes() {
    return ["remote", "module", "props"];
  }
 
  async connectedCallback() {
    const remote = this.getAttribute("remote");
    const module = this.getAttribute("module");
 
    if (remote && module) {
      this.Component = await this.loadFederatedComponent(remote, module);
      this.render();
    }
  }
 
  private async loadFederatedComponent(scope: string, module: string) {
    // @ts-ignore
    await __webpack_init_sharing__("default");
    const container = window[scope];
    // @ts-ignore
    await container.init(__webpack_share_scopes__.default);
    const factory = await container.get(module);
    return factory().default;
  }
 
  private render() {
    if (!this.Component) return;
 
    const props = JSON.parse(this.getAttribute("props") || "{}");
 
    if (!this.root) {
      this.root = ReactDOM.createRoot(this);
    }
 
    this.root.render(React.createElement(this.Component, props));
  }
 
  disconnectedCallback() {
    this.root?.unmount();
  }
}
 
customElements.define("federated-wrapper", FederatedWrapper);

如此一來,即使在純 HTML 頁面或其他框架中,也能使用 federated 元件:

<federated-wrapper remote="remoteApp" module="./UserProfile" props='{"userId": "123"}'>
</federated-wrapper>

如何選擇?

微前端架構決策流程

flowchart TD
    A["評估專案需求"] --> B{"是否需要多框架共存?"}
    B -->|"是"| C["選擇 Web Components\n(框架無關、完全隔離)"]
    B -->|"否"| D["評估技術棧"]
    D --> E{"團隊是否統一使用現代框架?"}
    E -->|"React/Vue"| F["選擇 Module Federation\n(更好的 DX 與依賴共享)"]
    E -->|"混合/Legacy"| G["選擇 Web Components\n(漸進式整合)"]

結語

Web ComponentsModule Federation 並非互斥的選擇,而是針對不同問題的解決方案:

  • 當你需要框架無關的可攜性與真正的樣式隔離時,選擇 Web Components
  • 當你在現代框架生態系統中需要強大的依賴共享開發體驗時,選擇 Module Federation
  • 在複雜的企業環境中,兩者混合使用往往能達到最佳效果

最終的選擇應基於團隊的技術棧、專案的實際需求,以及長期維護的成本考量。理解兩者的權衡取捨,才能為你的微前端架構做出最明智的決策。