React 组件通讯指南:props、回调与 Context 的边界

64 阅读6分钟

前言

在学习 React 的过程中,组件通讯几乎是绕不开的问题。
props 能用、回调能跑、Context 也能生效,但很多时候只是“写对了”,却没真正想清楚为什么要这么写。

其实,组件通讯并不复杂,关键只在两件事上:
这份数据归谁管?又会被哪些组件用到?

想清楚这两点,很多选择会变得很自然:
是直接用 props 传,还是把状态往上提,或是干脆用 Context 做一次广播。

这篇文章不会按用法堆叠,而是沿着这个思路,把几种常见的组件通讯方式放回它们最合适的位置,慢慢讲清楚。

父 → 子(父组件通过 props 传递数据给子组件)

父组件拥有数据,把它以属性(props)形式传给子组件,子组件只能读取。

// ParentA.jsx
import ChildA from './ChildA'

export default function ParentA() {
  // 父组件拥有数据
  const userName = 'Yvmi'
  const userAge = 28

  return (
    <section>
      <h2>父组件(ParentA)</h2>
      {/* 把需要的字段明确传下去:可读、不可写 */}
      <ChildA name={userName} age={userAge} />
    </section>
  )
}
// ChildA.jsx
export default function ChildA({ name, age }) {
  // 这里只能读取 name 与 age
  return (
    <div>
      <p>子组件接收到了姓名:{name}</p>
      <p>子组件接收到了年龄:{age}</p>
    </div>
  )
}

需要注意的是:子组件只能使用 props,不能直接修改父组件传下来的数据。

三、子 → 父(孩子想告诉父亲一件事)——把“动作”作为 prop

当子组件需要把信息反馈给父组件时,React 的常见做法是:
父组件把一个函数当作 prop 传给子组件,子组件调用它并把数据当参数传回

就像父组件给子组件一张问卷(函数),子组件把答案交回去,父组件根据答案更新自己的状态。

// ParentB.jsx
import { useState } from 'react'
import ChildB from './ChildB'

export default function ParentB() {
  const [counter, setCounter] = useState(0)

  // 父亲定义如何处理孩子发来的数据:这是父亲的权利
  function handleChildValue(value) {
    // 只做自己该做的事:校验 -> 更新状态
    const num = Number(value)
    if (!Number.isNaN(num)) setCounter(num)
  }

  return (
    <div>
      <h2>父组件(ParentB)</h2>
      <p>来自子组件的计数:{counter}</p>
      <ChildB onSend={handleChildValue} />
    </div>
  )
}
// ChildB.jsx
export default function ChildB({ onSend }) {
  const localValue = 100 // 孩子自己的数据

  function send() {
    // 孩子决定何时发送,由父亲决定如何处理
    onSend(localValue)
  }

  return <button onClick={send}>把数据发给父亲</button>
}

这里顺带提一下:useState 是 React 提供的一个 Hook,用来在函数组件中保存和更新状态。
在这个例子里,父组件用它来存放“最终的数据”,子组件只是通过调用函数,把值交给父组件处理。

四、兄弟互通:把需要共享的 state 提升到最近的父亲

当两个平级组件要共享同一份信息时,最稳妥的做法是 把这份 state 提升到最近的共同父组件

ParentC:中转站

// ParentC.jsx
import { useState } from 'react'
import BrotherA from './BrotherA'
import BrotherB from './BrotherB'

export default function ParentC() {
  // 由父亲统一拥有这份状态
  const [sharedMessage, setSharedMessage] = useState('')

  return (
    <div>
      <h2>父组件(ParentC)- 中转站</h2>
      <BrotherA sendToParent={setSharedMessage} />
      <BrotherB received={sharedMessage} />
    </div>
  )
}

BrotherA:发送

// BrotherA.jsx
export default function BrotherA({ sendToParent }) {
  return (
    <button onClick={() => sendToParent('你好,兄弟!')}>
      兄弟 A 发送消息
    </button>
  )
}

BrotherB:接收

// BrotherB.jsx
export default function BrotherB({ received }) {
  return <div>兄弟 B 收到:{received}</div>
}

这比直接互改要好,不会出现多个组件同时试图修改同一份数据引起冲突。

五、跨多层级:家族广播台(Context)——父亲搭一个广播台,孙子随时收听

当需要在深层后代中访问顶层数据,把 props 一层层传下去会很笨拙。这时可以在父亲那儿搭一台“家族广播台”(Context)。

这个广播站里,父组件负责发声,所有后代组件不需要层层传递,只要调到这个频道,就能直接收到同一份信息。

在下面这个示例中,我们为了遵循 React 中的职责分离原则将 FamilyChannelFamily 分开处理:

  • FamilyChannel 仅作为数据广播的载体,负责管理和提供全局共享的数据;
  • Family 则通过 Provider 将数据传递给需要的子组件,专注于组件的渲染和 UI 结构。

这种做法让每个组件的职责更加明确,避免了代码的耦合性。FamilyChannel 专注于数据流的管理,而 Family 仅关注 UI 和布局,整体代码结构更加清晰,也让后期的维护和复用变得更加灵活。

FamilyChannel:播放信息的家族广播台

// FamilyChannel.jsx
import { createContext } from 'react'

// 如果外面没有 Provider,才会用到这个默认值(下面有具体解释)
export const FamilyChannel = createContext({ theme: 'light' })

Family:广播台的搭建者

import { FamilyChannel } from './FamilyChannel'
import LevelOne from './LevelOne'

export default function Family() {
  const familyData = useMemo(() => ({ theme: 'dark', language: 'zh-CN' }), [])

  return (
    <FamilyChannel.Provider value={familyData}>
      <section>
        <h2>父组件(Family) — 我搭了家族广播台</h2>
        <LevelOne />
      </section>
    </FamilyChannel.Provider>
  )
}

LevelTwo:孙子听广播,接到信息

// LevelTwo.jsx(孙)
import { useContext } from 'react'
import { FamilyChannel } from './FamilyChannel'

export default function LevelTwo() {
  // 这里接收到父组件 Provider 中广播出来的 value
  // { theme: 'dark', language: 'zh-CN' }
  const { theme, language } = useContext(FamilyChannel)
  return (
    <div>
      <p>孙子组件(LevelTwo)正在接收广播:</p>
      <p>主题:{theme}</p>
      <p>语言:{language}</p>
    </div>
  )
}

上面FamilyChannel中的 { theme: 'light' } 只是一个兜底值
一旦组件被包在 FamilyChannel.Provider 里面,Provider 提供的 value直接覆盖这个默认值,组件将完全拿不到 light
只有当组件不在任何 Provider 覆盖范围内时,useContext 才会返回这里的默认值。

LevelOne:儿子不听广播,也不妨碍别人听

// LevelOne.jsx(子)
import LevelTwo from './LevelTwo'
export default function LevelOne() {
  return (
    <div>
      <h3>子组件(LevelOne)</h3>
      <LevelTwo />
    </div>
  )
}

LevelOne 只是把 LevelTwo“带进了广播范围”,并没有帮它传话;真正发声的只有 Provider,真正收听的是 LevelTwo

六、温馨提示(别忘了两个入口文件)

在本地跑这些示例时,别忘了 App.jsxmain.jsx 这两个“幕后角色”:一个负责决定台前登场的是哪位父组件,另一个负责把整个应用真正挂到页面上。它们不参与组件通讯的剧情,却始终在背后把舞台撑住。

// App.jsx
// 你可以在这里切换要展示的 demo 父组件
// import ParentA from './demo1/ParentA'
// import ParentB from './demo2/ParentB'
// import ParentB from './demo3/ParentC'
import Family from './demo4/Family' // 展示当前要用的 `Family` 组件

export default function App() {
  return <Family />
}
// main.jsx
import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')).render(<App />)

最后

组件通讯从来不是在比谁会的 API 多,而是在练一种判断力:
这份数据该由谁掌控,又应该流向哪里。

props、回调、状态提升、Context,本质上都只是不同场景下的选择题。
当你能在画组件树时顺手标出“数据归属”和“使用范围”,答案往往已经写在图里了。

别急着记套路,把这些例子亲手敲一遍,改一改结构、加点日志、看清每一次更新发生的原因。
当你开始觉得「这份数据不该放在这里」的时候,你就真的开始理解 React 了。