使用 React 的微前端:从基础到高级的完整指南

发布: (2025年12月16日 GMT+8 13:55)
11 min read
原文: Dev.to

Source: Dev.to

什么是微前端?

可以这么想——与其让所有人共同提交到一个庞大的 React 应用,不如把它拆分成多个独立的 React 应用,它们各自存在并相互通信。

经典示例:

  • 由平台团队构建的 header 组件
  • 由商务团队构建的 product listing
  • 由结账团队构建的 cart

全部独立部署,且同时在同一页面上运行。这就是微前端的设置。

为什么我开始使用它们(真实原因)

我在一个拥有大约五个不同团队的电商平台上工作,而我们的主应用已经变成了噩梦:

  • 仅仅用于供应商仪表盘的 200 KB 包,90 % 的用户根本看不到。
  • 对页眉的一个改动就意味着要重新构建所有内容。
  • 部署冲突每天都会发生。

微前端看起来像是 “好吧,每个团队拥有自己的领域,他们可以独立发布代码,不再有冲突”。
这部分确实有效——前提是你把它设置得正确。

Source:

模块联邦实际工作原理

Webpack 5 引入了 Module Federation,一种在运行时从不同域/端口加载代码的方式,而不是预先将所有内容打包。

Header 应用(运行在 localhost:3001

// webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "production",
  entry: "./src/index",
  output: {
    path: __dirname + "/dist",
    filename: "[name].[contenthash].js",
  },
  devServer: {
    port: 3001,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "header",
      filename: "remoteEntry.js",
      exposes: {
        "./Header": "./src/Header",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    }),
  ],
};

关键点

属性含义
name告诉其他应用 “我是 Header 应用”。
exposes声明此应用共享的内容(Header 组件)。
shared(配合 singleton“如果你没有 React,就使用 我的 React 版本;不要加载两次 React”。

主壳应用(运行在 localhost:3000

new ModuleFederationPlugin({
  name: "mainApp",
  remotes: {
    header: "header@http://localhost:3001/remoteEntry.js",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^18.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
  },
});

使用远程组件

import React, { lazy, Suspense } from "react";

const Header = lazy(() => import("header/Header"));

export default function App() {
  return (
    <Suspense fallback={<div>loading header…</div>}>
      <Header />
      {/* rest of app */}
    </Suspense>
  );
}

这出乎意料地好用。Header 应用独立加载,React 只加载一次,所有东西都能很好地协同工作。

状态管理:让我吃亏的那一块

这就是我最初搞砸的地方。我曾以为“我可以在各模块之间直接使用 Context API!”

并不是这样。 Context 只能在同一个 React 树内部工作。当你从不同的 webpack 包中懒加载一个组件时,它实际上是一个不同的 React 根。你的 context 提供者位于外壳(shell)中,但远程模块拥有自己的 React 实例。我花了大约三天时间才弄清楚这点。

实际可行的方案

选项 1 – URL + localStorage(简单做法)

// 当用户在 auth 模块登录时
const user = { id: 123, name: "Alice" };
localStorage.setItem("user", JSON.stringify(user));
window.location.hash = "#user-logged-in";

// 在其他模块中,监听 storage 事件
useEffect(() => {
  const handleStorageChange = (e) => {
    if (e.key === "user") {
      const newUser = JSON.parse(e.newValue);
      setUser(newUser);
    }
  };
  window.addEventListener("storage", handleStorageChange);
  return () => window.removeEventListener("storage", handleStorageChange);
}, []);

说实话? 这对简单的场景(用户登录、偏好设置、基础状态)有效。对于复杂状态就不太适合了。

选项 2 – Window 事件(更好)

// Auth 模块抛出事件
function handleLogin(user) {
  const event = new CustomEvent("app:user-login", { detail: user });
  window.dispatchEvent(event);
}

// Header 模块监听
useEffect(() => {
  function handleUserLogin(e) {
    setUser(e.detail);
  }
  window.addEventListener("app:user-login", handleUserLogin);
  return () => window.removeEventListener("app:user-login", handleUserLogin);
}, []);

每个模块保持独立,通过事件进行通信。如果某个模块崩溃,其他模块仍能继续运行。

选项 3 – 共享状态服务(我们现在的做法)

// shared-state-service.js – 两边都要导入的轻量模块
class StateService {
  constructor() {
    this.listeners = new Map();
    this.state = {
      user: null,
      theme: "light",
      cart: [],
    };
  }

  subscribe(key, callback) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, []);
    }
    this.listeners.get(key).push(callback);

    // 返回一个取消订阅的函数
    return () => {
      const cbs = this.listeners.get(key);
      const idx = cbs.indexOf(callback);
      if (idx > -1) cbs.splice(idx, 1);
    };
  }

  setState(key, value) {
    this.state[key] = value;
    const cbs = this.listeners.get(key) || [];
    cbs.forEach((cb) => cb(value));
  }

  getState(key) {
    return this.state[key];
  }
}

// 导出单例
export const stateService = new StateService();

在任意微前端中的使用方式

import { stateService } from "./shared-state-service";

// 更新状态
stateService.setState("user", { id: 1, name: "Bob" });

// 订阅状态
useEffect(() => {
  const unsubscribe = stateService.subscribe("user", setUser);
  return unsubscribe;
}, []);

这为我们提供了一种轻量、与框架无关的状态共享方式,而无需引入完整的 store。

Source:

TL;DR

  • 微前端让独立团队能够在不相互干扰的情况下交付代码,但它们会增加运行时的复杂度。
  • Webpack 5 的 模块联邦(Module Federation) 使得可以在运行时动态加载独立的 bundle,同时共享同一个 React 实例。
  • 状态共享 不能仅依赖 React Context;请选择适合你应用复杂度的策略(localStorage + 事件、自定义 window 事件,或一个小型共享状态服务)。

如果配置得当,收益巨大:构建更快、部署独立、代码库更健康。只需做好额外的管道工作。祝你联邦化愉快!

// shared/state-service.js
class StateService {
  constructor() {
    this.state = {};
    this.listeners = new Map();
  }

  setState(key, value) {
    this.state[key] = value;
    if (this.listeners.has(key)) {
      this.listeners.get(key).forEach((cb) => cb(value));
    }
  }

  getState(key) {
    return this.state[key];
  }

  subscribe(key, cb) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, new Set());
    }
    this.listeners.get(key).add(cb);
    // Return an unsubscribe function
    return () => this.listeners.get(key).delete(cb);
  }
}

export default new StateService();

在模块中使用该服务

Auth 模块

// Auth module
import stateService from "shared/state-service";

function handleLogin(user) {
  stateService.setState("user", user);
}

Header 模块

// Header module
import stateService from "shared/state-service";
import { useEffect, useState } from "react";

export default function Header() {
  const [user, setUser] = useState(stateService.getState("user"));

  useEffect(() => {
    // Subscribe to changes and clean up on unmount
    return stateService.subscribe("user", setUser);
  }, []);

  return <>Hello {user?.name}</>;
}

这就是我们现在使用的方式。体积小,可靠,每个模块保持独立。

Source:

没人谈论的事

1. 版本冲突是真实存在的

你会遇到模块 A 需要 lodash@4,而模块 B 需要 lodash@3 的情况。它们都在 shared 中,结果生产环境中出现了两个版本,包体积会突然增加约 200 KB。

我们的做法: 在 shell 级别锁定版本。所有远程仓库 必须 使用我们指定的版本。

2. 错误处理很痛苦

const Header = lazy(() =>
  import("header/Header").catch((err) => {
    console.error("header load failed", err);
    // Return fallback component
    return { default: () => <>Header unavailable</> };
  })
);

如果生产环境中 header 应用宕机,整个页面不会崩溃——但你必须显式地处理这种情况。

3. 本地开发变得混乱

在 3001 端口运行 header,3002 端口运行 sidebar,3000 端口运行主应用?你需要一个脚本同时启动这三个服务。DevTools 会变得令人困惑,调试也很烦人,因为代码分散在多个标签页中。

我们现在使用 docker‑compose 进行本地开发。

当微前端真正有意义时

  • Multiple independent teams 在不同的时间表上部署
  • Large apps 中不同的领域几乎不相互交流
  • Different performance requirements(例如,购物车对性能至关重要,管理后台则不是)

当它们显得过度时

  • 小团队,单一应用
  • 大量跨域状态共享
  • 当你真的 喜欢 你的单体应用

老实说?我们现在使用它们是因为不得不使用,而且它能工作。如果可以回到过去,我可能会保持主应用的单体结构,只在真正独立的功能上使用微前端(比如把管理后台作为一个独立的应用)。

我遇到的真实坑

  • CSS 冲突 – 模块 A 设置 body { font-size: 16px },模块 B 期望 18px。使用 CSS 模块或 Shadow DOM。
  • 捆绑重复 – 忘记将 React 标记为共享,导致加载了三次。页面体积膨胀到 500 KB,而不是 200 KB。
  • CORS 问题remoteEntry.js 没有使用正确的响应头提供;所有内容都静默失败。
  • 状态不同步 – 一个模块缓存了用户数据,另一个模块获取了新数据 → 到处都是混乱。

我会不同的做法

  • 保持单体架构,直到真正需要微前端。
  • 只从 2–3 个 remote 开始,而不是 10 个。
  • 从第一天起就拥有一个用于状态服务的共享库。
  • 使用 feature flags 逐步推出每个模块。
  • 指定 ONE 个人负责 shell/orchestration 代码。

实际有帮助的资源

  • Module Federation 文档还不错。
  • Zack Jackson 的演讲非常有价值。
  • 老实说,你自己的代码是最好的老师。

微前端并不是适合所有人的未来。它们是解决特定问题的利器。不要仅仅因为听起来酷就使用它们。这个教训我吃了不少苦头。

Back to Blog

相关文章

阅读更多 »

澳大利亚首选的Web技术栈

为什么在澳大利亚选择技术栈很重要 澳大利亚企业优先考虑质量、安全性和性能。网站被期望能够无缝运行...