一个中后台页面写到第三个迭代,组件树已经有五六层深。这时候产品说,筛选栏改了条件之后,右上角的导出按钮要跟着变成禁用。筛选栏和导出按钮离得很远,中间隔了四层布局组件。这个 prop 该怎么传?
React 的数据流规则本身很简单,就一句「单向、自上而下」,但真到了工程里,组件之间的位置关系千变万化,能用的手段也不止 props 一种。这篇把四类通信场景的写法都过一遍,重点不在于代码怎么写,而在于什么情况下该选哪一种,以及选错了会付出什么代价。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 父传子、子传父、跨级、无嵌套关系四类场景各自的标准解法
- 回调函数传参时
bind和箭头函数的取舍 - 跨级通信为什么官方长期不推荐 context
- 事件总线的写法,以及它为什么容易把项目搞乱
- 四种方式的横向对比和选型判断
- Hooks 时代这几种写法各自变成了什么样

组件之间进行通信的几种情况:
- 父组件向子组件通信
- 子组件向父组件通信
- 跨级组件通信
- 没有嵌套关系组件之间的通信
# 一、父组件向子组件通信
React 数据流动是单向的,父组件向子组件通信也是最常见的。父组件通过 props 向子组件传递需要的信息。
// Child.jsx
import React from 'react';
import PropTypes from 'prop-types';
export default function Child({ name }) {
return <h1>Hello, {name}</h1>;
}
Child.propTypes = {
name: PropTypes.string.isRequired,
}
// Parent.jsx
import React, { Component } from 'react';
import Child from './Child';
class Parent extends Component {
render() {
return (
<div>
<Child name="Sara" />
</div>
);
}
}
export default Parent;
这段代码没什么可解释的,但有两个点值得多说。
一是 propTypes 加 isRequired 这个习惯。它在开发环境下会做运行时校验,父组件忘了传 name 会直接在控制台报警告。2018 年这是标配,今天更常见的做法是用 TypeScript 在编译期就把类型卡死,效果更好也更早。顺带一提,React 19 已经移除了对函数组件 propTypes 的支持,老项目升级时这类声明会静默失效,得留意。
二是「父传子」这条路走得再远也是有代价的。开头那个筛选栏到导出按钮的例子,如果硬用 props 传,中间四层布局组件每一层都要加一个自己根本不用的参数往下递。这种传法通常叫 props drilling,层数一多,改一个字段名要动五个文件。
所以 props 适合的是两三层以内的直接传递。再深就该考虑第三节的方案了。
# 二、子组件向父组件通信
数据是单向往下流的,那子组件怎么往上说话?两条路:
- 利用回调函数
- 利用自定义事件机制
回调函数是主流。原理很朴素,父组件把一个自己的方法当 props 传下去,子组件在合适的时机调它,函数体是在父组件的作用域里跑的,自然就能改父组件的 state。
下面这个例子实现的功能是,在子组件中点击隐藏组件按钮可以将自身隐藏。
import React, { Component } from 'react';
import PropTypes from 'prop-types';
class List3 extends Component {
static propTypes = {
hideConponent: PropTypes.func.isRequired,
}
render() {
return (
<div>
哈哈,我是List3
<button onClick={this.props.hideConponent}>隐藏List3组件</button>
</div>
);
}
}
export default List3;
子组件这一侧非常干净,它甚至不知道点了之后会发生什么,只负责在按钮被点的时候把消息喊出去。这种「子组件不持有业务语义」的设计是对的,换个父组件复用它,行为就可以完全不同。
父组件这一侧:
//app.jsx
import React, { Component } from 'react';
import List3 from './components/List3';
export default class App extends Component {
constructor(...args) {
super(...args);
this.state = {
isShowList3: false,
};
}
showConponent = () => {
this.setState({
isShowList3: true,
});
}
hideConponent = () => {
this.setState({
isShowList3: false,
});
}
render() {
return (
<div>
<button onClick={this.showConponent}>显示Lists组件</button>
{
this.state.isShowList3 ?
<List3 hideConponent={this.hideConponent} />
:
null
}
</div>
);
}
}
真正的状态 isShowList3 在父组件手里,子组件只能通过调函数来请求改变。这就是「状态提升」的标准形态,也是 React 里兄弟组件通信的默认解法:两个兄弟要通信,把共享的那部分 state 提到它们最近的共同父组件上去。
这里有个坑要注意,showConponent = () => {} 这种类属性加箭头函数的写法不是随便写的,它保证了 this 绑定。如果你写成普通的类方法 showConponent() {},传下去之后 this 就丢了,调用时会报 Cannot read property 'setState' of undefined。老代码里常见的替代方案是在 constructor 里写 this.showConponent = this.showConponent.bind(this)。
需要传参的时候,写法是 onClick={() => this.handleClick(id)}。这样每次渲染都会生成一个新的函数引用,如果子组件做了 React.memo 或者 shouldComponentUpdate 的浅比较优化,这个新引用会让优化直接失效。数据量小无所谓,长列表里就得注意,通常的做法是把 id 通过 data- 属性挂在 DOM 上,或者把每一项抽成独立组件,让每一项自己持有自己的 id。
# 三、跨级组件通信
跨级指的是隔了不止一层,比如爷爷组件要和孙子组件说话。
# 3.1 层层组件传递 props
例如 A 组件和 B 组件之间要进行通信,先找到 A 和 B 公共的父组件 C,A 先向 C 组件通信,C 组件通过 props 和 B 组件通信,此时 C 组件起的就是中间件的作用。
这条路的好处是不引入任何新概念,数据流向一眼能看清,坏处第一节已经说了,层数一多就是灾难。
我自己的判断线大概在三层。三层以内老实传,超过三层就该换方案了。
# 3.2 使用 context
context 是一个全局变量,像是一个大容器,在任何地方都可以访问到。我们可以把要通信的信息放在 context 上,然后在其他组件中可以随意取到。
但是 React 官方不建议使用大量 context,尽管它可以减少逐层传递,但是当组件结构复杂的时候,我们并不知道 context 是从哪里传过来的;而且 context 是一个全局变量,全局变量正是导致应用走向混乱的罪魁祸首。
这段是 2018 年的判断,那时候官方的态度确实很保守。原因不只是「全局变量容易乱」这一条,更硬的技术原因是旧版 context 的传播会被中间组件的 shouldComponentUpdate 截断,导致值改了但下游收不到。React 16.3 换成新的 createContext 之后这个缺陷被修掉了,官方态度也从「不推荐」转成了「主题、语言、当前用户这类场景推荐用」。这一段的机制细节我在 React 之 context 跨层级传值原理 里展开讲了,包括新 API 的重渲染范围问题。
下面例子中的组件关系是,ListItem 是 List 的子组件,List 是 app 的子组件。
// ListItem.jsx
import React, { Component } from 'react';
import PropTypes from 'prop-types';
class ListItem extends Component {
// 子组件声明自己要使用context
static contextTypes = {
color: PropTypes.string,
}
static propTypes = {
value: PropTypes.string,
}
render() {
const { value } = this.props;
return (
<li style={{ background: this.context.color }}>
<span>{value}</span>
</li>
);
}
}
export default ListItem;
消费方要显式声明 contextTypes,不声明的话 this.context 就是空对象。这个强制声明当年是为了让隐式依赖变得可见。
提供方这一侧:
// List.jsx
import ListItem from './ListItem';
class List extends Component {
// 父组件声明自己支持context
static childContextTypes = {
color: PropTypes.string,
}
static propTypes = {
list: PropTypes.array,
}
// 提供一个函数,用来返回相应的context对象
getChildContext() {
return {
color: 'red',
};
}
render() {
const { list } = this.props;
return (
<div>
<ul>
{
list.map((entry, index) =>
<ListItem key={`list-${index}`} value={entry.text} />,
)
}
</ul>
</div>
);
}
}
export default List;
注意 List 把 color 发下去之后,ListItem 就能直接读到,中间不需要任何 props 中转。这就是 context 省掉的那部分工作。
最外层: