React6:组件通信

25 阅读10分钟

第 06 课 · 组件通信

Vue 的通信工具箱是 props / emit / v-model / provide-inject / defineExpose / Pinia;React 没有这么多关键字,但每一项都有明确对应物,核心只有一句话:数据往下走靠 props,往上走靠回调函数 props,跨层靠 Context,命令式兜底靠 ref。

🎯 本课目标

  • 画出 React 通信方案全景图,给「父子 / 跨层 / 兄弟 / 全局」四类场景选对工具
  • 用回调 props 实现子传父,理解它就是 defineEmits + $emit 的对应物
  • 三步搭好 Context(createContext / Provider / useContext),并用拆分 Context 控制重渲染粒度
  • 会用 useImperativeHandle 暴露子组件方法,知道 React 19 可以把 ref 当普通 prop 传
  • 树立企业态度:能 props/回调解决的就不用 Context,能用 Context 的就不用 ref

1. 通信方案全景图

Vue 里你这样写

场景Vue 工具
父 → 子props
子 → 父defineEmits + $emit
跨层provide / inject
兄弟状态提升到共同父组件 / Pinia
全局Pinia

React 里这样写

场景React 工具对应 Vue
父 → 子props(数据、JSX、组件都可以传)props
子 → 父回调函数 props(onXxx)emit
跨层Context + useContextprovide / inject
兄弟状态提升(lift state up)同 Vue
全局Zustand(第 10 课) / TanStack Query(第 11 课)Pinia

关键差异与注意点

  • React 没有 emit 这套机制:事件就是「父传下来的函数,子去调用」,本质仍是 props。
  • 插槽也是 props(children / 传 JSX),Vue 里 slot 的灵活度在 React 里全靠「把 JSX 当参数」,这是第 2 课的内容。
  • 选型原则从上往下试:先 props → 不够再状态提升 → 跨层太多上 Context → 全局高频变化才上状态库。跳级直接上 Zustand 是常见过度设计。

2. 子传父 = 回调函数 props(对照 defineEmits)

Vue 里你这样写

<!-- 子组件 TodoItem.vue -->
<script setup lang="ts">
defineProps<{ todo: { id: number; text: string; done: boolean } }>()
const emit = defineEmits<{ toggle: [id: number]; remove: [id: number] }>()
</script>

<template>
  <input type="checkbox" :checked="todo.done" @change="emit('toggle', todo.id)" />
  <button @click="emit('remove', todo.id)">删除</button>
</template>

<!-- 父组件:<TodoItem :todo="t" @toggle="handleToggle" @remove="handleRemove" /> -->

React 里这样写

import { useState } from 'react'

interface Todo { id: number; text: string; done: boolean }

// 子组件:只负责展示 + 上报,自己从不改数据
function TodoItem({ todo, onToggle, onRemove }: {
  todo: Todo
  onToggle: (id: number) => void
  onRemove: (id: number) => void
}) {
  return (
    <li>
      <input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
      {todo.text}
      <button onClick={() => onRemove(todo.id)}>删除</button>
    </li>
  )
}

// 父组件:数据集中在这里,回调负责改
export default function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([{ id: 1, text: '学 Context', done: false }])

  function handleToggle(id: number) {
    setTodos(prev => prev.map(t => (t.id === id ? { ...t, done: !t.done } : t)))
  }
  function handleRemove(id: number) {
    setTodos(prev => prev.filter(t => t.id !== id))
  }

  return (
    <ul>
      {todos.map(t => (
        <TodoItem key={t.id} todo={t} onToggle={handleToggle} onRemove={handleRemove} />
      ))}
    </ul>
  )
}

关键差异与注意点

  • 回调命名铁律 onXxx:onToggle / onRemove / onChange / onSubmit。约定成俗到 Ant Design 全库如此,写成 toggleClick、del 会被 review 打回。
  • 子组件永远不自己改数据(props 是只读的,改了也不生效),只调用回调「上报意图」,改数据集中在父组件 —— 和 Vue 的单向数据流一模一样。
  • emit('toggle', todo.id) → onToggle(todo.id),参数一模一样,心智可平移。
  • 需要反向传数据给子组件时(受控子组件),把 state 和 onXxx 一起传下去,就是「受控模式」——上一课的表单就是这个原理。

3. Context + useContext(对照 provide / inject)

Vue 里你这样写

// 祖先组件
provide('theme', { theme, toggleTheme })
// 任意后代
const { theme, toggleTheme } = inject('theme')

React 里这样写(三步)

import { createContext, useContext, useState, type ReactNode } from 'react'

type Theme = 'light' | 'dark'
interface ThemeContextValue { theme: Theme; toggleTheme: () => void }

// ① 创建:单独放 theme-context.ts,谁都用得到
export const ThemeContext = createContext<ThemeContextValue | null>(null)

// 企业习惯:包一层自定义 hook,没包 Provider 时立刻抛错,而不是拿到 null 悄悄崩
export function useTheme() {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内使用')
  return ctx
}

// ② 提供方:value 变化时,所有消费组件都会重渲染
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light')
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => (t === 'light' ? 'dark' : 'light')) }}>
      {children}
    </ThemeContext.Provider>
  )
}

// ③ 任意深度的后代消费,中间层完全不用接 props
function ThemedButton() {
  const { theme, toggleTheme } = useTheme()
  return <button onClick={toggleTheme}>{theme === 'dark' ? '深色' : '浅色'}</button>
}

关键差异与注意点

  • 三步记牢:createContext → Provider 包住子树 → useContext 消费。中间隔多少层都无所谓,中间层零改动。
  • Context 的性能模型和 Vue 不同:Provider 的 value 引用一变,所有消费组件全部重渲染(用 Object.is 比较)。Vue 的 provide/inject 靠响应式按字段追踪,粒度更细。所以 React 里要么 value 用原始值、要么拆分 Context、要么记忆化(下一节)。
  • 拆分 Context(数据与更新分开):StateContext 放数据、ActionsContext 放方法包(useMemo 固定引用)。只需要改数据的组件(按钮)就不会因为数据变化而重渲染。
  • 何时不该用 Context:高频变化的全局数据(输入框内容、鼠标位置、每秒轮询的行情)会让大量消费组件跟着抖动,这类放 Zustand(store 订阅粒度可控)或 TanStack Query;Context 适合低频的横切关注点:主题、语言、当前登录用户、权限。
  • 默认值 createContext(null) + 自定义 hook 抛错,是企业标准写法:比「忘了包 Provider 还能跑出诡异默认值」好查得多。

4. useImperativeHandle:父调用子的方法(对照 defineExpose)

Vue 里你这样写

<!-- 子组件 -->
<script setup lang="ts">
function focus() { /* ... */ }
function validate(): string | null { return null }
defineExpose({ focus, validate })
</script>
<!-- 父组件:searchRef.value?.focus() -->

React 里这样写

import { forwardRef, useImperativeHandle, useRef, useState } from 'react'

interface SearchBoxHandle {
  focus: () => void
  clear: () => void
  validate: () => string | null
}

// 经典写法(React ≤18 标准,存量代码大量存在)
const SearchBox = forwardRef<SearchBoxHandle>(function SearchBox(_props, ref) {
  const [value, setValue] = useState('')
  const inputRef = useRef<HTMLInputElement>(null)

  // 不写它,父拿到的是 DOM;写了,父拿到的是这个方法包
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    clear: () => { setValue(''); inputRef.current?.focus() },
    validate: () => (value.trim() ? null : '搜索词不能为空'),
  }))

  return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} />
})

// React 19:ref 就是普通 prop,不用再包 forwardRef
function SearchBox19({ ref }: { ref?: React.Ref<SearchBoxHandle> }) {
  const inputRef = useRef<HTMLInputElement>(null)
  useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus() }))
  return <input ref={inputRef} />
}

关键差异与注意点

  • useImperativeHandle(ref, () => 方法包) 让 ref.current 变成你定义的方法集合,而不是 DOM 节点——这就是 defineExpose 的对应物,能暴露什么由子组件说了算。
  • React 19 变化:函数组件的 ref 可以直接当 prop 接收({ ref }: { ref?: Ref<Handle> }),不再需要 forwardRef。存量代码里 forwardRef 仍然到处都是,两种都要看得懂。
  • 企业态度:能 props / 回调解决的,就别用 ref 调方法。命令式调用绕过了单向数据流,让「谁改了什么」变难追踪。合理场景:聚焦输入框、滚动定位、播放/暂停视频、表单的 validate() / submit()。
  • 第三参数是依赖数组(同 useEffect 语义),方法包里用到响应式值时按需填写,默认每次渲染重建也无妨。

5. 兄弟通信 = 状态提升

Vue 里你这样写

两个兄弟组件要共享数据时,你会把状态放到共同父组件里,再分别用 props/emit 传下去——Pinia 出现前就是这么干的。

React 里这样写

import { useState } from 'react'

function FilterBar({ keyword, onChange }: { keyword: string; onChange: (v: string) => void }) {
  return <input value={keyword} onChange={e => onChange(e.target.value)} />
}

function UserList({ keyword }: { keyword: string }) {
  return <p>按「{keyword}」筛选(列表自己不发请求,等父组件处理)</p>
}

export default function UserPage() {
  // 状态提升:keyword 放在两个兄弟的共同父级
  const [keyword, setKeyword] = useState('')
  return (
    <>
      <FilterBar keyword={keyword} onChange={setKeyword} />
      <UserList keyword={keyword} />
    </>
  )
}

关键差异与注意点

  • 兄弟之间不直接通信,共同父组件持有关键状态,一路 props 下去、回调收上来——和 Vue 的思路完全一致。
  • 状态提升会让中间层多出「搬运 props」的代码,层数深了就是 props 钻井;两条出路:Context(跨层共享)或 children 组合(把组件当参数传,让中间层不必认识那些 props,第 7 课细讲)。
  • 提升到哪一层?「所有需要读写它的组件的最小公共祖先」,别一上来就提到 App 根组件。

📊 Vue ↔ React 对照速查表

Vue 3 写法React 写法一句话差异
defineProps参数解构 ({ todo }) / props 对象一样是父传子
defineEmits + emit('remove', id)回调 props onRemove(id)emit 变成「调用父传下来的函数」
@remove="handleRemove"onRemove={handleRemove}监听和传值是同一件事
provide('theme', v)<ThemeContext.Provider value={v}>提供方包住子树
inject('theme')useContext(ThemeContext)任意后代消费
defineExpose({ focus })useImperativeHandle(ref, () => ({ focus }))父拿子的方法包
ref 拿子实例useRef<Handle>(null) + ref.current?.focus()React 19 里 ref 可当普通 prop
兄弟通信:状态提升 / Pinia状态提升 / Zustand思路一致
全局状态 PiniaZustand(第 10 课)后面专门讲
slotchildren / JSX props插槽即参数

🏢 企业实战要点

  1. 回调命名 onXxx 铁律:内部处理函数 handleXxx,对外暴露的回调 onXxx。一个组件里 onChange 既是原生事件名又是自定义回调,注意别混淆——企业里常用 onValueChange 这类名字错开。
  2. Provider 的 value 必须记忆化:value={{ theme, setUser }} 这种内联对象每次渲染都是新引用,消费组件全体陪跑。数据放原始值/拆分 Context,方法包用 useMemo(第 7 课),review 必查。
  3. Context 文件单独放:context/user-context.ts 放 createContext + UserProvider + useUser(),业务组件只 import hook,不 import Context 本体——避免任意组件拿到 Context 乱塞数据。
  4. 避免 props 钻井的两条路:横切数据(主题/用户/权限)用 Context;纯结构复用(布局、卡片壳)用 children 组合把「未知的下层」当参数传进来。别用「层层透传 + 中间层改别名」糊。
  5. 拆 Context 的粒度:按「变化的频率和消费方」拆,不是按业务模块拆。变化快的(表单草稿)和几乎不变的(配置、翻译文案)绝不放同一个 Context。
  6. useImperativeHandle 少用:出现在 code review 里时,先问「为什么不用 props/回调」。真正合理的场景集中在焦点、滚动、媒体控制、表单命令式方法。
  7. 组件 props 类型显式 interface:回调函数的参数和返回值必须标全(onRemove: (id: number) => void),不标类型的高级组件在 TS 严格模式下会传染出一片 any。
  8. 真实坑:在子组件里直接改 props 对象属性(props.todo.done = true)不报错但不生效;父组件重建 value 对象导致下级疯狂重渲染;忘写 key 导致兄弟组件状态串位。

💼 面试高频

1. React 组件之间有哪些通信方式? 父子用 props 和回调 props;跨层用 Context(createContext + Provider + useContext);兄弟用状态提升;复杂全局状态用 Zustand/Redux,服务端状态用 TanStack Query;兜底的命令式通信用 useImperativeHandle。另外 JSX 本身可以当 props 传(children 组合),能减少很多透传。

2. Context 有什么性能问题?怎么规避? Provider 的 value 引用变化会让所有消费组件重渲染,且无法按字段细粒度订阅。规避:value 用原始值;拆分 Context(数据与更新分开,方法包用 useMemo 固定引用);把状态下放缩小 Provider 范围;高频变化的数据改用 Zustand 或 Query 等外部 store(订阅粒度可控)。

3. useImperativeHandle 的作用和使用时机? 它把 ref.current 从「DOM 节点」替换成你定义的方法包,实现父调用子方法(Vue 的 defineExpose 对应物)。时机:只在真正需要命令式操作时用——聚焦、滚动、播放控制、表单 validate/submit。能用 props 和回调表达的状态变化就不该用它,否则数据流不可追踪。

4. React 的 Context 和 Vue 的 provide/inject 有什么差异? 用法几乎一一对应(createContext/Provider/useContext 对 provide/inject)。差异在更新粒度:Vue 基于响应式系统,inject 的对象字段变化能精确更新用到它的地方;React 以「value 的引用是否变化」为单位,value 引用一变全部消费者重渲染,所以 React 里要自己设计拆分和记忆化。

5. React 19 在组件通信上有什么新特性? 函数组件的 ref 可以作为普通 prop 接收和传递,不再需要 forwardRef 包裹,也更容易在自定义 hook 里透传;同时 ref 作为 prop 后清理逻辑更统一。存量代码里 forwardRef 仍是主流写法,新代码可以逐步切换。

📌 小结 + 下节预告

这一课把 Vue 的通信全家桶翻译完毕:emit → 回调 props(命名 onXxx),provide/inject → Context 三步(注意 value 引用与拆分),defineExpose → useImperativeHandle(React 19 里 ref 直接当 prop),兄弟通信依旧是状态提升。选型顺序:props → 提升 → Context → 状态库,命令式 ref 只做兜底。

但 Demo 3 已经埋了伏笔:为什么 Provider 的 value 要 useMemo、为什么传给子组件的函数要 useCallback、为什么按钮组件「只用了 setCount」也会重渲染?下一课讲 React 性能优化三件套:memo / useMemo / useCallback——没有 Vue 那套自动依赖追踪之后,你需要手动管理「引用稳定性」,这是企业 code review 必查项。