React 应用间状态通信:利用事件总线(Event Bus)与跨应用 Context 模拟实现 React 应用的解耦

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 的“坑”

虽然这种方式很酷,但它有几个致命的问题,我们必须诚实面对。

  1. 状态同步延迟: React 的 Context 更新是基于组件渲染的。如果 App A 更新了状态,App B 不会立即知道,除非 App B 重新渲染。如果 App B 处于优化模式(使用了 React.memo),它可能根本不会重新渲染。这会导致状态不同步。
  2. “上帝对象”: SharedStore 变成了整个应用的控制中心。所有的状态变更都要经过它。如果 App C 修改了一个状态,App A 和 App B 都会收到通知,即使 App A 和 App B 根本不需要这个状态。这会造成不必要的性能开销。
  3. 调试噩梦: 状态去哪里了?因为它是内存里的,你没法在 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 是最佳选择,因为它能保证数据的一致性。


第四部分:终极实战——一个完整的微前端模拟场景

为了证明我们的理论,我们来做一个稍微复杂点的模拟。

场景设定:
我们有一个“主应用”。
主应用里有两个“子应用”:

  1. 导航应用: 负责菜单切换。
  2. 内容应用: 负责显示文章内容。

需求:
当用户在导航应用点击“关于我们”时,内容应用应该立即切换显示“关于我们”的页面。并且,无论点击哪个菜单,导航应用上当前选中的菜单项样式要改变。

这需要两个应用之间进行双向通信:

  1. 导航 -> 内容:点击菜单。
  2. 导航 -> 导航:更新选中样式。

解决方案:
我们使用 事件总线 来处理“点击菜单”的指令,使用 跨应用 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;

运行结果:
当你点击蓝色的菜单项时,你会看到:

  1. 导航应用上的蓝色背景会跟着变(Context 同步)。
  2. 内容应用下方的文字内容会跟着变(Context 同步)。
  3. 控制台会打印出 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 应用时,调试简直是一种艺术。
建议你在所有 emiton 的地方都加上 console.log
比如:
console.log('[EventBus] Emitting: LOGIN, Data:', data);
console.log('[EventBus] Listening: LOGIN, Callback:', callback);

这样,当事件丢失的时候,你能像福尔摩斯一样,在控制台里找到线索。

5.4 未来的趋势

说实话,虽然我们今天讲了这些技巧,但在现代前端架构中,这种“手动”的跨应用通信正变得越来越少。
微前端框架(如 qiankun, single-spa)通常提供了一套更高级的通信机制,比如 initGlobalState。它们本质上也是基于 EventBus 或者全局状态管理的,但是经过了封装,更安全,更易用。

但是,理解底层的原理是至关重要的。如果你不知道 EventBus 是怎么工作的,你就无法理解微前端框架为什么能实现应用隔离和通信。


结语:打破边界,拥抱自由

好了,今天的讲座就到这里。

我们学会了:

  1. EventBus:如何像扔纸飞机一样,把消息传给任何角落的组件。
  2. 跨应用 Context:如何像共享冰箱一样,让两个应用共享同一个状态。
  3. 组合拳:如何将两者结合,解决复杂的微前端通信问题。

React 的力量在于组件,而 React 的高级用法在于组件之间的关系。无论是父子关系,还是兄弟关系,甚至是两个独立的“陌生人”关系,只要掌握了事件总线和状态共享的技巧,你就能构建出灵活、解耦、甚至有点“黑客帝国”感觉的 Web 应用。

记住,代码不仅仅是写给机器看的,更是写给人类看的。当你的代码让不同模块之间不再互相依赖,不再互相咒骂,而是能够优雅地握手言和时,那就是你作为程序员的最高境界。

现在,去试试吧!把你的 App A 和 App B 连接起来,看看会发生什么神奇的事情!

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注