Props、Context、EventBus、状态管理:组件通信方案选择指南

0 阅读9分钟

写 React 的时间越长,越会遇到一个让人头疼的问题:明明只是想把数据传给某个深层组件,却要穿越好几层中间组件,每一层都得接收并转发这份数据。那些中间组件其实根本用不到这些 props,却因为「路过」不得不背负着它们。

这篇文章是我整理的关于组件通信的一些思考,聊聊各种方案的选择逻辑,以及背后的架构含义。


问题的起源

先描述一个典型场景:做一个电商页面,顶部导航需要显示购物车数量,商品详情页有「加入购物车」按钮。这两个组件相距 5 层嵌套,中间的 LayoutContainerContent 等组件对购物车一无所知,但你不得不让它们每层都接收并向下传递 cartCountaddToCart

// 环境:React
// 场景:典型的 Props Drilling 噩梦

function App() {
  const [cartItems, setCartItems] = useState([]);

  return (
    // 每一层都要传,即使它们完全不关心购物车
    <Layout cartItems={cartItems} setCartItems={setCartItems}>
      <Container cartItems={cartItems} setCartItems={setCartItems}>
        <Content cartItems={cartItems} setCartItems={setCartItems}>
          <ProductDetail cartItems={cartItems} setCartItems={setCartItems} />
        </Content>
      </Container>
    </Layout>
  );
}

这段代码本身不是错误,但它有一种难以言说的「不对劲」。每次修改数据结构,都要改好几层;每次移动组件位置,都要重新梳理 props 链条。

这让我开始思考:组件通信到底是技术问题,还是架构问题?

我的理解是,选择通信方案,本质上是在选择耦合程度——你愿意让哪些组件知道哪些数据?它们之间的关系应该有多紧密?


方案一:Props 传递——最基础,也最被滥用

父子通信用 Props,这没什么好说的。数据向下流,事件向上传,清晰直观:

// 环境:React
// 场景:标准的父子组件通信

function Parent() {
  const [count, setCount] = useState(0);

  return <Child count={count} onIncrement={() => setCount(c => c + 1)} />;
}

function Child({ count, onIncrement }) {
  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={onIncrement}>加一</button>
    </div>
  );
}

Props 的优点是数据流极其清晰,TypeScript 类型安全,也很容易单独测试子组件。但一旦层级变深,就会出现开头说的 Props Drilling 问题。

有一个常被忽视的技巧是组件组合(Component Composition) ,它能在不引入新方案的前提下,缓解这个问题:

// 环境:React
// 场景:用 children 避免中间层传递不必要的 props

// ❌ 传统方式:Layout 被迫接收 user
function App() {
  const [user] = useState({ name: 'Alice' });
  return (
    <Layout user={user}>
      <UserProfile user={user} />
    </Layout>
  );
}

// ✅ 组件组合:Layout 只负责布局结构
function App() {
  const [user] = useState({ name: 'Alice' });
  return (
    <Layout>
      <UserProfile user={user} />
    </Layout>
  );
}

// Layout 组件只接收 children,不关心内容
function Layout({ children }) {
  return <div className="layout">{children}</div>;
}

这个思路很简单:让容器组件只负责「结构」,不承担「内容」。它不需要知道 children 里有什么,自然也不需要传递那些数据。

可以接受 Props 传递的场景:层级不超过 2-3 层,数据关系稳定,不会频繁变动。超过这个范围,就该考虑其他方案了。


方案二:状态提升——兄弟组件的解法

兄弟组件之间无法直接通信,标准做法是把共享状态提升到最近的公共父组件:

// 环境:React
// 场景:两个兄弟组件需要共享计数状态

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <Counter count={count} onIncrement={() => setCount(c => c + 1)} />
      <Display count={count} />
    </>
  );
}

function Counter({ count, onIncrement }) {
  return <button onClick={onIncrement}>点击:{count}</button>;
}

function Display({ count }) {
  return <p>当前计数:{count}</p>;
}

状态提升有一个决策原则:把状态放在最近的需要它的公共祖先上。不要提升过高,否则顶层组件会变得臃肿,而且状态变化时会触发整棵子树的重渲染。

这个方案的局限很明显——当共同父组件距离很远,或者需要数据的组件分散在不同分支时,状态提升就会重新引入 Props Drilling 的问题。


方案三:Context API——跨层级的官方解

Context 的设计目的,就是解决跨层级数据共享的问题。它让深层组件可以直接「订阅」某个数据源,不需要中间层逐层传递:

// 环境:React
// 场景:主题切换,深层组件直接消费 Context

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Layout />
    </ThemeContext.Provider>
  );
}

// 深层组件,直接取值,不需要 Layout 传递任何东西
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <button onClick={() => setTheme(t => (t === 'light' ? 'dark' : 'light'))}>
      当前主题:{theme}
    </button>
  );
}

但 Context 有一个容易踩的性能陷阱:只要 Provider 的 value 发生变化,所有订阅了这个 Context 的组件都会重渲染,无论它们实际使用的数据有没有变。

// 场景:Context 的性能问题

function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('light');

  // ❌ 把所有数据放在一个 Context:
  // theme 改变时,只用 user 的组件也会重渲染
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      <Header />   {/* 只用 user */}
      <Content />  {/* 只用 theme */}
    </AppContext.Provider>
  );
}

一种常见的处理方式是按关注点拆分 Context

// ✅ 拆分 Context:各自订阅,互不影响
const UserContext = createContext();
const ThemeContext = createContext();

function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('light');

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        <Header />
        <Content />
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

function Header() {
  const { user } = useContext(UserContext);
  // theme 变化不会触发 Header 重渲染 ✅
  return <div>{user.name}</div>;
}

结合 useMemo 稳定 value 对象,是另一个常见优化手段:

// 用 useMemo 避免因父组件重渲染导致 value 引用变化
const userValue = useMemo(() => ({ user, setUser }), [user]);

return <UserContext.Provider value={userValue}>...</UserContext.Provider>;

Context 适合的场景:主题、语言/国际化、用户认证信息这类「低频变化、广泛消费」的数据。如果某个数据每秒变化多次,Context 可能不是最佳选择。


方案四:EventBus——完全解耦的代价

有时候,需要通信的两个组件之间没有任何父子或兄弟关系,它们甚至可能属于完全不同的模块。这时 EventBus(发布订阅模式)是一种思路:

// 环境:浏览器 / Node.js
// 场景:简单的 EventBus 实现

class EventBus {
  constructor() {
    this.events = {};
  }

  on(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
    // 返回取消订阅函数,方便清理
    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    };
  }

  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(cb => cb(data));
    }
  }
}

export const eventBus = new EventBus();

在 React 中使用时,要注意及时清理订阅,否则会有内存泄漏:

// 环境:React
// 场景:组件间通过 EventBus 通信(无父子关系)

function ProductDetail({ product }) {
  const addToCart = () => {
    // 发布事件,不关心谁在监听
    eventBus.emit('cart:add', product);
  };

  return <button onClick={addToCart}>加入购物车</button>;
}

function CartIcon() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const unsubscribe = eventBus.on('cart:add', () => {
      setCount(prev => prev + 1);
    });

    // ✅ 组件卸载时取消订阅,避免内存泄漏
    return unsubscribe;
  }, []);

  return <div>购物车 ({count})</div>;
}

EventBus 的吸引力在于「完全解耦」—— 两个组件互相不知道对方的存在。但这也带来了一个问题:当 bug 出现时,你很难追踪某个事件从哪里发出,有多少个地方在监听。数据流的可见性大幅降低。

如果需要类型安全,可以用 TypeScript 约束事件类型:

// 环境:TypeScript + React
// 场景:类型安全的 EventBus

type Events = {
  'cart:add': { productId: string; quantity: number };
  'toast:show': { message: string; type: 'success' | 'error' };
};

class TypedEventBus {
  private events: { [K in keyof Events]?: Array<(data: Events[K]) => void> } = {};

  on<K extends keyof Events>(event: K, callback: (data: Events[K]) => void) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event]!.push(callback);
    return () => {
      this.events[event] = this.events[event]!.filter(cb => cb !== callback);
    };
  }

  emit<K extends keyof Events>(event: K, data: Events[K]) {
    this.events[event]?.forEach(cb => cb(data));
  }
}

EventBus 适合的场景:Toast 通知、埋点上报这类「通知型」事件,或者与第三方库之间的通信。不太适合用来管理需要持久化或同步的状态。


方案五:状态管理库——有代价的强大

当应用复杂度到达一定程度,多个不相关的组件都需要访问和修改同一份状态时,引入状态管理库会更合适。

Zustand 是目前相对轻量的选择,API 简洁,没有繁琐的样板代码:

// 环境:React + Zustand
// 场景:全局购物车状态管理

import { create } from 'zustand';

const useCartStore = create(set => ({
  items: [],

  addItem: item =>
    set(state => ({ items: [...state.items, item] })),

  removeItem: id =>
    set(state => ({ items: state.items.filter(i => i.id !== id) })),
}));

// 任意组件中使用,且只订阅自己需要的那部分状态
function CartIcon() {
  // 精确订阅,items 长度不变时不触发重渲染
  const count = useCartStore(state => state.items.length);
  return <div>购物车 ({count})</div>;
}

function ProductDetail({ product }) {
  const addItem = useCartStore(state => state.addItem);
  return <button onClick={() => addItem(product)}>加入购物车</button>;
}

Zustand 的一个优点是选择性订阅—— 组件只会在自己订阅的那部分状态变化时重渲染,性能比 Context 好控制。

Redux Toolkit 则更适合大型团队和需要严格数据流规范的场景,它的 DevTools 支持时间旅行调试,中间件生态也更丰富,但相应地引入了更多约束和概念。

有一点值得注意:不是什么状态都适合放进状态管理库。一个只在局部使用的 Modal 开关状态,用 useState 就够了,把它放进 Redux 是典型的过度设计。

// ❌ 过度设计:Modal 状态没必要全局化
const useModalStore = create(set => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}));

// ✅ 简单场景就用 useState
function Page() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </>
  );
}

如何选择?

整理一下思路,大体上可以用这个决策流程:

graph TD
    A[需要组件通信] --> B{组件关系?}

    B -->|父子| C[Props]
    B -->|兄弟| D{层级深吗?}
    D -->|1-2 层| E[状态提升]
    D -->|3 层以上| F{数据变化频率?}
    F -->|低频| G[Context]
    F -->|高频| H[Zustand]

    B -->|无关系| I{通信类型?}
    I -->|通知/事件| J[EventBus]
    I -->|状态共享| K{项目规模?}
    K -->|中小型| H
    K -->|大型/团队| L[Redux]

方案的核心差异:

方案耦合程度适合场景主要风险
Props紧耦合父子,层级浅Props Drilling
状态提升较紧耦合兄弟,层级浅父组件臃肿
Context松耦合跨层级,低频变化全量重渲染
EventBus解耦通知类,跨模块数据流难追踪
Zustand解耦全局状态,中小型滥用导致混乱
Redux解耦+规范大型项目样板代码,学习成本

实际项目里通常是组合使用:Props 处理局部父子关系,Context 管理主题和用户信息,Zustand 或 Redux 处理核心业务状态,EventBus 负责 Toast 通知和埋点这类「一发即忘」的事件。


延伸与发散

在整理这些内容时,我产生了几个还没想清楚的问题:

React Server Components 如何改变通信模型? Server Components 本身不支持 state 和 context,如果组件树同时包含 Server 和 Client Components,数据如何在它们之间流动,目前还没有很好地弄明白。

Signals 是更好的答案吗? SolidJS 和 Preact Signals 的响应式模型在性能上有明显优势,组件不会因为无关状态变化而重渲染。React 社区也在讨论类似的方向,但目前还不是主流。

微前端场景下的通信怎么做? 主子应用之间的通信,无论是用 CustomEventqiankun 的全局状态还是 URL 参数,都有各自的取舍,这是另一个值得专门研究的话题。


小结

这篇文章更多是梳理思路,而非给出「最佳实践」的定论。一个让我印象比较深的认知是:选择通信方案,本质上是在选择组件之间的耦合程度。紧耦合的代码容易理解但难以重构,松耦合的代码灵活但追踪成本高——这个权衡在软件架构里是永恒的话题。

实用建议是:从最简单的方案开始,Props 能解决就用 Props,不够用再升级。过度设计的代价往往比技术债更难还清。


参考资料