React 应用间状态通信:利用事件总线(Event Bus)与跨应用 Context 模拟实现 React 应用的解耦
开场白:各位,准备好打破 React 的“自闭症”了吗?
大家好!欢迎来到今天的讲座。我是你们的老朋友,那个总是试图让代码像乐高积木一样松耦合的资深工程师。
今天我们要聊一个非常有意思,甚至有点“变态”的话题:React 应用间状态通信。
我们知道,React 是个很乖的孩子,它有个原则叫“单向数据流”。它喜欢把数据像传接力棒一样,从最上面的 App 传到 Header,再传到 Button,最后传到 Modal。这很完美,这叫“父子通信”。但是,一旦你的应用变得稍微复杂一点,比如你接手了一个微前端项目,或者你不得不把一个巨大的单体应用拆成了两个独立的 React 实例(App A 和 App B),问题就来了。
App A 想告诉 App B:“嘿,我刚买了个薯片!”
App B 回答:“我听不见啊!你在跟我说话吗?”
这就是 React 的局限性。React 的 Context API 和 Redux 等状态管理工具,它们通常只在一个 React 根节点下工作。它们是“局域网”高手,不是“互联网”专家。
那么,我们该怎么办?难道我们要通过 URL 参数传值?还是通过 window.localStorage 做个定时轮询?那也太老土了,那简直是 90 年代的互联网。
今天,我们要学习两种高级技巧:事件总线(Event Bus) 和 跨应用 Context 模拟。我们要用这些技巧,把两个互不认识的 React 应用变成一对“心有灵犀”的恋人。
准备好了吗?让我们开始这场“代码调情”之旅。
第一部分:事件总线——那个在派对上扔纸飞机的疯子
想象一下,你在一个巨大的派对上。你手里有一张写着“我买了奶茶”的纸飞机。你把它扔向空中。谁捡到了?谁在乎?也许是你隔壁桌的朋友,也许是一个路过的陌生人。但是,当纸飞机落地时,有人接住了,并且开心地喝了奶茶。
这就是发布/订阅模式,也就是我们今天要讲的事件总线。
1.1 核心概念:上帝的视角
在传统的 React 组件树里,组件是垂直连接的。但在事件总线里,组件是水平连接的。它们之间不需要知道对方长什么样,只需要知道“我要发个消息”和“我要听个消息”。
我们不需要去修改 App A 的代码去触碰 App B 的代码,我们只需要在 App A 里“广播”一个事件,App B 就能收到。
1.2 实现一个简单的 EventBus
咱们先来写个 EventBus 类。别怕,它其实就是个 Map 和一堆函数。
// EventBus.js
class EventBus {
constructor() {
// 这里的 Map 就像是一个巨大的收件箱,key 是事件名,value 是监听器的数组
this.events = new Map();
}
// 订阅事件
on(eventName, callback) {
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
// 把回调函数塞进数组里
this.events.get(eventName).push(callback);
// 返回一个取消订阅的函数,方便以后清理(防止内存泄漏,这点很重要!)
return () => this.off(eventName, callback);
}
// 取消订阅
off(eventName, callback) {
const callbacks = this.events.get(eventName);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
// 发布事件
emit(eventName, data) {
const callbacks = this.events.get(eventName);
if (callbacks) {
// 遍历所有监听者,执行回调
callbacks.forEach((callback) => callback(data));
}
}
}
// 创建一个全局单例,这就是我们的“广播电台”
export const eventBus = new EventBus();
1.3 在 React 中使用 EventBus
光有 EventBus 类还不够,我们需要把它包装成一个 React Hook,这样写起来才像“React 风格”。
// useEventBus.js
import { useEffect, useRef } from 'react';
import { eventBus } from './EventBus';
export const useEventBus = (eventName, callback, deps = []) => {
// 使用 ref 来存储 callback,防止闭包陷阱(老生常谈但很重要)
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
// 订阅事件
const unsubscribe = eventBus.on(eventName, (data) => {
// 执行回调
callbackRef.current(data);
});
// 组件卸载时自动取消订阅,防止内存泄漏
return () => {
unsubscribe();
};
}, [eventName, ...deps]);
};
1.4 实战演示:App A 和 App B 的“秘密通话”
现在,我们有两个独立的 React 应用。为了演示方便,我们假设它们在同一个页面里,或者通过 iframe 通信(但这里我们只模拟逻辑)。
场景: App A 是一个“登录系统”,App B 是一个“仪表盘”。当你在 App A 登录成功后,App B 应该显示你的名字。
App A 组件:
// AppA.jsx
import React, { useState } from 'react';
import { useEventBus } from './useEventBus';
const AppA = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
// 监听“登录成功”这个事件
useEventBus('USER_LOGIN', (userData) => {
console.log('App A 收到消息了!新用户是:', userData);
// App A 也可以做点什么,比如更新自己的状态
setIsLoggedIn(true);
});
const handleLogin = () => {
const fakeUser = { name: '张三', id: 101 };
// 触发“登录成功”事件
eventBus.emit('USER_LOGIN', fakeUser);
};
return (
<div style={{ border: '2px solid blue', padding: '20px', margin: '10px' }}>
<h2>App A (登录系统)</h2>
<p>状态:{isLoggedIn ? '已登录' : '未登录'}</p>
<button onClick={handleLogin}>点我登录</button>
</div>
);
};
App B 组件:
// AppB.jsx
import React from 'react';
import { useEventBus } from './useEventBus';
const AppB = () => {
const [userInfo, setUserInfo] = useState(null);
// 监听“用户信息更新”事件
useEventBus('USER_INFO_UPDATED', (data) => {
console.log('App B 收到消息了!用户更新为:', data);
setUserInfo(data);
});
return (
<div style={{ border: '2px solid green', padding: '20px', margin: '10px' }}>
<h2>App B (仪表盘)</h2>
{!userInfo ? (
<p>等待用户登录...</p>
) : (
<p>欢迎回来,{userInfo.name}!</p>
)}
</div>
);
};
看!这就是解耦的魅力。App A 和 App B 甚至不需要知道对方的存在。App A 只管抛出事件,App B 只管接住事件。如果有一天 App B 不想要这个功能了,它只需要 off 掉这个事件监听器,甚至不需要删掉 App A 的代码。这就是松耦合。
1.5 事件总线的“副作用”
当然,事件总线也不是万能的神药,它也有副作用。最著名的就是内存泄漏。
想象一下,App A 在页面刚加载时就注册了一个监听器,监听一个叫 BIG_DATA_EVENT 的事件。但是,App A 的用户马上就离开了页面。那个监听器还在内存里等着。如果此时 App C 发出了一个 BIG_DATA_EVENT,App A 早就销毁了,但它的监听器还在运行,试图更新一个不存在的组件。这就叫内存泄漏。
所以,我们在使用 useEventBus 时,一定要在 useEffect 的清理函数里 unsubscribe。
另外,事件总线还有一个问题:调试困难。如果 App A 闪烁了一下,App B 没反应,你很难瞬间定位是 App A 没发,还是 App B 没接,或者是中间的网络断了(如果是跨域)。你需要给每个事件加上日志,像侦探一样排查。
第二部分:跨应用 Context 模拟——共享冰箱里的秘密
现在,我们来到了第二个大招:跨应用 Context。
React 的 Context API 本质上是一个全局变量,只不过它被包装得很好看,用来避免 Props Drilling(属性穿透)。通常,我们在一个组件树的最顶端放一个 Provider,然后下面的所有组件都可以通过 useContext 获取数据。
但是,Context 是DOM 树绑定的。这意味着,如果 App A 是一个 React 根节点,App B 是另一个 React 根节点,Context 的 Provider 是无法跨越这两个根节点的。
但是!我们要模拟跨应用通信。怎么模拟?
2.1 核心思想:共享内存
既然 Context 不能跨 DOM 树,那我们就让两个 React 应用都访问同一个内存空间。
我们可以创建一个类,这个类里有一个 value 属性。App A 改变这个 value,App B 读取这个 value。这听起来很简单,对吧?
这其实就是一种“全局状态管理”的变体,只不过我们用 Context 来“消费”这个状态。
2.2 构建一个跨应用共享 Store
我们来写一个 createSharedContext 工具函数。
// createSharedContext.js
import { createContext, useContext } from 'react';
// 1. 创建一个 Context 对象
// 这里的初始值是 null,因为我们还没初始化
const SharedContext = createContext(null);
// 2. 创建一个 Store 类
// 这个类是单例的,所有的 App 都指向同一个实例
class SharedStore {
constructor(initialState = {}) {
this.state = initialState;
this.listeners = [];
}
// 更新状态的方法
setState(newState) {
this.state = { ...this.state, ...newState };
// 状态一变,通知所有监听者
this.listeners.forEach((listener) => listener(this.state));
}
// 获取状态的方法
getState() {
return this.state;
}
// 订阅状态变化
subscribe(listener) {
this.listeners.push(listener);
// 返回一个取消订阅的函数
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
}
// 创建一个全局唯一的 Store 实例
// 我们可以把这个 Store 挂载到 window 上,这样两个 React 应用都能拿到
export const sharedStore = new SharedStore({ user: null, theme: 'light' });
if (typeof window !== 'undefined') {
window.__SHARED_STORE__ = sharedStore;
}
// 3. 创建一个 Provider 组件
// 这个组件负责把 Store 的状态暴露给 Context
export const SharedProvider = ({ children }) => {
const [state, setState] = useState(sharedStore.getState());
useEffect(() => {
// 订阅 Store 的变化
const unsubscribe = sharedStore.subscribe((newState) => {
setState(newState);
});
// 组件卸载时取消订阅
return unsubscribe;
}, []);
return <SharedContext.Provider value={state}>{children}</SharedContext.Provider>;
};
// 4. 创建一个 Hook,方便获取状态和更新状态
export const useSharedState = () => {
const state = useContext(SharedContext);
const setState = useCallback((newState) => {
sharedStore.setState(newState);
}, []);
return [state, setState];
};
2.3 跨应用的“幽灵”连接
注意看上面的代码,关键在于 window.__SHARED_STORE__。我们利用了浏览器全局对象 window 来存储 Store 实例。
这意味着,无论 App A 在哪里,也无论 App B 在哪里,只要它们都能访问 window.__SHARED_STORE__,它们就共享了同一个状态对象。
这就像是你们两家住对门,你把钥匙放在门垫下面,我也能拿到。虽然有点不安全,但在同一个浏览器域下,这很有效。
2.4 实战演示:App A 修改,App B 实时响应
现在,让我们把上面的代码用到两个 React 应用中。
App A (修改者):
// AppA.jsx
import React from 'react';
import { useSharedState } from './createSharedContext';
const AppA = () => {
// 获取状态和更新函数
const [sharedData, setSharedData] = useSharedState();
const handleUpdateTheme = () => {
const newTheme = sharedData.theme === 'light' ? 'dark' : 'light';
// 调用共享 Store 的 setState
setSharedData({ theme: newTheme });
};
return (
<div style={{ border: '2px solid red', padding: '20px', margin: '10px' }}>
<h2>App A (主题控制)</h2>
<p>当前主题:{sharedData.theme}</p>
<button onClick={handleUpdateTheme}>
{sharedData.theme === 'light' ? '切换到暗黑模式' : '切换到亮色模式'}
</button>
</div>
);
};
App B (观察者):
// AppB.jsx
import React from 'react';
import { useSharedState } from './createSharedContext';
const AppB = () => {
const [sharedData] = useSharedState();
return (
<div style={{ border: '2px solid purple', padding: '20px', margin: '10px' }}>
<h2>App B (内容展示)</h2>
<p>我也看到了主题是:{sharedData.theme}</p>
<div style={{ background: sharedData.theme === 'light' ? '#fff' : '#333', color: sharedData.theme === 'light' ? '#000' : '#fff' }}>
这是一个受全局主题影响的盒子。
</div>
</div>
);
};
主入口 (模拟两个应用):
// main.jsx
import React, { useState } from 'react';
import { SharedProvider } from './createSharedContext';
import AppA from './AppA';
import AppB from './AppB';
const App = () => {
return (
<SharedProvider>
<div style={{ display: 'flex', gap: '20px', padding: '20px' }}>
<AppA />
<AppB />
</div>
</SharedProvider>
);
};
export default App;
效果:
当你点击 App A 的按钮时,App A 的主题会变,App B 的主题也会同步变。这就是跨应用 Context 的魔力!App B 甚至不需要知道 App A 的存在,它只是从 Context 里拿数据。
2.5 跨应用 Context 的“坑”
虽然这种方式很酷,但它有几个致命的问题,我们必须诚实面对。
- 状态同步延迟: React 的 Context 更新是基于组件渲染的。如果 App A 更新了状态,App B 不会立即知道,除非 App B 重新渲染。如果 App B 处于优化模式(使用了
React.memo),它可能根本不会重新渲染。这会导致状态不同步。 - “上帝对象”:
SharedStore变成了整个应用的控制中心。所有的状态变更都要经过它。如果 App C 修改了一个状态,App A 和 App B 都会收到通知,即使 App A 和 App B 根本不需要这个状态。这会造成不必要的性能开销。 - 调试噩梦: 状态去哪里了?因为它是内存里的,你没法在 React DevTools 里直接看到。你只能去控制台打印
window.__SHARED_STORE__.state。
第三部分:深度解析与对比——选哪个?
好了,我们已经介绍了两个技术。现在,让我们像喝咖啡一样,慢慢品品它们。
3.1 事件总线 vs. 跨应用 Context
| 特性 | 事件总线 (Event Bus) | 跨应用 Context (Shared Store) |
|---|---|---|
| 通信方式 | 发布/订阅 (点对点,广播式) | 状态共享 (读写操作) |
| 耦合度 | 极低。发布者和订阅者互不相识。 | 较高。Provider 和 Consumer 强依赖同一个 Store。 |
| 数据流 | 单向 (事件 -> 回调),但不可逆。 | 双向 (App A 写 -> Store 更新 -> App B 读)。 |
| 调试难度 | 高。事件多了容易乱,难以追踪。 | 中。虽然状态在内存里,但至少是数据驱动。 |
| 适用场景 | 简单的通知、日志记录、解耦逻辑。 | 需要实时共享数据、共享配置、共享用户信息。 |
| 性能 | 较好。按需触发。 | 较差。任何状态变更都会触发所有订阅者。 |
3.2 什么时候该用事件总线?
当你的需求是“通知”的时候。
比如:用户点击了“保存”按钮,App A 发出一个 SAVED_EVENT,App B 收到后弹出一个 Toast 提示“保存成功”。
App B 不需要知道用户是怎么点保存的,它只需要知道“有人保存了”。
3.3 什么时候该用跨应用 Context?
当你的需求是“共享”的时候。
比如:用户登录了,App A 显示“退出登录”,App B 显示“用户头像”。这两个 UI 必须显示同一个用户信息,不能让 App B 里的用户信息是旧的。
这种情况下,Context 是最佳选择,因为它能保证数据的一致性。
第四部分:终极实战——一个完整的微前端模拟场景
为了证明我们的理论,我们来做一个稍微复杂点的模拟。
场景设定:
我们有一个“主应用”。
主应用里有两个“子应用”:
- 导航应用: 负责菜单切换。
- 内容应用: 负责显示文章内容。
需求:
当用户在导航应用点击“关于我们”时,内容应用应该立即切换显示“关于我们”的页面。并且,无论点击哪个菜单,导航应用上当前选中的菜单项样式要改变。
这需要两个应用之间进行双向通信:
- 导航 -> 内容:点击菜单。
- 导航 -> 导航:更新选中样式。
解决方案:
我们使用 事件总线 来处理“点击菜单”的指令,使用 跨应用 Context 来处理“当前菜单项”的状态。
代码实现:
1. 定义工具函数:
// utils.js
// 事件总线
class EventBus {
constructor() {
this.events = new Map();
}
on(e, cb) {
if (!this.events.has(e)) this.events.set(e, []);
this.events.get(e).push(cb);
return () => this.off(e, cb);
}
off(e, cb) {
const cbs = this.events.get(e);
if (cbs) {
const i = cbs.indexOf(cb);
if (i > -1) cbs.splice(i, 1);
}
}
emit(e, d) {
const cbs = this.events.get(e);
if (cbs) cbs.forEach(cb => cb(d));
}
}
export const eventBus = new EventBus();
// 跨应用 Context
import { createContext, useContext } from 'react';
import { useState, useEffect } from 'react';
class AppState {
constructor() {
this.state = { activeMenu: 'home' };
this.listeners = [];
}
setState(newState) {
this.state = { ...this.state, ...newState };
this.listeners.forEach(l => l(this.state));
}
getState() { return this.state; }
subscribe(l) {
this.listeners.push(l);
return () => { this.listeners = this.listeners.filter(item => item !== l); };
}
}
export const globalState = new AppState();
if (typeof window !== 'undefined') window.__GLOBAL_STATE__ = globalState;
export const GlobalProvider = ({ children }) => {
const [state, setState] = useState(globalState.getState());
useEffect(() => globalState.subscribe(setState), []);
return <AppStateContext.Provider value={state}>{children}</AppStateContext.Provider>;
};
export const useAppState = () => useContext(AppStateContext);
2. 导航应用:
// NavigationApp.jsx
import React from 'react';
import { useEventBus, useAppState } from './utils';
import { eventBus } from './utils';
const NavigationApp = () => {
const { activeMenu } = useAppState();
const menus = ['Home', 'About', 'Contact', 'Blog'];
// 监听外部点击事件(虽然这里没外部,但为了模拟)
useEventBus('MENU_CLICK', (menuName) => {
console.log(`NavigationApp: 收到指令,切换到 ${menuName}`);
});
const handleMenuClick = (menu) => {
// 1. 更新自己的状态
globalState.setState({ activeMenu: menu });
// 2. 广播事件通知其他应用
eventBus.emit('MENU_CLICK', menu);
};
return (
<div style={{ border: '2px solid blue', padding: '10px', background: '#eee' }}>
<h3>导航应用</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{menus.map((menu) => (
<li
key={menu}
style={{
padding: '5px',
cursor: 'pointer',
backgroundColor: activeMenu === menu ? 'blue' : 'white',
color: activeMenu === menu ? 'white' : 'black',
display: 'inline-block',
marginRight: '5px',
}}
onClick={() => handleMenuClick(menu)}
>
{menu}
</li>
))}
</ul>
</div>
);
};
3. 内容应用:
// ContentApp.jsx
import React from 'react';
import { useEventBus, useAppState } from './utils';
const ContentApp = () => {
const { activeMenu } = useAppState();
// 监听菜单点击事件,根据事件更新内容
useEventBus('MENU_CLICK', (menuName) => {
console.log(`ContentApp: 收到切换请求,显示 ${menuName} 页面`);
// 这里可以触发路由跳转,或者更新内容状态
});
return (
<div style={{ border: '2px solid green', padding: '20px', minHeight: '200px' }}>
<h3>内容应用</h3>
<p>当前显示页面:{activeMenu}</p>
<p>
{activeMenu === 'Home' && '这里是首页内容...'}
{activeMenu === 'About' && '这里是关于我们...'}
{activeMenu === 'Contact' && '这里是联系方式...'}
{activeMenu === 'Blog' && '这里是博客列表...'}
</p>
</div>
);
};
4. 主应用:
// MainApp.jsx
import React from 'react';
import { GlobalProvider } from './utils';
import NavigationApp from './NavigationApp';
import ContentApp from './ContentApp';
const MainApp = () => {
return (
<GlobalProvider>
<div style={{ display: 'flex', fontFamily: 'Arial' }}>
<NavigationApp />
<ContentApp />
</div>
</GlobalProvider>
);
};
export default MainApp;
运行结果:
当你点击蓝色的菜单项时,你会看到:
- 导航应用上的蓝色背景会跟着变(Context 同步)。
- 内容应用下方的文字内容会跟着变(Context 同步)。
- 控制台会打印出 ContentApp 收到
MENU_CLICK事件的信息(EventBus 通信)。
这就是事件总线 + 跨应用 Context 的完美结合。Context 负责状态共享,EventBus 负责指令传递。
第五部分:专家的碎碎念与避坑指南
好了,代码演示完了。作为资深专家,我有几句心里话必须告诉你们。
5.1 不要滥用全局状态
很多人一听到“跨应用通信”,就喜欢用 window.state = ...。这就像在厨房的桌子上放了一块蛋糕,然后告诉全家:“谁想吃谁拿。”
结果呢?App A 把蛋糕吃了一半,App B 再拿的时候发现蛋糕少了一半。数据污染非常严重。
在使用 Context 模拟跨应用通信时,一定要确保你的 Store 是不可变数据。每次更新都要返回一个新的对象,而不是直接修改 this.state。
5.2 内存泄漏是头号杀手
我们在前面提到了内存泄漏。在 React Hooks 里,这是一个永恒的话题。
使用 useEventBus 时,一定要记得 useEffect 的返回函数。
使用 GlobalProvider 时,也要记得取消订阅。
如果你发现你的应用越用越卡,浏览器内存占用越来越高,第一个怀疑对象就是你的事件监听器和订阅者。
5.3 调试的艺术
当你面对两个互相通信的 React 应用时,调试简直是一种艺术。
建议你在所有 emit 和 on 的地方都加上 console.log。
比如:
console.log('[EventBus] Emitting: LOGIN, Data:', data);
console.log('[EventBus] Listening: LOGIN, Callback:', callback);
这样,当事件丢失的时候,你能像福尔摩斯一样,在控制台里找到线索。
5.4 未来的趋势
说实话,虽然我们今天讲了这些技巧,但在现代前端架构中,这种“手动”的跨应用通信正变得越来越少。
微前端框架(如 qiankun, single-spa)通常提供了一套更高级的通信机制,比如 initGlobalState。它们本质上也是基于 EventBus 或者全局状态管理的,但是经过了封装,更安全,更易用。
但是,理解底层的原理是至关重要的。如果你不知道 EventBus 是怎么工作的,你就无法理解微前端框架为什么能实现应用隔离和通信。
结语:打破边界,拥抱自由
好了,今天的讲座就到这里。
我们学会了:
- EventBus:如何像扔纸飞机一样,把消息传给任何角落的组件。
- 跨应用 Context:如何像共享冰箱一样,让两个应用共享同一个状态。
- 组合拳:如何将两者结合,解决复杂的微前端通信问题。
React 的力量在于组件,而 React 的高级用法在于组件之间的关系。无论是父子关系,还是兄弟关系,甚至是两个独立的“陌生人”关系,只要掌握了事件总线和状态共享的技巧,你就能构建出灵活、解耦、甚至有点“黑客帝国”感觉的 Web 应用。
记住,代码不仅仅是写给机器看的,更是写给人类看的。当你的代码让不同模块之间不再互相依赖,不再互相咒骂,而是能够优雅地握手言和时,那就是你作为程序员的最高境界。
现在,去试试吧!把你的 App A 和 App B 连接起来,看看会发生什么神奇的事情!
谢谢大家!