☰
React06:组件通信
2026/10/27 1:58:44 网站建设 项目流程

第 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)回调 propsonRemove(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 必查项。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询