各位同仁,下午好!
今天,我们齐聚一堂,共同探讨一个在现代前端开发中日益重要的议题:在微前端(Micro-frontend)架构下,如何有效地在多个 React 实例之间共享全局 Context 或状态。随着前端应用的复杂性不断提升,以及团队协作模式的演进,微前端已成为解决巨石应用痛点、提升开发效率和系统弹性的关键方案。然而,它也带来了一系列新的挑战,其中之一便是状态管理。
一、微前端架构:机遇与挑战
首先,让我们快速回顾一下微前端的核心理念。微前端是一种将大型前端应用拆分成多个小型、独立部署的子应用(微应用)的架构风格。每个微应用可以由不同的团队独立开发、测试、部署和运行,甚至可以使用不同的技术栈。
微前端的优势显而易见:
- 技术栈无关性: 允许团队选择最适合其业务的技术。
- 独立部署: 每个微应用可以独立上线,减少发布风险。
- 团队自治: 团队可以完全拥有其微应用,提升开发效率和责任感。
- 可伸缩性: 易于扩展和维护大型应用。
- 增量升级: 逐步替换旧系统,降低重构成本。
然而,正如任何强大的架构一样,微前端也伴随着挑战。其中一个核心挑战是如何管理跨越多个独立微应用的用户体验和数据一致性。当用户在一个微应用中触发的操作需要影响另一个微应用的状态,或者多个微应用需要访问相同的全局配置(如用户认证信息、主题设置、国际化语言),传统的单体应用状态管理方式便不再适用。
我们今天聚焦的问题是: 当多个微前端都是基于 React 开发时,它们通常会各自运行在独立的 React 根实例上。这意味着每个微应用都有自己的 React 组件树,其内部的 Context 机制默认是相互隔离的。如何在这样的隔离环境中,实现全局 Context 或状态的有效共享?这不仅仅关乎数据的传递,更关乎用户体验的连贯性、系统性能以及开发维护的复杂性。
二、React Context 机制回顾及其在单体应用中的作用
在深入微前端的挑战之前,我们有必要简要回顾一下 React 的 Context 机制。Context 提供了一种在组件树中传递数据的方法,而无需在每个层级手动传递 props。它主要由 React.createContext、Context.Provider 和 Context.Consumer(或 useContext hook)组成。
基本用法:
// theme-context.js
import React from 'react';
export const ThemeContext = React.createContext('light'); // 默认值
// app.js (或某个父组件)
import React, { useState } from 'react';
import { ThemeContext } from './theme-context';
import Toolbar from './Toolbar';
function App() {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<button onClick={toggleTheme}>Toggle Theme</button>
<Toolbar />
</ThemeContext.Provider>
);
}
// Toolbar.js
import React from 'react';
import ThemedButton from './ThemedButton';
function Toolbar() {
return (
<div>
<ThemedButton />
</div>
);
}
// ThemedButton.js
import React, { useContext } from 'react';
import { ThemeContext } from './theme-context';
function ThemedButton() {
const { theme, toggleTheme } = useContext(ThemeContext);
const buttonStyle = {
background: theme === 'dark' ? '#333' : '#eee',
color: theme === 'dark' ? 'white' : 'black',
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
};
return (
<button style={buttonStyle} onClick={toggleTheme}>
Current Theme: {theme}
</button>
);
}
在这个例子中,ThemeContext.Provider 封装了 Toolbar 组件,使得 Toolbar 及其所有后代组件(如 ThemedButton)都能够通过 useContext(ThemeContext) 访问到 theme 和 toggleTheme。这种机制在单个 React 应用内部非常高效和便捷。
然而,Context 的核心限制在于:它只在其所属的 React 组件树内部有效。 如果我们将 App 组件渲染到 DOM 节点 root1,而另一个完全独立的 React 应用(例如,另一个微前端)渲染到 DOM 节点 root2,那么 root2 中的组件将无法直接访问 root1 中 ThemeContext.Provider 提供的任何值。这就是微前端环境下的症结所在。
三、微前端中多个 React 实例的隔离性
在微前端架构中,通常会有以下几种方式来承载和运行多个 React 微应用:
-
多 DOM 根节点: 每个微应用被挂载到宿主应用(或称 Shell 应用)的不同 DOM 节点上。例如:
// 宿主应用 import React from 'react'; import ReactDOM from 'react-dom'; import App1 from './mfe1/App'; import App2 from './mfe2/App'; // 假设宿主应用的 index.html 中有 <div id="mfe-container-1"></div> 和 <div id="mfe-container-2"></div> ReactDOM.render(<App1 />, document.getElementById('mfe-container-1')); ReactDOM.render(<App2 />, document.getElementById('mfe-container-2'));在这种模式下,
App1和App2是完全独立的 React 应用,它们有各自的 React 渲染器实例和组件树。 -
Iframe: 每个微应用运行在一个独立的
iframe中。iframe提供了最强的隔离性,包括 DOM、CSS、JavaScript 执行环境等。但同时,它也带来了最大的通信挑战,因为跨iframe通信受到同源策略的限制。 -
Shadow DOM: 某些微前端方案(如 Web Components)可能利用 Shadow DOM 来提供 CSS 和 DOM 隔离。虽然它提供了一定的封装,但 JavaScript 执行环境仍然是共享的,因此状态共享的挑战与多 DOM 根节点类似。
在以上任何一种模式下,标准 React Context 都无法直接跨越这些隔离边界。Context 是一种组件树内部的机制,它依赖于 React 渲染器在遍历树时查找最近的 Provider。当存在多个独立的 React 根实例时,这些实例之间没有共同的父组件树来共享同一个 Provider。
示例:两个隔离的 React 应用
假设我们有两个微前端 MFE1 和 MFE2,每个都有自己的 ThemeContext:
// mfe1/src/theme-context.js
import React from 'react';
export const MFE1ThemeContext = React.createContext('light-mfe1');
// mfe1/src/App.js
import React, { useState } from 'react';
import { MFE1ThemeContext } from './theme-context';
import MFE1Component from './MFE1Component';
function MFE1App() {
const [theme, setTheme] = useState('light-mfe1');
return (
<MFE1ThemeContext.Provider value={{ theme, setTheme }}>
<h3>MFE1</h3>
<MFE1Component />
</MFE1ThemeContext.Provider>
);
}
// mfe1/src/MFE1Component.js
import React, { useContext } from 'react';
import { MFE1ThemeContext } from './theme-context';
function MFE1Component() {
const { theme, setTheme } = useContext(MFE1ThemeContext);
return (
<div>
<p>MFE1 Theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light-mfe1' ? 'dark-mfe1' : 'light-mfe1')}>
Toggle MFE1 Theme
</button>
</div>
);
}
// mfe2/src/theme-context.js
import React from 'react';
export const MFE2ThemeContext = React.createContext('light-mfe2');
// mfe2/src/App.js
import React, { useState } from 'react';
import { MFE2ThemeContext } from './theme-context';
import MFE2Component from './MFE2Component';
function MFE2App() {
const [theme, setTheme] = useState('light-mfe2');
return (
<MFE2ThemeContext.Provider value={{ theme, setTheme }}>
<h3>MFE2</h3>
<MFE2Component />
</MFE2ThemeContext.Provider>
);
}
// mfe2/src/MFE2Component.js
import React, { useContext } from 'react';
import { MFE2ThemeContext } from './theme-context';
function MFE2Component() {
const { theme, setTheme } = useContext(MFE2ThemeContext);
return (
<div>
<p>MFE2 Theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light-mfe2' ? 'dark-mfe2' : 'light-mfe2')}>
Toggle MFE2 Theme
</button>
</div>
);
}
// 宿主应用 main.js
import React from 'react';
import ReactDOM from 'react-dom';
import MFE1App from './mfe1/App';
import MFE2App from './mfe2/App';
ReactDOM.render(<MFE1App />, document.getElementById('mfe-container-1'));
ReactDOM.render(<MFE2App />, document.getElementById('mfe-container-2'));
运行这段代码,你会发现 MFE1 和 MFE2 的主题切换按钮是完全独立的,它们各自维护自己的主题状态,并且无法影响对方。这正是我们需要解决的核心问题:如何让它们感知到并响应同一个“全局”主题状态。
四、跨多个 React 实例共享状态的策略
要实现跨微前端的状态共享,我们需要跳出 React 自身 Context 的局限,利用浏览器提供的能力或更高级的工具。以下是几种常见且有效的策略,我们将逐一探讨其原理、实现方式、优缺点以及适用场景。
1. 浏览器原生机制:松散耦合的事件总线与本地存储
这种方法利用浏览器提供的标准 API 来实现微应用之间的通信,具有高度的解耦性,不依赖于任何特定的前端框架。
1.1. 自定义事件(Custom Events)/ 事件总线(Event Bus)
原理:
利用浏览器 DOM 的 EventTarget 接口(例如 window 对象或一个共享的 EventTarget 实例),通过 dispatchEvent 派发自定义事件,并通过 addEventListener 监听这些事件。数据可以在事件的 detail 属性中传递。
实现方式:
创建一个简单的事件总线模块,或者直接使用 window 对象作为事件中心。
// shared-utils/eventBus.js
const eventBus = new EventTarget();
export const dispatchGlobalEvent = (eventName, detail) => {
eventBus.dispatchEvent(new CustomEvent(eventName, { detail }));
};
export const addGlobalEventListener = (eventName, callback) => {
eventBus.addEventListener(eventName, callback);
};
export const removeGlobalEventListener = (eventName, callback) => {
eventBus.removeEventListener(eventName, callback);
};
// ----------------------------------------------------
// MFE1 (发布者)
// mfe1/src/App.js
import React, { useState } from 'react';
import { dispatchGlobalEvent } from '../../shared-utils/eventBus'; // 假设路径
function MFE1App() {
const [localTheme, setLocalTheme] = useState('light');
const toggleTheme = () => {
const newTheme = localTheme === 'light' ? 'dark' : 'light';
setLocalTheme(newTheme);
dispatchGlobalEvent('global-theme-change', { theme: newTheme, source: 'MFE1' });
};
return (
<div>
<h3>MFE1</h3>
<p>Local Theme: {localTheme}</p>
<button onClick={toggleTheme}>Toggle Global Theme from MFE1</button>
</div>
);
}
// ----------------------------------------------------
// MFE2 (订阅者)
// mfe2/src/App.js
import React, { useState, useEffect } from 'react';
import { addGlobalEventListener, removeGlobalEventListener } from '../../shared-utils/eventBus'; // 假设路径
function MFE2App() {
const [globalTheme, setGlobalTheme] = useState('light');
useEffect(() => {
const handleThemeChange = (event) => {
console.log(`MFE2 received theme change from ${event.detail.source}: ${event.detail.theme}`);
setGlobalTheme(event.detail.theme);
};
addGlobalEventListener('global-theme-change', handleThemeChange);
return () => {
removeGlobalEventListener('global-theme-change', handleThemeChange);
};
}, []);
const buttonStyle = {
background: globalTheme === 'dark' ? '#333' : '#eee',
color: globalTheme === 'dark' ? 'white' : 'black',
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
};
return (
<div>
<h3>MFE2</h3>
<p>Global Theme (from MFE2's perspective): {globalTheme}</p>
<button style={buttonStyle}>Themed Button</button>
</div>
);
}
优缺点:
| 特性 | 优点 | 缺点 |
|---|---|---|
| 耦合度 | 极低。微应用之间互不感知具体实现,只依赖事件名。 | |
| 灵活性 | 适用于任何 JavaScript 环境,包括非 React 组件。 | |
| 性能 | 事件触发和监听开销小。 | 可能导致频繁的组件重渲染(如果处理不当)。 |
| 复杂性 | 实现简单。 | 需要手动管理事件生命周期(添加/移除监听器)。 |
| 数据 | 需手动序列化和反序列化复杂数据结构。 | 事件名称可能冲突,数据类型不安全(运行时检查)。 |
| 调试 | 难以追踪事件流和数据源。 |
适用场景:
适用于需要松散耦合、非关键性、异步通知的场景,例如主题切换、语言切换、用户登录/登出等全局事件。当数据量不大且更新频率不高时,这是个不错的选择。
1.2. localStorage / sessionStorage
原理:
利用浏览器提供的本地存储 API (localStorage 或 sessionStorage) 来持久化数据。一个微应用写入数据,其他微应用通过监听 storage 事件或定期读取来获取更新。
实现方式:
// shared-utils/storage.js
const GLOBAL_THEME_KEY = 'global-app-theme';
export const getGlobalTheme = () => {
return localStorage.getItem(GLOBAL_THEME_KEY) || 'light';
};
export const setGlobalTheme = (theme) => {
localStorage.setItem(GLOBAL_THEME_KEY, theme);
// 注意:storage 事件只会在非当前窗口/标签页触发
// 如果需要在同一窗口内响应,可能需要结合 Event Bus 或手动触发更新
};
// ----------------------------------------------------
// MFE1 (写入者)
// mfe1/src/App.js
import React, { useState } from 'react';
import { getGlobalTheme, setGlobalTheme } from '../../shared-utils/storage';
function MFE1App() {
const [currentTheme, setCurrentTheme] = useState(getGlobalTheme());
const toggleTheme = () => {
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setGlobalTheme(newTheme);
setCurrentTheme(newTheme); // 更新当前 MFE 的本地状态
};
return (
<div>
<h3>MFE1</h3>
<p>Current Global Theme: {currentTheme}</p>
<button onClick={toggleTheme}>Toggle Global Theme from MFE1</button>
</div>
);
}
// ----------------------------------------------------
// MFE2 (读取者/监听者)
// mfe2/src/App.js
import React, { useState, useEffect } from 'react';
import { getGlobalTheme } from '../../shared-utils/storage';
function MFE2App() {
const [globalTheme, setGlobalTheme] = useState(getGlobalTheme());
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === GLOBAL_THEME_KEY) {
console.log(`MFE2 detected storage change: ${event.newValue}`);
setGlobalTheme(event.newValue);
}
};
// 监听 storage 事件 (跨窗口/标签页通信)
window.addEventListener('storage', handleStorageChange);
// 为了在同一窗口内也能响应 MFE1 的改变,MFE1 需要在 setGlobalTheme 后触发一个自定义事件
// 或者 MFE2 内部定时检查 localStorage (不推荐)
// 或者 MFE2 也可以订阅 MFE1 派发的自定义事件,以确保实时性
// 这里我们假设 MFE1 也会派发一个事件,MFE2 同时监听 storage 事件和自定义事件。
// 为了简化,我们只展示 storage 事件,但实际应用中通常需要配合 Event Bus。
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
const buttonStyle = {
background: globalTheme === 'dark' ? '#333' : '#eee',
color: globalTheme === 'dark' ? 'white' : 'black',
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
};
return (
<div>
<h3>MFE2</h3>
<p>Global Theme (from MFE2's perspective): {globalTheme}</p>
<button style={buttonStyle}>Themed Button</button>
</div>
);
}
优缺点:
| 特性 | 优点 | 缺点 |
|---|---|---|
| 持久性 | 数据在浏览器会话或关闭后依然存在(localStorage)。 |
|
| 跨 Tab | 自动支持跨浏览器标签页/窗口通信(通过 storage 事件)。 |
storage 事件不会在修改存储的当前窗口/标签页触发。 |
| 安全性 | 存在 XSS 风险,敏感数据不宜存放。 | |
| 数据 | 只能存储字符串,复杂对象需要 JSON.stringify/parse。 |
存储容量有限(通常 5MB)。 |
| 性能 | 读写操作相对较快,但频繁操作可能阻塞主线程。 | storage 事件的非实时性,可能需要额外机制确保同步。 |
适用场景:
适用于需要持久化且不频繁更新的全局数据,如用户偏好设置、记住的登录状态、一些配置信息等。不适合作为高频、实时状态同步的主要手段。通常需要与事件总线结合使用,以弥补 storage 事件的局限性。
2. 中心化状态管理库:共享一个 Store 实例
这种方法是将一个单一的状态管理库实例(如 Redux、Zustand、Jotai、Recoil 等)在所有微应用之间共享。这是实现真正“单一数据源”的强大方式。
2.1. 宿主应用提供共享 Store 实例
原理:
宿主应用负责初始化全局状态管理库的 store,然后以某种方式(例如通过 props、全局对象或 Module Federation)将该 store 实例传递给或暴露给其加载的微应用。所有微应用都连接到这个唯一的 store 实例。
实现方式(以 Zustand 为例,因为它轻量且易于共享):
第一步:定义共享的 Zustand Store
// shared-utils/globalStore.js
import { create } from 'zustand';
// 定义全局状态的接口
export const useGlobalStore = create((set) => ({
theme: 'light',
user: null,
isAuthenticated: false,
setTheme: (newTheme) => set({ theme: newTheme }),
login: (userData) => set({ user: userData, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
}));
第二步:宿主应用初始化并暴露 Store(通过全局变量)
// host/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import HostApp from './HostApp';
import { useGlobalStore } from '../../shared-utils/globalStore'; // 引入共享 store
// 确保在任何微应用加载之前,全局 store 已经被初始化
// 在宿主应用中,我们可以直接使用它,或者将其挂载到 window 对象上供微应用访问
window.globalStore = useGlobalStore; // 暴露 Zustand hook
ReactDOM.render(<HostApp />, document.getElementById('root'));
第三步:微应用消费共享 Store
// mfe1/src/App.js
import React from 'react';
// 从 window 对象获取共享 store hook
const useMFE1GlobalStore = window.globalStore;
function MFE1App() {
const theme = useMFE1GlobalStore((state) => state.theme);
const setTheme = useMFE1GlobalStore((state) => state.setTheme);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
};
return (
<div>
<h3>MFE1</h3>
<p>Global Theme: {theme}</p>
<button onClick={toggleTheme}>Toggle Global Theme from MFE1</button>
</div>
);
}
// mfe2/src/App.js
import React from 'react';
// 从 window 对象获取共享 store hook
const useMFE2GlobalStore = window.globalStore;
function MFE2App() {
const theme = useMFE2GlobalStore((state) => state.theme);
const user = useMFE2GlobalStore((state) => state.user);
const isAuthenticated = useMFE2GlobalStore((state) => state.isAuthenticated);
const login = useMFE2GlobalStore((state) => state.login);
const logout = useMFE2GlobalStore((state) => state.logout);
const buttonStyle = {
background: theme === 'dark' ? '#333' : '#eee',
color: theme === 'dark' ? 'white' : 'black',
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
};
return (
<div>
<h3>MFE2</h3>
<p>Global Theme: {theme}</p>
<p>User: {user ? user.name : 'Guest'}</p>
<p>Authenticated: {isAuthenticated ? 'Yes' : 'No'}</p>
<button style={buttonStyle}>Themed Button</button>
{!isAuthenticated ? (
<button onClick={() => login({ name: 'Micro-User' })}>Login</button>
) : (
<button onClick={logout}>Logout</button>
)}
</div>
);
}
// 宿主应用 main.js (渲染 MFE1App 和 MFE2App)
import React from 'react';
import ReactDOM from 'react-dom';
import MFE1App from './mfe1/App';
import MFE2App from './mfe2/App';
// 宿主应用本身也可以使用 globalStore
import { useGlobalStore } from './shared-utils/globalStore';
function HostWrapper() {
const theme = useGlobalStore((state) => state.theme);
return (
<div style={{ padding: '20px', border: `2px solid ${theme === 'dark' ? 'white' : 'black'}` }}>
<h1>Host Application</h1>
<div id="mfe-container-1">
<MFE1App />
</div>
<div id="mfe-container-2">
<MFE2App />
</div>
</div>
);
}
ReactDOM.render(<HostWrapper />, document.getElementById('root'));
优缺点:
| 特性 | 优点 | 缺点 |
|---|---|---|
| 数据源 | 真正的单一数据源,所有微应用看到的是同一份状态。 | |
| 一致性 | 状态更新实时同步,数据一致性高。 | |
| 调试 | 很多状态管理库提供强大的 DevTools。 | |
| 性能 | 订阅机制通常经过优化,避免不必要的重渲染。 | |
| 耦合度 | 微应用需要依赖宿主暴露的 store 实例。 | 宿主和微应用需要就状态管理库和其版本达成一致。 |
| 复杂性 | 需要约定好共享状态的结构和操作方法。 | 初始配置和组织共享状态可能需要更多设计。 |
| 类型安全 | TypeScript 可以提供很好的类型支持。 |
适用场景:
适用于需要高度一致性和实时同步的全局状态,如用户认证信息、全局主题/语言设置、购物车状态、通知中心等。当微应用之间存在紧密的数据交互需求时,这是一个非常强大的解决方案。
2.2. 使用 Monorepo 共享 Store 模块
如果宿主应用和所有微应用都位于一个 Monorepo 中,并且使用相同的技术栈(例如都用 React),那么可以直接将共享的状态管理模块(如 shared-utils/globalStore.js)作为 Monorepo 的一个包,供所有微应用直接导入和使用,而无需通过 window 对象进行暴露。这种方式提供了更好的类型安全和模块化。
3. 框架级解决方案:Webpack Module Federation
Webpack 5 引入的 Module Federation 是微前端架构中的一个里程碑式特性。它允许不同的 Webpack 构建(甚至不同的应用)在运行时共享模块。这不仅仅是共享组件,更是共享底层依赖(如 React、ReactDOM)和运行时模块的能力。
原理:
Module Federation 允许一个应用(Host 或 Container)从另一个应用(Remote)加载模块,反之亦然。这些模块可以是任何 JavaScript 模块,包括 React 组件、hooks,甚至是 React.createContext 创建的 Context 对象本身,或者状态管理库的 store 实例。
最强大的地方在于,它能够共享 React 和 ReactDOM 的实例。这意味着所有微应用可以共享同一个 React 运行时,从而打破了 React Context 的隔离限制。如果所有微应用都使用由宿主应用提供的同一个 React 实例,那么一个 Context.Provider 就可以在宿主应用中定义,并被所有微应用中的 useContext 钩子访问到。
实现方式(共享 React Context 实例):
第一步:定义共享 Context
// shared-context/src/GlobalThemeContext.js
import React from 'react';
export const GlobalThemeContext = React.createContext({
theme: 'light',
toggleTheme: () => {},
});
第二步:宿主应用配置 Module Federation
宿主应用(Host)将作为 React、ReactDOM 以及我们自定义的 GlobalThemeContext 的提供者。
// host/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
entry: './src/index.js',
mode: 'development',
devServer: {
port: 3000,
},
output: {
publicPath: 'http://localhost:3000/', // 宿主应用的公共路径
},
module: {
rules: [
{
test: /.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react'],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'host', // 宿主应用的名称
remotes: {
mfe1: 'mfe1@http://localhost:3001/remoteEntry.js', // 远程微应用1
mfe2: 'mfe2@http://localhost:3002/remoteEntry.js', // 远程微应用2
},
shared: {
// 共享 React 和 ReactDOM,确保所有应用使用同一个实例
react: {
singleton: true, // 确保只加载一次
requiredVersion: '^18.0.0', // 限制版本
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
// 共享我们的自定义 Context,让微应用可以直接导入使用
'./src/GlobalThemeContext': {
singleton: true,
requiredVersion: false, // 不限制版本,因为它将是宿主提供的唯一实例
},
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
// host/src/index.js
import React, { useState } from 'react';
import ReactDOM from 'react-dom/client'; // React 18 createRoot
import { GlobalThemeContext } from './GlobalThemeContext'; // 引入共享 Context
// 动态导入微应用
const MFE1App = React.lazy(() => import('mfe1/MFEApp'));
const MFE2App = React.lazy(() => import('mfe2/MFEApp'));
function HostApp() {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};
const contextValue = { theme, toggleTheme };
return (
<GlobalThemeContext.Provider value={contextValue}>
<div style={{ padding: '20px', border: `2px solid ${theme === 'dark' ? 'white' : 'black'}` }}>
<h1>Host Application (Theme: {theme})</h1>
<button onClick={toggleTheme}>Toggle Global Theme from Host</button>
<div style={{ marginTop: '20px', display: 'flex', gap: '20px' }}>
<div id="mfe-container-1">
<React.Suspense fallback={<div>Loading MFE1...</div>}>
<MFE1App />
</React.Suspense>
</div>
<div id="mfe-container-2">
<React.Suspense fallback={<div>Loading MFE2...</div>}>
<MFE2App />
</React.Suspense>
</div>
</div>
</div>
</GlobalThemeContext.Provider>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<HostApp />);
// host/src/GlobalThemeContext.js (这是宿主应用暴露出去的 Context)
import React from 'react';
export const GlobalThemeContext = React.createContext({
theme: 'light',
toggleTheme: () => {},
});
第三步:微应用配置 Module Federation
微应用(Remote)将从宿主应用消费 React、ReactDOM 和 GlobalThemeContext。
// mfe1/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
entry: './src/index.js',
mode: 'development',
devServer: {
port: 3001,
},
output: {
publicPath: 'http://localhost:3001/',
},
module: {
rules: [
{
test: /.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-react'],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'mfe1',
library: { type: 'var', name: 'mfe1' },
filename: 'remoteEntry.js', // 远程入口文件
exposes: {
'./MFEApp': './src/App', // 暴露微应用的主组件
},
remotes: {
host: 'host@http://localhost:3000/remoteEntry.js', // 声明宿主为远程
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
// 从宿主应用中导入共享 Context,注意这里的路径需要与宿主 expose 的路径一致
'./src/GlobalThemeContext': {
singleton: true,
requiredVersion: false,
},
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
// mfe1/src/App.js
import React, { useContext } from 'react';
// 从宿主应用共享的 GlobalThemeContext
import { GlobalThemeContext } from 'host/GlobalThemeContext'; // 注意这里的导入路径是 Module Federation 声明的
function MFE1App() {
const { theme, toggleTheme } = useContext(GlobalThemeContext);
const buttonStyle = {
background: theme === 'dark' ? '#333' : '#eee',
color: theme === 'dark' ? 'white' : 'black',
padding: '10px 20px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
};
return (
<div style={{ border: `1px dashed ${theme === 'dark' ? 'gray' : 'lightgray'}`, padding: '15px' }}>
<h3>MFE1 (Theme: {theme})</h3>
<button style={buttonStyle} onClick={toggleTheme}>
Toggle Global Theme from MFE1
</button>
</div>
);
}
// mfe1/src/index.js
import('./bootstrap'); // 动态导入,确保 Webpack 知道如何处理 Module Federation
mfe2 的配置与 mfe1 类似,只是端口和名称不同。
优缺点:
| 特性 | 优点 | 缺点 |
|---|---|---|
| 原生 | 真正共享 React Context 实例,符合 React 范式。 | |
| 性能 | 共享 React 和 ReactDOM 实例,减少包体积,避免重复加载。 | 首次加载可能较慢,需要网络请求加载远程模块。 |
| 耦合度 | 微应用需要依赖宿主提供的共享模块。 | 需要所有应用都使用 Webpack 5。 |
| 灵活度 | 不仅能共享 Context,还能共享组件、工具函数等。 | 配置复杂,学习曲线较陡峭。 |
| 版本控制 | requiredVersion 帮助管理共享依赖的版本兼容性。 |
共享模块的 API 变更可能影响所有消费者。 |
| 调试 | 相对容易,因为是同一个 React 树下的 Context。 | 运行时加载,可能增加调试难度(例如源地图)。 |
适用场景:
Module Federation 是目前微前端架构下共享 React Context 和复杂状态的最优雅、最强大的解决方案。它适用于所有微前端都使用 React 且对性能和模块共享有高要求的场景。尤其适合宿主应用和微应用之间存在紧密技术栈绑定和高度集成需求的场景。
4. 共享 JavaScript 模块(非 Module Federation)
如果不是使用 Webpack Module Federation,但所有微应用仍然可以访问到宿主应用或一个公共目录中的 JavaScript 文件(例如通过 CDN 或者宿主应用直接在 <script> 标签中加载),那么可以将共享的 createContext 实例或 Zustand store 定义在一个独立的 JavaScript 文件中,并在宿主应用和所有微应用中都导入并使用它。
实现方式:
类似于 2.1 中的 Zustand 示例,但不再通过 window.globalStore 暴露,而是通过一个共享的包或文件。
// shared-utils/globalStore.js (这个文件会被所有微应用直接 import)
import { create } from 'zustand';
export const useGlobalStore = create((set) => ({
theme: 'light',
setTheme: (newTheme) => set({ theme: newTheme }),
}));
// 宿主应用
// import { useGlobalStore } from 'shared-utils/globalStore';
// ... 宿主应用使用 useGlobalStore ...
// 微应用
// import { useGlobalStore } from 'shared-utils/globalStore';
// ... 微应用使用 useGlobalStore ...
关键挑战: 这种方式面临的挑战是如何确保所有微应用导入的 useGlobalStore 是同一个模块实例。如果每个微应用都独立打包,并且 shared-utils/globalStore.js 被打包进了每个微应用的 bundle 中,那么它们将各自拥有一个独立的 useGlobalStore 实例,从而导致状态隔离。
解决方案:
- Monorepo + 外部化(Externalization): 在 Monorepo 中,将
shared-utils作为公共包,并在每个微应用的 Webpack 配置中将其标记为external。宿主应用在 HTML 中通过<script>标签引入这个共享库的全局变量。这样,所有微应用都会引用宿主提供的全局变量,从而共享同一个实例。 - UMD/IIFE 打包的共享库: 将
shared-utils/globalStore.js打包成 UMD 或 IIFE 格式,并通过 CDN 或宿主应用直接引入到全局作用域(例如window.SharedStore)。微应用则通过window.SharedStore来访问。
这个方法与 2.1 类似,但更强调如何在没有 Module Federation 的情况下,通过打包和部署策略来确保共享模块的单一实例性。
五、架构考量与最佳实践
选择哪种状态共享策略,并非一概而论,需要综合考虑项目规模、团队结构、技术栈一致性、对解耦程度的要求、性能需求以及开发复杂性。
-
识别真正需要共享的状态:
- 并非所有状态都需要全局共享。过度共享会导致系统紧密耦合,增加维护难度。
- 优先考虑:用户认证信息、全局主题/语言设置、通知、导航状态等对用户体验一致性影响最大的状态。
- 避免共享:微应用内部特有的、不影响其他微应用的功能性状态。
-
明确状态所有权:
- 谁负责初始化、修改和维护共享状态?是宿主应用,还是某个特定的微应用,或者是一个独立的共享服务?
- 清晰的所有权能避免冲突和混乱。通常,宿主应用拥有或协调全局状态是更常见和推荐的做法。
-
版本控制与兼容性:
- 共享状态的结构和 API 可能会随着时间演进。
- 需要一套机制来处理共享状态的 breaking changes。例如,通过事件总线传递的事件详情可以包含版本号,或者共享 Store 可以通过 Reducer 组合来逐步升级。
- Module Federation 的
requiredVersion是管理依赖版本兼容性的强大工具。
-
性能优化:
- 频繁的状态更新可能导致不必要的重渲染。使用状态管理库时,应充分利用其选择器(selectors)和优化机制(如 memoization)来减少组件更新。
- 避免在
localStorage或事件总线中传递过大的数据负载。
-
错误处理与调试:
- 共享状态的复杂性增加了调试难度。
- 利用状态管理库提供的 DevTools(如 Redux DevTools、Zustand DevTools)可以有效追踪状态变化。
- 对于基于事件的通信,日志记录和明确的事件命名约定至关重要。
-
安全考虑:
- 不要在
localStorage中存储敏感的用户信息,除非经过加密。 - 跨域通信(如果涉及到 iframe)需要仔细处理 CORS 策略。
- 不要在
-
选择合适的工具:
- 简单通知/事件: Event Bus (Custom Events)。
- 持久化配置/用户偏好:
localStorage配合 Event Bus。 - 复杂、实时同步的全局状态: Zustand、Redux 等状态管理库,通过宿主暴露或 Module Federation 共享。
- 高度集成、共享 React 运行时: Webpack Module Federation。
-
Monorepo 与 Polyrepo:
- Monorepo: 更容易共享代码和配置,简化依赖管理。Module Federation 或共享 JS 模块在这里表现出色。
- Polyrepo: 独立性更强,但共享代码和协调版本更具挑战。Event Bus 和
localStorage可能更受欢迎,因为它们提供了更大的解耦。
六、结语
在微前端的世界中,状态共享是一个复杂而关键的问题。它要求我们超越传统单体应用的思维模式,拥抱分布式系统的设计理念。没有一劳永逸的解决方案,最佳实践取决于具体的项目需求和团队上下文。
通过理解 React Context 的本质限制,并掌握浏览器原生机制、中心化状态管理库以及 Webpack Module Federation 等高级工具,我们可以为微前端架构中的状态共享提供坚实的基础。在实践中,我们常常会结合多种策略,以在解耦、一致性、性能和开发效率之间取得最佳平衡。精心的设计和持续的迭代,将是构建健壮、可伸缩微前端系统的必由之路。
感谢大家的聆听!