Props、Context、EventBus、状态管理:组件通信方案选择指南
写 React 的时间越长,越会遇到一个让人头疼的问题:明明只是想把数据传给某个深层组件,却要穿越好几层中间组件,每一层都得接收并转发这份数据。那些中间组件其实根本用不到这些 props,却因为「路过」不得不背负着它们。
这篇文章是我整理的关于组件通信的一些思考,聊聊各种方案的选择逻辑,以及背后的架构含义。
问题的起源
先描述一个典型场景:做一个电商页面,顶部导航需要显示购物车数量,商品详情页有「加入购物车」按钮。这两个组件相距 5 层嵌套,中间的 Layout、Container、Content 等组件对购物车一无所知,但你不得不让它们每层都接收并向下传递 cartCount 和 addToCart。
// 环境: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 社区也在讨论类似的方向,但目前还不是主流。
微前端场景下的通信怎么做? 主子应用之间的通信,无论是用 CustomEvent、qiankun 的全局状态还是 URL 参数,都有各自的取舍,这是另一个值得专门研究的话题。
小结
这篇文章更多是梳理思路,而非给出「最佳实践」的定论。一个让我印象比较深的认知是:选择通信方案,本质上是在选择组件之间的耦合程度。紧耦合的代码容易理解但难以重构,松耦合的代码灵活但追踪成本高——这个权衡在软件架构里是永恒的话题。
实用建议是:从最简单的方案开始,Props 能解决就用 Props,不够用再升级。过度设计的代价往往比技术债更难还清。
参考资料
- React 官方文档 - Passing Data Deeply with Context - Context 的设计理念和使用场景
- Zustand GitHub - 轻量级状态管理库
- Redux Toolkit 官方文档 - 现代 Redux 的推荐写法
- use-context-selector - Context 选择性订阅的第三方解法