Skip to content
React 组件通信与 Context
单向数据流下的通信总览
React 中的数据流动是单向的。从组件树的根出发,数据以 props 的形式向下传递,状态变更只发生在拥有该状态的组件内部。这样做的好处是数据走向明确,追踪 bug 时不需要猜测“谁改了谁”。
但单向流并不意味着组件只能被动接收数据。子组件要通知父组件、兄弟组件要共享同一份状态、深层组件需要获取全局信息——这些场景在真实界面中几乎无法绕过。React 为此提供了一套在单向数据约束下的通信模式:
- 父→子:props 下传。
- 子→父:回调函数上浮。
- 兄弟之间:状态提升到公共父节点。
- 跨层级:Context 机制。
接下来的内容会按这条线索展开,重点放在回调、状态提升和 Context 三个核心手段上。
父子通信:props 下传与回调上浮
父组件通过 JSX 属性把数据传给子组件,子组件只能读取不能修改——这个规则在前面的 Props 与 State 部分已经建立起来了。
需要子组件“上报数据”时,做法是父组件提前定义一个回调函数,通过 props 一并传下去。子组件在合适的时机(比如用户输入)调用这个回调,把数据作为参数传回。整个过程中,子组件仍然没有直接改动父组件的 state,只是触发了一次调用,真正的状态更新在父组件内部完成。
一个典型的受控输入组件可以写成这样:
tsx
interface ValueInputProps {
value: string;
onChange: (value: string) => void;
}
function ValueInput({ value, onChange }: ValueInputProps) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
function Form() {
const [name, setName] = React.useState('');
return (
<div>
<ValueInput value={name} onChange={setName} />
<p>当前输入:{name}</p>
</div>
);
}ValueInput 不关心 onChange 具体做什么,它只是把最新值抛出去。Form 拿到值后调用 setName,触发一次重渲染,新的 name 又通过 props 流回 ValueInput。数据流动依然是单向的:Form 的状态 → ValueInput 的 props → 用户交互触发回调 → Form 更新状态。
命名上,回调 prop 通常采用 on 前缀,比如 onChange、onCommit、onRemove,对应原生 DOM 事件的习惯,这样调用方一眼就能分辨出哪些是数据、哪些是动作。
兄弟通信:状态提升到公共父节点
两个组件如果只是兄弟关系,彼此无法直接通信——它们拿不到对方的 props,也没有办法调用对方的方法。唯一的交叉点就是它们共同的父组件。
把需要共享的状态移动到父组件里,然后通过 props 分别把状态值和更新函数下发给两个兄弟节点,这就是状态提升。父组件充当了数据的中转站,两个子组件之间任何“同步”实际上都是父组件在协调。
一个搜索筛选的例子:
tsx
interface SearchBarProps {
keyword: string;
onKeywordChange: (kw: string) => void;
}
function SearchBar({ keyword, onKeywordChange }: SearchBarProps) {
return (
<input
placeholder="搜索..."
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
/>
);
}
interface ResultListProps {
items: string[];
}
function ResultList({ items }: ResultListProps) {
return (
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
function SearchPage() {
const [keyword, setKeyword] = React.useState('');
const allItems = ['TypeScript', 'React', 'Context'];
const filtered = allItems.filter((i) =>
i.toLowerCase().includes(keyword.toLowerCase())
);
return (
<div>
<SearchBar keyword={keyword} onKeywordChange={setKeyword} />
<ResultList items={filtered} />
</div>
);
}SearchBar 和 ResultList 各自只管自己的 UI,SearchPage 才是状态的唯一来源。当 SearchBar 通过回调把新关键词回传时,SearchPage 更新 keyword state,触发自身重渲染,连带两个子组件一起更新。
这种模式在小范围的组件协作里足够干净。但随着组件树加深,如果某份数据需要穿过好几层才能到达目标组件,中间每一层都要显式传递 props,哪怕自己根本用不到。这个问题直指 props 逐层透传(prop drilling)。
跨层级通信:Context
假设一个应用里有用户登录信息,头像组件藏在某条路由的深层 UI 中,而用户信息却在靠近根组件的地方管理。如果全走 props,那么从根到这个头像之间的一长串中间组件都要声明一个 user prop 并原封不动往下传。这些中间组件与用户信息毫无关系,却被迫参与传递。改动任何一个中间层的接口都会波及一片。
Context 就是用来切断这种逐层传递的。它允许一个父组件把数据广播到整棵子树,途中任何组件都可以直接取到,无需中间人。
Context 不是状态管理工具,它不负责“更新逻辑”。它只是一个运输通道:把某个值从 Provider 端点送进去,子树里的组件用 useContext 来接。至于值的来源是 state、props 还是其他什么,Context 本身不关心。
创建与注入:createContext 与 Provider
先创建 context:
tsx
const ThemeContext = React.createContext<string>('light');createContext 接收一个默认值,这个默认值只在消费组件上方没有匹配的 Provider 时才会生效。类型参数 string 约束了后续 Provider 的 value 和消费端拿到的值类型。
有了 context 对象之后,用它的 Provider 组件包裹子树并提供真实的 value:
tsx
function App() {
const [theme, setTheme] = React.useState('light');
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
);
}Provider 可以嵌套,value 也可以覆盖。内层的 Provider 会屏蔽外层同类型的 Context,消费组件向上查找时取最近的那个。
Provider 的 value 可以是任意类型的值——字符串、对象、函数都可以。但要注意,每次 App 渲染时,如果 value 传递的是一个新的引用(比如内联对象 {theme, setTheme}),即使数据没变,消费端也会把它当作一次更新。
消费上下文:useContext 实战
在函数组件里读取 context 值用 useContext:
tsx
function ThemedButton() {
const theme = React.useContext(ThemeContext);
const style = {
backgroundColor: theme === 'dark' ? '#333' : '#fff',
color: theme === 'dark' ? '#fff' : '#000',
};
return <button style={style}>当前主题:{theme}</button>;
}useContext 返回的是 Provider 传入的当前值;如果没有 Provider,则返回 createContext 的默认值。这个 Hook 必须在函数组件或自定义 Hook 的顶层调用,不能放在条件或循环里。
与老式的 Context.Consumer 组件相比,useContext 让读取逻辑完全融入组件自身的语句流,不需要再包一层 render props 函数,代码更平直。
Provider 更新与重渲染行为
当 Provider 的 value 属性变化时,React 会标记所有消费该 context 的组件为需要更新,并在下一次渲染时重新执行这些组件函数。这里的“变化”用的是 Object.is 比较。
因此一个常见的陷阱是:如果 Provider 每次渲染都传入一个新的对象字面量,即使内部字段的值完全没变,消费组件也会跟着重新渲染一次:
tsx
function App() {
const [theme, setTheme] = React.useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Page />
</ThemeContext.Provider>
);
}每次 App 渲染,value={{ theme, setTheme }} 都会创建一个新对象,Object.is 结果为 false,于是所有订阅了 ThemeContext 的组件即便 theme 的实际值没变也会重渲染。
修复的思路是把 value 的引用稳定化。一种做法是用 useMemo 缓存对象,使其仅在 theme 实际变化时才重建:
tsx
function App() {
const [theme, setTheme] = React.useState('light');
const ctx = React.useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={ctx}>
<Page />
</ThemeContext.Provider>
);
}除非重渲染已经造成可感知的性能压力,否则不需要过早处理。与 context 更新相关的大范围重渲染更多属于性能优化的范畴,这里不展开。
另一个行为点:当组件树中没有匹配的 Provider 时,消费组件取到的是 createContext 传入的默认值。如果默认值是引用类型,所有消费组件都会拿到同一个对象引用。后续渲染中,只要仍然没有 Provider,React 通过 Object.is 比较发现该引用未变更,就不会因为 context 默认值重新执行消费组件函数。因此,即使父组件触发渲染,订阅该默认 context 的组件也不会产生由 context 引起的额外渲染。
选择 Context 的时机与替代方案
Context 不是组件通信的首选。它解决的问题是“跨越很多层直送数据”。如果只是两三层内的传递,直接写 props 最简单,数据流清晰,也不容易因为 Provider 的重渲染把一片组件都搅进去。
在以下几种情况下用 Context 比较合适:
- 主题、语言、区域偏好这类全局配置信息。
- 当前登录用户对象,需要被大量深层组件访问。
- 某个特定子树内的共享数据,且中间层不方便转发时。
如果数据更新非常频繁(比如高频的动画状态、实时输入值),用 Context 会带来大范围的消费者重渲染,不如把状态放到离真正用到它的组件更近的地方,或通过状态提升 + 精细的 props 分发来控制影响面。
还有一种情况:中间组件只起转发作用,本身不关心数据。这时可以把 JSX 作为 children 传入,让父组件直接决定渲染内容,从而避开中间层的 props 声明。这种组件组合技巧在某些场景里可以代替 Context,但同样不解决跨越多层的问题,只适合相邻的父子关系。
参考链接
- [1] https://zh-hans.react.dev/learn/passing-data-deeply-with-context
- [4] https://zh-hans.react.dev/reference/react/useContext
- [7] https://zh-hans.react.dev/reference/react/createContext
- [9] https://zh-hans.react.dev/learn/passing-props-to-a-component
- [10] https://zh-hans.react.dev/learn/sharing-state-between-components
